From 4e2923be398b2af20e399d917fded34198f48814 Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Tue, 9 Jun 2026 12:22:58 +0300 Subject: [PATCH 01/18] test ais --- OsmAnd.xcodeproj/project.pbxproj | 56 ++ .../en.lproj/Localizable.strings | 158 +++++ Sources/Controllers/Map/Layers/OAMapLayers.h | 4 + Sources/Controllers/Map/Layers/OAMapLayers.mm | 4 + .../Panels/OAMapPanelViewController.mm | 1 + .../OAPluginDetailsViewController.mm | 5 +- .../OAOsmandDevelopmentViewController.mm | 69 +- .../TargetMenu/OATargetMenuViewController.mm | 7 + Sources/Data/OATargetPoint.h | 3 +- .../AisTrackerPlugin/AisDataManager.swift | 79 +++ .../AisTrackerPlugin/AisMessageDecoder.swift | 288 +++++++++ .../AisTrackerPlugin/AisNmeaConnection.swift | 160 +++++ .../AisTrackerPlugin/AisNmeaParser.swift | 88 +++ .../Plugins/AisTrackerPlugin/AisObject.swift | 416 ++++++++++++ .../AisSimulationProvider.swift | 204 ++++++ .../AisTrackerPlugin/AisTrackerHelper.swift | 223 +++++++ .../AisTrackerSettingsViewController.swift | 349 ++++++++++ .../OAAisObjectViewController.h | 9 + .../OAAisObjectViewController.mm | 185 ++++++ .../AisTrackerPlugin/OAAisTrackerLayer.h | 6 + .../AisTrackerPlugin/OAAisTrackerLayer.mm | 601 ++++++++++++++++++ .../AisTrackerPlugin/OAAisTrackerPlugin.swift | 316 +++++++++ Sources/Plugins/OAPluginsHelper.mm | 1 + Sources/Purchases/OAProducts.h | 5 + Sources/Purchases/OAProducts.mm | 44 ++ Sources/Services/OALocationServices.h | 1 + Sources/Services/OALocationServices.mm | 19 + 27 files changed, 3298 insertions(+), 3 deletions(-) create mode 100644 Sources/Plugins/AisTrackerPlugin/AisDataManager.swift create mode 100644 Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift create mode 100644 Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift create mode 100644 Sources/Plugins/AisTrackerPlugin/AisNmeaParser.swift create mode 100644 Sources/Plugins/AisTrackerPlugin/AisObject.swift create mode 100644 Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift create mode 100644 Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift create mode 100644 Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift create mode 100644 Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h create mode 100644 Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm create mode 100644 Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h create mode 100644 Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm create mode 100644 Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift diff --git a/OsmAnd.xcodeproj/project.pbxproj b/OsmAnd.xcodeproj/project.pbxproj index c1645665fa..c6ed1f5579 100644 --- a/OsmAnd.xcodeproj/project.pbxproj +++ b/OsmAnd.xcodeproj/project.pbxproj @@ -939,6 +939,17 @@ 46E9ADA128DC9FD000CC55F9 /* OAButtonTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 46E9ADA028DC9FD000CC55F9 /* OAButtonTableViewCell.m */; }; 46ED6C3E2B333B4400A5555F /* Plugin+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46ED6C3D2B333B4400A5555F /* Plugin+Extension.swift */; }; 46ED6C402B333B8100A5555F /* ExternalSensorsPlugin+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46ED6C3F2B333B8100A5555F /* ExternalSensorsPlugin+Extension.swift */; }; + 9F4844A52FD0000100484401 /* AisNmeaParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A12FD0000100484401 /* AisNmeaParser.swift */; }; + 9F4844A62FD0000100484401 /* AisNmeaConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A22FD0000100484401 /* AisNmeaConnection.swift */; }; + 9F4844A72FD0000100484401 /* OAAisTrackerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A32FD0000100484401 /* OAAisTrackerPlugin.swift */; }; + 9F4844A82FD0000100484401 /* AisTrackerSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */; }; + 9F4844AA2FD0000100484401 /* AisSimulationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */; }; + 9F4844AC2FD0000100484401 /* AisObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AB2FD0000100484401 /* AisObject.swift */; }; + 9F4844AE2FD0000100484401 /* AisDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AD2FD0000100484401 /* AisDataManager.swift */; }; + 9F4844B02FD0000100484401 /* AisMessageDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */; }; + 9F4844B32FD0000100484401 /* OAAisTrackerLayer.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */; }; + 9F4844B52FD0000100484401 /* AisTrackerHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */; }; + 9F4844B82FD0000100484401 /* OAAisObjectViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */; }; 46ED6C422B333BE400A5555F /* Pair.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46ED6C412B333BE400A5555F /* Pair.swift */; }; 46F0746F294C8F3400E641E9 /* OAEmissionHelper.mm in Sources */ = {isa = PBXBuildFile; fileRef = 46BB3678294BE77200155EC8 /* OAEmissionHelper.mm */; }; 46F0CA9829B0C4F50009C205 /* OAWikipediaSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 46F0CA9729B0C4F50009C205 /* OAWikipediaSettingsViewController.m */; }; @@ -4635,6 +4646,19 @@ 46E9ADA028DC9FD000CC55F9 /* OAButtonTableViewCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OAButtonTableViewCell.m; sourceTree = ""; }; 46ED6C3D2B333B4400A5555F /* Plugin+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Plugin+Extension.swift"; sourceTree = ""; }; 46ED6C3F2B333B8100A5555F /* ExternalSensorsPlugin+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ExternalSensorsPlugin+Extension.swift"; sourceTree = ""; }; + 9F4844A12FD0000100484401 /* AisNmeaParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisNmeaParser.swift; sourceTree = ""; }; + 9F4844A22FD0000100484401 /* AisNmeaConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisNmeaConnection.swift; sourceTree = ""; }; + 9F4844A32FD0000100484401 /* OAAisTrackerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAAisTrackerPlugin.swift; sourceTree = ""; }; + 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerSettingsViewController.swift; sourceTree = ""; }; + 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisSimulationProvider.swift; sourceTree = ""; }; + 9F4844AB2FD0000100484401 /* AisObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisObject.swift; sourceTree = ""; }; + 9F4844AD2FD0000100484401 /* AisDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisDataManager.swift; sourceTree = ""; }; + 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisMessageDecoder.swift; sourceTree = ""; }; + 9F4844B12FD0000100484401 /* OAAisTrackerLayer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OAAisTrackerLayer.h; sourceTree = ""; }; + 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OAAisTrackerLayer.mm; sourceTree = ""; }; + 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerHelper.swift; sourceTree = ""; }; + 9F4844B62FD0000100484401 /* OAAisObjectViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OAAisObjectViewController.h; sourceTree = ""; }; + 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OAAisObjectViewController.mm; sourceTree = ""; }; 46ED6C412B333BE400A5555F /* Pair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pair.swift; sourceTree = ""; }; 46F0CA9629B0C4E20009C205 /* OAWikipediaSettingsViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OAWikipediaSettingsViewController.h; sourceTree = ""; }; 46F0CA9729B0C4F50009C205 /* OAWikipediaSettingsViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OAWikipediaSettingsViewController.m; sourceTree = ""; }; @@ -11203,6 +11227,7 @@ DA5A797626C563A000F274C7 /* Plugins */ = { isa = PBXGroup; children = ( + 9F4844A02FD0000100484401 /* AisTrackerPlugin */, FA1D6DAC2DCE04640080E374 /* VehicleMetricsPlugin */, FACE409C2AEA9A8000E1E43A /* ExternalSensorsPlugin */, 32119B3A28477112005E1E0C /* Development */, @@ -11228,6 +11253,26 @@ path = Plugins; sourceTree = ""; }; + 9F4844A02FD0000100484401 /* AisTrackerPlugin */ = { + isa = PBXGroup; + children = ( + 9F4844A12FD0000100484401 /* AisNmeaParser.swift */, + 9F4844A22FD0000100484401 /* AisNmeaConnection.swift */, + 9F4844AB2FD0000100484401 /* AisObject.swift */, + 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */, + 9F4844AD2FD0000100484401 /* AisDataManager.swift */, + 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */, + 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */, + 9F4844A32FD0000100484401 /* OAAisTrackerPlugin.swift */, + 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */, + 9F4844B12FD0000100484401 /* OAAisTrackerLayer.h */, + 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */, + 9F4844B62FD0000100484401 /* OAAisObjectViewController.h */, + 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */, + ); + path = AisTrackerPlugin; + sourceTree = ""; + }; DA5A797726C563A000F274C7 /* Wikipedia */ = { isa = PBXGroup; children = ( @@ -17934,6 +17979,7 @@ 4657490E2B6803710006046B /* TrashItem.swift in Sources */, DA5A81F126C563A700F274C7 /* OAProfileIconColor.m in Sources */, DA5A83F026C563A800F274C7 /* OAQuickSearchTableController.mm in Sources */, + 9F4844A62FD0000100484401 /* AisNmeaConnection.swift in Sources */, DA5A819E26C563A700F274C7 /* NSData+CRC32.m in Sources */, DA69ED5C2A385B1B001022C7 /* WidgetPanelViewController.swift in Sources */, FACE409F2AEA9ACB00E1E43A /* OAExternalSensorsPlugin.mm in Sources */, @@ -17947,6 +17993,14 @@ DA5A83B626C563A800F274C7 /* OAActionAddCategoryViewController.mm in Sources */, 8AE943B327A28BE900961319 /* OAWeatherRasterLayer.mm in Sources */, FA1D6DAE2DCE04710080E374 /* VehicleMetricsPlugin.swift in Sources */, + 9F4844A72FD0000100484401 /* OAAisTrackerPlugin.swift in Sources */, + 9F4844AA2FD0000100484401 /* AisSimulationProvider.swift in Sources */, + 9F4844AC2FD0000100484401 /* AisObject.swift in Sources */, + 9F4844AE2FD0000100484401 /* AisDataManager.swift in Sources */, + 9F4844B02FD0000100484401 /* AisMessageDecoder.swift in Sources */, + 9F4844B32FD0000100484401 /* OAAisTrackerLayer.mm in Sources */, + 9F4844B52FD0000100484401 /* AisTrackerHelper.swift in Sources */, + 9F4844B82FD0000100484401 /* OAAisObjectViewController.mm in Sources */, DA5A813B26C563A700F274C7 /* OAOsmMapUtils.mm in Sources */, DA5A856026C563A900F274C7 /* OARTargetPoint.mm in Sources */, DA5A837426C563A800F274C7 /* OARouteSegmentShieldView.mm in Sources */, @@ -18180,6 +18234,7 @@ FACDC63D2DDB317D00CB0C55 /* VehicleMetricsDescriptionViewController.swift in Sources */, DA5A81DC26C563A700F274C7 /* OABuilding.mm in Sources */, DA5A849D26C563A900F274C7 /* OARemovePointCommand.mm in Sources */, + 9F4844A52FD0000100484401 /* AisNmeaParser.swift in Sources */, DA5A819926C563A700F274C7 /* OAGPXAction.mm in Sources */, FA0B58222A69578A006F8F9A /* FreeBackupBannerCell.swift in Sources */, 320F71272A823FB20071C0E7 /* PopularArticles.swift in Sources */, @@ -18199,6 +18254,7 @@ DA5A839A26C563A800F274C7 /* OALocalResourceInfoCell.m in Sources */, DA5A823926C563A700F274C7 /* OAOsmAccountSettingsViewController.m in Sources */, DA5A815C26C563A700F274C7 /* OAPlugin.mm in Sources */, + 9F4844A82FD0000100484401 /* AisTrackerSettingsViewController.swift in Sources */, DA5A812426C563A700F274C7 /* OATransportStopRoute.mm in Sources */, DA05A6A3285B3DCE00D0AFAB /* OABackupExporter.m in Sources */, 32119B502847D807005E1E0C /* OAOsmandDevelopmentSimulateLocationViewController.mm in Sources */, diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index 8229a7b23c..5613f570f0 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -800,6 +800,7 @@ "shared_string_select" = "Select"; "shared_string_back" = "Back"; "shared_string_ok" = "OK"; +"shared_string_disabled" = "Disabled"; "shared_string_cancel" = "Cancel"; "shared_string_exit" = "Exit"; "shared_string_close" = "Close"; @@ -2432,6 +2433,163 @@ "plugin_nautical_name" = "Nautical map view"; "product_desc_nautical" = "Vector maps appropriate for sailing routes navigation"; "plugin_nautical_descr" = "Enriches your map with information about sailing routes, navigation lights, nautical danger zones, zones with sailing or docking restrictions, etc. To use, download the respective Nautical maps and enable the \"Nautical map\" style under \"Configure map\". \n\nFurther reading at %@."; +"ais_load_data" = "Load AIS data from file"; +"ais_address_settings" = "IP address settings"; +"ais_address_settings_description" = "Choose the NMEA protocol (UDP/TCP) and specify the corresponding addresses and ports"; +"ais_nmea_protocol" = "Protocol for NMEA data reception"; +"ais_nmea_protocol_description" = "Choose protocol for NMEA data reception"; +"ais_address_nmea_server" = "IP address of NMEA data source"; +"ais_address_nmea_server_description" = "Define IP address of the NMEA data source (if TCP is used)"; +"ais_port_nmea_server" = "TCP port of NMEA data source"; +"ais_port_nmea_server_description" = "Define TCP port number of the NMEA data source"; +"ais_port_nmea_local" = "UDP port of local NMEA data receiver"; +"ais_port_nmea_local_description" = "Define UDP port where OsmAnd receives NMEA data"; +"ais_object_lost_timeouts" = "Timeout settings for AIS signal reception"; +"ais_object_lost_timeouts_description" = "Set timeout values to identify lost AIS objects if no signal was received for a specific time."; +"ais_object_lost_timeout" = "Timeout for visibility when ship is lost"; +"ais_object_lost_timeout_description" = "Set a timeout for AIS object visibility: If no signal is received within the specified duration, the object will be automatically removed from the display."; +"ais_ship_lost_timeout" = "Ship outdated timeout"; +"ais_ship_lost_timeout_description" = "Set timeout for ship outdated visibility: after this time without signal reception, the ship symbol will be crossed out."; +"ais_cpa_settings" = "Settings related to CPA"; +"ais_cpa_settings_description" = "These values determine how AIS objects are displayed when they approach the vessel's own position."; +"ais_cpa_warning_time" = "Warning time before the Closest Point of Approach (CPA)"; +"ais_cpa_warning_time_description" = "If the TCPA (Time to Closest Point of Approach) with another vessel falls below the defined threshold, the vessel is highlighted in red."; +"ais_cpa_warning_distance" = "Warning distance for the Closest Point of Approach (CPA)"; +"ais_cpa_warning_distance_description" = "Vessels are highlighted in red if the CPA (Closest Point of Approach) is below the defined threshold and the TCPA (Time to CPA) falls within the warning time set in the \"Warning time to reach the CPA\" setting."; +"plugin_ais_tracker_name" = "AIS vessel tracker"; +"plugin_ais_tracker_description" = "Displays AIS positions and information about nearby vessels. AIS data is received over the network from an external AIS receiver."; +"plugin_ais_tracker_disclaimer" = "DISCLAIMER\n\nThis plugin is a hobby project and is not intended for use in critical applications. It does not guarantee reliability or accuracy. Do not rely on this software for navigation, safety of life, or any other essential operations."; +"ais_use_nmea_location" = "Use NMEA as current location"; +"ais_add_test_objects" = "Add test AIS objects"; +"ais_clear_simulation" = "Clear AIS simulation"; +"ais_reconnect" = "Reconnect"; +"ais_nautical_mile" = "nautical mile"; +"ais_nautical_miles" = "nautical miles"; +"ais_error_ipv4_only" = "Only IPv4 address accepted (\"a.b.c.d\", where a,b,c,d in range 0..255)."; +"ais_error_port_only" = "Only numerical values accepted in range 0..65535."; +"ais_connection_connected" = "Connected"; +"ais_connection_connecting" = "Connecting"; +"ais_connection_failed" = "Connection failed"; +"ais_connection_disconnected" = "Disconnected"; +"ais_call_sign" = "Call sign"; +"ais_object_type" = "AIS type"; +"ais_ship_type" = "Ship type"; +"ais_position" = "Position"; +"ais_destination" = "Destination"; +"ais_message_types" = "AIS message types"; +"ais_heading" = "Heading"; +"ais_navigation_status" = "Navigation status"; +"ais_maneuver" = "Maneuver"; +"ais_aid_type" = "Aid type"; +"ais_dimensions" = "Dimensions"; +"ais_antenna" = "AIS antenna"; +"ais_antenna_offsets_format" = "bow %ld, stern %ld, port %ld, starboard %ld m"; +"ais_draught" = "Draught"; +"ais_last_update" = "Last update"; +"ais_type_vessel" = "Vessel"; +"ais_type_sport_vessel" = "Sport vessel"; +"ais_type_high_speed_vessel" = "High speed vessel"; +"ais_type_passenger_vessel" = "Passenger vessel"; +"ais_type_cargo_tanker" = "Cargo / tanker"; +"ais_type_commercial_vessel" = "Commercial vessel"; +"ais_type_authorities_vessel" = "Authorities vessel"; +"ais_type_sar_vessel" = "SAR vessel"; +"ais_type_base_station" = "Base station"; +"ais_type_sar_aircraft" = "SAR aircraft"; +"ais_type_sart" = "AIS-SART"; +"ais_type_aid_to_navigation" = "Aid to navigation"; +"ais_type_virtual_aid_to_navigation" = "Virtual aid to navigation"; +"ais_type_other_vessel" = "Other vessel"; +"ais_type_object" = "AIS object"; +"ais_unknown" = "unknown"; +"ais_not_specified" = "not specified"; +"ais_ship_type_wig" = "Wing in ground (WIG)"; +"ais_ship_type_wig_hazard_a" = "WIG, Hazardous category A"; +"ais_ship_type_wig_hazard_b" = "WIG, Hazardous category B"; +"ais_ship_type_wig_hazard_c" = "WIG, Hazardous category C"; +"ais_ship_type_wig_hazard_d" = "WIG, Hazardous category D"; +"ais_ship_type_fishing" = "Fishing"; +"ais_ship_type_towing" = "Towing"; +"ais_ship_type_dredging" = "Dredging"; +"ais_ship_type_diving_ops" = "Diving ops"; +"ais_ship_type_military_ops" = "Military ops"; +"ais_ship_type_sailing" = "Sailing"; +"ais_ship_type_pleasure_craft" = "Pleasure Craft"; +"ais_ship_type_hsc" = "High Speed Craft (HSC)"; +"ais_ship_type_hsc_hazard_a" = "HSC, Hazardous category A"; +"ais_ship_type_hsc_hazard_b" = "HSC, Hazardous category B"; +"ais_ship_type_hsc_hazard_c" = "HSC, Hazardous category C"; +"ais_ship_type_hsc_hazard_d" = "HSC, Hazardous category D"; +"ais_ship_type_pilot_vessel" = "Pilot Vessel"; +"ais_ship_type_search_and_rescue" = "Search and Rescue vessel"; +"ais_ship_type_tug" = "Tug"; +"ais_ship_type_port_tender" = "Port Tender"; +"ais_ship_type_antipollution" = "Anti-pollution equipment"; +"ais_ship_type_law_enforcement" = "Law Enforcement"; +"ais_ship_type_spare_local_vessel" = "Spare - Local Vessel"; +"ais_ship_type_medical_transport" = "Medical Transport"; +"ais_ship_type_noncombatant" = "Noncombatant ship according to RR Resolution No. 18"; +"ais_ship_type_passenger" = "Passenger"; +"ais_ship_type_passenger_hazard_a" = "Passenger, Hazardous category A"; +"ais_ship_type_passenger_hazard_b" = "Passenger, Hazardous category B"; +"ais_ship_type_passenger_hazard_c" = "Passenger, Hazardous category C"; +"ais_ship_type_passenger_hazard_d" = "Passenger, Hazardous category D"; +"ais_ship_type_passenger_cruise_ferry" = "Passenger/Cruise/Ferry"; +"ais_ship_type_cargo" = "Cargo"; +"ais_ship_type_cargo_hazard_a" = "Cargo, Hazardous category A"; +"ais_ship_type_cargo_hazard_b" = "Cargo, Hazardous category B"; +"ais_ship_type_cargo_hazard_c" = "Cargo, Hazardous category C"; +"ais_ship_type_cargo_hazard_d" = "Cargo, Hazardous category D"; +"ais_ship_type_tanker" = "Tanker"; +"ais_ship_type_tanker_hazard_a" = "Tanker, Hazardous category A"; +"ais_ship_type_tanker_hazard_b" = "Tanker, Hazardous category B"; +"ais_ship_type_tanker_hazard_c" = "Tanker, Hazardous category C"; +"ais_ship_type_tanker_hazard_d" = "Tanker, Hazardous category D"; +"ais_ship_type_other" = "Other Type"; +"ais_ship_type_other_hazard_a" = "Other Type, Hazardous category A"; +"ais_ship_type_other_hazard_b" = "Other Type, Hazardous category B"; +"ais_ship_type_other_hazard_c" = "Other Type, Hazardous category C"; +"ais_ship_type_other_hazard_d" = "Other Type, Hazardous category D"; +"ais_nav_status_under_way_engine" = "Under way using engine"; +"ais_nav_status_at_anchor" = "At anchor"; +"ais_nav_status_not_under_command" = "Not under command"; +"ais_nav_status_restricted_maneuverability" = "Restricted maneuverability"; +"ais_nav_status_constrained_draught" = "Constrained by her draught"; +"ais_nav_status_moored" = "Moored"; +"ais_nav_status_aground" = "Aground"; +"ais_nav_status_engaged_fishing" = "Engaged in Fishing"; +"ais_nav_status_under_way_sailing" = "Under way sailing"; +"ais_nav_status_towing_astern" = "Power-driven vessel towing astern"; +"ais_nav_status_pushing_or_towing" = "Power-driven vessel pushing ahead or towing alongside"; +"ais_nav_status_sart_active" = "AIS-SART is active"; +"ais_maneuver_no_special" = "No special maneuver"; +"ais_maneuver_special" = "Special maneuver"; +"ais_aid_reference_point" = "Reference point"; +"ais_aid_racon" = "RACON"; +"ais_aid_fixed_structure_off_shore" = "Fixed structure off shore"; +"ais_aid_light_without_sectors" = "Light, without sectors"; +"ais_aid_light_with_sectors" = "Light, with sectors"; +"ais_aid_leading_light_front" = "Leading Light Front"; +"ais_aid_leading_light_rear" = "Leading Light Rear"; +"ais_aid_beacon_cardinal_n" = "Beacon, Cardinal N"; +"ais_aid_beacon_cardinal_e" = "Beacon, Cardinal E"; +"ais_aid_beacon_cardinal_s" = "Beacon, Cardinal S"; +"ais_aid_beacon_cardinal_w" = "Beacon, Cardinal W"; +"ais_aid_beacon_port_hand" = "Beacon, Port hand"; +"ais_aid_beacon_starboard_hand" = "Beacon, Starboard hand"; +"ais_aid_beacon_isolated_danger" = "Beacon, Isolated danger"; +"ais_aid_beacon_safe_water" = "Beacon, Safe water"; +"ais_aid_beacon_special_mark" = "Beacon, Special mark"; +"ais_aid_cardinal_mark_n" = "Cardinal Mark N"; +"ais_aid_cardinal_mark_e" = "Cardinal Mark E"; +"ais_aid_cardinal_mark_s" = "Cardinal Mark S"; +"ais_aid_cardinal_mark_w" = "Cardinal Mark W"; +"ais_aid_port_hand_mark" = "Port hand Mark"; +"ais_aid_starboard_hand_mark" = "Starboard hand Mark"; +"ais_aid_isolated_danger" = "Isolated danger"; +"ais_aid_safe_water" = "Safe Water"; +"ais_aid_special_mark" = "Special Mark"; +"ais_aid_light_vessel_lanby_rigs" = "Light Vessel / LANBY / Rigs"; "osmand_parking_plugin_name" = "Parking position"; "product_desc_parking" = "Track the parking time and location of your car"; diff --git a/Sources/Controllers/Map/Layers/OAMapLayers.h b/Sources/Controllers/Map/Layers/OAMapLayers.h index 79bf29ca28..3516046ee8 100644 --- a/Sources/Controllers/Map/Layers/OAMapLayers.h +++ b/Sources/Controllers/Map/Layers/OAMapLayers.h @@ -35,6 +35,8 @@ #import "OANetworkRouteSelectionLayer.h" #import "OATravelSelectionLayer.h" +@class OAAisTrackerLayer; + @class OAMapViewController; @interface OAMapLayers : NSObject @@ -55,6 +57,7 @@ @property (nonatomic, readonly) OARulerByTapControlLayer *rulerByTapControlLayer; @property (nonatomic, readonly) OANetworkRouteSelectionLayer *networkRouteSelectionLayer; @property (nonatomic, readonly) OATravelSelectionLayer *travelSelectionLayer; +@property (nonatomic, readonly) OAAisTrackerLayer *aisTrackerLayer; @property (nonatomic) NSDate *weatherDate; @property (nonatomic, readonly) OAWeatherRasterLayer *weatherLayerLow; @@ -77,6 +80,7 @@ - (void) createLayers; - (void) destroyLayers; - (NSArray *) getLayers; +- (void) addLayer:(OAMapLayer *)layer; - (void) resetLayers; - (void) updateLayers; diff --git a/Sources/Controllers/Map/Layers/OAMapLayers.mm b/Sources/Controllers/Map/Layers/OAMapLayers.mm index eb69edc8fe..cb731e996b 100644 --- a/Sources/Controllers/Map/Layers/OAMapLayers.mm +++ b/Sources/Controllers/Map/Layers/OAMapLayers.mm @@ -14,6 +14,7 @@ #import "OAPlugin.h" #import "OAPluginsHelper.h" #import "OAAutoObserverProxy.h" +#import "OAAisTrackerLayer.h" @implementation OAMapLayers { @@ -75,6 +76,9 @@ - (void) createLayers _travelSelectionLayer = [[OATravelSelectionLayer alloc] initWithMapViewController:_mapViewController baseOrder:190000]; [self addLayer:_travelSelectionLayer]; + _aisTrackerLayer = [[OAAisTrackerLayer alloc] initWithMapViewController:_mapViewController baseOrder:-118000 pointsOrder:-160000]; + [self addLayer:_aisTrackerLayer]; + _routeMapLayer = [[OARouteLayer alloc] initWithMapViewController:_mapViewController baseOrder:200000 pointsOrder:-150000]; [self addLayer:_routeMapLayer]; diff --git a/Sources/Controllers/Panels/OAMapPanelViewController.mm b/Sources/Controllers/Panels/OAMapPanelViewController.mm index ea08fe8ae8..f38fb01b7b 100644 --- a/Sources/Controllers/Panels/OAMapPanelViewController.mm +++ b/Sources/Controllers/Panels/OAMapPanelViewController.mm @@ -2486,6 +2486,7 @@ - (void) showTargetPointMenu:(BOOL)saveMapState showFullMenu:(BOOL)showFullMenu case OATargetTurn: case OATargetMyLocation: case OATargetLocation: + case OATargetAisObject: case OATargetRenderedObject: case OATargetBaseDetailsObject: { diff --git a/Sources/Controllers/Resources/OAPluginDetailsViewController.mm b/Sources/Controllers/Resources/OAPluginDetailsViewController.mm index 99c1b0987e..80fd50965a 100644 --- a/Sources/Controllers/Resources/OAPluginDetailsViewController.mm +++ b/Sources/Controllers/Resources/OAPluginDetailsViewController.mm @@ -191,7 +191,8 @@ - (void) viewDidLoad - (void)updateSettingsButtonState { NSArray *supportedIdentifiers = @[ kInAppId_Addon_External_Sensors, - kInAppId_Addon_Vehicle_Metrics + kInAppId_Addon_Vehicle_Metrics, + kInAppId_Addon_Ais_Tracker ]; if ([supportedIdentifiers containsObject:_product.productIdentifier]) { @@ -445,6 +446,8 @@ - (UIViewController *) getSettingsViewController return [[UIStoryboard storyboardWithName:@"BLEExternalSensors" bundle:nil] instantiateViewControllerWithIdentifier:@"BLEExternalSensors"]; else if ([_product isKindOfClass:OAVehicleMetricsProduct.class]) return [[UIStoryboard storyboardWithName:@"VehicleMetricsSensors" bundle:nil] instantiateViewControllerWithIdentifier:@"VehicleMetricsSensors"]; + else if ([_product isKindOfClass:OAAisTrackerProduct.class]) + return [[OAPluginsHelper getPlugin:OAAisTrackerPlugin.class] getSettingsController]; return nil; } diff --git a/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm b/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm index 346adec194..94f62c4191 100644 --- a/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm +++ b/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm @@ -26,13 +26,15 @@ #import "OAMapPanelViewController.h" #import "OAMapViewController.h" #import "OAMapRendererView.h" +#import "OAUtilities.h" #import "OAIndexConstants.h" #import "OAPluginsHelper.h" #import "OASwitchTableViewCell.h" #import "OAObservable.h" #import "OsmAnd_Maps-Swift.h" +#import -@interface OAOsmandDevelopmentViewController () +@interface OAOsmandDevelopmentViewController () @end @@ -50,6 +52,7 @@ @implementation OAOsmandDevelopmentViewController NSString *const kShowTouchesKey = @"kShowTouchesKey"; NSString *const kVisualizingButtonGridKey = @"kVisualizingButtonGridKey"; NSString *const kSimulateLocationKey = @"kSimulateLocationKey"; +NSString *const kAisTrackerSimulationKey = @"kAisTrackerSimulationKey"; NSString *const kTraceRenderingKey = @"kTraceRenderingKey"; NSString *const kSimulateOBDDataKey = @"kSimulateOBDDataKey"; NSString *const kImageCacheKey = @"kImageCacheKey"; @@ -68,6 +71,7 @@ - (void)commonInit - (void)registerNotifications { [self addNotification:OAIAPProductPurchasedNotification selector:@selector(productPurchased:)]; + [self addNotification:@"OAAisSimulationStatusChanged" selector:@selector(onAisSimulationStatusChanged:)]; } #pragma mark - Base UI @@ -110,6 +114,25 @@ - (void)generateData kCellTitleKey : OALocalizedString(@"simulate_obd"), @"isOn" : @([[OAAppSettings sharedManager].simulateOBDData get]) }]; + + OAAisTrackerPlugin *aisPlugin = (OAAisTrackerPlugin *)[OAPluginsHelper getPlugin:OAAisTrackerPlugin.class]; + if (aisPlugin) + { + NSString *simulationDescription = aisPlugin.simulationFileName ?: @""; + if (aisPlugin.simulationStatusText.length > 0) + { + simulationDescription = simulationDescription.length > 0 + ? [NSString stringWithFormat:@"%@ • %@", simulationDescription, aisPlugin.simulationStatusText] + : aisPlugin.simulationStatusText; + } + [simulationSection addRowFromDictionary:@{ + kCellTypeKey : [OAValueTableViewCell getCellIdentifier], + kCellKeyKey : kAisTrackerSimulationKey, + kCellTitleKey : OALocalizedString(@"ais_load_data"), + kCellDescrKey : simulationDescription, + @"actionBlock" : (^void(){ [weakSelf openAisSimulationFilePicker]; }) + }]; + } [_data addSection:simulationSection]; @@ -319,6 +342,18 @@ - (void)openVisualizingButtonGridSettings [self showViewController:vc]; } +- (void)openAisSimulationFilePicker +{ + NSArray *contentTypes = @[ + UTTypePlainText, + UTTypeText + ]; + UIDocumentPickerViewController *documentPickerVC = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:contentTypes asCopy:YES]; + documentPickerVC.allowsMultipleSelection = NO; + documentPickerVC.delegate = self; + [self presentViewController:documentPickerVC animated:YES completion:nil]; +} + - (void)openProPlanScreen { if (![OAIAPHelper isOsmAndProAvailable]) @@ -341,4 +376,36 @@ - (void)onSimulateLocationInformationUpdated [self.tableView reloadData]; } +- (void)onAisSimulationStatusChanged:(NSNotification *)notification +{ + [self generateData]; + [self.tableView reloadData]; +} + +#pragma mark - UIDocumentPickerDelegate + +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls +{ + if (urls.count == 0) + return; + + OAAisTrackerPlugin *aisPlugin = (OAAisTrackerPlugin *)[OAPluginsHelper getPlugin:OAAisTrackerPlugin.class]; + if (!aisPlugin) + return; + + NSURL *url = urls.firstObject; + if (![aisPlugin isEnabled]) + [OAPluginsHelper enablePlugin:aisPlugin enable:YES recreateControls:NO]; + [aisPlugin startAisSimulation:url]; + [OsmAndApp.instance.data.mapLayersConfiguration setLayer:@"ais_tracker_layer" Visibility:YES]; + [OARootViewController.instance.mapPanel.mapViewController updateLayer:@"ais_tracker_layer"]; + [self generateData]; + [self.tableView reloadData]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + UIView *toastView = self.view.window ?: OARootViewController.instance.view; + NSString *details = aisPlugin.simulationStatusText.length > 0 ? aisPlugin.simulationStatusText : url.lastPathComponent; + [OAUtilities showToast:@"AIS simulation" details:details duration:5 inView:toastView]; + }); +} + @end diff --git a/Sources/Controllers/TargetMenu/OATargetMenuViewController.mm b/Sources/Controllers/TargetMenu/OATargetMenuViewController.mm index b98c2eb360..e567dd8b6f 100644 --- a/Sources/Controllers/TargetMenu/OATargetMenuViewController.mm +++ b/Sources/Controllers/TargetMenu/OATargetMenuViewController.mm @@ -54,6 +54,7 @@ #import "OAMapHudViewController.h" #import "OAMapRendererView.h" #import "OADownloadMapViewController.h" +#import "OAAisObjectViewController.h" #import "OAPlugin.h" #import "OAWikipediaPlugin.h" #import "OAPOI.h" @@ -212,6 +213,12 @@ + (OATargetMenuViewController *)createMenuController:(OATargetPoint *)targetPoin controller = [[RenderedObjectViewController alloc] initWithRenderedObject:targetPoint.targetObj]; break; } + + case OATargetAisObject: + { + controller = [[OAAisObjectViewController alloc] initWithAisObject:targetPoint.targetObj]; + break; + } case OATargetAddress: { diff --git a/Sources/Data/OATargetPoint.h b/Sources/Data/OATargetPoint.h index c7e0afb9d9..810062618e 100644 --- a/Sources/Data/OATargetPoint.h +++ b/Sources/Data/OATargetPoint.h @@ -59,7 +59,8 @@ typedef NS_ENUM(NSInteger, OATargetPointType) OATargetMapModeParametersSettings, OATargetProfileAppearanceIconSizeSettings, OATargetBaseDetailsObject, - OATargetRenderedObject + OATargetRenderedObject, + OATargetAisObject }; @interface OATargetPoint : NSObject diff --git a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift new file mode 100644 index 0000000000..64f42b6275 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift @@ -0,0 +1,79 @@ +import Foundation + +extension Notification.Name { + static let aisObjectReceived = Notification.Name("OAAisObjectReceived") + static let aisObjectRemoved = Notification.Name("OAAisObjectRemoved") + static let aisObjectsChanged = Notification.Name("OAAisObjectsChanged") + static let aisSimulationStatusChanged = Notification.Name("OAAisSimulationStatusChanged") +} + +@objcMembers +final class AisDataManager: NSObject { + private static let objectLimit = 200 + + private weak var plugin: OAAisTrackerPlugin? + private var objectsByMmsi: [Int: AisObject] = [:] + private var cleanupTimer: Timer? + + init(plugin: OAAisTrackerPlugin) { + self.plugin = plugin + super.init() + } + + var objects: [AisObject] { + Array(objectsByMmsi.values) + } + + func startUpdates() { + stopUpdates() + cleanupTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + self?.removeLostObjects() + } + } + + func stopUpdates() { + cleanupTimer?.invalidate() + cleanupTimer = nil + } + + func cleanupResources() { + stopUpdates() + objectsByMmsi.removeAll() + NotificationCenter.default.post(name: .aisObjectsChanged, object: plugin) + } + + func onAisObjectReceived(_ ais: AisObject) { + let object: AisObject + if let existing = objectsByMmsi[ais.mmsi] { + existing.merge(ais) + object = existing + } else { + objectsByMmsi[ais.mmsi] = ais + object = ais + } + if objectsByMmsi.count >= Self.objectLimit { + removeOldestObject() + } + plugin?.onAisObjectReceived(object) + NotificationCenter.default.post(name: .aisObjectsChanged, object: plugin) + } + + func removeLostObjects() { + guard let plugin else { return } + let maxAge = plugin.maxObjectAgeInMinutes() + let removed = objectsByMmsi.values.filter { $0.isLost(maxAgeMinutes: maxAge) } + for object in removed { + objectsByMmsi.removeValue(forKey: object.mmsi) + plugin.onAisObjectRemoved(object) + } + if !removed.isEmpty { + NotificationCenter.default.post(name: .aisObjectsChanged, object: plugin) + } + } + + private func removeOldestObject() { + guard let oldest = objectsByMmsi.values.min(by: { $0.lastUpdate < $1.lastUpdate }) else { return } + objectsByMmsi.removeValue(forKey: oldest.mmsi) + plugin?.onAisObjectRemoved(oldest) + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift b/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift new file mode 100644 index 0000000000..82586f59a6 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift @@ -0,0 +1,288 @@ +import Foundation + +final class AisMessageDecoder { + private struct FragmentBuffer { + let total: Int + var payloads: [Int: String] + var fillBits: Int + } + + private var fragments: [String: FragmentBuffer] = [:] + + func decode(sentence: String) -> AisObject? { + let trimmed = sentence.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("!AI") || trimmed.hasPrefix("!BS") else { return nil } + let noChecksum = trimmed.split(separator: "*", maxSplits: 1).first.map(String.init) ?? trimmed + let fields = noChecksum.split(separator: ",", omittingEmptySubsequences: false).map(String.init) + guard fields.count >= 7 else { return nil } + let talker = fields[0] + guard talker.hasSuffix("VDM") || talker.hasSuffix("VDO") else { return nil } + guard let total = Int(fields[1]), let number = Int(fields[2]) else { return nil } + let sequentialId = fields[3] + let channel = fields[4] + let payload = fields[5] + let fillBits = Int(fields[6]) ?? 0 + + let completePayload: String + let completeFillBits: Int + if total > 1 { + let key = "\(sequentialId)-\(channel)" + var buffer = fragments[key] ?? FragmentBuffer(total: total, payloads: [:], fillBits: fillBits) + buffer.payloads[number] = payload + buffer.fillBits = fillBits + fragments[key] = buffer + guard buffer.payloads.count == total else { return nil } + completePayload = (1...total).compactMap { buffer.payloads[$0] }.joined() + completeFillBits = buffer.fillBits + fragments.removeValue(forKey: key) + } else { + completePayload = payload + completeFillBits = fillBits + } + + let bits = AisBitReader(payload: completePayload) + bits.dropLast(completeFillBits) + guard let msgType = bits.uint(0, 6) else { return nil } + switch msgType { + case 1, 2, 3: + return decodePositionReport(bits: bits, msgType: msgType) + case 4: + return decodeBaseStation(bits: bits, msgType: msgType) + case 5: + return decodeStaticVoyage(bits: bits, msgType: msgType) + case 9: + return decodeAircraft(bits: bits, msgType: msgType) + case 18: + return decodeClassBPosition(bits: bits, msgType: msgType) + case 19: + return decodeExtendedClassBPosition(bits: bits, msgType: msgType) + case 21: + return decodeAton(bits: bits, msgType: msgType) + case 24: + return decodeStaticDataReport(bits: bits, msgType: msgType) + case 27: + return decodeLongRange(bits: bits, msgType: msgType) + default: + return nil + } + } + + private func decodePositionReport(bits: AisBitReader, msgType: Int) -> AisObject? { + guard let mmsi = bits.uint(8, 30) else { return nil } + let ais = AisObject(mmsi: mmsi, msgType: msgType) + ais.applyPosition(timestamp: bits.uint(137, 6) ?? 0, + navStatus: bits.uint(38, 4) ?? AisObjectConstants.invalidNavStatus, + maneuverIndicator: bits.uint(143, 2) ?? AisObjectConstants.invalidManeuverIndicator, + heading: bits.uint(128, 9) ?? AisObjectConstants.invalidHeading, + cog: scaled(bits.uint(116, 12), scale: 10, invalidRaw: 3600, invalid: AisObjectConstants.invalidCog), + sog: scaled(bits.uint(50, 10), scale: 10, invalidRaw: 1023, invalid: AisObjectConstants.invalidSog), + lat: latitude(bits.int(89, 27), divisor: 600000), + lon: longitude(bits.int(61, 28), divisor: 600000), + rot: rot(bits.int(42, 8))) + return ais + } + + private func decodeBaseStation(bits: AisBitReader, msgType: Int) -> AisObject? { + guard let mmsi = bits.uint(8, 30) else { return nil } + let ais = AisObject(mmsi: mmsi, msgType: msgType) + ais.applyBaseStation(lat: latitude(bits.int(107, 27), divisor: 600000), + lon: longitude(bits.int(79, 28), divisor: 600000)) + return ais + } + + private func decodeStaticVoyage(bits: AisBitReader, msgType: Int) -> AisObject? { + guard let mmsi = bits.uint(8, 30) else { return nil } + let ais = AisObject(mmsi: mmsi, msgType: msgType) + ais.applyStatic(imo: bits.uint(40, 30) ?? 0, + callSign: bits.string(70, 42), + shipName: bits.string(112, 120), + shipType: bits.uint(232, 8) ?? AisObjectConstants.invalidShipType, + bow: bits.uint(240, 9) ?? 0, + stern: bits.uint(249, 9) ?? 0, + port: bits.uint(258, 6) ?? 0, + starboard: bits.uint(264, 6) ?? 0, + draught: scaled(bits.uint(294, 8), scale: 10, invalidRaw: 0, invalid: 0), + destination: bits.string(302, 120), + etaMonth: bits.uint(274, 4) ?? 0, + etaDay: bits.uint(278, 5) ?? 0, + etaHour: bits.uint(283, 5) ?? AisObjectConstants.invalidEtaHour, + etaMinute: bits.uint(288, 6) ?? AisObjectConstants.invalidEtaMin) + return ais + } + + private func decodeAircraft(bits: AisBitReader, msgType: Int) -> AisObject? { + guard let mmsi = bits.uint(8, 30) else { return nil } + let ais = AisObject(mmsi: mmsi, msgType: msgType) + ais.applyAircraft(timestamp: bits.uint(137, 6) ?? 0, + altitude: bits.uint(38, 12) ?? AisObjectConstants.invalidAltitude, + cog: scaled(bits.uint(116, 12), scale: 10, invalidRaw: 3600, invalid: AisObjectConstants.invalidCog), + sog: scaled(bits.uint(50, 10), scale: 10, invalidRaw: 1023, invalid: AisObjectConstants.invalidSog), + lat: latitude(bits.int(89, 27), divisor: 600000), + lon: longitude(bits.int(61, 28), divisor: 600000)) + return ais + } + + private func decodeClassBPosition(bits: AisBitReader, msgType: Int) -> AisObject? { + guard let mmsi = bits.uint(8, 30) else { return nil } + let ais = AisObject(mmsi: mmsi, msgType: msgType) + ais.applyPosition(timestamp: bits.uint(133, 6) ?? 0, + navStatus: AisObjectConstants.invalidNavStatus, + maneuverIndicator: AisObjectConstants.invalidManeuverIndicator, + heading: bits.uint(124, 9) ?? AisObjectConstants.invalidHeading, + cog: scaled(bits.uint(116, 12), scale: 10, invalidRaw: 3600, invalid: AisObjectConstants.invalidCog), + sog: scaled(bits.uint(46, 10), scale: 10, invalidRaw: 1023, invalid: AisObjectConstants.invalidSog), + lat: latitude(bits.int(85, 27), divisor: 600000), + lon: longitude(bits.int(57, 28), divisor: 600000), + rot: AisObjectConstants.invalidRot) + return ais + } + + private func decodeExtendedClassBPosition(bits: AisBitReader, msgType: Int) -> AisObject? { + guard let ais = decodeClassBPosition(bits: bits, msgType: msgType) else { return nil } + ais.applyStatic(imo: 0, + callSign: ais.callSign, + shipName: bits.string(143, 120), + shipType: bits.uint(263, 8) ?? AisObjectConstants.invalidShipType, + bow: bits.uint(271, 9) ?? 0, + stern: bits.uint(280, 9) ?? 0, + port: bits.uint(289, 6) ?? 0, + starboard: bits.uint(295, 6) ?? 0, + draught: AisObjectConstants.invalidDraught, + destination: nil, + etaMonth: AisObjectConstants.invalidEta, + etaDay: AisObjectConstants.invalidEta, + etaHour: AisObjectConstants.invalidEtaHour, + etaMinute: AisObjectConstants.invalidEtaMin) + return ais + } + + private func decodeAton(bits: AisBitReader, msgType: Int) -> AisObject? { + guard let mmsi = bits.uint(8, 30) else { return nil } + let ais = AisObject(mmsi: mmsi, msgType: msgType) + ais.applyAton(lat: latitude(bits.int(164, 27), divisor: 600000), + lon: longitude(bits.int(135, 28), divisor: 600000), + aidType: bits.uint(38, 5) ?? AisObjectConstants.unspecifiedAidType, + bow: bits.uint(219, 9) ?? 0, + stern: bits.uint(228, 9) ?? 0, + port: bits.uint(237, 6) ?? 0, + starboard: bits.uint(243, 6) ?? 0) + return ais + } + + private func decodeStaticDataReport(bits: AisBitReader, msgType: Int) -> AisObject? { + guard let mmsi = bits.uint(8, 30) else { return nil } + let ais = AisObject(mmsi: mmsi, msgType: msgType) + let part = bits.uint(38, 2) ?? 0 + if part == 0 { + ais.applyStatic(imo: 0, callSign: nil, shipName: bits.string(40, 120), + shipType: AisObjectConstants.invalidShipType, + bow: 0, stern: 0, port: 0, starboard: 0, + draught: 0, destination: nil, + etaMonth: 0, etaDay: 0, etaHour: 24, etaMinute: 60) + } else { + ais.applyStatic(imo: 0, + callSign: bits.string(90, 42), + shipName: nil, + shipType: bits.uint(40, 8) ?? AisObjectConstants.invalidShipType, + bow: bits.uint(132, 9) ?? 0, + stern: bits.uint(141, 9) ?? 0, + port: bits.uint(150, 6) ?? 0, + starboard: bits.uint(156, 6) ?? 0, + draught: 0, destination: nil, + etaMonth: 0, etaDay: 0, etaHour: 24, etaMinute: 60) + } + return ais + } + + private func decodeLongRange(bits: AisBitReader, msgType: Int) -> AisObject? { + guard let mmsi = bits.uint(8, 30) else { return nil } + let ais = AisObject(mmsi: mmsi, msgType: msgType) + ais.applyPosition(timestamp: 0, + navStatus: bits.uint(40, 4) ?? AisObjectConstants.invalidNavStatus, + maneuverIndicator: AisObjectConstants.invalidManeuverIndicator, + heading: AisObjectConstants.invalidHeading, + cog: scaled(bits.uint(80, 9), scale: 10, invalidRaw: 511, invalid: AisObjectConstants.invalidCog), + sog: scaled(bits.uint(69, 6), scale: 1, invalidRaw: 63, invalid: AisObjectConstants.invalidSog), + lat: latitude(bits.int(62, 17), divisor: 600), + lon: longitude(bits.int(44, 18), divisor: 600), + rot: AisObjectConstants.invalidRot) + return ais + } + + private func scaled(_ raw: Int?, scale: Double, invalidRaw: Int, invalid: Double) -> Double { + guard let raw, raw != invalidRaw else { return invalid } + return Double(raw) / scale + } + + private func latitude(_ raw: Int?, divisor: Double) -> Double { + guard let raw else { return AisObjectConstants.invalidLat } + let value = Double(raw) / divisor + return abs(value) > 90 ? AisObjectConstants.invalidLat : value + } + + private func longitude(_ raw: Int?, divisor: Double) -> Double { + guard let raw else { return AisObjectConstants.invalidLon } + let value = Double(raw) / divisor + return abs(value) > 180 ? AisObjectConstants.invalidLon : value + } + + private func rot(_ raw: Int?) -> Double { + guard let raw, raw != -128 else { return AisObjectConstants.invalidRot } + return Double(raw) + } +} + +private final class AisBitReader { + private var bits: [Int] = [] + + init(payload: String) { + for scalar in payload.unicodeScalars { + var value = Int(scalar.value) - 48 + if value > 40 { value -= 8 } + guard value >= 0 && value <= 63 else { continue } + for shift in stride(from: 5, through: 0, by: -1) { + bits.append((value >> shift) & 1) + } + } + } + + func dropLast(_ count: Int) { + guard count > 0, count <= bits.count else { return } + bits.removeLast(count) + } + + func uint(_ start: Int, _ length: Int) -> Int? { + guard start >= 0, length > 0, start + length <= bits.count else { return nil } + var value = 0 + for idx in start..<(start + length) { + value = (value << 1) | bits[idx] + } + return value + } + + func int(_ start: Int, _ length: Int) -> Int? { + guard let unsigned = uint(start, length) else { return nil } + let signBit = 1 << (length - 1) + if unsigned & signBit == 0 { + return unsigned + } + return unsigned - (1 << length) + } + + func string(_ start: Int, _ length: Int) -> String? { + guard length > 0, start + length <= bits.count else { return nil } + let chars = stride(from: start, to: start + length, by: 6).compactMap { index -> Character? in + guard let value = uint(index, 6) else { return nil } + if value == 0 { return "@" } + if value >= 1 && value <= 26 { + return Character(UnicodeScalar(value + 64)!) + } + if value >= 32 && value <= 63 { + return Character(UnicodeScalar(value)!) + } + return " " + } + let text = String(chars).trimmingCharacters(in: CharacterSet(charactersIn: " @")) + return text.isEmpty ? nil : text + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift new file mode 100644 index 0000000000..16300c09fe --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift @@ -0,0 +1,160 @@ +import CoreLocation +import Foundation +import Network + +@objc enum AisNmeaProtocol: Int { + case udp = 0 + case tcp = 1 +} + +@objc enum AisNmeaConnectionState: Int { + case disconnected + case connecting + case connected + case failed +} + +final class AisNmeaConnection { + var onLocation: ((CLLocation) -> Void)? + var onSentence: ((String) -> Void)? + var onStateChanged: ((AisNmeaConnectionState) -> Void)? + + private let queue = DispatchQueue(label: "net.osmand.ais.nmea.connection") + private var listener: NWListener? + private var connection: NWConnection? + private var reconnectWorkItem: DispatchWorkItem? + private var buffer = "" + private var shouldReconnect = false + private var host = "" + private var port: UInt16 = 0 + + func startUDP(port: UInt16) { + stop() + updateState(.connecting) + do { + let params = NWParameters.udp + params.allowLocalEndpointReuse = true + let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!) + self.listener = listener + listener.newConnectionHandler = { [weak self] connection in + self?.receiveDatagrams(connection) + connection.start(queue: self?.queue ?? DispatchQueue.global()) + } + listener.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + self?.updateState(.connected) + case .failed: + self?.updateState(.failed) + case .cancelled: + self?.updateState(.disconnected) + default: + break + } + } + listener.start(queue: queue) + } catch { + updateState(.failed) + } + } + + func startTCP(host: String, port: UInt16) { + stop() + self.host = host + self.port = port + shouldReconnect = true + connectTCP() + } + + func stop() { + shouldReconnect = false + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + listener?.cancel() + listener = nil + connection?.cancel() + connection = nil + buffer = "" + updateState(.disconnected) + } + + private func connectTCP() { + updateState(.connecting) + let nwConnection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port)!, using: .tcp) + connection = nwConnection + nwConnection.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + self?.updateState(.connected) + self?.receiveStream(nwConnection) + case .failed, .waiting: + self?.updateState(.failed) + self?.scheduleReconnect() + case .cancelled: + self?.updateState(.disconnected) + default: + break + } + } + nwConnection.start(queue: queue) + } + + private func scheduleReconnect() { + guard shouldReconnect else { return } + connection?.cancel() + connection = nil + let work = DispatchWorkItem { [weak self] in + self?.connectTCP() + } + reconnectWorkItem = work + queue.asyncAfter(deadline: .now() + 5, execute: work) + } + + private func receiveDatagrams(_ connection: NWConnection) { + connection.receiveMessage { [weak self] data, _, _, _ in + if let data, let text = String(data: data, encoding: .ascii) { + self?.consume(text) + } + self?.receiveDatagrams(connection) + } + } + + private func receiveStream(_ connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in + if let data, let text = String(data: data, encoding: .ascii) { + self?.consume(text) + } + if isComplete || error != nil { + self?.scheduleReconnect() + } else { + self?.receiveStream(connection) + } + } + } + + private func consume(_ text: String) { + buffer += text + let separators = CharacterSet.newlines + while let range = buffer.rangeOfCharacter(from: separators) { + let line = String(buffer[.. 8192 { + buffer.removeAll() + } + } + + private func updateState(_ state: AisNmeaConnectionState) { + DispatchQueue.main.async { [weak self] in + self?.onStateChanged?(state) + } + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/AisNmeaParser.swift b/Sources/Plugins/AisTrackerPlugin/AisNmeaParser.swift new file mode 100644 index 0000000000..e94757ab91 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisNmeaParser.swift @@ -0,0 +1,88 @@ +import CoreLocation +import Foundation + +struct AisNmeaParser { + static func parseLocation(from sentence: String) -> CLLocation? { + let line = sentence.trimmingCharacters(in: .whitespacesAndNewlines) + guard line.hasPrefix("$"), isChecksumValid(line) else { return nil } + + let payload = line.dropFirst().split(separator: "*", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? "" + let fields = payload.split(separator: ",", omittingEmptySubsequences: false).map(String.init) + guard let type = fields.first else { return nil } + + if type.hasSuffix("RMC") { + return parseRMC(fields) + } else if type.hasSuffix("GGA") { + return parseGGA(fields) + } + return nil + } + + private static func parseRMC(_ fields: [String]) -> CLLocation? { + guard fields.count > 9, fields[2] == "A", + let latitude = coordinate(fields[3], hemisphere: fields[4]), + let longitude = coordinate(fields[5], hemisphere: fields[6]) else { + return nil + } + + let speed = (Double(fields[7]) ?? -1) * 0.514444 + let course = Double(fields[8]) ?? -1 + let timestamp = date(time: fields[1], date: fields[9]) ?? Date() + return CLLocation(coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), + altitude: 0, + horizontalAccuracy: 10, + verticalAccuracy: -1, + course: course, + speed: speed, + timestamp: timestamp) + } + + private static func parseGGA(_ fields: [String]) -> CLLocation? { + guard fields.count > 9, (Int(fields[6]) ?? 0) > 0, + let latitude = coordinate(fields[2], hemisphere: fields[3]), + let longitude = coordinate(fields[4], hemisphere: fields[5]) else { + return nil + } + + let altitude = Double(fields[9]) ?? 0 + let hdop = Double(fields[8]) ?? 1 + return CLLocation(coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), + altitude: altitude, + horizontalAccuracy: max(5, hdop * 5), + verticalAccuracy: 10, + course: -1, + speed: -1, + timestamp: Date()) + } + + private static func coordinate(_ value: String, hemisphere: String) -> CLLocationDegrees? { + guard let raw = Double(value), value.count >= 4 else { return nil } + let degrees = floor(raw / 100) + let minutes = raw - degrees * 100 + var result = degrees + minutes / 60 + if hemisphere == "S" || hemisphere == "W" { + result = -result + } + return result + } + + private static func date(time: String, date: String) -> Date? { + guard time.count >= 6, date.count == 6 else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "ddMMyyHHmmss.SS" + let normalizedTime = time.contains(".") ? time : "\(time).00" + return formatter.date(from: "\(date)\(normalizedTime)") + } + + private static func isChecksumValid(_ line: String) -> Bool { + let parts = line.split(separator: "*", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, let expected = UInt8(parts[1].prefix(2), radix: 16) else { + return true + } + + let payload = parts[0].dropFirst().utf8.reduce(UInt8(0)) { $0 ^ $1 } + return payload == expected + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/AisObject.swift b/Sources/Plugins/AisTrackerPlugin/AisObject.swift new file mode 100644 index 0000000000..a1655ff05e --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisObject.swift @@ -0,0 +1,416 @@ +import CoreLocation +import Foundation + +@objc enum AisObjType: Int { + case vessel + case vesselSport + case vesselFast + case vesselPassenger + case vesselFreight + case vesselCommercial + case vesselAuthorities + case vesselSar + case vesselOther + case landStation + case airplane + case sart + case aton + case atonVirtual + case invalid +} + +enum AisObjectConstants { + static let invalidHeading = 511 + static let invalidNavStatus = 15 + static let invalidManeuverIndicator = 0 + static let invalidShipType = 0 + static let invalidDimension = 0 + static let invalidEta = 0 + static let invalidEtaHour = 24 + static let invalidEtaMin = 60 + static let invalidAltitude = 4095 + static let unspecifiedAidType = 0 + static let invalidCog = 360.0 + static let invalidSog = 1023.0 + static let invalidLat = 91.0 + static let invalidLon = 181.0 + static let invalidRot = 128.0 + static let invalidDraught = 0.0 + static let invalidTcpa = -10000.0 + static let invalidCpa: Float = -1.0 + static let cpaUpdateTimeout: TimeInterval = 10 +} + +@objcMembers +final class AisObject: NSObject { + let mmsi: Int + private(set) var msgType: Int + private(set) var msgTypes = Set() + private(set) var timestamp = 0 + private(set) var imo = 0 + private(set) var heading = AisObjectConstants.invalidHeading + private(set) var navStatus = AisObjectConstants.invalidNavStatus + private(set) var maneuverIndicator = AisObjectConstants.invalidManeuverIndicator + private(set) var shipType = AisObjectConstants.invalidShipType + private(set) var dimensionToBow = AisObjectConstants.invalidDimension + private(set) var dimensionToStern = AisObjectConstants.invalidDimension + private(set) var dimensionToPort = AisObjectConstants.invalidDimension + private(set) var dimensionToStarboard = AisObjectConstants.invalidDimension + private(set) var etaMonth = AisObjectConstants.invalidEta + private(set) var etaDay = AisObjectConstants.invalidEta + private(set) var etaHour = AisObjectConstants.invalidEtaHour + private(set) var etaMinute = AisObjectConstants.invalidEtaMin + private(set) var altitude = AisObjectConstants.invalidAltitude + private(set) var aidType = AisObjectConstants.unspecifiedAidType + private(set) var draught = AisObjectConstants.invalidDraught + private(set) var cog = AisObjectConstants.invalidCog + private(set) var sog = AisObjectConstants.invalidSog + private(set) var rot = AisObjectConstants.invalidRot + private(set) var latitude = AisObjectConstants.invalidLat + private(set) var longitude = AisObjectConstants.invalidLon + private(set) var callSign: String? + private(set) var shipName: String? + private(set) var destination: String? + private(set) var objectClass: AisObjType = .invalid + private(set) var lastUpdate = Date() + let cpa = AisCpa() + + init(mmsi: Int, msgType: Int) { + self.mmsi = mmsi + self.msgType = msgType + super.init() + msgTypes.insert(msgType) + updateObjectClass() + } + + var hasPosition: Bool { + latitude != AisObjectConstants.invalidLat && longitude != AisObjectConstants.invalidLon + } + + var title: String { + if let shipName, !shipName.isEmpty { return shipName } + if let callSign, !callSign.isEmpty { return callSign } + return "MMSI \(mmsi)" + } + + var messageTypesString: String { + msgTypes.sorted().map(String.init).joined(separator: ", ") + } + + var location: CLLocation? { + guard hasPosition else { return nil } + return CLLocation(coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), + altitude: altitude == AisObjectConstants.invalidAltitude ? 0 : CLLocationDistance(altitude), + horizontalAccuracy: 20, + verticalAccuracy: -1, + course: cog == AisObjectConstants.invalidCog ? -1 : cog, + speed: sog == AisObjectConstants.invalidSog ? -1 : sog * 1852.0 / 3600.0, + timestamp: lastUpdate) + } + + var currentLocation: CLLocation? { + guard let location else { return nil } + let ageHours = Date().timeIntervalSince(lastUpdate) / 3600.0 + return AisTrackerHelper.newPosition(from: location, ageHours: ageHours) + } + + func merge(_ other: AisObject) { + msgType = other.msgType + msgTypes.insert(other.msgType) + if other.timestamp != 0 { timestamp = other.timestamp } + if other.imo != 0 { imo = other.imo } + if other.shipType != AisObjectConstants.invalidShipType { shipType = other.shipType } + if other.dimensionToBow != AisObjectConstants.invalidDimension { dimensionToBow = other.dimensionToBow } + if other.dimensionToStern != AisObjectConstants.invalidDimension { dimensionToStern = other.dimensionToStern } + if other.dimensionToPort != AisObjectConstants.invalidDimension { dimensionToPort = other.dimensionToPort } + if other.dimensionToStarboard != AisObjectConstants.invalidDimension { dimensionToStarboard = other.dimensionToStarboard } + if other.etaMonth != AisObjectConstants.invalidEta { etaMonth = other.etaMonth } + if other.etaDay != AisObjectConstants.invalidEta { etaDay = other.etaDay } + if other.etaHour != AisObjectConstants.invalidEtaHour { etaHour = other.etaHour } + if other.etaMinute != AisObjectConstants.invalidEtaMin { etaMinute = other.etaMinute } + if other.altitude != AisObjectConstants.invalidAltitude { altitude = other.altitude } + if other.aidType != AisObjectConstants.unspecifiedAidType { aidType = other.aidType } + if other.draught != AisObjectConstants.invalidDraught { draught = other.draught } + if other.hasPosition { + latitude = other.latitude + longitude = other.longitude + } + if let value = other.callSign { callSign = value } + if let value = other.shipName { shipName = value } + if let value = other.destination { destination = value } + + if [1, 2, 3, 18, 19, 27].contains(other.msgType) { + heading = other.heading + } + if [1, 2, 3, 27].contains(other.msgType) { + navStatus = other.navStatus + maneuverIndicator = other.maneuverIndicator + rot = other.rot + } + if [1, 2, 3, 9, 18, 19, 27].contains(other.msgType) { + cog = other.cog + sog = other.sog + } + lastUpdate = Date() + updateObjectClass() + } + + func isLost(maxAgeMinutes: Int) -> Bool { + Date().timeIntervalSince(lastUpdate) / 60.0 > Double(maxAgeMinutes) + } + + func signalLost(maxAgeMinutes: Int) -> Bool { + isLost(maxAgeMinutes: maxAgeMinutes) && isMovable && !isVesselAtRest + } + + var isMovable: Bool { + switch objectClass { + case .vessel, .vesselSport, .vesselFast, .vesselPassenger, .vesselFreight, .vesselCommercial, .vesselAuthorities, .vesselSar, .vesselOther, .airplane: + return true + case .invalid: + return sog != AisObjectConstants.invalidSog && sog > 0 + default: + return false + } + } + + var isVesselAtRest: Bool { + switch objectClass { + case .vessel, .vesselSport, .vesselFast, .vesselPassenger, .vesselFreight, .vesselCommercial, .vesselAuthorities, .vesselSar, .vesselOther: + if navStatus == 5 { + return cog == AisObjectConstants.invalidCog || sog < 0.2 + } + return (msgTypes.contains(18) || msgTypes.contains(24) || msgTypes.contains(1) || msgTypes.contains(3)) + && cog == AisObjectConstants.invalidCog && sog < 0.2 + default: + return false + } + } + + var vesselRotation: Double { + if cog != AisObjectConstants.invalidCog { return cog } + if heading != AisObjectConstants.invalidHeading { return Double(heading) } + return 0 + } + + var shipTypeString: String { + switch shipType { + case AisObjectConstants.invalidShipType: return localizedString("ais_unknown") + case 20: return localizedString("ais_ship_type_wig") + case 21: return localizedString("ais_ship_type_wig_hazard_a") + case 22: return localizedString("ais_ship_type_wig_hazard_b") + case 23: return localizedString("ais_ship_type_wig_hazard_c") + case 24: return localizedString("ais_ship_type_wig_hazard_d") + case 30: return localizedString("ais_ship_type_fishing") + case 31, 32: return localizedString("ais_ship_type_towing") + case 33: return localizedString("ais_ship_type_dredging") + case 34: return localizedString("ais_ship_type_diving_ops") + case 35: return localizedString("ais_ship_type_military_ops") + case 36: return localizedString("ais_ship_type_sailing") + case 37: return localizedString("ais_ship_type_pleasure_craft") + case 40: return localizedString("ais_ship_type_hsc") + case 41: return localizedString("ais_ship_type_hsc_hazard_a") + case 42: return localizedString("ais_ship_type_hsc_hazard_b") + case 43: return localizedString("ais_ship_type_hsc_hazard_c") + case 44: return localizedString("ais_ship_type_hsc_hazard_d") + case 49: return localizedString("ais_ship_type_hsc") + case 50: return localizedString("ais_ship_type_pilot_vessel") + case 51: return localizedString("ais_ship_type_search_and_rescue") + case 52: return localizedString("ais_ship_type_tug") + case 53: return localizedString("ais_ship_type_port_tender") + case 54: return localizedString("ais_ship_type_antipollution") + case 55: return localizedString("ais_ship_type_law_enforcement") + case 56, 57: return localizedString("ais_ship_type_spare_local_vessel") + case 58: return localizedString("ais_ship_type_medical_transport") + case 59: return localizedString("ais_ship_type_noncombatant") + case 60: return localizedString("ais_ship_type_passenger") + case 61: return localizedString("ais_ship_type_passenger_hazard_a") + case 62: return localizedString("ais_ship_type_passenger_hazard_b") + case 63: return localizedString("ais_ship_type_passenger_hazard_c") + case 64: return localizedString("ais_ship_type_passenger_hazard_d") + case 69: return localizedString("ais_ship_type_passenger_cruise_ferry") + case 70: return localizedString("ais_ship_type_cargo") + case 71: return localizedString("ais_ship_type_cargo_hazard_a") + case 72: return localizedString("ais_ship_type_cargo_hazard_b") + case 73: return localizedString("ais_ship_type_cargo_hazard_c") + case 74: return localizedString("ais_ship_type_cargo_hazard_d") + case 79: return localizedString("ais_ship_type_cargo") + case 80: return localizedString("ais_ship_type_tanker") + case 81: return localizedString("ais_ship_type_tanker_hazard_a") + case 82: return localizedString("ais_ship_type_tanker_hazard_b") + case 83: return localizedString("ais_ship_type_tanker_hazard_c") + case 84: return localizedString("ais_ship_type_tanker_hazard_d") + case 89: return localizedString("ais_ship_type_tanker") + case 90: return localizedString("ais_ship_type_other") + case 91: return localizedString("ais_ship_type_other_hazard_a") + case 92: return localizedString("ais_ship_type_other_hazard_b") + case 93: return localizedString("ais_ship_type_other_hazard_c") + case 94: return localizedString("ais_ship_type_other_hazard_d") + case 99: return localizedString("ais_ship_type_other") + default: return "\(shipType)" + } + } + + var navStatusString: String { + switch navStatus { + case 0: return localizedString("ais_nav_status_under_way_engine") + case 1: return localizedString("ais_nav_status_at_anchor") + case 2: return localizedString("ais_nav_status_not_under_command") + case 3: return localizedString("ais_nav_status_restricted_maneuverability") + case 4: return localizedString("ais_nav_status_constrained_draught") + case 5: return localizedString("ais_nav_status_moored") + case 6: return localizedString("ais_nav_status_aground") + case 7: return localizedString("ais_nav_status_engaged_fishing") + case 8: return localizedString("ais_nav_status_under_way_sailing") + case 11: return localizedString("ais_nav_status_towing_astern") + case 12: return localizedString("ais_nav_status_pushing_or_towing") + case 14: return localizedString("ais_nav_status_sart_active") + case AisObjectConstants.invalidNavStatus: return localizedString("ais_unknown") + default: return "\(navStatus)" + } + } + + var maneuverIndicatorString: String { + switch maneuverIndicator { + case 0: return localizedString("shared_string_not_available") + case 1: return localizedString("ais_maneuver_no_special") + case 2: return localizedString("ais_maneuver_special") + default: return "\(maneuverIndicator)" + } + } + + var aidTypeString: String { + switch aidType { + case 0: return localizedString("ais_not_specified") + case 1: return localizedString("ais_aid_reference_point") + case 2: return localizedString("ais_aid_racon") + case 3: return localizedString("ais_aid_fixed_structure_off_shore") + case 5: return localizedString("ais_aid_light_without_sectors") + case 6: return localizedString("ais_aid_light_with_sectors") + case 7: return localizedString("ais_aid_leading_light_front") + case 8: return localizedString("ais_aid_leading_light_rear") + case 9: return localizedString("ais_aid_beacon_cardinal_n") + case 10: return localizedString("ais_aid_beacon_cardinal_e") + case 11: return localizedString("ais_aid_beacon_cardinal_s") + case 12: return localizedString("ais_aid_beacon_cardinal_w") + case 13: return localizedString("ais_aid_beacon_port_hand") + case 14: return localizedString("ais_aid_beacon_starboard_hand") + case 17: return localizedString("ais_aid_beacon_isolated_danger") + case 18: return localizedString("ais_aid_beacon_safe_water") + case 19: return localizedString("ais_aid_beacon_special_mark") + case 20: return localizedString("ais_aid_cardinal_mark_n") + case 21: return localizedString("ais_aid_cardinal_mark_e") + case 22: return localizedString("ais_aid_cardinal_mark_s") + case 23: return localizedString("ais_aid_cardinal_mark_w") + case 24: return localizedString("ais_aid_port_hand_mark") + case 25: return localizedString("ais_aid_starboard_hand_mark") + case 28: return localizedString("ais_aid_isolated_danger") + case 29: return localizedString("ais_aid_safe_water") + case 30: return localizedString("ais_aid_special_mark") + case 31: return localizedString("ais_aid_light_vessel_lanby_rigs") + default: return "\(aidType)" + } + } + + func applyPosition(timestamp: Int, navStatus: Int, maneuverIndicator: Int, heading: Int, cog: Double, sog: Double, lat: Double, lon: Double, rot: Double) { + self.timestamp = timestamp + self.navStatus = navStatus + self.maneuverIndicator = maneuverIndicator + self.heading = heading + self.cog = cog + self.sog = sog + self.latitude = lat + self.longitude = lon + self.rot = rot + updateObjectClass() + } + + func applyBaseStation(lat: Double, lon: Double) { + latitude = lat + longitude = lon + updateObjectClass() + } + + func applyStatic(imo: Int, callSign: String?, shipName: String?, shipType: Int, bow: Int, stern: Int, port: Int, starboard: Int, draught: Double, destination: String?, etaMonth: Int, etaDay: Int, etaHour: Int, etaMinute: Int) { + self.imo = imo + self.callSign = callSign + self.shipName = shipName + self.shipType = shipType + dimensionToBow = bow + dimensionToStern = stern + dimensionToPort = port + dimensionToStarboard = starboard + self.draught = draught + if let destination, !destination.allSatisfy({ $0 == "@" }) { + self.destination = destination + } + self.etaMonth = etaMonth + self.etaDay = etaDay + self.etaHour = etaHour + self.etaMinute = etaMinute + updateObjectClass() + } + + func applyAircraft(timestamp: Int, altitude: Int, cog: Double, sog: Double, lat: Double, lon: Double) { + self.timestamp = timestamp + self.altitude = altitude + self.cog = cog + self.sog = sog + latitude = lat + longitude = lon + updateObjectClass() + } + + func applyAton(lat: Double, lon: Double, aidType: Int, bow: Int, stern: Int, port: Int, starboard: Int) { + latitude = lat + longitude = lon + self.aidType = aidType + dimensionToBow = bow + dimensionToStern = stern + dimensionToPort = port + dimensionToStarboard = starboard + updateObjectClass() + } + + private func updateObjectClass() { + switch shipType { + case 20...24, 40...44, 49: + objectClass = .vesselFast + case 30...34, 50, 52...54, 56, 57, 59: + objectClass = .vesselCommercial + case 35, 55: + objectClass = .vesselAuthorities + case 51, 58: + objectClass = .vesselSar + case 36, 37: + objectClass = .vesselSport + case 60...64, 69: + objectClass = .vesselPassenger + case 70...74, 79, 80...84, 89: + objectClass = .vesselFreight + case 90...94, 99: + objectClass = .vesselOther + default: + if msgTypes.contains(9) { + objectClass = .airplane + } else if msgTypes.contains(4) { + objectClass = .landStation + } else if msgTypes.contains(21) { + objectClass = (aidType == 29 || aidType == 30) ? .atonVirtual : .aton + } else if msgTypes.contains(18) { + objectClass = .vessel + } else { + switch navStatus { + case 0...6, 8, 11, 12: + objectClass = .vessel + case 7: + objectClass = .vesselCommercial + case 14: + objectClass = .sart + default: + objectClass = .invalid + } + } + } + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift new file mode 100644 index 0000000000..dd05816a6c --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift @@ -0,0 +1,204 @@ +import CoreLocation +import Foundation + +final class AisMessageSimulationListener { + private weak var plugin: OAAisTrackerPlugin? + private let fileURL: URL + private let latency: TimeInterval + private let queue = DispatchQueue(label: "net.osmand.ais.simulation.listener") + private var cancelled = false + + init(plugin: OAAisTrackerPlugin, fileURL: URL, latency: TimeInterval) { + self.plugin = plugin + self.fileURL = fileURL + self.latency = latency + } + + func start() { + cancelled = false + queue.async { [weak self] in + guard let self else { return } + let hasSecurityScopedAccess = self.fileURL.startAccessingSecurityScopedResource() + defer { + if hasSecurityScopedAccess { + self.fileURL.stopAccessingSecurityScopedResource() + } + } + guard let text = try? String(contentsOf: self.fileURL, encoding: .utf8) else { + self.postStatus(sentences: 0, decoded: 0, objects: 0, error: "Failed to read AIS simulation file") + return + } + let sentences = text.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let stats = self.collectStats(sentences: sentences) + self.postStatus(sentences: stats.sentences, decoded: stats.decoded, objects: stats.objects, error: nil) + for sentence in sentences { + if self.cancelled { + return + } + Thread.sleep(forTimeInterval: self.latency) + DispatchQueue.main.async { [weak self] in + self?.plugin?.handleSimulatedNmeaSentence(sentence) + } + } + } + } + + func stop() { + queue.async { [weak self] in + self?.cancelled = true + } + } + + private func collectStats(sentences: [String]) -> (sentences: Int, decoded: Int, objects: Int) { + let decoder = AisMessageDecoder() + var decoded = 0 + var mmsi = Set() + for sentence in sentences { + guard let object = decoder.decode(sentence: sentence), object.hasPosition else { + continue + } + decoded += 1 + mmsi.insert(object.mmsi) + } + return (sentences.count, decoded, mmsi.count) + } + + private func postStatus(sentences: Int, decoded: Int, objects: Int, error: String?) { + DispatchQueue.main.async { + self.plugin?.updateSimulationStatus(sentences: sentences, decoded: decoded, objects: objects, error: error) + var userInfo: [String: Any] = [ + "sentences": sentences, + "decoded": decoded, + "objects": objects + ] + if let error { + userInfo["error"] = error + } + NotificationCenter.default.post(name: .aisSimulationStatusChanged, + object: self.plugin, + userInfo: userInfo) + } + } +} + +@objcMembers +final class AisSimulationProvider: NSObject { + private static let simulatedLatency: TimeInterval = 0.1 + + private weak var plugin: OAAisTrackerPlugin? + private var listener: AisMessageSimulationListener? + + init(plugin: OAAisTrackerPlugin) { + self.plugin = plugin + super.init() + } + + func startAisSimulation(_ fileURL: URL) { + stopAisSimulation() + guard let plugin else { return } + plugin.prepareAisSimulation() + let listener = AisMessageSimulationListener(plugin: plugin, + fileURL: fileURL, + latency: Self.simulatedLatency) + self.listener = listener + listener.start() + } + + func stopAisSimulation() { + listener?.stop() + listener = nil + } + + func initFakePosition() { + let fake = CLLocation(coordinate: CLLocationCoordinate2D(latitude: 50.76077, longitude: 7.08747), + altitude: 0, + horizontalAccuracy: 5, + verticalAccuracy: -1, + course: 340, + speed: 3.0 * 0.514444, + timestamp: Date()) + plugin?.fakeOwnPosition(fake) + plugin?.handleSimulatedLocation(fake) + let position = AisObject(mmsi: 324578, msgType: 18) + position.applyPosition(timestamp: 20, + navStatus: AisObjectConstants.invalidNavStatus, + maneuverIndicator: AisObjectConstants.invalidManeuverIndicator, + heading: 340, + cog: 340, + sog: 3, + lat: 50.76077, + lon: 7.08747, + rot: AisObjectConstants.invalidRot) + plugin?.handleSimulatedAisObject(position) + + let data = AisObject(mmsi: 324578, msgType: 24) + data.applyStatic(imo: 0, + callSign: "callsign", + shipName: "fake", + shipType: 60, + bow: 56, + stern: 65, + port: 8, + starboard: 12, + draught: AisObjectConstants.invalidDraught, + destination: "home", + etaMonth: AisObjectConstants.invalidEta, + etaDay: AisObjectConstants.invalidEta, + etaHour: AisObjectConstants.invalidEtaHour, + etaMinute: AisObjectConstants.invalidEtaMin) + plugin?.handleSimulatedAisObject(data) + } + + func initTestPassengerShip() { + let position = AisObject(mmsi: 34568, msgType: 1) + position.applyPosition(timestamp: 20, navStatus: 0, maneuverIndicator: 1, heading: 320, cog: 320, sog: 8.4, lat: 50.738, lon: 7.099, rot: 0) + plugin?.handleSimulatedAisObject(position) + let data = AisObject(mmsi: 34568, msgType: 5) + data.applyStatic(imo: 0, callSign: "TEST-CALLSIGN1", shipName: "TEST-Ship", shipType: 60, bow: 56, stern: 65, port: 8, starboard: 12, draught: 2, destination: "Potsdam", etaMonth: 8, etaDay: 15, etaHour: 22, etaMinute: 5) + plugin?.handleSimulatedAisObject(data) + } + + func initTestSailingBoat() { + let position = AisObject(mmsi: 454011, msgType: 18) + position.applyPosition(timestamp: 20, + navStatus: AisObjectConstants.invalidNavStatus, + maneuverIndicator: AisObjectConstants.invalidManeuverIndicator, + heading: 125, + cog: 125, + sog: 4.4, + lat: 50.737, + lon: 7.098, + rot: AisObjectConstants.invalidRot) + plugin?.handleSimulatedAisObject(position) + let data = AisObject(mmsi: 454011, msgType: 24) + data.applyStatic(imo: 0, callSign: "TEST-CALLSIGN2", shipName: "TEST-Sailor", shipType: 36, bow: 0, stern: 0, port: 0, starboard: 0, draught: AisObjectConstants.invalidDraught, destination: "home", etaMonth: AisObjectConstants.invalidEta, etaDay: AisObjectConstants.invalidEta, etaHour: AisObjectConstants.invalidEtaHour, etaMinute: AisObjectConstants.invalidEtaMin) + plugin?.handleSimulatedAisObject(data) + } + + func initTestLandStation() { + let station = AisObject(mmsi: 878121, msgType: 4) + station.applyBaseStation(lat: 50.736, lon: 7.100) + plugin?.handleSimulatedAisObject(station) + + let aid = AisObject(mmsi: 521077, msgType: 21) + aid.applyAton(lat: 50.735, lon: 7.101, aidType: 1, bow: 0, stern: 0, port: 0, starboard: 0) + plugin?.handleSimulatedAisObject(aid) + } + + func initTestAircraft() { + let aircraft = AisObject(mmsi: 910323, msgType: 9) + aircraft.applyAircraft(timestamp: 15, altitude: 65, cog: 180.5, sog: 55.0, lat: 50.734, lon: 7.102) + plugin?.handleSimulatedAisObject(aircraft) + } + + func initTestLawEnforcement() { + let position = AisObject(mmsi: 34569, msgType: 1) + position.applyPosition(timestamp: 20, navStatus: 5, maneuverIndicator: 1, heading: 15, cog: 25, sog: 8.4, lat: 50.739, lon: 7.0931, rot: 0) + plugin?.handleSimulatedAisObject(position) + let data = AisObject(mmsi: 34569, msgType: 5) + data.applyStatic(imo: 0, callSign: "TEST-CALLSIGN3", shipName: "Mecklenburg Vorpommern", shipType: 55, bow: 26, stern: 5, port: 8, starboard: 4, draught: 1, destination: "Potsdam", etaMonth: 8, etaDay: 15, etaHour: 22, etaMinute: 5) + plugin?.handleSimulatedAisObject(data) + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift new file mode 100644 index 0000000000..55d2f9fc52 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift @@ -0,0 +1,223 @@ +import CoreLocation +import Foundation + +@objcMembers +final class AisCpa: NSObject { + private(set) var tcpa = AisObjectConstants.invalidTcpa + private(set) var cpaDistance = AisObjectConstants.invalidCpa + private(set) var cpaPosition1: CLLocation? + private(set) var cpaPosition2: CLLocation? + private(set) var crossingTime1 = 0.0 + private(set) var crossingTime2 = 0.0 + private(set) var valid = false + + func reset() { + tcpa = AisObjectConstants.invalidTcpa + cpaDistance = AisObjectConstants.invalidCpa + cpaPosition1 = nil + cpaPosition2 = nil + crossingTime1 = 0 + crossingTime2 = 0 + valid = false + } + + fileprivate func update(tcpa: Double, + cpaDistance: Float, + cpaPosition1: CLLocation?, + cpaPosition2: CLLocation?, + crossingTimes: (Double, Double)?) { + self.tcpa = tcpa + self.cpaDistance = cpaDistance + self.cpaPosition1 = cpaPosition1 + self.cpaPosition2 = cpaPosition2 + if let crossingTimes { + crossingTime1 = crossingTimes.0 + crossingTime2 = crossingTimes.1 + } + valid = cpaDistance != AisObjectConstants.invalidCpa + } +} + +enum AisTrackerHelper { + private struct Vector { + let x: Double + let y: Double + + func sub(_ other: Vector) -> Vector { + Vector(x: x - other.x, y: y - other.y) + } + + func dot(_ other: Vector) -> Double { + x * other.x + y * other.y + } + } + + private static var lastCorrectionUpdate = Date.distantPast + private static var correctionFactor = 1.0 + private static let maxCorrectionUpdateAge: TimeInterval = 60 * 60 + + static func knotsToMeterPerSecond(_ speed: Float) -> Float { + speed * 1852 / 3600 + } + + static func meterPerSecondToKnots(_ speed: Float) -> Float { + speed * 3600 / 1852 + } + + static func meterToMiles(_ distance: Float) -> Float { + distance / 1852 + } + + static func getTcpa(_ ownLocation: CLLocation, _ otherLocation: CLLocation) -> Double { + getTcpa(ownLocation, otherLocation, lonCorrection: getLonCorrection(ownLocation)) + } + + static func getCpa1(_ ownLocation: CLLocation, _ otherLocation: CLLocation) -> CLLocation? { + getCpa(ownLocation, otherLocation, useFirstAsReference: true) + } + + static func getCpa2(_ ownLocation: CLLocation, _ otherLocation: CLLocation) -> CLLocation? { + getCpa(ownLocation, otherLocation, useFirstAsReference: false) + } + + static func getCpaDistance(_ ownLocation: CLLocation, _ otherLocation: CLLocation) -> Float { + guard let cpa1 = getCpa1(ownLocation, otherLocation), + let cpa2 = getCpa2(ownLocation, otherLocation) else { + return AisObjectConstants.invalidCpa + } + return meterToMiles(Float(cpa1.distance(from: cpa2))) + } + + static func getCpa(_ ownLocation: CLLocation, _ otherLocation: CLLocation, result: AisCpa) { + result.reset() + guard !missingSpeedOrCourse(ownLocation, otherLocation) else { return } + let tcpa = getTcpa(ownLocation, otherLocation) + guard tcpa != AisObjectConstants.invalidTcpa else { return } + let cpa1 = newPosition(from: ownLocation, ageHours: tcpa) + let cpa2 = newPosition(from: otherLocation, ageHours: tcpa) + let cpaDistance: Float + if let cpa1, let cpa2 { + cpaDistance = meterToMiles(Float(cpa1.distance(from: cpa2))) + } else { + cpaDistance = AisObjectConstants.invalidCpa + } + result.update(tcpa: tcpa, + cpaDistance: cpaDistance, + cpaPosition1: cpa1, + cpaPosition2: cpa2, + crossingTimes: getCrossingTimes(ownLocation, otherLocation)) + } + + static func newPosition(from location: CLLocation?, ageHours: Double) -> CLLocation? { + guard let location, location.course >= 0, location.speed >= 0 else { return nil } + let distance = location.speed * ageHours * 3600.0 + let bearing = bearingInRad(location.course) + let angularDistance = distance / 6_371_000.0 + let lat1 = location.coordinate.latitude * .pi / 180.0 + let lon1 = location.coordinate.longitude * .pi / 180.0 + let lat2 = asin(sin(lat1) * cos(angularDistance) + cos(lat1) * sin(angularDistance) * cos(bearing)) + let lon2 = lon1 + atan2(sin(bearing) * sin(angularDistance) * cos(lat1), + cos(angularDistance) - sin(lat1) * sin(lat2)) + return CLLocation(coordinate: CLLocationCoordinate2D(latitude: lat2 * 180.0 / .pi, + longitude: lon2 * 180.0 / .pi), + altitude: location.altitude, + horizontalAccuracy: location.horizontalAccuracy, + verticalAccuracy: location.verticalAccuracy, + course: location.course, + speed: location.speed, + timestamp: Date()) + } + + private static func getCpa(_ ownLocation: CLLocation, + _ otherLocation: CLLocation, + useFirstAsReference: Bool) -> CLLocation? { + guard !missingSpeedOrCourse(ownLocation, otherLocation) else { return nil } + let tcpa = getTcpa(ownLocation, otherLocation) + guard tcpa != AisObjectConstants.invalidTcpa else { return nil } + return newPosition(from: useFirstAsReference ? ownLocation : otherLocation, ageHours: tcpa) + } + + private static func getTcpa(_ x: CLLocation, _ y: CLLocation, lonCorrection: Double) -> Double { + guard !missingSpeedOrCourse(x, y) else { return AisObjectConstants.invalidTcpa } + return getTcpa(locationToVector(x), + locationToVector(y), + courseToVector(cog: x.course, sog: Double(meterPerSecondToKnots(Float(x.speed)))), + courseToVector(cog: y.course, sog: Double(meterPerSecondToKnots(Float(y.speed)))), + lonCorrection: lonCorrection) + } + + private static func getTcpa(_ x: Vector, + _ y: Vector, + _ vx: Vector, + _ vy: Vector, + lonCorrection: Double) -> Double { + let dx = y.sub(x) + let dv = vy.sub(vx) + let divisor = dv.dot(dv) + guard abs(divisor) >= 1.0E-10, lonCorrection >= 1.0E-10 else { + return AisObjectConstants.invalidTcpa + } + return -(((dx.x * dv.x / lonCorrection) + (dx.y * dv.y)) / divisor) + } + + private static func getCrossingTimes(_ x: CLLocation, _ y: CLLocation) -> (Double, Double)? { + let lonCorrection = getLonCorrection(x) + let vX = locationToVector(x, lonCorrection: lonCorrection) + let vY = locationToVector(y, lonCorrection: lonCorrection) + let vVX = courseToVector(cog: x.course, sog: Double(meterPerSecondToKnots(Float(x.speed)))) + let vVY = courseToVector(cog: y.course, sog: Double(meterPerSecondToKnots(Float(y.speed)))) + let vDXY = vX.sub(vY) + let divisor = vVX.x * vVY.y - vVX.y * vVY.x + guard abs(divisor) >= 1.0E-10, lonCorrection >= 1.0E-10 else { return nil } + return ((vVY.x * vDXY.y - vVY.y * vDXY.x) / divisor, + (vVX.x * vDXY.y - vVX.y * vDXY.x) / divisor) + } + + private static func bearingInRad(_ bearingInDegrees: Double) -> Double { + var result = bearingInDegrees * 2 * .pi / 360.0 + while result >= .pi { result -= 2 * .pi } + return result + } + + private static func calculateLonCorrection(_ location: CLLocation?) -> Double { + guard let location else { return 1.0 } + let east = CLLocation(coordinate: location.coordinate, + altitude: location.altitude, + horizontalAccuracy: location.horizontalAccuracy, + verticalAccuracy: location.verticalAccuracy, + course: 90, + speed: CLLocationSpeed(knotsToMeterPerSecond(1)), + timestamp: location.timestamp) + guard let afterHour = newPosition(from: east, ageHours: 1.0) else { return 1.0 } + return (afterHour.coordinate.longitude - east.coordinate.longitude) * 60.0 + } + + private static func getLonCorrection(_ location: CLLocation?) -> Double { + if Date().timeIntervalSince(lastCorrectionUpdate) > maxCorrectionUpdateAge { + correctionFactor = calculateLonCorrection(location) + lastCorrectionUpdate = Date() + } + return correctionFactor + } + + private static func courseToVector(cog: Double, sog: Double) -> Vector { + var alpha = 450.0 - cog + while alpha < 0 { alpha += 360.0 } + while alpha >= 360.0 { alpha -= 360.0 } + alpha = alpha * .pi / 180.0 + return Vector(x: cos(alpha) * sog, y: sin(alpha) * sog) + } + + private static func locationToVector(_ location: CLLocation) -> Vector { + Vector(x: location.coordinate.longitude * 60.0, y: location.coordinate.latitude * 60.0) + } + + private static func locationToVector(_ location: CLLocation, lonCorrection: Double) -> Vector { + Vector(x: location.coordinate.longitude * 60.0 / lonCorrection, + y: location.coordinate.latitude * 60.0) + } + + private static func missingSpeedOrCourse(_ x: CLLocation, _ y: CLLocation) -> Bool { + x.course < 0 || y.course < 0 || x.speed < 0 || y.speed < 0 + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift new file mode 100644 index 0000000000..7a599d8799 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift @@ -0,0 +1,349 @@ +import UIKit + +@objcMembers +final class AisTrackerSettingsViewController: OABaseNavbarViewController { + private enum Section: Int, CaseIterable { + case address + case timeouts + case cpa + + var titleKey: String { + switch self { + case .address: "ais_address_settings" + case .timeouts: "ais_object_lost_timeouts" + case .cpa: "ais_cpa_settings" + } + } + + var rows: [Row] { + switch self { + case .address: [.protocolType, .host, .tcpPort, .udpPort] + case .timeouts: [.shipLostTimeout, .objectLostTimeout] + case .cpa: [.cpaWarningTime, .cpaWarningDistance] + } + } + } + + private enum Row { + case protocolType + case host + case tcpPort + case udpPort + case shipLostTimeout + case objectLostTimeout + case cpaWarningTime + case cpaWarningDistance + } + + private let plugin: OAAisTrackerPlugin + private let objectLostTimeoutValues = [3, 5, 7, 10, 12, 15, 20] + private let shipLostTimeoutValues = [2, 3, 4, 5, 7, 10, 15, 100] + private let cpaWarningTimeValues = [0, 1, 5, 10, 20, 30, 60] + private let cpaWarningDistanceValues = [0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0] + + init(plugin: OAAisTrackerPlugin) { + self.plugin = plugin + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + NotificationCenter.default.addObserver(self, selector: #selector(reloadStatus), name: .aisNmeaConnectionStateChanged, object: plugin) + NotificationCenter.default.addObserver(self, selector: #selector(reloadStatus), name: .aisNmeaLocationReceived, object: plugin) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func getTitle() -> String { + localizedString("plugin_ais_tracker_name") + } + + override func tableStyle() -> UITableView.Style { + .insetGrouped + } + + override func sectionsCount() -> Int { + Section.allCases.count + } + + override func rowsCount(_ section: Int) -> Int { + sectionData(section)?.rows.count ?? 0 + } + + override func getTitleForHeader(_ section: Int) -> String { + guard let section = sectionData(section) else { return "" } + return localizedString(section.titleKey) + } + + override func getRow(_ indexPath: IndexPath) -> UITableViewCell? { + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + guard let row = rowData(indexPath) else { return cell } + let enabled = isRowEnabled(row) + cell.isUserInteractionEnabled = enabled + cell.selectionStyle = enabled ? .default : .none + cell.textLabel?.numberOfLines = 0 + cell.detailTextLabel?.numberOfLines = 0 + cell.textLabel?.textColor = enabled ? .label : .secondaryLabel + cell.detailTextLabel?.textColor = .secondaryLabel + + switch row { + case .protocolType: + cell.textLabel?.text = localizedString("ais_nmea_protocol") + cell.detailTextLabel?.text = protocolText() + case .host: + cell.textLabel?.text = localizedString("ais_address_nmea_server") + cell.detailTextLabel?.text = plugin.hostPref.get() + case .tcpPort: + cell.textLabel?.text = localizedString("ais_port_nmea_server") + cell.detailTextLabel?.text = "\(plugin.tcpPortPref.get())" + case .udpPort: + cell.textLabel?.text = localizedString("ais_port_nmea_local") + cell.detailTextLabel?.text = "\(plugin.udpPortPref.get())" + case .objectLostTimeout: + cell.textLabel?.text = localizedString("ais_object_lost_timeout") + cell.detailTextLabel?.text = minutesText(Int(plugin.objectLostTimeoutPref.get())) + case .shipLostTimeout: + cell.textLabel?.text = localizedString("ais_ship_lost_timeout") + cell.detailTextLabel?.text = shipLostTimeoutText(Int(plugin.shipLostTimeoutPref.get())) + case .cpaWarningTime: + cell.textLabel?.text = localizedString("ais_cpa_warning_time") + cell.detailTextLabel?.text = cpaWarningTimeText(Int(plugin.cpaWarningTimePref.get())) + case .cpaWarningDistance: + cell.textLabel?.text = localizedString("ais_cpa_warning_distance") + cell.detailTextLabel?.text = nauticalMilesText(plugin.cpaWarningDistancePref.get()) + } + return cell + } + + override func onRowSelected(_ indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard let row = rowData(indexPath), isRowEnabled(row) else { return } + switch row { + case .protocolType: + chooseProtocol() + case .host: + editString(title: localizedString("ais_address_nmea_server"), message: descriptionText(for: row), value: plugin.hostPref.get()) { [weak self] value in + guard self?.isValidIPv4(value) == true else { + self?.showValidationError(localizedString("ais_error_ipv4_only")) + return + } + self?.plugin.hostPref.set(value) + self?.plugin.restartConnection() + } + case .tcpPort: + editPort(title: localizedString("ais_port_nmea_server"), message: descriptionText(for: row), value: Int(plugin.tcpPortPref.get())) { [weak self] value in + self?.plugin.tcpPortPref.set(Int32(value)) + self?.plugin.restartConnection() + } + case .udpPort: + editPort(title: localizedString("ais_port_nmea_local"), message: descriptionText(for: row), value: Int(plugin.udpPortPref.get())) { [weak self] value in + self?.plugin.udpPortPref.set(Int32(value)) + self?.plugin.restartConnection() + } + case .objectLostTimeout: + chooseIntValue(title: localizedString("ais_object_lost_timeout"), + message: descriptionText(for: row), + values: objectLostTimeoutValues, + current: Int(plugin.objectLostTimeoutPref.get()), + titleProvider: minutesText) { [weak self] value in + self?.plugin.objectLostTimeoutPref.set(Int32(value)) + self?.tableView.reloadData() + } + case .shipLostTimeout: + chooseIntValue(title: localizedString("ais_ship_lost_timeout"), + message: descriptionText(for: row), + values: shipLostTimeoutValues, + current: Int(plugin.shipLostTimeoutPref.get()), + titleProvider: shipLostTimeoutText) { [weak self] value in + self?.plugin.shipLostTimeoutPref.set(Int32(value)) + self?.tableView.reloadData() + } + case .cpaWarningTime: + chooseIntValue(title: localizedString("ais_cpa_warning_time"), + message: descriptionText(for: row), + values: cpaWarningTimeValues, + current: Int(plugin.cpaWarningTimePref.get()), + titleProvider: cpaWarningTimeText) { [weak self] value in + self?.plugin.cpaWarningTimePref.set(Int32(value)) + self?.tableView.reloadData() + } + case .cpaWarningDistance: + chooseDoubleValue(title: localizedString("ais_cpa_warning_distance"), + message: descriptionText(for: row), + values: cpaWarningDistanceValues, + current: plugin.cpaWarningDistancePref.get(), + titleProvider: nauticalMilesText) { [weak self] value in + self?.plugin.cpaWarningDistancePref.set(value) + self?.tableView.reloadData() + } + } + } + + private func chooseProtocol() { + let alert = UIAlertController(title: localizedString("ais_nmea_protocol"), message: descriptionText(for: .protocolType), preferredStyle: .actionSheet) + alert.addAction(UIAlertAction(title: "UDP", style: .default) { [weak self] _ in + self?.plugin.protocolPref.set(Int32(AisNmeaProtocol.udp.rawValue)) + self?.plugin.restartConnection() + self?.tableView.reloadData() + }) + alert.addAction(UIAlertAction(title: "TCP", style: .default) { [weak self] _ in + self?.plugin.protocolPref.set(Int32(AisNmeaProtocol.tcp.rawValue)) + self?.plugin.restartConnection() + self?.tableView.reloadData() + }) + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + present(alert, animated: true) + } + + private func editString(title: String, message: String?, value: String, onSave: @escaping (String) -> Void) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addTextField { textField in + textField.text = value + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + } + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + alert.addAction(UIAlertAction(title: localizedString("shared_string_save"), style: .default) { [weak alert, weak self] _ in + onSave(alert?.textFields?.first?.text ?? value) + self?.tableView.reloadData() + }) + present(alert, animated: true) + } + + private func editPort(title: String, message: String?, value: Int, onSave: @escaping (Int) -> Void) { + editString(title: title, message: message, value: "\(value)") { text in + if let port = Int(text), port >= 0, port <= 65535 { + onSave(port) + } else { + self.showValidationError(localizedString("ais_error_port_only")) + } + } + } + + private func chooseIntValue(title: String, message: String?, values: [Int], current: Int, titleProvider: @escaping (Int) -> String, onSelect: @escaping (Int) -> Void) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .actionSheet) + for value in values { + alert.addAction(UIAlertAction(title: titleProvider(value), style: .default) { _ in + onSelect(value) + }) + } + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + present(alert, animated: true) + } + + private func chooseDoubleValue(title: String, message: String?, values: [Double], current: Double, titleProvider: @escaping (Double) -> String, onSelect: @escaping (Double) -> Void) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .actionSheet) + for value in values { + alert.addAction(UIAlertAction(title: titleProvider(value), style: .default) { _ in + onSelect(value) + }) + } + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + present(alert, animated: true) + } + + private func descriptionText(for row: Row) -> String? { + let key: String + switch row { + case .protocolType: + key = "ais_nmea_protocol_description" + case .host: + key = "ais_address_nmea_server_description" + case .tcpPort: + key = "ais_port_nmea_server_description" + case .udpPort: + key = "ais_port_nmea_local_description" + case .shipLostTimeout: + key = "ais_ship_lost_timeout_description" + case .objectLostTimeout: + key = "ais_object_lost_timeout_description" + case .cpaWarningTime: + key = "ais_cpa_warning_time_description" + case .cpaWarningDistance: + key = "ais_cpa_warning_distance_description" + } + return localizedString(key) + } + + private func sectionData(_ section: Int) -> Section? { + Section(rawValue: section) + } + + private func rowData(_ indexPath: IndexPath) -> Row? { + guard let section = sectionData(indexPath.section), indexPath.row < section.rows.count else { return nil } + return section.rows[indexPath.row] + } + + private func isTcpSelected() -> Bool { + (AisNmeaProtocol(rawValue: Int(plugin.protocolPref.get())) ?? .udp) == .tcp + } + + private func isRowEnabled(_ row: Row) -> Bool { + switch row { + case .host, .tcpPort: + return isTcpSelected() + case .udpPort: + return !isTcpSelected() + case .cpaWarningDistance: + return plugin.cpaWarningTimePref.get() > 0 + default: + return true + } + } + + private func protocolText() -> String { + isTcpSelected() ? "TCP" : "UDP" + } + + private func minutesText(_ minutes: Int) -> String { + "\(minutes) \(localizedString("shared_string_minute_lowercase"))" + } + + private func shipLostTimeoutText(_ minutes: Int) -> String { + minutes >= 100 ? localizedString("shared_string_disabled") : minutesText(minutes) + } + + private func cpaWarningTimeText(_ minutes: Int) -> String { + minutes == 0 ? localizedString("shared_string_disabled") : minutesText(minutes) + } + + private func nauticalMilesText(_ miles: Double) -> String { + if abs(miles - 1.0) < 0.0001 { + return "1 \(localizedString("ais_nautical_mile"))" + } + let value: String + if ceil(miles) == miles { + value = String(format: "%.0f", miles) + } else if miles < 0.1 { + value = String(format: "%.2f", miles) + } else { + value = String(format: "%.1f", miles) + } + return "\(value) \(localizedString("ais_nautical_miles"))" + } + + private func isValidIPv4(_ value: String) -> Bool { + let parts = value.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return false } + return parts.allSatisfy { part in + guard let number = Int(part), number >= 0, number <= 255 else { return false } + return part.allSatisfy { $0.isNumber } + } + } + + private func showValidationError(_ message: String) { + let alert = UIAlertController(title: localizedString("shared_string_error"), message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: localizedString("shared_string_ok"), style: .default)) + present(alert, animated: true) + } + + @objc private func reloadStatus() { + tableView.reloadData() + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h new file mode 100644 index 0000000000..94ee7510ab --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h @@ -0,0 +1,9 @@ +#import "OATargetInfoViewController.h" + +@class AisObject; + +@interface OAAisObjectViewController : OATargetInfoViewController + +- (instancetype)initWithAisObject:(AisObject *)object; + +@end diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm new file mode 100644 index 0000000000..4a188d9e27 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm @@ -0,0 +1,185 @@ +#import "OAAisObjectViewController.h" +#import "OAAmenityInfoRow.h" +#import "OAPluginsHelper.h" +#import "OAPointDescription.h" +#import "Localization.h" +#import "OsmAnd_Maps-Swift.h" + +@implementation OAAisObjectViewController +{ + AisObject *_object; +} + +- (instancetype)initWithAisObject:(AisObject *)object +{ + self = [super init]; + if (self) + { + _object = object; + self.location = CLLocationCoordinate2DMake(object.latitude, object.longitude); + self.showTitleIfTruncated = NO; + self.customOnlinePhotosPosition = YES; + } + return self; +} + +- (id)getTargetObj +{ + return _object; +} + +- (UIImage *)getIcon +{ + return [UIImage imageNamed:@"ic_plugin_nautical"]; +} + +- (NSString *)getTypeStr +{ + return OALocalizedString(@"plugin_ais_tracker_name"); +} + +- (NSString *)getCommonTypeStr +{ + return [self getTypeStr]; +} + +- (BOOL)needAddress +{ + return NO; +} + +- (BOOL)showDetailsButton +{ + return NO; +} + +- (void)buildDescription:(NSMutableArray *)rows +{ + [self addRow:rows key:@"mmsi" prefix:@"MMSI" text:[NSString stringWithFormat:@"%ld", (long)_object.mmsi] order:0]; + if (_object.imo > 0) + [self addRow:rows key:@"imo" prefix:@"IMO" text:[NSString stringWithFormat:@"%ld", (long)_object.imo] order:1]; + if (_object.shipName.length > 0) + [self addRow:rows key:@"ship_name" prefix:OALocalizedString(@"shared_string_name") text:_object.shipName order:2]; + if (_object.callSign.length > 0) + [self addRow:rows key:@"callsign" prefix:OALocalizedString(@"ais_call_sign") text:_object.callSign order:3]; + [self addRow:rows key:@"object_type" prefix:OALocalizedString(@"ais_object_type") text:[self objectTypeName:_object.objectClass] order:4]; + if (_object.shipType != 0) + [self addRow:rows key:@"ship_type" prefix:OALocalizedString(@"ais_ship_type") text:_object.shipTypeString order:5]; +} + +- (void)buildInternal:(NSMutableArray *)rows +{ + NSInteger order = 100; + OAAisTrackerPlugin *plugin = (OAAisTrackerPlugin *)[OAPluginsHelper getPlugin:OAAisTrackerPlugin.class]; + if (plugin) + [plugin updateCpaFor:_object]; + + if (_object.hasPosition) + { + [self addRow:rows key:@"position" prefix:OALocalizedString(@"ais_position") text:[NSString stringWithFormat:@"%.5f, %.5f", _object.latitude, _object.longitude] order:order++]; + } + if (plugin) + { + double distance = [plugin distanceInNauticalMilesTo:_object]; + if (distance >= 0) + [self addRow:rows key:@"distance" prefix:OALocalizedString(@"shared_string_distance") text:[NSString stringWithFormat:@"%.2f nm", distance] order:order++]; + double bearing = [plugin bearingTo:_object]; + if (bearing >= 0) + [self addRow:rows key:@"bearing" prefix:OALocalizedString(@"shared_string_bearing") text:[NSString stringWithFormat:@"%.0f°", bearing] order:order++]; + } + if (_object.messageTypesString.length > 0) + [self addRow:rows key:@"message_types" prefix:OALocalizedString(@"ais_message_types") text:_object.messageTypesString order:order++]; + if (_object.sog != 1023.0) + [self addRow:rows key:@"sog" prefix:@"SOG" text:[NSString stringWithFormat:@"%.1f kn", _object.sog] order:order++]; + if (_object.cog != 360.0) + [self addRow:rows key:@"cog" prefix:@"COG" text:[NSString stringWithFormat:@"%.0f°", _object.cog] order:order++]; + if (_object.heading != 511) + [self addRow:rows key:@"heading" prefix:OALocalizedString(@"ais_heading") text:[NSString stringWithFormat:@"%ld°", (long)_object.heading] order:order++]; + if (_object.navStatus != 15) + [self addRow:rows key:@"nav_status" prefix:OALocalizedString(@"ais_navigation_status") text:_object.navStatusString order:order++]; + if (_object.maneuverIndicator != 0) + [self addRow:rows key:@"maneuver" prefix:OALocalizedString(@"ais_maneuver") text:_object.maneuverIndicatorString order:order++]; + if (_object.rot != 128.0) + [self addRow:rows key:@"rot" prefix:@"ROT" text:[NSString stringWithFormat:@"%.1f", _object.rot] order:order++]; + if (_object.altitude != 4095) + [self addRow:rows key:@"altitude" prefix:OALocalizedString(@"altitude") text:[NSString stringWithFormat:@"%ld m", (long)_object.altitude] order:order++]; + if (_object.aidType != 0) + [self addRow:rows key:@"aid_type" prefix:OALocalizedString(@"ais_aid_type") text:_object.aidTypeString order:order++]; + + NSInteger length = _object.dimensionToBow + _object.dimensionToStern; + NSInteger width = _object.dimensionToPort + _object.dimensionToStarboard; + if (length > 0 || width > 0) + [self addRow:rows key:@"dimensions" prefix:OALocalizedString(@"ais_dimensions") text:[NSString stringWithFormat:@"%ld x %ld m", (long)length, (long)width] order:order++]; + if (_object.dimensionToBow > 0 || _object.dimensionToStern > 0 || _object.dimensionToPort > 0 || _object.dimensionToStarboard > 0) + [self addRow:rows key:@"antenna" prefix:OALocalizedString(@"ais_antenna") text:[NSString stringWithFormat:OALocalizedString(@"ais_antenna_offsets_format"), (long)_object.dimensionToBow, (long)_object.dimensionToStern, (long)_object.dimensionToPort, (long)_object.dimensionToStarboard] order:order++]; + if (_object.draught > 0) + [self addRow:rows key:@"draught" prefix:OALocalizedString(@"ais_draught") text:[NSString stringWithFormat:@"%.1f m", _object.draught] order:order++]; + if (_object.destination.length > 0) + [self addRow:rows key:@"destination" prefix:OALocalizedString(@"ais_destination") text:_object.destination order:order++]; + if (_object.etaMonth > 0 && _object.etaDay > 0) + [self addRow:rows key:@"eta" prefix:@"ETA" text:[NSString stringWithFormat:@"%02ld/%02ld %02ld:%02ld", (long)_object.etaMonth, (long)_object.etaDay, (long)_object.etaHour, (long)_object.etaMinute] order:order++]; + + [self addRow:rows key:@"last_update" prefix:OALocalizedString(@"ais_last_update") text:[NSDateFormatter localizedStringFromDate:_object.lastUpdate dateStyle:NSDateFormatterNoStyle timeStyle:NSDateFormatterMediumStyle] order:order++]; + if (_object.cpa.valid) + { + [self addRow:rows key:@"cpa" prefix:@"CPA" text:[NSString stringWithFormat:@"%.2f nm", _object.cpa.cpaDistance] order:order++]; + [self addRow:rows key:@"tcpa" prefix:@"TCPA" text:[self formatTcpa:_object.cpa.tcpa] order:order++]; + } +} + +- (BOOL)needBuildCoordinatesRow +{ + return YES; +} + +- (void)addRow:(NSMutableArray *)rows key:(NSString *)key prefix:(NSString *)prefix text:(NSString *)text order:(NSInteger)order +{ + if (text.length == 0) + return; + OAAmenityInfoRow *row = [[OAAmenityInfoRow alloc] initWithKey:key + icon:nil + textPrefix:prefix + text:text + textColor:nil + isText:YES + needLinks:NO + order:order + typeName:key + isPhoneNumber:NO + isUrl:NO]; + [rows addObject:row]; +} + +- (NSString *)objectTypeName:(AisObjType)type +{ + switch (type) + { + case AisObjTypeVessel: return OALocalizedString(@"ais_type_vessel"); + case AisObjTypeVesselSport: return OALocalizedString(@"ais_type_sport_vessel"); + case AisObjTypeVesselFast: return OALocalizedString(@"ais_type_high_speed_vessel"); + case AisObjTypeVesselPassenger: return OALocalizedString(@"ais_type_passenger_vessel"); + case AisObjTypeVesselFreight: return OALocalizedString(@"ais_type_cargo_tanker"); + case AisObjTypeVesselCommercial: return OALocalizedString(@"ais_type_commercial_vessel"); + case AisObjTypeVesselAuthorities: return OALocalizedString(@"ais_type_authorities_vessel"); + case AisObjTypeVesselSar: return OALocalizedString(@"ais_type_sar_vessel"); + case AisObjTypeLandStation: return OALocalizedString(@"ais_type_base_station"); + case AisObjTypeAirplane: return OALocalizedString(@"ais_type_sar_aircraft"); + case AisObjTypeSart: return OALocalizedString(@"ais_type_sart"); + case AisObjTypeAton: return OALocalizedString(@"ais_type_aid_to_navigation"); + case AisObjTypeAtonVirtual: return OALocalizedString(@"ais_type_virtual_aid_to_navigation"); + case AisObjTypeVesselOther: return OALocalizedString(@"ais_type_other_vessel"); + default: return OALocalizedString(@"ais_type_object"); + } +} + +- (NSString *)formatTcpa:(double)tcpa +{ + BOOL future = tcpa >= 0; + double absTcpa = fabs(tcpa); + NSInteger hours = (NSInteger)absTcpa; + NSInteger minutes = (NSInteger)round((absTcpa - hours) * 60.0); + NSString *value = hours > 0 ? [NSString stringWithFormat:@"%ld h %ld min", (long)hours, (long)minutes] : [NSString stringWithFormat:@"%ld min", (long)minutes]; + return future ? value : [NSString stringWithFormat:@"-%@", value]; +} + +@end diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h new file mode 100644 index 0000000000..bf43ee9541 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h @@ -0,0 +1,6 @@ +#import "OAMapLayer.h" +#import "OAContextMenuProvider.h" + +@interface OAAisTrackerLayer : OAMapLayer + +@end diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm new file mode 100644 index 0000000000..a8ac0c853e --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm @@ -0,0 +1,601 @@ +#import "OAAisTrackerLayer.h" +#import "OAMapRendererView.h" +#import "OANativeUtilities.h" +#import "OAPluginsHelper.h" +#import "OATargetPoint.h" +#import "OAPointDescription.h" +#import "Localization.h" +#import "OsmAnd_Maps-Swift.h" + +#include +#include +#include +#include +#include +#include +#include + +#define kAisTrackerLayerId @"ais_tracker_layer" + +static const int kAisTrackerStartZoom = 6; +static int kAisIconKeyStorage; +static const OsmAnd::MapMarker::OnSurfaceIconKey kAisIconKey = &kAisIconKeyStorage; + +@interface OAAisObjectRenderData : NSObject + +@property (nonatomic) AisObject *object; + +- (instancetype)initWithObject:(AisObject *)object; +- (BOOL)hasRenderData; +- (void)createRenderDataWithBaseOrder:(int)baseOrder + markersCollection:(const std::shared_ptr &)markersCollection + vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection; +- (void)updateRenderDataWithMapView:(OAMapRendererView *)mapView + plugin:(OAAisTrackerPlugin *)plugin; +- (void)clearRenderDataFromMarkersCollection:(const std::shared_ptr &)markersCollection + vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection; + +@end + +@implementation OAAisObjectRenderData +{ + std::shared_ptr _activeMarker; + std::shared_ptr _restMarker; + std::shared_ptr _lostMarker; + std::shared_ptr _directionLine; +} + +- (instancetype)initWithObject:(AisObject *)object +{ + self = [super init]; + if (self) + _object = object; + return self; +} + +- (BOOL)hasRenderData +{ + return _activeMarker && _restMarker && _lostMarker && _directionLine; +} + +- (void)createRenderDataWithBaseOrder:(int)baseOrder + markersCollection:(const std::shared_ptr &)markersCollection + vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection +{ + if (!markersCollection || !vectorLinesCollection) + return; + + OsmAnd::MapMarkerBuilder markerBuilder; + markerBuilder + .setIsAccuracyCircleSupported(false) + .setBaseOrder(baseOrder + 10) + .setIsHidden(true) + .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage([self iconImageForState:0])); + _activeMarker = markerBuilder.buildAndAddToCollection(markersCollection); + + markerBuilder + .clearOnMapSurfaceIcons() + .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage([self iconImageForState:1])); + _restMarker = markerBuilder.buildAndAddToCollection(markersCollection); + + markerBuilder + .clearOnMapSurfaceIcons() + .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage([self iconImageForState:2])); + _lostMarker = markerBuilder.buildAndAddToCollection(markersCollection); + + QVector points; + points.push_back(OsmAnd::PointI(0, 0)); + points.push_back(OsmAnd::PointI(1, 1)); + + OsmAnd::VectorLineBuilder lineBuilder; + lineBuilder + .setLineId(_object.mmsi) + .setBaseOrder(baseOrder + 9) + .setIsHidden(true) + .setLineWidth(6.0f) + .setApproximationEnabled(false) + .setFillColor(OsmAnd::FColorARGB(1.0f, 0.0f, 0.0f, 0.0f)) + .setPoints(points); + _directionLine = lineBuilder.buildAndAddToCollection(vectorLinesCollection); + + [self updateRenderDataWithMapView:nil plugin:nil]; +} + +- (void)updateRenderDataWithMapView:(OAMapRendererView *)mapView + plugin:(OAAisTrackerPlugin *)plugin +{ + if (![self hasRenderData]) + return; + + const OsmAnd::ZoomLevel zoom = mapView ? mapView.zoomLevel : OsmAnd::ZoomLevel::MinZoomLevel; + if (!mapView || (int)zoom < kAisTrackerStartZoom || !_object.hasPosition) + { + _activeMarker->setIsHidden(true); + _restMarker->setIsHidden(true); + _lostMarker->setIsHidden(true); + _directionLine->setIsHidden(true); + return; + } + + CLLocation *location = _object.currentLocation ?: _object.location; + if (!location) + { + _activeMarker->setIsHidden(true); + _restMarker->setIsHidden(true); + _lostMarker->setIsHidden(true); + _directionLine->setIsHidden(true); + return; + } + + NSInteger vesselLostTimeout = plugin ? [plugin vesselLostTimeoutInMinutes] : 0; + BOOL vesselAtRest = [_object isVesselAtRest]; + BOOL lostTimeout = vesselLostTimeout > 0 && [_object isLostWithMaxAgeMinutes:vesselLostTimeout] && !vesselAtRest; + CGFloat speedFactor = [self movementFactor]; + BOOL drawDirectionLine = speedFactor > 0 && !lostTimeout && !vesselAtRest; + + BOOL cpaWarning = plugin ? [plugin hasCpaWarningFor:_object] : NO; + UIColor *uiColor = cpaWarning ? UIColor.redColor : [self colorForType:_object.objectClass]; + OsmAnd::ColorARGB iconColor = [uiColor toColorARGB]; + OsmAnd::FColorARGB lineColor = [uiColor toFColorARGB]; + _activeMarker->setOnSurfaceIconModulationColor(iconColor); + _restMarker->setOnSurfaceIconModulationColor(iconColor); + _directionLine->setFillColor(lineColor); + + _activeMarker->setIsHidden(vesselAtRest || lostTimeout); + _restMarker->setIsHidden(!vesselAtRest); + _lostMarker->setIsHidden(!lostTimeout); + + float rotation = fmod(_object.vesselRotation + 180.0, 360.0); + if (!vesselAtRest && [self needRotation]) + { + _activeMarker->setOnMapSurfaceIconDirection(kAisIconKey, rotation); + _lostMarker->setOnMapSurfaceIconDirection(kAisIconKey, rotation); + } + + OsmAnd::PointI markerLocation(OsmAnd::Utilities::get31TileNumberX(location.coordinate.longitude), + OsmAnd::Utilities::get31TileNumberY(location.coordinate.latitude)); + _activeMarker->setPosition(markerLocation); + _restMarker->setPosition(markerLocation); + _lostMarker->setPosition(markerLocation); + + if (drawDirectionLine) + { + int inverseZoom = (int)OsmAnd::ZoomLevel::MaxZoomLevel - (int)zoom; + double lineLength = speedFactor * std::pow(2.0, inverseZoom) * 34.0 * 0.75; + double theta = rotation * M_PI / 180.0; + int dx = (int)ceil(-sin(theta) * lineLength); + int dy = (int)ceil(cos(theta) * lineLength); + + QVector points; + points.push_back(markerLocation); + points.push_back(OsmAnd::PointI(markerLocation.x + dx, markerLocation.y + dy)); + _directionLine->setPoints(points); + } + _directionLine->setIsHidden(!drawDirectionLine); +} + +- (void)clearRenderDataFromMarkersCollection:(const std::shared_ptr &)markersCollection + vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection +{ + if (markersCollection) + { + if (_activeMarker) + markersCollection->removeMarker(_activeMarker); + if (_restMarker) + markersCollection->removeMarker(_restMarker); + if (_lostMarker) + markersCollection->removeMarker(_lostMarker); + } + if (vectorLinesCollection && _directionLine) + vectorLinesCollection->removeLine(_directionLine); + + _activeMarker.reset(); + _restMarker.reset(); + _lostMarker.reset(); + _directionLine.reset(); +} + +- (sk_sp)iconImageForState:(NSInteger)state +{ + CGFloat scale = UIScreen.mainScreen.scale; + CGSize size = CGSizeMake(34.0, 34.0); + UIGraphicsBeginImageContextWithOptions(size, NO, scale); + CGRect bounds = CGRectInset(CGRectMake(0, 0, size.width, size.height), 4, 4); + + UIBezierPath *path; + if (state == 1) + { + path = [UIBezierPath bezierPathWithOvalInRect:bounds]; + } + else if (_object.objectClass == AisObjTypeAton || _object.objectClass == AisObjTypeAtonVirtual) + { + path = [UIBezierPath bezierPathWithOvalInRect:bounds]; + } + else if ([_object isMovable]) + { + path = [UIBezierPath bezierPath]; + [path moveToPoint:CGPointMake(CGRectGetMidX(bounds), CGRectGetMinY(bounds))]; + [path addLineToPoint:CGPointMake(CGRectGetMaxX(bounds), CGRectGetMaxY(bounds))]; + [path addLineToPoint:CGPointMake(CGRectGetMidX(bounds), CGRectGetMaxY(bounds) - 6)]; + [path addLineToPoint:CGPointMake(CGRectGetMinX(bounds), CGRectGetMaxY(bounds))]; + [path closePath]; + } + else + { + path = [UIBezierPath bezierPathWithRoundedRect:bounds cornerRadius:4]; + } + + [UIColor.whiteColor setFill]; + [UIColor.whiteColor setStroke]; + path.lineWidth = 2; + [path fill]; + [path stroke]; + + if (state == 2) + { + UIBezierPath *cross = [UIBezierPath bezierPath]; + [cross moveToPoint:CGPointMake(CGRectGetMinX(bounds) + 2, CGRectGetMinY(bounds) + 2)]; + [cross addLineToPoint:CGPointMake(CGRectGetMaxX(bounds) - 2, CGRectGetMaxY(bounds) - 2)]; + [cross moveToPoint:CGPointMake(CGRectGetMaxX(bounds) - 2, CGRectGetMinY(bounds) + 2)]; + [cross addLineToPoint:CGPointMake(CGRectGetMinX(bounds) + 2, CGRectGetMaxY(bounds) - 2)]; + [UIColor.blackColor setStroke]; + cross.lineWidth = 3; + [cross stroke]; + } + + UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + return [OANativeUtilities skImageFromCGImage:image.CGImage]; +} + +- (UIColor *)colorForType:(AisObjType)type +{ + switch (type) + { + case AisObjTypeVessel: return UIColor.greenColor; + case AisObjTypeVesselSport: return UIColor.yellowColor; + case AisObjTypeVesselFast: return UIColor.blueColor; + case AisObjTypeVesselPassenger: return UIColor.cyanColor; + case AisObjTypeVesselFreight: return UIColor.grayColor; + case AisObjTypeVesselCommercial: return UIColor.lightGrayColor; + case AisObjTypeVesselAuthorities: return [UIColor colorWithRed:0.33 green:0.42 blue:0.18 alpha:1.0]; + case AisObjTypeVesselSar: + case AisObjTypeSart: return [UIColor colorWithRed:0.98 green:0.50 blue:0.45 alpha:1.0]; + case AisObjTypeVesselOther: return [UIColor colorWithRed:0.00 green:0.75 blue:1.00 alpha:1.0]; + case AisObjTypeAirplane: return [UIColor colorWithRed:0.45 green:0.27 blue:0.86 alpha:1.0]; + case AisObjTypeAton: + case AisObjTypeAtonVirtual: return [UIColor colorWithRed:0.92 green:0.82 blue:0.14 alpha:1.0]; + case AisObjTypeLandStation: return [UIColor colorWithRed:0.45 green:0.45 blue:0.45 alpha:1.0]; + default: return [UIColor colorWithRed:0.04 green:0.62 blue:0.72 alpha:1.0]; + } +} + +- (CGFloat)movementFactor +{ + if (_object.sog <= 0 || ![_object isMovable]) + return 0; + if (_object.sog < 2.0) + return 0; + if (_object.sog < 5.0) + return 1.0; + if (_object.sog < 10.0) + return 3.0; + if (_object.sog < 25.0) + return 6.0; + return 8.0; +} + +- (BOOL)needRotation +{ + return (((_object.cog != 360.0) && (_object.cog != 0.0)) || + ((_object.heading != 511) && (_object.heading != 0))) && [_object isMovable]; +} + +@end + +@implementation OAAisTrackerLayer +{ + OAAisTrackerPlugin *_plugin; + NSMutableDictionary *_objectRenderData; + std::shared_ptr _markersCollection; + std::shared_ptr _vectorLinesCollection; + id _objectsObserver; + BOOL _collectionsAdded; +} + +- (instancetype)initWithMapViewController:(OAMapViewController *)mapViewController baseOrder:(int)baseOrder +{ + self = [super initWithMapViewController:mapViewController baseOrder:baseOrder]; + if (self) + { + _plugin = (OAAisTrackerPlugin *)[OAPluginsHelper getPlugin:OAAisTrackerPlugin.class]; + _objectRenderData = [NSMutableDictionary dictionary]; + } + return self; +} + +- (NSString *)layerId +{ + return kAisTrackerLayerId; +} + +- (void)initLayer +{ + [super initLayer]; + [self resetCollections]; + [self.app.data.mapLayersConfiguration setLayer:self.layerId + Visibility:self.isVisible]; + + __weak OAAisTrackerLayer *weakSelf = self; + _objectsObserver = [NSNotificationCenter.defaultCenter addObserverForName:@"OAAisObjectsChanged" + object:nil + queue:NSOperationQueue.mainQueue + usingBlock:^(NSNotification * _Nonnull note) { + [weakSelf reloadObjects]; + }]; +} + +- (void)deinitLayer +{ + if (_objectsObserver) + { + [NSNotificationCenter.defaultCenter removeObserver:_objectsObserver]; + _objectsObserver = nil; + } + [self cleanupResources]; + [super deinitLayer]; +} + +- (BOOL)isVisible +{ + return [_plugin isEnabled]; +} + +- (void)show +{ + [self addCollectionsToRenderer]; + [self reloadObjects]; +} + +- (void)hide +{ + [self removeCollectionsFromRenderer]; +} + +- (BOOL)updateLayer +{ + if (![super updateLayer]) + return NO; + + [self.app.data.mapLayersConfiguration setLayer:self.layerId + Visibility:self.isVisible]; + if ([self isVisible]) + { + [self addCollectionsToRenderer]; + [self reloadObjects]; + } + else + { + [self removeCollectionsFromRenderer]; + } + return YES; +} + +- (void)onMapFrameRendered +{ + [self updateRenderData]; +} + +- (void)resetCollections +{ + _markersCollection = std::make_shared(); + _vectorLinesCollection = std::make_shared(); +} + +- (void)addCollectionsToRenderer +{ + if (!_markersCollection || !_vectorLinesCollection) + [self resetCollections]; + if (_collectionsAdded) + return; + + [self.mapViewController runWithRenderSync:^{ + [self.mapView addKeyedSymbolsProvider:_markersCollection]; + [self.mapView addKeyedSymbolsProvider:_vectorLinesCollection]; + _collectionsAdded = YES; + }]; +} + +- (void)removeCollectionsFromRenderer +{ + if (!_collectionsAdded) + return; + + [self.mapViewController runWithRenderSync:^{ + if (_markersCollection) + [self.mapView removeKeyedSymbolsProvider:_markersCollection]; + if (_vectorLinesCollection) + [self.mapView removeKeyedSymbolsProvider:_vectorLinesCollection]; + _collectionsAdded = NO; + }]; +} + +- (void)cleanupResources +{ + [self removeCollectionsFromRenderer]; + if (_markersCollection) + _markersCollection->removeAllMarkers(); + if (_vectorLinesCollection) + _vectorLinesCollection->removeAllLines(); + [_objectRenderData removeAllObjects]; + [self resetCollections]; +} + +- (void)reloadObjects +{ + if (![self isVisible]) + return; + + [self.mapViewController runWithRenderSync:^{ + [self reloadObjectsSync]; + }]; +} + +- (void)reloadObjectsSync +{ + NSArray *objects = [_plugin getAisObjects]; + NSMutableSet *visibleMmsi = [NSMutableSet set]; + for (AisObject *object in objects) + { + if (!object.hasPosition) + continue; + + NSNumber *key = @(object.mmsi); + [visibleMmsi addObject:key]; + OAAisObjectRenderData *renderData = _objectRenderData[key]; + if (!renderData) + { + renderData = [[OAAisObjectRenderData alloc] initWithObject:object]; + _objectRenderData[key] = renderData; + } + renderData.object = object; + if (![renderData hasRenderData]) + [renderData createRenderDataWithBaseOrder:self.baseOrder markersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; + [renderData updateRenderDataWithMapView:self.mapView plugin:_plugin]; + } + + for (NSNumber *key in [_objectRenderData.allKeys copy]) + { + if (![visibleMmsi containsObject:key]) + { + [_objectRenderData[key] clearRenderDataFromMarkersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; + [_objectRenderData removeObjectForKey:key]; + } + } +} + +- (void)updateRenderData +{ + if (![self isVisible]) + return; + + for (NSNumber *key in _objectRenderData) + [_objectRenderData[key] updateRenderDataWithMapView:self.mapView plugin:_plugin]; +} + +#pragma mark - OAContextMenuProvider + +- (OATargetPoint *)getTargetPoint:(id)obj touchLocation:(CLLocation *)touchLocation +{ + if (![obj isKindOfClass:AisObject.class] || !((AisObject *)obj).hasPosition) + return nil; + + AisObject *object = obj; + CLLocation *location = object.currentLocation ?: object.location; + if (!location) + return nil; + + OATargetPoint *targetPoint = [[OATargetPoint alloc] init]; + targetPoint.type = OATargetAisObject; + targetPoint.targetObj = object; + targetPoint.title = object.title; + targetPoint.titleSecond = [self objectTypeName:object.objectClass]; + targetPoint.location = location.coordinate; + targetPoint.icon = [UIImage imageNamed:@"ic_plugin_nautical"]; + targetPoint.sortIndex = OATargetAisObject; + targetPoint.centerMap = YES; + return targetPoint; +} + +- (OATargetPoint *)getTargetPointCpp:(const void *)obj +{ + return nil; +} + +- (BOOL)isSecondaryProvider +{ + return NO; +} + +- (CLLocation *)getObjectLocation:(id)obj +{ + if (![obj isKindOfClass:AisObject.class] || !((AisObject *)obj).hasPosition) + return nil; + AisObject *object = obj; + return object.currentLocation ?: object.location; +} + +- (OAPointDescription *)getObjectName:(id)obj +{ + if (![obj isKindOfClass:AisObject.class]) + return nil; + AisObject *object = obj; + return [[OAPointDescription alloc] initWithType:POINT_TYPE_LOCATION typeName:OALocalizedString(@"plugin_ais_tracker_name") name:object.title]; +} + +- (BOOL)showMenuAction:(id)object +{ + return NO; +} + +- (BOOL)runExclusiveAction:(id)obj unknownLocation:(BOOL)unknownLocation +{ + return NO; +} + +- (int64_t)getSelectionPointOrder:(id)selectedObject +{ + return self.pointsOrder; +} + +- (void)collectObjectsFromPoint:(MapSelectionResult *)result unknownLocation:(BOOL)unknownLocation excludeUntouchableObjects:(BOOL)excludeUntouchableObjects +{ + if (excludeUntouchableObjects || ![self isVisible] || (int)self.mapView.zoomLevel < kAisTrackerStartZoom) + return; + + CGPoint point = result.point; + int radius = MAX(28, (int)([self getScaledTouchRadius:[self getDefaultRadiusPoi]] * TOUCH_RADIUS_MULTIPLIER)); + QList touchPolygon31 = + [OANativeUtilities getPolygon31FromScreenAreaLeft:point.x - radius + top:point.y - radius + right:point.x + radius + bottom:point.y + radius]; + if (touchPolygon31.isEmpty()) + return; + + for (AisObject *object in [_plugin getAisObjects]) + { + CLLocation *location = object.currentLocation ?: object.location; + if (!location) + continue; + + if ([OANativeUtilities isPointInsidePolygonLat:location.coordinate.latitude + lon:location.coordinate.longitude + polygon31:touchPolygon31]) + [result collect:object provider:self]; + } +} + +- (NSString *)objectTypeName:(AisObjType)type +{ + switch (type) + { + case AisObjTypeVessel: return OALocalizedString(@"ais_type_vessel"); + case AisObjTypeVesselSport: return OALocalizedString(@"ais_type_sport_vessel"); + case AisObjTypeVesselFast: return OALocalizedString(@"ais_type_high_speed_vessel"); + case AisObjTypeVesselPassenger: return OALocalizedString(@"ais_type_passenger_vessel"); + case AisObjTypeVesselFreight: return OALocalizedString(@"ais_type_cargo_tanker"); + case AisObjTypeVesselCommercial: return OALocalizedString(@"ais_type_commercial_vessel"); + case AisObjTypeVesselAuthorities: return OALocalizedString(@"ais_type_authorities_vessel"); + case AisObjTypeVesselSar: return OALocalizedString(@"ais_type_sar_vessel"); + case AisObjTypeLandStation: return OALocalizedString(@"ais_type_base_station"); + case AisObjTypeAirplane: return OALocalizedString(@"ais_type_sar_aircraft"); + case AisObjTypeSart: return OALocalizedString(@"ais_type_sart"); + case AisObjTypeAton: return OALocalizedString(@"ais_type_aid_to_navigation"); + case AisObjTypeAtonVirtual: return OALocalizedString(@"ais_type_virtual_aid_to_navigation"); + case AisObjTypeVesselOther: return OALocalizedString(@"ais_type_other_vessel"); + default: return OALocalizedString(@"ais_type_object"); + } +} + +@end diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift new file mode 100644 index 0000000000..f5b1d47ff6 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift @@ -0,0 +1,316 @@ +import CoreLocation +import UIKit + +@objcMembers +final class OAAisTrackerPlugin: OAPlugin { + static let pluginId = "osmand.aistracker" + static let protocolPrefId = "ais_nmea_protocol" + static let hostPrefId = "ais_address_nmea_server" + static let tcpPortPrefId = "ais_port_nmea_server" + static let udpPortPrefId = "ais_port_nmea_local" + static let overrideLocationPrefId = "ais_use_nmea_location" + static let objectLostTimeoutPrefId = "ais_object_lost_timeout" + static let shipLostTimeoutPrefId = "ais_ship_lost_timeout" + static let cpaWarningTimePrefId = "ais_cpa_warning_time" + static let cpaWarningDistancePrefId = "ais_cpa_warning_distance" + + let protocolPref: OACommonInteger + let hostPref: OACommonString + let tcpPortPref: OACommonInteger + let udpPortPref: OACommonInteger + let overrideLocationPref: OACommonBoolean + let objectLostTimeoutPref: OACommonInteger + let shipLostTimeoutPref: OACommonInteger + let cpaWarningTimePref: OACommonInteger + let cpaWarningDistancePref: OACommonDouble + + private let connection = AisNmeaConnection() + private let decoder = AisMessageDecoder() + private lazy var simulationProvider = AisSimulationProvider(plugin: self) + private lazy var aisDataManager = AisDataManager(plugin: self) + private(set) var connectionState: AisNmeaConnectionState = .disconnected + private(set) var lastLocation: CLLocation? + private(set) var fakeOwnLocation: CLLocation? + private(set) var simulationFileName: String? + private(set) var simulationStatusText: String? + private(set) var lastMessageReceived = Date.distantPast + + override init() { + protocolPref = OAAppSettings.sharedManager().registerIntPreference(Self.protocolPrefId, defValue: Int32(AisNmeaProtocol.udp.rawValue)) + hostPref = OAAppSettings.sharedManager().registerStringPreference(Self.hostPrefId, defValue: "192.168.200.16") + tcpPortPref = OAAppSettings.sharedManager().registerIntPreference(Self.tcpPortPrefId, defValue: 4001) + udpPortPref = OAAppSettings.sharedManager().registerIntPreference(Self.udpPortPrefId, defValue: 10110) + overrideLocationPref = OAAppSettings.sharedManager().registerBooleanPreference(Self.overrideLocationPrefId, defValue: false) + objectLostTimeoutPref = OAAppSettings.sharedManager().registerIntPreference(Self.objectLostTimeoutPrefId, defValue: 7) + shipLostTimeoutPref = OAAppSettings.sharedManager().registerIntPreference(Self.shipLostTimeoutPrefId, defValue: 4) + cpaWarningTimePref = OAAppSettings.sharedManager().registerIntPreference(Self.cpaWarningTimePrefId, defValue: 0) + cpaWarningDistancePref = OAAppSettings.sharedManager().registerFloatPreference(Self.cpaWarningDistancePrefId, defValue: 1.0) + super.init() + + connection.onStateChanged = { [weak self] state in + self?.connectionState = state + NotificationCenter.default.post(name: .aisNmeaConnectionStateChanged, object: self) + } + connection.onLocation = { [weak self] location in + self?.handle(location) + } + connection.onSentence = { [weak self] sentence in + self?.handleAisSentence(sentence) + } + } + + override func getId() -> String { + kInAppId_Addon_Ais_Tracker + } + + override func getName() -> String { + localizedString("plugin_ais_tracker_name") + } + + override func getDescription() -> String { + localizedString("plugin_ais_tracker_description") + } + + override func getLogoResourceId() -> String? { + "ic_plugin_nautical" + } + + override func getAddedAppModes() -> [OAApplicationMode] { + [OAApplicationMode.boat()] + } + + override func initPlugin() -> Bool { + let result = super.initPlugin() + restartConnection() + return result + } + + override func setEnabled(_ enabled: Bool) { + super.setEnabled(enabled) + enabled ? restartConnection() : connection.stop() + } + + override func updateLayers() { + DispatchQueue.main.async { + OsmAndApp.swiftInstance().data.mapLayersConfiguration.setLayer("ais_tracker_layer", visibility: self.isEnabled()) + OARootViewController.instance().mapPanel.mapViewController.updateLayer("ais_tracker_layer") + } + } + + override func disable() { + connection.stop() + super.disable() + } + + override func getSettingsController() -> UIViewController? { + AisTrackerSettingsViewController(plugin: self) + } + + func getSimulationProvider() -> AisSimulationProvider { + simulationProvider + } + + func startAisSimulation(_ fileURL: URL) { + simulationFileName = fileURL.lastPathComponent + simulationStatusText = localizedString("shared_string_loading") + simulationProvider.startAisSimulation(fileURL) + } + + func updateSimulationStatus(sentences: Int, decoded: Int, objects: Int, error: String?) { + if let error, !error.isEmpty { + simulationStatusText = error + } else { + simulationStatusText = "sentences \(sentences), decoded \(decoded), objects \(objects)" + } + } + + func prepareAisSimulation() { + connection.stop() + aisDataManager.cleanupResources() + aisDataManager.startUpdates() + } + + func addTestSimulationObjects() { + simulationProvider.initFakePosition() + simulationProvider.initTestPassengerShip() + simulationProvider.initTestSailingBoat() + simulationProvider.initTestLandStation() + simulationProvider.initTestAircraft() + simulationProvider.initTestLawEnforcement() + } + + func clearSimulationObjects() { + simulationProvider.stopAisSimulation() + fakeOwnLocation = nil + simulationFileName = nil + simulationStatusText = nil + aisDataManager.cleanupResources() + } + + func restartConnection() { + guard isEnabled() else { return } + aisDataManager.startUpdates() + let proto = AisNmeaProtocol(rawValue: Int(protocolPref.get())) ?? .udp + switch proto { + case .udp: + connection.startUDP(port: UInt16(max(1, udpPortPref.get()))) + case .tcp: + connection.startTCP(host: hostPref.get(), port: UInt16(max(1, tcpPortPref.get()))) + } + } + + func stopAisNetworkListener() { + connection.stop() + aisDataManager.stopUpdates() + } + + func fakeOwnPosition(_ location: CLLocation?) { + fakeOwnLocation = location + } + + func handleSimulatedNmeaSentence(_ sentence: String) { + handleAisSentence(sentence) + if let location = AisNmeaParser.parseLocation(from: sentence) { + handleSimulatedLocation(location) + } + } + + func handleSimulatedLocation(_ location: CLLocation) { + handle(location) + } + + func handleSimulatedAisObject(_ object: AisObject) { + aisDataManager.onAisObjectReceived(object) + } + + func getAisObjects() -> [AisObject] { + aisDataManager.objects + } + + func maxObjectAgeInMinutes() -> Int { + max(1, Int(objectLostTimeoutPref.get())) + } + + func vesselLostTimeoutInMinutes() -> Int { + max(0, Int(shipLostTimeoutPref.get())) + } + + func cpaWarningTimeInMinutes() -> Int { + max(0, Int(cpaWarningTimePref.get())) + } + + func cpaWarningDistanceInNauticalMiles() -> Double { + max(0, cpaWarningDistancePref.get()) + } + + func ownPosition() -> CLLocation? { + if let fakeOwnLocation { + return fakeOwnLocation + } + return OsmAndApp.swiftInstance().locationServices?.lastKnownLocation + } + + func onAisObjectReceived(_ object: AisObject) { + lastMessageReceived = object.lastUpdate + NotificationCenter.default.post(name: .aisObjectReceived, object: self, userInfo: ["object": object]) + } + + func onAisObjectRemoved(_ object: AisObject) { + NotificationCenter.default.post(name: .aisObjectRemoved, object: self, userInfo: ["object": object]) + } + + func hasCpaWarning(for object: AisObject) -> Bool { + let warningTime = cpaWarningTimeInMinutes() + let warningDistance = cpaWarningDistanceInNauticalMiles() + guard object.isMovable, + object.objectClass != .airplane, + warningTime > 0, + object.sog > 0, + let ownPosition = ownPosition(), + let aisPosition = object.location else { + return false + } + AisTrackerHelper.getCpa(ownPosition, aisPosition, result: object.cpa) + guard object.cpa.valid, object.cpa.tcpa > 0 else { return false } + return Double(object.cpa.cpaDistance) <= warningDistance + && object.cpa.tcpa * 60.0 <= Double(warningTime) + && object.cpa.crossingTime1 >= 0 + && object.cpa.crossingTime2 >= 0 + } + + func updateCpa(for object: AisObject) { + guard let ownPosition = ownPosition(), + let aisPosition = object.currentLocation ?? object.location else { + object.cpa.reset() + return + } + AisTrackerHelper.getCpa(ownPosition, aisPosition, result: object.cpa) + } + + func distanceInNauticalMiles(to object: AisObject) -> Double { + guard let ownPosition = ownPosition(), + let aisPosition = object.currentLocation ?? object.location else { + return -1 + } + return ownPosition.distance(from: aisPosition) / 1852.0 + } + + func bearing(to object: AisObject) -> Double { + guard let ownPosition = ownPosition(), + let aisPosition = object.currentLocation ?? object.location else { + return -1 + } + return Self.bearing(from: ownPosition.coordinate, to: aisPosition.coordinate) + } + + func connectionDescription() -> String { + let proto = AisNmeaProtocol(rawValue: Int(protocolPref.get())) ?? .udp + switch proto { + case .udp: + return "UDP • \(udpPortPref.get())" + case .tcp: + return "TCP • \(hostPref.get()):\(tcpPortPref.get())" + } + } + + func statusDescription() -> String { + switch connectionState { + case .connected: + return localizedString("ais_connection_connected") + case .connecting: + return localizedString("ais_connection_connecting") + case .failed: + return localizedString("ais_connection_failed") + case .disconnected: + return localizedString("ais_connection_disconnected") + } + } + + private func handle(_ location: CLLocation) { + lastLocation = location + NotificationCenter.default.post(name: .aisNmeaLocationReceived, object: self) + if overrideLocationPref.get() { + OsmAndApp.swiftInstance().locationServices?.setLocationFromNMEA(location) + } + } + + private func handleAisSentence(_ sentence: String) { + guard let object = decoder.decode(sentence: sentence) else { return } + aisDataManager.onAisObjectReceived(object) + } + + private static func bearing(from start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D) -> Double { + let lat1 = start.latitude * .pi / 180 + let lat2 = end.latitude * .pi / 180 + let deltaLon = (end.longitude - start.longitude) * .pi / 180 + let y = sin(deltaLon) * cos(lat2) + let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(deltaLon) + let degrees = atan2(y, x) * 180 / .pi + return fmod(degrees + 360, 360) + } +} + +extension Notification.Name { + static let aisNmeaConnectionStateChanged = Notification.Name("OAAisNmeaConnectionStateChanged") + static let aisNmeaLocationReceived = Notification.Name("OAAisNmeaLocationReceived") +} diff --git a/Sources/Plugins/OAPluginsHelper.mm b/Sources/Plugins/OAPluginsHelper.mm index 49d3be8d90..a301e8505e 100644 --- a/Sources/Plugins/OAPluginsHelper.mm +++ b/Sources/Plugins/OAPluginsHelper.mm @@ -100,6 +100,7 @@ + (void) initPlugins [allPlugins addObject:[[OAMapillaryPlugin alloc] init]]; [allPlugins addObject:[[OAWeatherPlugin alloc] init]]; [allPlugins addObject:[[OAExternalSensorsPlugin alloc] init]]; + [allPlugins addObject:[OAAisTrackerPlugin new]]; [allPlugins addObject:[VehicleMetricsPlugin new]]; [allPlugins addObject:[[OAOsmandDevelopmentPlugin alloc] init]]; diff --git a/Sources/Purchases/OAProducts.h b/Sources/Purchases/OAProducts.h index 4471a0b99b..8e4ef8ebf8 100644 --- a/Sources/Purchases/OAProducts.h +++ b/Sources/Purchases/OAProducts.h @@ -50,6 +50,7 @@ NS_ASSUME_NONNULL_BEGIN #define kInAppId_Addon_OsmandDevelopment @"net.osmand.maps.inapp.addon.development" #define kInAppId_Addon_External_Sensors @"net.osmand.maps.inapp.addon.external_sensors" #define kInAppId_Addon_Vehicle_Metrics @"net.osmand.maps.inapp.addon.vehicle_metrics" +#define kInAppId_Addon_Ais_Tracker @"net.osmand.maps.inapp.addon.ais_tracker" // Addons default prices (EUR) #define kInApp_Addon_SkiMap_Default_Price 0.0 @@ -353,6 +354,9 @@ typedef NS_ENUM(NSUInteger, OAProductDiscountType) @interface OAVehicleMetricsProduct : OAProduct @end +@interface OAAisTrackerProduct : OAProduct +@end + @interface OACarPlayProduct : OAProduct @end @@ -407,6 +411,7 @@ typedef NS_ENUM(NSUInteger, OAProductDiscountType) @property (nonatomic, readonly) OAProduct *weather; @property (nonatomic, readonly) OAProduct *sensors; @property (nonatomic, readonly) OAProduct *vehicleMetrics; +@property (nonatomic, readonly) OAProduct *aisTracker; @property (nonatomic, readonly) OAProduct *carplay; @property (nonatomic, readonly) OAProduct *osmandDevelopment; diff --git a/Sources/Purchases/OAProducts.mm b/Sources/Purchases/OAProducts.mm index 9276be75ad..8cc15034f1 100644 --- a/Sources/Purchases/OAProducts.mm +++ b/Sources/Purchases/OAProducts.mm @@ -2531,6 +2531,47 @@ - (NSString *)localizedDescriptionExt @end +@implementation OAAisTrackerProduct + +- (instancetype)init +{ + self = [super initWithIdentifier:kInAppId_Addon_Ais_Tracker]; + if (self) + { + self.free = YES; + [self commonInit]; + } + return self; +} + +- (NSString *)productIconName +{ + return @"ic_plugin_nautical"; +} + +- (NSString *)productScreenshotName +{ + return @"img_plugin_nautical.jpg"; +} + +- (NSString *)localizedTitle +{ + return OALocalizedString(@"plugin_ais_tracker_name"); +} + +- (NSString *)localizedDescription +{ + return OALocalizedString(@"plugin_ais_tracker_description"); +} + +- (NSString *)localizedDescriptionExt +{ + return OALocalizedString(@"plugin_ais_tracker_description"); +} + +@end + + @implementation OACarPlayProduct - (instancetype) init @@ -2859,6 +2900,7 @@ @interface OAProducts() @property (nonatomic) OAProduct *weather; @property (nonatomic) OAProduct *sensors; @property (nonatomic) OAProduct *vehicleMetrics; +@property (nonatomic) OAProduct *aisTracker; @property (nonatomic) OAProduct *carplay; @property (nonatomic) OAProduct *osmandDevelopment; @@ -2910,6 +2952,7 @@ - (instancetype) init self.weather = [[OAWeatherProduct alloc] init]; self.sensors = [[OAExternalSensorsProduct alloc] init]; self.vehicleMetrics = [OAVehicleMetricsProduct new]; + self.aisTracker = [OAAisTrackerProduct new]; self.carplay = [[OACarPlayProduct alloc] init]; self.osmandDevelopment = [[OAOsmandDevelopmentProduct alloc] init]; @@ -2935,6 +2978,7 @@ - (instancetype) init self.weather, self.sensors, self.vehicleMetrics, + self.aisTracker, self.osmandDevelopment ]; diff --git a/Sources/Services/OALocationServices.h b/Sources/Services/OALocationServices.h index 7dd7a847de..83adbe1d90 100644 --- a/Sources/Services/OALocationServices.h +++ b/Sources/Services/OALocationServices.h @@ -57,6 +57,7 @@ typedef NS_ENUM(NSUInteger, OALocationServicesStatus) + (BOOL) isPointAccurateForRouting:(CLLocation *)loc; - (void) setLocationFromSimulation:(CLLocation *)location; +- (void) setLocationFromNMEA:(CLLocation *)location; - (BOOL) isInLocationSimulation; - (void)resume; diff --git a/Sources/Services/OALocationServices.mm b/Sources/Services/OALocationServices.mm index 10b8ad3306..b94210f287 100644 --- a/Sources/Services/OALocationServices.mm +++ b/Sources/Services/OALocationServices.mm @@ -35,6 +35,7 @@ #define LOST_LOCATION_CHECK_DELAY 18.0 #define START_LOCATION_SIMULATION_DELAY 2.0 #define ACCURACY_FOR_GPX_AND_ROUTING 50.0 +#define NMEA_LOCATION_OVERRIDE_INTERVAL 5.0 @interface OALocationServices () @end @@ -68,6 +69,7 @@ @implementation OALocationServices BOOL _isSuspended; NSDate *_locationLostTime; + NSDate *_nmeaLocationOverrideUntil; } - (instancetype) initWith:(OsmAndAppInstance)app @@ -642,6 +644,20 @@ - (void) setLocationFromSimulation:(CLLocation *)location [self setLocation:location]; } +- (void) setLocationFromNMEA:(CLLocation *)location +{ + if (!location || [_locationSimulation isRouteAnimating]) + return; + + _nmeaLocationOverrideUntil = [NSDate dateWithTimeIntervalSinceNow:NMEA_LOCATION_OVERRIDE_INTERVAL]; + if (location.course >= 0) + { + _lastHeading = location.course; + _lastMagneticHeading = location.course; + } + [self setLocation:location]; +} + - (BOOL) isInLocationSimulation { return _simulatePosition != nil; @@ -756,6 +772,9 @@ - (void) locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArra if (!locations || ![locations lastObject] || [_locationSimulation isRouteAnimating]) return; + + if (_nmeaLocationOverrideUntil && [_nmeaLocationOverrideUntil timeIntervalSinceNow] > 0) + return; BOOL wasLocationUnknown = (_lastLocation == nil); From c35f38f5b29ab429912ae8a2629322f9747c43d6 Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Wed, 10 Jun 2026 12:31:14 +0300 Subject: [PATCH 02/18] context menu ais base support --- .../en.lproj/Localizable.strings | 1 + .../AisTrackerPlugin/AisDataManager.swift | 7 +- .../AisTrackerPlugin/AisNmeaConnection.swift | 1 + .../Plugins/AisTrackerPlugin/AisObject.swift | 59 ++ .../OAAisObjectViewController.mm | 228 ++++++-- .../AisTrackerPlugin/OAAisTrackerLayer.mm | 514 +++++++++++++++--- .../AisTrackerPlugin/OAAisTrackerPlugin.swift | 72 ++- 7 files changed, 741 insertions(+), 141 deletions(-) diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index ef8ccc7067..fc4e4041cc 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -2501,6 +2501,7 @@ "ais_type_virtual_aid_to_navigation" = "Virtual aid to navigation"; "ais_type_other_vessel" = "Other vessel"; "ais_type_object" = "AIS object"; +"ais_object_with_mmsi" = "AIS object with MMSI %ld"; "ais_unknown" = "unknown"; "ais_not_specified" = "not specified"; "ais_ship_type_wig" = "Wing in ground (WIG)"; diff --git a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift index 64f42b6275..c854fe24f5 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift @@ -44,18 +44,21 @@ final class AisDataManager: NSObject { func onAisObjectReceived(_ ais: AisObject) { let object: AisObject + let event: String if let existing = objectsByMmsi[ais.mmsi] { existing.merge(ais) object = existing + event = "merge" } else { objectsByMmsi[ais.mmsi] = ais object = ais + event = "new" } if objectsByMmsi.count >= Self.objectLimit { removeOldestObject() } + aisDebugLog("data \(event) total=\(objectsByMmsi.count) \(object.debugSummary)") plugin?.onAisObjectReceived(object) - NotificationCenter.default.post(name: .aisObjectsChanged, object: plugin) } func removeLostObjects() { @@ -64,6 +67,7 @@ final class AisDataManager: NSObject { let removed = objectsByMmsi.values.filter { $0.isLost(maxAgeMinutes: maxAge) } for object in removed { objectsByMmsi.removeValue(forKey: object.mmsi) + aisDebugLog("data remove-lost maxAge=\(maxAge)m total=\(objectsByMmsi.count) \(object.debugSummary)") plugin.onAisObjectRemoved(object) } if !removed.isEmpty { @@ -74,6 +78,7 @@ final class AisDataManager: NSObject { private func removeOldestObject() { guard let oldest = objectsByMmsi.values.min(by: { $0.lastUpdate < $1.lastUpdate }) else { return } objectsByMmsi.removeValue(forKey: oldest.mmsi) + aisDebugLog("data remove-oldest limit=\(Self.objectLimit) total=\(objectsByMmsi.count) \(oldest.debugSummary)") plugin?.onAisObjectRemoved(oldest) } } diff --git a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift index 16300c09fe..2cd1cb3c86 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift @@ -13,6 +13,7 @@ import Network case connected case failed } +// NOTE: for test tcp 153.44.253.27 5631 final class AisNmeaConnection { var onLocation: ((CLLocation) -> Void)? diff --git a/Sources/Plugins/AisTrackerPlugin/AisObject.swift b/Sources/Plugins/AisTrackerPlugin/AisObject.swift index a1655ff05e..cda7860fe4 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisObject.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisObject.swift @@ -97,6 +97,18 @@ final class AisObject: NSObject { msgTypes.sorted().map(String.init).joined(separator: ", ") } + func hasMessageType(_ type: Int) -> Bool { + msgTypes.contains(type) + } + + var hasImoMessage: Bool { + hasMessageType(5) + } + + var hasShipTypeMessage: Bool { + hasMessageType(5) || hasMessageType(19) || hasMessageType(24) + } + var location: CLLocation? { guard hasPosition else { return nil } return CLLocation(coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), @@ -108,6 +120,27 @@ final class AisObject: NSObject { timestamp: lastUpdate) } + @objc var debugSummary: String { + let position = hasPosition + ? String(format: "%.6f,%.6f", latitude, longitude) + : "none" + let age = Date().timeIntervalSince(lastUpdate) + return String(format: "mmsi=%d msg=%d msgs=%@ class=%@ shipType=%d rest=%@ movable=%@ nav=%d sog=%.1f cog=%.1f heading=%d pos=%@ age=%.1fs", + mmsi, + msgType, + messageTypesString, + objectClassDebugName, + shipType, + isVesselAtRest ? "yes" : "no", + isMovable ? "yes" : "no", + navStatus, + sog, + cog, + heading, + position, + age) + } + var currentLocation: CLLocation? { guard let location else { return nil } let ageHours = Date().timeIntervalSince(lastUpdate) / 3600.0 @@ -413,4 +446,30 @@ final class AisObject: NSObject { } } } + + private var objectClassDebugName: String { + switch objectClass { + case .vessel: return "vessel" + case .vesselSport: return "vesselSport" + case .vesselFast: return "vesselFast" + case .vesselPassenger: return "vesselPassenger" + case .vesselFreight: return "vesselFreight" + case .vesselCommercial: return "vesselCommercial" + case .vesselAuthorities: return "vesselAuthorities" + case .vesselSar: return "vesselSar" + case .vesselOther: return "vesselOther" + case .landStation: return "landStation" + case .airplane: return "airplane" + case .sart: return "sart" + case .aton: return "aton" + case .atonVirtual: return "atonVirtual" + case .invalid: return "invalid" + } + } +} + +func aisDebugLog(_ message: @autoclosure () -> String) { +#if DEBUG + NSLog("[AIS] %@", message()) +#endif } diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm index 4a188d9e27..a73a7a5c01 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm @@ -3,26 +3,49 @@ #import "OAPluginsHelper.h" #import "OAPointDescription.h" #import "Localization.h" +#import "OALocationConvert.h" +#import "OAValueTableViewCell.h" #import "OsmAnd_Maps-Swift.h" +#import "GeneratedAssetSymbols.h" + +static const NSInteger kAisRowStartOrder = 100; +static const NSInteger kAisRowHeight = 50; + +#ifdef DEBUG +#define OAAisMenuLog(format, ...) NSLog((@"[AIS][Menu] " format), ##__VA_ARGS__) +#else +#define OAAisMenuLog(format, ...) +#endif @implementation OAAisObjectViewController { AisObject *_object; + NSMutableArray *_menuRows; + NSMutableSet *_aisValueRowKeys; } - (instancetype)initWithAisObject:(AisObject *)object { - self = [super init]; + self = [super initWithNibName:@"OATargetInfoViewController" bundle:nil]; if (self) { _object = object; self.location = CLLocationCoordinate2DMake(object.latitude, object.longitude); self.showTitleIfTruncated = NO; self.customOnlinePhotosPosition = YES; + OAAisMenuLog(@"init %@", object.debugSummary); } return self; } +- (void)viewDidLoad +{ + [super viewDidLoad]; + [self.tableView registerNib:[UINib nibWithNibName:[OAValueTableViewCell reuseIdentifier] bundle:nil] + forCellReuseIdentifier:[OAValueTableViewCell reuseIdentifier]]; + OAAisMenuLog(@"viewDidLoad table=%@ height=%.1f %@", self.tableView ? @"yes" : @"no", [self contentHeight], _object.debugSummary); +} + - (id)getTargetObj { return _object; @@ -35,7 +58,7 @@ - (UIImage *)getIcon - (NSString *)getTypeStr { - return OALocalizedString(@"plugin_ais_tracker_name"); + return [self objectTypeName:_object.objectClass]; } - (NSString *)getCommonTypeStr @@ -43,6 +66,16 @@ - (NSString *)getCommonTypeStr return [self getTypeStr]; } +- (NSString *)getNameStr +{ + return [NSString stringWithFormat:OALocalizedString(@"ais_object_with_mmsi"), (long)_object.mmsi]; +} + +- (NSAttributedString *)getAdditionalInfoStr +{ + return nil; +} + - (BOOL)needAddress { return NO; @@ -53,78 +86,100 @@ - (BOOL)showDetailsButton return NO; } +- (BOOL)showNearestWiki +{ + return NO; +} + +- (BOOL)showNearestPoi +{ + return NO; +} + - (void)buildDescription:(NSMutableArray *)rows { - [self addRow:rows key:@"mmsi" prefix:@"MMSI" text:[NSString stringWithFormat:@"%ld", (long)_object.mmsi] order:0]; - if (_object.imo > 0) - [self addRow:rows key:@"imo" prefix:@"IMO" text:[NSString stringWithFormat:@"%ld", (long)_object.imo] order:1]; - if (_object.shipName.length > 0) - [self addRow:rows key:@"ship_name" prefix:OALocalizedString(@"shared_string_name") text:_object.shipName order:2]; - if (_object.callSign.length > 0) - [self addRow:rows key:@"callsign" prefix:OALocalizedString(@"ais_call_sign") text:_object.callSign order:3]; - [self addRow:rows key:@"object_type" prefix:OALocalizedString(@"ais_object_type") text:[self objectTypeName:_object.objectClass] order:4]; - if (_object.shipType != 0) - [self addRow:rows key:@"ship_type" prefix:OALocalizedString(@"ais_ship_type") text:_object.shipTypeString order:5]; +} + +- (void)buildTopInternal:(NSMutableArray *)rows +{ +} + +- (void)buildMenu:(NSMutableArray *)rows +{ + _menuRows = rows; + _aisValueRowKeys = [NSMutableSet set]; + [super buildMenu:rows]; + OAAisMenuLog(@"buildMenu rows=%lu height=%.1f %@", (unsigned long)rows.count, [self contentHeight], _object.debugSummary); +} + +- (void)buildPluginRows:(NSMutableArray *)rows +{ } - (void)buildInternal:(NSMutableArray *)rows { - NSInteger order = 100; + NSInteger order = kAisRowStartOrder; OAAisTrackerPlugin *plugin = (OAAisTrackerPlugin *)[OAPluginsHelper getPlugin:OAAisTrackerPlugin.class]; if (plugin) [plugin updateCpaFor:_object]; + [self addRow:rows key:@"mmsi" prefix:@"MMSI" text:[NSString stringWithFormat:@"%ld", (long)_object.mmsi] order:order++]; if (_object.hasPosition) { - [self addRow:rows key:@"position" prefix:OALocalizedString(@"ais_position") text:[NSString stringWithFormat:@"%.5f, %.5f", _object.latitude, _object.longitude] order:order++]; + [self addRow:rows key:@"position" prefix:@"Position" text:[self formatPosition] order:order++]; } if (plugin) { double distance = [plugin distanceInNauticalMilesTo:_object]; if (distance >= 0) - [self addRow:rows key:@"distance" prefix:OALocalizedString(@"shared_string_distance") text:[NSString stringWithFormat:@"%.2f nm", distance] order:order++]; + [self addRow:rows key:@"distance" prefix:@"Distance" text:[NSString stringWithFormat:@"%.1f nm", distance] order:order++]; double bearing = [plugin bearingTo:_object]; if (bearing >= 0) - [self addRow:rows key:@"bearing" prefix:OALocalizedString(@"shared_string_bearing") text:[NSString stringWithFormat:@"%.0f°", bearing] order:order++]; + [self addRow:rows key:@"bearing" prefix:@"Bearing" text:[NSString stringWithFormat:@"%.0f", bearing] order:order++]; } - if (_object.messageTypesString.length > 0) - [self addRow:rows key:@"message_types" prefix:OALocalizedString(@"ais_message_types") text:_object.messageTypesString order:order++]; - if (_object.sog != 1023.0) - [self addRow:rows key:@"sog" prefix:@"SOG" text:[NSString stringWithFormat:@"%.1f kn", _object.sog] order:order++]; - if (_object.cog != 360.0) - [self addRow:rows key:@"cog" prefix:@"COG" text:[NSString stringWithFormat:@"%.0f°", _object.cog] order:order++]; - if (_object.heading != 511) - [self addRow:rows key:@"heading" prefix:OALocalizedString(@"ais_heading") text:[NSString stringWithFormat:@"%ld°", (long)_object.heading] order:order++]; - if (_object.navStatus != 15) - [self addRow:rows key:@"nav_status" prefix:OALocalizedString(@"ais_navigation_status") text:_object.navStatusString order:order++]; - if (_object.maneuverIndicator != 0) - [self addRow:rows key:@"maneuver" prefix:OALocalizedString(@"ais_maneuver") text:_object.maneuverIndicatorString order:order++]; - if (_object.rot != 128.0) - [self addRow:rows key:@"rot" prefix:@"ROT" text:[NSString stringWithFormat:@"%.1f", _object.rot] order:order++]; - if (_object.altitude != 4095) - [self addRow:rows key:@"altitude" prefix:OALocalizedString(@"altitude") text:[NSString stringWithFormat:@"%ld m", (long)_object.altitude] order:order++]; - if (_object.aidType != 0) - [self addRow:rows key:@"aid_type" prefix:OALocalizedString(@"ais_aid_type") text:_object.aidTypeString order:order++]; - - NSInteger length = _object.dimensionToBow + _object.dimensionToStern; - NSInteger width = _object.dimensionToPort + _object.dimensionToStarboard; - if (length > 0 || width > 0) - [self addRow:rows key:@"dimensions" prefix:OALocalizedString(@"ais_dimensions") text:[NSString stringWithFormat:@"%ld x %ld m", (long)length, (long)width] order:order++]; - if (_object.dimensionToBow > 0 || _object.dimensionToStern > 0 || _object.dimensionToPort > 0 || _object.dimensionToStarboard > 0) - [self addRow:rows key:@"antenna" prefix:OALocalizedString(@"ais_antenna") text:[NSString stringWithFormat:OALocalizedString(@"ais_antenna_offsets_format"), (long)_object.dimensionToBow, (long)_object.dimensionToStern, (long)_object.dimensionToPort, (long)_object.dimensionToStarboard] order:order++]; - if (_object.draught > 0) - [self addRow:rows key:@"draught" prefix:OALocalizedString(@"ais_draught") text:[NSString stringWithFormat:@"%.1f m", _object.draught] order:order++]; - if (_object.destination.length > 0) - [self addRow:rows key:@"destination" prefix:OALocalizedString(@"ais_destination") text:_object.destination order:order++]; - if (_object.etaMonth > 0 && _object.etaDay > 0) - [self addRow:rows key:@"eta" prefix:@"ETA" text:[NSString stringWithFormat:@"%02ld/%02ld %02ld:%02ld", (long)_object.etaMonth, (long)_object.etaDay, (long)_object.etaHour, (long)_object.etaMinute] order:order++]; - - [self addRow:rows key:@"last_update" prefix:OALocalizedString(@"ais_last_update") text:[NSDateFormatter localizedStringFromDate:_object.lastUpdate dateStyle:NSDateFormatterNoStyle timeStyle:NSDateFormatterMediumStyle] order:order++]; if (_object.cpa.valid) { - [self addRow:rows key:@"cpa" prefix:@"CPA" text:[NSString stringWithFormat:@"%.2f nm", _object.cpa.cpaDistance] order:order++]; + [self addRow:rows key:@"cpa" prefix:@"CPA" text:[NSString stringWithFormat:@"%.1f nm", _object.cpa.cpaDistance] order:order++]; [self addRow:rows key:@"tcpa" prefix:@"TCPA" text:[self formatTcpa:_object.cpa.tcpa] order:order++]; } + + if (_object.objectClass == AisObjTypeAton || _object.objectClass == AisObjTypeAtonVirtual) + { + if (_object.aidType != 0) + [self addRow:rows key:@"aid_type" prefix:@"Aid Type" text:_object.aidTypeString order:order++]; + order = [self addDimensionRows:rows order:order]; + } + else if (_object.objectClass == AisObjTypeAirplane) + { + [self addRow:rows key:@"object_type" prefix:@"Object Type" text:[self objectTypeName:_object.objectClass] order:order++]; + order = [self addCourseRows:rows order:order includeHeading:NO includeNavStatus:NO]; + if (_object.altitude != 4095) + [self addRow:rows key:@"altitude" prefix:@"Altitude" text:[NSString stringWithFormat:@"%ld m", (long)_object.altitude] order:order++]; + } + else + { + if (_object.callSign.length > 0) + [self addRow:rows key:@"callsign" prefix:@"Callsign" text:_object.callSign order:order++]; + if (_object.imo > 0 && _object.hasImoMessage) + [self addRow:rows key:@"imo" prefix:@"IMO" text:[NSString stringWithFormat:@"%ld", (long)_object.imo] order:order++]; + if (_object.shipName.length > 0) + [self addRow:rows key:@"ship_name" prefix:@"Shipname" text:_object.shipName order:order++]; + if (_object.shipType != 0 && _object.hasShipTypeMessage) + [self addRow:rows key:@"ship_type" prefix:@"Shiptype" text:_object.shipTypeString order:order++]; + order = [self addCourseRows:rows order:order includeHeading:YES includeNavStatus:YES]; + order = [self addDimensionRows:rows order:order]; + if (_object.draught > 0) + [self addRow:rows key:@"draught" prefix:@"Draught" text:[NSString stringWithFormat:@"%.1f m", _object.draught] order:order++]; + if (_object.destination.length > 0) + [self addRow:rows key:@"destination" prefix:@"Destination" text:_object.destination order:order++]; + if (_object.etaMonth > 0 && _object.etaDay > 0) + [self addRow:rows key:@"eta" prefix:@"ETA" text:[NSString stringWithFormat:@"%02ld.%02ld. %02ld:%02ld", (long)_object.etaDay, (long)_object.etaMonth, (long)_object.etaHour, (long)_object.etaMinute] order:order++]; + } + + [self addRow:rows key:@"last_update" prefix:@"Last Update" text:[self formatLastUpdate] order:order++]; + if (_object.messageTypesString.length > 0) + [self addRow:rows key:@"message_types" prefix:@"Message Type(s)" text:_object.messageTypesString order:order++]; + OAAisMenuLog(@"buildInternal rows=%lu %@", (unsigned long)rows.count, _object.debugSummary); } - (BOOL)needBuildCoordinatesRow @@ -140,14 +195,85 @@ - (void)addRow:(NSMutableArray *)rows key:(NSString *)key pr icon:nil textPrefix:prefix text:text - textColor:nil + textColor:[UIColor colorNamed:ACColorNameTextColorPrimary] isText:YES needLinks:NO order:order typeName:key isPhoneNumber:NO isUrl:NO]; + row.height = kAisRowHeight; [rows addObject:row]; + [_aisValueRowKeys addObject:key]; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + OAAmenityInfoRow *row = indexPath.row < _menuRows.count ? _menuRows[indexPath.row] : nil; + if (row.key.length > 0 && [_aisValueRowKeys containsObject:row.key]) + { + OAValueTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[OAValueTableViewCell reuseIdentifier]]; + + [cell leftIconVisibility:NO]; + [cell descriptionVisibility:NO]; + [cell valueVisibility:YES]; + [cell setupValueLabelFlexible]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.titleLabel.text = row.textPrefix; + cell.titleLabel.textColor = [UIColor colorNamed:ACColorNameTextColorPrimary]; + cell.titleLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; + cell.titleLabel.numberOfLines = 0; + cell.valueLabel.text = row.text; + cell.valueLabel.textColor = [UIColor colorNamed:ACColorNameTextColorActive]; + cell.valueLabel.font = [UIFont scaledSystemFontOfSize:16.0 weight:UIFontWeightMedium]; + cell.valueLabel.numberOfLines = 0; + cell.accessibilityLabel = row.textPrefix; + cell.accessibilityValue = row.text; + return cell; + } + return [super tableView:tableView cellForRowAtIndexPath:indexPath]; +} + +- (NSInteger)addCourseRows:(NSMutableArray *)rows + order:(NSInteger)order + includeHeading:(BOOL)includeHeading + includeNavStatus:(BOOL)includeNavStatus +{ + if (includeNavStatus && _object.navStatus != 15) + [self addRow:rows key:@"nav_status" prefix:@"Navigation Status" text:_object.navStatusString.upperCase order:order++]; + if (_object.cog != 360.0) + [self addRow:rows key:@"cog" prefix:@"COG" text:[NSString stringWithFormat:@"%.0f", _object.cog] order:order++]; + if (_object.sog != 1023.0) + [self addRow:rows key:@"sog" prefix:@"SOG" text:[NSString stringWithFormat:@"%.1f KTS", _object.sog] order:order++]; + if (includeHeading && _object.heading != 511) + [self addRow:rows key:@"heading" prefix:@"Heading" text:[NSString stringWithFormat:@"%ld", (long)_object.heading] order:order++]; + if (includeHeading && _object.rot != 128.0) + [self addRow:rows key:@"rot" prefix:@"Rate of Turn" text:[NSString stringWithFormat:@"%.1f", _object.rot] order:order++]; + return order; +} + +- (NSInteger)addDimensionRows:(NSMutableArray *)rows order:(NSInteger)order +{ + NSInteger length = _object.dimensionToBow + _object.dimensionToStern; + NSInteger width = _object.dimensionToPort + _object.dimensionToStarboard; + if (length > 0 && width > 0) + [self addRow:rows key:@"dimensions" prefix:@"Dimension" text:[NSString stringWithFormat:@"%ldm x %ldm", (long)length, (long)width] order:order++]; + return order; +} + +- (NSString *)formatPosition +{ + NSString *lat = [OALocationConvert convertLatitude:_object.latitude outputType:FORMAT_MINUTES addCardinalDirection:YES]; + NSString *lon = [OALocationConvert convertLongitude:_object.longitude outputType:FORMAT_MINUTES addCardinalDirection:YES]; + return [NSString stringWithFormat:@"%@, %@", lat, lon]; +} + +- (NSString *)formatLastUpdate +{ + NSInteger seconds = MAX(0, (NSInteger)round(-[_object.lastUpdate timeIntervalSinceNow])); + if (seconds > 60) + return [NSString stringWithFormat:@"%ld min %ld sec", (long)(seconds / 60), (long)(seconds % 60)]; + return [NSString stringWithFormat:@"%ld sec", (long)seconds]; } - (NSString *)objectTypeName:(AisObjType)type diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm index a8ac0c853e..91765508dc 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm @@ -5,6 +5,7 @@ #import "OATargetPoint.h" #import "OAPointDescription.h" #import "Localization.h" +#import "OAAppSettings.h" #import "OsmAnd_Maps-Swift.h" #include @@ -18,93 +19,167 @@ #define kAisTrackerLayerId @"ais_tracker_layer" static const int kAisTrackerStartZoom = 6; +static const CGFloat kAisBaseIconSize = 48.0; +static const CGFloat kAisDirectionLineStartIconFactor = 0.42; static int kAisIconKeyStorage; static const OsmAnd::MapMarker::OnSurfaceIconKey kAisIconKey = &kAisIconKeyStorage; -@interface OAAisObjectRenderData : NSObject +#ifdef DEBUG +#define OAAisLayerLog(format, ...) NSLog((@"[AIS][Layer] " format), ##__VA_ARGS__) +#else +#define OAAisLayerLog(format, ...) +#endif + +static NSString *OAAisObjectTitle(AisObject *object) +{ + return [NSString stringWithFormat:OALocalizedString(@"ais_object_with_mmsi"), (long)object.mmsi]; +} + +@interface AisObjectDrawable : NSObject @property (nonatomic) AisObject *object; +@property (nonatomic, copy) NSString *renderKey; - (instancetype)initWithObject:(AisObject *)object; -- (BOOL)hasRenderData; -- (void)createRenderDataWithBaseOrder:(int)baseOrder - markersCollection:(const std::shared_ptr &)markersCollection - vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection; -- (void)updateRenderDataWithMapView:(OAMapRendererView *)mapView - plugin:(OAAisTrackerPlugin *)plugin; -- (void)clearRenderDataFromMarkersCollection:(const std::shared_ptr &)markersCollection - vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection; +- (instancetype)initWithObject:(AisObject *)object + textScale:(CGFloat)textScale + displayDensityFactor:(CGFloat)displayDensityFactor; +- (void)set:(AisObject *)object; +- (void)setTextScale:(CGFloat)textScale + displayDensityFactor:(CGFloat)displayDensityFactor; +- (BOOL)hasAisRenderData; +- (NSString *)currentRenderKey; +- (int)renderGroupId; +- (OsmAnd::PointI)markerLocation; +- (void)createAisRenderDataWithBaseOrder:(int)baseOrder + markersCollection:(const std::shared_ptr &)markersCollection + vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection; +- (void)updateAisRenderDataWithMapView:(OAMapRendererView *)mapView + plugin:(OAAisTrackerPlugin *)plugin; +- (void)clearAisRenderDataFromMarkersCollection:(const std::shared_ptr &)markersCollection + vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection; @end -@implementation OAAisObjectRenderData +@implementation AisObjectDrawable { std::shared_ptr _activeMarker; std::shared_ptr _restMarker; std::shared_ptr _lostMarker; std::shared_ptr _directionLine; + CGFloat _textScale; + CGFloat _displayDensityFactor; +} + +- (instancetype)initWithObject:(AisObject *)object +{ + return [self initWithObject:object textScale:1.0 displayDensityFactor:UIScreen.mainScreen.scale]; } - (instancetype)initWithObject:(AisObject *)object + textScale:(CGFloat)textScale + displayDensityFactor:(CGFloat)displayDensityFactor { self = [super init]; if (self) + { _object = object; + [self setTextScale:textScale displayDensityFactor:displayDensityFactor]; + } return self; } -- (BOOL)hasRenderData +- (void)set:(AisObject *)object +{ + _object = object; +} + +- (void)setTextScale:(CGFloat)textScale + displayDensityFactor:(CGFloat)displayDensityFactor +{ + _textScale = textScale > 0 ? textScale : 1.0; + _displayDensityFactor = MAX(1.0, displayDensityFactor); +} + +- (BOOL)hasAisRenderData { return _activeMarker && _restMarker && _lostMarker && _directionLine; } -- (void)createRenderDataWithBaseOrder:(int)baseOrder - markersCollection:(const std::shared_ptr &)markersCollection - vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection +- (NSString *)currentRenderKey +{ + return [NSString stringWithFormat:@"surface-v3-%@-%d", [self iconResourceNameForType:_object.objectClass], (int)std::round([self iconSize] * 100.0)]; +} + +- (int)renderGroupId +{ + return (int)_object.mmsi; +} + +- (OsmAnd::PointI)markerLocation +{ + CLLocation *location = _object.location; + if (!location) + return OsmAnd::PointI(0, 0); + return OsmAnd::PointI(OsmAnd::Utilities::get31TileNumberX(location.coordinate.longitude), + OsmAnd::Utilities::get31TileNumberY(location.coordinate.latitude)); +} + +- (void)createAisRenderDataWithBaseOrder:(int)baseOrder + markersCollection:(const std::shared_ptr &)markersCollection + vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection { if (!markersCollection || !vectorLinesCollection) return; OsmAnd::MapMarkerBuilder markerBuilder; + OsmAnd::PointI markerLocation = [self markerLocation]; markerBuilder + .setGroupId([self renderGroupId]) + .setMarkerId(0) .setIsAccuracyCircleSupported(false) - .setBaseOrder(baseOrder + 10) + .setBaseOrder(baseOrder) .setIsHidden(true) + .setPosition(markerLocation) + .setUpdateAfterCreated(true) .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage([self iconImageForState:0])); _activeMarker = markerBuilder.buildAndAddToCollection(markersCollection); markerBuilder + .setMarkerId(1) .clearOnMapSurfaceIcons() .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage([self iconImageForState:1])); _restMarker = markerBuilder.buildAndAddToCollection(markersCollection); markerBuilder + .setMarkerId(2) .clearOnMapSurfaceIcons() .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage([self iconImageForState:2])); _lostMarker = markerBuilder.buildAndAddToCollection(markersCollection); QVector points; - points.push_back(OsmAnd::PointI(0, 0)); - points.push_back(OsmAnd::PointI(1, 1)); + points.push_back(markerLocation); + points.push_back(OsmAnd::PointI(markerLocation.x + 1, markerLocation.y + 1)); OsmAnd::VectorLineBuilder lineBuilder; lineBuilder - .setLineId(_object.mmsi) - .setBaseOrder(baseOrder + 9) + .setLineId((int)_object.mmsi) + .setBaseOrder(baseOrder + 10) .setIsHidden(true) - .setLineWidth(6.0f) + .setLineWidth(6.0) .setApproximationEnabled(false) .setFillColor(OsmAnd::FColorARGB(1.0f, 0.0f, 0.0f, 0.0f)) .setPoints(points); _directionLine = lineBuilder.buildAndAddToCollection(vectorLinesCollection); - [self updateRenderDataWithMapView:nil plugin:nil]; + _renderKey = [self currentRenderKey]; + [self updateAisRenderDataWithMapView:nil plugin:nil]; } -- (void)updateRenderDataWithMapView:(OAMapRendererView *)mapView - plugin:(OAAisTrackerPlugin *)plugin +- (void)updateAisRenderDataWithMapView:(OAMapRendererView *)mapView + plugin:(OAAisTrackerPlugin *)plugin { - if (![self hasRenderData]) + if (![self hasAisRenderData]) return; const OsmAnd::ZoomLevel zoom = mapView ? mapView.zoomLevel : OsmAnd::ZoomLevel::MinZoomLevel; @@ -113,17 +188,19 @@ - (void)updateRenderDataWithMapView:(OAMapRendererView *)mapView _activeMarker->setIsHidden(true); _restMarker->setIsHidden(true); _lostMarker->setIsHidden(true); - _directionLine->setIsHidden(true); + if (_directionLine) + _directionLine->setIsHidden(true); return; } - CLLocation *location = _object.currentLocation ?: _object.location; + CLLocation *location = _object.location; if (!location) { _activeMarker->setIsHidden(true); _restMarker->setIsHidden(true); _lostMarker->setIsHidden(true); - _directionLine->setIsHidden(true); + if (_directionLine) + _directionLine->setIsHidden(true); return; } @@ -136,10 +213,8 @@ - (void)updateRenderDataWithMapView:(OAMapRendererView *)mapView BOOL cpaWarning = plugin ? [plugin hasCpaWarningFor:_object] : NO; UIColor *uiColor = cpaWarning ? UIColor.redColor : [self colorForType:_object.objectClass]; OsmAnd::ColorARGB iconColor = [uiColor toColorARGB]; - OsmAnd::FColorARGB lineColor = [uiColor toFColorARGB]; _activeMarker->setOnSurfaceIconModulationColor(iconColor); _restMarker->setOnSurfaceIconModulationColor(iconColor); - _directionLine->setFillColor(lineColor); _activeMarker->setIsHidden(vesselAtRest || lostTimeout); _restMarker->setIsHidden(!vesselAtRest); @@ -151,34 +226,39 @@ - (void)updateRenderDataWithMapView:(OAMapRendererView *)mapView _activeMarker->setOnMapSurfaceIconDirection(kAisIconKey, rotation); _lostMarker->setOnMapSurfaceIconDirection(kAisIconKey, rotation); } - - OsmAnd::PointI markerLocation(OsmAnd::Utilities::get31TileNumberX(location.coordinate.longitude), - OsmAnd::Utilities::get31TileNumberY(location.coordinate.latitude)); + OsmAnd::PointI markerLocation = [self markerLocation]; _activeMarker->setPosition(markerLocation); _restMarker->setPosition(markerLocation); _lostMarker->setPosition(markerLocation); - if (drawDirectionLine) + if (drawDirectionLine && _directionLine) { - int inverseZoom = (int)OsmAnd::ZoomLevel::MaxZoomLevel - (int)zoom; - double lineLength = speedFactor * std::pow(2.0, inverseZoom) * 34.0 * 0.75; + int inverseZoom = (int)mapView.maxZoom - (int)zoom; + double zoomFactor = std::pow(2.0, inverseZoom); + CGFloat iconSize = [self iconSize]; + double lineLength = speedFactor * zoomFactor * iconSize * 0.75; + double lineStartOffset = std::min(lineLength * 0.8, zoomFactor * iconSize * kAisDirectionLineStartIconFactor); double theta = rotation * M_PI / 180.0; + int startDx = (int)ceil(-sin(theta) * lineStartOffset); + int startDy = (int)ceil(cos(theta) * lineStartOffset); int dx = (int)ceil(-sin(theta) * lineLength); int dy = (int)ceil(cos(theta) * lineLength); QVector points; - points.push_back(markerLocation); + points.push_back(OsmAnd::PointI(markerLocation.x + startDx, markerLocation.y + startDy)); points.push_back(OsmAnd::PointI(markerLocation.x + dx, markerLocation.y + dy)); _directionLine->setPoints(points); } - _directionLine->setIsHidden(!drawDirectionLine); + if (_directionLine) + _directionLine->setIsHidden(!drawDirectionLine); } -- (void)clearRenderDataFromMarkersCollection:(const std::shared_ptr &)markersCollection - vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection +- (void)clearAisRenderDataFromMarkersCollection:(const std::shared_ptr &)markersCollection + vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection { if (markersCollection) { + markersCollection->removeMarkersByGroupId([self renderGroupId]); if (_activeMarker) markersCollection->removeMarker(_activeMarker); if (_restMarker) @@ -187,36 +267,64 @@ - (void)clearRenderDataFromMarkersCollection:(const std::shared_ptrremoveMarker(_lostMarker); } if (vectorLinesCollection && _directionLine) + { + _directionLine->setIsHidden(true); vectorLinesCollection->removeLine(_directionLine); + } _activeMarker.reset(); _restMarker.reset(); _lostMarker.reset(); _directionLine.reset(); + _renderKey = nil; } - (sk_sp)iconImageForState:(NSInteger)state { - CGFloat scale = UIScreen.mainScreen.scale; - CGSize size = CGSizeMake(34.0, 34.0); - UIGraphicsBeginImageContextWithOptions(size, NO, scale); - CGRect bounds = CGRectInset(CGRectMake(0, 0, size.width, size.height), 4, 4); + CGFloat iconSize = [self iconSize]; + if (state != 1) + { + NSString *resourceName = state == 2 ? @"c_mx_ais_vessel_cross" : [self iconResourceNameForType:_object.objectClass]; + sk_sp image = [OANativeUtilities skImageFromSvgResource:resourceName width:iconSize height:iconSize]; + if (image) + return image; + } + + CGSize size = CGSizeMake(iconSize, iconSize); + UIGraphicsBeginImageContextWithOptions(size, NO, 1.0); + CGFloat sizeFactor = iconSize / 72.0; + CGRect bounds = CGRectInset(CGRectMake(0, 0, size.width, size.height), 6 * sizeFactor, 6 * sizeFactor); + + UIColor *baseColor = state == 2 + ? [UIColor colorWithWhite:0.75 alpha:1.0] + : UIColor.whiteColor; + UIColor *strokeColor = state == 2 + ? [UIColor colorWithWhite:0.47 alpha:1.0] + : [UIColor colorWithWhite:0.37 alpha:1.0]; UIBezierPath *path; if (state == 1) { - path = [UIBezierPath bezierPathWithOvalInRect:bounds]; + UIBezierPath *outer = [UIBezierPath bezierPathWithOvalInRect:CGRectInset(bounds, 1, 1)]; + [[UIColor darkGrayColor] setFill]; + [outer fill]; + path = [UIBezierPath bezierPathWithOvalInRect:CGRectInset(bounds, 4 * sizeFactor, 4 * sizeFactor)]; } else if (_object.objectClass == AisObjTypeAton || _object.objectClass == AisObjTypeAtonVirtual) { - path = [UIBezierPath bezierPathWithOvalInRect:bounds]; + path = [UIBezierPath bezierPath]; + [path moveToPoint:CGPointMake(CGRectGetMidX(bounds), CGRectGetMinY(bounds))]; + [path addLineToPoint:CGPointMake(CGRectGetMaxX(bounds), CGRectGetMidY(bounds))]; + [path addLineToPoint:CGPointMake(CGRectGetMidX(bounds), CGRectGetMaxY(bounds))]; + [path addLineToPoint:CGPointMake(CGRectGetMinX(bounds), CGRectGetMidY(bounds))]; + [path closePath]; } else if ([_object isMovable]) { path = [UIBezierPath bezierPath]; [path moveToPoint:CGPointMake(CGRectGetMidX(bounds), CGRectGetMinY(bounds))]; [path addLineToPoint:CGPointMake(CGRectGetMaxX(bounds), CGRectGetMaxY(bounds))]; - [path addLineToPoint:CGPointMake(CGRectGetMidX(bounds), CGRectGetMaxY(bounds) - 6)]; + [path addLineToPoint:CGPointMake(CGRectGetMidX(bounds), CGRectGetMaxY(bounds) - 9 * sizeFactor)]; [path addLineToPoint:CGPointMake(CGRectGetMinX(bounds), CGRectGetMaxY(bounds))]; [path closePath]; } @@ -225,21 +333,33 @@ - (void)clearRenderDataFromMarkersCollection:(const std::shared_ptr *_objectRenderData; + NSMutableDictionary *_objectDrawables; std::shared_ptr _markersCollection; std::shared_ptr _vectorLinesCollection; id _objectsObserver; + id _objectReceivedObserver; + id _objectRemovedObserver; BOOL _collectionsAdded; + CGFloat _textScale; + CGFloat _displayDensityFactor; } - (instancetype)initWithMapViewController:(OAMapViewController *)mapViewController baseOrder:(int)baseOrder @@ -309,7 +451,9 @@ - (instancetype)initWithMapViewController:(OAMapViewController *)mapViewControll if (self) { _plugin = (OAAisTrackerPlugin *)[OAPluginsHelper getPlugin:OAAisTrackerPlugin.class]; - _objectRenderData = [NSMutableDictionary dictionary]; + _objectDrawables = [NSMutableDictionary dictionary]; + _textScale = [OAAisTrackerLayer currentTextScale]; + _displayDensityFactor = MAX(1.0, mapViewController.displayDensityFactor); } return self; } @@ -319,9 +463,56 @@ - (NSString *)layerId return kAisTrackerLayerId; } +- (OAAisTrackerPlugin *)plugin +{ + if (!_plugin) + _plugin = (OAAisTrackerPlugin *)[OAPluginsHelper getPlugin:OAAisTrackerPlugin.class]; + return _plugin; +} + +- (void)ensureObjectDrawables +{ + if (!_objectDrawables) + _objectDrawables = [NSMutableDictionary dictionary]; +} + ++ (CGFloat)currentTextScale +{ + CGFloat textScale = [[OAAppSettings sharedManager].textSize get]; + return textScale > 0 ? textScale : 1.0; +} + +- (CGFloat)currentDisplayDensityFactor +{ + CGFloat displayDensityFactor = self.mapViewController.displayDensityFactor; + if (displayDensityFactor <= 0) + displayDensityFactor = UIScreen.mainScreen.scale; + return MAX(1.0, displayDensityFactor); +} + +- (CGFloat)currentIconSize +{ + return kAisBaseIconSize * _textScale * _displayDensityFactor; +} + +- (BOOL)updateScaleCache +{ + CGFloat textScale = [OAAisTrackerLayer currentTextScale]; + CGFloat displayDensityFactor = [self currentDisplayDensityFactor]; + BOOL changed = fabs(_textScale - textScale) > 0.0001 || fabs(_displayDensityFactor - displayDensityFactor) > 0.0001; + if (changed) + { + _textScale = textScale; + _displayDensityFactor = displayDensityFactor; + } + return changed; +} + - (void)initLayer { [super initLayer]; + [self ensureObjectDrawables]; + [self updateScaleCache]; [self resetCollections]; [self.app.data.mapLayersConfiguration setLayer:self.layerId Visibility:self.isVisible]; @@ -331,7 +522,43 @@ - (void)initLayer object:nil queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification * _Nonnull note) { - [weakSelf reloadObjects]; + OAAisTrackerLayer *strongSelf = weakSelf; + if (!strongSelf) + return; + if ([note.object isKindOfClass:OAAisTrackerPlugin.class]) + strongSelf->_plugin = note.object; + [strongSelf cleanupResources]; + if ([strongSelf isVisible]) + { + [strongSelf addCollectionsToRenderer]; + [strongSelf reloadObjects]; + } + }]; + _objectReceivedObserver = [NSNotificationCenter.defaultCenter addObserverForName:@"OAAisObjectReceived" + object:nil + queue:NSOperationQueue.mainQueue + usingBlock:^(NSNotification * _Nonnull note) { + OAAisTrackerLayer *strongSelf = weakSelf; + if (!strongSelf) + return; + if ([note.object isKindOfClass:OAAisTrackerPlugin.class]) + strongSelf->_plugin = note.object; + AisObject *object = note.userInfo[@"object"]; + if ([object isKindOfClass:AisObject.class]) + [strongSelf onAisObjectReceived:object]; + }]; + _objectRemovedObserver = [NSNotificationCenter.defaultCenter addObserverForName:@"OAAisObjectRemoved" + object:nil + queue:NSOperationQueue.mainQueue + usingBlock:^(NSNotification * _Nonnull note) { + OAAisTrackerLayer *strongSelf = weakSelf; + if (!strongSelf) + return; + if ([note.object isKindOfClass:OAAisTrackerPlugin.class]) + strongSelf->_plugin = note.object; + AisObject *object = note.userInfo[@"object"]; + if ([object isKindOfClass:AisObject.class]) + [strongSelf onAisObjectRemoved:object]; }]; } @@ -342,13 +569,23 @@ - (void)deinitLayer [NSNotificationCenter.defaultCenter removeObserver:_objectsObserver]; _objectsObserver = nil; } + if (_objectReceivedObserver) + { + [NSNotificationCenter.defaultCenter removeObserver:_objectReceivedObserver]; + _objectReceivedObserver = nil; + } + if (_objectRemovedObserver) + { + [NSNotificationCenter.defaultCenter removeObserver:_objectRemovedObserver]; + _objectRemovedObserver = nil; + } [self cleanupResources]; [super deinitLayer]; } - (BOOL)isVisible { - return [_plugin isEnabled]; + return [[self plugin] isActiveForCurrentProfile]; } - (void)show @@ -367,6 +604,13 @@ - (BOOL)updateLayer if (![super updateLayer]) return NO; + BOOL scaleChanged = [self updateScaleCache]; + if (scaleChanged) + { + OAAisLayerLog(@"scale changed textScale=%.2f density=%.2f iconSize=%.1f", _textScale, _displayDensityFactor, [self currentIconSize]); + [self cleanupResources]; + } + [self.app.data.mapLayersConfiguration setLayer:self.layerId Visibility:self.isVisible]; if ([self isVisible]) @@ -383,6 +627,11 @@ - (BOOL)updateLayer - (void)onMapFrameRendered { + if (![self isVisible]) + { + [self removeCollectionsFromRenderer]; + return; + } [self updateRenderData]; } @@ -422,12 +671,21 @@ - (void)removeCollectionsFromRenderer - (void)cleanupResources { - [self removeCollectionsFromRenderer]; - if (_markersCollection) - _markersCollection->removeAllMarkers(); - if (_vectorLinesCollection) - _vectorLinesCollection->removeAllLines(); - [_objectRenderData removeAllObjects]; + [self.mapViewController runWithRenderSync:^{ + if (_markersCollection) + _markersCollection->removeAllMarkers(); + if (_vectorLinesCollection) + _vectorLinesCollection->removeAllLines(); + if (_collectionsAdded) + { + if (_markersCollection) + [self.mapView removeKeyedSymbolsProvider:_markersCollection]; + if (_vectorLinesCollection) + [self.mapView removeKeyedSymbolsProvider:_vectorLinesCollection]; + _collectionsAdded = NO; + } + }]; + [_objectDrawables removeAllObjects]; [self resetCollections]; } @@ -443,7 +701,10 @@ - (void)reloadObjects - (void)reloadObjectsSync { - NSArray *objects = [_plugin getAisObjects]; + [self ensureObjectDrawables]; + [self updateScaleCache]; + OAAisTrackerPlugin *plugin = [self plugin]; + NSArray *objects = [plugin getAisObjects]; NSMutableSet *visibleMmsi = [NSMutableSet set]; for (AisObject *object in objects) { @@ -452,26 +713,85 @@ - (void)reloadObjectsSync NSNumber *key = @(object.mmsi); [visibleMmsi addObject:key]; - OAAisObjectRenderData *renderData = _objectRenderData[key]; - if (!renderData) + AisObjectDrawable *drawable = _objectDrawables[key]; + if (!drawable) { - renderData = [[OAAisObjectRenderData alloc] initWithObject:object]; - _objectRenderData[key] = renderData; + drawable = [[AisObjectDrawable alloc] initWithObject:object textScale:_textScale displayDensityFactor:_displayDensityFactor]; + _objectDrawables[key] = drawable; } - renderData.object = object; - if (![renderData hasRenderData]) - [renderData createRenderDataWithBaseOrder:self.baseOrder markersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; - [renderData updateRenderDataWithMapView:self.mapView plugin:_plugin]; + [drawable setTextScale:_textScale displayDensityFactor:_displayDensityFactor]; + [drawable set:object]; + if ([drawable hasAisRenderData] && ![drawable.renderKey isEqualToString:[drawable currentRenderKey]]) + [drawable clearAisRenderDataFromMarkersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; + if (![drawable hasAisRenderData]) + [drawable createAisRenderDataWithBaseOrder:self.baseOrder markersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; + [drawable updateAisRenderDataWithMapView:self.mapView plugin:plugin]; } - for (NSNumber *key in [_objectRenderData.allKeys copy]) + for (NSNumber *key in [_objectDrawables.allKeys copy]) { if (![visibleMmsi containsObject:key]) { - [_objectRenderData[key] clearRenderDataFromMarkersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; - [_objectRenderData removeObjectForKey:key]; + [_objectDrawables[key] clearAisRenderDataFromMarkersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; + [_objectDrawables removeObjectForKey:key]; } } + [plugin updateSimulationRenderedObjects:_objectDrawables.count]; +} + +- (void)onAisObjectReceived:(AisObject *)object +{ + if (![self isVisible] || !object.hasPosition) + return; + + OAAisLayerLog(@"receive %@", object.debugSummary); + [self addCollectionsToRenderer]; + [self.mapViewController runWithRenderSync:^{ + [self updateAisObjectSync:object]; + }]; +} + +- (void)onAisObjectRemoved:(AisObject *)object +{ + if (!object) + return; + + [self.mapViewController runWithRenderSync:^{ + NSNumber *key = @(object.mmsi); + AisObjectDrawable *drawable = _objectDrawables[key]; + OAAisLayerLog(@"remove hasDrawable=%@ drawables=%lu %@", drawable ? @"yes" : @"no", (unsigned long)_objectDrawables.count, object.debugSummary); + if (drawable) + { + [drawable clearAisRenderDataFromMarkersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; + [_objectDrawables removeObjectForKey:key]; + } + [[self plugin] updateSimulationRenderedObjects:_objectDrawables.count]; + }]; +} + +- (void)updateAisObjectSync:(AisObject *)object +{ + [self ensureObjectDrawables]; + [self updateScaleCache]; + NSNumber *key = @(object.mmsi); + AisObjectDrawable *drawable = _objectDrawables[key]; + if (!drawable) + { + drawable = [[AisObjectDrawable alloc] initWithObject:object textScale:_textScale displayDensityFactor:_displayDensityFactor]; + _objectDrawables[key] = drawable; + } + [drawable setTextScale:_textScale displayDensityFactor:_displayDensityFactor]; + [drawable set:object]; + BOOL recreated = [drawable hasAisRenderData] && ![drawable.renderKey isEqualToString:[drawable currentRenderKey]]; + if (recreated) + [drawable clearAisRenderDataFromMarkersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; + if (![drawable hasAisRenderData]) + [drawable createAisRenderDataWithBaseOrder:self.baseOrder markersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; + [drawable updateAisRenderDataWithMapView:self.mapView plugin:[self plugin]]; + int groupMarkers = _markersCollection ? _markersCollection->getMarkersCountByGroupId((int)object.mmsi) : 0; + int linesCount = _vectorLinesCollection ? _vectorLinesCollection->getLinesCount() : 0; + OAAisLayerLog(@"update recreated=%@ drawables=%lu groupMarkers=%d lines=%d %@", recreated ? @"yes" : @"no", (unsigned long)_objectDrawables.count, groupMarkers, linesCount, object.debugSummary); + [[self plugin] updateSimulationRenderedObjects:_objectDrawables.count]; } - (void)updateRenderData @@ -479,8 +799,18 @@ - (void)updateRenderData if (![self isVisible]) return; - for (NSNumber *key in _objectRenderData) - [_objectRenderData[key] updateRenderDataWithMapView:self.mapView plugin:_plugin]; + if ([self updateScaleCache]) + { + OAAisLayerLog(@"scale changed textScale=%.2f density=%.2f iconSize=%.1f", _textScale, _displayDensityFactor, [self currentIconSize]); + [self cleanupResources]; + [self addCollectionsToRenderer]; + [self reloadObjects]; + return; + } + + OAAisTrackerPlugin *plugin = [self plugin]; + for (NSNumber *key in _objectDrawables) + [_objectDrawables[key] updateAisRenderDataWithMapView:self.mapView plugin:plugin]; } #pragma mark - OAContextMenuProvider @@ -491,15 +821,17 @@ - (OATargetPoint *)getTargetPoint:(id)obj touchLocation:(CLLocation *)touchLocat return nil; AisObject *object = obj; - CLLocation *location = object.currentLocation ?: object.location; + CLLocation *location = object.location; if (!location) return nil; OATargetPoint *targetPoint = [[OATargetPoint alloc] init]; targetPoint.type = OATargetAisObject; targetPoint.targetObj = object; - targetPoint.title = object.title; - targetPoint.titleSecond = [self objectTypeName:object.objectClass]; + targetPoint.title = OAAisObjectTitle(object); + targetPoint.titleSecond = nil; + targetPoint.titleAddress = object.navStatusString.length > 0 ? object.navStatusString : nil; + targetPoint.shouldFetchAddress = NO; targetPoint.location = location.coordinate; targetPoint.icon = [UIImage imageNamed:@"ic_plugin_nautical"]; targetPoint.sortIndex = OATargetAisObject; @@ -522,7 +854,7 @@ - (CLLocation *)getObjectLocation:(id)obj if (![obj isKindOfClass:AisObject.class] || !((AisObject *)obj).hasPosition) return nil; AisObject *object = obj; - return object.currentLocation ?: object.location; + return object.location; } - (OAPointDescription *)getObjectName:(id)obj @@ -530,7 +862,7 @@ - (OAPointDescription *)getObjectName:(id)obj if (![obj isKindOfClass:AisObject.class]) return nil; AisObject *object = obj; - return [[OAPointDescription alloc] initWithType:POINT_TYPE_LOCATION typeName:OALocalizedString(@"plugin_ais_tracker_name") name:object.title]; + return [[OAPointDescription alloc] initWithType:POINT_TYPE_LOCATION typeName:OALocalizedString(@"ais_type_object") name:OAAisObjectTitle(object)]; } - (BOOL)showMenuAction:(id)object @@ -554,7 +886,9 @@ - (void)collectObjectsFromPoint:(MapSelectionResult *)result unknownLocation:(BO return; CGPoint point = result.point; - int radius = MAX(28, (int)([self getScaledTouchRadius:[self getDefaultRadiusPoi]] * TOUCH_RADIUS_MULTIPLIER)); + [self updateScaleCache]; + int iconRadius = (int)ceil([self currentIconSize] * 0.55); + int radius = MAX(iconRadius, (int)([self getScaledTouchRadius:[self getDefaultRadiusPoi]] * TOUCH_RADIUS_MULTIPLIER)); QList touchPolygon31 = [OANativeUtilities getPolygon31FromScreenAreaLeft:point.x - radius top:point.y - radius @@ -563,17 +897,25 @@ - (void)collectObjectsFromPoint:(MapSelectionResult *)result unknownLocation:(BO if (touchPolygon31.isEmpty()) return; - for (AisObject *object in [_plugin getAisObjects]) + NSArray *objects = [[self plugin] getAisObjects]; + BOOL collected = NO; + for (AisObject *object in objects) { - CLLocation *location = object.currentLocation ?: object.location; + CLLocation *location = object.location; if (!location) continue; if ([OANativeUtilities isPointInsidePolygonLat:location.coordinate.latitude lon:location.coordinate.longitude polygon31:touchPolygon31]) + { [result collect:object provider:self]; + collected = YES; + OAAisLayerLog(@"hit-test collect radius=%d %@", radius, object.debugSummary); + } } + if (!collected) + OAAisLayerLog(@"hit-test miss radius=%d objects=%lu point=(%.1f, %.1f)", radius, (unsigned long)objects.count, point.x, point.y); } - (NSString *)objectTypeName:(AisObjType)type diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift index f5b1d47ff6..20960eecd8 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift @@ -33,6 +33,11 @@ final class OAAisTrackerPlugin: OAPlugin { private(set) var fakeOwnLocation: CLLocation? private(set) var simulationFileName: String? private(set) var simulationStatusText: String? + private var simulationSentences = 0 + private var simulationDecoded = 0 + private var simulationObjects = 0 + private var simulationReceivedObjects = 0 + private var simulationRenderedObjects = 0 private(set) var lastMessageReceived = Date.distantPast override init() { @@ -87,12 +92,16 @@ final class OAAisTrackerPlugin: OAPlugin { override func setEnabled(_ enabled: Bool) { super.setEnabled(enabled) - enabled ? restartConnection() : connection.stop() + if enabled { + restartConnection() + } else { + connection.stop() + } } override func updateLayers() { DispatchQueue.main.async { - OsmAndApp.swiftInstance().data.mapLayersConfiguration.setLayer("ais_tracker_layer", visibility: self.isEnabled()) + OsmAndApp.swiftInstance().data.mapLayersConfiguration.setLayer("ais_tracker_layer", visibility: self.isActiveForCurrentProfile()) OARootViewController.instance().mapPanel.mapViewController.updateLayer("ais_tracker_layer") } } @@ -110,18 +119,42 @@ final class OAAisTrackerPlugin: OAPlugin { simulationProvider } + func isActiveForCurrentProfile() -> Bool { + isEnabled() && OAAppSettings.sharedManager().applicationMode.get().isDerivedRouting(from: .boat()) + } + func startAisSimulation(_ fileURL: URL) { simulationFileName = fileURL.lastPathComponent + simulationSentences = 0 + simulationDecoded = 0 + simulationObjects = 0 + simulationReceivedObjects = 0 + simulationRenderedObjects = 0 simulationStatusText = localizedString("shared_string_loading") + aisDebugLog("simulation start file=\(fileURL.lastPathComponent)") simulationProvider.startAisSimulation(fileURL) } func updateSimulationStatus(sentences: Int, decoded: Int, objects: Int, error: String?) { if let error, !error.isEmpty { simulationStatusText = error + aisDebugLog("simulation status error=\(error)") } else { - simulationStatusText = "sentences \(sentences), decoded \(decoded), objects \(objects)" + simulationSentences = sentences + simulationDecoded = decoded + simulationObjects = objects + updateSimulationStatusText() + aisDebugLog("simulation stats sentences=\(sentences) decoded=\(decoded) objects=\(objects)") } + postSimulationStatusChanged() + } + + func updateSimulationRenderedObjects(_ count: Int) { + guard simulationFileName != nil else { return } + guard simulationRenderedObjects != count else { return } + simulationRenderedObjects = count + updateSimulationStatusText() + postSimulationStatusChanged() } func prepareAisSimulation() { @@ -141,9 +174,15 @@ final class OAAisTrackerPlugin: OAPlugin { func clearSimulationObjects() { simulationProvider.stopAisSimulation() + aisDebugLog("simulation clear") fakeOwnLocation = nil simulationFileName = nil simulationStatusText = nil + simulationSentences = 0 + simulationDecoded = 0 + simulationObjects = 0 + simulationReceivedObjects = 0 + simulationRenderedObjects = 0 aisDataManager.cleanupResources() } @@ -212,10 +251,20 @@ final class OAAisTrackerPlugin: OAPlugin { func onAisObjectReceived(_ object: AisObject) { lastMessageReceived = object.lastUpdate + if simulationFileName != nil { + let receivedObjects = getAisObjects().filter(\.hasPosition).count + if simulationReceivedObjects != receivedObjects { + simulationReceivedObjects = receivedObjects + updateSimulationStatusText() + postSimulationStatusChanged() + } + } + aisDebugLog("plugin received withPosition=\(getAisObjects().filter(\.hasPosition).count) \(object.debugSummary)") NotificationCenter.default.post(name: .aisObjectReceived, object: self, userInfo: ["object": object]) } func onAisObjectRemoved(_ object: AisObject) { + aisDebugLog("plugin removed \(object.debugSummary)") NotificationCenter.default.post(name: .aisObjectRemoved, object: self, userInfo: ["object": object]) } @@ -299,6 +348,23 @@ final class OAAisTrackerPlugin: OAPlugin { aisDataManager.onAisObjectReceived(object) } + private func updateSimulationStatusText() { + var parts = [ + "sentences \(simulationSentences)", + "decoded \(simulationDecoded)", + "objects \(simulationObjects)" + ] + if simulationReceivedObjects > 0 || simulationRenderedObjects > 0 { + parts.append("received \(simulationReceivedObjects)") + parts.append("rendered \(simulationRenderedObjects)") + } + simulationStatusText = parts.joined(separator: ", ") + } + + private func postSimulationStatusChanged() { + NotificationCenter.default.post(name: .aisSimulationStatusChanged, object: self) + } + private static func bearing(from start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D) -> Double { let lat1 = start.latitude * .pi / 180 let lat2 = end.latitude * .pi / 180 From 13b41c3be7ccfb93b57d6cfe2dc9cf409e3b6015 Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Thu, 11 Jun 2026 10:33:32 +0300 Subject: [PATCH 03/18] grooming --- .../AisTrackerPlugin/AisDataManager.swift | 9 ++- .../AisTrackerPlugin/AisMessageDecoder.swift | 29 +++++++- .../AisTrackerPlugin/AisNmeaConnection.swift | 18 ++++- .../AisSimulationProvider.swift | 2 +- .../AisTrackerSettingsViewController.swift | 21 ++++-- .../AisTrackerPlugin/OAAisTrackerLayer.mm | 29 ++++---- .../AisTrackerPlugin/OAAisTrackerPlugin.swift | 71 ++++++++++++++++--- 7 files changed, 138 insertions(+), 41 deletions(-) diff --git a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift index c854fe24f5..a60f4fb70c 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift @@ -1,4 +1,3 @@ -import Foundation extension Notification.Name { static let aisObjectReceived = Notification.Name("OAAisObjectReceived") @@ -15,15 +14,15 @@ final class AisDataManager: NSObject { private var objectsByMmsi: [Int: AisObject] = [:] private var cleanupTimer: Timer? + var objects: [AisObject] { + Array(objectsByMmsi.values) + } + init(plugin: OAAisTrackerPlugin) { self.plugin = plugin super.init() } - var objects: [AisObject] { - Array(objectsByMmsi.values) - } - func startUpdates() { stopUpdates() cleanupTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in diff --git a/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift b/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift index 82586f59a6..c108879970 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift @@ -10,13 +10,28 @@ final class AisMessageDecoder { private var fragments: [String: FragmentBuffer] = [:] func decode(sentence: String) -> AisObject? { + // Remove leading/trailing whitespaces and newlines. let trimmed = sentence.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("!AI") || trimmed.hasPrefix("!BS") else { return nil } - let noChecksum = trimmed.split(separator: "*", maxSplits: 1).first.map(String.init) ?? trimmed + + // Strip an optional NMEA TAG block, e.g. `\s:2573267,c:1781087503*0A\!BSVDM...`. + let cleanSentence: String + if let lastBackslashIndex = trimmed.lastIndex(of: "\\") { + cleanSentence = String(trimmed[trimmed.index(after: lastBackslashIndex)...]) + } else { + cleanSentence = trimmed + } + + // !AIVDM is a mobile AIS station; !BSVDM is a base AIS station. + guard cleanSentence.hasPrefix("!AI") || cleanSentence.hasPrefix("!BS") else { return nil } + + let noChecksum = cleanSentence.split(separator: "*", maxSplits: 1).first.map(String.init) ?? cleanSentence + let fields = noChecksum.split(separator: ",", omittingEmptySubsequences: false).map(String.init) guard fields.count >= 7 else { return nil } + let talker = fields[0] guard talker.hasSuffix("VDM") || talker.hasSuffix("VDO") else { return nil } + guard let total = Int(fields[1]), let number = Int(fields[2]) else { return nil } let sequentialId = fields[3] let channel = fields[4] @@ -28,12 +43,18 @@ final class AisMessageDecoder { if total > 1 { let key = "\(sequentialId)-\(channel)" var buffer = fragments[key] ?? FragmentBuffer(total: total, payloads: [:], fillBits: fillBits) + buffer.payloads[number] = payload buffer.fillBits = fillBits fragments[key] = buffer + guard buffer.payloads.count == total else { return nil } - completePayload = (1...total).compactMap { buffer.payloads[$0] }.joined() + + let orderedPayloads = (1...total).compactMap { buffer.payloads[$0] } + guard orderedPayloads.count == total else { return nil } + completePayload = orderedPayloads.joined() completeFillBits = buffer.fillBits + fragments.removeValue(forKey: key) } else { completePayload = payload @@ -42,7 +63,9 @@ final class AisMessageDecoder { let bits = AisBitReader(payload: completePayload) bits.dropLast(completeFillBits) + guard let msgType = bits.uint(0, 6) else { return nil } + switch msgType { case 1, 2, 3: return decodePositionReport(bits: bits, msgType: msgType) diff --git a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift index 2cd1cb3c86..059a7eca7f 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift @@ -13,7 +13,8 @@ import Network case connected case failed } -// NOTE: for test tcp 153.44.253.27 5631 + +// NOTE: for test: tcp 153.44.253.27 5631 final class AisNmeaConnection { var onLocation: ((CLLocation) -> Void)? @@ -28,6 +29,9 @@ final class AisNmeaConnection { private var shouldReconnect = false private var host = "" private var port: UInt16 = 0 + var isRunning: Bool { + listener != nil || connection != nil || shouldReconnect + } func startUDP(port: UInt16) { stop() @@ -35,7 +39,11 @@ final class AisNmeaConnection { do { let params = NWParameters.udp params.allowLocalEndpointReuse = true - let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!) + guard let endpointPort = NWEndpoint.Port(rawValue: port) else { + updateState(.failed) + return + } + let listener = try NWListener(using: params, on: endpointPort) self.listener = listener listener.newConnectionHandler = { [weak self] connection in self?.receiveDatagrams(connection) @@ -81,7 +89,11 @@ final class AisNmeaConnection { private func connectTCP() { updateState(.connecting) - let nwConnection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port)!, using: .tcp) + guard let endpointPort = NWEndpoint.Port(rawValue: port) else { + updateState(.failed) + return + } + let nwConnection = NWConnection(host: NWEndpoint.Host(host), port: endpointPort, using: .tcp) connection = nwConnection nwConnection.stateUpdateHandler = { [weak self] state in switch state { diff --git a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift index dd05816a6c..3a1d5d12aa 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift @@ -85,7 +85,7 @@ final class AisMessageSimulationListener { @objcMembers final class AisSimulationProvider: NSObject { - private static let simulatedLatency: TimeInterval = 0.1 + private static let simulatedLatency: TimeInterval = 0.01 private weak var plugin: OAAisTrackerPlugin? private var listener: AisMessageSimulationListener? diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift index 7a599d8799..d48b351d08 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift @@ -129,12 +129,14 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { chooseProtocol() case .host: editString(title: localizedString("ais_address_nmea_server"), message: descriptionText(for: row), value: plugin.hostPref.get()) { [weak self] value in + let value = value.trimmingCharacters(in: .whitespacesAndNewlines) guard self?.isValidIPv4(value) == true else { self?.showValidationError(localizedString("ais_error_ipv4_only")) - return + return false } self?.plugin.hostPref.set(value) self?.plugin.restartConnection() + return true } case .tcpPort: editPort(title: localizedString("ais_port_nmea_server"), message: descriptionText(for: row), value: Int(plugin.tcpPortPref.get())) { [weak self] value in @@ -201,7 +203,7 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { present(alert, animated: true) } - private func editString(title: String, message: String?, value: String, onSave: @escaping (String) -> Void) { + private func editString(title: String, message: String?, value: String, onSave: @escaping (String) -> Bool) { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) alert.addTextField { textField in textField.text = value @@ -210,8 +212,9 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { } alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) alert.addAction(UIAlertAction(title: localizedString("shared_string_save"), style: .default) { [weak alert, weak self] _ in - onSave(alert?.textFields?.first?.text ?? value) - self?.tableView.reloadData() + if onSave(alert?.textFields?.first?.text ?? value) { + self?.tableView.reloadData() + } }) present(alert, animated: true) } @@ -220,8 +223,10 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { editString(title: title, message: message, value: "\(value)") { text in if let port = Int(text), port >= 0, port <= 65535 { onSave(port) + return true } else { self.showValidationError(localizedString("ais_error_port_only")) + return false } } } @@ -338,9 +343,11 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { } private func showValidationError(_ message: String) { - let alert = UIAlertController(title: localizedString("shared_string_error"), message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: localizedString("shared_string_ok"), style: .default)) - present(alert, animated: true) + DispatchQueue.main.async { [weak self] in + let alert = UIAlertController(title: localizedString("shared_string_error"), message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: localizedString("shared_string_ok"), style: .default)) + self?.present(alert, animated: true) + } } @objc private func reloadStatus() { diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm index 91765508dc..c4ce13621d 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm @@ -512,7 +512,6 @@ - (void)initLayer { [super initLayer]; [self ensureObjectDrawables]; - [self updateScaleCache]; [self resetCollections]; [self.app.data.mapLayersConfiguration setLayer:self.layerId Visibility:self.isVisible]; @@ -627,6 +626,7 @@ - (BOOL)updateLayer - (void)onMapFrameRendered { + NSLog(@"onMapFrameRendered"); if (![self isVisible]) { [self removeCollectionsFromRenderer]; @@ -635,6 +635,11 @@ - (void)onMapFrameRendered [self updateRenderData]; } +//- (void)onMapFrameAnimatorsUpdated +//{ +// NSLog(@"onMapFrameAnimatorsUpdated"); +//} + - (void)resetCollections { _markersCollection = std::make_shared(); @@ -702,7 +707,6 @@ - (void)reloadObjects - (void)reloadObjectsSync { [self ensureObjectDrawables]; - [self updateScaleCache]; OAAisTrackerPlugin *plugin = [self plugin]; NSArray *objects = [plugin getAisObjects]; NSMutableSet *visibleMmsi = [NSMutableSet set]; @@ -772,7 +776,6 @@ - (void)onAisObjectRemoved:(AisObject *)object - (void)updateAisObjectSync:(AisObject *)object { [self ensureObjectDrawables]; - [self updateScaleCache]; NSNumber *key = @(object.mmsi); AisObjectDrawable *drawable = _objectDrawables[key]; if (!drawable) @@ -799,14 +802,14 @@ - (void)updateRenderData if (![self isVisible]) return; - if ([self updateScaleCache]) - { - OAAisLayerLog(@"scale changed textScale=%.2f density=%.2f iconSize=%.1f", _textScale, _displayDensityFactor, [self currentIconSize]); - [self cleanupResources]; - [self addCollectionsToRenderer]; - [self reloadObjects]; - return; - } +// if ([self updateScaleCache]) +// { +// OAAisLayerLog(@"scale changed textScale=%.2f density=%.2f iconSize=%.1f", _textScale, _displayDensityFactor, [self currentIconSize]); +// [self cleanupResources]; +// [self addCollectionsToRenderer]; +// [self reloadObjects]; +// return; +// } OAAisTrackerPlugin *plugin = [self plugin]; for (NSNumber *key in _objectDrawables) @@ -835,7 +838,7 @@ - (OATargetPoint *)getTargetPoint:(id)obj touchLocation:(CLLocation *)touchLocat targetPoint.location = location.coordinate; targetPoint.icon = [UIImage imageNamed:@"ic_plugin_nautical"]; targetPoint.sortIndex = OATargetAisObject; - targetPoint.centerMap = YES; + targetPoint.centerMap = NO; return targetPoint; } @@ -886,7 +889,7 @@ - (void)collectObjectsFromPoint:(MapSelectionResult *)result unknownLocation:(BO return; CGPoint point = result.point; - [self updateScaleCache]; + //[self updateScaleCache]; int iconRadius = (int)ceil([self currentIconSize] * 0.55); int radius = MAX(iconRadius, (int)([self getScaledTouchRadius:[self getDefaultRadiusPoi]] * TOUCH_RADIUS_MULTIPLIER)); QList touchPolygon31 = diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift index 20960eecd8..49daf78d4f 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift @@ -26,19 +26,25 @@ final class OAAisTrackerPlugin: OAPlugin { private let connection = AisNmeaConnection() private let decoder = AisMessageDecoder() + private let aisDecoderQueue = DispatchQueue(label: "com.app.ais.decoder", qos: .userInitiated) + + private var applicationModeObserver: OAAutoObserverProxy? + private lazy var simulationProvider = AisSimulationProvider(plugin: self) private lazy var aisDataManager = AisDataManager(plugin: self) + private(set) var connectionState: AisNmeaConnectionState = .disconnected private(set) var lastLocation: CLLocation? private(set) var fakeOwnLocation: CLLocation? private(set) var simulationFileName: String? private(set) var simulationStatusText: String? + private(set) var lastMessageReceived = Date.distantPast + private var simulationSentences = 0 private var simulationDecoded = 0 private var simulationObjects = 0 private var simulationReceivedObjects = 0 private var simulationRenderedObjects = 0 - private(set) var lastMessageReceived = Date.distantPast override init() { protocolPref = OAAppSettings.sharedManager().registerIntPreference(Self.protocolPrefId, defValue: Int32(AisNmeaProtocol.udp.rawValue)) @@ -62,6 +68,13 @@ final class OAAisTrackerPlugin: OAPlugin { connection.onSentence = { [weak self] sentence in self?.handleAisSentence(sentence) } + applicationModeObserver = OAAutoObserverProxy(self, + withHandler: #selector(onApplicationModeChanged), + andObserve: OsmAndApp.swiftInstance().applicationModeChangedObservable) + } + + deinit { + applicationModeObserver?.detach() } override func getId() -> String { @@ -86,16 +99,16 @@ final class OAAisTrackerPlugin: OAPlugin { override func initPlugin() -> Bool { let result = super.initPlugin() - restartConnection() + updateConnectionForCurrentProfile() return result } override func setEnabled(_ enabled: Bool) { super.setEnabled(enabled) if enabled { - restartConnection() + updateConnectionForCurrentProfile() } else { - connection.stop() + stopAisNetworkListener() } } @@ -187,7 +200,10 @@ final class OAAisTrackerPlugin: OAPlugin { } func restartConnection() { - guard isEnabled() else { return } + guard isActiveForCurrentProfile() else { + stopAisNetworkListener() + return + } aisDataManager.startUpdates() let proto = AisNmeaProtocol(rawValue: Int(protocolPref.get())) ?? .udp switch proto { @@ -203,6 +219,16 @@ final class OAAisTrackerPlugin: OAPlugin { aisDataManager.stopUpdates() } + private func updateConnectionForCurrentProfile() { + if isActiveForCurrentProfile() { + if !connection.isRunning { + restartConnection() + } + } else { + stopAisNetworkListener() + } + } + func fakeOwnPosition(_ location: CLLocation?) { fakeOwnLocation = location } @@ -225,7 +251,7 @@ final class OAAisTrackerPlugin: OAPlugin { func getAisObjects() -> [AisObject] { aisDataManager.objects } - + // FIXME: cache for objectLostTimeoutPref shipLostTimeoutPref cpaWarningTimePref cpaWarningDistancePref func maxObjectAgeInMinutes() -> Int { max(1, Int(objectLostTimeoutPref.get())) } @@ -342,10 +368,37 @@ final class OAAisTrackerPlugin: OAPlugin { OsmAndApp.swiftInstance().locationServices?.setLocationFromNMEA(location) } } - + +// private func handleAisSentence(_ sentence: String) { +// Task { +// guard let object = await decoder.decode(sentence: sentence) else { return } +// +// await MainActor.run { +// self.aisDataManager.onAisObjectReceived(object) +// } +// } +// } + +// private func handleAisSentence(_ sentence: String) { +// guard let object = decoder.decode(sentence: sentence) else { return } +// aisDataManager.onAisObjectReceived(object) +// } + private func handleAisSentence(_ sentence: String) { - guard let object = decoder.decode(sentence: sentence) else { return } - aisDataManager.onAisObjectReceived(object) + aisDecoderQueue.async { [weak self] in + guard let self else { return } + + guard let object = decoder.decode(sentence: sentence) else { return } + + DispatchQueue.main.async { + self.aisDataManager.onAisObjectReceived(object) + } + } + } + + @objc private func onApplicationModeChanged() { + updateConnectionForCurrentProfile() + // updateLayers() } private func updateSimulationStatusText() { From fdc89ac83a9dc3a61a54fcc13fd22f85bbe1bc84 Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Thu, 11 Jun 2026 13:10:47 +0300 Subject: [PATCH 04/18] add logs --- OsmAnd.xcodeproj/project.pbxproj | 94 ++-- .../OAPluginDetailsViewController.mm | 2 +- .../OAOsmandDevelopmentViewController.mm | 28 +- .../AisTrackerPlugin/AisDataManager.swift | 10 +- .../Plugins/AisTrackerPlugin/AisLogger.swift | 26 + .../AisTrackerPlugin/AisNmeaConnection.swift | 67 ++- .../Plugins/AisTrackerPlugin/AisObject.swift | 6 +- .../AisSimulationProvider.swift | 34 +- .../AisTrackerPlugin/AisTrackerPlugin.swift | 444 ++++++++++++++++++ .../AisTrackerSettingsViewController.swift | 4 +- .../OAAisObjectViewController.mm | 2 +- .../AisTrackerPlugin/OAAisTrackerLayer.mm | 174 ++++--- .../AisTrackerPlugin/OAAisTrackerPlugin.swift | 19 +- Sources/Plugins/OAPluginsHelper.mm | 2 +- 14 files changed, 770 insertions(+), 142 deletions(-) create mode 100644 Sources/Plugins/AisTrackerPlugin/AisLogger.swift create mode 100644 Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift diff --git a/OsmAnd.xcodeproj/project.pbxproj b/OsmAnd.xcodeproj/project.pbxproj index f5c1004556..acab97c20e 100644 --- a/OsmAnd.xcodeproj/project.pbxproj +++ b/OsmAnd.xcodeproj/project.pbxproj @@ -940,17 +940,6 @@ 46E9ADA128DC9FD000CC55F9 /* OAButtonTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 46E9ADA028DC9FD000CC55F9 /* OAButtonTableViewCell.m */; }; 46ED6C3E2B333B4400A5555F /* Plugin+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46ED6C3D2B333B4400A5555F /* Plugin+Extension.swift */; }; 46ED6C402B333B8100A5555F /* ExternalSensorsPlugin+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46ED6C3F2B333B8100A5555F /* ExternalSensorsPlugin+Extension.swift */; }; - 9F4844A52FD0000100484401 /* AisNmeaParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A12FD0000100484401 /* AisNmeaParser.swift */; }; - 9F4844A62FD0000100484401 /* AisNmeaConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A22FD0000100484401 /* AisNmeaConnection.swift */; }; - 9F4844A72FD0000100484401 /* OAAisTrackerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A32FD0000100484401 /* OAAisTrackerPlugin.swift */; }; - 9F4844A82FD0000100484401 /* AisTrackerSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */; }; - 9F4844AA2FD0000100484401 /* AisSimulationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */; }; - 9F4844AC2FD0000100484401 /* AisObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AB2FD0000100484401 /* AisObject.swift */; }; - 9F4844AE2FD0000100484401 /* AisDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AD2FD0000100484401 /* AisDataManager.swift */; }; - 9F4844B02FD0000100484401 /* AisMessageDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */; }; - 9F4844B32FD0000100484401 /* OAAisTrackerLayer.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */; }; - 9F4844B52FD0000100484401 /* AisTrackerHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */; }; - 9F4844B82FD0000100484401 /* OAAisObjectViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */; }; 46ED6C422B333BE400A5555F /* Pair.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46ED6C412B333BE400A5555F /* Pair.swift */; }; 46F0746F294C8F3400E641E9 /* OAEmissionHelper.mm in Sources */ = {isa = PBXBuildFile; fileRef = 46BB3678294BE77200155EC8 /* OAEmissionHelper.mm */; }; 46F0CA9829B0C4F50009C205 /* OAWikipediaSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 46F0CA9729B0C4F50009C205 /* OAWikipediaSettingsViewController.m */; }; @@ -1373,6 +1362,17 @@ 97F6410929C475E7008F527B /* map_action_hill_climbing@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 97F6410529C475E6008F527B /* map_action_hill_climbing@3x.png */; }; 97F6410C29C475E7008F527B /* map_action_hill_climbing@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 97F6410829C475E7008F527B /* map_action_hill_climbing@2x.png */; }; 9A3A2ACC2C2C44B500B28C43 /* OATopIndexFilter.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9A3A2ACB2C2C44B500B28C43 /* OATopIndexFilter.mm */; }; + 9F4844A52FD0000100484401 /* AisNmeaParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A12FD0000100484401 /* AisNmeaParser.swift */; }; + 9F4844A62FD0000100484401 /* AisNmeaConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A22FD0000100484401 /* AisNmeaConnection.swift */; }; + 9F4844A72FD0000100484401 /* AisTrackerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A32FD0000100484401 /* AisTrackerPlugin.swift */; }; + 9F4844A82FD0000100484401 /* AisTrackerSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */; }; + 9F4844AA2FD0000100484401 /* AisSimulationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */; }; + 9F4844AC2FD0000100484401 /* AisObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AB2FD0000100484401 /* AisObject.swift */; }; + 9F4844AE2FD0000100484401 /* AisDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AD2FD0000100484401 /* AisDataManager.swift */; }; + 9F4844B02FD0000100484401 /* AisMessageDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */; }; + 9F4844B32FD0000100484401 /* OAAisTrackerLayer.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */; }; + 9F4844B52FD0000100484401 /* AisTrackerHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */; }; + 9F4844B82FD0000100484401 /* OAAisObjectViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */; }; B0D6100B2E010FD6F3088B80 /* OATrackMenuHeaderView.mm in Sources */ = {isa = PBXBuildFile; fileRef = B0D61823DCC461FF03FE8103 /* OATrackMenuHeaderView.mm */; }; B0D610BD70EF0179933FD55C /* OAAddWaypointViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = B0D618034074DE575CDAACDD /* OAAddWaypointViewController.mm */; }; B0D610C833B6EA61B1C74268 /* OADeleteWaypointsGroupBottomSheetViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = B0D61BD47F1716F617480A05 /* OADeleteWaypointsGroupBottomSheetViewController.mm */; }; @@ -3302,6 +3302,7 @@ FA4C57032D48E92F00438A5C /* NoInternetCardCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA4C57022D48E92F00438A5C /* NoInternetCardCell.xib */; }; FA4C57052D491EB900438A5C /* CardsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4C57042D491EB900438A5C /* CardsViewController.swift */; }; FA4D7F572B03D366003208D7 /* BLEWheelSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4D7F562B03D366003208D7 /* BLEWheelSettingsViewController.swift */; }; + FA4EDBF62FDAC090003B0AFC /* AisLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4EDBF52FDAC090003B0AFC /* AisLogger.swift */; }; FA4FC4AB2F4F3F8F009ABB6E /* SharedLibSmartFolderHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4FC4AA2F4F3F8F009ABB6E /* SharedLibSmartFolderHelper.swift */; }; FA5060B92D6DF807007638C8 /* TouchesPassView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5060B82D6DF807007638C8 /* TouchesPassView.swift */; }; FA52B0732D4D237D0001B693 /* CardsFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA52B0722D4D237D0001B693 /* CardsFilter.swift */; }; @@ -4652,19 +4653,6 @@ 46E9ADA028DC9FD000CC55F9 /* OAButtonTableViewCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OAButtonTableViewCell.m; sourceTree = ""; }; 46ED6C3D2B333B4400A5555F /* Plugin+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Plugin+Extension.swift"; sourceTree = ""; }; 46ED6C3F2B333B8100A5555F /* ExternalSensorsPlugin+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ExternalSensorsPlugin+Extension.swift"; sourceTree = ""; }; - 9F4844A12FD0000100484401 /* AisNmeaParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisNmeaParser.swift; sourceTree = ""; }; - 9F4844A22FD0000100484401 /* AisNmeaConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisNmeaConnection.swift; sourceTree = ""; }; - 9F4844A32FD0000100484401 /* OAAisTrackerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAAisTrackerPlugin.swift; sourceTree = ""; }; - 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerSettingsViewController.swift; sourceTree = ""; }; - 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisSimulationProvider.swift; sourceTree = ""; }; - 9F4844AB2FD0000100484401 /* AisObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisObject.swift; sourceTree = ""; }; - 9F4844AD2FD0000100484401 /* AisDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisDataManager.swift; sourceTree = ""; }; - 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisMessageDecoder.swift; sourceTree = ""; }; - 9F4844B12FD0000100484401 /* OAAisTrackerLayer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OAAisTrackerLayer.h; sourceTree = ""; }; - 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OAAisTrackerLayer.mm; sourceTree = ""; }; - 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerHelper.swift; sourceTree = ""; }; - 9F4844B62FD0000100484401 /* OAAisObjectViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OAAisObjectViewController.h; sourceTree = ""; }; - 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OAAisObjectViewController.mm; sourceTree = ""; }; 46ED6C412B333BE400A5555F /* Pair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pair.swift; sourceTree = ""; }; 46F0CA9629B0C4E20009C205 /* OAWikipediaSettingsViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OAWikipediaSettingsViewController.h; sourceTree = ""; }; 46F0CA9729B0C4F50009C205 /* OAWikipediaSettingsViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OAWikipediaSettingsViewController.m; sourceTree = ""; }; @@ -5132,6 +5120,19 @@ 97F6410829C475E7008F527B /* map_action_hill_climbing@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "map_action_hill_climbing@2x.png"; sourceTree = ""; }; 9A3A2ACA2C2C448300B28C43 /* OATopIndexFilter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OATopIndexFilter.h; sourceTree = ""; }; 9A3A2ACB2C2C44B500B28C43 /* OATopIndexFilter.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OATopIndexFilter.mm; sourceTree = ""; }; + 9F4844A12FD0000100484401 /* AisNmeaParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisNmeaParser.swift; sourceTree = ""; }; + 9F4844A22FD0000100484401 /* AisNmeaConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisNmeaConnection.swift; sourceTree = ""; }; + 9F4844A32FD0000100484401 /* AisTrackerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerPlugin.swift; sourceTree = ""; }; + 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerSettingsViewController.swift; sourceTree = ""; }; + 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisSimulationProvider.swift; sourceTree = ""; }; + 9F4844AB2FD0000100484401 /* AisObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisObject.swift; sourceTree = ""; }; + 9F4844AD2FD0000100484401 /* AisDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisDataManager.swift; sourceTree = ""; }; + 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisMessageDecoder.swift; sourceTree = ""; }; + 9F4844B12FD0000100484401 /* OAAisTrackerLayer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OAAisTrackerLayer.h; sourceTree = ""; }; + 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OAAisTrackerLayer.mm; sourceTree = ""; }; + 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerHelper.swift; sourceTree = ""; }; + 9F4844B62FD0000100484401 /* OAAisObjectViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OAAisObjectViewController.h; sourceTree = ""; }; + 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OAAisObjectViewController.mm; sourceTree = ""; }; ADA2D9C11AEC31EA6BB2AF2C /* Pods-OsmAnd Maps.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OsmAnd Maps.release.xcconfig"; path = "Target Support Files/Pods-OsmAnd Maps/Pods-OsmAnd Maps.release.xcconfig"; sourceTree = ""; }; B0D610268394E6C3CF0F3C8C /* OAEditWaypointsGroupBottomSheetViewController.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = OAEditWaypointsGroupBottomSheetViewController.mm; sourceTree = ""; }; B0D61037ED3E628F3BB6C1AA /* OASelectionCollapsableCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OASelectionCollapsableCell.h; sourceTree = ""; }; @@ -8084,6 +8085,7 @@ FA4C57022D48E92F00438A5C /* NoInternetCardCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = NoInternetCardCell.xib; sourceTree = ""; }; FA4C57042D491EB900438A5C /* CardsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardsViewController.swift; sourceTree = ""; }; FA4D7F562B03D366003208D7 /* BLEWheelSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEWheelSettingsViewController.swift; sourceTree = ""; }; + FA4EDBF52FDAC090003B0AFC /* AisLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisLogger.swift; sourceTree = ""; }; FA4FC4AA2F4F3F8F009ABB6E /* SharedLibSmartFolderHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedLibSmartFolderHelper.swift; sourceTree = ""; }; FA5060B82D6DF807007638C8 /* TouchesPassView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchesPassView.swift; sourceTree = ""; }; FA52B0722D4D237D0001B693 /* CardsFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardsFilter.swift; sourceTree = ""; }; @@ -10406,6 +10408,27 @@ path = Resources/Icons/Location; sourceTree = ""; }; + 9F4844A02FD0000100484401 /* AisTrackerPlugin */ = { + isa = PBXGroup; + children = ( + FA4EDBF52FDAC090003B0AFC /* AisLogger.swift */, + 9F4844A12FD0000100484401 /* AisNmeaParser.swift */, + 9F4844A22FD0000100484401 /* AisNmeaConnection.swift */, + 9F4844AB2FD0000100484401 /* AisObject.swift */, + 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */, + 9F4844AD2FD0000100484401 /* AisDataManager.swift */, + 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */, + 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */, + 9F4844A32FD0000100484401 /* AisTrackerPlugin.swift */, + 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */, + 9F4844B12FD0000100484401 /* OAAisTrackerLayer.h */, + 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */, + 9F4844B62FD0000100484401 /* OAAisObjectViewController.h */, + 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */, + ); + path = AisTrackerPlugin; + sourceTree = ""; + }; B247453027E35B1D00C18C3F /* Cloud */ = { isa = PBXGroup; children = ( @@ -11261,26 +11284,6 @@ path = Plugins; sourceTree = ""; }; - 9F4844A02FD0000100484401 /* AisTrackerPlugin */ = { - isa = PBXGroup; - children = ( - 9F4844A12FD0000100484401 /* AisNmeaParser.swift */, - 9F4844A22FD0000100484401 /* AisNmeaConnection.swift */, - 9F4844AB2FD0000100484401 /* AisObject.swift */, - 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */, - 9F4844AD2FD0000100484401 /* AisDataManager.swift */, - 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */, - 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */, - 9F4844A32FD0000100484401 /* OAAisTrackerPlugin.swift */, - 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */, - 9F4844B12FD0000100484401 /* OAAisTrackerLayer.h */, - 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */, - 9F4844B62FD0000100484401 /* OAAisObjectViewController.h */, - 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */, - ); - path = AisTrackerPlugin; - sourceTree = ""; - }; DA5A797726C563A000F274C7 /* Wikipedia */ = { isa = PBXGroup; children = ( @@ -18015,7 +18018,7 @@ FAACFF222FC9B42B002D765A /* MultipleValuesViewController.swift in Sources */, 8AE943B327A28BE900961319 /* OAWeatherRasterLayer.mm in Sources */, FA1D6DAE2DCE04710080E374 /* VehicleMetricsPlugin.swift in Sources */, - 9F4844A72FD0000100484401 /* OAAisTrackerPlugin.swift in Sources */, + 9F4844A72FD0000100484401 /* AisTrackerPlugin.swift in Sources */, 9F4844AA2FD0000100484401 /* AisSimulationProvider.swift in Sources */, 9F4844AC2FD0000100484401 /* AisObject.swift in Sources */, 9F4844AE2FD0000100484401 /* AisDataManager.swift in Sources */, @@ -18528,6 +18531,7 @@ DAB9AECD28E192D300AD01CE /* OAURLSessionProgress.m in Sources */, DA5A833326C563A800F274C7 /* OABottomSheetHeaderIconCell.m in Sources */, DA4E72B328784768007A894D /* OANetworkWriter.mm in Sources */, + FA4EDBF62FDAC090003B0AFC /* AisLogger.swift in Sources */, DA5A856D26C563A900F274C7 /* OATargetPointsHelper.mm in Sources */, DA5A824426C563A700F274C7 /* OAVehicleParametersSettingsViewController.mm in Sources */, DA5A846D26C563A900F274C7 /* OASelectedGPXHelper.mm in Sources */, diff --git a/Sources/Controllers/Resources/OAPluginDetailsViewController.mm b/Sources/Controllers/Resources/OAPluginDetailsViewController.mm index 80fd50965a..214c98a661 100644 --- a/Sources/Controllers/Resources/OAPluginDetailsViewController.mm +++ b/Sources/Controllers/Resources/OAPluginDetailsViewController.mm @@ -447,7 +447,7 @@ - (UIViewController *) getSettingsViewController else if ([_product isKindOfClass:OAVehicleMetricsProduct.class]) return [[UIStoryboard storyboardWithName:@"VehicleMetricsSensors" bundle:nil] instantiateViewControllerWithIdentifier:@"VehicleMetricsSensors"]; else if ([_product isKindOfClass:OAAisTrackerProduct.class]) - return [[OAPluginsHelper getPlugin:OAAisTrackerPlugin.class] getSettingsController]; + return [[OAPluginsHelper getPlugin:AisTrackerPlugin.class] getSettingsController]; return nil; } diff --git a/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm b/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm index 94f62c4191..577efe1dd9 100644 --- a/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm +++ b/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm @@ -53,6 +53,7 @@ @implementation OAOsmandDevelopmentViewController NSString *const kVisualizingButtonGridKey = @"kVisualizingButtonGridKey"; NSString *const kSimulateLocationKey = @"kSimulateLocationKey"; NSString *const kAisTrackerSimulationKey = @"kAisTrackerSimulationKey"; +NSString *const kAisTrackerDebugLoggingKey = @"kAisTrackerDebugLoggingKey"; NSString *const kTraceRenderingKey = @"kTraceRenderingKey"; NSString *const kSimulateOBDDataKey = @"kSimulateOBDDataKey"; NSString *const kImageCacheKey = @"kImageCacheKey"; @@ -115,9 +116,15 @@ - (void)generateData @"isOn" : @([[OAAppSettings sharedManager].simulateOBDData get]) }]; - OAAisTrackerPlugin *aisPlugin = (OAAisTrackerPlugin *)[OAPluginsHelper getPlugin:OAAisTrackerPlugin.class]; + AisTrackerPlugin *aisPlugin = (AisTrackerPlugin *)[OAPluginsHelper getPlugin:AisTrackerPlugin.class]; + + [_data addSection:simulationSection]; + if (aisPlugin) { + OATableSectionData *aisSection = [OATableSectionData sectionData]; + aisSection.headerText = OALocalizedString(@"plugin_ais_tracker_name"); + NSString *simulationDescription = aisPlugin.simulationFileName ?: @""; if (aisPlugin.simulationStatusText.length > 0) { @@ -125,17 +132,23 @@ - (void)generateData ? [NSString stringWithFormat:@"%@ • %@", simulationDescription, aisPlugin.simulationStatusText] : aisPlugin.simulationStatusText; } - [simulationSection addRowFromDictionary:@{ + [aisSection addRowFromDictionary:@{ kCellTypeKey : [OAValueTableViewCell getCellIdentifier], kCellKeyKey : kAisTrackerSimulationKey, kCellTitleKey : OALocalizedString(@"ais_load_data"), kCellDescrKey : simulationDescription, @"actionBlock" : (^void(){ [weakSelf openAisSimulationFilePicker]; }) }]; + + [aisSection addRowFromDictionary:@{ + kCellTypeKey : [OASwitchTableViewCell getCellIdentifier], + kCellKeyKey : kAisTrackerDebugLoggingKey, + kCellTitleKey : @"AIS logging", + @"isOn" : @([aisPlugin.debugLoggingPref get]) + }]; + [_data addSection:aisSection]; } - [_data addSection:simulationSection]; - OATableSectionData *renderingSection = [OATableSectionData sectionData]; renderingSection.headerText = OALocalizedString(@"shared_string_appearance"); [renderingSection addRowFromDictionary:@{ @@ -309,6 +322,11 @@ - (void)onSwitchPressed:(UISwitch *)sender if (!sender.isOn) [[DeviceHelper shared] disconnectOBDSimulator]; } + else if ([item.key isEqualToString:kAisTrackerDebugLoggingKey]) + { + AisTrackerPlugin *aisPlugin = (AisTrackerPlugin *)[OAPluginsHelper getPlugin:AisTrackerPlugin.class]; + [aisPlugin.debugLoggingPref set:sender.isOn]; + } else if ([item.key isEqualToString:kTraceRenderingKey]) { [[OAAppSettings sharedManager].debugRenderingInfo set:sender.isOn]; @@ -389,7 +407,7 @@ - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocum if (urls.count == 0) return; - OAAisTrackerPlugin *aisPlugin = (OAAisTrackerPlugin *)[OAPluginsHelper getPlugin:OAAisTrackerPlugin.class]; + AisTrackerPlugin *aisPlugin = (AisTrackerPlugin *)[OAPluginsHelper getPlugin:AisTrackerPlugin.class]; if (!aisPlugin) return; diff --git a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift index a60f4fb70c..0d8d60af2e 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift @@ -10,7 +10,7 @@ extension Notification.Name { final class AisDataManager: NSObject { private static let objectLimit = 200 - private weak var plugin: OAAisTrackerPlugin? + private weak var plugin: AisTrackerPlugin? private var objectsByMmsi: [Int: AisObject] = [:] private var cleanupTimer: Timer? @@ -18,7 +18,7 @@ final class AisDataManager: NSObject { Array(objectsByMmsi.values) } - init(plugin: OAAisTrackerPlugin) { + init(plugin: AisTrackerPlugin) { self.plugin = plugin super.init() } @@ -56,7 +56,7 @@ final class AisDataManager: NSObject { if objectsByMmsi.count >= Self.objectLimit { removeOldestObject() } - aisDebugLog("data \(event) total=\(objectsByMmsi.count) \(object.debugSummary)") + aisDebugLog("[AisDataManager] data \(event) total=\(objectsByMmsi.count) \(object.debugSummary)") plugin?.onAisObjectReceived(object) } @@ -66,7 +66,7 @@ final class AisDataManager: NSObject { let removed = objectsByMmsi.values.filter { $0.isLost(maxAgeMinutes: maxAge) } for object in removed { objectsByMmsi.removeValue(forKey: object.mmsi) - aisDebugLog("data remove-lost maxAge=\(maxAge)m total=\(objectsByMmsi.count) \(object.debugSummary)") + aisDebugLog("[AisDataManager] data remove-lost maxAge=\(maxAge)m total=\(objectsByMmsi.count) \(object.debugSummary)") plugin.onAisObjectRemoved(object) } if !removed.isEmpty { @@ -77,7 +77,7 @@ final class AisDataManager: NSObject { private func removeOldestObject() { guard let oldest = objectsByMmsi.values.min(by: { $0.lastUpdate < $1.lastUpdate }) else { return } objectsByMmsi.removeValue(forKey: oldest.mmsi) - aisDebugLog("data remove-oldest limit=\(Self.objectLimit) total=\(objectsByMmsi.count) \(oldest.debugSummary)") + aisDebugLog("[AisDataManager] data remove-oldest limit=\(Self.objectLimit) total=\(objectsByMmsi.count) \(oldest.debugSummary)") plugin?.onAisObjectRemoved(oldest) } } diff --git a/Sources/Plugins/AisTrackerPlugin/AisLogger.swift b/Sources/Plugins/AisTrackerPlugin/AisLogger.swift new file mode 100644 index 0000000000..38b0b3d3c2 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisLogger.swift @@ -0,0 +1,26 @@ +// +// AisLogger.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + + +final class AisLogger { + + static let shared = AisLogger() + + var isEnabled = true + + private init() {} + + func log(_ message: String, + file: String = #fileID, + function: String = #function, + line: Int = #line) { + guard isEnabled else { return } + + print("[AIS] \(message) (\(file):\(line) \(function))") + } +} \ No newline at end of file diff --git a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift index 059a7eca7f..ef2c4275f8 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift @@ -17,6 +17,7 @@ import Network // NOTE: for test: tcp 153.44.253.27 5631 final class AisNmeaConnection { + var isDebugLoggingEnabled: (() -> Bool)? var onLocation: ((CLLocation) -> Void)? var onSentence: ((String) -> Void)? var onStateChanged: ((AisNmeaConnectionState) -> Void)? @@ -35,25 +36,30 @@ final class AisNmeaConnection { func startUDP(port: UInt16) { stop() + log("start UDP port=\(port)") updateState(.connecting) do { let params = NWParameters.udp params.allowLocalEndpointReuse = true guard let endpointPort = NWEndpoint.Port(rawValue: port) else { + log("UDP start failed: invalid port \(port)") updateState(.failed) return } let listener = try NWListener(using: params, on: endpointPort) self.listener = listener listener.newConnectionHandler = { [weak self] connection in + self?.log("UDP connection accepted endpoint=\(connection.endpoint)") self?.receiveDatagrams(connection) connection.start(queue: self?.queue ?? DispatchQueue.global()) } listener.stateUpdateHandler = { [weak self] state in + self?.log("UDP listener state=\(state)") switch state { case .ready: self?.updateState(.connected) - case .failed: + case .failed(let error): + self?.log("UDP listener failed error=\(error)") self?.updateState(.failed) case .cancelled: self?.updateState(.disconnected) @@ -63,12 +69,14 @@ final class AisNmeaConnection { } listener.start(queue: queue) } catch { + log("UDP start failed error=\(error)") updateState(.failed) } } func startTCP(host: String, port: UInt16) { stop() + log("start TCP host=\(host) port=\(port)") self.host = host self.port = port shouldReconnect = true @@ -76,6 +84,7 @@ final class AisNmeaConnection { } func stop() { + log("stop listener=\(listener != nil) connection=\(connection != nil) reconnect=\(shouldReconnect)") shouldReconnect = false reconnectWorkItem?.cancel() reconnectWorkItem = nil @@ -88,19 +97,27 @@ final class AisNmeaConnection { } private func connectTCP() { + log("TCP connect host=\(host) port=\(port)") updateState(.connecting) guard let endpointPort = NWEndpoint.Port(rawValue: port) else { + log("TCP connect failed: invalid port \(port)") updateState(.failed) return } let nwConnection = NWConnection(host: NWEndpoint.Host(host), port: endpointPort, using: .tcp) connection = nwConnection nwConnection.stateUpdateHandler = { [weak self] state in + self?.log("TCP state=\(state)") switch state { case .ready: self?.updateState(.connected) self?.receiveStream(nwConnection) - case .failed, .waiting: + case .failed(let error): + self?.log("TCP failed error=\(error)") + self?.updateState(.failed) + self?.scheduleReconnect() + case .waiting(let error): + self?.log("TCP waiting error=\(error)") self?.updateState(.failed) self?.scheduleReconnect() case .cancelled: @@ -113,7 +130,11 @@ final class AisNmeaConnection { } private func scheduleReconnect() { - guard shouldReconnect else { return } + guard shouldReconnect else { + log("skip reconnect: disabled") + return + } + log("schedule TCP reconnect in 5s") connection?.cancel() connection = nil let work = DispatchWorkItem { [weak self] in @@ -124,9 +145,15 @@ final class AisNmeaConnection { } private func receiveDatagrams(_ connection: NWConnection) { - connection.receiveMessage { [weak self] data, _, _, _ in + connection.receiveMessage { [weak self] data, _, isComplete, error in + if let error { + self?.log("UDP receive error=\(error)") + } if let data, let text = String(data: data, encoding: .ascii) { + self?.log("UDP datagram bytes=\(data.count) complete=\(isComplete)") self?.consume(text) + } else if let data { + self?.log("UDP datagram ignored: non-ascii bytes=\(data.count)") } self?.receiveDatagrams(connection) } @@ -135,9 +162,17 @@ final class AisNmeaConnection { private func receiveStream(_ connection: NWConnection) { connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in if let data, let text = String(data: data, encoding: .ascii) { + self?.log("TCP chunk bytes=\(data.count) complete=\(isComplete)") self?.consume(text) + } else if let data { + self?.log("TCP chunk ignored: non-ascii bytes=\(data.count)") } if isComplete || error != nil { + if let error { + self?.log("TCP receive ended error=\(error)") + } else { + self?.log("TCP receive completed") + } self?.scheduleReconnect() } else { self?.receiveStream(connection) @@ -154,20 +189,44 @@ final class AisNmeaConnection { DispatchQueue.main.async { [weak self] in self?.onSentence?(line) } + log("sentence chars=\(line.count) type=\(sentenceType(line))") if let location = AisNmeaParser.parseLocation(from: line) { + log(String(format: "location lat=%.6f lon=%.6f speed=%.2f course=%.1f", + location.coordinate.latitude, + location.coordinate.longitude, + location.speed, + location.course)) DispatchQueue.main.async { [weak self] in self?.onLocation?(location) } } } if buffer.count > 8192 { + log("drop buffered data: size=\(buffer.count)") buffer.removeAll() } } private func updateState(_ state: AisNmeaConnectionState) { + log("state -> \(state)") DispatchQueue.main.async { [weak self] in self?.onStateChanged?(state) } } + + private func sentenceType(_ sentence: String) -> String { + let trimmed = sentence.trimmingCharacters(in: .whitespacesAndNewlines) + let cleanSentence: String + if let lastBackslashIndex = trimmed.lastIndex(of: "\\") { + cleanSentence = String(trimmed[trimmed.index(after: lastBackslashIndex)...]) + } else { + cleanSentence = trimmed + } + return cleanSentence.split(separator: ",", maxSplits: 1).first.map(String.init) ?? "unknown" + } + + private func log(_ message: @autoclosure () -> String) { + guard isDebugLoggingEnabled?() == true else { return } + NSLog("[AIS][AisNmeaConnection] %@", message()) + } } diff --git a/Sources/Plugins/AisTrackerPlugin/AisObject.swift b/Sources/Plugins/AisTrackerPlugin/AisObject.swift index cda7860fe4..ec419990ca 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisObject.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisObject.swift @@ -469,7 +469,9 @@ final class AisObject: NSObject { } func aisDebugLog(_ message: @autoclosure () -> String) { -#if DEBUG + guard let plugin = OAPluginsHelper.getPlugin(AisTrackerPlugin.self) as? AisTrackerPlugin, + plugin.isDebugLoggingEnabled() else { + return + } NSLog("[AIS] %@", message()) -#endif } diff --git a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift index 3a1d5d12aa..8add0d43b7 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift @@ -2,20 +2,21 @@ import CoreLocation import Foundation final class AisMessageSimulationListener { - private weak var plugin: OAAisTrackerPlugin? + private weak var plugin: AisTrackerPlugin? private let fileURL: URL private let latency: TimeInterval private let queue = DispatchQueue(label: "net.osmand.ais.simulation.listener") + private let lock = NSLock() private var cancelled = false - init(plugin: OAAisTrackerPlugin, fileURL: URL, latency: TimeInterval) { + init(plugin: AisTrackerPlugin, fileURL: URL, latency: TimeInterval) { self.plugin = plugin self.fileURL = fileURL self.latency = latency } func start() { - cancelled = false + setCancelled(false) queue.async { [weak self] in guard let self else { return } let hasSecurityScopedAccess = self.fileURL.startAccessingSecurityScopedResource() @@ -34,10 +35,13 @@ final class AisMessageSimulationListener { let stats = self.collectStats(sentences: sentences) self.postStatus(sentences: stats.sentences, decoded: stats.decoded, objects: stats.objects, error: nil) for sentence in sentences { - if self.cancelled { + if self.isCancelled { return } Thread.sleep(forTimeInterval: self.latency) + if self.isCancelled { + return + } DispatchQueue.main.async { [weak self] in self?.plugin?.handleSimulatedNmeaSentence(sentence) } @@ -46,9 +50,19 @@ final class AisMessageSimulationListener { } func stop() { - queue.async { [weak self] in - self?.cancelled = true - } + setCancelled(true) + } + + private var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled + } + + private func setCancelled(_ cancelled: Bool) { + lock.lock() + self.cancelled = cancelled + lock.unlock() } private func collectStats(sentences: [String]) -> (sentences: Int, decoded: Int, objects: Int) { @@ -85,12 +99,12 @@ final class AisMessageSimulationListener { @objcMembers final class AisSimulationProvider: NSObject { - private static let simulatedLatency: TimeInterval = 0.01 + private static let simulatedLatency: TimeInterval = 0.1 - private weak var plugin: OAAisTrackerPlugin? + private weak var plugin: AisTrackerPlugin? private var listener: AisMessageSimulationListener? - init(plugin: OAAisTrackerPlugin) { + init(plugin: AisTrackerPlugin) { self.plugin = plugin super.init() } diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift new file mode 100644 index 0000000000..cbb3309f36 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift @@ -0,0 +1,444 @@ +import CoreLocation +import UIKit + +@objcMembers +final class AisTrackerPlugin: OAPlugin { + static let pluginId = "osmand.aistracker" + static let protocolPrefId = "ais_nmea_protocol" + static let hostPrefId = "ais_address_nmea_server" + static let tcpPortPrefId = "ais_port_nmea_server" + static let udpPortPrefId = "ais_port_nmea_local" + static let overrideLocationPrefId = "ais_use_nmea_location" + static let objectLostTimeoutPrefId = "ais_object_lost_timeout" + static let shipLostTimeoutPrefId = "ais_ship_lost_timeout" + static let cpaWarningTimePrefId = "ais_cpa_warning_time" + static let cpaWarningDistancePrefId = "ais_cpa_warning_distance" + static let debugLoggingPrefId = "ais_debug_logging" + + let protocolPref: OACommonInteger + let hostPref: OACommonString + let tcpPortPref: OACommonInteger + let udpPortPref: OACommonInteger + let overrideLocationPref: OACommonBoolean + let objectLostTimeoutPref: OACommonInteger + let shipLostTimeoutPref: OACommonInteger + let cpaWarningTimePref: OACommonInteger + let cpaWarningDistancePref: OACommonDouble + let debugLoggingPref: OACommonBoolean + + private let connection = AisNmeaConnection() + private let decoder = AisMessageDecoder() + private let aisDecoderQueue = DispatchQueue(label: "com.app.ais.decoder", qos: .userInitiated) + + private var applicationModeObserver: OAAutoObserverProxy? + + private lazy var simulationProvider = AisSimulationProvider(plugin: self) + private lazy var aisDataManager = AisDataManager(plugin: self) + + private(set) var connectionState: AisNmeaConnectionState = .disconnected + private(set) var lastLocation: CLLocation? + private(set) var fakeOwnLocation: CLLocation? + private(set) var simulationFileName: String? + private(set) var simulationStatusText: String? + private(set) var lastMessageReceived = Date.distantPast + + private var simulationSentences = 0 + private var simulationDecoded = 0 + private var simulationObjects = 0 + private var simulationReceivedObjects = 0 + private var simulationRenderedObjects = 0 + + override init() { + protocolPref = OAAppSettings.sharedManager().registerIntPreference(Self.protocolPrefId, defValue: Int32(AisNmeaProtocol.udp.rawValue)) + hostPref = OAAppSettings.sharedManager().registerStringPreference(Self.hostPrefId, defValue: "192.168.200.16") + tcpPortPref = OAAppSettings.sharedManager().registerIntPreference(Self.tcpPortPrefId, defValue: 4001) + udpPortPref = OAAppSettings.sharedManager().registerIntPreference(Self.udpPortPrefId, defValue: 10110) + overrideLocationPref = OAAppSettings.sharedManager().registerBooleanPreference(Self.overrideLocationPrefId, defValue: false) + objectLostTimeoutPref = OAAppSettings.sharedManager().registerIntPreference(Self.objectLostTimeoutPrefId, defValue: 7) + shipLostTimeoutPref = OAAppSettings.sharedManager().registerIntPreference(Self.shipLostTimeoutPrefId, defValue: 4) + cpaWarningTimePref = OAAppSettings.sharedManager().registerIntPreference(Self.cpaWarningTimePrefId, defValue: 0) + cpaWarningDistancePref = OAAppSettings.sharedManager().registerFloatPreference(Self.cpaWarningDistancePrefId, defValue: 1.0) + debugLoggingPref = OAAppSettings.sharedManager().registerBooleanPreference(Self.debugLoggingPrefId, defValue: false) + super.init() + + connection.isDebugLoggingEnabled = { [weak self] in + self?.isDebugLoggingEnabled() ?? false + } + connection.onStateChanged = { [weak self] state in + self?.connectionState = state + NotificationCenter.default.post(name: .aisNmeaConnectionStateChanged, object: self) + } + connection.onLocation = { [weak self] location in + self?.handle(location) + } + connection.onSentence = { [weak self] sentence in + self?.handleAisSentence(sentence) + } + applicationModeObserver = OAAutoObserverProxy(self, + withHandler: #selector(onApplicationModeChanged), + andObserve: OsmAndApp.swiftInstance().applicationModeChangedObservable) + } + + deinit { + applicationModeObserver?.detach() + } + + override func getId() -> String { + kInAppId_Addon_Ais_Tracker + } + + override func getName() -> String { + localizedString("plugin_ais_tracker_name") + } + + override func getDescription() -> String { + localizedString("plugin_ais_tracker_description") + } + + override func getLogoResourceId() -> String? { + "ic_plugin_nautical" + } + + override func getAddedAppModes() -> [OAApplicationMode] { + [OAApplicationMode.boat()] + } + + override func initPlugin() -> Bool { + let result = super.initPlugin() + updateConnectionForCurrentProfile() + return result + } + + override func setEnabled(_ enabled: Bool) { + super.setEnabled(enabled) + if enabled { + updateConnectionForCurrentProfile() + } else { + stopAisNetworkListener() + } + } + + override func updateLayers() { + DispatchQueue.main.async { + OsmAndApp.swiftInstance().data.mapLayersConfiguration.setLayer("ais_tracker_layer", visibility: self.isActiveForCurrentProfile()) + OARootViewController.instance().mapPanel.mapViewController.updateLayer("ais_tracker_layer") + } + } + + override func disable() { + connection.stop() + super.disable() + } + + override func getSettingsController() -> UIViewController? { + AisTrackerSettingsViewController(plugin: self) + } + + func getSimulationProvider() -> AisSimulationProvider { + simulationProvider + } + + func isActiveForCurrentProfile() -> Bool { + isEnabled() && OAAppSettings.sharedManager().applicationMode.get().isDerivedRouting(from: .boat()) + } + + func isDebugLoggingEnabled() -> Bool { + debugLoggingPref.get() + } + + func startAisSimulation(_ fileURL: URL) { + simulationFileName = fileURL.lastPathComponent + simulationSentences = 0 + simulationDecoded = 0 + simulationObjects = 0 + simulationReceivedObjects = 0 + simulationRenderedObjects = 0 + simulationStatusText = localizedString("shared_string_loading") + aisDebugLog("simulation start file=\(fileURL.lastPathComponent)") + simulationProvider.startAisSimulation(fileURL) + } + + func updateSimulationStatus(sentences: Int, decoded: Int, objects: Int, error: String?) { + if let error, !error.isEmpty { + simulationStatusText = error + aisDebugLog("simulation status error=\(error)") + } else { + simulationSentences = sentences + simulationDecoded = decoded + simulationObjects = objects + updateSimulationStatusText() + aisDebugLog("simulation stats sentences=\(sentences) decoded=\(decoded) objects=\(objects)") + } + postSimulationStatusChanged() + } + + func updateSimulationRenderedObjects(_ count: Int) { + guard simulationFileName != nil else { return } + guard simulationRenderedObjects != count else { return } + simulationRenderedObjects = count + updateSimulationStatusText() + postSimulationStatusChanged() + } + + func prepareAisSimulation() { + connection.stop() + aisDataManager.cleanupResources() + aisDataManager.startUpdates() + } + + func addTestSimulationObjects() { + simulationProvider.initFakePosition() + simulationProvider.initTestPassengerShip() + simulationProvider.initTestSailingBoat() + simulationProvider.initTestLandStation() + simulationProvider.initTestAircraft() + simulationProvider.initTestLawEnforcement() + } + + func clearSimulationObjects() { + simulationProvider.stopAisSimulation() + aisDebugLog("simulation clear") + fakeOwnLocation = nil + simulationFileName = nil + simulationStatusText = nil + simulationSentences = 0 + simulationDecoded = 0 + simulationObjects = 0 + simulationReceivedObjects = 0 + simulationRenderedObjects = 0 + aisDataManager.cleanupResources() + } + + func restartConnection() { + guard isActiveForCurrentProfile() else { + stopAisNetworkListener() + return + } + aisDataManager.startUpdates() + let proto = AisNmeaProtocol(rawValue: Int(protocolPref.get())) ?? .udp + switch proto { + case .udp: + connection.startUDP(port: UInt16(max(1, udpPortPref.get()))) + case .tcp: + connection.startTCP(host: hostPref.get(), port: UInt16(max(1, tcpPortPref.get()))) + } + } + + func stopAisNetworkListener() { + connection.stop() + aisDataManager.stopUpdates() + } + + private func updateConnectionForCurrentProfile() { + if isActiveForCurrentProfile() { + if !connection.isRunning { + restartConnection() + } + } else { + stopAisNetworkListener() + } + } + + func fakeOwnPosition(_ location: CLLocation?) { + fakeOwnLocation = location + } + + func handleSimulatedNmeaSentence(_ sentence: String) { + handleAisSentence(sentence) + if let location = AisNmeaParser.parseLocation(from: sentence) { + handleSimulatedLocation(location) + } + } + + func handleSimulatedLocation(_ location: CLLocation) { + handle(location) + } + + func handleSimulatedAisObject(_ object: AisObject) { + aisDataManager.onAisObjectReceived(object) + } + + func getAisObjects() -> [AisObject] { + aisDataManager.objects + } + // FIXME: cache for objectLostTimeoutPref shipLostTimeoutPref cpaWarningTimePref cpaWarningDistancePref + func maxObjectAgeInMinutes() -> Int { + max(1, Int(objectLostTimeoutPref.get())) + } + + func vesselLostTimeoutInMinutes() -> Int { + max(0, Int(shipLostTimeoutPref.get())) + } + + func cpaWarningTimeInMinutes() -> Int { + max(0, Int(cpaWarningTimePref.get())) + } + + func cpaWarningDistanceInNauticalMiles() -> Double { + max(0, cpaWarningDistancePref.get()) + } + + func ownPosition() -> CLLocation? { + if let fakeOwnLocation { + return fakeOwnLocation + } + return OsmAndApp.swiftInstance().locationServices?.lastKnownLocation + } + + func onAisObjectReceived(_ object: AisObject) { + lastMessageReceived = object.lastUpdate + if simulationFileName != nil { + let receivedObjects = getAisObjects().filter(\.hasPosition).count + if simulationReceivedObjects != receivedObjects { + simulationReceivedObjects = receivedObjects + updateSimulationStatusText() + postSimulationStatusChanged() + } + } + aisDebugLog("plugin received withPosition=\(getAisObjects().filter(\.hasPosition).count) \(object.debugSummary)") + NotificationCenter.default.post(name: .aisObjectReceived, object: self, userInfo: ["object": object]) + } + + func onAisObjectRemoved(_ object: AisObject) { + aisDebugLog("plugin removed \(object.debugSummary)") + NotificationCenter.default.post(name: .aisObjectRemoved, object: self, userInfo: ["object": object]) + } + + func hasCpaWarning(for object: AisObject) -> Bool { + let warningTime = cpaWarningTimeInMinutes() + let warningDistance = cpaWarningDistanceInNauticalMiles() + guard object.isMovable, + object.objectClass != .airplane, + warningTime > 0, + object.sog > 0, + let ownPosition = ownPosition(), + let aisPosition = object.location else { + return false + } + AisTrackerHelper.getCpa(ownPosition, aisPosition, result: object.cpa) + guard object.cpa.valid, object.cpa.tcpa > 0 else { return false } + return Double(object.cpa.cpaDistance) <= warningDistance + && object.cpa.tcpa * 60.0 <= Double(warningTime) + && object.cpa.crossingTime1 >= 0 + && object.cpa.crossingTime2 >= 0 + } + + func updateCpa(for object: AisObject) { + guard let ownPosition = ownPosition(), + let aisPosition = object.currentLocation ?? object.location else { + object.cpa.reset() + return + } + AisTrackerHelper.getCpa(ownPosition, aisPosition, result: object.cpa) + } + + func distanceInNauticalMiles(to object: AisObject) -> Double { + guard let ownPosition = ownPosition(), + let aisPosition = object.currentLocation ?? object.location else { + return -1 + } + return ownPosition.distance(from: aisPosition) / 1852.0 + } + + func bearing(to object: AisObject) -> Double { + guard let ownPosition = ownPosition(), + let aisPosition = object.currentLocation ?? object.location else { + return -1 + } + return Self.bearing(from: ownPosition.coordinate, to: aisPosition.coordinate) + } + + func connectionDescription() -> String { + let proto = AisNmeaProtocol(rawValue: Int(protocolPref.get())) ?? .udp + switch proto { + case .udp: + return "UDP • \(udpPortPref.get())" + case .tcp: + return "TCP • \(hostPref.get()):\(tcpPortPref.get())" + } + } + + func statusDescription() -> String { + switch connectionState { + case .connected: + return localizedString("ais_connection_connected") + case .connecting: + return localizedString("ais_connection_connecting") + case .failed: + return localizedString("ais_connection_failed") + case .disconnected: + return localizedString("ais_connection_disconnected") + } + } + + private func handle(_ location: CLLocation) { + lastLocation = location + NotificationCenter.default.post(name: .aisNmeaLocationReceived, object: self) + if overrideLocationPref.get() { + OsmAndApp.swiftInstance().locationServices?.setLocationFromNMEA(location) + } + } + +// private func handleAisSentence(_ sentence: String) { +// Task { +// guard let object = await decoder.decode(sentence: sentence) else { return } +// +// await MainActor.run { +// self.aisDataManager.onAisObjectReceived(object) +// } +// } +// } + +// private func handleAisSentence(_ sentence: String) { +// guard let object = decoder.decode(sentence: sentence) else { return } +// aisDataManager.onAisObjectReceived(object) +// } + + private func handleAisSentence(_ sentence: String) { + aisDecoderQueue.async { [weak self] in + guard let self else { return } + + guard let object = decoder.decode(sentence: sentence) else { return } + + DispatchQueue.main.async { + self.aisDataManager.onAisObjectReceived(object) + } + } + } + + private func updateSimulationStatusText() { + var parts = [ + "sentences \(simulationSentences)", + "decoded \(simulationDecoded)", + "objects \(simulationObjects)" + ] + if simulationReceivedObjects > 0 || simulationRenderedObjects > 0 { + parts.append("received \(simulationReceivedObjects)") + parts.append("rendered \(simulationRenderedObjects)") + } + simulationStatusText = parts.joined(separator: ", ") + } + + private func postSimulationStatusChanged() { + NotificationCenter.default.post(name: .aisSimulationStatusChanged, object: self) + } + + private static func bearing(from start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D) -> Double { + let lat1 = start.latitude * .pi / 180 + let lat2 = end.latitude * .pi / 180 + let deltaLon = (end.longitude - start.longitude) * .pi / 180 + let y = sin(deltaLon) * cos(lat2) + let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(deltaLon) + let degrees = atan2(y, x) * 180 / .pi + return fmod(degrees + 360, 360) + } + + @objc private func onApplicationModeChanged() { + updateConnectionForCurrentProfile() + } +} + +extension Notification.Name { + static let aisNmeaConnectionStateChanged = Notification.Name("OAAisNmeaConnectionStateChanged") + static let aisNmeaLocationReceived = Notification.Name("OAAisNmeaLocationReceived") +} diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift index d48b351d08..7af52b0b48 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift @@ -35,13 +35,13 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { case cpaWarningDistance } - private let plugin: OAAisTrackerPlugin + private let plugin: AisTrackerPlugin private let objectLostTimeoutValues = [3, 5, 7, 10, 12, 15, 20] private let shipLostTimeoutValues = [2, 3, 4, 5, 7, 10, 15, 100] private let cpaWarningTimeValues = [0, 1, 5, 10, 20, 30, 60] private let cpaWarningDistanceValues = [0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0] - init(plugin: OAAisTrackerPlugin) { + init(plugin: AisTrackerPlugin) { self.plugin = plugin super.init() } diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm index a73a7a5c01..74d2d15dbf 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm @@ -119,7 +119,7 @@ - (void)buildPluginRows:(NSMutableArray *)rows - (void)buildInternal:(NSMutableArray *)rows { NSInteger order = kAisRowStartOrder; - OAAisTrackerPlugin *plugin = (OAAisTrackerPlugin *)[OAPluginsHelper getPlugin:OAAisTrackerPlugin.class]; + AisTrackerPlugin *plugin = (AisTrackerPlugin *)[OAPluginsHelper getPlugin:AisTrackerPlugin.class]; if (plugin) [plugin updateCpaFor:_object]; diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm index c4ce13621d..d8b7335495 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm @@ -21,20 +21,34 @@ static const int kAisTrackerStartZoom = 6; static const CGFloat kAisBaseIconSize = 48.0; static const CGFloat kAisDirectionLineStartIconFactor = 0.42; +static const NSTimeInterval kAisViewportRenderUpdateInterval = 0.2; static int kAisIconKeyStorage; static const OsmAnd::MapMarker::OnSurfaceIconKey kAisIconKey = &kAisIconKeyStorage; -#ifdef DEBUG -#define OAAisLayerLog(format, ...) NSLog((@"[AIS][Layer] " format), ##__VA_ARGS__) -#else -#define OAAisLayerLog(format, ...) -#endif - static NSString *OAAisObjectTitle(AisObject *object) { return [NSString stringWithFormat:OALocalizedString(@"ais_object_with_mmsi"), (long)object.mmsi]; } +static BOOL OAAisDebugLoggingEnabled() +{ + AisTrackerPlugin *plugin = (AisTrackerPlugin *)[OAPluginsHelper getPlugin:AisTrackerPlugin.class]; + return plugin && [plugin isDebugLoggingEnabled]; +} + +static void OAAisLayerLog(NSString *format, ...) NS_FORMAT_FUNCTION(1, 2); +static void OAAisLayerLog(NSString *format, ...) +{ + if (!OAAisDebugLoggingEnabled()) + return; + + va_list args; + va_start(args, format); + NSString *message = [[NSString alloc] initWithFormat:format arguments:args]; + va_end(args); + NSLog(@"[AIS][Layer] %@", message); +} + @interface AisObjectDrawable : NSObject @property (nonatomic) AisObject *object; @@ -49,13 +63,13 @@ - (void)setTextScale:(CGFloat)textScale displayDensityFactor:(CGFloat)displayDensityFactor; - (BOOL)hasAisRenderData; - (NSString *)currentRenderKey; -- (int)renderGroupId; - (OsmAnd::PointI)markerLocation; +- (void)setAisRenderDataHidden:(BOOL)hidden; - (void)createAisRenderDataWithBaseOrder:(int)baseOrder markersCollection:(const std::shared_ptr &)markersCollection vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection; - (void)updateAisRenderDataWithMapView:(OAMapRendererView *)mapView - plugin:(OAAisTrackerPlugin *)plugin; + plugin:(AisTrackerPlugin *)plugin; - (void)clearAisRenderDataFromMarkersCollection:(const std::shared_ptr &)markersCollection vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection; @@ -111,11 +125,6 @@ - (NSString *)currentRenderKey return [NSString stringWithFormat:@"surface-v3-%@-%d", [self iconResourceNameForType:_object.objectClass], (int)std::round([self iconSize] * 100.0)]; } -- (int)renderGroupId -{ - return (int)_object.mmsi; -} - - (OsmAnd::PointI)markerLocation { CLLocation *location = _object.location; @@ -125,6 +134,18 @@ - (int)renderGroupId OsmAnd::Utilities::get31TileNumberY(location.coordinate.latitude)); } +- (void)setAisRenderDataHidden:(BOOL)hidden +{ + if (_activeMarker) + _activeMarker->setIsHidden(hidden); + if (_restMarker) + _restMarker->setIsHidden(hidden); + if (_lostMarker) + _lostMarker->setIsHidden(hidden); + if (_directionLine) + _directionLine->setIsHidden(hidden); +} + - (void)createAisRenderDataWithBaseOrder:(int)baseOrder markersCollection:(const std::shared_ptr &)markersCollection vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection @@ -135,24 +156,18 @@ - (void)createAisRenderDataWithBaseOrder:(int)baseOrder OsmAnd::MapMarkerBuilder markerBuilder; OsmAnd::PointI markerLocation = [self markerLocation]; markerBuilder - .setGroupId([self renderGroupId]) - .setMarkerId(0) - .setIsAccuracyCircleSupported(false) .setBaseOrder(baseOrder) .setIsHidden(true) .setPosition(markerLocation) - .setUpdateAfterCreated(true) .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage([self iconImageForState:0])); _activeMarker = markerBuilder.buildAndAddToCollection(markersCollection); markerBuilder - .setMarkerId(1) .clearOnMapSurfaceIcons() .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage([self iconImageForState:1])); _restMarker = markerBuilder.buildAndAddToCollection(markersCollection); markerBuilder - .setMarkerId(2) .clearOnMapSurfaceIcons() .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage([self iconImageForState:2])); _lostMarker = markerBuilder.buildAndAddToCollection(markersCollection); @@ -177,7 +192,7 @@ - (void)createAisRenderDataWithBaseOrder:(int)baseOrder } - (void)updateAisRenderDataWithMapView:(OAMapRendererView *)mapView - plugin:(OAAisTrackerPlugin *)plugin + plugin:(AisTrackerPlugin *)plugin { if (![self hasAisRenderData]) return; @@ -185,22 +200,21 @@ - (void)updateAisRenderDataWithMapView:(OAMapRendererView *)mapView const OsmAnd::ZoomLevel zoom = mapView ? mapView.zoomLevel : OsmAnd::ZoomLevel::MinZoomLevel; if (!mapView || (int)zoom < kAisTrackerStartZoom || !_object.hasPosition) { - _activeMarker->setIsHidden(true); - _restMarker->setIsHidden(true); - _lostMarker->setIsHidden(true); - if (_directionLine) - _directionLine->setIsHidden(true); + [self setAisRenderDataHidden:YES]; return; } CLLocation *location = _object.location; if (!location) { - _activeMarker->setIsHidden(true); - _restMarker->setIsHidden(true); - _lostMarker->setIsHidden(true); - if (_directionLine) - _directionLine->setIsHidden(true); + [self setAisRenderDataHidden:YES]; + return; + } + + OsmAnd::PointI markerLocation = [self markerLocation]; + if (![mapView isPositionVisible:markerLocation]) + { + [self setAisRenderDataHidden:YES]; return; } @@ -226,7 +240,6 @@ - (void)updateAisRenderDataWithMapView:(OAMapRendererView *)mapView _activeMarker->setOnMapSurfaceIconDirection(kAisIconKey, rotation); _lostMarker->setOnMapSurfaceIconDirection(kAisIconKey, rotation); } - OsmAnd::PointI markerLocation = [self markerLocation]; _activeMarker->setPosition(markerLocation); _restMarker->setPosition(markerLocation); _lostMarker->setPosition(markerLocation); @@ -258,7 +271,6 @@ - (void)clearAisRenderDataFromMarkersCollection:(const std::shared_ptrremoveMarkersByGroupId([self renderGroupId]); if (_activeMarker) markersCollection->removeMarker(_activeMarker); if (_restMarker) @@ -431,9 +443,15 @@ - (BOOL)needRotation @end +@interface OAAisTrackerLayer () + +- (BOOL)shouldUpdateRenderDataForViewport; + +@end + @implementation OAAisTrackerLayer { - OAAisTrackerPlugin *_plugin; + AisTrackerPlugin *_plugin; NSMutableDictionary *_objectDrawables; std::shared_ptr _markersCollection; std::shared_ptr _vectorLinesCollection; @@ -443,6 +461,10 @@ @implementation OAAisTrackerLayer BOOL _collectionsAdded; CGFloat _textScale; CGFloat _displayDensityFactor; + BOOL _hasLastRenderViewport; + OsmAnd::AreaI _lastRenderBBox31; + int _lastRenderZoom; + NSTimeInterval _lastViewportRenderUpdateTime; } - (instancetype)initWithMapViewController:(OAMapViewController *)mapViewController baseOrder:(int)baseOrder @@ -450,10 +472,13 @@ - (instancetype)initWithMapViewController:(OAMapViewController *)mapViewControll self = [super initWithMapViewController:mapViewController baseOrder:baseOrder]; if (self) { - _plugin = (OAAisTrackerPlugin *)[OAPluginsHelper getPlugin:OAAisTrackerPlugin.class]; + _plugin = (AisTrackerPlugin *)[OAPluginsHelper getPlugin:AisTrackerPlugin.class]; _objectDrawables = [NSMutableDictionary dictionary]; _textScale = [OAAisTrackerLayer currentTextScale]; _displayDensityFactor = MAX(1.0, mapViewController.displayDensityFactor); + _hasLastRenderViewport = NO; + _lastRenderZoom = -1; + _lastViewportRenderUpdateTime = 0; } return self; } @@ -463,10 +488,10 @@ - (NSString *)layerId return kAisTrackerLayerId; } -- (OAAisTrackerPlugin *)plugin +- (AisTrackerPlugin *)plugin { if (!_plugin) - _plugin = (OAAisTrackerPlugin *)[OAPluginsHelper getPlugin:OAAisTrackerPlugin.class]; + _plugin = (AisTrackerPlugin *)[OAPluginsHelper getPlugin:AisTrackerPlugin.class]; return _plugin; } @@ -524,7 +549,7 @@ - (void)initLayer OAAisTrackerLayer *strongSelf = weakSelf; if (!strongSelf) return; - if ([note.object isKindOfClass:OAAisTrackerPlugin.class]) + if ([note.object isKindOfClass:AisTrackerPlugin.class]) strongSelf->_plugin = note.object; [strongSelf cleanupResources]; if ([strongSelf isVisible]) @@ -540,7 +565,7 @@ - (void)initLayer OAAisTrackerLayer *strongSelf = weakSelf; if (!strongSelf) return; - if ([note.object isKindOfClass:OAAisTrackerPlugin.class]) + if ([note.object isKindOfClass:AisTrackerPlugin.class]) strongSelf->_plugin = note.object; AisObject *object = note.userInfo[@"object"]; if ([object isKindOfClass:AisObject.class]) @@ -553,7 +578,7 @@ - (void)initLayer OAAisTrackerLayer *strongSelf = weakSelf; if (!strongSelf) return; - if ([note.object isKindOfClass:OAAisTrackerPlugin.class]) + if ([note.object isKindOfClass:AisTrackerPlugin.class]) strongSelf->_plugin = note.object; AisObject *object = note.userInfo[@"object"]; if ([object isKindOfClass:AisObject.class]) @@ -624,22 +649,21 @@ - (BOOL)updateLayer return YES; } -- (void)onMapFrameRendered -{ - NSLog(@"onMapFrameRendered"); - if (![self isVisible]) - { - [self removeCollectionsFromRenderer]; - return; - } - [self updateRenderData]; -} - -//- (void)onMapFrameAnimatorsUpdated +//- (void)onMapFrameRendered //{ -// NSLog(@"onMapFrameAnimatorsUpdated"); +// if (![self isVisible]) +// { +// [self removeCollectionsFromRenderer]; +// _hasLastRenderViewport = NO; +// _lastViewportRenderUpdateTime = 0; +// return; +// } +// if (![self shouldUpdateRenderDataForViewport]) +// return; +// [self updateRenderData]; //} + - (void)resetCollections { _markersCollection = std::make_shared(); @@ -691,6 +715,8 @@ - (void)cleanupResources } }]; [_objectDrawables removeAllObjects]; + _hasLastRenderViewport = NO; + _lastViewportRenderUpdateTime = 0; [self resetCollections]; } @@ -707,7 +733,7 @@ - (void)reloadObjects - (void)reloadObjectsSync { [self ensureObjectDrawables]; - OAAisTrackerPlugin *plugin = [self plugin]; + AisTrackerPlugin *plugin = [self plugin]; NSArray *objects = [plugin getAisObjects]; NSMutableSet *visibleMmsi = [NSMutableSet set]; for (AisObject *object in objects) @@ -791,9 +817,8 @@ - (void)updateAisObjectSync:(AisObject *)object if (![drawable hasAisRenderData]) [drawable createAisRenderDataWithBaseOrder:self.baseOrder markersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; [drawable updateAisRenderDataWithMapView:self.mapView plugin:[self plugin]]; - int groupMarkers = _markersCollection ? _markersCollection->getMarkersCountByGroupId((int)object.mmsi) : 0; int linesCount = _vectorLinesCollection ? _vectorLinesCollection->getLinesCount() : 0; - OAAisLayerLog(@"update recreated=%@ drawables=%lu groupMarkers=%d lines=%d %@", recreated ? @"yes" : @"no", (unsigned long)_objectDrawables.count, groupMarkers, linesCount, object.debugSummary); + OAAisLayerLog(@"update recreated=%@ drawables=%lu lines=%d %@", recreated ? @"yes" : @"no", (unsigned long)_objectDrawables.count, linesCount, object.debugSummary); [[self plugin] updateSimulationRenderedObjects:_objectDrawables.count]; } @@ -802,20 +827,39 @@ - (void)updateRenderData if (![self isVisible]) return; -// if ([self updateScaleCache]) -// { -// OAAisLayerLog(@"scale changed textScale=%.2f density=%.2f iconSize=%.1f", _textScale, _displayDensityFactor, [self currentIconSize]); -// [self cleanupResources]; -// [self addCollectionsToRenderer]; -// [self reloadObjects]; -// return; -// } - - OAAisTrackerPlugin *plugin = [self plugin]; + AisTrackerPlugin *plugin = [self plugin]; for (NSNumber *key in _objectDrawables) [_objectDrawables[key] updateAisRenderDataWithMapView:self.mapView plugin:plugin]; } +- (BOOL)shouldUpdateRenderDataForViewport +{ + OAMapRendererView *mapView = self.mapView; + if (!mapView) + return NO; + + const OsmAnd::AreaI visibleBBox31 = [mapView getVisibleBBox31]; + const int zoom = (int)mapView.zoomLevel; + if (!_hasLastRenderViewport + || _lastRenderZoom != zoom + || _lastRenderBBox31.left() != visibleBBox31.left() + || _lastRenderBBox31.top() != visibleBBox31.top() + || _lastRenderBBox31.right() != visibleBBox31.right() + || _lastRenderBBox31.bottom() != visibleBBox31.bottom()) + { + NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; + if (_hasLastRenderViewport && now - _lastViewportRenderUpdateTime < kAisViewportRenderUpdateInterval) + return NO; + + _lastRenderBBox31 = visibleBBox31; + _lastRenderZoom = zoom; + _hasLastRenderViewport = YES; + _lastViewportRenderUpdateTime = now; + return YES; + } + return NO; +} + #pragma mark - OAContextMenuProvider - (OATargetPoint *)getTargetPoint:(id)obj touchLocation:(CLLocation *)touchLocation diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift index 49daf78d4f..63f3b97c5d 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift @@ -2,7 +2,7 @@ import CoreLocation import UIKit @objcMembers -final class OAAisTrackerPlugin: OAPlugin { +final class AisTrackerPlugin: OAPlugin { static let pluginId = "osmand.aistracker" static let protocolPrefId = "ais_nmea_protocol" static let hostPrefId = "ais_address_nmea_server" @@ -13,6 +13,8 @@ final class OAAisTrackerPlugin: OAPlugin { static let shipLostTimeoutPrefId = "ais_ship_lost_timeout" static let cpaWarningTimePrefId = "ais_cpa_warning_time" static let cpaWarningDistancePrefId = "ais_cpa_warning_distance" + static let aisConnectLoggingPrefId = "ais_connect_logging" + static let layerDebugLoggingPrefId = "ais_layer_debug_logging" let protocolPref: OACommonInteger let hostPref: OACommonString @@ -23,6 +25,8 @@ final class OAAisTrackerPlugin: OAPlugin { let shipLostTimeoutPref: OACommonInteger let cpaWarningTimePref: OACommonInteger let cpaWarningDistancePref: OACommonDouble + let aisConnectLoggingPref: OACommonBoolean + let layerDebugLoggingPref: OACommonBoolean private let connection = AisNmeaConnection() private let decoder = AisMessageDecoder() @@ -56,8 +60,13 @@ final class OAAisTrackerPlugin: OAPlugin { shipLostTimeoutPref = OAAppSettings.sharedManager().registerIntPreference(Self.shipLostTimeoutPrefId, defValue: 4) cpaWarningTimePref = OAAppSettings.sharedManager().registerIntPreference(Self.cpaWarningTimePrefId, defValue: 0) cpaWarningDistancePref = OAAppSettings.sharedManager().registerFloatPreference(Self.cpaWarningDistancePrefId, defValue: 1.0) + aisConnectLoggingPref = OAAppSettings.sharedManager().registerBooleanPreference(Self.aisConnectLoggingPrefId, defValue: false) + layerDebugLoggingPref = OAAppSettings.sharedManager().registerBooleanPreference(Self.layerDebugLoggingPrefId, defValue: false) super.init() + connection.isConnectLoggingEnabled = { [weak self] in + self?.isConnectLoggingEnabled() ?? false + } connection.onStateChanged = { [weak self] state in self?.connectionState = state NotificationCenter.default.post(name: .aisNmeaConnectionStateChanged, object: self) @@ -136,6 +145,14 @@ final class OAAisTrackerPlugin: OAPlugin { isEnabled() && OAAppSettings.sharedManager().applicationMode.get().isDerivedRouting(from: .boat()) } + func isConnectLoggingEnabled() -> Bool { + aisConnectLoggingPref.get() + } + + func isLayerDebugLoggingEnabled() -> Bool { + layerDebugLoggingPref.get() + } + func startAisSimulation(_ fileURL: URL) { simulationFileName = fileURL.lastPathComponent simulationSentences = 0 diff --git a/Sources/Plugins/OAPluginsHelper.mm b/Sources/Plugins/OAPluginsHelper.mm index a301e8505e..2c0fcfba9b 100644 --- a/Sources/Plugins/OAPluginsHelper.mm +++ b/Sources/Plugins/OAPluginsHelper.mm @@ -100,7 +100,7 @@ + (void) initPlugins [allPlugins addObject:[[OAMapillaryPlugin alloc] init]]; [allPlugins addObject:[[OAWeatherPlugin alloc] init]]; [allPlugins addObject:[[OAExternalSensorsPlugin alloc] init]]; - [allPlugins addObject:[OAAisTrackerPlugin new]]; + [allPlugins addObject:[AisTrackerPlugin new]]; [allPlugins addObject:[VehicleMetricsPlugin new]]; [allPlugins addObject:[[OAOsmandDevelopmentPlugin alloc] init]]; From b4a9fbb874136ec1e6531451cd1f1e090e86925f Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Thu, 11 Jun 2026 13:19:16 +0300 Subject: [PATCH 05/18] fix headers --- .../AisTrackerPlugin/AisDataManager.swift | 7 +++++++ .../Plugins/AisTrackerPlugin/AisLogger.swift | 12 +++++++++--- .../AisTrackerPlugin/AisMessageDecoder.swift | 8 +++++++- .../AisTrackerPlugin/AisNmeaConnection.swift | 9 ++++++++- .../AisTrackerPlugin/AisNmeaParser.swift | 9 ++++++++- .../Plugins/AisTrackerPlugin/AisObject.swift | 8 ++++++++ .../AisSimulationProvider.swift | 8 ++++++++ .../AisTrackerPlugin/AisTrackerHelper.swift | 9 ++++++++- .../AisTrackerPlugin/AisTrackerPlugin.swift | 18 ++++++++++++------ .../AisTrackerSettingsViewController.swift | 8 +++++++- .../OAAisObjectViewController.h | 8 ++++++++ .../OAAisObjectViewController.mm | 19 ++++++++----------- .../AisTrackerPlugin/OAAisTrackerLayer.h | 8 ++++++++ .../AisTrackerPlugin/OAAisTrackerLayer.mm | 8 ++++++++ 14 files changed, 114 insertions(+), 25 deletions(-) diff --git a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift index 0d8d60af2e..2c6b94201d 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift @@ -1,3 +1,10 @@ +// +// AisDataManager.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// extension Notification.Name { static let aisObjectReceived = Notification.Name("OAAisObjectReceived") diff --git a/Sources/Plugins/AisTrackerPlugin/AisLogger.swift b/Sources/Plugins/AisTrackerPlugin/AisLogger.swift index 38b0b3d3c2..48bfcdac8a 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisLogger.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisLogger.swift @@ -7,13 +7,19 @@ // -final class AisLogger { +final class AisLogger: NSObject { static let shared = AisLogger() + static let debugLoggingPrefId = "ais_debug_logging" var isEnabled = true - private init() {} + private let debugLoggingPref: OACommonBoolean + + override private init() { + debugLoggingPref = OAAppSettings.sharedManager().registerBooleanPreference(Self.debugLoggingPrefId, defValue: false) + isEnabled = debugLoggingPref.get() + } func log(_ message: String, file: String = #fileID, @@ -23,4 +29,4 @@ final class AisLogger { print("[AIS] \(message) (\(file):\(line) \(function))") } -} \ No newline at end of file +} diff --git a/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift b/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift index c108879970..e4ba739cb2 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift @@ -1,4 +1,10 @@ -import Foundation +// +// AisMessageDecoder.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// final class AisMessageDecoder { private struct FragmentBuffer { diff --git a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift index ef2c4275f8..9ebcc19c98 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift @@ -1,5 +1,12 @@ +// +// AisNmeaConnection.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + import CoreLocation -import Foundation import Network @objc enum AisNmeaProtocol: Int { diff --git a/Sources/Plugins/AisTrackerPlugin/AisNmeaParser.swift b/Sources/Plugins/AisTrackerPlugin/AisNmeaParser.swift index e94757ab91..3018ddca29 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisNmeaParser.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisNmeaParser.swift @@ -1,5 +1,12 @@ +// +// AisNmeaParser.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + import CoreLocation -import Foundation struct AisNmeaParser { static func parseLocation(from sentence: String) -> CLLocation? { diff --git a/Sources/Plugins/AisTrackerPlugin/AisObject.swift b/Sources/Plugins/AisTrackerPlugin/AisObject.swift index ec419990ca..c8a6e53d83 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisObject.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisObject.swift @@ -1,3 +1,11 @@ +// +// AisObject.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + import CoreLocation import Foundation diff --git a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift index 8add0d43b7..4983f71d53 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift @@ -1,3 +1,11 @@ +// +// AisSimulationProvider.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + import CoreLocation import Foundation diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift index 55d2f9fc52..47de49d8e8 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift @@ -1,5 +1,12 @@ +// +// AisTrackerHelper.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + import CoreLocation -import Foundation @objcMembers final class AisCpa: NSObject { diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift index cbb3309f36..94d9528675 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift @@ -1,3 +1,11 @@ +// +// AisTrackerPlugin.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + import CoreLocation import UIKit @@ -13,7 +21,7 @@ final class AisTrackerPlugin: OAPlugin { static let shipLostTimeoutPrefId = "ais_ship_lost_timeout" static let cpaWarningTimePrefId = "ais_cpa_warning_time" static let cpaWarningDistancePrefId = "ais_cpa_warning_distance" - static let debugLoggingPrefId = "ais_debug_logging" + let protocolPref: OACommonInteger let hostPref: OACommonString @@ -24,7 +32,6 @@ final class AisTrackerPlugin: OAPlugin { let shipLostTimeoutPref: OACommonInteger let cpaWarningTimePref: OACommonInteger let cpaWarningDistancePref: OACommonDouble - let debugLoggingPref: OACommonBoolean private let connection = AisNmeaConnection() private let decoder = AisMessageDecoder() @@ -58,7 +65,6 @@ final class AisTrackerPlugin: OAPlugin { shipLostTimeoutPref = OAAppSettings.sharedManager().registerIntPreference(Self.shipLostTimeoutPrefId, defValue: 4) cpaWarningTimePref = OAAppSettings.sharedManager().registerIntPreference(Self.cpaWarningTimePrefId, defValue: 0) cpaWarningDistancePref = OAAppSettings.sharedManager().registerFloatPreference(Self.cpaWarningDistancePrefId, defValue: 1.0) - debugLoggingPref = OAAppSettings.sharedManager().registerBooleanPreference(Self.debugLoggingPrefId, defValue: false) super.init() connection.isDebugLoggingEnabled = { [weak self] in @@ -142,9 +148,9 @@ final class AisTrackerPlugin: OAPlugin { isEnabled() && OAAppSettings.sharedManager().applicationMode.get().isDerivedRouting(from: .boat()) } - func isDebugLoggingEnabled() -> Bool { - debugLoggingPref.get() - } +// func isDebugLoggingEnabled() -> Bool { +// debugLoggingPref.get() +// } func startAisSimulation(_ fileURL: URL) { simulationFileName = fileURL.lastPathComponent diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift index 7af52b0b48..76e5289217 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift @@ -1,4 +1,10 @@ -import UIKit +// +// AisTrackerSettingsViewController.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// @objcMembers final class AisTrackerSettingsViewController: OABaseNavbarViewController { diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h index 94ee7510ab..e4a9a6b649 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h +++ b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h @@ -1,3 +1,11 @@ +// +// OAAisObjectViewController.h +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + #import "OATargetInfoViewController.h" @class AisObject; diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm index 74d2d15dbf..1f0cff1865 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm @@ -1,3 +1,11 @@ +// +// OAAisObjectViewController.m +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + #import "OAAisObjectViewController.h" #import "OAAmenityInfoRow.h" #import "OAPluginsHelper.h" @@ -11,12 +19,6 @@ static const NSInteger kAisRowStartOrder = 100; static const NSInteger kAisRowHeight = 50; -#ifdef DEBUG -#define OAAisMenuLog(format, ...) NSLog((@"[AIS][Menu] " format), ##__VA_ARGS__) -#else -#define OAAisMenuLog(format, ...) -#endif - @implementation OAAisObjectViewController { AisObject *_object; @@ -33,7 +35,6 @@ - (instancetype)initWithAisObject:(AisObject *)object self.location = CLLocationCoordinate2DMake(object.latitude, object.longitude); self.showTitleIfTruncated = NO; self.customOnlinePhotosPosition = YES; - OAAisMenuLog(@"init %@", object.debugSummary); } return self; } @@ -43,7 +44,6 @@ - (void)viewDidLoad [super viewDidLoad]; [self.tableView registerNib:[UINib nibWithNibName:[OAValueTableViewCell reuseIdentifier] bundle:nil] forCellReuseIdentifier:[OAValueTableViewCell reuseIdentifier]]; - OAAisMenuLog(@"viewDidLoad table=%@ height=%.1f %@", self.tableView ? @"yes" : @"no", [self contentHeight], _object.debugSummary); } - (id)getTargetObj @@ -109,8 +109,6 @@ - (void)buildMenu:(NSMutableArray *)rows _menuRows = rows; _aisValueRowKeys = [NSMutableSet set]; [super buildMenu:rows]; - OAAisMenuLog(@"buildMenu rows=%lu height=%.1f %@", (unsigned long)rows.count, [self contentHeight], _object.debugSummary); -} - (void)buildPluginRows:(NSMutableArray *)rows { @@ -179,7 +177,6 @@ - (void)buildInternal:(NSMutableArray *)rows [self addRow:rows key:@"last_update" prefix:@"Last Update" text:[self formatLastUpdate] order:order++]; if (_object.messageTypesString.length > 0) [self addRow:rows key:@"message_types" prefix:@"Message Type(s)" text:_object.messageTypesString order:order++]; - OAAisMenuLog(@"buildInternal rows=%lu %@", (unsigned long)rows.count, _object.debugSummary); } - (BOOL)needBuildCoordinatesRow diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h index bf43ee9541..6e99b5b539 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h @@ -1,3 +1,11 @@ +// +// OAAisTrackerLayer.h +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + #import "OAMapLayer.h" #import "OAContextMenuProvider.h" diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm index d8b7335495..cd7f124425 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm @@ -1,3 +1,11 @@ +// +// OAAisTrackerLayer.m +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + #import "OAAisTrackerLayer.h" #import "OAMapRendererView.h" #import "OANativeUtilities.h" From 3b6885d6427221a63aa6211f6559299879311f44 Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Thu, 11 Jun 2026 16:43:52 +0300 Subject: [PATCH 06/18] add AisLogger --- .../OAOsmandDevelopmentViewController.mm | 7 +- .../Plugins/AisTrackerPlugin/AisLogger.swift | 15 +- .../AisTrackerPlugin/AisNmeaConnection.swift | 39 ++-- .../Plugins/AisTrackerPlugin/AisObject.swift | 174 +++++++++--------- .../AisTrackerPlugin/AisTrackerPlugin.swift | 8 - .../OAAisObjectViewController.mm | 4 +- .../AisTrackerPlugin/OAAisTrackerLayer.mm | 166 +++++++++++------ 7 files changed, 224 insertions(+), 189 deletions(-) diff --git a/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm b/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm index 577efe1dd9..b3a48ebca2 100644 --- a/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm +++ b/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm @@ -123,7 +123,7 @@ - (void)generateData if (aisPlugin) { OATableSectionData *aisSection = [OATableSectionData sectionData]; - aisSection.headerText = OALocalizedString(@"plugin_ais_tracker_name"); + aisSection.headerText = OALocalizedString(@"plugin_ais_tracker_name"); NSString *simulationDescription = aisPlugin.simulationFileName ?: @""; if (aisPlugin.simulationStatusText.length > 0) @@ -144,7 +144,7 @@ - (void)generateData kCellTypeKey : [OASwitchTableViewCell getCellIdentifier], kCellKeyKey : kAisTrackerDebugLoggingKey, kCellTitleKey : @"AIS logging", - @"isOn" : @([aisPlugin.debugLoggingPref get]) + @"isOn" : @([AisLogger shared].isEnabled) }]; [_data addSection:aisSection]; } @@ -324,8 +324,7 @@ - (void)onSwitchPressed:(UISwitch *)sender } else if ([item.key isEqualToString:kAisTrackerDebugLoggingKey]) { - AisTrackerPlugin *aisPlugin = (AisTrackerPlugin *)[OAPluginsHelper getPlugin:AisTrackerPlugin.class]; - [aisPlugin.debugLoggingPref set:sender.isOn]; + [AisLogger shared].isEnabled = sender.isOn; } else if ([item.key isEqualToString:kTraceRenderingKey]) { diff --git a/Sources/Plugins/AisTrackerPlugin/AisLogger.swift b/Sources/Plugins/AisTrackerPlugin/AisLogger.swift index 48bfcdac8a..32633b4c6b 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisLogger.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisLogger.swift @@ -6,13 +6,17 @@ // Copyright © 2026 OsmAnd. All rights reserved. // - +@objcMembers final class AisLogger: NSObject { static let shared = AisLogger() static let debugLoggingPrefId = "ais_debug_logging" - var isEnabled = true + var isEnabled: Bool { + didSet { + debugLoggingPref.set(isEnabled) + } + } private let debugLoggingPref: OACommonBoolean @@ -21,12 +25,9 @@ final class AisLogger: NSObject { isEnabled = debugLoggingPref.get() } - func log(_ message: String, - file: String = #fileID, - function: String = #function, - line: Int = #line) { + func log(_ message: String) { guard isEnabled else { return } - print("[AIS] \(message) (\(file):\(line) \(function))") + print("[AIS] \(message)") } } diff --git a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift index 9ebcc19c98..afb83d9faf 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift @@ -15,16 +15,12 @@ import Network } @objc enum AisNmeaConnectionState: Int { - case disconnected - case connecting - case connected - case failed + case disconnected, connecting, connected, failed } // NOTE: for test: tcp 153.44.253.27 5631 final class AisNmeaConnection { - var isDebugLoggingEnabled: (() -> Bool)? var onLocation: ((CLLocation) -> Void)? var onSentence: ((String) -> Void)? var onStateChanged: ((AisNmeaConnectionState) -> Void)? @@ -37,19 +33,20 @@ final class AisNmeaConnection { private var shouldReconnect = false private var host = "" private var port: UInt16 = 0 + var isRunning: Bool { listener != nil || connection != nil || shouldReconnect } func startUDP(port: UInt16) { stop() - log("start UDP port=\(port)") + AisLogger.shared.log("[AisNmeaConnection] start UDP port=\(port)") updateState(.connecting) do { let params = NWParameters.udp params.allowLocalEndpointReuse = true guard let endpointPort = NWEndpoint.Port(rawValue: port) else { - log("UDP start failed: invalid port \(port)") + AisLogger.shared.log("[AisNmeaConnection] UDP start failed: invalid port \(port)") updateState(.failed) return } @@ -114,21 +111,22 @@ final class AisNmeaConnection { let nwConnection = NWConnection(host: NWEndpoint.Host(host), port: endpointPort, using: .tcp) connection = nwConnection nwConnection.stateUpdateHandler = { [weak self] state in - self?.log("TCP state=\(state)") + guard let self else { return } + log("TCP state=\(state)") switch state { case .ready: - self?.updateState(.connected) - self?.receiveStream(nwConnection) + updateState(.connected) + receiveStream(nwConnection) case .failed(let error): - self?.log("TCP failed error=\(error)") - self?.updateState(.failed) - self?.scheduleReconnect() + log("TCP failed error=\(error)") + updateState(.failed) + scheduleReconnect() case .waiting(let error): - self?.log("TCP waiting error=\(error)") - self?.updateState(.failed) - self?.scheduleReconnect() + log("TCP waiting error=\(error)") + updateState(.failed) + scheduleReconnect() case .cancelled: - self?.updateState(.disconnected) + updateState(.disconnected) default: break } @@ -231,9 +229,8 @@ final class AisNmeaConnection { } return cleanSentence.split(separator: ",", maxSplits: 1).first.map(String.init) ?? "unknown" } - - private func log(_ message: @autoclosure () -> String) { - guard isDebugLoggingEnabled?() == true else { return } - NSLog("[AIS][AisNmeaConnection] %@", message()) + + private func log(_ message: String) { + AisLogger.shared.log("[AisNmeaConnection] \(message)") } } diff --git a/Sources/Plugins/AisTrackerPlugin/AisObject.swift b/Sources/Plugins/AisTrackerPlugin/AisObject.swift index c8a6e53d83..8753c3ae1c 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisObject.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisObject.swift @@ -52,6 +52,8 @@ enum AisObjectConstants { @objcMembers final class AisObject: NSObject { let mmsi: Int + let cpa = AisCpa() + private(set) var msgType: Int private(set) var msgTypes = Set() private(set) var timestamp = 0 @@ -81,15 +83,6 @@ final class AisObject: NSObject { private(set) var destination: String? private(set) var objectClass: AisObjType = .invalid private(set) var lastUpdate = Date() - let cpa = AisCpa() - - init(mmsi: Int, msgType: Int) { - self.mmsi = mmsi - self.msgType = msgType - super.init() - msgTypes.insert(msgType) - updateObjectClass() - } var hasPosition: Bool { latitude != AisObjectConstants.invalidLat && longitude != AisObjectConstants.invalidLon @@ -127,83 +120,14 @@ final class AisObject: NSObject { speed: sog == AisObjectConstants.invalidSog ? -1 : sog * 1852.0 / 3600.0, timestamp: lastUpdate) } - - @objc var debugSummary: String { - let position = hasPosition - ? String(format: "%.6f,%.6f", latitude, longitude) - : "none" - let age = Date().timeIntervalSince(lastUpdate) - return String(format: "mmsi=%d msg=%d msgs=%@ class=%@ shipType=%d rest=%@ movable=%@ nav=%d sog=%.1f cog=%.1f heading=%d pos=%@ age=%.1fs", - mmsi, - msgType, - messageTypesString, - objectClassDebugName, - shipType, - isVesselAtRest ? "yes" : "no", - isMovable ? "yes" : "no", - navStatus, - sog, - cog, - heading, - position, - age) - } - + var currentLocation: CLLocation? { guard let location else { return nil } let ageHours = Date().timeIntervalSince(lastUpdate) / 3600.0 return AisTrackerHelper.newPosition(from: location, ageHours: ageHours) } - - func merge(_ other: AisObject) { - msgType = other.msgType - msgTypes.insert(other.msgType) - if other.timestamp != 0 { timestamp = other.timestamp } - if other.imo != 0 { imo = other.imo } - if other.shipType != AisObjectConstants.invalidShipType { shipType = other.shipType } - if other.dimensionToBow != AisObjectConstants.invalidDimension { dimensionToBow = other.dimensionToBow } - if other.dimensionToStern != AisObjectConstants.invalidDimension { dimensionToStern = other.dimensionToStern } - if other.dimensionToPort != AisObjectConstants.invalidDimension { dimensionToPort = other.dimensionToPort } - if other.dimensionToStarboard != AisObjectConstants.invalidDimension { dimensionToStarboard = other.dimensionToStarboard } - if other.etaMonth != AisObjectConstants.invalidEta { etaMonth = other.etaMonth } - if other.etaDay != AisObjectConstants.invalidEta { etaDay = other.etaDay } - if other.etaHour != AisObjectConstants.invalidEtaHour { etaHour = other.etaHour } - if other.etaMinute != AisObjectConstants.invalidEtaMin { etaMinute = other.etaMinute } - if other.altitude != AisObjectConstants.invalidAltitude { altitude = other.altitude } - if other.aidType != AisObjectConstants.unspecifiedAidType { aidType = other.aidType } - if other.draught != AisObjectConstants.invalidDraught { draught = other.draught } - if other.hasPosition { - latitude = other.latitude - longitude = other.longitude - } - if let value = other.callSign { callSign = value } - if let value = other.shipName { shipName = value } - if let value = other.destination { destination = value } - - if [1, 2, 3, 18, 19, 27].contains(other.msgType) { - heading = other.heading - } - if [1, 2, 3, 27].contains(other.msgType) { - navStatus = other.navStatus - maneuverIndicator = other.maneuverIndicator - rot = other.rot - } - if [1, 2, 3, 9, 18, 19, 27].contains(other.msgType) { - cog = other.cog - sog = other.sog - } - lastUpdate = Date() - updateObjectClass() - } - - func isLost(maxAgeMinutes: Int) -> Bool { - Date().timeIntervalSince(lastUpdate) / 60.0 > Double(maxAgeMinutes) - } - - func signalLost(maxAgeMinutes: Int) -> Bool { - isLost(maxAgeMinutes: maxAgeMinutes) && isMovable && !isVesselAtRest - } - + + var isMovable: Bool { switch objectClass { case .vessel, .vesselSport, .vesselFast, .vesselPassenger, .vesselFreight, .vesselCommercial, .vesselAuthorities, .vesselSar, .vesselOther, .airplane: @@ -352,6 +276,84 @@ final class AisObject: NSObject { default: return "\(aidType)" } } + + @objc var debugSummary: String { + let position = hasPosition + ? String(format: "%.6f,%.6f", latitude, longitude) + : "none" + let age = Date().timeIntervalSince(lastUpdate) + return String(format: "mmsi=%d msg=%d msgs=%@ class=%@ shipType=%d rest=%@ movable=%@ nav=%d sog=%.1f cog=%.1f heading=%d pos=%@ age=%.1fs", + mmsi, + msgType, + messageTypesString, + objectClassDebugName, + shipType, + isVesselAtRest ? "yes" : "no", + isMovable ? "yes" : "no", + navStatus, + sog, + cog, + heading, + position, + age) + } + + init(mmsi: Int, msgType: Int) { + self.mmsi = mmsi + self.msgType = msgType + super.init() + msgTypes.insert(msgType) + updateObjectClass() + } + + func merge(_ other: AisObject) { + msgType = other.msgType + msgTypes.insert(other.msgType) + if other.timestamp != 0 { timestamp = other.timestamp } + if other.imo != 0 { imo = other.imo } + if other.shipType != AisObjectConstants.invalidShipType { shipType = other.shipType } + if other.dimensionToBow != AisObjectConstants.invalidDimension { dimensionToBow = other.dimensionToBow } + if other.dimensionToStern != AisObjectConstants.invalidDimension { dimensionToStern = other.dimensionToStern } + if other.dimensionToPort != AisObjectConstants.invalidDimension { dimensionToPort = other.dimensionToPort } + if other.dimensionToStarboard != AisObjectConstants.invalidDimension { dimensionToStarboard = other.dimensionToStarboard } + if other.etaMonth != AisObjectConstants.invalidEta { etaMonth = other.etaMonth } + if other.etaDay != AisObjectConstants.invalidEta { etaDay = other.etaDay } + if other.etaHour != AisObjectConstants.invalidEtaHour { etaHour = other.etaHour } + if other.etaMinute != AisObjectConstants.invalidEtaMin { etaMinute = other.etaMinute } + if other.altitude != AisObjectConstants.invalidAltitude { altitude = other.altitude } + if other.aidType != AisObjectConstants.unspecifiedAidType { aidType = other.aidType } + if other.draught != AisObjectConstants.invalidDraught { draught = other.draught } + if other.hasPosition { + latitude = other.latitude + longitude = other.longitude + } + if let value = other.callSign { callSign = value } + if let value = other.shipName { shipName = value } + if let value = other.destination { destination = value } + + if [1, 2, 3, 18, 19, 27].contains(other.msgType) { + heading = other.heading + } + if [1, 2, 3, 27].contains(other.msgType) { + navStatus = other.navStatus + maneuverIndicator = other.maneuverIndicator + rot = other.rot + } + if [1, 2, 3, 9, 18, 19, 27].contains(other.msgType) { + cog = other.cog + sog = other.sog + } + lastUpdate = Date() + updateObjectClass() + } + + func isLost(maxAgeMinutes: Int) -> Bool { + Date().timeIntervalSince(lastUpdate) / 60.0 > Double(maxAgeMinutes) + } + + func signalLost(maxAgeMinutes: Int) -> Bool { + isLost(maxAgeMinutes: maxAgeMinutes) && isMovable && !isVesselAtRest + } func applyPosition(timestamp: Int, navStatus: Int, maneuverIndicator: Int, heading: Int, cog: Double, sog: Double, lat: Double, lon: Double, rot: Double) { self.timestamp = timestamp @@ -454,7 +456,7 @@ final class AisObject: NSObject { } } } - + private var objectClassDebugName: String { switch objectClass { case .vessel: return "vessel" @@ -476,10 +478,6 @@ final class AisObject: NSObject { } } -func aisDebugLog(_ message: @autoclosure () -> String) { - guard let plugin = OAPluginsHelper.getPlugin(AisTrackerPlugin.self) as? AisTrackerPlugin, - plugin.isDebugLoggingEnabled() else { - return - } - NSLog("[AIS] %@", message()) +func aisDebugLog(_ message: String) { + AisLogger.shared.log(message) } diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift index 94d9528675..f21cca1ef4 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift @@ -21,7 +21,6 @@ final class AisTrackerPlugin: OAPlugin { static let shipLostTimeoutPrefId = "ais_ship_lost_timeout" static let cpaWarningTimePrefId = "ais_cpa_warning_time" static let cpaWarningDistancePrefId = "ais_cpa_warning_distance" - let protocolPref: OACommonInteger let hostPref: OACommonString @@ -67,9 +66,6 @@ final class AisTrackerPlugin: OAPlugin { cpaWarningDistancePref = OAAppSettings.sharedManager().registerFloatPreference(Self.cpaWarningDistancePrefId, defValue: 1.0) super.init() - connection.isDebugLoggingEnabled = { [weak self] in - self?.isDebugLoggingEnabled() ?? false - } connection.onStateChanged = { [weak self] state in self?.connectionState = state NotificationCenter.default.post(name: .aisNmeaConnectionStateChanged, object: self) @@ -148,10 +144,6 @@ final class AisTrackerPlugin: OAPlugin { isEnabled() && OAAppSettings.sharedManager().applicationMode.get().isDerivedRouting(from: .boat()) } -// func isDebugLoggingEnabled() -> Bool { -// debugLoggingPref.get() -// } - func startAisSimulation(_ fileURL: URL) { simulationFileName = fileURL.lastPathComponent simulationSentences = 0 diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm index 1f0cff1865..201a2317fe 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm @@ -53,7 +53,8 @@ - (id)getTargetObj - (UIImage *)getIcon { - return [UIImage imageNamed:@"ic_plugin_nautical"]; + return [[UIImage imageNamed:ACImageNameIcActionSailBoatDark] + imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal]; } - (NSString *)getTypeStr @@ -109,6 +110,7 @@ - (void)buildMenu:(NSMutableArray *)rows _menuRows = rows; _aisValueRowKeys = [NSMutableSet set]; [super buildMenu:rows]; +} - (void)buildPluginRows:(NSMutableArray *)rows { diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm index cd7f124425..010dbfd83f 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm @@ -14,6 +14,7 @@ #import "OAPointDescription.h" #import "Localization.h" #import "OAAppSettings.h" +#import "GeneratedAssetSymbols.h" #import "OsmAnd_Maps-Swift.h" #include @@ -29,6 +30,7 @@ static const int kAisTrackerStartZoom = 6; static const CGFloat kAisBaseIconSize = 48.0; static const CGFloat kAisDirectionLineStartIconFactor = 0.42; +static const float kAisRenderZoomEpsilon = 0.02f; static const NSTimeInterval kAisViewportRenderUpdateInterval = 0.2; static int kAisIconKeyStorage; static const OsmAnd::MapMarker::OnSurfaceIconKey kAisIconKey = &kAisIconKeyStorage; @@ -38,25 +40,6 @@ return [NSString stringWithFormat:OALocalizedString(@"ais_object_with_mmsi"), (long)object.mmsi]; } -static BOOL OAAisDebugLoggingEnabled() -{ - AisTrackerPlugin *plugin = (AisTrackerPlugin *)[OAPluginsHelper getPlugin:AisTrackerPlugin.class]; - return plugin && [plugin isDebugLoggingEnabled]; -} - -static void OAAisLayerLog(NSString *format, ...) NS_FORMAT_FUNCTION(1, 2); -static void OAAisLayerLog(NSString *format, ...) -{ - if (!OAAisDebugLoggingEnabled()) - return; - - va_list args; - va_start(args, format); - NSString *message = [[NSString alloc] initWithFormat:format arguments:args]; - va_end(args); - NSLog(@"[AIS][Layer] %@", message); -} - @interface AisObjectDrawable : NSObject @property (nonatomic) AisObject *object; @@ -70,9 +53,12 @@ - (void)set:(AisObject *)object; - (void)setTextScale:(CGFloat)textScale displayDensityFactor:(CGFloat)displayDensityFactor; - (BOOL)hasAisRenderData; +- (BOOL)hasAnyAisRenderData; +- (int)renderGroupId; - (NSString *)currentRenderKey; - (OsmAnd::PointI)markerLocation; - (void)setAisRenderDataHidden:(BOOL)hidden; +- (void)setAisMarkersUpdateAfterCreated; - (void)createAisRenderDataWithBaseOrder:(int)baseOrder markersCollection:(const std::shared_ptr &)markersCollection vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection; @@ -91,6 +77,7 @@ @implementation AisObjectDrawable std::shared_ptr _directionLine; CGFloat _textScale; CGFloat _displayDensityFactor; + int _baseOrder; } - (instancetype)initWithObject:(AisObject *)object @@ -116,8 +103,7 @@ - (void)set:(AisObject *)object _object = object; } -- (void)setTextScale:(CGFloat)textScale - displayDensityFactor:(CGFloat)displayDensityFactor +- (void)setTextScale:(CGFloat)textScale displayDensityFactor:(CGFloat)displayDensityFactor { _textScale = textScale > 0 ? textScale : 1.0; _displayDensityFactor = MAX(1.0, displayDensityFactor); @@ -128,6 +114,16 @@ - (BOOL)hasAisRenderData return _activeMarker && _restMarker && _lostMarker && _directionLine; } +- (BOOL)hasAnyAisRenderData +{ + return _activeMarker || _restMarker || _lostMarker || _directionLine; +} + +- (int)renderGroupId +{ + return (int)_object.mmsi; +} + - (NSString *)currentRenderKey { return [NSString stringWithFormat:@"surface-v3-%@-%d", [self iconResourceNameForType:_object.objectClass], (int)std::round([self iconSize] * 100.0)]; @@ -152,6 +148,17 @@ - (void)setAisRenderDataHidden:(BOOL)hidden _lostMarker->setIsHidden(hidden); if (_directionLine) _directionLine->setIsHidden(hidden); + [self setAisMarkersUpdateAfterCreated]; +} + +- (void)setAisMarkersUpdateAfterCreated +{ + if (_activeMarker) + _activeMarker->setUpdateAfterCreated(true); + if (_restMarker) + _restMarker->setUpdateAfterCreated(true); + if (_lostMarker) + _lostMarker->setUpdateAfterCreated(true); } - (void)createAisRenderDataWithBaseOrder:(int)baseOrder @@ -161,24 +168,39 @@ - (void)createAisRenderDataWithBaseOrder:(int)baseOrder if (!markersCollection || !vectorLinesCollection) return; + [self clearAisRenderDataFromMarkersCollection:markersCollection vectorLinesCollection:vectorLinesCollection]; + _baseOrder = baseOrder; + + sk_sp activeIcon = [self iconImageForState:0]; + sk_sp restIcon = [self iconImageForState:1]; + sk_sp lostIcon = [self iconImageForState:2]; + if (!activeIcon || !restIcon || !lostIcon) + return; + OsmAnd::MapMarkerBuilder markerBuilder; OsmAnd::PointI markerLocation = [self markerLocation]; markerBuilder + .setGroupId([self renderGroupId]) + .setMarkerId(0) .setBaseOrder(baseOrder) .setIsHidden(true) + .setUpdateAfterCreated(true) .setPosition(markerLocation) - .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage([self iconImageForState:0])); + .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage(activeIcon)); _activeMarker = markerBuilder.buildAndAddToCollection(markersCollection); markerBuilder + .setMarkerId(1) .clearOnMapSurfaceIcons() - .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage([self iconImageForState:1])); + .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage(restIcon)); _restMarker = markerBuilder.buildAndAddToCollection(markersCollection); markerBuilder + .setMarkerId(2) .clearOnMapSurfaceIcons() - .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage([self iconImageForState:2])); + .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage(lostIcon)); _lostMarker = markerBuilder.buildAndAddToCollection(markersCollection); + [self setAisMarkersUpdateAfterCreated]; QVector points; points.push_back(markerLocation); @@ -186,7 +208,7 @@ - (void)createAisRenderDataWithBaseOrder:(int)baseOrder OsmAnd::VectorLineBuilder lineBuilder; lineBuilder - .setLineId((int)_object.mmsi) + .setLineId([self renderGroupId]) .setBaseOrder(baseOrder + 10) .setIsHidden(true) .setLineWidth(6.0) @@ -196,6 +218,11 @@ - (void)createAisRenderDataWithBaseOrder:(int)baseOrder _directionLine = lineBuilder.buildAndAddToCollection(vectorLinesCollection); _renderKey = [self currentRenderKey]; + if (![self hasAisRenderData]) + { + [self clearAisRenderDataFromMarkersCollection:markersCollection vectorLinesCollection:vectorLinesCollection]; + return; + } [self updateAisRenderDataWithMapView:nil plugin:nil]; } @@ -251,14 +278,15 @@ - (void)updateAisRenderDataWithMapView:(OAMapRendererView *)mapView _activeMarker->setPosition(markerLocation); _restMarker->setPosition(markerLocation); _lostMarker->setPosition(markerLocation); + [self setAisMarkersUpdateAfterCreated]; if (drawDirectionLine && _directionLine) { - int inverseZoom = (int)mapView.maxZoom - (int)zoom; + double inverseZoom = mapView.maxZoom - mapView.zoom; double zoomFactor = std::pow(2.0, inverseZoom); CGFloat iconSize = [self iconSize]; - double lineLength = speedFactor * zoomFactor * iconSize * 0.75; - double lineStartOffset = std::min(lineLength * 0.8, zoomFactor * iconSize * kAisDirectionLineStartIconFactor); + double lineStartOffset = zoomFactor * iconSize * kAisDirectionLineStartIconFactor; + double lineLength = std::max(speedFactor * zoomFactor * iconSize * 0.75, lineStartOffset + zoomFactor * iconSize * 0.25); double theta = rotation * M_PI / 180.0; int startDx = (int)ceil(-sin(theta) * lineStartOffset); int startDy = (int)ceil(cos(theta) * lineStartOffset); @@ -279,6 +307,7 @@ - (void)clearAisRenderDataFromMarkersCollection:(const std::shared_ptrremoveMarkersByGroupId([self renderGroupId]); if (_activeMarker) markersCollection->removeMarker(_activeMarker); if (_restMarker) @@ -286,10 +315,22 @@ - (void)clearAisRenderDataFromMarkersCollection:(const std::shared_ptrremoveMarker(_lostMarker); } - if (vectorLinesCollection && _directionLine) + if (vectorLinesCollection) { - _directionLine->setIsHidden(true); - vectorLinesCollection->removeLine(_directionLine); + const int lineId = [self renderGroupId]; + for (const auto& line : vectorLinesCollection->getLines()) + { + if (line && line->lineId == lineId) + { + line->setIsHidden(true); + vectorLinesCollection->removeLine(line); + } + } + if (_directionLine) + { + _directionLine->setIsHidden(true); + vectorLinesCollection->removeLine(_directionLine); + } } _activeMarker.reset(); @@ -472,6 +513,7 @@ @implementation OAAisTrackerLayer BOOL _hasLastRenderViewport; OsmAnd::AreaI _lastRenderBBox31; int _lastRenderZoom; + float _lastRenderSurfaceZoom; NSTimeInterval _lastViewportRenderUpdateTime; } @@ -486,6 +528,7 @@ - (instancetype)initWithMapViewController:(OAMapViewController *)mapViewControll _displayDensityFactor = MAX(1.0, mapViewController.displayDensityFactor); _hasLastRenderViewport = NO; _lastRenderZoom = -1; + _lastRenderSurfaceZoom = -1.0f; _lastViewportRenderUpdateTime = 0; } return self; @@ -638,10 +681,7 @@ - (BOOL)updateLayer BOOL scaleChanged = [self updateScaleCache]; if (scaleChanged) - { - OAAisLayerLog(@"scale changed textScale=%.2f density=%.2f iconSize=%.1f", _textScale, _displayDensityFactor, [self currentIconSize]); [self cleanupResources]; - } [self.app.data.mapLayersConfiguration setLayer:self.layerId Visibility:self.isVisible]; @@ -657,19 +697,19 @@ - (BOOL)updateLayer return YES; } -//- (void)onMapFrameRendered -//{ -// if (![self isVisible]) -// { -// [self removeCollectionsFromRenderer]; -// _hasLastRenderViewport = NO; -// _lastViewportRenderUpdateTime = 0; -// return; -// } -// if (![self shouldUpdateRenderDataForViewport]) -// return; -// [self updateRenderData]; -//} +- (void)onMapFrameRendered +{ + if (![self isVisible]) + { + [self removeCollectionsFromRenderer]; + _hasLastRenderViewport = NO; + _lastViewportRenderUpdateTime = 0; + return; + } + if (![self shouldUpdateRenderDataForViewport]) + return; + [self updateRenderData]; +} - (void)resetCollections @@ -759,7 +799,9 @@ - (void)reloadObjectsSync } [drawable setTextScale:_textScale displayDensityFactor:_displayDensityFactor]; [drawable set:object]; - if ([drawable hasAisRenderData] && ![drawable.renderKey isEqualToString:[drawable currentRenderKey]]) + BOOL renderKeyChanged = [drawable hasAisRenderData] && ![drawable.renderKey isEqualToString:[drawable currentRenderKey]]; + BOOL partialRenderData = [drawable hasAnyAisRenderData] && ![drawable hasAisRenderData]; + if (renderKeyChanged || partialRenderData) [drawable clearAisRenderDataFromMarkersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; if (![drawable hasAisRenderData]) [drawable createAisRenderDataWithBaseOrder:self.baseOrder markersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; @@ -781,8 +823,7 @@ - (void)onAisObjectReceived:(AisObject *)object { if (![self isVisible] || !object.hasPosition) return; - - OAAisLayerLog(@"receive %@", object.debugSummary); + [[AisLogger shared] log:[NSString stringWithFormat:@"receive %@", object.debugSummary]]; [self addCollectionsToRenderer]; [self.mapViewController runWithRenderSync:^{ [self updateAisObjectSync:object]; @@ -797,7 +838,8 @@ - (void)onAisObjectRemoved:(AisObject *)object [self.mapViewController runWithRenderSync:^{ NSNumber *key = @(object.mmsi); AisObjectDrawable *drawable = _objectDrawables[key]; - OAAisLayerLog(@"remove hasDrawable=%@ drawables=%lu %@", drawable ? @"yes" : @"no", (unsigned long)_objectDrawables.count, object.debugSummary); + [[AisLogger shared] log:[NSString stringWithFormat:@"remove hasDrawable=%@ drawables=%lu %@", + drawable ? @"yes" : @"no", (unsigned long)_objectDrawables.count, object.debugSummary]]; if (drawable) { [drawable clearAisRenderDataFromMarkersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; @@ -819,14 +861,17 @@ - (void)updateAisObjectSync:(AisObject *)object } [drawable setTextScale:_textScale displayDensityFactor:_displayDensityFactor]; [drawable set:object]; - BOOL recreated = [drawable hasAisRenderData] && ![drawable.renderKey isEqualToString:[drawable currentRenderKey]]; + BOOL renderKeyChanged = [drawable hasAisRenderData] && ![drawable.renderKey isEqualToString:[drawable currentRenderKey]]; + BOOL partialRenderData = [drawable hasAnyAisRenderData] && ![drawable hasAisRenderData]; + BOOL recreated = renderKeyChanged || partialRenderData; if (recreated) [drawable clearAisRenderDataFromMarkersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; if (![drawable hasAisRenderData]) [drawable createAisRenderDataWithBaseOrder:self.baseOrder markersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; [drawable updateAisRenderDataWithMapView:self.mapView plugin:[self plugin]]; int linesCount = _vectorLinesCollection ? _vectorLinesCollection->getLinesCount() : 0; - OAAisLayerLog(@"update recreated=%@ drawables=%lu lines=%d %@", recreated ? @"yes" : @"no", (unsigned long)_objectDrawables.count, linesCount, object.debugSummary); + + [[AisLogger shared] log:[NSString stringWithFormat:@"update recreated=%@ drawables=%lu lines=%d %@", recreated ? @"yes" : @"no", (unsigned long)_objectDrawables.count, linesCount, object.debugSummary]]; [[self plugin] updateSimulationRenderedObjects:_objectDrawables.count]; } @@ -848,19 +893,23 @@ - (BOOL)shouldUpdateRenderDataForViewport const OsmAnd::AreaI visibleBBox31 = [mapView getVisibleBBox31]; const int zoom = (int)mapView.zoomLevel; + const float surfaceZoom = mapView.zoom; + const BOOL surfaceZoomChanged = std::fabs(_lastRenderSurfaceZoom - surfaceZoom) > kAisRenderZoomEpsilon; if (!_hasLastRenderViewport || _lastRenderZoom != zoom + || surfaceZoomChanged || _lastRenderBBox31.left() != visibleBBox31.left() || _lastRenderBBox31.top() != visibleBBox31.top() || _lastRenderBBox31.right() != visibleBBox31.right() || _lastRenderBBox31.bottom() != visibleBBox31.bottom()) { NSTimeInterval now = [[NSDate date] timeIntervalSince1970]; - if (_hasLastRenderViewport && now - _lastViewportRenderUpdateTime < kAisViewportRenderUpdateInterval) + if (!surfaceZoomChanged && _hasLastRenderViewport && now - _lastViewportRenderUpdateTime < kAisViewportRenderUpdateInterval) return NO; _lastRenderBBox31 = visibleBBox31; _lastRenderZoom = zoom; + _lastRenderSurfaceZoom = surfaceZoom; _hasLastRenderViewport = YES; _lastViewportRenderUpdateTime = now; return YES; @@ -888,7 +937,9 @@ - (OATargetPoint *)getTargetPoint:(id)obj touchLocation:(CLLocation *)touchLocat targetPoint.titleAddress = object.navStatusString.length > 0 ? object.navStatusString : nil; targetPoint.shouldFetchAddress = NO; targetPoint.location = location.coordinate; - targetPoint.icon = [UIImage imageNamed:@"ic_plugin_nautical"]; + + targetPoint.icon = [[UIImage imageNamed:ACImageNameIcActionSailBoatDark] + imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal]; targetPoint.sortIndex = OATargetAisObject; targetPoint.centerMap = NO; return targetPoint; @@ -953,7 +1004,6 @@ - (void)collectObjectsFromPoint:(MapSelectionResult *)result unknownLocation:(BO return; NSArray *objects = [[self plugin] getAisObjects]; - BOOL collected = NO; for (AisObject *object in objects) { CLLocation *location = object.location; @@ -965,12 +1015,8 @@ - (void)collectObjectsFromPoint:(MapSelectionResult *)result unknownLocation:(BO polygon31:touchPolygon31]) { [result collect:object provider:self]; - collected = YES; - OAAisLayerLog(@"hit-test collect radius=%d %@", radius, object.debugSummary); } } - if (!collected) - OAAisLayerLog(@"hit-test miss radius=%d objects=%lu point=(%.1f, %.1f)", radius, (unsigned long)objects.count, point.x, point.y); } - (NSString *)objectTypeName:(AisObjType)type From 79c01a8cadd6addc7de2fbf5d6d8efb6c446dd56 Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Fri, 12 Jun 2026 09:58:07 +0300 Subject: [PATCH 07/18] add OAAisImageCacheKey --- .../Images.xcassets/Images/Contents.json | 6 +++ .../Images/ais_map.imageset/Contents.json | 12 +++++ .../Images/ais_map.imageset/ais_map.png | Bin 0 -> 46783 bytes .../Panels/OAMapPanelViewController.mm | 10 +++- .../OAPluginInstalledViewController.mm | 2 + .../AisTrackerPlugin/AisTrackerPlugin.swift | 5 +- .../AisTrackerPlugin/OAAisTrackerLayer.mm | 47 +++++++++++++++--- .../AisTrackerPlugin/OAAisTrackerPlugin.swift | 2 +- Sources/Purchases/OAProducts.mm | 6 ++- 9 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 Resources/Images.xcassets/Images/Contents.json create mode 100644 Resources/Images.xcassets/Images/ais_map.imageset/Contents.json create mode 100644 Resources/Images.xcassets/Images/ais_map.imageset/ais_map.png diff --git a/Resources/Images.xcassets/Images/Contents.json b/Resources/Images.xcassets/Images/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Resources/Images.xcassets/Images/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Images/ais_map.imageset/Contents.json b/Resources/Images.xcassets/Images/ais_map.imageset/Contents.json new file mode 100644 index 0000000000..8a3853bbd0 --- /dev/null +++ b/Resources/Images.xcassets/Images/ais_map.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ais_map.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Images.xcassets/Images/ais_map.imageset/ais_map.png b/Resources/Images.xcassets/Images/ais_map.imageset/ais_map.png new file mode 100644 index 0000000000000000000000000000000000000000..6b99f9335ad4cfe38ff78f3316423e645cdbb19a GIT binary patch literal 46783 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

TjyYFPs}Nok@<4 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%0A!lcrGprSHHJDH8k+GKZvR8pNeSDJlr^gNSMi-8a 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 GNBke$V>V|1 literal 0 HcmV?d00001 diff --git a/Sources/Controllers/Panels/OAMapPanelViewController.mm b/Sources/Controllers/Panels/OAMapPanelViewController.mm index f38fb01b7b..0dd60d838f 100644 --- a/Sources/Controllers/Panels/OAMapPanelViewController.mm +++ b/Sources/Controllers/Panels/OAMapPanelViewController.mm @@ -1486,7 +1486,10 @@ - (void)showContextMenu:(OATargetPoint *)targetPoint saveState:(BOOL)saveState p [_mapViewController hidePolygonHighlight]; } // show context marker on map - [_mapViewController showContextPinMarker:targetPoint.location.latitude longitude:targetPoint.location.longitude animated:YES]; + if (targetPoint.type == OATargetAisObject) + [_mapViewController hideContextPinMarker]; + else + [_mapViewController showContextPinMarker:targetPoint.location.latitude longitude:targetPoint.location.longitude animated:YES]; [self applyTargetPoint:targetPoint]; [_targetMenuView setTargetPoint:targetPoint]; @@ -1648,7 +1651,10 @@ - (void)updateTargetPoint:(OATargetPoint *)targetPoint - (void) updateContextMenu:(OATargetPoint *)targetPoint { // show context marker on map - [_mapViewController showContextPinMarker:targetPoint.location.latitude longitude:targetPoint.location.longitude animated:YES]; + if (targetPoint.type == OATargetAisObject) + [_mapViewController hideContextPinMarker]; + else + [_mapViewController showContextPinMarker:targetPoint.location.latitude longitude:targetPoint.location.longitude animated:YES]; [self applyTargetPoint:targetPoint]; [_targetMenuView setTargetPoint:targetPoint]; diff --git a/Sources/Controllers/Settings/ImportExport/OAPluginInstalledViewController.mm b/Sources/Controllers/Settings/ImportExport/OAPluginInstalledViewController.mm index b85dabe6d4..77fb1c829c 100644 --- a/Sources/Controllers/Settings/ImportExport/OAPluginInstalledViewController.mm +++ b/Sources/Controllers/Settings/ImportExport/OAPluginInstalledViewController.mm @@ -103,6 +103,8 @@ - (void)viewDidLoad self.tableView.delegate = self; self.tableView.tableHeaderView = [self getHeaderForTableView:self.tableView withFirstSectionText:self.descriptionText boldFragment:self.descriptionBoldText]; + self.tableView.sectionFooterHeight = UITableViewAutomaticDimension; + self.tableView.estimatedSectionFooterHeight = 44.0; [self setupView]; diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift index f21cca1ef4..3a11b0d00c 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift @@ -92,9 +92,12 @@ final class AisTrackerPlugin: OAPlugin { override func getName() -> String { localizedString("plugin_ais_tracker_name") } - + override func getDescription() -> String { localizedString("plugin_ais_tracker_description") + + "

" + + localizedString("plugin_ais_tracker_disclaimer") + .replacingOccurrences(of: "\n\n", with: "

") } override func getLogoResourceId() -> String? { diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm index 010dbfd83f..db4fadc964 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm @@ -24,9 +24,10 @@ #include #include #include +#include +#include -#define kAisTrackerLayerId @"ais_tracker_layer" - +static NSString * const kAisTrackerLayerId = @"ais_tracker_layer"; static const int kAisTrackerStartZoom = 6; static const CGFloat kAisBaseIconSize = 48.0; static const CGFloat kAisDirectionLineStartIconFactor = 0.42; @@ -34,6 +35,26 @@ static const NSTimeInterval kAisViewportRenderUpdateInterval = 0.2; static int kAisIconKeyStorage; static const OsmAnd::MapMarker::OnSurfaceIconKey kAisIconKey = &kAisIconKeyStorage; +static std::unordered_map> kAisImagesCache; + +static std::string OAAisImageCacheKey(NSString *prefix, NSString *name, CGFloat iconSize) +{ + NSString *key = [NSString stringWithFormat:@"%@:%@:%d", prefix, name, (int)std::round(iconSize * 100.0)]; + return std::string(key.UTF8String); +} + +static sk_sp OAAisCachedSvgImage(NSString *resourceName, CGFloat iconSize) +{ + std::string key = OAAisImageCacheKey(@"svg", resourceName, iconSize); + const auto cachedImage = kAisImagesCache.find(key); + if (cachedImage != kAisImagesCache.end()) + return cachedImage->second; + + sk_sp image = [OANativeUtilities skImageFromSvgResource:resourceName width:iconSize height:iconSize]; + if (image) + kAisImagesCache[key] = image; + return image; +} static NSString *OAAisObjectTitle(AisObject *object) { @@ -346,11 +367,17 @@ - (void)clearAisRenderDataFromMarkersCollection:(const std::shared_ptr image = [OANativeUtilities skImageFromSvgResource:resourceName width:iconSize height:iconSize]; + sk_sp image = OAAisCachedSvgImage(resourceName, iconSize); if (image) return image; } + NSString *drawnKeyName = [NSString stringWithFormat:@"%ld:%ld", (long)state, (long)_object.objectClass]; + std::string drawnKey = OAAisImageCacheKey(@"drawn", drawnKeyName, iconSize); + const auto cachedImage = kAisImagesCache.find(drawnKey); + if (cachedImage != kAisImagesCache.end()) + return cachedImage->second; + CGSize size = CGSizeMake(iconSize, iconSize); UIGraphicsBeginImageContextWithOptions(size, NO, 1.0); CGFloat sizeFactor = iconSize / 72.0; @@ -426,7 +453,10 @@ - (void)clearAisRenderDataFromMarkersCollection:(const std::shared_ptr skImage = [OANativeUtilities skImageFromCGImage:image.CGImage]; + if (skImage) + kAisImagesCache[drawnKey] = skImage; + return skImage; } - (CGFloat)iconSize @@ -678,10 +708,12 @@ - (BOOL)updateLayer { if (![super updateLayer]) return NO; - BOOL scaleChanged = [self updateScaleCache]; if (scaleChanged) + { + kAisImagesCache.clear(); [self cleanupResources]; + } [self.app.data.mapLayersConfiguration setLayer:self.layerId Visibility:self.isVisible]; @@ -701,9 +733,8 @@ - (void)onMapFrameRendered { if (![self isVisible]) { - [self removeCollectionsFromRenderer]; - _hasLastRenderViewport = NO; - _lastViewportRenderUpdateTime = 0; + kAisImagesCache.clear(); + [self cleanupResources]; return; } if (![self shouldUpdateRenderDataForViewport]) diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift index 63f3b97c5d..5b33e6aa8c 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift @@ -95,7 +95,7 @@ final class AisTrackerPlugin: OAPlugin { } override func getDescription() -> String { - localizedString("plugin_ais_tracker_description") + localizedString("plugin_ais_tracker_description") + "\n\n" + localizedString("plugin_ais_tracker_disclaimer") } override func getLogoResourceId() -> String? { diff --git a/Sources/Purchases/OAProducts.mm b/Sources/Purchases/OAProducts.mm index 8cc15034f1..849ada6e03 100644 --- a/Sources/Purchases/OAProducts.mm +++ b/Sources/Purchases/OAProducts.mm @@ -2551,7 +2551,7 @@ - (NSString *)productIconName - (NSString *)productScreenshotName { - return @"img_plugin_nautical.jpg"; + return @"ais_map"; } - (NSString *)localizedTitle @@ -2566,7 +2566,9 @@ - (NSString *)localizedDescription - (NSString *)localizedDescriptionExt { - return OALocalizedString(@"plugin_ais_tracker_description"); + return [NSString stringWithFormat:@"%@\n\n%@", + OALocalizedString(@"plugin_ais_tracker_description"), + OALocalizedString(@"plugin_ais_tracker_disclaimer")]; } @end From cf77d15a92aeff5107787995e82eef622a7eb10c Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Fri, 12 Jun 2026 12:12:48 +0300 Subject: [PATCH 08/18] add OAAisTrackerLayerBridge --- OsmAnd.xcodeproj/project.pbxproj | 6 ++ Sources/OsmAnd Maps-Bridging-Header.h | 1 + .../AisTrackerPlugin/AisDataManager.swift | 8 +-- .../AisSimulationProvider.swift | 13 ++-- .../AisTrackerPlugin/AisTrackerPlugin.swift | 60 +++++++--------- .../AisTrackerPlugin/OAAisTrackerLayer.h | 6 ++ .../AisTrackerPlugin/OAAisTrackerLayer.mm | 71 +++---------------- .../OAAisTrackerLayerBridge.h | 19 +++++ .../OAAisTrackerLayerBridge.mm | 39 ++++++++++ .../AisTrackerPlugin/OAAisTrackerPlugin.swift | 29 ++++---- 10 files changed, 127 insertions(+), 125 deletions(-) create mode 100644 Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.h create mode 100644 Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.mm diff --git a/OsmAnd.xcodeproj/project.pbxproj b/OsmAnd.xcodeproj/project.pbxproj index acab97c20e..67f5e2a3b1 100644 --- a/OsmAnd.xcodeproj/project.pbxproj +++ b/OsmAnd.xcodeproj/project.pbxproj @@ -1373,6 +1373,7 @@ 9F4844B32FD0000100484401 /* OAAisTrackerLayer.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */; }; 9F4844B52FD0000100484401 /* AisTrackerHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */; }; 9F4844B82FD0000100484401 /* OAAisObjectViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */; }; + 9F4844C22FD0000100484401 /* OAAisTrackerLayerBridge.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844C12FD0000100484401 /* OAAisTrackerLayerBridge.mm */; }; B0D6100B2E010FD6F3088B80 /* OATrackMenuHeaderView.mm in Sources */ = {isa = PBXBuildFile; fileRef = B0D61823DCC461FF03FE8103 /* OATrackMenuHeaderView.mm */; }; B0D610BD70EF0179933FD55C /* OAAddWaypointViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = B0D618034074DE575CDAACDD /* OAAddWaypointViewController.mm */; }; B0D610C833B6EA61B1C74268 /* OADeleteWaypointsGroupBottomSheetViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = B0D61BD47F1716F617480A05 /* OADeleteWaypointsGroupBottomSheetViewController.mm */; }; @@ -5133,6 +5134,8 @@ 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerHelper.swift; sourceTree = ""; }; 9F4844B62FD0000100484401 /* OAAisObjectViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OAAisObjectViewController.h; sourceTree = ""; }; 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OAAisObjectViewController.mm; sourceTree = ""; }; + 9F4844C02FD0000100484401 /* OAAisTrackerLayerBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OAAisTrackerLayerBridge.h; sourceTree = ""; }; + 9F4844C12FD0000100484401 /* OAAisTrackerLayerBridge.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OAAisTrackerLayerBridge.mm; sourceTree = ""; }; ADA2D9C11AEC31EA6BB2AF2C /* Pods-OsmAnd Maps.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OsmAnd Maps.release.xcconfig"; path = "Target Support Files/Pods-OsmAnd Maps/Pods-OsmAnd Maps.release.xcconfig"; sourceTree = ""; }; B0D610268394E6C3CF0F3C8C /* OAEditWaypointsGroupBottomSheetViewController.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = OAEditWaypointsGroupBottomSheetViewController.mm; sourceTree = ""; }; B0D61037ED3E628F3BB6C1AA /* OASelectionCollapsableCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OASelectionCollapsableCell.h; sourceTree = ""; }; @@ -10423,6 +10426,8 @@ 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */, 9F4844B12FD0000100484401 /* OAAisTrackerLayer.h */, 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */, + 9F4844C02FD0000100484401 /* OAAisTrackerLayerBridge.h */, + 9F4844C12FD0000100484401 /* OAAisTrackerLayerBridge.mm */, 9F4844B62FD0000100484401 /* OAAisObjectViewController.h */, 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */, ); @@ -18025,6 +18030,7 @@ 9F4844B02FD0000100484401 /* AisMessageDecoder.swift in Sources */, 9F4844B32FD0000100484401 /* OAAisTrackerLayer.mm in Sources */, 9F4844B52FD0000100484401 /* AisTrackerHelper.swift in Sources */, + 9F4844C22FD0000100484401 /* OAAisTrackerLayerBridge.mm in Sources */, 9F4844B82FD0000100484401 /* OAAisObjectViewController.mm in Sources */, DA5A813B26C563A700F274C7 /* OAOsmMapUtils.mm in Sources */, DA5A856026C563A900F274C7 /* OARTargetPoint.mm in Sources */, diff --git a/Sources/OsmAnd Maps-Bridging-Header.h b/Sources/OsmAnd Maps-Bridging-Header.h index 2281c70491..654fc58265 100644 --- a/Sources/OsmAnd Maps-Bridging-Header.h +++ b/Sources/OsmAnd Maps-Bridging-Header.h @@ -156,6 +156,7 @@ #import "OAMapViewController.h" #import "OARootViewController.h" #import "OAMapPanelViewController.h" +#import "OAAisTrackerLayerBridge.h" #import "OAPOILayer.h" #import "OABaseNavbarViewController.h" #import "OABaseButtonsViewController.h" diff --git a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift index 2c6b94201d..9bda7cbe18 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift @@ -7,9 +7,6 @@ // extension Notification.Name { - static let aisObjectReceived = Notification.Name("OAAisObjectReceived") - static let aisObjectRemoved = Notification.Name("OAAisObjectRemoved") - static let aisObjectsChanged = Notification.Name("OAAisObjectsChanged") static let aisSimulationStatusChanged = Notification.Name("OAAisSimulationStatusChanged") } @@ -45,7 +42,7 @@ final class AisDataManager: NSObject { func cleanupResources() { stopUpdates() objectsByMmsi.removeAll() - NotificationCenter.default.post(name: .aisObjectsChanged, object: plugin) + plugin?.onAisObjectsChanged() } func onAisObjectReceived(_ ais: AisObject) { @@ -76,9 +73,6 @@ final class AisDataManager: NSObject { aisDebugLog("[AisDataManager] data remove-lost maxAge=\(maxAge)m total=\(objectsByMmsi.count) \(object.debugSummary)") plugin.onAisObjectRemoved(object) } - if !removed.isEmpty { - NotificationCenter.default.post(name: .aisObjectsChanged, object: plugin) - } } private func removeOldestObject() { diff --git a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift index 4983f71d53..87bcd8d92c 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift @@ -7,7 +7,6 @@ // import CoreLocation -import Foundation final class AisMessageSimulationListener { private weak var plugin: AisTrackerPlugin? @@ -16,6 +15,12 @@ final class AisMessageSimulationListener { private let queue = DispatchQueue(label: "net.osmand.ais.simulation.listener") private let lock = NSLock() private var cancelled = false + + private var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled + } init(plugin: AisTrackerPlugin, fileURL: URL, latency: TimeInterval) { self.plugin = plugin @@ -61,12 +66,6 @@ final class AisMessageSimulationListener { setCancelled(true) } - private var isCancelled: Bool { - lock.lock() - defer { lock.unlock() } - return cancelled - } - private func setCancelled(_ cancelled: Bool) { lock.lock() self.cancelled = cancelled diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift index 3a11b0d00c..8598ee2fdb 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift @@ -7,7 +7,11 @@ // import CoreLocation -import UIKit + +extension Notification.Name { + static let aisNmeaConnectionStateChanged = Notification.Name("OAAisNmeaConnectionStateChanged") + static let aisNmeaLocationReceived = Notification.Name("OAAisNmeaLocationReceived") +} @objcMembers final class AisTrackerPlugin: OAPlugin { @@ -230,16 +234,6 @@ final class AisTrackerPlugin: OAPlugin { aisDataManager.stopUpdates() } - private func updateConnectionForCurrentProfile() { - if isActiveForCurrentProfile() { - if !connection.isRunning { - restartConnection() - } - } else { - stopAisNetworkListener() - } - } - func fakeOwnPosition(_ location: CLLocation?) { fakeOwnLocation = location } @@ -262,7 +256,7 @@ final class AisTrackerPlugin: OAPlugin { func getAisObjects() -> [AisObject] { aisDataManager.objects } - // FIXME: cache for objectLostTimeoutPref shipLostTimeoutPref cpaWarningTimePref cpaWarningDistancePref + func maxObjectAgeInMinutes() -> Int { max(1, Int(objectLostTimeoutPref.get())) } @@ -297,12 +291,22 @@ final class AisTrackerPlugin: OAPlugin { } } aisDebugLog("plugin received withPosition=\(getAisObjects().filter(\.hasPosition).count) \(object.debugSummary)") - NotificationCenter.default.post(name: .aisObjectReceived, object: self, userInfo: ["object": object]) + DispatchQueue.main.async { + OAAisTrackerLayerBridge.onAisObjectReceived(object) + } } func onAisObjectRemoved(_ object: AisObject) { aisDebugLog("plugin removed \(object.debugSummary)") - NotificationCenter.default.post(name: .aisObjectRemoved, object: self, userInfo: ["object": object]) + DispatchQueue.main.async { + OAAisTrackerLayerBridge.onAisObjectRemoved(object) + } + } + + func onAisObjectsChanged() { + DispatchQueue.main.async { + OAAisTrackerLayerBridge.reloadAisObjects() + } } func hasCpaWarning(for object: AisObject) -> Bool { @@ -380,20 +384,15 @@ final class AisTrackerPlugin: OAPlugin { } } -// private func handleAisSentence(_ sentence: String) { -// Task { -// guard let object = await decoder.decode(sentence: sentence) else { return } -// -// await MainActor.run { -// self.aisDataManager.onAisObjectReceived(object) -// } -// } -// } - -// private func handleAisSentence(_ sentence: String) { -// guard let object = decoder.decode(sentence: sentence) else { return } -// aisDataManager.onAisObjectReceived(object) -// } + private func updateConnectionForCurrentProfile() { + if isActiveForCurrentProfile() { + if !connection.isRunning { + restartConnection() + } + } else { + stopAisNetworkListener() + } + } private func handleAisSentence(_ sentence: String) { aisDecoderQueue.async { [weak self] in @@ -438,8 +437,3 @@ final class AisTrackerPlugin: OAPlugin { updateConnectionForCurrentProfile() } } - -extension Notification.Name { - static let aisNmeaConnectionStateChanged = Notification.Name("OAAisNmeaConnectionStateChanged") - static let aisNmeaLocationReceived = Notification.Name("OAAisNmeaLocationReceived") -} diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h index 6e99b5b539..3dc74590ef 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h @@ -9,6 +9,12 @@ #import "OAMapLayer.h" #import "OAContextMenuProvider.h" +@class AisObject; + @interface OAAisTrackerLayer : OAMapLayer +- (void)reloadAisObjects; +- (void)onAisObjectReceived:(AisObject *)object; +- (void)onAisObjectRemoved:(AisObject *)object; + @end diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm index db4fadc964..53aaa3b4bf 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm @@ -534,9 +534,6 @@ @implementation OAAisTrackerLayer NSMutableDictionary *_objectDrawables; std::shared_ptr _markersCollection; std::shared_ptr _vectorLinesCollection; - id _objectsObserver; - id _objectReceivedObserver; - id _objectRemovedObserver; BOOL _collectionsAdded; CGFloat _textScale; CGFloat _displayDensityFactor; @@ -622,68 +619,10 @@ - (void)initLayer [self.app.data.mapLayersConfiguration setLayer:self.layerId Visibility:self.isVisible]; - __weak OAAisTrackerLayer *weakSelf = self; - _objectsObserver = [NSNotificationCenter.defaultCenter addObserverForName:@"OAAisObjectsChanged" - object:nil - queue:NSOperationQueue.mainQueue - usingBlock:^(NSNotification * _Nonnull note) { - OAAisTrackerLayer *strongSelf = weakSelf; - if (!strongSelf) - return; - if ([note.object isKindOfClass:AisTrackerPlugin.class]) - strongSelf->_plugin = note.object; - [strongSelf cleanupResources]; - if ([strongSelf isVisible]) - { - [strongSelf addCollectionsToRenderer]; - [strongSelf reloadObjects]; - } - }]; - _objectReceivedObserver = [NSNotificationCenter.defaultCenter addObserverForName:@"OAAisObjectReceived" - object:nil - queue:NSOperationQueue.mainQueue - usingBlock:^(NSNotification * _Nonnull note) { - OAAisTrackerLayer *strongSelf = weakSelf; - if (!strongSelf) - return; - if ([note.object isKindOfClass:AisTrackerPlugin.class]) - strongSelf->_plugin = note.object; - AisObject *object = note.userInfo[@"object"]; - if ([object isKindOfClass:AisObject.class]) - [strongSelf onAisObjectReceived:object]; - }]; - _objectRemovedObserver = [NSNotificationCenter.defaultCenter addObserverForName:@"OAAisObjectRemoved" - object:nil - queue:NSOperationQueue.mainQueue - usingBlock:^(NSNotification * _Nonnull note) { - OAAisTrackerLayer *strongSelf = weakSelf; - if (!strongSelf) - return; - if ([note.object isKindOfClass:AisTrackerPlugin.class]) - strongSelf->_plugin = note.object; - AisObject *object = note.userInfo[@"object"]; - if ([object isKindOfClass:AisObject.class]) - [strongSelf onAisObjectRemoved:object]; - }]; } - (void)deinitLayer { - if (_objectsObserver) - { - [NSNotificationCenter.defaultCenter removeObserver:_objectsObserver]; - _objectsObserver = nil; - } - if (_objectReceivedObserver) - { - [NSNotificationCenter.defaultCenter removeObserver:_objectReceivedObserver]; - _objectReceivedObserver = nil; - } - if (_objectRemovedObserver) - { - [NSNotificationCenter.defaultCenter removeObserver:_objectRemovedObserver]; - _objectRemovedObserver = nil; - } [self cleanupResources]; [super deinitLayer]; } @@ -799,6 +738,16 @@ - (void)cleanupResources [self resetCollections]; } +- (void)reloadAisObjects +{ + [self cleanupResources]; + if ([self isVisible]) + { + [self addCollectionsToRenderer]; + [self reloadObjects]; + } +} + - (void)reloadObjects { if (![self isVisible]) diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.h b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.h new file mode 100644 index 0000000000..ace3788c96 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.h @@ -0,0 +1,19 @@ +// +// OAAisTrackerLayerBridge.h +// OsmAnd +// +// Created by OpenAI on 12.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +#import + +@class AisObject; + +@interface OAAisTrackerLayerBridge : NSObject + ++ (void)reloadAisObjects; ++ (void)onAisObjectReceived:(AisObject *)object; ++ (void)onAisObjectRemoved:(AisObject *)object; + +@end diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.mm new file mode 100644 index 0000000000..1e0edc17da --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.mm @@ -0,0 +1,39 @@ +// +// OAAisTrackerLayerBridge.mm +// OsmAnd +// +// Created by OpenAI on 12.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +#import "OAAisTrackerLayerBridge.h" +#import "OAAisTrackerLayer.h" +#import "OAMapLayers.h" +#import "OAMapPanelViewController.h" +#import "OAMapViewController.h" +#import "OARootViewController.h" +#import "OsmAnd_Maps-Swift.h" + +@implementation OAAisTrackerLayerBridge + ++ (OAAisTrackerLayer *)aisTrackerLayer +{ + return OARootViewController.instance.mapPanel.mapViewController.mapLayers.aisTrackerLayer; +} + ++ (void)reloadAisObjects +{ + [[self aisTrackerLayer] reloadAisObjects]; +} + ++ (void)onAisObjectReceived:(AisObject *)object +{ + [[self aisTrackerLayer] onAisObjectReceived:object]; +} + ++ (void)onAisObjectRemoved:(AisObject *)object +{ + [[self aisTrackerLayer] onAisObjectRemoved:object]; +} + +@end diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift index 5b33e6aa8c..d5772e806a 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift @@ -303,12 +303,22 @@ final class AisTrackerPlugin: OAPlugin { } } aisDebugLog("plugin received withPosition=\(getAisObjects().filter(\.hasPosition).count) \(object.debugSummary)") - NotificationCenter.default.post(name: .aisObjectReceived, object: self, userInfo: ["object": object]) + DispatchQueue.main.async { + OAAisTrackerLayerBridge.onAisObjectReceived(object) + } } func onAisObjectRemoved(_ object: AisObject) { aisDebugLog("plugin removed \(object.debugSummary)") - NotificationCenter.default.post(name: .aisObjectRemoved, object: self, userInfo: ["object": object]) + DispatchQueue.main.async { + OAAisTrackerLayerBridge.onAisObjectRemoved(object) + } + } + + func onAisObjectsChanged() { + DispatchQueue.main.async { + OAAisTrackerLayerBridge.reloadAisObjects() + } } func hasCpaWarning(for object: AisObject) -> Bool { @@ -386,21 +396,6 @@ final class AisTrackerPlugin: OAPlugin { } } -// private func handleAisSentence(_ sentence: String) { -// Task { -// guard let object = await decoder.decode(sentence: sentence) else { return } -// -// await MainActor.run { -// self.aisDataManager.onAisObjectReceived(object) -// } -// } -// } - -// private func handleAisSentence(_ sentence: String) { -// guard let object = decoder.decode(sentence: sentence) else { return } -// aisDataManager.onAisObjectReceived(object) -// } - private func handleAisSentence(_ sentence: String) { aisDecoderQueue.async { [weak self] in guard let self else { return } From 268eb081598a4ded8c3a26d81ab503a4bee48f71 Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Fri, 12 Jun 2026 13:38:50 +0300 Subject: [PATCH 09/18] clearSimulationObjects --- .../AisTrackerPlugin/AisDataManager.swift | 1 + .../AisTrackerPlugin/AisNmeaConnection.swift | 29 ++++++++++--------- .../Plugins/AisTrackerPlugin/AisObject.swift | 9 +++--- .../AisSimulationProvider.swift | 11 +++++-- .../AisTrackerPlugin/AisTrackerPlugin.swift | 3 ++ .../AisTrackerPlugin/OAAisTrackerPlugin.swift | 3 ++ 6 files changed, 36 insertions(+), 20 deletions(-) diff --git a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift index 9bda7cbe18..896e201df7 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift @@ -15,6 +15,7 @@ final class AisDataManager: NSObject { private static let objectLimit = 200 private weak var plugin: AisTrackerPlugin? + private var objectsByMmsi: [Int: AisObject] = [:] private var cleanupTimer: Timer? diff --git a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift index afb83d9faf..24ff722cba 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift @@ -53,20 +53,22 @@ final class AisNmeaConnection { let listener = try NWListener(using: params, on: endpointPort) self.listener = listener listener.newConnectionHandler = { [weak self] connection in - self?.log("UDP connection accepted endpoint=\(connection.endpoint)") - self?.receiveDatagrams(connection) - connection.start(queue: self?.queue ?? DispatchQueue.global()) + guard let self else { return } + log("UDP connection accepted endpoint=\(connection.endpoint)") + receiveDatagrams(connection) + connection.start(queue: queue) } listener.stateUpdateHandler = { [weak self] state in - self?.log("UDP listener state=\(state)") + guard let self else { return } + log("UDP listener state=\(state)") switch state { case .ready: - self?.updateState(.connected) + updateState(.connected) case .failed(let error): - self?.log("UDP listener failed error=\(error)") - self?.updateState(.failed) + log("UDP listener failed error=\(error)") + updateState(.failed) case .cancelled: - self?.updateState(.disconnected) + updateState(.disconnected) default: break } @@ -151,16 +153,17 @@ final class AisNmeaConnection { private func receiveDatagrams(_ connection: NWConnection) { connection.receiveMessage { [weak self] data, _, isComplete, error in + guard let self else { return } if let error { - self?.log("UDP receive error=\(error)") + log("UDP receive error=\(error)") } if let data, let text = String(data: data, encoding: .ascii) { - self?.log("UDP datagram bytes=\(data.count) complete=\(isComplete)") - self?.consume(text) + log("UDP datagram bytes=\(data.count) complete=\(isComplete)") + consume(text) } else if let data { - self?.log("UDP datagram ignored: non-ascii bytes=\(data.count)") + log("UDP datagram ignored: non-ascii bytes=\(data.count)") } - self?.receiveDatagrams(connection) + receiveDatagrams(connection) } } diff --git a/Sources/Plugins/AisTrackerPlugin/AisObject.swift b/Sources/Plugins/AisTrackerPlugin/AisObject.swift index 8753c3ae1c..68673ce64f 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisObject.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisObject.swift @@ -7,7 +7,6 @@ // import CoreLocation -import Foundation @objc enum AisObjType: Int { case vessel @@ -98,10 +97,6 @@ final class AisObject: NSObject { msgTypes.sorted().map(String.init).joined(separator: ", ") } - func hasMessageType(_ type: Int) -> Bool { - msgTypes.contains(type) - } - var hasImoMessage: Bool { hasMessageType(5) } @@ -305,6 +300,10 @@ final class AisObject: NSObject { msgTypes.insert(msgType) updateObjectClass() } + + func hasMessageType(_ type: Int) -> Bool { + msgTypes.contains(type) + } func merge(_ other: AisObject) { msgType = other.msgType diff --git a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift index 87bcd8d92c..4f3863ad35 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift @@ -56,7 +56,8 @@ final class AisMessageSimulationListener { return } DispatchQueue.main.async { [weak self] in - self?.plugin?.handleSimulatedNmeaSentence(sentence) + guard let plugin = self?.plugin, plugin.isEnabled() else { return } + plugin.handleSimulatedNmeaSentence(sentence) } } } @@ -118,7 +119,7 @@ final class AisSimulationProvider: NSObject { func startAisSimulation(_ fileURL: URL) { stopAisSimulation() - guard let plugin else { return } + guard let plugin, plugin.isEnabled() else { return } plugin.prepareAisSimulation() let listener = AisMessageSimulationListener(plugin: plugin, fileURL: fileURL, @@ -133,6 +134,7 @@ final class AisSimulationProvider: NSObject { } func initFakePosition() { + guard plugin?.isEnabled() == true else { return } let fake = CLLocation(coordinate: CLLocationCoordinate2D(latitude: 50.76077, longitude: 7.08747), altitude: 0, horizontalAccuracy: 5, @@ -173,6 +175,7 @@ final class AisSimulationProvider: NSObject { } func initTestPassengerShip() { + guard plugin?.isEnabled() == true else { return } let position = AisObject(mmsi: 34568, msgType: 1) position.applyPosition(timestamp: 20, navStatus: 0, maneuverIndicator: 1, heading: 320, cog: 320, sog: 8.4, lat: 50.738, lon: 7.099, rot: 0) plugin?.handleSimulatedAisObject(position) @@ -182,6 +185,7 @@ final class AisSimulationProvider: NSObject { } func initTestSailingBoat() { + guard plugin?.isEnabled() == true else { return } let position = AisObject(mmsi: 454011, msgType: 18) position.applyPosition(timestamp: 20, navStatus: AisObjectConstants.invalidNavStatus, @@ -199,6 +203,7 @@ final class AisSimulationProvider: NSObject { } func initTestLandStation() { + guard plugin?.isEnabled() == true else { return } let station = AisObject(mmsi: 878121, msgType: 4) station.applyBaseStation(lat: 50.736, lon: 7.100) plugin?.handleSimulatedAisObject(station) @@ -209,12 +214,14 @@ final class AisSimulationProvider: NSObject { } func initTestAircraft() { + guard plugin?.isEnabled() == true else { return } let aircraft = AisObject(mmsi: 910323, msgType: 9) aircraft.applyAircraft(timestamp: 15, altitude: 65, cog: 180.5, sog: 55.0, lat: 50.734, lon: 7.102) plugin?.handleSimulatedAisObject(aircraft) } func initTestLawEnforcement() { + guard plugin?.isEnabled() == true else { return } let position = AisObject(mmsi: 34569, msgType: 1) position.applyPosition(timestamp: 20, navStatus: 5, maneuverIndicator: 1, heading: 15, cog: 25, sog: 8.4, lat: 50.739, lon: 7.0931, rot: 0) plugin?.handleSimulatedAisObject(position) diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift index 8598ee2fdb..9e98200374 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift @@ -123,6 +123,7 @@ final class AisTrackerPlugin: OAPlugin { if enabled { updateConnectionForCurrentProfile() } else { + clearSimulationObjects() stopAisNetworkListener() } } @@ -135,6 +136,7 @@ final class AisTrackerPlugin: OAPlugin { } override func disable() { + clearSimulationObjects() connection.stop() super.disable() } @@ -152,6 +154,7 @@ final class AisTrackerPlugin: OAPlugin { } func startAisSimulation(_ fileURL: URL) { + guard isEnabled() else { return } simulationFileName = fileURL.lastPathComponent simulationSentences = 0 simulationDecoded = 0 diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift index d5772e806a..0c3a9c2456 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift @@ -117,6 +117,7 @@ final class AisTrackerPlugin: OAPlugin { if enabled { updateConnectionForCurrentProfile() } else { + clearSimulationObjects() stopAisNetworkListener() } } @@ -129,6 +130,7 @@ final class AisTrackerPlugin: OAPlugin { } override func disable() { + clearSimulationObjects() connection.stop() super.disable() } @@ -154,6 +156,7 @@ final class AisTrackerPlugin: OAPlugin { } func startAisSimulation(_ fileURL: URL) { + guard isEnabled() else { return } simulationFileName = fileURL.lastPathComponent simulationSentences = 0 simulationDecoded = 0 From 5d0f5a466decdf287c5e741dc5ffcec15613cb7f Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Fri, 12 Jun 2026 14:43:42 +0300 Subject: [PATCH 10/18] add CustomStringConvertible for AisNmeaConnectionState: --- .../AisTrackerPlugin/AisNmeaConnection.swift | 15 +++++++++++++++ .../AisTrackerSettingsViewController.swift | 4 ---- .../Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm | 1 - 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift index 24ff722cba..8f2c810eba 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift @@ -18,6 +18,21 @@ import Network case disconnected, connecting, connected, failed } +extension AisNmeaConnectionState: CustomStringConvertible { + var description: String { + switch self { + case .disconnected: + return "disconnected" + case .connecting: + return "connecting" + case .connected: + return "connected" + case .failed: + return "failed" + } + } +} + // NOTE: for test: tcp 153.44.253.27 5631 final class AisNmeaConnection { diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift index 76e5289217..3555fac9e6 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift @@ -62,10 +62,6 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { NotificationCenter.default.addObserver(self, selector: #selector(reloadStatus), name: .aisNmeaLocationReceived, object: plugin) } - deinit { - NotificationCenter.default.removeObserver(self) - } - override func getTitle() -> String { localizedString("plugin_ais_tracker_name") } diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm index 53aaa3b4bf..351b4c2210 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm @@ -972,7 +972,6 @@ - (void)collectObjectsFromPoint:(MapSelectionResult *)result unknownLocation:(BO return; CGPoint point = result.point; - //[self updateScaleCache]; int iconRadius = (int)ceil([self currentIconSize] * 0.55); int radius = MAX(iconRadius, (int)([self getScaledTouchRadius:[self getDefaultRadiusPoi]] * TOUCH_RADIUS_MULTIPLIER)); QList touchPolygon31 = From 45ccc73fb5060bc32b7de2cf6fd57d6101a630ce Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Sun, 14 Jun 2026 20:06:11 +0300 Subject: [PATCH 11/18] add AisTrackerProduct --- OsmAnd.xcodeproj/project.pbxproj | 4 + .../OAPluginDetailsViewController.mm | 2 +- .../AisTrackerPlugin/AisDataManager.swift | 3 +- .../AisTrackerPlugin/AisTrackerProduct.swift | 32 ++ .../AisTrackerSettingsViewController.swift | 38 +- .../AisTrackerPlugin/OAAisTrackerLayer.mm | 7 +- .../AisTrackerPlugin/OAAisTrackerPlugin.swift | 450 ------------------ Sources/Purchases/OAProducts.h | 3 - Sources/Purchases/OAProducts.mm | 46 +- 9 files changed, 74 insertions(+), 511 deletions(-) create mode 100644 Sources/Plugins/AisTrackerPlugin/AisTrackerProduct.swift delete mode 100644 Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift diff --git a/OsmAnd.xcodeproj/project.pbxproj b/OsmAnd.xcodeproj/project.pbxproj index 67f5e2a3b1..342a8335ca 100644 --- a/OsmAnd.xcodeproj/project.pbxproj +++ b/OsmAnd.xcodeproj/project.pbxproj @@ -1367,6 +1367,7 @@ 9F4844A72FD0000100484401 /* AisTrackerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A32FD0000100484401 /* AisTrackerPlugin.swift */; }; 9F4844A82FD0000100484401 /* AisTrackerSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */; }; 9F4844AA2FD0000100484401 /* AisSimulationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */; }; + 9F4844D12FD0000100484401 /* AisTrackerProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844D02FD0000100484401 /* AisTrackerProduct.swift */; }; 9F4844AC2FD0000100484401 /* AisObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AB2FD0000100484401 /* AisObject.swift */; }; 9F4844AE2FD0000100484401 /* AisDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AD2FD0000100484401 /* AisDataManager.swift */; }; 9F4844B02FD0000100484401 /* AisMessageDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */; }; @@ -5126,6 +5127,7 @@ 9F4844A32FD0000100484401 /* AisTrackerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerPlugin.swift; sourceTree = ""; }; 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerSettingsViewController.swift; sourceTree = ""; }; 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisSimulationProvider.swift; sourceTree = ""; }; + 9F4844D02FD0000100484401 /* AisTrackerProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerProduct.swift; sourceTree = ""; }; 9F4844AB2FD0000100484401 /* AisObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisObject.swift; sourceTree = ""; }; 9F4844AD2FD0000100484401 /* AisDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisDataManager.swift; sourceTree = ""; }; 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisMessageDecoder.swift; sourceTree = ""; }; @@ -10422,6 +10424,7 @@ 9F4844AD2FD0000100484401 /* AisDataManager.swift */, 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */, 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */, + 9F4844D02FD0000100484401 /* AisTrackerProduct.swift */, 9F4844A32FD0000100484401 /* AisTrackerPlugin.swift */, 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */, 9F4844B12FD0000100484401 /* OAAisTrackerLayer.h */, @@ -18009,6 +18012,7 @@ DA5A81F126C563A700F274C7 /* OAProfileIconColor.m in Sources */, DA5A83F026C563A800F274C7 /* OAQuickSearchTableController.mm in Sources */, 9F4844A62FD0000100484401 /* AisNmeaConnection.swift in Sources */, + 9F4844D12FD0000100484401 /* AisTrackerProduct.swift in Sources */, DA5A819E26C563A700F274C7 /* NSData+CRC32.m in Sources */, DA69ED5C2A385B1B001022C7 /* WidgetPanelViewController.swift in Sources */, FACE409F2AEA9ACB00E1E43A /* OAExternalSensorsPlugin.mm in Sources */, diff --git a/Sources/Controllers/Resources/OAPluginDetailsViewController.mm b/Sources/Controllers/Resources/OAPluginDetailsViewController.mm index 214c98a661..903915ccea 100644 --- a/Sources/Controllers/Resources/OAPluginDetailsViewController.mm +++ b/Sources/Controllers/Resources/OAPluginDetailsViewController.mm @@ -446,7 +446,7 @@ - (UIViewController *) getSettingsViewController return [[UIStoryboard storyboardWithName:@"BLEExternalSensors" bundle:nil] instantiateViewControllerWithIdentifier:@"BLEExternalSensors"]; else if ([_product isKindOfClass:OAVehicleMetricsProduct.class]) return [[UIStoryboard storyboardWithName:@"VehicleMetricsSensors" bundle:nil] instantiateViewControllerWithIdentifier:@"VehicleMetricsSensors"]; - else if ([_product isKindOfClass:OAAisTrackerProduct.class]) + else if ([_product isKindOfClass:AisTrackerProduct.class]) return [[OAPluginsHelper getPlugin:AisTrackerPlugin.class] getSettingsController]; return nil; diff --git a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift index 896e201df7..f6ff9c970d 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift @@ -58,9 +58,10 @@ final class AisDataManager: NSObject { object = ais event = "new" } - if objectsByMmsi.count >= Self.objectLimit { + if objectsByMmsi.count > Self.objectLimit { removeOldestObject() } + guard let storedObject = objectsByMmsi[object.mmsi], storedObject === object else { return } aisDebugLog("[AisDataManager] data \(event) total=\(objectsByMmsi.count) \(object.debugSummary)") plugin?.onAisObjectReceived(object) } diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerProduct.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerProduct.swift new file mode 100644 index 0000000000..0e3e4075f7 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerProduct.swift @@ -0,0 +1,32 @@ +import Foundation + +@objcMembers +final class AisTrackerProduct: OAProduct { + override init() { + super.init(identifier: kInAppId_Addon_Ais_Tracker) + } + + override var free: Bool { + true + } + + override func productIconName() -> String { + "ic_plugin_nautical" + } + + override func productScreenshotName() -> String { + "ais_map" + } + + override var localizedTitle: String { + localizedString("plugin_ais_tracker_name") + } + + override var localizedDescription: String { + localizedString("plugin_ais_tracker_description") + } + + override var localizedDescriptionExt: String { + localizedString("plugin_ais_tracker_description") + "\n\n" + localizedString("plugin_ais_tracker_disclaimer") + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift index 3555fac9e6..15e820d7bc 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift @@ -128,7 +128,7 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { guard let row = rowData(indexPath), isRowEnabled(row) else { return } switch row { case .protocolType: - chooseProtocol() + chooseProtocol(sourceRow: indexPath) case .host: editString(title: localizedString("ais_address_nmea_server"), message: descriptionText(for: row), value: plugin.hostPref.get()) { [weak self] value in let value = value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -155,7 +155,8 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { message: descriptionText(for: row), values: objectLostTimeoutValues, current: Int(plugin.objectLostTimeoutPref.get()), - titleProvider: minutesText) { [weak self] value in + titleProvider: minutesText, + sourceRow: indexPath) { [weak self] value in self?.plugin.objectLostTimeoutPref.set(Int32(value)) self?.tableView.reloadData() } @@ -164,7 +165,8 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { message: descriptionText(for: row), values: shipLostTimeoutValues, current: Int(plugin.shipLostTimeoutPref.get()), - titleProvider: shipLostTimeoutText) { [weak self] value in + titleProvider: shipLostTimeoutText, + sourceRow: indexPath) { [weak self] value in self?.plugin.shipLostTimeoutPref.set(Int32(value)) self?.tableView.reloadData() } @@ -173,7 +175,8 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { message: descriptionText(for: row), values: cpaWarningTimeValues, current: Int(plugin.cpaWarningTimePref.get()), - titleProvider: cpaWarningTimeText) { [weak self] value in + titleProvider: cpaWarningTimeText, + sourceRow: indexPath) { [weak self] value in self?.plugin.cpaWarningTimePref.set(Int32(value)) self?.tableView.reloadData() } @@ -182,14 +185,15 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { message: descriptionText(for: row), values: cpaWarningDistanceValues, current: plugin.cpaWarningDistancePref.get(), - titleProvider: nauticalMilesText) { [weak self] value in + titleProvider: nauticalMilesText, + sourceRow: indexPath) { [weak self] value in self?.plugin.cpaWarningDistancePref.set(value) self?.tableView.reloadData() } } } - private func chooseProtocol() { + private func chooseProtocol(sourceRow: IndexPath) { let alert = UIAlertController(title: localizedString("ais_nmea_protocol"), message: descriptionText(for: .protocolType), preferredStyle: .actionSheet) alert.addAction(UIAlertAction(title: "UDP", style: .default) { [weak self] _ in self?.plugin.protocolPref.set(Int32(AisNmeaProtocol.udp.rawValue)) @@ -202,7 +206,7 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { self?.tableView.reloadData() }) alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) - present(alert, animated: true) + presentActionSheet(alert, sourceRow: sourceRow) } private func editString(title: String, message: String?, value: String, onSave: @escaping (String) -> Bool) { @@ -233,7 +237,7 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { } } - private func chooseIntValue(title: String, message: String?, values: [Int], current: Int, titleProvider: @escaping (Int) -> String, onSelect: @escaping (Int) -> Void) { + private func chooseIntValue(title: String, message: String?, values: [Int], current: Int, titleProvider: @escaping (Int) -> String, sourceRow: IndexPath, onSelect: @escaping (Int) -> Void) { let alert = UIAlertController(title: title, message: message, preferredStyle: .actionSheet) for value in values { alert.addAction(UIAlertAction(title: titleProvider(value), style: .default) { _ in @@ -241,10 +245,10 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { }) } alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) - present(alert, animated: true) + presentActionSheet(alert, sourceRow: sourceRow) } - private func chooseDoubleValue(title: String, message: String?, values: [Double], current: Double, titleProvider: @escaping (Double) -> String, onSelect: @escaping (Double) -> Void) { + private func chooseDoubleValue(title: String, message: String?, values: [Double], current: Double, titleProvider: @escaping (Double) -> String, sourceRow: IndexPath, onSelect: @escaping (Double) -> Void) { let alert = UIAlertController(title: title, message: message, preferredStyle: .actionSheet) for value in values { alert.addAction(UIAlertAction(title: titleProvider(value), style: .default) { _ in @@ -252,6 +256,20 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { }) } alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + presentActionSheet(alert, sourceRow: sourceRow) + } + + private func presentActionSheet(_ alert: UIAlertController, sourceRow: IndexPath) { + if let popover = alert.popoverPresentationController { + if let cell = tableView.cellForRow(at: sourceRow) { + popover.sourceView = cell + popover.sourceRect = cell.bounds + } else { + popover.sourceView = view + popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.midY, width: 1, height: 1) + } + popover.permittedArrowDirections = [.up, .down] + } present(alert, animated: true) } diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm index 351b4c2210..b2f37adc66 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm @@ -672,8 +672,11 @@ - (void)onMapFrameRendered { if (![self isVisible]) { - kAisImagesCache.clear(); - [self cleanupResources]; + if (_collectionsAdded || _objectDrawables.count > 0) + { + kAisImagesCache.clear(); + [self cleanupResources]; + } return; } if (![self shouldUpdateRenderDataForViewport]) diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift deleted file mode 100644 index 0c3a9c2456..0000000000 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerPlugin.swift +++ /dev/null @@ -1,450 +0,0 @@ -import CoreLocation -import UIKit - -@objcMembers -final class AisTrackerPlugin: OAPlugin { - static let pluginId = "osmand.aistracker" - static let protocolPrefId = "ais_nmea_protocol" - static let hostPrefId = "ais_address_nmea_server" - static let tcpPortPrefId = "ais_port_nmea_server" - static let udpPortPrefId = "ais_port_nmea_local" - static let overrideLocationPrefId = "ais_use_nmea_location" - static let objectLostTimeoutPrefId = "ais_object_lost_timeout" - static let shipLostTimeoutPrefId = "ais_ship_lost_timeout" - static let cpaWarningTimePrefId = "ais_cpa_warning_time" - static let cpaWarningDistancePrefId = "ais_cpa_warning_distance" - static let aisConnectLoggingPrefId = "ais_connect_logging" - static let layerDebugLoggingPrefId = "ais_layer_debug_logging" - - let protocolPref: OACommonInteger - let hostPref: OACommonString - let tcpPortPref: OACommonInteger - let udpPortPref: OACommonInteger - let overrideLocationPref: OACommonBoolean - let objectLostTimeoutPref: OACommonInteger - let shipLostTimeoutPref: OACommonInteger - let cpaWarningTimePref: OACommonInteger - let cpaWarningDistancePref: OACommonDouble - let aisConnectLoggingPref: OACommonBoolean - let layerDebugLoggingPref: OACommonBoolean - - private let connection = AisNmeaConnection() - private let decoder = AisMessageDecoder() - private let aisDecoderQueue = DispatchQueue(label: "com.app.ais.decoder", qos: .userInitiated) - - private var applicationModeObserver: OAAutoObserverProxy? - - private lazy var simulationProvider = AisSimulationProvider(plugin: self) - private lazy var aisDataManager = AisDataManager(plugin: self) - - private(set) var connectionState: AisNmeaConnectionState = .disconnected - private(set) var lastLocation: CLLocation? - private(set) var fakeOwnLocation: CLLocation? - private(set) var simulationFileName: String? - private(set) var simulationStatusText: String? - private(set) var lastMessageReceived = Date.distantPast - - private var simulationSentences = 0 - private var simulationDecoded = 0 - private var simulationObjects = 0 - private var simulationReceivedObjects = 0 - private var simulationRenderedObjects = 0 - - override init() { - protocolPref = OAAppSettings.sharedManager().registerIntPreference(Self.protocolPrefId, defValue: Int32(AisNmeaProtocol.udp.rawValue)) - hostPref = OAAppSettings.sharedManager().registerStringPreference(Self.hostPrefId, defValue: "192.168.200.16") - tcpPortPref = OAAppSettings.sharedManager().registerIntPreference(Self.tcpPortPrefId, defValue: 4001) - udpPortPref = OAAppSettings.sharedManager().registerIntPreference(Self.udpPortPrefId, defValue: 10110) - overrideLocationPref = OAAppSettings.sharedManager().registerBooleanPreference(Self.overrideLocationPrefId, defValue: false) - objectLostTimeoutPref = OAAppSettings.sharedManager().registerIntPreference(Self.objectLostTimeoutPrefId, defValue: 7) - shipLostTimeoutPref = OAAppSettings.sharedManager().registerIntPreference(Self.shipLostTimeoutPrefId, defValue: 4) - cpaWarningTimePref = OAAppSettings.sharedManager().registerIntPreference(Self.cpaWarningTimePrefId, defValue: 0) - cpaWarningDistancePref = OAAppSettings.sharedManager().registerFloatPreference(Self.cpaWarningDistancePrefId, defValue: 1.0) - aisConnectLoggingPref = OAAppSettings.sharedManager().registerBooleanPreference(Self.aisConnectLoggingPrefId, defValue: false) - layerDebugLoggingPref = OAAppSettings.sharedManager().registerBooleanPreference(Self.layerDebugLoggingPrefId, defValue: false) - super.init() - - connection.isConnectLoggingEnabled = { [weak self] in - self?.isConnectLoggingEnabled() ?? false - } - connection.onStateChanged = { [weak self] state in - self?.connectionState = state - NotificationCenter.default.post(name: .aisNmeaConnectionStateChanged, object: self) - } - connection.onLocation = { [weak self] location in - self?.handle(location) - } - connection.onSentence = { [weak self] sentence in - self?.handleAisSentence(sentence) - } - applicationModeObserver = OAAutoObserverProxy(self, - withHandler: #selector(onApplicationModeChanged), - andObserve: OsmAndApp.swiftInstance().applicationModeChangedObservable) - } - - deinit { - applicationModeObserver?.detach() - } - - override func getId() -> String { - kInAppId_Addon_Ais_Tracker - } - - override func getName() -> String { - localizedString("plugin_ais_tracker_name") - } - - override func getDescription() -> String { - localizedString("plugin_ais_tracker_description") + "\n\n" + localizedString("plugin_ais_tracker_disclaimer") - } - - override func getLogoResourceId() -> String? { - "ic_plugin_nautical" - } - - override func getAddedAppModes() -> [OAApplicationMode] { - [OAApplicationMode.boat()] - } - - override func initPlugin() -> Bool { - let result = super.initPlugin() - updateConnectionForCurrentProfile() - return result - } - - override func setEnabled(_ enabled: Bool) { - super.setEnabled(enabled) - if enabled { - updateConnectionForCurrentProfile() - } else { - clearSimulationObjects() - stopAisNetworkListener() - } - } - - override func updateLayers() { - DispatchQueue.main.async { - OsmAndApp.swiftInstance().data.mapLayersConfiguration.setLayer("ais_tracker_layer", visibility: self.isActiveForCurrentProfile()) - OARootViewController.instance().mapPanel.mapViewController.updateLayer("ais_tracker_layer") - } - } - - override func disable() { - clearSimulationObjects() - connection.stop() - super.disable() - } - - override func getSettingsController() -> UIViewController? { - AisTrackerSettingsViewController(plugin: self) - } - - func getSimulationProvider() -> AisSimulationProvider { - simulationProvider - } - - func isActiveForCurrentProfile() -> Bool { - isEnabled() && OAAppSettings.sharedManager().applicationMode.get().isDerivedRouting(from: .boat()) - } - - func isConnectLoggingEnabled() -> Bool { - aisConnectLoggingPref.get() - } - - func isLayerDebugLoggingEnabled() -> Bool { - layerDebugLoggingPref.get() - } - - func startAisSimulation(_ fileURL: URL) { - guard isEnabled() else { return } - simulationFileName = fileURL.lastPathComponent - simulationSentences = 0 - simulationDecoded = 0 - simulationObjects = 0 - simulationReceivedObjects = 0 - simulationRenderedObjects = 0 - simulationStatusText = localizedString("shared_string_loading") - aisDebugLog("simulation start file=\(fileURL.lastPathComponent)") - simulationProvider.startAisSimulation(fileURL) - } - - func updateSimulationStatus(sentences: Int, decoded: Int, objects: Int, error: String?) { - if let error, !error.isEmpty { - simulationStatusText = error - aisDebugLog("simulation status error=\(error)") - } else { - simulationSentences = sentences - simulationDecoded = decoded - simulationObjects = objects - updateSimulationStatusText() - aisDebugLog("simulation stats sentences=\(sentences) decoded=\(decoded) objects=\(objects)") - } - postSimulationStatusChanged() - } - - func updateSimulationRenderedObjects(_ count: Int) { - guard simulationFileName != nil else { return } - guard simulationRenderedObjects != count else { return } - simulationRenderedObjects = count - updateSimulationStatusText() - postSimulationStatusChanged() - } - - func prepareAisSimulation() { - connection.stop() - aisDataManager.cleanupResources() - aisDataManager.startUpdates() - } - - func addTestSimulationObjects() { - simulationProvider.initFakePosition() - simulationProvider.initTestPassengerShip() - simulationProvider.initTestSailingBoat() - simulationProvider.initTestLandStation() - simulationProvider.initTestAircraft() - simulationProvider.initTestLawEnforcement() - } - - func clearSimulationObjects() { - simulationProvider.stopAisSimulation() - aisDebugLog("simulation clear") - fakeOwnLocation = nil - simulationFileName = nil - simulationStatusText = nil - simulationSentences = 0 - simulationDecoded = 0 - simulationObjects = 0 - simulationReceivedObjects = 0 - simulationRenderedObjects = 0 - aisDataManager.cleanupResources() - } - - func restartConnection() { - guard isActiveForCurrentProfile() else { - stopAisNetworkListener() - return - } - aisDataManager.startUpdates() - let proto = AisNmeaProtocol(rawValue: Int(protocolPref.get())) ?? .udp - switch proto { - case .udp: - connection.startUDP(port: UInt16(max(1, udpPortPref.get()))) - case .tcp: - connection.startTCP(host: hostPref.get(), port: UInt16(max(1, tcpPortPref.get()))) - } - } - - func stopAisNetworkListener() { - connection.stop() - aisDataManager.stopUpdates() - } - - private func updateConnectionForCurrentProfile() { - if isActiveForCurrentProfile() { - if !connection.isRunning { - restartConnection() - } - } else { - stopAisNetworkListener() - } - } - - func fakeOwnPosition(_ location: CLLocation?) { - fakeOwnLocation = location - } - - func handleSimulatedNmeaSentence(_ sentence: String) { - handleAisSentence(sentence) - if let location = AisNmeaParser.parseLocation(from: sentence) { - handleSimulatedLocation(location) - } - } - - func handleSimulatedLocation(_ location: CLLocation) { - handle(location) - } - - func handleSimulatedAisObject(_ object: AisObject) { - aisDataManager.onAisObjectReceived(object) - } - - func getAisObjects() -> [AisObject] { - aisDataManager.objects - } - // FIXME: cache for objectLostTimeoutPref shipLostTimeoutPref cpaWarningTimePref cpaWarningDistancePref - func maxObjectAgeInMinutes() -> Int { - max(1, Int(objectLostTimeoutPref.get())) - } - - func vesselLostTimeoutInMinutes() -> Int { - max(0, Int(shipLostTimeoutPref.get())) - } - - func cpaWarningTimeInMinutes() -> Int { - max(0, Int(cpaWarningTimePref.get())) - } - - func cpaWarningDistanceInNauticalMiles() -> Double { - max(0, cpaWarningDistancePref.get()) - } - - func ownPosition() -> CLLocation? { - if let fakeOwnLocation { - return fakeOwnLocation - } - return OsmAndApp.swiftInstance().locationServices?.lastKnownLocation - } - - func onAisObjectReceived(_ object: AisObject) { - lastMessageReceived = object.lastUpdate - if simulationFileName != nil { - let receivedObjects = getAisObjects().filter(\.hasPosition).count - if simulationReceivedObjects != receivedObjects { - simulationReceivedObjects = receivedObjects - updateSimulationStatusText() - postSimulationStatusChanged() - } - } - aisDebugLog("plugin received withPosition=\(getAisObjects().filter(\.hasPosition).count) \(object.debugSummary)") - DispatchQueue.main.async { - OAAisTrackerLayerBridge.onAisObjectReceived(object) - } - } - - func onAisObjectRemoved(_ object: AisObject) { - aisDebugLog("plugin removed \(object.debugSummary)") - DispatchQueue.main.async { - OAAisTrackerLayerBridge.onAisObjectRemoved(object) - } - } - - func onAisObjectsChanged() { - DispatchQueue.main.async { - OAAisTrackerLayerBridge.reloadAisObjects() - } - } - - func hasCpaWarning(for object: AisObject) -> Bool { - let warningTime = cpaWarningTimeInMinutes() - let warningDistance = cpaWarningDistanceInNauticalMiles() - guard object.isMovable, - object.objectClass != .airplane, - warningTime > 0, - object.sog > 0, - let ownPosition = ownPosition(), - let aisPosition = object.location else { - return false - } - AisTrackerHelper.getCpa(ownPosition, aisPosition, result: object.cpa) - guard object.cpa.valid, object.cpa.tcpa > 0 else { return false } - return Double(object.cpa.cpaDistance) <= warningDistance - && object.cpa.tcpa * 60.0 <= Double(warningTime) - && object.cpa.crossingTime1 >= 0 - && object.cpa.crossingTime2 >= 0 - } - - func updateCpa(for object: AisObject) { - guard let ownPosition = ownPosition(), - let aisPosition = object.currentLocation ?? object.location else { - object.cpa.reset() - return - } - AisTrackerHelper.getCpa(ownPosition, aisPosition, result: object.cpa) - } - - func distanceInNauticalMiles(to object: AisObject) -> Double { - guard let ownPosition = ownPosition(), - let aisPosition = object.currentLocation ?? object.location else { - return -1 - } - return ownPosition.distance(from: aisPosition) / 1852.0 - } - - func bearing(to object: AisObject) -> Double { - guard let ownPosition = ownPosition(), - let aisPosition = object.currentLocation ?? object.location else { - return -1 - } - return Self.bearing(from: ownPosition.coordinate, to: aisPosition.coordinate) - } - - func connectionDescription() -> String { - let proto = AisNmeaProtocol(rawValue: Int(protocolPref.get())) ?? .udp - switch proto { - case .udp: - return "UDP • \(udpPortPref.get())" - case .tcp: - return "TCP • \(hostPref.get()):\(tcpPortPref.get())" - } - } - - func statusDescription() -> String { - switch connectionState { - case .connected: - return localizedString("ais_connection_connected") - case .connecting: - return localizedString("ais_connection_connecting") - case .failed: - return localizedString("ais_connection_failed") - case .disconnected: - return localizedString("ais_connection_disconnected") - } - } - - private func handle(_ location: CLLocation) { - lastLocation = location - NotificationCenter.default.post(name: .aisNmeaLocationReceived, object: self) - if overrideLocationPref.get() { - OsmAndApp.swiftInstance().locationServices?.setLocationFromNMEA(location) - } - } - - private func handleAisSentence(_ sentence: String) { - aisDecoderQueue.async { [weak self] in - guard let self else { return } - - guard let object = decoder.decode(sentence: sentence) else { return } - - DispatchQueue.main.async { - self.aisDataManager.onAisObjectReceived(object) - } - } - } - - @objc private func onApplicationModeChanged() { - updateConnectionForCurrentProfile() - // updateLayers() - } - - private func updateSimulationStatusText() { - var parts = [ - "sentences \(simulationSentences)", - "decoded \(simulationDecoded)", - "objects \(simulationObjects)" - ] - if simulationReceivedObjects > 0 || simulationRenderedObjects > 0 { - parts.append("received \(simulationReceivedObjects)") - parts.append("rendered \(simulationRenderedObjects)") - } - simulationStatusText = parts.joined(separator: ", ") - } - - private func postSimulationStatusChanged() { - NotificationCenter.default.post(name: .aisSimulationStatusChanged, object: self) - } - - private static func bearing(from start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D) -> Double { - let lat1 = start.latitude * .pi / 180 - let lat2 = end.latitude * .pi / 180 - let deltaLon = (end.longitude - start.longitude) * .pi / 180 - let y = sin(deltaLon) * cos(lat2) - let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(deltaLon) - let degrees = atan2(y, x) * 180 / .pi - return fmod(degrees + 360, 360) - } -} - -extension Notification.Name { - static let aisNmeaConnectionStateChanged = Notification.Name("OAAisNmeaConnectionStateChanged") - static let aisNmeaLocationReceived = Notification.Name("OAAisNmeaLocationReceived") -} diff --git a/Sources/Purchases/OAProducts.h b/Sources/Purchases/OAProducts.h index 8e4ef8ebf8..9c1f28f813 100644 --- a/Sources/Purchases/OAProducts.h +++ b/Sources/Purchases/OAProducts.h @@ -354,9 +354,6 @@ typedef NS_ENUM(NSUInteger, OAProductDiscountType) @interface OAVehicleMetricsProduct : OAProduct @end -@interface OAAisTrackerProduct : OAProduct -@end - @interface OACarPlayProduct : OAProduct @end diff --git a/Sources/Purchases/OAProducts.mm b/Sources/Purchases/OAProducts.mm index 849ada6e03..5545442f10 100644 --- a/Sources/Purchases/OAProducts.mm +++ b/Sources/Purchases/OAProducts.mm @@ -15,6 +15,7 @@ #import "OALinks.h" #import "OAObservable.h" #import "OAAppSettings.h" +#import "OsmAnd_Maps-Swift.h" @interface OAFunctionalAddon() @@ -2531,49 +2532,6 @@ - (NSString *)localizedDescriptionExt @end -@implementation OAAisTrackerProduct - -- (instancetype)init -{ - self = [super initWithIdentifier:kInAppId_Addon_Ais_Tracker]; - if (self) - { - self.free = YES; - [self commonInit]; - } - return self; -} - -- (NSString *)productIconName -{ - return @"ic_plugin_nautical"; -} - -- (NSString *)productScreenshotName -{ - return @"ais_map"; -} - -- (NSString *)localizedTitle -{ - return OALocalizedString(@"plugin_ais_tracker_name"); -} - -- (NSString *)localizedDescription -{ - return OALocalizedString(@"plugin_ais_tracker_description"); -} - -- (NSString *)localizedDescriptionExt -{ - return [NSString stringWithFormat:@"%@\n\n%@", - OALocalizedString(@"plugin_ais_tracker_description"), - OALocalizedString(@"plugin_ais_tracker_disclaimer")]; -} - -@end - - @implementation OACarPlayProduct - (instancetype) init @@ -2954,7 +2912,7 @@ - (instancetype) init self.weather = [[OAWeatherProduct alloc] init]; self.sensors = [[OAExternalSensorsProduct alloc] init]; self.vehicleMetrics = [OAVehicleMetricsProduct new]; - self.aisTracker = [OAAisTrackerProduct new]; + self.aisTracker = [AisTrackerProduct new]; self.carplay = [[OACarPlayProduct alloc] init]; self.osmandDevelopment = [[OAOsmandDevelopmentProduct alloc] init]; From 87033314f9f278103ee12018e3a9f4b07bec478c Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Mon, 15 Jun 2026 12:01:32 +0300 Subject: [PATCH 12/18] remove AisNmeaParser --- OsmAnd.xcodeproj/project.pbxproj | 8 - OsmAnd.xcworkspace/contents.xcworkspacedata | 332 +++++++++++- .../en.lproj/Localizable.strings | 12 +- .../AisTrackerPlugin/AisDataManager.swift | 25 +- .../AisTrackerPlugin/AisMessageDecoder.swift | 311 +---------- .../AisTrackerPlugin/AisNmeaConnection.swift | 254 --------- .../AisTrackerPlugin/AisNmeaParser.swift | 95 ---- .../Plugins/AisTrackerPlugin/AisObject.swift | 496 ++---------------- .../AisSimulationProvider.swift | 107 ++-- .../AisTrackerPlugin/AisTrackerHelper.swift | 224 +------- .../AisTrackerPlugin/AisTrackerPlugin.swift | 150 ++++-- .../AisTrackerSettingsViewController.swift | 1 - .../OAAisObjectViewController.h | 5 +- .../OAAisObjectViewController.mm | 171 +++--- .../AisTrackerPlugin/OAAisTrackerLayer.h | 7 +- .../AisTrackerPlugin/OAAisTrackerLayer.mm | 222 ++++---- .../OAAisTrackerLayerBridge.h | 7 +- .../OAAisTrackerLayerBridge.mm | 4 +- 18 files changed, 814 insertions(+), 1617 deletions(-) delete mode 100644 Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift delete mode 100644 Sources/Plugins/AisTrackerPlugin/AisNmeaParser.swift diff --git a/OsmAnd.xcodeproj/project.pbxproj b/OsmAnd.xcodeproj/project.pbxproj index c9dde4992f..196b3b0eba 100644 --- a/OsmAnd.xcodeproj/project.pbxproj +++ b/OsmAnd.xcodeproj/project.pbxproj @@ -1361,8 +1361,6 @@ 97F6410929C475E7008F527B /* map_action_hill_climbing@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 97F6410529C475E6008F527B /* map_action_hill_climbing@3x.png */; }; 97F6410C29C475E7008F527B /* map_action_hill_climbing@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 97F6410829C475E7008F527B /* map_action_hill_climbing@2x.png */; }; 9A3A2ACC2C2C44B500B28C43 /* OATopIndexFilter.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9A3A2ACB2C2C44B500B28C43 /* OATopIndexFilter.mm */; }; - 9F4844A52FD0000100484401 /* AisNmeaParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A12FD0000100484401 /* AisNmeaParser.swift */; }; - 9F4844A62FD0000100484401 /* AisNmeaConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A22FD0000100484401 /* AisNmeaConnection.swift */; }; 9F4844A72FD0000100484401 /* AisTrackerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A32FD0000100484401 /* AisTrackerPlugin.swift */; }; 9F4844A82FD0000100484401 /* AisTrackerSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */; }; 9F4844AA2FD0000100484401 /* AisSimulationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */; }; @@ -5126,8 +5124,6 @@ 97F6410829C475E7008F527B /* map_action_hill_climbing@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "map_action_hill_climbing@2x.png"; sourceTree = ""; }; 9A3A2ACA2C2C448300B28C43 /* OATopIndexFilter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OATopIndexFilter.h; sourceTree = ""; }; 9A3A2ACB2C2C44B500B28C43 /* OATopIndexFilter.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OATopIndexFilter.mm; sourceTree = ""; }; - 9F4844A12FD0000100484401 /* AisNmeaParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisNmeaParser.swift; sourceTree = ""; }; - 9F4844A22FD0000100484401 /* AisNmeaConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisNmeaConnection.swift; sourceTree = ""; }; 9F4844A32FD0000100484401 /* AisTrackerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerPlugin.swift; sourceTree = ""; }; 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerSettingsViewController.swift; sourceTree = ""; }; 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisSimulationProvider.swift; sourceTree = ""; }; @@ -10427,8 +10423,6 @@ isa = PBXGroup; children = ( FA4EDBF52FDAC090003B0AFC /* AisLogger.swift */, - 9F4844A12FD0000100484401 /* AisNmeaParser.swift */, - 9F4844A22FD0000100484401 /* AisNmeaConnection.swift */, 9F4844AB2FD0000100484401 /* AisObject.swift */, 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */, 9F4844AD2FD0000100484401 /* AisDataManager.swift */, @@ -18029,7 +18023,6 @@ 4657490E2B6803710006046B /* TrashItem.swift in Sources */, DA5A81F126C563A700F274C7 /* OAProfileIconColor.m in Sources */, DA5A83F026C563A800F274C7 /* OAQuickSearchTableController.mm in Sources */, - 9F4844A62FD0000100484401 /* AisNmeaConnection.swift in Sources */, 9F4844D12FD0000100484401 /* AisTrackerProduct.swift in Sources */, DA5A819E26C563A700F274C7 /* NSData+CRC32.m in Sources */, DA69ED5C2A385B1B001022C7 /* WidgetPanelViewController.swift in Sources */, @@ -18287,7 +18280,6 @@ FACDC63D2DDB317D00CB0C55 /* VehicleMetricsDescriptionViewController.swift in Sources */, DA5A81DC26C563A700F274C7 /* OABuilding.mm in Sources */, DA5A849D26C563A900F274C7 /* OARemovePointCommand.mm in Sources */, - 9F4844A52FD0000100484401 /* AisNmeaParser.swift in Sources */, DA5A819926C563A700F274C7 /* OAGPXAction.mm in Sources */, FA0B58222A69578A006F8F9A /* FreeBackupBannerCell.swift in Sources */, 320F71272A823FB20071C0E7 /* PopularArticles.swift in Sources */, diff --git a/OsmAnd.xcworkspace/contents.xcworkspacedata b/OsmAnd.xcworkspace/contents.xcworkspacedata index bbf48eda82..8ad7b244fa 100644 --- a/OsmAnd.xcworkspace/contents.xcworkspacedata +++ b/OsmAnd.xcworkspace/contents.xcworkspacedata @@ -13,6 +13,282 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -548,6 +824,9 @@ + + @@ -645,12 +924,43 @@ + + + + + + + + + + + + + + + + + + + + @@ -831,6 +1141,26 @@ location = "group:ButtonPositionSize.kt"> + + + + + + + + + + + + @@ -952,7 +1282,7 @@ location = "group:XmlFactoryAPI.kt"> + location = "group:NetworkProxyState.kt"> diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index e9d5dd5208..298217212e 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -2460,7 +2460,6 @@ "plugin_ais_tracker_name" = "AIS vessel tracker"; "plugin_ais_tracker_description" = "Displays AIS positions and information about nearby vessels. AIS data is received over the network from an external AIS receiver."; "plugin_ais_tracker_disclaimer" = "DISCLAIMER\n\nThis plugin is a hobby project and is not intended for use in critical applications. It does not guarantee reliability or accuracy. Do not rely on this software for navigation, safety of life, or any other essential operations."; -"ais_use_nmea_location" = "Use NMEA as current location"; "ais_add_test_objects" = "Add test AIS objects"; "ais_clear_simulation" = "Clear AIS simulation"; "ais_reconnect" = "Reconnect"; @@ -2473,6 +2472,15 @@ "ais_connection_failed" = "Connection failed"; "ais_connection_disconnected" = "Disconnected"; "ais_call_sign" = "Call sign"; +"ais_mmsi" = "MMSI"; +"ais_imo" = "IMO"; +"ais_ship_name" = "Ship name"; +"ais_cpa" = "CPA"; +"ais_tcpa" = "TCPA"; +"ais_cog" = "COG"; +"ais_sog" = "SOG"; +"ais_rate_of_turn" = "Rate of turn"; +"ais_eta" = "ETA"; "ais_object_type" = "AIS type"; "ais_ship_type" = "Ship type"; "ais_position" = "Position"; @@ -4763,4 +4771,4 @@ "shared_string_unlock" = "Unlock"; "shared_string_url" = "URL"; - +"shared_string_kts" = "kts"; diff --git a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift index f6ff9c970d..419bc05cf6 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift @@ -6,6 +6,8 @@ // Copyright © 2026 OsmAnd. All rights reserved. // +import OsmAndShared + extension Notification.Name { static let aisSimulationStatusChanged = Notification.Name("OAAisSimulationStatusChanged") } @@ -49,38 +51,39 @@ final class AisDataManager: NSObject { func onAisObjectReceived(_ ais: AisObject) { let object: AisObject let event: String - if let existing = objectsByMmsi[ais.mmsi] { - existing.merge(ais) + let mmsi = Int(ais.mmsi) + if let existing = objectsByMmsi[mmsi] { + existing.set(ais: ais) object = existing event = "merge" } else { - objectsByMmsi[ais.mmsi] = ais - object = ais + object = AisObject(ais: ais) + objectsByMmsi[mmsi] = object event = "new" } if objectsByMmsi.count > Self.objectLimit { removeOldestObject() } - guard let storedObject = objectsByMmsi[object.mmsi], storedObject === object else { return } - aisDebugLog("[AisDataManager] data \(event) total=\(objectsByMmsi.count) \(object.debugSummary)") + guard let storedObject = objectsByMmsi[Int(object.mmsi)], storedObject === object else { return } + AisObjectHelper.debugLog("[AisDataManager] data \(event) total=\(objectsByMmsi.count) \(AisObjectHelper.debugSummary(object))") plugin?.onAisObjectReceived(object) } func removeLostObjects() { guard let plugin else { return } let maxAge = plugin.maxObjectAgeInMinutes() - let removed = objectsByMmsi.values.filter { $0.isLost(maxAgeMinutes: maxAge) } + let removed = objectsByMmsi.values.filter { $0.isLost(maxAgeInMin: Int32(maxAge)) } for object in removed { - objectsByMmsi.removeValue(forKey: object.mmsi) - aisDebugLog("[AisDataManager] data remove-lost maxAge=\(maxAge)m total=\(objectsByMmsi.count) \(object.debugSummary)") + objectsByMmsi.removeValue(forKey: Int(object.mmsi)) + AisObjectHelper.debugLog("[AisDataManager] data remove-lost maxAge=\(maxAge)m total=\(objectsByMmsi.count) \(AisObjectHelper.debugSummary(object))") plugin.onAisObjectRemoved(object) } } private func removeOldestObject() { guard let oldest = objectsByMmsi.values.min(by: { $0.lastUpdate < $1.lastUpdate }) else { return } - objectsByMmsi.removeValue(forKey: oldest.mmsi) - aisDebugLog("[AisDataManager] data remove-oldest limit=\(Self.objectLimit) total=\(objectsByMmsi.count) \(oldest.debugSummary)") + objectsByMmsi.removeValue(forKey: Int(oldest.mmsi)) + AisObjectHelper.debugLog("[AisDataManager] data remove-oldest limit=\(Self.objectLimit) total=\(objectsByMmsi.count) \(AisObjectHelper.debugSummary(oldest))") plugin?.onAisObjectRemoved(oldest) } } diff --git a/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift b/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift index e4ba739cb2..07dd0f8d2c 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift @@ -6,312 +6,23 @@ // Copyright © 2026 OsmAnd. All rights reserved. // -final class AisMessageDecoder { - private struct FragmentBuffer { - let total: Int - var payloads: [Int: String] - var fillBits: Int - } +import OsmAndShared - private var fragments: [String: FragmentBuffer] = [:] +final class AisMessageDecoder { + private let dataListener = AisSharedDataListener() + private lazy var listener = OsmAndShared.AisMessageListener(dataListener: dataListener) func decode(sentence: String) -> AisObject? { - // Remove leading/trailing whitespaces and newlines. - let trimmed = sentence.trimmingCharacters(in: .whitespacesAndNewlines) - - // Strip an optional NMEA TAG block, e.g. `\s:2573267,c:1781087503*0A\!BSVDM...`. - let cleanSentence: String - if let lastBackslashIndex = trimmed.lastIndex(of: "\\") { - cleanSentence = String(trimmed[trimmed.index(after: lastBackslashIndex)...]) - } else { - cleanSentence = trimmed - } - - // !AIVDM is a mobile AIS station; !BSVDM is a base AIS station. - guard cleanSentence.hasPrefix("!AI") || cleanSentence.hasPrefix("!BS") else { return nil } - - let noChecksum = cleanSentence.split(separator: "*", maxSplits: 1).first.map(String.init) ?? cleanSentence - - let fields = noChecksum.split(separator: ",", omittingEmptySubsequences: false).map(String.init) - guard fields.count >= 7 else { return nil } - - let talker = fields[0] - guard talker.hasSuffix("VDM") || talker.hasSuffix("VDO") else { return nil } - - guard let total = Int(fields[1]), let number = Int(fields[2]) else { return nil } - let sequentialId = fields[3] - let channel = fields[4] - let payload = fields[5] - let fillBits = Int(fields[6]) ?? 0 - - let completePayload: String - let completeFillBits: Int - if total > 1 { - let key = "\(sequentialId)-\(channel)" - var buffer = fragments[key] ?? FragmentBuffer(total: total, payloads: [:], fillBits: fillBits) - - buffer.payloads[number] = payload - buffer.fillBits = fillBits - fragments[key] = buffer - - guard buffer.payloads.count == total else { return nil } - - let orderedPayloads = (1...total).compactMap { buffer.payloads[$0] } - guard orderedPayloads.count == total else { return nil } - completePayload = orderedPayloads.joined() - completeFillBits = buffer.fillBits - - fragments.removeValue(forKey: key) - } else { - completePayload = payload - completeFillBits = fillBits - } - - let bits = AisBitReader(payload: completePayload) - bits.dropLast(completeFillBits) - - guard let msgType = bits.uint(0, 6) else { return nil } - - switch msgType { - case 1, 2, 3: - return decodePositionReport(bits: bits, msgType: msgType) - case 4: - return decodeBaseStation(bits: bits, msgType: msgType) - case 5: - return decodeStaticVoyage(bits: bits, msgType: msgType) - case 9: - return decodeAircraft(bits: bits, msgType: msgType) - case 18: - return decodeClassBPosition(bits: bits, msgType: msgType) - case 19: - return decodeExtendedClassBPosition(bits: bits, msgType: msgType) - case 21: - return decodeAton(bits: bits, msgType: msgType) - case 24: - return decodeStaticDataReport(bits: bits, msgType: msgType) - case 27: - return decodeLongRange(bits: bits, msgType: msgType) - default: - return nil - } - } - - private func decodePositionReport(bits: AisBitReader, msgType: Int) -> AisObject? { - guard let mmsi = bits.uint(8, 30) else { return nil } - let ais = AisObject(mmsi: mmsi, msgType: msgType) - ais.applyPosition(timestamp: bits.uint(137, 6) ?? 0, - navStatus: bits.uint(38, 4) ?? AisObjectConstants.invalidNavStatus, - maneuverIndicator: bits.uint(143, 2) ?? AisObjectConstants.invalidManeuverIndicator, - heading: bits.uint(128, 9) ?? AisObjectConstants.invalidHeading, - cog: scaled(bits.uint(116, 12), scale: 10, invalidRaw: 3600, invalid: AisObjectConstants.invalidCog), - sog: scaled(bits.uint(50, 10), scale: 10, invalidRaw: 1023, invalid: AisObjectConstants.invalidSog), - lat: latitude(bits.int(89, 27), divisor: 600000), - lon: longitude(bits.int(61, 28), divisor: 600000), - rot: rot(bits.int(42, 8))) - return ais - } - - private func decodeBaseStation(bits: AisBitReader, msgType: Int) -> AisObject? { - guard let mmsi = bits.uint(8, 30) else { return nil } - let ais = AisObject(mmsi: mmsi, msgType: msgType) - ais.applyBaseStation(lat: latitude(bits.int(107, 27), divisor: 600000), - lon: longitude(bits.int(79, 28), divisor: 600000)) - return ais - } - - private func decodeStaticVoyage(bits: AisBitReader, msgType: Int) -> AisObject? { - guard let mmsi = bits.uint(8, 30) else { return nil } - let ais = AisObject(mmsi: mmsi, msgType: msgType) - ais.applyStatic(imo: bits.uint(40, 30) ?? 0, - callSign: bits.string(70, 42), - shipName: bits.string(112, 120), - shipType: bits.uint(232, 8) ?? AisObjectConstants.invalidShipType, - bow: bits.uint(240, 9) ?? 0, - stern: bits.uint(249, 9) ?? 0, - port: bits.uint(258, 6) ?? 0, - starboard: bits.uint(264, 6) ?? 0, - draught: scaled(bits.uint(294, 8), scale: 10, invalidRaw: 0, invalid: 0), - destination: bits.string(302, 120), - etaMonth: bits.uint(274, 4) ?? 0, - etaDay: bits.uint(278, 5) ?? 0, - etaHour: bits.uint(283, 5) ?? AisObjectConstants.invalidEtaHour, - etaMinute: bits.uint(288, 6) ?? AisObjectConstants.invalidEtaMin) - return ais - } - - private func decodeAircraft(bits: AisBitReader, msgType: Int) -> AisObject? { - guard let mmsi = bits.uint(8, 30) else { return nil } - let ais = AisObject(mmsi: mmsi, msgType: msgType) - ais.applyAircraft(timestamp: bits.uint(137, 6) ?? 0, - altitude: bits.uint(38, 12) ?? AisObjectConstants.invalidAltitude, - cog: scaled(bits.uint(116, 12), scale: 10, invalidRaw: 3600, invalid: AisObjectConstants.invalidCog), - sog: scaled(bits.uint(50, 10), scale: 10, invalidRaw: 1023, invalid: AisObjectConstants.invalidSog), - lat: latitude(bits.int(89, 27), divisor: 600000), - lon: longitude(bits.int(61, 28), divisor: 600000)) - return ais - } - - private func decodeClassBPosition(bits: AisBitReader, msgType: Int) -> AisObject? { - guard let mmsi = bits.uint(8, 30) else { return nil } - let ais = AisObject(mmsi: mmsi, msgType: msgType) - ais.applyPosition(timestamp: bits.uint(133, 6) ?? 0, - navStatus: AisObjectConstants.invalidNavStatus, - maneuverIndicator: AisObjectConstants.invalidManeuverIndicator, - heading: bits.uint(124, 9) ?? AisObjectConstants.invalidHeading, - cog: scaled(bits.uint(116, 12), scale: 10, invalidRaw: 3600, invalid: AisObjectConstants.invalidCog), - sog: scaled(bits.uint(46, 10), scale: 10, invalidRaw: 1023, invalid: AisObjectConstants.invalidSog), - lat: latitude(bits.int(85, 27), divisor: 600000), - lon: longitude(bits.int(57, 28), divisor: 600000), - rot: AisObjectConstants.invalidRot) - return ais - } - - private func decodeExtendedClassBPosition(bits: AisBitReader, msgType: Int) -> AisObject? { - guard let ais = decodeClassBPosition(bits: bits, msgType: msgType) else { return nil } - ais.applyStatic(imo: 0, - callSign: ais.callSign, - shipName: bits.string(143, 120), - shipType: bits.uint(263, 8) ?? AisObjectConstants.invalidShipType, - bow: bits.uint(271, 9) ?? 0, - stern: bits.uint(280, 9) ?? 0, - port: bits.uint(289, 6) ?? 0, - starboard: bits.uint(295, 6) ?? 0, - draught: AisObjectConstants.invalidDraught, - destination: nil, - etaMonth: AisObjectConstants.invalidEta, - etaDay: AisObjectConstants.invalidEta, - etaHour: AisObjectConstants.invalidEtaHour, - etaMinute: AisObjectConstants.invalidEtaMin) - return ais - } - - private func decodeAton(bits: AisBitReader, msgType: Int) -> AisObject? { - guard let mmsi = bits.uint(8, 30) else { return nil } - let ais = AisObject(mmsi: mmsi, msgType: msgType) - ais.applyAton(lat: latitude(bits.int(164, 27), divisor: 600000), - lon: longitude(bits.int(135, 28), divisor: 600000), - aidType: bits.uint(38, 5) ?? AisObjectConstants.unspecifiedAidType, - bow: bits.uint(219, 9) ?? 0, - stern: bits.uint(228, 9) ?? 0, - port: bits.uint(237, 6) ?? 0, - starboard: bits.uint(243, 6) ?? 0) - return ais - } - - private func decodeStaticDataReport(bits: AisBitReader, msgType: Int) -> AisObject? { - guard let mmsi = bits.uint(8, 30) else { return nil } - let ais = AisObject(mmsi: mmsi, msgType: msgType) - let part = bits.uint(38, 2) ?? 0 - if part == 0 { - ais.applyStatic(imo: 0, callSign: nil, shipName: bits.string(40, 120), - shipType: AisObjectConstants.invalidShipType, - bow: 0, stern: 0, port: 0, starboard: 0, - draught: 0, destination: nil, - etaMonth: 0, etaDay: 0, etaHour: 24, etaMinute: 60) - } else { - ais.applyStatic(imo: 0, - callSign: bits.string(90, 42), - shipName: nil, - shipType: bits.uint(40, 8) ?? AisObjectConstants.invalidShipType, - bow: bits.uint(132, 9) ?? 0, - stern: bits.uint(141, 9) ?? 0, - port: bits.uint(150, 6) ?? 0, - starboard: bits.uint(156, 6) ?? 0, - draught: 0, destination: nil, - etaMonth: 0, etaDay: 0, etaHour: 24, etaMinute: 60) - } - return ais - } - - private func decodeLongRange(bits: AisBitReader, msgType: Int) -> AisObject? { - guard let mmsi = bits.uint(8, 30) else { return nil } - let ais = AisObject(mmsi: mmsi, msgType: msgType) - ais.applyPosition(timestamp: 0, - navStatus: bits.uint(40, 4) ?? AisObjectConstants.invalidNavStatus, - maneuverIndicator: AisObjectConstants.invalidManeuverIndicator, - heading: AisObjectConstants.invalidHeading, - cog: scaled(bits.uint(80, 9), scale: 10, invalidRaw: 511, invalid: AisObjectConstants.invalidCog), - sog: scaled(bits.uint(69, 6), scale: 1, invalidRaw: 63, invalid: AisObjectConstants.invalidSog), - lat: latitude(bits.int(62, 17), divisor: 600), - lon: longitude(bits.int(44, 18), divisor: 600), - rot: AisObjectConstants.invalidRot) - return ais - } - - private func scaled(_ raw: Int?, scale: Double, invalidRaw: Int, invalid: Double) -> Double { - guard let raw, raw != invalidRaw else { return invalid } - return Double(raw) / scale - } - - private func latitude(_ raw: Int?, divisor: Double) -> Double { - guard let raw else { return AisObjectConstants.invalidLat } - let value = Double(raw) / divisor - return abs(value) > 90 ? AisObjectConstants.invalidLat : value - } - - private func longitude(_ raw: Int?, divisor: Double) -> Double { - guard let raw else { return AisObjectConstants.invalidLon } - let value = Double(raw) / divisor - return abs(value) > 180 ? AisObjectConstants.invalidLon : value - } - - private func rot(_ raw: Int?) -> Double { - guard let raw, raw != -128 else { return AisObjectConstants.invalidRot } - return Double(raw) + dataListener.lastObject = nil + listener.processLine(line: sentence) + return dataListener.lastObject } } -private final class AisBitReader { - private var bits: [Int] = [] - - init(payload: String) { - for scalar in payload.unicodeScalars { - var value = Int(scalar.value) - 48 - if value > 40 { value -= 8 } - guard value >= 0 && value <= 63 else { continue } - for shift in stride(from: 5, through: 0, by: -1) { - bits.append((value >> shift) & 1) - } - } - } - - func dropLast(_ count: Int) { - guard count > 0, count <= bits.count else { return } - bits.removeLast(count) - } - - func uint(_ start: Int, _ length: Int) -> Int? { - guard start >= 0, length > 0, start + length <= bits.count else { return nil } - var value = 0 - for idx in start..<(start + length) { - value = (value << 1) | bits[idx] - } - return value - } - - func int(_ start: Int, _ length: Int) -> Int? { - guard let unsigned = uint(start, length) else { return nil } - let signBit = 1 << (length - 1) - if unsigned & signBit == 0 { - return unsigned - } - return unsigned - (1 << length) - } +private final class AisSharedDataListener: NSObject, OsmAndShared.AisDataListener { + var lastObject: AisObject? - func string(_ start: Int, _ length: Int) -> String? { - guard length > 0, start + length <= bits.count else { return nil } - let chars = stride(from: start, to: start + length, by: 6).compactMap { index -> Character? in - guard let value = uint(index, 6) else { return nil } - if value == 0 { return "@" } - if value >= 1 && value <= 26 { - return Character(UnicodeScalar(value + 64)!) - } - if value >= 32 && value <= 63 { - return Character(UnicodeScalar(value)!) - } - return " " - } - let text = String(chars).trimmingCharacters(in: CharacterSet(charactersIn: " @")) - return text.isEmpty ? nil : text + func onAisObjectReceived(ais: AisObject) { + lastObject = ais } } diff --git a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift b/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift deleted file mode 100644 index 8f2c810eba..0000000000 --- a/Sources/Plugins/AisTrackerPlugin/AisNmeaConnection.swift +++ /dev/null @@ -1,254 +0,0 @@ -// -// AisNmeaConnection.swift -// OsmAnd -// -// Created by Oleksandr Panchenko on 11.06.2026. -// Copyright © 2026 OsmAnd. All rights reserved. -// - -import CoreLocation -import Network - -@objc enum AisNmeaProtocol: Int { - case udp = 0 - case tcp = 1 -} - -@objc enum AisNmeaConnectionState: Int { - case disconnected, connecting, connected, failed -} - -extension AisNmeaConnectionState: CustomStringConvertible { - var description: String { - switch self { - case .disconnected: - return "disconnected" - case .connecting: - return "connecting" - case .connected: - return "connected" - case .failed: - return "failed" - } - } -} - -// NOTE: for test: tcp 153.44.253.27 5631 - -final class AisNmeaConnection { - var onLocation: ((CLLocation) -> Void)? - var onSentence: ((String) -> Void)? - var onStateChanged: ((AisNmeaConnectionState) -> Void)? - - private let queue = DispatchQueue(label: "net.osmand.ais.nmea.connection") - private var listener: NWListener? - private var connection: NWConnection? - private var reconnectWorkItem: DispatchWorkItem? - private var buffer = "" - private var shouldReconnect = false - private var host = "" - private var port: UInt16 = 0 - - var isRunning: Bool { - listener != nil || connection != nil || shouldReconnect - } - - func startUDP(port: UInt16) { - stop() - AisLogger.shared.log("[AisNmeaConnection] start UDP port=\(port)") - updateState(.connecting) - do { - let params = NWParameters.udp - params.allowLocalEndpointReuse = true - guard let endpointPort = NWEndpoint.Port(rawValue: port) else { - AisLogger.shared.log("[AisNmeaConnection] UDP start failed: invalid port \(port)") - updateState(.failed) - return - } - let listener = try NWListener(using: params, on: endpointPort) - self.listener = listener - listener.newConnectionHandler = { [weak self] connection in - guard let self else { return } - log("UDP connection accepted endpoint=\(connection.endpoint)") - receiveDatagrams(connection) - connection.start(queue: queue) - } - listener.stateUpdateHandler = { [weak self] state in - guard let self else { return } - log("UDP listener state=\(state)") - switch state { - case .ready: - updateState(.connected) - case .failed(let error): - log("UDP listener failed error=\(error)") - updateState(.failed) - case .cancelled: - updateState(.disconnected) - default: - break - } - } - listener.start(queue: queue) - } catch { - log("UDP start failed error=\(error)") - updateState(.failed) - } - } - - func startTCP(host: String, port: UInt16) { - stop() - log("start TCP host=\(host) port=\(port)") - self.host = host - self.port = port - shouldReconnect = true - connectTCP() - } - - func stop() { - log("stop listener=\(listener != nil) connection=\(connection != nil) reconnect=\(shouldReconnect)") - shouldReconnect = false - reconnectWorkItem?.cancel() - reconnectWorkItem = nil - listener?.cancel() - listener = nil - connection?.cancel() - connection = nil - buffer = "" - updateState(.disconnected) - } - - private func connectTCP() { - log("TCP connect host=\(host) port=\(port)") - updateState(.connecting) - guard let endpointPort = NWEndpoint.Port(rawValue: port) else { - log("TCP connect failed: invalid port \(port)") - updateState(.failed) - return - } - let nwConnection = NWConnection(host: NWEndpoint.Host(host), port: endpointPort, using: .tcp) - connection = nwConnection - nwConnection.stateUpdateHandler = { [weak self] state in - guard let self else { return } - log("TCP state=\(state)") - switch state { - case .ready: - updateState(.connected) - receiveStream(nwConnection) - case .failed(let error): - log("TCP failed error=\(error)") - updateState(.failed) - scheduleReconnect() - case .waiting(let error): - log("TCP waiting error=\(error)") - updateState(.failed) - scheduleReconnect() - case .cancelled: - updateState(.disconnected) - default: - break - } - } - nwConnection.start(queue: queue) - } - - private func scheduleReconnect() { - guard shouldReconnect else { - log("skip reconnect: disabled") - return - } - log("schedule TCP reconnect in 5s") - connection?.cancel() - connection = nil - let work = DispatchWorkItem { [weak self] in - self?.connectTCP() - } - reconnectWorkItem = work - queue.asyncAfter(deadline: .now() + 5, execute: work) - } - - private func receiveDatagrams(_ connection: NWConnection) { - connection.receiveMessage { [weak self] data, _, isComplete, error in - guard let self else { return } - if let error { - log("UDP receive error=\(error)") - } - if let data, let text = String(data: data, encoding: .ascii) { - log("UDP datagram bytes=\(data.count) complete=\(isComplete)") - consume(text) - } else if let data { - log("UDP datagram ignored: non-ascii bytes=\(data.count)") - } - receiveDatagrams(connection) - } - } - - private func receiveStream(_ connection: NWConnection) { - connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in - if let data, let text = String(data: data, encoding: .ascii) { - self?.log("TCP chunk bytes=\(data.count) complete=\(isComplete)") - self?.consume(text) - } else if let data { - self?.log("TCP chunk ignored: non-ascii bytes=\(data.count)") - } - if isComplete || error != nil { - if let error { - self?.log("TCP receive ended error=\(error)") - } else { - self?.log("TCP receive completed") - } - self?.scheduleReconnect() - } else { - self?.receiveStream(connection) - } - } - } - - private func consume(_ text: String) { - buffer += text - let separators = CharacterSet.newlines - while let range = buffer.rangeOfCharacter(from: separators) { - let line = String(buffer[.. 8192 { - log("drop buffered data: size=\(buffer.count)") - buffer.removeAll() - } - } - - private func updateState(_ state: AisNmeaConnectionState) { - log("state -> \(state)") - DispatchQueue.main.async { [weak self] in - self?.onStateChanged?(state) - } - } - - private func sentenceType(_ sentence: String) -> String { - let trimmed = sentence.trimmingCharacters(in: .whitespacesAndNewlines) - let cleanSentence: String - if let lastBackslashIndex = trimmed.lastIndex(of: "\\") { - cleanSentence = String(trimmed[trimmed.index(after: lastBackslashIndex)...]) - } else { - cleanSentence = trimmed - } - return cleanSentence.split(separator: ",", maxSplits: 1).first.map(String.init) ?? "unknown" - } - - private func log(_ message: String) { - AisLogger.shared.log("[AisNmeaConnection] \(message)") - } -} diff --git a/Sources/Plugins/AisTrackerPlugin/AisNmeaParser.swift b/Sources/Plugins/AisTrackerPlugin/AisNmeaParser.swift deleted file mode 100644 index 3018ddca29..0000000000 --- a/Sources/Plugins/AisTrackerPlugin/AisNmeaParser.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// AisNmeaParser.swift -// OsmAnd -// -// Created by Oleksandr Panchenko on 11.06.2026. -// Copyright © 2026 OsmAnd. All rights reserved. -// - -import CoreLocation - -struct AisNmeaParser { - static func parseLocation(from sentence: String) -> CLLocation? { - let line = sentence.trimmingCharacters(in: .whitespacesAndNewlines) - guard line.hasPrefix("$"), isChecksumValid(line) else { return nil } - - let payload = line.dropFirst().split(separator: "*", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? "" - let fields = payload.split(separator: ",", omittingEmptySubsequences: false).map(String.init) - guard let type = fields.first else { return nil } - - if type.hasSuffix("RMC") { - return parseRMC(fields) - } else if type.hasSuffix("GGA") { - return parseGGA(fields) - } - return nil - } - - private static func parseRMC(_ fields: [String]) -> CLLocation? { - guard fields.count > 9, fields[2] == "A", - let latitude = coordinate(fields[3], hemisphere: fields[4]), - let longitude = coordinate(fields[5], hemisphere: fields[6]) else { - return nil - } - - let speed = (Double(fields[7]) ?? -1) * 0.514444 - let course = Double(fields[8]) ?? -1 - let timestamp = date(time: fields[1], date: fields[9]) ?? Date() - return CLLocation(coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), - altitude: 0, - horizontalAccuracy: 10, - verticalAccuracy: -1, - course: course, - speed: speed, - timestamp: timestamp) - } - - private static func parseGGA(_ fields: [String]) -> CLLocation? { - guard fields.count > 9, (Int(fields[6]) ?? 0) > 0, - let latitude = coordinate(fields[2], hemisphere: fields[3]), - let longitude = coordinate(fields[4], hemisphere: fields[5]) else { - return nil - } - - let altitude = Double(fields[9]) ?? 0 - let hdop = Double(fields[8]) ?? 1 - return CLLocation(coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), - altitude: altitude, - horizontalAccuracy: max(5, hdop * 5), - verticalAccuracy: 10, - course: -1, - speed: -1, - timestamp: Date()) - } - - private static func coordinate(_ value: String, hemisphere: String) -> CLLocationDegrees? { - guard let raw = Double(value), value.count >= 4 else { return nil } - let degrees = floor(raw / 100) - let minutes = raw - degrees * 100 - var result = degrees + minutes / 60 - if hemisphere == "S" || hemisphere == "W" { - result = -result - } - return result - } - - private static func date(time: String, date: String) -> Date? { - guard time.count >= 6, date.count == 6 else { return nil } - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "ddMMyyHHmmss.SS" - let normalizedTime = time.contains(".") ? time : "\(time).00" - return formatter.date(from: "\(date)\(normalizedTime)") - } - - private static func isChecksumValid(_ line: String) -> Bool { - let parts = line.split(separator: "*", maxSplits: 1, omittingEmptySubsequences: false) - guard parts.count == 2, let expected = UInt8(parts[1].prefix(2), radix: 16) else { - return true - } - - let payload = parts[0].dropFirst().utf8.reduce(UInt8(0)) { $0 ^ $1 } - return payload == expected - } -} diff --git a/Sources/Plugins/AisTrackerPlugin/AisObject.swift b/Sources/Plugins/AisTrackerPlugin/AisObject.swift index 68673ce64f..b12bf435e1 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisObject.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisObject.swift @@ -7,476 +7,60 @@ // import CoreLocation +import OsmAndShared -@objc enum AisObjType: Int { - case vessel - case vesselSport - case vesselFast - case vesselPassenger - case vesselFreight - case vesselCommercial - case vesselAuthorities - case vesselSar - case vesselOther - case landStation - case airplane - case sart - case aton - case atonVirtual - case invalid -} - -enum AisObjectConstants { - static let invalidHeading = 511 - static let invalidNavStatus = 15 - static let invalidManeuverIndicator = 0 - static let invalidShipType = 0 - static let invalidDimension = 0 - static let invalidEta = 0 - static let invalidEtaHour = 24 - static let invalidEtaMin = 60 - static let invalidAltitude = 4095 - static let unspecifiedAidType = 0 - static let invalidCog = 360.0 - static let invalidSog = 1023.0 - static let invalidLat = 91.0 - static let invalidLon = 181.0 - static let invalidRot = 128.0 - static let invalidDraught = 0.0 - static let invalidTcpa = -10000.0 - static let invalidCpa: Float = -1.0 - static let cpaUpdateTimeout: TimeInterval = 10 -} - -@objcMembers -final class AisObject: NSObject { - let mmsi: Int - let cpa = AisCpa() - - private(set) var msgType: Int - private(set) var msgTypes = Set() - private(set) var timestamp = 0 - private(set) var imo = 0 - private(set) var heading = AisObjectConstants.invalidHeading - private(set) var navStatus = AisObjectConstants.invalidNavStatus - private(set) var maneuverIndicator = AisObjectConstants.invalidManeuverIndicator - private(set) var shipType = AisObjectConstants.invalidShipType - private(set) var dimensionToBow = AisObjectConstants.invalidDimension - private(set) var dimensionToStern = AisObjectConstants.invalidDimension - private(set) var dimensionToPort = AisObjectConstants.invalidDimension - private(set) var dimensionToStarboard = AisObjectConstants.invalidDimension - private(set) var etaMonth = AisObjectConstants.invalidEta - private(set) var etaDay = AisObjectConstants.invalidEta - private(set) var etaHour = AisObjectConstants.invalidEtaHour - private(set) var etaMinute = AisObjectConstants.invalidEtaMin - private(set) var altitude = AisObjectConstants.invalidAltitude - private(set) var aidType = AisObjectConstants.unspecifiedAidType - private(set) var draught = AisObjectConstants.invalidDraught - private(set) var cog = AisObjectConstants.invalidCog - private(set) var sog = AisObjectConstants.invalidSog - private(set) var rot = AisObjectConstants.invalidRot - private(set) var latitude = AisObjectConstants.invalidLat - private(set) var longitude = AisObjectConstants.invalidLon - private(set) var callSign: String? - private(set) var shipName: String? - private(set) var destination: String? - private(set) var objectClass: AisObjType = .invalid - private(set) var lastUpdate = Date() - - var hasPosition: Bool { - latitude != AisObjectConstants.invalidLat && longitude != AisObjectConstants.invalidLon - } - - var title: String { - if let shipName, !shipName.isEmpty { return shipName } - if let callSign, !callSign.isEmpty { return callSign } - return "MMSI \(mmsi)" - } - - var messageTypesString: String { - msgTypes.sorted().map(String.init).joined(separator: ", ") - } - - var hasImoMessage: Bool { - hasMessageType(5) - } - - var hasShipTypeMessage: Bool { - hasMessageType(5) || hasMessageType(19) || hasMessageType(24) - } - - var location: CLLocation? { - guard hasPosition else { return nil } - return CLLocation(coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), - altitude: altitude == AisObjectConstants.invalidAltitude ? 0 : CLLocationDistance(altitude), - horizontalAccuracy: 20, - verticalAccuracy: -1, - course: cog == AisObjectConstants.invalidCog ? -1 : cog, - speed: sog == AisObjectConstants.invalidSog ? -1 : sog * 1852.0 / 3600.0, - timestamp: lastUpdate) - } - - var currentLocation: CLLocation? { - guard let location else { return nil } - let ageHours = Date().timeIntervalSince(lastUpdate) / 3600.0 - return AisTrackerHelper.newPosition(from: location, ageHours: ageHours) - } - - - var isMovable: Bool { - switch objectClass { - case .vessel, .vesselSport, .vesselFast, .vesselPassenger, .vesselFreight, .vesselCommercial, .vesselAuthorities, .vesselSar, .vesselOther, .airplane: - return true - case .invalid: - return sog != AisObjectConstants.invalidSog && sog > 0 - default: - return false - } - } - - var isVesselAtRest: Bool { - switch objectClass { - case .vessel, .vesselSport, .vesselFast, .vesselPassenger, .vesselFreight, .vesselCommercial, .vesselAuthorities, .vesselSar, .vesselOther: - if navStatus == 5 { - return cog == AisObjectConstants.invalidCog || sog < 0.2 - } - return (msgTypes.contains(18) || msgTypes.contains(24) || msgTypes.contains(1) || msgTypes.contains(3)) - && cog == AisObjectConstants.invalidCog && sog < 0.2 - default: - return false - } - } - - var vesselRotation: Double { - if cog != AisObjectConstants.invalidCog { return cog } - if heading != AisObjectConstants.invalidHeading { return Double(heading) } - return 0 +enum AisObjectHelper { + static func lastUpdateDate(_ object: AisObject) -> Foundation.Date { + Date(timeIntervalSince1970: TimeInterval(object.lastUpdate) / 1000.0) } - var shipTypeString: String { - switch shipType { - case AisObjectConstants.invalidShipType: return localizedString("ais_unknown") - case 20: return localizedString("ais_ship_type_wig") - case 21: return localizedString("ais_ship_type_wig_hazard_a") - case 22: return localizedString("ais_ship_type_wig_hazard_b") - case 23: return localizedString("ais_ship_type_wig_hazard_c") - case 24: return localizedString("ais_ship_type_wig_hazard_d") - case 30: return localizedString("ais_ship_type_fishing") - case 31, 32: return localizedString("ais_ship_type_towing") - case 33: return localizedString("ais_ship_type_dredging") - case 34: return localizedString("ais_ship_type_diving_ops") - case 35: return localizedString("ais_ship_type_military_ops") - case 36: return localizedString("ais_ship_type_sailing") - case 37: return localizedString("ais_ship_type_pleasure_craft") - case 40: return localizedString("ais_ship_type_hsc") - case 41: return localizedString("ais_ship_type_hsc_hazard_a") - case 42: return localizedString("ais_ship_type_hsc_hazard_b") - case 43: return localizedString("ais_ship_type_hsc_hazard_c") - case 44: return localizedString("ais_ship_type_hsc_hazard_d") - case 49: return localizedString("ais_ship_type_hsc") - case 50: return localizedString("ais_ship_type_pilot_vessel") - case 51: return localizedString("ais_ship_type_search_and_rescue") - case 52: return localizedString("ais_ship_type_tug") - case 53: return localizedString("ais_ship_type_port_tender") - case 54: return localizedString("ais_ship_type_antipollution") - case 55: return localizedString("ais_ship_type_law_enforcement") - case 56, 57: return localizedString("ais_ship_type_spare_local_vessel") - case 58: return localizedString("ais_ship_type_medical_transport") - case 59: return localizedString("ais_ship_type_noncombatant") - case 60: return localizedString("ais_ship_type_passenger") - case 61: return localizedString("ais_ship_type_passenger_hazard_a") - case 62: return localizedString("ais_ship_type_passenger_hazard_b") - case 63: return localizedString("ais_ship_type_passenger_hazard_c") - case 64: return localizedString("ais_ship_type_passenger_hazard_d") - case 69: return localizedString("ais_ship_type_passenger_cruise_ferry") - case 70: return localizedString("ais_ship_type_cargo") - case 71: return localizedString("ais_ship_type_cargo_hazard_a") - case 72: return localizedString("ais_ship_type_cargo_hazard_b") - case 73: return localizedString("ais_ship_type_cargo_hazard_c") - case 74: return localizedString("ais_ship_type_cargo_hazard_d") - case 79: return localizedString("ais_ship_type_cargo") - case 80: return localizedString("ais_ship_type_tanker") - case 81: return localizedString("ais_ship_type_tanker_hazard_a") - case 82: return localizedString("ais_ship_type_tanker_hazard_b") - case 83: return localizedString("ais_ship_type_tanker_hazard_c") - case 84: return localizedString("ais_ship_type_tanker_hazard_d") - case 89: return localizedString("ais_ship_type_tanker") - case 90: return localizedString("ais_ship_type_other") - case 91: return localizedString("ais_ship_type_other_hazard_a") - case 92: return localizedString("ais_ship_type_other_hazard_b") - case 93: return localizedString("ais_ship_type_other_hazard_c") - case 94: return localizedString("ais_ship_type_other_hazard_d") - case 99: return localizedString("ais_ship_type_other") - default: return "\(shipType)" - } + static func location(_ object: AisObject) -> CLLocation? { + guard let location = object.getAisLocation() else { return nil } + return makeLocation(location, timestamp: lastUpdateDate(object), altitude: object.altitude) } - var navStatusString: String { - switch navStatus { - case 0: return localizedString("ais_nav_status_under_way_engine") - case 1: return localizedString("ais_nav_status_at_anchor") - case 2: return localizedString("ais_nav_status_not_under_command") - case 3: return localizedString("ais_nav_status_restricted_maneuverability") - case 4: return localizedString("ais_nav_status_constrained_draught") - case 5: return localizedString("ais_nav_status_moored") - case 6: return localizedString("ais_nav_status_aground") - case 7: return localizedString("ais_nav_status_engaged_fishing") - case 8: return localizedString("ais_nav_status_under_way_sailing") - case 11: return localizedString("ais_nav_status_towing_astern") - case 12: return localizedString("ais_nav_status_pushing_or_towing") - case 14: return localizedString("ais_nav_status_sart_active") - case AisObjectConstants.invalidNavStatus: return localizedString("ais_unknown") - default: return "\(navStatus)" - } + static func currentLocation(_ object: AisObject) -> CLLocation? { + guard let location = object.getExtrapolatedLocation(now: Int64(Date().timeIntervalSince1970 * 1000)) else { return nil } + return makeLocation(location, timestamp: Date(), altitude: object.altitude) } - var maneuverIndicatorString: String { - switch maneuverIndicator { - case 0: return localizedString("shared_string_not_available") - case 1: return localizedString("ais_maneuver_no_special") - case 2: return localizedString("ais_maneuver_special") - default: return "\(maneuverIndicator)" - } + static func messageTypesString(_ object: AisObject) -> String { + let values = object.msgTypes.compactMap { ($0 as? KotlinInt).map { String($0.intValue) } } + return values.sorted().joined(separator: ", ") } - var aidTypeString: String { - switch aidType { - case 0: return localizedString("ais_not_specified") - case 1: return localizedString("ais_aid_reference_point") - case 2: return localizedString("ais_aid_racon") - case 3: return localizedString("ais_aid_fixed_structure_off_shore") - case 5: return localizedString("ais_aid_light_without_sectors") - case 6: return localizedString("ais_aid_light_with_sectors") - case 7: return localizedString("ais_aid_leading_light_front") - case 8: return localizedString("ais_aid_leading_light_rear") - case 9: return localizedString("ais_aid_beacon_cardinal_n") - case 10: return localizedString("ais_aid_beacon_cardinal_e") - case 11: return localizedString("ais_aid_beacon_cardinal_s") - case 12: return localizedString("ais_aid_beacon_cardinal_w") - case 13: return localizedString("ais_aid_beacon_port_hand") - case 14: return localizedString("ais_aid_beacon_starboard_hand") - case 17: return localizedString("ais_aid_beacon_isolated_danger") - case 18: return localizedString("ais_aid_beacon_safe_water") - case 19: return localizedString("ais_aid_beacon_special_mark") - case 20: return localizedString("ais_aid_cardinal_mark_n") - case 21: return localizedString("ais_aid_cardinal_mark_e") - case 22: return localizedString("ais_aid_cardinal_mark_s") - case 23: return localizedString("ais_aid_cardinal_mark_w") - case 24: return localizedString("ais_aid_port_hand_mark") - case 25: return localizedString("ais_aid_starboard_hand_mark") - case 28: return localizedString("ais_aid_isolated_danger") - case 29: return localizedString("ais_aid_safe_water") - case 30: return localizedString("ais_aid_special_mark") - case 31: return localizedString("ais_aid_light_vessel_lanby_rigs") - default: return "\(aidType)" - } - } - - @objc var debugSummary: String { - let position = hasPosition - ? String(format: "%.6f,%.6f", latitude, longitude) - : "none" - let age = Date().timeIntervalSince(lastUpdate) + static func debugSummary(_ object: AisObject) -> String { + let latitude = object.position?.latitude ?? AisObjectConstants.shared.INVALID_LAT + let longitude = object.position?.longitude ?? AisObjectConstants.shared.INVALID_LON + let positionText = object.position != nil ? String(format: "%.6f,%.6f", latitude, longitude) : "none" + let age = Date().timeIntervalSince(lastUpdateDate(object)) return String(format: "mmsi=%d msg=%d msgs=%@ class=%@ shipType=%d rest=%@ movable=%@ nav=%d sog=%.1f cog=%.1f heading=%d pos=%@ age=%.1fs", - mmsi, - msgType, - messageTypesString, - objectClassDebugName, - shipType, - isVesselAtRest ? "yes" : "no", - isMovable ? "yes" : "no", - navStatus, - sog, - cog, - heading, - position, + object.mmsi, + object.msgType, + messageTypesString(object), + object.objectClass.name, + object.shipType, + object.isVesselAtRest() ? "yes" : "no", + object.isMovable() ? "yes" : "no", + object.navStatus, + object.sog, + object.cog, + object.heading, + positionText, age) } - - init(mmsi: Int, msgType: Int) { - self.mmsi = mmsi - self.msgType = msgType - super.init() - msgTypes.insert(msgType) - updateObjectClass() - } - - func hasMessageType(_ type: Int) -> Bool { - msgTypes.contains(type) - } - - func merge(_ other: AisObject) { - msgType = other.msgType - msgTypes.insert(other.msgType) - if other.timestamp != 0 { timestamp = other.timestamp } - if other.imo != 0 { imo = other.imo } - if other.shipType != AisObjectConstants.invalidShipType { shipType = other.shipType } - if other.dimensionToBow != AisObjectConstants.invalidDimension { dimensionToBow = other.dimensionToBow } - if other.dimensionToStern != AisObjectConstants.invalidDimension { dimensionToStern = other.dimensionToStern } - if other.dimensionToPort != AisObjectConstants.invalidDimension { dimensionToPort = other.dimensionToPort } - if other.dimensionToStarboard != AisObjectConstants.invalidDimension { dimensionToStarboard = other.dimensionToStarboard } - if other.etaMonth != AisObjectConstants.invalidEta { etaMonth = other.etaMonth } - if other.etaDay != AisObjectConstants.invalidEta { etaDay = other.etaDay } - if other.etaHour != AisObjectConstants.invalidEtaHour { etaHour = other.etaHour } - if other.etaMinute != AisObjectConstants.invalidEtaMin { etaMinute = other.etaMinute } - if other.altitude != AisObjectConstants.invalidAltitude { altitude = other.altitude } - if other.aidType != AisObjectConstants.unspecifiedAidType { aidType = other.aidType } - if other.draught != AisObjectConstants.invalidDraught { draught = other.draught } - if other.hasPosition { - latitude = other.latitude - longitude = other.longitude - } - if let value = other.callSign { callSign = value } - if let value = other.shipName { shipName = value } - if let value = other.destination { destination = value } - - if [1, 2, 3, 18, 19, 27].contains(other.msgType) { - heading = other.heading - } - if [1, 2, 3, 27].contains(other.msgType) { - navStatus = other.navStatus - maneuverIndicator = other.maneuverIndicator - rot = other.rot - } - if [1, 2, 3, 9, 18, 19, 27].contains(other.msgType) { - cog = other.cog - sog = other.sog - } - lastUpdate = Date() - updateObjectClass() - } - func isLost(maxAgeMinutes: Int) -> Bool { - Date().timeIntervalSince(lastUpdate) / 60.0 > Double(maxAgeMinutes) + static func debugLog(_ message: String) { + AisLogger.shared.log(message) } - func signalLost(maxAgeMinutes: Int) -> Bool { - isLost(maxAgeMinutes: maxAgeMinutes) && isMovable && !isVesselAtRest + private static func makeLocation(_ location: AisLocation, timestamp: Foundation.Date, altitude: Int32) -> CLLocation { + CLLocation(coordinate: CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude), + altitude: altitude == AisObjectConstants.shared.INVALID_ALTITUDE ? 0 : CLLocationDistance(altitude), + horizontalAccuracy: 20, + verticalAccuracy: -1, + course: location.hasBearing ? CLLocationDirection(location.bearing) : -1, + speed: location.hasSpeed ? CLLocationSpeed(location.speed) : -1, + timestamp: timestamp) } - - func applyPosition(timestamp: Int, navStatus: Int, maneuverIndicator: Int, heading: Int, cog: Double, sog: Double, lat: Double, lon: Double, rot: Double) { - self.timestamp = timestamp - self.navStatus = navStatus - self.maneuverIndicator = maneuverIndicator - self.heading = heading - self.cog = cog - self.sog = sog - self.latitude = lat - self.longitude = lon - self.rot = rot - updateObjectClass() - } - - func applyBaseStation(lat: Double, lon: Double) { - latitude = lat - longitude = lon - updateObjectClass() - } - - func applyStatic(imo: Int, callSign: String?, shipName: String?, shipType: Int, bow: Int, stern: Int, port: Int, starboard: Int, draught: Double, destination: String?, etaMonth: Int, etaDay: Int, etaHour: Int, etaMinute: Int) { - self.imo = imo - self.callSign = callSign - self.shipName = shipName - self.shipType = shipType - dimensionToBow = bow - dimensionToStern = stern - dimensionToPort = port - dimensionToStarboard = starboard - self.draught = draught - if let destination, !destination.allSatisfy({ $0 == "@" }) { - self.destination = destination - } - self.etaMonth = etaMonth - self.etaDay = etaDay - self.etaHour = etaHour - self.etaMinute = etaMinute - updateObjectClass() - } - - func applyAircraft(timestamp: Int, altitude: Int, cog: Double, sog: Double, lat: Double, lon: Double) { - self.timestamp = timestamp - self.altitude = altitude - self.cog = cog - self.sog = sog - latitude = lat - longitude = lon - updateObjectClass() - } - - func applyAton(lat: Double, lon: Double, aidType: Int, bow: Int, stern: Int, port: Int, starboard: Int) { - latitude = lat - longitude = lon - self.aidType = aidType - dimensionToBow = bow - dimensionToStern = stern - dimensionToPort = port - dimensionToStarboard = starboard - updateObjectClass() - } - - private func updateObjectClass() { - switch shipType { - case 20...24, 40...44, 49: - objectClass = .vesselFast - case 30...34, 50, 52...54, 56, 57, 59: - objectClass = .vesselCommercial - case 35, 55: - objectClass = .vesselAuthorities - case 51, 58: - objectClass = .vesselSar - case 36, 37: - objectClass = .vesselSport - case 60...64, 69: - objectClass = .vesselPassenger - case 70...74, 79, 80...84, 89: - objectClass = .vesselFreight - case 90...94, 99: - objectClass = .vesselOther - default: - if msgTypes.contains(9) { - objectClass = .airplane - } else if msgTypes.contains(4) { - objectClass = .landStation - } else if msgTypes.contains(21) { - objectClass = (aidType == 29 || aidType == 30) ? .atonVirtual : .aton - } else if msgTypes.contains(18) { - objectClass = .vessel - } else { - switch navStatus { - case 0...6, 8, 11, 12: - objectClass = .vessel - case 7: - objectClass = .vesselCommercial - case 14: - objectClass = .sart - default: - objectClass = .invalid - } - } - } - } - - private var objectClassDebugName: String { - switch objectClass { - case .vessel: return "vessel" - case .vesselSport: return "vesselSport" - case .vesselFast: return "vesselFast" - case .vesselPassenger: return "vesselPassenger" - case .vesselFreight: return "vesselFreight" - case .vesselCommercial: return "vesselCommercial" - case .vesselAuthorities: return "vesselAuthorities" - case .vesselSar: return "vesselSar" - case .vesselOther: return "vesselOther" - case .landStation: return "landStation" - case .airplane: return "airplane" - case .sart: return "sart" - case .aton: return "aton" - case .atonVirtual: return "atonVirtual" - case .invalid: return "invalid" - } - } -} - -func aisDebugLog(_ message: String) { - AisLogger.shared.log(message) } diff --git a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift index 4f3863ad35..afee3bf46b 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift @@ -7,6 +7,7 @@ // import CoreLocation +import OsmAndShared final class AisMessageSimulationListener { private weak var plugin: AisTrackerPlugin? @@ -15,7 +16,7 @@ final class AisMessageSimulationListener { private let queue = DispatchQueue(label: "net.osmand.ais.simulation.listener") private let lock = NSLock() private var cancelled = false - + private var isCancelled: Bool { lock.lock() defer { lock.unlock() } @@ -78,11 +79,11 @@ final class AisMessageSimulationListener { var decoded = 0 var mmsi = Set() for sentence in sentences { - guard let object = decoder.decode(sentence: sentence), object.hasPosition else { + guard let object = decoder.decode(sentence: sentence), object.position != nil else { continue } decoded += 1 - mmsi.insert(object.mmsi) + mmsi.insert(Int(object.mmsi)) } return (sentences.count, decoded, mmsi.count) } @@ -143,90 +144,86 @@ final class AisSimulationProvider: NSObject { speed: 3.0 * 0.514444, timestamp: Date()) plugin?.fakeOwnPosition(fake) - plugin?.handleSimulatedLocation(fake) - let position = AisObject(mmsi: 324578, msgType: 18) - position.applyPosition(timestamp: 20, - navStatus: AisObjectConstants.invalidNavStatus, - maneuverIndicator: AisObjectConstants.invalidManeuverIndicator, - heading: 340, - cog: 340, - sog: 3, - lat: 50.76077, - lon: 7.08747, - rot: AisObjectConstants.invalidRot) + let constants = AisObjectConstants.shared + let position = AisObject(mmsi: 324578, + msgType: 18, + timeStamp: 20, + navStatus: constants.INVALID_NAV_STATUS, + manInd: constants.INVALID_MANEUVER_INDICATOR, + heading: 340, + cog: 340, + sog: 3, + lat: 50.76077, + lon: 7.08747, + rot: constants.INVALID_ROT) plugin?.handleSimulatedAisObject(position) - let data = AisObject(mmsi: 324578, msgType: 24) - data.applyStatic(imo: 0, - callSign: "callsign", - shipName: "fake", - shipType: 60, - bow: 56, - stern: 65, - port: 8, - starboard: 12, - draught: AisObjectConstants.invalidDraught, - destination: "home", - etaMonth: AisObjectConstants.invalidEta, - etaDay: AisObjectConstants.invalidEta, - etaHour: AisObjectConstants.invalidEtaHour, - etaMinute: AisObjectConstants.invalidEtaMin) + let data = AisObject(mmsi: 324578, + msgType: 24, + imo: 0, + callSign: "callsign", + shipName: "fake", + shipType: 60, + dimensionToBow: 56, + dimensionToStern: 65, + dimensionToPort: 8, + dimensionToStarboard: 12, + draught: constants.INVALID_DRAUGHT, + destination: "home", + etaMon: constants.INVALID_ETA, + etaDay: constants.INVALID_ETA, + etaHour: constants.INVALID_ETA_HOUR, + etaMin: constants.INVALID_ETA_MIN) plugin?.handleSimulatedAisObject(data) } func initTestPassengerShip() { guard plugin?.isEnabled() == true else { return } - let position = AisObject(mmsi: 34568, msgType: 1) - position.applyPosition(timestamp: 20, navStatus: 0, maneuverIndicator: 1, heading: 320, cog: 320, sog: 8.4, lat: 50.738, lon: 7.099, rot: 0) + let position = AisObject(mmsi: 34568, msgType: 1, timeStamp: 20, navStatus: 0, manInd: 1, heading: 320, cog: 320, sog: 8.4, lat: 50.738, lon: 7.099, rot: 0) plugin?.handleSimulatedAisObject(position) - let data = AisObject(mmsi: 34568, msgType: 5) - data.applyStatic(imo: 0, callSign: "TEST-CALLSIGN1", shipName: "TEST-Ship", shipType: 60, bow: 56, stern: 65, port: 8, starboard: 12, draught: 2, destination: "Potsdam", etaMonth: 8, etaDay: 15, etaHour: 22, etaMinute: 5) + let data = AisObject(mmsi: 34568, msgType: 5, imo: 0, callSign: "TEST-CALLSIGN1", shipName: "TEST-Ship", shipType: 60, dimensionToBow: 56, dimensionToStern: 65, dimensionToPort: 8, dimensionToStarboard: 12, draught: 2, destination: "Potsdam", etaMon: 8, etaDay: 15, etaHour: 22, etaMin: 5) plugin?.handleSimulatedAisObject(data) } func initTestSailingBoat() { guard plugin?.isEnabled() == true else { return } - let position = AisObject(mmsi: 454011, msgType: 18) - position.applyPosition(timestamp: 20, - navStatus: AisObjectConstants.invalidNavStatus, - maneuverIndicator: AisObjectConstants.invalidManeuverIndicator, - heading: 125, - cog: 125, - sog: 4.4, - lat: 50.737, - lon: 7.098, - rot: AisObjectConstants.invalidRot) + let constants = AisObjectConstants.shared + let position = AisObject(mmsi: 454011, + msgType: 18, + timeStamp: 20, + navStatus: constants.INVALID_NAV_STATUS, + manInd: constants.INVALID_MANEUVER_INDICATOR, + heading: 125, + cog: 125, + sog: 4.4, + lat: 50.737, + lon: 7.098, + rot: constants.INVALID_ROT) plugin?.handleSimulatedAisObject(position) - let data = AisObject(mmsi: 454011, msgType: 24) - data.applyStatic(imo: 0, callSign: "TEST-CALLSIGN2", shipName: "TEST-Sailor", shipType: 36, bow: 0, stern: 0, port: 0, starboard: 0, draught: AisObjectConstants.invalidDraught, destination: "home", etaMonth: AisObjectConstants.invalidEta, etaDay: AisObjectConstants.invalidEta, etaHour: AisObjectConstants.invalidEtaHour, etaMinute: AisObjectConstants.invalidEtaMin) + let data = AisObject(mmsi: 454011, msgType: 24, imo: 0, callSign: "TEST-CALLSIGN2", shipName: "TEST-Sailor", shipType: 36, dimensionToBow: 0, dimensionToStern: 0, dimensionToPort: 0, dimensionToStarboard: 0, draught: constants.INVALID_DRAUGHT, destination: "home", etaMon: constants.INVALID_ETA, etaDay: constants.INVALID_ETA, etaHour: constants.INVALID_ETA_HOUR, etaMin: constants.INVALID_ETA_MIN) plugin?.handleSimulatedAisObject(data) } func initTestLandStation() { guard plugin?.isEnabled() == true else { return } - let station = AisObject(mmsi: 878121, msgType: 4) - station.applyBaseStation(lat: 50.736, lon: 7.100) + let station = AisObject(mmsi: 878121, msgType: 4, lat: 50.736, lon: 7.100) plugin?.handleSimulatedAisObject(station) - let aid = AisObject(mmsi: 521077, msgType: 21) - aid.applyAton(lat: 50.735, lon: 7.101, aidType: 1, bow: 0, stern: 0, port: 0, starboard: 0) + let aid = AisObject(mmsi: 521077, msgType: 21, lat: 50.735, lon: 7.101, aidType: 1, dimensionToBow: 0, dimensionToStern: 0, dimensionToPort: 0, dimensionToStarboard: 0) plugin?.handleSimulatedAisObject(aid) } func initTestAircraft() { guard plugin?.isEnabled() == true else { return } - let aircraft = AisObject(mmsi: 910323, msgType: 9) - aircraft.applyAircraft(timestamp: 15, altitude: 65, cog: 180.5, sog: 55.0, lat: 50.734, lon: 7.102) + let aircraft = AisObject(mmsi: 910323, msgType: 9, timeStamp: 15, altitude: 65, cog: 180.5, sog: 55.0, lat: 50.734, lon: 7.102) plugin?.handleSimulatedAisObject(aircraft) } func initTestLawEnforcement() { guard plugin?.isEnabled() == true else { return } - let position = AisObject(mmsi: 34569, msgType: 1) - position.applyPosition(timestamp: 20, navStatus: 5, maneuverIndicator: 1, heading: 15, cog: 25, sog: 8.4, lat: 50.739, lon: 7.0931, rot: 0) + let position = AisObject(mmsi: 34569, msgType: 1, timeStamp: 20, navStatus: 5, manInd: 1, heading: 15, cog: 25, sog: 8.4, lat: 50.739, lon: 7.0931, rot: 0) plugin?.handleSimulatedAisObject(position) - let data = AisObject(mmsi: 34569, msgType: 5) - data.applyStatic(imo: 0, callSign: "TEST-CALLSIGN3", shipName: "Mecklenburg Vorpommern", shipType: 55, bow: 26, stern: 5, port: 8, starboard: 4, draught: 1, destination: "Potsdam", etaMonth: 8, etaDay: 15, etaHour: 22, etaMinute: 5) + let data = AisObject(mmsi: 34569, msgType: 5, imo: 0, callSign: "TEST-CALLSIGN3", shipName: "Mecklenburg Vorpommern", shipType: 55, dimensionToBow: 26, dimensionToStern: 5, dimensionToPort: 8, dimensionToStarboard: 4, draught: 1, destination: "Potsdam", etaMon: 8, etaDay: 15, etaHour: 22, etaMin: 5) plugin?.handleSimulatedAisObject(data) } } diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift index 47de49d8e8..a103c36092 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift @@ -7,224 +7,24 @@ // import CoreLocation +import OsmAndShared -@objcMembers -final class AisCpa: NSObject { - private(set) var tcpa = AisObjectConstants.invalidTcpa - private(set) var cpaDistance = AisObjectConstants.invalidCpa - private(set) var cpaPosition1: CLLocation? - private(set) var cpaPosition2: CLLocation? - private(set) var crossingTime1 = 0.0 - private(set) var crossingTime2 = 0.0 - private(set) var valid = false - - func reset() { - tcpa = AisObjectConstants.invalidTcpa - cpaDistance = AisObjectConstants.invalidCpa - cpaPosition1 = nil - cpaPosition2 = nil - crossingTime1 = 0 - crossingTime2 = 0 - valid = false - } - - fileprivate func update(tcpa: Double, - cpaDistance: Float, - cpaPosition1: CLLocation?, - cpaPosition2: CLLocation?, - crossingTimes: (Double, Double)?) { - self.tcpa = tcpa - self.cpaDistance = cpaDistance - self.cpaPosition1 = cpaPosition1 - self.cpaPosition2 = cpaPosition2 - if let crossingTimes { - crossingTime1 = crossingTimes.0 - crossingTime2 = crossingTimes.1 - } - valid = cpaDistance != AisObjectConstants.invalidCpa +private extension CLLocation { + var aisLocation: AisLocation { + OsmAndShared.AisLocation(latitude: coordinate.latitude, + longitude: coordinate.longitude, + speed: speed >= 0 ? Float(speed) : .nan, + bearing: course >= 0 ? Float(course) : .nan, + hasSpeed: speed >= 0, + hasBearing: course >= 0) } } enum AisTrackerHelper { - private struct Vector { - let x: Double - let y: Double - - func sub(_ other: Vector) -> Vector { - Vector(x: x - other.x, y: y - other.y) - } - - func dot(_ other: Vector) -> Double { - x * other.x + y * other.y - } - } - - private static var lastCorrectionUpdate = Date.distantPast - private static var correctionFactor = 1.0 - private static let maxCorrectionUpdateAge: TimeInterval = 60 * 60 - - static func knotsToMeterPerSecond(_ speed: Float) -> Float { - speed * 1852 / 3600 - } - - static func meterPerSecondToKnots(_ speed: Float) -> Float { - speed * 3600 / 1852 - } - - static func meterToMiles(_ distance: Float) -> Float { - distance / 1852 - } - - static func getTcpa(_ ownLocation: CLLocation, _ otherLocation: CLLocation) -> Double { - getTcpa(ownLocation, otherLocation, lonCorrection: getLonCorrection(ownLocation)) - } - - static func getCpa1(_ ownLocation: CLLocation, _ otherLocation: CLLocation) -> CLLocation? { - getCpa(ownLocation, otherLocation, useFirstAsReference: true) - } - - static func getCpa2(_ ownLocation: CLLocation, _ otherLocation: CLLocation) -> CLLocation? { - getCpa(ownLocation, otherLocation, useFirstAsReference: false) - } - - static func getCpaDistance(_ ownLocation: CLLocation, _ otherLocation: CLLocation) -> Float { - guard let cpa1 = getCpa1(ownLocation, otherLocation), - let cpa2 = getCpa2(ownLocation, otherLocation) else { - return AisObjectConstants.invalidCpa - } - return meterToMiles(Float(cpa1.distance(from: cpa2))) - } - static func getCpa(_ ownLocation: CLLocation, _ otherLocation: CLLocation, result: AisCpa) { result.reset() - guard !missingSpeedOrCourse(ownLocation, otherLocation) else { return } - let tcpa = getTcpa(ownLocation, otherLocation) - guard tcpa != AisObjectConstants.invalidTcpa else { return } - let cpa1 = newPosition(from: ownLocation, ageHours: tcpa) - let cpa2 = newPosition(from: otherLocation, ageHours: tcpa) - let cpaDistance: Float - if let cpa1, let cpa2 { - cpaDistance = meterToMiles(Float(cpa1.distance(from: cpa2))) - } else { - cpaDistance = AisObjectConstants.invalidCpa - } - result.update(tcpa: tcpa, - cpaDistance: cpaDistance, - cpaPosition1: cpa1, - cpaPosition2: cpa2, - crossingTimes: getCrossingTimes(ownLocation, otherLocation)) - } - - static func newPosition(from location: CLLocation?, ageHours: Double) -> CLLocation? { - guard let location, location.course >= 0, location.speed >= 0 else { return nil } - let distance = location.speed * ageHours * 3600.0 - let bearing = bearingInRad(location.course) - let angularDistance = distance / 6_371_000.0 - let lat1 = location.coordinate.latitude * .pi / 180.0 - let lon1 = location.coordinate.longitude * .pi / 180.0 - let lat2 = asin(sin(lat1) * cos(angularDistance) + cos(lat1) * sin(angularDistance) * cos(bearing)) - let lon2 = lon1 + atan2(sin(bearing) * sin(angularDistance) * cos(lat1), - cos(angularDistance) - sin(lat1) * sin(lat2)) - return CLLocation(coordinate: CLLocationCoordinate2D(latitude: lat2 * 180.0 / .pi, - longitude: lon2 * 180.0 / .pi), - altitude: location.altitude, - horizontalAccuracy: location.horizontalAccuracy, - verticalAccuracy: location.verticalAccuracy, - course: location.course, - speed: location.speed, - timestamp: Date()) - } - - private static func getCpa(_ ownLocation: CLLocation, - _ otherLocation: CLLocation, - useFirstAsReference: Bool) -> CLLocation? { - guard !missingSpeedOrCourse(ownLocation, otherLocation) else { return nil } - let tcpa = getTcpa(ownLocation, otherLocation) - guard tcpa != AisObjectConstants.invalidTcpa else { return nil } - return newPosition(from: useFirstAsReference ? ownLocation : otherLocation, ageHours: tcpa) - } - - private static func getTcpa(_ x: CLLocation, _ y: CLLocation, lonCorrection: Double) -> Double { - guard !missingSpeedOrCourse(x, y) else { return AisObjectConstants.invalidTcpa } - return getTcpa(locationToVector(x), - locationToVector(y), - courseToVector(cog: x.course, sog: Double(meterPerSecondToKnots(Float(x.speed)))), - courseToVector(cog: y.course, sog: Double(meterPerSecondToKnots(Float(y.speed)))), - lonCorrection: lonCorrection) - } - - private static func getTcpa(_ x: Vector, - _ y: Vector, - _ vx: Vector, - _ vy: Vector, - lonCorrection: Double) -> Double { - let dx = y.sub(x) - let dv = vy.sub(vx) - let divisor = dv.dot(dv) - guard abs(divisor) >= 1.0E-10, lonCorrection >= 1.0E-10 else { - return AisObjectConstants.invalidTcpa - } - return -(((dx.x * dv.x / lonCorrection) + (dx.y * dv.y)) / divisor) - } - - private static func getCrossingTimes(_ x: CLLocation, _ y: CLLocation) -> (Double, Double)? { - let lonCorrection = getLonCorrection(x) - let vX = locationToVector(x, lonCorrection: lonCorrection) - let vY = locationToVector(y, lonCorrection: lonCorrection) - let vVX = courseToVector(cog: x.course, sog: Double(meterPerSecondToKnots(Float(x.speed)))) - let vVY = courseToVector(cog: y.course, sog: Double(meterPerSecondToKnots(Float(y.speed)))) - let vDXY = vX.sub(vY) - let divisor = vVX.x * vVY.y - vVX.y * vVY.x - guard abs(divisor) >= 1.0E-10, lonCorrection >= 1.0E-10 else { return nil } - return ((vVY.x * vDXY.y - vVY.y * vDXY.x) / divisor, - (vVX.x * vDXY.y - vVX.y * vDXY.x) / divisor) - } - - private static func bearingInRad(_ bearingInDegrees: Double) -> Double { - var result = bearingInDegrees * 2 * .pi / 360.0 - while result >= .pi { result -= 2 * .pi } - return result - } - - private static func calculateLonCorrection(_ location: CLLocation?) -> Double { - guard let location else { return 1.0 } - let east = CLLocation(coordinate: location.coordinate, - altitude: location.altitude, - horizontalAccuracy: location.horizontalAccuracy, - verticalAccuracy: location.verticalAccuracy, - course: 90, - speed: CLLocationSpeed(knotsToMeterPerSecond(1)), - timestamp: location.timestamp) - guard let afterHour = newPosition(from: east, ageHours: 1.0) else { return 1.0 } - return (afterHour.coordinate.longitude - east.coordinate.longitude) * 60.0 - } - - private static func getLonCorrection(_ location: CLLocation?) -> Double { - if Date().timeIntervalSince(lastCorrectionUpdate) > maxCorrectionUpdateAge { - correctionFactor = calculateLonCorrection(location) - lastCorrectionUpdate = Date() - } - return correctionFactor - } - - private static func courseToVector(cog: Double, sog: Double) -> Vector { - var alpha = 450.0 - cog - while alpha < 0 { alpha += 360.0 } - while alpha >= 360.0 { alpha -= 360.0 } - alpha = alpha * .pi / 180.0 - return Vector(x: cos(alpha) * sog, y: sin(alpha) * sog) - } - - private static func locationToVector(_ location: CLLocation) -> Vector { - Vector(x: location.coordinate.longitude * 60.0, y: location.coordinate.latitude * 60.0) - } - - private static func locationToVector(_ location: CLLocation, lonCorrection: Double) -> Vector { - Vector(x: location.coordinate.longitude * 60.0 / lonCorrection, - y: location.coordinate.latitude * 60.0) - } - - private static func missingSpeedOrCourse(_ x: CLLocation, _ y: CLLocation) -> Bool { - x.course < 0 || y.course < 0 || x.speed < 0 || y.speed < 0 + OsmAndShared.AisTrackerMath.shared.getCpa(ownLocation: ownLocation.aisLocation, + otherLocation: otherLocation.aisLocation, + result: result) } } diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift index 9e98200374..87ed2f174f 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift @@ -7,10 +7,37 @@ // import CoreLocation +import OsmAndShared extension Notification.Name { static let aisNmeaConnectionStateChanged = Notification.Name("OAAisNmeaConnectionStateChanged") - static let aisNmeaLocationReceived = Notification.Name("OAAisNmeaLocationReceived") +} + +@objc enum AisNmeaProtocol: Int { + case udp = 0 + case tcp = 1 +} + +@objc enum AisNmeaConnectionState: Int { + case disconnected + case connecting + case connected + case failed +} + +extension AisNmeaConnectionState: CustomStringConvertible { + var description: String { + switch self { + case .disconnected: + return "disconnected" + case .connecting: + return "connecting" + case .connected: + return "connected" + case .failed: + return "failed" + } + } } @objcMembers @@ -20,7 +47,6 @@ final class AisTrackerPlugin: OAPlugin { static let hostPrefId = "ais_address_nmea_server" static let tcpPortPrefId = "ais_port_nmea_server" static let udpPortPrefId = "ais_port_nmea_local" - static let overrideLocationPrefId = "ais_use_nmea_location" static let objectLostTimeoutPrefId = "ais_object_lost_timeout" static let shipLostTimeoutPrefId = "ais_ship_lost_timeout" static let cpaWarningTimePrefId = "ais_cpa_warning_time" @@ -30,23 +56,22 @@ final class AisTrackerPlugin: OAPlugin { let hostPref: OACommonString let tcpPortPref: OACommonInteger let udpPortPref: OACommonInteger - let overrideLocationPref: OACommonBoolean let objectLostTimeoutPref: OACommonInteger let shipLostTimeoutPref: OACommonInteger let cpaWarningTimePref: OACommonInteger let cpaWarningDistancePref: OACommonDouble - private let connection = AisNmeaConnection() private let decoder = AisMessageDecoder() private let aisDecoderQueue = DispatchQueue(label: "com.app.ais.decoder", qos: .userInitiated) + private var networkListener: AisMessageListener? private var applicationModeObserver: OAAutoObserverProxy? private lazy var simulationProvider = AisSimulationProvider(plugin: self) private lazy var aisDataManager = AisDataManager(plugin: self) + private lazy var networkDataListener = AisNetworkDataListener(plugin: self) private(set) var connectionState: AisNmeaConnectionState = .disconnected - private(set) var lastLocation: CLLocation? private(set) var fakeOwnLocation: CLLocation? private(set) var simulationFileName: String? private(set) var simulationStatusText: String? @@ -63,23 +88,12 @@ final class AisTrackerPlugin: OAPlugin { hostPref = OAAppSettings.sharedManager().registerStringPreference(Self.hostPrefId, defValue: "192.168.200.16") tcpPortPref = OAAppSettings.sharedManager().registerIntPreference(Self.tcpPortPrefId, defValue: 4001) udpPortPref = OAAppSettings.sharedManager().registerIntPreference(Self.udpPortPrefId, defValue: 10110) - overrideLocationPref = OAAppSettings.sharedManager().registerBooleanPreference(Self.overrideLocationPrefId, defValue: false) objectLostTimeoutPref = OAAppSettings.sharedManager().registerIntPreference(Self.objectLostTimeoutPrefId, defValue: 7) shipLostTimeoutPref = OAAppSettings.sharedManager().registerIntPreference(Self.shipLostTimeoutPrefId, defValue: 4) cpaWarningTimePref = OAAppSettings.sharedManager().registerIntPreference(Self.cpaWarningTimePrefId, defValue: 0) cpaWarningDistancePref = OAAppSettings.sharedManager().registerFloatPreference(Self.cpaWarningDistancePrefId, defValue: 1.0) super.init() - connection.onStateChanged = { [weak self] state in - self?.connectionState = state - NotificationCenter.default.post(name: .aisNmeaConnectionStateChanged, object: self) - } - connection.onLocation = { [weak self] location in - self?.handle(location) - } - connection.onSentence = { [weak self] sentence in - self?.handleAisSentence(sentence) - } applicationModeObserver = OAAutoObserverProxy(self, withHandler: #selector(onApplicationModeChanged), andObserve: OsmAndApp.swiftInstance().applicationModeChangedObservable) @@ -137,7 +151,7 @@ final class AisTrackerPlugin: OAPlugin { override func disable() { clearSimulationObjects() - connection.stop() + stopAisNetworkListener() super.disable() } @@ -162,20 +176,20 @@ final class AisTrackerPlugin: OAPlugin { simulationReceivedObjects = 0 simulationRenderedObjects = 0 simulationStatusText = localizedString("shared_string_loading") - aisDebugLog("simulation start file=\(fileURL.lastPathComponent)") + AisObjectHelper.debugLog("simulation start file=\(fileURL.lastPathComponent)") simulationProvider.startAisSimulation(fileURL) } func updateSimulationStatus(sentences: Int, decoded: Int, objects: Int, error: String?) { if let error, !error.isEmpty { simulationStatusText = error - aisDebugLog("simulation status error=\(error)") + AisObjectHelper.debugLog("simulation status error=\(error)") } else { simulationSentences = sentences simulationDecoded = decoded simulationObjects = objects updateSimulationStatusText() - aisDebugLog("simulation stats sentences=\(sentences) decoded=\(decoded) objects=\(objects)") + AisObjectHelper.debugLog("simulation stats sentences=\(sentences) decoded=\(decoded) objects=\(objects)") } postSimulationStatusChanged() } @@ -189,7 +203,7 @@ final class AisTrackerPlugin: OAPlugin { } func prepareAisSimulation() { - connection.stop() + stopAisNetworkListener() aisDataManager.cleanupResources() aisDataManager.startUpdates() } @@ -205,7 +219,7 @@ final class AisTrackerPlugin: OAPlugin { func clearSimulationObjects() { simulationProvider.stopAisSimulation() - aisDebugLog("simulation clear") + AisObjectHelper.debugLog("simulation clear") fakeOwnLocation = nil simulationFileName = nil simulationStatusText = nil @@ -224,16 +238,24 @@ final class AisTrackerPlugin: OAPlugin { } aisDataManager.startUpdates() let proto = AisNmeaProtocol(rawValue: Int(protocolPref.get())) ?? .udp + stopSharedNetworkListener(updateState: false) + updateConnectionState(.connecting) switch proto { case .udp: - connection.startUDP(port: UInt16(max(1, udpPortPref.get()))) + let port = max(1, Int(udpPortPref.get())) + AisObjectHelper.debugLog("[AisTrackerPlugin] start shared AIS UDP port=\(port)") + networkListener = OsmAndShared.AisMessageListener(dataListener: networkDataListener, udpPort: Int32(port)) case .tcp: - connection.startTCP(host: hostPref.get(), port: UInt16(max(1, tcpPortPref.get()))) + let host = hostPref.get() + let port = max(1, Int(tcpPortPref.get())) + AisObjectHelper.debugLog("[AisTrackerPlugin] start shared AIS TCP host=\(host) port=\(port)") + networkListener = OsmAndShared.AisMessageListener(dataListener: networkDataListener, serverIp: host, serverPort: Int32(port)) } + updateConnectionState(.connected) } func stopAisNetworkListener() { - connection.stop() + stopSharedNetworkListener(updateState: true) aisDataManager.stopUpdates() } @@ -243,13 +265,6 @@ final class AisTrackerPlugin: OAPlugin { func handleSimulatedNmeaSentence(_ sentence: String) { handleAisSentence(sentence) - if let location = AisNmeaParser.parseLocation(from: sentence) { - handleSimulatedLocation(location) - } - } - - func handleSimulatedLocation(_ location: CLLocation) { - handle(location) } func handleSimulatedAisObject(_ object: AisObject) { @@ -284,23 +299,23 @@ final class AisTrackerPlugin: OAPlugin { } func onAisObjectReceived(_ object: AisObject) { - lastMessageReceived = object.lastUpdate + lastMessageReceived = AisObjectHelper.lastUpdateDate(object) if simulationFileName != nil { - let receivedObjects = getAisObjects().filter(\.hasPosition).count + let receivedObjects = getAisObjects().filter { $0.position != nil }.count if simulationReceivedObjects != receivedObjects { simulationReceivedObjects = receivedObjects updateSimulationStatusText() postSimulationStatusChanged() } } - aisDebugLog("plugin received withPosition=\(getAisObjects().filter(\.hasPosition).count) \(object.debugSummary)") + AisObjectHelper.debugLog("plugin received withPosition=\(getAisObjects().filter { $0.position != nil }.count) \(AisObjectHelper.debugSummary(object))") DispatchQueue.main.async { OAAisTrackerLayerBridge.onAisObjectReceived(object) } } func onAisObjectRemoved(_ object: AisObject) { - aisDebugLog("plugin removed \(object.debugSummary)") + AisObjectHelper.debugLog("plugin removed \(AisObjectHelper.debugSummary(object))") DispatchQueue.main.async { OAAisTrackerLayerBridge.onAisObjectRemoved(object) } @@ -315,25 +330,25 @@ final class AisTrackerPlugin: OAPlugin { func hasCpaWarning(for object: AisObject) -> Bool { let warningTime = cpaWarningTimeInMinutes() let warningDistance = cpaWarningDistanceInNauticalMiles() - guard object.isMovable, - object.objectClass != .airplane, + guard object.isMovable(), + object.objectClass != OsmAndShared.AisObjType.aisAirplane, warningTime > 0, object.sog > 0, let ownPosition = ownPosition(), - let aisPosition = object.location else { + let aisPosition = AisObjectHelper.location(object) else { return false } AisTrackerHelper.getCpa(ownPosition, aisPosition, result: object.cpa) guard object.cpa.valid, object.cpa.tcpa > 0 else { return false } - return Double(object.cpa.cpaDistance) <= warningDistance + return Double(object.cpa.cpa) <= warningDistance && object.cpa.tcpa * 60.0 <= Double(warningTime) - && object.cpa.crossingTime1 >= 0 - && object.cpa.crossingTime2 >= 0 + && object.cpa.t1 >= 0 + && object.cpa.t2 >= 0 } func updateCpa(for object: AisObject) { guard let ownPosition = ownPosition(), - let aisPosition = object.currentLocation ?? object.location else { + let aisPosition = AisObjectHelper.currentLocation(object) ?? AisObjectHelper.location(object) else { object.cpa.reset() return } @@ -342,7 +357,7 @@ final class AisTrackerPlugin: OAPlugin { func distanceInNauticalMiles(to object: AisObject) -> Double { guard let ownPosition = ownPosition(), - let aisPosition = object.currentLocation ?? object.location else { + let aisPosition = AisObjectHelper.currentLocation(object) ?? AisObjectHelper.location(object) else { return -1 } return ownPosition.distance(from: aisPosition) / 1852.0 @@ -350,7 +365,7 @@ final class AisTrackerPlugin: OAPlugin { func bearing(to object: AisObject) -> Double { guard let ownPosition = ownPosition(), - let aisPosition = object.currentLocation ?? object.location else { + let aisPosition = AisObjectHelper.currentLocation(object) ?? AisObjectHelper.location(object) else { return -1 } return Self.bearing(from: ownPosition.coordinate, to: aisPosition.coordinate) @@ -379,17 +394,9 @@ final class AisTrackerPlugin: OAPlugin { } } - private func handle(_ location: CLLocation) { - lastLocation = location - NotificationCenter.default.post(name: .aisNmeaLocationReceived, object: self) - if overrideLocationPref.get() { - OsmAndApp.swiftInstance().locationServices?.setLocationFromNMEA(location) - } - } - private func updateConnectionForCurrentProfile() { if isActiveForCurrentProfile() { - if !connection.isRunning { + if networkListener == nil { restartConnection() } } else { @@ -409,6 +416,28 @@ final class AisTrackerPlugin: OAPlugin { } } + fileprivate func onNetworkAisObjectReceived(_ object: AisObject) { + DispatchQueue.main.async { [weak self] in + self?.aisDataManager.onAisObjectReceived(object) + } + } + + private func stopSharedNetworkListener(updateState: Bool) { + if networkListener != nil { + AisObjectHelper.debugLog("[AisTrackerPlugin] stop shared AIS listener") + } + networkListener?.stopListener() + networkListener = nil + if updateState { + updateConnectionState(.disconnected) + } + } + + private func updateConnectionState(_ state: AisNmeaConnectionState) { + connectionState = state + NotificationCenter.default.post(name: .aisNmeaConnectionStateChanged, object: self) + } + private func updateSimulationStatusText() { var parts = [ "sentences \(simulationSentences)", @@ -440,3 +469,16 @@ final class AisTrackerPlugin: OAPlugin { updateConnectionForCurrentProfile() } } + +private final class AisNetworkDataListener: NSObject, OsmAndShared.AisDataListener { + private weak var plugin: AisTrackerPlugin? + + init(plugin: AisTrackerPlugin) { + self.plugin = plugin + super.init() + } + + func onAisObjectReceived(ais: AisObject) { + plugin?.onNetworkAisObjectReceived(ais) + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift index 15e820d7bc..b776154bae 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift @@ -59,7 +59,6 @@ final class AisTrackerSettingsViewController: OABaseNavbarViewController { override func viewDidLoad() { super.viewDidLoad() NotificationCenter.default.addObserver(self, selector: #selector(reloadStatus), name: .aisNmeaConnectionStateChanged, object: plugin) - NotificationCenter.default.addObserver(self, selector: #selector(reloadStatus), name: .aisNmeaLocationReceived, object: plugin) } override func getTitle() -> String { diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h index e4a9a6b649..aa8ae53a9e 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h +++ b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h @@ -7,11 +7,10 @@ // #import "OATargetInfoViewController.h" - -@class AisObject; +#import "OsmAndSharedWrapper.h" @interface OAAisObjectViewController : OATargetInfoViewController -- (instancetype)initWithAisObject:(AisObject *)object; +- (instancetype)initWithAisObject:(OASAisObject *)object; @end diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm index 201a2317fe..c0b9cea88c 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm @@ -19,20 +19,45 @@ static const NSInteger kAisRowStartOrder = 100; static const NSInteger kAisRowHeight = 50; +static BOOL OAAisTypeEquals(OASAisObjType *type, OASAisObjType *expected) +{ + return type == expected || [type isEqual:expected]; +} + +static NSDate *OAAisLastUpdateDate(OASAisObject *object) +{ + return [NSDate dateWithTimeIntervalSince1970:(NSTimeInterval)object.lastUpdate / 1000.0]; +} + +static BOOL OAAisHasMessageType(OASAisObject *object, int type) +{ + return [object.msgTypes containsObject:[[OASInt alloc] initWithInt:type]]; +} + +static NSString *OAAisMessageTypesString(OASAisObject *object) +{ + NSMutableArray *values = [NSMutableArray array]; + for (OASInt *type in object.msgTypes) + [values addObject:[NSString stringWithFormat:@"%d", type.intValue]]; + [values sortUsingSelector:@selector(compare:)]; + return [values componentsJoinedByString:@", "]; +} + @implementation OAAisObjectViewController { - AisObject *_object; + OASAisObject *_object; NSMutableArray *_menuRows; NSMutableSet *_aisValueRowKeys; } -- (instancetype)initWithAisObject:(AisObject *)object +- (instancetype)initWithAisObject:(OASAisObject *)object { self = [super initWithNibName:@"OATargetInfoViewController" bundle:nil]; if (self) { _object = object; - self.location = CLLocationCoordinate2DMake(object.latitude, object.longitude); + if (object.position) + self.location = CLLocationCoordinate2DMake(object.position.latitude, object.position.longitude); self.showTitleIfTruncated = NO; self.customOnlinePhotosPosition = YES; } @@ -123,62 +148,63 @@ - (void)buildInternal:(NSMutableArray *)rows if (plugin) [plugin updateCpaFor:_object]; - [self addRow:rows key:@"mmsi" prefix:@"MMSI" text:[NSString stringWithFormat:@"%ld", (long)_object.mmsi] order:order++]; - if (_object.hasPosition) + [self addRow:rows key:@"mmsi" prefix:OALocalizedString(@"ais_mmsi") text:[NSString stringWithFormat:@"%ld", (long)_object.mmsi] order:order++]; + if (_object.position) { - [self addRow:rows key:@"position" prefix:@"Position" text:[self formatPosition] order:order++]; + [self addRow:rows key:@"position" prefix:OALocalizedString(@"ais_position") text:[self formatPosition] order:order++]; } if (plugin) { double distance = [plugin distanceInNauticalMilesTo:_object]; if (distance >= 0) - [self addRow:rows key:@"distance" prefix:@"Distance" text:[NSString stringWithFormat:@"%.1f nm", distance] order:order++]; + [self addRow:rows key:@"distance" prefix:OALocalizedString(@"shared_string_distance") text:[NSString stringWithFormat:@"%.1f nm", distance] order:order++]; double bearing = [plugin bearingTo:_object]; if (bearing >= 0) - [self addRow:rows key:@"bearing" prefix:@"Bearing" text:[NSString stringWithFormat:@"%.0f", bearing] order:order++]; + [self addRow:rows key:@"bearing" prefix:OALocalizedString(@"shared_string_bearing") text:[NSString stringWithFormat:@"%.0f", bearing] order:order++]; } if (_object.cpa.valid) { - [self addRow:rows key:@"cpa" prefix:@"CPA" text:[NSString stringWithFormat:@"%.1f nm", _object.cpa.cpaDistance] order:order++]; - [self addRow:rows key:@"tcpa" prefix:@"TCPA" text:[self formatTcpa:_object.cpa.tcpa] order:order++]; + [self addRow:rows key:@"cpa" prefix:OALocalizedString(@"ais_cpa") text:[NSString stringWithFormat:@"%.1f nm", _object.cpa.cpa] order:order++]; + [self addRow:rows key:@"tcpa" prefix:OALocalizedString(@"ais_tcpa") text:[self formatTcpa:_object.cpa.tcpa] order:order++]; } - if (_object.objectClass == AisObjTypeAton || _object.objectClass == AisObjTypeAtonVirtual) + if (OAAisTypeEquals(_object.objectClass, OASAisObjType.aisAton) || OAAisTypeEquals(_object.objectClass, OASAisObjType.aisAtonVirtual)) { - if (_object.aidType != 0) - [self addRow:rows key:@"aid_type" prefix:@"Aid Type" text:_object.aidTypeString order:order++]; + if (_object.aidType != OASAisObjectConstants.shared.UNSPECIFIED_AID_TYPE) + [self addRow:rows key:@"aid_type" prefix:OALocalizedString(@"ais_aid_type") text:[_object getAidTypeString] order:order++]; order = [self addDimensionRows:rows order:order]; } - else if (_object.objectClass == AisObjTypeAirplane) + else if (OAAisTypeEquals(_object.objectClass, OASAisObjType.aisAirplane)) { - [self addRow:rows key:@"object_type" prefix:@"Object Type" text:[self objectTypeName:_object.objectClass] order:order++]; + [self addRow:rows key:@"object_type" prefix:OALocalizedString(@"ais_object_type") text:[self objectTypeName:_object.objectClass] order:order++]; order = [self addCourseRows:rows order:order includeHeading:NO includeNavStatus:NO]; - if (_object.altitude != 4095) - [self addRow:rows key:@"altitude" prefix:@"Altitude" text:[NSString stringWithFormat:@"%ld m", (long)_object.altitude] order:order++]; + if (_object.altitude != OASAisObjectConstants.shared.INVALID_ALTITUDE) + [self addRow:rows key:@"altitude" prefix:OALocalizedString(@"altitude") text:[NSString stringWithFormat:@"%ld m", (long)_object.altitude] order:order++]; } else { if (_object.callSign.length > 0) - [self addRow:rows key:@"callsign" prefix:@"Callsign" text:_object.callSign order:order++]; - if (_object.imo > 0 && _object.hasImoMessage) - [self addRow:rows key:@"imo" prefix:@"IMO" text:[NSString stringWithFormat:@"%ld", (long)_object.imo] order:order++]; + [self addRow:rows key:@"callsign" prefix:OALocalizedString(@"ais_call_sign") text:_object.callSign order:order++]; + if (_object.imo > 0 && OAAisHasMessageType(_object, 5)) + [self addRow:rows key:@"imo" prefix:OALocalizedString(@"ais_imo") text:[NSString stringWithFormat:@"%ld", (long)_object.imo] order:order++]; if (_object.shipName.length > 0) - [self addRow:rows key:@"ship_name" prefix:@"Shipname" text:_object.shipName order:order++]; - if (_object.shipType != 0 && _object.hasShipTypeMessage) - [self addRow:rows key:@"ship_type" prefix:@"Shiptype" text:_object.shipTypeString order:order++]; + [self addRow:rows key:@"ship_name" prefix:OALocalizedString(@"ais_ship_name") text:_object.shipName order:order++]; + if (_object.shipType != OASAisObjectConstants.shared.INVALID_DIMENSION && (OAAisHasMessageType(_object, 5) || OAAisHasMessageType(_object, 19) || OAAisHasMessageType(_object, 24))) + [self addRow:rows key:@"ship_type" prefix:OALocalizedString(@"ais_ship_type") text:[_object getShipTypeString] order:order++]; order = [self addCourseRows:rows order:order includeHeading:YES includeNavStatus:YES]; order = [self addDimensionRows:rows order:order]; - if (_object.draught > 0) - [self addRow:rows key:@"draught" prefix:@"Draught" text:[NSString stringWithFormat:@"%.1f m", _object.draught] order:order++]; + if (_object.draught != OASAisObjectConstants.shared.INVALID_DRAUGHT) + [self addRow:rows key:@"draught" prefix:OALocalizedString(@"ais_draught") text:[NSString stringWithFormat:@"%.1f m", _object.draught] order:order++]; if (_object.destination.length > 0) - [self addRow:rows key:@"destination" prefix:@"Destination" text:_object.destination order:order++]; - if (_object.etaMonth > 0 && _object.etaDay > 0) - [self addRow:rows key:@"eta" prefix:@"ETA" text:[NSString stringWithFormat:@"%02ld.%02ld. %02ld:%02ld", (long)_object.etaDay, (long)_object.etaMonth, (long)_object.etaHour, (long)_object.etaMinute] order:order++]; + [self addRow:rows key:@"destination" prefix:OALocalizedString(@"ais_destination") text:_object.destination order:order++]; + if (_object.etaMon != OASAisObjectConstants.shared.INVALID_ETA && _object.etaDay != OASAisObjectConstants.shared.INVALID_ETA) + [self addRow:rows key:@"eta" prefix:OALocalizedString(@"ais_eta") text:[NSString stringWithFormat:@"%02ld.%02ld. %02ld:%02ld", (long)_object.etaDay, (long)_object.etaMon, (long)_object.etaHour, (long)_object.etaMin] order:order++]; } - [self addRow:rows key:@"last_update" prefix:@"Last Update" text:[self formatLastUpdate] order:order++]; - if (_object.messageTypesString.length > 0) - [self addRow:rows key:@"message_types" prefix:@"Message Type(s)" text:_object.messageTypesString order:order++]; + [self addRow:rows key:@"last_update" prefix:OALocalizedString(@"ais_last_update") text:[self formatLastUpdate] order:order++]; + NSString *messageTypesString = OAAisMessageTypesString(_object); + if (messageTypesString.length > 0) + [self addRow:rows key:@"message_types" prefix:OALocalizedString(@"ais_message_types") text:messageTypesString order:order++]; } - (BOOL)needBuildCoordinatesRow @@ -238,16 +264,16 @@ - (NSInteger)addCourseRows:(NSMutableArray *)rows includeHeading:(BOOL)includeHeading includeNavStatus:(BOOL)includeNavStatus { - if (includeNavStatus && _object.navStatus != 15) - [self addRow:rows key:@"nav_status" prefix:@"Navigation Status" text:_object.navStatusString.upperCase order:order++]; - if (_object.cog != 360.0) - [self addRow:rows key:@"cog" prefix:@"COG" text:[NSString stringWithFormat:@"%.0f", _object.cog] order:order++]; - if (_object.sog != 1023.0) - [self addRow:rows key:@"sog" prefix:@"SOG" text:[NSString stringWithFormat:@"%.1f KTS", _object.sog] order:order++]; - if (includeHeading && _object.heading != 511) - [self addRow:rows key:@"heading" prefix:@"Heading" text:[NSString stringWithFormat:@"%ld", (long)_object.heading] order:order++]; - if (includeHeading && _object.rot != 128.0) - [self addRow:rows key:@"rot" prefix:@"Rate of Turn" text:[NSString stringWithFormat:@"%.1f", _object.rot] order:order++]; + if (includeNavStatus && _object.navStatus != OASAisObjectConstants.shared.INVALID_NAV_STATUS) + [self addRow:rows key:@"nav_status" prefix:OALocalizedString(@"ais_navigation_status") text:[_object getNavStatusString].upperCase order:order++]; + if (_object.cog != OASAisObjectConstants.shared.INVALID_COG) + [self addRow:rows key:@"cog" prefix:OALocalizedString(@"ais_cog") text:[NSString stringWithFormat:@"%.0f", _object.cog] order:order++]; + if (_object.sog != OASAisObjectConstants.shared.INVALID_SOG) + [self addRow:rows key:@"sog" prefix:OALocalizedString(@"ais_sog") text:[NSString stringWithFormat:@"%.1f %@", _object.sog, OALocalizedString(@"shared_string_kts")] order:order++]; + if (includeHeading && _object.heading != OASAisObjectConstants.shared.INVALID_HEADING) + [self addRow:rows key:@"heading" prefix:OALocalizedString(@"ais_heading") text:[NSString stringWithFormat:@"%ld", (long)_object.heading] order:order++]; + if (includeHeading && _object.rot != OASAisObjectConstants.shared.INVALID_ROT) + [self addRow:rows key:@"rot" prefix:OALocalizedString(@"ais_rate_of_turn") text:[NSString stringWithFormat:@"%.1f", _object.rot] order:order++]; return order; } @@ -256,45 +282,56 @@ - (NSInteger)addDimensionRows:(NSMutableArray *)rows order:( NSInteger length = _object.dimensionToBow + _object.dimensionToStern; NSInteger width = _object.dimensionToPort + _object.dimensionToStarboard; if (length > 0 && width > 0) - [self addRow:rows key:@"dimensions" prefix:@"Dimension" text:[NSString stringWithFormat:@"%ldm x %ldm", (long)length, (long)width] order:order++]; + [self addRow:rows key:@"dimensions" prefix:OALocalizedString(@"ais_dimensions") text:[NSString stringWithFormat:@"%ldm x %ldm", (long)length, (long)width] order:order++]; return order; } - (NSString *)formatPosition { - NSString *lat = [OALocationConvert convertLatitude:_object.latitude outputType:FORMAT_MINUTES addCardinalDirection:YES]; - NSString *lon = [OALocationConvert convertLongitude:_object.longitude outputType:FORMAT_MINUTES addCardinalDirection:YES]; + NSString *lat = [OALocationConvert convertLatitude:_object.position.latitude outputType:FORMAT_MINUTES addCardinalDirection:YES]; + NSString *lon = [OALocationConvert convertLongitude:_object.position.longitude outputType:FORMAT_MINUTES addCardinalDirection:YES]; return [NSString stringWithFormat:@"%@, %@", lat, lon]; } - (NSString *)formatLastUpdate { - NSInteger seconds = MAX(0, (NSInteger)round(-[_object.lastUpdate timeIntervalSinceNow])); + NSInteger seconds = MAX(0, (NSInteger)round(-[OAAisLastUpdateDate(_object) timeIntervalSinceNow])); if (seconds > 60) - return [NSString stringWithFormat:@"%ld min %ld sec", (long)(seconds / 60), (long)(seconds % 60)]; - return [NSString stringWithFormat:@"%ld sec", (long)seconds]; + return [NSString stringWithFormat:@"%ld %@ %ld %@", (long)(seconds / 60), OALocalizedString(@"shared_string_minute_lowercase"), (long)(seconds % 60), OALocalizedString(@"shared_string_sec")]; + return [NSString stringWithFormat:@"%ld %@", (long)seconds, OALocalizedString(@"shared_string_sec")]; } -- (NSString *)objectTypeName:(AisObjType)type +- (NSString *)objectTypeName:(OASAisObjType *)type { - switch (type) - { - case AisObjTypeVessel: return OALocalizedString(@"ais_type_vessel"); - case AisObjTypeVesselSport: return OALocalizedString(@"ais_type_sport_vessel"); - case AisObjTypeVesselFast: return OALocalizedString(@"ais_type_high_speed_vessel"); - case AisObjTypeVesselPassenger: return OALocalizedString(@"ais_type_passenger_vessel"); - case AisObjTypeVesselFreight: return OALocalizedString(@"ais_type_cargo_tanker"); - case AisObjTypeVesselCommercial: return OALocalizedString(@"ais_type_commercial_vessel"); - case AisObjTypeVesselAuthorities: return OALocalizedString(@"ais_type_authorities_vessel"); - case AisObjTypeVesselSar: return OALocalizedString(@"ais_type_sar_vessel"); - case AisObjTypeLandStation: return OALocalizedString(@"ais_type_base_station"); - case AisObjTypeAirplane: return OALocalizedString(@"ais_type_sar_aircraft"); - case AisObjTypeSart: return OALocalizedString(@"ais_type_sart"); - case AisObjTypeAton: return OALocalizedString(@"ais_type_aid_to_navigation"); - case AisObjTypeAtonVirtual: return OALocalizedString(@"ais_type_virtual_aid_to_navigation"); - case AisObjTypeVesselOther: return OALocalizedString(@"ais_type_other_vessel"); - default: return OALocalizedString(@"ais_type_object"); - } + if (OAAisTypeEquals(type, OASAisObjType.aisVessel)) + return OALocalizedString(@"ais_type_vessel"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselSport)) + return OALocalizedString(@"ais_type_sport_vessel"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselFast)) + return OALocalizedString(@"ais_type_high_speed_vessel"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselPassenger)) + return OALocalizedString(@"ais_type_passenger_vessel"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselFreight)) + return OALocalizedString(@"ais_type_cargo_tanker"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselCommercial)) + return OALocalizedString(@"ais_type_commercial_vessel"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselAuthorities)) + return OALocalizedString(@"ais_type_authorities_vessel"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselSar)) + return OALocalizedString(@"ais_type_sar_vessel"); + if (OAAisTypeEquals(type, OASAisObjType.aisLandstation)) + return OALocalizedString(@"ais_type_base_station"); + if (OAAisTypeEquals(type, OASAisObjType.aisAirplane)) + return OALocalizedString(@"ais_type_sar_aircraft"); + if (OAAisTypeEquals(type, OASAisObjType.aisSart)) + return OALocalizedString(@"ais_type_sart"); + if (OAAisTypeEquals(type, OASAisObjType.aisAton)) + return OALocalizedString(@"ais_type_aid_to_navigation"); + if (OAAisTypeEquals(type, OASAisObjType.aisAtonVirtual)) + return OALocalizedString(@"ais_type_virtual_aid_to_navigation"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselOther)) + return OALocalizedString(@"ais_type_other_vessel"); + return OALocalizedString(@"ais_type_object"); } - (NSString *)formatTcpa:(double)tcpa @@ -303,7 +340,7 @@ - (NSString *)formatTcpa:(double)tcpa double absTcpa = fabs(tcpa); NSInteger hours = (NSInteger)absTcpa; NSInteger minutes = (NSInteger)round((absTcpa - hours) * 60.0); - NSString *value = hours > 0 ? [NSString stringWithFormat:@"%ld h %ld min", (long)hours, (long)minutes] : [NSString stringWithFormat:@"%ld min", (long)minutes]; + NSString *value = hours > 0 ? [NSString stringWithFormat:@"%ld %@ %ld %@", (long)hours, OALocalizedString(@"int_hour"), (long)minutes, OALocalizedString(@"shared_string_minute_lowercase")] : [NSString stringWithFormat:@"%ld %@", (long)minutes, OALocalizedString(@"shared_string_minute_lowercase")]; return future ? value : [NSString stringWithFormat:@"-%@", value]; } diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h index 3dc74590ef..0341af9c2e 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h @@ -8,13 +8,12 @@ #import "OAMapLayer.h" #import "OAContextMenuProvider.h" - -@class AisObject; +#import "OsmAndSharedWrapper.h" @interface OAAisTrackerLayer : OAMapLayer - (void)reloadAisObjects; -- (void)onAisObjectReceived:(AisObject *)object; -- (void)onAisObjectRemoved:(AisObject *)object; +- (void)onAisObjectReceived:(OASAisObject *)object; +- (void)onAisObjectRemoved:(OASAisObject *)object; @end diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm index b2f37adc66..e1d8106a41 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm @@ -37,6 +37,11 @@ static const OsmAnd::MapMarker::OnSurfaceIconKey kAisIconKey = &kAisIconKeyStorage; static std::unordered_map> kAisImagesCache; +static BOOL OAAisTypeEquals(OASAisObjType *type, OASAisObjType *expected) +{ + return type == expected || [type isEqual:expected]; +} + static std::string OAAisImageCacheKey(NSString *prefix, NSString *name, CGFloat iconSize) { NSString *key = [NSString stringWithFormat:@"%@:%@:%d", prefix, name, (int)std::round(iconSize * 100.0)]; @@ -56,21 +61,72 @@ return image; } -static NSString *OAAisObjectTitle(AisObject *object) +static NSString *OAAisObjectTitle(OASAisObject *object) { return [NSString stringWithFormat:OALocalizedString(@"ais_object_with_mmsi"), (long)object.mmsi]; } +static NSDate *OAAisLastUpdateDate(OASAisObject *object) +{ + return [NSDate dateWithTimeIntervalSince1970:(NSTimeInterval)object.lastUpdate / 1000.0]; +} + +static CLLocation *OAAisObjectLocation(OASAisObject *object) +{ + OASAisLocation *location = [object getAisLocation]; + if (!location) + return nil; + CLLocationDistance altitude = object.altitude == OASAisObjectConstants.shared.INVALID_ALTITUDE ? 0 : object.altitude; + return [[CLLocation alloc] initWithCoordinate:CLLocationCoordinate2DMake(location.latitude, location.longitude) + altitude:altitude + horizontalAccuracy:20 + verticalAccuracy:-1 + course:location.hasBearing ? location.bearing : -1 + speed:location.hasSpeed ? location.speed : -1 + timestamp:OAAisLastUpdateDate(object)]; +} + +static NSString *OAAisMessageTypesString(OASAisObject *object) +{ + NSMutableArray *values = [NSMutableArray array]; + for (OASInt *type in object.msgTypes) + [values addObject:[NSString stringWithFormat:@"%d", type.intValue]]; + [values sortUsingSelector:@selector(compare:)]; + return [values componentsJoinedByString:@", "]; +} + +static NSString *OAAisDebugSummary(OASAisObject *object) +{ + NSString *positionText = object.position + ? [NSString stringWithFormat:@"%.6f,%.6f", object.position.latitude, object.position.longitude] + : @"none"; + NSTimeInterval age = [[NSDate date] timeIntervalSinceDate:OAAisLastUpdateDate(object)]; + return [NSString stringWithFormat:@"mmsi=%d msg=%d msgs=%@ class=%@ shipType=%d rest=%@ movable=%@ nav=%d sog=%.1f cog=%.1f heading=%d pos=%@ age=%.1fs", + object.mmsi, + object.msgType, + OAAisMessageTypesString(object), + object.objectClass.name, + object.shipType, + [object isVesselAtRest] ? @"yes" : @"no", + [object isMovable] ? @"yes" : @"no", + object.navStatus, + object.sog, + object.cog, + object.heading, + positionText, + age]; +} + @interface AisObjectDrawable : NSObject -@property (nonatomic) AisObject *object; +@property (nonatomic) OASAisObject *object; @property (nonatomic, copy) NSString *renderKey; -- (instancetype)initWithObject:(AisObject *)object; -- (instancetype)initWithObject:(AisObject *)object +- (instancetype)initWithObject:(OASAisObject *)object; +- (instancetype)initWithObject:(OASAisObject *)object textScale:(CGFloat)textScale displayDensityFactor:(CGFloat)displayDensityFactor; -- (void)set:(AisObject *)object; +- (void)set:(OASAisObject *)object; - (void)setTextScale:(CGFloat)textScale displayDensityFactor:(CGFloat)displayDensityFactor; - (BOOL)hasAisRenderData; @@ -101,12 +157,12 @@ @implementation AisObjectDrawable int _baseOrder; } -- (instancetype)initWithObject:(AisObject *)object +- (instancetype)initWithObject:(OASAisObject *)object { return [self initWithObject:object textScale:1.0 displayDensityFactor:UIScreen.mainScreen.scale]; } -- (instancetype)initWithObject:(AisObject *)object +- (instancetype)initWithObject:(OASAisObject *)object textScale:(CGFloat)textScale displayDensityFactor:(CGFloat)displayDensityFactor { @@ -119,7 +175,7 @@ - (instancetype)initWithObject:(AisObject *)object return self; } -- (void)set:(AisObject *)object +- (void)set:(OASAisObject *)object { _object = object; } @@ -152,7 +208,7 @@ - (NSString *)currentRenderKey - (OsmAnd::PointI)markerLocation { - CLLocation *location = _object.location; + CLLocation *location = OAAisObjectLocation(_object); if (!location) return OsmAnd::PointI(0, 0); return OsmAnd::PointI(OsmAnd::Utilities::get31TileNumberX(location.coordinate.longitude), @@ -254,13 +310,13 @@ - (void)updateAisRenderDataWithMapView:(OAMapRendererView *)mapView return; const OsmAnd::ZoomLevel zoom = mapView ? mapView.zoomLevel : OsmAnd::ZoomLevel::MinZoomLevel; - if (!mapView || (int)zoom < kAisTrackerStartZoom || !_object.hasPosition) + if (!mapView || (int)zoom < kAisTrackerStartZoom || !_object.position) { [self setAisRenderDataHidden:YES]; return; } - CLLocation *location = _object.location; + CLLocation *location = OAAisObjectLocation(_object); if (!location) { [self setAisRenderDataHidden:YES]; @@ -276,7 +332,7 @@ - (void)updateAisRenderDataWithMapView:(OAMapRendererView *)mapView NSInteger vesselLostTimeout = plugin ? [plugin vesselLostTimeoutInMinutes] : 0; BOOL vesselAtRest = [_object isVesselAtRest]; - BOOL lostTimeout = vesselLostTimeout > 0 && [_object isLostWithMaxAgeMinutes:vesselLostTimeout] && !vesselAtRest; + BOOL lostTimeout = vesselLostTimeout > 0 && [_object isLostMaxAgeInMin:(int32_t)vesselLostTimeout] && !vesselAtRest; CGFloat speedFactor = [self movementFactor]; BOOL drawDirectionLine = speedFactor > 0 && !lostTimeout && !vesselAtRest; @@ -290,7 +346,7 @@ - (void)updateAisRenderDataWithMapView:(OAMapRendererView *)mapView _restMarker->setIsHidden(!vesselAtRest); _lostMarker->setIsHidden(!lostTimeout); - float rotation = fmod(_object.vesselRotation + 180.0, 360.0); + float rotation = fmod([_object getVesselRotation] + 180.0, 360.0); if (!vesselAtRest && [self needRotation]) { _activeMarker->setOnMapSurfaceIconDirection(kAisIconKey, rotation); @@ -372,7 +428,7 @@ - (void)clearAisRenderDataFromMarkersCollection:(const std::shared_ptr *objects = [plugin getAisObjects]; + NSArray *objects = [plugin getAisObjects]; NSMutableSet *visibleMmsi = [NSMutableSet set]; - for (AisObject *object in objects) + for (OASAisObject *object in objects) { - if (!object.hasPosition) + if (!object.position) continue; NSNumber *key = @(object.mmsi); @@ -802,18 +850,18 @@ - (void)reloadObjectsSync [plugin updateSimulationRenderedObjects:_objectDrawables.count]; } -- (void)onAisObjectReceived:(AisObject *)object +- (void)onAisObjectReceived:(OASAisObject *)object { - if (![self isVisible] || !object.hasPosition) + if (![self isVisible] || !object.position) return; - [[AisLogger shared] log:[NSString stringWithFormat:@"receive %@", object.debugSummary]]; + [[AisLogger shared] log:[NSString stringWithFormat:@"receive %@", OAAisDebugSummary(object)]]; [self addCollectionsToRenderer]; [self.mapViewController runWithRenderSync:^{ [self updateAisObjectSync:object]; }]; } -- (void)onAisObjectRemoved:(AisObject *)object +- (void)onAisObjectRemoved:(OASAisObject *)object { if (!object) return; @@ -822,7 +870,7 @@ - (void)onAisObjectRemoved:(AisObject *)object NSNumber *key = @(object.mmsi); AisObjectDrawable *drawable = _objectDrawables[key]; [[AisLogger shared] log:[NSString stringWithFormat:@"remove hasDrawable=%@ drawables=%lu %@", - drawable ? @"yes" : @"no", (unsigned long)_objectDrawables.count, object.debugSummary]]; + drawable ? @"yes" : @"no", (unsigned long)_objectDrawables.count, OAAisDebugSummary(object)]]; if (drawable) { [drawable clearAisRenderDataFromMarkersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; @@ -832,7 +880,7 @@ - (void)onAisObjectRemoved:(AisObject *)object }]; } -- (void)updateAisObjectSync:(AisObject *)object +- (void)updateAisObjectSync:(OASAisObject *)object { [self ensureObjectDrawables]; NSNumber *key = @(object.mmsi); @@ -854,7 +902,7 @@ - (void)updateAisObjectSync:(AisObject *)object [drawable updateAisRenderDataWithMapView:self.mapView plugin:[self plugin]]; int linesCount = _vectorLinesCollection ? _vectorLinesCollection->getLinesCount() : 0; - [[AisLogger shared] log:[NSString stringWithFormat:@"update recreated=%@ drawables=%lu lines=%d %@", recreated ? @"yes" : @"no", (unsigned long)_objectDrawables.count, linesCount, object.debugSummary]]; + [[AisLogger shared] log:[NSString stringWithFormat:@"update recreated=%@ drawables=%lu lines=%d %@", recreated ? @"yes" : @"no", (unsigned long)_objectDrawables.count, linesCount, OAAisDebugSummary(object)]]; [[self plugin] updateSimulationRenderedObjects:_objectDrawables.count]; } @@ -904,11 +952,11 @@ - (BOOL)shouldUpdateRenderDataForViewport - (OATargetPoint *)getTargetPoint:(id)obj touchLocation:(CLLocation *)touchLocation { - if (![obj isKindOfClass:AisObject.class] || !((AisObject *)obj).hasPosition) + if (![obj isKindOfClass:OASAisObject.class] || !((OASAisObject *)obj).position) return nil; - AisObject *object = obj; - CLLocation *location = object.location; + OASAisObject *object = obj; + CLLocation *location = OAAisObjectLocation(object); if (!location) return nil; @@ -917,7 +965,8 @@ - (OATargetPoint *)getTargetPoint:(id)obj touchLocation:(CLLocation *)touchLocat targetPoint.targetObj = object; targetPoint.title = OAAisObjectTitle(object); targetPoint.titleSecond = nil; - targetPoint.titleAddress = object.navStatusString.length > 0 ? object.navStatusString : nil; + NSString *navStatus = [object getNavStatusString]; + targetPoint.titleAddress = navStatus.length > 0 ? navStatus : nil; targetPoint.shouldFetchAddress = NO; targetPoint.location = location.coordinate; @@ -940,17 +989,17 @@ - (BOOL)isSecondaryProvider - (CLLocation *)getObjectLocation:(id)obj { - if (![obj isKindOfClass:AisObject.class] || !((AisObject *)obj).hasPosition) + if (![obj isKindOfClass:OASAisObject.class] || !((OASAisObject *)obj).position) return nil; - AisObject *object = obj; - return object.location; + OASAisObject *object = obj; + return OAAisObjectLocation(object); } - (OAPointDescription *)getObjectName:(id)obj { - if (![obj isKindOfClass:AisObject.class]) + if (![obj isKindOfClass:OASAisObject.class]) return nil; - AisObject *object = obj; + OASAisObject *object = obj; return [[OAPointDescription alloc] initWithType:POINT_TYPE_LOCATION typeName:OALocalizedString(@"ais_type_object") name:OAAisObjectTitle(object)]; } @@ -985,10 +1034,10 @@ - (void)collectObjectsFromPoint:(MapSelectionResult *)result unknownLocation:(BO if (touchPolygon31.isEmpty()) return; - NSArray *objects = [[self plugin] getAisObjects]; - for (AisObject *object in objects) + NSArray *objects = [[self plugin] getAisObjects]; + for (OASAisObject *object in objects) { - CLLocation *location = object.location; + CLLocation *location = OAAisObjectLocation(object); if (!location) continue; @@ -1001,26 +1050,23 @@ - (void)collectObjectsFromPoint:(MapSelectionResult *)result unknownLocation:(BO } } -- (NSString *)objectTypeName:(AisObjType)type +- (NSString *)objectTypeName:(OASAisObjType *)type { - switch (type) - { - case AisObjTypeVessel: return OALocalizedString(@"ais_type_vessel"); - case AisObjTypeVesselSport: return OALocalizedString(@"ais_type_sport_vessel"); - case AisObjTypeVesselFast: return OALocalizedString(@"ais_type_high_speed_vessel"); - case AisObjTypeVesselPassenger: return OALocalizedString(@"ais_type_passenger_vessel"); - case AisObjTypeVesselFreight: return OALocalizedString(@"ais_type_cargo_tanker"); - case AisObjTypeVesselCommercial: return OALocalizedString(@"ais_type_commercial_vessel"); - case AisObjTypeVesselAuthorities: return OALocalizedString(@"ais_type_authorities_vessel"); - case AisObjTypeVesselSar: return OALocalizedString(@"ais_type_sar_vessel"); - case AisObjTypeLandStation: return OALocalizedString(@"ais_type_base_station"); - case AisObjTypeAirplane: return OALocalizedString(@"ais_type_sar_aircraft"); - case AisObjTypeSart: return OALocalizedString(@"ais_type_sart"); - case AisObjTypeAton: return OALocalizedString(@"ais_type_aid_to_navigation"); - case AisObjTypeAtonVirtual: return OALocalizedString(@"ais_type_virtual_aid_to_navigation"); - case AisObjTypeVesselOther: return OALocalizedString(@"ais_type_other_vessel"); - default: return OALocalizedString(@"ais_type_object"); - } + if (OAAisTypeEquals(type, OASAisObjType.aisVessel)) return OALocalizedString(@"ais_type_vessel"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselSport)) return OALocalizedString(@"ais_type_sport_vessel"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselFast)) return OALocalizedString(@"ais_type_high_speed_vessel"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselPassenger)) return OALocalizedString(@"ais_type_passenger_vessel"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselFreight)) return OALocalizedString(@"ais_type_cargo_tanker"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselCommercial)) return OALocalizedString(@"ais_type_commercial_vessel"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselAuthorities)) return OALocalizedString(@"ais_type_authorities_vessel"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselSar)) return OALocalizedString(@"ais_type_sar_vessel"); + if (OAAisTypeEquals(type, OASAisObjType.aisLandstation)) return OALocalizedString(@"ais_type_base_station"); + if (OAAisTypeEquals(type, OASAisObjType.aisAirplane)) return OALocalizedString(@"ais_type_sar_aircraft"); + if (OAAisTypeEquals(type, OASAisObjType.aisSart)) return OALocalizedString(@"ais_type_sart"); + if (OAAisTypeEquals(type, OASAisObjType.aisAton)) return OALocalizedString(@"ais_type_aid_to_navigation"); + if (OAAisTypeEquals(type, OASAisObjType.aisAtonVirtual)) return OALocalizedString(@"ais_type_virtual_aid_to_navigation"); + if (OAAisTypeEquals(type, OASAisObjType.aisVesselOther)) return OALocalizedString(@"ais_type_other_vessel"); + return OALocalizedString(@"ais_type_object"); } @end diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.h b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.h index ace3788c96..499b63ea02 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.h +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.h @@ -7,13 +7,12 @@ // #import - -@class AisObject; +#import "OsmAndSharedWrapper.h" @interface OAAisTrackerLayerBridge : NSObject + (void)reloadAisObjects; -+ (void)onAisObjectReceived:(AisObject *)object; -+ (void)onAisObjectRemoved:(AisObject *)object; ++ (void)onAisObjectReceived:(OASAisObject *)object; ++ (void)onAisObjectRemoved:(OASAisObject *)object; @end diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.mm index 1e0edc17da..1762b83e8d 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.mm @@ -26,12 +26,12 @@ + (void)reloadAisObjects [[self aisTrackerLayer] reloadAisObjects]; } -+ (void)onAisObjectReceived:(AisObject *)object ++ (void)onAisObjectReceived:(OASAisObject *)object { [[self aisTrackerLayer] onAisObjectReceived:object]; } -+ (void)onAisObjectRemoved:(AisObject *)object ++ (void)onAisObjectRemoved:(OASAisObject *)object { [[self aisTrackerLayer] onAisObjectRemoved:object]; } From 24b8d3a714374dee2d43117e098d7c957d9c9003 Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Tue, 16 Jun 2026 09:39:19 +0300 Subject: [PATCH 13/18] add AisObjectHelper --- OsmAnd.xcodeproj/project.pbxproj | 18 ++++++++-------- .../AisTrackerPlugin/AisDataManager.swift | 10 ++++----- .../Plugins/AisTrackerPlugin/AisLogger.swift | 2 +- .../AisTrackerPlugin/AisMessageDecoder.swift | 4 ++-- ...{AisObject.swift => AisObjectHelper.swift} | 2 +- .../AisSimulationProvider.swift | 7 +++++-- .../AisTrackerPlugin/AisTrackerHelper.swift | 20 +++++++++--------- .../AisTrackerPlugin/AisTrackerPlugin.swift | 21 +++++++++---------- .../AisTrackerPlugin/AisTrackerProduct.swift | 8 ++++++- 9 files changed, 50 insertions(+), 42 deletions(-) rename Sources/Plugins/AisTrackerPlugin/{AisObject.swift => AisObjectHelper.swift} (99%) diff --git a/OsmAnd.xcodeproj/project.pbxproj b/OsmAnd.xcodeproj/project.pbxproj index 196b3b0eba..b788d753b3 100644 --- a/OsmAnd.xcodeproj/project.pbxproj +++ b/OsmAnd.xcodeproj/project.pbxproj @@ -1364,14 +1364,14 @@ 9F4844A72FD0000100484401 /* AisTrackerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A32FD0000100484401 /* AisTrackerPlugin.swift */; }; 9F4844A82FD0000100484401 /* AisTrackerSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */; }; 9F4844AA2FD0000100484401 /* AisSimulationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */; }; - 9F4844D12FD0000100484401 /* AisTrackerProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844D02FD0000100484401 /* AisTrackerProduct.swift */; }; - 9F4844AC2FD0000100484401 /* AisObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AB2FD0000100484401 /* AisObject.swift */; }; + 9F4844AC2FD0000100484401 /* AisObjectHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AB2FD0000100484401 /* AisObjectHelper.swift */; }; 9F4844AE2FD0000100484401 /* AisDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AD2FD0000100484401 /* AisDataManager.swift */; }; 9F4844B02FD0000100484401 /* AisMessageDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */; }; 9F4844B32FD0000100484401 /* OAAisTrackerLayer.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */; }; 9F4844B52FD0000100484401 /* AisTrackerHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */; }; 9F4844B82FD0000100484401 /* OAAisObjectViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */; }; 9F4844C22FD0000100484401 /* OAAisTrackerLayerBridge.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844C12FD0000100484401 /* OAAisTrackerLayerBridge.mm */; }; + 9F4844D12FD0000100484401 /* AisTrackerProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844D02FD0000100484401 /* AisTrackerProduct.swift */; }; B0D6100B2E010FD6F3088B80 /* OATrackMenuHeaderView.mm in Sources */ = {isa = PBXBuildFile; fileRef = B0D61823DCC461FF03FE8103 /* OATrackMenuHeaderView.mm */; }; B0D610BD70EF0179933FD55C /* OAAddWaypointViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = B0D618034074DE575CDAACDD /* OAAddWaypointViewController.mm */; }; B0D610C833B6EA61B1C74268 /* OADeleteWaypointsGroupBottomSheetViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = B0D61BD47F1716F617480A05 /* OADeleteWaypointsGroupBottomSheetViewController.mm */; }; @@ -1641,13 +1641,13 @@ CE8A82A92FCFE11F00EADFD8 /* MapVariantReplacementManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8A82A82FCFE11F00EADFD8 /* MapVariantReplacementManager.swift */; }; D1A0B0012F50000100A0B001 /* OpeningHoursParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0AFFE2F50000100A0B001 /* OpeningHoursParserTest.swift */; }; D1A0B0032F50000100A0B001 /* OpeningHoursParserTestSupport.mm in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0002F50000100A0B001 /* OpeningHoursParserTestSupport.mm */; }; + D1A0B0112F50001100A0B001 /* URLExtractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0102F50001100A0B001 /* URLExtractionTests.swift */; }; + D1A0B0122F50001100A0B001 /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3216E9522BA097BD0087D0EF /* StringExtensions.swift */; }; D71B9A8C2FC95D8500FBB0F3 /* OrganizeTracksByViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71B9A8B2FC95D8500FBB0F3 /* OrganizeTracksByViewController.swift */; }; D71B9A8E2FC96D2D00FBB0F3 /* OrganizeByTypeExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71B9A8D2FC96D2D00FBB0F3 /* OrganizeByTypeExtension.swift */; }; D71B9A902FC99D5D00FBB0F3 /* OrganizeByTypeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71B9A8F2FC99D5D00FBB0F3 /* OrganizeByTypeCell.swift */; }; D7B76D0D2FD16070004EE3E9 /* OrganizeByStepSizeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B76D0C2FD16070004EE3E9 /* OrganizeByStepSizeViewController.swift */; }; D7BF04782FD2DB4400BABB31 /* TracksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7BF04772FD2DB4400BABB31 /* TracksViewController.swift */; }; - D1A0B0112F50001100A0B001 /* URLExtractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0102F50001100A0B001 /* URLExtractionTests.swift */; }; - D1A0B0122F50001100A0B001 /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3216E9522BA097BD0087D0EF /* StringExtensions.swift */; }; DA0132D42A1E0AB500920C14 /* WidgetsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0132D32A1E0AB500920C14 /* WidgetsListViewController.swift */; }; DA0132DD2A1E4A6300920C14 /* ic_custom20_screen_side_right@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = DA0132D52A1E4A6100920C14 /* ic_custom20_screen_side_right@2x.png */; }; DA0132DE2A1E4A6300920C14 /* ic_custom20_screen_side_top@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = DA0132D62A1E4A6100920C14 /* ic_custom20_screen_side_top@2x.png */; }; @@ -5127,8 +5127,7 @@ 9F4844A32FD0000100484401 /* AisTrackerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerPlugin.swift; sourceTree = ""; }; 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerSettingsViewController.swift; sourceTree = ""; }; 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisSimulationProvider.swift; sourceTree = ""; }; - 9F4844D02FD0000100484401 /* AisTrackerProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerProduct.swift; sourceTree = ""; }; - 9F4844AB2FD0000100484401 /* AisObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisObject.swift; sourceTree = ""; }; + 9F4844AB2FD0000100484401 /* AisObjectHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisObjectHelper.swift; sourceTree = ""; }; 9F4844AD2FD0000100484401 /* AisDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisDataManager.swift; sourceTree = ""; }; 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisMessageDecoder.swift; sourceTree = ""; }; 9F4844B12FD0000100484401 /* OAAisTrackerLayer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OAAisTrackerLayer.h; sourceTree = ""; }; @@ -5138,6 +5137,7 @@ 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OAAisObjectViewController.mm; sourceTree = ""; }; 9F4844C02FD0000100484401 /* OAAisTrackerLayerBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OAAisTrackerLayerBridge.h; sourceTree = ""; }; 9F4844C12FD0000100484401 /* OAAisTrackerLayerBridge.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OAAisTrackerLayerBridge.mm; sourceTree = ""; }; + 9F4844D02FD0000100484401 /* AisTrackerProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerProduct.swift; sourceTree = ""; }; ADA2D9C11AEC31EA6BB2AF2C /* Pods-OsmAnd Maps.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OsmAnd Maps.release.xcconfig"; path = "Target Support Files/Pods-OsmAnd Maps/Pods-OsmAnd Maps.release.xcconfig"; sourceTree = ""; }; B0D610268394E6C3CF0F3C8C /* OAEditWaypointsGroupBottomSheetViewController.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = OAEditWaypointsGroupBottomSheetViewController.mm; sourceTree = ""; }; B0D61037ED3E628F3BB6C1AA /* OASelectionCollapsableCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OASelectionCollapsableCell.h; sourceTree = ""; }; @@ -5598,12 +5598,12 @@ D1A0AFFE2F50000100A0B001 /* OpeningHoursParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpeningHoursParserTest.swift; sourceTree = ""; }; D1A0AFFF2F50000100A0B001 /* OpeningHoursParserTestSupport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OpeningHoursParserTestSupport.h; sourceTree = ""; }; D1A0B0002F50000100A0B001 /* OpeningHoursParserTestSupport.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OpeningHoursParserTestSupport.mm; sourceTree = ""; }; + D1A0B0102F50001100A0B001 /* URLExtractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtractionTests.swift; sourceTree = ""; }; D71B9A8B2FC95D8500FBB0F3 /* OrganizeTracksByViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizeTracksByViewController.swift; sourceTree = ""; }; D71B9A8D2FC96D2D00FBB0F3 /* OrganizeByTypeExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizeByTypeExtension.swift; sourceTree = ""; }; D71B9A8F2FC99D5D00FBB0F3 /* OrganizeByTypeCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizeByTypeCell.swift; sourceTree = ""; }; D7B76D0C2FD16070004EE3E9 /* OrganizeByStepSizeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganizeByStepSizeViewController.swift; sourceTree = ""; }; D7BF04772FD2DB4400BABB31 /* TracksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracksViewController.swift; sourceTree = ""; }; - D1A0B0102F50001100A0B001 /* URLExtractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtractionTests.swift; sourceTree = ""; }; DA0132D32A1E0AB500920C14 /* WidgetsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetsListViewController.swift; sourceTree = ""; }; DA0132D52A1E4A6100920C14 /* ic_custom20_screen_side_right@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "ic_custom20_screen_side_right@2x.png"; path = "Resources/Icons/ic_custom20_screen_side_right@2x.png"; sourceTree = ""; }; DA0132D62A1E4A6100920C14 /* ic_custom20_screen_side_top@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "ic_custom20_screen_side_top@2x.png"; path = "Resources/Icons/ic_custom20_screen_side_top@2x.png"; sourceTree = ""; }; @@ -10423,7 +10423,7 @@ isa = PBXGroup; children = ( FA4EDBF52FDAC090003B0AFC /* AisLogger.swift */, - 9F4844AB2FD0000100484401 /* AisObject.swift */, + 9F4844AB2FD0000100484401 /* AisObjectHelper.swift */, 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */, 9F4844AD2FD0000100484401 /* AisDataManager.swift */, 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */, @@ -18041,7 +18041,7 @@ FA1D6DAE2DCE04710080E374 /* VehicleMetricsPlugin.swift in Sources */, 9F4844A72FD0000100484401 /* AisTrackerPlugin.swift in Sources */, 9F4844AA2FD0000100484401 /* AisSimulationProvider.swift in Sources */, - 9F4844AC2FD0000100484401 /* AisObject.swift in Sources */, + 9F4844AC2FD0000100484401 /* AisObjectHelper.swift in Sources */, 9F4844AE2FD0000100484401 /* AisDataManager.swift in Sources */, 9F4844B02FD0000100484401 /* AisMessageDecoder.swift in Sources */, 9F4844B32FD0000100484401 /* OAAisTrackerLayer.mm in Sources */, diff --git a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift index 419bc05cf6..3205c4e4c0 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift @@ -16,15 +16,15 @@ extension Notification.Name { final class AisDataManager: NSObject { private static let objectLimit = 200 - private weak var plugin: AisTrackerPlugin? - - private var objectsByMmsi: [Int: AisObject] = [:] - private var cleanupTimer: Timer? - var objects: [AisObject] { Array(objectsByMmsi.values) } + private var objectsByMmsi: [Int: AisObject] = [:] + private var cleanupTimer: Timer? + + private weak var plugin: AisTrackerPlugin? + init(plugin: AisTrackerPlugin) { self.plugin = plugin super.init() diff --git a/Sources/Plugins/AisTrackerPlugin/AisLogger.swift b/Sources/Plugins/AisTrackerPlugin/AisLogger.swift index 32633b4c6b..42454f0d38 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisLogger.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisLogger.swift @@ -28,6 +28,6 @@ final class AisLogger: NSObject { func log(_ message: String) { guard isEnabled else { return } - print("[AIS] \(message)") + debugPrint("[AIS] \(message)") } } diff --git a/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift b/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift index 07dd0f8d2c..408d693727 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift @@ -10,7 +10,7 @@ import OsmAndShared final class AisMessageDecoder { private let dataListener = AisSharedDataListener() - private lazy var listener = OsmAndShared.AisMessageListener(dataListener: dataListener) + private lazy var listener = AisMessageListener(dataListener: dataListener) func decode(sentence: String) -> AisObject? { dataListener.lastObject = nil @@ -19,7 +19,7 @@ final class AisMessageDecoder { } } -private final class AisSharedDataListener: NSObject, OsmAndShared.AisDataListener { +private final class AisSharedDataListener: NSObject, AisDataListener { var lastObject: AisObject? func onAisObjectReceived(ais: AisObject) { diff --git a/Sources/Plugins/AisTrackerPlugin/AisObject.swift b/Sources/Plugins/AisTrackerPlugin/AisObjectHelper.swift similarity index 99% rename from Sources/Plugins/AisTrackerPlugin/AisObject.swift rename to Sources/Plugins/AisTrackerPlugin/AisObjectHelper.swift index b12bf435e1..6037c8ae83 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisObject.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisObjectHelper.swift @@ -1,5 +1,5 @@ // -// AisObject.swift +// AisObjectHelper.swift // OsmAnd // // Created by Oleksandr Panchenko on 11.06.2026. diff --git a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift index afee3bf46b..d9531d4c4a 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift @@ -10,12 +10,14 @@ import CoreLocation import OsmAndShared final class AisMessageSimulationListener { - private weak var plugin: AisTrackerPlugin? private let fileURL: URL private let latency: TimeInterval private let queue = DispatchQueue(label: "net.osmand.ais.simulation.listener") private let lock = NSLock() + private var cancelled = false + + private weak var plugin: AisTrackerPlugin? private var isCancelled: Bool { lock.lock() @@ -110,8 +112,9 @@ final class AisMessageSimulationListener { final class AisSimulationProvider: NSObject { private static let simulatedLatency: TimeInterval = 0.1 - private weak var plugin: AisTrackerPlugin? private var listener: AisMessageSimulationListener? + + private weak var plugin: AisTrackerPlugin? init(plugin: AisTrackerPlugin) { self.plugin = plugin diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift index a103c36092..46c517f6f4 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift @@ -9,9 +9,18 @@ import CoreLocation import OsmAndShared +enum AisTrackerHelper { + static func getCpa(_ ownLocation: CLLocation, _ otherLocation: CLLocation, result: AisCpa) { + result.reset() + AisTrackerMath.shared.getCpa(ownLocation: ownLocation.aisLocation, + otherLocation: otherLocation.aisLocation, + result: result) + } +} + private extension CLLocation { var aisLocation: AisLocation { - OsmAndShared.AisLocation(latitude: coordinate.latitude, + AisLocation(latitude: coordinate.latitude, longitude: coordinate.longitude, speed: speed >= 0 ? Float(speed) : .nan, bearing: course >= 0 ? Float(course) : .nan, @@ -19,12 +28,3 @@ private extension CLLocation { hasBearing: course >= 0) } } - -enum AisTrackerHelper { - static func getCpa(_ ownLocation: CLLocation, _ otherLocation: CLLocation, result: AisCpa) { - result.reset() - OsmAndShared.AisTrackerMath.shared.getCpa(ownLocation: ownLocation.aisLocation, - otherLocation: otherLocation.aisLocation, - result: result) - } -} diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift index 87ed2f174f..fc94cd8f63 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift @@ -63,9 +63,14 @@ final class AisTrackerPlugin: OAPlugin { private let decoder = AisMessageDecoder() private let aisDecoderQueue = DispatchQueue(label: "com.app.ais.decoder", qos: .userInitiated) - private var networkListener: AisMessageListener? + private var networkListener: AisMessageListener? private var applicationModeObserver: OAAutoObserverProxy? + private var simulationSentences = 0 + private var simulationDecoded = 0 + private var simulationObjects = 0 + private var simulationReceivedObjects = 0 + private var simulationRenderedObjects = 0 private lazy var simulationProvider = AisSimulationProvider(plugin: self) private lazy var aisDataManager = AisDataManager(plugin: self) @@ -76,12 +81,6 @@ final class AisTrackerPlugin: OAPlugin { private(set) var simulationFileName: String? private(set) var simulationStatusText: String? private(set) var lastMessageReceived = Date.distantPast - - private var simulationSentences = 0 - private var simulationDecoded = 0 - private var simulationObjects = 0 - private var simulationReceivedObjects = 0 - private var simulationRenderedObjects = 0 override init() { protocolPref = OAAppSettings.sharedManager().registerIntPreference(Self.protocolPrefId, defValue: Int32(AisNmeaProtocol.udp.rawValue)) @@ -244,12 +243,12 @@ final class AisTrackerPlugin: OAPlugin { case .udp: let port = max(1, Int(udpPortPref.get())) AisObjectHelper.debugLog("[AisTrackerPlugin] start shared AIS UDP port=\(port)") - networkListener = OsmAndShared.AisMessageListener(dataListener: networkDataListener, udpPort: Int32(port)) + networkListener = AisMessageListener(dataListener: networkDataListener, udpPort: Int32(port)) case .tcp: let host = hostPref.get() let port = max(1, Int(tcpPortPref.get())) AisObjectHelper.debugLog("[AisTrackerPlugin] start shared AIS TCP host=\(host) port=\(port)") - networkListener = OsmAndShared.AisMessageListener(dataListener: networkDataListener, serverIp: host, serverPort: Int32(port)) + networkListener = AisMessageListener(dataListener: networkDataListener, serverIp: host, serverPort: Int32(port)) } updateConnectionState(.connected) } @@ -331,7 +330,7 @@ final class AisTrackerPlugin: OAPlugin { let warningTime = cpaWarningTimeInMinutes() let warningDistance = cpaWarningDistanceInNauticalMiles() guard object.isMovable(), - object.objectClass != OsmAndShared.AisObjType.aisAirplane, + object.objectClass != AisObjType.aisAirplane, warningTime > 0, object.sog > 0, let ownPosition = ownPosition(), @@ -470,7 +469,7 @@ final class AisTrackerPlugin: OAPlugin { } } -private final class AisNetworkDataListener: NSObject, OsmAndShared.AisDataListener { +private final class AisNetworkDataListener: NSObject, AisDataListener { private weak var plugin: AisTrackerPlugin? init(plugin: AisTrackerPlugin) { diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerProduct.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerProduct.swift index 0e3e4075f7..5bf5d56382 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerProduct.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerProduct.swift @@ -1,4 +1,10 @@ -import Foundation +// +// AisTrackerProduct.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// @objcMembers final class AisTrackerProduct: OAProduct { From 3c9d19d7618895f23095c0debaa1405ed211011d Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Wed, 17 Jun 2026 10:00:22 +0300 Subject: [PATCH 14/18] fix addDimensionRows --- Resources/Localizations/en.lproj/Localizable.strings | 2 +- Sources/Controllers/Map/Helpers/OAMapSelectionHelper.mm | 1 - Sources/Controllers/Map/Layers/OAMapLayers.h | 4 +--- .../Plugins/AisTrackerPlugin/OAAisObjectViewController.mm | 6 ++++-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index 298217212e..fafbb90842 100644 --- a/Resources/Localizations/en.lproj/Localizable.strings +++ b/Resources/Localizations/en.lproj/Localizable.strings @@ -2490,7 +2490,7 @@ "ais_navigation_status" = "Navigation status"; "ais_maneuver" = "Maneuver"; "ais_aid_type" = "Aid type"; -"ais_dimensions" = "Dimensions"; +"ais_dimension" = "Dimension"; "ais_antenna" = "AIS antenna"; "ais_antenna_offsets_format" = "bow %ld, stern %ld, port %ld, starboard %ld m"; "ais_draught" = "Draught"; diff --git a/Sources/Controllers/Map/Helpers/OAMapSelectionHelper.mm b/Sources/Controllers/Map/Helpers/OAMapSelectionHelper.mm index f30877a1bf..61754ecf3c 100644 --- a/Sources/Controllers/Map/Helpers/OAMapSelectionHelper.mm +++ b/Sources/Controllers/Map/Helpers/OAMapSelectionHelper.mm @@ -730,7 +730,6 @@ - (BOOL)showContextMenuForSearchResult:(OAPOI *)poi filename:(NSString *)filenam MapSelectionResult *result = [[MapSelectionResult alloc] initWithPoint:CGPointMake(0, 0)]; CLLocation *latLon = [poi getLocation]; result.objectLatLon = latLon; - OATravelGpx *travelGpx = [[OATravelGpx alloc] initWithAmenity:poi]; if (filename) diff --git a/Sources/Controllers/Map/Layers/OAMapLayers.h b/Sources/Controllers/Map/Layers/OAMapLayers.h index 3516046ee8..6fbd1187d1 100644 --- a/Sources/Controllers/Map/Layers/OAMapLayers.h +++ b/Sources/Controllers/Map/Layers/OAMapLayers.h @@ -35,9 +35,7 @@ #import "OANetworkRouteSelectionLayer.h" #import "OATravelSelectionLayer.h" -@class OAAisTrackerLayer; - -@class OAMapViewController; +@class OAMapViewController, OAAisTrackerLayer; @interface OAMapLayers : NSObject diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm index c0b9cea88c..7b43a61d5a 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm @@ -279,10 +279,12 @@ - (NSInteger)addCourseRows:(NSMutableArray *)rows - (NSInteger)addDimensionRows:(NSMutableArray *)rows order:(NSInteger)order { + const int32_t invalidDimension = OASAisObjectConstants.shared.INVALID_DIMENSION; NSInteger length = _object.dimensionToBow + _object.dimensionToStern; NSInteger width = _object.dimensionToPort + _object.dimensionToStarboard; - if (length > 0 && width > 0) - [self addRow:rows key:@"dimensions" prefix:OALocalizedString(@"ais_dimensions") text:[NSString stringWithFormat:@"%ldm x %ldm", (long)length, (long)width] order:order++]; + if ((_object.dimensionToBow != invalidDimension || _object.dimensionToStern != invalidDimension) + && (_object.dimensionToPort != invalidDimension || _object.dimensionToStarboard != invalidDimension)) + [self addRow:rows key:@"dimension" prefix:OALocalizedString(@"ais_dimension") text:[NSString stringWithFormat:@"%ldm x %ldm", (long)length, (long)width] order:order++]; return order; } From 69579ed91a6e6c59c740adb64a6cdc1c7ac2d3bb Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Wed, 17 Jun 2026 10:54:06 +0300 Subject: [PATCH 15/18] clear updateSimulationRenderedObjects --- .../OAOsmandDevelopmentViewController.mm | 19 +------ .../AisTrackerPlugin/AisDataManager.swift | 4 -- .../AisSimulationProvider.swift | 11 ---- .../AisTrackerPlugin/AisTrackerPlugin.swift | 57 ------------------- .../AisTrackerPlugin/OAAisTrackerLayer.mm | 3 - 5 files changed, 2 insertions(+), 92 deletions(-) diff --git a/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm b/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm index 25ba932d79..469252f8ed 100644 --- a/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm +++ b/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm @@ -72,7 +72,6 @@ - (void)commonInit - (void)registerNotifications { [self addNotification:OAIAPProductPurchasedNotification selector:@selector(productPurchased:)]; - [self addNotification:@"OAAisSimulationStatusChanged" selector:@selector(onAisSimulationStatusChanged:)]; } #pragma mark - Base UI @@ -125,18 +124,11 @@ - (void)generateData OATableSectionData *aisSection = [OATableSectionData sectionData]; aisSection.headerText = OALocalizedString(@"plugin_ais_tracker_name"); - NSString *simulationDescription = aisPlugin.simulationFileName ?: @""; - if (aisPlugin.simulationStatusText.length > 0) - { - simulationDescription = simulationDescription.length > 0 - ? [NSString stringWithFormat:@"%@ • %@", simulationDescription, aisPlugin.simulationStatusText] - : aisPlugin.simulationStatusText; - } [aisSection addRowFromDictionary:@{ kCellTypeKey : [OAValueTableViewCell getCellIdentifier], kCellKeyKey : kAisTrackerSimulationKey, kCellTitleKey : OALocalizedString(@"ais_load_data"), - kCellDescrKey : simulationDescription, + kCellDescrKey : aisPlugin.simulationFileName ?: @"", @"actionBlock" : (^void(){ [weakSelf openAisSimulationFilePicker]; }) }]; @@ -393,12 +385,6 @@ - (void)onSimulateLocationInformationUpdated [self.tableView reloadData]; } -- (void)onAisSimulationStatusChanged:(NSNotification *)notification -{ - [self generateData]; - [self.tableView reloadData]; -} - #pragma mark - UIDocumentPickerDelegate - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls @@ -420,8 +406,7 @@ - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocum [self.tableView reloadData]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ UIView *toastView = self.view.window ?: OARootViewController.instance.view; - NSString *details = aisPlugin.simulationStatusText.length > 0 ? aisPlugin.simulationStatusText : url.lastPathComponent; - [OAUtilities showToast:@"AIS simulation" details:details duration:5 inView:toastView]; + [OAUtilities showToast:@"AIS simulation" details:url.lastPathComponent duration:5 inView:toastView]; }); } diff --git a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift index 3205c4e4c0..d5f50cb190 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift @@ -8,10 +8,6 @@ import OsmAndShared -extension Notification.Name { - static let aisSimulationStatusChanged = Notification.Name("OAAisSimulationStatusChanged") -} - @objcMembers final class AisDataManager: NSObject { private static let objectLimit = 200 diff --git a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift index d9531d4c4a..0408415643 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift @@ -93,17 +93,6 @@ final class AisMessageSimulationListener { private func postStatus(sentences: Int, decoded: Int, objects: Int, error: String?) { DispatchQueue.main.async { self.plugin?.updateSimulationStatus(sentences: sentences, decoded: decoded, objects: objects, error: error) - var userInfo: [String: Any] = [ - "sentences": sentences, - "decoded": decoded, - "objects": objects - ] - if let error { - userInfo["error"] = error - } - NotificationCenter.default.post(name: .aisSimulationStatusChanged, - object: self.plugin, - userInfo: userInfo) } } } diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift index fc94cd8f63..830efd716a 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift @@ -66,11 +66,6 @@ final class AisTrackerPlugin: OAPlugin { private var networkListener: AisMessageListener? private var applicationModeObserver: OAAutoObserverProxy? - private var simulationSentences = 0 - private var simulationDecoded = 0 - private var simulationObjects = 0 - private var simulationReceivedObjects = 0 - private var simulationRenderedObjects = 0 private lazy var simulationProvider = AisSimulationProvider(plugin: self) private lazy var aisDataManager = AisDataManager(plugin: self) @@ -79,7 +74,6 @@ final class AisTrackerPlugin: OAPlugin { private(set) var connectionState: AisNmeaConnectionState = .disconnected private(set) var fakeOwnLocation: CLLocation? private(set) var simulationFileName: String? - private(set) var simulationStatusText: String? private(set) var lastMessageReceived = Date.distantPast override init() { @@ -169,36 +163,16 @@ final class AisTrackerPlugin: OAPlugin { func startAisSimulation(_ fileURL: URL) { guard isEnabled() else { return } simulationFileName = fileURL.lastPathComponent - simulationSentences = 0 - simulationDecoded = 0 - simulationObjects = 0 - simulationReceivedObjects = 0 - simulationRenderedObjects = 0 - simulationStatusText = localizedString("shared_string_loading") AisObjectHelper.debugLog("simulation start file=\(fileURL.lastPathComponent)") simulationProvider.startAisSimulation(fileURL) } func updateSimulationStatus(sentences: Int, decoded: Int, objects: Int, error: String?) { if let error, !error.isEmpty { - simulationStatusText = error AisObjectHelper.debugLog("simulation status error=\(error)") } else { - simulationSentences = sentences - simulationDecoded = decoded - simulationObjects = objects - updateSimulationStatusText() AisObjectHelper.debugLog("simulation stats sentences=\(sentences) decoded=\(decoded) objects=\(objects)") } - postSimulationStatusChanged() - } - - func updateSimulationRenderedObjects(_ count: Int) { - guard simulationFileName != nil else { return } - guard simulationRenderedObjects != count else { return } - simulationRenderedObjects = count - updateSimulationStatusText() - postSimulationStatusChanged() } func prepareAisSimulation() { @@ -221,12 +195,6 @@ final class AisTrackerPlugin: OAPlugin { AisObjectHelper.debugLog("simulation clear") fakeOwnLocation = nil simulationFileName = nil - simulationStatusText = nil - simulationSentences = 0 - simulationDecoded = 0 - simulationObjects = 0 - simulationReceivedObjects = 0 - simulationRenderedObjects = 0 aisDataManager.cleanupResources() } @@ -299,14 +267,6 @@ final class AisTrackerPlugin: OAPlugin { func onAisObjectReceived(_ object: AisObject) { lastMessageReceived = AisObjectHelper.lastUpdateDate(object) - if simulationFileName != nil { - let receivedObjects = getAisObjects().filter { $0.position != nil }.count - if simulationReceivedObjects != receivedObjects { - simulationReceivedObjects = receivedObjects - updateSimulationStatusText() - postSimulationStatusChanged() - } - } AisObjectHelper.debugLog("plugin received withPosition=\(getAisObjects().filter { $0.position != nil }.count) \(AisObjectHelper.debugSummary(object))") DispatchQueue.main.async { OAAisTrackerLayerBridge.onAisObjectReceived(object) @@ -437,23 +397,6 @@ final class AisTrackerPlugin: OAPlugin { NotificationCenter.default.post(name: .aisNmeaConnectionStateChanged, object: self) } - private func updateSimulationStatusText() { - var parts = [ - "sentences \(simulationSentences)", - "decoded \(simulationDecoded)", - "objects \(simulationObjects)" - ] - if simulationReceivedObjects > 0 || simulationRenderedObjects > 0 { - parts.append("received \(simulationReceivedObjects)") - parts.append("rendered \(simulationRenderedObjects)") - } - simulationStatusText = parts.joined(separator: ", ") - } - - private func postSimulationStatusChanged() { - NotificationCenter.default.post(name: .aisSimulationStatusChanged, object: self) - } - private static func bearing(from start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D) -> Double { let lat1 = start.latitude * .pi / 180 let lat2 = end.latitude * .pi / 180 diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm index e1d8106a41..03db65b530 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm @@ -847,7 +847,6 @@ - (void)reloadObjectsSync [_objectDrawables removeObjectForKey:key]; } } - [plugin updateSimulationRenderedObjects:_objectDrawables.count]; } - (void)onAisObjectReceived:(OASAisObject *)object @@ -876,7 +875,6 @@ - (void)onAisObjectRemoved:(OASAisObject *)object [drawable clearAisRenderDataFromMarkersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; [_objectDrawables removeObjectForKey:key]; } - [[self plugin] updateSimulationRenderedObjects:_objectDrawables.count]; }]; } @@ -903,7 +901,6 @@ - (void)updateAisObjectSync:(OASAisObject *)object int linesCount = _vectorLinesCollection ? _vectorLinesCollection->getLinesCount() : 0; [[AisLogger shared] log:[NSString stringWithFormat:@"update recreated=%@ drawables=%lu lines=%d %@", recreated ? @"yes" : @"no", (unsigned long)_objectDrawables.count, linesCount, OAAisDebugSummary(object)]]; - [[self plugin] updateSimulationRenderedObjects:_objectDrawables.count]; } - (void)updateRenderData From 16bb9fe883269ac38096d1cfd66fbdfdefa1cbf3 Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Thu, 18 Jun 2026 11:41:35 +0300 Subject: [PATCH 16/18] update AisLogger --- .../AisTrackerPlugin/AisDataManager.swift | 16 +++-- .../AisSimulationProvider.swift | 2 +- .../AisTrackerPlugin/AisTrackerPlugin.swift | 69 +++++++++++-------- .../OAAisObjectViewController.mm | 2 +- .../AisTrackerPlugin/OAAisTrackerLayer.mm | 13 ++-- 5 files changed, 63 insertions(+), 39 deletions(-) diff --git a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift index d5f50cb190..b4d8594038 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift @@ -28,9 +28,11 @@ final class AisDataManager: NSObject { func startUpdates() { stopUpdates() - cleanupTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + let timer = Timer(timeInterval: 30, repeats: true) { [weak self] _ in self?.removeLostObjects() } + RunLoop.main.add(timer, forMode: .common) + cleanupTimer = timer } func stopUpdates() { @@ -61,7 +63,9 @@ final class AisDataManager: NSObject { removeOldestObject() } guard let storedObject = objectsByMmsi[Int(object.mmsi)], storedObject === object else { return } - AisObjectHelper.debugLog("[AisDataManager] data \(event) total=\(objectsByMmsi.count) \(AisObjectHelper.debugSummary(object))") + if AisLogger.shared.isEnabled { + AisObjectHelper.debugLog("[AisDataManager] data \(event) total=\(objectsByMmsi.count) \(AisObjectHelper.debugSummary(object))") + } plugin?.onAisObjectReceived(object) } @@ -71,7 +75,9 @@ final class AisDataManager: NSObject { let removed = objectsByMmsi.values.filter { $0.isLost(maxAgeInMin: Int32(maxAge)) } for object in removed { objectsByMmsi.removeValue(forKey: Int(object.mmsi)) - AisObjectHelper.debugLog("[AisDataManager] data remove-lost maxAge=\(maxAge)m total=\(objectsByMmsi.count) \(AisObjectHelper.debugSummary(object))") + if AisLogger.shared.isEnabled { + AisObjectHelper.debugLog("[AisDataManager] data remove-lost maxAge=\(maxAge)m total=\(objectsByMmsi.count) \(AisObjectHelper.debugSummary(object))") + } plugin.onAisObjectRemoved(object) } } @@ -79,7 +85,9 @@ final class AisDataManager: NSObject { private func removeOldestObject() { guard let oldest = objectsByMmsi.values.min(by: { $0.lastUpdate < $1.lastUpdate }) else { return } objectsByMmsi.removeValue(forKey: Int(oldest.mmsi)) - AisObjectHelper.debugLog("[AisDataManager] data remove-oldest limit=\(Self.objectLimit) total=\(objectsByMmsi.count) \(AisObjectHelper.debugSummary(oldest))") + if AisLogger.shared.isEnabled { + AisObjectHelper.debugLog("[AisDataManager] data remove-oldest limit=\(Self.objectLimit) total=\(objectsByMmsi.count) \(AisObjectHelper.debugSummary(oldest))") + } plugin?.onAisObjectRemoved(oldest) } } diff --git a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift index 0408415643..266872538a 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift @@ -59,7 +59,7 @@ final class AisMessageSimulationListener { return } DispatchQueue.main.async { [weak self] in - guard let plugin = self?.plugin, plugin.isEnabled() else { return } + guard let self, !self.isCancelled, let plugin = self.plugin, plugin.isEnabled() else { return } plugin.handleSimulatedNmeaSentence(sentence) } } diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift index 830efd716a..a492f183a0 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift @@ -61,20 +61,20 @@ final class AisTrackerPlugin: OAPlugin { let cpaWarningTimePref: OACommonInteger let cpaWarningDistancePref: OACommonDouble + private(set) var connectionState: AisNmeaConnectionState = .disconnected + private(set) var fakeOwnLocation: CLLocation? + private(set) var simulationFileName: String? + private(set) var lastMessageReceived = Date.distantPast + private let decoder = AisMessageDecoder() private let aisDecoderQueue = DispatchQueue(label: "com.app.ais.decoder", qos: .userInitiated) - + private var networkListener: AisMessageListener? private var applicationModeObserver: OAAutoObserverProxy? - + private lazy var simulationProvider = AisSimulationProvider(plugin: self) private lazy var aisDataManager = AisDataManager(plugin: self) private lazy var networkDataListener = AisNetworkDataListener(plugin: self) - - private(set) var connectionState: AisNmeaConnectionState = .disconnected - private(set) var fakeOwnLocation: CLLocation? - private(set) var simulationFileName: String? - private(set) var lastMessageReceived = Date.distantPast override init() { protocolPref = OAAppSettings.sharedManager().registerIntPreference(Self.protocolPrefId, defValue: Int32(AisNmeaProtocol.udp.rawValue)) @@ -92,8 +92,14 @@ final class AisTrackerPlugin: OAPlugin { andObserve: OsmAndApp.swiftInstance().applicationModeChangedObservable) } - deinit { - applicationModeObserver?.detach() + private static func bearing(from start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D) -> Double { + let lat1 = start.latitude * .pi / 180 + let lat2 = end.latitude * .pi / 180 + let deltaLon = (end.longitude - start.longitude) * .pi / 180 + let y = sin(deltaLon) * cos(lat2) + let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(deltaLon) + let degrees = atan2(y, x) * 180 / .pi + return fmod(degrees + 360, 360) } override func getId() -> String { @@ -103,7 +109,7 @@ final class AisTrackerPlugin: OAPlugin { override func getName() -> String { localizedString("plugin_ais_tracker_name") } - + override func getDescription() -> String { localizedString("plugin_ais_tracker_description") + "

" @@ -218,7 +224,6 @@ final class AisTrackerPlugin: OAPlugin { AisObjectHelper.debugLog("[AisTrackerPlugin] start shared AIS TCP host=\(host) port=\(port)") networkListener = AisMessageListener(dataListener: networkDataListener, serverIp: host, serverPort: Int32(port)) } - updateConnectionState(.connected) } func stopAisNetworkListener() { @@ -267,14 +272,18 @@ final class AisTrackerPlugin: OAPlugin { func onAisObjectReceived(_ object: AisObject) { lastMessageReceived = AisObjectHelper.lastUpdateDate(object) - AisObjectHelper.debugLog("plugin received withPosition=\(getAisObjects().filter { $0.position != nil }.count) \(AisObjectHelper.debugSummary(object))") + if AisLogger.shared.isEnabled { + AisObjectHelper.debugLog("plugin received withPosition=\(getAisObjects().filter { $0.position != nil }.count) \(AisObjectHelper.debugSummary(object))") + } DispatchQueue.main.async { OAAisTrackerLayerBridge.onAisObjectReceived(object) } } func onAisObjectRemoved(_ object: AisObject) { - AisObjectHelper.debugLog("plugin removed \(AisObjectHelper.debugSummary(object))") + if AisLogger.shared.isEnabled { + AisObjectHelper.debugLog("plugin removed \(AisObjectHelper.debugSummary(object))") + } DispatchQueue.main.async { OAAisTrackerLayerBridge.onAisObjectRemoved(object) } @@ -375,12 +384,6 @@ final class AisTrackerPlugin: OAPlugin { } } - fileprivate func onNetworkAisObjectReceived(_ object: AisObject) { - DispatchQueue.main.async { [weak self] in - self?.aisDataManager.onAisObjectReceived(object) - } - } - private func stopSharedNetworkListener(updateState: Bool) { if networkListener != nil { AisObjectHelper.debugLog("[AisTrackerPlugin] stop shared AIS listener") @@ -393,23 +396,33 @@ final class AisTrackerPlugin: OAPlugin { } private func updateConnectionState(_ state: AisNmeaConnectionState) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.updateConnectionState(state) + } + return + } connectionState = state NotificationCenter.default.post(name: .aisNmeaConnectionStateChanged, object: self) } - private static func bearing(from start: CLLocationCoordinate2D, to end: CLLocationCoordinate2D) -> Double { - let lat1 = start.latitude * .pi / 180 - let lat2 = end.latitude * .pi / 180 - let deltaLon = (end.longitude - start.longitude) * .pi / 180 - let y = sin(deltaLon) * cos(lat2) - let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(deltaLon) - let degrees = atan2(y, x) * 180 / .pi - return fmod(degrees + 360, 360) + fileprivate func onNetworkAisObjectReceived(_ object: AisObject) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if self.connectionState != .connected { + self.updateConnectionState(.connected) + } + self.aisDataManager.onAisObjectReceived(object) + } } - + @objc private func onApplicationModeChanged() { updateConnectionForCurrentProfile() } + + deinit { + applicationModeObserver?.detach() + } } private final class AisNetworkDataListener: NSObject, AisDataListener { diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm index 7b43a61d5a..9c6f551b94 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm @@ -189,7 +189,7 @@ - (void)buildInternal:(NSMutableArray *)rows [self addRow:rows key:@"imo" prefix:OALocalizedString(@"ais_imo") text:[NSString stringWithFormat:@"%ld", (long)_object.imo] order:order++]; if (_object.shipName.length > 0) [self addRow:rows key:@"ship_name" prefix:OALocalizedString(@"ais_ship_name") text:_object.shipName order:order++]; - if (_object.shipType != OASAisObjectConstants.shared.INVALID_DIMENSION && (OAAisHasMessageType(_object, 5) || OAAisHasMessageType(_object, 19) || OAAisHasMessageType(_object, 24))) + if (OAAisHasMessageType(_object, 5) || OAAisHasMessageType(_object, 19) || OAAisHasMessageType(_object, 24)) [self addRow:rows key:@"ship_type" prefix:OALocalizedString(@"ais_ship_type") text:[_object getShipTypeString] order:order++]; order = [self addCourseRows:rows order:order includeHeading:YES includeNavStatus:YES]; order = [self addDimensionRows:rows order:order]; diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm index 03db65b530..9a8b6167b7 100644 --- a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm @@ -853,7 +853,8 @@ - (void)onAisObjectReceived:(OASAisObject *)object { if (![self isVisible] || !object.position) return; - [[AisLogger shared] log:[NSString stringWithFormat:@"receive %@", OAAisDebugSummary(object)]]; + if ([AisLogger shared].isEnabled) + [[AisLogger shared] log:[NSString stringWithFormat:@"receive %@", OAAisDebugSummary(object)]]; [self addCollectionsToRenderer]; [self.mapViewController runWithRenderSync:^{ [self updateAisObjectSync:object]; @@ -868,8 +869,9 @@ - (void)onAisObjectRemoved:(OASAisObject *)object [self.mapViewController runWithRenderSync:^{ NSNumber *key = @(object.mmsi); AisObjectDrawable *drawable = _objectDrawables[key]; - [[AisLogger shared] log:[NSString stringWithFormat:@"remove hasDrawable=%@ drawables=%lu %@", - drawable ? @"yes" : @"no", (unsigned long)_objectDrawables.count, OAAisDebugSummary(object)]]; + if ([AisLogger shared].isEnabled) + [[AisLogger shared] log:[NSString stringWithFormat:@"remove hasDrawable=%@ drawables=%lu %@", + drawable ? @"yes" : @"no", (unsigned long)_objectDrawables.count, OAAisDebugSummary(object)]]; if (drawable) { [drawable clearAisRenderDataFromMarkersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; @@ -900,7 +902,8 @@ - (void)updateAisObjectSync:(OASAisObject *)object [drawable updateAisRenderDataWithMapView:self.mapView plugin:[self plugin]]; int linesCount = _vectorLinesCollection ? _vectorLinesCollection->getLinesCount() : 0; - [[AisLogger shared] log:[NSString stringWithFormat:@"update recreated=%@ drawables=%lu lines=%d %@", recreated ? @"yes" : @"no", (unsigned long)_objectDrawables.count, linesCount, OAAisDebugSummary(object)]]; + if ([AisLogger shared].isEnabled) + [[AisLogger shared] log:[NSString stringWithFormat:@"update recreated=%@ drawables=%lu lines=%d %@", recreated ? @"yes" : @"no", (unsigned long)_objectDrawables.count, linesCount, OAAisDebugSummary(object)]]; } - (void)updateRenderData @@ -909,7 +912,7 @@ - (void)updateRenderData return; AisTrackerPlugin *plugin = [self plugin]; - for (NSNumber *key in _objectDrawables) + for (NSNumber *key in [_objectDrawables.allKeys copy]) [_objectDrawables[key] updateAisRenderDataWithMapView:self.mapView plugin:plugin]; } From cf63985cca0183aa5ec55ae6f5cad8c405b2eb11 Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Thu, 18 Jun 2026 13:07:49 +0300 Subject: [PATCH 17/18] AisTrackerProduct upd order --- .../AisTrackerPlugin/AisTrackerProduct.swift | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerProduct.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerProduct.swift index 5bf5d56382..6cdc7805a4 100644 --- a/Sources/Plugins/AisTrackerPlugin/AisTrackerProduct.swift +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerProduct.swift @@ -8,22 +8,10 @@ @objcMembers final class AisTrackerProduct: OAProduct { - override init() { - super.init(identifier: kInAppId_Addon_Ais_Tracker) - } - override var free: Bool { true } - override func productIconName() -> String { - "ic_plugin_nautical" - } - - override func productScreenshotName() -> String { - "ais_map" - } - override var localizedTitle: String { localizedString("plugin_ais_tracker_name") } @@ -35,4 +23,16 @@ final class AisTrackerProduct: OAProduct { override var localizedDescriptionExt: String { localizedString("plugin_ais_tracker_description") + "\n\n" + localizedString("plugin_ais_tracker_disclaimer") } + + override init() { + super.init(identifier: kInAppId_Addon_Ais_Tracker) + } + + override func productIconName() -> String { + "ic_plugin_nautical" + } + + override func productScreenshotName() -> String { + "ais_map" + } } From e358030eccfab4ac95d96c3181ad1b54cf98b7b4 Mon Sep 17 00:00:00 2001 From: Aleksandr Panchenko Date: Fri, 19 Jun 2026 12:03:46 +0300 Subject: [PATCH 18/18] add AisObjectViewController --- OsmAnd.xcodeproj/project.pbxproj | 10 +- .../TargetMenu/OATargetInfoViewController.h | 1 + .../TargetMenu/OATargetMenuViewController.mm | 3 +- .../AisObjectViewController.swift | 289 +++++++++++++++ .../OAAisObjectViewController.h | 16 - .../OAAisObjectViewController.mm | 349 ------------------ Sources/Services/OALocationServices.h | 1 - Sources/Services/OALocationServices.mm | 19 - 8 files changed, 295 insertions(+), 393 deletions(-) create mode 100644 Sources/Plugins/AisTrackerPlugin/AisObjectViewController.swift delete mode 100644 Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h delete mode 100644 Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm diff --git a/OsmAnd.xcodeproj/project.pbxproj b/OsmAnd.xcodeproj/project.pbxproj index 65258af7fb..95f9582da3 100644 --- a/OsmAnd.xcodeproj/project.pbxproj +++ b/OsmAnd.xcodeproj/project.pbxproj @@ -1369,7 +1369,7 @@ 9F4844B02FD0000100484401 /* AisMessageDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */; }; 9F4844B32FD0000100484401 /* OAAisTrackerLayer.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */; }; 9F4844B52FD0000100484401 /* AisTrackerHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */; }; - 9F4844B82FD0000100484401 /* OAAisObjectViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */; }; + 9F4844B82FD0000100484401 /* AisObjectViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844B72FD0000100484401 /* AisObjectViewController.swift */; }; 9F4844C22FD0000100484401 /* OAAisTrackerLayerBridge.mm in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844C12FD0000100484401 /* OAAisTrackerLayerBridge.mm */; }; 9F4844D12FD0000100484401 /* AisTrackerProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4844D02FD0000100484401 /* AisTrackerProduct.swift */; }; B0D6100B2E010FD6F3088B80 /* OATrackMenuHeaderView.mm in Sources */ = {isa = PBXBuildFile; fileRef = B0D61823DCC461FF03FE8103 /* OATrackMenuHeaderView.mm */; }; @@ -5134,8 +5134,7 @@ 9F4844B12FD0000100484401 /* OAAisTrackerLayer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OAAisTrackerLayer.h; sourceTree = ""; }; 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OAAisTrackerLayer.mm; sourceTree = ""; }; 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerHelper.swift; sourceTree = ""; }; - 9F4844B62FD0000100484401 /* OAAisObjectViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OAAisObjectViewController.h; sourceTree = ""; }; - 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OAAisObjectViewController.mm; sourceTree = ""; }; + 9F4844B72FD0000100484401 /* AisObjectViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisObjectViewController.swift; sourceTree = ""; }; 9F4844C02FD0000100484401 /* OAAisTrackerLayerBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OAAisTrackerLayerBridge.h; sourceTree = ""; }; 9F4844C12FD0000100484401 /* OAAisTrackerLayerBridge.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = OAAisTrackerLayerBridge.mm; sourceTree = ""; }; 9F4844D02FD0000100484401 /* AisTrackerProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AisTrackerProduct.swift; sourceTree = ""; }; @@ -10437,8 +10436,7 @@ 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */, 9F4844C02FD0000100484401 /* OAAisTrackerLayerBridge.h */, 9F4844C12FD0000100484401 /* OAAisTrackerLayerBridge.mm */, - 9F4844B62FD0000100484401 /* OAAisObjectViewController.h */, - 9F4844B72FD0000100484401 /* OAAisObjectViewController.mm */, + 9F4844B72FD0000100484401 /* AisObjectViewController.swift */, ); path = AisTrackerPlugin; sourceTree = ""; @@ -18059,7 +18057,7 @@ 9F4844B32FD0000100484401 /* OAAisTrackerLayer.mm in Sources */, 9F4844B52FD0000100484401 /* AisTrackerHelper.swift in Sources */, 9F4844C22FD0000100484401 /* OAAisTrackerLayerBridge.mm in Sources */, - 9F4844B82FD0000100484401 /* OAAisObjectViewController.mm in Sources */, + 9F4844B82FD0000100484401 /* AisObjectViewController.swift in Sources */, DA5A813B26C563A700F274C7 /* OAOsmMapUtils.mm in Sources */, DA5A856026C563A900F274C7 /* OARTargetPoint.mm in Sources */, DA5A837426C563A800F274C7 /* OARouteSegmentShieldView.mm in Sources */, diff --git a/Sources/Controllers/TargetMenu/OATargetInfoViewController.h b/Sources/Controllers/TargetMenu/OATargetInfoViewController.h index 2dbd3787c1..b40e864e02 100644 --- a/Sources/Controllers/TargetMenu/OATargetInfoViewController.h +++ b/Sources/Controllers/TargetMenu/OATargetInfoViewController.h @@ -38,6 +38,7 @@ static NSString *kGroupRowType = @"kGroupRowType"; - (void) buildTopInternal:(NSMutableArray *)rows; - (void) buildDescription:(NSMutableArray *)rows; - (void) buildInternal:(NSMutableArray *)rows; +- (void) buildPluginRows:(NSMutableArray *)rows; - (void) buildMenu:(NSMutableArray *)rows; - (void) buildDateRow:(NSMutableArray *)rows timestamp:(NSDate *)timestamp; - (void) buildCommentRow:(NSMutableArray *)rows comment:(NSString *)comment; diff --git a/Sources/Controllers/TargetMenu/OATargetMenuViewController.mm b/Sources/Controllers/TargetMenu/OATargetMenuViewController.mm index e567dd8b6f..17f1f15319 100644 --- a/Sources/Controllers/TargetMenu/OATargetMenuViewController.mm +++ b/Sources/Controllers/TargetMenu/OATargetMenuViewController.mm @@ -54,7 +54,6 @@ #import "OAMapHudViewController.h" #import "OAMapRendererView.h" #import "OADownloadMapViewController.h" -#import "OAAisObjectViewController.h" #import "OAPlugin.h" #import "OAWikipediaPlugin.h" #import "OAPOI.h" @@ -216,7 +215,7 @@ + (OATargetMenuViewController *)createMenuController:(OATargetPoint *)targetPoin case OATargetAisObject: { - controller = [[OAAisObjectViewController alloc] initWithAisObject:targetPoint.targetObj]; + controller = [[AisObjectViewController alloc] initWithAisObject:targetPoint.targetObj]; break; } diff --git a/Sources/Plugins/AisTrackerPlugin/AisObjectViewController.swift b/Sources/Plugins/AisTrackerPlugin/AisObjectViewController.swift new file mode 100644 index 0000000000..19a13deede --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisObjectViewController.swift @@ -0,0 +1,289 @@ +// +// AisObjectViewController.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import CoreLocation +import OsmAndShared + +final class AisObjectViewController: OATargetInfoViewController { + + private static let rowStartOrder = 100 + private static let rowHeight: Int32 = 50 + + private let object: AisObject + + private var menuRows: NSMutableArray? + private var aisValueRowKeys = Set() + + @objc(initWithAisObject:) + init(aisObject: AisObject) { + object = aisObject + super.init(nibName: "OATargetInfoViewController", bundle: nil) + if let position = aisObject.position { + location = CLLocationCoordinate2D(latitude: position.latitude, longitude: position.longitude) + } + showTitleIfTruncated = false + customOnlinePhotosPosition = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UINib(nibName: OAValueTableViewCell.reuseIdentifier, bundle: nil), + forCellReuseIdentifier: OAValueTableViewCell.reuseIdentifier) + } + + override func getTargetObj() -> Any { + object + } + + override func getIcon() -> UIImage? { + .icActionSailBoatDark.withRenderingMode(.alwaysOriginal) + } + + override func getTypeStr() -> String? { + objectTypeName(object.objectClass) + } + + override func getCommonTypeStr() -> String { + getTypeStr() ?? "" + } + + override func getNameStr() -> String? { + String(format: localizedString("ais_object_with_mmsi"), Int(object.mmsi)) + } + + override func needAddress() -> Bool { + false + } + + override func showDetailsButton() -> Bool { + false + } + + override func showNearestWiki() -> Bool { + false + } + + override func showNearestPoi() -> Bool { + false + } + + override func buildDescription(_ rows: NSMutableArray) { + } + + override func buildTopInternal(_ rows: NSMutableArray) { + } + + override func buildMenu(_ rows: NSMutableArray) { + menuRows = rows + aisValueRowKeys.removeAll() + super.buildMenu(rows) + } + + override func buildPluginRows(_ rows: NSMutableArray) { + } + + override func buildInternal(_ rows: NSMutableArray) { + var order = Self.rowStartOrder + let plugin = OAPluginsHelper.getPlugin(AisTrackerPlugin.self) as? AisTrackerPlugin + plugin?.updateCpa(for: object) + + addRow(to: rows, key: "mmsi", prefix: localizedString("ais_mmsi"), text: String(object.mmsi), order: &order) + if object.position != nil { + addRow(to: rows, key: "position", prefix: localizedString("ais_position"), text: formatPosition(), order: &order) + } + if let plugin { + let distance = plugin.distanceInNauticalMiles(to: object) + if distance >= 0 { + addRow(to: rows, key: "distance", prefix: localizedString("shared_string_distance"), text: String(format: "%.1f nm", distance), order: &order) + } + let bearing = plugin.bearing(to: object) + if bearing >= 0 { + addRow(to: rows, key: "bearing", prefix: localizedString("shared_string_bearing"), text: String(format: "%.0f", bearing), order: &order) + } + } + if object.cpa.valid { + addRow(to: rows, key: "cpa", prefix: localizedString("ais_cpa"), text: String(format: "%.1f nm", object.cpa.cpa), order: &order) + addRow(to: rows, key: "tcpa", prefix: localizedString("ais_tcpa"), text: formatTcpa(object.cpa.tcpa), order: &order) + } + + if isType(object.objectClass, .aisAton) || isType(object.objectClass, .aisAtonVirtual) { + if object.aidType != AisObjectConstants.shared.UNSPECIFIED_AID_TYPE { + addRow(to: rows, key: "aid_type", prefix: localizedString("ais_aid_type"), text: object.getAidTypeString(), order: &order) + } + addDimensionRow(to: rows, order: &order) + } else if isType(object.objectClass, .aisAirplane) { + addRow(to: rows, key: "object_type", prefix: localizedString("ais_object_type"), text: objectTypeName(object.objectClass), order: &order) + addCourseRows(to: rows, order: &order, includeHeading: false, includeNavStatus: false) + if object.altitude != AisObjectConstants.shared.INVALID_ALTITUDE { + addRow(to: rows, key: "altitude", prefix: localizedString("altitude"), text: "\(object.altitude) m", order: &order) + } + } else { + addRow(to: rows, key: "callsign", prefix: localizedString("ais_call_sign"), text: object.callSign, order: &order) + if object.imo > 0, hasMessageType(5) { + addRow(to: rows, key: "imo", prefix: localizedString("ais_imo"), text: String(object.imo), order: &order) + } + addRow(to: rows, key: "ship_name", prefix: localizedString("ais_ship_name"), text: object.shipName, order: &order) + if hasMessageType(5) || hasMessageType(19) || hasMessageType(24) { + addRow(to: rows, key: "ship_type", prefix: localizedString("ais_ship_type"), text: object.getShipTypeString(), order: &order) + } + addCourseRows(to: rows, order: &order, includeHeading: true, includeNavStatus: true) + addDimensionRow(to: rows, order: &order) + if object.draught != AisObjectConstants.shared.INVALID_DRAUGHT { + addRow(to: rows, key: "draught", prefix: localizedString("ais_draught"), text: String(format: "%.1f m", object.draught), order: &order) + } + addRow(to: rows, key: "destination", prefix: localizedString("ais_destination"), text: object.destination, order: &order) + if object.etaMon != AisObjectConstants.shared.INVALID_ETA, + object.etaDay != AisObjectConstants.shared.INVALID_ETA { + let eta = String(format: "%02d.%02d. %02d:%02d", object.etaDay, object.etaMon, object.etaHour, object.etaMin) + addRow(to: rows, key: "eta", prefix: localizedString("ais_eta"), text: eta, order: &order) + } + } + + addRow(to: rows, key: "last_update", prefix: localizedString("ais_last_update"), text: formatLastUpdate(), order: &order) + addRow(to: rows, key: "message_types", prefix: localizedString("ais_message_types"), text: AisObjectHelper.messageTypesString(object), order: &order) + } + + override func needBuildCoordinatesRow() -> Bool { + true + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let menuRows, indexPath.row < menuRows.count, + let row = menuRows[indexPath.row] as? OAAmenityInfoRow else { + return super.tableView(tableView, cellForRowAt: indexPath) + } + let key = row.key + guard aisValueRowKeys.contains(key), + let cell = tableView.dequeueReusableCell(withIdentifier: OAValueTableViewCell.reuseIdentifier) as? OAValueTableViewCell else { + return super.tableView(tableView, cellForRowAt: indexPath) + } + + cell.leftIconVisibility(false) + cell.descriptionVisibility(false) + cell.valueVisibility(true) + cell.setupValueLabelFlexible() + cell.selectionStyle = .none + cell.titleLabel.text = row.textPrefix + cell.titleLabel.textColor = .textColorPrimary + cell.titleLabel.font = .preferredFont(forTextStyle: .body) + cell.titleLabel.numberOfLines = 0 + cell.valueLabel.text = row.text + cell.valueLabel.textColor = .textColorActive + cell.valueLabel.font = .scaledSystemFont(ofSize: 16, weight: .medium) + cell.valueLabel.numberOfLines = 0 + cell.accessibilityLabel = row.textPrefix + cell.accessibilityValue = row.text + return cell + } + + private func addRow(to rows: NSMutableArray, key: String, prefix: String, text: String?, order: inout Int) { + guard let text, !text.isEmpty else { return } + let row = OAAmenityInfoRow(key: key, + icon: nil, + textPrefix: prefix, + text: text, + textColor: .textColorPrimary, + isText: true, + needLinks: false, + order: order, + typeName: key, + isPhoneNumber: false, + isUrl: false) + row.height = Self.rowHeight + rows.add(row) + aisValueRowKeys.insert(key) + order += 1 + } + + private func addCourseRows(to rows: NSMutableArray, order: inout Int, includeHeading: Bool, includeNavStatus: Bool) { + let constants = AisObjectConstants.shared + if includeNavStatus, object.navStatus != constants.INVALID_NAV_STATUS { + addRow(to: rows, key: "nav_status", prefix: localizedString("ais_navigation_status"), text: object.getNavStatusString().uppercased(), order: &order) + } + if object.cog != constants.INVALID_COG { + addRow(to: rows, key: "cog", prefix: localizedString("ais_cog"), text: String(format: "%.0f", object.cog), order: &order) + } + if object.sog != constants.INVALID_SOG { + addRow(to: rows, key: "sog", prefix: localizedString("ais_sog"), text: String(format: "%.1f %@", object.sog, localizedString("shared_string_kts")), order: &order) + } + if includeHeading, object.heading != constants.INVALID_HEADING { + addRow(to: rows, key: "heading", prefix: localizedString("ais_heading"), text: String(object.heading), order: &order) + } + if includeHeading, object.rot != constants.INVALID_ROT { + addRow(to: rows, key: "rot", prefix: localizedString("ais_rate_of_turn"), text: String(format: "%.1f", object.rot), order: &order) + } + } + + private func addDimensionRow(to rows: NSMutableArray, order: inout Int) { + let invalidDimension = AisObjectConstants.shared.INVALID_DIMENSION + let hasLength = object.dimensionToBow != invalidDimension || object.dimensionToStern != invalidDimension + let hasWidth = object.dimensionToPort != invalidDimension || object.dimensionToStarboard != invalidDimension + guard hasLength, hasWidth else { return } + let length = object.dimensionToBow + object.dimensionToStern + let width = object.dimensionToPort + object.dimensionToStarboard + addRow(to: rows, key: "dimension", prefix: localizedString("ais_dimension"), text: "\(length)m x \(width)m", order: &order) + } + + private func formatPosition() -> String { + guard let position = object.position else { return "" } + let latitude = OALocationConvert.convertLatitude(position.latitude, outputType: Int(FORMAT_MINUTES), addCardinalDirection: true) ?? "" + let longitude = OALocationConvert.convertLongitude(position.longitude, outputType: Int(FORMAT_MINUTES), addCardinalDirection: true) ?? "" + return "\(latitude), \(longitude)" + } + + private func formatLastUpdate() -> String { + let seconds = max(0, Int(round(-AisObjectHelper.lastUpdateDate(object).timeIntervalSinceNow))) + if seconds > 60 { + return "\(seconds / 60) \(localizedString("shared_string_minute_lowercase")) \(seconds % 60) \(localizedString("shared_string_sec"))" + } + return "\(seconds) \(localizedString("shared_string_sec"))" + } + + private func objectTypeName(_ type: AisObjType) -> String { + let names: [(AisObjType, String)] = [ + (.aisVessel, "ais_type_vessel"), + (.aisVesselSport, "ais_type_sport_vessel"), + (.aisVesselFast, "ais_type_high_speed_vessel"), + (.aisVesselPassenger, "ais_type_passenger_vessel"), + (.aisVesselFreight, "ais_type_cargo_tanker"), + (.aisVesselCommercial, "ais_type_commercial_vessel"), + (.aisVesselAuthorities, "ais_type_authorities_vessel"), + (.aisVesselSar, "ais_type_sar_vessel"), + (.aisLandstation, "ais_type_base_station"), + (.aisAirplane, "ais_type_sar_aircraft"), + (.aisSart, "ais_type_sart"), + (.aisAton, "ais_type_aid_to_navigation"), + (.aisAtonVirtual, "ais_type_virtual_aid_to_navigation"), + (.aisVesselOther, "ais_type_other_vessel") + ] + return localizedString(names.first { isType(type, $0.0) }?.1 ?? "ais_type_object") + } + + private func formatTcpa(_ tcpa: Double) -> String { + let absoluteTcpa = abs(tcpa) + let hours = Int(absoluteTcpa) + let minutes = Int(round((absoluteTcpa - Double(hours)) * 60)) + let value = hours > 0 + ? "\(hours) \(localizedString("int_hour")) \(minutes) \(localizedString("shared_string_minute_lowercase"))" + : "\(minutes) \(localizedString("shared_string_minute_lowercase"))" + return tcpa >= 0 ? value : "-\(value)" + } + + private func hasMessageType(_ type: Int32) -> Bool { + object.msgTypes.compactMap { ($0 as? KotlinInt)?.intValue }.contains(Int(type)) + } + + private func isType(_ type: AisObjType, _ expected: AisObjType) -> Bool { + type === expected || type.isEqual(expected) + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h deleted file mode 100644 index aa8ae53a9e..0000000000 --- a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// OAAisObjectViewController.h -// OsmAnd -// -// Created by Oleksandr Panchenko on 11.06.2026. -// Copyright © 2026 OsmAnd. All rights reserved. -// - -#import "OATargetInfoViewController.h" -#import "OsmAndSharedWrapper.h" - -@interface OAAisObjectViewController : OATargetInfoViewController - -- (instancetype)initWithAisObject:(OASAisObject *)object; - -@end diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm b/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm deleted file mode 100644 index 9c6f551b94..0000000000 --- a/Sources/Plugins/AisTrackerPlugin/OAAisObjectViewController.mm +++ /dev/null @@ -1,349 +0,0 @@ -// -// OAAisObjectViewController.m -// OsmAnd -// -// Created by Oleksandr Panchenko on 11.06.2026. -// Copyright © 2026 OsmAnd. All rights reserved. -// - -#import "OAAisObjectViewController.h" -#import "OAAmenityInfoRow.h" -#import "OAPluginsHelper.h" -#import "OAPointDescription.h" -#import "Localization.h" -#import "OALocationConvert.h" -#import "OAValueTableViewCell.h" -#import "OsmAnd_Maps-Swift.h" -#import "GeneratedAssetSymbols.h" - -static const NSInteger kAisRowStartOrder = 100; -static const NSInteger kAisRowHeight = 50; - -static BOOL OAAisTypeEquals(OASAisObjType *type, OASAisObjType *expected) -{ - return type == expected || [type isEqual:expected]; -} - -static NSDate *OAAisLastUpdateDate(OASAisObject *object) -{ - return [NSDate dateWithTimeIntervalSince1970:(NSTimeInterval)object.lastUpdate / 1000.0]; -} - -static BOOL OAAisHasMessageType(OASAisObject *object, int type) -{ - return [object.msgTypes containsObject:[[OASInt alloc] initWithInt:type]]; -} - -static NSString *OAAisMessageTypesString(OASAisObject *object) -{ - NSMutableArray *values = [NSMutableArray array]; - for (OASInt *type in object.msgTypes) - [values addObject:[NSString stringWithFormat:@"%d", type.intValue]]; - [values sortUsingSelector:@selector(compare:)]; - return [values componentsJoinedByString:@", "]; -} - -@implementation OAAisObjectViewController -{ - OASAisObject *_object; - NSMutableArray *_menuRows; - NSMutableSet *_aisValueRowKeys; -} - -- (instancetype)initWithAisObject:(OASAisObject *)object -{ - self = [super initWithNibName:@"OATargetInfoViewController" bundle:nil]; - if (self) - { - _object = object; - if (object.position) - self.location = CLLocationCoordinate2DMake(object.position.latitude, object.position.longitude); - self.showTitleIfTruncated = NO; - self.customOnlinePhotosPosition = YES; - } - return self; -} - -- (void)viewDidLoad -{ - [super viewDidLoad]; - [self.tableView registerNib:[UINib nibWithNibName:[OAValueTableViewCell reuseIdentifier] bundle:nil] - forCellReuseIdentifier:[OAValueTableViewCell reuseIdentifier]]; -} - -- (id)getTargetObj -{ - return _object; -} - -- (UIImage *)getIcon -{ - return [[UIImage imageNamed:ACImageNameIcActionSailBoatDark] - imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal]; -} - -- (NSString *)getTypeStr -{ - return [self objectTypeName:_object.objectClass]; -} - -- (NSString *)getCommonTypeStr -{ - return [self getTypeStr]; -} - -- (NSString *)getNameStr -{ - return [NSString stringWithFormat:OALocalizedString(@"ais_object_with_mmsi"), (long)_object.mmsi]; -} - -- (NSAttributedString *)getAdditionalInfoStr -{ - return nil; -} - -- (BOOL)needAddress -{ - return NO; -} - -- (BOOL)showDetailsButton -{ - return NO; -} - -- (BOOL)showNearestWiki -{ - return NO; -} - -- (BOOL)showNearestPoi -{ - return NO; -} - -- (void)buildDescription:(NSMutableArray *)rows -{ -} - -- (void)buildTopInternal:(NSMutableArray *)rows -{ -} - -- (void)buildMenu:(NSMutableArray *)rows -{ - _menuRows = rows; - _aisValueRowKeys = [NSMutableSet set]; - [super buildMenu:rows]; -} - -- (void)buildPluginRows:(NSMutableArray *)rows -{ -} - -- (void)buildInternal:(NSMutableArray *)rows -{ - NSInteger order = kAisRowStartOrder; - AisTrackerPlugin *plugin = (AisTrackerPlugin *)[OAPluginsHelper getPlugin:AisTrackerPlugin.class]; - if (plugin) - [plugin updateCpaFor:_object]; - - [self addRow:rows key:@"mmsi" prefix:OALocalizedString(@"ais_mmsi") text:[NSString stringWithFormat:@"%ld", (long)_object.mmsi] order:order++]; - if (_object.position) - { - [self addRow:rows key:@"position" prefix:OALocalizedString(@"ais_position") text:[self formatPosition] order:order++]; - } - if (plugin) - { - double distance = [plugin distanceInNauticalMilesTo:_object]; - if (distance >= 0) - [self addRow:rows key:@"distance" prefix:OALocalizedString(@"shared_string_distance") text:[NSString stringWithFormat:@"%.1f nm", distance] order:order++]; - double bearing = [plugin bearingTo:_object]; - if (bearing >= 0) - [self addRow:rows key:@"bearing" prefix:OALocalizedString(@"shared_string_bearing") text:[NSString stringWithFormat:@"%.0f", bearing] order:order++]; - } - if (_object.cpa.valid) - { - [self addRow:rows key:@"cpa" prefix:OALocalizedString(@"ais_cpa") text:[NSString stringWithFormat:@"%.1f nm", _object.cpa.cpa] order:order++]; - [self addRow:rows key:@"tcpa" prefix:OALocalizedString(@"ais_tcpa") text:[self formatTcpa:_object.cpa.tcpa] order:order++]; - } - - if (OAAisTypeEquals(_object.objectClass, OASAisObjType.aisAton) || OAAisTypeEquals(_object.objectClass, OASAisObjType.aisAtonVirtual)) - { - if (_object.aidType != OASAisObjectConstants.shared.UNSPECIFIED_AID_TYPE) - [self addRow:rows key:@"aid_type" prefix:OALocalizedString(@"ais_aid_type") text:[_object getAidTypeString] order:order++]; - order = [self addDimensionRows:rows order:order]; - } - else if (OAAisTypeEquals(_object.objectClass, OASAisObjType.aisAirplane)) - { - [self addRow:rows key:@"object_type" prefix:OALocalizedString(@"ais_object_type") text:[self objectTypeName:_object.objectClass] order:order++]; - order = [self addCourseRows:rows order:order includeHeading:NO includeNavStatus:NO]; - if (_object.altitude != OASAisObjectConstants.shared.INVALID_ALTITUDE) - [self addRow:rows key:@"altitude" prefix:OALocalizedString(@"altitude") text:[NSString stringWithFormat:@"%ld m", (long)_object.altitude] order:order++]; - } - else - { - if (_object.callSign.length > 0) - [self addRow:rows key:@"callsign" prefix:OALocalizedString(@"ais_call_sign") text:_object.callSign order:order++]; - if (_object.imo > 0 && OAAisHasMessageType(_object, 5)) - [self addRow:rows key:@"imo" prefix:OALocalizedString(@"ais_imo") text:[NSString stringWithFormat:@"%ld", (long)_object.imo] order:order++]; - if (_object.shipName.length > 0) - [self addRow:rows key:@"ship_name" prefix:OALocalizedString(@"ais_ship_name") text:_object.shipName order:order++]; - if (OAAisHasMessageType(_object, 5) || OAAisHasMessageType(_object, 19) || OAAisHasMessageType(_object, 24)) - [self addRow:rows key:@"ship_type" prefix:OALocalizedString(@"ais_ship_type") text:[_object getShipTypeString] order:order++]; - order = [self addCourseRows:rows order:order includeHeading:YES includeNavStatus:YES]; - order = [self addDimensionRows:rows order:order]; - if (_object.draught != OASAisObjectConstants.shared.INVALID_DRAUGHT) - [self addRow:rows key:@"draught" prefix:OALocalizedString(@"ais_draught") text:[NSString stringWithFormat:@"%.1f m", _object.draught] order:order++]; - if (_object.destination.length > 0) - [self addRow:rows key:@"destination" prefix:OALocalizedString(@"ais_destination") text:_object.destination order:order++]; - if (_object.etaMon != OASAisObjectConstants.shared.INVALID_ETA && _object.etaDay != OASAisObjectConstants.shared.INVALID_ETA) - [self addRow:rows key:@"eta" prefix:OALocalizedString(@"ais_eta") text:[NSString stringWithFormat:@"%02ld.%02ld. %02ld:%02ld", (long)_object.etaDay, (long)_object.etaMon, (long)_object.etaHour, (long)_object.etaMin] order:order++]; - } - - [self addRow:rows key:@"last_update" prefix:OALocalizedString(@"ais_last_update") text:[self formatLastUpdate] order:order++]; - NSString *messageTypesString = OAAisMessageTypesString(_object); - if (messageTypesString.length > 0) - [self addRow:rows key:@"message_types" prefix:OALocalizedString(@"ais_message_types") text:messageTypesString order:order++]; -} - -- (BOOL)needBuildCoordinatesRow -{ - return YES; -} - -- (void)addRow:(NSMutableArray *)rows key:(NSString *)key prefix:(NSString *)prefix text:(NSString *)text order:(NSInteger)order -{ - if (text.length == 0) - return; - OAAmenityInfoRow *row = [[OAAmenityInfoRow alloc] initWithKey:key - icon:nil - textPrefix:prefix - text:text - textColor:[UIColor colorNamed:ACColorNameTextColorPrimary] - isText:YES - needLinks:NO - order:order - typeName:key - isPhoneNumber:NO - isUrl:NO]; - row.height = kAisRowHeight; - [rows addObject:row]; - [_aisValueRowKeys addObject:key]; -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath -{ - OAAmenityInfoRow *row = indexPath.row < _menuRows.count ? _menuRows[indexPath.row] : nil; - if (row.key.length > 0 && [_aisValueRowKeys containsObject:row.key]) - { - OAValueTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[OAValueTableViewCell reuseIdentifier]]; - - [cell leftIconVisibility:NO]; - [cell descriptionVisibility:NO]; - [cell valueVisibility:YES]; - [cell setupValueLabelFlexible]; - cell.selectionStyle = UITableViewCellSelectionStyleNone; - cell.titleLabel.text = row.textPrefix; - cell.titleLabel.textColor = [UIColor colorNamed:ACColorNameTextColorPrimary]; - cell.titleLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; - cell.titleLabel.numberOfLines = 0; - cell.valueLabel.text = row.text; - cell.valueLabel.textColor = [UIColor colorNamed:ACColorNameTextColorActive]; - cell.valueLabel.font = [UIFont scaledSystemFontOfSize:16.0 weight:UIFontWeightMedium]; - cell.valueLabel.numberOfLines = 0; - cell.accessibilityLabel = row.textPrefix; - cell.accessibilityValue = row.text; - return cell; - } - return [super tableView:tableView cellForRowAtIndexPath:indexPath]; -} - -- (NSInteger)addCourseRows:(NSMutableArray *)rows - order:(NSInteger)order - includeHeading:(BOOL)includeHeading - includeNavStatus:(BOOL)includeNavStatus -{ - if (includeNavStatus && _object.navStatus != OASAisObjectConstants.shared.INVALID_NAV_STATUS) - [self addRow:rows key:@"nav_status" prefix:OALocalizedString(@"ais_navigation_status") text:[_object getNavStatusString].upperCase order:order++]; - if (_object.cog != OASAisObjectConstants.shared.INVALID_COG) - [self addRow:rows key:@"cog" prefix:OALocalizedString(@"ais_cog") text:[NSString stringWithFormat:@"%.0f", _object.cog] order:order++]; - if (_object.sog != OASAisObjectConstants.shared.INVALID_SOG) - [self addRow:rows key:@"sog" prefix:OALocalizedString(@"ais_sog") text:[NSString stringWithFormat:@"%.1f %@", _object.sog, OALocalizedString(@"shared_string_kts")] order:order++]; - if (includeHeading && _object.heading != OASAisObjectConstants.shared.INVALID_HEADING) - [self addRow:rows key:@"heading" prefix:OALocalizedString(@"ais_heading") text:[NSString stringWithFormat:@"%ld", (long)_object.heading] order:order++]; - if (includeHeading && _object.rot != OASAisObjectConstants.shared.INVALID_ROT) - [self addRow:rows key:@"rot" prefix:OALocalizedString(@"ais_rate_of_turn") text:[NSString stringWithFormat:@"%.1f", _object.rot] order:order++]; - return order; -} - -- (NSInteger)addDimensionRows:(NSMutableArray *)rows order:(NSInteger)order -{ - const int32_t invalidDimension = OASAisObjectConstants.shared.INVALID_DIMENSION; - NSInteger length = _object.dimensionToBow + _object.dimensionToStern; - NSInteger width = _object.dimensionToPort + _object.dimensionToStarboard; - if ((_object.dimensionToBow != invalidDimension || _object.dimensionToStern != invalidDimension) - && (_object.dimensionToPort != invalidDimension || _object.dimensionToStarboard != invalidDimension)) - [self addRow:rows key:@"dimension" prefix:OALocalizedString(@"ais_dimension") text:[NSString stringWithFormat:@"%ldm x %ldm", (long)length, (long)width] order:order++]; - return order; -} - -- (NSString *)formatPosition -{ - NSString *lat = [OALocationConvert convertLatitude:_object.position.latitude outputType:FORMAT_MINUTES addCardinalDirection:YES]; - NSString *lon = [OALocationConvert convertLongitude:_object.position.longitude outputType:FORMAT_MINUTES addCardinalDirection:YES]; - return [NSString stringWithFormat:@"%@, %@", lat, lon]; -} - -- (NSString *)formatLastUpdate -{ - NSInteger seconds = MAX(0, (NSInteger)round(-[OAAisLastUpdateDate(_object) timeIntervalSinceNow])); - if (seconds > 60) - return [NSString stringWithFormat:@"%ld %@ %ld %@", (long)(seconds / 60), OALocalizedString(@"shared_string_minute_lowercase"), (long)(seconds % 60), OALocalizedString(@"shared_string_sec")]; - return [NSString stringWithFormat:@"%ld %@", (long)seconds, OALocalizedString(@"shared_string_sec")]; -} - -- (NSString *)objectTypeName:(OASAisObjType *)type -{ - if (OAAisTypeEquals(type, OASAisObjType.aisVessel)) - return OALocalizedString(@"ais_type_vessel"); - if (OAAisTypeEquals(type, OASAisObjType.aisVesselSport)) - return OALocalizedString(@"ais_type_sport_vessel"); - if (OAAisTypeEquals(type, OASAisObjType.aisVesselFast)) - return OALocalizedString(@"ais_type_high_speed_vessel"); - if (OAAisTypeEquals(type, OASAisObjType.aisVesselPassenger)) - return OALocalizedString(@"ais_type_passenger_vessel"); - if (OAAisTypeEquals(type, OASAisObjType.aisVesselFreight)) - return OALocalizedString(@"ais_type_cargo_tanker"); - if (OAAisTypeEquals(type, OASAisObjType.aisVesselCommercial)) - return OALocalizedString(@"ais_type_commercial_vessel"); - if (OAAisTypeEquals(type, OASAisObjType.aisVesselAuthorities)) - return OALocalizedString(@"ais_type_authorities_vessel"); - if (OAAisTypeEquals(type, OASAisObjType.aisVesselSar)) - return OALocalizedString(@"ais_type_sar_vessel"); - if (OAAisTypeEquals(type, OASAisObjType.aisLandstation)) - return OALocalizedString(@"ais_type_base_station"); - if (OAAisTypeEquals(type, OASAisObjType.aisAirplane)) - return OALocalizedString(@"ais_type_sar_aircraft"); - if (OAAisTypeEquals(type, OASAisObjType.aisSart)) - return OALocalizedString(@"ais_type_sart"); - if (OAAisTypeEquals(type, OASAisObjType.aisAton)) - return OALocalizedString(@"ais_type_aid_to_navigation"); - if (OAAisTypeEquals(type, OASAisObjType.aisAtonVirtual)) - return OALocalizedString(@"ais_type_virtual_aid_to_navigation"); - if (OAAisTypeEquals(type, OASAisObjType.aisVesselOther)) - return OALocalizedString(@"ais_type_other_vessel"); - return OALocalizedString(@"ais_type_object"); -} - -- (NSString *)formatTcpa:(double)tcpa -{ - BOOL future = tcpa >= 0; - double absTcpa = fabs(tcpa); - NSInteger hours = (NSInteger)absTcpa; - NSInteger minutes = (NSInteger)round((absTcpa - hours) * 60.0); - NSString *value = hours > 0 ? [NSString stringWithFormat:@"%ld %@ %ld %@", (long)hours, OALocalizedString(@"int_hour"), (long)minutes, OALocalizedString(@"shared_string_minute_lowercase")] : [NSString stringWithFormat:@"%ld %@", (long)minutes, OALocalizedString(@"shared_string_minute_lowercase")]; - return future ? value : [NSString stringWithFormat:@"-%@", value]; -} - -@end diff --git a/Sources/Services/OALocationServices.h b/Sources/Services/OALocationServices.h index 83adbe1d90..7dd7a847de 100644 --- a/Sources/Services/OALocationServices.h +++ b/Sources/Services/OALocationServices.h @@ -57,7 +57,6 @@ typedef NS_ENUM(NSUInteger, OALocationServicesStatus) + (BOOL) isPointAccurateForRouting:(CLLocation *)loc; - (void) setLocationFromSimulation:(CLLocation *)location; -- (void) setLocationFromNMEA:(CLLocation *)location; - (BOOL) isInLocationSimulation; - (void)resume; diff --git a/Sources/Services/OALocationServices.mm b/Sources/Services/OALocationServices.mm index b94210f287..acd0899bd7 100644 --- a/Sources/Services/OALocationServices.mm +++ b/Sources/Services/OALocationServices.mm @@ -35,7 +35,6 @@ #define LOST_LOCATION_CHECK_DELAY 18.0 #define START_LOCATION_SIMULATION_DELAY 2.0 #define ACCURACY_FOR_GPX_AND_ROUTING 50.0 -#define NMEA_LOCATION_OVERRIDE_INTERVAL 5.0 @interface OALocationServices () @end @@ -69,7 +68,6 @@ @implementation OALocationServices BOOL _isSuspended; NSDate *_locationLostTime; - NSDate *_nmeaLocationOverrideUntil; } - (instancetype) initWith:(OsmAndAppInstance)app @@ -644,20 +642,6 @@ - (void) setLocationFromSimulation:(CLLocation *)location [self setLocation:location]; } -- (void) setLocationFromNMEA:(CLLocation *)location -{ - if (!location || [_locationSimulation isRouteAnimating]) - return; - - _nmeaLocationOverrideUntil = [NSDate dateWithTimeIntervalSinceNow:NMEA_LOCATION_OVERRIDE_INTERVAL]; - if (location.course >= 0) - { - _lastHeading = location.course; - _lastMagneticHeading = location.course; - } - [self setLocation:location]; -} - - (BOOL) isInLocationSimulation { return _simulatePosition != nil; @@ -773,9 +757,6 @@ - (void) locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArra if (!locations || ![locations lastObject] || [_locationSimulation isRouteAnimating]) return; - if (_nmeaLocationOverrideUntil && [_nmeaLocationOverrideUntil timeIntervalSinceNow] > 0) - return; - BOOL wasLocationUnknown = (_lastLocation == nil); [self setLocation:[locations lastObject]];