2 Commits
v3 ... v5

Author SHA1 Message Date
Grigo b22cdd93eb Daily checkup 2026-05-20 08:49:14 +03:00
Grigo 2fbeae26a6 closd TG-6; Initial push after server migration 2026-05-04 10:01:25 +03:00
46 changed files with 5478 additions and 680 deletions
Binary file not shown.
+7
View File
@@ -82,6 +82,13 @@
android:configChanges="orientation|screenSize|keyboardHidden" android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/Theme.AISMap" /> android:theme="@style/Theme.AISMap" />
<activity
android:name=".RadarPlotterActivity"
android:exported="false"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/Theme.AISMap.RadarPlotter"
android:keepScreenOn="true" />
<!-- Foreground Service для фоновых обновлений AIS/GPS --> <!-- Foreground Service для фоновых обновлений AIS/GPS -->
<service <service
android:name=".services.AISForegroundService" android:name=".services.AISForegroundService"
@@ -16,6 +16,8 @@ import androidx.recyclerview.widget.RecyclerView;
import com.grigowashere.aismap.data.Repository; import com.grigowashere.aismap.data.Repository;
import com.grigowashere.aismap.data.entity.AISVesselEntity; import com.grigowashere.aismap.data.entity.AISVesselEntity;
import com.grigowashere.aismap.data.entity.VesselEntity; import com.grigowashere.aismap.data.entity.VesselEntity;
import com.grigowashere.aismap.utils.GeoUtils;
import com.grigowashere.aismap.utils.SettingsManager;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -23,6 +25,7 @@ import java.util.List;
public class AisTargetsActivity extends AppCompatActivity implements AisTargetsAdapter.OnItemClickListener { public class AisTargetsActivity extends AppCompatActivity implements AisTargetsAdapter.OnItemClickListener {
private Repository repository; private Repository repository;
private SettingsManager settingsManager;
private RecyclerView recyclerView; private RecyclerView recyclerView;
private AisTargetsAdapter adapter; private AisTargetsAdapter adapter;
private android.os.Handler tickerHandler; private android.os.Handler tickerHandler;
@@ -44,6 +47,7 @@ public class AisTargetsActivity extends AppCompatActivity implements AisTargetsA
setContentView(R.layout.activity_ais_targets); setContentView(R.layout.activity_ais_targets);
repository = new Repository(this); repository = new Repository(this);
settingsManager = new SettingsManager(this);
// Загружаем данные нашего корабля // Загружаем данные нашего корабля
loadOurVesselData(); loadOurVesselData();
@@ -118,6 +122,28 @@ public class AisTargetsActivity extends AppCompatActivity implements AisTargetsA
} }
} }
} }
// Дистанционный фильтр (range_filter): отбрасываем цели за пределами
// настроенного радиуса, если фильтр включён и известна позиция.
if (settingsManager != null && settingsManager.isRangeFilterEnabled()
&& GeoUtils.isValidCoordinates(ourLatitude, ourLongitude)) {
double maxDistanceM = settingsManager.getFilterRadiusMeters();
if (maxDistanceM > 0.0) {
java.util.List<AISVesselEntity> 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); adapter.submitList(filtered);
int targetCount = filtered.size(); int targetCount = filtered.size();
textTargetCount.setText("AIS цели: " + targetCount); textTargetCount.setText("AIS цели: " + targetCount);
@@ -60,7 +60,15 @@ import com.grigowashere.aismap.controllers.DefaultControllersFactory;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
/** Живой координатор для вторичных экранов (радар), пока MainActivity в стеке. */
private static volatile AppCoordinator sAppCoordinator;
private static final String TAG = "MainActivity"; 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 PERMISSION_REQUEST_CODE = 1001;
private static final int SETTINGS_REQUEST_CODE = 1002; private static final int SETTINGS_REQUEST_CODE = 1002;
private static final int NOTIFICATION_PERMISSION_REQUEST_CODE = 1003; private static final int NOTIFICATION_PERMISSION_REQUEST_CODE = 1003;
@@ -83,14 +91,19 @@ public class MainActivity extends AppCompatActivity {
private SettingsManager settingsManager; private SettingsManager settingsManager;
private ImageButton btnCenterOnVessel; private ImageButton btnCenterOnVessel;
private ImageButton btnNavigatorFollow;
private ImageButton btnMapOrientation; private ImageButton btnMapOrientation;
private ImageButton btnCursorToggle; private ImageButton btnCursorToggle;
private ImageButton btnSettings; private ImageButton btnSettings;
private ImageButton btnAisTargets; private ImageButton btnAisTargets;
private ImageButton btnRadarPlotter;
private ImageButton btnGpsSource; private ImageButton btnGpsSource;
private LinearLayout controlPanel; private LinearLayout controlPanel;
private CompassView compassView; private CompassView compassView;
private CoordinatesDockWidget coordinatesWidget; private CoordinatesDockWidget coordinatesWidget;
private com.grigowashere.aismap.view.DangerTargetsDockWidget dangerWidget;
private android.widget.LinearLayout bannerConnectionLost;
private TextView tvBannerConnectionLost;
// Троттлинг для UI обновлений // Троттлинг для UI обновлений
private android.os.Handler uiThrottleHandler; private android.os.Handler uiThrottleHandler;
@@ -214,14 +227,19 @@ public class MainActivity extends AppCompatActivity {
private void initializeViews() { private void initializeViews() {
mapView = findViewById(R.id.map_view); mapView = findViewById(R.id.map_view);
btnCenterOnVessel = findViewById(R.id.btn_center_vessel); btnCenterOnVessel = findViewById(R.id.btn_center_vessel);
btnNavigatorFollow = findViewById(R.id.btn_navigator_follow);
btnMapOrientation = findViewById(R.id.btn_map_orientation); btnMapOrientation = findViewById(R.id.btn_map_orientation);
btnCursorToggle = findViewById(R.id.btn_cursor_toggle); btnCursorToggle = findViewById(R.id.btn_cursor_toggle);
btnSettings = findViewById(R.id.btn_settings); btnSettings = findViewById(R.id.btn_settings);
btnAisTargets = findViewById(R.id.btn_ais_targets); btnAisTargets = findViewById(R.id.btn_ais_targets);
btnRadarPlotter = findViewById(R.id.btn_radar_plotter);
btnGpsSource = findViewById(R.id.btn_gps_source); btnGpsSource = findViewById(R.id.btn_gps_source);
controlPanel = findViewById(R.id.control_panel); controlPanel = findViewById(R.id.control_panel);
compassView = findViewById(R.id.compass_view); compassView = findViewById(R.id.compass_view);
coordinatesWidget = findViewById(R.id.coordinates_widget); 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(); installMainUiInsets();
// Инициализируем троттлинг // Инициализируем троттлинг
@@ -292,7 +310,12 @@ public class MainActivity extends AppCompatActivity {
} }
private void setupButtonListeners() { 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) { if (btnMapOrientation != null) {
btnMapOrientation.setOnClickListener(v -> cycleMapRotationMode()); btnMapOrientation.setOnClickListener(v -> cycleMapRotationMode());
} }
@@ -300,6 +323,7 @@ public class MainActivity extends AppCompatActivity {
if (btnCursorToggle != null) btnCursorToggle.setOnLongClickListener(v -> { toggleCursor(); return true; }); if (btnCursorToggle != null) btnCursorToggle.setOnLongClickListener(v -> { toggleCursor(); return true; });
if (btnSettings != null) btnSettings.setOnClickListener(v -> showSettings()); if (btnSettings != null) btnSettings.setOnClickListener(v -> showSettings());
if (btnAisTargets != null) btnAisTargets.setOnClickListener(v -> openAisTargets()); if (btnAisTargets != null) btnAisTargets.setOnClickListener(v -> openAisTargets());
if (btnRadarPlotter != null) btnRadarPlotter.setOnClickListener(v -> openRadarPlotter());
if (btnGpsSource != null) { if (btnGpsSource != null) {
refreshGpsSourceButtonIcon(); refreshGpsSourceButtonIcon();
btnGpsSource.setOnClickListener(v -> toggleGpsSource()); btnGpsSource.setOnClickListener(v -> toggleGpsSource());
@@ -316,13 +340,16 @@ public class MainActivity extends AppCompatActivity {
// Устанавливаем начальный азимут (например, север) // Устанавливаем начальный азимут (например, север)
compassView.setAzimuth(0); 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.post(() -> {
compassView.setDocked(true, true, 0, 0); compassView.invalidate();
compassView.invalidate(); // Принудительная отрисовка
// Выровнять паддинги под статус-бар/вырез камеры сразу после
// первого dock-позиционирования (до этого сторона неизвестна).
reapplyInsetsToDocks(); reapplyInsetsToDocks();
updateControlPanelPosition();
}); });
// Настраиваем слушатель изменения размера док-виджета // Настраиваем слушатель изменения размера док-виджета
@@ -441,15 +468,21 @@ public class MainActivity extends AppCompatActivity {
reapplyInsetsToDocks(); 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(() -> { coordinatesWidget.post(() -> {
Log.d(TAG, "Setting coordinates widget to dock mode"); coordinatesWidget.invalidate();
coordinatesWidget.setDocked(true, false, 0, 0); // false = dock снизу
coordinatesWidget.invalidate(); // Принудительная отрисовка
// Только сейчас мы знаем сторону дока (bottom) — переприменяем
// инсеты, чтобы виджет получил bottom padding под нав-бар
// сразу, а не только после первого ресайза пользователем.
reapplyInsetsToDocks(); reapplyInsetsToDocks();
updateControlPanelPosition();
}); });
} }
@@ -485,6 +518,8 @@ public class MainActivity extends AppCompatActivity {
Integer batt = appCoordinator.getLastBleBattery(); Integer batt = appCoordinator.getLastBleBattery();
tvBleBatt.setText(batt != null ? ("BLE Batt: " + batt + "%") : "BLE Batt: --"); tvBleBatt.setText(batt != null ? ("BLE Batt: " + batt + "%") : "BLE Batt: --");
} }
updateBleLinkLostBanner();
updateDangerWidget();
} }
} catch (Exception ignored) {} } catch (Exception ignored) {}
messageAgeHandler.postDelayed(this, 1000); messageAgeHandler.postDelayed(this, 1000);
@@ -494,6 +529,86 @@ public class MainActivity extends AppCompatActivity {
messageAgeHandler.postDelayed(messageAgeRunnable, 1000); 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<com.grigowashere.aismap.view.DangerTargetsDockWidget.DangerEntry> uiEntries =
new java.util.ArrayList<>();
if (enabled && dangerR > 0.0) {
java.util.List<com.grigowashere.aismap.controllers.AppCoordinator.DangerEntry> 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) { private int getAgeColor(int seconds) {
if (seconds < 0) { if (seconds < 0) {
// Нет данных // Нет данных
@@ -842,6 +957,7 @@ public class MainActivity extends AppCompatActivity {
// Инициализация главного координатора // Инициализация главного координатора
ControllersFactory controllersFactory = new DefaultControllersFactory(); ControllersFactory controllersFactory = new DefaultControllersFactory();
appCoordinator = controllersFactory.createAppCoordinator(this); appCoordinator = controllersFactory.createAppCoordinator(this);
sAppCoordinator = appCoordinator;
// Init UI binders // Init UI binders
menuBinder = new MenuBinder(appCoordinator, settingsManager, new MenuBinder.MenuActions() { menuBinder = new MenuBinder(appCoordinator, settingsManager, new MenuBinder.MenuActions() {
@@ -910,6 +1026,7 @@ public class MainActivity extends AppCompatActivity {
} }
}); });
refreshMapRotationButtonDescription(); refreshMapRotationButtonDescription();
refreshNavigatorButtonState();
} }
private void startControllers() { private void startControllers() {
@@ -1007,8 +1124,31 @@ public class MainActivity extends AppCompatActivity {
private void centerOnVessel() { private void centerOnVessel() {
appCoordinator.centerOnOwnVessel(); appCoordinator.centerOnOwnVessel();
if (!appCoordinator.isNavigatorCameraEnabled()) {
Toast.makeText(this, "Карта центрирована на судне", Toast.LENGTH_SHORT).show(); 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) { private static float normalizeBearingTo360(double deg) {
double x = deg % 360.0; double x = deg % 360.0;
@@ -1085,6 +1225,10 @@ public class MainActivity extends AppCompatActivity {
private void applyAutoMapBearingIfNeeded(MapInterface map) { private void applyAutoMapBearingIfNeeded(MapInterface map) {
if (settingsManager == null || appCoordinator == null || map == null) return; if (settingsManager == null || appCoordinator == null || map == null) return;
// В режиме навигатора bearing сглаживает NavigatorCameraController.
if (appCoordinator.isNavigatorCameraEnabled()) {
return;
}
String mode = settingsManager.getMapRotationMode(); String mode = settingsManager.getMapRotationMode();
if (SettingsManager.MAP_ROTATION_MANUAL.equals(mode)) { if (SettingsManager.MAP_ROTATION_MANUAL.equals(mode)) {
return; return;
@@ -1173,6 +1317,10 @@ public class MainActivity extends AppCompatActivity {
startActivity(intent); startActivity(intent);
} }
private void openRadarPlotter() {
startActivity(new Intent(this, RadarPlotterActivity.class));
}
/** /**
* Переключает источник координат между BLE Hub и Android GPS «на лету», * Переключает источник координат между BLE Hub и Android GPS «на лету»,
* обновляет иконку кнопки и уведомляет AppCoordinator. * обновляет иконку кнопки и уведомляет AppCoordinator.
@@ -1239,9 +1387,9 @@ public class MainActivity extends AppCompatActivity {
android.widget.RelativeLayout.LayoutParams lp = android.widget.RelativeLayout.LayoutParams lp =
(android.widget.RelativeLayout.LayoutParams) rawLp; (android.widget.RelativeLayout.LayoutParams) rawLp;
int dp8 = Math.round(getResources().getDisplayMetrics().density * 8); 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 coordsH = coordinatesWidget != null ? coordinatesWidget.getHeight() : 0;
int newTop = compassH + dp8; int newTop = compassBottom + dp8;
int newBottom = coordsH + dp8; int newBottom = coordsH + dp8;
if (lp.topMargin != newTop || lp.bottomMargin != newBottom) { if (lp.topMargin != newTop || lp.bottomMargin != newBottom) {
lp.topMargin = newTop; lp.topMargin = newTop;
@@ -1261,16 +1409,29 @@ public class MainActivity extends AppCompatActivity {
* Боковые паддинги даём всегда (landscape-камеры). * Боковые паддинги даём всегда (landscape-камеры).
*/ */
private void applyInsetsToDocks(Insets sys) { 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) { if (compassView != null) {
boolean top = compassView.isDockTop(); boolean top = compassView.isDockTop();
compassView.setPadding(sys.left, top ? sys.top : 0, // Верхний inset на компасе только когда баннера нет — иначе отступ уже в баннере.
sys.right, top ? 0 : sys.bottom); int topPad = top && !bannerVisible ? sys.top : 0;
compassView.setPadding(sys.left, topPad, sys.right, top ? 0 : sys.bottom);
} }
if (coordinatesWidget != null) { if (coordinatesWidget != null) {
boolean top = coordinatesWidget.isDockTop(); boolean top = coordinatesWidget.isDockTop();
coordinatesWidget.setPadding(sys.left, top ? sys.top : 0, coordinatesWidget.setPadding(sys.left, top ? sys.top : 0,
sys.right, top ? 0 : sys.bottom); 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) { if (mapInterface != null) {
// Сначала создаем UI Coordinator // Сначала создаем UI Coordinator
uiCoordinator = new UIRenderingCoordinator(mapInterface); uiCoordinator = new UIRenderingCoordinator(mapInterface);
uiCoordinator.setSettingsManager(getApplicationContext(), settingsManager);
Log.i(TAG, "UIRenderingCoordinator создан"); Log.i(TAG, "UIRenderingCoordinator создан");
// Подписываем UIRenderingCoordinator на изменения MapInterface // Подписываем UIRenderingCoordinator на изменения MapInterface
@@ -1439,6 +1601,8 @@ public class MainActivity extends AppCompatActivity {
appCoordinator.setUIDataChangeNotifier(uiCoordinator); appCoordinator.setUIDataChangeNotifier(uiCoordinator);
Log.i(TAG, "UIDataChangeNotifier установлен в AppCoordinator"); Log.i(TAG, "UIDataChangeNotifier установлен в AppCoordinator");
appCoordinator.onMapInterfaceReady(mapInterface);
// AppCoordinator уже подключен к MapController при инициализации // AppCoordinator уже подключен к MapController при инициализации
// setMapInterface больше не нужен, так как стратегия карты централизована // setMapInterface больше не нужен, так как стратегия карты централизована
Log.i(TAG, "AppCoordinator подключен к MapController"); Log.i(TAG, "AppCoordinator подключен к MapController");
@@ -1675,6 +1839,9 @@ public class MainActivity extends AppCompatActivity {
@Override @Override
protected void onDestroy() { protected void onDestroy() {
super.onDestroy(); super.onDestroy();
if (isFinishing()) {
sAppCoordinator = null;
}
// MapLibre lifecycle // MapLibre lifecycle
if (mapView != null) { if (mapView != null) {
@@ -1832,6 +1999,10 @@ public class MainActivity extends AppCompatActivity {
applySettings(); applySettings();
} }
refreshGpsSourceButtonIcon(); refreshGpsSourceButtonIcon();
refreshNavigatorButtonState();
if (appCoordinator != null) {
appCoordinator.setNavigatorCameraEnabled(settingsManager.isNavigatorCameraEnabled());
}
Toast.makeText(this, "Настройки применены", Toast.LENGTH_SHORT).show(); Toast.makeText(this, "Настройки применены", Toast.LENGTH_SHORT).show();
} }
@@ -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<AppCoordinator.DangerEntry> 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();
}
}
@@ -16,6 +16,7 @@ import com.google.android.material.switchmaterial.SwitchMaterial;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import com.grigowashere.aismap.utils.SettingsManager; 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 com.google.android.material.textfield.TextInputLayout tilOpenInterfaces;
private EditText etOpenInterfaces; 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 // Path/prediction
private EditText etPathMaxPoints; private EditText etPathMaxPoints;
private EditText etPathWidth; private EditText etPathWidth;
@@ -79,6 +96,7 @@ public class SettingsActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_settings); setContentView(R.layout.activity_settings);
applySettingsInsets();
// Инициализируем менеджер настроек // Инициализируем менеджер настроек
settingsManager = new SettingsManager(this); settingsManager = new SettingsManager(this);
@@ -98,6 +116,14 @@ public class SettingsActivity extends AppCompatActivity {
Log.i(TAG, "SettingsActivity создан"); 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 элементы * Инициализирует UI элементы
*/ */
@@ -136,6 +162,23 @@ public class SettingsActivity extends AppCompatActivity {
etPredictionWidth = findViewById(R.id.et_prediction_width); etPredictionWidth = findViewById(R.id.et_prediction_width);
etPredictionColor = findViewById(R.id.et_prediction_color); etPredictionColor = findViewById(R.id.et_prediction_color);
etPredictionHorizon = findViewById(R.id.et_prediction_horizon_sec); 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
} }
/** /**
@@ -196,6 +239,37 @@ public class SettingsActivity extends AppCompatActivity {
etPredictionColor.setText(String.format("#%06X", (0xFFFFFF & settingsManager.getPredictionColor()))); etPredictionColor.setText(String.format("#%06X", (0xFFFFFF & settingsManager.getPredictionColor())));
etPredictionHorizon.setText(String.valueOf(settingsManager.getPredictionHorizonSec())); 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"); Log.i(TAG, "Настройки загружены в UI");
} }
@@ -398,6 +472,15 @@ public class SettingsActivity extends AppCompatActivity {
try { settingsManager.setPredictionColor(parseColor(etPredictionColor.getText().toString().trim(), settingsManager.getPredictionColor())); } 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) {} try { settingsManager.setPredictionHorizonSec(Integer.parseInt(etPredictionHorizon.getText().toString().trim())); } catch (Exception ignored) {}
// Кольца дальности
if (!saveRangeRingsSettings()) {
return;
}
if (!saveNavigatorCameraSettings()) {
return;
}
Log.i(TAG, "Настройки сохранены: " + settingsManager.getSettingsSummary()); Log.i(TAG, "Настройки сохранены: " + settingsManager.getSettingsSummary());
// Проверяем, нужно ли уведомить MainActivity об изменениях // Проверяем, нужно ли уведомить MainActivity об изменениях
@@ -525,6 +608,100 @@ public class SettingsActivity extends AppCompatActivity {
settingsManager.shouldRestartNMEA(originalAndroidNMEAEnabled, originalUDPNMEAEnabled, originalDataMode); settingsManager.shouldRestartNMEA(originalAndroidNMEAEnabled, originalUDPNMEAEnabled, originalDataMode);
} }
/**
* Сохраняет настройки колец дальности с валидацией danger &lt; warning &lt; 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;
}
}
/** /**
* Очищает трекер пути собственного судна * Очищает трекер пути собственного судна
*/ */
@@ -104,6 +104,11 @@ public class AisHubGattClient {
private static final long SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS = 300_000L; private static final long SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS = 300_000L;
private static final long SNAPSHOT_RECOVERY_IDLE_MS = 10_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) { public AisHubGattClient(@NonNull Context context) {
this.appContext = context.getApplicationContext(); this.appContext = context.getApplicationContext();
BluetoothManager bm = (BluetoothManager) appContext.getSystemService(Context.BLUETOOTH_SERVICE); BluetoothManager bm = (BluetoothManager) appContext.getSystemService(Context.BLUETOOTH_SERVICE);
@@ -118,10 +123,32 @@ public class AisHubGattClient {
this.deviceMac = mac; 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() { public boolean isRunning() {
return running.get(); return running.get();
} }
/** {@code true}, если GATT-сессия в состоянии {@link BluetoothProfile#STATE_CONNECTED}. */
public boolean isConnected() {
return connected;
}
public void start() { public void start() {
if (running.get()) { if (running.get()) {
Log.w(TAG, "AIS Hub GATT already running"); Log.w(TAG, "AIS Hub GATT already running");
@@ -175,7 +202,12 @@ public class AisHubGattClient {
public void onConnectionStateChange(BluetoothGatt g, int status, int newState) { public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
if (!running.get()) return; if (!running.get()) return;
if (BLE_LOG) Log.d(TAG, "onConnectionStateChange: status=" + status + " newState=" + newState); 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); postError("BLE connect status: " + status);
} else if (status == 133) { } else if (status == 133) {
lastErrorWasDbFull = true; lastErrorWasDbFull = true;
@@ -197,6 +229,7 @@ public class AisHubGattClient {
connectionStartTimeMs = 0L; connectionStartTimeMs = 0L;
isConnecting.set(false); isConnecting.set(false);
lastErrorWasDbFull = false; lastErrorWasDbFull = false;
authWarningLogged = false;
reconnectLoop.set(false); reconnectLoop.set(false);
notifReady.set(false); notifReady.set(false);
mtuRequested.set(false); mtuRequested.set(false);
@@ -314,13 +347,24 @@ public class AisHubGattClient {
public void onDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor descriptor, int st) { public void onDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor descriptor, int st) {
gattBusy.set(false); gattBusy.set(false);
if (BLE_LOG) Log.d(TAG, "onDescriptorWrite: uuid=" + descriptor.getUuid() + " status=" + st); 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())) { if (st == BluetoothGatt.GATT_SUCCESS && CCCD.equals(descriptor.getUuid())) {
notifReady.set(true); notifReady.set(true);
lastDataAtMs = System.currentTimeMillis(); lastDataAtMs = System.currentTimeMillis();
postState("notifying"); postState("notifying");
if (batteryReadEnabled) {
try { resolveBatteryAndSchedule(g); } catch (Throwable ignore) {} try { resolveBatteryAndSchedule(g); } catch (Throwable ignore) {}
readBatteryOnce(g); readBatteryOnce(g);
startBatteryLoop(g); startBatteryLoop(g);
} else if (BLE_LOG) {
Log.d(TAG, "Battery read disabled by settings (skipping resolve/read/loop)");
}
enqueueControlJson(buildHello()); enqueueControlJson(buildHello());
// Snapshot триггерим ТОЛЬКО на HELLO_ACK (см. processDataRaw), // Snapshot триггерим ТОЛЬКО на HELLO_ACK (см. processDataRaw),
@@ -358,6 +402,17 @@ public class AisHubGattClient {
@Override @Override
public void onCharacteristicRead(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) { 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 (status == BluetoothGatt.GATT_SUCCESS) {
if (BATTERY_LEVEL.equals(ch.getUuid()) || (toShort(ch.getUuid()) != null && toShort(ch.getUuid()) == 0x2A19)) { if (BATTERY_LEVEL.equals(ch.getUuid()) || (toShort(ch.getUuid()) != null && toShort(ch.getUuid()) == 0x2A19)) {
byte[] v = ch.getValue(); byte[] v = ch.getValue();
@@ -371,6 +426,13 @@ public class AisHubGattClient {
@Override @Override
public void onCharacteristicWrite(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) { 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())) { if (controlChar != null && ch.getUuid().equals(controlChar.getUuid())) {
gattBusy.set(false); gattBusy.set(false);
if (status != BluetoothGatt.GATT_SUCCESS) { if (status != BluetoothGatt.GATT_SUCCESS) {
@@ -778,6 +840,31 @@ public class AisHubGattClient {
}, 2000, 10_000, TimeUnit.MILLISECONDS); }, 2000, 10_000, TimeUnit.MILLISECONDS);
} }
/**
* Возвращает {@code true} для GATT-статусов, требующих сопряжения:
* <ul>
* <li>{@code 5} — {@code GATT_INSUF_AUTHENTICATION}</li>
* <li>{@code 8} — {@code GATT_INSUF_ENCRYPTION}</li>
* <li>{@code 137} — {@code GATT_AUTH_FAIL}</li>
* </ul>
*/
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) { private void postState(String s) {
if (callback != null) { if (callback != null) {
mainHandler.post(() -> callback.onState(s)); mainHandler.post(() -> callback.onState(s));
@@ -48,6 +48,7 @@ public class AppCoordinator implements
private NotificationController notificationController; private NotificationController notificationController;
private CompassController compassController; private CompassController compassController;
private MapController mapController; private MapController mapController;
private NavigatorCameraController navigatorCameraController;
private AisHubGattClient aisHubGattClient; private AisHubGattClient aisHubGattClient;
// Состояние приложения // Состояние приложения
@@ -108,6 +109,7 @@ public class AppCoordinator implements
this.aisPathControllers = new HashMap<>(); this.aisPathControllers = new HashMap<>();
this.settingsManager = new SettingsManager(context); this.settingsManager = new SettingsManager(context);
this.pathController = new VesselPathController(context, settingsManager); this.pathController = new VesselPathController(context, settingsManager);
this.navigatorCameraController = new NavigatorCameraController(settingsManager);
this.uiHandler = new Handler(Looper.getMainLooper()); this.uiHandler = new Handler(Looper.getMainLooper());
initializeControllers(); initializeControllers();
@@ -193,6 +195,39 @@ public class AppCoordinator implements
if (mapController != null) { if (mapController != null) {
mapController.addMapInterfaceChangeListener(this); mapController.addMapInterfaceChangeListener(this);
Log.i(TAG, "AppCoordinator подключен к MapController"); 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(); networkController.startUDPListener();
compassController.startCompass(); compassController.startCompass();
dataController.startDatabaseCleanup(); dataController.startDatabaseCleanup();
// BLE старт по настройкам // BLE: применяем настройки (MAC + batteryRead) до старта клиента,
// иначе клиент стартует «голым» и любые опциональные операции
// (например, чтение Battery 0x2A19) могут спровоцировать системный
// диалог сопряжения. Конфигурация ставит batteryReadEnabled=false
// по умолчанию это убирает основной триггер pairing.
configureBleFromSettings();
tryStartBleIfEnabled(); tryStartBleIfEnabled();
// Восстанавливаем данные из БД // Восстанавливаем данные из БД
@@ -266,7 +306,9 @@ public class AppCoordinator implements
if (aisHubGattClient == null) return; if (aisHubGattClient == null) return;
String mac = settingsManager.getBLEDeviceMac(); String mac = settingsManager.getBLEDeviceMac();
aisHubGattClient.setDeviceMac(mac); 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() { private void tryStartBleIfEnabled() {
@@ -476,16 +518,56 @@ public class AppCoordinator implements
return; return;
} }
final List<AISVessel> copy = new ArrayList<>(vessels); final List<AISVessel> 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<AISVessel> inRange;
final List<String> 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 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(() -> { uiHandler.post(() -> {
if (uiDataNotifier == null) return; if (uiDataNotifier == null) return;
for (int i = from; i < to; i++) { 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<String> mmsis) { private void publishAisRemovalsToUiBatched(List<String> mmsis) {
@@ -559,6 +641,8 @@ public class AppCoordinator implements
" mode=" + settingsManager.getDataMode()); " mode=" + settingsManager.getDataMode());
} }
updateNavigatorCamera();
// Важно: ownship.update может приходить очень часто (десятки раз в секунду). // Важно: ownship.update может приходить очень часто (десятки раз в секунду).
// Модель обновляем всегда, а тяжёлые операции (путь/БД/UI) с throttling, // Модель обновляем всегда, а тяжёлые операции (путь/БД/UI) с throttling,
// чтобы не забивать главный поток и не провоцировать нестабильность BLE. // чтобы не забивать главный поток и не провоцировать нестабильность BLE.
@@ -674,6 +758,7 @@ public class AppCoordinator implements
ownVessel.setActiveSatellites(vessel.getActiveSatellites()); ownVessel.setActiveSatellites(vessel.getActiveSatellites());
markRecentGpsActivity(); markRecentGpsActivity();
updateNavigatorCamera();
if (pathController != null && isValidCoordinates(ownVessel.getLatitude(), ownVessel.getLongitude())) { if (pathController != null && isValidCoordinates(ownVessel.getLatitude(), ownVessel.getLongitude())) {
pathController.addPathPoint( pathController.addPathPoint(
ownVessel.getLongitude(), ownVessel.getLongitude(),
@@ -729,6 +814,7 @@ public class AppCoordinator implements
// Обновляем компас после изменения курса // Обновляем компас после изменения курса
updateCompass(); updateCompass();
updateNavigatorCamera();
// Сохраняем в БД // Сохраняем в БД
dataController.saveVesselPosition(ownVessel); dataController.saveVesselPosition(ownVessel);
@@ -1055,7 +1141,8 @@ public class AppCoordinator implements
// Устанавливаем MarkerClickListener на новую карту // Устанавливаем MarkerClickListener на новую карту
newMapInterface.setMarkerClickListener(this); newMapInterface.setMarkerClickListener(this);
Log.i(TAG, "MarkerClickListener установлен на новую карту"); Log.i(TAG, "MarkerClickListener установлен на новую карту");
syncNavigatorMapInterface();
updateNavigatorCamera();
// Восстанавливаем состояние на новой карте // Восстанавливаем состояние на новой карте
restoreMapStateOnNewInterface(); restoreMapStateOnNewInterface();
} }
@@ -1168,6 +1255,65 @@ public class AppCoordinator implements
return nearby; 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<DangerEntry> getDangerTargets(double maxRadiusMeters, int limit) {
List<DangerEntry> 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() { private void updateCompass() {
if (listener != null) { if (listener != null) {
float azimuth = (float) ownVessel.getCourse(); float azimuth = (float) ownVessel.getCourse();
@@ -1348,20 +1494,51 @@ public class AppCoordinator implements
return lastBleBattery; 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() { public void centerOnOwnVessel() {
if (ownVessel != null) { if (ownVessel == null) return;
Log.d(TAG, "Запрос центрирования карты на судне: " + ownVessel.getLatitude() + "," + ownVessel.getLongitude()); Log.d(TAG, "Запрос центрирования карты на судне: " + ownVessel.getLatitude() + "," + ownVessel.getLongitude());
// Уведомляем UI Coordinator о необходимости центрирования карты 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) { if (uiDataNotifier != null) {
uiDataNotifier.onRequestCenterMap(ownVessel.getLatitude(), ownVessel.getLongitude()); uiDataNotifier.onRequestCenterMap(ownVessel.getLatitude(), ownVessel.getLongitude());
} else { } else {
Log.w(TAG, "uiDataNotifier не установлен, центрирование карты пропущено"); 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);
} }
/** /**
@@ -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);
}
}
@@ -2,7 +2,11 @@ package com.grigowashere.aismap.maps;
import android.content.Context; import android.content.Context;
import android.graphics.Color; 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.Vessel;
import com.grigowashere.aismap.models.AISVessel; import com.grigowashere.aismap.models.AISVessel;
import com.grigowashere.aismap.view.CursorOverlay; import com.grigowashere.aismap.view.CursorOverlay;
@@ -33,6 +37,8 @@ public class MapForgeImpl implements MapInterface {
private Marker ownVesselMarker; private Marker ownVesselMarker;
private CursorOverlay cursorOverlay; private CursorOverlay cursorOverlay;
private Vessel ownVessel; private Vessel ownVessel;
private final Handler uiHandler = new Handler(Looper.getMainLooper());
private MapUserInteractionListener mapUserInteractionListener;
public MapForgeImpl(Context context, MapView mapView) { public MapForgeImpl(Context context, MapView mapView) {
this.context = context; this.context = context;
@@ -166,6 +172,53 @@ public class MapForgeImpl implements MapInterface {
return mapView.getModel().mapViewPosition.getZoomLevel(); 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 @Override
public void setBearing(float bearing) { public void setBearing(float bearing) {
// MapForge: нет прямой поддержки bearing у MapViewPosition игнорируем // MapForge: нет прямой поддержки bearing у MapViewPosition игнорируем
@@ -246,19 +299,26 @@ public class MapForgeImpl implements MapInterface {
} }
} }
@Override
public void setMapUserInteractionListener(MapUserInteractionListener listener) {
this.mapUserInteractionListener = listener;
}
/** /**
* Настраивает слушатель движения карты для обновления курсора * Настраивает слушатель движения карты для обновления курсора
*/ */
private void setupMapMovementListener() { private void setupMapMovementListener() {
if (mapView != null) { if (mapView == null) return;
// mapView.getModel().mapViewPosition.addObserver(new org.mapsforge.map.model.Observer() { mapView.setOnTouchListener((v, event) -> {
// @Override int action = event.getActionMasked();
// public void onChange() { if (mapUserInteractionListener != null
// // Обновляем координаты курсора при движении карты && (action == MotionEvent.ACTION_DOWN
// updateCursorFromMapCenter(); || action == MotionEvent.ACTION_MOVE
// } || action == MotionEvent.ACTION_POINTER_DOWN)) {
// }); mapUserInteractionListener.onUserMapInteraction();
} }
return false;
});
} }
@Override @Override
@@ -71,6 +71,40 @@ public interface MapInterface {
*/ */
float getZoom(); 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 = север вверх) * Установка курса (bearing) карты в градусах (0 = север вверх)
*/ */
@@ -136,6 +170,45 @@ public interface MapInterface {
*/ */
void clearAisVesselInfo(); 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 для реализаций без поддержки
}
/** /**
* Интерфейс для обработки кликов по меткам * Интерфейс для обработки кликов по меткам
*/ */
@@ -62,6 +62,24 @@ public class MapLibreMapImpl implements MapInterface {
private static final String LAYER_SEAMARKS = "seamarks_layer"; private static final String LAYER_SEAMARKS = "seamarks_layer";
private static final String SOURCE_NAVIGATION_AIDS = "navigation_aids_source"; private static final String SOURCE_NAVIGATION_AIDS = "navigation_aids_source";
private static final String LAYER_NAVIGATION_AIDS = "navigation_aids_layer"; 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_OWN = "ownship";
private static final String IMAGE_VESSEL_A = "vessel_icon_a"; private static final String IMAGE_VESSEL_A = "vessel_icon_a";
private static final String IMAGE_VESSEL_B = "vessel_icon_b"; private static final String IMAGE_VESSEL_B = "vessel_icon_b";
@@ -233,11 +251,18 @@ public class MapLibreMapImpl implements MapInterface {
private final Map<String, JSONObject> aisPredictionFeatures = new HashMap<>(); private final Map<String, JSONObject> aisPredictionFeatures = new HashMap<>();
private MarkerClickListener markerClickListener; private MarkerClickListener markerClickListener;
private MapUserInteractionListener mapUserInteractionListener;
// Pending центрирование до готовности карты/стиля // Pending центрирование до готовности карты/стиля
private Double pendingCenterLat = null; private Double pendingCenterLat = null;
private Double pendingCenterLon = 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) { public MapLibreMapImpl(Context context, MapView mapView) {
this.context = context; this.context = context;
this.mapView = mapView; this.mapView = mapView;
@@ -556,6 +581,7 @@ public class MapLibreMapImpl implements MapInterface {
String iconName = pickIconNameFor(vessel); String iconName = pickIconNameFor(vessel);
props.put("icon", iconName); props.put("icon", iconName);
props.put("stale", stale); props.put("stale", stale);
props.put("warning_zone", isInWarningZone(vessel.getLatitude(), vessel.getLongitude()));
// Проставим статусную иконку, если статус поддержан // Проставим статусную иконку, если статус поддержан
String status = vessel.getNavigationalStatus(); String status = vessel.getNavigationalStatus();
String statusIcon = mapStatusToIcon(status); String statusIcon = mapStatusToIcon(status);
@@ -613,6 +639,7 @@ public class MapLibreMapImpl implements MapInterface {
JSONObject props = feature.getJSONObject("properties"); JSONObject props = feature.getJSONObject("properties");
props.put("icon", pickIconNameFor(vessel)); props.put("icon", pickIconNameFor(vessel));
props.put("stale", stale); props.put("stale", stale);
props.put("warning_zone", isInWarningZone(vessel.getLatitude(), vessel.getLongitude()));
String statusIcon = mapStatusToIcon(vessel.getNavigationalStatus()); String statusIcon = mapStatusToIcon(vessel.getNavigationalStatus());
if (statusIcon != null) { if (statusIcon != null) {
props.put("status_icon", statusIcon); 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<String, JSONObject> 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 @Override
public void removeAISVesselMarker(String mmsi) { public void removeAISVesselMarker(String mmsi) {
if (mmsi == null) return; 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 @Override
public void addLayer(String layerId, Object layerData) { public void addLayer(String layerId, Object layerData) {
if (style == null || !isStyleValid()) { 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) { if (style.getLayer(LAYER_VESSELS) == null) {
SymbolLayer layer = new SymbolLayer(LAYER_VESSELS, SOURCE_VESSELS) SymbolLayer layer = new SymbolLayer(LAYER_VESSELS, SOURCE_VESSELS)
@@ -3161,11 +3334,19 @@ public class MapLibreMapImpl implements MapInterface {
Log.e(TAG, "updateAdditionalLayers: ошибка обновления слоев", e); Log.e(TAG, "updateAdditionalLayers: ошибка обновления слоев", e);
} }
} }
@Override
public void setMapUserInteractionListener(MapUserInteractionListener listener) {
this.mapUserInteractionListener = listener;
}
private void setupMapMovementListener() { private void setupMapMovementListener() {
if (maplibreMap != null) { if (maplibreMap != null) {
maplibreMap.addOnCameraMoveListener(() -> { maplibreMap.addOnCameraMoveListener(() -> updateCursorFromMapCenter());
// Обновляем координаты курсора при движении карты maplibreMap.addOnCameraMoveStartedListener(reason -> {
updateCursorFromMapCenter(); 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); 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<org.maplibre.geojson.Point> 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<java.util.List<org.maplibre.geojson.Point>> outer = new java.util.ArrayList<>(1);
outer.add(ring);
return org.maplibre.geojson.Polygon.fromLngLats(outer);
}
} }
@@ -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;
}
}
@@ -40,6 +40,7 @@ public class YandexMapImpl implements MapInterface {
// Слушатель поворота карты // Слушатель поворота карты
private com.yandex.mapkit.map.InputListener inputListener; private com.yandex.mapkit.map.InputListener inputListener;
private MapUserInteractionListener mapUserInteractionListener;
private float lastMapAzimuth = 0.0f; private float lastMapAzimuth = 0.0f;
// Курсор overlay // Курсор overlay
@@ -134,6 +135,7 @@ public class YandexMapImpl implements MapInterface {
if (markerManager != null) { if (markerManager != null) {
markerManager.updateAISVesselMarker(vessel); markerManager.updateAISVesselMarker(vessel);
} }
updateWarningHaloForVessel(vessel);
} }
@Override @Override
@@ -141,6 +143,7 @@ public class YandexMapImpl implements MapInterface {
if (markerManager != null) { if (markerManager != null) {
markerManager.updateAISVesselMarker(vessel); markerManager.updateAISVesselMarker(vessel);
} }
updateWarningHaloForVessel(vessel);
} }
@Override @Override
@@ -148,6 +151,7 @@ public class YandexMapImpl implements MapInterface {
if (vessels == null || markerManager == null) return; if (vessels == null || markerManager == null) return;
for (AISVessel vessel : vessels) { for (AISVessel vessel : vessels) {
markerManager.updateAISVesselMarker(vessel); markerManager.updateAISVesselMarker(vessel);
updateWarningHaloForVessel(vessel);
} }
} }
@@ -156,6 +160,12 @@ public class YandexMapImpl implements MapInterface {
if (markerManager != null) { if (markerManager != null) {
markerManager.removeAISVesselMarker(mmsi); 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 @Override
@@ -163,6 +173,7 @@ public class YandexMapImpl implements MapInterface {
if (markerManager != null) { if (markerManager != null) {
markerManager.clearAISVesselMarkers(); markerManager.clearAISVesselMarkers();
} }
clearAllWarningHalos();
} }
@Override @Override
@@ -185,6 +196,56 @@ public class YandexMapImpl implements MapInterface {
return mapView.getMap().getCameraPosition().getZoom(); 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 @Override
public void setBearing(float bearing) { public void setBearing(float bearing) {
try { try {
@@ -326,6 +387,173 @@ public class YandexMapImpl implements MapInterface {
// но если в будущем будет использоваться, нужно добавить очистку // но если в будущем будет использоваться, нужно добавить очистку
} }
// ===== 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<String, com.yandex.mapkit.map.CircleMapObject> 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) * Обновление всех путей судов на карте (заглушка для Yandex)
*/ */
@@ -487,13 +715,21 @@ public class YandexMapImpl implements MapInterface {
/** /**
* Настраивает слушатель движения карты для обновления курсора * Настраивает слушатель движения карты для обновления курсора
*/ */
@Override
public void setMapUserInteractionListener(MapUserInteractionListener listener) {
this.mapUserInteractionListener = listener;
}
private void setupMapMovementListener() { private void setupMapMovementListener() {
if (mapView != null) { if (mapView != null) {
mapView.getMap().addCameraListener(new com.yandex.mapkit.map.CameraListener() { mapView.getMap().addCameraListener(new com.yandex.mapkit.map.CameraListener() {
@Override @Override
public void onCameraPositionChanged(com.yandex.mapkit.map.Map map, com.yandex.mapkit.map.CameraPosition cameraPosition, com.yandex.mapkit.map.CameraUpdateReason cameraUpdateReason, boolean finished) { public void onCameraPositionChanged(com.yandex.mapkit.map.Map map, com.yandex.mapkit.map.CameraPosition cameraPosition, com.yandex.mapkit.map.CameraUpdateReason cameraUpdateReason, boolean finished) {
// Обновляем координаты курсора при движении карты
updateCursorFromMapCenter(); updateCursorFromMapCenter();
if (cameraUpdateReason == com.yandex.mapkit.map.CameraUpdateReason.GESTURES
&& mapUserInteractionListener != null) {
mapUserInteractionListener.onUserMapInteraction();
}
} }
}); });
} }
@@ -31,6 +31,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.switchmaterial.SwitchMaterial; import com.google.android.material.switchmaterial.SwitchMaterial;
import com.grigowashere.aismap.R; import com.grigowashere.aismap.R;
import com.grigowashere.aismap.utils.SettingsManager; import com.grigowashere.aismap.utils.SettingsManager;
import com.grigowashere.aismap.utils.UiInsetsUtils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -55,6 +56,9 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
private EditText etBleBridgeHost; private EditText etBleBridgeHost;
private EditText etBleBridgePort; private EditText etBleBridgePort;
// BLE optional battery read (system pairing trigger on some devices)
private SwitchMaterial swBleBatteryEnabled;
private Button btnSave; private Button btnSave;
private Button btnCancel; private Button btnCancel;
@@ -72,6 +76,7 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_interfaces_settings); setContentView(R.layout.activity_interfaces_settings);
applySettingsInsets();
settingsManager = new SettingsManager(this); settingsManager = new SettingsManager(this);
BluetoothManager bm = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE); BluetoothManager bm = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
btAdapter = bm != null ? bm.getAdapter() : null; btAdapter = bm != null ? bm.getAdapter() : null;
@@ -82,6 +87,14 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
setupRecycler(); 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() { private void initViews() {
etUdpPort = findViewById(R.id.et_udp_port); etUdpPort = findViewById(R.id.et_udp_port);
swUdpEnabled = findViewById(R.id.switch_udp_enabled); 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); swBleBridgeEnabled = findViewById(R.id.switch_ble_udp_bridge_enabled);
etBleBridgeHost = findViewById(R.id.et_ble_udp_host); etBleBridgeHost = findViewById(R.id.et_ble_udp_host);
etBleBridgePort = findViewById(R.id.et_ble_udp_port); etBleBridgePort = findViewById(R.id.et_ble_udp_port);
swBleBatteryEnabled = findViewById(R.id.switch_ble_battery_enabled);
btnSave = findViewById(R.id.btn_save); btnSave = findViewById(R.id.btn_save);
btnCancel = findViewById(R.id.btn_cancel); btnCancel = findViewById(R.id.btn_cancel);
btnBleScan = findViewById(R.id.btn_ble_scan); btnBleScan = findViewById(R.id.btn_ble_scan);
@@ -107,6 +121,10 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
swBleBridgeEnabled.setChecked(settingsManager.isBleUdpBridgeEnabled()); swBleBridgeEnabled.setChecked(settingsManager.isBleUdpBridgeEnabled());
etBleBridgeHost.setText(settingsManager.getBleUdpBridgeHost()); etBleBridgeHost.setText(settingsManager.getBleUdpBridgeHost());
etBleBridgePort.setText(String.valueOf(settingsManager.getBleUdpBridgePort())); etBleBridgePort.setText(String.valueOf(settingsManager.getBleUdpBridgePort()));
if (swBleBatteryEnabled != null) {
swBleBatteryEnabled.setChecked(settingsManager.isBleReadBatteryEnabled());
}
} }
private void setupHandlers() { private void setupHandlers() {
@@ -202,6 +220,10 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
int brPort = parseInt(etBleBridgePort.getText().toString().trim(), 10110, 1, 65535); int brPort = parseInt(etBleBridgePort.getText().toString().trim(), 10110, 1, 65535);
settingsManager.setBleUdpBridgePort(brPort); settingsManager.setBleUdpBridgePort(brPort);
if (swBleBatteryEnabled != null) {
settingsManager.setBleReadBatteryEnabled(swBleBatteryEnabled.isChecked());
}
Toast.makeText(this, "Настройки сохранены", Toast.LENGTH_SHORT).show(); Toast.makeText(this, "Настройки сохранены", Toast.LENGTH_SHORT).show();
finish(); finish();
} catch (Exception e) { } catch (Exception e) {
@@ -196,60 +196,63 @@ public class BottomSheetsManager {
if (tvTitle != null) { if (tvTitle != null) {
String name = vessel.getVesselName() != null && !vessel.getVesselName().isEmpty() ? vessel.getVesselName() : "AIS СУДНО"; String name = vessel.getVesselName() != null && !vessel.getVesselName().isEmpty() ? vessel.getVesselName() : "AIS СУДНО";
// Флаг страны по MMSI оставляем это единственный визуальный
// маркер, который тут реально несёт смысл. Остальные эмодзи в
// карточке цели убраны, чтобы текст не выглядел как чат.
String flag = getFlagEmojiForMMSI(vessel.getMmsi()); 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 (tvMmsi != null) tvMmsi.setText("MMSI: " + (vessel.getMmsi() != null ? vessel.getMmsi() : "--"));
if (tvCallsign != null) tvCallsign.setText("📻 Позывной: " + (vessel.getCallSign() != null ? vessel.getCallSign() : "--")); if (tvCallsign != null) tvCallsign.setText("Позывной: " + (vessel.getCallSign() != null ? vessel.getCallSign() : "--"));
if (tvImo != null) tvImo.setText("🏷️ IMO: " + (vessel.getImo() > 0 ? String.valueOf(vessel.getImo()) : "--")); if (tvImo != null) tvImo.setText("IMO: " + (vessel.getImo() > 0 ? String.valueOf(vessel.getImo()) : "--"));
if (tvType != null) tvType.setText("🚢 Тип: " + (vessel.getVesselType() != null ? vessel.getVesselType() : "--")); if (tvType != null) tvType.setText("Тип: " + (vessel.getVesselType() != null ? vessel.getVesselType() : "--"));
if (tvPosition != null) { if (tvPosition != null) {
if (vessel.getLatitude() != 0 && vessel.getLongitude() != 0) { 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 { } else {
tvPosition.setText("📍 Координаты: --"); tvPosition.setText("Координаты: --");
} }
} }
if (tvCourse != null) tvCourse.setText(vessel.getCourse() > 0 ? String.format("🧭 COG: %.1f°", vessel.getCourse()) : "🧭 COG: --°"); 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 (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 (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 (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 (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 (tvDraft != null) tvDraft.setText(vessel.getDraft() > 0 ? String.format("Осадка: %.1f м", vessel.getDraft()) : "Осадка: -- м");
if (tvDestination != null) tvDestination.setText("🎯 Назначение: " + (vessel.getDestination() != null ? vessel.getDestination() : "--")); 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 (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 (tvNavStatus != null) tvNavStatus.setText("Статус: " + (vessel.getNavigationalStatus() != null ? vessel.getNavigationalStatus() : "--"));
if (tvClass != null) tvClass.setText("📋 Класс: " + (vessel.getVesselClass() != null ? vessel.getVesselClass() : "--")); if (tvClass != null) tvClass.setText("Класс: " + (vessel.getVesselClass() != null ? vessel.getVesselClass() : "--"));
if (tvSignal != null) { if (tvSignal != null) {
if (vessel.getSignalStrength() > 0) { if (vessel.getSignalStrength() > 0) {
tvSignal.setText(String.format("📶 Сигнал: %d", vessel.getSignalStrength())); tvSignal.setText(String.format("Сигнал: %d", vessel.getSignalStrength()));
} else { } 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) { if (tvDistance != null || tvBearing != null) {
Vessel ourVessel = appCoordinator.getOwnVessel(); Vessel ourVessel = appCoordinator.getOwnVessel();
if (ourVessel != null && ourVessel.getLatitude() != 0 && ourVessel.getLongitude() != 0 && vessel.getLatitude() != 0 && vessel.getLongitude() != 0) { 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()); 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 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); 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 { } else {
if (tvDistance != null) tvDistance.setText("📏 Расстояние: --"); if (tvDistance != null) tvDistance.setText("Расстояние: --");
if (tvBearing != null) tvBearing.setText("🧭 Пеленг: --"); if (tvBearing != null) tvBearing.setText("Пеленг: --");
} }
} }
if (tvTimeAgo != null) { if (tvTimeAgo != null) {
if (vessel.getLastUpdate() != null) { if (vessel.getLastUpdate() != null) {
long secondsAgo = java.time.Duration.between(vessel.getLastUpdate(), java.time.LocalDateTime.now()).getSeconds(); long secondsAgo = java.time.Duration.between(vessel.getLastUpdate(), java.time.LocalDateTime.now()).getSeconds();
tvTimeAgo.setText("⏱️ Время назад: " + formatTimeAgo(secondsAgo)); tvTimeAgo.setText("Время назад: " + formatTimeAgo(secondsAgo));
} else { } else {
tvTimeAgo.setText("⏱️ Время назад: --"); tvTimeAgo.setText("Время назад: --");
} }
} }
} }
@@ -287,7 +290,7 @@ public class BottomSheetsManager {
TextView tvTimeAgo = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_time_ago); TextView tvTimeAgo = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_time_ago);
if (tvTimeAgo != null && currentAISVessel.getLastUpdate() != null) { if (tvTimeAgo != null && currentAISVessel.getLastUpdate() != null) {
long secondsAgo = java.time.Duration.between(currentAISVessel.getLastUpdate(), java.time.LocalDateTime.now()).getSeconds(); long secondsAgo = java.time.Duration.between(currentAISVessel.getLastUpdate(), java.time.LocalDateTime.now()).getSeconds();
tvTimeAgo.setText("⏱️ Время назад: " + formatTimeAgo(secondsAgo)); tvTimeAgo.setText("Время назад: " + formatTimeAgo(secondsAgo));
} }
} }
@@ -4,10 +4,17 @@ import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; 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.MapInterface;
import com.grigowashere.aismap.maps.MapInterfaceChangeListener; 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.Vessel;
import com.grigowashere.aismap.models.AISVessel; 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.HashSet;
import java.util.ArrayList; import java.util.ArrayList;
@@ -32,6 +39,8 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
private MapInterface mapInterface; private MapInterface mapInterface;
private Handler uiHandler; private Handler uiHandler;
private SettingsManager settingsManager;
private android.content.Context appContext;
// Pending операции для батчинга // Pending операции для батчинга
private Vessel pendingVesselUpdate; private Vessel pendingVesselUpdate;
@@ -56,6 +65,16 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
Log.i(TAG, "UIRenderingCoordinator инициализирован"); 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 механизмов * Настройка throttling механизмов
*/ */
@@ -137,6 +156,7 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
try { try {
Log.d(TAG, "Выполняем vessel update: " + pendingVesselUpdate.getLatitude() + "," + pendingVesselUpdate.getLongitude()); Log.d(TAG, "Выполняем vessel update: " + pendingVesselUpdate.getLatitude() + "," + pendingVesselUpdate.getLongitude());
mapInterface.updateOwnVesselPosition(pendingVesselUpdate); mapInterface.updateOwnVesselPosition(pendingVesselUpdate);
applyRangeRingsAround(pendingVesselUpdate);
Log.d(TAG, "Vessel update выполнен успешно"); Log.d(TAG, "Vessel update выполнен успешно");
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Ошибка vessel update: " + e.getMessage(), e); Log.e(TAG, "Ошибка vessel update: " + e.getMessage(), e);
@@ -145,6 +165,57 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
pendingVesselUpdate = null; 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 судов * Выполнение обновлений AIS судов
*/ */
@@ -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));
}
/**
* Ограничивает зум допустимым диапазоном карт (220).
*/
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;
}
/**
* Плавный поворот по кратчайшей дуге (градусы 0360).
*/
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;
}
}
@@ -0,0 +1,72 @@
package com.grigowashere.aismap.utils;
/**
* Чистые статические утилиты для логики колец дальности.
* <p>Не зависят от 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() { }
/**
* Конвертирует значение в выбранной единице измерения в метры.
* <p>Любое неизвестное значение единицы трактуется как {@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;
}
}
@@ -45,12 +45,29 @@ public class SettingsManager {
private static final String KEY_START_ZOOM_LEVEL = "start_zoom_level"; private static final String KEY_START_ZOOM_LEVEL = "start_zoom_level";
/** Как карта следует за ориентацией: {@link #MAP_ROTATION_COMPASS} / COURSE / MANUAL */ /** Как карта следует за ориентацией: {@link #MAP_ROTATION_COMPASS} / COURSE / MANUAL */
private static final String KEY_MAP_ROTATION_MODE = "map_rotation_mode"; 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 // BLE/NMEA settings
private static final String KEY_BLE_ENABLED = "ble_enabled"; 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_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_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_HOST = "ble_udp_bridge_host";
private static final String KEY_BLE_UDP_BRIDGE_PORT = "ble_udp_bridge_port"; 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 int DEFAULT_UDP_PORT = 10110;
@@ -84,6 +101,24 @@ public class SettingsManager {
private static final boolean DEFAULT_BLE_UDP_BRIDGE_ENABLED = false; 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 String DEFAULT_BLE_UDP_BRIDGE_HOST = "255.255.255.255";
private static final int DEFAULT_BLE_UDP_BRIDGE_PORT = 10110; 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"; public static final String DATA_MODE_HYBRID = "hybrid";
@@ -107,6 +142,11 @@ public class SettingsManager {
/** Как курс (COG / GPS bearing). */ /** Как курс (COG / GPS bearing). */
public static final String MAP_ROTATION_COURSE = "course"; public static final String MAP_ROTATION_COURSE = "course";
private static final String DEFAULT_MAP_ROTATION_MODE = MAP_ROTATION_MANUAL; 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 Context context;
private SharedPreferences prefs; private SharedPreferences prefs;
@@ -455,6 +495,63 @@ public class SettingsManager {
return 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 слушатель * Проверяет, нужно ли перезапустить UDP слушатель
*/ */
@@ -697,4 +794,86 @@ public class SettingsManager {
Log.i(TAG, "Морские знаки OpenSeaMap: " + (enabled ? "включены" : "выключены")); 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());
}
} }
@@ -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);
}
}
@@ -22,9 +22,51 @@ public abstract class BaseDockWidget extends FrameLayout {
protected static final float MAX_SCALE = 2.0f; protected static final float MAX_SCALE = 2.0f;
protected static final float SCALE_STEP = 0.1f; 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-области.
*
* <p>Наследники переопределяют этот метод и считают высоту по своим
* реальным метрикам отрисовки (размер шрифта, число строк, и т.п.), чтобы
* не быть привязанными к магической константе.
*
* <p>По умолчанию возвращает {@code dp(getDefaultDockHeightDp())} для
* обратной совместимости с виджетами, которые ещё не реализовали measure.
*/
protected int measureDockContentHeightPx(int widthPx) {
return (int) dp(getDefaultDockHeightDp());
}
/**
* Куда виджет «прикипает» по умолчанию: {@code true} к верху экрана,
* {@code false} к низу. Влияет на:
* <ul>
* <li>зону resize (верх/низ виджета),</li>
* <li>сторону, к которой подъезжают другие dock-виджеты при стакинге,</li>
* <li>позицию docking после ручного перетаскивания (если пользователь
* отпустил виджет в середине, мы возвращаем его на «домашнюю» сторону).</li>
* </ul>
* XML-якорь ({@code layout_alignParentBottom} / {@code layout_above}) задаёт
* стартовое положение визуально, а этот метод внутреннюю модель.
*/
protected boolean getDefaultDockTop() {
return true;
}
// Состояние виджета // Состояние виджета
protected boolean isDocked = true; // По умолчанию в dock-режиме protected boolean isDocked = true; // По умолчанию в dock-режиме
protected boolean dockTop = true; protected boolean dockTop = true; // Инициализируется в init() через getDefaultDockTop()
protected boolean isMorphing = false; protected boolean isMorphing = false;
protected float morphProgress = 0.0f; // 0 = dock, 1 = circle protected float morphProgress = 0.0f; // 0 = dock, 1 = circle
@@ -73,21 +115,18 @@ public abstract class BaseDockWidget extends FrameLayout {
setClickable(true); setClickable(true);
setFocusable(true); setFocusable(true);
// Инициализируем в dock-режиме // Стартовая сторона дока (top/bottom) определяется наследником. Само
post(() -> { // фактическое положение задаёт RelativeLayout (alignParentTop / Bottom /
if (isDocked) { // layout_above), а этот флаг внутренняя модель для resize-зоны и
ViewGroup parent = (ViewGroup) getParent(); // стакинга других dock-виджетов.
if (parent != null) { this.dockTop = getDefaultDockTop();
setX(0); // Высота view в dock-режиме считается в onMeasure через
setY(0); // measureDockContentHeightPx(...) при lp.height=WRAP_CONTENT (это
ViewGroup.LayoutParams lp = getLayoutParams(); // прописано в activity_main.xml). А переход dock<->circle сам выставляет
lp.width = ViewGroup.LayoutParams.MATCH_PARENT; // правильные lp в конце анимации (см. setDocked). Намеренно НЕ дёргаем
lp.height = (int) dp(DEFAULT_DOCK_HEIGHT_DP); // setLayoutParams() из init().post() это вызывало второй проход layout
dockHeightPx = 0; // Сбрасываем сохраненную высоту // ПОСЛЕ первого measure и оставляло координатный/danger виджет с нулевой
setLayoutParams(lp); // высотой на первый кадр, пока не приходил какой-нибудь size-update.
}
}
});
} }
@Override @Override
@@ -232,7 +271,7 @@ public abstract class BaseDockWidget extends FrameLayout {
// Ресайзим именно контент (dockHeightPx). Паддинги от WindowInsets // Ресайзим именно контент (dockHeightPx). Паддинги от WindowInsets
// прибавляются поверх в onMeasure, поэтому «рабочая» часть не уезжает // прибавляются поверх в onMeasure, поэтому «рабочая» часть не уезжает
// под системный бар даже при минимальном размере. // под системный бар даже при минимальном размере.
int currentContent = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP); int currentContent = dockHeightPx > 0 ? dockHeightPx : (int) dp(getDefaultDockHeightDp());
int newHeight = currentContent; int newHeight = currentContent;
if (dockTop) { if (dockTop) {
@@ -309,7 +348,7 @@ public abstract class BaseDockWidget extends FrameLayout {
// При докинге всегда устанавливаем размер по умолчанию // При докинге всегда устанавливаем размер по умолчанию
dockHeightPx = 0; // Сбрасываем сохраненную высоту 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) { private float getDistance(MotionEvent event) {
@@ -324,10 +363,16 @@ public abstract class BaseDockWidget extends FrameLayout {
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (isDocked) { if (isDocked) {
int width = MeasureSpec.getSize(widthMeasureSpec); int width = MeasureSpec.getSize(widthMeasureSpec);
// dockHeightPx/DEFAULT это высота полезного контента; к ней // Высота content-области:
// прибавляем padding от WindowInsets, чтобы виджет фактически // * если пользователь ВРУЧНУЮ растянул виджет (dockHeightPx>0)
// расширялся под статус-бар или нав-бар и не прятал контент. // используем эту фиксированную высоту;
int content = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP); // * иначе спрашиваем у конкретного виджета через measure-метод,
// сколько ему нужно для отрисовки на данной ширине.
// Системные паддинги (статус-бар/нав-бар) прибавляются СВЕРХУ
// content-области, чтобы карта не пряталась под бары.
int content = dockHeightPx > 0
? dockHeightPx
: measureDockContentHeightPx(width);
int height = content + getPaddingTop() + getPaddingBottom(); int height = content + getPaddingTop() + getPaddingBottom();
setMeasuredDimension(width, height); setMeasuredDimension(width, height);
} else { } else {
@@ -353,7 +398,15 @@ public abstract class BaseDockWidget extends FrameLayout {
} }
public void setDocked(boolean docked, boolean top, float targetX, float targetY) { 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; return;
} }
@@ -372,7 +425,7 @@ public abstract class BaseDockWidget extends FrameLayout {
ViewGroup parent = (ViewGroup) getParent(); ViewGroup parent = (ViewGroup) getParent();
int parentWidth = parent.getWidth(); int parentWidth = parent.getWidth();
int parentHeight = parent.getHeight(); 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 circleSize = (int) dp(CIRCLE_SIZE_DP);
int endW = docked ? parentWidth : circleSize; int endW = docked ? parentWidth : circleSize;
@@ -422,12 +475,30 @@ public abstract class BaseDockWidget extends FrameLayout {
@Override @Override
public void onAnimationEnd(Animator animation) { public void onAnimationEnd(Animator animation) {
ViewGroup.LayoutParams lp = getLayoutParams(); ViewGroup.LayoutParams lp = getLayoutParams();
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.width = endW;
lp.height = endH; lp.height = endH;
setLayoutParams(lp); setLayoutParams(lp);
// В circle-режиме виджет «свободно плавает», его позицию
// мы держим именно через translation (setX/setY).
setX(finalEndX); setX(finalEndX);
setY(finalEndY); setY(finalEndY);
}
morphProgress = endMorph; morphProgress = endMorph;
postInvalidateOnAnimation(); postInvalidateOnAnimation();
@@ -477,7 +548,7 @@ public abstract class BaseDockWidget extends FrameLayout {
ViewGroup parent = (ViewGroup) getParent(); ViewGroup parent = (ViewGroup) getParent();
if (parent == null) return 0; if (parent == null) return 0;
int dockHeight = (int) dp(DEFAULT_DOCK_HEIGHT_DP); int dockHeight = (int) dp(getDefaultDockHeightDp());
float y = 0; float y = 0;
if (dockTop) { 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) { public static void repositionAllDockedWidgets(ViewGroup parent) {
if (parent == null) return; if (parent == null) return;
// Собираем все docked виджеты сверху
java.util.List<BaseDockWidget> topWidgets = new java.util.ArrayList<>();
java.util.List<BaseDockWidget> bottomWidgets = new java.util.ArrayList<>();
for (int i = 0; i < parent.getChildCount(); i++) { for (int i = 0; i < parent.getChildCount(); i++) {
View child = parent.getChildAt(i); View child = parent.getChildAt(i);
if (child instanceof BaseDockWidget) { if (child instanceof BaseDockWidget) {
BaseDockWidget widget = (BaseDockWidget) child; BaseDockWidget widget = (BaseDockWidget) child;
if (widget.isDocked()) { if (widget.isDocked() && !widget.isMorphing) {
if (widget.isDockTop()) { widget.setTranslationX(0f);
topWidgets.add(widget); widget.setTranslationY(0f);
} else {
bottomWidgets.add(widget);
} }
} }
} }
} parent.requestLayout();
// Перепозиционируем виджеты сверху
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);
}
} }
// Абстрактные методы для переопределения в наследниках // Абстрактные методы для переопределения в наследниках
@@ -65,6 +65,29 @@ public class CompassView extends BaseDockWidget {
init(); init();
} }
/**
* Минимальная высота контента, при которой шкала компаса и её буквы N/S/W/E
* гарантированно помещаются в видимую область.
*
* <p>Считаем по факту отрисовки:
* <ul>
* <li>header (HEADING/MAG label+value+divider) 38dp,</li>
* <li>шкала с буквами по краям 56dp.</li>
* </ul>
* Итого 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() { private void init() {
paint.setColor(TICK_COLOR); paint.setColor(TICK_COLOR);
paint.setTextAlign(Paint.Align.CENTER); paint.setTextAlign(Paint.Align.CENTER);
@@ -156,38 +179,34 @@ public class CompassView extends BaseDockWidget {
// чтобы под статус-бар/бровь тоже уходил единый тон. // чтобы под статус-бар/бровь тоже уходил единый тон.
canvas.drawRect(0, 0, totalW, totalH, bgPaint); 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 в // Шапка в стиле LABEL + значение (как POSITION/SOG/COG/ACC в
// координатах): слева HEADING (азимут), справа MAG (магн. компас). // координатах): слева HEADING (азимут), справа MAG (магн. компас).
float cx = left + w / 2f; // Размеры шапки фиксированы и не зависят от высоты виджета это
// обычные строчки текста, они и так хорошо смотрятся при любой высоте.
float padInner = dp(10); float padInner = dp(10);
float labelY = top + dp(12) * Math.max(1f, scaleFactor * 0.9f); float labelY = top + dp(12);
float valueY = labelY + dp(16) * Math.max(1f, scaleFactor * 0.9f); float valueY = labelY + dp(16);
labelPaint.setTextAlign(Paint.Align.LEFT); labelPaint.setTextAlign(Paint.Align.LEFT);
valuePaint.setTextAlign(Paint.Align.LEFT); valuePaint.setTextAlign(Paint.Align.LEFT);
accentPaint.setTextAlign(Paint.Align.LEFT); accentPaint.setTextAlign(Paint.Align.LEFT);
canvas.drawText("HEADING", left + padInner, labelY, labelPaint); canvas.drawText(getResources().getString(com.grigowashere.aismap.R.string.compass_label_heading),
canvas.drawText(((int) currentAzimuth) + "°", left + padInner, labelY, labelPaint);
canvas.drawText(((int) currentAzimuth) + "\u00B0",
left + padInner, valueY, accentPaint); left + padInner, valueY, accentPaint);
labelPaint.setTextAlign(Paint.Align.RIGHT); labelPaint.setTextAlign(Paint.Align.RIGHT);
valuePaint.setTextAlign(Paint.Align.RIGHT); valuePaint.setTextAlign(Paint.Align.RIGHT);
canvas.drawText("MAG", right - padInner, labelY, labelPaint); canvas.drawText(getResources().getString(com.grigowashere.aismap.R.string.compass_label_mag),
canvas.drawText(((int) magneticCompass) + "°", right - padInner, labelY, labelPaint);
canvas.drawText(((int) magneticCompass) + "\u00B0",
right - padInner, valueY, valuePaint); right - padInner, valueY, valuePaint);
// Разделитель под шапкой такой же, как в координатах.
float dividerY = valueY + dp(6); float dividerY = valueY + dp(6);
canvas.drawLine(left + padInner, dividerY, right - padInner, dividerY, dividerPaint); canvas.drawLine(left + padInner, dividerY, right - padInner, dividerY, dividerPaint);
// Цвет делений шкалы светло-серый, чтобы не спорил с фоном палитры.
paint.setColor(TICK_COLOR); paint.setColor(TICK_COLOR);
paint.setTextSize(24 * scaleFactor);
paint.setTextAlign(Paint.Align.CENTER); paint.setTextAlign(Paint.Align.CENTER);
// Плавное обновление азимута // Плавное обновление азимута
@@ -200,49 +219,57 @@ public class CompassView extends BaseDockWidget {
postInvalidateOnAnimation(); postInvalidateOnAnimation();
} }
// Рисуем простую шкалу под шапкой. Центр смещён, чтобы шкала // === Шкала компаса ===
// не наезжала на label-строку HEADING/MAG. // ВСЕ метрики шкалы выражены в долях от фактической высоты scaleH
// тогда буквы N/S/W/E и градусные подписи никогда не вылезают за
// нижнюю границу виджета, даже если пользователь сжал его ручкой.
float centerX = left + w / 2f; float centerX = left + w / 2f;
float scaleTop = dividerY + dp(4); 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; float visibleDegrees = 120;
// Рисуем деления шкалы
for (int degree = 0; degree < 360; degree += 15) { for (int degree = 0; degree < 360; degree += 15) {
// Вычисляем относительное положение деления
float relativeDegree = getShortestRotation(currentAzimuth, degree); float relativeDegree = getShortestRotation(currentAzimuth, degree);
if (Math.abs(relativeDegree) > visibleDegrees / 2) continue;
// Рисуем только видимые деления
if (Math.abs(relativeDegree) <= visibleDegrees / 2) {
float x = centerX + (relativeDegree / (visibleDegrees / 2)) * (w / 2); float x = centerX + (relativeDegree / (visibleDegrees / 2)) * (w / 2);
float lineHeight = (degree % 30 == 0) ? 20 * scaleFactor : 10 * scaleFactor; float lineHeight = (degree % 30 == 0) ? majorTickH : minorTickH;
canvas.drawLine(x, centerY - lineHeight, x, centerY + lineHeight, paint); canvas.drawLine(x, centerY - lineHeight, x, centerY + lineHeight, paint);
if (degree % 30 == 0) { if (degree % 30 == 0) {
String degreeText = String.valueOf(degree); paint.setTextSize(degreeTextSize);
paint.setTextSize(16 * scaleFactor); canvas.drawText(String.valueOf(degree), x, degreeTextY, paint);
canvas.drawText(degreeText, x, centerY - 30 * scaleFactor, paint);
} }
if (degree % 45 == 0) { if (degree % 45 == 0) {
int directionIndex = (degree / 45) % 8; int directionIndex = (degree / 45) % 8;
if (directionIndex < directions.length) { if (directionIndex < directions.length) {
// Буква стороны света увеличивается при приближении к центру // Буква стороны света увеличивается при приближении к центру.
float proximity = 1f - Math.min(Math.abs(relativeDegree) / (visibleDegrees / 2f), 1f); float proximity = 1f - Math.min(Math.abs(relativeDegree) / (visibleDegrees / 2f), 1f);
float letterSize = (24f + 36f * proximity) * scaleFactor; // 24..48 // На краях ~0.35*scaleH, в центре ~0.7*scaleH никогда не больше scaleH.
float letterSize = scaleH * (0.35f + 0.35f * proximity);
paint.setTextSize(letterSize); paint.setTextSize(letterSize);
canvas.drawText(directions[directionIndex], x, centerY + 50 * scaleFactor, paint); 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) { for (AISVessel vessel : nearbyVessels) {
float relativeBearing = getShortestRotation(currentAzimuth, (float) vessel.getCourse()); float relativeBearing = getShortestRotation(currentAzimuth, (float) vessel.getCourse());
if (Math.abs(relativeBearing) <= visibleDegrees / 2) { if (Math.abs(relativeBearing) <= visibleDegrees / 2) {
float x = centerX + (relativeBearing / (visibleDegrees / 2)) * (w / 2); float x = centerX + (relativeBearing / (visibleDegrees / 2)) * (w / 2);
double distance = ourVessel != null ? GeoUtils.calculateDistance(ourVessel, vessel) : 0; 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)); vesselPaint.setColor(getVesselColor(vessel));
drawVesselTriangle(canvas, x, centerY, size, (float) (vessel.getCourse() - currentAzimuth)); drawVesselTriangle(canvas, x, centerY, size, (float) (vessel.getCourse() - currentAzimuth));
} }
@@ -251,7 +278,7 @@ public class CompassView extends BaseDockWidget {
// Центральная линия (направление вперёд) только в области шкалы, // Центральная линия (направление вперёд) только в области шкалы,
// чтобы не пересекать шапку HEADING/MAG. // чтобы не пересекать шапку HEADING/MAG.
paint.setColor(Color.RED); paint.setColor(Color.RED);
paint.setStrokeWidth(3 * scaleFactor); paint.setStrokeWidth(Math.max(2f, scaleH * 0.05f));
canvas.drawLine(centerX, scaleTop, centerX, bottom, paint); canvas.drawLine(centerX, scaleTop, centerX, bottom, paint);
paint.setColor(TICK_COLOR); paint.setColor(TICK_COLOR);
paint.setStrokeWidth(1); paint.setStrokeWidth(1);
@@ -360,7 +387,8 @@ public class CompassView extends BaseDockWidget {
canvas.drawText(((int) currentAzimuth) + "°", cx, cy + dp(2), accentPaint); canvas.drawText(((int) currentAzimuth) + "°", cx, cy + dp(2), accentPaint);
labelPaint.setTextAlign(Paint.Align.CENTER); labelPaint.setTextAlign(Paint.Align.CENTER);
labelPaint.setTextSize(dp(9) * Math.max(0.7f, Math.min(1.4f, scaleFactor))); 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);
} }
@@ -46,6 +46,37 @@ public class CoordinatesDockWidget extends BaseDockWidget {
init(); 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() { private void init() {
backgroundPaint = new Paint(); backgroundPaint = new Paint();
backgroundPaint.setColor(BACKGROUND_COLOR); backgroundPaint.setColor(BACKGROUND_COLOR);
@@ -125,7 +156,7 @@ public class CoordinatesDockWidget extends BaseDockWidget {
if (vessel.getLatitude() != 0 || vessel.getLongitude() != 0) { if (vessel.getLatitude() != 0 || vessel.getLongitude() != 0) {
coordinatesText = formatLatLon(vessel.getLatitude(), vessel.getLongitude()); coordinatesText = formatLatLon(vessel.getLatitude(), vessel.getLongitude());
} else { } else {
coordinatesText = "нет фикса"; coordinatesText = getResources().getString(com.grigowashere.aismap.R.string.coords_value_no_fix);
} }
if (vessel.getSpeed() > 0.05) { if (vessel.getSpeed() > 0.05) {
@@ -195,34 +226,40 @@ public class CoordinatesDockWidget extends BaseDockWidget {
float innerTop = top + dp(8); float innerTop = top + dp(8);
float innerBottom = bottom - dp(8); float innerBottom = bottom - dp(8);
// Строка 1: координаты (с подписью "POSITION"). // Строка 1: координаты (с подписью "КООРДИНАТЫ").
Paint posPaint = getCoordinatesPaint(); Paint posPaint = getCoordinatesPaint();
float labelH = labelPaint.getTextSize() * 1.1f; float labelH = labelPaint.getTextSize() * 1.1f;
float valueH = posPaint.getTextSize() * 1.15f; float valueH = posPaint.getTextSize() * 1.15f;
android.content.res.Resources res = getResources();
float y = innerTop + labelH; 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; y += valueH;
canvas.drawText(coordinatesText, innerLeft, y, posPaint); canvas.drawText(coordinatesText, innerLeft, y, posPaint);
// Строка 2: SOG | COG | ACC в три колонки. // Строка 2: SOG/COG/ACC в три колонки.
float colTop = y + dp(10); float colTop = y + dp(10);
float colW = (innerRight - innerLeft) / 3f; float colW = (innerRight - innerLeft) / 3f;
float colLabelY = colTop + labelH; float colLabelY = colTop + labelH;
float colValueY = colLabelY + valueH; float colValueY = colLabelY + valueH;
// SOG // SOG (скорость).
canvas.drawText("SOG", innerLeft, colLabelY, labelPaint); canvas.drawText(res.getString(com.grigowashere.aismap.R.string.coords_label_sog),
innerLeft, colLabelY, labelPaint);
canvas.drawText(sogText, innerLeft, colValueY, getSOGPaint()); canvas.drawText(sogText, innerLeft, colValueY, getSOGPaint());
// COG // COG (курс по земле).
float cogX = innerLeft + colW; 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()); canvas.drawText(cogText, cogX, colValueY, getCOGPaint());
// ACC // ACC (точность).
float accX = innerLeft + colW * 2f; 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()); canvas.drawText(accuracyText, accX, colValueY, getAccuracyPaint());
if (colValueY > innerBottom) { if (colValueY > innerBottom) {
@@ -278,7 +315,8 @@ public class CoordinatesDockWidget extends BaseDockWidget {
float y = centerY - totalH / 2f + smallLabel; 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; y += lineH;
drawCentered(canvas, latLine, centerX, y, posPaint); drawCentered(canvas, latLine, centerX, y, posPaint);
y += lineH; y += lineH;
@@ -291,14 +329,17 @@ public class CoordinatesDockWidget extends BaseDockWidget {
// SOG / COG бок о бок. // SOG / COG бок о бок.
float colCenterL = centerX - radius * 0.45f; float colCenterL = centerX - radius * 0.45f;
float colCenterR = centerX + radius * 0.45f; float colCenterR = centerX + radius * 0.45f;
drawCentered(canvas, "SOG", colCenterL, y, labelPaint); drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_sog),
drawCentered(canvas, "COG", colCenterR, y, labelPaint); colCenterL, y, labelPaint);
drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_cog),
colCenterR, y, labelPaint);
y += bigValue + lineGap; y += bigValue + lineGap;
drawCentered(canvas, sogText, colCenterL, y, sogPaint); drawCentered(canvas, sogText, colCenterL, y, sogPaint);
drawCentered(canvas, cogText, colCenterR, y, cogPaint); drawCentered(canvas, cogText, colCenterR, y, cogPaint);
y += dp(6); 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; y += smallValue + lineGap;
drawCentered(canvas, accuracyText, centerX, y, accPaint); drawCentered(canvas, accuracyText, centerX, y, accPaint);
@@ -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 в зависимости от настроек).
* <p>Обновление выполняется через {@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<DangerEntry> 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<DangerEntry> 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<DangerEntry> 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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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<Row> 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<AppCoordinator.DangerEntry> entries) {
List<Row> 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<Row> 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;
}
}
@@ -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<Blip> 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<AppCoordinator.DangerEntry> entries,
double dangerRadiusMeters) {
List<Blip> 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<AppCoordinator.DangerEntry> entries,
double dangerRadiusMeters) {
List<Blip> 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<Blip> 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;
}
}
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,2a10,10 0,1 1,-0.01,0zM12,4a8,8 0,1 0,0.01,0z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,6a6,6 0,1 1,-0.01,0zM12,8a4,4 0,1 0,0.01,0z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,12 L20,4 L18,12 L12,12z" />
<path
android:strokeColor="#FFFFFFFF"
android:strokeWidth="1.2"
android:pathData="M12,12 L12,3" />
</vector>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<!-- Базовая «волновая» иконка сигнала, перечёркнутая диагональной чертой,
что обозначает потерю связи. Цветовой токен берётся из темы. -->
<path
android:fillColor="@android:color/white"
android:pathData="M2,22h20l-3,-3H5z" />
<path
android:fillColor="@android:color/white"
android:pathData="M21.07,4.93l-1.41,-1.41L4.93,18.66c-0.39,0.39 -0.39,1.02 0,1.41l0.0,0.0c0.39,0.39 1.02,0.39 1.41,0L21.07,6.34C21.46,5.95 21.46,5.32 21.07,4.93z" />
<path
android:fillColor="@android:color/white"
android:pathData="M12,4l-1.5,1.5l1.5,1.5l1.5,-1.5L12,4zM7.5,8.5L9,10l3,-3l-1.5,-1.5L7.5,8.5z" />
</vector>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="135"
android:endColor="@color/plotter_bezel_dark"
android:startColor="@color/plotter_bezel_light"
android:type="linear" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/plotter_panel_bg" />
<stroke
android:width="1dp"
android:color="@color/plotter_panel_stroke" />
<corners android:radius="4dp" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="4dp" />
<solid android:color="@color/plotter_radar_bg" />
<stroke
android:width="3dp"
android:color="@color/plotter_bezel_highlight" />
</shape>
@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/radar_plotter_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/plotter_bezel_background"
tools:context=".RadarPlotterActivity">
<!-- Верхняя панель: заголовок + назад -->
<LinearLayout
android:id="@+id/radar_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="8dp"
android:paddingEnd="12dp"
android:paddingBottom="4dp">
<ImageButton
android:id="@+id/btn_radar_back"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/button_background"
android:contentDescription="@string/radar_plotter_back"
android:padding="8dp"
android:scaleType="fitCenter"
android:src="@android:drawable/ic_menu_revert" />
<TextView
android:id="@+id/tv_radar_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="@string/radar_plotter_title"
android:textColor="@color/plotter_text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_radar_range"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/plotter_text_accent"
android:textSize="12sp"
tools:text="5.0 nm" />
</LinearLayout>
<!-- Портрет: PPI сверху, приборы снизу -->
<LinearLayout
android:id="@+id/radar_plotter_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="46dp"
android:layout_marginBottom="4dp"
android:orientation="vertical"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:baselineAligned="false">
<FrameLayout
android:id="@+id/radar_viewport_frame"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1.2"
android:background="@drawable/plotter_radar_viewport_bg"
android:padding="4dp">
<org.maplibre.android.maps.MapView
android:id="@+id/radar_map_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.grigowashere.aismap.view.RadarGraticuleOverlay
android:id="@+id/radar_graticule"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
<LinearLayout
android:id="@+id/radar_instruments_panel"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="2dp"
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
android:id="@+id/plotter_instruments_row"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginBottom="4dp"
android:layout_weight="0.38"
android:baselineAligned="false"
android:orientation="horizontal">
<com.grigowashere.aismap.view.PlotterHeadingView
android:id="@+id/plotter_heading"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/plotter_panel_background" />
<com.grigowashere.aismap.view.PlotterSpeedometerView
android:id="@+id/plotter_speedometer"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:background="@drawable/plotter_panel_background" />
</LinearLayout>
<com.grigowashere.aismap.view.PlotterTargetsTableView
android:id="@+id/plotter_targets_table"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="@drawable/plotter_panel_background" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" <ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/settings_scroll"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fillViewport="true"
android:padding="16dp"> android:padding="16dp">
<LinearLayout <LinearLayout
@@ -13,20 +15,19 @@
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🔌 Интерфейсы: UDP и BLE" android:layout_marginBottom="24dp"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:gravity="center" android:gravity="center"
android:layout_marginBottom="24dp" /> android:text="@string/interfaces_title"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSurface" />
<!-- UDP --> <!-- UDP -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp" app:cardCornerRadius="12dp">
app:cardElevation="4dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -37,18 +38,17 @@
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="📡 UDP" android:layout_marginBottom="12dp"
android:textSize="18sp" android:text="@string/interfaces_section_udp"
android:textStyle="bold" android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="@android:color/black" android:textColor="?attr/colorOnSurface" />
android:layout_marginBottom="12dp" />
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
android:hint="UDP Порт" android:hint="@string/interfaces_udp_port_hint"
app:helperText="Порт для прослушивания AIS данных"> app:helperText="@string/interfaces_udp_port_helper">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_udp_port" android:id="@+id/et_udp_port"
@@ -63,20 +63,20 @@
android:id="@+id/switch_udp_enabled" android:id="@+id/switch_udp_enabled"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Включить UDP слушатель" android:checked="true"
android:textSize="16sp" android:text="@string/interfaces_udp_enabled" />
android:checked="true" />
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- BLE --> <!-- BLE -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp" app:cardCornerRadius="12dp">
app:cardElevation="4dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -87,33 +87,30 @@
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="📶 BLE" android:layout_marginBottom="12dp"
android:textSize="18sp" android:text="@string/interfaces_section_ble"
android:textStyle="bold" android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="@android:color/black" android:textColor="?attr/colorOnSurface" />
android:layout_marginBottom="12dp" />
<com.google.android.material.switchmaterial.SwitchMaterial <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_ble_enabled" android:id="@+id/switch_ble_enabled"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Включить BLE источник NMEA" android:layout_marginBottom="8dp"
android:textSize="16sp" android:text="@string/interfaces_ble_enabled" />
android:layout_marginBottom="8dp" />
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
android:hint="MAC адрес BLE устройства" android:hint="@string/interfaces_ble_mac_hint"
app:helperText="Например: 01:23:45:67:89:AB"> app:helperText="@string/interfaces_ble_mac_helper">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_ble_mac" android:id="@+id/et_ble_mac"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="text" android:inputType="text" />
android:text="" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
@@ -122,20 +119,20 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal"> android:orientation="horizontal">
<Button <com.google.android.material.button.MaterialButton
android:id="@+id/btn_ble_scan" android:id="@+id/btn_ble_scan"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Сканировать BLE" android:text="@string/interfaces_ble_scan" />
style="@style/Widget.Material3.Button" />
<Button <com.google.android.material.button.MaterialButton
android:id="@+id/btn_ble_stop_scan" android:id="@+id/btn_ble_stop_scan"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Стоп"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.OutlinedButton" /> android:text="@string/interfaces_ble_stop" />
</LinearLayout> </LinearLayout>
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
@@ -144,16 +141,33 @@
android:layout_height="200dp" android:layout_height="200dp"
android:layout_marginTop="8dp" /> android:layout_marginTop="8dp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_ble_battery_enabled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/interfaces_ble_battery_enabled" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="4dp"
android:text="@string/interfaces_ble_battery_helper"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- BLE UDP Bridge --> <!-- BLE UDP Bridge -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp" app:cardCornerRadius="12dp">
app:cardElevation="4dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -164,25 +178,23 @@
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🔁 BLE UDP Bridge" android:layout_marginBottom="12dp"
android:textSize="18sp" android:text="@string/interfaces_section_bridge"
android:textStyle="bold" android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="@android:color/black" android:textColor="?attr/colorOnSurface" />
android:layout_marginBottom="12dp" />
<com.google.android.material.switchmaterial.SwitchMaterial <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_ble_udp_bridge_enabled" android:id="@+id/switch_ble_udp_bridge_enabled"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Включить UDP-bridge (пересылать NMEA)" android:layout_marginBottom="8dp"
android:textSize="16sp" android:text="@string/interfaces_bridge_enabled" />
android:layout_marginBottom="8dp" />
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
android:hint="UDP Host (назначение)"> android:hint="@string/interfaces_bridge_host_hint">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_ble_udp_host" android:id="@+id/et_ble_udp_host"
@@ -196,8 +208,7 @@
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="8dp" android:hint="@string/interfaces_bridge_port_hint">
android:hint="UDP Port (назначение)">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_ble_udp_port" android:id="@+id/et_ble_udp_port"
@@ -209,29 +220,33 @@
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="end"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="end"> android:paddingTop="8dp"
android:paddingBottom="8dp">
<Button <com.google.android.material.button.MaterialButton
android:id="@+id/btn_cancel" android:id="@+id/btn_cancel"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Отмена"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
style="@style/Widget.Material3.Button.OutlinedButton" /> android:text="@string/settings_action_cancel" />
<Button <com.google.android.material.button.MaterialButton
android:id="@+id/btn_save" android:id="@+id/btn_save"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Сохранить" android:text="@string/settings_action_save" />
style="@style/Widget.Material3.Button" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
+84 -24
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main_root" android:id="@+id/main_root"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -13,29 +14,65 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<!-- Компас --> <!-- Баннер потери BLE-связи. Компас привязан ниже — не перекрывает текст. -->
<LinearLayout
android:id="@+id/banner_connection_lost"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:background="@color/connection_lost_bg"
android:elevation="8dp"
android:gravity="center"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingTop="10dp"
android:paddingEnd="16dp"
android:paddingBottom="10dp"
android:visibility="gone">
<TextView
android:id="@+id/tv_banner_connection_lost"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawablePadding="10dp"
android:gravity="center"
android:text="@string/banner_connection_lost_ble"
android:textAlignment="center"
android:textColor="@color/connection_lost_text"
android:textSize="14sp"
android:textStyle="bold"
app:drawableStartCompat="@drawable/ic_signal_off" />
</LinearLayout>
<!-- Компас. Высоту виджет считает сам через measureDockContentHeightPx —
contentH (~96dp) + системные паддинги (status bar/displayCutout). -->
<com.grigowashere.aismap.view.CompassView <com.grigowashere.aismap.view.CompassView
android:id="@+id/compass_view" android:id="@+id/compass_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="80dp" android:layout_height="wrap_content"
android:layout_alignParentTop="true" android:layout_below="@id/banner_connection_lost"
android:layout_marginLeft="0dp" android:elevation="2dp" />
android:layout_marginTop="0dp"
android:layout_marginRight="0dp"
android:layout_marginBottom="0dp" />
<!-- Виджет координат: нижний inset задаётся в MainActivity (system bar) --> <!-- Виджет координат: contentH считает сам (от размера шрифтов),
к нему MainActivity добавляет bottom inset под нав-бар. -->
<com.grigowashere.aismap.view.CoordinatesDockWidget <com.grigowashere.aismap.view.CoordinatesDockWidget
android:id="@+id/coordinates_widget" android:id="@+id/coordinates_widget"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="80dp" android:layout_height="wrap_content"
android:layout_alignParentBottom="true" android:layout_alignParentBottom="true"
android:layout_marginLeft="0dp"
android:layout_marginTop="0dp"
android:layout_marginRight="0dp"
android:layout_marginBottom="0dp"
android:elevation="2dp" /> android:elevation="2dp" />
<!-- Виджет «Опасные цели»: высота зависит от числа целей в зоне опасности
(rowH × N + title + паддинги). Когда целей нет — GONE, карта чистая. -->
<com.grigowashere.aismap.view.DangerTargetsDockWidget
android:id="@+id/danger_targets_widget"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_above="@id/coordinates_widget"
android:elevation="3dp"
android:visibility="gone" />
<!-- Панель управления (после координат в Z-order — не перекрывается снизу) --> <!-- Панель управления (после координат в Z-order — не перекрывается снизу) -->
<LinearLayout <LinearLayout
android:id="@+id/control_panel" android:id="@+id/control_panel"
@@ -60,6 +97,17 @@
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:layout_marginBottom="8dp" /> android:layout_marginBottom="8dp" />
<ImageButton
android:id="@+id/btn_navigator_follow"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/button_background"
android:src="@drawable/sail"
android:contentDescription="@string/main_navigator_button"
android:padding="8dp"
android:scaleType="fitCenter"
android:layout_marginBottom="8dp" />
<ImageButton <ImageButton
android:id="@+id/btn_map_orientation" android:id="@+id/btn_map_orientation"
android:layout_width="40dp" android:layout_width="40dp"
@@ -114,52 +162,64 @@
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:layout_marginTop="8dp" /> android:layout_marginTop="8dp" />
<ImageButton
android:id="@+id/btn_radar_plotter"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/button_background"
android:src="@drawable/ic_radar_plotter"
android:contentDescription="@string/radar_plotter_button"
android:padding="8dp"
android:scaleType="fitCenter"
android:layout_marginTop="8dp" />
<!-- Строки возраста последних сообщений GPS ($) и AIS (!) --> <!-- Компактный блок статусов: GPS / AIS возраст, BLE RSSI / батарея, FPS.
Уменьшены и шрифт (10sp), и отступы — на телефоне это даёт лишние
~30dp по высоте, не теряя читаемости. -->
<TextView <TextView
android:id="@+id/tv_gps_age" android:id="@+id/tv_gps_age"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="GPS: --" android:text="GPS: --"
android:textSize="11sp"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:layout_marginTop="8dp"/> android:textSize="10sp" />
<TextView <TextView
android:id="@+id/tv_ais_age" android:id="@+id/tv_ais_age"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="AIS: --" android:text="AIS: --"
android:textSize="11sp"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:layout_marginTop="4dp"/> android:textSize="10sp" />
<TextView <TextView
android:id="@+id/tv_ble_rssi" android:id="@+id/tv_ble_rssi"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="BLE RSSI: --" android:text="BLE RSSI: --"
android:textSize="11sp"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:layout_marginTop="4dp"/> android:textSize="10sp" />
<TextView <TextView
android:id="@+id/tv_ble_batt" android:id="@+id/tv_ble_batt"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="BLE Batt: --" android:text="BLE Batt: --"
android:textSize="11sp"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:layout_marginTop="2dp"/> android:textSize="10sp" />
<TextView <TextView
android:id="@+id/tv_fps" android:id="@+id/tv_fps"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="FPS: --" android:text="FPS: --"
android:textSize="11sp"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:layout_marginTop="4dp"/> android:textSize="10sp" />
</LinearLayout> </LinearLayout>
@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/radar_plotter_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/plotter_bezel_background"
tools:context=".RadarPlotterActivity">
<!-- Верхняя панель: заголовок + назад -->
<LinearLayout
android:id="@+id/radar_top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="8dp"
android:paddingEnd="12dp"
android:paddingBottom="4dp">
<ImageButton
android:id="@+id/btn_radar_back"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/button_background"
android:contentDescription="@string/radar_plotter_back"
android:padding="8dp"
android:scaleType="fitCenter"
android:src="@android:drawable/ic_menu_revert" />
<TextView
android:id="@+id/tv_radar_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="@string/radar_plotter_title"
android:textColor="@color/plotter_text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_radar_range"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/plotter_text_accent"
android:textSize="12sp"
tools:text="5.0 nm" />
</LinearLayout>
<!-- Альбом: PPI слева, приборы справа (портрет — layout-port) -->
<LinearLayout
android:id="@+id/radar_plotter_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="46dp"
android:layout_marginBottom="4dp"
android:orientation="horizontal"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:baselineAligned="false">
<!-- Область PPI / радара -->
<FrameLayout
android:id="@+id/radar_viewport_frame"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1.15"
android:background="@drawable/plotter_radar_viewport_bg"
android:padding="4dp">
<org.maplibre.android.maps.MapView
android:id="@+id/radar_map_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.grigowashere.aismap.view.RadarGraticuleOverlay
android:id="@+id/radar_graticule"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
<!-- Правая колонка: компас+спидометр в ряд, таблица -->
<LinearLayout
android:id="@+id/radar_instruments_panel"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="4dp"
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
android:id="@+id/plotter_instruments_row"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginBottom="4dp"
android:layout_weight="0.38"
android:baselineAligned="false"
android:orientation="horizontal">
<com.grigowashere.aismap.view.PlotterHeadingView
android:id="@+id/plotter_heading"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/plotter_panel_background" />
<com.grigowashere.aismap.view.PlotterSpeedometerView
android:id="@+id/plotter_speedometer"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:background="@drawable/plotter_panel_background" />
</LinearLayout>
<com.grigowashere.aismap.view.PlotterTargetsTableView
android:id="@+id/plotter_targets_table"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="@drawable/plotter_panel_background" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
File diff suppressed because it is too large Load Diff
@@ -20,7 +20,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:text="🚢 AIS СУДНО" android:text="AIS СУДНО"
android:textSize="18sp" android:textSize="18sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@android:color/black" /> android:textColor="@android:color/black" />
@@ -51,7 +51,7 @@
android:id="@+id/bottom_sheet_ais_time_ago" android:id="@+id/bottom_sheet_ais_time_ago"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="⏱️ Время назад: --" android:text="Время назад: --"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -62,7 +62,7 @@
android:id="@+id/bottom_sheet_ais_mmsi" android:id="@+id/bottom_sheet_ais_mmsi"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🆔 MMSI: --" android:text="MMSI: --"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -75,7 +75,7 @@
android:id="@+id/bottom_sheet_ais_callsign" android:id="@+id/bottom_sheet_ais_callsign"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="📻 Позывной: --" android:text="Позывной: --"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -87,7 +87,7 @@
android:id="@+id/bottom_sheet_ais_imo" android:id="@+id/bottom_sheet_ais_imo"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🏷️ IMO: --" android:text="IMO: --"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -99,7 +99,7 @@
android:id="@+id/bottom_sheet_ais_type" android:id="@+id/bottom_sheet_ais_type"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🚢 Тип: --" android:text="Тип: --"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -111,7 +111,7 @@
android:id="@+id/bottom_sheet_ais_position" android:id="@+id/bottom_sheet_ais_position"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="📍 Координаты: --" android:text="Координаты: --"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -130,7 +130,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:text="🧭 COG: --°" android:text="COG: --°"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -143,7 +143,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:text="🧭 HDG: --°" android:text="HDG: --°"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -156,7 +156,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:text="🔄 ROT: --°/мин" android:text="ROT: --°/мин"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -170,7 +170,7 @@
android:id="@+id/bottom_sheet_ais_speed" android:id="@+id/bottom_sheet_ais_speed"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Скорость: -- узлов" android:text="Скорость: -- узлов"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -182,7 +182,7 @@
android:id="@+id/bottom_sheet_ais_dimensions" android:id="@+id/bottom_sheet_ais_dimensions"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="📏 Размеры: --" android:text="Размеры: --"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -194,7 +194,7 @@
android:id="@+id/bottom_sheet_ais_draft" android:id="@+id/bottom_sheet_ais_draft"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🌊 Осадка: -- м" android:text="Осадка: -- м"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -206,7 +206,7 @@
android:id="@+id/bottom_sheet_ais_destination" android:id="@+id/bottom_sheet_ais_destination"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🎯 Назначение: --" android:text="Назначение: --"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -218,7 +218,7 @@
android:id="@+id/bottom_sheet_ais_eta" android:id="@+id/bottom_sheet_ais_eta"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="ETA: --" android:text="ETA: --"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -230,7 +230,7 @@
android:id="@+id/bottom_sheet_ais_nav_status" android:id="@+id/bottom_sheet_ais_nav_status"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🚦 Статус: --" android:text="Статус: --"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -242,7 +242,7 @@
android:id="@+id/bottom_sheet_ais_class" android:id="@+id/bottom_sheet_ais_class"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="📋 Класс: --" android:text="Класс: --"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -254,7 +254,7 @@
android:id="@+id/bottom_sheet_ais_signal" android:id="@+id/bottom_sheet_ais_signal"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="📶 Сигнал: --" android:text="Сигнал: --"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -266,7 +266,7 @@
android:id="@+id/bottom_sheet_ais_distance" android:id="@+id/bottom_sheet_ais_distance"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="📏 Расстояние: --" android:text="Расстояние: --"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -278,7 +278,7 @@
android:id="@+id/bottom_sheet_ais_bearing" android:id="@+id/bottom_sheet_ais_bearing"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🧭 Пеленг: --" android:text="Пеленг: --"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
@@ -290,7 +290,7 @@
android:id="@+id/bottom_sheet_ais_last_update" android:id="@+id/bottom_sheet_ais_last_update"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🕐 Обновлено: --" android:text="Обновлено: --"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
+29
View File
@@ -2,4 +2,33 @@
<resources> <resources>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<!-- Кольца дальности вокруг собственного судна.
Альфа-каналы подобраны так, чтобы линии и заливки не перекрывали карту. -->
<color name="range_ring_danger_stroke">#FFD32F2F</color>
<color name="range_ring_danger_fill">#22D32F2F</color>
<color name="range_ring_warning_stroke">#FFFFA000</color>
<color name="range_ring_warning_fill">#1AFFA000</color>
<color name="range_ring_filter_stroke">#FF1976D2</color>
<color name="range_ring_filter_fill">#001976D2</color>
<color name="range_target_warning_halo">#FFFFC107</color>
<!-- Баннер «нет связи» -->
<color name="connection_lost_bg">#E6B71C1C</color>
<color name="connection_lost_text">#FFFFFFFF</color>
<!-- Режим «картплоттер / радар» -->
<color name="plotter_bezel_dark">#FF0A0E12</color>
<color name="plotter_bezel_light">#FF1E2830</color>
<color name="plotter_bezel_highlight">#FF4A5A66</color>
<color name="plotter_panel_bg">#E612181C</color>
<color name="plotter_panel_stroke">#FF3D4F3A</color>
<color name="plotter_radar_bg">#88041008</color>
<color name="plotter_radar_grid">#5533FF66</color>
<color name="plotter_radar_grid_bright">#AA66FF99</color>
<color name="plotter_radar_sweep">#5533FF66</color>
<color name="plotter_text_primary">#FFE8F0E4</color>
<color name="plotter_text_label">#FF7FA88A</color>
<color name="plotter_text_accent">#FFFFB74D</color>
<color name="plotter_target_blip">#FF66FF99</color>
</resources> </resources>
+161
View File
@@ -1,3 +1,164 @@
<resources> <resources>
<string name="app_name">AISMap</string> <string name="app_name">AISMap</string>
<!-- ===== SettingsActivity ===== -->
<string name="settings_title">Настройки AIS Map</string>
<string name="settings_section_interfaces">Интерфейсы</string>
<string name="settings_open_interfaces_hint">Интерфейсы (UDP / BLE)</string>
<string name="settings_open_interfaces_helper">Перейти к настройкам UDP, BLE и UDP-bridge</string>
<string name="settings_open_interfaces_value">Открыть настройки интерфейсов</string>
<string name="settings_open_interfaces_action">Открыть</string>
<string name="settings_section_path">Путь и предсказание</string>
<string name="settings_path_max_points_hint">Максимум точек на судно</string>
<string name="settings_path_max_points_helper">Ограничение размера истории пути</string>
<string name="settings_path_width_hint">Толщина линии пути (px)</string>
<string name="settings_path_color_hint">Цвет пути (#RRGGBB)</string>
<string name="settings_prediction_width_hint">Толщина линии предсказания (px)</string>
<string name="settings_prediction_color_hint">Цвет предсказания (#RRGGBB)</string>
<string name="settings_prediction_horizon_hint">Горизонт предсказания (сек)</string>
<string name="settings_clear_path">Очистить трекер пути</string>
<string name="settings_clear_path_helper">Удаляет все сохранённые точки пути собственного судна</string>
<string name="settings_section_gps_source">Источник координат</string>
<string name="settings_gps_source_hint">Откуда приложение берёт позицию собственного судна.</string>
<string name="settings_gps_source_hub">AIS Hub (BLE)</string>
<string name="settings_gps_source_hub_hint">Позиция и AIS-цели приходят из внешнего AIS Hub по BLE.</string>
<string name="settings_gps_source_android">Android GPS</string>
<string name="settings_gps_source_android_hint">Встроенный GPS устройства (+опциональный внешний NMEA).</string>
<string name="settings_section_range_rings">Зоны вокруг судна</string>
<string name="settings_range_rings_hint">Кольца дальности позволяют выделить опасные цели и ограничить зону отображения.</string>
<string name="settings_range_rings_enabled">Показывать кольца на карте</string>
<string name="settings_range_units">Единицы измерения</string>
<string name="settings_range_unit_nm">Морские мили (nm)</string>
<string name="settings_range_unit_km">Километры (км)</string>
<string name="settings_range_danger_hint">Радиус зоны опасности</string>
<string name="settings_range_danger_helper">Цели в этой зоне отображаются в виджете и считаются опасными</string>
<string name="settings_range_warning_hint">Радиус зоны предупреждения</string>
<string name="settings_range_warning_helper">Цели в этой зоне подсвечиваются на карте</string>
<string name="settings_range_filter_hint">Радиус зоны фильтра</string>
<string name="settings_range_filter_helper">Скрывает цели, расположенные дальше указанного радиуса</string>
<string name="settings_range_filter_enabled">Скрывать цели за пределами зоны фильтра</string>
<string name="settings_range_validation_order">Радиусы должны быть возрастающими: опасность &lt; предупреждение &lt; фильтр</string>
<string name="settings_range_validation_positive">Все радиусы должны быть положительными</string>
<string name="settings_section_advanced_nmea">Расширенные NMEA-источники</string>
<string name="settings_advanced_nmea_hint">Открыть старые настройки Android NMEA / UDP NMEA / режимы данных. Нужны только если вы работаете без AIS Hub.</string>
<string name="settings_advanced_nmea_caption">Выберите источники данных для получения координат и навигационной информации:</string>
<string name="settings_android_nmea_enabled">Android NMEA (GPS API)</string>
<string name="settings_android_nmea_helper">Использовать встроенный GPS Android для получения координат</string>
<string name="settings_udp_nmea_enabled">UDP NMEA</string>
<string name="settings_udp_nmea_helper">Получать NMEA данные через UDP (курс, скорость, спутники)</string>
<string name="settings_data_mode">Режим работы</string>
<string name="settings_data_mode_hybrid">Гибридный режим (рекомендуется)</string>
<string name="settings_data_mode_hybrid_hint">Координаты от Android GPS, остальное от NMEA</string>
<string name="settings_data_mode_nmea">Только NMEA</string>
<string name="settings_data_mode_nmea_hint">Все данные только из NMEA сообщений</string>
<string name="settings_data_mode_android">Только Android GPS</string>
<string name="settings_data_mode_android_hint">Только встроенный GPS Android</string>
<string name="settings_section_stale_data">Устаревание данных AIS</string>
<string name="settings_stale_caption">Настройте время, через которое данные о судах считаются устаревшими:</string>
<string name="settings_stale_warning_hint">Время предупреждения (минуты)</string>
<string name="settings_stale_warning_helper">Суда старше этого времени будут помечены как устаревшие</string>
<string name="settings_stale_remove_hint">Время удаления (минуты)</string>
<string name="settings_stale_remove_helper">Суда старше этого времени будут удалены с карты</string>
<string name="settings_stale_tip">Устаревшие суда отображаются с иконкой потери цели</string>
<string name="settings_section_navigator_camera">Навигаторская камера</string>
<string name="settings_navigator_camera_hint">Карта следует за судном; зум зависит от скорости (0 уз — ближе, макс. скорость — дальше).</string>
<string name="settings_navigator_camera_enabled">Следовать за судном</string>
<string name="settings_navigator_camera_helper">Долгое нажатие на кнопку судна на карте также включает и выключает режим</string>
<string name="settings_navigator_max_speed_hint">Макс. скорость (уз)</string>
<string name="settings_navigator_max_speed_helper">При этой скорости и выше используется минимальный зум</string>
<string name="settings_navigator_zoom_zero_hint">Зум при 0 уз</string>
<string name="settings_navigator_zoom_zero_helper">Максимальное приближение (большее число)</string>
<string name="settings_navigator_zoom_max_hint">Зум при макс. скорости</string>
<string name="settings_navigator_zoom_max_helper">Максимальное отдаление (меньшее число)</string>
<string name="main_navigator_on">Навигатор: следование за судном</string>
<string name="main_navigator_off">Навигатор выключен</string>
<string name="main_navigator_button">Режим навигатора</string>
<string name="settings_section_screen">Управление экраном</string>
<string name="settings_screen_hint">Настройте поведение экрана во время навигации:</string>
<string name="settings_keep_screen_on">Не давать экрану засыпать</string>
<string name="settings_keep_screen_on_helper">Экран будет оставаться включенным во время навигации (рекомендуется для навигатора)</string>
<string name="settings_section_notifications">Уведомления о новых целях AIS</string>
<string name="settings_notifications_hint">Настройте уведомления при обнаружении новых судов:</string>
<string name="settings_vibration">Вибрация</string>
<string name="settings_vibration_helper">Вибрация устройства при обнаружении нового судна</string>
<string name="settings_sound">Звуковое уведомление</string>
<string name="settings_sound_helper">Звуковой сигнал при обнаружении нового судна</string>
<string name="settings_section_debug">Режим отладки</string>
<string name="settings_debug_hint">Включает расширенное логирование и диагностические элементы UI.</string>
<string name="settings_debug_enabled">Включить режим отладки</string>
<string name="settings_section_seamarks">Морские знаки OpenSeaMap</string>
<string name="settings_seamarks_hint">Отображать морские знаки (буи, маяки, навигационные знаки) поверх карты.</string>
<string name="settings_seamarks_enabled">Показывать морские знаки</string>
<string name="settings_seamarks_tip">Источник: OpenSeaMap.org — открытая база данных морских знаков</string>
<string name="settings_action_save">Сохранить</string>
<string name="settings_action_cancel">Отмена</string>
<!-- ===== InterfacesSettingsActivity ===== -->
<string name="interfaces_title">Интерфейсы: UDP и BLE</string>
<string name="interfaces_section_udp">UDP</string>
<string name="interfaces_udp_port_hint">UDP порт</string>
<string name="interfaces_udp_port_helper">Порт для прослушивания AIS данных</string>
<string name="interfaces_udp_enabled">Включить UDP-слушатель</string>
<string name="interfaces_section_ble">BLE</string>
<string name="interfaces_ble_enabled">Включить BLE-источник NMEA</string>
<string name="interfaces_ble_mac_hint">MAC-адрес BLE устройства</string>
<string name="interfaces_ble_mac_helper">Например: 01:23:45:67:89:AB</string>
<string name="interfaces_ble_scan">Сканировать BLE</string>
<string name="interfaces_ble_stop">Стоп</string>
<string name="interfaces_ble_battery_enabled">Читать уровень батареи AIS Hub</string>
<string name="interfaces_ble_battery_helper">Может вызывать запрос сопряжения на некоторых устройствах. Рекомендуется выключить, если периодически появляется системное окно «Сопряжение не выполнено».</string>
<string name="interfaces_section_bridge">BLE → UDP мост</string>
<string name="interfaces_bridge_enabled">Включить UDP-bridge (пересылать NMEA)</string>
<string name="interfaces_bridge_host_hint">UDP host (назначение)</string>
<string name="interfaces_bridge_port_hint">UDP port (назначение)</string>
<!-- ===== MainActivity (баннер связи и виджет опасности) ===== -->
<string name="banner_connection_lost_ble">Потеряна связь с устройством</string>
<string name="banner_pairing_required">BLE требует сопряжения. Проверьте устройство в настройках Bluetooth.</string>
<string name="banner_icon_description">Иконка предупреждения связи</string>
<string name="danger_widget_title">Опасные цели</string>
<string name="danger_widget_empty">В зоне опасности нет целей</string>
<string name="danger_widget_column_target">Цель</string>
<string name="danger_widget_column_bearing">Пел.</string>
<string name="danger_widget_column_distance">Дист.</string>
<!-- ===== Подписи в навигационных виджетах (компас + координаты) ===== -->
<string name="compass_label_heading">КУРС</string>
<string name="compass_label_mag">МАГН.</string>
<string name="coords_label_position">КООРДИНАТЫ</string>
<string name="coords_label_sog">СКОР.</string>
<string name="coords_label_cog">ПУТЬ</string>
<string name="coords_label_acc">ТОЧН.</string>
<string name="coords_value_no_fix">нет фикса</string>
<!-- ===== Режим картплоттера / «тупого радара» ===== -->
<string name="radar_plotter_title">Радар / картплоттер</string>
<string name="radar_plotter_button">Режим радара</string>
<string name="radar_plotter_no_coordinator">Вернитесь на карту — данные AIS недоступны</string>
<string name="radar_plotter_range_label">Дальность</string>
<string name="radar_plotter_sog_label">СКОР.</string>
<string name="radar_plotter_heading_label">КУРС</string>
<string name="radar_plotter_table_title">Ближайшие цели</string>
<string name="radar_plotter_col_name">Цель</string>
<string name="radar_plotter_col_brg">Пел.</string>
<string name="radar_plotter_col_rng">Дист.</string>
<string name="radar_plotter_col_cpa">CPA</string>
<string name="radar_plotter_cpa_na"></string>
<string name="radar_plotter_table_empty">Нет целей в радиусе</string>
<string name="radar_plotter_back">К карте</string>
</resources> </resources>
+8
View File
@@ -18,4 +18,12 @@
<item name="android:windowDrawsSystemBarBackgrounds" tools:targetApi="21">true</item> <item name="android:windowDrawsSystemBarBackgrounds" tools:targetApi="21">true</item>
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="27">shortEdges</item> <item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="27">shortEdges</item>
</style> </style>
<!-- Полноэкранный картплоттер: тёмный фон, без action bar -->
<style name="Theme.AISMap.RadarPlotter" parent="Theme.AISMap">
<item name="android:windowFullscreen">true</item>
<item name="android:statusBarColor">@color/plotter_bezel_dark</item>
<item name="android:navigationBarColor">@color/plotter_bezel_dark</item>
<item name="android:windowBackground">@color/plotter_bezel_dark</item>
</style>
</resources> </resources>
@@ -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);
}
}
@@ -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);
}
}
+76 -2
View File
@@ -12,6 +12,8 @@ import json
import os import os
import queue import queue
import random import random
import re
import subprocess
import threading import threading
import struct import struct
import sys import sys
@@ -1340,7 +1342,9 @@ class DataCharacteristic(Characteristic):
class StatusCharacteristic(Characteristic): class StatusCharacteristic(Characteristic):
def __init__(self, bus, index, service: Service, bridge: AisHubBridge): 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.bridge = bridge
self.notifying = False self.notifying = False
@@ -1417,6 +1421,74 @@ def find_adapter(bus):
return None 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() ============ # ============ main() ============
def main(): def main():
@@ -1432,6 +1504,7 @@ def main():
return 1 return 1
log_info(f'Используем адаптер: {adapter_path}') log_info(f'Используем адаптер: {adapter_path}')
configure_adapter_for_open_le(bus, adapter_path)
service_manager = dbus.Interface( service_manager = dbus.Interface(
bus.get_object(BLUEZ_SERVICE_NAME, adapter_path), bus.get_object(BLUEZ_SERVICE_NAME, adapter_path),
@@ -1455,11 +1528,12 @@ def main():
if "Connected" not in changed: if "Connected" not in changed:
return return
connected = bool(changed.get("Connected")) connected = bool(changed.get("Connected"))
dev_path = str(path or "") dev_path = "" if path is None else str(path)
if not dev_path: if not dev_path:
return return
if connected: if connected:
log_info(f"[BlueZ] Device connected: {dev_path} sessions={bridge.session_count()}") 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) bridge.get_or_create_session(dev_path)
else: else:
log_info(f"[BlueZ] Device disconnected: {dev_path} -> removing session") log_info(f"[BlueZ] Device disconnected: {dev_path} -> removing session")