From c8ae5fc341bb1ed0b2331485790b88d75b91fd9e Mon Sep 17 00:00:00 2001 From: grigo Date: Thu, 21 May 2026 12:38:18 +0300 Subject: [PATCH] Added radar --- .../com/grigowashere/aismap/MainActivity.java | 87 ++++++++-- .../aismap/controllers/AppCoordinator.java | 26 ++- .../aismap/maps/MapLibreMapImpl.java | 8 + .../aismap/maps/RadarMapHelper.java | 80 ++++++++- .../aismap/ui/BottomSheetsManager.java | 52 +++++- .../aismap/utils/AismapLocalHttpsProbe.java | 110 +++++++++++++ .../grigowashere/aismap/utils/RangeMath.java | 155 ++++++++++++++++++ .../aismap/view/DangerTargetsDockWidget.java | 62 +++++-- .../aismap/view/PlotterTargetsTableView.java | 23 ++- app/src/main/res/layout/activity_main.xml | 1 + .../res/layout/bottom_sheet_ais_vessel.xml | 12 ++ app/src/main/res/values/strings.xml | 8 +- .../aismap/utils/RangeMathTest.java | 56 +++++++ 13 files changed, 630 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/com/grigowashere/aismap/utils/AismapLocalHttpsProbe.java diff --git a/app/src/main/java/com/grigowashere/aismap/MainActivity.java b/app/src/main/java/com/grigowashere/aismap/MainActivity.java index 40d714c..f0820ab 100644 --- a/app/src/main/java/com/grigowashere/aismap/MainActivity.java +++ b/app/src/main/java/com/grigowashere/aismap/MainActivity.java @@ -5,6 +5,7 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.os.Bundle; import android.os.Looper; +import android.os.SystemClock; import android.util.Log; import android.util.Printer; import android.view.Menu; @@ -43,6 +44,7 @@ import com.grigowashere.aismap.view.BaseDockWidget; import com.grigowashere.aismap.ui.MenuBinder; import com.grigowashere.aismap.ui.BottomSheetsBinder; import com.grigowashere.aismap.ui.PermissionsBinder; +import com.grigowashere.aismap.utils.AismapLocalHttpsProbe; import com.grigowashere.aismap.utils.SettingsManager; import com.grigowashere.aismap.utils.LogSender; import com.grigowashere.aismap.utils.MIDToCountry; @@ -54,6 +56,9 @@ import org.maplibre.android.maps.MapView; import org.maplibre.android.MapLibre; import java.util.List; import java.util.ArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; import com.grigowashere.aismap.controllers.ControllersFactory; import com.grigowashere.aismap.controllers.DefaultControllersFactory; @@ -119,6 +124,12 @@ public class MainActivity extends AppCompatActivity { private TextView tvBleRssi; private TextView tvBleBatt; private TextView tvFps; + /** GET {@link AismapLocalHttpsProbe#PROBE_URL} on a worker; line shown under BLE RSSI. */ + private ExecutorService localHttpsProbeExecutor; + private final AtomicBoolean localHttpsProbeInFlight = new AtomicBoolean(false); + private volatile String localHttpsProbeStatusLine = "HTTPS aismap.local: —"; + private long nextLocalHttpsProbeElapsedMs = 0L; + private static final long LOCAL_HTTPS_PROBE_INTERVAL_MS = 12_000L; private int frameCount = 0; private long lastFpsTs = 0L; private final android.view.Choreographer.FrameCallback fpsCallback = new android.view.Choreographer.FrameCallback() { @@ -487,6 +498,14 @@ public class MainActivity extends AppCompatActivity { } private void setupMessageAgesUpdater() { + if (localHttpsProbeExecutor == null) { + localHttpsProbeExecutor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "aismap-local-https-probe"); + t.setDaemon(true); + return t; + }); + nextLocalHttpsProbeElapsedMs = 0L; + } messageAgeHandler = new android.os.Handler(android.os.Looper.getMainLooper()); messageAgeRunnable = new Runnable() { @Override @@ -506,13 +525,17 @@ public class MainActivity extends AppCompatActivity { } if (tvBleRssi != null) { Integer rssi = appCoordinator.getLastBleRssi(); + String bleLine; + int bleColor; if (rssi != null) { - tvBleRssi.setText("BLE RSSI: " + rssi); - tvBleRssi.setTextColor(getRssiColor(rssi)); + bleLine = "BLE RSSI: " + rssi; + bleColor = getRssiColor(rssi); } else { - tvBleRssi.setText("BLE RSSI: --"); - tvBleRssi.setTextColor(android.graphics.Color.parseColor("#F44336")); + bleLine = "BLE RSSI: --"; + bleColor = android.graphics.Color.parseColor("#F44336"); } + tvBleRssi.setText(bleLine + "\n" + localHttpsProbeStatusLine); + tvBleRssi.setTextColor(bleColor); } if (tvBleBatt != null) { Integer batt = appCoordinator.getLastBleBattery(); @@ -521,6 +544,11 @@ public class MainActivity extends AppCompatActivity { updateBleLinkLostBanner(); updateDangerWidget(); } + if (tvBleRssi != null && appCoordinator == null) { + tvBleRssi.setText("BLE RSSI: --\n" + localHttpsProbeStatusLine); + tvBleRssi.setTextColor(android.graphics.Color.parseColor("#F44336")); + } + triggerLocalHttpsProbeIfDue(); } catch (Exception ignored) {} messageAgeHandler.postDelayed(this, 1000); } @@ -529,6 +557,35 @@ public class MainActivity extends AppCompatActivity { messageAgeHandler.postDelayed(messageAgeRunnable, 1000); } + /** + * Throttled HTTPS GET to {@code aismap.local} on a worker; updates {@link #localHttpsProbeStatusLine} + * on the UI thread (shown with BLE RSSI). + */ + private void triggerLocalHttpsProbeIfDue() { + if (localHttpsProbeInFlight.get()) return; + long now = SystemClock.elapsedRealtime(); + if (now < nextLocalHttpsProbeElapsedMs) return; + ExecutorService ex = localHttpsProbeExecutor; + if (ex == null || ex.isShutdown()) return; + if (!localHttpsProbeInFlight.compareAndSet(false, true)) return; + nextLocalHttpsProbeElapsedMs = now + LOCAL_HTTPS_PROBE_INTERVAL_MS; + ex.execute(() -> { + try { + final String line = AismapLocalHttpsProbe.probeOnce(); + runOnUiThread(() -> { + localHttpsProbeStatusLine = line; + localHttpsProbeInFlight.set(false); + }); + } catch (Throwable t) { + Log.w(TAG, "local HTTPS probe worker", t); + runOnUiThread(() -> { + localHttpsProbeStatusLine = "HTTPS aismap.local: " + t.getClass().getSimpleName(); + localHttpsProbeInFlight.set(false); + }); + } + }); + } + /** * Баннер «нет связи с устройством» — только при потере BLE GATT-сессии с AIS Hub. * Возраст GPS/AIS здесь не используется: при обрыве линка данным доверять нельзя, @@ -571,19 +628,20 @@ public class MainActivity extends AppCompatActivity { } /** - * Обновляет таблицу «Опасные цели» каждую секунду из AppCoordinator. - * Если опасных целей нет (или функция отключена в настройках) — виджет - * полностью скрывается, чтобы не занимать место на карте. + * Обновляет таблицу «Ближайшие цели» каждую секунду из AppCoordinator. + * Цели берутся из жёлтой зоны предупреждения, а не из красного круга + * опасности. Если целей нет (или кольца отключены) — виджет полностью + * скрывается, чтобы не занимать место на карте. */ private void updateDangerWidget() { if (dangerWidget == null || appCoordinator == null || settingsManager == null) return; boolean enabled = settingsManager.isRangeRingsEnabled(); - double dangerR = enabled ? settingsManager.getDangerRadiusMeters() : 0.0; + double nearestR = enabled ? settingsManager.getWarningRadiusMeters() : 0.0; java.util.List uiEntries = new java.util.ArrayList<>(); - if (enabled && dangerR > 0.0) { + if (enabled && nearestR > 0.0) { java.util.List entries = - appCoordinator.getDangerTargets(dangerR, 5); + appCoordinator.getDangerTargets(nearestR, 5); if (entries != null) { for (com.grigowashere.aismap.controllers.AppCoordinator.DangerEntry e : entries) { if (e == null || e.vessel == null) continue; @@ -592,7 +650,8 @@ public class MainActivity extends AppCompatActivity { label = e.vessel.getMmsi() != null ? e.vessel.getMmsi() : "—"; } uiEntries.add(new com.grigowashere.aismap.view.DangerTargetsDockWidget.DangerEntry( - label, e.bearingDegrees, e.distanceMeters)); + label, e.bearingDegrees, e.distanceMeters, + e.cpaValid, e.cpaMeters, e.tcpaMinutes)); } } } @@ -1868,6 +1927,12 @@ public class MainActivity extends AppCompatActivity { messageAgeHandler.removeCallbacks(messageAgeRunnable); Log.i(TAG, "messageAgeHandler остановлен"); } + if (localHttpsProbeExecutor != null) { + try { + localHttpsProbeExecutor.shutdownNow(); + } catch (Throwable ignore) {} + localHttpsProbeExecutor = null; + } // Останавливаем UI watchdog if (uiWatchdogHandler != null && uiWatchdogRunnable != null) { diff --git a/app/src/main/java/com/grigowashere/aismap/controllers/AppCoordinator.java b/app/src/main/java/com/grigowashere/aismap/controllers/AppCoordinator.java index 58149b1..7375a69 100644 --- a/app/src/main/java/com/grigowashere/aismap/controllers/AppCoordinator.java +++ b/app/src/main/java/com/grigowashere/aismap/controllers/AppCoordinator.java @@ -1265,11 +1265,24 @@ public class AppCoordinator implements public final double distanceMeters; /** Пеленг от собственного судна на цель, °. */ public final double bearingDegrees; + /** CPA, м; {@link Double#NaN} если расчёт невозможен. */ + public final double cpaMeters; + /** TCPA, мин; {@link Double#NaN} если расчёт невозможен. */ + public final double tcpaMinutes; + public final boolean cpaValid; public DangerEntry(AISVessel vessel, double distanceMeters, double bearingDegrees) { + this(vessel, distanceMeters, bearingDegrees, false, Double.NaN, Double.NaN); + } + + public DangerEntry(AISVessel vessel, double distanceMeters, double bearingDegrees, + boolean cpaValid, double cpaMeters, double tcpaMinutes) { this.vessel = vessel; this.distanceMeters = distanceMeters; this.bearingDegrees = bearingDegrees; + this.cpaValid = cpaValid; + this.cpaMeters = cpaMeters; + this.tcpaMinutes = tcpaMinutes; } } @@ -1288,6 +1301,9 @@ public class AppCoordinator implements double oLat = ownVessel.getLatitude(); double oLon = ownVessel.getLongitude(); if (!com.grigowashere.aismap.utils.GeoUtils.isValidCoordinates(oLat, oLon)) return result; + double ownSog = ownVessel.getSpeed(); + double ownCog = ownVessel.getCourse(); + double ownHdg = ownVessel.getHeading(); synchronized (aisVessels) { for (AISVessel vessel : aisVessels.values()) { @@ -1298,7 +1314,15 @@ public class AppCoordinator implements double d = com.grigowashere.aismap.utils.GeoUtils.calculateDistance(oLat, oLon, lat, lon); if (d <= maxRadiusMeters) { double b = com.grigowashere.aismap.utils.GeoUtils.calculateBearing(oLat, oLon, lat, lon); - result.add(new DangerEntry(vessel, d, b)); + com.grigowashere.aismap.utils.RangeMath.CpaResult cpa = + com.grigowashere.aismap.utils.RangeMath.calculateCpa( + oLat, oLon, ownSog, ownCog, ownHdg, + lat, lon, vessel.getSpeed(), vessel.getCourse(), vessel.getHeading()); + if (cpa.valid) { + result.add(new DangerEntry(vessel, d, b, true, cpa.cpaMeters, cpa.tcpaMinutes)); + } else { + result.add(new DangerEntry(vessel, d, b)); + } } } } diff --git a/app/src/main/java/com/grigowashere/aismap/maps/MapLibreMapImpl.java b/app/src/main/java/com/grigowashere/aismap/maps/MapLibreMapImpl.java index 59218f0..6ce93ff 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/MapLibreMapImpl.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/MapLibreMapImpl.java @@ -649,6 +649,11 @@ public class MapLibreMapImpl implements MapInterface { } catch (Exception ignore) {} idToFeature.put(vessel.getMmsi(), feature); + if (PATH_FEATURES_ENABLED) { + String mmsi = vessel.getMmsi(); + updateAISPathSource(mmsi); + updateAISVesselPredictionSource(mmsi, vessel); + } updated++; } @@ -2456,6 +2461,9 @@ public class MapLibreMapImpl implements MapInterface { // Обновляем путь этого AIS судна updateAISVesselPath(mmsi, aisPathController); } + if (PATH_FEATURES_ENABLED) { + updateAISVesselPredictionSource(mmsi, vessel); + } } } catch (Exception e) { Log.e(TAG, "Ошибка обновления путей AIS судов: " + e.getMessage(), e); diff --git a/app/src/main/java/com/grigowashere/aismap/maps/RadarMapHelper.java b/app/src/main/java/com/grigowashere/aismap/maps/RadarMapHelper.java index 02a4c6f..f9fd900 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/RadarMapHelper.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/RadarMapHelper.java @@ -1,7 +1,11 @@ package com.grigowashere.aismap.maps; +import android.os.Handler; +import android.os.Looper; import android.util.Log; +import com.grigowashere.aismap.utils.NavigatorZoomMath; + import org.maplibre.android.camera.CameraPosition; import org.maplibre.android.camera.CameraUpdateFactory; import org.maplibre.android.geometry.LatLng; @@ -17,10 +21,24 @@ public class RadarMapHelper { private static final String TAG = "RadarMapHelper"; private static final String STYLE_URL = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; + private static final long FRAME_MS = 16L; + private static final float POSITION_ALPHA = 0.20f; + private static final float ZOOM_ALPHA = 0.14f; + /** Как в {@link com.grigowashere.aismap.controllers.NavigatorCameraController}. */ + private static final float BEARING_ALPHA = 0.09f; private final MapView mapView; + private final Handler handler = new Handler(Looper.getMainLooper()); private MapLibreMap map; private boolean styleLoaded; + private boolean followLoopRunning; + + private double targetLat = Double.NaN; + private double targetLon = Double.NaN; + private float targetBearing; + private double targetRangeMeters = 1000.0; + + private final Runnable followLoopRunnable = this::onFollowFrame; public RadarMapHelper(MapView mapView) { this.mapView = mapView; @@ -58,6 +76,7 @@ public class RadarMapHelper { } public void onPause() { + stopFollowLoop(); mapView.onPause(); } @@ -66,6 +85,7 @@ public class RadarMapHelper { } public void onDestroy() { + stopFollowLoop(); mapView.onDestroy(); } @@ -79,20 +99,64 @@ public class RadarMapHelper { /** * Центрирует карту на собственном судне; bearing задаёт режим «курс вверх». + * Поворот и смещение камеры сглаживаются так же, как в режиме навигатора. * * @param rangeMeters радиус PPI для подбора зума */ public void centerOnOwnShip(double lat, double lon, float bearingDeg, double rangeMeters) { if (map == null || !styleLoaded) return; if (Double.isNaN(lat) || Double.isNaN(lon)) return; - double zoom = zoomForRangeMeters(rangeMeters); - CameraPosition position = new CameraPosition.Builder() - .target(new LatLng(lat, lon)) - .zoom(zoom) - .bearing(bearingDeg) - .tilt(0.0) - .build(); - map.easeCamera(CameraUpdateFactory.newCameraPosition(position), 400); + targetLat = lat; + targetLon = lon; + targetBearing = bearingDeg; + if (rangeMeters > 0.0) { + targetRangeMeters = rangeMeters; + } + startFollowLoop(); + } + + private void startFollowLoop() { + if (followLoopRunning) return; + followLoopRunning = true; + handler.removeCallbacks(followLoopRunnable); + handler.post(followLoopRunnable); + } + + private void stopFollowLoop() { + followLoopRunning = false; + handler.removeCallbacks(followLoopRunnable); + } + + private void onFollowFrame() { + if (!followLoopRunning || map == null || !styleLoaded || Double.isNaN(targetLat)) { + followLoopRunning = false; + return; + } + + try { + CameraPosition current = map.getCameraPosition(); + double curLat = current.target.getLatitude(); + double curLon = current.target.getLongitude(); + float curBearing = (float) current.bearing; + double targetZoom = zoomForRangeMeters(targetRangeMeters); + + double newLat = NavigatorZoomMath.lerp(curLat, targetLat, POSITION_ALPHA); + double newLon = NavigatorZoomMath.lerp(curLon, targetLon, POSITION_ALPHA); + float newBearing = NavigatorZoomMath.lerpBearing(curBearing, targetBearing, BEARING_ALPHA); + double newZoom = NavigatorZoomMath.lerp(current.zoom, targetZoom, ZOOM_ALPHA); + + CameraPosition position = new CameraPosition.Builder() + .target(new LatLng(newLat, newLon)) + .zoom(newZoom) + .bearing(newBearing) + .tilt(0.0) + .build(); + map.moveCamera(CameraUpdateFactory.newCameraPosition(position)); + } catch (Exception e) { + Log.w(TAG, "onFollowFrame: " + e.getMessage()); + } + + handler.postDelayed(followLoopRunnable, FRAME_MS); } /** Подбирает зум так, чтобы весь радиус PPI помещался в круговой области. */ diff --git a/app/src/main/java/com/grigowashere/aismap/ui/BottomSheetsManager.java b/app/src/main/java/com/grigowashere/aismap/ui/BottomSheetsManager.java index 5342a77..00fb226 100644 --- a/app/src/main/java/com/grigowashere/aismap/ui/BottomSheetsManager.java +++ b/app/src/main/java/com/grigowashere/aismap/ui/BottomSheetsManager.java @@ -191,6 +191,7 @@ public class BottomSheetsManager { TextView tvSignal = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_signal); TextView tvDistance = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_distance); TextView tvBearing = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_bearing); + TextView tvCpa = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_cpa); TextView tvLastUpdate = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_last_update); TextView tvTimeAgo = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_time_ago); @@ -233,17 +234,52 @@ public class BottomSheetsManager { } if (tvLastUpdate != null) tvLastUpdate.setText(vessel.getLastUpdate() != null ? String.format("Обновлено: %s", vessel.getLastUpdate().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"))) : "Обновлено: --"); - if (tvDistance != null || tvBearing != null) { - Vessel ourVessel = appCoordinator.getOwnVessel(); - if (ourVessel != null && ourVessel.getLatitude() != 0 && ourVessel.getLongitude() != 0 && vessel.getLatitude() != 0 && vessel.getLongitude() != 0) { - double distance = com.grigowashere.aismap.utils.NavigationUtils.calculateDistance(ourVessel.getLatitude(), ourVessel.getLongitude(), vessel.getLatitude(), vessel.getLongitude()); - if (tvDistance != null) tvDistance.setText("Расстояние: " + com.grigowashere.aismap.utils.NavigationUtils.formatDistance(distance)); - double bearing = com.grigowashere.aismap.utils.NavigationUtils.calculateBearing(ourVessel.getLatitude(), ourVessel.getLongitude(), vessel.getLatitude(), vessel.getLongitude()); - double relativeBearing = com.grigowashere.aismap.utils.NavigationUtils.calculateRelativeBearing(ourVessel.getCourse(), bearing); - if (tvBearing != null) tvBearing.setText("Пеленг: " + com.grigowashere.aismap.utils.NavigationUtils.formatRelativeBearing(relativeBearing)); + if (tvDistance != null || tvBearing != null || tvCpa != null) { + Vessel ourVessel = appCoordinator.getOwnVessel(); + String cpaNa = context.getString(R.string.cpa_na); + if (ourVessel != null && ourVessel.getLatitude() != 0 && ourVessel.getLongitude() != 0 + && vessel.getLatitude() != 0 && vessel.getLongitude() != 0) { + double distance = com.grigowashere.aismap.utils.NavigationUtils.calculateDistance( + ourVessel.getLatitude(), ourVessel.getLongitude(), + vessel.getLatitude(), vessel.getLongitude()); + if (tvDistance != null) { + tvDistance.setText("Расстояние: " + + com.grigowashere.aismap.utils.NavigationUtils.formatDistance(distance)); + } + double bearing = com.grigowashere.aismap.utils.NavigationUtils.calculateBearing( + ourVessel.getLatitude(), ourVessel.getLongitude(), + vessel.getLatitude(), vessel.getLongitude()); + double relativeBearing = com.grigowashere.aismap.utils.NavigationUtils.calculateRelativeBearing( + ourVessel.getCourse(), bearing); + if (tvBearing != null) { + tvBearing.setText("Пеленг: " + + com.grigowashere.aismap.utils.NavigationUtils.formatRelativeBearing(relativeBearing)); + } + if (tvCpa != null) { + com.grigowashere.aismap.utils.SettingsManager sm = + new com.grigowashere.aismap.utils.SettingsManager(context); + boolean useNm = com.grigowashere.aismap.utils.SettingsManager.RANGE_UNIT_NM + .equals(sm.getRangeUnit()); + com.grigowashere.aismap.utils.RangeMath.CpaResult cpa = + com.grigowashere.aismap.utils.RangeMath.calculateCpa( + ourVessel.getLatitude(), ourVessel.getLongitude(), + ourVessel.getSpeed(), ourVessel.getCourse(), ourVessel.getHeading(), + vessel.getLatitude(), vessel.getLongitude(), + vessel.getSpeed(), vessel.getCourse(), vessel.getHeading()); + if (cpa.valid) { + String cpaDist = com.grigowashere.aismap.utils.RangeMath.formatCpaDistance( + cpa.cpaMeters, useNm, java.util.Locale.getDefault()); + String tcpa = com.grigowashere.aismap.utils.RangeMath.formatTcpa( + cpa.tcpaMinutes, java.util.Locale.getDefault()); + tvCpa.setText(context.getString(R.string.bottom_sheet_ais_cpa, cpaDist, tcpa)); + } else { + tvCpa.setText(context.getString(R.string.bottom_sheet_ais_cpa, cpaNa, cpaNa)); + } + } } else { if (tvDistance != null) tvDistance.setText("Расстояние: --"); if (tvBearing != null) tvBearing.setText("Пеленг: --"); + if (tvCpa != null) tvCpa.setText(context.getString(R.string.bottom_sheet_ais_cpa, cpaNa, cpaNa)); } } diff --git a/app/src/main/java/com/grigowashere/aismap/utils/AismapLocalHttpsProbe.java b/app/src/main/java/com/grigowashere/aismap/utils/AismapLocalHttpsProbe.java new file mode 100644 index 0000000..2fb0332 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/utils/AismapLocalHttpsProbe.java @@ -0,0 +1,110 @@ +package com.grigowashere.aismap.utils; + +import android.util.Log; + +import java.io.InputStream; +import java.net.ConnectException; +import java.net.HttpURLConnection; +import java.net.NoRouteToHostException; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.net.UnknownHostException; +import java.security.cert.CertificateException; +import java.util.Locale; + +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLPeerUnverifiedException; + +/** + * Lightweight reachability check for {@code https://aismap.local/} (feasibility / diagnostics). + * Runs on a background thread; callers must not invoke from the UI thread. + */ +public final class AismapLocalHttpsProbe { + + private static final String TAG = "AismapLocalHttpsProbe"; + + /** Hub HTTPS root — user-requested feasibility target. */ + public static final String PROBE_URL = "https://aismap.local/"; + + private static final int CONNECT_TIMEOUT_MS = 2500; + private static final int READ_TIMEOUT_MS = 2500; + + private AismapLocalHttpsProbe() {} + + /** + * Performs a GET with short timeouts. Returns a short UI-safe line (no stack traces). + */ + public static String probeOnce() { + HttpURLConnection conn = null; + try { + URL url = new URL(PROBE_URL); + conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setRequestMethod("GET"); + conn.setInstanceFollowRedirects(true); + conn.setUseCaches(false); + int code = conn.getResponseCode(); + InputStream body = code >= HttpURLConnection.HTTP_BAD_REQUEST + ? conn.getErrorStream() : conn.getInputStream(); + drain(body); + if (code >= 200 && code < 400) { + return "HTTPS aismap.local: OK HTTP " + code; + } + return "HTTPS aismap.local: HTTP " + code; + } catch (Throwable t) { + Log.w(TAG, "probe failed: " + t.getClass().getSimpleName() + ": " + t.getMessage(), t); + return "HTTPS aismap.local: " + classify(t); + } finally { + if (conn != null) { + try { + conn.disconnect(); + } catch (Throwable ignore) {} + } + } + } + + private static void drain(InputStream in) { + if (in == null) return; + byte[] buf = new byte[512]; + try (InputStream stream = in) { + while (stream.read(buf) != -1) { /* discard */ } + } catch (Throwable ignore) {} + } + + static String classify(Throwable t) { + if (t instanceof SSLHandshakeException || t instanceof SSLPeerUnverifiedException) { + return "TLS/cert issue"; + } + if (t instanceof CertificateException) { + return "TLS/cert issue"; + } + if (t instanceof SSLException) { + String m = t.getMessage(); + if (m != null && m.toLowerCase(Locale.US).contains("cert")) { + return "TLS/cert issue"; + } + return "TLS: " + t.getClass().getSimpleName(); + } + if (t instanceof SocketTimeoutException) { + return "timeout"; + } + if (t instanceof UnknownHostException) { + return "no DNS (aismap.local)"; + } + if (t instanceof ConnectException || t instanceof NoRouteToHostException) { + return "no route / refused"; + } + Throwable c = t.getCause(); + if (c != null && c != t) { + return classify(c); + } + String m = t.getMessage(); + if (m != null && !m.isEmpty()) { + String shortM = m.length() > 48 ? m.substring(0, 45) + "…" : m; + return t.getClass().getSimpleName() + ": " + shortM; + } + return t.getClass().getSimpleName(); + } +} diff --git a/app/src/main/java/com/grigowashere/aismap/utils/RangeMath.java b/app/src/main/java/com/grigowashere/aismap/utils/RangeMath.java index e577bb0..c0b8472 100644 --- a/app/src/main/java/com/grigowashere/aismap/utils/RangeMath.java +++ b/app/src/main/java/com/grigowashere/aismap/utils/RangeMath.java @@ -69,4 +69,159 @@ public final class RangeMath { double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } + + /** AIS SOG «not available» (102.3 kn). */ + public static final double AIS_SOG_NOT_AVAILABLE = 102.3; + /** AIS COG «not available» (360°). */ + public static final double AIS_COG_NOT_AVAILABLE = 360.0; + /** AIS HDG «not available» (511°). */ + public static final double AIS_HDG_NOT_AVAILABLE = 511.0; + + /** Минимальная относительная скорость (м/с) для расчёта TCPA. */ + private static final double MIN_REL_SPEED_MS = 0.02; + + /** + * Результат расчёта CPA/TCPA. + *

