closd TG-6; Initial push after server migration

This commit is contained in:
2026-05-04 08:53:25 +03:00
parent 939f069681
commit 1009f49a59
93 changed files with 16246 additions and 9549 deletions
+8
View File
@@ -6,6 +6,10 @@ android {
namespace 'com.grigowashere.aismap'
compileSdk 35
buildFeatures {
buildConfig true
}
defaultConfig {
applicationId "com.grigowashere.aismap"
minSdk 30
@@ -56,6 +60,10 @@ dependencies {
// MapLibre GL Android SDK (используем только один артефакт, без плагина аннотаций)
implementation group: 'org.maplibre.gl', name: 'android-sdk-opengl', version: '11.13.5'
// MessagePack — компактная бинарная сериализация для BLE Hub снапшотов
// (см. ble_gatt.py: AIS_BLE_BROADCAST_ENCODING=msgpack)
implementation 'org.msgpack:msgpack-core:0.9.8'
// Тестирование
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+17 -2
View File
@@ -20,6 +20,13 @@
<!-- Разрешения для UDP -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- BLE permissions -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<!-- Разрешения для вибрации -->
<uses-permission android:name="android.permission.VIBRATE" />
@@ -32,7 +39,9 @@
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
<uses-feature android:name="android.hardware.location" android:required="false" />
<application
<application
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
@@ -47,7 +56,7 @@
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/Theme.AISMap"
android:theme="@style/Theme.AISMap.Map"
android:keepScreenOn="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -60,6 +69,12 @@
android:exported="false"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/Theme.AISMap" />
<activity
android:name=".settings.InterfacesSettingsActivity"
android:exported="false"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/Theme.AISMap" />
<activity
android:name=".AisTargetsActivity"
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -22,6 +22,10 @@ import android.view.WindowManager;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import com.google.android.material.bottomsheet.BottomSheetDialog;
import com.grigowashere.aismap.controllers.AppCoordinator;
@@ -64,6 +68,9 @@ public class MainActivity extends AppCompatActivity {
// Статическая переменная для отслеживания инициализации Яндекс.Карт
private static boolean isYandexMapsInitialized = false;
// Флаг для отслеживания первого запуска приложения
private boolean isFirstStart = true;
private AppCoordinator appCoordinator;
// UI binders
private MenuBinder menuBinder;
@@ -80,6 +87,7 @@ public class MainActivity extends AppCompatActivity {
private ImageButton btnCursorToggle;
private ImageButton btnSettings;
private ImageButton btnAisTargets;
private ImageButton btnGpsSource;
private LinearLayout controlPanel;
private CompassView compassView;
private CoordinatesDockWidget coordinatesWidget;
@@ -95,6 +103,36 @@ public class MainActivity extends AppCompatActivity {
private static final long UI_UPDATE_THROTTLE_MS = 200; // 5 FPS максимум
private TextView tvGpsAge;
private TextView tvAisAge;
private TextView tvBleRssi;
private TextView tvBleBatt;
private TextView tvFps;
private int frameCount = 0;
private long lastFpsTs = 0L;
private final android.view.Choreographer.FrameCallback fpsCallback = new android.view.Choreographer.FrameCallback() {
@Override public void doFrame(long frameTimeNanos) {
// UI heartbeat: если кадры идут, UI точно жив.
updateUIActivity();
frameCount++;
long now = System.currentTimeMillis();
if (lastFpsTs == 0L) lastFpsTs = now;
if (now - lastFpsTs >= 1000) {
final int fps = frameCount;
frameCount = 0;
lastFpsTs = now;
runOnUiThread(() -> {
if (tvFps != null) {
tvFps.setText("FPS: " + fps);
int color;
if (fps >= 55) color = android.graphics.Color.parseColor("#4CAF50");
else if (fps >= 40) color = android.graphics.Color.parseColor("#FFC107");
else color = android.graphics.Color.parseColor("#F44336");
tvFps.setTextColor(color);
}
});
}
android.view.Choreographer.getInstance().postFrameCallback(this);
}
};
private android.os.Handler messageAgeHandler;
private Runnable messageAgeRunnable;
private BottomSheetsManager bottomSheetsManager;
@@ -119,6 +157,10 @@ public class MainActivity extends AppCompatActivity {
private long lastUIUpdateTime = 0;
private static final long UI_WATCHDOG_INTERVAL = 1000; // 1 секунда - быстрая диагностика
private static final long UI_TIMEOUT = 3000; // 3 секунды без обновлений = зависание
private final java.util.concurrent.ScheduledExecutorService uiWatchdogScheduler =
java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
private volatile long lastUiPongUptimeMs = 0L;
private volatile long lastUiHangLogUptimeMs = 0L;
// Диагностика компаса
private long lastCompassLogTime = 0;
@@ -144,7 +186,18 @@ public class MainActivity extends AppCompatActivity {
} catch (Exception e) {
Log.e(TAG, "Ошибка инициализации MapLibre: " + e.getMessage(), e);
}
// Edge-to-edge: приложение само раскладывает UI под статус/нав-барами
// и вырезами камеры. Без этого WindowInsets будут давать нули
// и координатная панель уедет под системную навигационную кнопку.
try {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
getWindow().setStatusBarColor(android.graphics.Color.TRANSPARENT);
getWindow().setNavigationBarColor(android.graphics.Color.TRANSPARENT);
} catch (Exception e) {
Log.w(TAG, "Не удалось включить edge-to-edge: " + e.getMessage());
}
setContentView(R.layout.activity_main);
initializeViews();
@@ -165,9 +218,11 @@ public class MainActivity extends AppCompatActivity {
btnCursorToggle = findViewById(R.id.btn_cursor_toggle);
btnSettings = findViewById(R.id.btn_settings);
btnAisTargets = findViewById(R.id.btn_ais_targets);
btnGpsSource = findViewById(R.id.btn_gps_source);
controlPanel = findViewById(R.id.control_panel);
compassView = findViewById(R.id.compass_view);
coordinatesWidget = findViewById(R.id.coordinates_widget);
installMainUiInsets();
// Инициализируем троттлинг
uiThrottleHandler = new android.os.Handler(android.os.Looper.getMainLooper());
@@ -206,6 +261,10 @@ public class MainActivity extends AppCompatActivity {
}
}
}
// В режимах «по компасу» / «по курсу» непрерывно подстраиваем bearing
// карты; в «вручную» не трогаем — пользователь крутит жестом.
applyAutoMapBearingIfNeeded(mapIf);
}
} catch (Exception ignore) {}
// Планируем следующее обновление
@@ -215,6 +274,9 @@ public class MainActivity extends AppCompatActivity {
};
tvGpsAge = findViewById(R.id.tv_gps_age);
tvAisAge = findViewById(R.id.tv_ais_age);
tvBleRssi = findViewById(R.id.tv_ble_rssi);
tvBleBatt = findViewById(R.id.tv_ble_batt);
tvFps = findViewById(R.id.tv_fps);
// Инициализируем магнитный компас через CompassController
// compassSensor = new CompassSensor(this); // Удалено - теперь используется CompassController
@@ -231,11 +293,17 @@ public class MainActivity extends AppCompatActivity {
private void setupButtonListeners() {
if (btnCenterOnVessel != null) btnCenterOnVessel.setOnClickListener(v -> centerOnVessel());
if (btnMapOrientation != null) btnMapOrientation.setOnClickListener(v -> toggleMapOrientation());
if (btnMapOrientation != null) {
btnMapOrientation.setOnClickListener(v -> cycleMapRotationMode());
}
if (btnCursorToggle != null) btnCursorToggle.setOnClickListener(v -> toggleCursor());
if (btnCursorToggle != null) btnCursorToggle.setOnLongClickListener(v -> { toggleCursor(); return true; });
if (btnSettings != null) btnSettings.setOnClickListener(v -> showSettings());
if (btnAisTargets != null) btnAisTargets.setOnClickListener(v -> openAisTargets());
if (btnGpsSource != null) {
refreshGpsSourceButtonIcon();
btnGpsSource.setOnClickListener(v -> toggleGpsSource());
}
// Кнопка для показа информации о судне
// Button btnShowVesselInfo = findViewById(R.id.btn_show_vessel_info);
@@ -252,6 +320,9 @@ public class MainActivity extends AppCompatActivity {
compassView.post(() -> {
compassView.setDocked(true, true, 0, 0);
compassView.invalidate(); // Принудительная отрисовка
// Выровнять паддинги под статус-бар/вырез камеры сразу после
// первого dock-позиционирования (до этого сторона неизвестна).
reapplyInsetsToDocks();
});
// Настраиваем слушатель изменения размера док-виджета
@@ -269,6 +340,9 @@ public class MainActivity extends AppCompatActivity {
BaseDockWidget.repositionAllDockedWidgets((ViewGroup) compassView.getParent());
updateControlPanelPosition();
// Док мог поменять сторону — паддинги под системные бары
// тоже должны переключиться (top <-> bottom).
reapplyInsetsToDocks();
});
//smt changed
// Настраиваем магнитный компас через CompassController
@@ -362,6 +436,9 @@ public class MainActivity extends AppCompatActivity {
BaseDockWidget.repositionAllDockedWidgets((ViewGroup) coordinatesWidget.getParent());
updateControlPanelPosition();
// Перекидываем системные паддинги в нужную сторону под новую
// дока — чтобы под нав-баром/брови ничего не оставалось.
reapplyInsetsToDocks();
});
// Устанавливаем виджет координат в dock-режим внизу экрана без тестовых данных
@@ -369,6 +446,10 @@ public class MainActivity extends AppCompatActivity {
Log.d(TAG, "Setting coordinates widget to dock mode");
coordinatesWidget.setDocked(true, false, 0, 0); // false = dock снизу
coordinatesWidget.invalidate(); // Принудительная отрисовка
// Только сейчас мы знаем сторону дока (bottom) — переприменяем
// инсеты, чтобы виджет получил bottom padding под нав-бар
// сразу, а не только после первого ресайза пользователем.
reapplyInsetsToDocks();
});
}
@@ -390,6 +471,20 @@ public class MainActivity extends AppCompatActivity {
tvAisAge.setText(aisSec >= 0 ? ("AIS: " + aisSec + " сек назад") : "AIS: --");
tvAisAge.setTextColor(getAgeColor(aisSec));
}
if (tvBleRssi != null) {
Integer rssi = appCoordinator.getLastBleRssi();
if (rssi != null) {
tvBleRssi.setText("BLE RSSI: " + rssi);
tvBleRssi.setTextColor(getRssiColor(rssi));
} else {
tvBleRssi.setText("BLE RSSI: --");
tvBleRssi.setTextColor(android.graphics.Color.parseColor("#F44336"));
}
}
if (tvBleBatt != null) {
Integer batt = appCoordinator.getLastBleBattery();
tvBleBatt.setText(batt != null ? ("BLE Batt: " + batt + "%") : "BLE Batt: --");
}
}
} catch (Exception ignored) {}
messageAgeHandler.postDelayed(this, 1000);
@@ -412,6 +507,17 @@ public class MainActivity extends AppCompatActivity {
return Color.parseColor("#F44336"); // красный
}
}
private int getRssiColor(int rssi) {
// Типичные пороги: >= -60 dBm (сильный) — зелёный; >= -80 dBm (средний) — жёлтый; иначе — красный
if (rssi >= -60) {
return android.graphics.Color.parseColor("#4CAF50");
} else if (rssi >= -80) {
return android.graphics.Color.parseColor("#FFC107");
} else {
return android.graphics.Color.parseColor("#F44336");
}
}
private void onUpdateCompass(float azimuth, List<AISVessel> nearbyVessels) {
if (compassView != null) {
@@ -474,42 +580,50 @@ public class MainActivity extends AppCompatActivity {
* Настраивает UI watchdog для отслеживания зависаний
*/
private void setupUIWatchdog() {
// ВАЖНО: watchdog не должен работать на UI Looper, иначе он не может детектить настоящий hang.
// Поэтому тикер в фоне, а "pong" — маленькая задачка на UI.
uiWatchdogHandler = new android.os.Handler(android.os.Looper.getMainLooper());
uiWatchdogRunnable = new Runnable() {
@Override
public void run() {
long currentTime = System.currentTimeMillis();
long timeSinceLastUpdate = currentTime - lastUIUpdateTime;
if (timeSinceLastUpdate > UI_TIMEOUT) {
Log.e(TAG, "🚨 UI WATCHDOG: UI ЗАВИС! Последнее обновление " +
(timeSinceLastUpdate / 1000) + " секунд назад");
Log.e(TAG, "🚨 UI WATCHDOG: Время зависания: " + new java.util.Date(currentTime));
Log.e(TAG, "🚨 UI WATCHDOG: Thread: " + Thread.currentThread().getName());
// Дамп стека главного потока и нескольких рабочих потоков
dumpThreadStacksForDiagnostics();
// Попытка восстановления
tryRecoverFromUIHang();
} else {
// Логируем каждые 10 секунд для мониторинга
if (timeSinceLastUpdate > 0 && (timeSinceLastUpdate / 1000) % 10 == 0) {
Log.i(TAG, "✅ UI WATCHDOG: UI активен, последнее обновление " +
(timeSinceLastUpdate / 1000) + " секунд назад");
lastUIUpdateTime = System.currentTimeMillis();
lastUiPongUptimeMs = android.os.SystemClock.uptimeMillis();
// Заглушка для обратной совместимости (на него ссылаются логи/tryRecoverFromUIHang)
uiWatchdogRunnable = () -> {};
try {
uiWatchdogScheduler.scheduleAtFixedRate(() -> {
final long pingUptime = android.os.SystemClock.uptimeMillis();
try {
uiWatchdogHandler.post(() -> {
// "pong": если это исполнилось — UI Looper жив.
lastUiPongUptimeMs = android.os.SystemClock.uptimeMillis();
updateUIActivity();
});
} catch (Throwable ignore) {}
long sincePong = pingUptime - lastUiPongUptimeMs;
if (sincePong > UI_TIMEOUT) {
// Не спамим логом каждую секунду.
if (pingUptime - lastUiHangLogUptimeMs > 10_000L) {
lastUiHangLogUptimeMs = pingUptime;
Log.e(TAG, "🚨 UI WATCHDOG: UI возможно завис (main looper не отвечает) " +
(sincePong / 1000) + " секунд");
dumpThreadStacksForDiagnosticsAsync();
// Recovery — только если UI хоть как-то отвечает (иначе бесполезно)
tryRecoverFromUIHang();
}
}
// Планируем следующую проверку
uiWatchdogHandler.postDelayed(this, UI_WATCHDOG_INTERVAL);
}
};
// Запускаем watchdog
lastUIUpdateTime = System.currentTimeMillis();
uiWatchdogHandler.postDelayed(uiWatchdogRunnable, UI_WATCHDOG_INTERVAL);
Log.i(TAG, "UI watchdog запущен");
}, UI_WATCHDOG_INTERVAL, UI_WATCHDOG_INTERVAL, java.util.concurrent.TimeUnit.MILLISECONDS);
} catch (Throwable t) {
Log.e(TAG, "UI watchdog: не удалось запустить scheduler: " + t.getMessage(), t);
}
Log.i(TAG, "UI watchdog запущен (background)");
}
private final java.util.concurrent.ExecutorService watchdogExecutor =
java.util.concurrent.Executors.newSingleThreadExecutor();
private volatile long lastRecoveryAttemptMs = 0L;
/**
* Обновляет время последней активности UI
*/
@@ -531,6 +645,14 @@ public class MainActivity extends AppCompatActivity {
Log.w(TAG, "UI WATCHDOG: Попытка восстановления...");
try {
long now = System.currentTimeMillis();
// Не долбим восстановлением каждую секунду — это само может стать причиной лагов
if (now - lastRecoveryAttemptMs < 10_000L) {
Log.i(TAG, "UI WATCHDOG: восстановление пропущено (throttle)");
return;
}
lastRecoveryAttemptMs = now;
// Диагностика: проверяем состояние handler'ов
boolean watchdogActive = uiWatchdogHandler != null && uiWatchdogRunnable != null;
boolean messageAgeActive = messageAgeHandler != null && messageAgeRunnable != null;
@@ -544,11 +666,12 @@ public class MainActivity extends AppCompatActivity {
", controlPanel=" + controlPanelActive +
", controlPanelCount=" + controlPanelUpdateCount);
// Принудительная сборка мусора
System.gc();
// ВАЖНО: никаких тяжёлых операций (System.gc) на UI-потоке.
// Если нужно, можно поставить фоновой GC после лагов, но это диагностическая функция,
// а не recovery, поэтому здесь намеренно ничего не делаем.
// Проверяем состояние основных компонентов
if (mapController.getCurrentMapInterface() == null) {
if (mapController != null && mapController.getCurrentMapInterface() == null) {
Log.w(TAG, "UI WATCHDOG: mapInterface == null, переинициализируем карту");
// Можно попробовать переинициализировать карту
}
@@ -580,7 +703,15 @@ public class MainActivity extends AppCompatActivity {
/**
* Диагностический дамп стеков главного и рабочих потоков
*/
private void dumpThreadStacksForDiagnostics() {
private void dumpThreadStacksForDiagnosticsAsync() {
try {
watchdogExecutor.execute(this::dumpThreadStacksForDiagnosticsBlocking);
} catch (Throwable t) {
Log.e(TAG, "UI WATCHDOG: не удалось запустить дамп стеков: " + t.getMessage(), t);
}
}
private void dumpThreadStacksForDiagnosticsBlocking() {
try {
java.util.Map<Thread, StackTraceElement[]> all = Thread.getAllStackTraces();
Thread main = Looper.getMainLooper().getThread();
@@ -675,6 +806,32 @@ public class MainActivity extends AppCompatActivity {
Log.i(TAG, "Режим экрана переключен: keepScreenOn=" + keepScreenOn);
}
/**
* Переключает отображение морских знаков OpenSeaMap
*/
public void toggleSeamarks() {
if (settingsManager == null) {
Log.w(TAG, "toggleSeamarks: settingsManager is null");
return;
}
boolean currentState = settingsManager.isSeamarksEnabled();
boolean newState = !currentState;
// Сохраняем настройку
settingsManager.setSeamarksEnabled(newState);
// Применяем изменения на карте
if (mapController.getCurrentMapInterface() instanceof MapLibreMapImpl) {
((MapLibreMapImpl) mapController.getCurrentMapInterface()).updateAdditionalLayers();
}
String message = newState ? "Морские знаки включены" : "Морские знаки выключены";
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
Log.i(TAG, "Морские знаки переключены: enabled=" + newState);
}
private void initializeControllers() {
// Инициализация менеджера настроек
settingsManager = new SettingsManager(this);
@@ -694,6 +851,7 @@ public class MainActivity extends AppCompatActivity {
@Override public void togglePathTracking() { MainActivity.this.togglePathTracking(); }
@Override public void testForegroundService() { MainActivity.this.testForegroundService(); }
@Override public void toggleKeepScreenOn() { MainActivity.this.toggleKeepScreenOn(); }
@Override public void toggleSeamarks() { MainActivity.this.toggleSeamarks(); }
});
// Не используем BottomSheetsBinder, оставляем рабочую реализацию в MainActivity
permissionsBinder = new PermissionsBinder(this);
@@ -751,6 +909,7 @@ public class MainActivity extends AppCompatActivity {
}
}
});
refreshMapRotationButtonDescription();
}
private void startControllers() {
@@ -851,20 +1010,106 @@ public class MainActivity extends AppCompatActivity {
Toast.makeText(this, "Карта центрирована на судне", Toast.LENGTH_SHORT).show();
}
private void toggleMapOrientation() {
if (mapController.getCurrentMapInterface() == null) return;
private static float normalizeBearingTo360(double deg) {
double x = deg % 360.0;
if (x < 0) x += 360.0;
return (float) x;
}
/**
* Три режима: по магнитному компасу, по курсу (COG), вручную
* (север вверх при переключении, дальше — только жесты пользователя).
* Кнопка циклически переключает: компас → курс → вручную → …
*/
private void cycleMapRotationMode() {
if (settingsManager == null) return;
String mode = settingsManager.cycleMapRotationMode();
refreshMapRotationButtonDescription();
if (mapController == null || mapController.getCurrentMapInterface() == null) {
Toast.makeText(this, "Режим карты сохранён — применится после загрузки карты",
Toast.LENGTH_SHORT).show();
return;
}
try {
float current = mapController.getCurrentMapInterface().getBearing();
// Простейший toggle: если близко к северу — повернуть на 45°, иначе выровнять по северу
if (Math.abs(current) < 1f) {
mapController.getCurrentMapInterface().setBearing(45f);
Toast.makeText(this, "Ориентация: произвольная (45°)", Toast.LENGTH_SHORT).show();
} else {
mapController.getCurrentMapInterface().setBearing(0f);
Toast.makeText(this, "Ориентация: север вверх", Toast.LENGTH_SHORT).show();
}
MapInterface map = mapController.getCurrentMapInterface();
applyMapRotationForMode(map, mode, true);
} catch (Exception e) {
Log.w(TAG, "toggleMapOrientation error: " + e.getMessage());
Log.w(TAG, "cycleMapRotationMode: " + e.getMessage());
}
}
private void applyMapRotationForMode(MapInterface map, String mode, boolean showShortToast) {
if (map == null || mode == null) return;
if (SettingsManager.MAP_ROTATION_MANUAL.equals(mode)) {
map.setBearing(0f);
if (showShortToast) {
Toast.makeText(this, "Карта: вручную (север вверх, дальше — жестом)",
Toast.LENGTH_LONG).show();
}
return;
}
if (appCoordinator == null) return;
Vessel own = appCoordinator.getOwnVessel();
if (own == null) {
if (showShortToast) {
Toast.makeText(this, "Нет данных собственного судна", Toast.LENGTH_SHORT).show();
}
return;
}
if (SettingsManager.MAP_ROTATION_COMPASS.equals(mode)) {
float b = normalizeBearingTo360(own.getMagneticCompass());
map.setBearing(b);
if (showShortToast) {
Toast.makeText(this,
String.format(java.util.Locale.US, "По компасу (%.0f°)", b),
Toast.LENGTH_SHORT).show();
}
return;
}
if (SettingsManager.MAP_ROTATION_COURSE.equals(mode)) {
if (Double.isNaN(own.getCourse())) {
if (showShortToast) {
Toast.makeText(this, "Пока нет курса (COG)", Toast.LENGTH_SHORT).show();
}
return;
}
float b = normalizeBearingTo360(own.getCourse());
map.setBearing(b);
if (showShortToast) {
Toast.makeText(this,
String.format(java.util.Locale.US, "По курсу COG (%.0f°)", b),
Toast.LENGTH_SHORT).show();
}
}
}
private void applyAutoMapBearingIfNeeded(MapInterface map) {
if (settingsManager == null || appCoordinator == null || map == null) return;
String mode = settingsManager.getMapRotationMode();
if (SettingsManager.MAP_ROTATION_MANUAL.equals(mode)) {
return;
}
try {
Vessel own = appCoordinator.getOwnVessel();
if (own == null) return;
if (SettingsManager.MAP_ROTATION_COMPASS.equals(mode)) {
map.setBearing(normalizeBearingTo360(own.getMagneticCompass()));
} else if (SettingsManager.MAP_ROTATION_COURSE.equals(mode)
&& !Double.isNaN(own.getCourse())) {
map.setBearing(normalizeBearingTo360(own.getCourse()));
}
} catch (Exception ignore) {}
}
private void refreshMapRotationButtonDescription() {
if (btnMapOrientation == null || settingsManager == null) return;
String m = settingsManager.getMapRotationMode();
if (SettingsManager.MAP_ROTATION_COMPASS.equals(m)) {
btnMapOrientation.setContentDescription("Карта по компасу (нажмите — смена режима)");
} else if (SettingsManager.MAP_ROTATION_COURSE.equals(m)) {
btnMapOrientation.setContentDescription("Карта по курсу COG (нажмите — смена режима)");
} else {
btnMapOrientation.setContentDescription("Карта вручную, север вверх (нажмите — смена режима)");
}
}
@@ -928,6 +1173,125 @@ public class MainActivity extends AppCompatActivity {
startActivity(intent);
}
/**
* Переключает источник координат между BLE Hub и Android GPS «на лету»,
* обновляет иконку кнопки и уведомляет AppCoordinator.
*/
private void toggleGpsSource() {
if (settingsManager == null) return;
String next = settingsManager.toggleGpsSource();
refreshGpsSourceButtonIcon();
if (appCoordinator != null) {
appCoordinator.applyGpsSourceChange();
}
String label = SettingsManager.GPS_SOURCE_HUB.equals(next)
? "Источник: AIS Hub (BLE)"
: "Источник: Android GPS";
Toast.makeText(this, label, Toast.LENGTH_SHORT).show();
}
/**
* Навешивает единый листенер WindowInsets на корень активити и рассыпает
* рассчитанные инсеты (system bars + display cutout) по трём ключевым
* элементам верхнего слоя: компас, координатный виджет, боковая панель
* управления. Благодаря этому контент не прячется за статус-баром,
* нав-баром и вырезами камеры, а фоновые прямоугольники продолжают
* доходить до физических краёв экрана.
*/
private Insets lastSysInsets = Insets.NONE;
private void installMainUiInsets() {
View root = findViewById(R.id.main_root);
if (root == null) return;
ViewCompat.setOnApplyWindowInsetsListener(root, (v, insets) -> {
Insets sys = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
| WindowInsetsCompat.Type.displayCutout());
lastSysInsets = sys;
applyInsetsToDocks(sys);
if (controlPanel != null) {
ViewGroup.LayoutParams rawLp = controlPanel.getLayoutParams();
if (rawLp instanceof android.widget.RelativeLayout.LayoutParams) {
android.widget.RelativeLayout.LayoutParams lp =
(android.widget.RelativeLayout.LayoutParams) rawLp;
int newRight = sys.right + Math.round(getResources().getDisplayMetrics().density * 8);
if (lp.rightMargin != newRight) {
lp.rightMargin = newRight;
controlPanel.setLayoutParams(lp);
}
}
}
return insets;
});
// На первом layout инсеты иногда ещё не выданы системой: мы выставляем
// слушатель, но callback не приходит до prewarm. Поэтому просим
// отложенно — через post — чтобы попасть после attach и первого layout.
ViewCompat.requestApplyInsets(root);
root.post(() -> ViewCompat.requestApplyInsets(root));
root.postDelayed(() -> ViewCompat.requestApplyInsets(root), 200);
// Маргин control_panel по вертикали пересчитываем от фактической
// высоты доков (она уже включает системные паддинги), чтобы панель
// никогда не наползала на компас/координаты при ресайзе.
View.OnLayoutChangeListener relayoutControlPanel = (v2, l, t, r, b, ol, ot, orr, ob) -> {
if (controlPanel == null) return;
ViewGroup.LayoutParams rawLp = controlPanel.getLayoutParams();
if (!(rawLp instanceof android.widget.RelativeLayout.LayoutParams)) return;
android.widget.RelativeLayout.LayoutParams lp =
(android.widget.RelativeLayout.LayoutParams) rawLp;
int dp8 = Math.round(getResources().getDisplayMetrics().density * 8);
int compassH = compassView != null ? compassView.getHeight() : 0;
int coordsH = coordinatesWidget != null ? coordinatesWidget.getHeight() : 0;
int newTop = compassH + dp8;
int newBottom = coordsH + dp8;
if (lp.topMargin != newTop || lp.bottomMargin != newBottom) {
lp.topMargin = newTop;
lp.bottomMargin = newBottom;
controlPanel.setLayoutParams(lp);
}
};
if (compassView != null) compassView.addOnLayoutChangeListener(relayoutControlPanel);
if (coordinatesWidget != null) coordinatesWidget.addOnLayoutChangeListener(relayoutControlPanel);
}
/**
* Применяет системные инсеты к компасу и координатному виджету в
* зависимости от того, к какой стороне экрана они пристыкованы.
* Если док у верхнего края — добавляем верхний паддинг (статус-бар,
* вырез камеры). Если у нижнего — добавляем нижний паддинг под нав-бар.
* Боковые паддинги даём всегда (landscape-камеры).
*/
private void applyInsetsToDocks(Insets sys) {
if (compassView != null) {
boolean top = compassView.isDockTop();
compassView.setPadding(sys.left, top ? sys.top : 0,
sys.right, top ? 0 : sys.bottom);
}
if (coordinatesWidget != null) {
boolean top = coordinatesWidget.isDockTop();
coordinatesWidget.setPadding(sys.left, top ? sys.top : 0,
sys.right, top ? 0 : sys.bottom);
}
}
/** Переприменяет уже собранные инсеты (вызывать при смене стороны дока). */
private void reapplyInsetsToDocks() {
applyInsetsToDocks(lastSysInsets);
View root = findViewById(R.id.main_root);
if (root != null) ViewCompat.requestApplyInsets(root);
}
private void refreshGpsSourceButtonIcon() {
if (btnGpsSource == null || settingsManager == null) return;
int icon = settingsManager.isGpsFromHub()
? R.drawable.ic_gps_source_hub
: R.drawable.ic_gps_source_android;
btnGpsSource.setImageResource(icon);
btnGpsSource.setContentDescription(
settingsManager.isGpsFromHub()
? "Источник: AIS Hub"
: "Источник: Android GPS");
}
/**
* Переключает отображение курсора на карте и сохраняет состояние
*/
@@ -1108,6 +1472,65 @@ public class MainActivity extends AppCompatActivity {
// Применяем отложенное центрирование, если было
applyPendingCenterIfAny();
// Старт: инициализируем ownVessel координатами устройства и центрируемся на нём
// НО ТОЛЬКО при первом запуске приложения и если нет интента центрирования на сторонний корабль
try {
Intent currentIntent = getIntent();
boolean hasExternalCenterIntent = currentIntent != null &&
currentIntent.hasExtra("center_lat") && currentIntent.hasExtra("center_lon");
if (isFirstStart && !hasExternalCenterIntent && settingsManager != null && settingsManager.isStartCenterOnLastEnabled()) {
Log.i(TAG, "Первый запуск: инициализируем ownVessel и центрируемся");
android.location.LocationManager lm = (android.location.LocationManager) getSystemService(android.content.Context.LOCATION_SERVICE);
android.location.Location lastLoc = null;
if (lm != null) {
// Пробуем GPS, затем NETWORK
try { lastLoc = lm.getLastKnownLocation(android.location.LocationManager.GPS_PROVIDER); } catch (Exception ignore) {}
if (lastLoc == null) {
try { lastLoc = lm.getLastKnownLocation(android.location.LocationManager.NETWORK_PROVIDER); } catch (Exception ignore) {}
}
}
if (lastLoc != null) {
double lat = lastLoc.getLatitude();
double lon = lastLoc.getLongitude();
Log.i(TAG, "Первый запуск: seed ownVessel из Android LastKnownLocation " + lat + "," + lon);
if (appCoordinator != null) {
appCoordinator.seedOwnVesselFromDeviceLocation(lat, lon);
appCoordinator.centerOnOwnVessel();
// Повторим центрирование чуть позже, когда стиль точно загрузится
mapView.postDelayed(() -> {
try {
appCoordinator.centerOnOwnVessel();
} catch (Exception ignore) {}
}, 500);
} else if (mapController.getCurrentMapInterface() != null) {
// fallback
mapController.getCurrentMapInterface().centerOnPosition(lat, lon);
}
float startZoom = settingsManager.getStartZoomLevel();
if (startZoom > 0f) {
mapController.getCurrentMapInterface().setZoom(startZoom);
}
} else {
Log.i(TAG, "Первый запуск: LastKnownLocation отсутствует");
}
// Отмечаем, что первый запуск завершен
isFirstStart = false;
} else if (hasExternalCenterIntent) {
Log.i(TAG, "Первый запуск с интентом центрирования на сторонний корабль - пропускаем центрирование на собственный");
// Отмечаем, что первый запуск завершен
isFirstStart = false;
} else if (!isFirstStart) {
Log.i(TAG, "Не первый запуск - пропускаем центрирование на собственный корабль");
} else {
Log.i(TAG, "Первый запуск, но центрирование отключено в настройках");
// Отмечаем, что первый запуск завершен
isFirstStart = false;
}
} catch (Exception e) {
Log.e(TAG, "Ошибка стартовой инициализации позиции/центрирования: " + e.getMessage(), e);
}
// Отслеживание путей для MapLibre будет добавлено позже
@@ -1149,6 +1572,12 @@ public class MainActivity extends AppCompatActivity {
// Перезапускаем цикл поворота кнопок после возврата в активити
startCompassButtonsLoop();
// Старт FPS трекера
if (tvFps != null) {
android.view.Choreographer.getInstance().removeFrameCallback(fpsCallback);
android.view.Choreographer.getInstance().postFrameCallback(fpsCallback);
}
}
@Override
@@ -1233,6 +1662,9 @@ public class MainActivity extends AppCompatActivity {
uiThrottleHandler.removeCallbacks(coordinatesUpdateRunnable);
uiThrottleHandler.removeCallbacks(compassButtonRotationRunnable);
}
// Останавливаем FPS трекер
try { android.view.Choreographer.getInstance().removeFrameCallback(fpsCallback); } catch (Exception ignore) {}
// Не останавливаем слушатели здесь, чтобы UDP продолжал работать в фоне
// if (appController != null) {
@@ -1275,6 +1707,7 @@ public class MainActivity extends AppCompatActivity {
uiWatchdogHandler.removeCallbacks(uiWatchdogRunnable);
Log.i(TAG, "UI watchdog остановлен");
}
try { uiWatchdogScheduler.shutdownNow(); } catch (Throwable ignore) {}
// Останавливаем throttling handler для control panel
if (controlPanelUpdateHandler != null && controlPanelUpdateRunnable != null) {
@@ -1385,6 +1818,12 @@ public class MainActivity extends AppCompatActivity {
((MapLibreMapImpl) mapController.getCurrentMapInterface()).setDebugMode(debugEnabled);
}
// Применяем настройки морских знаков
boolean seamarksEnabled = data.getBooleanExtra("seamarks_enabled", settingsManager.isSeamarksEnabled());
if (mapController.getCurrentMapInterface() instanceof MapLibreMapImpl) {
((MapLibreMapImpl) mapController.getCurrentMapInterface()).updateAdditionalLayers();
}
if (needsRestart) {
Log.i(TAG, "Требуется перезапуск сервисов");
restartServices();
@@ -1392,7 +1831,8 @@ public class MainActivity extends AppCompatActivity {
Log.i(TAG, "Применяем настройки без перезапуска");
applySettings();
}
refreshGpsSourceButtonIcon();
Toast.makeText(this, "Настройки применены", Toast.LENGTH_SHORT).show();
}
}
@@ -1442,6 +1882,9 @@ public class MainActivity extends AppCompatActivity {
} else if (id == R.id.menu_keep_screen_on) {
toggleKeepScreenOn();
return true;
} else if (id == R.id.menu_seamarks) {
toggleSeamarks();
return true;
}
return super.onOptionsItemSelected(item);
}
@@ -8,6 +8,8 @@ import android.widget.EditText;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.Toast;
import android.view.View;
import android.widget.LinearLayout;
import com.google.android.material.switchmaterial.SwitchMaterial;
@@ -33,15 +35,24 @@ public class SettingsActivity extends AppCompatActivity {
private RadioButton radioHybridMode;
private RadioButton radioNMEAOnly;
private RadioButton radioAndroidOnly;
private RadioGroup radioGroupGpsSource;
private RadioButton radioGpsSourceHub;
private RadioButton radioGpsSourceAndroid;
private SwitchMaterial switchShowAdvancedNmea;
private LinearLayout llAdvancedNmeaSection;
private EditText etStaleWarningMinutes;
private EditText etStaleRemoveMinutes;
private SwitchMaterial switchVibrationEnabled;
private SwitchMaterial switchSoundEnabled;
private SwitchMaterial switchKeepScreenOn;
private SwitchMaterial switchDebugEnabled;
private SwitchMaterial switchSeamarksEnabled;
private Button btnCancel;
private Button btnSave;
private Button btnClearPath;
private Button btnOpenInterfaces;
private com.google.android.material.textfield.TextInputLayout tilOpenInterfaces;
private EditText etOpenInterfaces;
// Path/prediction
private EditText etPathMaxPoints;
@@ -92,19 +103,29 @@ public class SettingsActivity extends AppCompatActivity {
*/
private void initializeViews() {
etUDPPort = findViewById(R.id.et_udp_port);
switchUDPEnabled = findViewById(R.id.switch_udp_enabled);
// UDP элементы перенесены на экран интерфейсов; здесь найдём только кнопку перехода
// Кнопка могла быть удалена из разметки: не инициализируем её по id
tilOpenInterfaces = findViewById(R.id.til_open_interfaces);
etOpenInterfaces = findViewById(R.id.et_open_interfaces);
switchUDPEnabled = findViewById(R.id.switch_udp_enabled); // может отсутствовать в новой разметке
switchAndroidNMEAEnabled = findViewById(R.id.switch_android_nmea_enabled);
switchUDPNMEAEnabled = findViewById(R.id.switch_udp_nmea_enabled);
radioGroupDataMode = findViewById(R.id.radio_group_data_mode);
radioHybridMode = findViewById(R.id.radio_hybrid_mode);
radioNMEAOnly = findViewById(R.id.radio_nmea_only);
radioAndroidOnly = findViewById(R.id.radio_android_only);
radioGroupGpsSource = findViewById(R.id.radio_group_gps_source);
radioGpsSourceHub = findViewById(R.id.radio_gps_source_hub);
radioGpsSourceAndroid = findViewById(R.id.radio_gps_source_android);
switchShowAdvancedNmea = findViewById(R.id.switch_show_advanced_nmea);
llAdvancedNmeaSection = findViewById(R.id.ll_advanced_nmea_section);
etStaleWarningMinutes = findViewById(R.id.et_stale_warning_minutes);
etStaleRemoveMinutes = findViewById(R.id.et_stale_remove_minutes);
switchVibrationEnabled = findViewById(R.id.switch_vibration_enabled);
switchSoundEnabled = findViewById(R.id.switch_sound_enabled);
switchKeepScreenOn = findViewById(R.id.switch_keep_screen_on);
switchDebugEnabled = findViewById(R.id.switch_debug_enabled);
switchSeamarksEnabled = findViewById(R.id.switch_seamarks_enabled);
btnCancel = findViewById(R.id.btn_cancel);
btnSave = findViewById(R.id.btn_save);
btnClearPath = findViewById(R.id.btn_clear_path);
@@ -122,14 +143,23 @@ public class SettingsActivity extends AppCompatActivity {
*/
private void loadCurrentSettings() {
// UDP настройки
etUDPPort.setText(String.valueOf(settingsManager.getUDPPort()));
switchUDPEnabled.setChecked(settingsManager.isUDPEnabled());
if (etUDPPort != null) etUDPPort.setText(String.valueOf(settingsManager.getUDPPort()));
if (switchUDPEnabled != null) switchUDPEnabled.setChecked(settingsManager.isUDPEnabled());
// NMEA настройки
switchAndroidNMEAEnabled.setChecked(settingsManager.isAndroidNMEAEnabled());
switchUDPNMEAEnabled.setChecked(settingsManager.isUDPNMEAEnabled());
// Режим данных
// Источник координат (основной переключатель).
if (radioGpsSourceHub != null && radioGpsSourceAndroid != null) {
if (settingsManager.isGpsFromAndroid()) {
radioGpsSourceAndroid.setChecked(true);
} else {
radioGpsSourceHub.setChecked(true);
}
}
// Legacy режим данных (внутри расширенной секции).
String dataMode = settingsManager.getDataMode();
switch (dataMode) {
case SettingsManager.DATA_MODE_HYBRID:
@@ -156,6 +186,7 @@ public class SettingsActivity extends AppCompatActivity {
// Дебаг
switchDebugEnabled.setChecked(settingsManager.isDebugEnabled());
switchSeamarksEnabled.setChecked(settingsManager.isSeamarksEnabled());
// Путь и предсказание
etPathMaxPoints.setText(String.valueOf(settingsManager.getPathMaxPoints()));
@@ -195,6 +226,22 @@ public class SettingsActivity extends AppCompatActivity {
Log.i(TAG, "Нажата кнопка отмены");
finish();
});
if (btnOpenInterfaces != null) {
btnOpenInterfaces.setOnClickListener(v -> openInterfacesSettings());
}
if (tilOpenInterfaces != null) {
tilOpenInterfaces.setEndIconOnClickListener(v -> openInterfacesSettings());
tilOpenInterfaces.setOnClickListener(v -> openInterfacesSettings());
}
if (etOpenInterfaces != null) {
etOpenInterfaces.setOnClickListener(v -> openInterfacesSettings());
}
// Секция "Расширенные NMEA-источники" скрыта по умолчанию и разворачивается по свитчу.
if (switchShowAdvancedNmea != null && llAdvancedNmeaSection != null) {
switchShowAdvancedNmea.setOnCheckedChangeListener((btn, checked) ->
llAdvancedNmeaSection.setVisibility(checked ? View.VISIBLE : View.GONE));
}
// Кнопка сохранения
btnSave.setOnClickListener(v -> {
@@ -222,6 +269,16 @@ public class SettingsActivity extends AppCompatActivity {
validateDataModeSettings();
});
}
private void openInterfacesSettings() {
try {
Intent i = new Intent(SettingsActivity.this, com.grigowashere.aismap.settings.InterfacesSettingsActivity.class);
startActivity(i);
} catch (Exception e) {
Log.e(TAG, "Ошибка открытия настроек интерфейсов: " + e.getMessage());
Toast.makeText(SettingsActivity.this, "Не удалось открыть интерфейсы", Toast.LENGTH_SHORT).show();
}
}
/**
* Обновляет описание режима данных
@@ -262,23 +319,27 @@ public class SettingsActivity extends AppCompatActivity {
*/
private void saveSettings() {
try {
// Валидируем UDP порт
String portText = etUDPPort.getText().toString().trim();
if (portText.isEmpty()) {
Toast.makeText(this, "Порт не может быть пустым", Toast.LENGTH_SHORT).show();
return;
}
// Валидируем UDP порт (поле могло быть перенесено на экран интерфейсов и отсутствовать в разметке)
int udpPort;
try {
udpPort = Integer.parseInt(portText);
if (udpPort < 1 || udpPort > 65535) {
Toast.makeText(this, "Порт должен быть от 1 до 65535", Toast.LENGTH_SHORT).show();
if (etUDPPort != null) {
String portText = etUDPPort.getText().toString().trim();
if (portText.isEmpty()) {
Toast.makeText(this, "Порт не может быть пустым", Toast.LENGTH_SHORT).show();
return;
}
} catch (NumberFormatException e) {
Toast.makeText(this, "Некорректный формат порта", Toast.LENGTH_SHORT).show();
return;
try {
udpPort = Integer.parseInt(portText);
if (udpPort < 1 || udpPort > 65535) {
Toast.makeText(this, "Порт должен быть от 1 до 65535", Toast.LENGTH_SHORT).show();
return;
}
} catch (NumberFormatException e) {
Toast.makeText(this, "Некорректный формат порта", Toast.LENGTH_SHORT).show();
return;
}
} else {
// Если поля нет на этом экране — используем текущее сохранённое значение
udpPort = settingsManager.getUDPPort();
}
// Получаем выбранный режим данных
@@ -303,11 +364,20 @@ public class SettingsActivity extends AppCompatActivity {
}
// Сохраняем настройки
settingsManager.setUDPPort(udpPort);
settingsManager.setUDPEnabled(switchUDPEnabled.isChecked());
if (etUDPPort != null) settingsManager.setUDPPort(udpPort);
if (switchUDPEnabled != null) settingsManager.setUDPEnabled(switchUDPEnabled.isChecked());
settingsManager.setAndroidNMEAEnabled(switchAndroidNMEAEnabled.isChecked());
settingsManager.setUDPNMEAEnabled(switchUDPNMEAEnabled.isChecked());
settingsManager.setDataMode(dataMode);
// Источник координат (независим от legacy dataMode).
if (radioGroupGpsSource != null) {
int checkedGps = radioGroupGpsSource.getCheckedRadioButtonId();
if (checkedGps == R.id.radio_gps_source_android) {
settingsManager.setGpsSource(SettingsManager.GPS_SOURCE_ANDROID);
} else {
settingsManager.setGpsSource(SettingsManager.GPS_SOURCE_HUB);
}
}
settingsManager.setDataStaleWarningMinutes(staleWarningMinutes);
settingsManager.setDataStaleRemoveMinutes(staleRemoveMinutes);
settingsManager.setVibrationEnabled(switchVibrationEnabled.isChecked());
@@ -315,6 +385,10 @@ public class SettingsActivity extends AppCompatActivity {
settingsManager.setKeepScreenOnEnabled(switchKeepScreenOn.isChecked());
boolean debugEnabled = switchDebugEnabled.isChecked();
settingsManager.setDebugEnabled(debugEnabled);
// Морские знаки
boolean seamarksEnabled = switchSeamarksEnabled.isChecked();
settingsManager.setSeamarksEnabled(seamarksEnabled);
// Путь и предсказание
try { settingsManager.setPathMaxPoints(Integer.parseInt(etPathMaxPoints.getText().toString().trim())); } catch (Exception ignored) {}
@@ -334,12 +408,14 @@ public class SettingsActivity extends AppCompatActivity {
resultIntent.putExtra("settings_changed", true);
resultIntent.putExtra("needs_restart", needsRestart);
resultIntent.putExtra("udp_port", udpPort);
resultIntent.putExtra("udp_enabled", switchUDPEnabled.isChecked());
boolean udpEnabledVal = (switchUDPEnabled != null) ? switchUDPEnabled.isChecked() : settingsManager.isUDPEnabled();
resultIntent.putExtra("udp_enabled", udpEnabledVal);
resultIntent.putExtra("android_nmea_enabled", switchAndroidNMEAEnabled.isChecked());
resultIntent.putExtra("udp_nmea_enabled", switchUDPNMEAEnabled.isChecked());
resultIntent.putExtra("data_mode", dataMode);
resultIntent.putExtra("cursor_enabled", settingsManager.isCursorEnabled());
resultIntent.putExtra("debug_enabled", debugEnabled);
resultIntent.putExtra("seamarks_enabled", seamarksEnabled);
setResult(RESULT_OK, resultIntent);
@@ -0,0 +1,164 @@
package com.grigowashere.aismap.ble.hub;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
/**
* Reassembles chunked logical messages keyed by (session_msg_id, msg_type).
*
* Payload may be JSON or MessagePack depending on the protocol-version byte of
* the incoming frames (see {@link AisHubConstants#PROTO_VERSION_JSON} /
* {@link AisHubConstants#PROTO_VERSION_MSGPACK}). The assembler stores the
* version of the first frame of an assembly and returns it to the caller when
* the message is complete; the caller then chooses the right decoder.
*/
public class AisHubChunkAssembler {
public static final class FeedStatus {
/**
* Non-null only when the whole message is complete.
* Historically this was a JSON string; it's kept for backward
* compatibility with callers that only ever saw JSON on the wire.
* When the assembly's proto version is MessagePack, {@link #json} is
* {@code null} even at completion — use {@link #payload} +
* {@link #protocolVersion} instead.
*/
public final String json;
/**
* Full reassembled payload bytes (always non-null when the message is
* complete, regardless of encoding). Callers that support both JSON
* and MessagePack should decode from this field and ignore
* {@link #json}.
*/
public final byte[] payload;
/** Protocol-version byte of the reassembled message (matches {@link AisHubConstants}). */
public final int protocolVersion;
public final int received;
public final int chunkCount;
/** True if we detected mismatch and reset assembly state. */
public final boolean wasReset;
FeedStatus(String json, byte[] payload, int protocolVersion,
int received, int chunkCount, boolean wasReset) {
this.json = json;
this.payload = payload;
this.protocolVersion = protocolVersion;
this.received = received;
this.chunkCount = chunkCount;
this.wasReset = wasReset;
}
}
private static final class Key {
final int sessionId;
final int msgType;
Key(int sessionId, int msgType) {
this.sessionId = sessionId;
this.msgType = msgType;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Key)) return false;
Key key = (Key) o;
return sessionId == key.sessionId && msgType == key.msgType;
}
@Override
public int hashCode() {
return 31 * sessionId + msgType;
}
}
private static final class Assembly {
final int chunkCount;
final byte[][] parts;
int received;
// Protocol version taken from the first frame of this assembly.
// We pin it so that a rogue frame with a mismatched version cannot
// silently change how we decode the combined payload.
int protocolVersion = -1;
Assembly(int chunkCount) {
this.chunkCount = chunkCount;
this.parts = new byte[chunkCount][];
}
}
private final Map<Key, Assembly> pending = new HashMap<>();
/**
* Feed one frame; returns complete UTF-8 JSON string or null if more chunks needed.
* <p>
* <b>Deprecated for mixed encodings:</b> MessagePack payloads will return
* {@code null} here even when the message is complete. Use
* {@link #feedStatus(AisHubFrame)} and inspect
* {@link FeedStatus#payload} + {@link FeedStatus#protocolVersion} instead.
*/
public String feed(AisHubFrame frame) {
FeedStatus st = feedStatus(frame);
return st != null ? st.json : null;
}
/**
* Same as {@link #feed(AisHubFrame)}, but also returns progress info for logging
* and the raw reassembled payload + protocol version on completion.
*/
public FeedStatus feedStatus(AisHubFrame frame) {
if (frame.chunkCount <= 0 || frame.chunkIndex < 0 || frame.chunkIndex >= frame.chunkCount) {
return new FeedStatus(null, null, frame.protocolVersion, 0, frame.chunkCount, false);
}
Key key = new Key(frame.sessionMsgId, frame.msgType);
Assembly a = pending.get(key);
boolean wasReset = false;
if (a == null) {
a = new Assembly(frame.chunkCount);
a.protocolVersion = frame.protocolVersion;
pending.put(key, a);
} else if (a.chunkCount != frame.chunkCount || a.protocolVersion != frame.protocolVersion) {
// Either the server changed its mind about the chunk count (unlikely
// but defensively handled) or the encoding — treat as a brand new
// assembly. Can happen if a previous message was lost and we're now
// seeing the start of the next one under the same (sid, msg_type).
pending.remove(key);
a = new Assembly(frame.chunkCount);
a.protocolVersion = frame.protocolVersion;
pending.put(key, a);
wasReset = true;
}
if (a.parts[frame.chunkIndex] == null) {
a.received++;
}
a.parts[frame.chunkIndex] = Arrays.copyOf(frame.payload, frame.payload.length);
if (a.received < a.chunkCount) {
return new FeedStatus(null, null, a.protocolVersion, a.received, a.chunkCount, wasReset);
}
int total = 0;
for (byte[] p : a.parts) {
if (p != null) total += p.length;
}
byte[] out = new byte[total];
int pos = 0;
for (byte[] p : a.parts) {
if (p != null) {
System.arraycopy(p, 0, out, pos, p.length);
pos += p.length;
}
}
pending.remove(key);
// Build the JSON string only for legacy JSON payloads, to keep old
// callers (that read FeedStatus.json directly) working as-is.
String jsonStr = (a.protocolVersion == AisHubConstants.PROTO_VERSION_JSON)
? new String(out, StandardCharsets.UTF_8)
: null;
return new FeedStatus(jsonStr, out, a.protocolVersion, a.received, a.chunkCount, wasReset);
}
public void clear() {
pending.clear();
}
}
@@ -0,0 +1,41 @@
package com.grigowashere.aismap.ble.hub;
import java.util.UUID;
/**
* BLE AIS Hub (protocol v2) — UUID and message type constants.
*/
public final class AisHubConstants {
private AisHubConstants() {}
public static final UUID SERVICE_UUID =
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0001");
public static final UUID CONTROL_UUID =
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0002");
public static final UUID DATA_UUID =
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0003");
public static final UUID STATUS_UUID =
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0004");
public static final int HEADER_SIZE = 10;
/**
* Protocol version byte values (first byte of every DATA frame header).
* The server writes this per-frame based on the encoding it chose for
* that message (negotiated via the "hello" command, or from the
* AIS_BLE_BROADCAST_ENCODING env for CCCD broadcast).
*/
public static final int PROTO_VERSION_JSON = 0x01;
public static final int PROTO_VERSION_MSGPACK = 0x02;
/** Server → client DATA msg_type */
public static final int MSG_HELLO_ACK = 0x01;
public static final int MSG_SNAPSHOT_BEGIN = 0x02;
public static final int MSG_SNAPSHOT_CHUNK = 0x03;
public static final int MSG_SNAPSHOT_END = 0x04;
public static final int MSG_EVENT = 0x05;
public static final int MSG_STATUS = 0x06;
public static final int MSG_ERROR = 0x07;
public static final int MSG_PONG = 0x08;
}
@@ -0,0 +1,48 @@
package com.grigowashere.aismap.ble.hub;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
/**
* One DATA notify frame: 10-byte header + payload.
*/
public class AisHubFrame {
public final int protocolVersion;
public final int msgType;
public final int sessionMsgId;
public final int chunkIndex;
public final int chunkCount;
public final byte[] payload;
public AisHubFrame(int protocolVersion, int msgType, int sessionMsgId,
int chunkIndex, int chunkCount, byte[] payload) {
this.protocolVersion = protocolVersion;
this.msgType = msgType;
this.sessionMsgId = sessionMsgId;
this.chunkIndex = chunkIndex;
this.chunkCount = chunkCount;
this.payload = payload;
}
/**
* @return null if buffer too short or truncated
*/
public static AisHubFrame parse(byte[] buf) {
if (buf == null || buf.length < AisHubConstants.HEADER_SIZE) {
return null;
}
ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
int pv = bb.get() & 0xFF;
int mt = bb.get() & 0xFF;
int sid = bb.getShort() & 0xFFFF;
int cidx = bb.getShort() & 0xFFFF;
int ccnt = bb.getShort() & 0xFFFF;
int plen = bb.getShort() & 0xFFFF;
if (buf.length < AisHubConstants.HEADER_SIZE + plen) {
return null;
}
byte[] payload = new byte[plen];
System.arraycopy(buf, AisHubConstants.HEADER_SIZE, payload, 0, plen);
return new AisHubFrame(pv, mt, sid, cidx, ccnt, payload);
}
}
@@ -0,0 +1,815 @@
package com.grigowashere.aismap.ble.hub;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.grigowashere.aismap.BuildConfig;
import com.grigowashere.aismap.utils.LogSender;
import org.json.JSONObject;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* GATT client for AIS Hub protocol v2: CONTROL writes (JSON), DATA binary frames, optional battery/RSSI.
*/
public class AisHubGattClient {
private static final String TAG = "AisHubGattClient";
private static final boolean BLE_LOG = BuildConfig.DEBUG;
private static final UUID CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
private static final UUID BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb");
private static final UUID BATTERY_LEVEL = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb");
public interface SessionCallback {
void onState(@NonNull String state);
void onError(@NonNull String message);
void onRssi(int rssi);
void onBatteryPercent(int percent);
/** Reassembled JSON from DATA, after chunk merge (per msg_type in protocol). */
void onDataJson(int msgType, @NonNull JSONObject json);
}
private final Context appContext;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
// executor: used ONLY for short-lived, non-blocking tasks (attemptConnect body).
private final ExecutorService executor = Executors.newSingleThreadExecutor();
// dataExecutor: dedicated to processDataRaw so that BLE notifications
// are never blocked behind long-running loops (RSSI, reconnect, watchdog).
// Using single-thread to preserve in-order chunk assembly semantics.
private final ExecutorService dataExecutor = Executors.newSingleThreadExecutor();
// scheduler: replaces Thread.sleep-based loops (RSSI, watchdog, reconnect backoff)
// so they don't hog the general executor and starve other tasks.
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final AtomicBoolean running = new AtomicBoolean(false);
private final AisHubChunkAssembler assembler = new AisHubChunkAssembler();
private final AtomicBoolean gattBusy = new AtomicBoolean(false);
private final AtomicBoolean isConnecting = new AtomicBoolean(false);
private final AtomicBoolean rssiLoop = new AtomicBoolean(false);
private final AtomicBoolean batteryLoop = new AtomicBoolean(false);
private final AtomicBoolean reconnectLoop = new AtomicBoolean(false);
private final AtomicBoolean notifReady = new AtomicBoolean(false);
private final AtomicBoolean mtuRequested = new AtomicBoolean(false);
private final AtomicBoolean snapshotRequested = new AtomicBoolean(false);
private final AtomicBoolean subscribeRequested = new AtomicBoolean(false);
private final ArrayBlockingQueue<byte[]> controlQueue = new ArrayBlockingQueue<>(32);
private BluetoothAdapter adapter;
private volatile BluetoothGatt gatt;
private volatile String deviceMac;
private volatile boolean connected;
private volatile long connectionStartTimeMs;
private volatile long lastDataAtMs;
private volatile boolean lastErrorWasDbFull;
private volatile BluetoothGattCharacteristic controlChar;
private volatile BluetoothGattCharacteristic dataChar;
private SessionCallback callback;
private volatile UUID cachedBatterySvc;
private volatile UUID cachedBatteryChar;
private final ScheduledExecutorService batteryScheduler = Executors.newSingleThreadScheduledExecutor();
private volatile ScheduledFuture<?> batteryTask;
private static final long CONNECTION_TIMEOUT_MS = 30_000L;
private static final long RECONNECT_DELAY_MS = 5_000L;
private static final long RECONNECT_DELAY_DB_FULL_MS = 15_000L;
private static final long SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS = 300_000L;
private static final long SNAPSHOT_RECOVERY_IDLE_MS = 10_000L;
public AisHubGattClient(@NonNull Context context) {
this.appContext = context.getApplicationContext();
BluetoothManager bm = (BluetoothManager) appContext.getSystemService(Context.BLUETOOTH_SERVICE);
this.adapter = bm != null ? bm.getAdapter() : null;
}
public void setCallback(@Nullable SessionCallback callback) {
this.callback = callback;
}
public void setDeviceMac(String mac) {
this.deviceMac = mac;
}
public boolean isRunning() {
return running.get();
}
public void start() {
if (running.get()) {
Log.w(TAG, "AIS Hub GATT already running");
return;
}
if (adapter == null || !adapter.isEnabled()) {
postError("Bluetooth is off or unavailable");
return;
}
if (deviceMac == null || deviceMac.isEmpty()) {
postError("BLE device MAC not set");
return;
}
running.set(true);
assembler.clear();
if (BLE_LOG) Log.d(TAG, "start(): mac=" + deviceMac);
startReconnectLoop();
}
public void stop() {
if (BLE_LOG) Log.d(TAG, "stop()");
running.set(false);
reconnectLoop.set(false);
rssiLoop.set(false);
batteryLoop.set(false);
if (batteryTask != null) {
try { batteryTask.cancel(true); } catch (Throwable ignore) {}
}
if (rssiTask != null) {
try { rssiTask.cancel(true); } catch (Throwable ignore) {}
}
notifReady.set(false);
controlChar = null;
dataChar = null;
mainHandler.removeCallbacksAndMessages(null);
try {
if (gatt != null) {
gatt.disconnect();
gatt.close();
}
} catch (Throwable ignore) {}
gatt = null;
connected = false;
postState("stopped");
}
// --- GATT callback ---
private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
@Override
public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
if (!running.get()) return;
if (BLE_LOG) Log.d(TAG, "onConnectionStateChange: status=" + status + " newState=" + newState);
if (status != BluetoothGatt.GATT_SUCCESS && status != 4 && status != 133) {
postError("BLE connect status: " + status);
} else if (status == 133) {
lastErrorWasDbFull = true;
isConnecting.set(false);
try {
if (g != null) {
g.disconnect();
g.close();
}
} catch (Throwable ignore) {}
gatt = null;
connectionStartTimeMs = 0L;
} else if (status == 4) {
isConnecting.set(false);
}
if (newState == BluetoothProfile.STATE_CONNECTED) {
postState("connected");
connected = true;
connectionStartTimeMs = 0L;
isConnecting.set(false);
lastErrorWasDbFull = false;
reconnectLoop.set(false);
notifReady.set(false);
mtuRequested.set(false);
snapshotRequested.set(false);
subscribeRequested.set(false);
controlChar = null;
dataChar = null;
// NOTE: BluetoothGatt.refresh() is hidden API and frequently destabilizes connections
// on some vendor stacks. Prefer stability over stale cache here.
scheduler.schedule(() -> {
if (g != null && running.get() && gatt == g) {
// Помогаем линк-слою: выше приоритет соединения.
try { g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH); } catch (Throwable ignore) {}
boolean ok = false;
try { ok = g.discoverServices(); } catch (Throwable ignore) {}
if (BLE_LOG) Log.d(TAG, "discoverServices(): " + ok);
}
}, 200, TimeUnit.MILLISECONDS);
// На некоторых стеках (особенно после refresh/MTU) service discovery может "зависнуть"
// без callback'а. Если так — мягко перезапускаем discovery и, при необходимости, reconnect.
scheduler.schedule(() -> {
if (!running.get()) return;
if (g == null || gatt != g) return;
if (!connected) return;
if (controlChar != null && dataChar != null) return;
if (BLE_LOG) Log.w(TAG, "Services discovery watchdog: no hub chars yet, retry discoverServices()");
boolean ok = false;
try { ok = g.discoverServices(); } catch (Throwable ignore) {}
if (BLE_LOG) Log.d(TAG, "discoverServices() retry: " + ok);
}, 6, TimeUnit.SECONDS);
scheduler.schedule(() -> {
if (!running.get()) return;
if (g == null || gatt != g) return;
if (!connected) return;
if (controlChar != null && dataChar != null) return;
postError("Service discovery timeout (no hub chars)");
try { g.disconnect(); } catch (Throwable ignore) {}
try { g.close(); } catch (Throwable ignore) {}
gatt = null;
connected = false;
isConnecting.set(false);
startReconnectLoop();
}, 12, TimeUnit.SECONDS);
startRssiLoop();
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
postState("disconnected");
notifReady.set(false);
controlChar = null;
dataChar = null;
mtuRequested.set(false);
snapshotRequested.set(false);
subscribeRequested.set(false);
rssiLoop.set(false);
batteryLoop.set(false);
if (rssiTask != null) {
try { rssiTask.cancel(false); } catch (Throwable ignore) {}
rssiTask = null;
}
try { if (gatt != null) gatt.close(); } catch (Throwable ignore) {}
gatt = null;
connected = false;
isConnecting.set(false);
if (running.get()) {
scheduler.schedule(() -> {
if (running.get() && !connected) startReconnectLoop();
}, 1, TimeUnit.SECONDS);
}
}
}
@Override
public void onServicesDiscovered(BluetoothGatt g, int status) {
if (BLE_LOG) Log.d(TAG, "onServicesDiscovered: status=" + status);
if (status != BluetoothGatt.GATT_SUCCESS) {
postError("Service discovery failed: " + status);
return;
}
if (mtuRequested.compareAndSet(false, true)) {
boolean ok = false;
try { ok = g.requestMtu(512); } catch (Throwable ignore) {}
if (BLE_LOG) Log.d(TAG, "requestMtu(512): " + ok);
}
BluetoothGattService hub = g.getService(AisHubConstants.SERVICE_UUID);
if (hub == null) {
postError("AIS Hub service not found");
return;
}
controlChar = hub.getCharacteristic(AisHubConstants.CONTROL_UUID);
dataChar = hub.getCharacteristic(AisHubConstants.DATA_UUID);
if (controlChar == null || dataChar == null) {
postError("CONTROL or DATA characteristic missing");
return;
}
if (BLE_LOG) {
Log.d(TAG, "Hub chars ok: CONTROL=" + controlChar.getUuid() + " DATA=" + dataChar.getUuid());
}
boolean ok = g.setCharacteristicNotification(dataChar, true);
if (!ok) {
postError("Failed to enable DATA notification");
return;
}
BluetoothGattDescriptor cccd = dataChar.getDescriptor(CCCD);
if (cccd == null) {
postError("CCCD not found for DATA");
return;
}
cccd.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gattBusy.set(true);
if (BLE_LOG) Log.d(TAG, "writeDescriptor(CCCD ENABLE_NOTIFICATION)");
g.writeDescriptor(cccd);
postState("subscribing");
}
@Override
public void onDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor descriptor, int st) {
gattBusy.set(false);
if (BLE_LOG) Log.d(TAG, "onDescriptorWrite: uuid=" + descriptor.getUuid() + " status=" + st);
if (st == BluetoothGatt.GATT_SUCCESS && CCCD.equals(descriptor.getUuid())) {
notifReady.set(true);
lastDataAtMs = System.currentTimeMillis();
postState("notifying");
try { resolveBatteryAndSchedule(g); } catch (Throwable ignore) {}
readBatteryOnce(g);
startBatteryLoop(g);
enqueueControlJson(buildHello());
// Snapshot триггерим ТОЛЬКО на HELLO_ACK (см. processDataRaw),
// чтобы не получить snapshot_busy от двойного запроса (постDelayed
// здесь и обработчик HELLO_ACK раньше успевали оба).
// Fallback: если HELLO_ACK не пришёл за 2с — всё равно дёргаем snapshot.
mainHandler.postDelayed(() -> {
if (!running.get() || !connected) return;
if (!notifReady.get() || controlChar == null) return;
if (snapshotRequested.compareAndSet(false, true)) {
if (BLE_LOG) Log.d(TAG, "HELLO_ACK timeout fallback -> enqueue get_snapshot");
enqueueGetSnapshot();
}
}, 2000);
}
}
@Override
public void onCharacteristicChanged(BluetoothGatt g, BluetoothGattCharacteristic ch) {
if (dataChar != null && ch.getUuid().equals(dataChar.getUuid())) {
// ВАЖНО: не парсим/не JSON-парсим в BLE callback потоке.
// Иначе при потоке EVENT'ов легко перегрузить стек и получить disconnect/status=5.
// Используем ВЫДЕЛЕННЫЙ dataExecutor, чтобы парсинг не стоял за RSSI/reconnect-петлями.
final byte[] raw = ch.getValue();
if (raw == null) return;
final byte[] copy = java.util.Arrays.copyOf(raw, raw.length);
dataExecutor.execute(() -> processDataRaw(copy));
}
}
@Override
public void onMtuChanged(BluetoothGatt g, int mtu, int status) {
if (BLE_LOG) Log.d(TAG, "onMtuChanged: status=" + status + " mtu=" + mtu);
}
@Override
public void onCharacteristicRead(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
if (status == BluetoothGatt.GATT_SUCCESS) {
if (BATTERY_LEVEL.equals(ch.getUuid()) || (toShort(ch.getUuid()) != null && toShort(ch.getUuid()) == 0x2A19)) {
byte[] v = ch.getValue();
if (v != null && v.length > 0 && callback != null) {
callback.onBatteryPercent(v[0] & 0xFF);
}
}
}
gattBusy.set(false);
}
@Override
public void onCharacteristicWrite(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
if (controlChar != null && ch.getUuid().equals(controlChar.getUuid())) {
gattBusy.set(false);
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "CONTROL write failed: " + status);
} else if (BLE_LOG) {
Log.d(TAG, "CONTROL write ok (" + (ch.getValue() != null ? ch.getValue().length : -1) + " bytes)");
}
mainHandler.post(AisHubGattClient.this::drainControlQueue);
}
}
@Override
public void onReadRemoteRssi(BluetoothGatt g, int rssi, int status) {
if (status == BluetoothGatt.GATT_SUCCESS && callback != null) {
callback.onRssi(rssi);
}
}
};
private void processDataRaw(@Nullable byte[] raw) {
if (raw == null) return;
if (raw.length < AisHubConstants.HEADER_SIZE) {
if (BLE_LOG) Log.w(TAG, "DATA notify too short: len=" + raw.length);
return;
}
lastDataAtMs = System.currentTimeMillis();
// Decode header fields even if payload is truncated, for diagnostics.
int pv = raw[0] & 0xFF;
int mt = raw[1] & 0xFF;
int sid = ((raw[2] & 0xFF) | ((raw[3] & 0xFF) << 8));
int cidx = ((raw[4] & 0xFF) | ((raw[5] & 0xFF) << 8));
int ccnt = ((raw[6] & 0xFF) | ((raw[7] & 0xFF) << 8));
int plen = ((raw[8] & 0xFF) | ((raw[9] & 0xFF) << 8));
if (BLE_LOG) {
Log.d(TAG, "DATA notify: len=" + raw.length +
" pv=" + pv +
" msgType=" + mt + "(" + msgTypeName(mt) + ")" +
" sid=" + sid +
" chunk=" + cidx + "/" + ccnt +
" plen=" + plen);
}
AisHubFrame frame = AisHubFrame.parse(raw);
if (frame == null) {
if (BLE_LOG) Log.w(TAG, "DATA parse failed/truncated: rawLen=" + raw.length + " expected>=" + (AisHubConstants.HEADER_SIZE + plen));
return;
}
AisHubChunkAssembler.FeedStatus st = assembler.feedStatus(frame);
if (st != null && BLE_LOG) {
if (st.wasReset) {
Log.w(TAG, "Assembler reset: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType + " newChunkCount=" + st.chunkCount);
}
if (st.payload == null) {
Log.d(TAG, "Assembler progress: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType +
" got=" + st.received + "/" + st.chunkCount);
}
}
if (st == null || st.payload == null) return;
try {
JSONObject root;
if (st.protocolVersion == AisHubConstants.PROTO_VERSION_MSGPACK) {
root = AisHubPayloadCodec.decodeToJsonObject(st.payload, st.protocolVersion);
if (root == null) {
Log.w(TAG, "msgpack decode: empty/non-object payload msgType=" + frame.msgType);
return;
}
if (BLE_LOG) {
Log.d(TAG, "DATA msgpack complete: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType +
" bytes=" + st.payload.length + " obj=" + abbreviate(root.toString(), 800));
}
} else {
// JSON path keeps the original behavior (zero-copy via st.json).
String jsonStr = st.json != null
? st.json
: new String(st.payload, StandardCharsets.UTF_8);
if (BLE_LOG) Log.d(TAG, "DATA json complete: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType +
" bytes=" + jsonStr.getBytes(StandardCharsets.UTF_8).length +
" json=" + abbreviate(jsonStr, 800));
root = new JSONObject(jsonStr);
}
// Keep our clock offset with the hub fresh from every frame that
// carries a server timestamp. This makes stale-data math on the
// client work correctly regardless of hub clock drift.
double envTs = root.optDouble("ts", Double.NaN);
if (!Double.isNaN(envTs)) {
HubTimeSync.updateFromServerSeconds(envTs);
} else {
double srvTime = root.optDouble("server_time", Double.NaN);
if (!Double.isNaN(srvTime)) HubTimeSync.updateFromServerSeconds(srvTime);
}
if (BLE_LOG && frame.msgType == AisHubConstants.MSG_EVENT) {
String type = root.optString("type", "");
JSONObject data = root.optJSONObject("data");
// target.update/vessel-snapshot nest lat/lon inside "dynamic"; ownship.update keeps them at root.
JSONObject src = data;
if (data != null) {
JSONObject d = data.optJSONObject("dynamic");
if (d != null) src = d;
}
double lat = src != null ? src.optDouble("lat", Double.NaN) : Double.NaN;
double lon = src != null ? src.optDouble("lon", Double.NaN) : Double.NaN;
Log.d(TAG, "EVENT received: type=" + type + " lat=" + lat + " lon=" + lon);
}
// Авто-старт сессии: после HELLO_ACK сразу запрашиваем snapshot (1 раз на соединение).
if (frame.msgType == AisHubConstants.MSG_HELLO_ACK && snapshotRequested.compareAndSet(false, true)) {
if (BLE_LOG) Log.d(TAG, "HELLO_ACK received -> enqueue get_snapshot");
mainHandler.post(this::enqueueGetSnapshot);
}
// После окончания snapshot включаем live подписки (1 раз на соединение).
if (frame.msgType == AisHubConstants.MSG_SNAPSHOT_END && subscribeRequested.compareAndSet(false, true)) {
boolean ok = root.optBoolean("ok", true);
if (BLE_LOG) Log.d(TAG, "SNAPSHOT_END(ok=" + ok + ") -> enqueue subscribe");
if (ok) {
mainHandler.post(this::enqueueSubscribe);
}
}
if (callback != null) {
mainHandler.post(() -> callback.onDataJson(frame.msgType, root));
}
} catch (Exception e) {
Log.w(TAG, "JSON parse: " + e.getMessage());
}
}
// --- control writes ---
@NonNull
private JSONObject buildHello() {
JSONObject c = new JSONObject();
try {
c.put("cmd", "hello");
c.put("client", "android");
String v = "1.0";
try {
PackageInfo pi = appContext.getPackageManager().getPackageInfo(appContext.getPackageName(), 0);
if (pi != null) v = pi.versionName != null ? pi.versionName : "1.0";
} catch (Exception ignore) {}
c.put("app_version", v);
c.put("proto", 1);
// Advertise encoding preferences. Server picks the first one it
// supports; we fall back to JSON automatically if msgpack isn't
// available server-side. msgpack is ~30-40% smaller on the wire
// which matters for snapshot transfers over BLE.
// Note: this only affects the AcquireNotify (per-fd) path. For the
// CCCD broadcast path Android clients share, the server uses the
// global AIS_BLE_BROADCAST_ENCODING env var — but our decoder
// handles both encodings per-frame via the proto-version byte, so
// switching the server's env has zero client-side impact.
org.json.JSONArray encs = new org.json.JSONArray();
encs.put("msgpack");
encs.put("json");
c.put("encodings", encs);
} catch (Exception ignore) {}
return c;
}
public void enqueueGetSnapshot() {
JSONObject c = new JSONObject();
try {
c.put("cmd", "get_snapshot");
org.json.JSONArray inc = new org.json.JSONArray();
inc.put("ownship");
inc.put("vessels");
inc.put("base_stations");
inc.put("atons");
inc.put("stats");
c.put("include", inc);
// Серверный хард-кап ограничивает дальше (см. AIS_BLE_SNAPSHOT_MAX_VESSELS).
// 5000 покрывает любые реалистичные сценарии (обычно в AOI < 2000 целей).
c.put("max_vessels", 5000);
} catch (Exception ignore) {}
enqueueControl(c);
// Recovery only: штатно live-подписка включается строго после SNAPSHOT_END.
// Короткий таймер здесь интерливил EVENT'ы в длинный snapshot по CCCD.
mainHandler.postDelayed(() -> {
if (!running.get() || !connected) return;
if (!notifReady.get() || controlChar == null) return;
long idleMs = System.currentTimeMillis() - lastDataAtMs;
if (idleMs < SNAPSHOT_RECOVERY_IDLE_MS) return;
if (subscribeRequested.compareAndSet(false, true)) {
if (BLE_LOG) Log.d(TAG, "snapshot recovery timeout -> enqueue subscribe");
enqueueSubscribe();
}
}, SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS);
}
public void enqueueSubscribe() {
JSONObject c = new JSONObject();
try {
c.put("cmd", "subscribe");
org.json.JSONArray ev = new org.json.JSONArray();
ev.put("ownship.update");
ev.put("target.update");
ev.put("base_station.update");
ev.put("aton.update");
ev.put("stats.update");
c.put("events", ev);
} catch (Exception ignore) {}
enqueueControl(c);
}
private void enqueueControlJson(@NonNull JSONObject cmd) {
try {
enqueueControl(cmd);
} catch (Exception e) {
Log.w(TAG, "enqueue: " + e.getMessage());
}
}
private void enqueueControl(@NonNull JSONObject json) {
if (!notifReady.get() || controlChar == null) return;
String s = json.toString();
if (BLE_LOG) Log.d(TAG, "CONTROL enqueue: " + abbreviate(s, 600));
byte[] u = s.getBytes(StandardCharsets.UTF_8);
if (!controlQueue.offer(u)) {
Log.w(TAG, "Control queue full");
}
mainHandler.post(this::drainControlQueue);
}
private void drainControlQueue() {
if (!running.get() || !notifReady.get() || gatt == null || controlChar == null) return;
if (gattBusy.get()) return;
byte[] next = controlQueue.poll();
if (next == null) return;
gattBusy.set(true);
controlChar.setValue(next);
controlChar.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT);
boolean ok = gatt.writeCharacteristic(controlChar);
if (!ok) {
if (BLE_LOG) Log.w(TAG, "CONTROL writeCharacteristic returned false, retrying");
gattBusy.set(false);
mainHandler.post(this::drainControlQueue);
}
}
// --- connect / reconnect ---
private void startReconnectLoop() {
if (reconnectLoop.getAndSet(true)) return;
executor.execute(() -> {
while (running.get() && reconnectLoop.get() && !connected) {
try {
if (connectionStartTimeMs > 0) {
long el = System.currentTimeMillis() - connectionStartTimeMs;
if (el > CONNECTION_TIMEOUT_MS) {
isConnecting.set(false);
try { if (gatt != null) { gatt.disconnect(); gatt.close(); } } catch (Throwable ignore) {}
gatt = null;
connectionStartTimeMs = 0L;
} else {
try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
continue;
}
}
if (!isConnecting.compareAndSet(false, true)) {
try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
continue;
}
attemptConnect();
} catch (Throwable t) {
Log.w(TAG, "reconnect: " + t.getMessage());
isConnecting.set(false);
}
long delay = lastErrorWasDbFull ? RECONNECT_DELAY_DB_FULL_MS : RECONNECT_DELAY_MS;
try { Thread.sleep(delay); } catch (InterruptedException ignored) {}
}
});
}
private void attemptConnect() {
if (adapter == null || deviceMac == null || deviceMac.isEmpty()) {
isConnecting.set(false);
return;
}
if (gatt != null) {
try {
gatt.disconnect();
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
gatt.close();
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
} catch (Throwable ignore) {}
gatt = null;
}
BluetoothDevice d = adapter.getRemoteDevice(deviceMac);
if (d == null) {
postError("BLE device not found: " + deviceMac);
isConnecting.set(false);
return;
}
if (BLE_LOG) Log.d(TAG, "attemptConnect(): " + deviceMac);
postState("connecting");
connectionStartTimeMs = System.currentTimeMillis();
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
gatt = d.connectGatt(appContext, false, gattCallback, BluetoothDevice.TRANSPORT_LE);
} else {
gatt = d.connectGatt(appContext, false, gattCallback);
}
if (gatt == null) isConnecting.set(false);
} catch (Throwable t) {
isConnecting.set(false);
connectionStartTimeMs = 0L;
}
}
private volatile ScheduledFuture<?> rssiTask;
private void startRssiLoop() {
if (gatt == null) return;
if (rssiLoop.getAndSet(true)) return;
if (rssiTask != null) try { rssiTask.cancel(true); } catch (Throwable ignore) {}
rssiTask = scheduler.scheduleAtFixedRate(() -> {
if (!running.get() || !rssiLoop.get() || gatt == null) return;
try { gatt.readRemoteRssi(); } catch (Throwable ignore) {}
}, 500, 2000, TimeUnit.MILLISECONDS);
}
// --- battery (same heuristics as old listener) ---
private void resolveBatteryAndSchedule(BluetoothGatt g) {
BluetoothGattCharacteristic bl = findBatteryChar(g);
if (bl != null) {
cachedBatterySvc = bl.getService().getUuid();
cachedBatteryChar = bl.getUuid();
}
}
@Nullable
private BluetoothGattCharacteristic findBatteryChar(BluetoothGatt g) {
if (g == null) return null;
if (cachedBatterySvc != null && cachedBatteryChar != null) {
BluetoothGattService s = g.getService(cachedBatterySvc);
if (s != null) {
BluetoothGattCharacteristic c = s.getCharacteristic(cachedBatteryChar);
if (c != null) return c;
}
}
BluetoothGattService s = g.getService(BATTERY_SERVICE);
if (s != null) {
BluetoothGattCharacteristic c = s.getCharacteristic(BATTERY_LEVEL);
if (c != null) {
cachedBatterySvc = BATTERY_SERVICE;
cachedBatteryChar = BATTERY_LEVEL;
return c;
}
}
List<BluetoothGattService> list = g.getServices();
if (list == null) return null;
for (BluetoothGattService sv : list) {
for (BluetoothGattCharacteristic ch : sv.getCharacteristics()) {
Integer sh = toShort(ch.getUuid());
if (sh != null && sh == 0x2A19) {
return ch;
}
}
}
return null;
}
private static Integer toShort(java.util.UUID uuid) {
if (uuid == null) return null;
String s = uuid.toString().toLowerCase();
if (s.startsWith("0000") && s.endsWith("-0000-1000-8000-00805f9b34fb")) {
try {
return Integer.parseInt(s.substring(4, 8), 16);
} catch (Throwable ignore) {}
}
return null;
}
private void readBatteryOnce(BluetoothGatt g) {
if (g == null) return;
BluetoothGattCharacteristic bl = findBatteryChar(g);
if (bl == null) return;
if (gattBusy.compareAndSet(false, true)) {
boolean ok = g.readCharacteristic(bl);
if (!ok) gattBusy.set(false);
}
}
private void startBatteryLoop(BluetoothGatt gRef) {
if (gRef == null) return;
if (!batteryLoop.compareAndSet(false, true)) return;
if (batteryTask != null) try { batteryTask.cancel(true); } catch (Throwable ignore) {}
batteryTask = batteryScheduler.scheduleAtFixedRate(() -> {
if (!running.get() || !batteryLoop.get() || gatt == null) {
batteryLoop.set(false);
return;
}
try {
if (gattBusy.get()) return;
BluetoothGattCharacteristic bl = findBatteryChar(gRef);
if (bl != null && gattBusy.compareAndSet(false, true)) {
boolean ok = gRef.readCharacteristic(bl);
if (!ok) gattBusy.set(false);
}
} catch (Throwable ignore) {}
}, 2000, 10_000, TimeUnit.MILLISECONDS);
}
private void postState(String s) {
if (callback != null) {
mainHandler.post(() -> callback.onState(s));
}
}
private void postError(String s) {
Log.e(TAG, s);
LogSender.logBLEError(s, deviceMac, "AisHub");
if (callback != null) {
mainHandler.post(() -> callback.onError(s));
}
}
@NonNull
private static String abbreviate(@NonNull String s, int max) {
if (s.length() <= max) return s;
return s.substring(0, Math.max(0, max)) + "…(" + s.length() + " chars)";
}
@NonNull
private static String msgTypeName(int mt) {
switch (mt) {
case AisHubConstants.MSG_HELLO_ACK: return "HELLO_ACK";
case AisHubConstants.MSG_SNAPSHOT_BEGIN: return "SNAPSHOT_BEGIN";
case AisHubConstants.MSG_SNAPSHOT_CHUNK: return "SNAPSHOT_CHUNK";
case AisHubConstants.MSG_SNAPSHOT_END: return "SNAPSHOT_END";
case AisHubConstants.MSG_EVENT: return "EVENT";
case AisHubConstants.MSG_STATUS: return "STATUS";
case AisHubConstants.MSG_ERROR: return "ERROR";
case AisHubConstants.MSG_PONG: return "PONG";
default: return "UNKNOWN";
}
}
}
@@ -0,0 +1,307 @@
package com.grigowashere.aismap.ble.hub;
import com.grigowashere.aismap.models.AISVessel;
import com.grigowashere.aismap.models.AISNavigationAid;
import com.grigowashere.aismap.models.Vessel;
import org.json.JSONObject;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
/**
* Maps loose JSON objects from ais_hub into app models (defensive keys).
*/
public final class AisHubJsonMapper {
private AisHubJsonMapper() {}
public static String mmsiString(JSONObject o) {
if (o == null) return null;
if (o.has("mmsi")) {
Object v = o.opt("mmsi");
if (v instanceof Number) return String.valueOf(((Number) v).longValue());
String s = o.optString("mmsi", null);
return s != null && !s.isEmpty() ? s : null;
}
return null;
}
/**
* Returns `o.dynamic` if it exists (AIS Hub v2 nests position/motion there
* for target.update and vessel snapshot items), otherwise returns `o` itself
* (ownship.update keeps lat/lon at root).
*/
private static JSONObject dyn(JSONObject o) {
if (o == null) return null;
JSONObject d = o.optJSONObject("dynamic");
return d != null ? d : o;
}
public static double optLat(JSONObject o) {
if (o == null) return Double.NaN;
JSONObject src = dyn(o);
if (src.has("latitude")) return src.optDouble("latitude", Double.NaN);
if (src.has("lat")) return src.optDouble("lat", Double.NaN);
return Double.NaN;
}
public static double optLon(JSONObject o) {
if (o == null) return Double.NaN;
JSONObject src = dyn(o);
if (src.has("longitude")) return src.optDouble("longitude", Double.NaN);
if (src.has("lon")) return src.optDouble("lon", Double.NaN);
return Double.NaN;
}
public static double optCourse(JSONObject o) {
if (o == null) return Double.NaN;
JSONObject src = dyn(o);
double v = src.optDouble("cog", Double.NaN);
if (!Double.isNaN(v)) return v;
v = src.optDouble("course", Double.NaN);
if (!Double.isNaN(v)) return v;
v = src.optDouble("true_course", Double.NaN);
if (!Double.isNaN(v)) return v;
return src.optDouble("heading", Double.NaN);
}
public static double optSpeed(JSONObject o) {
if (o == null) return Double.NaN;
JSONObject src = dyn(o);
double v = src.optDouble("sog", Double.NaN);
if (!Double.isNaN(v)) return v;
v = src.optDouble("speed", Double.NaN);
if (!Double.isNaN(v)) return v;
return src.optDouble("stw", Double.NaN);
}
public static double optHeading(JSONObject o) {
if (o == null) return Double.NaN;
JSONObject src = dyn(o);
double v = src.optDouble("heading", Double.NaN);
if (!Double.isNaN(v)) return v;
return src.optDouble("hdg", Double.NaN);
}
/**
* Returns a valid epoch-seconds timestamp taken from any of the known fields,
* or NaN if none present. Supports ts, last_dynamic_ts, last_seen, last_static_ts.
*/
private static double pickEpochSeconds(JSONObject o) {
if (o == null) return Double.NaN;
String[] keys = {"ts", "last_dynamic_ts", "last_seen", "last_static_ts"};
for (String k : keys) {
if (!o.has(k)) continue;
double ts = o.optDouble(k, Double.NaN);
if (Double.isNaN(ts) || ts <= 0) continue;
if (ts > 1e12) ts /= 1000.0;
if (ts > 946684800) return ts;
}
return Double.NaN;
}
/**
* Converts a server-epoch timestamp to a device-local {@link LocalDateTime}
* using {@link HubTimeSync} so that stale checks (which compare against
* {@code LocalDateTime.now()}) are immune to hub clock skew.
*/
private static LocalDateTime deviceLocalFromServerEpoch(double serverSec) {
double deviceSec = HubTimeSync.toDeviceEpochSeconds(serverSec);
long secs = (long) deviceSec;
int nanos = (int) ((deviceSec - secs) * 1e9);
if (nanos < 0) { secs -= 1; nanos += 1_000_000_000; }
return LocalDateTime.ofInstant(Instant.ofEpochSecond(secs, nanos), ZoneId.systemDefault());
}
public static void applyTimestampFromJson(JSONObject o, AISVessel vessel) {
double ts = pickEpochSeconds(o);
if (!Double.isNaN(ts)) {
vessel.setLastUpdate(deviceLocalFromServerEpoch(ts));
return;
}
if (o != null && o.has("last_update")) {
try {
String s = o.optString("last_update", "");
if (!s.isEmpty()) {
vessel.setLastUpdate(LocalDateTime.parse(s));
return;
}
} catch (Exception ignore) {}
}
vessel.setLastUpdate(LocalDateTime.now());
}
public static void applyTimestampFromJson(JSONObject o, AISNavigationAid aid) {
double ts = pickEpochSeconds(o);
if (!Double.isNaN(ts)) {
aid.setLastUpdate(deviceLocalFromServerEpoch(ts));
return;
}
aid.setLastUpdate(LocalDateTime.now());
}
public static void applyTimestampFromJson(JSONObject o, Vessel vessel) {
double ts = pickEpochSeconds(o);
if (!Double.isNaN(ts)) {
vessel.setLastUpdate(deviceLocalFromServerEpoch(ts));
return;
}
vessel.setLastUpdate(LocalDateTime.now());
}
public static AISVessel aisVesselFromJson(JSONObject o) {
if (o == null) return null;
String mmsi = mmsiString(o);
if (mmsi == null) return null;
AISVessel v = new AISVessel(mmsi);
String name = o.optString("name", o.optString("vessel_name", o.optString("shipname", "")));
if (!name.isEmpty()) v.setVesselName(name);
v.setCallSign(o.optString("call_sign", o.optString("callsign", "")));
double lat = optLat(o);
double lon = optLon(o);
double cog = optCourse(o);
double sog = optSpeed(o);
if (!Double.isNaN(lat) && !Double.isNaN(lon)
&& !Double.isNaN(cog) && !Double.isNaN(sog)) {
v.updatePosition(lat, lon, cog, sog);
} else {
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
v.setLatitude(lat);
v.setLongitude(lon);
}
if (!Double.isNaN(cog)) v.setCourse(cog);
if (!Double.isNaN(sog)) v.setSpeed(sog);
}
double hdg = optHeading(o);
if (!Double.isNaN(hdg)) v.setHeading(hdg);
String cls = o.optString("class", o.optString("vessel_class", o.optString("ship_class", "")));
if (!cls.isEmpty()) v.setVesselClass(cls);
int imo = o.optInt("imo", 0);
if (imo > 0) v.setImo(imo);
int shipType = o.optInt("ship_type", -1);
if (shipType >= 0) v.setVesselType(shipTypeName(shipType));
JSONObject dims = o.optJSONObject("dims");
if (dims != null) {
double a = dims.optDouble("a", 0.0);
double b = dims.optDouble("b", 0.0);
double c = dims.optDouble("c", 0.0);
double d = dims.optDouble("d", 0.0);
if (a + b > 0.0) v.setLength(a + b);
if (c + d > 0.0) v.setWidth(c + d);
}
JSONObject voyage = o.optJSONObject("voyage");
if (voyage != null) {
double draft = voyage.optDouble("draught", Double.NaN);
if (!Double.isNaN(draft) && draft > 0.0) v.setDraft(draft);
String destination = voyage.optString("destination", "");
if (!destination.isEmpty()) v.setDestination(destination);
}
JSONObject dynamic = o.optJSONObject("dynamic");
if (dynamic != null) {
if (dynamic.has("nav_status")) {
v.setNavigationalStatus(String.valueOf(dynamic.optInt("nav_status")));
}
double rot = dynamic.optDouble("rot", Double.NaN);
if (!Double.isNaN(rot)) v.setRateOfTurn(rot);
}
JSONObject signal = o.optJSONObject("signal");
if (signal != null) {
double db = signal.optDouble("last_db", Double.NaN);
if (!Double.isNaN(db)) v.setSignalStrength((int) Math.round(db));
}
if (o.has("position_accuracy")) {
v.setPositionAccuracy(o.optBoolean("position_accuracy", false));
}
applyTimestampFromJson(o, v);
return v;
}
public static AISVessel baseStationFromJson(JSONObject o) {
if (o == null) return null;
String mmsi = mmsiString(o);
if (mmsi == null) return null;
AISVessel v = new AISVessel(mmsi);
v.setVesselClass("Base Station");
v.setVesselType("Base Station");
v.setVesselName("Base Station " + mmsi);
double lat = optLat(o);
double lon = optLon(o);
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
v.setLatitude(lat);
v.setLongitude(lon);
}
if (o.has("accuracy")) {
v.setPositionAccuracy(o.optBoolean("accuracy", false));
}
if (o.has("epfd")) {
v.setDestination("EPFD: " + o.optInt("epfd"));
}
applyTimestampFromJson(o, v);
return v;
}
public static AISNavigationAid navigationAidFromJson(JSONObject o) {
if (o == null) return null;
String mmsi = mmsiString(o);
if (mmsi == null) return null;
AISNavigationAid aid = new AISNavigationAid(mmsi);
aid.setAidName(o.optString("name", "AtoN " + mmsi));
aid.setAidType(o.optInt("type", 0));
double lat = optLat(o);
double lon = optLon(o);
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
aid.setLatitude(lat);
aid.setLongitude(lon);
}
if (o.has("accuracy")) {
aid.setPositionAccuracy(o.optBoolean("accuracy", false));
}
if (o.has("virtual")) {
aid.setOffPositionIndicator(o.optBoolean("virtual", false));
}
applyTimestampFromJson(o, aid);
return aid;
}
public static void mergeVesselOwnship(JSONObject o, Vessel v) {
if (o == null || v == null) return;
String mmsi = mmsiString(o);
if (mmsi != null) v.setMmsi(mmsi);
String name = o.optString("name", o.optString("vessel_name", ""));
if (!name.isEmpty()) v.setVesselName(name);
String cs = o.optString("call_sign", o.optString("callsign", ""));
if (!cs.isEmpty()) v.setCallSign(cs);
double lat = optLat(o);
double lon = optLon(o);
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
v.setLatitude(lat);
v.setLongitude(lon);
}
double cog = optCourse(o);
if (!Double.isNaN(cog)) v.setCourse(cog);
double sog = optSpeed(o);
if (!Double.isNaN(sog)) v.setSpeed(sog);
double hdg = optHeading(o);
if (!Double.isNaN(hdg)) v.setHeading(hdg);
v.setFixTime(System.currentTimeMillis());
v.setFixQuality("HUB");
if (o.has("accuracy") || o.has("h_acc")) {
float acc = (float) o.optDouble("accuracy", o.optDouble("h_acc", -1));
if (acc >= 0) v.setAccuracy(acc);
}
applyTimestampFromJson(o, v);
}
private static String shipTypeName(int code) {
if (code >= 70 && code <= 79) return "Cargo";
if (code >= 80 && code <= 89) return "Tanker";
if (code == 30) return "Fishing";
if (code >= 60 && code <= 69) return "Passenger";
if (code == 36 || code == 37) return "Sailing/Pleasure";
if (code == 31 || code == 32 || code == 52) return "Tug";
if (code == 35) return "Military";
return "Other";
}
}
@@ -0,0 +1,127 @@
package com.grigowashere.aismap.ble.hub;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import org.msgpack.core.MessagePack;
import org.msgpack.core.MessageUnpacker;
import org.msgpack.value.Value;
import org.msgpack.value.ValueType;
import java.nio.charset.StandardCharsets;
import java.util.Map;
/**
* Decodes a reassembled AIS Hub message payload into a {@link JSONObject},
* regardless of whether the server sent it as JSON (proto 0x01) or
* MessagePack (proto 0x02). All downstream code in this app consumes
* {@link JSONObject}, so converting MessagePack → JSONObject here keeps the
* rest of the codebase unchanged.
*
* <p>Why convert to JSONObject instead of surfacing the raw msgpack Value?
* The existing consumers ({@code onDataJson}) and all the mappers in
* {@link AisHubJsonMapper} are written against {@code org.json.*}. Rewriting
* them would be a large blast radius for a wire-format optimization that the
* consumer should be agnostic to. The conversion is O(payload size) and runs
* off the BLE callback thread (see {@code AisHubGattClient#dataExecutor}).
*/
public final class AisHubPayloadCodec {
private AisHubPayloadCodec() {}
/**
* Decode a reassembled payload into a JSONObject.
*
* @param payload raw reassembled bytes (never null when called)
* @param protoVer one of {@link AisHubConstants#PROTO_VERSION_JSON} or
* {@link AisHubConstants#PROTO_VERSION_MSGPACK}
* @return the decoded root object, or null if the payload is malformed
* or the top-level value is not an object/map (we never expect
* a bare array or scalar at the root in this protocol).
*/
@Nullable
public static JSONObject decodeToJsonObject(@NonNull byte[] payload, int protoVer) throws Exception {
if (protoVer == AisHubConstants.PROTO_VERSION_MSGPACK) {
return decodeMsgpack(payload);
}
// Default / 0x01: JSON UTF-8. Unknown versions are treated as JSON
// rather than silently dropped, to be forward-compatible with future
// server versions that still emit JSON-shaped payloads.
String jsonStr = new String(payload, StandardCharsets.UTF_8);
return new JSONObject(jsonStr);
}
/**
* Same as {@link #decodeToJsonObject(byte[], int)} but takes a pre-built
* JSON string (used by the legacy fast path where the assembler already
* produced the UTF-8 string).
*/
@NonNull
public static JSONObject decodeJsonString(@NonNull String json) throws Exception {
return new JSONObject(json);
}
@Nullable
private static JSONObject decodeMsgpack(@NonNull byte[] payload) throws Exception {
try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(payload)) {
if (!unpacker.hasNext()) return null;
Value root = unpacker.unpackValue();
if (root == null || !root.getValueType().isMapType()) {
return null;
}
return (JSONObject) toJson(root);
}
}
/**
* Recursively convert a MessagePack {@link Value} to an {@code org.json.*}
* tree. Maps become {@link JSONObject}, arrays become {@link JSONArray},
* binary blobs become Base64 strings (we don't expect any in this
* protocol, but they must not crash the decoder).
*/
private static Object toJson(@NonNull Value v) throws Exception {
ValueType t = v.getValueType();
switch (t) {
case NIL:
return JSONObject.NULL;
case BOOLEAN:
return v.asBooleanValue().getBoolean();
case INTEGER:
// Prefer long to preserve 64-bit MMSI values etc.
return v.asIntegerValue().toLong();
case FLOAT:
return v.asFloatValue().toDouble();
case STRING:
return v.asStringValue().asString();
case BINARY:
// Shouldn't occur on this wire format; stringify defensively.
return android.util.Base64.encodeToString(
v.asBinaryValue().asByteArray(), android.util.Base64.NO_WRAP);
case ARRAY: {
JSONArray arr = new JSONArray();
for (Value item : v.asArrayValue()) {
arr.put(toJson(item));
}
return arr;
}
case MAP: {
JSONObject obj = new JSONObject();
for (Map.Entry<Value, Value> e : v.asMapValue().entrySet()) {
// Keys in our protocol are always strings. If a non-string
// key sneaks in, stringify it so we don't silently drop data.
String key = e.getKey().getValueType() == ValueType.STRING
? e.getKey().asStringValue().asString()
: e.getKey().toJson();
obj.put(key, toJson(e.getValue()));
}
return obj;
}
case EXTENSION:
default:
// Unknown / extension types: stringify to avoid crashing.
return v.toJson();
}
}
}
@@ -0,0 +1,91 @@
package com.grigowashere.aismap.ble.hub;
import android.util.Log;
/**
* Tracks clock offset between the AIS Hub server ("hub time") and this device
* ("device wall clock"). Used to translate absolute server timestamps
* (`ts`, `server_time`, `last_dynamic_ts`, ...) into device-local epoch seconds
* so that stale-data logic (which works against {@code LocalDateTime.now()})
* stays correct even when the two clocks disagree.
*
* <p>Thread-safety: all state is static/volatile; updates are cheap and safe
* from any thread.
*/
public final class HubTimeSync {
private static final String TAG = "HubTimeSync";
/**
* Minimum server epoch we consider plausible (2010-01-01). Anything below is
* ignored.
*/
private static final double MIN_EPOCH = 1262304000.0;
/**
* Clamp absurd offsets (1 year) to protect from a single bad sample.
*/
private static final double MAX_ABS_OFFSET_SEC = 365L * 24 * 3600;
private static volatile boolean hasOffset = false;
/** offsetSec = serverTime - deviceTime; deviceTime = serverTime - offsetSec. */
private static volatile double offsetSec = 0.0;
private static volatile long lastUpdateElapsedRealtimeMs = 0L;
private HubTimeSync() {}
/**
* Feeds a server timestamp (epoch seconds, possibly milliseconds) and the
* device wall-clock moment when it was observed. Values that look broken
* are dropped.
*/
public static void updateFromServerSeconds(double serverEpochSec) {
if (Double.isNaN(serverEpochSec) || Double.isInfinite(serverEpochSec)) return;
// Some payloads send milliseconds in a double; normalize.
if (serverEpochSec > 1e12) serverEpochSec /= 1000.0;
if (serverEpochSec < MIN_EPOCH) return;
double deviceNowSec = System.currentTimeMillis() / 1000.0;
double newOffset = serverEpochSec - deviceNowSec;
if (Math.abs(newOffset) > MAX_ABS_OFFSET_SEC) {
Log.w(TAG, "Ignoring implausible offset " + newOffset + "s (server=" + serverEpochSec + ")");
return;
}
// Light exponential smoothing to absorb jitter without lagging a real clock drift.
if (hasOffset) {
offsetSec = 0.2 * newOffset + 0.8 * offsetSec;
} else {
offsetSec = newOffset;
}
hasOffset = true;
lastUpdateElapsedRealtimeMs = android.os.SystemClock.elapsedRealtime();
}
public static boolean hasOffset() {
return hasOffset;
}
public static double getOffsetSec() {
return offsetSec;
}
/**
* Converts a server-side epoch-seconds timestamp into a device-side
* epoch-seconds timestamp using the learned offset. If no offset has been
* observed yet, returns {@code serverEpochSec} unchanged (best effort).
*/
public static double toDeviceEpochSeconds(double serverEpochSec) {
if (Double.isNaN(serverEpochSec) || serverEpochSec <= 0) return serverEpochSec;
if (serverEpochSec > 1e12) serverEpochSec /= 1000.0;
if (!hasOffset) return serverEpochSec;
return serverEpochSec - offsetSec;
}
/**
* Reset state (e.g. on BLE stop). Primarily useful for tests.
*/
public static void reset() {
hasOffset = false;
offsetSec = 0.0;
lastUpdateElapsedRealtimeMs = 0L;
}
}
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,7 @@ import com.grigowashere.aismap.data.entity.AISVesselEntity;
import com.grigowashere.aismap.data.mapper.AISVesselMapper;
import com.grigowashere.aismap.models.Vessel;
import com.grigowashere.aismap.models.AISVessel;
import com.grigowashere.aismap.models.AISNavigationAid;
import com.grigowashere.aismap.utils.SettingsManager;
import java.util.List;
import java.util.ArrayList;
@@ -38,7 +39,7 @@ public class DataController {
private DataControllerListener listener;
public interface DataControllerListener {
void onDataRestored(Vessel vessel, List<AISVessel> aisVessels);
void onDataRestored(Vessel vessel, List<AISVessel> aisVessels, List<AISNavigationAid> navigationAids);
void onDataSaved(String dataType, boolean success);
void onDataCleaned(int removedCount);
}
@@ -47,7 +48,7 @@ public class DataController {
this.context = context;
this.repository = new Repository(context);
this.settingsManager = new SettingsManager(context);
this.executor = Executors.newCachedThreadPool();
this.executor = Executors.newSingleThreadExecutor();
// Инициализируем Handler для периодической очистки БД
this.dbCleanupHandler = new Handler(Looper.getMainLooper());
@@ -70,6 +71,8 @@ public class DataController {
Log.i(TAG, "🔄 Запускаем асинхронное восстановление данных из БД...");
executor.execute(() -> {
try {
cleanupStaleAISSync("перед восстановлением");
Log.d(TAG, "📊 Загружаем данные судна из БД...");
VesselEntity latest = repository.getLatestOwnVesselSync();
Vessel vessel = null;
@@ -87,21 +90,29 @@ public class DataController {
Log.d(TAG, "🚢 Загружаем AIS суда из БД...");
List<AISVesselEntity> list = repository.getAllAISSync();
List<AISVessel> aisVessels = new ArrayList<>();
List<AISNavigationAid> navigationAids = new ArrayList<>();
if (list != null && !list.isEmpty()) {
for (AISVesselEntity entity : list) {
// Используем маппер для полного восстановления всех полей
AISVessel vesselModel = AISVesselMapper.toModel(entity);
aisVessels.add(vesselModel);
Log.d(TAG, "AIS судно восстановлено из БД с полными данными: " + vesselModel.getMmsi());
// Проверяем, является ли это навигационным знаком
if ("Navigation Aid".equals(entity.vesselClass)) {
// Создаем AISNavigationAid из entity
AISNavigationAid navigationAid = createNavigationAidFromEntity(entity);
navigationAids.add(navigationAid);
} else {
// Используем маппер для полного восстановления всех полей AIS судна
AISVessel vesselModel = AISVesselMapper.toModel(entity);
aisVessels.add(vesselModel);
}
}
Log.i(TAG, "✅ Восстановлено " + list.size() + " AIS судов из БД с полными данными");
Log.i(TAG, "✅ Восстановлено " + aisVessels.size() + " AIS судов и " + navigationAids.size() + " навигационных знаков из БД");
} else {
Log.d(TAG, "ℹ️ Нет AIS судов в БД");
}
// Уведомляем слушателя о восстановленных данных
if (listener != null) {
listener.onDataRestored(vessel, aisVessels);
listener.onDataRestored(vessel, aisVessels, navigationAids);
}
} catch (Exception e) {
@@ -147,8 +158,8 @@ public class DataController {
try {
// Используем маппер для полной конвертации всех полей
AISVesselEntity entity = AISVesselMapper.toEntity(vessel);
repository.upsertAIS(entity);
Log.d(TAG, "AIS судно сохранено в БД с полными данными: " + vessel.getMmsi());
repository.upsertAISSync(entity);
Log.d(TAG, "AIS судно сохранено в БД: " + vessel.getMmsi());
if (listener != null) {
listener.onDataSaved("ais_vessel", true);
@@ -161,6 +172,134 @@ public class DataController {
}
});
}
/**
* Пакетно сохраняет AIS суда в БД.
*/
public void saveAISVessels(List<AISVessel> vessels) {
if (vessels == null || vessels.isEmpty()) return;
executor.execute(() -> {
try {
List<AISVesselEntity> entities = new ArrayList<>(vessels.size());
for (AISVessel vessel : vessels) {
if (vessel == null || vessel.getMmsi() == null) continue;
AISVesselEntity entity = AISVesselMapper.toEntity(vessel);
if (entity != null) {
entities.add(entity);
}
}
repository.upsertAISBatchSync(entities);
Log.d(TAG, "Пакетно сохранено AIS судов в БД: " + entities.size());
if (listener != null) {
listener.onDataSaved("ais_vessels_batch", true);
}
} catch (Exception e) {
Log.e(TAG, "Ошибка пакетного апсерта AIS в БД: " + e.getMessage(), e);
if (listener != null) {
listener.onDataSaved("ais_vessels_batch", false);
}
}
});
}
/**
* Сохраняет навигационный знак в БД
*/
public void saveNavigationAid(AISNavigationAid navigationAid) {
if (navigationAid == null) return;
executor.execute(() -> {
try {
// Создаем AISVesselEntity из навигационного знака для совместимости с БД
AISVesselEntity entity = new AISVesselEntity(navigationAid.getMmsi());
entity.mmsi = navigationAid.getMmsi();
entity.latitude = navigationAid.getLatitude();
entity.longitude = navigationAid.getLongitude();
entity.vesselName = navigationAid.getAidName();
entity.vesselClass = "Navigation Aid";
entity.vesselType = navigationAid.getAidTypeDescription();
entity.length = navigationAid.getLength();
entity.width = navigationAid.getWidth();
entity.draft = navigationAid.getDraft();
entity.positionAccuracy = navigationAid.isPositionAccuracy();
// Сохраняем время последнего обновления как epoch ms
if (navigationAid.getLastUpdate() != null) {
entity.lastUpdateEpochMs = navigationAid.getLastUpdate().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli();
}
// Добавляем специальные поля для навигационных знаков в destination
StringBuilder destination = new StringBuilder();
destination.append("Type: ").append(navigationAid.getAidType()).append(" (").append(navigationAid.getAidTypeDescription()).append(")");
if (navigationAid.isOffPositionIndicator()) {
destination.append(" - Off Position");
}
if (navigationAid.isRaimFlag()) {
destination.append(" - RAIM Active");
}
entity.destination = destination.toString();
repository.upsertAISSync(entity);
Log.d(TAG, "Навигационный знак сохранен в БД: " + navigationAid.getMmsi());
if (listener != null) {
listener.onDataSaved("navigation_aid", true);
}
} catch (Exception e) {
Log.e(TAG, "Ошибка сохранения навигационного знака в БД: " + e.getMessage(), e);
if (listener != null) {
listener.onDataSaved("navigation_aid", false);
}
}
});
}
/**
* Создает AISNavigationAid из AISVesselEntity
*/
private AISNavigationAid createNavigationAidFromEntity(AISVesselEntity entity) {
AISNavigationAid navigationAid = new AISNavigationAid(entity.mmsi);
navigationAid.setLatitude(entity.latitude);
navigationAid.setLongitude(entity.longitude);
navigationAid.setAidName(entity.vesselName);
navigationAid.setAidTypeDescription(entity.vesselType);
navigationAid.setLength(entity.length);
navigationAid.setWidth(entity.width);
navigationAid.setDraft(entity.draft);
navigationAid.setPositionAccuracy(entity.positionAccuracy);
// Восстанавливаем время из epoch ms, если доступно
if (entity.lastUpdateEpochMs > 0) {
navigationAid.setLastUpdate(java.time.Instant.ofEpochMilli(entity.lastUpdateEpochMs)
.atZone(java.time.ZoneId.systemDefault()).toLocalDateTime());
}
// Парсим специальные поля из destination
if (entity.destination != null && entity.destination.startsWith("Type: ")) {
try {
// Извлекаем тип из destination: "Type: 21 (Cardinal Mark E)"
String typePart = entity.destination.substring(6); // Убираем "Type: "
int parenIndex = typePart.indexOf(' ');
if (parenIndex > 0) {
String typeStr = typePart.substring(0, parenIndex);
int aidType = Integer.parseInt(typeStr);
navigationAid.setAidType(aidType);
}
// Проверяем дополнительные флаги
if (entity.destination.contains("Off Position")) {
navigationAid.setOffPositionIndicator(true);
}
if (entity.destination.contains("RAIM Active")) {
navigationAid.setRaimFlag(true);
}
} catch (Exception e) {
Log.w(TAG, "Ошибка парсинга destination для навигационного знака: " + entity.destination);
}
}
return navigationAid;
}
/**
* Запускает периодическую очистку БД от устаревших AIS целей
@@ -186,25 +325,32 @@ public class DataController {
* Выполняет очистку БД от устаревших AIS целей
*/
private void performDatabaseCleanup() {
try {
int staleRemoveMinutes = settingsManager.getDataStaleRemoveMinutes();
long thresholdEpochMs = System.currentTimeMillis() - (staleRemoveMinutes * 60 * 1000L);
repository.deleteStaleAIS(thresholdEpochMs);
Log.i(TAG, "Выполнена очистка БД от AIS целей старше " + staleRemoveMinutes + " минут");
if (listener != null) {
listener.onDataCleaned(0); // Метод не возвращает количество удаленных записей
executor.execute(() -> {
try {
int removed = cleanupStaleAISSync(null);
if (listener != null) {
listener.onDataCleaned(removed);
}
// Планируем следующую очистку
if (dbCleanupHandler != null && dbCleanupRunnable != null) {
dbCleanupHandler.postDelayed(dbCleanupRunnable, DB_CLEANUP_INTERVAL);
}
} catch (Exception e) {
Log.e(TAG, "Ошибка при очистке БД от устаревших AIS целей: " + e.getMessage(), e);
}
// Планируем следующую очистку
if (dbCleanupHandler != null && dbCleanupRunnable != null) {
dbCleanupHandler.postDelayed(dbCleanupRunnable, DB_CLEANUP_INTERVAL);
}
} catch (Exception e) {
Log.e(TAG, "Ошибка при очистке БД от устаревших AIS целей: " + e.getMessage(), e);
}
});
}
private int cleanupStaleAISSync(String reason) {
int staleRemoveMinutes = settingsManager.getDataStaleRemoveMinutes();
long thresholdEpochMs = System.currentTimeMillis() - (staleRemoveMinutes * 60 * 1000L);
int removed = repository.deleteStaleAISSync(thresholdEpochMs);
String suffix = reason != null ? " " + reason : "";
Log.i(TAG, "Выполнена очистка БД" + suffix + ": удалено=" + removed +
", старше=" + staleRemoveMinutes + " минут");
return removed;
}
/**
@@ -225,14 +225,24 @@ public class GPSLocationListener implements LocationListener {
Log.i(TAG, "📍 Location обновлен: " + location.getLatitude() + ", " + location.getLongitude());
Log.i(TAG, "📍 Точность: " + location.getAccuracy() + "м, время: " + location.getTime());
// Создаем объект судна с полученными данными
Vessel vessel = new Vessel();
vessel.setLatitude(location.getLatitude());
vessel.setLongitude(location.getLongitude());
vessel.setAccuracy(location.getAccuracy());
vessel.setFixTime(location.getTime());
// Определяем качество фикса
// Android Location умеет отдавать скорость (м/с) и курс (°); они нужны
// координатному виджету и стрелке на карте. Преобразуем м/с → узлы.
if (location.hasSpeed()) {
double knots = location.getSpeed() * 1.9438444924;
vessel.setSpeed(knots);
} else {
vessel.setSpeed(0);
}
if (location.hasBearing()) {
vessel.setCourse(location.getBearing());
}
if (location.hasAccuracy()) {
if (location.getAccuracy() <= 3) {
vessel.setFixQuality("HIGH_ACCURACY");
@@ -242,8 +252,7 @@ public class GPSLocationListener implements LocationListener {
vessel.setFixQuality("LOW_ACCURACY");
}
}
// Обновляем информацию о спутниках
vessel.updateGPSQuality(satelliteCount, activeSatellites, pdop, hdop, vdop, location.getAccuracy());
// Отправляем обновление через callback
@@ -40,6 +40,7 @@ public class NMEAController implements
void onVesselUpdated(Vessel vessel);
void onDOPUpdated(double pdop, double hdop, double vdop);
void onAISVesselUpdated(AISVessel vessel);
void onNavigationAidUpdated(com.grigowashere.aismap.models.AISNavigationAid navigationAid);
void onParseError(String error);
void onGPSLocationUpdated(Vessel vessel);
}
@@ -306,6 +307,13 @@ public class NMEAController implements
}
}
@Override
public void onNavigationAidUpdated(com.grigowashere.aismap.models.AISNavigationAid navigationAid) {
if (listener != null) {
listener.onNavigationAidUpdated(navigationAid);
}
}
@Override
public void onParseError(String error) {
Log.e(TAG, "Ошибка парсинга NMEA: " + error);
@@ -3,6 +3,7 @@ package com.grigowashere.aismap.controllers;
import android.util.Log;
import com.grigowashere.aismap.models.Vessel;
import com.grigowashere.aismap.models.AISVessel;
import com.grigowashere.aismap.models.AISNavigationAid;
import com.grigowashere.aismap.utils.LogSender;
import java.util.List;
@@ -33,6 +34,7 @@ public class NMEAParser {
private Vessel ownVessel;
private List<AISVessel> aisVessels;
private List<AISNavigationAid> navigationAids;
private NMEAParserListener listener;
private GPSLocationListener gpsLocationListener;
@@ -55,6 +57,7 @@ public class NMEAParser {
public interface NMEAParserListener {
void onVesselUpdated(Vessel vessel);
void onAISVesselUpdated(AISVessel vessel);
void onNavigationAidUpdated(AISNavigationAid navigationAid);
void onParseError(String error);
void onDOPUpdated(double pdop, double hdop, double vdop);
}
@@ -62,6 +65,7 @@ public class NMEAParser {
public NMEAParser() {
this.ownVessel = new Vessel();
this.aisVessels = new ArrayList<>();
this.navigationAids = new ArrayList<>();
}
public void setListener(NMEAParserListener listener) {
@@ -97,6 +101,7 @@ public class NMEAParser {
String cleanedSentence = cleanNMEASentence(nmeaSentence);
if (cleanedSentence == null) {
Log.w(TAG, "NMEA сообщение не удалось очистить или слишком короткое: " + nmeaSentence);
LogSender.logDroppedNMEA("Очистка не удалась", nmeaSentence, "Сообщение слишком короткое или некорректное");
return;
}
// Диагностика: логируем только каждые 10 секунд
@@ -114,6 +119,7 @@ public class NMEAParser {
String[] fields = cleanedSentence.split(",");
if (fields.length < 2) {
Log.w(TAG, "NMEA сообщение слишком короткое: " + cleanedSentence);
LogSender.logDroppedNMEA("Слишком короткое", cleanedSentence, "Меньше 2 полей: " + fields.length);
return;
}
@@ -121,6 +127,7 @@ public class NMEAParser {
String preamble = fields[0];
if (preamble.length() < 6) {
Log.w(TAG, "Некорректная приамбула: " + preamble);
LogSender.logDroppedNMEA("Некорректная приамбула", cleanedSentence, "Длина приамбуды: " + preamble.length());
return;
}
@@ -159,15 +166,18 @@ public class NMEAParser {
} else {
// Убираем лишние логи - только каждые 10 секунд
long now2 = System.currentTimeMillis();
if (now2 - lastNMEALogTime > 10000) {
Log.d(TAG, "📡 NMEAParser: неподдерживаемый тип: " + messageType);
LogSender.logDroppedNMEA("Неподдерживаемый тип", cleanedSentence, "Тип: " + messageType);
lastNMEALogTime = now2;
}
}
break;
}
} catch (Exception e) {
Log.e(TAG, "Ошибка парсинга NMEA: " + e.getMessage(), e);
LogSender.logError("NMEA_PARSE_EXCEPTION", "Ошибка парсинга NMEA",
String.format("Exception: %s | Message: %s", e.getMessage(), cleanedSentence));
if (listener != null) {
listener.onParseError("Ошибка парсинга NMEA: " + e.getMessage());
}
@@ -509,44 +519,16 @@ public class NMEAParser {
listener.onVesselUpdated(ownVessel);
}
}
/**
* Парсит GLL сообщение (Geographic Position - Latitude/Longitude)
* В гибридном режиме игнорируем
* Формат: $GPGLL,lat,N/S,lon,E/W,time,status,mode*checksum
*/
private void parseGLL(String[] fields) {
// Разбираем время фикса (поле 5), статус (поле 6) и режим (поле 7)
String utcTimeStr = getField(fields, 5); // hhmmss.ss
String status = getField(fields, 6); // A/V
String mode = getField(fields, 7); // A/D/E/M/S/N (может отсутствовать)
// Устанавливаем fixQuality на основе статуса и режима
if (status != null) {
if ("A".equals(status)) {
// Валидные данные: уточняем по mode
if (mode != null) {
switch (mode) {
case "A": ownVessel.setFixQuality("AUTONOMOUS"); break;
case "D": ownVessel.setFixQuality("DIFFERENTIAL"); break;
case "E": ownVessel.setFixQuality("ESTIMATED"); break;
case "M": ownVessel.setFixQuality("MANUAL"); break;
case "S": ownVessel.setFixQuality("SIMULATOR"); break;
case "N": ownVessel.setFixQuality("NOT_VALID"); break;
default: ownVessel.setFixQuality("AUTONOMOUS"); break;
}
} else {
ownVessel.setFixQuality("AUTONOMOUS");
}
} else {
ownVessel.setFixQuality("NOT_VALID");
}
}
// GLL не содержит дату — epoch не пишем, но строковое время сохраним
if (utcTimeStr != null && utcTimeStr.length() >= 6) {
ownVessel.setFixTimeNmea(utcTimeStr);
}
// GLL: не обновляем fixQuality и время фикса — источники: GSA и RMC/ZDA
// Если не в гибридном режиме — обновляем координаты
if (!hybridMode) {
@@ -800,6 +782,13 @@ public class NMEAParser {
ownVessel.setHdop(hdop);
ownVessel.setVdop(vdop);
// Обновляем оценку точности в метрах из HDOP
// Эмпирически принимаем ~5 м на единицу HDOP (типовое допущение для GNSS)
if (hdop > 0) {
float accuracyMeters = (float)(hdop * 5.0);
ownVessel.setAccuracy(accuracyMeters);
}
// Отправляем DOP значения в GPS Location Listener
if (gpsLocationListener != null) {
gpsLocationListener.setDOPValues(pdop, hdop, vdop);
@@ -827,6 +816,7 @@ public class NMEAParser {
// Log.d(TAG, "AIS поля (" + fields.length + "): " + java.util.Arrays.toString(fields));
if (fields.length < 7) {
Log.w(TAG, "AIS сообщение слишком короткое: " + ais);
LogSender.logAISParseErrorWithFullNMEA("Слишком короткое", ais, ais, "Поля: " + fields.length + " < 7");
return;
}
@@ -876,6 +866,7 @@ public class NMEAParser {
// Проверяем контрольную сумму
if (!validateChecksum(ais)) {
//Log.w(TAG, "AIS сообщение с неверной контрольной суммой: " + ais);
LogSender.logAISParseErrorWithFullNMEA("Неверная контрольная сумма", ais, payload, "Checksum validation failed");
return;
}
@@ -883,19 +874,21 @@ public class NMEAParser {
if (payload != null && !payload.trim().isEmpty()) {
if (totalFragments == 1) {
// Одноканальное сообщение - декодируем сразу
decodeAISPayload(payload, channel != null && channel.equals("A") ? 0 : 1);
decodeAISPayload(payload, channel != null && channel.equals("A") ? 0 : 1, ais);
} else {
// Многочастное сообщение - собираем фрагменты
// Используем номер фрагмента как sequenceId если поле пустое
String actualSequenceId = (sequenceId != null && !sequenceId.trim().isEmpty()) ?
sequenceId : String.valueOf(fragmentNumber);
collectAISFragments(actualSequenceId, fragmentNumber, totalFragments, payload, channel != null && channel.equals("A") ? 0 : 1);
collectAISFragments(actualSequenceId, fragmentNumber, totalFragments, payload, channel != null && channel.equals("A") ? 0 : 1, ais);
}
} else {
//Log.w(TAG, "AIS payload пустой, пропускаем сообщение");
LogSender.logAISParseErrorWithFullNMEA("Пустой payload", ais, payload, "Payload is null or empty");
}
} catch (Exception e) {
//Log.e(TAG, "Ошибка парсинга AIS сообщения: " + e.getMessage() + " для сообщения: " + ais);
LogSender.logAISParseErrorWithFullNMEA("Exception", ais, ais, "Exception: " + e.getMessage());
if (listener != null) {
listener.onParseError("Ошибка парсинга AIS: " + e.getMessage());
}
@@ -905,7 +898,7 @@ public class NMEAParser {
/**
* Декодирует AIS payload
*/
private void decodeAISPayload(String payload, int channel) {
private void decodeAISPayload(String payload, int channel, String fullNMEAMessage) {
try {
// Определяем тип AIS сообщения по первым 6 битам
String messageTypeBits = decodeAISField(payload, 0, 6);
@@ -953,10 +946,12 @@ public class NMEAParser {
break;
default:
Log.d(TAG, "Неподдерживаемый тип AIS сообщения: " + messageType);
LogSender.logAISParseErrorWithFullNMEA("Неподдерживаемый тип", fullNMEAMessage, payload, "Тип: " + messageType);
break;
}
} catch (Exception e) {
Log.e(TAG, "Ошибка декодирования AIS payload: " + e.getMessage(), e);
LogSender.logAISParseErrorWithFullNMEA("Payload decode exception", fullNMEAMessage, payload, "Exception: " + e.getMessage());
}
}
@@ -964,7 +959,7 @@ public class NMEAParser {
* Собирает фрагменты многочастного AIS сообщения
*/
private void collectAISFragments(String sequenceId, int fragmentNumber, int totalFragments,
String payload, int channel) {
String payload, int channel, String fullNMEAMessage) {
String key = sequenceId + "_" + channel;
// Log.d(TAG, String.format("Собираем AIS фраг мент: %d/%d для %s",
@@ -1007,7 +1002,7 @@ public class NMEAParser {
// Log.d(TAG, "Собрано полное AIS сообщение длиной " + completePayload.length() + " символов");
// Декодируем полное сообщение
decodeAISPayload(completePayload, channel);
decodeAISPayload(completePayload, channel, fullNMEAMessage);
// Удаляем собранные фрагменты
aisFragments.remove(key);
@@ -1079,6 +1074,9 @@ public class NMEAParser {
", payloadLength=" + payload.length() +
", binaryLength=" + fullBinary.length()
);
LogSender.logAISParseError("AIS поле за границами", payload,
String.format("startBit=%d, length=%d, payloadLength=%d, binaryLength=%d",
startBit, length, payload.length(), fullBinary.length()));
// Если поле выходит за границы, возвращаем то что есть, дополняя нулями
if (startBit >= fullBinary.length()) {
// Если startBit уже за границами, возвращаем строку из нулей
@@ -1172,9 +1170,11 @@ public class NMEAParser {
// Проверяем, что координаты в разумных пределах
if (latitude < -90 || latitude > 90) {
Log.w(TAG, "Широта вне допустимых пределов: " + latitude);
LogSender.logAISParseError("Некорректная широта", payload, "Latitude: " + latitude);
}
if (longitude < -180 || longitude > 180) {
Log.w(TAG, "Долгота вне допустимых пределов: " + longitude);
LogSender.logAISParseError("Некорректная долгота", payload, "Longitude: " + longitude);
}
Log.d(TAG, String.format("AIS Position: MMSI=%d, lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, status=%d, heading=%.1f, rot=%.1f",
@@ -1223,6 +1223,7 @@ public class NMEAParser {
} catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Position Report: " + e.getMessage(), e);
LogSender.logAISParseError("Position Report decode exception", payload, "Exception: " + e.getMessage());
}
}
@@ -1364,6 +1365,7 @@ public class NMEAParser {
} catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Static Data: " + e.getMessage(), e);
LogSender.logAISParseError("Static Data decode exception", payload, "Exception: " + e.getMessage());
}
}
@@ -1625,6 +1627,46 @@ public class NMEAParser {
}
}
/**
* Получает тип навигационного знака по коду согласно стандарту AIS
*/
private String getAidToNavigationType(int aidType) {
switch (aidType) {
case 0: return "Reference point";
case 1: return "RACON (radar transponder marking a navigation hazard)";
case 2: return "Fixed structure off shore, such as oil platforms, wind farms, rigs";
case 3: return "Spare, Reserved for future use";
case 4: return "Light, without sectors";
case 5: return "Light, with sectors";
case 6: return "Leading Light Front";
case 7: return "Leading Light Rear";
case 8: return "Beacon, Cardinal N";
case 9: return "Beacon, Cardinal E";
case 10: return "Beacon, Cardinal S";
case 11: return "Beacon, Cardinal W";
case 12: return "Beacon, Port hand";
case 13: return "Beacon, Starboard hand";
case 14: return "Beacon, Preferred Channel port hand";
case 15: return "Beacon, Preferred Channel starboard hand";
case 16: return "Beacon, Isolated danger";
case 17: return "Beacon, Safe water";
case 18: return "Beacon, Special mark";
case 19: return "Cardinal Mark N";
case 20: return "Cardinal Mark E";
case 21: return "Cardinal Mark S";
case 22: return "Cardinal Mark W";
case 23: return "Port hand Mark";
case 24: return "Starboard hand Mark";
case 25: return "Preferred Channel port hand";
case 26: return "Preferred Channel starboard hand";
case 27: return "Isolated danger";
case 28: return "Safe water";
case 29: return "Special mark";
case 30: return "Light Vessel / LANBY / Rigs";
default: return "Unknown Aid-to-Navigation";
}
}
/**
* Получает тип судна по коду согласно стандарту AIS
*/
@@ -1752,6 +1794,23 @@ public class NMEAParser {
return newVessel;
}
/**
* Находит существующий навигационный знак или создает новый
*/
private AISNavigationAid findOrCreateNavigationAid(String mmsi) {
for (AISNavigationAid aid : navigationAids) {
if (mmsi.equals(aid.getMmsi())) {
return aid;
}
}
// Создаем новый навигационный знак
AISNavigationAid newAid = new AISNavigationAid(mmsi);
navigationAids.add(newAid);
Log.d(TAG, "Создан новый навигационный знак: " + mmsi);
return newAid;
}
/**
* Очищает устаревшие AIS суда (данные старше 10 минут)
*/
@@ -1793,6 +1852,54 @@ public class NMEAParser {
return null;
}
/**
* Получает список всех навигационных знаков
*/
public List<AISNavigationAid> getNavigationAids() {
return new ArrayList<>(navigationAids);
}
/**
* Получает количество активных навигационных знаков
*/
public int getActiveNavigationAidCount() {
cleanupStaleNavigationAids();
return navigationAids.size();
}
/**
* Получает навигационный знак по MMSI
*/
public AISNavigationAid getNavigationAidByMMSI(String mmsi) {
for (AISNavigationAid aid : navigationAids) {
if (mmsi.equals(aid.getMmsi())) {
return aid;
}
}
return null;
}
/**
* Очищает устаревшие навигационные знаки (данные старше 10 минут)
*/
public void cleanupStaleNavigationAids() {
java.util.Iterator<AISNavigationAid> iterator = navigationAids.iterator();
int removedCount = 0;
while (iterator.hasNext()) {
AISNavigationAid aid = iterator.next();
if (aid.isDataStale(10)) {
iterator.remove();
removedCount++;
Log.d(TAG, "Удален устаревший навигационный знак: " + aid.getMmsi());
}
}
if (removedCount > 0) {
Log.i(TAG, "Удалено " + removedCount + " устаревших навигационных знаков");
}
}
/**
* Обновляет статус активности AIS судов
*/
@@ -1822,6 +1929,8 @@ public class NMEAParser {
return isPositive ? result : -result;
} catch (NumberFormatException e) {
Log.w(TAG, "Ошибка парсинга координаты: " + coordinate + ", ошибка: " + e.getMessage());
LogSender.logError("COORDINATE_PARSE_ERROR", "Ошибка парсинга координаты",
String.format("Coordinate: %s, Error: %s", coordinate, e.getMessage()));
return 0.0;
}
}
@@ -1994,6 +2103,7 @@ public class NMEAParser {
} catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Base Station Report: " + e.getMessage(), e);
LogSender.logAISParseError("Base Station Report decode exception", payload, "Exception: " + e.getMessage());
}
}
@@ -2040,6 +2150,7 @@ public class NMEAParser {
} catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Safety Broadcast: " + e.getMessage(), e);
LogSender.logAISParseError("Safety Broadcast decode exception", payload, "Exception: " + e.getMessage());
}
}
@@ -2162,6 +2273,7 @@ public class NMEAParser {
} catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Class B Position Report: " + e.getMessage(), e);
LogSender.logAISParseError("Class B Position Report decode exception", payload, "Exception: " + e.getMessage());
}
}
@@ -2178,6 +2290,7 @@ public class NMEAParser {
if (totalBits < 312) { // Минимум для Extended Class B
Log.w(TAG, "Extended Class B payload слишком короткий: " + totalBits + " бит, ожидается минимум 312");
LogSender.logAISParseError("Extended Class B слишком короткий", payload, "Bits: " + totalBits + " < 312");
return;
}
@@ -2314,6 +2427,8 @@ public class NMEAParser {
// Проверяем, что размеры в разумных пределах (0-1000 метров)
if (dimA > 1000 || dimB > 1000 || dimC > 1000 || dimD > 1000) {
Log.w(TAG, "Размеры судна выходят за разумные пределы: A=" + dimA + ", B=" + dimB + ", C=" + dimC + ", D=" + dimD);
LogSender.logAISParseError("Некорректные размеры", payload,
String.format("Dimensions out of range: A=%d, B=%d, C=%d, D=%d", dimA, dimB, dimC, dimD));
// Возможно, мы неправильно интерпретируем битовые поля
// Попробуем интерпретировать как 6-битные значения
dimA = dimA & 0x3F; // Берем только младшие 6 бит
@@ -2380,6 +2495,7 @@ public class NMEAParser {
} catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Extended Class B Position Report: " + e.getMessage(), e);
LogSender.logAISParseError("Extended Class B decode exception", payload, "Exception: " + e.getMessage());
}
}
@@ -2388,40 +2504,65 @@ public class NMEAParser {
*/
private void decodeAidToNavigationReport(String payload) {
try {
// Log.d(TAG, "Декодируем Aid-to-Navigation Report, payload: " + payload + " (длина: " + payload.length() + ")");
Log.d(TAG, "Декодируем Aid-to-Navigation Report, payload: " + payload + " (длина: " + payload.length() + ")");
// Проверяем длину payload - для Aid-to-Navigation должно быть достаточно битов
int totalBits = payload.length() * 6;
Log.d(TAG, "Общая длина payload в битах: " + totalBits);
if (totalBits < 192) { // Минимум для основных полей
Log.w(TAG, "Aid-to-Navigation payload слишком короткий: " + totalBits + " бит, ожидается минимум 192");
LogSender.logAISParseError("Aid-to-Navigation слишком короткий", payload, "Bits: " + totalBits + " < 192");
return;
}
// MMSI (30 бит) - начинается с бита 8
String mmsiBits = decodeAISField(payload, 8, 30);
int mmsi = Integer.parseInt(mmsiBits, 2);
// Убираем лишние логи
// Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi);
Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi);
// Aid Type (5 бит) - бит 38
String aidTypeBits = decodeAISField(payload, 38, 5);
int aidType = Integer.parseInt(aidTypeBits, 2);
// Log.d(TAG, "Aid Type bits: " + aidTypeBits + " = " + aidType);
Log.d(TAG, "Aid Type bits: " + aidTypeBits + " = " + aidType);
// Name (120 бит) - бит 43
String nameBits = decodeAISField(payload, 43, 120);
String aidName = decodeAISString(nameBits);
// Log.d(TAG, "Name bits: " + nameBits + " = '" + aidName + "'");
Log.d(TAG, "Name bits: " + nameBits + " = '" + aidName + "'");
// Position Accuracy (1 бит) - бит 163
String accuracyBits = decodeAISField(payload, 163, 1);
int accuracy = Integer.parseInt(accuracyBits, 2);
// Log.d(TAG, "Accuracy bits: " + accuracyBits + " = " + accuracy);
Log.d(TAG, "Accuracy bits: " + accuracyBits + " = " + accuracy);
// Longitude (28 бит) - бит 164
String lonBits = decodeAISField(payload, 164, 28);
double longitude = parseAISCoordinate(lonBits, 28);
// Убираем лишние логи
// Log.d(TAG, "Longitude bits: " + lonBits + " = " + longitude);
Log.d(TAG, "Longitude bits: " + lonBits + " = " + longitude);
// Latitude (27 бит) - бит 192
String latBits = decodeAISField(payload, 192, 27);
double latitude = parseAISCoordinate(latBits, 27);
// Убираем лишние логи
// Log.d(TAG, "Latitude bits: " + latBits + " = " + latitude);
Log.d(TAG, "Latitude bits: " + latBits + " = " + latitude);
// Создаем навигационный знак
AISNavigationAid navigationAid = findOrCreateNavigationAid(String.valueOf(mmsi));
navigationAid.setAidName(aidName);
navigationAid.setAidType(aidType);
navigationAid.updatePosition(latitude, longitude);
navigationAid.setPositionAccuracy(accuracy == 1);
// Проверяем, есть ли достаточно битов для размеров
if (totalBits < 235) {
Log.w(TAG, "Aid-to-Navigation - недостаточно битов для размеров: " + totalBits + " < 235");
navigationAid.setLastUpdate(java.time.LocalDateTime.now());
if (listener != null) {
listener.onNavigationAidUpdated(navigationAid);
}
return;
}
// Dimension Reference (4 бита) - бит 219
String dimRefABits = decodeAISField(payload, 219, 4);
@@ -2434,6 +2575,13 @@ public class NMEAParser {
int dimRefC = Integer.parseInt(dimRefCBits, 2);
int dimRefD = Integer.parseInt(dimRefDBits, 2);
Log.d(TAG, "Dimension Reference: A=" + dimRefA + ", B=" + dimRefB + ", C=" + dimRefC + ", D=" + dimRefD);
navigationAid.setDimRefA(dimRefA);
navigationAid.setDimRefB(dimRefB);
navigationAid.setDimRefC(dimRefC);
navigationAid.setDimRefD(dimRefD);
// Vessel Dimensions (30 бит) - бит 235
// Dim.A (10 бит) - от носа до антенны
String dimABits = decodeAISField(payload, 235, 10);
@@ -2443,43 +2591,92 @@ public class NMEAParser {
String dimCBits = decodeAISField(payload, 255, 10);
// Dim.D (10 бит) - от антенны до правого борта
String dimDBits = decodeAISField(payload, 265, 10);
// Draft (8 бит) - осадка
String draftBits = decodeAISField(payload, 275, 8);
int dimA = Integer.parseInt(dimABits, 2);
int dimB = Integer.parseInt(dimBBits, 2);
int dimC = Integer.parseInt(dimCBits, 2);
int dimD = Integer.parseInt(dimDBits, 2);
Log.d(TAG, "Raw dimension bits - Dim.A: " + dimABits + ", Dim.B: " + dimBBits + ", Dim.C: " + dimCBits + ", Dim.D: " + dimDBits);
Log.d(TAG, "Raw dimensions - A=" + dimA + ", B=" + dimB + ", C=" + dimC + ", D=" + dimD);
// Размеры судна рассчитываются как:
// Длина = Dim.A + Dim.B (от носа до антенны + от антенны до кормы)
// Ширина = Dim.C + Dim.D (от левого борта до антенны + от антенны до правого борта)
double length = dimA + dimB;
double width = dimC + dimD;
double draft = Integer.parseInt(draftBits, 2) / 10.0;
// Log.d(TAG, String.format("AIS Aid-to-Navigation: MMSI=%d, type=%d, name='%s', lat=%.6f, lon=%.6f, L=%.1f, W=%.1f, D=%.1f",
// mmsi, aidType, aidName, latitude, longitude, length, width, draft));
Log.d(TAG, "Dimensions - Total Length (A+B): " + length + "m");
Log.d(TAG, "Dimensions - Total Width (C+D): " + width + "m");
// Создаем или обновляем AIS судно (навигационный знак)
AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi));
vessel.updatePosition(latitude, longitude, 0.0, 0.0);
vessel.setPositionAccuracy(accuracy == 1);
vessel.setVesselName(aidName);
vessel.setVesselType("Aid-to-Navigation");
vessel.setLength(length);
vessel.setWidth(width);
vessel.setDraft(draft);
vessel.setLastUpdate(java.time.LocalDateTime.now());
vessel.setVesselClass("Navigation Aid");
navigationAid.setLength(length);
navigationAid.setWidth(width);
// Проверяем, есть ли достаточно битов для дополнительных полей
if (totalBits >= 283) {
// Draft (8 бит) - осадка
String draftBits = decodeAISField(payload, 275, 8);
double draft = Integer.parseInt(draftBits, 2) / 10.0;
Log.d(TAG, "Draft bits: " + draftBits + " = " + draft);
navigationAid.setDraft(draft);
// EPFD Type (4 бита) - бит 283
if (totalBits >= 287) {
String epfdBits = decodeAISField(payload, 283, 4);
int epfdType = Integer.parseInt(epfdBits, 2);
Log.d(TAG, "EPFD Type bits: " + epfdBits + " = " + epfdType);
navigationAid.setEpfdType(epfdType);
// UTC Second (6 бит) - бит 287
if (totalBits >= 293) {
String utcSecondBits = decodeAISField(payload, 287, 6);
int utcSecond = Integer.parseInt(utcSecondBits, 2);
Log.d(TAG, "UTC Second bits: " + utcSecondBits + " = " + utcSecond);
navigationAid.setUtcSecond(utcSecond);
// Off Position Indicator (1 бит) - бит 293
if (totalBits >= 294) {
String offPosBits = decodeAISField(payload, 293, 1);
boolean offPosition = Integer.parseInt(offPosBits, 2) == 1;
Log.d(TAG, "Off Position Indicator bits: " + offPosBits + " = " + offPosition);
navigationAid.setOffPositionIndicator(offPosition);
// Regional Reserved (8 бит) - бит 294
if (totalBits >= 302) {
String regionalBits = decodeAISField(payload, 294, 8);
int regional = Integer.parseInt(regionalBits, 2);
Log.d(TAG, "Regional Reserved bits: " + regionalBits + " = " + regional);
navigationAid.setRegionalReserved(regional);
// RAIM flag (1 бит) - бит 302
if (totalBits >= 303) {
String raimBits = decodeAISField(payload, 302, 1);
boolean raim = Integer.parseInt(raimBits, 2) == 1;
Log.d(TAG, "RAIM flag bits: " + raimBits + " = " + raim);
navigationAid.setRaimFlag(raim);
}
}
}
}
}
} else {
Log.d(TAG, "Aid-to-Navigation - недостаточно битов для дополнительных полей: " + totalBits + " < 283");
}
navigationAid.setLastUpdate(java.time.LocalDateTime.now());
Log.d(TAG, String.format("AIS Aid-to-Navigation: MMSI=%d, type=%d (%s), name='%s', lat=%.6f, lon=%.6f, L=%.1f, W=%.1f, D=%.1f",
mmsi, aidType, navigationAid.getAidTypeDescription(), aidName, latitude, longitude,
navigationAid.getLength(), navigationAid.getWidth(), navigationAid.getDraft()));
// Уведомляем слушателя
if (listener != null) {
listener.onAISVesselUpdated(vessel);
listener.onNavigationAidUpdated(navigationAid);
}
} catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Aid-to-Navigation Report: " + e.getMessage(), e);
LogSender.logAISParseError("Aid-to-Navigation Report decode exception", payload, "Exception: " + e.getMessage());
}
}
@@ -2589,6 +2786,7 @@ public class NMEAParser {
} else {
Log.w(TAG, "Static Data Part B - недостаточно битов для размеров: " + totalBits + " < 168");
LogSender.logAISParseError("Static Data Part B слишком короткий", payload, "Bits: " + totalBits + " < 168");
// Используем нулевые размеры
length = 0.0;
width = 0.0;
@@ -2623,6 +2821,7 @@ public class NMEAParser {
} catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Static Data Report: " + e.getMessage(), e);
LogSender.logAISParseError("Static Data Report decode exception", payload, "Exception: " + e.getMessage());
}
}
}
@@ -28,10 +28,23 @@ public class Repository {
ioExecutor.execute(() -> aisVesselDao.upsert(entity));
}
public void upsertAISSync(AISVesselEntity entity) {
aisVesselDao.upsert(entity);
}
public void upsertAISBatchSync(List<AISVesselEntity> entities) {
if (entities == null || entities.isEmpty()) return;
aisVesselDao.upsertAll(entities);
}
public void deleteStaleAIS(long thresholdEpochMs) {
ioExecutor.execute(() -> aisVesselDao.deleteStale(thresholdEpochMs));
}
public int deleteStaleAISSync(long thresholdEpochMs) {
return aisVesselDao.deleteStale(thresholdEpochMs);
}
public List<AISVesselEntity> getAllAISSync() {
return aisVesselDao.getAll();
}
@@ -16,6 +16,9 @@ public interface AISVesselDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
void upsert(AISVesselEntity entity);
@Insert(onConflict = OnConflictStrategy.REPLACE)
void upsertAll(List<AISVesselEntity> entities);
@Update
void update(AISVesselEntity entity);
@@ -29,7 +32,7 @@ public interface AISVesselDao {
AISVesselEntity getByMmsi(String mmsi);
@Query("DELETE FROM ais_vessels WHERE lastUpdateEpochMs < :threshold")
void deleteStale(long threshold);
int deleteStale(long threshold);
}
@@ -16,6 +16,7 @@ import org.mapsforge.core.graphics.Bitmap;
import android.view.ViewGroup;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
@@ -112,14 +113,24 @@ public class MapForgeImpl implements MapInterface {
@Override
public void updateAISVesselPosition(AISVessel vessel) {
Marker marker = aisMarkers.get(vessel.getMmsi());
if (marker != null) {
LatLong newPosition = new LatLong(vessel.getLatitude(), vessel.getLongitude());
marker.setLatLong(newPosition);
// Используем heading вместо course для поворота маркера AIS судна
double rotationAngle = vessel.getHeading() > 0 ? vessel.getHeading() : vessel.getCourse();
org.mapsforge.core.graphics.Bitmap icon = createMapForgeIcon(Color.RED, rotationAngle);
marker.setBitmap(icon);
if (marker == null) {
addAISVesselMarker(vessel);
return;
}
LatLong newPosition = new LatLong(vessel.getLatitude(), vessel.getLongitude());
marker.setLatLong(newPosition);
// Используем heading вместо course для поворота маркера AIS судна
double rotationAngle = vessel.getHeading() > 0 ? vessel.getHeading() : vessel.getCourse();
org.mapsforge.core.graphics.Bitmap icon = createMapForgeIcon(Color.RED, rotationAngle);
marker.setBitmap(icon);
}
@Override
public void updateAISVesselPositions(List<AISVessel> vessels) {
if (vessels == null) return;
for (AISVessel vessel : vessels) {
updateAISVesselPosition(vessel);
}
}
@@ -3,6 +3,8 @@ package com.grigowashere.aismap.maps;
import com.grigowashere.aismap.models.Vessel;
import com.grigowashere.aismap.models.AISVessel;
import java.util.List;
/**
* Интерфейс для работы с картами
* Позволяет использовать разные SDK карт
@@ -38,6 +40,11 @@ public interface MapInterface {
* Обновление позиции AIS судна
*/
void updateAISVesselPosition(AISVessel vessel);
/**
* Пакетное обновление AIS судов
*/
void updateAISVesselPositions(List<AISVessel> vessels);
/**
* Удаление метки AIS судна
@@ -8,6 +8,7 @@ import android.graphics.drawable.Drawable;
import android.util.Log;
import com.grigowashere.aismap.models.AISVessel;
import com.grigowashere.aismap.models.AISNavigationAid;
import com.grigowashere.aismap.models.Vessel;
import com.grigowashere.aismap.utils.SettingsManager;
import com.grigowashere.aismap.utils.GeoUtils;
@@ -32,6 +33,8 @@ import org.maplibre.android.style.expressions.Expression;
import org.maplibre.android.style.layers.PropertyFactory;
import org.maplibre.android.style.layers.SymbolLayer;
import org.maplibre.android.style.sources.GeoJsonSource;
import org.maplibre.android.style.sources.RasterSource;
import org.maplibre.android.style.layers.RasterLayer;
import java.util.HashMap;
import java.util.Map;
@@ -55,6 +58,10 @@ public class MapLibreMapImpl implements MapInterface {
private static final String LAYER_AIS_PATHS = "ais_paths_layer";
private static final String SOURCE_AIS_PREDICTIONS = "ais_predictions_source";
private static final String LAYER_AIS_PREDICTIONS = "ais_predictions_layer";
private static final String SOURCE_SEAMARKS = "seamarks_source";
private static final String LAYER_SEAMARKS = "seamarks_layer";
private static final String SOURCE_NAVIGATION_AIDS = "navigation_aids_source";
private static final String LAYER_NAVIGATION_AIDS = "navigation_aids_layer";
private static final String IMAGE_VESSEL_OWN = "ownship";
private static final String IMAGE_VESSEL_A = "vessel_icon_a";
private static final String IMAGE_VESSEL_B = "vessel_icon_b";
@@ -69,6 +76,44 @@ public class MapLibreMapImpl implements MapInterface {
private static final String TYPE_NAVY = "navy";
private static final String TYPE_OTHER = "other";
private static final String IMAGE_VESSEL_LOSING = "vessel_icon_losing";
// Navigation Aid icons - полный набор для всех типов буйков
private static final String IMAGE_NAVIGATION_AID = "navigation_aid";
// Кардинальные буи (North, East, South, West)
private static final String IMAGE_BUOY_CARDINAL_N = "buoy_cardinal_n";
private static final String IMAGE_BUOY_CARDINAL_E = "buoy_cardinal_e";
private static final String IMAGE_BUOY_CARDINAL_S = "buoy_cardinal_s";
private static final String IMAGE_BUOY_CARDINAL_W = "buoy_cardinal_w";
private static final String IMAGE_BUOY_CARDINAL = "buoy_cardinal"; // Общий fallback
// Латеральные буи (Port/Starboard)
private static final String IMAGE_BUOY_PORT = "buoy_port";
private static final String IMAGE_BUOY_STARBOARD = "buoy_starboard";
// Предпочтительные каналы
private static final String IMAGE_BUOY_PREFERRED_PORT = "buoy_preferred_port";
private static final String IMAGE_BUOY_PREFERRED_STARBOARD = "buoy_preferred_starboard";
// Маяки и огни
private static final String IMAGE_LIGHT = "light";
private static final String IMAGE_LIGHT_SECTOR = "light_sector";
private static final String IMAGE_LIGHT_LEADING_FRONT = "light_leading_front";
private static final String IMAGE_LIGHT_LEADING_REAR = "light_leading_rear";
// Буи и знаки
private static final String IMAGE_BEACON = "beacon";
private static final String IMAGE_BEACON_ISOLATED_DANGER = "beacon_isolated_danger";
private static final String IMAGE_BEACON_SAFE_WATER = "beacon_safe_water";
private static final String IMAGE_BEACON_SPECIAL = "beacon_special";
// Платформы и плавучие маяки
private static final String IMAGE_BASE_STATION = "base_station";
private static final String IMAGE_LIGHT_VESSEL = "light_vessel";
private static final String IMAGE_PLATFORM = "platform";
// RACON и специальные знаки
private static final String IMAGE_RACON = "racon";
private static final String IMAGE_REFERENCE_POINT = "reference_point";
// Status overlay drawable names present in res/drawable
private static final String STATUS_UNDER_WAY_ENGINE = "engine";
private static final String STATUS_AT_ANCHOR = "achor"; // anchor icon filename
@@ -177,6 +222,7 @@ public class MapLibreMapImpl implements MapInterface {
private final Map<String, JSONObject> idToFeature = new HashMap<>();
// Хранилище последних модельных объектов для кликов
private final Map<String, AISVessel> idToAisVessel = new HashMap<>();
private final Map<String, AISNavigationAid> idToNavigationAid = new HashMap<>();
private Vessel lastOwnVessel;
// Буфер координат пути собственного судна
private final JSONArray ownPathCoords = new JSONArray();
@@ -188,6 +234,10 @@ public class MapLibreMapImpl implements MapInterface {
private MarkerClickListener markerClickListener;
// Pending центрирование до готовности карты/стиля
private Double pendingCenterLat = null;
private Double pendingCenterLon = null;
public MapLibreMapImpl(Context context, MapView mapView) {
this.context = context;
this.mapView = mapView;
@@ -274,6 +324,18 @@ public class MapLibreMapImpl implements MapInterface {
try {
mapView.getMapAsync(map -> {
maplibreMap = map;
// Отключаем встроенный компас MapLibre (он появлялся в углу
// при ненулевом bearing и дублировал наш компас), а также
// стандартные UI-элементы, которые нам не нужны.
try {
if (maplibreMap.getUiSettings() != null) {
maplibreMap.getUiSettings().setCompassEnabled(false);
maplibreMap.getUiSettings().setAttributionEnabled(false);
maplibreMap.getUiSettings().setLogoEnabled(false);
}
} catch (Exception e) {
Log.w(TAG, "Не удалось настроить UI MapLibre: " + e.getMessage());
}
maplibreMap.setStyle("https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", loadedStyle -> {
style = loadedStyle;
ensureSourcesAndLayers();
@@ -302,6 +364,15 @@ public class MapLibreMapImpl implements MapInterface {
staleHandler.removeCallbacks(staleRunnable);
staleHandler.postDelayed(staleRunnable, 5_000L);
// Если было отложенное центрирование применим его сразу после загрузки стиля
if (pendingCenterLat != null && pendingCenterLon != null) {
final double lat = pendingCenterLat;
final double lon = pendingCenterLon;
pendingCenterLat = null;
pendingCenterLon = null;
uiHandler.post(() -> centerOnPosition(lat, lon));
}
});
});
} catch (Exception e) {
@@ -502,6 +573,66 @@ public class MapLibreMapImpl implements MapInterface {
}
}
@Override
public void updateAISVesselPositions(List<AISVessel> vessels) {
if (vessels == null || vessels.isEmpty()) return;
try {
int updated = 0;
int removed = 0;
int skipped = 0;
for (AISVessel vessel : vessels) {
if (vessel == null || vessel.getMmsi() == null) {
skipped++;
continue;
}
if (!GeoUtils.isValidCoordinates(vessel.getLatitude(), vessel.getLongitude())) {
skipped++;
continue;
}
if (vessel.shouldBeRemoved(settingsManager.getDataStaleRemoveMinutes())) {
idToFeature.remove(vessel.getMmsi());
idToAisVessel.remove(vessel.getMmsi());
aisPathFeatures.remove(vessel.getMmsi());
aisPredictionFeatures.remove(vessel.getMmsi());
removed++;
continue;
}
idToAisVessel.put(vessel.getMmsi(), vessel);
JSONObject feature = buildFeature(
vessel.getMmsi(),
vessel.getLongitude(),
vessel.getLatitude(),
getDisplayCourse(vessel),
false
);
try {
boolean stale = vessel.isDataStale(settingsManager.getDataStaleWarningMinutes());
JSONObject props = feature.getJSONObject("properties");
props.put("icon", pickIconNameFor(vessel));
props.put("stale", stale);
String statusIcon = mapStatusToIcon(vessel.getNavigationalStatus());
if (statusIcon != null) {
props.put("status_icon", statusIcon);
} else {
props.remove("status_icon");
}
} catch (Exception ignore) {}
idToFeature.put(vessel.getMmsi(), feature);
updated++;
}
refreshGeoJson();
Log.d(TAG, "AIS batch update: updated=" + updated +
", removed=" + removed + ", skipped=" + skipped);
} catch (Exception e) {
Log.e(TAG, "updateAISVesselPositions: " + e.getMessage(), e);
}
}
@Override
public void removeAISVesselMarker(String mmsi) {
if (mmsi == null) return;
@@ -555,13 +686,122 @@ public class MapLibreMapImpl implements MapInterface {
uiHandler.post(() -> refreshGeoJson());
}
/**
* Добавляет навигационный знак на карту
*/
public void addNavigationAidMarker(AISNavigationAid navigationAid) {
updateNavigationAidPosition(navigationAid);
}
/**
* Обновляет позицию навигационного знака на карте
*/
public void updateNavigationAidPosition(AISNavigationAid navigationAid) {
if (!isStyleValid()) {
Log.w(TAG, "Style not ready, skipping updateNavigationAidPosition");
return;
}
try {
String mmsi = navigationAid.getMmsi();
Log.d(TAG, "updateNavigationAidPosition: Navigation aid " + mmsi + " at " +
navigationAid.getLatitude() + "," + navigationAid.getLongitude() +
", type=" + navigationAid.getAidType() + ", name='" + navigationAid.getAidName() + "'");
// Создаем GeoJSON фичу для навигационного знака
JSONObject feature = buildNavigationAidFeature(navigationAid);
idToFeature.put(mmsi, feature);
idToNavigationAid.put(mmsi, navigationAid);
// Обновляем источник данных
refreshNavigationAidsSource();
Log.d(TAG, "Navigation aid " + mmsi + " updated on map");
} catch (Exception e) {
Log.e(TAG, "Error updating navigation aid position: " + e.getMessage(), e);
}
}
/**
* Удаляет навигационный знак с карты
*/
public void removeNavigationAidMarker(String mmsi) {
if (!isStyleValid()) {
Log.w(TAG, "Style not ready, skipping removeNavigationAidMarker");
return;
}
try {
// Удаляем из хранилищ
idToFeature.remove(mmsi);
idToNavigationAid.remove(mmsi);
// Обновляем источник данных
refreshNavigationAidsSource();
Log.d(TAG, "Navigation aid " + mmsi + " removed from map");
} catch (Exception e) {
Log.e(TAG, "Error removing navigation aid marker: " + e.getMessage(), e);
}
}
/**
* Очищает все навигационные знаки с карты
*/
public void clearNavigationAidMarkers() {
if (!isStyleValid()) {
Log.w(TAG, "Style not ready, skipping clearNavigationAidMarkers");
return;
}
try {
GeoJsonSource source = style.getSourceAs(SOURCE_NAVIGATION_AIDS);
if (source != null) {
source.setGeoJson(emptyFeatureCollection());
}
// Очищаем хранилища
idToNavigationAid.clear();
Log.d(TAG, "Navigation aid markers cleared");
} catch (Exception e) {
Log.e(TAG, "Error clearing navigation aid markers: " + e.getMessage(), e);
}
}
@Override
public void centerOnPosition(double latitude, double longitude) {
if (maplibreMap == null) return;
maplibreMap.setCameraPosition(new org.maplibre.android.camera.CameraPosition.Builder()
.target(new LatLng(latitude, longitude))
.zoom(13.0)
.build());
if (maplibreMap == null || style == null) {
// Сохраним pending, применим после загрузки стиля
pendingCenterLat = latitude;
pendingCenterLon = longitude;
// И на всякий случай повторим позже
try {
if (uiHandler != null) {
uiHandler.postDelayed(() -> centerOnPosition(latitude, longitude), 300);
}
} catch (Exception ignore) {}
return;
}
try {
org.maplibre.android.camera.CameraPosition current = maplibreMap.getCameraPosition();
float targetZoom = (float) current.zoom;
// Если зум слишком маленький (меньше 5), используем стартовый зум из настроек
if (targetZoom < 5.0f) {
targetZoom = settingsManager.getStartZoomLevel();
Log.i(TAG, "Принудительно устанавливаем стартовый зум: " + targetZoom);
}
maplibreMap.setCameraPosition(new org.maplibre.android.camera.CameraPosition.Builder()
.target(new LatLng(latitude, longitude))
.zoom(targetZoom)
.tilt(current.tilt)
.bearing(current.bearing)
.build());
} catch (Exception e) {
Log.w(TAG, "centerOnPosition: MapView may be destroyed: " + e.getMessage());
}
}
@Override
@@ -620,12 +860,38 @@ public class MapLibreMapImpl implements MapInterface {
@Override
public void addLayer(String layerId, Object layerData) {
// Не используется в первой итерации
if (style == null || !isStyleValid()) {
Log.w(TAG, "addLayer: стиль не готов, откладываем добавление слоя " + layerId);
return;
}
try {
if ("seamarks".equals(layerId)) {
addSeamarksLayer();
} else {
Log.w(TAG, "addLayer: неизвестный тип слоя " + layerId);
}
} catch (Exception e) {
Log.e(TAG, "addLayer: ошибка добавления слоя " + layerId, e);
}
}
@Override
public void removeLayer(String layerId) {
// Не используется в первой итерации
if (style == null || !isStyleValid()) {
Log.w(TAG, "removeLayer: стиль не готов, пропускаем удаление слоя " + layerId);
return;
}
try {
if ("seamarks".equals(layerId)) {
removeSeamarksLayer();
} else {
Log.w(TAG, "removeLayer: неизвестный тип слоя " + layerId);
}
} catch (Exception e) {
Log.e(TAG, "removeLayer: ошибка удаления слоя " + layerId, e);
}
}
@Override
@@ -671,11 +937,16 @@ public class MapLibreMapImpl implements MapInterface {
Log.w(TAG, "Не удалось добавить иконки: " + e.getMessage());
}
// Источник GeoJSON
// Источник GeoJSON для судов
if (style.getSource(SOURCE_VESSELS) == null) {
style.addSource(new GeoJsonSource(SOURCE_VESSELS, emptyFeatureCollection()));
}
// Источник GeoJSON для навигационных знаков
if (style.getSource(SOURCE_NAVIGATION_AIDS) == null) {
style.addSource(new GeoJsonSource(SOURCE_NAVIGATION_AIDS, emptyFeatureCollection()));
}
// Отладочные линии удалены
// Слой символов (основные иконки)
@@ -712,6 +983,36 @@ public class MapLibreMapImpl implements MapInterface {
style.addLayer(layer);
}
// Слой навигационных знаков
if (style.getLayer(LAYER_NAVIGATION_AIDS) == null) {
SymbolLayer navigationAidLayer = new SymbolLayer(LAYER_NAVIGATION_AIDS, SOURCE_NAVIGATION_AIDS)
.withProperties(
PropertyFactory.iconImage(
Expression.coalesce(
Expression.get("icon"),
Expression.literal("green_buey")
)
),
PropertyFactory.iconAnchor(org.maplibre.android.style.layers.Property.ICON_ANCHOR_CENTER),
PropertyFactory.iconRotationAlignment(org.maplibre.android.style.layers.Property.ICON_ROTATION_ALIGNMENT_MAP),
PropertyFactory.iconAllowOverlap(true),
PropertyFactory.iconIgnorePlacement(true),
PropertyFactory.iconSize(
Expression.interpolate(
Expression.linear(),
Expression.zoom(),
Expression.stop(5, 0.08f),
Expression.stop(8, 0.10f),
Expression.stop(12, 0.15f),
Expression.stop(15, 0.20f),
Expression.stop(17, 0.25f)
)
)
);
style.addLayer(navigationAidLayer);
}
// Слой предупреждения (losing) поверх рисуется поверх, если feature.properties.stale == true
if (style.getLayer(LAYER_VESSELS_STALE) == null && style.getImage(IMAGE_VESSEL_LOSING) != null) {
SymbolLayer losingLayer = new SymbolLayer(LAYER_VESSELS_STALE, SOURCE_VESSELS)
@@ -783,6 +1084,9 @@ public class MapLibreMapImpl implements MapInterface {
// Восстанавливаем путь судна после создания слоев
restoreVesselPath();
// Обновляем дополнительные слои на основе настроек
updateAdditionalLayers();
Log.d(TAG, "ensureSourcesAndLayers: completed");
}
@@ -958,6 +1262,54 @@ public class MapLibreMapImpl implements MapInterface {
Log.e(TAG, "refreshGeoJson: " + e.getMessage(), e);
}
}
/**
* Обновляет источник данных навигационных знаков
*/
private void refreshNavigationAidsSource() {
if (style == null) return;
try {
if (!isStyleValid()) {
Log.w(TAG, "refreshNavigationAidsSource: стиль не валиден, пропускаем обновление");
return;
}
GeoJsonSource source = (GeoJsonSource) style.getSource(SOURCE_NAVIGATION_AIDS);
if (source == null) {
Log.w(TAG, "refreshNavigationAidsSource: источник NAVIGATION_AIDS не найден, пропускаем обновление");
return;
}
// Создаем FeatureCollection из всех навигационных знаков
JSONObject featureCollection = new JSONObject();
featureCollection.put("type", "FeatureCollection");
JSONArray features = new JSONArray();
for (Map.Entry<String, AISNavigationAid> entry : idToNavigationAid.entrySet()) {
String mmsi = entry.getKey();
AISNavigationAid navigationAid = entry.getValue();
// Проверяем, что у нас есть фича для этого навигационного знака
JSONObject feature = idToFeature.get(mmsi);
if (feature != null) {
features.put(feature);
}
}
featureCollection.put("features", features);
// Обновляем источник
// GeoJsonSource принимает Feature/FeatureCollection/Geometry/String
// Передаем как строку JSON
source.setGeoJson(featureCollection.toString());
Log.d(TAG, "refreshNavigationAidsSource: обновлено " + features.length() + " навигационных знаков");
} catch (Exception e) {
Log.e(TAG, "refreshNavigationAidsSource: " + e.getMessage(), e);
}
}
private void logMemoryUsage() {
Runtime runtime = Runtime.getRuntime();
@@ -1072,6 +1424,204 @@ public class MapLibreMapImpl implements MapInterface {
return feature;
}
/**
* Создает GeoJSON фичу для навигационного знака
*/
private JSONObject buildNavigationAidFeature(AISNavigationAid navigationAid) throws Exception {
JSONObject feature = new JSONObject();
feature.put("type", "Feature");
feature.put("id", navigationAid.getMmsi());
JSONObject geom = new JSONObject();
geom.put("type", "Point");
JSONArray coords = new JSONArray();
coords.put(navigationAid.getLongitude());
coords.put(navigationAid.getLatitude());
geom.put("coordinates", coords);
feature.put("geometry", geom);
JSONObject props = new JSONObject();
props.put("mmsi", navigationAid.getMmsi());
props.put("name", navigationAid.getAidName() != null ? navigationAid.getAidName() : "");
props.put("type", navigationAid.getAidType());
props.put("typeDescription", navigationAid.getAidTypeDescription());
props.put("length", navigationAid.getLength());
props.put("width", navigationAid.getWidth());
props.put("draft", navigationAid.getDraft());
props.put("accuracy", navigationAid.isPositionAccuracy());
props.put("offPosition", navigationAid.isOffPositionIndicator());
props.put("raim", navigationAid.isRaimFlag());
// Выбираем иконку в зависимости от типа навигационного знака
String iconName = getNavigationAidIcon(navigationAid.getAidType());
props.put("icon", iconName);
feature.put("properties", props);
return feature;
}
/**
* Возвращает имя иконки для навигационного знака в зависимости от типа
*/
private String getNavigationAidIcon(int aidType) {
switch (aidType) {
// Reference point
case 0:
return "reference_point"; // Специальная иконка для референсной точки
// RACON (radar transponder marking a navigation hazard)
case 1:
return "racon"; // RACON иконка
// Fixed structure off shore, such as oil platforms, wind farms, rigs
case 2:
return "platform"; // Платформа
// Spare, Reserved for future use
case 3:
return "green_buey"; // Fallback для тестирования
// Light, without sectors
case 4:
return "light"; // Маяк
// Light, with sectors
case 5:
return "light_sector"; // Маяк с секторами
// Leading Light Front
case 6:
return "light_leading_front"; // Передний ведущий маяк
// Leading Light Rear
case 7:
return "light_leading_rear"; // Задний ведущий маяк
// Beacon, Cardinal N
case 8:
return "buoy_cardinal_n"; // Кардинальный буй Север
// Beacon, Cardinal E
case 9:
return "buoy_cardinal_e"; // Кардинальный буй Восток
// Beacon, Cardinal S
case 10:
return "buoy_cardinal_s"; // Кардинальный буй Юг
// Beacon, Cardinal W
case 11:
return "buoy_cardinal_w"; // Кардинальный буй Запад
// Beacon, Port hand
case 12:
return "buoy_port"; // Портовый буй
// Beacon, Starboard hand
case 13:
return "buoy_starboard"; // Правобортный буй
// Beacon, Preferred Channel port hand
case 14:
return "buoy_preferred_port"; // Предпочтительный канал порт
// Beacon, Preferred Channel starboard hand
case 15:
return "buoy_preferred_starboard"; // Предпочтительный канал правый борт
// Beacon, Isolated danger
case 16:
return "beacon_isolated_danger"; // Изолированная опасность
// Beacon, Safe water
case 17:
return "beacon_safe_water"; // Безопасная вода
// Beacon, Special mark
case 18:
return "beacon_special"; // Специальный знак
// Cardinal Mark N
case 19:
return "buoy_cardinal_n"; // Кардинальный знак Север
// Cardinal Mark E
case 20:
return "buoy_cardinal_e"; // Кардинальный знак Восток
// Cardinal Mark S
case 21:
return "buoy_cardinal_s"; // Кардинальный знак Юг
// Cardinal Mark W
case 22:
return "buoy_cardinal_w"; // Кардинальный знак Запад
// Port hand Mark
case 23:
return "buoy_port"; // Портовый знак
// Starboard hand Mark
case 24:
return "buoy_starboard"; // Правобортный знак
// Preferred Channel port hand
case 25:
return "buoy_preferred_port"; // Предпочтительный канал порт
// Preferred Channel starboard hand
case 26:
return "buoy_preferred_starboard"; // Предпочтительный канал правый борт
// Isolated danger
case 27:
return "beacon_isolated_danger"; // Изолированная опасность
// Safe water
case 28:
return "beacon_safe_water"; // Безопасная вода
// Special mark
case 29:
return "beacon_special"; // Специальный знак
// Light Vessel / LANBY / Rigs
case 30:
return "light_vessel"; // Плавучий маяк
default:
return "green_buey"; // Fallback для тестирования
}
}
/**
* Создает временный AISVessel из навигационного знака для совместимости с UI
*/
private AISVessel createTempVesselFromNavigationAid(AISNavigationAid navigationAid) {
AISVessel tempVessel = new AISVessel(navigationAid.getMmsi());
tempVessel.setLatitude(navigationAid.getLatitude());
tempVessel.setLongitude(navigationAid.getLongitude());
tempVessel.setVesselName(navigationAid.getAidName());
tempVessel.setVesselClass("Navigation Aid");
tempVessel.setVesselType(navigationAid.getAidTypeDescription());
tempVessel.setLength(navigationAid.getLength());
tempVessel.setWidth(navigationAid.getWidth());
tempVessel.setDraft(navigationAid.getDraft());
tempVessel.setPositionAccuracy(navigationAid.isPositionAccuracy());
tempVessel.setLastUpdate(navigationAid.getLastUpdate());
// Добавляем специальные поля для навигационных знаков
tempVessel.setDestination("Type: " + navigationAid.getAidType() + " (" + navigationAid.getAidTypeDescription() + ")");
if (navigationAid.isOffPositionIndicator()) {
tempVessel.setDestination(tempVessel.getDestination() + " - Off Position");
}
if (navigationAid.isRaimFlag()) {
tempVessel.setDestination(tempVessel.getDestination() + " - RAIM Active");
}
return tempVessel;
}
private double getDisplayCourse(AISVessel v) {
double hdg = v.getHeading();
if (!Double.isNaN(hdg) && !Double.isInfinite(hdg)) {
@@ -2130,21 +2680,33 @@ public class MapLibreMapImpl implements MapInterface {
Log.d(TAG, String.format("checkAisVesselUnderCursor: searchRect=[%.1f,%.1f,%.1f,%.1f]",
searchRect.left, searchRect.top, searchRect.right, searchRect.bottom));
// Ищем AIS суда в адаптивном радиусе от центра
java.util.List<org.maplibre.geojson.Feature> features = maplibreMap.queryRenderedFeatures(searchRect, LAYER_VESSELS);
// Ищем AIS суда и навигационные знаки в адаптивном радиусе от центра
java.util.List<org.maplibre.geojson.Feature> vesselFeatures = maplibreMap.queryRenderedFeatures(searchRect, LAYER_VESSELS);
java.util.List<org.maplibre.geojson.Feature> aidFeatures = maplibreMap.queryRenderedFeatures(searchRect, LAYER_NAVIGATION_AIDS);
Log.d(TAG, String.format("checkAisVesselUnderCursor: найдено %d features в основном поиске",
features != null ? features.size() : 0));
// Объединяем результаты
java.util.List<org.maplibre.geojson.Feature> features = new java.util.ArrayList<>();
if (vesselFeatures != null) features.addAll(vesselFeatures);
if (aidFeatures != null) features.addAll(aidFeatures);
Log.d(TAG, String.format("checkAisVesselUnderCursor: найдено %d судов и %d навигационных знаков в основном поиске",
vesselFeatures != null ? vesselFeatures.size() : 0, aidFeatures != null ? aidFeatures.size() : 0));
// Если не нашли в основном радиусе, попробуем расширенный поиск
if ((features == null || features.isEmpty()) && pixelRadius < 150) {
if (features.isEmpty() && pixelRadius < 150) {
android.graphics.RectF expandedRect = new android.graphics.RectF(
screenPoint.x - 150, screenPoint.y - 150,
screenPoint.x + 150, screenPoint.y + 150
);
features = maplibreMap.queryRenderedFeatures(expandedRect, LAYER_VESSELS);
Log.d(TAG, String.format("checkAisVesselUnderCursor: найдено %d features в расширенном поиске",
features != null ? features.size() : 0));
vesselFeatures = maplibreMap.queryRenderedFeatures(expandedRect, LAYER_VESSELS);
aidFeatures = maplibreMap.queryRenderedFeatures(expandedRect, LAYER_NAVIGATION_AIDS);
features.clear();
if (vesselFeatures != null) features.addAll(vesselFeatures);
if (aidFeatures != null) features.addAll(aidFeatures);
Log.d(TAG, String.format("checkAisVesselUnderCursor: найдено %d судов и %d навигационных знаков в расширенном поиске",
vesselFeatures != null ? vesselFeatures.size() : 0, aidFeatures != null ? aidFeatures.size() : 0));
}
if (features != null && !features.isEmpty()) {
@@ -2160,6 +2722,7 @@ public class MapLibreMapImpl implements MapInterface {
Log.d(TAG, String.format("checkAisVesselUnderCursor: проверяем feature с id=%s", id));
if (id != null && !"own_vessel".equals(id)) {
// Проверяем AIS судно
AISVessel vessel = idToAisVessel.get(id);
if (vessel != null) {
// Вычисляем географическое расстояние от центра до судна
@@ -2201,6 +2764,48 @@ public class MapLibreMapImpl implements MapInterface {
} else {
Log.d(TAG, String.format("checkAisVesselUnderCursor: судно с id=%s не найдено в idToAisVessel", id));
}
// Проверяем навигационный знак
AISNavigationAid navigationAid = idToNavigationAid.get(id);
if (navigationAid != null) {
// Вычисляем географическое расстояние от центра до навигационного знака
double geoDistance = GeoUtils.calculateDistance(
center.getLatitude(), center.getLongitude(),
navigationAid.getLatitude(), navigationAid.getLongitude()
);
// Вычисляем экранное расстояние
android.graphics.PointF aidScreenPoint = maplibreMap.getProjection()
.toScreenLocation(new org.maplibre.android.geometry.LatLng(
navigationAid.getLatitude(), navigationAid.getLongitude()));
double screenDistance = Math.sqrt(
Math.pow(screenPoint.x - aidScreenPoint.x, 2) +
Math.pow(screenPoint.y - aidScreenPoint.y, 2)
);
Log.d(TAG, String.format("checkAisVesselUnderCursor: навигационный знак %s - geoDistance=%.1f м, screenDistance=%.1f пикс",
id, geoDistance, screenDistance));
// Приоритет отдаем экранному расстоянию, но учитываем и географическое
boolean isBetterCandidate = false;
if (closestVessel == null) {
isBetterCandidate = true;
} else if (screenDistance < minScreenDistance * 0.8) {
// Если экранное расстояние значительно меньше
isBetterCandidate = true;
} else if (screenDistance <= minScreenDistance * 1.2 && geoDistance < minGeoDistance) {
// Если экранное расстояние примерно равно, но географическое меньше
isBetterCandidate = true;
}
if (isBetterCandidate) {
Log.d(TAG, String.format("checkAisVesselUnderCursor: выбираем навигационный знак %s как лучший кандидат", id));
minScreenDistance = screenDistance;
minGeoDistance = geoDistance;
// Для навигационных знаков создаем временный AISVessel для совместимости
closestVessel = createTempVesselFromNavigationAid(navigationAid);
}
}
} else if ("own_vessel".equals(id)) {
Log.d(TAG, "checkAisVesselUnderCursor: пропускаем собственное судно");
}
@@ -2407,8 +3012,155 @@ public class MapLibreMapImpl implements MapInterface {
}
/**
* Настраивает слушатель движения карты для обновления курсора
* Добавляет слой морских знаков OpenSeaMap
*/
private void addSeamarksLayer() {
if (style == null || !isStyleValid()) {
Log.w(TAG, "addSeamarksLayer: стиль не готов");
return;
}
try {
// Проверяем, не добавлен ли уже слой
if (style.getLayer(LAYER_SEAMARKS) != null) {
Log.d(TAG, "addSeamarksLayer: слой уже существует");
return;
}
// Создаем источник тайлов морских знаков через TileSet, чтобы шаблоны {z}/{x}/{y} обрабатывались корректно
String[] seamarksUrls = {
"http://t1.openseamap.org/seamark/{z}/{x}/{y}.png",
"http://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"
};
boolean sourceAdded = false;
for (String url : seamarksUrls) {
try {
Log.d(TAG, "addSeamarksLayer: пробуем TileSet URL " + url);
org.maplibre.android.style.sources.TileSet tileSet =
new org.maplibre.android.style.sources.TileSet("2.1.0", url);
org.maplibre.android.style.sources.RasterSource seamarksSource =
new org.maplibre.android.style.sources.RasterSource(
SOURCE_SEAMARKS,
tileSet,
256
);
style.addSource(seamarksSource);
Log.d(TAG, "addSeamarksLayer: источник добавлен успешно через TileSet " + url);
sourceAdded = true;
break;
} catch (Exception urlError) {
Log.w(TAG, "addSeamarksLayer: TileSet не удалось для URL " + url + ": " + urlError.getMessage());
}
}
if (!sourceAdded) {
Log.w(TAG, "addSeamarksLayer: ни один TileSet не подошел, пробуем информационный слой");
createSeamarksInfoLayer();
return;
}
// Создаем растровый слой
org.maplibre.android.style.layers.RasterLayer seamarksLayer =
new org.maplibre.android.style.layers.RasterLayer(LAYER_SEAMARKS, SOURCE_SEAMARKS);
// Настраиваем прозрачность слоя (чтобы не перекрывать основную карту)
seamarksLayer.setProperties(
org.maplibre.android.style.layers.PropertyFactory.rasterOpacity(0.8f)
);
// Добавляем слой поверх всех остальных слоев
style.addLayer(seamarksLayer);
Log.d(TAG, "addSeamarksLayer: слой добавлен успешно");
Log.i(TAG, "✓ Слой морских знаков OpenSeaMap добавлен");
} catch (Exception e) {
Log.e(TAG, "addSeamarksLayer: ошибка добавления слоя морских знаков", e);
// Пробуем альтернативный подход - используем второй официальный сервер
try {
Log.d(TAG, "addSeamarksLayer: пробуем альтернативный сервер tiles.openseamap.org");
org.maplibre.android.style.sources.RasterSource fallbackSource =
new org.maplibre.android.style.sources.RasterSource(
SOURCE_SEAMARKS + "_fallback",
"https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png",
256
);
style.addSource(fallbackSource);
org.maplibre.android.style.layers.RasterLayer fallbackLayer =
new org.maplibre.android.style.layers.RasterLayer(LAYER_SEAMARKS, SOURCE_SEAMARKS + "_fallback");
fallbackLayer.setProperties(
org.maplibre.android.style.layers.PropertyFactory.rasterOpacity(0.8f)
);
style.addLayer(fallbackLayer);
Log.i(TAG, "✓ Слой морских знаков добавлен через fallback");
} catch (Exception fallbackError) {
Log.e(TAG, "addSeamarksLayer: fallback тоже не сработал", fallbackError);
}
}
}
/**
* Создает информационный слой морских знаков (альтернатива растровым тайлам)
*/
private void createSeamarksInfoLayer() {
try {
Log.d(TAG, "createSeamarksInfoLayer: создаем информационный слой");
// Создаем простой источник с пустыми данными
org.maplibre.android.style.sources.GeoJsonSource infoSource =
new org.maplibre.android.style.sources.GeoJsonSource(SOURCE_SEAMARKS, "{\"type\":\"FeatureCollection\",\"features\":[]}");
style.addSource(infoSource);
// Создаем слой символов для отображения информации
org.maplibre.android.style.layers.SymbolLayer infoLayer =
new org.maplibre.android.style.layers.SymbolLayer(LAYER_SEAMARKS, SOURCE_SEAMARKS);
infoLayer.setProperties(
org.maplibre.android.style.layers.PropertyFactory.textField(""),
org.maplibre.android.style.layers.PropertyFactory.textSize(16f),
org.maplibre.android.style.layers.PropertyFactory.textColor(android.graphics.Color.BLUE),
org.maplibre.android.style.layers.PropertyFactory.textOpacity(0.7f)
);
style.addLayer(infoLayer);
Log.i(TAG, "✓ Информационный слой морских знаков создан (альтернативный режим)");
} catch (Exception e) {
Log.e(TAG, "createSeamarksInfoLayer: ошибка создания информационного слоя", e);
}
}
/**
* Обновляет дополнительные слои карты на основе настроек
*/
public void updateAdditionalLayers() {
if (style == null || !isStyleValid()) {
Log.w(TAG, "updateAdditionalLayers: стиль не готов");
return;
}
try {
// Обновляем слой морских знаков
boolean seamarksEnabled = settingsManager.isSeamarksEnabled();
boolean seamarksLayerExists = style.getLayer(LAYER_SEAMARKS) != null;
if (seamarksEnabled && !seamarksLayerExists) {
Log.i(TAG, "updateAdditionalLayers: включаем морские знаки");
addSeamarksLayer();
} else if (!seamarksEnabled && seamarksLayerExists) {
Log.i(TAG, "updateAdditionalLayers: выключаем морские знаки");
removeSeamarksLayer();
}
} catch (Exception e) {
Log.e(TAG, "updateAdditionalLayers: ошибка обновления слоев", e);
}
}
private void setupMapMovementListener() {
if (maplibreMap != null) {
maplibreMap.addOnCameraMoveListener(() -> {
@@ -2417,6 +3169,33 @@ public class MapLibreMapImpl implements MapInterface {
});
}
}
/**
* Удаляет слой морских знаков OpenSeaMap
*/
private void removeSeamarksLayer() {
if (style == null || !isStyleValid()) {
Log.w(TAG, "removeSeamarksLayer: стиль не готов");
return;
}
try {
// Удаляем слой
if (style.getLayer(LAYER_SEAMARKS) != null) {
style.removeLayer(LAYER_SEAMARKS);
Log.d(TAG, "removeSeamarksLayer: слой удален");
}
// Удаляем источник
if (style.getSource(SOURCE_SEAMARKS) != null) {
style.removeSource(SOURCE_SEAMARKS);
Log.d(TAG, "removeSeamarksLayer: источник удален");
}
Log.i(TAG, "✓ Слой морских знаков OpenSeaMap удален");
} catch (Exception e) {
Log.e(TAG, "removeSeamarksLayer: ошибка удаления слоя морских знаков", e);
}
}
}
@@ -21,6 +21,7 @@ import com.yandex.mapkit.mapview.MapView;
import com.yandex.runtime.image.ImageProvider;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
@@ -141,6 +142,14 @@ public class YandexMapImpl implements MapInterface {
markerManager.updateAISVesselMarker(vessel);
}
}
@Override
public void updateAISVesselPositions(List<AISVessel> vessels) {
if (vessels == null || markerManager == null) return;
for (AISVessel vessel : vessels) {
markerManager.updateAISVesselMarker(vessel);
}
}
@Override
public void removeAISVesselMarker(String mmsi) {
@@ -0,0 +1,257 @@
package com.grigowashere.aismap.models;
import java.time.LocalDateTime;
/**
* Модель AIS навигационного знака (буйка, маяка, платформы и т.д.)
* Специализированный класс для сообщений типа 21 (Aid-to-Navigation Report)
*/
public class AISNavigationAid {
private String mmsi; // Maritime Mobile Service Identity
private String aidName; // название навигационного знака
private int aidType; // тип навигационного знака (0-30)
private String aidTypeDescription; // описание типа
private double latitude;
private double longitude;
private boolean positionAccuracy; // точность позиции
// Размеры навигационного знака
private double length; // длина в метрах
private double width; // ширина в метрах
private double draft; // осадка в метрах
// Dimension Reference поля (для коротких сообщений)
private int dimRefA; // от носа до антенны
private int dimRefB; // от антенны до кормы
private int dimRefC; // от левого борта до антенны
private int dimRefD; // от антенны до правого борта
// Дополнительные поля для полных сообщений
private int epfdType; // тип электронного устройства позиционирования
private int utcSecond; // секунда UTC timestamp
private boolean offPositionIndicator; // индикатор смещения с позиции
private int regionalReserved; // зарезервировано для регионального использования
private boolean raimFlag; // флаг RAIM (Receiver Autonomous Integrity Monitoring)
private LocalDateTime lastUpdate;
private boolean isActive; // активно ли устройство
private boolean selected; // выделено ли на карте
public AISNavigationAid() {
this.lastUpdate = LocalDateTime.now();
this.isActive = true;
}
public AISNavigationAid(String mmsi) {
this();
this.mmsi = mmsi;
}
// Геттеры и сеттеры
public String getMmsi() { return mmsi; }
public void setMmsi(String mmsi) { this.mmsi = mmsi; }
public String getAidName() { return aidName; }
public void setAidName(String aidName) { this.aidName = aidName; }
public int getAidType() { return aidType; }
public void setAidType(int aidType) {
this.aidType = aidType;
this.aidTypeDescription = getAidTypeDescription(aidType);
}
public String getAidTypeDescription() { return aidTypeDescription; }
public void setAidTypeDescription(String aidTypeDescription) { this.aidTypeDescription = aidTypeDescription; }
public double getLatitude() { return latitude; }
public void setLatitude(double latitude) { this.latitude = latitude; }
public double getLongitude() { return longitude; }
public void setLongitude(double longitude) { this.longitude = longitude; }
public boolean isPositionAccuracy() { return positionAccuracy; }
public void setPositionAccuracy(boolean positionAccuracy) { this.positionAccuracy = positionAccuracy; }
public double getLength() { return length; }
public void setLength(double length) { this.length = length; }
public double getWidth() { return width; }
public void setWidth(double width) { this.width = width; }
public double getDraft() { return draft; }
public void setDraft(double draft) { this.draft = draft; }
public int getDimRefA() { return dimRefA; }
public void setDimRefA(int dimRefA) { this.dimRefA = dimRefA; }
public int getDimRefB() { return dimRefB; }
public void setDimRefB(int dimRefB) { this.dimRefB = dimRefB; }
public int getDimRefC() { return dimRefC; }
public void setDimRefC(int dimRefC) { this.dimRefC = dimRefC; }
public int getDimRefD() { return dimRefD; }
public void setDimRefD(int dimRefD) { this.dimRefD = dimRefD; }
public int getEpfdType() { return epfdType; }
public void setEpfdType(int epfdType) { this.epfdType = epfdType; }
public int getUtcSecond() { return utcSecond; }
public void setUtcSecond(int utcSecond) { this.utcSecond = utcSecond; }
public boolean isOffPositionIndicator() { return offPositionIndicator; }
public void setOffPositionIndicator(boolean offPositionIndicator) { this.offPositionIndicator = offPositionIndicator; }
public int getRegionalReserved() { return regionalReserved; }
public void setRegionalReserved(int regionalReserved) { this.regionalReserved = regionalReserved; }
public boolean isRaimFlag() { return raimFlag; }
public void setRaimFlag(boolean raimFlag) { this.raimFlag = raimFlag; }
public LocalDateTime getLastUpdate() { return lastUpdate; }
public void setLastUpdate(LocalDateTime lastUpdate) { this.lastUpdate = lastUpdate; }
public boolean isActive() { return isActive; }
public void setActive(boolean active) { isActive = active; }
public boolean isSelected() { return selected; }
public void setSelected(boolean selected) { this.selected = selected; }
/**
* Обновляет позицию навигационного знака
*/
public void updatePosition(double latitude, double longitude) {
this.latitude = latitude;
this.longitude = longitude;
this.lastUpdate = LocalDateTime.now();
}
/**
* Проверяет, не устарели ли данные на указанное количество минут
*/
public boolean isDataStale(int warningMinutes) {
return LocalDateTime.now().minusMinutes(warningMinutes).isAfter(lastUpdate);
}
/**
* Проверяет, нужно ли удалить данные (старше указанного количества минут)
*/
public boolean shouldBeRemoved(int removeMinutes) {
return LocalDateTime.now().minusMinutes(removeMinutes).isAfter(lastUpdate);
}
/**
* Получает количество минут с последнего обновления
*/
public long getMinutesSinceLastUpdate() {
return java.time.Duration.between(lastUpdate, LocalDateTime.now()).toMinutes();
}
/**
* Получает описание типа электронного устройства позиционирования
*/
public String getEpfdTypeDescription() {
switch (epfdType) {
case 0: return "Undefined";
case 1: return "GPS";
case 2: return "GLONASS";
case 3: return "Combined GPS/GLONASS";
case 4: return "Loran-C";
case 5: return "Chayka";
case 6: return "Integrated navigation system";
case 7: return "Surveyed";
case 8:
case 9:
case 10:
case 11:
case 12:
case 13:
case 14:
case 15: return "Not used";
default: return "Unknown";
}
}
/**
* Получает описание типа навигационного знака по коду
*/
private String getAidTypeDescription(int aidType) {
switch (aidType) {
case 0: return "Reference point";
case 1: return "RACON (radar transponder marking a navigation hazard)";
case 2: return "Fixed structure off shore, such as oil platforms, wind farms, rigs";
case 3: return "Spare, Reserved for future use";
case 4: return "Light, without sectors";
case 5: return "Light, with sectors";
case 6: return "Leading Light Front";
case 7: return "Leading Light Rear";
case 8: return "Beacon, Cardinal N";
case 9: return "Beacon, Cardinal E";
case 10: return "Beacon, Cardinal S";
case 11: return "Beacon, Cardinal W";
case 12: return "Beacon, Port hand";
case 13: return "Beacon, Starboard hand";
case 14: return "Beacon, Preferred Channel port hand";
case 15: return "Beacon, Preferred Channel starboard hand";
case 16: return "Beacon, Isolated danger";
case 17: return "Beacon, Safe water";
case 18: return "Beacon, Special mark";
case 19: return "Cardinal Mark N";
case 20: return "Cardinal Mark E";
case 21: return "Cardinal Mark S";
case 22: return "Cardinal Mark W";
case 23: return "Port hand Mark";
case 24: return "Starboard hand Mark";
case 25: return "Preferred Channel port hand";
case 26: return "Preferred Channel starboard hand";
case 27: return "Isolated danger";
case 28: return "Safe water";
case 29: return "Special mark";
case 30: return "Light Vessel / LANBY / Rigs";
default: return "Unknown Aid-to-Navigation";
}
}
/**
* Вычисляет общие размеры из Dimension Reference полей
*/
public void calculateDimensionsFromRefs() {
this.length = dimRefA + dimRefB;
this.width = dimRefC + dimRefD;
}
/**
* Проверяет, является ли это буйком (Cardinal Mark)
*/
public boolean isCardinalMark() {
return aidType >= 19 && aidType <= 22;
}
/**
* Проверяет, является ли это маяком
*/
public boolean isLight() {
return aidType >= 4 && aidType <= 7;
}
/**
* Проверяет, является ли это платформой или стационарной конструкцией
*/
public boolean isFixedStructure() {
return aidType == 2 || aidType == 30;
}
@Override
public String toString() {
return "AISNavigationAid{" +
"mmsi='" + mmsi + '\'' +
", name='" + aidName + '\'' +
", type=" + aidType + " (" + aidTypeDescription + ")" +
", lat=" + latitude +
", lon=" + longitude +
", L=" + length +
", W=" + width +
", D=" + draft +
'}';
}
}
@@ -31,9 +31,12 @@ public class CompassSensor implements SensorEventListener {
// Скользящий фильтр для сглаживания значений
private static final int FILTER_SIZE = 60;
private static final float DEADBAND_DEG = 1.5f;
private float[] azimuthBuffer = new float[FILTER_SIZE];
private int bufferIndex = 0;
private boolean bufferFull = false;
/** Last value sent to UI (circular deadband). */
private float lastReportedAzimuth = Float.NaN;
public interface CompassListener {
void onCompassChanged(float azimuth);
@@ -81,6 +84,7 @@ public class CompassSensor implements SensorEventListener {
private void resetFilter() {
bufferIndex = 0;
bufferFull = false;
lastReportedAzimuth = Float.NaN;
for (int i = 0; i < FILTER_SIZE; i++) {
azimuthBuffer[i] = 0;
}
@@ -142,26 +146,39 @@ public class CompassSensor implements SensorEventListener {
}
/**
* Применяет скользящий фильтр для сглаживания значений
* Скользящее усреднение по кругу (векторное среднее), без скачков у 0°/360°.
*/
private float applyLowPassFilter(float newValue) {
// Добавляем новое значение в буфер
azimuthBuffer[bufferIndex] = newValue;
bufferIndex = (bufferIndex + 1) % FILTER_SIZE;
if (bufferIndex == 0) {
bufferFull = true;
}
// Вычисляем среднее значение
float sum = 0;
int count = bufferFull ? FILTER_SIZE : bufferIndex;
for (int i = 0; i < count; i++) {
sum += azimuthBuffer[i];
if (count <= 0) {
return newValue;
}
return sum / count;
double sx = 0.0;
double sy = 0.0;
for (int i = 0; i < count; i++) {
double rad = Math.toRadians(azimuthBuffer[i]);
sx += Math.cos(rad);
sy += Math.sin(rad);
}
float mean = (float) Math.toDegrees(Math.atan2(sy / count, sx / count));
if (mean < 0) {
mean += 360;
}
if (!Float.isNaN(lastReportedAzimuth)) {
float d = mean - lastReportedAzimuth;
while (d > 180) d -= 360;
while (d < -180) d += 360;
if (Math.abs(d) < DEADBAND_DEG) {
return lastReportedAzimuth;
}
}
lastReportedAzimuth = mean;
return mean;
}
public boolean isAvailable() {
@@ -0,0 +1,271 @@
package com.grigowashere.aismap.settings;
import android.Manifest;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothManager;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanResult;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.switchmaterial.SwitchMaterial;
import com.grigowashere.aismap.R;
import com.grigowashere.aismap.utils.SettingsManager;
import java.util.ArrayList;
import java.util.List;
public class InterfacesSettingsActivity extends AppCompatActivity {
private static final String TAG = "InterfacesSettings";
private static final int REQ_PERMS_BLE = 2001;
private SettingsManager settingsManager;
// UDP
private EditText etUdpPort;
private SwitchMaterial swUdpEnabled;
// BLE
private SwitchMaterial swBleEnabled;
private EditText etBleMac;
// BLE UDP Bridge
private SwitchMaterial swBleBridgeEnabled;
private EditText etBleBridgeHost;
private EditText etBleBridgePort;
private Button btnSave;
private Button btnCancel;
// Scan UI
private Button btnBleScan;
private Button btnBleStopScan;
private RecyclerView rvBle;
private DevicesAdapter devicesAdapter;
private BluetoothAdapter btAdapter;
private BluetoothLeScanner bleScanner;
private boolean scanning = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_interfaces_settings);
settingsManager = new SettingsManager(this);
BluetoothManager bm = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
btAdapter = bm != null ? bm.getAdapter() : null;
bleScanner = btAdapter != null ? btAdapter.getBluetoothLeScanner() : null;
initViews();
loadValues();
setupHandlers();
setupRecycler();
}
private void initViews() {
etUdpPort = findViewById(R.id.et_udp_port);
swUdpEnabled = findViewById(R.id.switch_udp_enabled);
swBleEnabled = findViewById(R.id.switch_ble_enabled);
etBleMac = findViewById(R.id.et_ble_mac);
swBleBridgeEnabled = findViewById(R.id.switch_ble_udp_bridge_enabled);
etBleBridgeHost = findViewById(R.id.et_ble_udp_host);
etBleBridgePort = findViewById(R.id.et_ble_udp_port);
btnSave = findViewById(R.id.btn_save);
btnCancel = findViewById(R.id.btn_cancel);
btnBleScan = findViewById(R.id.btn_ble_scan);
btnBleStopScan = findViewById(R.id.btn_ble_stop_scan);
rvBle = findViewById(R.id.rv_ble_devices);
}
private void loadValues() {
etUdpPort.setText(String.valueOf(settingsManager.getUDPPort()));
swUdpEnabled.setChecked(settingsManager.isUDPEnabled());
swBleEnabled.setChecked(settingsManager.isBLEEnabled());
etBleMac.setText(settingsManager.getBLEDeviceMac());
swBleBridgeEnabled.setChecked(settingsManager.isBleUdpBridgeEnabled());
etBleBridgeHost.setText(settingsManager.getBleUdpBridgeHost());
etBleBridgePort.setText(String.valueOf(settingsManager.getBleUdpBridgePort()));
}
private void setupHandlers() {
btnCancel.setOnClickListener(v -> finish());
btnSave.setOnClickListener(v -> saveAndExit());
btnBleScan.setOnClickListener(v -> startScan());
btnBleStopScan.setOnClickListener(v -> stopScan());
}
private void setupRecycler() {
devicesAdapter = new DevicesAdapter(device -> {
if (device == null) return;
String mac = device.getAddress();
etBleMac.setText(mac);
Toast.makeText(this, "Выбрано устройство: " + device.getName() + " (" + mac + ")", Toast.LENGTH_SHORT).show();
});
rvBle.setLayoutManager(new LinearLayoutManager(this));
rvBle.setAdapter(devicesAdapter);
}
private void startScan() {
if (btAdapter == null || !btAdapter.isEnabled()) {
Toast.makeText(this, "Bluetooth не включен", Toast.LENGTH_SHORT).show();
return;
}
if (!ensureBlePerms()) return;
if (bleScanner == null) {
Toast.makeText(this, "BLE Scanner недоступен", Toast.LENGTH_SHORT).show();
return;
}
if (scanning) return;
devicesAdapter.clear();
bleScanner.startScan(scanCallback);
scanning = true;
Toast.makeText(this, "BLE сканирование запущено", Toast.LENGTH_SHORT).show();
}
private void stopScan() {
if (!scanning) return;
try { bleScanner.stopScan(scanCallback); } catch (Exception ignore) {}
scanning = false;
Toast.makeText(this, "BLE сканирование остановлено", Toast.LENGTH_SHORT).show();
}
private boolean ensureBlePerms() {
List<String> need = new ArrayList<>();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) need.add(Manifest.permission.BLUETOOTH_SCAN);
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) need.add(Manifest.permission.BLUETOOTH_CONNECT);
} else {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH) != PackageManager.PERMISSION_GRANTED) need.add(Manifest.permission.BLUETOOTH);
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_ADMIN) != PackageManager.PERMISSION_GRANTED) need.add(Manifest.permission.BLUETOOTH_ADMIN);
}
if (!need.isEmpty()) {
ActivityCompat.requestPermissions(this, need.toArray(new String[0]), REQ_PERMS_BLE);
return false;
}
return true;
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQ_PERMS_BLE) {
boolean ok = true;
for (int r : grantResults) {
if (r != PackageManager.PERMISSION_GRANTED) { ok = false; break; }
}
if (ok) startScan(); else Toast.makeText(this, "Разрешения BLE не предоставлены", Toast.LENGTH_SHORT).show();
}
}
private final ScanCallback scanCallback = new ScanCallback() {
@Override
public void onScanResult(int callbackType, @NonNull ScanResult result) {
BluetoothDevice d = result.getDevice();
if (d == null || d.getAddress() == null) return;
devicesAdapter.addOrUpdate(d, result.getRssi());
}
};
private void saveAndExit() {
try {
int udpPort = parseInt(etUdpPort.getText().toString().trim(), 10110, 1, 65535);
settingsManager.setUDPPort(udpPort);
settingsManager.setUDPEnabled(swUdpEnabled.isChecked());
settingsManager.setBLEEnabled(swBleEnabled.isChecked());
settingsManager.setBLEDeviceMac(etBleMac.getText().toString().trim());
settingsManager.setBleUdpBridgeEnabled(swBleBridgeEnabled.isChecked());
settingsManager.setBleUdpBridgeHost(etBleBridgeHost.getText().toString().trim());
int brPort = parseInt(etBleBridgePort.getText().toString().trim(), 10110, 1, 65535);
settingsManager.setBleUdpBridgePort(brPort);
Toast.makeText(this, "Настройки сохранены", Toast.LENGTH_SHORT).show();
finish();
} catch (Exception e) {
Log.e(TAG, "Ошибка сохранения: " + e.getMessage(), e);
Toast.makeText(this, "Ошибка сохранения настроек", Toast.LENGTH_SHORT).show();
}
}
private int parseInt(String s, int def, int min, int max) {
try {
int v = Integer.parseInt(s);
if (v < min || v > max) return def;
return v;
} catch (Exception e) {
return def;
}
}
// Recycler adapter
private static class DevicesAdapter extends RecyclerView.Adapter<DevicesAdapter.VH> {
interface OnClick { void onClick(BluetoothDevice d); }
private final List<Item> items = new ArrayList<>();
private final OnClick onClick;
DevicesAdapter(OnClick onClick) { this.onClick = onClick; }
static class Item { BluetoothDevice d; int rssi; }
static class VH extends RecyclerView.ViewHolder {
TextView tvName; TextView tvMac; TextView tvRssi;
VH(@NonNull View itemView) {
super(itemView);
tvName = itemView.findViewById(android.R.id.text1);
tvMac = itemView.findViewById(android.R.id.text2);
tvRssi = itemView.findViewById(R.id.text3);
}
}
@NonNull
@Override
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_ble_device, parent, false);
return new VH(v);
}
@Override
public void onBindViewHolder(@NonNull VH holder, int position) {
Item it = items.get(position);
String name = it.d.getName();
holder.tvName.setText(name != null ? name : "(без имени)");
holder.tvMac.setText(it.d.getAddress());
holder.tvRssi.setText("RSSI: " + it.rssi);
holder.itemView.setOnClickListener(v -> onClick.onClick(it.d));
}
@Override
public int getItemCount() { return items.size(); }
void clear() { items.clear(); notifyDataSetChanged(); }
void addOrUpdate(BluetoothDevice d, int rssi) {
for (Item it : items) {
if (it.d.getAddress().equals(d.getAddress())) { it.rssi = rssi; notifyDataSetChanged(); return; }
}
Item it = new Item(); it.d = d; it.rssi = rssi; items.add(it); notifyDataSetChanged();
}
}
}
@@ -49,6 +49,11 @@ public class MenuBinder {
boolean screenEnabled = settingsManager.isKeepScreenOnEnabled();
screenItem.setTitle(screenEnabled ? "Экран ✓" : "Экран");
}
MenuItem seamarksItem = menu.findItem(R.id.menu_seamarks);
if (seamarksItem != null) {
boolean seamarksEnabled = settingsManager.isSeamarksEnabled();
seamarksItem.setTitle(seamarksEnabled ? "Морские знаки ✓" : "Морские знаки");
}
return true;
} catch (Exception e) {
Log.w(TAG, "onPrepareOptionsMenu: " + e.getMessage());
@@ -78,6 +83,9 @@ public class MenuBinder {
} else if (id == R.id.menu_keep_screen_on) {
actions.toggleKeepScreenOn();
return true;
} else if (id == R.id.menu_seamarks) {
actions.toggleSeamarks();
return true;
}
} catch (Exception e) {
Log.w(TAG, "onOptionsItemSelected error: " + e.getMessage());
@@ -95,6 +103,7 @@ public class MenuBinder {
void togglePathTracking();
void testForegroundService();
void toggleKeepScreenOn();
void toggleSeamarks();
}
}
@@ -10,6 +10,8 @@ import com.grigowashere.aismap.models.Vessel;
import com.grigowashere.aismap.models.AISVessel;
import java.util.HashSet;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.HashMap;
@@ -156,10 +158,10 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
mapInterface.removeAISVesselMarker(mmsi);
}
// Обновляем или добавляем суда (различать не будем - MapInterface сам решит)
for (AISVessel vessel : pendingAISUpdates.values()) {
Log.d(TAG, "Обновляем/добавляем AIS судно: " + vessel.getMmsi());
mapInterface.updateAISVesselPosition(vessel);
// Обновляем или добавляем суда пачкой, чтобы карта сделала один GeoJSON refresh.
List<AISVessel> updates = new ArrayList<>(pendingAISUpdates.values());
if (!updates.isEmpty()) {
mapInterface.updateAISVesselPositions(updates);
}
Log.d(TAG, "AIS updates выполнены: удалено=" + pendingAISRemovals.size() +
@@ -2,370 +2,172 @@ package com.grigowashere.aismap.utils;
import android.util.Log;
import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutorService;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* Утилита для отправки логов на внешний ресурс
* Отправляет GET запросы на https://ais.grigowashere.ru/add
* Отправляет пакеты логов раз в секунду на https://ais.grigowashere.ru/logs/batch
*/
public class LogSender {
private static final String TAG = "LogSender";
private static final String BASE_URL = "https://ais.grigowashere.ru/add";
private static final ExecutorService executor = Executors.newSingleThreadExecutor();
private static final String BASE_URL = "https://ais.grigowashere.ru";
/**
* Отправляет лог NMEA сообщения
* @param nmeaMessage NMEA сообщение
* Временно отключено, чтобы не создавать сетевой шум и фоновые потоки.
* Если снова понадобится переключить в true или завязать на настройку/BuildConfig.
*/
public static void logNMEA(String nmeaMessage) {
if (nmeaMessage == null || nmeaMessage.trim().isEmpty()) {
private static final boolean ENABLED = false;
// Мягкие цвета для лучшей читаемости на фоне #364758
private static final String COLOR_SOFT_BLUE = "#8AB4F8"; // мягкий синий
private static final String COLOR_SOFT_RED = "#FF8A80"; // мягкий красный
// Настройки генерации цветов для кораблей
private static final float VESSEL_COLOR_SATURATION = 0.4f; // Низкая насыщенность для мягкости
private static final float VESSEL_COLOR_VALUE = 0.75f; // Средняя яркость для контраста с темным фоном
// Буферизация логов
private static final List<LogEntry> logBuffer = new ArrayList<>();
private static final Object bufferLock = new Object();
private static volatile ScheduledExecutorService scheduler = null;
private static volatile boolean schedulerStarted = false;
/**
* Структура для хранения лога
*/
private static class LogEntry {
String type;
String message;
String color;
long timestamp;
LogEntry(String type, String message, String color) {
this.type = type;
this.message = message;
this.color = color;
this.timestamp = System.currentTimeMillis();
}
}
/**
* Инициализирует планировщик отправки логов
*/
private static void startScheduler() {
if (!ENABLED) {
return;
}
if (!schedulerStarted) {
synchronized (LogSender.class) {
if (!schedulerStarted) {
if (scheduler == null || scheduler.isShutdown()) {
scheduler = Executors.newSingleThreadScheduledExecutor();
}
scheduler.scheduleAtFixedRate(() -> {
sendBufferedLogs();
}, 1, 1, TimeUnit.SECONDS);
schedulerStarted = true;
Log.d(TAG, "Планировщик отправки логов запущен (каждую секунду)");
}
}
}
}
/**
* Добавляет лог в буфер
*/
private static void addToBuffer(String type, String message, String color) {
if (!ENABLED) {
return;
}
synchronized (bufferLock) {
logBuffer.add(new LogEntry(type, message, color));
}
startScheduler(); // Запускаем планировщик при первом логе
}
/**
* Отправляет все накопленные логи пакетом
*/
private static void sendBufferedLogs() {
if (!ENABLED) {
return;
}
List<LogEntry> logsToSend;
synchronized (bufferLock) {
if (logBuffer.isEmpty()) {
return;
}
logsToSend = new ArrayList<>(logBuffer);
logBuffer.clear();
}
if (logsToSend.isEmpty()) {
return;
}
executor.execute(() -> {
try {
String encodedMessage = encodeForURL(nmeaMessage);
String url = BASE_URL + "?nmea=" + encodedMessage + "&color=blue";
sendGetRequest(url);
// Убираем лишние логи
// Log.d(TAG, "NMEA лог отправлен: " + nmeaMessage);
} catch (Exception e) {
Log.e(TAG, "Ошибка отправки NMEA лога: " + e.getMessage(), e);
}
});
Log.d(TAG, "Отправляем пакет из " + logsToSend.size() + " логов");
sendLogsBatch(logsToSend);
}
/**
* Отправляет лог обновления информации о корабле
* @param mmsi MMSI корабля
* @param vesselInfo Информация о корабле
* Отправляет пакет логов через POST запрос
*/
public static void logShipUpdate(String mmsi, String vesselInfo) {
if (mmsi == null || mmsi.trim().isEmpty()) {
private static void sendLogsBatch(List<LogEntry> logs) {
if (!ENABLED) {
return;
}
executor.execute(() -> {
try {
String message = "MMSI: " + mmsi;
if (vesselInfo != null && !vesselInfo.trim().isEmpty()) {
message += " | " + vesselInfo;
}
// Извлекаем тип судна из vesselInfo и генерируем цвет
// Генерируем уникальный цвет для корабля на основе MMSI
String vesselColor = generateVesselColor(mmsi);
String encodedMessage = encodeForURL(message);
String encodedColor = encodeColorForURL(vesselColor);
String url = BASE_URL + "?ships=" + encodedMessage + "&color=" + encodedColor;
sendGetRequest(url);
// Убираем лишние логи
// Log.d(TAG, "Ship update лог отправлен: " + message + " ( " + ", цвет: " + vesselColor + ")");
} catch (Exception e) {
Log.e(TAG, "Ошибка отправки ship update лога: " + e.getMessage(), e);
}
});
}
/**
* Отправляет лог обновления информации о корабле с заданным цветом
* @param mmsi MMSI корабля
* @param vesselInfo Информация о корабле
* @param color Цвет в формате HEX (#RRGGBB) или имя цвета
*/
public static void logShipUpdate(String mmsi, String vesselInfo, String color) {
if (mmsi == null || mmsi.trim().isEmpty()) {
return;
}
executor.execute(() -> {
try {
String message = "MMSI: " + mmsi;
if (vesselInfo != null && !vesselInfo.trim().isEmpty()) {
message += " | " + vesselInfo;
}
// Используем переданный цвет или генерируем на основе типа судна
String vesselColor;
if (color != null && !color.trim().isEmpty()) {
vesselColor = color;
} else {
// Генерируем уникальный цвет для корабля на основе MMSI
vesselColor = generateVesselColor(mmsi);
}
String encodedMessage = encodeForURL(message);
String encodedColor = encodeColorForURL(vesselColor);
String url = BASE_URL + "?ships=" + encodedMessage + "&color=" + encodedColor;
sendGetRequest(url);
// Убираем лишние логи
// Log.d(TAG, "Ship update лог отправлен: " + message + " (цвет: " + vesselColor + ")");
} catch (Exception e) {
Log.e(TAG, "Ошибка отправки ship update лога: " + e.getMessage(), e);
}
});
}
/**
* Отправляет произвольный лог
* @param logName Имя лога
* @param message Сообщение
* @param color Цвет (опционально)
*/
public static void logCustom(String logName, String message, String color) {
if (logName == null || logName.trim().isEmpty() || message == null || message.trim().isEmpty()) {
return;
}
executor.execute(() -> {
try {
String encodedMessage = encodeForURL(message);
String url = BASE_URL + "?" + logName + "=" + encodedMessage;
if (color != null && !color.trim().isEmpty()) {
url += "&color=" + color;
}
sendGetRequest(url);
Log.d(TAG, "Custom лог отправлен: " + logName + " = " + message);
} catch (Exception e) {
Log.e(TAG, "Ошибка отправки custom лога: " + e.getMessage(), e);
}
});
}
/**
* Генерирует уникальный цвет для корабля на основе MMSI (устаревший метод)
* @param mmsi MMSI корабля
* @return HEX цвет в формате #RRGGBB
*/
private static String generateVesselColor(String mmsi) {
try {
// Преобразуем MMSI в число для хеширования
long mmsiValue = Long.parseLong(mmsi);
// Используем хеш-функцию для получения равномерного распределения
int hash = Long.hashCode(mmsiValue);
// Извлекаем RGB компоненты из хеша
int r = (hash & 0xFF0000) >> 16;
int g = (hash & 0x00FF00) >> 8;
int b = hash & 0x0000FF;
// Проверяем, не слишком ли темный цвет (чтобы избежать черного)
int brightness = (r + g + b) / 3;
if (brightness < 100) {
// Если цвет слишком темный, осветляем его
r = Math.min(255, r + 120);
g = Math.min(255, g + 120);
b = Math.min(255, b + 120);
}
// Проверяем, не слишком ли светлый цвет (чтобы избежать белого)
if (brightness > 220) {
// Если цвет слишком светлый, затемняем его
r = Math.max(0, r - 60);
g = Math.max(0, g - 60);
b = Math.max(0, b - 60);
}
// Форматируем в HEX
String color = String.format("#%02X%02X%02X", r, g, b);
// Убираем лишние логи
// Log.d(TAG, "Сгенерирован цвет для MMSI " + mmsi + ": " + color + " (RGB: " + r + "," + g + "," + b + ")");
return color;
} catch (NumberFormatException e) {
Log.w(TAG, "Не удалось распарсить MMSI как число: " + mmsi + ", используем цвет по умолчанию");
return "#00AA00"; // Зеленый по умолчанию
} catch (Exception e) {
Log.e(TAG, "Ошибка генерации цвета для MMSI " + mmsi + ": " + e.getMessage(), e);
return "#00AA00"; // Зеленый по умолчанию
}
}
/**
* Определяет тип судна по MMSI
* Использует более точную логику на основе стандартных диапазонов MMSI
* @param mmsi MMSI судна
* @return Тип судна
*/
private static String getVesselTypeByMMSI(long mmsi) {
// Стандартные диапазоны MMSI для разных типов судов
if (mmsi >= 100000000 && mmsi <= 199999999) {
return "COASTAL"; // Прибрежные суда
} else if (mmsi >= 200000000 && mmsi <= 299999999) {
return "FISHING"; // Рыболовные суда
} else if (mmsi >= 300000000 && mmsi <= 399999999) {
return "CARGO"; // Грузовые суда
} else if (mmsi >= 400000000 && mmsi <= 499999999) {
return "TANKER"; // Танкеры
} else if (mmsi >= 500000000 && mmsi <= 599999999) {
return "PASSENGER"; // Пассажирские суда
} else if (mmsi >= 600000000 && mmsi <= 699999999) {
return "MILITARY"; // Военные корабли
} else if (mmsi >= 700000000 && mmsi <= 799999999) {
return "PILOT"; // Лоцманские суда
} else if (mmsi >= 800000000 && mmsi <= 899999999) {
return "PILOT"; // Лоцманские суда (дополнительный диапазон)
} else if (mmsi >= 900000000 && mmsi <= 999999999) {
return "MILITARY"; // Военные корабли (дополнительный диапазон)
} else if (mmsi >= 1000000000 && mmsi <= 1099999999) {
return "SAR"; // Спасательные суда
} else if (mmsi >= 1100000000 && mmsi <= 1199999999) {
return "TUG"; // Буксиры
} else if (mmsi >= 1200000000 && mmsi <= 1299999999) {
return "PORT_TENDER"; // Портовые суда
} else if (mmsi >= 1300000000 && mmsi <= 1399999999) {
return "ANTI_POLLUTION"; // Антизагрязнительные суда
} else if (mmsi >= 1400000000 && mmsi <= 1499999999) {
return "LAW_ENFORCEMENT"; // Правоохранительные суда
} else if (mmsi >= 1500000000 && mmsi <= 1599999999) {
return "MEDICAL"; // Медицинские суда
} else if (mmsi >= 1600000000 && mmsi <= 1699999999) {
return "SPECIAL_CRAFT"; // Специальные суда
} else if (mmsi >= 1700000000 && mmsi <= 1799999999) {
return "PASSENGER"; // Пассажирские суда (дополнительный диапазон)
} else if (mmsi >= 1800000000 && mmsi <= 1899999999) {
return "CARGO"; // Грузовые суда (дополнительный диапазон)
} else if (mmsi >= 1900000000 && mmsi <= 1999999999) {
return "TANKER"; // Танкеры (дополнительный диапазон)
} else if (mmsi >= 2000000000 && mmsi <= 2099999999) {
return "OTHER"; // Другие типы судов
} else if (mmsi >= 2100000000L && mmsi <= 2199999999L) {
return "OTHER"; // Другие типы судов (дополнительный диапазон)
} else if (mmsi >= 2200000000L && mmsi <= 2299999999L) {
return "OTHER"; // Другие типы судов (дополнительный диапазон)
} else if (mmsi >= 2300000000L && mmsi <= 2399999999L) {
return "OTHER"; // Другие типы судов (дополнительный диапазон)
} else if (mmsi >= 2400000000L && mmsi <= 2499999999L) {
return "OTHER"; // Другие типы судов (дополнительный диапазон)
} else if (mmsi >= 2500000000L && mmsi <= 2599999999L) {
return "OTHER"; // Другие типы судов (дополнительный диапазон)
} else if (mmsi >= 2600000000L && mmsi <= 2699999999L) {
return "OTHER"; // Другие типы судов (дополнительный диапазон)
} else if (mmsi >= 2700000000L && mmsi <= 2799999999L) {
return "OTHER"; // Другие типы судов (дополнительный диапазон)
} else if (mmsi >= 2800000000L && mmsi <= 2899999999L) {
return "OTHER"; // Другие типы судов (дополнительный диапазон)
} else if (mmsi >= 2900000000L && mmsi <= 2999999999L) {
return "OTHER"; // Другие типы судов (дополнительный диапазон)
} else {
return "UNKNOWN"; // Неизвестный тип
}
}
/**
* Кодирует цвет для безопасного использования в URL
* Специально обрабатывает HEX цвета, заменяя # на %23
* @param color Цвет в формате HEX (#RRGGBB) или имя цвета
* @return Закодированный цвет
*/
private static String encodeColorForURL(String color) {
if (color == null || color.trim().isEmpty()) {
return "green"; // Цвет по умолчанию
}
try {
// Если цвет начинается с #, заменяем его на %23
if (color.startsWith("#")) {
String encoded = "%23" + color.substring(1);
Log.d(TAG, "Закодирован HEX цвет: " + color + " -> " + encoded);
return encoded;
} else {
// Для именованных цветов используем стандартное кодирование
String encoded = URLEncoder.encode(color, StandardCharsets.UTF_8.toString());
Log.d(TAG, "Закодирован именованный цвет: " + color + " -> " + encoded);
return encoded;
}
} catch (Exception e) {
Log.e(TAG, "Ошибка кодирования цвета: " + e.getMessage(), e);
return "green"; // Цвет по умолчанию
}
}
/**
* Кодирует строку для безопасного использования в URL
* Дополнительно экранирует символы, которые могут вызывать проблемы
* @param message Исходное сообщение
* @return Закодированное сообщение
*/
private static String encodeForURL(String message) {
try {
// Сначала используем стандартное URL кодирование
String encoded = URLEncoder.encode(message, StandardCharsets.UTF_8.toString());
// Дополнительно экранируем символы, которые могут вызывать проблемы
// Заменяем < на %3C, > на %3E, & на %26, " на %22, ' на %27, # на %23
encoded = encoded.replace("<", "%3C")
.replace(">", "%3E")
.replace("&", "%26")
.replace("\"", "%22")
.replace("'", "%27")
.replace("#", "%23");
// Убираем лишние логи
// Log.d(TAG, "Исходное сообщение: " + message);
// Log.d(TAG, "Закодированное сообщение: " + encoded);
return encoded;
} catch (Exception e) {
Log.e(TAG, "Ошибка кодирования URL: " + e.getMessage(), e);
// В случае ошибки возвращаем базовое кодирование
String fallback = message.replace("<", "%3C")
.replace(">", "%3E")
.replace("&", "%26")
.replace("\"", "%22")
.replace("'", "%27")
.replace("#", "%23")
.replace(" ", "%20");
Log.d(TAG, "Fallback кодирование: " + fallback);
return fallback;
}
}
/**
* Отправляет GET запрос
* @param urlString URL для запроса
*/
private static void sendGetRequest(String urlString) {
HttpURLConnection connection = null;
try {
// Убираем лишние логи
// Log.d(TAG, "Отправляем GET запрос на: " + urlString);
@SuppressWarnings("deprecation")
URL url = new URL(urlString);
URL url = new URL(BASE_URL + "/api/logs/batch");
connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(5000); // 5 секунд
connection.setReadTimeout(5000); // 5 секунд
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("User-Agent", "AISMap/1.0");
connection.setConnectTimeout(5000);
connection.setReadTimeout(10000);
connection.setDoOutput(true);
// Формируем JSON
StringBuilder json = new StringBuilder("{\"logs\":[");
for (int i = 0; i < logs.size(); i++) {
LogEntry log = logs.get(i);
if (i > 0) json.append(",");
json.append("{")
.append("\"type\":\"").append(log.type).append("\",")
.append("\"message\":\"").append(log.message.replace("\"", "\\\"")).append("\",")
.append("\"color\":\"").append(log.color).append("\",")
.append("\"timestamp\":").append(log.timestamp)
.append("}");
}
json.append("]}");
// Отправляем JSON
try (OutputStream os = connection.getOutputStream()) {
os.write(json.toString().getBytes(StandardCharsets.UTF_8));
}
int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) {
// Убираем лишние логи
// Log.d(TAG, "Лог успешно отправлен, код ответа: " + responseCode);
Log.d(TAG, "Пакет из " + logs.size() + " логов успешно отправлен");
} else {
Log.w(TAG, "Лог отправлен с предупреждением, код ответа: " + responseCode);
Log.w(TAG, "Пакет логов отправлен с предупреждением, код ответа: " + responseCode);
}
} catch (IOException e) {
Log.e(TAG, "Ошибка HTTP запроса: " + e.getMessage(), e);
Log.e(TAG, "Ошибка отправки пакета логов: " + e.getMessage(), e);
// Возвращаем логи в буфер при ошибке
synchronized (bufferLock) {
logBuffer.addAll(0, logs); // Добавляем в начало буфера
}
} finally {
if (connection != null) {
connection.disconnect();
@@ -374,9 +176,297 @@ public class LogSender {
}
/**
* Останавливает executor (вызывать при завершении приложения)
* Отправляет лог NMEA сообщения
* @param nmeaMessage NMEA сообщение
*/
public static void logNMEA(String nmeaMessage) {
if (!ENABLED) {
return;
}
if (nmeaMessage == null || nmeaMessage.trim().isEmpty()) {
return;
}
addToBuffer("nmea", nmeaMessage, COLOR_SOFT_BLUE);
}
/**
* Отправляет лог обновления информации о корабле
* @param mmsi MMSI корабля
* @param vesselInfo Информация о корабле
*/
public static void logShipUpdate(String mmsi, String vesselInfo) {
if (!ENABLED) {
return;
}
if (mmsi == null || mmsi.trim().isEmpty()) {
return;
}
String message = "MMSI: " + mmsi;
if (vesselInfo != null && !vesselInfo.trim().isEmpty()) {
message += " | " + vesselInfo;
}
addToBuffer("ships", message, generateVesselColor(mmsi));
}
/**
* Отправляет лог обновления информации о корабле с заданным цветом
* @param mmsi MMSI корабля
* @param vesselInfo Информация о корабле
* @param color Цвет лога
*/
public static void logShipUpdate(String mmsi, String vesselInfo, String color) {
if (!ENABLED) {
return;
}
if (mmsi == null || mmsi.trim().isEmpty()) {
return;
}
String message = "MMSI: " + mmsi;
if (vesselInfo != null && !vesselInfo.trim().isEmpty()) {
message += " | " + vesselInfo;
}
addToBuffer("ships", message, color != null ? color : generateVesselColor(mmsi));
}
/**
* Отправляет кастомный лог
* @param logName Имя лога
* @param message Сообщение
* @param color Цвет
*/
public static void logCustom(String logName, String message, String color) {
if (!ENABLED) {
return;
}
if (logName == null || message == null || message.trim().isEmpty()) {
return;
}
addToBuffer(logName, message, color != null ? color : COLOR_SOFT_BLUE);
}
/**
* Генерирует уникальный мягкий цвет для корабля на основе MMSI
* Оптимизирован для читаемости на фоне #364758
*/
private static String generateVesselColor(String mmsi) {
if (mmsi == null || mmsi.trim().isEmpty()) {
return COLOR_SOFT_BLUE;
}
try {
long mmsiLong = Long.parseLong(mmsi);
// Используем MMSI для генерации цвета
int hash = (int) (mmsiLong % 360);
// Генерируем мягкий цвет в HSV для фона #364758
float hue = hash;
float saturation = VESSEL_COLOR_SATURATION; // Низкая насыщенность для мягкости
float value = VESSEL_COLOR_VALUE; // Средняя яркость для контраста с темным фоном
// Конвертируем HSV в RGB (Android-совместимая реализация)
float[] rgb = hsvToRgb(hue / 360f, saturation, value);
int red = Math.round(rgb[0] * 255);
int green = Math.round(rgb[1] * 255);
int blue = Math.round(rgb[2] * 255);
return String.format("#%02X%02X%02X", red, green, blue);
} catch (NumberFormatException e) {
// Если MMSI не число, используем хеш строки
int hash = Math.abs(mmsi.hashCode()) % 360;
float hue = hash;
float saturation = VESSEL_COLOR_SATURATION; // Низкая насыщенность для мягкости
float value = VESSEL_COLOR_VALUE; // Средняя яркость для контраста с темным фоном
float[] rgb = hsvToRgb(hue / 360f, saturation, value);
int red = Math.round(rgb[0] * 255);
int green = Math.round(rgb[1] * 255);
int blue = Math.round(rgb[2] * 255);
return String.format("#%02X%02X%02X", red, green, blue);
}
}
/**
* Конвертирует HSV в RGB (Android-совместимая реализация)
*/
private static float[] hsvToRgb(float h, float s, float v) {
float[] rgb = new float[3];
if (s == 0) {
// Оттенки серого
rgb[0] = rgb[1] = rgb[2] = v;
} else {
float c = v * s;
float x = c * (1 - Math.abs((h * 6) % 2 - 1));
float m = v - c;
if (h < 1f/6f) {
rgb[0] = c; rgb[1] = x; rgb[2] = 0;
} else if (h < 2f/6f) {
rgb[0] = x; rgb[1] = c; rgb[2] = 0;
} else if (h < 3f/6f) {
rgb[0] = 0; rgb[1] = c; rgb[2] = x;
} else if (h < 4f/6f) {
rgb[0] = 0; rgb[1] = x; rgb[2] = c;
} else if (h < 5f/6f) {
rgb[0] = x; rgb[1] = 0; rgb[2] = c;
} else {
rgb[0] = c; rgb[1] = 0; rgb[2] = x;
}
rgb[0] += m;
rgb[1] += m;
rgb[2] += m;
}
return rgb;
}
/**
* Логирует ошибки парсинга NMEA сообщений
*/
public static void logError(String errorType, String message, String details) {
if (!ENABLED) {
return;
}
String timestamp = java.time.LocalDateTime.now().format(
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
String logMessage = String.format(java.util.Locale.US,
"[%s] %s: %s | Details: %s",
timestamp, errorType, message, details);
addToBuffer("errors", logMessage, COLOR_SOFT_RED);
}
/**
* Логирует отброшенное NMEA сообщение
*/
public static void logDroppedNMEA(String reason, String nmeaMessage, String details) {
if (!ENABLED) {
return;
}
logError("DROPPED_NMEA",
String.format("Отброшено NMEA сообщение: %s", reason),
String.format("Message: %s | %s",
nmeaMessage != null ? nmeaMessage.substring(0, Math.min(100, nmeaMessage.length())) : "null",
details != null ? details : ""));
}
/**
* Логирует ошибку парсинга AIS
*/
public static void logAISParseError(String error, String aisMessage, String details) {
if (!ENABLED) {
return;
}
logError("AIS_PARSE_ERROR",
String.format("Ошибка парсинга AIS: %s", error),
String.format("AIS: %s | %s",
aisMessage != null ? aisMessage.substring(0, Math.min(100, aisMessage.length())) : "null",
details != null ? details : ""));
}
/**
* Логирует ошибку парсинга AIS с полным NMEA сообщением
*/
public static void logAISParseErrorWithFullNMEA(String error, String fullNMEAMessage, String aisPayload, String details) {
if (!ENABLED) {
return;
}
logError("AIS_PARSE_ERROR",
String.format("Ошибка парсинга AIS: %s", error),
String.format("Full NMEA: %s | AIS Payload: %s | %s",
fullNMEAMessage != null ? fullNMEAMessage : "null",
aisPayload != null ? aisPayload.substring(0, Math.min(100, aisPayload.length())) : "null",
details != null ? details : ""));
}
/**
* Логирует ошибку BLE соединения
*/
public static void logBLEError(String error, String deviceMac, String details) {
if (!ENABLED) {
return;
}
logError("BLE_ERROR",
String.format("Ошибка BLE: %s", error),
String.format("Device: %s | %s",
deviceMac != null ? deviceMac : "unknown",
details != null ? details : ""));
}
/**
* Логирует полученный BLE кусок данных
*/
public static void logBLEDataChunk(String deviceMac, String dataChunk) {
if (!ENABLED) {
return;
}
if (dataChunk == null || dataChunk.trim().isEmpty()) {
return;
}
String message = "BLE Data from " + (deviceMac != null ? deviceMac : "unknown") + ": " + dataChunk;
addToBuffer("ble", message, COLOR_SOFT_BLUE);
}
/**
* Получает количество логов в буфере
*/
public static int getBufferSize() {
if (!ENABLED) {
return 0;
}
synchronized (bufferLock) {
return logBuffer.size();
}
}
/**
* Принудительно отправляет все накопленные логи
*/
public static void flushLogs() {
if (!ENABLED) {
return;
}
sendBufferedLogs();
}
/**
* Останавливает планировщик (вызывать при завершении приложения)
*/
public static void shutdown() {
executor.shutdown();
if (!ENABLED) {
return;
}
if (scheduler != null) {
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(2, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
}
/**
* Тестовый метод для демонстрации мягких цветов кораблей
* Можно использовать для проверки читаемости на фоне #364758
*/
public static void testVesselColors() {
if (!ENABLED) {
return;
}
String[] testMMSIs = {"123456789", "987654321", "555666777", "111222333", "999888777"};
Log.d(TAG, "=== Тест мягких цветов для кораблей (фон #364758) ===");
for (String mmsi : testMMSIs) {
String color = generateVesselColor(mmsi);
Log.d(TAG, String.format("MMSI %s -> цвет %s", mmsi, color));
}
// Log.d(TAG, "=== Настройки: насыщенность=%.1f, яркость=%.1f ===",VESSEL_COLOR_SATURATION, VESSEL_COLOR_VALUE);
}
}
@@ -19,6 +19,10 @@ public class SettingsManager {
private static final String KEY_ANDROID_NMEA_ENABLED = "android_nmea_enabled";
private static final String KEY_UDP_NMEA_ENABLED = "udp_nmea_enabled";
private static final String KEY_DATA_MODE = "data_mode";
// Источник координат собственного судна. Отделён от KEY_DATA_MODE,
// так как начиная с BLE v2 ais_hub сам поставляет ownship по BLE,
// а настройки выше трактуют только старый NMEA-тракт.
private static final String KEY_GPS_SOURCE = "gps_source";
private static final String KEY_DATA_STALE_WARNING_MINUTES = "data_stale_warning_minutes";
private static final String KEY_DATA_STALE_REMOVE_MINUTES = "data_stale_remove_minutes";
private static final String KEY_PATH_TRACKING_ENABLED = "path_tracking_enabled";
@@ -34,7 +38,19 @@ public class SettingsManager {
private static final String KEY_CURSOR_ENABLED = "cursor_enabled";
private static final String KEY_NOTIFICATIONS_ENABLED = "notifications_enabled";
private static final String KEY_DEBUG_ENABLED = "debug_enabled";
private static final String KEY_SEAMARKS_ENABLED = "seamarks_enabled";
private static final String KEY_ANDROID_GPS_ENABLED = "android_gps_enabled";
// Map startup behavior
private static final String KEY_START_CENTER_ON_LAST = "start_center_on_last";
private static final String KEY_START_ZOOM_LEVEL = "start_zoom_level";
/** Как карта следует за ориентацией: {@link #MAP_ROTATION_COMPASS} / COURSE / MANUAL */
private static final String KEY_MAP_ROTATION_MODE = "map_rotation_mode";
// BLE/NMEA settings
private static final String KEY_BLE_ENABLED = "ble_enabled";
private static final String KEY_BLE_DEVICE_MAC = "ble_device_mac";
private static final String KEY_BLE_UDP_BRIDGE_ENABLED = "ble_udp_bridge_enabled";
private static final String KEY_BLE_UDP_BRIDGE_HOST = "ble_udp_bridge_host";
private static final String KEY_BLE_UDP_BRIDGE_PORT = "ble_udp_bridge_port";
// Значения по умолчанию
private static final int DEFAULT_UDP_PORT = 10110;
@@ -58,11 +74,39 @@ public class SettingsManager {
private static final boolean DEFAULT_NOTIFICATIONS_ENABLED = true;
private static final boolean DEFAULT_ANDROID_GPS_ENABLED = true;
private static final boolean DEFAULT_DEBUG_ENABLED = false;
private static final boolean DEFAULT_SEAMARKS_ENABLED = false;
// Map startup defaults
private static final boolean DEFAULT_START_CENTER_ON_LAST = true;
private static final float DEFAULT_START_ZOOM_LEVEL = 14.0f;
// BLE defaults
private static final boolean DEFAULT_BLE_ENABLED = false;
private static final String DEFAULT_BLE_DEVICE_MAC = "";
private static final boolean DEFAULT_BLE_UDP_BRIDGE_ENABLED = false;
private static final String DEFAULT_BLE_UDP_BRIDGE_HOST = "255.255.255.255";
private static final int DEFAULT_BLE_UDP_BRIDGE_PORT = 10110;
// Режимы работы с данными
public static final String DATA_MODE_HYBRID = "hybrid";
public static final String DATA_MODE_NMEA_ONLY = "nmea_only";
public static final String DATA_MODE_ANDROID_ONLY = "android_only";
// Источник координат собственного судна.
// - GPS_SOURCE_HUB: позиция берётся из ais_hub (BLE ownship.update).
// Android GPS/NMEA слушать не нужно. AIS-цели всегда идут через BLE.
// - GPS_SOURCE_ANDROID: позиция берётся из Android Location API
// (+ опциональный внешний NMEA по UDP). BLE может оставаться включённым
// ради AIS-целей, но его ownship.update игнорируется.
public static final String GPS_SOURCE_HUB = "ble_hub";
public static final String GPS_SOURCE_ANDROID = "android";
private static final String DEFAULT_GPS_SOURCE = GPS_SOURCE_HUB;
/** Север вверх; поворот двумя пальцами, авто-не вмешивается. */
public static final String MAP_ROTATION_MANUAL = "manual";
/** Как магнитный компас / азимут корпуса. */
public static final String MAP_ROTATION_COMPASS = "compass";
/** Как курс (COG / GPS bearing). */
public static final String MAP_ROTATION_COURSE = "course";
private static final String DEFAULT_MAP_ROTATION_MODE = MAP_ROTATION_MANUAL;
private Context context;
private SharedPreferences prefs;
@@ -185,6 +229,38 @@ public class SettingsManager {
return DATA_MODE_ANDROID_ONLY.equals(getDataMode());
}
/**
* Текущий источник координат собственного судна: {@link #GPS_SOURCE_HUB}
* или {@link #GPS_SOURCE_ANDROID}. По умолчанию HUB.
*/
public String getGpsSource() {
String v = prefs.getString(KEY_GPS_SOURCE, DEFAULT_GPS_SOURCE);
if (!GPS_SOURCE_HUB.equals(v) && !GPS_SOURCE_ANDROID.equals(v)) {
return DEFAULT_GPS_SOURCE;
}
return v;
}
public void setGpsSource(String source) {
if (!GPS_SOURCE_HUB.equals(source) && !GPS_SOURCE_ANDROID.equals(source)) {
source = DEFAULT_GPS_SOURCE;
}
prefs.edit().putString(KEY_GPS_SOURCE, source).apply();
Log.i(TAG, "GPS source: " + source);
}
public boolean isGpsFromHub() { return GPS_SOURCE_HUB.equals(getGpsSource()); }
public boolean isGpsFromAndroid() { return GPS_SOURCE_ANDROID.equals(getGpsSource()); }
/**
* Переключает источник координат и возвращает новое значение.
*/
public String toggleGpsSource() {
String next = isGpsFromHub() ? GPS_SOURCE_ANDROID : GPS_SOURCE_HUB;
setGpsSource(next);
return next;
}
/**
* Проверяет, включен ли Android GPS (Location API)
*/
@@ -199,6 +275,55 @@ public class SettingsManager {
prefs.edit().putBoolean(KEY_ANDROID_GPS_ENABLED, enabled).apply();
Log.i(TAG, "Android GPS: " + (enabled ? "включен" : "выключен"));
}
// ===== BLE settings =====
public boolean isBLEEnabled() {
return prefs.getBoolean(KEY_BLE_ENABLED, DEFAULT_BLE_ENABLED);
}
public void setBLEEnabled(boolean enabled) {
prefs.edit().putBoolean(KEY_BLE_ENABLED, enabled).apply();
Log.i(TAG, "BLE: " + (enabled ? "включен" : "выключен"));
}
public String getBLEDeviceMac() {
return prefs.getString(KEY_BLE_DEVICE_MAC, DEFAULT_BLE_DEVICE_MAC);
}
public void setBLEDeviceMac(String mac) {
if (mac == null) mac = "";
prefs.edit().putString(KEY_BLE_DEVICE_MAC, mac).apply();
Log.i(TAG, "BLE MAC сохранён: " + mac);
}
public boolean isBleUdpBridgeEnabled() {
return prefs.getBoolean(KEY_BLE_UDP_BRIDGE_ENABLED, DEFAULT_BLE_UDP_BRIDGE_ENABLED);
}
public void setBleUdpBridgeEnabled(boolean enabled) {
prefs.edit().putBoolean(KEY_BLE_UDP_BRIDGE_ENABLED, enabled).apply();
Log.i(TAG, "BLE UDP-bridge: " + (enabled ? "включен" : "выключен"));
}
public String getBleUdpBridgeHost() {
return prefs.getString(KEY_BLE_UDP_BRIDGE_HOST, DEFAULT_BLE_UDP_BRIDGE_HOST);
}
public void setBleUdpBridgeHost(String host) {
if (host == null || host.trim().isEmpty()) host = DEFAULT_BLE_UDP_BRIDGE_HOST;
prefs.edit().putString(KEY_BLE_UDP_BRIDGE_HOST, host).apply();
Log.i(TAG, "BLE UDP-bridge host: " + host);
}
public int getBleUdpBridgePort() {
return prefs.getInt(KEY_BLE_UDP_BRIDGE_PORT, DEFAULT_BLE_UDP_BRIDGE_PORT);
}
public void setBleUdpBridgePort(int port) {
if (port < 1 || port > 65535) port = DEFAULT_BLE_UDP_BRIDGE_PORT;
prefs.edit().putInt(KEY_BLE_UDP_BRIDGE_PORT, port).apply();
Log.i(TAG, "BLE UDP-bridge port: " + port);
}
/**
* Сбрасывает все настройки к значениям по умолчанию
@@ -215,6 +340,14 @@ public class SettingsManager {
.putBoolean(KEY_VIBRATION_ENABLED, DEFAULT_VIBRATION_ENABLED)
.putBoolean(KEY_SOUND_ENABLED, DEFAULT_SOUND_ENABLED)
.putBoolean(KEY_KEEP_SCREEN_ON_ENABLED, DEFAULT_KEEP_SCREEN_ON_ENABLED)
.putBoolean(KEY_START_CENTER_ON_LAST, DEFAULT_START_CENTER_ON_LAST)
.putFloat(KEY_START_ZOOM_LEVEL, DEFAULT_START_ZOOM_LEVEL)
.putString(KEY_MAP_ROTATION_MODE, DEFAULT_MAP_ROTATION_MODE)
.putBoolean(KEY_BLE_ENABLED, DEFAULT_BLE_ENABLED)
.putString(KEY_BLE_DEVICE_MAC, DEFAULT_BLE_DEVICE_MAC)
.putBoolean(KEY_BLE_UDP_BRIDGE_ENABLED, DEFAULT_BLE_UDP_BRIDGE_ENABLED)
.putString(KEY_BLE_UDP_BRIDGE_HOST, DEFAULT_BLE_UDP_BRIDGE_HOST)
.putInt(KEY_BLE_UDP_BRIDGE_PORT, DEFAULT_BLE_UDP_BRIDGE_PORT)
.apply();
Log.i(TAG, "Настройки сброшены к значениям по умолчанию");
}
@@ -247,17 +380,80 @@ public class SettingsManager {
"UDP: порт=%d, включен=%s\n" +
"Android NMEA: %s\n" +
"UDP NMEA: %s\n" +
"Старт центр по последней: %s, стартовый зум=%.1f\n" +
"BLE: %s, MAC=%s, Bridge=%s %s:%d\n" +
"Режим данных: %s\n" +
"Уведомления: вибрация=%s, звук=%s",
getUDPPort(),
isUDPEnabled() ? "да" : "нет",
isAndroidNMEAEnabled() ? "включен" : "выключен",
isUDPNMEAEnabled() ? "включен" : "выключен",
isStartCenterOnLastEnabled() ? "да" : "нет",
getStartZoomLevel(),
isBLEEnabled() ? "включен" : "выключен",
getBLEDeviceMac(),
isBleUdpBridgeEnabled() ? "вкл" : "выкл",
getBleUdpBridgeHost(),
getBleUdpBridgePort(),
getDataMode(),
isVibrationEnabled() ? "включена" : "выключена",
isSoundEnabled() ? "включен" : "выключен"
);
}
// ===== Map startup behavior =====
public boolean isStartCenterOnLastEnabled() {
return prefs.getBoolean(KEY_START_CENTER_ON_LAST, DEFAULT_START_CENTER_ON_LAST);
}
public void setStartCenterOnLastEnabled(boolean enabled) {
prefs.edit().putBoolean(KEY_START_CENTER_ON_LAST, enabled).apply();
Log.i(TAG, "Старт: центр по последней позиции: " + (enabled ? "включен" : "выключен"));
}
public float getStartZoomLevel() {
return prefs.getFloat(KEY_START_ZOOM_LEVEL, DEFAULT_START_ZOOM_LEVEL);
}
public void setStartZoomLevel(float zoom) {
if (zoom < 2.0f) zoom = 2.0f;
if (zoom > 20.0f) zoom = 20.0f;
prefs.edit().putFloat(KEY_START_ZOOM_LEVEL, zoom).apply();
Log.i(TAG, "Стартовый зум установлен: " + zoom);
}
public String getMapRotationMode() {
String m = prefs.getString(KEY_MAP_ROTATION_MODE, DEFAULT_MAP_ROTATION_MODE);
if (!MAP_ROTATION_MANUAL.equals(m) && !MAP_ROTATION_COMPASS.equals(m) && !MAP_ROTATION_COURSE.equals(m)) {
return DEFAULT_MAP_ROTATION_MODE;
}
return m;
}
public void setMapRotationMode(String mode) {
if (!MAP_ROTATION_MANUAL.equals(mode) && !MAP_ROTATION_COMPASS.equals(mode) && !MAP_ROTATION_COURSE.equals(mode)) {
mode = DEFAULT_MAP_ROTATION_MODE;
}
prefs.edit().putString(KEY_MAP_ROTATION_MODE, mode).apply();
Log.i(TAG, "Режим вращения карты: " + mode);
}
/**
* Цикл: компас курс вручную компас
*/
public String cycleMapRotationMode() {
String current = getMapRotationMode();
String next;
if (MAP_ROTATION_COMPASS.equals(current)) {
next = MAP_ROTATION_COURSE;
} else if (MAP_ROTATION_COURSE.equals(current)) {
next = MAP_ROTATION_MANUAL;
} else {
next = MAP_ROTATION_COMPASS;
}
setMapRotationMode(next);
return next;
}
/**
* Проверяет, нужно ли перезапустить UDP слушатель
@@ -486,4 +682,19 @@ public class SettingsManager {
Log.i(TAG, "Дебаг-режим: " + (enabled ? "включен" : "выключен"));
}
/**
* Проверяет, включены ли морские знаки OpenSeaMap
*/
public boolean isSeamarksEnabled() {
return prefs.getBoolean(KEY_SEAMARKS_ENABLED, DEFAULT_SEAMARKS_ENABLED);
}
/**
* Включает/выключает морские знаки OpenSeaMap
*/
public void setSeamarksEnabled(boolean enabled) {
prefs.edit().putBoolean(KEY_SEAMARKS_ENABLED, enabled).apply();
Log.i(TAG, "Морские знаки OpenSeaMap: " + (enabled ? "включены" : "выключены"));
}
}
@@ -228,28 +228,28 @@ public abstract class BaseDockWidget extends FrameLayout {
private void handleDockResize(MotionEvent event) {
float deltaY = event.getRawY() - lastTouchY;
lastTouchY = event.getRawY();
ViewGroup.LayoutParams lp = getLayoutParams();
int newHeight = lp.height;
// Направление изменения размера зависит от позиции закрепления
// Ресайзим именно контент (dockHeightPx). Паддинги от WindowInsets
// прибавляются поверх в onMeasure, поэтому «рабочая» часть не уезжает
// под системный бар даже при минимальном размере.
int currentContent = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
int newHeight = currentContent;
if (dockTop) {
// Если закреплен сверху, увеличиваем размер при движении вниз
newHeight += (int) deltaY;
} else {
// Если закреплен снизу, увеличиваем размер при движении вверх
newHeight -= (int) deltaY;
}
// Ограничиваем минимальную и максимальную высоту
int minHeight = (int) dp(40);
int maxHeight = ((ViewGroup) getParent()).getHeight() / 2;
newHeight = Math.max(minHeight, Math.min(newHeight, maxHeight));
if (newHeight != lp.height) {
lp.height = newHeight;
if (newHeight != currentContent) {
dockHeightPx = newHeight;
ViewGroup.LayoutParams lp = getLayoutParams();
lp.height = newHeight + getPaddingTop() + getPaddingBottom();
setLayoutParams(lp);
// Корректируем позицию Y в зависимости от позиции закрепления
@@ -324,7 +324,11 @@ public abstract class BaseDockWidget extends FrameLayout {
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (isDocked) {
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
// dockHeightPx/DEFAULT это высота полезного контента; к ней
// прибавляем padding от WindowInsets, чтобы виджет фактически
// расширялся под статус-бар или нав-бар и не прятал контент.
int content = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
int height = content + getPaddingTop() + getPaddingBottom();
setMeasuredDimension(width, height);
} else {
int size = (int)(dp(CIRCLE_SIZE_DP) * scaleFactor);
@@ -9,6 +9,7 @@ import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.graphics.Typeface;
import android.util.AttributeSet;
import android.util.Log;
import android.view.ViewGroup;
@@ -22,17 +23,32 @@ import java.util.List;
public class CompassView extends BaseDockWidget {
private static final String TAG = "CompassView";
// Палитра синхронизирована с CoordinatesDockWidget чтобы компас и
// координаты выглядели единым виджетом, а не двумя разными стилями.
private static final int BACKGROUND_COLOR = 0xD91A1F24;
private static final int TEXT_COLOR = 0xFFFFFFFF;
private static final int LABEL_COLOR = 0xFF9AA4B2;
private static final int ACCENT_COLOR = 0xFF4CAF50; // курс/heading
private static final int DIVIDER_COLOR = 0x33FFFFFF;
private static final int TICK_COLOR = 0xFFD0D4DA;
private float targetAzimuth = 0;
private float currentAzimuth = 0;
private float magneticCompass = 0; // магнитный компас
private final Paint paint = 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 Paint accentPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint dividerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint vesselPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path vesselPath = new Path();
private final String[] directions = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"};
private float centerX;
private float centerY;
private static final float SMOOTHING_FACTOR = 0.15f;
private static final float AZIMUTH_DRAW_EPS = 0.5f;
private List<AISVessel> nearbyVessels = new ArrayList<>();
private Vessel ourVessel; // наше судно для расчета расстояний
private static final float MAX_DISPLAY_DISTANCE = 10000; // 10 км
@@ -50,15 +66,33 @@ public class CompassView extends BaseDockWidget {
}
private void init() {
paint.setColor(Color.WHITE);
paint.setColor(TICK_COLOR);
paint.setTextAlign(Paint.Align.CENTER);
paint.setTextSize(36f);
labelPaint.setColor(LABEL_COLOR);
labelPaint.setTextSize(dp(11));
labelPaint.setTypeface(Typeface.DEFAULT);
labelPaint.setLetterSpacing(0.08f);
valuePaint.setColor(TEXT_COLOR);
valuePaint.setTextSize(dp(16));
valuePaint.setTypeface(Typeface.DEFAULT_BOLD);
accentPaint.setColor(ACCENT_COLOR);
accentPaint.setTextSize(dp(16));
accentPaint.setTypeface(Typeface.DEFAULT_BOLD);
bgPaint.setColor(BACKGROUND_COLOR);
bgPaint.setStyle(Paint.Style.FILL);
dividerPaint.setColor(DIVIDER_COLOR);
dividerPaint.setStrokeWidth(dp(1));
vesselPaint.setStyle(Paint.Style.FILL);
vesselPaint.setAntiAlias(true);
// Устанавливаем фон для видимости
setBackgroundColor(Color.argb(200, 0, 0, 0));
setBackgroundColor(Color.TRANSPARENT);
}
@Override
@@ -96,46 +130,81 @@ public class CompassView extends BaseDockWidget {
// Прямая шкала (dock-режим)
@Override
protected void onDrawDock(Canvas canvas) {
// Log.d(TAG, "onDrawDock called, width=" + getWidth() + ", height=" + getHeight());
float w = getWidth();
float h = getHeight();
if (w <= 0 || h <= 0) {
Log.w(TAG, "Invalid dimensions: width=" + w + ", height=" + h);
float totalW = getWidth();
float totalH = getHeight();
if (totalW <= 0 || totalH <= 0) {
Log.w(TAG, "Invalid dimensions: width=" + totalW + ", height=" + totalH);
return;
}
// Простой фон для начала
paint.setColor(Color.argb(200, 0, 0, 0));
canvas.drawRect(0, 0, w, h, paint);
// Масштабируем размеры в зависимости от высоты виджета
float baseHeight = dp(80); // базовая высота
// Учитываем паддинги (которые MainActivity назначает по системным
// инсетам и вырезам камеры). Фон рисуем на всю область виджета,
// а весь контент только внутри padding-box.
int pl = getPaddingLeft();
int pt = getPaddingTop();
int pr = getPaddingRight();
int pb = getPaddingBottom();
float left = pl;
float top = pt;
float right = totalW - pr;
float bottom = totalH - pb;
float w = Math.max(0f, right - left);
float h = Math.max(0f, bottom - top);
if (w <= 0 || h <= 0) return;
// Фон в палитре координатного виджета рисуем на всю область,
// чтобы под статус-бар/бровь тоже уходил единый тон.
canvas.drawRect(0, 0, totalW, totalH, bgPaint);
// Масштабируем размеры в зависимости от высоты контентной области.
float baseHeight = dp(80);
float scaleFactor = Math.max(0.8f, Math.min(2.0f, h / baseHeight));
// Простой текст для проверки (убрана надпись "КОМПАС")
paint.setColor(Color.WHITE);
// Шапка в стиле LABEL + значение (как POSITION/SOG/COG/ACC в
// координатах): слева HEADING (азимут), справа MAG (магн. компас).
float cx = left + w / 2f;
float padInner = dp(10);
float labelY = top + dp(12) * Math.max(1f, scaleFactor * 0.9f);
float valueY = labelY + dp(16) * Math.max(1f, scaleFactor * 0.9f);
labelPaint.setTextAlign(Paint.Align.LEFT);
valuePaint.setTextAlign(Paint.Align.LEFT);
accentPaint.setTextAlign(Paint.Align.LEFT);
canvas.drawText("HEADING", left + padInner, labelY, labelPaint);
canvas.drawText(((int) currentAzimuth) + "°",
left + padInner, valueY, accentPaint);
labelPaint.setTextAlign(Paint.Align.RIGHT);
valuePaint.setTextAlign(Paint.Align.RIGHT);
canvas.drawText("MAG", right - padInner, labelY, labelPaint);
canvas.drawText(((int) magneticCompass) + "°",
right - padInner, valueY, valuePaint);
// Разделитель под шапкой такой же, как в координатах.
float dividerY = valueY + dp(6);
canvas.drawLine(left + padInner, dividerY, right - padInner, dividerY, dividerPaint);
// Цвет делений шкалы светло-серый, чтобы не спорил с фоном палитры.
paint.setColor(TICK_COLOR);
paint.setTextSize(24 * scaleFactor);
paint.setTextAlign(Paint.Align.CENTER);
float topTextY = dp(18) * scaleFactor;
canvas.drawText("Азимут: " + (int)currentAzimuth + "°", w/2, topTextY, paint);
canvas.drawText("Магн: " + (int)magneticCompass + "°", w/2, topTextY + 24 * scaleFactor, paint);
// Плавное обновление азимута
float diff = getShortestRotation(currentAzimuth, targetAzimuth);
if (Math.abs(diff) > 0.1f) {
// Ограничиваем максимальное изменение за один кадр
float maxChange = 3.0f; // максимальное изменение в градусах за кадр
if (Math.abs(diff) > AZIMUTH_DRAW_EPS) {
float maxChange = 3.0f;
float change = Math.signum(diff) * Math.min(Math.abs(diff * SMOOTHING_FACTOR), maxChange);
currentAzimuth += change;
currentAzimuth = normalizeAngle(currentAzimuth);
postInvalidateOnAnimation();
}
// Рисуем простую шкалу
float centerX = w / 2f;
float centerY = h / 2f;
// Рисуем простую шкалу под шапкой. Центр смещён, чтобы шкала
// не наезжала на label-строку HEADING/MAG.
float centerX = left + w / 2f;
float scaleTop = dividerY + dp(4);
float centerY = scaleTop + (bottom - scaleTop) * 0.5f;
float visibleDegrees = 120;
// Рисуем деления шкалы
@@ -179,31 +248,31 @@ public class CompassView extends BaseDockWidget {
}
}
// Центральная линия (направление вперёд)
// Центральная линия (направление вперёд) только в области шкалы,
// чтобы не пересекать шапку HEADING/MAG.
paint.setColor(Color.RED);
paint.setStrokeWidth(3 * scaleFactor);
canvas.drawLine(centerX, centerY - h/2, centerX, centerY + h/2, paint);
paint.setColor(Color.WHITE);
canvas.drawLine(centerX, scaleTop, centerX, bottom, paint);
paint.setColor(TICK_COLOR);
paint.setStrokeWidth(1);
// Выделяем зону resize в зависимости от позиции закрепления
// Зоны resize остаются привязанными к физическим краям виджета,
// а не к padding-box, иначе пользователь не попадёт пальцем.
if (isDocked) {
Paint resizePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
resizePaint.setColor(Color.argb(120, 255, 255, 255));
resizePaint.setStyle(Paint.Style.STROKE);
resizePaint.setStrokeWidth(2);
paint.setTextSize(12);
paint.setColor(Color.WHITE);
if (isDockTop()) {
// Если закреплен сверху, показываем зону resize снизу
canvas.drawRect(0, h - dp(24), w, h, resizePaint);
canvas.drawText("", w/2, h - dp(12), paint);
canvas.drawRect(0, totalH - dp(24), totalW, totalH, resizePaint);
canvas.drawText("", totalW / 2f, totalH - dp(12), paint);
} else {
// Если закреплен снизу, показываем зону resize сверху
canvas.drawRect(0, 0, w, dp(24), resizePaint);
canvas.drawText("", w/2, dp(12), paint);
canvas.drawRect(0, 0, totalW, dp(24), resizePaint);
canvas.drawText("", totalW / 2f, dp(12), paint);
}
}
}
@@ -229,16 +298,15 @@ public class CompassView extends BaseDockWidget {
float baseSize = dp(120); // базовая высота
float scaleFactor = Math.max(0.8f, Math.min(2.0f, Math.min(w, h) / baseSize));
// Фон
paint.setColor(Color.argb(200, 0, 0, 0));
canvas.drawCircle(cx, cy, radius, paint);
paint.setColor(Color.WHITE);
// Фон круглого компаса та же палитра, что и у координатного
// виджета в draggable-режиме. Используем bgPaint без argb(...).
canvas.drawCircle(cx, cy, radius, bgPaint);
paint.setColor(TICK_COLOR);
// Плавное обновление азимута
float diff = getShortestRotation(currentAzimuth, targetAzimuth);
if (Math.abs(diff) > 0.1f) {
// Ограничиваем максимальное изменение за один кадр
float maxChange = 3.0f; // максимальное изменение в градусах за кадр
if (Math.abs(diff) > AZIMUTH_DRAW_EPS) {
float maxChange = 3.0f;
float change = Math.signum(diff) * Math.min(Math.abs(diff * SMOOTHING_FACTOR), maxChange);
currentAzimuth += change;
currentAzimuth = normalizeAngle(currentAzimuth);
@@ -282,13 +350,17 @@ public class CompassView extends BaseDockWidget {
paint.setColor(Color.RED);
paint.setStrokeWidth(3 * scaleFactor);
canvas.drawLine(cx, cy, cx, cy - radius, paint);
paint.setColor(Color.WHITE);
paint.setColor(TICK_COLOR);
paint.setStrokeWidth(1);
// Текст азимута в центре
paint.setTextSize(14 * scaleFactor);
paint.setTextAlign(Paint.Align.CENTER);
canvas.drawText((int)currentAzimuth + "°", cx, cy + 5 * scaleFactor, paint);
// Центральный текст: значение HEADING в акцентном цвете, ниже
// мелкий LABEL, по аналогии с блоками в CoordinatesDockWidget.
accentPaint.setTextAlign(Paint.Align.CENTER);
accentPaint.setTextSize(dp(18) * Math.max(0.7f, Math.min(1.4f, scaleFactor)));
canvas.drawText(((int) currentAzimuth) + "°", cx, cy + dp(2), accentPaint);
labelPaint.setTextAlign(Paint.Align.CENTER);
labelPaint.setTextSize(dp(9) * Math.max(0.7f, Math.min(1.4f, scaleFactor)));
canvas.drawText("HEADING", cx, cy + dp(14), labelPaint);
}
@@ -13,24 +13,28 @@ public class CoordinatesDockWidget extends BaseDockWidget {
private static final String TAG = "CoordinatesDockWidget";
// Цвета
private static final int BACKGROUND_COLOR = 0xE6000000; // Полупрозрачный черный
private static final int BACKGROUND_COLOR = 0xD91A1F24; // Полупрозрачный тёмный с лёгким синим
private static final int TEXT_COLOR = 0xFFFFFFFF; // Белый
private static final int LABEL_COLOR = 0xFF9AA4B2; // Серо-голубой
private static final int ACCENT_COLOR = 0xFF4CAF50; // Зеленый
private static final int WARNING_COLOR = 0xFFFF9800; // Оранжевый
private static final int ERROR_COLOR = 0xFFF44336; // Красный
// Кисти
private Paint backgroundPaint;
private Paint labelPaint;
private Paint textPaint;
private Paint accentPaint;
private Paint warningPaint;
private Paint errorPaint;
private Paint dividerPaint;
// Данные для отображения
private Vessel vessel;
private String coordinatesText = "Координаты: --";
private String sogText = "SOG: --";
private String cogText = "COG: --";
private String coordinatesText = "--";
private String sogText = "--";
private String cogText = "--";
private String accuracyText = "--";
public CoordinatesDockWidget(Context context) {
super(context);
@@ -43,38 +47,51 @@ public class CoordinatesDockWidget extends BaseDockWidget {
}
private void init() {
// Инициализируем кисти
backgroundPaint = new Paint();
backgroundPaint.setColor(BACKGROUND_COLOR);
backgroundPaint.setStyle(Paint.Style.FILL);
backgroundPaint.setAntiAlias(true);
labelPaint = new Paint();
labelPaint.setColor(LABEL_COLOR);
labelPaint.setTextSize(dp(11));
labelPaint.setTypeface(Typeface.DEFAULT);
labelPaint.setAntiAlias(true);
labelPaint.setLetterSpacing(0.08f);
textPaint = new Paint();
textPaint.setColor(TEXT_COLOR);
textPaint.setTextSize(dp(14));
textPaint.setTextSize(dp(16));
textPaint.setTypeface(Typeface.DEFAULT_BOLD);
textPaint.setAntiAlias(true);
accentPaint = new Paint();
accentPaint.setColor(ACCENT_COLOR);
accentPaint.setTextSize(dp(14));
accentPaint.setTextSize(dp(16));
accentPaint.setTypeface(Typeface.DEFAULT_BOLD);
accentPaint.setAntiAlias(true);
warningPaint = new Paint();
warningPaint.setColor(WARNING_COLOR);
warningPaint.setTextSize(dp(14));
warningPaint.setTextSize(dp(16));
warningPaint.setTypeface(Typeface.DEFAULT_BOLD);
warningPaint.setAntiAlias(true);
errorPaint = new Paint();
errorPaint.setColor(ERROR_COLOR);
errorPaint.setTextSize(dp(14));
errorPaint.setTextSize(dp(16));
errorPaint.setTypeface(Typeface.DEFAULT_BOLD);
errorPaint.setAntiAlias(true);
// Устанавливаем фон для видимости (как в CompassView)
setBackgroundColor(android.graphics.Color.argb(200, 0, 0, 0));
dividerPaint = new Paint();
dividerPaint.setColor(0x33FFFFFF);
dividerPaint.setStrokeWidth(dp(1));
dividerPaint.setAntiAlias(true);
// Фон самой view держим прозрачным в dock/circle режиме фон
// рисуем вручную в onDraw*, иначе в round-режиме виден чёрный
// квадрат вокруг окружности.
setBackgroundColor(android.graphics.Color.TRANSPARENT);
}
/**
@@ -98,125 +115,206 @@ public class CoordinatesDockWidget extends BaseDockWidget {
*/
private void updateDisplayText() {
if (vessel == null) {
coordinatesText = "Координаты: --";
sogText = "SOG: --";
cogText = "COG: --";
coordinatesText = "--";
sogText = "--";
cogText = "--";
accuracyText = "--";
return;
}
// Координаты
if (vessel.getLatitude() != 0 && vessel.getLongitude() != 0) {
coordinatesText = String.format("📍 %.6f, %.6f",
vessel.getLatitude(), vessel.getLongitude());
if (vessel.getLatitude() != 0 || vessel.getLongitude() != 0) {
coordinatesText = formatLatLon(vessel.getLatitude(), vessel.getLongitude());
} else {
coordinatesText = "📍 Координаты: --";
coordinatesText = "нет фикса";
}
// SOG (Speed Over Ground)
if (vessel.getSpeed() > 0) {
sogText = String.format("⚡ SOG: %.1f уз", vessel.getSpeed());
if (vessel.getSpeed() > 0.05) {
sogText = String.format(java.util.Locale.US, "%.1f kn", vessel.getSpeed());
} else {
sogText = "⚡ SOG: --";
sogText = "0.0 kn";
}
// COG (Course Over Ground)
if (vessel.getCourse() > 0) {
cogText = String.format("🧭 COG: %.1f°", vessel.getCourse());
if (vessel.getCourse() > 0 || vessel.getSpeed() > 0.05) {
cogText = String.format(java.util.Locale.US, "%.0f\u00B0", vessel.getCourse());
} else {
cogText = "🧭 COG: --";
cogText = "---\u00B0";
}
float acc = vessel.getAccuracy();
if (acc > 0f) {
accuracyText = String.format(java.util.Locale.US, "\u00B1%.1f m", acc);
} else {
accuracyText = "--";
}
}
private String formatLatLon(double lat, double lon) {
char latHemi = lat >= 0 ? 'N' : 'S';
char lonHemi = lon >= 0 ? 'E' : 'W';
double absLat = Math.abs(lat);
double absLon = Math.abs(lon);
int latDeg = (int) absLat;
double latMin = (absLat - latDeg) * 60.0;
int lonDeg = (int) absLon;
double lonMin = (absLon - lonDeg) * 60.0;
return String.format(java.util.Locale.US,
"%02d\u00B0%06.3f'%c %03d\u00B0%06.3f'%c",
latDeg, latMin, latHemi, lonDeg, lonMin, lonHemi);
}
@Override
protected void onDrawDock(Canvas canvas) {
int width = getWidth();
int height = getHeight();
Log.d(TAG, "onDrawDock called, width=" + width + ", height=" + height);
if (width <= 0 || height <= 0) {
Log.w(TAG, "Invalid dimensions: width=" + width + ", height=" + height);
return;
}
// Рисуем фон
// Фон рисуем на всю область виджета (уезжает под нав-бар/вырез),
// а контент в рамках паддингов от WindowInsets.
canvas.drawRect(0, 0, width, height, backgroundPaint);
// Вычисляем позиции для текста
float textSize = dp(14);
float lineHeight = textSize * 1.2f;
float startY = (height - (lineHeight * 3)) / 2 + textSize;
// Определяем цвета в зависимости от качества данных
Paint coordinatesPaint = getCoordinatesPaint();
Paint sogPaint = getSOGPaint();
Paint cogPaint = getCOGPaint();
// Рисуем тестовый заголовок для проверки видимости
Paint testPaint = new Paint();
testPaint.setColor(android.graphics.Color.WHITE);
testPaint.setTextSize(dp(16));
testPaint.setTypeface(android.graphics.Typeface.DEFAULT_BOLD);
testPaint.setAntiAlias(true);
// canvas.drawText("КООРДИНАТЫ", dp(16), dp(20), testPaint);
// Рисуем текст
canvas.drawText(coordinatesText, dp(16), startY, coordinatesPaint);
canvas.drawText(sogText, dp(16), startY + lineHeight, sogPaint);
canvas.drawText(cogText, dp(16), startY + lineHeight * 2, cogPaint);
// Рисуем разделительную линию сверху, если закреплен снизу
float left = getPaddingLeft();
float top = getPaddingTop();
float right = width - getPaddingRight();
float bottom = height - getPaddingBottom();
float contentW = Math.max(0f, right - left);
float contentH = Math.max(0f, bottom - top);
if (contentW <= 0 || contentH <= 0) return;
// Верхняя тонкая разделительная линия (виджет снизу): визуальная
// граница между картой и панелью.
if (!isDockTop()) {
Paint linePaint = new Paint();
linePaint.setColor(ACCENT_COLOR);
linePaint.setStrokeWidth(dp(2));
canvas.drawLine(0, 0, width, 0, linePaint);
canvas.drawLine(left, top, right, top, dividerPaint);
}
float padX = dp(16);
float innerLeft = left + padX;
float innerRight = right - padX;
float innerTop = top + dp(8);
float innerBottom = bottom - dp(8);
// Строка 1: координаты (с подписью "POSITION").
Paint posPaint = getCoordinatesPaint();
float labelH = labelPaint.getTextSize() * 1.1f;
float valueH = posPaint.getTextSize() * 1.15f;
float y = innerTop + labelH;
canvas.drawText("POSITION", innerLeft, y, labelPaint);
y += valueH;
canvas.drawText(coordinatesText, innerLeft, y, posPaint);
// Строка 2: SOG | COG | ACC в три колонки.
float colTop = y + dp(10);
float colW = (innerRight - innerLeft) / 3f;
float colLabelY = colTop + labelH;
float colValueY = colLabelY + valueH;
// SOG
canvas.drawText("SOG", innerLeft, colLabelY, labelPaint);
canvas.drawText(sogText, innerLeft, colValueY, getSOGPaint());
// COG
float cogX = innerLeft + colW;
canvas.drawText("COG", cogX, colLabelY, labelPaint);
canvas.drawText(cogText, cogX, colValueY, getCOGPaint());
// ACC
float accX = innerLeft + colW * 2f;
canvas.drawText("ACC", accX, colLabelY, labelPaint);
canvas.drawText(accuracyText, accX, colValueY, getAccuracyPaint());
if (colValueY > innerBottom) {
// На всякий случай: если текст не помещается, оставляем только
// первую строку (координаты).
}
}
@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(8);
// Рисуем фон
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(3));
borderPaint.setStrokeWidth(dp(2));
borderPaint.setAntiAlias(true);
canvas.drawCircle(centerX, centerY, radius, borderPaint);
// Вычисляем позиции для текста в круге
float textSize = dp(12);
float lineHeight = textSize * 1.3f;
float startY = centerY - lineHeight;
// Определяем цвета
Paint coordinatesPaint = getCoordinatesPaint();
// Более компактная вёрстка: 4 строки (POS lat / POS lon / SOG·COG / ACC)
Paint posPaint = getCoordinatesPaint();
float smallLabel = dp(9);
float smallValue = dp(11);
float bigValue = dp(13);
labelPaint.setTextSize(smallLabel);
posPaint.setTextSize(smallValue);
Paint sogPaint = getSOGPaint();
Paint cogPaint = getCOGPaint();
// Центрируем текст
Rect textBounds = new Rect();
// Координаты
coordinatesPaint.getTextBounds(coordinatesText, 0, coordinatesText.length(), textBounds);
canvas.drawText(coordinatesText, centerX - textBounds.width() / 2f, startY, coordinatesPaint);
// SOG
sogPaint.getTextBounds(sogText, 0, sogText.length(), textBounds);
canvas.drawText(sogText, centerX - textBounds.width() / 2f, startY + lineHeight, sogPaint);
// COG
cogPaint.getTextBounds(cogText, 0, cogText.length(), textBounds);
canvas.drawText(cogText, centerX - textBounds.width() / 2f, startY + lineHeight * 2, cogPaint);
Paint accPaint = getAccuracyPaint();
sogPaint.setTextSize(bigValue);
cogPaint.setTextSize(bigValue);
accPaint.setTextSize(smallValue);
String[] latLon = coordinatesText.split(" ", 2);
String latLine = latLon.length > 0 ? latLon[0] : coordinatesText;
String lonLine = latLon.length > 1 ? latLon[1] : "";
float lineGap = dp(2);
float lineH = smallValue + lineGap;
// Считаем общую высоту блока для вертикального центрирования.
float totalH = smallLabel + lineH + lineH // POSITION + 2 строки
+ dp(6)
+ smallLabel + bigValue + lineGap // SOG/COG label+value
+ dp(4)
+ smallLabel + smallValue; // ACC
float y = centerY - totalH / 2f + smallLabel;
drawCentered(canvas, "POSITION", centerX, y, labelPaint);
y += lineH;
drawCentered(canvas, latLine, centerX, y, posPaint);
y += lineH;
if (!lonLine.isEmpty()) {
drawCentered(canvas, lonLine, centerX, y, posPaint);
y += lineH;
}
y += dp(4);
// SOG / COG бок о бок.
float colCenterL = centerX - radius * 0.45f;
float colCenterR = centerX + radius * 0.45f;
drawCentered(canvas, "SOG", colCenterL, y, labelPaint);
drawCentered(canvas, "COG", colCenterR, y, labelPaint);
y += bigValue + lineGap;
drawCentered(canvas, sogText, colCenterL, y, sogPaint);
drawCentered(canvas, cogText, colCenterR, y, cogPaint);
y += dp(6);
drawCentered(canvas, "ACC", centerX, y, labelPaint);
y += smallValue + lineGap;
drawCentered(canvas, accuracyText, centerX, y, accPaint);
// Восстанавливаем типовые размеры для dock-режима.
labelPaint.setTextSize(dp(11));
textPaint.setTextSize(dp(16));
accentPaint.setTextSize(dp(16));
warningPaint.setTextSize(dp(16));
errorPaint.setTextSize(dp(16));
}
private void drawCentered(Canvas canvas, String text, float cx, float y, Paint p) {
if (text == null) return;
Rect b = new Rect();
p.getTextBounds(text, 0, text.length(), b);
canvas.drawText(text, cx - b.width() / 2f - b.left, y, p);
}
/**
@@ -256,11 +354,25 @@ public class CoordinatesDockWidget extends BaseDockWidget {
* Определяет цвет для отображения COG
*/
private Paint getCOGPaint() {
if (vessel == null || vessel.getCourse() <= 0) {
return errorPaint;
if (vessel == null) return errorPaint;
// Курс может быть 0 при движении чётко на север поэтому считаем
// валидным любой курс при наличии скорости, а также любой курс > 0.
if (vessel.getCourse() > 0 || vessel.getSpeed() > 0.05) {
return textPaint;
}
return accentPaint; // Если есть данные о курсе - зеленый
return errorPaint;
}
/**
* Определяет цвет для отображения точности (ACC, ±метры).
*/
private Paint getAccuracyPaint() {
if (vessel == null) return errorPaint;
float acc = vessel.getAccuracy();
if (acc <= 0f) return errorPaint;
if (acc <= 5f) return accentPaint;
if (acc <= 20f) return warningPaint;
return errorPaint;
}
/**
+1 -1
View File
@@ -16,7 +16,7 @@
<path
android:pathData="M95,8.1l-12.08,-6.7 -35.92,80.7L15,115.1s11.69,-0.03 28,0c0,0 1,-10 11,-10 9,0 10,10 10,10 9.54,0.02 15.06,0 15,0l-5,-29L95,8.1Z"
android:strokeWidth="2"
android:fillColor="#00ff00"
android:fillColor="#7BE435"
android:strokeColor="#000"/>
<path
android:pathData="M53.5,115.6m-10.5,0a10.5,10.5 0,1 1,21 0a10.5,10.5 0,1 1,-21 0"
@@ -0,0 +1,17 @@
<?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">
<!-- Satellite/positioning glyph: source from Android GPS -->
<path
android:fillColor="@android:color/white"
android:pathData="M12,2 C6.48,2 2,6.48 2,12 C2,17.52 6.48,22 12,22 C17.52,22 22,17.52 22,12 C22,6.48 17.52,2 12,2 Z M12,20 C7.58,20 4,16.42 4,12 C4,7.58 7.58,4 12,4 C16.42,4 20,7.58 20,12 C20,16.42 16.42,20 12,20 Z" />
<path
android:fillColor="@android:color/white"
android:pathData="M12,7 L13.41,8.41 L9.41,12.41 L11,14 L15,10 L16.41,11.41 L12,15.83 L7.59,11.41 Z" />
<path
android:fillColor="@android:color/white"
android:pathData="M11,1 L13,1 L13,3 L11,3 Z M11,21 L13,21 L13,23 L11,23 Z M1,11 L3,11 L3,13 L1,13 Z M21,11 L23,11 L23,13 L21,13 Z" />
</vector>
@@ -0,0 +1,11 @@
<?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">
<!-- Bluetooth glyph: source from ais_hub via BLE -->
<path
android:fillColor="@android:color/white"
android:pathData="M12,2 L12,22 L18,16 L8,8 M12,2 L18,8 L8,16 L12,22" />
</vector>
@@ -1,30 +1,117 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
android:viewportWidth="1024"
android:viewportHeight="1024">
<group android:scaleX="0.72527474"
android:scaleY="0.72527474"
android:translateX="130.34433"
android:translateY="140.65935">
<path
android:pathData="M99.2,86.8h850.4v850.4h-850.4z"
android:fillColor="#bcd542"/>
<path
android:pathData="M750.9,341.8l-6.2,12.3l7.3,1.4l-1.1,-13.8z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:pathData="M755.8,302.7c-2.2,8.2 -70.6,228.5 -72.1,233 -4.9,-1.2 -9.9,-2.5 -14.8,-3.7 -0.9,-0.2 -1.7,-0.4 -2.6,-0.7 -1.6,-0.4 -3.2,-0.8 -4.8,-1.2 -5.6,-1.5 -10.7,-1.8 -16.5,-1.8h-2.8c-12,0 -21.8,4.8 -32.1,10.5 -14.5,8 -29.4,12.9 -45.9,8.4 -11.9,-4.1 -18.7,-11.3 -25.3,-21.8 -1.9,-2.9 -3.9,-5.8 -5.9,-8.7 -0.7,-1 -1.4,-2 -2.1,-3.1 -14.7,-20.4 -36.9,-25.8 -59.8,-32.3 -8.4,-2.4 -23.6,-11.6 -24.2,-12 -13.3,-10.1 -19.4,-28.1 -25,-43.1 -10.8,-28.9 -27.3,-50.3 -55.1,-64.6 2.3,-2.5 30.3,-18.4 37.9,-25 0.7,-0.6 1.3,-1.1 2,-1.7 10.6,-9.6 16.7,-24.2 23.1,-36.7 9.5,-18.5 21.6,-34.2 41.9,-41.6 6.1,-1.7 12.2,-2.7 18.4,-3.7 19,-2.9 34.8,-9.5 48.6,-23.3 0.6,-0.6 1.1,-1.1 1.7,-1.7 5.7,-6.1 9.7,-13.8 13.3,-21.3 8.6,6.7 12.7,14.1 16.9,23.9 5.9,13.7 14.6,23.7 28.1,30.1 4.2,1.5 153.8,41.2 157.1,42.1Z"
android:fillColor="#d5eb5a"/>
<path
android:pathData="M648.7,255.6v2c-7.7,-1.4 -15.3,-3.2 -22.9,-5.2 -1.3,-0.3 -2.6,-0.7 -3.9,-1 -15.3,-3.8 -28,-8.8 -36.5,-22.6 -1.8,-3.3 -3.2,-6.7 -4.7,-10.2 -2.7,-6.7 -6.3,-11.5 -11.1,-16.9 -1.3,-1.6 -2.6,-3.2 -3.9,-4.8 -0.6,-0.7 -1.1,-1.4 -1.7,-2.1 -4,-5.8 -4.2,-11.3 -3.4,-18.1 1.2,-5.6 2.8,-11.1 4.3,-16.6 1,-3.7 1.9,-7.4 2.7,-11.2 1.2,-5.3 2.5,-10.5 3.9,-15.7 1.8,-6.5 3.3,-13.1 4.7,-19.7 0.8,-3.7 1.6,-6.3 2.3,-7.8h0.3c28.3,0 56.6,0 84.9,-0.1h115.4c38.6,-0.1 71.8,9.9 99.7,37.2 0.7,0.6 1.3,1.2 2,1.9 5,4.7 9.8,10.1 13,16.1 -0.7,5.5 -3,10.6 -5.1,15.8 -0.4,1 -0.8,2 -1.2,3.1 -3.4,8.6 -7.3,16.9 -11.7,25.1 -0.5,0.9 -1,1.9 -1.5,2.8 -3.7,6.5 -8.3,11.8 -13.5,17.2 -0.6,0.7 -1.2,1.3 -1.9,1.9 -9.4,9 -20.3,12.4 -32.7,14.7 -24.2,4.4 -43.4,16.2 -57.5,36.4 -2.1,3.3 -4.1,6.6 -6,10 -4.3,-0.4 -8.1,-0.9 -12.1,-2.4 -5.2,-1.8 -10.6,-3.1 -16,-4.3 -1,-0.2 -1.9,-0.4 -2.9,-0.7 -2.3,-0.5 -4.7,-1.1 -7,-1.6"
android:fillColor="#d5eb5a"/>
<path
android:pathData="M770.8,306.7c9.8,2.4 96.5,26 100.1,27.1 10,2.8 41.7,10.9 45.2,11.8 0.2,22.8 0,159.9 0,161.6 0.1,8.5 -0.3,14.2 -2,17 -1.7,1.8 -3.4,3.1 -5.5,4.5 -0.9,0.7 -1.7,1.5 -2.6,2.3 -2.8,2.3 -5.6,4.5 -8.4,6.7 -0.6,0.5 -1.1,0.9 -1.7,1.4 -13.8,11.1 -24.9,16.3 -33.3,15.6 -12.1,-2.1 -19.5,-13.7 -26.3,-22.8 -9.2,-12.2 -19.4,-20.8 -34.7,-24.2 -19.2,-2.3 -33.7,2.8 -49,14 -1.9,1.7 -3.6,3.4 -5.3,5.2 -9.2,9.5 -19.7,15.8 -33.1,16 -5.5,0 -10.3,-0.8 -15.6,-2.2 0.5,-7.4 69.3,-223.7 72,-233.9l0.2,-0.1Z"
android:fillColor="#d4ea59"/>
<path
android:pathData="M578.8,105.7c28.3,0 198.2,-0.1 200.3,-0.1 38.6,-0.1 71.8,9.9 99.7,37.2 0.7,0.6 1.3,1.2 2,1.9 5,4.7 9.8,10.1 13,16.1 -0.7,5.5 -3,10.6 -5.1,15.8 -0.4,1 -0.8,2 -1.2,3.1 -3.4,8.6 -7.3,16.9 -11.7,25.1 -0.5,0.9 -1,1.9 -1.5,2.8 -3.7,6.5 -8.3,11.8 -13.5,17.2 -0.6,0.7 -1.2,1.3 -1.9,1.9 -9.4,9 -20.3,12.4 -32.7,14.7 -24.2,4.4 -43.4,16.2 -57.5,36.4 -2.1,3.3 -4.1,6.6 -6,10 -4.2,-0.4 -8,-0.9 -11.9,-2.3 -5.3,-1.9 -117.5,-30.7 -125.1,-32.7 -1.3,-0.3 -31.9,-9.8 -40.4,-23.6 -1.8,-3.3 -3.2,-6.7 -4.7,-10.2 -2.7,-6.7 -6.3,-11.5 -11.1,-16.9 -1.3,-1.6 -5,-6.2 -5.6,-6.9 -4,-5.8 -4.2,-11.3 -3.4,-18.1 1.2,-5.6 17.2,-69.5 17.9,-71l0.4,-0.4Z"
android:fillColor="#d8ed5c"/>
<path
android:pathData="M338.8,105.7h227c-1.3,5.3 -5.2,20.9 -5.8,23.4 -2.1,8.4 -4.3,16.7 -6.7,25 -2.7,9.3 -5.2,18.7 -7.6,28.1 -5.7,21.5 -12.8,37.3 -32.5,49.8 -8.6,4.5 -17.8,6.9 -27.5,7.4 -14.5,1.5 -32.6,7.7 -42.9,18.4 -2.3,2.3 -3.9,3.7 -6.9,4.9 -6.3,-0.4 -132,-33.7 -133,-34 0.6,-5.4 34.9,-120 35.8,-122.8l0.1,-0.2Z"
android:fillColor="#d8ed5d"/>
<path
android:pathData="M107.7,366.7c0,-18.4 -0.2,-111.3 -0.2,-115.2 -0.1,-18.8 0.1,-36.8 6.4,-54.8h-0.1c12.3,2.6 46.6,11.4 51.7,12.8 1,0.3 84.3,21.8 91.4,23.4 8.4,1.9 16.7,4.3 24.9,6.8 -0.7,6.8 -45.7,157 -51,175 -12.6,-1.7 -25.4,-15 -32.9,-24.8 -0.5,-0.7 -1.1,-1.5 -1.7,-2.2 -1.9,-2.6 -4.1,-4.8 -6.5,-7 -0.6,-0.6 -1.3,-1.2 -2,-1.9 -10.3,-9.6 -22.7,-16.6 -37,-17.1 -1.1,0 -2.2,-0.1 -3.3,-0.2 -13.5,-0.3 -26.7,1.4 -39.7,5.2"
android:fillColor="#d6ec5b"/>
<path
android:pathData="M246,105.4c2.2,0 64.3,0 75.8,0.1 -0.6,2.6 -32.5,110.1 -34.9,117.9 -6,-0.6 -157.2,-39.2 -166.8,-41.9 4.9,-12.5 12.3,-23.9 21.7,-33.6 1.6,-1.6 3.1,-3.4 4.6,-5.3 3.1,-3.8 6.8,-6.6 10.7,-9.5 0.8,-0.6 1.5,-1.1 2.3,-1.7 12.1,-9 24.5,-16.2 38.9,-20.5 0.6,-0.2 1.3,-0.4 1.9,-0.6 15.5,-4.5 30.1,-5.2 46.1,-5.1l-0.3,0.2Z"
android:fillColor="#d8ee5d"/>
<path
android:pathData="M297.8,243.7c0.9,0.2 1.7,0.4 2.6,0.7 6.4,1.6 115.7,30.5 116.8,30.7 1.9,0.5 3.8,1.1 5.6,1.7 -0.9,6.4 -4.2,11.9 -7,17.6l-3,6c-9.9,19.9 -22.2,32.6 -42.5,41.8 -18,8.2 -33.8,22.3 -46.6,37.4 -1.8,2 -3.6,3.9 -5.4,5.8 -0.7,0.7 -7.1,7.1 -9.7,9.7l-8.2,8.2c-14.6,13.9 -34.7,14.9 -53.6,14.5h-1.9c1.8,-6.7 52.5,-171.4 53,-174.1h-0.1Z"
android:fillColor="#d6ec5b"/>
<path
android:pathData="M902.8,181.7h2c1.1,2.3 2.2,4.6 3.2,6.9 0.3,0.6 0.6,1.2 0.9,1.9 7.6,16.6 7.4,34.1 7.2,52 0,0.4 0,3 0,7.1 -0,18.8 0.1,68.9 0,80.2 -1.7,-0.3 -137,-36.6 -138.3,-37.9 10,-16.3 22.7,-29.7 41.9,-34.8 4.1,-0.9 8.3,-1.7 12.5,-2.5 19.7,-3.7 37.4,-13.1 49.4,-29.5 8.8,-13.6 15,-28.4 21.2,-43.2v-0.2Z"
android:fillColor="#d6ec5b"/>
<path
android:pathData="M458.8,263.7l2,1c-0.7,0.5 -1.3,1.1 -2,1.6 -6.7,5.5 -13,11.3 -19,17.4 0,-4.2 1.9,-5.7 4.6,-8.7 4.4,-4.2 9.4,-7.7 14.4,-11.3h0Z"
android:fillColor="#dbf15f"/>
<path
android:pathData="M553.8,206.7l2,1c-1.5,3.1 -3.2,6.1 -5,9 -0.3,-0.7 -0.7,-1.3 -1,-2 0.5,-1.3 1.2,-2.6 1.9,-4.1 0.7,-1.5 1.1,-2.2 1.2,-2.2 0.3,-0.6 0.6,-1.1 0.9,-1.7h0ZM548.8,216.7l1,2 -3,3c0.7,-1.6 1.3,-3.3 2,-5Z"
android:fillColor="#dcf160"/>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
android:pathData="M747.7,356.9c-0.3,1.6 -0.7,3.4 -1.1,5.4"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M743.9,372.7c-0.7,2.4 -1.5,4.9 -2.4,7.5 -5.5,15.7 -10.6,22.8 -15.5,34.2 -4.1,9.5 -6,21.1 -8.5,33.9"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M716.5,453.6c-0.1,0.5 -0.2,1 -0.3,1.5 -0.3,1.6 -0.6,3 -0.8,3.9"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:pathData="M531.8,588.6c7.4,5.6 12.6,13 14.5,22.2 0.2,2 0.3,3.9 0.2,5.8v1.5c-0.2,7.2 -2.4,13.2 -5.8,19.4 -0.6,1.1 -1.1,2.2 -1.7,3.3 -3.2,6 -7,11.6 -10.9,17.1 -1.1,1.5 -2.2,3.2 -3.2,4.7 -2.1,3 -4.1,6 -6.2,8.9 -0.2,0.3 -0.4,0.6 -0.6,0.9 -1,1.4 -2,2.7 -3.3,3.8 -1.8,-0.6 -2,-0.9 -3,-2.4 -0.3,-0.4 -0.6,-0.8 -0.8,-1.2 -0.3,-0.5 -0.6,-0.9 -0.9,-1.3 -0.7,-0.9 -1.3,-1.9 -2,-2.8 -0.4,-0.5 -0.7,-1 -1,-1.5 -1,-1.5 -2.1,-3 -3.2,-4.6 -16.1,-23 -23.4,-40.2 -21.8,-51.7 1.1,-6 3.6,-10.8 7.4,-15.6 0.3,-0.3 0.5,-0.7 0.7,-1 9.4,-12.3 29.2,-13.7 41.6,-5.7l-0.1,0.1Z"
android:fillColor="#fefdea"/>
<path
android:pathData="M522,603.6c3.4,2.2 5.8,5.4 7,9.3 0.9,4.7 -0.1,9 -2.5,13 -2.2,2.9 -5.5,5 -9.1,5.6 -5,0.6 -8.7,-0.3 -12.9,-3.1 -3.2,-2.6 -4.9,-6.3 -5.4,-10.4 -0.2,-5 1.1,-8.5 4.4,-12.2 5.5,-5.1 12,-5.5 18.5,-2.2h0Z"
android:fillColor="#bcd542"/>
<path
android:pathData="M219.5,612.1c-12,-6.1 -27.7,-7.1 -40.5,-3.8 -4.5,1.5 -8.7,3.6 -12.8,6.1 -5.1,3 -10.1,4 -16,3.8l-21,-42.9 -2.6,-10.8 137.6,-0.3c17,0 28.8,-3.5 42.2,-14.3 16,-12.7 34.9,-19 55.1,-20.7 0.8,0 19,-0.9 27.4,-0.9l-0.1,-0.1h17.6c0.6,0 1.9,0.6 3.9,1.7 -0.3,5 -6.2,14.6 -7,15.9 -0.4,0.6 -7.3,12.6 -10.3,18.1 -0.4,0.7 -18.3,36.7 -26,54.4 -4.5,0.2 -8,0 -12.1,-1.7 -0.9,-0.4 -7.1,-3.2 -9.3,-4.3 -12.4,-6.4 -27.8,-6.9 -41,-2.9 -5.5,1.8 -10.6,4.6 -15.7,7.5 -9.1,5.1 -19.5,5.7 -29.6,5.7h-2.8c-9.2,-0.1 -17.9,-1.7 -26.3,-5.4l-10.5,-5Z"
android:fillColor="#fefce9"/>
<path
android:pathData="M220.2,454.2h16.6v33.5c3.9,-0.1 43.1,0 44.2,0 7.3,-0.2 12.5,0.2 18.1,5.2 0.5,0.4 1,0.9 1.5,1.3 10.2,8.9 17,25.6 21.4,38.2 -0.9,0.3 -1.9,0.6 -2.8,0.9 -10.6,3.6 -19,9.6 -27.9,16.2 -9.7,7.1 -19.6,9.5 -31.5,9.4 -1,0 -83,-0.1 -96.2,-0.2v-14.2h20.5c0,-3.1 0.1,-35.7 0,-36.7 0,-5.7 0.3,-9 4.2,-13.3 4.7,-4.5 8.5,-6.6 15.1,-6.8 2,0 4.1,-0.1 6.2,0 3.6,0.1 7.3,-0.2 11,0v-33.3l-0.1,-0.2Z"
android:fillColor="#fdfce9"/>
<path
android:pathData="M214.8,622.5c4.2,1.8 14.2,5.6 15.1,5.9 19.4,7.3 42.4,7.4 61.7,-0.6 3.4,-1.6 6.7,-3.2 10,-5 10.9,-5.4 23.3,-6.6 34.9,-2.8 3.6,1.3 7.2,2.7 10.7,4.3 14,5.9 30.8,6.2 45,1.1 3.1,-1.3 11.8,-5.8 13.4,-6.3 0.6,5.3 0.3,9 -0.9,11.1 -9.7,9.1 -24,12.9 -37.1,12.8 -9.8,-0.3 -17.8,-3 -26.8,-6.9 -12.1,-5.1 -22.2,-6 -34.6,-1.1 -3.2,1.3 -6.4,2.8 -9.5,4.2 -4.8,2.1 -16.9,5.7 -17.9,6 -5.7,1.3 -18.6,1 -19.2,1 -9.7,0 -29.4,-3.8 -30,-4 -5.9,-1.8 -11.5,-4.1 -17.1,-6.8 -11.3,-5.4 -22.2,-4.7 -33.9,-0.6 -1.7,0.8 -3.5,1.6 -5.2,2.4 -12.7,6.1 -29.3,7.2 -42.7,2.5 -4.9,-1.9 -14.8,-8 -15.4,-8.2 -0.9,-0.6 -1.9,-1.6 -3.1,-3.1 -0.4,-3.2 -0.2,-6.2 0,-9.5 2.8,1.1 5.5,2.3 8.3,3.5 14.8,6.7 30.4,8.2 46.1,3.5 3.2,-1.3 6.2,-2.7 9.1,-4.3 12.3,-5.8 26.9,-4.7 39.1,0.6l0.2,0.2Z"
android:fillColor="#fdfce9"/>
<path
android:pathData="M241.3,511.2c1.5,2.3 1,13.6 0,15.7 -3.1,3 -24.5,1.2 -25.7,0 -1.7,-2.8 -0.6,-14.8 0,-15.7 2.2,-1.5 24.8,-0.7 25.7,0Z"
android:fillColor="#b1cc36"/>
<path
android:pathData="M285.4,511.2c1.5,2.3 1,13.6 0,15.7 -3.1,3 -24.5,1.2 -25.7,0 -1.7,-2.8 -0.6,-14.8 0,-15.7 2.2,-1.5 24.8,-0.7 25.7,0Z"
android:fillColor="#b1cc36"/>
<path
android:pathData="M431.2,700.7l22.7,175.9h-40.8l-4.7,-43.2h-22.2l-6.7,43.2h-34.6l29.9,-175.9h56.3ZM405.5,806.6l-7.2,-78.3h-0.5l-7.2,78.3h14.8Z"
android:fillColor="#fff"/>
<path
android:pathData="M491.5,876.5v-175.9h41v175.9h-41Z"
android:fillColor="#fff"/>
<path
android:pathData="M635.3,763.7v-24.7c0,-8.6 -2.7,-13.6 -10.9,-13.6 -8.9,0 -10.9,4.9 -10.9,13.6 0,29.6 62.7,38.3 62.7,92.6 0,33.1 -17.8,48.4 -52.1,48.4 -26.2,0 -51.6,-8.9 -51.6,-39.3v-32.6h41v30.4c0,10.4 3.2,13.3 10.9,13.3 6.7,0 10.9,-3 10.9,-13.3 0,-39.8 -62.7,-40.5 -62.7,-97.3 0,-31.9 21,-44 52.6,-44 27.7,0 51.1,9.4 51.1,38.8v27.7h-41Z"
android:fillColor="#fff"/>
<path
android:pathData="M418.4,308.7l-5,1.2 -4.7,6.8s2.7,1.8 2.7,1.8c0,0 3,2 3,2 1.6,-2.3 3.2,-4.6 4.7,-6.8l-0.6,-5.1Z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M410.6,320.6c-0.5,0.6 -1,1.3 -1.5,2"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M405.9,326.2c-2.1,2.2 -4.7,4.7 -7.8,7.1 -10,7.9 -15.4,7.7 -29.4,16.2 -12.5,7.6 -18.7,11.4 -20.1,18 -1.3,5.9 1.5,10.9 -2,18.4 -0.5,1.1 -1.1,2.2 -1.7,3.1"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M343.4,391c-0.6,0.7 -1.2,1.3 -1.7,1.8"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
</group>
</vector>
+112
View File
@@ -0,0 +1,112 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="1024dp"
android:height="1024dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M99.2,86.8h850.4v850.4h-850.4z"
android:fillColor="#bcd542"/>
<path
android:pathData="M750.9,341.8l-6.2,12.3l7.3,1.4l-1.1,-13.8z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:pathData="M755.8,302.7c-2.2,8.2 -70.6,228.5 -72.1,233 -4.9,-1.2 -9.9,-2.5 -14.8,-3.7 -0.9,-0.2 -1.7,-0.4 -2.6,-0.7 -1.6,-0.4 -3.2,-0.8 -4.8,-1.2 -5.6,-1.5 -10.7,-1.8 -16.5,-1.8h-2.8c-12,0 -21.8,4.8 -32.1,10.5 -14.5,8 -29.4,12.9 -45.9,8.4 -11.9,-4.1 -18.7,-11.3 -25.3,-21.8 -1.9,-2.9 -3.9,-5.8 -5.9,-8.7 -0.7,-1 -1.4,-2 -2.1,-3.1 -14.7,-20.4 -36.9,-25.8 -59.8,-32.3 -8.4,-2.4 -23.6,-11.6 -24.2,-12 -13.3,-10.1 -19.4,-28.1 -25,-43.1 -10.8,-28.9 -27.3,-50.3 -55.1,-64.6 2.3,-2.5 30.3,-18.4 37.9,-25 0.7,-0.6 1.3,-1.1 2,-1.7 10.6,-9.6 16.7,-24.2 23.1,-36.7 9.5,-18.5 21.6,-34.2 41.9,-41.6 6.1,-1.7 12.2,-2.7 18.4,-3.7 19,-2.9 34.8,-9.5 48.6,-23.3 0.6,-0.6 1.1,-1.1 1.7,-1.7 5.7,-6.1 9.7,-13.8 13.3,-21.3 8.6,6.7 12.7,14.1 16.9,23.9 5.9,13.7 14.6,23.7 28.1,30.1 4.2,1.5 153.8,41.2 157.1,42.1Z"
android:fillColor="#d5eb5a"/>
<path
android:pathData="M648.7,255.6v2c-7.7,-1.4 -15.3,-3.2 -22.9,-5.2 -1.3,-0.3 -2.6,-0.7 -3.9,-1 -15.3,-3.8 -28,-8.8 -36.5,-22.6 -1.8,-3.3 -3.2,-6.7 -4.7,-10.2 -2.7,-6.7 -6.3,-11.5 -11.1,-16.9 -1.3,-1.6 -2.6,-3.2 -3.9,-4.8 -0.6,-0.7 -1.1,-1.4 -1.7,-2.1 -4,-5.8 -4.2,-11.3 -3.4,-18.1 1.2,-5.6 2.8,-11.1 4.3,-16.6 1,-3.7 1.9,-7.4 2.7,-11.2 1.2,-5.3 2.5,-10.5 3.9,-15.7 1.8,-6.5 3.3,-13.1 4.7,-19.7 0.8,-3.7 1.6,-6.3 2.3,-7.8h0.3c28.3,0 56.6,0 84.9,-0.1h115.4c38.6,-0.1 71.8,9.9 99.7,37.2 0.7,0.6 1.3,1.2 2,1.9 5,4.7 9.8,10.1 13,16.1 -0.7,5.5 -3,10.6 -5.1,15.8 -0.4,1 -0.8,2 -1.2,3.1 -3.4,8.6 -7.3,16.9 -11.7,25.1 -0.5,0.9 -1,1.9 -1.5,2.8 -3.7,6.5 -8.3,11.8 -13.5,17.2 -0.6,0.7 -1.2,1.3 -1.9,1.9 -9.4,9 -20.3,12.4 -32.7,14.7 -24.2,4.4 -43.4,16.2 -57.5,36.4 -2.1,3.3 -4.1,6.6 -6,10 -4.3,-0.4 -8.1,-0.9 -12.1,-2.4 -5.2,-1.8 -10.6,-3.1 -16,-4.3 -1,-0.2 -1.9,-0.4 -2.9,-0.7 -2.3,-0.5 -4.7,-1.1 -7,-1.6"
android:fillColor="#d5eb5a"/>
<path
android:pathData="M770.8,306.7c9.8,2.4 96.5,26 100.1,27.1 10,2.8 41.7,10.9 45.2,11.8 0.2,22.8 0,159.9 0,161.6 0.1,8.5 -0.3,14.2 -2,17 -1.7,1.8 -3.4,3.1 -5.5,4.5 -0.9,0.7 -1.7,1.5 -2.6,2.3 -2.8,2.3 -5.6,4.5 -8.4,6.7 -0.6,0.5 -1.1,0.9 -1.7,1.4 -13.8,11.1 -24.9,16.3 -33.3,15.6 -12.1,-2.1 -19.5,-13.7 -26.3,-22.8 -9.2,-12.2 -19.4,-20.8 -34.7,-24.2 -19.2,-2.3 -33.7,2.8 -49,14 -1.9,1.7 -3.6,3.4 -5.3,5.2 -9.2,9.5 -19.7,15.8 -33.1,16 -5.5,0 -10.3,-0.8 -15.6,-2.2 0.5,-7.4 69.3,-223.7 72,-233.9l0.2,-0.1Z"
android:fillColor="#d4ea59"/>
<path
android:pathData="M578.8,105.7c28.3,0 198.2,-0.1 200.3,-0.1 38.6,-0.1 71.8,9.9 99.7,37.2 0.7,0.6 1.3,1.2 2,1.9 5,4.7 9.8,10.1 13,16.1 -0.7,5.5 -3,10.6 -5.1,15.8 -0.4,1 -0.8,2 -1.2,3.1 -3.4,8.6 -7.3,16.9 -11.7,25.1 -0.5,0.9 -1,1.9 -1.5,2.8 -3.7,6.5 -8.3,11.8 -13.5,17.2 -0.6,0.7 -1.2,1.3 -1.9,1.9 -9.4,9 -20.3,12.4 -32.7,14.7 -24.2,4.4 -43.4,16.2 -57.5,36.4 -2.1,3.3 -4.1,6.6 -6,10 -4.2,-0.4 -8,-0.9 -11.9,-2.3 -5.3,-1.9 -117.5,-30.7 -125.1,-32.7 -1.3,-0.3 -31.9,-9.8 -40.4,-23.6 -1.8,-3.3 -3.2,-6.7 -4.7,-10.2 -2.7,-6.7 -6.3,-11.5 -11.1,-16.9 -1.3,-1.6 -5,-6.2 -5.6,-6.9 -4,-5.8 -4.2,-11.3 -3.4,-18.1 1.2,-5.6 17.2,-69.5 17.9,-71l0.4,-0.4Z"
android:fillColor="#d8ed5c"/>
<path
android:pathData="M338.8,105.7h227c-1.3,5.3 -5.2,20.9 -5.8,23.4 -2.1,8.4 -4.3,16.7 -6.7,25 -2.7,9.3 -5.2,18.7 -7.6,28.1 -5.7,21.5 -12.8,37.3 -32.5,49.8 -8.6,4.5 -17.8,6.9 -27.5,7.4 -14.5,1.5 -32.6,7.7 -42.9,18.4 -2.3,2.3 -3.9,3.7 -6.9,4.9 -6.3,-0.4 -132,-33.7 -133,-34 0.6,-5.4 34.9,-120 35.8,-122.8l0.1,-0.2Z"
android:fillColor="#d8ed5d"/>
<path
android:pathData="M107.7,366.7c0,-18.4 -0.2,-111.3 -0.2,-115.2 -0.1,-18.8 0.1,-36.8 6.4,-54.8h-0.1c12.3,2.6 46.6,11.4 51.7,12.8 1,0.3 84.3,21.8 91.4,23.4 8.4,1.9 16.7,4.3 24.9,6.8 -0.7,6.8 -45.7,157 -51,175 -12.6,-1.7 -25.4,-15 -32.9,-24.8 -0.5,-0.7 -1.1,-1.5 -1.7,-2.2 -1.9,-2.6 -4.1,-4.8 -6.5,-7 -0.6,-0.6 -1.3,-1.2 -2,-1.9 -10.3,-9.6 -22.7,-16.6 -37,-17.1 -1.1,0 -2.2,-0.1 -3.3,-0.2 -13.5,-0.3 -26.7,1.4 -39.7,5.2"
android:fillColor="#d6ec5b"/>
<path
android:pathData="M246,105.4c2.2,0 64.3,0 75.8,0.1 -0.6,2.6 -32.5,110.1 -34.9,117.9 -6,-0.6 -157.2,-39.2 -166.8,-41.9 4.9,-12.5 12.3,-23.9 21.7,-33.6 1.6,-1.6 3.1,-3.4 4.6,-5.3 3.1,-3.8 6.8,-6.6 10.7,-9.5 0.8,-0.6 1.5,-1.1 2.3,-1.7 12.1,-9 24.5,-16.2 38.9,-20.5 0.6,-0.2 1.3,-0.4 1.9,-0.6 15.5,-4.5 30.1,-5.2 46.1,-5.1l-0.3,0.2Z"
android:fillColor="#d8ee5d"/>
<path
android:pathData="M297.8,243.7c0.9,0.2 1.7,0.4 2.6,0.7 6.4,1.6 115.7,30.5 116.8,30.7 1.9,0.5 3.8,1.1 5.6,1.7 -0.9,6.4 -4.2,11.9 -7,17.6l-3,6c-9.9,19.9 -22.2,32.6 -42.5,41.8 -18,8.2 -33.8,22.3 -46.6,37.4 -1.8,2 -3.6,3.9 -5.4,5.8 -0.7,0.7 -7.1,7.1 -9.7,9.7l-8.2,8.2c-14.6,13.9 -34.7,14.9 -53.6,14.5h-1.9c1.8,-6.7 52.5,-171.4 53,-174.1h-0.1Z"
android:fillColor="#d6ec5b"/>
<path
android:pathData="M902.8,181.7h2c1.1,2.3 2.2,4.6 3.2,6.9 0.3,0.6 0.6,1.2 0.9,1.9 7.6,16.6 7.4,34.1 7.2,52 0,0.4 0,3 0,7.1 -0,18.8 0.1,68.9 0,80.2 -1.7,-0.3 -137,-36.6 -138.3,-37.9 10,-16.3 22.7,-29.7 41.9,-34.8 4.1,-0.9 8.3,-1.7 12.5,-2.5 19.7,-3.7 37.4,-13.1 49.4,-29.5 8.8,-13.6 15,-28.4 21.2,-43.2v-0.2Z"
android:fillColor="#d6ec5b"/>
<path
android:pathData="M458.8,263.7l2,1c-0.7,0.5 -1.3,1.1 -2,1.6 -6.7,5.5 -13,11.3 -19,17.4 0,-4.2 1.9,-5.7 4.6,-8.7 4.4,-4.2 9.4,-7.7 14.4,-11.3h0Z"
android:fillColor="#dbf15f"/>
<path
android:pathData="M553.8,206.7l2,1c-1.5,3.1 -3.2,6.1 -5,9 -0.3,-0.7 -0.7,-1.3 -1,-2 0.5,-1.3 1.2,-2.6 1.9,-4.1 0.7,-1.5 1.1,-2.2 1.2,-2.2 0.3,-0.6 0.6,-1.1 0.9,-1.7h0ZM548.8,216.7l1,2 -3,3c0.7,-1.6 1.3,-3.3 2,-5Z"
android:fillColor="#dcf160"/>
<path
android:strokeWidth="1"
android:pathData="M747.7,356.9c-0.3,1.6 -0.7,3.4 -1.1,5.4"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M743.9,372.7c-0.7,2.4 -1.5,4.9 -2.4,7.5 -5.5,15.7 -10.6,22.8 -15.5,34.2 -4.1,9.5 -6,21.1 -8.5,33.9"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M716.5,453.6c-0.1,0.5 -0.2,1 -0.3,1.5 -0.3,1.6 -0.6,3 -0.8,3.9"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:pathData="M531.8,588.6c7.4,5.6 12.6,13 14.5,22.2 0.2,2 0.3,3.9 0.2,5.8v1.5c-0.2,7.2 -2.4,13.2 -5.8,19.4 -0.6,1.1 -1.1,2.2 -1.7,3.3 -3.2,6 -7,11.6 -10.9,17.1 -1.1,1.5 -2.2,3.2 -3.2,4.7 -2.1,3 -4.1,6 -6.2,8.9 -0.2,0.3 -0.4,0.6 -0.6,0.9 -1,1.4 -2,2.7 -3.3,3.8 -1.8,-0.6 -2,-0.9 -3,-2.4 -0.3,-0.4 -0.6,-0.8 -0.8,-1.2 -0.3,-0.5 -0.6,-0.9 -0.9,-1.3 -0.7,-0.9 -1.3,-1.9 -2,-2.8 -0.4,-0.5 -0.7,-1 -1,-1.5 -1,-1.5 -2.1,-3 -3.2,-4.6 -16.1,-23 -23.4,-40.2 -21.8,-51.7 1.1,-6 3.6,-10.8 7.4,-15.6 0.3,-0.3 0.5,-0.7 0.7,-1 9.4,-12.3 29.2,-13.7 41.6,-5.7l-0.1,0.1Z"
android:fillColor="#fefdea"/>
<path
android:pathData="M522,603.6c3.4,2.2 5.8,5.4 7,9.3 0.9,4.7 -0.1,9 -2.5,13 -2.2,2.9 -5.5,5 -9.1,5.6 -5,0.6 -8.7,-0.3 -12.9,-3.1 -3.2,-2.6 -4.9,-6.3 -5.4,-10.4 -0.2,-5 1.1,-8.5 4.4,-12.2 5.5,-5.1 12,-5.5 18.5,-2.2h0Z"
android:fillColor="#bcd542"/>
<path
android:pathData="M219.5,612.1c-12,-6.1 -27.7,-7.1 -40.5,-3.8 -4.5,1.5 -8.7,3.6 -12.8,6.1 -5.1,3 -10.1,4 -16,3.8l-21,-42.9 -2.6,-10.8 137.6,-0.3c17,0 28.8,-3.5 42.2,-14.3 16,-12.7 34.9,-19 55.1,-20.7 0.8,0 19,-0.9 27.4,-0.9l-0.1,-0.1h17.6c0.6,0 1.9,0.6 3.9,1.7 -0.3,5 -6.2,14.6 -7,15.9 -0.4,0.6 -7.3,12.6 -10.3,18.1 -0.4,0.7 -18.3,36.7 -26,54.4 -4.5,0.2 -8,0 -12.1,-1.7 -0.9,-0.4 -7.1,-3.2 -9.3,-4.3 -12.4,-6.4 -27.8,-6.9 -41,-2.9 -5.5,1.8 -10.6,4.6 -15.7,7.5 -9.1,5.1 -19.5,5.7 -29.6,5.7h-2.8c-9.2,-0.1 -17.9,-1.7 -26.3,-5.4l-10.5,-5Z"
android:fillColor="#fefce9"/>
<path
android:pathData="M220.2,454.2h16.6v33.5c3.9,-0.1 43.1,0 44.2,0 7.3,-0.2 12.5,0.2 18.1,5.2 0.5,0.4 1,0.9 1.5,1.3 10.2,8.9 17,25.6 21.4,38.2 -0.9,0.3 -1.9,0.6 -2.8,0.9 -10.6,3.6 -19,9.6 -27.9,16.2 -9.7,7.1 -19.6,9.5 -31.5,9.4 -1,0 -83,-0.1 -96.2,-0.2v-14.2h20.5c0,-3.1 0.1,-35.7 0,-36.7 0,-5.7 0.3,-9 4.2,-13.3 4.7,-4.5 8.5,-6.6 15.1,-6.8 2,0 4.1,-0.1 6.2,0 3.6,0.1 7.3,-0.2 11,0v-33.3l-0.1,-0.2Z"
android:fillColor="#fdfce9"/>
<path
android:pathData="M214.8,622.5c4.2,1.8 14.2,5.6 15.1,5.9 19.4,7.3 42.4,7.4 61.7,-0.6 3.4,-1.6 6.7,-3.2 10,-5 10.9,-5.4 23.3,-6.6 34.9,-2.8 3.6,1.3 7.2,2.7 10.7,4.3 14,5.9 30.8,6.2 45,1.1 3.1,-1.3 11.8,-5.8 13.4,-6.3 0.6,5.3 0.3,9 -0.9,11.1 -9.7,9.1 -24,12.9 -37.1,12.8 -9.8,-0.3 -17.8,-3 -26.8,-6.9 -12.1,-5.1 -22.2,-6 -34.6,-1.1 -3.2,1.3 -6.4,2.8 -9.5,4.2 -4.8,2.1 -16.9,5.7 -17.9,6 -5.7,1.3 -18.6,1 -19.2,1 -9.7,0 -29.4,-3.8 -30,-4 -5.9,-1.8 -11.5,-4.1 -17.1,-6.8 -11.3,-5.4 -22.2,-4.7 -33.9,-0.6 -1.7,0.8 -3.5,1.6 -5.2,2.4 -12.7,6.1 -29.3,7.2 -42.7,2.5 -4.9,-1.9 -14.8,-8 -15.4,-8.2 -0.9,-0.6 -1.9,-1.6 -3.1,-3.1 -0.4,-3.2 -0.2,-6.2 0,-9.5 2.8,1.1 5.5,2.3 8.3,3.5 14.8,6.7 30.4,8.2 46.1,3.5 3.2,-1.3 6.2,-2.7 9.1,-4.3 12.3,-5.8 26.9,-4.7 39.1,0.6l0.2,0.2Z"
android:fillColor="#fdfce9"/>
<path
android:pathData="M241.3,511.2c1.5,2.3 1,13.6 0,15.7 -3.1,3 -24.5,1.2 -25.7,0 -1.7,-2.8 -0.6,-14.8 0,-15.7 2.2,-1.5 24.8,-0.7 25.7,0Z"
android:fillColor="#b1cc36"/>
<path
android:pathData="M285.4,511.2c1.5,2.3 1,13.6 0,15.7 -3.1,3 -24.5,1.2 -25.7,0 -1.7,-2.8 -0.6,-14.8 0,-15.7 2.2,-1.5 24.8,-0.7 25.7,0Z"
android:fillColor="#b1cc36"/>
<path
android:pathData="M431.2,700.7l22.7,175.9h-40.8l-4.7,-43.2h-22.2l-6.7,43.2h-34.6l29.9,-175.9h56.3ZM405.5,806.6l-7.2,-78.3h-0.5l-7.2,78.3h14.8Z"
android:fillColor="#fff"/>
<path
android:pathData="M491.5,876.5v-175.9h41v175.9h-41Z"
android:fillColor="#fff"/>
<path
android:pathData="M635.3,763.7v-24.7c0,-8.6 -2.7,-13.6 -10.9,-13.6 -8.9,0 -10.9,4.9 -10.9,13.6 0,29.6 62.7,38.3 62.7,92.6 0,33.1 -17.8,48.4 -52.1,48.4 -26.2,0 -51.6,-8.9 -51.6,-39.3v-32.6h41v30.4c0,10.4 3.2,13.3 10.9,13.3 6.7,0 10.9,-3 10.9,-13.3 0,-39.8 -62.7,-40.5 -62.7,-97.3 0,-31.9 21,-44 52.6,-44 27.7,0 51.1,9.4 51.1,38.8v27.7h-41Z"
android:fillColor="#fff"/>
<path
android:pathData="M418.4,308.7l-5,1.2 -4.7,6.8s2.7,1.8 2.7,1.8c0,0 3,2 3,2 1.6,-2.3 3.2,-4.6 4.7,-6.8l-0.6,-5.1Z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M410.6,320.6c-0.5,0.6 -1,1.3 -1.5,2"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M405.9,326.2c-2.1,2.2 -4.7,4.7 -7.8,7.1 -10,7.9 -15.4,7.7 -29.4,16.2 -12.5,7.6 -18.7,11.4 -20.1,18 -1.3,5.9 1.5,10.9 -2,18.4 -0.5,1.1 -1.1,2.2 -1.7,3.1"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M343.4,391c-0.6,0.7 -1.2,1.3 -1.7,1.8"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
</vector>
@@ -0,0 +1,237 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔌 Интерфейсы: UDP и BLE"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:gravity="center"
android:layout_marginBottom="24dp" />
<!-- UDP -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📡 UDP"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="12dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="UDP Порт"
app:helperText="Порт для прослушивания AIS данных">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_udp_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:text="10110" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_udp_enabled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Включить UDP слушатель"
android:textSize="16sp"
android:checked="true" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- BLE -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📶 BLE"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="12dp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_ble_enabled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Включить BLE источник NMEA"
android:textSize="16sp"
android:layout_marginBottom="8dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="MAC адрес BLE устройства"
app:helperText="Например: 01:23:45:67:89:AB">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_ble_mac"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:text="" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/btn_ble_scan"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Сканировать BLE"
style="@style/Widget.Material3.Button" />
<Button
android:id="@+id/btn_ble_stop_scan"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Стоп"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_ble_devices"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_marginTop="8dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- BLE UDP Bridge -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔁 BLE UDP Bridge"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="12dp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_ble_udp_bridge_enabled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Включить UDP-bridge (пересылать NMEA)"
android:textSize="16sp"
android:layout_marginBottom="8dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="UDP Host (назначение)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_ble_udp_host"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:text="255.255.255.255" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="UDP Port (назначение)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_ble_udp_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:text="10110" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="end">
<Button
android:id="@+id/btn_cancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Отмена"
android:layout_marginEnd="8dp"
style="@style/Widget.Material3.Button.OutlinedButton" />
<Button
android:id="@+id/btn_save"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Сохранить"
style="@style/Widget.Material3.Button" />
</LinearLayout>
</LinearLayout>
</ScrollView>
+65 -26
View File
@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="false"
tools:context=".MainActivity">
<!-- Карта -->
@@ -11,20 +13,41 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- Панель управления -->
<!-- android:layout_below="@id/compass_view"-->
<!-- Компас -->
<com.grigowashere.aismap.view.CompassView
android:id="@+id/compass_view"
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_alignParentTop="true"
android:layout_marginLeft="0dp"
android:layout_marginTop="0dp"
android:layout_marginRight="0dp"
android:layout_marginBottom="0dp" />
<!-- Виджет координат: нижний inset задаётся в MainActivity (system bar) -->
<com.grigowashere.aismap.view.CoordinatesDockWidget
android:id="@+id/coordinates_widget"
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_alignParentBottom="true"
android:layout_marginLeft="0dp"
android:layout_marginTop="0dp"
android:layout_marginRight="0dp"
android:layout_marginBottom="0dp"
android:elevation="2dp" />
<!-- Панель управления (после координат в Z-order — не перекрывается снизу) -->
<LinearLayout
android:id="@+id/control_panel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_marginEnd="8dp"
android:background="@android:color/transparent"
android:orientation="vertical"
android:padding="8dp"
android:gravity="end"
android:elevation="4dp">
android:elevation="10dp">
<ImageButton
android:id="@+id/btn_center_vessel"
@@ -59,6 +82,17 @@
android:scaleType="fitCenter"
android:layout_marginBottom="8dp" />
<ImageButton
android:id="@+id/btn_gps_source"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/button_background"
android:src="@drawable/ic_gps_source_hub"
android:contentDescription="Источник координат"
android:padding="8dp"
android:scaleType="fitCenter"
android:layout_marginBottom="8dp" />
<ImageButton
android:id="@+id/btn_settings"
android:layout_width="40dp"
@@ -100,30 +134,35 @@
android:textColor="@android:color/white"
android:layout_marginTop="4dp"/>
<TextView
android:id="@+id/tv_ble_rssi"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="BLE RSSI: --"
android:textSize="11sp"
android:textColor="@android:color/white"
android:layout_marginTop="4dp"/>
<TextView
android:id="@+id/tv_ble_batt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="BLE Batt: --"
android:textSize="11sp"
android:textColor="@android:color/white"
android:layout_marginTop="2dp"/>
<TextView
android:id="@+id/tv_fps"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="FPS: --"
android:textSize="11sp"
android:textColor="@android:color/white"
android:layout_marginTop="4dp"/>
</LinearLayout>
<!-- Компас -->
<com.grigowashere.aismap.view.CompassView
android:id="@+id/compass_view"
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_alignParentTop="true"
android:layout_marginLeft="0dp"
android:layout_marginTop="0dp"
android:layout_marginRight="0dp"
android:layout_marginBottom="0dp" />
<!-- Виджет координат -->
<com.grigowashere.aismap.view.CoordinatesDockWidget
android:id="@+id/coordinates_widget"
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_alignParentBottom="true"
android:layout_marginLeft="0dp"
android:layout_marginTop="0dp"
android:layout_marginRight="0dp"
android:layout_marginBottom="0dp" />
<!-- Простая информационная панель
<LinearLayout
android:layout_width="wrap_content"
+162 -17
View File
@@ -21,7 +21,7 @@
android:gravity="center"
android:layout_marginBottom="24dp" />
<!-- UDP Настройки -->
<!-- Интерфейсы (UDP/BLE) -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -38,36 +38,36 @@
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📡 UDP Настройки"
android:text="🔌 Интерфейсы"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="12dp" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/til_open_interfaces"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="UDP Порт"
app:helperText="Порт для прослушивания AIS данных">
android:hint="Интерфейсы (UDP / BLE)"
app:helperText="Перейти к настройкам UDP, BLE и UDP-bridge"
app:endIconMode="custom"
app:endIconContentDescription="Открыть">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_udp_port"
android:id="@+id/et_open_interfaces"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:text="10110" />
android:focusable="false"
android:focusableInTouchMode="false"
android:clickable="true"
android:cursorVisible="false"
android:inputType="none"
android:text="Открыть настройки интерфейсов (UDP / BLE)" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_udp_enabled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Включить UDP слушатель"
android:textSize="16sp"
android:checked="true" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
@@ -209,7 +209,7 @@
</com.google.android.material.card.MaterialCardView>
<!-- Приоритеты данных -->
<!-- Источник координат (GPS Source) -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -226,12 +226,103 @@
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📊 Приоритеты данных"
android:text="📡 Источник координат"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Откуда приложение берёт позицию собственного судна."
android:textSize="13sp"
android:textColor="@android:color/darker_gray"
android:layout_marginBottom="8dp" />
<RadioGroup
android:id="@+id/radio_group_gps_source"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RadioButton
android:id="@+id/radio_gps_source_hub"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="AIS Hub (BLE)"
android:textSize="14sp"
android:checked="true" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Позиция и AIS-цели приходят из внешнего AIS Hub по BLE."
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:layout_marginBottom="8dp"
android:layout_marginStart="16dp" />
<RadioButton
android:id="@+id/radio_gps_source_android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Android GPS"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Встроенный GPS устройства (+опциональный внешний NMEA)."
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:layout_marginBottom="4dp"
android:layout_marginStart="16dp" />
</RadioGroup>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Расширенные: NMEA/UDP источники (скрыты по умолчанию) -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_show_advanced_nmea"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📊 Расширенные NMEA-источники"
android:textSize="16sp"
android:checked="false" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Открыть старые настройки Android NMEA / UDP NMEA / режимы данных. Нужны, только если вы работаете без AIS Hub."
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:layout_marginStart="16dp"
android:layout_marginBottom="12dp" />
<LinearLayout
android:id="@+id/ll_advanced_nmea_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -342,6 +433,8 @@
</RadioGroup>
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
@@ -589,6 +682,58 @@
</com.google.android.material.card.MaterialCardView>
<!-- Морские знаки -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⚓ Морские знаки OpenSeaMap"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Отображать морские знаки (буи, маяки, навигационные знаки) поверх карты."
android:textSize="14sp"
android:textColor="@android:color/darker_gray"
android:layout_marginBottom="16dp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_seamarks_enabled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Показывать морские знаки"
android:textSize="16sp"
android:checked="false"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="💡 Источник: OpenSeaMap.org - открытая база данных морских знаков"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:layout_marginTop="4dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Кнопки -->
<LinearLayout
android:layout_width="match_parent"
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@android:id/text1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textSize="16sp"
android:text="Device name" />
<TextView
android:id="@android:id/text2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="@android:color/darker_gray"
android:text="MAC" />
<TextView
android:id="@+id/text3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:text="RSSI: -60" />
</LinearLayout>
+6
View File
@@ -38,4 +38,10 @@
android:icon="@android:drawable/ic_menu_view"
app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_seamarks"
android:title="Морские знаки"
android:icon="@android:drawable/ic_menu_mapmode"
app:showAsAction="ifRoom" />
</menu>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#BBD341</color>
</resources>
+12
View File
@@ -6,4 +6,16 @@
</style>
<style name="Theme.AISMap" parent="Base.Theme.AISMap" />
<!-- Главный экран: edge-to-edge + рисуем под брови камеры. Паддинги
для компаса, координатного виджета и панели кнопок задаёт
MainActivity через WindowInsets-листенер. -->
<style name="Theme.AISMap.Map" parent="Theme.AISMap">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowTranslucentStatus" tools:targetApi="19">false</item>
<item name="android:windowTranslucentNavigation" tools:targetApi="19">false</item>
<item name="android:windowDrawsSystemBarBackgrounds" tools:targetApi="21">true</item>
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="27">shortEdges</item>
</style>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">t1.openseamap.org</domain>
<domain includeSubdomains="true">tiles.openseamap.org</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,108 @@
package com.grigowashere.aismap.controllers;
import android.content.Context;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import com.grigowashere.aismap.models.AISVessel;
import com.grigowashere.aismap.models.Vessel;
import com.grigowashere.aismap.utils.SettingsManager;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.*;
//import static org.mockito.Mockito.*;
/**
* Тест для проверки исправления ConcurrentModificationException в AppCoordinator
*/
//@RunWith(MockitoJUnitRunner.class)
//public class AppCoordinatorConcurrencyTest {
//
// @Mock
// private Context mockContext;
//
// @Mock
// private SettingsManager mockSettingsManager;
//
// private AppCoordinator appCoordinator;
//
// @Before
// public void setUp() {
// when(mockContext.getApplicationContext()).thenReturn(mockContext);
// appCoordinator = new AppCoordinator(mockContext);
// }
//
// @Test
// public void testGetNearbyVesselsConcurrency() throws InterruptedException {
// // Создаем несколько AIS судов для тестирования
// AISVessel vessel1 = new AISVessel();
// vessel1.setMmsi("123456789");
// vessel1.setLatitude(55.7558);
// vessel1.setLongitude(37.6176);
//
// AISVessel vessel2 = new AISVessel();
// vessel2.setMmsi("987654321");
// vessel2.setLatitude(55.7559);
// vessel2.setLongitude(37.6177);
//
// // Добавляем суда
// appCoordinator.onAISVesselChanged(vessel1);
// appCoordinator.onAISVesselChanged(vessel2);
//
// // Устанавливаем собственное судно
// Vessel ownVessel = new Vessel();
// ownVessel.setLatitude(55.7558);
// ownVessel.setLongitude(37.6176);
// appCoordinator.onOwnVesselChanged(ownVessel);
//
// int threadCount = 10;
// int iterationsPerThread = 100;
// CountDownLatch latch = new CountDownLatch(threadCount);
// ExecutorService executor = Executors.newFixedThreadPool(threadCount);
//
// // Запускаем несколько потоков, которые одновременно вызывают getNearbyVessels
// // и модифицируют коллекцию aisVessels
// for (int i = 0; i < threadCount; i++) {
// final int threadId = i;
// executor.submit(() -> {
// try {
// for (int j = 0; j < iterationsPerThread; j++) {
// // Вызываем getNearbyVessels через onCompassChanged
// appCoordinator.onCompassChanged(0.0f);
//
// // Добавляем новое судно
// AISVessel newVessel = new AISVessel();
// newVessel.setMmsi("thread" + threadId + "_vessel" + j);
// newVessel.setLatitude(55.7558 + (j * 0.001));
// newVessel.setLongitude(37.6176 + (j * 0.001));
// appCoordinator.onAISVesselChanged(newVessel);
//
// // Небольшая задержка для увеличения вероятности race condition
// Thread.sleep(1);
// }
// } catch (Exception e) {
// fail("ConcurrentModificationException не должна возникать: " + e.getMessage());
// } finally {
// latch.countDown();
// }
// });
// }
//
// // Ждем завершения всех потоков
// boolean finished = latch.await(30, TimeUnit.SECONDS);
// executor.shutdown();
//
// assertTrue("Тест не завершился в течение 30 секунд", finished);
//
// // Проверяем, что метод getAISVessels работает корректно
// List<AISVessel> vessels = appCoordinator.getAISVessels();
// assertNotNull("Список судов не должен быть null", vessels);
// assertTrue("Должно быть добавлено несколько судов", vessels.size() > 0);
// }
//}
@@ -0,0 +1,94 @@
package com.grigowashere.aismap.controllers;
import org.junit.Test;
import org.junit.Before;
import static org.junit.Assert.*;
///**
// * Тест для проверки логирования ошибок в NMEAParser
// */
//public class NMEAParserErrorLoggingTest {
//
// private NMEAParser parser;
// private TestNMEAParserListener listener;
//
// @Before
// public void setUp() {
// parser = new NMEAParser();
// listener = new TestNMEAParserListener();
// parser.setListener(listener);
// }
//
// @Test
// public void testParseInvalidNMEA() {
// // Тестируем парсинг некорректного NMEA сообщения
// parser.parseNMEA("$INVALID");
// // Проверяем, что ошибка была залогирована (через LogSender)
// // В реальном тесте мы бы проверили, что LogSender.logDroppedNMEA был вызван
// assertTrue("Парсинг некорректного NMEA должен завершиться без исключений", true);
// }
//
// @Test
// public void testParseTooShortNMEA() {
// // Тестируем парсинг слишком короткого NMEA сообщения
// parser.parseNMEA("$GP");
// assertTrue("Парсинг слишком короткого NMEA должен завершиться без исключений", true);
// }
//
// @Test
// public void testParseEmptyNMEA() {
// // Тестируем парсинг пустого NMEA сообщения
// parser.parseNMEA("");
// parser.parseNMEA(null);
// assertTrue("Парсинг пустого NMEA должен завершиться без исключений", true);
// }
//
// @Test
// public void testParseInvalidAIS() {
// // Тестируем парсинг некорректного AIS сообщения
// parser.parseNMEA("!AIVDM,1,1,,A,*AB"); // Слишком короткое
// assertTrue("Парсинг некорректного AIS должен завершиться без исключений", true);
// }
//
// @Test
// public void testParseAISWithInvalidChecksum() {
// // Тестируем парсинг AIS с неверной контрольной суммой
// parser.parseNMEA("!AIVDM,1,1,,A,1234567890ABCDEF,*ZZ"); // Неверная контрольная сумма
// assertTrue("Парсинг AIS с неверной контрольной суммой должен завершиться без исключений", true);
// }
//
// @Test
// public void testParseUnsupportedMessageType() {
// // Тестируем парсинг неподдерживаемого типа сообщения
// parser.parseNMEA("$GPXXX,123,456,789,*AB");
// assertTrue("Парсинг неподдерживаемого типа должен завершиться без исключений", true);
// }
//
// // Тестовый слушатель для проверки вызовов
// private static class TestNMEAParserListener implements NMEAParser.NMEAParserListener {
// public boolean onVesselUpdatedCalled = false;
// public boolean onAISVesselUpdatedCalled = false;
// public boolean onParseErrorCalled = false;
// public boolean onDOPUpdatedCalled = false;
//
// @Override
// public void onVesselUpdated(com.grigowashere.aismap.models.Vessel vessel) {
// onVesselUpdatedCalled = true;
// }
//
// @Override
// public void onAISVesselUpdated(com.grigowashere.aismap.models.AISVessel vessel) {
// onAISVesselUpdatedCalled = true;
// }
//
// @Override
// public void onParseError(String error) {
// onParseErrorCalled = true;
// }
//
// @Override
// public void onDOPUpdated(double pdop, double hdop, double vdop) {
// onDOPUpdatedCalled = true;
// }
// }
//}
@@ -0,0 +1,60 @@
package com.grigowashere.aismap.utils;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* Тест для проверки новых методов логирования с полным NMEA и BLE кусками
*/
public class LogSenderExtendedTest {
@Test
public void testAISParseErrorWithFullNMEA() {
// Тестируем новый метод с полным NMEA сообщением
try {
String fullNMEA = "!AIVDM,1,1,,A,D02VqQ1K`Nfq@AN>56DK6E@UK6E1H0,*AB";
String aisPayload = "D02VqQ1K`Nfq@AN>56DK6E@UK6E1H0";
LogSender.logAISParseErrorWithFullNMEA("Неподдерживаемый тип", fullNMEA, aisPayload, "Тип: 20");
assertTrue("logAISParseErrorWithFullNMEA должен работать без исключений", true);
} catch (Exception e) {
fail("logAISParseErrorWithFullNMEA должен работать без исключений: " + e.getMessage());
}
}
@Test
public void testBLEDataChunkLogging() {
// Тестируем логирование BLE кусков
try {
String deviceMac = "AA:BB:CC:DD:EE:FF";
String dataChunk = "$GPGGA,123456,1234.5678,N,12345.6789,E,1,8,1.2,123.4,M,45.6,M,,*AB\r\n";
LogSender.logBLEDataChunk(deviceMac, dataChunk);
assertTrue("logBLEDataChunk должен работать без исключений", true);
} catch (Exception e) {
fail("logBLEDataChunk должен работать без исключений: " + e.getMessage());
}
}
@Test
public void testBLEDataChunkWithNullValues() {
// Тестируем обработку null значений
try {
LogSender.logBLEDataChunk(null, null);
assertTrue("logBLEDataChunk должен обрабатывать null значения", true);
} catch (Exception e) {
fail("logBLEDataChunk должен обрабатывать null значения: " + e.getMessage());
}
}
@Test
public void testAISParseErrorWithFullNMEANullValues() {
// Тестируем обработку null значений в новом методе
try {
LogSender.logAISParseErrorWithFullNMEA("Тест", null, null, "Детали");
assertTrue("logAISParseErrorWithFullNMEA должен обрабатывать null значения", true);
} catch (Exception e) {
fail("logAISParseErrorWithFullNMEA должен обрабатывать null значения: " + e.getMessage());
}
}
}
@@ -0,0 +1,69 @@
package com.grigowashere.aismap.utils;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* Тест для проверки логирования ошибок через LogSender
*/
public class LogSenderTest {
@Test
public void testLogError() {
// Тестируем, что метод logError не падает с исключениями
try {
LogSender.logError("TEST_ERROR", "Тестовое сообщение", "Тестовые детали");
// Если метод выполнился без исключений, тест прошел
assertTrue(true);
} catch (Exception e) {
fail("logError должен работать без исключений: " + e.getMessage());
}
}
@Test
public void testLogDroppedNMEA() {
// Тестируем логирование отброшенных NMEA сообщений
try {
LogSender.logDroppedNMEA("Тестовая причина", "$GPGGA,123456,1234.5678,N,12345.6789,E,1,8,1.2,123.4,M,45.6,M,,*AB", "Тестовые детали");
assertTrue(true);
} catch (Exception e) {
fail("logDroppedNMEA должен работать без исключений: " + e.getMessage());
}
}
@Test
public void testLogAISParseError() {
// Тестируем логирование ошибок парсинга AIS
try {
LogSender.logAISParseError("Тестовая ошибка", "!AIVDM,1,1,,A,1234567890ABCDEF,*AB", "Тестовые детали");
assertTrue(true);
} catch (Exception e) {
fail("logAISParseError должен работать без исключений: " + e.getMessage());
}
}
@Test
public void testLogBLEError() {
// Тестируем логирование ошибок BLE
try {
LogSender.logBLEError("Тестовая ошибка BLE", "AA:BB:CC:DD:EE:FF", "Тестовые детали");
assertTrue(true);
} catch (Exception e) {
fail("logBLEError должен работать без исключений: " + e.getMessage());
}
}
@Test
public void testLogErrorWithNullValues() {
// Тестируем обработку null значений
try {
LogSender.logError(null, null, null);
LogSender.logDroppedNMEA(null, null, null);
LogSender.logAISParseError(null, null, null);
LogSender.logBLEError(null, null, null);
assertTrue(true);
} catch (Exception e) {
fail("Методы должны обрабатывать null значения: " + e.getMessage());
}
}
}
@@ -0,0 +1,57 @@
package com.grigowashere.aismap.utils;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* Тест для проверки правильного URL логирования ошибок
*/
public class LogSenderURLTest {
@Test
public void testErrorLoggingURL() {
// Тестируем, что URL формируется правильно
try {
// Симулируем вызов logError
LogSender.logError("TEST_ERROR", "Тестовое сообщение", "Тестовые детали");
// Если метод выполнился без исключений, тест прошел
assertTrue("logError должен работать без исключений", true);
} catch (Exception e) {
fail("logError должен работать без исключений: " + e.getMessage());
}
}
@Test
public void testDroppedNMEALoggingURL() {
// Тестируем логирование отброшенных NMEA сообщений
try {
LogSender.logDroppedNMEA("Тестовая причина", "$GPGGA,123456,1234.5678,N,12345.6789,E,1,8,1.2,123.4,M,45.6,M,,*AB", "Тестовые детали");
assertTrue("logDroppedNMEA должен работать без исключений", true);
} catch (Exception e) {
fail("logDroppedNMEA должен работать без исключений: " + e.getMessage());
}
}
@Test
public void testAISParseErrorLoggingURL() {
// Тестируем логирование ошибок парсинга AIS
try {
LogSender.logAISParseError("Тестовая ошибка", "!AIVDM,1,1,,A,1234567890ABCDEF,*AB", "Тестовые детали");
assertTrue("logAISParseError должен работать без исключений", true);
} catch (Exception e) {
fail("logAISParseError должен работать без исключений: " + e.getMessage());
}
}
@Test
public void testBLEErrorLoggingURL() {
// Тестируем логирование ошибок BLE
try {
LogSender.logBLEError("Тестовая ошибка BLE", "AA:BB:CC:DD:EE:FF", "Тестовые детали");
assertTrue("logBLEError должен работать без исключений", true);
} catch (Exception e) {
fail("logBLEError должен работать без исключений: " + e.getMessage());
}
}
}