closd TG-6; Initial push after server migration

This commit is contained in:
2026-05-04 08:53:25 +03:00
parent 939f069681
commit 1009f49a59
93 changed files with 16246 additions and 9549 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-09-23T13:53:32.308312900Z"> <DropdownSelection timestamp="2026-04-30T06:54:41.293712400Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=bc722e5b" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=bc722e5b" />
-91
View File
@@ -1,91 +0,0 @@
# 🔧 Архитектурные исправления - ЗАВЕРШЕНО!
## ✅ Исправленные проблемы:
### 1. **MarkerManager перенесен в MapController** ✅
#### **Проблема:**
MarkerManager хранился в реализации карты (YandexMapImpl), что нарушало принцип централизованного управления.
#### **Решение:**
-**Интерфейс MarkerManager** уже существовал
-**Добавлен в MapController**:
- Переменная `private MarkerManager markerManager;`
- Метод `getMarkerManager()` для доступа
- Метод `initializeMarkerManager()` для инициализации
- Метод `cleanupMarkerManager()` для очистки
-**Интеграция с инициализацией карт**:
- Вызов `initializeMarkerManager()` в `initializeMap()` и `initializeMapLibre()`
- Вызов `cleanupMarkerManager()` в `cleanup()`
-**SDK-специфичная логика**:
- Yandex: создается `YandexMarkerManager`
- MapLibre: `null` (использует встроенное управление маркерами)
### 2. **Исправлена логика GPSLocationListener** ✅
#### **Проблема:**
GPSLocationListener дублировался в NMEAParser и NMEAController, логика работы была неправильной.
#### **Решение:**
-**Убрано дублирование**: GPSLocationListener теперь только в NMEAController
-**Добавлена правильная логика режимов**:
#### **Режимы работы:**
1. **`hybrid` (гибридный)**:
- GPS Android API для координат
- NMEA с Android идет в NMEA Parser
- Игнорируем "$" сообщения по UDP
2. **`android_only` (только Android GPS)**:
- Только встроенный GPS
- НЕ шлем в парсер NMEA с Android
- Игнорируем все NMEA сообщения
3. **`nmea_only` (только NMEA)**:
- Игнорируем locationManager
- Ищем RMC через Android NMEA
- Обрабатываем все NMEA сообщения
-**Добавлен метод `configureMode()`** для настройки режима
-**Обновлен `setDataMode()`** для применения настроек
-**Добавлена фильтрация в `parseNMEAMessage()`**:
- В режиме `android_only` игнорируются NMEA сообщения
-**Сохранение текущего режима** в переменной `currentDataMode`
## 🎯 **Результат:**
### ✅ **MarkerManager централизован:**
- Управляется через MapController
- SDK-специфичная логика изолирована
- Легко расширяется для новых SDK
### ✅ **GPS логика исправлена:**
- Нет дублирования GPSLocationListener
- Правильная работа режимов
- Фильтрация сообщений по режиму
- Четкое разделение источников данных
## 📊 **Статистика исправлений:**
- **Исправлено проблем**: 2
- **Обновлено файлов**: 2
- **Добавлено методов**: 6
- **Добавлено строк кода**: ~100
## 🚀 **Архитектура улучшена:**
```
MapController
├── MarkerManager (централизован)
│ ├── YandexMarkerManager (для Yandex)
│ └── null (для MapLibre - встроенное управление)
└── MapInterface (стратегия карт)
NMEAController
├── GPSLocationListener (единственный экземпляр)
├── AndroidNMEAListener
└── NMEAParser (с правильной логикой режимов)
```
**Архитектура стала более чистой и соответствует принципам SOLID!** 🎉
+99
View File
@@ -0,0 +1,99 @@
# Пакетная отправка логов - Серверная часть
## Новый endpoint для пакетной отправки логов
### POST `/logs/batch`
Принимает JSON с массивом логов и сохраняет их в базу данных одним запросом.
#### Формат запроса:
```json
{
"logs": [
{
"type": "nmea",
"message": "$GPGGA,123456.00,5542.1234,N,03741.5678,E,1,08,1.0,545.4,M,46.9,M,,*47",
"color": "#8AB4F8",
"timestamp": 1642248000000
},
{
"type": "errors",
"message": "[2024-01-15 12:00:00] AIS_PARSE_ERROR: Ошибка парсинга AIS: Неверная контрольная сумма",
"color": "#FF8A80",
"timestamp": 1642248001000
},
{
"type": "ships",
"message": "MMSI: 123456789 | Class A: lat=55.123456, lon=37.123456, course=45.0, speed=12.5",
"color": "#FF5733",
"timestamp": 1642248002000
},
{
"type": "ble",
"message": "BLE Data from AA:BB:CC:DD:EE:FF: $GPGGA,123456.00,5542.1234,N,03741.5678,E,1,08,1.0,545.4,M,46.9,M,,*47",
"color": "#8AB4F8",
"timestamp": 1642248003000
}
]
}
```
#### Реализация на Python (Flask):
```python
@app.route("/logs/batch", methods=["POST"])
def add_logs_batch():
try:
data = request.get_json()
logs = data.get("logs", [])
if not logs:
return "No logs provided", 400
# Подготавливаем данные для batch INSERT
log_data = []
for log in logs:
log_data.append((
log.get("type", "unknown"),
log.get("message", ""),
log.get("color", "#FFFFFF"),
datetime.fromtimestamp(log.get("timestamp", 0) / 1000) if log.get("timestamp") else datetime.now()
))
# Batch INSERT всех логов одним запросом
with get_conn() as conn:
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO logs (log_type, message, color, ts) VALUES (%s, %s, %s, %s)",
log_data
)
conn.commit()
return f"Added {len(logs)} logs successfully", 200
except Exception as e:
print(f"Error processing batch logs: {e}")
return f"Error: {str(e)}", 500
```
## Преимущества пакетной отправки:
**Производительность**: 1 HTTP запрос вместо N запросов
**Надежность**: Retry на уровне пакета
**Контроль нагрузки**: Отправка раз в секунду
**Все логи сохраняются**: Ничего не теряется
**Меньше нагрузки на сервер**: Batch INSERT вместо множественных INSERT
## Типы логов:
- `nmea` - NMEA сообщения (синий цвет)
- `errors` - Ошибки парсинга (красный цвет)
- `ships` - Информация о кораблях (уникальный цвет по MMSI)
- `ble` - BLE данные (синий цвет)
## Обработка ошибок:
- При ошибке HTTP запроса логи возвращаются в буфер
- Retry происходит автоматически через секунду
- Сервер возвращает HTTP 200 при успехе, 500 при ошибке
+245
View File
@@ -0,0 +1,245 @@
# BLE protocol v2 (AIS Hub transport)
Source of truth: `ais_hub`. BLE is **transport** for snapshot + live updates.
## 1. GATT layout
One custom service:
- **Service UUID**: `AIS_HUB_SERVICE_UUID` (see `ble_gatt.py`)
Characteristics:
- **`CONTROL`** (`AIS_HUB_CONTROL_UUID`): `write` / `write-without-response`
Client → server commands as **UTF-8 JSON**.
- **`DATA`** (`AIS_HUB_DATA_UUID`): `notify`
Server → client frames with **binary envelope + JSON payload**.
- **`STATUS`** (`AIS_HUB_STATUS_UUID`): `read` (+ optional `notify`)
Short **UTF-8 JSON** status blob (see section 7).
## 2. DATA frame format (binary envelope)
Every `DATA` notify is one **frame**:
```text
Byte 0 : protocol_version (u8)
Byte 1 : msg_type (u8)
Byte 2-3 : session_msg_id (u16 LE)
Byte 4-5 : chunk_index (u16 LE)
Byte 6-7 : chunk_count (u16 LE)
Byte 8-9 : payload_len (u16 LE)
Byte 10+ : payload bytes (payload_len bytes, UTF-8 JSON)
```
Encoding: little-endian for all multi-byte integers. Header size = **10 bytes**.
If the logical message fits into one frame:
- `chunk_index = 0`
- `chunk_count = 1`
## 3. msg_type enum
Server → client (`DATA`):
| Hex | Name | When |
| --- | --- | --- |
| `0x01` | `HELLO_ACK` | Reply to `hello` |
| `0x02` | `SNAPSHOT_BEGIN` | Snapshot start |
| `0x03` | `SNAPSHOT_CHUNK` | Snapshot section chunk |
| `0x04` | `SNAPSHOT_END` | Snapshot end |
| `0x05` | `EVENT` | Live event from `ais_hub` |
| `0x06` | `STATUS` | Acknowledgements / state updates |
| `0x07` | `ERROR` | Error payload |
| `0x08` | `PONG` | Reply to `ping` |
## 4. CONTROL commands (client → server)
Write UTF-8 JSON into `CONTROL`.
### 4.1 hello
```json
{"cmd":"hello","client":"android","app_version":"1.2.3","proto":1}
```
Reply: `HELLO_ACK` (payload JSON).
### 4.2 get_snapshot
```json
{"cmd":"get_snapshot","include":["ownship","vessels","base_stations","atons","stats"],"max_vessels":500}
```
Reply sequence: `SNAPSHOT_BEGIN` → N×`SNAPSHOT_CHUNK``SNAPSHOT_END`.
### 4.3 subscribe / unsubscribe
```json
{"cmd":"subscribe","events":["ownship.update","target.update","base_station.update","aton.update","stats.update"]}
```
```json
{"cmd":"unsubscribe","events":["stats.update"]}
```
### 4.4 set_filters (reserved)
```json
{"cmd":"set_filters","targets":{"radius_nm":20,"classes":["A","B"]}}
```
### 4.5 ping
```json
{"cmd":"ping","id":123}
```
Reply: `PONG` payload: `{"id":123,"server_time":...}`.
## 5. Chunking algorithm (server side)
Given a logical message payload object:
1. Serialize payload as JSON (UTF-8), no pretty formatting required.
2. Let `max_payload_bytes` be the per-session limit (default 120; may be reduced based on negotiated ATT MTU).
3. Split payload bytes into chunks of at most `max_payload_bytes`.
4. For each chunk:
- Build 10-byte header with the same `session_msg_id`
- Set `chunk_index` from `0..chunk_count-1`
- Set `chunk_count` to total chunks
- Set `payload_len` to the chunk length
- Append `payload bytes`
5. Send frames in order.
Client-side reassembly:
1. Group frames by `(session_msg_id, msg_type)` within the BLE connection.
2. Collect `chunk_count` frames.
3. Concatenate chunk payloads by `chunk_index`.
4. Decode UTF-8 and JSON-parse.
## 6. Snapshot payloads (JSON inside DATA)
### 6.1 SNAPSHOT_BEGIN
```json
{
"snapshot_id": 42,
"sections": ["ownship","vessels","base_stations","atons","stats"],
"total_objects": {"ownship":1,"vessels":183,"base_stations":4,"atons":12,"stats":1}
}
```
### 6.2 SNAPSHOT_CHUNK
For `ownship`/`stats` (single object):
```json
{"snapshot_id":42,"section":"ownship","seq":1,"more":false,"item":{...}}
```
For `vessels`/`base_stations`/`atons` (batched array):
```json
{"snapshot_id":42,"section":"vessels","seq":7,"more":true,"items":[{...},{...}]}
```
`base_stations` items follow `GET /api/v1/base_stations`.
`atons` items follow `GET /api/v1/atons`.
### 6.3 SNAPSHOT_END
```json
{"snapshot_id":42,"ok":true}
```
## 7. STATUS characteristic payload (UTF-8 JSON)
`STATUS.ReadValue` returns compact JSON, example:
```json
{
"proto": 1,
"server_time": 1710000000.123,
"gps_fix": null,
"vessels_active": null,
"ws_source_alive": true,
"snapshot_in_progress": false,
"tx_queue": 0,
"tx_dropped": 0
}
```
## 8. Examples (real frame bytes)
Notation: header is `struct <BBHHHH>` = 10 bytes.
### 8.1 HELLO_ACK example (single chunk)
Assume:
- `protocol_version = 1``01`
- `msg_type = HELLO_ACK (0x01)``01`
- `session_msg_id = 0x002A (42)``2A 00`
- `chunk_index = 0``00 00`
- `chunk_count = 1``01 00`
Payload JSON:
```json
{"ok":true,"proto":1,"server":"ais_ble","server_time":1710000000.0,"features":{"snapshot":true,"live_events":true,"filters":true,"compression":false}}
```
Let `payload_len = 0x0096 (150)` (example value; actual length depends on JSON formatting) → `96 00`.
Frame hex:
```text
01 01 2A 00 00 00 01 00 96 00 7B 22 6F 6B 22 3A 74 72 75 65 ...
```
Where `7B 22 6F 6B ...` is the UTF-8 JSON (`{ "ok":true ... }`).
### 8.2 EVENT example (chunked)
Assume:
- `msg_type = EVENT (0x05)``05`
- `session_msg_id = 0x0042 (66)``42 00`
- payload does not fit → split into 3 chunks (`chunk_count = 3``03 00`)
Chunk #0 header (payload length `0x0078 (120)`):
```text
01 05 42 00 00 00 03 00 78 00 <120 payload bytes>
```
Chunk #1 header:
```text
01 05 42 00 01 00 03 00 78 00 <120 payload bytes>
```
Chunk #2 header (last chunk shorter, e.g. `0x0034 (52)`):
```text
01 05 42 00 02 00 03 00 34 00 <52 payload bytes>
```
Reassembled JSON becomes the original event:
```json
{"type":"target.update","ts":1700000000.123,"data":{"mmsi":506140446,...}}
```
Other live event payloads use the same envelope:
```json
{"type":"base_station.update","ts":1700000000.123,"data":{"mmsi":2570001,"lat":59.9,"lon":10.7,"epfd":1}}
```
```json
{"type":"aton.update","ts":1700000000.123,"data":{"mmsi":992570001,"lat":59.9,"lon":10.7,"type":3,"name":"FLAKFORTET","virtual":false}}
```
-92
View File
@@ -1,92 +0,0 @@
# 🔧 Исправления GPS и AIS логики - ЗАВЕРШЕНО!
## ✅ Исправленные проблемы:
### 1. **Сохранение настроек не меняет поведение** ✅
#### **Проблема:**
При изменении настроек GPS режима (`hybrid`, `android_only`, `nmea_only`) изменения не применялись без перезапуска приложения.
#### **Решение:**
-**Добавлен перезапуск слушателей** в `setDataMode()`:
- Проверка изменения режима
- Вызов `restartListeners()` при изменении
-**Реализован метод `restartListeners()`**:
- Остановка всех слушателей
- Запуск нужных слушателей по режиму
- Логирование процесса
#### **Логика перезапуска:**
```java
public void setDataMode(String mode) {
String oldMode = currentDataMode;
configureMode(mode);
// Если режим изменился, перезапускаем слушатели
if (!oldMode.equals(mode)) {
Log.i(TAG, "Режим изменился с " + oldMode + " на " + mode + ", перезапускаем слушатели");
restartListeners();
}
}
```
### 2. **AIS данные отключались в режиме android_only** ✅
#### **Проблема:**
В режиме `android_only` игнорировались ВСЕ NMEA сообщения, включая AIS данные (`!` сообщения).
#### **Решение:**
-**Добавлена умная фильтрация** в `parseNMEAMessage()`:
- Метод `isGPSNMEAMessage()` для различения типов сообщений
- AIS сообщения (`!`) всегда пропускаются
- GPS NMEA сообщения (`$`) фильтруются по режиму
-**Логика фильтрации**:
- `hybrid`: все сообщения пропускаются
- `android_only`: только AIS (`!`) пропускаются, GPS (`$`) игнорируются
- `nmea_only`: все сообщения пропускаются
#### **Код фильтрации:**
```java
private boolean isGPSNMEAMessage(String message) {
// AIS сообщения начинаются с "!" - их всегда пропускаем
if (message.startsWith("!")) {
return false;
}
// GPS NMEA сообщения начинаются с "$" - их фильтруем в режиме android_only
if (message.startsWith("$")) {
return true;
}
return false;
}
```
## 🎯 **Результат:**
### ✅ **Настройки применяются мгновенно:**
- Изменение GPS режима сразу перезапускает слушатели
- Не нужно перезапускать приложение
- Логирование процесса для отладки
### ✅ **AIS данные работают во всех режимах:**
- `hybrid`: GPS + NMEA + AIS
- `android_only`: только Android GPS + AIS (без внешнего NMEA)
- `nmea_only`: только внешний NMEA + AIS (без Android GPS)
## 📊 **Статистика исправлений:**
- **Исправлено проблем**: 2
- **Обновлено файлов**: 1
- **Добавлено методов**: 2
- **Добавлено строк кода**: ~50
## 🚀 **Логика работы режимов:**
| Режим | Android GPS | Android NMEA | UDP NMEA | AIS |
|-------|-------------|--------------|----------|-----|
| `hybrid` | ✅ | ✅ | ✅ | ✅ |
| `android_only` | ✅ | ❌ | ❌ | ✅ |
| `nmea_only` | ❌ | ✅ | ✅ | ✅ |
**Теперь GPS настройки работают корректно и AIS данные не теряются!** 🎉
-142
View File
@@ -1,142 +0,0 @@
# Гибридный GPS подход в AISMap
## Проблема
Изначально приложение пыталось получать все GPS данные через NMEA сообщения, но столкнулось с нестабильностью получения RMC сообщений (курс, скорость). NMEA сообщения могут приходить нерегулярно или вообще не приходить, что делает невозможным получение критически важной навигационной информации.
## Решение: Гибридный подход
Мы реализовали **гибридный подход**, который комбинирует два источника данных:
### 1. Android Location API (основной источник координат)
- **Что получаем**: Координаты (широта/долгота), точность, время фикса, качество фикса
- **Преимущества**: Стабильный, надежный, работает на всех устройствах
- **Класс**: `GPSLocationListener`
### 2. NMEA сообщения (дополнительные данные)
- **Что получаем**: Курс, скорость, количество спутников, DOP (PDOP/HDOP/VDOP), высота
- **Преимущества**: Детальная навигационная информация, стандарт морской навигации
- **Класс**: `AndroidNMEAListener` + `NMEAParser`
## Архитектура
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ MainActivity │ │ AppController │ │ MapInterface │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │ │
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ GPSLocation │ │ NMEAParser │ │ Карта │
│ Listener │ │ (гибридный) │ │ │
│ (координаты) │ │ (курс, DOP) │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │
│ │
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ Android NMEA │ │ UDP Listener │
│ Listener │ │ (AIS данные) │
│ (NMEA сообщения)│ │ │
└─────────────────┘ └──────────────────┘
```
## Ключевые компоненты
### GPSLocationListener
- Получает координаты через `LocationManager.requestLocationUpdates()`
- Отслеживает количество спутников через `GnssStatus.Callback`
- Предоставляет точность позиции в метрах
- Определяет качество фикса (GPS, DGPS, RTK)
### NMEAParser (гибридный режим)
- В гибридном режиме **НЕ обновляет координаты** из NMEA
- Парсит только дополнительные данные:
- **GGA**: количество спутников, высота
- **RMC**: курс, скорость
- **VTG**: курс, скорость (альтернативный источник)
- **GSA**: DOP значения, активные спутники
- **GSV**: спутники в поле зрения
### AppController
- Координирует работу всех компонентов
- Объединяет данные из разных источников в единый объект `Vessel`
- Управляет жизненным циклом слушателей
## Преимущества гибридного подхода
### ✅ Надежность
- Координаты всегда доступны через Location API
- Не зависит от стабильности NMEA сообщений
### ✅ Полнота данных
- Получаем все необходимые навигационные параметры
- DOP значения для оценки качества GPS
### ✅ Совместимость
- Работает на всех версиях Android
- Поддерживает как старый GPS API, так и новый GNSS API
### ✅ Производительность
- Location API оптимизирован системой
- NMEA парсинг только для дополнительных данных
## Использование
### Включение гибридного режима
```java
// В AppController
nmeaParser.setHybridMode(true);
nmeaParser.setGPSLocationListener(gpsLocationListener);
// Запуск слушателей
appController.setGPSLocationEnabled(true); // Для координат
appController.setAndroidNMEAEnabled(true); // Для дополнительных данных
```
### Получение данных
```java
Vessel vessel = appController.getOwnVessel();
// Координаты (из Location API)
double lat = vessel.getLatitude();
double lon = vessel.getLongitude();
float accuracy = vessel.getAccuracy();
// Навигационные данные (из NMEA)
double course = vessel.getCourse();
double speed = vessel.getSpeed();
int satellites = vessel.getSatellites();
// Качество GPS (из NMEA + Location API)
double pdop = vessel.getPdop();
double hdop = vessel.getHdop();
double vdop = vessel.getVdop();
String quality = vessel.getGPSQualityDescription();
```
## Тестирование
Для тестирования создан класс `GPSHybridTest`, который демонстрирует работу гибридного подхода:
```java
GPSHybridTest test = new GPSHybridTest(context);
test.startTest(); // Запускает тест
test.stopTest(); // Останавливает тест
test.cleanup(); // Освобождает ресурсы
```
## Логирование
Приложение ведет детальное логирование для отладки:
- `📍` - GPS Location данные
- `📡` - NMEA данные
- `📊` - DOP значения
- `🛰️` - Информация о спутниках
- `🚢` - Статус судна
## Заключение
Гибридный подход решает проблему нестабильности NMEA сообщений, обеспечивая надежное получение координат через Location API и дополнительных навигационных данных через NMEA. Это дает нам лучшее из двух миров: надежность Android системы и богатство морских навигационных данных.
-148
View File
@@ -1,148 +0,0 @@
# Решение проблем с NMEA сообщениями в Android
## Проблема
RMC пакеты то приходят, то не приходят, особенно при старте приложения. Это типичная проблема холодного запуска GPS в Android.
## Причины проблемы
### 1. Холодный запуск GPS
- **GPS модуль включен** ≠ **GPS активен и отправляет данные**
- Android может держать GPS в "спящем" режиме для экономии батареи
- NMEA сообщения не отправляются без активных слушателей
### 2. Отсутствие активного запроса локации
- `addNmeaListener()` регистрирует слушатель, но не активирует GPS
- Нужен активный `requestLocationUpdates()` для "пробуждения" GPS
- Без этого NMEA может не приходить часами
### 3. Проблемы с регистрацией слушателей
- Неправильный порядок регистрации
- Отсутствие обработки ошибок
- Проблемы с версиями Android API
## Решения
### 1. Улучшенная регистрация NMEA слушателя
```java
// Регистрируем с Handler для лучшей производительности
locationManager.addNmeaListener(this, mainHandler);
// Сразу запрашиваем обновления локации для активации GPS
requestLocationUpdates();
```
### 2. Агрессивная активация GPS
```java
// Минимальные интервалы для быстрой активации
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
100, // 100мс вместо 1000мс
0, // 0 метров
locationListener,
mainHandler
);
// Дополнительно запрашиваем одиночное обновление
locationManager.requestSingleUpdate(LocationManager.GPS_PROVIDER, ...);
```
### 3. Обработка GPS событий
```java
// При старте GNSS сразу активируем GPS
@Override
public void onStarted() {
requestLocationUpdates(); // Активируем GPS
}
// При первом фиксировании также активируем
@Override
public void onFirstFix(int ttffMillis) {
requestLocationUpdates(); // Дополнительная активация
}
```
### 4. Поддержка старых версий Android
```java
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
// Старый API - GpsStatus.Listener
locationManager.addGpsStatusListener(gpsStatusListener);
} else {
// Новый API - GnssStatus.Callback
locationManager.registerGnssStatusCallback(gnssStatusCallback, mainHandler);
}
```
## Диагностика
### Логи для отслеживания
```
=== ДИАГНОСТИКА GPS ===
Доступные провайдеры:
gps: включен
network: включен
passive: включен
=== РЕГИСТРАЦИЯ СЛУШАТЕЛЕЙ ===
✅ NMEA слушатель зарегистрирован
✅ GNSS статус callback зарегистрирован (новый API)
✅ Location updates запрошены с минимальным интервалом (100мс)
✅ Одиночное обновление запрошено
🎯 NMEA [RMC] получено: $GPRMC,123456,A,...
🚢 RMC сообщение получено - GPS активен!
```
### Проверка состояния
```java
// Детальная информация о GPS
String gpsInfo = nmeaListener.getGPSStatusInfo();
Log.i(TAG, gpsInfo);
// Принудительная активация
nmeaListener.forceGPSActivation();
```
## Рекомендации
### 1. При запуске приложения
- Сразу регистрируйте NMEA слушатель
- Запрашивайте location updates с минимальными интервалами
- Обрабатывайте GPS события для дополнительной активации
### 2. При проблемах с получением NMEA
- Проверьте логи на наличие ошибок
- Убедитесь, что GPS провайдер включен
- Попробуйте принудительную активацию GPS
- Проверьте количество видимых спутников
### 3. Оптимизация
- Используйте минимальные интервалы только при необходимости
- Обрабатывайте ошибки и повторяйте попытки
- Мониторьте состояние GPS через callback'и
## Тестирование
Используйте `NMEATestHelper` для диагностики:
```java
NMEATestHelper testHelper = new NMEATestHelper(context);
testHelper.startTest(); // Запускает тест с периодической диагностикой
testHelper.forceGPSActivation(); // Принудительная активация
testHelper.stopTest(); // Остановка теста
```
## Частые проблемы
1. **"GPS провайдер отключен"** - включите GPS в настройках
2. **"Нет разрешения ACCESS_FINE_LOCATION"** - запросите разрешение
3. **"LocationManager недоступен"** - проверьте инициализацию
4. **NMEA не приходит** - проверьте логи, попробуйте принудительную активацию
## Заключение
Проблема с RMC пакетами решается правильной последовательностью:
1. Регистрация NMEA слушателя
2. Активация GPS через location updates
3. Обработка GPS событий
4. Диагностика и мониторинг
После этих изменений NMEA сообщения должны приходить стабильно, даже при холодном запуске GPS.
Vendored
+184
View File
@@ -0,0 +1,184 @@
// AndroidAisMap: compileSdk/targetSdk 35, AGP 8.9, Gradle 8.11.1, Java 11 (toolchain on agent: JDK 21 OK).
// На сервере один раз (подставьте путь к sdkmanager):
// yes | sdkmanager --licenses
// sdkmanager --install "cmdline-tools;latest" "platforms;android-35" "build-tools;35.0.0" "platform-tools"
// NDK/CMake в проекте не заданы — отдельно не нужны, пока не добавите native-сборку.
pipeline {
agent any
environment {
JAVA_HOME = '/usr/lib/jvm/java-21-openjdk-amd64'
ANDROID_HOME = '/opt/android-sdk'
ANDROID_SDK_ROOT = '/opt/android-sdk'
PATH = "/usr/lib/jvm/java-21-openjdk-amd64/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:${env.PATH}"
TAIGA_PROJECT_ID = '3'
TAIGA_URL = 'https://taiga.grigowashere.ru'
GITEA_OWNER = 'Grigo'
GITEA_REPO = 'AndroidAisMap'
GITEA_URL = 'https://git.grigowashere.ru'
GITEA_API_URL = "${GITEA_URL}/api/v1"
GITEA_TOKEN_CREDENTIALS_ID = 'Gitea_Credentials' // ID ваших креденшлов для Gitea в Jenkins
}
stages {
stage('Build Android APK') {
steps {
cleanWs()
checkout scm
sh '''
chmod +x ./gradlew
./gradlew clean assembleDebug
'''
archiveArtifacts artifacts: '**/build/outputs/apk/**/*.apk', fingerprint: true
stash name: 'apk', includes: '**/build/outputs/apk/**/*.apk'
}
}
stage('Create Gitea Release and Upload APK') {
steps {
unstash 'apk'
withCredentials([string(credentialsId: "${env.GITEA_TOKEN_CREDENTIALS_ID}", variable: 'GITEA_TOKEN')]) {
writeFile file: 'gitea-release.sh', text: '''
#!/bin/bash
apkPath="app/build/outputs/apk/debug/app-debug.apk"
headers="Authorization: token $GITEA_TOKEN"
commitish="${GIT_COMMIT:-$(git rev-parse HEAD)}"
# Создаем релиз на Gitea (target_commitish — текущий коммит сборки)
release=$(curl -X POST "${GITEA_API_URL}/repos/$GITEA_OWNER/$GITEA_REPO/releases" \
-H "$headers" \
-H "Content-Type: application/json" \
-d '{
"tag_name": "v'$BUILD_NUMBER'",
"target_commitish": "'"$commitish"'",
"name": "Release v'$BUILD_NUMBER'",
"body": "Jenkins Android build '$BUILD_NUMBER'",
"draft": false,
"prerelease": false
}')
releaseId=$(echo $release | jq -r .id)
# Формируем URL для загрузки APK
uploadUrl="${GITEA_API_URL}/repos/$GITEA_OWNER/$GITEA_REPO/releases/$releaseId/assets?name=app-debug.apk"
# Загружаем APK файл в Gitea
curl -X POST "$uploadUrl" \
-H "$headers" \
-F "attachment=@$apkPath;type=application/vnd.android.package-archive"
if [ $? -ne 0 ]; then
echo "Gitea asset upload failed: $apkPath"
exit 1
fi
'''
sh '''
chmod +x gitea-release.sh
./gitea-release.sh
'''
}
}
}
}
post {
always {
script {
def result = currentBuild.currentResult ?: 'UNKNOWN'
withCredentials([string(credentialsId: 'TAIGA_TOKEN', variable: 'TAIGA_TOKEN')]) {
sh(returnStatus: true, script: """
set +e
REF=\$(git log -1 --pretty=%B | grep -oE 'TG-[0-9]+' | head -1 | cut -d- -f2 || true)
if [ -z "\$REF" ]; then
echo "No TG-* reference found"
exit 0
fi
export REF
export BUILD_RESULT="${result}"
python3 - <<'PY'
import json
import os
import urllib.request
taiga_url = os.environ["TAIGA_URL"]
project_id = os.environ["TAIGA_PROJECT_ID"]
token = os.environ["TAIGA_TOKEN"]
ref = os.environ["REF"]
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
def get_json(path):
url = f"{taiga_url}{path}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode("utf-8"))
except Exception:
return None
targets = [
("userstories", "User Story"),
("issues", "Issue"),
("tasks", "Task"),
]
found = None
for endpoint, label in targets:
data = get_json(f"/api/v1/{endpoint}/by_ref?project={project_id}&ref={ref}")
if data and "id" in data:
found = (endpoint, label, data)
break
if not found:
print(f"Taiga TG-{ref} not found")
raise SystemExit(0)
endpoint, label, data = found
comment = (
f"Jenkins Android build #{os.environ['BUILD_NUMBER']}: {os.environ['BUILD_RESULT']}\\n"
f"{os.environ['BUILD_URL']}"
)
payload = json.dumps({
"comment": comment,
"version": data["version"],
}).encode("utf-8")
url = f"{taiga_url}/api/v1/{endpoint}/{data['id']}"
req = urllib.request.Request(
url,
data=payload,
headers=headers,
method="PATCH",
)
try:
with urllib.request.urlopen(req) as r:
print(f"Commented Taiga TG-{ref} ({label}), HTTP {r.status}")
except Exception as e:
print(f"Taiga comment warning: {e}")
raise SystemExit(0)
PY
""".stripIndent())
}
}
}
}
}
-94
View File
@@ -1,94 +0,0 @@
# РЕАЛЬНОЕ исправление зависаний карты и кнопок
## НАЙДЕНА ИСТИННАЯ ПРИЧИНА!
**Главная проблема**: В `MapLibreMapImpl.updateOwnVesselPosition()` вызывалось **ТРИ отдельных** `uiHandler.post()` операции:
```java
// СТАРЫЙ КОД - ПРОБЛЕМНЫЙ:
uiHandler.post(() -> updateOwnVesselPathSource("own_vessel", pathCoords)); // 1
uiHandler.post(() -> updateOwnVesselPredictionSource("own_vessel", vessel)); // 2
uiHandler.post(() -> refreshGeoJson()); // 3
```
## Проблемы создававшие блокировки:
### 1. **Множественные UI операции**
Каждое обновление GPS/NMEA вызывало **4 раза** `updateOwnVesselPosition` из AppController:
- Из GPS `onLocationUpdated` (2 раза)
- Из NMEA `onVesselUpdated` (2 раза)
### 2. **Тяжелые операции в UI потоке**:
- `refreshGeoJson()` - пересоздание всей GeoJSON каждый раз
- `updateOwnVesselPathSource()` - обновление источника с множеством координат
- `updateOwnVesselPredictionSource()` - расчет прогноза
### 3. **reffreshGeoJson() проблематичен**:
```java
private void refreshGeoJson() {
JSONObject fc = new JSONObject();
fc.put("type", "FeatureCollection");
JSONArray features = new JSONArray();
for (JSONObject f : idToFeature.values()) { // Итерация по ВСЕМ судам
features.put(f); // Объект преобразуется в JSON
}
fc.put("features", features);
source.setGeoJson(fc.toString()); // Создание большой строки!
}
```
## ВНЕСЕННЫЕ ИСПРАВЛЕНИЯ:
### 1. **Throttling система в MapLibreMapImpl**:
```java
// Новые переменные для throttling
private final android.os.Handler mapUpdateHandler = new android.os.Handler(android.os.Looper.getMainLooper());
private Runnable mapUpdateRunnable;
private boolean mapUpdatePending = false;
private static final long MAP_UPDATE_DELAY = 500; // 500ms throttling
```
### 2. **Переработанный updateOwnVesselPosition**:
```java
@Override
public void updateOwnVesselPosition(Vessel vessel) {
// Данные обновляются СРАЗУ (не блокирующее)
JSONObject feature = buildFeature(...);
idToFeature.put("own_vessel", feature);
// Throttled обновление карты
updateMapThrottled(vessel);
}
```
### 3. **Батчевое обновление карты**:
```java
private void updateMapBatched(Vessel vessel) {
uiHandler.post(() -> {
// ВСЕ операции в ОДНОМ UI вызове:
updateOwnVesselPathSource("own_vessel", pathCoords);
updateOwnVesselPredictionSource("own_vessel", vessel);
refreshGeoJson(); // Только один раз!
});
}
```
### 4. **Убрали дублированные вызовы в AppController**:
- Удалили **2 избыточных** вызова `updateOwnVesselPosition`
- Теперь остается только **1 throttled** вызов вместо **4 обычных**
## Результат:
-**Throttling**: вместо постоянных обновлений - 1 раз в 500мс
-**Батчинг**: вместо 3 отдельных UI вызовов - 1 объединенный
-**Дедупликация**: вместо 4 вызовов из AppController - 1 throttled
-**Защита от зависания**: cleanup handler'ов в cleanup()
## Ожидаемый эффект:
1. **Карта и кнопки перестанут зависать**
2. **Доквиджеты продолжат работать** (они не затрагивались)
3. **Фоновые процессы не пострадают**
4. **Обновления карты станут плавными** вместо лагающих
**Ключевая диагностика**: Смотрите в логах `"Карта обновлена батчом"` - это означает что throttling работает правильно.
-77
View File
@@ -1,77 +0,0 @@
# Новая архитектура приложения
## Проблемы текущей архитектуры:
1. **AppController смешивает логику и UI**:
- `mapInterface.updateOwnVesselPosition()` вызывается напрямую
- `uiHandler.post(() -> mapInterface.addAISVesselMarker())`
- Контроллеры знают о UI деталях
2. **Нет единого поток UI операций**:
- Каждый контроллер делает свои UI вызовы
- Нет централизованного throttling для карты
- Перегрузка UI потока
## Новая архитектура:
### 1. **Контроллеры (Background Threads)**:
- Только **обновляют модель данных** (ownVessel, aisVessels)
- Только **вычисления** (paths, predictions, compass)
- Только **парсинг данных** (NMEA, GPS, UDP)
- **Не знают** о UI карте
### 2. **UI Coordinator (Main Thread)**:
- **Единая точка** всех UI операций
- **Централизованный throttling** (500мс, 1сек)
- **Батчинг** операций карты
- **Координация** между контроллерами и UI
### 3. **Data Flow**:
```
Background Threads → Model Updates → UI Coordinator → Throttled Map Updates
↓ ↓ ↓ ↓
NMEA/GPS ownVessel Batched UI MapLibre
Parsing AIS Data Operations Rendering
```
## План реализации:
### Этап 1: Создать UI Coordinator
```java
public class UIRenderingCoordinator {
private MapInterface mapInterface;
private Handler uiHandler;
private Runnable batchedUpdateRunnable;
private Set<String> pendingVesselUpdates;
private Map<String, AISVessel> pendingAISUpdates;
void requestVesselUpdate(Vessel vessel) { /* add to pending */ }
void requestAISUpdate(AISVessel vessel) { /* add to pending */ }
void executeBatchUpdate() { /* throttled map rendering */ }
}
```
### Этап 2: Рефакторить AppController
- Убрать все `mapInterface.*` вызовы
- Только обновлять `ownVessel` данные
- Уведомлять `UIRenderingCoordinator` через интерфейс
- Никаких `uiHandler.post()` в контроллерах
### Этап 3: Установить throttling
- Все UI операции → UI Coordinator
- Throttling 500мс для критичных операций
- Throttling 1сек для некритичных (paths, compass)
### Этап 4: Батчинг операций
- Собирать все изменения за период throttling
- Одним вызовом обновить всю карту
- Минимизировать количество `mapInterface` вызовов
## Ожидаемый результат:
**Стабильная производительность** - контроллеры работают в фоне
**Предсказуемые зависания** - все UI операции через единую точку
**Масштабируемость** - легко добавить новые контроллеры
**Тестируемость** - контроллеры не зависят от UI
**Производительность** - минимум UI операций, максимум батчинга
File diff suppressed because it is too large Load Diff
-85
View File
@@ -1,85 +0,0 @@
# 🔧 Исправления после рефакторинга - ЗАВЕРШЕНО!
## ✅ Исправленные проблемы:
### 1. **Трейсер других судов (AIS vessels path tracking)** ✅
#### **Проблема:**
После рефакторинга перестали отрисовываться пути AIS судов на карте.
#### **Причина:**
- Метод `executePathUpdates()` в `UIRenderingCoordinator` содержал только заглушку
- Не было уведомлений UI о изменении путей AIS судов
- Отсутствовал метод `updateAllVesselPaths()` в `MapInterface`
#### **Исправления:**
1. **✅ Добавлен метод `updateAllVesselPaths()` в MapInterface**
2. **✅ Реализован метод в MapLibreMapImpl:**
- `updateAllVesselPaths()` - обновляет все пути судов
- `updateAISVesselPaths()` - обновляет пути AIS судов
- `updateAISVesselPath()` - обновляет путь конкретного AIS судна
- `updateAISVesselPathSource()` - обновляет источник пути на карте
3. **✅ Исправлен UIRenderingCoordinator:**
- Реализован метод `executePathUpdates()`
- Добавлены уведомления о путях AIS судов
4. **✅ Обновлен AppCoordinator:**
- Добавлены уведомления UI при изменении путей AIS судов
- Метод `addAISVesselPathPoint()` теперь уведомляет UI
### 2. **Настройки GPS - выбор одного источника данных** ✅
#### **Проблема:**
Приложение брало GPS данные из обоих источников одновременно (Android GPS + NMEA), независимо от настроек.
#### **Причина:**
В `startAllControllers()` всегда запускались и Android NMEA, и GPS Location слушатели.
#### **Исправления:**
1. **✅ Добавлен метод `startControllersBasedOnSettings()`:**
- **hybrid**: Android GPS + NMEA (по умолчанию)
- **android_only**: только встроенный GPS
- **nmea_only**: только внешний NMEA
2. **✅ Обновлен метод `applySettings()`:**
- Добавлен вызов `restartDataControllers()`
- При изменении настроек перезапускаются контроллеры данных
3. **✅ Добавлен метод `restartDataControllers()`:**
- Останавливает текущие контроллеры данных
- Запускает с новыми настройками
## 🎯 **Результат:**
### ✅ **Трейсер AIS судов работает:**
- Пути AIS судов отрисовываются на карте
- UI получает уведомления об изменении путей
- Поддерживается throttling для производительности
### ✅ **Настройки GPS работают корректно:**
- Можно выбрать один источник данных
- Настройки применяются динамически
- Логирование режимов работы
## 📊 **Статистика исправлений:**
- **Исправлено проблем**: 2
- **Добавлено методов**: 6
- **Обновлено файлов**: 4
- **Добавлено строк кода**: ~150
## 🚀 **Архитектура полностью функциональна:**
```
MainActivity
├── AppCoordinator (координация + исправления)
│ ├── NMEAController (NMEA парсинг)
│ ├── NetworkController (UDP)
│ ├── DataController (БД)
│ ├── NotificationController (уведомления)
│ └── MapController (карты)
└── CompassController (компас)
```
**Все проблемы после рефакторинга исправлены! Приложение готово к использованию!** 🎉
-123
View File
@@ -1,123 +0,0 @@
# 🎉 Рефакторинг AppController - ПОЛНОСТЬЮ ЗАВЕРШЕН!
## ✅ Все задачи выполнены успешно!
### 📦 **Созданные контроллеры:**
#### 1. **NMEAController** ✅
- **Ответственность**: Парсинг NMEA сообщений
- **Методы**: `parseNMEAMessage()`, `startAndroidNMEAListener()`, `setDataMode()`
- **Интерфейсы**: `NMEAParserListener`, `NMEAMessageCallback`
#### 2. **NetworkController** ✅
- **Ответственность**: UDP слушание и отправка данных
- **Методы**: `startUDPListener()`, `sendUDPData()`, `setUDPPort()`
- **Интерфейсы**: `UDPListenerCallback`
#### 3. **DataController** ✅
- **Ответственность**: Операции с базой данных
- **Методы**: `saveVesselPosition()`, `saveAISVessel()`, `restoreDataAsync()`
- **Интерфейсы**: `DataControllerListener`
#### 4. **NotificationController** ✅
- **Ответственность**: Управление уведомлениями
- **Методы**: `notifyNewAISTarget()`, `notifySafetyMessage()`
- **Интерфейсы**: `NotificationControllerListener`
#### 5. **CompassController** ✅
- **Ответственность**: Управление магнитным компасом
- **Методы**: `startCompass()`, `updateCompassWithVesselData()`
- **Интерфейсы**: `CompassListener`
#### 6. **AppCoordinator** ✅ (Главный координатор)
- **Ответственность**: Координация между всеми контроллерами
- **Методы**: `startAllControllers()`, `applySettings()`, `cleanup()`
- **Интерфейсы**: Все listener'ы контроллеров + `MarkerClickListener`, `MapInterfaceChangeListener`
### 🔧 **Добавленные методы:**
#### **AppCoordinator** ✅
-`getSecondsSinceLastGPSMessage()`
-`getSecondsSinceLastAISMessage()`
-`centerOnOwnVessel()`
-`clearVesselPath()`
-`clearAISVessels()`
-`isAndroidNMEAEnabled()`
-`isUDPEnabled()`
-`getUDPPort()`
-`restartUDPListener()`
#### **MapController** ✅
-`setAppCoordinator(AppCoordinator appCoordinator)`
#### **SettingsManager** ✅
-`areNotificationsEnabled()`
-`setNotificationsEnabled(boolean enabled)`
#### **NotificationService** ✅
-`setNotificationsEnabled(boolean enabled)`
### 🔄 **Обновленные компоненты:**
#### **MainActivity** ✅
- ✅ Заменил `AppController` на `AppCoordinator`
- ✅ Добавил `CompassController` для управления компасом
- ✅ Обновил все методы для работы с новой архитектурой
- ✅ Исправил все TODO комментарии
### 🎯 **Результаты рефакторинга:**
#### ✅ **Single Responsibility Principle (SRP)**
- **До**: 1 монолитный класс с 12+ ответственностями
- **После**: 6 специализированных контроллеров + 1 координатор
#### ✅ **Open/Closed Principle (OCP)**
- Легко добавлять новые типы контроллеров
- Расширение функциональности без изменения существующего кода
#### ✅ **Dependency Inversion Principle (DIP)**
- Контроллеры зависят от абстракций (интерфейсов)
- AppCoordinator координирует, но не создает зависимости напрямую
#### ✅ **Улучшенная читаемость**
- MainActivity стал значительно проще
- Четкое разделение ответственностей
- Легче найти и исправить баги
#### ✅ **Готовность к Strategy Pattern**
- MapController уже готов для разных SDK
- Легко добавить новые типы карт
- Четкое разделение логики карты и данных
### 🚀 **Архитектура готова к продакшену:**
```
MainActivity
├── AppCoordinator (координация)
│ ├── NMEAController (NMEA парсинг)
│ ├── NetworkController (UDP)
│ ├── DataController (БД)
│ ├── NotificationController (уведомления)
│ └── MapController (карты)
└── CompassController (компас)
```
### 📊 **Статистика:**
- **Создано файлов**: 6 новых контроллеров
- **Исправлено ошибок компиляции**: 27
- **Добавлено методов**: 12
- **Обновлено файлов**: 7
- **Удалено TODO**: 15
### 🎉 **Итог:**
**Рефакторинг ПОЛНОСТЬЮ ЗАВЕРШЕН!**
**Проект компилируется без ошибок**
**Все методы реализованы**
**Архитектура соответствует SOLID принципам**
**Код готов к тестированию и продакшену**
**Готов к реализации Strategy Pattern**
**Монолитный AppController успешно разбит на специализированные контроллеры с четким разделением ответственностей!** 🎯
-97
View File
@@ -1,97 +0,0 @@
# 🏗️ Новая архитектура приложения - Рефакторинг AppController
## ✅ Выполненный рефакторинг
Мы успешно разбили монолитный `AppController` на отдельные контроллеры согласно принципам SOLID:
### 📦 Созданные контроллеры:
#### 1. **NMEAController**
- **Ответственность**: Парсинг NMEA сообщений
- **Интерфейсы**: `NMEAParserListener`, `NMEAMessageCallback`
- **Методы**: `parseNMEAMessage()`, `startAndroidNMEAListener()`, `setDataMode()`
#### 2. **NetworkController**
- **Ответственность**: UDP слушание и отправка данных
- **Интерфейсы**: `UDPListenerCallback`
- **Методы**: `startUDPListener()`, `sendUDPData()`, `setUDPPort()`
#### 3. **DataController**
- **Ответственность**: Операции с базой данных
- **Интерфейсы**: `DataControllerListener`
- **Методы**: `saveVesselPosition()`, `saveAISVessel()`, `restoreDataAsync()`
#### 4. **NotificationController**
- **Ответственность**: Управление уведомлениями
- **Интерфейсы**: `NotificationControllerListener`
- **Методы**: `notifyNewAISTarget()`, `notifySafetyMessage()`
#### 5. **CompassController**
- **Ответственность**: Управление магнитным компасом
- **Интерфейсы**: `CompassListener`
- **Методы**: `startCompass()`, `updateCompassWithVesselData()`
#### 6. **AppCoordinator** (Главный координатор)
- **Ответственность**: Координация между всеми контроллерами
- **Интерфейсы**: Все listener'ы контроллеров + `MarkerClickListener`, `MapInterfaceChangeListener`
- **Методы**: `startAllControllers()`, `applySettings()`, `cleanup()`
### 🔄 Обновленный MainActivity:
- Заменил `AppController` на `AppCoordinator`
- Добавил `CompassController` для управления компасом
- Упростил методы `applySettings()`, `startControllers()`, `onDestroy()`
- Обновил callback'и для работы с новой архитектурой
## 🎯 Преимущества новой архитектуры:
### ✅ **Single Responsibility Principle (SRP)**
- Каждый контроллер отвечает только за одну область
- Легко понять назначение каждого класса
- Простое тестирование отдельных компонентов
### ✅ **Open/Closed Principle (OCP)**
- Легко добавлять новые типы контроллеров
- Расширение функциональности без изменения существующего кода
### ✅ **Dependency Inversion Principle (DIP)**
- Контроллеры зависят от абстракций (интерфейсов)
- AppCoordinator координирует, но не создает зависимости напрямую
### ✅ **Улучшенная читаемость**
- MainActivity стал значительно проще
- Четкое разделение ответственностей
- Легче найти и исправить баги
### ✅ **Готовность к Strategy Pattern**
- MapController уже готов для разных SDK
- Легко добавить новые типы карт
- Четкое разделение логики карты и данных
## 🚀 Следующие шаги:
1. **Добавить недостающие методы в AppCoordinator**:
- `getSecondsSinceLastGPSMessage()`
- `getSecondsSinceLastAISMessage()`
- `centerOnOwnVessel()`
- Методы управления UDP/GPS
2. **Создать LocationController** (если нужен отдельно от NMEA)
3. **Протестировать новую архитектуру**
4. **Обновить документацию**
## 📊 Результат:
**До рефакторинга**: 1 монолитный класс с 12+ ответственностями
**После рефакторинга**: 6 специализированных контроллеров + 1 координатор
**Код стал**:
- ✅ Более читаемым
- ✅ Легче тестируемым
- ✅ Проще расширяемым
- ✅ Соответствующим SOLID принципам
- ✅ Готовым к Strategy Pattern
🎉 **Рефакторинг успешно завершен!**
-116
View File
@@ -1,116 +0,0 @@
# Анализ и исправление зависаний UI в MainActivity
## Выявленные проблемы:
### 1. **Основная причина зависания**: `updateControlPanelPosition()`
- Функция вызывается **слишком часто** (7+ мест вызова)
- Выполняет **дорогие операции в главном потоке**:
- Множественные `getHeight()` вызывают **layout pass**
- `setLayoutParams()` - одна из **самых дорогих операций** в Android UI
- Множество логирования в главном потоке
- Вызывается каждые несколько секунд из-за автоматических обновлений
### 2. **Цепочка блокировок**:
```
coordinatesWidget.updateVessel()
→ invalidate()
→ onDraw()
→ getHeight()
→ onDockResize callback
→ updateControlPanelPosition()
→ setLayoutParams() ← BLOCKING!
```
### 3. **Множественные UI обновления**:
- `messageAgeRunnable` - каждую секунду
- `bottomSheetUpdateRunnable` - каждую секунду
- `timeUpdateRunnable` - каждую секунду
- Все в главном UI потоке без throttling
## Внесенные исправления:
### 1. **Throttling для `updateControlPanelPosition`**:
```java
// Добавлены переменные для throttling
private android.os.Handler controlPanelUpdateHandler;
private Runnable controlPanelUpdateRunnable;
private boolean controlPanelUpdatePending = false;
private static final long CONTROL_PANEL_UPDATE_DELAY = 200; // 200ms throttling
// Переработана функция с оптимизациями
private void updateControlPanelPositionSafe() {
// Проверки на нулевые размеры (избегаем layout pass)
if (compassHeight <= 0) return;
if (coordinatesHeight <= 0) return;
// Изменения только если отличаются от текущих
if (params.topMargin != topMargin || params.bottomMargin != bottomMargin) {
// Применяем изменения
}
}
```
### 2. **Безопасные UI обновления**:
```java
private void updateVesselPositionUI(Vessel vessel) {
if (isFinishing() || isDestroyed()) return; // Защита
runOnUiThread(() -> {
try {
updateUIActivity(); // Обновляем watchdog
// ... безопасные операции
} catch (Exception e) {
Log.e(TAG, "Ошибка в updateVesselPositionUI: " + e.getMessage(), e);
}
});
}
```
### 3. **Дополнительная диагностика**:
```java
// Добавлен счетчик вызовов updateControlPanelPosition
private int controlPanelUpdateCount = 0;
// Улучшен UI Watchdog с диагностикой handler'ов
Log.i(TAG, "UI WATCHDOG: Handler status - " +
"watchdog=" + watchdogActive +
", controlPanelCount=" + controlPanelUpdateCount);
// Принудительная остановка при превышении лимита
if (controlPanelUpdateCount > 50) {
// Останавливаем слишком частые обновления
}
```
### 4. **Очистка ресурсов**:
```java
@Override
protected void onDestroy() {
// Добалена очистка throttling handler'а
if (controlPanelUpdateHandler != null) {
controlPanelUpdateHandler.removeCallbacks(controlPanelUpdateRunnable);
}
}
```
## Ожидаемый результат:
1. **Значительное снижение нагрузки** на главный UI поток
2. **Устранение блокировок** от `setLayoutParams()`
3. **Throttling обновлений** control panel до безопасного уровня
4. **Улучшенная диагностика** для понимания проблем в рантайме
5. **Автоматическое восстановление** при превышении лимитов
## Мониторинг:
Следите за логами:
- `"Control panel updates count: X за последние 10 сек"` - количество обновлений
- `"UI WATCHDOG: Handler status"` - состояние всех handler'ов
- `"Control panel updated: top=X, bottom=Y"` - фактические обновления
## Если проблема остается:
1. Проверьте количество вызовов updateControlPanelPosition
2. Рассмотрите полную отключение тестового обновления coordinatesWidget
3. Увеличьте CONTROL_PANEL_UPDATE_DELAY до 500мс
4. Добавьте дополнительный throttling для BottomSheet обновлений
-95
View File
@@ -1,95 +0,0 @@
# Оптимизации производительности UI для устранения зависаний
## Проблемы, которые были исправлены:
### 1. Избыточные периодические обновления маркеров
**Проблема:** YandexMarkerManager обновлял все маркеры каждые 2 секунды
**Решение:**
- Увеличен интервал до 10 секунд
- Изменена логика: теперь проверяется только валидность маркеров, а не полное пересоздание
### 2. Частые обновления камеры карты
**Проблема:** Слушатель камеры срабатывал каждые 50мс
**Решение:**
- Увеличен throttling до 200мс
- Увеличена чувствительность изменения зума с 0.5 до 1.0
- Оптимизирована логика обновления маркеров
### 3. Множественные Handler'ы в UI потоке
**Проблема:** Слишком частые обновления UI элементов
**Решение:**
- MainActivity: интервал обновления сообщений увеличен с 1 до 2 секунд
- BottomSheet: интервал обновления увеличен с 1 до 3 секунд
- AisTargetsActivity: интервал обновления увеличен с 1 до 2 секунд
### 4. Частые операции с layout
**Проблема:** updateControlPanelPosition() вызывался слишком часто
**Решение:**
- Добавлен throttling с задержкой 50мс
- Добавлена обработка исключений
### 5. Операции с картой без throttling
**Проблема:** Обновления позиции судна на карте без задержки
**Решение:**
- Добавлен throttling с задержкой 100мс для обновлений карты
## Дополнительные рекомендации:
### 1. Мониторинг производительности
Добавьте логирование времени выполнения операций:
```java
long startTime = System.currentTimeMillis();
// операция
long duration = System.currentTimeMillis() - startTime;
if (duration > 16) { // больше одного кадра (60 FPS)
Log.w(TAG, "Медленная операция: " + duration + "мс");
}
```
### 2. Оптимизация RecyclerView
В AisTargetsAdapter добавьте:
```java
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position, @NonNull List<Object> payloads) {
if (payloads.isEmpty()) {
super.onBindViewHolder(holder, position, payloads);
} else {
// Обновляем только измененные поля
for (Object payload : payloads) {
if ("time_update".equals(payload)) {
updateTimeAgo(holder);
}
}
}
}
```
### 3. Использование ViewStub для тяжелых компонентов
Для компонентов, которые не всегда видны, используйте ViewStub.
### 4. Оптимизация изображений маркеров
- Используйте кеширование Bitmap'ов
- Предварительно масштабируйте изображения
- Используйте hardware acceleration где возможно
### 5. Мониторинг памяти
Добавьте проверки на утечки памяти:
```java
if (BuildConfig.DEBUG) {
Runtime runtime = Runtime.getRuntime();
long usedMemory = runtime.totalMemory() - runtime.freeMemory();
long maxMemory = runtime.maxMemory();
if (usedMemory > maxMemory * 0.8) {
Log.w(TAG, "Высокое использование памяти: " + (usedMemory / 1024 / 1024) + "MB");
}
}
```
## Результат оптимизаций:
- Снижена частота обновлений UI в 2-5 раз
- Добавлен throttling для предотвращения блокировок
- Улучшена обработка ошибок
- Снижена нагрузка на главный поток
Эти изменения должны значительно уменьшить зависания UI и улучшить общую производительность приложения.
+6113
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -6,6 +6,10 @@ android {
namespace 'com.grigowashere.aismap' namespace 'com.grigowashere.aismap'
compileSdk 35 compileSdk 35
buildFeatures {
buildConfig true
}
defaultConfig { defaultConfig {
applicationId "com.grigowashere.aismap" applicationId "com.grigowashere.aismap"
minSdk 30 minSdk 30
@@ -56,6 +60,10 @@ dependencies {
// MapLibre GL Android SDK (используем только один артефакт, без плагина аннотаций) // MapLibre GL Android SDK (используем только один артефакт, без плагина аннотаций)
implementation group: 'org.maplibre.gl', name: 'android-sdk-opengl', version: '11.13.5' implementation group: 'org.maplibre.gl', name: 'android-sdk-opengl', version: '11.13.5'
// MessagePack — компактная бинарная сериализация для BLE Hub снапшотов
// (см. ble_gatt.py: AIS_BLE_BROADCAST_ENCODING=msgpack)
implementation 'org.msgpack:msgpack-core:0.9.8'
// Тестирование // Тестирование
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+17 -2
View File
@@ -20,6 +20,13 @@
<!-- Разрешения для UDP --> <!-- Разрешения для UDP -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <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" /> <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.gps" android:required="false" />
<uses-feature android:name="android.hardware.location" 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:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
@@ -47,7 +56,7 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden" android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/Theme.AISMap" android:theme="@style/Theme.AISMap.Map"
android:keepScreenOn="true"> android:keepScreenOn="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -60,6 +69,12 @@
android:exported="false" android:exported="false"
android:configChanges="orientation|screenSize|keyboardHidden" android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/Theme.AISMap" /> android:theme="@style/Theme.AISMap" />
<activity
android:name=".settings.InterfacesSettingsActivity"
android:exported="false"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/Theme.AISMap" />
<activity <activity
android:name=".AisTargetsActivity" android:name=".AisTargetsActivity"
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -22,6 +22,10 @@ import android.view.WindowManager;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat; 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.google.android.material.bottomsheet.BottomSheetDialog;
import com.grigowashere.aismap.controllers.AppCoordinator; import com.grigowashere.aismap.controllers.AppCoordinator;
@@ -64,6 +68,9 @@ public class MainActivity extends AppCompatActivity {
// Статическая переменная для отслеживания инициализации Яндекс.Карт // Статическая переменная для отслеживания инициализации Яндекс.Карт
private static boolean isYandexMapsInitialized = false; private static boolean isYandexMapsInitialized = false;
// Флаг для отслеживания первого запуска приложения
private boolean isFirstStart = true;
private AppCoordinator appCoordinator; private AppCoordinator appCoordinator;
// UI binders // UI binders
private MenuBinder menuBinder; private MenuBinder menuBinder;
@@ -80,6 +87,7 @@ public class MainActivity extends AppCompatActivity {
private ImageButton btnCursorToggle; private ImageButton btnCursorToggle;
private ImageButton btnSettings; private ImageButton btnSettings;
private ImageButton btnAisTargets; private ImageButton btnAisTargets;
private ImageButton btnGpsSource;
private LinearLayout controlPanel; private LinearLayout controlPanel;
private CompassView compassView; private CompassView compassView;
private CoordinatesDockWidget coordinatesWidget; private CoordinatesDockWidget coordinatesWidget;
@@ -95,6 +103,36 @@ public class MainActivity extends AppCompatActivity {
private static final long UI_UPDATE_THROTTLE_MS = 200; // 5 FPS максимум private static final long UI_UPDATE_THROTTLE_MS = 200; // 5 FPS максимум
private TextView tvGpsAge; private TextView tvGpsAge;
private TextView tvAisAge; 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 android.os.Handler messageAgeHandler;
private Runnable messageAgeRunnable; private Runnable messageAgeRunnable;
private BottomSheetsManager bottomSheetsManager; private BottomSheetsManager bottomSheetsManager;
@@ -119,6 +157,10 @@ public class MainActivity extends AppCompatActivity {
private long lastUIUpdateTime = 0; private long lastUIUpdateTime = 0;
private static final long UI_WATCHDOG_INTERVAL = 1000; // 1 секунда - быстрая диагностика private static final long UI_WATCHDOG_INTERVAL = 1000; // 1 секунда - быстрая диагностика
private static final long UI_TIMEOUT = 3000; // 3 секунды без обновлений = зависание 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; private long lastCompassLogTime = 0;
@@ -144,7 +186,18 @@ public class MainActivity extends AppCompatActivity {
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Ошибка инициализации MapLibre: " + e.getMessage(), 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); setContentView(R.layout.activity_main);
initializeViews(); initializeViews();
@@ -165,9 +218,11 @@ public class MainActivity extends AppCompatActivity {
btnCursorToggle = findViewById(R.id.btn_cursor_toggle); btnCursorToggle = findViewById(R.id.btn_cursor_toggle);
btnSettings = findViewById(R.id.btn_settings); btnSettings = findViewById(R.id.btn_settings);
btnAisTargets = findViewById(R.id.btn_ais_targets); btnAisTargets = findViewById(R.id.btn_ais_targets);
btnGpsSource = findViewById(R.id.btn_gps_source);
controlPanel = findViewById(R.id.control_panel); controlPanel = findViewById(R.id.control_panel);
compassView = findViewById(R.id.compass_view); compassView = findViewById(R.id.compass_view);
coordinatesWidget = findViewById(R.id.coordinates_widget); coordinatesWidget = findViewById(R.id.coordinates_widget);
installMainUiInsets();
// Инициализируем троттлинг // Инициализируем троттлинг
uiThrottleHandler = new android.os.Handler(android.os.Looper.getMainLooper()); uiThrottleHandler = new android.os.Handler(android.os.Looper.getMainLooper());
@@ -206,6 +261,10 @@ public class MainActivity extends AppCompatActivity {
} }
} }
} }
// В режимах «по компасу» / «по курсу» непрерывно подстраиваем bearing
// карты; в «вручную» не трогаем — пользователь крутит жестом.
applyAutoMapBearingIfNeeded(mapIf);
} }
} catch (Exception ignore) {} } catch (Exception ignore) {}
// Планируем следующее обновление // Планируем следующее обновление
@@ -215,6 +274,9 @@ public class MainActivity extends AppCompatActivity {
}; };
tvGpsAge = findViewById(R.id.tv_gps_age); tvGpsAge = findViewById(R.id.tv_gps_age);
tvAisAge = findViewById(R.id.tv_ais_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 // Инициализируем магнитный компас через CompassController
// compassSensor = new CompassSensor(this); // Удалено - теперь используется CompassController // compassSensor = new CompassSensor(this); // Удалено - теперь используется CompassController
@@ -231,11 +293,17 @@ public class MainActivity extends AppCompatActivity {
private void setupButtonListeners() { private void setupButtonListeners() {
if (btnCenterOnVessel != null) btnCenterOnVessel.setOnClickListener(v -> centerOnVessel()); if (btnCenterOnVessel != null) btnCenterOnVessel.setOnClickListener(v -> centerOnVessel());
if (btnMapOrientation != null) btnMapOrientation.setOnClickListener(v -> toggleMapOrientation()); if (btnMapOrientation != null) {
btnMapOrientation.setOnClickListener(v -> cycleMapRotationMode());
}
if (btnCursorToggle != null) btnCursorToggle.setOnClickListener(v -> toggleCursor()); if (btnCursorToggle != null) btnCursorToggle.setOnClickListener(v -> toggleCursor());
if (btnCursorToggle != null) btnCursorToggle.setOnLongClickListener(v -> { toggleCursor(); return true; }); if (btnCursorToggle != null) btnCursorToggle.setOnLongClickListener(v -> { toggleCursor(); return true; });
if (btnSettings != null) btnSettings.setOnClickListener(v -> showSettings()); if (btnSettings != null) btnSettings.setOnClickListener(v -> showSettings());
if (btnAisTargets != null) btnAisTargets.setOnClickListener(v -> openAisTargets()); if (btnAisTargets != null) btnAisTargets.setOnClickListener(v -> openAisTargets());
if (btnGpsSource != null) {
refreshGpsSourceButtonIcon();
btnGpsSource.setOnClickListener(v -> toggleGpsSource());
}
// Кнопка для показа информации о судне // Кнопка для показа информации о судне
// Button btnShowVesselInfo = findViewById(R.id.btn_show_vessel_info); // Button btnShowVesselInfo = findViewById(R.id.btn_show_vessel_info);
@@ -252,6 +320,9 @@ public class MainActivity extends AppCompatActivity {
compassView.post(() -> { compassView.post(() -> {
compassView.setDocked(true, true, 0, 0); compassView.setDocked(true, true, 0, 0);
compassView.invalidate(); // Принудительная отрисовка compassView.invalidate(); // Принудительная отрисовка
// Выровнять паддинги под статус-бар/вырез камеры сразу после
// первого dock-позиционирования (до этого сторона неизвестна).
reapplyInsetsToDocks();
}); });
// Настраиваем слушатель изменения размера док-виджета // Настраиваем слушатель изменения размера док-виджета
@@ -269,6 +340,9 @@ public class MainActivity extends AppCompatActivity {
BaseDockWidget.repositionAllDockedWidgets((ViewGroup) compassView.getParent()); BaseDockWidget.repositionAllDockedWidgets((ViewGroup) compassView.getParent());
updateControlPanelPosition(); updateControlPanelPosition();
// Док мог поменять сторону — паддинги под системные бары
// тоже должны переключиться (top <-> bottom).
reapplyInsetsToDocks();
}); });
//smt changed //smt changed
// Настраиваем магнитный компас через CompassController // Настраиваем магнитный компас через CompassController
@@ -362,6 +436,9 @@ public class MainActivity extends AppCompatActivity {
BaseDockWidget.repositionAllDockedWidgets((ViewGroup) coordinatesWidget.getParent()); BaseDockWidget.repositionAllDockedWidgets((ViewGroup) coordinatesWidget.getParent());
updateControlPanelPosition(); updateControlPanelPosition();
// Перекидываем системные паддинги в нужную сторону под новую
// дока — чтобы под нав-баром/брови ничего не оставалось.
reapplyInsetsToDocks();
}); });
// Устанавливаем виджет координат в dock-режим внизу экрана без тестовых данных // Устанавливаем виджет координат в dock-режим внизу экрана без тестовых данных
@@ -369,6 +446,10 @@ public class MainActivity extends AppCompatActivity {
Log.d(TAG, "Setting coordinates widget to dock mode"); Log.d(TAG, "Setting coordinates widget to dock mode");
coordinatesWidget.setDocked(true, false, 0, 0); // false = dock снизу coordinatesWidget.setDocked(true, false, 0, 0); // false = dock снизу
coordinatesWidget.invalidate(); // Принудительная отрисовка coordinatesWidget.invalidate(); // Принудительная отрисовка
// Только сейчас мы знаем сторону дока (bottom) — переприменяем
// инсеты, чтобы виджет получил bottom padding под нав-бар
// сразу, а не только после первого ресайза пользователем.
reapplyInsetsToDocks();
}); });
} }
@@ -390,6 +471,20 @@ public class MainActivity extends AppCompatActivity {
tvAisAge.setText(aisSec >= 0 ? ("AIS: " + aisSec + " сек назад") : "AIS: --"); tvAisAge.setText(aisSec >= 0 ? ("AIS: " + aisSec + " сек назад") : "AIS: --");
tvAisAge.setTextColor(getAgeColor(aisSec)); 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) {} } catch (Exception ignored) {}
messageAgeHandler.postDelayed(this, 1000); messageAgeHandler.postDelayed(this, 1000);
@@ -412,6 +507,17 @@ public class MainActivity extends AppCompatActivity {
return Color.parseColor("#F44336"); // красный 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) { private void onUpdateCompass(float azimuth, List<AISVessel> nearbyVessels) {
if (compassView != null) { if (compassView != null) {
@@ -474,42 +580,50 @@ public class MainActivity extends AppCompatActivity {
* Настраивает UI watchdog для отслеживания зависаний * Настраивает UI watchdog для отслеживания зависаний
*/ */
private void setupUIWatchdog() { private void setupUIWatchdog() {
// ВАЖНО: watchdog не должен работать на UI Looper, иначе он не может детектить настоящий hang.
// Поэтому тикер в фоне, а "pong" — маленькая задачка на UI.
uiWatchdogHandler = new android.os.Handler(android.os.Looper.getMainLooper()); uiWatchdogHandler = new android.os.Handler(android.os.Looper.getMainLooper());
uiWatchdogRunnable = new Runnable() { lastUIUpdateTime = System.currentTimeMillis();
@Override lastUiPongUptimeMs = android.os.SystemClock.uptimeMillis();
public void run() {
long currentTime = System.currentTimeMillis(); // Заглушка для обратной совместимости (на него ссылаются логи/tryRecoverFromUIHang)
long timeSinceLastUpdate = currentTime - lastUIUpdateTime; uiWatchdogRunnable = () -> {};
if (timeSinceLastUpdate > UI_TIMEOUT) { try {
Log.e(TAG, "🚨 UI WATCHDOG: UI ЗАВИС! Последнее обновление " + uiWatchdogScheduler.scheduleAtFixedRate(() -> {
(timeSinceLastUpdate / 1000) + " секунд назад"); final long pingUptime = android.os.SystemClock.uptimeMillis();
Log.e(TAG, "🚨 UI WATCHDOG: Время зависания: " + new java.util.Date(currentTime)); try {
Log.e(TAG, "🚨 UI WATCHDOG: Thread: " + Thread.currentThread().getName()); uiWatchdogHandler.post(() -> {
// Дамп стека главного потока и нескольких рабочих потоков // "pong": если это исполнилось — UI Looper жив.
dumpThreadStacksForDiagnostics(); lastUiPongUptimeMs = android.os.SystemClock.uptimeMillis();
updateUIActivity();
// Попытка восстановления });
tryRecoverFromUIHang(); } catch (Throwable ignore) {}
} else {
// Логируем каждые 10 секунд для мониторинга long sincePong = pingUptime - lastUiPongUptimeMs;
if (timeSinceLastUpdate > 0 && (timeSinceLastUpdate / 1000) % 10 == 0) { if (sincePong > UI_TIMEOUT) {
Log.i(TAG, "✅ UI WATCHDOG: UI активен, последнее обновление " + // Не спамим логом каждую секунду.
(timeSinceLastUpdate / 1000) + " секунд назад"); if (pingUptime - lastUiHangLogUptimeMs > 10_000L) {
lastUiHangLogUptimeMs = pingUptime;
Log.e(TAG, "🚨 UI WATCHDOG: UI возможно завис (main looper не отвечает) " +
(sincePong / 1000) + " секунд");
dumpThreadStacksForDiagnosticsAsync();
// Recovery — только если UI хоть как-то отвечает (иначе бесполезно)
tryRecoverFromUIHang();
} }
} }
}, UI_WATCHDOG_INTERVAL, UI_WATCHDOG_INTERVAL, java.util.concurrent.TimeUnit.MILLISECONDS);
// Планируем следующую проверку } catch (Throwable t) {
uiWatchdogHandler.postDelayed(this, UI_WATCHDOG_INTERVAL); Log.e(TAG, "UI watchdog: не удалось запустить scheduler: " + t.getMessage(), t);
} }
};
Log.i(TAG, "UI watchdog запущен (background)");
// Запускаем watchdog
lastUIUpdateTime = System.currentTimeMillis();
uiWatchdogHandler.postDelayed(uiWatchdogRunnable, UI_WATCHDOG_INTERVAL);
Log.i(TAG, "UI watchdog запущен");
} }
private final java.util.concurrent.ExecutorService watchdogExecutor =
java.util.concurrent.Executors.newSingleThreadExecutor();
private volatile long lastRecoveryAttemptMs = 0L;
/** /**
* Обновляет время последней активности UI * Обновляет время последней активности UI
*/ */
@@ -531,6 +645,14 @@ public class MainActivity extends AppCompatActivity {
Log.w(TAG, "UI WATCHDOG: Попытка восстановления..."); Log.w(TAG, "UI WATCHDOG: Попытка восстановления...");
try { try {
long now = System.currentTimeMillis();
// Не долбим восстановлением каждую секунду — это само может стать причиной лагов
if (now - lastRecoveryAttemptMs < 10_000L) {
Log.i(TAG, "UI WATCHDOG: восстановление пропущено (throttle)");
return;
}
lastRecoveryAttemptMs = now;
// Диагностика: проверяем состояние handler'ов // Диагностика: проверяем состояние handler'ов
boolean watchdogActive = uiWatchdogHandler != null && uiWatchdogRunnable != null; boolean watchdogActive = uiWatchdogHandler != null && uiWatchdogRunnable != null;
boolean messageAgeActive = messageAgeHandler != null && messageAgeRunnable != null; boolean messageAgeActive = messageAgeHandler != null && messageAgeRunnable != null;
@@ -544,11 +666,12 @@ public class MainActivity extends AppCompatActivity {
", controlPanel=" + controlPanelActive + ", controlPanel=" + controlPanelActive +
", controlPanelCount=" + controlPanelUpdateCount); ", controlPanelCount=" + controlPanelUpdateCount);
// Принудительная сборка мусора // ВАЖНО: никаких тяжёлых операций (System.gc) на UI-потоке.
System.gc(); // Если нужно, можно поставить фоновой GC после лагов, но это диагностическая функция,
// а не recovery, поэтому здесь намеренно ничего не делаем.
// Проверяем состояние основных компонентов // Проверяем состояние основных компонентов
if (mapController.getCurrentMapInterface() == null) { if (mapController != null && mapController.getCurrentMapInterface() == null) {
Log.w(TAG, "UI WATCHDOG: mapInterface == 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 { try {
java.util.Map<Thread, StackTraceElement[]> all = Thread.getAllStackTraces(); java.util.Map<Thread, StackTraceElement[]> all = Thread.getAllStackTraces();
Thread main = Looper.getMainLooper().getThread(); Thread main = Looper.getMainLooper().getThread();
@@ -675,6 +806,32 @@ public class MainActivity extends AppCompatActivity {
Log.i(TAG, "Режим экрана переключен: keepScreenOn=" + keepScreenOn); 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() { private void initializeControllers() {
// Инициализация менеджера настроек // Инициализация менеджера настроек
settingsManager = new SettingsManager(this); settingsManager = new SettingsManager(this);
@@ -694,6 +851,7 @@ public class MainActivity extends AppCompatActivity {
@Override public void togglePathTracking() { MainActivity.this.togglePathTracking(); } @Override public void togglePathTracking() { MainActivity.this.togglePathTracking(); }
@Override public void testForegroundService() { MainActivity.this.testForegroundService(); } @Override public void testForegroundService() { MainActivity.this.testForegroundService(); }
@Override public void toggleKeepScreenOn() { MainActivity.this.toggleKeepScreenOn(); } @Override public void toggleKeepScreenOn() { MainActivity.this.toggleKeepScreenOn(); }
@Override public void toggleSeamarks() { MainActivity.this.toggleSeamarks(); }
}); });
// Не используем BottomSheetsBinder, оставляем рабочую реализацию в MainActivity // Не используем BottomSheetsBinder, оставляем рабочую реализацию в MainActivity
permissionsBinder = new PermissionsBinder(this); permissionsBinder = new PermissionsBinder(this);
@@ -751,6 +909,7 @@ public class MainActivity extends AppCompatActivity {
} }
} }
}); });
refreshMapRotationButtonDescription();
} }
private void startControllers() { private void startControllers() {
@@ -851,20 +1010,106 @@ public class MainActivity extends AppCompatActivity {
Toast.makeText(this, "Карта центрирована на судне", Toast.LENGTH_SHORT).show(); Toast.makeText(this, "Карта центрирована на судне", Toast.LENGTH_SHORT).show();
} }
private void toggleMapOrientation() { private static float normalizeBearingTo360(double deg) {
if (mapController.getCurrentMapInterface() == null) return; 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 { try {
float current = mapController.getCurrentMapInterface().getBearing(); MapInterface map = mapController.getCurrentMapInterface();
// Простейший toggle: если близко к северу — повернуть на 45°, иначе выровнять по северу applyMapRotationForMode(map, mode, true);
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();
}
} catch (Exception e) { } 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); 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(); 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 будет добавлено позже // Отслеживание путей для MapLibre будет добавлено позже
@@ -1149,6 +1572,12 @@ public class MainActivity extends AppCompatActivity {
// Перезапускаем цикл поворота кнопок после возврата в активити // Перезапускаем цикл поворота кнопок после возврата в активити
startCompassButtonsLoop(); startCompassButtonsLoop();
// Старт FPS трекера
if (tvFps != null) {
android.view.Choreographer.getInstance().removeFrameCallback(fpsCallback);
android.view.Choreographer.getInstance().postFrameCallback(fpsCallback);
}
} }
@Override @Override
@@ -1233,6 +1662,9 @@ public class MainActivity extends AppCompatActivity {
uiThrottleHandler.removeCallbacks(coordinatesUpdateRunnable); uiThrottleHandler.removeCallbacks(coordinatesUpdateRunnable);
uiThrottleHandler.removeCallbacks(compassButtonRotationRunnable); uiThrottleHandler.removeCallbacks(compassButtonRotationRunnable);
} }
// Останавливаем FPS трекер
try { android.view.Choreographer.getInstance().removeFrameCallback(fpsCallback); } catch (Exception ignore) {}
// Не останавливаем слушатели здесь, чтобы UDP продолжал работать в фоне // Не останавливаем слушатели здесь, чтобы UDP продолжал работать в фоне
// if (appController != null) { // if (appController != null) {
@@ -1275,6 +1707,7 @@ public class MainActivity extends AppCompatActivity {
uiWatchdogHandler.removeCallbacks(uiWatchdogRunnable); uiWatchdogHandler.removeCallbacks(uiWatchdogRunnable);
Log.i(TAG, "UI watchdog остановлен"); Log.i(TAG, "UI watchdog остановлен");
} }
try { uiWatchdogScheduler.shutdownNow(); } catch (Throwable ignore) {}
// Останавливаем throttling handler для control panel // Останавливаем throttling handler для control panel
if (controlPanelUpdateHandler != null && controlPanelUpdateRunnable != null) { if (controlPanelUpdateHandler != null && controlPanelUpdateRunnable != null) {
@@ -1385,6 +1818,12 @@ public class MainActivity extends AppCompatActivity {
((MapLibreMapImpl) mapController.getCurrentMapInterface()).setDebugMode(debugEnabled); ((MapLibreMapImpl) mapController.getCurrentMapInterface()).setDebugMode(debugEnabled);
} }
// Применяем настройки морских знаков
boolean seamarksEnabled = data.getBooleanExtra("seamarks_enabled", settingsManager.isSeamarksEnabled());
if (mapController.getCurrentMapInterface() instanceof MapLibreMapImpl) {
((MapLibreMapImpl) mapController.getCurrentMapInterface()).updateAdditionalLayers();
}
if (needsRestart) { if (needsRestart) {
Log.i(TAG, "Требуется перезапуск сервисов"); Log.i(TAG, "Требуется перезапуск сервисов");
restartServices(); restartServices();
@@ -1392,7 +1831,8 @@ public class MainActivity extends AppCompatActivity {
Log.i(TAG, "Применяем настройки без перезапуска"); Log.i(TAG, "Применяем настройки без перезапуска");
applySettings(); applySettings();
} }
refreshGpsSourceButtonIcon();
Toast.makeText(this, "Настройки применены", Toast.LENGTH_SHORT).show(); 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) { } else if (id == R.id.menu_keep_screen_on) {
toggleKeepScreenOn(); toggleKeepScreenOn();
return true; return true;
} else if (id == R.id.menu_seamarks) {
toggleSeamarks();
return true;
} }
return super.onOptionsItemSelected(item); return super.onOptionsItemSelected(item);
} }
@@ -8,6 +8,8 @@ import android.widget.EditText;
import android.widget.RadioButton; import android.widget.RadioButton;
import android.widget.RadioGroup; import android.widget.RadioGroup;
import android.widget.Toast; import android.widget.Toast;
import android.view.View;
import android.widget.LinearLayout;
import com.google.android.material.switchmaterial.SwitchMaterial; import com.google.android.material.switchmaterial.SwitchMaterial;
@@ -33,15 +35,24 @@ public class SettingsActivity extends AppCompatActivity {
private RadioButton radioHybridMode; private RadioButton radioHybridMode;
private RadioButton radioNMEAOnly; private RadioButton radioNMEAOnly;
private RadioButton radioAndroidOnly; private RadioButton radioAndroidOnly;
private RadioGroup radioGroupGpsSource;
private RadioButton radioGpsSourceHub;
private RadioButton radioGpsSourceAndroid;
private SwitchMaterial switchShowAdvancedNmea;
private LinearLayout llAdvancedNmeaSection;
private EditText etStaleWarningMinutes; private EditText etStaleWarningMinutes;
private EditText etStaleRemoveMinutes; private EditText etStaleRemoveMinutes;
private SwitchMaterial switchVibrationEnabled; private SwitchMaterial switchVibrationEnabled;
private SwitchMaterial switchSoundEnabled; private SwitchMaterial switchSoundEnabled;
private SwitchMaterial switchKeepScreenOn; private SwitchMaterial switchKeepScreenOn;
private SwitchMaterial switchDebugEnabled; private SwitchMaterial switchDebugEnabled;
private SwitchMaterial switchSeamarksEnabled;
private Button btnCancel; private Button btnCancel;
private Button btnSave; private Button btnSave;
private Button btnClearPath; private Button btnClearPath;
private Button btnOpenInterfaces;
private com.google.android.material.textfield.TextInputLayout tilOpenInterfaces;
private EditText etOpenInterfaces;
// Path/prediction // Path/prediction
private EditText etPathMaxPoints; private EditText etPathMaxPoints;
@@ -92,19 +103,29 @@ public class SettingsActivity extends AppCompatActivity {
*/ */
private void initializeViews() { private void initializeViews() {
etUDPPort = findViewById(R.id.et_udp_port); 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); switchAndroidNMEAEnabled = findViewById(R.id.switch_android_nmea_enabled);
switchUDPNMEAEnabled = findViewById(R.id.switch_udp_nmea_enabled); switchUDPNMEAEnabled = findViewById(R.id.switch_udp_nmea_enabled);
radioGroupDataMode = findViewById(R.id.radio_group_data_mode); radioGroupDataMode = findViewById(R.id.radio_group_data_mode);
radioHybridMode = findViewById(R.id.radio_hybrid_mode); radioHybridMode = findViewById(R.id.radio_hybrid_mode);
radioNMEAOnly = findViewById(R.id.radio_nmea_only); radioNMEAOnly = findViewById(R.id.radio_nmea_only);
radioAndroidOnly = findViewById(R.id.radio_android_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); etStaleWarningMinutes = findViewById(R.id.et_stale_warning_minutes);
etStaleRemoveMinutes = findViewById(R.id.et_stale_remove_minutes); etStaleRemoveMinutes = findViewById(R.id.et_stale_remove_minutes);
switchVibrationEnabled = findViewById(R.id.switch_vibration_enabled); switchVibrationEnabled = findViewById(R.id.switch_vibration_enabled);
switchSoundEnabled = findViewById(R.id.switch_sound_enabled); switchSoundEnabled = findViewById(R.id.switch_sound_enabled);
switchKeepScreenOn = findViewById(R.id.switch_keep_screen_on); switchKeepScreenOn = findViewById(R.id.switch_keep_screen_on);
switchDebugEnabled = findViewById(R.id.switch_debug_enabled); switchDebugEnabled = findViewById(R.id.switch_debug_enabled);
switchSeamarksEnabled = findViewById(R.id.switch_seamarks_enabled);
btnCancel = findViewById(R.id.btn_cancel); btnCancel = findViewById(R.id.btn_cancel);
btnSave = findViewById(R.id.btn_save); btnSave = findViewById(R.id.btn_save);
btnClearPath = findViewById(R.id.btn_clear_path); btnClearPath = findViewById(R.id.btn_clear_path);
@@ -122,14 +143,23 @@ public class SettingsActivity extends AppCompatActivity {
*/ */
private void loadCurrentSettings() { private void loadCurrentSettings() {
// UDP настройки // UDP настройки
etUDPPort.setText(String.valueOf(settingsManager.getUDPPort())); if (etUDPPort != null) etUDPPort.setText(String.valueOf(settingsManager.getUDPPort()));
switchUDPEnabled.setChecked(settingsManager.isUDPEnabled()); if (switchUDPEnabled != null) switchUDPEnabled.setChecked(settingsManager.isUDPEnabled());
// NMEA настройки // NMEA настройки
switchAndroidNMEAEnabled.setChecked(settingsManager.isAndroidNMEAEnabled()); switchAndroidNMEAEnabled.setChecked(settingsManager.isAndroidNMEAEnabled());
switchUDPNMEAEnabled.setChecked(settingsManager.isUDPNMEAEnabled()); switchUDPNMEAEnabled.setChecked(settingsManager.isUDPNMEAEnabled());
// Режим данных // Источник координат (основной переключатель).
if (radioGpsSourceHub != null && radioGpsSourceAndroid != null) {
if (settingsManager.isGpsFromAndroid()) {
radioGpsSourceAndroid.setChecked(true);
} else {
radioGpsSourceHub.setChecked(true);
}
}
// Legacy режим данных (внутри расширенной секции).
String dataMode = settingsManager.getDataMode(); String dataMode = settingsManager.getDataMode();
switch (dataMode) { switch (dataMode) {
case SettingsManager.DATA_MODE_HYBRID: case SettingsManager.DATA_MODE_HYBRID:
@@ -156,6 +186,7 @@ public class SettingsActivity extends AppCompatActivity {
// Дебаг // Дебаг
switchDebugEnabled.setChecked(settingsManager.isDebugEnabled()); switchDebugEnabled.setChecked(settingsManager.isDebugEnabled());
switchSeamarksEnabled.setChecked(settingsManager.isSeamarksEnabled());
// Путь и предсказание // Путь и предсказание
etPathMaxPoints.setText(String.valueOf(settingsManager.getPathMaxPoints())); etPathMaxPoints.setText(String.valueOf(settingsManager.getPathMaxPoints()));
@@ -195,6 +226,22 @@ public class SettingsActivity extends AppCompatActivity {
Log.i(TAG, "Нажата кнопка отмены"); Log.i(TAG, "Нажата кнопка отмены");
finish(); 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 -> { btnSave.setOnClickListener(v -> {
@@ -222,6 +269,16 @@ public class SettingsActivity extends AppCompatActivity {
validateDataModeSettings(); 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() { private void saveSettings() {
try { try {
// Валидируем UDP порт // Валидируем UDP порт (поле могло быть перенесено на экран интерфейсов и отсутствовать в разметке)
String portText = etUDPPort.getText().toString().trim();
if (portText.isEmpty()) {
Toast.makeText(this, "Порт не может быть пустым", Toast.LENGTH_SHORT).show();
return;
}
int udpPort; int udpPort;
try { if (etUDPPort != null) {
udpPort = Integer.parseInt(portText); String portText = etUDPPort.getText().toString().trim();
if (udpPort < 1 || udpPort > 65535) { if (portText.isEmpty()) {
Toast.makeText(this, "Порт должен быть от 1 до 65535", Toast.LENGTH_SHORT).show(); Toast.makeText(this, "Порт не может быть пустым", Toast.LENGTH_SHORT).show();
return; return;
} }
} catch (NumberFormatException e) { try {
Toast.makeText(this, "Некорректный формат порта", Toast.LENGTH_SHORT).show(); udpPort = Integer.parseInt(portText);
return; 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); if (etUDPPort != null) settingsManager.setUDPPort(udpPort);
settingsManager.setUDPEnabled(switchUDPEnabled.isChecked()); if (switchUDPEnabled != null) settingsManager.setUDPEnabled(switchUDPEnabled.isChecked());
settingsManager.setAndroidNMEAEnabled(switchAndroidNMEAEnabled.isChecked()); settingsManager.setAndroidNMEAEnabled(switchAndroidNMEAEnabled.isChecked());
settingsManager.setUDPNMEAEnabled(switchUDPNMEAEnabled.isChecked()); settingsManager.setUDPNMEAEnabled(switchUDPNMEAEnabled.isChecked());
settingsManager.setDataMode(dataMode); 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.setDataStaleWarningMinutes(staleWarningMinutes);
settingsManager.setDataStaleRemoveMinutes(staleRemoveMinutes); settingsManager.setDataStaleRemoveMinutes(staleRemoveMinutes);
settingsManager.setVibrationEnabled(switchVibrationEnabled.isChecked()); settingsManager.setVibrationEnabled(switchVibrationEnabled.isChecked());
@@ -315,6 +385,10 @@ public class SettingsActivity extends AppCompatActivity {
settingsManager.setKeepScreenOnEnabled(switchKeepScreenOn.isChecked()); settingsManager.setKeepScreenOnEnabled(switchKeepScreenOn.isChecked());
boolean debugEnabled = switchDebugEnabled.isChecked(); boolean debugEnabled = switchDebugEnabled.isChecked();
settingsManager.setDebugEnabled(debugEnabled); settingsManager.setDebugEnabled(debugEnabled);
// Морские знаки
boolean seamarksEnabled = switchSeamarksEnabled.isChecked();
settingsManager.setSeamarksEnabled(seamarksEnabled);
// Путь и предсказание // Путь и предсказание
try { settingsManager.setPathMaxPoints(Integer.parseInt(etPathMaxPoints.getText().toString().trim())); } catch (Exception ignored) {} 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("settings_changed", true);
resultIntent.putExtra("needs_restart", needsRestart); resultIntent.putExtra("needs_restart", needsRestart);
resultIntent.putExtra("udp_port", udpPort); 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("android_nmea_enabled", switchAndroidNMEAEnabled.isChecked());
resultIntent.putExtra("udp_nmea_enabled", switchUDPNMEAEnabled.isChecked()); resultIntent.putExtra("udp_nmea_enabled", switchUDPNMEAEnabled.isChecked());
resultIntent.putExtra("data_mode", dataMode); resultIntent.putExtra("data_mode", dataMode);
resultIntent.putExtra("cursor_enabled", settingsManager.isCursorEnabled()); resultIntent.putExtra("cursor_enabled", settingsManager.isCursorEnabled());
resultIntent.putExtra("debug_enabled", debugEnabled); resultIntent.putExtra("debug_enabled", debugEnabled);
resultIntent.putExtra("seamarks_enabled", seamarksEnabled);
setResult(RESULT_OK, resultIntent); setResult(RESULT_OK, resultIntent);
@@ -0,0 +1,164 @@
package com.grigowashere.aismap.ble.hub;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
/**
* Reassembles chunked logical messages keyed by (session_msg_id, msg_type).
*
* Payload may be JSON or MessagePack depending on the protocol-version byte of
* the incoming frames (see {@link AisHubConstants#PROTO_VERSION_JSON} /
* {@link AisHubConstants#PROTO_VERSION_MSGPACK}). The assembler stores the
* version of the first frame of an assembly and returns it to the caller when
* the message is complete; the caller then chooses the right decoder.
*/
public class AisHubChunkAssembler {
public static final class FeedStatus {
/**
* Non-null only when the whole message is complete.
* Historically this was a JSON string; it's kept for backward
* compatibility with callers that only ever saw JSON on the wire.
* When the assembly's proto version is MessagePack, {@link #json} is
* {@code null} even at completion — use {@link #payload} +
* {@link #protocolVersion} instead.
*/
public final String json;
/**
* Full reassembled payload bytes (always non-null when the message is
* complete, regardless of encoding). Callers that support both JSON
* and MessagePack should decode from this field and ignore
* {@link #json}.
*/
public final byte[] payload;
/** Protocol-version byte of the reassembled message (matches {@link AisHubConstants}). */
public final int protocolVersion;
public final int received;
public final int chunkCount;
/** True if we detected mismatch and reset assembly state. */
public final boolean wasReset;
FeedStatus(String json, byte[] payload, int protocolVersion,
int received, int chunkCount, boolean wasReset) {
this.json = json;
this.payload = payload;
this.protocolVersion = protocolVersion;
this.received = received;
this.chunkCount = chunkCount;
this.wasReset = wasReset;
}
}
private static final class Key {
final int sessionId;
final int msgType;
Key(int sessionId, int msgType) {
this.sessionId = sessionId;
this.msgType = msgType;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Key)) return false;
Key key = (Key) o;
return sessionId == key.sessionId && msgType == key.msgType;
}
@Override
public int hashCode() {
return 31 * sessionId + msgType;
}
}
private static final class Assembly {
final int chunkCount;
final byte[][] parts;
int received;
// Protocol version taken from the first frame of this assembly.
// We pin it so that a rogue frame with a mismatched version cannot
// silently change how we decode the combined payload.
int protocolVersion = -1;
Assembly(int chunkCount) {
this.chunkCount = chunkCount;
this.parts = new byte[chunkCount][];
}
}
private final Map<Key, Assembly> pending = new HashMap<>();
/**
* Feed one frame; returns complete UTF-8 JSON string or null if more chunks needed.
* <p>
* <b>Deprecated for mixed encodings:</b> MessagePack payloads will return
* {@code null} here even when the message is complete. Use
* {@link #feedStatus(AisHubFrame)} and inspect
* {@link FeedStatus#payload} + {@link FeedStatus#protocolVersion} instead.
*/
public String feed(AisHubFrame frame) {
FeedStatus st = feedStatus(frame);
return st != null ? st.json : null;
}
/**
* Same as {@link #feed(AisHubFrame)}, but also returns progress info for logging
* and the raw reassembled payload + protocol version on completion.
*/
public FeedStatus feedStatus(AisHubFrame frame) {
if (frame.chunkCount <= 0 || frame.chunkIndex < 0 || frame.chunkIndex >= frame.chunkCount) {
return new FeedStatus(null, null, frame.protocolVersion, 0, frame.chunkCount, false);
}
Key key = new Key(frame.sessionMsgId, frame.msgType);
Assembly a = pending.get(key);
boolean wasReset = false;
if (a == null) {
a = new Assembly(frame.chunkCount);
a.protocolVersion = frame.protocolVersion;
pending.put(key, a);
} else if (a.chunkCount != frame.chunkCount || a.protocolVersion != frame.protocolVersion) {
// Either the server changed its mind about the chunk count (unlikely
// but defensively handled) or the encoding — treat as a brand new
// assembly. Can happen if a previous message was lost and we're now
// seeing the start of the next one under the same (sid, msg_type).
pending.remove(key);
a = new Assembly(frame.chunkCount);
a.protocolVersion = frame.protocolVersion;
pending.put(key, a);
wasReset = true;
}
if (a.parts[frame.chunkIndex] == null) {
a.received++;
}
a.parts[frame.chunkIndex] = Arrays.copyOf(frame.payload, frame.payload.length);
if (a.received < a.chunkCount) {
return new FeedStatus(null, null, a.protocolVersion, a.received, a.chunkCount, wasReset);
}
int total = 0;
for (byte[] p : a.parts) {
if (p != null) total += p.length;
}
byte[] out = new byte[total];
int pos = 0;
for (byte[] p : a.parts) {
if (p != null) {
System.arraycopy(p, 0, out, pos, p.length);
pos += p.length;
}
}
pending.remove(key);
// Build the JSON string only for legacy JSON payloads, to keep old
// callers (that read FeedStatus.json directly) working as-is.
String jsonStr = (a.protocolVersion == AisHubConstants.PROTO_VERSION_JSON)
? new String(out, StandardCharsets.UTF_8)
: null;
return new FeedStatus(jsonStr, out, a.protocolVersion, a.received, a.chunkCount, wasReset);
}
public void clear() {
pending.clear();
}
}
@@ -0,0 +1,41 @@
package com.grigowashere.aismap.ble.hub;
import java.util.UUID;
/**
* BLE AIS Hub (protocol v2) — UUID and message type constants.
*/
public final class AisHubConstants {
private AisHubConstants() {}
public static final UUID SERVICE_UUID =
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0001");
public static final UUID CONTROL_UUID =
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0002");
public static final UUID DATA_UUID =
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0003");
public static final UUID STATUS_UUID =
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0004");
public static final int HEADER_SIZE = 10;
/**
* Protocol version byte values (first byte of every DATA frame header).
* The server writes this per-frame based on the encoding it chose for
* that message (negotiated via the "hello" command, or from the
* AIS_BLE_BROADCAST_ENCODING env for CCCD broadcast).
*/
public static final int PROTO_VERSION_JSON = 0x01;
public static final int PROTO_VERSION_MSGPACK = 0x02;
/** Server → client DATA msg_type */
public static final int MSG_HELLO_ACK = 0x01;
public static final int MSG_SNAPSHOT_BEGIN = 0x02;
public static final int MSG_SNAPSHOT_CHUNK = 0x03;
public static final int MSG_SNAPSHOT_END = 0x04;
public static final int MSG_EVENT = 0x05;
public static final int MSG_STATUS = 0x06;
public static final int MSG_ERROR = 0x07;
public static final int MSG_PONG = 0x08;
}
@@ -0,0 +1,48 @@
package com.grigowashere.aismap.ble.hub;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
/**
* One DATA notify frame: 10-byte header + payload.
*/
public class AisHubFrame {
public final int protocolVersion;
public final int msgType;
public final int sessionMsgId;
public final int chunkIndex;
public final int chunkCount;
public final byte[] payload;
public AisHubFrame(int protocolVersion, int msgType, int sessionMsgId,
int chunkIndex, int chunkCount, byte[] payload) {
this.protocolVersion = protocolVersion;
this.msgType = msgType;
this.sessionMsgId = sessionMsgId;
this.chunkIndex = chunkIndex;
this.chunkCount = chunkCount;
this.payload = payload;
}
/**
* @return null if buffer too short or truncated
*/
public static AisHubFrame parse(byte[] buf) {
if (buf == null || buf.length < AisHubConstants.HEADER_SIZE) {
return null;
}
ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
int pv = bb.get() & 0xFF;
int mt = bb.get() & 0xFF;
int sid = bb.getShort() & 0xFFFF;
int cidx = bb.getShort() & 0xFFFF;
int ccnt = bb.getShort() & 0xFFFF;
int plen = bb.getShort() & 0xFFFF;
if (buf.length < AisHubConstants.HEADER_SIZE + plen) {
return null;
}
byte[] payload = new byte[plen];
System.arraycopy(buf, AisHubConstants.HEADER_SIZE, payload, 0, plen);
return new AisHubFrame(pv, mt, sid, cidx, ccnt, payload);
}
}
@@ -0,0 +1,815 @@
package com.grigowashere.aismap.ble.hub;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.grigowashere.aismap.BuildConfig;
import com.grigowashere.aismap.utils.LogSender;
import org.json.JSONObject;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* GATT client for AIS Hub protocol v2: CONTROL writes (JSON), DATA binary frames, optional battery/RSSI.
*/
public class AisHubGattClient {
private static final String TAG = "AisHubGattClient";
private static final boolean BLE_LOG = BuildConfig.DEBUG;
private static final UUID CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
private static final UUID BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb");
private static final UUID BATTERY_LEVEL = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb");
public interface SessionCallback {
void onState(@NonNull String state);
void onError(@NonNull String message);
void onRssi(int rssi);
void onBatteryPercent(int percent);
/** Reassembled JSON from DATA, after chunk merge (per msg_type in protocol). */
void onDataJson(int msgType, @NonNull JSONObject json);
}
private final Context appContext;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
// executor: used ONLY for short-lived, non-blocking tasks (attemptConnect body).
private final ExecutorService executor = Executors.newSingleThreadExecutor();
// dataExecutor: dedicated to processDataRaw so that BLE notifications
// are never blocked behind long-running loops (RSSI, reconnect, watchdog).
// Using single-thread to preserve in-order chunk assembly semantics.
private final ExecutorService dataExecutor = Executors.newSingleThreadExecutor();
// scheduler: replaces Thread.sleep-based loops (RSSI, watchdog, reconnect backoff)
// so they don't hog the general executor and starve other tasks.
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final AtomicBoolean running = new AtomicBoolean(false);
private final AisHubChunkAssembler assembler = new AisHubChunkAssembler();
private final AtomicBoolean gattBusy = new AtomicBoolean(false);
private final AtomicBoolean isConnecting = new AtomicBoolean(false);
private final AtomicBoolean rssiLoop = new AtomicBoolean(false);
private final AtomicBoolean batteryLoop = new AtomicBoolean(false);
private final AtomicBoolean reconnectLoop = new AtomicBoolean(false);
private final AtomicBoolean notifReady = new AtomicBoolean(false);
private final AtomicBoolean mtuRequested = new AtomicBoolean(false);
private final AtomicBoolean snapshotRequested = new AtomicBoolean(false);
private final AtomicBoolean subscribeRequested = new AtomicBoolean(false);
private final ArrayBlockingQueue<byte[]> controlQueue = new ArrayBlockingQueue<>(32);
private BluetoothAdapter adapter;
private volatile BluetoothGatt gatt;
private volatile String deviceMac;
private volatile boolean connected;
private volatile long connectionStartTimeMs;
private volatile long lastDataAtMs;
private volatile boolean lastErrorWasDbFull;
private volatile BluetoothGattCharacteristic controlChar;
private volatile BluetoothGattCharacteristic dataChar;
private SessionCallback callback;
private volatile UUID cachedBatterySvc;
private volatile UUID cachedBatteryChar;
private final ScheduledExecutorService batteryScheduler = Executors.newSingleThreadScheduledExecutor();
private volatile ScheduledFuture<?> batteryTask;
private static final long CONNECTION_TIMEOUT_MS = 30_000L;
private static final long RECONNECT_DELAY_MS = 5_000L;
private static final long RECONNECT_DELAY_DB_FULL_MS = 15_000L;
private static final long SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS = 300_000L;
private static final long SNAPSHOT_RECOVERY_IDLE_MS = 10_000L;
public AisHubGattClient(@NonNull Context context) {
this.appContext = context.getApplicationContext();
BluetoothManager bm = (BluetoothManager) appContext.getSystemService(Context.BLUETOOTH_SERVICE);
this.adapter = bm != null ? bm.getAdapter() : null;
}
public void setCallback(@Nullable SessionCallback callback) {
this.callback = callback;
}
public void setDeviceMac(String mac) {
this.deviceMac = mac;
}
public boolean isRunning() {
return running.get();
}
public void start() {
if (running.get()) {
Log.w(TAG, "AIS Hub GATT already running");
return;
}
if (adapter == null || !adapter.isEnabled()) {
postError("Bluetooth is off or unavailable");
return;
}
if (deviceMac == null || deviceMac.isEmpty()) {
postError("BLE device MAC not set");
return;
}
running.set(true);
assembler.clear();
if (BLE_LOG) Log.d(TAG, "start(): mac=" + deviceMac);
startReconnectLoop();
}
public void stop() {
if (BLE_LOG) Log.d(TAG, "stop()");
running.set(false);
reconnectLoop.set(false);
rssiLoop.set(false);
batteryLoop.set(false);
if (batteryTask != null) {
try { batteryTask.cancel(true); } catch (Throwable ignore) {}
}
if (rssiTask != null) {
try { rssiTask.cancel(true); } catch (Throwable ignore) {}
}
notifReady.set(false);
controlChar = null;
dataChar = null;
mainHandler.removeCallbacksAndMessages(null);
try {
if (gatt != null) {
gatt.disconnect();
gatt.close();
}
} catch (Throwable ignore) {}
gatt = null;
connected = false;
postState("stopped");
}
// --- GATT callback ---
private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
@Override
public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
if (!running.get()) return;
if (BLE_LOG) Log.d(TAG, "onConnectionStateChange: status=" + status + " newState=" + newState);
if (status != BluetoothGatt.GATT_SUCCESS && status != 4 && status != 133) {
postError("BLE connect status: " + status);
} else if (status == 133) {
lastErrorWasDbFull = true;
isConnecting.set(false);
try {
if (g != null) {
g.disconnect();
g.close();
}
} catch (Throwable ignore) {}
gatt = null;
connectionStartTimeMs = 0L;
} else if (status == 4) {
isConnecting.set(false);
}
if (newState == BluetoothProfile.STATE_CONNECTED) {
postState("connected");
connected = true;
connectionStartTimeMs = 0L;
isConnecting.set(false);
lastErrorWasDbFull = false;
reconnectLoop.set(false);
notifReady.set(false);
mtuRequested.set(false);
snapshotRequested.set(false);
subscribeRequested.set(false);
controlChar = null;
dataChar = null;
// NOTE: BluetoothGatt.refresh() is hidden API and frequently destabilizes connections
// on some vendor stacks. Prefer stability over stale cache here.
scheduler.schedule(() -> {
if (g != null && running.get() && gatt == g) {
// Помогаем линк-слою: выше приоритет соединения.
try { g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH); } catch (Throwable ignore) {}
boolean ok = false;
try { ok = g.discoverServices(); } catch (Throwable ignore) {}
if (BLE_LOG) Log.d(TAG, "discoverServices(): " + ok);
}
}, 200, TimeUnit.MILLISECONDS);
// На некоторых стеках (особенно после refresh/MTU) service discovery может "зависнуть"
// без callback'а. Если так — мягко перезапускаем discovery и, при необходимости, reconnect.
scheduler.schedule(() -> {
if (!running.get()) return;
if (g == null || gatt != g) return;
if (!connected) return;
if (controlChar != null && dataChar != null) return;
if (BLE_LOG) Log.w(TAG, "Services discovery watchdog: no hub chars yet, retry discoverServices()");
boolean ok = false;
try { ok = g.discoverServices(); } catch (Throwable ignore) {}
if (BLE_LOG) Log.d(TAG, "discoverServices() retry: " + ok);
}, 6, TimeUnit.SECONDS);
scheduler.schedule(() -> {
if (!running.get()) return;
if (g == null || gatt != g) return;
if (!connected) return;
if (controlChar != null && dataChar != null) return;
postError("Service discovery timeout (no hub chars)");
try { g.disconnect(); } catch (Throwable ignore) {}
try { g.close(); } catch (Throwable ignore) {}
gatt = null;
connected = false;
isConnecting.set(false);
startReconnectLoop();
}, 12, TimeUnit.SECONDS);
startRssiLoop();
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
postState("disconnected");
notifReady.set(false);
controlChar = null;
dataChar = null;
mtuRequested.set(false);
snapshotRequested.set(false);
subscribeRequested.set(false);
rssiLoop.set(false);
batteryLoop.set(false);
if (rssiTask != null) {
try { rssiTask.cancel(false); } catch (Throwable ignore) {}
rssiTask = null;
}
try { if (gatt != null) gatt.close(); } catch (Throwable ignore) {}
gatt = null;
connected = false;
isConnecting.set(false);
if (running.get()) {
scheduler.schedule(() -> {
if (running.get() && !connected) startReconnectLoop();
}, 1, TimeUnit.SECONDS);
}
}
}
@Override
public void onServicesDiscovered(BluetoothGatt g, int status) {
if (BLE_LOG) Log.d(TAG, "onServicesDiscovered: status=" + status);
if (status != BluetoothGatt.GATT_SUCCESS) {
postError("Service discovery failed: " + status);
return;
}
if (mtuRequested.compareAndSet(false, true)) {
boolean ok = false;
try { ok = g.requestMtu(512); } catch (Throwable ignore) {}
if (BLE_LOG) Log.d(TAG, "requestMtu(512): " + ok);
}
BluetoothGattService hub = g.getService(AisHubConstants.SERVICE_UUID);
if (hub == null) {
postError("AIS Hub service not found");
return;
}
controlChar = hub.getCharacteristic(AisHubConstants.CONTROL_UUID);
dataChar = hub.getCharacteristic(AisHubConstants.DATA_UUID);
if (controlChar == null || dataChar == null) {
postError("CONTROL or DATA characteristic missing");
return;
}
if (BLE_LOG) {
Log.d(TAG, "Hub chars ok: CONTROL=" + controlChar.getUuid() + " DATA=" + dataChar.getUuid());
}
boolean ok = g.setCharacteristicNotification(dataChar, true);
if (!ok) {
postError("Failed to enable DATA notification");
return;
}
BluetoothGattDescriptor cccd = dataChar.getDescriptor(CCCD);
if (cccd == null) {
postError("CCCD not found for DATA");
return;
}
cccd.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gattBusy.set(true);
if (BLE_LOG) Log.d(TAG, "writeDescriptor(CCCD ENABLE_NOTIFICATION)");
g.writeDescriptor(cccd);
postState("subscribing");
}
@Override
public void onDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor descriptor, int st) {
gattBusy.set(false);
if (BLE_LOG) Log.d(TAG, "onDescriptorWrite: uuid=" + descriptor.getUuid() + " status=" + st);
if (st == BluetoothGatt.GATT_SUCCESS && CCCD.equals(descriptor.getUuid())) {
notifReady.set(true);
lastDataAtMs = System.currentTimeMillis();
postState("notifying");
try { resolveBatteryAndSchedule(g); } catch (Throwable ignore) {}
readBatteryOnce(g);
startBatteryLoop(g);
enqueueControlJson(buildHello());
// Snapshot триггерим ТОЛЬКО на HELLO_ACK (см. processDataRaw),
// чтобы не получить snapshot_busy от двойного запроса (постDelayed
// здесь и обработчик HELLO_ACK раньше успевали оба).
// Fallback: если HELLO_ACK не пришёл за 2с — всё равно дёргаем snapshot.
mainHandler.postDelayed(() -> {
if (!running.get() || !connected) return;
if (!notifReady.get() || controlChar == null) return;
if (snapshotRequested.compareAndSet(false, true)) {
if (BLE_LOG) Log.d(TAG, "HELLO_ACK timeout fallback -> enqueue get_snapshot");
enqueueGetSnapshot();
}
}, 2000);
}
}
@Override
public void onCharacteristicChanged(BluetoothGatt g, BluetoothGattCharacteristic ch) {
if (dataChar != null && ch.getUuid().equals(dataChar.getUuid())) {
// ВАЖНО: не парсим/не JSON-парсим в BLE callback потоке.
// Иначе при потоке EVENT'ов легко перегрузить стек и получить disconnect/status=5.
// Используем ВЫДЕЛЕННЫЙ dataExecutor, чтобы парсинг не стоял за RSSI/reconnect-петлями.
final byte[] raw = ch.getValue();
if (raw == null) return;
final byte[] copy = java.util.Arrays.copyOf(raw, raw.length);
dataExecutor.execute(() -> processDataRaw(copy));
}
}
@Override
public void onMtuChanged(BluetoothGatt g, int mtu, int status) {
if (BLE_LOG) Log.d(TAG, "onMtuChanged: status=" + status + " mtu=" + mtu);
}
@Override
public void onCharacteristicRead(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
if (status == BluetoothGatt.GATT_SUCCESS) {
if (BATTERY_LEVEL.equals(ch.getUuid()) || (toShort(ch.getUuid()) != null && toShort(ch.getUuid()) == 0x2A19)) {
byte[] v = ch.getValue();
if (v != null && v.length > 0 && callback != null) {
callback.onBatteryPercent(v[0] & 0xFF);
}
}
}
gattBusy.set(false);
}
@Override
public void onCharacteristicWrite(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
if (controlChar != null && ch.getUuid().equals(controlChar.getUuid())) {
gattBusy.set(false);
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "CONTROL write failed: " + status);
} else if (BLE_LOG) {
Log.d(TAG, "CONTROL write ok (" + (ch.getValue() != null ? ch.getValue().length : -1) + " bytes)");
}
mainHandler.post(AisHubGattClient.this::drainControlQueue);
}
}
@Override
public void onReadRemoteRssi(BluetoothGatt g, int rssi, int status) {
if (status == BluetoothGatt.GATT_SUCCESS && callback != null) {
callback.onRssi(rssi);
}
}
};
private void processDataRaw(@Nullable byte[] raw) {
if (raw == null) return;
if (raw.length < AisHubConstants.HEADER_SIZE) {
if (BLE_LOG) Log.w(TAG, "DATA notify too short: len=" + raw.length);
return;
}
lastDataAtMs = System.currentTimeMillis();
// Decode header fields even if payload is truncated, for diagnostics.
int pv = raw[0] & 0xFF;
int mt = raw[1] & 0xFF;
int sid = ((raw[2] & 0xFF) | ((raw[3] & 0xFF) << 8));
int cidx = ((raw[4] & 0xFF) | ((raw[5] & 0xFF) << 8));
int ccnt = ((raw[6] & 0xFF) | ((raw[7] & 0xFF) << 8));
int plen = ((raw[8] & 0xFF) | ((raw[9] & 0xFF) << 8));
if (BLE_LOG) {
Log.d(TAG, "DATA notify: len=" + raw.length +
" pv=" + pv +
" msgType=" + mt + "(" + msgTypeName(mt) + ")" +
" sid=" + sid +
" chunk=" + cidx + "/" + ccnt +
" plen=" + plen);
}
AisHubFrame frame = AisHubFrame.parse(raw);
if (frame == null) {
if (BLE_LOG) Log.w(TAG, "DATA parse failed/truncated: rawLen=" + raw.length + " expected>=" + (AisHubConstants.HEADER_SIZE + plen));
return;
}
AisHubChunkAssembler.FeedStatus st = assembler.feedStatus(frame);
if (st != null && BLE_LOG) {
if (st.wasReset) {
Log.w(TAG, "Assembler reset: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType + " newChunkCount=" + st.chunkCount);
}
if (st.payload == null) {
Log.d(TAG, "Assembler progress: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType +
" got=" + st.received + "/" + st.chunkCount);
}
}
if (st == null || st.payload == null) return;
try {
JSONObject root;
if (st.protocolVersion == AisHubConstants.PROTO_VERSION_MSGPACK) {
root = AisHubPayloadCodec.decodeToJsonObject(st.payload, st.protocolVersion);
if (root == null) {
Log.w(TAG, "msgpack decode: empty/non-object payload msgType=" + frame.msgType);
return;
}
if (BLE_LOG) {
Log.d(TAG, "DATA msgpack complete: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType +
" bytes=" + st.payload.length + " obj=" + abbreviate(root.toString(), 800));
}
} else {
// JSON path keeps the original behavior (zero-copy via st.json).
String jsonStr = st.json != null
? st.json
: new String(st.payload, StandardCharsets.UTF_8);
if (BLE_LOG) Log.d(TAG, "DATA json complete: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType +
" bytes=" + jsonStr.getBytes(StandardCharsets.UTF_8).length +
" json=" + abbreviate(jsonStr, 800));
root = new JSONObject(jsonStr);
}
// Keep our clock offset with the hub fresh from every frame that
// carries a server timestamp. This makes stale-data math on the
// client work correctly regardless of hub clock drift.
double envTs = root.optDouble("ts", Double.NaN);
if (!Double.isNaN(envTs)) {
HubTimeSync.updateFromServerSeconds(envTs);
} else {
double srvTime = root.optDouble("server_time", Double.NaN);
if (!Double.isNaN(srvTime)) HubTimeSync.updateFromServerSeconds(srvTime);
}
if (BLE_LOG && frame.msgType == AisHubConstants.MSG_EVENT) {
String type = root.optString("type", "");
JSONObject data = root.optJSONObject("data");
// target.update/vessel-snapshot nest lat/lon inside "dynamic"; ownship.update keeps them at root.
JSONObject src = data;
if (data != null) {
JSONObject d = data.optJSONObject("dynamic");
if (d != null) src = d;
}
double lat = src != null ? src.optDouble("lat", Double.NaN) : Double.NaN;
double lon = src != null ? src.optDouble("lon", Double.NaN) : Double.NaN;
Log.d(TAG, "EVENT received: type=" + type + " lat=" + lat + " lon=" + lon);
}
// Авто-старт сессии: после HELLO_ACK сразу запрашиваем snapshot (1 раз на соединение).
if (frame.msgType == AisHubConstants.MSG_HELLO_ACK && snapshotRequested.compareAndSet(false, true)) {
if (BLE_LOG) Log.d(TAG, "HELLO_ACK received -> enqueue get_snapshot");
mainHandler.post(this::enqueueGetSnapshot);
}
// После окончания snapshot включаем live подписки (1 раз на соединение).
if (frame.msgType == AisHubConstants.MSG_SNAPSHOT_END && subscribeRequested.compareAndSet(false, true)) {
boolean ok = root.optBoolean("ok", true);
if (BLE_LOG) Log.d(TAG, "SNAPSHOT_END(ok=" + ok + ") -> enqueue subscribe");
if (ok) {
mainHandler.post(this::enqueueSubscribe);
}
}
if (callback != null) {
mainHandler.post(() -> callback.onDataJson(frame.msgType, root));
}
} catch (Exception e) {
Log.w(TAG, "JSON parse: " + e.getMessage());
}
}
// --- control writes ---
@NonNull
private JSONObject buildHello() {
JSONObject c = new JSONObject();
try {
c.put("cmd", "hello");
c.put("client", "android");
String v = "1.0";
try {
PackageInfo pi = appContext.getPackageManager().getPackageInfo(appContext.getPackageName(), 0);
if (pi != null) v = pi.versionName != null ? pi.versionName : "1.0";
} catch (Exception ignore) {}
c.put("app_version", v);
c.put("proto", 1);
// Advertise encoding preferences. Server picks the first one it
// supports; we fall back to JSON automatically if msgpack isn't
// available server-side. msgpack is ~30-40% smaller on the wire
// which matters for snapshot transfers over BLE.
// Note: this only affects the AcquireNotify (per-fd) path. For the
// CCCD broadcast path Android clients share, the server uses the
// global AIS_BLE_BROADCAST_ENCODING env var — but our decoder
// handles both encodings per-frame via the proto-version byte, so
// switching the server's env has zero client-side impact.
org.json.JSONArray encs = new org.json.JSONArray();
encs.put("msgpack");
encs.put("json");
c.put("encodings", encs);
} catch (Exception ignore) {}
return c;
}
public void enqueueGetSnapshot() {
JSONObject c = new JSONObject();
try {
c.put("cmd", "get_snapshot");
org.json.JSONArray inc = new org.json.JSONArray();
inc.put("ownship");
inc.put("vessels");
inc.put("base_stations");
inc.put("atons");
inc.put("stats");
c.put("include", inc);
// Серверный хард-кап ограничивает дальше (см. AIS_BLE_SNAPSHOT_MAX_VESSELS).
// 5000 покрывает любые реалистичные сценарии (обычно в AOI < 2000 целей).
c.put("max_vessels", 5000);
} catch (Exception ignore) {}
enqueueControl(c);
// Recovery only: штатно live-подписка включается строго после SNAPSHOT_END.
// Короткий таймер здесь интерливил EVENT'ы в длинный snapshot по CCCD.
mainHandler.postDelayed(() -> {
if (!running.get() || !connected) return;
if (!notifReady.get() || controlChar == null) return;
long idleMs = System.currentTimeMillis() - lastDataAtMs;
if (idleMs < SNAPSHOT_RECOVERY_IDLE_MS) return;
if (subscribeRequested.compareAndSet(false, true)) {
if (BLE_LOG) Log.d(TAG, "snapshot recovery timeout -> enqueue subscribe");
enqueueSubscribe();
}
}, SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS);
}
public void enqueueSubscribe() {
JSONObject c = new JSONObject();
try {
c.put("cmd", "subscribe");
org.json.JSONArray ev = new org.json.JSONArray();
ev.put("ownship.update");
ev.put("target.update");
ev.put("base_station.update");
ev.put("aton.update");
ev.put("stats.update");
c.put("events", ev);
} catch (Exception ignore) {}
enqueueControl(c);
}
private void enqueueControlJson(@NonNull JSONObject cmd) {
try {
enqueueControl(cmd);
} catch (Exception e) {
Log.w(TAG, "enqueue: " + e.getMessage());
}
}
private void enqueueControl(@NonNull JSONObject json) {
if (!notifReady.get() || controlChar == null) return;
String s = json.toString();
if (BLE_LOG) Log.d(TAG, "CONTROL enqueue: " + abbreviate(s, 600));
byte[] u = s.getBytes(StandardCharsets.UTF_8);
if (!controlQueue.offer(u)) {
Log.w(TAG, "Control queue full");
}
mainHandler.post(this::drainControlQueue);
}
private void drainControlQueue() {
if (!running.get() || !notifReady.get() || gatt == null || controlChar == null) return;
if (gattBusy.get()) return;
byte[] next = controlQueue.poll();
if (next == null) return;
gattBusy.set(true);
controlChar.setValue(next);
controlChar.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT);
boolean ok = gatt.writeCharacteristic(controlChar);
if (!ok) {
if (BLE_LOG) Log.w(TAG, "CONTROL writeCharacteristic returned false, retrying");
gattBusy.set(false);
mainHandler.post(this::drainControlQueue);
}
}
// --- connect / reconnect ---
private void startReconnectLoop() {
if (reconnectLoop.getAndSet(true)) return;
executor.execute(() -> {
while (running.get() && reconnectLoop.get() && !connected) {
try {
if (connectionStartTimeMs > 0) {
long el = System.currentTimeMillis() - connectionStartTimeMs;
if (el > CONNECTION_TIMEOUT_MS) {
isConnecting.set(false);
try { if (gatt != null) { gatt.disconnect(); gatt.close(); } } catch (Throwable ignore) {}
gatt = null;
connectionStartTimeMs = 0L;
} else {
try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
continue;
}
}
if (!isConnecting.compareAndSet(false, true)) {
try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
continue;
}
attemptConnect();
} catch (Throwable t) {
Log.w(TAG, "reconnect: " + t.getMessage());
isConnecting.set(false);
}
long delay = lastErrorWasDbFull ? RECONNECT_DELAY_DB_FULL_MS : RECONNECT_DELAY_MS;
try { Thread.sleep(delay); } catch (InterruptedException ignored) {}
}
});
}
private void attemptConnect() {
if (adapter == null || deviceMac == null || deviceMac.isEmpty()) {
isConnecting.set(false);
return;
}
if (gatt != null) {
try {
gatt.disconnect();
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
gatt.close();
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
} catch (Throwable ignore) {}
gatt = null;
}
BluetoothDevice d = adapter.getRemoteDevice(deviceMac);
if (d == null) {
postError("BLE device not found: " + deviceMac);
isConnecting.set(false);
return;
}
if (BLE_LOG) Log.d(TAG, "attemptConnect(): " + deviceMac);
postState("connecting");
connectionStartTimeMs = System.currentTimeMillis();
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
gatt = d.connectGatt(appContext, false, gattCallback, BluetoothDevice.TRANSPORT_LE);
} else {
gatt = d.connectGatt(appContext, false, gattCallback);
}
if (gatt == null) isConnecting.set(false);
} catch (Throwable t) {
isConnecting.set(false);
connectionStartTimeMs = 0L;
}
}
private volatile ScheduledFuture<?> rssiTask;
private void startRssiLoop() {
if (gatt == null) return;
if (rssiLoop.getAndSet(true)) return;
if (rssiTask != null) try { rssiTask.cancel(true); } catch (Throwable ignore) {}
rssiTask = scheduler.scheduleAtFixedRate(() -> {
if (!running.get() || !rssiLoop.get() || gatt == null) return;
try { gatt.readRemoteRssi(); } catch (Throwable ignore) {}
}, 500, 2000, TimeUnit.MILLISECONDS);
}
// --- battery (same heuristics as old listener) ---
private void resolveBatteryAndSchedule(BluetoothGatt g) {
BluetoothGattCharacteristic bl = findBatteryChar(g);
if (bl != null) {
cachedBatterySvc = bl.getService().getUuid();
cachedBatteryChar = bl.getUuid();
}
}
@Nullable
private BluetoothGattCharacteristic findBatteryChar(BluetoothGatt g) {
if (g == null) return null;
if (cachedBatterySvc != null && cachedBatteryChar != null) {
BluetoothGattService s = g.getService(cachedBatterySvc);
if (s != null) {
BluetoothGattCharacteristic c = s.getCharacteristic(cachedBatteryChar);
if (c != null) return c;
}
}
BluetoothGattService s = g.getService(BATTERY_SERVICE);
if (s != null) {
BluetoothGattCharacteristic c = s.getCharacteristic(BATTERY_LEVEL);
if (c != null) {
cachedBatterySvc = BATTERY_SERVICE;
cachedBatteryChar = BATTERY_LEVEL;
return c;
}
}
List<BluetoothGattService> list = g.getServices();
if (list == null) return null;
for (BluetoothGattService sv : list) {
for (BluetoothGattCharacteristic ch : sv.getCharacteristics()) {
Integer sh = toShort(ch.getUuid());
if (sh != null && sh == 0x2A19) {
return ch;
}
}
}
return null;
}
private static Integer toShort(java.util.UUID uuid) {
if (uuid == null) return null;
String s = uuid.toString().toLowerCase();
if (s.startsWith("0000") && s.endsWith("-0000-1000-8000-00805f9b34fb")) {
try {
return Integer.parseInt(s.substring(4, 8), 16);
} catch (Throwable ignore) {}
}
return null;
}
private void readBatteryOnce(BluetoothGatt g) {
if (g == null) return;
BluetoothGattCharacteristic bl = findBatteryChar(g);
if (bl == null) return;
if (gattBusy.compareAndSet(false, true)) {
boolean ok = g.readCharacteristic(bl);
if (!ok) gattBusy.set(false);
}
}
private void startBatteryLoop(BluetoothGatt gRef) {
if (gRef == null) return;
if (!batteryLoop.compareAndSet(false, true)) return;
if (batteryTask != null) try { batteryTask.cancel(true); } catch (Throwable ignore) {}
batteryTask = batteryScheduler.scheduleAtFixedRate(() -> {
if (!running.get() || !batteryLoop.get() || gatt == null) {
batteryLoop.set(false);
return;
}
try {
if (gattBusy.get()) return;
BluetoothGattCharacteristic bl = findBatteryChar(gRef);
if (bl != null && gattBusy.compareAndSet(false, true)) {
boolean ok = gRef.readCharacteristic(bl);
if (!ok) gattBusy.set(false);
}
} catch (Throwable ignore) {}
}, 2000, 10_000, TimeUnit.MILLISECONDS);
}
private void postState(String s) {
if (callback != null) {
mainHandler.post(() -> callback.onState(s));
}
}
private void postError(String s) {
Log.e(TAG, s);
LogSender.logBLEError(s, deviceMac, "AisHub");
if (callback != null) {
mainHandler.post(() -> callback.onError(s));
}
}
@NonNull
private static String abbreviate(@NonNull String s, int max) {
if (s.length() <= max) return s;
return s.substring(0, Math.max(0, max)) + "…(" + s.length() + " chars)";
}
@NonNull
private static String msgTypeName(int mt) {
switch (mt) {
case AisHubConstants.MSG_HELLO_ACK: return "HELLO_ACK";
case AisHubConstants.MSG_SNAPSHOT_BEGIN: return "SNAPSHOT_BEGIN";
case AisHubConstants.MSG_SNAPSHOT_CHUNK: return "SNAPSHOT_CHUNK";
case AisHubConstants.MSG_SNAPSHOT_END: return "SNAPSHOT_END";
case AisHubConstants.MSG_EVENT: return "EVENT";
case AisHubConstants.MSG_STATUS: return "STATUS";
case AisHubConstants.MSG_ERROR: return "ERROR";
case AisHubConstants.MSG_PONG: return "PONG";
default: return "UNKNOWN";
}
}
}
@@ -0,0 +1,307 @@
package com.grigowashere.aismap.ble.hub;
import com.grigowashere.aismap.models.AISVessel;
import com.grigowashere.aismap.models.AISNavigationAid;
import com.grigowashere.aismap.models.Vessel;
import org.json.JSONObject;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
/**
* Maps loose JSON objects from ais_hub into app models (defensive keys).
*/
public final class AisHubJsonMapper {
private AisHubJsonMapper() {}
public static String mmsiString(JSONObject o) {
if (o == null) return null;
if (o.has("mmsi")) {
Object v = o.opt("mmsi");
if (v instanceof Number) return String.valueOf(((Number) v).longValue());
String s = o.optString("mmsi", null);
return s != null && !s.isEmpty() ? s : null;
}
return null;
}
/**
* Returns `o.dynamic` if it exists (AIS Hub v2 nests position/motion there
* for target.update and vessel snapshot items), otherwise returns `o` itself
* (ownship.update keeps lat/lon at root).
*/
private static JSONObject dyn(JSONObject o) {
if (o == null) return null;
JSONObject d = o.optJSONObject("dynamic");
return d != null ? d : o;
}
public static double optLat(JSONObject o) {
if (o == null) return Double.NaN;
JSONObject src = dyn(o);
if (src.has("latitude")) return src.optDouble("latitude", Double.NaN);
if (src.has("lat")) return src.optDouble("lat", Double.NaN);
return Double.NaN;
}
public static double optLon(JSONObject o) {
if (o == null) return Double.NaN;
JSONObject src = dyn(o);
if (src.has("longitude")) return src.optDouble("longitude", Double.NaN);
if (src.has("lon")) return src.optDouble("lon", Double.NaN);
return Double.NaN;
}
public static double optCourse(JSONObject o) {
if (o == null) return Double.NaN;
JSONObject src = dyn(o);
double v = src.optDouble("cog", Double.NaN);
if (!Double.isNaN(v)) return v;
v = src.optDouble("course", Double.NaN);
if (!Double.isNaN(v)) return v;
v = src.optDouble("true_course", Double.NaN);
if (!Double.isNaN(v)) return v;
return src.optDouble("heading", Double.NaN);
}
public static double optSpeed(JSONObject o) {
if (o == null) return Double.NaN;
JSONObject src = dyn(o);
double v = src.optDouble("sog", Double.NaN);
if (!Double.isNaN(v)) return v;
v = src.optDouble("speed", Double.NaN);
if (!Double.isNaN(v)) return v;
return src.optDouble("stw", Double.NaN);
}
public static double optHeading(JSONObject o) {
if (o == null) return Double.NaN;
JSONObject src = dyn(o);
double v = src.optDouble("heading", Double.NaN);
if (!Double.isNaN(v)) return v;
return src.optDouble("hdg", Double.NaN);
}
/**
* Returns a valid epoch-seconds timestamp taken from any of the known fields,
* or NaN if none present. Supports ts, last_dynamic_ts, last_seen, last_static_ts.
*/
private static double pickEpochSeconds(JSONObject o) {
if (o == null) return Double.NaN;
String[] keys = {"ts", "last_dynamic_ts", "last_seen", "last_static_ts"};
for (String k : keys) {
if (!o.has(k)) continue;
double ts = o.optDouble(k, Double.NaN);
if (Double.isNaN(ts) || ts <= 0) continue;
if (ts > 1e12) ts /= 1000.0;
if (ts > 946684800) return ts;
}
return Double.NaN;
}
/**
* Converts a server-epoch timestamp to a device-local {@link LocalDateTime}
* using {@link HubTimeSync} so that stale checks (which compare against
* {@code LocalDateTime.now()}) are immune to hub clock skew.
*/
private static LocalDateTime deviceLocalFromServerEpoch(double serverSec) {
double deviceSec = HubTimeSync.toDeviceEpochSeconds(serverSec);
long secs = (long) deviceSec;
int nanos = (int) ((deviceSec - secs) * 1e9);
if (nanos < 0) { secs -= 1; nanos += 1_000_000_000; }
return LocalDateTime.ofInstant(Instant.ofEpochSecond(secs, nanos), ZoneId.systemDefault());
}
public static void applyTimestampFromJson(JSONObject o, AISVessel vessel) {
double ts = pickEpochSeconds(o);
if (!Double.isNaN(ts)) {
vessel.setLastUpdate(deviceLocalFromServerEpoch(ts));
return;
}
if (o != null && o.has("last_update")) {
try {
String s = o.optString("last_update", "");
if (!s.isEmpty()) {
vessel.setLastUpdate(LocalDateTime.parse(s));
return;
}
} catch (Exception ignore) {}
}
vessel.setLastUpdate(LocalDateTime.now());
}
public static void applyTimestampFromJson(JSONObject o, AISNavigationAid aid) {
double ts = pickEpochSeconds(o);
if (!Double.isNaN(ts)) {
aid.setLastUpdate(deviceLocalFromServerEpoch(ts));
return;
}
aid.setLastUpdate(LocalDateTime.now());
}
public static void applyTimestampFromJson(JSONObject o, Vessel vessel) {
double ts = pickEpochSeconds(o);
if (!Double.isNaN(ts)) {
vessel.setLastUpdate(deviceLocalFromServerEpoch(ts));
return;
}
vessel.setLastUpdate(LocalDateTime.now());
}
public static AISVessel aisVesselFromJson(JSONObject o) {
if (o == null) return null;
String mmsi = mmsiString(o);
if (mmsi == null) return null;
AISVessel v = new AISVessel(mmsi);
String name = o.optString("name", o.optString("vessel_name", o.optString("shipname", "")));
if (!name.isEmpty()) v.setVesselName(name);
v.setCallSign(o.optString("call_sign", o.optString("callsign", "")));
double lat = optLat(o);
double lon = optLon(o);
double cog = optCourse(o);
double sog = optSpeed(o);
if (!Double.isNaN(lat) && !Double.isNaN(lon)
&& !Double.isNaN(cog) && !Double.isNaN(sog)) {
v.updatePosition(lat, lon, cog, sog);
} else {
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
v.setLatitude(lat);
v.setLongitude(lon);
}
if (!Double.isNaN(cog)) v.setCourse(cog);
if (!Double.isNaN(sog)) v.setSpeed(sog);
}
double hdg = optHeading(o);
if (!Double.isNaN(hdg)) v.setHeading(hdg);
String cls = o.optString("class", o.optString("vessel_class", o.optString("ship_class", "")));
if (!cls.isEmpty()) v.setVesselClass(cls);
int imo = o.optInt("imo", 0);
if (imo > 0) v.setImo(imo);
int shipType = o.optInt("ship_type", -1);
if (shipType >= 0) v.setVesselType(shipTypeName(shipType));
JSONObject dims = o.optJSONObject("dims");
if (dims != null) {
double a = dims.optDouble("a", 0.0);
double b = dims.optDouble("b", 0.0);
double c = dims.optDouble("c", 0.0);
double d = dims.optDouble("d", 0.0);
if (a + b > 0.0) v.setLength(a + b);
if (c + d > 0.0) v.setWidth(c + d);
}
JSONObject voyage = o.optJSONObject("voyage");
if (voyage != null) {
double draft = voyage.optDouble("draught", Double.NaN);
if (!Double.isNaN(draft) && draft > 0.0) v.setDraft(draft);
String destination = voyage.optString("destination", "");
if (!destination.isEmpty()) v.setDestination(destination);
}
JSONObject dynamic = o.optJSONObject("dynamic");
if (dynamic != null) {
if (dynamic.has("nav_status")) {
v.setNavigationalStatus(String.valueOf(dynamic.optInt("nav_status")));
}
double rot = dynamic.optDouble("rot", Double.NaN);
if (!Double.isNaN(rot)) v.setRateOfTurn(rot);
}
JSONObject signal = o.optJSONObject("signal");
if (signal != null) {
double db = signal.optDouble("last_db", Double.NaN);
if (!Double.isNaN(db)) v.setSignalStrength((int) Math.round(db));
}
if (o.has("position_accuracy")) {
v.setPositionAccuracy(o.optBoolean("position_accuracy", false));
}
applyTimestampFromJson(o, v);
return v;
}
public static AISVessel baseStationFromJson(JSONObject o) {
if (o == null) return null;
String mmsi = mmsiString(o);
if (mmsi == null) return null;
AISVessel v = new AISVessel(mmsi);
v.setVesselClass("Base Station");
v.setVesselType("Base Station");
v.setVesselName("Base Station " + mmsi);
double lat = optLat(o);
double lon = optLon(o);
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
v.setLatitude(lat);
v.setLongitude(lon);
}
if (o.has("accuracy")) {
v.setPositionAccuracy(o.optBoolean("accuracy", false));
}
if (o.has("epfd")) {
v.setDestination("EPFD: " + o.optInt("epfd"));
}
applyTimestampFromJson(o, v);
return v;
}
public static AISNavigationAid navigationAidFromJson(JSONObject o) {
if (o == null) return null;
String mmsi = mmsiString(o);
if (mmsi == null) return null;
AISNavigationAid aid = new AISNavigationAid(mmsi);
aid.setAidName(o.optString("name", "AtoN " + mmsi));
aid.setAidType(o.optInt("type", 0));
double lat = optLat(o);
double lon = optLon(o);
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
aid.setLatitude(lat);
aid.setLongitude(lon);
}
if (o.has("accuracy")) {
aid.setPositionAccuracy(o.optBoolean("accuracy", false));
}
if (o.has("virtual")) {
aid.setOffPositionIndicator(o.optBoolean("virtual", false));
}
applyTimestampFromJson(o, aid);
return aid;
}
public static void mergeVesselOwnship(JSONObject o, Vessel v) {
if (o == null || v == null) return;
String mmsi = mmsiString(o);
if (mmsi != null) v.setMmsi(mmsi);
String name = o.optString("name", o.optString("vessel_name", ""));
if (!name.isEmpty()) v.setVesselName(name);
String cs = o.optString("call_sign", o.optString("callsign", ""));
if (!cs.isEmpty()) v.setCallSign(cs);
double lat = optLat(o);
double lon = optLon(o);
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
v.setLatitude(lat);
v.setLongitude(lon);
}
double cog = optCourse(o);
if (!Double.isNaN(cog)) v.setCourse(cog);
double sog = optSpeed(o);
if (!Double.isNaN(sog)) v.setSpeed(sog);
double hdg = optHeading(o);
if (!Double.isNaN(hdg)) v.setHeading(hdg);
v.setFixTime(System.currentTimeMillis());
v.setFixQuality("HUB");
if (o.has("accuracy") || o.has("h_acc")) {
float acc = (float) o.optDouble("accuracy", o.optDouble("h_acc", -1));
if (acc >= 0) v.setAccuracy(acc);
}
applyTimestampFromJson(o, v);
}
private static String shipTypeName(int code) {
if (code >= 70 && code <= 79) return "Cargo";
if (code >= 80 && code <= 89) return "Tanker";
if (code == 30) return "Fishing";
if (code >= 60 && code <= 69) return "Passenger";
if (code == 36 || code == 37) return "Sailing/Pleasure";
if (code == 31 || code == 32 || code == 52) return "Tug";
if (code == 35) return "Military";
return "Other";
}
}
@@ -0,0 +1,127 @@
package com.grigowashere.aismap.ble.hub;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import org.msgpack.core.MessagePack;
import org.msgpack.core.MessageUnpacker;
import org.msgpack.value.Value;
import org.msgpack.value.ValueType;
import java.nio.charset.StandardCharsets;
import java.util.Map;
/**
* Decodes a reassembled AIS Hub message payload into a {@link JSONObject},
* regardless of whether the server sent it as JSON (proto 0x01) or
* MessagePack (proto 0x02). All downstream code in this app consumes
* {@link JSONObject}, so converting MessagePack → JSONObject here keeps the
* rest of the codebase unchanged.
*
* <p>Why convert to JSONObject instead of surfacing the raw msgpack Value?
* The existing consumers ({@code onDataJson}) and all the mappers in
* {@link AisHubJsonMapper} are written against {@code org.json.*}. Rewriting
* them would be a large blast radius for a wire-format optimization that the
* consumer should be agnostic to. The conversion is O(payload size) and runs
* off the BLE callback thread (see {@code AisHubGattClient#dataExecutor}).
*/
public final class AisHubPayloadCodec {
private AisHubPayloadCodec() {}
/**
* Decode a reassembled payload into a JSONObject.
*
* @param payload raw reassembled bytes (never null when called)
* @param protoVer one of {@link AisHubConstants#PROTO_VERSION_JSON} or
* {@link AisHubConstants#PROTO_VERSION_MSGPACK}
* @return the decoded root object, or null if the payload is malformed
* or the top-level value is not an object/map (we never expect
* a bare array or scalar at the root in this protocol).
*/
@Nullable
public static JSONObject decodeToJsonObject(@NonNull byte[] payload, int protoVer) throws Exception {
if (protoVer == AisHubConstants.PROTO_VERSION_MSGPACK) {
return decodeMsgpack(payload);
}
// Default / 0x01: JSON UTF-8. Unknown versions are treated as JSON
// rather than silently dropped, to be forward-compatible with future
// server versions that still emit JSON-shaped payloads.
String jsonStr = new String(payload, StandardCharsets.UTF_8);
return new JSONObject(jsonStr);
}
/**
* Same as {@link #decodeToJsonObject(byte[], int)} but takes a pre-built
* JSON string (used by the legacy fast path where the assembler already
* produced the UTF-8 string).
*/
@NonNull
public static JSONObject decodeJsonString(@NonNull String json) throws Exception {
return new JSONObject(json);
}
@Nullable
private static JSONObject decodeMsgpack(@NonNull byte[] payload) throws Exception {
try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(payload)) {
if (!unpacker.hasNext()) return null;
Value root = unpacker.unpackValue();
if (root == null || !root.getValueType().isMapType()) {
return null;
}
return (JSONObject) toJson(root);
}
}
/**
* Recursively convert a MessagePack {@link Value} to an {@code org.json.*}
* tree. Maps become {@link JSONObject}, arrays become {@link JSONArray},
* binary blobs become Base64 strings (we don't expect any in this
* protocol, but they must not crash the decoder).
*/
private static Object toJson(@NonNull Value v) throws Exception {
ValueType t = v.getValueType();
switch (t) {
case NIL:
return JSONObject.NULL;
case BOOLEAN:
return v.asBooleanValue().getBoolean();
case INTEGER:
// Prefer long to preserve 64-bit MMSI values etc.
return v.asIntegerValue().toLong();
case FLOAT:
return v.asFloatValue().toDouble();
case STRING:
return v.asStringValue().asString();
case BINARY:
// Shouldn't occur on this wire format; stringify defensively.
return android.util.Base64.encodeToString(
v.asBinaryValue().asByteArray(), android.util.Base64.NO_WRAP);
case ARRAY: {
JSONArray arr = new JSONArray();
for (Value item : v.asArrayValue()) {
arr.put(toJson(item));
}
return arr;
}
case MAP: {
JSONObject obj = new JSONObject();
for (Map.Entry<Value, Value> e : v.asMapValue().entrySet()) {
// Keys in our protocol are always strings. If a non-string
// key sneaks in, stringify it so we don't silently drop data.
String key = e.getKey().getValueType() == ValueType.STRING
? e.getKey().asStringValue().asString()
: e.getKey().toJson();
obj.put(key, toJson(e.getValue()));
}
return obj;
}
case EXTENSION:
default:
// Unknown / extension types: stringify to avoid crashing.
return v.toJson();
}
}
}
@@ -0,0 +1,91 @@
package com.grigowashere.aismap.ble.hub;
import android.util.Log;
/**
* Tracks clock offset between the AIS Hub server ("hub time") and this device
* ("device wall clock"). Used to translate absolute server timestamps
* (`ts`, `server_time`, `last_dynamic_ts`, ...) into device-local epoch seconds
* so that stale-data logic (which works against {@code LocalDateTime.now()})
* stays correct even when the two clocks disagree.
*
* <p>Thread-safety: all state is static/volatile; updates are cheap and safe
* from any thread.
*/
public final class HubTimeSync {
private static final String TAG = "HubTimeSync";
/**
* Minimum server epoch we consider plausible (2010-01-01). Anything below is
* ignored.
*/
private static final double MIN_EPOCH = 1262304000.0;
/**
* Clamp absurd offsets (1 year) to protect from a single bad sample.
*/
private static final double MAX_ABS_OFFSET_SEC = 365L * 24 * 3600;
private static volatile boolean hasOffset = false;
/** offsetSec = serverTime - deviceTime; deviceTime = serverTime - offsetSec. */
private static volatile double offsetSec = 0.0;
private static volatile long lastUpdateElapsedRealtimeMs = 0L;
private HubTimeSync() {}
/**
* Feeds a server timestamp (epoch seconds, possibly milliseconds) and the
* device wall-clock moment when it was observed. Values that look broken
* are dropped.
*/
public static void updateFromServerSeconds(double serverEpochSec) {
if (Double.isNaN(serverEpochSec) || Double.isInfinite(serverEpochSec)) return;
// Some payloads send milliseconds in a double; normalize.
if (serverEpochSec > 1e12) serverEpochSec /= 1000.0;
if (serverEpochSec < MIN_EPOCH) return;
double deviceNowSec = System.currentTimeMillis() / 1000.0;
double newOffset = serverEpochSec - deviceNowSec;
if (Math.abs(newOffset) > MAX_ABS_OFFSET_SEC) {
Log.w(TAG, "Ignoring implausible offset " + newOffset + "s (server=" + serverEpochSec + ")");
return;
}
// Light exponential smoothing to absorb jitter without lagging a real clock drift.
if (hasOffset) {
offsetSec = 0.2 * newOffset + 0.8 * offsetSec;
} else {
offsetSec = newOffset;
}
hasOffset = true;
lastUpdateElapsedRealtimeMs = android.os.SystemClock.elapsedRealtime();
}
public static boolean hasOffset() {
return hasOffset;
}
public static double getOffsetSec() {
return offsetSec;
}
/**
* Converts a server-side epoch-seconds timestamp into a device-side
* epoch-seconds timestamp using the learned offset. If no offset has been
* observed yet, returns {@code serverEpochSec} unchanged (best effort).
*/
public static double toDeviceEpochSeconds(double serverEpochSec) {
if (Double.isNaN(serverEpochSec) || serverEpochSec <= 0) return serverEpochSec;
if (serverEpochSec > 1e12) serverEpochSec /= 1000.0;
if (!hasOffset) return serverEpochSec;
return serverEpochSec - offsetSec;
}
/**
* Reset state (e.g. on BLE stop). Primarily useful for tests.
*/
public static void reset() {
hasOffset = false;
offsetSec = 0.0;
lastUpdateElapsedRealtimeMs = 0L;
}
}
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,7 @@ import com.grigowashere.aismap.data.entity.AISVesselEntity;
import com.grigowashere.aismap.data.mapper.AISVesselMapper; import com.grigowashere.aismap.data.mapper.AISVesselMapper;
import com.grigowashere.aismap.models.Vessel; import com.grigowashere.aismap.models.Vessel;
import com.grigowashere.aismap.models.AISVessel; import com.grigowashere.aismap.models.AISVessel;
import com.grigowashere.aismap.models.AISNavigationAid;
import com.grigowashere.aismap.utils.SettingsManager; import com.grigowashere.aismap.utils.SettingsManager;
import java.util.List; import java.util.List;
import java.util.ArrayList; import java.util.ArrayList;
@@ -38,7 +39,7 @@ public class DataController {
private DataControllerListener listener; private DataControllerListener listener;
public interface DataControllerListener { 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 onDataSaved(String dataType, boolean success);
void onDataCleaned(int removedCount); void onDataCleaned(int removedCount);
} }
@@ -47,7 +48,7 @@ public class DataController {
this.context = context; this.context = context;
this.repository = new Repository(context); this.repository = new Repository(context);
this.settingsManager = new SettingsManager(context); this.settingsManager = new SettingsManager(context);
this.executor = Executors.newCachedThreadPool(); this.executor = Executors.newSingleThreadExecutor();
// Инициализируем Handler для периодической очистки БД // Инициализируем Handler для периодической очистки БД
this.dbCleanupHandler = new Handler(Looper.getMainLooper()); this.dbCleanupHandler = new Handler(Looper.getMainLooper());
@@ -70,6 +71,8 @@ public class DataController {
Log.i(TAG, "🔄 Запускаем асинхронное восстановление данных из БД..."); Log.i(TAG, "🔄 Запускаем асинхронное восстановление данных из БД...");
executor.execute(() -> { executor.execute(() -> {
try { try {
cleanupStaleAISSync("перед восстановлением");
Log.d(TAG, "📊 Загружаем данные судна из БД..."); Log.d(TAG, "📊 Загружаем данные судна из БД...");
VesselEntity latest = repository.getLatestOwnVesselSync(); VesselEntity latest = repository.getLatestOwnVesselSync();
Vessel vessel = null; Vessel vessel = null;
@@ -87,21 +90,29 @@ public class DataController {
Log.d(TAG, "🚢 Загружаем AIS суда из БД..."); Log.d(TAG, "🚢 Загружаем AIS суда из БД...");
List<AISVesselEntity> list = repository.getAllAISSync(); List<AISVesselEntity> list = repository.getAllAISSync();
List<AISVessel> aisVessels = new ArrayList<>(); List<AISVessel> aisVessels = new ArrayList<>();
List<AISNavigationAid> navigationAids = new ArrayList<>();
if (list != null && !list.isEmpty()) { if (list != null && !list.isEmpty()) {
for (AISVesselEntity entity : list) { for (AISVesselEntity entity : list) {
// Используем маппер для полного восстановления всех полей // Проверяем, является ли это навигационным знаком
AISVessel vesselModel = AISVesselMapper.toModel(entity); if ("Navigation Aid".equals(entity.vesselClass)) {
aisVessels.add(vesselModel); // Создаем AISNavigationAid из entity
Log.d(TAG, "AIS судно восстановлено из БД с полными данными: " + vesselModel.getMmsi()); 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 { } else {
Log.d(TAG, "ℹ️ Нет AIS судов в БД"); Log.d(TAG, "ℹ️ Нет AIS судов в БД");
} }
// Уведомляем слушателя о восстановленных данных // Уведомляем слушателя о восстановленных данных
if (listener != null) { if (listener != null) {
listener.onDataRestored(vessel, aisVessels); listener.onDataRestored(vessel, aisVessels, navigationAids);
} }
} catch (Exception e) { } catch (Exception e) {
@@ -147,8 +158,8 @@ public class DataController {
try { try {
// Используем маппер для полной конвертации всех полей // Используем маппер для полной конвертации всех полей
AISVesselEntity entity = AISVesselMapper.toEntity(vessel); AISVesselEntity entity = AISVesselMapper.toEntity(vessel);
repository.upsertAIS(entity); repository.upsertAISSync(entity);
Log.d(TAG, "AIS судно сохранено в БД с полными данными: " + vessel.getMmsi()); Log.d(TAG, "AIS судно сохранено в БД: " + vessel.getMmsi());
if (listener != null) { if (listener != null) {
listener.onDataSaved("ais_vessel", true); 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 целей * Запускает периодическую очистку БД от устаревших AIS целей
@@ -186,25 +325,32 @@ public class DataController {
* Выполняет очистку БД от устаревших AIS целей * Выполняет очистку БД от устаревших AIS целей
*/ */
private void performDatabaseCleanup() { private void performDatabaseCleanup() {
try { executor.execute(() -> {
int staleRemoveMinutes = settingsManager.getDataStaleRemoveMinutes(); try {
long thresholdEpochMs = System.currentTimeMillis() - (staleRemoveMinutes * 60 * 1000L); int removed = cleanupStaleAISSync(null);
repository.deleteStaleAIS(thresholdEpochMs); if (listener != null) {
listener.onDataCleaned(removed);
Log.i(TAG, "Выполнена очистка БД от AIS целей старше " + staleRemoveMinutes + " минут"); }
if (listener != null) { // Планируем следующую очистку
listener.onDataCleaned(0); // Метод не возвращает количество удаленных записей 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); private int cleanupStaleAISSync(String reason) {
} int staleRemoveMinutes = settingsManager.getDataStaleRemoveMinutes();
} catch (Exception e) { long thresholdEpochMs = System.currentTimeMillis() - (staleRemoveMinutes * 60 * 1000L);
Log.e(TAG, "Ошибка при очистке БД от устаревших AIS целей: " + e.getMessage(), e); 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 обновлен: " + location.getLatitude() + ", " + location.getLongitude());
Log.i(TAG, "📍 Точность: " + location.getAccuracy() + "м, время: " + location.getTime()); Log.i(TAG, "📍 Точность: " + location.getAccuracy() + "м, время: " + location.getTime());
// Создаем объект судна с полученными данными
Vessel vessel = new Vessel(); Vessel vessel = new Vessel();
vessel.setLatitude(location.getLatitude()); vessel.setLatitude(location.getLatitude());
vessel.setLongitude(location.getLongitude()); vessel.setLongitude(location.getLongitude());
vessel.setAccuracy(location.getAccuracy()); vessel.setAccuracy(location.getAccuracy());
vessel.setFixTime(location.getTime()); 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.hasAccuracy()) {
if (location.getAccuracy() <= 3) { if (location.getAccuracy() <= 3) {
vessel.setFixQuality("HIGH_ACCURACY"); vessel.setFixQuality("HIGH_ACCURACY");
@@ -242,8 +252,7 @@ public class GPSLocationListener implements LocationListener {
vessel.setFixQuality("LOW_ACCURACY"); vessel.setFixQuality("LOW_ACCURACY");
} }
} }
// Обновляем информацию о спутниках
vessel.updateGPSQuality(satelliteCount, activeSatellites, pdop, hdop, vdop, location.getAccuracy()); vessel.updateGPSQuality(satelliteCount, activeSatellites, pdop, hdop, vdop, location.getAccuracy());
// Отправляем обновление через callback // Отправляем обновление через callback
@@ -40,6 +40,7 @@ public class NMEAController implements
void onVesselUpdated(Vessel vessel); void onVesselUpdated(Vessel vessel);
void onDOPUpdated(double pdop, double hdop, double vdop); void onDOPUpdated(double pdop, double hdop, double vdop);
void onAISVesselUpdated(AISVessel vessel); void onAISVesselUpdated(AISVessel vessel);
void onNavigationAidUpdated(com.grigowashere.aismap.models.AISNavigationAid navigationAid);
void onParseError(String error); void onParseError(String error);
void onGPSLocationUpdated(Vessel vessel); 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 @Override
public void onParseError(String error) { public void onParseError(String error) {
Log.e(TAG, "Ошибка парсинга NMEA: " + error); Log.e(TAG, "Ошибка парсинга NMEA: " + error);
@@ -3,6 +3,7 @@ package com.grigowashere.aismap.controllers;
import android.util.Log; import android.util.Log;
import com.grigowashere.aismap.models.Vessel; import com.grigowashere.aismap.models.Vessel;
import com.grigowashere.aismap.models.AISVessel; import com.grigowashere.aismap.models.AISVessel;
import com.grigowashere.aismap.models.AISNavigationAid;
import com.grigowashere.aismap.utils.LogSender; import com.grigowashere.aismap.utils.LogSender;
import java.util.List; import java.util.List;
@@ -33,6 +34,7 @@ public class NMEAParser {
private Vessel ownVessel; private Vessel ownVessel;
private List<AISVessel> aisVessels; private List<AISVessel> aisVessels;
private List<AISNavigationAid> navigationAids;
private NMEAParserListener listener; private NMEAParserListener listener;
private GPSLocationListener gpsLocationListener; private GPSLocationListener gpsLocationListener;
@@ -55,6 +57,7 @@ public class NMEAParser {
public interface NMEAParserListener { public interface NMEAParserListener {
void onVesselUpdated(Vessel vessel); void onVesselUpdated(Vessel vessel);
void onAISVesselUpdated(AISVessel vessel); void onAISVesselUpdated(AISVessel vessel);
void onNavigationAidUpdated(AISNavigationAid navigationAid);
void onParseError(String error); void onParseError(String error);
void onDOPUpdated(double pdop, double hdop, double vdop); void onDOPUpdated(double pdop, double hdop, double vdop);
} }
@@ -62,6 +65,7 @@ public class NMEAParser {
public NMEAParser() { public NMEAParser() {
this.ownVessel = new Vessel(); this.ownVessel = new Vessel();
this.aisVessels = new ArrayList<>(); this.aisVessels = new ArrayList<>();
this.navigationAids = new ArrayList<>();
} }
public void setListener(NMEAParserListener listener) { public void setListener(NMEAParserListener listener) {
@@ -97,6 +101,7 @@ public class NMEAParser {
String cleanedSentence = cleanNMEASentence(nmeaSentence); String cleanedSentence = cleanNMEASentence(nmeaSentence);
if (cleanedSentence == null) { if (cleanedSentence == null) {
Log.w(TAG, "NMEA сообщение не удалось очистить или слишком короткое: " + nmeaSentence); Log.w(TAG, "NMEA сообщение не удалось очистить или слишком короткое: " + nmeaSentence);
LogSender.logDroppedNMEA("Очистка не удалась", nmeaSentence, "Сообщение слишком короткое или некорректное");
return; return;
} }
// Диагностика: логируем только каждые 10 секунд // Диагностика: логируем только каждые 10 секунд
@@ -114,6 +119,7 @@ public class NMEAParser {
String[] fields = cleanedSentence.split(","); String[] fields = cleanedSentence.split(",");
if (fields.length < 2) { if (fields.length < 2) {
Log.w(TAG, "NMEA сообщение слишком короткое: " + cleanedSentence); Log.w(TAG, "NMEA сообщение слишком короткое: " + cleanedSentence);
LogSender.logDroppedNMEA("Слишком короткое", cleanedSentence, "Меньше 2 полей: " + fields.length);
return; return;
} }
@@ -121,6 +127,7 @@ public class NMEAParser {
String preamble = fields[0]; String preamble = fields[0];
if (preamble.length() < 6) { if (preamble.length() < 6) {
Log.w(TAG, "Некорректная приамбула: " + preamble); Log.w(TAG, "Некорректная приамбула: " + preamble);
LogSender.logDroppedNMEA("Некорректная приамбула", cleanedSentence, "Длина приамбуды: " + preamble.length());
return; return;
} }
@@ -159,15 +166,18 @@ public class NMEAParser {
} else { } else {
// Убираем лишние логи - только каждые 10 секунд // Убираем лишние логи - только каждые 10 секунд
long now2 = System.currentTimeMillis(); long now2 = System.currentTimeMillis();
if (now2 - lastNMEALogTime > 10000) {
Log.d(TAG, "📡 NMEAParser: неподдерживаемый тип: " + messageType); Log.d(TAG, "📡 NMEAParser: неподдерживаемый тип: " + messageType);
LogSender.logDroppedNMEA("Неподдерживаемый тип", cleanedSentence, "Тип: " + messageType);
lastNMEALogTime = now2; lastNMEALogTime = now2;
}
} }
break; break;
} }
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Ошибка парсинга NMEA: " + e.getMessage(), 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) { if (listener != null) {
listener.onParseError("Ошибка парсинга NMEA: " + e.getMessage()); listener.onParseError("Ошибка парсинга NMEA: " + e.getMessage());
} }
@@ -509,44 +519,16 @@ public class NMEAParser {
listener.onVesselUpdated(ownVessel); listener.onVesselUpdated(ownVessel);
} }
} }
/** /**
* Парсит GLL сообщение (Geographic Position - Latitude/Longitude) * Парсит GLL сообщение (Geographic Position - Latitude/Longitude)
* В гибридном режиме игнорируем * В гибридном режиме игнорируем
* Формат: $GPGLL,lat,N/S,lon,E/W,time,status,mode*checksum * Формат: $GPGLL,lat,N/S,lon,E/W,time,status,mode*checksum
*/ */
private void parseGLL(String[] fields) { private void parseGLL(String[] fields) {
// Разбираем время фикса (поле 5), статус (поле 6) и режим (поле 7) // GLL: не обновляем fixQuality и время фикса источники: GSA и RMC/ZDA
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);
}
// Если не в гибридном режиме обновляем координаты // Если не в гибридном режиме обновляем координаты
if (!hybridMode) { if (!hybridMode) {
@@ -800,6 +782,13 @@ public class NMEAParser {
ownVessel.setHdop(hdop); ownVessel.setHdop(hdop);
ownVessel.setVdop(vdop); ownVessel.setVdop(vdop);
// Обновляем оценку точности в метрах из HDOP
// Эмпирически принимаем ~5 м на единицу HDOP (типовое допущение для GNSS)
if (hdop > 0) {
float accuracyMeters = (float)(hdop * 5.0);
ownVessel.setAccuracy(accuracyMeters);
}
// Отправляем DOP значения в GPS Location Listener // Отправляем DOP значения в GPS Location Listener
if (gpsLocationListener != null) { if (gpsLocationListener != null) {
gpsLocationListener.setDOPValues(pdop, hdop, vdop); gpsLocationListener.setDOPValues(pdop, hdop, vdop);
@@ -827,6 +816,7 @@ public class NMEAParser {
// Log.d(TAG, "AIS поля (" + fields.length + "): " + java.util.Arrays.toString(fields)); // Log.d(TAG, "AIS поля (" + fields.length + "): " + java.util.Arrays.toString(fields));
if (fields.length < 7) { if (fields.length < 7) {
Log.w(TAG, "AIS сообщение слишком короткое: " + ais); Log.w(TAG, "AIS сообщение слишком короткое: " + ais);
LogSender.logAISParseErrorWithFullNMEA("Слишком короткое", ais, ais, "Поля: " + fields.length + " < 7");
return; return;
} }
@@ -876,6 +866,7 @@ public class NMEAParser {
// Проверяем контрольную сумму // Проверяем контрольную сумму
if (!validateChecksum(ais)) { if (!validateChecksum(ais)) {
//Log.w(TAG, "AIS сообщение с неверной контрольной суммой: " + ais); //Log.w(TAG, "AIS сообщение с неверной контрольной суммой: " + ais);
LogSender.logAISParseErrorWithFullNMEA("Неверная контрольная сумма", ais, payload, "Checksum validation failed");
return; return;
} }
@@ -883,19 +874,21 @@ public class NMEAParser {
if (payload != null && !payload.trim().isEmpty()) { if (payload != null && !payload.trim().isEmpty()) {
if (totalFragments == 1) { if (totalFragments == 1) {
// Одноканальное сообщение - декодируем сразу // Одноканальное сообщение - декодируем сразу
decodeAISPayload(payload, channel != null && channel.equals("A") ? 0 : 1); decodeAISPayload(payload, channel != null && channel.equals("A") ? 0 : 1, ais);
} else { } else {
// Многочастное сообщение - собираем фрагменты // Многочастное сообщение - собираем фрагменты
// Используем номер фрагмента как sequenceId если поле пустое // Используем номер фрагмента как sequenceId если поле пустое
String actualSequenceId = (sequenceId != null && !sequenceId.trim().isEmpty()) ? String actualSequenceId = (sequenceId != null && !sequenceId.trim().isEmpty()) ?
sequenceId : String.valueOf(fragmentNumber); 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 { } else {
//Log.w(TAG, "AIS payload пустой, пропускаем сообщение"); //Log.w(TAG, "AIS payload пустой, пропускаем сообщение");
LogSender.logAISParseErrorWithFullNMEA("Пустой payload", ais, payload, "Payload is null or empty");
} }
} catch (Exception e) { } catch (Exception e) {
//Log.e(TAG, "Ошибка парсинга AIS сообщения: " + e.getMessage() + " для сообщения: " + ais); //Log.e(TAG, "Ошибка парсинга AIS сообщения: " + e.getMessage() + " для сообщения: " + ais);
LogSender.logAISParseErrorWithFullNMEA("Exception", ais, ais, "Exception: " + e.getMessage());
if (listener != null) { if (listener != null) {
listener.onParseError("Ошибка парсинга AIS: " + e.getMessage()); listener.onParseError("Ошибка парсинга AIS: " + e.getMessage());
} }
@@ -905,7 +898,7 @@ public class NMEAParser {
/** /**
* Декодирует AIS payload * Декодирует AIS payload
*/ */
private void decodeAISPayload(String payload, int channel) { private void decodeAISPayload(String payload, int channel, String fullNMEAMessage) {
try { try {
// Определяем тип AIS сообщения по первым 6 битам // Определяем тип AIS сообщения по первым 6 битам
String messageTypeBits = decodeAISField(payload, 0, 6); String messageTypeBits = decodeAISField(payload, 0, 6);
@@ -953,10 +946,12 @@ public class NMEAParser {
break; break;
default: default:
Log.d(TAG, "Неподдерживаемый тип AIS сообщения: " + messageType); Log.d(TAG, "Неподдерживаемый тип AIS сообщения: " + messageType);
LogSender.logAISParseErrorWithFullNMEA("Неподдерживаемый тип", fullNMEAMessage, payload, "Тип: " + messageType);
break; break;
} }
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Ошибка декодирования AIS payload: " + e.getMessage(), 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 сообщения * Собирает фрагменты многочастного AIS сообщения
*/ */
private void collectAISFragments(String sequenceId, int fragmentNumber, int totalFragments, private void collectAISFragments(String sequenceId, int fragmentNumber, int totalFragments,
String payload, int channel) { String payload, int channel, String fullNMEAMessage) {
String key = sequenceId + "_" + channel; String key = sequenceId + "_" + channel;
// Log.d(TAG, String.format("Собираем AIS фраг мент: %d/%d для %s", // Log.d(TAG, String.format("Собираем AIS фраг мент: %d/%d для %s",
@@ -1007,7 +1002,7 @@ public class NMEAParser {
// Log.d(TAG, "Собрано полное AIS сообщение длиной " + completePayload.length() + " символов"); // Log.d(TAG, "Собрано полное AIS сообщение длиной " + completePayload.length() + " символов");
// Декодируем полное сообщение // Декодируем полное сообщение
decodeAISPayload(completePayload, channel); decodeAISPayload(completePayload, channel, fullNMEAMessage);
// Удаляем собранные фрагменты // Удаляем собранные фрагменты
aisFragments.remove(key); aisFragments.remove(key);
@@ -1079,6 +1074,9 @@ public class NMEAParser {
", payloadLength=" + payload.length() + ", payloadLength=" + payload.length() +
", binaryLength=" + fullBinary.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()) { if (startBit >= fullBinary.length()) {
// Если startBit уже за границами, возвращаем строку из нулей // Если startBit уже за границами, возвращаем строку из нулей
@@ -1172,9 +1170,11 @@ public class NMEAParser {
// Проверяем, что координаты в разумных пределах // Проверяем, что координаты в разумных пределах
if (latitude < -90 || latitude > 90) { if (latitude < -90 || latitude > 90) {
Log.w(TAG, "Широта вне допустимых пределов: " + latitude); Log.w(TAG, "Широта вне допустимых пределов: " + latitude);
LogSender.logAISParseError("Некорректная широта", payload, "Latitude: " + latitude);
} }
if (longitude < -180 || longitude > 180) { if (longitude < -180 || longitude > 180) {
Log.w(TAG, "Долгота вне допустимых пределов: " + longitude); 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", 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) { } catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Position Report: " + e.getMessage(), 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) { } catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Static Data: " + e.getMessage(), 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 * Получает тип судна по коду согласно стандарту AIS
*/ */
@@ -1752,6 +1794,23 @@ public class NMEAParser {
return newVessel; 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 минут) * Очищает устаревшие AIS суда (данные старше 10 минут)
*/ */
@@ -1793,6 +1852,54 @@ public class NMEAParser {
return null; 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 судов * Обновляет статус активности AIS судов
*/ */
@@ -1822,6 +1929,8 @@ public class NMEAParser {
return isPositive ? result : -result; return isPositive ? result : -result;
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
Log.w(TAG, "Ошибка парсинга координаты: " + coordinate + ", ошибка: " + e.getMessage()); Log.w(TAG, "Ошибка парсинга координаты: " + coordinate + ", ошибка: " + e.getMessage());
LogSender.logError("COORDINATE_PARSE_ERROR", "Ошибка парсинга координаты",
String.format("Coordinate: %s, Error: %s", coordinate, e.getMessage()));
return 0.0; return 0.0;
} }
} }
@@ -1994,6 +2103,7 @@ public class NMEAParser {
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Base Station Report: " + e.getMessage(), 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) { } catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Safety Broadcast: " + e.getMessage(), 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) { } catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Class B Position Report: " + e.getMessage(), 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 if (totalBits < 312) { // Минимум для Extended Class B
Log.w(TAG, "Extended Class B payload слишком короткий: " + totalBits + " бит, ожидается минимум 312"); Log.w(TAG, "Extended Class B payload слишком короткий: " + totalBits + " бит, ожидается минимум 312");
LogSender.logAISParseError("Extended Class B слишком короткий", payload, "Bits: " + totalBits + " < 312");
return; return;
} }
@@ -2314,6 +2427,8 @@ public class NMEAParser {
// Проверяем, что размеры в разумных пределах (0-1000 метров) // Проверяем, что размеры в разумных пределах (0-1000 метров)
if (dimA > 1000 || dimB > 1000 || dimC > 1000 || dimD > 1000) { if (dimA > 1000 || dimB > 1000 || dimC > 1000 || dimD > 1000) {
Log.w(TAG, "Размеры судна выходят за разумные пределы: A=" + dimA + ", B=" + dimB + ", C=" + dimC + ", D=" + dimD); 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-битные значения // Попробуем интерпретировать как 6-битные значения
dimA = dimA & 0x3F; // Берем только младшие 6 бит dimA = dimA & 0x3F; // Берем только младшие 6 бит
@@ -2380,6 +2495,7 @@ public class NMEAParser {
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Extended Class B Position Report: " + e.getMessage(), 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) { private void decodeAidToNavigationReport(String payload) {
try { 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 // MMSI (30 бит) - начинается с бита 8
String mmsiBits = decodeAISField(payload, 8, 30); String mmsiBits = decodeAISField(payload, 8, 30);
int mmsi = Integer.parseInt(mmsiBits, 2); int mmsi = Integer.parseInt(mmsiBits, 2);
// Убираем лишние логи Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi);
// Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi);
// Aid Type (5 бит) - бит 38 // Aid Type (5 бит) - бит 38
String aidTypeBits = decodeAISField(payload, 38, 5); String aidTypeBits = decodeAISField(payload, 38, 5);
int aidType = Integer.parseInt(aidTypeBits, 2); 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 // Name (120 бит) - бит 43
String nameBits = decodeAISField(payload, 43, 120); String nameBits = decodeAISField(payload, 43, 120);
String aidName = decodeAISString(nameBits); String aidName = decodeAISString(nameBits);
// Log.d(TAG, "Name bits: " + nameBits + " = '" + aidName + "'"); Log.d(TAG, "Name bits: " + nameBits + " = '" + aidName + "'");
// Position Accuracy (1 бит) - бит 163 // Position Accuracy (1 бит) - бит 163
String accuracyBits = decodeAISField(payload, 163, 1); String accuracyBits = decodeAISField(payload, 163, 1);
int accuracy = Integer.parseInt(accuracyBits, 2); int accuracy = Integer.parseInt(accuracyBits, 2);
// Log.d(TAG, "Accuracy bits: " + accuracyBits + " = " + accuracy); Log.d(TAG, "Accuracy bits: " + accuracyBits + " = " + accuracy);
// Longitude (28 бит) - бит 164 // Longitude (28 бит) - бит 164
String lonBits = decodeAISField(payload, 164, 28); String lonBits = decodeAISField(payload, 164, 28);
double longitude = parseAISCoordinate(lonBits, 28); double longitude = parseAISCoordinate(lonBits, 28);
// Убираем лишние логи Log.d(TAG, "Longitude bits: " + lonBits + " = " + longitude);
// Log.d(TAG, "Longitude bits: " + lonBits + " = " + longitude);
// Latitude (27 бит) - бит 192 // Latitude (27 бит) - бит 192
String latBits = decodeAISField(payload, 192, 27); String latBits = decodeAISField(payload, 192, 27);
double latitude = parseAISCoordinate(latBits, 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 // Dimension Reference (4 бита) - бит 219
String dimRefABits = decodeAISField(payload, 219, 4); String dimRefABits = decodeAISField(payload, 219, 4);
@@ -2434,6 +2575,13 @@ public class NMEAParser {
int dimRefC = Integer.parseInt(dimRefCBits, 2); int dimRefC = Integer.parseInt(dimRefCBits, 2);
int dimRefD = Integer.parseInt(dimRefDBits, 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 // Vessel Dimensions (30 бит) - бит 235
// Dim.A (10 бит) - от носа до антенны // Dim.A (10 бит) - от носа до антенны
String dimABits = decodeAISField(payload, 235, 10); String dimABits = decodeAISField(payload, 235, 10);
@@ -2443,43 +2591,92 @@ public class NMEAParser {
String dimCBits = decodeAISField(payload, 255, 10); String dimCBits = decodeAISField(payload, 255, 10);
// Dim.D (10 бит) - от антенны до правого борта // Dim.D (10 бит) - от антенны до правого борта
String dimDBits = decodeAISField(payload, 265, 10); String dimDBits = decodeAISField(payload, 265, 10);
// Draft (8 бит) - осадка
String draftBits = decodeAISField(payload, 275, 8);
int dimA = Integer.parseInt(dimABits, 2); int dimA = Integer.parseInt(dimABits, 2);
int dimB = Integer.parseInt(dimBBits, 2); int dimB = Integer.parseInt(dimBBits, 2);
int dimC = Integer.parseInt(dimCBits, 2); int dimC = Integer.parseInt(dimCBits, 2);
int dimD = Integer.parseInt(dimDBits, 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.A + Dim.B (от носа до антенны + от антенны до кормы)
// Ширина = Dim.C + Dim.D (от левого борта до антенны + от антенны до правого борта) // Ширина = Dim.C + Dim.D (от левого борта до антенны + от антенны до правого борта)
double length = dimA + dimB; double length = dimA + dimB;
double width = dimC + dimD; 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", Log.d(TAG, "Dimensions - Total Length (A+B): " + length + "m");
// mmsi, aidType, aidName, latitude, longitude, length, width, draft)); Log.d(TAG, "Dimensions - Total Width (C+D): " + width + "m");
// Создаем или обновляем AIS судно (навигационный знак) navigationAid.setLength(length);
AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); navigationAid.setWidth(width);
vessel.updatePosition(latitude, longitude, 0.0, 0.0);
vessel.setPositionAccuracy(accuracy == 1); // Проверяем, есть ли достаточно битов для дополнительных полей
vessel.setVesselName(aidName); if (totalBits >= 283) {
vessel.setVesselType("Aid-to-Navigation"); // Draft (8 бит) - осадка
vessel.setLength(length); String draftBits = decodeAISField(payload, 275, 8);
vessel.setWidth(width); double draft = Integer.parseInt(draftBits, 2) / 10.0;
vessel.setDraft(draft); Log.d(TAG, "Draft bits: " + draftBits + " = " + draft);
vessel.setLastUpdate(java.time.LocalDateTime.now()); navigationAid.setDraft(draft);
vessel.setVesselClass("Navigation Aid");
// 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) { if (listener != null) {
listener.onAISVesselUpdated(vessel); listener.onNavigationAidUpdated(navigationAid);
} }
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Aid-to-Navigation Report: " + e.getMessage(), 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 { } else {
Log.w(TAG, "Static Data Part B - недостаточно битов для размеров: " + totalBits + " < 168"); Log.w(TAG, "Static Data Part B - недостаточно битов для размеров: " + totalBits + " < 168");
LogSender.logAISParseError("Static Data Part B слишком короткий", payload, "Bits: " + totalBits + " < 168");
// Используем нулевые размеры // Используем нулевые размеры
length = 0.0; length = 0.0;
width = 0.0; width = 0.0;
@@ -2623,6 +2821,7 @@ public class NMEAParser {
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Ошибка декодирования Static Data Report: " + e.getMessage(), 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)); 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) { public void deleteStaleAIS(long thresholdEpochMs) {
ioExecutor.execute(() -> aisVesselDao.deleteStale(thresholdEpochMs)); ioExecutor.execute(() -> aisVesselDao.deleteStale(thresholdEpochMs));
} }
public int deleteStaleAISSync(long thresholdEpochMs) {
return aisVesselDao.deleteStale(thresholdEpochMs);
}
public List<AISVesselEntity> getAllAISSync() { public List<AISVesselEntity> getAllAISSync() {
return aisVesselDao.getAll(); return aisVesselDao.getAll();
} }
@@ -16,6 +16,9 @@ public interface AISVesselDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
void upsert(AISVesselEntity entity); void upsert(AISVesselEntity entity);
@Insert(onConflict = OnConflictStrategy.REPLACE)
void upsertAll(List<AISVesselEntity> entities);
@Update @Update
void update(AISVesselEntity entity); void update(AISVesselEntity entity);
@@ -29,7 +32,7 @@ public interface AISVesselDao {
AISVesselEntity getByMmsi(String mmsi); AISVesselEntity getByMmsi(String mmsi);
@Query("DELETE FROM ais_vessels WHERE lastUpdateEpochMs < :threshold") @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 android.view.ViewGroup;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@@ -112,14 +113,24 @@ public class MapForgeImpl implements MapInterface {
@Override @Override
public void updateAISVesselPosition(AISVessel vessel) { public void updateAISVesselPosition(AISVessel vessel) {
Marker marker = aisMarkers.get(vessel.getMmsi()); Marker marker = aisMarkers.get(vessel.getMmsi());
if (marker != null) { if (marker == null) {
LatLong newPosition = new LatLong(vessel.getLatitude(), vessel.getLongitude()); addAISVesselMarker(vessel);
marker.setLatLong(newPosition); return;
}
// Используем heading вместо course для поворота маркера AIS судна LatLong newPosition = new LatLong(vessel.getLatitude(), vessel.getLongitude());
double rotationAngle = vessel.getHeading() > 0 ? vessel.getHeading() : vessel.getCourse(); marker.setLatLong(newPosition);
org.mapsforge.core.graphics.Bitmap icon = createMapForgeIcon(Color.RED, rotationAngle);
marker.setBitmap(icon); // Используем 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.Vessel;
import com.grigowashere.aismap.models.AISVessel; import com.grigowashere.aismap.models.AISVessel;
import java.util.List;
/** /**
* Интерфейс для работы с картами * Интерфейс для работы с картами
* Позволяет использовать разные SDK карт * Позволяет использовать разные SDK карт
@@ -38,6 +40,11 @@ public interface MapInterface {
* Обновление позиции AIS судна * Обновление позиции AIS судна
*/ */
void updateAISVesselPosition(AISVessel vessel); void updateAISVesselPosition(AISVessel vessel);
/**
* Пакетное обновление AIS судов
*/
void updateAISVesselPositions(List<AISVessel> vessels);
/** /**
* Удаление метки AIS судна * Удаление метки AIS судна
@@ -8,6 +8,7 @@ import android.graphics.drawable.Drawable;
import android.util.Log; import android.util.Log;
import com.grigowashere.aismap.models.AISVessel; import com.grigowashere.aismap.models.AISVessel;
import com.grigowashere.aismap.models.AISNavigationAid;
import com.grigowashere.aismap.models.Vessel; import com.grigowashere.aismap.models.Vessel;
import com.grigowashere.aismap.utils.SettingsManager; import com.grigowashere.aismap.utils.SettingsManager;
import com.grigowashere.aismap.utils.GeoUtils; 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.PropertyFactory;
import org.maplibre.android.style.layers.SymbolLayer; import org.maplibre.android.style.layers.SymbolLayer;
import org.maplibre.android.style.sources.GeoJsonSource; 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.HashMap;
import java.util.Map; 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 LAYER_AIS_PATHS = "ais_paths_layer";
private static final String SOURCE_AIS_PREDICTIONS = "ais_predictions_source"; private static final String SOURCE_AIS_PREDICTIONS = "ais_predictions_source";
private static final String LAYER_AIS_PREDICTIONS = "ais_predictions_layer"; 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_OWN = "ownship";
private static final String IMAGE_VESSEL_A = "vessel_icon_a"; private static final String IMAGE_VESSEL_A = "vessel_icon_a";
private static final String IMAGE_VESSEL_B = "vessel_icon_b"; private static final String IMAGE_VESSEL_B = "vessel_icon_b";
@@ -69,6 +76,44 @@ public class MapLibreMapImpl implements MapInterface {
private static final String TYPE_NAVY = "navy"; private static final String TYPE_NAVY = "navy";
private static final String TYPE_OTHER = "other"; private static final String TYPE_OTHER = "other";
private static final String IMAGE_VESSEL_LOSING = "vessel_icon_losing"; 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 // Status overlay drawable names present in res/drawable
private static final String STATUS_UNDER_WAY_ENGINE = "engine"; private static final String STATUS_UNDER_WAY_ENGINE = "engine";
private static final String STATUS_AT_ANCHOR = "achor"; // anchor icon filename 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, JSONObject> idToFeature = new HashMap<>();
// Хранилище последних модельных объектов для кликов // Хранилище последних модельных объектов для кликов
private final Map<String, AISVessel> idToAisVessel = new HashMap<>(); private final Map<String, AISVessel> idToAisVessel = new HashMap<>();
private final Map<String, AISNavigationAid> idToNavigationAid = new HashMap<>();
private Vessel lastOwnVessel; private Vessel lastOwnVessel;
// Буфер координат пути собственного судна // Буфер координат пути собственного судна
private final JSONArray ownPathCoords = new JSONArray(); private final JSONArray ownPathCoords = new JSONArray();
@@ -188,6 +234,10 @@ public class MapLibreMapImpl implements MapInterface {
private MarkerClickListener markerClickListener; private MarkerClickListener markerClickListener;
// Pending центрирование до готовности карты/стиля
private Double pendingCenterLat = null;
private Double pendingCenterLon = null;
public MapLibreMapImpl(Context context, MapView mapView) { public MapLibreMapImpl(Context context, MapView mapView) {
this.context = context; this.context = context;
this.mapView = mapView; this.mapView = mapView;
@@ -274,6 +324,18 @@ public class MapLibreMapImpl implements MapInterface {
try { try {
mapView.getMapAsync(map -> { mapView.getMapAsync(map -> {
maplibreMap = 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 -> { maplibreMap.setStyle("https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", loadedStyle -> {
style = loadedStyle; style = loadedStyle;
ensureSourcesAndLayers(); ensureSourcesAndLayers();
@@ -302,6 +364,15 @@ public class MapLibreMapImpl implements MapInterface {
staleHandler.removeCallbacks(staleRunnable); staleHandler.removeCallbacks(staleRunnable);
staleHandler.postDelayed(staleRunnable, 5_000L); 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) { } 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 @Override
public void removeAISVesselMarker(String mmsi) { public void removeAISVesselMarker(String mmsi) {
if (mmsi == null) return; if (mmsi == null) return;
@@ -555,13 +686,122 @@ public class MapLibreMapImpl implements MapInterface {
uiHandler.post(() -> refreshGeoJson()); 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 @Override
public void centerOnPosition(double latitude, double longitude) { public void centerOnPosition(double latitude, double longitude) {
if (maplibreMap == null) return; if (maplibreMap == null || style == null) {
maplibreMap.setCameraPosition(new org.maplibre.android.camera.CameraPosition.Builder() // Сохраним pending, применим после загрузки стиля
.target(new LatLng(latitude, longitude)) pendingCenterLat = latitude;
.zoom(13.0) pendingCenterLon = longitude;
.build()); // И на всякий случай повторим позже
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 @Override
@@ -620,12 +860,38 @@ public class MapLibreMapImpl implements MapInterface {
@Override @Override
public void addLayer(String layerId, Object layerData) { 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 @Override
public void removeLayer(String layerId) { 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 @Override
@@ -671,11 +937,16 @@ public class MapLibreMapImpl implements MapInterface {
Log.w(TAG, "Не удалось добавить иконки: " + e.getMessage()); Log.w(TAG, "Не удалось добавить иконки: " + e.getMessage());
} }
// Источник GeoJSON // Источник GeoJSON для судов
if (style.getSource(SOURCE_VESSELS) == null) { if (style.getSource(SOURCE_VESSELS) == null) {
style.addSource(new GeoJsonSource(SOURCE_VESSELS, emptyFeatureCollection())); 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); 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 // Слой предупреждения (losing) поверх рисуется поверх, если feature.properties.stale == true
if (style.getLayer(LAYER_VESSELS_STALE) == null && style.getImage(IMAGE_VESSEL_LOSING) != null) { if (style.getLayer(LAYER_VESSELS_STALE) == null && style.getImage(IMAGE_VESSEL_LOSING) != null) {
SymbolLayer losingLayer = new SymbolLayer(LAYER_VESSELS_STALE, SOURCE_VESSELS) SymbolLayer losingLayer = new SymbolLayer(LAYER_VESSELS_STALE, SOURCE_VESSELS)
@@ -783,6 +1084,9 @@ public class MapLibreMapImpl implements MapInterface {
// Восстанавливаем путь судна после создания слоев // Восстанавливаем путь судна после создания слоев
restoreVesselPath(); restoreVesselPath();
// Обновляем дополнительные слои на основе настроек
updateAdditionalLayers();
Log.d(TAG, "ensureSourcesAndLayers: completed"); Log.d(TAG, "ensureSourcesAndLayers: completed");
} }
@@ -958,6 +1262,54 @@ public class MapLibreMapImpl implements MapInterface {
Log.e(TAG, "refreshGeoJson: " + e.getMessage(), e); 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() { private void logMemoryUsage() {
Runtime runtime = Runtime.getRuntime(); Runtime runtime = Runtime.getRuntime();
@@ -1072,6 +1424,204 @@ public class MapLibreMapImpl implements MapInterface {
return feature; 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) { private double getDisplayCourse(AISVessel v) {
double hdg = v.getHeading(); double hdg = v.getHeading();
if (!Double.isNaN(hdg) && !Double.isInfinite(hdg)) { 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]", Log.d(TAG, String.format("checkAisVesselUnderCursor: searchRect=[%.1f,%.1f,%.1f,%.1f]",
searchRect.left, searchRect.top, searchRect.right, searchRect.bottom)); searchRect.left, searchRect.top, searchRect.right, searchRect.bottom));
// Ищем AIS суда в адаптивном радиусе от центра // Ищем AIS суда и навигационные знаки в адаптивном радиусе от центра
java.util.List<org.maplibre.geojson.Feature> features = maplibreMap.queryRenderedFeatures(searchRect, LAYER_VESSELS); 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( android.graphics.RectF expandedRect = new android.graphics.RectF(
screenPoint.x - 150, screenPoint.y - 150, screenPoint.x - 150, screenPoint.y - 150,
screenPoint.x + 150, screenPoint.y + 150 screenPoint.x + 150, screenPoint.y + 150
); );
features = maplibreMap.queryRenderedFeatures(expandedRect, LAYER_VESSELS); vesselFeatures = maplibreMap.queryRenderedFeatures(expandedRect, LAYER_VESSELS);
Log.d(TAG, String.format("checkAisVesselUnderCursor: найдено %d features в расширенном поиске", aidFeatures = maplibreMap.queryRenderedFeatures(expandedRect, LAYER_NAVIGATION_AIDS);
features != null ? features.size() : 0));
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()) { if (features != null && !features.isEmpty()) {
@@ -2160,6 +2722,7 @@ public class MapLibreMapImpl implements MapInterface {
Log.d(TAG, String.format("checkAisVesselUnderCursor: проверяем feature с id=%s", id)); Log.d(TAG, String.format("checkAisVesselUnderCursor: проверяем feature с id=%s", id));
if (id != null && !"own_vessel".equals(id)) { if (id != null && !"own_vessel".equals(id)) {
// Проверяем AIS судно
AISVessel vessel = idToAisVessel.get(id); AISVessel vessel = idToAisVessel.get(id);
if (vessel != null) { if (vessel != null) {
// Вычисляем географическое расстояние от центра до судна // Вычисляем географическое расстояние от центра до судна
@@ -2201,6 +2764,48 @@ public class MapLibreMapImpl implements MapInterface {
} else { } else {
Log.d(TAG, String.format("checkAisVesselUnderCursor: судно с id=%s не найдено в idToAisVessel", id)); 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)) { } else if ("own_vessel".equals(id)) {
Log.d(TAG, "checkAisVesselUnderCursor: пропускаем собственное судно"); 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() { private void setupMapMovementListener() {
if (maplibreMap != null) { if (maplibreMap != null) {
maplibreMap.addOnCameraMoveListener(() -> { 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 com.yandex.runtime.image.ImageProvider;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@@ -141,6 +142,14 @@ public class YandexMapImpl implements MapInterface {
markerManager.updateAISVesselMarker(vessel); markerManager.updateAISVesselMarker(vessel);
} }
} }
@Override
public void updateAISVesselPositions(List<AISVessel> vessels) {
if (vessels == null || markerManager == null) return;
for (AISVessel vessel : vessels) {
markerManager.updateAISVesselMarker(vessel);
}
}
@Override @Override
public void removeAISVesselMarker(String mmsi) { 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 int FILTER_SIZE = 60;
private static final float DEADBAND_DEG = 1.5f;
private float[] azimuthBuffer = new float[FILTER_SIZE]; private float[] azimuthBuffer = new float[FILTER_SIZE];
private int bufferIndex = 0; private int bufferIndex = 0;
private boolean bufferFull = false; private boolean bufferFull = false;
/** Last value sent to UI (circular deadband). */
private float lastReportedAzimuth = Float.NaN;
public interface CompassListener { public interface CompassListener {
void onCompassChanged(float azimuth); void onCompassChanged(float azimuth);
@@ -81,6 +84,7 @@ public class CompassSensor implements SensorEventListener {
private void resetFilter() { private void resetFilter() {
bufferIndex = 0; bufferIndex = 0;
bufferFull = false; bufferFull = false;
lastReportedAzimuth = Float.NaN;
for (int i = 0; i < FILTER_SIZE; i++) { for (int i = 0; i < FILTER_SIZE; i++) {
azimuthBuffer[i] = 0; azimuthBuffer[i] = 0;
} }
@@ -142,26 +146,39 @@ public class CompassSensor implements SensorEventListener {
} }
/** /**
* Применяет скользящий фильтр для сглаживания значений * Скользящее усреднение по кругу (векторное среднее), без скачков у 0°/360°.
*/ */
private float applyLowPassFilter(float newValue) { private float applyLowPassFilter(float newValue) {
// Добавляем новое значение в буфер
azimuthBuffer[bufferIndex] = newValue; azimuthBuffer[bufferIndex] = newValue;
bufferIndex = (bufferIndex + 1) % FILTER_SIZE; bufferIndex = (bufferIndex + 1) % FILTER_SIZE;
if (bufferIndex == 0) { if (bufferIndex == 0) {
bufferFull = true; bufferFull = true;
} }
// Вычисляем среднее значение
float sum = 0;
int count = bufferFull ? FILTER_SIZE : bufferIndex; int count = bufferFull ? FILTER_SIZE : bufferIndex;
if (count <= 0) {
for (int i = 0; i < count; i++) { return newValue;
sum += azimuthBuffer[i];
} }
double sx = 0.0;
return sum / count; 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() { 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(); boolean screenEnabled = settingsManager.isKeepScreenOnEnabled();
screenItem.setTitle(screenEnabled ? "Экран ✓" : "Экран"); screenItem.setTitle(screenEnabled ? "Экран ✓" : "Экран");
} }
MenuItem seamarksItem = menu.findItem(R.id.menu_seamarks);
if (seamarksItem != null) {
boolean seamarksEnabled = settingsManager.isSeamarksEnabled();
seamarksItem.setTitle(seamarksEnabled ? "Морские знаки ✓" : "Морские знаки");
}
return true; return true;
} catch (Exception e) { } catch (Exception e) {
Log.w(TAG, "onPrepareOptionsMenu: " + e.getMessage()); Log.w(TAG, "onPrepareOptionsMenu: " + e.getMessage());
@@ -78,6 +83,9 @@ public class MenuBinder {
} else if (id == R.id.menu_keep_screen_on) { } else if (id == R.id.menu_keep_screen_on) {
actions.toggleKeepScreenOn(); actions.toggleKeepScreenOn();
return true; return true;
} else if (id == R.id.menu_seamarks) {
actions.toggleSeamarks();
return true;
} }
} catch (Exception e) { } catch (Exception e) {
Log.w(TAG, "onOptionsItemSelected error: " + e.getMessage()); Log.w(TAG, "onOptionsItemSelected error: " + e.getMessage());
@@ -95,6 +103,7 @@ public class MenuBinder {
void togglePathTracking(); void togglePathTracking();
void testForegroundService(); void testForegroundService();
void toggleKeepScreenOn(); void toggleKeepScreenOn();
void toggleSeamarks();
} }
} }
@@ -10,6 +10,8 @@ import com.grigowashere.aismap.models.Vessel;
import com.grigowashere.aismap.models.AISVessel; import com.grigowashere.aismap.models.AISVessel;
import java.util.HashSet; import java.util.HashSet;
import java.util.ArrayList;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.HashMap; import java.util.HashMap;
@@ -156,10 +158,10 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
mapInterface.removeAISVesselMarker(mmsi); mapInterface.removeAISVesselMarker(mmsi);
} }
// Обновляем или добавляем суда (различать не будем - MapInterface сам решит) // Обновляем или добавляем суда пачкой, чтобы карта сделала один GeoJSON refresh.
for (AISVessel vessel : pendingAISUpdates.values()) { List<AISVessel> updates = new ArrayList<>(pendingAISUpdates.values());
Log.d(TAG, "Обновляем/добавляем AIS судно: " + vessel.getMmsi()); if (!updates.isEmpty()) {
mapInterface.updateAISVesselPosition(vessel); mapInterface.updateAISVesselPositions(updates);
} }
Log.d(TAG, "AIS updates выполнены: удалено=" + pendingAISRemovals.size() + Log.d(TAG, "AIS updates выполнены: удалено=" + pendingAISRemovals.size() +
@@ -2,370 +2,172 @@ package com.grigowashere.aismap.utils;
import android.util.Log; import android.util.Log;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; 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.Executors;
import java.util.concurrent.TimeUnit;
/** /**
* Утилита для отправки логов на внешний ресурс * Утилита для отправки логов на внешний ресурс
* Отправляет GET запросы на https://ais.grigowashere.ru/add * Отправляет пакеты логов раз в секунду на https://ais.grigowashere.ru/logs/batch
*/ */
public class LogSender { public class LogSender {
private static final String TAG = "LogSender"; private static final String TAG = "LogSender";
private static final String BASE_URL = "https://ais.grigowashere.ru/add"; private static final String BASE_URL = "https://ais.grigowashere.ru";
private static final ExecutorService executor = Executors.newSingleThreadExecutor();
/** /**
* Отправляет лог NMEA сообщения * Временно отключено, чтобы не создавать сетевой шум и фоновые потоки.
* @param nmeaMessage NMEA сообщение * Если снова понадобится переключить в true или завязать на настройку/BuildConfig.
*/ */
public static void logNMEA(String nmeaMessage) { private static final boolean ENABLED = false;
if (nmeaMessage == null || nmeaMessage.trim().isEmpty()) {
// Мягкие цвета для лучшей читаемости на фоне #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; return;
} }
executor.execute(() -> { Log.d(TAG, "Отправляем пакет из " + logsToSend.size() + " логов");
try { sendLogsBatch(logsToSend);
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);
}
});
} }
/** /**
* Отправляет лог обновления информации о корабле * Отправляет пакет логов через POST запрос
* @param mmsi MMSI корабля
* @param vesselInfo Информация о корабле
*/ */
public static void logShipUpdate(String mmsi, String vesselInfo) { private static void sendLogsBatch(List<LogEntry> logs) {
if (mmsi == null || mmsi.trim().isEmpty()) { if (!ENABLED) {
return; 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; HttpURLConnection connection = null;
try { try {
// Убираем лишние логи URL url = new URL(BASE_URL + "/api/logs/batch");
// Log.d(TAG, "Отправляем GET запрос на: " + urlString);
@SuppressWarnings("deprecation")
URL url = new URL(urlString);
connection = (HttpURLConnection) url.openConnection(); connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET"); connection.setRequestMethod("POST");
connection.setConnectTimeout(5000); // 5 секунд connection.setRequestProperty("Content-Type", "application/json");
connection.setReadTimeout(5000); // 5 секунд
connection.setRequestProperty("User-Agent", "AISMap/1.0"); 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(); int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) { if (responseCode == HttpURLConnection.HTTP_OK) {
// Убираем лишние логи Log.d(TAG, "Пакет из " + logs.size() + " логов успешно отправлен");
// Log.d(TAG, "Лог успешно отправлен, код ответа: " + responseCode);
} else { } else {
Log.w(TAG, "Лог отправлен с предупреждением, код ответа: " + responseCode); Log.w(TAG, "Пакет логов отправлен с предупреждением, код ответа: " + responseCode);
} }
} catch (IOException e) { } catch (IOException e) {
Log.e(TAG, "Ошибка HTTP запроса: " + e.getMessage(), e); Log.e(TAG, "Ошибка отправки пакета логов: " + e.getMessage(), e);
// Возвращаем логи в буфер при ошибке
synchronized (bufferLock) {
logBuffer.addAll(0, logs); // Добавляем в начало буфера
}
} finally { } finally {
if (connection != null) { if (connection != null) {
connection.disconnect(); 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() { 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_ANDROID_NMEA_ENABLED = "android_nmea_enabled";
private static final String KEY_UDP_NMEA_ENABLED = "udp_nmea_enabled"; private static final String KEY_UDP_NMEA_ENABLED = "udp_nmea_enabled";
private static final String KEY_DATA_MODE = "data_mode"; 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_WARNING_MINUTES = "data_stale_warning_minutes";
private static final String KEY_DATA_STALE_REMOVE_MINUTES = "data_stale_remove_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"; 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_CURSOR_ENABLED = "cursor_enabled";
private static final String KEY_NOTIFICATIONS_ENABLED = "notifications_enabled"; private static final String KEY_NOTIFICATIONS_ENABLED = "notifications_enabled";
private static final String KEY_DEBUG_ENABLED = "debug_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"; 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; 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_NOTIFICATIONS_ENABLED = true;
private static final boolean DEFAULT_ANDROID_GPS_ENABLED = true; private static final boolean DEFAULT_ANDROID_GPS_ENABLED = true;
private static final boolean DEFAULT_DEBUG_ENABLED = false; 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_HYBRID = "hybrid";
public static final String DATA_MODE_NMEA_ONLY = "nmea_only"; public static final String DATA_MODE_NMEA_ONLY = "nmea_only";
public static final String DATA_MODE_ANDROID_ONLY = "android_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 Context context;
private SharedPreferences prefs; private SharedPreferences prefs;
@@ -185,6 +229,38 @@ public class SettingsManager {
return DATA_MODE_ANDROID_ONLY.equals(getDataMode()); 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) * Проверяет, включен ли Android GPS (Location API)
*/ */
@@ -199,6 +275,55 @@ public class SettingsManager {
prefs.edit().putBoolean(KEY_ANDROID_GPS_ENABLED, enabled).apply(); prefs.edit().putBoolean(KEY_ANDROID_GPS_ENABLED, enabled).apply();
Log.i(TAG, "Android GPS: " + (enabled ? "включен" : "выключен")); 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_VIBRATION_ENABLED, DEFAULT_VIBRATION_ENABLED)
.putBoolean(KEY_SOUND_ENABLED, DEFAULT_SOUND_ENABLED) .putBoolean(KEY_SOUND_ENABLED, DEFAULT_SOUND_ENABLED)
.putBoolean(KEY_KEEP_SCREEN_ON_ENABLED, DEFAULT_KEEP_SCREEN_ON_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(); .apply();
Log.i(TAG, "Настройки сброшены к значениям по умолчанию"); Log.i(TAG, "Настройки сброшены к значениям по умолчанию");
} }
@@ -247,17 +380,80 @@ public class SettingsManager {
"UDP: порт=%d, включен=%s\n" + "UDP: порт=%d, включен=%s\n" +
"Android NMEA: %s\n" + "Android NMEA: %s\n" +
"UDP NMEA: %s\n" + "UDP NMEA: %s\n" +
"Старт центр по последней: %s, стартовый зум=%.1f\n" +
"BLE: %s, MAC=%s, Bridge=%s %s:%d\n" +
"Режим данных: %s\n" + "Режим данных: %s\n" +
"Уведомления: вибрация=%s, звук=%s", "Уведомления: вибрация=%s, звук=%s",
getUDPPort(), getUDPPort(),
isUDPEnabled() ? "да" : "нет", isUDPEnabled() ? "да" : "нет",
isAndroidNMEAEnabled() ? "включен" : "выключен", isAndroidNMEAEnabled() ? "включен" : "выключен",
isUDPNMEAEnabled() ? "включен" : "выключен", isUDPNMEAEnabled() ? "включен" : "выключен",
isStartCenterOnLastEnabled() ? "да" : "нет",
getStartZoomLevel(),
isBLEEnabled() ? "включен" : "выключен",
getBLEDeviceMac(),
isBleUdpBridgeEnabled() ? "вкл" : "выкл",
getBleUdpBridgeHost(),
getBleUdpBridgePort(),
getDataMode(), getDataMode(),
isVibrationEnabled() ? "включена" : "выключена", isVibrationEnabled() ? "включена" : "выключена",
isSoundEnabled() ? "включен" : "выключен" 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 слушатель * Проверяет, нужно ли перезапустить UDP слушатель
@@ -486,4 +682,19 @@ public class SettingsManager {
Log.i(TAG, "Дебаг-режим: " + (enabled ? "включен" : "выключен")); 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) { private void handleDockResize(MotionEvent event) {
float deltaY = event.getRawY() - lastTouchY; float deltaY = event.getRawY() - lastTouchY;
lastTouchY = event.getRawY(); lastTouchY = event.getRawY();
ViewGroup.LayoutParams lp = getLayoutParams(); // Ресайзим именно контент (dockHeightPx). Паддинги от WindowInsets
int newHeight = lp.height; // прибавляются поверх в onMeasure, поэтому «рабочая» часть не уезжает
// под системный бар даже при минимальном размере.
// Направление изменения размера зависит от позиции закрепления int currentContent = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
int newHeight = currentContent;
if (dockTop) { if (dockTop) {
// Если закреплен сверху, увеличиваем размер при движении вниз
newHeight += (int) deltaY; newHeight += (int) deltaY;
} else { } else {
// Если закреплен снизу, увеличиваем размер при движении вверх
newHeight -= (int) deltaY; newHeight -= (int) deltaY;
} }
// Ограничиваем минимальную и максимальную высоту
int minHeight = (int) dp(40); int minHeight = (int) dp(40);
int maxHeight = ((ViewGroup) getParent()).getHeight() / 2; int maxHeight = ((ViewGroup) getParent()).getHeight() / 2;
newHeight = Math.max(minHeight, Math.min(newHeight, maxHeight)); newHeight = Math.max(minHeight, Math.min(newHeight, maxHeight));
if (newHeight != lp.height) { if (newHeight != currentContent) {
lp.height = newHeight;
dockHeightPx = newHeight; dockHeightPx = newHeight;
ViewGroup.LayoutParams lp = getLayoutParams();
lp.height = newHeight + getPaddingTop() + getPaddingBottom();
setLayoutParams(lp); setLayoutParams(lp);
// Корректируем позицию Y в зависимости от позиции закрепления // Корректируем позицию Y в зависимости от позиции закрепления
@@ -324,7 +324,11 @@ public abstract class BaseDockWidget extends FrameLayout {
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (isDocked) { if (isDocked) {
int width = MeasureSpec.getSize(widthMeasureSpec); int width = MeasureSpec.getSize(widthMeasureSpec);
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); setMeasuredDimension(width, height);
} else { } else {
int size = (int)(dp(CIRCLE_SIZE_DP) * scaleFactor); int size = (int)(dp(CIRCLE_SIZE_DP) * scaleFactor);
@@ -9,6 +9,7 @@ import android.graphics.Color;
import android.graphics.Paint; import android.graphics.Paint;
import android.graphics.Path; import android.graphics.Path;
import android.graphics.RectF; import android.graphics.RectF;
import android.graphics.Typeface;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.util.Log; import android.util.Log;
import android.view.ViewGroup; import android.view.ViewGroup;
@@ -22,17 +23,32 @@ import java.util.List;
public class CompassView extends BaseDockWidget { public class CompassView extends BaseDockWidget {
private static final String TAG = "CompassView"; 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 targetAzimuth = 0;
private float currentAzimuth = 0; private float currentAzimuth = 0;
private float magneticCompass = 0; // магнитный компас private float magneticCompass = 0; // магнитный компас
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); 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 Paint vesselPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path vesselPath = new Path(); private final Path vesselPath = new Path();
private final String[] directions = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"}; private final String[] directions = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"};
private float centerX; private float centerX;
private float centerY; private float centerY;
private static final float SMOOTHING_FACTOR = 0.15f; private static final float SMOOTHING_FACTOR = 0.15f;
private static final float AZIMUTH_DRAW_EPS = 0.5f;
private List<AISVessel> nearbyVessels = new ArrayList<>(); private List<AISVessel> nearbyVessels = new ArrayList<>();
private Vessel ourVessel; // наше судно для расчета расстояний private Vessel ourVessel; // наше судно для расчета расстояний
private static final float MAX_DISPLAY_DISTANCE = 10000; // 10 км private static final float MAX_DISPLAY_DISTANCE = 10000; // 10 км
@@ -50,15 +66,33 @@ public class CompassView extends BaseDockWidget {
} }
private void init() { private void init() {
paint.setColor(Color.WHITE); paint.setColor(TICK_COLOR);
paint.setTextAlign(Paint.Align.CENTER); paint.setTextAlign(Paint.Align.CENTER);
paint.setTextSize(36f); 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.setStyle(Paint.Style.FILL);
vesselPaint.setAntiAlias(true); vesselPaint.setAntiAlias(true);
// Устанавливаем фон для видимости setBackgroundColor(Color.TRANSPARENT);
setBackgroundColor(Color.argb(200, 0, 0, 0));
} }
@Override @Override
@@ -96,46 +130,81 @@ public class CompassView extends BaseDockWidget {
// Прямая шкала (dock-режим) // Прямая шкала (dock-режим)
@Override @Override
protected void onDrawDock(Canvas canvas) { protected void onDrawDock(Canvas canvas) {
// Log.d(TAG, "onDrawDock called, width=" + getWidth() + ", height=" + getHeight()); float totalW = getWidth();
float totalH = getHeight();
float w = getWidth(); if (totalW <= 0 || totalH <= 0) {
float h = getHeight(); Log.w(TAG, "Invalid dimensions: width=" + totalW + ", height=" + totalH);
if (w <= 0 || h <= 0) {
Log.w(TAG, "Invalid dimensions: width=" + w + ", height=" + h);
return; return;
} }
// Простой фон для начала // Учитываем паддинги (которые MainActivity назначает по системным
paint.setColor(Color.argb(200, 0, 0, 0)); // инсетам и вырезам камеры). Фон рисуем на всю область виджета,
canvas.drawRect(0, 0, w, h, paint); // а весь контент только внутри padding-box.
int pl = getPaddingLeft();
// Масштабируем размеры в зависимости от высоты виджета int pt = getPaddingTop();
float baseHeight = dp(80); // базовая высота 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)); float scaleFactor = Math.max(0.8f, Math.min(2.0f, h / baseHeight));
// Простой текст для проверки (убрана надпись "КОМПАС") // Шапка в стиле LABEL + значение (как POSITION/SOG/COG/ACC в
paint.setColor(Color.WHITE); // координатах): слева 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.setTextSize(24 * scaleFactor);
paint.setTextAlign(Paint.Align.CENTER); 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); float diff = getShortestRotation(currentAzimuth, targetAzimuth);
if (Math.abs(diff) > 0.1f) { if (Math.abs(diff) > AZIMUTH_DRAW_EPS) {
// Ограничиваем максимальное изменение за один кадр float maxChange = 3.0f;
float maxChange = 3.0f; // максимальное изменение в градусах за кадр
float change = Math.signum(diff) * Math.min(Math.abs(diff * SMOOTHING_FACTOR), maxChange); float change = Math.signum(diff) * Math.min(Math.abs(diff * SMOOTHING_FACTOR), maxChange);
currentAzimuth += change; currentAzimuth += change;
currentAzimuth = normalizeAngle(currentAzimuth); currentAzimuth = normalizeAngle(currentAzimuth);
postInvalidateOnAnimation(); postInvalidateOnAnimation();
} }
// Рисуем простую шкалу // Рисуем простую шкалу под шапкой. Центр смещён, чтобы шкала
float centerX = w / 2f; // не наезжала на label-строку HEADING/MAG.
float centerY = h / 2f; float centerX = left + w / 2f;
float scaleTop = dividerY + dp(4);
float centerY = scaleTop + (bottom - scaleTop) * 0.5f;
float visibleDegrees = 120; float visibleDegrees = 120;
// Рисуем деления шкалы // Рисуем деления шкалы
@@ -179,31 +248,31 @@ public class CompassView extends BaseDockWidget {
} }
} }
// Центральная линия (направление вперёд) // Центральная линия (направление вперёд) только в области шкалы,
// чтобы не пересекать шапку HEADING/MAG.
paint.setColor(Color.RED); paint.setColor(Color.RED);
paint.setStrokeWidth(3 * scaleFactor); paint.setStrokeWidth(3 * scaleFactor);
canvas.drawLine(centerX, centerY - h/2, centerX, centerY + h/2, paint); canvas.drawLine(centerX, scaleTop, centerX, bottom, paint);
paint.setColor(Color.WHITE); paint.setColor(TICK_COLOR);
paint.setStrokeWidth(1); paint.setStrokeWidth(1);
// Выделяем зону resize в зависимости от позиции закрепления // Зоны resize остаются привязанными к физическим краям виджета,
// а не к padding-box, иначе пользователь не попадёт пальцем.
if (isDocked) { if (isDocked) {
Paint resizePaint = new Paint(Paint.ANTI_ALIAS_FLAG); Paint resizePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
resizePaint.setColor(Color.argb(120, 255, 255, 255)); resizePaint.setColor(Color.argb(120, 255, 255, 255));
resizePaint.setStyle(Paint.Style.STROKE); resizePaint.setStyle(Paint.Style.STROKE);
resizePaint.setStrokeWidth(2); resizePaint.setStrokeWidth(2);
paint.setTextSize(12); paint.setTextSize(12);
paint.setColor(Color.WHITE); paint.setColor(Color.WHITE);
if (isDockTop()) { if (isDockTop()) {
// Если закреплен сверху, показываем зону resize снизу canvas.drawRect(0, totalH - dp(24), totalW, totalH, resizePaint);
canvas.drawRect(0, h - dp(24), w, h, resizePaint); canvas.drawText("", totalW / 2f, totalH - dp(12), paint);
canvas.drawText("", w/2, h - dp(12), paint);
} else { } else {
// Если закреплен снизу, показываем зону resize сверху canvas.drawRect(0, 0, totalW, dp(24), resizePaint);
canvas.drawRect(0, 0, w, dp(24), resizePaint); canvas.drawText("", totalW / 2f, dp(12), paint);
canvas.drawText("", w/2, dp(12), paint);
} }
} }
} }
@@ -229,16 +298,15 @@ public class CompassView extends BaseDockWidget {
float baseSize = dp(120); // базовая высота float baseSize = dp(120); // базовая высота
float scaleFactor = Math.max(0.8f, Math.min(2.0f, Math.min(w, h) / baseSize)); float scaleFactor = Math.max(0.8f, Math.min(2.0f, Math.min(w, h) / baseSize));
// Фон // Фон круглого компаса та же палитра, что и у координатного
paint.setColor(Color.argb(200, 0, 0, 0)); // виджета в draggable-режиме. Используем bgPaint без argb(...).
canvas.drawCircle(cx, cy, radius, paint); canvas.drawCircle(cx, cy, radius, bgPaint);
paint.setColor(Color.WHITE); paint.setColor(TICK_COLOR);
// Плавное обновление азимута // Плавное обновление азимута
float diff = getShortestRotation(currentAzimuth, targetAzimuth); float diff = getShortestRotation(currentAzimuth, targetAzimuth);
if (Math.abs(diff) > 0.1f) { if (Math.abs(diff) > AZIMUTH_DRAW_EPS) {
// Ограничиваем максимальное изменение за один кадр float maxChange = 3.0f;
float maxChange = 3.0f; // максимальное изменение в градусах за кадр
float change = Math.signum(diff) * Math.min(Math.abs(diff * SMOOTHING_FACTOR), maxChange); float change = Math.signum(diff) * Math.min(Math.abs(diff * SMOOTHING_FACTOR), maxChange);
currentAzimuth += change; currentAzimuth += change;
currentAzimuth = normalizeAngle(currentAzimuth); currentAzimuth = normalizeAngle(currentAzimuth);
@@ -282,13 +350,17 @@ public class CompassView extends BaseDockWidget {
paint.setColor(Color.RED); paint.setColor(Color.RED);
paint.setStrokeWidth(3 * scaleFactor); paint.setStrokeWidth(3 * scaleFactor);
canvas.drawLine(cx, cy, cx, cy - radius, paint); canvas.drawLine(cx, cy, cx, cy - radius, paint);
paint.setColor(Color.WHITE); paint.setColor(TICK_COLOR);
paint.setStrokeWidth(1); paint.setStrokeWidth(1);
// Текст азимута в центре // Центральный текст: значение HEADING в акцентном цвете, ниже
paint.setTextSize(14 * scaleFactor); // мелкий LABEL, по аналогии с блоками в CoordinatesDockWidget.
paint.setTextAlign(Paint.Align.CENTER); accentPaint.setTextAlign(Paint.Align.CENTER);
canvas.drawText((int)currentAzimuth + "°", cx, cy + 5 * scaleFactor, paint); 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 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 TEXT_COLOR = 0xFFFFFFFF; // Белый
private static final int LABEL_COLOR = 0xFF9AA4B2; // Серо-голубой
private static final int ACCENT_COLOR = 0xFF4CAF50; // Зеленый private static final int ACCENT_COLOR = 0xFF4CAF50; // Зеленый
private static final int WARNING_COLOR = 0xFFFF9800; // Оранжевый private static final int WARNING_COLOR = 0xFFFF9800; // Оранжевый
private static final int ERROR_COLOR = 0xFFF44336; // Красный private static final int ERROR_COLOR = 0xFFF44336; // Красный
// Кисти // Кисти
private Paint backgroundPaint; private Paint backgroundPaint;
private Paint labelPaint;
private Paint textPaint; private Paint textPaint;
private Paint accentPaint; private Paint accentPaint;
private Paint warningPaint; private Paint warningPaint;
private Paint errorPaint; private Paint errorPaint;
private Paint dividerPaint;
// Данные для отображения // Данные для отображения
private Vessel vessel; private Vessel vessel;
private String coordinatesText = "Координаты: --"; private String coordinatesText = "--";
private String sogText = "SOG: --"; private String sogText = "--";
private String cogText = "COG: --"; private String cogText = "--";
private String accuracyText = "--";
public CoordinatesDockWidget(Context context) { public CoordinatesDockWidget(Context context) {
super(context); super(context);
@@ -43,38 +47,51 @@ public class CoordinatesDockWidget extends BaseDockWidget {
} }
private void init() { private void init() {
// Инициализируем кисти
backgroundPaint = new Paint(); backgroundPaint = new Paint();
backgroundPaint.setColor(BACKGROUND_COLOR); backgroundPaint.setColor(BACKGROUND_COLOR);
backgroundPaint.setStyle(Paint.Style.FILL); backgroundPaint.setStyle(Paint.Style.FILL);
backgroundPaint.setAntiAlias(true); 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 = new Paint();
textPaint.setColor(TEXT_COLOR); textPaint.setColor(TEXT_COLOR);
textPaint.setTextSize(dp(14)); textPaint.setTextSize(dp(16));
textPaint.setTypeface(Typeface.DEFAULT_BOLD); textPaint.setTypeface(Typeface.DEFAULT_BOLD);
textPaint.setAntiAlias(true); textPaint.setAntiAlias(true);
accentPaint = new Paint(); accentPaint = new Paint();
accentPaint.setColor(ACCENT_COLOR); accentPaint.setColor(ACCENT_COLOR);
accentPaint.setTextSize(dp(14)); accentPaint.setTextSize(dp(16));
accentPaint.setTypeface(Typeface.DEFAULT_BOLD); accentPaint.setTypeface(Typeface.DEFAULT_BOLD);
accentPaint.setAntiAlias(true); accentPaint.setAntiAlias(true);
warningPaint = new Paint(); warningPaint = new Paint();
warningPaint.setColor(WARNING_COLOR); warningPaint.setColor(WARNING_COLOR);
warningPaint.setTextSize(dp(14)); warningPaint.setTextSize(dp(16));
warningPaint.setTypeface(Typeface.DEFAULT_BOLD); warningPaint.setTypeface(Typeface.DEFAULT_BOLD);
warningPaint.setAntiAlias(true); warningPaint.setAntiAlias(true);
errorPaint = new Paint(); errorPaint = new Paint();
errorPaint.setColor(ERROR_COLOR); errorPaint.setColor(ERROR_COLOR);
errorPaint.setTextSize(dp(14)); errorPaint.setTextSize(dp(16));
errorPaint.setTypeface(Typeface.DEFAULT_BOLD); errorPaint.setTypeface(Typeface.DEFAULT_BOLD);
errorPaint.setAntiAlias(true); errorPaint.setAntiAlias(true);
// Устанавливаем фон для видимости (как в CompassView) dividerPaint = new Paint();
setBackgroundColor(android.graphics.Color.argb(200, 0, 0, 0)); 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() { private void updateDisplayText() {
if (vessel == null) { if (vessel == null) {
coordinatesText = "Координаты: --"; coordinatesText = "--";
sogText = "SOG: --"; sogText = "--";
cogText = "COG: --"; cogText = "--";
accuracyText = "--";
return; return;
} }
// Координаты if (vessel.getLatitude() != 0 || vessel.getLongitude() != 0) {
if (vessel.getLatitude() != 0 && vessel.getLongitude() != 0) { coordinatesText = formatLatLon(vessel.getLatitude(), vessel.getLongitude());
coordinatesText = String.format("📍 %.6f, %.6f",
vessel.getLatitude(), vessel.getLongitude());
} else { } else {
coordinatesText = "📍 Координаты: --"; coordinatesText = "нет фикса";
} }
// SOG (Speed Over Ground) if (vessel.getSpeed() > 0.05) {
if (vessel.getSpeed() > 0) { sogText = String.format(java.util.Locale.US, "%.1f kn", vessel.getSpeed());
sogText = String.format("⚡ SOG: %.1f уз", vessel.getSpeed());
} else { } else {
sogText = "⚡ SOG: --"; sogText = "0.0 kn";
} }
// COG (Course Over Ground) if (vessel.getCourse() > 0 || vessel.getSpeed() > 0.05) {
if (vessel.getCourse() > 0) { cogText = String.format(java.util.Locale.US, "%.0f\u00B0", vessel.getCourse());
cogText = String.format("🧭 COG: %.1f°", vessel.getCourse());
} else { } 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 @Override
protected void onDrawDock(Canvas canvas) { protected void onDrawDock(Canvas canvas) {
int width = getWidth(); int width = getWidth();
int height = getHeight(); int height = getHeight();
Log.d(TAG, "onDrawDock called, width=" + width + ", height=" + height);
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0) {
Log.w(TAG, "Invalid dimensions: width=" + width + ", height=" + height); Log.w(TAG, "Invalid dimensions: width=" + width + ", height=" + height);
return; return;
} }
// Рисуем фон // Фон рисуем на всю область виджета (уезжает под нав-бар/вырез),
// а контент в рамках паддингов от WindowInsets.
canvas.drawRect(0, 0, width, height, backgroundPaint); canvas.drawRect(0, 0, width, height, backgroundPaint);
// Вычисляем позиции для текста float left = getPaddingLeft();
float textSize = dp(14); float top = getPaddingTop();
float lineHeight = textSize * 1.2f; float right = width - getPaddingRight();
float startY = (height - (lineHeight * 3)) / 2 + textSize; float bottom = height - getPaddingBottom();
float contentW = Math.max(0f, right - left);
// Определяем цвета в зависимости от качества данных float contentH = Math.max(0f, bottom - top);
Paint coordinatesPaint = getCoordinatesPaint(); if (contentW <= 0 || contentH <= 0) return;
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);
// Рисуем разделительную линию сверху, если закреплен снизу
if (!isDockTop()) { if (!isDockTop()) {
Paint linePaint = new Paint(); canvas.drawLine(left, top, right, top, dividerPaint);
linePaint.setColor(ACCENT_COLOR); }
linePaint.setStrokeWidth(dp(2));
canvas.drawLine(0, 0, width, 0, linePaint); 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 @Override
protected void onDrawCircle(Canvas canvas) { protected void onDrawCircle(Canvas canvas) {
int width = getWidth(); int width = getWidth();
int height = getHeight(); int height = getHeight();
int centerX = width / 2; int centerX = width / 2;
int centerY = height / 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); canvas.drawCircle(centerX, centerY, radius, backgroundPaint);
// Рисуем рамку
Paint borderPaint = new Paint(); Paint borderPaint = new Paint();
borderPaint.setColor(ACCENT_COLOR); borderPaint.setColor(ACCENT_COLOR);
borderPaint.setStyle(Paint.Style.STROKE); borderPaint.setStyle(Paint.Style.STROKE);
borderPaint.setStrokeWidth(dp(3)); borderPaint.setStrokeWidth(dp(2));
borderPaint.setAntiAlias(true); borderPaint.setAntiAlias(true);
canvas.drawCircle(centerX, centerY, radius, borderPaint); canvas.drawCircle(centerX, centerY, radius, borderPaint);
// Вычисляем позиции для текста в круге // Более компактная вёрстка: 4 строки (POS lat / POS lon / SOG·COG / ACC)
float textSize = dp(12); Paint posPaint = getCoordinatesPaint();
float lineHeight = textSize * 1.3f; float smallLabel = dp(9);
float startY = centerY - lineHeight; float smallValue = dp(11);
float bigValue = dp(13);
// Определяем цвета labelPaint.setTextSize(smallLabel);
Paint coordinatesPaint = getCoordinatesPaint(); posPaint.setTextSize(smallValue);
Paint sogPaint = getSOGPaint(); Paint sogPaint = getSOGPaint();
Paint cogPaint = getCOGPaint(); Paint cogPaint = getCOGPaint();
Paint accPaint = getAccuracyPaint();
// Центрируем текст sogPaint.setTextSize(bigValue);
Rect textBounds = new Rect(); cogPaint.setTextSize(bigValue);
accPaint.setTextSize(smallValue);
// Координаты
coordinatesPaint.getTextBounds(coordinatesText, 0, coordinatesText.length(), textBounds); String[] latLon = coordinatesText.split(" ", 2);
canvas.drawText(coordinatesText, centerX - textBounds.width() / 2f, startY, coordinatesPaint); String latLine = latLon.length > 0 ? latLon[0] : coordinatesText;
String lonLine = latLon.length > 1 ? latLon[1] : "";
// SOG
sogPaint.getTextBounds(sogText, 0, sogText.length(), textBounds); float lineGap = dp(2);
canvas.drawText(sogText, centerX - textBounds.width() / 2f, startY + lineHeight, sogPaint); float lineH = smallValue + lineGap;
// COG // Считаем общую высоту блока для вертикального центрирования.
cogPaint.getTextBounds(cogText, 0, cogText.length(), textBounds); float totalH = smallLabel + lineH + lineH // POSITION + 2 строки
canvas.drawText(cogText, centerX - textBounds.width() / 2f, startY + lineHeight * 2, cogPaint); + 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 * Определяет цвет для отображения COG
*/ */
private Paint getCOGPaint() { private Paint getCOGPaint() {
if (vessel == null || vessel.getCourse() <= 0) { if (vessel == null) return errorPaint;
return errorPaint; // Курс может быть 0 при движении чётко на север поэтому считаем
// валидным любой курс при наличии скорости, а также любой курс > 0.
if (vessel.getCourse() > 0 || vessel.getSpeed() > 0.05) {
return textPaint;
} }
return errorPaint;
return accentPaint; // Если есть данные о курсе - зеленый }
/**
* Определяет цвет для отображения точности (ACC, ±метры).
*/
private Paint getAccuracyPaint() {
if (vessel == null) return errorPaint;
float acc = vessel.getAccuracy();
if (acc <= 0f) return errorPaint;
if (acc <= 5f) return accentPaint;
if (acc <= 20f) return warningPaint;
return errorPaint;
} }
/** /**
+1 -1
View File
@@ -16,7 +16,7 @@
<path <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: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:strokeWidth="2"
android:fillColor="#00ff00" android:fillColor="#7BE435"
android:strokeColor="#000"/> android:strokeColor="#000"/>
<path <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" 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" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="1024"
android:viewportHeight="108"> android:viewportHeight="1024">
<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"> <group android:scaleX="0.72527474"
<aapt:attr name="android:fillColor"> android:scaleY="0.72527474"
<gradient android:translateX="130.34433"
android:endX="85.84757" android:translateY="140.65935">
android:endY="92.4963" <path
android:startX="42.9492" android:pathData="M99.2,86.8h850.4v850.4h-850.4z"
android:startY="49.59793" android:fillColor="#bcd542"/>
android:type="linear"> <path
<item android:pathData="M750.9,341.8l-6.2,12.3l7.3,1.4l-1.1,-13.8z"
android:color="#44000000" android:strokeWidth="2"
android:offset="0.0" /> android:fillColor="#00000000"
<item android:strokeColor="#fff"/>
android:color="#00000000" <path
android:offset="1.0" /> 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"
</gradient> android:fillColor="#d5eb5a"/>
</aapt:attr> <path
</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 <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:strokeWidth="1"
android:strokeColor="#00000000" /> android:pathData="M747.7,356.9c-0.3,1.6 -0.7,3.4 -1.1,5.4"
</vector> android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M743.9,372.7c-0.7,2.4 -1.5,4.9 -2.4,7.5 -5.5,15.7 -10.6,22.8 -15.5,34.2 -4.1,9.5 -6,21.1 -8.5,33.9"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M716.5,453.6c-0.1,0.5 -0.2,1 -0.3,1.5 -0.3,1.6 -0.6,3 -0.8,3.9"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:pathData="M531.8,588.6c7.4,5.6 12.6,13 14.5,22.2 0.2,2 0.3,3.9 0.2,5.8v1.5c-0.2,7.2 -2.4,13.2 -5.8,19.4 -0.6,1.1 -1.1,2.2 -1.7,3.3 -3.2,6 -7,11.6 -10.9,17.1 -1.1,1.5 -2.2,3.2 -3.2,4.7 -2.1,3 -4.1,6 -6.2,8.9 -0.2,0.3 -0.4,0.6 -0.6,0.9 -1,1.4 -2,2.7 -3.3,3.8 -1.8,-0.6 -2,-0.9 -3,-2.4 -0.3,-0.4 -0.6,-0.8 -0.8,-1.2 -0.3,-0.5 -0.6,-0.9 -0.9,-1.3 -0.7,-0.9 -1.3,-1.9 -2,-2.8 -0.4,-0.5 -0.7,-1 -1,-1.5 -1,-1.5 -2.1,-3 -3.2,-4.6 -16.1,-23 -23.4,-40.2 -21.8,-51.7 1.1,-6 3.6,-10.8 7.4,-15.6 0.3,-0.3 0.5,-0.7 0.7,-1 9.4,-12.3 29.2,-13.7 41.6,-5.7l-0.1,0.1Z"
android:fillColor="#fefdea"/>
<path
android:pathData="M522,603.6c3.4,2.2 5.8,5.4 7,9.3 0.9,4.7 -0.1,9 -2.5,13 -2.2,2.9 -5.5,5 -9.1,5.6 -5,0.6 -8.7,-0.3 -12.9,-3.1 -3.2,-2.6 -4.9,-6.3 -5.4,-10.4 -0.2,-5 1.1,-8.5 4.4,-12.2 5.5,-5.1 12,-5.5 18.5,-2.2h0Z"
android:fillColor="#bcd542"/>
<path
android:pathData="M219.5,612.1c-12,-6.1 -27.7,-7.1 -40.5,-3.8 -4.5,1.5 -8.7,3.6 -12.8,6.1 -5.1,3 -10.1,4 -16,3.8l-21,-42.9 -2.6,-10.8 137.6,-0.3c17,0 28.8,-3.5 42.2,-14.3 16,-12.7 34.9,-19 55.1,-20.7 0.8,0 19,-0.9 27.4,-0.9l-0.1,-0.1h17.6c0.6,0 1.9,0.6 3.9,1.7 -0.3,5 -6.2,14.6 -7,15.9 -0.4,0.6 -7.3,12.6 -10.3,18.1 -0.4,0.7 -18.3,36.7 -26,54.4 -4.5,0.2 -8,0 -12.1,-1.7 -0.9,-0.4 -7.1,-3.2 -9.3,-4.3 -12.4,-6.4 -27.8,-6.9 -41,-2.9 -5.5,1.8 -10.6,4.6 -15.7,7.5 -9.1,5.1 -19.5,5.7 -29.6,5.7h-2.8c-9.2,-0.1 -17.9,-1.7 -26.3,-5.4l-10.5,-5Z"
android:fillColor="#fefce9"/>
<path
android:pathData="M220.2,454.2h16.6v33.5c3.9,-0.1 43.1,0 44.2,0 7.3,-0.2 12.5,0.2 18.1,5.2 0.5,0.4 1,0.9 1.5,1.3 10.2,8.9 17,25.6 21.4,38.2 -0.9,0.3 -1.9,0.6 -2.8,0.9 -10.6,3.6 -19,9.6 -27.9,16.2 -9.7,7.1 -19.6,9.5 -31.5,9.4 -1,0 -83,-0.1 -96.2,-0.2v-14.2h20.5c0,-3.1 0.1,-35.7 0,-36.7 0,-5.7 0.3,-9 4.2,-13.3 4.7,-4.5 8.5,-6.6 15.1,-6.8 2,0 4.1,-0.1 6.2,0 3.6,0.1 7.3,-0.2 11,0v-33.3l-0.1,-0.2Z"
android:fillColor="#fdfce9"/>
<path
android:pathData="M214.8,622.5c4.2,1.8 14.2,5.6 15.1,5.9 19.4,7.3 42.4,7.4 61.7,-0.6 3.4,-1.6 6.7,-3.2 10,-5 10.9,-5.4 23.3,-6.6 34.9,-2.8 3.6,1.3 7.2,2.7 10.7,4.3 14,5.9 30.8,6.2 45,1.1 3.1,-1.3 11.8,-5.8 13.4,-6.3 0.6,5.3 0.3,9 -0.9,11.1 -9.7,9.1 -24,12.9 -37.1,12.8 -9.8,-0.3 -17.8,-3 -26.8,-6.9 -12.1,-5.1 -22.2,-6 -34.6,-1.1 -3.2,1.3 -6.4,2.8 -9.5,4.2 -4.8,2.1 -16.9,5.7 -17.9,6 -5.7,1.3 -18.6,1 -19.2,1 -9.7,0 -29.4,-3.8 -30,-4 -5.9,-1.8 -11.5,-4.1 -17.1,-6.8 -11.3,-5.4 -22.2,-4.7 -33.9,-0.6 -1.7,0.8 -3.5,1.6 -5.2,2.4 -12.7,6.1 -29.3,7.2 -42.7,2.5 -4.9,-1.9 -14.8,-8 -15.4,-8.2 -0.9,-0.6 -1.9,-1.6 -3.1,-3.1 -0.4,-3.2 -0.2,-6.2 0,-9.5 2.8,1.1 5.5,2.3 8.3,3.5 14.8,6.7 30.4,8.2 46.1,3.5 3.2,-1.3 6.2,-2.7 9.1,-4.3 12.3,-5.8 26.9,-4.7 39.1,0.6l0.2,0.2Z"
android:fillColor="#fdfce9"/>
<path
android:pathData="M241.3,511.2c1.5,2.3 1,13.6 0,15.7 -3.1,3 -24.5,1.2 -25.7,0 -1.7,-2.8 -0.6,-14.8 0,-15.7 2.2,-1.5 24.8,-0.7 25.7,0Z"
android:fillColor="#b1cc36"/>
<path
android:pathData="M285.4,511.2c1.5,2.3 1,13.6 0,15.7 -3.1,3 -24.5,1.2 -25.7,0 -1.7,-2.8 -0.6,-14.8 0,-15.7 2.2,-1.5 24.8,-0.7 25.7,0Z"
android:fillColor="#b1cc36"/>
<path
android:pathData="M431.2,700.7l22.7,175.9h-40.8l-4.7,-43.2h-22.2l-6.7,43.2h-34.6l29.9,-175.9h56.3ZM405.5,806.6l-7.2,-78.3h-0.5l-7.2,78.3h14.8Z"
android:fillColor="#fff"/>
<path
android:pathData="M491.5,876.5v-175.9h41v175.9h-41Z"
android:fillColor="#fff"/>
<path
android:pathData="M635.3,763.7v-24.7c0,-8.6 -2.7,-13.6 -10.9,-13.6 -8.9,0 -10.9,4.9 -10.9,13.6 0,29.6 62.7,38.3 62.7,92.6 0,33.1 -17.8,48.4 -52.1,48.4 -26.2,0 -51.6,-8.9 -51.6,-39.3v-32.6h41v30.4c0,10.4 3.2,13.3 10.9,13.3 6.7,0 10.9,-3 10.9,-13.3 0,-39.8 -62.7,-40.5 -62.7,-97.3 0,-31.9 21,-44 52.6,-44 27.7,0 51.1,9.4 51.1,38.8v27.7h-41Z"
android:fillColor="#fff"/>
<path
android:pathData="M418.4,308.7l-5,1.2 -4.7,6.8s2.7,1.8 2.7,1.8c0,0 3,2 3,2 1.6,-2.3 3.2,-4.6 4.7,-6.8l-0.6,-5.1Z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M410.6,320.6c-0.5,0.6 -1,1.3 -1.5,2"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M405.9,326.2c-2.1,2.2 -4.7,4.7 -7.8,7.1 -10,7.9 -15.4,7.7 -29.4,16.2 -12.5,7.6 -18.7,11.4 -20.1,18 -1.3,5.9 1.5,10.9 -2,18.4 -0.5,1.1 -1.1,2.2 -1.7,3.1"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M343.4,391c-0.6,0.7 -1.2,1.3 -1.7,1.8"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
</group>
</vector>
+112
View File
@@ -0,0 +1,112 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="1024dp"
android:height="1024dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M99.2,86.8h850.4v850.4h-850.4z"
android:fillColor="#bcd542"/>
<path
android:pathData="M750.9,341.8l-6.2,12.3l7.3,1.4l-1.1,-13.8z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:pathData="M755.8,302.7c-2.2,8.2 -70.6,228.5 -72.1,233 -4.9,-1.2 -9.9,-2.5 -14.8,-3.7 -0.9,-0.2 -1.7,-0.4 -2.6,-0.7 -1.6,-0.4 -3.2,-0.8 -4.8,-1.2 -5.6,-1.5 -10.7,-1.8 -16.5,-1.8h-2.8c-12,0 -21.8,4.8 -32.1,10.5 -14.5,8 -29.4,12.9 -45.9,8.4 -11.9,-4.1 -18.7,-11.3 -25.3,-21.8 -1.9,-2.9 -3.9,-5.8 -5.9,-8.7 -0.7,-1 -1.4,-2 -2.1,-3.1 -14.7,-20.4 -36.9,-25.8 -59.8,-32.3 -8.4,-2.4 -23.6,-11.6 -24.2,-12 -13.3,-10.1 -19.4,-28.1 -25,-43.1 -10.8,-28.9 -27.3,-50.3 -55.1,-64.6 2.3,-2.5 30.3,-18.4 37.9,-25 0.7,-0.6 1.3,-1.1 2,-1.7 10.6,-9.6 16.7,-24.2 23.1,-36.7 9.5,-18.5 21.6,-34.2 41.9,-41.6 6.1,-1.7 12.2,-2.7 18.4,-3.7 19,-2.9 34.8,-9.5 48.6,-23.3 0.6,-0.6 1.1,-1.1 1.7,-1.7 5.7,-6.1 9.7,-13.8 13.3,-21.3 8.6,6.7 12.7,14.1 16.9,23.9 5.9,13.7 14.6,23.7 28.1,30.1 4.2,1.5 153.8,41.2 157.1,42.1Z"
android:fillColor="#d5eb5a"/>
<path
android:pathData="M648.7,255.6v2c-7.7,-1.4 -15.3,-3.2 -22.9,-5.2 -1.3,-0.3 -2.6,-0.7 -3.9,-1 -15.3,-3.8 -28,-8.8 -36.5,-22.6 -1.8,-3.3 -3.2,-6.7 -4.7,-10.2 -2.7,-6.7 -6.3,-11.5 -11.1,-16.9 -1.3,-1.6 -2.6,-3.2 -3.9,-4.8 -0.6,-0.7 -1.1,-1.4 -1.7,-2.1 -4,-5.8 -4.2,-11.3 -3.4,-18.1 1.2,-5.6 2.8,-11.1 4.3,-16.6 1,-3.7 1.9,-7.4 2.7,-11.2 1.2,-5.3 2.5,-10.5 3.9,-15.7 1.8,-6.5 3.3,-13.1 4.7,-19.7 0.8,-3.7 1.6,-6.3 2.3,-7.8h0.3c28.3,0 56.6,0 84.9,-0.1h115.4c38.6,-0.1 71.8,9.9 99.7,37.2 0.7,0.6 1.3,1.2 2,1.9 5,4.7 9.8,10.1 13,16.1 -0.7,5.5 -3,10.6 -5.1,15.8 -0.4,1 -0.8,2 -1.2,3.1 -3.4,8.6 -7.3,16.9 -11.7,25.1 -0.5,0.9 -1,1.9 -1.5,2.8 -3.7,6.5 -8.3,11.8 -13.5,17.2 -0.6,0.7 -1.2,1.3 -1.9,1.9 -9.4,9 -20.3,12.4 -32.7,14.7 -24.2,4.4 -43.4,16.2 -57.5,36.4 -2.1,3.3 -4.1,6.6 -6,10 -4.3,-0.4 -8.1,-0.9 -12.1,-2.4 -5.2,-1.8 -10.6,-3.1 -16,-4.3 -1,-0.2 -1.9,-0.4 -2.9,-0.7 -2.3,-0.5 -4.7,-1.1 -7,-1.6"
android:fillColor="#d5eb5a"/>
<path
android:pathData="M770.8,306.7c9.8,2.4 96.5,26 100.1,27.1 10,2.8 41.7,10.9 45.2,11.8 0.2,22.8 0,159.9 0,161.6 0.1,8.5 -0.3,14.2 -2,17 -1.7,1.8 -3.4,3.1 -5.5,4.5 -0.9,0.7 -1.7,1.5 -2.6,2.3 -2.8,2.3 -5.6,4.5 -8.4,6.7 -0.6,0.5 -1.1,0.9 -1.7,1.4 -13.8,11.1 -24.9,16.3 -33.3,15.6 -12.1,-2.1 -19.5,-13.7 -26.3,-22.8 -9.2,-12.2 -19.4,-20.8 -34.7,-24.2 -19.2,-2.3 -33.7,2.8 -49,14 -1.9,1.7 -3.6,3.4 -5.3,5.2 -9.2,9.5 -19.7,15.8 -33.1,16 -5.5,0 -10.3,-0.8 -15.6,-2.2 0.5,-7.4 69.3,-223.7 72,-233.9l0.2,-0.1Z"
android:fillColor="#d4ea59"/>
<path
android:pathData="M578.8,105.7c28.3,0 198.2,-0.1 200.3,-0.1 38.6,-0.1 71.8,9.9 99.7,37.2 0.7,0.6 1.3,1.2 2,1.9 5,4.7 9.8,10.1 13,16.1 -0.7,5.5 -3,10.6 -5.1,15.8 -0.4,1 -0.8,2 -1.2,3.1 -3.4,8.6 -7.3,16.9 -11.7,25.1 -0.5,0.9 -1,1.9 -1.5,2.8 -3.7,6.5 -8.3,11.8 -13.5,17.2 -0.6,0.7 -1.2,1.3 -1.9,1.9 -9.4,9 -20.3,12.4 -32.7,14.7 -24.2,4.4 -43.4,16.2 -57.5,36.4 -2.1,3.3 -4.1,6.6 -6,10 -4.2,-0.4 -8,-0.9 -11.9,-2.3 -5.3,-1.9 -117.5,-30.7 -125.1,-32.7 -1.3,-0.3 -31.9,-9.8 -40.4,-23.6 -1.8,-3.3 -3.2,-6.7 -4.7,-10.2 -2.7,-6.7 -6.3,-11.5 -11.1,-16.9 -1.3,-1.6 -5,-6.2 -5.6,-6.9 -4,-5.8 -4.2,-11.3 -3.4,-18.1 1.2,-5.6 17.2,-69.5 17.9,-71l0.4,-0.4Z"
android:fillColor="#d8ed5c"/>
<path
android:pathData="M338.8,105.7h227c-1.3,5.3 -5.2,20.9 -5.8,23.4 -2.1,8.4 -4.3,16.7 -6.7,25 -2.7,9.3 -5.2,18.7 -7.6,28.1 -5.7,21.5 -12.8,37.3 -32.5,49.8 -8.6,4.5 -17.8,6.9 -27.5,7.4 -14.5,1.5 -32.6,7.7 -42.9,18.4 -2.3,2.3 -3.9,3.7 -6.9,4.9 -6.3,-0.4 -132,-33.7 -133,-34 0.6,-5.4 34.9,-120 35.8,-122.8l0.1,-0.2Z"
android:fillColor="#d8ed5d"/>
<path
android:pathData="M107.7,366.7c0,-18.4 -0.2,-111.3 -0.2,-115.2 -0.1,-18.8 0.1,-36.8 6.4,-54.8h-0.1c12.3,2.6 46.6,11.4 51.7,12.8 1,0.3 84.3,21.8 91.4,23.4 8.4,1.9 16.7,4.3 24.9,6.8 -0.7,6.8 -45.7,157 -51,175 -12.6,-1.7 -25.4,-15 -32.9,-24.8 -0.5,-0.7 -1.1,-1.5 -1.7,-2.2 -1.9,-2.6 -4.1,-4.8 -6.5,-7 -0.6,-0.6 -1.3,-1.2 -2,-1.9 -10.3,-9.6 -22.7,-16.6 -37,-17.1 -1.1,0 -2.2,-0.1 -3.3,-0.2 -13.5,-0.3 -26.7,1.4 -39.7,5.2"
android:fillColor="#d6ec5b"/>
<path
android:pathData="M246,105.4c2.2,0 64.3,0 75.8,0.1 -0.6,2.6 -32.5,110.1 -34.9,117.9 -6,-0.6 -157.2,-39.2 -166.8,-41.9 4.9,-12.5 12.3,-23.9 21.7,-33.6 1.6,-1.6 3.1,-3.4 4.6,-5.3 3.1,-3.8 6.8,-6.6 10.7,-9.5 0.8,-0.6 1.5,-1.1 2.3,-1.7 12.1,-9 24.5,-16.2 38.9,-20.5 0.6,-0.2 1.3,-0.4 1.9,-0.6 15.5,-4.5 30.1,-5.2 46.1,-5.1l-0.3,0.2Z"
android:fillColor="#d8ee5d"/>
<path
android:pathData="M297.8,243.7c0.9,0.2 1.7,0.4 2.6,0.7 6.4,1.6 115.7,30.5 116.8,30.7 1.9,0.5 3.8,1.1 5.6,1.7 -0.9,6.4 -4.2,11.9 -7,17.6l-3,6c-9.9,19.9 -22.2,32.6 -42.5,41.8 -18,8.2 -33.8,22.3 -46.6,37.4 -1.8,2 -3.6,3.9 -5.4,5.8 -0.7,0.7 -7.1,7.1 -9.7,9.7l-8.2,8.2c-14.6,13.9 -34.7,14.9 -53.6,14.5h-1.9c1.8,-6.7 52.5,-171.4 53,-174.1h-0.1Z"
android:fillColor="#d6ec5b"/>
<path
android:pathData="M902.8,181.7h2c1.1,2.3 2.2,4.6 3.2,6.9 0.3,0.6 0.6,1.2 0.9,1.9 7.6,16.6 7.4,34.1 7.2,52 0,0.4 0,3 0,7.1 -0,18.8 0.1,68.9 0,80.2 -1.7,-0.3 -137,-36.6 -138.3,-37.9 10,-16.3 22.7,-29.7 41.9,-34.8 4.1,-0.9 8.3,-1.7 12.5,-2.5 19.7,-3.7 37.4,-13.1 49.4,-29.5 8.8,-13.6 15,-28.4 21.2,-43.2v-0.2Z"
android:fillColor="#d6ec5b"/>
<path
android:pathData="M458.8,263.7l2,1c-0.7,0.5 -1.3,1.1 -2,1.6 -6.7,5.5 -13,11.3 -19,17.4 0,-4.2 1.9,-5.7 4.6,-8.7 4.4,-4.2 9.4,-7.7 14.4,-11.3h0Z"
android:fillColor="#dbf15f"/>
<path
android:pathData="M553.8,206.7l2,1c-1.5,3.1 -3.2,6.1 -5,9 -0.3,-0.7 -0.7,-1.3 -1,-2 0.5,-1.3 1.2,-2.6 1.9,-4.1 0.7,-1.5 1.1,-2.2 1.2,-2.2 0.3,-0.6 0.6,-1.1 0.9,-1.7h0ZM548.8,216.7l1,2 -3,3c0.7,-1.6 1.3,-3.3 2,-5Z"
android:fillColor="#dcf160"/>
<path
android:strokeWidth="1"
android:pathData="M747.7,356.9c-0.3,1.6 -0.7,3.4 -1.1,5.4"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M743.9,372.7c-0.7,2.4 -1.5,4.9 -2.4,7.5 -5.5,15.7 -10.6,22.8 -15.5,34.2 -4.1,9.5 -6,21.1 -8.5,33.9"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M716.5,453.6c-0.1,0.5 -0.2,1 -0.3,1.5 -0.3,1.6 -0.6,3 -0.8,3.9"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:pathData="M531.8,588.6c7.4,5.6 12.6,13 14.5,22.2 0.2,2 0.3,3.9 0.2,5.8v1.5c-0.2,7.2 -2.4,13.2 -5.8,19.4 -0.6,1.1 -1.1,2.2 -1.7,3.3 -3.2,6 -7,11.6 -10.9,17.1 -1.1,1.5 -2.2,3.2 -3.2,4.7 -2.1,3 -4.1,6 -6.2,8.9 -0.2,0.3 -0.4,0.6 -0.6,0.9 -1,1.4 -2,2.7 -3.3,3.8 -1.8,-0.6 -2,-0.9 -3,-2.4 -0.3,-0.4 -0.6,-0.8 -0.8,-1.2 -0.3,-0.5 -0.6,-0.9 -0.9,-1.3 -0.7,-0.9 -1.3,-1.9 -2,-2.8 -0.4,-0.5 -0.7,-1 -1,-1.5 -1,-1.5 -2.1,-3 -3.2,-4.6 -16.1,-23 -23.4,-40.2 -21.8,-51.7 1.1,-6 3.6,-10.8 7.4,-15.6 0.3,-0.3 0.5,-0.7 0.7,-1 9.4,-12.3 29.2,-13.7 41.6,-5.7l-0.1,0.1Z"
android:fillColor="#fefdea"/>
<path
android:pathData="M522,603.6c3.4,2.2 5.8,5.4 7,9.3 0.9,4.7 -0.1,9 -2.5,13 -2.2,2.9 -5.5,5 -9.1,5.6 -5,0.6 -8.7,-0.3 -12.9,-3.1 -3.2,-2.6 -4.9,-6.3 -5.4,-10.4 -0.2,-5 1.1,-8.5 4.4,-12.2 5.5,-5.1 12,-5.5 18.5,-2.2h0Z"
android:fillColor="#bcd542"/>
<path
android:pathData="M219.5,612.1c-12,-6.1 -27.7,-7.1 -40.5,-3.8 -4.5,1.5 -8.7,3.6 -12.8,6.1 -5.1,3 -10.1,4 -16,3.8l-21,-42.9 -2.6,-10.8 137.6,-0.3c17,0 28.8,-3.5 42.2,-14.3 16,-12.7 34.9,-19 55.1,-20.7 0.8,0 19,-0.9 27.4,-0.9l-0.1,-0.1h17.6c0.6,0 1.9,0.6 3.9,1.7 -0.3,5 -6.2,14.6 -7,15.9 -0.4,0.6 -7.3,12.6 -10.3,18.1 -0.4,0.7 -18.3,36.7 -26,54.4 -4.5,0.2 -8,0 -12.1,-1.7 -0.9,-0.4 -7.1,-3.2 -9.3,-4.3 -12.4,-6.4 -27.8,-6.9 -41,-2.9 -5.5,1.8 -10.6,4.6 -15.7,7.5 -9.1,5.1 -19.5,5.7 -29.6,5.7h-2.8c-9.2,-0.1 -17.9,-1.7 -26.3,-5.4l-10.5,-5Z"
android:fillColor="#fefce9"/>
<path
android:pathData="M220.2,454.2h16.6v33.5c3.9,-0.1 43.1,0 44.2,0 7.3,-0.2 12.5,0.2 18.1,5.2 0.5,0.4 1,0.9 1.5,1.3 10.2,8.9 17,25.6 21.4,38.2 -0.9,0.3 -1.9,0.6 -2.8,0.9 -10.6,3.6 -19,9.6 -27.9,16.2 -9.7,7.1 -19.6,9.5 -31.5,9.4 -1,0 -83,-0.1 -96.2,-0.2v-14.2h20.5c0,-3.1 0.1,-35.7 0,-36.7 0,-5.7 0.3,-9 4.2,-13.3 4.7,-4.5 8.5,-6.6 15.1,-6.8 2,0 4.1,-0.1 6.2,0 3.6,0.1 7.3,-0.2 11,0v-33.3l-0.1,-0.2Z"
android:fillColor="#fdfce9"/>
<path
android:pathData="M214.8,622.5c4.2,1.8 14.2,5.6 15.1,5.9 19.4,7.3 42.4,7.4 61.7,-0.6 3.4,-1.6 6.7,-3.2 10,-5 10.9,-5.4 23.3,-6.6 34.9,-2.8 3.6,1.3 7.2,2.7 10.7,4.3 14,5.9 30.8,6.2 45,1.1 3.1,-1.3 11.8,-5.8 13.4,-6.3 0.6,5.3 0.3,9 -0.9,11.1 -9.7,9.1 -24,12.9 -37.1,12.8 -9.8,-0.3 -17.8,-3 -26.8,-6.9 -12.1,-5.1 -22.2,-6 -34.6,-1.1 -3.2,1.3 -6.4,2.8 -9.5,4.2 -4.8,2.1 -16.9,5.7 -17.9,6 -5.7,1.3 -18.6,1 -19.2,1 -9.7,0 -29.4,-3.8 -30,-4 -5.9,-1.8 -11.5,-4.1 -17.1,-6.8 -11.3,-5.4 -22.2,-4.7 -33.9,-0.6 -1.7,0.8 -3.5,1.6 -5.2,2.4 -12.7,6.1 -29.3,7.2 -42.7,2.5 -4.9,-1.9 -14.8,-8 -15.4,-8.2 -0.9,-0.6 -1.9,-1.6 -3.1,-3.1 -0.4,-3.2 -0.2,-6.2 0,-9.5 2.8,1.1 5.5,2.3 8.3,3.5 14.8,6.7 30.4,8.2 46.1,3.5 3.2,-1.3 6.2,-2.7 9.1,-4.3 12.3,-5.8 26.9,-4.7 39.1,0.6l0.2,0.2Z"
android:fillColor="#fdfce9"/>
<path
android:pathData="M241.3,511.2c1.5,2.3 1,13.6 0,15.7 -3.1,3 -24.5,1.2 -25.7,0 -1.7,-2.8 -0.6,-14.8 0,-15.7 2.2,-1.5 24.8,-0.7 25.7,0Z"
android:fillColor="#b1cc36"/>
<path
android:pathData="M285.4,511.2c1.5,2.3 1,13.6 0,15.7 -3.1,3 -24.5,1.2 -25.7,0 -1.7,-2.8 -0.6,-14.8 0,-15.7 2.2,-1.5 24.8,-0.7 25.7,0Z"
android:fillColor="#b1cc36"/>
<path
android:pathData="M431.2,700.7l22.7,175.9h-40.8l-4.7,-43.2h-22.2l-6.7,43.2h-34.6l29.9,-175.9h56.3ZM405.5,806.6l-7.2,-78.3h-0.5l-7.2,78.3h14.8Z"
android:fillColor="#fff"/>
<path
android:pathData="M491.5,876.5v-175.9h41v175.9h-41Z"
android:fillColor="#fff"/>
<path
android:pathData="M635.3,763.7v-24.7c0,-8.6 -2.7,-13.6 -10.9,-13.6 -8.9,0 -10.9,4.9 -10.9,13.6 0,29.6 62.7,38.3 62.7,92.6 0,33.1 -17.8,48.4 -52.1,48.4 -26.2,0 -51.6,-8.9 -51.6,-39.3v-32.6h41v30.4c0,10.4 3.2,13.3 10.9,13.3 6.7,0 10.9,-3 10.9,-13.3 0,-39.8 -62.7,-40.5 -62.7,-97.3 0,-31.9 21,-44 52.6,-44 27.7,0 51.1,9.4 51.1,38.8v27.7h-41Z"
android:fillColor="#fff"/>
<path
android:pathData="M418.4,308.7l-5,1.2 -4.7,6.8s2.7,1.8 2.7,1.8c0,0 3,2 3,2 1.6,-2.3 3.2,-4.6 4.7,-6.8l-0.6,-5.1Z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M410.6,320.6c-0.5,0.6 -1,1.3 -1.5,2"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M405.9,326.2c-2.1,2.2 -4.7,4.7 -7.8,7.1 -10,7.9 -15.4,7.7 -29.4,16.2 -12.5,7.6 -18.7,11.4 -20.1,18 -1.3,5.9 1.5,10.9 -2,18.4 -0.5,1.1 -1.1,2.2 -1.7,3.1"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
<path
android:strokeWidth="1"
android:pathData="M343.4,391c-0.6,0.7 -1.2,1.3 -1.7,1.8"
android:fillColor="#00000000"
android:strokeColor="#fff"/>
</vector>
@@ -0,0 +1,237 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔌 Интерфейсы: UDP и BLE"
android:textSize="22sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:gravity="center"
android:layout_marginBottom="24dp" />
<!-- UDP -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📡 UDP"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="12dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="UDP Порт"
app:helperText="Порт для прослушивания AIS данных">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_udp_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:text="10110" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_udp_enabled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Включить UDP слушатель"
android:textSize="16sp"
android:checked="true" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- BLE -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📶 BLE"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="12dp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_ble_enabled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Включить BLE источник NMEA"
android:textSize="16sp"
android:layout_marginBottom="8dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="MAC адрес BLE устройства"
app:helperText="Например: 01:23:45:67:89:AB">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_ble_mac"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:text="" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/btn_ble_scan"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Сканировать BLE"
style="@style/Widget.Material3.Button" />
<Button
android:id="@+id/btn_ble_stop_scan"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Стоп"
android:layout_marginStart="8dp"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_ble_devices"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_marginTop="8dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- BLE UDP Bridge -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔁 BLE UDP Bridge"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="12dp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_ble_udp_bridge_enabled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Включить UDP-bridge (пересылать NMEA)"
android:textSize="16sp"
android:layout_marginBottom="8dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="UDP Host (назначение)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_ble_udp_host"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:text="255.255.255.255" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="UDP Port (назначение)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_ble_udp_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:text="10110" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="end">
<Button
android:id="@+id/btn_cancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Отмена"
android:layout_marginEnd="8dp"
style="@style/Widget.Material3.Button.OutlinedButton" />
<Button
android:id="@+id/btn_save"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Сохранить"
style="@style/Widget.Material3.Button" />
</LinearLayout>
</LinearLayout>
</ScrollView>
+65 -26
View File
@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fitsSystemWindows="false"
tools:context=".MainActivity"> tools:context=".MainActivity">
<!-- Карта --> <!-- Карта -->
@@ -11,20 +13,41 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="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 <LinearLayout
android:id="@+id/control_panel" android:id="@+id/control_panel"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignParentEnd="true" android:layout_alignParentEnd="true"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:orientation="vertical" android:orientation="vertical"
android:padding="8dp" android:padding="8dp"
android:gravity="end" android:gravity="end"
android:elevation="4dp"> android:elevation="10dp">
<ImageButton <ImageButton
android:id="@+id/btn_center_vessel" android:id="@+id/btn_center_vessel"
@@ -59,6 +82,17 @@
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:layout_marginBottom="8dp" /> 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 <ImageButton
android:id="@+id/btn_settings" android:id="@+id/btn_settings"
android:layout_width="40dp" android:layout_width="40dp"
@@ -100,30 +134,35 @@
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:layout_marginTop="4dp"/> 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> </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 <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
+162 -17
View File
@@ -21,7 +21,7 @@
android:gravity="center" android:gravity="center"
android:layout_marginBottom="24dp" /> android:layout_marginBottom="24dp" />
<!-- UDP Настройки --> <!-- Интерфейсы (UDP/BLE) -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -38,36 +38,36 @@
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="📡 UDP Настройки" android:text="🔌 Интерфейсы"
android:textSize="18sp" android:textSize="18sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="12dp" /> android:layout_marginBottom="12dp" />
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/til_open_interfaces"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
android:hint="UDP Порт" android:hint="Интерфейсы (UDP / BLE)"
app:helperText="Порт для прослушивания AIS данных"> app:helperText="Перейти к настройкам UDP, BLE и UDP-bridge"
app:endIconMode="custom"
app:endIconContentDescription="Открыть">
<com.google.android.material.textfield.TextInputEditText <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_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="number" android:focusable="false"
android:text="10110" /> 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.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> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
@@ -209,7 +209,7 @@
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- Приоритеты данных --> <!-- Источник координат (GPS Source) -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -226,12 +226,103 @@
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="📊 Приоритеты данных" android:text="📡 Источник координат"
android:textSize="18sp" android:textSize="18sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="12dp" /> 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 <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -342,6 +433,8 @@
</RadioGroup> </RadioGroup>
</LinearLayout>
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
@@ -589,6 +682,58 @@
</com.google.android.material.card.MaterialCardView> </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 <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@android:id/text1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textSize="16sp"
android:text="Device name" />
<TextView
android:id="@android:id/text2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="@android:color/darker_gray"
android:text="MAC" />
<TextView
android:id="@+id/text3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:text="RSSI: -60" />
</LinearLayout>
+6
View File
@@ -38,4 +38,10 @@
android:icon="@android:drawable/ic_menu_view" android:icon="@android:drawable/ic_menu_view"
app:showAsAction="ifRoom" /> app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_seamarks"
android:title="Морские знаки"
android:icon="@android:drawable/ic_menu_mapmode"
app:showAsAction="ifRoom" />
</menu> </menu>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#BBD341</color>
</resources>
+12
View File
@@ -6,4 +6,16 @@
</style> </style>
<style name="Theme.AISMap" parent="Base.Theme.AISMap" /> <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> </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());
}
}
}
+259
View File
@@ -0,0 +1,259 @@
root@aismap:/opt/aismapv2# AIS_BLE_BROADCAST_ENCODING=msgpack
root@aismap:/opt/aismapv2# systemctl restart ble-gatt.service
root@aismap:/opt/aismapv2# journalctl -u ble-gatt.service -f
Journal file /var/log/journal/5dcf904c44344fed93689949d7018827/system@00a94b2b966748b6a2f50a7b3948cb43-000000000000151e-0006501c00a6f34a.journal is truncated, ignoring file.
Apr 24 10:56:54 aismap python3[25757]: [2026-04-24 10:56:54.749] [INFO] Возвращаем 4 объектов (сервисы + характеристики)
Apr 24 10:56:54 aismap python3[25757]: [2026-04-24 10:56:54.799] [INFO] ✅ GATT Application зарегистрирован успешно
Apr 24 10:56:54 aismap python3[25757]: [2026-04-24 10:56:54.822] [INFO] [Advertisement] GetAll вызван для интерфейса: org.bluez.LEAdvertisement1
Apr 24 10:56:54 aismap python3[25757]: [2026-04-24 10:56:54.829] [INFO] [Advertisement] Возвращаем свойства рекламы:
Apr 24 10:56:54 aismap python3[25757]: [2026-04-24 10:56:54.834] [INFO] Type: peripheral
Apr 24 10:56:54 aismap python3[25757]: [2026-04-24 10:56:54.836] [INFO] LocalName: AIS
Apr 24 10:56:54 aismap python3[25757]: [2026-04-24 10:56:54.836] [INFO] ServiceUUIDs: 1 сервисов
Apr 24 10:56:54 aismap python3[25757]: [2026-04-24 10:56:54.844] [INFO] ✅ Advertisement зарегистрирован успешно - устройство должно быть видно при сканировании
Apr 24 10:56:56 aismap python3[25757]: [2026-04-24 10:56:56.656] [INFO] [ais_hub] WS connecting: ws://127.0.0.1:8081/ws
Apr 24 10:56:56 aismap python3[25757]: [2026-04-24 10:56:56.668] [INFO] [ais_hub] WS connected
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.488] [INFO] [DATA] StartNotify (CCCD enabled)
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.716] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=95 preview=7b22636d64223a2268656c6c6f222c22636c69656e74223a22616e64726f6964...(+63B)
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.717] [INFO] [CONTROL] cmd=hello device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'hello', 'client': 'android', 'app_version': '1.0', 'proto': 1, 'encodings': ['msgpack', 'json']}
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.720] [DEBUG] [BCAST] paced emit #1 qdepth=1 dropped=0 gap_ms=8.0
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.722] [DEBUG] [DATA] notify broadcast sent=1 bytes=130 preview=010101000000020078007b226f6b223a747275652c2270726f746f223a312c22...(+98B)
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.730] [DEBUG] [BCAST] paced emit #2 qdepth=0 dropped=0 gap_ms=8.0
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.732] [DEBUG] [DATA] notify broadcast sent=2 bytes=102 preview=01010100010002005c00652c226c6976655f6576656e7473223a747275652c22...(+70B)
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.789] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=80 preview=7b22636d64223a226765745f736e617073686f74222c22696e636c756465223a...(+48B)
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.790] [INFO] [CONTROL] cmd=get_snapshot device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'get_snapshot', 'include': ['ownship', 'vessels', 'stats'], 'max_vessels': 500}
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.794] [DEBUG] [SNAPSHOT] waiting broadcast lock sess=/org/bluez/hci0/dev_4B_4E_32_24_64_CE snapshot_id=1
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.795] [DEBUG] [SNAPSHOT] acquired broadcast lock sess=/org/bluez/hci0/dev_4B_4E_32_24_64_CE
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.865] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=80 preview=7b22636d64223a226765745f736e617073686f74222c22696e636c756465223a...(+48B)
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.866] [INFO] [CONTROL] cmd=get_snapshot device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'get_snapshot', 'include': ['ownship', 'vessels', 'stats'], 'max_vessels': 500}
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.867] [DEBUG] [DATA] broadcast frames=1 msg_type=0x07 msg_id=2 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.868] [DEBUG] [BCAST] paced emit #3 qdepth=0 dropped=0 gap_ms=8.0
Apr 24 11:03:03 aismap python3[25757]: [2026-04-24 11:03:03.870] [DEBUG] [DATA] notify broadcast sent=3 bytes=75 preview=010702000000010041007b22636f6465223a22736e617073686f745f62757379...(+43B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.067] [DEBUG] [DATA] broadcast frames=1 msg_type=0x02 msg_id=3 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.070] [DEBUG] [SNAPSHOT] vessels progress sess=/org/bluez/hci0/dev_4B_4E_32_24_64_CE sent=360/500 seq=10 bcast_q=1
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.071] [DEBUG] [DATA] broadcast frames=2 msg_type=0x03 msg_id=4 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.074] [DEBUG] [BCAST] paced emit #4 qdepth=2 dropped=0 gap_ms=8.0
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.073] [INFO] [SNAPSHOT] done sess=/org/bluez/hci0/dev_4B_4E_32_24_64_CE snapshot_id=1 vessels=500 bcast_dropped=0
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.075] [DEBUG] [DATA] broadcast frames=6 msg_type=0x03 msg_id=5 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.084] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=6 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.085] [DEBUG] [BCAST] paced emit #5 qdepth=124 dropped=0 gap_ms=8.0
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.093] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=7 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.101] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=8 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.107] [DEBUG] [BCAST] paced emit #6 qdepth=357 dropped=0 gap_ms=8.0
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.110] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=9 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.118] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=10 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.124] [DEBUG] [BCAST] paced emit #7 qdepth=590 dropped=0 gap_ms=8.0
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.127] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=11 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.136] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=12 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.137] [DEBUG] [BCAST] paced emit #8 qdepth=823 dropped=0 gap_ms=8.0
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.148] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=13 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.150] [DEBUG] [BCAST] paced emit #9 qdepth=939 dropped=0 gap_ms=8.0
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.158] [DEBUG] [DATA] broadcast frames=116 msg_type=0x03 msg_id=14 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.166] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=15 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.173] [DEBUG] [BCAST] paced emit #10 qdepth=1174 dropped=0 gap_ms=8.0
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.175] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=16 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.183] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=17 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.188] [DEBUG] [DATA] broadcast frames=59 msg_type=0x03 msg_id=18 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.189] [DEBUG] [DATA] broadcast frames=1 msg_type=0x04 msg_id=19 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.191] [DEBUG] [DATA] notify broadcast sent=4 bytes=122 preview=010203000000010070007b22736e617073686f745f6964223a312c2273656374...(+90B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.193] [DEBUG] [DATA] notify broadcast sent=5 bytes=190 preview=0103040000000200b4007b22736e617073686f745f6964223a312c2273656374...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.194] [DEBUG] [DATA] notify broadcast sent=6 bytes=47 preview=01030400010002002500785f7175616c697479223a332c2273617473223a6e75...(+15B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.196] [DEBUG] [DATA] notify broadcast sent=7 bytes=190 preview=0103050000000600b4007b22736e617073686f745f6964223a312c2273656374...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.200] [DEBUG] [DATA] notify broadcast sent=8 bytes=190 preview=0103050001000600b400636865725f727373695f646174616772616d73223a31...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.202] [DEBUG] [DATA] notify broadcast sent=9 bytes=190 preview=0103050002000600b4006f727473223a363936362c226169735f6d73675f3234...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.212] [DEBUG] [DATA] notify broadcast sent=10 bytes=190 preview=0103050003000600b400616d73223a31323031352c2273746174655f736c6f74...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.214] [DEBUG] [DATA] notify broadcast sent=11 bytes=190 preview=0103050004000600b40065735f677073223a393531342c226770735f66697865...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.215] [DEBUG] [DATA] notify broadcast sent=12 bytes=68 preview=01030500050006003a00735f6d73675f32223a332c226169735f6d73675f3139...(+36B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.218] [DEBUG] [DATA] notify broadcast sent=13 bytes=190 preview=0103060000007500b4007b22736e617073686f745f6964223a312c2273656374...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.229] [DEBUG] [DATA] notify broadcast sent=14 bytes=190 preview=0103060001007500b400382c2264223a31387d2c22766f79616765223a7b2265...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.233] [DEBUG] [DATA] notify broadcast sent=15 bytes=190 preview=0103060002007500b400616c223a7b226c6173745f6462223a6e756c6c2c226c...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.240] [DEBUG] [DATA] notify broadcast sent=16 bytes=190 preview=0103060003007500b400703a302e302e302e303a353030353a3139322e313638...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.244] [DEBUG] [DATA] notify broadcast sent=17 bytes=190 preview=0103060004007500b40061223a6e756c6c2c2264726175676874223a6e756c6c...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.251] [DEBUG] [DATA] notify broadcast sent=18 bytes=190 preview=0103060005007500b4006173745f7473223a6e756c6c2c226c6173745f736c6f...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.259] [DEBUG] [DATA] notify broadcast sent=19 bytes=190 preview=0103060006007500b4002e32322e3230225d2c226d73675f7479706573223a5b...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.268] [DEBUG] [DATA] notify broadcast sent=20 bytes=190 preview=0103060007007500b4006c2c2264657374696e6174696f6e223a6e756c6c7d2c...(+158B)
Apr 24 11:03:05 aismap python3[25757]: [2026-04-24 11:03:05.794] [DEBUG] [DATA] notify broadcast sent=200 bytes=190 preview=0103070046007500b4006c6c2c2264726175676874223a6e756c6c2c22646573...(+158B)
Apr 24 11:03:06 aismap python3[25757]: [2026-04-24 11:03:06.264] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=78 preview=7b22636d64223a22737562736372696265222c226576656e7473223a5b226f77...(+46B)
Apr 24 11:03:06 aismap python3[25757]: [2026-04-24 11:03:06.266] [INFO] [CONTROL] cmd=subscribe device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'subscribe', 'events': ['ownship.update', 'target.update', 'stats.update']}
Apr 24 11:03:06 aismap python3[25757]: [2026-04-24 11:03:06.268] [DEBUG] [DATA] broadcast frames=1 msg_type=0x06 msg_id=20 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:07 aismap python3[25757]: [2026-04-24 11:03:07.403] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:07 aismap python3[25757]: [2026-04-24 11:03:07.499] [DEBUG] [DATA] notify broadcast sent=400 bytes=190 preview=0103090024007500b4006963223a7b226c6174223a6e756c6c2c226c6f6e223a...(+158B)
Apr 24 11:03:08 aismap python3[25757]: [2026-04-24 11:03:08.349] [DEBUG] [BCAST] paced emit #500 qdepth=982 dropped=0 gap_ms=8.0
Apr 24 11:03:09 aismap python3[25757]: [2026-04-24 11:03:09.200] [DEBUG] [DATA] notify broadcast sent=600 bytes=190 preview=01030b0002007500b400745f6462223a6e756c6c2c226c6173745f7473223a6e...(+158B)
Apr 24 11:03:10 aismap python3[25757]: [2026-04-24 11:03:10.896] [DEBUG] [DATA] notify broadcast sent=800 bytes=190 preview=01030c0055007500b400322c2264696d73223a7b2261223a372c2262223a3132...(+158B)
Apr 24 11:03:12 aismap python3[25757]: [2026-04-24 11:03:12.421] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:12 aismap python3[25757]: [2026-04-24 11:03:12.600] [DEBUG] [BCAST] paced emit #1000 qdepth=488 dropped=0 gap_ms=8.0
Apr 24 11:03:12 aismap python3[25757]: [2026-04-24 11:03:12.602] [DEBUG] [DATA] notify broadcast sent=1000 bytes=190 preview=01030e0033007400b400676e616c223a7b226c6173745f6462223a6e756c6c2c...(+158B)
Apr 24 11:03:14 aismap python3[25757]: [2026-04-24 11:03:14.325] [DEBUG] [DATA] notify broadcast sent=1200 bytes=190 preview=0103100012007500b400393036222c22696d6f223a6e756c6c2c22736869705f...(+158B)
Apr 24 11:03:16 aismap python3[25757]: [2026-04-24 11:03:16.033] [DEBUG] [DATA] notify broadcast sent=1400 bytes=190 preview=0103110065007500b4006c6c2c226c6173745f6368616e6e656c223a6e756c6c...(+158B)
Apr 24 11:03:16 aismap python3[25757]: [2026-04-24 11:03:16.952] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=78 preview=7b22636d64223a22737562736372696265222c226576656e7473223a5b226f77...(+46B)
Apr 24 11:03:16 aismap python3[25757]: [2026-04-24 11:03:16.953] [INFO] [CONTROL] cmd=subscribe device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'subscribe', 'events': ['ownship.update', 'target.update', 'stats.update']}
Apr 24 11:03:16 aismap python3[25757]: [2026-04-24 11:03:16.954] [DEBUG] [DATA] broadcast frames=1 msg_type=0x06 msg_id=21 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:17 aismap python3[25757]: [2026-04-24 11:03:17.425] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:22 aismap python3[25757]: [2026-04-24 11:03:22.440] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:22 aismap python3[25757]: [2026-04-24 11:03:22.476] [DEBUG] [BCAST] paced emit #1500 qdepth=1 dropped=0 gap_ms=8.0
Apr 24 11:03:27 aismap python3[25757]: [2026-04-24 11:03:27.461] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:32 aismap python3[25757]: [2026-04-24 11:03:32.482] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:33 aismap python3[25757]: [2026-04-24 11:03:33.526] [INFO] [BlueZ] Device disconnected: /org/bluez/hci0/dev_4B_4E_32_24_64_CE -> removing session
Apr 24 11:03:33 aismap python3[25757]: [2026-04-24 11:03:33.527] [INFO] [BlueZ] sessions now: 0
Apr 24 11:03:33 aismap python3[25757]: [2026-04-24 11:03:33.528] [INFO] [DATA] StopNotify (CCCD disabled)
Apr 24 11:03:34 aismap python3[25757]: [2026-04-24 11:03:34.867] [INFO] [BlueZ] Device connected: /org/bluez/hci0/dev_4B_4E_32_24_64_CE sessions=0
Apr 24 11:03:36 aismap python3[25757]: [2026-04-24 11:03:36.937] [INFO] [DATA] StartNotify (CCCD enabled)
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.127] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=95 preview=7b22636d64223a2268656c6c6f222c22636c69656e74223a22616e64726f6964...(+63B)
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.128] [INFO] [CONTROL] cmd=hello device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'hello', 'client': 'android', 'app_version': '1.0', 'proto': 1, 'encodings': ['msgpack', 'json']}
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.202] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=80 preview=7b22636d64223a226765745f736e617073686f74222c22696e636c756465223a...(+48B)
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.203] [INFO] [CONTROL] cmd=get_snapshot device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'get_snapshot', 'include': ['ownship', 'vessels', 'stats'], 'max_vessels': 500}
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.204] [DEBUG] [SNAPSHOT] waiting broadcast lock sess=/org/bluez/hci0/dev_4B_4E_32_24_64_CE snapshot_id=1
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.206] [DEBUG] [SNAPSHOT] acquired broadcast lock sess=/org/bluez/hci0/dev_4B_4E_32_24_64_CE
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.277] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=80 preview=7b22636d64223a226765745f736e617073686f74222c22696e636c756465223a...(+48B)
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.278] [INFO] [CONTROL] cmd=get_snapshot device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'get_snapshot', 'include': ['ownship', 'vessels', 'stats'], 'max_vessels': 500}
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.279] [DEBUG] [DATA] broadcast frames=1 msg_type=0x07 msg_id=2 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.460] [DEBUG] [DATA] broadcast frames=1 msg_type=0x02 msg_id=3 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.462] [DEBUG] [SNAPSHOT] vessels progress sess=/org/bluez/hci0/dev_4B_4E_32_24_64_CE sent=360/500 seq=10 bcast_q=1
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.464] [DEBUG] [DATA] broadcast frames=2 msg_type=0x03 msg_id=4 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.465] [INFO] [SNAPSHOT] done sess=/org/bluez/hci0/dev_4B_4E_32_24_64_CE snapshot_id=1 vessels=500 bcast_dropped=0
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.468] [DEBUG] [DATA] broadcast frames=6 msg_type=0x03 msg_id=5 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.475] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=6 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.483] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=7 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.492] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=8 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.504] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=9 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.508] [DEBUG] [FANOUT] suppress ev_type=stats.update during active snapshot sessions=1
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.517] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=10 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.525] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=11 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.534] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=12 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.542] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=13 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.550] [DEBUG] [DATA] broadcast frames=116 msg_type=0x03 msg_id=14 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.559] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=15 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.567] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=16 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.576] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=17 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.580] [DEBUG] [DATA] broadcast frames=59 msg_type=0x03 msg_id=18 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.581] [DEBUG] [DATA] broadcast frames=1 msg_type=0x04 msg_id=19 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:38 aismap python3[25757]: [2026-04-24 11:03:38.220] [DEBUG] [DATA] notify broadcast sent=1600 bytes=190 preview=010306004a007500b400796e616d6963223a7b226c6174223a36322e35393439...(+158B)
Apr 24 11:03:39 aismap python3[25757]: [2026-04-24 11:03:39.198] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=78 preview=7b22636d64223a22737562736372696265222c226576656e7473223a5b226f77...(+46B)
Apr 24 11:03:39 aismap python3[25757]: [2026-04-24 11:03:39.199] [INFO] [CONTROL] cmd=subscribe device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'subscribe', 'events': ['ownship.update', 'target.update', 'stats.update']}
Apr 24 11:03:39 aismap python3[25757]: [2026-04-24 11:03:39.200] [DEBUG] [DATA] broadcast frames=1 msg_type=0x06 msg_id=20 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:39 aismap python3[25757]: [2026-04-24 11:03:39.916] [DEBUG] [DATA] notify broadcast sent=1800 bytes=190 preview=0103080028007500b40074223a2d312e307d2c227369676e616c223a7b226c61...(+158B)
Apr 24 11:03:41 aismap python3[25757]: [2026-04-24 11:03:41.613] [DEBUG] [BCAST] paced emit #2000 qdepth=989 dropped=0 gap_ms=8.0
Apr 24 11:03:41 aismap python3[25757]: [2026-04-24 11:03:41.615] [DEBUG] [DATA] notify broadcast sent=2000 bytes=190 preview=01030a0006007500b400353a3139322e3136382e32322e3230225d2c226d7367...(+158B)
Apr 24 11:03:42 aismap python3[25757]: [2026-04-24 11:03:42.520] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:43 aismap python3[25757]: [2026-04-24 11:03:43.316] [DEBUG] [DATA] notify broadcast sent=2200 bytes=190 preview=01030b0059007500b4007374617469635f7473223a302e302c226c6173745f64...(+158B)
Apr 24 11:03:45 aismap python3[25757]: [2026-04-24 11:03:45.012] [DEBUG] [DATA] notify broadcast sent=2400 bytes=190 preview=01030d0037007500b4005f7473223a302e302c226c6173745f7365656e223a31...(+158B)
Apr 24 11:03:45 aismap python3[25757]: [2026-04-24 11:03:45.862] [DEBUG] [BCAST] paced emit #2500 qdepth=495 dropped=0 gap_ms=8.0
Apr 24 11:03:46 aismap python3[25757]: [2026-04-24 11:03:46.717] [DEBUG] [DATA] notify broadcast sent=2600 bytes=190 preview=01030f0016007500b400383031392c22736f67223a302e302c22636f67223a33...(+158B)
Apr 24 11:03:47 aismap python3[25757]: [2026-04-24 11:03:47.547] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:48 aismap python3[25757]: [2026-04-24 11:03:48.449] [DEBUG] [DATA] notify broadcast sent=2800 bytes=190 preview=0103100069007500b400223a337d2c22766f79616765223a7b22657461223a6e...(+158B)
Apr 24 11:03:50 aismap python3[25757]: [2026-04-24 11:03:50.147] [DEBUG] [BCAST] paced emit #3000 qdepth=1 dropped=0 gap_ms=8.0
Apr 24 11:03:50 aismap python3[25757]: [2026-04-24 11:03:50.150] [DEBUG] [DATA] notify broadcast sent=3000 bytes=190 preview=0105000004000600b4003531342c226770735f6669786573223a363334332c22...(+158B)
Apr 24 11:03:50 aismap python3[25757]: [2026-04-24 11:03:50.251] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=78 preview=7b22636d64223a22737562736372696265222c226576656e7473223a5b226f77...(+46B)
Apr 24 11:03:50 aismap python3[25757]: [2026-04-24 11:03:50.252] [INFO] [CONTROL] cmd=subscribe device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'subscribe', 'events': ['ownship.update', 'target.update', 'stats.update']}
Apr 24 11:03:50 aismap python3[25757]: [2026-04-24 11:03:50.253] [DEBUG] [DATA] broadcast frames=1 msg_type=0x06 msg_id=21 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:52 aismap python3[25757]: [2026-04-24 11:03:52.559] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:57 aismap python3[25757]: [2026-04-24 11:03:57.580] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:04:02 aismap python3[25757]: [2026-04-24 11:04:02.601] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:04:07 aismap python3[25757]: [2026-04-24 11:04:07.617] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:04:10 aismap python3[25757]: [2026-04-24 11:04:10.952] [INFO] [BlueZ] Device disconnected: /org/bluez/hci0/dev_4B_4E_32_24_64_CE -> removing session
Apr 24 11:04:10 aismap python3[25757]: [2026-04-24 11:04:10.953] [INFO] [BlueZ] sessions now: 0
Apr 24 11:04:10 aismap python3[25757]: [2026-04-24 11:04:10.955] [INFO] [DATA] StopNotify (CCCD disabled)
^C
root@aismap:/opt/aismapv2# journalctl -u ble-gatt.service -n 100 --no-pager
Journal file /var/log/journal/5dcf904c44344fed93689949d7018827/system@00a94b2b966748b6a2f50a7b3948cb43-000000000000151e-0006501c00a6f34a.journal is truncated, ignoring file.
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.189] [DEBUG] [DATA] broadcast frames=1 msg_type=0x04 msg_id=19 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.191] [DEBUG] [DATA] notify broadcast sent=4 bytes=122 preview=010203000000010070007b22736e617073686f745f6964223a312c2273656374...(+90B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.193] [DEBUG] [DATA] notify broadcast sent=5 bytes=190 preview=0103040000000200b4007b22736e617073686f745f6964223a312c2273656374...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.194] [DEBUG] [DATA] notify broadcast sent=6 bytes=47 preview=01030400010002002500785f7175616c697479223a332c2273617473223a6e75...(+15B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.196] [DEBUG] [DATA] notify broadcast sent=7 bytes=190 preview=0103050000000600b4007b22736e617073686f745f6964223a312c2273656374...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.200] [DEBUG] [DATA] notify broadcast sent=8 bytes=190 preview=0103050001000600b400636865725f727373695f646174616772616d73223a31...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.202] [DEBUG] [DATA] notify broadcast sent=9 bytes=190 preview=0103050002000600b4006f727473223a363936362c226169735f6d73675f3234...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.212] [DEBUG] [DATA] notify broadcast sent=10 bytes=190 preview=0103050003000600b400616d73223a31323031352c2273746174655f736c6f74...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.214] [DEBUG] [DATA] notify broadcast sent=11 bytes=190 preview=0103050004000600b40065735f677073223a393531342c226770735f66697865...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.215] [DEBUG] [DATA] notify broadcast sent=12 bytes=68 preview=01030500050006003a00735f6d73675f32223a332c226169735f6d73675f3139...(+36B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.218] [DEBUG] [DATA] notify broadcast sent=13 bytes=190 preview=0103060000007500b4007b22736e617073686f745f6964223a312c2273656374...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.229] [DEBUG] [DATA] notify broadcast sent=14 bytes=190 preview=0103060001007500b400382c2264223a31387d2c22766f79616765223a7b2265...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.233] [DEBUG] [DATA] notify broadcast sent=15 bytes=190 preview=0103060002007500b400616c223a7b226c6173745f6462223a6e756c6c2c226c...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.240] [DEBUG] [DATA] notify broadcast sent=16 bytes=190 preview=0103060003007500b400703a302e302e302e303a353030353a3139322e313638...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.244] [DEBUG] [DATA] notify broadcast sent=17 bytes=190 preview=0103060004007500b40061223a6e756c6c2c2264726175676874223a6e756c6c...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.251] [DEBUG] [DATA] notify broadcast sent=18 bytes=190 preview=0103060005007500b4006173745f7473223a6e756c6c2c226c6173745f736c6f...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.259] [DEBUG] [DATA] notify broadcast sent=19 bytes=190 preview=0103060006007500b4002e32322e3230225d2c226d73675f7479706573223a5b...(+158B)
Apr 24 11:03:04 aismap python3[25757]: [2026-04-24 11:03:04.268] [DEBUG] [DATA] notify broadcast sent=20 bytes=190 preview=0103060007007500b4006c2c2264657374696e6174696f6e223a6e756c6c7d2c...(+158B)
Apr 24 11:03:05 aismap python3[25757]: [2026-04-24 11:03:05.794] [DEBUG] [DATA] notify broadcast sent=200 bytes=190 preview=0103070046007500b4006c6c2c2264726175676874223a6e756c6c2c22646573...(+158B)
Apr 24 11:03:06 aismap python3[25757]: [2026-04-24 11:03:06.264] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=78 preview=7b22636d64223a22737562736372696265222c226576656e7473223a5b226f77...(+46B)
Apr 24 11:03:06 aismap python3[25757]: [2026-04-24 11:03:06.266] [INFO] [CONTROL] cmd=subscribe device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'subscribe', 'events': ['ownship.update', 'target.update', 'stats.update']}
Apr 24 11:03:06 aismap python3[25757]: [2026-04-24 11:03:06.268] [DEBUG] [DATA] broadcast frames=1 msg_type=0x06 msg_id=20 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:07 aismap python3[25757]: [2026-04-24 11:03:07.403] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:07 aismap python3[25757]: [2026-04-24 11:03:07.499] [DEBUG] [DATA] notify broadcast sent=400 bytes=190 preview=0103090024007500b4006963223a7b226c6174223a6e756c6c2c226c6f6e223a...(+158B)
Apr 24 11:03:08 aismap python3[25757]: [2026-04-24 11:03:08.349] [DEBUG] [BCAST] paced emit #500 qdepth=982 dropped=0 gap_ms=8.0
Apr 24 11:03:09 aismap python3[25757]: [2026-04-24 11:03:09.200] [DEBUG] [DATA] notify broadcast sent=600 bytes=190 preview=01030b0002007500b400745f6462223a6e756c6c2c226c6173745f7473223a6e...(+158B)
Apr 24 11:03:10 aismap python3[25757]: [2026-04-24 11:03:10.896] [DEBUG] [DATA] notify broadcast sent=800 bytes=190 preview=01030c0055007500b400322c2264696d73223a7b2261223a372c2262223a3132...(+158B)
Apr 24 11:03:12 aismap python3[25757]: [2026-04-24 11:03:12.421] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:12 aismap python3[25757]: [2026-04-24 11:03:12.600] [DEBUG] [BCAST] paced emit #1000 qdepth=488 dropped=0 gap_ms=8.0
Apr 24 11:03:12 aismap python3[25757]: [2026-04-24 11:03:12.602] [DEBUG] [DATA] notify broadcast sent=1000 bytes=190 preview=01030e0033007400b400676e616c223a7b226c6173745f6462223a6e756c6c2c...(+158B)
Apr 24 11:03:14 aismap python3[25757]: [2026-04-24 11:03:14.325] [DEBUG] [DATA] notify broadcast sent=1200 bytes=190 preview=0103100012007500b400393036222c22696d6f223a6e756c6c2c22736869705f...(+158B)
Apr 24 11:03:16 aismap python3[25757]: [2026-04-24 11:03:16.033] [DEBUG] [DATA] notify broadcast sent=1400 bytes=190 preview=0103110065007500b4006c6c2c226c6173745f6368616e6e656c223a6e756c6c...(+158B)
Apr 24 11:03:16 aismap python3[25757]: [2026-04-24 11:03:16.952] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=78 preview=7b22636d64223a22737562736372696265222c226576656e7473223a5b226f77...(+46B)
Apr 24 11:03:16 aismap python3[25757]: [2026-04-24 11:03:16.953] [INFO] [CONTROL] cmd=subscribe device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'subscribe', 'events': ['ownship.update', 'target.update', 'stats.update']}
Apr 24 11:03:16 aismap python3[25757]: [2026-04-24 11:03:16.954] [DEBUG] [DATA] broadcast frames=1 msg_type=0x06 msg_id=21 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:17 aismap python3[25757]: [2026-04-24 11:03:17.425] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:22 aismap python3[25757]: [2026-04-24 11:03:22.440] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:22 aismap python3[25757]: [2026-04-24 11:03:22.476] [DEBUG] [BCAST] paced emit #1500 qdepth=1 dropped=0 gap_ms=8.0
Apr 24 11:03:27 aismap python3[25757]: [2026-04-24 11:03:27.461] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:32 aismap python3[25757]: [2026-04-24 11:03:32.482] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:33 aismap python3[25757]: [2026-04-24 11:03:33.526] [INFO] [BlueZ] Device disconnected: /org/bluez/hci0/dev_4B_4E_32_24_64_CE -> removing session
Apr 24 11:03:33 aismap python3[25757]: [2026-04-24 11:03:33.527] [INFO] [BlueZ] sessions now: 0
Apr 24 11:03:33 aismap python3[25757]: [2026-04-24 11:03:33.528] [INFO] [DATA] StopNotify (CCCD disabled)
Apr 24 11:03:34 aismap python3[25757]: [2026-04-24 11:03:34.867] [INFO] [BlueZ] Device connected: /org/bluez/hci0/dev_4B_4E_32_24_64_CE sessions=0
Apr 24 11:03:36 aismap python3[25757]: [2026-04-24 11:03:36.937] [INFO] [DATA] StartNotify (CCCD enabled)
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.127] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=95 preview=7b22636d64223a2268656c6c6f222c22636c69656e74223a22616e64726f6964...(+63B)
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.128] [INFO] [CONTROL] cmd=hello device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'hello', 'client': 'android', 'app_version': '1.0', 'proto': 1, 'encodings': ['msgpack', 'json']}
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.202] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=80 preview=7b22636d64223a226765745f736e617073686f74222c22696e636c756465223a...(+48B)
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.203] [INFO] [CONTROL] cmd=get_snapshot device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'get_snapshot', 'include': ['ownship', 'vessels', 'stats'], 'max_vessels': 500}
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.204] [DEBUG] [SNAPSHOT] waiting broadcast lock sess=/org/bluez/hci0/dev_4B_4E_32_24_64_CE snapshot_id=1
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.206] [DEBUG] [SNAPSHOT] acquired broadcast lock sess=/org/bluez/hci0/dev_4B_4E_32_24_64_CE
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.277] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=80 preview=7b22636d64223a226765745f736e617073686f74222c22696e636c756465223a...(+48B)
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.278] [INFO] [CONTROL] cmd=get_snapshot device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'get_snapshot', 'include': ['ownship', 'vessels', 'stats'], 'max_vessels': 500}
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.279] [DEBUG] [DATA] broadcast frames=1 msg_type=0x07 msg_id=2 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.460] [DEBUG] [DATA] broadcast frames=1 msg_type=0x02 msg_id=3 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.462] [DEBUG] [SNAPSHOT] vessels progress sess=/org/bluez/hci0/dev_4B_4E_32_24_64_CE sent=360/500 seq=10 bcast_q=1
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.464] [DEBUG] [DATA] broadcast frames=2 msg_type=0x03 msg_id=4 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.465] [INFO] [SNAPSHOT] done sess=/org/bluez/hci0/dev_4B_4E_32_24_64_CE snapshot_id=1 vessels=500 bcast_dropped=0
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.468] [DEBUG] [DATA] broadcast frames=6 msg_type=0x03 msg_id=5 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.475] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=6 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.483] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=7 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.492] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=8 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.504] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=9 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.508] [DEBUG] [FANOUT] suppress ev_type=stats.update during active snapshot sessions=1
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.517] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=10 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.525] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=11 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.534] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=12 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.542] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=13 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.550] [DEBUG] [DATA] broadcast frames=116 msg_type=0x03 msg_id=14 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.559] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=15 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.567] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=16 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.576] [DEBUG] [DATA] broadcast frames=117 msg_type=0x03 msg_id=17 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.580] [DEBUG] [DATA] broadcast frames=59 msg_type=0x03 msg_id=18 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:37 aismap python3[25757]: [2026-04-24 11:03:37.581] [DEBUG] [DATA] broadcast frames=1 msg_type=0x04 msg_id=19 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:38 aismap python3[25757]: [2026-04-24 11:03:38.220] [DEBUG] [DATA] notify broadcast sent=1600 bytes=190 preview=010306004a007500b400796e616d6963223a7b226c6174223a36322e35393439...(+158B)
Apr 24 11:03:39 aismap python3[25757]: [2026-04-24 11:03:39.198] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=78 preview=7b22636d64223a22737562736372696265222c226576656e7473223a5b226f77...(+46B)
Apr 24 11:03:39 aismap python3[25757]: [2026-04-24 11:03:39.199] [INFO] [CONTROL] cmd=subscribe device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'subscribe', 'events': ['ownship.update', 'target.update', 'stats.update']}
Apr 24 11:03:39 aismap python3[25757]: [2026-04-24 11:03:39.200] [DEBUG] [DATA] broadcast frames=1 msg_type=0x06 msg_id=20 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:39 aismap python3[25757]: [2026-04-24 11:03:39.916] [DEBUG] [DATA] notify broadcast sent=1800 bytes=190 preview=0103080028007500b40074223a2d312e307d2c227369676e616c223a7b226c61...(+158B)
Apr 24 11:03:41 aismap python3[25757]: [2026-04-24 11:03:41.613] [DEBUG] [BCAST] paced emit #2000 qdepth=989 dropped=0 gap_ms=8.0
Apr 24 11:03:41 aismap python3[25757]: [2026-04-24 11:03:41.615] [DEBUG] [DATA] notify broadcast sent=2000 bytes=190 preview=01030a0006007500b400353a3139322e3136382e32322e3230225d2c226d7367...(+158B)
Apr 24 11:03:42 aismap python3[25757]: [2026-04-24 11:03:42.520] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:43 aismap python3[25757]: [2026-04-24 11:03:43.316] [DEBUG] [DATA] notify broadcast sent=2200 bytes=190 preview=01030b0059007500b4007374617469635f7473223a302e302c226c6173745f64...(+158B)
Apr 24 11:03:45 aismap python3[25757]: [2026-04-24 11:03:45.012] [DEBUG] [DATA] notify broadcast sent=2400 bytes=190 preview=01030d0037007500b4005f7473223a302e302c226c6173745f7365656e223a31...(+158B)
Apr 24 11:03:45 aismap python3[25757]: [2026-04-24 11:03:45.862] [DEBUG] [BCAST] paced emit #2500 qdepth=495 dropped=0 gap_ms=8.0
Apr 24 11:03:46 aismap python3[25757]: [2026-04-24 11:03:46.717] [DEBUG] [DATA] notify broadcast sent=2600 bytes=190 preview=01030f0016007500b400383031392c22736f67223a302e302c22636f67223a33...(+158B)
Apr 24 11:03:47 aismap python3[25757]: [2026-04-24 11:03:47.547] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:48 aismap python3[25757]: [2026-04-24 11:03:48.449] [DEBUG] [DATA] notify broadcast sent=2800 bytes=190 preview=0103100069007500b400223a337d2c22766f79616765223a7b22657461223a6e...(+158B)
Apr 24 11:03:50 aismap python3[25757]: [2026-04-24 11:03:50.147] [DEBUG] [BCAST] paced emit #3000 qdepth=1 dropped=0 gap_ms=8.0
Apr 24 11:03:50 aismap python3[25757]: [2026-04-24 11:03:50.150] [DEBUG] [DATA] notify broadcast sent=3000 bytes=190 preview=0105000004000600b4003531342c226770735f6669786573223a363334332c22...(+158B)
Apr 24 11:03:50 aismap python3[25757]: [2026-04-24 11:03:50.251] [INFO] [CONTROL] WriteValue device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE bytes=78 preview=7b22636d64223a22737562736372696265222c226576656e7473223a5b226f77...(+46B)
Apr 24 11:03:50 aismap python3[25757]: [2026-04-24 11:03:50.252] [INFO] [CONTROL] cmd=subscribe device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE json={'cmd': 'subscribe', 'events': ['ownship.update', 'target.update', 'stats.update']}
Apr 24 11:03:50 aismap python3[25757]: [2026-04-24 11:03:50.253] [DEBUG] [DATA] broadcast frames=1 msg_type=0x06 msg_id=21 device=/org/bluez/hci0/dev_4B_4E_32_24_64_CE enc=json
Apr 24 11:03:52 aismap python3[25757]: [2026-04-24 11:03:52.559] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:03:57 aismap python3[25757]: [2026-04-24 11:03:57.580] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:04:02 aismap python3[25757]: [2026-04-24 11:04:02.601] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:04:07 aismap python3[25757]: [2026-04-24 11:04:07.617] [DEBUG] [DATA] broadcast frames=6 msg_type=0x05 ev_type=stats.update sessions=1 enc=json
Apr 24 11:04:10 aismap python3[25757]: [2026-04-24 11:04:10.952] [INFO] [BlueZ] Device disconnected: /org/bluez/hci0/dev_4B_4E_32_24_64_CE -> removing session
Apr 24 11:04:10 aismap python3[25757]: [2026-04-24 11:04:10.953] [INFO] [BlueZ] sessions now: 0
Apr 24 11:04:10 aismap python3[25757]: [2026-04-24 11:04:10.955] [INFO] [DATA] StopNotify (CCCD disabled)
root@aismap:/opt/aismapv2#
+1538
View File
File diff suppressed because it is too large Load Diff
+556
View File
@@ -0,0 +1,556 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import dbus
import dbus.exceptions
import dbus.mainloop.glib
import dbus.service
import threading
import socket
import struct
import sys
import time
from datetime import datetime
from gi.repository import GLib
BLUEZ_SERVICE_NAME = 'org.bluez'
GATT_MANAGER_IFACE = 'org.bluez.GattManager1'
LE_ADVERTISING_MANAGER_IFACE = 'org.bluez.LEAdvertisingManager1'
LE_ADVERTISEMENT_IFACE = 'org.bluez.LEAdvertisement1'
GATT_SERVICE_IFACE = 'org.bluez.GattService1'
GATT_CHRC_IFACE = 'org.bluez.GattCharacteristic1'
DBUS_OM_IFACE = 'org.freedesktop.DBus.ObjectManager'
DBUS_PROP_IFACE = 'org.freedesktop.DBus.Properties'
MAIN_LOOP = None
# ============ Логирование ============
def log(level, message):
"""Логирование с временной меткой"""
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
print(f'[{timestamp}] [{level}] {message}', flush=True)
def log_info(message):
log('INFO', message)
def log_warn(message):
log('WARN', message)
def log_error(message):
log('ERROR', message)
def log_debug(message):
log('DEBUG', message)
# ============ UUID (вариант A: короткие) ============
# Battery Service / Level стандартные
BAT_SERVICE_UUID = '0000180f-0000-1000-8000-00805f9b34fb'
BAT_LEVEL_UUID = '00002a19-0000-1000-8000-00805f9b34fb'
# P2P 16-битки в SIG-диапазоне
P2P_SERVICE_UUID = '0000fe40-0000-1000-8000-00805f9b34fb'
P2P_CHAR_UUID = '0000fe42-0000-1000-8000-00805f9b34fb'
# RW – кастом 16-битный диапазон
RW_SERVICE_UUID = '0000ab10-0000-1000-8000-00805f9b34fb'
RW_CHAR_UUID = '0000ab11-0000-1000-8000-00805f9b34fb'
# ============ D-Bus Exceptions ============
class InvalidArgsException(dbus.exceptions.DBusException):
_dbus_error_name = 'org.freedesktop.DBus.Error.InvalidArgs'
class NotSupportedException(dbus.exceptions.DBusException):
_dbus_error_name = 'org.bluez.Error.NotSupported'
# ============ Application (ObjectManager) ============
class Application(dbus.service.Object):
"""
Корневой объект, который отдаёт BlueZ все наши сервисы/характеристики через GetManagedObjects.
"""
def __init__(self, bus):
self.path = '/org/bluez/example/app'
self.services = []
dbus.service.Object.__init__(self, bus, self.path)
def get_path(self):
return dbus.ObjectPath(self.path)
def add_service(self, service):
self.services.append(service)
def get_services(self):
return self.services
@dbus.service.method(DBUS_OM_IFACE,
out_signature='a{oa{sa{sv}}}')
def GetManagedObjects(self):
log_info('🔵 GetManagedObjects вызван - клиент запрашивает список сервисов')
log_info(' Это означает, что подключение установлено и начинается дискаверинг')
managed_objects = {}
for service in self.services:
managed_objects[service.get_path()] = service.get_properties()
for chrc in service.get_characteristics():
managed_objects[chrc.get_path()] = chrc.get_properties()
log_info(f' Возвращаем {len(managed_objects)} объектов (сервисы + характеристики)')
return managed_objects
# ============ GATT Service ============
class Service(dbus.service.Object):
def __init__(self, bus, index, uuid, primary):
self.path = f'/org/bluez/example/service{index}'
self.bus = bus
self.uuid = uuid
self.primary = primary
self.characteristics = []
dbus.service.Object.__init__(self, bus, self.path)
def get_path(self):
return dbus.ObjectPath(self.path)
def add_characteristic(self, chrc):
self.characteristics.append(chrc)
def get_characteristics(self):
return self.characteristics
def get_properties(self):
return {
GATT_SERVICE_IFACE: {
'UUID': self.uuid,
'Primary': dbus.Boolean(self.primary),
'Includes': dbus.Array([], signature='o'),
}
}
# ============ GATT Characteristic (base) ============
class Characteristic(dbus.service.Object):
def __init__(self, bus, index, uuid, flags, service):
self.path = service.path + f'/char{index}'
self.bus = bus
self.uuid = uuid
self.flags = flags
self.service = service
self.notifying = False
self.value_bytes = bytes([0x00]) # дефолт
dbus.service.Object.__init__(self, bus, self.path)
def get_path(self):
return dbus.ObjectPath(self.path)
def get_properties(self):
return {
GATT_CHRC_IFACE: {
'Service': self.service.get_path(),
'UUID': self.uuid,
'Flags': dbus.Array(self.flags, signature='s'),
# Стартовое Value, чтобы клиенты видели, что характеристика живая
'Value': dbus.Array([dbus.Byte(b) for b in self.value_bytes], signature='y'),
}
}
@dbus.service.method(GATT_CHRC_IFACE,
in_signature='a{sv}',
out_signature='ay')
def ReadValue(self, options):
print(f'{self.uuid}: ReadValue (base stub)')
raise NotSupportedException('Read not implemented')
@dbus.service.method(GATT_CHRC_IFACE,
in_signature='aya{sv}',
out_signature='')
def WriteValue(self, value, options):
print(f'{self.uuid}: WriteValue (base stub)')
raise NotSupportedException('Write not implemented')
@dbus.service.method(GATT_CHRC_IFACE,
in_signature='',
out_signature='')
def StartNotify(self):
print(f'{self.uuid}: StartNotify (base stub)')
raise NotSupportedException('Notifications not supported')
@dbus.service.method(GATT_CHRC_IFACE,
in_signature='',
out_signature='')
def StopNotify(self):
print(f'{self.uuid}: StopNotify (base stub)')
raise NotSupportedException('Notifications not supported')
# helper для рассылки notify
def _update_value_and_notify(self, new_bytes: bytes):
self.value_bytes = new_bytes
self.PropertiesChanged(
GATT_CHRC_IFACE,
{'Value': dbus.Array([dbus.Byte(b) for b in self.value_bytes], signature='y')},
[]
)
@dbus.service.signal(DBUS_PROP_IFACE,
signature='sa{sv}as')
def PropertiesChanged(self, interface, changed, invalidated):
pass
# ============ Battery Service ============
class BatteryService(Service):
def __init__(self, bus, index):
Service.__init__(self, bus, index, BAT_SERVICE_UUID, True)
self.level_characteristic = BatteryLevelCharacteristic(bus, 0, self)
self.add_characteristic(self.level_characteristic)
class BatteryLevelCharacteristic(Characteristic):
def __init__(self, bus, index, service):
Characteristic.__init__(self, bus, index, BAT_LEVEL_UUID,
['read', 'notify'], service)
self.level = 77 # стартовый % батарейки
self.value_bytes = bytes([self.level])
@dbus.service.method(GATT_CHRC_IFACE,
in_signature='a{sv}',
out_signature='ay')
def ReadValue(self, options):
log_info(f'[Battery] ReadValue -> {self.level}%')
return dbus.Array([dbus.Byte(self.level)], signature='y')
def set_level(self, value):
value = max(0, min(100, int(value)))
self.level = value
if self.notifying:
self._update_value_and_notify(bytes([self.level]))
@dbus.service.method(GATT_CHRC_IFACE,
in_signature='',
out_signature='')
def StartNotify(self):
log_info('[Battery] StartNotify - клиент подписался на уведомления')
self.notifying = True
self._update_value_and_notify(bytes([self.level]))
@dbus.service.method(GATT_CHRC_IFACE,
in_signature='',
out_signature='')
def StopNotify(self):
log_info('[Battery] StopNotify - клиент отписался от уведомлений')
self.notifying = False
# ============ P2P Service (write+notify, UDP→notify) ============
class P2PService(Service):
def __init__(self, bus, index):
Service.__init__(self, bus, index, P2P_SERVICE_UUID, True)
self.p2p_char = P2PCharacteristic(bus, 0, self)
self.add_characteristic(self.p2p_char)
class P2PCharacteristic(Characteristic):
"""
P2P:
- BLE: read + write-without-response + notify
- UDP listener (порт 5005) режем датаграмму на чанки по 20 байт и шлём notify
"""
def __init__(self, bus, index, service):
Characteristic.__init__(self, bus, index, P2P_CHAR_UUID,
['read', 'write-without-response', 'notify'], service)
self.data = bytearray(b'\x00' * 20)
self.value_bytes = bytes(self.data)
self.notifying = False
# UDP listener
self.udp_port = 5005
self._udp_thread = threading.Thread(target=self._udp_listener, daemon=True)
self._udp_thread.start()
log_info(f'[P2P] UDP listener запущен на порту {self.udp_port}')
# BLE read
@dbus.service.method(GATT_CHRC_IFACE,
in_signature='a{sv}',
out_signature='ay')
def ReadValue(self, options):
data_hex = self.data.hex()
log_info(f'[P2P] ReadValue -> {len(self.data)} байт: {data_hex}')
return dbus.Array([dbus.Byte(b) for b in self.data], signature='y')
# BLE write
@dbus.service.method(GATT_CHRC_IFACE,
in_signature='aya{sv}',
out_signature='')
def WriteValue(self, value, options):
raw = bytes(value)[:20]
self.data[:] = raw.ljust(20, b'\x00')
data_hex = raw.hex()
log_info(f'[P2P] WriteValue <- {len(raw)} байт: {data_hex}')
if self.notifying:
self._update_value_and_notify(bytes(self.data))
log_debug('[P2P] Отправлено уведомление после записи')
# BLE start notifications
@dbus.service.method(GATT_CHRC_IFACE,
in_signature='',
out_signature='')
def StartNotify(self):
log_info('[P2P] StartNotify - клиент подписался на уведомления')
self.notifying = True
self._update_value_and_notify(bytes(self.data))
# BLE stop notifications
@dbus.service.method(GATT_CHRC_IFACE,
in_signature='',
out_signature='')
def StopNotify(self):
log_info('[P2P] StopNotify - клиент отписался от уведомлений')
self.notifying = False
# ======================
# UDP Listener Thread
# ======================
def _udp_listener(self):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", self.udp_port))
log_info(f'[UDP] Слушаем на 0.0.0.0:{self.udp_port}')
while True:
try:
data, addr = sock.recvfrom(2048)
log_debug(f'[UDP] Получено {len(data)} байт от {addr[0]}:{addr[1]}')
# режем на чанки по 20 байт
chunks_count = (len(data) + 19) // 20
for i in range(0, len(data), 20):
chunk = data[i:i + 20]
chunk_hex = chunk.hex()
log_debug(f'[UDP] → BLE chunk {i//20 + 1}/{chunks_count} ({len(chunk)} байт): {chunk_hex}')
# отправляем notify из потока GLib (потокобезопасно)
GLib.idle_add(self._send_notify_safe, chunk)
except Exception as e:
log_error(f'[UDP] Ошибка при получении данных: {e}')
# вызывается внутри GLib main loop
def _send_notify_safe(self, chunk: bytes):
if self.notifying:
self.data[:] = chunk.ljust(20, b'\x00')
self._update_value_and_notify(bytes(self.data))
log_debug(f'[P2P] Отправлено notify из UDP: {chunk.hex()}')
else:
log_debug('[P2P] Пропущено notify из UDP (клиент не подписан)')
return False # чтобы idle_add не повторял вызов
# ============ RW Int32 Service (read/write/notify) ============
class RWService(Service):
def __init__(self, bus, index):
Service.__init__(self, bus, index, RW_SERVICE_UUID, True)
self.rw_char = RWIntCharacteristic(bus, 0, self)
self.add_characteristic(self.rw_char)
class RWIntCharacteristic(Characteristic):
"""
RW Int32 (LE signed, 4 байта)
"""
def __init__(self, bus, index, service):
Characteristic.__init__(self, bus, index, RW_CHAR_UUID,
['read', 'write', 'notify'], service)
self.value = 1234
self.value_bytes = struct.pack('<i', self.value)
@dbus.service.method(GATT_CHRC_IFACE,
in_signature='a{sv}',
out_signature='ay')
def ReadValue(self, options):
log_info(f'[RWInt] ReadValue -> {self.value}')
data = struct.pack('<i', int(self.value))
return dbus.Array([dbus.Byte(b) for b in data], signature='y')
@dbus.service.method(GATT_CHRC_IFACE,
in_signature='aya{sv}',
out_signature='')
def WriteValue(self, value, options):
raw = bytes(value)
if len(raw) < 4:
log_warn(f'[RWInt] WriteValue: слишком короткие данные ({len(raw)} байт), игнорируем')
return
self.value = struct.unpack('<i', raw[:4])[0]
log_info(f'[RWInt] WriteValue <- {self.value}')
if self.notifying:
self._update_value_and_notify(struct.pack('<i', self.value))
log_debug('[RWInt] Отправлено уведомление после записи')
@dbus.service.method(GATT_CHRC_IFACE,
in_signature='',
out_signature='')
def StartNotify(self):
log_info('[RWInt] StartNotify - клиент подписался на уведомления')
self.notifying = True
self._update_value_and_notify(struct.pack('<i', self.value))
@dbus.service.method(GATT_CHRC_IFACE,
in_signature='',
out_signature='')
def StopNotify(self):
log_info('[RWInt] StopNotify - клиент отписался от уведомлений')
self.notifying = False
# ============ LE Advertisement ============
class TestAdvertisement(dbus.service.Object):
PATH_BASE = '/org/bluez/example/advertisement'
def __init__(self, bus, index):
self.path = self.PATH_BASE + str(index)
self.bus = bus
dbus.service.Object.__init__(self, bus, self.path)
def get_path(self):
return dbus.ObjectPath(self.path)
@dbus.service.method(DBUS_PROP_IFACE,
in_signature='s',
out_signature='a{sv}')
def GetAll(self, interface):
log_info('[Advertisement] GetAll вызван для интерфейса: ' + interface)
if interface != LE_ADVERTISEMENT_IFACE:
log_error('[Advertisement] Неверный интерфейс: ' + interface)
raise InvalidArgsException()
# Connectable реклама с явными флагами
# Важно: IncludeTxPower и другие параметры могут помочь с подключением
props = {
'Type': dbus.String('peripheral'),
'ServiceUUIDs': dbus.Array([
BAT_SERVICE_UUID,
P2P_SERVICE_UUID,
RW_SERVICE_UUID,
], signature='s'),
'LocalName': dbus.String('AIS'),
}
log_info('[Advertisement] Возвращаем свойства рекламы:')
log_info(f' Type: {props["Type"]}')
log_info(f' LocalName: {props["LocalName"]}')
log_info(f' ServiceUUIDs: {len(props["ServiceUUIDs"])} сервисов')
return props
@dbus.service.method(LE_ADVERTISEMENT_IFACE,
in_signature='',
out_signature='')
def Release(self):
log_warn('[Advertisement] Release вызван - возможно, клиент отключился или реклама была отменена')
log_warn('[Advertisement] Это может означать, что реклама была остановлена BlueZ')
# ============ Вспомогательное: поиск адаптера с GATT Manager ============
def find_adapter(bus):
obj = bus.get_object(BLUEZ_SERVICE_NAME, '/')
om = dbus.Interface(obj, DBUS_OM_IFACE)
objects = om.GetManagedObjects()
for path, ifaces in objects.items():
if GATT_MANAGER_IFACE in ifaces:
return path
return None
# ============ main() ============
def main():
global MAIN_LOOP
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
adapter_path = find_adapter(bus)
if not adapter_path:
log_error('Не найден адаптер с GattManager1. '
'Проверь, что bluetoothd запущен с --experimental')
return 1
log_info(f'Используем адаптер: {adapter_path}')
service_manager = dbus.Interface(
bus.get_object(BLUEZ_SERVICE_NAME, adapter_path),
GATT_MANAGER_IFACE
)
advertising_manager = dbus.Interface(
bus.get_object(BLUEZ_SERVICE_NAME, adapter_path),
LE_ADVERTISING_MANAGER_IFACE
)
app = Application(bus)
# Добавляем сервисы
bat_srv = BatteryService(bus, 0)
p2p_srv = P2PService(bus, 1)
rw_srv = RWService(bus, 2)
app.add_service(bat_srv)
app.add_service(p2p_srv)
app.add_service(rw_srv)
adv = TestAdvertisement(bus, 0)
# Регистрируем GATT апп
log_info('Регистрируем GATT Application...')
service_manager.RegisterApplication(
app.get_path(),
{},
reply_handler=lambda: log_info('✅ GATT Application зарегистрирован успешно'),
error_handler=lambda e: log_error(f'❌ Ошибка RegisterApplication: {e}'),
)
# Регистрируем рекламу
log_info('Регистрируем рекламу...')
log_info(f'Путь рекламы: {adv.get_path()}')
advertising_manager.RegisterAdvertisement(
adv.get_path(),
{},
reply_handler=lambda: log_info('✅ Advertisement зарегистрирован успешно - устройство должно быть видно при сканировании'),
error_handler=lambda e: log_error(f'❌ Ошибка RegisterAdvertisement: {e}'),
)
log_info('=' * 60)
log_info('BLE GATT сервер запущен и готов к подключениям')
log_info(f'Имя устройства: AIS')
log_info(f'Сервисы: Battery ({BAT_SERVICE_UUID}), P2P ({P2P_SERVICE_UUID}), RW ({RW_SERVICE_UUID})')
log_info('=' * 60)
MAIN_LOOP = GLib.MainLoop()
try:
MAIN_LOOP.run()
except KeyboardInterrupt:
log_info('Получен сигнал завершения (Ctrl+C)')
log_info('Останавливаем сервер...')
try:
# Отменяем рекламу
advertising_manager.UnregisterAdvertisement(adv.get_path())
log_info('✅ Advertisement отменён')
except Exception as e:
log_error(f'❌ Ошибка при отмене advertisement: {e}')
try:
# Отменяем регистрацию GATT приложения
service_manager.UnregisterApplication(app.get_path())
log_info('✅ GATT Application отменён')
except Exception as e:
log_error(f'❌ Ошибка при отмене GATT application: {e}')
log_info('Сервер остановлен')
return 0
if __name__ == '__main__':
sys.exit(main() or 0)
-352
View File
@@ -1,352 +0,0 @@
# Диаграмма классов AISMap
```mermaid
classDiagram
%% ========== MAIN ACTIVITY ==========
class MainActivity {
-AppController appController
-MapController mapController
-MapInterface mapInterface
-UIRenderingCoordinator uiCoordinator
-CompassView compassView
-CoordinatesDockWidget coordinatesWidget
-SettingsManager settingsManager
+onCreate()
+onStart()
+onStop()
+checkPermissions()
}
%% ========== CORE CONTROLLERS ==========
class AppController {
-Context context
-NMEAParser nmeaParser
-UDPListener udpListener
-AndroidNMEAListener androidNmeaListener
-GPSLocationListener gpsLocationListener
-MapInterface mapInterface
-Repository repository
-Vessel ownVessel
-Map~String,AISVessel~ activeVessels
-ExecutorService executor
+setMapInterface(MapInterface)
+startGPS()
+startUDP()
+getNearbyVessels()
+cleanup()
+Note: "Runtime State Manager"
}
class MapController {
-MapInterface mapInterface
-VesselPathController pathController
+initialize()
+updateVesselPosition()
+addAISVessel()
}
class VesselPathController {
-List~VesselPathPoint~ pathPoints
-int maxPathPoints
+addPathPoint(Vessel)
+getPath()
+clearPath()
}
%% ========== DATA MODELS ==========
class Vessel {
-double latitude
-double longitude
-double course
-double speed
-double heading
-int signalStrength
-LocalDateTime lastUpdate
-String vesselName
-String mmsi
-double altitude
-int satellites
-double pdop, hdop, vdop
+updatePosition()
+updateGPSQuality()
+getGPSQualityPercentage()
}
class AISVessel {
-String mmsi
-String vesselName
-String callSign
-int imo
-String vesselType
-double latitude, longitude
-double course, speed
-double heading, rateOfTurn
-double length, width, draft
-String destination
-LocalDateTime eta
-String navigationalStatus
+updatePosition()
+isDataStale()
+shouldBeRemoved()
}
class VesselPathPoint {
-double latitude
-double longitude
-LocalDateTime timestamp
-double course
-double speed
}
%% ========== MAP INTERFACES ==========
class MapInterface {
<<interface>>
+initialize()
+cleanup()
+addOwnVesselMarker(Vessel)
+updateOwnVesselPosition(Vessel)
+addAISVesselMarker(AISVessel)
+updateAISVesselPosition(AISVessel)
+removeAISVesselMarker(String)
+centerOnPosition(double, double)
+setMarkerClickListener(MarkerClickListener)
}
class YandexMapImpl {
-MapView mapView
-Map~String, PlacemarkMapObject~ aisMarkers
-PlacemarkMapObject ownVesselMarker
+initialize()
+addOwnVesselMarker()
+updateOwnVesselPosition()
}
class MapLibreMapImpl {
-MapView mapView
-GeoJsonSource source
-Map~String, JSONObject~ idToFeature
-Handler uiHandler
+initialize()
+refreshGeoJson()
+updateOwnVesselPosition()
}
class MapForgeImpl {
-MapView mapView
-List~Marker~ aisMarkers
-Marker ownVesselMarker
+initialize()
+addMarker()
+updateMarker()
}
%% ========== DATA PARSERS ==========
class NMEAParser {
-Vessel ownVessel
-List~AISVessel~ aisVessels
-NMEAParserListener listener
-GPSLocationListener gpsLocationListener
-boolean hybridMode
+parseNMEA(String)
+setHybridMode(boolean)
+parseGGA()
+parseRMC()
+parseAIS()
}
class UDPListener {
-int port
-DatagramSocket socket
-UDPListenerCallback callback
-boolean isListening
+startListening()
+stopListening()
+sendData()
}
class AndroidNMEAListener {
-LocationManager locationManager
-NMEAMessageCallback callback
+startListening()
+stopListening()
}
class GPSLocationListener {
-LocationManager locationManager
-LocationCallback callback
-Location lastLocation
+startLocationUpdates()
+stopLocationUpdates()
}
%% ========== UI COMPONENTS ==========
class CompassView {
-float targetAzimuth
-float currentAzimuth
-float magneticCompass
-List~AISVessel~ nearbyVessels
-Vessel ourVessel
+updateAzimuth(float)
+updateNearbyVessels()
+onDrawDock()
}
class CoordinatesDockWidget {
-Vessel vessel
-String coordinatesText
-String sogText
-String cogText
+updateVessel(Vessel)
+onDrawDock()
}
class BaseDockWidget {
<<abstract>>
-boolean isDockTop
-OnDockResizeListener resizeListener
+setDockPosition()
#onDrawDock()
#dp(int)
}
class CursorOverlay {
-TextView tvCursorLatitude
-TextView tvCursorLongitude
-TextView tvDistance
-Vessel ownVessel
-AISVessel currentAisVessel
+updateCursorPosition()
+updateAISVesselInfo()
}
%% ========== UTILITIES ==========
class SettingsManager {
-SharedPreferences prefs
+getUDPPort()
+isUDPEnabled()
+getDataMode()
+saveSettings()
}
class NavigationUtils {
<<utility>>
+calculateDistance()
+calculateBearing()
+formatCoordinates()
+formatSpeed()
}
class GeoUtils {
<<utility>>
+calculateDistance()
+calculateBearing()
+getVesselsInRadius()
+calculateCompassPosition()
+formatCoordinates()
+predictPosition()
+getNavigationStatusCode()
}
%% ========== DATA LAYER ==========
class Repository {
-AppDatabase database
-AISVesselDao aisDao
-VesselDao vesselDao
+saveVessel()
+saveAISVessel()
+getHistoricalVessels()
+cleanupOldData()
+getAISVessels()
+Note: "Persistent Data Manager"
}
class AppDatabase {
<<Room Database>>
+aisVesselDao()
+vesselDao()
}
%% ========== INTERFACES ==========
class NMEAParserListener {
<<interface>>
+onVesselUpdated(Vessel)
+onAISVesselUpdated(AISVessel)
+onParseError(String)
}
class UDPListenerCallback {
<<interface>>
+onDataReceived(String)
+onError(String)
}
class LocationCallback {
<<interface>>
+onLocationUpdated(Location)
+onLocationError(String)
}
class MarkerClickListener {
<<interface>>
+onOwnVesselClick(Vessel)
+onAISVesselClick(AISVessel)
}
%% ========== RELATIONSHIPS ==========
%% Main Activity relationships
MainActivity --> AppController : uses
MainActivity --> MapController : uses
MainActivity --> MapInterface : uses
MainActivity --> CompassView : contains
MainActivity --> CoordinatesDockWidget : contains
MainActivity --> SettingsManager : uses
%% AppController relationships
AppController --> NMEAParser : uses
AppController --> UDPListener : uses
AppController --> AndroidNMEAListener : uses
AppController --> GPSLocationListener : uses
AppController --> MapInterface : uses
AppController --> Vessel : manages
AppController --> AISVessel : manages
AppController ..|> NMEAParserListener : implements
AppController ..|> UDPListenerCallback : implements
AppController ..|> LocationCallback : implements
AppController ..|> MarkerClickListener : implements
%% Map implementations
MapInterface <|.. YandexMapImpl : implements
MapInterface <|.. MapLibreMapImpl : implements
MapInterface <|.. MapForgeImpl : implements
%% Parser relationships
NMEAParser --> NMEAParserListener : notifies
UDPListener --> UDPListenerCallback : notifies
GPSLocationListener --> LocationCallback : notifies
%% UI relationships
BaseDockWidget <|-- CompassView : extends
BaseDockWidget <|-- CoordinatesDockWidget : extends
CompassView --> Vessel : displays
CompassView --> AISVessel : displays
CoordinatesDockWidget --> Vessel : displays
%% Data relationships
MapController --> VesselPathController : uses
VesselPathController --> VesselPathPoint : manages
AppController --> Repository : uses for persistence
Repository --> AppDatabase : uses
Repository --> Vessel : stores
Repository --> AISVessel : stores
%% Utility relationships - CENTRALIZED
AppController --> GeoUtils : uses for calculations
CompassView --> GeoUtils : uses for positioning
CursorOverlay --> GeoUtils : uses for distance/bearing
MapController --> GeoUtils : uses for predictions
NavigationUtils ..> Vessel : formats
NavigationUtils ..> AISVessel : formats
GeoUtils ..> Vessel : calculates
GeoUtils ..> AISVessel : calculates
```
-937
View File
@@ -1,937 +0,0 @@
# Диаграмма классов AIS Map Application (Финальная архитектура)
```mermaid
classDiagram
%% Основные Activity и UI компоненты
class MainActivity {
-AppCoordinator appCoordinator
-MenuBinder menuBinder
-BottomSheetsBinder bottomSheetsBinder
-PermissionsBinder permissionsBinder
-MapController mapController
-CompassController compassController
-UIRenderingCoordinator uiCoordinator
-MapView mapView
-SettingsManager settingsManager
-CompassView compassView
-CoordinatesDockWidget coordinatesWidget
-BottomSheetsManager bottomSheetsManager
+onCreate()
+onResume()
+onPause()
+onDestroy()
+onCreateOptionsMenu()
+onOptionsItemSelected()
}
class AisTargetsActivity {
-AisTargetsAdapter adapter
-List~AISVessel~ aisVessels
+onCreate()
+updateAISList()
}
class SettingsActivity {
-SettingsManager settingsManager
+onCreate()
+saveSettings()
}
%% Главный координатор приложения
class AppCoordinator {
-Context context
-NMEAController nmeaController
-NetworkController networkController
-DataController dataController
-NotificationController notificationController
-CompassController compassController
-MapController mapController
-Vessel ownVessel
-List~AISVessel~ aisVessels
-Map~String, VesselPathController~ aisPathControllers
-SettingsManager settingsManager
-VesselPathController pathController
-UIDataChangeNotifier uiDataNotifier
-Handler uiHandler
-AppCoordinatorListener listener
+initializeControllers()
+startServices()
+stopServices()
+onVesselUpdated()
+onAISVesselUpdated()
+onDOPUpdated()
+onDataReceived()
+onNotificationShown()
+onCompassChanged()
+isAndroidNMEAEnabled()
+isUDPEnabled()
}
%% Фабрика контроллеров
class ControllersFactory {
<<interface>>
+createAppCoordinator() AppCoordinator
}
class DefaultControllersFactory {
+createAppCoordinator() AppCoordinator
}
%% Специализированные контроллеры
class NMEAController {
-Context context
-NMEAParser nmeaParser
-AndroidNMEAListener androidNmeaListener
-GPSLocationListener gpsLocationListener
-ExecutorService executor
-NMEAControllerListener listener
+startAndroidNMEAListener()
+stopAndroidNMEAListener()
+startGPSLocationListener()
+stopGPSLocationListener()
+parseNMEAData()
+onVesselUpdated()
+onAISVesselUpdated()
+onDOPUpdated()
}
class NetworkController {
-Context context
-UDPListener udpListener
-ExecutorService executor
-int udpPort
-boolean isUDPEnabled
-boolean isUDPNMEAEnabled
-NetworkControllerListener listener
+setUDPEnabled()
+startUDPListener()
+stopUDPListener()
+onDataReceived()
+onUDPError()
}
class DataController {
-Context context
-Repository repository
-SettingsManager settingsManager
-ExecutorService executor
-Handler dbCleanupHandler
-Runnable dbCleanupRunnable
-DataControllerListener listener
+restoreDataAsync()
+saveVesselData()
+saveAISData()
+performDatabaseCleanup()
+onDataRestored()
+onDataSaved()
+onDataCleaned()
}
class NotificationController {
-Context context
-NotificationService notificationService
-NotificationControllerListener listener
+notifyNewAISTarget()
+notifySafetyMessage()
+notifyGPSStatus()
+onNotificationShown()
+onNotificationError()
}
class CompassController {
-Context context
-CompassSensor compassSensor
-Handler uiHandler
-CompassControllerListener listener
+startCompass()
+stopCompass()
+isCompassAvailable()
+isCompassActive()
+getCompassStatus()
+onCompassChanged()
+onCompassError()
}
class MapController {
-Context context
-MapInterface currentMapInterface
-MapView mapView
-org.maplibre.android.maps.MapView mapLibreView
-List~MapInterfaceChangeListener~ listeners
+addMapInterfaceChangeListener()
+removeMapInterfaceChangeListener()
+switchToYandexMaps()
+switchToMapLibre()
+getCurrentMapInterface()
}
%% UI Binders (новая архитектура)
class MenuBinder {
-AppCoordinator appCoordinator
-SettingsManager settingsManager
-MenuActions actions
+onCreateOptionsMenu()
+onPrepareOptionsMenu()
+onOptionsItemSelected()
}
class BottomSheetsBinder {
-Context context
-BottomSheetDialog ownVesselBottomSheet
-BottomSheetDialog aisVesselBottomSheet
-View ownBottomSheetView
-View aisBottomSheetView
-AISVessel currentAISVessel
-Handler updateHandler
-Runnable updateRunnable
+init()
+initAIS()
+showOwnVesselSheet()
+showAISVesselSheet()
+startAutoUpdate()
+stopAutoUpdate()
}
class BottomSheetsManager {
-Context context
-AppCoordinator appCoordinator
-BottomSheetDialog ownVesselBottomSheet
-BottomSheetDialog aisVesselBottomSheet
-View bottomSheetView
-View aisBottomSheetView
-AISVessel currentAISVessel
-Handler timeUpdateHandler
-Handler bottomSheetUpdateHandler
+init()
+showOwnVesselSheet()
+showAISVesselSheet()
+updateOwnVesselUI()
+updateAISBottomSheetUI()
+stopAutoUpdate()
}
class PermissionsBinder {
-Activity activity
+ensurePermission()
+handleOnRequestPermissionsResult()
}
%% Базовые компоненты
class NMEAParser {
-Vessel ownVessel
-List~AISVessel~ aisVessels
-NMEAParserListener listener
-GPSLocationListener gpsLocationListener
-Map~String, Map~Integer, String~~ aisFragments
-boolean hybridMode
+parseNMEA()
+setHybridMode()
+setGPSLocationListener()
}
class UDPListener {
-int port
-DatagramSocket socket
-ExecutorService executor
-AtomicBoolean isRunning
-UDPListenerCallback callback
+start()
+stop()
+setCallback()
}
class AndroidNMEAListener {
-LocationManager locationManager
-NMEAMessageCallback callback
-boolean isListening
+startListening()
+stopListening()
+setCallback()
}
class GPSLocationListener {
-Context context
-LocationManager locationManager
-LocationCallback callback
-boolean isListening
-int satelliteCount
-int activeSatellites
-double pdop
-double hdop
-double vdop
+startListening()
+stopListening()
+setCallback()
}
class VesselPathController {
-Context context
-SettingsManager settingsManager
-SharedPreferences prefs
-String vesselId
-Handler uiHandler
-List~VesselPathPoint~ pathPoints
-VesselPathPoint lastPoint
+addPathPoint()
+getPathPoints()
+clearPath()
+savePath()
+loadPath()
}
%% Интерфейсы карт
class MapInterface {
<<interface>>
+initialize()
+cleanup()
+addOwnVesselMarker()
+updateOwnVesselPosition()
+addAISVesselMarker()
+updateAISVesselPosition()
+removeAISVesselMarker()
+clearAISVesselMarkers()
+centerOnPosition()
+setZoom()
+getZoom()
+setBearing()
+getBearing()
+addLayer()
+removeLayer()
+setMarkerClickListener()
+clearVesselPath()
+showCursor()
+hideCursor()
+updateCursorCoordinates()
+updateCursorFromMapCenter()
+setAisVesselInfo()
+clearAisVesselInfo()
}
class MapInterfaceChangeListener {
<<interface>>
+onMapInterfaceChanged()
}
class MarkerClickListener {
<<interface>>
+onOwnVesselClick()
+onAISVesselClick()
}
%% Реализации карт
class YandexMapImpl {
-Context context
-MapView mapView
-MapObjectCollection mapObjects
-MarkerClickListener markerClickListener
-YandexMarkerManager markerManager
-CursorOverlay cursorOverlay
-Vessel ownVessel
+initialize()
+cleanup()
+addOwnVesselMarker()
+updateOwnVesselPosition()
+addAISVesselMarker()
+updateAISVesselPosition()
+removeAISVesselMarker()
+clearAISVesselMarkers()
+centerOnPosition()
+setZoom()
+getZoom()
+setBearing()
+getBearing()
}
class MapLibreMapImpl {
-Context context
-MapView mapView
-MapLibreMap mapLibreMap
-MarkerClickListener markerClickListener
-CursorOverlay cursorOverlay
-Vessel ownVessel
-Map~String, AISVessel~ aisVessels
+initialize()
+cleanup()
+addOwnVesselMarker()
+updateOwnVesselPosition()
+addAISVesselMarker()
+updateAISVesselPosition()
+removeAISVesselMarker()
+clearAISVesselMarkers()
+centerOnPosition()
+setZoom()
+getZoom()
+setBearing()
+getBearing()
}
class MapForgeImpl {
-Context context
-MapView mapView
-MarkerClickListener markerClickListener
-CursorOverlay cursorOverlay
-Vessel ownVessel
+initialize()
+cleanup()
+addOwnVesselMarker()
+updateOwnVesselPosition()
+addAISVesselMarker()
+updateAISVesselPosition()
+removeAISVesselMarker()
+clearAISVesselMarkers()
+centerOnPosition()
+setZoom()
+getZoom()
+setBearing()
+getBearing()
}
%% Менеджеры маркеров
class YandexMarkerManager {
-MapObjectCollection mapObjects
-Map~String, YandexMarkerWrapper~ ownVesselMarkers
-Map~String, YandexMarkerWrapper~ aisVesselMarkers
+addOwnVesselMarker()
+updateOwnVesselMarker()
+addAISVesselMarker()
+updateAISVesselMarker()
+removeAISVesselMarker()
+clearAllMarkers()
}
class MarkerManager {
-MapLibreMap mapLibreMap
-Map~String, MarkerWrapper~ ownVesselMarkers
-Map~String, MarkerWrapper~ aisVesselMarkers
+addOwnVesselMarker()
+updateOwnVesselMarker()
+addAISVesselMarker()
+updateAISVesselMarker()
+removeAISVesselMarker()
+clearAllMarkers()
}
%% Модели данных
class Vessel {
-double latitude
-double longitude
-double course
-double speed
-double heading
-double magneticCompass
-int signalStrength
-LocalDateTime lastUpdate
-String vesselName
-String mmsi
-String callSign
-double altitude
-int satellites
-int activeSatellites
-double pdop
-double hdop
-double vdop
-float accuracy
-long fixTime
-String fixQuality
+updatePosition()
+updateGPSQuality()
+getGPSQualityPercentage()
+getGPSQualityDescription()
}
class AISVessel {
-String mmsi
-String vesselName
-String callSign
-int imo
-String vesselType
-double latitude
-double longitude
-double course
-double speed
-double heading
-double rateOfTurn
-double length
-double width
-double draft
-String destination
-LocalDateTime eta
-LocalDateTime lastUpdate
-int signalStrength
-boolean isActive
-String navigationalStatus
-String lastSafetyMessage
-boolean positionAccuracy
-String vesselClass
-String vendorId
-boolean selected
+updatePosition()
+isDataStale()
+shouldBeRemoved()
+getMinutesSinceLastUpdate()
}
class VesselPathPoint {
-double latitude
-double longitude
-double course
-double speed
-long timestamp
+VesselPathPoint()
+toJSON()
+fromJSON()
}
%% База данных и репозиторий
class AppDatabase {
<<abstract>>
+aisVesselDao() AISVesselDao
+vesselDao() VesselDao
+getInstance() AppDatabase
}
class Repository {
-AISVesselDao aisVesselDao
-VesselDao vesselDao
-ExecutorService ioExecutor
+upsertAIS()
+deleteStaleAIS()
+getAllAISSync()
+observeAllAIS()
+getAISByMmsiSync()
+upsertOwnVessel()
+getLatestOwnVesselSync()
+getLatestOwnVesselAsync()
}
class AISVesselDao {
<<interface>>
+upsert()
+deleteStale()
+getAll()
+observeAll()
+getByMmsi()
}
class VesselDao {
<<interface>>
+upsert()
+getLatest()
}
class AISVesselEntity {
-String mmsi
-String vesselName
-String callSign
-int imo
-String vesselType
-double latitude
-double longitude
-double course
-double speed
-double heading
-double rateOfTurn
-double length
-double width
-double draft
-String destination
-long etaEpochMs
-long lastUpdateEpochMs
-int signalStrength
-boolean isActive
-String navigationalStatus
-String lastSafetyMessage
-boolean positionAccuracy
-String vesselClass
-String vendorId
}
class VesselEntity {
-double latitude
-double longitude
-double course
-double speed
-double heading
-double magneticCompass
-int signalStrength
-long lastUpdateEpochMs
-String vesselName
-String mmsi
-String callSign
-double altitude
-int satellites
-int activeSatellites
-double pdop
-double hdop
-double vdop
-float accuracy
-long fixTime
-String fixQuality
}
%% UI компоненты
class UIRenderingCoordinator {
-MapInterface mapInterface
-Handler uiHandler
-Vessel pendingVesselUpdate
-Map~String, AISVessel~ pendingAISUpdates
-Set~String~ pendingAISRemovals
-Runnable vesselUpdateRunnable
-Runnable aisUpdateRunnable
-Runnable pathUpdateRunnable
-boolean vesselUpdatePending
-boolean aisUpdatePending
-boolean pathUpdatePending
+requestVesselUpdate()
+requestAISUpdate()
+requestAISRemoval()
+flushPendingOperations()
+cleanup()
}
class UIDataChangeNotifier {
<<interface>>
+onVesselPositionChanged()
+onGPSQualityChanged()
+onAISVesselChanged()
+onAISVesselRemoved()
+onVesselPathChanged()
+onRequestCenterMap()
+onCompassUpdate()
}
class CompassView {
-float azimuth
-Paint compassPaint
-Paint needlePaint
-Paint textPaint
-List~AISVessel~ nearbyVessels
+setAzimuth()
+setNearbyVessels()
+onDraw()
}
class CompassSensor {
-SensorManager sensorManager
-Sensor magnetometer
-Sensor accelerometer
-CompassListener callback
-float[] lastAccelerometer
-float[] lastMagnetometer
-boolean lastAccelerometerSet
-boolean lastMagnetometerSet
-float[] rotationMatrix
-float[] orientation
+startListening()
+stopListening()
+setCallback()
}
class CoordinatesDockWidget {
-TextView latitudeText
-TextView longitudeText
-TextView accuracyText
-TextView satellitesText
-TextView qualityText
+updateCoordinates()
+updateGPSQuality()
}
class CursorOverlay {
-ViewGroup parentView
-TextView coordinatesText
-TextView vesselInfoText
-boolean isVisible
+show()
+hide()
+updateCoordinates()
+setVesselInfo()
+clearVesselInfo()
}
%% Сервисы
class NotificationService {
-Context context
-SettingsManager settingsManager
-Vibrator vibrator
-ToneGenerator toneGenerator
-boolean isInitialized
+showSafetyAlert()
+showNewVesselNotification()
+clearNotifications()
+setVibrationEnabled()
+setSoundEnabled()
}
class AISForegroundService {
-Context context
-AppCoordinator appCoordinator
-NotificationManager notificationManager
-boolean isRunning
+startForeground()
+stopForeground()
+onStartCommand()
+onDestroy()
}
%% Утилиты
class SettingsManager {
-Context context
-SharedPreferences prefs
+getUDPPort()
+setUDPPort()
+isUDPEnabled()
+setUDPEnabled()
+isAndroidNMEAEnabled()
+setAndroidNMEAEnabled()
+isUDPNMEAEnabled()
+setUDPNMEAEnabled()
+getDataMode()
+setDataMode()
+getDataStaleWarningMinutes()
+setDataStaleWarningMinutes()
+getDataStaleRemoveMinutes()
+setDataStaleRemoveMinutes()
+isPathTrackingEnabled()
+setPathTrackingEnabled()
+getPathColor()
+setPathColor()
+getPredictionColor()
+setPredictionColor()
+getPathWidth()
+setPathWidth()
+getPredictionWidth()
+setPredictionWidth()
+getPathMaxPoints()
+setPathMaxPoints()
+getPredictionHorizonSec()
+setPredictionHorizonSec()
+isVibrationEnabled()
+setVibrationEnabled()
+isSoundEnabled()
+setSoundEnabled()
+isKeepScreenOnEnabled()
+setKeepScreenOnEnabled()
+isCursorEnabled()
+setCursorEnabled()
}
class GeoUtils {
<<utility>>
+calculateDistance()
+calculateBearing()
+isValidCoordinate()
+formatCoordinate()
+convertToDecimalDegrees()
}
class LogSender {
<<utility>>
+sendLog()
+sendError()
+sendWarning()
+sendInfo()
}
class MIDToCountry {
<<utility>>
+getCountryByMID()
+getCountryName()
+isValidMID()
}
class NavigationUtils {
<<utility>>
+calculateCourse()
+calculateSpeed()
+calculateETA()
+isCollisionRisk()
}
%% Связи между классами (финальная архитектура)
MainActivity --> AppCoordinator : uses
MainActivity --> MenuBinder : uses
MainActivity --> BottomSheetsBinder : uses
MainActivity --> PermissionsBinder : uses
MainActivity --> MapController : uses
MainActivity --> CompassController : uses
MainActivity --> UIRenderingCoordinator : uses
MainActivity --> CompassView : uses
MainActivity --> CoordinatesDockWidget : uses
MainActivity --> BottomSheetsManager : uses
%% Фабрика контроллеров
ControllersFactory <|.. DefaultControllersFactory : implements
MainActivity --> ControllersFactory : uses
DefaultControllersFactory --> AppCoordinator : creates
%% AppCoordinator координирует все контроллеры
AppCoordinator --> NMEAController : coordinates
AppCoordinator --> NetworkController : coordinates
AppCoordinator --> DataController : coordinates
AppCoordinator --> NotificationController : coordinates
AppCoordinator --> CompassController : coordinates
AppCoordinator --> MapController : coordinates
AppCoordinator --> VesselPathController : uses
AppCoordinator --> SettingsManager : uses
AppCoordinator --> UIRenderingCoordinator : uses
%% Специализированные контроллеры
NMEAController --> NMEAParser : uses
NMEAController --> AndroidNMEAListener : uses
NMEAController --> GPSLocationListener : uses
NetworkController --> UDPListener : uses
DataController --> Repository : uses
DataController --> SettingsManager : uses
NotificationController --> NotificationService : uses
CompassController --> CompassSensor : uses
%% UI Binders
MenuBinder --> AppCoordinator : uses
MenuBinder --> SettingsManager : uses
BottomSheetsBinder --> Context : uses
BottomSheetsManager --> AppCoordinator : uses
PermissionsBinder --> Activity : uses
%% Карты
MapController --> MapInterface : manages
MapController --> YandexMapImpl : creates
MapController --> MapLibreMapImpl : creates
MapController --> MapForgeImpl : creates
MapController --> MapInterfaceChangeListener : notifies
YandexMapImpl ..|> MapInterface : implements
MapLibreMapImpl ..|> MapInterface : implements
MapForgeImpl ..|> MapInterface : implements
YandexMapImpl --> YandexMarkerManager : uses
MapLibreMapImpl --> MarkerManager : uses
%% База данных
Repository --> AppDatabase : uses
Repository --> AISVesselDao : uses
Repository --> VesselDao : uses
AppDatabase --> AISVesselEntity : contains
AppDatabase --> VesselEntity : contains
%% UI координация
UIRenderingCoordinator ..|> UIDataChangeNotifier : implements
UIRenderingCoordinator ..|> MapInterfaceChangeListener : implements
%% Данные
NMEAParser --> Vessel : creates/updates
NMEAParser --> AISVessel : creates/updates
NMEAParser --> GPSLocationListener : uses
VesselPathController --> VesselPathPoint : manages
VesselPathController --> SettingsManager : uses
%% Сервисы
NotificationService --> SettingsManager : uses
%% Компас
CompassSensor --> CompassView : updates
CompassView --> AISVessel : displays
%% Интерфейсы и их реализации (финальная архитектура)
AppCoordinator ..|> NMEAControllerListener : implements
AppCoordinator ..|> NetworkControllerListener : implements
AppCoordinator ..|> DataControllerListener : implements
AppCoordinator ..|> NotificationControllerListener : implements
AppCoordinator ..|> CompassControllerListener : implements
AppCoordinator ..|> MarkerClickListener : implements
AppCoordinator ..|> MapInterfaceChangeListener : implements
NMEAController ..|> NMEAParserListener : implements
NMEAController ..|> NMEAMessageCallback : implements
NetworkController ..|> UDPListenerCallback : implements
CompassController ..|> CompassListener : implements
NMEAParser ..|> NMEAParserListener : implements
UDPListener ..|> UDPListenerCallback : implements
AndroidNMEAListener ..|> NMEAMessageCallback : implements
GPSLocationListener ..|> LocationCallback : implements
CompassSensor ..|> CompassListener : implements
```
## Описание финальной архитектуры
### 🏗️ **Основные принципы архитектуры:**
#### **1. Разделение ответственности (Single Responsibility Principle)**
- Каждый контроллер отвечает за одну конкретную область функциональности
- UI компоненты разделены на специализированные Binder'ы
- Четкое разделение между слоями данных, бизнес-логики и представления
#### **2. Координаторный паттерн (Coordinator Pattern)**
- **AppCoordinator** - центральный координатор всех контроллеров
- Управляет жизненным циклом и взаимодействием между компонентами
- Единая точка входа для всех событий и состояний
#### **3. Фабричный паттерн (Factory Pattern)**
- **ControllersFactory** - интерфейс для создания контроллеров
- **DefaultControllersFactory** - базовая реализация фабрики
- Позволяет легко тестировать и заменять компоненты
### 📦 **Специализированные контроллеры:**
1. **NMEAController** - парсинг и обработка NMEA сообщений
2. **NetworkController** - UDP слушание и сетевые операции
3. **DataController** - операции с базой данных
4. **NotificationController** - управление уведомлениями
5. **CompassController** - управление магнитным компасом
6. **MapController** - управление картами и переключение между ними
### 🎨 **UI Binders (новая архитектура):**
1. **MenuBinder** - управление меню и обработка действий
2. **BottomSheetsBinder** - базовое управление BottomSheet'ами
3. **BottomSheetsManager** - полное управление BottomSheet'ами с автообновлением
4. **PermissionsBinder** - управление разрешениями
### 🗺️ **Система карт:**
- **MapInterface** - единый интерфейс для всех карт
- **YandexMapImpl** - реализация для Яндекс.Карт
- **MapLibreMapImpl** - реализация для MapLibre GL
- **MapForgeImpl** - реализация для MapForge
- **Strategy Pattern** для переключения между картами
### 💾 **Слой данных:**
- **Repository Pattern** для работы с данными
- **Room Database** для персистентности
- **Entity/Model** разделение для чистоты архитектуры
- **Mapper** для преобразования между слоями
### 🔄 **Паттерны взаимодействия:**
- **Observer Pattern** - для уведомлений об изменениях
- **Command Pattern** - для отложенного выполнения UI операций
- **Throttling** - для оптимизации производительности
- **Auto-update** - для автоматического обновления UI
### ✅ **Преимущества финальной архитектуры:**
1. **Модульность** - каждый компонент можно тестировать и развивать независимо
2. **Расширяемость** - легко добавлять новые контроллеры и UI компоненты
3. **Поддерживаемость** - четкое разделение ответственности упрощает поддержку
4. **Тестируемость** - каждый компонент можно тестировать изолированно
5. **Производительность** - оптимизированные обновления UI и управление ресурсами
6. **Гибкость** - возможность переключения между различными реализациями карт
### 🎯 **Особенности реализации:**
- Гибридный режим GPS (Location API + NMEA)
- Throttling UI обновлений для производительности
- Автоматическое управление жизненным циклом компонентов
- Централизованная обработка ошибок и логирования
- Поддержка множественных источников данных
- Система уведомлений с вибрацией и звуком
-803
View File
@@ -1,803 +0,0 @@
# Диаграмма классов AIS Map Application (Обновленная архитектура)
```mermaid
classDiagram
%% Основные Activity и UI компоненты
class MainActivity {
-AppCoordinator appCoordinator
-MapController mapController
-CompassController compassController
-UIRenderingCoordinator uiCoordinator
-MapView mapView
-SettingsManager settingsManager
-CompassView compassView
-CoordinatesDockWidget coordinatesWidget
+onCreate()
+onResume()
+onPause()
+onDestroy()
}
class AisTargetsActivity {
-AisTargetsAdapter adapter
-List~AISVessel~ aisVessels
+onCreate()
+updateAISList()
}
class SettingsActivity {
-SettingsManager settingsManager
+onCreate()
+saveSettings()
}
%% Главный координатор приложения (новая архитектура)
class AppCoordinator {
-Context context
-NMEAController nmeaController
-NetworkController networkController
-DataController dataController
-NotificationController notificationController
-CompassController compassController
-MapController mapController
-Vessel ownVessel
-List~AISVessel~ aisVessels
-Map~String, VesselPathController~ aisPathControllers
-SettingsManager settingsManager
-VesselPathController pathController
-UIDataChangeNotifier uiDataNotifier
-Handler uiHandler
-AppCoordinatorListener listener
+initializeControllers()
+startServices()
+stopServices()
+onVesselUpdated()
+onAISVesselUpdated()
+onDOPUpdated()
+onDataReceived()
+onNotificationShown()
+onCompassChanged()
}
%% Специализированные контроллеры (новая архитектура)
class NMEAController {
-Context context
-NMEAParser nmeaParser
-AndroidNMEAListener androidNmeaListener
-GPSLocationListener gpsLocationListener
-ExecutorService executor
-NMEAControllerListener listener
+startAndroidNMEAListener()
+stopAndroidNMEAListener()
+startGPSLocationListener()
+stopGPSLocationListener()
+parseNMEAData()
+onVesselUpdated()
+onAISVesselUpdated()
+onDOPUpdated()
}
class NetworkController {
-Context context
-UDPListener udpListener
-ExecutorService executor
-int udpPort
-boolean isUDPEnabled
-boolean isUDPNMEAEnabled
-NetworkControllerListener listener
+setUDPEnabled()
+startUDPListener()
+stopUDPListener()
+onDataReceived()
+onUDPError()
}
class DataController {
-Context context
-Repository repository
-SettingsManager settingsManager
-ExecutorService executor
-Handler dbCleanupHandler
-Runnable dbCleanupRunnable
-DataControllerListener listener
+restoreDataAsync()
+saveVesselData()
+saveAISData()
+performDatabaseCleanup()
+onDataRestored()
+onDataSaved()
+onDataCleaned()
}
class NotificationController {
-Context context
-NotificationService notificationService
-NotificationControllerListener listener
+notifyNewAISTarget()
+notifySafetyMessage()
+notifyGPSStatus()
+onNotificationShown()
+onNotificationError()
}
class CompassController {
-Context context
-CompassSensor compassSensor
-Handler uiHandler
-CompassControllerListener listener
+startCompass()
+stopCompass()
+isCompassAvailable()
+isCompassActive()
+getCompassStatus()
+onCompassChanged()
+onCompassError()
}
%% Контроллеры
class MapController {
-Context context
-MapInterface currentMapInterface
-MapView mapView
-org.maplibre.android.maps.MapView mapLibreView
-List~MapInterfaceChangeListener~ listeners
+addMapInterfaceChangeListener()
+removeMapInterfaceChangeListener()
+switchToYandexMaps()
+switchToMapLibre()
+getCurrentMapInterface()
}
class NMEAParser {
-Vessel ownVessel
-List~AISVessel~ aisVessels
-NMEAParserListener listener
-GPSLocationListener gpsLocationListener
-Map~String, Map~Integer, String~~ aisFragments
-boolean hybridMode
+parseNMEA()
+setHybridMode()
+setGPSLocationListener()
}
class UDPListener {
-int port
-DatagramSocket socket
-ExecutorService executor
-AtomicBoolean isRunning
-UDPListenerCallback callback
+start()
+stop()
+setCallback()
}
class AndroidNMEAListener {
-LocationManager locationManager
-NMEAMessageCallback callback
-boolean isListening
+startListening()
+stopListening()
+setCallback()
}
class GPSLocationListener {
-Context context
-LocationManager locationManager
-LocationCallback callback
-boolean isListening
-int satelliteCount
-int activeSatellites
-double pdop
-double hdop
-double vdop
+startListening()
+stopListening()
+setCallback()
}
class VesselPathController {
-Context context
-SettingsManager settingsManager
-SharedPreferences prefs
-String vesselId
-Handler uiHandler
-List~VesselPathPoint~ pathPoints
-VesselPathPoint lastPoint
+addPathPoint()
+getPathPoints()
+clearPath()
+savePath()
+loadPath()
}
%% Интерфейсы карт
class MapInterface {
<<interface>>
+initialize()
+cleanup()
+addOwnVesselMarker()
+updateOwnVesselPosition()
+addAISVesselMarker()
+updateAISVesselPosition()
+removeAISVesselMarker()
+clearAISVesselMarkers()
+centerOnPosition()
+setZoom()
+getZoom()
+setBearing()
+getBearing()
+addLayer()
+removeLayer()
+setMarkerClickListener()
+clearVesselPath()
+showCursor()
+hideCursor()
+updateCursorCoordinates()
+updateCursorFromMapCenter()
+setAisVesselInfo()
+clearAisVesselInfo()
}
class MapInterfaceChangeListener {
<<interface>>
+onMapInterfaceChanged()
}
class MarkerClickListener {
<<interface>>
+onOwnVesselClick()
+onAISVesselClick()
}
%% Реализации карт
class YandexMapImpl {
-Context context
-MapView mapView
-MapObjectCollection mapObjects
-MarkerClickListener markerClickListener
-YandexMarkerManager markerManager
-CursorOverlay cursorOverlay
-Vessel ownVessel
+initialize()
+cleanup()
+addOwnVesselMarker()
+updateOwnVesselPosition()
+addAISVesselMarker()
+updateAISVesselPosition()
+removeAISVesselMarker()
+clearAISVesselMarkers()
+centerOnPosition()
+setZoom()
+getZoom()
+setBearing()
+getBearing()
}
class MapLibreMapImpl {
-Context context
-MapView mapView
-MapLibreMap mapLibreMap
-MarkerClickListener markerClickListener
-CursorOverlay cursorOverlay
-Vessel ownVessel
-Map~String, AISVessel~ aisVessels
+initialize()
+cleanup()
+addOwnVesselMarker()
+updateOwnVesselPosition()
+addAISVesselMarker()
+updateAISVesselPosition()
+removeAISVesselMarker()
+clearAISVesselMarkers()
+centerOnPosition()
+setZoom()
+getZoom()
+setBearing()
+getBearing()
}
%% Менеджеры маркеров
class YandexMarkerManager {
-MapObjectCollection mapObjects
-Map~String, YandexMarkerWrapper~ ownVesselMarkers
-Map~String, YandexMarkerWrapper~ aisVesselMarkers
+addOwnVesselMarker()
+updateOwnVesselMarker()
+addAISVesselMarker()
+updateAISVesselMarker()
+removeAISVesselMarker()
+clearAllMarkers()
}
class MarkerManager {
-MapLibreMap mapLibreMap
-Map~String, MarkerWrapper~ ownVesselMarkers
-Map~String, MarkerWrapper~ aisVesselMarkers
+addOwnVesselMarker()
+updateOwnVesselMarker()
+addAISVesselMarker()
+updateAISVesselMarker()
+removeAISVesselMarker()
+clearAllMarkers()
}
%% Модели данных
class Vessel {
-double latitude
-double longitude
-double course
-double speed
-double heading
-double magneticCompass
-int signalStrength
-LocalDateTime lastUpdate
-String vesselName
-String mmsi
-String callSign
-double altitude
-int satellites
-int activeSatellites
-double pdop
-double hdop
-double vdop
-float accuracy
-long fixTime
-String fixQuality
+updatePosition()
+updateGPSQuality()
+getGPSQualityPercentage()
+getGPSQualityDescription()
}
class AISVessel {
-String mmsi
-String vesselName
-String callSign
-int imo
-String vesselType
-double latitude
-double longitude
-double course
-double speed
-double heading
-double rateOfTurn
-double length
-double width
-double draft
-String destination
-LocalDateTime eta
-LocalDateTime lastUpdate
-int signalStrength
-boolean isActive
-String navigationalStatus
-String lastSafetyMessage
-boolean positionAccuracy
-String vesselClass
-String vendorId
-boolean selected
+updatePosition()
+isDataStale()
+shouldBeRemoved()
+getMinutesSinceLastUpdate()
}
class VesselPathPoint {
-double latitude
-double longitude
-double course
-double speed
-long timestamp
+VesselPathPoint()
+toJSON()
+fromJSON()
}
%% База данных и репозиторий
class AppDatabase {
<<abstract>>
+aisVesselDao() AISVesselDao
+vesselDao() VesselDao
+getInstance() AppDatabase
}
class Repository {
-AISVesselDao aisVesselDao
-VesselDao vesselDao
-ExecutorService ioExecutor
+upsertAIS()
+deleteStaleAIS()
+getAllAISSync()
+observeAllAIS()
+getAISByMmsiSync()
+upsertOwnVessel()
+getLatestOwnVesselSync()
+getLatestOwnVesselAsync()
}
class AISVesselDao {
<<interface>>
+upsert()
+deleteStale()
+getAll()
+observeAll()
+getByMmsi()
}
class VesselDao {
<<interface>>
+upsert()
+getLatest()
}
class AISVesselEntity {
-String mmsi
-String vesselName
-String callSign
-int imo
-String vesselType
-double latitude
-double longitude
-double course
-double speed
-double heading
-double rateOfTurn
-double length
-double width
-double draft
-String destination
-long etaEpochMs
-long lastUpdateEpochMs
-int signalStrength
-boolean isActive
-String navigationalStatus
-String lastSafetyMessage
-boolean positionAccuracy
-String vesselClass
-String vendorId
}
class VesselEntity {
-double latitude
-double longitude
-double course
-double speed
-double heading
-double magneticCompass
-int signalStrength
-long lastUpdateEpochMs
-String vesselName
-String mmsi
-String callSign
-double altitude
-int satellites
-int activeSatellites
-double pdop
-double hdop
-double vdop
-float accuracy
-long fixTime
-String fixQuality
}
%% UI компоненты
class UIRenderingCoordinator {
-MapInterface mapInterface
-Handler uiHandler
-Vessel pendingVesselUpdate
-Map~String, AISVessel~ pendingAISUpdates
-Set~String~ pendingAISRemovals
-Runnable vesselUpdateRunnable
-Runnable aisUpdateRunnable
-Runnable pathUpdateRunnable
-boolean vesselUpdatePending
-boolean aisUpdatePending
-boolean pathUpdatePending
+requestVesselUpdate()
+requestAISUpdate()
+requestAISRemoval()
+flushPendingOperations()
+cleanup()
}
class UIDataChangeNotifier {
<<interface>>
+onVesselPositionChanged()
+onGPSQualityChanged()
+onAISVesselChanged()
+onAISVesselRemoved()
+onVesselPathChanged()
+onRequestCenterMap()
+onCompassUpdate()
}
class CompassView {
-float azimuth
-Paint compassPaint
-Paint needlePaint
-Paint textPaint
-List~AISVessel~ nearbyVessels
+setAzimuth()
+setNearbyVessels()
+onDraw()
}
class CompassSensor {
-SensorManager sensorManager
-Sensor magnetometer
-Sensor accelerometer
-CompassCallback callback
-float[] lastAccelerometer
-float[] lastMagnetometer
-boolean lastAccelerometerSet
-boolean lastMagnetometerSet
-float[] rotationMatrix
-float[] orientation
+startListening()
+stopListening()
+setCallback()
}
class CoordinatesDockWidget {
-TextView latitudeText
-TextView longitudeText
-TextView accuracyText
-TextView satellitesText
-TextView qualityText
+updateCoordinates()
+updateGPSQuality()
}
class CursorOverlay {
-ViewGroup parentView
-TextView coordinatesText
-TextView vesselInfoText
-boolean isVisible
+show()
+hide()
+updateCoordinates()
+setVesselInfo()
+clearVesselInfo()
}
%% Сервисы
class NotificationService {
-Context context
-SettingsManager settingsManager
-Vibrator vibrator
-ToneGenerator toneGenerator
-boolean isInitialized
+showSafetyAlert()
+showNewVesselNotification()
+clearNotifications()
+setVibrationEnabled()
+setSoundEnabled()
}
class AISForegroundService {
-Context context
-AppController appController
-NotificationManager notificationManager
-boolean isRunning
+startForeground()
+stopForeground()
+onStartCommand()
+onDestroy()
}
%% Утилиты
class SettingsManager {
-Context context
-SharedPreferences prefs
+getUDPPort()
+setUDPPort()
+isUDPEnabled()
+setUDPEnabled()
+isAndroidNMEAEnabled()
+setAndroidNMEAEnabled()
+isUDPNMEAEnabled()
+setUDPNMEAEnabled()
+getDataMode()
+setDataMode()
+getDataStaleWarningMinutes()
+setDataStaleWarningMinutes()
+getDataStaleRemoveMinutes()
+setDataStaleRemoveMinutes()
+isPathTrackingEnabled()
+setPathTrackingEnabled()
+getPathColor()
+setPathColor()
+getPredictionColor()
+setPredictionColor()
+getPathWidth()
+setPathWidth()
+getPredictionWidth()
+setPredictionWidth()
+getPathMaxPoints()
+setPathMaxPoints()
+getPredictionHorizonSec()
+setPredictionHorizonSec()
+isVibrationEnabled()
+setVibrationEnabled()
+isSoundEnabled()
+setSoundEnabled()
+isKeepScreenOnEnabled()
+setKeepScreenOnEnabled()
+isCursorEnabled()
+setCursorEnabled()
}
class GeoUtils {
<<utility>>
+calculateDistance()
+calculateBearing()
+isValidCoordinate()
+formatCoordinate()
+convertToDecimalDegrees()
}
class LogSender {
<<utility>>
+sendLog()
+sendError()
+sendWarning()
+sendInfo()
}
class MIDToCountry {
<<utility>>
+getCountryByMID()
+getCountryName()
+isValidMID()
}
class NavigationUtils {
<<utility>>
+calculateCourse()
+calculateSpeed()
+calculateETA()
+isCollisionRisk()
}
%% Связи между классами (новая архитектура)
MainActivity --> AppCoordinator : uses
MainActivity --> MapController : uses
MainActivity --> CompassController : uses
MainActivity --> UIRenderingCoordinator : uses
MainActivity --> CompassView : uses
MainActivity --> CoordinatesDockWidget : uses
AppCoordinator --> NMEAController : coordinates
AppCoordinator --> NetworkController : coordinates
AppCoordinator --> DataController : coordinates
AppCoordinator --> NotificationController : coordinates
AppCoordinator --> CompassController : coordinates
AppCoordinator --> MapController : coordinates
AppCoordinator --> VesselPathController : uses
AppCoordinator --> SettingsManager : uses
AppCoordinator --> UIRenderingCoordinator : uses
NMEAController --> NMEAParser : uses
NMEAController --> AndroidNMEAListener : uses
NMEAController --> GPSLocationListener : uses
NetworkController --> UDPListener : uses
DataController --> Repository : uses
DataController --> SettingsManager : uses
NotificationController --> NotificationService : uses
CompassController --> CompassSensor : uses
MapController --> MapInterface : manages
MapController --> YandexMapImpl : creates
MapController --> MapLibreMapImpl : creates
MapController --> MapInterfaceChangeListener : notifies
YandexMapImpl ..|> MapInterface : implements
MapLibreMapImpl ..|> MapInterface : implements
AppController ..|> MapInterfaceChangeListener : implements
AppController ..|> MarkerClickListener : implements
YandexMapImpl --> YandexMarkerManager : uses
MapLibreMapImpl --> MarkerManager : uses
Repository --> AppDatabase : uses
Repository --> AISVesselDao : uses
Repository --> VesselDao : uses
AppDatabase --> AISVesselEntity : contains
AppDatabase --> VesselEntity : contains
UIRenderingCoordinator ..|> UIDataChangeNotifier : implements
UIRenderingCoordinator ..|> MapInterfaceChangeListener : implements
NMEAParser --> Vessel : creates/updates
NMEAParser --> AISVessel : creates/updates
NMEAParser --> GPSLocationListener : uses
VesselPathController --> VesselPathPoint : manages
VesselPathController --> SettingsManager : uses
NotificationService --> SettingsManager : uses
CompassSensor --> CompassView : updates
CompassView --> AISVessel : displays
%% Интерфейсы и их реализации (новая архитектура)
AppCoordinator ..|> NMEAControllerListener : implements
AppCoordinator ..|> NetworkControllerListener : implements
AppCoordinator ..|> DataControllerListener : implements
AppCoordinator ..|> NotificationControllerListener : implements
AppCoordinator ..|> CompassControllerListener : implements
AppCoordinator ..|> MarkerClickListener : implements
AppCoordinator ..|> MapInterfaceChangeListener : implements
NMEAController ..|> NMEAParserListener : implements
NMEAController ..|> NMEAMessageCallback : implements
NetworkController ..|> UDPListenerCallback : implements
CompassController ..|> CompassListener : implements
NMEAParser ..|> NMEAParserListener : implements
UDPListener ..|> UDPListenerCallback : implements
AndroidNMEAListener ..|> NMEAMessageCallback : implements
GPSLocationListener ..|> LocationCallback : implements
CompassSensor ..|> CompassListener : implements
```
## Описание обновленной архитектуры
### Основные компоненты:
1. **MainActivity** - главная активность приложения, координирует UI компоненты
2. **AppCoordinator** - главный координатор, управляет всеми специализированными контроллерами
3. **NMEAController** - специализированный контроллер для обработки NMEA сообщений
4. **NetworkController** - контроллер для сетевых операций (UDP)
5. **DataController** - контроллер для операций с базой данных
6. **NotificationController** - контроллер для управления уведомлениями
7. **CompassController** - контроллер для управления магнитным компасом
8. **MapController** - управляет переключением между различными реализациями карт
9. **UIRenderingCoordinator** - координирует обновления UI с throttling
10. **VesselPathController** - управляет путями судов
### Новая архитектура - Разделение ответственности:
#### **AppCoordinator** (Главный координатор):
- Координирует работу всех специализированных контроллеров
- Управляет общим состоянием приложения
- Обрабатывает события от контроллеров и передает их в UI
#### **Специализированные контроллеры**:
- **NMEAController** - только парсинг и обработка NMEA данных
- **NetworkController** - только сетевые операции (UDP слушание)
- **DataController** - только операции с базой данных
- **NotificationController** - только показ уведомлений
- **CompassController** - только работа с магнитным компасом
### Паттерны архитектуры:
- **Coordinator Pattern** - AppCoordinator координирует работу контроллеров
- **Single Responsibility Principle** - каждый контроллер отвечает за одну область
- **Strategy Pattern** - для переключения между различными реализациями карт
- **Observer Pattern** - для уведомлений об изменениях данных
- **Repository Pattern** - для работы с данными
- **Command Pattern** - для отложенного выполнения UI операций
### Преимущества новой архитектуры:
1. **Разделение ответственности** - каждый контроллер отвечает за свою область
2. **Лучшая тестируемость** - контроллеры можно тестировать независимо
3. **Упрощенная поддержка** - изменения в одной области не влияют на другие
4. **Масштабируемость** - легко добавлять новые контроллеры
5. **Чистая архитектура** - четкое разделение между слоями
### Особенности:
- Гибридный режим получения GPS данных (через Location API + NMEA)
- Throttling UI обновлений для производительности
- Поддержка множественных источников данных (UDP, Android NMEA, GPS)
- Модульная архитектура с возможностью переключения карт
- Система уведомлений с вибрацией и звуком
- Централизованная координация через AppCoordinator
-890
View File
@@ -1,890 +0,0 @@
# Диаграмма классов AIS Map Application (PlantUML)
```plantuml
@startuml AIS_Map_Architecture
!define RECTANGLE class
skinparam classAttributeIconSize 0
skinparam classFontSize 9
skinparam packageFontSize 11
skinparam backgroundColor white
skinparam classBackgroundColor white
skinparam packageBackgroundColor lightblue
skinparam packageBorderColor black
skinparam classBorderColor black
skinparam interfaceBackgroundColor lightgreen
skinparam interfaceBorderColor black
package "Main Activity" {
class MainActivity {
- AppCoordinator appCoordinator
- MenuBinder menuBinder
- BottomSheetsBinder bottomSheetsBinder
- PermissionsBinder permissionsBinder
- MapController mapController
- CompassController compassController
- UIRenderingCoordinator uiCoordinator
- MapView mapView
- SettingsManager settingsManager
- CompassView compassView
- CoordinatesDockWidget coordinatesWidget
- BottomSheetsManager bottomSheetsManager
+ onCreate()
+ onResume()
+ onPause()
+ onDestroy()
}
class AisTargetsActivity {
- AisTargetsAdapter adapter
- List<AISVessel> aisVessels
+ onCreate()
+ updateAISList()
}
class SettingsActivity {
- SettingsManager settingsManager
+ onCreate()
+ saveSettings()
}
}
package "Controllers Factory" {
interface ControllersFactory {
+ createAppCoordinator() : AppCoordinator
}
class DefaultControllersFactory {
+ createAppCoordinator() : AppCoordinator
}
}
package "Core Controllers" {
class AppCoordinator {
- Context context
- NMEAController nmeaController
- NetworkController networkController
- DataController dataController
- NotificationController notificationController
- CompassController compassController
- MapController mapController
- Vessel ownVessel
- List<AISVessel> aisVessels
- Map<String, VesselPathController> aisPathControllers
- SettingsManager settingsManager
- VesselPathController pathController
- UIDataChangeNotifier uiDataNotifier
- Handler uiHandler
- AppCoordinatorListener listener
+ initializeControllers()
+ startServices()
+ stopServices()
+ onVesselUpdated()
+ onAISVesselUpdated()
+ onDOPUpdated()
+ onDataReceived()
+ onNotificationShown()
+ onCompassChanged()
+ isAndroidNMEAEnabled() : boolean
+ isUDPEnabled() : boolean
}
class NMEAController {
- Context context
- NMEAParser nmeaParser
- AndroidNMEAListener androidNmeaListener
- GPSLocationListener gpsLocationListener
- ExecutorService executor
- NMEAControllerListener listener
+ startAndroidNMEAListener()
+ stopAndroidNMEAListener()
+ startGPSLocationListener()
+ stopGPSLocationListener()
+ parseNMEAData()
+ onVesselUpdated()
+ onAISVesselUpdated()
+ onDOPUpdated()
}
class NetworkController {
- Context context
- UDPListener udpListener
- ExecutorService executor
- int udpPort
- boolean isUDPEnabled
- boolean isUDPNMEAEnabled
- NetworkControllerListener listener
+ setUDPEnabled()
+ startUDPListener()
+ stopUDPListener()
+ onDataReceived()
+ onUDPError()
}
class DataController {
- Context context
- Repository repository
- SettingsManager settingsManager
- ExecutorService executor
- Handler dbCleanupHandler
- Runnable dbCleanupRunnable
- DataControllerListener listener
+ restoreDataAsync()
+ saveVesselData()
+ saveAISData()
+ performDatabaseCleanup()
+ onDataRestored()
+ onDataSaved()
+ onDataCleaned()
}
class NotificationController {
- Context context
- NotificationService notificationService
- NotificationControllerListener listener
+ notifyNewAISTarget()
+ notifySafetyMessage()
+ notifyGPSStatus()
+ onNotificationShown()
+ onNotificationError()
}
class CompassController {
- Context context
- CompassSensor compassSensor
- Handler uiHandler
- CompassControllerListener listener
+ startCompass()
+ stopCompass()
+ isCompassAvailable() : boolean
+ isCompassActive() : boolean
+ getCompassStatus() : String
+ onCompassChanged()
+ onCompassError()
}
class MapController {
- Context context
- MapInterface currentMapInterface
- MapView mapView
- MapLibreMapView mapLibreView
- List<MapInterfaceChangeListener> listeners
+ addMapInterfaceChangeListener()
+ removeMapInterfaceChangeListener()
+ switchToYandexMaps()
+ switchToMapLibre()
+ getCurrentMapInterface() : MapInterface
}
}
package "UI Binders" {
class MenuBinder {
- AppCoordinator appCoordinator
- SettingsManager settingsManager
- MenuActions actions
+ onCreateOptionsMenu()
+ onPrepareOptionsMenu()
+ onOptionsItemSelected()
}
class BottomSheetsBinder {
- Context context
- BottomSheetDialog ownVesselBottomSheet
- BottomSheetDialog aisVesselBottomSheet
- View ownBottomSheetView
- View aisBottomSheetView
- AISVessel currentAISVessel
- Handler updateHandler
- Runnable updateRunnable
+ init()
+ initAIS()
+ showOwnVesselSheet()
+ showAISVesselSheet()
+ startAutoUpdate()
+ stopAutoUpdate()
}
class BottomSheetsManager {
- Context context
- AppCoordinator appCoordinator
- BottomSheetDialog ownVesselBottomSheet
- BottomSheetDialog aisVesselBottomSheet
- View bottomSheetView
- View aisBottomSheetView
- AISVessel currentAISVessel
- Handler timeUpdateHandler
- Handler bottomSheetUpdateHandler
+ init()
+ showOwnVesselSheet()
+ showAISVesselSheet()
+ updateOwnVesselUI()
+ updateAISBottomSheetUI()
+ stopAutoUpdate()
}
class PermissionsBinder {
- Activity activity
+ ensurePermission() : boolean
+ handleOnRequestPermissionsResult() : boolean
}
}
package "Data Processing" {
class NMEAParser {
- Vessel ownVessel
- List<AISVessel> aisVessels
- NMEAParserListener listener
- GPSLocationListener gpsLocationListener
- Map<String, Map<Integer, String>> aisFragments
- boolean hybridMode
+ parseNMEA()
+ setHybridMode()
+ setGPSLocationListener()
}
class UDPListener {
- int port
- DatagramSocket socket
- ExecutorService executor
- AtomicBoolean isRunning
- UDPListenerCallback callback
+ start()
+ stop()
+ setCallback()
}
class AndroidNMEAListener {
- LocationManager locationManager
- NMEAMessageCallback callback
- boolean isListening
+ startListening() : boolean
+ stopListening()
+ setCallback()
}
class GPSLocationListener {
- Context context
- LocationManager locationManager
- LocationCallback callback
- boolean isListening
- int satelliteCount
- int activeSatellites
- double pdop
- double hdop
- double vdop
+ startListening() : boolean
+ stopListening()
+ setCallback()
}
class VesselPathController {
- Context context
- SettingsManager settingsManager
- SharedPreferences prefs
- String vesselId
- Handler uiHandler
- List<VesselPathPoint> pathPoints
- VesselPathPoint lastPoint
+ addPathPoint()
+ getPathPoints() : List<VesselPathPoint>
+ clearPath()
+ savePath()
+ loadPath()
}
}
package "Maps" {
interface MapInterface {
+ initialize()
+ cleanup()
+ addOwnVesselMarker()
+ updateOwnVesselPosition()
+ addAISVesselMarker()
+ updateAISVesselPosition()
+ removeAISVesselMarker()
+ clearAISVesselMarkers()
+ centerOnPosition()
+ setZoom()
+ getZoom() : float
+ setBearing()
+ getBearing() : float
+ addLayer()
+ removeLayer()
+ setMarkerClickListener()
+ clearVesselPath()
+ showCursor()
+ hideCursor()
+ updateCursorCoordinates()
+ updateCursorFromMapCenter()
+ setAisVesselInfo()
+ clearAisVesselInfo()
}
class YandexMapImpl {
- Context context
- MapView mapView
- MapObjectCollection mapObjects
- MarkerClickListener markerClickListener
- YandexMarkerManager markerManager
- CursorOverlay cursorOverlay
- Vessel ownVessel
+ initialize()
+ cleanup()
+ addOwnVesselMarker()
+ updateOwnVesselPosition()
+ addAISVesselMarker()
+ updateAISVesselPosition()
+ removeAISVesselMarker()
+ clearAISVesselMarkers()
+ centerOnPosition()
+ setZoom()
+ getZoom() : float
+ setBearing()
+ getBearing() : float
}
class MapLibreMapImpl {
- Context context
- MapView mapView
- MapLibreMap mapLibreMap
- MarkerClickListener markerClickListener
- CursorOverlay cursorOverlay
- Vessel ownVessel
- Map<String, AISVessel> aisVessels
+ initialize()
+ cleanup()
+ addOwnVesselMarker()
+ updateOwnVesselPosition()
+ addAISVesselMarker()
+ updateAISVesselPosition()
+ removeAISVesselMarker()
+ clearAISVesselMarkers()
+ centerOnPosition()
+ setZoom()
+ getZoom() : float
+ setBearing()
+ getBearing() : float
}
class MapForgeImpl {
- Context context
- MapView mapView
- MarkerClickListener markerClickListener
- CursorOverlay cursorOverlay
- Vessel ownVessel
+ initialize()
+ cleanup()
+ addOwnVesselMarker()
+ updateOwnVesselPosition()
+ addAISVesselMarker()
+ updateAISVesselPosition()
+ removeAISVesselMarker()
+ clearAISVesselMarkers()
+ centerOnPosition()
+ setZoom()
+ getZoom() : float
+ setBearing()
+ getBearing() : float
}
class YandexMarkerManager {
- MapObjectCollection mapObjects
- Map<String, YandexMarkerWrapper> ownVesselMarkers
- Map<String, YandexMarkerWrapper> aisVesselMarkers
+ addOwnVesselMarker()
+ updateOwnVesselMarker()
+ addAISVesselMarker()
+ updateAISVesselMarker()
+ removeAISVesselMarker()
+ clearAllMarkers()
}
class MarkerManager {
- MapLibreMap mapLibreMap
- Map<String, MarkerWrapper> ownVesselMarkers
- Map<String, MarkerWrapper> aisVesselMarkers
+ addOwnVesselMarker()
+ updateOwnVesselMarker()
+ addAISVesselMarker()
+ updateAISVesselMarker()
+ removeAISVesselMarker()
+ clearAllMarkers()
}
}
package "Data Models" {
class Vessel {
- double latitude
- double longitude
- double course
- double speed
- double heading
- double magneticCompass
- int signalStrength
- LocalDateTime lastUpdate
- String vesselName
- String mmsi
- String callSign
- double altitude
- int satellites
- int activeSatellites
- double pdop
- double hdop
- double vdop
- float accuracy
- long fixTime
- String fixQuality
+ updatePosition()
+ updateGPSQuality()
+ getGPSQualityPercentage() : int
+ getGPSQualityDescription() : String
}
class AISVessel {
- String mmsi
- String vesselName
- String callSign
- int imo
- String vesselType
- double latitude
- double longitude
- double course
- double speed
- double heading
- double rateOfTurn
- double length
- double width
- double draft
- String destination
- LocalDateTime eta
- LocalDateTime lastUpdate
- int signalStrength
- boolean isActive
- String navigationalStatus
- String lastSafetyMessage
- boolean positionAccuracy
- String vesselClass
- String vendorId
- boolean selected
+ updatePosition()
+ isDataStale() : boolean
+ shouldBeRemoved() : boolean
+ getMinutesSinceLastUpdate() : long
}
class VesselPathPoint {
- double latitude
- double longitude
- double course
- double speed
- long timestamp
+ VesselPathPoint()
+ toJSON() : String
+ fromJSON()
}
}
package "Database" {
abstract class AppDatabase {
+ aisVesselDao() : AISVesselDao
+ vesselDao() : VesselDao
+ getInstance() : AppDatabase
}
class Repository {
- AISVesselDao aisVesselDao
- VesselDao vesselDao
- ExecutorService ioExecutor
+ upsertAIS()
+ deleteStaleAIS()
+ getAllAISSync() : List<AISVesselEntity>
+ observeAllAIS() : LiveData<List<AISVesselEntity>>
+ getAISByMmsiSync() : AISVesselEntity
+ upsertOwnVessel()
+ getLatestOwnVesselSync() : VesselEntity
+ getLatestOwnVesselAsync()
}
interface AISVesselDao {
+ upsert()
+ deleteStale()
+ getAll() : List<AISVesselEntity>
+ observeAll() : LiveData<List<AISVesselEntity>>
+ getByMmsi() : AISVesselEntity
}
interface VesselDao {
+ upsert()
+ getLatest() : VesselEntity
}
class AISVesselEntity {
- String mmsi
- String vesselName
- String callSign
- int imo
- String vesselType
- double latitude
- double longitude
- double course
- double speed
- double heading
- double rateOfTurn
- double length
- double width
- double draft
- String destination
- long etaEpochMs
- long lastUpdateEpochMs
- int signalStrength
- boolean isActive
- String navigationalStatus
- String lastSafetyMessage
- boolean positionAccuracy
- String vesselClass
- String vendorId
}
class VesselEntity {
- double latitude
- double longitude
- double course
- double speed
- double heading
- double magneticCompass
- int signalStrength
- long lastUpdateEpochMs
- String vesselName
- String mmsi
- String callSign
- double altitude
- int satellites
- int activeSatellites
- double pdop
- double hdop
- double vdop
- float accuracy
- long fixTime
- String fixQuality
}
}
package "UI Components" {
class UIRenderingCoordinator {
- MapInterface mapInterface
- Handler uiHandler
- Vessel pendingVesselUpdate
- Map<String, AISVessel> pendingAISUpdates
- Set<String> pendingAISRemovals
- Runnable vesselUpdateRunnable
- Runnable aisUpdateRunnable
- Runnable pathUpdateRunnable
- boolean vesselUpdatePending
- boolean aisUpdatePending
- boolean pathUpdatePending
+ requestVesselUpdate()
+ requestAISUpdate()
+ requestAISRemoval()
+ flushPendingOperations()
+ cleanup()
}
interface UIDataChangeNotifier {
+ onVesselPositionChanged()
+ onGPSQualityChanged()
+ onAISVesselChanged()
+ onAISVesselRemoved()
+ onVesselPathChanged()
+ onRequestCenterMap()
+ onCompassUpdate()
}
class CompassView {
- float azimuth
- Paint compassPaint
- Paint needlePaint
- Paint textPaint
- List<AISVessel> nearbyVessels
+ setAzimuth()
+ setNearbyVessels()
+ onDraw()
}
class CompassSensor {
- SensorManager sensorManager
- Sensor magnetometer
- Sensor accelerometer
- CompassListener callback
- float[] lastAccelerometer
- float[] lastMagnetometer
- boolean lastAccelerometerSet
- boolean lastMagnetometerSet
- float[] rotationMatrix
- float[] orientation
+ startListening()
+ stopListening()
+ setCallback()
}
class CoordinatesDockWidget {
- TextView latitudeText
- TextView longitudeText
- TextView accuracyText
- TextView satellitesText
- TextView qualityText
+ updateCoordinates()
+ updateGPSQuality()
}
class CursorOverlay {
- ViewGroup parentView
- TextView coordinatesText
- TextView vesselInfoText
- boolean isVisible
+ show()
+ hide()
+ updateCoordinates()
+ setVesselInfo()
+ clearVesselInfo()
}
}
package "Services" {
class NotificationService {
- Context context
- SettingsManager settingsManager
- Vibrator vibrator
- ToneGenerator toneGenerator
- boolean isInitialized
+ showSafetyAlert()
+ showNewVesselNotification()
+ clearNotifications()
+ setVibrationEnabled()
+ setSoundEnabled()
}
class AISForegroundService {
- Context context
- AppCoordinator appCoordinator
- NotificationManager notificationManager
- boolean isRunning
+ startForeground()
+ stopForeground()
+ onStartCommand()
+ onDestroy()
}
}
package "Utils" {
class SettingsManager {
- Context context
- SharedPreferences prefs
+ getUDPPort() : int
+ setUDPPort()
+ isUDPEnabled() : boolean
+ setUDPEnabled()
+ isAndroidNMEAEnabled() : boolean
+ setAndroidNMEAEnabled()
+ isUDPNMEAEnabled() : boolean
+ setUDPNMEAEnabled()
+ getDataMode() : String
+ setDataMode()
+ getDataStaleWarningMinutes() : int
+ setDataStaleWarningMinutes()
+ getDataStaleRemoveMinutes() : int
+ setDataStaleRemoveMinutes()
+ isPathTrackingEnabled() : boolean
+ setPathTrackingEnabled()
+ getPathColor() : int
+ setPathColor()
+ getPredictionColor() : int
+ setPredictionColor()
+ getPathWidth() : float
+ setPathWidth()
+ getPredictionWidth() : float
+ setPredictionWidth()
+ getPathMaxPoints() : int
+ setPathMaxPoints()
+ getPredictionHorizonSec() : int
+ setPredictionHorizonSec()
+ isVibrationEnabled() : boolean
+ setVibrationEnabled()
+ isSoundEnabled() : boolean
+ setSoundEnabled()
+ isKeepScreenOnEnabled() : boolean
+ setKeepScreenOnEnabled()
+ isCursorEnabled() : boolean
+ setCursorEnabled()
}
class GeoUtils {
+ calculateDistance() : double
+ calculateBearing() : double
+ isValidCoordinate() : boolean
+ formatCoordinate() : String
+ convertToDecimalDegrees() : double
}
class LogSender {
+ sendLog()
+ sendError()
+ sendWarning()
+ sendInfo()
}
class MIDToCountry {
+ getCountryByMID() : String
+ getCountryName() : String
+ isValidMID() : boolean
}
class NavigationUtils {
+ calculateCourse() : double
+ calculateSpeed() : double
+ calculateETA() : LocalDateTime
+ isCollisionRisk() : boolean
}
}
' Relationships
MainActivity --> AppCoordinator : uses
MainActivity --> MenuBinder : uses
MainActivity --> BottomSheetsBinder : uses
MainActivity --> PermissionsBinder : uses
MainActivity --> MapController : uses
MainActivity --> CompassController : uses
MainActivity --> UIRenderingCoordinator : uses
MainActivity --> CompassView : uses
MainActivity --> CoordinatesDockWidget : uses
MainActivity --> BottomSheetsManager : uses
ControllersFactory <|.. DefaultControllersFactory : implements
MainActivity --> ControllersFactory : uses
DefaultControllersFactory --> AppCoordinator : creates
AppCoordinator --> NMEAController : coordinates
AppCoordinator --> NetworkController : coordinates
AppCoordinator --> DataController : coordinates
AppCoordinator --> NotificationController : coordinates
AppCoordinator --> CompassController : coordinates
AppCoordinator --> MapController : coordinates
AppCoordinator --> VesselPathController : uses
AppCoordinator --> SettingsManager : uses
AppCoordinator --> UIRenderingCoordinator : uses
NMEAController --> NMEAParser : uses
NMEAController --> AndroidNMEAListener : uses
NMEAController --> GPSLocationListener : uses
NetworkController --> UDPListener : uses
DataController --> Repository : uses
DataController --> SettingsManager : uses
NotificationController --> NotificationService : uses
CompassController --> CompassSensor : uses
MenuBinder --> AppCoordinator : uses
MenuBinder --> SettingsManager : uses
BottomSheetsBinder --> Context : uses
BottomSheetsManager --> AppCoordinator : uses
PermissionsBinder --> Activity : uses
MapController --> MapInterface : manages
MapController --> YandexMapImpl : creates
MapController --> MapLibreMapImpl : creates
MapController --> MapForgeImpl : creates
YandexMapImpl ..|> MapInterface : implements
MapLibreMapImpl ..|> MapInterface : implements
MapForgeImpl ..|> MapInterface : implements
YandexMapImpl --> YandexMarkerManager : uses
MapLibreMapImpl --> MarkerManager : uses
Repository --> AppDatabase : uses
Repository --> AISVesselDao : uses
Repository --> VesselDao : uses
AppDatabase --> AISVesselEntity : contains
AppDatabase --> VesselEntity : contains
UIRenderingCoordinator ..|> UIDataChangeNotifier : implements
NMEAParser --> Vessel : creates/updates
NMEAParser --> AISVessel : creates/updates
NMEAParser --> GPSLocationListener : uses
VesselPathController --> VesselPathPoint : manages
VesselPathController --> SettingsManager : uses
NotificationService --> SettingsManager : uses
CompassSensor --> CompassView : updates
CompassView --> AISVessel : displays
' Interface implementations
AppCoordinator ..|> NMEAControllerListener : implements
AppCoordinator ..|> NetworkControllerListener : implements
AppCoordinator ..|> DataControllerListener : implements
AppCoordinator ..|> NotificationControllerListener : implements
AppCoordinator ..|> CompassControllerListener : implements
AppCoordinator ..|> MarkerClickListener : implements
AppCoordinator ..|> MapInterfaceChangeListener : implements
NMEAController ..|> NMEAParserListener : implements
NMEAController ..|> NMEAMessageCallback : implements
NetworkController ..|> UDPListenerCallback : implements
CompassController ..|> CompassListener : implements
NMEAParser ..|> NMEAParserListener : implements
UDPListener ..|> UDPListenerCallback : implements
AndroidNMEAListener ..|> NMEAMessageCallback : implements
GPSLocationListener ..|> LocationCallback : implements
CompassSensor ..|> CompassListener : implements
@enduml
```
## Описание PlantUML диаграммы
### 🎯 **Преимущества PlantUML:**
1. **Компактность** - более читаемая структура
2. **Группировка** - логическое разделение по пакетам
3. **Цветовое кодирование** - разные цвета для разных типов компонентов
4. **Автоматическое позиционирование** - PlantUML сам расставляет элементы
5. **Экспорт** - легко экспортировать в PNG, SVG, PDF
### 📦 **Структура пакетов:**
- **Main Activity** - основные активности приложения
- **Controllers Factory** - фабрика для создания контроллеров
- **Core Controllers** - основные контроллеры системы
- **UI Binders** - компоненты для управления UI
- **Data Processing** - обработка данных и парсинг
- **Maps** - система карт и маркеров
- **Data Models** - модели данных
- **Database** - слой базы данных
- **UI Components** - UI компоненты
- **Services** - сервисы приложения
- **Utils** - утилиты и вспомогательные классы
### 🔗 **Типы связей:**
- `-->` - использование/зависимость
- `..|>` - реализация интерфейса
- `<|..` - наследование
- `--` - ассоциация
### 🎨 **Визуальные особенности:**
- Интерфейсы выделены специальным стилем
- Абстрактные классы помечены как `abstract`
- Утилиты помечены как `<<utility>>`
- Четкое разделение по функциональным областям
Эта диаграмма более компактна и лучше подходит для презентаций и документации. Вы можете использовать её в PlantUML редакторах или онлайн сервисах для генерации изображений.
-796
View File
@@ -1,796 +0,0 @@
# Диаграмма классов AIS Map Application (PlantUML - Без Graphviz)
```plantuml
@startuml AIS_Map_Architecture_NoGraphviz
!define RECTANGLE class
skinparam classAttributeIconSize 0
skinparam classFontSize 8
skinparam packageFontSize 10
skinparam backgroundColor white
skinparam classBackgroundColor white
skinparam packageBackgroundColor lightblue
skinparam packageBorderColor black
skinparam classBorderColor black
skinparam interfaceBackgroundColor lightgreen
skinparam interfaceBorderColor black
skinparam arrowColor black
skinparam arrowThickness 1
package "Main Activity" {
class MainActivity {
- AppCoordinator appCoordinator
- MenuBinder menuBinder
- BottomSheetsBinder bottomSheetsBinder
- PermissionsBinder permissionsBinder
- MapController mapController
- CompassController compassController
- UIRenderingCoordinator uiCoordinator
+ onCreate()
+ onResume()
+ onPause()
+ onDestroy()
}
class AisTargetsActivity {
- AisTargetsAdapter adapter
- List<AISVessel> aisVessels
+ onCreate()
+ updateAISList()
}
class SettingsActivity {
- SettingsManager settingsManager
+ onCreate()
+ saveSettings()
}
}
package "Controllers Factory" {
interface ControllersFactory {
+ createAppCoordinator() : AppCoordinator
}
class DefaultControllersFactory {
+ createAppCoordinator() : AppCoordinator
}
}
package "Core Controllers" {
class AppCoordinator {
- Context context
- NMEAController nmeaController
- NetworkController networkController
- DataController dataController
- NotificationController notificationController
- CompassController compassController
- MapController mapController
- Vessel ownVessel
- List<AISVessel> aisVessels
- SettingsManager settingsManager
+ initializeControllers()
+ startServices()
+ stopServices()
+ onVesselUpdated()
+ onAISVesselUpdated()
+ onDOPUpdated()
+ onDataReceived()
+ onNotificationShown()
+ onCompassChanged()
}
class NMEAController {
- Context context
- NMEAParser nmeaParser
- AndroidNMEAListener androidNmeaListener
- GPSLocationListener gpsLocationListener
- ExecutorService executor
+ startAndroidNMEAListener()
+ stopAndroidNMEAListener()
+ startGPSLocationListener()
+ stopGPSLocationListener()
+ parseNMEAData()
}
class NetworkController {
- Context context
- UDPListener udpListener
- ExecutorService executor
- int udpPort
- boolean isUDPEnabled
+ setUDPEnabled()
+ startUDPListener()
+ stopUDPListener()
}
class DataController {
- Context context
- Repository repository
- SettingsManager settingsManager
- ExecutorService executor
+ restoreDataAsync()
+ saveVesselData()
+ saveAISData()
+ performDatabaseCleanup()
}
class NotificationController {
- Context context
- NotificationService notificationService
+ notifyNewAISTarget()
+ notifySafetyMessage()
+ notifyGPSStatus()
}
class CompassController {
- Context context
- CompassSensor compassSensor
- Handler uiHandler
+ startCompass()
+ stopCompass()
+ isCompassAvailable() : boolean
+ isCompassActive() : boolean
+ getCompassStatus() : String
}
class MapController {
- Context context
- MapInterface currentMapInterface
- MapView mapView
- MapLibreMapView mapLibreView
- List<MapInterfaceChangeListener> listeners
+ addMapInterfaceChangeListener()
+ removeMapInterfaceChangeListener()
+ switchToYandexMaps()
+ switchToMapLibre()
+ getCurrentMapInterface() : MapInterface
}
}
package "UI Binders" {
class MenuBinder {
- AppCoordinator appCoordinator
- SettingsManager settingsManager
- MenuActions actions
+ onCreateOptionsMenu()
+ onPrepareOptionsMenu()
+ onOptionsItemSelected()
}
class BottomSheetsBinder {
- Context context
- BottomSheetDialog ownVesselBottomSheet
- BottomSheetDialog aisVesselBottomSheet
- View ownBottomSheetView
- View aisBottomSheetView
- AISVessel currentAISVessel
+ init()
+ initAIS()
+ showOwnVesselSheet()
+ showAISVesselSheet()
+ startAutoUpdate()
+ stopAutoUpdate()
}
class BottomSheetsManager {
- Context context
- AppCoordinator appCoordinator
- BottomSheetDialog ownVesselBottomSheet
- BottomSheetDialog aisVesselBottomSheet
- View bottomSheetView
- View aisBottomSheetView
- AISVessel currentAISVessel
+ init()
+ showOwnVesselSheet()
+ showAISVesselSheet()
+ updateOwnVesselUI()
+ updateAISBottomSheetUI()
+ stopAutoUpdate()
}
class PermissionsBinder {
- Activity activity
+ ensurePermission() : boolean
+ handleOnRequestPermissionsResult() : boolean
}
}
package "Data Processing" {
class NMEAParser {
- Vessel ownVessel
- List<AISVessel> aisVessels
- NMEAParserListener listener
- GPSLocationListener gpsLocationListener
- Map<String, Map<Integer, String>> aisFragments
- boolean hybridMode
+ parseNMEA()
+ setHybridMode()
+ setGPSLocationListener()
}
class UDPListener {
- int port
- DatagramSocket socket
- ExecutorService executor
- AtomicBoolean isRunning
- UDPListenerCallback callback
+ start()
+ stop()
+ setCallback()
}
class AndroidNMEAListener {
- LocationManager locationManager
- NMEAMessageCallback callback
- boolean isListening
+ startListening() : boolean
+ stopListening()
+ setCallback()
}
class GPSLocationListener {
- Context context
- LocationManager locationManager
- LocationCallback callback
- boolean isListening
- int satelliteCount
- int activeSatellites
- double pdop
- double hdop
- double vdop
+ startListening() : boolean
+ stopListening()
+ setCallback()
}
class VesselPathController {
- Context context
- SettingsManager settingsManager
- SharedPreferences prefs
- String vesselId
- Handler uiHandler
- List<VesselPathPoint> pathPoints
- VesselPathPoint lastPoint
+ addPathPoint()
+ getPathPoints() : List<VesselPathPoint>
+ clearPath()
+ savePath()
+ loadPath()
}
}
package "Maps" {
interface MapInterface {
+ initialize()
+ cleanup()
+ addOwnVesselMarker()
+ updateOwnVesselPosition()
+ addAISVesselMarker()
+ updateAISVesselPosition()
+ removeAISVesselMarker()
+ clearAISVesselMarkers()
+ centerOnPosition()
+ setZoom()
+ getZoom() : float
+ setBearing()
+ getBearing() : float
}
class YandexMapImpl {
- Context context
- MapView mapView
- MapObjectCollection mapObjects
- MarkerClickListener markerClickListener
- YandexMarkerManager markerManager
- CursorOverlay cursorOverlay
- Vessel ownVessel
+ initialize()
+ cleanup()
+ addOwnVesselMarker()
+ updateOwnVesselPosition()
+ addAISVesselMarker()
+ updateAISVesselPosition()
+ removeAISVesselMarker()
+ clearAISVesselMarkers()
+ centerOnPosition()
+ setZoom()
+ getZoom() : float
+ setBearing()
+ getBearing() : float
}
class MapLibreMapImpl {
- Context context
- MapView mapView
- MapLibreMap mapLibreMap
- MarkerClickListener markerClickListener
- CursorOverlay cursorOverlay
- Vessel ownVessel
- Map<String, AISVessel> aisVessels
+ initialize()
+ cleanup()
+ addOwnVesselMarker()
+ updateOwnVesselPosition()
+ addAISVesselMarker()
+ updateAISVesselPosition()
+ removeAISVesselMarker()
+ clearAISVesselMarkers()
+ centerOnPosition()
+ setZoom()
+ getZoom() : float
+ setBearing()
+ getBearing() : float
}
class MapForgeImpl {
- Context context
- MapView mapView
- MarkerClickListener markerClickListener
- CursorOverlay cursorOverlay
- Vessel ownVessel
+ initialize()
+ cleanup()
+ addOwnVesselMarker()
+ updateOwnVesselPosition()
+ addAISVesselMarker()
+ updateAISVesselPosition()
+ removeAISVesselMarker()
+ clearAISVesselMarkers()
+ centerOnPosition()
+ setZoom()
+ getZoom() : float
+ setBearing()
+ getBearing() : float
}
}
package "Data Models" {
class Vessel {
- double latitude
- double longitude
- double course
- double speed
- double heading
- double magneticCompass
- int signalStrength
- LocalDateTime lastUpdate
- String vesselName
- String mmsi
- String callSign
- double altitude
- int satellites
- int activeSatellites
- double pdop
- double hdop
- double vdop
- float accuracy
- long fixTime
- String fixQuality
+ updatePosition()
+ updateGPSQuality()
+ getGPSQualityPercentage() : int
+ getGPSQualityDescription() : String
}
class AISVessel {
- String mmsi
- String vesselName
- String callSign
- int imo
- String vesselType
- double latitude
- double longitude
- double course
- double speed
- double heading
- double rateOfTurn
- double length
- double width
- double draft
- String destination
- LocalDateTime eta
- LocalDateTime lastUpdate
- int signalStrength
- boolean isActive
- String navigationalStatus
- String lastSafetyMessage
- boolean positionAccuracy
- String vesselClass
- String vendorId
- boolean selected
+ updatePosition()
+ isDataStale() : boolean
+ shouldBeRemoved() : boolean
+ getMinutesSinceLastUpdate() : long
}
class VesselPathPoint {
- double latitude
- double longitude
- double course
- double speed
- long timestamp
+ VesselPathPoint()
+ toJSON() : String
+ fromJSON()
}
}
package "Database" {
abstract class AppDatabase {
+ aisVesselDao() : AISVesselDao
+ vesselDao() : VesselDao
+ getInstance() : AppDatabase
}
class Repository {
- AISVesselDao aisVesselDao
- VesselDao vesselDao
- ExecutorService ioExecutor
+ upsertAIS()
+ deleteStaleAIS()
+ getAllAISSync() : List<AISVesselEntity>
+ observeAllAIS() : LiveData<List<AISVesselEntity>>
+ getAISByMmsiSync() : AISVesselEntity
+ upsertOwnVessel()
+ getLatestOwnVesselSync() : VesselEntity
+ getLatestOwnVesselAsync()
}
interface AISVesselDao {
+ upsert()
+ deleteStale()
+ getAll() : List<AISVesselEntity>
+ observeAll() : LiveData<List<AISVesselEntity>>
+ getByMmsi() : AISVesselEntity
}
interface VesselDao {
+ upsert()
+ getLatest() : VesselEntity
}
class AISVesselEntity {
- String mmsi
- String vesselName
- String callSign
- int imo
- String vesselType
- double latitude
- double longitude
- double course
- double speed
- double heading
- double rateOfTurn
- double length
- double width
- double draft
- String destination
- long etaEpochMs
- long lastUpdateEpochMs
- int signalStrength
- boolean isActive
- String navigationalStatus
- String lastSafetyMessage
- boolean positionAccuracy
- String vesselClass
- String vendorId
}
class VesselEntity {
- double latitude
- double longitude
- double course
- double speed
- double heading
- double magneticCompass
- int signalStrength
- long lastUpdateEpochMs
- String vesselName
- String mmsi
- String callSign
- double altitude
- int satellites
- int activeSatellites
- double pdop
- double hdop
- double vdop
- float accuracy
- long fixTime
- String fixQuality
}
}
package "UI Components" {
class UIRenderingCoordinator {
- MapInterface mapInterface
- Handler uiHandler
- Vessel pendingVesselUpdate
- Map<String, AISVessel> pendingAISUpdates
- Set<String> pendingAISRemovals
- Runnable vesselUpdateRunnable
- Runnable aisUpdateRunnable
- Runnable pathUpdateRunnable
- boolean vesselUpdatePending
- boolean aisUpdatePending
- boolean pathUpdatePending
+ requestVesselUpdate()
+ requestAISUpdate()
+ requestAISRemoval()
+ flushPendingOperations()
+ cleanup()
}
interface UIDataChangeNotifier {
+ onVesselPositionChanged()
+ onGPSQualityChanged()
+ onAISVesselChanged()
+ onAISVesselRemoved()
+ onVesselPathChanged()
+ onRequestCenterMap()
+ onCompassUpdate()
}
class CompassView {
- float azimuth
- Paint compassPaint
- Paint needlePaint
- Paint textPaint
- List<AISVessel> nearbyVessels
+ setAzimuth()
+ setNearbyVessels()
+ onDraw()
}
class CompassSensor {
- SensorManager sensorManager
- Sensor magnetometer
- Sensor accelerometer
- CompassListener callback
- float[] lastAccelerometer
- float[] lastMagnetometer
- boolean lastAccelerometerSet
- boolean lastMagnetometerSet
- float[] rotationMatrix
- float[] orientation
+ startListening()
+ stopListening()
+ setCallback()
}
class CoordinatesDockWidget {
- TextView latitudeText
- TextView longitudeText
- TextView accuracyText
- TextView satellitesText
- TextView qualityText
+ updateCoordinates()
+ updateGPSQuality()
}
class CursorOverlay {
- ViewGroup parentView
- TextView coordinatesText
- TextView vesselInfoText
- boolean isVisible
+ show()
+ hide()
+ updateCoordinates()
+ setVesselInfo()
+ clearVesselInfo()
}
}
package "Services" {
class NotificationService {
- Context context
- SettingsManager settingsManager
- Vibrator vibrator
- ToneGenerator toneGenerator
- boolean isInitialized
+ showSafetyAlert()
+ showNewVesselNotification()
+ clearNotifications()
+ setVibrationEnabled()
+ setSoundEnabled()
}
class AISForegroundService {
- Context context
- AppCoordinator appCoordinator
- NotificationManager notificationManager
- boolean isRunning
+ startForeground()
+ stopForeground()
+ onStartCommand()
+ onDestroy()
}
}
package "Utils" {
class SettingsManager {
- Context context
- SharedPreferences prefs
+ getUDPPort() : int
+ setUDPPort()
+ isUDPEnabled() : boolean
+ setUDPEnabled()
+ isAndroidNMEAEnabled() : boolean
+ setAndroidNMEAEnabled()
+ isUDPNMEAEnabled() : boolean
+ setUDPNMEAEnabled()
+ getDataMode() : String
+ setDataMode()
+ getDataStaleWarningMinutes() : int
+ setDataStaleWarningMinutes()
+ getDataStaleRemoveMinutes() : int
+ setDataStaleRemoveMinutes()
+ isPathTrackingEnabled() : boolean
+ setPathTrackingEnabled()
+ getPathColor() : int
+ setPathColor()
+ getPredictionColor() : int
+ setPredictionColor()
+ getPathWidth() : float
+ setPathWidth()
+ getPredictionWidth() : float
+ setPredictionWidth()
+ getPathMaxPoints() : int
+ setPathMaxPoints()
+ getPredictionHorizonSec() : int
+ setPredictionHorizonSec()
+ isVibrationEnabled() : boolean
+ setVibrationEnabled()
+ isSoundEnabled() : boolean
+ setSoundEnabled()
+ isKeepScreenOnEnabled() : boolean
+ setKeepScreenOnEnabled()
+ isCursorEnabled() : boolean
+ setCursorEnabled()
}
class GeoUtils {
+ calculateDistance() : double
+ calculateBearing() : double
+ isValidCoordinate() : boolean
+ formatCoordinate() : String
+ convertToDecimalDegrees() : double
}
class LogSender {
+ sendLog()
+ sendError()
+ sendWarning()
+ sendInfo()
}
class MIDToCountry {
+ getCountryByMID() : String
+ getCountryName() : String
+ isValidMID() : boolean
}
class NavigationUtils {
+ calculateCourse() : double
+ calculateSpeed() : double
+ calculateETA() : LocalDateTime
+ isCollisionRisk() : boolean
}
}
' Основные связи
MainActivity --> AppCoordinator : uses
MainActivity --> MenuBinder : uses
MainActivity --> BottomSheetsBinder : uses
MainActivity --> PermissionsBinder : uses
MainActivity --> MapController : uses
MainActivity --> CompassController : uses
MainActivity --> UIRenderingCoordinator : uses
MainActivity --> CompassView : uses
MainActivity --> CoordinatesDockWidget : uses
MainActivity --> BottomSheetsManager : uses
ControllersFactory <|.. DefaultControllersFactory : implements
MainActivity --> ControllersFactory : uses
DefaultControllersFactory --> AppCoordinator : creates
AppCoordinator --> NMEAController : coordinates
AppCoordinator --> NetworkController : coordinates
AppCoordinator --> DataController : coordinates
AppCoordinator --> NotificationController : coordinates
AppCoordinator --> CompassController : coordinates
AppCoordinator --> MapController : coordinates
AppCoordinator --> VesselPathController : uses
AppCoordinator --> SettingsManager : uses
AppCoordinator --> UIRenderingCoordinator : uses
NMEAController --> NMEAParser : uses
NMEAController --> AndroidNMEAListener : uses
NMEAController --> GPSLocationListener : uses
NetworkController --> UDPListener : uses
DataController --> Repository : uses
DataController --> SettingsManager : uses
NotificationController --> NotificationService : uses
CompassController --> CompassSensor : uses
MenuBinder --> AppCoordinator : uses
MenuBinder --> SettingsManager : uses
BottomSheetsBinder --> Context : uses
BottomSheetsManager --> AppCoordinator : uses
PermissionsBinder --> Activity : uses
MapController --> MapInterface : manages
MapController --> YandexMapImpl : creates
MapController --> MapLibreMapImpl : creates
MapController --> MapForgeImpl : creates
YandexMapImpl ..|> MapInterface : implements
MapLibreMapImpl ..|> MapInterface : implements
MapForgeImpl ..|> MapInterface : implements
Repository --> AppDatabase : uses
Repository --> AISVesselDao : uses
Repository --> VesselDao : uses
AppDatabase --> AISVesselEntity : contains
AppDatabase --> VesselEntity : contains
UIRenderingCoordinator ..|> UIDataChangeNotifier : implements
NMEAParser --> Vessel : creates/updates
NMEAParser --> AISVessel : creates/updates
NMEAParser --> GPSLocationListener : uses
VesselPathController --> VesselPathPoint : manages
VesselPathController --> SettingsManager : uses
NotificationService --> SettingsManager : uses
CompassSensor --> CompassView : updates
CompassView --> AISVessel : displays
@enduml
```
## Описание PlantUML диаграммы без Graphviz
### 🎯 **Ключевые изменения для совместимости:**
1. **Убрана зависимость от Graphviz** - диаграмма использует только встроенные возможности PlantUML
2. **Упрощены настройки** - оставлены только базовые skinparam параметры
3. **Оптимизированы размеры** - уменьшены размеры шрифтов для лучшей читаемости
4. **Убраны сложные элементы** - удалены некоторые методы для упрощения
### 📦 **Структура пакетов:**
- **Main Activity** - основные активности приложения
- **Controllers Factory** - фабрика для создания контроллеров
- **Core Controllers** - основные контроллеры системы
- **UI Binders** - компоненты для управления UI
- **Data Processing** - обработка данных и парсинг
- **Maps** - система карт и маркеров
- **Data Models** - модели данных
- **Database** - слой базы данных
- **UI Components** - UI компоненты
- **Services** - сервисы приложения
- **Utils** - утилиты и вспомогательные классы
### ✅ **Преимущества этой версии:**
1. **Совместимость** - работает без Graphviz
2. **Производительность** - быстрее генерируется
3. **Портативность** - работает в большинстве редакторов
4. **Читаемость** - четкая структура и связи
### 🔧 **Как использовать:**
1. **Онлайн редакторы:** PlantUML Online Server, PlantText
2. **IDE плагины:** IntelliJ IDEA, VS Code, Eclipse
3. **Командная строка:** PlantUML jar файл
4. **Документация:** GitLab, GitHub поддерживают PlantUML
Эта версия должна работать без проблем и показывать всю архитектуру вашего AIS Map приложения!
-793
View File
@@ -1,793 +0,0 @@
# Диаграмма классов AIS Map Application (PlantUML - Упрощенная версия)
```plantuml
@startuml AIS_Map_Architecture_Simple
skinparam classAttributeIconSize 0
skinparam classFontSize 9
skinparam packageFontSize 11
skinparam backgroundColor white
skinparam classBackgroundColor white
skinparam packageBackgroundColor lightblue
skinparam packageBorderColor black
skinparam classBorderColor black
skinparam interfaceBackgroundColor lightgreen
skinparam interfaceBorderColor black
package "Main Activity" {
class MainActivity {
- AppCoordinator appCoordinator
- MenuBinder menuBinder
- BottomSheetsBinder bottomSheetsBinder
- PermissionsBinder permissionsBinder
- MapController mapController
- CompassController compassController
- UIRenderingCoordinator uiCoordinator
+ onCreate()
+ onResume()
+ onPause()
+ onDestroy()
}
class AisTargetsActivity {
- AisTargetsAdapter adapter
- List<AISVessel> aisVessels
+ onCreate()
+ updateAISList()
}
class SettingsActivity {
- SettingsManager settingsManager
+ onCreate()
+ saveSettings()
}
}
package "Controllers Factory" {
interface ControllersFactory {
+ createAppCoordinator() : AppCoordinator
}
class DefaultControllersFactory {
+ createAppCoordinator() : AppCoordinator
}
}
package "Core Controllers" {
class AppCoordinator {
- Context context
- NMEAController nmeaController
- NetworkController networkController
- DataController dataController
- NotificationController notificationController
- CompassController compassController
- MapController mapController
- Vessel ownVessel
- List<AISVessel> aisVessels
- SettingsManager settingsManager
+ initializeControllers()
+ startServices()
+ stopServices()
+ onVesselUpdated()
+ onAISVesselUpdated()
+ onDOPUpdated()
+ onDataReceived()
+ onNotificationShown()
+ onCompassChanged()
}
class NMEAController {
- Context context
- NMEAParser nmeaParser
- AndroidNMEAListener androidNmeaListener
- GPSLocationListener gpsLocationListener
- ExecutorService executor
+ startAndroidNMEAListener()
+ stopAndroidNMEAListener()
+ startGPSLocationListener()
+ stopGPSLocationListener()
+ parseNMEAData()
}
class NetworkController {
- Context context
- UDPListener udpListener
- ExecutorService executor
- int udpPort
- boolean isUDPEnabled
+ setUDPEnabled()
+ startUDPListener()
+ stopUDPListener()
}
class DataController {
- Context context
- Repository repository
- SettingsManager settingsManager
- ExecutorService executor
+ restoreDataAsync()
+ saveVesselData()
+ saveAISData()
+ performDatabaseCleanup()
}
class NotificationController {
- Context context
- NotificationService notificationService
+ notifyNewAISTarget()
+ notifySafetyMessage()
+ notifyGPSStatus()
}
class CompassController {
- Context context
- CompassSensor compassSensor
- Handler uiHandler
+ startCompass()
+ stopCompass()
+ isCompassAvailable() : boolean
+ isCompassActive() : boolean
+ getCompassStatus() : String
}
class MapController {
- Context context
- MapInterface currentMapInterface
- MapView mapView
- MapLibreMapView mapLibreView
- List<MapInterfaceChangeListener> listeners
+ addMapInterfaceChangeListener()
+ removeMapInterfaceChangeListener()
+ switchToYandexMaps()
+ switchToMapLibre()
+ getCurrentMapInterface() : MapInterface
}
}
package "UI Binders" {
class MenuBinder {
- AppCoordinator appCoordinator
- SettingsManager settingsManager
- MenuActions actions
+ onCreateOptionsMenu()
+ onPrepareOptionsMenu()
+ onOptionsItemSelected()
}
class BottomSheetsBinder {
- Context context
- BottomSheetDialog ownVesselBottomSheet
- BottomSheetDialog aisVesselBottomSheet
- View ownBottomSheetView
- View aisBottomSheetView
- AISVessel currentAISVessel
+ init()
+ initAIS()
+ showOwnVesselSheet()
+ showAISVesselSheet()
+ startAutoUpdate()
+ stopAutoUpdate()
}
class BottomSheetsManager {
- Context context
- AppCoordinator appCoordinator
- BottomSheetDialog ownVesselBottomSheet
- BottomSheetDialog aisVesselBottomSheet
- View bottomSheetView
- View aisBottomSheetView
- AISVessel currentAISVessel
+ init()
+ showOwnVesselSheet()
+ showAISVesselSheet()
+ updateOwnVesselUI()
+ updateAISBottomSheetUI()
+ stopAutoUpdate()
}
class PermissionsBinder {
- Activity activity
+ ensurePermission() : boolean
+ handleOnRequestPermissionsResult() : boolean
}
}
package "Data Processing" {
class NMEAParser {
- Vessel ownVessel
- List<AISVessel> aisVessels
- NMEAParserListener listener
- GPSLocationListener gpsLocationListener
- Map<String, Map<Integer, String>> aisFragments
- boolean hybridMode
+ parseNMEA()
+ setHybridMode()
+ setGPSLocationListener()
}
class UDPListener {
- int port
- DatagramSocket socket
- ExecutorService executor
- AtomicBoolean isRunning
- UDPListenerCallback callback
+ start()
+ stop()
+ setCallback()
}
class AndroidNMEAListener {
- LocationManager locationManager
- NMEAMessageCallback callback
- boolean isListening
+ startListening() : boolean
+ stopListening()
+ setCallback()
}
class GPSLocationListener {
- Context context
- LocationManager locationManager
- LocationCallback callback
- boolean isListening
- int satelliteCount
- int activeSatellites
- double pdop
- double hdop
- double vdop
+ startListening() : boolean
+ stopListening()
+ setCallback()
}
class VesselPathController {
- Context context
- SettingsManager settingsManager
- SharedPreferences prefs
- String vesselId
- Handler uiHandler
- List<VesselPathPoint> pathPoints
- VesselPathPoint lastPoint
+ addPathPoint()
+ getPathPoints() : List<VesselPathPoint>
+ clearPath()
+ savePath()
+ loadPath()
}
}
package "Maps" {
interface MapInterface {
+ initialize()
+ cleanup()
+ addOwnVesselMarker()
+ updateOwnVesselPosition()
+ addAISVesselMarker()
+ updateAISVesselPosition()
+ removeAISVesselMarker()
+ clearAISVesselMarkers()
+ centerOnPosition()
+ setZoom()
+ getZoom() : float
+ setBearing()
+ getBearing() : float
}
class YandexMapImpl {
- Context context
- MapView mapView
- MapObjectCollection mapObjects
- MarkerClickListener markerClickListener
- YandexMarkerManager markerManager
- CursorOverlay cursorOverlay
- Vessel ownVessel
+ initialize()
+ cleanup()
+ addOwnVesselMarker()
+ updateOwnVesselPosition()
+ addAISVesselMarker()
+ updateAISVesselPosition()
+ removeAISVesselMarker()
+ clearAISVesselMarkers()
+ centerOnPosition()
+ setZoom()
+ getZoom() : float
+ setBearing()
+ getBearing() : float
}
class MapLibreMapImpl {
- Context context
- MapView mapView
- MapLibreMap mapLibreMap
- MarkerClickListener markerClickListener
- CursorOverlay cursorOverlay
- Vessel ownVessel
- Map<String, AISVessel> aisVessels
+ initialize()
+ cleanup()
+ addOwnVesselMarker()
+ updateOwnVesselPosition()
+ addAISVesselMarker()
+ updateAISVesselPosition()
+ removeAISVesselMarker()
+ clearAISVesselMarkers()
+ centerOnPosition()
+ setZoom()
+ getZoom() : float
+ setBearing()
+ getBearing() : float
}
class MapForgeImpl {
- Context context
- MapView mapView
- MarkerClickListener markerClickListener
- CursorOverlay cursorOverlay
- Vessel ownVessel
+ initialize()
+ cleanup()
+ addOwnVesselMarker()
+ updateOwnVesselPosition()
+ addAISVesselMarker()
+ updateAISVesselPosition()
+ removeAISVesselMarker()
+ clearAISVesselMarkers()
+ centerOnPosition()
+ setZoom()
+ getZoom() : float
+ setBearing()
+ getBearing() : float
}
}
package "Data Models" {
class Vessel {
- double latitude
- double longitude
- double course
- double speed
- double heading
- double magneticCompass
- int signalStrength
- LocalDateTime lastUpdate
- String vesselName
- String mmsi
- String callSign
- double altitude
- int satellites
- int activeSatellites
- double pdop
- double hdop
- double vdop
- float accuracy
- long fixTime
- String fixQuality
+ updatePosition()
+ updateGPSQuality()
+ getGPSQualityPercentage() : int
+ getGPSQualityDescription() : String
}
class AISVessel {
- String mmsi
- String vesselName
- String callSign
- int imo
- String vesselType
- double latitude
- double longitude
- double course
- double speed
- double heading
- double rateOfTurn
- double length
- double width
- double draft
- String destination
- LocalDateTime eta
- LocalDateTime lastUpdate
- int signalStrength
- boolean isActive
- String navigationalStatus
- String lastSafetyMessage
- boolean positionAccuracy
- String vesselClass
- String vendorId
- boolean selected
+ updatePosition()
+ isDataStale() : boolean
+ shouldBeRemoved() : boolean
+ getMinutesSinceLastUpdate() : long
}
class VesselPathPoint {
- double latitude
- double longitude
- double course
- double speed
- long timestamp
+ VesselPathPoint()
+ toJSON() : String
+ fromJSON()
}
}
package "Database" {
abstract class AppDatabase {
+ aisVesselDao() : AISVesselDao
+ vesselDao() : VesselDao
+ getInstance() : AppDatabase
}
class Repository {
- AISVesselDao aisVesselDao
- VesselDao vesselDao
- ExecutorService ioExecutor
+ upsertAIS()
+ deleteStaleAIS()
+ getAllAISSync() : List<AISVesselEntity>
+ observeAllAIS() : LiveData<List<AISVesselEntity>>
+ getAISByMmsiSync() : AISVesselEntity
+ upsertOwnVessel()
+ getLatestOwnVesselSync() : VesselEntity
+ getLatestOwnVesselAsync()
}
interface AISVesselDao {
+ upsert()
+ deleteStale()
+ getAll() : List<AISVesselEntity>
+ observeAll() : LiveData<List<AISVesselEntity>>
+ getByMmsi() : AISVesselEntity
}
interface VesselDao {
+ upsert()
+ getLatest() : VesselEntity
}
class AISVesselEntity {
- String mmsi
- String vesselName
- String callSign
- int imo
- String vesselType
- double latitude
- double longitude
- double course
- double speed
- double heading
- double rateOfTurn
- double length
- double width
- double draft
- String destination
- long etaEpochMs
- long lastUpdateEpochMs
- int signalStrength
- boolean isActive
- String navigationalStatus
- String lastSafetyMessage
- boolean positionAccuracy
- String vesselClass
- String vendorId
}
class VesselEntity {
- double latitude
- double longitude
- double course
- double speed
- double heading
- double magneticCompass
- int signalStrength
- long lastUpdateEpochMs
- String vesselName
- String mmsi
- String callSign
- double altitude
- int satellites
- int activeSatellites
- double pdop
- double hdop
- double vdop
- float accuracy
- long fixTime
- String fixQuality
}
}
package "UI Components" {
class UIRenderingCoordinator {
- MapInterface mapInterface
- Handler uiHandler
- Vessel pendingVesselUpdate
- Map<String, AISVessel> pendingAISUpdates
- Set<String> pendingAISRemovals
- Runnable vesselUpdateRunnable
- Runnable aisUpdateRunnable
- Runnable pathUpdateRunnable
- boolean vesselUpdatePending
- boolean aisUpdatePending
- boolean pathUpdatePending
+ requestVesselUpdate()
+ requestAISUpdate()
+ requestAISRemoval()
+ flushPendingOperations()
+ cleanup()
}
interface UIDataChangeNotifier {
+ onVesselPositionChanged()
+ onGPSQualityChanged()
+ onAISVesselChanged()
+ onAISVesselRemoved()
+ onVesselPathChanged()
+ onRequestCenterMap()
+ onCompassUpdate()
}
class CompassView {
- float azimuth
- Paint compassPaint
- Paint needlePaint
- Paint textPaint
- List<AISVessel> nearbyVessels
+ setAzimuth()
+ setNearbyVessels()
+ onDraw()
}
class CompassSensor {
- SensorManager sensorManager
- Sensor magnetometer
- Sensor accelerometer
- CompassListener callback
- float[] lastAccelerometer
- float[] lastMagnetometer
- boolean lastAccelerometerSet
- boolean lastMagnetometerSet
- float[] rotationMatrix
- float[] orientation
+ startListening()
+ stopListening()
+ setCallback()
}
class CoordinatesDockWidget {
- TextView latitudeText
- TextView longitudeText
- TextView accuracyText
- TextView satellitesText
- TextView qualityText
+ updateCoordinates()
+ updateGPSQuality()
}
class CursorOverlay {
- ViewGroup parentView
- TextView coordinatesText
- TextView vesselInfoText
- boolean isVisible
+ show()
+ hide()
+ updateCoordinates()
+ setVesselInfo()
+ clearVesselInfo()
}
}
package "Services" {
class NotificationService {
- Context context
- SettingsManager settingsManager
- Vibrator vibrator
- ToneGenerator toneGenerator
- boolean isInitialized
+ showSafetyAlert()
+ showNewVesselNotification()
+ clearNotifications()
+ setVibrationEnabled()
+ setSoundEnabled()
}
class AISForegroundService {
- Context context
- AppCoordinator appCoordinator
- NotificationManager notificationManager
- boolean isRunning
+ startForeground()
+ stopForeground()
+ onStartCommand()
+ onDestroy()
}
}
package "Utils" {
class SettingsManager {
- Context context
- SharedPreferences prefs
+ getUDPPort() : int
+ setUDPPort()
+ isUDPEnabled() : boolean
+ setUDPEnabled()
+ isAndroidNMEAEnabled() : boolean
+ setAndroidNMEAEnabled()
+ isUDPNMEAEnabled() : boolean
+ setUDPNMEAEnabled()
+ getDataMode() : String
+ setDataMode()
+ getDataStaleWarningMinutes() : int
+ setDataStaleWarningMinutes()
+ getDataStaleRemoveMinutes() : int
+ setDataStaleRemoveMinutes()
+ isPathTrackingEnabled() : boolean
+ setPathTrackingEnabled()
+ getPathColor() : int
+ setPathColor()
+ getPredictionColor() : int
+ setPredictionColor()
+ getPathWidth() : float
+ setPathWidth()
+ getPredictionWidth() : float
+ setPredictionWidth()
+ getPathMaxPoints() : int
+ setPathMaxPoints()
+ getPredictionHorizonSec() : int
+ setPredictionHorizonSec()
+ isVibrationEnabled() : boolean
+ setVibrationEnabled()
+ isSoundEnabled() : boolean
+ setSoundEnabled()
+ isKeepScreenOnEnabled() : boolean
+ setKeepScreenOnEnabled()
+ isCursorEnabled() : boolean
+ setCursorEnabled()
}
class GeoUtils {
+ calculateDistance() : double
+ calculateBearing() : double
+ isValidCoordinate() : boolean
+ formatCoordinate() : String
+ convertToDecimalDegrees() : double
}
class LogSender {
+ sendLog()
+ sendError()
+ sendWarning()
+ sendInfo()
}
class MIDToCountry {
+ getCountryByMID() : String
+ getCountryName() : String
+ isValidMID() : boolean
}
class NavigationUtils {
+ calculateCourse() : double
+ calculateSpeed() : double
+ calculateETA() : LocalDateTime
+ isCollisionRisk() : boolean
}
}
' Основные связи
MainActivity --> AppCoordinator : uses
MainActivity --> MenuBinder : uses
MainActivity --> BottomSheetsBinder : uses
MainActivity --> PermissionsBinder : uses
MainActivity --> MapController : uses
MainActivity --> CompassController : uses
MainActivity --> UIRenderingCoordinator : uses
MainActivity --> CompassView : uses
MainActivity --> CoordinatesDockWidget : uses
MainActivity --> BottomSheetsManager : uses
ControllersFactory <|.. DefaultControllersFactory : implements
MainActivity --> ControllersFactory : uses
DefaultControllersFactory --> AppCoordinator : creates
AppCoordinator --> NMEAController : coordinates
AppCoordinator --> NetworkController : coordinates
AppCoordinator --> DataController : coordinates
AppCoordinator --> NotificationController : coordinates
AppCoordinator --> CompassController : coordinates
AppCoordinator --> MapController : coordinates
AppCoordinator --> VesselPathController : uses
AppCoordinator --> SettingsManager : uses
AppCoordinator --> UIRenderingCoordinator : uses
NMEAController --> NMEAParser : uses
NMEAController --> AndroidNMEAListener : uses
NMEAController --> GPSLocationListener : uses
NetworkController --> UDPListener : uses
DataController --> Repository : uses
DataController --> SettingsManager : uses
NotificationController --> NotificationService : uses
CompassController --> CompassSensor : uses
MenuBinder --> AppCoordinator : uses
MenuBinder --> SettingsManager : uses
BottomSheetsBinder --> Context : uses
BottomSheetsManager --> AppCoordinator : uses
PermissionsBinder --> Activity : uses
MapController --> MapInterface : manages
MapController --> YandexMapImpl : creates
MapController --> MapLibreMapImpl : creates
MapController --> MapForgeImpl : creates
YandexMapImpl ..|> MapInterface : implements
MapLibreMapImpl ..|> MapInterface : implements
MapForgeImpl ..|> MapInterface : implements
Repository --> AppDatabase : uses
Repository --> AISVesselDao : uses
Repository --> VesselDao : uses
AppDatabase --> AISVesselEntity : contains
AppDatabase --> VesselEntity : contains
UIRenderingCoordinator ..|> UIDataChangeNotifier : implements
NMEAParser --> Vessel : creates/updates
NMEAParser --> AISVessel : creates/updates
NMEAParser --> GPSLocationListener : uses
VesselPathController --> VesselPathPoint : manages
VesselPathController --> SettingsManager : uses
NotificationService --> SettingsManager : uses
CompassSensor --> CompassView : updates
CompassView --> AISVessel : displays
@enduml
```
## Описание упрощенной PlantUML диаграммы
### 🎯 **Исправления для совместимости:**
1. **Убрана тема** - удалена `!theme plain` которая могла вызывать проблемы
2. **Упрощены настройки** - оставлены только базовые skinparam
3. **Убраны сложные элементы** - удалены некоторые методы для упрощения
4. **Оптимизированы связи** - оставлены только основные связи
### 📦 **Структура пакетов:**
- **Main Activity** - основные активности приложения
- **Controllers Factory** - фабрика для создания контроллеров
- **Core Controllers** - основные контроллеры системы
- **UI Binders** - компоненты для управления UI
- **Data Processing** - обработка данных и парсинг
- **Maps** - система карт и маркеров
- **Data Models** - модели данных
- **Database** - слой базы данных
- **UI Components** - UI компоненты
- **Services** - сервисы приложения
- **Utils** - утилиты и вспомогательные классы
### 🔧 **Как использовать:**
1. **Онлайн редакторы:** PlantUML Online Server, PlantText
2. **IDE плагины:** IntelliJ IDEA, VS Code, Eclipse
3. **Командная строка:** PlantUML jar файл
4. **Документация:** GitLab, GitHub поддерживают PlantUML
### ✅ **Преимущества упрощенной версии:**
- Более совместима с различными PlantUML редакторами
- Меньше вероятность ошибок с Graphviz
- Сохраняет всю основную архитектуру
- Легче для понимания и презентации
Эта упрощенная версия должна работать без проблем с Graphviz и показывать всю архитектуру вашего приложения.
+102
View File
@@ -0,0 +1,102 @@
{
"$schema": "https://opencode.ai/config.json",
"model": "AIMETRO/claude-opus-4.7",
"provider": {
"AIMETRO": {
"npm": "@ai-sdk/openai-compatible",
"name": "AIMETRO",
"options": {
"baseURL": "https://api.rxwave.ru/",
"apiKey": "sk-ant-api01-QLHWXU5RS96yRMNnFJ2VlN5UmR5L1t5KGTMnyr8wAHhWpxWFJrP9FctzZtaZHvPk"
},
"models": {
"claude-opus-4.7": {
"name": "Claude Opus 4.7",
"limit": { "context": 200000, "output": 64000 }
},
"claude-opus-4.6": {
"name": "Claude Opus 4.6",
"limit": { "context": 200000, "output": 64000 },
"variants": { "high": {}, "medium": {}, "low": {} }
},
"claude-opus-4.6-fast": {
"name": "Claude Opus 4.6 (fast)",
"limit": { "context": 200000, "output": 64000 }
},
"claude-opus-4.5": {
"name": "Claude Opus 4.5",
"limit": { "context": 200000, "output": 64000 }
},
"claude-sonnet-4.6": {
"name": "Claude Sonnet 4.6",
"limit": { "context": 200000, "output": 64000 }
},
"claude-sonnet-4.5": {
"name": "Claude Sonnet 4.5",
"limit": { "context": 200000, "output": 64000 }
},
"claude-sonnet-4": {
"name": "Claude Sonnet 4",
"limit": { "context": 200000, "output": 64000 }
},
"claude-haiku-4.5": {
"name": "Claude Haiku 4.5",
"limit": { "context": 200000, "output": 64000 }
},
"gpt-5.4": {
"name": "GPT-5.4",
"limit": { "context": 400000, "output": 128000 },
"options": { "include": ["reasoning.encrypted_content"] },
"variants": { "low": {}, "medium": {}, "high": {}, "xhigh": {} }
},
"gpt-5.4-mini": {
"name": "GPT-5.4 mini",
"limit": { "context": 400000, "output": 128000 }
},
"gpt-5.4-nano": {
"name": "GPT-5.4 nano",
"limit": { "context": 400000, "output": 128000 }
},
"gpt-5.3-codex": {
"name": "GPT-5.3 Codex",
"limit": { "context": 400000, "output": 128000 }
},
"gpt-5.2-codex": {
"name": "GPT-5.2 Codex",
"limit": { "context": 200000, "output": 64000 }
},
"gpt-5.2": {
"name": "GPT-5.2",
"limit": { "context": 200000, "output": 64000 }
},
"gpt-5-mini": {
"name": "GPT-5 mini",
"limit": { "context": 200000, "output": 64000 }
},
"gpt-4.1": {
"name": "GPT-4.1",
"limit": { "context": 1000000, "output": 32000 }
},
"gpt-4o-mini": {
"name": "GPT-4o mini",
"limit": { "context": 128000, "output": 16000 }
},
"gemini-3.1-pro-preview": {
"name": "Gemini 3.1 Pro Preview",
"limit": { "context": 1000000, "output": 65536 }
},
"gemini-2.5-pro": {
"name": "Gemini 2.5 Pro",
"limit": { "context": 1000000, "output": 65536 }
},
"grok-code-fast-1": {
"name": "Grok Code Fast 1",
"limit": { "context": 256000, "output": 32000 }
}
}
}
},
"plugin": [
"oh-my-opencode@latest"
]
}
-23
View File
@@ -1,23 +0,0 @@
warning: in the working copy of '.idea/misc.xml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java', LF will be replaced by CRLF the next time Git touches it
.idea/deploymentTargetSelector.xml
.idea/vcs.xml
app/build.gradle
app/src/main/AndroidManifest.xml
app/src/main/java/com/grigowashere/aismap/MainActivity.java
app/src/main/java/com/grigowashere/aismap/SettingsActivity.java
app/src/main/java/com/grigowashere/aismap/controllers/AppController.java
app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java
app/src/main/java/com/grigowashere/aismap/maps/MapForgeImpl.java
app/src/main/java/com/grigowashere/aismap/maps/MarkerManager.java
app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java
app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerManager.java
app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerWrapper.java
app/src/main/java/com/grigowashere/aismap/models/AISVessel.java
app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java
app/src/main/res/drawable/target.xml
app/src/main/res/drawable/targetclassa.xml
app/src/main/res/layout/activity_main.xml
app/src/main/res/layout/activity_settings.xml
app/src/main/res/layout/bottom_sheet_ais_vessel.xml
app/src/main/res/menu/main_menu.xml
+132
View File
@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<style>
.cls-1 {
fill: #d6ec5b;
}
.cls-2 {
fill: #d8ed5d;
}
.cls-3 {
fill: #fff;
}
.cls-4 {
stroke-dasharray: 4.83 4.83;
}
.cls-4, .cls-5, .cls-6, .cls-7 {
fill: none;
stroke: #fff;
stroke-miterlimit: 10;
}
.cls-8 {
fill: #d8ee5d;
}
.cls-9 {
fill: #fefdea;
}
.cls-10 {
fill: #d4ea59;
}
.cls-11 {
fill: #d5eb5a;
}
.cls-12 {
fill: #dcf160;
}
.cls-13 {
fill: #fefce9;
}
.cls-14 {
fill: #dbf15f;
}
.cls-6 {
stroke-dasharray: 10.74 10.74;
}
.cls-7 {
stroke-width: 2px;
}
.cls-15 {
fill: #b1cc36;
}
.cls-16 {
fill: #bcd542;
}
.cls-17 {
fill: #fdfce9;
}
.cls-18 {
fill: #d8ed5c;
}
</style>
</defs>
<g id="_Слой_4" data-name="Слой_4">
<rect class="cls-16" x="99.21" y="86.8" width="850.39" height="850.39"/>
<g id="_Слой_2-2">
<polygon class="cls-7" points="750.88 341.78 744.68 354.12 752.02 355.55 750.88 341.78"/>
</g>
</g>
<g id="_Слой_3" data-name="Слой_3">
<path class="cls-11" d="M755.8,302.69c-2.2,8.2-70.6,228.5-72.1,233-4.9-1.2-9.9-2.5-14.8-3.7-.9-.2-1.7-.4-2.6-.7-1.6-.4-3.2-.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-.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,.7-.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.6-.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"/>
<path class="cls-11" d="M648.7,255.59v2c-7.7-1.4-15.3-3.2-22.9-5.2-1.3-.3-2.6-.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-.6-.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.8-3.73,1.57-6.33,2.3-7.8h.3c28.3,0,56.6,0,84.9-.1h115.4c38.6-.1,71.8,9.9,99.7,37.2.7.6,1.3,1.2,2,1.9,5,4.7,9.8,10.1,13,16.1-.7,5.5-3,10.6-5.1,15.8-.4,1-.8,2.03-1.2,3.1-3.4,8.6-7.3,16.9-11.7,25.1-.5.9-1,1.9-1.5,2.8-3.7,6.5-8.3,11.8-13.5,17.2-.6.67-1.23,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-.4-8.1-.9-12.1-2.4-5.2-1.8-10.6-3.1-16-4.3-1-.2-1.9-.4-2.9-.7-2.3-.5-4.7-1.1-7-1.6"/>
<path class="cls-10" d="M770.8,306.69c9.8,2.4,96.5,26,100.1,27.1,10,2.8,41.69,10.9,45.19,11.8.2,22.8,0,159.9,0,161.6.13,8.53-.26,14.2-1.99,17-1.7,1.8-3.4,3.1-5.5,4.5-.9.7-1.7,1.5-2.6,2.3-2.8,2.3-5.6,4.5-8.4,6.7-.6.5-1.1.9-1.7,1.4-13.8,11.13-24.9,16.33-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-.8-15.6-2.2.5-7.4,69.3-223.7,72-233.9l.2-.1Z"/>
<path class="cls-18" d="M578.8,105.69c28.3,0,198.2-.1,200.3-.1,38.6-.1,71.8,9.9,99.7,37.2.67.6,1.33,1.23,2,1.9,5,4.7,9.8,10.1,13,16.1-.7,5.5-3,10.6-5.1,15.8-.4,1-.8,2-1.2,3.1-3.4,8.6-7.3,16.9-11.7,25.1-.5.9-1,1.9-1.5,2.8-3.7,6.5-8.3,11.8-13.5,17.2-.6.67-1.23,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-.4-8-.9-11.9-2.3-5.3-1.9-117.5-30.7-125.1-32.7-1.3-.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.17-69.53,17.9-71l.4-.4Z"/>
<path class="cls-2" d="M338.8,105.69h227c-1.3,5.3-5.2,20.93-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-.4-132-33.67-133-34,.6-5.4,34.9-120,35.8-122.8l.1-.2Z"/>
<path class="cls-1" d="M107.7,366.69c0-18.4-.2-111.3-.2-115.2-.1-18.8.1-36.8,6.4-54.8h-.1c12.3,2.6,46.6,11.4,51.7,12.8,1,.3,84.3,21.8,91.4,23.4,8.4,1.9,16.7,4.3,24.9,6.8-.7,6.8-45.7,157-51,175-12.6-1.7-25.4-15-32.9-24.8-.5-.7-1.1-1.5-1.7-2.2-1.9-2.6-4.1-4.8-6.5-7-.6-.6-1.3-1.2-2-1.9-10.3-9.6-22.7-16.6-37-17.1-1.1,0-2.2-.1-3.3-.2-13.5-.3-26.7,1.4-39.7,5.2"/>
<path class="cls-8" d="M246,105.39c2.2,0,64.3,0,75.8.1-.6,2.6-32.5,110.1-34.9,117.9-6-.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.13-3.37,4.6-5.3,3.1-3.8,6.8-6.6,10.7-9.5.8-.6,1.5-1.1,2.3-1.7,12.1-9,24.5-16.2,38.9-20.5.6-.2,1.3-.4,1.9-.6,15.5-4.5,30.1-5.2,46.1-5.1l-.3.2Z"/>
<path class="cls-1" d="M297.8,243.69c.9.2,1.7.4,2.6.7,6.4,1.6,115.7,30.5,116.8,30.7,1.9.5,3.8,1.1,5.6,1.7-.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-.7.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-.1Z"/>
<path class="cls-1" d="M902.8,181.69h2c1.1,2.3,2.2,4.6,3.2,6.9.33.6.63,1.23.9,1.9,7.6,16.6,7.39,34.1,7.19,52,0,.44,0,3.03,0,7.06-.03,18.76.08,68.88,0,80.24-1.7-.3-136.96-36.57-138.29-37.9,10-16.3,22.7-29.7,41.9-34.8,4.1-.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-.2Z"/>
<path class="cls-14" d="M458.8,263.69l2,1c-.67.53-1.33,1.07-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"/>
<path class="cls-12" d="M553.8,206.69l2,1c-1.5,3.1-3.2,6.1-5,9-.3-.7-.7-1.3-1-2,.53-1.27,1.17-2.63,1.9-4.1.73-1.47,1.13-2.2,1.2-2.2.3-.6.6-1.1.9-1.7h0ZM548.8,216.69l1,2-3,3c.7-1.6,1.3-3.3,2-5Z"/>
<g>
<path class="cls-5" d="M747.72,356.89c-.3,1.57-.67,3.38-1.13,5.38"/>
<path class="cls-6" d="M743.87,372.66c-.7,2.38-1.51,4.9-2.42,7.52-5.47,15.71-10.57,22.75-15.46,34.24-4.06,9.54-6.01,21.12-8.46,33.94"/>
<path class="cls-5" d="M716.52,453.63c-.1.49-.2.99-.3,1.48-.33,1.65-.6,3-.78,3.91"/>
</g>
</g>
<g id="_Слой_5" data-name="Слой_5">
<path class="cls-9" d="M531.76,588.64c7.39,5.63,12.6,13.02,14.46,22.21.21,1.96.26,3.87.21,5.78v1.55c-.15,7.23-2.43,13.17-5.84,19.42-.57,1.08-1.14,2.17-1.7,3.25-3.2,5.99-6.97,11.57-10.85,17.15-1.08,1.55-2.17,3.15-3.2,4.7-2.07,3-4.13,5.99-6.2,8.94-.21.31-.41.62-.62.93-.98,1.39-1.96,2.69-3.25,3.82-1.76-.57-1.96-.93-3-2.43-.28-.41-.55-.83-.83-1.24-.31-.46-.62-.88-.93-1.34-.67-.93-1.29-1.91-1.96-2.84-.36-.52-.67-.98-1.03-1.5-1.03-1.5-2.12-3.05-3.15-4.55-16.12-22.97-23.38-40.2-21.8-51.7,1.14-6.04,3.56-10.8,7.39-15.55.26-.31.46-.67.72-.98,9.4-12.34,29.18-13.69,41.63-5.73l-.05.1Z"/>
<path class="cls-16" d="M521.95,603.62c3.41,2.22,5.84,5.37,7.02,9.3.88,4.65-.05,8.99-2.53,12.96-2.22,2.89-5.53,4.96-9.14,5.63-5.01.57-8.73-.26-12.91-3.1-3.2-2.63-4.86-6.35-5.37-10.43-.21-4.96,1.14-8.52,4.39-12.19,5.53-5.06,11.98-5.48,18.54-2.17h0Z"/>
</g>
<g id="_Слой_2" data-name="Слой_2">
<path class="cls-13" d="M219.47,612.1c-11.99-6.07-27.68-7.1-40.54-3.79-4.5,1.5-8.68,3.63-12.78,6.07-5.13,3-10.09,4.02-16.01,3.79l-20.95-42.86-2.63-10.76,137.62-.32c16.96,0,28.79-3.55,42.19-14.27,16.01-12.7,34.94-19.01,55.13-20.74.79,0,19.01-.95,27.37-.95l-.08-.08h17.59c.58,0,1.87.58,3.86,1.74-.32,4.97-6.23,14.59-7.02,15.85-.39.63-7.33,12.62-10.25,18.06-.39.71-18.3,36.67-26.03,54.42-4.5.16-7.97,0-12.15-1.74-.87-.39-7.1-3.15-9.31-4.34-12.38-6.39-27.76-6.86-41.01-2.92-5.52,1.81-10.65,4.57-15.69,7.49-9.15,5.13-19.48,5.68-29.65,5.68h-2.84c-9.23-.08-17.9-1.66-26.34-5.36l-10.49-4.97Z"/>
<path class="cls-17" d="M220.18,454.21h16.56v33.52c3.89-.05,43.06,0,44.24,0,7.26-.16,12.54.16,18.06,5.21.47.39,1.03.87,1.5,1.26,10.17,8.91,16.96,25.63,21.37,38.17-.95.32-1.89.55-2.84.87-10.65,3.63-19.01,9.62-27.92,16.17-9.7,7.1-19.56,9.54-31.55,9.38-1.03,0-83.04-.08-96.21-.16v-14.2h20.5c0-3.08.08-35.73,0-36.67,0-5.68.32-8.99,4.18-13.25,4.65-4.5,8.52-6.55,15.06-6.78,2.05,0,4.1-.08,6.15,0,3.63.08,7.26-.16,10.96,0v-33.28l-.08-.24Z"/>
<path class="cls-17" d="M214.81,622.51c4.18,1.81,14.2,5.6,15.06,5.91,19.4,7.33,42.43,7.41,61.67-.63,3.39-1.58,6.7-3.23,10.02-4.97,10.88-5.44,23.26-6.55,34.86-2.84,3.63,1.26,7.18,2.68,10.73,4.26,13.96,5.91,30.76,6.15,44.95,1.1,3.08-1.26,11.83-5.78,13.41-6.31.63,5.26.32,8.96-.95,11.12-9.7,9.15-24.05,12.93-37.07,12.78-9.78-.32-17.82-3-26.81-6.86-12.07-5.13-22.16-5.99-34.62-1.1-3.23,1.34-6.39,2.76-9.54,4.18-4.81,2.13-16.9,5.68-17.9,5.99-5.68,1.26-18.61,1.03-19.24,1.03-9.7,0-29.42-3.79-30.05-4.02-5.91-1.81-11.51-4.1-17.11-6.78-11.28-5.36-22.16-4.65-33.91-.63-1.74.79-3.47,1.58-5.21,2.44-12.7,6.07-29.26,7.18-42.67,2.52-4.89-1.89-14.83-7.97-15.38-8.2-.89-.58-1.92-1.6-3.08-3.08-.39-3.15-.24-6.23,0-9.46,2.84,1.1,5.52,2.29,8.28,3.55,14.83,6.7,30.36,8.2,46.14,3.55,3.15-1.26,6.15-2.68,9.15-4.26,12.3-5.84,26.89-4.73,39.12.55l.16.16Z"/>
<path class="cls-15" d="M241.31,511.23c1.5,2.29,1.03,13.56,0,15.69-3.08,3-24.5,1.21-25.71,0-1.74-2.84-.63-14.8,0-15.69,2.21-1.5,24.76-.68,25.71,0Z"/>
<path class="cls-15" d="M285.42,511.23c1.5,2.29,1.03,13.56,0,15.69-3.08,3-24.5,1.21-25.71,0-1.74-2.84-.63-14.8,0-15.69,2.21-1.5,24.76-.68,25.71,0Z"/>
</g>
<g id="_Слой_6" data-name="Слой_6">
<g>
<path class="cls-3" d="M431.23,700.66l22.72,175.86h-40.75l-4.69-43.22h-22.23l-6.67,43.22h-34.58l29.89-175.86h56.31ZM405.54,806.62l-7.16-78.3h-.49l-7.16,78.3h14.82Z"/>
<path class="cls-3" d="M491.5,876.52v-175.86h41v175.86h-41Z"/>
<path class="cls-3" d="M635.25,763.65v-24.7c0-8.65-2.72-13.58-10.87-13.58-8.89,0-10.87,4.94-10.87,13.58,0,29.64,62.74,38.28,62.74,92.62,0,33.1-17.78,48.41-52.12,48.41-26.18,0-51.62-8.89-51.62-39.27v-32.6h41v30.38c0,10.37,3.21,13.34,10.87,13.34,6.67,0,10.87-2.96,10.87-13.34,0-39.77-62.74-40.51-62.74-97.32,0-31.86,21-43.96,52.61-43.96,27.66,0,51.13,9.39,51.13,38.78v27.66h-41Z"/>
</g>
</g>
<g id="_Слой_8" data-name="Слой_8">
<g id="_Слой_7-2" data-name="_Слой_7">
<path class="cls-7" d="M418.41,308.67l-5,1.22-4.73,6.85s2.65,1.83,2.66,1.84c.01.01,2.95,2.04,2.97,2.05,1.58-2.28,3.16-4.57,4.73-6.85l-.63-5.11Z"/>
</g>
<g>
<path class="cls-5" d="M410.61,320.62c-.46.6-.97,1.25-1.55,1.96"/>
<path class="cls-4" d="M405.87,326.2c-2.11,2.24-4.69,4.7-7.78,7.14-10,7.88-15.36,7.7-29.35,16.24-12.46,7.61-18.69,11.42-20.15,18-1.32,5.94,1.49,10.94-1.96,18.39-.53,1.15-1.13,2.18-1.74,3.09"/>
<path class="cls-5" d="M343.44,390.99c-.61.73-1.19,1.34-1.7,1.83"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

-72
View File
@@ -1,72 +0,0 @@
import java.util.regex.Pattern;
import java.util.regex.Matcher;
public class TestAISDecoding {
public static void main(String[] args) {
TestAISDecoding test = new TestAISDecoding();
// Тестируем AIS сообщение типа 1
String ais1 = "17RdG7V04d039I5wwj2kh30d050l";
System.out.println("=== Тест AIS типа 1 ===");
test.testDecodeAISField(ais1, 8, 30, "MMSI");
test.testDecodeAISField(ais1, 38, 4, "Navigation Status");
test.testDecodeAISField(ais1, 50, 10, "Speed");
test.testDecodeAISField(ais1, 61, 28, "Longitude");
test.testDecodeAISField(ais1, 89, 27, "Latitude");
test.testDecodeAISField(ais1, 116, 12, "Course");
// Тестируем AIS сообщение типа 5 (собранное из фрагментов)
String ais5 = "57RdG7T1M>wh4U?62204U?62222222222222220R:0D5?1Uf4<Q1APEC588888888888882";
System.out.println("\n=== Тест AIS типа 5 ===");
test.testDecodeAISField(ais5, 8, 30, "MMSI");
test.testDecodeAISField(ais5, 38, 2, "AIS Version");
test.testDecodeAISField(ais5, 40, 30, "IMO");
test.testDecodeAISField(ais5, 70, 42, "Call Sign");
test.testDecodeAISField(ais5, 112, 120, "Vessel Name");
test.testDecodeAISField(ais5, 232, 8, "Ship Type");
test.testDecodeAISField(ais5, 256, 10, "Length");
test.testDecodeAISField(ais5, 266, 10, "Width");
test.testDecodeAISField(ais5, 276, 8, "Draft");
}
private void testDecodeAISField(String payload, int startBit, int length, String fieldName) {
String bits = decodeAISField(payload, startBit, length);
System.out.printf("%s (биты %d-%d): %s (длина: %d)%n",
fieldName, startBit, startBit + length - 1, bits, bits.length());
if (length <= 30) {
try {
int value = Integer.parseInt(bits, 2);
System.out.printf(" Десятичное значение: %d%n", value);
} catch (NumberFormatException e) {
System.out.printf(" Ошибка парсинга: %s%n", e.getMessage());
}
}
}
private String decodeAISField(String payload, int startBit, int length) {
StringBuilder result = new StringBuilder();
// Преобразуем каждый символ payload в 6-битное значение
for (int i = 0; i < payload.length(); i++) {
char c = payload.charAt(i);
int value = c - 48; // AIS использует ASCII 48-119 для значений 0-71
if (value < 0) value += 64;
String binary = String.format("%6s", Integer.toBinaryString(value)).replace(' ', '0');
result.append(binary);
}
String fullBinary = result.toString();
// Вырезаем нужный диапазон битов
if (startBit + length <= fullBinary.length()) {
return fullBinary.substring(startBit, startBit + length);
} else {
System.out.printf(" ВНИМАНИЕ: AIS поле выходит за границы: startBit=%d, length=%d, payloadLength=%d, binaryLength=%d%n",
startBit, length, payload.length(), fullBinary.length());
return fullBinary.substring(startBit, Math.min(startBit + length, fullBinary.length()));
}
}
}