{@link #tcpaMinutes} отрицательный — CPA уже пройден или сближение невозможно. + */ + public static final class CpaResult { + public final boolean valid; + /** Минимальная дистанция сближения, м. */ + public final double cpaMeters; + /** Время до CPA, мин (отрицательное — в прошлом / расхождение). */ + public final double tcpaMinutes; + + public CpaResult(boolean valid, double cpaMeters, double tcpaMinutes) { + this.valid = valid; + this.cpaMeters = cpaMeters; + this.tcpaMinutes = tcpaMinutes; + } + + public static final CpaResult INVALID = new CpaResult(false, Double.NaN, Double.NaN); + } + + public static boolean isValidAisSpeed(double sogKn) { + if (Double.isNaN(sogKn) || Double.isInfinite(sogKn)) return false; + return sogKn >= 0.0 && sogKn < AIS_SOG_NOT_AVAILABLE; + } + + public static boolean isValidAisCourse(double cogDeg) { + if (Double.isNaN(cogDeg) || Double.isInfinite(cogDeg)) return false; + return cogDeg >= 0.0 && cogDeg < AIS_COG_NOT_AVAILABLE; + } + + public static boolean isValidAisHeading(double hdgDeg) { + if (Double.isNaN(hdgDeg) || Double.isInfinite(hdgDeg)) return false; + int h = (int) Math.round(hdgDeg); + if (h == (int) AIS_HDG_NOT_AVAILABLE) return false; + return h >= 0 && h <= 359; + } + + /** + * Курс для вектора движения: валидный HDG, иначе COG. + */ + public static double resolveMotionCourse(double cogDeg, double headingDeg) { + if (isValidAisHeading(headingDeg)) { + return normalizeCourse360(headingDeg); + } + if (isValidAisCourse(cogDeg)) { + return normalizeCourse360(cogDeg); + } + return Double.NaN; + } + + public static double normalizeCourse360(double courseDeg) { + if (Double.isNaN(courseDeg) || Double.isInfinite(courseDeg)) return Double.NaN; + double c = courseDeg % 360.0; + if (c < 0) c += 360.0; + return c; + } + + public static double knotsToMetersPerSecond(double knots) { + return knots * METERS_PER_NM / 3600.0; + } + + /** + * CPA/TCPA в локальной плоскости (север/восток), стандартный относительный вектор. + */ + public static CpaResult calculateCpa(double ownLat, double ownLon, + double ownSogKn, double ownCogDeg, double ownHdgDeg, + double tgtLat, double tgtLon, + double tgtSogKn, double tgtCogDeg, double tgtHdgDeg) { + if (Double.isNaN(ownLat) || Double.isNaN(ownLon) + || Double.isNaN(tgtLat) || Double.isNaN(tgtLon)) { + return CpaResult.INVALID; + } + if (!isValidAisSpeed(ownSogKn) || !isValidAisSpeed(tgtSogKn)) { + return CpaResult.INVALID; + } + double ownCourse = resolveMotionCourse(ownCogDeg, ownHdgDeg); + double tgtCourse = resolveMotionCourse(tgtCogDeg, tgtHdgDeg); + if (Double.isNaN(ownCourse) || Double.isNaN(tgtCourse)) { + return CpaResult.INVALID; + } + + double[] pr = relativePositionNeMeters(ownLat, ownLon, tgtLat, tgtLon); + double[] vo = velocityNeMetersPerSecond(ownSogKn, ownCourse); + double[] vt = velocityNeMetersPerSecond(tgtSogKn, tgtCourse); + double vrN = vt[0] - vo[0]; + double vrE = vt[1] - vo[1]; + double vr2 = vrN * vrN + vrE * vrE; + if (vr2 < MIN_REL_SPEED_MS * MIN_REL_SPEED_MS) { + return CpaResult.INVALID; + } + double dot = pr[0] * vrN + pr[1] * vrE; + double tcpaSec = -dot / vr2; + double cpaN = pr[0] + vrN * tcpaSec; + double cpaE = pr[1] + vrE * tcpaSec; + double cpaMeters = Math.hypot(cpaN, cpaE); + double tcpaMinutes = tcpaSec / 60.0; + return new CpaResult(true, cpaMeters, tcpaMinutes); + } + + /** NE-смещение цели относительно своего судна, м (север, восток). */ + static double[] relativePositionNeMeters(double ownLat, double ownLon, + double tgtLat, double tgtLon) { + double latMidRad = Math.toRadians((ownLat + tgtLat) * 0.5); + double mPerDegLat = 111_320.0; + double mPerDegLon = 111_320.0 * Math.cos(latMidRad); + double north = (tgtLat - ownLat) * mPerDegLat; + double east = (tgtLon - ownLon) * mPerDegLon; + return new double[] { north, east }; + } + + /** Скорость по курсу (0° = север), м/с. */ + static double[] velocityNeMetersPerSecond(double sogKn, double cogDeg) { + double speedMs = knotsToMetersPerSecond(sogKn); + double rad = Math.toRadians(cogDeg); + return new double[] { + speedMs * Math.cos(rad), + speedMs * Math.sin(rad) + }; + } + + /** Краткая строка CPA + TCPA для таблиц (nm/km + мин). */ + public static String formatCpaTcpaShort(CpaResult result, boolean useNm, + String na, java.util.Locale locale) { + if (result == null || !result.valid) return na; + return formatCpaDistance(result.cpaMeters, useNm, locale) + + " " + formatTcpa(result.tcpaMinutes, locale); + } + + public static String formatCpaDistance(double meters, boolean useNm, java.util.Locale locale) { + if (useNm) { + return String.format(locale, "%.2f nm", meters / METERS_PER_NM); + } + if (meters >= 1000.0) { + return String.format(locale, "%.2f km", meters / METERS_PER_KM); + } + return String.format(locale, "%.0f m", meters); + } + + public static String formatTcpa(double tcpaMinutes, java.util.Locale locale) { + if (tcpaMinutes >= 0.0) { + return String.format(locale, "+%.0f мин", tcpaMinutes); + } + return String.format(locale, "%.0f мин", tcpaMinutes); + } } diff --git a/app/src/main/java/com/grigowashere/aismap/view/DangerTargetsDockWidget.java b/app/src/main/java/com/grigowashere/aismap/view/DangerTargetsDockWidget.java index 063eb48..796f216 100644 --- a/app/src/main/java/com/grigowashere/aismap/view/DangerTargetsDockWidget.java +++ b/app/src/main/java/com/grigowashere/aismap/view/DangerTargetsDockWidget.java @@ -9,6 +9,7 @@ import android.util.AttributeSet; import com.grigowashere.aismap.R; import com.grigowashere.aismap.controllers.AppCoordinator; +import com.grigowashere.aismap.utils.RangeMath; import com.grigowashere.aismap.utils.SettingsManager; import java.util.ArrayList; @@ -17,10 +18,12 @@ import java.util.List; import java.util.Locale; /** - * Виджет «Опасные цели» — таблица ближайших AIS-целей в зоне опасности. - * Колонки: имя/MMSI, пеленг (°), дистанция (nm/km — в зависимости от настроек). + * Виджет «Ближайшие цели» — таблица AIS-целей в радиусе жёлтого кольца + * предупреждения. Колонки: имя/MMSI, пеленг (°), дистанция (nm/km — в + * зависимости от настроек). *

Обновление выполняется через {@link #setEntries(List)} с частотой 1 Hz из - * MainActivity, источник данных — {@link AppCoordinator#getDangerTargets(double, int)}. + * MainActivity, источник данных — {@link AppCoordinator#getDangerTargets(double, int)} + * с радиусом {@link SettingsManager#getWarningRadiusMeters()}. */ public class DangerTargetsDockWidget extends BaseDockWidget { @@ -29,11 +32,22 @@ public class DangerTargetsDockWidget extends BaseDockWidget { public final String name; public final double bearingDeg; public final double distanceMeters; + public final boolean cpaValid; + public final double cpaMeters; + public final double tcpaMinutes; public DangerEntry(String name, double bearingDeg, double distanceMeters) { + this(name, bearingDeg, distanceMeters, false, Double.NaN, Double.NaN); + } + + public DangerEntry(String name, double bearingDeg, double distanceMeters, + boolean cpaValid, double cpaMeters, double tcpaMinutes) { this.name = name; this.bearingDeg = bearingDeg; this.distanceMeters = distanceMeters; + this.cpaValid = cpaValid; + this.cpaMeters = cpaMeters; + this.tcpaMinutes = tcpaMinutes; } } @@ -49,7 +63,7 @@ public class DangerTargetsDockWidget extends BaseDockWidget { private static final int BACKGROUND_COLOR = 0xD92A1A1A; private static final int LABEL_COLOR = 0xFFE0B0B0; private static final int TEXT_COLOR = 0xFFFFFFFF; - private static final int ACCENT_COLOR = 0xFFFF6B6B; + private static final int ACCENT_COLOR = 0xFFFFA000; private Paint backgroundPaint; private Paint titlePaint; @@ -78,7 +92,7 @@ public class DangerTargetsDockWidget extends BaseDockWidget { @Override protected boolean getDefaultDockTop() { - // Виджет «Опасные цели» по умолчанию сидит ВНИЗУ над координатами — + // Виджет «Ближайшие цели» по умолчанию сидит ВНИЗУ над координатами — // это самая безболезненная для карты позиция: при отсутствии целей // он вообще скрыт, а когда появляется, не разрывает обзор по центру. return false; @@ -217,7 +231,7 @@ public class DangerTargetsDockWidget extends BaseDockWidget { snapshot = new ArrayList<>(entries); } - // === Заголовок: «Опасные цели · N» === + // === Заголовок: «Ближайшие цели · N» === float titleBaseline = top + dp(4) + titlePaint.getTextSize(); String titleBase = getResources().getString(R.string.danger_widget_title); String title = snapshot.isEmpty() @@ -233,14 +247,15 @@ public class DangerTargetsDockWidget extends BaseDockWidget { return; } - // === Строки целей. Колонки: имя | пеленг | дистанция === - // Имя — широкая колонка слева, пеленг и дистанция — справа выровнены по - // фиксированной ширине, чтобы цифры не «прыгали» между строками. + // === Строки целей. Колонки: имя | пеленг | дистанция | CPA === + String cpaNa = getResources().getString(R.string.cpa_na); + float cpaMaxWidth = accentPaint.measureText("9.99 nm +99 мин"); float distMaxWidth = textPaint.measureText("999.99 nm"); float bearingMaxWidth = textPaint.measureText("000\u00B0"); - float colDistanceRight = innerRight; - float colBearingRight = colDistanceRight - distMaxWidth - dp(10); - float nameRight = colBearingRight - bearingMaxWidth - dp(10); + float colCpaRight = innerRight; + float colDistanceRight = colCpaRight - cpaMaxWidth - dp(6); + float colBearingRight = colDistanceRight - distMaxWidth - dp(6); + float nameRight = colBearingRight - bearingMaxWidth - dp(6); boolean useNm = settingsManager == null || SettingsManager.RANGE_UNIT_NM.equals(settingsManager.getRangeUnit()); @@ -254,13 +269,21 @@ public class DangerTargetsDockWidget extends BaseDockWidget { String name = ellipsize(rawName, textPaint, nameRight - innerLeft - dp(4)); String bearing = String.format(Locale.US, "%03.0f\u00B0", e.bearingDeg); String distance = formatDistance(e.distanceMeters, useNm); + String cpa; + if (e.cpaValid) { + cpa = RangeMath.formatCpaDistance(e.cpaMeters, useNm, Locale.US) + + " " + RangeMath.formatTcpa(e.tcpaMinutes, Locale.US); + } else { + cpa = cpaNa; + } canvas.drawText(name, innerLeft, y, textPaint); - // Пеленг и дистанция — выравнивание справа по своим колонкам. float bearingWidth = textPaint.measureText(bearing); canvas.drawText(bearing, colBearingRight - bearingWidth, y, textPaint); - float distanceWidth = accentPaint.measureText(distance); - canvas.drawText(distance, colDistanceRight - distanceWidth, y, accentPaint); + float distanceWidth = textPaint.measureText(distance); + canvas.drawText(distance, colDistanceRight - distanceWidth, y, textPaint); + float cpaWidth = accentPaint.measureText(cpa); + canvas.drawText(cpa, colCpaRight - cpaWidth, y, accentPaint); y += rowH; } @@ -310,8 +333,13 @@ public class DangerTargetsDockWidget extends BaseDockWidget { centerY - dp(28), subPaint); if (nearest != null) { - String nearestStr = String.format(Locale.US, "%03.0f\u00B0 %s", - nearest.bearingDeg, formatDistance(nearest.distanceMeters, useNm)); + String cpaPart = nearest.cpaValid + ? RangeMath.formatCpaTcpaShort( + new RangeMath.CpaResult(true, nearest.cpaMeters, nearest.tcpaMinutes), + useNm, getResources().getString(R.string.cpa_na), Locale.US) + : getResources().getString(R.string.cpa_na); + String nearestStr = String.format(Locale.US, "%03.0f\u00B0 %s %s", + nearest.bearingDeg, formatDistance(nearest.distanceMeters, useNm), cpaPart); b = new Rect(); subPaint.getTextBounds(nearestStr, 0, nearestStr.length(), b); canvas.drawText(nearestStr, centerX - b.width() / 2f - b.left, diff --git a/app/src/main/java/com/grigowashere/aismap/view/PlotterTargetsTableView.java b/app/src/main/java/com/grigowashere/aismap/view/PlotterTargetsTableView.java index f920c2d..1adda83 100644 --- a/app/src/main/java/com/grigowashere/aismap/view/PlotterTargetsTableView.java +++ b/app/src/main/java/com/grigowashere/aismap/view/PlotterTargetsTableView.java @@ -12,6 +12,7 @@ import androidx.core.content.ContextCompat; import com.grigowashere.aismap.R; import com.grigowashere.aismap.controllers.AppCoordinator; +import com.grigowashere.aismap.utils.RangeMath; import com.grigowashere.aismap.utils.SettingsManager; import java.util.ArrayList; @@ -28,11 +29,22 @@ public class PlotterTargetsTableView extends View { public final String name; public final double bearingDeg; public final double distanceMeters; + public final boolean cpaValid; + public final double cpaMeters; + public final double tcpaMinutes; public Row(String name, double bearingDeg, double distanceMeters) { + this(name, bearingDeg, distanceMeters, false, Double.NaN, Double.NaN); + } + + public Row(String name, double bearingDeg, double distanceMeters, + boolean cpaValid, double cpaMeters, double tcpaMinutes) { this.name = name; this.bearingDeg = bearingDeg; this.distanceMeters = distanceMeters; + this.cpaValid = cpaValid; + this.cpaMeters = cpaMeters; + this.tcpaMinutes = tcpaMinutes; } } @@ -100,7 +112,8 @@ public class PlotterTargetsTableView extends View { if (label == null || label.trim().isEmpty()) { label = e.vessel.getMmsi() != null ? e.vessel.getMmsi() : "—"; } - next.add(new Row(label, e.bearingDegrees, e.distanceMeters)); + next.add(new Row(label, e.bearingDegrees, e.distanceMeters, + e.cpaValid, e.cpaMeters, e.tcpaMinutes)); if (next.size() >= MAX_ROWS) break; } } @@ -156,7 +169,13 @@ public class PlotterTargetsTableView extends View { canvas.drawText(name, colName, y, rowPaint); canvas.drawText(String.format(Locale.US, "%03.0f\u00B0", r.bearingDeg), colBrg, y, rowPaint); canvas.drawText(formatDistance(r.distanceMeters, useNm), colRng, y, rowPaint); - canvas.drawText(cpaNa, colCpa, y, accentPaint); + String cpaText; + if (r.cpaValid) { + cpaText = RangeMath.formatCpaDistance(r.cpaMeters, useNm, Locale.US); + } else { + cpaText = cpaNa; + } + canvas.drawText(cpaText, colCpa, y, accentPaint); y += rowH; if (y > h - pad) break; } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 20fa37b..b5d1d3d 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -199,6 +199,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" + android:maxLines="8" android:text="BLE RSSI: --" android:textColor="@android:color/white" android:textSize="10sp" /> diff --git a/app/src/main/res/layout/bottom_sheet_ais_vessel.xml b/app/src/main/res/layout/bottom_sheet_ais_vessel.xml index 050ab88..d46d6fe 100644 --- a/app/src/main/res/layout/bottom_sheet_ais_vessel.xml +++ b/app/src/main/res/layout/bottom_sheet_ais_vessel.xml @@ -285,6 +285,18 @@ android:background="@android:color/transparent" android:padding="8dp" /> + + + Морские мили (nm) Километры (км) Радиус зоны опасности - Цели в этой зоне отображаются в виджете и считаются опасными + Красное кольцо опасности на карте Радиус зоны предупреждения Цели в этой зоне подсвечиваются на карте Радиус зоны фильтра @@ -131,11 +131,13 @@ BLE требует сопряжения. Проверьте устройство в настройках Bluetooth. Иконка предупреждения связи - Опасные цели - В зоне опасности нет целей + Ближайшие цели + Ближайших целей нет Цель Пел. Дист. + + CPA: %1$s, TCPA: %2$s КУРС diff --git a/app/src/test/java/com/grigowashere/aismap/utils/RangeMathTest.java b/app/src/test/java/com/grigowashere/aismap/utils/RangeMathTest.java index d38092b..74b7296 100644 --- a/app/src/test/java/com/grigowashere/aismap/utils/RangeMathTest.java +++ b/app/src/test/java/com/grigowashere/aismap/utils/RangeMathTest.java @@ -86,4 +86,60 @@ public class RangeMathTest { assertTrue("Distance Moscow-SPB ~ 633km, got=" + d, Math.abs(d - 633_000.0) < 10_000.0); } + + @Test + public void testAisValidityHelpers() { + assertTrue(RangeMath.isValidAisSpeed(0.0)); + assertTrue(RangeMath.isValidAisSpeed(10.5)); + assertFalse(RangeMath.isValidAisSpeed(102.3)); + assertFalse(RangeMath.isValidAisSpeed(Double.NaN)); + + assertTrue(RangeMath.isValidAisCourse(359.9)); + assertFalse(RangeMath.isValidAisCourse(360.0)); + assertFalse(RangeMath.isValidAisHeading(511.0)); + assertTrue(RangeMath.isValidAisHeading(90.0)); + } + + @Test + public void testCpaHeadOnClosure() { + double lat = 55.0; + double ownLon = 37.0; + double tgtLon = 37.0; + double nm = RangeMath.METERS_PER_NM; + double tgtLat = lat + (nm / 111_320.0); + + RangeMath.CpaResult r = RangeMath.calculateCpa( + lat, ownLon, 10.0, 0.0, Double.NaN, + tgtLat, tgtLon, 10.0, 180.0, Double.NaN); + assertTrue(r.valid); + assertTrue("CPA head-on should be small, got " + r.cpaMeters, r.cpaMeters < 200.0); + assertTrue("TCPA head-on should be positive, got " + r.tcpaMinutes, r.tcpaMinutes > 0.0); + assertTrue("TCPA head-on ~3 min, got " + r.tcpaMinutes, + Math.abs(r.tcpaMinutes - 3.0) < 0.5); + } + + @Test + public void testCpaParallelSameVectorsInvalidTcpa() { + RangeMath.CpaResult r = RangeMath.calculateCpa( + 55.0, 37.0, 10.0, 90.0, Double.NaN, + 55.0, 37.01, 10.0, 90.0, Double.NaN); + assertFalse(r.valid); + } + + @Test + public void testCpaMissingCourseInvalid() { + RangeMath.CpaResult r = RangeMath.calculateCpa( + 55.0, 37.0, 5.0, 360.0, Double.NaN, + 55.01, 37.0, 5.0, 90.0, Double.NaN); + assertFalse(r.valid); + } + + @Test + public void testCpaUsesHeadingOverCog() { + RangeMath.CpaResult withHdg = RangeMath.calculateCpa( + 55.0, 37.0, 10.0, 0.0, 90.0, + 55.0, 37.01, 10.0, 0.0, 270.0); + assertTrue(withHdg.valid); + assertTrue(withHdg.cpaMeters < 500.0); + } }