diff --git a/OsmAnd.xcodeproj/project.pbxproj b/OsmAnd.xcodeproj/project.pbxproj index ebe3caf12f..95f9582da3 100644 --- a/OsmAnd.xcodeproj/project.pbxproj +++ b/OsmAnd.xcodeproj/project.pbxproj @@ -1361,6 +1361,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 */; }; + 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 /* 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 /* 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 */; }; B0D610BD70EF0179933FD55C /* OAAddWaypointViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = B0D618034074DE575CDAACDD /* OAAddWaypointViewController.mm */; }; B0D610C833B6EA61B1C74268 /* OADeleteWaypointsGroupBottomSheetViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = B0D61BD47F1716F617480A05 /* OADeleteWaypointsGroupBottomSheetViewController.mm */; }; @@ -3296,6 +3307,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 */; }; @@ -5113,6 +5125,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 = ""; }; + 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 /* 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 = ""; }; + 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 = ""; }; + 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 = ""; }; 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 = ""; }; @@ -8071,6 +8096,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 = ""; }; @@ -10394,6 +10420,27 @@ path = Resources/Icons/Location; sourceTree = ""; }; + 9F4844A02FD0000100484401 /* AisTrackerPlugin */ = { + isa = PBXGroup; + children = ( + FA4EDBF52FDAC090003B0AFC /* AisLogger.swift */, + 9F4844AB2FD0000100484401 /* AisObjectHelper.swift */, + 9F4844B42FD0000100484401 /* AisTrackerHelper.swift */, + 9F4844AD2FD0000100484401 /* AisDataManager.swift */, + 9F4844AF2FD0000100484401 /* AisMessageDecoder.swift */, + 9F4844A92FD0000100484401 /* AisSimulationProvider.swift */, + 9F4844D02FD0000100484401 /* AisTrackerProduct.swift */, + 9F4844A32FD0000100484401 /* AisTrackerPlugin.swift */, + 9F4844A42FD0000100484401 /* AisTrackerSettingsViewController.swift */, + 9F4844B12FD0000100484401 /* OAAisTrackerLayer.h */, + 9F4844B22FD0000100484401 /* OAAisTrackerLayer.mm */, + 9F4844C02FD0000100484401 /* OAAisTrackerLayerBridge.h */, + 9F4844C12FD0000100484401 /* OAAisTrackerLayerBridge.mm */, + 9F4844B72FD0000100484401 /* AisObjectViewController.swift */, + ); + path = AisTrackerPlugin; + sourceTree = ""; + }; B247453027E35B1D00C18C3F /* Cloud */ = { isa = PBXGroup; children = ( @@ -11224,6 +11271,7 @@ DA5A797626C563A000F274C7 /* Plugins */ = { isa = PBXGroup; children = ( + 9F4844A02FD0000100484401 /* AisTrackerPlugin */, FA1D6DAC2DCE04640080E374 /* VehicleMetricsPlugin */, FACE409C2AEA9A8000E1E43A /* ExternalSensorsPlugin */, 32119B3A28477112005E1E0C /* Development */, @@ -17985,6 +18033,7 @@ 4657490E2B6803710006046B /* TrashItem.swift in Sources */, DA5A81F126C563A700F274C7 /* OAProfileIconColor.m in Sources */, DA5A83F026C563A800F274C7 /* OAQuickSearchTableController.mm in Sources */, + 9F4844D12FD0000100484401 /* AisTrackerProduct.swift in Sources */, DA5A819E26C563A700F274C7 /* NSData+CRC32.m in Sources */, DA69ED5C2A385B1B001022C7 /* WidgetPanelViewController.swift in Sources */, FACE409F2AEA9ACB00E1E43A /* OAExternalSensorsPlugin.mm in Sources */, @@ -18000,6 +18049,15 @@ FAACFF222FC9B42B002D765A /* MultipleValuesViewController.swift in Sources */, 8AE943B327A28BE900961319 /* OAWeatherRasterLayer.mm in Sources */, FA1D6DAE2DCE04710080E374 /* VehicleMetricsPlugin.swift in Sources */, + 9F4844A72FD0000100484401 /* AisTrackerPlugin.swift in Sources */, + 9F4844AA2FD0000100484401 /* AisSimulationProvider.swift in Sources */, + 9F4844AC2FD0000100484401 /* AisObjectHelper.swift in Sources */, + 9F4844AE2FD0000100484401 /* AisDataManager.swift in Sources */, + 9F4844B02FD0000100484401 /* AisMessageDecoder.swift in Sources */, + 9F4844B32FD0000100484401 /* OAAisTrackerLayer.mm in Sources */, + 9F4844B52FD0000100484401 /* AisTrackerHelper.swift in Sources */, + 9F4844C22FD0000100484401 /* OAAisTrackerLayerBridge.mm in Sources */, + 9F4844B82FD0000100484401 /* AisObjectViewController.swift in Sources */, DA5A813B26C563A700F274C7 /* OAOsmMapUtils.mm in Sources */, DA5A856026C563A900F274C7 /* OARTargetPoint.mm in Sources */, DA5A837426C563A800F274C7 /* OARouteSegmentShieldView.mm in Sources */, @@ -18252,6 +18310,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 */, @@ -18504,6 +18563,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/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/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 0000000000..6b99f9335a Binary files /dev/null and b/Resources/Images.xcassets/Images/ais_map.imageset/ais_map.png differ diff --git a/Resources/Localizations/en.lproj/Localizable.strings b/Resources/Localizations/en.lproj/Localizable.strings index 5b9d7c69e3..fafbb90842 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"; @@ -2433,6 +2434,172 @@ "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_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_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"; +"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_dimension" = "Dimension"; +"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_object_with_mmsi" = "AIS object with MMSI %ld"; +"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"; @@ -4604,4 +4771,4 @@ "shared_string_unlock" = "Unlock"; "shared_string_url" = "URL"; - +"shared_string_kts" = "kts"; 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 79bf29ca28..6fbd1187d1 100644 --- a/Sources/Controllers/Map/Layers/OAMapLayers.h +++ b/Sources/Controllers/Map/Layers/OAMapLayers.h @@ -35,7 +35,7 @@ #import "OANetworkRouteSelectionLayer.h" #import "OATravelSelectionLayer.h" -@class OAMapViewController; +@class OAMapViewController, OAAisTrackerLayer; @interface OAMapLayers : NSObject @@ -55,6 +55,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 +78,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..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]; @@ -2486,6 +2492,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..903915ccea 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:AisTrackerProduct.class]) + return [[OAPluginsHelper getPlugin:AisTrackerPlugin.class] getSettingsController]; return nil; } 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/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm b/Sources/Controllers/Settings/OsmandDevelopment/OAOsmandDevelopmentViewController.mm index 969325ace4..469252f8ed 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,8 @@ @implementation OAOsmandDevelopmentViewController NSString *const kShowTouchesKey = @"kShowTouchesKey"; 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"; @@ -110,8 +114,32 @@ - (void)generateData kCellTitleKey : OALocalizedString(@"simulate_obd"), @"isOn" : @([[OAAppSettings sharedManager].simulateOBDData get]) }]; + + AisTrackerPlugin *aisPlugin = (AisTrackerPlugin *)[OAPluginsHelper getPlugin:AisTrackerPlugin.class]; [_data addSection:simulationSection]; + + if (aisPlugin) + { + OATableSectionData *aisSection = [OATableSectionData sectionData]; + aisSection.headerText = OALocalizedString(@"plugin_ais_tracker_name"); + + [aisSection addRowFromDictionary:@{ + kCellTypeKey : [OAValueTableViewCell getCellIdentifier], + kCellKeyKey : kAisTrackerSimulationKey, + kCellTitleKey : OALocalizedString(@"ais_load_data"), + kCellDescrKey : aisPlugin.simulationFileName ?: @"", + @"actionBlock" : (^void(){ [weakSelf openAisSimulationFilePicker]; }) + }]; + + [aisSection addRowFromDictionary:@{ + kCellTypeKey : [OASwitchTableViewCell getCellIdentifier], + kCellKeyKey : kAisTrackerDebugLoggingKey, + kCellTitleKey : @"AIS logging", + @"isOn" : @([AisLogger shared].isEnabled) + }]; + [_data addSection:aisSection]; + } OATableSectionData *renderingSection = [OATableSectionData sectionData]; renderingSection.headerText = OALocalizedString(@"shared_string_appearance"); @@ -286,6 +314,10 @@ - (void)onSwitchPressed:(UISwitch *)sender if (!sender.isOn) [[DeviceHelper shared] disconnectOBDSimulator]; } + else if ([item.key isEqualToString:kAisTrackerDebugLoggingKey]) + { + [AisLogger shared].isEnabled = sender.isOn; + } else if ([item.key isEqualToString:kTraceRenderingKey]) { [[OAAppSettings sharedManager].debugRenderingInfo set:sender.isOn]; @@ -319,6 +351,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 +385,29 @@ - (void)onSimulateLocationInformationUpdated [self.tableView reloadData]; } +#pragma mark - UIDocumentPickerDelegate + +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls +{ + if (urls.count == 0) + return; + + AisTrackerPlugin *aisPlugin = (AisTrackerPlugin *)[OAPluginsHelper getPlugin:AisTrackerPlugin.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; + [OAUtilities showToast:@"AIS simulation" details:url.lastPathComponent duration:5 inView:toastView]; + }); +} + @end 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 b98c2eb360..17f1f15319 100644 --- a/Sources/Controllers/TargetMenu/OATargetMenuViewController.mm +++ b/Sources/Controllers/TargetMenu/OATargetMenuViewController.mm @@ -212,6 +212,12 @@ + (OATargetMenuViewController *)createMenuController:(OATargetPoint *)targetPoin controller = [[RenderedObjectViewController alloc] initWithRenderedObject:targetPoint.targetObj]; break; } + + case OATargetAisObject: + { + controller = [[AisObjectViewController 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/OsmAnd Maps-Bridging-Header.h b/Sources/OsmAnd Maps-Bridging-Header.h index 4e8a4f659d..31e5f62f36 100644 --- a/Sources/OsmAnd Maps-Bridging-Header.h +++ b/Sources/OsmAnd Maps-Bridging-Header.h @@ -157,6 +157,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 new file mode 100644 index 0000000000..b4d8594038 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisDataManager.swift @@ -0,0 +1,93 @@ +// +// AisDataManager.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import OsmAndShared + +@objcMembers +final class AisDataManager: NSObject { + private static let objectLimit = 200 + + 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() + } + + func startUpdates() { + stopUpdates() + let timer = Timer(timeInterval: 30, repeats: true) { [weak self] _ in + self?.removeLostObjects() + } + RunLoop.main.add(timer, forMode: .common) + cleanupTimer = timer + } + + func stopUpdates() { + cleanupTimer?.invalidate() + cleanupTimer = nil + } + + func cleanupResources() { + stopUpdates() + objectsByMmsi.removeAll() + plugin?.onAisObjectsChanged() + } + + func onAisObjectReceived(_ ais: AisObject) { + let object: AisObject + let event: String + let mmsi = Int(ais.mmsi) + if let existing = objectsByMmsi[mmsi] { + existing.set(ais: ais) + object = existing + event = "merge" + } else { + object = AisObject(ais: ais) + objectsByMmsi[mmsi] = object + event = "new" + } + if objectsByMmsi.count > Self.objectLimit { + removeOldestObject() + } + guard let storedObject = objectsByMmsi[Int(object.mmsi)], storedObject === object else { return } + if AisLogger.shared.isEnabled { + 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(maxAgeInMin: Int32(maxAge)) } + for object in removed { + objectsByMmsi.removeValue(forKey: Int(object.mmsi)) + if AisLogger.shared.isEnabled { + 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: Int(oldest.mmsi)) + 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/AisLogger.swift b/Sources/Plugins/AisTrackerPlugin/AisLogger.swift new file mode 100644 index 0000000000..42454f0d38 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisLogger.swift @@ -0,0 +1,33 @@ +// +// AisLogger.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +@objcMembers +final class AisLogger: NSObject { + + static let shared = AisLogger() + static let debugLoggingPrefId = "ais_debug_logging" + + var isEnabled: Bool { + didSet { + debugLoggingPref.set(isEnabled) + } + } + + private let debugLoggingPref: OACommonBoolean + + override private init() { + debugLoggingPref = OAAppSettings.sharedManager().registerBooleanPreference(Self.debugLoggingPrefId, defValue: false) + isEnabled = debugLoggingPref.get() + } + + func log(_ message: String) { + guard isEnabled else { return } + + debugPrint("[AIS] \(message)") + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift b/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift new file mode 100644 index 0000000000..408d693727 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisMessageDecoder.swift @@ -0,0 +1,28 @@ +// +// AisMessageDecoder.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import OsmAndShared + +final class AisMessageDecoder { + private let dataListener = AisSharedDataListener() + private lazy var listener = AisMessageListener(dataListener: dataListener) + + func decode(sentence: String) -> AisObject? { + dataListener.lastObject = nil + listener.processLine(line: sentence) + return dataListener.lastObject + } +} + +private final class AisSharedDataListener: NSObject, AisDataListener { + var lastObject: AisObject? + + func onAisObjectReceived(ais: AisObject) { + lastObject = ais + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/AisObjectHelper.swift b/Sources/Plugins/AisTrackerPlugin/AisObjectHelper.swift new file mode 100644 index 0000000000..6037c8ae83 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisObjectHelper.swift @@ -0,0 +1,66 @@ +// +// AisObjectHelper.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import CoreLocation +import OsmAndShared + +enum AisObjectHelper { + static func lastUpdateDate(_ object: AisObject) -> Foundation.Date { + Date(timeIntervalSince1970: TimeInterval(object.lastUpdate) / 1000.0) + } + + static func location(_ object: AisObject) -> CLLocation? { + guard let location = object.getAisLocation() else { return nil } + return makeLocation(location, timestamp: lastUpdateDate(object), altitude: object.altitude) + } + + 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) + } + + static func messageTypesString(_ object: AisObject) -> String { + let values = object.msgTypes.compactMap { ($0 as? KotlinInt).map { String($0.intValue) } } + return values.sorted().joined(separator: ", ") + } + + 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", + 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) + } + + static func debugLog(_ message: String) { + AisLogger.shared.log(message) + } + + 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) + } +} 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/AisSimulationProvider.swift b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift new file mode 100644 index 0000000000..266872538a --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisSimulationProvider.swift @@ -0,0 +1,221 @@ +// +// AisSimulationProvider.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import CoreLocation +import OsmAndShared + +final class AisMessageSimulationListener { + 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() + defer { lock.unlock() } + return cancelled + } + + init(plugin: AisTrackerPlugin, fileURL: URL, latency: TimeInterval) { + self.plugin = plugin + self.fileURL = fileURL + self.latency = latency + } + + func start() { + setCancelled(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.isCancelled { + return + } + Thread.sleep(forTimeInterval: self.latency) + if self.isCancelled { + return + } + DispatchQueue.main.async { [weak self] in + guard let self, !self.isCancelled, let plugin = self.plugin, plugin.isEnabled() else { return } + plugin.handleSimulatedNmeaSentence(sentence) + } + } + } + } + + func stop() { + setCancelled(true) + } + + private func setCancelled(_ cancelled: Bool) { + lock.lock() + self.cancelled = cancelled + lock.unlock() + } + + 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.position != nil else { + continue + } + decoded += 1 + mmsi.insert(Int(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) + } + } +} + +@objcMembers +final class AisSimulationProvider: NSObject { + private static let simulatedLatency: TimeInterval = 0.1 + + private var listener: AisMessageSimulationListener? + + private weak var plugin: AisTrackerPlugin? + + init(plugin: AisTrackerPlugin) { + self.plugin = plugin + super.init() + } + + func startAisSimulation(_ fileURL: URL) { + stopAisSimulation() + guard let plugin, plugin.isEnabled() 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() { + guard plugin?.isEnabled() == true else { return } + 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) + 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, + 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, 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, 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 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, 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, lat: 50.736, lon: 7.100) + plugin?.handleSimulatedAisObject(station) + + 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, 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, 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, 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 new file mode 100644 index 0000000000..46c517f6f4 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerHelper.swift @@ -0,0 +1,30 @@ +// +// AisTrackerHelper.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +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 { + 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) + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift new file mode 100644 index 0000000000..a492f183a0 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerPlugin.swift @@ -0,0 +1,439 @@ +// +// AisTrackerPlugin.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +import CoreLocation +import OsmAndShared + +extension Notification.Name { + static let aisNmeaConnectionStateChanged = Notification.Name("OAAisNmeaConnectionStateChanged") +} + +@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 +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 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 objectLostTimeoutPref: OACommonInteger + let shipLostTimeoutPref: OACommonInteger + 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) + + 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) + 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() + + applicationModeObserver = OAAutoObserverProxy(self, + withHandler: #selector(onApplicationModeChanged), + andObserve: OsmAndApp.swiftInstance().applicationModeChangedObservable) + } + + 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 { + kInAppId_Addon_Ais_Tracker + } + + 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? { + "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() + stopAisNetworkListener() + 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 startAisSimulation(_ fileURL: URL) { + guard isEnabled() else { return } + simulationFileName = 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 { + AisObjectHelper.debugLog("simulation status error=\(error)") + } else { + AisObjectHelper.debugLog("simulation stats sentences=\(sentences) decoded=\(decoded) objects=\(objects)") + } + } + + func prepareAisSimulation() { + stopAisNetworkListener() + aisDataManager.cleanupResources() + aisDataManager.startUpdates() + } + + func addTestSimulationObjects() { + simulationProvider.initFakePosition() + simulationProvider.initTestPassengerShip() + simulationProvider.initTestSailingBoat() + simulationProvider.initTestLandStation() + simulationProvider.initTestAircraft() + simulationProvider.initTestLawEnforcement() + } + + func clearSimulationObjects() { + simulationProvider.stopAisSimulation() + AisObjectHelper.debugLog("simulation clear") + fakeOwnLocation = nil + simulationFileName = nil + aisDataManager.cleanupResources() + } + + func restartConnection() { + guard isActiveForCurrentProfile() else { + stopAisNetworkListener() + return + } + aisDataManager.startUpdates() + let proto = AisNmeaProtocol(rawValue: Int(protocolPref.get())) ?? .udp + stopSharedNetworkListener(updateState: false) + updateConnectionState(.connecting) + switch proto { + case .udp: + let port = max(1, Int(udpPortPref.get())) + AisObjectHelper.debugLog("[AisTrackerPlugin] start shared AIS UDP port=\(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 = AisMessageListener(dataListener: networkDataListener, serverIp: host, serverPort: Int32(port)) + } + } + + func stopAisNetworkListener() { + stopSharedNetworkListener(updateState: true) + aisDataManager.stopUpdates() + } + + func fakeOwnPosition(_ location: CLLocation?) { + fakeOwnLocation = location + } + + func handleSimulatedNmeaSentence(_ sentence: String) { + handleAisSentence(sentence) + } + + 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 = AisObjectHelper.lastUpdateDate(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) { + if AisLogger.shared.isEnabled { + AisObjectHelper.debugLog("plugin removed \(AisObjectHelper.debugSummary(object))") + } + 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 != AisObjType.aisAirplane, + warningTime > 0, + object.sog > 0, + let ownPosition = ownPosition(), + 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.cpa) <= warningDistance + && object.cpa.tcpa * 60.0 <= Double(warningTime) + && object.cpa.t1 >= 0 + && object.cpa.t2 >= 0 + } + + func updateCpa(for object: AisObject) { + guard let ownPosition = ownPosition(), + let aisPosition = AisObjectHelper.currentLocation(object) ?? AisObjectHelper.location(object) else { + object.cpa.reset() + return + } + AisTrackerHelper.getCpa(ownPosition, aisPosition, result: object.cpa) + } + + func distanceInNauticalMiles(to object: AisObject) -> Double { + guard let ownPosition = ownPosition(), + let aisPosition = AisObjectHelper.currentLocation(object) ?? AisObjectHelper.location(object) else { + return -1 + } + return ownPosition.distance(from: aisPosition) / 1852.0 + } + + func bearing(to object: AisObject) -> Double { + guard let ownPosition = ownPosition(), + let aisPosition = AisObjectHelper.currentLocation(object) ?? AisObjectHelper.location(object) 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 updateConnectionForCurrentProfile() { + if isActiveForCurrentProfile() { + if networkListener == nil { + restartConnection() + } + } else { + stopAisNetworkListener() + } + } + + 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 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) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.updateConnectionState(state) + } + return + } + connectionState = state + NotificationCenter.default.post(name: .aisNmeaConnectionStateChanged, object: self) + } + + 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 { + 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/AisTrackerProduct.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerProduct.swift new file mode 100644 index 0000000000..6cdc7805a4 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerProduct.swift @@ -0,0 +1,38 @@ +// +// AisTrackerProduct.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 15.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +@objcMembers +final class AisTrackerProduct: OAProduct { + override var free: Bool { + true + } + + 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") + } + + override init() { + super.init(identifier: kInAppId_Addon_Ais_Tracker) + } + + override func productIconName() -> String { + "ic_plugin_nautical" + } + + override func productScreenshotName() -> String { + "ais_map" + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift new file mode 100644 index 0000000000..b776154bae --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/AisTrackerSettingsViewController.swift @@ -0,0 +1,375 @@ +// +// AisTrackerSettingsViewController.swift +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +@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: 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: AisTrackerPlugin) { + 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) + } + + 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(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) + guard self?.isValidIPv4(value) == true else { + self?.showValidationError(localizedString("ais_error_ipv4_only")) + 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 + 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, + sourceRow: indexPath) { [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, + sourceRow: indexPath) { [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, + sourceRow: indexPath) { [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, + sourceRow: indexPath) { [weak self] value in + self?.plugin.cpaWarningDistancePref.set(value) + self?.tableView.reloadData() + } + } + } + + 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)) + 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)) + presentActionSheet(alert, sourceRow: sourceRow) + } + + 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 + 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 + if 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) + return true + } else { + self.showValidationError(localizedString("ais_error_port_only")) + return false + } + } + } + + 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 + onSelect(value) + }) + } + alert.addAction(UIAlertAction(title: localizedString("shared_string_cancel"), style: .cancel)) + presentActionSheet(alert, sourceRow: sourceRow) + } + + 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 + onSelect(value) + }) + } + 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) + } + + 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) { + 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() { + tableView.reloadData() + } +} diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h new file mode 100644 index 0000000000..0341af9c2e --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.h @@ -0,0 +1,19 @@ +// +// OAAisTrackerLayer.h +// OsmAnd +// +// Created by Oleksandr Panchenko on 11.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +#import "OAMapLayer.h" +#import "OAContextMenuProvider.h" +#import "OsmAndSharedWrapper.h" + +@interface OAAisTrackerLayer : OAMapLayer + +- (void)reloadAisObjects; +- (void)onAisObjectReceived:(OASAisObject *)object; +- (void)onAisObjectRemoved:(OASAisObject *)object; + +@end diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm new file mode 100644 index 0000000000..9a8b6167b7 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayer.mm @@ -0,0 +1,1072 @@ +// +// 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" +#import "OAPluginsHelper.h" +#import "OATargetPoint.h" +#import "OAPointDescription.h" +#import "Localization.h" +#import "OAAppSettings.h" +#import "GeneratedAssetSymbols.h" +#import "OsmAnd_Maps-Swift.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static NSString * const kAisTrackerLayerId = @"ais_tracker_layer"; +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; +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)]; + 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(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) OASAisObject *object; +@property (nonatomic, copy) NSString *renderKey; + +- (instancetype)initWithObject:(OASAisObject *)object; +- (instancetype)initWithObject:(OASAisObject *)object + textScale:(CGFloat)textScale + displayDensityFactor:(CGFloat)displayDensityFactor; +- (void)set:(OASAisObject *)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; +- (void)updateAisRenderDataWithMapView:(OAMapRendererView *)mapView + plugin:(AisTrackerPlugin *)plugin; +- (void)clearAisRenderDataFromMarkersCollection:(const std::shared_ptr &)markersCollection + vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection; + +@end + +@implementation AisObjectDrawable +{ + std::shared_ptr _activeMarker; + std::shared_ptr _restMarker; + std::shared_ptr _lostMarker; + std::shared_ptr _directionLine; + CGFloat _textScale; + CGFloat _displayDensityFactor; + int _baseOrder; +} + +- (instancetype)initWithObject:(OASAisObject *)object +{ + return [self initWithObject:object textScale:1.0 displayDensityFactor:UIScreen.mainScreen.scale]; +} + +- (instancetype)initWithObject:(OASAisObject *)object + textScale:(CGFloat)textScale + displayDensityFactor:(CGFloat)displayDensityFactor +{ + self = [super init]; + if (self) + { + _object = object; + [self setTextScale:textScale displayDensityFactor:displayDensityFactor]; + } + return self; +} + +- (void)set:(OASAisObject *)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; +} + +- (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)]; +} + +- (OsmAnd::PointI)markerLocation +{ + CLLocation *location = OAAisObjectLocation(_object); + if (!location) + return OsmAnd::PointI(0, 0); + return OsmAnd::PointI(OsmAnd::Utilities::get31TileNumberX(location.coordinate.longitude), + 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); + [self setAisMarkersUpdateAfterCreated]; +} + +- (void)setAisMarkersUpdateAfterCreated +{ + if (_activeMarker) + _activeMarker->setUpdateAfterCreated(true); + if (_restMarker) + _restMarker->setUpdateAfterCreated(true); + if (_lostMarker) + _lostMarker->setUpdateAfterCreated(true); +} + +- (void)createAisRenderDataWithBaseOrder:(int)baseOrder + markersCollection:(const std::shared_ptr &)markersCollection + vectorLinesCollection:(const std::shared_ptr &)vectorLinesCollection +{ + 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(activeIcon)); + _activeMarker = markerBuilder.buildAndAddToCollection(markersCollection); + + markerBuilder + .setMarkerId(1) + .clearOnMapSurfaceIcons() + .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage(restIcon)); + _restMarker = markerBuilder.buildAndAddToCollection(markersCollection); + + markerBuilder + .setMarkerId(2) + .clearOnMapSurfaceIcons() + .addOnMapSurfaceIcon(kAisIconKey, OsmAnd::SingleSkImage(lostIcon)); + _lostMarker = markerBuilder.buildAndAddToCollection(markersCollection); + [self setAisMarkersUpdateAfterCreated]; + + QVector points; + points.push_back(markerLocation); + points.push_back(OsmAnd::PointI(markerLocation.x + 1, markerLocation.y + 1)); + + OsmAnd::VectorLineBuilder lineBuilder; + lineBuilder + .setLineId([self renderGroupId]) + .setBaseOrder(baseOrder + 10) + .setIsHidden(true) + .setLineWidth(6.0) + .setApproximationEnabled(false) + .setFillColor(OsmAnd::FColorARGB(1.0f, 0.0f, 0.0f, 0.0f)) + .setPoints(points); + _directionLine = lineBuilder.buildAndAddToCollection(vectorLinesCollection); + + _renderKey = [self currentRenderKey]; + if (![self hasAisRenderData]) + { + [self clearAisRenderDataFromMarkersCollection:markersCollection vectorLinesCollection:vectorLinesCollection]; + return; + } + [self updateAisRenderDataWithMapView:nil plugin:nil]; +} + +- (void)updateAisRenderDataWithMapView:(OAMapRendererView *)mapView + plugin:(AisTrackerPlugin *)plugin +{ + if (![self hasAisRenderData]) + return; + + const OsmAnd::ZoomLevel zoom = mapView ? mapView.zoomLevel : OsmAnd::ZoomLevel::MinZoomLevel; + if (!mapView || (int)zoom < kAisTrackerStartZoom || !_object.position) + { + [self setAisRenderDataHidden:YES]; + return; + } + + CLLocation *location = OAAisObjectLocation(_object); + if (!location) + { + [self setAisRenderDataHidden:YES]; + return; + } + + OsmAnd::PointI markerLocation = [self markerLocation]; + if (![mapView isPositionVisible:markerLocation]) + { + [self setAisRenderDataHidden:YES]; + return; + } + + NSInteger vesselLostTimeout = plugin ? [plugin vesselLostTimeoutInMinutes] : 0; + BOOL vesselAtRest = [_object isVesselAtRest]; + BOOL lostTimeout = vesselLostTimeout > 0 && [_object isLostMaxAgeInMin:(int32_t)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]; + _activeMarker->setOnSurfaceIconModulationColor(iconColor); + _restMarker->setOnSurfaceIconModulationColor(iconColor); + + _activeMarker->setIsHidden(vesselAtRest || lostTimeout); + _restMarker->setIsHidden(!vesselAtRest); + _lostMarker->setIsHidden(!lostTimeout); + + float rotation = fmod([_object getVesselRotation] + 180.0, 360.0); + if (!vesselAtRest && [self needRotation]) + { + _activeMarker->setOnMapSurfaceIconDirection(kAisIconKey, rotation); + _lostMarker->setOnMapSurfaceIconDirection(kAisIconKey, rotation); + } + _activeMarker->setPosition(markerLocation); + _restMarker->setPosition(markerLocation); + _lostMarker->setPosition(markerLocation); + [self setAisMarkersUpdateAfterCreated]; + + if (drawDirectionLine && _directionLine) + { + double inverseZoom = mapView.maxZoom - mapView.zoom; + double zoomFactor = std::pow(2.0, inverseZoom); + CGFloat iconSize = [self iconSize]; + 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); + int dx = (int)ceil(-sin(theta) * lineLength); + int dy = (int)ceil(cos(theta) * lineLength); + + QVector points; + 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); + } + if (_directionLine) + _directionLine->setIsHidden(!drawDirectionLine); +} + +- (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) + markersCollection->removeMarker(_restMarker); + if (_lostMarker) + markersCollection->removeMarker(_lostMarker); + } + if (vectorLinesCollection) + { + 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(); + _restMarker.reset(); + _lostMarker.reset(); + _directionLine.reset(); + _renderKey = nil; +} + +- (sk_sp)iconImageForState:(NSInteger)state +{ + CGFloat iconSize = [self iconSize]; + if (state != 1) + { + NSString *resourceName = state == 2 ? @"c_mx_ais_vessel_cross" : [self iconResourceNameForType:_object.objectClass]; + sk_sp image = OAAisCachedSvgImage(resourceName, iconSize); + if (image) + return image; + } + + NSString *drawnKeyName = [NSString stringWithFormat:@"%ld:%@", (long)state, _object.objectClass.name]; + 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; + 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) + { + UIBezierPath *outer = [UIBezierPath bezierPathWithOvalInRect:CGRectInset(bounds, 1, 1)]; + [[UIColor darkGrayColor] setFill]; + [outer fill]; + path = [UIBezierPath bezierPathWithOvalInRect:CGRectInset(bounds, 4 * sizeFactor, 4 * sizeFactor)]; + } + else if (OAAisTypeEquals(_object.objectClass, OASAisObjType.aisAton) || OAAisTypeEquals(_object.objectClass, OASAisObjType.aisAtonVirtual)) + { + 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) - 9 * sizeFactor)]; + [path addLineToPoint:CGPointMake(CGRectGetMinX(bounds), CGRectGetMaxY(bounds))]; + [path closePath]; + } + else + { + path = [UIBezierPath bezierPathWithRoundedRect:bounds cornerRadius:4]; + } + + [baseColor setFill]; + [strokeColor setStroke]; + path.lineWidth = 4 * sizeFactor; + [path fill]; + [path stroke]; + + if (OAAisTypeEquals(_object.objectClass, OASAisObjType.aisAtonVirtual) && state != 1) + { + UIBezierPath *plus = [UIBezierPath bezierPath]; + [plus moveToPoint:CGPointMake(CGRectGetMidX(bounds), CGRectGetMinY(bounds) + 12 * sizeFactor)]; + [plus addLineToPoint:CGPointMake(CGRectGetMidX(bounds), CGRectGetMaxY(bounds) - 12 * sizeFactor)]; + [plus moveToPoint:CGPointMake(CGRectGetMinX(bounds) + 12 * sizeFactor, CGRectGetMidY(bounds))]; + [plus addLineToPoint:CGPointMake(CGRectGetMaxX(bounds) - 12 * sizeFactor, CGRectGetMidY(bounds))]; + [strokeColor setStroke]; + plus.lineWidth = 3 * sizeFactor; + [plus stroke]; + } + + if (state == 2) + { + UIBezierPath *cross = [UIBezierPath bezierPath]; + [cross moveToPoint:CGPointMake(CGRectGetMinX(bounds) + 2 * sizeFactor, CGRectGetMinY(bounds) + 2 * sizeFactor)]; + [cross addLineToPoint:CGPointMake(CGRectGetMaxX(bounds) - 2 * sizeFactor, CGRectGetMaxY(bounds) - 2 * sizeFactor)]; + [cross moveToPoint:CGPointMake(CGRectGetMaxX(bounds) - 2 * sizeFactor, CGRectGetMinY(bounds) + 2 * sizeFactor)]; + [cross addLineToPoint:CGPointMake(CGRectGetMinX(bounds) + 2 * sizeFactor, CGRectGetMaxY(bounds) - 2 * sizeFactor)]; + [UIColor.blackColor setStroke]; + cross.lineWidth = 3 * sizeFactor; + [cross stroke]; + } + + UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + sk_sp skImage = [OANativeUtilities skImageFromCGImage:image.CGImage]; + if (skImage) + kAisImagesCache[drawnKey] = skImage; + return skImage; +} + +- (CGFloat)iconSize +{ + return kAisBaseIconSize * _textScale * _displayDensityFactor; +} + +- (UIColor *)colorForType:(OASAisObjType *)type +{ + if (OAAisTypeEquals(type, OASAisObjType.aisVessel)) return UIColor.greenColor; + if (OAAisTypeEquals(type, OASAisObjType.aisVesselSport)) return UIColor.yellowColor; + if (OAAisTypeEquals(type, OASAisObjType.aisVesselFast)) return UIColor.blueColor; + if (OAAisTypeEquals(type, OASAisObjType.aisVesselPassenger)) return UIColor.cyanColor; + if (OAAisTypeEquals(type, OASAisObjType.aisVesselFreight)) return UIColor.grayColor; + if (OAAisTypeEquals(type, OASAisObjType.aisVesselCommercial)) return UIColor.lightGrayColor; + if (OAAisTypeEquals(type, OASAisObjType.aisVesselAuthorities)) return [UIColor colorWithRed:0.33 green:0.42 blue:0.18 alpha:1.0]; + if (OAAisTypeEquals(type, OASAisObjType.aisVesselSar) || OAAisTypeEquals(type, OASAisObjType.aisSart)) return [UIColor colorWithRed:0.98 green:0.50 blue:0.45 alpha:1.0]; + if (OAAisTypeEquals(type, OASAisObjType.aisVesselOther)) return [UIColor colorWithRed:0.00 green:0.75 blue:1.00 alpha:1.0]; + if (OAAisTypeEquals(type, OASAisObjType.aisAirplane)) return [UIColor colorWithRed:0.45 green:0.27 blue:0.86 alpha:1.0]; + if (OAAisTypeEquals(type, OASAisObjType.aisAton) || OAAisTypeEquals(type, OASAisObjType.aisAtonVirtual)) return [UIColor colorWithRed:0.92 green:0.82 blue:0.14 alpha:1.0]; + if (OAAisTypeEquals(type, OASAisObjType.aisLandstation)) return [UIColor colorWithRed:0.45 green:0.45 blue:0.45 alpha:1.0]; + return [UIColor colorWithRed:0.04 green:0.62 blue:0.72 alpha:1.0]; +} + +- (NSString *)iconResourceNameForType:(OASAisObjType *)type +{ + if (OAAisTypeEquals(type, OASAisObjType.aisLandstation)) return @"c_mx_ais_land"; + if (OAAisTypeEquals(type, OASAisObjType.aisAirplane)) return @"c_mx_ais_plane"; + if (OAAisTypeEquals(type, OASAisObjType.aisSart)) return @"c_mx_ais_sar"; + if (OAAisTypeEquals(type, OASAisObjType.aisAton)) return @"c_mx_ais_aton"; + if (OAAisTypeEquals(type, OASAisObjType.aisAtonVirtual)) return @"c_mx_ais_aton_virt"; + return @"c_mx_ais_vessel"; +} + +- (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 != OASAisObjectConstants.shared.INVALID_COG) && (_object.cog != 0.0)) || + ((_object.heading != OASAisObjectConstants.shared.INVALID_HEADING) && (_object.heading != 0))) && [_object isMovable]; +} + +@end + +@interface OAAisTrackerLayer () + +- (BOOL)shouldUpdateRenderDataForViewport; + +@end + +@implementation OAAisTrackerLayer +{ + AisTrackerPlugin *_plugin; + NSMutableDictionary *_objectDrawables; + std::shared_ptr _markersCollection; + std::shared_ptr _vectorLinesCollection; + BOOL _collectionsAdded; + CGFloat _textScale; + CGFloat _displayDensityFactor; + BOOL _hasLastRenderViewport; + OsmAnd::AreaI _lastRenderBBox31; + int _lastRenderZoom; + float _lastRenderSurfaceZoom; + NSTimeInterval _lastViewportRenderUpdateTime; +} + +- (instancetype)initWithMapViewController:(OAMapViewController *)mapViewController baseOrder:(int)baseOrder +{ + self = [super initWithMapViewController:mapViewController baseOrder:baseOrder]; + if (self) + { + _plugin = (AisTrackerPlugin *)[OAPluginsHelper getPlugin:AisTrackerPlugin.class]; + _objectDrawables = [NSMutableDictionary dictionary]; + _textScale = [OAAisTrackerLayer currentTextScale]; + _displayDensityFactor = MAX(1.0, mapViewController.displayDensityFactor); + _hasLastRenderViewport = NO; + _lastRenderZoom = -1; + _lastRenderSurfaceZoom = -1.0f; + _lastViewportRenderUpdateTime = 0; + } + return self; +} + +- (NSString *)layerId +{ + return kAisTrackerLayerId; +} + +- (AisTrackerPlugin *)plugin +{ + if (!_plugin) + _plugin = (AisTrackerPlugin *)[OAPluginsHelper getPlugin:AisTrackerPlugin.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 resetCollections]; + [self.app.data.mapLayersConfiguration setLayer:self.layerId + Visibility:self.isVisible]; + +} + +- (void)deinitLayer +{ + [self cleanupResources]; + [super deinitLayer]; +} + +- (BOOL)isVisible +{ + return [[self plugin] isActiveForCurrentProfile]; +} + +- (void)show +{ + [self addCollectionsToRenderer]; + [self reloadObjects]; +} + +- (void)hide +{ + [self removeCollectionsFromRenderer]; +} + +- (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]; + if ([self isVisible]) + { + [self addCollectionsToRenderer]; + [self reloadObjects]; + } + else + { + [self removeCollectionsFromRenderer]; + } + return YES; +} + +- (void)onMapFrameRendered +{ + if (![self isVisible]) + { + if (_collectionsAdded || _objectDrawables.count > 0) + { + kAisImagesCache.clear(); + [self cleanupResources]; + } + return; + } + if (![self shouldUpdateRenderDataForViewport]) + return; + [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.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]; + _hasLastRenderViewport = NO; + _lastViewportRenderUpdateTime = 0; + [self resetCollections]; +} + +- (void)reloadAisObjects +{ + [self cleanupResources]; + if ([self isVisible]) + { + [self addCollectionsToRenderer]; + [self reloadObjects]; + } +} + +- (void)reloadObjects +{ + if (![self isVisible]) + return; + + [self.mapViewController runWithRenderSync:^{ + [self reloadObjectsSync]; + }]; +} + +- (void)reloadObjectsSync +{ + [self ensureObjectDrawables]; + AisTrackerPlugin *plugin = [self plugin]; + NSArray *objects = [plugin getAisObjects]; + NSMutableSet *visibleMmsi = [NSMutableSet set]; + for (OASAisObject *object in objects) + { + if (!object.position) + continue; + + NSNumber *key = @(object.mmsi); + [visibleMmsi addObject:key]; + 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 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]; + [drawable updateAisRenderDataWithMapView:self.mapView plugin:plugin]; + } + + for (NSNumber *key in [_objectDrawables.allKeys copy]) + { + if (![visibleMmsi containsObject:key]) + { + [_objectDrawables[key] clearAisRenderDataFromMarkersCollection:_markersCollection vectorLinesCollection:_vectorLinesCollection]; + [_objectDrawables removeObjectForKey:key]; + } + } +} + +- (void)onAisObjectReceived:(OASAisObject *)object +{ + if (![self isVisible] || !object.position) + return; + if ([AisLogger shared].isEnabled) + [[AisLogger shared] log:[NSString stringWithFormat:@"receive %@", OAAisDebugSummary(object)]]; + [self addCollectionsToRenderer]; + [self.mapViewController runWithRenderSync:^{ + [self updateAisObjectSync:object]; + }]; +} + +- (void)onAisObjectRemoved:(OASAisObject *)object +{ + if (!object) + return; + + [self.mapViewController runWithRenderSync:^{ + NSNumber *key = @(object.mmsi); + AisObjectDrawable *drawable = _objectDrawables[key]; + 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]; + [_objectDrawables removeObjectForKey:key]; + } + }]; +} + +- (void)updateAisObjectSync:(OASAisObject *)object +{ + [self ensureObjectDrawables]; + 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 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; + + 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 +{ + if (![self isVisible]) + return; + + AisTrackerPlugin *plugin = [self plugin]; + for (NSNumber *key in [_objectDrawables.allKeys copy]) + [_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; + 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 (!surfaceZoomChanged && _hasLastRenderViewport && now - _lastViewportRenderUpdateTime < kAisViewportRenderUpdateInterval) + return NO; + + _lastRenderBBox31 = visibleBBox31; + _lastRenderZoom = zoom; + _lastRenderSurfaceZoom = surfaceZoom; + _hasLastRenderViewport = YES; + _lastViewportRenderUpdateTime = now; + return YES; + } + return NO; +} + +#pragma mark - OAContextMenuProvider + +- (OATargetPoint *)getTargetPoint:(id)obj touchLocation:(CLLocation *)touchLocation +{ + if (![obj isKindOfClass:OASAisObject.class] || !((OASAisObject *)obj).position) + return nil; + + OASAisObject *object = obj; + CLLocation *location = OAAisObjectLocation(object); + if (!location) + return nil; + + OATargetPoint *targetPoint = [[OATargetPoint alloc] init]; + targetPoint.type = OATargetAisObject; + targetPoint.targetObj = object; + targetPoint.title = OAAisObjectTitle(object); + targetPoint.titleSecond = nil; + NSString *navStatus = [object getNavStatusString]; + targetPoint.titleAddress = navStatus.length > 0 ? navStatus : nil; + targetPoint.shouldFetchAddress = NO; + targetPoint.location = location.coordinate; + + targetPoint.icon = [[UIImage imageNamed:ACImageNameIcActionSailBoatDark] + imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal]; + targetPoint.sortIndex = OATargetAisObject; + targetPoint.centerMap = NO; + return targetPoint; +} + +- (OATargetPoint *)getTargetPointCpp:(const void *)obj +{ + return nil; +} + +- (BOOL)isSecondaryProvider +{ + return NO; +} + +- (CLLocation *)getObjectLocation:(id)obj +{ + if (![obj isKindOfClass:OASAisObject.class] || !((OASAisObject *)obj).position) + return nil; + OASAisObject *object = obj; + return OAAisObjectLocation(object); +} + +- (OAPointDescription *)getObjectName:(id)obj +{ + if (![obj isKindOfClass:OASAisObject.class]) + return nil; + OASAisObject *object = obj; + return [[OAPointDescription alloc] initWithType:POINT_TYPE_LOCATION typeName:OALocalizedString(@"ais_type_object") name:OAAisObjectTitle(object)]; +} + +- (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 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 + right:point.x + radius + bottom:point.y + radius]; + if (touchPolygon31.isEmpty()) + return; + + NSArray *objects = [[self plugin] getAisObjects]; + for (OASAisObject *object in objects) + { + CLLocation *location = OAAisObjectLocation(object); + if (!location) + continue; + + if ([OANativeUtilities isPointInsidePolygonLat:location.coordinate.latitude + lon:location.coordinate.longitude + polygon31:touchPolygon31]) + { + [result collect:object provider:self]; + } + } +} + +- (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"); +} + +@end diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.h b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.h new file mode 100644 index 0000000000..499b63ea02 --- /dev/null +++ b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.h @@ -0,0 +1,18 @@ +// +// OAAisTrackerLayerBridge.h +// OsmAnd +// +// Created by OpenAI on 12.06.2026. +// Copyright © 2026 OsmAnd. All rights reserved. +// + +#import +#import "OsmAndSharedWrapper.h" + +@interface OAAisTrackerLayerBridge : NSObject + ++ (void)reloadAisObjects; ++ (void)onAisObjectReceived:(OASAisObject *)object; ++ (void)onAisObjectRemoved:(OASAisObject *)object; + +@end diff --git a/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.mm b/Sources/Plugins/AisTrackerPlugin/OAAisTrackerLayerBridge.mm new file mode 100644 index 0000000000..1762b83e8d --- /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:(OASAisObject *)object +{ + [[self aisTrackerLayer] onAisObjectReceived:object]; +} + ++ (void)onAisObjectRemoved:(OASAisObject *)object +{ + [[self aisTrackerLayer] onAisObjectRemoved:object]; +} + +@end diff --git a/Sources/Plugins/OAPluginsHelper.mm b/Sources/Plugins/OAPluginsHelper.mm index 49d3be8d90..2c0fcfba9b 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:[AisTrackerPlugin new]]; [allPlugins addObject:[VehicleMetricsPlugin new]]; [allPlugins addObject:[[OAOsmandDevelopmentPlugin alloc] init]]; diff --git a/Sources/Purchases/OAProducts.h b/Sources/Purchases/OAProducts.h index 4471a0b99b..9c1f28f813 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 @@ -407,6 +408,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..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() @@ -2859,6 +2860,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 +2912,7 @@ - (instancetype) init self.weather = [[OAWeatherProduct alloc] init]; self.sensors = [[OAExternalSensorsProduct alloc] init]; self.vehicleMetrics = [OAVehicleMetricsProduct new]; + self.aisTracker = [AisTrackerProduct new]; self.carplay = [[OACarPlayProduct alloc] init]; self.osmandDevelopment = [[OAOsmandDevelopmentProduct alloc] init]; @@ -2935,6 +2938,7 @@ - (instancetype) init self.weather, self.sensors, self.vehicleMetrics, + self.aisTracker, self.osmandDevelopment ]; diff --git a/Sources/Services/OALocationServices.mm b/Sources/Services/OALocationServices.mm index 10b8ad3306..acd0899bd7 100644 --- a/Sources/Services/OALocationServices.mm +++ b/Sources/Services/OALocationServices.mm @@ -756,7 +756,7 @@ - (void) locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArra if (!locations || ![locations lastObject] || [_locationSimulation isRouteAnimating]) return; - + BOOL wasLocationUnknown = (_lastLocation == nil); [self setLocation:[locations lastObject]];