closd TG-6; Initial push after server migration
@@ -4,7 +4,7 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2025-09-23T13:53:32.308312900Z">
|
<DropdownSelection timestamp="2026-04-30T06:54:41.293712400Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=bc722e5b" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=bc722e5b" />
|
||||||
|
|||||||
@@ -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'
|
namespace 'com.grigowashere.aismap'
|
||||||
compileSdk 35
|
compileSdk 35
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig true
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.grigowashere.aismap"
|
applicationId "com.grigowashere.aismap"
|
||||||
minSdk 30
|
minSdk 30
|
||||||
@@ -56,6 +60,10 @@ dependencies {
|
|||||||
// MapLibre GL Android SDK (используем только один артефакт, без плагина аннотаций)
|
// MapLibre GL Android SDK (используем только один артефакт, без плагина аннотаций)
|
||||||
implementation group: 'org.maplibre.gl', name: 'android-sdk-opengl', version: '11.13.5'
|
implementation group: 'org.maplibre.gl', name: 'android-sdk-opengl', version: '11.13.5'
|
||||||
|
|
||||||
|
// MessagePack — компактная бинарная сериализация для BLE Hub снапшотов
|
||||||
|
// (см. ble_gatt.py: AIS_BLE_BROADCAST_ENCODING=msgpack)
|
||||||
|
implementation 'org.msgpack:msgpack-core:0.9.8'
|
||||||
|
|
||||||
// Тестирование
|
// Тестирование
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||||
|
|||||||
@@ -20,6 +20,13 @@
|
|||||||
|
|
||||||
<!-- Разрешения для UDP -->
|
<!-- Разрешения для UDP -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
|
||||||
|
<!-- BLE permissions -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||||
|
|
||||||
<!-- Разрешения для вибрации -->
|
<!-- Разрешения для вибрации -->
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
@@ -32,7 +39,9 @@
|
|||||||
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.location" android:required="false" />
|
<uses-feature android:name="android.hardware.location" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
@@ -47,7 +56,7 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||||
android:theme="@style/Theme.AISMap"
|
android:theme="@style/Theme.AISMap.Map"
|
||||||
android:keepScreenOn="true">
|
android:keepScreenOn="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@@ -60,6 +69,12 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||||
android:theme="@style/Theme.AISMap" />
|
android:theme="@style/Theme.AISMap" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".settings.InterfacesSettingsActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||||
|
android:theme="@style/Theme.AISMap" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".AisTargetsActivity"
|
android:name=".AisTargetsActivity"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 31 KiB |
@@ -22,6 +22,10 @@ import android.view.WindowManager;
|
|||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.core.app.ActivityCompat;
|
import androidx.core.app.ActivityCompat;
|
||||||
import androidx.core.content.ContextCompat;
|
import androidx.core.content.ContextCompat;
|
||||||
|
import androidx.core.graphics.Insets;
|
||||||
|
import androidx.core.view.ViewCompat;
|
||||||
|
import androidx.core.view.WindowCompat;
|
||||||
|
import androidx.core.view.WindowInsetsCompat;
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialog;
|
import com.google.android.material.bottomsheet.BottomSheetDialog;
|
||||||
|
|
||||||
import com.grigowashere.aismap.controllers.AppCoordinator;
|
import com.grigowashere.aismap.controllers.AppCoordinator;
|
||||||
@@ -64,6 +68,9 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
// Статическая переменная для отслеживания инициализации Яндекс.Карт
|
// Статическая переменная для отслеживания инициализации Яндекс.Карт
|
||||||
private static boolean isYandexMapsInitialized = false;
|
private static boolean isYandexMapsInitialized = false;
|
||||||
|
|
||||||
|
// Флаг для отслеживания первого запуска приложения
|
||||||
|
private boolean isFirstStart = true;
|
||||||
|
|
||||||
private AppCoordinator appCoordinator;
|
private AppCoordinator appCoordinator;
|
||||||
// UI binders
|
// UI binders
|
||||||
private MenuBinder menuBinder;
|
private MenuBinder menuBinder;
|
||||||
@@ -80,6 +87,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
private ImageButton btnCursorToggle;
|
private ImageButton btnCursorToggle;
|
||||||
private ImageButton btnSettings;
|
private ImageButton btnSettings;
|
||||||
private ImageButton btnAisTargets;
|
private ImageButton btnAisTargets;
|
||||||
|
private ImageButton btnGpsSource;
|
||||||
private LinearLayout controlPanel;
|
private LinearLayout controlPanel;
|
||||||
private CompassView compassView;
|
private CompassView compassView;
|
||||||
private CoordinatesDockWidget coordinatesWidget;
|
private CoordinatesDockWidget coordinatesWidget;
|
||||||
@@ -95,6 +103,36 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
private static final long UI_UPDATE_THROTTLE_MS = 200; // 5 FPS максимум
|
private static final long UI_UPDATE_THROTTLE_MS = 200; // 5 FPS максимум
|
||||||
private TextView tvGpsAge;
|
private TextView tvGpsAge;
|
||||||
private TextView tvAisAge;
|
private TextView tvAisAge;
|
||||||
|
private TextView tvBleRssi;
|
||||||
|
private TextView tvBleBatt;
|
||||||
|
private TextView tvFps;
|
||||||
|
private int frameCount = 0;
|
||||||
|
private long lastFpsTs = 0L;
|
||||||
|
private final android.view.Choreographer.FrameCallback fpsCallback = new android.view.Choreographer.FrameCallback() {
|
||||||
|
@Override public void doFrame(long frameTimeNanos) {
|
||||||
|
// UI heartbeat: если кадры идут, UI точно жив.
|
||||||
|
updateUIActivity();
|
||||||
|
frameCount++;
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (lastFpsTs == 0L) lastFpsTs = now;
|
||||||
|
if (now - lastFpsTs >= 1000) {
|
||||||
|
final int fps = frameCount;
|
||||||
|
frameCount = 0;
|
||||||
|
lastFpsTs = now;
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
if (tvFps != null) {
|
||||||
|
tvFps.setText("FPS: " + fps);
|
||||||
|
int color;
|
||||||
|
if (fps >= 55) color = android.graphics.Color.parseColor("#4CAF50");
|
||||||
|
else if (fps >= 40) color = android.graphics.Color.parseColor("#FFC107");
|
||||||
|
else color = android.graphics.Color.parseColor("#F44336");
|
||||||
|
tvFps.setTextColor(color);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
android.view.Choreographer.getInstance().postFrameCallback(this);
|
||||||
|
}
|
||||||
|
};
|
||||||
private android.os.Handler messageAgeHandler;
|
private android.os.Handler messageAgeHandler;
|
||||||
private Runnable messageAgeRunnable;
|
private Runnable messageAgeRunnable;
|
||||||
private BottomSheetsManager bottomSheetsManager;
|
private BottomSheetsManager bottomSheetsManager;
|
||||||
@@ -119,6 +157,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
private long lastUIUpdateTime = 0;
|
private long lastUIUpdateTime = 0;
|
||||||
private static final long UI_WATCHDOG_INTERVAL = 1000; // 1 секунда - быстрая диагностика
|
private static final long UI_WATCHDOG_INTERVAL = 1000; // 1 секунда - быстрая диагностика
|
||||||
private static final long UI_TIMEOUT = 3000; // 3 секунды без обновлений = зависание
|
private static final long UI_TIMEOUT = 3000; // 3 секунды без обновлений = зависание
|
||||||
|
private final java.util.concurrent.ScheduledExecutorService uiWatchdogScheduler =
|
||||||
|
java.util.concurrent.Executors.newSingleThreadScheduledExecutor();
|
||||||
|
private volatile long lastUiPongUptimeMs = 0L;
|
||||||
|
private volatile long lastUiHangLogUptimeMs = 0L;
|
||||||
|
|
||||||
// Диагностика компаса
|
// Диагностика компаса
|
||||||
private long lastCompassLogTime = 0;
|
private long lastCompassLogTime = 0;
|
||||||
@@ -144,7 +186,18 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Ошибка инициализации MapLibre: " + e.getMessage(), e);
|
Log.e(TAG, "Ошибка инициализации MapLibre: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Edge-to-edge: приложение само раскладывает UI под статус/нав-барами
|
||||||
|
// и вырезами камеры. Без этого WindowInsets будут давать нули
|
||||||
|
// и координатная панель уедет под системную навигационную кнопку.
|
||||||
|
try {
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||||
|
getWindow().setStatusBarColor(android.graphics.Color.TRANSPARENT);
|
||||||
|
getWindow().setNavigationBarColor(android.graphics.Color.TRANSPARENT);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "Не удалось включить edge-to-edge: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
setContentView(R.layout.activity_main);
|
setContentView(R.layout.activity_main);
|
||||||
|
|
||||||
initializeViews();
|
initializeViews();
|
||||||
@@ -165,9 +218,11 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
btnCursorToggle = findViewById(R.id.btn_cursor_toggle);
|
btnCursorToggle = findViewById(R.id.btn_cursor_toggle);
|
||||||
btnSettings = findViewById(R.id.btn_settings);
|
btnSettings = findViewById(R.id.btn_settings);
|
||||||
btnAisTargets = findViewById(R.id.btn_ais_targets);
|
btnAisTargets = findViewById(R.id.btn_ais_targets);
|
||||||
|
btnGpsSource = findViewById(R.id.btn_gps_source);
|
||||||
controlPanel = findViewById(R.id.control_panel);
|
controlPanel = findViewById(R.id.control_panel);
|
||||||
compassView = findViewById(R.id.compass_view);
|
compassView = findViewById(R.id.compass_view);
|
||||||
coordinatesWidget = findViewById(R.id.coordinates_widget);
|
coordinatesWidget = findViewById(R.id.coordinates_widget);
|
||||||
|
installMainUiInsets();
|
||||||
|
|
||||||
// Инициализируем троттлинг
|
// Инициализируем троттлинг
|
||||||
uiThrottleHandler = new android.os.Handler(android.os.Looper.getMainLooper());
|
uiThrottleHandler = new android.os.Handler(android.os.Looper.getMainLooper());
|
||||||
@@ -206,6 +261,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// В режимах «по компасу» / «по курсу» непрерывно подстраиваем bearing
|
||||||
|
// карты; в «вручную» не трогаем — пользователь крутит жестом.
|
||||||
|
applyAutoMapBearingIfNeeded(mapIf);
|
||||||
}
|
}
|
||||||
} catch (Exception ignore) {}
|
} catch (Exception ignore) {}
|
||||||
// Планируем следующее обновление
|
// Планируем следующее обновление
|
||||||
@@ -215,6 +274,9 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
};
|
};
|
||||||
tvGpsAge = findViewById(R.id.tv_gps_age);
|
tvGpsAge = findViewById(R.id.tv_gps_age);
|
||||||
tvAisAge = findViewById(R.id.tv_ais_age);
|
tvAisAge = findViewById(R.id.tv_ais_age);
|
||||||
|
tvBleRssi = findViewById(R.id.tv_ble_rssi);
|
||||||
|
tvBleBatt = findViewById(R.id.tv_ble_batt);
|
||||||
|
tvFps = findViewById(R.id.tv_fps);
|
||||||
|
|
||||||
// Инициализируем магнитный компас через CompassController
|
// Инициализируем магнитный компас через CompassController
|
||||||
// compassSensor = new CompassSensor(this); // Удалено - теперь используется CompassController
|
// compassSensor = new CompassSensor(this); // Удалено - теперь используется CompassController
|
||||||
@@ -231,11 +293,17 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
private void setupButtonListeners() {
|
private void setupButtonListeners() {
|
||||||
if (btnCenterOnVessel != null) btnCenterOnVessel.setOnClickListener(v -> centerOnVessel());
|
if (btnCenterOnVessel != null) btnCenterOnVessel.setOnClickListener(v -> centerOnVessel());
|
||||||
if (btnMapOrientation != null) btnMapOrientation.setOnClickListener(v -> toggleMapOrientation());
|
if (btnMapOrientation != null) {
|
||||||
|
btnMapOrientation.setOnClickListener(v -> cycleMapRotationMode());
|
||||||
|
}
|
||||||
if (btnCursorToggle != null) btnCursorToggle.setOnClickListener(v -> toggleCursor());
|
if (btnCursorToggle != null) btnCursorToggle.setOnClickListener(v -> toggleCursor());
|
||||||
if (btnCursorToggle != null) btnCursorToggle.setOnLongClickListener(v -> { toggleCursor(); return true; });
|
if (btnCursorToggle != null) btnCursorToggle.setOnLongClickListener(v -> { toggleCursor(); return true; });
|
||||||
if (btnSettings != null) btnSettings.setOnClickListener(v -> showSettings());
|
if (btnSettings != null) btnSettings.setOnClickListener(v -> showSettings());
|
||||||
if (btnAisTargets != null) btnAisTargets.setOnClickListener(v -> openAisTargets());
|
if (btnAisTargets != null) btnAisTargets.setOnClickListener(v -> openAisTargets());
|
||||||
|
if (btnGpsSource != null) {
|
||||||
|
refreshGpsSourceButtonIcon();
|
||||||
|
btnGpsSource.setOnClickListener(v -> toggleGpsSource());
|
||||||
|
}
|
||||||
|
|
||||||
// Кнопка для показа информации о судне
|
// Кнопка для показа информации о судне
|
||||||
// Button btnShowVesselInfo = findViewById(R.id.btn_show_vessel_info);
|
// Button btnShowVesselInfo = findViewById(R.id.btn_show_vessel_info);
|
||||||
@@ -252,6 +320,9 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
compassView.post(() -> {
|
compassView.post(() -> {
|
||||||
compassView.setDocked(true, true, 0, 0);
|
compassView.setDocked(true, true, 0, 0);
|
||||||
compassView.invalidate(); // Принудительная отрисовка
|
compassView.invalidate(); // Принудительная отрисовка
|
||||||
|
// Выровнять паддинги под статус-бар/вырез камеры сразу после
|
||||||
|
// первого dock-позиционирования (до этого сторона неизвестна).
|
||||||
|
reapplyInsetsToDocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Настраиваем слушатель изменения размера док-виджета
|
// Настраиваем слушатель изменения размера док-виджета
|
||||||
@@ -269,6 +340,9 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
BaseDockWidget.repositionAllDockedWidgets((ViewGroup) compassView.getParent());
|
BaseDockWidget.repositionAllDockedWidgets((ViewGroup) compassView.getParent());
|
||||||
|
|
||||||
updateControlPanelPosition();
|
updateControlPanelPosition();
|
||||||
|
// Док мог поменять сторону — паддинги под системные бары
|
||||||
|
// тоже должны переключиться (top <-> bottom).
|
||||||
|
reapplyInsetsToDocks();
|
||||||
});
|
});
|
||||||
//smt changed
|
//smt changed
|
||||||
// Настраиваем магнитный компас через CompassController
|
// Настраиваем магнитный компас через CompassController
|
||||||
@@ -362,6 +436,9 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
BaseDockWidget.repositionAllDockedWidgets((ViewGroup) coordinatesWidget.getParent());
|
BaseDockWidget.repositionAllDockedWidgets((ViewGroup) coordinatesWidget.getParent());
|
||||||
|
|
||||||
updateControlPanelPosition();
|
updateControlPanelPosition();
|
||||||
|
// Перекидываем системные паддинги в нужную сторону под новую
|
||||||
|
// дока — чтобы под нав-баром/брови ничего не оставалось.
|
||||||
|
reapplyInsetsToDocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Устанавливаем виджет координат в dock-режим внизу экрана без тестовых данных
|
// Устанавливаем виджет координат в dock-режим внизу экрана без тестовых данных
|
||||||
@@ -369,6 +446,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
Log.d(TAG, "Setting coordinates widget to dock mode");
|
Log.d(TAG, "Setting coordinates widget to dock mode");
|
||||||
coordinatesWidget.setDocked(true, false, 0, 0); // false = dock снизу
|
coordinatesWidget.setDocked(true, false, 0, 0); // false = dock снизу
|
||||||
coordinatesWidget.invalidate(); // Принудительная отрисовка
|
coordinatesWidget.invalidate(); // Принудительная отрисовка
|
||||||
|
// Только сейчас мы знаем сторону дока (bottom) — переприменяем
|
||||||
|
// инсеты, чтобы виджет получил bottom padding под нав-бар
|
||||||
|
// сразу, а не только после первого ресайза пользователем.
|
||||||
|
reapplyInsetsToDocks();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +471,20 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
tvAisAge.setText(aisSec >= 0 ? ("AIS: " + aisSec + " сек назад") : "AIS: --");
|
tvAisAge.setText(aisSec >= 0 ? ("AIS: " + aisSec + " сек назад") : "AIS: --");
|
||||||
tvAisAge.setTextColor(getAgeColor(aisSec));
|
tvAisAge.setTextColor(getAgeColor(aisSec));
|
||||||
}
|
}
|
||||||
|
if (tvBleRssi != null) {
|
||||||
|
Integer rssi = appCoordinator.getLastBleRssi();
|
||||||
|
if (rssi != null) {
|
||||||
|
tvBleRssi.setText("BLE RSSI: " + rssi);
|
||||||
|
tvBleRssi.setTextColor(getRssiColor(rssi));
|
||||||
|
} else {
|
||||||
|
tvBleRssi.setText("BLE RSSI: --");
|
||||||
|
tvBleRssi.setTextColor(android.graphics.Color.parseColor("#F44336"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tvBleBatt != null) {
|
||||||
|
Integer batt = appCoordinator.getLastBleBattery();
|
||||||
|
tvBleBatt.setText(batt != null ? ("BLE Batt: " + batt + "%") : "BLE Batt: --");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
messageAgeHandler.postDelayed(this, 1000);
|
messageAgeHandler.postDelayed(this, 1000);
|
||||||
@@ -412,6 +507,17 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
return Color.parseColor("#F44336"); // красный
|
return Color.parseColor("#F44336"); // красный
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int getRssiColor(int rssi) {
|
||||||
|
// Типичные пороги: >= -60 dBm (сильный) — зелёный; >= -80 dBm (средний) — жёлтый; иначе — красный
|
||||||
|
if (rssi >= -60) {
|
||||||
|
return android.graphics.Color.parseColor("#4CAF50");
|
||||||
|
} else if (rssi >= -80) {
|
||||||
|
return android.graphics.Color.parseColor("#FFC107");
|
||||||
|
} else {
|
||||||
|
return android.graphics.Color.parseColor("#F44336");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void onUpdateCompass(float azimuth, List<AISVessel> nearbyVessels) {
|
private void onUpdateCompass(float azimuth, List<AISVessel> nearbyVessels) {
|
||||||
if (compassView != null) {
|
if (compassView != null) {
|
||||||
@@ -474,42 +580,50 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
* Настраивает UI watchdog для отслеживания зависаний
|
* Настраивает UI watchdog для отслеживания зависаний
|
||||||
*/
|
*/
|
||||||
private void setupUIWatchdog() {
|
private void setupUIWatchdog() {
|
||||||
|
// ВАЖНО: watchdog не должен работать на UI Looper, иначе он не может детектить настоящий hang.
|
||||||
|
// Поэтому тикер в фоне, а "pong" — маленькая задачка на UI.
|
||||||
uiWatchdogHandler = new android.os.Handler(android.os.Looper.getMainLooper());
|
uiWatchdogHandler = new android.os.Handler(android.os.Looper.getMainLooper());
|
||||||
uiWatchdogRunnable = new Runnable() {
|
lastUIUpdateTime = System.currentTimeMillis();
|
||||||
@Override
|
lastUiPongUptimeMs = android.os.SystemClock.uptimeMillis();
|
||||||
public void run() {
|
|
||||||
long currentTime = System.currentTimeMillis();
|
// Заглушка для обратной совместимости (на него ссылаются логи/tryRecoverFromUIHang)
|
||||||
long timeSinceLastUpdate = currentTime - lastUIUpdateTime;
|
uiWatchdogRunnable = () -> {};
|
||||||
|
|
||||||
if (timeSinceLastUpdate > UI_TIMEOUT) {
|
try {
|
||||||
Log.e(TAG, "🚨 UI WATCHDOG: UI ЗАВИС! Последнее обновление " +
|
uiWatchdogScheduler.scheduleAtFixedRate(() -> {
|
||||||
(timeSinceLastUpdate / 1000) + " секунд назад");
|
final long pingUptime = android.os.SystemClock.uptimeMillis();
|
||||||
Log.e(TAG, "🚨 UI WATCHDOG: Время зависания: " + new java.util.Date(currentTime));
|
try {
|
||||||
Log.e(TAG, "🚨 UI WATCHDOG: Thread: " + Thread.currentThread().getName());
|
uiWatchdogHandler.post(() -> {
|
||||||
// Дамп стека главного потока и нескольких рабочих потоков
|
// "pong": если это исполнилось — UI Looper жив.
|
||||||
dumpThreadStacksForDiagnostics();
|
lastUiPongUptimeMs = android.os.SystemClock.uptimeMillis();
|
||||||
|
updateUIActivity();
|
||||||
// Попытка восстановления
|
});
|
||||||
tryRecoverFromUIHang();
|
} catch (Throwable ignore) {}
|
||||||
} else {
|
|
||||||
// Логируем каждые 10 секунд для мониторинга
|
long sincePong = pingUptime - lastUiPongUptimeMs;
|
||||||
if (timeSinceLastUpdate > 0 && (timeSinceLastUpdate / 1000) % 10 == 0) {
|
if (sincePong > UI_TIMEOUT) {
|
||||||
Log.i(TAG, "✅ UI WATCHDOG: UI активен, последнее обновление " +
|
// Не спамим логом каждую секунду.
|
||||||
(timeSinceLastUpdate / 1000) + " секунд назад");
|
if (pingUptime - lastUiHangLogUptimeMs > 10_000L) {
|
||||||
|
lastUiHangLogUptimeMs = pingUptime;
|
||||||
|
Log.e(TAG, "🚨 UI WATCHDOG: UI возможно завис (main looper не отвечает) " +
|
||||||
|
(sincePong / 1000) + " секунд");
|
||||||
|
dumpThreadStacksForDiagnosticsAsync();
|
||||||
|
// Recovery — только если UI хоть как-то отвечает (иначе бесполезно)
|
||||||
|
tryRecoverFromUIHang();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, UI_WATCHDOG_INTERVAL, UI_WATCHDOG_INTERVAL, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||||
// Планируем следующую проверку
|
} catch (Throwable t) {
|
||||||
uiWatchdogHandler.postDelayed(this, UI_WATCHDOG_INTERVAL);
|
Log.e(TAG, "UI watchdog: не удалось запустить scheduler: " + t.getMessage(), t);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
Log.i(TAG, "UI watchdog запущен (background)");
|
||||||
// Запускаем watchdog
|
|
||||||
lastUIUpdateTime = System.currentTimeMillis();
|
|
||||||
uiWatchdogHandler.postDelayed(uiWatchdogRunnable, UI_WATCHDOG_INTERVAL);
|
|
||||||
Log.i(TAG, "UI watchdog запущен");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final java.util.concurrent.ExecutorService watchdogExecutor =
|
||||||
|
java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||||
|
private volatile long lastRecoveryAttemptMs = 0L;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обновляет время последней активности UI
|
* Обновляет время последней активности UI
|
||||||
*/
|
*/
|
||||||
@@ -531,6 +645,14 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
Log.w(TAG, "UI WATCHDOG: Попытка восстановления...");
|
Log.w(TAG, "UI WATCHDOG: Попытка восстановления...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
// Не долбим восстановлением каждую секунду — это само может стать причиной лагов
|
||||||
|
if (now - lastRecoveryAttemptMs < 10_000L) {
|
||||||
|
Log.i(TAG, "UI WATCHDOG: восстановление пропущено (throttle)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastRecoveryAttemptMs = now;
|
||||||
|
|
||||||
// Диагностика: проверяем состояние handler'ов
|
// Диагностика: проверяем состояние handler'ов
|
||||||
boolean watchdogActive = uiWatchdogHandler != null && uiWatchdogRunnable != null;
|
boolean watchdogActive = uiWatchdogHandler != null && uiWatchdogRunnable != null;
|
||||||
boolean messageAgeActive = messageAgeHandler != null && messageAgeRunnable != null;
|
boolean messageAgeActive = messageAgeHandler != null && messageAgeRunnable != null;
|
||||||
@@ -544,11 +666,12 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
", controlPanel=" + controlPanelActive +
|
", controlPanel=" + controlPanelActive +
|
||||||
", controlPanelCount=" + controlPanelUpdateCount);
|
", controlPanelCount=" + controlPanelUpdateCount);
|
||||||
|
|
||||||
// Принудительная сборка мусора
|
// ВАЖНО: никаких тяжёлых операций (System.gc) на UI-потоке.
|
||||||
System.gc();
|
// Если нужно, можно поставить фоновой GC после лагов, но это диагностическая функция,
|
||||||
|
// а не recovery, поэтому здесь намеренно ничего не делаем.
|
||||||
|
|
||||||
// Проверяем состояние основных компонентов
|
// Проверяем состояние основных компонентов
|
||||||
if (mapController.getCurrentMapInterface() == null) {
|
if (mapController != null && mapController.getCurrentMapInterface() == null) {
|
||||||
Log.w(TAG, "UI WATCHDOG: mapInterface == null, переинициализируем карту");
|
Log.w(TAG, "UI WATCHDOG: mapInterface == null, переинициализируем карту");
|
||||||
// Можно попробовать переинициализировать карту
|
// Можно попробовать переинициализировать карту
|
||||||
}
|
}
|
||||||
@@ -580,7 +703,15 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
/**
|
/**
|
||||||
* Диагностический дамп стеков главного и рабочих потоков
|
* Диагностический дамп стеков главного и рабочих потоков
|
||||||
*/
|
*/
|
||||||
private void dumpThreadStacksForDiagnostics() {
|
private void dumpThreadStacksForDiagnosticsAsync() {
|
||||||
|
try {
|
||||||
|
watchdogExecutor.execute(this::dumpThreadStacksForDiagnosticsBlocking);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.e(TAG, "UI WATCHDOG: не удалось запустить дамп стеков: " + t.getMessage(), t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dumpThreadStacksForDiagnosticsBlocking() {
|
||||||
try {
|
try {
|
||||||
java.util.Map<Thread, StackTraceElement[]> all = Thread.getAllStackTraces();
|
java.util.Map<Thread, StackTraceElement[]> all = Thread.getAllStackTraces();
|
||||||
Thread main = Looper.getMainLooper().getThread();
|
Thread main = Looper.getMainLooper().getThread();
|
||||||
@@ -675,6 +806,32 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
Log.i(TAG, "Режим экрана переключен: keepScreenOn=" + keepScreenOn);
|
Log.i(TAG, "Режим экрана переключен: keepScreenOn=" + keepScreenOn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключает отображение морских знаков OpenSeaMap
|
||||||
|
*/
|
||||||
|
public void toggleSeamarks() {
|
||||||
|
if (settingsManager == null) {
|
||||||
|
Log.w(TAG, "toggleSeamarks: settingsManager is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean currentState = settingsManager.isSeamarksEnabled();
|
||||||
|
boolean newState = !currentState;
|
||||||
|
|
||||||
|
// Сохраняем настройку
|
||||||
|
settingsManager.setSeamarksEnabled(newState);
|
||||||
|
|
||||||
|
// Применяем изменения на карте
|
||||||
|
if (mapController.getCurrentMapInterface() instanceof MapLibreMapImpl) {
|
||||||
|
((MapLibreMapImpl) mapController.getCurrentMapInterface()).updateAdditionalLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
String message = newState ? "Морские знаки включены" : "Морские знаки выключены";
|
||||||
|
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
|
||||||
|
|
||||||
|
Log.i(TAG, "Морские знаки переключены: enabled=" + newState);
|
||||||
|
}
|
||||||
|
|
||||||
private void initializeControllers() {
|
private void initializeControllers() {
|
||||||
// Инициализация менеджера настроек
|
// Инициализация менеджера настроек
|
||||||
settingsManager = new SettingsManager(this);
|
settingsManager = new SettingsManager(this);
|
||||||
@@ -694,6 +851,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
@Override public void togglePathTracking() { MainActivity.this.togglePathTracking(); }
|
@Override public void togglePathTracking() { MainActivity.this.togglePathTracking(); }
|
||||||
@Override public void testForegroundService() { MainActivity.this.testForegroundService(); }
|
@Override public void testForegroundService() { MainActivity.this.testForegroundService(); }
|
||||||
@Override public void toggleKeepScreenOn() { MainActivity.this.toggleKeepScreenOn(); }
|
@Override public void toggleKeepScreenOn() { MainActivity.this.toggleKeepScreenOn(); }
|
||||||
|
@Override public void toggleSeamarks() { MainActivity.this.toggleSeamarks(); }
|
||||||
});
|
});
|
||||||
// Не используем BottomSheetsBinder, оставляем рабочую реализацию в MainActivity
|
// Не используем BottomSheetsBinder, оставляем рабочую реализацию в MainActivity
|
||||||
permissionsBinder = new PermissionsBinder(this);
|
permissionsBinder = new PermissionsBinder(this);
|
||||||
@@ -751,6 +909,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
refreshMapRotationButtonDescription();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startControllers() {
|
private void startControllers() {
|
||||||
@@ -851,20 +1010,106 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
Toast.makeText(this, "Карта центрирована на судне", Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, "Карта центрирована на судне", Toast.LENGTH_SHORT).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void toggleMapOrientation() {
|
private static float normalizeBearingTo360(double deg) {
|
||||||
if (mapController.getCurrentMapInterface() == null) return;
|
double x = deg % 360.0;
|
||||||
|
if (x < 0) x += 360.0;
|
||||||
|
return (float) x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Три режима: по магнитному компасу, по курсу (COG), вручную
|
||||||
|
* (север вверх при переключении, дальше — только жесты пользователя).
|
||||||
|
* Кнопка циклически переключает: компас → курс → вручную → …
|
||||||
|
*/
|
||||||
|
private void cycleMapRotationMode() {
|
||||||
|
if (settingsManager == null) return;
|
||||||
|
String mode = settingsManager.cycleMapRotationMode();
|
||||||
|
refreshMapRotationButtonDescription();
|
||||||
|
if (mapController == null || mapController.getCurrentMapInterface() == null) {
|
||||||
|
Toast.makeText(this, "Режим карты сохранён — применится после загрузки карты",
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
float current = mapController.getCurrentMapInterface().getBearing();
|
MapInterface map = mapController.getCurrentMapInterface();
|
||||||
// Простейший toggle: если близко к северу — повернуть на 45°, иначе выровнять по северу
|
applyMapRotationForMode(map, mode, true);
|
||||||
if (Math.abs(current) < 1f) {
|
|
||||||
mapController.getCurrentMapInterface().setBearing(45f);
|
|
||||||
Toast.makeText(this, "Ориентация: произвольная (45°)", Toast.LENGTH_SHORT).show();
|
|
||||||
} else {
|
|
||||||
mapController.getCurrentMapInterface().setBearing(0f);
|
|
||||||
Toast.makeText(this, "Ориентация: север вверх", Toast.LENGTH_SHORT).show();
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.w(TAG, "toggleMapOrientation error: " + e.getMessage());
|
Log.w(TAG, "cycleMapRotationMode: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyMapRotationForMode(MapInterface map, String mode, boolean showShortToast) {
|
||||||
|
if (map == null || mode == null) return;
|
||||||
|
if (SettingsManager.MAP_ROTATION_MANUAL.equals(mode)) {
|
||||||
|
map.setBearing(0f);
|
||||||
|
if (showShortToast) {
|
||||||
|
Toast.makeText(this, "Карта: вручную (север вверх, дальше — жестом)",
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (appCoordinator == null) return;
|
||||||
|
Vessel own = appCoordinator.getOwnVessel();
|
||||||
|
if (own == null) {
|
||||||
|
if (showShortToast) {
|
||||||
|
Toast.makeText(this, "Нет данных собственного судна", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (SettingsManager.MAP_ROTATION_COMPASS.equals(mode)) {
|
||||||
|
float b = normalizeBearingTo360(own.getMagneticCompass());
|
||||||
|
map.setBearing(b);
|
||||||
|
if (showShortToast) {
|
||||||
|
Toast.makeText(this,
|
||||||
|
String.format(java.util.Locale.US, "По компасу (%.0f°)", b),
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (SettingsManager.MAP_ROTATION_COURSE.equals(mode)) {
|
||||||
|
if (Double.isNaN(own.getCourse())) {
|
||||||
|
if (showShortToast) {
|
||||||
|
Toast.makeText(this, "Пока нет курса (COG)", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
float b = normalizeBearingTo360(own.getCourse());
|
||||||
|
map.setBearing(b);
|
||||||
|
if (showShortToast) {
|
||||||
|
Toast.makeText(this,
|
||||||
|
String.format(java.util.Locale.US, "По курсу COG (%.0f°)", b),
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyAutoMapBearingIfNeeded(MapInterface map) {
|
||||||
|
if (settingsManager == null || appCoordinator == null || map == null) return;
|
||||||
|
String mode = settingsManager.getMapRotationMode();
|
||||||
|
if (SettingsManager.MAP_ROTATION_MANUAL.equals(mode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Vessel own = appCoordinator.getOwnVessel();
|
||||||
|
if (own == null) return;
|
||||||
|
if (SettingsManager.MAP_ROTATION_COMPASS.equals(mode)) {
|
||||||
|
map.setBearing(normalizeBearingTo360(own.getMagneticCompass()));
|
||||||
|
} else if (SettingsManager.MAP_ROTATION_COURSE.equals(mode)
|
||||||
|
&& !Double.isNaN(own.getCourse())) {
|
||||||
|
map.setBearing(normalizeBearingTo360(own.getCourse()));
|
||||||
|
}
|
||||||
|
} catch (Exception ignore) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshMapRotationButtonDescription() {
|
||||||
|
if (btnMapOrientation == null || settingsManager == null) return;
|
||||||
|
String m = settingsManager.getMapRotationMode();
|
||||||
|
if (SettingsManager.MAP_ROTATION_COMPASS.equals(m)) {
|
||||||
|
btnMapOrientation.setContentDescription("Карта по компасу (нажмите — смена режима)");
|
||||||
|
} else if (SettingsManager.MAP_ROTATION_COURSE.equals(m)) {
|
||||||
|
btnMapOrientation.setContentDescription("Карта по курсу COG (нажмите — смена режима)");
|
||||||
|
} else {
|
||||||
|
btnMapOrientation.setContentDescription("Карта вручную, север вверх (нажмите — смена режима)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -928,6 +1173,125 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
startActivity(intent);
|
startActivity(intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключает источник координат между BLE Hub и Android GPS «на лету»,
|
||||||
|
* обновляет иконку кнопки и уведомляет AppCoordinator.
|
||||||
|
*/
|
||||||
|
private void toggleGpsSource() {
|
||||||
|
if (settingsManager == null) return;
|
||||||
|
String next = settingsManager.toggleGpsSource();
|
||||||
|
refreshGpsSourceButtonIcon();
|
||||||
|
if (appCoordinator != null) {
|
||||||
|
appCoordinator.applyGpsSourceChange();
|
||||||
|
}
|
||||||
|
String label = SettingsManager.GPS_SOURCE_HUB.equals(next)
|
||||||
|
? "Источник: AIS Hub (BLE)"
|
||||||
|
: "Источник: Android GPS";
|
||||||
|
Toast.makeText(this, label, Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Навешивает единый листенер WindowInsets на корень активити и рассыпает
|
||||||
|
* рассчитанные инсеты (system bars + display cutout) по трём ключевым
|
||||||
|
* элементам верхнего слоя: компас, координатный виджет, боковая панель
|
||||||
|
* управления. Благодаря этому контент не прячется за статус-баром,
|
||||||
|
* нав-баром и вырезами камеры, а фоновые прямоугольники продолжают
|
||||||
|
* доходить до физических краёв экрана.
|
||||||
|
*/
|
||||||
|
private Insets lastSysInsets = Insets.NONE;
|
||||||
|
|
||||||
|
private void installMainUiInsets() {
|
||||||
|
View root = findViewById(R.id.main_root);
|
||||||
|
if (root == null) return;
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(root, (v, insets) -> {
|
||||||
|
Insets sys = insets.getInsets(
|
||||||
|
WindowInsetsCompat.Type.systemBars()
|
||||||
|
| WindowInsetsCompat.Type.displayCutout());
|
||||||
|
lastSysInsets = sys;
|
||||||
|
applyInsetsToDocks(sys);
|
||||||
|
if (controlPanel != null) {
|
||||||
|
ViewGroup.LayoutParams rawLp = controlPanel.getLayoutParams();
|
||||||
|
if (rawLp instanceof android.widget.RelativeLayout.LayoutParams) {
|
||||||
|
android.widget.RelativeLayout.LayoutParams lp =
|
||||||
|
(android.widget.RelativeLayout.LayoutParams) rawLp;
|
||||||
|
int newRight = sys.right + Math.round(getResources().getDisplayMetrics().density * 8);
|
||||||
|
if (lp.rightMargin != newRight) {
|
||||||
|
lp.rightMargin = newRight;
|
||||||
|
controlPanel.setLayoutParams(lp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return insets;
|
||||||
|
});
|
||||||
|
// На первом layout инсеты иногда ещё не выданы системой: мы выставляем
|
||||||
|
// слушатель, но callback не приходит до prewarm. Поэтому просим
|
||||||
|
// отложенно — через post — чтобы попасть после attach и первого layout.
|
||||||
|
ViewCompat.requestApplyInsets(root);
|
||||||
|
root.post(() -> ViewCompat.requestApplyInsets(root));
|
||||||
|
root.postDelayed(() -> ViewCompat.requestApplyInsets(root), 200);
|
||||||
|
// Маргин control_panel по вертикали пересчитываем от фактической
|
||||||
|
// высоты доков (она уже включает системные паддинги), чтобы панель
|
||||||
|
// никогда не наползала на компас/координаты при ресайзе.
|
||||||
|
View.OnLayoutChangeListener relayoutControlPanel = (v2, l, t, r, b, ol, ot, orr, ob) -> {
|
||||||
|
if (controlPanel == null) return;
|
||||||
|
ViewGroup.LayoutParams rawLp = controlPanel.getLayoutParams();
|
||||||
|
if (!(rawLp instanceof android.widget.RelativeLayout.LayoutParams)) return;
|
||||||
|
android.widget.RelativeLayout.LayoutParams lp =
|
||||||
|
(android.widget.RelativeLayout.LayoutParams) rawLp;
|
||||||
|
int dp8 = Math.round(getResources().getDisplayMetrics().density * 8);
|
||||||
|
int compassH = compassView != null ? compassView.getHeight() : 0;
|
||||||
|
int coordsH = coordinatesWidget != null ? coordinatesWidget.getHeight() : 0;
|
||||||
|
int newTop = compassH + dp8;
|
||||||
|
int newBottom = coordsH + dp8;
|
||||||
|
if (lp.topMargin != newTop || lp.bottomMargin != newBottom) {
|
||||||
|
lp.topMargin = newTop;
|
||||||
|
lp.bottomMargin = newBottom;
|
||||||
|
controlPanel.setLayoutParams(lp);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (compassView != null) compassView.addOnLayoutChangeListener(relayoutControlPanel);
|
||||||
|
if (coordinatesWidget != null) coordinatesWidget.addOnLayoutChangeListener(relayoutControlPanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применяет системные инсеты к компасу и координатному виджету в
|
||||||
|
* зависимости от того, к какой стороне экрана они пристыкованы.
|
||||||
|
* Если док у верхнего края — добавляем верхний паддинг (статус-бар,
|
||||||
|
* вырез камеры). Если у нижнего — добавляем нижний паддинг под нав-бар.
|
||||||
|
* Боковые паддинги даём всегда (landscape-камеры).
|
||||||
|
*/
|
||||||
|
private void applyInsetsToDocks(Insets sys) {
|
||||||
|
if (compassView != null) {
|
||||||
|
boolean top = compassView.isDockTop();
|
||||||
|
compassView.setPadding(sys.left, top ? sys.top : 0,
|
||||||
|
sys.right, top ? 0 : sys.bottom);
|
||||||
|
}
|
||||||
|
if (coordinatesWidget != null) {
|
||||||
|
boolean top = coordinatesWidget.isDockTop();
|
||||||
|
coordinatesWidget.setPadding(sys.left, top ? sys.top : 0,
|
||||||
|
sys.right, top ? 0 : sys.bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Переприменяет уже собранные инсеты (вызывать при смене стороны дока). */
|
||||||
|
private void reapplyInsetsToDocks() {
|
||||||
|
applyInsetsToDocks(lastSysInsets);
|
||||||
|
View root = findViewById(R.id.main_root);
|
||||||
|
if (root != null) ViewCompat.requestApplyInsets(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshGpsSourceButtonIcon() {
|
||||||
|
if (btnGpsSource == null || settingsManager == null) return;
|
||||||
|
int icon = settingsManager.isGpsFromHub()
|
||||||
|
? R.drawable.ic_gps_source_hub
|
||||||
|
: R.drawable.ic_gps_source_android;
|
||||||
|
btnGpsSource.setImageResource(icon);
|
||||||
|
btnGpsSource.setContentDescription(
|
||||||
|
settingsManager.isGpsFromHub()
|
||||||
|
? "Источник: AIS Hub"
|
||||||
|
: "Источник: Android GPS");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Переключает отображение курсора на карте и сохраняет состояние
|
* Переключает отображение курсора на карте и сохраняет состояние
|
||||||
*/
|
*/
|
||||||
@@ -1108,6 +1472,65 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
// Применяем отложенное центрирование, если было
|
// Применяем отложенное центрирование, если было
|
||||||
applyPendingCenterIfAny();
|
applyPendingCenterIfAny();
|
||||||
|
|
||||||
|
// Старт: инициализируем ownVessel координатами устройства и центрируемся на нём
|
||||||
|
// НО ТОЛЬКО при первом запуске приложения и если нет интента центрирования на сторонний корабль
|
||||||
|
try {
|
||||||
|
Intent currentIntent = getIntent();
|
||||||
|
boolean hasExternalCenterIntent = currentIntent != null &&
|
||||||
|
currentIntent.hasExtra("center_lat") && currentIntent.hasExtra("center_lon");
|
||||||
|
|
||||||
|
if (isFirstStart && !hasExternalCenterIntent && settingsManager != null && settingsManager.isStartCenterOnLastEnabled()) {
|
||||||
|
Log.i(TAG, "Первый запуск: инициализируем ownVessel и центрируемся");
|
||||||
|
android.location.LocationManager lm = (android.location.LocationManager) getSystemService(android.content.Context.LOCATION_SERVICE);
|
||||||
|
android.location.Location lastLoc = null;
|
||||||
|
if (lm != null) {
|
||||||
|
// Пробуем GPS, затем NETWORK
|
||||||
|
try { lastLoc = lm.getLastKnownLocation(android.location.LocationManager.GPS_PROVIDER); } catch (Exception ignore) {}
|
||||||
|
if (lastLoc == null) {
|
||||||
|
try { lastLoc = lm.getLastKnownLocation(android.location.LocationManager.NETWORK_PROVIDER); } catch (Exception ignore) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastLoc != null) {
|
||||||
|
double lat = lastLoc.getLatitude();
|
||||||
|
double lon = lastLoc.getLongitude();
|
||||||
|
Log.i(TAG, "Первый запуск: seed ownVessel из Android LastKnownLocation " + lat + "," + lon);
|
||||||
|
if (appCoordinator != null) {
|
||||||
|
appCoordinator.seedOwnVesselFromDeviceLocation(lat, lon);
|
||||||
|
appCoordinator.centerOnOwnVessel();
|
||||||
|
// Повторим центрирование чуть позже, когда стиль точно загрузится
|
||||||
|
mapView.postDelayed(() -> {
|
||||||
|
try {
|
||||||
|
appCoordinator.centerOnOwnVessel();
|
||||||
|
} catch (Exception ignore) {}
|
||||||
|
}, 500);
|
||||||
|
} else if (mapController.getCurrentMapInterface() != null) {
|
||||||
|
// fallback
|
||||||
|
mapController.getCurrentMapInterface().centerOnPosition(lat, lon);
|
||||||
|
}
|
||||||
|
float startZoom = settingsManager.getStartZoomLevel();
|
||||||
|
if (startZoom > 0f) {
|
||||||
|
mapController.getCurrentMapInterface().setZoom(startZoom);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "Первый запуск: LastKnownLocation отсутствует");
|
||||||
|
}
|
||||||
|
// Отмечаем, что первый запуск завершен
|
||||||
|
isFirstStart = false;
|
||||||
|
} else if (hasExternalCenterIntent) {
|
||||||
|
Log.i(TAG, "Первый запуск с интентом центрирования на сторонний корабль - пропускаем центрирование на собственный");
|
||||||
|
// Отмечаем, что первый запуск завершен
|
||||||
|
isFirstStart = false;
|
||||||
|
} else if (!isFirstStart) {
|
||||||
|
Log.i(TAG, "Не первый запуск - пропускаем центрирование на собственный корабль");
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "Первый запуск, но центрирование отключено в настройках");
|
||||||
|
// Отмечаем, что первый запуск завершен
|
||||||
|
isFirstStart = false;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Ошибка стартовой инициализации позиции/центрирования: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
// Отслеживание путей для MapLibre будет добавлено позже
|
// Отслеживание путей для MapLibre будет добавлено позже
|
||||||
|
|
||||||
@@ -1149,6 +1572,12 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
// Перезапускаем цикл поворота кнопок после возврата в активити
|
// Перезапускаем цикл поворота кнопок после возврата в активити
|
||||||
startCompassButtonsLoop();
|
startCompassButtonsLoop();
|
||||||
|
|
||||||
|
// Старт FPS трекера
|
||||||
|
if (tvFps != null) {
|
||||||
|
android.view.Choreographer.getInstance().removeFrameCallback(fpsCallback);
|
||||||
|
android.view.Choreographer.getInstance().postFrameCallback(fpsCallback);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -1233,6 +1662,9 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
uiThrottleHandler.removeCallbacks(coordinatesUpdateRunnable);
|
uiThrottleHandler.removeCallbacks(coordinatesUpdateRunnable);
|
||||||
uiThrottleHandler.removeCallbacks(compassButtonRotationRunnable);
|
uiThrottleHandler.removeCallbacks(compassButtonRotationRunnable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Останавливаем FPS трекер
|
||||||
|
try { android.view.Choreographer.getInstance().removeFrameCallback(fpsCallback); } catch (Exception ignore) {}
|
||||||
|
|
||||||
// Не останавливаем слушатели здесь, чтобы UDP продолжал работать в фоне
|
// Не останавливаем слушатели здесь, чтобы UDP продолжал работать в фоне
|
||||||
// if (appController != null) {
|
// if (appController != null) {
|
||||||
@@ -1275,6 +1707,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
uiWatchdogHandler.removeCallbacks(uiWatchdogRunnable);
|
uiWatchdogHandler.removeCallbacks(uiWatchdogRunnable);
|
||||||
Log.i(TAG, "UI watchdog остановлен");
|
Log.i(TAG, "UI watchdog остановлен");
|
||||||
}
|
}
|
||||||
|
try { uiWatchdogScheduler.shutdownNow(); } catch (Throwable ignore) {}
|
||||||
|
|
||||||
// Останавливаем throttling handler для control panel
|
// Останавливаем throttling handler для control panel
|
||||||
if (controlPanelUpdateHandler != null && controlPanelUpdateRunnable != null) {
|
if (controlPanelUpdateHandler != null && controlPanelUpdateRunnable != null) {
|
||||||
@@ -1385,6 +1818,12 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
((MapLibreMapImpl) mapController.getCurrentMapInterface()).setDebugMode(debugEnabled);
|
((MapLibreMapImpl) mapController.getCurrentMapInterface()).setDebugMode(debugEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Применяем настройки морских знаков
|
||||||
|
boolean seamarksEnabled = data.getBooleanExtra("seamarks_enabled", settingsManager.isSeamarksEnabled());
|
||||||
|
if (mapController.getCurrentMapInterface() instanceof MapLibreMapImpl) {
|
||||||
|
((MapLibreMapImpl) mapController.getCurrentMapInterface()).updateAdditionalLayers();
|
||||||
|
}
|
||||||
|
|
||||||
if (needsRestart) {
|
if (needsRestart) {
|
||||||
Log.i(TAG, "Требуется перезапуск сервисов");
|
Log.i(TAG, "Требуется перезапуск сервисов");
|
||||||
restartServices();
|
restartServices();
|
||||||
@@ -1392,7 +1831,8 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
Log.i(TAG, "Применяем настройки без перезапуска");
|
Log.i(TAG, "Применяем настройки без перезапуска");
|
||||||
applySettings();
|
applySettings();
|
||||||
}
|
}
|
||||||
|
refreshGpsSourceButtonIcon();
|
||||||
|
|
||||||
Toast.makeText(this, "Настройки применены", Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, "Настройки применены", Toast.LENGTH_SHORT).show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1442,6 +1882,9 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
} else if (id == R.id.menu_keep_screen_on) {
|
} else if (id == R.id.menu_keep_screen_on) {
|
||||||
toggleKeepScreenOn();
|
toggleKeepScreenOn();
|
||||||
return true;
|
return true;
|
||||||
|
} else if (id == R.id.menu_seamarks) {
|
||||||
|
toggleSeamarks();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return super.onOptionsItemSelected(item);
|
return super.onOptionsItemSelected(item);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import android.widget.EditText;
|
|||||||
import android.widget.RadioButton;
|
import android.widget.RadioButton;
|
||||||
import android.widget.RadioGroup;
|
import android.widget.RadioGroup;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
|
||||||
import com.google.android.material.switchmaterial.SwitchMaterial;
|
import com.google.android.material.switchmaterial.SwitchMaterial;
|
||||||
|
|
||||||
@@ -33,15 +35,24 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
private RadioButton radioHybridMode;
|
private RadioButton radioHybridMode;
|
||||||
private RadioButton radioNMEAOnly;
|
private RadioButton radioNMEAOnly;
|
||||||
private RadioButton radioAndroidOnly;
|
private RadioButton radioAndroidOnly;
|
||||||
|
private RadioGroup radioGroupGpsSource;
|
||||||
|
private RadioButton radioGpsSourceHub;
|
||||||
|
private RadioButton radioGpsSourceAndroid;
|
||||||
|
private SwitchMaterial switchShowAdvancedNmea;
|
||||||
|
private LinearLayout llAdvancedNmeaSection;
|
||||||
private EditText etStaleWarningMinutes;
|
private EditText etStaleWarningMinutes;
|
||||||
private EditText etStaleRemoveMinutes;
|
private EditText etStaleRemoveMinutes;
|
||||||
private SwitchMaterial switchVibrationEnabled;
|
private SwitchMaterial switchVibrationEnabled;
|
||||||
private SwitchMaterial switchSoundEnabled;
|
private SwitchMaterial switchSoundEnabled;
|
||||||
private SwitchMaterial switchKeepScreenOn;
|
private SwitchMaterial switchKeepScreenOn;
|
||||||
private SwitchMaterial switchDebugEnabled;
|
private SwitchMaterial switchDebugEnabled;
|
||||||
|
private SwitchMaterial switchSeamarksEnabled;
|
||||||
private Button btnCancel;
|
private Button btnCancel;
|
||||||
private Button btnSave;
|
private Button btnSave;
|
||||||
private Button btnClearPath;
|
private Button btnClearPath;
|
||||||
|
private Button btnOpenInterfaces;
|
||||||
|
private com.google.android.material.textfield.TextInputLayout tilOpenInterfaces;
|
||||||
|
private EditText etOpenInterfaces;
|
||||||
|
|
||||||
// Path/prediction
|
// Path/prediction
|
||||||
private EditText etPathMaxPoints;
|
private EditText etPathMaxPoints;
|
||||||
@@ -92,19 +103,29 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
*/
|
*/
|
||||||
private void initializeViews() {
|
private void initializeViews() {
|
||||||
etUDPPort = findViewById(R.id.et_udp_port);
|
etUDPPort = findViewById(R.id.et_udp_port);
|
||||||
switchUDPEnabled = findViewById(R.id.switch_udp_enabled);
|
// UDP элементы перенесены на экран интерфейсов; здесь найдём только кнопку перехода
|
||||||
|
// Кнопка могла быть удалена из разметки: не инициализируем её по id
|
||||||
|
tilOpenInterfaces = findViewById(R.id.til_open_interfaces);
|
||||||
|
etOpenInterfaces = findViewById(R.id.et_open_interfaces);
|
||||||
|
switchUDPEnabled = findViewById(R.id.switch_udp_enabled); // может отсутствовать в новой разметке
|
||||||
switchAndroidNMEAEnabled = findViewById(R.id.switch_android_nmea_enabled);
|
switchAndroidNMEAEnabled = findViewById(R.id.switch_android_nmea_enabled);
|
||||||
switchUDPNMEAEnabled = findViewById(R.id.switch_udp_nmea_enabled);
|
switchUDPNMEAEnabled = findViewById(R.id.switch_udp_nmea_enabled);
|
||||||
radioGroupDataMode = findViewById(R.id.radio_group_data_mode);
|
radioGroupDataMode = findViewById(R.id.radio_group_data_mode);
|
||||||
radioHybridMode = findViewById(R.id.radio_hybrid_mode);
|
radioHybridMode = findViewById(R.id.radio_hybrid_mode);
|
||||||
radioNMEAOnly = findViewById(R.id.radio_nmea_only);
|
radioNMEAOnly = findViewById(R.id.radio_nmea_only);
|
||||||
radioAndroidOnly = findViewById(R.id.radio_android_only);
|
radioAndroidOnly = findViewById(R.id.radio_android_only);
|
||||||
|
radioGroupGpsSource = findViewById(R.id.radio_group_gps_source);
|
||||||
|
radioGpsSourceHub = findViewById(R.id.radio_gps_source_hub);
|
||||||
|
radioGpsSourceAndroid = findViewById(R.id.radio_gps_source_android);
|
||||||
|
switchShowAdvancedNmea = findViewById(R.id.switch_show_advanced_nmea);
|
||||||
|
llAdvancedNmeaSection = findViewById(R.id.ll_advanced_nmea_section);
|
||||||
etStaleWarningMinutes = findViewById(R.id.et_stale_warning_minutes);
|
etStaleWarningMinutes = findViewById(R.id.et_stale_warning_minutes);
|
||||||
etStaleRemoveMinutes = findViewById(R.id.et_stale_remove_minutes);
|
etStaleRemoveMinutes = findViewById(R.id.et_stale_remove_minutes);
|
||||||
switchVibrationEnabled = findViewById(R.id.switch_vibration_enabled);
|
switchVibrationEnabled = findViewById(R.id.switch_vibration_enabled);
|
||||||
switchSoundEnabled = findViewById(R.id.switch_sound_enabled);
|
switchSoundEnabled = findViewById(R.id.switch_sound_enabled);
|
||||||
switchKeepScreenOn = findViewById(R.id.switch_keep_screen_on);
|
switchKeepScreenOn = findViewById(R.id.switch_keep_screen_on);
|
||||||
switchDebugEnabled = findViewById(R.id.switch_debug_enabled);
|
switchDebugEnabled = findViewById(R.id.switch_debug_enabled);
|
||||||
|
switchSeamarksEnabled = findViewById(R.id.switch_seamarks_enabled);
|
||||||
btnCancel = findViewById(R.id.btn_cancel);
|
btnCancel = findViewById(R.id.btn_cancel);
|
||||||
btnSave = findViewById(R.id.btn_save);
|
btnSave = findViewById(R.id.btn_save);
|
||||||
btnClearPath = findViewById(R.id.btn_clear_path);
|
btnClearPath = findViewById(R.id.btn_clear_path);
|
||||||
@@ -122,14 +143,23 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
*/
|
*/
|
||||||
private void loadCurrentSettings() {
|
private void loadCurrentSettings() {
|
||||||
// UDP настройки
|
// UDP настройки
|
||||||
etUDPPort.setText(String.valueOf(settingsManager.getUDPPort()));
|
if (etUDPPort != null) etUDPPort.setText(String.valueOf(settingsManager.getUDPPort()));
|
||||||
switchUDPEnabled.setChecked(settingsManager.isUDPEnabled());
|
if (switchUDPEnabled != null) switchUDPEnabled.setChecked(settingsManager.isUDPEnabled());
|
||||||
|
|
||||||
// NMEA настройки
|
// NMEA настройки
|
||||||
switchAndroidNMEAEnabled.setChecked(settingsManager.isAndroidNMEAEnabled());
|
switchAndroidNMEAEnabled.setChecked(settingsManager.isAndroidNMEAEnabled());
|
||||||
switchUDPNMEAEnabled.setChecked(settingsManager.isUDPNMEAEnabled());
|
switchUDPNMEAEnabled.setChecked(settingsManager.isUDPNMEAEnabled());
|
||||||
|
|
||||||
// Режим данных
|
// Источник координат (основной переключатель).
|
||||||
|
if (radioGpsSourceHub != null && radioGpsSourceAndroid != null) {
|
||||||
|
if (settingsManager.isGpsFromAndroid()) {
|
||||||
|
radioGpsSourceAndroid.setChecked(true);
|
||||||
|
} else {
|
||||||
|
radioGpsSourceHub.setChecked(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy режим данных (внутри расширенной секции).
|
||||||
String dataMode = settingsManager.getDataMode();
|
String dataMode = settingsManager.getDataMode();
|
||||||
switch (dataMode) {
|
switch (dataMode) {
|
||||||
case SettingsManager.DATA_MODE_HYBRID:
|
case SettingsManager.DATA_MODE_HYBRID:
|
||||||
@@ -156,6 +186,7 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
// Дебаг
|
// Дебаг
|
||||||
switchDebugEnabled.setChecked(settingsManager.isDebugEnabled());
|
switchDebugEnabled.setChecked(settingsManager.isDebugEnabled());
|
||||||
|
switchSeamarksEnabled.setChecked(settingsManager.isSeamarksEnabled());
|
||||||
|
|
||||||
// Путь и предсказание
|
// Путь и предсказание
|
||||||
etPathMaxPoints.setText(String.valueOf(settingsManager.getPathMaxPoints()));
|
etPathMaxPoints.setText(String.valueOf(settingsManager.getPathMaxPoints()));
|
||||||
@@ -195,6 +226,22 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
Log.i(TAG, "Нажата кнопка отмены");
|
Log.i(TAG, "Нажата кнопка отмены");
|
||||||
finish();
|
finish();
|
||||||
});
|
});
|
||||||
|
if (btnOpenInterfaces != null) {
|
||||||
|
btnOpenInterfaces.setOnClickListener(v -> openInterfacesSettings());
|
||||||
|
}
|
||||||
|
if (tilOpenInterfaces != null) {
|
||||||
|
tilOpenInterfaces.setEndIconOnClickListener(v -> openInterfacesSettings());
|
||||||
|
tilOpenInterfaces.setOnClickListener(v -> openInterfacesSettings());
|
||||||
|
}
|
||||||
|
if (etOpenInterfaces != null) {
|
||||||
|
etOpenInterfaces.setOnClickListener(v -> openInterfacesSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Секция "Расширенные NMEA-источники" скрыта по умолчанию и разворачивается по свитчу.
|
||||||
|
if (switchShowAdvancedNmea != null && llAdvancedNmeaSection != null) {
|
||||||
|
switchShowAdvancedNmea.setOnCheckedChangeListener((btn, checked) ->
|
||||||
|
llAdvancedNmeaSection.setVisibility(checked ? View.VISIBLE : View.GONE));
|
||||||
|
}
|
||||||
|
|
||||||
// Кнопка сохранения
|
// Кнопка сохранения
|
||||||
btnSave.setOnClickListener(v -> {
|
btnSave.setOnClickListener(v -> {
|
||||||
@@ -222,6 +269,16 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
validateDataModeSettings();
|
validateDataModeSettings();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void openInterfacesSettings() {
|
||||||
|
try {
|
||||||
|
Intent i = new Intent(SettingsActivity.this, com.grigowashere.aismap.settings.InterfacesSettingsActivity.class);
|
||||||
|
startActivity(i);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Ошибка открытия настроек интерфейсов: " + e.getMessage());
|
||||||
|
Toast.makeText(SettingsActivity.this, "Не удалось открыть интерфейсы", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обновляет описание режима данных
|
* Обновляет описание режима данных
|
||||||
@@ -262,23 +319,27 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
*/
|
*/
|
||||||
private void saveSettings() {
|
private void saveSettings() {
|
||||||
try {
|
try {
|
||||||
// Валидируем UDP порт
|
// Валидируем UDP порт (поле могло быть перенесено на экран интерфейсов и отсутствовать в разметке)
|
||||||
String portText = etUDPPort.getText().toString().trim();
|
|
||||||
if (portText.isEmpty()) {
|
|
||||||
Toast.makeText(this, "Порт не может быть пустым", Toast.LENGTH_SHORT).show();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int udpPort;
|
int udpPort;
|
||||||
try {
|
if (etUDPPort != null) {
|
||||||
udpPort = Integer.parseInt(portText);
|
String portText = etUDPPort.getText().toString().trim();
|
||||||
if (udpPort < 1 || udpPort > 65535) {
|
if (portText.isEmpty()) {
|
||||||
Toast.makeText(this, "Порт должен быть от 1 до 65535", Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, "Порт не может быть пустым", Toast.LENGTH_SHORT).show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (NumberFormatException e) {
|
try {
|
||||||
Toast.makeText(this, "Некорректный формат порта", Toast.LENGTH_SHORT).show();
|
udpPort = Integer.parseInt(portText);
|
||||||
return;
|
if (udpPort < 1 || udpPort > 65535) {
|
||||||
|
Toast.makeText(this, "Порт должен быть от 1 до 65535", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
Toast.makeText(this, "Некорректный формат порта", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Если поля нет на этом экране — используем текущее сохранённое значение
|
||||||
|
udpPort = settingsManager.getUDPPort();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем выбранный режим данных
|
// Получаем выбранный режим данных
|
||||||
@@ -303,11 +364,20 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Сохраняем настройки
|
// Сохраняем настройки
|
||||||
settingsManager.setUDPPort(udpPort);
|
if (etUDPPort != null) settingsManager.setUDPPort(udpPort);
|
||||||
settingsManager.setUDPEnabled(switchUDPEnabled.isChecked());
|
if (switchUDPEnabled != null) settingsManager.setUDPEnabled(switchUDPEnabled.isChecked());
|
||||||
settingsManager.setAndroidNMEAEnabled(switchAndroidNMEAEnabled.isChecked());
|
settingsManager.setAndroidNMEAEnabled(switchAndroidNMEAEnabled.isChecked());
|
||||||
settingsManager.setUDPNMEAEnabled(switchUDPNMEAEnabled.isChecked());
|
settingsManager.setUDPNMEAEnabled(switchUDPNMEAEnabled.isChecked());
|
||||||
settingsManager.setDataMode(dataMode);
|
settingsManager.setDataMode(dataMode);
|
||||||
|
// Источник координат (независим от legacy dataMode).
|
||||||
|
if (radioGroupGpsSource != null) {
|
||||||
|
int checkedGps = radioGroupGpsSource.getCheckedRadioButtonId();
|
||||||
|
if (checkedGps == R.id.radio_gps_source_android) {
|
||||||
|
settingsManager.setGpsSource(SettingsManager.GPS_SOURCE_ANDROID);
|
||||||
|
} else {
|
||||||
|
settingsManager.setGpsSource(SettingsManager.GPS_SOURCE_HUB);
|
||||||
|
}
|
||||||
|
}
|
||||||
settingsManager.setDataStaleWarningMinutes(staleWarningMinutes);
|
settingsManager.setDataStaleWarningMinutes(staleWarningMinutes);
|
||||||
settingsManager.setDataStaleRemoveMinutes(staleRemoveMinutes);
|
settingsManager.setDataStaleRemoveMinutes(staleRemoveMinutes);
|
||||||
settingsManager.setVibrationEnabled(switchVibrationEnabled.isChecked());
|
settingsManager.setVibrationEnabled(switchVibrationEnabled.isChecked());
|
||||||
@@ -315,6 +385,10 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
settingsManager.setKeepScreenOnEnabled(switchKeepScreenOn.isChecked());
|
settingsManager.setKeepScreenOnEnabled(switchKeepScreenOn.isChecked());
|
||||||
boolean debugEnabled = switchDebugEnabled.isChecked();
|
boolean debugEnabled = switchDebugEnabled.isChecked();
|
||||||
settingsManager.setDebugEnabled(debugEnabled);
|
settingsManager.setDebugEnabled(debugEnabled);
|
||||||
|
|
||||||
|
// Морские знаки
|
||||||
|
boolean seamarksEnabled = switchSeamarksEnabled.isChecked();
|
||||||
|
settingsManager.setSeamarksEnabled(seamarksEnabled);
|
||||||
|
|
||||||
// Путь и предсказание
|
// Путь и предсказание
|
||||||
try { settingsManager.setPathMaxPoints(Integer.parseInt(etPathMaxPoints.getText().toString().trim())); } catch (Exception ignored) {}
|
try { settingsManager.setPathMaxPoints(Integer.parseInt(etPathMaxPoints.getText().toString().trim())); } catch (Exception ignored) {}
|
||||||
@@ -334,12 +408,14 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
resultIntent.putExtra("settings_changed", true);
|
resultIntent.putExtra("settings_changed", true);
|
||||||
resultIntent.putExtra("needs_restart", needsRestart);
|
resultIntent.putExtra("needs_restart", needsRestart);
|
||||||
resultIntent.putExtra("udp_port", udpPort);
|
resultIntent.putExtra("udp_port", udpPort);
|
||||||
resultIntent.putExtra("udp_enabled", switchUDPEnabled.isChecked());
|
boolean udpEnabledVal = (switchUDPEnabled != null) ? switchUDPEnabled.isChecked() : settingsManager.isUDPEnabled();
|
||||||
|
resultIntent.putExtra("udp_enabled", udpEnabledVal);
|
||||||
resultIntent.putExtra("android_nmea_enabled", switchAndroidNMEAEnabled.isChecked());
|
resultIntent.putExtra("android_nmea_enabled", switchAndroidNMEAEnabled.isChecked());
|
||||||
resultIntent.putExtra("udp_nmea_enabled", switchUDPNMEAEnabled.isChecked());
|
resultIntent.putExtra("udp_nmea_enabled", switchUDPNMEAEnabled.isChecked());
|
||||||
resultIntent.putExtra("data_mode", dataMode);
|
resultIntent.putExtra("data_mode", dataMode);
|
||||||
resultIntent.putExtra("cursor_enabled", settingsManager.isCursorEnabled());
|
resultIntent.putExtra("cursor_enabled", settingsManager.isCursorEnabled());
|
||||||
resultIntent.putExtra("debug_enabled", debugEnabled);
|
resultIntent.putExtra("debug_enabled", debugEnabled);
|
||||||
|
resultIntent.putExtra("seamarks_enabled", seamarksEnabled);
|
||||||
|
|
||||||
setResult(RESULT_OK, resultIntent);
|
setResult(RESULT_OK, resultIntent);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package com.grigowashere.aismap.ble.hub;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reassembles chunked logical messages keyed by (session_msg_id, msg_type).
|
||||||
|
*
|
||||||
|
* Payload may be JSON or MessagePack depending on the protocol-version byte of
|
||||||
|
* the incoming frames (see {@link AisHubConstants#PROTO_VERSION_JSON} /
|
||||||
|
* {@link AisHubConstants#PROTO_VERSION_MSGPACK}). The assembler stores the
|
||||||
|
* version of the first frame of an assembly and returns it to the caller when
|
||||||
|
* the message is complete; the caller then chooses the right decoder.
|
||||||
|
*/
|
||||||
|
public class AisHubChunkAssembler {
|
||||||
|
|
||||||
|
public static final class FeedStatus {
|
||||||
|
/**
|
||||||
|
* Non-null only when the whole message is complete.
|
||||||
|
* Historically this was a JSON string; it's kept for backward
|
||||||
|
* compatibility with callers that only ever saw JSON on the wire.
|
||||||
|
* When the assembly's proto version is MessagePack, {@link #json} is
|
||||||
|
* {@code null} even at completion — use {@link #payload} +
|
||||||
|
* {@link #protocolVersion} instead.
|
||||||
|
*/
|
||||||
|
public final String json;
|
||||||
|
/**
|
||||||
|
* Full reassembled payload bytes (always non-null when the message is
|
||||||
|
* complete, regardless of encoding). Callers that support both JSON
|
||||||
|
* and MessagePack should decode from this field and ignore
|
||||||
|
* {@link #json}.
|
||||||
|
*/
|
||||||
|
public final byte[] payload;
|
||||||
|
/** Protocol-version byte of the reassembled message (matches {@link AisHubConstants}). */
|
||||||
|
public final int protocolVersion;
|
||||||
|
public final int received;
|
||||||
|
public final int chunkCount;
|
||||||
|
/** True if we detected mismatch and reset assembly state. */
|
||||||
|
public final boolean wasReset;
|
||||||
|
|
||||||
|
FeedStatus(String json, byte[] payload, int protocolVersion,
|
||||||
|
int received, int chunkCount, boolean wasReset) {
|
||||||
|
this.json = json;
|
||||||
|
this.payload = payload;
|
||||||
|
this.protocolVersion = protocolVersion;
|
||||||
|
this.received = received;
|
||||||
|
this.chunkCount = chunkCount;
|
||||||
|
this.wasReset = wasReset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Key {
|
||||||
|
final int sessionId;
|
||||||
|
final int msgType;
|
||||||
|
|
||||||
|
Key(int sessionId, int msgType) {
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
this.msgType = msgType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (!(o instanceof Key)) return false;
|
||||||
|
Key key = (Key) o;
|
||||||
|
return sessionId == key.sessionId && msgType == key.msgType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return 31 * sessionId + msgType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Assembly {
|
||||||
|
final int chunkCount;
|
||||||
|
final byte[][] parts;
|
||||||
|
int received;
|
||||||
|
// Protocol version taken from the first frame of this assembly.
|
||||||
|
// We pin it so that a rogue frame with a mismatched version cannot
|
||||||
|
// silently change how we decode the combined payload.
|
||||||
|
int protocolVersion = -1;
|
||||||
|
|
||||||
|
Assembly(int chunkCount) {
|
||||||
|
this.chunkCount = chunkCount;
|
||||||
|
this.parts = new byte[chunkCount][];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Map<Key, Assembly> pending = new HashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed one frame; returns complete UTF-8 JSON string or null if more chunks needed.
|
||||||
|
* <p>
|
||||||
|
* <b>Deprecated for mixed encodings:</b> MessagePack payloads will return
|
||||||
|
* {@code null} here even when the message is complete. Use
|
||||||
|
* {@link #feedStatus(AisHubFrame)} and inspect
|
||||||
|
* {@link FeedStatus#payload} + {@link FeedStatus#protocolVersion} instead.
|
||||||
|
*/
|
||||||
|
public String feed(AisHubFrame frame) {
|
||||||
|
FeedStatus st = feedStatus(frame);
|
||||||
|
return st != null ? st.json : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link #feed(AisHubFrame)}, but also returns progress info for logging
|
||||||
|
* and the raw reassembled payload + protocol version on completion.
|
||||||
|
*/
|
||||||
|
public FeedStatus feedStatus(AisHubFrame frame) {
|
||||||
|
if (frame.chunkCount <= 0 || frame.chunkIndex < 0 || frame.chunkIndex >= frame.chunkCount) {
|
||||||
|
return new FeedStatus(null, null, frame.protocolVersion, 0, frame.chunkCount, false);
|
||||||
|
}
|
||||||
|
Key key = new Key(frame.sessionMsgId, frame.msgType);
|
||||||
|
Assembly a = pending.get(key);
|
||||||
|
boolean wasReset = false;
|
||||||
|
if (a == null) {
|
||||||
|
a = new Assembly(frame.chunkCount);
|
||||||
|
a.protocolVersion = frame.protocolVersion;
|
||||||
|
pending.put(key, a);
|
||||||
|
} else if (a.chunkCount != frame.chunkCount || a.protocolVersion != frame.protocolVersion) {
|
||||||
|
// Either the server changed its mind about the chunk count (unlikely
|
||||||
|
// but defensively handled) or the encoding — treat as a brand new
|
||||||
|
// assembly. Can happen if a previous message was lost and we're now
|
||||||
|
// seeing the start of the next one under the same (sid, msg_type).
|
||||||
|
pending.remove(key);
|
||||||
|
a = new Assembly(frame.chunkCount);
|
||||||
|
a.protocolVersion = frame.protocolVersion;
|
||||||
|
pending.put(key, a);
|
||||||
|
wasReset = true;
|
||||||
|
}
|
||||||
|
if (a.parts[frame.chunkIndex] == null) {
|
||||||
|
a.received++;
|
||||||
|
}
|
||||||
|
a.parts[frame.chunkIndex] = Arrays.copyOf(frame.payload, frame.payload.length);
|
||||||
|
if (a.received < a.chunkCount) {
|
||||||
|
return new FeedStatus(null, null, a.protocolVersion, a.received, a.chunkCount, wasReset);
|
||||||
|
}
|
||||||
|
int total = 0;
|
||||||
|
for (byte[] p : a.parts) {
|
||||||
|
if (p != null) total += p.length;
|
||||||
|
}
|
||||||
|
byte[] out = new byte[total];
|
||||||
|
int pos = 0;
|
||||||
|
for (byte[] p : a.parts) {
|
||||||
|
if (p != null) {
|
||||||
|
System.arraycopy(p, 0, out, pos, p.length);
|
||||||
|
pos += p.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending.remove(key);
|
||||||
|
// Build the JSON string only for legacy JSON payloads, to keep old
|
||||||
|
// callers (that read FeedStatus.json directly) working as-is.
|
||||||
|
String jsonStr = (a.protocolVersion == AisHubConstants.PROTO_VERSION_JSON)
|
||||||
|
? new String(out, StandardCharsets.UTF_8)
|
||||||
|
: null;
|
||||||
|
return new FeedStatus(jsonStr, out, a.protocolVersion, a.received, a.chunkCount, wasReset);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clear() {
|
||||||
|
pending.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.grigowashere.aismap.ble.hub;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BLE AIS Hub (protocol v2) — UUID and message type constants.
|
||||||
|
*/
|
||||||
|
public final class AisHubConstants {
|
||||||
|
|
||||||
|
private AisHubConstants() {}
|
||||||
|
|
||||||
|
public static final UUID SERVICE_UUID =
|
||||||
|
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0001");
|
||||||
|
public static final UUID CONTROL_UUID =
|
||||||
|
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0002");
|
||||||
|
public static final UUID DATA_UUID =
|
||||||
|
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0003");
|
||||||
|
public static final UUID STATUS_UUID =
|
||||||
|
UUID.fromString("34b5f2a0-5b23-4c5a-9b2a-3c4c1a9c0004");
|
||||||
|
|
||||||
|
public static final int HEADER_SIZE = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protocol version byte values (first byte of every DATA frame header).
|
||||||
|
* The server writes this per-frame based on the encoding it chose for
|
||||||
|
* that message (negotiated via the "hello" command, or from the
|
||||||
|
* AIS_BLE_BROADCAST_ENCODING env for CCCD broadcast).
|
||||||
|
*/
|
||||||
|
public static final int PROTO_VERSION_JSON = 0x01;
|
||||||
|
public static final int PROTO_VERSION_MSGPACK = 0x02;
|
||||||
|
|
||||||
|
/** Server → client DATA msg_type */
|
||||||
|
public static final int MSG_HELLO_ACK = 0x01;
|
||||||
|
public static final int MSG_SNAPSHOT_BEGIN = 0x02;
|
||||||
|
public static final int MSG_SNAPSHOT_CHUNK = 0x03;
|
||||||
|
public static final int MSG_SNAPSHOT_END = 0x04;
|
||||||
|
public static final int MSG_EVENT = 0x05;
|
||||||
|
public static final int MSG_STATUS = 0x06;
|
||||||
|
public static final int MSG_ERROR = 0x07;
|
||||||
|
public static final int MSG_PONG = 0x08;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.grigowashere.aismap.ble.hub;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One DATA notify frame: 10-byte header + payload.
|
||||||
|
*/
|
||||||
|
public class AisHubFrame {
|
||||||
|
public final int protocolVersion;
|
||||||
|
public final int msgType;
|
||||||
|
public final int sessionMsgId;
|
||||||
|
public final int chunkIndex;
|
||||||
|
public final int chunkCount;
|
||||||
|
public final byte[] payload;
|
||||||
|
|
||||||
|
public AisHubFrame(int protocolVersion, int msgType, int sessionMsgId,
|
||||||
|
int chunkIndex, int chunkCount, byte[] payload) {
|
||||||
|
this.protocolVersion = protocolVersion;
|
||||||
|
this.msgType = msgType;
|
||||||
|
this.sessionMsgId = sessionMsgId;
|
||||||
|
this.chunkIndex = chunkIndex;
|
||||||
|
this.chunkCount = chunkCount;
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return null if buffer too short or truncated
|
||||||
|
*/
|
||||||
|
public static AisHubFrame parse(byte[] buf) {
|
||||||
|
if (buf == null || buf.length < AisHubConstants.HEADER_SIZE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ByteBuffer bb = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN);
|
||||||
|
int pv = bb.get() & 0xFF;
|
||||||
|
int mt = bb.get() & 0xFF;
|
||||||
|
int sid = bb.getShort() & 0xFFFF;
|
||||||
|
int cidx = bb.getShort() & 0xFFFF;
|
||||||
|
int ccnt = bb.getShort() & 0xFFFF;
|
||||||
|
int plen = bb.getShort() & 0xFFFF;
|
||||||
|
if (buf.length < AisHubConstants.HEADER_SIZE + plen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
byte[] payload = new byte[plen];
|
||||||
|
System.arraycopy(buf, AisHubConstants.HEADER_SIZE, payload, 0, plen);
|
||||||
|
return new AisHubFrame(pv, mt, sid, cidx, ccnt, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,815 @@
|
|||||||
|
package com.grigowashere.aismap.ble.hub;
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothAdapter;
|
||||||
|
import android.bluetooth.BluetoothDevice;
|
||||||
|
import android.bluetooth.BluetoothGatt;
|
||||||
|
import android.bluetooth.BluetoothGattCallback;
|
||||||
|
import android.bluetooth.BluetoothGattCharacteristic;
|
||||||
|
import android.bluetooth.BluetoothGattDescriptor;
|
||||||
|
import android.bluetooth.BluetoothGattService;
|
||||||
|
import android.bluetooth.BluetoothManager;
|
||||||
|
import android.bluetooth.BluetoothProfile;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.pm.PackageInfo;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.grigowashere.aismap.BuildConfig;
|
||||||
|
import com.grigowashere.aismap.utils.LogSender;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ArrayBlockingQueue;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GATT client for AIS Hub protocol v2: CONTROL writes (JSON), DATA binary frames, optional battery/RSSI.
|
||||||
|
*/
|
||||||
|
public class AisHubGattClient {
|
||||||
|
|
||||||
|
private static final String TAG = "AisHubGattClient";
|
||||||
|
private static final boolean BLE_LOG = BuildConfig.DEBUG;
|
||||||
|
private static final UUID CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
|
||||||
|
private static final UUID BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb");
|
||||||
|
private static final UUID BATTERY_LEVEL = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb");
|
||||||
|
|
||||||
|
public interface SessionCallback {
|
||||||
|
void onState(@NonNull String state);
|
||||||
|
void onError(@NonNull String message);
|
||||||
|
void onRssi(int rssi);
|
||||||
|
void onBatteryPercent(int percent);
|
||||||
|
/** Reassembled JSON from DATA, after chunk merge (per msg_type in protocol). */
|
||||||
|
void onDataJson(int msgType, @NonNull JSONObject json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Context appContext;
|
||||||
|
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
|
// executor: used ONLY for short-lived, non-blocking tasks (attemptConnect body).
|
||||||
|
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||||
|
// dataExecutor: dedicated to processDataRaw so that BLE notifications
|
||||||
|
// are never blocked behind long-running loops (RSSI, reconnect, watchdog).
|
||||||
|
// Using single-thread to preserve in-order chunk assembly semantics.
|
||||||
|
private final ExecutorService dataExecutor = Executors.newSingleThreadExecutor();
|
||||||
|
// scheduler: replaces Thread.sleep-based loops (RSSI, watchdog, reconnect backoff)
|
||||||
|
// so they don't hog the general executor and starve other tasks.
|
||||||
|
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||||
|
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||||
|
private final AisHubChunkAssembler assembler = new AisHubChunkAssembler();
|
||||||
|
private final AtomicBoolean gattBusy = new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean isConnecting = new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean rssiLoop = new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean batteryLoop = new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean reconnectLoop = new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean notifReady = new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean mtuRequested = new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean snapshotRequested = new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean subscribeRequested = new AtomicBoolean(false);
|
||||||
|
private final ArrayBlockingQueue<byte[]> controlQueue = new ArrayBlockingQueue<>(32);
|
||||||
|
|
||||||
|
private BluetoothAdapter adapter;
|
||||||
|
private volatile BluetoothGatt gatt;
|
||||||
|
private volatile String deviceMac;
|
||||||
|
private volatile boolean connected;
|
||||||
|
private volatile long connectionStartTimeMs;
|
||||||
|
private volatile long lastDataAtMs;
|
||||||
|
private volatile boolean lastErrorWasDbFull;
|
||||||
|
|
||||||
|
private volatile BluetoothGattCharacteristic controlChar;
|
||||||
|
private volatile BluetoothGattCharacteristic dataChar;
|
||||||
|
|
||||||
|
private SessionCallback callback;
|
||||||
|
private volatile UUID cachedBatterySvc;
|
||||||
|
private volatile UUID cachedBatteryChar;
|
||||||
|
|
||||||
|
private final ScheduledExecutorService batteryScheduler = Executors.newSingleThreadScheduledExecutor();
|
||||||
|
private volatile ScheduledFuture<?> batteryTask;
|
||||||
|
|
||||||
|
private static final long CONNECTION_TIMEOUT_MS = 30_000L;
|
||||||
|
private static final long RECONNECT_DELAY_MS = 5_000L;
|
||||||
|
private static final long RECONNECT_DELAY_DB_FULL_MS = 15_000L;
|
||||||
|
private static final long SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS = 300_000L;
|
||||||
|
private static final long SNAPSHOT_RECOVERY_IDLE_MS = 10_000L;
|
||||||
|
|
||||||
|
public AisHubGattClient(@NonNull Context context) {
|
||||||
|
this.appContext = context.getApplicationContext();
|
||||||
|
BluetoothManager bm = (BluetoothManager) appContext.getSystemService(Context.BLUETOOTH_SERVICE);
|
||||||
|
this.adapter = bm != null ? bm.getAdapter() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCallback(@Nullable SessionCallback callback) {
|
||||||
|
this.callback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceMac(String mac) {
|
||||||
|
this.deviceMac = mac;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isRunning() {
|
||||||
|
return running.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
if (running.get()) {
|
||||||
|
Log.w(TAG, "AIS Hub GATT already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (adapter == null || !adapter.isEnabled()) {
|
||||||
|
postError("Bluetooth is off or unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (deviceMac == null || deviceMac.isEmpty()) {
|
||||||
|
postError("BLE device MAC not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
running.set(true);
|
||||||
|
assembler.clear();
|
||||||
|
if (BLE_LOG) Log.d(TAG, "start(): mac=" + deviceMac);
|
||||||
|
startReconnectLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
if (BLE_LOG) Log.d(TAG, "stop()");
|
||||||
|
running.set(false);
|
||||||
|
reconnectLoop.set(false);
|
||||||
|
rssiLoop.set(false);
|
||||||
|
batteryLoop.set(false);
|
||||||
|
if (batteryTask != null) {
|
||||||
|
try { batteryTask.cancel(true); } catch (Throwable ignore) {}
|
||||||
|
}
|
||||||
|
if (rssiTask != null) {
|
||||||
|
try { rssiTask.cancel(true); } catch (Throwable ignore) {}
|
||||||
|
}
|
||||||
|
notifReady.set(false);
|
||||||
|
controlChar = null;
|
||||||
|
dataChar = null;
|
||||||
|
mainHandler.removeCallbacksAndMessages(null);
|
||||||
|
try {
|
||||||
|
if (gatt != null) {
|
||||||
|
gatt.disconnect();
|
||||||
|
gatt.close();
|
||||||
|
}
|
||||||
|
} catch (Throwable ignore) {}
|
||||||
|
gatt = null;
|
||||||
|
connected = false;
|
||||||
|
postState("stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GATT callback ---
|
||||||
|
|
||||||
|
private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
|
||||||
|
@Override
|
||||||
|
public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
|
||||||
|
if (!running.get()) return;
|
||||||
|
if (BLE_LOG) Log.d(TAG, "onConnectionStateChange: status=" + status + " newState=" + newState);
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS && status != 4 && status != 133) {
|
||||||
|
postError("BLE connect status: " + status);
|
||||||
|
} else if (status == 133) {
|
||||||
|
lastErrorWasDbFull = true;
|
||||||
|
isConnecting.set(false);
|
||||||
|
try {
|
||||||
|
if (g != null) {
|
||||||
|
g.disconnect();
|
||||||
|
g.close();
|
||||||
|
}
|
||||||
|
} catch (Throwable ignore) {}
|
||||||
|
gatt = null;
|
||||||
|
connectionStartTimeMs = 0L;
|
||||||
|
} else if (status == 4) {
|
||||||
|
isConnecting.set(false);
|
||||||
|
}
|
||||||
|
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||||
|
postState("connected");
|
||||||
|
connected = true;
|
||||||
|
connectionStartTimeMs = 0L;
|
||||||
|
isConnecting.set(false);
|
||||||
|
lastErrorWasDbFull = false;
|
||||||
|
reconnectLoop.set(false);
|
||||||
|
notifReady.set(false);
|
||||||
|
mtuRequested.set(false);
|
||||||
|
snapshotRequested.set(false);
|
||||||
|
subscribeRequested.set(false);
|
||||||
|
controlChar = null;
|
||||||
|
dataChar = null;
|
||||||
|
// NOTE: BluetoothGatt.refresh() is hidden API and frequently destabilizes connections
|
||||||
|
// on some vendor stacks. Prefer stability over stale cache here.
|
||||||
|
scheduler.schedule(() -> {
|
||||||
|
if (g != null && running.get() && gatt == g) {
|
||||||
|
// Помогаем линк-слою: выше приоритет соединения.
|
||||||
|
try { g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH); } catch (Throwable ignore) {}
|
||||||
|
boolean ok = false;
|
||||||
|
try { ok = g.discoverServices(); } catch (Throwable ignore) {}
|
||||||
|
if (BLE_LOG) Log.d(TAG, "discoverServices(): " + ok);
|
||||||
|
}
|
||||||
|
}, 200, TimeUnit.MILLISECONDS);
|
||||||
|
// На некоторых стеках (особенно после refresh/MTU) service discovery может "зависнуть"
|
||||||
|
// без callback'а. Если так — мягко перезапускаем discovery и, при необходимости, reconnect.
|
||||||
|
scheduler.schedule(() -> {
|
||||||
|
if (!running.get()) return;
|
||||||
|
if (g == null || gatt != g) return;
|
||||||
|
if (!connected) return;
|
||||||
|
if (controlChar != null && dataChar != null) return;
|
||||||
|
if (BLE_LOG) Log.w(TAG, "Services discovery watchdog: no hub chars yet, retry discoverServices()");
|
||||||
|
boolean ok = false;
|
||||||
|
try { ok = g.discoverServices(); } catch (Throwable ignore) {}
|
||||||
|
if (BLE_LOG) Log.d(TAG, "discoverServices() retry: " + ok);
|
||||||
|
}, 6, TimeUnit.SECONDS);
|
||||||
|
scheduler.schedule(() -> {
|
||||||
|
if (!running.get()) return;
|
||||||
|
if (g == null || gatt != g) return;
|
||||||
|
if (!connected) return;
|
||||||
|
if (controlChar != null && dataChar != null) return;
|
||||||
|
postError("Service discovery timeout (no hub chars)");
|
||||||
|
try { g.disconnect(); } catch (Throwable ignore) {}
|
||||||
|
try { g.close(); } catch (Throwable ignore) {}
|
||||||
|
gatt = null;
|
||||||
|
connected = false;
|
||||||
|
isConnecting.set(false);
|
||||||
|
startReconnectLoop();
|
||||||
|
}, 12, TimeUnit.SECONDS);
|
||||||
|
startRssiLoop();
|
||||||
|
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||||
|
postState("disconnected");
|
||||||
|
notifReady.set(false);
|
||||||
|
controlChar = null;
|
||||||
|
dataChar = null;
|
||||||
|
mtuRequested.set(false);
|
||||||
|
snapshotRequested.set(false);
|
||||||
|
subscribeRequested.set(false);
|
||||||
|
rssiLoop.set(false);
|
||||||
|
batteryLoop.set(false);
|
||||||
|
if (rssiTask != null) {
|
||||||
|
try { rssiTask.cancel(false); } catch (Throwable ignore) {}
|
||||||
|
rssiTask = null;
|
||||||
|
}
|
||||||
|
try { if (gatt != null) gatt.close(); } catch (Throwable ignore) {}
|
||||||
|
gatt = null;
|
||||||
|
connected = false;
|
||||||
|
isConnecting.set(false);
|
||||||
|
if (running.get()) {
|
||||||
|
scheduler.schedule(() -> {
|
||||||
|
if (running.get() && !connected) startReconnectLoop();
|
||||||
|
}, 1, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServicesDiscovered(BluetoothGatt g, int status) {
|
||||||
|
if (BLE_LOG) Log.d(TAG, "onServicesDiscovered: status=" + status);
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
postError("Service discovery failed: " + status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mtuRequested.compareAndSet(false, true)) {
|
||||||
|
boolean ok = false;
|
||||||
|
try { ok = g.requestMtu(512); } catch (Throwable ignore) {}
|
||||||
|
if (BLE_LOG) Log.d(TAG, "requestMtu(512): " + ok);
|
||||||
|
}
|
||||||
|
BluetoothGattService hub = g.getService(AisHubConstants.SERVICE_UUID);
|
||||||
|
if (hub == null) {
|
||||||
|
postError("AIS Hub service not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controlChar = hub.getCharacteristic(AisHubConstants.CONTROL_UUID);
|
||||||
|
dataChar = hub.getCharacteristic(AisHubConstants.DATA_UUID);
|
||||||
|
if (controlChar == null || dataChar == null) {
|
||||||
|
postError("CONTROL or DATA characteristic missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (BLE_LOG) {
|
||||||
|
Log.d(TAG, "Hub chars ok: CONTROL=" + controlChar.getUuid() + " DATA=" + dataChar.getUuid());
|
||||||
|
}
|
||||||
|
boolean ok = g.setCharacteristicNotification(dataChar, true);
|
||||||
|
if (!ok) {
|
||||||
|
postError("Failed to enable DATA notification");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
BluetoothGattDescriptor cccd = dataChar.getDescriptor(CCCD);
|
||||||
|
if (cccd == null) {
|
||||||
|
postError("CCCD not found for DATA");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cccd.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
|
||||||
|
gattBusy.set(true);
|
||||||
|
if (BLE_LOG) Log.d(TAG, "writeDescriptor(CCCD ENABLE_NOTIFICATION)");
|
||||||
|
g.writeDescriptor(cccd);
|
||||||
|
postState("subscribing");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor descriptor, int st) {
|
||||||
|
gattBusy.set(false);
|
||||||
|
if (BLE_LOG) Log.d(TAG, "onDescriptorWrite: uuid=" + descriptor.getUuid() + " status=" + st);
|
||||||
|
if (st == BluetoothGatt.GATT_SUCCESS && CCCD.equals(descriptor.getUuid())) {
|
||||||
|
notifReady.set(true);
|
||||||
|
lastDataAtMs = System.currentTimeMillis();
|
||||||
|
postState("notifying");
|
||||||
|
try { resolveBatteryAndSchedule(g); } catch (Throwable ignore) {}
|
||||||
|
readBatteryOnce(g);
|
||||||
|
startBatteryLoop(g);
|
||||||
|
enqueueControlJson(buildHello());
|
||||||
|
|
||||||
|
// Snapshot триггерим ТОЛЬКО на HELLO_ACK (см. processDataRaw),
|
||||||
|
// чтобы не получить snapshot_busy от двойного запроса (постDelayed
|
||||||
|
// здесь и обработчик HELLO_ACK раньше успевали оба).
|
||||||
|
// Fallback: если HELLO_ACK не пришёл за 2с — всё равно дёргаем snapshot.
|
||||||
|
mainHandler.postDelayed(() -> {
|
||||||
|
if (!running.get() || !connected) return;
|
||||||
|
if (!notifReady.get() || controlChar == null) return;
|
||||||
|
if (snapshotRequested.compareAndSet(false, true)) {
|
||||||
|
if (BLE_LOG) Log.d(TAG, "HELLO_ACK timeout fallback -> enqueue get_snapshot");
|
||||||
|
enqueueGetSnapshot();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCharacteristicChanged(BluetoothGatt g, BluetoothGattCharacteristic ch) {
|
||||||
|
if (dataChar != null && ch.getUuid().equals(dataChar.getUuid())) {
|
||||||
|
// ВАЖНО: не парсим/не JSON-парсим в BLE callback потоке.
|
||||||
|
// Иначе при потоке EVENT'ов легко перегрузить стек и получить disconnect/status=5.
|
||||||
|
// Используем ВЫДЕЛЕННЫЙ dataExecutor, чтобы парсинг не стоял за RSSI/reconnect-петлями.
|
||||||
|
final byte[] raw = ch.getValue();
|
||||||
|
if (raw == null) return;
|
||||||
|
final byte[] copy = java.util.Arrays.copyOf(raw, raw.length);
|
||||||
|
dataExecutor.execute(() -> processDataRaw(copy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMtuChanged(BluetoothGatt g, int mtu, int status) {
|
||||||
|
if (BLE_LOG) Log.d(TAG, "onMtuChanged: status=" + status + " mtu=" + mtu);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCharacteristicRead(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
|
||||||
|
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
if (BATTERY_LEVEL.equals(ch.getUuid()) || (toShort(ch.getUuid()) != null && toShort(ch.getUuid()) == 0x2A19)) {
|
||||||
|
byte[] v = ch.getValue();
|
||||||
|
if (v != null && v.length > 0 && callback != null) {
|
||||||
|
callback.onBatteryPercent(v[0] & 0xFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gattBusy.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCharacteristicWrite(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
|
||||||
|
if (controlChar != null && ch.getUuid().equals(controlChar.getUuid())) {
|
||||||
|
gattBusy.set(false);
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
Log.w(TAG, "CONTROL write failed: " + status);
|
||||||
|
} else if (BLE_LOG) {
|
||||||
|
Log.d(TAG, "CONTROL write ok (" + (ch.getValue() != null ? ch.getValue().length : -1) + " bytes)");
|
||||||
|
}
|
||||||
|
mainHandler.post(AisHubGattClient.this::drainControlQueue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReadRemoteRssi(BluetoothGatt g, int rssi, int status) {
|
||||||
|
if (status == BluetoothGatt.GATT_SUCCESS && callback != null) {
|
||||||
|
callback.onRssi(rssi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private void processDataRaw(@Nullable byte[] raw) {
|
||||||
|
if (raw == null) return;
|
||||||
|
if (raw.length < AisHubConstants.HEADER_SIZE) {
|
||||||
|
if (BLE_LOG) Log.w(TAG, "DATA notify too short: len=" + raw.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastDataAtMs = System.currentTimeMillis();
|
||||||
|
// Decode header fields even if payload is truncated, for diagnostics.
|
||||||
|
int pv = raw[0] & 0xFF;
|
||||||
|
int mt = raw[1] & 0xFF;
|
||||||
|
int sid = ((raw[2] & 0xFF) | ((raw[3] & 0xFF) << 8));
|
||||||
|
int cidx = ((raw[4] & 0xFF) | ((raw[5] & 0xFF) << 8));
|
||||||
|
int ccnt = ((raw[6] & 0xFF) | ((raw[7] & 0xFF) << 8));
|
||||||
|
int plen = ((raw[8] & 0xFF) | ((raw[9] & 0xFF) << 8));
|
||||||
|
|
||||||
|
if (BLE_LOG) {
|
||||||
|
Log.d(TAG, "DATA notify: len=" + raw.length +
|
||||||
|
" pv=" + pv +
|
||||||
|
" msgType=" + mt + "(" + msgTypeName(mt) + ")" +
|
||||||
|
" sid=" + sid +
|
||||||
|
" chunk=" + cidx + "/" + ccnt +
|
||||||
|
" plen=" + plen);
|
||||||
|
}
|
||||||
|
|
||||||
|
AisHubFrame frame = AisHubFrame.parse(raw);
|
||||||
|
if (frame == null) {
|
||||||
|
if (BLE_LOG) Log.w(TAG, "DATA parse failed/truncated: rawLen=" + raw.length + " expected>=" + (AisHubConstants.HEADER_SIZE + plen));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AisHubChunkAssembler.FeedStatus st = assembler.feedStatus(frame);
|
||||||
|
if (st != null && BLE_LOG) {
|
||||||
|
if (st.wasReset) {
|
||||||
|
Log.w(TAG, "Assembler reset: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType + " newChunkCount=" + st.chunkCount);
|
||||||
|
}
|
||||||
|
if (st.payload == null) {
|
||||||
|
Log.d(TAG, "Assembler progress: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType +
|
||||||
|
" got=" + st.received + "/" + st.chunkCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (st == null || st.payload == null) return;
|
||||||
|
try {
|
||||||
|
JSONObject root;
|
||||||
|
if (st.protocolVersion == AisHubConstants.PROTO_VERSION_MSGPACK) {
|
||||||
|
root = AisHubPayloadCodec.decodeToJsonObject(st.payload, st.protocolVersion);
|
||||||
|
if (root == null) {
|
||||||
|
Log.w(TAG, "msgpack decode: empty/non-object payload msgType=" + frame.msgType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (BLE_LOG) {
|
||||||
|
Log.d(TAG, "DATA msgpack complete: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType +
|
||||||
|
" bytes=" + st.payload.length + " obj=" + abbreviate(root.toString(), 800));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// JSON path keeps the original behavior (zero-copy via st.json).
|
||||||
|
String jsonStr = st.json != null
|
||||||
|
? st.json
|
||||||
|
: new String(st.payload, StandardCharsets.UTF_8);
|
||||||
|
if (BLE_LOG) Log.d(TAG, "DATA json complete: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType +
|
||||||
|
" bytes=" + jsonStr.getBytes(StandardCharsets.UTF_8).length +
|
||||||
|
" json=" + abbreviate(jsonStr, 800));
|
||||||
|
root = new JSONObject(jsonStr);
|
||||||
|
}
|
||||||
|
// Keep our clock offset with the hub fresh from every frame that
|
||||||
|
// carries a server timestamp. This makes stale-data math on the
|
||||||
|
// client work correctly regardless of hub clock drift.
|
||||||
|
double envTs = root.optDouble("ts", Double.NaN);
|
||||||
|
if (!Double.isNaN(envTs)) {
|
||||||
|
HubTimeSync.updateFromServerSeconds(envTs);
|
||||||
|
} else {
|
||||||
|
double srvTime = root.optDouble("server_time", Double.NaN);
|
||||||
|
if (!Double.isNaN(srvTime)) HubTimeSync.updateFromServerSeconds(srvTime);
|
||||||
|
}
|
||||||
|
if (BLE_LOG && frame.msgType == AisHubConstants.MSG_EVENT) {
|
||||||
|
String type = root.optString("type", "");
|
||||||
|
JSONObject data = root.optJSONObject("data");
|
||||||
|
// target.update/vessel-snapshot nest lat/lon inside "dynamic"; ownship.update keeps them at root.
|
||||||
|
JSONObject src = data;
|
||||||
|
if (data != null) {
|
||||||
|
JSONObject d = data.optJSONObject("dynamic");
|
||||||
|
if (d != null) src = d;
|
||||||
|
}
|
||||||
|
double lat = src != null ? src.optDouble("lat", Double.NaN) : Double.NaN;
|
||||||
|
double lon = src != null ? src.optDouble("lon", Double.NaN) : Double.NaN;
|
||||||
|
Log.d(TAG, "EVENT received: type=" + type + " lat=" + lat + " lon=" + lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Авто-старт сессии: после HELLO_ACK сразу запрашиваем snapshot (1 раз на соединение).
|
||||||
|
if (frame.msgType == AisHubConstants.MSG_HELLO_ACK && snapshotRequested.compareAndSet(false, true)) {
|
||||||
|
if (BLE_LOG) Log.d(TAG, "HELLO_ACK received -> enqueue get_snapshot");
|
||||||
|
mainHandler.post(this::enqueueGetSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// После окончания snapshot включаем live подписки (1 раз на соединение).
|
||||||
|
if (frame.msgType == AisHubConstants.MSG_SNAPSHOT_END && subscribeRequested.compareAndSet(false, true)) {
|
||||||
|
boolean ok = root.optBoolean("ok", true);
|
||||||
|
if (BLE_LOG) Log.d(TAG, "SNAPSHOT_END(ok=" + ok + ") -> enqueue subscribe");
|
||||||
|
if (ok) {
|
||||||
|
mainHandler.post(this::enqueueSubscribe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callback != null) {
|
||||||
|
mainHandler.post(() -> callback.onDataJson(frame.msgType, root));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "JSON parse: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- control writes ---
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private JSONObject buildHello() {
|
||||||
|
JSONObject c = new JSONObject();
|
||||||
|
try {
|
||||||
|
c.put("cmd", "hello");
|
||||||
|
c.put("client", "android");
|
||||||
|
String v = "1.0";
|
||||||
|
try {
|
||||||
|
PackageInfo pi = appContext.getPackageManager().getPackageInfo(appContext.getPackageName(), 0);
|
||||||
|
if (pi != null) v = pi.versionName != null ? pi.versionName : "1.0";
|
||||||
|
} catch (Exception ignore) {}
|
||||||
|
c.put("app_version", v);
|
||||||
|
c.put("proto", 1);
|
||||||
|
// Advertise encoding preferences. Server picks the first one it
|
||||||
|
// supports; we fall back to JSON automatically if msgpack isn't
|
||||||
|
// available server-side. msgpack is ~30-40% smaller on the wire
|
||||||
|
// which matters for snapshot transfers over BLE.
|
||||||
|
// Note: this only affects the AcquireNotify (per-fd) path. For the
|
||||||
|
// CCCD broadcast path Android clients share, the server uses the
|
||||||
|
// global AIS_BLE_BROADCAST_ENCODING env var — but our decoder
|
||||||
|
// handles both encodings per-frame via the proto-version byte, so
|
||||||
|
// switching the server's env has zero client-side impact.
|
||||||
|
org.json.JSONArray encs = new org.json.JSONArray();
|
||||||
|
encs.put("msgpack");
|
||||||
|
encs.put("json");
|
||||||
|
c.put("encodings", encs);
|
||||||
|
} catch (Exception ignore) {}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void enqueueGetSnapshot() {
|
||||||
|
JSONObject c = new JSONObject();
|
||||||
|
try {
|
||||||
|
c.put("cmd", "get_snapshot");
|
||||||
|
org.json.JSONArray inc = new org.json.JSONArray();
|
||||||
|
inc.put("ownship");
|
||||||
|
inc.put("vessels");
|
||||||
|
inc.put("base_stations");
|
||||||
|
inc.put("atons");
|
||||||
|
inc.put("stats");
|
||||||
|
c.put("include", inc);
|
||||||
|
// Серверный хард-кап ограничивает дальше (см. AIS_BLE_SNAPSHOT_MAX_VESSELS).
|
||||||
|
// 5000 покрывает любые реалистичные сценарии (обычно в AOI < 2000 целей).
|
||||||
|
c.put("max_vessels", 5000);
|
||||||
|
} catch (Exception ignore) {}
|
||||||
|
enqueueControl(c);
|
||||||
|
|
||||||
|
// Recovery only: штатно live-подписка включается строго после SNAPSHOT_END.
|
||||||
|
// Короткий таймер здесь интерливил EVENT'ы в длинный snapshot по CCCD.
|
||||||
|
mainHandler.postDelayed(() -> {
|
||||||
|
if (!running.get() || !connected) return;
|
||||||
|
if (!notifReady.get() || controlChar == null) return;
|
||||||
|
long idleMs = System.currentTimeMillis() - lastDataAtMs;
|
||||||
|
if (idleMs < SNAPSHOT_RECOVERY_IDLE_MS) return;
|
||||||
|
if (subscribeRequested.compareAndSet(false, true)) {
|
||||||
|
if (BLE_LOG) Log.d(TAG, "snapshot recovery timeout -> enqueue subscribe");
|
||||||
|
enqueueSubscribe();
|
||||||
|
}
|
||||||
|
}, SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void enqueueSubscribe() {
|
||||||
|
JSONObject c = new JSONObject();
|
||||||
|
try {
|
||||||
|
c.put("cmd", "subscribe");
|
||||||
|
org.json.JSONArray ev = new org.json.JSONArray();
|
||||||
|
ev.put("ownship.update");
|
||||||
|
ev.put("target.update");
|
||||||
|
ev.put("base_station.update");
|
||||||
|
ev.put("aton.update");
|
||||||
|
ev.put("stats.update");
|
||||||
|
c.put("events", ev);
|
||||||
|
} catch (Exception ignore) {}
|
||||||
|
enqueueControl(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enqueueControlJson(@NonNull JSONObject cmd) {
|
||||||
|
try {
|
||||||
|
enqueueControl(cmd);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "enqueue: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enqueueControl(@NonNull JSONObject json) {
|
||||||
|
if (!notifReady.get() || controlChar == null) return;
|
||||||
|
String s = json.toString();
|
||||||
|
if (BLE_LOG) Log.d(TAG, "CONTROL enqueue: " + abbreviate(s, 600));
|
||||||
|
byte[] u = s.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (!controlQueue.offer(u)) {
|
||||||
|
Log.w(TAG, "Control queue full");
|
||||||
|
}
|
||||||
|
mainHandler.post(this::drainControlQueue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drainControlQueue() {
|
||||||
|
if (!running.get() || !notifReady.get() || gatt == null || controlChar == null) return;
|
||||||
|
if (gattBusy.get()) return;
|
||||||
|
byte[] next = controlQueue.poll();
|
||||||
|
if (next == null) return;
|
||||||
|
gattBusy.set(true);
|
||||||
|
controlChar.setValue(next);
|
||||||
|
controlChar.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT);
|
||||||
|
boolean ok = gatt.writeCharacteristic(controlChar);
|
||||||
|
if (!ok) {
|
||||||
|
if (BLE_LOG) Log.w(TAG, "CONTROL writeCharacteristic returned false, retrying");
|
||||||
|
gattBusy.set(false);
|
||||||
|
mainHandler.post(this::drainControlQueue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- connect / reconnect ---
|
||||||
|
|
||||||
|
private void startReconnectLoop() {
|
||||||
|
if (reconnectLoop.getAndSet(true)) return;
|
||||||
|
executor.execute(() -> {
|
||||||
|
while (running.get() && reconnectLoop.get() && !connected) {
|
||||||
|
try {
|
||||||
|
if (connectionStartTimeMs > 0) {
|
||||||
|
long el = System.currentTimeMillis() - connectionStartTimeMs;
|
||||||
|
if (el > CONNECTION_TIMEOUT_MS) {
|
||||||
|
isConnecting.set(false);
|
||||||
|
try { if (gatt != null) { gatt.disconnect(); gatt.close(); } } catch (Throwable ignore) {}
|
||||||
|
gatt = null;
|
||||||
|
connectionStartTimeMs = 0L;
|
||||||
|
} else {
|
||||||
|
try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isConnecting.compareAndSet(false, true)) {
|
||||||
|
try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
attemptConnect();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "reconnect: " + t.getMessage());
|
||||||
|
isConnecting.set(false);
|
||||||
|
}
|
||||||
|
long delay = lastErrorWasDbFull ? RECONNECT_DELAY_DB_FULL_MS : RECONNECT_DELAY_MS;
|
||||||
|
try { Thread.sleep(delay); } catch (InterruptedException ignored) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void attemptConnect() {
|
||||||
|
if (adapter == null || deviceMac == null || deviceMac.isEmpty()) {
|
||||||
|
isConnecting.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gatt != null) {
|
||||||
|
try {
|
||||||
|
gatt.disconnect();
|
||||||
|
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
|
||||||
|
gatt.close();
|
||||||
|
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
|
||||||
|
} catch (Throwable ignore) {}
|
||||||
|
gatt = null;
|
||||||
|
}
|
||||||
|
BluetoothDevice d = adapter.getRemoteDevice(deviceMac);
|
||||||
|
if (d == null) {
|
||||||
|
postError("BLE device not found: " + deviceMac);
|
||||||
|
isConnecting.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (BLE_LOG) Log.d(TAG, "attemptConnect(): " + deviceMac);
|
||||||
|
postState("connecting");
|
||||||
|
connectionStartTimeMs = System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
gatt = d.connectGatt(appContext, false, gattCallback, BluetoothDevice.TRANSPORT_LE);
|
||||||
|
} else {
|
||||||
|
gatt = d.connectGatt(appContext, false, gattCallback);
|
||||||
|
}
|
||||||
|
if (gatt == null) isConnecting.set(false);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
isConnecting.set(false);
|
||||||
|
connectionStartTimeMs = 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private volatile ScheduledFuture<?> rssiTask;
|
||||||
|
|
||||||
|
private void startRssiLoop() {
|
||||||
|
if (gatt == null) return;
|
||||||
|
if (rssiLoop.getAndSet(true)) return;
|
||||||
|
if (rssiTask != null) try { rssiTask.cancel(true); } catch (Throwable ignore) {}
|
||||||
|
rssiTask = scheduler.scheduleAtFixedRate(() -> {
|
||||||
|
if (!running.get() || !rssiLoop.get() || gatt == null) return;
|
||||||
|
try { gatt.readRemoteRssi(); } catch (Throwable ignore) {}
|
||||||
|
}, 500, 2000, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- battery (same heuristics as old listener) ---
|
||||||
|
|
||||||
|
private void resolveBatteryAndSchedule(BluetoothGatt g) {
|
||||||
|
BluetoothGattCharacteristic bl = findBatteryChar(g);
|
||||||
|
if (bl != null) {
|
||||||
|
cachedBatterySvc = bl.getService().getUuid();
|
||||||
|
cachedBatteryChar = bl.getUuid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private BluetoothGattCharacteristic findBatteryChar(BluetoothGatt g) {
|
||||||
|
if (g == null) return null;
|
||||||
|
if (cachedBatterySvc != null && cachedBatteryChar != null) {
|
||||||
|
BluetoothGattService s = g.getService(cachedBatterySvc);
|
||||||
|
if (s != null) {
|
||||||
|
BluetoothGattCharacteristic c = s.getCharacteristic(cachedBatteryChar);
|
||||||
|
if (c != null) return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BluetoothGattService s = g.getService(BATTERY_SERVICE);
|
||||||
|
if (s != null) {
|
||||||
|
BluetoothGattCharacteristic c = s.getCharacteristic(BATTERY_LEVEL);
|
||||||
|
if (c != null) {
|
||||||
|
cachedBatterySvc = BATTERY_SERVICE;
|
||||||
|
cachedBatteryChar = BATTERY_LEVEL;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<BluetoothGattService> list = g.getServices();
|
||||||
|
if (list == null) return null;
|
||||||
|
for (BluetoothGattService sv : list) {
|
||||||
|
for (BluetoothGattCharacteristic ch : sv.getCharacteristics()) {
|
||||||
|
Integer sh = toShort(ch.getUuid());
|
||||||
|
if (sh != null && sh == 0x2A19) {
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer toShort(java.util.UUID uuid) {
|
||||||
|
if (uuid == null) return null;
|
||||||
|
String s = uuid.toString().toLowerCase();
|
||||||
|
if (s.startsWith("0000") && s.endsWith("-0000-1000-8000-00805f9b34fb")) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(s.substring(4, 8), 16);
|
||||||
|
} catch (Throwable ignore) {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void readBatteryOnce(BluetoothGatt g) {
|
||||||
|
if (g == null) return;
|
||||||
|
BluetoothGattCharacteristic bl = findBatteryChar(g);
|
||||||
|
if (bl == null) return;
|
||||||
|
if (gattBusy.compareAndSet(false, true)) {
|
||||||
|
boolean ok = g.readCharacteristic(bl);
|
||||||
|
if (!ok) gattBusy.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startBatteryLoop(BluetoothGatt gRef) {
|
||||||
|
if (gRef == null) return;
|
||||||
|
if (!batteryLoop.compareAndSet(false, true)) return;
|
||||||
|
if (batteryTask != null) try { batteryTask.cancel(true); } catch (Throwable ignore) {}
|
||||||
|
batteryTask = batteryScheduler.scheduleAtFixedRate(() -> {
|
||||||
|
if (!running.get() || !batteryLoop.get() || gatt == null) {
|
||||||
|
batteryLoop.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (gattBusy.get()) return;
|
||||||
|
BluetoothGattCharacteristic bl = findBatteryChar(gRef);
|
||||||
|
if (bl != null && gattBusy.compareAndSet(false, true)) {
|
||||||
|
boolean ok = gRef.readCharacteristic(bl);
|
||||||
|
if (!ok) gattBusy.set(false);
|
||||||
|
}
|
||||||
|
} catch (Throwable ignore) {}
|
||||||
|
}, 2000, 10_000, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postState(String s) {
|
||||||
|
if (callback != null) {
|
||||||
|
mainHandler.post(() -> callback.onState(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postError(String s) {
|
||||||
|
Log.e(TAG, s);
|
||||||
|
LogSender.logBLEError(s, deviceMac, "AisHub");
|
||||||
|
if (callback != null) {
|
||||||
|
mainHandler.post(() -> callback.onError(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private static String abbreviate(@NonNull String s, int max) {
|
||||||
|
if (s.length() <= max) return s;
|
||||||
|
return s.substring(0, Math.max(0, max)) + "…(" + s.length() + " chars)";
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private static String msgTypeName(int mt) {
|
||||||
|
switch (mt) {
|
||||||
|
case AisHubConstants.MSG_HELLO_ACK: return "HELLO_ACK";
|
||||||
|
case AisHubConstants.MSG_SNAPSHOT_BEGIN: return "SNAPSHOT_BEGIN";
|
||||||
|
case AisHubConstants.MSG_SNAPSHOT_CHUNK: return "SNAPSHOT_CHUNK";
|
||||||
|
case AisHubConstants.MSG_SNAPSHOT_END: return "SNAPSHOT_END";
|
||||||
|
case AisHubConstants.MSG_EVENT: return "EVENT";
|
||||||
|
case AisHubConstants.MSG_STATUS: return "STATUS";
|
||||||
|
case AisHubConstants.MSG_ERROR: return "ERROR";
|
||||||
|
case AisHubConstants.MSG_PONG: return "PONG";
|
||||||
|
default: return "UNKNOWN";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package com.grigowashere.aismap.ble.hub;
|
||||||
|
|
||||||
|
import com.grigowashere.aismap.models.AISVessel;
|
||||||
|
import com.grigowashere.aismap.models.AISNavigationAid;
|
||||||
|
import com.grigowashere.aismap.models.Vessel;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps loose JSON objects from ais_hub into app models (defensive keys).
|
||||||
|
*/
|
||||||
|
public final class AisHubJsonMapper {
|
||||||
|
|
||||||
|
private AisHubJsonMapper() {}
|
||||||
|
|
||||||
|
public static String mmsiString(JSONObject o) {
|
||||||
|
if (o == null) return null;
|
||||||
|
if (o.has("mmsi")) {
|
||||||
|
Object v = o.opt("mmsi");
|
||||||
|
if (v instanceof Number) return String.valueOf(((Number) v).longValue());
|
||||||
|
String s = o.optString("mmsi", null);
|
||||||
|
return s != null && !s.isEmpty() ? s : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `o.dynamic` if it exists (AIS Hub v2 nests position/motion there
|
||||||
|
* for target.update and vessel snapshot items), otherwise returns `o` itself
|
||||||
|
* (ownship.update keeps lat/lon at root).
|
||||||
|
*/
|
||||||
|
private static JSONObject dyn(JSONObject o) {
|
||||||
|
if (o == null) return null;
|
||||||
|
JSONObject d = o.optJSONObject("dynamic");
|
||||||
|
return d != null ? d : o;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double optLat(JSONObject o) {
|
||||||
|
if (o == null) return Double.NaN;
|
||||||
|
JSONObject src = dyn(o);
|
||||||
|
if (src.has("latitude")) return src.optDouble("latitude", Double.NaN);
|
||||||
|
if (src.has("lat")) return src.optDouble("lat", Double.NaN);
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double optLon(JSONObject o) {
|
||||||
|
if (o == null) return Double.NaN;
|
||||||
|
JSONObject src = dyn(o);
|
||||||
|
if (src.has("longitude")) return src.optDouble("longitude", Double.NaN);
|
||||||
|
if (src.has("lon")) return src.optDouble("lon", Double.NaN);
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double optCourse(JSONObject o) {
|
||||||
|
if (o == null) return Double.NaN;
|
||||||
|
JSONObject src = dyn(o);
|
||||||
|
double v = src.optDouble("cog", Double.NaN);
|
||||||
|
if (!Double.isNaN(v)) return v;
|
||||||
|
v = src.optDouble("course", Double.NaN);
|
||||||
|
if (!Double.isNaN(v)) return v;
|
||||||
|
v = src.optDouble("true_course", Double.NaN);
|
||||||
|
if (!Double.isNaN(v)) return v;
|
||||||
|
return src.optDouble("heading", Double.NaN);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double optSpeed(JSONObject o) {
|
||||||
|
if (o == null) return Double.NaN;
|
||||||
|
JSONObject src = dyn(o);
|
||||||
|
double v = src.optDouble("sog", Double.NaN);
|
||||||
|
if (!Double.isNaN(v)) return v;
|
||||||
|
v = src.optDouble("speed", Double.NaN);
|
||||||
|
if (!Double.isNaN(v)) return v;
|
||||||
|
return src.optDouble("stw", Double.NaN);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double optHeading(JSONObject o) {
|
||||||
|
if (o == null) return Double.NaN;
|
||||||
|
JSONObject src = dyn(o);
|
||||||
|
double v = src.optDouble("heading", Double.NaN);
|
||||||
|
if (!Double.isNaN(v)) return v;
|
||||||
|
return src.optDouble("hdg", Double.NaN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a valid epoch-seconds timestamp taken from any of the known fields,
|
||||||
|
* or NaN if none present. Supports ts, last_dynamic_ts, last_seen, last_static_ts.
|
||||||
|
*/
|
||||||
|
private static double pickEpochSeconds(JSONObject o) {
|
||||||
|
if (o == null) return Double.NaN;
|
||||||
|
String[] keys = {"ts", "last_dynamic_ts", "last_seen", "last_static_ts"};
|
||||||
|
for (String k : keys) {
|
||||||
|
if (!o.has(k)) continue;
|
||||||
|
double ts = o.optDouble(k, Double.NaN);
|
||||||
|
if (Double.isNaN(ts) || ts <= 0) continue;
|
||||||
|
if (ts > 1e12) ts /= 1000.0;
|
||||||
|
if (ts > 946684800) return ts;
|
||||||
|
}
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a server-epoch timestamp to a device-local {@link LocalDateTime}
|
||||||
|
* using {@link HubTimeSync} so that stale checks (which compare against
|
||||||
|
* {@code LocalDateTime.now()}) are immune to hub clock skew.
|
||||||
|
*/
|
||||||
|
private static LocalDateTime deviceLocalFromServerEpoch(double serverSec) {
|
||||||
|
double deviceSec = HubTimeSync.toDeviceEpochSeconds(serverSec);
|
||||||
|
long secs = (long) deviceSec;
|
||||||
|
int nanos = (int) ((deviceSec - secs) * 1e9);
|
||||||
|
if (nanos < 0) { secs -= 1; nanos += 1_000_000_000; }
|
||||||
|
return LocalDateTime.ofInstant(Instant.ofEpochSecond(secs, nanos), ZoneId.systemDefault());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void applyTimestampFromJson(JSONObject o, AISVessel vessel) {
|
||||||
|
double ts = pickEpochSeconds(o);
|
||||||
|
if (!Double.isNaN(ts)) {
|
||||||
|
vessel.setLastUpdate(deviceLocalFromServerEpoch(ts));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (o != null && o.has("last_update")) {
|
||||||
|
try {
|
||||||
|
String s = o.optString("last_update", "");
|
||||||
|
if (!s.isEmpty()) {
|
||||||
|
vessel.setLastUpdate(LocalDateTime.parse(s));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (Exception ignore) {}
|
||||||
|
}
|
||||||
|
vessel.setLastUpdate(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void applyTimestampFromJson(JSONObject o, AISNavigationAid aid) {
|
||||||
|
double ts = pickEpochSeconds(o);
|
||||||
|
if (!Double.isNaN(ts)) {
|
||||||
|
aid.setLastUpdate(deviceLocalFromServerEpoch(ts));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
aid.setLastUpdate(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void applyTimestampFromJson(JSONObject o, Vessel vessel) {
|
||||||
|
double ts = pickEpochSeconds(o);
|
||||||
|
if (!Double.isNaN(ts)) {
|
||||||
|
vessel.setLastUpdate(deviceLocalFromServerEpoch(ts));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vessel.setLastUpdate(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AISVessel aisVesselFromJson(JSONObject o) {
|
||||||
|
if (o == null) return null;
|
||||||
|
String mmsi = mmsiString(o);
|
||||||
|
if (mmsi == null) return null;
|
||||||
|
AISVessel v = new AISVessel(mmsi);
|
||||||
|
String name = o.optString("name", o.optString("vessel_name", o.optString("shipname", "")));
|
||||||
|
if (!name.isEmpty()) v.setVesselName(name);
|
||||||
|
v.setCallSign(o.optString("call_sign", o.optString("callsign", "")));
|
||||||
|
double lat = optLat(o);
|
||||||
|
double lon = optLon(o);
|
||||||
|
double cog = optCourse(o);
|
||||||
|
double sog = optSpeed(o);
|
||||||
|
if (!Double.isNaN(lat) && !Double.isNaN(lon)
|
||||||
|
&& !Double.isNaN(cog) && !Double.isNaN(sog)) {
|
||||||
|
v.updatePosition(lat, lon, cog, sog);
|
||||||
|
} else {
|
||||||
|
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
|
||||||
|
v.setLatitude(lat);
|
||||||
|
v.setLongitude(lon);
|
||||||
|
}
|
||||||
|
if (!Double.isNaN(cog)) v.setCourse(cog);
|
||||||
|
if (!Double.isNaN(sog)) v.setSpeed(sog);
|
||||||
|
}
|
||||||
|
double hdg = optHeading(o);
|
||||||
|
if (!Double.isNaN(hdg)) v.setHeading(hdg);
|
||||||
|
String cls = o.optString("class", o.optString("vessel_class", o.optString("ship_class", "")));
|
||||||
|
if (!cls.isEmpty()) v.setVesselClass(cls);
|
||||||
|
int imo = o.optInt("imo", 0);
|
||||||
|
if (imo > 0) v.setImo(imo);
|
||||||
|
int shipType = o.optInt("ship_type", -1);
|
||||||
|
if (shipType >= 0) v.setVesselType(shipTypeName(shipType));
|
||||||
|
JSONObject dims = o.optJSONObject("dims");
|
||||||
|
if (dims != null) {
|
||||||
|
double a = dims.optDouble("a", 0.0);
|
||||||
|
double b = dims.optDouble("b", 0.0);
|
||||||
|
double c = dims.optDouble("c", 0.0);
|
||||||
|
double d = dims.optDouble("d", 0.0);
|
||||||
|
if (a + b > 0.0) v.setLength(a + b);
|
||||||
|
if (c + d > 0.0) v.setWidth(c + d);
|
||||||
|
}
|
||||||
|
JSONObject voyage = o.optJSONObject("voyage");
|
||||||
|
if (voyage != null) {
|
||||||
|
double draft = voyage.optDouble("draught", Double.NaN);
|
||||||
|
if (!Double.isNaN(draft) && draft > 0.0) v.setDraft(draft);
|
||||||
|
String destination = voyage.optString("destination", "");
|
||||||
|
if (!destination.isEmpty()) v.setDestination(destination);
|
||||||
|
}
|
||||||
|
JSONObject dynamic = o.optJSONObject("dynamic");
|
||||||
|
if (dynamic != null) {
|
||||||
|
if (dynamic.has("nav_status")) {
|
||||||
|
v.setNavigationalStatus(String.valueOf(dynamic.optInt("nav_status")));
|
||||||
|
}
|
||||||
|
double rot = dynamic.optDouble("rot", Double.NaN);
|
||||||
|
if (!Double.isNaN(rot)) v.setRateOfTurn(rot);
|
||||||
|
}
|
||||||
|
JSONObject signal = o.optJSONObject("signal");
|
||||||
|
if (signal != null) {
|
||||||
|
double db = signal.optDouble("last_db", Double.NaN);
|
||||||
|
if (!Double.isNaN(db)) v.setSignalStrength((int) Math.round(db));
|
||||||
|
}
|
||||||
|
if (o.has("position_accuracy")) {
|
||||||
|
v.setPositionAccuracy(o.optBoolean("position_accuracy", false));
|
||||||
|
}
|
||||||
|
applyTimestampFromJson(o, v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AISVessel baseStationFromJson(JSONObject o) {
|
||||||
|
if (o == null) return null;
|
||||||
|
String mmsi = mmsiString(o);
|
||||||
|
if (mmsi == null) return null;
|
||||||
|
AISVessel v = new AISVessel(mmsi);
|
||||||
|
v.setVesselClass("Base Station");
|
||||||
|
v.setVesselType("Base Station");
|
||||||
|
v.setVesselName("Base Station " + mmsi);
|
||||||
|
double lat = optLat(o);
|
||||||
|
double lon = optLon(o);
|
||||||
|
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
|
||||||
|
v.setLatitude(lat);
|
||||||
|
v.setLongitude(lon);
|
||||||
|
}
|
||||||
|
if (o.has("accuracy")) {
|
||||||
|
v.setPositionAccuracy(o.optBoolean("accuracy", false));
|
||||||
|
}
|
||||||
|
if (o.has("epfd")) {
|
||||||
|
v.setDestination("EPFD: " + o.optInt("epfd"));
|
||||||
|
}
|
||||||
|
applyTimestampFromJson(o, v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AISNavigationAid navigationAidFromJson(JSONObject o) {
|
||||||
|
if (o == null) return null;
|
||||||
|
String mmsi = mmsiString(o);
|
||||||
|
if (mmsi == null) return null;
|
||||||
|
AISNavigationAid aid = new AISNavigationAid(mmsi);
|
||||||
|
aid.setAidName(o.optString("name", "AtoN " + mmsi));
|
||||||
|
aid.setAidType(o.optInt("type", 0));
|
||||||
|
double lat = optLat(o);
|
||||||
|
double lon = optLon(o);
|
||||||
|
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
|
||||||
|
aid.setLatitude(lat);
|
||||||
|
aid.setLongitude(lon);
|
||||||
|
}
|
||||||
|
if (o.has("accuracy")) {
|
||||||
|
aid.setPositionAccuracy(o.optBoolean("accuracy", false));
|
||||||
|
}
|
||||||
|
if (o.has("virtual")) {
|
||||||
|
aid.setOffPositionIndicator(o.optBoolean("virtual", false));
|
||||||
|
}
|
||||||
|
applyTimestampFromJson(o, aid);
|
||||||
|
return aid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void mergeVesselOwnship(JSONObject o, Vessel v) {
|
||||||
|
if (o == null || v == null) return;
|
||||||
|
String mmsi = mmsiString(o);
|
||||||
|
if (mmsi != null) v.setMmsi(mmsi);
|
||||||
|
String name = o.optString("name", o.optString("vessel_name", ""));
|
||||||
|
if (!name.isEmpty()) v.setVesselName(name);
|
||||||
|
String cs = o.optString("call_sign", o.optString("callsign", ""));
|
||||||
|
if (!cs.isEmpty()) v.setCallSign(cs);
|
||||||
|
double lat = optLat(o);
|
||||||
|
double lon = optLon(o);
|
||||||
|
if (!Double.isNaN(lat) && !Double.isNaN(lon)) {
|
||||||
|
v.setLatitude(lat);
|
||||||
|
v.setLongitude(lon);
|
||||||
|
}
|
||||||
|
double cog = optCourse(o);
|
||||||
|
if (!Double.isNaN(cog)) v.setCourse(cog);
|
||||||
|
double sog = optSpeed(o);
|
||||||
|
if (!Double.isNaN(sog)) v.setSpeed(sog);
|
||||||
|
double hdg = optHeading(o);
|
||||||
|
if (!Double.isNaN(hdg)) v.setHeading(hdg);
|
||||||
|
v.setFixTime(System.currentTimeMillis());
|
||||||
|
v.setFixQuality("HUB");
|
||||||
|
if (o.has("accuracy") || o.has("h_acc")) {
|
||||||
|
float acc = (float) o.optDouble("accuracy", o.optDouble("h_acc", -1));
|
||||||
|
if (acc >= 0) v.setAccuracy(acc);
|
||||||
|
}
|
||||||
|
applyTimestampFromJson(o, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String shipTypeName(int code) {
|
||||||
|
if (code >= 70 && code <= 79) return "Cargo";
|
||||||
|
if (code >= 80 && code <= 89) return "Tanker";
|
||||||
|
if (code == 30) return "Fishing";
|
||||||
|
if (code >= 60 && code <= 69) return "Passenger";
|
||||||
|
if (code == 36 || code == 37) return "Sailing/Pleasure";
|
||||||
|
if (code == 31 || code == 32 || code == 52) return "Tug";
|
||||||
|
if (code == 35) return "Military";
|
||||||
|
return "Other";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.grigowashere.aismap.ble.hub;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
import org.msgpack.core.MessagePack;
|
||||||
|
import org.msgpack.core.MessageUnpacker;
|
||||||
|
import org.msgpack.value.Value;
|
||||||
|
import org.msgpack.value.ValueType;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a reassembled AIS Hub message payload into a {@link JSONObject},
|
||||||
|
* regardless of whether the server sent it as JSON (proto 0x01) or
|
||||||
|
* MessagePack (proto 0x02). All downstream code in this app consumes
|
||||||
|
* {@link JSONObject}, so converting MessagePack → JSONObject here keeps the
|
||||||
|
* rest of the codebase unchanged.
|
||||||
|
*
|
||||||
|
* <p>Why convert to JSONObject instead of surfacing the raw msgpack Value?
|
||||||
|
* The existing consumers ({@code onDataJson}) and all the mappers in
|
||||||
|
* {@link AisHubJsonMapper} are written against {@code org.json.*}. Rewriting
|
||||||
|
* them would be a large blast radius for a wire-format optimization that the
|
||||||
|
* consumer should be agnostic to. The conversion is O(payload size) and runs
|
||||||
|
* off the BLE callback thread (see {@code AisHubGattClient#dataExecutor}).
|
||||||
|
*/
|
||||||
|
public final class AisHubPayloadCodec {
|
||||||
|
|
||||||
|
private AisHubPayloadCodec() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a reassembled payload into a JSONObject.
|
||||||
|
*
|
||||||
|
* @param payload raw reassembled bytes (never null when called)
|
||||||
|
* @param protoVer one of {@link AisHubConstants#PROTO_VERSION_JSON} or
|
||||||
|
* {@link AisHubConstants#PROTO_VERSION_MSGPACK}
|
||||||
|
* @return the decoded root object, or null if the payload is malformed
|
||||||
|
* or the top-level value is not an object/map (we never expect
|
||||||
|
* a bare array or scalar at the root in this protocol).
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public static JSONObject decodeToJsonObject(@NonNull byte[] payload, int protoVer) throws Exception {
|
||||||
|
if (protoVer == AisHubConstants.PROTO_VERSION_MSGPACK) {
|
||||||
|
return decodeMsgpack(payload);
|
||||||
|
}
|
||||||
|
// Default / 0x01: JSON UTF-8. Unknown versions are treated as JSON
|
||||||
|
// rather than silently dropped, to be forward-compatible with future
|
||||||
|
// server versions that still emit JSON-shaped payloads.
|
||||||
|
String jsonStr = new String(payload, StandardCharsets.UTF_8);
|
||||||
|
return new JSONObject(jsonStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link #decodeToJsonObject(byte[], int)} but takes a pre-built
|
||||||
|
* JSON string (used by the legacy fast path where the assembler already
|
||||||
|
* produced the UTF-8 string).
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public static JSONObject decodeJsonString(@NonNull String json) throws Exception {
|
||||||
|
return new JSONObject(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static JSONObject decodeMsgpack(@NonNull byte[] payload) throws Exception {
|
||||||
|
try (MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(payload)) {
|
||||||
|
if (!unpacker.hasNext()) return null;
|
||||||
|
Value root = unpacker.unpackValue();
|
||||||
|
if (root == null || !root.getValueType().isMapType()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (JSONObject) toJson(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively convert a MessagePack {@link Value} to an {@code org.json.*}
|
||||||
|
* tree. Maps become {@link JSONObject}, arrays become {@link JSONArray},
|
||||||
|
* binary blobs become Base64 strings (we don't expect any in this
|
||||||
|
* protocol, but they must not crash the decoder).
|
||||||
|
*/
|
||||||
|
private static Object toJson(@NonNull Value v) throws Exception {
|
||||||
|
ValueType t = v.getValueType();
|
||||||
|
switch (t) {
|
||||||
|
case NIL:
|
||||||
|
return JSONObject.NULL;
|
||||||
|
case BOOLEAN:
|
||||||
|
return v.asBooleanValue().getBoolean();
|
||||||
|
case INTEGER:
|
||||||
|
// Prefer long to preserve 64-bit MMSI values etc.
|
||||||
|
return v.asIntegerValue().toLong();
|
||||||
|
case FLOAT:
|
||||||
|
return v.asFloatValue().toDouble();
|
||||||
|
case STRING:
|
||||||
|
return v.asStringValue().asString();
|
||||||
|
case BINARY:
|
||||||
|
// Shouldn't occur on this wire format; stringify defensively.
|
||||||
|
return android.util.Base64.encodeToString(
|
||||||
|
v.asBinaryValue().asByteArray(), android.util.Base64.NO_WRAP);
|
||||||
|
case ARRAY: {
|
||||||
|
JSONArray arr = new JSONArray();
|
||||||
|
for (Value item : v.asArrayValue()) {
|
||||||
|
arr.put(toJson(item));
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
case MAP: {
|
||||||
|
JSONObject obj = new JSONObject();
|
||||||
|
for (Map.Entry<Value, Value> e : v.asMapValue().entrySet()) {
|
||||||
|
// Keys in our protocol are always strings. If a non-string
|
||||||
|
// key sneaks in, stringify it so we don't silently drop data.
|
||||||
|
String key = e.getKey().getValueType() == ValueType.STRING
|
||||||
|
? e.getKey().asStringValue().asString()
|
||||||
|
: e.getKey().toJson();
|
||||||
|
obj.put(key, toJson(e.getValue()));
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
case EXTENSION:
|
||||||
|
default:
|
||||||
|
// Unknown / extension types: stringify to avoid crashing.
|
||||||
|
return v.toJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.grigowashere.aismap.ble.hub;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks clock offset between the AIS Hub server ("hub time") and this device
|
||||||
|
* ("device wall clock"). Used to translate absolute server timestamps
|
||||||
|
* (`ts`, `server_time`, `last_dynamic_ts`, ...) into device-local epoch seconds
|
||||||
|
* so that stale-data logic (which works against {@code LocalDateTime.now()})
|
||||||
|
* stays correct even when the two clocks disagree.
|
||||||
|
*
|
||||||
|
* <p>Thread-safety: all state is static/volatile; updates are cheap and safe
|
||||||
|
* from any thread.
|
||||||
|
*/
|
||||||
|
public final class HubTimeSync {
|
||||||
|
|
||||||
|
private static final String TAG = "HubTimeSync";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum server epoch we consider plausible (2010-01-01). Anything below is
|
||||||
|
* ignored.
|
||||||
|
*/
|
||||||
|
private static final double MIN_EPOCH = 1262304000.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp absurd offsets (1 year) to protect from a single bad sample.
|
||||||
|
*/
|
||||||
|
private static final double MAX_ABS_OFFSET_SEC = 365L * 24 * 3600;
|
||||||
|
|
||||||
|
private static volatile boolean hasOffset = false;
|
||||||
|
/** offsetSec = serverTime - deviceTime; deviceTime = serverTime - offsetSec. */
|
||||||
|
private static volatile double offsetSec = 0.0;
|
||||||
|
private static volatile long lastUpdateElapsedRealtimeMs = 0L;
|
||||||
|
|
||||||
|
private HubTimeSync() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feeds a server timestamp (epoch seconds, possibly milliseconds) and the
|
||||||
|
* device wall-clock moment when it was observed. Values that look broken
|
||||||
|
* are dropped.
|
||||||
|
*/
|
||||||
|
public static void updateFromServerSeconds(double serverEpochSec) {
|
||||||
|
if (Double.isNaN(serverEpochSec) || Double.isInfinite(serverEpochSec)) return;
|
||||||
|
// Some payloads send milliseconds in a double; normalize.
|
||||||
|
if (serverEpochSec > 1e12) serverEpochSec /= 1000.0;
|
||||||
|
if (serverEpochSec < MIN_EPOCH) return;
|
||||||
|
double deviceNowSec = System.currentTimeMillis() / 1000.0;
|
||||||
|
double newOffset = serverEpochSec - deviceNowSec;
|
||||||
|
if (Math.abs(newOffset) > MAX_ABS_OFFSET_SEC) {
|
||||||
|
Log.w(TAG, "Ignoring implausible offset " + newOffset + "s (server=" + serverEpochSec + ")");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Light exponential smoothing to absorb jitter without lagging a real clock drift.
|
||||||
|
if (hasOffset) {
|
||||||
|
offsetSec = 0.2 * newOffset + 0.8 * offsetSec;
|
||||||
|
} else {
|
||||||
|
offsetSec = newOffset;
|
||||||
|
}
|
||||||
|
hasOffset = true;
|
||||||
|
lastUpdateElapsedRealtimeMs = android.os.SystemClock.elapsedRealtime();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean hasOffset() {
|
||||||
|
return hasOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double getOffsetSec() {
|
||||||
|
return offsetSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a server-side epoch-seconds timestamp into a device-side
|
||||||
|
* epoch-seconds timestamp using the learned offset. If no offset has been
|
||||||
|
* observed yet, returns {@code serverEpochSec} unchanged (best effort).
|
||||||
|
*/
|
||||||
|
public static double toDeviceEpochSeconds(double serverEpochSec) {
|
||||||
|
if (Double.isNaN(serverEpochSec) || serverEpochSec <= 0) return serverEpochSec;
|
||||||
|
if (serverEpochSec > 1e12) serverEpochSec /= 1000.0;
|
||||||
|
if (!hasOffset) return serverEpochSec;
|
||||||
|
return serverEpochSec - offsetSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset state (e.g. on BLE stop). Primarily useful for tests.
|
||||||
|
*/
|
||||||
|
public static void reset() {
|
||||||
|
hasOffset = false;
|
||||||
|
offsetSec = 0.0;
|
||||||
|
lastUpdateElapsedRealtimeMs = 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import com.grigowashere.aismap.data.entity.AISVesselEntity;
|
|||||||
import com.grigowashere.aismap.data.mapper.AISVesselMapper;
|
import com.grigowashere.aismap.data.mapper.AISVesselMapper;
|
||||||
import com.grigowashere.aismap.models.Vessel;
|
import com.grigowashere.aismap.models.Vessel;
|
||||||
import com.grigowashere.aismap.models.AISVessel;
|
import com.grigowashere.aismap.models.AISVessel;
|
||||||
|
import com.grigowashere.aismap.models.AISNavigationAid;
|
||||||
import com.grigowashere.aismap.utils.SettingsManager;
|
import com.grigowashere.aismap.utils.SettingsManager;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -38,7 +39,7 @@ public class DataController {
|
|||||||
private DataControllerListener listener;
|
private DataControllerListener listener;
|
||||||
|
|
||||||
public interface DataControllerListener {
|
public interface DataControllerListener {
|
||||||
void onDataRestored(Vessel vessel, List<AISVessel> aisVessels);
|
void onDataRestored(Vessel vessel, List<AISVessel> aisVessels, List<AISNavigationAid> navigationAids);
|
||||||
void onDataSaved(String dataType, boolean success);
|
void onDataSaved(String dataType, boolean success);
|
||||||
void onDataCleaned(int removedCount);
|
void onDataCleaned(int removedCount);
|
||||||
}
|
}
|
||||||
@@ -47,7 +48,7 @@ public class DataController {
|
|||||||
this.context = context;
|
this.context = context;
|
||||||
this.repository = new Repository(context);
|
this.repository = new Repository(context);
|
||||||
this.settingsManager = new SettingsManager(context);
|
this.settingsManager = new SettingsManager(context);
|
||||||
this.executor = Executors.newCachedThreadPool();
|
this.executor = Executors.newSingleThreadExecutor();
|
||||||
|
|
||||||
// Инициализируем Handler для периодической очистки БД
|
// Инициализируем Handler для периодической очистки БД
|
||||||
this.dbCleanupHandler = new Handler(Looper.getMainLooper());
|
this.dbCleanupHandler = new Handler(Looper.getMainLooper());
|
||||||
@@ -70,6 +71,8 @@ public class DataController {
|
|||||||
Log.i(TAG, "🔄 Запускаем асинхронное восстановление данных из БД...");
|
Log.i(TAG, "🔄 Запускаем асинхронное восстановление данных из БД...");
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
try {
|
try {
|
||||||
|
cleanupStaleAISSync("перед восстановлением");
|
||||||
|
|
||||||
Log.d(TAG, "📊 Загружаем данные судна из БД...");
|
Log.d(TAG, "📊 Загружаем данные судна из БД...");
|
||||||
VesselEntity latest = repository.getLatestOwnVesselSync();
|
VesselEntity latest = repository.getLatestOwnVesselSync();
|
||||||
Vessel vessel = null;
|
Vessel vessel = null;
|
||||||
@@ -87,21 +90,29 @@ public class DataController {
|
|||||||
Log.d(TAG, "🚢 Загружаем AIS суда из БД...");
|
Log.d(TAG, "🚢 Загружаем AIS суда из БД...");
|
||||||
List<AISVesselEntity> list = repository.getAllAISSync();
|
List<AISVesselEntity> list = repository.getAllAISSync();
|
||||||
List<AISVessel> aisVessels = new ArrayList<>();
|
List<AISVessel> aisVessels = new ArrayList<>();
|
||||||
|
List<AISNavigationAid> navigationAids = new ArrayList<>();
|
||||||
|
|
||||||
if (list != null && !list.isEmpty()) {
|
if (list != null && !list.isEmpty()) {
|
||||||
for (AISVesselEntity entity : list) {
|
for (AISVesselEntity entity : list) {
|
||||||
// Используем маппер для полного восстановления всех полей
|
// Проверяем, является ли это навигационным знаком
|
||||||
AISVessel vesselModel = AISVesselMapper.toModel(entity);
|
if ("Navigation Aid".equals(entity.vesselClass)) {
|
||||||
aisVessels.add(vesselModel);
|
// Создаем AISNavigationAid из entity
|
||||||
Log.d(TAG, "AIS судно восстановлено из БД с полными данными: " + vesselModel.getMmsi());
|
AISNavigationAid navigationAid = createNavigationAidFromEntity(entity);
|
||||||
|
navigationAids.add(navigationAid);
|
||||||
|
} else {
|
||||||
|
// Используем маппер для полного восстановления всех полей AIS судна
|
||||||
|
AISVessel vesselModel = AISVesselMapper.toModel(entity);
|
||||||
|
aisVessels.add(vesselModel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Log.i(TAG, "✅ Восстановлено " + list.size() + " AIS судов из БД с полными данными");
|
Log.i(TAG, "✅ Восстановлено " + aisVessels.size() + " AIS судов и " + navigationAids.size() + " навигационных знаков из БД");
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "ℹ️ Нет AIS судов в БД");
|
Log.d(TAG, "ℹ️ Нет AIS судов в БД");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Уведомляем слушателя о восстановленных данных
|
// Уведомляем слушателя о восстановленных данных
|
||||||
if (listener != null) {
|
if (listener != null) {
|
||||||
listener.onDataRestored(vessel, aisVessels);
|
listener.onDataRestored(vessel, aisVessels, navigationAids);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -147,8 +158,8 @@ public class DataController {
|
|||||||
try {
|
try {
|
||||||
// Используем маппер для полной конвертации всех полей
|
// Используем маппер для полной конвертации всех полей
|
||||||
AISVesselEntity entity = AISVesselMapper.toEntity(vessel);
|
AISVesselEntity entity = AISVesselMapper.toEntity(vessel);
|
||||||
repository.upsertAIS(entity);
|
repository.upsertAISSync(entity);
|
||||||
Log.d(TAG, "AIS судно сохранено в БД с полными данными: " + vessel.getMmsi());
|
Log.d(TAG, "AIS судно сохранено в БД: " + vessel.getMmsi());
|
||||||
|
|
||||||
if (listener != null) {
|
if (listener != null) {
|
||||||
listener.onDataSaved("ais_vessel", true);
|
listener.onDataSaved("ais_vessel", true);
|
||||||
@@ -161,6 +172,134 @@ public class DataController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пакетно сохраняет AIS суда в БД.
|
||||||
|
*/
|
||||||
|
public void saveAISVessels(List<AISVessel> vessels) {
|
||||||
|
if (vessels == null || vessels.isEmpty()) return;
|
||||||
|
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
List<AISVesselEntity> entities = new ArrayList<>(vessels.size());
|
||||||
|
for (AISVessel vessel : vessels) {
|
||||||
|
if (vessel == null || vessel.getMmsi() == null) continue;
|
||||||
|
AISVesselEntity entity = AISVesselMapper.toEntity(vessel);
|
||||||
|
if (entity != null) {
|
||||||
|
entities.add(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repository.upsertAISBatchSync(entities);
|
||||||
|
Log.d(TAG, "Пакетно сохранено AIS судов в БД: " + entities.size());
|
||||||
|
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onDataSaved("ais_vessels_batch", true);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Ошибка пакетного апсерта AIS в БД: " + e.getMessage(), e);
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onDataSaved("ais_vessels_batch", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохраняет навигационный знак в БД
|
||||||
|
*/
|
||||||
|
public void saveNavigationAid(AISNavigationAid navigationAid) {
|
||||||
|
if (navigationAid == null) return;
|
||||||
|
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
// Создаем AISVesselEntity из навигационного знака для совместимости с БД
|
||||||
|
AISVesselEntity entity = new AISVesselEntity(navigationAid.getMmsi());
|
||||||
|
entity.mmsi = navigationAid.getMmsi();
|
||||||
|
entity.latitude = navigationAid.getLatitude();
|
||||||
|
entity.longitude = navigationAid.getLongitude();
|
||||||
|
entity.vesselName = navigationAid.getAidName();
|
||||||
|
entity.vesselClass = "Navigation Aid";
|
||||||
|
entity.vesselType = navigationAid.getAidTypeDescription();
|
||||||
|
entity.length = navigationAid.getLength();
|
||||||
|
entity.width = navigationAid.getWidth();
|
||||||
|
entity.draft = navigationAid.getDraft();
|
||||||
|
entity.positionAccuracy = navigationAid.isPositionAccuracy();
|
||||||
|
// Сохраняем время последнего обновления как epoch ms
|
||||||
|
if (navigationAid.getLastUpdate() != null) {
|
||||||
|
entity.lastUpdateEpochMs = navigationAid.getLastUpdate().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем специальные поля для навигационных знаков в destination
|
||||||
|
StringBuilder destination = new StringBuilder();
|
||||||
|
destination.append("Type: ").append(navigationAid.getAidType()).append(" (").append(navigationAid.getAidTypeDescription()).append(")");
|
||||||
|
if (navigationAid.isOffPositionIndicator()) {
|
||||||
|
destination.append(" - Off Position");
|
||||||
|
}
|
||||||
|
if (navigationAid.isRaimFlag()) {
|
||||||
|
destination.append(" - RAIM Active");
|
||||||
|
}
|
||||||
|
entity.destination = destination.toString();
|
||||||
|
|
||||||
|
repository.upsertAISSync(entity);
|
||||||
|
Log.d(TAG, "Навигационный знак сохранен в БД: " + navigationAid.getMmsi());
|
||||||
|
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onDataSaved("navigation_aid", true);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Ошибка сохранения навигационного знака в БД: " + e.getMessage(), e);
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onDataSaved("navigation_aid", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает AISNavigationAid из AISVesselEntity
|
||||||
|
*/
|
||||||
|
private AISNavigationAid createNavigationAidFromEntity(AISVesselEntity entity) {
|
||||||
|
AISNavigationAid navigationAid = new AISNavigationAid(entity.mmsi);
|
||||||
|
navigationAid.setLatitude(entity.latitude);
|
||||||
|
navigationAid.setLongitude(entity.longitude);
|
||||||
|
navigationAid.setAidName(entity.vesselName);
|
||||||
|
navigationAid.setAidTypeDescription(entity.vesselType);
|
||||||
|
navigationAid.setLength(entity.length);
|
||||||
|
navigationAid.setWidth(entity.width);
|
||||||
|
navigationAid.setDraft(entity.draft);
|
||||||
|
navigationAid.setPositionAccuracy(entity.positionAccuracy);
|
||||||
|
// Восстанавливаем время из epoch ms, если доступно
|
||||||
|
if (entity.lastUpdateEpochMs > 0) {
|
||||||
|
navigationAid.setLastUpdate(java.time.Instant.ofEpochMilli(entity.lastUpdateEpochMs)
|
||||||
|
.atZone(java.time.ZoneId.systemDefault()).toLocalDateTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим специальные поля из destination
|
||||||
|
if (entity.destination != null && entity.destination.startsWith("Type: ")) {
|
||||||
|
try {
|
||||||
|
// Извлекаем тип из destination: "Type: 21 (Cardinal Mark E)"
|
||||||
|
String typePart = entity.destination.substring(6); // Убираем "Type: "
|
||||||
|
int parenIndex = typePart.indexOf(' ');
|
||||||
|
if (parenIndex > 0) {
|
||||||
|
String typeStr = typePart.substring(0, parenIndex);
|
||||||
|
int aidType = Integer.parseInt(typeStr);
|
||||||
|
navigationAid.setAidType(aidType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем дополнительные флаги
|
||||||
|
if (entity.destination.contains("Off Position")) {
|
||||||
|
navigationAid.setOffPositionIndicator(true);
|
||||||
|
}
|
||||||
|
if (entity.destination.contains("RAIM Active")) {
|
||||||
|
navigationAid.setRaimFlag(true);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "Ошибка парсинга destination для навигационного знака: " + entity.destination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return navigationAid;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Запускает периодическую очистку БД от устаревших AIS целей
|
* Запускает периодическую очистку БД от устаревших AIS целей
|
||||||
@@ -186,25 +325,32 @@ public class DataController {
|
|||||||
* Выполняет очистку БД от устаревших AIS целей
|
* Выполняет очистку БД от устаревших AIS целей
|
||||||
*/
|
*/
|
||||||
private void performDatabaseCleanup() {
|
private void performDatabaseCleanup() {
|
||||||
try {
|
executor.execute(() -> {
|
||||||
int staleRemoveMinutes = settingsManager.getDataStaleRemoveMinutes();
|
try {
|
||||||
long thresholdEpochMs = System.currentTimeMillis() - (staleRemoveMinutes * 60 * 1000L);
|
int removed = cleanupStaleAISSync(null);
|
||||||
|
|
||||||
repository.deleteStaleAIS(thresholdEpochMs);
|
if (listener != null) {
|
||||||
|
listener.onDataCleaned(removed);
|
||||||
Log.i(TAG, "Выполнена очистка БД от AIS целей старше " + staleRemoveMinutes + " минут");
|
}
|
||||||
|
|
||||||
if (listener != null) {
|
// Планируем следующую очистку
|
||||||
listener.onDataCleaned(0); // Метод не возвращает количество удаленных записей
|
if (dbCleanupHandler != null && dbCleanupRunnable != null) {
|
||||||
|
dbCleanupHandler.postDelayed(dbCleanupRunnable, DB_CLEANUP_INTERVAL);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Ошибка при очистке БД от устаревших AIS целей: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// Планируем следующую очистку
|
}
|
||||||
if (dbCleanupHandler != null && dbCleanupRunnable != null) {
|
|
||||||
dbCleanupHandler.postDelayed(dbCleanupRunnable, DB_CLEANUP_INTERVAL);
|
private int cleanupStaleAISSync(String reason) {
|
||||||
}
|
int staleRemoveMinutes = settingsManager.getDataStaleRemoveMinutes();
|
||||||
} catch (Exception e) {
|
long thresholdEpochMs = System.currentTimeMillis() - (staleRemoveMinutes * 60 * 1000L);
|
||||||
Log.e(TAG, "Ошибка при очистке БД от устаревших AIS целей: " + e.getMessage(), e);
|
int removed = repository.deleteStaleAISSync(thresholdEpochMs);
|
||||||
}
|
String suffix = reason != null ? " " + reason : "";
|
||||||
|
Log.i(TAG, "Выполнена очистка БД" + suffix + ": удалено=" + removed +
|
||||||
|
", старше=" + staleRemoveMinutes + " минут");
|
||||||
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -225,14 +225,24 @@ public class GPSLocationListener implements LocationListener {
|
|||||||
Log.i(TAG, "📍 Location обновлен: " + location.getLatitude() + ", " + location.getLongitude());
|
Log.i(TAG, "📍 Location обновлен: " + location.getLatitude() + ", " + location.getLongitude());
|
||||||
Log.i(TAG, "📍 Точность: " + location.getAccuracy() + "м, время: " + location.getTime());
|
Log.i(TAG, "📍 Точность: " + location.getAccuracy() + "м, время: " + location.getTime());
|
||||||
|
|
||||||
// Создаем объект судна с полученными данными
|
|
||||||
Vessel vessel = new Vessel();
|
Vessel vessel = new Vessel();
|
||||||
vessel.setLatitude(location.getLatitude());
|
vessel.setLatitude(location.getLatitude());
|
||||||
vessel.setLongitude(location.getLongitude());
|
vessel.setLongitude(location.getLongitude());
|
||||||
vessel.setAccuracy(location.getAccuracy());
|
vessel.setAccuracy(location.getAccuracy());
|
||||||
vessel.setFixTime(location.getTime());
|
vessel.setFixTime(location.getTime());
|
||||||
|
|
||||||
// Определяем качество фикса
|
// Android Location умеет отдавать скорость (м/с) и курс (°); они нужны
|
||||||
|
// координатному виджету и стрелке на карте. Преобразуем м/с → узлы.
|
||||||
|
if (location.hasSpeed()) {
|
||||||
|
double knots = location.getSpeed() * 1.9438444924;
|
||||||
|
vessel.setSpeed(knots);
|
||||||
|
} else {
|
||||||
|
vessel.setSpeed(0);
|
||||||
|
}
|
||||||
|
if (location.hasBearing()) {
|
||||||
|
vessel.setCourse(location.getBearing());
|
||||||
|
}
|
||||||
|
|
||||||
if (location.hasAccuracy()) {
|
if (location.hasAccuracy()) {
|
||||||
if (location.getAccuracy() <= 3) {
|
if (location.getAccuracy() <= 3) {
|
||||||
vessel.setFixQuality("HIGH_ACCURACY");
|
vessel.setFixQuality("HIGH_ACCURACY");
|
||||||
@@ -242,8 +252,7 @@ public class GPSLocationListener implements LocationListener {
|
|||||||
vessel.setFixQuality("LOW_ACCURACY");
|
vessel.setFixQuality("LOW_ACCURACY");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем информацию о спутниках
|
|
||||||
vessel.updateGPSQuality(satelliteCount, activeSatellites, pdop, hdop, vdop, location.getAccuracy());
|
vessel.updateGPSQuality(satelliteCount, activeSatellites, pdop, hdop, vdop, location.getAccuracy());
|
||||||
|
|
||||||
// Отправляем обновление через callback
|
// Отправляем обновление через callback
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public class NMEAController implements
|
|||||||
void onVesselUpdated(Vessel vessel);
|
void onVesselUpdated(Vessel vessel);
|
||||||
void onDOPUpdated(double pdop, double hdop, double vdop);
|
void onDOPUpdated(double pdop, double hdop, double vdop);
|
||||||
void onAISVesselUpdated(AISVessel vessel);
|
void onAISVesselUpdated(AISVessel vessel);
|
||||||
|
void onNavigationAidUpdated(com.grigowashere.aismap.models.AISNavigationAid navigationAid);
|
||||||
void onParseError(String error);
|
void onParseError(String error);
|
||||||
void onGPSLocationUpdated(Vessel vessel);
|
void onGPSLocationUpdated(Vessel vessel);
|
||||||
}
|
}
|
||||||
@@ -306,6 +307,13 @@ public class NMEAController implements
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNavigationAidUpdated(com.grigowashere.aismap.models.AISNavigationAid navigationAid) {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onNavigationAidUpdated(navigationAid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onParseError(String error) {
|
public void onParseError(String error) {
|
||||||
Log.e(TAG, "Ошибка парсинга NMEA: " + error);
|
Log.e(TAG, "Ошибка парсинга NMEA: " + error);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.grigowashere.aismap.controllers;
|
|||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import com.grigowashere.aismap.models.Vessel;
|
import com.grigowashere.aismap.models.Vessel;
|
||||||
import com.grigowashere.aismap.models.AISVessel;
|
import com.grigowashere.aismap.models.AISVessel;
|
||||||
|
import com.grigowashere.aismap.models.AISNavigationAid;
|
||||||
import com.grigowashere.aismap.utils.LogSender;
|
import com.grigowashere.aismap.utils.LogSender;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -33,6 +34,7 @@ public class NMEAParser {
|
|||||||
|
|
||||||
private Vessel ownVessel;
|
private Vessel ownVessel;
|
||||||
private List<AISVessel> aisVessels;
|
private List<AISVessel> aisVessels;
|
||||||
|
private List<AISNavigationAid> navigationAids;
|
||||||
private NMEAParserListener listener;
|
private NMEAParserListener listener;
|
||||||
private GPSLocationListener gpsLocationListener;
|
private GPSLocationListener gpsLocationListener;
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ public class NMEAParser {
|
|||||||
public interface NMEAParserListener {
|
public interface NMEAParserListener {
|
||||||
void onVesselUpdated(Vessel vessel);
|
void onVesselUpdated(Vessel vessel);
|
||||||
void onAISVesselUpdated(AISVessel vessel);
|
void onAISVesselUpdated(AISVessel vessel);
|
||||||
|
void onNavigationAidUpdated(AISNavigationAid navigationAid);
|
||||||
void onParseError(String error);
|
void onParseError(String error);
|
||||||
void onDOPUpdated(double pdop, double hdop, double vdop);
|
void onDOPUpdated(double pdop, double hdop, double vdop);
|
||||||
}
|
}
|
||||||
@@ -62,6 +65,7 @@ public class NMEAParser {
|
|||||||
public NMEAParser() {
|
public NMEAParser() {
|
||||||
this.ownVessel = new Vessel();
|
this.ownVessel = new Vessel();
|
||||||
this.aisVessels = new ArrayList<>();
|
this.aisVessels = new ArrayList<>();
|
||||||
|
this.navigationAids = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setListener(NMEAParserListener listener) {
|
public void setListener(NMEAParserListener listener) {
|
||||||
@@ -97,6 +101,7 @@ public class NMEAParser {
|
|||||||
String cleanedSentence = cleanNMEASentence(nmeaSentence);
|
String cleanedSentence = cleanNMEASentence(nmeaSentence);
|
||||||
if (cleanedSentence == null) {
|
if (cleanedSentence == null) {
|
||||||
Log.w(TAG, "NMEA сообщение не удалось очистить или слишком короткое: " + nmeaSentence);
|
Log.w(TAG, "NMEA сообщение не удалось очистить или слишком короткое: " + nmeaSentence);
|
||||||
|
LogSender.logDroppedNMEA("Очистка не удалась", nmeaSentence, "Сообщение слишком короткое или некорректное");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Диагностика: логируем только каждые 10 секунд
|
// Диагностика: логируем только каждые 10 секунд
|
||||||
@@ -114,6 +119,7 @@ public class NMEAParser {
|
|||||||
String[] fields = cleanedSentence.split(",");
|
String[] fields = cleanedSentence.split(",");
|
||||||
if (fields.length < 2) {
|
if (fields.length < 2) {
|
||||||
Log.w(TAG, "NMEA сообщение слишком короткое: " + cleanedSentence);
|
Log.w(TAG, "NMEA сообщение слишком короткое: " + cleanedSentence);
|
||||||
|
LogSender.logDroppedNMEA("Слишком короткое", cleanedSentence, "Меньше 2 полей: " + fields.length);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +127,7 @@ public class NMEAParser {
|
|||||||
String preamble = fields[0];
|
String preamble = fields[0];
|
||||||
if (preamble.length() < 6) {
|
if (preamble.length() < 6) {
|
||||||
Log.w(TAG, "Некорректная приамбула: " + preamble);
|
Log.w(TAG, "Некорректная приамбула: " + preamble);
|
||||||
|
LogSender.logDroppedNMEA("Некорректная приамбула", cleanedSentence, "Длина приамбуды: " + preamble.length());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,15 +166,18 @@ public class NMEAParser {
|
|||||||
} else {
|
} else {
|
||||||
// Убираем лишние логи - только каждые 10 секунд
|
// Убираем лишние логи - только каждые 10 секунд
|
||||||
long now2 = System.currentTimeMillis();
|
long now2 = System.currentTimeMillis();
|
||||||
if (now2 - lastNMEALogTime > 10000) {
|
|
||||||
Log.d(TAG, "📡 NMEAParser: неподдерживаемый тип: " + messageType);
|
Log.d(TAG, "📡 NMEAParser: неподдерживаемый тип: " + messageType);
|
||||||
|
LogSender.logDroppedNMEA("Неподдерживаемый тип", cleanedSentence, "Тип: " + messageType);
|
||||||
lastNMEALogTime = now2;
|
lastNMEALogTime = now2;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Ошибка парсинга NMEA: " + e.getMessage(), e);
|
Log.e(TAG, "Ошибка парсинга NMEA: " + e.getMessage(), e);
|
||||||
|
LogSender.logError("NMEA_PARSE_EXCEPTION", "Ошибка парсинга NMEA",
|
||||||
|
String.format("Exception: %s | Message: %s", e.getMessage(), cleanedSentence));
|
||||||
if (listener != null) {
|
if (listener != null) {
|
||||||
listener.onParseError("Ошибка парсинга NMEA: " + e.getMessage());
|
listener.onParseError("Ошибка парсинга NMEA: " + e.getMessage());
|
||||||
}
|
}
|
||||||
@@ -509,44 +519,16 @@ public class NMEAParser {
|
|||||||
listener.onVesselUpdated(ownVessel);
|
listener.onVesselUpdated(ownVessel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Парсит GLL сообщение (Geographic Position - Latitude/Longitude)
|
* Парсит GLL сообщение (Geographic Position - Latitude/Longitude)
|
||||||
* В гибридном режиме игнорируем
|
* В гибридном режиме игнорируем
|
||||||
* Формат: $GPGLL,lat,N/S,lon,E/W,time,status,mode*checksum
|
* Формат: $GPGLL,lat,N/S,lon,E/W,time,status,mode*checksum
|
||||||
*/
|
*/
|
||||||
private void parseGLL(String[] fields) {
|
private void parseGLL(String[] fields) {
|
||||||
// Разбираем время фикса (поле 5), статус (поле 6) и режим (поле 7)
|
// GLL: не обновляем fixQuality и время фикса — источники: GSA и RMC/ZDA
|
||||||
String utcTimeStr = getField(fields, 5); // hhmmss.ss
|
|
||||||
String status = getField(fields, 6); // A/V
|
|
||||||
String mode = getField(fields, 7); // A/D/E/M/S/N (может отсутствовать)
|
|
||||||
|
|
||||||
// Устанавливаем fixQuality на основе статуса и режима
|
|
||||||
if (status != null) {
|
|
||||||
if ("A".equals(status)) {
|
|
||||||
// Валидные данные: уточняем по mode
|
|
||||||
if (mode != null) {
|
|
||||||
switch (mode) {
|
|
||||||
case "A": ownVessel.setFixQuality("AUTONOMOUS"); break;
|
|
||||||
case "D": ownVessel.setFixQuality("DIFFERENTIAL"); break;
|
|
||||||
case "E": ownVessel.setFixQuality("ESTIMATED"); break;
|
|
||||||
case "M": ownVessel.setFixQuality("MANUAL"); break;
|
|
||||||
case "S": ownVessel.setFixQuality("SIMULATOR"); break;
|
|
||||||
case "N": ownVessel.setFixQuality("NOT_VALID"); break;
|
|
||||||
default: ownVessel.setFixQuality("AUTONOMOUS"); break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ownVessel.setFixQuality("AUTONOMOUS");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ownVessel.setFixQuality("NOT_VALID");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GLL не содержит дату — epoch не пишем, но строковое время сохраним
|
|
||||||
if (utcTimeStr != null && utcTimeStr.length() >= 6) {
|
|
||||||
ownVessel.setFixTimeNmea(utcTimeStr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если не в гибридном режиме — обновляем координаты
|
// Если не в гибридном режиме — обновляем координаты
|
||||||
if (!hybridMode) {
|
if (!hybridMode) {
|
||||||
@@ -800,6 +782,13 @@ public class NMEAParser {
|
|||||||
ownVessel.setHdop(hdop);
|
ownVessel.setHdop(hdop);
|
||||||
ownVessel.setVdop(vdop);
|
ownVessel.setVdop(vdop);
|
||||||
|
|
||||||
|
// Обновляем оценку точности в метрах из HDOP
|
||||||
|
// Эмпирически принимаем ~5 м на единицу HDOP (типовое допущение для GNSS)
|
||||||
|
if (hdop > 0) {
|
||||||
|
float accuracyMeters = (float)(hdop * 5.0);
|
||||||
|
ownVessel.setAccuracy(accuracyMeters);
|
||||||
|
}
|
||||||
|
|
||||||
// Отправляем DOP значения в GPS Location Listener
|
// Отправляем DOP значения в GPS Location Listener
|
||||||
if (gpsLocationListener != null) {
|
if (gpsLocationListener != null) {
|
||||||
gpsLocationListener.setDOPValues(pdop, hdop, vdop);
|
gpsLocationListener.setDOPValues(pdop, hdop, vdop);
|
||||||
@@ -827,6 +816,7 @@ public class NMEAParser {
|
|||||||
// Log.d(TAG, "AIS поля (" + fields.length + "): " + java.util.Arrays.toString(fields));
|
// Log.d(TAG, "AIS поля (" + fields.length + "): " + java.util.Arrays.toString(fields));
|
||||||
if (fields.length < 7) {
|
if (fields.length < 7) {
|
||||||
Log.w(TAG, "AIS сообщение слишком короткое: " + ais);
|
Log.w(TAG, "AIS сообщение слишком короткое: " + ais);
|
||||||
|
LogSender.logAISParseErrorWithFullNMEA("Слишком короткое", ais, ais, "Поля: " + fields.length + " < 7");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,6 +866,7 @@ public class NMEAParser {
|
|||||||
// Проверяем контрольную сумму
|
// Проверяем контрольную сумму
|
||||||
if (!validateChecksum(ais)) {
|
if (!validateChecksum(ais)) {
|
||||||
//Log.w(TAG, "AIS сообщение с неверной контрольной суммой: " + ais);
|
//Log.w(TAG, "AIS сообщение с неверной контрольной суммой: " + ais);
|
||||||
|
LogSender.logAISParseErrorWithFullNMEA("Неверная контрольная сумма", ais, payload, "Checksum validation failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,19 +874,21 @@ public class NMEAParser {
|
|||||||
if (payload != null && !payload.trim().isEmpty()) {
|
if (payload != null && !payload.trim().isEmpty()) {
|
||||||
if (totalFragments == 1) {
|
if (totalFragments == 1) {
|
||||||
// Одноканальное сообщение - декодируем сразу
|
// Одноканальное сообщение - декодируем сразу
|
||||||
decodeAISPayload(payload, channel != null && channel.equals("A") ? 0 : 1);
|
decodeAISPayload(payload, channel != null && channel.equals("A") ? 0 : 1, ais);
|
||||||
} else {
|
} else {
|
||||||
// Многочастное сообщение - собираем фрагменты
|
// Многочастное сообщение - собираем фрагменты
|
||||||
// Используем номер фрагмента как sequenceId если поле пустое
|
// Используем номер фрагмента как sequenceId если поле пустое
|
||||||
String actualSequenceId = (sequenceId != null && !sequenceId.trim().isEmpty()) ?
|
String actualSequenceId = (sequenceId != null && !sequenceId.trim().isEmpty()) ?
|
||||||
sequenceId : String.valueOf(fragmentNumber);
|
sequenceId : String.valueOf(fragmentNumber);
|
||||||
collectAISFragments(actualSequenceId, fragmentNumber, totalFragments, payload, channel != null && channel.equals("A") ? 0 : 1);
|
collectAISFragments(actualSequenceId, fragmentNumber, totalFragments, payload, channel != null && channel.equals("A") ? 0 : 1, ais);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
//Log.w(TAG, "AIS payload пустой, пропускаем сообщение");
|
//Log.w(TAG, "AIS payload пустой, пропускаем сообщение");
|
||||||
|
LogSender.logAISParseErrorWithFullNMEA("Пустой payload", ais, payload, "Payload is null or empty");
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
//Log.e(TAG, "Ошибка парсинга AIS сообщения: " + e.getMessage() + " для сообщения: " + ais);
|
//Log.e(TAG, "Ошибка парсинга AIS сообщения: " + e.getMessage() + " для сообщения: " + ais);
|
||||||
|
LogSender.logAISParseErrorWithFullNMEA("Exception", ais, ais, "Exception: " + e.getMessage());
|
||||||
if (listener != null) {
|
if (listener != null) {
|
||||||
listener.onParseError("Ошибка парсинга AIS: " + e.getMessage());
|
listener.onParseError("Ошибка парсинга AIS: " + e.getMessage());
|
||||||
}
|
}
|
||||||
@@ -905,7 +898,7 @@ public class NMEAParser {
|
|||||||
/**
|
/**
|
||||||
* Декодирует AIS payload
|
* Декодирует AIS payload
|
||||||
*/
|
*/
|
||||||
private void decodeAISPayload(String payload, int channel) {
|
private void decodeAISPayload(String payload, int channel, String fullNMEAMessage) {
|
||||||
try {
|
try {
|
||||||
// Определяем тип AIS сообщения по первым 6 битам
|
// Определяем тип AIS сообщения по первым 6 битам
|
||||||
String messageTypeBits = decodeAISField(payload, 0, 6);
|
String messageTypeBits = decodeAISField(payload, 0, 6);
|
||||||
@@ -953,10 +946,12 @@ public class NMEAParser {
|
|||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
Log.d(TAG, "Неподдерживаемый тип AIS сообщения: " + messageType);
|
Log.d(TAG, "Неподдерживаемый тип AIS сообщения: " + messageType);
|
||||||
|
LogSender.logAISParseErrorWithFullNMEA("Неподдерживаемый тип", fullNMEAMessage, payload, "Тип: " + messageType);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Ошибка декодирования AIS payload: " + e.getMessage(), e);
|
Log.e(TAG, "Ошибка декодирования AIS payload: " + e.getMessage(), e);
|
||||||
|
LogSender.logAISParseErrorWithFullNMEA("Payload decode exception", fullNMEAMessage, payload, "Exception: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,7 +959,7 @@ public class NMEAParser {
|
|||||||
* Собирает фрагменты многочастного AIS сообщения
|
* Собирает фрагменты многочастного AIS сообщения
|
||||||
*/
|
*/
|
||||||
private void collectAISFragments(String sequenceId, int fragmentNumber, int totalFragments,
|
private void collectAISFragments(String sequenceId, int fragmentNumber, int totalFragments,
|
||||||
String payload, int channel) {
|
String payload, int channel, String fullNMEAMessage) {
|
||||||
String key = sequenceId + "_" + channel;
|
String key = sequenceId + "_" + channel;
|
||||||
|
|
||||||
// Log.d(TAG, String.format("Собираем AIS фраг мент: %d/%d для %s",
|
// Log.d(TAG, String.format("Собираем AIS фраг мент: %d/%d для %s",
|
||||||
@@ -1007,7 +1002,7 @@ public class NMEAParser {
|
|||||||
// Log.d(TAG, "Собрано полное AIS сообщение длиной " + completePayload.length() + " символов");
|
// Log.d(TAG, "Собрано полное AIS сообщение длиной " + completePayload.length() + " символов");
|
||||||
|
|
||||||
// Декодируем полное сообщение
|
// Декодируем полное сообщение
|
||||||
decodeAISPayload(completePayload, channel);
|
decodeAISPayload(completePayload, channel, fullNMEAMessage);
|
||||||
|
|
||||||
// Удаляем собранные фрагменты
|
// Удаляем собранные фрагменты
|
||||||
aisFragments.remove(key);
|
aisFragments.remove(key);
|
||||||
@@ -1079,6 +1074,9 @@ public class NMEAParser {
|
|||||||
", payloadLength=" + payload.length() +
|
", payloadLength=" + payload.length() +
|
||||||
", binaryLength=" + fullBinary.length()
|
", binaryLength=" + fullBinary.length()
|
||||||
);
|
);
|
||||||
|
LogSender.logAISParseError("AIS поле за границами", payload,
|
||||||
|
String.format("startBit=%d, length=%d, payloadLength=%d, binaryLength=%d",
|
||||||
|
startBit, length, payload.length(), fullBinary.length()));
|
||||||
// Если поле выходит за границы, возвращаем то что есть, дополняя нулями
|
// Если поле выходит за границы, возвращаем то что есть, дополняя нулями
|
||||||
if (startBit >= fullBinary.length()) {
|
if (startBit >= fullBinary.length()) {
|
||||||
// Если startBit уже за границами, возвращаем строку из нулей
|
// Если startBit уже за границами, возвращаем строку из нулей
|
||||||
@@ -1172,9 +1170,11 @@ public class NMEAParser {
|
|||||||
// Проверяем, что координаты в разумных пределах
|
// Проверяем, что координаты в разумных пределах
|
||||||
if (latitude < -90 || latitude > 90) {
|
if (latitude < -90 || latitude > 90) {
|
||||||
Log.w(TAG, "Широта вне допустимых пределов: " + latitude);
|
Log.w(TAG, "Широта вне допустимых пределов: " + latitude);
|
||||||
|
LogSender.logAISParseError("Некорректная широта", payload, "Latitude: " + latitude);
|
||||||
}
|
}
|
||||||
if (longitude < -180 || longitude > 180) {
|
if (longitude < -180 || longitude > 180) {
|
||||||
Log.w(TAG, "Долгота вне допустимых пределов: " + longitude);
|
Log.w(TAG, "Долгота вне допустимых пределов: " + longitude);
|
||||||
|
LogSender.logAISParseError("Некорректная долгота", payload, "Longitude: " + longitude);
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, String.format("AIS Position: MMSI=%d, lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, status=%d, heading=%.1f, rot=%.1f",
|
Log.d(TAG, String.format("AIS Position: MMSI=%d, lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, status=%d, heading=%.1f, rot=%.1f",
|
||||||
@@ -1223,6 +1223,7 @@ public class NMEAParser {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Ошибка декодирования Position Report: " + e.getMessage(), e);
|
Log.e(TAG, "Ошибка декодирования Position Report: " + e.getMessage(), e);
|
||||||
|
LogSender.logAISParseError("Position Report decode exception", payload, "Exception: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1364,6 +1365,7 @@ public class NMEAParser {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Ошибка декодирования Static Data: " + e.getMessage(), e);
|
Log.e(TAG, "Ошибка декодирования Static Data: " + e.getMessage(), e);
|
||||||
|
LogSender.logAISParseError("Static Data decode exception", payload, "Exception: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1625,6 +1627,46 @@ public class NMEAParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает тип навигационного знака по коду согласно стандарту AIS
|
||||||
|
*/
|
||||||
|
private String getAidToNavigationType(int aidType) {
|
||||||
|
switch (aidType) {
|
||||||
|
case 0: return "Reference point";
|
||||||
|
case 1: return "RACON (radar transponder marking a navigation hazard)";
|
||||||
|
case 2: return "Fixed structure off shore, such as oil platforms, wind farms, rigs";
|
||||||
|
case 3: return "Spare, Reserved for future use";
|
||||||
|
case 4: return "Light, without sectors";
|
||||||
|
case 5: return "Light, with sectors";
|
||||||
|
case 6: return "Leading Light Front";
|
||||||
|
case 7: return "Leading Light Rear";
|
||||||
|
case 8: return "Beacon, Cardinal N";
|
||||||
|
case 9: return "Beacon, Cardinal E";
|
||||||
|
case 10: return "Beacon, Cardinal S";
|
||||||
|
case 11: return "Beacon, Cardinal W";
|
||||||
|
case 12: return "Beacon, Port hand";
|
||||||
|
case 13: return "Beacon, Starboard hand";
|
||||||
|
case 14: return "Beacon, Preferred Channel port hand";
|
||||||
|
case 15: return "Beacon, Preferred Channel starboard hand";
|
||||||
|
case 16: return "Beacon, Isolated danger";
|
||||||
|
case 17: return "Beacon, Safe water";
|
||||||
|
case 18: return "Beacon, Special mark";
|
||||||
|
case 19: return "Cardinal Mark N";
|
||||||
|
case 20: return "Cardinal Mark E";
|
||||||
|
case 21: return "Cardinal Mark S";
|
||||||
|
case 22: return "Cardinal Mark W";
|
||||||
|
case 23: return "Port hand Mark";
|
||||||
|
case 24: return "Starboard hand Mark";
|
||||||
|
case 25: return "Preferred Channel port hand";
|
||||||
|
case 26: return "Preferred Channel starboard hand";
|
||||||
|
case 27: return "Isolated danger";
|
||||||
|
case 28: return "Safe water";
|
||||||
|
case 29: return "Special mark";
|
||||||
|
case 30: return "Light Vessel / LANBY / Rigs";
|
||||||
|
default: return "Unknown Aid-to-Navigation";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получает тип судна по коду согласно стандарту AIS
|
* Получает тип судна по коду согласно стандарту AIS
|
||||||
*/
|
*/
|
||||||
@@ -1752,6 +1794,23 @@ public class NMEAParser {
|
|||||||
return newVessel;
|
return newVessel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Находит существующий навигационный знак или создает новый
|
||||||
|
*/
|
||||||
|
private AISNavigationAid findOrCreateNavigationAid(String mmsi) {
|
||||||
|
for (AISNavigationAid aid : navigationAids) {
|
||||||
|
if (mmsi.equals(aid.getMmsi())) {
|
||||||
|
return aid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем новый навигационный знак
|
||||||
|
AISNavigationAid newAid = new AISNavigationAid(mmsi);
|
||||||
|
navigationAids.add(newAid);
|
||||||
|
Log.d(TAG, "Создан новый навигационный знак: " + mmsi);
|
||||||
|
return newAid;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Очищает устаревшие AIS суда (данные старше 10 минут)
|
* Очищает устаревшие AIS суда (данные старше 10 минут)
|
||||||
*/
|
*/
|
||||||
@@ -1793,6 +1852,54 @@ public class NMEAParser {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает список всех навигационных знаков
|
||||||
|
*/
|
||||||
|
public List<AISNavigationAid> getNavigationAids() {
|
||||||
|
return new ArrayList<>(navigationAids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает количество активных навигационных знаков
|
||||||
|
*/
|
||||||
|
public int getActiveNavigationAidCount() {
|
||||||
|
cleanupStaleNavigationAids();
|
||||||
|
return navigationAids.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает навигационный знак по MMSI
|
||||||
|
*/
|
||||||
|
public AISNavigationAid getNavigationAidByMMSI(String mmsi) {
|
||||||
|
for (AISNavigationAid aid : navigationAids) {
|
||||||
|
if (mmsi.equals(aid.getMmsi())) {
|
||||||
|
return aid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очищает устаревшие навигационные знаки (данные старше 10 минут)
|
||||||
|
*/
|
||||||
|
public void cleanupStaleNavigationAids() {
|
||||||
|
java.util.Iterator<AISNavigationAid> iterator = navigationAids.iterator();
|
||||||
|
int removedCount = 0;
|
||||||
|
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
AISNavigationAid aid = iterator.next();
|
||||||
|
if (aid.isDataStale(10)) {
|
||||||
|
iterator.remove();
|
||||||
|
removedCount++;
|
||||||
|
Log.d(TAG, "Удален устаревший навигационный знак: " + aid.getMmsi());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedCount > 0) {
|
||||||
|
Log.i(TAG, "Удалено " + removedCount + " устаревших навигационных знаков");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обновляет статус активности AIS судов
|
* Обновляет статус активности AIS судов
|
||||||
*/
|
*/
|
||||||
@@ -1822,6 +1929,8 @@ public class NMEAParser {
|
|||||||
return isPositive ? result : -result;
|
return isPositive ? result : -result;
|
||||||
} catch (NumberFormatException e) {
|
} catch (NumberFormatException e) {
|
||||||
Log.w(TAG, "Ошибка парсинга координаты: " + coordinate + ", ошибка: " + e.getMessage());
|
Log.w(TAG, "Ошибка парсинга координаты: " + coordinate + ", ошибка: " + e.getMessage());
|
||||||
|
LogSender.logError("COORDINATE_PARSE_ERROR", "Ошибка парсинга координаты",
|
||||||
|
String.format("Coordinate: %s, Error: %s", coordinate, e.getMessage()));
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1994,6 +2103,7 @@ public class NMEAParser {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Ошибка декодирования Base Station Report: " + e.getMessage(), e);
|
Log.e(TAG, "Ошибка декодирования Base Station Report: " + e.getMessage(), e);
|
||||||
|
LogSender.logAISParseError("Base Station Report decode exception", payload, "Exception: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2040,6 +2150,7 @@ public class NMEAParser {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Ошибка декодирования Safety Broadcast: " + e.getMessage(), e);
|
Log.e(TAG, "Ошибка декодирования Safety Broadcast: " + e.getMessage(), e);
|
||||||
|
LogSender.logAISParseError("Safety Broadcast decode exception", payload, "Exception: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2162,6 +2273,7 @@ public class NMEAParser {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Ошибка декодирования Class B Position Report: " + e.getMessage(), e);
|
Log.e(TAG, "Ошибка декодирования Class B Position Report: " + e.getMessage(), e);
|
||||||
|
LogSender.logAISParseError("Class B Position Report decode exception", payload, "Exception: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2178,6 +2290,7 @@ public class NMEAParser {
|
|||||||
|
|
||||||
if (totalBits < 312) { // Минимум для Extended Class B
|
if (totalBits < 312) { // Минимум для Extended Class B
|
||||||
Log.w(TAG, "Extended Class B payload слишком короткий: " + totalBits + " бит, ожидается минимум 312");
|
Log.w(TAG, "Extended Class B payload слишком короткий: " + totalBits + " бит, ожидается минимум 312");
|
||||||
|
LogSender.logAISParseError("Extended Class B слишком короткий", payload, "Bits: " + totalBits + " < 312");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2314,6 +2427,8 @@ public class NMEAParser {
|
|||||||
// Проверяем, что размеры в разумных пределах (0-1000 метров)
|
// Проверяем, что размеры в разумных пределах (0-1000 метров)
|
||||||
if (dimA > 1000 || dimB > 1000 || dimC > 1000 || dimD > 1000) {
|
if (dimA > 1000 || dimB > 1000 || dimC > 1000 || dimD > 1000) {
|
||||||
Log.w(TAG, "Размеры судна выходят за разумные пределы: A=" + dimA + ", B=" + dimB + ", C=" + dimC + ", D=" + dimD);
|
Log.w(TAG, "Размеры судна выходят за разумные пределы: A=" + dimA + ", B=" + dimB + ", C=" + dimC + ", D=" + dimD);
|
||||||
|
LogSender.logAISParseError("Некорректные размеры", payload,
|
||||||
|
String.format("Dimensions out of range: A=%d, B=%d, C=%d, D=%d", dimA, dimB, dimC, dimD));
|
||||||
// Возможно, мы неправильно интерпретируем битовые поля
|
// Возможно, мы неправильно интерпретируем битовые поля
|
||||||
// Попробуем интерпретировать как 6-битные значения
|
// Попробуем интерпретировать как 6-битные значения
|
||||||
dimA = dimA & 0x3F; // Берем только младшие 6 бит
|
dimA = dimA & 0x3F; // Берем только младшие 6 бит
|
||||||
@@ -2380,6 +2495,7 @@ public class NMEAParser {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Ошибка декодирования Extended Class B Position Report: " + e.getMessage(), e);
|
Log.e(TAG, "Ошибка декодирования Extended Class B Position Report: " + e.getMessage(), e);
|
||||||
|
LogSender.logAISParseError("Extended Class B decode exception", payload, "Exception: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2388,40 +2504,65 @@ public class NMEAParser {
|
|||||||
*/
|
*/
|
||||||
private void decodeAidToNavigationReport(String payload) {
|
private void decodeAidToNavigationReport(String payload) {
|
||||||
try {
|
try {
|
||||||
// Log.d(TAG, "Декодируем Aid-to-Navigation Report, payload: " + payload + " (длина: " + payload.length() + ")");
|
Log.d(TAG, "Декодируем Aid-to-Navigation Report, payload: " + payload + " (длина: " + payload.length() + ")");
|
||||||
|
|
||||||
|
// Проверяем длину payload - для Aid-to-Navigation должно быть достаточно битов
|
||||||
|
int totalBits = payload.length() * 6;
|
||||||
|
Log.d(TAG, "Общая длина payload в битах: " + totalBits);
|
||||||
|
|
||||||
|
if (totalBits < 192) { // Минимум для основных полей
|
||||||
|
Log.w(TAG, "Aid-to-Navigation payload слишком короткий: " + totalBits + " бит, ожидается минимум 192");
|
||||||
|
LogSender.logAISParseError("Aid-to-Navigation слишком короткий", payload, "Bits: " + totalBits + " < 192");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// MMSI (30 бит) - начинается с бита 8
|
// MMSI (30 бит) - начинается с бита 8
|
||||||
String mmsiBits = decodeAISField(payload, 8, 30);
|
String mmsiBits = decodeAISField(payload, 8, 30);
|
||||||
int mmsi = Integer.parseInt(mmsiBits, 2);
|
int mmsi = Integer.parseInt(mmsiBits, 2);
|
||||||
// Убираем лишние логи
|
Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi);
|
||||||
// Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi);
|
|
||||||
|
|
||||||
// Aid Type (5 бит) - бит 38
|
// Aid Type (5 бит) - бит 38
|
||||||
String aidTypeBits = decodeAISField(payload, 38, 5);
|
String aidTypeBits = decodeAISField(payload, 38, 5);
|
||||||
int aidType = Integer.parseInt(aidTypeBits, 2);
|
int aidType = Integer.parseInt(aidTypeBits, 2);
|
||||||
// Log.d(TAG, "Aid Type bits: " + aidTypeBits + " = " + aidType);
|
Log.d(TAG, "Aid Type bits: " + aidTypeBits + " = " + aidType);
|
||||||
|
|
||||||
// Name (120 бит) - бит 43
|
// Name (120 бит) - бит 43
|
||||||
String nameBits = decodeAISField(payload, 43, 120);
|
String nameBits = decodeAISField(payload, 43, 120);
|
||||||
String aidName = decodeAISString(nameBits);
|
String aidName = decodeAISString(nameBits);
|
||||||
// Log.d(TAG, "Name bits: " + nameBits + " = '" + aidName + "'");
|
Log.d(TAG, "Name bits: " + nameBits + " = '" + aidName + "'");
|
||||||
|
|
||||||
// Position Accuracy (1 бит) - бит 163
|
// Position Accuracy (1 бит) - бит 163
|
||||||
String accuracyBits = decodeAISField(payload, 163, 1);
|
String accuracyBits = decodeAISField(payload, 163, 1);
|
||||||
int accuracy = Integer.parseInt(accuracyBits, 2);
|
int accuracy = Integer.parseInt(accuracyBits, 2);
|
||||||
// Log.d(TAG, "Accuracy bits: " + accuracyBits + " = " + accuracy);
|
Log.d(TAG, "Accuracy bits: " + accuracyBits + " = " + accuracy);
|
||||||
|
|
||||||
// Longitude (28 бит) - бит 164
|
// Longitude (28 бит) - бит 164
|
||||||
String lonBits = decodeAISField(payload, 164, 28);
|
String lonBits = decodeAISField(payload, 164, 28);
|
||||||
double longitude = parseAISCoordinate(lonBits, 28);
|
double longitude = parseAISCoordinate(lonBits, 28);
|
||||||
// Убираем лишние логи
|
Log.d(TAG, "Longitude bits: " + lonBits + " = " + longitude);
|
||||||
// Log.d(TAG, "Longitude bits: " + lonBits + " = " + longitude);
|
|
||||||
|
|
||||||
// Latitude (27 бит) - бит 192
|
// Latitude (27 бит) - бит 192
|
||||||
String latBits = decodeAISField(payload, 192, 27);
|
String latBits = decodeAISField(payload, 192, 27);
|
||||||
double latitude = parseAISCoordinate(latBits, 27);
|
double latitude = parseAISCoordinate(latBits, 27);
|
||||||
// Убираем лишние логи
|
Log.d(TAG, "Latitude bits: " + latBits + " = " + latitude);
|
||||||
// Log.d(TAG, "Latitude bits: " + latBits + " = " + latitude);
|
|
||||||
|
// Создаем навигационный знак
|
||||||
|
AISNavigationAid navigationAid = findOrCreateNavigationAid(String.valueOf(mmsi));
|
||||||
|
navigationAid.setAidName(aidName);
|
||||||
|
navigationAid.setAidType(aidType);
|
||||||
|
navigationAid.updatePosition(latitude, longitude);
|
||||||
|
navigationAid.setPositionAccuracy(accuracy == 1);
|
||||||
|
|
||||||
|
// Проверяем, есть ли достаточно битов для размеров
|
||||||
|
if (totalBits < 235) {
|
||||||
|
Log.w(TAG, "Aid-to-Navigation - недостаточно битов для размеров: " + totalBits + " < 235");
|
||||||
|
navigationAid.setLastUpdate(java.time.LocalDateTime.now());
|
||||||
|
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onNavigationAidUpdated(navigationAid);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Dimension Reference (4 бита) - бит 219
|
// Dimension Reference (4 бита) - бит 219
|
||||||
String dimRefABits = decodeAISField(payload, 219, 4);
|
String dimRefABits = decodeAISField(payload, 219, 4);
|
||||||
@@ -2434,6 +2575,13 @@ public class NMEAParser {
|
|||||||
int dimRefC = Integer.parseInt(dimRefCBits, 2);
|
int dimRefC = Integer.parseInt(dimRefCBits, 2);
|
||||||
int dimRefD = Integer.parseInt(dimRefDBits, 2);
|
int dimRefD = Integer.parseInt(dimRefDBits, 2);
|
||||||
|
|
||||||
|
Log.d(TAG, "Dimension Reference: A=" + dimRefA + ", B=" + dimRefB + ", C=" + dimRefC + ", D=" + dimRefD);
|
||||||
|
|
||||||
|
navigationAid.setDimRefA(dimRefA);
|
||||||
|
navigationAid.setDimRefB(dimRefB);
|
||||||
|
navigationAid.setDimRefC(dimRefC);
|
||||||
|
navigationAid.setDimRefD(dimRefD);
|
||||||
|
|
||||||
// Vessel Dimensions (30 бит) - бит 235
|
// Vessel Dimensions (30 бит) - бит 235
|
||||||
// Dim.A (10 бит) - от носа до антенны
|
// Dim.A (10 бит) - от носа до антенны
|
||||||
String dimABits = decodeAISField(payload, 235, 10);
|
String dimABits = decodeAISField(payload, 235, 10);
|
||||||
@@ -2443,43 +2591,92 @@ public class NMEAParser {
|
|||||||
String dimCBits = decodeAISField(payload, 255, 10);
|
String dimCBits = decodeAISField(payload, 255, 10);
|
||||||
// Dim.D (10 бит) - от антенны до правого борта
|
// Dim.D (10 бит) - от антенны до правого борта
|
||||||
String dimDBits = decodeAISField(payload, 265, 10);
|
String dimDBits = decodeAISField(payload, 265, 10);
|
||||||
// Draft (8 бит) - осадка
|
|
||||||
String draftBits = decodeAISField(payload, 275, 8);
|
|
||||||
|
|
||||||
int dimA = Integer.parseInt(dimABits, 2);
|
int dimA = Integer.parseInt(dimABits, 2);
|
||||||
int dimB = Integer.parseInt(dimBBits, 2);
|
int dimB = Integer.parseInt(dimBBits, 2);
|
||||||
int dimC = Integer.parseInt(dimCBits, 2);
|
int dimC = Integer.parseInt(dimCBits, 2);
|
||||||
int dimD = Integer.parseInt(dimDBits, 2);
|
int dimD = Integer.parseInt(dimDBits, 2);
|
||||||
|
|
||||||
|
Log.d(TAG, "Raw dimension bits - Dim.A: " + dimABits + ", Dim.B: " + dimBBits + ", Dim.C: " + dimCBits + ", Dim.D: " + dimDBits);
|
||||||
|
Log.d(TAG, "Raw dimensions - A=" + dimA + ", B=" + dimB + ", C=" + dimC + ", D=" + dimD);
|
||||||
|
|
||||||
// Размеры судна рассчитываются как:
|
// Размеры судна рассчитываются как:
|
||||||
// Длина = Dim.A + Dim.B (от носа до антенны + от антенны до кормы)
|
// Длина = Dim.A + Dim.B (от носа до антенны + от антенны до кормы)
|
||||||
// Ширина = Dim.C + Dim.D (от левого борта до антенны + от антенны до правого борта)
|
// Ширина = Dim.C + Dim.D (от левого борта до антенны + от антенны до правого борта)
|
||||||
double length = dimA + dimB;
|
double length = dimA + dimB;
|
||||||
double width = dimC + dimD;
|
double width = dimC + dimD;
|
||||||
double draft = Integer.parseInt(draftBits, 2) / 10.0;
|
|
||||||
|
|
||||||
// Log.d(TAG, String.format("AIS Aid-to-Navigation: MMSI=%d, type=%d, name='%s', lat=%.6f, lon=%.6f, L=%.1f, W=%.1f, D=%.1f",
|
Log.d(TAG, "Dimensions - Total Length (A+B): " + length + "m");
|
||||||
// mmsi, aidType, aidName, latitude, longitude, length, width, draft));
|
Log.d(TAG, "Dimensions - Total Width (C+D): " + width + "m");
|
||||||
|
|
||||||
// Создаем или обновляем AIS судно (навигационный знак)
|
navigationAid.setLength(length);
|
||||||
AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi));
|
navigationAid.setWidth(width);
|
||||||
vessel.updatePosition(latitude, longitude, 0.0, 0.0);
|
|
||||||
vessel.setPositionAccuracy(accuracy == 1);
|
// Проверяем, есть ли достаточно битов для дополнительных полей
|
||||||
vessel.setVesselName(aidName);
|
if (totalBits >= 283) {
|
||||||
vessel.setVesselType("Aid-to-Navigation");
|
// Draft (8 бит) - осадка
|
||||||
vessel.setLength(length);
|
String draftBits = decodeAISField(payload, 275, 8);
|
||||||
vessel.setWidth(width);
|
double draft = Integer.parseInt(draftBits, 2) / 10.0;
|
||||||
vessel.setDraft(draft);
|
Log.d(TAG, "Draft bits: " + draftBits + " = " + draft);
|
||||||
vessel.setLastUpdate(java.time.LocalDateTime.now());
|
navigationAid.setDraft(draft);
|
||||||
vessel.setVesselClass("Navigation Aid");
|
|
||||||
|
// EPFD Type (4 бита) - бит 283
|
||||||
|
if (totalBits >= 287) {
|
||||||
|
String epfdBits = decodeAISField(payload, 283, 4);
|
||||||
|
int epfdType = Integer.parseInt(epfdBits, 2);
|
||||||
|
Log.d(TAG, "EPFD Type bits: " + epfdBits + " = " + epfdType);
|
||||||
|
navigationAid.setEpfdType(epfdType);
|
||||||
|
|
||||||
|
// UTC Second (6 бит) - бит 287
|
||||||
|
if (totalBits >= 293) {
|
||||||
|
String utcSecondBits = decodeAISField(payload, 287, 6);
|
||||||
|
int utcSecond = Integer.parseInt(utcSecondBits, 2);
|
||||||
|
Log.d(TAG, "UTC Second bits: " + utcSecondBits + " = " + utcSecond);
|
||||||
|
navigationAid.setUtcSecond(utcSecond);
|
||||||
|
|
||||||
|
// Off Position Indicator (1 бит) - бит 293
|
||||||
|
if (totalBits >= 294) {
|
||||||
|
String offPosBits = decodeAISField(payload, 293, 1);
|
||||||
|
boolean offPosition = Integer.parseInt(offPosBits, 2) == 1;
|
||||||
|
Log.d(TAG, "Off Position Indicator bits: " + offPosBits + " = " + offPosition);
|
||||||
|
navigationAid.setOffPositionIndicator(offPosition);
|
||||||
|
|
||||||
|
// Regional Reserved (8 бит) - бит 294
|
||||||
|
if (totalBits >= 302) {
|
||||||
|
String regionalBits = decodeAISField(payload, 294, 8);
|
||||||
|
int regional = Integer.parseInt(regionalBits, 2);
|
||||||
|
Log.d(TAG, "Regional Reserved bits: " + regionalBits + " = " + regional);
|
||||||
|
navigationAid.setRegionalReserved(regional);
|
||||||
|
|
||||||
|
// RAIM flag (1 бит) - бит 302
|
||||||
|
if (totalBits >= 303) {
|
||||||
|
String raimBits = decodeAISField(payload, 302, 1);
|
||||||
|
boolean raim = Integer.parseInt(raimBits, 2) == 1;
|
||||||
|
Log.d(TAG, "RAIM flag bits: " + raimBits + " = " + raim);
|
||||||
|
navigationAid.setRaimFlag(raim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Aid-to-Navigation - недостаточно битов для дополнительных полей: " + totalBits + " < 283");
|
||||||
|
}
|
||||||
|
|
||||||
|
navigationAid.setLastUpdate(java.time.LocalDateTime.now());
|
||||||
|
|
||||||
|
Log.d(TAG, String.format("AIS Aid-to-Navigation: MMSI=%d, type=%d (%s), name='%s', lat=%.6f, lon=%.6f, L=%.1f, W=%.1f, D=%.1f",
|
||||||
|
mmsi, aidType, navigationAid.getAidTypeDescription(), aidName, latitude, longitude,
|
||||||
|
navigationAid.getLength(), navigationAid.getWidth(), navigationAid.getDraft()));
|
||||||
|
|
||||||
// Уведомляем слушателя
|
// Уведомляем слушателя
|
||||||
if (listener != null) {
|
if (listener != null) {
|
||||||
listener.onAISVesselUpdated(vessel);
|
listener.onNavigationAidUpdated(navigationAid);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Ошибка декодирования Aid-to-Navigation Report: " + e.getMessage(), e);
|
Log.e(TAG, "Ошибка декодирования Aid-to-Navigation Report: " + e.getMessage(), e);
|
||||||
|
LogSender.logAISParseError("Aid-to-Navigation Report decode exception", payload, "Exception: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2589,6 +2786,7 @@ public class NMEAParser {
|
|||||||
|
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Static Data Part B - недостаточно битов для размеров: " + totalBits + " < 168");
|
Log.w(TAG, "Static Data Part B - недостаточно битов для размеров: " + totalBits + " < 168");
|
||||||
|
LogSender.logAISParseError("Static Data Part B слишком короткий", payload, "Bits: " + totalBits + " < 168");
|
||||||
// Используем нулевые размеры
|
// Используем нулевые размеры
|
||||||
length = 0.0;
|
length = 0.0;
|
||||||
width = 0.0;
|
width = 0.0;
|
||||||
@@ -2623,6 +2821,7 @@ public class NMEAParser {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Ошибка декодирования Static Data Report: " + e.getMessage(), e);
|
Log.e(TAG, "Ошибка декодирования Static Data Report: " + e.getMessage(), e);
|
||||||
|
LogSender.logAISParseError("Static Data Report decode exception", payload, "Exception: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,23 @@ public class Repository {
|
|||||||
ioExecutor.execute(() -> aisVesselDao.upsert(entity));
|
ioExecutor.execute(() -> aisVesselDao.upsert(entity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void upsertAISSync(AISVesselEntity entity) {
|
||||||
|
aisVesselDao.upsert(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void upsertAISBatchSync(List<AISVesselEntity> entities) {
|
||||||
|
if (entities == null || entities.isEmpty()) return;
|
||||||
|
aisVesselDao.upsertAll(entities);
|
||||||
|
}
|
||||||
|
|
||||||
public void deleteStaleAIS(long thresholdEpochMs) {
|
public void deleteStaleAIS(long thresholdEpochMs) {
|
||||||
ioExecutor.execute(() -> aisVesselDao.deleteStale(thresholdEpochMs));
|
ioExecutor.execute(() -> aisVesselDao.deleteStale(thresholdEpochMs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int deleteStaleAISSync(long thresholdEpochMs) {
|
||||||
|
return aisVesselDao.deleteStale(thresholdEpochMs);
|
||||||
|
}
|
||||||
|
|
||||||
public List<AISVesselEntity> getAllAISSync() {
|
public List<AISVesselEntity> getAllAISSync() {
|
||||||
return aisVesselDao.getAll();
|
return aisVesselDao.getAll();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ public interface AISVesselDao {
|
|||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
void upsert(AISVesselEntity entity);
|
void upsert(AISVesselEntity entity);
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
void upsertAll(List<AISVesselEntity> entities);
|
||||||
|
|
||||||
@Update
|
@Update
|
||||||
void update(AISVesselEntity entity);
|
void update(AISVesselEntity entity);
|
||||||
|
|
||||||
@@ -29,7 +32,7 @@ public interface AISVesselDao {
|
|||||||
AISVesselEntity getByMmsi(String mmsi);
|
AISVesselEntity getByMmsi(String mmsi);
|
||||||
|
|
||||||
@Query("DELETE FROM ais_vessels WHERE lastUpdateEpochMs < :threshold")
|
@Query("DELETE FROM ais_vessels WHERE lastUpdateEpochMs < :threshold")
|
||||||
void deleteStale(long threshold);
|
int deleteStale(long threshold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import org.mapsforge.core.graphics.Bitmap;
|
|||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,14 +113,24 @@ public class MapForgeImpl implements MapInterface {
|
|||||||
@Override
|
@Override
|
||||||
public void updateAISVesselPosition(AISVessel vessel) {
|
public void updateAISVesselPosition(AISVessel vessel) {
|
||||||
Marker marker = aisMarkers.get(vessel.getMmsi());
|
Marker marker = aisMarkers.get(vessel.getMmsi());
|
||||||
if (marker != null) {
|
if (marker == null) {
|
||||||
LatLong newPosition = new LatLong(vessel.getLatitude(), vessel.getLongitude());
|
addAISVesselMarker(vessel);
|
||||||
marker.setLatLong(newPosition);
|
return;
|
||||||
|
}
|
||||||
// Используем heading вместо course для поворота маркера AIS судна
|
LatLong newPosition = new LatLong(vessel.getLatitude(), vessel.getLongitude());
|
||||||
double rotationAngle = vessel.getHeading() > 0 ? vessel.getHeading() : vessel.getCourse();
|
marker.setLatLong(newPosition);
|
||||||
org.mapsforge.core.graphics.Bitmap icon = createMapForgeIcon(Color.RED, rotationAngle);
|
|
||||||
marker.setBitmap(icon);
|
// Используем heading вместо course для поворота маркера AIS судна
|
||||||
|
double rotationAngle = vessel.getHeading() > 0 ? vessel.getHeading() : vessel.getCourse();
|
||||||
|
org.mapsforge.core.graphics.Bitmap icon = createMapForgeIcon(Color.RED, rotationAngle);
|
||||||
|
marker.setBitmap(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateAISVesselPositions(List<AISVessel> vessels) {
|
||||||
|
if (vessels == null) return;
|
||||||
|
for (AISVessel vessel : vessels) {
|
||||||
|
updateAISVesselPosition(vessel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.grigowashere.aismap.maps;
|
|||||||
import com.grigowashere.aismap.models.Vessel;
|
import com.grigowashere.aismap.models.Vessel;
|
||||||
import com.grigowashere.aismap.models.AISVessel;
|
import com.grigowashere.aismap.models.AISVessel;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Интерфейс для работы с картами
|
* Интерфейс для работы с картами
|
||||||
* Позволяет использовать разные SDK карт
|
* Позволяет использовать разные SDK карт
|
||||||
@@ -38,6 +40,11 @@ public interface MapInterface {
|
|||||||
* Обновление позиции AIS судна
|
* Обновление позиции AIS судна
|
||||||
*/
|
*/
|
||||||
void updateAISVesselPosition(AISVessel vessel);
|
void updateAISVesselPosition(AISVessel vessel);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пакетное обновление AIS судов
|
||||||
|
*/
|
||||||
|
void updateAISVesselPositions(List<AISVessel> vessels);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Удаление метки AIS судна
|
* Удаление метки AIS судна
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.graphics.drawable.Drawable;
|
|||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import com.grigowashere.aismap.models.AISVessel;
|
import com.grigowashere.aismap.models.AISVessel;
|
||||||
|
import com.grigowashere.aismap.models.AISNavigationAid;
|
||||||
import com.grigowashere.aismap.models.Vessel;
|
import com.grigowashere.aismap.models.Vessel;
|
||||||
import com.grigowashere.aismap.utils.SettingsManager;
|
import com.grigowashere.aismap.utils.SettingsManager;
|
||||||
import com.grigowashere.aismap.utils.GeoUtils;
|
import com.grigowashere.aismap.utils.GeoUtils;
|
||||||
@@ -32,6 +33,8 @@ import org.maplibre.android.style.expressions.Expression;
|
|||||||
import org.maplibre.android.style.layers.PropertyFactory;
|
import org.maplibre.android.style.layers.PropertyFactory;
|
||||||
import org.maplibre.android.style.layers.SymbolLayer;
|
import org.maplibre.android.style.layers.SymbolLayer;
|
||||||
import org.maplibre.android.style.sources.GeoJsonSource;
|
import org.maplibre.android.style.sources.GeoJsonSource;
|
||||||
|
import org.maplibre.android.style.sources.RasterSource;
|
||||||
|
import org.maplibre.android.style.layers.RasterLayer;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -55,6 +58,10 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
private static final String LAYER_AIS_PATHS = "ais_paths_layer";
|
private static final String LAYER_AIS_PATHS = "ais_paths_layer";
|
||||||
private static final String SOURCE_AIS_PREDICTIONS = "ais_predictions_source";
|
private static final String SOURCE_AIS_PREDICTIONS = "ais_predictions_source";
|
||||||
private static final String LAYER_AIS_PREDICTIONS = "ais_predictions_layer";
|
private static final String LAYER_AIS_PREDICTIONS = "ais_predictions_layer";
|
||||||
|
private static final String SOURCE_SEAMARKS = "seamarks_source";
|
||||||
|
private static final String LAYER_SEAMARKS = "seamarks_layer";
|
||||||
|
private static final String SOURCE_NAVIGATION_AIDS = "navigation_aids_source";
|
||||||
|
private static final String LAYER_NAVIGATION_AIDS = "navigation_aids_layer";
|
||||||
private static final String IMAGE_VESSEL_OWN = "ownship";
|
private static final String IMAGE_VESSEL_OWN = "ownship";
|
||||||
private static final String IMAGE_VESSEL_A = "vessel_icon_a";
|
private static final String IMAGE_VESSEL_A = "vessel_icon_a";
|
||||||
private static final String IMAGE_VESSEL_B = "vessel_icon_b";
|
private static final String IMAGE_VESSEL_B = "vessel_icon_b";
|
||||||
@@ -69,6 +76,44 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
private static final String TYPE_NAVY = "navy";
|
private static final String TYPE_NAVY = "navy";
|
||||||
private static final String TYPE_OTHER = "other";
|
private static final String TYPE_OTHER = "other";
|
||||||
private static final String IMAGE_VESSEL_LOSING = "vessel_icon_losing";
|
private static final String IMAGE_VESSEL_LOSING = "vessel_icon_losing";
|
||||||
|
// Navigation Aid icons - полный набор для всех типов буйков
|
||||||
|
private static final String IMAGE_NAVIGATION_AID = "navigation_aid";
|
||||||
|
|
||||||
|
// Кардинальные буи (North, East, South, West)
|
||||||
|
private static final String IMAGE_BUOY_CARDINAL_N = "buoy_cardinal_n";
|
||||||
|
private static final String IMAGE_BUOY_CARDINAL_E = "buoy_cardinal_e";
|
||||||
|
private static final String IMAGE_BUOY_CARDINAL_S = "buoy_cardinal_s";
|
||||||
|
private static final String IMAGE_BUOY_CARDINAL_W = "buoy_cardinal_w";
|
||||||
|
private static final String IMAGE_BUOY_CARDINAL = "buoy_cardinal"; // Общий fallback
|
||||||
|
|
||||||
|
// Латеральные буи (Port/Starboard)
|
||||||
|
private static final String IMAGE_BUOY_PORT = "buoy_port";
|
||||||
|
private static final String IMAGE_BUOY_STARBOARD = "buoy_starboard";
|
||||||
|
|
||||||
|
// Предпочтительные каналы
|
||||||
|
private static final String IMAGE_BUOY_PREFERRED_PORT = "buoy_preferred_port";
|
||||||
|
private static final String IMAGE_BUOY_PREFERRED_STARBOARD = "buoy_preferred_starboard";
|
||||||
|
|
||||||
|
// Маяки и огни
|
||||||
|
private static final String IMAGE_LIGHT = "light";
|
||||||
|
private static final String IMAGE_LIGHT_SECTOR = "light_sector";
|
||||||
|
private static final String IMAGE_LIGHT_LEADING_FRONT = "light_leading_front";
|
||||||
|
private static final String IMAGE_LIGHT_LEADING_REAR = "light_leading_rear";
|
||||||
|
|
||||||
|
// Буи и знаки
|
||||||
|
private static final String IMAGE_BEACON = "beacon";
|
||||||
|
private static final String IMAGE_BEACON_ISOLATED_DANGER = "beacon_isolated_danger";
|
||||||
|
private static final String IMAGE_BEACON_SAFE_WATER = "beacon_safe_water";
|
||||||
|
private static final String IMAGE_BEACON_SPECIAL = "beacon_special";
|
||||||
|
|
||||||
|
// Платформы и плавучие маяки
|
||||||
|
private static final String IMAGE_BASE_STATION = "base_station";
|
||||||
|
private static final String IMAGE_LIGHT_VESSEL = "light_vessel";
|
||||||
|
private static final String IMAGE_PLATFORM = "platform";
|
||||||
|
|
||||||
|
// RACON и специальные знаки
|
||||||
|
private static final String IMAGE_RACON = "racon";
|
||||||
|
private static final String IMAGE_REFERENCE_POINT = "reference_point";
|
||||||
// Status overlay drawable names present in res/drawable
|
// Status overlay drawable names present in res/drawable
|
||||||
private static final String STATUS_UNDER_WAY_ENGINE = "engine";
|
private static final String STATUS_UNDER_WAY_ENGINE = "engine";
|
||||||
private static final String STATUS_AT_ANCHOR = "achor"; // anchor icon filename
|
private static final String STATUS_AT_ANCHOR = "achor"; // anchor icon filename
|
||||||
@@ -177,6 +222,7 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
private final Map<String, JSONObject> idToFeature = new HashMap<>();
|
private final Map<String, JSONObject> idToFeature = new HashMap<>();
|
||||||
// Хранилище последних модельных объектов для кликов
|
// Хранилище последних модельных объектов для кликов
|
||||||
private final Map<String, AISVessel> idToAisVessel = new HashMap<>();
|
private final Map<String, AISVessel> idToAisVessel = new HashMap<>();
|
||||||
|
private final Map<String, AISNavigationAid> idToNavigationAid = new HashMap<>();
|
||||||
private Vessel lastOwnVessel;
|
private Vessel lastOwnVessel;
|
||||||
// Буфер координат пути собственного судна
|
// Буфер координат пути собственного судна
|
||||||
private final JSONArray ownPathCoords = new JSONArray();
|
private final JSONArray ownPathCoords = new JSONArray();
|
||||||
@@ -188,6 +234,10 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
|
|
||||||
private MarkerClickListener markerClickListener;
|
private MarkerClickListener markerClickListener;
|
||||||
|
|
||||||
|
// Pending центрирование до готовности карты/стиля
|
||||||
|
private Double pendingCenterLat = null;
|
||||||
|
private Double pendingCenterLon = null;
|
||||||
|
|
||||||
public MapLibreMapImpl(Context context, MapView mapView) {
|
public MapLibreMapImpl(Context context, MapView mapView) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.mapView = mapView;
|
this.mapView = mapView;
|
||||||
@@ -274,6 +324,18 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
try {
|
try {
|
||||||
mapView.getMapAsync(map -> {
|
mapView.getMapAsync(map -> {
|
||||||
maplibreMap = map;
|
maplibreMap = map;
|
||||||
|
// Отключаем встроенный компас MapLibre (он появлялся в углу
|
||||||
|
// при ненулевом bearing и дублировал наш компас), а также
|
||||||
|
// стандартные UI-элементы, которые нам не нужны.
|
||||||
|
try {
|
||||||
|
if (maplibreMap.getUiSettings() != null) {
|
||||||
|
maplibreMap.getUiSettings().setCompassEnabled(false);
|
||||||
|
maplibreMap.getUiSettings().setAttributionEnabled(false);
|
||||||
|
maplibreMap.getUiSettings().setLogoEnabled(false);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "Не удалось настроить UI MapLibre: " + e.getMessage());
|
||||||
|
}
|
||||||
maplibreMap.setStyle("https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", loadedStyle -> {
|
maplibreMap.setStyle("https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", loadedStyle -> {
|
||||||
style = loadedStyle;
|
style = loadedStyle;
|
||||||
ensureSourcesAndLayers();
|
ensureSourcesAndLayers();
|
||||||
@@ -302,6 +364,15 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
|
|
||||||
staleHandler.removeCallbacks(staleRunnable);
|
staleHandler.removeCallbacks(staleRunnable);
|
||||||
staleHandler.postDelayed(staleRunnable, 5_000L);
|
staleHandler.postDelayed(staleRunnable, 5_000L);
|
||||||
|
|
||||||
|
// Если было отложенное центрирование — применим его сразу после загрузки стиля
|
||||||
|
if (pendingCenterLat != null && pendingCenterLon != null) {
|
||||||
|
final double lat = pendingCenterLat;
|
||||||
|
final double lon = pendingCenterLon;
|
||||||
|
pendingCenterLat = null;
|
||||||
|
pendingCenterLon = null;
|
||||||
|
uiHandler.post(() -> centerOnPosition(lat, lon));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -502,6 +573,66 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateAISVesselPositions(List<AISVessel> vessels) {
|
||||||
|
if (vessels == null || vessels.isEmpty()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
int updated = 0;
|
||||||
|
int removed = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
|
||||||
|
for (AISVessel vessel : vessels) {
|
||||||
|
if (vessel == null || vessel.getMmsi() == null) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!GeoUtils.isValidCoordinates(vessel.getLatitude(), vessel.getLongitude())) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (vessel.shouldBeRemoved(settingsManager.getDataStaleRemoveMinutes())) {
|
||||||
|
idToFeature.remove(vessel.getMmsi());
|
||||||
|
idToAisVessel.remove(vessel.getMmsi());
|
||||||
|
aisPathFeatures.remove(vessel.getMmsi());
|
||||||
|
aisPredictionFeatures.remove(vessel.getMmsi());
|
||||||
|
removed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
idToAisVessel.put(vessel.getMmsi(), vessel);
|
||||||
|
JSONObject feature = buildFeature(
|
||||||
|
vessel.getMmsi(),
|
||||||
|
vessel.getLongitude(),
|
||||||
|
vessel.getLatitude(),
|
||||||
|
getDisplayCourse(vessel),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
boolean stale = vessel.isDataStale(settingsManager.getDataStaleWarningMinutes());
|
||||||
|
JSONObject props = feature.getJSONObject("properties");
|
||||||
|
props.put("icon", pickIconNameFor(vessel));
|
||||||
|
props.put("stale", stale);
|
||||||
|
String statusIcon = mapStatusToIcon(vessel.getNavigationalStatus());
|
||||||
|
if (statusIcon != null) {
|
||||||
|
props.put("status_icon", statusIcon);
|
||||||
|
} else {
|
||||||
|
props.remove("status_icon");
|
||||||
|
}
|
||||||
|
} catch (Exception ignore) {}
|
||||||
|
|
||||||
|
idToFeature.put(vessel.getMmsi(), feature);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshGeoJson();
|
||||||
|
Log.d(TAG, "AIS batch update: updated=" + updated +
|
||||||
|
", removed=" + removed + ", skipped=" + skipped);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "updateAISVesselPositions: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void removeAISVesselMarker(String mmsi) {
|
public void removeAISVesselMarker(String mmsi) {
|
||||||
if (mmsi == null) return;
|
if (mmsi == null) return;
|
||||||
@@ -555,13 +686,122 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
uiHandler.post(() -> refreshGeoJson());
|
uiHandler.post(() -> refreshGeoJson());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляет навигационный знак на карту
|
||||||
|
*/
|
||||||
|
public void addNavigationAidMarker(AISNavigationAid navigationAid) {
|
||||||
|
updateNavigationAidPosition(navigationAid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет позицию навигационного знака на карте
|
||||||
|
*/
|
||||||
|
public void updateNavigationAidPosition(AISNavigationAid navigationAid) {
|
||||||
|
if (!isStyleValid()) {
|
||||||
|
Log.w(TAG, "Style not ready, skipping updateNavigationAidPosition");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String mmsi = navigationAid.getMmsi();
|
||||||
|
Log.d(TAG, "updateNavigationAidPosition: Navigation aid " + mmsi + " at " +
|
||||||
|
navigationAid.getLatitude() + "," + navigationAid.getLongitude() +
|
||||||
|
", type=" + navigationAid.getAidType() + ", name='" + navigationAid.getAidName() + "'");
|
||||||
|
|
||||||
|
// Создаем GeoJSON фичу для навигационного знака
|
||||||
|
JSONObject feature = buildNavigationAidFeature(navigationAid);
|
||||||
|
idToFeature.put(mmsi, feature);
|
||||||
|
idToNavigationAid.put(mmsi, navigationAid);
|
||||||
|
|
||||||
|
// Обновляем источник данных
|
||||||
|
refreshNavigationAidsSource();
|
||||||
|
|
||||||
|
Log.d(TAG, "Navigation aid " + mmsi + " updated on map");
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error updating navigation aid position: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет навигационный знак с карты
|
||||||
|
*/
|
||||||
|
public void removeNavigationAidMarker(String mmsi) {
|
||||||
|
if (!isStyleValid()) {
|
||||||
|
Log.w(TAG, "Style not ready, skipping removeNavigationAidMarker");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Удаляем из хранилищ
|
||||||
|
idToFeature.remove(mmsi);
|
||||||
|
idToNavigationAid.remove(mmsi);
|
||||||
|
|
||||||
|
// Обновляем источник данных
|
||||||
|
refreshNavigationAidsSource();
|
||||||
|
|
||||||
|
Log.d(TAG, "Navigation aid " + mmsi + " removed from map");
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error removing navigation aid marker: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очищает все навигационные знаки с карты
|
||||||
|
*/
|
||||||
|
public void clearNavigationAidMarkers() {
|
||||||
|
if (!isStyleValid()) {
|
||||||
|
Log.w(TAG, "Style not ready, skipping clearNavigationAidMarkers");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
GeoJsonSource source = style.getSourceAs(SOURCE_NAVIGATION_AIDS);
|
||||||
|
if (source != null) {
|
||||||
|
source.setGeoJson(emptyFeatureCollection());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очищаем хранилища
|
||||||
|
idToNavigationAid.clear();
|
||||||
|
|
||||||
|
Log.d(TAG, "Navigation aid markers cleared");
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error clearing navigation aid markers: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void centerOnPosition(double latitude, double longitude) {
|
public void centerOnPosition(double latitude, double longitude) {
|
||||||
if (maplibreMap == null) return;
|
if (maplibreMap == null || style == null) {
|
||||||
maplibreMap.setCameraPosition(new org.maplibre.android.camera.CameraPosition.Builder()
|
// Сохраним pending, применим после загрузки стиля
|
||||||
.target(new LatLng(latitude, longitude))
|
pendingCenterLat = latitude;
|
||||||
.zoom(13.0)
|
pendingCenterLon = longitude;
|
||||||
.build());
|
// И на всякий случай повторим позже
|
||||||
|
try {
|
||||||
|
if (uiHandler != null) {
|
||||||
|
uiHandler.postDelayed(() -> centerOnPosition(latitude, longitude), 300);
|
||||||
|
}
|
||||||
|
} catch (Exception ignore) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
org.maplibre.android.camera.CameraPosition current = maplibreMap.getCameraPosition();
|
||||||
|
float targetZoom = (float) current.zoom;
|
||||||
|
|
||||||
|
// Если зум слишком маленький (меньше 5), используем стартовый зум из настроек
|
||||||
|
if (targetZoom < 5.0f) {
|
||||||
|
targetZoom = settingsManager.getStartZoomLevel();
|
||||||
|
Log.i(TAG, "Принудительно устанавливаем стартовый зум: " + targetZoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
maplibreMap.setCameraPosition(new org.maplibre.android.camera.CameraPosition.Builder()
|
||||||
|
.target(new LatLng(latitude, longitude))
|
||||||
|
.zoom(targetZoom)
|
||||||
|
.tilt(current.tilt)
|
||||||
|
.bearing(current.bearing)
|
||||||
|
.build());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "centerOnPosition: MapView may be destroyed: " + e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -620,12 +860,38 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addLayer(String layerId, Object layerData) {
|
public void addLayer(String layerId, Object layerData) {
|
||||||
// Не используется в первой итерации
|
if (style == null || !isStyleValid()) {
|
||||||
|
Log.w(TAG, "addLayer: стиль не готов, откладываем добавление слоя " + layerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ("seamarks".equals(layerId)) {
|
||||||
|
addSeamarksLayer();
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "addLayer: неизвестный тип слоя " + layerId);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "addLayer: ошибка добавления слоя " + layerId, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void removeLayer(String layerId) {
|
public void removeLayer(String layerId) {
|
||||||
// Не используется в первой итерации
|
if (style == null || !isStyleValid()) {
|
||||||
|
Log.w(TAG, "removeLayer: стиль не готов, пропускаем удаление слоя " + layerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ("seamarks".equals(layerId)) {
|
||||||
|
removeSeamarksLayer();
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "removeLayer: неизвестный тип слоя " + layerId);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "removeLayer: ошибка удаления слоя " + layerId, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -671,11 +937,16 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
Log.w(TAG, "Не удалось добавить иконки: " + e.getMessage());
|
Log.w(TAG, "Не удалось добавить иконки: " + e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Источник GeoJSON
|
// Источник GeoJSON для судов
|
||||||
if (style.getSource(SOURCE_VESSELS) == null) {
|
if (style.getSource(SOURCE_VESSELS) == null) {
|
||||||
style.addSource(new GeoJsonSource(SOURCE_VESSELS, emptyFeatureCollection()));
|
style.addSource(new GeoJsonSource(SOURCE_VESSELS, emptyFeatureCollection()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Источник GeoJSON для навигационных знаков
|
||||||
|
if (style.getSource(SOURCE_NAVIGATION_AIDS) == null) {
|
||||||
|
style.addSource(new GeoJsonSource(SOURCE_NAVIGATION_AIDS, emptyFeatureCollection()));
|
||||||
|
}
|
||||||
|
|
||||||
// Отладочные линии удалены
|
// Отладочные линии удалены
|
||||||
|
|
||||||
// Слой символов (основные иконки)
|
// Слой символов (основные иконки)
|
||||||
@@ -712,6 +983,36 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
style.addLayer(layer);
|
style.addLayer(layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Слой навигационных знаков
|
||||||
|
if (style.getLayer(LAYER_NAVIGATION_AIDS) == null) {
|
||||||
|
SymbolLayer navigationAidLayer = new SymbolLayer(LAYER_NAVIGATION_AIDS, SOURCE_NAVIGATION_AIDS)
|
||||||
|
.withProperties(
|
||||||
|
PropertyFactory.iconImage(
|
||||||
|
Expression.coalesce(
|
||||||
|
Expression.get("icon"),
|
||||||
|
Expression.literal("green_buey")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
PropertyFactory.iconAnchor(org.maplibre.android.style.layers.Property.ICON_ANCHOR_CENTER),
|
||||||
|
PropertyFactory.iconRotationAlignment(org.maplibre.android.style.layers.Property.ICON_ROTATION_ALIGNMENT_MAP),
|
||||||
|
PropertyFactory.iconAllowOverlap(true),
|
||||||
|
PropertyFactory.iconIgnorePlacement(true),
|
||||||
|
PropertyFactory.iconSize(
|
||||||
|
Expression.interpolate(
|
||||||
|
Expression.linear(),
|
||||||
|
Expression.zoom(),
|
||||||
|
Expression.stop(5, 0.08f),
|
||||||
|
Expression.stop(8, 0.10f),
|
||||||
|
Expression.stop(12, 0.15f),
|
||||||
|
Expression.stop(15, 0.20f),
|
||||||
|
Expression.stop(17, 0.25f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
style.addLayer(navigationAidLayer);
|
||||||
|
}
|
||||||
|
|
||||||
// Слой предупреждения (losing) поверх — рисуется поверх, если feature.properties.stale == true
|
// Слой предупреждения (losing) поверх — рисуется поверх, если feature.properties.stale == true
|
||||||
if (style.getLayer(LAYER_VESSELS_STALE) == null && style.getImage(IMAGE_VESSEL_LOSING) != null) {
|
if (style.getLayer(LAYER_VESSELS_STALE) == null && style.getImage(IMAGE_VESSEL_LOSING) != null) {
|
||||||
SymbolLayer losingLayer = new SymbolLayer(LAYER_VESSELS_STALE, SOURCE_VESSELS)
|
SymbolLayer losingLayer = new SymbolLayer(LAYER_VESSELS_STALE, SOURCE_VESSELS)
|
||||||
@@ -783,6 +1084,9 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
// Восстанавливаем путь судна после создания слоев
|
// Восстанавливаем путь судна после создания слоев
|
||||||
restoreVesselPath();
|
restoreVesselPath();
|
||||||
|
|
||||||
|
// Обновляем дополнительные слои на основе настроек
|
||||||
|
updateAdditionalLayers();
|
||||||
|
|
||||||
Log.d(TAG, "ensureSourcesAndLayers: completed");
|
Log.d(TAG, "ensureSourcesAndLayers: completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,6 +1262,54 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
Log.e(TAG, "refreshGeoJson: " + e.getMessage(), e);
|
Log.e(TAG, "refreshGeoJson: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет источник данных навигационных знаков
|
||||||
|
*/
|
||||||
|
private void refreshNavigationAidsSource() {
|
||||||
|
if (style == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isStyleValid()) {
|
||||||
|
Log.w(TAG, "refreshNavigationAidsSource: стиль не валиден, пропускаем обновление");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GeoJsonSource source = (GeoJsonSource) style.getSource(SOURCE_NAVIGATION_AIDS);
|
||||||
|
if (source == null) {
|
||||||
|
Log.w(TAG, "refreshNavigationAidsSource: источник NAVIGATION_AIDS не найден, пропускаем обновление");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем FeatureCollection из всех навигационных знаков
|
||||||
|
JSONObject featureCollection = new JSONObject();
|
||||||
|
featureCollection.put("type", "FeatureCollection");
|
||||||
|
|
||||||
|
JSONArray features = new JSONArray();
|
||||||
|
for (Map.Entry<String, AISNavigationAid> entry : idToNavigationAid.entrySet()) {
|
||||||
|
String mmsi = entry.getKey();
|
||||||
|
AISNavigationAid navigationAid = entry.getValue();
|
||||||
|
|
||||||
|
// Проверяем, что у нас есть фича для этого навигационного знака
|
||||||
|
JSONObject feature = idToFeature.get(mmsi);
|
||||||
|
if (feature != null) {
|
||||||
|
features.put(feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
featureCollection.put("features", features);
|
||||||
|
|
||||||
|
// Обновляем источник
|
||||||
|
// GeoJsonSource принимает Feature/FeatureCollection/Geometry/String
|
||||||
|
// Передаем как строку JSON
|
||||||
|
source.setGeoJson(featureCollection.toString());
|
||||||
|
|
||||||
|
Log.d(TAG, "refreshNavigationAidsSource: обновлено " + features.length() + " навигационных знаков");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "refreshNavigationAidsSource: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void logMemoryUsage() {
|
private void logMemoryUsage() {
|
||||||
Runtime runtime = Runtime.getRuntime();
|
Runtime runtime = Runtime.getRuntime();
|
||||||
@@ -1072,6 +1424,204 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
return feature;
|
return feature;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает GeoJSON фичу для навигационного знака
|
||||||
|
*/
|
||||||
|
private JSONObject buildNavigationAidFeature(AISNavigationAid navigationAid) throws Exception {
|
||||||
|
JSONObject feature = new JSONObject();
|
||||||
|
feature.put("type", "Feature");
|
||||||
|
feature.put("id", navigationAid.getMmsi());
|
||||||
|
|
||||||
|
JSONObject geom = new JSONObject();
|
||||||
|
geom.put("type", "Point");
|
||||||
|
JSONArray coords = new JSONArray();
|
||||||
|
coords.put(navigationAid.getLongitude());
|
||||||
|
coords.put(navigationAid.getLatitude());
|
||||||
|
geom.put("coordinates", coords);
|
||||||
|
feature.put("geometry", geom);
|
||||||
|
|
||||||
|
JSONObject props = new JSONObject();
|
||||||
|
props.put("mmsi", navigationAid.getMmsi());
|
||||||
|
props.put("name", navigationAid.getAidName() != null ? navigationAid.getAidName() : "");
|
||||||
|
props.put("type", navigationAid.getAidType());
|
||||||
|
props.put("typeDescription", navigationAid.getAidTypeDescription());
|
||||||
|
props.put("length", navigationAid.getLength());
|
||||||
|
props.put("width", navigationAid.getWidth());
|
||||||
|
props.put("draft", navigationAid.getDraft());
|
||||||
|
props.put("accuracy", navigationAid.isPositionAccuracy());
|
||||||
|
props.put("offPosition", navigationAid.isOffPositionIndicator());
|
||||||
|
props.put("raim", navigationAid.isRaimFlag());
|
||||||
|
|
||||||
|
// Выбираем иконку в зависимости от типа навигационного знака
|
||||||
|
String iconName = getNavigationAidIcon(navigationAid.getAidType());
|
||||||
|
props.put("icon", iconName);
|
||||||
|
|
||||||
|
feature.put("properties", props);
|
||||||
|
return feature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает имя иконки для навигационного знака в зависимости от типа
|
||||||
|
*/
|
||||||
|
private String getNavigationAidIcon(int aidType) {
|
||||||
|
switch (aidType) {
|
||||||
|
// Reference point
|
||||||
|
case 0:
|
||||||
|
return "reference_point"; // Специальная иконка для референсной точки
|
||||||
|
|
||||||
|
// RACON (radar transponder marking a navigation hazard)
|
||||||
|
case 1:
|
||||||
|
return "racon"; // RACON иконка
|
||||||
|
|
||||||
|
// Fixed structure off shore, such as oil platforms, wind farms, rigs
|
||||||
|
case 2:
|
||||||
|
return "platform"; // Платформа
|
||||||
|
|
||||||
|
// Spare, Reserved for future use
|
||||||
|
case 3:
|
||||||
|
return "green_buey"; // Fallback для тестирования
|
||||||
|
|
||||||
|
// Light, without sectors
|
||||||
|
case 4:
|
||||||
|
return "light"; // Маяк
|
||||||
|
|
||||||
|
// Light, with sectors
|
||||||
|
case 5:
|
||||||
|
return "light_sector"; // Маяк с секторами
|
||||||
|
|
||||||
|
// Leading Light Front
|
||||||
|
case 6:
|
||||||
|
return "light_leading_front"; // Передний ведущий маяк
|
||||||
|
|
||||||
|
// Leading Light Rear
|
||||||
|
case 7:
|
||||||
|
return "light_leading_rear"; // Задний ведущий маяк
|
||||||
|
|
||||||
|
// Beacon, Cardinal N
|
||||||
|
case 8:
|
||||||
|
return "buoy_cardinal_n"; // Кардинальный буй Север
|
||||||
|
|
||||||
|
// Beacon, Cardinal E
|
||||||
|
case 9:
|
||||||
|
return "buoy_cardinal_e"; // Кардинальный буй Восток
|
||||||
|
|
||||||
|
// Beacon, Cardinal S
|
||||||
|
case 10:
|
||||||
|
return "buoy_cardinal_s"; // Кардинальный буй Юг
|
||||||
|
|
||||||
|
// Beacon, Cardinal W
|
||||||
|
case 11:
|
||||||
|
return "buoy_cardinal_w"; // Кардинальный буй Запад
|
||||||
|
|
||||||
|
// Beacon, Port hand
|
||||||
|
case 12:
|
||||||
|
return "buoy_port"; // Портовый буй
|
||||||
|
|
||||||
|
// Beacon, Starboard hand
|
||||||
|
case 13:
|
||||||
|
return "buoy_starboard"; // Правобортный буй
|
||||||
|
|
||||||
|
// Beacon, Preferred Channel port hand
|
||||||
|
case 14:
|
||||||
|
return "buoy_preferred_port"; // Предпочтительный канал порт
|
||||||
|
|
||||||
|
// Beacon, Preferred Channel starboard hand
|
||||||
|
case 15:
|
||||||
|
return "buoy_preferred_starboard"; // Предпочтительный канал правый борт
|
||||||
|
|
||||||
|
// Beacon, Isolated danger
|
||||||
|
case 16:
|
||||||
|
return "beacon_isolated_danger"; // Изолированная опасность
|
||||||
|
|
||||||
|
// Beacon, Safe water
|
||||||
|
case 17:
|
||||||
|
return "beacon_safe_water"; // Безопасная вода
|
||||||
|
|
||||||
|
// Beacon, Special mark
|
||||||
|
case 18:
|
||||||
|
return "beacon_special"; // Специальный знак
|
||||||
|
|
||||||
|
// Cardinal Mark N
|
||||||
|
case 19:
|
||||||
|
return "buoy_cardinal_n"; // Кардинальный знак Север
|
||||||
|
|
||||||
|
// Cardinal Mark E
|
||||||
|
case 20:
|
||||||
|
return "buoy_cardinal_e"; // Кардинальный знак Восток
|
||||||
|
|
||||||
|
// Cardinal Mark S
|
||||||
|
case 21:
|
||||||
|
return "buoy_cardinal_s"; // Кардинальный знак Юг
|
||||||
|
|
||||||
|
// Cardinal Mark W
|
||||||
|
case 22:
|
||||||
|
return "buoy_cardinal_w"; // Кардинальный знак Запад
|
||||||
|
|
||||||
|
// Port hand Mark
|
||||||
|
case 23:
|
||||||
|
return "buoy_port"; // Портовый знак
|
||||||
|
|
||||||
|
// Starboard hand Mark
|
||||||
|
case 24:
|
||||||
|
return "buoy_starboard"; // Правобортный знак
|
||||||
|
|
||||||
|
// Preferred Channel port hand
|
||||||
|
case 25:
|
||||||
|
return "buoy_preferred_port"; // Предпочтительный канал порт
|
||||||
|
|
||||||
|
// Preferred Channel starboard hand
|
||||||
|
case 26:
|
||||||
|
return "buoy_preferred_starboard"; // Предпочтительный канал правый борт
|
||||||
|
|
||||||
|
// Isolated danger
|
||||||
|
case 27:
|
||||||
|
return "beacon_isolated_danger"; // Изолированная опасность
|
||||||
|
|
||||||
|
// Safe water
|
||||||
|
case 28:
|
||||||
|
return "beacon_safe_water"; // Безопасная вода
|
||||||
|
|
||||||
|
// Special mark
|
||||||
|
case 29:
|
||||||
|
return "beacon_special"; // Специальный знак
|
||||||
|
|
||||||
|
// Light Vessel / LANBY / Rigs
|
||||||
|
case 30:
|
||||||
|
return "light_vessel"; // Плавучий маяк
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "green_buey"; // Fallback для тестирования
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает временный AISVessel из навигационного знака для совместимости с UI
|
||||||
|
*/
|
||||||
|
private AISVessel createTempVesselFromNavigationAid(AISNavigationAid navigationAid) {
|
||||||
|
AISVessel tempVessel = new AISVessel(navigationAid.getMmsi());
|
||||||
|
tempVessel.setLatitude(navigationAid.getLatitude());
|
||||||
|
tempVessel.setLongitude(navigationAid.getLongitude());
|
||||||
|
tempVessel.setVesselName(navigationAid.getAidName());
|
||||||
|
tempVessel.setVesselClass("Navigation Aid");
|
||||||
|
tempVessel.setVesselType(navigationAid.getAidTypeDescription());
|
||||||
|
tempVessel.setLength(navigationAid.getLength());
|
||||||
|
tempVessel.setWidth(navigationAid.getWidth());
|
||||||
|
tempVessel.setDraft(navigationAid.getDraft());
|
||||||
|
tempVessel.setPositionAccuracy(navigationAid.isPositionAccuracy());
|
||||||
|
tempVessel.setLastUpdate(navigationAid.getLastUpdate());
|
||||||
|
|
||||||
|
// Добавляем специальные поля для навигационных знаков
|
||||||
|
tempVessel.setDestination("Type: " + navigationAid.getAidType() + " (" + navigationAid.getAidTypeDescription() + ")");
|
||||||
|
if (navigationAid.isOffPositionIndicator()) {
|
||||||
|
tempVessel.setDestination(tempVessel.getDestination() + " - Off Position");
|
||||||
|
}
|
||||||
|
if (navigationAid.isRaimFlag()) {
|
||||||
|
tempVessel.setDestination(tempVessel.getDestination() + " - RAIM Active");
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempVessel;
|
||||||
|
}
|
||||||
|
|
||||||
private double getDisplayCourse(AISVessel v) {
|
private double getDisplayCourse(AISVessel v) {
|
||||||
double hdg = v.getHeading();
|
double hdg = v.getHeading();
|
||||||
if (!Double.isNaN(hdg) && !Double.isInfinite(hdg)) {
|
if (!Double.isNaN(hdg) && !Double.isInfinite(hdg)) {
|
||||||
@@ -2130,21 +2680,33 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
Log.d(TAG, String.format("checkAisVesselUnderCursor: searchRect=[%.1f,%.1f,%.1f,%.1f]",
|
Log.d(TAG, String.format("checkAisVesselUnderCursor: searchRect=[%.1f,%.1f,%.1f,%.1f]",
|
||||||
searchRect.left, searchRect.top, searchRect.right, searchRect.bottom));
|
searchRect.left, searchRect.top, searchRect.right, searchRect.bottom));
|
||||||
|
|
||||||
// Ищем AIS суда в адаптивном радиусе от центра
|
// Ищем AIS суда и навигационные знаки в адаптивном радиусе от центра
|
||||||
java.util.List<org.maplibre.geojson.Feature> features = maplibreMap.queryRenderedFeatures(searchRect, LAYER_VESSELS);
|
java.util.List<org.maplibre.geojson.Feature> vesselFeatures = maplibreMap.queryRenderedFeatures(searchRect, LAYER_VESSELS);
|
||||||
|
java.util.List<org.maplibre.geojson.Feature> aidFeatures = maplibreMap.queryRenderedFeatures(searchRect, LAYER_NAVIGATION_AIDS);
|
||||||
|
|
||||||
Log.d(TAG, String.format("checkAisVesselUnderCursor: найдено %d features в основном поиске",
|
// Объединяем результаты
|
||||||
features != null ? features.size() : 0));
|
java.util.List<org.maplibre.geojson.Feature> features = new java.util.ArrayList<>();
|
||||||
|
if (vesselFeatures != null) features.addAll(vesselFeatures);
|
||||||
|
if (aidFeatures != null) features.addAll(aidFeatures);
|
||||||
|
|
||||||
|
Log.d(TAG, String.format("checkAisVesselUnderCursor: найдено %d судов и %d навигационных знаков в основном поиске",
|
||||||
|
vesselFeatures != null ? vesselFeatures.size() : 0, aidFeatures != null ? aidFeatures.size() : 0));
|
||||||
|
|
||||||
// Если не нашли в основном радиусе, попробуем расширенный поиск
|
// Если не нашли в основном радиусе, попробуем расширенный поиск
|
||||||
if ((features == null || features.isEmpty()) && pixelRadius < 150) {
|
if (features.isEmpty() && pixelRadius < 150) {
|
||||||
android.graphics.RectF expandedRect = new android.graphics.RectF(
|
android.graphics.RectF expandedRect = new android.graphics.RectF(
|
||||||
screenPoint.x - 150, screenPoint.y - 150,
|
screenPoint.x - 150, screenPoint.y - 150,
|
||||||
screenPoint.x + 150, screenPoint.y + 150
|
screenPoint.x + 150, screenPoint.y + 150
|
||||||
);
|
);
|
||||||
features = maplibreMap.queryRenderedFeatures(expandedRect, LAYER_VESSELS);
|
vesselFeatures = maplibreMap.queryRenderedFeatures(expandedRect, LAYER_VESSELS);
|
||||||
Log.d(TAG, String.format("checkAisVesselUnderCursor: найдено %d features в расширенном поиске",
|
aidFeatures = maplibreMap.queryRenderedFeatures(expandedRect, LAYER_NAVIGATION_AIDS);
|
||||||
features != null ? features.size() : 0));
|
|
||||||
|
features.clear();
|
||||||
|
if (vesselFeatures != null) features.addAll(vesselFeatures);
|
||||||
|
if (aidFeatures != null) features.addAll(aidFeatures);
|
||||||
|
|
||||||
|
Log.d(TAG, String.format("checkAisVesselUnderCursor: найдено %d судов и %d навигационных знаков в расширенном поиске",
|
||||||
|
vesselFeatures != null ? vesselFeatures.size() : 0, aidFeatures != null ? aidFeatures.size() : 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (features != null && !features.isEmpty()) {
|
if (features != null && !features.isEmpty()) {
|
||||||
@@ -2160,6 +2722,7 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
Log.d(TAG, String.format("checkAisVesselUnderCursor: проверяем feature с id=%s", id));
|
Log.d(TAG, String.format("checkAisVesselUnderCursor: проверяем feature с id=%s", id));
|
||||||
|
|
||||||
if (id != null && !"own_vessel".equals(id)) {
|
if (id != null && !"own_vessel".equals(id)) {
|
||||||
|
// Проверяем AIS судно
|
||||||
AISVessel vessel = idToAisVessel.get(id);
|
AISVessel vessel = idToAisVessel.get(id);
|
||||||
if (vessel != null) {
|
if (vessel != null) {
|
||||||
// Вычисляем географическое расстояние от центра до судна
|
// Вычисляем географическое расстояние от центра до судна
|
||||||
@@ -2201,6 +2764,48 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
} else {
|
} else {
|
||||||
Log.d(TAG, String.format("checkAisVesselUnderCursor: судно с id=%s не найдено в idToAisVessel", id));
|
Log.d(TAG, String.format("checkAisVesselUnderCursor: судно с id=%s не найдено в idToAisVessel", id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем навигационный знак
|
||||||
|
AISNavigationAid navigationAid = idToNavigationAid.get(id);
|
||||||
|
if (navigationAid != null) {
|
||||||
|
// Вычисляем географическое расстояние от центра до навигационного знака
|
||||||
|
double geoDistance = GeoUtils.calculateDistance(
|
||||||
|
center.getLatitude(), center.getLongitude(),
|
||||||
|
navigationAid.getLatitude(), navigationAid.getLongitude()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Вычисляем экранное расстояние
|
||||||
|
android.graphics.PointF aidScreenPoint = maplibreMap.getProjection()
|
||||||
|
.toScreenLocation(new org.maplibre.android.geometry.LatLng(
|
||||||
|
navigationAid.getLatitude(), navigationAid.getLongitude()));
|
||||||
|
double screenDistance = Math.sqrt(
|
||||||
|
Math.pow(screenPoint.x - aidScreenPoint.x, 2) +
|
||||||
|
Math.pow(screenPoint.y - aidScreenPoint.y, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
Log.d(TAG, String.format("checkAisVesselUnderCursor: навигационный знак %s - geoDistance=%.1f м, screenDistance=%.1f пикс",
|
||||||
|
id, geoDistance, screenDistance));
|
||||||
|
|
||||||
|
// Приоритет отдаем экранному расстоянию, но учитываем и географическое
|
||||||
|
boolean isBetterCandidate = false;
|
||||||
|
if (closestVessel == null) {
|
||||||
|
isBetterCandidate = true;
|
||||||
|
} else if (screenDistance < minScreenDistance * 0.8) {
|
||||||
|
// Если экранное расстояние значительно меньше
|
||||||
|
isBetterCandidate = true;
|
||||||
|
} else if (screenDistance <= minScreenDistance * 1.2 && geoDistance < minGeoDistance) {
|
||||||
|
// Если экранное расстояние примерно равно, но географическое меньше
|
||||||
|
isBetterCandidate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBetterCandidate) {
|
||||||
|
Log.d(TAG, String.format("checkAisVesselUnderCursor: выбираем навигационный знак %s как лучший кандидат", id));
|
||||||
|
minScreenDistance = screenDistance;
|
||||||
|
minGeoDistance = geoDistance;
|
||||||
|
// Для навигационных знаков создаем временный AISVessel для совместимости
|
||||||
|
closestVessel = createTempVesselFromNavigationAid(navigationAid);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if ("own_vessel".equals(id)) {
|
} else if ("own_vessel".equals(id)) {
|
||||||
Log.d(TAG, "checkAisVesselUnderCursor: пропускаем собственное судно");
|
Log.d(TAG, "checkAisVesselUnderCursor: пропускаем собственное судно");
|
||||||
}
|
}
|
||||||
@@ -2407,8 +3012,155 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Настраивает слушатель движения карты для обновления курсора
|
* Добавляет слой морских знаков OpenSeaMap
|
||||||
*/
|
*/
|
||||||
|
private void addSeamarksLayer() {
|
||||||
|
if (style == null || !isStyleValid()) {
|
||||||
|
Log.w(TAG, "addSeamarksLayer: стиль не готов");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Проверяем, не добавлен ли уже слой
|
||||||
|
if (style.getLayer(LAYER_SEAMARKS) != null) {
|
||||||
|
Log.d(TAG, "addSeamarksLayer: слой уже существует");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем источник тайлов морских знаков через TileSet, чтобы шаблоны {z}/{x}/{y} обрабатывались корректно
|
||||||
|
String[] seamarksUrls = {
|
||||||
|
"http://t1.openseamap.org/seamark/{z}/{x}/{y}.png",
|
||||||
|
"http://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"
|
||||||
|
};
|
||||||
|
|
||||||
|
boolean sourceAdded = false;
|
||||||
|
for (String url : seamarksUrls) {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "addSeamarksLayer: пробуем TileSet URL " + url);
|
||||||
|
org.maplibre.android.style.sources.TileSet tileSet =
|
||||||
|
new org.maplibre.android.style.sources.TileSet("2.1.0", url);
|
||||||
|
org.maplibre.android.style.sources.RasterSource seamarksSource =
|
||||||
|
new org.maplibre.android.style.sources.RasterSource(
|
||||||
|
SOURCE_SEAMARKS,
|
||||||
|
tileSet,
|
||||||
|
256
|
||||||
|
);
|
||||||
|
style.addSource(seamarksSource);
|
||||||
|
Log.d(TAG, "addSeamarksLayer: источник добавлен успешно через TileSet " + url);
|
||||||
|
sourceAdded = true;
|
||||||
|
break;
|
||||||
|
} catch (Exception urlError) {
|
||||||
|
Log.w(TAG, "addSeamarksLayer: TileSet не удалось для URL " + url + ": " + urlError.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceAdded) {
|
||||||
|
Log.w(TAG, "addSeamarksLayer: ни один TileSet не подошел, пробуем информационный слой");
|
||||||
|
createSeamarksInfoLayer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем растровый слой
|
||||||
|
org.maplibre.android.style.layers.RasterLayer seamarksLayer =
|
||||||
|
new org.maplibre.android.style.layers.RasterLayer(LAYER_SEAMARKS, SOURCE_SEAMARKS);
|
||||||
|
|
||||||
|
// Настраиваем прозрачность слоя (чтобы не перекрывать основную карту)
|
||||||
|
seamarksLayer.setProperties(
|
||||||
|
org.maplibre.android.style.layers.PropertyFactory.rasterOpacity(0.8f)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Добавляем слой поверх всех остальных слоев
|
||||||
|
style.addLayer(seamarksLayer);
|
||||||
|
Log.d(TAG, "addSeamarksLayer: слой добавлен успешно");
|
||||||
|
|
||||||
|
Log.i(TAG, "✓ Слой морских знаков OpenSeaMap добавлен");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "addSeamarksLayer: ошибка добавления слоя морских знаков", e);
|
||||||
|
// Пробуем альтернативный подход - используем второй официальный сервер
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "addSeamarksLayer: пробуем альтернативный сервер tiles.openseamap.org");
|
||||||
|
org.maplibre.android.style.sources.RasterSource fallbackSource =
|
||||||
|
new org.maplibre.android.style.sources.RasterSource(
|
||||||
|
SOURCE_SEAMARKS + "_fallback",
|
||||||
|
"https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png",
|
||||||
|
256
|
||||||
|
);
|
||||||
|
style.addSource(fallbackSource);
|
||||||
|
|
||||||
|
org.maplibre.android.style.layers.RasterLayer fallbackLayer =
|
||||||
|
new org.maplibre.android.style.layers.RasterLayer(LAYER_SEAMARKS, SOURCE_SEAMARKS + "_fallback");
|
||||||
|
fallbackLayer.setProperties(
|
||||||
|
org.maplibre.android.style.layers.PropertyFactory.rasterOpacity(0.8f)
|
||||||
|
);
|
||||||
|
style.addLayer(fallbackLayer);
|
||||||
|
|
||||||
|
Log.i(TAG, "✓ Слой морских знаков добавлен через fallback");
|
||||||
|
} catch (Exception fallbackError) {
|
||||||
|
Log.e(TAG, "addSeamarksLayer: fallback тоже не сработал", fallbackError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает информационный слой морских знаков (альтернатива растровым тайлам)
|
||||||
|
*/
|
||||||
|
private void createSeamarksInfoLayer() {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "createSeamarksInfoLayer: создаем информационный слой");
|
||||||
|
|
||||||
|
// Создаем простой источник с пустыми данными
|
||||||
|
org.maplibre.android.style.sources.GeoJsonSource infoSource =
|
||||||
|
new org.maplibre.android.style.sources.GeoJsonSource(SOURCE_SEAMARKS, "{\"type\":\"FeatureCollection\",\"features\":[]}");
|
||||||
|
|
||||||
|
style.addSource(infoSource);
|
||||||
|
|
||||||
|
// Создаем слой символов для отображения информации
|
||||||
|
org.maplibre.android.style.layers.SymbolLayer infoLayer =
|
||||||
|
new org.maplibre.android.style.layers.SymbolLayer(LAYER_SEAMARKS, SOURCE_SEAMARKS);
|
||||||
|
|
||||||
|
infoLayer.setProperties(
|
||||||
|
org.maplibre.android.style.layers.PropertyFactory.textField("⚓"),
|
||||||
|
org.maplibre.android.style.layers.PropertyFactory.textSize(16f),
|
||||||
|
org.maplibre.android.style.layers.PropertyFactory.textColor(android.graphics.Color.BLUE),
|
||||||
|
org.maplibre.android.style.layers.PropertyFactory.textOpacity(0.7f)
|
||||||
|
);
|
||||||
|
|
||||||
|
style.addLayer(infoLayer);
|
||||||
|
|
||||||
|
Log.i(TAG, "✓ Информационный слой морских знаков создан (альтернативный режим)");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "createSeamarksInfoLayer: ошибка создания информационного слоя", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет дополнительные слои карты на основе настроек
|
||||||
|
*/
|
||||||
|
public void updateAdditionalLayers() {
|
||||||
|
if (style == null || !isStyleValid()) {
|
||||||
|
Log.w(TAG, "updateAdditionalLayers: стиль не готов");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Обновляем слой морских знаков
|
||||||
|
boolean seamarksEnabled = settingsManager.isSeamarksEnabled();
|
||||||
|
boolean seamarksLayerExists = style.getLayer(LAYER_SEAMARKS) != null;
|
||||||
|
|
||||||
|
if (seamarksEnabled && !seamarksLayerExists) {
|
||||||
|
Log.i(TAG, "updateAdditionalLayers: включаем морские знаки");
|
||||||
|
addSeamarksLayer();
|
||||||
|
} else if (!seamarksEnabled && seamarksLayerExists) {
|
||||||
|
Log.i(TAG, "updateAdditionalLayers: выключаем морские знаки");
|
||||||
|
removeSeamarksLayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "updateAdditionalLayers: ошибка обновления слоев", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
private void setupMapMovementListener() {
|
private void setupMapMovementListener() {
|
||||||
if (maplibreMap != null) {
|
if (maplibreMap != null) {
|
||||||
maplibreMap.addOnCameraMoveListener(() -> {
|
maplibreMap.addOnCameraMoveListener(() -> {
|
||||||
@@ -2417,6 +3169,33 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет слой морских знаков OpenSeaMap
|
||||||
|
*/
|
||||||
|
private void removeSeamarksLayer() {
|
||||||
|
if (style == null || !isStyleValid()) {
|
||||||
|
Log.w(TAG, "removeSeamarksLayer: стиль не готов");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Удаляем слой
|
||||||
|
if (style.getLayer(LAYER_SEAMARKS) != null) {
|
||||||
|
style.removeLayer(LAYER_SEAMARKS);
|
||||||
|
Log.d(TAG, "removeSeamarksLayer: слой удален");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем источник
|
||||||
|
if (style.getSource(SOURCE_SEAMARKS) != null) {
|
||||||
|
style.removeSource(SOURCE_SEAMARKS);
|
||||||
|
Log.d(TAG, "removeSeamarksLayer: источник удален");
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "✓ Слой морских знаков OpenSeaMap удален");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "removeSeamarksLayer: ошибка удаления слоя морских знаков", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import com.yandex.mapkit.mapview.MapView;
|
|||||||
import com.yandex.runtime.image.ImageProvider;
|
import com.yandex.runtime.image.ImageProvider;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,6 +142,14 @@ public class YandexMapImpl implements MapInterface {
|
|||||||
markerManager.updateAISVesselMarker(vessel);
|
markerManager.updateAISVesselMarker(vessel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateAISVesselPositions(List<AISVessel> vessels) {
|
||||||
|
if (vessels == null || markerManager == null) return;
|
||||||
|
for (AISVessel vessel : vessels) {
|
||||||
|
markerManager.updateAISVesselMarker(vessel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void removeAISVesselMarker(String mmsi) {
|
public void removeAISVesselMarker(String mmsi) {
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package com.grigowashere.aismap.models;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель AIS навигационного знака (буйка, маяка, платформы и т.д.)
|
||||||
|
* Специализированный класс для сообщений типа 21 (Aid-to-Navigation Report)
|
||||||
|
*/
|
||||||
|
public class AISNavigationAid {
|
||||||
|
private String mmsi; // Maritime Mobile Service Identity
|
||||||
|
private String aidName; // название навигационного знака
|
||||||
|
private int aidType; // тип навигационного знака (0-30)
|
||||||
|
private String aidTypeDescription; // описание типа
|
||||||
|
private double latitude;
|
||||||
|
private double longitude;
|
||||||
|
private boolean positionAccuracy; // точность позиции
|
||||||
|
|
||||||
|
// Размеры навигационного знака
|
||||||
|
private double length; // длина в метрах
|
||||||
|
private double width; // ширина в метрах
|
||||||
|
private double draft; // осадка в метрах
|
||||||
|
|
||||||
|
// Dimension Reference поля (для коротких сообщений)
|
||||||
|
private int dimRefA; // от носа до антенны
|
||||||
|
private int dimRefB; // от антенны до кормы
|
||||||
|
private int dimRefC; // от левого борта до антенны
|
||||||
|
private int dimRefD; // от антенны до правого борта
|
||||||
|
|
||||||
|
// Дополнительные поля для полных сообщений
|
||||||
|
private int epfdType; // тип электронного устройства позиционирования
|
||||||
|
private int utcSecond; // секунда UTC timestamp
|
||||||
|
private boolean offPositionIndicator; // индикатор смещения с позиции
|
||||||
|
private int regionalReserved; // зарезервировано для регионального использования
|
||||||
|
private boolean raimFlag; // флаг RAIM (Receiver Autonomous Integrity Monitoring)
|
||||||
|
|
||||||
|
private LocalDateTime lastUpdate;
|
||||||
|
private boolean isActive; // активно ли устройство
|
||||||
|
private boolean selected; // выделено ли на карте
|
||||||
|
|
||||||
|
public AISNavigationAid() {
|
||||||
|
this.lastUpdate = LocalDateTime.now();
|
||||||
|
this.isActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AISNavigationAid(String mmsi) {
|
||||||
|
this();
|
||||||
|
this.mmsi = mmsi;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Геттеры и сеттеры
|
||||||
|
public String getMmsi() { return mmsi; }
|
||||||
|
public void setMmsi(String mmsi) { this.mmsi = mmsi; }
|
||||||
|
|
||||||
|
public String getAidName() { return aidName; }
|
||||||
|
public void setAidName(String aidName) { this.aidName = aidName; }
|
||||||
|
|
||||||
|
public int getAidType() { return aidType; }
|
||||||
|
public void setAidType(int aidType) {
|
||||||
|
this.aidType = aidType;
|
||||||
|
this.aidTypeDescription = getAidTypeDescription(aidType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAidTypeDescription() { return aidTypeDescription; }
|
||||||
|
public void setAidTypeDescription(String aidTypeDescription) { this.aidTypeDescription = aidTypeDescription; }
|
||||||
|
|
||||||
|
public double getLatitude() { return latitude; }
|
||||||
|
public void setLatitude(double latitude) { this.latitude = latitude; }
|
||||||
|
|
||||||
|
public double getLongitude() { return longitude; }
|
||||||
|
public void setLongitude(double longitude) { this.longitude = longitude; }
|
||||||
|
|
||||||
|
public boolean isPositionAccuracy() { return positionAccuracy; }
|
||||||
|
public void setPositionAccuracy(boolean positionAccuracy) { this.positionAccuracy = positionAccuracy; }
|
||||||
|
|
||||||
|
public double getLength() { return length; }
|
||||||
|
public void setLength(double length) { this.length = length; }
|
||||||
|
|
||||||
|
public double getWidth() { return width; }
|
||||||
|
public void setWidth(double width) { this.width = width; }
|
||||||
|
|
||||||
|
public double getDraft() { return draft; }
|
||||||
|
public void setDraft(double draft) { this.draft = draft; }
|
||||||
|
|
||||||
|
public int getDimRefA() { return dimRefA; }
|
||||||
|
public void setDimRefA(int dimRefA) { this.dimRefA = dimRefA; }
|
||||||
|
|
||||||
|
public int getDimRefB() { return dimRefB; }
|
||||||
|
public void setDimRefB(int dimRefB) { this.dimRefB = dimRefB; }
|
||||||
|
|
||||||
|
public int getDimRefC() { return dimRefC; }
|
||||||
|
public void setDimRefC(int dimRefC) { this.dimRefC = dimRefC; }
|
||||||
|
|
||||||
|
public int getDimRefD() { return dimRefD; }
|
||||||
|
public void setDimRefD(int dimRefD) { this.dimRefD = dimRefD; }
|
||||||
|
|
||||||
|
public int getEpfdType() { return epfdType; }
|
||||||
|
public void setEpfdType(int epfdType) { this.epfdType = epfdType; }
|
||||||
|
|
||||||
|
public int getUtcSecond() { return utcSecond; }
|
||||||
|
public void setUtcSecond(int utcSecond) { this.utcSecond = utcSecond; }
|
||||||
|
|
||||||
|
public boolean isOffPositionIndicator() { return offPositionIndicator; }
|
||||||
|
public void setOffPositionIndicator(boolean offPositionIndicator) { this.offPositionIndicator = offPositionIndicator; }
|
||||||
|
|
||||||
|
public int getRegionalReserved() { return regionalReserved; }
|
||||||
|
public void setRegionalReserved(int regionalReserved) { this.regionalReserved = regionalReserved; }
|
||||||
|
|
||||||
|
public boolean isRaimFlag() { return raimFlag; }
|
||||||
|
public void setRaimFlag(boolean raimFlag) { this.raimFlag = raimFlag; }
|
||||||
|
|
||||||
|
public LocalDateTime getLastUpdate() { return lastUpdate; }
|
||||||
|
public void setLastUpdate(LocalDateTime lastUpdate) { this.lastUpdate = lastUpdate; }
|
||||||
|
|
||||||
|
public boolean isActive() { return isActive; }
|
||||||
|
public void setActive(boolean active) { isActive = active; }
|
||||||
|
|
||||||
|
public boolean isSelected() { return selected; }
|
||||||
|
public void setSelected(boolean selected) { this.selected = selected; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет позицию навигационного знака
|
||||||
|
*/
|
||||||
|
public void updatePosition(double latitude, double longitude) {
|
||||||
|
this.latitude = latitude;
|
||||||
|
this.longitude = longitude;
|
||||||
|
this.lastUpdate = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, не устарели ли данные на указанное количество минут
|
||||||
|
*/
|
||||||
|
public boolean isDataStale(int warningMinutes) {
|
||||||
|
return LocalDateTime.now().minusMinutes(warningMinutes).isAfter(lastUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, нужно ли удалить данные (старше указанного количества минут)
|
||||||
|
*/
|
||||||
|
public boolean shouldBeRemoved(int removeMinutes) {
|
||||||
|
return LocalDateTime.now().minusMinutes(removeMinutes).isAfter(lastUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает количество минут с последнего обновления
|
||||||
|
*/
|
||||||
|
public long getMinutesSinceLastUpdate() {
|
||||||
|
return java.time.Duration.between(lastUpdate, LocalDateTime.now()).toMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает описание типа электронного устройства позиционирования
|
||||||
|
*/
|
||||||
|
public String getEpfdTypeDescription() {
|
||||||
|
switch (epfdType) {
|
||||||
|
case 0: return "Undefined";
|
||||||
|
case 1: return "GPS";
|
||||||
|
case 2: return "GLONASS";
|
||||||
|
case 3: return "Combined GPS/GLONASS";
|
||||||
|
case 4: return "Loran-C";
|
||||||
|
case 5: return "Chayka";
|
||||||
|
case 6: return "Integrated navigation system";
|
||||||
|
case 7: return "Surveyed";
|
||||||
|
case 8:
|
||||||
|
case 9:
|
||||||
|
case 10:
|
||||||
|
case 11:
|
||||||
|
case 12:
|
||||||
|
case 13:
|
||||||
|
case 14:
|
||||||
|
case 15: return "Not used";
|
||||||
|
default: return "Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает описание типа навигационного знака по коду
|
||||||
|
*/
|
||||||
|
private String getAidTypeDescription(int aidType) {
|
||||||
|
switch (aidType) {
|
||||||
|
case 0: return "Reference point";
|
||||||
|
case 1: return "RACON (radar transponder marking a navigation hazard)";
|
||||||
|
case 2: return "Fixed structure off shore, such as oil platforms, wind farms, rigs";
|
||||||
|
case 3: return "Spare, Reserved for future use";
|
||||||
|
case 4: return "Light, without sectors";
|
||||||
|
case 5: return "Light, with sectors";
|
||||||
|
case 6: return "Leading Light Front";
|
||||||
|
case 7: return "Leading Light Rear";
|
||||||
|
case 8: return "Beacon, Cardinal N";
|
||||||
|
case 9: return "Beacon, Cardinal E";
|
||||||
|
case 10: return "Beacon, Cardinal S";
|
||||||
|
case 11: return "Beacon, Cardinal W";
|
||||||
|
case 12: return "Beacon, Port hand";
|
||||||
|
case 13: return "Beacon, Starboard hand";
|
||||||
|
case 14: return "Beacon, Preferred Channel port hand";
|
||||||
|
case 15: return "Beacon, Preferred Channel starboard hand";
|
||||||
|
case 16: return "Beacon, Isolated danger";
|
||||||
|
case 17: return "Beacon, Safe water";
|
||||||
|
case 18: return "Beacon, Special mark";
|
||||||
|
case 19: return "Cardinal Mark N";
|
||||||
|
case 20: return "Cardinal Mark E";
|
||||||
|
case 21: return "Cardinal Mark S";
|
||||||
|
case 22: return "Cardinal Mark W";
|
||||||
|
case 23: return "Port hand Mark";
|
||||||
|
case 24: return "Starboard hand Mark";
|
||||||
|
case 25: return "Preferred Channel port hand";
|
||||||
|
case 26: return "Preferred Channel starboard hand";
|
||||||
|
case 27: return "Isolated danger";
|
||||||
|
case 28: return "Safe water";
|
||||||
|
case 29: return "Special mark";
|
||||||
|
case 30: return "Light Vessel / LANBY / Rigs";
|
||||||
|
default: return "Unknown Aid-to-Navigation";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вычисляет общие размеры из Dimension Reference полей
|
||||||
|
*/
|
||||||
|
public void calculateDimensionsFromRefs() {
|
||||||
|
this.length = dimRefA + dimRefB;
|
||||||
|
this.width = dimRefC + dimRefD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, является ли это буйком (Cardinal Mark)
|
||||||
|
*/
|
||||||
|
public boolean isCardinalMark() {
|
||||||
|
return aidType >= 19 && aidType <= 22;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, является ли это маяком
|
||||||
|
*/
|
||||||
|
public boolean isLight() {
|
||||||
|
return aidType >= 4 && aidType <= 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, является ли это платформой или стационарной конструкцией
|
||||||
|
*/
|
||||||
|
public boolean isFixedStructure() {
|
||||||
|
return aidType == 2 || aidType == 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "AISNavigationAid{" +
|
||||||
|
"mmsi='" + mmsi + '\'' +
|
||||||
|
", name='" + aidName + '\'' +
|
||||||
|
", type=" + aidType + " (" + aidTypeDescription + ")" +
|
||||||
|
", lat=" + latitude +
|
||||||
|
", lon=" + longitude +
|
||||||
|
", L=" + length +
|
||||||
|
", W=" + width +
|
||||||
|
", D=" + draft +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,9 +31,12 @@ public class CompassSensor implements SensorEventListener {
|
|||||||
|
|
||||||
// Скользящий фильтр для сглаживания значений
|
// Скользящий фильтр для сглаживания значений
|
||||||
private static final int FILTER_SIZE = 60;
|
private static final int FILTER_SIZE = 60;
|
||||||
|
private static final float DEADBAND_DEG = 1.5f;
|
||||||
private float[] azimuthBuffer = new float[FILTER_SIZE];
|
private float[] azimuthBuffer = new float[FILTER_SIZE];
|
||||||
private int bufferIndex = 0;
|
private int bufferIndex = 0;
|
||||||
private boolean bufferFull = false;
|
private boolean bufferFull = false;
|
||||||
|
/** Last value sent to UI (circular deadband). */
|
||||||
|
private float lastReportedAzimuth = Float.NaN;
|
||||||
|
|
||||||
public interface CompassListener {
|
public interface CompassListener {
|
||||||
void onCompassChanged(float azimuth);
|
void onCompassChanged(float azimuth);
|
||||||
@@ -81,6 +84,7 @@ public class CompassSensor implements SensorEventListener {
|
|||||||
private void resetFilter() {
|
private void resetFilter() {
|
||||||
bufferIndex = 0;
|
bufferIndex = 0;
|
||||||
bufferFull = false;
|
bufferFull = false;
|
||||||
|
lastReportedAzimuth = Float.NaN;
|
||||||
for (int i = 0; i < FILTER_SIZE; i++) {
|
for (int i = 0; i < FILTER_SIZE; i++) {
|
||||||
azimuthBuffer[i] = 0;
|
azimuthBuffer[i] = 0;
|
||||||
}
|
}
|
||||||
@@ -142,26 +146,39 @@ public class CompassSensor implements SensorEventListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Применяет скользящий фильтр для сглаживания значений
|
* Скользящее усреднение по кругу (векторное среднее), без скачков у 0°/360°.
|
||||||
*/
|
*/
|
||||||
private float applyLowPassFilter(float newValue) {
|
private float applyLowPassFilter(float newValue) {
|
||||||
// Добавляем новое значение в буфер
|
|
||||||
azimuthBuffer[bufferIndex] = newValue;
|
azimuthBuffer[bufferIndex] = newValue;
|
||||||
bufferIndex = (bufferIndex + 1) % FILTER_SIZE;
|
bufferIndex = (bufferIndex + 1) % FILTER_SIZE;
|
||||||
|
|
||||||
if (bufferIndex == 0) {
|
if (bufferIndex == 0) {
|
||||||
bufferFull = true;
|
bufferFull = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вычисляем среднее значение
|
|
||||||
float sum = 0;
|
|
||||||
int count = bufferFull ? FILTER_SIZE : bufferIndex;
|
int count = bufferFull ? FILTER_SIZE : bufferIndex;
|
||||||
|
if (count <= 0) {
|
||||||
for (int i = 0; i < count; i++) {
|
return newValue;
|
||||||
sum += azimuthBuffer[i];
|
|
||||||
}
|
}
|
||||||
|
double sx = 0.0;
|
||||||
return sum / count;
|
double sy = 0.0;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
double rad = Math.toRadians(azimuthBuffer[i]);
|
||||||
|
sx += Math.cos(rad);
|
||||||
|
sy += Math.sin(rad);
|
||||||
|
}
|
||||||
|
float mean = (float) Math.toDegrees(Math.atan2(sy / count, sx / count));
|
||||||
|
if (mean < 0) {
|
||||||
|
mean += 360;
|
||||||
|
}
|
||||||
|
if (!Float.isNaN(lastReportedAzimuth)) {
|
||||||
|
float d = mean - lastReportedAzimuth;
|
||||||
|
while (d > 180) d -= 360;
|
||||||
|
while (d < -180) d += 360;
|
||||||
|
if (Math.abs(d) < DEADBAND_DEG) {
|
||||||
|
return lastReportedAzimuth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastReportedAzimuth = mean;
|
||||||
|
return mean;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isAvailable() {
|
public boolean isAvailable() {
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package com.grigowashere.aismap.settings;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.bluetooth.BluetoothAdapter;
|
||||||
|
import android.bluetooth.BluetoothDevice;
|
||||||
|
import android.bluetooth.BluetoothManager;
|
||||||
|
import android.bluetooth.le.BluetoothLeScanner;
|
||||||
|
import android.bluetooth.le.ScanCallback;
|
||||||
|
import android.bluetooth.le.ScanResult;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.core.app.ActivityCompat;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.google.android.material.switchmaterial.SwitchMaterial;
|
||||||
|
import com.grigowashere.aismap.R;
|
||||||
|
import com.grigowashere.aismap.utils.SettingsManager;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class InterfacesSettingsActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private static final String TAG = "InterfacesSettings";
|
||||||
|
private static final int REQ_PERMS_BLE = 2001;
|
||||||
|
|
||||||
|
private SettingsManager settingsManager;
|
||||||
|
|
||||||
|
// UDP
|
||||||
|
private EditText etUdpPort;
|
||||||
|
private SwitchMaterial swUdpEnabled;
|
||||||
|
|
||||||
|
// BLE
|
||||||
|
private SwitchMaterial swBleEnabled;
|
||||||
|
private EditText etBleMac;
|
||||||
|
|
||||||
|
// BLE UDP Bridge
|
||||||
|
private SwitchMaterial swBleBridgeEnabled;
|
||||||
|
private EditText etBleBridgeHost;
|
||||||
|
private EditText etBleBridgePort;
|
||||||
|
|
||||||
|
private Button btnSave;
|
||||||
|
private Button btnCancel;
|
||||||
|
|
||||||
|
// Scan UI
|
||||||
|
private Button btnBleScan;
|
||||||
|
private Button btnBleStopScan;
|
||||||
|
private RecyclerView rvBle;
|
||||||
|
private DevicesAdapter devicesAdapter;
|
||||||
|
|
||||||
|
private BluetoothAdapter btAdapter;
|
||||||
|
private BluetoothLeScanner bleScanner;
|
||||||
|
private boolean scanning = false;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_interfaces_settings);
|
||||||
|
settingsManager = new SettingsManager(this);
|
||||||
|
BluetoothManager bm = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
|
||||||
|
btAdapter = bm != null ? bm.getAdapter() : null;
|
||||||
|
bleScanner = btAdapter != null ? btAdapter.getBluetoothLeScanner() : null;
|
||||||
|
initViews();
|
||||||
|
loadValues();
|
||||||
|
setupHandlers();
|
||||||
|
setupRecycler();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initViews() {
|
||||||
|
etUdpPort = findViewById(R.id.et_udp_port);
|
||||||
|
swUdpEnabled = findViewById(R.id.switch_udp_enabled);
|
||||||
|
swBleEnabled = findViewById(R.id.switch_ble_enabled);
|
||||||
|
etBleMac = findViewById(R.id.et_ble_mac);
|
||||||
|
swBleBridgeEnabled = findViewById(R.id.switch_ble_udp_bridge_enabled);
|
||||||
|
etBleBridgeHost = findViewById(R.id.et_ble_udp_host);
|
||||||
|
etBleBridgePort = findViewById(R.id.et_ble_udp_port);
|
||||||
|
btnSave = findViewById(R.id.btn_save);
|
||||||
|
btnCancel = findViewById(R.id.btn_cancel);
|
||||||
|
btnBleScan = findViewById(R.id.btn_ble_scan);
|
||||||
|
btnBleStopScan = findViewById(R.id.btn_ble_stop_scan);
|
||||||
|
rvBle = findViewById(R.id.rv_ble_devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadValues() {
|
||||||
|
etUdpPort.setText(String.valueOf(settingsManager.getUDPPort()));
|
||||||
|
swUdpEnabled.setChecked(settingsManager.isUDPEnabled());
|
||||||
|
|
||||||
|
swBleEnabled.setChecked(settingsManager.isBLEEnabled());
|
||||||
|
etBleMac.setText(settingsManager.getBLEDeviceMac());
|
||||||
|
|
||||||
|
swBleBridgeEnabled.setChecked(settingsManager.isBleUdpBridgeEnabled());
|
||||||
|
etBleBridgeHost.setText(settingsManager.getBleUdpBridgeHost());
|
||||||
|
etBleBridgePort.setText(String.valueOf(settingsManager.getBleUdpBridgePort()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setupHandlers() {
|
||||||
|
btnCancel.setOnClickListener(v -> finish());
|
||||||
|
btnSave.setOnClickListener(v -> saveAndExit());
|
||||||
|
btnBleScan.setOnClickListener(v -> startScan());
|
||||||
|
btnBleStopScan.setOnClickListener(v -> stopScan());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setupRecycler() {
|
||||||
|
devicesAdapter = new DevicesAdapter(device -> {
|
||||||
|
if (device == null) return;
|
||||||
|
String mac = device.getAddress();
|
||||||
|
etBleMac.setText(mac);
|
||||||
|
Toast.makeText(this, "Выбрано устройство: " + device.getName() + " (" + mac + ")", Toast.LENGTH_SHORT).show();
|
||||||
|
});
|
||||||
|
rvBle.setLayoutManager(new LinearLayoutManager(this));
|
||||||
|
rvBle.setAdapter(devicesAdapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startScan() {
|
||||||
|
if (btAdapter == null || !btAdapter.isEnabled()) {
|
||||||
|
Toast.makeText(this, "Bluetooth не включен", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ensureBlePerms()) return;
|
||||||
|
if (bleScanner == null) {
|
||||||
|
Toast.makeText(this, "BLE Scanner недоступен", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scanning) return;
|
||||||
|
devicesAdapter.clear();
|
||||||
|
bleScanner.startScan(scanCallback);
|
||||||
|
scanning = true;
|
||||||
|
Toast.makeText(this, "BLE сканирование запущено", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopScan() {
|
||||||
|
if (!scanning) return;
|
||||||
|
try { bleScanner.stopScan(scanCallback); } catch (Exception ignore) {}
|
||||||
|
scanning = false;
|
||||||
|
Toast.makeText(this, "BLE сканирование остановлено", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean ensureBlePerms() {
|
||||||
|
List<String> need = new ArrayList<>();
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) need.add(Manifest.permission.BLUETOOTH_SCAN);
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) need.add(Manifest.permission.BLUETOOTH_CONNECT);
|
||||||
|
} else {
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH) != PackageManager.PERMISSION_GRANTED) need.add(Manifest.permission.BLUETOOTH);
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_ADMIN) != PackageManager.PERMISSION_GRANTED) need.add(Manifest.permission.BLUETOOTH_ADMIN);
|
||||||
|
}
|
||||||
|
if (!need.isEmpty()) {
|
||||||
|
ActivityCompat.requestPermissions(this, need.toArray(new String[0]), REQ_PERMS_BLE);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||||
|
if (requestCode == REQ_PERMS_BLE) {
|
||||||
|
boolean ok = true;
|
||||||
|
for (int r : grantResults) {
|
||||||
|
if (r != PackageManager.PERMISSION_GRANTED) { ok = false; break; }
|
||||||
|
}
|
||||||
|
if (ok) startScan(); else Toast.makeText(this, "Разрешения BLE не предоставлены", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ScanCallback scanCallback = new ScanCallback() {
|
||||||
|
@Override
|
||||||
|
public void onScanResult(int callbackType, @NonNull ScanResult result) {
|
||||||
|
BluetoothDevice d = result.getDevice();
|
||||||
|
if (d == null || d.getAddress() == null) return;
|
||||||
|
devicesAdapter.addOrUpdate(d, result.getRssi());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private void saveAndExit() {
|
||||||
|
try {
|
||||||
|
int udpPort = parseInt(etUdpPort.getText().toString().trim(), 10110, 1, 65535);
|
||||||
|
settingsManager.setUDPPort(udpPort);
|
||||||
|
settingsManager.setUDPEnabled(swUdpEnabled.isChecked());
|
||||||
|
|
||||||
|
settingsManager.setBLEEnabled(swBleEnabled.isChecked());
|
||||||
|
settingsManager.setBLEDeviceMac(etBleMac.getText().toString().trim());
|
||||||
|
|
||||||
|
settingsManager.setBleUdpBridgeEnabled(swBleBridgeEnabled.isChecked());
|
||||||
|
settingsManager.setBleUdpBridgeHost(etBleBridgeHost.getText().toString().trim());
|
||||||
|
int brPort = parseInt(etBleBridgePort.getText().toString().trim(), 10110, 1, 65535);
|
||||||
|
settingsManager.setBleUdpBridgePort(brPort);
|
||||||
|
|
||||||
|
Toast.makeText(this, "Настройки сохранены", Toast.LENGTH_SHORT).show();
|
||||||
|
finish();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Ошибка сохранения: " + e.getMessage(), e);
|
||||||
|
Toast.makeText(this, "Ошибка сохранения настроек", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int parseInt(String s, int def, int min, int max) {
|
||||||
|
try {
|
||||||
|
int v = Integer.parseInt(s);
|
||||||
|
if (v < min || v > max) return def;
|
||||||
|
return v;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recycler adapter
|
||||||
|
private static class DevicesAdapter extends RecyclerView.Adapter<DevicesAdapter.VH> {
|
||||||
|
interface OnClick { void onClick(BluetoothDevice d); }
|
||||||
|
private final List<Item> items = new ArrayList<>();
|
||||||
|
private final OnClick onClick;
|
||||||
|
DevicesAdapter(OnClick onClick) { this.onClick = onClick; }
|
||||||
|
|
||||||
|
static class Item { BluetoothDevice d; int rssi; }
|
||||||
|
|
||||||
|
static class VH extends RecyclerView.ViewHolder {
|
||||||
|
TextView tvName; TextView tvMac; TextView tvRssi;
|
||||||
|
VH(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
tvName = itemView.findViewById(android.R.id.text1);
|
||||||
|
tvMac = itemView.findViewById(android.R.id.text2);
|
||||||
|
tvRssi = itemView.findViewById(R.id.text3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_ble_device, parent, false);
|
||||||
|
return new VH(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull VH holder, int position) {
|
||||||
|
Item it = items.get(position);
|
||||||
|
String name = it.d.getName();
|
||||||
|
holder.tvName.setText(name != null ? name : "(без имени)");
|
||||||
|
holder.tvMac.setText(it.d.getAddress());
|
||||||
|
holder.tvRssi.setText("RSSI: " + it.rssi);
|
||||||
|
holder.itemView.setOnClickListener(v -> onClick.onClick(it.d));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() { return items.size(); }
|
||||||
|
|
||||||
|
void clear() { items.clear(); notifyDataSetChanged(); }
|
||||||
|
|
||||||
|
void addOrUpdate(BluetoothDevice d, int rssi) {
|
||||||
|
for (Item it : items) {
|
||||||
|
if (it.d.getAddress().equals(d.getAddress())) { it.rssi = rssi; notifyDataSetChanged(); return; }
|
||||||
|
}
|
||||||
|
Item it = new Item(); it.d = d; it.rssi = rssi; items.add(it); notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,11 @@ public class MenuBinder {
|
|||||||
boolean screenEnabled = settingsManager.isKeepScreenOnEnabled();
|
boolean screenEnabled = settingsManager.isKeepScreenOnEnabled();
|
||||||
screenItem.setTitle(screenEnabled ? "Экран ✓" : "Экран");
|
screenItem.setTitle(screenEnabled ? "Экран ✓" : "Экран");
|
||||||
}
|
}
|
||||||
|
MenuItem seamarksItem = menu.findItem(R.id.menu_seamarks);
|
||||||
|
if (seamarksItem != null) {
|
||||||
|
boolean seamarksEnabled = settingsManager.isSeamarksEnabled();
|
||||||
|
seamarksItem.setTitle(seamarksEnabled ? "Морские знаки ✓" : "Морские знаки");
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.w(TAG, "onPrepareOptionsMenu: " + e.getMessage());
|
Log.w(TAG, "onPrepareOptionsMenu: " + e.getMessage());
|
||||||
@@ -78,6 +83,9 @@ public class MenuBinder {
|
|||||||
} else if (id == R.id.menu_keep_screen_on) {
|
} else if (id == R.id.menu_keep_screen_on) {
|
||||||
actions.toggleKeepScreenOn();
|
actions.toggleKeepScreenOn();
|
||||||
return true;
|
return true;
|
||||||
|
} else if (id == R.id.menu_seamarks) {
|
||||||
|
actions.toggleSeamarks();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.w(TAG, "onOptionsItemSelected error: " + e.getMessage());
|
Log.w(TAG, "onOptionsItemSelected error: " + e.getMessage());
|
||||||
@@ -95,6 +103,7 @@ public class MenuBinder {
|
|||||||
void togglePathTracking();
|
void togglePathTracking();
|
||||||
void testForegroundService();
|
void testForegroundService();
|
||||||
void toggleKeepScreenOn();
|
void toggleKeepScreenOn();
|
||||||
|
void toggleSeamarks();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import com.grigowashere.aismap.models.Vessel;
|
|||||||
import com.grigowashere.aismap.models.AISVessel;
|
import com.grigowashere.aismap.models.AISVessel;
|
||||||
|
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -156,10 +158,10 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
|
|||||||
mapInterface.removeAISVesselMarker(mmsi);
|
mapInterface.removeAISVesselMarker(mmsi);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем или добавляем суда (различать не будем - MapInterface сам решит)
|
// Обновляем или добавляем суда пачкой, чтобы карта сделала один GeoJSON refresh.
|
||||||
for (AISVessel vessel : pendingAISUpdates.values()) {
|
List<AISVessel> updates = new ArrayList<>(pendingAISUpdates.values());
|
||||||
Log.d(TAG, "Обновляем/добавляем AIS судно: " + vessel.getMmsi());
|
if (!updates.isEmpty()) {
|
||||||
mapInterface.updateAISVesselPosition(vessel);
|
mapInterface.updateAISVesselPositions(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "AIS updates выполнены: удалено=" + pendingAISRemovals.size() +
|
Log.d(TAG, "AIS updates выполнены: удалено=" + pendingAISRemovals.size() +
|
||||||
|
|||||||
@@ -2,370 +2,172 @@ package com.grigowashere.aismap.utils;
|
|||||||
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
import java.net.HttpURLConnection;
|
import java.net.HttpURLConnection;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Утилита для отправки логов на внешний ресурс
|
* Утилита для отправки логов на внешний ресурс
|
||||||
* Отправляет GET запросы на https://ais.grigowashere.ru/add
|
* Отправляет пакеты логов раз в секунду на https://ais.grigowashere.ru/logs/batch
|
||||||
*/
|
*/
|
||||||
public class LogSender {
|
public class LogSender {
|
||||||
|
|
||||||
private static final String TAG = "LogSender";
|
private static final String TAG = "LogSender";
|
||||||
private static final String BASE_URL = "https://ais.grigowashere.ru/add";
|
private static final String BASE_URL = "https://ais.grigowashere.ru";
|
||||||
private static final ExecutorService executor = Executors.newSingleThreadExecutor();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отправляет лог NMEA сообщения
|
* Временно отключено, чтобы не создавать сетевой шум и фоновые потоки.
|
||||||
* @param nmeaMessage NMEA сообщение
|
* Если снова понадобится — переключить в true или завязать на настройку/BuildConfig.
|
||||||
*/
|
*/
|
||||||
public static void logNMEA(String nmeaMessage) {
|
private static final boolean ENABLED = false;
|
||||||
if (nmeaMessage == null || nmeaMessage.trim().isEmpty()) {
|
|
||||||
|
// Мягкие цвета для лучшей читаемости на фоне #364758
|
||||||
|
private static final String COLOR_SOFT_BLUE = "#8AB4F8"; // мягкий синий
|
||||||
|
private static final String COLOR_SOFT_RED = "#FF8A80"; // мягкий красный
|
||||||
|
|
||||||
|
// Настройки генерации цветов для кораблей
|
||||||
|
private static final float VESSEL_COLOR_SATURATION = 0.4f; // Низкая насыщенность для мягкости
|
||||||
|
private static final float VESSEL_COLOR_VALUE = 0.75f; // Средняя яркость для контраста с темным фоном
|
||||||
|
|
||||||
|
// Буферизация логов
|
||||||
|
private static final List<LogEntry> logBuffer = new ArrayList<>();
|
||||||
|
private static final Object bufferLock = new Object();
|
||||||
|
private static volatile ScheduledExecutorService scheduler = null;
|
||||||
|
private static volatile boolean schedulerStarted = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Структура для хранения лога
|
||||||
|
*/
|
||||||
|
private static class LogEntry {
|
||||||
|
String type;
|
||||||
|
String message;
|
||||||
|
String color;
|
||||||
|
long timestamp;
|
||||||
|
|
||||||
|
LogEntry(String type, String message, String color) {
|
||||||
|
this.type = type;
|
||||||
|
this.message = message;
|
||||||
|
this.color = color;
|
||||||
|
this.timestamp = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициализирует планировщик отправки логов
|
||||||
|
*/
|
||||||
|
private static void startScheduler() {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!schedulerStarted) {
|
||||||
|
synchronized (LogSender.class) {
|
||||||
|
if (!schedulerStarted) {
|
||||||
|
if (scheduler == null || scheduler.isShutdown()) {
|
||||||
|
scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||||
|
}
|
||||||
|
scheduler.scheduleAtFixedRate(() -> {
|
||||||
|
sendBufferedLogs();
|
||||||
|
}, 1, 1, TimeUnit.SECONDS);
|
||||||
|
schedulerStarted = true;
|
||||||
|
Log.d(TAG, "Планировщик отправки логов запущен (каждую секунду)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляет лог в буфер
|
||||||
|
*/
|
||||||
|
private static void addToBuffer(String type, String message, String color) {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
synchronized (bufferLock) {
|
||||||
|
logBuffer.add(new LogEntry(type, message, color));
|
||||||
|
}
|
||||||
|
startScheduler(); // Запускаем планировщик при первом логе
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправляет все накопленные логи пакетом
|
||||||
|
*/
|
||||||
|
private static void sendBufferedLogs() {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<LogEntry> logsToSend;
|
||||||
|
synchronized (bufferLock) {
|
||||||
|
if (logBuffer.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logsToSend = new ArrayList<>(logBuffer);
|
||||||
|
logBuffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logsToSend.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
executor.execute(() -> {
|
Log.d(TAG, "Отправляем пакет из " + logsToSend.size() + " логов");
|
||||||
try {
|
sendLogsBatch(logsToSend);
|
||||||
String encodedMessage = encodeForURL(nmeaMessage);
|
|
||||||
String url = BASE_URL + "?nmea=" + encodedMessage + "&color=blue";
|
|
||||||
|
|
||||||
sendGetRequest(url);
|
|
||||||
// Убираем лишние логи
|
|
||||||
// Log.d(TAG, "NMEA лог отправлен: " + nmeaMessage);
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Ошибка отправки NMEA лога: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отправляет лог обновления информации о корабле
|
* Отправляет пакет логов через POST запрос
|
||||||
* @param mmsi MMSI корабля
|
|
||||||
* @param vesselInfo Информация о корабле
|
|
||||||
*/
|
*/
|
||||||
public static void logShipUpdate(String mmsi, String vesselInfo) {
|
private static void sendLogsBatch(List<LogEntry> logs) {
|
||||||
if (mmsi == null || mmsi.trim().isEmpty()) {
|
if (!ENABLED) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
executor.execute(() -> {
|
|
||||||
try {
|
|
||||||
String message = "MMSI: " + mmsi;
|
|
||||||
if (vesselInfo != null && !vesselInfo.trim().isEmpty()) {
|
|
||||||
message += " | " + vesselInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Извлекаем тип судна из vesselInfo и генерируем цвет
|
|
||||||
// Генерируем уникальный цвет для корабля на основе MMSI
|
|
||||||
String vesselColor = generateVesselColor(mmsi);
|
|
||||||
|
|
||||||
String encodedMessage = encodeForURL(message);
|
|
||||||
String encodedColor = encodeColorForURL(vesselColor);
|
|
||||||
String url = BASE_URL + "?ships=" + encodedMessage + "&color=" + encodedColor;
|
|
||||||
|
|
||||||
sendGetRequest(url);
|
|
||||||
// Убираем лишние логи
|
|
||||||
// Log.d(TAG, "Ship update лог отправлен: " + message + " ( " + ", цвет: " + vesselColor + ")");
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Ошибка отправки ship update лога: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Отправляет лог обновления информации о корабле с заданным цветом
|
|
||||||
* @param mmsi MMSI корабля
|
|
||||||
* @param vesselInfo Информация о корабле
|
|
||||||
* @param color Цвет в формате HEX (#RRGGBB) или имя цвета
|
|
||||||
*/
|
|
||||||
public static void logShipUpdate(String mmsi, String vesselInfo, String color) {
|
|
||||||
if (mmsi == null || mmsi.trim().isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
executor.execute(() -> {
|
|
||||||
try {
|
|
||||||
String message = "MMSI: " + mmsi;
|
|
||||||
if (vesselInfo != null && !vesselInfo.trim().isEmpty()) {
|
|
||||||
message += " | " + vesselInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Используем переданный цвет или генерируем на основе типа судна
|
|
||||||
String vesselColor;
|
|
||||||
if (color != null && !color.trim().isEmpty()) {
|
|
||||||
vesselColor = color;
|
|
||||||
} else {
|
|
||||||
// Генерируем уникальный цвет для корабля на основе MMSI
|
|
||||||
vesselColor = generateVesselColor(mmsi);
|
|
||||||
}
|
|
||||||
|
|
||||||
String encodedMessage = encodeForURL(message);
|
|
||||||
String encodedColor = encodeColorForURL(vesselColor);
|
|
||||||
String url = BASE_URL + "?ships=" + encodedMessage + "&color=" + encodedColor;
|
|
||||||
|
|
||||||
sendGetRequest(url);
|
|
||||||
// Убираем лишние логи
|
|
||||||
// Log.d(TAG, "Ship update лог отправлен: " + message + " (цвет: " + vesselColor + ")");
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Ошибка отправки ship update лога: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Отправляет произвольный лог
|
|
||||||
* @param logName Имя лога
|
|
||||||
* @param message Сообщение
|
|
||||||
* @param color Цвет (опционально)
|
|
||||||
*/
|
|
||||||
public static void logCustom(String logName, String message, String color) {
|
|
||||||
if (logName == null || logName.trim().isEmpty() || message == null || message.trim().isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
executor.execute(() -> {
|
|
||||||
try {
|
|
||||||
String encodedMessage = encodeForURL(message);
|
|
||||||
String url = BASE_URL + "?" + logName + "=" + encodedMessage;
|
|
||||||
|
|
||||||
if (color != null && !color.trim().isEmpty()) {
|
|
||||||
url += "&color=" + color;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendGetRequest(url);
|
|
||||||
Log.d(TAG, "Custom лог отправлен: " + logName + " = " + message);
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Ошибка отправки custom лога: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Генерирует уникальный цвет для корабля на основе MMSI (устаревший метод)
|
|
||||||
* @param mmsi MMSI корабля
|
|
||||||
* @return HEX цвет в формате #RRGGBB
|
|
||||||
*/
|
|
||||||
private static String generateVesselColor(String mmsi) {
|
|
||||||
try {
|
|
||||||
// Преобразуем MMSI в число для хеширования
|
|
||||||
long mmsiValue = Long.parseLong(mmsi);
|
|
||||||
|
|
||||||
// Используем хеш-функцию для получения равномерного распределения
|
|
||||||
int hash = Long.hashCode(mmsiValue);
|
|
||||||
|
|
||||||
// Извлекаем RGB компоненты из хеша
|
|
||||||
int r = (hash & 0xFF0000) >> 16;
|
|
||||||
int g = (hash & 0x00FF00) >> 8;
|
|
||||||
int b = hash & 0x0000FF;
|
|
||||||
|
|
||||||
// Проверяем, не слишком ли темный цвет (чтобы избежать черного)
|
|
||||||
int brightness = (r + g + b) / 3;
|
|
||||||
if (brightness < 100) {
|
|
||||||
// Если цвет слишком темный, осветляем его
|
|
||||||
r = Math.min(255, r + 120);
|
|
||||||
g = Math.min(255, g + 120);
|
|
||||||
b = Math.min(255, b + 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, не слишком ли светлый цвет (чтобы избежать белого)
|
|
||||||
if (brightness > 220) {
|
|
||||||
// Если цвет слишком светлый, затемняем его
|
|
||||||
r = Math.max(0, r - 60);
|
|
||||||
g = Math.max(0, g - 60);
|
|
||||||
b = Math.max(0, b - 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Форматируем в HEX
|
|
||||||
String color = String.format("#%02X%02X%02X", r, g, b);
|
|
||||||
|
|
||||||
// Убираем лишние логи
|
|
||||||
// Log.d(TAG, "Сгенерирован цвет для MMSI " + mmsi + ": " + color + " (RGB: " + r + "," + g + "," + b + ")");
|
|
||||||
|
|
||||||
return color;
|
|
||||||
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
Log.w(TAG, "Не удалось распарсить MMSI как число: " + mmsi + ", используем цвет по умолчанию");
|
|
||||||
return "#00AA00"; // Зеленый по умолчанию
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Ошибка генерации цвета для MMSI " + mmsi + ": " + e.getMessage(), e);
|
|
||||||
return "#00AA00"; // Зеленый по умолчанию
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Определяет тип судна по MMSI
|
|
||||||
* Использует более точную логику на основе стандартных диапазонов MMSI
|
|
||||||
* @param mmsi MMSI судна
|
|
||||||
* @return Тип судна
|
|
||||||
*/
|
|
||||||
private static String getVesselTypeByMMSI(long mmsi) {
|
|
||||||
// Стандартные диапазоны MMSI для разных типов судов
|
|
||||||
if (mmsi >= 100000000 && mmsi <= 199999999) {
|
|
||||||
return "COASTAL"; // Прибрежные суда
|
|
||||||
} else if (mmsi >= 200000000 && mmsi <= 299999999) {
|
|
||||||
return "FISHING"; // Рыболовные суда
|
|
||||||
} else if (mmsi >= 300000000 && mmsi <= 399999999) {
|
|
||||||
return "CARGO"; // Грузовые суда
|
|
||||||
} else if (mmsi >= 400000000 && mmsi <= 499999999) {
|
|
||||||
return "TANKER"; // Танкеры
|
|
||||||
} else if (mmsi >= 500000000 && mmsi <= 599999999) {
|
|
||||||
return "PASSENGER"; // Пассажирские суда
|
|
||||||
} else if (mmsi >= 600000000 && mmsi <= 699999999) {
|
|
||||||
return "MILITARY"; // Военные корабли
|
|
||||||
} else if (mmsi >= 700000000 && mmsi <= 799999999) {
|
|
||||||
return "PILOT"; // Лоцманские суда
|
|
||||||
} else if (mmsi >= 800000000 && mmsi <= 899999999) {
|
|
||||||
return "PILOT"; // Лоцманские суда (дополнительный диапазон)
|
|
||||||
} else if (mmsi >= 900000000 && mmsi <= 999999999) {
|
|
||||||
return "MILITARY"; // Военные корабли (дополнительный диапазон)
|
|
||||||
} else if (mmsi >= 1000000000 && mmsi <= 1099999999) {
|
|
||||||
return "SAR"; // Спасательные суда
|
|
||||||
} else if (mmsi >= 1100000000 && mmsi <= 1199999999) {
|
|
||||||
return "TUG"; // Буксиры
|
|
||||||
} else if (mmsi >= 1200000000 && mmsi <= 1299999999) {
|
|
||||||
return "PORT_TENDER"; // Портовые суда
|
|
||||||
} else if (mmsi >= 1300000000 && mmsi <= 1399999999) {
|
|
||||||
return "ANTI_POLLUTION"; // Антизагрязнительные суда
|
|
||||||
} else if (mmsi >= 1400000000 && mmsi <= 1499999999) {
|
|
||||||
return "LAW_ENFORCEMENT"; // Правоохранительные суда
|
|
||||||
} else if (mmsi >= 1500000000 && mmsi <= 1599999999) {
|
|
||||||
return "MEDICAL"; // Медицинские суда
|
|
||||||
} else if (mmsi >= 1600000000 && mmsi <= 1699999999) {
|
|
||||||
return "SPECIAL_CRAFT"; // Специальные суда
|
|
||||||
} else if (mmsi >= 1700000000 && mmsi <= 1799999999) {
|
|
||||||
return "PASSENGER"; // Пассажирские суда (дополнительный диапазон)
|
|
||||||
} else if (mmsi >= 1800000000 && mmsi <= 1899999999) {
|
|
||||||
return "CARGO"; // Грузовые суда (дополнительный диапазон)
|
|
||||||
} else if (mmsi >= 1900000000 && mmsi <= 1999999999) {
|
|
||||||
return "TANKER"; // Танкеры (дополнительный диапазон)
|
|
||||||
} else if (mmsi >= 2000000000 && mmsi <= 2099999999) {
|
|
||||||
return "OTHER"; // Другие типы судов
|
|
||||||
} else if (mmsi >= 2100000000L && mmsi <= 2199999999L) {
|
|
||||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
|
||||||
} else if (mmsi >= 2200000000L && mmsi <= 2299999999L) {
|
|
||||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
|
||||||
} else if (mmsi >= 2300000000L && mmsi <= 2399999999L) {
|
|
||||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
|
||||||
} else if (mmsi >= 2400000000L && mmsi <= 2499999999L) {
|
|
||||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
|
||||||
} else if (mmsi >= 2500000000L && mmsi <= 2599999999L) {
|
|
||||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
|
||||||
} else if (mmsi >= 2600000000L && mmsi <= 2699999999L) {
|
|
||||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
|
||||||
} else if (mmsi >= 2700000000L && mmsi <= 2799999999L) {
|
|
||||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
|
||||||
} else if (mmsi >= 2800000000L && mmsi <= 2899999999L) {
|
|
||||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
|
||||||
} else if (mmsi >= 2900000000L && mmsi <= 2999999999L) {
|
|
||||||
return "OTHER"; // Другие типы судов (дополнительный диапазон)
|
|
||||||
} else {
|
|
||||||
return "UNKNOWN"; // Неизвестный тип
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Кодирует цвет для безопасного использования в URL
|
|
||||||
* Специально обрабатывает HEX цвета, заменяя # на %23
|
|
||||||
* @param color Цвет в формате HEX (#RRGGBB) или имя цвета
|
|
||||||
* @return Закодированный цвет
|
|
||||||
*/
|
|
||||||
private static String encodeColorForURL(String color) {
|
|
||||||
if (color == null || color.trim().isEmpty()) {
|
|
||||||
return "green"; // Цвет по умолчанию
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Если цвет начинается с #, заменяем его на %23
|
|
||||||
if (color.startsWith("#")) {
|
|
||||||
String encoded = "%23" + color.substring(1);
|
|
||||||
Log.d(TAG, "Закодирован HEX цвет: " + color + " -> " + encoded);
|
|
||||||
return encoded;
|
|
||||||
} else {
|
|
||||||
// Для именованных цветов используем стандартное кодирование
|
|
||||||
String encoded = URLEncoder.encode(color, StandardCharsets.UTF_8.toString());
|
|
||||||
Log.d(TAG, "Закодирован именованный цвет: " + color + " -> " + encoded);
|
|
||||||
return encoded;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Ошибка кодирования цвета: " + e.getMessage(), e);
|
|
||||||
return "green"; // Цвет по умолчанию
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Кодирует строку для безопасного использования в URL
|
|
||||||
* Дополнительно экранирует символы, которые могут вызывать проблемы
|
|
||||||
* @param message Исходное сообщение
|
|
||||||
* @return Закодированное сообщение
|
|
||||||
*/
|
|
||||||
private static String encodeForURL(String message) {
|
|
||||||
try {
|
|
||||||
// Сначала используем стандартное URL кодирование
|
|
||||||
String encoded = URLEncoder.encode(message, StandardCharsets.UTF_8.toString());
|
|
||||||
|
|
||||||
// Дополнительно экранируем символы, которые могут вызывать проблемы
|
|
||||||
// Заменяем < на %3C, > на %3E, & на %26, " на %22, ' на %27, # на %23
|
|
||||||
encoded = encoded.replace("<", "%3C")
|
|
||||||
.replace(">", "%3E")
|
|
||||||
.replace("&", "%26")
|
|
||||||
.replace("\"", "%22")
|
|
||||||
.replace("'", "%27")
|
|
||||||
.replace("#", "%23");
|
|
||||||
|
|
||||||
// Убираем лишние логи
|
|
||||||
// Log.d(TAG, "Исходное сообщение: " + message);
|
|
||||||
// Log.d(TAG, "Закодированное сообщение: " + encoded);
|
|
||||||
|
|
||||||
return encoded;
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.e(TAG, "Ошибка кодирования URL: " + e.getMessage(), e);
|
|
||||||
// В случае ошибки возвращаем базовое кодирование
|
|
||||||
String fallback = message.replace("<", "%3C")
|
|
||||||
.replace(">", "%3E")
|
|
||||||
.replace("&", "%26")
|
|
||||||
.replace("\"", "%22")
|
|
||||||
.replace("'", "%27")
|
|
||||||
.replace("#", "%23")
|
|
||||||
.replace(" ", "%20");
|
|
||||||
Log.d(TAG, "Fallback кодирование: " + fallback);
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Отправляет GET запрос
|
|
||||||
* @param urlString URL для запроса
|
|
||||||
*/
|
|
||||||
private static void sendGetRequest(String urlString) {
|
|
||||||
HttpURLConnection connection = null;
|
HttpURLConnection connection = null;
|
||||||
try {
|
try {
|
||||||
// Убираем лишние логи
|
URL url = new URL(BASE_URL + "/api/logs/batch");
|
||||||
// Log.d(TAG, "Отправляем GET запрос на: " + urlString);
|
|
||||||
|
|
||||||
@SuppressWarnings("deprecation")
|
|
||||||
URL url = new URL(urlString);
|
|
||||||
connection = (HttpURLConnection) url.openConnection();
|
connection = (HttpURLConnection) url.openConnection();
|
||||||
connection.setRequestMethod("GET");
|
connection.setRequestMethod("POST");
|
||||||
connection.setConnectTimeout(5000); // 5 секунд
|
connection.setRequestProperty("Content-Type", "application/json");
|
||||||
connection.setReadTimeout(5000); // 5 секунд
|
|
||||||
connection.setRequestProperty("User-Agent", "AISMap/1.0");
|
connection.setRequestProperty("User-Agent", "AISMap/1.0");
|
||||||
|
connection.setConnectTimeout(5000);
|
||||||
|
connection.setReadTimeout(10000);
|
||||||
|
connection.setDoOutput(true);
|
||||||
|
|
||||||
|
// Формируем JSON
|
||||||
|
StringBuilder json = new StringBuilder("{\"logs\":[");
|
||||||
|
for (int i = 0; i < logs.size(); i++) {
|
||||||
|
LogEntry log = logs.get(i);
|
||||||
|
if (i > 0) json.append(",");
|
||||||
|
json.append("{")
|
||||||
|
.append("\"type\":\"").append(log.type).append("\",")
|
||||||
|
.append("\"message\":\"").append(log.message.replace("\"", "\\\"")).append("\",")
|
||||||
|
.append("\"color\":\"").append(log.color).append("\",")
|
||||||
|
.append("\"timestamp\":").append(log.timestamp)
|
||||||
|
.append("}");
|
||||||
|
}
|
||||||
|
json.append("]}");
|
||||||
|
|
||||||
|
// Отправляем JSON
|
||||||
|
try (OutputStream os = connection.getOutputStream()) {
|
||||||
|
os.write(json.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
int responseCode = connection.getResponseCode();
|
int responseCode = connection.getResponseCode();
|
||||||
if (responseCode == HttpURLConnection.HTTP_OK) {
|
if (responseCode == HttpURLConnection.HTTP_OK) {
|
||||||
// Убираем лишние логи
|
Log.d(TAG, "Пакет из " + logs.size() + " логов успешно отправлен");
|
||||||
// Log.d(TAG, "Лог успешно отправлен, код ответа: " + responseCode);
|
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Лог отправлен с предупреждением, код ответа: " + responseCode);
|
Log.w(TAG, "Пакет логов отправлен с предупреждением, код ответа: " + responseCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.e(TAG, "Ошибка HTTP запроса: " + e.getMessage(), e);
|
Log.e(TAG, "Ошибка отправки пакета логов: " + e.getMessage(), e);
|
||||||
|
// Возвращаем логи в буфер при ошибке
|
||||||
|
synchronized (bufferLock) {
|
||||||
|
logBuffer.addAll(0, logs); // Добавляем в начало буфера
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (connection != null) {
|
if (connection != null) {
|
||||||
connection.disconnect();
|
connection.disconnect();
|
||||||
@@ -374,9 +176,297 @@ public class LogSender {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Останавливает executor (вызывать при завершении приложения)
|
* Отправляет лог NMEA сообщения
|
||||||
|
* @param nmeaMessage NMEA сообщение
|
||||||
|
*/
|
||||||
|
public static void logNMEA(String nmeaMessage) {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nmeaMessage == null || nmeaMessage.trim().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addToBuffer("nmea", nmeaMessage, COLOR_SOFT_BLUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправляет лог обновления информации о корабле
|
||||||
|
* @param mmsi MMSI корабля
|
||||||
|
* @param vesselInfo Информация о корабле
|
||||||
|
*/
|
||||||
|
public static void logShipUpdate(String mmsi, String vesselInfo) {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mmsi == null || mmsi.trim().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String message = "MMSI: " + mmsi;
|
||||||
|
if (vesselInfo != null && !vesselInfo.trim().isEmpty()) {
|
||||||
|
message += " | " + vesselInfo;
|
||||||
|
}
|
||||||
|
addToBuffer("ships", message, generateVesselColor(mmsi));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправляет лог обновления информации о корабле с заданным цветом
|
||||||
|
* @param mmsi MMSI корабля
|
||||||
|
* @param vesselInfo Информация о корабле
|
||||||
|
* @param color Цвет лога
|
||||||
|
*/
|
||||||
|
public static void logShipUpdate(String mmsi, String vesselInfo, String color) {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mmsi == null || mmsi.trim().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String message = "MMSI: " + mmsi;
|
||||||
|
if (vesselInfo != null && !vesselInfo.trim().isEmpty()) {
|
||||||
|
message += " | " + vesselInfo;
|
||||||
|
}
|
||||||
|
addToBuffer("ships", message, color != null ? color : generateVesselColor(mmsi));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправляет кастомный лог
|
||||||
|
* @param logName Имя лога
|
||||||
|
* @param message Сообщение
|
||||||
|
* @param color Цвет
|
||||||
|
*/
|
||||||
|
public static void logCustom(String logName, String message, String color) {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (logName == null || message == null || message.trim().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addToBuffer(logName, message, color != null ? color : COLOR_SOFT_BLUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерирует уникальный мягкий цвет для корабля на основе MMSI
|
||||||
|
* Оптимизирован для читаемости на фоне #364758
|
||||||
|
*/
|
||||||
|
private static String generateVesselColor(String mmsi) {
|
||||||
|
if (mmsi == null || mmsi.trim().isEmpty()) {
|
||||||
|
return COLOR_SOFT_BLUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
long mmsiLong = Long.parseLong(mmsi);
|
||||||
|
// Используем MMSI для генерации цвета
|
||||||
|
int hash = (int) (mmsiLong % 360);
|
||||||
|
|
||||||
|
// Генерируем мягкий цвет в HSV для фона #364758
|
||||||
|
float hue = hash;
|
||||||
|
float saturation = VESSEL_COLOR_SATURATION; // Низкая насыщенность для мягкости
|
||||||
|
float value = VESSEL_COLOR_VALUE; // Средняя яркость для контраста с темным фоном
|
||||||
|
|
||||||
|
// Конвертируем HSV в RGB (Android-совместимая реализация)
|
||||||
|
float[] rgb = hsvToRgb(hue / 360f, saturation, value);
|
||||||
|
int red = Math.round(rgb[0] * 255);
|
||||||
|
int green = Math.round(rgb[1] * 255);
|
||||||
|
int blue = Math.round(rgb[2] * 255);
|
||||||
|
|
||||||
|
return String.format("#%02X%02X%02X", red, green, blue);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
// Если MMSI не число, используем хеш строки
|
||||||
|
int hash = Math.abs(mmsi.hashCode()) % 360;
|
||||||
|
float hue = hash;
|
||||||
|
float saturation = VESSEL_COLOR_SATURATION; // Низкая насыщенность для мягкости
|
||||||
|
float value = VESSEL_COLOR_VALUE; // Средняя яркость для контраста с темным фоном
|
||||||
|
|
||||||
|
float[] rgb = hsvToRgb(hue / 360f, saturation, value);
|
||||||
|
int red = Math.round(rgb[0] * 255);
|
||||||
|
int green = Math.round(rgb[1] * 255);
|
||||||
|
int blue = Math.round(rgb[2] * 255);
|
||||||
|
|
||||||
|
return String.format("#%02X%02X%02X", red, green, blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует HSV в RGB (Android-совместимая реализация)
|
||||||
|
*/
|
||||||
|
private static float[] hsvToRgb(float h, float s, float v) {
|
||||||
|
float[] rgb = new float[3];
|
||||||
|
|
||||||
|
if (s == 0) {
|
||||||
|
// Оттенки серого
|
||||||
|
rgb[0] = rgb[1] = rgb[2] = v;
|
||||||
|
} else {
|
||||||
|
float c = v * s;
|
||||||
|
float x = c * (1 - Math.abs((h * 6) % 2 - 1));
|
||||||
|
float m = v - c;
|
||||||
|
|
||||||
|
if (h < 1f/6f) {
|
||||||
|
rgb[0] = c; rgb[1] = x; rgb[2] = 0;
|
||||||
|
} else if (h < 2f/6f) {
|
||||||
|
rgb[0] = x; rgb[1] = c; rgb[2] = 0;
|
||||||
|
} else if (h < 3f/6f) {
|
||||||
|
rgb[0] = 0; rgb[1] = c; rgb[2] = x;
|
||||||
|
} else if (h < 4f/6f) {
|
||||||
|
rgb[0] = 0; rgb[1] = x; rgb[2] = c;
|
||||||
|
} else if (h < 5f/6f) {
|
||||||
|
rgb[0] = x; rgb[1] = 0; rgb[2] = c;
|
||||||
|
} else {
|
||||||
|
rgb[0] = c; rgb[1] = 0; rgb[2] = x;
|
||||||
|
}
|
||||||
|
|
||||||
|
rgb[0] += m;
|
||||||
|
rgb[1] += m;
|
||||||
|
rgb[2] += m;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирует ошибки парсинга NMEA сообщений
|
||||||
|
*/
|
||||||
|
public static void logError(String errorType, String message, String details) {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String timestamp = java.time.LocalDateTime.now().format(
|
||||||
|
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
|
||||||
|
String logMessage = String.format(java.util.Locale.US,
|
||||||
|
"[%s] %s: %s | Details: %s",
|
||||||
|
timestamp, errorType, message, details);
|
||||||
|
|
||||||
|
addToBuffer("errors", logMessage, COLOR_SOFT_RED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирует отброшенное NMEA сообщение
|
||||||
|
*/
|
||||||
|
public static void logDroppedNMEA(String reason, String nmeaMessage, String details) {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logError("DROPPED_NMEA",
|
||||||
|
String.format("Отброшено NMEA сообщение: %s", reason),
|
||||||
|
String.format("Message: %s | %s",
|
||||||
|
nmeaMessage != null ? nmeaMessage.substring(0, Math.min(100, nmeaMessage.length())) : "null",
|
||||||
|
details != null ? details : ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирует ошибку парсинга AIS
|
||||||
|
*/
|
||||||
|
public static void logAISParseError(String error, String aisMessage, String details) {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logError("AIS_PARSE_ERROR",
|
||||||
|
String.format("Ошибка парсинга AIS: %s", error),
|
||||||
|
String.format("AIS: %s | %s",
|
||||||
|
aisMessage != null ? aisMessage.substring(0, Math.min(100, aisMessage.length())) : "null",
|
||||||
|
details != null ? details : ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирует ошибку парсинга AIS с полным NMEA сообщением
|
||||||
|
*/
|
||||||
|
public static void logAISParseErrorWithFullNMEA(String error, String fullNMEAMessage, String aisPayload, String details) {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logError("AIS_PARSE_ERROR",
|
||||||
|
String.format("Ошибка парсинга AIS: %s", error),
|
||||||
|
String.format("Full NMEA: %s | AIS Payload: %s | %s",
|
||||||
|
fullNMEAMessage != null ? fullNMEAMessage : "null",
|
||||||
|
aisPayload != null ? aisPayload.substring(0, Math.min(100, aisPayload.length())) : "null",
|
||||||
|
details != null ? details : ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирует ошибку BLE соединения
|
||||||
|
*/
|
||||||
|
public static void logBLEError(String error, String deviceMac, String details) {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logError("BLE_ERROR",
|
||||||
|
String.format("Ошибка BLE: %s", error),
|
||||||
|
String.format("Device: %s | %s",
|
||||||
|
deviceMac != null ? deviceMac : "unknown",
|
||||||
|
details != null ? details : ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Логирует полученный BLE кусок данных
|
||||||
|
*/
|
||||||
|
public static void logBLEDataChunk(String deviceMac, String dataChunk) {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dataChunk == null || dataChunk.trim().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String message = "BLE Data from " + (deviceMac != null ? deviceMac : "unknown") + ": " + dataChunk;
|
||||||
|
addToBuffer("ble", message, COLOR_SOFT_BLUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает количество логов в буфере
|
||||||
|
*/
|
||||||
|
public static int getBufferSize() {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
synchronized (bufferLock) {
|
||||||
|
return logBuffer.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Принудительно отправляет все накопленные логи
|
||||||
|
*/
|
||||||
|
public static void flushLogs() {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendBufferedLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Останавливает планировщик (вызывать при завершении приложения)
|
||||||
*/
|
*/
|
||||||
public static void shutdown() {
|
public static void shutdown() {
|
||||||
executor.shutdown();
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scheduler != null) {
|
||||||
|
scheduler.shutdown();
|
||||||
|
try {
|
||||||
|
if (!scheduler.awaitTermination(2, TimeUnit.SECONDS)) {
|
||||||
|
scheduler.shutdownNow();
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
scheduler.shutdownNow();
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Тестовый метод для демонстрации мягких цветов кораблей
|
||||||
|
* Можно использовать для проверки читаемости на фоне #364758
|
||||||
|
*/
|
||||||
|
public static void testVesselColors() {
|
||||||
|
if (!ENABLED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String[] testMMSIs = {"123456789", "987654321", "555666777", "111222333", "999888777"};
|
||||||
|
|
||||||
|
Log.d(TAG, "=== Тест мягких цветов для кораблей (фон #364758) ===");
|
||||||
|
for (String mmsi : testMMSIs) {
|
||||||
|
String color = generateVesselColor(mmsi);
|
||||||
|
Log.d(TAG, String.format("MMSI %s -> цвет %s", mmsi, color));
|
||||||
|
}
|
||||||
|
// Log.d(TAG, "=== Настройки: насыщенность=%.1f, яркость=%.1f ===",VESSEL_COLOR_SATURATION, VESSEL_COLOR_VALUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ public class SettingsManager {
|
|||||||
private static final String KEY_ANDROID_NMEA_ENABLED = "android_nmea_enabled";
|
private static final String KEY_ANDROID_NMEA_ENABLED = "android_nmea_enabled";
|
||||||
private static final String KEY_UDP_NMEA_ENABLED = "udp_nmea_enabled";
|
private static final String KEY_UDP_NMEA_ENABLED = "udp_nmea_enabled";
|
||||||
private static final String KEY_DATA_MODE = "data_mode";
|
private static final String KEY_DATA_MODE = "data_mode";
|
||||||
|
// Источник координат собственного судна. Отделён от KEY_DATA_MODE,
|
||||||
|
// так как начиная с BLE v2 ais_hub сам поставляет ownship по BLE,
|
||||||
|
// а настройки выше трактуют только старый NMEA-тракт.
|
||||||
|
private static final String KEY_GPS_SOURCE = "gps_source";
|
||||||
private static final String KEY_DATA_STALE_WARNING_MINUTES = "data_stale_warning_minutes";
|
private static final String KEY_DATA_STALE_WARNING_MINUTES = "data_stale_warning_minutes";
|
||||||
private static final String KEY_DATA_STALE_REMOVE_MINUTES = "data_stale_remove_minutes";
|
private static final String KEY_DATA_STALE_REMOVE_MINUTES = "data_stale_remove_minutes";
|
||||||
private static final String KEY_PATH_TRACKING_ENABLED = "path_tracking_enabled";
|
private static final String KEY_PATH_TRACKING_ENABLED = "path_tracking_enabled";
|
||||||
@@ -34,7 +38,19 @@ public class SettingsManager {
|
|||||||
private static final String KEY_CURSOR_ENABLED = "cursor_enabled";
|
private static final String KEY_CURSOR_ENABLED = "cursor_enabled";
|
||||||
private static final String KEY_NOTIFICATIONS_ENABLED = "notifications_enabled";
|
private static final String KEY_NOTIFICATIONS_ENABLED = "notifications_enabled";
|
||||||
private static final String KEY_DEBUG_ENABLED = "debug_enabled";
|
private static final String KEY_DEBUG_ENABLED = "debug_enabled";
|
||||||
|
private static final String KEY_SEAMARKS_ENABLED = "seamarks_enabled";
|
||||||
private static final String KEY_ANDROID_GPS_ENABLED = "android_gps_enabled";
|
private static final String KEY_ANDROID_GPS_ENABLED = "android_gps_enabled";
|
||||||
|
// Map startup behavior
|
||||||
|
private static final String KEY_START_CENTER_ON_LAST = "start_center_on_last";
|
||||||
|
private static final String KEY_START_ZOOM_LEVEL = "start_zoom_level";
|
||||||
|
/** Как карта следует за ориентацией: {@link #MAP_ROTATION_COMPASS} / COURSE / MANUAL */
|
||||||
|
private static final String KEY_MAP_ROTATION_MODE = "map_rotation_mode";
|
||||||
|
// BLE/NMEA settings
|
||||||
|
private static final String KEY_BLE_ENABLED = "ble_enabled";
|
||||||
|
private static final String KEY_BLE_DEVICE_MAC = "ble_device_mac";
|
||||||
|
private static final String KEY_BLE_UDP_BRIDGE_ENABLED = "ble_udp_bridge_enabled";
|
||||||
|
private static final String KEY_BLE_UDP_BRIDGE_HOST = "ble_udp_bridge_host";
|
||||||
|
private static final String KEY_BLE_UDP_BRIDGE_PORT = "ble_udp_bridge_port";
|
||||||
|
|
||||||
// Значения по умолчанию
|
// Значения по умолчанию
|
||||||
private static final int DEFAULT_UDP_PORT = 10110;
|
private static final int DEFAULT_UDP_PORT = 10110;
|
||||||
@@ -58,11 +74,39 @@ public class SettingsManager {
|
|||||||
private static final boolean DEFAULT_NOTIFICATIONS_ENABLED = true;
|
private static final boolean DEFAULT_NOTIFICATIONS_ENABLED = true;
|
||||||
private static final boolean DEFAULT_ANDROID_GPS_ENABLED = true;
|
private static final boolean DEFAULT_ANDROID_GPS_ENABLED = true;
|
||||||
private static final boolean DEFAULT_DEBUG_ENABLED = false;
|
private static final boolean DEFAULT_DEBUG_ENABLED = false;
|
||||||
|
private static final boolean DEFAULT_SEAMARKS_ENABLED = false;
|
||||||
|
// Map startup defaults
|
||||||
|
private static final boolean DEFAULT_START_CENTER_ON_LAST = true;
|
||||||
|
private static final float DEFAULT_START_ZOOM_LEVEL = 14.0f;
|
||||||
|
// BLE defaults
|
||||||
|
private static final boolean DEFAULT_BLE_ENABLED = false;
|
||||||
|
private static final String DEFAULT_BLE_DEVICE_MAC = "";
|
||||||
|
private static final boolean DEFAULT_BLE_UDP_BRIDGE_ENABLED = false;
|
||||||
|
private static final String DEFAULT_BLE_UDP_BRIDGE_HOST = "255.255.255.255";
|
||||||
|
private static final int DEFAULT_BLE_UDP_BRIDGE_PORT = 10110;
|
||||||
|
|
||||||
// Режимы работы с данными
|
// Режимы работы с данными
|
||||||
public static final String DATA_MODE_HYBRID = "hybrid";
|
public static final String DATA_MODE_HYBRID = "hybrid";
|
||||||
public static final String DATA_MODE_NMEA_ONLY = "nmea_only";
|
public static final String DATA_MODE_NMEA_ONLY = "nmea_only";
|
||||||
public static final String DATA_MODE_ANDROID_ONLY = "android_only";
|
public static final String DATA_MODE_ANDROID_ONLY = "android_only";
|
||||||
|
|
||||||
|
// Источник координат собственного судна.
|
||||||
|
// - GPS_SOURCE_HUB: позиция берётся из ais_hub (BLE ownship.update).
|
||||||
|
// Android GPS/NMEA слушать не нужно. AIS-цели всегда идут через BLE.
|
||||||
|
// - GPS_SOURCE_ANDROID: позиция берётся из Android Location API
|
||||||
|
// (+ опциональный внешний NMEA по UDP). BLE может оставаться включённым
|
||||||
|
// ради AIS-целей, но его ownship.update игнорируется.
|
||||||
|
public static final String GPS_SOURCE_HUB = "ble_hub";
|
||||||
|
public static final String GPS_SOURCE_ANDROID = "android";
|
||||||
|
private static final String DEFAULT_GPS_SOURCE = GPS_SOURCE_HUB;
|
||||||
|
|
||||||
|
/** Север вверх; поворот двумя пальцами, авто-не вмешивается. */
|
||||||
|
public static final String MAP_ROTATION_MANUAL = "manual";
|
||||||
|
/** Как магнитный компас / азимут корпуса. */
|
||||||
|
public static final String MAP_ROTATION_COMPASS = "compass";
|
||||||
|
/** Как курс (COG / GPS bearing). */
|
||||||
|
public static final String MAP_ROTATION_COURSE = "course";
|
||||||
|
private static final String DEFAULT_MAP_ROTATION_MODE = MAP_ROTATION_MANUAL;
|
||||||
|
|
||||||
private Context context;
|
private Context context;
|
||||||
private SharedPreferences prefs;
|
private SharedPreferences prefs;
|
||||||
@@ -185,6 +229,38 @@ public class SettingsManager {
|
|||||||
return DATA_MODE_ANDROID_ONLY.equals(getDataMode());
|
return DATA_MODE_ANDROID_ONLY.equals(getDataMode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текущий источник координат собственного судна: {@link #GPS_SOURCE_HUB}
|
||||||
|
* или {@link #GPS_SOURCE_ANDROID}. По умолчанию — HUB.
|
||||||
|
*/
|
||||||
|
public String getGpsSource() {
|
||||||
|
String v = prefs.getString(KEY_GPS_SOURCE, DEFAULT_GPS_SOURCE);
|
||||||
|
if (!GPS_SOURCE_HUB.equals(v) && !GPS_SOURCE_ANDROID.equals(v)) {
|
||||||
|
return DEFAULT_GPS_SOURCE;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setGpsSource(String source) {
|
||||||
|
if (!GPS_SOURCE_HUB.equals(source) && !GPS_SOURCE_ANDROID.equals(source)) {
|
||||||
|
source = DEFAULT_GPS_SOURCE;
|
||||||
|
}
|
||||||
|
prefs.edit().putString(KEY_GPS_SOURCE, source).apply();
|
||||||
|
Log.i(TAG, "GPS source: " + source);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isGpsFromHub() { return GPS_SOURCE_HUB.equals(getGpsSource()); }
|
||||||
|
public boolean isGpsFromAndroid() { return GPS_SOURCE_ANDROID.equals(getGpsSource()); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключает источник координат и возвращает новое значение.
|
||||||
|
*/
|
||||||
|
public String toggleGpsSource() {
|
||||||
|
String next = isGpsFromHub() ? GPS_SOURCE_ANDROID : GPS_SOURCE_HUB;
|
||||||
|
setGpsSource(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверяет, включен ли Android GPS (Location API)
|
* Проверяет, включен ли Android GPS (Location API)
|
||||||
*/
|
*/
|
||||||
@@ -199,6 +275,55 @@ public class SettingsManager {
|
|||||||
prefs.edit().putBoolean(KEY_ANDROID_GPS_ENABLED, enabled).apply();
|
prefs.edit().putBoolean(KEY_ANDROID_GPS_ENABLED, enabled).apply();
|
||||||
Log.i(TAG, "Android GPS: " + (enabled ? "включен" : "выключен"));
|
Log.i(TAG, "Android GPS: " + (enabled ? "включен" : "выключен"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== BLE settings =====
|
||||||
|
public boolean isBLEEnabled() {
|
||||||
|
return prefs.getBoolean(KEY_BLE_ENABLED, DEFAULT_BLE_ENABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBLEEnabled(boolean enabled) {
|
||||||
|
prefs.edit().putBoolean(KEY_BLE_ENABLED, enabled).apply();
|
||||||
|
Log.i(TAG, "BLE: " + (enabled ? "включен" : "выключен"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBLEDeviceMac() {
|
||||||
|
return prefs.getString(KEY_BLE_DEVICE_MAC, DEFAULT_BLE_DEVICE_MAC);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBLEDeviceMac(String mac) {
|
||||||
|
if (mac == null) mac = "";
|
||||||
|
prefs.edit().putString(KEY_BLE_DEVICE_MAC, mac).apply();
|
||||||
|
Log.i(TAG, "BLE MAC сохранён: " + mac);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isBleUdpBridgeEnabled() {
|
||||||
|
return prefs.getBoolean(KEY_BLE_UDP_BRIDGE_ENABLED, DEFAULT_BLE_UDP_BRIDGE_ENABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBleUdpBridgeEnabled(boolean enabled) {
|
||||||
|
prefs.edit().putBoolean(KEY_BLE_UDP_BRIDGE_ENABLED, enabled).apply();
|
||||||
|
Log.i(TAG, "BLE UDP-bridge: " + (enabled ? "включен" : "выключен"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBleUdpBridgeHost() {
|
||||||
|
return prefs.getString(KEY_BLE_UDP_BRIDGE_HOST, DEFAULT_BLE_UDP_BRIDGE_HOST);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBleUdpBridgeHost(String host) {
|
||||||
|
if (host == null || host.trim().isEmpty()) host = DEFAULT_BLE_UDP_BRIDGE_HOST;
|
||||||
|
prefs.edit().putString(KEY_BLE_UDP_BRIDGE_HOST, host).apply();
|
||||||
|
Log.i(TAG, "BLE UDP-bridge host: " + host);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getBleUdpBridgePort() {
|
||||||
|
return prefs.getInt(KEY_BLE_UDP_BRIDGE_PORT, DEFAULT_BLE_UDP_BRIDGE_PORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBleUdpBridgePort(int port) {
|
||||||
|
if (port < 1 || port > 65535) port = DEFAULT_BLE_UDP_BRIDGE_PORT;
|
||||||
|
prefs.edit().putInt(KEY_BLE_UDP_BRIDGE_PORT, port).apply();
|
||||||
|
Log.i(TAG, "BLE UDP-bridge port: " + port);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сбрасывает все настройки к значениям по умолчанию
|
* Сбрасывает все настройки к значениям по умолчанию
|
||||||
@@ -215,6 +340,14 @@ public class SettingsManager {
|
|||||||
.putBoolean(KEY_VIBRATION_ENABLED, DEFAULT_VIBRATION_ENABLED)
|
.putBoolean(KEY_VIBRATION_ENABLED, DEFAULT_VIBRATION_ENABLED)
|
||||||
.putBoolean(KEY_SOUND_ENABLED, DEFAULT_SOUND_ENABLED)
|
.putBoolean(KEY_SOUND_ENABLED, DEFAULT_SOUND_ENABLED)
|
||||||
.putBoolean(KEY_KEEP_SCREEN_ON_ENABLED, DEFAULT_KEEP_SCREEN_ON_ENABLED)
|
.putBoolean(KEY_KEEP_SCREEN_ON_ENABLED, DEFAULT_KEEP_SCREEN_ON_ENABLED)
|
||||||
|
.putBoolean(KEY_START_CENTER_ON_LAST, DEFAULT_START_CENTER_ON_LAST)
|
||||||
|
.putFloat(KEY_START_ZOOM_LEVEL, DEFAULT_START_ZOOM_LEVEL)
|
||||||
|
.putString(KEY_MAP_ROTATION_MODE, DEFAULT_MAP_ROTATION_MODE)
|
||||||
|
.putBoolean(KEY_BLE_ENABLED, DEFAULT_BLE_ENABLED)
|
||||||
|
.putString(KEY_BLE_DEVICE_MAC, DEFAULT_BLE_DEVICE_MAC)
|
||||||
|
.putBoolean(KEY_BLE_UDP_BRIDGE_ENABLED, DEFAULT_BLE_UDP_BRIDGE_ENABLED)
|
||||||
|
.putString(KEY_BLE_UDP_BRIDGE_HOST, DEFAULT_BLE_UDP_BRIDGE_HOST)
|
||||||
|
.putInt(KEY_BLE_UDP_BRIDGE_PORT, DEFAULT_BLE_UDP_BRIDGE_PORT)
|
||||||
.apply();
|
.apply();
|
||||||
Log.i(TAG, "Настройки сброшены к значениям по умолчанию");
|
Log.i(TAG, "Настройки сброшены к значениям по умолчанию");
|
||||||
}
|
}
|
||||||
@@ -247,17 +380,80 @@ public class SettingsManager {
|
|||||||
"UDP: порт=%d, включен=%s\n" +
|
"UDP: порт=%d, включен=%s\n" +
|
||||||
"Android NMEA: %s\n" +
|
"Android NMEA: %s\n" +
|
||||||
"UDP NMEA: %s\n" +
|
"UDP NMEA: %s\n" +
|
||||||
|
"Старт центр по последней: %s, стартовый зум=%.1f\n" +
|
||||||
|
"BLE: %s, MAC=%s, Bridge=%s %s:%d\n" +
|
||||||
"Режим данных: %s\n" +
|
"Режим данных: %s\n" +
|
||||||
"Уведомления: вибрация=%s, звук=%s",
|
"Уведомления: вибрация=%s, звук=%s",
|
||||||
getUDPPort(),
|
getUDPPort(),
|
||||||
isUDPEnabled() ? "да" : "нет",
|
isUDPEnabled() ? "да" : "нет",
|
||||||
isAndroidNMEAEnabled() ? "включен" : "выключен",
|
isAndroidNMEAEnabled() ? "включен" : "выключен",
|
||||||
isUDPNMEAEnabled() ? "включен" : "выключен",
|
isUDPNMEAEnabled() ? "включен" : "выключен",
|
||||||
|
isStartCenterOnLastEnabled() ? "да" : "нет",
|
||||||
|
getStartZoomLevel(),
|
||||||
|
isBLEEnabled() ? "включен" : "выключен",
|
||||||
|
getBLEDeviceMac(),
|
||||||
|
isBleUdpBridgeEnabled() ? "вкл" : "выкл",
|
||||||
|
getBleUdpBridgeHost(),
|
||||||
|
getBleUdpBridgePort(),
|
||||||
getDataMode(),
|
getDataMode(),
|
||||||
isVibrationEnabled() ? "включена" : "выключена",
|
isVibrationEnabled() ? "включена" : "выключена",
|
||||||
isSoundEnabled() ? "включен" : "выключен"
|
isSoundEnabled() ? "включен" : "выключен"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Map startup behavior =====
|
||||||
|
public boolean isStartCenterOnLastEnabled() {
|
||||||
|
return prefs.getBoolean(KEY_START_CENTER_ON_LAST, DEFAULT_START_CENTER_ON_LAST);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStartCenterOnLastEnabled(boolean enabled) {
|
||||||
|
prefs.edit().putBoolean(KEY_START_CENTER_ON_LAST, enabled).apply();
|
||||||
|
Log.i(TAG, "Старт: центр по последней позиции: " + (enabled ? "включен" : "выключен"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public float getStartZoomLevel() {
|
||||||
|
return prefs.getFloat(KEY_START_ZOOM_LEVEL, DEFAULT_START_ZOOM_LEVEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStartZoomLevel(float zoom) {
|
||||||
|
if (zoom < 2.0f) zoom = 2.0f;
|
||||||
|
if (zoom > 20.0f) zoom = 20.0f;
|
||||||
|
prefs.edit().putFloat(KEY_START_ZOOM_LEVEL, zoom).apply();
|
||||||
|
Log.i(TAG, "Стартовый зум установлен: " + zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMapRotationMode() {
|
||||||
|
String m = prefs.getString(KEY_MAP_ROTATION_MODE, DEFAULT_MAP_ROTATION_MODE);
|
||||||
|
if (!MAP_ROTATION_MANUAL.equals(m) && !MAP_ROTATION_COMPASS.equals(m) && !MAP_ROTATION_COURSE.equals(m)) {
|
||||||
|
return DEFAULT_MAP_ROTATION_MODE;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMapRotationMode(String mode) {
|
||||||
|
if (!MAP_ROTATION_MANUAL.equals(mode) && !MAP_ROTATION_COMPASS.equals(mode) && !MAP_ROTATION_COURSE.equals(mode)) {
|
||||||
|
mode = DEFAULT_MAP_ROTATION_MODE;
|
||||||
|
}
|
||||||
|
prefs.edit().putString(KEY_MAP_ROTATION_MODE, mode).apply();
|
||||||
|
Log.i(TAG, "Режим вращения карты: " + mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Цикл: компас → курс → вручную → компас …
|
||||||
|
*/
|
||||||
|
public String cycleMapRotationMode() {
|
||||||
|
String current = getMapRotationMode();
|
||||||
|
String next;
|
||||||
|
if (MAP_ROTATION_COMPASS.equals(current)) {
|
||||||
|
next = MAP_ROTATION_COURSE;
|
||||||
|
} else if (MAP_ROTATION_COURSE.equals(current)) {
|
||||||
|
next = MAP_ROTATION_MANUAL;
|
||||||
|
} else {
|
||||||
|
next = MAP_ROTATION_COMPASS;
|
||||||
|
}
|
||||||
|
setMapRotationMode(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверяет, нужно ли перезапустить UDP слушатель
|
* Проверяет, нужно ли перезапустить UDP слушатель
|
||||||
@@ -486,4 +682,19 @@ public class SettingsManager {
|
|||||||
Log.i(TAG, "Дебаг-режим: " + (enabled ? "включен" : "выключен"));
|
Log.i(TAG, "Дебаг-режим: " + (enabled ? "включен" : "выключен"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, включены ли морские знаки OpenSeaMap
|
||||||
|
*/
|
||||||
|
public boolean isSeamarksEnabled() {
|
||||||
|
return prefs.getBoolean(KEY_SEAMARKS_ENABLED, DEFAULT_SEAMARKS_ENABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Включает/выключает морские знаки OpenSeaMap
|
||||||
|
*/
|
||||||
|
public void setSeamarksEnabled(boolean enabled) {
|
||||||
|
prefs.edit().putBoolean(KEY_SEAMARKS_ENABLED, enabled).apply();
|
||||||
|
Log.i(TAG, "Морские знаки OpenSeaMap: " + (enabled ? "включены" : "выключены"));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,28 +228,28 @@ public abstract class BaseDockWidget extends FrameLayout {
|
|||||||
private void handleDockResize(MotionEvent event) {
|
private void handleDockResize(MotionEvent event) {
|
||||||
float deltaY = event.getRawY() - lastTouchY;
|
float deltaY = event.getRawY() - lastTouchY;
|
||||||
lastTouchY = event.getRawY();
|
lastTouchY = event.getRawY();
|
||||||
|
|
||||||
ViewGroup.LayoutParams lp = getLayoutParams();
|
// Ресайзим именно контент (dockHeightPx). Паддинги от WindowInsets
|
||||||
int newHeight = lp.height;
|
// прибавляются поверх в onMeasure, поэтому «рабочая» часть не уезжает
|
||||||
|
// под системный бар даже при минимальном размере.
|
||||||
// Направление изменения размера зависит от позиции закрепления
|
int currentContent = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
|
||||||
|
int newHeight = currentContent;
|
||||||
|
|
||||||
if (dockTop) {
|
if (dockTop) {
|
||||||
// Если закреплен сверху, увеличиваем размер при движении вниз
|
|
||||||
newHeight += (int) deltaY;
|
newHeight += (int) deltaY;
|
||||||
} else {
|
} else {
|
||||||
// Если закреплен снизу, увеличиваем размер при движении вверх
|
|
||||||
newHeight -= (int) deltaY;
|
newHeight -= (int) deltaY;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ограничиваем минимальную и максимальную высоту
|
|
||||||
int minHeight = (int) dp(40);
|
int minHeight = (int) dp(40);
|
||||||
int maxHeight = ((ViewGroup) getParent()).getHeight() / 2;
|
int maxHeight = ((ViewGroup) getParent()).getHeight() / 2;
|
||||||
|
|
||||||
newHeight = Math.max(minHeight, Math.min(newHeight, maxHeight));
|
newHeight = Math.max(minHeight, Math.min(newHeight, maxHeight));
|
||||||
|
|
||||||
if (newHeight != lp.height) {
|
if (newHeight != currentContent) {
|
||||||
lp.height = newHeight;
|
|
||||||
dockHeightPx = newHeight;
|
dockHeightPx = newHeight;
|
||||||
|
ViewGroup.LayoutParams lp = getLayoutParams();
|
||||||
|
lp.height = newHeight + getPaddingTop() + getPaddingBottom();
|
||||||
setLayoutParams(lp);
|
setLayoutParams(lp);
|
||||||
|
|
||||||
// Корректируем позицию Y в зависимости от позиции закрепления
|
// Корректируем позицию Y в зависимости от позиции закрепления
|
||||||
@@ -324,7 +324,11 @@ public abstract class BaseDockWidget extends FrameLayout {
|
|||||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||||
if (isDocked) {
|
if (isDocked) {
|
||||||
int width = MeasureSpec.getSize(widthMeasureSpec);
|
int width = MeasureSpec.getSize(widthMeasureSpec);
|
||||||
int height = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
|
// dockHeightPx/DEFAULT — это высота полезного контента; к ней
|
||||||
|
// прибавляем padding от WindowInsets, чтобы виджет фактически
|
||||||
|
// расширялся под статус-бар или нав-бар и не прятал контент.
|
||||||
|
int content = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
|
||||||
|
int height = content + getPaddingTop() + getPaddingBottom();
|
||||||
setMeasuredDimension(width, height);
|
setMeasuredDimension(width, height);
|
||||||
} else {
|
} else {
|
||||||
int size = (int)(dp(CIRCLE_SIZE_DP) * scaleFactor);
|
int size = (int)(dp(CIRCLE_SIZE_DP) * scaleFactor);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.graphics.Color;
|
|||||||
import android.graphics.Paint;
|
import android.graphics.Paint;
|
||||||
import android.graphics.Path;
|
import android.graphics.Path;
|
||||||
import android.graphics.RectF;
|
import android.graphics.RectF;
|
||||||
|
import android.graphics.Typeface;
|
||||||
import android.util.AttributeSet;
|
import android.util.AttributeSet;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
@@ -22,17 +23,32 @@ import java.util.List;
|
|||||||
|
|
||||||
public class CompassView extends BaseDockWidget {
|
public class CompassView extends BaseDockWidget {
|
||||||
private static final String TAG = "CompassView";
|
private static final String TAG = "CompassView";
|
||||||
|
|
||||||
|
// Палитра синхронизирована с CoordinatesDockWidget — чтобы компас и
|
||||||
|
// координаты выглядели единым виджетом, а не двумя разными стилями.
|
||||||
|
private static final int BACKGROUND_COLOR = 0xD91A1F24;
|
||||||
|
private static final int TEXT_COLOR = 0xFFFFFFFF;
|
||||||
|
private static final int LABEL_COLOR = 0xFF9AA4B2;
|
||||||
|
private static final int ACCENT_COLOR = 0xFF4CAF50; // курс/heading
|
||||||
|
private static final int DIVIDER_COLOR = 0x33FFFFFF;
|
||||||
|
private static final int TICK_COLOR = 0xFFD0D4DA;
|
||||||
|
|
||||||
private float targetAzimuth = 0;
|
private float targetAzimuth = 0;
|
||||||
private float currentAzimuth = 0;
|
private float currentAzimuth = 0;
|
||||||
private float magneticCompass = 0; // магнитный компас
|
private float magneticCompass = 0; // магнитный компас
|
||||||
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint valuePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint accentPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint dividerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
private final Paint vesselPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
private final Paint vesselPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
private final Path vesselPath = new Path();
|
private final Path vesselPath = new Path();
|
||||||
private final String[] directions = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"};
|
private final String[] directions = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"};
|
||||||
private float centerX;
|
private float centerX;
|
||||||
private float centerY;
|
private float centerY;
|
||||||
private static final float SMOOTHING_FACTOR = 0.15f;
|
private static final float SMOOTHING_FACTOR = 0.15f;
|
||||||
|
private static final float AZIMUTH_DRAW_EPS = 0.5f;
|
||||||
private List<AISVessel> nearbyVessels = new ArrayList<>();
|
private List<AISVessel> nearbyVessels = new ArrayList<>();
|
||||||
private Vessel ourVessel; // наше судно для расчета расстояний
|
private Vessel ourVessel; // наше судно для расчета расстояний
|
||||||
private static final float MAX_DISPLAY_DISTANCE = 10000; // 10 км
|
private static final float MAX_DISPLAY_DISTANCE = 10000; // 10 км
|
||||||
@@ -50,15 +66,33 @@ public class CompassView extends BaseDockWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void init() {
|
private void init() {
|
||||||
paint.setColor(Color.WHITE);
|
paint.setColor(TICK_COLOR);
|
||||||
paint.setTextAlign(Paint.Align.CENTER);
|
paint.setTextAlign(Paint.Align.CENTER);
|
||||||
paint.setTextSize(36f);
|
paint.setTextSize(36f);
|
||||||
|
|
||||||
|
labelPaint.setColor(LABEL_COLOR);
|
||||||
|
labelPaint.setTextSize(dp(11));
|
||||||
|
labelPaint.setTypeface(Typeface.DEFAULT);
|
||||||
|
labelPaint.setLetterSpacing(0.08f);
|
||||||
|
|
||||||
|
valuePaint.setColor(TEXT_COLOR);
|
||||||
|
valuePaint.setTextSize(dp(16));
|
||||||
|
valuePaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
|
|
||||||
|
accentPaint.setColor(ACCENT_COLOR);
|
||||||
|
accentPaint.setTextSize(dp(16));
|
||||||
|
accentPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
|
|
||||||
|
bgPaint.setColor(BACKGROUND_COLOR);
|
||||||
|
bgPaint.setStyle(Paint.Style.FILL);
|
||||||
|
|
||||||
|
dividerPaint.setColor(DIVIDER_COLOR);
|
||||||
|
dividerPaint.setStrokeWidth(dp(1));
|
||||||
|
|
||||||
vesselPaint.setStyle(Paint.Style.FILL);
|
vesselPaint.setStyle(Paint.Style.FILL);
|
||||||
vesselPaint.setAntiAlias(true);
|
vesselPaint.setAntiAlias(true);
|
||||||
|
|
||||||
// Устанавливаем фон для видимости
|
setBackgroundColor(Color.TRANSPARENT);
|
||||||
setBackgroundColor(Color.argb(200, 0, 0, 0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -96,46 +130,81 @@ public class CompassView extends BaseDockWidget {
|
|||||||
// Прямая шкала (dock-режим)
|
// Прямая шкала (dock-режим)
|
||||||
@Override
|
@Override
|
||||||
protected void onDrawDock(Canvas canvas) {
|
protected void onDrawDock(Canvas canvas) {
|
||||||
// Log.d(TAG, "onDrawDock called, width=" + getWidth() + ", height=" + getHeight());
|
float totalW = getWidth();
|
||||||
|
float totalH = getHeight();
|
||||||
float w = getWidth();
|
if (totalW <= 0 || totalH <= 0) {
|
||||||
float h = getHeight();
|
Log.w(TAG, "Invalid dimensions: width=" + totalW + ", height=" + totalH);
|
||||||
|
|
||||||
if (w <= 0 || h <= 0) {
|
|
||||||
Log.w(TAG, "Invalid dimensions: width=" + w + ", height=" + h);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Простой фон для начала
|
// Учитываем паддинги (которые MainActivity назначает по системным
|
||||||
paint.setColor(Color.argb(200, 0, 0, 0));
|
// инсетам и вырезам камеры). Фон рисуем на всю область виджета,
|
||||||
canvas.drawRect(0, 0, w, h, paint);
|
// а весь контент — только внутри padding-box.
|
||||||
|
int pl = getPaddingLeft();
|
||||||
// Масштабируем размеры в зависимости от высоты виджета
|
int pt = getPaddingTop();
|
||||||
float baseHeight = dp(80); // базовая высота
|
int pr = getPaddingRight();
|
||||||
|
int pb = getPaddingBottom();
|
||||||
|
float left = pl;
|
||||||
|
float top = pt;
|
||||||
|
float right = totalW - pr;
|
||||||
|
float bottom = totalH - pb;
|
||||||
|
float w = Math.max(0f, right - left);
|
||||||
|
float h = Math.max(0f, bottom - top);
|
||||||
|
if (w <= 0 || h <= 0) return;
|
||||||
|
|
||||||
|
// Фон в палитре координатного виджета — рисуем на всю область,
|
||||||
|
// чтобы под статус-бар/бровь тоже уходил единый тон.
|
||||||
|
canvas.drawRect(0, 0, totalW, totalH, bgPaint);
|
||||||
|
|
||||||
|
// Масштабируем размеры в зависимости от высоты контентной области.
|
||||||
|
float baseHeight = dp(80);
|
||||||
float scaleFactor = Math.max(0.8f, Math.min(2.0f, h / baseHeight));
|
float scaleFactor = Math.max(0.8f, Math.min(2.0f, h / baseHeight));
|
||||||
|
|
||||||
// Простой текст для проверки (убрана надпись "КОМПАС")
|
// Шапка в стиле LABEL + значение (как POSITION/SOG/COG/ACC в
|
||||||
paint.setColor(Color.WHITE);
|
// координатах): слева HEADING (азимут), справа MAG (магн. компас).
|
||||||
|
float cx = left + w / 2f;
|
||||||
|
float padInner = dp(10);
|
||||||
|
float labelY = top + dp(12) * Math.max(1f, scaleFactor * 0.9f);
|
||||||
|
float valueY = labelY + dp(16) * Math.max(1f, scaleFactor * 0.9f);
|
||||||
|
|
||||||
|
labelPaint.setTextAlign(Paint.Align.LEFT);
|
||||||
|
valuePaint.setTextAlign(Paint.Align.LEFT);
|
||||||
|
accentPaint.setTextAlign(Paint.Align.LEFT);
|
||||||
|
|
||||||
|
canvas.drawText("HEADING", left + padInner, labelY, labelPaint);
|
||||||
|
canvas.drawText(((int) currentAzimuth) + "°",
|
||||||
|
left + padInner, valueY, accentPaint);
|
||||||
|
|
||||||
|
labelPaint.setTextAlign(Paint.Align.RIGHT);
|
||||||
|
valuePaint.setTextAlign(Paint.Align.RIGHT);
|
||||||
|
canvas.drawText("MAG", right - padInner, labelY, labelPaint);
|
||||||
|
canvas.drawText(((int) magneticCompass) + "°",
|
||||||
|
right - padInner, valueY, valuePaint);
|
||||||
|
|
||||||
|
// Разделитель под шапкой — такой же, как в координатах.
|
||||||
|
float dividerY = valueY + dp(6);
|
||||||
|
canvas.drawLine(left + padInner, dividerY, right - padInner, dividerY, dividerPaint);
|
||||||
|
|
||||||
|
// Цвет делений шкалы — светло-серый, чтобы не спорил с фоном палитры.
|
||||||
|
paint.setColor(TICK_COLOR);
|
||||||
paint.setTextSize(24 * scaleFactor);
|
paint.setTextSize(24 * scaleFactor);
|
||||||
paint.setTextAlign(Paint.Align.CENTER);
|
paint.setTextAlign(Paint.Align.CENTER);
|
||||||
float topTextY = dp(18) * scaleFactor;
|
|
||||||
canvas.drawText("Азимут: " + (int)currentAzimuth + "°", w/2, topTextY, paint);
|
|
||||||
canvas.drawText("Магн: " + (int)magneticCompass + "°", w/2, topTextY + 24 * scaleFactor, paint);
|
|
||||||
|
|
||||||
// Плавное обновление азимута
|
// Плавное обновление азимута
|
||||||
float diff = getShortestRotation(currentAzimuth, targetAzimuth);
|
float diff = getShortestRotation(currentAzimuth, targetAzimuth);
|
||||||
if (Math.abs(diff) > 0.1f) {
|
if (Math.abs(diff) > AZIMUTH_DRAW_EPS) {
|
||||||
// Ограничиваем максимальное изменение за один кадр
|
float maxChange = 3.0f;
|
||||||
float maxChange = 3.0f; // максимальное изменение в градусах за кадр
|
|
||||||
float change = Math.signum(diff) * Math.min(Math.abs(diff * SMOOTHING_FACTOR), maxChange);
|
float change = Math.signum(diff) * Math.min(Math.abs(diff * SMOOTHING_FACTOR), maxChange);
|
||||||
currentAzimuth += change;
|
currentAzimuth += change;
|
||||||
currentAzimuth = normalizeAngle(currentAzimuth);
|
currentAzimuth = normalizeAngle(currentAzimuth);
|
||||||
postInvalidateOnAnimation();
|
postInvalidateOnAnimation();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Рисуем простую шкалу
|
// Рисуем простую шкалу под шапкой. Центр смещён, чтобы шкала
|
||||||
float centerX = w / 2f;
|
// не наезжала на label-строку HEADING/MAG.
|
||||||
float centerY = h / 2f;
|
float centerX = left + w / 2f;
|
||||||
|
float scaleTop = dividerY + dp(4);
|
||||||
|
float centerY = scaleTop + (bottom - scaleTop) * 0.5f;
|
||||||
float visibleDegrees = 120;
|
float visibleDegrees = 120;
|
||||||
|
|
||||||
// Рисуем деления шкалы
|
// Рисуем деления шкалы
|
||||||
@@ -179,31 +248,31 @@ public class CompassView extends BaseDockWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Центральная линия (направление вперёд)
|
// Центральная линия (направление вперёд) — только в области шкалы,
|
||||||
|
// чтобы не пересекать шапку HEADING/MAG.
|
||||||
paint.setColor(Color.RED);
|
paint.setColor(Color.RED);
|
||||||
paint.setStrokeWidth(3 * scaleFactor);
|
paint.setStrokeWidth(3 * scaleFactor);
|
||||||
canvas.drawLine(centerX, centerY - h/2, centerX, centerY + h/2, paint);
|
canvas.drawLine(centerX, scaleTop, centerX, bottom, paint);
|
||||||
paint.setColor(Color.WHITE);
|
paint.setColor(TICK_COLOR);
|
||||||
paint.setStrokeWidth(1);
|
paint.setStrokeWidth(1);
|
||||||
|
|
||||||
// Выделяем зону resize в зависимости от позиции закрепления
|
// Зоны resize остаются привязанными к физическим краям виджета,
|
||||||
|
// а не к padding-box, иначе пользователь не попадёт пальцем.
|
||||||
if (isDocked) {
|
if (isDocked) {
|
||||||
Paint resizePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
Paint resizePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
resizePaint.setColor(Color.argb(120, 255, 255, 255));
|
resizePaint.setColor(Color.argb(120, 255, 255, 255));
|
||||||
resizePaint.setStyle(Paint.Style.STROKE);
|
resizePaint.setStyle(Paint.Style.STROKE);
|
||||||
resizePaint.setStrokeWidth(2);
|
resizePaint.setStrokeWidth(2);
|
||||||
|
|
||||||
paint.setTextSize(12);
|
paint.setTextSize(12);
|
||||||
paint.setColor(Color.WHITE);
|
paint.setColor(Color.WHITE);
|
||||||
|
|
||||||
if (isDockTop()) {
|
if (isDockTop()) {
|
||||||
// Если закреплен сверху, показываем зону resize снизу
|
canvas.drawRect(0, totalH - dp(24), totalW, totalH, resizePaint);
|
||||||
canvas.drawRect(0, h - dp(24), w, h, resizePaint);
|
canvas.drawText("↕", totalW / 2f, totalH - dp(12), paint);
|
||||||
canvas.drawText("↕", w/2, h - dp(12), paint);
|
|
||||||
} else {
|
} else {
|
||||||
// Если закреплен снизу, показываем зону resize сверху
|
canvas.drawRect(0, 0, totalW, dp(24), resizePaint);
|
||||||
canvas.drawRect(0, 0, w, dp(24), resizePaint);
|
canvas.drawText("↕", totalW / 2f, dp(12), paint);
|
||||||
canvas.drawText("↕", w/2, dp(12), paint);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,16 +298,15 @@ public class CompassView extends BaseDockWidget {
|
|||||||
float baseSize = dp(120); // базовая высота
|
float baseSize = dp(120); // базовая высота
|
||||||
float scaleFactor = Math.max(0.8f, Math.min(2.0f, Math.min(w, h) / baseSize));
|
float scaleFactor = Math.max(0.8f, Math.min(2.0f, Math.min(w, h) / baseSize));
|
||||||
|
|
||||||
// Фон
|
// Фон круглого компаса — та же палитра, что и у координатного
|
||||||
paint.setColor(Color.argb(200, 0, 0, 0));
|
// виджета в draggable-режиме. Используем bgPaint без argb(...).
|
||||||
canvas.drawCircle(cx, cy, radius, paint);
|
canvas.drawCircle(cx, cy, radius, bgPaint);
|
||||||
paint.setColor(Color.WHITE);
|
paint.setColor(TICK_COLOR);
|
||||||
|
|
||||||
// Плавное обновление азимута
|
// Плавное обновление азимута
|
||||||
float diff = getShortestRotation(currentAzimuth, targetAzimuth);
|
float diff = getShortestRotation(currentAzimuth, targetAzimuth);
|
||||||
if (Math.abs(diff) > 0.1f) {
|
if (Math.abs(diff) > AZIMUTH_DRAW_EPS) {
|
||||||
// Ограничиваем максимальное изменение за один кадр
|
float maxChange = 3.0f;
|
||||||
float maxChange = 3.0f; // максимальное изменение в градусах за кадр
|
|
||||||
float change = Math.signum(diff) * Math.min(Math.abs(diff * SMOOTHING_FACTOR), maxChange);
|
float change = Math.signum(diff) * Math.min(Math.abs(diff * SMOOTHING_FACTOR), maxChange);
|
||||||
currentAzimuth += change;
|
currentAzimuth += change;
|
||||||
currentAzimuth = normalizeAngle(currentAzimuth);
|
currentAzimuth = normalizeAngle(currentAzimuth);
|
||||||
@@ -282,13 +350,17 @@ public class CompassView extends BaseDockWidget {
|
|||||||
paint.setColor(Color.RED);
|
paint.setColor(Color.RED);
|
||||||
paint.setStrokeWidth(3 * scaleFactor);
|
paint.setStrokeWidth(3 * scaleFactor);
|
||||||
canvas.drawLine(cx, cy, cx, cy - radius, paint);
|
canvas.drawLine(cx, cy, cx, cy - radius, paint);
|
||||||
paint.setColor(Color.WHITE);
|
paint.setColor(TICK_COLOR);
|
||||||
paint.setStrokeWidth(1);
|
paint.setStrokeWidth(1);
|
||||||
|
|
||||||
// Текст азимута в центре
|
// Центральный текст: значение HEADING в акцентном цвете, ниже —
|
||||||
paint.setTextSize(14 * scaleFactor);
|
// мелкий LABEL, по аналогии с блоками в CoordinatesDockWidget.
|
||||||
paint.setTextAlign(Paint.Align.CENTER);
|
accentPaint.setTextAlign(Paint.Align.CENTER);
|
||||||
canvas.drawText((int)currentAzimuth + "°", cx, cy + 5 * scaleFactor, paint);
|
accentPaint.setTextSize(dp(18) * Math.max(0.7f, Math.min(1.4f, scaleFactor)));
|
||||||
|
canvas.drawText(((int) currentAzimuth) + "°", cx, cy + dp(2), accentPaint);
|
||||||
|
labelPaint.setTextAlign(Paint.Align.CENTER);
|
||||||
|
labelPaint.setTextSize(dp(9) * Math.max(0.7f, Math.min(1.4f, scaleFactor)));
|
||||||
|
canvas.drawText("HEADING", cx, cy + dp(14), labelPaint);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,24 +13,28 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
|||||||
private static final String TAG = "CoordinatesDockWidget";
|
private static final String TAG = "CoordinatesDockWidget";
|
||||||
|
|
||||||
// Цвета
|
// Цвета
|
||||||
private static final int BACKGROUND_COLOR = 0xE6000000; // Полупрозрачный черный
|
private static final int BACKGROUND_COLOR = 0xD91A1F24; // Полупрозрачный тёмный с лёгким синим
|
||||||
private static final int TEXT_COLOR = 0xFFFFFFFF; // Белый
|
private static final int TEXT_COLOR = 0xFFFFFFFF; // Белый
|
||||||
|
private static final int LABEL_COLOR = 0xFF9AA4B2; // Серо-голубой
|
||||||
private static final int ACCENT_COLOR = 0xFF4CAF50; // Зеленый
|
private static final int ACCENT_COLOR = 0xFF4CAF50; // Зеленый
|
||||||
private static final int WARNING_COLOR = 0xFFFF9800; // Оранжевый
|
private static final int WARNING_COLOR = 0xFFFF9800; // Оранжевый
|
||||||
private static final int ERROR_COLOR = 0xFFF44336; // Красный
|
private static final int ERROR_COLOR = 0xFFF44336; // Красный
|
||||||
|
|
||||||
// Кисти
|
// Кисти
|
||||||
private Paint backgroundPaint;
|
private Paint backgroundPaint;
|
||||||
|
private Paint labelPaint;
|
||||||
private Paint textPaint;
|
private Paint textPaint;
|
||||||
private Paint accentPaint;
|
private Paint accentPaint;
|
||||||
private Paint warningPaint;
|
private Paint warningPaint;
|
||||||
private Paint errorPaint;
|
private Paint errorPaint;
|
||||||
|
private Paint dividerPaint;
|
||||||
|
|
||||||
// Данные для отображения
|
// Данные для отображения
|
||||||
private Vessel vessel;
|
private Vessel vessel;
|
||||||
private String coordinatesText = "Координаты: --";
|
private String coordinatesText = "--";
|
||||||
private String sogText = "SOG: --";
|
private String sogText = "--";
|
||||||
private String cogText = "COG: --";
|
private String cogText = "--";
|
||||||
|
private String accuracyText = "--";
|
||||||
|
|
||||||
public CoordinatesDockWidget(Context context) {
|
public CoordinatesDockWidget(Context context) {
|
||||||
super(context);
|
super(context);
|
||||||
@@ -43,38 +47,51 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void init() {
|
private void init() {
|
||||||
// Инициализируем кисти
|
|
||||||
backgroundPaint = new Paint();
|
backgroundPaint = new Paint();
|
||||||
backgroundPaint.setColor(BACKGROUND_COLOR);
|
backgroundPaint.setColor(BACKGROUND_COLOR);
|
||||||
backgroundPaint.setStyle(Paint.Style.FILL);
|
backgroundPaint.setStyle(Paint.Style.FILL);
|
||||||
backgroundPaint.setAntiAlias(true);
|
backgroundPaint.setAntiAlias(true);
|
||||||
|
|
||||||
|
labelPaint = new Paint();
|
||||||
|
labelPaint.setColor(LABEL_COLOR);
|
||||||
|
labelPaint.setTextSize(dp(11));
|
||||||
|
labelPaint.setTypeface(Typeface.DEFAULT);
|
||||||
|
labelPaint.setAntiAlias(true);
|
||||||
|
labelPaint.setLetterSpacing(0.08f);
|
||||||
|
|
||||||
textPaint = new Paint();
|
textPaint = new Paint();
|
||||||
textPaint.setColor(TEXT_COLOR);
|
textPaint.setColor(TEXT_COLOR);
|
||||||
textPaint.setTextSize(dp(14));
|
textPaint.setTextSize(dp(16));
|
||||||
textPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
textPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
textPaint.setAntiAlias(true);
|
textPaint.setAntiAlias(true);
|
||||||
|
|
||||||
accentPaint = new Paint();
|
accentPaint = new Paint();
|
||||||
accentPaint.setColor(ACCENT_COLOR);
|
accentPaint.setColor(ACCENT_COLOR);
|
||||||
accentPaint.setTextSize(dp(14));
|
accentPaint.setTextSize(dp(16));
|
||||||
accentPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
accentPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
accentPaint.setAntiAlias(true);
|
accentPaint.setAntiAlias(true);
|
||||||
|
|
||||||
warningPaint = new Paint();
|
warningPaint = new Paint();
|
||||||
warningPaint.setColor(WARNING_COLOR);
|
warningPaint.setColor(WARNING_COLOR);
|
||||||
warningPaint.setTextSize(dp(14));
|
warningPaint.setTextSize(dp(16));
|
||||||
warningPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
warningPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
warningPaint.setAntiAlias(true);
|
warningPaint.setAntiAlias(true);
|
||||||
|
|
||||||
errorPaint = new Paint();
|
errorPaint = new Paint();
|
||||||
errorPaint.setColor(ERROR_COLOR);
|
errorPaint.setColor(ERROR_COLOR);
|
||||||
errorPaint.setTextSize(dp(14));
|
errorPaint.setTextSize(dp(16));
|
||||||
errorPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
errorPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
errorPaint.setAntiAlias(true);
|
errorPaint.setAntiAlias(true);
|
||||||
|
|
||||||
// Устанавливаем фон для видимости (как в CompassView)
|
dividerPaint = new Paint();
|
||||||
setBackgroundColor(android.graphics.Color.argb(200, 0, 0, 0));
|
dividerPaint.setColor(0x33FFFFFF);
|
||||||
|
dividerPaint.setStrokeWidth(dp(1));
|
||||||
|
dividerPaint.setAntiAlias(true);
|
||||||
|
|
||||||
|
// Фон самой view держим прозрачным — в dock/circle режиме фон
|
||||||
|
// рисуем вручную в onDraw*, иначе в round-режиме виден чёрный
|
||||||
|
// квадрат вокруг окружности.
|
||||||
|
setBackgroundColor(android.graphics.Color.TRANSPARENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,125 +115,206 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
|||||||
*/
|
*/
|
||||||
private void updateDisplayText() {
|
private void updateDisplayText() {
|
||||||
if (vessel == null) {
|
if (vessel == null) {
|
||||||
coordinatesText = "Координаты: --";
|
coordinatesText = "--";
|
||||||
sogText = "SOG: --";
|
sogText = "--";
|
||||||
cogText = "COG: --";
|
cogText = "--";
|
||||||
|
accuracyText = "--";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Координаты
|
if (vessel.getLatitude() != 0 || vessel.getLongitude() != 0) {
|
||||||
if (vessel.getLatitude() != 0 && vessel.getLongitude() != 0) {
|
coordinatesText = formatLatLon(vessel.getLatitude(), vessel.getLongitude());
|
||||||
coordinatesText = String.format("📍 %.6f, %.6f",
|
|
||||||
vessel.getLatitude(), vessel.getLongitude());
|
|
||||||
} else {
|
} else {
|
||||||
coordinatesText = "📍 Координаты: --";
|
coordinatesText = "нет фикса";
|
||||||
}
|
}
|
||||||
|
|
||||||
// SOG (Speed Over Ground)
|
if (vessel.getSpeed() > 0.05) {
|
||||||
if (vessel.getSpeed() > 0) {
|
sogText = String.format(java.util.Locale.US, "%.1f kn", vessel.getSpeed());
|
||||||
sogText = String.format("⚡ SOG: %.1f уз", vessel.getSpeed());
|
|
||||||
} else {
|
} else {
|
||||||
sogText = "⚡ SOG: --";
|
sogText = "0.0 kn";
|
||||||
}
|
}
|
||||||
|
|
||||||
// COG (Course Over Ground)
|
if (vessel.getCourse() > 0 || vessel.getSpeed() > 0.05) {
|
||||||
if (vessel.getCourse() > 0) {
|
cogText = String.format(java.util.Locale.US, "%.0f\u00B0", vessel.getCourse());
|
||||||
cogText = String.format("🧭 COG: %.1f°", vessel.getCourse());
|
|
||||||
} else {
|
} else {
|
||||||
cogText = "🧭 COG: --";
|
cogText = "---\u00B0";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
float acc = vessel.getAccuracy();
|
||||||
|
if (acc > 0f) {
|
||||||
|
accuracyText = String.format(java.util.Locale.US, "\u00B1%.1f m", acc);
|
||||||
|
} else {
|
||||||
|
accuracyText = "--";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatLatLon(double lat, double lon) {
|
||||||
|
char latHemi = lat >= 0 ? 'N' : 'S';
|
||||||
|
char lonHemi = lon >= 0 ? 'E' : 'W';
|
||||||
|
double absLat = Math.abs(lat);
|
||||||
|
double absLon = Math.abs(lon);
|
||||||
|
int latDeg = (int) absLat;
|
||||||
|
double latMin = (absLat - latDeg) * 60.0;
|
||||||
|
int lonDeg = (int) absLon;
|
||||||
|
double lonMin = (absLon - lonDeg) * 60.0;
|
||||||
|
return String.format(java.util.Locale.US,
|
||||||
|
"%02d\u00B0%06.3f'%c %03d\u00B0%06.3f'%c",
|
||||||
|
latDeg, latMin, latHemi, lonDeg, lonMin, lonHemi);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onDrawDock(Canvas canvas) {
|
protected void onDrawDock(Canvas canvas) {
|
||||||
int width = getWidth();
|
int width = getWidth();
|
||||||
int height = getHeight();
|
int height = getHeight();
|
||||||
|
|
||||||
Log.d(TAG, "onDrawDock called, width=" + width + ", height=" + height);
|
|
||||||
|
|
||||||
if (width <= 0 || height <= 0) {
|
if (width <= 0 || height <= 0) {
|
||||||
Log.w(TAG, "Invalid dimensions: width=" + width + ", height=" + height);
|
Log.w(TAG, "Invalid dimensions: width=" + width + ", height=" + height);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Рисуем фон
|
// Фон рисуем на всю область виджета (уезжает под нав-бар/вырез),
|
||||||
|
// а контент — в рамках паддингов от WindowInsets.
|
||||||
canvas.drawRect(0, 0, width, height, backgroundPaint);
|
canvas.drawRect(0, 0, width, height, backgroundPaint);
|
||||||
|
|
||||||
// Вычисляем позиции для текста
|
float left = getPaddingLeft();
|
||||||
float textSize = dp(14);
|
float top = getPaddingTop();
|
||||||
float lineHeight = textSize * 1.2f;
|
float right = width - getPaddingRight();
|
||||||
float startY = (height - (lineHeight * 3)) / 2 + textSize;
|
float bottom = height - getPaddingBottom();
|
||||||
|
float contentW = Math.max(0f, right - left);
|
||||||
// Определяем цвета в зависимости от качества данных
|
float contentH = Math.max(0f, bottom - top);
|
||||||
Paint coordinatesPaint = getCoordinatesPaint();
|
if (contentW <= 0 || contentH <= 0) return;
|
||||||
Paint sogPaint = getSOGPaint();
|
|
||||||
Paint cogPaint = getCOGPaint();
|
// Верхняя тонкая разделительная линия (виджет снизу): визуальная
|
||||||
|
// граница между картой и панелью.
|
||||||
// Рисуем тестовый заголовок для проверки видимости
|
|
||||||
Paint testPaint = new Paint();
|
|
||||||
testPaint.setColor(android.graphics.Color.WHITE);
|
|
||||||
testPaint.setTextSize(dp(16));
|
|
||||||
testPaint.setTypeface(android.graphics.Typeface.DEFAULT_BOLD);
|
|
||||||
testPaint.setAntiAlias(true);
|
|
||||||
// canvas.drawText("КООРДИНАТЫ", dp(16), dp(20), testPaint);
|
|
||||||
|
|
||||||
// Рисуем текст
|
|
||||||
canvas.drawText(coordinatesText, dp(16), startY, coordinatesPaint);
|
|
||||||
canvas.drawText(sogText, dp(16), startY + lineHeight, sogPaint);
|
|
||||||
canvas.drawText(cogText, dp(16), startY + lineHeight * 2, cogPaint);
|
|
||||||
|
|
||||||
// Рисуем разделительную линию сверху, если закреплен снизу
|
|
||||||
if (!isDockTop()) {
|
if (!isDockTop()) {
|
||||||
Paint linePaint = new Paint();
|
canvas.drawLine(left, top, right, top, dividerPaint);
|
||||||
linePaint.setColor(ACCENT_COLOR);
|
}
|
||||||
linePaint.setStrokeWidth(dp(2));
|
|
||||||
canvas.drawLine(0, 0, width, 0, linePaint);
|
float padX = dp(16);
|
||||||
|
float innerLeft = left + padX;
|
||||||
|
float innerRight = right - padX;
|
||||||
|
float innerTop = top + dp(8);
|
||||||
|
float innerBottom = bottom - dp(8);
|
||||||
|
|
||||||
|
// Строка 1: координаты (с подписью "POSITION").
|
||||||
|
Paint posPaint = getCoordinatesPaint();
|
||||||
|
float labelH = labelPaint.getTextSize() * 1.1f;
|
||||||
|
float valueH = posPaint.getTextSize() * 1.15f;
|
||||||
|
|
||||||
|
float y = innerTop + labelH;
|
||||||
|
canvas.drawText("POSITION", innerLeft, y, labelPaint);
|
||||||
|
y += valueH;
|
||||||
|
canvas.drawText(coordinatesText, innerLeft, y, posPaint);
|
||||||
|
|
||||||
|
// Строка 2: SOG | COG | ACC в три колонки.
|
||||||
|
float colTop = y + dp(10);
|
||||||
|
float colW = (innerRight - innerLeft) / 3f;
|
||||||
|
float colLabelY = colTop + labelH;
|
||||||
|
float colValueY = colLabelY + valueH;
|
||||||
|
|
||||||
|
// SOG
|
||||||
|
canvas.drawText("SOG", innerLeft, colLabelY, labelPaint);
|
||||||
|
canvas.drawText(sogText, innerLeft, colValueY, getSOGPaint());
|
||||||
|
|
||||||
|
// COG
|
||||||
|
float cogX = innerLeft + colW;
|
||||||
|
canvas.drawText("COG", cogX, colLabelY, labelPaint);
|
||||||
|
canvas.drawText(cogText, cogX, colValueY, getCOGPaint());
|
||||||
|
|
||||||
|
// ACC
|
||||||
|
float accX = innerLeft + colW * 2f;
|
||||||
|
canvas.drawText("ACC", accX, colLabelY, labelPaint);
|
||||||
|
canvas.drawText(accuracyText, accX, colValueY, getAccuracyPaint());
|
||||||
|
|
||||||
|
if (colValueY > innerBottom) {
|
||||||
|
// На всякий случай: если текст не помещается, оставляем только
|
||||||
|
// первую строку (координаты).
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onDrawCircle(Canvas canvas) {
|
protected void onDrawCircle(Canvas canvas) {
|
||||||
int width = getWidth();
|
int width = getWidth();
|
||||||
int height = getHeight();
|
int height = getHeight();
|
||||||
int centerX = width / 2;
|
int centerX = width / 2;
|
||||||
int centerY = height / 2;
|
int centerY = height / 2;
|
||||||
int radius = Math.min(width, height) / 2 - (int)dp(8);
|
int radius = Math.min(width, height) / 2 - (int) dp(4);
|
||||||
|
|
||||||
// Рисуем фон
|
|
||||||
canvas.drawCircle(centerX, centerY, radius, backgroundPaint);
|
canvas.drawCircle(centerX, centerY, radius, backgroundPaint);
|
||||||
|
|
||||||
// Рисуем рамку
|
|
||||||
Paint borderPaint = new Paint();
|
Paint borderPaint = new Paint();
|
||||||
borderPaint.setColor(ACCENT_COLOR);
|
borderPaint.setColor(ACCENT_COLOR);
|
||||||
borderPaint.setStyle(Paint.Style.STROKE);
|
borderPaint.setStyle(Paint.Style.STROKE);
|
||||||
borderPaint.setStrokeWidth(dp(3));
|
borderPaint.setStrokeWidth(dp(2));
|
||||||
borderPaint.setAntiAlias(true);
|
borderPaint.setAntiAlias(true);
|
||||||
canvas.drawCircle(centerX, centerY, radius, borderPaint);
|
canvas.drawCircle(centerX, centerY, radius, borderPaint);
|
||||||
|
|
||||||
// Вычисляем позиции для текста в круге
|
// Более компактная вёрстка: 4 строки (POS lat / POS lon / SOG·COG / ACC)
|
||||||
float textSize = dp(12);
|
Paint posPaint = getCoordinatesPaint();
|
||||||
float lineHeight = textSize * 1.3f;
|
float smallLabel = dp(9);
|
||||||
float startY = centerY - lineHeight;
|
float smallValue = dp(11);
|
||||||
|
float bigValue = dp(13);
|
||||||
// Определяем цвета
|
labelPaint.setTextSize(smallLabel);
|
||||||
Paint coordinatesPaint = getCoordinatesPaint();
|
posPaint.setTextSize(smallValue);
|
||||||
Paint sogPaint = getSOGPaint();
|
Paint sogPaint = getSOGPaint();
|
||||||
Paint cogPaint = getCOGPaint();
|
Paint cogPaint = getCOGPaint();
|
||||||
|
Paint accPaint = getAccuracyPaint();
|
||||||
// Центрируем текст
|
sogPaint.setTextSize(bigValue);
|
||||||
Rect textBounds = new Rect();
|
cogPaint.setTextSize(bigValue);
|
||||||
|
accPaint.setTextSize(smallValue);
|
||||||
// Координаты
|
|
||||||
coordinatesPaint.getTextBounds(coordinatesText, 0, coordinatesText.length(), textBounds);
|
String[] latLon = coordinatesText.split(" ", 2);
|
||||||
canvas.drawText(coordinatesText, centerX - textBounds.width() / 2f, startY, coordinatesPaint);
|
String latLine = latLon.length > 0 ? latLon[0] : coordinatesText;
|
||||||
|
String lonLine = latLon.length > 1 ? latLon[1] : "";
|
||||||
// SOG
|
|
||||||
sogPaint.getTextBounds(sogText, 0, sogText.length(), textBounds);
|
float lineGap = dp(2);
|
||||||
canvas.drawText(sogText, centerX - textBounds.width() / 2f, startY + lineHeight, sogPaint);
|
float lineH = smallValue + lineGap;
|
||||||
|
|
||||||
// COG
|
// Считаем общую высоту блока для вертикального центрирования.
|
||||||
cogPaint.getTextBounds(cogText, 0, cogText.length(), textBounds);
|
float totalH = smallLabel + lineH + lineH // POSITION + 2 строки
|
||||||
canvas.drawText(cogText, centerX - textBounds.width() / 2f, startY + lineHeight * 2, cogPaint);
|
+ dp(6)
|
||||||
|
+ smallLabel + bigValue + lineGap // SOG/COG label+value
|
||||||
|
+ dp(4)
|
||||||
|
+ smallLabel + smallValue; // ACC
|
||||||
|
|
||||||
|
float y = centerY - totalH / 2f + smallLabel;
|
||||||
|
|
||||||
|
drawCentered(canvas, "POSITION", centerX, y, labelPaint);
|
||||||
|
y += lineH;
|
||||||
|
drawCentered(canvas, latLine, centerX, y, posPaint);
|
||||||
|
y += lineH;
|
||||||
|
if (!lonLine.isEmpty()) {
|
||||||
|
drawCentered(canvas, lonLine, centerX, y, posPaint);
|
||||||
|
y += lineH;
|
||||||
|
}
|
||||||
|
y += dp(4);
|
||||||
|
|
||||||
|
// SOG / COG бок о бок.
|
||||||
|
float colCenterL = centerX - radius * 0.45f;
|
||||||
|
float colCenterR = centerX + radius * 0.45f;
|
||||||
|
drawCentered(canvas, "SOG", colCenterL, y, labelPaint);
|
||||||
|
drawCentered(canvas, "COG", colCenterR, y, labelPaint);
|
||||||
|
y += bigValue + lineGap;
|
||||||
|
drawCentered(canvas, sogText, colCenterL, y, sogPaint);
|
||||||
|
drawCentered(canvas, cogText, colCenterR, y, cogPaint);
|
||||||
|
y += dp(6);
|
||||||
|
|
||||||
|
drawCentered(canvas, "ACC", centerX, y, labelPaint);
|
||||||
|
y += smallValue + lineGap;
|
||||||
|
drawCentered(canvas, accuracyText, centerX, y, accPaint);
|
||||||
|
|
||||||
|
// Восстанавливаем типовые размеры для dock-режима.
|
||||||
|
labelPaint.setTextSize(dp(11));
|
||||||
|
textPaint.setTextSize(dp(16));
|
||||||
|
accentPaint.setTextSize(dp(16));
|
||||||
|
warningPaint.setTextSize(dp(16));
|
||||||
|
errorPaint.setTextSize(dp(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawCentered(Canvas canvas, String text, float cx, float y, Paint p) {
|
||||||
|
if (text == null) return;
|
||||||
|
Rect b = new Rect();
|
||||||
|
p.getTextBounds(text, 0, text.length(), b);
|
||||||
|
canvas.drawText(text, cx - b.width() / 2f - b.left, y, p);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -256,11 +354,25 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
|||||||
* Определяет цвет для отображения COG
|
* Определяет цвет для отображения COG
|
||||||
*/
|
*/
|
||||||
private Paint getCOGPaint() {
|
private Paint getCOGPaint() {
|
||||||
if (vessel == null || vessel.getCourse() <= 0) {
|
if (vessel == null) return errorPaint;
|
||||||
return errorPaint;
|
// Курс может быть 0 при движении чётко на север — поэтому считаем
|
||||||
|
// валидным любой курс при наличии скорости, а также любой курс > 0.
|
||||||
|
if (vessel.getCourse() > 0 || vessel.getSpeed() > 0.05) {
|
||||||
|
return textPaint;
|
||||||
}
|
}
|
||||||
|
return errorPaint;
|
||||||
return accentPaint; // Если есть данные о курсе - зеленый
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Определяет цвет для отображения точности (ACC, ±метры).
|
||||||
|
*/
|
||||||
|
private Paint getAccuracyPaint() {
|
||||||
|
if (vessel == null) return errorPaint;
|
||||||
|
float acc = vessel.getAccuracy();
|
||||||
|
if (acc <= 0f) return errorPaint;
|
||||||
|
if (acc <= 5f) return accentPaint;
|
||||||
|
if (acc <= 20f) return warningPaint;
|
||||||
|
return errorPaint;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<path
|
<path
|
||||||
android:pathData="M95,8.1l-12.08,-6.7 -35.92,80.7L15,115.1s11.69,-0.03 28,0c0,0 1,-10 11,-10 9,0 10,10 10,10 9.54,0.02 15.06,0 15,0l-5,-29L95,8.1Z"
|
android:pathData="M95,8.1l-12.08,-6.7 -35.92,80.7L15,115.1s11.69,-0.03 28,0c0,0 1,-10 11,-10 9,0 10,10 10,10 9.54,0.02 15.06,0 15,0l-5,-29L95,8.1Z"
|
||||||
android:strokeWidth="2"
|
android:strokeWidth="2"
|
||||||
android:fillColor="#00ff00"
|
android:fillColor="#7BE435"
|
||||||
android:strokeColor="#000"/>
|
android:strokeColor="#000"/>
|
||||||
<path
|
<path
|
||||||
android:pathData="M53.5,115.6m-10.5,0a10.5,10.5 0,1 1,21 0a10.5,10.5 0,1 1,-21 0"
|
android:pathData="M53.5,115.6m-10.5,0a10.5,10.5 0,1 1,21 0a10.5,10.5 0,1 1,-21 0"
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<!-- Satellite/positioning glyph: source from Android GPS -->
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,2 C6.48,2 2,6.48 2,12 C2,17.52 6.48,22 12,22 C17.52,22 22,17.52 22,12 C22,6.48 17.52,2 12,2 Z M12,20 C7.58,20 4,16.42 4,12 C4,7.58 7.58,4 12,4 C16.42,4 20,7.58 20,12 C20,16.42 16.42,20 12,20 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,7 L13.41,8.41 L9.41,12.41 L11,14 L15,10 L16.41,11.41 L12,15.83 L7.59,11.41 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M11,1 L13,1 L13,3 L11,3 Z M11,21 L13,21 L13,23 L11,23 Z M1,11 L3,11 L3,13 L1,13 Z M21,11 L23,11 L23,13 L21,13 Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<!-- Bluetooth glyph: source from ais_hub via BLE -->
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,2 L12,22 L18,16 L8,8 M12,2 L18,8 L8,16 L12,22" />
|
||||||
|
</vector>
|
||||||
@@ -1,30 +1,117 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="1024"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="1024">
|
||||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
<group android:scaleX="0.72527474"
|
||||||
<aapt:attr name="android:fillColor">
|
android:scaleY="0.72527474"
|
||||||
<gradient
|
android:translateX="130.34433"
|
||||||
android:endX="85.84757"
|
android:translateY="140.65935">
|
||||||
android:endY="92.4963"
|
<path
|
||||||
android:startX="42.9492"
|
android:pathData="M99.2,86.8h850.4v850.4h-850.4z"
|
||||||
android:startY="49.59793"
|
android:fillColor="#bcd542"/>
|
||||||
android:type="linear">
|
<path
|
||||||
<item
|
android:pathData="M750.9,341.8l-6.2,12.3l7.3,1.4l-1.1,-13.8z"
|
||||||
android:color="#44000000"
|
android:strokeWidth="2"
|
||||||
android:offset="0.0" />
|
android:fillColor="#00000000"
|
||||||
<item
|
android:strokeColor="#fff"/>
|
||||||
android:color="#00000000"
|
<path
|
||||||
android:offset="1.0" />
|
android:pathData="M755.8,302.7c-2.2,8.2 -70.6,228.5 -72.1,233 -4.9,-1.2 -9.9,-2.5 -14.8,-3.7 -0.9,-0.2 -1.7,-0.4 -2.6,-0.7 -1.6,-0.4 -3.2,-0.8 -4.8,-1.2 -5.6,-1.5 -10.7,-1.8 -16.5,-1.8h-2.8c-12,0 -21.8,4.8 -32.1,10.5 -14.5,8 -29.4,12.9 -45.9,8.4 -11.9,-4.1 -18.7,-11.3 -25.3,-21.8 -1.9,-2.9 -3.9,-5.8 -5.9,-8.7 -0.7,-1 -1.4,-2 -2.1,-3.1 -14.7,-20.4 -36.9,-25.8 -59.8,-32.3 -8.4,-2.4 -23.6,-11.6 -24.2,-12 -13.3,-10.1 -19.4,-28.1 -25,-43.1 -10.8,-28.9 -27.3,-50.3 -55.1,-64.6 2.3,-2.5 30.3,-18.4 37.9,-25 0.7,-0.6 1.3,-1.1 2,-1.7 10.6,-9.6 16.7,-24.2 23.1,-36.7 9.5,-18.5 21.6,-34.2 41.9,-41.6 6.1,-1.7 12.2,-2.7 18.4,-3.7 19,-2.9 34.8,-9.5 48.6,-23.3 0.6,-0.6 1.1,-1.1 1.7,-1.7 5.7,-6.1 9.7,-13.8 13.3,-21.3 8.6,6.7 12.7,14.1 16.9,23.9 5.9,13.7 14.6,23.7 28.1,30.1 4.2,1.5 153.8,41.2 157.1,42.1Z"
|
||||||
</gradient>
|
android:fillColor="#d5eb5a"/>
|
||||||
</aapt:attr>
|
<path
|
||||||
</path>
|
android:pathData="M648.7,255.6v2c-7.7,-1.4 -15.3,-3.2 -22.9,-5.2 -1.3,-0.3 -2.6,-0.7 -3.9,-1 -15.3,-3.8 -28,-8.8 -36.5,-22.6 -1.8,-3.3 -3.2,-6.7 -4.7,-10.2 -2.7,-6.7 -6.3,-11.5 -11.1,-16.9 -1.3,-1.6 -2.6,-3.2 -3.9,-4.8 -0.6,-0.7 -1.1,-1.4 -1.7,-2.1 -4,-5.8 -4.2,-11.3 -3.4,-18.1 1.2,-5.6 2.8,-11.1 4.3,-16.6 1,-3.7 1.9,-7.4 2.7,-11.2 1.2,-5.3 2.5,-10.5 3.9,-15.7 1.8,-6.5 3.3,-13.1 4.7,-19.7 0.8,-3.7 1.6,-6.3 2.3,-7.8h0.3c28.3,0 56.6,0 84.9,-0.1h115.4c38.6,-0.1 71.8,9.9 99.7,37.2 0.7,0.6 1.3,1.2 2,1.9 5,4.7 9.8,10.1 13,16.1 -0.7,5.5 -3,10.6 -5.1,15.8 -0.4,1 -0.8,2 -1.2,3.1 -3.4,8.6 -7.3,16.9 -11.7,25.1 -0.5,0.9 -1,1.9 -1.5,2.8 -3.7,6.5 -8.3,11.8 -13.5,17.2 -0.6,0.7 -1.2,1.3 -1.9,1.9 -9.4,9 -20.3,12.4 -32.7,14.7 -24.2,4.4 -43.4,16.2 -57.5,36.4 -2.1,3.3 -4.1,6.6 -6,10 -4.3,-0.4 -8.1,-0.9 -12.1,-2.4 -5.2,-1.8 -10.6,-3.1 -16,-4.3 -1,-0.2 -1.9,-0.4 -2.9,-0.7 -2.3,-0.5 -4.7,-1.1 -7,-1.6"
|
||||||
|
android:fillColor="#d5eb5a"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M770.8,306.7c9.8,2.4 96.5,26 100.1,27.1 10,2.8 41.7,10.9 45.2,11.8 0.2,22.8 0,159.9 0,161.6 0.1,8.5 -0.3,14.2 -2,17 -1.7,1.8 -3.4,3.1 -5.5,4.5 -0.9,0.7 -1.7,1.5 -2.6,2.3 -2.8,2.3 -5.6,4.5 -8.4,6.7 -0.6,0.5 -1.1,0.9 -1.7,1.4 -13.8,11.1 -24.9,16.3 -33.3,15.6 -12.1,-2.1 -19.5,-13.7 -26.3,-22.8 -9.2,-12.2 -19.4,-20.8 -34.7,-24.2 -19.2,-2.3 -33.7,2.8 -49,14 -1.9,1.7 -3.6,3.4 -5.3,5.2 -9.2,9.5 -19.7,15.8 -33.1,16 -5.5,0 -10.3,-0.8 -15.6,-2.2 0.5,-7.4 69.3,-223.7 72,-233.9l0.2,-0.1Z"
|
||||||
|
android:fillColor="#d4ea59"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M578.8,105.7c28.3,0 198.2,-0.1 200.3,-0.1 38.6,-0.1 71.8,9.9 99.7,37.2 0.7,0.6 1.3,1.2 2,1.9 5,4.7 9.8,10.1 13,16.1 -0.7,5.5 -3,10.6 -5.1,15.8 -0.4,1 -0.8,2 -1.2,3.1 -3.4,8.6 -7.3,16.9 -11.7,25.1 -0.5,0.9 -1,1.9 -1.5,2.8 -3.7,6.5 -8.3,11.8 -13.5,17.2 -0.6,0.7 -1.2,1.3 -1.9,1.9 -9.4,9 -20.3,12.4 -32.7,14.7 -24.2,4.4 -43.4,16.2 -57.5,36.4 -2.1,3.3 -4.1,6.6 -6,10 -4.2,-0.4 -8,-0.9 -11.9,-2.3 -5.3,-1.9 -117.5,-30.7 -125.1,-32.7 -1.3,-0.3 -31.9,-9.8 -40.4,-23.6 -1.8,-3.3 -3.2,-6.7 -4.7,-10.2 -2.7,-6.7 -6.3,-11.5 -11.1,-16.9 -1.3,-1.6 -5,-6.2 -5.6,-6.9 -4,-5.8 -4.2,-11.3 -3.4,-18.1 1.2,-5.6 17.2,-69.5 17.9,-71l0.4,-0.4Z"
|
||||||
|
android:fillColor="#d8ed5c"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M338.8,105.7h227c-1.3,5.3 -5.2,20.9 -5.8,23.4 -2.1,8.4 -4.3,16.7 -6.7,25 -2.7,9.3 -5.2,18.7 -7.6,28.1 -5.7,21.5 -12.8,37.3 -32.5,49.8 -8.6,4.5 -17.8,6.9 -27.5,7.4 -14.5,1.5 -32.6,7.7 -42.9,18.4 -2.3,2.3 -3.9,3.7 -6.9,4.9 -6.3,-0.4 -132,-33.7 -133,-34 0.6,-5.4 34.9,-120 35.8,-122.8l0.1,-0.2Z"
|
||||||
|
android:fillColor="#d8ed5d"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M107.7,366.7c0,-18.4 -0.2,-111.3 -0.2,-115.2 -0.1,-18.8 0.1,-36.8 6.4,-54.8h-0.1c12.3,2.6 46.6,11.4 51.7,12.8 1,0.3 84.3,21.8 91.4,23.4 8.4,1.9 16.7,4.3 24.9,6.8 -0.7,6.8 -45.7,157 -51,175 -12.6,-1.7 -25.4,-15 -32.9,-24.8 -0.5,-0.7 -1.1,-1.5 -1.7,-2.2 -1.9,-2.6 -4.1,-4.8 -6.5,-7 -0.6,-0.6 -1.3,-1.2 -2,-1.9 -10.3,-9.6 -22.7,-16.6 -37,-17.1 -1.1,0 -2.2,-0.1 -3.3,-0.2 -13.5,-0.3 -26.7,1.4 -39.7,5.2"
|
||||||
|
android:fillColor="#d6ec5b"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M246,105.4c2.2,0 64.3,0 75.8,0.1 -0.6,2.6 -32.5,110.1 -34.9,117.9 -6,-0.6 -157.2,-39.2 -166.8,-41.9 4.9,-12.5 12.3,-23.9 21.7,-33.6 1.6,-1.6 3.1,-3.4 4.6,-5.3 3.1,-3.8 6.8,-6.6 10.7,-9.5 0.8,-0.6 1.5,-1.1 2.3,-1.7 12.1,-9 24.5,-16.2 38.9,-20.5 0.6,-0.2 1.3,-0.4 1.9,-0.6 15.5,-4.5 30.1,-5.2 46.1,-5.1l-0.3,0.2Z"
|
||||||
|
android:fillColor="#d8ee5d"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M297.8,243.7c0.9,0.2 1.7,0.4 2.6,0.7 6.4,1.6 115.7,30.5 116.8,30.7 1.9,0.5 3.8,1.1 5.6,1.7 -0.9,6.4 -4.2,11.9 -7,17.6l-3,6c-9.9,19.9 -22.2,32.6 -42.5,41.8 -18,8.2 -33.8,22.3 -46.6,37.4 -1.8,2 -3.6,3.9 -5.4,5.8 -0.7,0.7 -7.1,7.1 -9.7,9.7l-8.2,8.2c-14.6,13.9 -34.7,14.9 -53.6,14.5h-1.9c1.8,-6.7 52.5,-171.4 53,-174.1h-0.1Z"
|
||||||
|
android:fillColor="#d6ec5b"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M902.8,181.7h2c1.1,2.3 2.2,4.6 3.2,6.9 0.3,0.6 0.6,1.2 0.9,1.9 7.6,16.6 7.4,34.1 7.2,52 0,0.4 0,3 0,7.1 -0,18.8 0.1,68.9 0,80.2 -1.7,-0.3 -137,-36.6 -138.3,-37.9 10,-16.3 22.7,-29.7 41.9,-34.8 4.1,-0.9 8.3,-1.7 12.5,-2.5 19.7,-3.7 37.4,-13.1 49.4,-29.5 8.8,-13.6 15,-28.4 21.2,-43.2v-0.2Z"
|
||||||
|
android:fillColor="#d6ec5b"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M458.8,263.7l2,1c-0.7,0.5 -1.3,1.1 -2,1.6 -6.7,5.5 -13,11.3 -19,17.4 0,-4.2 1.9,-5.7 4.6,-8.7 4.4,-4.2 9.4,-7.7 14.4,-11.3h0Z"
|
||||||
|
android:fillColor="#dbf15f"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M553.8,206.7l2,1c-1.5,3.1 -3.2,6.1 -5,9 -0.3,-0.7 -0.7,-1.3 -1,-2 0.5,-1.3 1.2,-2.6 1.9,-4.1 0.7,-1.5 1.1,-2.2 1.2,-2.2 0.3,-0.6 0.6,-1.1 0.9,-1.7h0ZM548.8,216.7l1,2 -3,3c0.7,-1.6 1.3,-3.3 2,-5Z"
|
||||||
|
android:fillColor="#dcf160"/>
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:fillType="nonZero"
|
|
||||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
|
||||||
android:strokeWidth="1"
|
android:strokeWidth="1"
|
||||||
android:strokeColor="#00000000" />
|
android:pathData="M747.7,356.9c-0.3,1.6 -0.7,3.4 -1.1,5.4"
|
||||||
</vector>
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#fff"/>
|
||||||
|
<path
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:pathData="M743.9,372.7c-0.7,2.4 -1.5,4.9 -2.4,7.5 -5.5,15.7 -10.6,22.8 -15.5,34.2 -4.1,9.5 -6,21.1 -8.5,33.9"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#fff"/>
|
||||||
|
<path
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:pathData="M716.5,453.6c-0.1,0.5 -0.2,1 -0.3,1.5 -0.3,1.6 -0.6,3 -0.8,3.9"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#fff"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M531.8,588.6c7.4,5.6 12.6,13 14.5,22.2 0.2,2 0.3,3.9 0.2,5.8v1.5c-0.2,7.2 -2.4,13.2 -5.8,19.4 -0.6,1.1 -1.1,2.2 -1.7,3.3 -3.2,6 -7,11.6 -10.9,17.1 -1.1,1.5 -2.2,3.2 -3.2,4.7 -2.1,3 -4.1,6 -6.2,8.9 -0.2,0.3 -0.4,0.6 -0.6,0.9 -1,1.4 -2,2.7 -3.3,3.8 -1.8,-0.6 -2,-0.9 -3,-2.4 -0.3,-0.4 -0.6,-0.8 -0.8,-1.2 -0.3,-0.5 -0.6,-0.9 -0.9,-1.3 -0.7,-0.9 -1.3,-1.9 -2,-2.8 -0.4,-0.5 -0.7,-1 -1,-1.5 -1,-1.5 -2.1,-3 -3.2,-4.6 -16.1,-23 -23.4,-40.2 -21.8,-51.7 1.1,-6 3.6,-10.8 7.4,-15.6 0.3,-0.3 0.5,-0.7 0.7,-1 9.4,-12.3 29.2,-13.7 41.6,-5.7l-0.1,0.1Z"
|
||||||
|
android:fillColor="#fefdea"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M522,603.6c3.4,2.2 5.8,5.4 7,9.3 0.9,4.7 -0.1,9 -2.5,13 -2.2,2.9 -5.5,5 -9.1,5.6 -5,0.6 -8.7,-0.3 -12.9,-3.1 -3.2,-2.6 -4.9,-6.3 -5.4,-10.4 -0.2,-5 1.1,-8.5 4.4,-12.2 5.5,-5.1 12,-5.5 18.5,-2.2h0Z"
|
||||||
|
android:fillColor="#bcd542"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M219.5,612.1c-12,-6.1 -27.7,-7.1 -40.5,-3.8 -4.5,1.5 -8.7,3.6 -12.8,6.1 -5.1,3 -10.1,4 -16,3.8l-21,-42.9 -2.6,-10.8 137.6,-0.3c17,0 28.8,-3.5 42.2,-14.3 16,-12.7 34.9,-19 55.1,-20.7 0.8,0 19,-0.9 27.4,-0.9l-0.1,-0.1h17.6c0.6,0 1.9,0.6 3.9,1.7 -0.3,5 -6.2,14.6 -7,15.9 -0.4,0.6 -7.3,12.6 -10.3,18.1 -0.4,0.7 -18.3,36.7 -26,54.4 -4.5,0.2 -8,0 -12.1,-1.7 -0.9,-0.4 -7.1,-3.2 -9.3,-4.3 -12.4,-6.4 -27.8,-6.9 -41,-2.9 -5.5,1.8 -10.6,4.6 -15.7,7.5 -9.1,5.1 -19.5,5.7 -29.6,5.7h-2.8c-9.2,-0.1 -17.9,-1.7 -26.3,-5.4l-10.5,-5Z"
|
||||||
|
android:fillColor="#fefce9"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M220.2,454.2h16.6v33.5c3.9,-0.1 43.1,0 44.2,0 7.3,-0.2 12.5,0.2 18.1,5.2 0.5,0.4 1,0.9 1.5,1.3 10.2,8.9 17,25.6 21.4,38.2 -0.9,0.3 -1.9,0.6 -2.8,0.9 -10.6,3.6 -19,9.6 -27.9,16.2 -9.7,7.1 -19.6,9.5 -31.5,9.4 -1,0 -83,-0.1 -96.2,-0.2v-14.2h20.5c0,-3.1 0.1,-35.7 0,-36.7 0,-5.7 0.3,-9 4.2,-13.3 4.7,-4.5 8.5,-6.6 15.1,-6.8 2,0 4.1,-0.1 6.2,0 3.6,0.1 7.3,-0.2 11,0v-33.3l-0.1,-0.2Z"
|
||||||
|
android:fillColor="#fdfce9"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M214.8,622.5c4.2,1.8 14.2,5.6 15.1,5.9 19.4,7.3 42.4,7.4 61.7,-0.6 3.4,-1.6 6.7,-3.2 10,-5 10.9,-5.4 23.3,-6.6 34.9,-2.8 3.6,1.3 7.2,2.7 10.7,4.3 14,5.9 30.8,6.2 45,1.1 3.1,-1.3 11.8,-5.8 13.4,-6.3 0.6,5.3 0.3,9 -0.9,11.1 -9.7,9.1 -24,12.9 -37.1,12.8 -9.8,-0.3 -17.8,-3 -26.8,-6.9 -12.1,-5.1 -22.2,-6 -34.6,-1.1 -3.2,1.3 -6.4,2.8 -9.5,4.2 -4.8,2.1 -16.9,5.7 -17.9,6 -5.7,1.3 -18.6,1 -19.2,1 -9.7,0 -29.4,-3.8 -30,-4 -5.9,-1.8 -11.5,-4.1 -17.1,-6.8 -11.3,-5.4 -22.2,-4.7 -33.9,-0.6 -1.7,0.8 -3.5,1.6 -5.2,2.4 -12.7,6.1 -29.3,7.2 -42.7,2.5 -4.9,-1.9 -14.8,-8 -15.4,-8.2 -0.9,-0.6 -1.9,-1.6 -3.1,-3.1 -0.4,-3.2 -0.2,-6.2 0,-9.5 2.8,1.1 5.5,2.3 8.3,3.5 14.8,6.7 30.4,8.2 46.1,3.5 3.2,-1.3 6.2,-2.7 9.1,-4.3 12.3,-5.8 26.9,-4.7 39.1,0.6l0.2,0.2Z"
|
||||||
|
android:fillColor="#fdfce9"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M241.3,511.2c1.5,2.3 1,13.6 0,15.7 -3.1,3 -24.5,1.2 -25.7,0 -1.7,-2.8 -0.6,-14.8 0,-15.7 2.2,-1.5 24.8,-0.7 25.7,0Z"
|
||||||
|
android:fillColor="#b1cc36"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M285.4,511.2c1.5,2.3 1,13.6 0,15.7 -3.1,3 -24.5,1.2 -25.7,0 -1.7,-2.8 -0.6,-14.8 0,-15.7 2.2,-1.5 24.8,-0.7 25.7,0Z"
|
||||||
|
android:fillColor="#b1cc36"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M431.2,700.7l22.7,175.9h-40.8l-4.7,-43.2h-22.2l-6.7,43.2h-34.6l29.9,-175.9h56.3ZM405.5,806.6l-7.2,-78.3h-0.5l-7.2,78.3h14.8Z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M491.5,876.5v-175.9h41v175.9h-41Z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M635.3,763.7v-24.7c0,-8.6 -2.7,-13.6 -10.9,-13.6 -8.9,0 -10.9,4.9 -10.9,13.6 0,29.6 62.7,38.3 62.7,92.6 0,33.1 -17.8,48.4 -52.1,48.4 -26.2,0 -51.6,-8.9 -51.6,-39.3v-32.6h41v30.4c0,10.4 3.2,13.3 10.9,13.3 6.7,0 10.9,-3 10.9,-13.3 0,-39.8 -62.7,-40.5 -62.7,-97.3 0,-31.9 21,-44 52.6,-44 27.7,0 51.1,9.4 51.1,38.8v27.7h-41Z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M418.4,308.7l-5,1.2 -4.7,6.8s2.7,1.8 2.7,1.8c0,0 3,2 3,2 1.6,-2.3 3.2,-4.6 4.7,-6.8l-0.6,-5.1Z"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#fff"/>
|
||||||
|
<path
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:pathData="M410.6,320.6c-0.5,0.6 -1,1.3 -1.5,2"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#fff"/>
|
||||||
|
<path
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:pathData="M405.9,326.2c-2.1,2.2 -4.7,4.7 -7.8,7.1 -10,7.9 -15.4,7.7 -29.4,16.2 -12.5,7.6 -18.7,11.4 -20.1,18 -1.3,5.9 1.5,10.9 -2,18.4 -0.5,1.1 -1.1,2.2 -1.7,3.1"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#fff"/>
|
||||||
|
<path
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:pathData="M343.4,391c-0.6,0.7 -1.2,1.3 -1.7,1.8"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#fff"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
|
|||||||
@@ -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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/main_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
|
android:fitsSystemWindows="false"
|
||||||
tools:context=".MainActivity">
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
<!-- Карта -->
|
<!-- Карта -->
|
||||||
@@ -11,20 +13,41 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
<!-- Панель управления -->
|
<!-- Компас -->
|
||||||
<!-- android:layout_below="@id/compass_view"-->
|
<com.grigowashere.aismap.view.CompassView
|
||||||
|
android:id="@+id/compass_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="80dp"
|
||||||
|
android:layout_alignParentTop="true"
|
||||||
|
android:layout_marginLeft="0dp"
|
||||||
|
android:layout_marginTop="0dp"
|
||||||
|
android:layout_marginRight="0dp"
|
||||||
|
android:layout_marginBottom="0dp" />
|
||||||
|
|
||||||
|
<!-- Виджет координат: нижний inset задаётся в MainActivity (system bar) -->
|
||||||
|
<com.grigowashere.aismap.view.CoordinatesDockWidget
|
||||||
|
android:id="@+id/coordinates_widget"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="80dp"
|
||||||
|
android:layout_alignParentBottom="true"
|
||||||
|
android:layout_marginLeft="0dp"
|
||||||
|
android:layout_marginTop="0dp"
|
||||||
|
android:layout_marginRight="0dp"
|
||||||
|
android:layout_marginBottom="0dp"
|
||||||
|
android:elevation="2dp" />
|
||||||
|
|
||||||
|
<!-- Панель управления (после координат в Z-order — не перекрывается снизу) -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/control_panel"
|
android:id="@+id/control_panel"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|
||||||
android:layout_alignParentEnd="true"
|
android:layout_alignParentEnd="true"
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="8dp"
|
||||||
android:background="@android:color/transparent"
|
android:background="@android:color/transparent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="8dp"
|
android:padding="8dp"
|
||||||
android:gravity="end"
|
android:gravity="end"
|
||||||
android:elevation="4dp">
|
android:elevation="10dp">
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/btn_center_vessel"
|
android:id="@+id/btn_center_vessel"
|
||||||
@@ -59,6 +82,17 @@
|
|||||||
android:scaleType="fitCenter"
|
android:scaleType="fitCenter"
|
||||||
android:layout_marginBottom="8dp" />
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btn_gps_source"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="@drawable/button_background"
|
||||||
|
android:src="@drawable/ic_gps_source_hub"
|
||||||
|
android:contentDescription="Источник координат"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/btn_settings"
|
android:id="@+id/btn_settings"
|
||||||
android:layout_width="40dp"
|
android:layout_width="40dp"
|
||||||
@@ -100,30 +134,35 @@
|
|||||||
android:textColor="@android:color/white"
|
android:textColor="@android:color/white"
|
||||||
android:layout_marginTop="4dp"/>
|
android:layout_marginTop="4dp"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_ble_rssi"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="BLE RSSI: --"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:layout_marginTop="4dp"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_ble_batt"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="BLE Batt: --"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:layout_marginTop="2dp"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_fps"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="FPS: --"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:layout_marginTop="4dp"/>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Компас -->
|
|
||||||
<com.grigowashere.aismap.view.CompassView
|
|
||||||
android:id="@+id/compass_view"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="80dp"
|
|
||||||
android:layout_alignParentTop="true"
|
|
||||||
android:layout_marginLeft="0dp"
|
|
||||||
android:layout_marginTop="0dp"
|
|
||||||
android:layout_marginRight="0dp"
|
|
||||||
android:layout_marginBottom="0dp" />
|
|
||||||
|
|
||||||
<!-- Виджет координат -->
|
|
||||||
<com.grigowashere.aismap.view.CoordinatesDockWidget
|
|
||||||
android:id="@+id/coordinates_widget"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="80dp"
|
|
||||||
android:layout_alignParentBottom="true"
|
|
||||||
android:layout_marginLeft="0dp"
|
|
||||||
android:layout_marginTop="0dp"
|
|
||||||
android:layout_marginRight="0dp"
|
|
||||||
android:layout_marginBottom="0dp" />
|
|
||||||
|
|
||||||
<!-- Простая информационная панель
|
<!-- Простая информационная панель
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:layout_marginBottom="24dp" />
|
android:layout_marginBottom="24dp" />
|
||||||
|
|
||||||
<!-- UDP Настройки -->
|
<!-- Интерфейсы (UDP/BLE) -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
@@ -38,36 +38,36 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="📡 UDP Настройки"
|
android:text="🔌 Интерфейсы"
|
||||||
android:textSize="18sp"
|
android:textSize="18sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="12dp" />
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/til_open_interfaces"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:hint="UDP Порт"
|
android:hint="Интерфейсы (UDP / BLE)"
|
||||||
app:helperText="Порт для прослушивания AIS данных">
|
app:helperText="Перейти к настройкам UDP, BLE и UDP-bridge"
|
||||||
|
app:endIconMode="custom"
|
||||||
|
|
||||||
|
app:endIconContentDescription="Открыть">
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
android:id="@+id/et_udp_port"
|
android:id="@+id/et_open_interfaces"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:inputType="number"
|
android:focusable="false"
|
||||||
android:text="10110" />
|
android:focusableInTouchMode="false"
|
||||||
|
android:clickable="true"
|
||||||
|
android:cursorVisible="false"
|
||||||
|
android:inputType="none"
|
||||||
|
android:text="Открыть настройки интерфейсов (UDP / BLE)" />
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
|
||||||
android:id="@+id/switch_udp_enabled"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Включить UDP слушатель"
|
|
||||||
android:textSize="16sp"
|
|
||||||
android:checked="true" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
@@ -209,7 +209,7 @@
|
|||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<!-- Приоритеты данных -->
|
<!-- Источник координат (GPS Source) -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
@@ -226,12 +226,103 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="📊 Приоритеты данных"
|
android:text="📡 Источник координат"
|
||||||
android:textSize="18sp"
|
android:textSize="18sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="12dp" />
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Откуда приложение берёт позицию собственного судна."
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textColor="@android:color/darker_gray"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
android:id="@+id/radio_group_gps_source"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radio_gps_source_hub"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="AIS Hub (BLE)"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:checked="true" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Позиция и AIS-цели приходят из внешнего AIS Hub по BLE."
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="@android:color/darker_gray"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:layout_marginStart="16dp" />
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radio_gps_source_android"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Android GPS"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Встроенный GPS устройства (+опциональный внешний NMEA)."
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="@android:color/darker_gray"
|
||||||
|
android:layout_marginBottom="4dp"
|
||||||
|
android:layout_marginStart="16dp" />
|
||||||
|
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Расширенные: NMEA/UDP источники (скрыты по умолчанию) -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardCornerRadius="8dp"
|
||||||
|
app:cardElevation="4dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/switch_show_advanced_nmea"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="📊 Расширенные NMEA-источники"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:checked="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Открыть старые настройки Android NMEA / UDP NMEA / режимы данных. Нужны, только если вы работаете без AIS Hub."
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="@android:color/darker_gray"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/ll_advanced_nmea_section"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
@@ -342,6 +433,8 @@
|
|||||||
|
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
@@ -589,6 +682,58 @@
|
|||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Морские знаки -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardCornerRadius="8dp"
|
||||||
|
app:cardElevation="4dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="⚓ Морские знаки OpenSeaMap"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@android:color/black"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Отображать морские знаки (буи, маяки, навигационные знаки) поверх карты."
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@android:color/darker_gray"
|
||||||
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/switch_seamarks_enabled"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Показывать морские знаки"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:checked="false"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="💡 Источник: OpenSeaMap.org - открытая база данных морских знаков"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="@android:color/darker_gray"
|
||||||
|
android:layout_marginTop="4dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<!-- Кнопки -->
|
<!-- Кнопки -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@android:id/text1"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:text="Device name" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@android:id/text2"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@android:color/darker_gray"
|
||||||
|
android:text="MAC" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/text3"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="@android:color/darker_gray"
|
||||||
|
android:text="RSSI: -60" />
|
||||||
|
</LinearLayout>
|
||||||
@@ -38,4 +38,10 @@
|
|||||||
android:icon="@android:drawable/ic_menu_view"
|
android:icon="@android:drawable/ic_menu_view"
|
||||||
app:showAsAction="ifRoom" />
|
app:showAsAction="ifRoom" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/menu_seamarks"
|
||||||
|
android:title="Морские знаки"
|
||||||
|
android:icon="@android:drawable/ic_menu_mapmode"
|
||||||
|
app:showAsAction="ifRoom" />
|
||||||
|
|
||||||
</menu>
|
</menu>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
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>
|
||||||
|
|
||||||
<style name="Theme.AISMap" parent="Base.Theme.AISMap" />
|
<style name="Theme.AISMap" parent="Base.Theme.AISMap" />
|
||||||
|
|
||||||
|
<!-- Главный экран: edge-to-edge + рисуем под брови камеры. Паддинги
|
||||||
|
для компаса, координатного виджета и панели кнопок задаёт
|
||||||
|
MainActivity через WindowInsets-листенер. -->
|
||||||
|
<style name="Theme.AISMap.Map" parent="Theme.AISMap">
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowTranslucentStatus" tools:targetApi="19">false</item>
|
||||||
|
<item name="android:windowTranslucentNavigation" tools:targetApi="19">false</item>
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds" tools:targetApi="21">true</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="27">shortEdges</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">t1.openseamap.org</domain>
|
||||||
|
<domain includeSubdomains="true">tiles.openseamap.org</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.grigowashere.aismap.controllers;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
import com.grigowashere.aismap.models.AISVessel;
|
||||||
|
import com.grigowashere.aismap.models.Vessel;
|
||||||
|
import com.grigowashere.aismap.utils.SettingsManager;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
//import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тест для проверки исправления ConcurrentModificationException в AppCoordinator
|
||||||
|
*/
|
||||||
|
//@RunWith(MockitoJUnitRunner.class)
|
||||||
|
//public class AppCoordinatorConcurrencyTest {
|
||||||
|
//
|
||||||
|
// @Mock
|
||||||
|
// private Context mockContext;
|
||||||
|
//
|
||||||
|
// @Mock
|
||||||
|
// private SettingsManager mockSettingsManager;
|
||||||
|
//
|
||||||
|
// private AppCoordinator appCoordinator;
|
||||||
|
//
|
||||||
|
// @Before
|
||||||
|
// public void setUp() {
|
||||||
|
// when(mockContext.getApplicationContext()).thenReturn(mockContext);
|
||||||
|
// appCoordinator = new AppCoordinator(mockContext);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @Test
|
||||||
|
// public void testGetNearbyVesselsConcurrency() throws InterruptedException {
|
||||||
|
// // Создаем несколько AIS судов для тестирования
|
||||||
|
// AISVessel vessel1 = new AISVessel();
|
||||||
|
// vessel1.setMmsi("123456789");
|
||||||
|
// vessel1.setLatitude(55.7558);
|
||||||
|
// vessel1.setLongitude(37.6176);
|
||||||
|
//
|
||||||
|
// AISVessel vessel2 = new AISVessel();
|
||||||
|
// vessel2.setMmsi("987654321");
|
||||||
|
// vessel2.setLatitude(55.7559);
|
||||||
|
// vessel2.setLongitude(37.6177);
|
||||||
|
//
|
||||||
|
// // Добавляем суда
|
||||||
|
// appCoordinator.onAISVesselChanged(vessel1);
|
||||||
|
// appCoordinator.onAISVesselChanged(vessel2);
|
||||||
|
//
|
||||||
|
// // Устанавливаем собственное судно
|
||||||
|
// Vessel ownVessel = new Vessel();
|
||||||
|
// ownVessel.setLatitude(55.7558);
|
||||||
|
// ownVessel.setLongitude(37.6176);
|
||||||
|
// appCoordinator.onOwnVesselChanged(ownVessel);
|
||||||
|
//
|
||||||
|
// int threadCount = 10;
|
||||||
|
// int iterationsPerThread = 100;
|
||||||
|
// CountDownLatch latch = new CountDownLatch(threadCount);
|
||||||
|
// ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||||
|
//
|
||||||
|
// // Запускаем несколько потоков, которые одновременно вызывают getNearbyVessels
|
||||||
|
// // и модифицируют коллекцию aisVessels
|
||||||
|
// for (int i = 0; i < threadCount; i++) {
|
||||||
|
// final int threadId = i;
|
||||||
|
// executor.submit(() -> {
|
||||||
|
// try {
|
||||||
|
// for (int j = 0; j < iterationsPerThread; j++) {
|
||||||
|
// // Вызываем getNearbyVessels через onCompassChanged
|
||||||
|
// appCoordinator.onCompassChanged(0.0f);
|
||||||
|
//
|
||||||
|
// // Добавляем новое судно
|
||||||
|
// AISVessel newVessel = new AISVessel();
|
||||||
|
// newVessel.setMmsi("thread" + threadId + "_vessel" + j);
|
||||||
|
// newVessel.setLatitude(55.7558 + (j * 0.001));
|
||||||
|
// newVessel.setLongitude(37.6176 + (j * 0.001));
|
||||||
|
// appCoordinator.onAISVesselChanged(newVessel);
|
||||||
|
//
|
||||||
|
// // Небольшая задержка для увеличения вероятности race condition
|
||||||
|
// Thread.sleep(1);
|
||||||
|
// }
|
||||||
|
// } catch (Exception e) {
|
||||||
|
// fail("ConcurrentModificationException не должна возникать: " + e.getMessage());
|
||||||
|
// } finally {
|
||||||
|
// latch.countDown();
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Ждем завершения всех потоков
|
||||||
|
// boolean finished = latch.await(30, TimeUnit.SECONDS);
|
||||||
|
// executor.shutdown();
|
||||||
|
//
|
||||||
|
// assertTrue("Тест не завершился в течение 30 секунд", finished);
|
||||||
|
//
|
||||||
|
// // Проверяем, что метод getAISVessels работает корректно
|
||||||
|
// List<AISVessel> vessels = appCoordinator.getAISVessels();
|
||||||
|
// assertNotNull("Список судов не должен быть null", vessels);
|
||||||
|
// assertTrue("Должно быть добавлено несколько судов", vessels.size() > 0);
|
||||||
|
// }
|
||||||
|
//}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.grigowashere.aismap.controllers;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.Before;
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
///**
|
||||||
|
// * Тест для проверки логирования ошибок в NMEAParser
|
||||||
|
// */
|
||||||
|
//public class NMEAParserErrorLoggingTest {
|
||||||
|
//
|
||||||
|
// private NMEAParser parser;
|
||||||
|
// private TestNMEAParserListener listener;
|
||||||
|
//
|
||||||
|
// @Before
|
||||||
|
// public void setUp() {
|
||||||
|
// parser = new NMEAParser();
|
||||||
|
// listener = new TestNMEAParserListener();
|
||||||
|
// parser.setListener(listener);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @Test
|
||||||
|
// public void testParseInvalidNMEA() {
|
||||||
|
// // Тестируем парсинг некорректного NMEA сообщения
|
||||||
|
// parser.parseNMEA("$INVALID");
|
||||||
|
// // Проверяем, что ошибка была залогирована (через LogSender)
|
||||||
|
// // В реальном тесте мы бы проверили, что LogSender.logDroppedNMEA был вызван
|
||||||
|
// assertTrue("Парсинг некорректного NMEA должен завершиться без исключений", true);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @Test
|
||||||
|
// public void testParseTooShortNMEA() {
|
||||||
|
// // Тестируем парсинг слишком короткого NMEA сообщения
|
||||||
|
// parser.parseNMEA("$GP");
|
||||||
|
// assertTrue("Парсинг слишком короткого NMEA должен завершиться без исключений", true);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @Test
|
||||||
|
// public void testParseEmptyNMEA() {
|
||||||
|
// // Тестируем парсинг пустого NMEA сообщения
|
||||||
|
// parser.parseNMEA("");
|
||||||
|
// parser.parseNMEA(null);
|
||||||
|
// assertTrue("Парсинг пустого NMEA должен завершиться без исключений", true);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @Test
|
||||||
|
// public void testParseInvalidAIS() {
|
||||||
|
// // Тестируем парсинг некорректного AIS сообщения
|
||||||
|
// parser.parseNMEA("!AIVDM,1,1,,A,*AB"); // Слишком короткое
|
||||||
|
// assertTrue("Парсинг некорректного AIS должен завершиться без исключений", true);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @Test
|
||||||
|
// public void testParseAISWithInvalidChecksum() {
|
||||||
|
// // Тестируем парсинг AIS с неверной контрольной суммой
|
||||||
|
// parser.parseNMEA("!AIVDM,1,1,,A,1234567890ABCDEF,*ZZ"); // Неверная контрольная сумма
|
||||||
|
// assertTrue("Парсинг AIS с неверной контрольной суммой должен завершиться без исключений", true);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @Test
|
||||||
|
// public void testParseUnsupportedMessageType() {
|
||||||
|
// // Тестируем парсинг неподдерживаемого типа сообщения
|
||||||
|
// parser.parseNMEA("$GPXXX,123,456,789,*AB");
|
||||||
|
// assertTrue("Парсинг неподдерживаемого типа должен завершиться без исключений", true);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Тестовый слушатель для проверки вызовов
|
||||||
|
// private static class TestNMEAParserListener implements NMEAParser.NMEAParserListener {
|
||||||
|
// public boolean onVesselUpdatedCalled = false;
|
||||||
|
// public boolean onAISVesselUpdatedCalled = false;
|
||||||
|
// public boolean onParseErrorCalled = false;
|
||||||
|
// public boolean onDOPUpdatedCalled = false;
|
||||||
|
//
|
||||||
|
// @Override
|
||||||
|
// public void onVesselUpdated(com.grigowashere.aismap.models.Vessel vessel) {
|
||||||
|
// onVesselUpdatedCalled = true;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @Override
|
||||||
|
// public void onAISVesselUpdated(com.grigowashere.aismap.models.AISVessel vessel) {
|
||||||
|
// onAISVesselUpdatedCalled = true;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @Override
|
||||||
|
// public void onParseError(String error) {
|
||||||
|
// onParseErrorCalled = true;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @Override
|
||||||
|
// public void onDOPUpdated(double pdop, double hdop, double vdop) {
|
||||||
|
// onDOPUpdatedCalled = true;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.grigowashere.aismap.utils;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тест для проверки новых методов логирования с полным NMEA и BLE кусками
|
||||||
|
*/
|
||||||
|
public class LogSenderExtendedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testAISParseErrorWithFullNMEA() {
|
||||||
|
// Тестируем новый метод с полным NMEA сообщением
|
||||||
|
try {
|
||||||
|
String fullNMEA = "!AIVDM,1,1,,A,D02VqQ1K`Nfq@AN>56DK6E@UK6E1H0,*AB";
|
||||||
|
String aisPayload = "D02VqQ1K`Nfq@AN>56DK6E@UK6E1H0";
|
||||||
|
|
||||||
|
LogSender.logAISParseErrorWithFullNMEA("Неподдерживаемый тип", fullNMEA, aisPayload, "Тип: 20");
|
||||||
|
assertTrue("logAISParseErrorWithFullNMEA должен работать без исключений", true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail("logAISParseErrorWithFullNMEA должен работать без исключений: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testBLEDataChunkLogging() {
|
||||||
|
// Тестируем логирование BLE кусков
|
||||||
|
try {
|
||||||
|
String deviceMac = "AA:BB:CC:DD:EE:FF";
|
||||||
|
String dataChunk = "$GPGGA,123456,1234.5678,N,12345.6789,E,1,8,1.2,123.4,M,45.6,M,,*AB\r\n";
|
||||||
|
|
||||||
|
LogSender.logBLEDataChunk(deviceMac, dataChunk);
|
||||||
|
assertTrue("logBLEDataChunk должен работать без исключений", true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail("logBLEDataChunk должен работать без исключений: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testBLEDataChunkWithNullValues() {
|
||||||
|
// Тестируем обработку null значений
|
||||||
|
try {
|
||||||
|
LogSender.logBLEDataChunk(null, null);
|
||||||
|
assertTrue("logBLEDataChunk должен обрабатывать null значения", true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail("logBLEDataChunk должен обрабатывать null значения: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testAISParseErrorWithFullNMEANullValues() {
|
||||||
|
// Тестируем обработку null значений в новом методе
|
||||||
|
try {
|
||||||
|
LogSender.logAISParseErrorWithFullNMEA("Тест", null, null, "Детали");
|
||||||
|
assertTrue("logAISParseErrorWithFullNMEA должен обрабатывать null значения", true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail("logAISParseErrorWithFullNMEA должен обрабатывать null значения: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.grigowashere.aismap.utils;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тест для проверки логирования ошибок через LogSender
|
||||||
|
*/
|
||||||
|
public class LogSenderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testLogError() {
|
||||||
|
// Тестируем, что метод logError не падает с исключениями
|
||||||
|
try {
|
||||||
|
LogSender.logError("TEST_ERROR", "Тестовое сообщение", "Тестовые детали");
|
||||||
|
// Если метод выполнился без исключений, тест прошел
|
||||||
|
assertTrue(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail("logError должен работать без исключений: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testLogDroppedNMEA() {
|
||||||
|
// Тестируем логирование отброшенных NMEA сообщений
|
||||||
|
try {
|
||||||
|
LogSender.logDroppedNMEA("Тестовая причина", "$GPGGA,123456,1234.5678,N,12345.6789,E,1,8,1.2,123.4,M,45.6,M,,*AB", "Тестовые детали");
|
||||||
|
assertTrue(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail("logDroppedNMEA должен работать без исключений: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testLogAISParseError() {
|
||||||
|
// Тестируем логирование ошибок парсинга AIS
|
||||||
|
try {
|
||||||
|
LogSender.logAISParseError("Тестовая ошибка", "!AIVDM,1,1,,A,1234567890ABCDEF,*AB", "Тестовые детали");
|
||||||
|
assertTrue(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail("logAISParseError должен работать без исключений: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testLogBLEError() {
|
||||||
|
// Тестируем логирование ошибок BLE
|
||||||
|
try {
|
||||||
|
LogSender.logBLEError("Тестовая ошибка BLE", "AA:BB:CC:DD:EE:FF", "Тестовые детали");
|
||||||
|
assertTrue(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail("logBLEError должен работать без исключений: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testLogErrorWithNullValues() {
|
||||||
|
// Тестируем обработку null значений
|
||||||
|
try {
|
||||||
|
LogSender.logError(null, null, null);
|
||||||
|
LogSender.logDroppedNMEA(null, null, null);
|
||||||
|
LogSender.logAISParseError(null, null, null);
|
||||||
|
LogSender.logBLEError(null, null, null);
|
||||||
|
assertTrue(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail("Методы должны обрабатывать null значения: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.grigowashere.aismap.utils;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тест для проверки правильного URL логирования ошибок
|
||||||
|
*/
|
||||||
|
public class LogSenderURLTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testErrorLoggingURL() {
|
||||||
|
// Тестируем, что URL формируется правильно
|
||||||
|
try {
|
||||||
|
// Симулируем вызов logError
|
||||||
|
LogSender.logError("TEST_ERROR", "Тестовое сообщение", "Тестовые детали");
|
||||||
|
|
||||||
|
// Если метод выполнился без исключений, тест прошел
|
||||||
|
assertTrue("logError должен работать без исключений", true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail("logError должен работать без исключений: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDroppedNMEALoggingURL() {
|
||||||
|
// Тестируем логирование отброшенных NMEA сообщений
|
||||||
|
try {
|
||||||
|
LogSender.logDroppedNMEA("Тестовая причина", "$GPGGA,123456,1234.5678,N,12345.6789,E,1,8,1.2,123.4,M,45.6,M,,*AB", "Тестовые детали");
|
||||||
|
assertTrue("logDroppedNMEA должен работать без исключений", true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail("logDroppedNMEA должен работать без исключений: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testAISParseErrorLoggingURL() {
|
||||||
|
// Тестируем логирование ошибок парсинга AIS
|
||||||
|
try {
|
||||||
|
LogSender.logAISParseError("Тестовая ошибка", "!AIVDM,1,1,,A,1234567890ABCDEF,*AB", "Тестовые детали");
|
||||||
|
assertTrue("logAISParseError должен работать без исключений", true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail("logAISParseError должен работать без исключений: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testBLEErrorLoggingURL() {
|
||||||
|
// Тестируем логирование ошибок BLE
|
||||||
|
try {
|
||||||
|
LogSender.logBLEError("Тестовая ошибка BLE", "AA:BB:CC:DD:EE:FF", "Тестовые детали");
|
||||||
|
assertTrue("logBLEError должен работать без исключений", true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
fail("logBLEError должен работать без исключений: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||