closd TG-6; Initial push after server migration
@@ -4,7 +4,7 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<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">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=bc722e5b" />
|
||||
|
||||
@@ -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!** 🎉
|
||||
@@ -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 при ошибке
|
||||
@@ -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}}
|
||||
```
|
||||
|
||||
@@ -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 данные не теряются!** 🎉
|
||||
@@ -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 системы и богатство морских навигационных данных.
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 работает правильно.
|
||||
@@ -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 операций, максимум батчинга
|
||||
@@ -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 (компас)
|
||||
```
|
||||
|
||||
**Все проблемы после рефакторинга исправлены! Приложение готово к использованию!** 🎉
|
||||
@@ -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 успешно разбит на специализированные контроллеры с четким разделением ответственностей!** 🎯
|
||||
@@ -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
|
||||
|
||||
🎉 **Рефакторинг успешно завершен!**
|
||||
@@ -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 обновлений
|
||||
@@ -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 и улучшить общую производительность приложения.
|
||||
|
||||
@@ -6,6 +6,10 @@ android {
|
||||
namespace 'com.grigowashere.aismap'
|
||||
compileSdk 35
|
||||
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.grigowashere.aismap"
|
||||
minSdk 30
|
||||
@@ -56,6 +60,10 @@ dependencies {
|
||||
// MapLibre GL Android SDK (используем только один артефакт, без плагина аннотаций)
|
||||
implementation group: 'org.maplibre.gl', name: 'android-sdk-opengl', version: '11.13.5'
|
||||
|
||||
// MessagePack — компактная бинарная сериализация для BLE Hub снапшотов
|
||||
// (см. ble_gatt.py: AIS_BLE_BROADCAST_ENCODING=msgpack)
|
||||
implementation 'org.msgpack:msgpack-core:0.9.8'
|
||||
|
||||
// Тестирование
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
|
||||
@@ -20,6 +20,13 @@
|
||||
|
||||
<!-- Разрешения для UDP -->
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<!-- BLE permissions -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
|
||||
<!-- Разрешения для вибрации -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
@@ -32,7 +39,9 @@
|
||||
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.location" android:required="false" />
|
||||
|
||||
<application
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
@@ -47,7 +56,7 @@
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:theme="@style/Theme.AISMap"
|
||||
android:theme="@style/Theme.AISMap.Map"
|
||||
android:keepScreenOn="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -60,6 +69,12 @@
|
||||
android:exported="false"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:theme="@style/Theme.AISMap" />
|
||||
|
||||
<activity
|
||||
android:name=".settings.InterfacesSettingsActivity"
|
||||
android:exported="false"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:theme="@style/Theme.AISMap" />
|
||||
|
||||
<activity
|
||||
android:name=".AisTargetsActivity"
|
||||
|
||||
|
After Width: | Height: | Size: 31 KiB |
@@ -22,6 +22,10 @@ import android.view.WindowManager;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog;
|
||||
|
||||
import com.grigowashere.aismap.controllers.AppCoordinator;
|
||||
@@ -64,6 +68,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
// Статическая переменная для отслеживания инициализации Яндекс.Карт
|
||||
private static boolean isYandexMapsInitialized = false;
|
||||
|
||||
// Флаг для отслеживания первого запуска приложения
|
||||
private boolean isFirstStart = true;
|
||||
|
||||
private AppCoordinator appCoordinator;
|
||||
// UI binders
|
||||
private MenuBinder menuBinder;
|
||||
@@ -80,6 +87,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
private ImageButton btnCursorToggle;
|
||||
private ImageButton btnSettings;
|
||||
private ImageButton btnAisTargets;
|
||||
private ImageButton btnGpsSource;
|
||||
private LinearLayout controlPanel;
|
||||
private CompassView compassView;
|
||||
private CoordinatesDockWidget coordinatesWidget;
|
||||
@@ -95,6 +103,36 @@ public class MainActivity extends AppCompatActivity {
|
||||
private static final long UI_UPDATE_THROTTLE_MS = 200; // 5 FPS максимум
|
||||
private TextView tvGpsAge;
|
||||
private TextView tvAisAge;
|
||||
private TextView tvBleRssi;
|
||||
private TextView tvBleBatt;
|
||||
private TextView tvFps;
|
||||
private int frameCount = 0;
|
||||
private long lastFpsTs = 0L;
|
||||
private final android.view.Choreographer.FrameCallback fpsCallback = new android.view.Choreographer.FrameCallback() {
|
||||
@Override public void doFrame(long frameTimeNanos) {
|
||||
// UI heartbeat: если кадры идут, UI точно жив.
|
||||
updateUIActivity();
|
||||
frameCount++;
|
||||
long now = System.currentTimeMillis();
|
||||
if (lastFpsTs == 0L) lastFpsTs = now;
|
||||
if (now - lastFpsTs >= 1000) {
|
||||
final int fps = frameCount;
|
||||
frameCount = 0;
|
||||
lastFpsTs = now;
|
||||
runOnUiThread(() -> {
|
||||
if (tvFps != null) {
|
||||
tvFps.setText("FPS: " + fps);
|
||||
int color;
|
||||
if (fps >= 55) color = android.graphics.Color.parseColor("#4CAF50");
|
||||
else if (fps >= 40) color = android.graphics.Color.parseColor("#FFC107");
|
||||
else color = android.graphics.Color.parseColor("#F44336");
|
||||
tvFps.setTextColor(color);
|
||||
}
|
||||
});
|
||||
}
|
||||
android.view.Choreographer.getInstance().postFrameCallback(this);
|
||||
}
|
||||
};
|
||||
private android.os.Handler messageAgeHandler;
|
||||
private Runnable messageAgeRunnable;
|
||||
private BottomSheetsManager bottomSheetsManager;
|
||||
@@ -119,6 +157,10 @@ public class MainActivity extends AppCompatActivity {
|
||||
private long lastUIUpdateTime = 0;
|
||||
private static final long UI_WATCHDOG_INTERVAL = 1000; // 1 секунда - быстрая диагностика
|
||||
private static final long UI_TIMEOUT = 3000; // 3 секунды без обновлений = зависание
|
||||
private final java.util.concurrent.ScheduledExecutorService uiWatchdogScheduler =
|
||||
java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||
private volatile long lastUiPongUptimeMs = 0L;
|
||||
private volatile long lastUiHangLogUptimeMs = 0L;
|
||||
|
||||
// Диагностика компаса
|
||||
private long lastCompassLogTime = 0;
|
||||
@@ -144,7 +186,18 @@ public class MainActivity extends AppCompatActivity {
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка инициализации MapLibre: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
|
||||
// Edge-to-edge: приложение само раскладывает UI под статус/нав-барами
|
||||
// и вырезами камеры. Без этого WindowInsets будут давать нули
|
||||
// и координатная панель уедет под системную навигационную кнопку.
|
||||
try {
|
||||
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||
getWindow().setStatusBarColor(android.graphics.Color.TRANSPARENT);
|
||||
getWindow().setNavigationBarColor(android.graphics.Color.TRANSPARENT);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Не удалось включить edge-to-edge: " + e.getMessage());
|
||||
}
|
||||
|
||||
setContentView(R.layout.activity_main);
|
||||
|
||||
initializeViews();
|
||||
@@ -165,9 +218,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
btnCursorToggle = findViewById(R.id.btn_cursor_toggle);
|
||||
btnSettings = findViewById(R.id.btn_settings);
|
||||
btnAisTargets = findViewById(R.id.btn_ais_targets);
|
||||
btnGpsSource = findViewById(R.id.btn_gps_source);
|
||||
controlPanel = findViewById(R.id.control_panel);
|
||||
compassView = findViewById(R.id.compass_view);
|
||||
coordinatesWidget = findViewById(R.id.coordinates_widget);
|
||||
installMainUiInsets();
|
||||
|
||||
// Инициализируем троттлинг
|
||||
uiThrottleHandler = new android.os.Handler(android.os.Looper.getMainLooper());
|
||||
@@ -206,6 +261,10 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// В режимах «по компасу» / «по курсу» непрерывно подстраиваем bearing
|
||||
// карты; в «вручную» не трогаем — пользователь крутит жестом.
|
||||
applyAutoMapBearingIfNeeded(mapIf);
|
||||
}
|
||||
} catch (Exception ignore) {}
|
||||
// Планируем следующее обновление
|
||||
@@ -215,6 +274,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
};
|
||||
tvGpsAge = findViewById(R.id.tv_gps_age);
|
||||
tvAisAge = findViewById(R.id.tv_ais_age);
|
||||
tvBleRssi = findViewById(R.id.tv_ble_rssi);
|
||||
tvBleBatt = findViewById(R.id.tv_ble_batt);
|
||||
tvFps = findViewById(R.id.tv_fps);
|
||||
|
||||
// Инициализируем магнитный компас через CompassController
|
||||
// compassSensor = new CompassSensor(this); // Удалено - теперь используется CompassController
|
||||
@@ -231,11 +293,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private void setupButtonListeners() {
|
||||
if (btnCenterOnVessel != null) btnCenterOnVessel.setOnClickListener(v -> centerOnVessel());
|
||||
if (btnMapOrientation != null) btnMapOrientation.setOnClickListener(v -> toggleMapOrientation());
|
||||
if (btnMapOrientation != null) {
|
||||
btnMapOrientation.setOnClickListener(v -> cycleMapRotationMode());
|
||||
}
|
||||
if (btnCursorToggle != null) btnCursorToggle.setOnClickListener(v -> toggleCursor());
|
||||
if (btnCursorToggle != null) btnCursorToggle.setOnLongClickListener(v -> { toggleCursor(); return true; });
|
||||
if (btnSettings != null) btnSettings.setOnClickListener(v -> showSettings());
|
||||
if (btnAisTargets != null) btnAisTargets.setOnClickListener(v -> openAisTargets());
|
||||
if (btnGpsSource != null) {
|
||||
refreshGpsSourceButtonIcon();
|
||||
btnGpsSource.setOnClickListener(v -> toggleGpsSource());
|
||||
}
|
||||
|
||||
// Кнопка для показа информации о судне
|
||||
// Button btnShowVesselInfo = findViewById(R.id.btn_show_vessel_info);
|
||||
@@ -252,6 +320,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
compassView.post(() -> {
|
||||
compassView.setDocked(true, true, 0, 0);
|
||||
compassView.invalidate(); // Принудительная отрисовка
|
||||
// Выровнять паддинги под статус-бар/вырез камеры сразу после
|
||||
// первого dock-позиционирования (до этого сторона неизвестна).
|
||||
reapplyInsetsToDocks();
|
||||
});
|
||||
|
||||
// Настраиваем слушатель изменения размера док-виджета
|
||||
@@ -269,6 +340,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
BaseDockWidget.repositionAllDockedWidgets((ViewGroup) compassView.getParent());
|
||||
|
||||
updateControlPanelPosition();
|
||||
// Док мог поменять сторону — паддинги под системные бары
|
||||
// тоже должны переключиться (top <-> bottom).
|
||||
reapplyInsetsToDocks();
|
||||
});
|
||||
//smt changed
|
||||
// Настраиваем магнитный компас через CompassController
|
||||
@@ -362,6 +436,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
BaseDockWidget.repositionAllDockedWidgets((ViewGroup) coordinatesWidget.getParent());
|
||||
|
||||
updateControlPanelPosition();
|
||||
// Перекидываем системные паддинги в нужную сторону под новую
|
||||
// дока — чтобы под нав-баром/брови ничего не оставалось.
|
||||
reapplyInsetsToDocks();
|
||||
});
|
||||
|
||||
// Устанавливаем виджет координат в dock-режим внизу экрана без тестовых данных
|
||||
@@ -369,6 +446,10 @@ public class MainActivity extends AppCompatActivity {
|
||||
Log.d(TAG, "Setting coordinates widget to dock mode");
|
||||
coordinatesWidget.setDocked(true, false, 0, 0); // false = dock снизу
|
||||
coordinatesWidget.invalidate(); // Принудительная отрисовка
|
||||
// Только сейчас мы знаем сторону дока (bottom) — переприменяем
|
||||
// инсеты, чтобы виджет получил bottom padding под нав-бар
|
||||
// сразу, а не только после первого ресайза пользователем.
|
||||
reapplyInsetsToDocks();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -390,6 +471,20 @@ public class MainActivity extends AppCompatActivity {
|
||||
tvAisAge.setText(aisSec >= 0 ? ("AIS: " + aisSec + " сек назад") : "AIS: --");
|
||||
tvAisAge.setTextColor(getAgeColor(aisSec));
|
||||
}
|
||||
if (tvBleRssi != null) {
|
||||
Integer rssi = appCoordinator.getLastBleRssi();
|
||||
if (rssi != null) {
|
||||
tvBleRssi.setText("BLE RSSI: " + rssi);
|
||||
tvBleRssi.setTextColor(getRssiColor(rssi));
|
||||
} else {
|
||||
tvBleRssi.setText("BLE RSSI: --");
|
||||
tvBleRssi.setTextColor(android.graphics.Color.parseColor("#F44336"));
|
||||
}
|
||||
}
|
||||
if (tvBleBatt != null) {
|
||||
Integer batt = appCoordinator.getLastBleBattery();
|
||||
tvBleBatt.setText(batt != null ? ("BLE Batt: " + batt + "%") : "BLE Batt: --");
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
messageAgeHandler.postDelayed(this, 1000);
|
||||
@@ -412,6 +507,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
return Color.parseColor("#F44336"); // красный
|
||||
}
|
||||
}
|
||||
|
||||
private int getRssiColor(int rssi) {
|
||||
// Типичные пороги: >= -60 dBm (сильный) — зелёный; >= -80 dBm (средний) — жёлтый; иначе — красный
|
||||
if (rssi >= -60) {
|
||||
return android.graphics.Color.parseColor("#4CAF50");
|
||||
} else if (rssi >= -80) {
|
||||
return android.graphics.Color.parseColor("#FFC107");
|
||||
} else {
|
||||
return android.graphics.Color.parseColor("#F44336");
|
||||
}
|
||||
}
|
||||
|
||||
private void onUpdateCompass(float azimuth, List<AISVessel> nearbyVessels) {
|
||||
if (compassView != null) {
|
||||
@@ -474,42 +580,50 @@ public class MainActivity extends AppCompatActivity {
|
||||
* Настраивает UI watchdog для отслеживания зависаний
|
||||
*/
|
||||
private void setupUIWatchdog() {
|
||||
// ВАЖНО: watchdog не должен работать на UI Looper, иначе он не может детектить настоящий hang.
|
||||
// Поэтому тикер в фоне, а "pong" — маленькая задачка на UI.
|
||||
uiWatchdogHandler = new android.os.Handler(android.os.Looper.getMainLooper());
|
||||
uiWatchdogRunnable = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long timeSinceLastUpdate = currentTime - lastUIUpdateTime;
|
||||
|
||||
if (timeSinceLastUpdate > UI_TIMEOUT) {
|
||||
Log.e(TAG, "🚨 UI WATCHDOG: UI ЗАВИС! Последнее обновление " +
|
||||
(timeSinceLastUpdate / 1000) + " секунд назад");
|
||||
Log.e(TAG, "🚨 UI WATCHDOG: Время зависания: " + new java.util.Date(currentTime));
|
||||
Log.e(TAG, "🚨 UI WATCHDOG: Thread: " + Thread.currentThread().getName());
|
||||
// Дамп стека главного потока и нескольких рабочих потоков
|
||||
dumpThreadStacksForDiagnostics();
|
||||
|
||||
// Попытка восстановления
|
||||
tryRecoverFromUIHang();
|
||||
} else {
|
||||
// Логируем каждые 10 секунд для мониторинга
|
||||
if (timeSinceLastUpdate > 0 && (timeSinceLastUpdate / 1000) % 10 == 0) {
|
||||
Log.i(TAG, "✅ UI WATCHDOG: UI активен, последнее обновление " +
|
||||
(timeSinceLastUpdate / 1000) + " секунд назад");
|
||||
lastUIUpdateTime = System.currentTimeMillis();
|
||||
lastUiPongUptimeMs = android.os.SystemClock.uptimeMillis();
|
||||
|
||||
// Заглушка для обратной совместимости (на него ссылаются логи/tryRecoverFromUIHang)
|
||||
uiWatchdogRunnable = () -> {};
|
||||
|
||||
try {
|
||||
uiWatchdogScheduler.scheduleAtFixedRate(() -> {
|
||||
final long pingUptime = android.os.SystemClock.uptimeMillis();
|
||||
try {
|
||||
uiWatchdogHandler.post(() -> {
|
||||
// "pong": если это исполнилось — UI Looper жив.
|
||||
lastUiPongUptimeMs = android.os.SystemClock.uptimeMillis();
|
||||
updateUIActivity();
|
||||
});
|
||||
} catch (Throwable ignore) {}
|
||||
|
||||
long sincePong = pingUptime - lastUiPongUptimeMs;
|
||||
if (sincePong > UI_TIMEOUT) {
|
||||
// Не спамим логом каждую секунду.
|
||||
if (pingUptime - lastUiHangLogUptimeMs > 10_000L) {
|
||||
lastUiHangLogUptimeMs = pingUptime;
|
||||
Log.e(TAG, "🚨 UI WATCHDOG: UI возможно завис (main looper не отвечает) " +
|
||||
(sincePong / 1000) + " секунд");
|
||||
dumpThreadStacksForDiagnosticsAsync();
|
||||
// Recovery — только если UI хоть как-то отвечает (иначе бесполезно)
|
||||
tryRecoverFromUIHang();
|
||||
}
|
||||
}
|
||||
|
||||
// Планируем следующую проверку
|
||||
uiWatchdogHandler.postDelayed(this, UI_WATCHDOG_INTERVAL);
|
||||
}
|
||||
};
|
||||
|
||||
// Запускаем watchdog
|
||||
lastUIUpdateTime = System.currentTimeMillis();
|
||||
uiWatchdogHandler.postDelayed(uiWatchdogRunnable, UI_WATCHDOG_INTERVAL);
|
||||
Log.i(TAG, "UI watchdog запущен");
|
||||
}, UI_WATCHDOG_INTERVAL, UI_WATCHDOG_INTERVAL, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "UI watchdog: не удалось запустить scheduler: " + t.getMessage(), t);
|
||||
}
|
||||
|
||||
Log.i(TAG, "UI watchdog запущен (background)");
|
||||
}
|
||||
|
||||
private final java.util.concurrent.ExecutorService watchdogExecutor =
|
||||
java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
private volatile long lastRecoveryAttemptMs = 0L;
|
||||
|
||||
/**
|
||||
* Обновляет время последней активности UI
|
||||
*/
|
||||
@@ -531,6 +645,14 @@ public class MainActivity extends AppCompatActivity {
|
||||
Log.w(TAG, "UI WATCHDOG: Попытка восстановления...");
|
||||
|
||||
try {
|
||||
long now = System.currentTimeMillis();
|
||||
// Не долбим восстановлением каждую секунду — это само может стать причиной лагов
|
||||
if (now - lastRecoveryAttemptMs < 10_000L) {
|
||||
Log.i(TAG, "UI WATCHDOG: восстановление пропущено (throttle)");
|
||||
return;
|
||||
}
|
||||
lastRecoveryAttemptMs = now;
|
||||
|
||||
// Диагностика: проверяем состояние handler'ов
|
||||
boolean watchdogActive = uiWatchdogHandler != null && uiWatchdogRunnable != null;
|
||||
boolean messageAgeActive = messageAgeHandler != null && messageAgeRunnable != null;
|
||||
@@ -544,11 +666,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
", controlPanel=" + controlPanelActive +
|
||||
", controlPanelCount=" + controlPanelUpdateCount);
|
||||
|
||||
// Принудительная сборка мусора
|
||||
System.gc();
|
||||
// ВАЖНО: никаких тяжёлых операций (System.gc) на UI-потоке.
|
||||
// Если нужно, можно поставить фоновой GC после лагов, но это диагностическая функция,
|
||||
// а не recovery, поэтому здесь намеренно ничего не делаем.
|
||||
|
||||
// Проверяем состояние основных компонентов
|
||||
if (mapController.getCurrentMapInterface() == null) {
|
||||
if (mapController != null && mapController.getCurrentMapInterface() == null) {
|
||||
Log.w(TAG, "UI WATCHDOG: mapInterface == null, переинициализируем карту");
|
||||
// Можно попробовать переинициализировать карту
|
||||
}
|
||||
@@ -580,7 +703,15 @@ public class MainActivity extends AppCompatActivity {
|
||||
/**
|
||||
* Диагностический дамп стеков главного и рабочих потоков
|
||||
*/
|
||||
private void dumpThreadStacksForDiagnostics() {
|
||||
private void dumpThreadStacksForDiagnosticsAsync() {
|
||||
try {
|
||||
watchdogExecutor.execute(this::dumpThreadStacksForDiagnosticsBlocking);
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "UI WATCHDOG: не удалось запустить дамп стеков: " + t.getMessage(), t);
|
||||
}
|
||||
}
|
||||
|
||||
private void dumpThreadStacksForDiagnosticsBlocking() {
|
||||
try {
|
||||
java.util.Map<Thread, StackTraceElement[]> all = Thread.getAllStackTraces();
|
||||
Thread main = Looper.getMainLooper().getThread();
|
||||
@@ -675,6 +806,32 @@ public class MainActivity extends AppCompatActivity {
|
||||
Log.i(TAG, "Режим экрана переключен: keepScreenOn=" + keepScreenOn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключает отображение морских знаков OpenSeaMap
|
||||
*/
|
||||
public void toggleSeamarks() {
|
||||
if (settingsManager == null) {
|
||||
Log.w(TAG, "toggleSeamarks: settingsManager is null");
|
||||
return;
|
||||
}
|
||||
|
||||
boolean currentState = settingsManager.isSeamarksEnabled();
|
||||
boolean newState = !currentState;
|
||||
|
||||
// Сохраняем настройку
|
||||
settingsManager.setSeamarksEnabled(newState);
|
||||
|
||||
// Применяем изменения на карте
|
||||
if (mapController.getCurrentMapInterface() instanceof MapLibreMapImpl) {
|
||||
((MapLibreMapImpl) mapController.getCurrentMapInterface()).updateAdditionalLayers();
|
||||
}
|
||||
|
||||
String message = newState ? "Морские знаки включены" : "Морские знаки выключены";
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
|
||||
|
||||
Log.i(TAG, "Морские знаки переключены: enabled=" + newState);
|
||||
}
|
||||
|
||||
private void initializeControllers() {
|
||||
// Инициализация менеджера настроек
|
||||
settingsManager = new SettingsManager(this);
|
||||
@@ -694,6 +851,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
@Override public void togglePathTracking() { MainActivity.this.togglePathTracking(); }
|
||||
@Override public void testForegroundService() { MainActivity.this.testForegroundService(); }
|
||||
@Override public void toggleKeepScreenOn() { MainActivity.this.toggleKeepScreenOn(); }
|
||||
@Override public void toggleSeamarks() { MainActivity.this.toggleSeamarks(); }
|
||||
});
|
||||
// Не используем BottomSheetsBinder, оставляем рабочую реализацию в MainActivity
|
||||
permissionsBinder = new PermissionsBinder(this);
|
||||
@@ -751,6 +909,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
});
|
||||
refreshMapRotationButtonDescription();
|
||||
}
|
||||
|
||||
private void startControllers() {
|
||||
@@ -851,20 +1010,106 @@ public class MainActivity extends AppCompatActivity {
|
||||
Toast.makeText(this, "Карта центрирована на судне", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
private void toggleMapOrientation() {
|
||||
if (mapController.getCurrentMapInterface() == null) return;
|
||||
private static float normalizeBearingTo360(double deg) {
|
||||
double x = deg % 360.0;
|
||||
if (x < 0) x += 360.0;
|
||||
return (float) x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Три режима: по магнитному компасу, по курсу (COG), вручную
|
||||
* (север вверх при переключении, дальше — только жесты пользователя).
|
||||
* Кнопка циклически переключает: компас → курс → вручную → …
|
||||
*/
|
||||
private void cycleMapRotationMode() {
|
||||
if (settingsManager == null) return;
|
||||
String mode = settingsManager.cycleMapRotationMode();
|
||||
refreshMapRotationButtonDescription();
|
||||
if (mapController == null || mapController.getCurrentMapInterface() == null) {
|
||||
Toast.makeText(this, "Режим карты сохранён — применится после загрузки карты",
|
||||
Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
float current = mapController.getCurrentMapInterface().getBearing();
|
||||
// Простейший toggle: если близко к северу — повернуть на 45°, иначе выровнять по северу
|
||||
if (Math.abs(current) < 1f) {
|
||||
mapController.getCurrentMapInterface().setBearing(45f);
|
||||
Toast.makeText(this, "Ориентация: произвольная (45°)", Toast.LENGTH_SHORT).show();
|
||||
} else {
|
||||
mapController.getCurrentMapInterface().setBearing(0f);
|
||||
Toast.makeText(this, "Ориентация: север вверх", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
MapInterface map = mapController.getCurrentMapInterface();
|
||||
applyMapRotationForMode(map, mode, true);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "toggleMapOrientation error: " + e.getMessage());
|
||||
Log.w(TAG, "cycleMapRotationMode: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void applyMapRotationForMode(MapInterface map, String mode, boolean showShortToast) {
|
||||
if (map == null || mode == null) return;
|
||||
if (SettingsManager.MAP_ROTATION_MANUAL.equals(mode)) {
|
||||
map.setBearing(0f);
|
||||
if (showShortToast) {
|
||||
Toast.makeText(this, "Карта: вручную (север вверх, дальше — жестом)",
|
||||
Toast.LENGTH_LONG).show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (appCoordinator == null) return;
|
||||
Vessel own = appCoordinator.getOwnVessel();
|
||||
if (own == null) {
|
||||
if (showShortToast) {
|
||||
Toast.makeText(this, "Нет данных собственного судна", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (SettingsManager.MAP_ROTATION_COMPASS.equals(mode)) {
|
||||
float b = normalizeBearingTo360(own.getMagneticCompass());
|
||||
map.setBearing(b);
|
||||
if (showShortToast) {
|
||||
Toast.makeText(this,
|
||||
String.format(java.util.Locale.US, "По компасу (%.0f°)", b),
|
||||
Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (SettingsManager.MAP_ROTATION_COURSE.equals(mode)) {
|
||||
if (Double.isNaN(own.getCourse())) {
|
||||
if (showShortToast) {
|
||||
Toast.makeText(this, "Пока нет курса (COG)", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
float b = normalizeBearingTo360(own.getCourse());
|
||||
map.setBearing(b);
|
||||
if (showShortToast) {
|
||||
Toast.makeText(this,
|
||||
String.format(java.util.Locale.US, "По курсу COG (%.0f°)", b),
|
||||
Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void applyAutoMapBearingIfNeeded(MapInterface map) {
|
||||
if (settingsManager == null || appCoordinator == null || map == null) return;
|
||||
String mode = settingsManager.getMapRotationMode();
|
||||
if (SettingsManager.MAP_ROTATION_MANUAL.equals(mode)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Vessel own = appCoordinator.getOwnVessel();
|
||||
if (own == null) return;
|
||||
if (SettingsManager.MAP_ROTATION_COMPASS.equals(mode)) {
|
||||
map.setBearing(normalizeBearingTo360(own.getMagneticCompass()));
|
||||
} else if (SettingsManager.MAP_ROTATION_COURSE.equals(mode)
|
||||
&& !Double.isNaN(own.getCourse())) {
|
||||
map.setBearing(normalizeBearingTo360(own.getCourse()));
|
||||
}
|
||||
} catch (Exception ignore) {}
|
||||
}
|
||||
|
||||
private void refreshMapRotationButtonDescription() {
|
||||
if (btnMapOrientation == null || settingsManager == null) return;
|
||||
String m = settingsManager.getMapRotationMode();
|
||||
if (SettingsManager.MAP_ROTATION_COMPASS.equals(m)) {
|
||||
btnMapOrientation.setContentDescription("Карта по компасу (нажмите — смена режима)");
|
||||
} else if (SettingsManager.MAP_ROTATION_COURSE.equals(m)) {
|
||||
btnMapOrientation.setContentDescription("Карта по курсу COG (нажмите — смена режима)");
|
||||
} else {
|
||||
btnMapOrientation.setContentDescription("Карта вручную, север вверх (нажмите — смена режима)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -928,6 +1173,125 @@ public class MainActivity extends AppCompatActivity {
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключает источник координат между BLE Hub и Android GPS «на лету»,
|
||||
* обновляет иконку кнопки и уведомляет AppCoordinator.
|
||||
*/
|
||||
private void toggleGpsSource() {
|
||||
if (settingsManager == null) return;
|
||||
String next = settingsManager.toggleGpsSource();
|
||||
refreshGpsSourceButtonIcon();
|
||||
if (appCoordinator != null) {
|
||||
appCoordinator.applyGpsSourceChange();
|
||||
}
|
||||
String label = SettingsManager.GPS_SOURCE_HUB.equals(next)
|
||||
? "Источник: AIS Hub (BLE)"
|
||||
: "Источник: Android GPS";
|
||||
Toast.makeText(this, label, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Навешивает единый листенер WindowInsets на корень активити и рассыпает
|
||||
* рассчитанные инсеты (system bars + display cutout) по трём ключевым
|
||||
* элементам верхнего слоя: компас, координатный виджет, боковая панель
|
||||
* управления. Благодаря этому контент не прячется за статус-баром,
|
||||
* нав-баром и вырезами камеры, а фоновые прямоугольники продолжают
|
||||
* доходить до физических краёв экрана.
|
||||
*/
|
||||
private Insets lastSysInsets = Insets.NONE;
|
||||
|
||||
private void installMainUiInsets() {
|
||||
View root = findViewById(R.id.main_root);
|
||||
if (root == null) return;
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root, (v, insets) -> {
|
||||
Insets sys = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars()
|
||||
| WindowInsetsCompat.Type.displayCutout());
|
||||
lastSysInsets = sys;
|
||||
applyInsetsToDocks(sys);
|
||||
if (controlPanel != null) {
|
||||
ViewGroup.LayoutParams rawLp = controlPanel.getLayoutParams();
|
||||
if (rawLp instanceof android.widget.RelativeLayout.LayoutParams) {
|
||||
android.widget.RelativeLayout.LayoutParams lp =
|
||||
(android.widget.RelativeLayout.LayoutParams) rawLp;
|
||||
int newRight = sys.right + Math.round(getResources().getDisplayMetrics().density * 8);
|
||||
if (lp.rightMargin != newRight) {
|
||||
lp.rightMargin = newRight;
|
||||
controlPanel.setLayoutParams(lp);
|
||||
}
|
||||
}
|
||||
}
|
||||
return insets;
|
||||
});
|
||||
// На первом layout инсеты иногда ещё не выданы системой: мы выставляем
|
||||
// слушатель, но callback не приходит до prewarm. Поэтому просим
|
||||
// отложенно — через post — чтобы попасть после attach и первого layout.
|
||||
ViewCompat.requestApplyInsets(root);
|
||||
root.post(() -> ViewCompat.requestApplyInsets(root));
|
||||
root.postDelayed(() -> ViewCompat.requestApplyInsets(root), 200);
|
||||
// Маргин control_panel по вертикали пересчитываем от фактической
|
||||
// высоты доков (она уже включает системные паддинги), чтобы панель
|
||||
// никогда не наползала на компас/координаты при ресайзе.
|
||||
View.OnLayoutChangeListener relayoutControlPanel = (v2, l, t, r, b, ol, ot, orr, ob) -> {
|
||||
if (controlPanel == null) return;
|
||||
ViewGroup.LayoutParams rawLp = controlPanel.getLayoutParams();
|
||||
if (!(rawLp instanceof android.widget.RelativeLayout.LayoutParams)) return;
|
||||
android.widget.RelativeLayout.LayoutParams lp =
|
||||
(android.widget.RelativeLayout.LayoutParams) rawLp;
|
||||
int dp8 = Math.round(getResources().getDisplayMetrics().density * 8);
|
||||
int compassH = compassView != null ? compassView.getHeight() : 0;
|
||||
int coordsH = coordinatesWidget != null ? coordinatesWidget.getHeight() : 0;
|
||||
int newTop = compassH + dp8;
|
||||
int newBottom = coordsH + dp8;
|
||||
if (lp.topMargin != newTop || lp.bottomMargin != newBottom) {
|
||||
lp.topMargin = newTop;
|
||||
lp.bottomMargin = newBottom;
|
||||
controlPanel.setLayoutParams(lp);
|
||||
}
|
||||
};
|
||||
if (compassView != null) compassView.addOnLayoutChangeListener(relayoutControlPanel);
|
||||
if (coordinatesWidget != null) coordinatesWidget.addOnLayoutChangeListener(relayoutControlPanel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет системные инсеты к компасу и координатному виджету в
|
||||
* зависимости от того, к какой стороне экрана они пристыкованы.
|
||||
* Если док у верхнего края — добавляем верхний паддинг (статус-бар,
|
||||
* вырез камеры). Если у нижнего — добавляем нижний паддинг под нав-бар.
|
||||
* Боковые паддинги даём всегда (landscape-камеры).
|
||||
*/
|
||||
private void applyInsetsToDocks(Insets sys) {
|
||||
if (compassView != null) {
|
||||
boolean top = compassView.isDockTop();
|
||||
compassView.setPadding(sys.left, top ? sys.top : 0,
|
||||
sys.right, top ? 0 : sys.bottom);
|
||||
}
|
||||
if (coordinatesWidget != null) {
|
||||
boolean top = coordinatesWidget.isDockTop();
|
||||
coordinatesWidget.setPadding(sys.left, top ? sys.top : 0,
|
||||
sys.right, top ? 0 : sys.bottom);
|
||||
}
|
||||
}
|
||||
|
||||
/** Переприменяет уже собранные инсеты (вызывать при смене стороны дока). */
|
||||
private void reapplyInsetsToDocks() {
|
||||
applyInsetsToDocks(lastSysInsets);
|
||||
View root = findViewById(R.id.main_root);
|
||||
if (root != null) ViewCompat.requestApplyInsets(root);
|
||||
}
|
||||
|
||||
private void refreshGpsSourceButtonIcon() {
|
||||
if (btnGpsSource == null || settingsManager == null) return;
|
||||
int icon = settingsManager.isGpsFromHub()
|
||||
? R.drawable.ic_gps_source_hub
|
||||
: R.drawable.ic_gps_source_android;
|
||||
btnGpsSource.setImageResource(icon);
|
||||
btnGpsSource.setContentDescription(
|
||||
settingsManager.isGpsFromHub()
|
||||
? "Источник: AIS Hub"
|
||||
: "Источник: Android GPS");
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключает отображение курсора на карте и сохраняет состояние
|
||||
*/
|
||||
@@ -1108,6 +1472,65 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
// Применяем отложенное центрирование, если было
|
||||
applyPendingCenterIfAny();
|
||||
|
||||
// Старт: инициализируем ownVessel координатами устройства и центрируемся на нём
|
||||
// НО ТОЛЬКО при первом запуске приложения и если нет интента центрирования на сторонний корабль
|
||||
try {
|
||||
Intent currentIntent = getIntent();
|
||||
boolean hasExternalCenterIntent = currentIntent != null &&
|
||||
currentIntent.hasExtra("center_lat") && currentIntent.hasExtra("center_lon");
|
||||
|
||||
if (isFirstStart && !hasExternalCenterIntent && settingsManager != null && settingsManager.isStartCenterOnLastEnabled()) {
|
||||
Log.i(TAG, "Первый запуск: инициализируем ownVessel и центрируемся");
|
||||
android.location.LocationManager lm = (android.location.LocationManager) getSystemService(android.content.Context.LOCATION_SERVICE);
|
||||
android.location.Location lastLoc = null;
|
||||
if (lm != null) {
|
||||
// Пробуем GPS, затем NETWORK
|
||||
try { lastLoc = lm.getLastKnownLocation(android.location.LocationManager.GPS_PROVIDER); } catch (Exception ignore) {}
|
||||
if (lastLoc == null) {
|
||||
try { lastLoc = lm.getLastKnownLocation(android.location.LocationManager.NETWORK_PROVIDER); } catch (Exception ignore) {}
|
||||
}
|
||||
}
|
||||
if (lastLoc != null) {
|
||||
double lat = lastLoc.getLatitude();
|
||||
double lon = lastLoc.getLongitude();
|
||||
Log.i(TAG, "Первый запуск: seed ownVessel из Android LastKnownLocation " + lat + "," + lon);
|
||||
if (appCoordinator != null) {
|
||||
appCoordinator.seedOwnVesselFromDeviceLocation(lat, lon);
|
||||
appCoordinator.centerOnOwnVessel();
|
||||
// Повторим центрирование чуть позже, когда стиль точно загрузится
|
||||
mapView.postDelayed(() -> {
|
||||
try {
|
||||
appCoordinator.centerOnOwnVessel();
|
||||
} catch (Exception ignore) {}
|
||||
}, 500);
|
||||
} else if (mapController.getCurrentMapInterface() != null) {
|
||||
// fallback
|
||||
mapController.getCurrentMapInterface().centerOnPosition(lat, lon);
|
||||
}
|
||||
float startZoom = settingsManager.getStartZoomLevel();
|
||||
if (startZoom > 0f) {
|
||||
mapController.getCurrentMapInterface().setZoom(startZoom);
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Первый запуск: LastKnownLocation отсутствует");
|
||||
}
|
||||
// Отмечаем, что первый запуск завершен
|
||||
isFirstStart = false;
|
||||
} else if (hasExternalCenterIntent) {
|
||||
Log.i(TAG, "Первый запуск с интентом центрирования на сторонний корабль - пропускаем центрирование на собственный");
|
||||
// Отмечаем, что первый запуск завершен
|
||||
isFirstStart = false;
|
||||
} else if (!isFirstStart) {
|
||||
Log.i(TAG, "Не первый запуск - пропускаем центрирование на собственный корабль");
|
||||
} else {
|
||||
Log.i(TAG, "Первый запуск, но центрирование отключено в настройках");
|
||||
// Отмечаем, что первый запуск завершен
|
||||
isFirstStart = false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка стартовой инициализации позиции/центрирования: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
// Отслеживание путей для MapLibre будет добавлено позже
|
||||
|
||||
@@ -1149,6 +1572,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
// Перезапускаем цикл поворота кнопок после возврата в активити
|
||||
startCompassButtonsLoop();
|
||||
|
||||
// Старт FPS трекера
|
||||
if (tvFps != null) {
|
||||
android.view.Choreographer.getInstance().removeFrameCallback(fpsCallback);
|
||||
android.view.Choreographer.getInstance().postFrameCallback(fpsCallback);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1233,6 +1662,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
uiThrottleHandler.removeCallbacks(coordinatesUpdateRunnable);
|
||||
uiThrottleHandler.removeCallbacks(compassButtonRotationRunnable);
|
||||
}
|
||||
|
||||
// Останавливаем FPS трекер
|
||||
try { android.view.Choreographer.getInstance().removeFrameCallback(fpsCallback); } catch (Exception ignore) {}
|
||||
|
||||
// Не останавливаем слушатели здесь, чтобы UDP продолжал работать в фоне
|
||||
// if (appController != null) {
|
||||
@@ -1275,6 +1707,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
uiWatchdogHandler.removeCallbacks(uiWatchdogRunnable);
|
||||
Log.i(TAG, "UI watchdog остановлен");
|
||||
}
|
||||
try { uiWatchdogScheduler.shutdownNow(); } catch (Throwable ignore) {}
|
||||
|
||||
// Останавливаем throttling handler для control panel
|
||||
if (controlPanelUpdateHandler != null && controlPanelUpdateRunnable != null) {
|
||||
@@ -1385,6 +1818,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
((MapLibreMapImpl) mapController.getCurrentMapInterface()).setDebugMode(debugEnabled);
|
||||
}
|
||||
|
||||
// Применяем настройки морских знаков
|
||||
boolean seamarksEnabled = data.getBooleanExtra("seamarks_enabled", settingsManager.isSeamarksEnabled());
|
||||
if (mapController.getCurrentMapInterface() instanceof MapLibreMapImpl) {
|
||||
((MapLibreMapImpl) mapController.getCurrentMapInterface()).updateAdditionalLayers();
|
||||
}
|
||||
|
||||
if (needsRestart) {
|
||||
Log.i(TAG, "Требуется перезапуск сервисов");
|
||||
restartServices();
|
||||
@@ -1392,7 +1831,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
Log.i(TAG, "Применяем настройки без перезапуска");
|
||||
applySettings();
|
||||
}
|
||||
|
||||
refreshGpsSourceButtonIcon();
|
||||
|
||||
Toast.makeText(this, "Настройки применены", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
@@ -1442,6 +1882,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
} else if (id == R.id.menu_keep_screen_on) {
|
||||
toggleKeepScreenOn();
|
||||
return true;
|
||||
} else if (id == R.id.menu_seamarks) {
|
||||
toggleSeamarks();
|
||||
return true;
|
||||
}
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import android.widget.EditText;
|
||||
import android.widget.RadioButton;
|
||||
import android.widget.RadioGroup;
|
||||
import android.widget.Toast;
|
||||
import android.view.View;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial;
|
||||
|
||||
@@ -33,15 +35,24 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
private RadioButton radioHybridMode;
|
||||
private RadioButton radioNMEAOnly;
|
||||
private RadioButton radioAndroidOnly;
|
||||
private RadioGroup radioGroupGpsSource;
|
||||
private RadioButton radioGpsSourceHub;
|
||||
private RadioButton radioGpsSourceAndroid;
|
||||
private SwitchMaterial switchShowAdvancedNmea;
|
||||
private LinearLayout llAdvancedNmeaSection;
|
||||
private EditText etStaleWarningMinutes;
|
||||
private EditText etStaleRemoveMinutes;
|
||||
private SwitchMaterial switchVibrationEnabled;
|
||||
private SwitchMaterial switchSoundEnabled;
|
||||
private SwitchMaterial switchKeepScreenOn;
|
||||
private SwitchMaterial switchDebugEnabled;
|
||||
private SwitchMaterial switchSeamarksEnabled;
|
||||
private Button btnCancel;
|
||||
private Button btnSave;
|
||||
private Button btnClearPath;
|
||||
private Button btnOpenInterfaces;
|
||||
private com.google.android.material.textfield.TextInputLayout tilOpenInterfaces;
|
||||
private EditText etOpenInterfaces;
|
||||
|
||||
// Path/prediction
|
||||
private EditText etPathMaxPoints;
|
||||
@@ -92,19 +103,29 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
*/
|
||||
private void initializeViews() {
|
||||
etUDPPort = findViewById(R.id.et_udp_port);
|
||||
switchUDPEnabled = findViewById(R.id.switch_udp_enabled);
|
||||
// UDP элементы перенесены на экран интерфейсов; здесь найдём только кнопку перехода
|
||||
// Кнопка могла быть удалена из разметки: не инициализируем её по id
|
||||
tilOpenInterfaces = findViewById(R.id.til_open_interfaces);
|
||||
etOpenInterfaces = findViewById(R.id.et_open_interfaces);
|
||||
switchUDPEnabled = findViewById(R.id.switch_udp_enabled); // может отсутствовать в новой разметке
|
||||
switchAndroidNMEAEnabled = findViewById(R.id.switch_android_nmea_enabled);
|
||||
switchUDPNMEAEnabled = findViewById(R.id.switch_udp_nmea_enabled);
|
||||
radioGroupDataMode = findViewById(R.id.radio_group_data_mode);
|
||||
radioHybridMode = findViewById(R.id.radio_hybrid_mode);
|
||||
radioNMEAOnly = findViewById(R.id.radio_nmea_only);
|
||||
radioAndroidOnly = findViewById(R.id.radio_android_only);
|
||||
radioGroupGpsSource = findViewById(R.id.radio_group_gps_source);
|
||||
radioGpsSourceHub = findViewById(R.id.radio_gps_source_hub);
|
||||
radioGpsSourceAndroid = findViewById(R.id.radio_gps_source_android);
|
||||
switchShowAdvancedNmea = findViewById(R.id.switch_show_advanced_nmea);
|
||||
llAdvancedNmeaSection = findViewById(R.id.ll_advanced_nmea_section);
|
||||
etStaleWarningMinutes = findViewById(R.id.et_stale_warning_minutes);
|
||||
etStaleRemoveMinutes = findViewById(R.id.et_stale_remove_minutes);
|
||||
switchVibrationEnabled = findViewById(R.id.switch_vibration_enabled);
|
||||
switchSoundEnabled = findViewById(R.id.switch_sound_enabled);
|
||||
switchKeepScreenOn = findViewById(R.id.switch_keep_screen_on);
|
||||
switchDebugEnabled = findViewById(R.id.switch_debug_enabled);
|
||||
switchSeamarksEnabled = findViewById(R.id.switch_seamarks_enabled);
|
||||
btnCancel = findViewById(R.id.btn_cancel);
|
||||
btnSave = findViewById(R.id.btn_save);
|
||||
btnClearPath = findViewById(R.id.btn_clear_path);
|
||||
@@ -122,14 +143,23 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
*/
|
||||
private void loadCurrentSettings() {
|
||||
// UDP настройки
|
||||
etUDPPort.setText(String.valueOf(settingsManager.getUDPPort()));
|
||||
switchUDPEnabled.setChecked(settingsManager.isUDPEnabled());
|
||||
if (etUDPPort != null) etUDPPort.setText(String.valueOf(settingsManager.getUDPPort()));
|
||||
if (switchUDPEnabled != null) switchUDPEnabled.setChecked(settingsManager.isUDPEnabled());
|
||||
|
||||
// NMEA настройки
|
||||
switchAndroidNMEAEnabled.setChecked(settingsManager.isAndroidNMEAEnabled());
|
||||
switchUDPNMEAEnabled.setChecked(settingsManager.isUDPNMEAEnabled());
|
||||
|
||||
// Режим данных
|
||||
// Источник координат (основной переключатель).
|
||||
if (radioGpsSourceHub != null && radioGpsSourceAndroid != null) {
|
||||
if (settingsManager.isGpsFromAndroid()) {
|
||||
radioGpsSourceAndroid.setChecked(true);
|
||||
} else {
|
||||
radioGpsSourceHub.setChecked(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy режим данных (внутри расширенной секции).
|
||||
String dataMode = settingsManager.getDataMode();
|
||||
switch (dataMode) {
|
||||
case SettingsManager.DATA_MODE_HYBRID:
|
||||
@@ -156,6 +186,7 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
|
||||
// Дебаг
|
||||
switchDebugEnabled.setChecked(settingsManager.isDebugEnabled());
|
||||
switchSeamarksEnabled.setChecked(settingsManager.isSeamarksEnabled());
|
||||
|
||||
// Путь и предсказание
|
||||
etPathMaxPoints.setText(String.valueOf(settingsManager.getPathMaxPoints()));
|
||||
@@ -195,6 +226,22 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
Log.i(TAG, "Нажата кнопка отмены");
|
||||
finish();
|
||||
});
|
||||
if (btnOpenInterfaces != null) {
|
||||
btnOpenInterfaces.setOnClickListener(v -> openInterfacesSettings());
|
||||
}
|
||||
if (tilOpenInterfaces != null) {
|
||||
tilOpenInterfaces.setEndIconOnClickListener(v -> openInterfacesSettings());
|
||||
tilOpenInterfaces.setOnClickListener(v -> openInterfacesSettings());
|
||||
}
|
||||
if (etOpenInterfaces != null) {
|
||||
etOpenInterfaces.setOnClickListener(v -> openInterfacesSettings());
|
||||
}
|
||||
|
||||
// Секция "Расширенные NMEA-источники" скрыта по умолчанию и разворачивается по свитчу.
|
||||
if (switchShowAdvancedNmea != null && llAdvancedNmeaSection != null) {
|
||||
switchShowAdvancedNmea.setOnCheckedChangeListener((btn, checked) ->
|
||||
llAdvancedNmeaSection.setVisibility(checked ? View.VISIBLE : View.GONE));
|
||||
}
|
||||
|
||||
// Кнопка сохранения
|
||||
btnSave.setOnClickListener(v -> {
|
||||
@@ -222,6 +269,16 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
validateDataModeSettings();
|
||||
});
|
||||
}
|
||||
|
||||
private void openInterfacesSettings() {
|
||||
try {
|
||||
Intent i = new Intent(SettingsActivity.this, com.grigowashere.aismap.settings.InterfacesSettingsActivity.class);
|
||||
startActivity(i);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка открытия настроек интерфейсов: " + e.getMessage());
|
||||
Toast.makeText(SettingsActivity.this, "Не удалось открыть интерфейсы", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет описание режима данных
|
||||
@@ -262,23 +319,27 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
*/
|
||||
private void saveSettings() {
|
||||
try {
|
||||
// Валидируем UDP порт
|
||||
String portText = etUDPPort.getText().toString().trim();
|
||||
if (portText.isEmpty()) {
|
||||
Toast.makeText(this, "Порт не может быть пустым", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
// Валидируем UDP порт (поле могло быть перенесено на экран интерфейсов и отсутствовать в разметке)
|
||||
int udpPort;
|
||||
try {
|
||||
udpPort = Integer.parseInt(portText);
|
||||
if (udpPort < 1 || udpPort > 65535) {
|
||||
Toast.makeText(this, "Порт должен быть от 1 до 65535", Toast.LENGTH_SHORT).show();
|
||||
if (etUDPPort != null) {
|
||||
String portText = etUDPPort.getText().toString().trim();
|
||||
if (portText.isEmpty()) {
|
||||
Toast.makeText(this, "Порт не может быть пустым", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
Toast.makeText(this, "Некорректный формат порта", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
try {
|
||||
udpPort = Integer.parseInt(portText);
|
||||
if (udpPort < 1 || udpPort > 65535) {
|
||||
Toast.makeText(this, "Порт должен быть от 1 до 65535", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
Toast.makeText(this, "Некорректный формат порта", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Если поля нет на этом экране — используем текущее сохранённое значение
|
||||
udpPort = settingsManager.getUDPPort();
|
||||
}
|
||||
|
||||
// Получаем выбранный режим данных
|
||||
@@ -303,11 +364,20 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
// Сохраняем настройки
|
||||
settingsManager.setUDPPort(udpPort);
|
||||
settingsManager.setUDPEnabled(switchUDPEnabled.isChecked());
|
||||
if (etUDPPort != null) settingsManager.setUDPPort(udpPort);
|
||||
if (switchUDPEnabled != null) settingsManager.setUDPEnabled(switchUDPEnabled.isChecked());
|
||||
settingsManager.setAndroidNMEAEnabled(switchAndroidNMEAEnabled.isChecked());
|
||||
settingsManager.setUDPNMEAEnabled(switchUDPNMEAEnabled.isChecked());
|
||||
settingsManager.setDataMode(dataMode);
|
||||
// Источник координат (независим от legacy dataMode).
|
||||
if (radioGroupGpsSource != null) {
|
||||
int checkedGps = radioGroupGpsSource.getCheckedRadioButtonId();
|
||||
if (checkedGps == R.id.radio_gps_source_android) {
|
||||
settingsManager.setGpsSource(SettingsManager.GPS_SOURCE_ANDROID);
|
||||
} else {
|
||||
settingsManager.setGpsSource(SettingsManager.GPS_SOURCE_HUB);
|
||||
}
|
||||
}
|
||||
settingsManager.setDataStaleWarningMinutes(staleWarningMinutes);
|
||||
settingsManager.setDataStaleRemoveMinutes(staleRemoveMinutes);
|
||||
settingsManager.setVibrationEnabled(switchVibrationEnabled.isChecked());
|
||||
@@ -315,6 +385,10 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
settingsManager.setKeepScreenOnEnabled(switchKeepScreenOn.isChecked());
|
||||
boolean debugEnabled = switchDebugEnabled.isChecked();
|
||||
settingsManager.setDebugEnabled(debugEnabled);
|
||||
|
||||
// Морские знаки
|
||||
boolean seamarksEnabled = switchSeamarksEnabled.isChecked();
|
||||
settingsManager.setSeamarksEnabled(seamarksEnabled);
|
||||
|
||||
// Путь и предсказание
|
||||
try { settingsManager.setPathMaxPoints(Integer.parseInt(etPathMaxPoints.getText().toString().trim())); } catch (Exception ignored) {}
|
||||
@@ -334,12 +408,14 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
resultIntent.putExtra("settings_changed", true);
|
||||
resultIntent.putExtra("needs_restart", needsRestart);
|
||||
resultIntent.putExtra("udp_port", udpPort);
|
||||
resultIntent.putExtra("udp_enabled", switchUDPEnabled.isChecked());
|
||||
boolean udpEnabledVal = (switchUDPEnabled != null) ? switchUDPEnabled.isChecked() : settingsManager.isUDPEnabled();
|
||||
resultIntent.putExtra("udp_enabled", udpEnabledVal);
|
||||
resultIntent.putExtra("android_nmea_enabled", switchAndroidNMEAEnabled.isChecked());
|
||||
resultIntent.putExtra("udp_nmea_enabled", switchUDPNMEAEnabled.isChecked());
|
||||
resultIntent.putExtra("data_mode", dataMode);
|
||||
resultIntent.putExtra("cursor_enabled", settingsManager.isCursorEnabled());
|
||||
resultIntent.putExtra("debug_enabled", debugEnabled);
|
||||
resultIntent.putExtra("seamarks_enabled", seamarksEnabled);
|
||||
|
||||
setResult(RESULT_OK, resultIntent);
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.grigowashere.aismap.ble.hub;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Reassembles chunked logical messages keyed by (session_msg_id, msg_type).
|
||||
*
|
||||
* Payload may be JSON or MessagePack depending on the protocol-version byte of
|
||||
* the incoming frames (see {@link AisHubConstants#PROTO_VERSION_JSON} /
|
||||
* {@link AisHubConstants#PROTO_VERSION_MSGPACK}). The assembler stores the
|
||||
* version of the first frame of an assembly and returns it to the caller when
|
||||
* the message is complete; the caller then chooses the right decoder.
|
||||
*/
|
||||
public class AisHubChunkAssembler {
|
||||
|
||||
public static final class FeedStatus {
|
||||
/**
|
||||
* Non-null only when the whole message is complete.
|
||||
* Historically this was a JSON string; it's kept for backward
|
||||
* compatibility with callers that only ever saw JSON on the wire.
|
||||
* When the assembly's proto version is MessagePack, {@link #json} is
|
||||
* {@code null} even at completion — use {@link #payload} +
|
||||
* {@link #protocolVersion} instead.
|
||||
*/
|
||||
public final String json;
|
||||
/**
|
||||
* Full reassembled payload bytes (always non-null when the message is
|
||||
* complete, regardless of encoding). Callers that support both JSON
|
||||
* and MessagePack should decode from this field and ignore
|
||||
* {@link #json}.
|
||||
*/
|
||||
public final byte[] payload;
|
||||
/** Protocol-version byte of the reassembled message (matches {@link AisHubConstants}). */
|
||||
public final int protocolVersion;
|
||||
public final int received;
|
||||
public final int chunkCount;
|
||||
/** True if we detected mismatch and reset assembly state. */
|
||||
public final boolean wasReset;
|
||||
|
||||
FeedStatus(String json, byte[] payload, int protocolVersion,
|
||||
int received, int chunkCount, boolean wasReset) {
|
||||
this.json = json;
|
||||
this.payload = payload;
|
||||
this.protocolVersion = protocolVersion;
|
||||
this.received = received;
|
||||
this.chunkCount = chunkCount;
|
||||
this.wasReset = wasReset;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Key {
|
||||
final int sessionId;
|
||||
final int msgType;
|
||||
|
||||
Key(int sessionId, int msgType) {
|
||||
this.sessionId = sessionId;
|
||||
this.msgType = msgType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof Key)) return false;
|
||||
Key key = (Key) o;
|
||||
return sessionId == key.sessionId && msgType == key.msgType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return 31 * sessionId + msgType;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Assembly {
|
||||
final int chunkCount;
|
||||
final byte[][] parts;
|
||||
int received;
|
||||
// Protocol version taken from the first frame of this assembly.
|
||||
// We pin it so that a rogue frame with a mismatched version cannot
|
||||
// silently change how we decode the combined payload.
|
||||
int protocolVersion = -1;
|
||||
|
||||
Assembly(int chunkCount) {
|
||||
this.chunkCount = chunkCount;
|
||||
this.parts = new byte[chunkCount][];
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<Key, Assembly> pending = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Feed one frame; returns complete UTF-8 JSON string or null if more chunks needed.
|
||||
* <p>
|
||||
* <b>Deprecated for mixed encodings:</b> MessagePack payloads will return
|
||||
* {@code null} here even when the message is complete. Use
|
||||
* {@link #feedStatus(AisHubFrame)} and inspect
|
||||
* {@link FeedStatus#payload} + {@link FeedStatus#protocolVersion} instead.
|
||||
*/
|
||||
public String feed(AisHubFrame frame) {
|
||||
FeedStatus st = feedStatus(frame);
|
||||
return st != null ? st.json : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link #feed(AisHubFrame)}, but also returns progress info for logging
|
||||
* and the raw reassembled payload + protocol version on completion.
|
||||
*/
|
||||
public FeedStatus feedStatus(AisHubFrame frame) {
|
||||
if (frame.chunkCount <= 0 || frame.chunkIndex < 0 || frame.chunkIndex >= frame.chunkCount) {
|
||||
return new FeedStatus(null, null, frame.protocolVersion, 0, frame.chunkCount, false);
|
||||
}
|
||||
Key key = new Key(frame.sessionMsgId, frame.msgType);
|
||||
Assembly a = pending.get(key);
|
||||
boolean wasReset = false;
|
||||
if (a == null) {
|
||||
a = new Assembly(frame.chunkCount);
|
||||
a.protocolVersion = frame.protocolVersion;
|
||||
pending.put(key, a);
|
||||
} else if (a.chunkCount != frame.chunkCount || a.protocolVersion != frame.protocolVersion) {
|
||||
// Either the server changed its mind about the chunk count (unlikely
|
||||
// but defensively handled) or the encoding — treat as a brand new
|
||||
// assembly. Can happen if a previous message was lost and we're now
|
||||
// seeing the start of the next one under the same (sid, msg_type).
|
||||
pending.remove(key);
|
||||
a = new Assembly(frame.chunkCount);
|
||||
a.protocolVersion = frame.protocolVersion;
|
||||
pending.put(key, a);
|
||||
wasReset = true;
|
||||
}
|
||||
if (a.parts[frame.chunkIndex] == null) {
|
||||
a.received++;
|
||||
}
|
||||
a.parts[frame.chunkIndex] = Arrays.copyOf(frame.payload, frame.payload.length);
|
||||
if (a.received < a.chunkCount) {
|
||||
return new FeedStatus(null, null, a.protocolVersion, a.received, a.chunkCount, wasReset);
|
||||
}
|
||||
int total = 0;
|
||||
for (byte[] p : a.parts) {
|
||||
if (p != null) total += p.length;
|
||||
}
|
||||
byte[] out = new byte[total];
|
||||
int pos = 0;
|
||||
for (byte[] p : a.parts) {
|
||||
if (p != null) {
|
||||
System.arraycopy(p, 0, out, pos, p.length);
|
||||
pos += p.length;
|
||||
}
|
||||
}
|
||||
pending.remove(key);
|
||||
// Build the JSON string only for legacy JSON payloads, to keep old
|
||||
// callers (that read FeedStatus.json directly) working as-is.
|
||||
String jsonStr = (a.protocolVersion == AisHubConstants.PROTO_VERSION_JSON)
|
||||
? new String(out, StandardCharsets.UTF_8)
|
||||
: null;
|
||||
return new FeedStatus(jsonStr, out, a.protocolVersion, a.received, a.chunkCount, wasReset);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
pending.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.grigowashere.aismap.ble.hub;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* BLE AIS Hub (protocol v2) — UUID and message type constants.
|
||||
*/
|
||||
public final class AisHubConstants {
|
||||
|
||||
private AisHubConstants() {}
|
||||
|
||||
public static final UUID SERVICE_UUID =
|
||||
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0001");
|
||||
public static final UUID CONTROL_UUID =
|
||||
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0002");
|
||||
public static final UUID DATA_UUID =
|
||||
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0003");
|
||||
public static final UUID STATUS_UUID =
|
||||
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0004");
|
||||
|
||||
public static final int HEADER_SIZE = 10;
|
||||
|
||||
/**
|
||||
* Protocol version byte values (first byte of every DATA frame header).
|
||||
* The server writes this per-frame based on the encoding it chose for
|
||||
* that message (negotiated via the "hello" command, or from the
|
||||
* AIS_BLE_BROADCAST_ENCODING env for CCCD broadcast).
|
||||
*/
|
||||
public static final int PROTO_VERSION_JSON = 0x01;
|
||||
public static final int PROTO_VERSION_MSGPACK = 0x02;
|
||||
|
||||
/** Server → client DATA msg_type */
|
||||
public static final int MSG_HELLO_ACK = 0x01;
|
||||
public static final int MSG_SNAPSHOT_BEGIN = 0x02;
|
||||
public static final int MSG_SNAPSHOT_CHUNK = 0x03;
|
||||
public static final int MSG_SNAPSHOT_END = 0x04;
|
||||
public static final int MSG_EVENT = 0x05;
|
||||
public static final int MSG_STATUS = 0x06;
|
||||
public static final int MSG_ERROR = 0x07;
|
||||
public static final int MSG_PONG = 0x08;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.grigowashere.aismap.ble.hub;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
|
||||
/**
|
||||
* One DATA notify frame: 10-byte header + payload.
|
||||
*/
|
||||
public class AisHubFrame {
|
||||
public final int protocolVersion;
|
||||
public final int msgType;
|
||||
public final int sessionMsgId;
|
||||
public final int chunkIndex;
|
||||
public final int chunkCount;
|
||||
public final byte[] payload;
|
||||
|
||||
public AisHubFrame(int protocolVersion, int msgType, int sessionMsgId,
|
||||
int chunkIndex, int chunkCount, byte[] payload) {
|
||||
this.protocolVersion = protocolVersion;
|
||||
this.msgType = msgType;
|
||||
this.sessionMsgId = sessionMsgId;
|
||||
this.chunkIndex = chunkIndex;
|
||||
this.chunkCount = chunkCount;
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null if buffer too short or truncated
|
||||
*/
|
||||
public static AisHubFrame parse(byte[] buf) {
|
||||
if (buf == null || buf.length < AisHubConstants.HEADER_SIZE) {
|
||||
return null;
|
||||
}
|
||||
ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
|
||||
int pv = bb.get() & 0xFF;
|
||||
int mt = bb.get() & 0xFF;
|
||||
int sid = bb.getShort() & 0xFFFF;
|
||||
int cidx = bb.getShort() & 0xFFFF;
|
||||
int ccnt = bb.getShort() & 0xFFFF;
|
||||
int plen = bb.getShort() & 0xFFFF;
|
||||
if (buf.length < AisHubConstants.HEADER_SIZE + plen) {
|
||||
return null;
|
||||
}
|
||||
byte[] payload = new byte[plen];
|
||||
System.arraycopy(buf, AisHubConstants.HEADER_SIZE, payload, 0, plen);
|
||||
return new AisHubFrame(pv, mt, sid, cidx, ccnt, payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
package com.grigowashere.aismap.ble.hub;
|
||||
|
||||
import android.bluetooth.BluetoothAdapter;
|
||||
import android.bluetooth.BluetoothDevice;
|
||||
import android.bluetooth.BluetoothGatt;
|
||||
import android.bluetooth.BluetoothGattCallback;
|
||||
import android.bluetooth.BluetoothGattCharacteristic;
|
||||
import android.bluetooth.BluetoothGattDescriptor;
|
||||
import android.bluetooth.BluetoothGattService;
|
||||
import android.bluetooth.BluetoothManager;
|
||||
import android.bluetooth.BluetoothProfile;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import com.grigowashere.aismap.BuildConfig;
|
||||
import com.grigowashere.aismap.utils.LogSender;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* GATT client for AIS Hub protocol v2: CONTROL writes (JSON), DATA binary frames, optional battery/RSSI.
|
||||
*/
|
||||
public class AisHubGattClient {
|
||||
|
||||
private static final String TAG = "AisHubGattClient";
|
||||
private static final boolean BLE_LOG = BuildConfig.DEBUG;
|
||||
private static final UUID CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
|
||||
private static final UUID BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb");
|
||||
private static final UUID BATTERY_LEVEL = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb");
|
||||
|
||||
public interface SessionCallback {
|
||||
void onState(@NonNull String state);
|
||||
void onError(@NonNull String message);
|
||||
void onRssi(int rssi);
|
||||
void onBatteryPercent(int percent);
|
||||
/** Reassembled JSON from DATA, after chunk merge (per msg_type in protocol). */
|
||||
void onDataJson(int msgType, @NonNull JSONObject json);
|
||||
}
|
||||
|
||||
private final Context appContext;
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
// executor: used ONLY for short-lived, non-blocking tasks (attemptConnect body).
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
// dataExecutor: dedicated to processDataRaw so that BLE notifications
|
||||
// are never blocked behind long-running loops (RSSI, reconnect, watchdog).
|
||||
// Using single-thread to preserve in-order chunk assembly semantics.
|
||||
private final ExecutorService dataExecutor = Executors.newSingleThreadExecutor();
|
||||
// scheduler: replaces Thread.sleep-based loops (RSSI, watchdog, reconnect backoff)
|
||||
// so they don't hog the general executor and starve other tasks.
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
private final AisHubChunkAssembler assembler = new AisHubChunkAssembler();
|
||||
private final AtomicBoolean gattBusy = new AtomicBoolean(false);
|
||||
private final AtomicBoolean isConnecting = new AtomicBoolean(false);
|
||||
private final AtomicBoolean rssiLoop = new AtomicBoolean(false);
|
||||
private final AtomicBoolean batteryLoop = new AtomicBoolean(false);
|
||||
private final AtomicBoolean reconnectLoop = new AtomicBoolean(false);
|
||||
private final AtomicBoolean notifReady = new AtomicBoolean(false);
|
||||
private final AtomicBoolean mtuRequested = new AtomicBoolean(false);
|
||||
private final AtomicBoolean snapshotRequested = new AtomicBoolean(false);
|
||||
private final AtomicBoolean subscribeRequested = new AtomicBoolean(false);
|
||||
private final ArrayBlockingQueue<byte[]> controlQueue = new ArrayBlockingQueue<>(32);
|
||||
|
||||
private BluetoothAdapter adapter;
|
||||
private volatile BluetoothGatt gatt;
|
||||
private volatile String deviceMac;
|
||||
private volatile boolean connected;
|
||||
private volatile long connectionStartTimeMs;
|
||||
private volatile long lastDataAtMs;
|
||||
private volatile boolean lastErrorWasDbFull;
|
||||
|
||||
private volatile BluetoothGattCharacteristic controlChar;
|
||||
private volatile BluetoothGattCharacteristic dataChar;
|
||||
|
||||
private SessionCallback callback;
|
||||
private volatile UUID cachedBatterySvc;
|
||||
private volatile UUID cachedBatteryChar;
|
||||
|
||||
private final ScheduledExecutorService batteryScheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
private volatile ScheduledFuture<?> batteryTask;
|
||||
|
||||
private static final long CONNECTION_TIMEOUT_MS = 30_000L;
|
||||
private static final long RECONNECT_DELAY_MS = 5_000L;
|
||||
private static final long RECONNECT_DELAY_DB_FULL_MS = 15_000L;
|
||||
private static final long SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS = 300_000L;
|
||||
private static final long SNAPSHOT_RECOVERY_IDLE_MS = 10_000L;
|
||||
|
||||
public AisHubGattClient(@NonNull Context context) {
|
||||
this.appContext = context.getApplicationContext();
|
||||
BluetoothManager bm = (BluetoothManager) appContext.getSystemService(Context.BLUETOOTH_SERVICE);
|
||||
this.adapter = bm != null ? bm.getAdapter() : null;
|
||||
}
|
||||
|
||||
public void setCallback(@Nullable SessionCallback callback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
public void setDeviceMac(String mac) {
|
||||
this.deviceMac = mac;
|
||||
}
|
||||
|
||||
public boolean isRunning() {
|
||||
return running.get();
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (running.get()) {
|
||||
Log.w(TAG, "AIS Hub GATT already running");
|
||||
return;
|
||||
}
|
||||
if (adapter == null || !adapter.isEnabled()) {
|
||||
postError("Bluetooth is off or unavailable");
|
||||
return;
|
||||
}
|
||||
if (deviceMac == null || deviceMac.isEmpty()) {
|
||||
postError("BLE device MAC not set");
|
||||
return;
|
||||
}
|
||||
running.set(true);
|
||||
assembler.clear();
|
||||
if (BLE_LOG) Log.d(TAG, "start(): mac=" + deviceMac);
|
||||
startReconnectLoop();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (BLE_LOG) Log.d(TAG, "stop()");
|
||||
running.set(false);
|
||||
reconnectLoop.set(false);
|
||||
rssiLoop.set(false);
|
||||
batteryLoop.set(false);
|
||||
if (batteryTask != null) {
|
||||
try { batteryTask.cancel(true); } catch (Throwable ignore) {}
|
||||
}
|
||||
if (rssiTask != null) {
|
||||
try { rssiTask.cancel(true); } catch (Throwable ignore) {}
|
||||
}
|
||||
notifReady.set(false);
|
||||
controlChar = null;
|
||||
dataChar = null;
|
||||
mainHandler.removeCallbacksAndMessages(null);
|
||||
try {
|
||||
if (gatt != null) {
|
||||
gatt.disconnect();
|
||||
gatt.close();
|
||||
}
|
||||
} catch (Throwable ignore) {}
|
||||
gatt = null;
|
||||
connected = false;
|
||||
postState("stopped");
|
||||
}
|
||||
|
||||
// --- GATT callback ---
|
||||
|
||||
private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
|
||||
@Override
|
||||
public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
|
||||
if (!running.get()) return;
|
||||
if (BLE_LOG) Log.d(TAG, "onConnectionStateChange: status=" + status + " newState=" + newState);
|
||||
if (status != BluetoothGatt.GATT_SUCCESS && status != 4 && status != 133) {
|
||||
postError("BLE connect status: " + status);
|
||||
} else if (status == 133) {
|
||||
lastErrorWasDbFull = true;
|
||||
isConnecting.set(false);
|
||||
try {
|
||||
if (g != null) {
|
||||
g.disconnect();
|
||||
g.close();
|
||||
}
|
||||
} catch (Throwable ignore) {}
|
||||
gatt = null;
|
||||
connectionStartTimeMs = 0L;
|
||||
} else if (status == 4) {
|
||||
isConnecting.set(false);
|
||||
}
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
postState("connected");
|
||||
connected = true;
|
||||
connectionStartTimeMs = 0L;
|
||||
isConnecting.set(false);
|
||||
lastErrorWasDbFull = false;
|
||||
reconnectLoop.set(false);
|
||||
notifReady.set(false);
|
||||
mtuRequested.set(false);
|
||||
snapshotRequested.set(false);
|
||||
subscribeRequested.set(false);
|
||||
controlChar = null;
|
||||
dataChar = null;
|
||||
// NOTE: BluetoothGatt.refresh() is hidden API and frequently destabilizes connections
|
||||
// on some vendor stacks. Prefer stability over stale cache here.
|
||||
scheduler.schedule(() -> {
|
||||
if (g != null && running.get() && gatt == g) {
|
||||
// Помогаем линк-слою: выше приоритет соединения.
|
||||
try { g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH); } catch (Throwable ignore) {}
|
||||
boolean ok = false;
|
||||
try { ok = g.discoverServices(); } catch (Throwable ignore) {}
|
||||
if (BLE_LOG) Log.d(TAG, "discoverServices(): " + ok);
|
||||
}
|
||||
}, 200, TimeUnit.MILLISECONDS);
|
||||
// На некоторых стеках (особенно после refresh/MTU) service discovery может "зависнуть"
|
||||
// без callback'а. Если так — мягко перезапускаем discovery и, при необходимости, reconnect.
|
||||
scheduler.schedule(() -> {
|
||||
if (!running.get()) return;
|
||||
if (g == null || gatt != g) return;
|
||||
if (!connected) return;
|
||||
if (controlChar != null && dataChar != null) return;
|
||||
if (BLE_LOG) Log.w(TAG, "Services discovery watchdog: no hub chars yet, retry discoverServices()");
|
||||
boolean ok = false;
|
||||
try { ok = g.discoverServices(); } catch (Throwable ignore) {}
|
||||
if (BLE_LOG) Log.d(TAG, "discoverServices() retry: " + ok);
|
||||
}, 6, TimeUnit.SECONDS);
|
||||
scheduler.schedule(() -> {
|
||||
if (!running.get()) return;
|
||||
if (g == null || gatt != g) return;
|
||||
if (!connected) return;
|
||||
if (controlChar != null && dataChar != null) return;
|
||||
postError("Service discovery timeout (no hub chars)");
|
||||
try { g.disconnect(); } catch (Throwable ignore) {}
|
||||
try { g.close(); } catch (Throwable ignore) {}
|
||||
gatt = null;
|
||||
connected = false;
|
||||
isConnecting.set(false);
|
||||
startReconnectLoop();
|
||||
}, 12, TimeUnit.SECONDS);
|
||||
startRssiLoop();
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
postState("disconnected");
|
||||
notifReady.set(false);
|
||||
controlChar = null;
|
||||
dataChar = null;
|
||||
mtuRequested.set(false);
|
||||
snapshotRequested.set(false);
|
||||
subscribeRequested.set(false);
|
||||
rssiLoop.set(false);
|
||||
batteryLoop.set(false);
|
||||
if (rssiTask != null) {
|
||||
try { rssiTask.cancel(false); } catch (Throwable ignore) {}
|
||||
rssiTask = null;
|
||||
}
|
||||
try { if (gatt != null) gatt.close(); } catch (Throwable ignore) {}
|
||||
gatt = null;
|
||||
connected = false;
|
||||
isConnecting.set(false);
|
||||
if (running.get()) {
|
||||
scheduler.schedule(() -> {
|
||||
if (running.get() && !connected) startReconnectLoop();
|
||||
}, 1, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServicesDiscovered(BluetoothGatt g, int status) {
|
||||
if (BLE_LOG) Log.d(TAG, "onServicesDiscovered: status=" + status);
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
postError("Service discovery failed: " + status);
|
||||
return;
|
||||
}
|
||||
if (mtuRequested.compareAndSet(false, true)) {
|
||||
boolean ok = false;
|
||||
try { ok = g.requestMtu(512); } catch (Throwable ignore) {}
|
||||
if (BLE_LOG) Log.d(TAG, "requestMtu(512): " + ok);
|
||||
}
|
||||
BluetoothGattService hub = g.getService(AisHubConstants.SERVICE_UUID);
|
||||
if (hub == null) {
|
||||
postError("AIS Hub service not found");
|
||||
return;
|
||||
}
|
||||
controlChar = hub.getCharacteristic(AisHubConstants.CONTROL_UUID);
|
||||
dataChar = hub.getCharacteristic(AisHubConstants.DATA_UUID);
|
||||
if (controlChar == null || dataChar == null) {
|
||||
postError("CONTROL or DATA characteristic missing");
|
||||
return;
|
||||
}
|
||||
if (BLE_LOG) {
|
||||
Log.d(TAG, "Hub chars ok: CONTROL=" + controlChar.getUuid() + " DATA=" + dataChar.getUuid());
|
||||
}
|
||||
boolean ok = g.setCharacteristicNotification(dataChar, true);
|
||||
if (!ok) {
|
||||
postError("Failed to enable DATA notification");
|
||||
return;
|
||||
}
|
||||
BluetoothGattDescriptor cccd = dataChar.getDescriptor(CCCD);
|
||||
if (cccd == null) {
|
||||
postError("CCCD not found for DATA");
|
||||
return;
|
||||
}
|
||||
cccd.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
|
||||
gattBusy.set(true);
|
||||
if (BLE_LOG) Log.d(TAG, "writeDescriptor(CCCD ENABLE_NOTIFICATION)");
|
||||
g.writeDescriptor(cccd);
|
||||
postState("subscribing");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor descriptor, int st) {
|
||||
gattBusy.set(false);
|
||||
if (BLE_LOG) Log.d(TAG, "onDescriptorWrite: uuid=" + descriptor.getUuid() + " status=" + st);
|
||||
if (st == BluetoothGatt.GATT_SUCCESS && CCCD.equals(descriptor.getUuid())) {
|
||||
notifReady.set(true);
|
||||
lastDataAtMs = System.currentTimeMillis();
|
||||
postState("notifying");
|
||||
try { resolveBatteryAndSchedule(g); } catch (Throwable ignore) {}
|
||||
readBatteryOnce(g);
|
||||
startBatteryLoop(g);
|
||||
enqueueControlJson(buildHello());
|
||||
|
||||
// Snapshot триггерим ТОЛЬКО на HELLO_ACK (см. processDataRaw),
|
||||
// чтобы не получить snapshot_busy от двойного запроса (постDelayed
|
||||
// здесь и обработчик HELLO_ACK раньше успевали оба).
|
||||
// Fallback: если HELLO_ACK не пришёл за 2с — всё равно дёргаем snapshot.
|
||||
mainHandler.postDelayed(() -> {
|
||||
if (!running.get() || !connected) return;
|
||||
if (!notifReady.get() || controlChar == null) return;
|
||||
if (snapshotRequested.compareAndSet(false, true)) {
|
||||
if (BLE_LOG) Log.d(TAG, "HELLO_ACK timeout fallback -> enqueue get_snapshot");
|
||||
enqueueGetSnapshot();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCharacteristicChanged(BluetoothGatt g, BluetoothGattCharacteristic ch) {
|
||||
if (dataChar != null && ch.getUuid().equals(dataChar.getUuid())) {
|
||||
// ВАЖНО: не парсим/не JSON-парсим в BLE callback потоке.
|
||||
// Иначе при потоке EVENT'ов легко перегрузить стек и получить disconnect/status=5.
|
||||
// Используем ВЫДЕЛЕННЫЙ dataExecutor, чтобы парсинг не стоял за RSSI/reconnect-петлями.
|
||||
final byte[] raw = ch.getValue();
|
||||
if (raw == null) return;
|
||||
final byte[] copy = java.util.Arrays.copyOf(raw, raw.length);
|
||||
dataExecutor.execute(() -> processDataRaw(copy));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMtuChanged(BluetoothGatt g, int mtu, int status) {
|
||||
if (BLE_LOG) Log.d(TAG, "onMtuChanged: status=" + status + " mtu=" + mtu);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCharacteristicRead(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
if (BATTERY_LEVEL.equals(ch.getUuid()) || (toShort(ch.getUuid()) != null && toShort(ch.getUuid()) == 0x2A19)) {
|
||||
byte[] v = ch.getValue();
|
||||
if (v != null && v.length > 0 && callback != null) {
|
||||
callback.onBatteryPercent(v[0] & 0xFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
gattBusy.set(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCharacteristicWrite(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
|
||||
if (controlChar != null && ch.getUuid().equals(controlChar.getUuid())) {
|
||||
gattBusy.set(false);
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.w(TAG, "CONTROL write failed: " + status);
|
||||
} else if (BLE_LOG) {
|
||||
Log.d(TAG, "CONTROL write ok (" + (ch.getValue() != null ? ch.getValue().length : -1) + " bytes)");
|
||||
}
|
||||
mainHandler.post(AisHubGattClient.this::drainControlQueue);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReadRemoteRssi(BluetoothGatt g, int rssi, int status) {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS && callback != null) {
|
||||
callback.onRssi(rssi);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private void processDataRaw(@Nullable byte[] raw) {
|
||||
if (raw == null) return;
|
||||
if (raw.length < AisHubConstants.HEADER_SIZE) {
|
||||
if (BLE_LOG) Log.w(TAG, "DATA notify too short: len=" + raw.length);
|
||||
return;
|
||||
}
|
||||
lastDataAtMs = System.currentTimeMillis();
|
||||
// Decode header fields even if payload is truncated, for diagnostics.
|
||||
int pv = raw[0] & 0xFF;
|
||||
int mt = raw[1] & 0xFF;
|
||||
int sid = ((raw[2] & 0xFF) | ((raw[3] & 0xFF) << 8));
|
||||
int cidx = ((raw[4] & 0xFF) | ((raw[5] & 0xFF) << 8));
|
||||
int ccnt = ((raw[6] & 0xFF) | ((raw[7] & 0xFF) << 8));
|
||||
int plen = ((raw[8] & 0xFF) | ((raw[9] & 0xFF) << 8));
|
||||
|
||||
if (BLE_LOG) {
|
||||
Log.d(TAG, "DATA notify: len=" + raw.length +
|
||||
" pv=" + pv +
|
||||
" msgType=" + mt + "(" + msgTypeName(mt) + ")" +
|
||||
" sid=" + sid +
|
||||
" chunk=" + cidx + "/" + ccnt +
|
||||
" plen=" + plen);
|
||||
}
|
||||
|
||||
AisHubFrame frame = AisHubFrame.parse(raw);
|
||||
if (frame == null) {
|
||||
if (BLE_LOG) Log.w(TAG, "DATA parse failed/truncated: rawLen=" + raw.length + " expected>=" + (AisHubConstants.HEADER_SIZE + plen));
|
||||
return;
|
||||
}
|
||||
|
||||
AisHubChunkAssembler.FeedStatus st = assembler.feedStatus(frame);
|
||||
if (st != null && BLE_LOG) {
|
||||
if (st.wasReset) {
|
||||
Log.w(TAG, "Assembler reset: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType + " newChunkCount=" + st.chunkCount);
|
||||
}
|
||||
if (st.payload == null) {
|
||||
Log.d(TAG, "Assembler progress: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType +
|
||||
" got=" + st.received + "/" + st.chunkCount);
|
||||
}
|
||||
}
|
||||
if (st == null || st.payload == null) return;
|
||||
try {
|
||||
JSONObject root;
|
||||
if (st.protocolVersion == AisHubConstants.PROTO_VERSION_MSGPACK) {
|
||||
root = AisHubPayloadCodec.decodeToJsonObject(st.payload, st.protocolVersion);
|
||||
if (root == null) {
|
||||
Log.w(TAG, "msgpack decode: empty/non-object payload msgType=" + frame.msgType);
|
||||
return;
|
||||
}
|
||||
if (BLE_LOG) {
|
||||
Log.d(TAG, "DATA msgpack complete: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType +
|
||||
" bytes=" + st.payload.length + " obj=" + abbreviate(root.toString(), 800));
|
||||
}
|
||||
} else {
|
||||
// JSON path keeps the original behavior (zero-copy via st.json).
|
||||
String jsonStr = st.json != null
|
||||
? st.json
|
||||
: new String(st.payload, StandardCharsets.UTF_8);
|
||||
if (BLE_LOG) Log.d(TAG, "DATA json complete: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType +
|
||||
" bytes=" + jsonStr.getBytes(StandardCharsets.UTF_8).length +
|
||||
" json=" + abbreviate(jsonStr, 800));
|
||||
root = new JSONObject(jsonStr);
|
||||
}
|
||||
// Keep our clock offset with the hub fresh from every frame that
|
||||
// carries a server timestamp. This makes stale-data math on the
|
||||
// client work correctly regardless of hub clock drift.
|
||||
double envTs = root.optDouble("ts", Double.NaN);
|
||||
if (!Double.isNaN(envTs)) {
|
||||
HubTimeSync.updateFromServerSeconds(envTs);
|
||||
} else {
|
||||
double srvTime = root.optDouble("server_time", Double.NaN);
|
||||
if (!Double.isNaN(srvTime)) HubTimeSync.updateFromServerSeconds(srvTime);
|
||||
}
|
||||
if (BLE_LOG && frame.msgType == AisHubConstants.MSG_EVENT) {
|
||||
String type = root.optString("type", "");
|
||||
JSONObject data = root.optJSONObject("data");
|
||||
// target.update/vessel-snapshot nest lat/lon inside "dynamic"; ownship.update keeps them at root.
|
||||
JSONObject src = data;
|
||||
if (data != null) {
|
||||
JSONObject d = data.optJSONObject("dynamic");
|
||||
if (d != null) src = d;
|
||||
}
|
||||
double lat = src != null ? src.optDouble("lat", Double.NaN) : Double.NaN;
|
||||
double lon = src != null ? src.optDouble("lon", Double.NaN) : Double.NaN;
|
||||
Log.d(TAG, "EVENT received: type=" + type + " lat=" + lat + " lon=" + lon);
|
||||
}
|
||||
|
||||
// Авто-старт сессии: после HELLO_ACK сразу запрашиваем snapshot (1 раз на соединение).
|
||||
if (frame.msgType == AisHubConstants.MSG_HELLO_ACK && snapshotRequested.compareAndSet(false, true)) {
|
||||
if (BLE_LOG) Log.d(TAG, "HELLO_ACK received -> enqueue get_snapshot");
|
||||
mainHandler.post(this::enqueueGetSnapshot);
|
||||
}
|
||||
|
||||
// После окончания snapshot включаем live подписки (1 раз на соединение).
|
||||
if (frame.msgType == AisHubConstants.MSG_SNAPSHOT_END && subscribeRequested.compareAndSet(false, true)) {
|
||||
boolean ok = root.optBoolean("ok", true);
|
||||
if (BLE_LOG) Log.d(TAG, "SNAPSHOT_END(ok=" + ok + ") -> enqueue subscribe");
|
||||
if (ok) {
|
||||
mainHandler.post(this::enqueueSubscribe);
|
||||
}
|
||||
}
|
||||
|
||||
if (callback != null) {
|
||||
mainHandler.post(() -> callback.onDataJson(frame.msgType, root));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "JSON parse: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// --- control writes ---
|
||||
|
||||
@NonNull
|
||||
private JSONObject buildHello() {
|
||||
JSONObject c = new JSONObject();
|
||||
try {
|
||||
c.put("cmd", "hello");
|
||||
c.put("client", "android");
|
||||
String v = "1.0";
|
||||
try {
|
||||
PackageInfo pi = appContext.getPackageManager().getPackageInfo(appContext.getPackageName(), 0);
|
||||
if (pi != null) v = pi.versionName != null ? pi.versionName : "1.0";
|
||||
} catch (Exception ignore) {}
|
||||
c.put("app_version", v);
|
||||
c.put("proto", 1);
|
||||
// Advertise encoding preferences. Server picks the first one it
|
||||
// supports; we fall back to JSON automatically if msgpack isn't
|
||||
// available server-side. msgpack is ~30-40% smaller on the wire
|
||||
// which matters for snapshot transfers over BLE.
|
||||
// Note: this only affects the AcquireNotify (per-fd) path. For the
|
||||
// CCCD broadcast path Android clients share, the server uses the
|
||||
// global AIS_BLE_BROADCAST_ENCODING env var — but our decoder
|
||||
// handles both encodings per-frame via the proto-version byte, so
|
||||
// switching the server's env has zero client-side impact.
|
||||
org.json.JSONArray encs = new org.json.JSONArray();
|
||||
encs.put("msgpack");
|
||||
encs.put("json");
|
||||
c.put("encodings", encs);
|
||||
} catch (Exception ignore) {}
|
||||
return c;
|
||||
}
|
||||
|
||||
public void enqueueGetSnapshot() {
|
||||
JSONObject c = new JSONObject();
|
||||
try {
|
||||
c.put("cmd", "get_snapshot");
|
||||
org.json.JSONArray inc = new org.json.JSONArray();
|
||||
inc.put("ownship");
|
||||
inc.put("vessels");
|
||||
inc.put("base_stations");
|
||||
inc.put("atons");
|
||||
inc.put("stats");
|
||||
c.put("include", inc);
|
||||
// Серверный хард-кап ограничивает дальше (см. AIS_BLE_SNAPSHOT_MAX_VESSELS).
|
||||
// 5000 покрывает любые реалистичные сценарии (обычно в AOI < 2000 целей).
|
||||
c.put("max_vessels", 5000);
|
||||
} catch (Exception ignore) {}
|
||||
enqueueControl(c);
|
||||
|
||||
// Recovery only: штатно live-подписка включается строго после SNAPSHOT_END.
|
||||
// Короткий таймер здесь интерливил EVENT'ы в длинный snapshot по CCCD.
|
||||
mainHandler.postDelayed(() -> {
|
||||
if (!running.get() || !connected) return;
|
||||
if (!notifReady.get() || controlChar == null) return;
|
||||
long idleMs = System.currentTimeMillis() - lastDataAtMs;
|
||||
if (idleMs < SNAPSHOT_RECOVERY_IDLE_MS) return;
|
||||
if (subscribeRequested.compareAndSet(false, true)) {
|
||||
if (BLE_LOG) Log.d(TAG, "snapshot recovery timeout -> enqueue subscribe");
|
||||
enqueueSubscribe();
|
||||
}
|
||||
}, SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
public void enqueueSubscribe() {
|
||||
JSONObject c = new JSONObject();
|
||||
try {
|
||||
c.put("cmd", "subscribe");
|
||||
org.json.JSONArray ev = new org.json.JSONArray();
|
||||
ev.put("ownship.update");
|
||||
ev.put("target.update");
|
||||
ev.put("base_station.update");
|
||||
ev.put("aton.update");
|
||||
ev.put("stats.update");
|
||||
c.put("events", ev);
|
||||
} catch (Exception ignore) {}
|
||||
enqueueControl(c);
|
||||
}
|
||||
|
||||
private void enqueueControlJson(@NonNull JSONObject cmd) {
|
||||
try {
|
||||
enqueueControl(cmd);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "enqueue: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void enqueueControl(@NonNull JSONObject json) {
|
||||
if (!notifReady.get() || controlChar == null) return;
|
||||
String s = json.toString();
|
||||
if (BLE_LOG) Log.d(TAG, "CONTROL enqueue: " + abbreviate(s, 600));
|
||||
byte[] u = s.getBytes(StandardCharsets.UTF_8);
|
||||
if (!controlQueue.offer(u)) {
|
||||
Log.w(TAG, "Control queue full");
|
||||
}
|
||||
mainHandler.post(this::drainControlQueue);
|
||||
}
|
||||
|
||||
private void drainControlQueue() {
|
||||
if (!running.get() || !notifReady.get() || gatt == null || controlChar == null) return;
|
||||
if (gattBusy.get()) return;
|
||||
byte[] next = controlQueue.poll();
|
||||
if (next == null) return;
|
||||
gattBusy.set(true);
|
||||
controlChar.setValue(next);
|
||||
controlChar.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT);
|
||||
boolean ok = gatt.writeCharacteristic(controlChar);
|
||||
if (!ok) {
|
||||
if (BLE_LOG) Log.w(TAG, "CONTROL writeCharacteristic returned false, retrying");
|
||||
gattBusy.set(false);
|
||||
mainHandler.post(this::drainControlQueue);
|
||||
}
|
||||
}
|
||||
|
||||
// --- connect / reconnect ---
|
||||
|
||||
private void startReconnectLoop() {
|
||||
if (reconnectLoop.getAndSet(true)) return;
|
||||
executor.execute(() -> {
|
||||
while (running.get() && reconnectLoop.get() && !connected) {
|
||||
try {
|
||||
if (connectionStartTimeMs > 0) {
|
||||
long el = System.currentTimeMillis() - connectionStartTimeMs;
|
||||
if (el > CONNECTION_TIMEOUT_MS) {
|
||||
isConnecting.set(false);
|
||||
try { if (gatt != null) { gatt.disconnect(); gatt.close(); } } catch (Throwable ignore) {}
|
||||
gatt = null;
|
||||
connectionStartTimeMs = 0L;
|
||||
} else {
|
||||
try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!isConnecting.compareAndSet(false, true)) {
|
||||
try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
|
||||
continue;
|
||||
}
|
||||
attemptConnect();
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "reconnect: " + t.getMessage());
|
||||
isConnecting.set(false);
|
||||
}
|
||||
long delay = lastErrorWasDbFull ? RECONNECT_DELAY_DB_FULL_MS : RECONNECT_DELAY_MS;
|
||||
try { Thread.sleep(delay); } catch (InterruptedException ignored) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void attemptConnect() {
|
||||
if (adapter == null || deviceMac == null || deviceMac.isEmpty()) {
|
||||
isConnecting.set(false);
|
||||
return;
|
||||
}
|
||||
if (gatt != null) {
|
||||
try {
|
||||
gatt.disconnect();
|
||||
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
|
||||
gatt.close();
|
||||
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
|
||||
} catch (Throwable ignore) {}
|
||||
gatt = null;
|
||||
}
|
||||
BluetoothDevice d = adapter.getRemoteDevice(deviceMac);
|
||||
if (d == null) {
|
||||
postError("BLE device not found: " + deviceMac);
|
||||
isConnecting.set(false);
|
||||
return;
|
||||
}
|
||||
if (BLE_LOG) Log.d(TAG, "attemptConnect(): " + deviceMac);
|
||||
postState("connecting");
|
||||
connectionStartTimeMs = System.currentTimeMillis();
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
gatt = d.connectGatt(appContext, false, gattCallback, BluetoothDevice.TRANSPORT_LE);
|
||||
} else {
|
||||
gatt = d.connectGatt(appContext, false, gattCallback);
|
||||
}
|
||||
if (gatt == null) isConnecting.set(false);
|
||||
} catch (Throwable t) {
|
||||
isConnecting.set(false);
|
||||
connectionStartTimeMs = 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private volatile ScheduledFuture<?> rssiTask;
|
||||
|
||||
private void startRssiLoop() {
|
||||
if (gatt == null) return;
|
||||
if (rssiLoop.getAndSet(true)) return;
|
||||
if (rssiTask != null) try { rssiTask.cancel(true); } catch (Throwable ignore) {}
|
||||
rssiTask = scheduler.scheduleAtFixedRate(() -> {
|
||||
if (!running.get() || !rssiLoop.get() || gatt == null) return;
|
||||
try { gatt.readRemoteRssi(); } catch (Throwable ignore) {}
|
||||
}, 500, 2000, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
// --- battery (same heuristics as old listener) ---
|
||||
|
||||
private void resolveBatteryAndSchedule(BluetoothGatt g) {
|
||||
BluetoothGattCharacteristic bl = findBatteryChar(g);
|
||||
if (bl != null) {
|
||||
cachedBatterySvc = bl.getService().getUuid();
|
||||
cachedBatteryChar = bl.getUuid();
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private BluetoothGattCharacteristic findBatteryChar(BluetoothGatt g) {
|
||||
if (g == null) return null;
|
||||
if (cachedBatterySvc != null && cachedBatteryChar != null) {
|
||||
BluetoothGattService s = g.getService(cachedBatterySvc);
|
||||
if (s != null) {
|
||||
BluetoothGattCharacteristic c = s.getCharacteristic(cachedBatteryChar);
|
||||
if (c != null) return c;
|
||||
}
|
||||
}
|
||||
BluetoothGattService s = g.getService(BATTERY_SERVICE);
|
||||
if (s != null) {
|
||||
BluetoothGattCharacteristic c = s.getCharacteristic(BATTERY_LEVEL);
|
||||
if (c != null) {
|
||||
cachedBatterySvc = BATTERY_SERVICE;
|
||||
cachedBatteryChar = BATTERY_LEVEL;
|
||||
return c;
|
||||
}
|
||||
}
|
||||
List<BluetoothGattService> list = g.getServices();
|
||||
if (list == null) return null;
|
||||
for (BluetoothGattService sv : list) {
|
||||
for (BluetoothGattCharacteristic ch : sv.getCharacteristics()) {
|
||||
Integer sh = toShort(ch.getUuid());
|
||||
if (sh != null && sh == 0x2A19) {
|
||||
return ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Integer toShort(java.util.UUID uuid) {
|
||||
if (uuid == null) return null;
|
||||
String s = uuid.toString().toLowerCase();
|
||||
if (s.startsWith("0000") && s.endsWith("-0000-1000-8000-00805f9b34fb")) {
|
||||
try {
|
||||
return Integer.parseInt(s.substring(4, 8), 16);
|
||||
} catch (Throwable ignore) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void readBatteryOnce(BluetoothGatt g) {
|
||||
if (g == null) return;
|
||||
BluetoothGattCharacteristic bl = findBatteryChar(g);
|
||||
if (bl == null) return;
|
||||
if (gattBusy.compareAndSet(false, true)) {
|
||||
boolean ok = g.readCharacteristic(bl);
|
||||
if (!ok) gattBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void startBatteryLoop(BluetoothGatt gRef) {
|
||||
if (gRef == null) return;
|
||||
if (!batteryLoop.compareAndSet(false, true)) return;
|
||||
if (batteryTask != null) try { batteryTask.cancel(true); } catch (Throwable ignore) {}
|
||||
batteryTask = batteryScheduler.scheduleAtFixedRate(() -> {
|
||||
if (!running.get() || !batteryLoop.get() || gatt == null) {
|
||||
batteryLoop.set(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (gattBusy.get()) return;
|
||||
BluetoothGattCharacteristic bl = findBatteryChar(gRef);
|
||||
if (bl != null && gattBusy.compareAndSet(false, true)) {
|
||||
boolean ok = gRef.readCharacteristic(bl);
|
||||
if (!ok) gattBusy.set(false);
|
||||
}
|
||||
} catch (Throwable ignore) {}
|
||||
}, 2000, 10_000, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private void postState(String s) {
|
||||
if (callback != null) {
|
||||
mainHandler.post(() -> callback.onState(s));
|
||||
}
|
||||
}
|
||||
|
||||
private void postError(String s) {
|
||||
Log.e(TAG, s);
|
||||
LogSender.logBLEError(s, deviceMac, "AisHub");
|
||||
if (callback != null) {
|
||||
mainHandler.post(() -> callback.onError(s));
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static String abbreviate(@NonNull String s, int max) {
|
||||
if (s.length() <= max) return s;
|
||||
return s.substring(0, Math.max(0, max)) + "…(" + s.length() + " chars)";
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static String msgTypeName(int mt) {
|
||||
switch (mt) {
|
||||
case AisHubConstants.MSG_HELLO_ACK: return "HELLO_ACK";
|
||||
case AisHubConstants.MSG_SNAPSHOT_BEGIN: return "SNAPSHOT_BEGIN";
|
||||
case AisHubConstants.MSG_SNAPSHOT_CHUNK: return "SNAPSHOT_CHUNK";
|
||||
case AisHubConstants.MSG_SNAPSHOT_END: return "SNAPSHOT_END";
|
||||
case AisHubConstants.MSG_EVENT: return "EVENT";
|
||||
case AisHubConstants.MSG_STATUS: return "STATUS";
|
||||
case AisHubConstants.MSG_ERROR: return "ERROR";
|
||||
case AisHubConstants.MSG_PONG: return "PONG";
|
||||
default: return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package com.grigowashere.aismap.ble.hub;
|
||||
|
||||
import com.grigowashere.aismap.models.AISVessel;
|
||||
import com.grigowashere.aismap.models.AISNavigationAid;
|
||||
import com.grigowashere.aismap.models.Vessel;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* Maps loose JSON objects from ais_hub into app models (defensive keys).
|
||||
*/
|
||||
public final class AisHubJsonMapper {
|
||||
|
||||
private AisHubJsonMapper() {}
|
||||
|
||||
public static String mmsiString(JSONObject o) {
|
||||
if (o == null) return null;
|
||||
if (o.has("mmsi")) {
|
||||
Object v = o.opt("mmsi");
|
||||
if (v instanceof Number) return String.valueOf(((Number) v).longValue());
|
||||
String s = o.optString("mmsi", null);
|
||||
return s != null && !s.isEmpty() ? s : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `o.dynamic` if it exists (AIS Hub v2 nests position/motion there
|
||||
* for target.update and vessel snapshot items), otherwise returns `o` itself
|
||||
* (ownship.update keeps lat/lon at root).
|
||||
*/
|
||||
private static JSONObject dyn(JSONObject o) {
|
||||
if (o == null) return null;
|
||||
JSONObject d = o.optJSONObject("dynamic");
|
||||
return d != null ? d : o;
|
||||
}
|
||||
|
||||
public static double optLat(JSONObject o) {
|
||||
if (o == null) return Double.NaN;
|
||||
JSONObject src = dyn(o);
|
||||
if (src.has("latitude")) return src.optDouble("latitude", Double.NaN);
|
||||
if (src.has("lat")) return src.optDouble("lat", Double.NaN);
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
public static double optLon(JSONObject o) {
|
||||
if (o == null) return Double.NaN;
|
||||
JSONObject src = dyn(o);
|
||||
if (src.has("longitude")) return src.optDouble("longitude", Double.NaN);
|
||||
if (src.has("lon")) return src.optDouble("lon", Double.NaN);
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
public static double optCourse(JSONObject o) {
|
||||
if (o == null) return Double.NaN;
|
||||
JSONObject src = dyn(o);
|
||||
double v = src.optDouble("cog", Double.NaN);
|
||||
if (!Double.isNaN(v)) return v;
|
||||
v = src.optDouble("course", Double.NaN);
|
||||
if (!Double.isNaN(v)) return v;
|
||||
v = src.optDouble("true_course", Double.NaN);
|
||||
if (!Double.isNaN(v)) return v;
|
||||
return src.optDouble("heading", Double.NaN);
|
||||
}
|
||||
|
||||
public static double optSpeed(JSONObject o) {
|
||||
if (o == null) return Double.NaN;
|
||||
JSONObject src = dyn(o);
|
||||
double v = src.optDouble("sog", Double.NaN);
|
||||
if (!Double.isNaN(v)) return v;
|
||||
v = src.optDouble("speed", Double.NaN);
|
||||
if (!Double.isNaN(v)) return v;
|
||||
return src.optDouble("stw", Double.NaN);
|
||||
}
|
||||
|
||||
public static double optHeading(JSONObject o) {
|
||||
if (o == null) return Double.NaN;
|
||||
JSONObject src = dyn(o);
|
||||
double v = src.optDouble("heading", Double.NaN);
|
||||
if (!Double.isNaN(v)) return v;
|
||||
return src.optDouble("hdg", Double.NaN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a valid epoch-seconds timestamp taken from any of the known fields,
|
||||
* or NaN if none present. Supports ts, last_dynamic_ts, last_seen, last_static_ts.
|
||||
*/
|
||||
private static double pickEpochSeconds(JSONObject o) {
|
||||
if (o == null) return Double.NaN;
|
||||
String[] keys = {"ts", "last_dynamic_ts", "last_seen", "last_static_ts"};
|
||||
for (String k : keys) {
|
||||
if (!o.has(k)) continue;
|
||||
double ts = o.optDouble(k, Double.NaN);
|
||||
if (Double.isNaN(ts) || ts <= 0) continue;
|
||||
if (ts > 1e12) ts /= 1000.0;
|
||||
if (ts > 946684800) return ts;
|
||||
}
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a server-epoch timestamp to a device-local {@link LocalDateTime}
|
||||
* using {@link HubTimeSync} so that stale checks (which compare against
|
||||
* {@code LocalDateTime.now()}) are immune to hub clock skew.
|
||||
*/
|
||||
private static LocalDateTime deviceLocalFromServerEpoch(double serverSec) {
|
||||
double deviceSec = HubTimeSync.toDeviceEpochSeconds(serverSec);
|
||||
long secs = (long) deviceSec;
|
||||
int nanos = (int) ((deviceSec - secs) * 1e9);
|
||||
if (nanos < 0) { secs -= 1; nanos += 1_000_000_000; }
|
||||
return LocalDateTime.ofInstant(Instant.ofEpochSecond(secs, nanos), ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
public static void applyTimestampFromJson(JSONObject o, AISVessel vessel) {
|
||||
double ts = pickEpochSeconds(o);
|
||||
if (!Double.isNaN(ts)) {
|
||||
vessel.setLastUpdate(deviceLocalFromServerEpoch(ts));
|
||||
return;
|
||||
}
|
||||
if (o != null && o.has("last_update")) {
|
||||
try {
|
||||
String s = o.optString("last_update", "");
|
||||
if (!s.isEmpty()) {
|
||||
vessel.setLastUpdate(LocalDateTime.parse(s));
|
||||
return;
|
||||
}
|
||||
} catch (Exception ignore) {}
|
||||
}
|
||||
vessel.setLastUpdate(LocalDateTime.now());
|
||||
}
|
||||
|
||||
public static void applyTimestampFromJson(JSONObject o, AISNavigationAid aid) {
|
||||
double ts = pickEpochSeconds(o);
|
||||
if (!Double.isNaN(ts)) {
|
||||
aid.setLastUpdate(deviceLocalFromServerEpoch(ts));
|
||||
return;
|
||||
}
|
||||
aid.setLastUpdate(LocalDateTime.now());
|
||||
}
|
||||
|
||||
public static void applyTimestampFromJson(JSONObject o, Vessel vessel) {
|
||||
double ts = pickEpochSeconds(o);
|
||||
if (!Double.isNaN(ts)) {
|
||||
vessel.setLastUpdate(deviceLocalFromServerEpoch(ts));
|
||||
return;
|
||||
}
|
||||
vessel.setLastUpdate(LocalDateTime.now());
|
||||
}
|
||||
|
||||
public static AISVessel aisVesselFromJson(JSONObject o) {
|
||||
if (o == null) return null;
|
||||
String mmsi = mmsiString(o);
|
||||
if (mmsi == null) return null;
|
||||
AISVessel v = new AISVessel(mmsi);
|
||||
String name = o.optString("name", o.optString("vessel_name", o.optString("shipname", "")));
|
||||
if (!name.isEmpty()) v.setVesselName(name);
|
||||
v.setCallSign(o.optString("call_sign", o.optString("callsign", "")));
|
||||
double lat = optLat(o);
|
||||
double lon = optLon(o);
|
||||
double cog = optCourse(o);
|
||||
double sog = optSpeed(o);
|
||||
if (!Double.isNaN(lat) && !Double.isNaN(lon)
|
||||
&& !Double.isNaN(cog) && !Double.isNaN(sog)) {
|
||||
v.updatePosition(lat, lon, cog, sog);
|
||||
} else {
|
||||
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
|
||||
v.setLatitude(lat);
|
||||
v.setLongitude(lon);
|
||||
}
|
||||
if (!Double.isNaN(cog)) v.setCourse(cog);
|
||||
if (!Double.isNaN(sog)) v.setSpeed(sog);
|
||||
}
|
||||
double hdg = optHeading(o);
|
||||
if (!Double.isNaN(hdg)) v.setHeading(hdg);
|
||||
String cls = o.optString("class", o.optString("vessel_class", o.optString("ship_class", "")));
|
||||
if (!cls.isEmpty()) v.setVesselClass(cls);
|
||||
int imo = o.optInt("imo", 0);
|
||||
if (imo > 0) v.setImo(imo);
|
||||
int shipType = o.optInt("ship_type", -1);
|
||||
if (shipType >= 0) v.setVesselType(shipTypeName(shipType));
|
||||
JSONObject dims = o.optJSONObject("dims");
|
||||
if (dims != null) {
|
||||
double a = dims.optDouble("a", 0.0);
|
||||
double b = dims.optDouble("b", 0.0);
|
||||
double c = dims.optDouble("c", 0.0);
|
||||
double d = dims.optDouble("d", 0.0);
|
||||
if (a + b > 0.0) v.setLength(a + b);
|
||||
if (c + d > 0.0) v.setWidth(c + d);
|
||||
}
|
||||
JSONObject voyage = o.optJSONObject("voyage");
|
||||
if (voyage != null) {
|
||||
double draft = voyage.optDouble("draught", Double.NaN);
|
||||
if (!Double.isNaN(draft) && draft > 0.0) v.setDraft(draft);
|
||||
String destination = voyage.optString("destination", "");
|
||||
if (!destination.isEmpty()) v.setDestination(destination);
|
||||
}
|
||||
JSONObject dynamic = o.optJSONObject("dynamic");
|
||||
if (dynamic != null) {
|
||||
if (dynamic.has("nav_status")) {
|
||||
v.setNavigationalStatus(String.valueOf(dynamic.optInt("nav_status")));
|
||||
}
|
||||
double rot = dynamic.optDouble("rot", Double.NaN);
|
||||
if (!Double.isNaN(rot)) v.setRateOfTurn(rot);
|
||||
}
|
||||
JSONObject signal = o.optJSONObject("signal");
|
||||
if (signal != null) {
|
||||
double db = signal.optDouble("last_db", Double.NaN);
|
||||
if (!Double.isNaN(db)) v.setSignalStrength((int) Math.round(db));
|
||||
}
|
||||
if (o.has("position_accuracy")) {
|
||||
v.setPositionAccuracy(o.optBoolean("position_accuracy", false));
|
||||
}
|
||||
applyTimestampFromJson(o, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
public static AISVessel baseStationFromJson(JSONObject o) {
|
||||
if (o == null) return null;
|
||||
String mmsi = mmsiString(o);
|
||||
if (mmsi == null) return null;
|
||||
AISVessel v = new AISVessel(mmsi);
|
||||
v.setVesselClass("Base Station");
|
||||
v.setVesselType("Base Station");
|
||||
v.setVesselName("Base Station " + mmsi);
|
||||
double lat = optLat(o);
|
||||
double lon = optLon(o);
|
||||
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
|
||||
v.setLatitude(lat);
|
||||
v.setLongitude(lon);
|
||||
}
|
||||
if (o.has("accuracy")) {
|
||||
v.setPositionAccuracy(o.optBoolean("accuracy", false));
|
||||
}
|
||||
if (o.has("epfd")) {
|
||||
v.setDestination("EPFD: " + o.optInt("epfd"));
|
||||
}
|
||||
applyTimestampFromJson(o, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
public static AISNavigationAid navigationAidFromJson(JSONObject o) {
|
||||
if (o == null) return null;
|
||||
String mmsi = mmsiString(o);
|
||||
if (mmsi == null) return null;
|
||||
AISNavigationAid aid = new AISNavigationAid(mmsi);
|
||||
aid.setAidName(o.optString("name", "AtoN " + mmsi));
|
||||
aid.setAidType(o.optInt("type", 0));
|
||||
double lat = optLat(o);
|
||||
double lon = optLon(o);
|
||||
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
|
||||
aid.setLatitude(lat);
|
||||
aid.setLongitude(lon);
|
||||
}
|
||||
if (o.has("accuracy")) {
|
||||
aid.setPositionAccuracy(o.optBoolean("accuracy", false));
|
||||
}
|
||||
if (o.has("virtual")) {
|
||||
aid.setOffPositionIndicator(o.optBoolean("virtual", false));
|
||||
}
|
||||
applyTimestampFromJson(o, aid);
|
||||
return aid;
|
||||
}
|
||||
|
||||
public static void mergeVesselOwnship(JSONObject o, Vessel v) {
|
||||
if (o == null || v == null) return;
|
||||
String mmsi = mmsiString(o);
|
||||
if (mmsi != null) v.setMmsi(mmsi);
|
||||
String name = o.optString("name", o.optString("vessel_name", ""));
|
||||
if (!name.isEmpty()) v.setVesselName(name);
|
||||
String cs = o.optString("call_sign", o.optString("callsign", ""));
|
||||
if (!cs.isEmpty()) v.setCallSign(cs);
|
||||
double lat = optLat(o);
|
||||
double lon = optLon(o);
|
||||
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
|
||||
v.setLatitude(lat);
|
||||
v.setLongitude(lon);
|
||||
}
|
||||
double cog = optCourse(o);
|
||||
if (!Double.isNaN(cog)) v.setCourse(cog);
|
||||
double sog = optSpeed(o);
|
||||
if (!Double.isNaN(sog)) v.setSpeed(sog);
|
||||
double hdg = optHeading(o);
|
||||
if (!Double.isNaN(hdg)) v.setHeading(hdg);
|
||||
v.setFixTime(System.currentTimeMillis());
|
||||
v.setFixQuality("HUB");
|
||||
if (o.has("accuracy") || o.has("h_acc")) {
|
||||
float acc = (float) o.optDouble("accuracy", o.optDouble("h_acc", -1));
|
||||
if (acc >= 0) v.setAccuracy(acc);
|
||||
}
|
||||
applyTimestampFromJson(o, v);
|
||||
}
|
||||
|
||||
private static String shipTypeName(int code) {
|
||||
if (code >= 70 && code <= 79) return "Cargo";
|
||||
if (code >= 80 && code <= 89) return "Tanker";
|
||||
if (code == 30) return "Fishing";
|
||||
if (code >= 60 && code <= 69) return "Passenger";
|
||||
if (code == 36 || code == 37) return "Sailing/Pleasure";
|
||||
if (code == 31 || code == 32 || code == 52) return "Tug";
|
||||
if (code == 35) return "Military";
|
||||
return "Other";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.grigowashere.aismap.ble.hub;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.msgpack.core.MessagePack;
|
||||
import org.msgpack.core.MessageUnpacker;
|
||||
import org.msgpack.value.Value;
|
||||
import org.msgpack.value.ValueType;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Decodes a reassembled AIS Hub message payload into a {@link JSONObject},
|
||||
* regardless of whether the server sent it as JSON (proto 0x01) or
|
||||
* MessagePack (proto 0x02). All downstream code in this app consumes
|
||||
* {@link JSONObject}, so converting MessagePack → JSONObject here keeps the
|
||||
* rest of the codebase unchanged.
|
||||
*
|
||||
* <p>Why convert to JSONObject instead of surfacing the raw msgpack Value?
|
||||
* The existing consumers ({@code onDataJson}) and all the mappers in
|
||||
* {@link AisHubJsonMapper} are written against {@code org.json.*}. Rewriting
|
||||
* them would be a large blast radius for a wire-format optimization that the
|
||||
* consumer should be agnostic to. The conversion is O(payload size) and runs
|
||||
* off the BLE callback thread (see {@code AisHubGattClient#dataExecutor}).
|
||||
*/
|
||||
public final class AisHubPayloadCodec {
|
||||
|
||||
private AisHubPayloadCodec() {}
|
||||
|
||||
/**
|
||||
* Decode a reassembled payload into a JSONObject.
|
||||
*
|
||||
* @param payload raw reassembled bytes (never null when called)
|
||||
* @param protoVer one of {@link AisHubConstants#PROTO_VERSION_JSON} or
|
||||
* {@link AisHubConstants#PROTO_VERSION_MSGPACK}
|
||||
* @return the decoded root object, or null if the payload is malformed
|
||||
* or the top-level value is not an object/map (we never expect
|
||||
* a bare array or scalar at the root in this protocol).
|
||||
*/
|
||||
@Nullable
|
||||
public static JSONObject decodeToJsonObject(@NonNull byte[] payload, int protoVer) throws Exception {
|
||||
if (protoVer == AisHubConstants.PROTO_VERSION_MSGPACK) {
|
||||
return decodeMsgpack(payload);
|
||||
}
|
||||
// Default / 0x01: JSON UTF-8. Unknown versions are treated as JSON
|
||||
// rather than silently dropped, to be forward-compatible with future
|
||||
// server versions that still emit JSON-shaped payloads.
|
||||
String jsonStr = new String(payload, StandardCharsets.UTF_8);
|
||||
return new JSONObject(jsonStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link #decodeToJsonObject(byte[], int)} but takes a pre-built
|
||||
* JSON string (used by the legacy fast path where the assembler already
|
||||
* produced the UTF-8 string).
|
||||
*/
|
||||
@NonNull
|
||||
public static JSONObject decodeJsonString(@NonNull String json) throws Exception {
|
||||
return new JSONObject(json);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static JSONObject decodeMsgpack(@NonNull byte[] payload) throws Exception {
|
||||
try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(payload)) {
|
||||
if (!unpacker.hasNext()) return null;
|
||||
Value root = unpacker.unpackValue();
|
||||
if (root == null || !root.getValueType().isMapType()) {
|
||||
return null;
|
||||
}
|
||||
return (JSONObject) toJson(root);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively convert a MessagePack {@link Value} to an {@code org.json.*}
|
||||
* tree. Maps become {@link JSONObject}, arrays become {@link JSONArray},
|
||||
* binary blobs become Base64 strings (we don't expect any in this
|
||||
* protocol, but they must not crash the decoder).
|
||||
*/
|
||||
private static Object toJson(@NonNull Value v) throws Exception {
|
||||
ValueType t = v.getValueType();
|
||||
switch (t) {
|
||||
case NIL:
|
||||
return JSONObject.NULL;
|
||||
case BOOLEAN:
|
||||
return v.asBooleanValue().getBoolean();
|
||||
case INTEGER:
|
||||
// Prefer long to preserve 64-bit MMSI values etc.
|
||||
return v.asIntegerValue().toLong();
|
||||
case FLOAT:
|
||||
return v.asFloatValue().toDouble();
|
||||
case STRING:
|
||||
return v.asStringValue().asString();
|
||||
case BINARY:
|
||||
// Shouldn't occur on this wire format; stringify defensively.
|
||||
return android.util.Base64.encodeToString(
|
||||
v.asBinaryValue().asByteArray(), android.util.Base64.NO_WRAP);
|
||||
case ARRAY: {
|
||||
JSONArray arr = new JSONArray();
|
||||
for (Value item : v.asArrayValue()) {
|
||||
arr.put(toJson(item));
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
case MAP: {
|
||||
JSONObject obj = new JSONObject();
|
||||
for (Map.Entry<Value, Value> e : v.asMapValue().entrySet()) {
|
||||
// Keys in our protocol are always strings. If a non-string
|
||||
// key sneaks in, stringify it so we don't silently drop data.
|
||||
String key = e.getKey().getValueType() == ValueType.STRING
|
||||
? e.getKey().asStringValue().asString()
|
||||
: e.getKey().toJson();
|
||||
obj.put(key, toJson(e.getValue()));
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
case EXTENSION:
|
||||
default:
|
||||
// Unknown / extension types: stringify to avoid crashing.
|
||||
return v.toJson();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.grigowashere.aismap.ble.hub;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
/**
|
||||
* Tracks clock offset between the AIS Hub server ("hub time") and this device
|
||||
* ("device wall clock"). Used to translate absolute server timestamps
|
||||
* (`ts`, `server_time`, `last_dynamic_ts`, ...) into device-local epoch seconds
|
||||
* so that stale-data logic (which works against {@code LocalDateTime.now()})
|
||||
* stays correct even when the two clocks disagree.
|
||||
*
|
||||
* <p>Thread-safety: all state is static/volatile; updates are cheap and safe
|
||||
* from any thread.
|
||||
*/
|
||||
public final class HubTimeSync {
|
||||
|
||||
private static final String TAG = "HubTimeSync";
|
||||
|
||||
/**
|
||||
* Minimum server epoch we consider plausible (2010-01-01). Anything below is
|
||||
* ignored.
|
||||
*/
|
||||
private static final double MIN_EPOCH = 1262304000.0;
|
||||
|
||||
/**
|
||||
* Clamp absurd offsets (1 year) to protect from a single bad sample.
|
||||
*/
|
||||
private static final double MAX_ABS_OFFSET_SEC = 365L * 24 * 3600;
|
||||
|
||||
private static volatile boolean hasOffset = false;
|
||||
/** offsetSec = serverTime - deviceTime; deviceTime = serverTime - offsetSec. */
|
||||
private static volatile double offsetSec = 0.0;
|
||||
private static volatile long lastUpdateElapsedRealtimeMs = 0L;
|
||||
|
||||
private HubTimeSync() {}
|
||||
|
||||
/**
|
||||
* Feeds a server timestamp (epoch seconds, possibly milliseconds) and the
|
||||
* device wall-clock moment when it was observed. Values that look broken
|
||||
* are dropped.
|
||||
*/
|
||||
public static void updateFromServerSeconds(double serverEpochSec) {
|
||||
if (Double.isNaN(serverEpochSec) || Double.isInfinite(serverEpochSec)) return;
|
||||
// Some payloads send milliseconds in a double; normalize.
|
||||
if (serverEpochSec > 1e12) serverEpochSec /= 1000.0;
|
||||
if (serverEpochSec < MIN_EPOCH) return;
|
||||
double deviceNowSec = System.currentTimeMillis() / 1000.0;
|
||||
double newOffset = serverEpochSec - deviceNowSec;
|
||||
if (Math.abs(newOffset) > MAX_ABS_OFFSET_SEC) {
|
||||
Log.w(TAG, "Ignoring implausible offset " + newOffset + "s (server=" + serverEpochSec + ")");
|
||||
return;
|
||||
}
|
||||
// Light exponential smoothing to absorb jitter without lagging a real clock drift.
|
||||
if (hasOffset) {
|
||||
offsetSec = 0.2 * newOffset + 0.8 * offsetSec;
|
||||
} else {
|
||||
offsetSec = newOffset;
|
||||
}
|
||||
hasOffset = true;
|
||||
lastUpdateElapsedRealtimeMs = android.os.SystemClock.elapsedRealtime();
|
||||
}
|
||||
|
||||
public static boolean hasOffset() {
|
||||
return hasOffset;
|
||||
}
|
||||
|
||||
public static double getOffsetSec() {
|
||||
return offsetSec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a server-side epoch-seconds timestamp into a device-side
|
||||
* epoch-seconds timestamp using the learned offset. If no offset has been
|
||||
* observed yet, returns {@code serverEpochSec} unchanged (best effort).
|
||||
*/
|
||||
public static double toDeviceEpochSeconds(double serverEpochSec) {
|
||||
if (Double.isNaN(serverEpochSec) || serverEpochSec <= 0) return serverEpochSec;
|
||||
if (serverEpochSec > 1e12) serverEpochSec /= 1000.0;
|
||||
if (!hasOffset) return serverEpochSec;
|
||||
return serverEpochSec - offsetSec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state (e.g. on BLE stop). Primarily useful for tests.
|
||||
*/
|
||||
public static void reset() {
|
||||
hasOffset = false;
|
||||
offsetSec = 0.0;
|
||||
lastUpdateElapsedRealtimeMs = 0L;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import com.grigowashere.aismap.data.entity.AISVesselEntity;
|
||||
import com.grigowashere.aismap.data.mapper.AISVesselMapper;
|
||||
import com.grigowashere.aismap.models.Vessel;
|
||||
import com.grigowashere.aismap.models.AISVessel;
|
||||
import com.grigowashere.aismap.models.AISNavigationAid;
|
||||
import com.grigowashere.aismap.utils.SettingsManager;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
@@ -38,7 +39,7 @@ public class DataController {
|
||||
private DataControllerListener listener;
|
||||
|
||||
public interface DataControllerListener {
|
||||
void onDataRestored(Vessel vessel, List<AISVessel> aisVessels);
|
||||
void onDataRestored(Vessel vessel, List<AISVessel> aisVessels, List<AISNavigationAid> navigationAids);
|
||||
void onDataSaved(String dataType, boolean success);
|
||||
void onDataCleaned(int removedCount);
|
||||
}
|
||||
@@ -47,7 +48,7 @@ public class DataController {
|
||||
this.context = context;
|
||||
this.repository = new Repository(context);
|
||||
this.settingsManager = new SettingsManager(context);
|
||||
this.executor = Executors.newCachedThreadPool();
|
||||
this.executor = Executors.newSingleThreadExecutor();
|
||||
|
||||
// Инициализируем Handler для периодической очистки БД
|
||||
this.dbCleanupHandler = new Handler(Looper.getMainLooper());
|
||||
@@ -70,6 +71,8 @@ public class DataController {
|
||||
Log.i(TAG, "🔄 Запускаем асинхронное восстановление данных из БД...");
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
cleanupStaleAISSync("перед восстановлением");
|
||||
|
||||
Log.d(TAG, "📊 Загружаем данные судна из БД...");
|
||||
VesselEntity latest = repository.getLatestOwnVesselSync();
|
||||
Vessel vessel = null;
|
||||
@@ -87,21 +90,29 @@ public class DataController {
|
||||
Log.d(TAG, "🚢 Загружаем AIS суда из БД...");
|
||||
List<AISVesselEntity> list = repository.getAllAISSync();
|
||||
List<AISVessel> aisVessels = new ArrayList<>();
|
||||
List<AISNavigationAid> navigationAids = new ArrayList<>();
|
||||
|
||||
if (list != null && !list.isEmpty()) {
|
||||
for (AISVesselEntity entity : list) {
|
||||
// Используем маппер для полного восстановления всех полей
|
||||
AISVessel vesselModel = AISVesselMapper.toModel(entity);
|
||||
aisVessels.add(vesselModel);
|
||||
Log.d(TAG, "AIS судно восстановлено из БД с полными данными: " + vesselModel.getMmsi());
|
||||
// Проверяем, является ли это навигационным знаком
|
||||
if ("Navigation Aid".equals(entity.vesselClass)) {
|
||||
// Создаем AISNavigationAid из entity
|
||||
AISNavigationAid navigationAid = createNavigationAidFromEntity(entity);
|
||||
navigationAids.add(navigationAid);
|
||||
} else {
|
||||
// Используем маппер для полного восстановления всех полей AIS судна
|
||||
AISVessel vesselModel = AISVesselMapper.toModel(entity);
|
||||
aisVessels.add(vesselModel);
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "✅ Восстановлено " + list.size() + " AIS судов из БД с полными данными");
|
||||
Log.i(TAG, "✅ Восстановлено " + aisVessels.size() + " AIS судов и " + navigationAids.size() + " навигационных знаков из БД");
|
||||
} else {
|
||||
Log.d(TAG, "ℹ️ Нет AIS судов в БД");
|
||||
}
|
||||
|
||||
// Уведомляем слушателя о восстановленных данных
|
||||
if (listener != null) {
|
||||
listener.onDataRestored(vessel, aisVessels);
|
||||
listener.onDataRestored(vessel, aisVessels, navigationAids);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -147,8 +158,8 @@ public class DataController {
|
||||
try {
|
||||
// Используем маппер для полной конвертации всех полей
|
||||
AISVesselEntity entity = AISVesselMapper.toEntity(vessel);
|
||||
repository.upsertAIS(entity);
|
||||
Log.d(TAG, "AIS судно сохранено в БД с полными данными: " + vessel.getMmsi());
|
||||
repository.upsertAISSync(entity);
|
||||
Log.d(TAG, "AIS судно сохранено в БД: " + vessel.getMmsi());
|
||||
|
||||
if (listener != null) {
|
||||
listener.onDataSaved("ais_vessel", true);
|
||||
@@ -161,6 +172,134 @@ public class DataController {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Пакетно сохраняет AIS суда в БД.
|
||||
*/
|
||||
public void saveAISVessels(List<AISVessel> vessels) {
|
||||
if (vessels == null || vessels.isEmpty()) return;
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
List<AISVesselEntity> entities = new ArrayList<>(vessels.size());
|
||||
for (AISVessel vessel : vessels) {
|
||||
if (vessel == null || vessel.getMmsi() == null) continue;
|
||||
AISVesselEntity entity = AISVesselMapper.toEntity(vessel);
|
||||
if (entity != null) {
|
||||
entities.add(entity);
|
||||
}
|
||||
}
|
||||
repository.upsertAISBatchSync(entities);
|
||||
Log.d(TAG, "Пакетно сохранено AIS судов в БД: " + entities.size());
|
||||
|
||||
if (listener != null) {
|
||||
listener.onDataSaved("ais_vessels_batch", true);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка пакетного апсерта AIS в БД: " + e.getMessage(), e);
|
||||
if (listener != null) {
|
||||
listener.onDataSaved("ais_vessels_batch", false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет навигационный знак в БД
|
||||
*/
|
||||
public void saveNavigationAid(AISNavigationAid navigationAid) {
|
||||
if (navigationAid == null) return;
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
// Создаем AISVesselEntity из навигационного знака для совместимости с БД
|
||||
AISVesselEntity entity = new AISVesselEntity(navigationAid.getMmsi());
|
||||
entity.mmsi = navigationAid.getMmsi();
|
||||
entity.latitude = navigationAid.getLatitude();
|
||||
entity.longitude = navigationAid.getLongitude();
|
||||
entity.vesselName = navigationAid.getAidName();
|
||||
entity.vesselClass = "Navigation Aid";
|
||||
entity.vesselType = navigationAid.getAidTypeDescription();
|
||||
entity.length = navigationAid.getLength();
|
||||
entity.width = navigationAid.getWidth();
|
||||
entity.draft = navigationAid.getDraft();
|
||||
entity.positionAccuracy = navigationAid.isPositionAccuracy();
|
||||
// Сохраняем время последнего обновления как epoch ms
|
||||
if (navigationAid.getLastUpdate() != null) {
|
||||
entity.lastUpdateEpochMs = navigationAid.getLastUpdate().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli();
|
||||
}
|
||||
|
||||
// Добавляем специальные поля для навигационных знаков в destination
|
||||
StringBuilder destination = new StringBuilder();
|
||||
destination.append("Type: ").append(navigationAid.getAidType()).append(" (").append(navigationAid.getAidTypeDescription()).append(")");
|
||||
if (navigationAid.isOffPositionIndicator()) {
|
||||
destination.append(" - Off Position");
|
||||
}
|
||||
if (navigationAid.isRaimFlag()) {
|
||||
destination.append(" - RAIM Active");
|
||||
}
|
||||
entity.destination = destination.toString();
|
||||
|
||||
repository.upsertAISSync(entity);
|
||||
Log.d(TAG, "Навигационный знак сохранен в БД: " + navigationAid.getMmsi());
|
||||
|
||||
if (listener != null) {
|
||||
listener.onDataSaved("navigation_aid", true);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка сохранения навигационного знака в БД: " + e.getMessage(), e);
|
||||
if (listener != null) {
|
||||
listener.onDataSaved("navigation_aid", false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает AISNavigationAid из AISVesselEntity
|
||||
*/
|
||||
private AISNavigationAid createNavigationAidFromEntity(AISVesselEntity entity) {
|
||||
AISNavigationAid navigationAid = new AISNavigationAid(entity.mmsi);
|
||||
navigationAid.setLatitude(entity.latitude);
|
||||
navigationAid.setLongitude(entity.longitude);
|
||||
navigationAid.setAidName(entity.vesselName);
|
||||
navigationAid.setAidTypeDescription(entity.vesselType);
|
||||
navigationAid.setLength(entity.length);
|
||||
navigationAid.setWidth(entity.width);
|
||||
navigationAid.setDraft(entity.draft);
|
||||
navigationAid.setPositionAccuracy(entity.positionAccuracy);
|
||||
// Восстанавливаем время из epoch ms, если доступно
|
||||
if (entity.lastUpdateEpochMs > 0) {
|
||||
navigationAid.setLastUpdate(java.time.Instant.ofEpochMilli(entity.lastUpdateEpochMs)
|
||||
.atZone(java.time.ZoneId.systemDefault()).toLocalDateTime());
|
||||
}
|
||||
|
||||
// Парсим специальные поля из destination
|
||||
if (entity.destination != null && entity.destination.startsWith("Type: ")) {
|
||||
try {
|
||||
// Извлекаем тип из destination: "Type: 21 (Cardinal Mark E)"
|
||||
String typePart = entity.destination.substring(6); // Убираем "Type: "
|
||||
int parenIndex = typePart.indexOf(' ');
|
||||
if (parenIndex > 0) {
|
||||
String typeStr = typePart.substring(0, parenIndex);
|
||||
int aidType = Integer.parseInt(typeStr);
|
||||
navigationAid.setAidType(aidType);
|
||||
}
|
||||
|
||||
// Проверяем дополнительные флаги
|
||||
if (entity.destination.contains("Off Position")) {
|
||||
navigationAid.setOffPositionIndicator(true);
|
||||
}
|
||||
if (entity.destination.contains("RAIM Active")) {
|
||||
navigationAid.setRaimFlag(true);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Ошибка парсинга destination для навигационного знака: " + entity.destination);
|
||||
}
|
||||
}
|
||||
|
||||
return navigationAid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Запускает периодическую очистку БД от устаревших AIS целей
|
||||
@@ -186,25 +325,32 @@ public class DataController {
|
||||
* Выполняет очистку БД от устаревших AIS целей
|
||||
*/
|
||||
private void performDatabaseCleanup() {
|
||||
try {
|
||||
int staleRemoveMinutes = settingsManager.getDataStaleRemoveMinutes();
|
||||
long thresholdEpochMs = System.currentTimeMillis() - (staleRemoveMinutes * 60 * 1000L);
|
||||
|
||||
repository.deleteStaleAIS(thresholdEpochMs);
|
||||
|
||||
Log.i(TAG, "Выполнена очистка БД от AIS целей старше " + staleRemoveMinutes + " минут");
|
||||
|
||||
if (listener != null) {
|
||||
listener.onDataCleaned(0); // Метод не возвращает количество удаленных записей
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
int removed = cleanupStaleAISSync(null);
|
||||
|
||||
if (listener != null) {
|
||||
listener.onDataCleaned(removed);
|
||||
}
|
||||
|
||||
// Планируем следующую очистку
|
||||
if (dbCleanupHandler != null && dbCleanupRunnable != null) {
|
||||
dbCleanupHandler.postDelayed(dbCleanupRunnable, DB_CLEANUP_INTERVAL);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка при очистке БД от устаревших AIS целей: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
// Планируем следующую очистку
|
||||
if (dbCleanupHandler != null && dbCleanupRunnable != null) {
|
||||
dbCleanupHandler.postDelayed(dbCleanupRunnable, DB_CLEANUP_INTERVAL);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка при очистке БД от устаревших AIS целей: " + e.getMessage(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private int cleanupStaleAISSync(String reason) {
|
||||
int staleRemoveMinutes = settingsManager.getDataStaleRemoveMinutes();
|
||||
long thresholdEpochMs = System.currentTimeMillis() - (staleRemoveMinutes * 60 * 1000L);
|
||||
int removed = repository.deleteStaleAISSync(thresholdEpochMs);
|
||||
String suffix = reason != null ? " " + reason : "";
|
||||
Log.i(TAG, "Выполнена очистка БД" + suffix + ": удалено=" + removed +
|
||||
", старше=" + staleRemoveMinutes + " минут");
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -225,14 +225,24 @@ public class GPSLocationListener implements LocationListener {
|
||||
Log.i(TAG, "📍 Location обновлен: " + location.getLatitude() + ", " + location.getLongitude());
|
||||
Log.i(TAG, "📍 Точность: " + location.getAccuracy() + "м, время: " + location.getTime());
|
||||
|
||||
// Создаем объект судна с полученными данными
|
||||
Vessel vessel = new Vessel();
|
||||
vessel.setLatitude(location.getLatitude());
|
||||
vessel.setLongitude(location.getLongitude());
|
||||
vessel.setAccuracy(location.getAccuracy());
|
||||
vessel.setFixTime(location.getTime());
|
||||
|
||||
// Определяем качество фикса
|
||||
|
||||
// Android Location умеет отдавать скорость (м/с) и курс (°); они нужны
|
||||
// координатному виджету и стрелке на карте. Преобразуем м/с → узлы.
|
||||
if (location.hasSpeed()) {
|
||||
double knots = location.getSpeed() * 1.9438444924;
|
||||
vessel.setSpeed(knots);
|
||||
} else {
|
||||
vessel.setSpeed(0);
|
||||
}
|
||||
if (location.hasBearing()) {
|
||||
vessel.setCourse(location.getBearing());
|
||||
}
|
||||
|
||||
if (location.hasAccuracy()) {
|
||||
if (location.getAccuracy() <= 3) {
|
||||
vessel.setFixQuality("HIGH_ACCURACY");
|
||||
@@ -242,8 +252,7 @@ public class GPSLocationListener implements LocationListener {
|
||||
vessel.setFixQuality("LOW_ACCURACY");
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем информацию о спутниках
|
||||
|
||||
vessel.updateGPSQuality(satelliteCount, activeSatellites, pdop, hdop, vdop, location.getAccuracy());
|
||||
|
||||
// Отправляем обновление через callback
|
||||
|
||||
@@ -40,6 +40,7 @@ public class NMEAController implements
|
||||
void onVesselUpdated(Vessel vessel);
|
||||
void onDOPUpdated(double pdop, double hdop, double vdop);
|
||||
void onAISVesselUpdated(AISVessel vessel);
|
||||
void onNavigationAidUpdated(com.grigowashere.aismap.models.AISNavigationAid navigationAid);
|
||||
void onParseError(String error);
|
||||
void onGPSLocationUpdated(Vessel vessel);
|
||||
}
|
||||
@@ -306,6 +307,13 @@ public class NMEAController implements
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNavigationAidUpdated(com.grigowashere.aismap.models.AISNavigationAid navigationAid) {
|
||||
if (listener != null) {
|
||||
listener.onNavigationAidUpdated(navigationAid);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onParseError(String error) {
|
||||
Log.e(TAG, "Ошибка парсинга NMEA: " + error);
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.grigowashere.aismap.controllers;
|
||||
import android.util.Log;
|
||||
import com.grigowashere.aismap.models.Vessel;
|
||||
import com.grigowashere.aismap.models.AISVessel;
|
||||
import com.grigowashere.aismap.models.AISNavigationAid;
|
||||
import com.grigowashere.aismap.utils.LogSender;
|
||||
|
||||
import java.util.List;
|
||||
@@ -33,6 +34,7 @@ public class NMEAParser {
|
||||
|
||||
private Vessel ownVessel;
|
||||
private List<AISVessel> aisVessels;
|
||||
private List<AISNavigationAid> navigationAids;
|
||||
private NMEAParserListener listener;
|
||||
private GPSLocationListener gpsLocationListener;
|
||||
|
||||
@@ -55,6 +57,7 @@ public class NMEAParser {
|
||||
public interface NMEAParserListener {
|
||||
void onVesselUpdated(Vessel vessel);
|
||||
void onAISVesselUpdated(AISVessel vessel);
|
||||
void onNavigationAidUpdated(AISNavigationAid navigationAid);
|
||||
void onParseError(String error);
|
||||
void onDOPUpdated(double pdop, double hdop, double vdop);
|
||||
}
|
||||
@@ -62,6 +65,7 @@ public class NMEAParser {
|
||||
public NMEAParser() {
|
||||
this.ownVessel = new Vessel();
|
||||
this.aisVessels = new ArrayList<>();
|
||||
this.navigationAids = new ArrayList<>();
|
||||
}
|
||||
|
||||
public void setListener(NMEAParserListener listener) {
|
||||
@@ -97,6 +101,7 @@ public class NMEAParser {
|
||||
String cleanedSentence = cleanNMEASentence(nmeaSentence);
|
||||
if (cleanedSentence == null) {
|
||||
Log.w(TAG, "NMEA сообщение не удалось очистить или слишком короткое: " + nmeaSentence);
|
||||
LogSender.logDroppedNMEA("Очистка не удалась", nmeaSentence, "Сообщение слишком короткое или некорректное");
|
||||
return;
|
||||
}
|
||||
// Диагностика: логируем только каждые 10 секунд
|
||||
@@ -114,6 +119,7 @@ public class NMEAParser {
|
||||
String[] fields = cleanedSentence.split(",");
|
||||
if (fields.length < 2) {
|
||||
Log.w(TAG, "NMEA сообщение слишком короткое: " + cleanedSentence);
|
||||
LogSender.logDroppedNMEA("Слишком короткое", cleanedSentence, "Меньше 2 полей: " + fields.length);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -121,6 +127,7 @@ public class NMEAParser {
|
||||
String preamble = fields[0];
|
||||
if (preamble.length() < 6) {
|
||||
Log.w(TAG, "Некорректная приамбула: " + preamble);
|
||||
LogSender.logDroppedNMEA("Некорректная приамбула", cleanedSentence, "Длина приамбуды: " + preamble.length());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,15 +166,18 @@ public class NMEAParser {
|
||||
} else {
|
||||
// Убираем лишние логи - только каждые 10 секунд
|
||||
long now2 = System.currentTimeMillis();
|
||||
if (now2 - lastNMEALogTime > 10000) {
|
||||
|
||||
Log.d(TAG, "📡 NMEAParser: неподдерживаемый тип: " + messageType);
|
||||
LogSender.logDroppedNMEA("Неподдерживаемый тип", cleanedSentence, "Тип: " + messageType);
|
||||
lastNMEALogTime = now2;
|
||||
}
|
||||
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка парсинга NMEA: " + e.getMessage(), e);
|
||||
LogSender.logError("NMEA_PARSE_EXCEPTION", "Ошибка парсинга NMEA",
|
||||
String.format("Exception: %s | Message: %s", e.getMessage(), cleanedSentence));
|
||||
if (listener != null) {
|
||||
listener.onParseError("Ошибка парсинга NMEA: " + e.getMessage());
|
||||
}
|
||||
@@ -509,44 +519,16 @@ public class NMEAParser {
|
||||
listener.onVesselUpdated(ownVessel);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Парсит GLL сообщение (Geographic Position - Latitude/Longitude)
|
||||
* В гибридном режиме игнорируем
|
||||
* Формат: $GPGLL,lat,N/S,lon,E/W,time,status,mode*checksum
|
||||
*/
|
||||
private void parseGLL(String[] fields) {
|
||||
// Разбираем время фикса (поле 5), статус (поле 6) и режим (поле 7)
|
||||
String utcTimeStr = getField(fields, 5); // hhmmss.ss
|
||||
String status = getField(fields, 6); // A/V
|
||||
String mode = getField(fields, 7); // A/D/E/M/S/N (может отсутствовать)
|
||||
|
||||
// Устанавливаем fixQuality на основе статуса и режима
|
||||
if (status != null) {
|
||||
if ("A".equals(status)) {
|
||||
// Валидные данные: уточняем по mode
|
||||
if (mode != null) {
|
||||
switch (mode) {
|
||||
case "A": ownVessel.setFixQuality("AUTONOMOUS"); break;
|
||||
case "D": ownVessel.setFixQuality("DIFFERENTIAL"); break;
|
||||
case "E": ownVessel.setFixQuality("ESTIMATED"); break;
|
||||
case "M": ownVessel.setFixQuality("MANUAL"); break;
|
||||
case "S": ownVessel.setFixQuality("SIMULATOR"); break;
|
||||
case "N": ownVessel.setFixQuality("NOT_VALID"); break;
|
||||
default: ownVessel.setFixQuality("AUTONOMOUS"); break;
|
||||
}
|
||||
} else {
|
||||
ownVessel.setFixQuality("AUTONOMOUS");
|
||||
}
|
||||
} else {
|
||||
ownVessel.setFixQuality("NOT_VALID");
|
||||
}
|
||||
}
|
||||
|
||||
// GLL не содержит дату — epoch не пишем, но строковое время сохраним
|
||||
if (utcTimeStr != null && utcTimeStr.length() >= 6) {
|
||||
ownVessel.setFixTimeNmea(utcTimeStr);
|
||||
}
|
||||
// GLL: не обновляем fixQuality и время фикса — источники: GSA и RMC/ZDA
|
||||
|
||||
// Если не в гибридном режиме — обновляем координаты
|
||||
if (!hybridMode) {
|
||||
@@ -800,6 +782,13 @@ public class NMEAParser {
|
||||
ownVessel.setHdop(hdop);
|
||||
ownVessel.setVdop(vdop);
|
||||
|
||||
// Обновляем оценку точности в метрах из HDOP
|
||||
// Эмпирически принимаем ~5 м на единицу HDOP (типовое допущение для GNSS)
|
||||
if (hdop > 0) {
|
||||
float accuracyMeters = (float)(hdop * 5.0);
|
||||
ownVessel.setAccuracy(accuracyMeters);
|
||||
}
|
||||
|
||||
// Отправляем DOP значения в GPS Location Listener
|
||||
if (gpsLocationListener != null) {
|
||||
gpsLocationListener.setDOPValues(pdop, hdop, vdop);
|
||||
@@ -827,6 +816,7 @@ public class NMEAParser {
|
||||
// Log.d(TAG, "AIS поля (" + fields.length + "): " + java.util.Arrays.toString(fields));
|
||||
if (fields.length < 7) {
|
||||
Log.w(TAG, "AIS сообщение слишком короткое: " + ais);
|
||||
LogSender.logAISParseErrorWithFullNMEA("Слишком короткое", ais, ais, "Поля: " + fields.length + " < 7");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -876,6 +866,7 @@ public class NMEAParser {
|
||||
// Проверяем контрольную сумму
|
||||
if (!validateChecksum(ais)) {
|
||||
//Log.w(TAG, "AIS сообщение с неверной контрольной суммой: " + ais);
|
||||
LogSender.logAISParseErrorWithFullNMEA("Неверная контрольная сумма", ais, payload, "Checksum validation failed");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -883,19 +874,21 @@ public class NMEAParser {
|
||||
if (payload != null && !payload.trim().isEmpty()) {
|
||||
if (totalFragments == 1) {
|
||||
// Одноканальное сообщение - декодируем сразу
|
||||
decodeAISPayload(payload, channel != null && channel.equals("A") ? 0 : 1);
|
||||
decodeAISPayload(payload, channel != null && channel.equals("A") ? 0 : 1, ais);
|
||||
} else {
|
||||
// Многочастное сообщение - собираем фрагменты
|
||||
// Используем номер фрагмента как sequenceId если поле пустое
|
||||
String actualSequenceId = (sequenceId != null && !sequenceId.trim().isEmpty()) ?
|
||||
sequenceId : String.valueOf(fragmentNumber);
|
||||
collectAISFragments(actualSequenceId, fragmentNumber, totalFragments, payload, channel != null && channel.equals("A") ? 0 : 1);
|
||||
collectAISFragments(actualSequenceId, fragmentNumber, totalFragments, payload, channel != null && channel.equals("A") ? 0 : 1, ais);
|
||||
}
|
||||
} else {
|
||||
//Log.w(TAG, "AIS payload пустой, пропускаем сообщение");
|
||||
LogSender.logAISParseErrorWithFullNMEA("Пустой payload", ais, payload, "Payload is null or empty");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
//Log.e(TAG, "Ошибка парсинга AIS сообщения: " + e.getMessage() + " для сообщения: " + ais);
|
||||
LogSender.logAISParseErrorWithFullNMEA("Exception", ais, ais, "Exception: " + e.getMessage());
|
||||
if (listener != null) {
|
||||
listener.onParseError("Ошибка парсинга AIS: " + e.getMessage());
|
||||
}
|
||||
@@ -905,7 +898,7 @@ public class NMEAParser {
|
||||
/**
|
||||
* Декодирует AIS payload
|
||||
*/
|
||||
private void decodeAISPayload(String payload, int channel) {
|
||||
private void decodeAISPayload(String payload, int channel, String fullNMEAMessage) {
|
||||
try {
|
||||
// Определяем тип AIS сообщения по первым 6 битам
|
||||
String messageTypeBits = decodeAISField(payload, 0, 6);
|
||||
@@ -953,10 +946,12 @@ public class NMEAParser {
|
||||
break;
|
||||
default:
|
||||
Log.d(TAG, "Неподдерживаемый тип AIS сообщения: " + messageType);
|
||||
LogSender.logAISParseErrorWithFullNMEA("Неподдерживаемый тип", fullNMEAMessage, payload, "Тип: " + messageType);
|
||||
break;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка декодирования AIS payload: " + e.getMessage(), e);
|
||||
LogSender.logAISParseErrorWithFullNMEA("Payload decode exception", fullNMEAMessage, payload, "Exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -964,7 +959,7 @@ public class NMEAParser {
|
||||
* Собирает фрагменты многочастного AIS сообщения
|
||||
*/
|
||||
private void collectAISFragments(String sequenceId, int fragmentNumber, int totalFragments,
|
||||
String payload, int channel) {
|
||||
String payload, int channel, String fullNMEAMessage) {
|
||||
String key = sequenceId + "_" + channel;
|
||||
|
||||
// Log.d(TAG, String.format("Собираем AIS фраг мент: %d/%d для %s",
|
||||
@@ -1007,7 +1002,7 @@ public class NMEAParser {
|
||||
// Log.d(TAG, "Собрано полное AIS сообщение длиной " + completePayload.length() + " символов");
|
||||
|
||||
// Декодируем полное сообщение
|
||||
decodeAISPayload(completePayload, channel);
|
||||
decodeAISPayload(completePayload, channel, fullNMEAMessage);
|
||||
|
||||
// Удаляем собранные фрагменты
|
||||
aisFragments.remove(key);
|
||||
@@ -1079,6 +1074,9 @@ public class NMEAParser {
|
||||
", payloadLength=" + payload.length() +
|
||||
", binaryLength=" + fullBinary.length()
|
||||
);
|
||||
LogSender.logAISParseError("AIS поле за границами", payload,
|
||||
String.format("startBit=%d, length=%d, payloadLength=%d, binaryLength=%d",
|
||||
startBit, length, payload.length(), fullBinary.length()));
|
||||
// Если поле выходит за границы, возвращаем то что есть, дополняя нулями
|
||||
if (startBit >= fullBinary.length()) {
|
||||
// Если startBit уже за границами, возвращаем строку из нулей
|
||||
@@ -1172,9 +1170,11 @@ public class NMEAParser {
|
||||
// Проверяем, что координаты в разумных пределах
|
||||
if (latitude < -90 || latitude > 90) {
|
||||
Log.w(TAG, "Широта вне допустимых пределов: " + latitude);
|
||||
LogSender.logAISParseError("Некорректная широта", payload, "Latitude: " + latitude);
|
||||
}
|
||||
if (longitude < -180 || longitude > 180) {
|
||||
Log.w(TAG, "Долгота вне допустимых пределов: " + longitude);
|
||||
LogSender.logAISParseError("Некорректная долгота", payload, "Longitude: " + longitude);
|
||||
}
|
||||
|
||||
Log.d(TAG, String.format("AIS Position: MMSI=%d, lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, status=%d, heading=%.1f, rot=%.1f",
|
||||
@@ -1223,6 +1223,7 @@ public class NMEAParser {
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка декодирования Position Report: " + e.getMessage(), e);
|
||||
LogSender.logAISParseError("Position Report decode exception", payload, "Exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1364,6 +1365,7 @@ public class NMEAParser {
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка декодирования Static Data: " + e.getMessage(), e);
|
||||
LogSender.logAISParseError("Static Data decode exception", payload, "Exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1625,6 +1627,46 @@ public class NMEAParser {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает тип навигационного знака по коду согласно стандарту AIS
|
||||
*/
|
||||
private String getAidToNavigationType(int aidType) {
|
||||
switch (aidType) {
|
||||
case 0: return "Reference point";
|
||||
case 1: return "RACON (radar transponder marking a navigation hazard)";
|
||||
case 2: return "Fixed structure off shore, such as oil platforms, wind farms, rigs";
|
||||
case 3: return "Spare, Reserved for future use";
|
||||
case 4: return "Light, without sectors";
|
||||
case 5: return "Light, with sectors";
|
||||
case 6: return "Leading Light Front";
|
||||
case 7: return "Leading Light Rear";
|
||||
case 8: return "Beacon, Cardinal N";
|
||||
case 9: return "Beacon, Cardinal E";
|
||||
case 10: return "Beacon, Cardinal S";
|
||||
case 11: return "Beacon, Cardinal W";
|
||||
case 12: return "Beacon, Port hand";
|
||||
case 13: return "Beacon, Starboard hand";
|
||||
case 14: return "Beacon, Preferred Channel port hand";
|
||||
case 15: return "Beacon, Preferred Channel starboard hand";
|
||||
case 16: return "Beacon, Isolated danger";
|
||||
case 17: return "Beacon, Safe water";
|
||||
case 18: return "Beacon, Special mark";
|
||||
case 19: return "Cardinal Mark N";
|
||||
case 20: return "Cardinal Mark E";
|
||||
case 21: return "Cardinal Mark S";
|
||||
case 22: return "Cardinal Mark W";
|
||||
case 23: return "Port hand Mark";
|
||||
case 24: return "Starboard hand Mark";
|
||||
case 25: return "Preferred Channel port hand";
|
||||
case 26: return "Preferred Channel starboard hand";
|
||||
case 27: return "Isolated danger";
|
||||
case 28: return "Safe water";
|
||||
case 29: return "Special mark";
|
||||
case 30: return "Light Vessel / LANBY / Rigs";
|
||||
default: return "Unknown Aid-to-Navigation";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает тип судна по коду согласно стандарту AIS
|
||||
*/
|
||||
@@ -1752,6 +1794,23 @@ public class NMEAParser {
|
||||
return newVessel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит существующий навигационный знак или создает новый
|
||||
*/
|
||||
private AISNavigationAid findOrCreateNavigationAid(String mmsi) {
|
||||
for (AISNavigationAid aid : navigationAids) {
|
||||
if (mmsi.equals(aid.getMmsi())) {
|
||||
return aid;
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем новый навигационный знак
|
||||
AISNavigationAid newAid = new AISNavigationAid(mmsi);
|
||||
navigationAids.add(newAid);
|
||||
Log.d(TAG, "Создан новый навигационный знак: " + mmsi);
|
||||
return newAid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает устаревшие AIS суда (данные старше 10 минут)
|
||||
*/
|
||||
@@ -1793,6 +1852,54 @@ public class NMEAParser {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает список всех навигационных знаков
|
||||
*/
|
||||
public List<AISNavigationAid> getNavigationAids() {
|
||||
return new ArrayList<>(navigationAids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает количество активных навигационных знаков
|
||||
*/
|
||||
public int getActiveNavigationAidCount() {
|
||||
cleanupStaleNavigationAids();
|
||||
return navigationAids.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает навигационный знак по MMSI
|
||||
*/
|
||||
public AISNavigationAid getNavigationAidByMMSI(String mmsi) {
|
||||
for (AISNavigationAid aid : navigationAids) {
|
||||
if (mmsi.equals(aid.getMmsi())) {
|
||||
return aid;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает устаревшие навигационные знаки (данные старше 10 минут)
|
||||
*/
|
||||
public void cleanupStaleNavigationAids() {
|
||||
java.util.Iterator<AISNavigationAid> iterator = navigationAids.iterator();
|
||||
int removedCount = 0;
|
||||
|
||||
while (iterator.hasNext()) {
|
||||
AISNavigationAid aid = iterator.next();
|
||||
if (aid.isDataStale(10)) {
|
||||
iterator.remove();
|
||||
removedCount++;
|
||||
Log.d(TAG, "Удален устаревший навигационный знак: " + aid.getMmsi());
|
||||
}
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
Log.i(TAG, "Удалено " + removedCount + " устаревших навигационных знаков");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет статус активности AIS судов
|
||||
*/
|
||||
@@ -1822,6 +1929,8 @@ public class NMEAParser {
|
||||
return isPositive ? result : -result;
|
||||
} catch (NumberFormatException e) {
|
||||
Log.w(TAG, "Ошибка парсинга координаты: " + coordinate + ", ошибка: " + e.getMessage());
|
||||
LogSender.logError("COORDINATE_PARSE_ERROR", "Ошибка парсинга координаты",
|
||||
String.format("Coordinate: %s, Error: %s", coordinate, e.getMessage()));
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
@@ -1994,6 +2103,7 @@ public class NMEAParser {
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка декодирования Base Station Report: " + e.getMessage(), e);
|
||||
LogSender.logAISParseError("Base Station Report decode exception", payload, "Exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2040,6 +2150,7 @@ public class NMEAParser {
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка декодирования Safety Broadcast: " + e.getMessage(), e);
|
||||
LogSender.logAISParseError("Safety Broadcast decode exception", payload, "Exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2162,6 +2273,7 @@ public class NMEAParser {
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка декодирования Class B Position Report: " + e.getMessage(), e);
|
||||
LogSender.logAISParseError("Class B Position Report decode exception", payload, "Exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2178,6 +2290,7 @@ public class NMEAParser {
|
||||
|
||||
if (totalBits < 312) { // Минимум для Extended Class B
|
||||
Log.w(TAG, "Extended Class B payload слишком короткий: " + totalBits + " бит, ожидается минимум 312");
|
||||
LogSender.logAISParseError("Extended Class B слишком короткий", payload, "Bits: " + totalBits + " < 312");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2314,6 +2427,8 @@ public class NMEAParser {
|
||||
// Проверяем, что размеры в разумных пределах (0-1000 метров)
|
||||
if (dimA > 1000 || dimB > 1000 || dimC > 1000 || dimD > 1000) {
|
||||
Log.w(TAG, "Размеры судна выходят за разумные пределы: A=" + dimA + ", B=" + dimB + ", C=" + dimC + ", D=" + dimD);
|
||||
LogSender.logAISParseError("Некорректные размеры", payload,
|
||||
String.format("Dimensions out of range: A=%d, B=%d, C=%d, D=%d", dimA, dimB, dimC, dimD));
|
||||
// Возможно, мы неправильно интерпретируем битовые поля
|
||||
// Попробуем интерпретировать как 6-битные значения
|
||||
dimA = dimA & 0x3F; // Берем только младшие 6 бит
|
||||
@@ -2380,6 +2495,7 @@ public class NMEAParser {
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка декодирования Extended Class B Position Report: " + e.getMessage(), e);
|
||||
LogSender.logAISParseError("Extended Class B decode exception", payload, "Exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2388,40 +2504,65 @@ public class NMEAParser {
|
||||
*/
|
||||
private void decodeAidToNavigationReport(String payload) {
|
||||
try {
|
||||
// Log.d(TAG, "Декодируем Aid-to-Navigation Report, payload: " + payload + " (длина: " + payload.length() + ")");
|
||||
Log.d(TAG, "Декодируем Aid-to-Navigation Report, payload: " + payload + " (длина: " + payload.length() + ")");
|
||||
|
||||
// Проверяем длину payload - для Aid-to-Navigation должно быть достаточно битов
|
||||
int totalBits = payload.length() * 6;
|
||||
Log.d(TAG, "Общая длина payload в битах: " + totalBits);
|
||||
|
||||
if (totalBits < 192) { // Минимум для основных полей
|
||||
Log.w(TAG, "Aid-to-Navigation payload слишком короткий: " + totalBits + " бит, ожидается минимум 192");
|
||||
LogSender.logAISParseError("Aid-to-Navigation слишком короткий", payload, "Bits: " + totalBits + " < 192");
|
||||
return;
|
||||
}
|
||||
|
||||
// MMSI (30 бит) - начинается с бита 8
|
||||
String mmsiBits = decodeAISField(payload, 8, 30);
|
||||
int mmsi = Integer.parseInt(mmsiBits, 2);
|
||||
// Убираем лишние логи
|
||||
// Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi);
|
||||
Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi);
|
||||
|
||||
// Aid Type (5 бит) - бит 38
|
||||
String aidTypeBits = decodeAISField(payload, 38, 5);
|
||||
int aidType = Integer.parseInt(aidTypeBits, 2);
|
||||
// Log.d(TAG, "Aid Type bits: " + aidTypeBits + " = " + aidType);
|
||||
Log.d(TAG, "Aid Type bits: " + aidTypeBits + " = " + aidType);
|
||||
|
||||
// Name (120 бит) - бит 43
|
||||
String nameBits = decodeAISField(payload, 43, 120);
|
||||
String aidName = decodeAISString(nameBits);
|
||||
// Log.d(TAG, "Name bits: " + nameBits + " = '" + aidName + "'");
|
||||
Log.d(TAG, "Name bits: " + nameBits + " = '" + aidName + "'");
|
||||
|
||||
// Position Accuracy (1 бит) - бит 163
|
||||
String accuracyBits = decodeAISField(payload, 163, 1);
|
||||
int accuracy = Integer.parseInt(accuracyBits, 2);
|
||||
// Log.d(TAG, "Accuracy bits: " + accuracyBits + " = " + accuracy);
|
||||
Log.d(TAG, "Accuracy bits: " + accuracyBits + " = " + accuracy);
|
||||
|
||||
// Longitude (28 бит) - бит 164
|
||||
String lonBits = decodeAISField(payload, 164, 28);
|
||||
double longitude = parseAISCoordinate(lonBits, 28);
|
||||
// Убираем лишние логи
|
||||
// Log.d(TAG, "Longitude bits: " + lonBits + " = " + longitude);
|
||||
Log.d(TAG, "Longitude bits: " + lonBits + " = " + longitude);
|
||||
|
||||
// Latitude (27 бит) - бит 192
|
||||
String latBits = decodeAISField(payload, 192, 27);
|
||||
double latitude = parseAISCoordinate(latBits, 27);
|
||||
// Убираем лишние логи
|
||||
// Log.d(TAG, "Latitude bits: " + latBits + " = " + latitude);
|
||||
Log.d(TAG, "Latitude bits: " + latBits + " = " + latitude);
|
||||
|
||||
// Создаем навигационный знак
|
||||
AISNavigationAid navigationAid = findOrCreateNavigationAid(String.valueOf(mmsi));
|
||||
navigationAid.setAidName(aidName);
|
||||
navigationAid.setAidType(aidType);
|
||||
navigationAid.updatePosition(latitude, longitude);
|
||||
navigationAid.setPositionAccuracy(accuracy == 1);
|
||||
|
||||
// Проверяем, есть ли достаточно битов для размеров
|
||||
if (totalBits < 235) {
|
||||
Log.w(TAG, "Aid-to-Navigation - недостаточно битов для размеров: " + totalBits + " < 235");
|
||||
navigationAid.setLastUpdate(java.time.LocalDateTime.now());
|
||||
|
||||
if (listener != null) {
|
||||
listener.onNavigationAidUpdated(navigationAid);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Dimension Reference (4 бита) - бит 219
|
||||
String dimRefABits = decodeAISField(payload, 219, 4);
|
||||
@@ -2434,6 +2575,13 @@ public class NMEAParser {
|
||||
int dimRefC = Integer.parseInt(dimRefCBits, 2);
|
||||
int dimRefD = Integer.parseInt(dimRefDBits, 2);
|
||||
|
||||
Log.d(TAG, "Dimension Reference: A=" + dimRefA + ", B=" + dimRefB + ", C=" + dimRefC + ", D=" + dimRefD);
|
||||
|
||||
navigationAid.setDimRefA(dimRefA);
|
||||
navigationAid.setDimRefB(dimRefB);
|
||||
navigationAid.setDimRefC(dimRefC);
|
||||
navigationAid.setDimRefD(dimRefD);
|
||||
|
||||
// Vessel Dimensions (30 бит) - бит 235
|
||||
// Dim.A (10 бит) - от носа до антенны
|
||||
String dimABits = decodeAISField(payload, 235, 10);
|
||||
@@ -2443,43 +2591,92 @@ public class NMEAParser {
|
||||
String dimCBits = decodeAISField(payload, 255, 10);
|
||||
// Dim.D (10 бит) - от антенны до правого борта
|
||||
String dimDBits = decodeAISField(payload, 265, 10);
|
||||
// Draft (8 бит) - осадка
|
||||
String draftBits = decodeAISField(payload, 275, 8);
|
||||
|
||||
int dimA = Integer.parseInt(dimABits, 2);
|
||||
int dimB = Integer.parseInt(dimBBits, 2);
|
||||
int dimC = Integer.parseInt(dimCBits, 2);
|
||||
int dimD = Integer.parseInt(dimDBits, 2);
|
||||
|
||||
Log.d(TAG, "Raw dimension bits - Dim.A: " + dimABits + ", Dim.B: " + dimBBits + ", Dim.C: " + dimCBits + ", Dim.D: " + dimDBits);
|
||||
Log.d(TAG, "Raw dimensions - A=" + dimA + ", B=" + dimB + ", C=" + dimC + ", D=" + dimD);
|
||||
|
||||
// Размеры судна рассчитываются как:
|
||||
// Длина = Dim.A + Dim.B (от носа до антенны + от антенны до кормы)
|
||||
// Ширина = Dim.C + Dim.D (от левого борта до антенны + от антенны до правого борта)
|
||||
double length = dimA + dimB;
|
||||
double width = dimC + dimD;
|
||||
double draft = Integer.parseInt(draftBits, 2) / 10.0;
|
||||
|
||||
// Log.d(TAG, String.format("AIS Aid-to-Navigation: MMSI=%d, type=%d, name='%s', lat=%.6f, lon=%.6f, L=%.1f, W=%.1f, D=%.1f",
|
||||
// mmsi, aidType, aidName, latitude, longitude, length, width, draft));
|
||||
Log.d(TAG, "Dimensions - Total Length (A+B): " + length + "m");
|
||||
Log.d(TAG, "Dimensions - Total Width (C+D): " + width + "m");
|
||||
|
||||
// Создаем или обновляем AIS судно (навигационный знак)
|
||||
AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi));
|
||||
vessel.updatePosition(latitude, longitude, 0.0, 0.0);
|
||||
vessel.setPositionAccuracy(accuracy == 1);
|
||||
vessel.setVesselName(aidName);
|
||||
vessel.setVesselType("Aid-to-Navigation");
|
||||
vessel.setLength(length);
|
||||
vessel.setWidth(width);
|
||||
vessel.setDraft(draft);
|
||||
vessel.setLastUpdate(java.time.LocalDateTime.now());
|
||||
vessel.setVesselClass("Navigation Aid");
|
||||
navigationAid.setLength(length);
|
||||
navigationAid.setWidth(width);
|
||||
|
||||
// Проверяем, есть ли достаточно битов для дополнительных полей
|
||||
if (totalBits >= 283) {
|
||||
// Draft (8 бит) - осадка
|
||||
String draftBits = decodeAISField(payload, 275, 8);
|
||||
double draft = Integer.parseInt(draftBits, 2) / 10.0;
|
||||
Log.d(TAG, "Draft bits: " + draftBits + " = " + draft);
|
||||
navigationAid.setDraft(draft);
|
||||
|
||||
// EPFD Type (4 бита) - бит 283
|
||||
if (totalBits >= 287) {
|
||||
String epfdBits = decodeAISField(payload, 283, 4);
|
||||
int epfdType = Integer.parseInt(epfdBits, 2);
|
||||
Log.d(TAG, "EPFD Type bits: " + epfdBits + " = " + epfdType);
|
||||
navigationAid.setEpfdType(epfdType);
|
||||
|
||||
// UTC Second (6 бит) - бит 287
|
||||
if (totalBits >= 293) {
|
||||
String utcSecondBits = decodeAISField(payload, 287, 6);
|
||||
int utcSecond = Integer.parseInt(utcSecondBits, 2);
|
||||
Log.d(TAG, "UTC Second bits: " + utcSecondBits + " = " + utcSecond);
|
||||
navigationAid.setUtcSecond(utcSecond);
|
||||
|
||||
// Off Position Indicator (1 бит) - бит 293
|
||||
if (totalBits >= 294) {
|
||||
String offPosBits = decodeAISField(payload, 293, 1);
|
||||
boolean offPosition = Integer.parseInt(offPosBits, 2) == 1;
|
||||
Log.d(TAG, "Off Position Indicator bits: " + offPosBits + " = " + offPosition);
|
||||
navigationAid.setOffPositionIndicator(offPosition);
|
||||
|
||||
// Regional Reserved (8 бит) - бит 294
|
||||
if (totalBits >= 302) {
|
||||
String regionalBits = decodeAISField(payload, 294, 8);
|
||||
int regional = Integer.parseInt(regionalBits, 2);
|
||||
Log.d(TAG, "Regional Reserved bits: " + regionalBits + " = " + regional);
|
||||
navigationAid.setRegionalReserved(regional);
|
||||
|
||||
// RAIM flag (1 бит) - бит 302
|
||||
if (totalBits >= 303) {
|
||||
String raimBits = decodeAISField(payload, 302, 1);
|
||||
boolean raim = Integer.parseInt(raimBits, 2) == 1;
|
||||
Log.d(TAG, "RAIM flag bits: " + raimBits + " = " + raim);
|
||||
navigationAid.setRaimFlag(raim);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Aid-to-Navigation - недостаточно битов для дополнительных полей: " + totalBits + " < 283");
|
||||
}
|
||||
|
||||
navigationAid.setLastUpdate(java.time.LocalDateTime.now());
|
||||
|
||||
Log.d(TAG, String.format("AIS Aid-to-Navigation: MMSI=%d, type=%d (%s), name='%s', lat=%.6f, lon=%.6f, L=%.1f, W=%.1f, D=%.1f",
|
||||
mmsi, aidType, navigationAid.getAidTypeDescription(), aidName, latitude, longitude,
|
||||
navigationAid.getLength(), navigationAid.getWidth(), navigationAid.getDraft()));
|
||||
|
||||
// Уведомляем слушателя
|
||||
if (listener != null) {
|
||||
listener.onAISVesselUpdated(vessel);
|
||||
listener.onNavigationAidUpdated(navigationAid);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка декодирования Aid-to-Navigation Report: " + e.getMessage(), e);
|
||||
LogSender.logAISParseError("Aid-to-Navigation Report decode exception", payload, "Exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2589,6 +2786,7 @@ public class NMEAParser {
|
||||
|
||||
} else {
|
||||
Log.w(TAG, "Static Data Part B - недостаточно битов для размеров: " + totalBits + " < 168");
|
||||
LogSender.logAISParseError("Static Data Part B слишком короткий", payload, "Bits: " + totalBits + " < 168");
|
||||
// Используем нулевые размеры
|
||||
length = 0.0;
|
||||
width = 0.0;
|
||||
@@ -2623,6 +2821,7 @@ public class NMEAParser {
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка декодирования Static Data Report: " + e.getMessage(), e);
|
||||
LogSender.logAISParseError("Static Data Report decode exception", payload, "Exception: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +28,23 @@ public class Repository {
|
||||
ioExecutor.execute(() -> aisVesselDao.upsert(entity));
|
||||
}
|
||||
|
||||
public void upsertAISSync(AISVesselEntity entity) {
|
||||
aisVesselDao.upsert(entity);
|
||||
}
|
||||
|
||||
public void upsertAISBatchSync(List<AISVesselEntity> entities) {
|
||||
if (entities == null || entities.isEmpty()) return;
|
||||
aisVesselDao.upsertAll(entities);
|
||||
}
|
||||
|
||||
public void deleteStaleAIS(long thresholdEpochMs) {
|
||||
ioExecutor.execute(() -> aisVesselDao.deleteStale(thresholdEpochMs));
|
||||
}
|
||||
|
||||
public int deleteStaleAISSync(long thresholdEpochMs) {
|
||||
return aisVesselDao.deleteStale(thresholdEpochMs);
|
||||
}
|
||||
|
||||
public List<AISVesselEntity> getAllAISSync() {
|
||||
return aisVesselDao.getAll();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ public interface AISVesselDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void upsert(AISVesselEntity entity);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void upsertAll(List<AISVesselEntity> entities);
|
||||
|
||||
@Update
|
||||
void update(AISVesselEntity entity);
|
||||
|
||||
@@ -29,7 +32,7 @@ public interface AISVesselDao {
|
||||
AISVesselEntity getByMmsi(String mmsi);
|
||||
|
||||
@Query("DELETE FROM ais_vessels WHERE lastUpdateEpochMs < :threshold")
|
||||
void deleteStale(long threshold);
|
||||
int deleteStale(long threshold);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.mapsforge.core.graphics.Bitmap;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -112,14 +113,24 @@ public class MapForgeImpl implements MapInterface {
|
||||
@Override
|
||||
public void updateAISVesselPosition(AISVessel vessel) {
|
||||
Marker marker = aisMarkers.get(vessel.getMmsi());
|
||||
if (marker != null) {
|
||||
LatLong newPosition = new LatLong(vessel.getLatitude(), vessel.getLongitude());
|
||||
marker.setLatLong(newPosition);
|
||||
|
||||
// Используем heading вместо course для поворота маркера AIS судна
|
||||
double rotationAngle = vessel.getHeading() > 0 ? vessel.getHeading() : vessel.getCourse();
|
||||
org.mapsforge.core.graphics.Bitmap icon = createMapForgeIcon(Color.RED, rotationAngle);
|
||||
marker.setBitmap(icon);
|
||||
if (marker == null) {
|
||||
addAISVesselMarker(vessel);
|
||||
return;
|
||||
}
|
||||
LatLong newPosition = new LatLong(vessel.getLatitude(), vessel.getLongitude());
|
||||
marker.setLatLong(newPosition);
|
||||
|
||||
// Используем heading вместо course для поворота маркера AIS судна
|
||||
double rotationAngle = vessel.getHeading() > 0 ? vessel.getHeading() : vessel.getCourse();
|
||||
org.mapsforge.core.graphics.Bitmap icon = createMapForgeIcon(Color.RED, rotationAngle);
|
||||
marker.setBitmap(icon);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateAISVesselPositions(List<AISVessel> vessels) {
|
||||
if (vessels == null) return;
|
||||
for (AISVessel vessel : vessels) {
|
||||
updateAISVesselPosition(vessel);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.grigowashere.aismap.maps;
|
||||
import com.grigowashere.aismap.models.Vessel;
|
||||
import com.grigowashere.aismap.models.AISVessel;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Интерфейс для работы с картами
|
||||
* Позволяет использовать разные SDK карт
|
||||
@@ -38,6 +40,11 @@ public interface MapInterface {
|
||||
* Обновление позиции AIS судна
|
||||
*/
|
||||
void updateAISVesselPosition(AISVessel vessel);
|
||||
|
||||
/**
|
||||
* Пакетное обновление AIS судов
|
||||
*/
|
||||
void updateAISVesselPositions(List<AISVessel> vessels);
|
||||
|
||||
/**
|
||||
* Удаление метки AIS судна
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.graphics.drawable.Drawable;
|
||||
import android.util.Log;
|
||||
|
||||
import com.grigowashere.aismap.models.AISVessel;
|
||||
import com.grigowashere.aismap.models.AISNavigationAid;
|
||||
import com.grigowashere.aismap.models.Vessel;
|
||||
import com.grigowashere.aismap.utils.SettingsManager;
|
||||
import com.grigowashere.aismap.utils.GeoUtils;
|
||||
@@ -32,6 +33,8 @@ import org.maplibre.android.style.expressions.Expression;
|
||||
import org.maplibre.android.style.layers.PropertyFactory;
|
||||
import org.maplibre.android.style.layers.SymbolLayer;
|
||||
import org.maplibre.android.style.sources.GeoJsonSource;
|
||||
import org.maplibre.android.style.sources.RasterSource;
|
||||
import org.maplibre.android.style.layers.RasterLayer;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
@@ -55,6 +58,10 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
private static final String LAYER_AIS_PATHS = "ais_paths_layer";
|
||||
private static final String SOURCE_AIS_PREDICTIONS = "ais_predictions_source";
|
||||
private static final String LAYER_AIS_PREDICTIONS = "ais_predictions_layer";
|
||||
private static final String SOURCE_SEAMARKS = "seamarks_source";
|
||||
private static final String LAYER_SEAMARKS = "seamarks_layer";
|
||||
private static final String SOURCE_NAVIGATION_AIDS = "navigation_aids_source";
|
||||
private static final String LAYER_NAVIGATION_AIDS = "navigation_aids_layer";
|
||||
private static final String IMAGE_VESSEL_OWN = "ownship";
|
||||
private static final String IMAGE_VESSEL_A = "vessel_icon_a";
|
||||
private static final String IMAGE_VESSEL_B = "vessel_icon_b";
|
||||
@@ -69,6 +76,44 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
private static final String TYPE_NAVY = "navy";
|
||||
private static final String TYPE_OTHER = "other";
|
||||
private static final String IMAGE_VESSEL_LOSING = "vessel_icon_losing";
|
||||
// Navigation Aid icons - полный набор для всех типов буйков
|
||||
private static final String IMAGE_NAVIGATION_AID = "navigation_aid";
|
||||
|
||||
// Кардинальные буи (North, East, South, West)
|
||||
private static final String IMAGE_BUOY_CARDINAL_N = "buoy_cardinal_n";
|
||||
private static final String IMAGE_BUOY_CARDINAL_E = "buoy_cardinal_e";
|
||||
private static final String IMAGE_BUOY_CARDINAL_S = "buoy_cardinal_s";
|
||||
private static final String IMAGE_BUOY_CARDINAL_W = "buoy_cardinal_w";
|
||||
private static final String IMAGE_BUOY_CARDINAL = "buoy_cardinal"; // Общий fallback
|
||||
|
||||
// Латеральные буи (Port/Starboard)
|
||||
private static final String IMAGE_BUOY_PORT = "buoy_port";
|
||||
private static final String IMAGE_BUOY_STARBOARD = "buoy_starboard";
|
||||
|
||||
// Предпочтительные каналы
|
||||
private static final String IMAGE_BUOY_PREFERRED_PORT = "buoy_preferred_port";
|
||||
private static final String IMAGE_BUOY_PREFERRED_STARBOARD = "buoy_preferred_starboard";
|
||||
|
||||
// Маяки и огни
|
||||
private static final String IMAGE_LIGHT = "light";
|
||||
private static final String IMAGE_LIGHT_SECTOR = "light_sector";
|
||||
private static final String IMAGE_LIGHT_LEADING_FRONT = "light_leading_front";
|
||||
private static final String IMAGE_LIGHT_LEADING_REAR = "light_leading_rear";
|
||||
|
||||
// Буи и знаки
|
||||
private static final String IMAGE_BEACON = "beacon";
|
||||
private static final String IMAGE_BEACON_ISOLATED_DANGER = "beacon_isolated_danger";
|
||||
private static final String IMAGE_BEACON_SAFE_WATER = "beacon_safe_water";
|
||||
private static final String IMAGE_BEACON_SPECIAL = "beacon_special";
|
||||
|
||||
// Платформы и плавучие маяки
|
||||
private static final String IMAGE_BASE_STATION = "base_station";
|
||||
private static final String IMAGE_LIGHT_VESSEL = "light_vessel";
|
||||
private static final String IMAGE_PLATFORM = "platform";
|
||||
|
||||
// RACON и специальные знаки
|
||||
private static final String IMAGE_RACON = "racon";
|
||||
private static final String IMAGE_REFERENCE_POINT = "reference_point";
|
||||
// Status overlay drawable names present in res/drawable
|
||||
private static final String STATUS_UNDER_WAY_ENGINE = "engine";
|
||||
private static final String STATUS_AT_ANCHOR = "achor"; // anchor icon filename
|
||||
@@ -177,6 +222,7 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
private final Map<String, JSONObject> idToFeature = new HashMap<>();
|
||||
// Хранилище последних модельных объектов для кликов
|
||||
private final Map<String, AISVessel> idToAisVessel = new HashMap<>();
|
||||
private final Map<String, AISNavigationAid> idToNavigationAid = new HashMap<>();
|
||||
private Vessel lastOwnVessel;
|
||||
// Буфер координат пути собственного судна
|
||||
private final JSONArray ownPathCoords = new JSONArray();
|
||||
@@ -188,6 +234,10 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
|
||||
private MarkerClickListener markerClickListener;
|
||||
|
||||
// Pending центрирование до готовности карты/стиля
|
||||
private Double pendingCenterLat = null;
|
||||
private Double pendingCenterLon = null;
|
||||
|
||||
public MapLibreMapImpl(Context context, MapView mapView) {
|
||||
this.context = context;
|
||||
this.mapView = mapView;
|
||||
@@ -274,6 +324,18 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
try {
|
||||
mapView.getMapAsync(map -> {
|
||||
maplibreMap = map;
|
||||
// Отключаем встроенный компас MapLibre (он появлялся в углу
|
||||
// при ненулевом bearing и дублировал наш компас), а также
|
||||
// стандартные UI-элементы, которые нам не нужны.
|
||||
try {
|
||||
if (maplibreMap.getUiSettings() != null) {
|
||||
maplibreMap.getUiSettings().setCompassEnabled(false);
|
||||
maplibreMap.getUiSettings().setAttributionEnabled(false);
|
||||
maplibreMap.getUiSettings().setLogoEnabled(false);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Не удалось настроить UI MapLibre: " + e.getMessage());
|
||||
}
|
||||
maplibreMap.setStyle("https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", loadedStyle -> {
|
||||
style = loadedStyle;
|
||||
ensureSourcesAndLayers();
|
||||
@@ -302,6 +364,15 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
|
||||
staleHandler.removeCallbacks(staleRunnable);
|
||||
staleHandler.postDelayed(staleRunnable, 5_000L);
|
||||
|
||||
// Если было отложенное центрирование — применим его сразу после загрузки стиля
|
||||
if (pendingCenterLat != null && pendingCenterLon != null) {
|
||||
final double lat = pendingCenterLat;
|
||||
final double lon = pendingCenterLon;
|
||||
pendingCenterLat = null;
|
||||
pendingCenterLon = null;
|
||||
uiHandler.post(() -> centerOnPosition(lat, lon));
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (Exception e) {
|
||||
@@ -502,6 +573,66 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateAISVesselPositions(List<AISVessel> vessels) {
|
||||
if (vessels == null || vessels.isEmpty()) return;
|
||||
|
||||
try {
|
||||
int updated = 0;
|
||||
int removed = 0;
|
||||
int skipped = 0;
|
||||
|
||||
for (AISVessel vessel : vessels) {
|
||||
if (vessel == null || vessel.getMmsi() == null) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!GeoUtils.isValidCoordinates(vessel.getLatitude(), vessel.getLongitude())) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (vessel.shouldBeRemoved(settingsManager.getDataStaleRemoveMinutes())) {
|
||||
idToFeature.remove(vessel.getMmsi());
|
||||
idToAisVessel.remove(vessel.getMmsi());
|
||||
aisPathFeatures.remove(vessel.getMmsi());
|
||||
aisPredictionFeatures.remove(vessel.getMmsi());
|
||||
removed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
idToAisVessel.put(vessel.getMmsi(), vessel);
|
||||
JSONObject feature = buildFeature(
|
||||
vessel.getMmsi(),
|
||||
vessel.getLongitude(),
|
||||
vessel.getLatitude(),
|
||||
getDisplayCourse(vessel),
|
||||
false
|
||||
);
|
||||
try {
|
||||
boolean stale = vessel.isDataStale(settingsManager.getDataStaleWarningMinutes());
|
||||
JSONObject props = feature.getJSONObject("properties");
|
||||
props.put("icon", pickIconNameFor(vessel));
|
||||
props.put("stale", stale);
|
||||
String statusIcon = mapStatusToIcon(vessel.getNavigationalStatus());
|
||||
if (statusIcon != null) {
|
||||
props.put("status_icon", statusIcon);
|
||||
} else {
|
||||
props.remove("status_icon");
|
||||
}
|
||||
} catch (Exception ignore) {}
|
||||
|
||||
idToFeature.put(vessel.getMmsi(), feature);
|
||||
updated++;
|
||||
}
|
||||
|
||||
refreshGeoJson();
|
||||
Log.d(TAG, "AIS batch update: updated=" + updated +
|
||||
", removed=" + removed + ", skipped=" + skipped);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "updateAISVesselPositions: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAISVesselMarker(String mmsi) {
|
||||
if (mmsi == null) return;
|
||||
@@ -555,13 +686,122 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
uiHandler.post(() -> refreshGeoJson());
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет навигационный знак на карту
|
||||
*/
|
||||
public void addNavigationAidMarker(AISNavigationAid navigationAid) {
|
||||
updateNavigationAidPosition(navigationAid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет позицию навигационного знака на карте
|
||||
*/
|
||||
public void updateNavigationAidPosition(AISNavigationAid navigationAid) {
|
||||
if (!isStyleValid()) {
|
||||
Log.w(TAG, "Style not ready, skipping updateNavigationAidPosition");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
String mmsi = navigationAid.getMmsi();
|
||||
Log.d(TAG, "updateNavigationAidPosition: Navigation aid " + mmsi + " at " +
|
||||
navigationAid.getLatitude() + "," + navigationAid.getLongitude() +
|
||||
", type=" + navigationAid.getAidType() + ", name='" + navigationAid.getAidName() + "'");
|
||||
|
||||
// Создаем GeoJSON фичу для навигационного знака
|
||||
JSONObject feature = buildNavigationAidFeature(navigationAid);
|
||||
idToFeature.put(mmsi, feature);
|
||||
idToNavigationAid.put(mmsi, navigationAid);
|
||||
|
||||
// Обновляем источник данных
|
||||
refreshNavigationAidsSource();
|
||||
|
||||
Log.d(TAG, "Navigation aid " + mmsi + " updated on map");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error updating navigation aid position: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет навигационный знак с карты
|
||||
*/
|
||||
public void removeNavigationAidMarker(String mmsi) {
|
||||
if (!isStyleValid()) {
|
||||
Log.w(TAG, "Style not ready, skipping removeNavigationAidMarker");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Удаляем из хранилищ
|
||||
idToFeature.remove(mmsi);
|
||||
idToNavigationAid.remove(mmsi);
|
||||
|
||||
// Обновляем источник данных
|
||||
refreshNavigationAidsSource();
|
||||
|
||||
Log.d(TAG, "Navigation aid " + mmsi + " removed from map");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error removing navigation aid marker: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает все навигационные знаки с карты
|
||||
*/
|
||||
public void clearNavigationAidMarkers() {
|
||||
if (!isStyleValid()) {
|
||||
Log.w(TAG, "Style not ready, skipping clearNavigationAidMarkers");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
GeoJsonSource source = style.getSourceAs(SOURCE_NAVIGATION_AIDS);
|
||||
if (source != null) {
|
||||
source.setGeoJson(emptyFeatureCollection());
|
||||
}
|
||||
|
||||
// Очищаем хранилища
|
||||
idToNavigationAid.clear();
|
||||
|
||||
Log.d(TAG, "Navigation aid markers cleared");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error clearing navigation aid markers: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void centerOnPosition(double latitude, double longitude) {
|
||||
if (maplibreMap == null) return;
|
||||
maplibreMap.setCameraPosition(new org.maplibre.android.camera.CameraPosition.Builder()
|
||||
.target(new LatLng(latitude, longitude))
|
||||
.zoom(13.0)
|
||||
.build());
|
||||
if (maplibreMap == null || style == null) {
|
||||
// Сохраним pending, применим после загрузки стиля
|
||||
pendingCenterLat = latitude;
|
||||
pendingCenterLon = longitude;
|
||||
// И на всякий случай повторим позже
|
||||
try {
|
||||
if (uiHandler != null) {
|
||||
uiHandler.postDelayed(() -> centerOnPosition(latitude, longitude), 300);
|
||||
}
|
||||
} catch (Exception ignore) {}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
org.maplibre.android.camera.CameraPosition current = maplibreMap.getCameraPosition();
|
||||
float targetZoom = (float) current.zoom;
|
||||
|
||||
// Если зум слишком маленький (меньше 5), используем стартовый зум из настроек
|
||||
if (targetZoom < 5.0f) {
|
||||
targetZoom = settingsManager.getStartZoomLevel();
|
||||
Log.i(TAG, "Принудительно устанавливаем стартовый зум: " + targetZoom);
|
||||
}
|
||||
|
||||
maplibreMap.setCameraPosition(new org.maplibre.android.camera.CameraPosition.Builder()
|
||||
.target(new LatLng(latitude, longitude))
|
||||
.zoom(targetZoom)
|
||||
.tilt(current.tilt)
|
||||
.bearing(current.bearing)
|
||||
.build());
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "centerOnPosition: MapView may be destroyed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -620,12 +860,38 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
|
||||
@Override
|
||||
public void addLayer(String layerId, Object layerData) {
|
||||
// Не используется в первой итерации
|
||||
if (style == null || !isStyleValid()) {
|
||||
Log.w(TAG, "addLayer: стиль не готов, откладываем добавление слоя " + layerId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if ("seamarks".equals(layerId)) {
|
||||
addSeamarksLayer();
|
||||
} else {
|
||||
Log.w(TAG, "addLayer: неизвестный тип слоя " + layerId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "addLayer: ошибка добавления слоя " + layerId, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeLayer(String layerId) {
|
||||
// Не используется в первой итерации
|
||||
if (style == null || !isStyleValid()) {
|
||||
Log.w(TAG, "removeLayer: стиль не готов, пропускаем удаление слоя " + layerId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if ("seamarks".equals(layerId)) {
|
||||
removeSeamarksLayer();
|
||||
} else {
|
||||
Log.w(TAG, "removeLayer: неизвестный тип слоя " + layerId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "removeLayer: ошибка удаления слоя " + layerId, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -671,11 +937,16 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
Log.w(TAG, "Не удалось добавить иконки: " + e.getMessage());
|
||||
}
|
||||
|
||||
// Источник GeoJSON
|
||||
// Источник GeoJSON для судов
|
||||
if (style.getSource(SOURCE_VESSELS) == null) {
|
||||
style.addSource(new GeoJsonSource(SOURCE_VESSELS, emptyFeatureCollection()));
|
||||
}
|
||||
|
||||
// Источник GeoJSON для навигационных знаков
|
||||
if (style.getSource(SOURCE_NAVIGATION_AIDS) == null) {
|
||||
style.addSource(new GeoJsonSource(SOURCE_NAVIGATION_AIDS, emptyFeatureCollection()));
|
||||
}
|
||||
|
||||
// Отладочные линии удалены
|
||||
|
||||
// Слой символов (основные иконки)
|
||||
@@ -712,6 +983,36 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
style.addLayer(layer);
|
||||
}
|
||||
|
||||
// Слой навигационных знаков
|
||||
if (style.getLayer(LAYER_NAVIGATION_AIDS) == null) {
|
||||
SymbolLayer navigationAidLayer = new SymbolLayer(LAYER_NAVIGATION_AIDS, SOURCE_NAVIGATION_AIDS)
|
||||
.withProperties(
|
||||
PropertyFactory.iconImage(
|
||||
Expression.coalesce(
|
||||
Expression.get("icon"),
|
||||
Expression.literal("green_buey")
|
||||
)
|
||||
),
|
||||
PropertyFactory.iconAnchor(org.maplibre.android.style.layers.Property.ICON_ANCHOR_CENTER),
|
||||
PropertyFactory.iconRotationAlignment(org.maplibre.android.style.layers.Property.ICON_ROTATION_ALIGNMENT_MAP),
|
||||
PropertyFactory.iconAllowOverlap(true),
|
||||
PropertyFactory.iconIgnorePlacement(true),
|
||||
PropertyFactory.iconSize(
|
||||
Expression.interpolate(
|
||||
Expression.linear(),
|
||||
Expression.zoom(),
|
||||
Expression.stop(5, 0.08f),
|
||||
Expression.stop(8, 0.10f),
|
||||
Expression.stop(12, 0.15f),
|
||||
Expression.stop(15, 0.20f),
|
||||
Expression.stop(17, 0.25f)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
style.addLayer(navigationAidLayer);
|
||||
}
|
||||
|
||||
// Слой предупреждения (losing) поверх — рисуется поверх, если feature.properties.stale == true
|
||||
if (style.getLayer(LAYER_VESSELS_STALE) == null && style.getImage(IMAGE_VESSEL_LOSING) != null) {
|
||||
SymbolLayer losingLayer = new SymbolLayer(LAYER_VESSELS_STALE, SOURCE_VESSELS)
|
||||
@@ -783,6 +1084,9 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
// Восстанавливаем путь судна после создания слоев
|
||||
restoreVesselPath();
|
||||
|
||||
// Обновляем дополнительные слои на основе настроек
|
||||
updateAdditionalLayers();
|
||||
|
||||
Log.d(TAG, "ensureSourcesAndLayers: completed");
|
||||
}
|
||||
|
||||
@@ -958,6 +1262,54 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
Log.e(TAG, "refreshGeoJson: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет источник данных навигационных знаков
|
||||
*/
|
||||
private void refreshNavigationAidsSource() {
|
||||
if (style == null) return;
|
||||
|
||||
try {
|
||||
if (!isStyleValid()) {
|
||||
Log.w(TAG, "refreshNavigationAidsSource: стиль не валиден, пропускаем обновление");
|
||||
return;
|
||||
}
|
||||
|
||||
GeoJsonSource source = (GeoJsonSource) style.getSource(SOURCE_NAVIGATION_AIDS);
|
||||
if (source == null) {
|
||||
Log.w(TAG, "refreshNavigationAidsSource: источник NAVIGATION_AIDS не найден, пропускаем обновление");
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем FeatureCollection из всех навигационных знаков
|
||||
JSONObject featureCollection = new JSONObject();
|
||||
featureCollection.put("type", "FeatureCollection");
|
||||
|
||||
JSONArray features = new JSONArray();
|
||||
for (Map.Entry<String, AISNavigationAid> entry : idToNavigationAid.entrySet()) {
|
||||
String mmsi = entry.getKey();
|
||||
AISNavigationAid navigationAid = entry.getValue();
|
||||
|
||||
// Проверяем, что у нас есть фича для этого навигационного знака
|
||||
JSONObject feature = idToFeature.get(mmsi);
|
||||
if (feature != null) {
|
||||
features.put(feature);
|
||||
}
|
||||
}
|
||||
|
||||
featureCollection.put("features", features);
|
||||
|
||||
// Обновляем источник
|
||||
// GeoJsonSource принимает Feature/FeatureCollection/Geometry/String
|
||||
// Передаем как строку JSON
|
||||
source.setGeoJson(featureCollection.toString());
|
||||
|
||||
Log.d(TAG, "refreshNavigationAidsSource: обновлено " + features.length() + " навигационных знаков");
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "refreshNavigationAidsSource: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void logMemoryUsage() {
|
||||
Runtime runtime = Runtime.getRuntime();
|
||||
@@ -1072,6 +1424,204 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
return feature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает GeoJSON фичу для навигационного знака
|
||||
*/
|
||||
private JSONObject buildNavigationAidFeature(AISNavigationAid navigationAid) throws Exception {
|
||||
JSONObject feature = new JSONObject();
|
||||
feature.put("type", "Feature");
|
||||
feature.put("id", navigationAid.getMmsi());
|
||||
|
||||
JSONObject geom = new JSONObject();
|
||||
geom.put("type", "Point");
|
||||
JSONArray coords = new JSONArray();
|
||||
coords.put(navigationAid.getLongitude());
|
||||
coords.put(navigationAid.getLatitude());
|
||||
geom.put("coordinates", coords);
|
||||
feature.put("geometry", geom);
|
||||
|
||||
JSONObject props = new JSONObject();
|
||||
props.put("mmsi", navigationAid.getMmsi());
|
||||
props.put("name", navigationAid.getAidName() != null ? navigationAid.getAidName() : "");
|
||||
props.put("type", navigationAid.getAidType());
|
||||
props.put("typeDescription", navigationAid.getAidTypeDescription());
|
||||
props.put("length", navigationAid.getLength());
|
||||
props.put("width", navigationAid.getWidth());
|
||||
props.put("draft", navigationAid.getDraft());
|
||||
props.put("accuracy", navigationAid.isPositionAccuracy());
|
||||
props.put("offPosition", navigationAid.isOffPositionIndicator());
|
||||
props.put("raim", navigationAid.isRaimFlag());
|
||||
|
||||
// Выбираем иконку в зависимости от типа навигационного знака
|
||||
String iconName = getNavigationAidIcon(navigationAid.getAidType());
|
||||
props.put("icon", iconName);
|
||||
|
||||
feature.put("properties", props);
|
||||
return feature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает имя иконки для навигационного знака в зависимости от типа
|
||||
*/
|
||||
private String getNavigationAidIcon(int aidType) {
|
||||
switch (aidType) {
|
||||
// Reference point
|
||||
case 0:
|
||||
return "reference_point"; // Специальная иконка для референсной точки
|
||||
|
||||
// RACON (radar transponder marking a navigation hazard)
|
||||
case 1:
|
||||
return "racon"; // RACON иконка
|
||||
|
||||
// Fixed structure off shore, such as oil platforms, wind farms, rigs
|
||||
case 2:
|
||||
return "platform"; // Платформа
|
||||
|
||||
// Spare, Reserved for future use
|
||||
case 3:
|
||||
return "green_buey"; // Fallback для тестирования
|
||||
|
||||
// Light, without sectors
|
||||
case 4:
|
||||
return "light"; // Маяк
|
||||
|
||||
// Light, with sectors
|
||||
case 5:
|
||||
return "light_sector"; // Маяк с секторами
|
||||
|
||||
// Leading Light Front
|
||||
case 6:
|
||||
return "light_leading_front"; // Передний ведущий маяк
|
||||
|
||||
// Leading Light Rear
|
||||
case 7:
|
||||
return "light_leading_rear"; // Задний ведущий маяк
|
||||
|
||||
// Beacon, Cardinal N
|
||||
case 8:
|
||||
return "buoy_cardinal_n"; // Кардинальный буй Север
|
||||
|
||||
// Beacon, Cardinal E
|
||||
case 9:
|
||||
return "buoy_cardinal_e"; // Кардинальный буй Восток
|
||||
|
||||
// Beacon, Cardinal S
|
||||
case 10:
|
||||
return "buoy_cardinal_s"; // Кардинальный буй Юг
|
||||
|
||||
// Beacon, Cardinal W
|
||||
case 11:
|
||||
return "buoy_cardinal_w"; // Кардинальный буй Запад
|
||||
|
||||
// Beacon, Port hand
|
||||
case 12:
|
||||
return "buoy_port"; // Портовый буй
|
||||
|
||||
// Beacon, Starboard hand
|
||||
case 13:
|
||||
return "buoy_starboard"; // Правобортный буй
|
||||
|
||||
// Beacon, Preferred Channel port hand
|
||||
case 14:
|
||||
return "buoy_preferred_port"; // Предпочтительный канал порт
|
||||
|
||||
// Beacon, Preferred Channel starboard hand
|
||||
case 15:
|
||||
return "buoy_preferred_starboard"; // Предпочтительный канал правый борт
|
||||
|
||||
// Beacon, Isolated danger
|
||||
case 16:
|
||||
return "beacon_isolated_danger"; // Изолированная опасность
|
||||
|
||||
// Beacon, Safe water
|
||||
case 17:
|
||||
return "beacon_safe_water"; // Безопасная вода
|
||||
|
||||
// Beacon, Special mark
|
||||
case 18:
|
||||
return "beacon_special"; // Специальный знак
|
||||
|
||||
// Cardinal Mark N
|
||||
case 19:
|
||||
return "buoy_cardinal_n"; // Кардинальный знак Север
|
||||
|
||||
// Cardinal Mark E
|
||||
case 20:
|
||||
return "buoy_cardinal_e"; // Кардинальный знак Восток
|
||||
|
||||
// Cardinal Mark S
|
||||
case 21:
|
||||
return "buoy_cardinal_s"; // Кардинальный знак Юг
|
||||
|
||||
// Cardinal Mark W
|
||||
case 22:
|
||||
return "buoy_cardinal_w"; // Кардинальный знак Запад
|
||||
|
||||
// Port hand Mark
|
||||
case 23:
|
||||
return "buoy_port"; // Портовый знак
|
||||
|
||||
// Starboard hand Mark
|
||||
case 24:
|
||||
return "buoy_starboard"; // Правобортный знак
|
||||
|
||||
// Preferred Channel port hand
|
||||
case 25:
|
||||
return "buoy_preferred_port"; // Предпочтительный канал порт
|
||||
|
||||
// Preferred Channel starboard hand
|
||||
case 26:
|
||||
return "buoy_preferred_starboard"; // Предпочтительный канал правый борт
|
||||
|
||||
// Isolated danger
|
||||
case 27:
|
||||
return "beacon_isolated_danger"; // Изолированная опасность
|
||||
|
||||
// Safe water
|
||||
case 28:
|
||||
return "beacon_safe_water"; // Безопасная вода
|
||||
|
||||
// Special mark
|
||||
case 29:
|
||||
return "beacon_special"; // Специальный знак
|
||||
|
||||
// Light Vessel / LANBY / Rigs
|
||||
case 30:
|
||||
return "light_vessel"; // Плавучий маяк
|
||||
|
||||
default:
|
||||
return "green_buey"; // Fallback для тестирования
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает временный AISVessel из навигационного знака для совместимости с UI
|
||||
*/
|
||||
private AISVessel createTempVesselFromNavigationAid(AISNavigationAid navigationAid) {
|
||||
AISVessel tempVessel = new AISVessel(navigationAid.getMmsi());
|
||||
tempVessel.setLatitude(navigationAid.getLatitude());
|
||||
tempVessel.setLongitude(navigationAid.getLongitude());
|
||||
tempVessel.setVesselName(navigationAid.getAidName());
|
||||
tempVessel.setVesselClass("Navigation Aid");
|
||||
tempVessel.setVesselType(navigationAid.getAidTypeDescription());
|
||||
tempVessel.setLength(navigationAid.getLength());
|
||||
tempVessel.setWidth(navigationAid.getWidth());
|
||||
tempVessel.setDraft(navigationAid.getDraft());
|
||||
tempVessel.setPositionAccuracy(navigationAid.isPositionAccuracy());
|
||||
tempVessel.setLastUpdate(navigationAid.getLastUpdate());
|
||||
|
||||
// Добавляем специальные поля для навигационных знаков
|
||||
tempVessel.setDestination("Type: " + navigationAid.getAidType() + " (" + navigationAid.getAidTypeDescription() + ")");
|
||||
if (navigationAid.isOffPositionIndicator()) {
|
||||
tempVessel.setDestination(tempVessel.getDestination() + " - Off Position");
|
||||
}
|
||||
if (navigationAid.isRaimFlag()) {
|
||||
tempVessel.setDestination(tempVessel.getDestination() + " - RAIM Active");
|
||||
}
|
||||
|
||||
return tempVessel;
|
||||
}
|
||||
|
||||
private double getDisplayCourse(AISVessel v) {
|
||||
double hdg = v.getHeading();
|
||||
if (!Double.isNaN(hdg) && !Double.isInfinite(hdg)) {
|
||||
@@ -2130,21 +2680,33 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
Log.d(TAG, String.format("checkAisVesselUnderCursor: searchRect=[%.1f,%.1f,%.1f,%.1f]",
|
||||
searchRect.left, searchRect.top, searchRect.right, searchRect.bottom));
|
||||
|
||||
// Ищем AIS суда в адаптивном радиусе от центра
|
||||
java.util.List<org.maplibre.geojson.Feature> features = maplibreMap.queryRenderedFeatures(searchRect, LAYER_VESSELS);
|
||||
// Ищем AIS суда и навигационные знаки в адаптивном радиусе от центра
|
||||
java.util.List<org.maplibre.geojson.Feature> vesselFeatures = maplibreMap.queryRenderedFeatures(searchRect, LAYER_VESSELS);
|
||||
java.util.List<org.maplibre.geojson.Feature> aidFeatures = maplibreMap.queryRenderedFeatures(searchRect, LAYER_NAVIGATION_AIDS);
|
||||
|
||||
Log.d(TAG, String.format("checkAisVesselUnderCursor: найдено %d features в основном поиске",
|
||||
features != null ? features.size() : 0));
|
||||
// Объединяем результаты
|
||||
java.util.List<org.maplibre.geojson.Feature> features = new java.util.ArrayList<>();
|
||||
if (vesselFeatures != null) features.addAll(vesselFeatures);
|
||||
if (aidFeatures != null) features.addAll(aidFeatures);
|
||||
|
||||
Log.d(TAG, String.format("checkAisVesselUnderCursor: найдено %d судов и %d навигационных знаков в основном поиске",
|
||||
vesselFeatures != null ? vesselFeatures.size() : 0, aidFeatures != null ? aidFeatures.size() : 0));
|
||||
|
||||
// Если не нашли в основном радиусе, попробуем расширенный поиск
|
||||
if ((features == null || features.isEmpty()) && pixelRadius < 150) {
|
||||
if (features.isEmpty() && pixelRadius < 150) {
|
||||
android.graphics.RectF expandedRect = new android.graphics.RectF(
|
||||
screenPoint.x - 150, screenPoint.y - 150,
|
||||
screenPoint.x + 150, screenPoint.y + 150
|
||||
);
|
||||
features = maplibreMap.queryRenderedFeatures(expandedRect, LAYER_VESSELS);
|
||||
Log.d(TAG, String.format("checkAisVesselUnderCursor: найдено %d features в расширенном поиске",
|
||||
features != null ? features.size() : 0));
|
||||
vesselFeatures = maplibreMap.queryRenderedFeatures(expandedRect, LAYER_VESSELS);
|
||||
aidFeatures = maplibreMap.queryRenderedFeatures(expandedRect, LAYER_NAVIGATION_AIDS);
|
||||
|
||||
features.clear();
|
||||
if (vesselFeatures != null) features.addAll(vesselFeatures);
|
||||
if (aidFeatures != null) features.addAll(aidFeatures);
|
||||
|
||||
Log.d(TAG, String.format("checkAisVesselUnderCursor: найдено %d судов и %d навигационных знаков в расширенном поиске",
|
||||
vesselFeatures != null ? vesselFeatures.size() : 0, aidFeatures != null ? aidFeatures.size() : 0));
|
||||
}
|
||||
|
||||
if (features != null && !features.isEmpty()) {
|
||||
@@ -2160,6 +2722,7 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
Log.d(TAG, String.format("checkAisVesselUnderCursor: проверяем feature с id=%s", id));
|
||||
|
||||
if (id != null && !"own_vessel".equals(id)) {
|
||||
// Проверяем AIS судно
|
||||
AISVessel vessel = idToAisVessel.get(id);
|
||||
if (vessel != null) {
|
||||
// Вычисляем географическое расстояние от центра до судна
|
||||
@@ -2201,6 +2764,48 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
} else {
|
||||
Log.d(TAG, String.format("checkAisVesselUnderCursor: судно с id=%s не найдено в idToAisVessel", id));
|
||||
}
|
||||
|
||||
// Проверяем навигационный знак
|
||||
AISNavigationAid navigationAid = idToNavigationAid.get(id);
|
||||
if (navigationAid != null) {
|
||||
// Вычисляем географическое расстояние от центра до навигационного знака
|
||||
double geoDistance = GeoUtils.calculateDistance(
|
||||
center.getLatitude(), center.getLongitude(),
|
||||
navigationAid.getLatitude(), navigationAid.getLongitude()
|
||||
);
|
||||
|
||||
// Вычисляем экранное расстояние
|
||||
android.graphics.PointF aidScreenPoint = maplibreMap.getProjection()
|
||||
.toScreenLocation(new org.maplibre.android.geometry.LatLng(
|
||||
navigationAid.getLatitude(), navigationAid.getLongitude()));
|
||||
double screenDistance = Math.sqrt(
|
||||
Math.pow(screenPoint.x - aidScreenPoint.x, 2) +
|
||||
Math.pow(screenPoint.y - aidScreenPoint.y, 2)
|
||||
);
|
||||
|
||||
Log.d(TAG, String.format("checkAisVesselUnderCursor: навигационный знак %s - geoDistance=%.1f м, screenDistance=%.1f пикс",
|
||||
id, geoDistance, screenDistance));
|
||||
|
||||
// Приоритет отдаем экранному расстоянию, но учитываем и географическое
|
||||
boolean isBetterCandidate = false;
|
||||
if (closestVessel == null) {
|
||||
isBetterCandidate = true;
|
||||
} else if (screenDistance < minScreenDistance * 0.8) {
|
||||
// Если экранное расстояние значительно меньше
|
||||
isBetterCandidate = true;
|
||||
} else if (screenDistance <= minScreenDistance * 1.2 && geoDistance < minGeoDistance) {
|
||||
// Если экранное расстояние примерно равно, но географическое меньше
|
||||
isBetterCandidate = true;
|
||||
}
|
||||
|
||||
if (isBetterCandidate) {
|
||||
Log.d(TAG, String.format("checkAisVesselUnderCursor: выбираем навигационный знак %s как лучший кандидат", id));
|
||||
minScreenDistance = screenDistance;
|
||||
minGeoDistance = geoDistance;
|
||||
// Для навигационных знаков создаем временный AISVessel для совместимости
|
||||
closestVessel = createTempVesselFromNavigationAid(navigationAid);
|
||||
}
|
||||
}
|
||||
} else if ("own_vessel".equals(id)) {
|
||||
Log.d(TAG, "checkAisVesselUnderCursor: пропускаем собственное судно");
|
||||
}
|
||||
@@ -2407,8 +3012,155 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
}
|
||||
|
||||
/**
|
||||
* Настраивает слушатель движения карты для обновления курсора
|
||||
* Добавляет слой морских знаков OpenSeaMap
|
||||
*/
|
||||
private void addSeamarksLayer() {
|
||||
if (style == null || !isStyleValid()) {
|
||||
Log.w(TAG, "addSeamarksLayer: стиль не готов");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Проверяем, не добавлен ли уже слой
|
||||
if (style.getLayer(LAYER_SEAMARKS) != null) {
|
||||
Log.d(TAG, "addSeamarksLayer: слой уже существует");
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем источник тайлов морских знаков через TileSet, чтобы шаблоны {z}/{x}/{y} обрабатывались корректно
|
||||
String[] seamarksUrls = {
|
||||
"http://t1.openseamap.org/seamark/{z}/{x}/{y}.png",
|
||||
"http://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"
|
||||
};
|
||||
|
||||
boolean sourceAdded = false;
|
||||
for (String url : seamarksUrls) {
|
||||
try {
|
||||
Log.d(TAG, "addSeamarksLayer: пробуем TileSet URL " + url);
|
||||
org.maplibre.android.style.sources.TileSet tileSet =
|
||||
new org.maplibre.android.style.sources.TileSet("2.1.0", url);
|
||||
org.maplibre.android.style.sources.RasterSource seamarksSource =
|
||||
new org.maplibre.android.style.sources.RasterSource(
|
||||
SOURCE_SEAMARKS,
|
||||
tileSet,
|
||||
256
|
||||
);
|
||||
style.addSource(seamarksSource);
|
||||
Log.d(TAG, "addSeamarksLayer: источник добавлен успешно через TileSet " + url);
|
||||
sourceAdded = true;
|
||||
break;
|
||||
} catch (Exception urlError) {
|
||||
Log.w(TAG, "addSeamarksLayer: TileSet не удалось для URL " + url + ": " + urlError.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceAdded) {
|
||||
Log.w(TAG, "addSeamarksLayer: ни один TileSet не подошел, пробуем информационный слой");
|
||||
createSeamarksInfoLayer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем растровый слой
|
||||
org.maplibre.android.style.layers.RasterLayer seamarksLayer =
|
||||
new org.maplibre.android.style.layers.RasterLayer(LAYER_SEAMARKS, SOURCE_SEAMARKS);
|
||||
|
||||
// Настраиваем прозрачность слоя (чтобы не перекрывать основную карту)
|
||||
seamarksLayer.setProperties(
|
||||
org.maplibre.android.style.layers.PropertyFactory.rasterOpacity(0.8f)
|
||||
);
|
||||
|
||||
// Добавляем слой поверх всех остальных слоев
|
||||
style.addLayer(seamarksLayer);
|
||||
Log.d(TAG, "addSeamarksLayer: слой добавлен успешно");
|
||||
|
||||
Log.i(TAG, "✓ Слой морских знаков OpenSeaMap добавлен");
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "addSeamarksLayer: ошибка добавления слоя морских знаков", e);
|
||||
// Пробуем альтернативный подход - используем второй официальный сервер
|
||||
try {
|
||||
Log.d(TAG, "addSeamarksLayer: пробуем альтернативный сервер tiles.openseamap.org");
|
||||
org.maplibre.android.style.sources.RasterSource fallbackSource =
|
||||
new org.maplibre.android.style.sources.RasterSource(
|
||||
SOURCE_SEAMARKS + "_fallback",
|
||||
"https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png",
|
||||
256
|
||||
);
|
||||
style.addSource(fallbackSource);
|
||||
|
||||
org.maplibre.android.style.layers.RasterLayer fallbackLayer =
|
||||
new org.maplibre.android.style.layers.RasterLayer(LAYER_SEAMARKS, SOURCE_SEAMARKS + "_fallback");
|
||||
fallbackLayer.setProperties(
|
||||
org.maplibre.android.style.layers.PropertyFactory.rasterOpacity(0.8f)
|
||||
);
|
||||
style.addLayer(fallbackLayer);
|
||||
|
||||
Log.i(TAG, "✓ Слой морских знаков добавлен через fallback");
|
||||
} catch (Exception fallbackError) {
|
||||
Log.e(TAG, "addSeamarksLayer: fallback тоже не сработал", fallbackError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает информационный слой морских знаков (альтернатива растровым тайлам)
|
||||
*/
|
||||
private void createSeamarksInfoLayer() {
|
||||
try {
|
||||
Log.d(TAG, "createSeamarksInfoLayer: создаем информационный слой");
|
||||
|
||||
// Создаем простой источник с пустыми данными
|
||||
org.maplibre.android.style.sources.GeoJsonSource infoSource =
|
||||
new org.maplibre.android.style.sources.GeoJsonSource(SOURCE_SEAMARKS, "{\"type\":\"FeatureCollection\",\"features\":[]}");
|
||||
|
||||
style.addSource(infoSource);
|
||||
|
||||
// Создаем слой символов для отображения информации
|
||||
org.maplibre.android.style.layers.SymbolLayer infoLayer =
|
||||
new org.maplibre.android.style.layers.SymbolLayer(LAYER_SEAMARKS, SOURCE_SEAMARKS);
|
||||
|
||||
infoLayer.setProperties(
|
||||
org.maplibre.android.style.layers.PropertyFactory.textField("⚓"),
|
||||
org.maplibre.android.style.layers.PropertyFactory.textSize(16f),
|
||||
org.maplibre.android.style.layers.PropertyFactory.textColor(android.graphics.Color.BLUE),
|
||||
org.maplibre.android.style.layers.PropertyFactory.textOpacity(0.7f)
|
||||
);
|
||||
|
||||
style.addLayer(infoLayer);
|
||||
|
||||
Log.i(TAG, "✓ Информационный слой морских знаков создан (альтернативный режим)");
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "createSeamarksInfoLayer: ошибка создания информационного слоя", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет дополнительные слои карты на основе настроек
|
||||
*/
|
||||
public void updateAdditionalLayers() {
|
||||
if (style == null || !isStyleValid()) {
|
||||
Log.w(TAG, "updateAdditionalLayers: стиль не готов");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Обновляем слой морских знаков
|
||||
boolean seamarksEnabled = settingsManager.isSeamarksEnabled();
|
||||
boolean seamarksLayerExists = style.getLayer(LAYER_SEAMARKS) != null;
|
||||
|
||||
if (seamarksEnabled && !seamarksLayerExists) {
|
||||
Log.i(TAG, "updateAdditionalLayers: включаем морские знаки");
|
||||
addSeamarksLayer();
|
||||
} else if (!seamarksEnabled && seamarksLayerExists) {
|
||||
Log.i(TAG, "updateAdditionalLayers: выключаем морские знаки");
|
||||
removeSeamarksLayer();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "updateAdditionalLayers: ошибка обновления слоев", e);
|
||||
}
|
||||
}
|
||||
private void setupMapMovementListener() {
|
||||
if (maplibreMap != null) {
|
||||
maplibreMap.addOnCameraMoveListener(() -> {
|
||||
@@ -2417,6 +3169,33 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет слой морских знаков OpenSeaMap
|
||||
*/
|
||||
private void removeSeamarksLayer() {
|
||||
if (style == null || !isStyleValid()) {
|
||||
Log.w(TAG, "removeSeamarksLayer: стиль не готов");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Удаляем слой
|
||||
if (style.getLayer(LAYER_SEAMARKS) != null) {
|
||||
style.removeLayer(LAYER_SEAMARKS);
|
||||
Log.d(TAG, "removeSeamarksLayer: слой удален");
|
||||
}
|
||||
|
||||
// Удаляем источник
|
||||
if (style.getSource(SOURCE_SEAMARKS) != null) {
|
||||
style.removeSource(SOURCE_SEAMARKS);
|
||||
Log.d(TAG, "removeSeamarksLayer: источник удален");
|
||||
}
|
||||
|
||||
Log.i(TAG, "✓ Слой морских знаков OpenSeaMap удален");
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "removeSeamarksLayer: ошибка удаления слоя морских знаков", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.yandex.mapkit.mapview.MapView;
|
||||
import com.yandex.runtime.image.ImageProvider;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -141,6 +142,14 @@ public class YandexMapImpl implements MapInterface {
|
||||
markerManager.updateAISVesselMarker(vessel);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateAISVesselPositions(List<AISVessel> vessels) {
|
||||
if (vessels == null || markerManager == null) return;
|
||||
for (AISVessel vessel : vessels) {
|
||||
markerManager.updateAISVesselMarker(vessel);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAISVesselMarker(String mmsi) {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.grigowashere.aismap.models;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Модель AIS навигационного знака (буйка, маяка, платформы и т.д.)
|
||||
* Специализированный класс для сообщений типа 21 (Aid-to-Navigation Report)
|
||||
*/
|
||||
public class AISNavigationAid {
|
||||
private String mmsi; // Maritime Mobile Service Identity
|
||||
private String aidName; // название навигационного знака
|
||||
private int aidType; // тип навигационного знака (0-30)
|
||||
private String aidTypeDescription; // описание типа
|
||||
private double latitude;
|
||||
private double longitude;
|
||||
private boolean positionAccuracy; // точность позиции
|
||||
|
||||
// Размеры навигационного знака
|
||||
private double length; // длина в метрах
|
||||
private double width; // ширина в метрах
|
||||
private double draft; // осадка в метрах
|
||||
|
||||
// Dimension Reference поля (для коротких сообщений)
|
||||
private int dimRefA; // от носа до антенны
|
||||
private int dimRefB; // от антенны до кормы
|
||||
private int dimRefC; // от левого борта до антенны
|
||||
private int dimRefD; // от антенны до правого борта
|
||||
|
||||
// Дополнительные поля для полных сообщений
|
||||
private int epfdType; // тип электронного устройства позиционирования
|
||||
private int utcSecond; // секунда UTC timestamp
|
||||
private boolean offPositionIndicator; // индикатор смещения с позиции
|
||||
private int regionalReserved; // зарезервировано для регионального использования
|
||||
private boolean raimFlag; // флаг RAIM (Receiver Autonomous Integrity Monitoring)
|
||||
|
||||
private LocalDateTime lastUpdate;
|
||||
private boolean isActive; // активно ли устройство
|
||||
private boolean selected; // выделено ли на карте
|
||||
|
||||
public AISNavigationAid() {
|
||||
this.lastUpdate = LocalDateTime.now();
|
||||
this.isActive = true;
|
||||
}
|
||||
|
||||
public AISNavigationAid(String mmsi) {
|
||||
this();
|
||||
this.mmsi = mmsi;
|
||||
}
|
||||
|
||||
// Геттеры и сеттеры
|
||||
public String getMmsi() { return mmsi; }
|
||||
public void setMmsi(String mmsi) { this.mmsi = mmsi; }
|
||||
|
||||
public String getAidName() { return aidName; }
|
||||
public void setAidName(String aidName) { this.aidName = aidName; }
|
||||
|
||||
public int getAidType() { return aidType; }
|
||||
public void setAidType(int aidType) {
|
||||
this.aidType = aidType;
|
||||
this.aidTypeDescription = getAidTypeDescription(aidType);
|
||||
}
|
||||
|
||||
public String getAidTypeDescription() { return aidTypeDescription; }
|
||||
public void setAidTypeDescription(String aidTypeDescription) { this.aidTypeDescription = aidTypeDescription; }
|
||||
|
||||
public double getLatitude() { return latitude; }
|
||||
public void setLatitude(double latitude) { this.latitude = latitude; }
|
||||
|
||||
public double getLongitude() { return longitude; }
|
||||
public void setLongitude(double longitude) { this.longitude = longitude; }
|
||||
|
||||
public boolean isPositionAccuracy() { return positionAccuracy; }
|
||||
public void setPositionAccuracy(boolean positionAccuracy) { this.positionAccuracy = positionAccuracy; }
|
||||
|
||||
public double getLength() { return length; }
|
||||
public void setLength(double length) { this.length = length; }
|
||||
|
||||
public double getWidth() { return width; }
|
||||
public void setWidth(double width) { this.width = width; }
|
||||
|
||||
public double getDraft() { return draft; }
|
||||
public void setDraft(double draft) { this.draft = draft; }
|
||||
|
||||
public int getDimRefA() { return dimRefA; }
|
||||
public void setDimRefA(int dimRefA) { this.dimRefA = dimRefA; }
|
||||
|
||||
public int getDimRefB() { return dimRefB; }
|
||||
public void setDimRefB(int dimRefB) { this.dimRefB = dimRefB; }
|
||||
|
||||
public int getDimRefC() { return dimRefC; }
|
||||
public void setDimRefC(int dimRefC) { this.dimRefC = dimRefC; }
|
||||
|
||||
public int getDimRefD() { return dimRefD; }
|
||||
public void setDimRefD(int dimRefD) { this.dimRefD = dimRefD; }
|
||||
|
||||
public int getEpfdType() { return epfdType; }
|
||||
public void setEpfdType(int epfdType) { this.epfdType = epfdType; }
|
||||
|
||||
public int getUtcSecond() { return utcSecond; }
|
||||
public void setUtcSecond(int utcSecond) { this.utcSecond = utcSecond; }
|
||||
|
||||
public boolean isOffPositionIndicator() { return offPositionIndicator; }
|
||||
public void setOffPositionIndicator(boolean offPositionIndicator) { this.offPositionIndicator = offPositionIndicator; }
|
||||
|
||||
public int getRegionalReserved() { return regionalReserved; }
|
||||
public void setRegionalReserved(int regionalReserved) { this.regionalReserved = regionalReserved; }
|
||||
|
||||
public boolean isRaimFlag() { return raimFlag; }
|
||||
public void setRaimFlag(boolean raimFlag) { this.raimFlag = raimFlag; }
|
||||
|
||||
public LocalDateTime getLastUpdate() { return lastUpdate; }
|
||||
public void setLastUpdate(LocalDateTime lastUpdate) { this.lastUpdate = lastUpdate; }
|
||||
|
||||
public boolean isActive() { return isActive; }
|
||||
public void setActive(boolean active) { isActive = active; }
|
||||
|
||||
public boolean isSelected() { return selected; }
|
||||
public void setSelected(boolean selected) { this.selected = selected; }
|
||||
|
||||
/**
|
||||
* Обновляет позицию навигационного знака
|
||||
*/
|
||||
public void updatePosition(double latitude, double longitude) {
|
||||
this.latitude = latitude;
|
||||
this.longitude = longitude;
|
||||
this.lastUpdate = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, не устарели ли данные на указанное количество минут
|
||||
*/
|
||||
public boolean isDataStale(int warningMinutes) {
|
||||
return LocalDateTime.now().minusMinutes(warningMinutes).isAfter(lastUpdate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, нужно ли удалить данные (старше указанного количества минут)
|
||||
*/
|
||||
public boolean shouldBeRemoved(int removeMinutes) {
|
||||
return LocalDateTime.now().minusMinutes(removeMinutes).isAfter(lastUpdate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает количество минут с последнего обновления
|
||||
*/
|
||||
public long getMinutesSinceLastUpdate() {
|
||||
return java.time.Duration.between(lastUpdate, LocalDateTime.now()).toMinutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает описание типа электронного устройства позиционирования
|
||||
*/
|
||||
public String getEpfdTypeDescription() {
|
||||
switch (epfdType) {
|
||||
case 0: return "Undefined";
|
||||
case 1: return "GPS";
|
||||
case 2: return "GLONASS";
|
||||
case 3: return "Combined GPS/GLONASS";
|
||||
case 4: return "Loran-C";
|
||||
case 5: return "Chayka";
|
||||
case 6: return "Integrated navigation system";
|
||||
case 7: return "Surveyed";
|
||||
case 8:
|
||||
case 9:
|
||||
case 10:
|
||||
case 11:
|
||||
case 12:
|
||||
case 13:
|
||||
case 14:
|
||||
case 15: return "Not used";
|
||||
default: return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает описание типа навигационного знака по коду
|
||||
*/
|
||||
private String getAidTypeDescription(int aidType) {
|
||||
switch (aidType) {
|
||||
case 0: return "Reference point";
|
||||
case 1: return "RACON (radar transponder marking a navigation hazard)";
|
||||
case 2: return "Fixed structure off shore, such as oil platforms, wind farms, rigs";
|
||||
case 3: return "Spare, Reserved for future use";
|
||||
case 4: return "Light, without sectors";
|
||||
case 5: return "Light, with sectors";
|
||||
case 6: return "Leading Light Front";
|
||||
case 7: return "Leading Light Rear";
|
||||
case 8: return "Beacon, Cardinal N";
|
||||
case 9: return "Beacon, Cardinal E";
|
||||
case 10: return "Beacon, Cardinal S";
|
||||
case 11: return "Beacon, Cardinal W";
|
||||
case 12: return "Beacon, Port hand";
|
||||
case 13: return "Beacon, Starboard hand";
|
||||
case 14: return "Beacon, Preferred Channel port hand";
|
||||
case 15: return "Beacon, Preferred Channel starboard hand";
|
||||
case 16: return "Beacon, Isolated danger";
|
||||
case 17: return "Beacon, Safe water";
|
||||
case 18: return "Beacon, Special mark";
|
||||
case 19: return "Cardinal Mark N";
|
||||
case 20: return "Cardinal Mark E";
|
||||
case 21: return "Cardinal Mark S";
|
||||
case 22: return "Cardinal Mark W";
|
||||
case 23: return "Port hand Mark";
|
||||
case 24: return "Starboard hand Mark";
|
||||
case 25: return "Preferred Channel port hand";
|
||||
case 26: return "Preferred Channel starboard hand";
|
||||
case 27: return "Isolated danger";
|
||||
case 28: return "Safe water";
|
||||
case 29: return "Special mark";
|
||||
case 30: return "Light Vessel / LANBY / Rigs";
|
||||
default: return "Unknown Aid-to-Navigation";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисляет общие размеры из Dimension Reference полей
|
||||
*/
|
||||
public void calculateDimensionsFromRefs() {
|
||||
this.length = dimRefA + dimRefB;
|
||||
this.width = dimRefC + dimRefD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли это буйком (Cardinal Mark)
|
||||
*/
|
||||
public boolean isCardinalMark() {
|
||||
return aidType >= 19 && aidType <= 22;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли это маяком
|
||||
*/
|
||||
public boolean isLight() {
|
||||
return aidType >= 4 && aidType <= 7;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли это платформой или стационарной конструкцией
|
||||
*/
|
||||
public boolean isFixedStructure() {
|
||||
return aidType == 2 || aidType == 30;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "AISNavigationAid{" +
|
||||
"mmsi='" + mmsi + '\'' +
|
||||
", name='" + aidName + '\'' +
|
||||
", type=" + aidType + " (" + aidTypeDescription + ")" +
|
||||
", lat=" + latitude +
|
||||
", lon=" + longitude +
|
||||
", L=" + length +
|
||||
", W=" + width +
|
||||
", D=" + draft +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,12 @@ public class CompassSensor implements SensorEventListener {
|
||||
|
||||
// Скользящий фильтр для сглаживания значений
|
||||
private static final int FILTER_SIZE = 60;
|
||||
private static final float DEADBAND_DEG = 1.5f;
|
||||
private float[] azimuthBuffer = new float[FILTER_SIZE];
|
||||
private int bufferIndex = 0;
|
||||
private boolean bufferFull = false;
|
||||
/** Last value sent to UI (circular deadband). */
|
||||
private float lastReportedAzimuth = Float.NaN;
|
||||
|
||||
public interface CompassListener {
|
||||
void onCompassChanged(float azimuth);
|
||||
@@ -81,6 +84,7 @@ public class CompassSensor implements SensorEventListener {
|
||||
private void resetFilter() {
|
||||
bufferIndex = 0;
|
||||
bufferFull = false;
|
||||
lastReportedAzimuth = Float.NaN;
|
||||
for (int i = 0; i < FILTER_SIZE; i++) {
|
||||
azimuthBuffer[i] = 0;
|
||||
}
|
||||
@@ -142,26 +146,39 @@ public class CompassSensor implements SensorEventListener {
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет скользящий фильтр для сглаживания значений
|
||||
* Скользящее усреднение по кругу (векторное среднее), без скачков у 0°/360°.
|
||||
*/
|
||||
private float applyLowPassFilter(float newValue) {
|
||||
// Добавляем новое значение в буфер
|
||||
azimuthBuffer[bufferIndex] = newValue;
|
||||
bufferIndex = (bufferIndex + 1) % FILTER_SIZE;
|
||||
|
||||
if (bufferIndex == 0) {
|
||||
bufferFull = true;
|
||||
}
|
||||
|
||||
// Вычисляем среднее значение
|
||||
float sum = 0;
|
||||
int count = bufferFull ? FILTER_SIZE : bufferIndex;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
sum += azimuthBuffer[i];
|
||||
if (count <= 0) {
|
||||
return newValue;
|
||||
}
|
||||
|
||||
return sum / count;
|
||||
double sx = 0.0;
|
||||
double sy = 0.0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
double rad = Math.toRadians(azimuthBuffer[i]);
|
||||
sx += Math.cos(rad);
|
||||
sy += Math.sin(rad);
|
||||
}
|
||||
float mean = (float) Math.toDegrees(Math.atan2(sy / count, sx / count));
|
||||
if (mean < 0) {
|
||||
mean += 360;
|
||||
}
|
||||
if (!Float.isNaN(lastReportedAzimuth)) {
|
||||
float d = mean - lastReportedAzimuth;
|
||||
while (d > 180) d -= 360;
|
||||
while (d < -180) d += 360;
|
||||
if (Math.abs(d) < DEADBAND_DEG) {
|
||||
return lastReportedAzimuth;
|
||||
}
|
||||
}
|
||||
lastReportedAzimuth = mean;
|
||||
return mean;
|
||||
}
|
||||
|
||||
public boolean isAvailable() {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package com.grigowashere.aismap.settings;
|
||||
|
||||
import android.Manifest;
|
||||
import android.bluetooth.BluetoothAdapter;
|
||||
import android.bluetooth.BluetoothDevice;
|
||||
import android.bluetooth.BluetoothManager;
|
||||
import android.bluetooth.le.BluetoothLeScanner;
|
||||
import android.bluetooth.le.ScanCallback;
|
||||
import android.bluetooth.le.ScanResult;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial;
|
||||
import com.grigowashere.aismap.R;
|
||||
import com.grigowashere.aismap.utils.SettingsManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class InterfacesSettingsActivity extends AppCompatActivity {
|
||||
|
||||
private static final String TAG = "InterfacesSettings";
|
||||
private static final int REQ_PERMS_BLE = 2001;
|
||||
|
||||
private SettingsManager settingsManager;
|
||||
|
||||
// UDP
|
||||
private EditText etUdpPort;
|
||||
private SwitchMaterial swUdpEnabled;
|
||||
|
||||
// BLE
|
||||
private SwitchMaterial swBleEnabled;
|
||||
private EditText etBleMac;
|
||||
|
||||
// BLE UDP Bridge
|
||||
private SwitchMaterial swBleBridgeEnabled;
|
||||
private EditText etBleBridgeHost;
|
||||
private EditText etBleBridgePort;
|
||||
|
||||
private Button btnSave;
|
||||
private Button btnCancel;
|
||||
|
||||
// Scan UI
|
||||
private Button btnBleScan;
|
||||
private Button btnBleStopScan;
|
||||
private RecyclerView rvBle;
|
||||
private DevicesAdapter devicesAdapter;
|
||||
|
||||
private BluetoothAdapter btAdapter;
|
||||
private BluetoothLeScanner bleScanner;
|
||||
private boolean scanning = false;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_interfaces_settings);
|
||||
settingsManager = new SettingsManager(this);
|
||||
BluetoothManager bm = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
|
||||
btAdapter = bm != null ? bm.getAdapter() : null;
|
||||
bleScanner = btAdapter != null ? btAdapter.getBluetoothLeScanner() : null;
|
||||
initViews();
|
||||
loadValues();
|
||||
setupHandlers();
|
||||
setupRecycler();
|
||||
}
|
||||
|
||||
private void initViews() {
|
||||
etUdpPort = findViewById(R.id.et_udp_port);
|
||||
swUdpEnabled = findViewById(R.id.switch_udp_enabled);
|
||||
swBleEnabled = findViewById(R.id.switch_ble_enabled);
|
||||
etBleMac = findViewById(R.id.et_ble_mac);
|
||||
swBleBridgeEnabled = findViewById(R.id.switch_ble_udp_bridge_enabled);
|
||||
etBleBridgeHost = findViewById(R.id.et_ble_udp_host);
|
||||
etBleBridgePort = findViewById(R.id.et_ble_udp_port);
|
||||
btnSave = findViewById(R.id.btn_save);
|
||||
btnCancel = findViewById(R.id.btn_cancel);
|
||||
btnBleScan = findViewById(R.id.btn_ble_scan);
|
||||
btnBleStopScan = findViewById(R.id.btn_ble_stop_scan);
|
||||
rvBle = findViewById(R.id.rv_ble_devices);
|
||||
}
|
||||
|
||||
private void loadValues() {
|
||||
etUdpPort.setText(String.valueOf(settingsManager.getUDPPort()));
|
||||
swUdpEnabled.setChecked(settingsManager.isUDPEnabled());
|
||||
|
||||
swBleEnabled.setChecked(settingsManager.isBLEEnabled());
|
||||
etBleMac.setText(settingsManager.getBLEDeviceMac());
|
||||
|
||||
swBleBridgeEnabled.setChecked(settingsManager.isBleUdpBridgeEnabled());
|
||||
etBleBridgeHost.setText(settingsManager.getBleUdpBridgeHost());
|
||||
etBleBridgePort.setText(String.valueOf(settingsManager.getBleUdpBridgePort()));
|
||||
}
|
||||
|
||||
private void setupHandlers() {
|
||||
btnCancel.setOnClickListener(v -> finish());
|
||||
btnSave.setOnClickListener(v -> saveAndExit());
|
||||
btnBleScan.setOnClickListener(v -> startScan());
|
||||
btnBleStopScan.setOnClickListener(v -> stopScan());
|
||||
}
|
||||
|
||||
private void setupRecycler() {
|
||||
devicesAdapter = new DevicesAdapter(device -> {
|
||||
if (device == null) return;
|
||||
String mac = device.getAddress();
|
||||
etBleMac.setText(mac);
|
||||
Toast.makeText(this, "Выбрано устройство: " + device.getName() + " (" + mac + ")", Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
rvBle.setLayoutManager(new LinearLayoutManager(this));
|
||||
rvBle.setAdapter(devicesAdapter);
|
||||
}
|
||||
|
||||
private void startScan() {
|
||||
if (btAdapter == null || !btAdapter.isEnabled()) {
|
||||
Toast.makeText(this, "Bluetooth не включен", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
if (!ensureBlePerms()) return;
|
||||
if (bleScanner == null) {
|
||||
Toast.makeText(this, "BLE Scanner недоступен", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
if (scanning) return;
|
||||
devicesAdapter.clear();
|
||||
bleScanner.startScan(scanCallback);
|
||||
scanning = true;
|
||||
Toast.makeText(this, "BLE сканирование запущено", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
private void stopScan() {
|
||||
if (!scanning) return;
|
||||
try { bleScanner.stopScan(scanCallback); } catch (Exception ignore) {}
|
||||
scanning = false;
|
||||
Toast.makeText(this, "BLE сканирование остановлено", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
private boolean ensureBlePerms() {
|
||||
List<String> need = new ArrayList<>();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) need.add(Manifest.permission.BLUETOOTH_SCAN);
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) need.add(Manifest.permission.BLUETOOTH_CONNECT);
|
||||
} else {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH) != PackageManager.PERMISSION_GRANTED) need.add(Manifest.permission.BLUETOOTH);
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_ADMIN) != PackageManager.PERMISSION_GRANTED) need.add(Manifest.permission.BLUETOOTH_ADMIN);
|
||||
}
|
||||
if (!need.isEmpty()) {
|
||||
ActivityCompat.requestPermissions(this, need.toArray(new String[0]), REQ_PERMS_BLE);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
if (requestCode == REQ_PERMS_BLE) {
|
||||
boolean ok = true;
|
||||
for (int r : grantResults) {
|
||||
if (r != PackageManager.PERMISSION_GRANTED) { ok = false; break; }
|
||||
}
|
||||
if (ok) startScan(); else Toast.makeText(this, "Разрешения BLE не предоставлены", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private final ScanCallback scanCallback = new ScanCallback() {
|
||||
@Override
|
||||
public void onScanResult(int callbackType, @NonNull ScanResult result) {
|
||||
BluetoothDevice d = result.getDevice();
|
||||
if (d == null || d.getAddress() == null) return;
|
||||
devicesAdapter.addOrUpdate(d, result.getRssi());
|
||||
}
|
||||
};
|
||||
|
||||
private void saveAndExit() {
|
||||
try {
|
||||
int udpPort = parseInt(etUdpPort.getText().toString().trim(), 10110, 1, 65535);
|
||||
settingsManager.setUDPPort(udpPort);
|
||||
settingsManager.setUDPEnabled(swUdpEnabled.isChecked());
|
||||
|
||||
settingsManager.setBLEEnabled(swBleEnabled.isChecked());
|
||||
settingsManager.setBLEDeviceMac(etBleMac.getText().toString().trim());
|
||||
|
||||
settingsManager.setBleUdpBridgeEnabled(swBleBridgeEnabled.isChecked());
|
||||
settingsManager.setBleUdpBridgeHost(etBleBridgeHost.getText().toString().trim());
|
||||
int brPort = parseInt(etBleBridgePort.getText().toString().trim(), 10110, 1, 65535);
|
||||
settingsManager.setBleUdpBridgePort(brPort);
|
||||
|
||||
Toast.makeText(this, "Настройки сохранены", Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка сохранения: " + e.getMessage(), e);
|
||||
Toast.makeText(this, "Ошибка сохранения настроек", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private int parseInt(String s, int def, int min, int max) {
|
||||
try {
|
||||
int v = Integer.parseInt(s);
|
||||
if (v < min || v > max) return def;
|
||||
return v;
|
||||
} catch (Exception e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
// Recycler adapter
|
||||
private static class DevicesAdapter extends RecyclerView.Adapter<DevicesAdapter.VH> {
|
||||
interface OnClick { void onClick(BluetoothDevice d); }
|
||||
private final List<Item> items = new ArrayList<>();
|
||||
private final OnClick onClick;
|
||||
DevicesAdapter(OnClick onClick) { this.onClick = onClick; }
|
||||
|
||||
static class Item { BluetoothDevice d; int rssi; }
|
||||
|
||||
static class VH extends RecyclerView.ViewHolder {
|
||||
TextView tvName; TextView tvMac; TextView tvRssi;
|
||||
VH(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
tvName = itemView.findViewById(android.R.id.text1);
|
||||
tvMac = itemView.findViewById(android.R.id.text2);
|
||||
tvRssi = itemView.findViewById(R.id.text3);
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_ble_device, parent, false);
|
||||
return new VH(v);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull VH holder, int position) {
|
||||
Item it = items.get(position);
|
||||
String name = it.d.getName();
|
||||
holder.tvName.setText(name != null ? name : "(без имени)");
|
||||
holder.tvMac.setText(it.d.getAddress());
|
||||
holder.tvRssi.setText("RSSI: " + it.rssi);
|
||||
holder.itemView.setOnClickListener(v -> onClick.onClick(it.d));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() { return items.size(); }
|
||||
|
||||
void clear() { items.clear(); notifyDataSetChanged(); }
|
||||
|
||||
void addOrUpdate(BluetoothDevice d, int rssi) {
|
||||
for (Item it : items) {
|
||||
if (it.d.getAddress().equals(d.getAddress())) { it.rssi = rssi; notifyDataSetChanged(); return; }
|
||||
}
|
||||
Item it = new Item(); it.d = d; it.rssi = rssi; items.add(it); notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,11 @@ public class MenuBinder {
|
||||
boolean screenEnabled = settingsManager.isKeepScreenOnEnabled();
|
||||
screenItem.setTitle(screenEnabled ? "Экран ✓" : "Экран");
|
||||
}
|
||||
MenuItem seamarksItem = menu.findItem(R.id.menu_seamarks);
|
||||
if (seamarksItem != null) {
|
||||
boolean seamarksEnabled = settingsManager.isSeamarksEnabled();
|
||||
seamarksItem.setTitle(seamarksEnabled ? "Морские знаки ✓" : "Морские знаки");
|
||||
}
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "onPrepareOptionsMenu: " + e.getMessage());
|
||||
@@ -78,6 +83,9 @@ public class MenuBinder {
|
||||
} else if (id == R.id.menu_keep_screen_on) {
|
||||
actions.toggleKeepScreenOn();
|
||||
return true;
|
||||
} else if (id == R.id.menu_seamarks) {
|
||||
actions.toggleSeamarks();
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "onOptionsItemSelected error: " + e.getMessage());
|
||||
@@ -95,6 +103,7 @@ public class MenuBinder {
|
||||
void togglePathTracking();
|
||||
void testForegroundService();
|
||||
void toggleKeepScreenOn();
|
||||
void toggleSeamarks();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import com.grigowashere.aismap.models.Vessel;
|
||||
import com.grigowashere.aismap.models.AISVessel;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.HashMap;
|
||||
@@ -156,10 +158,10 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
|
||||
mapInterface.removeAISVesselMarker(mmsi);
|
||||
}
|
||||
|
||||
// Обновляем или добавляем суда (различать не будем - MapInterface сам решит)
|
||||
for (AISVessel vessel : pendingAISUpdates.values()) {
|
||||
Log.d(TAG, "Обновляем/добавляем AIS судно: " + vessel.getMmsi());
|
||||
mapInterface.updateAISVesselPosition(vessel);
|
||||
// Обновляем или добавляем суда пачкой, чтобы карта сделала один GeoJSON refresh.
|
||||
List<AISVessel> updates = new ArrayList<>(pendingAISUpdates.values());
|
||||
if (!updates.isEmpty()) {
|
||||
mapInterface.updateAISVesselPositions(updates);
|
||||
}
|
||||
|
||||
Log.d(TAG, "AIS updates выполнены: удалено=" + pendingAISRemovals.size() +
|
||||
|
||||
@@ -2,370 +2,172 @@ package com.grigowashere.aismap.utils;
|
||||
|
||||
import android.util.Log;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Утилита для отправки логов на внешний ресурс
|
||||
* Отправляет GET запросы на https://ais.grigowashere.ru/add
|
||||
* Отправляет пакеты логов раз в секунду на https://ais.grigowashere.ru/logs/batch
|
||||
*/
|
||||
public class LogSender {
|
||||
|
||||
private static final String TAG = "LogSender";
|
||||
private static final String BASE_URL = "https://ais.grigowashere.ru/add";
|
||||
private static final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
|
||||
private static final String BASE_URL = "https://ais.grigowashere.ru";
|
||||
|
||||
/**
|
||||
* Отправляет лог NMEA сообщения
|
||||
* @param nmeaMessage NMEA сообщение
|
||||
* Временно отключено, чтобы не создавать сетевой шум и фоновые потоки.
|
||||
* Если снова понадобится — переключить в true или завязать на настройку/BuildConfig.
|
||||
*/
|
||||
public static void logNMEA(String nmeaMessage) {
|
||||
if (nmeaMessage == null || nmeaMessage.trim().isEmpty()) {
|
||||
private static final boolean ENABLED = false;
|
||||
|
||||
// Мягкие цвета для лучшей читаемости на фоне #364758
|
||||
private static final String COLOR_SOFT_BLUE = "#8AB4F8"; // мягкий синий
|
||||
private static final String COLOR_SOFT_RED = "#FF8A80"; // мягкий красный
|
||||
|
||||
// Настройки генерации цветов для кораблей
|
||||
private static final float VESSEL_COLOR_SATURATION = 0.4f; // Низкая насыщенность для мягкости
|
||||
private static final float VESSEL_COLOR_VALUE = 0.75f; // Средняя яркость для контраста с темным фоном
|
||||
|
||||
// Буферизация логов
|
||||
private static final List<LogEntry> logBuffer = new ArrayList<>();
|
||||
private static final Object bufferLock = new Object();
|
||||
private static volatile ScheduledExecutorService scheduler = null;
|
||||
private static volatile boolean schedulerStarted = false;
|
||||
|
||||
/**
|
||||
* Структура для хранения лога
|
||||
*/
|
||||
private static class LogEntry {
|
||||
String type;
|
||||
String message;
|
||||
String color;
|
||||
long timestamp;
|
||||
|
||||
LogEntry(String type, String message, String color) {
|
||||
this.type = type;
|
||||
this.message = message;
|
||||
this.color = color;
|
||||
this.timestamp = System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует планировщик отправки логов
|
||||
*/
|
||||
private static void startScheduler() {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (!schedulerStarted) {
|
||||
synchronized (LogSender.class) {
|
||||
if (!schedulerStarted) {
|
||||
if (scheduler == null || scheduler.isShutdown()) {
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
}
|
||||
scheduler.scheduleAtFixedRate(() -> {
|
||||
sendBufferedLogs();
|
||||
}, 1, 1, TimeUnit.SECONDS);
|
||||
schedulerStarted = true;
|
||||
Log.d(TAG, "Планировщик отправки логов запущен (каждую секунду)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет лог в буфер
|
||||
*/
|
||||
private static void addToBuffer(String type, String message, String color) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
synchronized (bufferLock) {
|
||||
logBuffer.add(new LogEntry(type, message, color));
|
||||
}
|
||||
startScheduler(); // Запускаем планировщик при первом логе
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет все накопленные логи пакетом
|
||||
*/
|
||||
private static void sendBufferedLogs() {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
List<LogEntry> logsToSend;
|
||||
synchronized (bufferLock) {
|
||||
if (logBuffer.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
logsToSend = new ArrayList<>(logBuffer);
|
||||
logBuffer.clear();
|
||||
}
|
||||
|
||||
if (logsToSend.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
String encodedMessage = encodeForURL(nmeaMessage);
|
||||
String url = BASE_URL + "?nmea=" + encodedMessage + "&color=blue";
|
||||
|
||||
sendGetRequest(url);
|
||||
// Убираем лишние логи
|
||||
// Log.d(TAG, "NMEA лог отправлен: " + nmeaMessage);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка отправки NMEA лога: " + e.getMessage(), e);
|
||||
}
|
||||
});
|
||||
Log.d(TAG, "Отправляем пакет из " + logsToSend.size() + " логов");
|
||||
sendLogsBatch(logsToSend);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Отправляет лог обновления информации о корабле
|
||||
* @param mmsi MMSI корабля
|
||||
* @param vesselInfo Информация о корабле
|
||||
* Отправляет пакет логов через POST запрос
|
||||
*/
|
||||
public static void logShipUpdate(String mmsi, String vesselInfo) {
|
||||
if (mmsi == null || mmsi.trim().isEmpty()) {
|
||||
private static void sendLogsBatch(List<LogEntry> logs) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
String message = "MMSI: " + mmsi;
|
||||
if (vesselInfo != null && !vesselInfo.trim().isEmpty()) {
|
||||
message += " | " + vesselInfo;
|
||||
}
|
||||
|
||||
// Извлекаем тип судна из vesselInfo и генерируем цвет
|
||||
// Генерируем уникальный цвет для корабля на основе MMSI
|
||||
String vesselColor = generateVesselColor(mmsi);
|
||||
|
||||
String encodedMessage = encodeForURL(message);
|
||||
String encodedColor = encodeColorForURL(vesselColor);
|
||||
String url = BASE_URL + "?ships=" + encodedMessage + "&color=" + encodedColor;
|
||||
|
||||
sendGetRequest(url);
|
||||
// Убираем лишние логи
|
||||
// Log.d(TAG, "Ship update лог отправлен: " + message + " ( " + ", цвет: " + vesselColor + ")");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка отправки ship update лога: " + e.getMessage(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет лог обновления информации о корабле с заданным цветом
|
||||
* @param mmsi MMSI корабля
|
||||
* @param vesselInfo Информация о корабле
|
||||
* @param color Цвет в формате HEX (#RRGGBB) или имя цвета
|
||||
*/
|
||||
public static void logShipUpdate(String mmsi, String vesselInfo, String color) {
|
||||
if (mmsi == null || mmsi.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
String message = "MMSI: " + mmsi;
|
||||
if (vesselInfo != null && !vesselInfo.trim().isEmpty()) {
|
||||
message += " | " + vesselInfo;
|
||||
}
|
||||
|
||||
// Используем переданный цвет или генерируем на основе типа судна
|
||||
String vesselColor;
|
||||
if (color != null && !color.trim().isEmpty()) {
|
||||
vesselColor = color;
|
||||
} else {
|
||||
// Генерируем уникальный цвет для корабля на основе MMSI
|
||||
vesselColor = generateVesselColor(mmsi);
|
||||
}
|
||||
|
||||
String encodedMessage = encodeForURL(message);
|
||||
String encodedColor = encodeColorForURL(vesselColor);
|
||||
String url = BASE_URL + "?ships=" + encodedMessage + "&color=" + encodedColor;
|
||||
|
||||
sendGetRequest(url);
|
||||
// Убираем лишние логи
|
||||
// Log.d(TAG, "Ship update лог отправлен: " + message + " (цвет: " + vesselColor + ")");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка отправки ship update лога: " + e.getMessage(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет произвольный лог
|
||||
* @param logName Имя лога
|
||||
* @param message Сообщение
|
||||
* @param color Цвет (опционально)
|
||||
*/
|
||||
public static void logCustom(String logName, String message, String color) {
|
||||
if (logName == null || logName.trim().isEmpty() || message == null || message.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
String encodedMessage = encodeForURL(message);
|
||||
String url = BASE_URL + "?" + logName + "=" + encodedMessage;
|
||||
|
||||
if (color != null && !color.trim().isEmpty()) {
|
||||
url += "&color=" + color;
|
||||
}
|
||||
|
||||
sendGetRequest(url);
|
||||
Log.d(TAG, "Custom лог отправлен: " + logName + " = " + message);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка отправки custom лога: " + e.getMessage(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Генерирует уникальный цвет для корабля на основе MMSI (устаревший метод)
|
||||
* @param mmsi MMSI корабля
|
||||
* @return HEX цвет в формате #RRGGBB
|
||||
*/
|
||||
private static String generateVesselColor(String mmsi) {
|
||||
try {
|
||||
// Преобразуем MMSI в число для хеширования
|
||||
long mmsiValue = Long.parseLong(mmsi);
|
||||
|
||||
// Используем хеш-функцию для получения равномерного распределения
|
||||
int hash = Long.hashCode(mmsiValue);
|
||||
|
||||
// Извлекаем RGB компоненты из хеша
|
||||
int r = (hash & 0xFF0000) >> 16;
|
||||
int g = (hash & 0x00FF00) >> 8;
|
||||
int b = hash & 0x0000FF;
|
||||
|
||||
// Проверяем, не слишком ли темный цвет (чтобы избежать черного)
|
||||
int brightness = (r + g + b) / 3;
|
||||
if (brightness < 100) {
|
||||
// Если цвет слишком темный, осветляем его
|
||||
r = Math.min(255, r + 120);
|
||||
g = Math.min(255, g + 120);
|
||||
b = Math.min(255, b + 120);
|
||||
}
|
||||
|
||||
// Проверяем, не слишком ли светлый цвет (чтобы избежать белого)
|
||||
if (brightness > 220) {
|
||||
// Если цвет слишком светлый, затемняем его
|
||||
r = Math.max(0, r - 60);
|
||||
g = Math.max(0, g - 60);
|
||||
b = Math.max(0, b - 60);
|
||||
}
|
||||
|
||||
// Форматируем в HEX
|
||||
String color = String.format("#%02X%02X%02X", r, g, b);
|
||||
|
||||
// Убираем лишние логи
|
||||
// Log.d(TAG, "Сгенерирован цвет для MMSI " + mmsi + ": " + color + " (RGB: " + r + "," + g + "," + b + ")");
|
||||
|
||||
return color;
|
||||
|
||||
} catch (NumberFormatException e) {
|
||||
Log.w(TAG, "Не удалось распарсить MMSI как число: " + mmsi + ", используем цвет по умолчанию");
|
||||
return "#00AA00"; // Зеленый по умолчанию
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка генерации цвета для MMSI " + mmsi + ": " + e.getMessage(), e);
|
||||
return "#00AA00"; // Зеленый по умолчанию
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Определяет тип судна по MMSI
|
||||
* Использует более точную логику на основе стандартных диапазонов MMSI
|
||||
* @param mmsi MMSI судна
|
||||
* @return Тип судна
|
||||
*/
|
||||
private static String getVesselTypeByMMSI(long mmsi) {
|
||||
// Стандартные диапазоны MMSI для разных типов судов
|
||||
if (mmsi >= 100000000 && mmsi <= 199999999) {
|
||||
return "COASTAL"; // Прибрежные суда
|
||||
} else if (mmsi >= 200000000 && mmsi <= 299999999) {
|
||||
return "FISHING"; // Рыболовные суда
|
||||
} else if (mmsi >= 300000000 && mmsi <= 399999999) {
|
||||
return "CARGO"; // Грузовые суда
|
||||
} else if (mmsi >= 400000000 && mmsi <= 499999999) {
|
||||
return "TANKER"; // Танкеры
|
||||
} else if (mmsi >= 500000000 && mmsi <= 599999999) {
|
||||
return "PASSENGER"; // Пассажирские суда
|
||||
} else if (mmsi >= 600000000 && mmsi <= 699999999) {
|
||||
return "MILITARY"; // Военные корабли
|
||||
} else if (mmsi >= 700000000 && mmsi <= 799999999) {
|
||||
return "PILOT"; // Лоцманские суда
|
||||
} else if (mmsi >= 800000000 && mmsi <= 899999999) {
|
||||
return "PILOT"; // Лоцманские суда (дополнительный диапазон)
|
||||
} else if (mmsi >= 900000000 && mmsi <= 999999999) {
|
||||
return "MILITARY"; // Военные корабли (дополнительный диапазон)
|
||||
} else if (mmsi >= 1000000000 && mmsi <= 1099999999) {
|
||||
return "SAR"; // Спасательные суда
|
||||
} else if (mmsi >= 1100000000 && mmsi <= 1199999999) {
|
||||
return "TUG"; // Буксиры
|
||||
} else if (mmsi >= 1200000000 && mmsi <= 1299999999) {
|
||||
return "PORT_TENDER"; // Портовые суда
|
||||
} else if (mmsi >= 1300000000 && mmsi <= 1399999999) {
|
||||
return "ANTI_POLLUTION"; // Антизагрязнительные суда
|
||||
} else if (mmsi >= 1400000000 && mmsi <= 1499999999) {
|
||||
return "LAW_ENFORCEMENT"; // Правоохранительные суда
|
||||
} else if (mmsi >= 1500000000 && mmsi <= 1599999999) {
|
||||
return "MEDICAL"; // Медицинские суда
|
||||
} else if (mmsi >= 1600000000 && mmsi <= 1699999999) {
|
||||
return "SPECIAL_CRAFT"; // Специальные суда
|
||||
} else if (mmsi >= 1700000000 && mmsi <= 1799999999) {
|
||||
return "PASSENGER"; // Пассажирские суда (дополнительный диапазон)
|
||||
} else if (mmsi >= 1800000000 && mmsi <= 1899999999) {
|
||||
return "CARGO"; // Грузовые суда (дополнительный диапазон)
|
||||
} else if (mmsi >= 1900000000 && mmsi <= 1999999999) {
|
||||
return "TANKER"; // Танкеры (дополнительный диапазон)
|
||||
} else if (mmsi >= 2000000000 && mmsi <= 2099999999) {
|
||||
return "OTHER"; // Другие типы судов
|
||||
} else if (mmsi >= 2100000000L && mmsi <= 2199999999L) {
|
||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
||||
} else if (mmsi >= 2200000000L && mmsi <= 2299999999L) {
|
||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
||||
} else if (mmsi >= 2300000000L && mmsi <= 2399999999L) {
|
||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
||||
} else if (mmsi >= 2400000000L && mmsi <= 2499999999L) {
|
||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
||||
} else if (mmsi >= 2500000000L && mmsi <= 2599999999L) {
|
||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
||||
} else if (mmsi >= 2600000000L && mmsi <= 2699999999L) {
|
||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
||||
} else if (mmsi >= 2700000000L && mmsi <= 2799999999L) {
|
||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
||||
} else if (mmsi >= 2800000000L && mmsi <= 2899999999L) {
|
||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
||||
} else if (mmsi >= 2900000000L && mmsi <= 2999999999L) {
|
||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
||||
} else {
|
||||
return "UNKNOWN"; // Неизвестный тип
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Кодирует цвет для безопасного использования в URL
|
||||
* Специально обрабатывает HEX цвета, заменяя # на %23
|
||||
* @param color Цвет в формате HEX (#RRGGBB) или имя цвета
|
||||
* @return Закодированный цвет
|
||||
*/
|
||||
private static String encodeColorForURL(String color) {
|
||||
if (color == null || color.trim().isEmpty()) {
|
||||
return "green"; // Цвет по умолчанию
|
||||
}
|
||||
|
||||
try {
|
||||
// Если цвет начинается с #, заменяем его на %23
|
||||
if (color.startsWith("#")) {
|
||||
String encoded = "%23" + color.substring(1);
|
||||
Log.d(TAG, "Закодирован HEX цвет: " + color + " -> " + encoded);
|
||||
return encoded;
|
||||
} else {
|
||||
// Для именованных цветов используем стандартное кодирование
|
||||
String encoded = URLEncoder.encode(color, StandardCharsets.UTF_8.toString());
|
||||
Log.d(TAG, "Закодирован именованный цвет: " + color + " -> " + encoded);
|
||||
return encoded;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка кодирования цвета: " + e.getMessage(), e);
|
||||
return "green"; // Цвет по умолчанию
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Кодирует строку для безопасного использования в URL
|
||||
* Дополнительно экранирует символы, которые могут вызывать проблемы
|
||||
* @param message Исходное сообщение
|
||||
* @return Закодированное сообщение
|
||||
*/
|
||||
private static String encodeForURL(String message) {
|
||||
try {
|
||||
// Сначала используем стандартное URL кодирование
|
||||
String encoded = URLEncoder.encode(message, StandardCharsets.UTF_8.toString());
|
||||
|
||||
// Дополнительно экранируем символы, которые могут вызывать проблемы
|
||||
// Заменяем < на %3C, > на %3E, & на %26, " на %22, ' на %27, # на %23
|
||||
encoded = encoded.replace("<", "%3C")
|
||||
.replace(">", "%3E")
|
||||
.replace("&", "%26")
|
||||
.replace("\"", "%22")
|
||||
.replace("'", "%27")
|
||||
.replace("#", "%23");
|
||||
|
||||
// Убираем лишние логи
|
||||
// Log.d(TAG, "Исходное сообщение: " + message);
|
||||
// Log.d(TAG, "Закодированное сообщение: " + encoded);
|
||||
|
||||
return encoded;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка кодирования URL: " + e.getMessage(), e);
|
||||
// В случае ошибки возвращаем базовое кодирование
|
||||
String fallback = message.replace("<", "%3C")
|
||||
.replace(">", "%3E")
|
||||
.replace("&", "%26")
|
||||
.replace("\"", "%22")
|
||||
.replace("'", "%27")
|
||||
.replace("#", "%23")
|
||||
.replace(" ", "%20");
|
||||
Log.d(TAG, "Fallback кодирование: " + fallback);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет GET запрос
|
||||
* @param urlString URL для запроса
|
||||
*/
|
||||
private static void sendGetRequest(String urlString) {
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
// Убираем лишние логи
|
||||
// Log.d(TAG, "Отправляем GET запрос на: " + urlString);
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
URL url = new URL(urlString);
|
||||
URL url = new URL(BASE_URL + "/api/logs/batch");
|
||||
connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setConnectTimeout(5000); // 5 секунд
|
||||
connection.setReadTimeout(5000); // 5 секунд
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
connection.setRequestProperty("User-Agent", "AISMap/1.0");
|
||||
connection.setConnectTimeout(5000);
|
||||
connection.setReadTimeout(10000);
|
||||
connection.setDoOutput(true);
|
||||
|
||||
// Формируем JSON
|
||||
StringBuilder json = new StringBuilder("{\"logs\":[");
|
||||
for (int i = 0; i < logs.size(); i++) {
|
||||
LogEntry log = logs.get(i);
|
||||
if (i > 0) json.append(",");
|
||||
json.append("{")
|
||||
.append("\"type\":\"").append(log.type).append("\",")
|
||||
.append("\"message\":\"").append(log.message.replace("\"", "\\\"")).append("\",")
|
||||
.append("\"color\":\"").append(log.color).append("\",")
|
||||
.append("\"timestamp\":").append(log.timestamp)
|
||||
.append("}");
|
||||
}
|
||||
json.append("]}");
|
||||
|
||||
// Отправляем JSON
|
||||
try (OutputStream os = connection.getOutputStream()) {
|
||||
os.write(json.toString().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
if (responseCode == HttpURLConnection.HTTP_OK) {
|
||||
// Убираем лишние логи
|
||||
// Log.d(TAG, "Лог успешно отправлен, код ответа: " + responseCode);
|
||||
Log.d(TAG, "Пакет из " + logs.size() + " логов успешно отправлен");
|
||||
} else {
|
||||
Log.w(TAG, "Лог отправлен с предупреждением, код ответа: " + responseCode);
|
||||
Log.w(TAG, "Пакет логов отправлен с предупреждением, код ответа: " + responseCode);
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Ошибка HTTP запроса: " + e.getMessage(), e);
|
||||
Log.e(TAG, "Ошибка отправки пакета логов: " + e.getMessage(), e);
|
||||
// Возвращаем логи в буфер при ошибке
|
||||
synchronized (bufferLock) {
|
||||
logBuffer.addAll(0, logs); // Добавляем в начало буфера
|
||||
}
|
||||
} finally {
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
@@ -374,9 +176,297 @@ public class LogSender {
|
||||
}
|
||||
|
||||
/**
|
||||
* Останавливает executor (вызывать при завершении приложения)
|
||||
* Отправляет лог NMEA сообщения
|
||||
* @param nmeaMessage NMEA сообщение
|
||||
*/
|
||||
public static void logNMEA(String nmeaMessage) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (nmeaMessage == null || nmeaMessage.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
addToBuffer("nmea", nmeaMessage, COLOR_SOFT_BLUE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет лог обновления информации о корабле
|
||||
* @param mmsi MMSI корабля
|
||||
* @param vesselInfo Информация о корабле
|
||||
*/
|
||||
public static void logShipUpdate(String mmsi, String vesselInfo) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (mmsi == null || mmsi.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String message = "MMSI: " + mmsi;
|
||||
if (vesselInfo != null && !vesselInfo.trim().isEmpty()) {
|
||||
message += " | " + vesselInfo;
|
||||
}
|
||||
addToBuffer("ships", message, generateVesselColor(mmsi));
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет лог обновления информации о корабле с заданным цветом
|
||||
* @param mmsi MMSI корабля
|
||||
* @param vesselInfo Информация о корабле
|
||||
* @param color Цвет лога
|
||||
*/
|
||||
public static void logShipUpdate(String mmsi, String vesselInfo, String color) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (mmsi == null || mmsi.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String message = "MMSI: " + mmsi;
|
||||
if (vesselInfo != null && !vesselInfo.trim().isEmpty()) {
|
||||
message += " | " + vesselInfo;
|
||||
}
|
||||
addToBuffer("ships", message, color != null ? color : generateVesselColor(mmsi));
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет кастомный лог
|
||||
* @param logName Имя лога
|
||||
* @param message Сообщение
|
||||
* @param color Цвет
|
||||
*/
|
||||
public static void logCustom(String logName, String message, String color) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (logName == null || message == null || message.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
addToBuffer(logName, message, color != null ? color : COLOR_SOFT_BLUE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует уникальный мягкий цвет для корабля на основе MMSI
|
||||
* Оптимизирован для читаемости на фоне #364758
|
||||
*/
|
||||
private static String generateVesselColor(String mmsi) {
|
||||
if (mmsi == null || mmsi.trim().isEmpty()) {
|
||||
return COLOR_SOFT_BLUE;
|
||||
}
|
||||
|
||||
try {
|
||||
long mmsiLong = Long.parseLong(mmsi);
|
||||
// Используем MMSI для генерации цвета
|
||||
int hash = (int) (mmsiLong % 360);
|
||||
|
||||
// Генерируем мягкий цвет в HSV для фона #364758
|
||||
float hue = hash;
|
||||
float saturation = VESSEL_COLOR_SATURATION; // Низкая насыщенность для мягкости
|
||||
float value = VESSEL_COLOR_VALUE; // Средняя яркость для контраста с темным фоном
|
||||
|
||||
// Конвертируем HSV в RGB (Android-совместимая реализация)
|
||||
float[] rgb = hsvToRgb(hue / 360f, saturation, value);
|
||||
int red = Math.round(rgb[0] * 255);
|
||||
int green = Math.round(rgb[1] * 255);
|
||||
int blue = Math.round(rgb[2] * 255);
|
||||
|
||||
return String.format("#%02X%02X%02X", red, green, blue);
|
||||
} catch (NumberFormatException e) {
|
||||
// Если MMSI не число, используем хеш строки
|
||||
int hash = Math.abs(mmsi.hashCode()) % 360;
|
||||
float hue = hash;
|
||||
float saturation = VESSEL_COLOR_SATURATION; // Низкая насыщенность для мягкости
|
||||
float value = VESSEL_COLOR_VALUE; // Средняя яркость для контраста с темным фоном
|
||||
|
||||
float[] rgb = hsvToRgb(hue / 360f, saturation, value);
|
||||
int red = Math.round(rgb[0] * 255);
|
||||
int green = Math.round(rgb[1] * 255);
|
||||
int blue = Math.round(rgb[2] * 255);
|
||||
|
||||
return String.format("#%02X%02X%02X", red, green, blue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертирует HSV в RGB (Android-совместимая реализация)
|
||||
*/
|
||||
private static float[] hsvToRgb(float h, float s, float v) {
|
||||
float[] rgb = new float[3];
|
||||
|
||||
if (s == 0) {
|
||||
// Оттенки серого
|
||||
rgb[0] = rgb[1] = rgb[2] = v;
|
||||
} else {
|
||||
float c = v * s;
|
||||
float x = c * (1 - Math.abs((h * 6) % 2 - 1));
|
||||
float m = v - c;
|
||||
|
||||
if (h < 1f/6f) {
|
||||
rgb[0] = c; rgb[1] = x; rgb[2] = 0;
|
||||
} else if (h < 2f/6f) {
|
||||
rgb[0] = x; rgb[1] = c; rgb[2] = 0;
|
||||
} else if (h < 3f/6f) {
|
||||
rgb[0] = 0; rgb[1] = c; rgb[2] = x;
|
||||
} else if (h < 4f/6f) {
|
||||
rgb[0] = 0; rgb[1] = x; rgb[2] = c;
|
||||
} else if (h < 5f/6f) {
|
||||
rgb[0] = x; rgb[1] = 0; rgb[2] = c;
|
||||
} else {
|
||||
rgb[0] = c; rgb[1] = 0; rgb[2] = x;
|
||||
}
|
||||
|
||||
rgb[0] += m;
|
||||
rgb[1] += m;
|
||||
rgb[2] += m;
|
||||
}
|
||||
|
||||
return rgb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Логирует ошибки парсинга NMEA сообщений
|
||||
*/
|
||||
public static void logError(String errorType, String message, String details) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
String timestamp = java.time.LocalDateTime.now().format(
|
||||
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||
|
||||
String logMessage = String.format(java.util.Locale.US,
|
||||
"[%s] %s: %s | Details: %s",
|
||||
timestamp, errorType, message, details);
|
||||
|
||||
addToBuffer("errors", logMessage, COLOR_SOFT_RED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Логирует отброшенное NMEA сообщение
|
||||
*/
|
||||
public static void logDroppedNMEA(String reason, String nmeaMessage, String details) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
logError("DROPPED_NMEA",
|
||||
String.format("Отброшено NMEA сообщение: %s", reason),
|
||||
String.format("Message: %s | %s",
|
||||
nmeaMessage != null ? nmeaMessage.substring(0, Math.min(100, nmeaMessage.length())) : "null",
|
||||
details != null ? details : ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Логирует ошибку парсинга AIS
|
||||
*/
|
||||
public static void logAISParseError(String error, String aisMessage, String details) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
logError("AIS_PARSE_ERROR",
|
||||
String.format("Ошибка парсинга AIS: %s", error),
|
||||
String.format("AIS: %s | %s",
|
||||
aisMessage != null ? aisMessage.substring(0, Math.min(100, aisMessage.length())) : "null",
|
||||
details != null ? details : ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Логирует ошибку парсинга AIS с полным NMEA сообщением
|
||||
*/
|
||||
public static void logAISParseErrorWithFullNMEA(String error, String fullNMEAMessage, String aisPayload, String details) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
logError("AIS_PARSE_ERROR",
|
||||
String.format("Ошибка парсинга AIS: %s", error),
|
||||
String.format("Full NMEA: %s | AIS Payload: %s | %s",
|
||||
fullNMEAMessage != null ? fullNMEAMessage : "null",
|
||||
aisPayload != null ? aisPayload.substring(0, Math.min(100, aisPayload.length())) : "null",
|
||||
details != null ? details : ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Логирует ошибку BLE соединения
|
||||
*/
|
||||
public static void logBLEError(String error, String deviceMac, String details) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
logError("BLE_ERROR",
|
||||
String.format("Ошибка BLE: %s", error),
|
||||
String.format("Device: %s | %s",
|
||||
deviceMac != null ? deviceMac : "unknown",
|
||||
details != null ? details : ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Логирует полученный BLE кусок данных
|
||||
*/
|
||||
public static void logBLEDataChunk(String deviceMac, String dataChunk) {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (dataChunk == null || dataChunk.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String message = "BLE Data from " + (deviceMac != null ? deviceMac : "unknown") + ": " + dataChunk;
|
||||
addToBuffer("ble", message, COLOR_SOFT_BLUE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает количество логов в буфере
|
||||
*/
|
||||
public static int getBufferSize() {
|
||||
if (!ENABLED) {
|
||||
return 0;
|
||||
}
|
||||
synchronized (bufferLock) {
|
||||
return logBuffer.size();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Принудительно отправляет все накопленные логи
|
||||
*/
|
||||
public static void flushLogs() {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
sendBufferedLogs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Останавливает планировщик (вызывать при завершении приложения)
|
||||
*/
|
||||
public static void shutdown() {
|
||||
executor.shutdown();
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdown();
|
||||
try {
|
||||
if (!scheduler.awaitTermination(2, TimeUnit.SECONDS)) {
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
scheduler.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Тестовый метод для демонстрации мягких цветов кораблей
|
||||
* Можно использовать для проверки читаемости на фоне #364758
|
||||
*/
|
||||
public static void testVesselColors() {
|
||||
if (!ENABLED) {
|
||||
return;
|
||||
}
|
||||
String[] testMMSIs = {"123456789", "987654321", "555666777", "111222333", "999888777"};
|
||||
|
||||
Log.d(TAG, "=== Тест мягких цветов для кораблей (фон #364758) ===");
|
||||
for (String mmsi : testMMSIs) {
|
||||
String color = generateVesselColor(mmsi);
|
||||
Log.d(TAG, String.format("MMSI %s -> цвет %s", mmsi, color));
|
||||
}
|
||||
// Log.d(TAG, "=== Настройки: насыщенность=%.1f, яркость=%.1f ===",VESSEL_COLOR_SATURATION, VESSEL_COLOR_VALUE);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ public class SettingsManager {
|
||||
private static final String KEY_ANDROID_NMEA_ENABLED = "android_nmea_enabled";
|
||||
private static final String KEY_UDP_NMEA_ENABLED = "udp_nmea_enabled";
|
||||
private static final String KEY_DATA_MODE = "data_mode";
|
||||
// Источник координат собственного судна. Отделён от KEY_DATA_MODE,
|
||||
// так как начиная с BLE v2 ais_hub сам поставляет ownship по BLE,
|
||||
// а настройки выше трактуют только старый NMEA-тракт.
|
||||
private static final String KEY_GPS_SOURCE = "gps_source";
|
||||
private static final String KEY_DATA_STALE_WARNING_MINUTES = "data_stale_warning_minutes";
|
||||
private static final String KEY_DATA_STALE_REMOVE_MINUTES = "data_stale_remove_minutes";
|
||||
private static final String KEY_PATH_TRACKING_ENABLED = "path_tracking_enabled";
|
||||
@@ -34,7 +38,19 @@ public class SettingsManager {
|
||||
private static final String KEY_CURSOR_ENABLED = "cursor_enabled";
|
||||
private static final String KEY_NOTIFICATIONS_ENABLED = "notifications_enabled";
|
||||
private static final String KEY_DEBUG_ENABLED = "debug_enabled";
|
||||
private static final String KEY_SEAMARKS_ENABLED = "seamarks_enabled";
|
||||
private static final String KEY_ANDROID_GPS_ENABLED = "android_gps_enabled";
|
||||
// Map startup behavior
|
||||
private static final String KEY_START_CENTER_ON_LAST = "start_center_on_last";
|
||||
private static final String KEY_START_ZOOM_LEVEL = "start_zoom_level";
|
||||
/** Как карта следует за ориентацией: {@link #MAP_ROTATION_COMPASS} / COURSE / MANUAL */
|
||||
private static final String KEY_MAP_ROTATION_MODE = "map_rotation_mode";
|
||||
// BLE/NMEA settings
|
||||
private static final String KEY_BLE_ENABLED = "ble_enabled";
|
||||
private static final String KEY_BLE_DEVICE_MAC = "ble_device_mac";
|
||||
private static final String KEY_BLE_UDP_BRIDGE_ENABLED = "ble_udp_bridge_enabled";
|
||||
private static final String KEY_BLE_UDP_BRIDGE_HOST = "ble_udp_bridge_host";
|
||||
private static final String KEY_BLE_UDP_BRIDGE_PORT = "ble_udp_bridge_port";
|
||||
|
||||
// Значения по умолчанию
|
||||
private static final int DEFAULT_UDP_PORT = 10110;
|
||||
@@ -58,11 +74,39 @@ public class SettingsManager {
|
||||
private static final boolean DEFAULT_NOTIFICATIONS_ENABLED = true;
|
||||
private static final boolean DEFAULT_ANDROID_GPS_ENABLED = true;
|
||||
private static final boolean DEFAULT_DEBUG_ENABLED = false;
|
||||
private static final boolean DEFAULT_SEAMARKS_ENABLED = false;
|
||||
// Map startup defaults
|
||||
private static final boolean DEFAULT_START_CENTER_ON_LAST = true;
|
||||
private static final float DEFAULT_START_ZOOM_LEVEL = 14.0f;
|
||||
// BLE defaults
|
||||
private static final boolean DEFAULT_BLE_ENABLED = false;
|
||||
private static final String DEFAULT_BLE_DEVICE_MAC = "";
|
||||
private static final boolean DEFAULT_BLE_UDP_BRIDGE_ENABLED = false;
|
||||
private static final String DEFAULT_BLE_UDP_BRIDGE_HOST = "255.255.255.255";
|
||||
private static final int DEFAULT_BLE_UDP_BRIDGE_PORT = 10110;
|
||||
|
||||
// Режимы работы с данными
|
||||
public static final String DATA_MODE_HYBRID = "hybrid";
|
||||
public static final String DATA_MODE_NMEA_ONLY = "nmea_only";
|
||||
public static final String DATA_MODE_ANDROID_ONLY = "android_only";
|
||||
|
||||
// Источник координат собственного судна.
|
||||
// - GPS_SOURCE_HUB: позиция берётся из ais_hub (BLE ownship.update).
|
||||
// Android GPS/NMEA слушать не нужно. AIS-цели всегда идут через BLE.
|
||||
// - GPS_SOURCE_ANDROID: позиция берётся из Android Location API
|
||||
// (+ опциональный внешний NMEA по UDP). BLE может оставаться включённым
|
||||
// ради AIS-целей, но его ownship.update игнорируется.
|
||||
public static final String GPS_SOURCE_HUB = "ble_hub";
|
||||
public static final String GPS_SOURCE_ANDROID = "android";
|
||||
private static final String DEFAULT_GPS_SOURCE = GPS_SOURCE_HUB;
|
||||
|
||||
/** Север вверх; поворот двумя пальцами, авто-не вмешивается. */
|
||||
public static final String MAP_ROTATION_MANUAL = "manual";
|
||||
/** Как магнитный компас / азимут корпуса. */
|
||||
public static final String MAP_ROTATION_COMPASS = "compass";
|
||||
/** Как курс (COG / GPS bearing). */
|
||||
public static final String MAP_ROTATION_COURSE = "course";
|
||||
private static final String DEFAULT_MAP_ROTATION_MODE = MAP_ROTATION_MANUAL;
|
||||
|
||||
private Context context;
|
||||
private SharedPreferences prefs;
|
||||
@@ -185,6 +229,38 @@ public class SettingsManager {
|
||||
return DATA_MODE_ANDROID_ONLY.equals(getDataMode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Текущий источник координат собственного судна: {@link #GPS_SOURCE_HUB}
|
||||
* или {@link #GPS_SOURCE_ANDROID}. По умолчанию — HUB.
|
||||
*/
|
||||
public String getGpsSource() {
|
||||
String v = prefs.getString(KEY_GPS_SOURCE, DEFAULT_GPS_SOURCE);
|
||||
if (!GPS_SOURCE_HUB.equals(v) && !GPS_SOURCE_ANDROID.equals(v)) {
|
||||
return DEFAULT_GPS_SOURCE;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
public void setGpsSource(String source) {
|
||||
if (!GPS_SOURCE_HUB.equals(source) && !GPS_SOURCE_ANDROID.equals(source)) {
|
||||
source = DEFAULT_GPS_SOURCE;
|
||||
}
|
||||
prefs.edit().putString(KEY_GPS_SOURCE, source).apply();
|
||||
Log.i(TAG, "GPS source: " + source);
|
||||
}
|
||||
|
||||
public boolean isGpsFromHub() { return GPS_SOURCE_HUB.equals(getGpsSource()); }
|
||||
public boolean isGpsFromAndroid() { return GPS_SOURCE_ANDROID.equals(getGpsSource()); }
|
||||
|
||||
/**
|
||||
* Переключает источник координат и возвращает новое значение.
|
||||
*/
|
||||
public String toggleGpsSource() {
|
||||
String next = isGpsFromHub() ? GPS_SOURCE_ANDROID : GPS_SOURCE_HUB;
|
||||
setGpsSource(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, включен ли Android GPS (Location API)
|
||||
*/
|
||||
@@ -199,6 +275,55 @@ public class SettingsManager {
|
||||
prefs.edit().putBoolean(KEY_ANDROID_GPS_ENABLED, enabled).apply();
|
||||
Log.i(TAG, "Android GPS: " + (enabled ? "включен" : "выключен"));
|
||||
}
|
||||
|
||||
// ===== BLE settings =====
|
||||
public boolean isBLEEnabled() {
|
||||
return prefs.getBoolean(KEY_BLE_ENABLED, DEFAULT_BLE_ENABLED);
|
||||
}
|
||||
|
||||
public void setBLEEnabled(boolean enabled) {
|
||||
prefs.edit().putBoolean(KEY_BLE_ENABLED, enabled).apply();
|
||||
Log.i(TAG, "BLE: " + (enabled ? "включен" : "выключен"));
|
||||
}
|
||||
|
||||
public String getBLEDeviceMac() {
|
||||
return prefs.getString(KEY_BLE_DEVICE_MAC, DEFAULT_BLE_DEVICE_MAC);
|
||||
}
|
||||
|
||||
public void setBLEDeviceMac(String mac) {
|
||||
if (mac == null) mac = "";
|
||||
prefs.edit().putString(KEY_BLE_DEVICE_MAC, mac).apply();
|
||||
Log.i(TAG, "BLE MAC сохранён: " + mac);
|
||||
}
|
||||
|
||||
public boolean isBleUdpBridgeEnabled() {
|
||||
return prefs.getBoolean(KEY_BLE_UDP_BRIDGE_ENABLED, DEFAULT_BLE_UDP_BRIDGE_ENABLED);
|
||||
}
|
||||
|
||||
public void setBleUdpBridgeEnabled(boolean enabled) {
|
||||
prefs.edit().putBoolean(KEY_BLE_UDP_BRIDGE_ENABLED, enabled).apply();
|
||||
Log.i(TAG, "BLE UDP-bridge: " + (enabled ? "включен" : "выключен"));
|
||||
}
|
||||
|
||||
public String getBleUdpBridgeHost() {
|
||||
return prefs.getString(KEY_BLE_UDP_BRIDGE_HOST, DEFAULT_BLE_UDP_BRIDGE_HOST);
|
||||
}
|
||||
|
||||
public void setBleUdpBridgeHost(String host) {
|
||||
if (host == null || host.trim().isEmpty()) host = DEFAULT_BLE_UDP_BRIDGE_HOST;
|
||||
prefs.edit().putString(KEY_BLE_UDP_BRIDGE_HOST, host).apply();
|
||||
Log.i(TAG, "BLE UDP-bridge host: " + host);
|
||||
}
|
||||
|
||||
public int getBleUdpBridgePort() {
|
||||
return prefs.getInt(KEY_BLE_UDP_BRIDGE_PORT, DEFAULT_BLE_UDP_BRIDGE_PORT);
|
||||
}
|
||||
|
||||
public void setBleUdpBridgePort(int port) {
|
||||
if (port < 1 || port > 65535) port = DEFAULT_BLE_UDP_BRIDGE_PORT;
|
||||
prefs.edit().putInt(KEY_BLE_UDP_BRIDGE_PORT, port).apply();
|
||||
Log.i(TAG, "BLE UDP-bridge port: " + port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сбрасывает все настройки к значениям по умолчанию
|
||||
@@ -215,6 +340,14 @@ public class SettingsManager {
|
||||
.putBoolean(KEY_VIBRATION_ENABLED, DEFAULT_VIBRATION_ENABLED)
|
||||
.putBoolean(KEY_SOUND_ENABLED, DEFAULT_SOUND_ENABLED)
|
||||
.putBoolean(KEY_KEEP_SCREEN_ON_ENABLED, DEFAULT_KEEP_SCREEN_ON_ENABLED)
|
||||
.putBoolean(KEY_START_CENTER_ON_LAST, DEFAULT_START_CENTER_ON_LAST)
|
||||
.putFloat(KEY_START_ZOOM_LEVEL, DEFAULT_START_ZOOM_LEVEL)
|
||||
.putString(KEY_MAP_ROTATION_MODE, DEFAULT_MAP_ROTATION_MODE)
|
||||
.putBoolean(KEY_BLE_ENABLED, DEFAULT_BLE_ENABLED)
|
||||
.putString(KEY_BLE_DEVICE_MAC, DEFAULT_BLE_DEVICE_MAC)
|
||||
.putBoolean(KEY_BLE_UDP_BRIDGE_ENABLED, DEFAULT_BLE_UDP_BRIDGE_ENABLED)
|
||||
.putString(KEY_BLE_UDP_BRIDGE_HOST, DEFAULT_BLE_UDP_BRIDGE_HOST)
|
||||
.putInt(KEY_BLE_UDP_BRIDGE_PORT, DEFAULT_BLE_UDP_BRIDGE_PORT)
|
||||
.apply();
|
||||
Log.i(TAG, "Настройки сброшены к значениям по умолчанию");
|
||||
}
|
||||
@@ -247,17 +380,80 @@ public class SettingsManager {
|
||||
"UDP: порт=%d, включен=%s\n" +
|
||||
"Android NMEA: %s\n" +
|
||||
"UDP NMEA: %s\n" +
|
||||
"Старт центр по последней: %s, стартовый зум=%.1f\n" +
|
||||
"BLE: %s, MAC=%s, Bridge=%s %s:%d\n" +
|
||||
"Режим данных: %s\n" +
|
||||
"Уведомления: вибрация=%s, звук=%s",
|
||||
getUDPPort(),
|
||||
isUDPEnabled() ? "да" : "нет",
|
||||
isAndroidNMEAEnabled() ? "включен" : "выключен",
|
||||
isUDPNMEAEnabled() ? "включен" : "выключен",
|
||||
isStartCenterOnLastEnabled() ? "да" : "нет",
|
||||
getStartZoomLevel(),
|
||||
isBLEEnabled() ? "включен" : "выключен",
|
||||
getBLEDeviceMac(),
|
||||
isBleUdpBridgeEnabled() ? "вкл" : "выкл",
|
||||
getBleUdpBridgeHost(),
|
||||
getBleUdpBridgePort(),
|
||||
getDataMode(),
|
||||
isVibrationEnabled() ? "включена" : "выключена",
|
||||
isSoundEnabled() ? "включен" : "выключен"
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Map startup behavior =====
|
||||
public boolean isStartCenterOnLastEnabled() {
|
||||
return prefs.getBoolean(KEY_START_CENTER_ON_LAST, DEFAULT_START_CENTER_ON_LAST);
|
||||
}
|
||||
|
||||
public void setStartCenterOnLastEnabled(boolean enabled) {
|
||||
prefs.edit().putBoolean(KEY_START_CENTER_ON_LAST, enabled).apply();
|
||||
Log.i(TAG, "Старт: центр по последней позиции: " + (enabled ? "включен" : "выключен"));
|
||||
}
|
||||
|
||||
public float getStartZoomLevel() {
|
||||
return prefs.getFloat(KEY_START_ZOOM_LEVEL, DEFAULT_START_ZOOM_LEVEL);
|
||||
}
|
||||
|
||||
public void setStartZoomLevel(float zoom) {
|
||||
if (zoom < 2.0f) zoom = 2.0f;
|
||||
if (zoom > 20.0f) zoom = 20.0f;
|
||||
prefs.edit().putFloat(KEY_START_ZOOM_LEVEL, zoom).apply();
|
||||
Log.i(TAG, "Стартовый зум установлен: " + zoom);
|
||||
}
|
||||
|
||||
public String getMapRotationMode() {
|
||||
String m = prefs.getString(KEY_MAP_ROTATION_MODE, DEFAULT_MAP_ROTATION_MODE);
|
||||
if (!MAP_ROTATION_MANUAL.equals(m) && !MAP_ROTATION_COMPASS.equals(m) && !MAP_ROTATION_COURSE.equals(m)) {
|
||||
return DEFAULT_MAP_ROTATION_MODE;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
public void setMapRotationMode(String mode) {
|
||||
if (!MAP_ROTATION_MANUAL.equals(mode) && !MAP_ROTATION_COMPASS.equals(mode) && !MAP_ROTATION_COURSE.equals(mode)) {
|
||||
mode = DEFAULT_MAP_ROTATION_MODE;
|
||||
}
|
||||
prefs.edit().putString(KEY_MAP_ROTATION_MODE, mode).apply();
|
||||
Log.i(TAG, "Режим вращения карты: " + mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Цикл: компас → курс → вручную → компас …
|
||||
*/
|
||||
public String cycleMapRotationMode() {
|
||||
String current = getMapRotationMode();
|
||||
String next;
|
||||
if (MAP_ROTATION_COMPASS.equals(current)) {
|
||||
next = MAP_ROTATION_COURSE;
|
||||
} else if (MAP_ROTATION_COURSE.equals(current)) {
|
||||
next = MAP_ROTATION_MANUAL;
|
||||
} else {
|
||||
next = MAP_ROTATION_COMPASS;
|
||||
}
|
||||
setMapRotationMode(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, нужно ли перезапустить UDP слушатель
|
||||
@@ -486,4 +682,19 @@ public class SettingsManager {
|
||||
Log.i(TAG, "Дебаг-режим: " + (enabled ? "включен" : "выключен"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, включены ли морские знаки OpenSeaMap
|
||||
*/
|
||||
public boolean isSeamarksEnabled() {
|
||||
return prefs.getBoolean(KEY_SEAMARKS_ENABLED, DEFAULT_SEAMARKS_ENABLED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Включает/выключает морские знаки OpenSeaMap
|
||||
*/
|
||||
public void setSeamarksEnabled(boolean enabled) {
|
||||
prefs.edit().putBoolean(KEY_SEAMARKS_ENABLED, enabled).apply();
|
||||
Log.i(TAG, "Морские знаки OpenSeaMap: " + (enabled ? "включены" : "выключены"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -228,28 +228,28 @@ public abstract class BaseDockWidget extends FrameLayout {
|
||||
private void handleDockResize(MotionEvent event) {
|
||||
float deltaY = event.getRawY() - lastTouchY;
|
||||
lastTouchY = event.getRawY();
|
||||
|
||||
ViewGroup.LayoutParams lp = getLayoutParams();
|
||||
int newHeight = lp.height;
|
||||
|
||||
// Направление изменения размера зависит от позиции закрепления
|
||||
|
||||
// Ресайзим именно контент (dockHeightPx). Паддинги от WindowInsets
|
||||
// прибавляются поверх в onMeasure, поэтому «рабочая» часть не уезжает
|
||||
// под системный бар даже при минимальном размере.
|
||||
int currentContent = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
|
||||
int newHeight = currentContent;
|
||||
|
||||
if (dockTop) {
|
||||
// Если закреплен сверху, увеличиваем размер при движении вниз
|
||||
newHeight += (int) deltaY;
|
||||
} else {
|
||||
// Если закреплен снизу, увеличиваем размер при движении вверх
|
||||
newHeight -= (int) deltaY;
|
||||
}
|
||||
|
||||
// Ограничиваем минимальную и максимальную высоту
|
||||
|
||||
int minHeight = (int) dp(40);
|
||||
int maxHeight = ((ViewGroup) getParent()).getHeight() / 2;
|
||||
|
||||
|
||||
newHeight = Math.max(minHeight, Math.min(newHeight, maxHeight));
|
||||
|
||||
if (newHeight != lp.height) {
|
||||
lp.height = newHeight;
|
||||
|
||||
if (newHeight != currentContent) {
|
||||
dockHeightPx = newHeight;
|
||||
ViewGroup.LayoutParams lp = getLayoutParams();
|
||||
lp.height = newHeight + getPaddingTop() + getPaddingBottom();
|
||||
setLayoutParams(lp);
|
||||
|
||||
// Корректируем позицию Y в зависимости от позиции закрепления
|
||||
@@ -324,7 +324,11 @@ public abstract class BaseDockWidget extends FrameLayout {
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
if (isDocked) {
|
||||
int width = MeasureSpec.getSize(widthMeasureSpec);
|
||||
int height = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
|
||||
// dockHeightPx/DEFAULT — это высота полезного контента; к ней
|
||||
// прибавляем padding от WindowInsets, чтобы виджет фактически
|
||||
// расширялся под статус-бар или нав-бар и не прятал контент.
|
||||
int content = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
|
||||
int height = content + getPaddingTop() + getPaddingBottom();
|
||||
setMeasuredDimension(width, height);
|
||||
} else {
|
||||
int size = (int)(dp(CIRCLE_SIZE_DP) * scaleFactor);
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Typeface;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.Log;
|
||||
import android.view.ViewGroup;
|
||||
@@ -22,17 +23,32 @@ import java.util.List;
|
||||
|
||||
public class CompassView extends BaseDockWidget {
|
||||
private static final String TAG = "CompassView";
|
||||
|
||||
|
||||
// Палитра синхронизирована с CoordinatesDockWidget — чтобы компас и
|
||||
// координаты выглядели единым виджетом, а не двумя разными стилями.
|
||||
private static final int BACKGROUND_COLOR = 0xD91A1F24;
|
||||
private static final int TEXT_COLOR = 0xFFFFFFFF;
|
||||
private static final int LABEL_COLOR = 0xFF9AA4B2;
|
||||
private static final int ACCENT_COLOR = 0xFF4CAF50; // курс/heading
|
||||
private static final int DIVIDER_COLOR = 0x33FFFFFF;
|
||||
private static final int TICK_COLOR = 0xFFD0D4DA;
|
||||
|
||||
private float targetAzimuth = 0;
|
||||
private float currentAzimuth = 0;
|
||||
private float magneticCompass = 0; // магнитный компас
|
||||
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint valuePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint accentPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint dividerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint vesselPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Path vesselPath = new Path();
|
||||
private final String[] directions = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"};
|
||||
private float centerX;
|
||||
private float centerY;
|
||||
private static final float SMOOTHING_FACTOR = 0.15f;
|
||||
private static final float AZIMUTH_DRAW_EPS = 0.5f;
|
||||
private List<AISVessel> nearbyVessels = new ArrayList<>();
|
||||
private Vessel ourVessel; // наше судно для расчета расстояний
|
||||
private static final float MAX_DISPLAY_DISTANCE = 10000; // 10 км
|
||||
@@ -50,15 +66,33 @@ public class CompassView extends BaseDockWidget {
|
||||
}
|
||||
|
||||
private void init() {
|
||||
paint.setColor(Color.WHITE);
|
||||
paint.setColor(TICK_COLOR);
|
||||
paint.setTextAlign(Paint.Align.CENTER);
|
||||
paint.setTextSize(36f);
|
||||
|
||||
labelPaint.setColor(LABEL_COLOR);
|
||||
labelPaint.setTextSize(dp(11));
|
||||
labelPaint.setTypeface(Typeface.DEFAULT);
|
||||
labelPaint.setLetterSpacing(0.08f);
|
||||
|
||||
valuePaint.setColor(TEXT_COLOR);
|
||||
valuePaint.setTextSize(dp(16));
|
||||
valuePaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
|
||||
accentPaint.setColor(ACCENT_COLOR);
|
||||
accentPaint.setTextSize(dp(16));
|
||||
accentPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
|
||||
bgPaint.setColor(BACKGROUND_COLOR);
|
||||
bgPaint.setStyle(Paint.Style.FILL);
|
||||
|
||||
dividerPaint.setColor(DIVIDER_COLOR);
|
||||
dividerPaint.setStrokeWidth(dp(1));
|
||||
|
||||
vesselPaint.setStyle(Paint.Style.FILL);
|
||||
vesselPaint.setAntiAlias(true);
|
||||
|
||||
// Устанавливаем фон для видимости
|
||||
setBackgroundColor(Color.argb(200, 0, 0, 0));
|
||||
|
||||
setBackgroundColor(Color.TRANSPARENT);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -96,46 +130,81 @@ public class CompassView extends BaseDockWidget {
|
||||
// Прямая шкала (dock-режим)
|
||||
@Override
|
||||
protected void onDrawDock(Canvas canvas) {
|
||||
// Log.d(TAG, "onDrawDock called, width=" + getWidth() + ", height=" + getHeight());
|
||||
|
||||
float w = getWidth();
|
||||
float h = getHeight();
|
||||
|
||||
if (w <= 0 || h <= 0) {
|
||||
Log.w(TAG, "Invalid dimensions: width=" + w + ", height=" + h);
|
||||
float totalW = getWidth();
|
||||
float totalH = getHeight();
|
||||
if (totalW <= 0 || totalH <= 0) {
|
||||
Log.w(TAG, "Invalid dimensions: width=" + totalW + ", height=" + totalH);
|
||||
return;
|
||||
}
|
||||
|
||||
// Простой фон для начала
|
||||
paint.setColor(Color.argb(200, 0, 0, 0));
|
||||
canvas.drawRect(0, 0, w, h, paint);
|
||||
|
||||
// Масштабируем размеры в зависимости от высоты виджета
|
||||
float baseHeight = dp(80); // базовая высота
|
||||
|
||||
// Учитываем паддинги (которые MainActivity назначает по системным
|
||||
// инсетам и вырезам камеры). Фон рисуем на всю область виджета,
|
||||
// а весь контент — только внутри padding-box.
|
||||
int pl = getPaddingLeft();
|
||||
int pt = getPaddingTop();
|
||||
int pr = getPaddingRight();
|
||||
int pb = getPaddingBottom();
|
||||
float left = pl;
|
||||
float top = pt;
|
||||
float right = totalW - pr;
|
||||
float bottom = totalH - pb;
|
||||
float w = Math.max(0f, right - left);
|
||||
float h = Math.max(0f, bottom - top);
|
||||
if (w <= 0 || h <= 0) return;
|
||||
|
||||
// Фон в палитре координатного виджета — рисуем на всю область,
|
||||
// чтобы под статус-бар/бровь тоже уходил единый тон.
|
||||
canvas.drawRect(0, 0, totalW, totalH, bgPaint);
|
||||
|
||||
// Масштабируем размеры в зависимости от высоты контентной области.
|
||||
float baseHeight = dp(80);
|
||||
float scaleFactor = Math.max(0.8f, Math.min(2.0f, h / baseHeight));
|
||||
|
||||
// Простой текст для проверки (убрана надпись "КОМПАС")
|
||||
paint.setColor(Color.WHITE);
|
||||
|
||||
// Шапка в стиле LABEL + значение (как POSITION/SOG/COG/ACC в
|
||||
// координатах): слева HEADING (азимут), справа MAG (магн. компас).
|
||||
float cx = left + w / 2f;
|
||||
float padInner = dp(10);
|
||||
float labelY = top + dp(12) * Math.max(1f, scaleFactor * 0.9f);
|
||||
float valueY = labelY + dp(16) * Math.max(1f, scaleFactor * 0.9f);
|
||||
|
||||
labelPaint.setTextAlign(Paint.Align.LEFT);
|
||||
valuePaint.setTextAlign(Paint.Align.LEFT);
|
||||
accentPaint.setTextAlign(Paint.Align.LEFT);
|
||||
|
||||
canvas.drawText("HEADING", left + padInner, labelY, labelPaint);
|
||||
canvas.drawText(((int) currentAzimuth) + "°",
|
||||
left + padInner, valueY, accentPaint);
|
||||
|
||||
labelPaint.setTextAlign(Paint.Align.RIGHT);
|
||||
valuePaint.setTextAlign(Paint.Align.RIGHT);
|
||||
canvas.drawText("MAG", right - padInner, labelY, labelPaint);
|
||||
canvas.drawText(((int) magneticCompass) + "°",
|
||||
right - padInner, valueY, valuePaint);
|
||||
|
||||
// Разделитель под шапкой — такой же, как в координатах.
|
||||
float dividerY = valueY + dp(6);
|
||||
canvas.drawLine(left + padInner, dividerY, right - padInner, dividerY, dividerPaint);
|
||||
|
||||
// Цвет делений шкалы — светло-серый, чтобы не спорил с фоном палитры.
|
||||
paint.setColor(TICK_COLOR);
|
||||
paint.setTextSize(24 * scaleFactor);
|
||||
paint.setTextAlign(Paint.Align.CENTER);
|
||||
float topTextY = dp(18) * scaleFactor;
|
||||
canvas.drawText("Азимут: " + (int)currentAzimuth + "°", w/2, topTextY, paint);
|
||||
canvas.drawText("Магн: " + (int)magneticCompass + "°", w/2, topTextY + 24 * scaleFactor, paint);
|
||||
|
||||
// Плавное обновление азимута
|
||||
float diff = getShortestRotation(currentAzimuth, targetAzimuth);
|
||||
if (Math.abs(diff) > 0.1f) {
|
||||
// Ограничиваем максимальное изменение за один кадр
|
||||
float maxChange = 3.0f; // максимальное изменение в градусах за кадр
|
||||
if (Math.abs(diff) > AZIMUTH_DRAW_EPS) {
|
||||
float maxChange = 3.0f;
|
||||
float change = Math.signum(diff) * Math.min(Math.abs(diff * SMOOTHING_FACTOR), maxChange);
|
||||
currentAzimuth += change;
|
||||
currentAzimuth = normalizeAngle(currentAzimuth);
|
||||
postInvalidateOnAnimation();
|
||||
}
|
||||
|
||||
// Рисуем простую шкалу
|
||||
float centerX = w / 2f;
|
||||
float centerY = h / 2f;
|
||||
// Рисуем простую шкалу под шапкой. Центр смещён, чтобы шкала
|
||||
// не наезжала на label-строку HEADING/MAG.
|
||||
float centerX = left + w / 2f;
|
||||
float scaleTop = dividerY + dp(4);
|
||||
float centerY = scaleTop + (bottom - scaleTop) * 0.5f;
|
||||
float visibleDegrees = 120;
|
||||
|
||||
// Рисуем деления шкалы
|
||||
@@ -179,31 +248,31 @@ public class CompassView extends BaseDockWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Центральная линия (направление вперёд)
|
||||
// Центральная линия (направление вперёд) — только в области шкалы,
|
||||
// чтобы не пересекать шапку HEADING/MAG.
|
||||
paint.setColor(Color.RED);
|
||||
paint.setStrokeWidth(3 * scaleFactor);
|
||||
canvas.drawLine(centerX, centerY - h/2, centerX, centerY + h/2, paint);
|
||||
paint.setColor(Color.WHITE);
|
||||
canvas.drawLine(centerX, scaleTop, centerX, bottom, paint);
|
||||
paint.setColor(TICK_COLOR);
|
||||
paint.setStrokeWidth(1);
|
||||
|
||||
// Выделяем зону resize в зависимости от позиции закрепления
|
||||
|
||||
// Зоны resize остаются привязанными к физическим краям виджета,
|
||||
// а не к padding-box, иначе пользователь не попадёт пальцем.
|
||||
if (isDocked) {
|
||||
Paint resizePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
resizePaint.setColor(Color.argb(120, 255, 255, 255));
|
||||
resizePaint.setStyle(Paint.Style.STROKE);
|
||||
resizePaint.setStrokeWidth(2);
|
||||
|
||||
|
||||
paint.setTextSize(12);
|
||||
paint.setColor(Color.WHITE);
|
||||
|
||||
|
||||
if (isDockTop()) {
|
||||
// Если закреплен сверху, показываем зону resize снизу
|
||||
canvas.drawRect(0, h - dp(24), w, h, resizePaint);
|
||||
canvas.drawText("↕", w/2, h - dp(12), paint);
|
||||
canvas.drawRect(0, totalH - dp(24), totalW, totalH, resizePaint);
|
||||
canvas.drawText("↕", totalW / 2f, totalH - dp(12), paint);
|
||||
} else {
|
||||
// Если закреплен снизу, показываем зону resize сверху
|
||||
canvas.drawRect(0, 0, w, dp(24), resizePaint);
|
||||
canvas.drawText("↕", w/2, dp(12), paint);
|
||||
canvas.drawRect(0, 0, totalW, dp(24), resizePaint);
|
||||
canvas.drawText("↕", totalW / 2f, dp(12), paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,16 +298,15 @@ public class CompassView extends BaseDockWidget {
|
||||
float baseSize = dp(120); // базовая высота
|
||||
float scaleFactor = Math.max(0.8f, Math.min(2.0f, Math.min(w, h) / baseSize));
|
||||
|
||||
// Фон
|
||||
paint.setColor(Color.argb(200, 0, 0, 0));
|
||||
canvas.drawCircle(cx, cy, radius, paint);
|
||||
paint.setColor(Color.WHITE);
|
||||
// Фон круглого компаса — та же палитра, что и у координатного
|
||||
// виджета в draggable-режиме. Используем bgPaint без argb(...).
|
||||
canvas.drawCircle(cx, cy, radius, bgPaint);
|
||||
paint.setColor(TICK_COLOR);
|
||||
|
||||
// Плавное обновление азимута
|
||||
float diff = getShortestRotation(currentAzimuth, targetAzimuth);
|
||||
if (Math.abs(diff) > 0.1f) {
|
||||
// Ограничиваем максимальное изменение за один кадр
|
||||
float maxChange = 3.0f; // максимальное изменение в градусах за кадр
|
||||
if (Math.abs(diff) > AZIMUTH_DRAW_EPS) {
|
||||
float maxChange = 3.0f;
|
||||
float change = Math.signum(diff) * Math.min(Math.abs(diff * SMOOTHING_FACTOR), maxChange);
|
||||
currentAzimuth += change;
|
||||
currentAzimuth = normalizeAngle(currentAzimuth);
|
||||
@@ -282,13 +350,17 @@ public class CompassView extends BaseDockWidget {
|
||||
paint.setColor(Color.RED);
|
||||
paint.setStrokeWidth(3 * scaleFactor);
|
||||
canvas.drawLine(cx, cy, cx, cy - radius, paint);
|
||||
paint.setColor(Color.WHITE);
|
||||
paint.setColor(TICK_COLOR);
|
||||
paint.setStrokeWidth(1);
|
||||
|
||||
// Текст азимута в центре
|
||||
paint.setTextSize(14 * scaleFactor);
|
||||
paint.setTextAlign(Paint.Align.CENTER);
|
||||
canvas.drawText((int)currentAzimuth + "°", cx, cy + 5 * scaleFactor, paint);
|
||||
|
||||
// Центральный текст: значение HEADING в акцентном цвете, ниже —
|
||||
// мелкий LABEL, по аналогии с блоками в CoordinatesDockWidget.
|
||||
accentPaint.setTextAlign(Paint.Align.CENTER);
|
||||
accentPaint.setTextSize(dp(18) * Math.max(0.7f, Math.min(1.4f, scaleFactor)));
|
||||
canvas.drawText(((int) currentAzimuth) + "°", cx, cy + dp(2), accentPaint);
|
||||
labelPaint.setTextAlign(Paint.Align.CENTER);
|
||||
labelPaint.setTextSize(dp(9) * Math.max(0.7f, Math.min(1.4f, scaleFactor)));
|
||||
canvas.drawText("HEADING", cx, cy + dp(14), labelPaint);
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -13,24 +13,28 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
||||
private static final String TAG = "CoordinatesDockWidget";
|
||||
|
||||
// Цвета
|
||||
private static final int BACKGROUND_COLOR = 0xE6000000; // Полупрозрачный черный
|
||||
private static final int BACKGROUND_COLOR = 0xD91A1F24; // Полупрозрачный тёмный с лёгким синим
|
||||
private static final int TEXT_COLOR = 0xFFFFFFFF; // Белый
|
||||
private static final int LABEL_COLOR = 0xFF9AA4B2; // Серо-голубой
|
||||
private static final int ACCENT_COLOR = 0xFF4CAF50; // Зеленый
|
||||
private static final int WARNING_COLOR = 0xFFFF9800; // Оранжевый
|
||||
private static final int ERROR_COLOR = 0xFFF44336; // Красный
|
||||
|
||||
|
||||
// Кисти
|
||||
private Paint backgroundPaint;
|
||||
private Paint labelPaint;
|
||||
private Paint textPaint;
|
||||
private Paint accentPaint;
|
||||
private Paint warningPaint;
|
||||
private Paint errorPaint;
|
||||
|
||||
private Paint dividerPaint;
|
||||
|
||||
// Данные для отображения
|
||||
private Vessel vessel;
|
||||
private String coordinatesText = "Координаты: --";
|
||||
private String sogText = "SOG: --";
|
||||
private String cogText = "COG: --";
|
||||
private String coordinatesText = "--";
|
||||
private String sogText = "--";
|
||||
private String cogText = "--";
|
||||
private String accuracyText = "--";
|
||||
|
||||
public CoordinatesDockWidget(Context context) {
|
||||
super(context);
|
||||
@@ -43,38 +47,51 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
||||
}
|
||||
|
||||
private void init() {
|
||||
// Инициализируем кисти
|
||||
backgroundPaint = new Paint();
|
||||
backgroundPaint.setColor(BACKGROUND_COLOR);
|
||||
backgroundPaint.setStyle(Paint.Style.FILL);
|
||||
backgroundPaint.setAntiAlias(true);
|
||||
|
||||
|
||||
labelPaint = new Paint();
|
||||
labelPaint.setColor(LABEL_COLOR);
|
||||
labelPaint.setTextSize(dp(11));
|
||||
labelPaint.setTypeface(Typeface.DEFAULT);
|
||||
labelPaint.setAntiAlias(true);
|
||||
labelPaint.setLetterSpacing(0.08f);
|
||||
|
||||
textPaint = new Paint();
|
||||
textPaint.setColor(TEXT_COLOR);
|
||||
textPaint.setTextSize(dp(14));
|
||||
textPaint.setTextSize(dp(16));
|
||||
textPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
textPaint.setAntiAlias(true);
|
||||
|
||||
|
||||
accentPaint = new Paint();
|
||||
accentPaint.setColor(ACCENT_COLOR);
|
||||
accentPaint.setTextSize(dp(14));
|
||||
accentPaint.setTextSize(dp(16));
|
||||
accentPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
accentPaint.setAntiAlias(true);
|
||||
|
||||
|
||||
warningPaint = new Paint();
|
||||
warningPaint.setColor(WARNING_COLOR);
|
||||
warningPaint.setTextSize(dp(14));
|
||||
warningPaint.setTextSize(dp(16));
|
||||
warningPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
warningPaint.setAntiAlias(true);
|
||||
|
||||
|
||||
errorPaint = new Paint();
|
||||
errorPaint.setColor(ERROR_COLOR);
|
||||
errorPaint.setTextSize(dp(14));
|
||||
errorPaint.setTextSize(dp(16));
|
||||
errorPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
errorPaint.setAntiAlias(true);
|
||||
|
||||
// Устанавливаем фон для видимости (как в CompassView)
|
||||
setBackgroundColor(android.graphics.Color.argb(200, 0, 0, 0));
|
||||
|
||||
dividerPaint = new Paint();
|
||||
dividerPaint.setColor(0x33FFFFFF);
|
||||
dividerPaint.setStrokeWidth(dp(1));
|
||||
dividerPaint.setAntiAlias(true);
|
||||
|
||||
// Фон самой view держим прозрачным — в dock/circle режиме фон
|
||||
// рисуем вручную в onDraw*, иначе в round-режиме виден чёрный
|
||||
// квадрат вокруг окружности.
|
||||
setBackgroundColor(android.graphics.Color.TRANSPARENT);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,125 +115,206 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
||||
*/
|
||||
private void updateDisplayText() {
|
||||
if (vessel == null) {
|
||||
coordinatesText = "Координаты: --";
|
||||
sogText = "SOG: --";
|
||||
cogText = "COG: --";
|
||||
coordinatesText = "--";
|
||||
sogText = "--";
|
||||
cogText = "--";
|
||||
accuracyText = "--";
|
||||
return;
|
||||
}
|
||||
|
||||
// Координаты
|
||||
if (vessel.getLatitude() != 0 && vessel.getLongitude() != 0) {
|
||||
coordinatesText = String.format("📍 %.6f, %.6f",
|
||||
vessel.getLatitude(), vessel.getLongitude());
|
||||
|
||||
if (vessel.getLatitude() != 0 || vessel.getLongitude() != 0) {
|
||||
coordinatesText = formatLatLon(vessel.getLatitude(), vessel.getLongitude());
|
||||
} else {
|
||||
coordinatesText = "📍 Координаты: --";
|
||||
coordinatesText = "нет фикса";
|
||||
}
|
||||
|
||||
// SOG (Speed Over Ground)
|
||||
if (vessel.getSpeed() > 0) {
|
||||
sogText = String.format("⚡ SOG: %.1f уз", vessel.getSpeed());
|
||||
|
||||
if (vessel.getSpeed() > 0.05) {
|
||||
sogText = String.format(java.util.Locale.US, "%.1f kn", vessel.getSpeed());
|
||||
} else {
|
||||
sogText = "⚡ SOG: --";
|
||||
sogText = "0.0 kn";
|
||||
}
|
||||
|
||||
// COG (Course Over Ground)
|
||||
if (vessel.getCourse() > 0) {
|
||||
cogText = String.format("🧭 COG: %.1f°", vessel.getCourse());
|
||||
|
||||
if (vessel.getCourse() > 0 || vessel.getSpeed() > 0.05) {
|
||||
cogText = String.format(java.util.Locale.US, "%.0f\u00B0", vessel.getCourse());
|
||||
} else {
|
||||
cogText = "🧭 COG: --";
|
||||
cogText = "---\u00B0";
|
||||
}
|
||||
|
||||
float acc = vessel.getAccuracy();
|
||||
if (acc > 0f) {
|
||||
accuracyText = String.format(java.util.Locale.US, "\u00B1%.1f m", acc);
|
||||
} else {
|
||||
accuracyText = "--";
|
||||
}
|
||||
}
|
||||
|
||||
private String formatLatLon(double lat, double lon) {
|
||||
char latHemi = lat >= 0 ? 'N' : 'S';
|
||||
char lonHemi = lon >= 0 ? 'E' : 'W';
|
||||
double absLat = Math.abs(lat);
|
||||
double absLon = Math.abs(lon);
|
||||
int latDeg = (int) absLat;
|
||||
double latMin = (absLat - latDeg) * 60.0;
|
||||
int lonDeg = (int) absLon;
|
||||
double lonMin = (absLon - lonDeg) * 60.0;
|
||||
return String.format(java.util.Locale.US,
|
||||
"%02d\u00B0%06.3f'%c %03d\u00B0%06.3f'%c",
|
||||
latDeg, latMin, latHemi, lonDeg, lonMin, lonHemi);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDrawDock(Canvas canvas) {
|
||||
int width = getWidth();
|
||||
int height = getHeight();
|
||||
|
||||
Log.d(TAG, "onDrawDock called, width=" + width + ", height=" + height);
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
Log.w(TAG, "Invalid dimensions: width=" + width + ", height=" + height);
|
||||
return;
|
||||
}
|
||||
|
||||
// Рисуем фон
|
||||
|
||||
// Фон рисуем на всю область виджета (уезжает под нав-бар/вырез),
|
||||
// а контент — в рамках паддингов от WindowInsets.
|
||||
canvas.drawRect(0, 0, width, height, backgroundPaint);
|
||||
|
||||
// Вычисляем позиции для текста
|
||||
float textSize = dp(14);
|
||||
float lineHeight = textSize * 1.2f;
|
||||
float startY = (height - (lineHeight * 3)) / 2 + textSize;
|
||||
|
||||
// Определяем цвета в зависимости от качества данных
|
||||
Paint coordinatesPaint = getCoordinatesPaint();
|
||||
Paint sogPaint = getSOGPaint();
|
||||
Paint cogPaint = getCOGPaint();
|
||||
|
||||
// Рисуем тестовый заголовок для проверки видимости
|
||||
Paint testPaint = new Paint();
|
||||
testPaint.setColor(android.graphics.Color.WHITE);
|
||||
testPaint.setTextSize(dp(16));
|
||||
testPaint.setTypeface(android.graphics.Typeface.DEFAULT_BOLD);
|
||||
testPaint.setAntiAlias(true);
|
||||
// canvas.drawText("КООРДИНАТЫ", dp(16), dp(20), testPaint);
|
||||
|
||||
// Рисуем текст
|
||||
canvas.drawText(coordinatesText, dp(16), startY, coordinatesPaint);
|
||||
canvas.drawText(sogText, dp(16), startY + lineHeight, sogPaint);
|
||||
canvas.drawText(cogText, dp(16), startY + lineHeight * 2, cogPaint);
|
||||
|
||||
// Рисуем разделительную линию сверху, если закреплен снизу
|
||||
|
||||
float left = getPaddingLeft();
|
||||
float top = getPaddingTop();
|
||||
float right = width - getPaddingRight();
|
||||
float bottom = height - getPaddingBottom();
|
||||
float contentW = Math.max(0f, right - left);
|
||||
float contentH = Math.max(0f, bottom - top);
|
||||
if (contentW <= 0 || contentH <= 0) return;
|
||||
|
||||
// Верхняя тонкая разделительная линия (виджет снизу): визуальная
|
||||
// граница между картой и панелью.
|
||||
if (!isDockTop()) {
|
||||
Paint linePaint = new Paint();
|
||||
linePaint.setColor(ACCENT_COLOR);
|
||||
linePaint.setStrokeWidth(dp(2));
|
||||
canvas.drawLine(0, 0, width, 0, linePaint);
|
||||
canvas.drawLine(left, top, right, top, dividerPaint);
|
||||
}
|
||||
|
||||
float padX = dp(16);
|
||||
float innerLeft = left + padX;
|
||||
float innerRight = right - padX;
|
||||
float innerTop = top + dp(8);
|
||||
float innerBottom = bottom - dp(8);
|
||||
|
||||
// Строка 1: координаты (с подписью "POSITION").
|
||||
Paint posPaint = getCoordinatesPaint();
|
||||
float labelH = labelPaint.getTextSize() * 1.1f;
|
||||
float valueH = posPaint.getTextSize() * 1.15f;
|
||||
|
||||
float y = innerTop + labelH;
|
||||
canvas.drawText("POSITION", innerLeft, y, labelPaint);
|
||||
y += valueH;
|
||||
canvas.drawText(coordinatesText, innerLeft, y, posPaint);
|
||||
|
||||
// Строка 2: SOG | COG | ACC в три колонки.
|
||||
float colTop = y + dp(10);
|
||||
float colW = (innerRight - innerLeft) / 3f;
|
||||
float colLabelY = colTop + labelH;
|
||||
float colValueY = colLabelY + valueH;
|
||||
|
||||
// SOG
|
||||
canvas.drawText("SOG", innerLeft, colLabelY, labelPaint);
|
||||
canvas.drawText(sogText, innerLeft, colValueY, getSOGPaint());
|
||||
|
||||
// COG
|
||||
float cogX = innerLeft + colW;
|
||||
canvas.drawText("COG", cogX, colLabelY, labelPaint);
|
||||
canvas.drawText(cogText, cogX, colValueY, getCOGPaint());
|
||||
|
||||
// ACC
|
||||
float accX = innerLeft + colW * 2f;
|
||||
canvas.drawText("ACC", accX, colLabelY, labelPaint);
|
||||
canvas.drawText(accuracyText, accX, colValueY, getAccuracyPaint());
|
||||
|
||||
if (colValueY > innerBottom) {
|
||||
// На всякий случай: если текст не помещается, оставляем только
|
||||
// первую строку (координаты).
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void onDrawCircle(Canvas canvas) {
|
||||
int width = getWidth();
|
||||
int height = getHeight();
|
||||
int centerX = width / 2;
|
||||
int centerY = height / 2;
|
||||
int radius = Math.min(width, height) / 2 - (int)dp(8);
|
||||
|
||||
// Рисуем фон
|
||||
int radius = Math.min(width, height) / 2 - (int) dp(4);
|
||||
|
||||
canvas.drawCircle(centerX, centerY, radius, backgroundPaint);
|
||||
|
||||
// Рисуем рамку
|
||||
|
||||
Paint borderPaint = new Paint();
|
||||
borderPaint.setColor(ACCENT_COLOR);
|
||||
borderPaint.setStyle(Paint.Style.STROKE);
|
||||
borderPaint.setStrokeWidth(dp(3));
|
||||
borderPaint.setStrokeWidth(dp(2));
|
||||
borderPaint.setAntiAlias(true);
|
||||
canvas.drawCircle(centerX, centerY, radius, borderPaint);
|
||||
|
||||
// Вычисляем позиции для текста в круге
|
||||
float textSize = dp(12);
|
||||
float lineHeight = textSize * 1.3f;
|
||||
float startY = centerY - lineHeight;
|
||||
|
||||
// Определяем цвета
|
||||
Paint coordinatesPaint = getCoordinatesPaint();
|
||||
|
||||
// Более компактная вёрстка: 4 строки (POS lat / POS lon / SOG·COG / ACC)
|
||||
Paint posPaint = getCoordinatesPaint();
|
||||
float smallLabel = dp(9);
|
||||
float smallValue = dp(11);
|
||||
float bigValue = dp(13);
|
||||
labelPaint.setTextSize(smallLabel);
|
||||
posPaint.setTextSize(smallValue);
|
||||
Paint sogPaint = getSOGPaint();
|
||||
Paint cogPaint = getCOGPaint();
|
||||
|
||||
// Центрируем текст
|
||||
Rect textBounds = new Rect();
|
||||
|
||||
// Координаты
|
||||
coordinatesPaint.getTextBounds(coordinatesText, 0, coordinatesText.length(), textBounds);
|
||||
canvas.drawText(coordinatesText, centerX - textBounds.width() / 2f, startY, coordinatesPaint);
|
||||
|
||||
// SOG
|
||||
sogPaint.getTextBounds(sogText, 0, sogText.length(), textBounds);
|
||||
canvas.drawText(sogText, centerX - textBounds.width() / 2f, startY + lineHeight, sogPaint);
|
||||
|
||||
// COG
|
||||
cogPaint.getTextBounds(cogText, 0, cogText.length(), textBounds);
|
||||
canvas.drawText(cogText, centerX - textBounds.width() / 2f, startY + lineHeight * 2, cogPaint);
|
||||
Paint accPaint = getAccuracyPaint();
|
||||
sogPaint.setTextSize(bigValue);
|
||||
cogPaint.setTextSize(bigValue);
|
||||
accPaint.setTextSize(smallValue);
|
||||
|
||||
String[] latLon = coordinatesText.split(" ", 2);
|
||||
String latLine = latLon.length > 0 ? latLon[0] : coordinatesText;
|
||||
String lonLine = latLon.length > 1 ? latLon[1] : "";
|
||||
|
||||
float lineGap = dp(2);
|
||||
float lineH = smallValue + lineGap;
|
||||
|
||||
// Считаем общую высоту блока для вертикального центрирования.
|
||||
float totalH = smallLabel + lineH + lineH // POSITION + 2 строки
|
||||
+ dp(6)
|
||||
+ smallLabel + bigValue + lineGap // SOG/COG label+value
|
||||
+ dp(4)
|
||||
+ smallLabel + smallValue; // ACC
|
||||
|
||||
float y = centerY - totalH / 2f + smallLabel;
|
||||
|
||||
drawCentered(canvas, "POSITION", centerX, y, labelPaint);
|
||||
y += lineH;
|
||||
drawCentered(canvas, latLine, centerX, y, posPaint);
|
||||
y += lineH;
|
||||
if (!lonLine.isEmpty()) {
|
||||
drawCentered(canvas, lonLine, centerX, y, posPaint);
|
||||
y += lineH;
|
||||
}
|
||||
y += dp(4);
|
||||
|
||||
// SOG / COG бок о бок.
|
||||
float colCenterL = centerX - radius * 0.45f;
|
||||
float colCenterR = centerX + radius * 0.45f;
|
||||
drawCentered(canvas, "SOG", colCenterL, y, labelPaint);
|
||||
drawCentered(canvas, "COG", colCenterR, y, labelPaint);
|
||||
y += bigValue + lineGap;
|
||||
drawCentered(canvas, sogText, colCenterL, y, sogPaint);
|
||||
drawCentered(canvas, cogText, colCenterR, y, cogPaint);
|
||||
y += dp(6);
|
||||
|
||||
drawCentered(canvas, "ACC", centerX, y, labelPaint);
|
||||
y += smallValue + lineGap;
|
||||
drawCentered(canvas, accuracyText, centerX, y, accPaint);
|
||||
|
||||
// Восстанавливаем типовые размеры для dock-режима.
|
||||
labelPaint.setTextSize(dp(11));
|
||||
textPaint.setTextSize(dp(16));
|
||||
accentPaint.setTextSize(dp(16));
|
||||
warningPaint.setTextSize(dp(16));
|
||||
errorPaint.setTextSize(dp(16));
|
||||
}
|
||||
|
||||
private void drawCentered(Canvas canvas, String text, float cx, float y, Paint p) {
|
||||
if (text == null) return;
|
||||
Rect b = new Rect();
|
||||
p.getTextBounds(text, 0, text.length(), b);
|
||||
canvas.drawText(text, cx - b.width() / 2f - b.left, y, p);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,11 +354,25 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
||||
* Определяет цвет для отображения COG
|
||||
*/
|
||||
private Paint getCOGPaint() {
|
||||
if (vessel == null || vessel.getCourse() <= 0) {
|
||||
return errorPaint;
|
||||
if (vessel == null) return errorPaint;
|
||||
// Курс может быть 0 при движении чётко на север — поэтому считаем
|
||||
// валидным любой курс при наличии скорости, а также любой курс > 0.
|
||||
if (vessel.getCourse() > 0 || vessel.getSpeed() > 0.05) {
|
||||
return textPaint;
|
||||
}
|
||||
|
||||
return accentPaint; // Если есть данные о курсе - зеленый
|
||||
return errorPaint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Определяет цвет для отображения точности (ACC, ±метры).
|
||||
*/
|
||||
private Paint getAccuracyPaint() {
|
||||
if (vessel == null) return errorPaint;
|
||||
float acc = vessel.getAccuracy();
|
||||
if (acc <= 0f) return errorPaint;
|
||||
if (acc <= 5f) return accentPaint;
|
||||
if (acc <= 20f) return warningPaint;
|
||||
return errorPaint;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<path
|
||||
android:pathData="M95,8.1l-12.08,-6.7 -35.92,80.7L15,115.1s11.69,-0.03 28,0c0,0 1,-10 11,-10 9,0 10,10 10,10 9.54,0.02 15.06,0 15,0l-5,-29L95,8.1Z"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00ff00"
|
||||
android:fillColor="#7BE435"
|
||||
android:strokeColor="#000"/>
|
||||
<path
|
||||
android:pathData="M53.5,115.6m-10.5,0a10.5,10.5 0,1 1,21 0a10.5,10.5 0,1 1,-21 0"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<!-- Satellite/positioning glyph: source from Android GPS -->
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2 C6.48,2 2,6.48 2,12 C2,17.52 6.48,22 12,22 C17.52,22 22,17.52 22,12 C22,6.48 17.52,2 12,2 Z M12,20 C7.58,20 4,16.42 4,12 C4,7.58 7.58,4 12,4 C16.42,4 20,7.58 20,12 C20,16.42 16.42,20 12,20 Z" />
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,7 L13.41,8.41 L9.41,12.41 L11,14 L15,10 L16.41,11.41 L12,15.83 L7.59,11.41 Z" />
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M11,1 L13,1 L13,3 L11,3 Z M11,21 L13,21 L13,23 L11,23 Z M1,11 L3,11 L3,13 L1,13 Z M21,11 L23,11 L23,13 L21,13 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<!-- Bluetooth glyph: source from ais_hub via BLE -->
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2 L12,22 L18,16 L8,8 M12,2 L18,8 L8,16 L12,22" />
|
||||
</vector>
|
||||
@@ -1,30 +1,117 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
<group android:scaleX="0.72527474"
|
||||
android:scaleY="0.72527474"
|
||||
android:translateX="130.34433"
|
||||
android:translateY="140.65935">
|
||||
<path
|
||||
android:pathData="M99.2,86.8h850.4v850.4h-850.4z"
|
||||
android:fillColor="#bcd542"/>
|
||||
<path
|
||||
android:pathData="M750.9,341.8l-6.2,12.3l7.3,1.4l-1.1,-13.8z"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M755.8,302.7c-2.2,8.2 -70.6,228.5 -72.1,233 -4.9,-1.2 -9.9,-2.5 -14.8,-3.7 -0.9,-0.2 -1.7,-0.4 -2.6,-0.7 -1.6,-0.4 -3.2,-0.8 -4.8,-1.2 -5.6,-1.5 -10.7,-1.8 -16.5,-1.8h-2.8c-12,0 -21.8,4.8 -32.1,10.5 -14.5,8 -29.4,12.9 -45.9,8.4 -11.9,-4.1 -18.7,-11.3 -25.3,-21.8 -1.9,-2.9 -3.9,-5.8 -5.9,-8.7 -0.7,-1 -1.4,-2 -2.1,-3.1 -14.7,-20.4 -36.9,-25.8 -59.8,-32.3 -8.4,-2.4 -23.6,-11.6 -24.2,-12 -13.3,-10.1 -19.4,-28.1 -25,-43.1 -10.8,-28.9 -27.3,-50.3 -55.1,-64.6 2.3,-2.5 30.3,-18.4 37.9,-25 0.7,-0.6 1.3,-1.1 2,-1.7 10.6,-9.6 16.7,-24.2 23.1,-36.7 9.5,-18.5 21.6,-34.2 41.9,-41.6 6.1,-1.7 12.2,-2.7 18.4,-3.7 19,-2.9 34.8,-9.5 48.6,-23.3 0.6,-0.6 1.1,-1.1 1.7,-1.7 5.7,-6.1 9.7,-13.8 13.3,-21.3 8.6,6.7 12.7,14.1 16.9,23.9 5.9,13.7 14.6,23.7 28.1,30.1 4.2,1.5 153.8,41.2 157.1,42.1Z"
|
||||
android:fillColor="#d5eb5a"/>
|
||||
<path
|
||||
android:pathData="M648.7,255.6v2c-7.7,-1.4 -15.3,-3.2 -22.9,-5.2 -1.3,-0.3 -2.6,-0.7 -3.9,-1 -15.3,-3.8 -28,-8.8 -36.5,-22.6 -1.8,-3.3 -3.2,-6.7 -4.7,-10.2 -2.7,-6.7 -6.3,-11.5 -11.1,-16.9 -1.3,-1.6 -2.6,-3.2 -3.9,-4.8 -0.6,-0.7 -1.1,-1.4 -1.7,-2.1 -4,-5.8 -4.2,-11.3 -3.4,-18.1 1.2,-5.6 2.8,-11.1 4.3,-16.6 1,-3.7 1.9,-7.4 2.7,-11.2 1.2,-5.3 2.5,-10.5 3.9,-15.7 1.8,-6.5 3.3,-13.1 4.7,-19.7 0.8,-3.7 1.6,-6.3 2.3,-7.8h0.3c28.3,0 56.6,0 84.9,-0.1h115.4c38.6,-0.1 71.8,9.9 99.7,37.2 0.7,0.6 1.3,1.2 2,1.9 5,4.7 9.8,10.1 13,16.1 -0.7,5.5 -3,10.6 -5.1,15.8 -0.4,1 -0.8,2 -1.2,3.1 -3.4,8.6 -7.3,16.9 -11.7,25.1 -0.5,0.9 -1,1.9 -1.5,2.8 -3.7,6.5 -8.3,11.8 -13.5,17.2 -0.6,0.7 -1.2,1.3 -1.9,1.9 -9.4,9 -20.3,12.4 -32.7,14.7 -24.2,4.4 -43.4,16.2 -57.5,36.4 -2.1,3.3 -4.1,6.6 -6,10 -4.3,-0.4 -8.1,-0.9 -12.1,-2.4 -5.2,-1.8 -10.6,-3.1 -16,-4.3 -1,-0.2 -1.9,-0.4 -2.9,-0.7 -2.3,-0.5 -4.7,-1.1 -7,-1.6"
|
||||
android:fillColor="#d5eb5a"/>
|
||||
<path
|
||||
android:pathData="M770.8,306.7c9.8,2.4 96.5,26 100.1,27.1 10,2.8 41.7,10.9 45.2,11.8 0.2,22.8 0,159.9 0,161.6 0.1,8.5 -0.3,14.2 -2,17 -1.7,1.8 -3.4,3.1 -5.5,4.5 -0.9,0.7 -1.7,1.5 -2.6,2.3 -2.8,2.3 -5.6,4.5 -8.4,6.7 -0.6,0.5 -1.1,0.9 -1.7,1.4 -13.8,11.1 -24.9,16.3 -33.3,15.6 -12.1,-2.1 -19.5,-13.7 -26.3,-22.8 -9.2,-12.2 -19.4,-20.8 -34.7,-24.2 -19.2,-2.3 -33.7,2.8 -49,14 -1.9,1.7 -3.6,3.4 -5.3,5.2 -9.2,9.5 -19.7,15.8 -33.1,16 -5.5,0 -10.3,-0.8 -15.6,-2.2 0.5,-7.4 69.3,-223.7 72,-233.9l0.2,-0.1Z"
|
||||
android:fillColor="#d4ea59"/>
|
||||
<path
|
||||
android:pathData="M578.8,105.7c28.3,0 198.2,-0.1 200.3,-0.1 38.6,-0.1 71.8,9.9 99.7,37.2 0.7,0.6 1.3,1.2 2,1.9 5,4.7 9.8,10.1 13,16.1 -0.7,5.5 -3,10.6 -5.1,15.8 -0.4,1 -0.8,2 -1.2,3.1 -3.4,8.6 -7.3,16.9 -11.7,25.1 -0.5,0.9 -1,1.9 -1.5,2.8 -3.7,6.5 -8.3,11.8 -13.5,17.2 -0.6,0.7 -1.2,1.3 -1.9,1.9 -9.4,9 -20.3,12.4 -32.7,14.7 -24.2,4.4 -43.4,16.2 -57.5,36.4 -2.1,3.3 -4.1,6.6 -6,10 -4.2,-0.4 -8,-0.9 -11.9,-2.3 -5.3,-1.9 -117.5,-30.7 -125.1,-32.7 -1.3,-0.3 -31.9,-9.8 -40.4,-23.6 -1.8,-3.3 -3.2,-6.7 -4.7,-10.2 -2.7,-6.7 -6.3,-11.5 -11.1,-16.9 -1.3,-1.6 -5,-6.2 -5.6,-6.9 -4,-5.8 -4.2,-11.3 -3.4,-18.1 1.2,-5.6 17.2,-69.5 17.9,-71l0.4,-0.4Z"
|
||||
android:fillColor="#d8ed5c"/>
|
||||
<path
|
||||
android:pathData="M338.8,105.7h227c-1.3,5.3 -5.2,20.9 -5.8,23.4 -2.1,8.4 -4.3,16.7 -6.7,25 -2.7,9.3 -5.2,18.7 -7.6,28.1 -5.7,21.5 -12.8,37.3 -32.5,49.8 -8.6,4.5 -17.8,6.9 -27.5,7.4 -14.5,1.5 -32.6,7.7 -42.9,18.4 -2.3,2.3 -3.9,3.7 -6.9,4.9 -6.3,-0.4 -132,-33.7 -133,-34 0.6,-5.4 34.9,-120 35.8,-122.8l0.1,-0.2Z"
|
||||
android:fillColor="#d8ed5d"/>
|
||||
<path
|
||||
android:pathData="M107.7,366.7c0,-18.4 -0.2,-111.3 -0.2,-115.2 -0.1,-18.8 0.1,-36.8 6.4,-54.8h-0.1c12.3,2.6 46.6,11.4 51.7,12.8 1,0.3 84.3,21.8 91.4,23.4 8.4,1.9 16.7,4.3 24.9,6.8 -0.7,6.8 -45.7,157 -51,175 -12.6,-1.7 -25.4,-15 -32.9,-24.8 -0.5,-0.7 -1.1,-1.5 -1.7,-2.2 -1.9,-2.6 -4.1,-4.8 -6.5,-7 -0.6,-0.6 -1.3,-1.2 -2,-1.9 -10.3,-9.6 -22.7,-16.6 -37,-17.1 -1.1,0 -2.2,-0.1 -3.3,-0.2 -13.5,-0.3 -26.7,1.4 -39.7,5.2"
|
||||
android:fillColor="#d6ec5b"/>
|
||||
<path
|
||||
android:pathData="M246,105.4c2.2,0 64.3,0 75.8,0.1 -0.6,2.6 -32.5,110.1 -34.9,117.9 -6,-0.6 -157.2,-39.2 -166.8,-41.9 4.9,-12.5 12.3,-23.9 21.7,-33.6 1.6,-1.6 3.1,-3.4 4.6,-5.3 3.1,-3.8 6.8,-6.6 10.7,-9.5 0.8,-0.6 1.5,-1.1 2.3,-1.7 12.1,-9 24.5,-16.2 38.9,-20.5 0.6,-0.2 1.3,-0.4 1.9,-0.6 15.5,-4.5 30.1,-5.2 46.1,-5.1l-0.3,0.2Z"
|
||||
android:fillColor="#d8ee5d"/>
|
||||
<path
|
||||
android:pathData="M297.8,243.7c0.9,0.2 1.7,0.4 2.6,0.7 6.4,1.6 115.7,30.5 116.8,30.7 1.9,0.5 3.8,1.1 5.6,1.7 -0.9,6.4 -4.2,11.9 -7,17.6l-3,6c-9.9,19.9 -22.2,32.6 -42.5,41.8 -18,8.2 -33.8,22.3 -46.6,37.4 -1.8,2 -3.6,3.9 -5.4,5.8 -0.7,0.7 -7.1,7.1 -9.7,9.7l-8.2,8.2c-14.6,13.9 -34.7,14.9 -53.6,14.5h-1.9c1.8,-6.7 52.5,-171.4 53,-174.1h-0.1Z"
|
||||
android:fillColor="#d6ec5b"/>
|
||||
<path
|
||||
android:pathData="M902.8,181.7h2c1.1,2.3 2.2,4.6 3.2,6.9 0.3,0.6 0.6,1.2 0.9,1.9 7.6,16.6 7.4,34.1 7.2,52 0,0.4 0,3 0,7.1 -0,18.8 0.1,68.9 0,80.2 -1.7,-0.3 -137,-36.6 -138.3,-37.9 10,-16.3 22.7,-29.7 41.9,-34.8 4.1,-0.9 8.3,-1.7 12.5,-2.5 19.7,-3.7 37.4,-13.1 49.4,-29.5 8.8,-13.6 15,-28.4 21.2,-43.2v-0.2Z"
|
||||
android:fillColor="#d6ec5b"/>
|
||||
<path
|
||||
android:pathData="M458.8,263.7l2,1c-0.7,0.5 -1.3,1.1 -2,1.6 -6.7,5.5 -13,11.3 -19,17.4 0,-4.2 1.9,-5.7 4.6,-8.7 4.4,-4.2 9.4,-7.7 14.4,-11.3h0Z"
|
||||
android:fillColor="#dbf15f"/>
|
||||
<path
|
||||
android:pathData="M553.8,206.7l2,1c-1.5,3.1 -3.2,6.1 -5,9 -0.3,-0.7 -0.7,-1.3 -1,-2 0.5,-1.3 1.2,-2.6 1.9,-4.1 0.7,-1.5 1.1,-2.2 1.2,-2.2 0.3,-0.6 0.6,-1.1 0.9,-1.7h0ZM548.8,216.7l1,2 -3,3c0.7,-1.6 1.3,-3.3 2,-5Z"
|
||||
android:fillColor="#dcf160"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
android:pathData="M747.7,356.9c-0.3,1.6 -0.7,3.4 -1.1,5.4"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M743.9,372.7c-0.7,2.4 -1.5,4.9 -2.4,7.5 -5.5,15.7 -10.6,22.8 -15.5,34.2 -4.1,9.5 -6,21.1 -8.5,33.9"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M716.5,453.6c-0.1,0.5 -0.2,1 -0.3,1.5 -0.3,1.6 -0.6,3 -0.8,3.9"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M531.8,588.6c7.4,5.6 12.6,13 14.5,22.2 0.2,2 0.3,3.9 0.2,5.8v1.5c-0.2,7.2 -2.4,13.2 -5.8,19.4 -0.6,1.1 -1.1,2.2 -1.7,3.3 -3.2,6 -7,11.6 -10.9,17.1 -1.1,1.5 -2.2,3.2 -3.2,4.7 -2.1,3 -4.1,6 -6.2,8.9 -0.2,0.3 -0.4,0.6 -0.6,0.9 -1,1.4 -2,2.7 -3.3,3.8 -1.8,-0.6 -2,-0.9 -3,-2.4 -0.3,-0.4 -0.6,-0.8 -0.8,-1.2 -0.3,-0.5 -0.6,-0.9 -0.9,-1.3 -0.7,-0.9 -1.3,-1.9 -2,-2.8 -0.4,-0.5 -0.7,-1 -1,-1.5 -1,-1.5 -2.1,-3 -3.2,-4.6 -16.1,-23 -23.4,-40.2 -21.8,-51.7 1.1,-6 3.6,-10.8 7.4,-15.6 0.3,-0.3 0.5,-0.7 0.7,-1 9.4,-12.3 29.2,-13.7 41.6,-5.7l-0.1,0.1Z"
|
||||
android:fillColor="#fefdea"/>
|
||||
<path
|
||||
android:pathData="M522,603.6c3.4,2.2 5.8,5.4 7,9.3 0.9,4.7 -0.1,9 -2.5,13 -2.2,2.9 -5.5,5 -9.1,5.6 -5,0.6 -8.7,-0.3 -12.9,-3.1 -3.2,-2.6 -4.9,-6.3 -5.4,-10.4 -0.2,-5 1.1,-8.5 4.4,-12.2 5.5,-5.1 12,-5.5 18.5,-2.2h0Z"
|
||||
android:fillColor="#bcd542"/>
|
||||
<path
|
||||
android:pathData="M219.5,612.1c-12,-6.1 -27.7,-7.1 -40.5,-3.8 -4.5,1.5 -8.7,3.6 -12.8,6.1 -5.1,3 -10.1,4 -16,3.8l-21,-42.9 -2.6,-10.8 137.6,-0.3c17,0 28.8,-3.5 42.2,-14.3 16,-12.7 34.9,-19 55.1,-20.7 0.8,0 19,-0.9 27.4,-0.9l-0.1,-0.1h17.6c0.6,0 1.9,0.6 3.9,1.7 -0.3,5 -6.2,14.6 -7,15.9 -0.4,0.6 -7.3,12.6 -10.3,18.1 -0.4,0.7 -18.3,36.7 -26,54.4 -4.5,0.2 -8,0 -12.1,-1.7 -0.9,-0.4 -7.1,-3.2 -9.3,-4.3 -12.4,-6.4 -27.8,-6.9 -41,-2.9 -5.5,1.8 -10.6,4.6 -15.7,7.5 -9.1,5.1 -19.5,5.7 -29.6,5.7h-2.8c-9.2,-0.1 -17.9,-1.7 -26.3,-5.4l-10.5,-5Z"
|
||||
android:fillColor="#fefce9"/>
|
||||
<path
|
||||
android:pathData="M220.2,454.2h16.6v33.5c3.9,-0.1 43.1,0 44.2,0 7.3,-0.2 12.5,0.2 18.1,5.2 0.5,0.4 1,0.9 1.5,1.3 10.2,8.9 17,25.6 21.4,38.2 -0.9,0.3 -1.9,0.6 -2.8,0.9 -10.6,3.6 -19,9.6 -27.9,16.2 -9.7,7.1 -19.6,9.5 -31.5,9.4 -1,0 -83,-0.1 -96.2,-0.2v-14.2h20.5c0,-3.1 0.1,-35.7 0,-36.7 0,-5.7 0.3,-9 4.2,-13.3 4.7,-4.5 8.5,-6.6 15.1,-6.8 2,0 4.1,-0.1 6.2,0 3.6,0.1 7.3,-0.2 11,0v-33.3l-0.1,-0.2Z"
|
||||
android:fillColor="#fdfce9"/>
|
||||
<path
|
||||
android:pathData="M214.8,622.5c4.2,1.8 14.2,5.6 15.1,5.9 19.4,7.3 42.4,7.4 61.7,-0.6 3.4,-1.6 6.7,-3.2 10,-5 10.9,-5.4 23.3,-6.6 34.9,-2.8 3.6,1.3 7.2,2.7 10.7,4.3 14,5.9 30.8,6.2 45,1.1 3.1,-1.3 11.8,-5.8 13.4,-6.3 0.6,5.3 0.3,9 -0.9,11.1 -9.7,9.1 -24,12.9 -37.1,12.8 -9.8,-0.3 -17.8,-3 -26.8,-6.9 -12.1,-5.1 -22.2,-6 -34.6,-1.1 -3.2,1.3 -6.4,2.8 -9.5,4.2 -4.8,2.1 -16.9,5.7 -17.9,6 -5.7,1.3 -18.6,1 -19.2,1 -9.7,0 -29.4,-3.8 -30,-4 -5.9,-1.8 -11.5,-4.1 -17.1,-6.8 -11.3,-5.4 -22.2,-4.7 -33.9,-0.6 -1.7,0.8 -3.5,1.6 -5.2,2.4 -12.7,6.1 -29.3,7.2 -42.7,2.5 -4.9,-1.9 -14.8,-8 -15.4,-8.2 -0.9,-0.6 -1.9,-1.6 -3.1,-3.1 -0.4,-3.2 -0.2,-6.2 0,-9.5 2.8,1.1 5.5,2.3 8.3,3.5 14.8,6.7 30.4,8.2 46.1,3.5 3.2,-1.3 6.2,-2.7 9.1,-4.3 12.3,-5.8 26.9,-4.7 39.1,0.6l0.2,0.2Z"
|
||||
android:fillColor="#fdfce9"/>
|
||||
<path
|
||||
android:pathData="M241.3,511.2c1.5,2.3 1,13.6 0,15.7 -3.1,3 -24.5,1.2 -25.7,0 -1.7,-2.8 -0.6,-14.8 0,-15.7 2.2,-1.5 24.8,-0.7 25.7,0Z"
|
||||
android:fillColor="#b1cc36"/>
|
||||
<path
|
||||
android:pathData="M285.4,511.2c1.5,2.3 1,13.6 0,15.7 -3.1,3 -24.5,1.2 -25.7,0 -1.7,-2.8 -0.6,-14.8 0,-15.7 2.2,-1.5 24.8,-0.7 25.7,0Z"
|
||||
android:fillColor="#b1cc36"/>
|
||||
<path
|
||||
android:pathData="M431.2,700.7l22.7,175.9h-40.8l-4.7,-43.2h-22.2l-6.7,43.2h-34.6l29.9,-175.9h56.3ZM405.5,806.6l-7.2,-78.3h-0.5l-7.2,78.3h14.8Z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M491.5,876.5v-175.9h41v175.9h-41Z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M635.3,763.7v-24.7c0,-8.6 -2.7,-13.6 -10.9,-13.6 -8.9,0 -10.9,4.9 -10.9,13.6 0,29.6 62.7,38.3 62.7,92.6 0,33.1 -17.8,48.4 -52.1,48.4 -26.2,0 -51.6,-8.9 -51.6,-39.3v-32.6h41v30.4c0,10.4 3.2,13.3 10.9,13.3 6.7,0 10.9,-3 10.9,-13.3 0,-39.8 -62.7,-40.5 -62.7,-97.3 0,-31.9 21,-44 52.6,-44 27.7,0 51.1,9.4 51.1,38.8v27.7h-41Z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M418.4,308.7l-5,1.2 -4.7,6.8s2.7,1.8 2.7,1.8c0,0 3,2 3,2 1.6,-2.3 3.2,-4.6 4.7,-6.8l-0.6,-5.1Z"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M410.6,320.6c-0.5,0.6 -1,1.3 -1.5,2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M405.9,326.2c-2.1,2.2 -4.7,4.7 -7.8,7.1 -10,7.9 -15.4,7.7 -29.4,16.2 -12.5,7.6 -18.7,11.4 -20.1,18 -1.3,5.9 1.5,10.9 -2,18.4 -0.5,1.1 -1.1,2.2 -1.7,3.1"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M343.4,391c-0.6,0.7 -1.2,1.3 -1.7,1.8"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="1024dp"
|
||||
android:height="1024dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
<path
|
||||
android:pathData="M99.2,86.8h850.4v850.4h-850.4z"
|
||||
android:fillColor="#bcd542"/>
|
||||
<path
|
||||
android:pathData="M750.9,341.8l-6.2,12.3l7.3,1.4l-1.1,-13.8z"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M755.8,302.7c-2.2,8.2 -70.6,228.5 -72.1,233 -4.9,-1.2 -9.9,-2.5 -14.8,-3.7 -0.9,-0.2 -1.7,-0.4 -2.6,-0.7 -1.6,-0.4 -3.2,-0.8 -4.8,-1.2 -5.6,-1.5 -10.7,-1.8 -16.5,-1.8h-2.8c-12,0 -21.8,4.8 -32.1,10.5 -14.5,8 -29.4,12.9 -45.9,8.4 -11.9,-4.1 -18.7,-11.3 -25.3,-21.8 -1.9,-2.9 -3.9,-5.8 -5.9,-8.7 -0.7,-1 -1.4,-2 -2.1,-3.1 -14.7,-20.4 -36.9,-25.8 -59.8,-32.3 -8.4,-2.4 -23.6,-11.6 -24.2,-12 -13.3,-10.1 -19.4,-28.1 -25,-43.1 -10.8,-28.9 -27.3,-50.3 -55.1,-64.6 2.3,-2.5 30.3,-18.4 37.9,-25 0.7,-0.6 1.3,-1.1 2,-1.7 10.6,-9.6 16.7,-24.2 23.1,-36.7 9.5,-18.5 21.6,-34.2 41.9,-41.6 6.1,-1.7 12.2,-2.7 18.4,-3.7 19,-2.9 34.8,-9.5 48.6,-23.3 0.6,-0.6 1.1,-1.1 1.7,-1.7 5.7,-6.1 9.7,-13.8 13.3,-21.3 8.6,6.7 12.7,14.1 16.9,23.9 5.9,13.7 14.6,23.7 28.1,30.1 4.2,1.5 153.8,41.2 157.1,42.1Z"
|
||||
android:fillColor="#d5eb5a"/>
|
||||
<path
|
||||
android:pathData="M648.7,255.6v2c-7.7,-1.4 -15.3,-3.2 -22.9,-5.2 -1.3,-0.3 -2.6,-0.7 -3.9,-1 -15.3,-3.8 -28,-8.8 -36.5,-22.6 -1.8,-3.3 -3.2,-6.7 -4.7,-10.2 -2.7,-6.7 -6.3,-11.5 -11.1,-16.9 -1.3,-1.6 -2.6,-3.2 -3.9,-4.8 -0.6,-0.7 -1.1,-1.4 -1.7,-2.1 -4,-5.8 -4.2,-11.3 -3.4,-18.1 1.2,-5.6 2.8,-11.1 4.3,-16.6 1,-3.7 1.9,-7.4 2.7,-11.2 1.2,-5.3 2.5,-10.5 3.9,-15.7 1.8,-6.5 3.3,-13.1 4.7,-19.7 0.8,-3.7 1.6,-6.3 2.3,-7.8h0.3c28.3,0 56.6,0 84.9,-0.1h115.4c38.6,-0.1 71.8,9.9 99.7,37.2 0.7,0.6 1.3,1.2 2,1.9 5,4.7 9.8,10.1 13,16.1 -0.7,5.5 -3,10.6 -5.1,15.8 -0.4,1 -0.8,2 -1.2,3.1 -3.4,8.6 -7.3,16.9 -11.7,25.1 -0.5,0.9 -1,1.9 -1.5,2.8 -3.7,6.5 -8.3,11.8 -13.5,17.2 -0.6,0.7 -1.2,1.3 -1.9,1.9 -9.4,9 -20.3,12.4 -32.7,14.7 -24.2,4.4 -43.4,16.2 -57.5,36.4 -2.1,3.3 -4.1,6.6 -6,10 -4.3,-0.4 -8.1,-0.9 -12.1,-2.4 -5.2,-1.8 -10.6,-3.1 -16,-4.3 -1,-0.2 -1.9,-0.4 -2.9,-0.7 -2.3,-0.5 -4.7,-1.1 -7,-1.6"
|
||||
android:fillColor="#d5eb5a"/>
|
||||
<path
|
||||
android:pathData="M770.8,306.7c9.8,2.4 96.5,26 100.1,27.1 10,2.8 41.7,10.9 45.2,11.8 0.2,22.8 0,159.9 0,161.6 0.1,8.5 -0.3,14.2 -2,17 -1.7,1.8 -3.4,3.1 -5.5,4.5 -0.9,0.7 -1.7,1.5 -2.6,2.3 -2.8,2.3 -5.6,4.5 -8.4,6.7 -0.6,0.5 -1.1,0.9 -1.7,1.4 -13.8,11.1 -24.9,16.3 -33.3,15.6 -12.1,-2.1 -19.5,-13.7 -26.3,-22.8 -9.2,-12.2 -19.4,-20.8 -34.7,-24.2 -19.2,-2.3 -33.7,2.8 -49,14 -1.9,1.7 -3.6,3.4 -5.3,5.2 -9.2,9.5 -19.7,15.8 -33.1,16 -5.5,0 -10.3,-0.8 -15.6,-2.2 0.5,-7.4 69.3,-223.7 72,-233.9l0.2,-0.1Z"
|
||||
android:fillColor="#d4ea59"/>
|
||||
<path
|
||||
android:pathData="M578.8,105.7c28.3,0 198.2,-0.1 200.3,-0.1 38.6,-0.1 71.8,9.9 99.7,37.2 0.7,0.6 1.3,1.2 2,1.9 5,4.7 9.8,10.1 13,16.1 -0.7,5.5 -3,10.6 -5.1,15.8 -0.4,1 -0.8,2 -1.2,3.1 -3.4,8.6 -7.3,16.9 -11.7,25.1 -0.5,0.9 -1,1.9 -1.5,2.8 -3.7,6.5 -8.3,11.8 -13.5,17.2 -0.6,0.7 -1.2,1.3 -1.9,1.9 -9.4,9 -20.3,12.4 -32.7,14.7 -24.2,4.4 -43.4,16.2 -57.5,36.4 -2.1,3.3 -4.1,6.6 -6,10 -4.2,-0.4 -8,-0.9 -11.9,-2.3 -5.3,-1.9 -117.5,-30.7 -125.1,-32.7 -1.3,-0.3 -31.9,-9.8 -40.4,-23.6 -1.8,-3.3 -3.2,-6.7 -4.7,-10.2 -2.7,-6.7 -6.3,-11.5 -11.1,-16.9 -1.3,-1.6 -5,-6.2 -5.6,-6.9 -4,-5.8 -4.2,-11.3 -3.4,-18.1 1.2,-5.6 17.2,-69.5 17.9,-71l0.4,-0.4Z"
|
||||
android:fillColor="#d8ed5c"/>
|
||||
<path
|
||||
android:pathData="M338.8,105.7h227c-1.3,5.3 -5.2,20.9 -5.8,23.4 -2.1,8.4 -4.3,16.7 -6.7,25 -2.7,9.3 -5.2,18.7 -7.6,28.1 -5.7,21.5 -12.8,37.3 -32.5,49.8 -8.6,4.5 -17.8,6.9 -27.5,7.4 -14.5,1.5 -32.6,7.7 -42.9,18.4 -2.3,2.3 -3.9,3.7 -6.9,4.9 -6.3,-0.4 -132,-33.7 -133,-34 0.6,-5.4 34.9,-120 35.8,-122.8l0.1,-0.2Z"
|
||||
android:fillColor="#d8ed5d"/>
|
||||
<path
|
||||
android:pathData="M107.7,366.7c0,-18.4 -0.2,-111.3 -0.2,-115.2 -0.1,-18.8 0.1,-36.8 6.4,-54.8h-0.1c12.3,2.6 46.6,11.4 51.7,12.8 1,0.3 84.3,21.8 91.4,23.4 8.4,1.9 16.7,4.3 24.9,6.8 -0.7,6.8 -45.7,157 -51,175 -12.6,-1.7 -25.4,-15 -32.9,-24.8 -0.5,-0.7 -1.1,-1.5 -1.7,-2.2 -1.9,-2.6 -4.1,-4.8 -6.5,-7 -0.6,-0.6 -1.3,-1.2 -2,-1.9 -10.3,-9.6 -22.7,-16.6 -37,-17.1 -1.1,0 -2.2,-0.1 -3.3,-0.2 -13.5,-0.3 -26.7,1.4 -39.7,5.2"
|
||||
android:fillColor="#d6ec5b"/>
|
||||
<path
|
||||
android:pathData="M246,105.4c2.2,0 64.3,0 75.8,0.1 -0.6,2.6 -32.5,110.1 -34.9,117.9 -6,-0.6 -157.2,-39.2 -166.8,-41.9 4.9,-12.5 12.3,-23.9 21.7,-33.6 1.6,-1.6 3.1,-3.4 4.6,-5.3 3.1,-3.8 6.8,-6.6 10.7,-9.5 0.8,-0.6 1.5,-1.1 2.3,-1.7 12.1,-9 24.5,-16.2 38.9,-20.5 0.6,-0.2 1.3,-0.4 1.9,-0.6 15.5,-4.5 30.1,-5.2 46.1,-5.1l-0.3,0.2Z"
|
||||
android:fillColor="#d8ee5d"/>
|
||||
<path
|
||||
android:pathData="M297.8,243.7c0.9,0.2 1.7,0.4 2.6,0.7 6.4,1.6 115.7,30.5 116.8,30.7 1.9,0.5 3.8,1.1 5.6,1.7 -0.9,6.4 -4.2,11.9 -7,17.6l-3,6c-9.9,19.9 -22.2,32.6 -42.5,41.8 -18,8.2 -33.8,22.3 -46.6,37.4 -1.8,2 -3.6,3.9 -5.4,5.8 -0.7,0.7 -7.1,7.1 -9.7,9.7l-8.2,8.2c-14.6,13.9 -34.7,14.9 -53.6,14.5h-1.9c1.8,-6.7 52.5,-171.4 53,-174.1h-0.1Z"
|
||||
android:fillColor="#d6ec5b"/>
|
||||
<path
|
||||
android:pathData="M902.8,181.7h2c1.1,2.3 2.2,4.6 3.2,6.9 0.3,0.6 0.6,1.2 0.9,1.9 7.6,16.6 7.4,34.1 7.2,52 0,0.4 0,3 0,7.1 -0,18.8 0.1,68.9 0,80.2 -1.7,-0.3 -137,-36.6 -138.3,-37.9 10,-16.3 22.7,-29.7 41.9,-34.8 4.1,-0.9 8.3,-1.7 12.5,-2.5 19.7,-3.7 37.4,-13.1 49.4,-29.5 8.8,-13.6 15,-28.4 21.2,-43.2v-0.2Z"
|
||||
android:fillColor="#d6ec5b"/>
|
||||
<path
|
||||
android:pathData="M458.8,263.7l2,1c-0.7,0.5 -1.3,1.1 -2,1.6 -6.7,5.5 -13,11.3 -19,17.4 0,-4.2 1.9,-5.7 4.6,-8.7 4.4,-4.2 9.4,-7.7 14.4,-11.3h0Z"
|
||||
android:fillColor="#dbf15f"/>
|
||||
<path
|
||||
android:pathData="M553.8,206.7l2,1c-1.5,3.1 -3.2,6.1 -5,9 -0.3,-0.7 -0.7,-1.3 -1,-2 0.5,-1.3 1.2,-2.6 1.9,-4.1 0.7,-1.5 1.1,-2.2 1.2,-2.2 0.3,-0.6 0.6,-1.1 0.9,-1.7h0ZM548.8,216.7l1,2 -3,3c0.7,-1.6 1.3,-3.3 2,-5Z"
|
||||
android:fillColor="#dcf160"/>
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M747.7,356.9c-0.3,1.6 -0.7,3.4 -1.1,5.4"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M743.9,372.7c-0.7,2.4 -1.5,4.9 -2.4,7.5 -5.5,15.7 -10.6,22.8 -15.5,34.2 -4.1,9.5 -6,21.1 -8.5,33.9"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M716.5,453.6c-0.1,0.5 -0.2,1 -0.3,1.5 -0.3,1.6 -0.6,3 -0.8,3.9"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M531.8,588.6c7.4,5.6 12.6,13 14.5,22.2 0.2,2 0.3,3.9 0.2,5.8v1.5c-0.2,7.2 -2.4,13.2 -5.8,19.4 -0.6,1.1 -1.1,2.2 -1.7,3.3 -3.2,6 -7,11.6 -10.9,17.1 -1.1,1.5 -2.2,3.2 -3.2,4.7 -2.1,3 -4.1,6 -6.2,8.9 -0.2,0.3 -0.4,0.6 -0.6,0.9 -1,1.4 -2,2.7 -3.3,3.8 -1.8,-0.6 -2,-0.9 -3,-2.4 -0.3,-0.4 -0.6,-0.8 -0.8,-1.2 -0.3,-0.5 -0.6,-0.9 -0.9,-1.3 -0.7,-0.9 -1.3,-1.9 -2,-2.8 -0.4,-0.5 -0.7,-1 -1,-1.5 -1,-1.5 -2.1,-3 -3.2,-4.6 -16.1,-23 -23.4,-40.2 -21.8,-51.7 1.1,-6 3.6,-10.8 7.4,-15.6 0.3,-0.3 0.5,-0.7 0.7,-1 9.4,-12.3 29.2,-13.7 41.6,-5.7l-0.1,0.1Z"
|
||||
android:fillColor="#fefdea"/>
|
||||
<path
|
||||
android:pathData="M522,603.6c3.4,2.2 5.8,5.4 7,9.3 0.9,4.7 -0.1,9 -2.5,13 -2.2,2.9 -5.5,5 -9.1,5.6 -5,0.6 -8.7,-0.3 -12.9,-3.1 -3.2,-2.6 -4.9,-6.3 -5.4,-10.4 -0.2,-5 1.1,-8.5 4.4,-12.2 5.5,-5.1 12,-5.5 18.5,-2.2h0Z"
|
||||
android:fillColor="#bcd542"/>
|
||||
<path
|
||||
android:pathData="M219.5,612.1c-12,-6.1 -27.7,-7.1 -40.5,-3.8 -4.5,1.5 -8.7,3.6 -12.8,6.1 -5.1,3 -10.1,4 -16,3.8l-21,-42.9 -2.6,-10.8 137.6,-0.3c17,0 28.8,-3.5 42.2,-14.3 16,-12.7 34.9,-19 55.1,-20.7 0.8,0 19,-0.9 27.4,-0.9l-0.1,-0.1h17.6c0.6,0 1.9,0.6 3.9,1.7 -0.3,5 -6.2,14.6 -7,15.9 -0.4,0.6 -7.3,12.6 -10.3,18.1 -0.4,0.7 -18.3,36.7 -26,54.4 -4.5,0.2 -8,0 -12.1,-1.7 -0.9,-0.4 -7.1,-3.2 -9.3,-4.3 -12.4,-6.4 -27.8,-6.9 -41,-2.9 -5.5,1.8 -10.6,4.6 -15.7,7.5 -9.1,5.1 -19.5,5.7 -29.6,5.7h-2.8c-9.2,-0.1 -17.9,-1.7 -26.3,-5.4l-10.5,-5Z"
|
||||
android:fillColor="#fefce9"/>
|
||||
<path
|
||||
android:pathData="M220.2,454.2h16.6v33.5c3.9,-0.1 43.1,0 44.2,0 7.3,-0.2 12.5,0.2 18.1,5.2 0.5,0.4 1,0.9 1.5,1.3 10.2,8.9 17,25.6 21.4,38.2 -0.9,0.3 -1.9,0.6 -2.8,0.9 -10.6,3.6 -19,9.6 -27.9,16.2 -9.7,7.1 -19.6,9.5 -31.5,9.4 -1,0 -83,-0.1 -96.2,-0.2v-14.2h20.5c0,-3.1 0.1,-35.7 0,-36.7 0,-5.7 0.3,-9 4.2,-13.3 4.7,-4.5 8.5,-6.6 15.1,-6.8 2,0 4.1,-0.1 6.2,0 3.6,0.1 7.3,-0.2 11,0v-33.3l-0.1,-0.2Z"
|
||||
android:fillColor="#fdfce9"/>
|
||||
<path
|
||||
android:pathData="M214.8,622.5c4.2,1.8 14.2,5.6 15.1,5.9 19.4,7.3 42.4,7.4 61.7,-0.6 3.4,-1.6 6.7,-3.2 10,-5 10.9,-5.4 23.3,-6.6 34.9,-2.8 3.6,1.3 7.2,2.7 10.7,4.3 14,5.9 30.8,6.2 45,1.1 3.1,-1.3 11.8,-5.8 13.4,-6.3 0.6,5.3 0.3,9 -0.9,11.1 -9.7,9.1 -24,12.9 -37.1,12.8 -9.8,-0.3 -17.8,-3 -26.8,-6.9 -12.1,-5.1 -22.2,-6 -34.6,-1.1 -3.2,1.3 -6.4,2.8 -9.5,4.2 -4.8,2.1 -16.9,5.7 -17.9,6 -5.7,1.3 -18.6,1 -19.2,1 -9.7,0 -29.4,-3.8 -30,-4 -5.9,-1.8 -11.5,-4.1 -17.1,-6.8 -11.3,-5.4 -22.2,-4.7 -33.9,-0.6 -1.7,0.8 -3.5,1.6 -5.2,2.4 -12.7,6.1 -29.3,7.2 -42.7,2.5 -4.9,-1.9 -14.8,-8 -15.4,-8.2 -0.9,-0.6 -1.9,-1.6 -3.1,-3.1 -0.4,-3.2 -0.2,-6.2 0,-9.5 2.8,1.1 5.5,2.3 8.3,3.5 14.8,6.7 30.4,8.2 46.1,3.5 3.2,-1.3 6.2,-2.7 9.1,-4.3 12.3,-5.8 26.9,-4.7 39.1,0.6l0.2,0.2Z"
|
||||
android:fillColor="#fdfce9"/>
|
||||
<path
|
||||
android:pathData="M241.3,511.2c1.5,2.3 1,13.6 0,15.7 -3.1,3 -24.5,1.2 -25.7,0 -1.7,-2.8 -0.6,-14.8 0,-15.7 2.2,-1.5 24.8,-0.7 25.7,0Z"
|
||||
android:fillColor="#b1cc36"/>
|
||||
<path
|
||||
android:pathData="M285.4,511.2c1.5,2.3 1,13.6 0,15.7 -3.1,3 -24.5,1.2 -25.7,0 -1.7,-2.8 -0.6,-14.8 0,-15.7 2.2,-1.5 24.8,-0.7 25.7,0Z"
|
||||
android:fillColor="#b1cc36"/>
|
||||
<path
|
||||
android:pathData="M431.2,700.7l22.7,175.9h-40.8l-4.7,-43.2h-22.2l-6.7,43.2h-34.6l29.9,-175.9h56.3ZM405.5,806.6l-7.2,-78.3h-0.5l-7.2,78.3h14.8Z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M491.5,876.5v-175.9h41v175.9h-41Z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M635.3,763.7v-24.7c0,-8.6 -2.7,-13.6 -10.9,-13.6 -8.9,0 -10.9,4.9 -10.9,13.6 0,29.6 62.7,38.3 62.7,92.6 0,33.1 -17.8,48.4 -52.1,48.4 -26.2,0 -51.6,-8.9 -51.6,-39.3v-32.6h41v30.4c0,10.4 3.2,13.3 10.9,13.3 6.7,0 10.9,-3 10.9,-13.3 0,-39.8 -62.7,-40.5 -62.7,-97.3 0,-31.9 21,-44 52.6,-44 27.7,0 51.1,9.4 51.1,38.8v27.7h-41Z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M418.4,308.7l-5,1.2 -4.7,6.8s2.7,1.8 2.7,1.8c0,0 3,2 3,2 1.6,-2.3 3.2,-4.6 4.7,-6.8l-0.6,-5.1Z"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M410.6,320.6c-0.5,0.6 -1,1.3 -1.5,2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M405.9,326.2c-2.1,2.2 -4.7,4.7 -7.8,7.1 -10,7.9 -15.4,7.7 -29.4,16.2 -12.5,7.6 -18.7,11.4 -20.1,18 -1.3,5.9 1.5,10.9 -2,18.4 -0.5,1.1 -1.1,2.2 -1.7,3.1"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M343.4,391c-0.6,0.7 -1.2,1.3 -1.7,1.8"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#fff"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,237 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔌 Интерфейсы: UDP и BLE"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/black"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<!-- UDP -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📡 UDP"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="UDP Порт"
|
||||
app:helperText="Порт для прослушивания AIS данных">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/et_udp_port"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:text="10110" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switch_udp_enabled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Включить UDP слушатель"
|
||||
android:textSize="16sp"
|
||||
android:checked="true" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- BLE -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📶 BLE"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switch_ble_enabled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Включить BLE источник NMEA"
|
||||
android:textSize="16sp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="MAC адрес BLE устройства"
|
||||
app:helperText="Например: 01:23:45:67:89:AB">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/et_ble_mac"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:text="" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_ble_scan"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Сканировать BLE"
|
||||
style="@style/Widget.Material3.Button" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_ble_stop_scan"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Стоп"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rv_ble_devices"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="200dp"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- BLE UDP Bridge -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔁 BLE UDP Bridge"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switch_ble_udp_bridge_enabled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Включить UDP-bridge (пересылать NMEA)"
|
||||
android:textSize="16sp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="UDP Host (назначение)">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/et_ble_udp_host"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:text="255.255.255.255" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="UDP Port (назначение)">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/et_ble_udp_port"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:text="10110" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_cancel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Отмена"
|
||||
android:layout_marginEnd="8dp"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_save"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Сохранить"
|
||||
style="@style/Widget.Material3.Button" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -1,8 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/main_root"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="false"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<!-- Карта -->
|
||||
@@ -11,20 +13,41 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<!-- Панель управления -->
|
||||
<!-- android:layout_below="@id/compass_view"-->
|
||||
<!-- Компас -->
|
||||
<com.grigowashere.aismap.view.CompassView
|
||||
android:id="@+id/compass_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp"
|
||||
android:layout_alignParentTop="true"
|
||||
android:layout_marginLeft="0dp"
|
||||
android:layout_marginTop="0dp"
|
||||
android:layout_marginRight="0dp"
|
||||
android:layout_marginBottom="0dp" />
|
||||
|
||||
<!-- Виджет координат: нижний inset задаётся в MainActivity (system bar) -->
|
||||
<com.grigowashere.aismap.view.CoordinatesDockWidget
|
||||
android:id="@+id/coordinates_widget"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_marginLeft="0dp"
|
||||
android:layout_marginTop="0dp"
|
||||
android:layout_marginRight="0dp"
|
||||
android:layout_marginBottom="0dp"
|
||||
android:elevation="2dp" />
|
||||
|
||||
<!-- Панель управления (после координат в Z-order — не перекрывается снизу) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/control_panel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp"
|
||||
android:gravity="end"
|
||||
android:elevation="4dp">
|
||||
android:elevation="10dp">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_center_vessel"
|
||||
@@ -59,6 +82,17 @@
|
||||
android:scaleType="fitCenter"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_gps_source"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/button_background"
|
||||
android:src="@drawable/ic_gps_source_hub"
|
||||
android:contentDescription="Источник координат"
|
||||
android:padding="8dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_settings"
|
||||
android:layout_width="40dp"
|
||||
@@ -100,30 +134,35 @@
|
||||
android:textColor="@android:color/white"
|
||||
android:layout_marginTop="4dp"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_ble_rssi"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="BLE RSSI: --"
|
||||
android:textSize="11sp"
|
||||
android:textColor="@android:color/white"
|
||||
android:layout_marginTop="4dp"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_ble_batt"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="BLE Batt: --"
|
||||
android:textSize="11sp"
|
||||
android:textColor="@android:color/white"
|
||||
android:layout_marginTop="2dp"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_fps"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="FPS: --"
|
||||
android:textSize="11sp"
|
||||
android:textColor="@android:color/white"
|
||||
android:layout_marginTop="4dp"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Компас -->
|
||||
<com.grigowashere.aismap.view.CompassView
|
||||
android:id="@+id/compass_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp"
|
||||
android:layout_alignParentTop="true"
|
||||
android:layout_marginLeft="0dp"
|
||||
android:layout_marginTop="0dp"
|
||||
android:layout_marginRight="0dp"
|
||||
android:layout_marginBottom="0dp" />
|
||||
|
||||
<!-- Виджет координат -->
|
||||
<com.grigowashere.aismap.view.CoordinatesDockWidget
|
||||
android:id="@+id/coordinates_widget"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_marginLeft="0dp"
|
||||
android:layout_marginTop="0dp"
|
||||
android:layout_marginRight="0dp"
|
||||
android:layout_marginBottom="0dp" />
|
||||
|
||||
<!-- Простая информационная панель
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<!-- UDP Настройки -->
|
||||
<!-- Интерфейсы (UDP/BLE) -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -38,36 +38,36 @@
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📡 UDP Настройки"
|
||||
android:text="🔌 Интерфейсы"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/til_open_interfaces"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="UDP Порт"
|
||||
app:helperText="Порт для прослушивания AIS данных">
|
||||
android:hint="Интерфейсы (UDP / BLE)"
|
||||
app:helperText="Перейти к настройкам UDP, BLE и UDP-bridge"
|
||||
app:endIconMode="custom"
|
||||
|
||||
app:endIconContentDescription="Открыть">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/et_udp_port"
|
||||
android:id="@+id/et_open_interfaces"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:text="10110" />
|
||||
android:focusable="false"
|
||||
android:focusableInTouchMode="false"
|
||||
android:clickable="true"
|
||||
android:cursorVisible="false"
|
||||
android:inputType="none"
|
||||
android:text="Открыть настройки интерфейсов (UDP / BLE)" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switch_udp_enabled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Включить UDP слушатель"
|
||||
android:textSize="16sp"
|
||||
android:checked="true" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -209,7 +209,7 @@
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Приоритеты данных -->
|
||||
<!-- Источник координат (GPS Source) -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -226,12 +226,103 @@
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📊 Приоритеты данных"
|
||||
android:text="📡 Источник координат"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Откуда приложение берёт позицию собственного судна."
|
||||
android:textSize="13sp"
|
||||
android:textColor="@android:color/darker_gray"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/radio_group_gps_source"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/radio_gps_source_hub"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="AIS Hub (BLE)"
|
||||
android:textSize="14sp"
|
||||
android:checked="true" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Позиция и AIS-цели приходят из внешнего AIS Hub по BLE."
|
||||
android:textSize="12sp"
|
||||
android:textColor="@android:color/darker_gray"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:layout_marginStart="16dp" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/radio_gps_source_android"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Android GPS"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Встроенный GPS устройства (+опциональный внешний NMEA)."
|
||||
android:textSize="12sp"
|
||||
android:textColor="@android:color/darker_gray"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:layout_marginStart="16dp" />
|
||||
|
||||
</RadioGroup>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Расширенные: NMEA/UDP источники (скрыты по умолчанию) -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switch_show_advanced_nmea"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📊 Расширенные NMEA-источники"
|
||||
android:textSize="16sp"
|
||||
android:checked="false" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Открыть старые настройки Android NMEA / UDP NMEA / режимы данных. Нужны, только если вы работаете без AIS Hub."
|
||||
android:textSize="12sp"
|
||||
android:textColor="@android:color/darker_gray"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/ll_advanced_nmea_section"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -342,6 +433,8 @@
|
||||
|
||||
</RadioGroup>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -589,6 +682,58 @@
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Морские знаки -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⚓ Морские знаки OpenSeaMap"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Отображать морские знаки (буи, маяки, навигационные знаки) поверх карты."
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/darker_gray"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switch_seamarks_enabled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Показывать морские знаки"
|
||||
android:textSize="16sp"
|
||||
android:checked="false"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="💡 Источник: OpenSeaMap.org - открытая база данных морских знаков"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@android:color/darker_gray"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Кнопки -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@android:id/text1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textStyle="bold"
|
||||
android:textSize="16sp"
|
||||
android:text="Device name" />
|
||||
|
||||
<TextView
|
||||
android:id="@android:id/text2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/darker_gray"
|
||||
android:text="MAC" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text3"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12sp"
|
||||
android:textColor="@android:color/darker_gray"
|
||||
android:text="RSSI: -60" />
|
||||
</LinearLayout>
|
||||
@@ -38,4 +38,10 @@
|
||||
android:icon="@android:drawable/ic_menu_view"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/menu_seamarks"
|
||||
android:title="Морские знаки"
|
||||
android:icon="@android:drawable/ic_menu_mapmode"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
</menu>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#BBD341</color>
|
||||
</resources>
|
||||
@@ -6,4 +6,16 @@
|
||||
</style>
|
||||
|
||||
<style name="Theme.AISMap" parent="Base.Theme.AISMap" />
|
||||
|
||||
<!-- Главный экран: edge-to-edge + рисуем под брови камеры. Паддинги
|
||||
для компаса, координатного виджета и панели кнопок задаёт
|
||||
MainActivity через WindowInsets-листенер. -->
|
||||
<style name="Theme.AISMap.Map" parent="Theme.AISMap">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowTranslucentStatus" tools:targetApi="19">false</item>
|
||||
<item name="android:windowTranslucentNavigation" tools:targetApi="19">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds" tools:targetApi="21">true</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="27">shortEdges</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">t1.openseamap.org</domain>
|
||||
<domain includeSubdomains="true">tiles.openseamap.org</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.grigowashere.aismap.controllers;
|
||||
|
||||
import android.content.Context;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import com.grigowashere.aismap.models.AISVessel;
|
||||
import com.grigowashere.aismap.models.Vessel;
|
||||
import com.grigowashere.aismap.utils.SettingsManager;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
//import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Тест для проверки исправления ConcurrentModificationException в AppCoordinator
|
||||
*/
|
||||
//@RunWith(MockitoJUnitRunner.class)
|
||||
//public class AppCoordinatorConcurrencyTest {
|
||||
//
|
||||
// @Mock
|
||||
// private Context mockContext;
|
||||
//
|
||||
// @Mock
|
||||
// private SettingsManager mockSettingsManager;
|
||||
//
|
||||
// private AppCoordinator appCoordinator;
|
||||
//
|
||||
// @Before
|
||||
// public void setUp() {
|
||||
// when(mockContext.getApplicationContext()).thenReturn(mockContext);
|
||||
// appCoordinator = new AppCoordinator(mockContext);
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// public void testGetNearbyVesselsConcurrency() throws InterruptedException {
|
||||
// // Создаем несколько AIS судов для тестирования
|
||||
// AISVessel vessel1 = new AISVessel();
|
||||
// vessel1.setMmsi("123456789");
|
||||
// vessel1.setLatitude(55.7558);
|
||||
// vessel1.setLongitude(37.6176);
|
||||
//
|
||||
// AISVessel vessel2 = new AISVessel();
|
||||
// vessel2.setMmsi("987654321");
|
||||
// vessel2.setLatitude(55.7559);
|
||||
// vessel2.setLongitude(37.6177);
|
||||
//
|
||||
// // Добавляем суда
|
||||
// appCoordinator.onAISVesselChanged(vessel1);
|
||||
// appCoordinator.onAISVesselChanged(vessel2);
|
||||
//
|
||||
// // Устанавливаем собственное судно
|
||||
// Vessel ownVessel = new Vessel();
|
||||
// ownVessel.setLatitude(55.7558);
|
||||
// ownVessel.setLongitude(37.6176);
|
||||
// appCoordinator.onOwnVesselChanged(ownVessel);
|
||||
//
|
||||
// int threadCount = 10;
|
||||
// int iterationsPerThread = 100;
|
||||
// CountDownLatch latch = new CountDownLatch(threadCount);
|
||||
// ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
//
|
||||
// // Запускаем несколько потоков, которые одновременно вызывают getNearbyVessels
|
||||
// // и модифицируют коллекцию aisVessels
|
||||
// for (int i = 0; i < threadCount; i++) {
|
||||
// final int threadId = i;
|
||||
// executor.submit(() -> {
|
||||
// try {
|
||||
// for (int j = 0; j < iterationsPerThread; j++) {
|
||||
// // Вызываем getNearbyVessels через onCompassChanged
|
||||
// appCoordinator.onCompassChanged(0.0f);
|
||||
//
|
||||
// // Добавляем новое судно
|
||||
// AISVessel newVessel = new AISVessel();
|
||||
// newVessel.setMmsi("thread" + threadId + "_vessel" + j);
|
||||
// newVessel.setLatitude(55.7558 + (j * 0.001));
|
||||
// newVessel.setLongitude(37.6176 + (j * 0.001));
|
||||
// appCoordinator.onAISVesselChanged(newVessel);
|
||||
//
|
||||
// // Небольшая задержка для увеличения вероятности race condition
|
||||
// Thread.sleep(1);
|
||||
// }
|
||||
// } catch (Exception e) {
|
||||
// fail("ConcurrentModificationException не должна возникать: " + e.getMessage());
|
||||
// } finally {
|
||||
// latch.countDown();
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// // Ждем завершения всех потоков
|
||||
// boolean finished = latch.await(30, TimeUnit.SECONDS);
|
||||
// executor.shutdown();
|
||||
//
|
||||
// assertTrue("Тест не завершился в течение 30 секунд", finished);
|
||||
//
|
||||
// // Проверяем, что метод getAISVessels работает корректно
|
||||
// List<AISVessel> vessels = appCoordinator.getAISVessels();
|
||||
// assertNotNull("Список судов не должен быть null", vessels);
|
||||
// assertTrue("Должно быть добавлено несколько судов", vessels.size() > 0);
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.grigowashere.aismap.controllers;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.Before;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
///**
|
||||
// * Тест для проверки логирования ошибок в NMEAParser
|
||||
// */
|
||||
//public class NMEAParserErrorLoggingTest {
|
||||
//
|
||||
// private NMEAParser parser;
|
||||
// private TestNMEAParserListener listener;
|
||||
//
|
||||
// @Before
|
||||
// public void setUp() {
|
||||
// parser = new NMEAParser();
|
||||
// listener = new TestNMEAParserListener();
|
||||
// parser.setListener(listener);
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// public void testParseInvalidNMEA() {
|
||||
// // Тестируем парсинг некорректного NMEA сообщения
|
||||
// parser.parseNMEA("$INVALID");
|
||||
// // Проверяем, что ошибка была залогирована (через LogSender)
|
||||
// // В реальном тесте мы бы проверили, что LogSender.logDroppedNMEA был вызван
|
||||
// assertTrue("Парсинг некорректного NMEA должен завершиться без исключений", true);
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// public void testParseTooShortNMEA() {
|
||||
// // Тестируем парсинг слишком короткого NMEA сообщения
|
||||
// parser.parseNMEA("$GP");
|
||||
// assertTrue("Парсинг слишком короткого NMEA должен завершиться без исключений", true);
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// public void testParseEmptyNMEA() {
|
||||
// // Тестируем парсинг пустого NMEA сообщения
|
||||
// parser.parseNMEA("");
|
||||
// parser.parseNMEA(null);
|
||||
// assertTrue("Парсинг пустого NMEA должен завершиться без исключений", true);
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// public void testParseInvalidAIS() {
|
||||
// // Тестируем парсинг некорректного AIS сообщения
|
||||
// parser.parseNMEA("!AIVDM,1,1,,A,*AB"); // Слишком короткое
|
||||
// assertTrue("Парсинг некорректного AIS должен завершиться без исключений", true);
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// public void testParseAISWithInvalidChecksum() {
|
||||
// // Тестируем парсинг AIS с неверной контрольной суммой
|
||||
// parser.parseNMEA("!AIVDM,1,1,,A,1234567890ABCDEF,*ZZ"); // Неверная контрольная сумма
|
||||
// assertTrue("Парсинг AIS с неверной контрольной суммой должен завершиться без исключений", true);
|
||||
// }
|
||||
//
|
||||
// @Test
|
||||
// public void testParseUnsupportedMessageType() {
|
||||
// // Тестируем парсинг неподдерживаемого типа сообщения
|
||||
// parser.parseNMEA("$GPXXX,123,456,789,*AB");
|
||||
// assertTrue("Парсинг неподдерживаемого типа должен завершиться без исключений", true);
|
||||
// }
|
||||
//
|
||||
// // Тестовый слушатель для проверки вызовов
|
||||
// private static class TestNMEAParserListener implements NMEAParser.NMEAParserListener {
|
||||
// public boolean onVesselUpdatedCalled = false;
|
||||
// public boolean onAISVesselUpdatedCalled = false;
|
||||
// public boolean onParseErrorCalled = false;
|
||||
// public boolean onDOPUpdatedCalled = false;
|
||||
//
|
||||
// @Override
|
||||
// public void onVesselUpdated(com.grigowashere.aismap.models.Vessel vessel) {
|
||||
// onVesselUpdatedCalled = true;
|
||||
// }
|
||||
//
|
||||
// @Override
|
||||
// public void onAISVesselUpdated(com.grigowashere.aismap.models.AISVessel vessel) {
|
||||
// onAISVesselUpdatedCalled = true;
|
||||
// }
|
||||
//
|
||||
// @Override
|
||||
// public void onParseError(String error) {
|
||||
// onParseErrorCalled = true;
|
||||
// }
|
||||
//
|
||||
// @Override
|
||||
// public void onDOPUpdated(double pdop, double hdop, double vdop) {
|
||||
// onDOPUpdatedCalled = true;
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.grigowashere.aismap.utils;
|
||||
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Тест для проверки новых методов логирования с полным NMEA и BLE кусками
|
||||
*/
|
||||
public class LogSenderExtendedTest {
|
||||
|
||||
@Test
|
||||
public void testAISParseErrorWithFullNMEA() {
|
||||
// Тестируем новый метод с полным NMEA сообщением
|
||||
try {
|
||||
String fullNMEA = "!AIVDM,1,1,,A,D02VqQ1K`Nfq@AN>56DK6E@UK6E1H0,*AB";
|
||||
String aisPayload = "D02VqQ1K`Nfq@AN>56DK6E@UK6E1H0";
|
||||
|
||||
LogSender.logAISParseErrorWithFullNMEA("Неподдерживаемый тип", fullNMEA, aisPayload, "Тип: 20");
|
||||
assertTrue("logAISParseErrorWithFullNMEA должен работать без исключений", true);
|
||||
} catch (Exception e) {
|
||||
fail("logAISParseErrorWithFullNMEA должен работать без исключений: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBLEDataChunkLogging() {
|
||||
// Тестируем логирование BLE кусков
|
||||
try {
|
||||
String deviceMac = "AA:BB:CC:DD:EE:FF";
|
||||
String dataChunk = "$GPGGA,123456,1234.5678,N,12345.6789,E,1,8,1.2,123.4,M,45.6,M,,*AB\r\n";
|
||||
|
||||
LogSender.logBLEDataChunk(deviceMac, dataChunk);
|
||||
assertTrue("logBLEDataChunk должен работать без исключений", true);
|
||||
} catch (Exception e) {
|
||||
fail("logBLEDataChunk должен работать без исключений: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBLEDataChunkWithNullValues() {
|
||||
// Тестируем обработку null значений
|
||||
try {
|
||||
LogSender.logBLEDataChunk(null, null);
|
||||
assertTrue("logBLEDataChunk должен обрабатывать null значения", true);
|
||||
} catch (Exception e) {
|
||||
fail("logBLEDataChunk должен обрабатывать null значения: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAISParseErrorWithFullNMEANullValues() {
|
||||
// Тестируем обработку null значений в новом методе
|
||||
try {
|
||||
LogSender.logAISParseErrorWithFullNMEA("Тест", null, null, "Детали");
|
||||
assertTrue("logAISParseErrorWithFullNMEA должен обрабатывать null значения", true);
|
||||
} catch (Exception e) {
|
||||
fail("logAISParseErrorWithFullNMEA должен обрабатывать null значения: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.grigowashere.aismap.utils;
|
||||
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Тест для проверки логирования ошибок через LogSender
|
||||
*/
|
||||
public class LogSenderTest {
|
||||
|
||||
@Test
|
||||
public void testLogError() {
|
||||
// Тестируем, что метод logError не падает с исключениями
|
||||
try {
|
||||
LogSender.logError("TEST_ERROR", "Тестовое сообщение", "Тестовые детали");
|
||||
// Если метод выполнился без исключений, тест прошел
|
||||
assertTrue(true);
|
||||
} catch (Exception e) {
|
||||
fail("logError должен работать без исключений: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLogDroppedNMEA() {
|
||||
// Тестируем логирование отброшенных NMEA сообщений
|
||||
try {
|
||||
LogSender.logDroppedNMEA("Тестовая причина", "$GPGGA,123456,1234.5678,N,12345.6789,E,1,8,1.2,123.4,M,45.6,M,,*AB", "Тестовые детали");
|
||||
assertTrue(true);
|
||||
} catch (Exception e) {
|
||||
fail("logDroppedNMEA должен работать без исключений: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLogAISParseError() {
|
||||
// Тестируем логирование ошибок парсинга AIS
|
||||
try {
|
||||
LogSender.logAISParseError("Тестовая ошибка", "!AIVDM,1,1,,A,1234567890ABCDEF,*AB", "Тестовые детали");
|
||||
assertTrue(true);
|
||||
} catch (Exception e) {
|
||||
fail("logAISParseError должен работать без исключений: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLogBLEError() {
|
||||
// Тестируем логирование ошибок BLE
|
||||
try {
|
||||
LogSender.logBLEError("Тестовая ошибка BLE", "AA:BB:CC:DD:EE:FF", "Тестовые детали");
|
||||
assertTrue(true);
|
||||
} catch (Exception e) {
|
||||
fail("logBLEError должен работать без исключений: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLogErrorWithNullValues() {
|
||||
// Тестируем обработку null значений
|
||||
try {
|
||||
LogSender.logError(null, null, null);
|
||||
LogSender.logDroppedNMEA(null, null, null);
|
||||
LogSender.logAISParseError(null, null, null);
|
||||
LogSender.logBLEError(null, null, null);
|
||||
assertTrue(true);
|
||||
} catch (Exception e) {
|
||||
fail("Методы должны обрабатывать null значения: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.grigowashere.aismap.utils;
|
||||
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Тест для проверки правильного URL логирования ошибок
|
||||
*/
|
||||
public class LogSenderURLTest {
|
||||
|
||||
@Test
|
||||
public void testErrorLoggingURL() {
|
||||
// Тестируем, что URL формируется правильно
|
||||
try {
|
||||
// Симулируем вызов logError
|
||||
LogSender.logError("TEST_ERROR", "Тестовое сообщение", "Тестовые детали");
|
||||
|
||||
// Если метод выполнился без исключений, тест прошел
|
||||
assertTrue("logError должен работать без исключений", true);
|
||||
} catch (Exception e) {
|
||||
fail("logError должен работать без исключений: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDroppedNMEALoggingURL() {
|
||||
// Тестируем логирование отброшенных NMEA сообщений
|
||||
try {
|
||||
LogSender.logDroppedNMEA("Тестовая причина", "$GPGGA,123456,1234.5678,N,12345.6789,E,1,8,1.2,123.4,M,45.6,M,,*AB", "Тестовые детали");
|
||||
assertTrue("logDroppedNMEA должен работать без исключений", true);
|
||||
} catch (Exception e) {
|
||||
fail("logDroppedNMEA должен работать без исключений: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAISParseErrorLoggingURL() {
|
||||
// Тестируем логирование ошибок парсинга AIS
|
||||
try {
|
||||
LogSender.logAISParseError("Тестовая ошибка", "!AIVDM,1,1,,A,1234567890ABCDEF,*AB", "Тестовые детали");
|
||||
assertTrue("logAISParseError должен работать без исключений", true);
|
||||
} catch (Exception e) {
|
||||
fail("logAISParseError должен работать без исключений: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBLEErrorLoggingURL() {
|
||||
// Тестируем логирование ошибок BLE
|
||||
try {
|
||||
LogSender.logBLEError("Тестовая ошибка BLE", "AA:BB:CC:DD:EE:FF", "Тестовые детали");
|
||||
assertTrue("logBLEError должен работать без исключений", true);
|
||||
} catch (Exception e) {
|
||||
fail("logBLEError должен работать без исключений: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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#
|
||||
@@ -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)
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 обновлений для производительности
|
||||
- Автоматическое управление жизненным циклом компонентов
|
||||
- Централизованная обработка ошибок и логирования
|
||||
- Поддержка множественных источников данных
|
||||
- Система уведомлений с вибрацией и звуком
|
||||
@@ -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
|
||||
@@ -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 редакторах или онлайн сервисах для генерации изображений.
|
||||
@@ -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 приложения!
|
||||
@@ -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 и показывать всю архитектуру вашего приложения.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||