diff --git a/__pycache__/ble_gatt.cpython-313.pyc b/__pycache__/ble_gatt.cpython-313.pyc
new file mode 100644
index 0000000..529a1c7
Binary files /dev/null and b/__pycache__/ble_gatt.cpython-313.pyc differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e634467..e9e3865 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -81,6 +81,13 @@
android:exported="false"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/Theme.AISMap" />
+
+ 0.0) {
+ java.util.List distanceFiltered = new java.util.ArrayList<>(filtered.size());
+ for (AISVesselEntity e : filtered) {
+ if (e == null) continue;
+ if (!GeoUtils.isValidCoordinates(e.latitude, e.longitude)) {
+ distanceFiltered.add(e);
+ continue;
+ }
+ double d = GeoUtils.calculateDistance(
+ ourLatitude, ourLongitude, e.latitude, e.longitude);
+ if (d <= maxDistanceM) {
+ distanceFiltered.add(e);
+ }
+ }
+ filtered = distanceFiltered;
+ }
+ }
adapter.submitList(filtered);
int targetCount = filtered.size();
textTargetCount.setText("AIS цели: " + targetCount);
diff --git a/app/src/main/java/com/grigowashere/aismap/MainActivity.java b/app/src/main/java/com/grigowashere/aismap/MainActivity.java
index 42d3553..40d714c 100644
--- a/app/src/main/java/com/grigowashere/aismap/MainActivity.java
+++ b/app/src/main/java/com/grigowashere/aismap/MainActivity.java
@@ -59,8 +59,16 @@ import com.grigowashere.aismap.controllers.ControllersFactory;
import com.grigowashere.aismap.controllers.DefaultControllersFactory;
public class MainActivity extends AppCompatActivity {
+
+ /** Живой координатор для вторичных экранов (радар), пока MainActivity в стеке. */
+ private static volatile AppCoordinator sAppCoordinator;
private static final String TAG = "MainActivity";
+
+ /** @return координатор приложения или {@code null}, если карта ещё не инициализирована */
+ public static AppCoordinator getAppCoordinator() {
+ return sAppCoordinator;
+ }
private static final int PERMISSION_REQUEST_CODE = 1001;
private static final int SETTINGS_REQUEST_CODE = 1002;
private static final int NOTIFICATION_PERMISSION_REQUEST_CODE = 1003;
@@ -83,14 +91,19 @@ public class MainActivity extends AppCompatActivity {
private SettingsManager settingsManager;
private ImageButton btnCenterOnVessel;
+ private ImageButton btnNavigatorFollow;
private ImageButton btnMapOrientation;
private ImageButton btnCursorToggle;
private ImageButton btnSettings;
private ImageButton btnAisTargets;
+ private ImageButton btnRadarPlotter;
private ImageButton btnGpsSource;
private LinearLayout controlPanel;
private CompassView compassView;
private CoordinatesDockWidget coordinatesWidget;
+ private com.grigowashere.aismap.view.DangerTargetsDockWidget dangerWidget;
+ private android.widget.LinearLayout bannerConnectionLost;
+ private TextView tvBannerConnectionLost;
// Троттлинг для UI обновлений
private android.os.Handler uiThrottleHandler;
@@ -214,14 +227,19 @@ public class MainActivity extends AppCompatActivity {
private void initializeViews() {
mapView = findViewById(R.id.map_view);
btnCenterOnVessel = findViewById(R.id.btn_center_vessel);
+ btnNavigatorFollow = findViewById(R.id.btn_navigator_follow);
btnMapOrientation = findViewById(R.id.btn_map_orientation);
btnCursorToggle = findViewById(R.id.btn_cursor_toggle);
btnSettings = findViewById(R.id.btn_settings);
btnAisTargets = findViewById(R.id.btn_ais_targets);
+ btnRadarPlotter = findViewById(R.id.btn_radar_plotter);
btnGpsSource = findViewById(R.id.btn_gps_source);
controlPanel = findViewById(R.id.control_panel);
compassView = findViewById(R.id.compass_view);
coordinatesWidget = findViewById(R.id.coordinates_widget);
+ dangerWidget = findViewById(R.id.danger_targets_widget);
+ bannerConnectionLost = findViewById(R.id.banner_connection_lost);
+ tvBannerConnectionLost = findViewById(R.id.tv_banner_connection_lost);
installMainUiInsets();
// Инициализируем троттлинг
@@ -292,7 +310,12 @@ public class MainActivity extends AppCompatActivity {
}
private void setupButtonListeners() {
- if (btnCenterOnVessel != null) btnCenterOnVessel.setOnClickListener(v -> centerOnVessel());
+ if (btnCenterOnVessel != null) {
+ btnCenterOnVessel.setOnClickListener(v -> centerOnVessel());
+ }
+ if (btnNavigatorFollow != null) {
+ btnNavigatorFollow.setOnClickListener(v -> toggleNavigatorCamera());
+ }
if (btnMapOrientation != null) {
btnMapOrientation.setOnClickListener(v -> cycleMapRotationMode());
}
@@ -300,6 +323,7 @@ public class MainActivity extends AppCompatActivity {
if (btnCursorToggle != null) btnCursorToggle.setOnLongClickListener(v -> { toggleCursor(); return true; });
if (btnSettings != null) btnSettings.setOnClickListener(v -> showSettings());
if (btnAisTargets != null) btnAisTargets.setOnClickListener(v -> openAisTargets());
+ if (btnRadarPlotter != null) btnRadarPlotter.setOnClickListener(v -> openRadarPlotter());
if (btnGpsSource != null) {
refreshGpsSourceButtonIcon();
btnGpsSource.setOnClickListener(v -> toggleGpsSource());
@@ -316,13 +340,16 @@ public class MainActivity extends AppCompatActivity {
// Устанавливаем начальный азимут (например, север)
compassView.setAzimuth(0);
- // Устанавливаем компас в dock-режим вверху экрана
+ // Компас уже стартует в dock-state=true, dockTop=true (см.
+ // BaseDockWidget.init() + getDefaultDockTop() по умолчанию). Позиция
+ // задаётся layout_alignParentTop в activity_main.xml. Раньше тут стоял
+ // post(() -> setDocked(true, true, 0, 0)) — для top-дока он чаще всего
+ // был no-op'ом, но для симметрии с координатами оставляем тут только
+ // переприменение insets и обновление контрол-панели.
compassView.post(() -> {
- compassView.setDocked(true, true, 0, 0);
- compassView.invalidate(); // Принудительная отрисовка
- // Выровнять паддинги под статус-бар/вырез камеры сразу после
- // первого dock-позиционирования (до этого сторона неизвестна).
+ compassView.invalidate();
reapplyInsetsToDocks();
+ updateControlPanelPosition();
});
// Настраиваем слушатель изменения размера док-виджета
@@ -441,15 +468,21 @@ public class MainActivity extends AppCompatActivity {
reapplyInsetsToDocks();
});
- // Устанавливаем виджет координат в dock-режим внизу экрана без тестовых данных
+ // Виджет координат уже стартует в dock-state=true, dockTop=false
+ // (см. CoordinatesDockWidget.getDefaultDockTop() и BaseDockWidget.init()),
+ // а позиция определяется layout_alignParentBottom в activity_main.xml.
+ // Раньше тут стоял coordinatesWidget.post(() -> setDocked(true, false, 0, 0)),
+ // и из-за того, что post-колбэк выполнялся уже после первого layout-а,
+ // ранний return в setDocked не срабатывал, а calculateDockPosition в
+ // момент колбэка часто получал parent.getHeight() == 0 → endY уходило
+ // в отрицательную область, и анимация уезжала translation-ом ВВЕРХ за
+ // экран. Виджеты «мигали в центре, улетали вверх и появлялись снизу
+ // только после ресайза». Теперь мы просто переприменяем insets, чтобы
+ // виджет сразу получил bottom-padding под нав-бар.
coordinatesWidget.post(() -> {
- Log.d(TAG, "Setting coordinates widget to dock mode");
- coordinatesWidget.setDocked(true, false, 0, 0); // false = dock снизу
- coordinatesWidget.invalidate(); // Принудительная отрисовка
- // Только сейчас мы знаем сторону дока (bottom) — переприменяем
- // инсеты, чтобы виджет получил bottom padding под нав-бар
- // сразу, а не только после первого ресайза пользователем.
+ coordinatesWidget.invalidate();
reapplyInsetsToDocks();
+ updateControlPanelPosition();
});
}
@@ -485,6 +518,8 @@ public class MainActivity extends AppCompatActivity {
Integer batt = appCoordinator.getLastBleBattery();
tvBleBatt.setText(batt != null ? ("BLE Batt: " + batt + "%") : "BLE Batt: --");
}
+ updateBleLinkLostBanner();
+ updateDangerWidget();
}
} catch (Exception ignored) {}
messageAgeHandler.postDelayed(this, 1000);
@@ -494,6 +529,86 @@ public class MainActivity extends AppCompatActivity {
messageAgeHandler.postDelayed(messageAgeRunnable, 1000);
}
+ /**
+ * Баннер «нет связи с устройством» — только при потере BLE GATT-сессии с AIS Hub.
+ * Возраст GPS/AIS здесь не используется: при обрыве линка данным доверять нельзя,
+ * после reconnect клиент сам запрашивает snapshot.
+ */
+ private void updateBleLinkLostBanner() {
+ if (bannerConnectionLost == null || tvBannerConnectionLost == null) return;
+ if (appCoordinator == null) return;
+ boolean linkLost = appCoordinator.isBleHubLinkLost();
+ if (!linkLost) {
+ if (bannerConnectionLost.getVisibility() != View.GONE) {
+ bannerConnectionLost.animate().alpha(0f).setDuration(200)
+ .withEndAction(() -> {
+ bannerConnectionLost.setVisibility(View.GONE);
+ onBannerVisibilityChanged();
+ })
+ .start();
+ }
+ return;
+ }
+ tvBannerConnectionLost.setText(R.string.banner_connection_lost_ble);
+ if (bannerConnectionLost.getVisibility() != View.VISIBLE) {
+ bannerConnectionLost.setAlpha(0f);
+ bannerConnectionLost.setVisibility(View.VISIBLE);
+ onBannerVisibilityChanged();
+ bannerConnectionLost.animate().alpha(1f).setDuration(200).start();
+ }
+ }
+
+ /** Пересчёт инсетов и layout после показа/скрытия баннера связи. */
+ private void onBannerVisibilityChanged() {
+ applyInsetsToDocks(lastSysInsets);
+ View root = findViewById(R.id.main_root);
+ if (root != null) {
+ root.requestLayout();
+ }
+ if (compassView != null) {
+ compassView.requestLayout();
+ }
+ }
+
+ /**
+ * Обновляет таблицу «Опасные цели» каждую секунду из AppCoordinator.
+ * Если опасных целей нет (или функция отключена в настройках) — виджет
+ * полностью скрывается, чтобы не занимать место на карте.
+ */
+ private void updateDangerWidget() {
+ if (dangerWidget == null || appCoordinator == null || settingsManager == null) return;
+ boolean enabled = settingsManager.isRangeRingsEnabled();
+ double dangerR = enabled ? settingsManager.getDangerRadiusMeters() : 0.0;
+ java.util.List uiEntries =
+ new java.util.ArrayList<>();
+ if (enabled && dangerR > 0.0) {
+ java.util.List entries =
+ appCoordinator.getDangerTargets(dangerR, 5);
+ if (entries != null) {
+ for (com.grigowashere.aismap.controllers.AppCoordinator.DangerEntry e : entries) {
+ if (e == null || e.vessel == null) continue;
+ String label = e.vessel.getVesselName();
+ if (label == null || label.trim().isEmpty()) {
+ label = e.vessel.getMmsi() != null ? e.vessel.getMmsi() : "—";
+ }
+ uiEntries.add(new com.grigowashere.aismap.view.DangerTargetsDockWidget.DangerEntry(
+ label, e.bearingDegrees, e.distanceMeters));
+ }
+ }
+ }
+ if (uiEntries.isEmpty()) {
+ if (dangerWidget.getVisibility() != View.GONE) {
+ dangerWidget.setVisibility(View.GONE);
+ }
+ dangerWidget.setEntries(uiEntries);
+ return;
+ }
+ dangerWidget.setEntries(uiEntries);
+ if (dangerWidget.getVisibility() != View.VISIBLE) {
+ dangerWidget.setVisibility(View.VISIBLE);
+ }
+ }
+
private int getAgeColor(int seconds) {
if (seconds < 0) {
// Нет данных
@@ -842,6 +957,7 @@ public class MainActivity extends AppCompatActivity {
// Инициализация главного координатора
ControllersFactory controllersFactory = new DefaultControllersFactory();
appCoordinator = controllersFactory.createAppCoordinator(this);
+ sAppCoordinator = appCoordinator;
// Init UI binders
menuBinder = new MenuBinder(appCoordinator, settingsManager, new MenuBinder.MenuActions() {
@@ -910,6 +1026,7 @@ public class MainActivity extends AppCompatActivity {
}
});
refreshMapRotationButtonDescription();
+ refreshNavigatorButtonState();
}
private void startControllers() {
@@ -1007,7 +1124,30 @@ public class MainActivity extends AppCompatActivity {
private void centerOnVessel() {
appCoordinator.centerOnOwnVessel();
- Toast.makeText(this, "Карта центрирована на судне", Toast.LENGTH_SHORT).show();
+ if (!appCoordinator.isNavigatorCameraEnabled()) {
+ Toast.makeText(this, "Карта центрирована на судне", Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ private void toggleNavigatorCamera() {
+ if (appCoordinator == null) return;
+ appCoordinator.toggleNavigatorCamera();
+ refreshNavigatorButtonState();
+ int msg = appCoordinator.isNavigatorCameraEnabled()
+ ? R.string.main_navigator_on
+ : R.string.main_navigator_off;
+ Toast.makeText(this, msg, Toast.LENGTH_SHORT).show();
+ }
+
+ private void refreshNavigatorButtonState() {
+ if (appCoordinator == null) return;
+ boolean on = appCoordinator.isNavigatorCameraEnabled();
+ if (btnNavigatorFollow != null) {
+ btnNavigatorFollow.setAlpha(on ? 1f : 0.65f);
+ btnNavigatorFollow.setSelected(on);
+ btnNavigatorFollow.setContentDescription(getString(
+ on ? R.string.main_navigator_on : R.string.main_navigator_button));
+ }
}
private static float normalizeBearingTo360(double deg) {
@@ -1085,6 +1225,10 @@ public class MainActivity extends AppCompatActivity {
private void applyAutoMapBearingIfNeeded(MapInterface map) {
if (settingsManager == null || appCoordinator == null || map == null) return;
+ // В режиме навигатора bearing сглаживает NavigatorCameraController.
+ if (appCoordinator.isNavigatorCameraEnabled()) {
+ return;
+ }
String mode = settingsManager.getMapRotationMode();
if (SettingsManager.MAP_ROTATION_MANUAL.equals(mode)) {
return;
@@ -1173,6 +1317,10 @@ public class MainActivity extends AppCompatActivity {
startActivity(intent);
}
+ private void openRadarPlotter() {
+ startActivity(new Intent(this, RadarPlotterActivity.class));
+ }
+
/**
* Переключает источник координат между BLE Hub и Android GPS «на лету»,
* обновляет иконку кнопки и уведомляет AppCoordinator.
@@ -1239,9 +1387,9 @@ public class MainActivity extends AppCompatActivity {
android.widget.RelativeLayout.LayoutParams lp =
(android.widget.RelativeLayout.LayoutParams) rawLp;
int dp8 = Math.round(getResources().getDisplayMetrics().density * 8);
- int compassH = compassView != null ? compassView.getHeight() : 0;
+ int compassBottom = compassView != null ? compassView.getBottom() : 0;
int coordsH = coordinatesWidget != null ? coordinatesWidget.getHeight() : 0;
- int newTop = compassH + dp8;
+ int newTop = compassBottom + dp8;
int newBottom = coordsH + dp8;
if (lp.topMargin != newTop || lp.bottomMargin != newBottom) {
lp.topMargin = newTop;
@@ -1261,16 +1409,29 @@ public class MainActivity extends AppCompatActivity {
* Боковые паддинги даём всегда (landscape-камеры).
*/
private void applyInsetsToDocks(Insets sys) {
+ boolean bannerVisible = bannerConnectionLost != null
+ && bannerConnectionLost.getVisibility() == View.VISIBLE;
+ if (bannerConnectionLost != null) {
+ int bottomPad = Math.round(getResources().getDisplayMetrics().density * 10);
+ bannerConnectionLost.setPadding(sys.left, sys.top, sys.right, bottomPad);
+ }
if (compassView != null) {
boolean top = compassView.isDockTop();
- compassView.setPadding(sys.left, top ? sys.top : 0,
- sys.right, top ? 0 : sys.bottom);
+ // Верхний inset на компасе только когда баннера нет — иначе отступ уже в баннере.
+ int topPad = top && !bannerVisible ? sys.top : 0;
+ compassView.setPadding(sys.left, topPad, sys.right, top ? 0 : sys.bottom);
}
if (coordinatesWidget != null) {
boolean top = coordinatesWidget.isDockTop();
coordinatesWidget.setPadding(sys.left, top ? sys.top : 0,
sys.right, top ? 0 : sys.bottom);
}
+ // Danger сидит МЕЖДУ компасом и координатами — статус-/нав-бар его
+ // не касаются, но боковые displayCutout (landscape) могут перекрыть
+ // текст таблицы. Так что прокидываем только left/right.
+ if (dangerWidget != null) {
+ dangerWidget.setPadding(sys.left, 0, sys.right, 0);
+ }
}
/** Переприменяет уже собранные инсеты (вызывать при смене стороны дока). */
@@ -1429,6 +1590,7 @@ public class MainActivity extends AppCompatActivity {
if (mapInterface != null) {
// Сначала создаем UI Coordinator
uiCoordinator = new UIRenderingCoordinator(mapInterface);
+ uiCoordinator.setSettingsManager(getApplicationContext(), settingsManager);
Log.i(TAG, "UIRenderingCoordinator создан");
// Подписываем UIRenderingCoordinator на изменения MapInterface
@@ -1438,6 +1600,8 @@ public class MainActivity extends AppCompatActivity {
// Устанавливаем UI Coordinator как notifier для AppCoordinator
appCoordinator.setUIDataChangeNotifier(uiCoordinator);
Log.i(TAG, "UIDataChangeNotifier установлен в AppCoordinator");
+
+ appCoordinator.onMapInterfaceReady(mapInterface);
// AppCoordinator уже подключен к MapController при инициализации
// setMapInterface больше не нужен, так как стратегия карты централизована
@@ -1675,7 +1839,10 @@ public class MainActivity extends AppCompatActivity {
@Override
protected void onDestroy() {
super.onDestroy();
-
+ if (isFinishing()) {
+ sAppCoordinator = null;
+ }
+
// MapLibre lifecycle
if (mapView != null) {
mapView.onDestroy();
@@ -1832,6 +1999,10 @@ public class MainActivity extends AppCompatActivity {
applySettings();
}
refreshGpsSourceButtonIcon();
+ refreshNavigatorButtonState();
+ if (appCoordinator != null) {
+ appCoordinator.setNavigatorCameraEnabled(settingsManager.isNavigatorCameraEnabled());
+ }
Toast.makeText(this, "Настройки применены", Toast.LENGTH_SHORT).show();
}
diff --git a/app/src/main/java/com/grigowashere/aismap/RadarPlotterActivity.java b/app/src/main/java/com/grigowashere/aismap/RadarPlotterActivity.java
new file mode 100644
index 0000000..7e5d426
--- /dev/null
+++ b/app/src/main/java/com/grigowashere/aismap/RadarPlotterActivity.java
@@ -0,0 +1,321 @@
+package com.grigowashere.aismap;
+
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.grigowashere.aismap.controllers.AppCoordinator;
+import com.grigowashere.aismap.maps.RadarMapHelper;
+import com.grigowashere.aismap.models.Vessel;
+import com.grigowashere.aismap.utils.GeoUtils;
+import com.grigowashere.aismap.utils.RangeMath;
+import com.grigowashere.aismap.utils.SettingsManager;
+import com.grigowashere.aismap.utils.UiInsetsUtils;
+import com.grigowashere.aismap.view.PlotterHeadingView;
+import com.grigowashere.aismap.view.PlotterSpeedometerView;
+import com.grigowashere.aismap.view.PlotterTargetsTableView;
+import com.grigowashere.aismap.view.RadarGraticuleOverlay;
+
+import org.maplibre.android.MapLibre;
+import org.maplibre.android.maps.MapView;
+
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Альтернативный UI в стиле картплоттера с PPI-радаром поверх картовых тайлов.
+ * Данные AIS/GPS берутся из {@link MainActivity#getAppCoordinator()}.
+ */
+public class RadarPlotterActivity extends AppCompatActivity {
+
+ private static final long UPDATE_INTERVAL_MS = 1000L;
+ private static final int TABLE_LIMIT = 8;
+
+ private View radarContentLayout;
+ private View radarViewportFrame;
+ private View radarInstrumentsPanel;
+ private int lastSquareLayoutContentW = -1;
+ private int lastSquareLayoutContentH = -1;
+
+ private final ViewTreeObserver.OnGlobalLayoutListener squareViewportLayoutListener =
+ this::applySquareRadarViewport;
+
+ private AppCoordinator appCoordinator;
+ private SettingsManager settingsManager;
+ private RadarMapHelper mapHelper;
+ private MapView mapView;
+ private RadarGraticuleOverlay graticuleOverlay;
+ private PlotterHeadingView headingView;
+ private PlotterSpeedometerView speedometerView;
+ private PlotterTargetsTableView targetsTableView;
+ private TextView tvRange;
+
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private final Runnable updateRunnable = this::tickUi;
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ appCoordinator = MainActivity.getAppCoordinator();
+ if (appCoordinator == null) {
+ Toast.makeText(this, R.string.radar_plotter_no_coordinator, Toast.LENGTH_LONG).show();
+ finish();
+ return;
+ }
+
+ try {
+ MapLibre.getInstance(getApplicationContext());
+ } catch (Exception ignore) { }
+
+ setContentView(R.layout.activity_radar_plotter);
+ settingsManager = new SettingsManager(this);
+
+ mapView = findViewById(R.id.radar_map_view);
+ graticuleOverlay = findViewById(R.id.radar_graticule);
+ headingView = findViewById(R.id.plotter_heading);
+ speedometerView = findViewById(R.id.plotter_speedometer);
+ targetsTableView = findViewById(R.id.plotter_targets_table);
+ tvRange = findViewById(R.id.tv_radar_range);
+
+ ImageButton btnBack = findViewById(R.id.btn_radar_back);
+ if (btnBack != null) {
+ btnBack.setOnClickListener(v -> finish());
+ }
+
+ if (mapView != null) {
+ mapView.setAlpha(0.58f);
+ }
+
+ if (graticuleOverlay != null) {
+ graticuleOverlay.setRangeUnit(settingsManager.getRangeUnit());
+ }
+
+ mapHelper = new RadarMapHelper(mapView);
+ mapHelper.initialize(() -> handler.post(this::tickUi));
+
+ applyPlotterInsets();
+ setupSquareRadarViewport();
+ }
+
+ /**
+ * Inscribes the PPI viewport in a square (max side = min(contentW, contentH)) so tall phones
+ * leave more room for compass, speedometer, and targets table.
+ */
+ private void setupSquareRadarViewport() {
+ radarViewportFrame = findViewById(R.id.radar_viewport_frame);
+ radarInstrumentsPanel = findViewById(R.id.radar_instruments_panel);
+ radarContentLayout = findViewById(R.id.radar_plotter_content);
+ if (radarContentLayout == null || radarViewportFrame == null || radarInstrumentsPanel == null) {
+ return;
+ }
+ radarContentLayout.getViewTreeObserver().addOnGlobalLayoutListener(squareViewportLayoutListener);
+ }
+
+ private void applySquareRadarViewport() {
+ if (radarContentLayout == null || radarViewportFrame == null || radarInstrumentsPanel == null) {
+ return;
+ }
+
+ int contentW = radarContentLayout.getWidth()
+ - radarContentLayout.getPaddingLeft() - radarContentLayout.getPaddingRight();
+ int contentH = radarContentLayout.getHeight()
+ - radarContentLayout.getPaddingTop() - radarContentLayout.getPaddingBottom();
+ if (contentW <= 0 || contentH <= 0) {
+ return;
+ }
+ if (contentW == lastSquareLayoutContentW && contentH == lastSquareLayoutContentH) {
+ return;
+ }
+ lastSquareLayoutContentW = contentW;
+ lastSquareLayoutContentH = contentH;
+
+ int squareSize = Math.min(contentW, contentH);
+
+ ViewGroup.LayoutParams vpRaw = radarViewportFrame.getLayoutParams();
+ ViewGroup.LayoutParams panelRaw = radarInstrumentsPanel.getLayoutParams();
+ if (!(vpRaw instanceof LinearLayout.LayoutParams) || !(panelRaw instanceof LinearLayout.LayoutParams)) {
+ return;
+ }
+ if (!(radarContentLayout instanceof LinearLayout)) {
+ return;
+ }
+
+ LinearLayout content = (LinearLayout) radarContentLayout;
+ LinearLayout.LayoutParams vpLp = (LinearLayout.LayoutParams) vpRaw;
+ LinearLayout.LayoutParams panelLp = (LinearLayout.LayoutParams) panelRaw;
+ boolean vertical = content.getOrientation() == LinearLayout.VERTICAL;
+
+ if (vertical) {
+ if (vpLp.width == ViewGroup.LayoutParams.MATCH_PARENT
+ && vpLp.height == squareSize
+ && vpLp.weight == 0f
+ && panelLp.height == 0
+ && panelLp.weight == 1f) {
+ return;
+ }
+ vpLp.width = ViewGroup.LayoutParams.MATCH_PARENT;
+ vpLp.height = squareSize;
+ vpLp.weight = 0f;
+ panelLp.width = ViewGroup.LayoutParams.MATCH_PARENT;
+ panelLp.height = 0;
+ panelLp.weight = 1f;
+ } else {
+ if (vpLp.width == squareSize
+ && vpLp.height == ViewGroup.LayoutParams.MATCH_PARENT
+ && vpLp.weight == 0f
+ && panelLp.width == 0
+ && panelLp.weight == 1f) {
+ return;
+ }
+ vpLp.width = squareSize;
+ vpLp.height = ViewGroup.LayoutParams.MATCH_PARENT;
+ vpLp.weight = 0f;
+ panelLp.width = 0;
+ panelLp.height = ViewGroup.LayoutParams.MATCH_PARENT;
+ panelLp.weight = 1f;
+ }
+
+ radarViewportFrame.setLayoutParams(vpLp);
+ radarInstrumentsPanel.setLayoutParams(panelLp);
+ }
+
+ private void applyPlotterInsets() {
+ View panel = findViewById(R.id.radar_instruments_panel);
+ if (panel != null) {
+ int pad = Math.round(getResources().getDisplayMetrics().density * 8);
+ UiInsetsUtils.applySystemBarPadding(panel, pad);
+ }
+ }
+
+ private void tickUi() {
+ if (appCoordinator == null) return;
+
+ double ppiRangeM = resolvePpiRangeMeters();
+ double dangerM = settingsManager.isRangeRingsEnabled()
+ ? settingsManager.getDangerRadiusMeters() : 0.0;
+
+ if (tvRange != null) {
+ tvRange.setText(getString(R.string.radar_plotter_range_label) + ": "
+ + formatRangeLabel(ppiRangeM));
+ }
+ if (graticuleOverlay != null) {
+ graticuleOverlay.setRangeMeters(ppiRangeM);
+ graticuleOverlay.setRangeUnit(settingsManager.getRangeUnit());
+ }
+
+ Vessel own = appCoordinator.getOwnVessel();
+ float heading = 0f;
+ double speedKn = 0.0;
+ if (own != null) {
+ heading = (float) (own.getCourse() > 0 ? own.getCourse() : own.getHeading());
+ speedKn = own.getSpeed();
+ if (graticuleOverlay != null) {
+ graticuleOverlay.setHeadingUpDeg(heading);
+ }
+ if (headingView != null) {
+ float mag = (float) own.getMagneticCompass();
+ headingView.setHeading(heading, mag > 0 ? mag : Float.NaN);
+ }
+ if (speedometerView != null) {
+ speedometerView.setSpeedKnots(speedKn);
+ }
+ if (mapHelper != null && GeoUtils.isValidCoordinates(own.getLatitude(), own.getLongitude())) {
+ mapHelper.centerOnOwnShip(own.getLatitude(), own.getLongitude(), heading, ppiRangeM);
+ }
+ }
+
+ List nearest =
+ appCoordinator.getDangerTargets(ppiRangeM, TABLE_LIMIT);
+ if (graticuleOverlay != null) {
+ graticuleOverlay.setAllTargetsInRange(nearest, dangerM);
+ }
+ if (targetsTableView != null) {
+ targetsTableView.setRowsFromCoordinatorEntries(nearest);
+ }
+
+ handler.removeCallbacks(updateRunnable);
+ handler.postDelayed(updateRunnable, UPDATE_INTERVAL_MS);
+ }
+
+ private double resolvePpiRangeMeters() {
+ if (settingsManager.isRangeFilterEnabled()) {
+ double f = settingsManager.getFilterRadiusMeters();
+ if (f > 0) return f;
+ }
+ if (settingsManager.isRangeRingsEnabled()) {
+ double w = settingsManager.getWarningRadiusMeters();
+ if (w > 0) return w;
+ }
+ return RangeMath.toMeters(5.0, settingsManager.getRangeUnit());
+ }
+
+ private String formatRangeLabel(double meters) {
+ if (SettingsManager.RANGE_UNIT_KM.equals(settingsManager.getRangeUnit())) {
+ if (meters >= 1000.0) {
+ return String.format(Locale.US, "%.1f km", meters / 1000.0);
+ }
+ return String.format(Locale.US, "%.0f m", meters);
+ }
+ return String.format(Locale.US, "%.1f nm", meters / RangeMath.METERS_PER_NM);
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ if (mapHelper != null) mapHelper.onStart();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (mapHelper != null) mapHelper.onResume();
+ handler.post(updateRunnable);
+ }
+
+ @Override
+ protected void onPause() {
+ handler.removeCallbacks(updateRunnable);
+ if (mapHelper != null) mapHelper.onPause();
+ super.onPause();
+ }
+
+ @Override
+ protected void onStop() {
+ if (mapHelper != null) mapHelper.onStop();
+ super.onStop();
+ }
+
+ @Override
+ protected void onDestroy() {
+ handler.removeCallbacks(updateRunnable);
+ if (radarContentLayout != null) {
+ radarContentLayout.getViewTreeObserver()
+ .removeOnGlobalLayoutListener(squareViewportLayoutListener);
+ }
+ if (mapHelper != null) mapHelper.onDestroy();
+ super.onDestroy();
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (mapHelper != null) mapHelper.onSaveInstanceState(outState);
+ }
+
+ @Override
+ public void onLowMemory() {
+ super.onLowMemory();
+ if (mapHelper != null) mapHelper.onLowMemory();
+ }
+}
diff --git a/app/src/main/java/com/grigowashere/aismap/SettingsActivity.java b/app/src/main/java/com/grigowashere/aismap/SettingsActivity.java
index 8a8a4a8..fbbbe26 100644
--- a/app/src/main/java/com/grigowashere/aismap/SettingsActivity.java
+++ b/app/src/main/java/com/grigowashere/aismap/SettingsActivity.java
@@ -16,6 +16,7 @@ import com.google.android.material.switchmaterial.SwitchMaterial;
import androidx.appcompat.app.AppCompatActivity;
import com.grigowashere.aismap.utils.SettingsManager;
+import com.grigowashere.aismap.utils.UiInsetsUtils;
/**
* Экран настроек приложения
@@ -54,6 +55,22 @@ public class SettingsActivity extends AppCompatActivity {
private com.google.android.material.textfield.TextInputLayout tilOpenInterfaces;
private EditText etOpenInterfaces;
+ // Range rings
+ private SwitchMaterial switchRangeRingsEnabled;
+ private SwitchMaterial switchRangeFilterEnabled;
+ private RadioGroup radioGroupRangeUnit;
+ private RadioButton radioRangeUnitNm;
+ private RadioButton radioRangeUnitKm;
+ private EditText etRangeDanger;
+ private EditText etRangeWarning;
+ private EditText etRangeFilter;
+
+ // Navigator camera
+ private SwitchMaterial switchNavigatorCameraEnabled;
+ private EditText etNavigatorMaxSpeed;
+ private EditText etNavigatorZoomZero;
+ private EditText etNavigatorZoomMax;
+
// Path/prediction
private EditText etPathMaxPoints;
private EditText etPathWidth;
@@ -79,7 +96,8 @@ public class SettingsActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_settings);
-
+ applySettingsInsets();
+
// Инициализируем менеджер настроек
settingsManager = new SettingsManager(this);
@@ -97,6 +115,14 @@ public class SettingsActivity extends AppCompatActivity {
Log.i(TAG, "SettingsActivity создан");
}
+
+ private void applySettingsInsets() {
+ View scroll = findViewById(R.id.settings_scroll);
+ if (scroll != null) {
+ int pad = Math.round(getResources().getDisplayMetrics().density * 16);
+ UiInsetsUtils.applySystemBarPadding(scroll, pad);
+ }
+ }
/**
* Инициализирует UI элементы
@@ -136,6 +162,23 @@ public class SettingsActivity extends AppCompatActivity {
etPredictionWidth = findViewById(R.id.et_prediction_width);
etPredictionColor = findViewById(R.id.et_prediction_color);
etPredictionHorizon = findViewById(R.id.et_prediction_horizon_sec);
+
+ // Range rings
+ switchRangeRingsEnabled = findViewById(R.id.switch_range_rings_enabled);
+ switchRangeFilterEnabled = findViewById(R.id.switch_range_filter_enabled);
+ radioGroupRangeUnit = findViewById(R.id.radio_group_range_unit);
+ radioRangeUnitNm = findViewById(R.id.radio_range_unit_nm);
+ radioRangeUnitKm = findViewById(R.id.radio_range_unit_km);
+ etRangeDanger = findViewById(R.id.et_range_danger);
+ etRangeWarning = findViewById(R.id.et_range_warning);
+ etRangeFilter = findViewById(R.id.et_range_filter);
+
+ switchNavigatorCameraEnabled = findViewById(R.id.switch_navigator_camera_enabled);
+ etNavigatorMaxSpeed = findViewById(R.id.et_navigator_max_speed);
+ etNavigatorZoomZero = findViewById(R.id.et_navigator_zoom_zero);
+ etNavigatorZoomMax = findViewById(R.id.et_navigator_zoom_max);
+
+ // Connection thresholds
}
/**
@@ -195,7 +238,38 @@ public class SettingsActivity extends AppCompatActivity {
etPredictionWidth.setText(String.valueOf(settingsManager.getPredictionWidth()));
etPredictionColor.setText(String.format("#%06X", (0xFFFFFF & settingsManager.getPredictionColor())));
etPredictionHorizon.setText(String.valueOf(settingsManager.getPredictionHorizonSec()));
-
+
+ // Кольца дальности
+ if (switchRangeRingsEnabled != null) {
+ switchRangeRingsEnabled.setChecked(settingsManager.isRangeRingsEnabled());
+ }
+ if (switchRangeFilterEnabled != null) {
+ switchRangeFilterEnabled.setChecked(settingsManager.isRangeFilterEnabled());
+ }
+ if (radioRangeUnitNm != null && radioRangeUnitKm != null) {
+ if (SettingsManager.RANGE_UNIT_KM.equals(settingsManager.getRangeUnit())) {
+ radioRangeUnitKm.setChecked(true);
+ } else {
+ radioRangeUnitNm.setChecked(true);
+ }
+ }
+ if (etRangeDanger != null) etRangeDanger.setText(String.valueOf(settingsManager.getRangeDanger()));
+ if (etRangeWarning != null) etRangeWarning.setText(String.valueOf(settingsManager.getRangeWarning()));
+ if (etRangeFilter != null) etRangeFilter.setText(String.valueOf(settingsManager.getRangeFilter()));
+
+ if (switchNavigatorCameraEnabled != null) {
+ switchNavigatorCameraEnabled.setChecked(settingsManager.isNavigatorCameraEnabled());
+ }
+ if (etNavigatorMaxSpeed != null) {
+ etNavigatorMaxSpeed.setText(String.valueOf(settingsManager.getNavigatorMaxSpeedKnots()));
+ }
+ if (etNavigatorZoomZero != null) {
+ etNavigatorZoomZero.setText(String.valueOf(settingsManager.getNavigatorZoomAtZeroSpeed()));
+ }
+ if (etNavigatorZoomMax != null) {
+ etNavigatorZoomMax.setText(String.valueOf(settingsManager.getNavigatorZoomAtMaxSpeed()));
+ }
+
Log.i(TAG, "Настройки загружены в UI");
}
@@ -397,7 +471,16 @@ public class SettingsActivity extends AppCompatActivity {
try { settingsManager.setPredictionWidth(Float.parseFloat(etPredictionWidth.getText().toString().trim())); } catch (Exception ignored) {}
try { settingsManager.setPredictionColor(parseColor(etPredictionColor.getText().toString().trim(), settingsManager.getPredictionColor())); } catch (Exception ignored) {}
try { settingsManager.setPredictionHorizonSec(Integer.parseInt(etPredictionHorizon.getText().toString().trim())); } catch (Exception ignored) {}
-
+
+ // Кольца дальности
+ if (!saveRangeRingsSettings()) {
+ return;
+ }
+
+ if (!saveNavigatorCameraSettings()) {
+ return;
+ }
+
Log.i(TAG, "Настройки сохранены: " + settingsManager.getSettingsSummary());
// Проверяем, нужно ли уведомить MainActivity об изменениях
@@ -525,6 +608,100 @@ public class SettingsActivity extends AppCompatActivity {
settingsManager.shouldRestartNMEA(originalAndroidNMEAEnabled, originalUDPNMEAEnabled, originalDataMode);
}
+ /**
+ * Сохраняет настройки колец дальности с валидацией danger < warning < filter.
+ * Возвращает {@code false}, если данные некорректны, и не закрывает экран.
+ */
+ private boolean saveRangeRingsSettings() {
+ if (etRangeDanger == null || etRangeWarning == null || etRangeFilter == null) {
+ return true;
+ }
+ try {
+ float danger = parseRange(etRangeDanger.getText().toString().trim(), settingsManager.getRangeDanger());
+ float warning = parseRange(etRangeWarning.getText().toString().trim(), settingsManager.getRangeWarning());
+ float filter = parseRange(etRangeFilter.getText().toString().trim(), settingsManager.getRangeFilter());
+ if (danger <= 0f || warning <= 0f || filter <= 0f) {
+ Toast.makeText(this, R.string.settings_range_validation_positive, Toast.LENGTH_LONG).show();
+ return false;
+ }
+ if (!(danger < warning && warning < filter)) {
+ Toast.makeText(this, R.string.settings_range_validation_order, Toast.LENGTH_LONG).show();
+ return false;
+ }
+
+ String unit = SettingsManager.RANGE_UNIT_NM;
+ if (radioGroupRangeUnit != null) {
+ int checked = radioGroupRangeUnit.getCheckedRadioButtonId();
+ if (checked == R.id.radio_range_unit_km) {
+ unit = SettingsManager.RANGE_UNIT_KM;
+ }
+ }
+
+ settingsManager.setRangeUnit(unit);
+ settingsManager.setRangeDanger(danger);
+ settingsManager.setRangeWarning(warning);
+ settingsManager.setRangeFilter(filter);
+ if (switchRangeRingsEnabled != null) {
+ settingsManager.setRangeRingsEnabled(switchRangeRingsEnabled.isChecked());
+ }
+ if (switchRangeFilterEnabled != null) {
+ settingsManager.setRangeFilterEnabled(switchRangeFilterEnabled.isChecked());
+ }
+ return true;
+ } catch (Exception e) {
+ Log.w(TAG, "saveRangeRingsSettings: " + e.getMessage(), e);
+ return true; // фейл валидации не должен блокировать всё сохранение
+ }
+ }
+
+ private float parseRange(String text, float fallback) {
+ try {
+ if (text == null || text.isEmpty()) return fallback;
+ return Float.parseFloat(text.replace(',', '.'));
+ } catch (Exception e) {
+ return fallback;
+ }
+ }
+
+ private boolean saveNavigatorCameraSettings() {
+ try {
+ if (switchNavigatorCameraEnabled != null) {
+ settingsManager.setNavigatorCameraEnabled(switchNavigatorCameraEnabled.isChecked());
+ }
+ if (etNavigatorMaxSpeed != null) {
+ float maxSpeed = parseRange(
+ etNavigatorMaxSpeed.getText().toString().trim(),
+ settingsManager.getNavigatorMaxSpeedKnots());
+ if (maxSpeed < 1f) {
+ Toast.makeText(this, "Макс. скорость должна быть не меньше 1 уз", Toast.LENGTH_SHORT).show();
+ return false;
+ }
+ settingsManager.setNavigatorMaxSpeedKnots(maxSpeed);
+ }
+ if (etNavigatorZoomZero != null) {
+ settingsManager.setNavigatorZoomAtZeroSpeed(parseRange(
+ etNavigatorZoomZero.getText().toString().trim(),
+ settingsManager.getNavigatorZoomAtZeroSpeed()));
+ }
+ if (etNavigatorZoomMax != null) {
+ float zoomMax = parseRange(
+ etNavigatorZoomMax.getText().toString().trim(),
+ settingsManager.getNavigatorZoomAtMaxSpeed());
+ float zoomZero = settingsManager.getNavigatorZoomAtZeroSpeed();
+ if (zoomMax >= zoomZero) {
+ Toast.makeText(this, "Зум при макс. скорости должен быть меньше зума при 0 уз",
+ Toast.LENGTH_LONG).show();
+ return false;
+ }
+ settingsManager.setNavigatorZoomAtMaxSpeed(zoomMax);
+ }
+ return true;
+ } catch (Exception e) {
+ Log.w(TAG, "saveNavigatorCameraSettings: " + e.getMessage(), e);
+ return true;
+ }
+ }
+
/**
* Очищает трекер пути собственного судна
*/
diff --git a/app/src/main/java/com/grigowashere/aismap/ble/hub/AisHubGattClient.java b/app/src/main/java/com/grigowashere/aismap/ble/hub/AisHubGattClient.java
index 2dc1f2f..827e56b 100644
--- a/app/src/main/java/com/grigowashere/aismap/ble/hub/AisHubGattClient.java
+++ b/app/src/main/java/com/grigowashere/aismap/ble/hub/AisHubGattClient.java
@@ -104,6 +104,11 @@ public class AisHubGattClient {
private static final long SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS = 300_000L;
private static final long SNAPSHOT_RECOVERY_IDLE_MS = 10_000L;
+ /** Чтение Battery (0x180F/0x2A19) включено пользователем. По умолчанию off. */
+ private volatile boolean batteryReadEnabled = false;
+ /** Залогировали один раз, чтобы не спамить, если стек продолжает возвращать auth-error. */
+ private volatile boolean authWarningLogged = false;
+
public AisHubGattClient(@NonNull Context context) {
this.appContext = context.getApplicationContext();
BluetoothManager bm = (BluetoothManager) appContext.getSystemService(Context.BLUETOOTH_SERVICE);
@@ -118,10 +123,32 @@ public class AisHubGattClient {
this.deviceMac = mac;
}
+ /**
+ * Включает/выключает периодическое чтение характеристики Battery
+ * ({@code 0x180F/0x2A19}). На некоторых устройствах эта характеристика
+ * требует шифрования, что приводит к появлению системного диалога
+ * сопряжения. По умолчанию выключено.
+ */
+ public void setBatteryReadEnabled(boolean enabled) {
+ this.batteryReadEnabled = enabled;
+ if (!enabled) {
+ batteryLoop.set(false);
+ if (batteryTask != null) {
+ try { batteryTask.cancel(false); } catch (Throwable ignore) {}
+ batteryTask = null;
+ }
+ }
+ }
+
public boolean isRunning() {
return running.get();
}
+ /** {@code true}, если GATT-сессия в состоянии {@link BluetoothProfile#STATE_CONNECTED}. */
+ public boolean isConnected() {
+ return connected;
+ }
+
public void start() {
if (running.get()) {
Log.w(TAG, "AIS Hub GATT already running");
@@ -175,7 +202,12 @@ public class AisHubGattClient {
public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
if (!running.get()) return;
if (BLE_LOG) Log.d(TAG, "onConnectionStateChange: status=" + status + " newState=" + newState);
- if (status != BluetoothGatt.GATT_SUCCESS && status != 4 && status != 133) {
+ if (isAuthStatus(status)) {
+ logAuthOnce("onConnectionStateChange status=" + status);
+ // ВАЖНО: BLE — основной источник данных. Auth-статус мы НЕ считаем
+ // фатальным: продолжаем reconnect-цикл, а проблемные операции
+ // (read battery и т.п.) сами по себе уже отключены/опциональны.
+ } else if (status != BluetoothGatt.GATT_SUCCESS && status != 4 && status != 133) {
postError("BLE connect status: " + status);
} else if (status == 133) {
lastErrorWasDbFull = true;
@@ -197,6 +229,7 @@ public class AisHubGattClient {
connectionStartTimeMs = 0L;
isConnecting.set(false);
lastErrorWasDbFull = false;
+ authWarningLogged = false;
reconnectLoop.set(false);
notifReady.set(false);
mtuRequested.set(false);
@@ -314,13 +347,24 @@ public class AisHubGattClient {
public void onDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor descriptor, int st) {
gattBusy.set(false);
if (BLE_LOG) Log.d(TAG, "onDescriptorWrite: uuid=" + descriptor.getUuid() + " status=" + st);
+ if (isAuthStatus(st)) {
+ // Не валим соединение и не показываем плашку — просто логируем
+ // один раз и пропускаем эту необязательную операцию (CCCD
+ // на доп.характеристиках, например STATUS).
+ logAuthOnce("onDescriptorWrite status=" + st + " uuid=" + descriptor.getUuid());
+ return;
+ }
if (st == BluetoothGatt.GATT_SUCCESS && CCCD.equals(descriptor.getUuid())) {
notifReady.set(true);
lastDataAtMs = System.currentTimeMillis();
postState("notifying");
- try { resolveBatteryAndSchedule(g); } catch (Throwable ignore) {}
- readBatteryOnce(g);
- startBatteryLoop(g);
+ if (batteryReadEnabled) {
+ try { resolveBatteryAndSchedule(g); } catch (Throwable ignore) {}
+ readBatteryOnce(g);
+ startBatteryLoop(g);
+ } else if (BLE_LOG) {
+ Log.d(TAG, "Battery read disabled by settings (skipping resolve/read/loop)");
+ }
enqueueControlJson(buildHello());
// Snapshot триггерим ТОЛЬКО на HELLO_ACK (см. processDataRaw),
@@ -358,6 +402,17 @@ public class AisHubGattClient {
@Override
public void onCharacteristicRead(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
+ if (isAuthStatus(status)) {
+ gattBusy.set(false);
+ logAuthOnce("onCharacteristicRead status=" + status + " uuid=" + ch.getUuid());
+ // Если это была попытка чтения Battery — на всякий случай гасим
+ // её, чтобы не провоцировать повторный системный диалог.
+ if (BATTERY_LEVEL.equals(ch.getUuid())
+ || (toShort(ch.getUuid()) != null && toShort(ch.getUuid()) == 0x2A19)) {
+ setBatteryReadEnabled(false);
+ }
+ return;
+ }
if (status == BluetoothGatt.GATT_SUCCESS) {
if (BATTERY_LEVEL.equals(ch.getUuid()) || (toShort(ch.getUuid()) != null && toShort(ch.getUuid()) == 0x2A19)) {
byte[] v = ch.getValue();
@@ -371,6 +426,13 @@ public class AisHubGattClient {
@Override
public void onCharacteristicWrite(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
+ if (isAuthStatus(status)) {
+ gattBusy.set(false);
+ logAuthOnce("onCharacteristicWrite status=" + status + " uuid=" + ch.getUuid());
+ // Просто допускаем дренаж очереди дальше — соединение НЕ рвём.
+ mainHandler.post(AisHubGattClient.this::drainControlQueue);
+ return;
+ }
if (controlChar != null && ch.getUuid().equals(controlChar.getUuid())) {
gattBusy.set(false);
if (status != BluetoothGatt.GATT_SUCCESS) {
@@ -778,6 +840,31 @@ public class AisHubGattClient {
}, 2000, 10_000, TimeUnit.MILLISECONDS);
}
+ /**
+ * Возвращает {@code true} для GATT-статусов, требующих сопряжения:
+ *
+ *
{@code 5} — {@code GATT_INSUF_AUTHENTICATION}
+ *
{@code 8} — {@code GATT_INSUF_ENCRYPTION}
+ *
{@code 137} — {@code GATT_AUTH_FAIL}
+ *
+ */
+ private static boolean isAuthStatus(int status) {
+ return status == 5 || status == 8 || status == 137;
+ }
+
+ /**
+ * Лог auth-status. Один раз пишем в логи, чтобы не спамить, но соединение
+ * НЕ рвём и reconnect-loop не глушим — BLE является основным источником
+ * данных, отключать его по auth-ошибке нельзя. Само сопряжение — это
+ * системный диалог Android, который пользователь может проигнорировать.
+ */
+ private void logAuthOnce(String reason) {
+ if (authWarningLogged) return;
+ authWarningLogged = true;
+ Log.w(TAG, "BLE auth status (suppressed, connection kept): " + reason);
+ LogSender.logBLEError("auth status (suppressed): " + reason, deviceMac, "AisHub");
+ }
+
private void postState(String s) {
if (callback != null) {
mainHandler.post(() -> callback.onState(s));
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 7e3ebff..58149b1 100644
--- a/app/src/main/java/com/grigowashere/aismap/controllers/AppCoordinator.java
+++ b/app/src/main/java/com/grigowashere/aismap/controllers/AppCoordinator.java
@@ -48,6 +48,7 @@ public class AppCoordinator implements
private NotificationController notificationController;
private CompassController compassController;
private MapController mapController;
+ private NavigatorCameraController navigatorCameraController;
private AisHubGattClient aisHubGattClient;
// Состояние приложения
@@ -108,6 +109,7 @@ public class AppCoordinator implements
this.aisPathControllers = new HashMap<>();
this.settingsManager = new SettingsManager(context);
this.pathController = new VesselPathController(context, settingsManager);
+ this.navigatorCameraController = new NavigatorCameraController(settingsManager);
this.uiHandler = new Handler(Looper.getMainLooper());
initializeControllers();
@@ -193,6 +195,39 @@ public class AppCoordinator implements
if (mapController != null) {
mapController.addMapInterfaceChangeListener(this);
Log.i(TAG, "AppCoordinator подключен к MapController");
+ syncNavigatorMapInterface();
+ }
+ }
+
+ public NavigatorCameraController getNavigatorCameraController() {
+ return navigatorCameraController;
+ }
+
+ public boolean isNavigatorCameraEnabled() {
+ return navigatorCameraController != null && navigatorCameraController.isEnabled();
+ }
+
+ public void setNavigatorCameraEnabled(boolean enabled) {
+ if (navigatorCameraController == null) return;
+ navigatorCameraController.setEnabled(enabled);
+ if (enabled) {
+ syncNavigatorMapInterface();
+ updateNavigatorCamera();
+ }
+ }
+
+ public void toggleNavigatorCamera() {
+ setNavigatorCameraEnabled(!isNavigatorCameraEnabled());
+ }
+
+ /**
+ * Вызывается из MainActivity после инициализации карты — подключает навигатор к MapInterface.
+ */
+ public void onMapInterfaceReady(MapInterface mapInterface) {
+ if (navigatorCameraController == null) return;
+ navigatorCameraController.setMapInterface(mapInterface);
+ if (navigatorCameraController.isEnabled()) {
+ updateNavigatorCamera();
}
}
@@ -216,7 +251,12 @@ public class AppCoordinator implements
networkController.startUDPListener();
compassController.startCompass();
dataController.startDatabaseCleanup();
- // BLE старт по настройкам
+ // BLE: применяем настройки (MAC + batteryRead) до старта клиента,
+ // иначе клиент стартует «голым» и любые опциональные операции
+ // (например, чтение Battery 0x2A19) могут спровоцировать системный
+ // диалог сопряжения. Конфигурация ставит batteryReadEnabled=false
+ // по умолчанию — это убирает основной триггер pairing.
+ configureBleFromSettings();
tryStartBleIfEnabled();
// Восстанавливаем данные из БД
@@ -266,7 +306,9 @@ public class AppCoordinator implements
if (aisHubGattClient == null) return;
String mac = settingsManager.getBLEDeviceMac();
aisHubGattClient.setDeviceMac(mac);
- Log.i(TAG, "BLE AIS Hub: mac=" + mac);
+ boolean batteryEnabled = settingsManager.isBleReadBatteryEnabled();
+ aisHubGattClient.setBatteryReadEnabled(batteryEnabled);
+ Log.i(TAG, "BLE AIS Hub: mac=" + mac + " batteryRead=" + batteryEnabled);
}
private void tryStartBleIfEnabled() {
@@ -476,16 +518,56 @@ public class AppCoordinator implements
return;
}
final List copy = new ArrayList<>(vessels);
- for (int start = 0; start < copy.size(); start += AIS_UI_BATCH_SIZE) {
+ // Если включён фильтр-круг — отбрасываем цели за его пределами и
+ // одновременно «снимаем» их с карты (publishAisRemovalsToUiBatched).
+ final boolean filterEnabled = settingsManager != null
+ && settingsManager.isRangeFilterEnabled();
+ final double filterRadiusM = filterEnabled
+ ? settingsManager.getFilterRadiusMeters()
+ : Double.POSITIVE_INFINITY;
+ final boolean ownValid = ownVessel != null
+ && com.grigowashere.aismap.utils.GeoUtils.isValidCoordinates(
+ ownVessel.getLatitude(), ownVessel.getLongitude());
+ final List inRange;
+ final List filteredOutMmsis;
+ if (filterEnabled && ownValid && filterRadiusM > 0.0
+ && filterRadiusM != Double.POSITIVE_INFINITY) {
+ inRange = new ArrayList<>(copy.size());
+ filteredOutMmsis = new ArrayList<>();
+ for (AISVessel v : copy) {
+ if (v == null) continue;
+ if (!com.grigowashere.aismap.utils.GeoUtils.isValidCoordinates(
+ v.getLatitude(), v.getLongitude())) {
+ inRange.add(v);
+ continue;
+ }
+ double d = com.grigowashere.aismap.utils.GeoUtils.calculateDistance(
+ ownVessel.getLatitude(), ownVessel.getLongitude(),
+ v.getLatitude(), v.getLongitude());
+ if (d <= filterRadiusM) {
+ inRange.add(v);
+ } else if (v.getMmsi() != null) {
+ filteredOutMmsis.add(v.getMmsi());
+ }
+ }
+ } else {
+ inRange = copy;
+ filteredOutMmsis = null;
+ }
+
+ for (int start = 0; start < inRange.size(); start += AIS_UI_BATCH_SIZE) {
final int from = start;
- final int to = Math.min(start + AIS_UI_BATCH_SIZE, copy.size());
+ final int to = Math.min(start + AIS_UI_BATCH_SIZE, inRange.size());
uiHandler.post(() -> {
if (uiDataNotifier == null) return;
for (int i = from; i < to; i++) {
- uiDataNotifier.onAISVesselChanged(copy.get(i));
+ uiDataNotifier.onAISVesselChanged(inRange.get(i));
}
});
}
+ if (filteredOutMmsis != null && !filteredOutMmsis.isEmpty()) {
+ publishAisRemovalsToUiBatched(filteredOutMmsis);
+ }
}
private void publishAisRemovalsToUiBatched(List mmsis) {
@@ -559,6 +641,8 @@ public class AppCoordinator implements
" mode=" + settingsManager.getDataMode());
}
+ updateNavigatorCamera();
+
// Важно: ownship.update может приходить очень часто (десятки раз в секунду).
// Модель обновляем всегда, а тяжёлые операции (путь/БД/UI) — с throttling,
// чтобы не забивать главный поток и не провоцировать нестабильность BLE.
@@ -674,6 +758,7 @@ public class AppCoordinator implements
ownVessel.setActiveSatellites(vessel.getActiveSatellites());
markRecentGpsActivity();
+ updateNavigatorCamera();
if (pathController != null && isValidCoordinates(ownVessel.getLatitude(), ownVessel.getLongitude())) {
pathController.addPathPoint(
ownVessel.getLongitude(),
@@ -729,6 +814,7 @@ public class AppCoordinator implements
// Обновляем компас после изменения курса
updateCompass();
+ updateNavigatorCamera();
// Сохраняем в БД
dataController.saveVesselPosition(ownVessel);
@@ -1055,7 +1141,8 @@ public class AppCoordinator implements
// Устанавливаем MarkerClickListener на новую карту
newMapInterface.setMarkerClickListener(this);
Log.i(TAG, "MarkerClickListener установлен на новую карту");
-
+ syncNavigatorMapInterface();
+ updateNavigatorCamera();
// Восстанавливаем состояние на новой карте
restoreMapStateOnNewInterface();
}
@@ -1167,6 +1254,65 @@ public class AppCoordinator implements
return nearby;
}
+
+ /**
+ * Контейнер «цель + дистанция/пеленг до собственного судна».
+ * Используется виджетом ближайших целей в зоне опасности.
+ */
+ public static final class DangerEntry {
+ public final AISVessel vessel;
+ /** Дистанция в метрах. */
+ public final double distanceMeters;
+ /** Пеленг от собственного судна на цель, °. */
+ public final double bearingDegrees;
+
+ public DangerEntry(AISVessel vessel, double distanceMeters, double bearingDegrees) {
+ this.vessel = vessel;
+ this.distanceMeters = distanceMeters;
+ this.bearingDegrees = bearingDegrees;
+ }
+ }
+
+ /**
+ * Возвращает цели в зоне опасности (по {@code maxRadiusMeters}) с
+ * дистанцией и пеленгом, отсортированные по возрастанию дистанции.
+ * Если собственная позиция неизвестна — возвращает пустой список.
+ *
+ * @param maxRadiusMeters максимальный радиус, м
+ * @param limit максимальное число записей (>=1)
+ */
+ public List getDangerTargets(double maxRadiusMeters, int limit) {
+ List result = new ArrayList<>();
+ if (!(maxRadiusMeters > 0.0)) return result;
+ if (ownVessel == null) return result;
+ double oLat = ownVessel.getLatitude();
+ double oLon = ownVessel.getLongitude();
+ if (!com.grigowashere.aismap.utils.GeoUtils.isValidCoordinates(oLat, oLon)) return result;
+
+ synchronized (aisVessels) {
+ for (AISVessel vessel : aisVessels.values()) {
+ if (vessel == null) continue;
+ double lat = vessel.getLatitude();
+ double lon = vessel.getLongitude();
+ if (!com.grigowashere.aismap.utils.GeoUtils.isValidCoordinates(lat, lon)) continue;
+ 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));
+ }
+ }
+ }
+ java.util.Collections.sort(result, (a, b) -> Double.compare(a.distanceMeters, b.distanceMeters));
+ if (limit > 0 && result.size() > limit) {
+ return new ArrayList<>(result.subList(0, limit));
+ }
+ return result;
+ }
+
+ /** Возвращает {@code ownVessel} (может быть {@code null} до первой фиксации). */
+ public Vessel getOwnVesselSnapshot() {
+ return ownVessel;
+ }
private void updateCompass() {
if (listener != null) {
@@ -1347,21 +1493,52 @@ public class AppCoordinator implements
public Integer getLastBleBattery() {
return lastBleBattery;
}
+
+ /**
+ * {@code true}, если BLE включён, MAC задан, клиент запущен, но GATT не подключён
+ * (в т.ч. идёт переподключение). В этом режиме данным с хаба доверять нельзя.
+ */
+ public boolean isBleHubLinkLost() {
+ if (!settingsManager.isBLEEnabled()) return false;
+ String mac = settingsManager.getBLEDeviceMac();
+ if (mac == null || mac.trim().isEmpty()) return false;
+ if (aisHubGattClient == null || !aisHubGattClient.isRunning()) return false;
+ return !aisHubGattClient.isConnected();
+ }
/**
* Центрирует карту на позиции нашего судна
*/
public void centerOnOwnVessel() {
- if (ownVessel != null) {
- Log.d(TAG, "Запрос центрирования карты на судне: " + ownVessel.getLatitude() + "," + ownVessel.getLongitude());
-
- // Уведомляем UI Coordinator о необходимости центрирования карты
- if (uiDataNotifier != null) {
- uiDataNotifier.onRequestCenterMap(ownVessel.getLatitude(), ownVessel.getLongitude());
- } else {
- Log.w(TAG, "uiDataNotifier не установлен, центрирование карты пропущено");
- }
+ if (ownVessel == null) return;
+ Log.d(TAG, "Запрос центрирования карты на судне: " + ownVessel.getLatitude() + "," + ownVessel.getLongitude());
+
+ syncNavigatorMapInterface();
+ if (navigatorCameraController != null && navigatorCameraController.isEnabled()) {
+ navigatorCameraController.onOwnVesselUpdated(ownVessel);
+ return;
}
+ if (navigatorCameraController != null && mapController != null
+ && mapController.getCurrentMapInterface() != null) {
+ navigatorCameraController.centerOnOwnVesselNow(ownVessel);
+ return;
+ }
+ if (uiDataNotifier != null) {
+ uiDataNotifier.onRequestCenterMap(ownVessel.getLatitude(), ownVessel.getLongitude());
+ } else {
+ Log.w(TAG, "uiDataNotifier не установлен, центрирование карты пропущено");
+ }
+ }
+
+ private void syncNavigatorMapInterface() {
+ if (navigatorCameraController == null || mapController == null) return;
+ navigatorCameraController.setMapInterface(mapController.getCurrentMapInterface());
+ }
+
+ private void updateNavigatorCamera() {
+ if (navigatorCameraController == null || !navigatorCameraController.isEnabled()) return;
+ syncNavigatorMapInterface();
+ navigatorCameraController.onOwnVesselUpdated(ownVessel);
}
/**
diff --git a/app/src/main/java/com/grigowashere/aismap/controllers/NavigatorCameraController.java b/app/src/main/java/com/grigowashere/aismap/controllers/NavigatorCameraController.java
new file mode 100644
index 0000000..2773755
--- /dev/null
+++ b/app/src/main/java/com/grigowashere/aismap/controllers/NavigatorCameraController.java
@@ -0,0 +1,296 @@
+package com.grigowashere.aismap.controllers;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.util.Log;
+
+import com.grigowashere.aismap.maps.MapInterface;
+import com.grigowashere.aismap.models.Vessel;
+import com.grigowashere.aismap.utils.GeoUtils;
+import com.grigowashere.aismap.utils.NavigatorZoomMath;
+import com.grigowashere.aismap.utils.SettingsManager;
+
+/**
+ * Следит за собственным судном: центр карты на позиции, зум от скорости,
+ * плавные переходы камеры и bearing (если включён режим компас/курс).
+ */
+public class NavigatorCameraController {
+
+ private static final String TAG = "NavigatorCamera";
+ private static final long FRAME_MS = 16L;
+ /** Пауза следования после последнего жеста пользователя на карте. */
+ public static final long USER_OVERRIDE_RESUME_MS = 5000L;
+ private static final float POSITION_ALPHA = 0.20f;
+ private static final float ZOOM_ALPHA = 0.14f;
+ /** Меньше alpha — плавнее поворот карты с небольшой задержкой. */
+ private static final float BEARING_ALPHA = 0.09f;
+
+ private final SettingsManager settingsManager;
+ private final Handler handler;
+
+ private MapInterface map;
+ private Vessel lastVessel;
+ private boolean followLoopRunning;
+ private boolean userOverrideActive;
+ private long lastUserInteractionUptimeMs;
+
+ private double targetLat = Double.NaN;
+ private double targetLon = Double.NaN;
+ private float targetZoom = 14f;
+
+ private final Runnable followLoopRunnable = this::onFollowFrame;
+ private final Runnable overrideResumeRunnable = this::onOverrideResumeTimeout;
+ private final MapInterface.MapUserInteractionListener userInteractionListener =
+ this::onUserMapInteraction;
+
+ public NavigatorCameraController(SettingsManager settingsManager) {
+ this.settingsManager = settingsManager;
+ this.handler = new Handler(Looper.getMainLooper());
+ }
+
+ public void setMapInterface(MapInterface map) {
+ if (this.map != null) {
+ this.map.setMapUserInteractionListener(null);
+ }
+ this.map = map;
+ if (map != null) {
+ map.setMapUserInteractionListener(userInteractionListener);
+ }
+ if (isEnabled()) {
+ ensureFollowLoop();
+ }
+ }
+
+ public boolean isUserOverrideActive() {
+ return userOverrideActive;
+ }
+
+ /**
+ * Пользователь сдвинул/масштабировал/повернул карту — временно отключаем авто-камеру.
+ */
+ public void onUserMapInteraction() {
+ if (!isEnabled()) {
+ return;
+ }
+ lastUserInteractionUptimeMs = SystemClock.uptimeMillis();
+ if (!userOverrideActive) {
+ userOverrideActive = true;
+ Log.d(TAG, "Follow paused: user map interaction");
+ }
+ scheduleOverrideResumeCheck();
+ }
+
+ public boolean isEnabled() {
+ return settingsManager != null && settingsManager.isNavigatorCameraEnabled();
+ }
+
+ public void setEnabled(boolean enabled) {
+ if (settingsManager == null) return;
+ settingsManager.setNavigatorCameraEnabled(enabled);
+ if (enabled) {
+ ensureFollowLoop();
+ if (lastVessel != null) {
+ applyTargetsFromVessel(lastVessel);
+ }
+ } else {
+ clearUserOverride();
+ stopFollowLoop();
+ }
+ }
+
+ /**
+ * Вызывается при каждом обновлении координат/скорости собственного судна.
+ */
+ public void onOwnVesselUpdated(Vessel vessel) {
+ if (!isEnabled() || vessel == null) {
+ return;
+ }
+ lastVessel = vessel;
+ applyTargetsFromVessel(vessel);
+ if (map == null) {
+ return;
+ }
+ ensureFollowLoop();
+ }
+
+ /**
+ * Однократное центрирование с зумом по скорости (кнопка «на судно»).
+ */
+ public void centerOnOwnVesselNow(Vessel vessel) {
+ if (vessel == null || map == null) return;
+ lastVessel = vessel;
+ double lat = vessel.getLatitude();
+ double lon = vessel.getLongitude();
+ if (!GeoUtils.isValidCoordinates(lat, lon)) return;
+
+ float zoom = zoomForVessel(vessel);
+ long duration = settingsManager.getNavigatorCameraTransitionMs();
+ float bearing = resolveTargetBearing(vessel);
+ if (Float.isNaN(bearing)) {
+ bearing = map.getBearing();
+ }
+ map.moveCameraSmooth(lat, lon, zoom, duration);
+ targetLat = lat;
+ targetLon = lon;
+ targetZoom = zoom;
+ }
+
+ private void applyTargetsFromVessel(Vessel vessel) {
+ targetLat = vessel.getLatitude();
+ targetLon = vessel.getLongitude();
+ targetZoom = zoomForVessel(vessel);
+ }
+
+ private float zoomForVessel(Vessel vessel) {
+ double speed = vessel != null ? vessel.getSpeed() : 0.0;
+ return NavigatorZoomMath.zoomForSpeed(
+ speed,
+ settingsManager.getNavigatorZoomAtZeroSpeed(),
+ settingsManager.getNavigatorZoomAtMaxSpeed(),
+ settingsManager.getNavigatorMaxSpeedKnots());
+ }
+
+ private void ensureFollowLoop() {
+ if (!isEnabled() || followLoopRunning) return;
+ followLoopRunning = true;
+ handler.post(followLoopRunnable);
+ }
+
+ private void stopFollowLoop() {
+ followLoopRunning = false;
+ handler.removeCallbacks(followLoopRunnable);
+ handler.removeCallbacks(overrideResumeRunnable);
+ }
+
+ private void clearUserOverride() {
+ userOverrideActive = false;
+ handler.removeCallbacks(overrideResumeRunnable);
+ }
+
+ private void scheduleOverrideResumeCheck() {
+ handler.removeCallbacks(overrideResumeRunnable);
+ handler.postDelayed(overrideResumeRunnable, USER_OVERRIDE_RESUME_MS);
+ }
+
+ private void onOverrideResumeTimeout() {
+ if (!isEnabled() || !userOverrideActive) {
+ return;
+ }
+ long idle = SystemClock.uptimeMillis() - lastUserInteractionUptimeMs;
+ if (idle < USER_OVERRIDE_RESUME_MS) {
+ scheduleOverrideResumeCheck();
+ return;
+ }
+ userOverrideActive = false;
+ Log.d(TAG, "Follow resumed after user idle");
+ if (lastVessel != null) {
+ applyTargetsFromVessel(lastVessel);
+ }
+ }
+
+ private void onFollowFrame() {
+ if (!isEnabled() || map == null) {
+ followLoopRunning = false;
+ return;
+ }
+ if (userOverrideActive) {
+ handler.postDelayed(followLoopRunnable, FRAME_MS);
+ return;
+ }
+ if (!GeoUtils.isValidCoordinates(targetLat, targetLon)) {
+ handler.postDelayed(followLoopRunnable, FRAME_MS);
+ return;
+ }
+
+ double curLat = map.getCenterLatitude();
+ double curLon = map.getCenterLongitude();
+ float curZoom = map.getZoom();
+ float curBearing = map.getBearing();
+
+ if (!GeoUtils.isValidCoordinates(curLat, curLon)) {
+ curLat = targetLat;
+ curLon = targetLon;
+ curZoom = targetZoom;
+ }
+
+ double newLat = NavigatorZoomMath.lerp(curLat, targetLat, POSITION_ALPHA);
+ double newLon = NavigatorZoomMath.lerp(curLon, targetLon, POSITION_ALPHA);
+ float newZoom = NavigatorZoomMath.lerp(curZoom, targetZoom, ZOOM_ALPHA);
+
+ float bearingArg = Float.NaN;
+ float targetBearing = resolveTargetBearing(lastVessel);
+ if (!Float.isNaN(targetBearing)) {
+ bearingArg = NavigatorZoomMath.lerpBearing(curBearing, targetBearing, BEARING_ALPHA);
+ }
+
+ try {
+ map.setCameraView(newLat, newLon, newZoom, bearingArg);
+ } catch (Exception e) {
+ Log.w(TAG, "onFollowFrame: " + e.getMessage());
+ }
+
+ handler.postDelayed(followLoopRunnable, FRAME_MS);
+ }
+
+ /**
+ * Bearing для карты по настройке вращения; {@link Float#NaN} — не менять (ручной режим).
+ */
+ float resolveTargetBearing(Vessel vessel) {
+ if (settingsManager == null || vessel == null) {
+ return Float.NaN;
+ }
+ String mode = settingsManager.getMapRotationMode();
+ if (SettingsManager.MAP_ROTATION_COMPASS.equals(mode)) {
+ double c = vessel.getMagneticCompass();
+ if (!Double.isNaN(c)) {
+ return NavigatorZoomMath.normalizeBearing360((float) c);
+ }
+ } else if (SettingsManager.MAP_ROTATION_COURSE.equals(mode)) {
+ double cog = vessel.getCourse();
+ if (!Double.isNaN(cog)) {
+ return NavigatorZoomMath.normalizeBearing360((float) cog);
+ }
+ }
+ return Float.NaN;
+ }
+
+ /**
+ * Плавный переход к цели за фиксированное время (разовые вызовы).
+ */
+ public static void runSmoothTransition(MapInterface map,
+ double fromLat, double fromLon, float fromZoom,
+ double toLat, double toLon, float toZoom,
+ long durationMs,
+ Handler handler,
+ Runnable onComplete) {
+ if (map == null || handler == null) return;
+ if (durationMs <= 0) {
+ map.setCameraView(toLat, toLon, toZoom, Float.NaN);
+ if (onComplete != null) onComplete.run();
+ return;
+ }
+ final long startMs = SystemClock.uptimeMillis();
+ final float fromBearing = map.getBearing();
+ Runnable frame = new Runnable() {
+ @Override
+ public void run() {
+ float t = (SystemClock.uptimeMillis() - startMs) / (float) durationMs;
+ if (t >= 1f) t = 1f;
+ float eased = NavigatorZoomMath.easeOutCubic(t);
+ double lat = NavigatorZoomMath.lerp(fromLat, toLat, eased);
+ double lon = NavigatorZoomMath.lerp(fromLon, toLon, eased);
+ float zoom = NavigatorZoomMath.lerp(fromZoom, toZoom, eased);
+ try {
+ map.setCameraView(lat, lon, zoom, Float.NaN);
+ } catch (Exception ignore) { }
+ if (t < 1f) {
+ handler.postDelayed(this, FRAME_MS);
+ } else if (onComplete != null) {
+ onComplete.run();
+ }
+ }
+ };
+ handler.post(frame);
+ }
+}
diff --git a/app/src/main/java/com/grigowashere/aismap/maps/MapForgeImpl.java b/app/src/main/java/com/grigowashere/aismap/maps/MapForgeImpl.java
index 9d32b64..a594919 100644
--- a/app/src/main/java/com/grigowashere/aismap/maps/MapForgeImpl.java
+++ b/app/src/main/java/com/grigowashere/aismap/maps/MapForgeImpl.java
@@ -2,7 +2,11 @@ package com.grigowashere.aismap.maps;
import android.content.Context;
import android.graphics.Color;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.MotionEvent;
+import com.grigowashere.aismap.controllers.NavigatorCameraController;
import com.grigowashere.aismap.models.Vessel;
import com.grigowashere.aismap.models.AISVessel;
import com.grigowashere.aismap.view.CursorOverlay;
@@ -33,6 +37,8 @@ public class MapForgeImpl implements MapInterface {
private Marker ownVesselMarker;
private CursorOverlay cursorOverlay;
private Vessel ownVessel;
+ private final Handler uiHandler = new Handler(Looper.getMainLooper());
+ private MapUserInteractionListener mapUserInteractionListener;
public MapForgeImpl(Context context, MapView mapView) {
this.context = context;
@@ -165,6 +171,53 @@ public class MapForgeImpl implements MapInterface {
public float getZoom() {
return mapView.getModel().mapViewPosition.getZoomLevel();
}
+
+ @Override
+ public double getCenterLatitude() {
+ if (mapView == null) return Double.NaN;
+ try {
+ LatLong center = mapView.getModel().mapViewPosition.getCenter();
+ return center != null ? center.latitude : Double.NaN;
+ } catch (Exception e) {
+ return Double.NaN;
+ }
+ }
+
+ @Override
+ public double getCenterLongitude() {
+ if (mapView == null) return Double.NaN;
+ try {
+ LatLong center = mapView.getModel().mapViewPosition.getCenter();
+ return center != null ? center.longitude : Double.NaN;
+ } catch (Exception e) {
+ return Double.NaN;
+ }
+ }
+
+ @Override
+ public void setCameraView(double latitude, double longitude, float zoom, float bearingDegrees) {
+ if (mapView == null) return;
+ LatLong position = new LatLong(latitude, longitude);
+ mapView.getModel().mapViewPosition.setCenter(position);
+ mapView.getModel().mapViewPosition.setZoomLevel((byte) zoom);
+ // MapForge: bearing не поддерживается
+ }
+
+ @Override
+ public void moveCameraSmooth(double latitude, double longitude, float zoom, long durationMs) {
+ if (mapView == null) return;
+ double fromLat = getCenterLatitude();
+ double fromLon = getCenterLongitude();
+ float fromZoom = getZoom();
+ if (Double.isNaN(fromLat) || Double.isNaN(fromLon)) {
+ fromLat = latitude;
+ fromLon = longitude;
+ fromZoom = zoom;
+ }
+ NavigatorCameraController.runSmoothTransition(
+ this, fromLat, fromLon, fromZoom,
+ latitude, longitude, zoom, durationMs, uiHandler, null);
+ }
@Override
public void setBearing(float bearing) {
@@ -246,19 +299,26 @@ public class MapForgeImpl implements MapInterface {
}
}
+ @Override
+ public void setMapUserInteractionListener(MapUserInteractionListener listener) {
+ this.mapUserInteractionListener = listener;
+ }
+
/**
* Настраивает слушатель движения карты для обновления курсора
*/
private void setupMapMovementListener() {
- if (mapView != null) {
- // mapView.getModel().mapViewPosition.addObserver(new org.mapsforge.map.model.Observer() {
-// @Override
-// public void onChange() {
-// // Обновляем координаты курсора при движении карты
-// updateCursorFromMapCenter();
-// }
-// });
- }
+ if (mapView == null) return;
+ mapView.setOnTouchListener((v, event) -> {
+ int action = event.getActionMasked();
+ if (mapUserInteractionListener != null
+ && (action == MotionEvent.ACTION_DOWN
+ || action == MotionEvent.ACTION_MOVE
+ || action == MotionEvent.ACTION_POINTER_DOWN)) {
+ mapUserInteractionListener.onUserMapInteraction();
+ }
+ return false;
+ });
}
@Override
diff --git a/app/src/main/java/com/grigowashere/aismap/maps/MapInterface.java b/app/src/main/java/com/grigowashere/aismap/maps/MapInterface.java
index 070da47..b8a76ac 100644
--- a/app/src/main/java/com/grigowashere/aismap/maps/MapInterface.java
+++ b/app/src/main/java/com/grigowashere/aismap/maps/MapInterface.java
@@ -70,6 +70,40 @@ public interface MapInterface {
* Получение текущего зума
*/
float getZoom();
+
+ /**
+ * Широта центра карты (видимой области). Если неизвестна — {@link Double#NaN}.
+ */
+ default double getCenterLatitude() {
+ return Double.NaN;
+ }
+
+ /**
+ * Долгота центра карты. Если неизвестна — {@link Double#NaN}.
+ */
+ default double getCenterLongitude() {
+ return Double.NaN;
+ }
+
+ /**
+ * Атомарно задаёт центр, зум и (опционально) bearing одним обновлением камеры.
+ * {@code bearingDegrees == Float.NaN} — bearing не меняется.
+ */
+ default void setCameraView(double latitude, double longitude, float zoom, float bearingDegrees) {
+ centerOnPosition(latitude, longitude);
+ setZoom(zoom);
+ if (!Float.isNaN(bearingDegrees)) {
+ setBearing(bearingDegrees);
+ }
+ }
+
+ /**
+ * Плавно перемещает камеру к позиции с заданным зумом.
+ * {@code durationMs == 0} — мгновенно через {@link #setCameraView}.
+ */
+ default void moveCameraSmooth(double latitude, double longitude, float zoom, long durationMs) {
+ setCameraView(latitude, longitude, zoom, Float.NaN);
+ }
/**
* Установка курса (bearing) карты в градусах (0 = север вверх)
@@ -135,7 +169,46 @@ public interface MapInterface {
* Очистить информацию об AIS судне
*/
void clearAisVesselInfo();
-
+
+ /**
+ * Рисует/обновляет до трёх колец вокруг собственного судна.
+ * Все массивы должны быть одной длины (обычно 3: опасность/предупреждение/фильтр).
+ * Если карта ещё не готова или координаты невалидны — реализация молча игнорирует вызов.
+ *
+ * @param lat широта центра в градусах (собственное судно)
+ * @param lon долгота центра в градусах
+ * @param radiiMeters массив радиусов в метрах
+ * @param strokeColors массив цветов обводки (ARGB)
+ * @param fillColors массив цветов заливки (ARGB; 0 = без заливки)
+ * @param visible массив флагов видимости (false = пропустить кольцо)
+ */
+ default void setOwnShipRangeRings(double lat, double lon,
+ double[] radiiMeters, int[] strokeColors, int[] fillColors,
+ boolean[] visible) {
+ // Карты, не поддерживающие кольца, безопасно игнорируют вызов.
+ }
+
+ /**
+ * Полностью убирает все кольца дальности с карты.
+ */
+ default void clearOwnShipRangeRings() {
+ // no-op для неподдерживающих реализаций
+ }
+
+ /**
+ * Слушатель жестов пользователя на карте (пан, зум, поворот).
+ */
+ interface MapUserInteractionListener {
+ void onUserMapInteraction();
+ }
+
+ /**
+ * Подписка на жесты пользователя; {@code null} — отписаться.
+ */
+ default void setMapUserInteractionListener(MapUserInteractionListener listener) {
+ // no-op для реализаций без поддержки
+ }
+
/**
* Интерфейс для обработки кликов по меткам
*/
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 977ce83..59218f0 100644
--- a/app/src/main/java/com/grigowashere/aismap/maps/MapLibreMapImpl.java
+++ b/app/src/main/java/com/grigowashere/aismap/maps/MapLibreMapImpl.java
@@ -62,6 +62,24 @@ public class MapLibreMapImpl implements MapInterface {
private static final String LAYER_SEAMARKS = "seamarks_layer";
private static final String SOURCE_NAVIGATION_AIDS = "navigation_aids_source";
private static final String LAYER_NAVIGATION_AIDS = "navigation_aids_layer";
+ /** Подсветка целей в зоне предупреждения (CircleLayer под основным слоем судов). */
+ private static final String LAYER_VESSELS_WARNING_HALO = "vessels_warning_halo";
+ // Range rings around own ship (3 zones: danger / warning / filter).
+ private static final String[] RANGE_RING_SOURCES = {
+ "range_ring_danger_source",
+ "range_ring_warning_source",
+ "range_ring_filter_source"
+ };
+ private static final String[] RANGE_RING_FILL_LAYERS = {
+ "range_ring_danger_fill",
+ "range_ring_warning_fill",
+ "range_ring_filter_fill"
+ };
+ private static final String[] RANGE_RING_LINE_LAYERS = {
+ "range_ring_danger_line",
+ "range_ring_warning_line",
+ "range_ring_filter_line"
+ };
private static final String IMAGE_VESSEL_OWN = "ownship";
private static final String IMAGE_VESSEL_A = "vessel_icon_a";
private static final String IMAGE_VESSEL_B = "vessel_icon_b";
@@ -233,11 +251,18 @@ public class MapLibreMapImpl implements MapInterface {
private final Map aisPredictionFeatures = new HashMap<>();
private MarkerClickListener markerClickListener;
+ private MapUserInteractionListener mapUserInteractionListener;
// Pending центрирование до готовности карты/стиля
private Double pendingCenterLat = null;
private Double pendingCenterLon = null;
+ // ----- Warning-zone подсветка целей -----
+ /** Радиус зоны предупреждения в метрах; 0 = подсветка отключена. */
+ private volatile double warningRadiusMeters = 0.0;
+ private volatile double warningOwnLat = Double.NaN;
+ private volatile double warningOwnLon = Double.NaN;
+
public MapLibreMapImpl(Context context, MapView mapView) {
this.context = context;
this.mapView = mapView;
@@ -556,6 +581,7 @@ public class MapLibreMapImpl implements MapInterface {
String iconName = pickIconNameFor(vessel);
props.put("icon", iconName);
props.put("stale", stale);
+ props.put("warning_zone", isInWarningZone(vessel.getLatitude(), vessel.getLongitude()));
// Проставим статусную иконку, если статус поддержан
String status = vessel.getNavigationalStatus();
String statusIcon = mapStatusToIcon(status);
@@ -613,6 +639,7 @@ public class MapLibreMapImpl implements MapInterface {
JSONObject props = feature.getJSONObject("properties");
props.put("icon", pickIconNameFor(vessel));
props.put("stale", stale);
+ props.put("warning_zone", isInWarningZone(vessel.getLatitude(), vessel.getLongitude()));
String statusIcon = mapStatusToIcon(vessel.getNavigationalStatus());
if (statusIcon != null) {
props.put("status_icon", statusIcon);
@@ -633,6 +660,51 @@ public class MapLibreMapImpl implements MapInterface {
}
}
+ /**
+ * Сохраняет координаты собственного судна и радиус зоны предупреждения для
+ * data-driven подсветки целей. Передайте {@code warningRadiusMeters <= 0}
+ * чтобы выключить подсветку.
+ */
+ public void setWarningZoneParams(double ownLat, double ownLon, double warningRadiusMeters) {
+ this.warningOwnLat = ownLat;
+ this.warningOwnLon = ownLon;
+ this.warningRadiusMeters = warningRadiusMeters;
+ // Перепроставим warning_zone у уже известных судов и обновим источник.
+ try {
+ for (java.util.Map.Entry e : idToFeature.entrySet()) {
+ if ("own_vessel".equals(e.getKey())) continue;
+ AISVessel v = idToAisVessel.get(e.getKey());
+ if (v == null) continue;
+ JSONObject feature = e.getValue();
+ if (feature == null) continue;
+ try {
+ JSONObject props = feature.getJSONObject("properties");
+ props.put("warning_zone", isInWarningZone(v.getLatitude(), v.getLongitude()));
+ } catch (Exception ignore) {}
+ }
+ uiHandler.post(this::refreshGeoJson);
+ } catch (Throwable ignore) {}
+ }
+
+ /**
+ * Проверяет, попадает ли точка в зону предупреждения вокруг собственного
+ * судна. Если параметры зоны не заданы — возвращает {@code false}.
+ */
+ private boolean isInWarningZone(double lat, double lon) {
+ double r = warningRadiusMeters;
+ if (!(r > 0.0)) return false;
+ double oLat = warningOwnLat;
+ double oLon = warningOwnLon;
+ if (Double.isNaN(oLat) || Double.isNaN(oLon)) return false;
+ if (!GeoUtils.isValidCoordinates(lat, lon)) return false;
+ try {
+ double d = GeoUtils.calculateDistance(oLat, oLon, lat, lon);
+ return d <= r;
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+
@Override
public void removeAISVesselMarker(String mmsi) {
if (mmsi == null) return;
@@ -858,6 +930,74 @@ public class MapLibreMapImpl implements MapInterface {
}
}
+ @Override
+ public double getCenterLatitude() {
+ if (maplibreMap == null) return Double.NaN;
+ try {
+ org.maplibre.android.geometry.LatLng t = maplibreMap.getCameraPosition().target;
+ return t != null ? t.getLatitude() : Double.NaN;
+ } catch (Exception e) {
+ return Double.NaN;
+ }
+ }
+
+ @Override
+ public double getCenterLongitude() {
+ if (maplibreMap == null) return Double.NaN;
+ try {
+ org.maplibre.android.geometry.LatLng t = maplibreMap.getCameraPosition().target;
+ return t != null ? t.getLongitude() : Double.NaN;
+ } catch (Exception e) {
+ return Double.NaN;
+ }
+ }
+
+ @Override
+ public void setCameraView(double latitude, double longitude, float zoom, float bearingDegrees) {
+ if (maplibreMap == null || mapView == null) {
+ pendingCenterLat = latitude;
+ pendingCenterLon = longitude;
+ return;
+ }
+ try {
+ org.maplibre.android.camera.CameraPosition current = maplibreMap.getCameraPosition();
+ float bearing = Float.isNaN(bearingDegrees) ? (float) current.bearing : bearingDegrees;
+ maplibreMap.setCameraPosition(new org.maplibre.android.camera.CameraPosition.Builder()
+ .target(new LatLng(latitude, longitude))
+ .zoom(zoom)
+ .bearing(bearing)
+ .tilt(current.tilt)
+ .build());
+ } catch (Exception e) {
+ Log.w(TAG, "setCameraView: " + e.getMessage());
+ }
+ }
+
+ @Override
+ public void moveCameraSmooth(double latitude, double longitude, float zoom, long durationMs) {
+ if (maplibreMap == null) return;
+ if (durationMs <= 0) {
+ setCameraView(latitude, longitude, zoom, Float.NaN);
+ return;
+ }
+ try {
+ org.maplibre.android.camera.CameraPosition current = maplibreMap.getCameraPosition();
+ org.maplibre.android.camera.CameraPosition target =
+ new org.maplibre.android.camera.CameraPosition.Builder()
+ .target(new org.maplibre.android.geometry.LatLng(latitude, longitude))
+ .zoom(zoom)
+ .bearing(current.bearing)
+ .tilt(current.tilt)
+ .build();
+ maplibreMap.animateCamera(
+ org.maplibre.android.camera.CameraUpdateFactory.newCameraPosition(target),
+ (int) durationMs);
+ } catch (Exception e) {
+ Log.w(TAG, "moveCameraSmooth: " + e.getMessage());
+ setCameraView(latitude, longitude, zoom, Float.NaN);
+ }
+ }
+
@Override
public void addLayer(String layerId, Object layerData) {
if (style == null || !isStyleValid()) {
@@ -949,6 +1089,39 @@ public class MapLibreMapImpl implements MapInterface {
// Отладочные линии удалены
+ // Подсветка целей в зоне предупреждения (под основным слоем судов).
+ // Виден только для feature.properties.warning_zone == true.
+ if (style.getLayer(LAYER_VESSELS_WARNING_HALO) == null) {
+ try {
+ int haloColor = androidx.core.content.ContextCompat.getColor(
+ context, com.grigowashere.aismap.R.color.range_target_warning_halo);
+ org.maplibre.android.style.layers.CircleLayer haloLayer =
+ new org.maplibre.android.style.layers.CircleLayer(LAYER_VESSELS_WARNING_HALO, SOURCE_VESSELS)
+ .withFilter(Expression.eq(Expression.get("warning_zone"), true))
+ .withProperties(
+ PropertyFactory.circleColor(haloColor),
+ PropertyFactory.circleOpacity(0.55f),
+ PropertyFactory.circleStrokeColor(haloColor),
+ PropertyFactory.circleStrokeOpacity(0.95f),
+ PropertyFactory.circleStrokeWidth(2.0f),
+ PropertyFactory.circleRadius(
+ Expression.interpolate(
+ Expression.linear(),
+ Expression.zoom(),
+ Expression.stop(5, 6.0f),
+ Expression.stop(8, 9.0f),
+ Expression.stop(12, 14.0f),
+ Expression.stop(15, 20.0f),
+ Expression.stop(17, 26.0f)
+ )
+ )
+ );
+ style.addLayer(haloLayer);
+ } catch (Throwable t) {
+ Log.w(TAG, "Failed to add warning halo layer: " + t.getMessage());
+ }
+ }
+
// Слой символов (основные иконки)
if (style.getLayer(LAYER_VESSELS) == null) {
SymbolLayer layer = new SymbolLayer(LAYER_VESSELS, SOURCE_VESSELS)
@@ -3161,11 +3334,19 @@ public class MapLibreMapImpl implements MapInterface {
Log.e(TAG, "updateAdditionalLayers: ошибка обновления слоев", e);
}
}
+ @Override
+ public void setMapUserInteractionListener(MapUserInteractionListener listener) {
+ this.mapUserInteractionListener = listener;
+ }
+
private void setupMapMovementListener() {
if (maplibreMap != null) {
- maplibreMap.addOnCameraMoveListener(() -> {
- // Обновляем координаты курсора при движении карты
- updateCursorFromMapCenter();
+ maplibreMap.addOnCameraMoveListener(() -> updateCursorFromMapCenter());
+ maplibreMap.addOnCameraMoveStartedListener(reason -> {
+ if (reason == org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE
+ && mapUserInteractionListener != null) {
+ mapUserInteractionListener.onUserMapInteraction();
+ }
});
}
}
@@ -3198,4 +3379,145 @@ public class MapLibreMapImpl implements MapInterface {
Log.e(TAG, "removeSeamarksLayer: ошибка удаления слоя морских знаков", e);
}
}
+
+ // ===== Range rings around own ship =====
+
+ /**
+ * Количество вершин в полигоне-аппроксимации круга. 64 — компромисс
+ * между плавностью контура на низком зуме и стоимостью обновления
+ * GeoJsonSource на 1 Hz.
+ */
+ private static final int RANGE_RING_VERTICES = 64;
+ /** Радиус Земли (м) — тот же, что и в GeoUtils, держим локально, чтобы избежать межмодульной зависимости. */
+ private static final double RANGE_EARTH_RADIUS_M = 6371000.0;
+
+ @Override
+ public void setOwnShipRangeRings(double lat, double lon,
+ double[] radiiMeters, int[] strokeColors, int[] fillColors,
+ boolean[] visible) {
+ if (style == null || !isStyleValid()) {
+ return;
+ }
+ if (radiiMeters == null || strokeColors == null || fillColors == null || visible == null) {
+ return;
+ }
+ // Координаты должны быть в валидном диапазоне; иначе круги превратятся в мусор.
+ if (Double.isNaN(lat) || Double.isNaN(lon) ||
+ lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) {
+ return;
+ }
+ int n = Math.min(RANGE_RING_SOURCES.length,
+ Math.min(radiiMeters.length, Math.min(strokeColors.length,
+ Math.min(fillColors.length, visible.length))));
+
+ try {
+ for (int i = 0; i < n; i++) {
+ String sourceId = RANGE_RING_SOURCES[i];
+ String fillId = RANGE_RING_FILL_LAYERS[i];
+ String lineId = RANGE_RING_LINE_LAYERS[i];
+
+ if (!visible[i] || radiiMeters[i] <= 0.0) {
+ removeRangeRingLayers(sourceId, fillId, lineId);
+ continue;
+ }
+
+ org.maplibre.geojson.Polygon polygon = buildCirclePolygon(lat, lon, radiiMeters[i]);
+ org.maplibre.geojson.Feature feature = org.maplibre.geojson.Feature.fromGeometry(polygon);
+
+ GeoJsonSource source = (GeoJsonSource) style.getSource(sourceId);
+ if (source == null) {
+ source = new GeoJsonSource(sourceId, feature);
+ style.addSource(source);
+ } else {
+ source.setGeoJson(feature);
+ }
+
+ if (style.getLayer(fillId) == null) {
+ org.maplibre.android.style.layers.FillLayer fillLayer =
+ new org.maplibre.android.style.layers.FillLayer(fillId, sourceId);
+ fillLayer.setProperties(
+ org.maplibre.android.style.layers.PropertyFactory.fillColor(fillColors[i]),
+ org.maplibre.android.style.layers.PropertyFactory.fillOpacity(1.0f)
+ );
+ if (style.getLayer(LAYER_VESSELS) != null) {
+ style.addLayerBelow(fillLayer, LAYER_VESSELS);
+ } else {
+ style.addLayer(fillLayer);
+ }
+ } else {
+ style.getLayer(fillId).setProperties(
+ org.maplibre.android.style.layers.PropertyFactory.fillColor(fillColors[i])
+ );
+ }
+
+ if (style.getLayer(lineId) == null) {
+ org.maplibre.android.style.layers.LineLayer lineLayer =
+ new org.maplibre.android.style.layers.LineLayer(lineId, sourceId);
+ lineLayer.setProperties(
+ org.maplibre.android.style.layers.PropertyFactory.lineColor(strokeColors[i]),
+ org.maplibre.android.style.layers.PropertyFactory.lineWidth(2f),
+ org.maplibre.android.style.layers.PropertyFactory.lineOpacity(0.95f)
+ );
+ if (style.getLayer(LAYER_VESSELS) != null) {
+ style.addLayerBelow(lineLayer, LAYER_VESSELS);
+ } else {
+ style.addLayer(lineLayer);
+ }
+ } else {
+ style.getLayer(lineId).setProperties(
+ org.maplibre.android.style.layers.PropertyFactory.lineColor(strokeColors[i])
+ );
+ }
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "setOwnShipRangeRings: " + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public void clearOwnShipRangeRings() {
+ if (style == null || !isStyleValid()) {
+ return;
+ }
+ try {
+ for (int i = 0; i < RANGE_RING_SOURCES.length; i++) {
+ removeRangeRingLayers(RANGE_RING_SOURCES[i], RANGE_RING_FILL_LAYERS[i], RANGE_RING_LINE_LAYERS[i]);
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "clearOwnShipRangeRings: " + e.getMessage(), e);
+ }
+ }
+
+ private void removeRangeRingLayers(String sourceId, String fillId, String lineId) {
+ try {
+ if (style.getLayer(fillId) != null) style.removeLayer(fillId);
+ if (style.getLayer(lineId) != null) style.removeLayer(lineId);
+ if (style.getSource(sourceId) != null) style.removeSource(sourceId);
+ } catch (Exception ignore) {}
+ }
+
+ /**
+ * Возвращает полигон-аппроксимацию окружности радиуса {@code radiusMeters}
+ * вокруг точки ({@code centerLat}, {@code centerLon}) с {@link #RANGE_RING_VERTICES} вершинами.
+ */
+ private static org.maplibre.geojson.Polygon buildCirclePolygon(double centerLat, double centerLon, double radiusMeters) {
+ double latRad = Math.toRadians(centerLat);
+ double lonRad = Math.toRadians(centerLon);
+ double angularDistance = radiusMeters / RANGE_EARTH_RADIUS_M;
+
+ java.util.List ring = new java.util.ArrayList<>(RANGE_RING_VERTICES + 1);
+ for (int i = 0; i <= RANGE_RING_VERTICES; i++) {
+ double bearing = Math.toRadians((360.0 / RANGE_RING_VERTICES) * i);
+ double lat2 = Math.asin(Math.sin(latRad) * Math.cos(angularDistance)
+ + Math.cos(latRad) * Math.sin(angularDistance) * Math.cos(bearing));
+ double lon2 = lonRad + Math.atan2(
+ Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latRad),
+ Math.cos(angularDistance) - Math.sin(latRad) * Math.sin(lat2));
+ ring.add(org.maplibre.geojson.Point.fromLngLat(Math.toDegrees(lon2), Math.toDegrees(lat2)));
+ }
+
+ java.util.List> outer = new java.util.ArrayList<>(1);
+ outer.add(ring);
+ return org.maplibre.geojson.Polygon.fromLngLats(outer);
+ }
}
diff --git a/app/src/main/java/com/grigowashere/aismap/maps/RadarMapHelper.java b/app/src/main/java/com/grigowashere/aismap/maps/RadarMapHelper.java
new file mode 100644
index 0000000..02a4c6f
--- /dev/null
+++ b/app/src/main/java/com/grigowashere/aismap/maps/RadarMapHelper.java
@@ -0,0 +1,103 @@
+package com.grigowashere.aismap.maps;
+
+import android.util.Log;
+
+import org.maplibre.android.camera.CameraPosition;
+import org.maplibre.android.camera.CameraUpdateFactory;
+import org.maplibre.android.geometry.LatLng;
+import org.maplibre.android.maps.MapLibreMap;
+import org.maplibre.android.maps.MapView;
+
+/**
+ * Минимальная инициализация MapLibre для режима картплоттера:
+ * только береговые тайлы, без маркеров AIS и без жестов.
+ */
+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 final MapView mapView;
+ private MapLibreMap map;
+ private boolean styleLoaded;
+
+ public RadarMapHelper(MapView mapView) {
+ this.mapView = mapView;
+ }
+
+ public void initialize(Runnable onReady) {
+ mapView.getMapAsync(loadedMap -> {
+ map = loadedMap;
+ try {
+ if (map.getUiSettings() != null) {
+ map.getUiSettings().setCompassEnabled(false);
+ map.getUiSettings().setAttributionEnabled(false);
+ map.getUiSettings().setLogoEnabled(false);
+ map.getUiSettings().setRotateGesturesEnabled(false);
+ map.getUiSettings().setScrollGesturesEnabled(false);
+ map.getUiSettings().setZoomGesturesEnabled(false);
+ map.getUiSettings().setTiltGesturesEnabled(false);
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "UI settings: " + e.getMessage());
+ }
+ map.setStyle(STYLE_URL, style -> {
+ styleLoaded = true;
+ if (onReady != null) onReady.run();
+ });
+ });
+ }
+
+ public void onStart() {
+ mapView.onStart();
+ }
+
+ public void onResume() {
+ mapView.onResume();
+ }
+
+ public void onPause() {
+ mapView.onPause();
+ }
+
+ public void onStop() {
+ mapView.onStop();
+ }
+
+ public void onDestroy() {
+ mapView.onDestroy();
+ }
+
+ public void onSaveInstanceState(android.os.Bundle outState) {
+ mapView.onSaveInstanceState(outState);
+ }
+
+ public void onLowMemory() {
+ mapView.onLowMemory();
+ }
+
+ /**
+ * Центрирует карту на собственном судне; 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);
+ }
+
+ /** Подбирает зум так, чтобы весь радиус PPI помещался в круговой области. */
+ static double zoomForRangeMeters(double rangeMeters) {
+ double nm = Math.max(0.25, rangeMeters / 1852.0);
+ return 14.8 - Math.log10(nm) * 2.35;
+ }
+}
diff --git a/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java b/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java
index a208c82..4ff3045 100644
--- a/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java
+++ b/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java
@@ -40,6 +40,7 @@ public class YandexMapImpl implements MapInterface {
// Слушатель поворота карты
private com.yandex.mapkit.map.InputListener inputListener;
+ private MapUserInteractionListener mapUserInteractionListener;
private float lastMapAzimuth = 0.0f;
// Курсор overlay
@@ -134,6 +135,7 @@ public class YandexMapImpl implements MapInterface {
if (markerManager != null) {
markerManager.updateAISVesselMarker(vessel);
}
+ updateWarningHaloForVessel(vessel);
}
@Override
@@ -141,6 +143,7 @@ public class YandexMapImpl implements MapInterface {
if (markerManager != null) {
markerManager.updateAISVesselMarker(vessel);
}
+ updateWarningHaloForVessel(vessel);
}
@Override
@@ -148,6 +151,7 @@ public class YandexMapImpl implements MapInterface {
if (vessels == null || markerManager == null) return;
for (AISVessel vessel : vessels) {
markerManager.updateAISVesselMarker(vessel);
+ updateWarningHaloForVessel(vessel);
}
}
@@ -156,6 +160,12 @@ public class YandexMapImpl implements MapInterface {
if (markerManager != null) {
markerManager.removeAISVesselMarker(mmsi);
}
+ if (mmsi != null) {
+ com.yandex.mapkit.map.CircleMapObject halo = warningHalos.remove(mmsi);
+ if (halo != null && mapObjects != null) {
+ try { mapObjects.remove(halo); } catch (Throwable ignore) {}
+ }
+ }
}
@Override
@@ -163,6 +173,7 @@ public class YandexMapImpl implements MapInterface {
if (markerManager != null) {
markerManager.clearAISVesselMarkers();
}
+ clearAllWarningHalos();
}
@Override
@@ -185,6 +196,56 @@ public class YandexMapImpl implements MapInterface {
return mapView.getMap().getCameraPosition().getZoom();
}
+ @Override
+ public double getCenterLatitude() {
+ try {
+ com.yandex.mapkit.geometry.Point p = mapView.getMap().getCameraPosition().getTarget();
+ return p != null ? p.getLatitude() : Double.NaN;
+ } catch (Exception e) {
+ return Double.NaN;
+ }
+ }
+
+ @Override
+ public double getCenterLongitude() {
+ try {
+ com.yandex.mapkit.geometry.Point p = mapView.getMap().getCameraPosition().getTarget();
+ return p != null ? p.getLongitude() : Double.NaN;
+ } catch (Exception e) {
+ return Double.NaN;
+ }
+ }
+
+ @Override
+ public void setCameraView(double latitude, double longitude, float zoom, float bearingDegrees) {
+ try {
+ Point point = new Point(latitude, longitude);
+ CameraPosition current = mapView.getMap().getCameraPosition();
+ float azimuth = Float.isNaN(bearingDegrees) ? current.getAzimuth() : bearingDegrees;
+ CameraPosition pos = new CameraPosition(point, zoom, azimuth, current.getTilt());
+ mapView.getMap().move(pos, new Animation(Animation.Type.SMOOTH, 0.35f), null);
+ } catch (Exception ignore) {
+ centerOnPosition(latitude, longitude);
+ setZoom(zoom);
+ if (!Float.isNaN(bearingDegrees)) {
+ setBearing(bearingDegrees);
+ }
+ }
+ }
+
+ @Override
+ public void moveCameraSmooth(double latitude, double longitude, float zoom, long durationMs) {
+ try {
+ Point point = new Point(latitude, longitude);
+ float durationSec = durationMs <= 0 ? 0.1f : Math.min(3f, durationMs / 1000f);
+ CameraPosition current = mapView.getMap().getCameraPosition();
+ CameraPosition pos = new CameraPosition(point, zoom, current.getAzimuth(), current.getTilt());
+ mapView.getMap().move(pos, new Animation(Animation.Type.SMOOTH, durationSec), null);
+ } catch (Exception ignore) {
+ setCameraView(latitude, longitude, zoom, Float.NaN);
+ }
+ }
+
@Override
public void setBearing(float bearing) {
try {
@@ -325,6 +386,173 @@ public class YandexMapImpl implements MapInterface {
// В YandexMapImpl VesselPathController не используется напрямую,
// но если в будущем будет использоваться, нужно добавить очистку
}
+
+ // ===== Range rings around own ship =====
+
+ /** Ссылки на нарисованные кольца (3 зоны). */
+ private final com.yandex.mapkit.map.CircleMapObject[] rangeRingObjects =
+ new com.yandex.mapkit.map.CircleMapObject[3];
+
+ @Override
+ public void setOwnShipRangeRings(double lat, double lon,
+ double[] radiiMeters, int[] strokeColors, int[] fillColors,
+ boolean[] visible) {
+ if (mapObjects == null) return;
+ if (radiiMeters == null || strokeColors == null || fillColors == null || visible == null) return;
+ if (Double.isNaN(lat) || Double.isNaN(lon)) return;
+
+ int n = Math.min(rangeRingObjects.length,
+ Math.min(radiiMeters.length, Math.min(strokeColors.length,
+ Math.min(fillColors.length, visible.length))));
+ try {
+ for (int i = 0; i < n; i++) {
+ if (!visible[i] || radiiMeters[i] <= 0.0) {
+ if (rangeRingObjects[i] != null) {
+ try { mapObjects.remove(rangeRingObjects[i]); } catch (Throwable ignore) {}
+ rangeRingObjects[i] = null;
+ }
+ continue;
+ }
+ com.yandex.mapkit.geometry.Circle circle = new com.yandex.mapkit.geometry.Circle(
+ new com.yandex.mapkit.geometry.Point(lat, lon),
+ (float) radiiMeters[i]);
+ if (rangeRingObjects[i] == null) {
+ rangeRingObjects[i] = mapObjects.addCircle(circle);
+ try {
+ rangeRingObjects[i].setStrokeColor(strokeColors[i]);
+ rangeRingObjects[i].setStrokeWidth(2f);
+ rangeRingObjects[i].setFillColor(fillColors[i]);
+ } catch (Throwable ignore) {}
+ } else {
+ try {
+ rangeRingObjects[i].setGeometry(circle);
+ rangeRingObjects[i].setStrokeColor(strokeColors[i]);
+ rangeRingObjects[i].setStrokeWidth(2f);
+ rangeRingObjects[i].setFillColor(fillColors[i]);
+ } catch (Throwable t) {
+ // Если объект финализирован — пересоздаём.
+ try { mapObjects.remove(rangeRingObjects[i]); } catch (Throwable ignore) {}
+ rangeRingObjects[i] = mapObjects.addCircle(circle);
+ try {
+ rangeRingObjects[i].setStrokeColor(strokeColors[i]);
+ rangeRingObjects[i].setStrokeWidth(2f);
+ rangeRingObjects[i].setFillColor(fillColors[i]);
+ } catch (Throwable ignore) {}
+ }
+ }
+ }
+ } catch (Throwable t) {
+ android.util.Log.w("YandexMapImpl", "setOwnShipRangeRings: " + t.getMessage());
+ }
+ }
+
+ @Override
+ public void clearOwnShipRangeRings() {
+ if (mapObjects == null) return;
+ for (int i = 0; i < rangeRingObjects.length; i++) {
+ if (rangeRingObjects[i] != null) {
+ try { mapObjects.remove(rangeRingObjects[i]); } catch (Throwable ignore) {}
+ rangeRingObjects[i] = null;
+ }
+ }
+ }
+
+ // ===== Warning-zone подсветка целей =====
+
+ private final Map warningHalos = new HashMap<>();
+ private volatile double warningRadiusMeters = 0.0;
+ private volatile double warningOwnLat = Double.NaN;
+ private volatile double warningOwnLon = Double.NaN;
+ /**
+ * Радиус halo-кольца вокруг цели (в метрах). Подобран небольшим, чтобы
+ * не загромождать карту, и виден на средних/больших зумах.
+ */
+ private static final double WARNING_HALO_RADIUS_M = 250.0;
+
+ /**
+ * Сохраняет параметры зоны предупреждения для подсветки целей.
+ * При {@code warningRadiusMeters <= 0} подсветка очищается.
+ */
+ public void setWarningZoneParams(double ownLat, double ownLon, double warningRadiusMeters) {
+ this.warningOwnLat = ownLat;
+ this.warningOwnLon = ownLon;
+ this.warningRadiusMeters = warningRadiusMeters;
+ if (!(warningRadiusMeters > 0.0)) {
+ clearAllWarningHalos();
+ }
+ }
+
+ private boolean isInWarningZone(double lat, double lon) {
+ double r = warningRadiusMeters;
+ if (!(r > 0.0)) return false;
+ double oLat = warningOwnLat;
+ double oLon = warningOwnLon;
+ if (Double.isNaN(oLat) || Double.isNaN(oLon)) return false;
+ try {
+ double d = com.grigowashere.aismap.utils.GeoUtils.calculateDistance(oLat, oLon, lat, lon);
+ return d <= r;
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+
+ /** Создаёт/обновляет/удаляет halo для одной цели в зависимости от попадания в зону. */
+ private void updateWarningHaloForVessel(AISVessel vessel) {
+ if (vessel == null || vessel.getMmsi() == null || mapObjects == null) return;
+ String mmsi = vessel.getMmsi();
+ boolean inZone = isInWarningZone(vessel.getLatitude(), vessel.getLongitude());
+ com.yandex.mapkit.map.CircleMapObject existing = warningHalos.get(mmsi);
+ try {
+ if (!inZone) {
+ if (existing != null) {
+ try { mapObjects.remove(existing); } catch (Throwable ignore) {}
+ warningHalos.remove(mmsi);
+ }
+ return;
+ }
+ int strokeColor = androidx.core.content.ContextCompat.getColor(context, R.color.range_target_warning_halo);
+ int fillColor = (strokeColor & 0x00FFFFFF) | 0x55000000;
+ com.yandex.mapkit.geometry.Circle circle = new com.yandex.mapkit.geometry.Circle(
+ new com.yandex.mapkit.geometry.Point(vessel.getLatitude(), vessel.getLongitude()),
+ (float) WARNING_HALO_RADIUS_M);
+ if (existing == null) {
+ com.yandex.mapkit.map.CircleMapObject created = mapObjects.addCircle(circle);
+ try {
+ created.setStrokeColor(strokeColor);
+ created.setStrokeWidth(2f);
+ created.setFillColor(fillColor);
+ } catch (Throwable ignore) {}
+ warningHalos.put(mmsi, created);
+ } else {
+ try {
+ existing.setGeometry(circle);
+ } catch (Throwable t) {
+ try { mapObjects.remove(existing); } catch (Throwable ignore) {}
+ com.yandex.mapkit.map.CircleMapObject created = mapObjects.addCircle(circle);
+ try {
+ created.setStrokeColor(strokeColor);
+ created.setStrokeWidth(2f);
+ created.setFillColor(fillColor);
+ } catch (Throwable ignore2) {}
+ warningHalos.put(mmsi, created);
+ }
+ }
+ } catch (Throwable t) {
+ android.util.Log.w("YandexMapImpl", "updateWarningHalo: " + t.getMessage());
+ }
+ }
+
+ private void clearAllWarningHalos() {
+ if (mapObjects == null) {
+ warningHalos.clear();
+ return;
+ }
+ for (com.yandex.mapkit.map.CircleMapObject obj : warningHalos.values()) {
+ if (obj == null) continue;
+ try { mapObjects.remove(obj); } catch (Throwable ignore) {}
+ }
+ warningHalos.clear();
+ }
/**
* Обновление всех путей судов на карте (заглушка для Yandex)
@@ -487,13 +715,21 @@ public class YandexMapImpl implements MapInterface {
/**
* Настраивает слушатель движения карты для обновления курсора
*/
+ @Override
+ public void setMapUserInteractionListener(MapUserInteractionListener listener) {
+ this.mapUserInteractionListener = listener;
+ }
+
private void setupMapMovementListener() {
if (mapView != null) {
mapView.getMap().addCameraListener(new com.yandex.mapkit.map.CameraListener() {
@Override
public void onCameraPositionChanged(com.yandex.mapkit.map.Map map, com.yandex.mapkit.map.CameraPosition cameraPosition, com.yandex.mapkit.map.CameraUpdateReason cameraUpdateReason, boolean finished) {
- // Обновляем координаты курсора при движении карты
updateCursorFromMapCenter();
+ if (cameraUpdateReason == com.yandex.mapkit.map.CameraUpdateReason.GESTURES
+ && mapUserInteractionListener != null) {
+ mapUserInteractionListener.onUserMapInteraction();
+ }
}
});
}
diff --git a/app/src/main/java/com/grigowashere/aismap/settings/InterfacesSettingsActivity.java b/app/src/main/java/com/grigowashere/aismap/settings/InterfacesSettingsActivity.java
index de512b6..553975a 100644
--- a/app/src/main/java/com/grigowashere/aismap/settings/InterfacesSettingsActivity.java
+++ b/app/src/main/java/com/grigowashere/aismap/settings/InterfacesSettingsActivity.java
@@ -31,6 +31,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.switchmaterial.SwitchMaterial;
import com.grigowashere.aismap.R;
import com.grigowashere.aismap.utils.SettingsManager;
+import com.grigowashere.aismap.utils.UiInsetsUtils;
import java.util.ArrayList;
import java.util.List;
@@ -55,6 +56,9 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
private EditText etBleBridgeHost;
private EditText etBleBridgePort;
+ // BLE optional battery read (system pairing trigger on some devices)
+ private SwitchMaterial swBleBatteryEnabled;
+
private Button btnSave;
private Button btnCancel;
@@ -72,6 +76,7 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_interfaces_settings);
+ applySettingsInsets();
settingsManager = new SettingsManager(this);
BluetoothManager bm = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
btAdapter = bm != null ? bm.getAdapter() : null;
@@ -82,6 +87,14 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
setupRecycler();
}
+ private void applySettingsInsets() {
+ View scroll = findViewById(R.id.settings_scroll);
+ if (scroll != null) {
+ int pad = Math.round(getResources().getDisplayMetrics().density * 16);
+ UiInsetsUtils.applySystemBarPadding(scroll, pad);
+ }
+ }
+
private void initViews() {
etUdpPort = findViewById(R.id.et_udp_port);
swUdpEnabled = findViewById(R.id.switch_udp_enabled);
@@ -90,6 +103,7 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
swBleBridgeEnabled = findViewById(R.id.switch_ble_udp_bridge_enabled);
etBleBridgeHost = findViewById(R.id.et_ble_udp_host);
etBleBridgePort = findViewById(R.id.et_ble_udp_port);
+ swBleBatteryEnabled = findViewById(R.id.switch_ble_battery_enabled);
btnSave = findViewById(R.id.btn_save);
btnCancel = findViewById(R.id.btn_cancel);
btnBleScan = findViewById(R.id.btn_ble_scan);
@@ -107,6 +121,10 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
swBleBridgeEnabled.setChecked(settingsManager.isBleUdpBridgeEnabled());
etBleBridgeHost.setText(settingsManager.getBleUdpBridgeHost());
etBleBridgePort.setText(String.valueOf(settingsManager.getBleUdpBridgePort()));
+
+ if (swBleBatteryEnabled != null) {
+ swBleBatteryEnabled.setChecked(settingsManager.isBleReadBatteryEnabled());
+ }
}
private void setupHandlers() {
@@ -202,6 +220,10 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
int brPort = parseInt(etBleBridgePort.getText().toString().trim(), 10110, 1, 65535);
settingsManager.setBleUdpBridgePort(brPort);
+ if (swBleBatteryEnabled != null) {
+ settingsManager.setBleReadBatteryEnabled(swBleBatteryEnabled.isChecked());
+ }
+
Toast.makeText(this, "Настройки сохранены", Toast.LENGTH_SHORT).show();
finish();
} catch (Exception e) {
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 d09c443..5342a77 100644
--- a/app/src/main/java/com/grigowashere/aismap/ui/BottomSheetsManager.java
+++ b/app/src/main/java/com/grigowashere/aismap/ui/BottomSheetsManager.java
@@ -196,60 +196,63 @@ public class BottomSheetsManager {
if (tvTitle != null) {
String name = vessel.getVesselName() != null && !vessel.getVesselName().isEmpty() ? vessel.getVesselName() : "AIS СУДНО";
+ // Флаг страны по MMSI оставляем — это единственный визуальный
+ // маркер, который тут реально несёт смысл. Остальные эмодзи в
+ // карточке цели убраны, чтобы текст не выглядел как чат.
String flag = getFlagEmojiForMMSI(vessel.getMmsi());
- tvTitle.setText((flag != null ? flag + " " : "") + "🚢 " + name);
+ tvTitle.setText((flag != null ? flag + " " : "") + name);
}
- if (tvMmsi != null) tvMmsi.setText("🆔 MMSI: " + (vessel.getMmsi() != null ? vessel.getMmsi() : "--"));
- if (tvCallsign != null) tvCallsign.setText("📻 Позывной: " + (vessel.getCallSign() != null ? vessel.getCallSign() : "--"));
- if (tvImo != null) tvImo.setText("🏷️ IMO: " + (vessel.getImo() > 0 ? String.valueOf(vessel.getImo()) : "--"));
- if (tvType != null) tvType.setText("🚢 Тип: " + (vessel.getVesselType() != null ? vessel.getVesselType() : "--"));
+ if (tvMmsi != null) tvMmsi.setText("MMSI: " + (vessel.getMmsi() != null ? vessel.getMmsi() : "--"));
+ if (tvCallsign != null) tvCallsign.setText("Позывной: " + (vessel.getCallSign() != null ? vessel.getCallSign() : "--"));
+ if (tvImo != null) tvImo.setText("IMO: " + (vessel.getImo() > 0 ? String.valueOf(vessel.getImo()) : "--"));
+ if (tvType != null) tvType.setText("Тип: " + (vessel.getVesselType() != null ? vessel.getVesselType() : "--"));
if (tvPosition != null) {
if (vessel.getLatitude() != 0 && vessel.getLongitude() != 0) {
- tvPosition.setText(String.format("📍 Координаты: %.6f, %.6f", vessel.getLatitude(), vessel.getLongitude()));
+ tvPosition.setText(String.format("Координаты: %.6f, %.6f", vessel.getLatitude(), vessel.getLongitude()));
} else {
- tvPosition.setText("📍 Координаты: --");
+ tvPosition.setText("Координаты: --");
}
}
- if (tvCourse != null) tvCourse.setText(vessel.getCourse() > 0 ? String.format("🧭 COG: %.1f°", vessel.getCourse()) : "🧭 COG: --°");
- if (tvRot != null) tvRot.setText(vessel.getRateOfTurn() != 0 ? String.format("🔄 ROT: %.1f°/мин", vessel.getRateOfTurn()) : "🔄 ROT: --°/мин");
- if (tvHeading != null) tvHeading.setText(vessel.getHeading() > 0 ? String.format("🧭 HDG: %.1f°", vessel.getHeading()) : "🧭 HDG: --°");
- if (tvSpeed != null) tvSpeed.setText(vessel.getSpeed() > 0 ? String.format("⚡ Скорость: %.1f узлов", vessel.getSpeed()) : "⚡ Скорость: -- узлов");
- if (tvDimensions != null) tvDimensions.setText((vessel.getLength() > 0 && vessel.getWidth() > 0) ? String.format("📏 Размеры: %.1f x %.1f м", vessel.getLength(), vessel.getWidth()) : "📏 Размеры: --");
- if (tvDraft != null) tvDraft.setText(vessel.getDraft() > 0 ? String.format("🌊 Осадка: %.1f м", vessel.getDraft()) : "🌊 Осадка: -- м");
- if (tvDestination != null) tvDestination.setText("🎯 Назначение: " + (vessel.getDestination() != null ? vessel.getDestination() : "--"));
- if (tvEta != null) tvEta.setText(vessel.getEta() != null ? String.format("⏰ ETA: %s", vessel.getEta().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))) : "⏰ ETA: --");
- if (tvNavStatus != null) tvNavStatus.setText("🚦 Статус: " + (vessel.getNavigationalStatus() != null ? vessel.getNavigationalStatus() : "--"));
- if (tvClass != null) tvClass.setText("📋 Класс: " + (vessel.getVesselClass() != null ? vessel.getVesselClass() : "--"));
+ if (tvCourse != null) tvCourse.setText(vessel.getCourse() > 0 ? String.format("COG: %.1f°", vessel.getCourse()) : "COG: --°");
+ if (tvRot != null) tvRot.setText(vessel.getRateOfTurn() != 0 ? String.format("ROT: %.1f°/мин", vessel.getRateOfTurn()) : "ROT: --°/мин");
+ if (tvHeading != null) tvHeading.setText(vessel.getHeading() > 0 ? String.format("HDG: %.1f°", vessel.getHeading()) : "HDG: --°");
+ if (tvSpeed != null) tvSpeed.setText(vessel.getSpeed() > 0 ? String.format("Скорость: %.1f узлов", vessel.getSpeed()) : "Скорость: -- узлов");
+ if (tvDimensions != null) tvDimensions.setText((vessel.getLength() > 0 && vessel.getWidth() > 0) ? String.format("Размеры: %.1f x %.1f м", vessel.getLength(), vessel.getWidth()) : "Размеры: --");
+ if (tvDraft != null) tvDraft.setText(vessel.getDraft() > 0 ? String.format("Осадка: %.1f м", vessel.getDraft()) : "Осадка: -- м");
+ if (tvDestination != null) tvDestination.setText("Назначение: " + (vessel.getDestination() != null ? vessel.getDestination() : "--"));
+ if (tvEta != null) tvEta.setText(vessel.getEta() != null ? String.format("ETA: %s", vessel.getEta().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))) : "ETA: --");
+ if (tvNavStatus != null) tvNavStatus.setText("Статус: " + (vessel.getNavigationalStatus() != null ? vessel.getNavigationalStatus() : "--"));
+ if (tvClass != null) tvClass.setText("Класс: " + (vessel.getVesselClass() != null ? vessel.getVesselClass() : "--"));
if (tvSignal != null) {
if (vessel.getSignalStrength() > 0) {
- tvSignal.setText(String.format("📶 Сигнал: %d", vessel.getSignalStrength()));
+ tvSignal.setText(String.format("Сигнал: %d", vessel.getSignalStrength()));
} else {
- tvSignal.setText(vessel.isPositionAccuracy() ? "📶 Точность: высокая" : "📶 Точность: низкая");
+ tvSignal.setText(vessel.isPositionAccuracy() ? "Точность: высокая" : "Точность: низкая");
}
}
- 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 (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));
+ 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 (tvBearing != null) tvBearing.setText("Пеленг: " + com.grigowashere.aismap.utils.NavigationUtils.formatRelativeBearing(relativeBearing));
} else {
- if (tvDistance != null) tvDistance.setText("📏 Расстояние: --");
- if (tvBearing != null) tvBearing.setText("🧭 Пеленг: --");
+ if (tvDistance != null) tvDistance.setText("Расстояние: --");
+ if (tvBearing != null) tvBearing.setText("Пеленг: --");
}
}
if (tvTimeAgo != null) {
if (vessel.getLastUpdate() != null) {
long secondsAgo = java.time.Duration.between(vessel.getLastUpdate(), java.time.LocalDateTime.now()).getSeconds();
- tvTimeAgo.setText("⏱️ Время назад: " + formatTimeAgo(secondsAgo));
+ tvTimeAgo.setText("Время назад: " + formatTimeAgo(secondsAgo));
} else {
- tvTimeAgo.setText("⏱️ Время назад: --");
+ tvTimeAgo.setText("Время назад: --");
}
}
}
@@ -287,7 +290,7 @@ public class BottomSheetsManager {
TextView tvTimeAgo = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_time_ago);
if (tvTimeAgo != null && currentAISVessel.getLastUpdate() != null) {
long secondsAgo = java.time.Duration.between(currentAISVessel.getLastUpdate(), java.time.LocalDateTime.now()).getSeconds();
- tvTimeAgo.setText("⏱️ Время назад: " + formatTimeAgo(secondsAgo));
+ tvTimeAgo.setText("Время назад: " + formatTimeAgo(secondsAgo));
}
}
diff --git a/app/src/main/java/com/grigowashere/aismap/ui/UIRenderingCoordinator.java b/app/src/main/java/com/grigowashere/aismap/ui/UIRenderingCoordinator.java
index fb53d06..a9d4d16 100644
--- a/app/src/main/java/com/grigowashere/aismap/ui/UIRenderingCoordinator.java
+++ b/app/src/main/java/com/grigowashere/aismap/ui/UIRenderingCoordinator.java
@@ -4,10 +4,17 @@ import android.os.Handler;
import android.os.Looper;
import android.util.Log;
+import androidx.core.content.ContextCompat;
+
+import com.grigowashere.aismap.R;
import com.grigowashere.aismap.maps.MapInterface;
import com.grigowashere.aismap.maps.MapInterfaceChangeListener;
+import com.grigowashere.aismap.maps.MapLibreMapImpl;
+import com.grigowashere.aismap.maps.YandexMapImpl;
import com.grigowashere.aismap.models.Vessel;
import com.grigowashere.aismap.models.AISVessel;
+import com.grigowashere.aismap.utils.GeoUtils;
+import com.grigowashere.aismap.utils.SettingsManager;
import java.util.HashSet;
import java.util.ArrayList;
@@ -32,6 +39,8 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
private MapInterface mapInterface;
private Handler uiHandler;
+ private SettingsManager settingsManager;
+ private android.content.Context appContext;
// Pending операции для батчинга
private Vessel pendingVesselUpdate;
@@ -55,6 +64,16 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
setupThrottling();
Log.i(TAG, "UIRenderingCoordinator инициализирован");
}
+
+ /**
+ * Передаёт {@link SettingsManager}, чтобы координатор смог отрисовать
+ * кольца дальности вокруг собственного судна и halo предупреждения у
+ * целей. Если не вызвать — отрисовка колец не выполняется.
+ */
+ public void setSettingsManager(android.content.Context context, SettingsManager sm) {
+ this.appContext = context != null ? context.getApplicationContext() : null;
+ this.settingsManager = sm;
+ }
/**
* Настройка throttling механизмов
@@ -137,6 +156,7 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
try {
Log.d(TAG, "Выполняем vessel update: " + pendingVesselUpdate.getLatitude() + "," + pendingVesselUpdate.getLongitude());
mapInterface.updateOwnVesselPosition(pendingVesselUpdate);
+ applyRangeRingsAround(pendingVesselUpdate);
Log.d(TAG, "Vessel update выполнен успешно");
} catch (Exception e) {
Log.e(TAG, "Ошибка vessel update: " + e.getMessage(), e);
@@ -144,6 +164,57 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
pendingVesselUpdate = null;
}
+
+ /**
+ * Перерисовывает 3 кольца дальности (опасность/предупреждение/фильтр)
+ * вокруг собственного судна и сообщает картам параметры warning-зоны для
+ * подсветки целей. Если кольца отключены в настройках или координаты
+ * невалидны — кольца очищаются.
+ */
+ private void applyRangeRingsAround(Vessel vessel) {
+ if (mapInterface == null) return;
+ if (settingsManager == null || appContext == null) return;
+ try {
+ double lat = vessel != null ? vessel.getLatitude() : Double.NaN;
+ double lon = vessel != null ? vessel.getLongitude() : Double.NaN;
+ boolean ringsOn = settingsManager.isRangeRingsEnabled()
+ && GeoUtils.isValidCoordinates(lat, lon);
+ if (!ringsOn) {
+ mapInterface.clearOwnShipRangeRings();
+ if (mapInterface instanceof MapLibreMapImpl) {
+ ((MapLibreMapImpl) mapInterface).setWarningZoneParams(lat, lon, 0.0);
+ } else if (mapInterface instanceof YandexMapImpl) {
+ ((YandexMapImpl) mapInterface).setWarningZoneParams(lat, lon, 0.0);
+ }
+ return;
+ }
+ double danger = settingsManager.getDangerRadiusMeters();
+ double warning = settingsManager.getWarningRadiusMeters();
+ double filter = settingsManager.getFilterRadiusMeters();
+ int dangerStroke = ContextCompat.getColor(appContext, R.color.range_ring_danger_stroke);
+ int dangerFill = ContextCompat.getColor(appContext, R.color.range_ring_danger_fill);
+ int warningStroke = ContextCompat.getColor(appContext, R.color.range_ring_warning_stroke);
+ int warningFill = ContextCompat.getColor(appContext, R.color.range_ring_warning_fill);
+ int filterStroke = ContextCompat.getColor(appContext, R.color.range_ring_filter_stroke);
+ int filterFill = ContextCompat.getColor(appContext, R.color.range_ring_filter_fill);
+ double[] radii = new double[] { danger, warning, filter };
+ int[] strokes = new int[] { dangerStroke, warningStroke, filterStroke };
+ int[] fills = new int[] { dangerFill, warningFill, filterFill };
+ boolean[] visible = new boolean[] {
+ danger > 0.0,
+ warning > 0.0,
+ filter > 0.0 && settingsManager.isRangeFilterEnabled()
+ };
+ mapInterface.setOwnShipRangeRings(lat, lon, radii, strokes, fills, visible);
+ if (mapInterface instanceof MapLibreMapImpl) {
+ ((MapLibreMapImpl) mapInterface).setWarningZoneParams(lat, lon, warning);
+ } else if (mapInterface instanceof YandexMapImpl) {
+ ((YandexMapImpl) mapInterface).setWarningZoneParams(lat, lon, warning);
+ }
+ } catch (Throwable t) {
+ Log.w(TAG, "applyRangeRingsAround: " + t.getMessage());
+ }
+ }
/**
* Выполнение обновлений AIS судов
diff --git a/app/src/main/java/com/grigowashere/aismap/utils/NavigatorZoomMath.java b/app/src/main/java/com/grigowashere/aismap/utils/NavigatorZoomMath.java
new file mode 100644
index 0000000..d6c6966
--- /dev/null
+++ b/app/src/main/java/com/grigowashere/aismap/utils/NavigatorZoomMath.java
@@ -0,0 +1,90 @@
+package com.grigowashere.aismap.utils;
+
+/**
+ * Чистая логика зума навигаторской камеры от скорости судна (узлы).
+ * Не зависит от Android — покрывается unit-тестами без Robolectric.
+ */
+public final class NavigatorZoomMath {
+
+ private NavigatorZoomMath() { }
+
+ /**
+ * Линейная интерполяция зума: при {@code speedKnots == 0} — {@code zoomAtZeroSpeed}
+ * (максимальное приближение), при {@code speedKnots >= maxSpeedKnots} —
+ * {@code zoomAtMaxSpeed} (максимальное отдаление).
+ *
+ * @param speedKnots скорость в узлах (отрицательные трактуются как 0)
+ * @param zoomAtZeroSpeed зум при нулевой скорости (обычно больше)
+ * @param zoomAtMaxSpeed зум при максимальной скорости (обычно меньше)
+ * @param maxSpeedKnots скорость, при которой достигается {@code zoomAtMaxSpeed}
+ */
+ public static float zoomForSpeed(double speedKnots,
+ float zoomAtZeroSpeed,
+ float zoomAtMaxSpeed,
+ float maxSpeedKnots) {
+ float z0 = clampZoom(zoomAtZeroSpeed);
+ float zMax = clampZoom(zoomAtMaxSpeed);
+ if (maxSpeedKnots <= 0f) {
+ return z0;
+ }
+ double speed = speedKnots;
+ if (speed < 0.0 || Double.isNaN(speed) || Double.isInfinite(speed)) {
+ speed = 0.0;
+ }
+ double t = Math.min(1.0, speed / maxSpeedKnots);
+ return (float) (z0 + t * (zMax - z0));
+ }
+
+ /**
+ * Ограничивает зум допустимым диапазоном карт (2…20).
+ */
+ public static float clampZoom(float zoom) {
+ if (Float.isNaN(zoom) || Float.isInfinite(zoom)) {
+ return 14f;
+ }
+ if (zoom < 2f) return 2f;
+ if (zoom > 20f) return 20f;
+ return zoom;
+ }
+
+ /**
+ * Линейная интерполяция между {@code a} и {@code b} при {@code t} в [0, 1].
+ */
+ public static double lerp(double a, double b, double t) {
+ return a + (b - a) * t;
+ }
+
+ public static float lerp(float a, float b, float t) {
+ return a + (b - a) * t;
+ }
+
+ /**
+ * Ease-out cubic: быстрый старт, плавное завершение.
+ */
+ public static float easeOutCubic(float t) {
+ if (t <= 0f) return 0f;
+ if (t >= 1f) return 1f;
+ float u = 1f - t;
+ return 1f - u * u * u;
+ }
+
+ /**
+ * Плавный поворот по кратчайшей дуге (градусы 0…360).
+ */
+ public static float lerpBearing(float fromDeg, float toDeg, float alpha) {
+ if (alpha <= 0f) return normalizeBearing360(fromDeg);
+ if (alpha >= 1f) return normalizeBearing360(toDeg);
+ float from = normalizeBearing360(fromDeg);
+ float to = normalizeBearing360(toDeg);
+ float delta = to - from;
+ if (delta > 180f) delta -= 360f;
+ if (delta < -180f) delta += 360f;
+ return normalizeBearing360(from + delta * alpha);
+ }
+
+ public static float normalizeBearing360(float deg) {
+ float x = deg % 360f;
+ if (x < 0f) x += 360f;
+ return x;
+ }
+}
diff --git a/app/src/main/java/com/grigowashere/aismap/utils/RangeMath.java b/app/src/main/java/com/grigowashere/aismap/utils/RangeMath.java
new file mode 100644
index 0000000..e577bb0
--- /dev/null
+++ b/app/src/main/java/com/grigowashere/aismap/utils/RangeMath.java
@@ -0,0 +1,72 @@
+package com.grigowashere.aismap.utils;
+
+/**
+ * Чистые статические утилиты для логики колец дальности.
+ *
Не зависят от Android — это упрощает покрытие unit-тестами без Robolectric.
+ */
+public final class RangeMath {
+
+ /** 1 морская миля в метрах. */
+ public static final double METERS_PER_NM = 1852.0;
+ /** 1 километр в метрах. */
+ public static final double METERS_PER_KM = 1000.0;
+
+ /** Идентификаторы единиц измерения, совместимые с {@link SettingsManager}. */
+ public static final String UNIT_NM = "nm";
+ public static final String UNIT_KM = "km";
+
+ private RangeMath() { }
+
+ /**
+ * Конвертирует значение в выбранной единице измерения в метры.
+ *
Любое неизвестное значение единицы трактуется как {@link #UNIT_NM}.
+ */
+ public static double toMeters(double value, String unit) {
+ if (UNIT_KM.equals(unit)) {
+ return value * METERS_PER_KM;
+ }
+ return value * METERS_PER_NM;
+ }
+
+ /**
+ * Возвращает {@code true}, если радиусы колец в порядке возрастания
+ * и все строго положительны: {@code 0 < danger < warning < filter}.
+ */
+ public static boolean isValidRingOrder(double danger, double warning, double filter) {
+ return danger > 0.0 && warning > 0.0 && filter > 0.0
+ && danger < warning && warning < filter;
+ }
+
+ /**
+ * Возвращает {@code true}, если цель находится внутри радиуса фильтра
+ * относительно собственного судна. Если фильтр выключен или координаты
+ * собственного/цели некорректны — возвращает {@code true} (цель остаётся).
+ */
+ public static boolean isInsideFilter(boolean filterEnabled,
+ double filterRadiusMeters,
+ double ownLat, double ownLon,
+ double targetLat, double targetLon) {
+ if (!filterEnabled) return true;
+ if (!(filterRadiusMeters > 0.0)) return true;
+ if (Double.isNaN(ownLat) || Double.isNaN(ownLon)) return true;
+ if (Double.isNaN(targetLat) || Double.isNaN(targetLon)) return true;
+ double d = haversineMeters(ownLat, ownLon, targetLat, targetLon);
+ return d <= filterRadiusMeters;
+ }
+
+ /**
+ * Расстояние по большому кругу (формула гаверсинуса), м.
+ * Совпадает с {@link GeoUtils#calculateDistance(double, double, double, double)}
+ * с точностью до выбора радиуса Земли (используется WGS-84 mean ≈ 6_371_000 m).
+ */
+ public static double haversineMeters(double lat1, double lon1, double lat2, double lon2) {
+ final double R = 6_371_000.0;
+ double dLat = Math.toRadians(lat2 - lat1);
+ double dLon = Math.toRadians(lon2 - lon1);
+ double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
+ * Math.sin(dLon / 2) * Math.sin(dLon / 2);
+ double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ return R * c;
+ }
+}
diff --git a/app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java b/app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java
index a125845..5f69d4a 100644
--- a/app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java
+++ b/app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java
@@ -45,13 +45,30 @@ public class SettingsManager {
private static final String KEY_START_ZOOM_LEVEL = "start_zoom_level";
/** Как карта следует за ориентацией: {@link #MAP_ROTATION_COMPASS} / COURSE / MANUAL */
private static final String KEY_MAP_ROTATION_MODE = "map_rotation_mode";
+ // Navigator camera (follow own ship + speed-based zoom)
+ private static final String KEY_NAVIGATOR_CAMERA_ENABLED = "navigator_camera_enabled";
+ private static final String KEY_NAVIGATOR_MAX_SPEED_KNOTS = "navigator_max_speed_knots";
+ private static final String KEY_NAVIGATOR_ZOOM_AT_ZERO_SPEED = "navigator_zoom_at_zero_speed";
+ private static final String KEY_NAVIGATOR_ZOOM_AT_MAX_SPEED = "navigator_zoom_at_max_speed";
+ private static final String KEY_NAVIGATOR_CAMERA_TRANSITION_MS = "navigator_camera_transition_ms";
// BLE/NMEA settings
private static final String KEY_BLE_ENABLED = "ble_enabled";
private static final String KEY_BLE_DEVICE_MAC = "ble_device_mac";
private static final String KEY_BLE_UDP_BRIDGE_ENABLED = "ble_udp_bridge_enabled";
private static final String KEY_BLE_UDP_BRIDGE_HOST = "ble_udp_bridge_host";
private static final String KEY_BLE_UDP_BRIDGE_PORT = "ble_udp_bridge_port";
-
+ /** Включает чтение battery 0x2A19. По умолчанию выключено: на ряде хабов
+ * чтение этой характеристики триггерит запрос сопряжения каждые 10 секунд. */
+ private static final String KEY_BLE_READ_BATTERY_ENABLED = "ble_read_battery_enabled";
+
+ // ===== Range rings around own ship =====
+ private static final String KEY_RANGE_RINGS_ENABLED = "range_rings_enabled";
+ private static final String KEY_RANGE_UNIT = "range_unit";
+ private static final String KEY_RANGE_DANGER = "range_danger";
+ private static final String KEY_RANGE_WARNING = "range_warning";
+ private static final String KEY_RANGE_FILTER = "range_filter";
+ private static final String KEY_RANGE_FILTER_ENABLED = "range_filter_enabled";
+
// Значения по умолчанию
private static final int DEFAULT_UDP_PORT = 10110;
private static final boolean DEFAULT_UDP_ENABLED = true;
@@ -84,6 +101,24 @@ public class SettingsManager {
private static final boolean DEFAULT_BLE_UDP_BRIDGE_ENABLED = false;
private static final String DEFAULT_BLE_UDP_BRIDGE_HOST = "255.255.255.255";
private static final int DEFAULT_BLE_UDP_BRIDGE_PORT = 10110;
+ private static final boolean DEFAULT_BLE_READ_BATTERY_ENABLED = false;
+
+ // Range rings defaults
+ private static final boolean DEFAULT_RANGE_RINGS_ENABLED = true;
+ private static final String DEFAULT_RANGE_UNIT = "nm"; // "nm" | "km"
+ private static final float DEFAULT_RANGE_DANGER = 0.5f;
+ private static final float DEFAULT_RANGE_WARNING = 1.5f;
+ private static final float DEFAULT_RANGE_FILTER = 5.0f;
+ private static final boolean DEFAULT_RANGE_FILTER_ENABLED = true;
+
+ // Range unit constants
+ public static final String RANGE_UNIT_NM = "nm";
+ public static final String RANGE_UNIT_KM = "km";
+
+ /** 1 морская миля в метрах. */
+ private static final double METERS_PER_NM = 1852.0;
+ /** 1 километр в метрах. */
+ private static final double METERS_PER_KM = 1000.0;
// Режимы работы с данными
public static final String DATA_MODE_HYBRID = "hybrid";
@@ -107,6 +142,11 @@ public class SettingsManager {
/** Как курс (COG / GPS bearing). */
public static final String MAP_ROTATION_COURSE = "course";
private static final String DEFAULT_MAP_ROTATION_MODE = MAP_ROTATION_MANUAL;
+ private static final boolean DEFAULT_NAVIGATOR_CAMERA_ENABLED = false;
+ private static final float DEFAULT_NAVIGATOR_MAX_SPEED_KNOTS = 20f;
+ private static final float DEFAULT_NAVIGATOR_ZOOM_AT_ZERO_SPEED = 18f;
+ private static final float DEFAULT_NAVIGATOR_ZOOM_AT_MAX_SPEED = 10f;
+ private static final int DEFAULT_NAVIGATOR_CAMERA_TRANSITION_MS = 600;
private Context context;
private SharedPreferences prefs;
@@ -454,6 +494,63 @@ public class SettingsManager {
setMapRotationMode(next);
return next;
}
+
+ // ===== Navigator camera =====
+
+ public boolean isNavigatorCameraEnabled() {
+ return prefs.getBoolean(KEY_NAVIGATOR_CAMERA_ENABLED, DEFAULT_NAVIGATOR_CAMERA_ENABLED);
+ }
+
+ public void setNavigatorCameraEnabled(boolean enabled) {
+ prefs.edit().putBoolean(KEY_NAVIGATOR_CAMERA_ENABLED, enabled).apply();
+ Log.i(TAG, "Навигаторская камера: " + (enabled ? "включена" : "выключена"));
+ }
+
+ public float getNavigatorMaxSpeedKnots() {
+ float v = prefs.getFloat(KEY_NAVIGATOR_MAX_SPEED_KNOTS, DEFAULT_NAVIGATOR_MAX_SPEED_KNOTS);
+ if (v < 1f) v = DEFAULT_NAVIGATOR_MAX_SPEED_KNOTS;
+ return v;
+ }
+
+ public void setNavigatorMaxSpeedKnots(float knots) {
+ if (knots < 1f) knots = DEFAULT_NAVIGATOR_MAX_SPEED_KNOTS;
+ prefs.edit().putFloat(KEY_NAVIGATOR_MAX_SPEED_KNOTS, knots).apply();
+ }
+
+ public float getNavigatorZoomAtZeroSpeed() {
+ return clampNavigatorZoom(prefs.getFloat(KEY_NAVIGATOR_ZOOM_AT_ZERO_SPEED, DEFAULT_NAVIGATOR_ZOOM_AT_ZERO_SPEED));
+ }
+
+ public void setNavigatorZoomAtZeroSpeed(float zoom) {
+ prefs.edit().putFloat(KEY_NAVIGATOR_ZOOM_AT_ZERO_SPEED, clampNavigatorZoom(zoom)).apply();
+ }
+
+ public float getNavigatorZoomAtMaxSpeed() {
+ return clampNavigatorZoom(prefs.getFloat(KEY_NAVIGATOR_ZOOM_AT_MAX_SPEED, DEFAULT_NAVIGATOR_ZOOM_AT_MAX_SPEED));
+ }
+
+ public void setNavigatorZoomAtMaxSpeed(float zoom) {
+ prefs.edit().putFloat(KEY_NAVIGATOR_ZOOM_AT_MAX_SPEED, clampNavigatorZoom(zoom)).apply();
+ }
+
+ public long getNavigatorCameraTransitionMs() {
+ int ms = prefs.getInt(KEY_NAVIGATOR_CAMERA_TRANSITION_MS, DEFAULT_NAVIGATOR_CAMERA_TRANSITION_MS);
+ if (ms < 0) ms = 0;
+ if (ms > 5000) ms = 5000;
+ return ms;
+ }
+
+ public void setNavigatorCameraTransitionMs(int ms) {
+ if (ms < 0) ms = 0;
+ if (ms > 5000) ms = 5000;
+ prefs.edit().putInt(KEY_NAVIGATOR_CAMERA_TRANSITION_MS, ms).apply();
+ }
+
+ private static float clampNavigatorZoom(float zoom) {
+ if (zoom < 2f) return 2f;
+ if (zoom > 20f) return 20f;
+ return zoom;
+ }
/**
* Проверяет, нужно ли перезапустить UDP слушатель
@@ -696,5 +793,87 @@ public class SettingsManager {
prefs.edit().putBoolean(KEY_SEAMARKS_ENABLED, enabled).apply();
Log.i(TAG, "Морские знаки OpenSeaMap: " + (enabled ? "включены" : "выключены"));
}
-
+
+ // ===== BLE battery opt-in =====
+ public boolean isBleReadBatteryEnabled() {
+ return prefs.getBoolean(KEY_BLE_READ_BATTERY_ENABLED, DEFAULT_BLE_READ_BATTERY_ENABLED);
+ }
+
+ public void setBleReadBatteryEnabled(boolean enabled) {
+ prefs.edit().putBoolean(KEY_BLE_READ_BATTERY_ENABLED, enabled).apply();
+ Log.i(TAG, "BLE read battery: " + (enabled ? "включено" : "выключено"));
+ }
+
+ // ===== Range rings =====
+ public boolean isRangeRingsEnabled() {
+ return prefs.getBoolean(KEY_RANGE_RINGS_ENABLED, DEFAULT_RANGE_RINGS_ENABLED);
+ }
+
+ public void setRangeRingsEnabled(boolean enabled) {
+ prefs.edit().putBoolean(KEY_RANGE_RINGS_ENABLED, enabled).apply();
+ }
+
+ public String getRangeUnit() {
+ String v = prefs.getString(KEY_RANGE_UNIT, DEFAULT_RANGE_UNIT);
+ if (!RANGE_UNIT_NM.equals(v) && !RANGE_UNIT_KM.equals(v)) return DEFAULT_RANGE_UNIT;
+ return v;
+ }
+
+ public void setRangeUnit(String unit) {
+ if (!RANGE_UNIT_NM.equals(unit) && !RANGE_UNIT_KM.equals(unit)) unit = DEFAULT_RANGE_UNIT;
+ prefs.edit().putString(KEY_RANGE_UNIT, unit).apply();
+ }
+
+ public float getRangeDanger() {
+ return prefs.getFloat(KEY_RANGE_DANGER, DEFAULT_RANGE_DANGER);
+ }
+
+ public void setRangeDanger(float v) {
+ prefs.edit().putFloat(KEY_RANGE_DANGER, v).apply();
+ }
+
+ public float getRangeWarning() {
+ return prefs.getFloat(KEY_RANGE_WARNING, DEFAULT_RANGE_WARNING);
+ }
+
+ public void setRangeWarning(float v) {
+ prefs.edit().putFloat(KEY_RANGE_WARNING, v).apply();
+ }
+
+ public float getRangeFilter() {
+ return prefs.getFloat(KEY_RANGE_FILTER, DEFAULT_RANGE_FILTER);
+ }
+
+ public void setRangeFilter(float v) {
+ prefs.edit().putFloat(KEY_RANGE_FILTER, v).apply();
+ }
+
+ public boolean isRangeFilterEnabled() {
+ return prefs.getBoolean(KEY_RANGE_FILTER_ENABLED, DEFAULT_RANGE_FILTER_ENABLED);
+ }
+
+ public void setRangeFilterEnabled(boolean enabled) {
+ prefs.edit().putBoolean(KEY_RANGE_FILTER_ENABLED, enabled).apply();
+ }
+
+ /** Конвертирует значение в выбранной единице ({@link #getRangeUnit()}) в метры. */
+ public double convertRangeToMeters(float value) {
+ if (RANGE_UNIT_KM.equals(getRangeUnit())) {
+ return value * METERS_PER_KM;
+ }
+ return value * METERS_PER_NM;
+ }
+
+ public double getDangerRadiusMeters() {
+ return convertRangeToMeters(getRangeDanger());
+ }
+
+ public double getWarningRadiusMeters() {
+ return convertRangeToMeters(getRangeWarning());
+ }
+
+ public double getFilterRadiusMeters() {
+ return convertRangeToMeters(getRangeFilter());
+ }
+
}
diff --git a/app/src/main/java/com/grigowashere/aismap/utils/UiInsetsUtils.java b/app/src/main/java/com/grigowashere/aismap/utils/UiInsetsUtils.java
new file mode 100644
index 0000000..ab3273d
--- /dev/null
+++ b/app/src/main/java/com/grigowashere/aismap/utils/UiInsetsUtils.java
@@ -0,0 +1,34 @@
+package com.grigowashere.aismap.utils;
+
+import android.view.View;
+
+import androidx.core.graphics.Insets;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowInsetsCompat;
+
+/**
+ * Паддинги под системные бары для экранов с edge-to-edge (targetSdk 35+).
+ */
+public final class UiInsetsUtils {
+
+ private UiInsetsUtils() {
+ }
+
+ /**
+ * Добавляет к {@code basePaddingPx} отступы status/nav-bar и display cutout.
+ */
+ public static void applySystemBarPadding(View view, int basePaddingPx) {
+ ViewCompat.setOnApplyWindowInsetsListener(view, (v, insets) -> {
+ Insets sys = insets.getInsets(
+ WindowInsetsCompat.Type.systemBars()
+ | WindowInsetsCompat.Type.displayCutout());
+ v.setPadding(
+ basePaddingPx + sys.left,
+ basePaddingPx + sys.top,
+ basePaddingPx + sys.right,
+ basePaddingPx + sys.bottom);
+ return insets;
+ });
+ ViewCompat.requestApplyInsets(view);
+ }
+}
diff --git a/app/src/main/java/com/grigowashere/aismap/view/BaseDockWidget.java b/app/src/main/java/com/grigowashere/aismap/view/BaseDockWidget.java
index eb44f34..c79b41d 100644
--- a/app/src/main/java/com/grigowashere/aismap/view/BaseDockWidget.java
+++ b/app/src/main/java/com/grigowashere/aismap/view/BaseDockWidget.java
@@ -21,10 +21,52 @@ public abstract class BaseDockWidget extends FrameLayout {
protected static final float MIN_SCALE = 0.5f;
protected static final float MAX_SCALE = 2.0f;
protected static final float SCALE_STEP = 0.1f;
+
+ /**
+ * Высота в dock-режиме «по умолчанию» (в dp). Используется как fallback,
+ * если наследник НЕ переопределил {@link #measureDockContentHeightPx(int)}.
+ * Большинству виджетов достаточно переопределить только measure-метод.
+ */
+ protected int getDefaultDockHeightDp() {
+ return DEFAULT_DOCK_HEIGHT_DP;
+ }
+
+ /**
+ * Сколько пикселей ПОЛЕЗНОГО КОНТЕНТА (без учёта системных паддингов под
+ * статус-/нав-бар) нужно этому виджету при данной ширине, чтобы корректно
+ * нарисоваться в dock-режиме. Возвращаемое значение используется в
+ * {@link #onMeasure(int, int)} как высота content-области.
+ *
+ *
Наследники переопределяют этот метод и считают высоту по своим
+ * реальным метрикам отрисовки (размер шрифта, число строк, и т.п.), чтобы
+ * не быть привязанными к магической константе.
+ *
+ *
По умолчанию возвращает {@code dp(getDefaultDockHeightDp())} — для
+ * обратной совместимости с виджетами, которые ещё не реализовали measure.
+ */
+ protected int measureDockContentHeightPx(int widthPx) {
+ return (int) dp(getDefaultDockHeightDp());
+ }
+
+ /**
+ * Куда виджет «прикипает» по умолчанию: {@code true} — к верху экрана,
+ * {@code false} — к низу. Влияет на:
+ *
+ *
зону resize (верх/низ виджета),
+ *
сторону, к которой подъезжают другие dock-виджеты при стакинге,
+ *
позицию docking после ручного перетаскивания (если пользователь
+ * отпустил виджет в середине, мы возвращаем его на «домашнюю» сторону).
+ *
+ * XML-якорь ({@code layout_alignParentBottom} / {@code layout_above}) задаёт
+ * стартовое положение визуально, а этот метод — внутреннюю модель.
+ */
+ protected boolean getDefaultDockTop() {
+ return true;
+ }
// Состояние виджета
protected boolean isDocked = true; // По умолчанию в dock-режиме
- protected boolean dockTop = true;
+ protected boolean dockTop = true; // Инициализируется в init() через getDefaultDockTop()
protected boolean isMorphing = false;
protected float morphProgress = 0.0f; // 0 = dock, 1 = circle
@@ -72,22 +114,19 @@ public abstract class BaseDockWidget extends FrameLayout {
private void init() {
setClickable(true);
setFocusable(true);
-
- // Инициализируем в dock-режиме
- post(() -> {
- if (isDocked) {
- ViewGroup parent = (ViewGroup) getParent();
- if (parent != null) {
- setX(0);
- setY(0);
- ViewGroup.LayoutParams lp = getLayoutParams();
- lp.width = ViewGroup.LayoutParams.MATCH_PARENT;
- lp.height = (int) dp(DEFAULT_DOCK_HEIGHT_DP);
- dockHeightPx = 0; // Сбрасываем сохраненную высоту
- setLayoutParams(lp);
- }
- }
- });
+
+ // Стартовая сторона дока (top/bottom) определяется наследником. Само
+ // фактическое положение задаёт RelativeLayout (alignParentTop / Bottom /
+ // layout_above), а этот флаг — внутренняя модель для resize-зоны и
+ // стакинга других dock-виджетов.
+ this.dockTop = getDefaultDockTop();
+ // Высота view в dock-режиме считается в onMeasure через
+ // measureDockContentHeightPx(...) при lp.height=WRAP_CONTENT (это
+ // прописано в activity_main.xml). А переход dock<->circle сам выставляет
+ // правильные lp в конце анимации (см. setDocked). Намеренно НЕ дёргаем
+ // setLayoutParams() из init().post() — это вызывало второй проход layout
+ // ПОСЛЕ первого measure и оставляло координатный/danger виджет с нулевой
+ // высотой на первый кадр, пока не приходил какой-нибудь size-update.
}
@Override
@@ -232,7 +271,7 @@ public abstract class BaseDockWidget extends FrameLayout {
// Ресайзим именно контент (dockHeightPx). Паддинги от WindowInsets
// прибавляются поверх в onMeasure, поэтому «рабочая» часть не уезжает
// под системный бар даже при минимальном размере.
- int currentContent = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
+ int currentContent = dockHeightPx > 0 ? dockHeightPx : (int) dp(getDefaultDockHeightDp());
int newHeight = currentContent;
if (dockTop) {
@@ -309,7 +348,7 @@ public abstract class BaseDockWidget extends FrameLayout {
// При докинге всегда устанавливаем размер по умолчанию
dockHeightPx = 0; // Сбрасываем сохраненную высоту
- setDocked(true, dockToTop, 0f, dockToTop ? 0f : screenHeight - dp(DEFAULT_DOCK_HEIGHT_DP));
+ setDocked(true, dockToTop, 0f, dockToTop ? 0f : screenHeight - dp(getDefaultDockHeightDp()));
}
private float getDistance(MotionEvent event) {
@@ -324,10 +363,16 @@ public abstract class BaseDockWidget extends FrameLayout {
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (isDocked) {
int width = MeasureSpec.getSize(widthMeasureSpec);
- // dockHeightPx/DEFAULT — это высота полезного контента; к ней
- // прибавляем padding от WindowInsets, чтобы виджет фактически
- // расширялся под статус-бар или нав-бар и не прятал контент.
- int content = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
+ // Высота content-области:
+ // * если пользователь ВРУЧНУЮ растянул виджет (dockHeightPx>0) —
+ // используем эту фиксированную высоту;
+ // * иначе спрашиваем у конкретного виджета через measure-метод,
+ // сколько ему нужно для отрисовки на данной ширине.
+ // Системные паддинги (статус-бар/нав-бар) прибавляются СВЕРХУ
+ // content-области, чтобы карта не пряталась под бары.
+ int content = dockHeightPx > 0
+ ? dockHeightPx
+ : measureDockContentHeightPx(width);
int height = content + getPaddingTop() + getPaddingBottom();
setMeasuredDimension(width, height);
} else {
@@ -353,7 +398,15 @@ public abstract class BaseDockWidget extends FrameLayout {
}
public void setDocked(boolean docked, boolean top, float targetX, float targetY) {
- if (this.isDocked == docked && this.dockTop == top && getX() == targetX && getY() == targetY) {
+ // Раннее завершение опираем ТОЛЬКО на isDocked/dockTop. Позицию dock-виджета
+ // задаёт RelativeLayout (alignParentTop / alignParentBottom / layout_above),
+ // а не translationX/Y. Сравнение с getX()/getY() сюда подмешивало проблему:
+ // если кто-то делал coordinatesWidget.post(() -> setDocked(true, false, 0, 0))
+ // ПОСЛЕ первого layout, getY() уже был = parent.bottom-h ≠ 0, ранний return
+ // не срабатывал, и анимация уезжала к computed-position через setX/setY,
+ // оставляя translation-ом виджет за экраном. Теперь повторный setDocked
+ // в ту же сторону — это явный no-op.
+ if (this.isDocked == docked && this.dockTop == top) {
return;
}
@@ -372,7 +425,7 @@ public abstract class BaseDockWidget extends FrameLayout {
ViewGroup parent = (ViewGroup) getParent();
int parentWidth = parent.getWidth();
int parentHeight = parent.getHeight();
- int dockHeight = (int) dp(DEFAULT_DOCK_HEIGHT_DP);
+ int dockHeight = (int) dp(getDefaultDockHeightDp());
int circleSize = (int) dp(CIRCLE_SIZE_DP);
int endW = docked ? parentWidth : circleSize;
@@ -422,16 +475,34 @@ public abstract class BaseDockWidget extends FrameLayout {
@Override
public void onAnimationEnd(Animator animation) {
ViewGroup.LayoutParams lp = getLayoutParams();
- lp.width = endW;
- lp.height = endH;
- setLayoutParams(lp);
-
- setX(finalEndX);
- setY(finalEndY);
+ if (docked) {
+ // В dock-режиме высоту контролирует measureDockContentHeightPx,
+ // ширину — родитель. Если оставить фиксированные endW/endH
+ // от анимации, виджет навсегда «застрянет» в этом размере
+ // и нарушит формулу контент+паддинги.
+ lp.width = ViewGroup.LayoutParams.MATCH_PARENT;
+ lp.height = ViewGroup.LayoutParams.WRAP_CONTENT;
+ setLayoutParams(lp);
+ // Финальную позицию dock-виджета задаёт RelativeLayout
+ // (alignParentTop/Bottom/layout_above). Translation от
+ // анимации тут лишний — он бы «прибил» виджет к
+ // animation-target, ломая layout-правила (например, после
+ // ресайза/вставки insets) и мог увести его за экран.
+ setTranslationX(0f);
+ setTranslationY(0f);
+ } else {
+ lp.width = endW;
+ lp.height = endH;
+ setLayoutParams(lp);
+ // В circle-режиме виджет «свободно плавает», его позицию
+ // мы держим именно через translation (setX/setY).
+ setX(finalEndX);
+ setY(finalEndY);
+ }
morphProgress = endMorph;
-
+
postInvalidateOnAnimation();
-
+
isMorphing = false;
}
});
@@ -477,7 +548,7 @@ public abstract class BaseDockWidget extends FrameLayout {
ViewGroup parent = (ViewGroup) getParent();
if (parent == null) return 0;
- int dockHeight = (int) dp(DEFAULT_DOCK_HEIGHT_DP);
+ int dockHeight = (int) dp(getDefaultDockHeightDp());
float y = 0;
if (dockTop) {
@@ -518,43 +589,30 @@ public abstract class BaseDockWidget extends FrameLayout {
}
/**
- * Перепозиционирует все docked виджеты, чтобы они прижались к краям
+ * Сбрасывает translation у всех dock-виджетов, чтобы их положение
+ * определялось layout-правилами родителя (alignParentTop / alignParentBottom /
+ * layout_above), а не остаточной анимационной трансляцией.
+ *
+ * Раньше этот метод сам считал Y по getHeight() и звал setY(...) — это
+ * генерировало translationY ≠ 0 поверх RelativeLayout-ов, и виджеты после
+ * dock-state-change «прилипали» к старым координатам (вплоть до ухода за
+ * экран, если parent ещё не успел измериться). Теперь мы доверяем layout-у
+ * и только обнуляем translation, чтобы visual position == layout position.
*/
public static void repositionAllDockedWidgets(ViewGroup parent) {
if (parent == null) return;
-
- // Собираем все docked виджеты сверху
- java.util.List topWidgets = new java.util.ArrayList<>();
- java.util.List bottomWidgets = new java.util.ArrayList<>();
-
+
for (int i = 0; i < parent.getChildCount(); i++) {
View child = parent.getChildAt(i);
if (child instanceof BaseDockWidget) {
BaseDockWidget widget = (BaseDockWidget) child;
- if (widget.isDocked()) {
- if (widget.isDockTop()) {
- topWidgets.add(widget);
- } else {
- bottomWidgets.add(widget);
- }
+ if (widget.isDocked() && !widget.isMorphing) {
+ widget.setTranslationX(0f);
+ widget.setTranslationY(0f);
}
}
}
-
- // Перепозиционируем виджеты сверху
- float currentY = 0;
- for (BaseDockWidget widget : topWidgets) {
- widget.setY(currentY);
- currentY += widget.getHeight();
- }
-
- // Перепозиционируем виджеты снизу
- currentY = parent.getHeight();
- for (int i = bottomWidgets.size() - 1; i >= 0; i--) {
- BaseDockWidget widget = bottomWidgets.get(i);
- currentY -= widget.getHeight();
- widget.setY(currentY);
- }
+ parent.requestLayout();
}
// Абстрактные методы для переопределения в наследниках
diff --git a/app/src/main/java/com/grigowashere/aismap/view/CompassView.java b/app/src/main/java/com/grigowashere/aismap/view/CompassView.java
index a0aa665..8cc5ac6 100644
--- a/app/src/main/java/com/grigowashere/aismap/view/CompassView.java
+++ b/app/src/main/java/com/grigowashere/aismap/view/CompassView.java
@@ -65,6 +65,29 @@ public class CompassView extends BaseDockWidget {
init();
}
+ /**
+ * Минимальная высота контента, при которой шкала компаса и её буквы N/S/W/E
+ * гарантированно помещаются в видимую область.
+ *
+ *
Считаем по факту отрисовки:
+ *
+ *
header (HEADING/MAG label+value+divider) ≈ 38dp,
+ *
шкала с буквами по краям ≈ 56dp.
+ *
+ * Итого ≈ 94dp полезного контента; ставим 96dp с запасом на baselines.
+ */
+ private static final int CONTENT_HEIGHT_DP = 96;
+
+ @Override
+ protected int getDefaultDockHeightDp() {
+ return CONTENT_HEIGHT_DP;
+ }
+
+ @Override
+ protected int measureDockContentHeightPx(int widthPx) {
+ return (int) dp(CONTENT_HEIGHT_DP);
+ }
+
private void init() {
paint.setColor(TICK_COLOR);
paint.setTextAlign(Paint.Align.CENTER);
@@ -156,40 +179,36 @@ public class CompassView extends BaseDockWidget {
// чтобы под статус-бар/бровь тоже уходил единый тон.
canvas.drawRect(0, 0, totalW, totalH, bgPaint);
- // Масштабируем размеры в зависимости от высоты контентной области.
- float baseHeight = dp(80);
- float scaleFactor = Math.max(0.8f, Math.min(2.0f, h / baseHeight));
-
// Шапка в стиле LABEL + значение (как POSITION/SOG/COG/ACC в
// координатах): слева HEADING (азимут), справа MAG (магн. компас).
- float cx = left + w / 2f;
+ // Размеры шапки фиксированы и не зависят от высоты виджета — это
+ // обычные строчки текста, они и так хорошо смотрятся при любой высоте.
float padInner = dp(10);
- float labelY = top + dp(12) * Math.max(1f, scaleFactor * 0.9f);
- float valueY = labelY + dp(16) * Math.max(1f, scaleFactor * 0.9f);
+ float labelY = top + dp(12);
+ float valueY = labelY + dp(16);
labelPaint.setTextAlign(Paint.Align.LEFT);
valuePaint.setTextAlign(Paint.Align.LEFT);
accentPaint.setTextAlign(Paint.Align.LEFT);
- canvas.drawText("HEADING", left + padInner, labelY, labelPaint);
- canvas.drawText(((int) currentAzimuth) + "°",
+ canvas.drawText(getResources().getString(com.grigowashere.aismap.R.string.compass_label_heading),
+ left + padInner, labelY, labelPaint);
+ canvas.drawText(((int) currentAzimuth) + "\u00B0",
left + padInner, valueY, accentPaint);
labelPaint.setTextAlign(Paint.Align.RIGHT);
valuePaint.setTextAlign(Paint.Align.RIGHT);
- canvas.drawText("MAG", right - padInner, labelY, labelPaint);
- canvas.drawText(((int) magneticCompass) + "°",
+ canvas.drawText(getResources().getString(com.grigowashere.aismap.R.string.compass_label_mag),
+ right - padInner, labelY, labelPaint);
+ canvas.drawText(((int) magneticCompass) + "\u00B0",
right - padInner, valueY, valuePaint);
- // Разделитель под шапкой — такой же, как в координатах.
float dividerY = valueY + dp(6);
canvas.drawLine(left + padInner, dividerY, right - padInner, dividerY, dividerPaint);
- // Цвет делений шкалы — светло-серый, чтобы не спорил с фоном палитры.
paint.setColor(TICK_COLOR);
- paint.setTextSize(24 * scaleFactor);
paint.setTextAlign(Paint.Align.CENTER);
-
+
// Плавное обновление азимута
float diff = getShortestRotation(currentAzimuth, targetAzimuth);
if (Math.abs(diff) > AZIMUTH_DRAW_EPS) {
@@ -199,59 +218,67 @@ public class CompassView extends BaseDockWidget {
currentAzimuth = normalizeAngle(currentAzimuth);
postInvalidateOnAnimation();
}
-
- // Рисуем простую шкалу под шапкой. Центр смещён, чтобы шкала
- // не наезжала на label-строку HEADING/MAG.
+
+ // === Шкала компаса ===
+ // ВСЕ метрики шкалы выражены в долях от фактической высоты scaleH —
+ // тогда буквы N/S/W/E и градусные подписи никогда не вылезают за
+ // нижнюю границу виджета, даже если пользователь сжал его ручкой.
float centerX = left + w / 2f;
float scaleTop = dividerY + dp(4);
- float centerY = scaleTop + (bottom - scaleTop) * 0.5f;
+ float scaleH = Math.max(dp(28), bottom - scaleTop);
+ float centerY = scaleTop + scaleH * 0.5f;
+
+ // Размеры тиков и подписей — фракции от scaleH.
+ float majorTickH = scaleH * 0.18f;
+ float minorTickH = scaleH * 0.09f;
+ float degreeTextY = centerY - scaleH * 0.32f; // подпись 0/30/60... над центром
+ float letterBaseY = centerY + scaleH * 0.40f; // буквы N/E/S/W под центром
+ float degreeTextSize = Math.max(dp(8), scaleH * 0.22f);
+
float visibleDegrees = 120;
-
- // Рисуем деления шкалы
for (int degree = 0; degree < 360; degree += 15) {
- // Вычисляем относительное положение деления
float relativeDegree = getShortestRotation(currentAzimuth, degree);
-
- // Рисуем только видимые деления
- if (Math.abs(relativeDegree) <= visibleDegrees / 2) {
- float x = centerX + (relativeDegree / (visibleDegrees / 2)) * (w / 2);
- float lineHeight = (degree % 30 == 0) ? 20 * scaleFactor : 10 * scaleFactor;
- canvas.drawLine(x, centerY - lineHeight, x, centerY + lineHeight, paint);
-
- if (degree % 30 == 0) {
- String degreeText = String.valueOf(degree);
- paint.setTextSize(16 * scaleFactor);
- canvas.drawText(degreeText, x, centerY - 30 * scaleFactor, paint);
- }
- if (degree % 45 == 0) {
- int directionIndex = (degree / 45) % 8;
- if (directionIndex < directions.length) {
- // Буква стороны света увеличивается при приближении к центру
- float proximity = 1f - Math.min(Math.abs(relativeDegree) / (visibleDegrees / 2f), 1f);
- float letterSize = (24f + 36f * proximity) * scaleFactor; // 24..48
- paint.setTextSize(letterSize);
- canvas.drawText(directions[directionIndex], x, centerY + 50 * scaleFactor, paint);
- }
+ if (Math.abs(relativeDegree) > visibleDegrees / 2) continue;
+
+ float x = centerX + (relativeDegree / (visibleDegrees / 2)) * (w / 2);
+ float lineHeight = (degree % 30 == 0) ? majorTickH : minorTickH;
+ canvas.drawLine(x, centerY - lineHeight, x, centerY + lineHeight, paint);
+
+ if (degree % 30 == 0) {
+ paint.setTextSize(degreeTextSize);
+ canvas.drawText(String.valueOf(degree), x, degreeTextY, paint);
+ }
+ if (degree % 45 == 0) {
+ int directionIndex = (degree / 45) % 8;
+ if (directionIndex < directions.length) {
+ // Буква стороны света увеличивается при приближении к центру.
+ float proximity = 1f - Math.min(Math.abs(relativeDegree) / (visibleDegrees / 2f), 1f);
+ // На краях ~0.35*scaleH, в центре ~0.7*scaleH — никогда не больше scaleH.
+ float letterSize = scaleH * (0.35f + 0.35f * proximity);
+ paint.setTextSize(letterSize);
+ canvas.drawText(directions[directionIndex], x, letterBaseY, paint);
}
}
}
-
- // Рисуем суда
+
+ // Рисуем суда: размер тоже скейлим по scaleH.
+ float vesselScale = scaleH / dp(60); // 1.0 при scaleH=60dp
+ vesselScale = Math.max(0.6f, Math.min(1.6f, vesselScale));
for (AISVessel vessel : nearbyVessels) {
float relativeBearing = getShortestRotation(currentAzimuth, (float) vessel.getCourse());
if (Math.abs(relativeBearing) <= visibleDegrees / 2) {
float x = centerX + (relativeBearing / (visibleDegrees / 2)) * (w / 2);
double distance = ourVessel != null ? GeoUtils.calculateDistance(ourVessel, vessel) : 0;
- float size = calculateVesselSize((float) distance) * scaleFactor;
+ float size = calculateVesselSize((float) distance) * vesselScale;
vesselPaint.setColor(getVesselColor(vessel));
drawVesselTriangle(canvas, x, centerY, size, (float) (vessel.getCourse() - currentAzimuth));
}
}
-
+
// Центральная линия (направление вперёд) — только в области шкалы,
// чтобы не пересекать шапку HEADING/MAG.
paint.setColor(Color.RED);
- paint.setStrokeWidth(3 * scaleFactor);
+ paint.setStrokeWidth(Math.max(2f, scaleH * 0.05f));
canvas.drawLine(centerX, scaleTop, centerX, bottom, paint);
paint.setColor(TICK_COLOR);
paint.setStrokeWidth(1);
@@ -360,7 +387,8 @@ public class CompassView extends BaseDockWidget {
canvas.drawText(((int) currentAzimuth) + "°", cx, cy + dp(2), accentPaint);
labelPaint.setTextAlign(Paint.Align.CENTER);
labelPaint.setTextSize(dp(9) * Math.max(0.7f, Math.min(1.4f, scaleFactor)));
- canvas.drawText("HEADING", cx, cy + dp(14), labelPaint);
+ canvas.drawText(getResources().getString(com.grigowashere.aismap.R.string.compass_label_heading),
+ cx, cy + dp(14), labelPaint);
}
diff --git a/app/src/main/java/com/grigowashere/aismap/view/CoordinatesDockWidget.java b/app/src/main/java/com/grigowashere/aismap/view/CoordinatesDockWidget.java
index d7cfa9c..36ee379 100644
--- a/app/src/main/java/com/grigowashere/aismap/view/CoordinatesDockWidget.java
+++ b/app/src/main/java/com/grigowashere/aismap/view/CoordinatesDockWidget.java
@@ -45,7 +45,38 @@ public class CoordinatesDockWidget extends BaseDockWidget {
super(context, attrs);
init();
}
-
+
+ @Override
+ protected int getDefaultDockHeightDp() {
+ // Fallback на случай, если по какой-то причине measureDockContentHeightPx
+ // не сработает. Реальная высота считается через measureDockContentHeightPx.
+ return 88;
+ }
+
+ @Override
+ protected boolean getDefaultDockTop() {
+ return false;
+ }
+
+ @Override
+ protected int measureDockContentHeightPx(int widthPx) {
+ // Контент состоит из 2 строк «label + value»:
+ // 1) POSITION + координаты,
+ // 2) SOG | COG | ACC.
+ // Каждая строка — labelH + valueH, плюс паддинги 8dp сверху/снизу и
+ // зазор 10dp между строками. Размеры берём ровно из тех Paint'ов,
+ // которыми отрисовка пользуется — это гарантирует, что любая правка
+ // размера шрифта автоматически подстроит высоту виджета.
+ float labelH = (labelPaint != null ? labelPaint.getTextSize() : dp(11)) * 1.1f;
+ float valueH = (textPaint != null ? textPaint.getTextSize() : dp(16)) * 1.15f;
+ float total = dp(8) // верхний внутренний отступ
+ + labelH + valueH // строка 1: POSITION
+ + dp(10) // зазор между блоками
+ + labelH + valueH // строка 2: SOG/COG/ACC
+ + dp(8); // нижний внутренний отступ
+ return (int) Math.ceil(total);
+ }
+
private void init() {
backgroundPaint = new Paint();
backgroundPaint.setColor(BACKGROUND_COLOR);
@@ -125,7 +156,7 @@ public class CoordinatesDockWidget extends BaseDockWidget {
if (vessel.getLatitude() != 0 || vessel.getLongitude() != 0) {
coordinatesText = formatLatLon(vessel.getLatitude(), vessel.getLongitude());
} else {
- coordinatesText = "нет фикса";
+ coordinatesText = getResources().getString(com.grigowashere.aismap.R.string.coords_value_no_fix);
}
if (vessel.getSpeed() > 0.05) {
@@ -195,34 +226,40 @@ public class CoordinatesDockWidget extends BaseDockWidget {
float innerTop = top + dp(8);
float innerBottom = bottom - dp(8);
- // Строка 1: координаты (с подписью "POSITION").
+ // Строка 1: координаты (с подписью "КООРДИНАТЫ").
Paint posPaint = getCoordinatesPaint();
float labelH = labelPaint.getTextSize() * 1.1f;
float valueH = posPaint.getTextSize() * 1.15f;
+ android.content.res.Resources res = getResources();
+
float y = innerTop + labelH;
- canvas.drawText("POSITION", innerLeft, y, labelPaint);
+ canvas.drawText(res.getString(com.grigowashere.aismap.R.string.coords_label_position),
+ innerLeft, y, labelPaint);
y += valueH;
canvas.drawText(coordinatesText, innerLeft, y, posPaint);
- // Строка 2: SOG | COG | ACC в три колонки.
+ // Строка 2: SOG/COG/ACC в три колонки.
float colTop = y + dp(10);
float colW = (innerRight - innerLeft) / 3f;
float colLabelY = colTop + labelH;
float colValueY = colLabelY + valueH;
- // SOG
- canvas.drawText("SOG", innerLeft, colLabelY, labelPaint);
+ // SOG (скорость).
+ canvas.drawText(res.getString(com.grigowashere.aismap.R.string.coords_label_sog),
+ innerLeft, colLabelY, labelPaint);
canvas.drawText(sogText, innerLeft, colValueY, getSOGPaint());
- // COG
+ // COG (курс по земле).
float cogX = innerLeft + colW;
- canvas.drawText("COG", cogX, colLabelY, labelPaint);
+ canvas.drawText(res.getString(com.grigowashere.aismap.R.string.coords_label_cog),
+ cogX, colLabelY, labelPaint);
canvas.drawText(cogText, cogX, colValueY, getCOGPaint());
- // ACC
+ // ACC (точность).
float accX = innerLeft + colW * 2f;
- canvas.drawText("ACC", accX, colLabelY, labelPaint);
+ canvas.drawText(res.getString(com.grigowashere.aismap.R.string.coords_label_acc),
+ accX, colLabelY, labelPaint);
canvas.drawText(accuracyText, accX, colValueY, getAccuracyPaint());
if (colValueY > innerBottom) {
@@ -278,7 +315,8 @@ public class CoordinatesDockWidget extends BaseDockWidget {
float y = centerY - totalH / 2f + smallLabel;
- drawCentered(canvas, "POSITION", centerX, y, labelPaint);
+ drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_position),
+ centerX, y, labelPaint);
y += lineH;
drawCentered(canvas, latLine, centerX, y, posPaint);
y += lineH;
@@ -291,14 +329,17 @@ public class CoordinatesDockWidget extends BaseDockWidget {
// SOG / COG бок о бок.
float colCenterL = centerX - radius * 0.45f;
float colCenterR = centerX + radius * 0.45f;
- drawCentered(canvas, "SOG", colCenterL, y, labelPaint);
- drawCentered(canvas, "COG", colCenterR, y, labelPaint);
+ drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_sog),
+ colCenterL, y, labelPaint);
+ drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_cog),
+ colCenterR, y, labelPaint);
y += bigValue + lineGap;
drawCentered(canvas, sogText, colCenterL, y, sogPaint);
drawCentered(canvas, cogText, colCenterR, y, cogPaint);
y += dp(6);
- drawCentered(canvas, "ACC", centerX, y, labelPaint);
+ drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_acc),
+ centerX, y, labelPaint);
y += smallValue + lineGap;
drawCentered(canvas, accuracyText, centerX, y, accPaint);
diff --git a/app/src/main/java/com/grigowashere/aismap/view/DangerTargetsDockWidget.java b/app/src/main/java/com/grigowashere/aismap/view/DangerTargetsDockWidget.java
new file mode 100644
index 0000000..063eb48
--- /dev/null
+++ b/app/src/main/java/com/grigowashere/aismap/view/DangerTargetsDockWidget.java
@@ -0,0 +1,345 @@
+package com.grigowashere.aismap.view;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.Typeface;
+import android.util.AttributeSet;
+
+import com.grigowashere.aismap.R;
+import com.grigowashere.aismap.controllers.AppCoordinator;
+import com.grigowashere.aismap.utils.SettingsManager;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Виджет «Опасные цели» — таблица ближайших AIS-целей в зоне опасности.
+ * Колонки: имя/MMSI, пеленг (°), дистанция (nm/km — в зависимости от настроек).
+ *
Обновление выполняется через {@link #setEntries(List)} с частотой 1 Hz из
+ * MainActivity, источник данных — {@link AppCoordinator#getDangerTargets(double, int)}.
+ */
+public class DangerTargetsDockWidget extends BaseDockWidget {
+
+ /** Запись таблицы: имя/MMSI + пеленг + дистанция в метрах. */
+ public static final class DangerEntry {
+ public final String name;
+ public final double bearingDeg;
+ public final double distanceMeters;
+
+ public DangerEntry(String name, double bearingDeg, double distanceMeters) {
+ this.name = name;
+ this.bearingDeg = bearingDeg;
+ this.distanceMeters = distanceMeters;
+ }
+ }
+
+ private static final int MAX_ROWS = 5;
+
+ /**
+ * Высота dock-режима по умолчанию (dp). Достаточная, чтобы вместить заголовок
+ * и 2 строки целей мелким шрифтом. Пользователь может растянуть ручкой за
+ * нижний край, если хочет увидеть больше строк.
+ */
+ private static final int DEFAULT_DOCK_HEIGHT_DP_DANGER = 72;
+
+ 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 Paint backgroundPaint;
+ private Paint titlePaint;
+ private Paint labelPaint;
+ private Paint textPaint;
+ private Paint accentPaint;
+ private Paint dividerPaint;
+
+ private final List entries = Collections.synchronizedList(new ArrayList<>());
+ private SettingsManager settingsManager;
+
+ public DangerTargetsDockWidget(Context context) {
+ super(context);
+ init();
+ }
+
+ public DangerTargetsDockWidget(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ @Override
+ protected int getDefaultDockHeightDp() {
+ return DEFAULT_DOCK_HEIGHT_DP_DANGER;
+ }
+
+ @Override
+ protected boolean getDefaultDockTop() {
+ // Виджет «Опасные цели» по умолчанию сидит ВНИЗУ над координатами —
+ // это самая безболезненная для карты позиция: при отсутствии целей
+ // он вообще скрыт, а когда появляется, не разрывает обзор по центру.
+ return false;
+ }
+
+ @Override
+ protected int measureDockContentHeightPx(int widthPx) {
+ // Высота зависит от количества опасных целей:
+ // * 1 строка → ~52dp
+ // * 2 строки → ~70dp
+ // * 3 строки → ~88dp
+ // * 4 строки → ~106dp
+ // * 5 строк → ~124dp
+ // Когда целей нет — виджет вообще GONE (см. MainActivity.updateDangerWidget),
+ // но если попадём сюда — считаем минимальную высоту с пустой строкой.
+ int count;
+ synchronized (entries) {
+ count = entries.size();
+ }
+ float titleH = (titlePaint != null ? titlePaint.getTextSize() : dp(11));
+ float rowH = (textPaint != null ? textPaint.getTextSize() : dp(12)) + dp(6);
+ float emptyH = (labelPaint != null ? labelPaint.getTextSize() : dp(10));
+
+ // top pad + title + (rowsH ИЛИ empty-строка) + bottom pad
+ float total;
+ if (count <= 0) {
+ total = dp(4) + titleH + dp(6) + emptyH + dp(8);
+ } else {
+ total = dp(4) + titleH + dp(6) + rowH * count + dp(8);
+ }
+ // Гарантируем минимум 52dp — иначе на 1 цель текст касается рамки.
+ return (int) Math.ceil(Math.max(dp(52), total));
+ }
+
+ private void init() {
+ settingsManager = new SettingsManager(getContext());
+
+ backgroundPaint = new Paint();
+ backgroundPaint.setColor(BACKGROUND_COLOR);
+ backgroundPaint.setStyle(Paint.Style.FILL);
+ backgroundPaint.setAntiAlias(true);
+
+ titlePaint = new Paint();
+ titlePaint.setColor(ACCENT_COLOR);
+ titlePaint.setTextSize(dp(11));
+ titlePaint.setTypeface(Typeface.DEFAULT_BOLD);
+ titlePaint.setLetterSpacing(0.08f);
+ titlePaint.setAntiAlias(true);
+
+ labelPaint = new Paint();
+ labelPaint.setColor(LABEL_COLOR);
+ labelPaint.setTextSize(dp(10));
+ labelPaint.setTypeface(Typeface.DEFAULT);
+ labelPaint.setLetterSpacing(0.04f);
+ labelPaint.setAntiAlias(true);
+
+ textPaint = new Paint();
+ textPaint.setColor(TEXT_COLOR);
+ textPaint.setTextSize(dp(12));
+ textPaint.setTypeface(Typeface.DEFAULT_BOLD);
+ textPaint.setAntiAlias(true);
+
+ accentPaint = new Paint();
+ accentPaint.setColor(ACCENT_COLOR);
+ accentPaint.setTextSize(dp(12));
+ accentPaint.setTypeface(Typeface.DEFAULT_BOLD);
+ accentPaint.setAntiAlias(true);
+
+ dividerPaint = new Paint();
+ dividerPaint.setColor(0x33FFFFFF);
+ dividerPaint.setStrokeWidth(dp(1));
+ dividerPaint.setAntiAlias(true);
+
+ setBackgroundColor(android.graphics.Color.TRANSPARENT);
+ }
+
+ /**
+ * Обновляет список опасных целей, пересчитывает высоту виджета
+ * (через requestLayout — measureDockContentHeightPx зависит от числа целей)
+ * и перерисовывает контент.
+ */
+ public void setEntries(List newEntries) {
+ int prevSize;
+ int newSize;
+ synchronized (entries) {
+ prevSize = entries.size();
+ entries.clear();
+ if (newEntries != null) {
+ for (DangerEntry e : newEntries) {
+ if (e != null) entries.add(e);
+ if (entries.size() >= MAX_ROWS) break;
+ }
+ }
+ newSize = entries.size();
+ }
+ // requestLayout только если число строк реально изменилось — лишний
+ // measure-pass на 1Hz обновлении содержимого ни к чему.
+ if (prevSize != newSize) {
+ requestLayout();
+ }
+ invalidate();
+ }
+
+ @Override
+ protected void onDrawDock(Canvas canvas) {
+ int width = getWidth();
+ int height = getHeight();
+ if (width <= 0 || height <= 0) return;
+
+ canvas.drawRect(0, 0, width, height, backgroundPaint);
+
+ float left = getPaddingLeft();
+ float top = getPaddingTop();
+ float right = width - getPaddingRight();
+ float bottom = height - getPaddingBottom();
+ if (right - left <= 0 || bottom - top <= 0) return;
+
+ // Тонкая красная полоска вдоль края, ближайшего к карте — она работает как
+ // акцент и одновременно как resize-зона (см. BaseDockWidget).
+ Paint edgePaint = new Paint(dividerPaint);
+ edgePaint.setColor(ACCENT_COLOR);
+ edgePaint.setStrokeWidth(dp(2));
+ if (isDockTop()) {
+ canvas.drawLine(left, bottom - dp(1), right, bottom - dp(1), edgePaint);
+ } else {
+ canvas.drawLine(left, top + dp(1), right, top + dp(1), edgePaint);
+ }
+
+ float padX = dp(12);
+ float innerLeft = left + padX;
+ float innerRight = right - padX;
+
+ // Снапшот текущих записей.
+ List snapshot;
+ synchronized (entries) {
+ snapshot = new ArrayList<>(entries);
+ }
+
+ // === Заголовок: «Опасные цели · N» ===
+ float titleBaseline = top + dp(4) + titlePaint.getTextSize();
+ String titleBase = getResources().getString(R.string.danger_widget_title);
+ String title = snapshot.isEmpty()
+ ? titleBase
+ : titleBase + " \u00B7 " + snapshot.size();
+ canvas.drawText(title, innerLeft, titleBaseline, titlePaint);
+
+ // Пустое состояние: рендерим короткое сообщение тонким шрифтом и выходим.
+ if (snapshot.isEmpty()) {
+ float emptyBaseline = titleBaseline + labelPaint.getTextSize() + dp(4);
+ canvas.drawText(getResources().getString(R.string.danger_widget_empty),
+ innerLeft, emptyBaseline, labelPaint);
+ return;
+ }
+
+ // === Строки целей. Колонки: имя | пеленг | дистанция ===
+ // Имя — широкая колонка слева, пеленг и дистанция — справа выровнены по
+ // фиксированной ширине, чтобы цифры не «прыгали» между строками.
+ 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);
+
+ boolean useNm = settingsManager == null
+ || SettingsManager.RANGE_UNIT_NM.equals(settingsManager.getRangeUnit());
+
+ // Высоту строки считаем по реальному текстовому шрифту.
+ float rowH = textPaint.getTextSize() + dp(6);
+ float y = titleBaseline + dp(6) + textPaint.getTextSize();
+ for (DangerEntry e : snapshot) {
+ if (y > bottom - dp(2)) break;
+ String rawName = (e.name == null || e.name.isEmpty()) ? "\u2014" : e.name;
+ 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);
+
+ 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);
+
+ y += rowH;
+ }
+ }
+
+ @Override
+ protected void onDrawCircle(Canvas canvas) {
+ int width = getWidth();
+ int height = getHeight();
+ int centerX = width / 2;
+ int centerY = height / 2;
+ int radius = Math.min(width, height) / 2 - (int) dp(4);
+
+ canvas.drawCircle(centerX, centerY, radius, backgroundPaint);
+
+ Paint borderPaint = new Paint();
+ borderPaint.setColor(ACCENT_COLOR);
+ borderPaint.setStyle(Paint.Style.STROKE);
+ borderPaint.setStrokeWidth(dp(2));
+ borderPaint.setAntiAlias(true);
+ canvas.drawCircle(centerX, centerY, radius, borderPaint);
+
+ // В compact-режиме показываем только число опасных целей и ближайшую.
+ int count;
+ DangerEntry nearest;
+ synchronized (entries) {
+ count = entries.size();
+ nearest = entries.isEmpty() ? null : entries.get(0);
+ }
+ boolean useNm = settingsManager == null
+ || SettingsManager.RANGE_UNIT_NM.equals(settingsManager.getRangeUnit());
+
+ Paint countPaint = new Paint(accentPaint);
+ countPaint.setTextSize(dp(28));
+ Paint subPaint = new Paint(textPaint);
+ subPaint.setTextSize(dp(11));
+
+ String countStr = String.valueOf(count);
+ Rect b = new Rect();
+ countPaint.getTextBounds(countStr, 0, countStr.length(), b);
+ canvas.drawText(countStr, centerX - b.width() / 2f - b.left, centerY, countPaint);
+
+ String label = getResources().getString(R.string.danger_widget_title);
+ b = new Rect();
+ subPaint.getTextBounds(label, 0, label.length(), b);
+ canvas.drawText(label, centerX - b.width() / 2f - b.left,
+ centerY - dp(28), subPaint);
+
+ if (nearest != null) {
+ String nearestStr = String.format(Locale.US, "%03.0f\u00B0 %s",
+ nearest.bearingDeg, formatDistance(nearest.distanceMeters, useNm));
+ b = new Rect();
+ subPaint.getTextBounds(nearestStr, 0, nearestStr.length(), b);
+ canvas.drawText(nearestStr, centerX - b.width() / 2f - b.left,
+ centerY + dp(20), subPaint);
+ }
+ }
+
+ private static String formatDistance(double meters, boolean useNm) {
+ if (useNm) {
+ double nm = meters / 1852.0;
+ return String.format(Locale.US, "%.2f nm", nm);
+ }
+ if (meters >= 1000.0) {
+ return String.format(Locale.US, "%.2f km", meters / 1000.0);
+ }
+ return String.format(Locale.US, "%.0f m", meters);
+ }
+
+ private static String ellipsize(String text, Paint paint, float maxWidth) {
+ if (text == null) return "";
+ if (maxWidth <= 0) return text;
+ if (paint.measureText(text) <= maxWidth) return text;
+ String ellipsis = "\u2026";
+ int len = text.length();
+ while (len > 0 && paint.measureText(text.substring(0, len) + ellipsis) > maxWidth) {
+ len--;
+ }
+ if (len <= 0) return ellipsis;
+ return text.substring(0, len) + ellipsis;
+ }
+}
diff --git a/app/src/main/java/com/grigowashere/aismap/view/PlotterHeadingView.java b/app/src/main/java/com/grigowashere/aismap/view/PlotterHeadingView.java
new file mode 100644
index 0000000..b0d717a
--- /dev/null
+++ b/app/src/main/java/com/grigowashere/aismap/view/PlotterHeadingView.java
@@ -0,0 +1,147 @@
+package com.grigowashere.aismap.view;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.util.AttributeSet;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+
+import com.grigowashere.aismap.R;
+
+import java.util.Locale;
+
+/**
+ * Компактная роза курса для режима картплоттера (без dock-поведения).
+ */
+public class PlotterHeadingView extends View {
+
+ private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint valuePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint ringPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint tickPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint needlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Path needle = new Path();
+
+ private float headingDeg = 0f;
+ private float magneticDeg = Float.NaN;
+
+ public PlotterHeadingView(Context context) {
+ super(context);
+ init();
+ }
+
+ public PlotterHeadingView(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ private void init() {
+ int label = ContextCompat.getColor(getContext(), R.color.plotter_text_label);
+ int text = ContextCompat.getColor(getContext(), R.color.plotter_text_primary);
+ int accent = ContextCompat.getColor(getContext(), R.color.plotter_text_accent);
+
+ labelPaint.setColor(label);
+ labelPaint.setTextSize(dp(9));
+ labelPaint.setLetterSpacing(0.06f);
+
+ valuePaint.setColor(text);
+ valuePaint.setTextSize(dp(16));
+ valuePaint.setFakeBoldText(true);
+
+ ringPaint.setStyle(Paint.Style.STROKE);
+ ringPaint.setStrokeWidth(dp(1.5f));
+ ringPaint.setColor(accent);
+
+ tickPaint.set(ringPaint);
+ tickPaint.setStrokeWidth(dp(1));
+
+ needlePaint.setStyle(Paint.Style.FILL);
+ needlePaint.setColor(accent);
+ }
+
+ public void setHeading(float headingDeg, float magneticDeg) {
+ this.headingDeg = headingDeg;
+ this.magneticDeg = magneticDeg;
+ invalidate();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ int w = getWidth();
+ int h = getHeight();
+ if (w <= 0 || h <= 0) return;
+
+ String hdgLabel = getContext().getString(R.string.radar_plotter_heading_label);
+ canvas.drawText(hdgLabel, dp(8), dp(12), labelPaint);
+
+ String hdgVal = String.format(Locale.US, "%03.0f\u00B0", normalize(headingDeg));
+ canvas.drawText(hdgVal, dp(8), dp(32), valuePaint);
+
+ String mag = null;
+ if (!Float.isNaN(magneticDeg)) {
+ mag = String.format(Locale.US, "MAG %03.0f\u00B0", normalize(magneticDeg));
+ canvas.drawText(mag, dp(8), dp(48), labelPaint);
+ }
+
+ float textBlockW = Math.max(labelPaint.measureText(hdgLabel), valuePaint.measureText(hdgVal));
+ if (mag != null) {
+ textBlockW = Math.max(textBlockW, labelPaint.measureText(mag));
+ }
+ float pad = dp(8);
+ float roseLeft = pad + textBlockW + pad;
+ float roseRight = w - pad;
+ float roseTop = pad;
+ float roseBottom = h - pad;
+ float availW = Math.max(0f, roseRight - roseLeft);
+ float availH = Math.max(0f, roseBottom - roseTop);
+ float cx = roseLeft + availW * 0.5f;
+ float cy = roseTop + availH * 0.5f;
+ float r = Math.min(availW, availH) * 0.45f - dp(2);
+ if (r < dp(8)) {
+ r = dp(8);
+ }
+
+ canvas.drawCircle(cx, cy, r, ringPaint);
+ String[] dirs = {"N", "E", "S", "W"};
+ for (int i = 0; i < 4; i++) {
+ double ang = Math.toRadians(i * 90 - headingDeg);
+ float tx = cx + (float) (Math.sin(ang) * (r + dp(10)));
+ float ty = cy - (float) (Math.cos(ang) * (r + dp(10)));
+ String d = dirs[i];
+ canvas.drawText(d, tx - labelPaint.measureText(d) / 2f, ty + dp(4), labelPaint);
+ }
+
+ for (int deg = 0; deg < 360; deg += 30) {
+ double ang = Math.toRadians(deg - headingDeg);
+ float x1 = cx + (float) (Math.sin(ang) * (r - dp(4)));
+ float y1 = cy - (float) (Math.cos(ang) * (r - dp(4)));
+ float x2 = cx + (float) (Math.sin(ang) * r);
+ float y2 = cy - (float) (Math.cos(ang) * r);
+ canvas.drawLine(x1, y1, x2, y2, tickPaint);
+ }
+
+ double needleAng = Math.toRadians(-headingDeg);
+ float nx = cx + (float) (Math.sin(needleAng) * (r - dp(6)));
+ float ny = cy - (float) (Math.cos(needleAng) * (r - dp(6)));
+ needle.reset();
+ needle.moveTo(cx, cy);
+ needle.lineTo(nx, ny);
+ needle.lineTo(cx + dp(4), cy);
+ needle.close();
+ canvas.drawPath(needle, needlePaint);
+ canvas.drawCircle(cx, cy, dp(3), needlePaint);
+ }
+
+ private static float normalize(float deg) {
+ float d = deg % 360f;
+ return d < 0 ? d + 360f : d;
+ }
+
+ private float dp(float v) {
+ return v * getResources().getDisplayMetrics().density;
+ }
+}
diff --git a/app/src/main/java/com/grigowashere/aismap/view/PlotterSpeedometerView.java b/app/src/main/java/com/grigowashere/aismap/view/PlotterSpeedometerView.java
new file mode 100644
index 0000000..fd1cf8a
--- /dev/null
+++ b/app/src/main/java/com/grigowashere/aismap/view/PlotterSpeedometerView.java
@@ -0,0 +1,141 @@
+package com.grigowashere.aismap.view;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.util.AttributeSet;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+
+import com.grigowashere.aismap.R;
+
+import java.util.Locale;
+
+/**
+ * Простой аналоговый спидометр (узлы) в стиле картплоттера.
+ */
+public class PlotterSpeedometerView extends View {
+
+ private static final float MAX_SPEED_KNOTS = 30f;
+
+ private final Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint arcPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint tickPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint needlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint valuePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final RectF arcRect = new RectF();
+
+ private float speedKnots = 0f;
+ private String title;
+
+ public PlotterSpeedometerView(Context context) {
+ super(context);
+ init();
+ }
+
+ public PlotterSpeedometerView(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ private void init() {
+ title = getContext().getString(R.string.radar_plotter_sog_label);
+ int labelColor = ContextCompat.getColor(getContext(), R.color.plotter_text_label);
+ int textColor = ContextCompat.getColor(getContext(), R.color.plotter_text_primary);
+ int accent = ContextCompat.getColor(getContext(), R.color.plotter_text_accent);
+
+ bgPaint.setStyle(Paint.Style.STROKE);
+ bgPaint.setStrokeWidth(dp(2));
+ bgPaint.setColor(0x44FFFFFF);
+
+ arcPaint.setStyle(Paint.Style.STROKE);
+ arcPaint.setStrokeWidth(dp(6));
+ arcPaint.setColor(accent);
+ arcPaint.setStrokeCap(Paint.Cap.ROUND);
+
+ tickPaint.setStyle(Paint.Style.STROKE);
+ tickPaint.setStrokeWidth(dp(1));
+ tickPaint.setColor(labelColor);
+
+ needlePaint.setStyle(Paint.Style.STROKE);
+ needlePaint.setStrokeWidth(dp(2.5f));
+ needlePaint.setColor(accent);
+ needlePaint.setStrokeCap(Paint.Cap.ROUND);
+
+ labelPaint.setColor(labelColor);
+ labelPaint.setTextSize(dp(10));
+ labelPaint.setLetterSpacing(0.08f);
+
+ valuePaint.setColor(textColor);
+ valuePaint.setTextSize(dp(18));
+ valuePaint.setFakeBoldText(true);
+ }
+
+ public void setSpeedKnots(double knots) {
+ float v = (float) Math.max(0.0, Math.min(MAX_SPEED_KNOTS, knots));
+ if (Math.abs(v - speedKnots) > 0.05f) {
+ speedKnots = v;
+ invalidate();
+ }
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ int w = getWidth();
+ int h = getHeight();
+ if (w <= 0 || h <= 0) return;
+
+ float pad = dp(8);
+ float titleY = dp(14);
+ canvas.drawText(title, w * 0.5f - labelPaint.measureText(title) / 2f, titleY, labelPaint);
+
+ float cx = w * 0.5f;
+ float bottom = h - pad;
+ float maxRadius = Math.min((w - 2f * pad) * 0.5f, bottom - titleY - pad);
+ float radius = Math.max(dp(12), maxRadius * 0.88f);
+ float cy = bottom;
+ arcRect.set(cx - radius, cy - radius, cx + radius, cy + radius);
+
+ canvas.drawArc(arcRect, 180f, 180f, false, bgPaint);
+
+ float sweep = 180f * (speedKnots / MAX_SPEED_KNOTS);
+ canvas.drawArc(arcRect, 180f, sweep, false, arcPaint);
+
+ for (int k = 0; k <= 30; k += 5) {
+ float frac = k / MAX_SPEED_KNOTS;
+ double ang = Math.toRadians(180 + 180 * frac);
+ float x1 = cx + (float) (Math.cos(ang) * (radius - dp(4)));
+ float y1 = cy + (float) (Math.sin(ang) * (radius - dp(4)));
+ float x2 = cx + (float) (Math.cos(ang) * radius);
+ float y2 = cy + (float) (Math.sin(ang) * radius);
+ canvas.drawLine(x1, y1, x2, y2, tickPaint);
+ if (k % 10 == 0) {
+ String t = String.valueOf(k);
+ float tw = labelPaint.measureText(t);
+ canvas.drawText(t,
+ cx + (float) (Math.cos(ang) * (radius - dp(16))) - tw / 2f,
+ cy + (float) (Math.sin(ang) * (radius - dp(16))) + dp(4),
+ labelPaint);
+ }
+ }
+
+ double needleAng = Math.toRadians(180 + 180 * (speedKnots / MAX_SPEED_KNOTS));
+ float nx = cx + (float) (Math.cos(needleAng) * (radius - dp(10)));
+ float ny = cy + (float) (Math.sin(needleAng) * (radius - dp(10)));
+ canvas.drawLine(cx, cy, nx, ny, needlePaint);
+ canvas.drawCircle(cx, cy, dp(4), needlePaint);
+
+ String val = String.format(Locale.US, "%.1f", speedKnots);
+ canvas.drawText(val, cx - valuePaint.measureText(val) / 2f, cy - dp(6), valuePaint);
+ String unit = "kn";
+ canvas.drawText(unit, cx - labelPaint.measureText(unit) / 2f, cy + dp(12), labelPaint);
+ }
+
+ private float dp(float v) {
+ return v * getResources().getDisplayMetrics().density;
+ }
+}
diff --git a/app/src/main/java/com/grigowashere/aismap/view/PlotterTargetsTableView.java b/app/src/main/java/com/grigowashere/aismap/view/PlotterTargetsTableView.java
new file mode 100644
index 0000000..f920c2d
--- /dev/null
+++ b/app/src/main/java/com/grigowashere/aismap/view/PlotterTargetsTableView.java
@@ -0,0 +1,189 @@
+package com.grigowashere.aismap.view;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Typeface;
+import android.util.AttributeSet;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+
+import com.grigowashere.aismap.R;
+import com.grigowashere.aismap.controllers.AppCoordinator;
+import com.grigowashere.aismap.utils.SettingsManager;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Таблица ближайших AIS-целей для режима картплоттера.
+ */
+public class PlotterTargetsTableView extends View {
+
+ public static final class Row {
+ public final String name;
+ public final double bearingDeg;
+ public final double distanceMeters;
+
+ public Row(String name, double bearingDeg, double distanceMeters) {
+ this.name = name;
+ this.bearingDeg = bearingDeg;
+ this.distanceMeters = distanceMeters;
+ }
+ }
+
+ private static final int MAX_ROWS = 8;
+
+ private final Paint titlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint headerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint rowPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint accentPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint emptyPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint dividerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ private final List rows = Collections.synchronizedList(new ArrayList<>());
+ private SettingsManager settingsManager;
+ private String title;
+ private String emptyText;
+
+ public PlotterTargetsTableView(Context context) {
+ super(context);
+ init();
+ }
+
+ public PlotterTargetsTableView(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ private void init() {
+ settingsManager = new SettingsManager(getContext());
+ title = getContext().getString(R.string.radar_plotter_table_title);
+ emptyText = getContext().getString(R.string.radar_plotter_table_empty);
+
+ int label = ContextCompat.getColor(getContext(), R.color.plotter_text_label);
+ int text = ContextCompat.getColor(getContext(), R.color.plotter_text_primary);
+ int accent = ContextCompat.getColor(getContext(), R.color.plotter_text_accent);
+
+ titlePaint.setColor(accent);
+ titlePaint.setTextSize(dp(10));
+ titlePaint.setTypeface(Typeface.DEFAULT_BOLD);
+ titlePaint.setLetterSpacing(0.06f);
+
+ headerPaint.setColor(label);
+ headerPaint.setTextSize(dp(9));
+
+ rowPaint.setColor(text);
+ rowPaint.setTextSize(dp(10));
+ rowPaint.setTypeface(Typeface.DEFAULT_BOLD);
+
+ accentPaint.set(rowPaint);
+ accentPaint.setColor(accent);
+
+ emptyPaint.setColor(label);
+ emptyPaint.setTextSize(dp(10));
+
+ dividerPaint.setColor(0x33FFFFFF);
+ dividerPaint.setStrokeWidth(dp(1));
+ }
+
+ public void setRowsFromCoordinatorEntries(List entries) {
+ List next = new ArrayList<>();
+ if (entries != null) {
+ for (AppCoordinator.DangerEntry e : entries) {
+ if (e == null || e.vessel == null) continue;
+ String label = e.vessel.getVesselName();
+ if (label == null || label.trim().isEmpty()) {
+ label = e.vessel.getMmsi() != null ? e.vessel.getMmsi() : "—";
+ }
+ next.add(new Row(label, e.bearingDegrees, e.distanceMeters));
+ if (next.size() >= MAX_ROWS) break;
+ }
+ }
+ synchronized (rows) {
+ rows.clear();
+ rows.addAll(next);
+ }
+ invalidate();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ int w = getWidth();
+ int h = getHeight();
+ if (w <= 0 || h <= 0) return;
+
+ float pad = dp(8);
+ float y = pad + dp(12);
+ canvas.drawText(title, pad, y, titlePaint);
+ y += dp(14);
+
+ boolean useNm = settingsManager != null
+ && SettingsManager.RANGE_UNIT_NM.equals(settingsManager.getRangeUnit());
+ String cpaNa = getContext().getString(R.string.radar_plotter_cpa_na);
+
+ float colName = pad;
+ float colBrg = w * 0.52f;
+ float colRng = w * 0.68f;
+ float colCpa = w * 0.84f;
+
+ canvas.drawText(getContext().getString(R.string.radar_plotter_col_name), colName, y, headerPaint);
+ canvas.drawText(getContext().getString(R.string.radar_plotter_col_brg), colBrg, y, headerPaint);
+ canvas.drawText(getContext().getString(R.string.radar_plotter_col_rng), colRng, y, headerPaint);
+ canvas.drawText(getContext().getString(R.string.radar_plotter_col_cpa), colCpa, y, headerPaint);
+ y += dp(6);
+ canvas.drawLine(pad, y, w - pad, y, dividerPaint);
+ y += dp(10);
+
+ List snapshot;
+ synchronized (rows) {
+ snapshot = new ArrayList<>(rows);
+ }
+
+ if (snapshot.isEmpty()) {
+ canvas.drawText(emptyText, pad, y, emptyPaint);
+ return;
+ }
+
+ float rowH = dp(14);
+ float nameMax = colBrg - colName - dp(4);
+ for (Row r : snapshot) {
+ String name = ellipsize(r.name, rowPaint, nameMax);
+ 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);
+ y += rowH;
+ if (y > h - pad) break;
+ }
+ }
+
+ private static String formatDistance(double meters, boolean useNm) {
+ if (useNm) {
+ return String.format(Locale.US, "%.2f", meters / 1852.0);
+ }
+ if (meters >= 1000.0) {
+ return String.format(Locale.US, "%.1f", meters / 1000.0);
+ }
+ return String.format(Locale.US, "%.0f", meters);
+ }
+
+ private static String ellipsize(String text, Paint paint, float maxWidth) {
+ if (text == null) return "";
+ if (maxWidth <= 0 || paint.measureText(text) <= maxWidth) return text;
+ String ellipsis = "\u2026";
+ int len = text.length();
+ while (len > 0 && paint.measureText(text.substring(0, len) + ellipsis) > maxWidth) {
+ len--;
+ }
+ return len <= 0 ? ellipsis : text.substring(0, len) + ellipsis;
+ }
+
+ private float dp(float v) {
+ return v * getResources().getDisplayMetrics().density;
+ }
+}
diff --git a/app/src/main/java/com/grigowashere/aismap/view/RadarGraticuleOverlay.java b/app/src/main/java/com/grigowashere/aismap/view/RadarGraticuleOverlay.java
new file mode 100644
index 0000000..f1886dd
--- /dev/null
+++ b/app/src/main/java/com/grigowashere/aismap/view/RadarGraticuleOverlay.java
@@ -0,0 +1,319 @@
+package com.grigowashere.aismap.view;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffXfermode;
+import android.graphics.RectF;
+import android.util.AttributeSet;
+import android.view.Choreographer;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+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;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * PPI-наложение: кольца дальности, сетка пеленгов, «свип» и цели AIS.
+ */
+public class RadarGraticuleOverlay extends View {
+
+ public static final class Blip {
+ public final double bearingDeg;
+ /** 0..1 относительно радиуса PPI */
+ public final float rangeFraction;
+ public final boolean danger;
+
+ public Blip(double bearingDeg, float rangeFraction, boolean danger) {
+ this.bearingDeg = bearingDeg;
+ this.rangeFraction = rangeFraction;
+ this.danger = danger;
+ }
+ }
+
+ private final Paint gridPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint gridBrightPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint sweepPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint sweepGlowPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint sweepCorePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint blipPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint dangerBlipPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint vignettePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Path sweepPath = new Path();
+ private final RectF circleRect = new RectF();
+
+ private final List blips = new ArrayList<>();
+ private final Choreographer choreographer = Choreographer.getInstance();
+ private final Choreographer.FrameCallback sweepFrameCallback = this::onSweepFrame;
+
+ private float sweepAngle = 0f;
+ private long lastSweepNanos = 0L;
+ private boolean sweepRunning;
+ private double rangeMeters = 1852.0 * 5.0;
+ private String rangeUnit = SettingsManager.RANGE_UNIT_NM;
+ private float headingUpDeg = 0f;
+
+ /** Полный оборот свипа, секунды */
+ private static final float SWEEP_PERIOD_SEC = 5f;
+
+ public RadarGraticuleOverlay(Context context) {
+ super(context);
+ init();
+ }
+
+ public RadarGraticuleOverlay(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ private void init() {
+ setLayerType(LAYER_TYPE_HARDWARE, null);
+
+ int grid = ContextCompat.getColor(getContext(), R.color.plotter_radar_grid);
+ int gridBright = ContextCompat.getColor(getContext(), R.color.plotter_radar_grid_bright);
+ int sweep = ContextCompat.getColor(getContext(), R.color.plotter_radar_sweep);
+ int blip = ContextCompat.getColor(getContext(), R.color.plotter_target_blip);
+
+ gridPaint.setStyle(Paint.Style.STROKE);
+ gridPaint.setStrokeWidth(dp(1));
+ gridPaint.setColor(grid);
+
+ gridBrightPaint.set(gridPaint);
+ gridBrightPaint.setColor(gridBright);
+ gridBrightPaint.setStrokeWidth(dp(1.2f));
+
+ sweepPaint.setStyle(Paint.Style.FILL);
+ sweepPaint.setColor(sweep);
+ sweepPaint.setAlpha(64);
+
+ sweepGlowPaint.set(sweepPaint);
+ sweepGlowPaint.setAlpha(36);
+
+ sweepCorePaint.setStyle(Paint.Style.STROKE);
+ sweepCorePaint.setColor(sweep);
+ sweepCorePaint.setStrokeWidth(dp(2.5f));
+ sweepCorePaint.setStrokeCap(Paint.Cap.ROUND);
+ sweepCorePaint.setAlpha(220);
+
+ blipPaint.setStyle(Paint.Style.FILL);
+ blipPaint.setColor(blip);
+
+ dangerBlipPaint.set(blipPaint);
+ dangerBlipPaint.setColor(ContextCompat.getColor(getContext(), R.color.plotter_text_accent));
+
+ labelPaint.setColor(ContextCompat.getColor(getContext(), R.color.plotter_text_label));
+ labelPaint.setTextSize(dp(9));
+ labelPaint.setLetterSpacing(0.05f);
+
+ vignettePaint.setStyle(Paint.Style.FILL);
+ vignettePaint.setColor(0x44000000);
+ }
+
+ public void setRangeMeters(double rangeMeters) {
+ if (rangeMeters > 0) {
+ this.rangeMeters = rangeMeters;
+ invalidate();
+ }
+ }
+
+ public void setRangeUnit(String unit) {
+ if (unit != null && !unit.isEmpty()) {
+ rangeUnit = unit;
+ invalidate();
+ }
+ }
+
+ public void setHeadingUpDeg(float headingUpDeg) {
+ this.headingUpDeg = headingUpDeg;
+ }
+
+ public void setBlipsFromDangerEntries(List entries,
+ double dangerRadiusMeters) {
+ List next = new ArrayList<>();
+ if (entries != null && rangeMeters > 0) {
+ for (AppCoordinator.DangerEntry e : entries) {
+ if (e == null) continue;
+ float frac = (float) Math.min(1.0, e.distanceMeters / rangeMeters);
+ boolean danger = dangerRadiusMeters > 0 && e.distanceMeters <= dangerRadiusMeters;
+ next.add(new Blip(e.bearingDegrees, frac, danger));
+ }
+ }
+ synchronized (blips) {
+ blips.clear();
+ blips.addAll(next);
+ }
+ invalidate();
+ }
+
+ public void setAllTargetsInRange(List entries,
+ double dangerRadiusMeters) {
+ List next = new ArrayList<>();
+ if (entries != null && rangeMeters > 0) {
+ for (AppCoordinator.DangerEntry e : entries) {
+ if (e == null) continue;
+ float frac = (float) Math.min(1.0, e.distanceMeters / rangeMeters);
+ boolean danger = dangerRadiusMeters > 0 && e.distanceMeters <= dangerRadiusMeters;
+ next.add(new Blip(e.bearingDegrees, frac, danger));
+ }
+ }
+ synchronized (blips) {
+ blips.clear();
+ blips.addAll(next);
+ }
+ invalidate();
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ startSweepAnimation();
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ stopSweepAnimation();
+ super.onDetachedFromWindow();
+ }
+
+ private void startSweepAnimation() {
+ if (sweepRunning) return;
+ sweepRunning = true;
+ lastSweepNanos = System.nanoTime();
+ choreographer.postFrameCallback(sweepFrameCallback);
+ }
+
+ private void stopSweepAnimation() {
+ if (!sweepRunning) return;
+ sweepRunning = false;
+ choreographer.removeFrameCallback(sweepFrameCallback);
+ }
+
+ private void onSweepFrame(long frameTimeNanos) {
+ if (!sweepRunning) return;
+ if (lastSweepNanos > 0L) {
+ float dtSec = (frameTimeNanos - lastSweepNanos) / 1_000_000_000f;
+ sweepAngle = (sweepAngle + (360f / SWEEP_PERIOD_SEC) * dtSec) % 360f;
+ invalidate();
+ }
+ lastSweepNanos = frameTimeNanos;
+ choreographer.postFrameCallback(sweepFrameCallback);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ int w = getWidth();
+ int h = getHeight();
+ if (w <= 0 || h <= 0) return;
+
+ float cx = w * 0.5f;
+ float cy = h * 0.5f;
+ float radius = Math.min(cx, cy) - dp(4);
+ circleRect.set(cx - radius, cy - radius, cx + radius, cy + radius);
+
+ canvas.saveLayer(0, 0, w, h, null);
+
+ // Затемнение за пределами круга (маска PPI)
+ canvas.drawRect(0, 0, w, h, vignettePaint);
+ Paint clear = new Paint();
+ clear.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
+ canvas.drawCircle(cx, cy, radius, clear);
+ clear.setXfermode(null);
+
+ canvas.save();
+ canvas.clipRect(circleRect);
+
+ // Кольца PPI (4 кольца) + метки дальности на пересечениях с осями N/E/S/W
+ for (int i = 1; i <= 4; i++) {
+ float r = radius * i / 4f;
+ Paint p = (i == 4) ? gridBrightPaint : gridPaint;
+ canvas.drawCircle(cx, cy, r, p);
+ drawRingRangeLabels(canvas, cx, cy, r, rangeMeters * i / 4.0);
+ }
+
+ // Лучи каждые 30°
+ for (int deg = 0; deg < 360; deg += 30) {
+ double rad = Math.toRadians(deg - headingUpDeg);
+ float x2 = cx + (float) (Math.sin(rad) * radius);
+ float y2 = cy - (float) (Math.cos(rad) * radius);
+ canvas.drawLine(cx, cy, x2, y2, deg % 90 == 0 ? gridBrightPaint : gridPaint);
+ }
+
+ // Свип с мягким свечением
+ double sweepRad = Math.toRadians(sweepAngle - headingUpDeg);
+ float tipX = cx + (float) (Math.sin(sweepRad) * radius);
+ float tipY = cy - (float) (Math.cos(sweepRad) * radius);
+ drawSweepWedge(canvas, cx, cy, radius, sweepRad, 0.22, sweepGlowPaint);
+ drawSweepWedge(canvas, cx, cy, radius, sweepRad, 0.10, sweepPaint);
+ canvas.drawLine(cx, cy, tipX, tipY, sweepCorePaint);
+
+ List snapshot;
+ synchronized (blips) {
+ snapshot = new ArrayList<>(blips);
+ }
+ for (Blip b : snapshot) {
+ double rel = Math.toRadians(b.bearingDeg - headingUpDeg);
+ float dist = radius * b.rangeFraction;
+ float bx = cx + (float) (Math.sin(rel) * dist);
+ float by = cy - (float) (Math.cos(rel) * dist);
+ Paint p = b.danger ? dangerBlipPaint : blipPaint;
+ canvas.drawRect(bx - dp(3), by - dp(3), bx + dp(3), by + dp(3), p);
+ }
+
+ canvas.restore();
+ canvas.restore();
+ }
+
+ private void drawSweepWedge(Canvas canvas, float cx, float cy, float radius,
+ double sweepRad, double halfAngleRad, Paint paint) {
+ sweepPath.reset();
+ sweepPath.moveTo(cx, cy);
+ sweepPath.lineTo(cx + (float) (Math.sin(sweepRad) * radius),
+ cy - (float) (Math.cos(sweepRad) * radius));
+ sweepPath.lineTo(cx + (float) (Math.sin(sweepRad + halfAngleRad) * radius * 0.12f),
+ cy - (float) (Math.cos(sweepRad + halfAngleRad) * radius * 0.12f));
+ sweepPath.close();
+ canvas.drawPath(sweepPath, paint);
+ }
+
+ /** Метки дальности кольца на пересечениях с пеленгами 0°/90°/180°/270° (курс вверх). */
+ private void drawRingRangeLabels(Canvas canvas, float cx, float cy, float ringRadius,
+ double ringMeters) {
+ String label = formatRangeLabel(ringMeters);
+ float tw = labelPaint.measureText(label);
+ float th = labelPaint.getTextSize();
+ float pad = dp(3);
+ float northY = cy - ringRadius - pad;
+ canvas.drawText(label, cx - tw / 2f, northY, labelPaint);
+ float southY = cy + ringRadius + th + pad;
+ canvas.drawText(label, cx - tw / 2f, southY, labelPaint);
+ float eastX = cx + ringRadius + pad;
+ canvas.drawText(label, eastX, cy + th / 3f, labelPaint);
+ float westX = cx - ringRadius - pad - tw;
+ canvas.drawText(label, westX, cy + th / 3f, labelPaint);
+ }
+
+ private String formatRangeLabel(double meters) {
+ if (SettingsManager.RANGE_UNIT_KM.equals(rangeUnit)) {
+ if (meters >= RangeMath.METERS_PER_KM) {
+ return String.format(Locale.US, "%.1f km", meters / RangeMath.METERS_PER_KM);
+ }
+ return String.format(Locale.US, "%.0f m", meters);
+ }
+ return String.format(Locale.US, "%.1f nm", meters / RangeMath.METERS_PER_NM);
+ }
+
+ private float dp(float v) {
+ return v * getResources().getDisplayMetrics().density;
+ }
+}
diff --git a/app/src/main/res/drawable/ic_radar_plotter.xml b/app/src/main/res/drawable/ic_radar_plotter.xml
new file mode 100644
index 0000000..bf250e2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_radar_plotter.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_signal_off.xml b/app/src/main/res/drawable/ic_signal_off.xml
new file mode 100644
index 0000000..dec3d6c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_signal_off.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/plotter_bezel_background.xml b/app/src/main/res/drawable/plotter_bezel_background.xml
new file mode 100644
index 0000000..fe078af
--- /dev/null
+++ b/app/src/main/res/drawable/plotter_bezel_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/plotter_panel_background.xml b/app/src/main/res/drawable/plotter_panel_background.xml
new file mode 100644
index 0000000..90d0a22
--- /dev/null
+++ b/app/src/main/res/drawable/plotter_panel_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/plotter_radar_viewport_bg.xml b/app/src/main/res/drawable/plotter_radar_viewport_bg.xml
new file mode 100644
index 0000000..a83f0de
--- /dev/null
+++ b/app/src/main/res/drawable/plotter_radar_viewport_bg.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-port/activity_radar_plotter.xml b/app/src/main/res/layout-port/activity_radar_plotter.xml
new file mode 100644
index 0000000..0ee7d60
--- /dev/null
+++ b/app/src/main/res/layout-port/activity_radar_plotter.xml
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_interfaces_settings.xml b/app/src/main/res/layout/activity_interfaces_settings.xml
index 92c0a17..dd80fff 100644
--- a/app/src/main/res/layout/activity_interfaces_settings.xml
+++ b/app/src/main/res/layout/activity_interfaces_settings.xml
@@ -1,8 +1,10 @@
+ android:text="@string/interfaces_title"
+ android:textAppearance="?attr/textAppearanceHeadlineSmall"
+ android:textColor="?attr/colorOnSurface" />
+ app:cardCornerRadius="12dp">
+ android:layout_marginBottom="12dp"
+ android:text="@string/interfaces_section_udp"
+ android:textAppearance="?attr/textAppearanceTitleMedium"
+ android:textColor="?attr/colorOnSurface" />
+ android:hint="@string/interfaces_udp_port_hint"
+ app:helperText="@string/interfaces_udp_port_helper">
+ android:checked="true"
+ android:text="@string/interfaces_udp_enabled" />
+
+ app:cardCornerRadius="12dp">
+ android:layout_marginBottom="12dp"
+ android:text="@string/interfaces_section_ble"
+ android:textAppearance="?attr/textAppearanceTitleMedium"
+ android:textColor="?attr/colorOnSurface" />
+ android:layout_marginBottom="8dp"
+ android:text="@string/interfaces_ble_enabled" />
+ android:hint="@string/interfaces_ble_mac_hint"
+ app:helperText="@string/interfaces_ble_mac_helper">
+ android:inputType="text" />
@@ -122,20 +119,20 @@
android:layout_height="wrap_content"
android:orientation="horizontal">
-
+ android:text="@string/interfaces_ble_scan" />
-
+ android:text="@string/interfaces_ble_stop" />
+
+
+
+
+
+
-
+
+ app:cardCornerRadius="12dp">
+ android:layout_marginBottom="12dp"
+ android:text="@string/interfaces_section_bridge"
+ android:textAppearance="?attr/textAppearanceTitleMedium"
+ android:textColor="?attr/colorOnSurface" />
+ android:layout_marginBottom="8dp"
+ android:text="@string/interfaces_bridge_enabled" />
+ android:hint="@string/interfaces_bridge_host_hint">
+ android:hint="@string/interfaces_bridge_port_hint">
+
+ android:paddingTop="8dp"
+ android:paddingBottom="8dp">
-
+ android:text="@string/settings_action_cancel" />
-
+ android:text="@string/settings_action_save" />
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 246a513..20fa37b 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,5 +1,6 @@
-
+
+
+
+
+
+
+
+
+ android:layout_height="wrap_content"
+ android:layout_below="@id/banner_connection_lost"
+ android:elevation="2dp" />
-
+
+
+
+
+
+
+
-
+
+ android:textSize="10sp" />
+ android:textSize="10sp" />
+ android:textSize="10sp" />
+ android:textSize="10sp" />
+ android:textSize="10sp" />
diff --git a/app/src/main/res/layout/activity_radar_plotter.xml b/app/src/main/res/layout/activity_radar_plotter.xml
new file mode 100644
index 0000000..a21018f
--- /dev/null
+++ b/app/src/main/res/layout/activity_radar_plotter.xml
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
index bac9e58..e6c97f0 100644
--- a/app/src/main/res/layout/activity_settings.xml
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -1,8 +1,10 @@
+ android:text="@string/settings_title"
+ android:textAppearance="?attr/textAppearanceHeadlineSmall"
+ android:textColor="?attr/colorOnSurface" />
+ app:cardCornerRadius="12dp">
+ android:layout_marginBottom="12dp"
+ android:text="@string/settings_section_interfaces"
+ android:textAppearance="?attr/textAppearanceTitleMedium"
+ android:textColor="?attr/colorOnSurface" />
+ app:helperText="@string/settings_open_interfaces_helper">
+ android:text="@string/settings_open_interfaces_value" />
@@ -72,13 +71,13 @@
-
+
+ app:cardCornerRadius="12dp">
+ android:layout_marginBottom="8dp"
+ android:text="@string/settings_section_gps_source"
+ android:textAppearance="?attr/textAppearanceTitleMedium"
+ android:textColor="?attr/colorOnSurface" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:hint="@string/settings_range_danger_hint"
+ app:helperText="@string/settings_range_danger_helper">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:hint="@string/settings_path_width_hint">
+ android:hint="@string/settings_path_color_hint">
+ android:hint="@string/settings_prediction_width_hint">
+ android:hint="@string/settings_prediction_color_hint">
+ android:hint="@string/settings_prediction_horizon_hint">
-
-
+ android:text="@string/settings_clear_path" />
+ android:text="@string/settings_clear_path_helper"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant" />
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ app:cardCornerRadius="12dp">
+ android:checked="false"
+ android:text="@string/settings_section_advanced_nmea" />
+ android:layout_marginStart="32dp"
+ android:layout_marginTop="4dp"
+ android:layout_marginBottom="12dp"
+ android:text="@string/settings_advanced_nmea_hint"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:layout_marginBottom="12dp"
+ android:text="@string/settings_advanced_nmea_caption"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+
+ android:layout_marginStart="32dp"
+ android:layout_marginBottom="12dp"
+ android:text="@string/settings_android_nmea_helper"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant" />
-
+ android:checked="true"
+ android:layout_marginBottom="4dp"
+ android:text="@string/settings_udp_nmea_enabled" />
-
-
+ android:layout_marginStart="32dp"
+ android:layout_marginBottom="12dp"
+ android:text="@string/settings_udp_nmea_helper"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+ android:layout_marginBottom="4dp"
+ android:text="@string/settings_data_mode"
+ android:textAppearance="?attr/textAppearanceLabelLarge"
+ android:textColor="?attr/colorOnSurface" />
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -439,13 +546,13 @@
-
+
+ app:cardCornerRadius="12dp">
+ android:layout_marginBottom="8dp"
+ android:text="@string/settings_section_stale_data"
+ android:textAppearance="?attr/textAppearanceTitleMedium"
+ android:textColor="?attr/colorOnSurface" />
+ android:layout_marginBottom="12dp"
+ android:text="@string/settings_stale_caption"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+ android:hint="@string/settings_stale_warning_hint"
+ app:helperText="@string/settings_stale_warning_helper">
+ android:hint="@string/settings_stale_remove_hint"
+ app:helperText="@string/settings_stale_remove_helper">
+ android:layout_marginTop="8dp"
+ android:text="@string/settings_stale_tip"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant" />
-
+
+ app:cardCornerRadius="12dp">
+ android:layout_marginBottom="8dp"
+ android:text="@string/settings_section_navigator_camera"
+ android:textAppearance="?attr/textAppearanceTitleMedium"
+ android:textColor="?attr/colorOnSurface" />
+ android:layout_marginBottom="12dp"
+ android:text="@string/settings_navigator_camera_hint"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_marginBottom="4dp"
+ android:text="@string/settings_keep_screen_on" />
+ android:layout_marginStart="32dp"
+ android:text="@string/settings_keep_screen_on_helper"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant" />
-
+
+ app:cardCornerRadius="12dp">
+ android:layout_marginBottom="8dp"
+ android:text="@string/settings_section_notifications"
+ android:textAppearance="?attr/textAppearanceTitleMedium"
+ android:textColor="?attr/colorOnSurface" />
+ android:layout_marginBottom="12dp"
+ android:text="@string/settings_notifications_hint"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+ android:layout_marginBottom="4dp"
+ android:text="@string/settings_vibration" />
+ android:layout_marginStart="32dp"
+ android:layout_marginBottom="12dp"
+ android:text="@string/settings_vibration_helper"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+ android:layout_marginBottom="4dp"
+ android:text="@string/settings_sound" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:layout_marginStart="32dp"
+ android:text="@string/settings_sound_helper"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant" />
@@ -684,11 +832,11 @@
+ app:cardCornerRadius="12dp">
+ android:layout_marginBottom="8dp"
+ android:text="@string/settings_section_seamarks"
+ android:textAppearance="?attr/textAppearanceTitleMedium"
+ android:textColor="?attr/colorOnSurface" />
+ android:layout_marginBottom="12dp"
+ android:text="@string/settings_seamarks_hint"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant" />
+ android:text="@string/settings_seamarks_enabled" />
+ android:layout_marginTop="8dp"
+ android:text="@string/settings_seamarks_tip"
+ android:textAppearance="?attr/textAppearanceBodySmall"
+ android:textColor="?attr/colorOnSurfaceVariant" />
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:paddingTop="8dp"
+ android:paddingBottom="8dp">
-
+ android:text="@string/settings_action_cancel" />
-
+ android:text="@string/settings_action_save" />
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 862495e..050ab88 100644
--- a/app/src/main/res/layout/bottom_sheet_ais_vessel.xml
+++ b/app/src/main/res/layout/bottom_sheet_ais_vessel.xml
@@ -20,7 +20,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:text="🚢 AIS СУДНО"
+ android:text="AIS СУДНО"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@android:color/black" />
@@ -51,7 +51,7 @@
android:id="@+id/bottom_sheet_ais_time_ago"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="⏱️ Время назад: --"
+ android:text="Время назад: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -62,7 +62,7 @@
android:id="@+id/bottom_sheet_ais_mmsi"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="🆔 MMSI: --"
+ android:text="MMSI: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -75,7 +75,7 @@
android:id="@+id/bottom_sheet_ais_callsign"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="📻 Позывной: --"
+ android:text="Позывной: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -87,7 +87,7 @@
android:id="@+id/bottom_sheet_ais_imo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="🏷️ IMO: --"
+ android:text="IMO: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -99,7 +99,7 @@
android:id="@+id/bottom_sheet_ais_type"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="🚢 Тип: --"
+ android:text="Тип: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -111,7 +111,7 @@
android:id="@+id/bottom_sheet_ais_position"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="📍 Координаты: --"
+ android:text="Координаты: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -130,7 +130,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:text="🧭 COG: --°"
+ android:text="COG: --°"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -143,7 +143,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:text="🧭 HDG: --°"
+ android:text="HDG: --°"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -156,7 +156,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:text="🔄 ROT: --°/мин"
+ android:text="ROT: --°/мин"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -170,7 +170,7 @@
android:id="@+id/bottom_sheet_ais_speed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="⚡ Скорость: -- узлов"
+ android:text="Скорость: -- узлов"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -182,7 +182,7 @@
android:id="@+id/bottom_sheet_ais_dimensions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="📏 Размеры: --"
+ android:text="Размеры: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -194,7 +194,7 @@
android:id="@+id/bottom_sheet_ais_draft"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="🌊 Осадка: -- м"
+ android:text="Осадка: -- м"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -206,7 +206,7 @@
android:id="@+id/bottom_sheet_ais_destination"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="🎯 Назначение: --"
+ android:text="Назначение: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -218,7 +218,7 @@
android:id="@+id/bottom_sheet_ais_eta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="⏰ ETA: --"
+ android:text="ETA: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -230,7 +230,7 @@
android:id="@+id/bottom_sheet_ais_nav_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="🚦 Статус: --"
+ android:text="Статус: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -242,7 +242,7 @@
android:id="@+id/bottom_sheet_ais_class"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="📋 Класс: --"
+ android:text="Класс: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -254,7 +254,7 @@
android:id="@+id/bottom_sheet_ais_signal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="📶 Сигнал: --"
+ android:text="Сигнал: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -266,7 +266,7 @@
android:id="@+id/bottom_sheet_ais_distance"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="📏 Расстояние: --"
+ android:text="Расстояние: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -278,7 +278,7 @@
android:id="@+id/bottom_sheet_ais_bearing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="🧭 Пеленг: --"
+ android:text="Пеленг: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
@@ -290,7 +290,7 @@
android:id="@+id/bottom_sheet_ais_last_update"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:text="🕐 Обновлено: --"
+ android:text="Обновлено: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index c8524cd..1c42e64 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -2,4 +2,33 @@
#FF000000#FFFFFFFF
-
\ No newline at end of file
+
+
+ #FFD32F2F
+ #22D32F2F
+ #FFFFA000
+ #1AFFA000
+ #FF1976D2
+ #001976D2
+ #FFFFC107
+
+
+ #E6B71C1C
+ #FFFFFFFF
+
+
+ #FF0A0E12
+ #FF1E2830
+ #FF4A5A66
+ #E612181C
+ #FF3D4F3A
+ #88041008
+ #5533FF66
+ #AA66FF99
+ #5533FF66
+ #FFE8F0E4
+ #FF7FA88A
+ #FFFFB74D
+ #FF66FF99
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 1af3ddf..767455d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,164 @@
AISMap
-
\ No newline at end of file
+
+
+ Настройки AIS Map
+
+ Интерфейсы
+ Интерфейсы (UDP / BLE)
+ Перейти к настройкам UDP, BLE и UDP-bridge
+ Открыть настройки интерфейсов
+ Открыть
+
+ Путь и предсказание
+ Максимум точек на судно
+ Ограничение размера истории пути
+ Толщина линии пути (px)
+ Цвет пути (#RRGGBB)
+ Толщина линии предсказания (px)
+ Цвет предсказания (#RRGGBB)
+ Горизонт предсказания (сек)
+ Очистить трекер пути
+ Удаляет все сохранённые точки пути собственного судна
+
+ Источник координат
+ Откуда приложение берёт позицию собственного судна.
+ AIS Hub (BLE)
+ Позиция и AIS-цели приходят из внешнего AIS Hub по BLE.
+ Android GPS
+ Встроенный GPS устройства (+опциональный внешний NMEA).
+
+ Зоны вокруг судна
+ Кольца дальности позволяют выделить опасные цели и ограничить зону отображения.
+ Показывать кольца на карте
+ Единицы измерения
+ Морские мили (nm)
+ Километры (км)
+ Радиус зоны опасности
+ Цели в этой зоне отображаются в виджете и считаются опасными
+ Радиус зоны предупреждения
+ Цели в этой зоне подсвечиваются на карте
+ Радиус зоны фильтра
+ Скрывает цели, расположенные дальше указанного радиуса
+ Скрывать цели за пределами зоны фильтра
+ Радиусы должны быть возрастающими: опасность < предупреждение < фильтр
+ Все радиусы должны быть положительными
+
+ Расширенные NMEA-источники
+ Открыть старые настройки Android NMEA / UDP NMEA / режимы данных. Нужны только если вы работаете без AIS Hub.
+ Выберите источники данных для получения координат и навигационной информации:
+ Android NMEA (GPS API)
+ Использовать встроенный GPS Android для получения координат
+ UDP NMEA
+ Получать NMEA данные через UDP (курс, скорость, спутники)
+ Режим работы
+ Гибридный режим (рекомендуется)
+ Координаты от Android GPS, остальное от NMEA
+ Только NMEA
+ Все данные только из NMEA сообщений
+ Только Android GPS
+ Только встроенный GPS Android
+
+ Устаревание данных AIS
+ Настройте время, через которое данные о судах считаются устаревшими:
+ Время предупреждения (минуты)
+ Суда старше этого времени будут помечены как устаревшие
+ Время удаления (минуты)
+ Суда старше этого времени будут удалены с карты
+ Устаревшие суда отображаются с иконкой потери цели
+
+ Навигаторская камера
+ Карта следует за судном; зум зависит от скорости (0 уз — ближе, макс. скорость — дальше).
+ Следовать за судном
+ Долгое нажатие на кнопку судна на карте также включает и выключает режим
+ Макс. скорость (уз)
+ При этой скорости и выше используется минимальный зум
+ Зум при 0 уз
+ Максимальное приближение (большее число)
+ Зум при макс. скорости
+ Максимальное отдаление (меньшее число)
+ Навигатор: следование за судном
+ Навигатор выключен
+ Режим навигатора
+
+ Управление экраном
+ Настройте поведение экрана во время навигации:
+ Не давать экрану засыпать
+ Экран будет оставаться включенным во время навигации (рекомендуется для навигатора)
+
+ Уведомления о новых целях AIS
+ Настройте уведомления при обнаружении новых судов:
+ Вибрация
+ Вибрация устройства при обнаружении нового судна
+ Звуковое уведомление
+ Звуковой сигнал при обнаружении нового судна
+
+ Режим отладки
+ Включает расширенное логирование и диагностические элементы UI.
+ Включить режим отладки
+
+ Морские знаки OpenSeaMap
+ Отображать морские знаки (буи, маяки, навигационные знаки) поверх карты.
+ Показывать морские знаки
+ Источник: OpenSeaMap.org — открытая база данных морских знаков
+
+ Сохранить
+ Отмена
+
+
+ Интерфейсы: UDP и BLE
+ UDP
+ UDP порт
+ Порт для прослушивания AIS данных
+ Включить UDP-слушатель
+
+ BLE
+ Включить BLE-источник NMEA
+ MAC-адрес BLE устройства
+ Например: 01:23:45:67:89:AB
+ Сканировать BLE
+ Стоп
+ Читать уровень батареи AIS Hub
+ Может вызывать запрос сопряжения на некоторых устройствах. Рекомендуется выключить, если периодически появляется системное окно «Сопряжение не выполнено».
+
+ BLE → UDP мост
+ Включить UDP-bridge (пересылать NMEA)
+ UDP host (назначение)
+ UDP port (назначение)
+
+
+ Потеряна связь с устройством
+ BLE требует сопряжения. Проверьте устройство в настройках Bluetooth.
+ Иконка предупреждения связи
+
+ Опасные цели
+ В зоне опасности нет целей
+ Цель
+ Пел.
+ Дист.
+
+
+ КУРС
+ МАГН.
+ КООРДИНАТЫ
+ СКОР.
+ ПУТЬ
+ ТОЧН.
+ нет фикса
+
+
+ Радар / картплоттер
+ Режим радара
+ Вернитесь на карту — данные AIS недоступны
+ Дальность
+ СКОР.
+ КУРС
+ Ближайшие цели
+ Цель
+ Пел.
+ Дист.
+ CPA
+ —
+ Нет целей в радиусе
+ К карте
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 7869d49..c6ed568 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -18,4 +18,12 @@
trueshortEdges
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/com/grigowashere/aismap/utils/NavigatorZoomMathTest.java b/app/src/test/java/com/grigowashere/aismap/utils/NavigatorZoomMathTest.java
new file mode 100644
index 0000000..7a85965
--- /dev/null
+++ b/app/src/test/java/com/grigowashere/aismap/utils/NavigatorZoomMathTest.java
@@ -0,0 +1,59 @@
+package com.grigowashere.aismap.utils;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+/**
+ * Unit-тесты зума навигаторской камеры от скорости.
+ */
+public class NavigatorZoomMathTest {
+
+ private static final float EPS = 1e-4f;
+
+ @Test
+ public void zeroSpeedUsesZoomAtZero() {
+ assertEquals(18f, NavigatorZoomMath.zoomForSpeed(0.0, 18f, 10f, 20f), EPS);
+ }
+
+ @Test
+ public void maxSpeedUsesZoomAtMax() {
+ assertEquals(10f, NavigatorZoomMath.zoomForSpeed(20.0, 18f, 10f, 20f), EPS);
+ assertEquals(10f, NavigatorZoomMath.zoomForSpeed(25.0, 18f, 10f, 20f), EPS);
+ }
+
+ @Test
+ public void halfSpeedInterpolatesLinearly() {
+ assertEquals(14f, NavigatorZoomMath.zoomForSpeed(10.0, 18f, 10f, 20f), EPS);
+ }
+
+ @Test
+ public void negativeSpeedTreatedAsZero() {
+ assertEquals(18f, NavigatorZoomMath.zoomForSpeed(-3.0, 18f, 10f, 20f), EPS);
+ }
+
+ @Test
+ public void invalidMaxSpeedReturnsZoomAtZero() {
+ assertEquals(16f, NavigatorZoomMath.zoomForSpeed(5.0, 16f, 8f, 0f), EPS);
+ }
+
+ @Test
+ public void clampZoomBounds() {
+ assertEquals(2f, NavigatorZoomMath.clampZoom(0f), EPS);
+ assertEquals(20f, NavigatorZoomMath.clampZoom(99f), EPS);
+ assertEquals(14f, NavigatorZoomMath.clampZoom(14f), EPS);
+ }
+
+ @Test
+ public void easeOutCubicEndpoints() {
+ assertEquals(0f, NavigatorZoomMath.easeOutCubic(0f), EPS);
+ assertEquals(1f, NavigatorZoomMath.easeOutCubic(1f), EPS);
+ }
+
+ @Test
+ public void lerpBearingShortPath() {
+ assertEquals(5f, NavigatorZoomMath.lerpBearing(0f, 10f, 0.5f), EPS);
+ assertEquals(10f, NavigatorZoomMath.lerpBearing(350f, 10f, 1f), EPS);
+ assertEquals(0f, NavigatorZoomMath.lerpBearing(350f, 10f, 0.5f), EPS);
+ }
+}
diff --git a/app/src/test/java/com/grigowashere/aismap/utils/RangeMathTest.java b/app/src/test/java/com/grigowashere/aismap/utils/RangeMathTest.java
new file mode 100644
index 0000000..d38092b
--- /dev/null
+++ b/app/src/test/java/com/grigowashere/aismap/utils/RangeMathTest.java
@@ -0,0 +1,89 @@
+package com.grigowashere.aismap.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/**
+ * Проверка чистой логики колец дальности.
+ * Тест работает без Android (только JUnit), чтобы не тащить Robolectric.
+ */
+public class RangeMathTest {
+
+ private static final double EPS = 1e-6;
+
+ @Test
+ public void testToMetersNm() {
+ assertEquals(1852.0, RangeMath.toMeters(1.0, RangeMath.UNIT_NM), EPS);
+ assertEquals(926.0, RangeMath.toMeters(0.5, RangeMath.UNIT_NM), EPS);
+ assertEquals(9260.0, RangeMath.toMeters(5.0, RangeMath.UNIT_NM), EPS);
+ }
+
+ @Test
+ public void testToMetersKm() {
+ assertEquals(1000.0, RangeMath.toMeters(1.0, RangeMath.UNIT_KM), EPS);
+ assertEquals(2500.0, RangeMath.toMeters(2.5, RangeMath.UNIT_KM), EPS);
+ }
+
+ @Test
+ public void testToMetersUnknownUnitDefaultsToNm() {
+ assertEquals(1852.0, RangeMath.toMeters(1.0, "miles"), EPS);
+ assertEquals(1852.0, RangeMath.toMeters(1.0, null), EPS);
+ }
+
+ @Test
+ public void testRingOrderValid() {
+ assertTrue(RangeMath.isValidRingOrder(0.5, 1.5, 5.0));
+ assertTrue(RangeMath.isValidRingOrder(0.1, 0.2, 0.3));
+ }
+
+ @Test
+ public void testRingOrderInvalid() {
+ // равные значения — невалидно
+ assertFalse(RangeMath.isValidRingOrder(1.0, 1.0, 5.0));
+ assertFalse(RangeMath.isValidRingOrder(1.0, 5.0, 5.0));
+ // обратный порядок — невалидно
+ assertFalse(RangeMath.isValidRingOrder(5.0, 1.5, 0.5));
+ // ноль или отрицательное — невалидно
+ assertFalse(RangeMath.isValidRingOrder(0.0, 1.0, 2.0));
+ assertFalse(RangeMath.isValidRingOrder(-1.0, 1.0, 2.0));
+ }
+
+ @Test
+ public void testFilterDisabledKeepsAllTargets() {
+ assertTrue(RangeMath.isInsideFilter(false, 100.0,
+ 55.0, 37.0, 56.0, 38.0));
+ }
+
+ @Test
+ public void testFilterIncludesNearbyTarget() {
+ // 0.001° по широте ≈ 111 м — точно ≤ 1000 м.
+ assertTrue(RangeMath.isInsideFilter(true, 1000.0,
+ 55.7558, 37.6173, 55.7568, 37.6173));
+ }
+
+ @Test
+ public void testFilterExcludesFarTarget() {
+ // ~1° по широте ≈ 111 км, очевидно > 1000 м.
+ assertFalse(RangeMath.isInsideFilter(true, 1000.0,
+ 55.0, 37.0, 56.0, 37.0));
+ }
+
+ @Test
+ public void testFilterInvalidCoordsKeepsTarget() {
+ assertTrue(RangeMath.isInsideFilter(true, 1000.0,
+ Double.NaN, Double.NaN, 55.0, 37.0));
+ assertTrue(RangeMath.isInsideFilter(true, 1000.0,
+ 55.0, 37.0, Double.NaN, 37.0));
+ }
+
+ @Test
+ public void testHaversineMeters() {
+ // Москва -> Санкт-Петербург ≈ 633 км. Допуск ±10 км.
+ double d = RangeMath.haversineMeters(55.7558, 37.6173, 59.9343, 30.3351);
+ assertTrue("Distance Moscow-SPB ~ 633km, got=" + d,
+ Math.abs(d - 633_000.0) < 10_000.0);
+ }
+}
diff --git a/ble_gatt.py b/ble_gatt.py
index 0267061..665ee6a 100644
--- a/ble_gatt.py
+++ b/ble_gatt.py
@@ -12,6 +12,8 @@ import json
import os
import queue
import random
+import re
+import subprocess
import threading
import struct
import sys
@@ -1340,7 +1342,9 @@ class DataCharacteristic(Characteristic):
class StatusCharacteristic(Characteristic):
def __init__(self, bus, index, service: Service, bridge: AisHubBridge):
- Characteristic.__init__(self, bus, index, AIS_HUB_STATUS_UUID, ["read", "notify"], service)
+ # Только read: notify не используется клиентом; лишний notify увеличивает
+ # поверхность ATT без пользы. Шифрование не требуется (без encrypt-*).
+ Characteristic.__init__(self, bus, index, AIS_HUB_STATUS_UUID, ["read"], service)
self.bridge = bridge
self.notifying = False
@@ -1417,6 +1421,74 @@ def find_adapter(bus):
return None
+def _hci_index_from_adapter_path(adapter_path: str) -> str | None:
+ m = re.search(r'hci(\d+)$', adapter_path)
+ return m.group(1) if m else None
+
+
+def configure_adapter_for_open_le(bus, adapter_path: str) -> None:
+ """
+ Снижает вероятность системного диалога pairing на Android.
+
+ Наши характеристики без encrypt-* флагов, но bluetoothd по умолчанию:
+ - шлёт SMP Security Request (bonding) при LE connect;
+ - делает reverse GATT discovery к central (читает Battery и т.п. на
+ телефоне) -> Insufficient Authentication -> Android показывает pairing.
+
+ См. bluez/bluez#851, ukBaz/python-bluezero#390.
+ """
+ props = dbus.Interface(
+ bus.get_object(BLUEZ_SERVICE_NAME, adapter_path),
+ DBUS_PROP_IFACE,
+ )
+ try:
+ props.Set('org.bluez.Adapter1', 'Pairable', dbus.Boolean(False))
+ log_info('[Adapter] Pairable=false (no incoming pairing UI from adapter)')
+ except Exception as e:
+ log_warn(f'[Adapter] Pairable=false failed: {e}')
+
+ # btmgmt: bondable off + NoInputNoOutput — peripheral не инициирует bonding.
+ hci = _hci_index_from_adapter_path(adapter_path)
+ if hci is None:
+ log_warn('[Adapter] cannot parse hci index from ' + adapter_path)
+ return
+ for args in (
+ ['bondable', 'off'],
+ ['io-cap', '3'], # NoInputNoOutput
+ ):
+ cmd = ['btmgmt', '-i', hci] + args
+ try:
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
+ if r.returncode == 0:
+ log_info(f'[Adapter] btmgmt {" ".join(args)}: ok')
+ else:
+ err = (r.stderr or r.stdout or '').strip()
+ log_warn(f'[Adapter] btmgmt {" ".join(args)} failed ({r.returncode}): {err}')
+ except FileNotFoundError:
+ log_warn('[Adapter] btmgmt not found; set ReverseServiceDiscovery=false in /etc/bluetooth/main.conf')
+ break
+ except Exception as e:
+ log_warn(f'[Adapter] btmgmt {" ".join(args)}: {e}')
+
+ log_info(
+ '[Adapter] Рекомендуется в /etc/bluetooth/main.conf: '
+ 'ReverseServiceDiscovery=false и [GATT] Client=false (перезапуск bluetoothd)'
+ )
+
+
+def trust_connected_device(bus, device_path: str) -> None:
+ """Помечает central как Trusted — на части стеков убирает повторный pairing prompt."""
+ if not device_path or device_path == 'unknown':
+ return
+ try:
+ dev = bus.get_object(BLUEZ_SERVICE_NAME, device_path)
+ props = dbus.Interface(dev, DBUS_PROP_IFACE)
+ props.Set('org.bluez.Device1', 'Trusted', dbus.Boolean(True))
+ log_debug(f'[BlueZ] Device Trusted=true: {device_path}')
+ except Exception as e:
+ log_debug(f'[BlueZ] Trusted=true failed for {device_path}: {e}')
+
+
# ============ main() ============
def main():
@@ -1432,6 +1504,7 @@ def main():
return 1
log_info(f'Используем адаптер: {adapter_path}')
+ configure_adapter_for_open_le(bus, adapter_path)
service_manager = dbus.Interface(
bus.get_object(BLUEZ_SERVICE_NAME, adapter_path),
@@ -1455,11 +1528,12 @@ def main():
if "Connected" not in changed:
return
connected = bool(changed.get("Connected"))
- dev_path = str(path or "")
+ dev_path = "" if path is None else str(path)
if not dev_path:
return
if connected:
log_info(f"[BlueZ] Device connected: {dev_path} sessions={bridge.session_count()}")
+ trust_connected_device(bus, dev_path)
bridge.get_or_create_session(dev_path)
else:
log_info(f"[BlueZ] Device disconnected: {dev_path} -> removing session")