closd TG-6; Initial push after server migration
@@ -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"
|
||||
|
||||
|
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 5.7 KiB |
|
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>
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||