From 41432665eaab88168e8c183134e7efb2d4758250 Mon Sep 17 00:00:00 2001 From: grigo Date: Tue, 23 Sep 2025 11:53:23 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=B3=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20=D0=BA=20=D0=BA=D1=80=D1=83=D0=BF=D0=BD?= =?UTF-8?q?=D1=8B=D0=BC=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=D0=BC:=20=D0=BA=D0=B0=D1=80=D1=82=D0=B0,=20AIS=20=D0=B8?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Яндекс/MapForge: правки в менеджерах и обёртках маркеров (улучшена отрисовка/логика) - NMEAParser: корректировки парсинга и стабильности - Модель AISVessel: уточнение полей/логики - Настройки: правки в SettingsActivity и SettingsManager, актуализация AppController - UI: обновлены activity_main, activity_settings, bottom_sheet_ais_vessel; меню main_menu - Ресурсы: добавлен drawable/targetclassa.xml, обновлён drawable/target.xml - Конфигурация: правки AndroidManifest и app/build.gradle - Прочее: изменения в .idea (не влияют на сборку) --- .idea/deploymentTargetSelector.xml | 8 + NMEAParser_backup.java | 2835 +++++++++++++++++ app/build.gradle | 7 + app/src/main/AndroidManifest.xml | 22 +- .../aismap/AisTargetsActivity.java | 108 + .../aismap/AisTargetsAdapter.java | 138 + .../com/grigowashere/aismap/MainActivity.java | 265 +- .../grigowashere/aismap/SettingsActivity.java | 63 + .../aismap/controllers/AppController.java | 226 +- .../aismap/controllers/NMEAParser.java | 284 +- .../grigowashere/aismap/data/AppDatabase.java | 37 + .../grigowashere/aismap/data/Repository.java | 58 + .../aismap/data/dao/AISVesselDao.java | 35 + .../aismap/data/dao/VesselDao.java | 23 + .../aismap/data/entity/AISVesselEntity.java | 61 + .../aismap/data/entity/VesselEntity.java | 23 + .../aismap/data/mapper/AISVesselMapper.java | 131 + .../aismap/maps/MapForgeImpl.java | 8 +- .../aismap/maps/MarkerManager.java | 20 + .../aismap/maps/VesselPathTracker.java | 329 ++ .../aismap/maps/YandexMapImpl.java | 74 +- .../aismap/maps/YandexMarkerManager.java | 318 +- .../aismap/maps/YandexMarkerWrapper.java | 401 ++- .../grigowashere/aismap/models/AISVessel.java | 40 + .../aismap/services/AISForegroundService.java | 75 + .../aismap/services/NotificationService.java | 237 ++ .../aismap/utils/MIDToCountry.java | 314 ++ .../aismap/utils/SettingsManager.java | 180 +- app/src/main/res/drawable/target.xml | 13 +- app/src/main/res/drawable/targetclassa.xml | 18 + .../main/res/layout/activity_ais_targets.xml | 26 + app/src/main/res/layout/activity_main.xml | 29 + app/src/main/res/layout/activity_settings.xml | 146 + .../res/layout/bottom_sheet_ais_vessel.xml | 75 +- app/src/main/res/layout/item_ais_target.xml | 73 + app/src/main/res/menu/main_menu.xml | 6 + rawAssets/SVG/TargetClassA.svg | 16 + 37 files changed, 6561 insertions(+), 161 deletions(-) create mode 100644 NMEAParser_backup.java create mode 100644 app/src/main/java/com/grigowashere/aismap/AisTargetsActivity.java create mode 100644 app/src/main/java/com/grigowashere/aismap/AisTargetsAdapter.java create mode 100644 app/src/main/java/com/grigowashere/aismap/data/AppDatabase.java create mode 100644 app/src/main/java/com/grigowashere/aismap/data/Repository.java create mode 100644 app/src/main/java/com/grigowashere/aismap/data/dao/AISVesselDao.java create mode 100644 app/src/main/java/com/grigowashere/aismap/data/dao/VesselDao.java create mode 100644 app/src/main/java/com/grigowashere/aismap/data/entity/AISVesselEntity.java create mode 100644 app/src/main/java/com/grigowashere/aismap/data/entity/VesselEntity.java create mode 100644 app/src/main/java/com/grigowashere/aismap/data/mapper/AISVesselMapper.java create mode 100644 app/src/main/java/com/grigowashere/aismap/maps/VesselPathTracker.java create mode 100644 app/src/main/java/com/grigowashere/aismap/services/AISForegroundService.java create mode 100644 app/src/main/java/com/grigowashere/aismap/services/NotificationService.java create mode 100644 app/src/main/java/com/grigowashere/aismap/utils/MIDToCountry.java create mode 100644 app/src/main/res/drawable/targetclassa.xml create mode 100644 app/src/main/res/layout/activity_ais_targets.xml create mode 100644 app/src/main/res/layout/item_ais_target.xml create mode 100644 rawAssets/SVG/TargetClassA.svg diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b268ef3..8d22e48 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,6 +4,14 @@ diff --git a/NMEAParser_backup.java b/NMEAParser_backup.java new file mode 100644 index 0000000..1163a86 --- /dev/null +++ b/NMEAParser_backup.java @@ -0,0 +1,2835 @@ +package com.grigowashere.aismap.controllers; + +import android.util.Log; +import com.grigowashere.aismap.models.Vessel; +import com.grigowashere.aismap.models.AISVessel; +import com.grigowashere.aismap.utils.LogSender; + +import java.util.List; +import java.util.ArrayList; + +/** + * Контроллер для парсинга NMEA сообщений + * Работает в гибридном режиме: координаты через Location API, остальное через NMEA + * + * ВАЖНО: Размеры судна в AIS сообщениях рассчитываются относительно положения антенны: + * - Длина = Dim.A + Dim.B (от носа до антенны + от антенны до кормы) + * - Ширина = Dim.C + Dim.D (от левого борта до антенны + от антенны до правого борта) + * Координаты в AIS указывают положение антенны, а не центра судна. + */ + +/** + * Контроллер для парсинга NMEA сообщений + * Использует простой разбор по запятым вместо регулярных выражений + */ +public class NMEAParser { + + private static final String TAG = "NMEAParser"; + + private Vessel ownVessel; + private List aisVessels; + private NMEAParserListener listener; + private GPSLocationListener gpsLocationListener; + + // Поля для работы с AIS фрагментами + private java.util.Map> aisFragments = new java.util.HashMap<>(); + private java.util.Map aisFragmentTimestamps = new java.util.HashMap<>(); + private static final long AIS_FRAGMENT_TIMEOUT = 10000; // 10 секунд + + // Флаг для гибридного режима + private boolean hybridMode = true; + + // Поля для отслеживания спутников по системам + private int gpsSatellites = 0; + private int glonassSatellites = 0; + private int galileoSatellites = 0; + + public interface NMEAParserListener { + void onVesselUpdated(Vessel vessel); + void onAISVesselUpdated(AISVessel vessel); + void onParseError(String error); + void onDOPUpdated(double pdop, double hdop, double vdop); + } + + public NMEAParser() { + this.ownVessel = new Vessel(); + this.aisVessels = new ArrayList<>(); + } + + public void setListener(NMEAParserListener listener) { + this.listener = listener; + } + + /** + * Устанавливает GPS Location Listener для гибридного режима + */ + public void setGPSLocationListener(GPSLocationListener gpsLocationListener) { + this.gpsLocationListener = gpsLocationListener; + } + + /** + * Включает/выключает гибридный режим + */ + public void setHybridMode(boolean enabled) { + this.hybridMode = enabled; + Log.i(TAG, "🔄 Гибридный режим: " + (enabled ? "включен" : "отключен")); + Log.i(TAG, "📍 В режиме " + (enabled ? "гибридном" : "только NMEA") + " координаты будут " + + (enabled ? "браться из Android GPS API" : "браться из NMEA сообщений")); + } + + /** + * Парсит NMEA сообщение + */ + public void parseNMEA(String nmeaSentence) { + if (nmeaSentence == null || nmeaSentence.trim().isEmpty()) { + return; + } + + // Очищаем сообщение от лишних символов + String cleanedSentence = cleanNMEASentence(nmeaSentence); + if (cleanedSentence == null) { + Log.w(TAG, "NMEA сообщение не удалось очистить или слишком короткое: " + nmeaSentence); + return; + } + Log.d(TAG, "Парсим NMEA: " + cleanedSentence); + + // Отправляем NMEA сообщение на внешний ресурс + LogSender.logNMEA(cleanedSentence); + + try { + // Разбираем сообщение по запятым + String[] fields = cleanedSentence.split(","); + if (fields.length < 2) { + Log.w(TAG, "NMEA сообщение слишком короткое: " + cleanedSentence); + return; + } + + // Извлекаем приамбуду (первые 6 символов после $) + String preamble = fields[0]; + if (preamble.length() < 6) { + Log.w(TAG, "Некорректная приамбула: " + preamble); + return; + } + + // Определяем тип сообщения по последним трем символам приамбуды + String messageType = preamble.substring(preamble.length() - 3); + + switch (messageType) { + case "GGA": + parseGGA(fields); + break; + case "RMC": + parseRMC(fields); + break; + case "VTG": + parseVTG(fields); + break; + case "GLL": + parseGLL(fields); + break; + case "GSV": + parseGSV(fields); + break; + case "GNS": + parseGNS(fields); + break; + case "GSA": + parseGSA(fields); + break; + case "ZDA": + parseZDA(fields); + break; + default: + // Проверяем AIS сообщения + if (cleanedSentence.startsWith("!AIVDM")) { + parseAIS(cleanedSentence); + } else { + Log.d(TAG, "Неподдерживаемый тип NMEA сообщения: " + messageType); + } + break; + } + } catch (Exception e) { + Log.e(TAG, "Ошибка парсинга NMEA: " + e.getMessage(), e); + if (listener != null) { + listener.onParseError("Ошибка парсинга NMEA: " + e.getMessage()); + } + } + } + + /** + * Безопасно получает поле по индексу + */ + private String getField(String[] fields, int index) { + if (index < fields.length && !fields[index].trim().isEmpty()) { + return fields[index].trim(); + } + return null; + } + + /** + * Безопасно парсит double значение из поля + */ + private double parseDoubleField(String[] fields, int index, double defaultValue) { + String field = getField(fields, index); + if (field != null) { + try { + return Double.parseDouble(field); + } catch (NumberFormatException e) { + Log.w(TAG, "Не удалось распарсить double из поля " + index + ": '" + field + "'"); + } + } + return defaultValue; + } + + /** + * Безопасно парсит int значение из поля + */ + private int parseIntField(String[] fields, int index, int defaultValue) { + String field = getField(fields, index); + if (field != null) { + try { + return Integer.parseInt(field); + } catch (NumberFormatException e) { + Log.w(TAG, "Не удалось распарсить int из поля " + index + ": '" + field + "'"); + } + } + return defaultValue; + } + + /** + * Очищает NMEA сообщение от лишних символов + */ + private String cleanNMEASentence(String sentence) { + if (sentence == null || sentence.trim().isEmpty()) { + return null; + } + + // Убираем пробелы в начале и конце + String cleaned = sentence.trim(); + + // Проверяем минимальную длину NMEA сообщения + if (cleaned.length() < 6) { // Минимум: $GPGGA*XX + Log.w(TAG, "Слишком короткое NMEA сообщение: '" + cleaned + "'"); + return null; + } + + // Исправляем двойной $ ($$GNGGA -> $GNGGA) + if (cleaned.startsWith("$$")) { + cleaned = cleaned.substring(1); + Log.d(TAG, "Исправлен двойной $: " + cleaned); + } + + // Обрабатываем смешанные сообщения (например, VTG содержит GGA) + if (cleaned.contains("$G") && cleaned.indexOf("$G") > 0) { + // Находим первое полное NMEA сообщение + int firstDollar = cleaned.indexOf("$G"); + if (firstDollar > 0) { + String firstMessage = cleaned.substring(firstDollar); + int asteriskIndex = firstMessage.indexOf('*'); + if (asteriskIndex > 0) { + // Проверяем, что после * есть достаточно символов для контрольной суммы + if (asteriskIndex + 2 < firstMessage.length()) { + cleaned = firstMessage.substring(0, asteriskIndex + 3); + } else if (asteriskIndex + 1 < firstMessage.length()) { + cleaned = firstMessage.substring(0, asteriskIndex + 2); + } else { + cleaned = firstMessage.substring(0, asteriskIndex + 1); + } + Log.d(TAG, "Извлечено первое NMEA сообщение: " + cleaned); + } + } + } + + // Убираем все символы после последнего * + int asteriskIndex = cleaned.lastIndexOf('*'); + if (asteriskIndex >= 0) { + // Проверяем, что после * есть достаточно символов для контрольной суммы + if (asteriskIndex + 2 < cleaned.length()) { + cleaned = cleaned.substring(0, asteriskIndex + 3); // включаем * и 2 символа контрольной суммы + } else if (asteriskIndex + 1 < cleaned.length()) { + cleaned = cleaned.substring(0, asteriskIndex + 2); // включаем * и 1 символ контрольной суммы + } else { + cleaned = cleaned.substring(0, asteriskIndex + 1); // включаем только * + } + } + + // Убираем все непечатаемые символы + cleaned = cleaned.replaceAll("[^\\x20-\\x7E]", ""); + + Log.d(TAG, "Очищено NMEA: '" + cleaned + "' (длина: " + cleaned.length() + ")"); + + return cleaned; + } + + /** + * Парсит GGA сообщение (Global Positioning System Fix Data) + * В гибридном режиме используем только количество спутников и высоту + * Формат: $GPGGA,time,lat,N/S,lon,E/W,quality,numSV,HDOP,alt,M,sep,M,diffAge,diffStation*checksum + */ + private void parseGGA(String[] fields) { + Log.d(TAG, "Парсим GGA с " + fields.length + " полями"); + + // Поле 7: количество спутников + int satellites = parseIntField(fields, 7, 0); + + // Поле 9: высота над эллипсоидом + double altitude = parseDoubleField(fields, 9, 0.0); + + Log.d(TAG, String.format("GGA: sat=%d, alt=%.1f", satellites, altitude)); + + // В гибридном режиме не обновляем координаты + if (!hybridMode) { + // Поля 2,3: широта и направление + String latStr = getField(fields, 2); + String latDir = getField(fields, 3); + if (latStr != null && latDir != null) { + double latitude = parseCoordinate(latStr, latDir.equals("N")); + ownVessel.setLatitude(latitude); + } + + // Поля 4,5: долгота и направление + String lonStr = getField(fields, 4); + String lonDir = getField(fields, 5); + if (lonStr != null && lonDir != null) { + double longitude = parseCoordinate(lonStr, lonDir.equals("E")); + ownVessel.setLongitude(longitude); + } + } + + ownVessel.setSatellites(satellites); + ownVessel.setAltitude(altitude); + + // Синхронизируем с GPSLocationListener для получения активных спутников + if (gpsLocationListener != null) { + gpsLocationListener.setSatellitesInVessel(ownVessel); + } + + if (listener != null) { + listener.onVesselUpdated(ownVessel); + } + } + + /** + * Парсит RMC сообщение (Recommended Minimum Navigation Information) + * В гибридном режиме используем только курс и скорость + * Формат: $GPRMC,time,status,lat,N/S,lon,E/W,speed,course,date,magVar,E/W,mode*checksum + */ + private void parseRMC(String[] fields) { + Log.d(TAG, "Парсим RMC с " + fields.length + " полями"); + + // Поле 2: статус валидности (A = валидный, V = невалидный) + String status = getField(fields, 2); + boolean isValid = status != null && status.startsWith("A"); + Log.d(TAG, "RMC статус: " + status + " (валидный: " + isValid + ")"); + + // Поле 7: скорость в узлах + double speed = parseDoubleField(fields, 7, 0.0); + + // Поле 8: курс в градусах + double course = parseDoubleField(fields, 8, 0.0); + + Log.d(TAG, String.format("RMC: speed=%.1f, course=%.1f, valid=%s", speed, course, isValid)); + + // В гибридном режиме не обновляем координаты + if (!hybridMode && isValid) { + Log.d(TAG, "Режим НЕ гибридный - обрабатываем координаты из RMC"); + + // Поля 3,4: широта и направление + String latStr = getField(fields, 3); + String latDir = getField(fields, 4); + if (latStr != null && latDir != null) { + double latitude = parseCoordinate(latStr, latDir.equals("N")); + Log.d(TAG, "RMC широта: " + latStr + " " + latDir + " = " + latitude); + ownVessel.setLatitude(latitude); + } + + // Поля 5,6: долгота и направление + String lonStr = getField(fields, 5); + String lonDir = getField(fields, 6); + if (lonStr != null && lonDir != null) { + double longitude = parseCoordinate(lonStr, lonDir.equals("E")); + Log.d(TAG, "RMC долгота: " + lonStr + " " + lonDir + " = " + longitude); + ownVessel.setLongitude(longitude); + } + } else if (hybridMode) { + Log.d(TAG, "Гибридный режим - координаты из RMC игнорируются"); + } else { + Log.d(TAG, "RMC данные невалидны (статус V) - координаты не обновляем"); + } + + // Обновляем скорость и курс только если данные валидны + if (isValid) { + ownVessel.setSpeed(speed); + ownVessel.setCourse(course); + } + + Log.d(TAG, "RMC обновлено судно: lat=" + ownVessel.getLatitude() + + ", lon=" + ownVessel.getLongitude() + + ", speed=" + speed + + ", course=" + course); + + if (listener != null) { + listener.onVesselUpdated(ownVessel); + } + } + + /** + * Парсит VTG сообщение (Course Over Ground and Ground Speed) + * Формат: $GPVTG,course,T,course,M,speed,N,speed,K,mode*checksum + */ + private void parseVTG(String[] fields) { + Log.d(TAG, "Парсим VTG с " + fields.length + " полями"); + + // Поле 1: курс в градусах (True) + double course = parseDoubleField(fields, 1, 0.0); + + // Поле 5: скорость в узлах + double speed = parseDoubleField(fields, 5, 0.0); + + Log.d(TAG, String.format("VTG: course=%.1f, speed=%.1f", course, speed)); + + ownVessel.setCourse(course); + ownVessel.setSpeed(speed); + + if (listener != null) { + listener.onVesselUpdated(ownVessel); + } + } + + /** + * Парсит GLL сообщение (Geographic Position - Latitude/Longitude) + * В гибридном режиме игнорируем + * Формат: $GPGLL,lat,N/S,lon,E/W,time,status,mode*checksum + */ + private void parseGLL(String[] fields) { + if (hybridMode) { + Log.d(TAG, "GLL игнорируется в гибридном режиме"); + return; + } + + Log.d(TAG, "Парсим GLL с " + fields.length + " полями"); + + // Поля 1,2: широта и направление + String latStr = getField(fields, 1); + String latDir = getField(fields, 2); + if (latStr != null && latDir != null) { + double latitude = parseCoordinate(latStr, latDir.equals("N")); + ownVessel.setLatitude(latitude); + } + + // Поля 3,4: долгота и направление + String lonStr = getField(fields, 3); + String lonDir = getField(fields, 4); + if (lonStr != null && lonDir != null) { + double longitude = parseCoordinate(lonStr, lonDir.equals("E")); + ownVessel.setLongitude(longitude); + } + + Log.d(TAG, String.format("GLL: lat=%.6f, lon=%.6f", ownVessel.getLatitude(), ownVessel.getLongitude())); + + if (listener != null) { + listener.onVesselUpdated(ownVessel); + } + } + + /** + * Парсит GSV сообщение (GPS Satellites in View) + * Формат: $GPGSV,totalMsgs,msgNum,totalSats,satId1,elev1,azim1,snr1,satId2,elev2,azim2,snr2,...*checksum + */ + private void parseGSV(String[] fields) { + Log.d(TAG, "Парсим GSV с " + fields.length + " полями"); + + // Поля 1,2,3: общее количество сообщений, номер сообщения, общее количество спутников + int totalMessages = parseIntField(fields, 1, 1); + int messageNumber = parseIntField(fields, 2, 1); + int satellitesInView = parseIntField(fields, 3, 0); + + // Определяем тип системы спутников по приамбуде + String systemType = "Unknown"; + String preamble = fields[0]; + if (preamble.startsWith("$GPGSV")) { + systemType = "GPS"; + } else if (preamble.startsWith("$GLGSV")) { + systemType = "GLONASS"; + } else if (preamble.startsWith("$GAGSV")) { + systemType = "Galileo"; + } else if (preamble.startsWith("$GBGSV")) { + systemType = "BeiDou"; + } + + Log.d(TAG, String.format("GSV [%s]: %d/%d, спутников в поле зрения: %d", + systemType, messageNumber, totalMessages, satellitesInView)); + + // Парсим данные о спутниках (начиная с поля 4, каждые 4 поля = 1 спутник) + for (int i = 4; i < fields.length - 1; i += 4) { // -1 чтобы исключить контрольную сумму + if (i + 3 < fields.length) { + String satId = getField(fields, i); + String elevation = getField(fields, i + 1); + String azimuth = getField(fields, i + 2); + String snr = getField(fields, i + 3); + + if (satId != null) { + Log.d(TAG, String.format("Спутник %s: elev=%s, azim=%s, SNR=%s", + satId, elevation, azimuth, snr)); + } + } + } + + // Обновляем количество спутников только для последнего сообщения в серии + if (messageNumber == totalMessages) { + // Обновляем количество спутников для соответствующей системы + switch (systemType) { + case "GPS": + gpsSatellites = satellitesInView; + break; + case "GLONASS": + glonassSatellites = satellitesInView; + break; + case "Galileo": + galileoSatellites = satellitesInView; + break; + case "BeiDou": + // Пока не добавляем отдельный счетчик для BeiDou, считаем как GPS + gpsSatellites = Math.max(gpsSatellites, satellitesInView); + break; + } + + // Обновляем общее количество спутников + int totalSatellites = gpsSatellites + glonassSatellites + galileoSatellites; + ownVessel.setSatellites(totalSatellites); + + // Синхронизируем с GPSLocationListener для получения активных спутников + if (gpsLocationListener != null) { + gpsLocationListener.setSatellitesInVessel(ownVessel); + } + + Log.d(TAG, String.format("GSV [%s] завершен: %d спутников. Общий счет: GPS=%d, GLONASS=%d, Galileo=%d, Всего=%d", + systemType, satellitesInView, gpsSatellites, glonassSatellites, galileoSatellites, totalSatellites)); + + if (listener != null) { + listener.onVesselUpdated(ownVessel); + } + } + } + + /** + * Парсит GNS сообщение (GNSS Fix Data) + * В гибридном режиме используем только количество спутников и высоту + * Формат: $GNGNS,time,lat,N/S,lon,E/W,mode,numSV,HDOP,alt,sep,diffAge,diffStation,navStatus*checksum + */ + private void parseGNS(String[] fields) { + Log.d(TAG, "Парсим GNS с " + fields.length + " полями"); + + // Поле 7: количество спутников + int satellites = parseIntField(fields, 7, 0); + + // Поле 9: высота над эллипсоидом + double altitude = parseDoubleField(fields, 9, 0.0); + + Log.d(TAG, String.format("GNS: sat=%d, alt=%.1f", satellites, altitude)); + + // В гибридном режиме не обновляем координаты + if (!hybridMode) { + // Поля 2,3: широта и направление + String latStr = getField(fields, 2); + String latDir = getField(fields, 3); + if (latStr != null && latDir != null) { + double latitude = parseCoordinate(latStr, latDir.equals("N")); + ownVessel.setLatitude(latitude); + } + + // Поля 4,5: долгота и направление + String lonStr = getField(fields, 4); + String lonDir = getField(fields, 5); + if (lonStr != null && lonDir != null) { + double longitude = parseCoordinate(lonStr, lonDir.equals("E")); + ownVessel.setLongitude(longitude); + } + } + + ownVessel.setSatellites(satellites); + ownVessel.setAltitude(altitude); + + // Синхронизируем с GPSLocationListener для получения активных спутников + if (gpsLocationListener != null) { + gpsLocationListener.setSatellitesInVessel(ownVessel); + } + + if (listener != null) { + listener.onVesselUpdated(ownVessel); + } + } + + /** + * Парсит ZDA сообщение (Date and Time) + * Формат: $GPZDA,time,day,month,year,timezoneHours,timezoneMinutes*checksum + */ + private void parseZDA(String[] fields) { + Log.d(TAG, "Парсим ZDA с " + fields.length + " полями"); + + try { + // Поле 1: время (HHMMSS.SS) + String timeStr = getField(fields, 1); + + // Поля 2,3,4: день, месяц, год + int day = parseIntField(fields, 2, 0); + int month = parseIntField(fields, 3, 0); + int year = parseIntField(fields, 4, 0); + + // Поля 5,6: часовой пояс (часы и минуты) + int timezoneHours = parseIntField(fields, 5, 0); + int timezoneMinutes = parseIntField(fields, 6, 0); + + Log.d(TAG, String.format("ZDA: %04d-%02d-%02d %s, TZ: %+03d:%02d", + year, month, day, timeStr, timezoneHours, timezoneMinutes)); + + // Обновляем время последнего обновления + ownVessel.setLastUpdate(java.time.LocalDateTime.now()); + + if (listener != null) { + listener.onVesselUpdated(ownVessel); + } + + } catch (Exception e) { + Log.w(TAG, "Ошибка парсинга ZDA: " + e.getMessage()); + } + } + + /** + * Парсит GSA сообщение (GPS DOP and Active Satellites) + * КЛЮЧЕВОЕ сообщение для получения DOP и активных спутников + * Формат: $GPGSA,mode,fixType,sat1,sat2,...,sat12,PDOP,HDOP,VDOP*checksum + */ + private void parseGSA(String[] fields) { + Log.d(TAG, "Парсим GSA с " + fields.length + " полями"); + + // Подсчитываем активные спутники (поля 3-14 содержат ID спутников) + int activeSatellites = 0; + for (int i = 3; i <= 14 && i < fields.length; i++) { + String satId = getField(fields, i); + if (satId != null && !satId.equals("0")) { + activeSatellites++; + Log.d(TAG, "Активный спутник: " + satId); + } + } + + // Получаем DOP значения - могут быть в разных позициях в зависимости от количества полей + double pdop = 0.0; + double hdop = 0.0; + double vdop = 0.0; + + // DOP значения обычно в последних полях перед контрольной суммой + if (fields.length >= 17) { + // Полное GSA сообщение + pdop = parseDoubleField(fields, 15, 0.0); // PDOP + hdop = parseDoubleField(fields, 16, 0.0); // HDOP + vdop = parseDoubleField(fields, 17, 0.0); // VDOP + } else if (fields.length >= 6) { + // Обрезанное GSA сообщение - DOP в последних полях + int dopStartIndex = fields.length - 4; // -4 чтобы исключить контрольную сумму + if (dopStartIndex >= 3) { + pdop = parseDoubleField(fields, dopStartIndex, 0.0); + hdop = parseDoubleField(fields, dopStartIndex + 1, 0.0); + vdop = parseDoubleField(fields, dopStartIndex + 2, 0.0); + } + } + + Log.d(TAG, String.format("GSA: активных спутников=%d, PDOP=%.2f, HDOP=%.2f, VDOP=%.2f", + activeSatellites, pdop, hdop, vdop)); + + // Обновляем информацию о спутниках + ownVessel.setActiveSatellites(activeSatellites); + ownVessel.setPdop(pdop); + ownVessel.setHdop(hdop); + ownVessel.setVdop(vdop); + + // Отправляем DOP значения в GPS Location Listener + if (gpsLocationListener != null) { + gpsLocationListener.setDOPValues(pdop, hdop, vdop); + // Синхронизируем с GPSLocationListener для получения активных спутников + gpsLocationListener.setSatellitesInVessel(ownVessel); + } + + // Уведомляем слушателя о DOP + if (listener != null) { + listener.onDOPUpdated(pdop, hdop, vdop); + listener.onVesselUpdated(ownVessel); + } + } + + /** + * Парсит AIS сообщение (Automatic Identification System) + * Формат: !AIVDM,totalFragments,fragmentNumber,sequenceId,channel,payload,fillBits*checksum + */ + private void parseAIS(String ais) { + Log.d(TAG, "Парсим AIS: " + ais); + + // Разбираем AIS сообщение по запятым + String[] fields = ais.split(","); + Log.d(TAG, "AIS поля (" + fields.length + "): " + java.util.Arrays.toString(fields)); + if (fields.length < 7) { + Log.w(TAG, "AIS сообщение слишком короткое: " + ais); + return; + } + + try { + // Поля 1,2: общее количество фрагментов, номер фрагмента + int totalFragments = parseIntField(fields, 1, 1); + int fragmentNumber = parseIntField(fields, 2, 1); + + // Поле 3: ID последовательности + String sequenceId = getField(fields, 3); + + // Поле 4: канал (A или B) + String channel = getField(fields, 4); + + // Поле 5: payload (данные) + String payload = getField(fields, 5); + + // Поле 6: количество бит заполнения (может содержать *checksum) + String fillBitsField = getField(fields, 6); + int fillBits = 0; + if (fillBitsField != null) { + // Если поле содержит *, берем только часть до * + if (fillBitsField.contains("*")) { + fillBitsField = fillBitsField.split("\\*")[0]; + } + try { + fillBits = Integer.parseInt(fillBitsField); + } catch (NumberFormatException e) { + Log.w(TAG, "Не удалось распарсить fillBits из поля 6: '" + fillBitsField + "'"); + } + } + + // Контрольная сумма находится в последнем поле после * + String lastField = fields[fields.length - 1]; + String checksum = null; + if (lastField != null && lastField.contains("*")) { + String[] parts = lastField.split("\\*"); + if (parts.length > 1) { + checksum = parts[1]; + } + } + + Log.d(TAG, String.format("AIS: %d/%d, seq='%s', ch='%s', payload='%s', fillBits=%d, checksum='%s'", + fragmentNumber, totalFragments, sequenceId, channel, payload, fillBits, checksum)); + + // Проверяем контрольную сумму + if (!validateChecksum(ais)) { + Log.w(TAG, "AIS сообщение с неверной контрольной суммой: " + ais); + return; + } + + // Проверяем, что payload не пустой + if (payload != null && !payload.trim().isEmpty()) { + if (totalFragments == 1) { + // Одноканальное сообщение - декодируем сразу + decodeAISPayload(payload, channel != null && channel.equals("A") ? 0 : 1); + } else { + // Многочастное сообщение - собираем фрагменты + // Используем номер фрагмента как sequenceId если поле пустое + String actualSequenceId = (sequenceId != null && !sequenceId.trim().isEmpty()) ? + sequenceId : String.valueOf(fragmentNumber); + collectAISFragments(actualSequenceId, fragmentNumber, totalFragments, payload, channel != null && channel.equals("A") ? 0 : 1); + } + } else { + Log.w(TAG, "AIS payload пустой, пропускаем сообщение"); + } + } catch (Exception e) { + Log.e(TAG, "Ошибка парсинга AIS сообщения: " + e.getMessage() + " для сообщения: " + ais); + if (listener != null) { + listener.onParseError("Ошибка парсинга AIS: " + e.getMessage()); + } + } + } + + /** + * Декодирует AIS payload + */ + private void decodeAISPayload(String payload, int channel) { + try { + // Определяем тип AIS сообщения по первым 6 битам + String messageTypeBits = decodeAISField(payload, 0, 6); + int messageType = Integer.parseInt(messageTypeBits, 2); + + Log.d(TAG, "Декодируем AIS тип " + messageType + " на канале " + channel + " (биты: " + messageTypeBits + ")"); + + switch (messageType) { + case 1: + case 2: + case 3: + // Position Report + Log.d(TAG, "Обрабатываем Position Report (тип " + messageType + ")"); + decodePositionReport(payload, messageType); + break; + case 5: + // Static Data + Log.d(TAG, "Обрабатываем Static Data (тип " + messageType + ")"); + decodeStaticData(payload); + break; + case 4: // Base Station Report + Log.d(TAG, "Обрабатываем Base Station Report (тип " + messageType + ")"); + decodeBaseStationReport(payload); + break; + case 14: // Safety Related Broadcast Message + Log.d(TAG, "Обрабатываем Safety Broadcast (тип " + messageType + ")"); + decodeSafetyBroadcast(payload); + break; + case 18: // Standard Class B Equipment Position Report + Log.d(TAG, "Обрабатываем Class B Position Report (тип " + messageType + ")"); + decodeClassBPositionReport(payload); + break; + case 19: // Extended Class B Equipment Position Report + Log.d(TAG, "Обрабатываем Extended Class B Position Report (тип " + messageType + ")"); + decodeExtendedClassBPositionReport(payload); + break; + case 21: // Aid-to-Navigation Report + Log.d(TAG, "Обрабатываем Aid-to-Navigation Report (тип " + messageType + ")"); + decodeAidToNavigationReport(payload); + break; + case 24: // Static Data Report + Log.d(TAG, "Обрабатываем Static Data Report (тип " + messageType + ")"); + decodeStaticDataReport(payload); + break; + default: + Log.d(TAG, "Неподдерживаемый тип AIS сообщения: " + messageType); + break; + } + } catch (Exception e) { + Log.e(TAG, "Ошибка декодирования AIS payload: " + e.getMessage(), e); + } + } + + /** + * Собирает фрагменты многочастного AIS сообщения + */ + private void collectAISFragments(String sequenceId, int fragmentNumber, int totalFragments, + String payload, int channel) { + String key = sequenceId + "_" + channel; + + Log.d(TAG, String.format("Собираем AIS фраг мент: %d/%d для %s", + fragmentNumber, totalFragments, key)); + + // Очищаем старые фрагменты + cleanupOldFragments(); + + // Получаем или создаем карту фрагментов для этой последовательности + java.util.Map fragments = aisFragments.get(key); + if (fragments == null) { + fragments = new java.util.HashMap<>(); + aisFragments.put(key, fragments); + aisFragmentTimestamps.put(key, System.currentTimeMillis()); + Log.d(TAG, "Создан новый набор фрагментов для: " + key); + } + + // Добавляем фрагмент + fragments.put(fragmentNumber, payload); + Log.d(TAG, String.format("Добавлен фрагмент %d/%d для %s", + fragmentNumber, totalFragments, key)); + + // Проверяем, все ли фрагменты получены + if (fragments.size() == totalFragments) { + Log.d(TAG, "Все фрагменты получены для " + key + ", собираем сообщение"); + + // Собираем полное сообщение + StringBuilder fullPayload = new StringBuilder(); + for (int i = 1; i <= totalFragments; i++) { + String fragment = fragments.get(i); + if (fragment != null) { + fullPayload.append(fragment); + } else { + Log.w(TAG, "Отсутствует фрагмент " + i + " для " + key); + return; + } + } + + String completePayload = fullPayload.toString(); + Log.d(TAG, "Собрано полное AIS сообщение длиной " + completePayload.length() + " символов"); + + // Декодируем полное сообщение + decodeAISPayload(completePayload, channel); + + // Удаляем собранные фрагменты + aisFragments.remove(key); + aisFragmentTimestamps.remove(key); + Log.d(TAG, "Фрагменты удалены для " + key); + } else { + Log.d(TAG, String.format("Ожидаем еще %d фрагментов для %s", + totalFragments - fragments.size(), key)); + } + } + + /** + * Очищает старые AIS фрагменты + */ + private void cleanupOldFragments() { + long currentTime = System.currentTimeMillis(); + java.util.Iterator> iterator = aisFragmentTimestamps.entrySet().iterator(); + + while (iterator.hasNext()) { + java.util.Map.Entry entry = iterator.next(); + if (currentTime - entry.getValue() > AIS_FRAGMENT_TIMEOUT) { + String key = entry.getKey(); + aisFragments.remove(key); + iterator.remove(); + Log.d(TAG, "Удален устаревший AIS фрагмент: " + key); + } + } + } + + /** + * Декодирует AIS поле из битовой строки + */ + private String decodeAISField(String payload, int startBit, int length) { + StringBuilder result = new StringBuilder(); + + // Преобразуем каждый символ payload в 6-битное значение + for (int i = 0; i < payload.length(); i++) { + int ascii = payload.charAt(i); + int value; + + if (ascii >= 48 && ascii <= 87) { + value = ascii - 48; // '0'..'W' + } else if (ascii >= 88 && ascii <= 119) { + value = ascii - 56; // 'X'..'w' + } else { + throw new IllegalArgumentException("Недопустимый символ AIS payload: " + (char)ascii); + } + + // Дополняем до 6 бит слева нулями и добавляем в общую строку + String binary = String.format("%6s", Integer.toBinaryString(value)).replace(' ', '0'); + result.append(binary); + } + + String fullBinary = result.toString(); + + // Вырезаем нужный диапазон битов + if (startBit + length <= fullBinary.length()) { + String fieldResult = fullBinary.substring(startBit, startBit + length); + // Дополнительное логирование для первых 6 бит (тип сообщения) + if (startBit == 0 && length == 6) { + Log.d(TAG, "AIS Message Type bits: " + fieldResult + " (payload: " + payload + ")"); + } + return fieldResult; + } else { + Log.w(TAG, + "AIS поле выходит за границы: startBit=" + startBit + + ", length=" + length + + ", payloadLength=" + payload.length() + + ", binaryLength=" + fullBinary.length() + ); + // Если поле выходит за границы, возвращаем то что есть, дополняя нулями + if (startBit >= fullBinary.length()) { + // Если startBit уже за границами, возвращаем строку из нулей + return "0".repeat(length); + } else { + // Возвращаем доступную часть, дополняя нулями до нужной длины + String available = fullBinary.substring(startBit); + if (available.length() < length) { + available += "0".repeat(length - available.length()); + } + return available; + } + } + } + + /** + * Декодирует AIS сообщение типа 1, 2, 3 (Position Report) + */ + private void decodePositionReport(String payload, int messageType) { + try { + Log.d(TAG, "Декодируем Position Report тип " + messageType + ", payload: " + payload + " (длина: " + payload.length() + ")"); + + // MMSI (30 бит) - начинается с бита 8 + String mmsiBits = decodeAISField(payload, 8, 30); + int mmsi = Integer.parseInt(mmsiBits, 2); + Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi); + + // Navigation Status (4 бита) - бит 38 + String statusBits = decodeAISField(payload, 38, 4); + int status = Integer.parseInt(statusBits, 2); + Log.d(TAG, "Status bits: " + statusBits + " = " + status); + + // Rate of Turn (8 бит) - бит 42 + String rotBits = decodeAISField(payload, 42, 8); + double rateOfTurn = parseAISRateOfTurn(rotBits); + Log.d(TAG, "Rate of Turn bits: " + rotBits + " = " + rateOfTurn + " °/мин"); + + // Speed Over Ground (10 бит) - бит 50 + String speedBits = decodeAISField(payload, 50, 10); + double speed = Integer.parseInt(speedBits, 2) / 10.0; + Log.d(TAG, "Speed bits: " + speedBits + " = " + speed); + + // Position Accuracy (1 бит) - бит 60 + String accuracyBits = decodeAISField(payload, 60, 1); + int accuracy = Integer.parseInt(accuracyBits, 2); + Log.d(TAG, "Accuracy bits: " + accuracyBits + " = " + accuracy); + + // Longitude (28 бит) - бит 61 + String lonBits = decodeAISField(payload, 61, 28); + double longitude = parseAISCoordinate(lonBits, 28); + Log.d(TAG, "Longitude bits: " + lonBits + " (длина: " + lonBits.length() + ") = " + longitude); + + // Latitude (27 бит) - бит 89 + String latBits = decodeAISField(payload, 89, 27); + double latitude = parseAISCoordinate(latBits, 27); + Log.d(TAG, "Latitude bits: " + latBits + " (длина: " + latBits.length() + ") = " + latitude); + + // Course Over Ground (12 бит) - бит 116 + String courseBits = decodeAISField(payload, 116, 12); + double course = Integer.parseInt(courseBits, 2) / 10.0; + Log.d(TAG, "Course bits: " + courseBits + " = " + course); + + // True Heading (9 бит) - бит 128 + String headingBits = decodeAISField(payload, 128, 9); + double heading = Integer.parseInt(headingBits, 2); + Log.d(TAG, "Heading bits: " + headingBits + " = " + heading); + + // Time Stamp (6 бит) - бит 137 + String timestampBits = decodeAISField(payload, 137, 6); + int timestamp = Integer.parseInt(timestampBits, 2); + Log.d(TAG, "Timestamp bits: " + timestampBits + " = " + timestamp); + + // Проверяем, что координаты в разумных пределах + if (latitude < -90 || latitude > 90) { + Log.w(TAG, "Широта вне допустимых пределов: " + latitude); + } + if (longitude < -180 || longitude > 180) { + Log.w(TAG, "Долгота вне допустимых пределов: " + longitude); + } + + Log.d(TAG, String.format("AIS Position: MMSI=%d, lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, status=%d, heading=%.1f, ROT=%.1f", + mmsi, latitude, longitude, course, speed, status, heading, rateOfTurn)); + + // Создаем или обновляем AIS судно + AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); + vessel.updatePosition(latitude, longitude, course, speed, rateOfTurn); + vessel.setHeading(heading); + vessel.setNavigationalStatus(getNavigationStatus(status)); + vessel.setLastUpdate(java.time.LocalDateTime.now()); + + // Отправляем информацию о корабле на внешний ресурс + String vesselInfo = String.format("lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, status=%s, ROT=%.1f", + latitude, longitude, course, speed, getNavigationStatus(status), rateOfTurn); + LogSender.logShipUpdate(String.valueOf(mmsi), vesselInfo); + + // Уведомляем слушателя + if (listener != null) { + listener.onAISVesselUpdated(vessel); + } + + } catch (Exception e) { + Log.e(TAG, "Ошибка декодирования Position Report: " + e.getMessage(), e); + } + } + + /** + * Декодирует AIS сообщение типа 5 (Static Data) + */ + private void decodeStaticData(String payload) { + try { + Log.d(TAG, "Декодируем Static Data, payload: " + payload + " (длина: " + payload.length() + ")"); + Log.d(TAG, "Общая длина в битах: " + (payload.length() * 6)); + + // MMSI (30 бит) - начинается с бита 8 + String mmsiBits = decodeAISField(payload, 8, 30); + int mmsi = Integer.parseInt(mmsiBits, 2); + Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi); + + // AIS Version (2 бита) - бит 38 + String aisVersionBits = decodeAISField(payload, 38, 2); + int aisVersion = Integer.parseInt(aisVersionBits, 2); + Log.d(TAG, "AIS Version bits: " + aisVersionBits + " = " + aisVersion); + + // IMO Number (30 бит) - бит 40 + String imoBits = decodeAISField(payload, 40, 30); + int imo = Integer.parseInt(imoBits, 2); + Log.d(TAG, "IMO bits: " + imoBits + " = " + imo); + + // Call Sign (42 бита) - бит 70 + String callSignBits = decodeAISField(payload, 70, 42); + String callSign = decodeAISString(callSignBits); + Log.d(TAG, "Call Sign bits: " + callSignBits + " = '" + callSign + "'"); + + // Vessel Name (120 бит) - бит 112 + String nameBits = decodeAISField(payload, 112, 120); + String vesselName = decodeAISString(nameBits); + Log.d(TAG, "Name bits: " + nameBits + " = '" + vesselName + "'"); + + // Ship Type (8 бит) - бит 232 + String typeBits = decodeAISField(payload, 232, 8); + int vesselTypeCode = Integer.parseInt(typeBits, 2); + Log.d(TAG, "Type bits: " + typeBits + " = " + vesselTypeCode); + + // Dimension Reference (9, 9, 6, 6 бит) - бит 240 + String dimRefABits = decodeAISField(payload, 240, 9); + String dimRefBBits = decodeAISField(payload, 249, 9); + String dimRefCBits = decodeAISField(payload, 258, 6); + String dimRefDBits = decodeAISField(payload, 264, 6); + + int dimRefA = Integer.parseInt(dimRefABits, 2); + int dimRefB = Integer.parseInt(dimRefBBits, 2); + int dimRefC = Integer.parseInt(dimRefCBits, 2); + int dimRefD = Integer.parseInt(dimRefDBits, 2); + + Log.d(TAG, "Dimension Reference: A=" + dimRefA + ", B=" + dimRefB + ", C=" + dimRefC + ", D=" + dimRefD); + + // Для сообщения типа 5 используем Dimension Reference поля (9, 9, 6, 6 бит) + // Размеры судна рассчитываются как: + // Длина = Dim.A + Dim.B (от носа до антенны + от антенны до кормы) + // Ширина = Dim.C + Dim.D (от левого борта до антенны + от антенны до правого борта) + double length = dimRefA + dimRefB; + double width = dimRefC + dimRefD; + + // Draft (8 бит) - осадка - бит 294 + String draftBits = decodeAISField(payload, 294, 8); + double draft = Integer.parseInt(draftBits, 2) / 10.0; + + Log.d(TAG, "Static Data - используем Dimension Reference поля (9, 9, 6, 6 бит):"); + Log.d(TAG, " Dim.A (нос-антенна): " + dimRefABits + " = " + dimRefA + " м"); + Log.d(TAG, " Dim.B (антенна-корма): " + dimRefBBits + " = " + dimRefB + " м"); + Log.d(TAG, " Dim.C (левый борт-антенна): " + dimRefCBits + " = " + dimRefC + " м"); + Log.d(TAG, " Dim.D (антенна-правый борт): " + dimRefDBits + " = " + dimRefD + " м"); + Log.d(TAG, " Total Length (A+B): " + length + " м"); + Log.d(TAG, " Total Width (C+D): " + width + " м"); + Log.d(TAG, " Draft: " + draftBits + " = " + draft + " м"); + + // ETA (20 бит) - бит 274 + String etaBits = decodeAISField(payload, 274, 20); + int eta = Integer.parseInt(etaBits, 2); + Log.d(TAG, "ETA bits: " + etaBits + " = " + eta); + + // Парсим ETA согласно стандарту: MMDDHHMM UTC + // Bits 19-16: month; 1-12; 0 = not available = default + // Bits 15-11: day; 1-31; 0 = not available = default + // Bits 10-6: hour; 0-23; 24 = not available = default + // Bits 5-0: minute; 0-59; 60 = not available = default + java.time.LocalDateTime etaDateTime = parseETA(eta); + Log.d(TAG, "ETA parsed: " + etaDateTime); + + // Вычисляем доступную длину для оставшихся полей + int totalBits = payload.length() * 6; + int remainingBits = totalBits - 294; // Остается после ETA + Log.d(TAG, "Remaining bits after ETA: " + remainingBits + " (total: " + totalBits + ")"); + + String destination = ""; + double maxDraught = 0.0; + String epfdDescription = "Unknown"; + boolean dteReady = false; + + // Destination (120 бит) - бит 302 + if (totalBits >= 302 + 120) { + String destBits = decodeAISField(payload, 302, 120); + destination = decodeAISString(destBits); + Log.d(TAG, "Destination bits: " + destBits + " = '" + destination + "'"); + } else if (remainingBits > 0) { + // Если сообщение короткое, читаем доступные биты + int destStartBit = 302; + int destLength = Math.min(remainingBits, 120); + String destBits = decodeAISField(payload, destStartBit, destLength); + destination = decodeAISString(destBits); + Log.d(TAG, "Destination bits (short): " + destBits + " = '" + destination + "' (length: " + destLength + ")"); + } + + Log.d(TAG, String.format("AIS Static: MMSI=%d, IMO=%d, name='%s', callSign='%s', type=%d, L=%.1f, W=%.1f, D=%.1f, maxD=%.1f, ETA=%s, EPFD=%s, DTE=%s, dest='%s'", + mmsi, imo, vesselName, callSign, vesselTypeCode, length, width, draft, maxDraught, etaDateTime, epfdDescription, dteReady, destination)); + + // Обновляем AIS судно + AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); + vessel.setVesselName(vesselName); + vessel.setCallSign(callSign); + vessel.setImo(imo); + vessel.setVesselType(getVesselType(vesselTypeCode)); + vessel.setLength(length); + vessel.setWidth(width); + vessel.setDraft(draft); + vessel.setDestination(destination); + vessel.setEta(etaDateTime); // Добавляем ETA в модель + vessel.setLastUpdate(java.time.LocalDateTime.now()); + + // Отправляем информацию о корабле на внешний ресурс + String vesselInfo = String.format("name='%s', callSign='%s', type=%s, L=%.1f, W=%.1f, D=%.1f, dest='%s'", + vesselName, callSign, getVesselType(vesselTypeCode), length, width, draft, destination); + LogSender.logShipUpdate(String.valueOf(mmsi), vesselInfo); + + // Уведомляем слушателя + if (listener != null) { + listener.onAISVesselUpdated(vessel); + } + + } catch (Exception e) { + Log.e(TAG, "Ошибка декодирования Static Data: " + e.getMessage(), e); + } + } + + /** + * Парсит ETA (Estimated Time of Arrival) из 20-битного значения + * Формат: MMDDHHMM UTC + * Bits 19-16: month; 1-12; 0 = not available = default + * Bits 15-11: day; 1-31; 0 = not available = default + * Bits 10-6: hour; 0-23; 24 = not available = default + * Bits 5-0: minute; 0-59; 60 = not available = default + */ + private java.time.LocalDateTime parseETA(int eta) { + if (eta == 0) { + return null; // Not available + } + + Log.d(TAG, "ETA raw value: " + eta + " (binary: " + Integer.toBinaryString(eta) + ")"); + + // Извлекаем компоненты из 20-битного значения + // Правильный порядок битов: MMMM DDDDD HHHHH MMMMMM + int month = (eta >> 16) & 0x0F; // Bits 19-16 (4 бита) + int day = (eta >> 11) & 0x1F; // Bits 15-11 (5 бит) + int hour = (eta >> 6) & 0x1F; // Bits 10-6 (5 бит) + int minute = eta & 0x3F; // Bits 5-0 (6 бит) + + Log.d(TAG, String.format("ETA components: month=%d, day=%d, hour=%d, minute=%d", + month, day, hour, minute)); + + // Проверяем на значения по умолчанию + if (month == 0 || month > 12) return null; // Not available + if (day == 0 || day > 31) return null; // Not available + if (hour == 24 || hour > 23) return null; // Not available + if (minute == 60 || minute > 59) return null; // Not available + + try { + // Создаем LocalDateTime для текущего года + int currentYear = java.time.LocalDate.now().getYear(); + java.time.LocalDateTime etaDateTime = java.time.LocalDateTime.of( + currentYear, month, day, hour, minute); + + Log.d(TAG, "ETA parsed as LocalDateTime: " + etaDateTime); + return etaDateTime; + } catch (Exception e) { + Log.w(TAG, "Ошибка создания LocalDateTime для ETA: " + e.getMessage()); + return null; + } + } + + /** + * Парсит AIS Rate of Turn (скорость поворота) + * Согласно стандарту ITU-R M.1371-5, таблица 47 + */ + private double parseAISRateOfTurn(String bits) { + int value = Integer.parseInt(bits, 2); + + // Специальные значения согласно стандарту + if (value == 0) { + return 0.0; // Не поворачивается + } else if (value == 127) { + return 0.0; // Неопределенное значение + } else if (value >= 1 && value <= 126) { + // Поворот вправо: ROT = value / 4.733 + return value / 4.733; + } else if (value >= 128 && value <= 255) { + // Поворот влево: ROT = -(value - 128) / 4.733 + return -(value - 128) / 4.733; + } else { + return 0.0; // Неопределенное значение + } + } + + /** + * Парсит AIS координаты + */ + } else if (value == 2) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 3) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 4) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 5) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 6) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 7) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 8) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 9) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 10) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 11) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 12) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 13) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 14) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 15) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 16) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 17) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 18) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 19) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 20) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 21) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 22) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 23) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 24) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 25) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 26) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 27) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 28) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 29) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 30) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 31) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 32) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 33) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 34) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 35) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 36) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 37) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 38) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 39) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 40) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 41) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 42) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 43) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 44) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 45) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 46) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 47) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 48) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 49) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 50) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 51) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 52) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 53) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 54) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 55) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 56) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 57) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 58) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 59) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 60) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 61) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 62) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 63) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 64) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 65) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 66) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 67) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 68) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 69) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 70) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 71) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 72) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 73) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 74) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 75) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 76) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 77) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 78) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 79) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 80) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 81) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 82) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 83) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 84) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 85) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 86) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 87) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 88) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 89) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 90) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 91) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 92) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 93) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 94) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 95) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 96) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 97) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 98) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 99) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 100) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 101) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 102) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 103) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 104) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 105) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 106) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 107) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 108) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 109) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 110) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 111) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 112) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 113) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 114) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 115) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 116) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 117) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 118) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 119) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 120) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 121) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 122) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 123) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 124) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 125) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 126) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 127) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 128) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 129) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 130) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 131) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 132) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 133) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 134) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 135) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 136) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 137) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 138) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 139) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 140) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 141) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 142) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 143) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 144) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 145) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 146) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 147) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 148) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 149) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 150) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 151) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 152) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 153) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 154) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 155) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 156) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 157) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 158) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 159) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 160) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 161) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 162) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 163) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 164) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 165) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 166) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 167) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 168) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 169) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 170) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 171) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 172) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 173) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 174) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 175) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 176) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 177) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 178) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 179) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 180) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 181) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 182) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 183) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 184) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 185) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 186) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 187) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 188) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 189) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 190) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 191) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 192) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 193) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 194) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 195) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 196) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 197) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 198) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 199) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 200) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 201) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 202) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 203) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 204) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 205) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 206) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 207) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 208) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 209) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 210) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 211) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 212) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 213) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 214) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 215) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 216) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 217) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 218) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 219) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 220) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 221) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 222) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 223) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 224) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 225) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 226) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 227) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 228) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 229) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 230) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 231) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 232) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 233) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 234) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 235) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 236) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 237) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 238) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 239) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 240) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 241) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 242) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 243) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 244) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 245) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 246) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 247) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 248) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 249) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 250) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 251) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 252) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 253) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else if (value == 254) { + return 0.0; // Поворачивается влево со скоростью более 5°/мин + } else if (value == 255) { + return 0.0; // Поворачивается вправо со скоростью более 5°/мин + } else { + // Для значений 1-126: поворот вправо + // Для значений 128-255: поворот влево + // Формула: ROT = (value - 128) / 4.733 + if (value >= 1 && value <= 126) { + // Поворот вправо: ROT = value / 4.733 + return value / 4.733; + } else if (value >= 128 && value <= 255) { + // Поворот влево: ROT = -(value - 128) / 4.733 + return -(value - 128) / 4.733; + } else { + return 0.0; // Неопределенное значение + } + } + } + + /** + * Парсит AIS координаты + */ + private double parseAISCoordinate(String bits, int bitLength) { + // Проверяем знаковый бит + boolean isNegative = bits.charAt(0) == '1'; + + // Преобразуем в беззнаковое число + long value = Long.parseLong(bits, 2); + + if (bitLength == 27) { + // Широта: 27 бит, диапазон -90 до +90 + if (isNegative) { + // Для отрицательных чисел применяем дополнение до двух + value = value - (1L << 27); + } + return value / 600000.0; + } else { + // Долгота: 28 бит, диапазон -180 до +180 + if (isNegative) { + // Для отрицательных чисел применяем дополнение до двух + value = value - (1L << 28); + } + return value / 600000.0; + } + } + + /** + * Декодирует AIS строку согласно стандарту ITU-R M.1371-5, таблица 44 + * Простой switch case для всех 64 возможных значений 6-битной кодировки + */ + private String decodeAISString(String bits) { + StringBuilder result = new StringBuilder(); + + Log.d(TAG, "Декодируем AIS строку, биты: " + bits + " (длина: " + bits.length() + ")"); + + for (int i = 0; i + 6 <= bits.length(); i += 6) { + String charBits = bits.substring(i, i + 6); + int value = Integer.parseInt(charBits, 2); + + char decodedChar; + // Простой switch case для всех 64 возможных значений + switch (value) { + case 0: decodedChar = ' '; break; + case 1: decodedChar = 'A'; break; + case 2: decodedChar = 'B'; break; + case 3: decodedChar = 'C'; break; + case 4: decodedChar = 'D'; break; + case 5: decodedChar = 'E'; break; + case 6: decodedChar = 'F'; break; + case 7: decodedChar = 'G'; break; + case 8: decodedChar = 'H'; break; + case 9: decodedChar = 'I'; break; + case 10: decodedChar = 'J'; break; + case 11: decodedChar = 'K'; break; + case 12: decodedChar = 'L'; break; + case 13: decodedChar = 'M'; break; + case 14: decodedChar = 'N'; break; + case 15: decodedChar = 'O'; break; + case 16: decodedChar = 'P'; break; + case 17: decodedChar = 'Q'; break; + case 18: decodedChar = 'R'; break; + case 19: decodedChar = 'S'; break; + case 20: decodedChar = 'T'; break; + case 21: decodedChar = 'U'; break; + case 22: decodedChar = 'V'; break; + case 23: decodedChar = 'W'; break; + case 24: decodedChar = 'X'; break; + case 25: decodedChar = 'Y'; break; + case 26: decodedChar = 'Z'; break; + case 27: decodedChar = '0'; break; + case 28: decodedChar = '1'; break; + case 29: decodedChar = '2'; break; + case 30: decodedChar = '3'; break; + case 31: decodedChar = '4'; break; + case 32: decodedChar = ' '; break; // пробел + case 33: decodedChar = '5'; break; + case 34: decodedChar = '6'; break; + case 35: decodedChar = '7'; break; + case 36: decodedChar = '8'; break; + case 37: decodedChar = '9'; break; + case 38: decodedChar = ' '; break; // пробел + case 39: decodedChar = ' '; break; // пробел + case 40: decodedChar = ' '; break; // пробел + case 41: decodedChar = ' '; break; // пробел + case 42: decodedChar = ' '; break; // пробел + case 43: decodedChar = ' '; break; // пробел + case 44: decodedChar = ' '; break; // пробел + case 45: decodedChar = ' '; break; // пробел + case 46: decodedChar = ' '; break; // пробел + case 47: decodedChar = ' '; break; // пробел + case 48: decodedChar = '0'; break; // пробел + case 49: decodedChar = '1'; break; // пробел + case 50: decodedChar = '2'; break; // пробел + case 51: decodedChar = '3'; break; // пробел + case 52: decodedChar = '4'; break; // пробел + case 53: decodedChar = '5'; break; // пробел + case 54: decodedChar = '6'; break; // пробел + case 55: decodedChar = '7'; break; // пробел + case 56: decodedChar = '8'; break; // пробел + case 57: decodedChar = '9'; break; // пробел + case 58: decodedChar = ' '; break; // пробел + case 59: decodedChar = ' '; break; // пробел + case 60: decodedChar = ' '; break; // пробел + case 61: decodedChar = ' '; break; // пробел + case 62: decodedChar = ' '; break; // пробел + case 63: decodedChar = ' '; break; // пробел + default: decodedChar = ' '; break; // на всякий случай + } + + Log.d(TAG, "Символ " + (i/6 + 1) + ": биты=" + charBits + ", значение=" + value + ", символ='" + decodedChar + "'"); + result.append(decodedChar); + } + + String resultStr = result.toString().trim(); + Log.d(TAG, "Результат декодирования: '" + resultStr + "'"); + return resultStr; + } + + /** + * Получает навигационный статус по коду + */ + private String getNavigationStatus(int status) { + switch (status) { + case 0: return "Under way using engine"; + case 1: return "At anchor"; + case 2: return "Not under command"; + case 3: return "Restricted manoeuvrability"; + case 4: return "Constrained by her draught"; + case 5: return "Moored"; + case 6: return "Aground"; + case 7: return "Engaged in fishing"; + case 8: return "Under way sailing"; + case 9: return "Reserved"; + case 10: return "Reserved"; + case 11: return "Reserved"; + case 12: return "Reserved"; + case 13: return "Reserved"; + case 14: return "AIS-SART"; + case 15: return "Not defined"; + default: return "Unknown"; + } + } + + /** + * Получает описание типа электронного устройства позиционирования + */ + private String getEPFDType(int epfdType) { + 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"; + } + } + + /** + * Получает тип судна по коду согласно стандарту AIS + */ + private String getVesselType(int typeCode) { + switch (typeCode) { + case 0: return "Not available"; + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + case 8: + case 9: + case 10: + case 11: + case 12: + case 13: + case 14: + case 15: + case 16: + case 17: + case 18: + case 19: return "Reserved for future use"; + case 20: return "Wing in ground (WIG), all ships"; + case 21: return "Wing in ground (WIG), Hazardous category A"; + case 22: return "Wing in ground (WIG), Hazardous category B"; + case 23: return "Wing in ground (WIG), Hazardous category C"; + case 24: return "Wing in ground (WIG), Hazardous category D"; + case 25: + case 26: + case 27: + case 28: + case 29: return "Wing in ground (WIG), Reserved"; + case 30: return "Fishing"; + case 31: return "Towing"; + case 32: return "Towing: length exceeds 200m or breadth exceeds 25m"; + case 33: return "Dredging or underwater ops"; + case 34: return "Diving ops"; + case 35: return "Military ops"; + case 36: return "Sailing"; + case 37: return "Pleasure Craft"; + case 38: + case 39: return "Reserved"; + case 40: return "High speed craft (HSC), all ships"; + case 41: return "High speed craft (HSC), Hazardous category A"; + case 42: return "High speed craft (HSC), Hazardous category B"; + case 43: return "High speed craft (HSC), Hazardous category C"; + case 44: return "High speed craft (HSC), Hazardous category D"; + case 45: + case 46: + case 47: + case 48: return "High speed craft (HSC), Reserved"; + case 49: return "High speed craft (HSC), No additional information"; + case 50: return "Pilot Vessel"; + case 51: return "Search and Rescue vessel"; + case 52: return "Tug"; + case 53: return "Port Tender"; + case 54: return "Anti-pollution equipment"; + case 55: return "Law Enforcement"; + case 56: + case 57: return "Spare - Local Vessel"; + case 58: return "Medical Transport"; + case 59: return "Noncombatant ship according to RR Resolution No. 18"; + case 60: return "Passenger, all ships"; + case 61: return "Passenger, Hazardous category A"; + case 62: return "Passenger, Hazardous category B"; + case 63: return "Passenger, Hazardous category C"; + case 64: return "Passenger, Hazardous category D"; + case 65: + case 66: + case 67: + case 68: return "Passenger, Reserved"; + case 69: return "Passenger, No additional information"; + case 70: return "Cargo, all ships"; + case 71: return "Cargo, Hazardous category A"; + case 72: return "Cargo, Hazardous category B"; + case 73: return "Cargo, Hazardous category C"; + case 74: return "Cargo, Hazardous category D"; + case 75: + case 76: + case 77: + case 78: return "Cargo, Reserved"; + case 79: return "Cargo, No additional information"; + case 80: return "Tanker, all ships"; + case 81: return "Tanker, Hazardous category A"; + case 82: return "Tanker, Hazardous category B"; + case 83: return "Tanker, Hazardous category C"; + case 84: return "Tanker, Hazardous category D"; + case 85: + case 86: + case 87: + case 88: return "Tanker, Reserved"; + case 89: return "Tanker, No additional information"; + case 90: return "Other Type, all ships"; + case 91: return "Other Type, Hazardous category A"; + case 92: return "Other Type, Hazardous category B"; + case 93: return "Other Type, Hazardous category C"; + case 94: return "Other Type, Hazardous category D"; + case 95: + case 96: + case 97: + case 98: return "Other Type, Reserved"; + case 99: return "Other Type, no additional information"; + default: return "Unknown"; + } + } + + /** + * Находит существующее AIS судно или создает новое + */ + private AISVessel findOrCreateAISVessel(String mmsi) { + for (AISVessel vessel : aisVessels) { + if (mmsi.equals(vessel.getMmsi())) { + return vessel; + } + } + + // Создаем новое судно + AISVessel newVessel = new AISVessel(mmsi); + aisVessels.add(newVessel); + Log.d(TAG, "Создано новое AIS судно: " + mmsi); + return newVessel; + } + + /** + * Очищает устаревшие AIS суда (данные старше 10 минут) + */ + public void cleanupStaleAISVessels() { + java.util.Iterator iterator = aisVessels.iterator(); + int removedCount = 0; + + while (iterator.hasNext()) { + AISVessel vessel = iterator.next(); + if (vessel.isDataStale()) { + iterator.remove(); + removedCount++; + Log.d(TAG, "Удалено устаревшее AIS судно: " + vessel.getMmsi()); + } + } + + if (removedCount > 0) { + Log.i(TAG, "Удалено " + removedCount + " устаревших AIS судов"); + } + } + + /** + * Получает количество активных AIS судов + */ + public int getActiveAISVesselCount() { + cleanupStaleAISVessels(); + return aisVessels.size(); + } + + /** + * Получает AIS судно по MMSI + */ + public AISVessel getAISVesselByMMSI(String mmsi) { + for (AISVessel vessel : aisVessels) { + if (mmsi.equals(vessel.getMmsi())) { + return vessel; + } + } + return null; + } + + /** + * Обновляет статус активности AIS судов + */ + public void updateAISVesselActivity() { + long currentTime = System.currentTimeMillis(); + for (AISVessel vessel : aisVessels) { + // Считаем судно активным, если данные получены менее 5 минут назад + boolean isActive = (currentTime - vessel.getLastUpdate().toInstant(java.time.ZoneOffset.UTC).toEpochMilli()) < 300000; + vessel.setActive(isActive); + } + } + + /** + * Парсит координаты из NMEA формата + */ + private double parseCoordinate(String coordinate, boolean isPositive) { + // Проверяем, что координата не пустая + if (coordinate == null || coordinate.trim().isEmpty()) { + return 0.0; + } + + try { + double value = Double.parseDouble(coordinate); + int degrees = (int) (value / 100); + double minutes = value - (degrees * 100); + double result = degrees + (minutes / 60.0); + return isPositive ? result : -result; + } catch (NumberFormatException e) { + Log.w(TAG, "Ошибка парсинга координаты: " + coordinate + ", ошибка: " + e.getMessage()); + return 0.0; + } + } + + /** + * Проверяет контрольную сумму NMEA сообщения + */ + public boolean validateChecksum(String nmeaSentence) { + if (nmeaSentence == null || !nmeaSentence.contains("*")) { + return false; + } + + int asteriskIndex = nmeaSentence.indexOf('*'); + String sentence = nmeaSentence.substring(1, asteriskIndex); + String checksum = nmeaSentence.substring(asteriskIndex + 1); + + int calculatedChecksum = 0; + for (char c : sentence.toCharArray()) { + calculatedChecksum ^= c; + } + + String hexChecksum = String.format("%02X", calculatedChecksum); + return hexChecksum.equals(checksum); + } + + public Vessel getOwnVessel() { + return ownVessel; + } + + public List getAISVessels() { + return new ArrayList<>(aisVessels); + } + + /** + * Получает количество спутников GPS + */ + public int getGPSSatellites() { + return gpsSatellites; + } + + /** + * Получает количество спутников GLONASS + */ + public int getGLONASSSatellites() { + return glonassSatellites; + } + + /** + * Получает количество спутников Galileo + */ + public int getGalileoSatellites() { + return galileoSatellites; + } + + /** + * Получает общее количество спутников всех систем + */ + public int getTotalSatellites() { + return gpsSatellites + glonassSatellites + galileoSatellites; + } + + /** + * Сбрасывает счетчики спутников + */ + public void resetSatelliteCounters() { + gpsSatellites = 0; + glonassSatellites = 0; + galileoSatellites = 0; + ownVessel.setSatellites(0); + Log.d(TAG, "Счетчики спутников сброшены"); + } + + /** + * Синхронизирует данные о спутниках с GPSLocationListener + */ + public void syncSatelliteData() { + if (gpsLocationListener != null) { + gpsLocationListener.setSatellitesInVessel(ownVessel); + } + } + + /** + * Получает текущее состояние объекта Vessel + */ + public String getVesselStatus() { + return String.format("Vessel: satellites=%d, activeSatellites=%d, GPS=%d, GLONASS=%d, Galileo=%d", + ownVessel.getSatellites(), ownVessel.getActiveSatellites(), + gpsSatellites, glonassSatellites, galileoSatellites); + } + + /** + * Декодирует AIS сообщение типа 4 (Base Station Report) + */ + private void decodeBaseStationReport(String payload) { + try { + Log.d(TAG, "Декодируем Base Station Report, payload: " + payload + " (длина: " + payload.length() + ")"); + + // MMSI (30 бит) - начинается с бита 8 + String mmsiBits = decodeAISField(payload, 8, 30); + int mmsi = Integer.parseInt(mmsiBits, 2); + Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi); + + // Year (14 бит) - бит 38 + String yearBits = decodeAISField(payload, 38, 14); + int year = Integer.parseInt(yearBits, 2); + Log.d(TAG, "Year bits: " + yearBits + " = " + year); + + // Month (4 бита) - бит 52 + String monthBits = decodeAISField(payload, 52, 4); + int month = Integer.parseInt(monthBits, 2); + Log.d(TAG, "Month bits: " + monthBits + " = " + month); + + // Day (5 бит) - бит 56 + String dayBits = decodeAISField(payload, 56, 5); + int day = Integer.parseInt(dayBits, 2); + Log.d(TAG, "Day bits: " + dayBits + " = " + day); + + // Hour (5 бит) - бит 61 + String hourBits = decodeAISField(payload, 61, 5); + int hour = Integer.parseInt(hourBits, 2); + Log.d(TAG, "Hour bits: " + hourBits + " = " + hour); + + // Minute (6 бит) - бит 66 + String minuteBits = decodeAISField(payload, 66, 6); + int minute = Integer.parseInt(minuteBits, 2); + Log.d(TAG, "Minute bits: " + minuteBits + " = " + minute); + + // Second (6 бит) - бит 72 + String secondBits = decodeAISField(payload, 72, 6); + int second = Integer.parseInt(secondBits, 2); + Log.d(TAG, "Second bits: " + secondBits + " = " + second); + + // Position Accuracy (1 бит) - бит 78 + String accuracyBits = decodeAISField(payload, 78, 1); + int accuracy = Integer.parseInt(accuracyBits, 2); + Log.d(TAG, "Accuracy bits: " + accuracyBits + " = " + accuracy); + + // Longitude (28 бит) - бит 79 + String lonBits = decodeAISField(payload, 79, 28); + double longitude = parseAISCoordinate(lonBits, 28); + Log.d(TAG, "Longitude bits: " + lonBits + " = " + longitude); + + // Latitude (27 бит) - бит 107 + String latBits = decodeAISField(payload, 107, 27); + double latitude = parseAISCoordinate(latBits, 27); + Log.d(TAG, "Latitude bits: " + latBits + " = " + latitude); + + // EPFD Type (4 бита) - бит 134 + String epfdBits = decodeAISField(payload, 134, 4); + int epfdType = Integer.parseInt(epfdBits, 2); + Log.d(TAG, "EPFD Type bits: " + epfdBits + " = " + epfdType); + + Log.d(TAG, String.format("AIS Base Station: MMSI=%d, date=%04d-%02d-%02d %02d:%02d:%02d, lat=%.6f, lon=%.6f, accuracy=%d, epfd=%d", + mmsi, year, month, day, hour, minute, second, latitude, longitude, accuracy, epfdType)); + + // Создаем или обновляем AIS судно (базовая станция) + AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); + vessel.updatePosition(latitude, longitude, 0.0, 0.0); + vessel.setPositionAccuracy(accuracy == 1); + vessel.setVesselClass("Base Station"); + vessel.setLastUpdate(java.time.LocalDateTime.now()); + + // Уведомляем слушателя + if (listener != null) { + listener.onAISVesselUpdated(vessel); + } + + } catch (Exception e) { + Log.e(TAG, "Ошибка декодирования Base Station Report: " + e.getMessage(), e); + } + } + + /** + * Декодирует AIS сообщение типа 14 (Safety Related Broadcast Message) + */ + private void decodeSafetyBroadcast(String payload) { + try { + Log.d(TAG, "Декодируем Safety Broadcast, payload: " + payload + " (длина: " + payload.length() + ")"); + + // MMSI (30 бит) - начинается с бита 8 + String mmsiBits = decodeAISField(payload, 8, 30); + int mmsi = Integer.parseInt(mmsiBits, 2); + Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi); + + // Spare (2 бита) - бит 38 + String spareBits = decodeAISField(payload, 38, 2); + int spare = Integer.parseInt(spareBits, 2); + Log.d(TAG, "Spare bits: " + spareBits + " = " + spare); + + // Text (120 бит) - бит 40 + String textBits = decodeAISField(payload, 40, 120); + String safetyText = decodeAISString(textBits); + Log.d(TAG, "Safety Text bits: " + textBits + " = '" + safetyText + "'"); + + Log.d(TAG, String.format("AIS Safety Broadcast: MMSI=%d, text='%s'", mmsi, safetyText)); + + // Создаем или обновляем AIS судно + AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); + vessel.setLastSafetyMessage(safetyText); + vessel.setLastUpdate(java.time.LocalDateTime.now()); + + // Уведомляем слушателя + if (listener != null) { + listener.onAISVesselUpdated(vessel); + } + + } catch (Exception e) { + Log.e(TAG, "Ошибка декодирования Safety Broadcast: " + e.getMessage(), e); + } + } + + /** + * Декодирует AIS сообщение типа 18 (Standard Class B Equipment Position Report) + */ + private void decodeClassBPositionReport(String payload) { + try { + Log.d(TAG, "Декодируем Class B Position Report, payload: " + payload + " (длина: " + payload.length() + ")"); + + // MMSI (30 бит) - начинается с бита 8 + String mmsiBits = decodeAISField(payload, 8, 30); + int mmsi = Integer.parseInt(mmsiBits, 2); + Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi); + + // Speed Over Ground (10 бит) - бит 46 + String speedBits = decodeAISField(payload, 46, 10); + double speed = Integer.parseInt(speedBits, 2) / 10.0; + Log.d(TAG, "Speed bits: " + speedBits + " = " + speed); + + // Position Accuracy (1 бит) - бит 56 + String accuracyBits = decodeAISField(payload, 56, 1); + int accuracy = Integer.parseInt(accuracyBits, 2); + Log.d(TAG, "Accuracy bits: " + accuracyBits + " = " + accuracy); + + // Longitude (28 бит) - бит 57 + String lonBits = decodeAISField(payload, 57, 28); + double longitude = parseAISCoordinate(lonBits, 28); + Log.d(TAG, "Longitude bits: " + lonBits + " = " + longitude); + + // Latitude (27 бит) - бит 85 + String latBits = decodeAISField(payload, 85, 27); + double latitude = parseAISCoordinate(latBits, 27); + Log.d(TAG, "Latitude bits: " + latBits + " = " + latitude); + + // Course Over Ground (12 бит) - бит 112 + String courseBits = decodeAISField(payload, 112, 12); + double course = Integer.parseInt(courseBits, 2) / 10.0; + Log.d(TAG, "Course bits: " + courseBits + " = " + course); + + // True Heading (9 бит) - бит 124 + String headingBits = decodeAISField(payload, 124, 9); + double heading = Integer.parseInt(headingBits, 2); + Log.d(TAG, "Heading bits: " + headingBits + " = " + heading); + + // Time Stamp (6 бит) - бит 133 + String timestampBits = decodeAISField(payload, 133, 6); + int timestamp = Integer.parseInt(timestampBits, 2); + Log.d(TAG, "Timestamp bits: " + timestampBits + " = " + timestamp); + + // Regional Reserved (2 бита) - бит 139 + String regionalBits = decodeAISField(payload, 139, 2); + int regional = Integer.parseInt(regionalBits, 2); + Log.d(TAG, "Regional bits: " + regionalBits + " = " + regional); + + // Spare (3 бита) - бит 141 + String spareBits = decodeAISField(payload, 141, 3); + int spare = Integer.parseInt(spareBits, 2); + Log.d(TAG, "Spare bits: " + spareBits + " = " + spare); + + Log.d(TAG, String.format("AIS Class B Position: MMSI=%d, lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, heading=%.1f", + mmsi, latitude, longitude, course, speed, heading)); + + // Создаем или обновляем AIS судно + AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); + vessel.updatePosition(latitude, longitude, course, speed); + vessel.setHeading(heading); + vessel.setPositionAccuracy(accuracy == 1); + vessel.setLastUpdate(java.time.LocalDateTime.now()); + vessel.setVesselClass("Class B"); + + // В Class B Position Report размеры не передаются, но мы сохраняем существующие + Log.d(TAG, "Class B Position Report - размеры не передаются, сохраняем существующие: L=" + vessel.getLength() + ", W=" + vessel.getWidth()); + + // Отправляем информацию о корабле на внешний ресурс + String vesselInfo = String.format("Class B: lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, heading=%.1f, accuracy=%s", + latitude, longitude, course, speed, heading, accuracy == 1 ? "high" : "low"); + LogSender.logShipUpdate(String.valueOf(mmsi), vesselInfo); + + // Уведомляем слушателя + if (listener != null) { + listener.onAISVesselUpdated(vessel); + } + + } catch (Exception e) { + Log.e(TAG, "Ошибка декодирования Class B Position Report: " + e.getMessage(), e); + } + } + + /** + * Декодирует AIS сообщение типа 19 (Extended Class B Equipment Position Report) + */ + private void decodeExtendedClassBPositionReport(String payload) { + try { + Log.d(TAG, "Декодируем Extended Class B Position Report, payload: " + payload + " (длина: " + payload.length() + ")"); + + // Проверяем длину payload - для Extended Class B должно быть достаточно битов + int totalBits = payload.length() * 6; + Log.d(TAG, "Общая длина payload в битах: " + totalBits); + + if (totalBits < 312) { // Минимум для Extended Class B + Log.w(TAG, "Extended Class B payload слишком короткий: " + totalBits + " бит, ожидается минимум 312"); + return; + } + + // MMSI (30 бит) - начинается с бита 8 + String mmsiBits = decodeAISField(payload, 8, 30); + int mmsi = Integer.parseInt(mmsiBits, 2); + Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi); + + // Speed Over Ground (10 бит) - бит 46 + String speedBits = decodeAISField(payload, 46, 10); + double speed = Integer.parseInt(speedBits, 2) / 10.0; + Log.d(TAG, "Speed bits: " + speedBits + " = " + speed); + + // Position Accuracy (1 бит) - бит 56 + String accuracyBits = decodeAISField(payload, 56, 1); + int accuracy = Integer.parseInt(accuracyBits, 2); + Log.d(TAG, "Accuracy bits: " + accuracyBits + " = " + accuracy); + + // Longitude (28 бит) - бит 57 + String lonBits = decodeAISField(payload, 57, 28); + double longitude = parseAISCoordinate(lonBits, 28); + Log.d(TAG, "Longitude bits: " + lonBits + " = " + longitude); + + // Latitude (27 бит) - бит 85 + String latBits = decodeAISField(payload, 85, 27); + double latitude = parseAISCoordinate(latBits, 27); + Log.d(TAG, "Latitude bits: " + latBits + " = " + latitude); + + // Course Over Ground (12 бит) - бит 112 + String courseBits = decodeAISField(payload, 112, 12); + double course = Integer.parseInt(courseBits, 2) / 10.0; + Log.d(TAG, "Course bits: " + courseBits + " = " + course); + + // True Heading (9 бит) - бит 124 + String headingBits = decodeAISField(payload, 124, 9); + double heading = Integer.parseInt(headingBits, 2); + Log.d(TAG, "Heading bits: " + headingBits + " = " + heading); + + // Time Stamp (6 бит) - бит 133 + String timestampBits = decodeAISField(payload, 133, 6); + int timestamp = Integer.parseInt(timestampBits, 2); + Log.d(TAG, "Timestamp bits: " + timestampBits + " = " + timestamp); + + // Regional Reserved (4 бита) - бит 139 + String regionalBits = decodeAISField(payload, 139, 4); + int regional = Integer.parseInt(regionalBits, 2); + Log.d(TAG, "Regional bits: " + regionalBits + " = " + regional); + + // Vessel Name (120 бит) - бит 143 + String nameBits = decodeAISField(payload, 143, 120); + String vesselName = decodeAISString(nameBits); + Log.d(TAG, "Name bits: " + nameBits + " = '" + vesselName + "'"); + + // Ship Type (8 бит) - бит 263 + String typeBits = decodeAISField(payload, 263, 8); + int vesselTypeCode = Integer.parseInt(typeBits, 2); + Log.d(TAG, "Type bits: " + typeBits + " = " + vesselTypeCode); + + // Dimension Reference (4 бита) - бит 271 + String dimRefABits = decodeAISField(payload, 271, 4); + String dimRefBBits = decodeAISField(payload, 275, 4); + String dimRefCBits = decodeAISField(payload, 279, 4); + String dimRefDBits = decodeAISField(payload, 283, 4); + + int dimRefA = Integer.parseInt(dimRefABits, 2); + int dimRefB = Integer.parseInt(dimRefBBits, 2); + int dimRefC = Integer.parseInt(dimRefCBits, 2); + int dimRefD = Integer.parseInt(dimRefDBits, 2); + + Log.d(TAG, "Dimension Reference: A=" + dimRefA + ", B=" + dimRefB + ", C=" + dimRefC + ", D=" + dimRefD); + + // Vessel Dimensions (40 бит) - начинаются с бита 287 + // Проверяем, есть ли достаточно битов для размеров + if (totalBits < 327) { + Log.w(TAG, "Extended Class B - недостаточно битов для размеров: " + totalBits + " < 327"); + // Создаем судно без размеров + AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); + vessel.updatePosition(latitude, longitude, course, speed); + vessel.setHeading(heading); + vessel.setPositionAccuracy(accuracy == 1); + vessel.setVesselName(vesselName); + vessel.setVesselType(getVesselType(vesselTypeCode)); + vessel.setLastUpdate(java.time.LocalDateTime.now()); + vessel.setVesselClass("Extended Class B"); + + if (listener != null) { + listener.onAISVesselUpdated(vessel); + } + return; + } + + // Dim.A (10 бит) - от носа до антенны + String dimABits = decodeAISField(payload, 287, 10); + // Dim.B (10 бит) - от антенны до кормы + String dimBBits = decodeAISField(payload, 297, 10); + // Dim.C (10 бит) - от левого борта до антенны + String dimCBits = decodeAISField(payload, 307, 10); + // Dim.D (10 бит) - от антенны до правого борта + String dimDBits = decodeAISField(payload, 317, 10); + + Log.d(TAG, "Raw dimension bits - Dim.A: " + dimABits + ", Dim.B: " + dimBBits + ", Dim.C: " + dimCBits + ", Dim.D: " + dimDBits); + + int dimA = Integer.parseInt(dimABits, 2); + int dimB = Integer.parseInt(dimBBits, 2); + int dimC = Integer.parseInt(dimCBits, 2); + int dimD = Integer.parseInt(dimDBits, 2); + + // В AIS стандарте размеры кодируются как 6-битные значения: + // 0 = не указано, 1-62 = размер в метрах, 63 = размер 63+ метра + // Но мы получаем 10-битные значения, поэтому нужно их правильно интерпретировать + + // Проверяем, что размеры в разумных пределах (0-1000 метров) + if (dimA > 1000 || dimB > 1000 || dimC > 1000 || dimD > 1000) { + Log.w(TAG, "Размеры судна выходят за разумные пределы: A=" + dimA + ", B=" + dimB + ", C=" + dimC + ", D=" + dimD); + // Возможно, мы неправильно интерпретируем битовые поля + // Попробуем интерпретировать как 6-битные значения + dimA = dimA & 0x3F; // Берем только младшие 6 бит + dimB = dimB & 0x3F; + dimC = dimC & 0x3F; + dimD = dimD & 0x3F; + Log.d(TAG, "Исправленные размеры (6-битные): A=" + dimA + ", B=" + dimB + ", C=" + dimC + ", D=" + dimD); + } + + // Дополнительная проверка: если размеры все еще неразумные, используем Dimension Reference + if (dimA > 100 || dimB > 100 || dimC > 100 || dimD > 100) { + Log.w(TAG, "Размеры все еще неразумные, используем Dimension Reference: A=" + dimA + ", B=" + dimB + ", C=" + dimC + ", D=" + dimD); + // Используем Dimension Reference как fallback + dimA = dimRefA; + dimB = dimRefB; + dimC = dimRefC; + dimD = dimRefD; + Log.d(TAG, "Fallback размеры из Dimension Reference: A=" + dimA + ", B=" + dimB + ", C=" + dimC + ", D=" + dimD); + } + + // Размеры судна рассчитываются как: + // Длина = Dim.A + Dim.B (от носа до антенны + от антенны до кормы) + // Ширина = Dim.C + Dim.D (от левого борта до антенны + от антенны до правого борта) + double length = dimA + dimB; + double width = dimC + dimD; + + Log.d(TAG, "Dimensions - Dim.A (нос-антенна): " + dimABits + " = " + dimA); + Log.d(TAG, "Dimensions - Dim.B (антенна-корма): " + dimBBits + " = " + dimB); + Log.d(TAG, "Dimensions - Dim.C (левый борт-антенна): " + dimCBits + " = " + dimC); + Log.d(TAG, "Dimensions - Dim.D (антенна-правый борт): " + dimDBits + " = " + dimD); + Log.d(TAG, "Dimensions - Total Length (A+B): " + length + "m"); + Log.d(TAG, "Dimensions - Total Width (C+D): " + width + "m"); + + Log.d(TAG, String.format("AIS Extended Class B: MMSI=%d, name='%s', lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, type=%d, L=%.1f, W=%.1f", + mmsi, vesselName, latitude, longitude, course, speed, vesselTypeCode, length, width)); + + // Создаем или обновляем AIS судно + AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); + vessel.updatePosition(latitude, longitude, course, speed); + vessel.setHeading(heading); + vessel.setPositionAccuracy(accuracy == 1); + vessel.setVesselName(vesselName); + vessel.setVesselType(getVesselType(vesselTypeCode)); + vessel.setLength(length); + vessel.setWidth(width); + vessel.setLastUpdate(java.time.LocalDateTime.now()); + vessel.setVesselClass("Extended Class B"); + + // Отправляем информацию о корабле на внешний ресурс + String vesselInfo = String.format("Extended Class B: name='%s', lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, type=%s, L=%.1f, W=%.1f", + vesselName, latitude, longitude, course, speed, getVesselType(vesselTypeCode), length, width); + LogSender.logShipUpdate(String.valueOf(mmsi), vesselInfo); + + // Уведомляем слушателя + if (listener != null) { + listener.onAISVesselUpdated(vessel); + } + + } catch (Exception e) { + Log.e(TAG, "Ошибка декодирования Extended Class B Position Report: " + e.getMessage(), e); + } + } + + /** + * Декодирует AIS сообщение типа 21 (Aid-to-Navigation Report) + */ + private void decodeAidToNavigationReport(String payload) { + try { + Log.d(TAG, "Декодируем Aid-to-Navigation Report, payload: " + payload + " (длина: " + payload.length() + ")"); + + // MMSI (30 бит) - начинается с бита 8 + String mmsiBits = decodeAISField(payload, 8, 30); + int mmsi = Integer.parseInt(mmsiBits, 2); + Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi); + + // Aid Type (5 бит) - бит 38 + String aidTypeBits = decodeAISField(payload, 38, 5); + int aidType = Integer.parseInt(aidTypeBits, 2); + Log.d(TAG, "Aid Type bits: " + aidTypeBits + " = " + aidType); + + // Name (120 бит) - бит 43 + String nameBits = decodeAISField(payload, 43, 120); + String aidName = decodeAISString(nameBits); + Log.d(TAG, "Name bits: " + nameBits + " = '" + aidName + "'"); + + // Position Accuracy (1 бит) - бит 163 + String accuracyBits = decodeAISField(payload, 163, 1); + int accuracy = Integer.parseInt(accuracyBits, 2); + Log.d(TAG, "Accuracy bits: " + accuracyBits + " = " + accuracy); + + // Longitude (28 бит) - бит 164 + String lonBits = decodeAISField(payload, 164, 28); + double longitude = parseAISCoordinate(lonBits, 28); + Log.d(TAG, "Longitude bits: " + lonBits + " = " + longitude); + + // Latitude (27 бит) - бит 192 + String latBits = decodeAISField(payload, 192, 27); + double latitude = parseAISCoordinate(latBits, 27); + Log.d(TAG, "Latitude bits: " + latBits + " = " + latitude); + + // Dimension Reference (4 бита) - бит 219 + String dimRefABits = decodeAISField(payload, 219, 4); + String dimRefBBits = decodeAISField(payload, 223, 4); + String dimRefCBits = decodeAISField(payload, 227, 4); + String dimRefDBits = decodeAISField(payload, 231, 4); + + int dimRefA = Integer.parseInt(dimRefABits, 2); + int dimRefB = Integer.parseInt(dimRefBBits, 2); + int dimRefC = Integer.parseInt(dimRefCBits, 2); + int dimRefD = Integer.parseInt(dimRefDBits, 2); + + // Vessel Dimensions (30 бит) - бит 235 + // Dim.A (10 бит) - от носа до антенны + String dimABits = decodeAISField(payload, 235, 10); + // Dim.B (10 бит) - от антенны до кормы + String dimBBits = decodeAISField(payload, 245, 10); + // Dim.C (10 бит) - от левого борта до антенны + String dimCBits = decodeAISField(payload, 255, 10); + // Dim.D (10 бит) - от антенны до правого борта + String dimDBits = decodeAISField(payload, 265, 10); + // Draft (8 бит) - осадка + String draftBits = decodeAISField(payload, 275, 8); + + int dimA = Integer.parseInt(dimABits, 2); + int dimB = Integer.parseInt(dimBBits, 2); + int dimC = Integer.parseInt(dimCBits, 2); + int dimD = Integer.parseInt(dimDBits, 2); + + // Размеры судна рассчитываются как: + // Длина = Dim.A + Dim.B (от носа до антенны + от антенны до кормы) + // Ширина = Dim.C + Dim.D (от левого борта до антенны + от антенны до правого борта) + double length = dimA + dimB; + double width = dimC + dimD; + double draft = Integer.parseInt(draftBits, 2) / 10.0; + + Log.d(TAG, String.format("AIS Aid-to-Navigation: MMSI=%d, type=%d, name='%s', lat=%.6f, lon=%.6f, L=%.1f, W=%.1f, D=%.1f", + mmsi, aidType, aidName, latitude, longitude, length, width, draft)); + + // Создаем или обновляем AIS судно (навигационный знак) + AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); + vessel.updatePosition(latitude, longitude, 0.0, 0.0); + vessel.setPositionAccuracy(accuracy == 1); + vessel.setVesselName(aidName); + vessel.setVesselType("Aid-to-Navigation"); + vessel.setLength(length); + vessel.setWidth(width); + vessel.setDraft(draft); + vessel.setLastUpdate(java.time.LocalDateTime.now()); + vessel.setVesselClass("Navigation Aid"); + + // Уведомляем слушателя + if (listener != null) { + listener.onAISVesselUpdated(vessel); + } + + } catch (Exception e) { + Log.e(TAG, "Ошибка декодирования Aid-to-Navigation Report: " + e.getMessage(), e); + } + } + + /** + * Декодирует AIS сообщение типа 24 (Static Data Report) + */ + private void decodeStaticDataReport(String payload) { + try { + Log.d(TAG, "Декодируем Static Data Report, payload: " + payload + " (длина: " + payload.length() + ")"); + + // MMSI (30 бит) - начинается с бита 8 + String mmsiBits = decodeAISField(payload, 8, 30); + int mmsi = Integer.parseInt(mmsiBits, 2); + Log.d(TAG, "MMSI bits: " + mmsiBits + " = " + mmsi); + + // Part Number (2 бита) - бит 38 + String partBits = decodeAISField(payload, 38, 2); + int partNumber = Integer.parseInt(partBits, 2); + Log.d(TAG, "Part Number bits: " + partBits + " = " + partNumber); + + if (partNumber == 0) { + // Part A: Vessel Name + String nameBits = decodeAISField(payload, 40, 120); + String vesselName = decodeAISString(nameBits); + Log.d(TAG, "Vessel Name bits: " + nameBits + " = '" + vesselName + "'"); + + Log.d(TAG, String.format("AIS Static Data Part A: MMSI=%d, name='%s'", mmsi, vesselName)); + + // Обновляем AIS судно + AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); + vessel.setVesselName(vesselName); + vessel.setLastUpdate(java.time.LocalDateTime.now()); + + if (listener != null) { + listener.onAISVesselUpdated(vessel); + } + + } else if (partNumber == 1) { + // Part B: Vessel Type, Dimensions, etc. + String typeBits = decodeAISField(payload, 40, 8); + int vesselTypeCode = Integer.parseInt(typeBits, 2); + Log.d(TAG, "Vessel Type bits: " + typeBits + " = " + vesselTypeCode); + + // Vendor ID (42 бита) - бит 48 + String vendorBits = decodeAISField(payload, 48, 42); + String vendorId = decodeAISString(vendorBits); + Log.d(TAG, "Vendor ID bits: " + vendorBits + " = '" + vendorId + "'"); + + // Call Sign (42 бита) - бит 90 + String callSignBits = decodeAISField(payload, 90, 42); + String callSign = decodeAISString(callSignBits); + Log.d(TAG, "Call Sign bits: " + callSignBits + " = '" + callSign + "'"); + + // Dimension Reference (6 бит каждое) - бит 132 + // Согласно онлайн декодеру, размеры находятся в других позициях + // Попробуем позиции, которые соответствуют онлайн декодеру + String dimRefABits = decodeAISField(payload, 132, 9); + String dimRefBBits = decodeAISField(payload, 141, 9); + String dimRefCBits = decodeAISField(payload, 150, 6); + String dimRefDBits = decodeAISField(payload, 156, 6); + + int dimRefA = Integer.parseInt(dimRefABits, 2); + int dimRefB = Integer.parseInt(dimRefBBits, 2); + int dimRefC = Integer.parseInt(dimRefCBits, 2); + int dimRefD = Integer.parseInt(dimRefDBits, 2); + + Log.d(TAG, "Dimension Reference bits - A: " + dimRefABits + " = " + dimRefA); + Log.d(TAG, "Dimension Reference bits - B: " + dimRefBBits + " = " + dimRefB); + Log.d(TAG, "Dimension Reference bits - C: " + dimRefCBits + " = " + dimRefC); + Log.d(TAG, "Dimension Reference bits - D: " + dimRefDBits + " = " + dimRefD); + + // Проверяем, есть ли достаточно битов для размеров + int totalBits = payload.length() * 6; + Log.d(TAG, "Static Data Part B - общая длина payload в битах: " + totalBits); + + double length = 0.0; + double width = 0.0; + double draft = 0.0; + + // Для коротких сообщений типа 24 Part B (168 бит) используем Dimension Reference + // В коротких сообщениях размеры кодируются в Dimension Reference полях + if (totalBits >= 168) { + // В сообщениях типа 24 Part B для Class B судов + // размеры кодируются в полях Dimension Reference (биты 132-147) + // где каждое поле - 4 бита и представляет размер в метрах + // Эти поля уже правильно декодированы выше + + // Размеры судна рассчитываются как: + // Длина = Dim.A + Dim.B (от носа до антенны + от антенны до кормы) + // Ширина = Dim.C + Dim.D (от левого борта до антенны + от антенны до правого борта) + length = dimRefA + dimRefB; + width = dimRefC + dimRefD; + + Log.d(TAG, "Static Data Part B - используем Dimension Reference:"); + Log.d(TAG, " Dim.A (нос-антенна): " + dimRefA + " м"); + Log.d(TAG, " Dim.B (антенна-корма): " + dimRefB + " м"); + Log.d(TAG, " Dim.C (левый борт-антенна): " + dimRefC + " м"); + Log.d(TAG, " Dim.D (антенна-правый борт): " + dimRefD + " м"); + Log.d(TAG, "Static Data Part B - итоговые размеры: L=" + length + ", W=" + width); + + } else { + Log.w(TAG, "Static Data Part B - недостаточно битов для размеров: " + totalBits + " < 168"); + // Используем нулевые размеры + length = 0.0; + width = 0.0; + } + + Log.d(TAG, String.format("AIS Static Data Part B: MMSI=%d, type=%d, vendor='%s', callSign='%s', L=%.1f, W=%.1f, D=%.1f", + mmsi, vesselTypeCode, vendorId, callSign, length, width, draft)); + + // Обновляем AIS судно + AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); + vessel.setVesselType(getVesselType(vesselTypeCode)); + vessel.setVendorId(vendorId); + vessel.setCallSign(callSign); + vessel.setLength(length); + vessel.setWidth(width); + vessel.setDraft(draft); + vessel.setLastUpdate(java.time.LocalDateTime.now()); + + if (listener != null) { + listener.onAISVesselUpdated(vessel); + } + } + + } catch (Exception e) { + Log.e(TAG, "Ошибка декодирования Static Data Report: " + e.getMessage(), e); + } + } +} diff --git a/app/build.gradle b/app/build.gradle index 855f6e0..695945b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -46,6 +46,13 @@ dependencies { implementation group: 'org.mapsforge', name: 'mapsforge-map-reader', version: '0.25.0' implementation group: 'org.mapsforge', name: 'mapsforge-core', version: '0.25.0' + // Room + implementation "androidx.room:room-runtime:2.6.1" + annotationProcessor "androidx.room:room-compiler:2.6.1" + // Lifecycle (для сервисов/репозитория при необходимости) + implementation 'androidx.lifecycle:lifecycle-runtime:2.8.3' + implementation 'androidx.lifecycle:lifecycle-livedata:2.8.3' + // Тестирование testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 816f8b8..e7566c4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,11 @@ + + + + + @@ -13,6 +18,9 @@ + + + @@ -31,7 +39,7 @@ android:supportsRtl="true" android:theme="@style/Theme.AISMap" tools:targetApi="31"> - + + + + + + (), this); + recyclerView.setAdapter(adapter); + + repository.observeAllAIS().observe(this, new Observer>() { + @Override + public void onChanged(List entities) { + // Стабильная сортировка по MMSI для предсказуемого порядка + if (entities != null) { + java.util.Collections.sort(entities, (a, b) -> a.mmsi.compareTo(b.mmsi)); + } + adapter.submitList(entities); + + // Показываем/скрываем сообщение о пустом состоянии + if (entities == null || entities.isEmpty()) { + textEmptyState.setVisibility(android.view.View.VISIBLE); + recyclerView.setVisibility(android.view.View.GONE); + } else { + textEmptyState.setVisibility(android.view.View.GONE); + recyclerView.setVisibility(android.view.View.VISIBLE); + } + } + }); + + // Тикер для обновления поля "N сек назад" + tickerHandler = new android.os.Handler(android.os.Looper.getMainLooper()); + tickerRunnable = new Runnable() { + @Override + public void run() { + try { + // Обновляем только элементы с данными, чтобы избежать мигания + int itemCount = adapter.getItemCount(); + for (int i = 0; i < itemCount; i++) { + adapter.notifyItemChanged(i, "time_update"); + } + } finally { + tickerHandler.postDelayed(this, 1000); + } + } + }; + tickerHandler.postDelayed(tickerRunnable, 1000); + } + + @Override + public void onMarinetrafficClick(String mmsi) { + String url = "https://www.marinetraffic.com/ru/ais/details/ships/mmsi:" + mmsi; + Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + startActivity(browserIntent); + } + + @Override + public void onCenterOnMapClick(String mmsi, double lat, double lon) { + Intent intent = new Intent(this, MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + intent.putExtra("center_mmsi", mmsi); + intent.putExtra("center_lat", lat); + intent.putExtra("center_lon", lon); + startActivity(intent); + finish(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (tickerHandler != null && tickerRunnable != null) { + tickerHandler.removeCallbacks(tickerRunnable); + } + } +} + + diff --git a/app/src/main/java/com/grigowashere/aismap/AisTargetsAdapter.java b/app/src/main/java/com/grigowashere/aismap/AisTargetsAdapter.java new file mode 100644 index 0000000..3c95e02 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/AisTargetsAdapter.java @@ -0,0 +1,138 @@ +package com.grigowashere.aismap; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import com.grigowashere.aismap.data.entity.AISVesselEntity; + +class AisTargetsAdapter extends ListAdapter { + + interface OnItemClickListener { + void onMarinetrafficClick(String mmsi); + void onCenterOnMapClick(String mmsi, double lat, double lon); + } + + private final OnItemClickListener listener; + + protected AisTargetsAdapter(@NonNull DiffUtil.ItemCallback diffCallback, OnItemClickListener listener) { + super(diffCallback); + this.listener = listener; + } + + public AisTargetsAdapter(java.util.List initial, OnItemClickListener listener) { + this(DIFF_CALLBACK, listener); + submitList(initial); + } + + static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull AISVesselEntity oldItem, @NonNull AISVesselEntity newItem) { + return oldItem.mmsi.equals(newItem.mmsi); + } + + @Override + public boolean areContentsTheSame(@NonNull AISVesselEntity oldItem, @NonNull AISVesselEntity newItem) { + return oldItem.latitude == newItem.latitude && + oldItem.longitude == newItem.longitude && + oldItem.course == newItem.course && + oldItem.speed == newItem.speed && + ((oldItem.vesselName == null && newItem.vesselName == null) || (oldItem.vesselName != null && oldItem.vesselName.equals(newItem.vesselName))); + // Не проверяем lastUpdateEpochMs, чтобы избежать мигания при обновлении времени + } + }; + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_ais_target, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + AISVesselEntity item = getItem(position); + holder.bind(item, listener); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position, @NonNull java.util.List payloads) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads); + } else { + // Частичное обновление только времени + AISVesselEntity item = getItem(position); + holder.updateTimeOnly(item); + } + } + + static class ViewHolder extends RecyclerView.ViewHolder { + TextView tvTitle; + TextView tvMmsi; + TextView tvCoords; + TextView tvCourseSpeed; + TextView tvLastUpdate; + TextView tvTimeAgo; + Button btnMarineTraffic; + Button btnCenterOnMap; + + ViewHolder(@NonNull View itemView) { + super(itemView); + tvTitle = itemView.findViewById(R.id.tv_title); + tvMmsi = itemView.findViewById(R.id.tv_mmsi); + tvCoords = itemView.findViewById(R.id.tv_coords); + tvCourseSpeed = itemView.findViewById(R.id.tv_course_speed); + tvLastUpdate = itemView.findViewById(R.id.tv_last_update); + tvTimeAgo = itemView.findViewById(R.id.tv_time_ago); + btnMarineTraffic = itemView.findViewById(R.id.btn_marine_traffic); + btnCenterOnMap = itemView.findViewById(R.id.btn_center_on_map); + } + + void bind(AISVesselEntity entity, OnItemClickListener listener) { + String name = entity.vesselName != null && !entity.vesselName.isEmpty() ? entity.vesselName : "MMSI " + entity.mmsi; + tvTitle.setText(name); + tvMmsi.setText("MMSI: " + entity.mmsi); + tvCoords.setText(String.format(java.util.Locale.getDefault(), "%.6f, %.6f", entity.latitude, entity.longitude)); + tvCourseSpeed.setText(String.format(java.util.Locale.getDefault(), "COG %.1f° • %.1f kn", entity.course, entity.speed)); + // Время последнего обновления и ago + if (entity.lastUpdateEpochMs > 0) { + java.text.SimpleDateFormat df = new java.text.SimpleDateFormat("dd.MM.yyyy HH:mm:ss", java.util.Locale.getDefault()); + String last = df.format(new java.util.Date(entity.lastUpdateEpochMs)); + tvLastUpdate.setText("Обновлено: " + last); + long secondsAgo = (System.currentTimeMillis() - entity.lastUpdateEpochMs) / 1000L; + tvTimeAgo.setText("" + secondsAgo + " сек назад"); + } else { + tvLastUpdate.setText("Обновлено: --"); + tvTimeAgo.setText("-- сек назад"); + } + btnMarineTraffic.setOnClickListener(v -> listener.onMarinetrafficClick(entity.mmsi)); + btnCenterOnMap.setOnClickListener(v -> { + android.util.Log.i("AisTargetsAdapter", "Кнопка 'На карте' нажата для MMSI=" + entity.mmsi + ", lat=" + entity.latitude + ", lon=" + entity.longitude); + listener.onCenterOnMapClick(entity.mmsi, entity.latitude, entity.longitude); + }); + } + + void updateTimeOnly(AISVesselEntity entity) { + // Обновляем только поля времени, чтобы избежать мигания всего элемента + if (entity.lastUpdateEpochMs > 0) { + java.text.SimpleDateFormat df = new java.text.SimpleDateFormat("dd.MM.yyyy HH:mm:ss", java.util.Locale.getDefault()); + String last = df.format(new java.util.Date(entity.lastUpdateEpochMs)); + tvLastUpdate.setText("Обновлено: " + last); + long secondsAgo = (System.currentTimeMillis() - entity.lastUpdateEpochMs) / 1000L; + tvTimeAgo.setText("" + secondsAgo + " сек назад"); + } else { + tvLastUpdate.setText("Обновлено: --"); + tvTimeAgo.setText("-- сек назад"); + } + } + } +} + + diff --git a/app/src/main/java/com/grigowashere/aismap/MainActivity.java b/app/src/main/java/com/grigowashere/aismap/MainActivity.java index 78b1354..846d6bc 100644 --- a/app/src/main/java/com/grigowashere/aismap/MainActivity.java +++ b/app/src/main/java/com/grigowashere/aismap/MainActivity.java @@ -14,6 +14,7 @@ import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; import android.view.ViewGroup; +import android.graphics.Color; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.ActivityCompat; @@ -31,6 +32,7 @@ import com.grigowashere.aismap.view.CoordinatesDockWidget; import com.grigowashere.aismap.view.BaseDockWidget; import com.grigowashere.aismap.utils.SettingsManager; import com.grigowashere.aismap.utils.LogSender; +import com.grigowashere.aismap.utils.MIDToCountry; import com.yandex.mapkit.mapview.MapView; import java.util.List; import java.util.ArrayList; @@ -53,10 +55,15 @@ public class MainActivity extends AppCompatActivity { private Button btnCenterOnVessel; private Button btnMapOrientation; private Button btnSettings; + private Button btnAisTargets; private LinearLayout controlPanel; private CompassView compassView; private CompassSensor compassSensor; private CoordinatesDockWidget coordinatesWidget; + private TextView tvGpsAge; + private TextView tvAisAge; + private android.os.Handler messageAgeHandler; + private Runnable messageAgeRunnable; // BottomSheet для отображения информации о нашем судне private BottomSheetDialog ownVesselBottomSheet; @@ -73,6 +80,10 @@ public class MainActivity extends AppCompatActivity { private android.os.Handler bottomSheetUpdateHandler; // Handler для обновления BottomSheet private Runnable bottomSheetUpdateRunnable; // Runnable для обновления BottomSheet private static final int BOTTOM_SHEET_UPDATE_INTERVAL = 1000; // Обновление каждую секунду + + // Отложенное центрирование из внешнего интента + private Double pendingCenterLat = null; + private Double pendingCenterLon = null; @Override protected void onCreate(Bundle savedInstanceState) { @@ -93,9 +104,12 @@ public class MainActivity extends AppCompatActivity { btnCenterOnVessel = findViewById(R.id.btn_center_vessel); btnMapOrientation = findViewById(R.id.btn_map_orientation); btnSettings = findViewById(R.id.btn_settings); + btnAisTargets = findViewById(R.id.btn_ais_targets); controlPanel = findViewById(R.id.control_panel); compassView = findViewById(R.id.compass_view); coordinatesWidget = findViewById(R.id.coordinates_widget); + tvGpsAge = findViewById(R.id.tv_gps_age); + tvAisAge = findViewById(R.id.tv_ais_age); // Инициализируем магнитный компас compassSensor = new CompassSensor(this); @@ -104,12 +118,16 @@ public class MainActivity extends AppCompatActivity { setupButtonListeners(); setupCompass(); setupCoordinatesWidget(); + setupMessageAgesUpdater(); } private void setupButtonListeners() { btnCenterOnVessel.setOnClickListener(v -> centerOnVessel()); btnMapOrientation.setOnClickListener(v -> toggleMapOrientation()); btnSettings.setOnClickListener(v -> showSettings()); + if (btnAisTargets != null) { + btnAisTargets.setOnClickListener(v -> openAisTargets()); + } // Кнопка для показа информации о судне // Button btnShowVesselInfo = findViewById(R.id.btn_show_vessel_info); @@ -216,6 +234,46 @@ public class MainActivity extends AppCompatActivity { updateControlPanelPosition(); }); } + + private void setupMessageAgesUpdater() { + messageAgeHandler = new android.os.Handler(android.os.Looper.getMainLooper()); + messageAgeRunnable = new Runnable() { + @Override + public void run() { + try { + if (appController != null) { + int gpsSec = appController.getSecondsSinceLastGPSMessage(); + int aisSec = appController.getSecondsSinceLastAISMessage(); + if (tvGpsAge != null) { + tvGpsAge.setText(gpsSec >= 0 ? ("GPS: " + gpsSec + " сек назад") : "GPS: --"); + tvGpsAge.setTextColor(getAgeColor(gpsSec)); + } + if (tvAisAge != null) { + tvAisAge.setText(aisSec >= 0 ? ("AIS: " + aisSec + " сек назад") : "AIS: --"); + tvAisAge.setTextColor(getAgeColor(aisSec)); + } + } + } catch (Exception ignored) {} + messageAgeHandler.postDelayed(this, 1000); + } + }; + // Стартуем после первичной инициализации + messageAgeHandler.postDelayed(messageAgeRunnable, 1000); + } + + private int getAgeColor(int seconds) { + if (seconds < 0) { + // Нет данных + return Color.parseColor("#F44336"); // красный + } + if (seconds < 30) { + return Color.parseColor("#4CAF50"); // зелёный + } else if (seconds < 300) { + return Color.parseColor("#FFC107"); // жёлтый + } else { + return Color.parseColor("#F44336"); // красный + } + } private void onUpdateCompass(float azimuth, List nearbyVessels) { if (compassView != null) { @@ -329,6 +387,18 @@ public class MainActivity extends AppCompatActivity { mapController = new MapController(this); // Устанавливаем callback для обновления UI + + // Запускаем Foreground Service для фоновых обновлений AIS/GPS + try { + android.content.Intent svc = new android.content.Intent(this, com.grigowashere.aismap.services.AISForegroundService.class); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + startForegroundService(svc); + } else { + startService(svc); + } + } catch (Exception e) { + android.util.Log.e("MainActivity", "Не удалось запустить ForegroundService: " + e.getMessage(), e); + } appController.setUIUpdateCallback(new AppController.ExtendedUIUpdateCallback() { @Override public void onVesselPositionUpdated(Vessel vessel) { @@ -470,20 +540,45 @@ public class MainActivity extends AppCompatActivity { Toast.makeText(this, "Переключение ориентации карты (в разработке)", Toast.LENGTH_SHORT).show(); } + private void togglePathTracking() { + boolean currentState = settingsManager.isPathTrackingEnabled(); + boolean newState = !currentState; + + settingsManager.setPathTrackingEnabled(newState); + + // Обновляем состояние в карте + if (mapInterface instanceof com.grigowashere.aismap.maps.YandexMapImpl) { + ((com.grigowashere.aismap.maps.YandexMapImpl) mapInterface).setPathTrackingEnabled(newState); + } + + String message = newState ? "Отслеживание путей включено" : "Отслеживание путей выключено"; + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); + + // Обновляем меню + invalidateOptionsMenu(); + } + private void showSettings() { Intent intent = new Intent(this, SettingsActivity.class); startActivityForResult(intent, SETTINGS_REQUEST_CODE); } + + private void openAisTargets() { + Intent intent = new Intent(this, AisTargetsActivity.class); + startActivity(intent); + } /** * Обновляет позицию панели управления в зависимости от состояния docked виджетов */ private void updateControlPanelPosition() { if (controlPanel != null) { - runOnUiThread(() -> { - // Получаем текущие параметры layout - android.widget.RelativeLayout.LayoutParams params = - (android.widget.RelativeLayout.LayoutParams) controlPanel.getLayoutParams(); + // Используем postDelayed для предотвращения частых обновлений layout + controlPanel.postDelayed(() -> { + try { + // Получаем текущие параметры layout + android.widget.RelativeLayout.LayoutParams params = + (android.widget.RelativeLayout.LayoutParams) controlPanel.getLayoutParams(); int topMargin = dpToPx(16); // По умолчанию отступ сверху int bottomMargin = dpToPx(16); // По умолчанию отступ снизу @@ -525,16 +620,19 @@ public class MainActivity extends AppCompatActivity { // Применяем новые параметры controlPanel.setLayoutParams(params); - Log.d(TAG, "Control panel position updated: " + - "topMargin=" + topMargin + "px, " + - "bottomMargin=" + bottomMargin + "px, " + - "totalTopHeight=" + totalTopHeight + "px, " + - "totalBottomHeight=" + totalBottomHeight + "px, " + - "compassDocked=" + (compassView != null ? compassView.isDocked() : false) + - ", compassTop=" + (compassView != null ? compassView.isDockTop() : false) + - ", coordinatesDocked=" + (coordinatesWidget != null ? coordinatesWidget.isDocked() : false) + - ", coordinatesTop=" + (coordinatesWidget != null ? coordinatesWidget.isDockTop() : false)); - }); + Log.d(TAG, "Control panel position updated: " + + "topMargin=" + topMargin + "px, " + + "bottomMargin=" + bottomMargin + "px, " + + "totalTopHeight=" + totalTopHeight + "px, " + + "totalBottomHeight=" + totalBottomHeight + "px, " + + "compassDocked=" + (compassView != null ? compassView.isDocked() : false) + + ", compassTop=" + (compassView != null ? compassView.isDockTop() : false) + + ", coordinatesDocked=" + (coordinatesWidget != null ? coordinatesWidget.isDocked() : false) + + ", coordinatesTop=" + (coordinatesWidget != null ? coordinatesWidget.isDockTop() : false)); + } catch (Exception e) { + Log.e(TAG, "Ошибка при обновлении позиции панели управления: " + e.getMessage(), e); + } + }, 50); // Задержка 50мс для throttling } } @@ -574,6 +672,16 @@ public class MainActivity extends AppCompatActivity { mapInterface.initialize(); Log.i(TAG, "Карта инициализирована"); + + // Применяем отложенное центрирование, если было + applyPendingCenterIfAny(); + + // Инициализируем отслеживание путей + if (mapInterface instanceof com.grigowashere.aismap.maps.YandexMapImpl) { + boolean pathTrackingEnabled = settingsManager.isPathTrackingEnabled(); + ((com.grigowashere.aismap.maps.YandexMapImpl) mapInterface).setPathTrackingEnabled(pathTrackingEnabled); + Log.i(TAG, "Отслеживание путей: " + (pathTrackingEnabled ? "включено" : "выключено")); + } // Проверяем, что все настроено правильно Log.i(TAG, "Проверяем настройку карты..."); @@ -590,9 +698,53 @@ public class MainActivity extends AppCompatActivity { } } + // Обрабатываем возможный интент центрирования + handleCenterIntentIfAny(getIntent()); + // Проверяем разрешения и запускаем контроллеры checkPermissions(); } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + handleCenterIntentIfAny(intent); + } + + private void handleCenterIntentIfAny(Intent intent) { + if (intent == null) return; + if (intent.hasExtra("center_lat") && intent.hasExtra("center_lon")) { + double lat = intent.getDoubleExtra("center_lat", 0); + double lon = intent.getDoubleExtra("center_lon", 0); + Log.i(TAG, "Получен интент центрирования: lat=" + lat + ", lon=" + lon); + if (lat != 0 || lon != 0) { + if (mapInterface != null) { + Log.i(TAG, "Центрируем карту немедленно"); + mapInterface.centerOnPosition(lat, lon); + } else { + // Сохраняем для применения после инициализации карты + Log.i(TAG, "Сохраняем координаты для отложенного центрирования"); + pendingCenterLat = lat; + pendingCenterLon = lon; + } + } + // Сбрасываем, чтобы не повторялось при поворотах + intent.removeExtra("center_lat"); + intent.removeExtra("center_lon"); + intent.removeExtra("center_mmsi"); + } + } + + private void applyPendingCenterIfAny() { + if (mapInterface == null) return; + if (pendingCenterLat != null && pendingCenterLon != null) { + Log.i(TAG, "Применяем отложенное центрирование: lat=" + pendingCenterLat + ", lon=" + pendingCenterLon); + mapInterface.centerOnPosition(pendingCenterLat, pendingCenterLon); + pendingCenterLat = null; + pendingCenterLon = null; + } + } @Override protected void onStop() { @@ -603,10 +755,10 @@ public class MainActivity extends AppCompatActivity { mapInterface.cleanup(); } - // Останавливаем все слушатели - if (appController != null) { - appController.stopAllListeners(); - } + // Не останавливаем слушатели здесь, чтобы UDP продолжал работать в фоне + // if (appController != null) { + // appController.stopAllListeners(); + // } } @Override @@ -727,6 +879,12 @@ public class MainActivity extends AppCompatActivity { udpItem.setTitle(appController.isUDPEnabled() ? "UDP ✓" : "UDP"); } + MenuItem pathItem = menu.findItem(R.id.menu_path_tracking); + if (pathItem != null) { + boolean pathEnabled = settingsManager.isPathTrackingEnabled(); + pathItem.setTitle(pathEnabled ? "Пути ✓" : "Пути"); + } + return true; } @@ -743,6 +901,9 @@ public class MainActivity extends AppCompatActivity { } else if (id == R.id.menu_clear_ais) { clearAIS(); return true; + } else if (id == R.id.menu_path_tracking) { + togglePathTracking(); + return true; } return super.onOptionsItemSelected(item); @@ -996,12 +1157,13 @@ public class MainActivity extends AppCompatActivity { // Обновляем все поля в AIS BottomSheet TextView tvTitle = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_title); TextView tvMmsi = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_mmsi); - TextView tvName = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_name); TextView tvCallsign = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_callsign); TextView tvImo = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_imo); TextView tvType = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_type); TextView tvPosition = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_position); TextView tvCourse = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_course); + TextView tvRot = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_rot); + TextView tvHeading = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_heading); TextView tvSpeed = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_speed); TextView tvDimensions = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_dimensions); TextView tvDraft = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_draft); @@ -1015,9 +1177,11 @@ public class MainActivity extends AppCompatActivity { // Заголовок if (tvTitle != null) { - String title = vessel.getVesselName() != null && !vessel.getVesselName().isEmpty() - ? "🚢 " + vessel.getVesselName() - : "🚢 AIS СУДНО"; + String name = vessel.getVesselName() != null && !vessel.getVesselName().isEmpty() + ? vessel.getVesselName() + : "AIS СУДНО"; + String flag = getFlagEmojiForMMSI(vessel.getMmsi()); + String title = (flag != null ? flag + " " : "") + "🚢 " + name; tvTitle.setText(title); } @@ -1027,9 +1191,7 @@ public class MainActivity extends AppCompatActivity { } // Название судна - if (tvName != null) { - tvName.setText("📛 Название: " + (vessel.getVesselName() != null ? vessel.getVesselName() : "--")); - } + // Позывной if (tvCallsign != null) { @@ -1057,13 +1219,34 @@ public class MainActivity extends AppCompatActivity { } } - // Курс + // Курс (COG) if (tvCourse != null) { if (vessel.getCourse() > 0) { - String courseText = String.format("🧭 Курс: %.1f°", vessel.getCourse()); + String courseText = String.format("🧭 COG: %.1f°", vessel.getCourse()); tvCourse.setText(courseText); } else { - tvCourse.setText("🧭 Курс: --°"); + tvCourse.setText("🧭 COG: --°"); + } + } + + // Скорость поворота (ROT) + if (tvRot != null) { + double rot = vessel.getRateOfTurn(); + if (rot != 0) { + String rotText = String.format("🔄 ROT: %.1f°/мин", rot); + tvRot.setText(rotText); + } else { + tvRot.setText("🔄 ROT: --°/мин"); + } + } + + // Направление (HDG) + if (tvHeading != null) { + if (vessel.getHeading() > 0) { + String headingText = String.format("🧭 HDG: %.1f°", vessel.getHeading()); + tvHeading.setText(headingText); + } else { + tvHeading.setText("🧭 HDG: --°"); } } @@ -1130,7 +1313,9 @@ public class MainActivity extends AppCompatActivity { String signalText = String.format("📶 Сигнал: %d", vessel.getSignalStrength()); tvSignal.setText(signalText); } else { - tvSignal.setText("📶 Сигнал: --"); + // Показываем качество позиции по AIS Accuracy биту + String qualityText = vessel.isPositionAccuracy() ? "📶 Точность: высокая" : "📶 Точность: низкая"; + tvSignal.setText(qualityText); } } @@ -1175,6 +1360,26 @@ public class MainActivity extends AppCompatActivity { return days + " дн"; } } + + /** + * Возвращает флаг-эмодзи по MMSI через MID->ISO2. + */ + private String getFlagEmojiForMMSI(String mmsi) { + try { + if (mmsi == null || mmsi.length() < 3) return null; + String mid = mmsi.substring(0, 3); + String iso2 = MIDToCountry.MID_TO_COUNTRY.get(mid); + if (iso2 == null || iso2.length() != 2) return null; + char a = Character.toUpperCase(iso2.charAt(0)); + char b = Character.toUpperCase(iso2.charAt(1)); + int base = 0x1F1E6; + int cp1 = base + (a - 'A'); + int cp2 = base + (b - 'A'); + return new String(Character.toChars(cp1)) + new String(Character.toChars(cp2)); + } catch (Exception ignored) { + return null; + } + } /** * Восстанавливает обработчики кликов для маркеров @@ -1226,7 +1431,7 @@ public class MainActivity extends AppCompatActivity { if (!isYandexMapsInitialized) { try { // Инициализация Яндекс.Карт - com.yandex.mapkit.MapKitFactory.setApiKey("your_api_key_here"); + com.yandex.mapkit.MapKitFactory.setApiKey("9ae1917c-2049-4927-9d1e-29dd0d3e8ebc"); com.yandex.mapkit.MapKitFactory.initialize(this); isYandexMapsInitialized = true; diff --git a/app/src/main/java/com/grigowashere/aismap/SettingsActivity.java b/app/src/main/java/com/grigowashere/aismap/SettingsActivity.java index fcccbdf..c95c429 100644 --- a/app/src/main/java/com/grigowashere/aismap/SettingsActivity.java +++ b/app/src/main/java/com/grigowashere/aismap/SettingsActivity.java @@ -33,6 +33,10 @@ public class SettingsActivity extends AppCompatActivity { private RadioButton radioHybridMode; private RadioButton radioNMEAOnly; private RadioButton radioAndroidOnly; + private EditText etStaleWarningMinutes; + private EditText etStaleRemoveMinutes; + private SwitchMaterial switchVibrationEnabled; + private SwitchMaterial switchSoundEnabled; private Button btnCancel; private Button btnSave; @@ -42,6 +46,10 @@ public class SettingsActivity extends AppCompatActivity { private boolean originalAndroidNMEAEnabled; private boolean originalUDPNMEAEnabled; private String originalDataMode; + private int originalStaleWarningMinutes; + private int originalStaleRemoveMinutes; + private boolean originalVibrationEnabled; + private boolean originalSoundEnabled; @Override protected void onCreate(Bundle savedInstanceState) { @@ -78,6 +86,10 @@ public class SettingsActivity extends AppCompatActivity { radioHybridMode = findViewById(R.id.radio_hybrid_mode); radioNMEAOnly = findViewById(R.id.radio_nmea_only); radioAndroidOnly = findViewById(R.id.radio_android_only); + etStaleWarningMinutes = findViewById(R.id.et_stale_warning_minutes); + etStaleRemoveMinutes = findViewById(R.id.et_stale_remove_minutes); + switchVibrationEnabled = findViewById(R.id.switch_vibration_enabled); + switchSoundEnabled = findViewById(R.id.switch_sound_enabled); btnCancel = findViewById(R.id.btn_cancel); btnSave = findViewById(R.id.btn_save); } @@ -108,6 +120,14 @@ public class SettingsActivity extends AppCompatActivity { break; } + // Настройки устаревания данных + etStaleWarningMinutes.setText(String.valueOf(settingsManager.getDataStaleWarningMinutes())); + etStaleRemoveMinutes.setText(String.valueOf(settingsManager.getDataStaleRemoveMinutes())); + + // Настройки уведомлений + switchVibrationEnabled.setChecked(settingsManager.isVibrationEnabled()); + switchSoundEnabled.setChecked(settingsManager.isSoundEnabled()); + Log.i(TAG, "Настройки загружены в UI"); } @@ -120,6 +140,10 @@ public class SettingsActivity extends AppCompatActivity { originalAndroidNMEAEnabled = settingsManager.isAndroidNMEAEnabled(); originalUDPNMEAEnabled = settingsManager.isUDPNMEAEnabled(); originalDataMode = settingsManager.getDataMode(); + originalStaleWarningMinutes = settingsManager.getDataStaleWarningMinutes(); + originalStaleRemoveMinutes = settingsManager.getDataStaleRemoveMinutes(); + originalVibrationEnabled = settingsManager.isVibrationEnabled(); + originalSoundEnabled = settingsManager.isSoundEnabled(); Log.i(TAG, "Оригинальные настройки сохранены"); } @@ -221,12 +245,29 @@ public class SettingsActivity extends AppCompatActivity { return; } + // Валидируем настройки устаревания данных + int staleWarningMinutes = validateStaleMinutes(etStaleWarningMinutes.getText().toString().trim(), "время предупреждения"); + if (staleWarningMinutes == -1) return; + + int staleRemoveMinutes = validateStaleMinutes(etStaleRemoveMinutes.getText().toString().trim(), "время удаления"); + if (staleRemoveMinutes == -1) return; + + // Проверяем логичность значений + if (staleWarningMinutes >= staleRemoveMinutes) { + Toast.makeText(this, "Время предупреждения должно быть меньше времени удаления", Toast.LENGTH_SHORT).show(); + return; + } + // Сохраняем настройки settingsManager.setUDPPort(udpPort); settingsManager.setUDPEnabled(switchUDPEnabled.isChecked()); settingsManager.setAndroidNMEAEnabled(switchAndroidNMEAEnabled.isChecked()); settingsManager.setUDPNMEAEnabled(switchUDPNMEAEnabled.isChecked()); settingsManager.setDataMode(dataMode); + settingsManager.setDataStaleWarningMinutes(staleWarningMinutes); + settingsManager.setDataStaleRemoveMinutes(staleRemoveMinutes); + settingsManager.setVibrationEnabled(switchVibrationEnabled.isChecked()); + settingsManager.setSoundEnabled(switchSoundEnabled.isChecked()); Log.i(TAG, "Настройки сохранены: " + settingsManager.getSettingsSummary()); @@ -307,6 +348,28 @@ public class SettingsActivity extends AppCompatActivity { return true; } + /** + * Валидирует время устаревания данных + */ + private int validateStaleMinutes(String text, String fieldName) { + if (text.isEmpty()) { + Toast.makeText(this, fieldName + " не может быть пустым", Toast.LENGTH_SHORT).show(); + return -1; + } + + try { + int minutes = Integer.parseInt(text); + if (minutes < 1 || minutes > 60) { + Toast.makeText(this, fieldName + " должно быть от 1 до 60 минут", Toast.LENGTH_SHORT).show(); + return -1; + } + return minutes; + } catch (NumberFormatException e) { + Toast.makeText(this, "Некорректный формат " + fieldName, Toast.LENGTH_SHORT).show(); + return -1; + } + } + /** * Проверяет, нужно ли перезапустить сервисы */ diff --git a/app/src/main/java/com/grigowashere/aismap/controllers/AppController.java b/app/src/main/java/com/grigowashere/aismap/controllers/AppController.java index c1debdc..2c110e0 100644 --- a/app/src/main/java/com/grigowashere/aismap/controllers/AppController.java +++ b/app/src/main/java/com/grigowashere/aismap/controllers/AppController.java @@ -5,6 +5,9 @@ import android.util.Log; import com.grigowashere.aismap.models.Vessel; import com.grigowashere.aismap.models.AISVessel; import com.grigowashere.aismap.maps.MapInterface; +import com.grigowashere.aismap.data.Repository; +import com.grigowashere.aismap.data.mapper.AISVesselMapper; +import com.grigowashere.aismap.services.NotificationService; import java.util.List; import java.util.ArrayList; import java.util.concurrent.ExecutorService; @@ -34,6 +37,8 @@ public class AppController implements private Vessel ownVessel; private List aisVessels; private ExecutorService executor; + private com.grigowashere.aismap.data.Repository repository; + private NotificationService notificationService; private boolean isUDPEnabled; private boolean isAndroidNMEAEnabled; @@ -42,6 +47,15 @@ public class AppController implements private int udpPort; private String dataMode; + // Время последнего получения сообщений ($ GPS) и (! AIS) в elapsedRealtime + private long lastGPSMessageRealtimeMs; + private long lastAISMessageRealtimeMs; + + // Периодическая очистка БД от устаревших AIS целей + private android.os.Handler dbCleanupHandler; + private Runnable dbCleanupRunnable; + private static final long DB_CLEANUP_INTERVAL = 60000; // 1 минута + // Callback для обновления UI private UIUpdateCallback uiUpdateCallback; @@ -64,6 +78,12 @@ public class AppController implements this.ownVessel = new Vessel(); this.aisVessels = new ArrayList<>(); this.executor = Executors.newCachedThreadPool(); + this.repository = new com.grigowashere.aismap.data.Repository(context); + this.notificationService = new NotificationService(context); + + // Инициализируем Handler для периодической очистки БД + this.dbCleanupHandler = new android.os.Handler(android.os.Looper.getMainLooper()); + this.dbCleanupRunnable = this::performDatabaseCleanup; initializeControllers(); } @@ -92,6 +112,29 @@ public class AppController implements // Инициализация Android NMEA слушателя (для курса, скорости, DOP) androidNmeaListener = new AndroidNMEAListener(context); androidNmeaListener.setCallback(this); + + // Восстанавливаем данные из БД при старте + try { + com.grigowashere.aismap.data.entity.VesselEntity latest = repository.getLatestOwnVesselSync(); + if (latest != null) { + ownVessel.setLatitude(latest.latitude); + ownVessel.setLongitude(latest.longitude); + ownVessel.setAccuracy(latest.accuracy); + ownVessel.setFixTime(latest.fixTime); + } + java.util.List list = repository.getAllAISSync(); + if (list != null) { + for (com.grigowashere.aismap.data.entity.AISVesselEntity entity : list) { + // Используем маппер для полного восстановления всех полей + AISVessel vessel = AISVesselMapper.toModel(entity); + aisVessels.add(vessel); + Log.d(TAG, "AIS судно восстановлено из БД с полными данными: " + vessel.getMmsi()); + } + Log.i(TAG, "Восстановлено " + list.size() + " AIS судов из БД с полными данными"); + } + } catch (Exception e) { + Log.e(TAG, "Ошибка восстановления данных из БД: " + e.getMessage(), e); + } } /** @@ -104,6 +147,24 @@ public class AppController implements Log.i(TAG, "Устанавливаем MarkerClickListener в MapInterface"); mapInterface.setMarkerClickListener(this); Log.i(TAG, "MarkerClickListener установлен, теперь можно создавать маркеры"); + + // Восстановление отрисовки сохранённых данных на карте + new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> { + try { + // Позиция нашего судна + if (ownVessel != null && ownVessel.getLatitude() != 0 && ownVessel.getLongitude() != 0) { + mapInterface.updateOwnVesselPosition(ownVessel); + } + // AIS маркеры + if (aisVessels != null && !aisVessels.isEmpty()) { + for (AISVessel v : aisVessels) { + mapInterface.addAISVesselMarker(v); + } + } + } catch (Exception e) { + Log.e(TAG, "Ошибка восстановления отрисовки на карте: " + e.getMessage(), e); + } + }); } } @@ -135,7 +196,8 @@ public class AppController implements }); } - + // Запускаем периодическую очистку БД от устаревших AIS целей + startDatabaseCleanup(); } @@ -144,6 +206,9 @@ public class AppController implements * Останавливает все слушатели */ public void stopAllListeners() { + // Останавливаем периодическую очистку БД + stopDatabaseCleanup(); + executor.execute(() -> { udpListener.stop(); androidNmeaListener.stopListening(); @@ -279,15 +344,28 @@ public class AppController implements ownVessel.setFixTime(vessel.getFixTime()); ownVessel.setFixQuality(vessel.getFixQuality()); + // Сохраняем позицию в локальную БД + try { + com.grigowashere.aismap.data.entity.VesselEntity ve = new com.grigowashere.aismap.data.entity.VesselEntity(); + ve.latitude = ownVessel.getLatitude(); + ve.longitude = ownVessel.getLongitude(); + ve.accuracy = ownVessel.getAccuracy(); + ve.fixTime = ownVessel.getFixTime(); + repository.upsertOwnVessel(ve); + } catch (Exception e) { + Log.e(TAG, "Ошибка сохранения позиции в БД: " + e.getMessage(), e); + } + // Обновляем UI через callback if (uiUpdateCallback != null) { uiUpdateCallback.onVesselPositionUpdated(ownVessel); } - // Обновляем карту в главном потоке + // Обновляем карту в главном потоке с throttling if (mapInterface != null) { Log.i(TAG, "Обновляем позицию на карте..."); - new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> { + // Используем postDelayed для предотвращения частых обновлений + new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { try { Log.i(TAG, "Вызываем mapInterface.updateOwnVesselPosition..."); mapInterface.updateOwnVesselPosition(ownVessel); @@ -295,7 +373,7 @@ public class AppController implements } catch (Exception e) { Log.e(TAG, "Ошибка обновления позиции на карте: " + e.getMessage(), e); } - }); + }, 100); // Задержка 100мс для throttling } } @@ -381,6 +459,17 @@ public class AppController implements AISVessel existingVessel = findAISVesselByMMSI(vessel.getMmsi()); if (existingVessel != null) { + // Если пришло новое safety-сообщение (тип 14), уведомим пользователя + if (vessel.getLastSafetyMessage() != null && !vessel.getLastSafetyMessage().isEmpty()) { + String prev = existingVessel.getLastSafetyMessage(); + String curr = vessel.getLastSafetyMessage(); + if (prev == null || !prev.equals(curr)) { + if (notificationService != null && notificationService.areNotificationsEnabled()) { + notificationService.notifySafetyMessage(vessel.getMmsi(), curr); + } + } + existingVessel.setLastSafetyMessage(curr); + } // Обновляем существующее судно existingVessel.updatePosition( vessel.getLatitude(), @@ -388,6 +477,14 @@ public class AppController implements vessel.getCourse(), vessel.getSpeed() ); + try { + // Используем маппер для полной конвертации всех полей + com.grigowashere.aismap.data.entity.AISVesselEntity entity = AISVesselMapper.toEntity(existingVessel); + repository.upsertAIS(entity); + Log.d(TAG, "AIS судно сохранено в БД с полными данными: " + existingVessel.getMmsi()); + } catch (Exception e) { + Log.e(TAG, "Ошибка апсерта AIS в БД: " + e.getMessage(), e); + } if (mapInterface != null) { // Используем Handler для выполнения в главном потоке @@ -402,6 +499,28 @@ public class AppController implements } else { // Добавляем новое судно aisVessels.add(vessel); + + // Если это новое судно сразу пришло с safety-сообщением — уведомим + if (vessel.getLastSafetyMessage() != null && !vessel.getLastSafetyMessage().isEmpty()) { + if (notificationService != null && notificationService.areNotificationsEnabled()) { + notificationService.notifySafetyMessage(vessel.getMmsi(), vessel.getLastSafetyMessage()); + } + } + + // Воспроизводим уведомление о новой цели + if (notificationService != null && notificationService.areNotificationsEnabled()) { + notificationService.notifyNewAISTarget(); + Log.i(TAG, "🔔 Уведомление о новой AIS цели: " + vessel.getMmsi()); + } + + try { + // Используем маппер для полной конвертации всех полей + com.grigowashere.aismap.data.entity.AISVesselEntity entity = AISVesselMapper.toEntity(vessel); + repository.upsertAIS(entity); + Log.d(TAG, "Новое AIS судно сохранено в БД с полными данными: " + vessel.getMmsi()); + } catch (Exception e) { + Log.e(TAG, "Ошибка апсерта AIS в БД: " + e.getMessage(), e); + } if (mapInterface != null) { // Используем Handler для выполнения в главном потоке @@ -465,6 +584,8 @@ public class AppController implements // Парсим полученные данные как NMEA nmeaParser.parseNMEA(data); + // Обновляем метки времени по префиксу + updateLastMessageAgesFromRaw(data); } @Override @@ -485,6 +606,18 @@ public class AppController implements // Парсим полученные данные как NMEA nmeaParser.parseNMEA(message); + if (message != null) { + String trimmed = message.trim(); + if (!trimmed.isEmpty()) { + char c = trimmed.charAt(0); + long now = android.os.SystemClock.elapsedRealtime(); + if (c == '$') { + lastGPSMessageRealtimeMs = now; + } else if (c == '!') { + lastAISMessageRealtimeMs = now; + } + } + } } // Реализация MarkerClickListener @@ -577,11 +710,56 @@ public class AppController implements } } + /** + * Запускает периодическую очистку БД от устаревших AIS целей + */ + public void startDatabaseCleanup() { + if (dbCleanupHandler != null && dbCleanupRunnable != null) { + dbCleanupHandler.postDelayed(dbCleanupRunnable, DB_CLEANUP_INTERVAL); + Log.i(TAG, "Запущена периодическая очистка БД от устаревших AIS целей"); + } + } + + /** + * Останавливает периодическую очистку БД + */ + public void stopDatabaseCleanup() { + if (dbCleanupHandler != null && dbCleanupRunnable != null) { + dbCleanupHandler.removeCallbacks(dbCleanupRunnable); + Log.i(TAG, "Остановлена периодическая очистка БД от устаревших AIS целей"); + } + } + + /** + * Выполняет очистку БД от устаревших AIS целей + */ + private void performDatabaseCleanup() { + try { + com.grigowashere.aismap.utils.SettingsManager settingsManager = + new com.grigowashere.aismap.utils.SettingsManager(context); + + int staleRemoveMinutes = settingsManager.getDataStaleRemoveMinutes(); + long thresholdEpochMs = System.currentTimeMillis() - (staleRemoveMinutes * 60 * 1000L); + + repository.deleteStaleAIS(thresholdEpochMs); + + Log.i(TAG, "Выполнена очистка БД от AIS целей старше " + staleRemoveMinutes + " минут"); + + // Планируем следующую очистку + if (dbCleanupHandler != null && dbCleanupRunnable != null) { + dbCleanupHandler.postDelayed(dbCleanupRunnable, DB_CLEANUP_INTERVAL); + } + } catch (Exception e) { + Log.e(TAG, "Ошибка при очистке БД от устаревших AIS целей: " + e.getMessage(), e); + } + } + /** * Освобождает ресурсы */ public void cleanup() { stopAllListeners(); + stopDatabaseCleanup(); if (udpListener != null) { udpListener.cleanup(); @@ -595,11 +773,51 @@ public class AppController implements gpsLocationListener.cleanup(); } + if (notificationService != null) { + notificationService.cleanup(); + } + if (executor != null && !executor.isShutdown()) { executor.shutdown(); } } + // ===== Метки времени последних сообщений ($ и !) ===== + private void updateLastMessageAgesFromRaw(String raw) { + if (raw == null) return; + long now = android.os.SystemClock.elapsedRealtime(); + String[] lines = raw.split("\r?\n"); + for (String line : lines) { + if (line == null) continue; + String t = line.trim(); + if (t.isEmpty()) continue; + char c = t.charAt(0); + if (c == '$') { + lastGPSMessageRealtimeMs = now; + break; + } else if (c == '!') { + lastAISMessageRealtimeMs = now; + break; + } + } + } + + /** Возвращает секунды с последнего GPS ($) сообщения; -1 если не было */ + public int getSecondsSinceLastGPSMessage() { + if (lastGPSMessageRealtimeMs <= 0) return -1; + long diff = android.os.SystemClock.elapsedRealtime() - lastGPSMessageRealtimeMs; + if (diff < 0) return 0; + return (int)(diff / 1000L); + } + + /** Возвращает секунды с последнего AIS (!) сообщения; -1 если не было */ + public int getSecondsSinceLastAISMessage() { + if (lastAISMessageRealtimeMs <= 0) return -1; + long diff = android.os.SystemClock.elapsedRealtime() - lastAISMessageRealtimeMs; + if (diff < 0) return 0; + return (int)(diff / 1000L); + } + // Методы для управления настройками /** diff --git a/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java b/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java index 80f5770..f353ffb 100644 --- a/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java +++ b/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java @@ -952,8 +952,26 @@ public class NMEAParser { // Rate of Turn (8 бит) - бит 42 String rotBits = decodeAISField(payload, 42, 8); - int rot = Integer.parseInt(rotBits, 2); - Log.d(TAG, "Rate of Turn bits: " + rotBits + " = " + rot); + int rotRaw = Integer.parseInt(rotBits, 2); + if (rotRaw > 127) { + rotRaw -= 256; + } + double rateOfTurn = parseRateOfTurn(rotRaw); + Log.d(TAG, "Rate of Turn bits: " + rotBits + " = " + rotRaw + " -> " + rateOfTurn + " °/min"); + + // Дополнительная отладка - показываем все биты payload + String fullBinary = payloadToBinary(payload); + Log.d(TAG, "Full payload binary: " + fullBinary); + Log.d(TAG, "ROT bits 42-49: " + fullBinary.substring(42, Math.min(50, fullBinary.length()))); + + // Ищем ROT в разных позициях для отладки + // for (int pos = 0; pos < Math.min(fullBinary.length() - 8, 100); pos++) { + // String testBits = fullBinary.substring(pos, pos + 8); + // int testValue = Integer.parseInt(testBits, 2); + // double testRot = parseRateOfTurn(testValue); + // Log.d(TAG, String.format("Position %d: bits=%s, value=%d, rot=%.1f", + // pos, testBits, testValue, testRot)); + // } // Speed Over Ground (10 бит) - бит 50 String speedBits = decodeAISField(payload, 50, 10); @@ -998,20 +1016,44 @@ public class NMEAParser { Log.w(TAG, "Долгота вне допустимых пределов: " + longitude); } - Log.d(TAG, String.format("AIS Position: MMSI=%d, lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, status=%d, heading=%.1f", - mmsi, latitude, longitude, course, speed, status, heading)); + Log.d(TAG, String.format("AIS Position: MMSI=%d, lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, status=%d, heading=%.1f, rot=%.1f", + mmsi, latitude, longitude, course, speed, status, heading, rateOfTurn)); // Создаем или обновляем AIS судно AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); - vessel.updatePosition(latitude, longitude, course, speed); + vessel.updatePosition(latitude, longitude, course, speed, rateOfTurn); + vessel.setPositionAccuracy(accuracy == 1); vessel.setHeading(heading); vessel.setNavigationalStatus(getNavigationStatus(status)); vessel.setLastUpdate(java.time.LocalDateTime.now()); + // Помечаем класс судна как Class A, чтобы предотвратить дальнейшее перезаписывание Class B сообщениями + vessel.setVesselClass("Class A"); - // Отправляем информацию о корабле на внешний ресурс - String vesselInfo = String.format("lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, status=%s", - latitude, longitude, course, speed, getNavigationStatus(status)); - LogSender.logShipUpdate(String.valueOf(mmsi), vesselInfo); + // Отправляем информацию о корабле на внешний ресурс (помечаем как Class A и добавляем статические поля, если известны) + StringBuilder infoA = new StringBuilder( + String.format(java.util.Locale.US, + "Class A: lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, rot=%.1f, status=%s", + latitude, longitude, course, speed, rateOfTurn, getNavigationStatus(status)) + ); + if (vessel.getVesselName() != null && !vessel.getVesselName().trim().isEmpty()) { + infoA.append(String.format(java.util.Locale.US, ", name='%s'", vessel.getVesselName())); + } + if (vessel.getCallSign() != null && !vessel.getCallSign().trim().isEmpty()) { + infoA.append(String.format(java.util.Locale.US, ", callSign='%s'", vessel.getCallSign())); + } + if (vessel.getVesselType() != null && !vessel.getVesselType().trim().isEmpty()) { + infoA.append(String.format(java.util.Locale.US, ", type=%s", vessel.getVesselType())); + } + if (vessel.getLength() > 0 || vessel.getWidth() > 0) { + infoA.append(String.format(java.util.Locale.US, ", L=%.1f, W=%.1f", vessel.getLength(), vessel.getWidth())); + } + if (vessel.getDraft() > 0) { + infoA.append(String.format(java.util.Locale.US, ", D=%.1f", vessel.getDraft())); + } + if (vessel.getDestination() != null && !vessel.getDestination().trim().isEmpty()) { + infoA.append(String.format(java.util.Locale.US, ", dest='%s'", vessel.getDestination())); + } + LogSender.logShipUpdate(String.valueOf(mmsi), infoA.toString()); // Уведомляем слушателя if (listener != null) { @@ -1147,8 +1189,9 @@ public class NMEAParser { vessel.setEta(etaDateTime); // Добавляем ETA в модель vessel.setLastUpdate(java.time.LocalDateTime.now()); - // Отправляем информацию о корабле на внешний ресурс - String vesselInfo = String.format("name='%s', callSign='%s', type=%s, L=%.1f, W=%.1f, D=%.1f, dest='%s'", + // Отправляем информацию о корабле на внешний ресурс (помечаем как Class A Static) + String vesselInfo = String.format(java.util.Locale.US, + "Class A Static: name='%s', callSign='%s', type=%s, L=%.1f, W=%.1f, D=%.1f, dest='%s'", vesselName, callSign, getVesselType(vesselTypeCode), length, width, draft, destination); LogSender.logShipUpdate(String.valueOf(mmsi), vesselInfo); @@ -1207,6 +1250,48 @@ public class NMEAParser { } } + /** + * Преобразует AIS payload в полную битовую строку для отладки + */ + private String payloadToBinary(String payload) { + StringBuilder result = new StringBuilder(); + for (int i = 0; i < payload.length(); i++) { + int ascii = payload.charAt(i); + int value; + if (ascii >= 48 && ascii <= 87) { + value = ascii - 48; + } else if (ascii >= 88 && ascii <= 119) { + value = ascii - 56; + } else { + value = 0; + } + String binary = String.format("%6s", Integer.toBinaryString(value)).replace(' ', '0'); + result.append(binary); + } + return result.toString(); + } + + /** + * Парсит Rate of Turn согласно стандарту AIS + * ROTAIS = 4.733 SQRT(ROTINDICATED) degrees/min + * Значения: 0-126 = поворот вправо, 127 = поворот влево >5°/30с, 128-255 = поворот влево + */ + private double parseRateOfTurn(int rotRaw) { + if (rotRaw == -128) { + return Double.NaN; // Нет данных + } + if (rotRaw == -127) { + return -720.0; // Влево > 708°/мин + } + if (rotRaw == 127) { + return 720.0; // Вправо > 708°/мин + } + + // В диапазоне -126..126 + double rot = rotRaw / 4.733; + return Math.signum(rotRaw) * rot * rot; + } + /** * Парсит AIS координаты */ @@ -1235,31 +1320,95 @@ public class NMEAParser { } /** - * Декодирует AIS строку + * Декодирует AIS строку согласно стандарту ITU-R M.1371-5, таблица 44 + * Простой switch case для всех 64 возможных значений 6-битной кодировки */ - //TODO: Исправить на нормальный декодер строк private String decodeAISString(String bits) { StringBuilder result = new StringBuilder(); + + Log.d(TAG, "Декодируем AIS строку, биты: " + bits + " (длина: " + bits.length() + ")"); for (int i = 0; i + 6 <= bits.length(); i += 6) { String charBits = bits.substring(i, i + 6); int value = Integer.parseInt(charBits, 2); char decodedChar; - if (value == 0) { - decodedChar = ' '; // 0 = пробел - } else if (value >= 1 && value <= 26) { - decodedChar = (char) ('A' + value - 1); // 1..26 = A..Z - } else if (value >= 27 && value <= 36) { - decodedChar = (char) ('0' + (value - 27)); // 27..36 = 0..9 - } else { - decodedChar = ' '; // всё остальное = пробел + // Простой switch case для всех 64 возможных значений + switch (value) { + case 0: decodedChar = ' '; break; + case 1: decodedChar = 'A'; break; + case 2: decodedChar = 'B'; break; + case 3: decodedChar = 'C'; break; + case 4: decodedChar = 'D'; break; + case 5: decodedChar = 'E'; break; + case 6: decodedChar = 'F'; break; + case 7: decodedChar = 'G'; break; + case 8: decodedChar = 'H'; break; + case 9: decodedChar = 'I'; break; + case 10: decodedChar = 'J'; break; + case 11: decodedChar = 'K'; break; + case 12: decodedChar = 'L'; break; + case 13: decodedChar = 'M'; break; + case 14: decodedChar = 'N'; break; + case 15: decodedChar = 'O'; break; + case 16: decodedChar = 'P'; break; + case 17: decodedChar = 'Q'; break; + case 18: decodedChar = 'R'; break; + case 19: decodedChar = 'S'; break; + case 20: decodedChar = 'T'; break; + case 21: decodedChar = 'U'; break; + case 22: decodedChar = 'V'; break; + case 23: decodedChar = 'W'; break; + case 24: decodedChar = 'X'; break; + case 25: decodedChar = 'Y'; break; + case 26: decodedChar = 'Z'; break; + case 27: decodedChar = '0'; break; + case 28: decodedChar = '1'; break; + case 29: decodedChar = '2'; break; + case 30: decodedChar = '3'; break; + case 31: decodedChar = '4'; break; + case 32: decodedChar = ' '; break; // пробел + case 33: decodedChar = '5'; break; + case 34: decodedChar = '6'; break; + case 35: decodedChar = '7'; break; + case 36: decodedChar = '8'; break; + case 37: decodedChar = '9'; break; + case 38: decodedChar = ' '; break; // пробел + case 39: decodedChar = ' '; break; // пробел + case 40: decodedChar = ' '; break; // пробел + case 41: decodedChar = ' '; break; // пробел + case 42: decodedChar = ' '; break; // пробел + case 43: decodedChar = ' '; break; // пробел + case 44: decodedChar = ' '; break; // пробел + case 45: decodedChar = ' '; break; // пробел + case 46: decodedChar = ' '; break; // пробел + case 47: decodedChar = ' '; break; // пробел + case 48: decodedChar = '0'; break; // пробел + case 49: decodedChar = '1'; break; // пробел + case 50: decodedChar = '2'; break; // пробел + case 51: decodedChar = '3'; break; // пробел + case 52: decodedChar = '4'; break; // пробел + case 53: decodedChar = '5'; break; // пробел + case 54: decodedChar = '6'; break; // пробел + case 55: decodedChar = '7'; break; // пробел + case 56: decodedChar = '8'; break; // пробел + case 57: decodedChar = '9'; break; // пробел + case 58: decodedChar = ' '; break; // пробел + case 59: decodedChar = ' '; break; // пробел + case 60: decodedChar = ' '; break; // пробел + case 61: decodedChar = ' '; break; // пробел + case 62: decodedChar = ' '; break; // пробел + case 63: decodedChar = ' '; break; // пробел + default: decodedChar = ' '; break; // на всякий случай } - + + Log.d(TAG, "Символ " + (i/6 + 1) + ": биты=" + charBits + ", значение=" + value + ", символ='" + decodedChar + "'"); result.append(decodedChar); } - - return result.toString().trim(); + + String resultStr = result.toString().trim(); + Log.d(TAG, "Результат декодирования: '" + resultStr + "'"); + return resultStr; } /** @@ -1703,6 +1852,12 @@ public class NMEAParser { Log.d(TAG, "Safety Text bits: " + textBits + " = '" + safetyText + "'"); Log.d(TAG, String.format("AIS Safety Broadcast: MMSI=%d, text='%s'", mmsi, safetyText)); + // Отправляем лог наружу + try { + com.grigowashere.aismap.utils.LogSender.logShipUpdate(String.valueOf(mmsi), "Safety: " + safetyText); + } catch (Throwable t) { + Log.w(TAG, "Ошибка отправки safety-лога: " + t.getMessage()); + } // Создаем или обновляем AIS судно AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); @@ -1781,19 +1936,52 @@ public class NMEAParser { // Создаем или обновляем AIS судно AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); + // Логика приоритета классов: + // - Если уже Class A: игнорируем обновление типа 18 полностью + // - Если Extended Class B: обновляем только динамику (позиция, скорость, курс и т.п.), класс не меняем + String existingClass = vessel.getVesselClass(); + if ("Class A".equals(existingClass)) { + Log.d(TAG, "Пропускаем обновление Class B (тип 18) для судна класса Class A: " + mmsi); + return; + } + boolean keepExtended = "Extended Class B".equals(existingClass); vessel.updatePosition(latitude, longitude, course, speed); vessel.setHeading(heading); vessel.setPositionAccuracy(accuracy == 1); vessel.setLastUpdate(java.time.LocalDateTime.now()); - vessel.setVesselClass("Class B"); + if (!keepExtended) { + vessel.setVesselClass("Class B"); + } // В Class B Position Report размеры не передаются, но мы сохраняем существующие Log.d(TAG, "Class B Position Report - размеры не передаются, сохраняем существующие: L=" + vessel.getLength() + ", W=" + vessel.getWidth()); // Отправляем информацию о корабле на внешний ресурс - String vesselInfo = String.format("Class B: lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, heading=%.1f, accuracy=%s", - latitude, longitude, course, speed, heading, accuracy == 1 ? "high" : "low"); - LogSender.logShipUpdate(String.valueOf(mmsi), vesselInfo); + // Добавляем статические поля, если они уже известны (из сообщений 24 и др.) + StringBuilder info = new StringBuilder( + String.format(java.util.Locale.US, + "Class B: lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, heading=%.1f, accuracy=%s", + latitude, longitude, course, speed, heading, (accuracy == 1 ? "high" : "low")) + ); + if (vessel.getVesselName() != null && !vessel.getVesselName().trim().isEmpty()) { + info.append(String.format(java.util.Locale.US, ", name='%s'", vessel.getVesselName())); + } + if (vessel.getCallSign() != null && !vessel.getCallSign().trim().isEmpty()) { + info.append(String.format(java.util.Locale.US, ", callSign='%s'", vessel.getCallSign())); + } + if (vessel.getVesselType() != null && !vessel.getVesselType().trim().isEmpty()) { + info.append(String.format(java.util.Locale.US, ", type=%s", vessel.getVesselType())); + } + if (vessel.getLength() > 0 || vessel.getWidth() > 0) { + info.append(String.format(java.util.Locale.US, ", L=%.1f, W=%.1f", vessel.getLength(), vessel.getWidth())); + } + if (vessel.getDraft() > 0) { + info.append(String.format(java.util.Locale.US, ", D=%.1f", vessel.getDraft())); + } + if (vessel.getDestination() != null && !vessel.getDestination().trim().isEmpty()) { + info.append(String.format(java.util.Locale.US, ", dest='%s'", vessel.getDestination())); + } + LogSender.logShipUpdate(String.valueOf(mmsi), info.toString()); // Уведомляем слушателя if (listener != null) { @@ -1895,6 +2083,12 @@ public class NMEAParser { Log.w(TAG, "Extended Class B - недостаточно битов для размеров: " + totalBits + " < 327"); // Создаем судно без размеров AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); + // Если судно уже Class A, не перезаписываем данными Extended Class B + String existingClassShort = vessel.getVesselClass(); + if ("Class A".equals(existingClassShort)) { + Log.d(TAG, "Пропускаем обновление Extended Class B для судна класса Class A: " + mmsi); + return; + } vessel.updatePosition(latitude, longitude, course, speed); vessel.setHeading(heading); vessel.setPositionAccuracy(accuracy == 1); @@ -1906,6 +2100,19 @@ public class NMEAParser { if (listener != null) { listener.onAISVesselUpdated(vessel); } + // Логируем короткое сообщение типа 19 с доступными данными + StringBuilder shortInfo = new StringBuilder( + String.format(java.util.Locale.US, + "Extended Class B (short): name='%s', lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, heading=%.1f, accuracy=%s", + vesselName, latitude, longitude, course, speed, heading, (accuracy == 1 ? "high" : "low")) + ); + if (vessel.getCallSign() != null && !vessel.getCallSign().trim().isEmpty()) { + shortInfo.append(String.format(java.util.Locale.US, ", callSign='%s'", vessel.getCallSign())); + } + if (vessel.getDestination() != null && !vessel.getDestination().trim().isEmpty()) { + shortInfo.append(String.format(java.util.Locale.US, ", dest='%s'", vessel.getDestination())); + } + LogSender.logShipUpdate(String.valueOf(mmsi), shortInfo.toString()); return; } @@ -1970,6 +2177,12 @@ public class NMEAParser { // Создаем или обновляем AIS судно AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); + // Если судно уже Class A, не перезаписываем данными Extended Class B + String existingClassFull = vessel.getVesselClass(); + if ("Class A".equals(existingClassFull)) { + Log.d(TAG, "Пропускаем обновление Extended Class B для судна класса Class A: " + mmsi); + return; + } vessel.updatePosition(latitude, longitude, course, speed); vessel.setHeading(heading); vessel.setPositionAccuracy(accuracy == 1); @@ -2125,6 +2338,12 @@ public class NMEAParser { if (listener != null) { listener.onAISVesselUpdated(vessel); } + + // Логируем статические данные Class B (Part A) + String vesselInfo = String.format(java.util.Locale.US, + "Class B Static A: name='%s'", + vesselName); + LogSender.logShipUpdate(String.valueOf(mmsi), vesselInfo); } else if (partNumber == 1) { // Part B: Vessel Type, Dimensions, etc. @@ -2212,6 +2431,15 @@ public class NMEAParser { if (listener != null) { listener.onAISVesselUpdated(vessel); } + + // Логируем статические данные Class B (Part B) + String vesselInfoB = String.format(java.util.Locale.US, + "Class B Static B: name='%s', callSign='%s', type=%s, L=%.1f, W=%.1f, D=%.1f", + vessel.getVesselName() != null ? vessel.getVesselName() : "", + callSign, + getVesselType(vesselTypeCode), + length, width, draft); + LogSender.logShipUpdate(String.valueOf(mmsi), vesselInfoB); } } catch (Exception e) { diff --git a/app/src/main/java/com/grigowashere/aismap/data/AppDatabase.java b/app/src/main/java/com/grigowashere/aismap/data/AppDatabase.java new file mode 100644 index 0000000..ea07c5c --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/data/AppDatabase.java @@ -0,0 +1,37 @@ +package com.grigowashere.aismap.data; + +import android.content.Context; + +import androidx.room.Database; +import androidx.room.Room; +import androidx.room.RoomDatabase; + +import com.grigowashere.aismap.data.dao.AISVesselDao; +import com.grigowashere.aismap.data.dao.VesselDao; +import com.grigowashere.aismap.data.entity.AISVesselEntity; +import com.grigowashere.aismap.data.entity.VesselEntity; + +@Database(entities = {AISVesselEntity.class, VesselEntity.class}, version = 3, exportSchema = false) +public abstract class AppDatabase extends RoomDatabase { + public abstract AISVesselDao aisVesselDao(); + public abstract VesselDao vesselDao(); + + private static volatile AppDatabase INSTANCE; + + public static AppDatabase getInstance(Context context) { + if (INSTANCE == null) { + synchronized (AppDatabase.class) { + if (INSTANCE == null) { + INSTANCE = Room.databaseBuilder( + context.getApplicationContext(), + AppDatabase.class, + "aismap.db" + ).fallbackToDestructiveMigration().build(); + } + } + } + return INSTANCE; + } +} + + diff --git a/app/src/main/java/com/grigowashere/aismap/data/Repository.java b/app/src/main/java/com/grigowashere/aismap/data/Repository.java new file mode 100644 index 0000000..b7e1008 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/data/Repository.java @@ -0,0 +1,58 @@ +package com.grigowashere.aismap.data; + +import android.content.Context; + +import androidx.lifecycle.LiveData; + +import com.grigowashere.aismap.data.dao.AISVesselDao; +import com.grigowashere.aismap.data.dao.VesselDao; +import com.grigowashere.aismap.data.entity.AISVesselEntity; +import com.grigowashere.aismap.data.entity.VesselEntity; + +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class Repository { + private final AISVesselDao aisVesselDao; + private final VesselDao vesselDao; + private final ExecutorService ioExecutor = Executors.newSingleThreadExecutor(); + + public Repository(Context context) { + AppDatabase db = AppDatabase.getInstance(context); + this.aisVesselDao = db.aisVesselDao(); + this.vesselDao = db.vesselDao(); + } + + public void upsertAIS(AISVesselEntity entity) { + ioExecutor.execute(() -> aisVesselDao.upsert(entity)); + } + + public void deleteStaleAIS(long thresholdEpochMs) { + ioExecutor.execute(() -> aisVesselDao.deleteStale(thresholdEpochMs)); + } + + public List getAllAISSync() { + return aisVesselDao.getAll(); + } + + public LiveData> observeAllAIS() { + return aisVesselDao.observeAll(); + } + + public AISVesselEntity getAISByMmsiSync(String mmsi) { + return aisVesselDao.getByMmsi(mmsi); + } + + public void upsertOwnVessel(VesselEntity entity) { + ioExecutor.execute(() -> { + vesselDao.upsert(entity); + }); + } + + public VesselEntity getLatestOwnVesselSync() { + return vesselDao.getLatest(); + } +} + + diff --git a/app/src/main/java/com/grigowashere/aismap/data/dao/AISVesselDao.java b/app/src/main/java/com/grigowashere/aismap/data/dao/AISVesselDao.java new file mode 100644 index 0000000..834b704 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/data/dao/AISVesselDao.java @@ -0,0 +1,35 @@ +package com.grigowashere.aismap.data.dao; + +import androidx.lifecycle.LiveData; +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; +import androidx.room.Update; + +import com.grigowashere.aismap.data.entity.AISVesselEntity; + +import java.util.List; + +@Dao +public interface AISVesselDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + void upsert(AISVesselEntity entity); + + @Update + void update(AISVesselEntity entity); + + @Query("SELECT * FROM ais_vessels") + List getAll(); + + @Query("SELECT * FROM ais_vessels") + LiveData> observeAll(); + + @Query("SELECT * FROM ais_vessels WHERE mmsi = :mmsi LIMIT 1") + AISVesselEntity getByMmsi(String mmsi); + + @Query("DELETE FROM ais_vessels WHERE lastUpdateEpochMs < :threshold") + void deleteStale(long threshold); +} + + diff --git a/app/src/main/java/com/grigowashere/aismap/data/dao/VesselDao.java b/app/src/main/java/com/grigowashere/aismap/data/dao/VesselDao.java new file mode 100644 index 0000000..2c6c2b4 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/data/dao/VesselDao.java @@ -0,0 +1,23 @@ +package com.grigowashere.aismap.data.dao; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; +import androidx.room.Update; + +import com.grigowashere.aismap.data.entity.VesselEntity; + +@Dao +public interface VesselDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + long upsert(VesselEntity entity); + + @Update + void update(VesselEntity entity); + + @Query("SELECT * FROM own_vessel ORDER BY id DESC LIMIT 1") + VesselEntity getLatest(); +} + + diff --git a/app/src/main/java/com/grigowashere/aismap/data/entity/AISVesselEntity.java b/app/src/main/java/com/grigowashere/aismap/data/entity/AISVesselEntity.java new file mode 100644 index 0000000..8a3bb89 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/data/entity/AISVesselEntity.java @@ -0,0 +1,61 @@ +package com.grigowashere.aismap.data.entity; + +import androidx.annotation.NonNull; +import androidx.room.Entity; +import androidx.room.PrimaryKey; + +/** + * Room-сущность для хранения AIS цели + * Теперь содержит ВСЕ поля из AISVessel модели + */ +@Entity(tableName = "ais_vessels") +public class AISVesselEntity { + @PrimaryKey + @NonNull + public String mmsi; + + // Основная информация о судне + public String vesselName; + public String callSign; + public int imo; // IMO номер + public String vesselType; // тип судна + + // Позиция и движение + public double latitude; + public double longitude; + public double course; // курс в градусах (0-360) + public double speed; // скорость в узлах + public double heading; // направление движения в градусах + public double rateOfTurn; // скорость поворота в градусах/минуту + + // Размеры судна + public double length; // длина судна в метрах + public double width; // ширина судна в метрах + public double draft; // осадка в метрах + + // Навигационная информация + public String destination; // пункт назначения + public long etaEpochMs; // предполагаемое время прибытия (epoch ms) + public String navigationalStatus; // навигационный статус + public boolean positionAccuracy; // точность позиции + + // Техническая информация + public int signalStrength; // сила AIS сигнала + public String vesselClass; // класс судна (Class A, Class B, Extended Class B) + public String vendorId; // идентификатор производителя оборудования + public String lastSafetyMessage; // последнее сообщение безопасности + + // Состояние и время + public long lastUpdateEpochMs; // время последнего обновления (epoch ms) + public boolean isActive; // активно ли судно + public boolean selected; // выделено ли судно на карте + + public AISVesselEntity(@NonNull String mmsi) { + this.mmsi = mmsi; + this.isActive = true; + this.selected = false; + this.lastUpdateEpochMs = System.currentTimeMillis(); + } +} + + diff --git a/app/src/main/java/com/grigowashere/aismap/data/entity/VesselEntity.java b/app/src/main/java/com/grigowashere/aismap/data/entity/VesselEntity.java new file mode 100644 index 0000000..b155ce6 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/data/entity/VesselEntity.java @@ -0,0 +1,23 @@ +package com.grigowashere.aismap.data.entity; + +import androidx.room.Entity; +import androidx.room.PrimaryKey; + +/** + * Room-сущность для хранения нашего судна/позиции + */ +@Entity(tableName = "own_vessel") +public class VesselEntity { + @PrimaryKey(autoGenerate = true) + public long id; + + public double latitude; + public double longitude; + public double course; + public double speed; + public double heading; + public float accuracy; + public long fixTime; +} + + diff --git a/app/src/main/java/com/grigowashere/aismap/data/mapper/AISVesselMapper.java b/app/src/main/java/com/grigowashere/aismap/data/mapper/AISVesselMapper.java new file mode 100644 index 0000000..ede5079 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/data/mapper/AISVesselMapper.java @@ -0,0 +1,131 @@ +package com.grigowashere.aismap.data.mapper; + +import com.grigowashere.aismap.data.entity.AISVesselEntity; +import com.grigowashere.aismap.models.AISVessel; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** + * Маппер для конвертации между AISVessel (модель) и AISVesselEntity (БД) + * Решает проблему потери данных при сохранении/восстановлении AIS судов + */ +public class AISVesselMapper { + + /** + * Конвертирует AISVessel модель в AISVesselEntity для сохранения в БД + */ + public static AISVesselEntity toEntity(AISVessel vessel) { + if (vessel == null) { + return null; + } + + AISVesselEntity entity = new AISVesselEntity(vessel.getMmsi()); + + // Основная информация о судне + entity.vesselName = vessel.getVesselName(); + entity.callSign = vessel.getCallSign(); + entity.imo = vessel.getImo(); + entity.vesselType = vessel.getVesselType(); + + // Позиция и движение + entity.latitude = vessel.getLatitude(); + entity.longitude = vessel.getLongitude(); + entity.course = vessel.getCourse(); + entity.speed = vessel.getSpeed(); + entity.heading = vessel.getHeading(); + + // Размеры судна + entity.length = vessel.getLength(); + entity.width = vessel.getWidth(); + entity.draft = vessel.getDraft(); + + // Навигационная информация + entity.destination = vessel.getDestination(); + entity.etaEpochMs = convertLocalDateTimeToEpochMs(vessel.getEta()); + entity.navigationalStatus = vessel.getNavigationalStatus(); + entity.positionAccuracy = vessel.isPositionAccuracy(); + + // Техническая информация + entity.signalStrength = vessel.getSignalStrength(); + entity.vesselClass = vessel.getVesselClass(); + entity.vendorId = vessel.getVendorId(); + entity.lastSafetyMessage = vessel.getLastSafetyMessage(); + + // Состояние и время + entity.lastUpdateEpochMs = convertLocalDateTimeToEpochMs(vessel.getLastUpdate()); + entity.isActive = vessel.isActive(); + entity.selected = vessel.isSelected(); + + return entity; + } + + /** + * Конвертирует AISVesselEntity из БД в AISVessel модель + */ + public static AISVessel toModel(AISVesselEntity entity) { + if (entity == null) { + return null; + } + + AISVessel vessel = new AISVessel(entity.mmsi); + + // Основная информация о судне + vessel.setVesselName(entity.vesselName); + vessel.setCallSign(entity.callSign); + vessel.setImo(entity.imo); + vessel.setVesselType(entity.vesselType); + + // Позиция и движение + vessel.setLatitude(entity.latitude); + vessel.setLongitude(entity.longitude); + vessel.setCourse(entity.course); + vessel.setSpeed(entity.speed); + vessel.setHeading(entity.heading); + + // Размеры судна + vessel.setLength(entity.length); + vessel.setWidth(entity.width); + vessel.setDraft(entity.draft); + + // Навигационная информация + vessel.setDestination(entity.destination); + vessel.setEta(convertEpochMsToLocalDateTime(entity.etaEpochMs)); + vessel.setNavigationalStatus(entity.navigationalStatus); + vessel.setPositionAccuracy(entity.positionAccuracy); + + // Техническая информация + vessel.setSignalStrength(entity.signalStrength); + vessel.setVesselClass(entity.vesselClass); + vessel.setVendorId(entity.vendorId); + vessel.setLastSafetyMessage(entity.lastSafetyMessage); + + // Состояние и время + vessel.setLastUpdate(convertEpochMsToLocalDateTime(entity.lastUpdateEpochMs)); + vessel.setActive(entity.isActive); + vessel.setSelected(entity.selected); + + return vessel; + } + + /** + * Конвертирует LocalDateTime в epoch milliseconds + */ + private static long convertLocalDateTimeToEpochMs(LocalDateTime dateTime) { + if (dateTime == null) { + return 0; + } + return dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + } + + /** + * Конвертирует epoch milliseconds в LocalDateTime + */ + private static LocalDateTime convertEpochMsToLocalDateTime(long epochMs) { + if (epochMs <= 0) { + return LocalDateTime.now(); + } + return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMs), ZoneId.systemDefault()); + } +} diff --git a/app/src/main/java/com/grigowashere/aismap/maps/MapForgeImpl.java b/app/src/main/java/com/grigowashere/aismap/maps/MapForgeImpl.java index 14b60c4..90c844d 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/MapForgeImpl.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/MapForgeImpl.java @@ -76,7 +76,9 @@ public class MapForgeImpl implements MapInterface { @Override public void addAISVesselMarker(AISVessel vessel) { LatLong position = new LatLong(vessel.getLatitude(), vessel.getLongitude()); - org.mapsforge.core.graphics.Bitmap icon = createMapForgeIcon(Color.RED, vessel.getCourse()); + // Используем heading вместо course для поворота маркера AIS судна + double rotationAngle = vessel.getHeading() > 0 ? vessel.getHeading() : vessel.getCourse(); + org.mapsforge.core.graphics.Bitmap icon = createMapForgeIcon(Color.RED, rotationAngle); Marker marker = new Marker(position, icon, 0, 0); // MapForge не поддерживает OnTapListener напрямую @@ -92,7 +94,9 @@ public class MapForgeImpl implements MapInterface { LatLong newPosition = new LatLong(vessel.getLatitude(), vessel.getLongitude()); marker.setLatLong(newPosition); - org.mapsforge.core.graphics.Bitmap icon = createMapForgeIcon(Color.RED, vessel.getCourse()); + // Используем 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); } } diff --git a/app/src/main/java/com/grigowashere/aismap/maps/MarkerManager.java b/app/src/main/java/com/grigowashere/aismap/maps/MarkerManager.java index cfe3f4c..76fb9ea 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/MarkerManager.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/MarkerManager.java @@ -58,4 +58,24 @@ public interface MarkerManager { * Получение количества активных маркеров */ int getActiveMarkerCount(); + + /** + * Включает/выключает отображение путей движения + */ + void setPathTrackingEnabled(boolean enabled); + + /** + * Очищает путь конкретного судна + */ + void clearVesselPath(String mmsi); + + /** + * Очищает все пути движения + */ + void clearAllPaths(); + + /** + * Обновляет настройки отображения путей + */ + void updatePathSettings(int pathColor, int predictionColor, float pathWidth, float predictionWidth); } diff --git a/app/src/main/java/com/grigowashere/aismap/maps/VesselPathTracker.java b/app/src/main/java/com/grigowashere/aismap/maps/VesselPathTracker.java new file mode 100644 index 0000000..ded1130 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/maps/VesselPathTracker.java @@ -0,0 +1,329 @@ +package com.grigowashere.aismap.maps; + +import android.graphics.Color; +import com.yandex.mapkit.geometry.Point; +import com.yandex.mapkit.map.MapObjectCollection; +import com.yandex.mapkit.map.PolylineMapObject; +import com.yandex.mapkit.map.MapObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * Класс для отслеживания и отображения пути движения судна + * Отображает сплошную линию пройденного пути и прогнозируемое движение + */ +public class VesselPathTracker { + + private static final int MAX_PATH_POINTS = 100; // Максимальное количество точек в пути + private static final long MIN_TIME_BETWEEN_POINTS = 1000; // Минимальное время между точками (1 секунда) + private static final double MIN_DISTANCE_BETWEEN_POINTS = 10.0; // Минимальное расстояние между точками (10 метров) + + private String vesselId; + private MapObjectCollection mapObjects; + private ConcurrentLinkedQueue pathHistory; + private PolylineMapObject pathLine; + private PolylineMapObject predictionLine; + private long lastUpdateTime; + private Point lastPosition; + + + // Настройки отображения + private int pathColor = Color.CYAN; + private int predictionColor = Color.YELLOW; + private float pathWidth = 3.0f; + private float predictionWidth = 2.0f; + private boolean isEnabled = true; + + /** + * Точка пути с временной меткой + */ + private static class PathPoint { + public final Point position; + public final long timestamp; + public final double speed; + public final double course; + + public PathPoint(Point position, long timestamp, double speed, double course) { + this.position = position; + this.timestamp = timestamp; + this.speed = speed; + this.course = course; + } + } + + public VesselPathTracker(String vesselId, MapObjectCollection mapObjects) { + this.vesselId = vesselId; + this.mapObjects = mapObjects; + this.pathHistory = new ConcurrentLinkedQueue<>(); + this.lastUpdateTime = 0; + } + + /** + * Обновляет путь судна новой позицией + */ + public void updatePosition(double latitude, double longitude, double speed, double course) { + if (!isEnabled) { + return; + } + + long currentTime = System.currentTimeMillis(); + Point newPosition = new Point(latitude, longitude); + + // Проверяем, нужно ли добавить новую точку + if (shouldAddPoint(newPosition, currentTime)) { + PathPoint newPoint = new PathPoint(newPosition, currentTime, speed, course); + pathHistory.offer(newPoint); + + // Ограничиваем количество точек + while (pathHistory.size() > MAX_PATH_POINTS) { + pathHistory.poll(); + } + + lastPosition = newPosition; + lastUpdateTime = currentTime; + + // Обновляем отображение пути + updatePathDisplay(); + } + } + + /** + * Проверяет, нужно ли добавить новую точку в путь + */ + private boolean shouldAddPoint(Point newPosition, long currentTime) { + // Проверяем время + if (currentTime - lastUpdateTime < MIN_TIME_BETWEEN_POINTS) { + return false; + } + + // Проверяем расстояние + if (lastPosition != null) { + double distance = calculateDistance(lastPosition, newPosition); + if (distance < MIN_DISTANCE_BETWEEN_POINTS) { + return false; + } + } + + return true; + } + + /** + * Обновляет отображение пути на карте + */ + private void updatePathDisplay() { + if (pathHistory.isEmpty()) { + return; + } + if (mapObjects == null) { + return; + } + + // Создаем список точек для пройденного пути + List pathPoints = new ArrayList<>(); + for (PathPoint point : pathHistory) { + pathPoints.add(point.position); + } + + // Удаляем старые линии + try { + if (pathLine != null) { + mapObjects.remove(pathLine); + pathLine = null; + } + if (predictionLine != null) { + mapObjects.remove(predictionLine); + predictionLine = null; + } + } catch (RuntimeException ignored) { + // Коллекция могла быть инвалидирована (weak_ptr expired). Прекращаем обновления. + isEnabled = false; + return; + } + + // Создаем линию пройденного пути + if (pathPoints.size() > 1) { + try { + pathLine = mapObjects.addPolyline(new com.yandex.mapkit.geometry.Polyline(pathPoints)); + if (pathLine != null) { + pathLine.setStrokeColor(pathColor); + pathLine.setStrokeWidth(pathWidth); + } + } catch (RuntimeException ignored) { + isEnabled = false; + return; + } + } + + // Создаем линию прогнозируемого движения + createPredictionLine(); + } + + /** + * Создает линию прогнозируемого движения + */ + private void createPredictionLine() { + if (pathHistory.isEmpty()) { + return; + } + if (mapObjects == null) { + return; + } + + // Получаем последнюю точку + PathPoint lastPoint = null; + for (PathPoint point : pathHistory) { + lastPoint = point; + } + + if (lastPoint == null || lastPoint.speed <= 0) { + return; + } + + // Рассчитываем прогнозируемую позицию через 1 минуту + double predictionTimeMinutes = 1.0; // 1 минута + double predictionDistance = lastPoint.speed * predictionTimeMinutes * 60.0; // расстояние в метрах + + // Конвертируем курс в радианы + double courseRad = Math.toRadians(lastPoint.course); + + // Рассчитываем новую позицию + double earthRadius = 6371000; // радиус Земли в метрах + double lat1 = Math.toRadians(lastPoint.position.getLatitude()); + double lon1 = Math.toRadians(lastPoint.position.getLongitude()); + + double lat2 = Math.asin(Math.sin(lat1) * Math.cos(predictionDistance / earthRadius) + + Math.cos(lat1) * Math.sin(predictionDistance / earthRadius) * Math.cos(courseRad)); + + double lon2 = lon1 + Math.atan2(Math.sin(courseRad) * Math.sin(predictionDistance / earthRadius) * Math.cos(lat1), + Math.cos(predictionDistance / earthRadius) - Math.sin(lat1) * Math.sin(lat2)); + + Point predictionPoint = new Point(Math.toDegrees(lat2), Math.toDegrees(lon2)); + + // Создаем линию прогноза + List predictionPoints = new ArrayList<>(); + predictionPoints.add(lastPoint.position); + predictionPoints.add(predictionPoint); + + try { + predictionLine = mapObjects.addPolyline(new com.yandex.mapkit.geometry.Polyline(predictionPoints)); + if (predictionLine != null) { + predictionLine.setStrokeColor(predictionColor); + predictionLine.setStrokeWidth(predictionWidth); + // Сплошная линия для прогноза (по умолчанию) + } + } catch (RuntimeException ignored) { + isEnabled = false; + } + } + + /** + * Рассчитывает расстояние между двумя точками в метрах + */ + private double calculateDistance(Point point1, Point point2) { + double lat1 = Math.toRadians(point1.getLatitude()); + double lon1 = Math.toRadians(point1.getLongitude()); + double lat2 = Math.toRadians(point2.getLatitude()); + double lon2 = Math.toRadians(point2.getLongitude()); + + double dlat = lat2 - lat1; + double dlon = lon2 - lon1; + + double a = Math.sin(dlat / 2) * Math.sin(dlat / 2) + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dlon / 2) * Math.sin(dlon / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return 6371000 * c; // радиус Земли в метрах + } + + /** + * Очищает путь судна + */ + public void clearPath() { + try { + if (pathLine != null && mapObjects != null) { + mapObjects.remove(pathLine); + pathLine = null; + } + if (predictionLine != null && mapObjects != null) { + mapObjects.remove(predictionLine); + predictionLine = null; + } + } catch (RuntimeException ignored) { + // Игнорируем ошибки очистки при невалидной коллекции + } + + + pathHistory.clear(); + lastPosition = null; + } + + /** + * Удаляет трекер пути + */ + public void remove() { + clearPath(); + } + + /** + * Включает/выключает отображение пути + */ + public void setEnabled(boolean enabled) { + this.isEnabled = enabled; + if (!enabled) { + clearPath(); + } + } + + /** + * Устанавливает цвет пройденного пути + */ + public void setPathColor(int color) { + this.pathColor = color; + if (pathLine != null) { + pathLine.setStrokeColor(color); + } + + } + + /** + * Устанавливает цвет прогнозируемого пути + */ + public void setPredictionColor(int color) { + this.predictionColor = color; + if (predictionLine != null) { + predictionLine.setStrokeColor(color); + } + } + + /** + * Устанавливает ширину линий + */ + public void setLineWidth(float pathWidth, float predictionWidth) { + this.pathWidth = pathWidth; + this.predictionWidth = predictionWidth; + + if (pathLine != null) { + pathLine.setStrokeWidth(pathWidth); + } + if (predictionLine != null) { + predictionLine.setStrokeWidth(predictionWidth); + } + + } + + /** + * Проверяет, активен ли трекер + */ + public boolean isActive() { + return isEnabled && !pathHistory.isEmpty(); + } + + /** + * Получает количество точек в пути + */ + public int getPathPointCount() { + return pathHistory.size(); + } +} diff --git a/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java b/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java index a7e4736..ba433fa 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java @@ -46,7 +46,9 @@ public class YandexMapImpl implements MapInterface { try { this.mapObjects = mapView.getMap().getMapObjects().addCollection(); // Инициализируем менеджер маркеров - this.markerManager = new YandexMarkerManager(context, mapObjects, mapView); + com.grigowashere.aismap.utils.SettingsManager settingsManager = + new com.grigowashere.aismap.utils.SettingsManager(context); + this.markerManager = new YandexMarkerManager(context, mapObjects, mapView, settingsManager); } catch (Exception e) { // Ошибка создания коллекции объектов карты } @@ -129,7 +131,7 @@ public class YandexMapImpl implements MapInterface { @Override public void centerOnPosition(double latitude, double longitude) { Point point = new Point(latitude, longitude); - CameraPosition cameraPosition = new CameraPosition(point, 15.0f, 0.0f, 0.0f); + CameraPosition cameraPosition = new CameraPosition(point, 13.0f, 0.0f, 0.0f); mapView.getMap().move(cameraPosition, new Animation(Animation.Type.SMOOTH, 1.0f), null); } @@ -207,6 +209,15 @@ public class YandexMapImpl implements MapInterface { } } + /** + * Принудительно обновляет все маркеры при изменении зума + */ + public void forceRefreshMarkersOnZoomChange() { + if (markerManager != null) { + markerManager.forceRefreshAllMarkers(); + } + } + /** * Проверяет и восстанавливает финализированные маркеры */ @@ -226,6 +237,42 @@ public class YandexMapImpl implements MapInterface { return 0; } + /** + * Включает/выключает отображение путей движения + */ + public void setPathTrackingEnabled(boolean enabled) { + if (markerManager != null) { + markerManager.setPathTrackingEnabled(enabled); + } + } + + /** + * Очищает путь конкретного судна + */ + public void clearVesselPath(String mmsi) { + if (markerManager != null) { + markerManager.clearVesselPath(mmsi); + } + } + + /** + * Очищает все пути движения + */ + public void clearAllPaths() { + if (markerManager != null) { + markerManager.clearAllPaths(); + } + } + + /** + * Обновляет настройки отображения путей + */ + public void updatePathSettings(int pathColor, int predictionColor, float pathWidth, float predictionWidth) { + if (markerManager != null) { + markerManager.updatePathSettings(pathColor, predictionColor, pathWidth, predictionWidth); + } + } + /** * Получение MapView для использования в layout @@ -257,10 +304,11 @@ public class YandexMapImpl implements MapInterface { // Включаем жесты поворота карты mapView.getMap().setRotateGesturesEnabled(true); - // Добавляем слушатель изменений камеры для обновления маркеров при повороте + // Добавляем слушатель изменений камеры для обновления маркеров при повороте и зуме mapView.getMap().addCameraListener(new com.yandex.mapkit.map.CameraListener() { private long lastUpdateTime = 0; - private static final long UPDATE_THROTTLE = 50; // 50мс между обновлениями + private static final long UPDATE_THROTTLE = 200; // 200мс между обновлениями (увеличено для снижения нагрузки) + private float lastZoom = -1; @Override public void onCameraPositionChanged(com.yandex.mapkit.map.Map map, @@ -270,9 +318,23 @@ public class YandexMapImpl implements MapInterface { // Обновляем маркеры в реальном времени с throttling long currentTime = System.currentTimeMillis(); - if (currentTime - lastUpdateTime >= UPDATE_THROTTLE) { - onMapRotationChanged(); + float currentZoom = cameraPosition.getZoom(); + + // Проверяем, изменился ли зум значительно (больше чем на 1.0) + boolean zoomChanged = Math.abs(currentZoom - lastZoom) > 1.0f; + + if (currentTime - lastUpdateTime >= UPDATE_THROTTLE || zoomChanged) { + //onMapRotationChanged(); + // Обновляем маркеры только при значительных изменениях + if (zoomChanged) { + // При изменении зума принудительно обновляем все маркеры + forceRefreshMarkersOnZoomChange(); + } else { + // При повороте только проверяем валидность маркеров + checkAndRestoreMarkers(); + } lastUpdateTime = currentTime; + lastZoom = currentZoom; } } }); diff --git a/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerManager.java b/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerManager.java index 49ccca7..9ad37e7 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerManager.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerManager.java @@ -26,11 +26,17 @@ public class YandexMarkerManager implements MarkerManager { private MapObjectCollection mapObjects; private com.yandex.mapkit.mapview.MapView mapView; private MapInterface.MarkerClickListener markerClickListener; + private com.grigowashere.aismap.utils.SettingsManager settingsManager; // Кеш маркеров с управлением жизненным циклом private Map markerCache = new ConcurrentHashMap<>(); private YandexMarkerWrapper ownVesselMarker; + // Трекеры путей движения судов + private Map pathTrackers = new ConcurrentHashMap<>(); + private VesselPathTracker ownVesselPathTracker; + private boolean pathTrackingEnabled = true; + // Периодическая очистка устаревших маркеров private Handler cleanupHandler; private Runnable cleanupRunnable; @@ -41,10 +47,11 @@ public class YandexMarkerManager implements MarkerManager { private Runnable refreshRunnable; private static final long REFRESH_INTERVAL = 2000; // 2 секунды - public YandexMarkerManager(Context context, MapObjectCollection mapObjects, com.yandex.mapkit.mapview.MapView mapView) { + public YandexMarkerManager(Context context, MapObjectCollection mapObjects, com.yandex.mapkit.mapview.MapView mapView, com.grigowashere.aismap.utils.SettingsManager settingsManager) { this.context = context; this.mapObjects = mapObjects; this.mapView = mapView; + this.settingsManager = settingsManager; this.cleanupHandler = new Handler(Looper.getMainLooper()); this.refreshHandler = new Handler(Looper.getMainLooper()); } @@ -53,6 +60,17 @@ public class YandexMarkerManager implements MarkerManager { public void initialize() { startPeriodicCleanup(); startPeriodicRefresh(); + + // Инициализируем настройки путей из SettingsManager + if (settingsManager != null) { + pathTrackingEnabled = settingsManager.isPathTrackingEnabled(); + updatePathSettings( + settingsManager.getPathColor(), + settingsManager.getPredictionColor(), + settingsManager.getPathWidth(), + settingsManager.getPredictionWidth() + ); + } } @Override @@ -70,6 +88,17 @@ public class YandexMarkerManager implements MarkerManager { ownVesselMarker.remove(); ownVesselMarker = null; } + + // Очищаем трекеры путей + for (VesselPathTracker tracker : pathTrackers.values()) { + tracker.remove(); + } + pathTrackers.clear(); + + if (ownVesselPathTracker != null) { + ownVesselPathTracker.remove(); + ownVesselPathTracker = null; + } } @Override @@ -90,7 +119,7 @@ public class YandexMarkerManager implements MarkerManager { } // Создаем новый маркер - ownVesselMarker = new YandexMarkerWrapper(context, mapObjects, mapView, vessel, "own_vessel"); + ownVesselMarker = new YandexMarkerWrapper(context, mapObjects, mapView, vessel, "own_vessel", settingsManager); if (markerClickListener != null) { ownVesselMarker.setClickListener(() -> { if (markerClickListener != null) { @@ -98,6 +127,9 @@ public class YandexMarkerManager implements MarkerManager { } }); } + + // Обновляем трекер пути для собственного судна + updateOwnVesselPath(vessel); } @Override @@ -121,7 +153,7 @@ public class YandexMarkerManager implements MarkerManager { } // Создаем новый маркер - marker = new YandexMarkerWrapper(context, mapObjects, mapView, vessel, mmsi); + marker = new YandexMarkerWrapper(context, mapObjects, mapView, vessel, mmsi, settingsManager); markerCache.put(mmsi, marker); if (markerClickListener != null) { @@ -131,6 +163,9 @@ public class YandexMarkerManager implements MarkerManager { } }); } + + // Обновляем трекер пути для AIS судна + updateAISVesselPath(vessel); } @Override @@ -139,6 +174,12 @@ public class YandexMarkerManager implements MarkerManager { if (marker != null) { marker.remove(); } + + // Удаляем трекер пути + VesselPathTracker pathTracker = pathTrackers.remove(mmsi); + if (pathTracker != null) { + pathTracker.remove(); + } } @Override @@ -147,6 +188,12 @@ public class YandexMarkerManager implements MarkerManager { marker.remove(); } markerCache.clear(); + + // Очищаем все трекеры путей AIS судов + for (VesselPathTracker tracker : pathTrackers.values()) { + tracker.remove(); + } + pathTrackers.clear(); } @Override @@ -177,6 +224,9 @@ public class YandexMarkerManager implements MarkerManager { public void refreshAllMarkers() { // При повороте карты пересоздаем все маркеры // Это гарантирует правильную ориентацию относительно севера + if (mapObjects == null || mapView == null) { + return; + } // Пересоздаем маркер нашего судна if (ownVesselMarker != null) { @@ -193,7 +243,11 @@ public class YandexMarkerManager implements MarkerManager { YandexMarkerWrapper marker = entry.getValue(); AISVessel vessel = marker.getAISVessel(); if (vessel != null) { - marker.remove(); + try { + marker.remove(); + } catch (RuntimeException ignored) { + // Игнорируем, если underlying объект недоступен + } vesselsToRecreate.put(entry.getKey(), vessel); } } @@ -201,7 +255,11 @@ public class YandexMarkerManager implements MarkerManager { // Очищаем кеш и пересоздаем маркеры markerCache.clear(); for (Map.Entry entry : vesselsToRecreate.entrySet()) { - updateAISVesselMarker(entry.getValue()); + try { + updateAISVesselMarker(entry.getValue()); + } catch (RuntimeException ignored) { + // Пропускаем пересоздание при ошибке + } } } @@ -284,6 +342,12 @@ public class YandexMarkerManager implements MarkerManager { @Override public void run() { refreshAllMarkers(); + try { + // Проверяем только валидность маркеров, не пересоздаем их + checkAndRestoreMarkers(); + } catch (Exception e) { + android.util.Log.e(TAG, "Ошибка при периодическом обновлении маркеров: " + e.getMessage(), e); + } // Планируем следующее обновление refreshHandler.postDelayed(this, REFRESH_INTERVAL); } @@ -309,20 +373,262 @@ public class YandexMarkerManager implements MarkerManager { Set toRemove = new HashSet<>(); for (Map.Entry entry : markerCache.entrySet()) { YandexMarkerWrapper marker = entry.getValue(); - if (marker.isExpired() || !marker.isValid()) { + if (marker.isExpired() || !marker.isValid() || marker.shouldBeRemoved()) { marker.remove(); toRemove.add(entry.getKey()); } } + // Удаляем маркеры и их трекеры путей for (String mmsi : toRemove) { markerCache.remove(mmsi); + + // Удаляем трекер пути для этого судна + VesselPathTracker pathTracker = pathTrackers.remove(mmsi); + if (pathTracker != null) { + pathTracker.remove(); + } } // Проверяем маркер нашего судна if (ownVesselMarker != null && (ownVesselMarker.isExpired() || !ownVesselMarker.isValid())) { ownVesselMarker.remove(); ownVesselMarker = null; + + // Удаляем трекер пути нашего судна + if (ownVesselPathTracker != null) { + ownVesselPathTracker.remove(); + ownVesselPathTracker = null; + } } } + + /** + * Принудительно обновляет все маркеры (например, при изменении зума) + */ + public void forceRefreshAllMarkers() { + // Пересоздаем маркер нашего судна + if (ownVesselMarker != null) { + Vessel vessel = ownVesselMarker.getVessel(); + if (vessel != null) { + ownVesselMarker.remove(); + updateOwnVesselMarker(vessel); + } + } + + // Пересоздаем все AIS маркеры + Map vesselsToRecreate = new HashMap<>(); + for (Map.Entry entry : markerCache.entrySet()) { + YandexMarkerWrapper marker = entry.getValue(); + AISVessel vessel = marker.getAISVessel(); + if (vessel != null) { + marker.remove(); + vesselsToRecreate.put(entry.getKey(), vessel); + } + } + + // Очищаем кеш и трекеры путей + markerCache.clear(); + + // Очищаем все трекеры путей AIS судов + for (VesselPathTracker tracker : pathTrackers.values()) { + tracker.remove(); + } + pathTrackers.clear(); + + // Пересоздаем маркеры + for (Map.Entry entry : vesselsToRecreate.entrySet()) { + updateAISVesselMarker(entry.getValue()); + } + } + + @Override + public void setPathTrackingEnabled(boolean enabled) { + this.pathTrackingEnabled = enabled; + + // Сохраняем настройку в SettingsManager + if (settingsManager != null) { + settingsManager.setPathTrackingEnabled(enabled); + } + + // Обновляем состояние всех трекеров + if (ownVesselPathTracker != null) { + ownVesselPathTracker.setEnabled(enabled); + } + + for (VesselPathTracker tracker : pathTrackers.values()) { + tracker.setEnabled(enabled); + } + } + + @Override + public void clearVesselPath(String mmsi) { + VesselPathTracker tracker = pathTrackers.get(mmsi); + if (tracker != null) { + tracker.clearPath(); + } + } + + @Override + public void clearAllPaths() { + if (ownVesselPathTracker != null) { + ownVesselPathTracker.clearPath(); + } + + for (VesselPathTracker tracker : pathTrackers.values()) { + tracker.clearPath(); + } + } + + @Override + public void updatePathSettings(int pathColor, int predictionColor, float pathWidth, float predictionWidth) { + // Сохраняем настройки в SettingsManager + if (settingsManager != null) { + settingsManager.setPathColor(pathColor); + settingsManager.setPredictionColor(predictionColor); + settingsManager.setPathWidth(pathWidth); + settingsManager.setPredictionWidth(predictionWidth); + } + + // Обновляем настройки всех трекеров + if (ownVesselPathTracker != null) { + ownVesselPathTracker.setPathColor(pathColor); + ownVesselPathTracker.setPredictionColor(predictionColor); + ownVesselPathTracker.setLineWidth(pathWidth, predictionWidth); + } + + for (VesselPathTracker tracker : pathTrackers.values()) { + tracker.setPathColor(pathColor); + tracker.setPredictionColor(predictionColor); + tracker.setLineWidth(pathWidth, predictionWidth); + } + } + + /** + * Обновляет трекер пути для собственного судна + */ + private void updateOwnVesselPath(Vessel vessel) { + if (!pathTrackingEnabled || vessel == null) { + return; + } + + // Проверяем, движется ли судно + if (!isVesselMoving(vessel)) { + return; + } + + if (ownVesselPathTracker == null) { + ownVesselPathTracker = new VesselPathTracker("own_vessel", mapObjects); + } + + ownVesselPathTracker.updatePosition( + vessel.getLatitude(), + vessel.getLongitude(), + vessel.getSpeed(), + vessel.getCourse() + ); + } + + /** + * Обновляет трекер пути для AIS судна + */ + private void updateAISVesselPath(AISVessel vessel) { + if (!pathTrackingEnabled || vessel == null || vessel.getMmsi() == null) { + return; + } + + // Проверяем, движется ли судно + if (!isAISVesselMoving(vessel)) { + return; + } + + String mmsi = vessel.getMmsi(); + VesselPathTracker tracker = pathTrackers.get(mmsi); + + if (tracker == null) { + tracker = new VesselPathTracker(mmsi, mapObjects); + pathTrackers.put(mmsi, tracker); + } + + // Курс для прогноза: HDG (0..359) если валиден, иначе COG + double displayCourse = getAISDisplayCourse(vessel); + + tracker.updatePosition( + vessel.getLatitude(), + vessel.getLongitude(), + vessel.getSpeed(), + displayCourse + ); + } + + /** + * Проверяет, движется ли собственное судно + */ + private boolean isVesselMoving(Vessel vessel) { + if (vessel == null) { + return false; + } + + // Считаем, что судно движется, если скорость больше 0.5 узла + return vessel.getSpeed() > 0.5; + } + + /** + * Проверяет, движется ли AIS судно + */ + private boolean isAISVesselMoving(AISVessel vessel) { + if (vessel == null) { + return false; + } + + // Проверяем навигационный статус + String navStatus = vessel.getNavigationalStatus(); + if (navStatus != null) { + String status = navStatus.toLowerCase(); + // Считаем, что судно движется, если не стоит на якоре и не пришвартовано + if (status.contains("at anchor") || + status.contains("moored") || + status.contains("not under command")) { + return false; + } + } + + // Считаем, что судно движется, если скорость больше 0.5 узла + return vessel.getSpeed() > 0.5; + } + + /** + * Возвращает курс для AIS: валидный HDG (0..359), 511 — невалидно; иначе COG + */ + private double getAISDisplayCourse(AISVessel vessel) { + try { + double hdg = vessel.getHeading(); + if (isValidHeading(hdg)) { + return normalizeCourse(hdg); + } + return normalizeCourse(vessel.getCourse()); + } catch (Exception ignored) { + return 0.0; + } + } + + /** + * Проверяет валидность HDG + */ + private boolean isValidHeading(double heading) { + if (Double.isNaN(heading) || Double.isInfinite(heading)) return false; + int h = (int) Math.round(heading); + if (h == 511) return false; + return h >= 0 && h <= 359; + } + + /** + * Нормализует курс в диапазон [0, 360) + */ + private double normalizeCourse(double course) { + if (Double.isNaN(course) || Double.isInfinite(course)) return 0.0; + double c = course % 360.0; + if (c < 0) c += 360.0; + return c; + } } diff --git a/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerWrapper.java b/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerWrapper.java index f4ec683..95b0f07 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerWrapper.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerWrapper.java @@ -44,28 +44,44 @@ public class YandexMarkerWrapper extends MarkerWrapper { private double cachedIconCourse = Double.NaN; private int cachedIconColor = -1; private boolean cachedIconSelected = false; + private float cachedIconZoom = -1; + private boolean cachedIconStale = false; + + // Ссылка на SettingsManager для получения настроек устаревания + private com.grigowashere.aismap.utils.SettingsManager settingsManager; + + // Константы для масштабирования маркеров + private static final float MIN_MARKER_SIZE = 24f; // Минимальный размер маркера в пикселях (увеличен) + private static final float MAX_MARKER_SIZE = 200f; // Максимальный размер маркера в пикселях (увеличен) + private static final float ZOOM_THRESHOLD_FOR_REAL_SIZE = 12f; // Зум, при котором начинаем использовать реальные размеры (снижен) + private static final float MEDIUM_ZOOM_SIZE = 48f; // Размер маркера на среднем приближении + private static final float CLOSE_ZOOM_SIZE = 80f; // Размер маркера на близком приближении public YandexMarkerWrapper(Context context, MapObjectCollection mapObjects, - com.yandex.mapkit.mapview.MapView mapView, Vessel vessel, String id) { + com.yandex.mapkit.mapview.MapView mapView, Vessel vessel, String id, + com.grigowashere.aismap.utils.SettingsManager settingsManager) { super(id); this.context = context; this.mapObjects = mapObjects; this.mapView = mapView; this.vessel = vessel; this.isOwnVessel = true; + this.settingsManager = settingsManager; // Предварительно создаем иконку preloadIcon(); createMarker(); } public YandexMarkerWrapper(Context context, MapObjectCollection mapObjects, - com.yandex.mapkit.mapview.MapView mapView, AISVessel vessel, String id) { + com.yandex.mapkit.mapview.MapView mapView, AISVessel vessel, String id, + com.grigowashere.aismap.utils.SettingsManager settingsManager) { super(id); this.context = context; this.mapObjects = mapObjects; this.mapView = mapView; this.aisVessel = vessel; this.isOwnVessel = false; + this.settingsManager = settingsManager; // Предварительно создаем иконку preloadIcon(); createMarker(); @@ -76,14 +92,17 @@ public class YandexMarkerWrapper extends MarkerWrapper { */ private void preloadIcon() { try { - double course = isOwnVessel ? vessel.getCourse() : aisVessel.getCourse(); + // Курс для поворота: HDG (0..359) если валиден, иначе COG + double course = getDisplayCourse(); int color = isOwnVessel ? android.graphics.Color.BLUE : getVesselColor(); boolean selected = !isOwnVessel && aisVessel.isSelected(); + boolean stale = isDataStale(); // Проверяем устаревание данных - cachedIconBitmap = createRotatedIcon(course, color, selected); + cachedIconBitmap = createRotatedIcon(course, color, selected, stale); cachedIconCourse = course; cachedIconColor = color; cachedIconSelected = selected; + cachedIconStale = stale; } catch (Exception e) { // Ошибка предварительной загрузки иконки cachedIconBitmap = null; @@ -122,11 +141,13 @@ public class YandexMarkerWrapper extends MarkerWrapper { */ private Bitmap createIconBitmap() { try { - double course = isOwnVessel ? vessel.getCourse() : aisVessel.getCourse(); + // Курс для поворота: HDG (0..359) если валиден, иначе COG + double course = getDisplayCourse(); int color = isOwnVessel ? android.graphics.Color.BLUE : getVesselColor(); boolean selected = !isOwnVessel && aisVessel.isSelected(); + boolean stale = isDataStale(); // Проверяем устаревание данных - return createRotatedIcon(course, color, selected); + return createRotatedIcon(course, color, selected, stale); } catch (Exception e) { return null; } @@ -145,27 +166,36 @@ public class YandexMarkerWrapper extends MarkerWrapper { */ private void setIconImmediately() { try { - double course = isOwnVessel ? vessel.getCourse() : aisVessel.getCourse(); + // Курс для поворота: HDG (0..359) если валиден, иначе COG + double course = getDisplayCourse(); int color = isOwnVessel ? android.graphics.Color.BLUE : getVesselColor(); boolean selected = !isOwnVessel && aisVessel.isSelected(); + boolean stale = isDataStale(); // Проверяем устаревание данных + + // Получаем текущий зум для проверки кеша + float currentZoom = getCurrentZoom(); // Проверяем кеш иконки Bitmap iconBitmap = null; if (Double.compare(course, cachedIconCourse) == 0 && color == cachedIconColor && selected == cachedIconSelected && + stale == cachedIconStale && + Float.compare(currentZoom, cachedIconZoom) == 0 && cachedIconBitmap != null) { // Используем кешированную иконку iconBitmap = cachedIconBitmap; } else { // Создаем новую иконку - iconBitmap = createRotatedIcon(course, color, selected); + iconBitmap = createRotatedIcon(course, color, selected, stale); if (iconBitmap != null) { // Кешируем иконку cachedIconBitmap = iconBitmap; cachedIconCourse = course; cachedIconColor = color; cachedIconSelected = selected; + cachedIconStale = stale; + cachedIconZoom = currentZoom; } } @@ -267,38 +297,55 @@ public class YandexMarkerWrapper extends MarkerWrapper { } } - private Bitmap createRotatedIcon(double course, int color, boolean isSelected) { + private Bitmap createRotatedIcon(double course, int color, boolean isSelected, boolean isStale) { + // Получаем текущий зум карты + float currentZoom = getCurrentZoom(); + try { - // Получаем drawable из ресурса - int iconResId = context.getResources().getIdentifier("target", "drawable", context.getPackageName()); - if (iconResId == 0) { - return createSimpleIcon(color, course); + // Сначала выбираем базовую иконку: для AIS Class A используем targetclassa + String baseIconName = (!isOwnVessel && isAISClassA()) ? "targetclassa" : "target"; + int targetIconResId = context.getResources().getIdentifier(baseIconName, "drawable", context.getPackageName()); + if (targetIconResId == 0) { + return createSimpleIcon(color, course, currentZoom, isStale); } - Drawable drawable = context.getResources().getDrawable(iconResId, null); - if (drawable == null) { - return createSimpleIcon(color, course); + Drawable targetDrawable = context.getResources().getDrawable(targetIconResId, null); + if (targetDrawable == null) { + return createSimpleIcon(color, course, currentZoom, isStale); } - // Применяем цвет + // Получаем иконку losingtarget для наложения (если данные устарели) + Drawable losingTargetDrawable = null; + if (isStale) { + int losingTargetIconResId = context.getResources().getIdentifier("losingtarget", "drawable", context.getPackageName()); + if (losingTargetIconResId != 0) { + losingTargetDrawable = context.getResources().getDrawable(losingTargetIconResId, null); + } + } + + // Применяем цвет к основной иконке if (color != 0) { - drawable.setColorFilter(color, android.graphics.PorterDuff.Mode.SRC_IN); + targetDrawable.setColorFilter(color, android.graphics.PorterDuff.Mode.SRC_IN); } - // Получаем размеры - int originalWidth = drawable.getIntrinsicWidth(); - int originalHeight = drawable.getIntrinsicHeight(); + // Получаем размеры основной иконки + int originalWidth = targetDrawable.getIntrinsicWidth(); + int originalHeight = targetDrawable.getIntrinsicHeight(); if (originalWidth <= 0) originalWidth = 32; if (originalHeight <= 0) originalHeight = 48; - // Масштабируем - float scale = 0.3f; + // Рассчитываем размер маркера на основе зума и размеров судна + float markerSize = calculateMarkerSize(currentZoom); + + // Масштабируем пропорционально рассчитанному размеру + float scale = markerSize / Math.max(originalWidth, originalHeight); int width = (int) (originalWidth * scale); int height = (int) (originalHeight * scale); - // Создаем bitmap - int bitmapSize = Math.max(width, height) + 8; + // Создаем bitmap с дополнительным пространством для обводки и тени + int padding = 12; + int bitmapSize = Math.max(width, height) + padding * 2; Bitmap bitmap = Bitmap.createBitmap(bitmapSize, bitmapSize, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); @@ -313,7 +360,7 @@ public class YandexMarkerWrapper extends MarkerWrapper { // Не удалось получить азимут карты, используем 0 } - // Поворачиваем маркер на курс судна с учетом поворота карты + // Поворачиваем основную иконку на курс судна с учетом поворота карты // Курс судна - это направление относительно севера // Азимут карты - это поворот карты относительно севера // Итоговый поворот = курс судна - азимут карты (чтобы маркер оставался относительно севера) @@ -324,13 +371,31 @@ public class YandexMarkerWrapper extends MarkerWrapper { int left = centerX - width / 2; int top = centerY - height / 2; - drawable.setBounds(left, top, left + width, top + height); + // Рисуем тень (смещенную копию) + targetDrawable.setBounds(left + 2, top + 2, left + width + 2, top + height + 2); + targetDrawable.setColorFilter(0x80000000, android.graphics.PorterDuff.Mode.SRC_IN); canvas.save(); canvas.rotate(rotationAngle, centerX, centerY); - drawable.draw(canvas); + targetDrawable.draw(canvas); canvas.restore(); + // Рисуем основную иконку target (поворачивается) + targetDrawable.setBounds(left, top, left + width, top + height); + targetDrawable.setColorFilter(color, android.graphics.PorterDuff.Mode.SRC_IN); + + canvas.save(); + canvas.rotate(rotationAngle, centerX, centerY); + targetDrawable.draw(canvas); + canvas.restore(); + + // Рисуем losingtarget поверх (НЕ поворачивается) + if (losingTargetDrawable != null) { + // Используем тот же размер для losingtarget + losingTargetDrawable.setBounds(left, top, left + width, top + height); + losingTargetDrawable.draw(canvas); // Без поворота! + } + // Добавляем рамку выделения если нужно if (isSelected) { addSelectionFrame(canvas, centerX, centerY, Math.max(width, height)); @@ -338,31 +403,139 @@ public class YandexMarkerWrapper extends MarkerWrapper { return bitmap; } catch (Exception e) { - return createSimpleIcon(color, course); + return createSimpleIcon(color, course, currentZoom, isStale); + } + } + + /** + * Возвращает курс для отображения маркера: валидный HDG (0..359), иначе COG + */ + private double getDisplayCourse() { + try { + if (isOwnVessel) { + double cog = vessel != null ? vessel.getCourse() : 0.0; + return normalizeCourse(cog); + } + if (aisVessel != null) { + double hdg = aisVessel.getHeading(); + if (isValidHeading(hdg)) { + return normalizeCourse(hdg); + } + double cog = aisVessel.getCourse(); + return normalizeCourse(cog); + } + } catch (Exception ignored) { + } + return 0.0; + } + + /** + * Проверка валидности HDG: 0..359 включительно, 511 — невалидно + */ + private boolean isValidHeading(double heading) { + if (Double.isNaN(heading) || Double.isInfinite(heading)) return false; + int h = (int) Math.round(heading); + if (h == 511) return false; + return h >= 0 && h <= 359; + } + + /** + * Нормализует курс к диапазону [0, 360) + */ + private double normalizeCourse(double course) { + if (Double.isNaN(course) || Double.isInfinite(course)) return 0.0; + double c = course % 360.0; + if (c < 0) c += 360.0; + return c; + } + + private boolean isAISClassA() { + try { + if (aisVessel == null) return false; + String cls = aisVessel.getVesselClass(); + if (cls == null) return false; + String s = cls.trim().toLowerCase(); + return s.equals("class a") || s.equals("a") || s.contains("class a"); + } catch (Exception ignored) { + return false; } } - private Bitmap createSimpleIcon(int color, double course) { + private Bitmap createSimpleIcon(int color, double course, float zoom, boolean isStale) { try { - int size = 32; - Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); + // Рассчитываем размер маркера на основе зума + float markerSize = calculateMarkerSize(zoom); + int size = (int) markerSize; + + // Увеличиваем размер bitmap для обводки и тени + int padding = 8; + int bitmapSize = size + padding * 2; + Bitmap bitmap = Bitmap.createBitmap(bitmapSize, bitmapSize, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); - Paint paint = new Paint(); - paint.setColor(color); - paint.setStyle(Paint.Style.FILL); - paint.setAntiAlias(true); + // Смещаем координаты с учетом padding + float centerX = bitmapSize / 2f; + float centerY = bitmapSize / 2f; - // Рисуем треугольник + // Создаем путь для треугольника android.graphics.Path path = new android.graphics.Path(); - path.moveTo(size / 2f, 0); - path.lineTo(size * 0.1f, size * 0.8f); - path.lineTo(size * 0.9f, size * 0.8f); + path.moveTo(centerX, padding); + path.lineTo(padding + size * 0.1f, padding + size * 0.8f); + path.lineTo(padding + size * 0.9f, padding + size * 0.8f); path.close(); + // Рисуем тень (смещенную копию) + Paint shadowPaint = new Paint(); + shadowPaint.setColor(0x80000000); // Полупрозрачный черный + shadowPaint.setStyle(Paint.Style.FILL); + shadowPaint.setAntiAlias(true); + canvas.save(); - canvas.rotate((float) course, size / 2f, size / 2f); - canvas.drawPath(path, paint); + canvas.translate(2, 2); // Смещение для тени + canvas.rotate((float) course, centerX, centerY); + canvas.drawPath(path, shadowPaint); + canvas.restore(); + + // Рисуем внешнюю обводку + Paint outlinePaint = new Paint(); + outlinePaint.setColor(0xFF000000); // Черная обводка + outlinePaint.setStyle(Paint.Style.STROKE); + outlinePaint.setStrokeWidth(4f); + outlinePaint.setAntiAlias(true); + + canvas.save(); + canvas.rotate((float) course, centerX, centerY); + canvas.drawPath(path, outlinePaint); + canvas.restore(); + + // Рисуем внутреннюю обводку + Paint innerOutlinePaint = new Paint(); + innerOutlinePaint.setColor(0xFFFFFFFF); // Белая внутренняя обводка + innerOutlinePaint.setStyle(Paint.Style.STROKE); + innerOutlinePaint.setStrokeWidth(2f); + innerOutlinePaint.setAntiAlias(true); + + canvas.save(); + canvas.rotate((float) course, centerX, centerY); + canvas.drawPath(path, innerOutlinePaint); + canvas.restore(); + + // Рисуем основную заливку + Paint fillPaint = new Paint(); + fillPaint.setColor(color); + fillPaint.setStyle(Paint.Style.FILL); + fillPaint.setAntiAlias(true); + + // Для устаревших данных рисуем пунктирный треугольник + if (isStale) { + fillPaint.setStyle(Paint.Style.STROKE); + fillPaint.setStrokeWidth(3f); + fillPaint.setPathEffect(new android.graphics.DashPathEffect(new float[]{10, 5}, 0)); + } + + canvas.save(); + canvas.rotate((float) course, centerX, centerY); + canvas.drawPath(path, fillPaint); canvas.restore(); return bitmap; @@ -373,19 +546,50 @@ public class YandexMarkerWrapper extends MarkerWrapper { private void addSelectionFrame(Canvas canvas, int centerX, int centerY, int size) { try { + // Сначала рисуем тень для рамки выделения + Paint shadowPaint = new Paint(); + shadowPaint.setColor(0x80000000); + shadowPaint.setStyle(Paint.Style.STROKE); + shadowPaint.setStrokeWidth(6f); + shadowPaint.setAntiAlias(true); + + int shadowSize = size + 20; + canvas.drawCircle(centerX + 2, centerY + 2, shadowSize / 2, shadowPaint); + + // Рисуем внешнюю обводку + Paint outerOutlinePaint = new Paint(); + outerOutlinePaint.setColor(0xFF000000); + outerOutlinePaint.setStyle(Paint.Style.STROKE); + outerOutlinePaint.setStrokeWidth(4f); + outerOutlinePaint.setAntiAlias(true); + + int outerSize = size + 18; + canvas.drawCircle(centerX, centerY, outerSize / 2, outerOutlinePaint); + + // Рисуем внутреннюю обводку + Paint innerOutlinePaint = new Paint(); + innerOutlinePaint.setColor(0xFFFFFFFF); + innerOutlinePaint.setStyle(Paint.Style.STROKE); + innerOutlinePaint.setStrokeWidth(2f); + innerOutlinePaint.setAntiAlias(true); + + int innerSize = size + 16; + canvas.drawCircle(centerX, centerY, innerSize / 2, innerOutlinePaint); + + // Пробуем использовать иконку chosentarget если доступна int iconResId = context.getResources().getIdentifier("chosentarget", "drawable", context.getPackageName()); - if (iconResId == 0) return; - - Drawable selectionDrawable = context.getResources().getDrawable(iconResId, null); - if (selectionDrawable == null) return; - - int selectionSize = size + 16; - int selectionLeft = centerX - selectionSize / 2; - int selectionTop = centerY - selectionSize / 2; - - selectionDrawable.setBounds(selectionLeft, selectionTop, - selectionLeft + selectionSize, selectionTop + selectionSize); - selectionDrawable.draw(canvas); + if (iconResId != 0) { + Drawable selectionDrawable = context.getResources().getDrawable(iconResId, null); + if (selectionDrawable != null) { + int selectionSize = size + 16; + int selectionLeft = centerX - selectionSize / 2; + int selectionTop = centerY - selectionSize / 2; + + selectionDrawable.setBounds(selectionLeft, selectionTop, + selectionLeft + selectionSize, selectionTop + selectionSize); + selectionDrawable.draw(canvas); + } + } } catch (Exception e) { // Игнорируем ошибки рамки выделения } @@ -425,4 +629,93 @@ public class YandexMarkerWrapper extends MarkerWrapper { public boolean isOwnVessel() { return isOwnVessel; } + + /** + * Проверяет, устарели ли данные судна (для AIS судов) + */ + public boolean isDataStale() { + if (isOwnVessel || aisVessel == null || settingsManager == null) { + return false; // Собственное судно никогда не устаревает + } + return aisVessel.isDataStale(settingsManager.getDataStaleWarningMinutes()); + } + + /** + * Проверяет, нужно ли удалить судно (для AIS судов) + */ + public boolean shouldBeRemoved() { + if (isOwnVessel || aisVessel == null || settingsManager == null) { + return false; // Собственное судно никогда не удаляется + } + return aisVessel.shouldBeRemoved(settingsManager.getDataStaleRemoveMinutes()); + } + + /** + * Получает текущий зум карты + */ + private float getCurrentZoom() { + try { + if (mapView != null) { + com.yandex.mapkit.map.CameraPosition cameraPosition = mapView.getMap().getCameraPosition(); + return cameraPosition.getZoom(); + } + } catch (Exception e) { + // Ошибка получения зума, возвращаем значение по умолчанию + } + return 10f; // Значение по умолчанию + } + + /** + * Рассчитывает размер маркера на основе зума и размеров судна + */ + private float calculateMarkerSize(float zoom) { + // На очень большом расстоянии используем минимальный размер + if (zoom < 8) { + return MIN_MARKER_SIZE; + } + + // На среднем расстоянии используем средний размер + if (zoom < 12) { + return MEDIUM_ZOOM_SIZE; + } + + // На близком расстоянии используем крупный размер + if (zoom < 15) { + return CLOSE_ZOOM_SIZE; + } + + // При очень близком приближении рассчитываем размер на основе реальных размеров судна + double vesselLength = 0; + double vesselWidth = 0; + + if (isOwnVessel && vessel != null) { + // Для собственного судна используем примерные размеры + vesselLength = 50; // метры + vesselWidth = 10; // метры + } else if (!isOwnVessel && aisVessel != null) { + vesselLength = aisVessel.getLength(); + vesselWidth = aisVessel.getWidth(); + } + + // Если размеры не заданы или очень маленькие, используем увеличенный базовый размер + if (vesselLength <= 0 || vesselWidth <= 0 || vesselLength < 10 || vesselWidth < 5) { + // Используем размер, основанный на зуме, но увеличенный + float baseSize = CLOSE_ZOOM_SIZE + (zoom - 15) * 8; // Увеличиваем размер с зумом + return Math.max(CLOSE_ZOOM_SIZE, Math.min(MAX_MARKER_SIZE, baseSize)); + } + + // Рассчитываем размер на основе большего из размеров судна + double vesselSize = Math.max(vesselLength, vesselWidth); + + // Коэффициент масштабирования (пиксели на метр при текущем зуме) + // Чем больше зум, тем больше пикселей на метр + float pixelsPerMeter = (float) (Math.pow(2, zoom - 12) * 1.0); // Увеличенный коэффициент + + // Размер маркера в пикселях + float calculatedSize = (float) (vesselSize * pixelsPerMeter); + + // Ограничиваем размер маркера, но с более высоким минимумом + float minSize = Math.max(CLOSE_ZOOM_SIZE, MIN_MARKER_SIZE); + return Math.max(minSize, Math.min(MAX_MARKER_SIZE, calculatedSize)); + } } diff --git a/app/src/main/java/com/grigowashere/aismap/models/AISVessel.java b/app/src/main/java/com/grigowashere/aismap/models/AISVessel.java index 9c00826..70882d8 100644 --- a/app/src/main/java/com/grigowashere/aismap/models/AISVessel.java +++ b/app/src/main/java/com/grigowashere/aismap/models/AISVessel.java @@ -16,6 +16,7 @@ public class AISVessel { private double course; // курс в градусах (0-360) private double speed; // скорость в узлах private double heading; // направление движения в градусах + private double rateOfTurn; // скорость поворота в градусах/минуту private double length; // длина судна в метрах private double width; // ширина судна в метрах private double draft; // осадка в метрах @@ -72,6 +73,9 @@ public class AISVessel { public double getHeading() { return heading; } public void setHeading(double heading) { this.heading = heading; } + public double getRateOfTurn() { return rateOfTurn; } + public void setRateOfTurn(double rateOfTurn) { this.rateOfTurn = rateOfTurn; } + public double getLength() { return length; } public void setLength(double length) { this.length = length; } @@ -124,13 +128,48 @@ public class AISVessel { this.speed = speed; this.lastUpdate = LocalDateTime.now(); } + + /** + * Обновляет позицию, курс и скорость поворота судна + */ + public void updatePosition(double latitude, double longitude, double course, double speed, double rateOfTurn) { + this.latitude = latitude; + this.longitude = longitude; + this.course = course; + this.speed = speed; + this.rateOfTurn = rateOfTurn; + this.lastUpdate = LocalDateTime.now(); + } /** * Проверяет, не устарели ли данные (больше 10 минут) + * @deprecated Используйте isDataStale(int warningMinutes) для настраиваемого времени */ + @Deprecated public boolean isDataStale() { return LocalDateTime.now().minusMinutes(10).isAfter(lastUpdate); } + + /** + * Проверяет, не устарели ли данные на указанное количество минут + */ + 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(); + } @Override public String toString() { @@ -141,6 +180,7 @@ public class AISVessel { ", lon=" + longitude + ", course=" + course + ", speed=" + speed + + ", rot=" + rateOfTurn + '}'; } } diff --git a/app/src/main/java/com/grigowashere/aismap/services/AISForegroundService.java b/app/src/main/java/com/grigowashere/aismap/services/AISForegroundService.java new file mode 100644 index 0000000..93dc7c6 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/services/AISForegroundService.java @@ -0,0 +1,75 @@ +package com.grigowashere.aismap.services; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import com.grigowashere.aismap.MainActivity; +import com.grigowashere.aismap.R; + +public class AISForegroundService extends Service { + + public static final String CHANNEL_ID = "aismap_foreground"; + private static final int NOTIFICATION_ID = 1001; + + @Override + public void onCreate() { + super.onCreate(); + createNotificationChannel(); + startForeground(NOTIFICATION_ID, buildNotification("Работа в фоне: обновление AIS/GPS")); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + // Здесь в дальнейшем запустим прием NMEA/UDP и GPS слушателей + return START_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + "AISMap Background", + NotificationManager.IMPORTANCE_LOW + ); + channel.setDescription("Фоновые обновления AIS и GPS"); + NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (nm != null) nm.createNotificationChannel(channel); + } + } + + private Notification buildNotification(String content) { + Intent notificationIntent = new Intent(this, MainActivity.class); + int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT : 0; + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, flags); + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("AISMap") + .setContentText(content) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentIntent(pendingIntent) + .setOngoing(true) + .build(); + } +} + + diff --git a/app/src/main/java/com/grigowashere/aismap/services/NotificationService.java b/app/src/main/java/com/grigowashere/aismap/services/NotificationService.java new file mode 100644 index 0000000..dd12bda --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/services/NotificationService.java @@ -0,0 +1,237 @@ +package com.grigowashere.aismap.services; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.media.AudioManager; +import android.media.ToneGenerator; +import android.os.Build; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.os.VibratorManager; +import android.util.Log; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import com.grigowashere.aismap.MainActivity; +import com.grigowashere.aismap.R; +import com.grigowashere.aismap.utils.SettingsManager; + +/** + * Сервис для обработки уведомлений о новых AIS целях + * Поддерживает вибрацию и звуковые уведомления + */ +public class NotificationService { + + private static final String TAG = "NotificationService"; + private static final String ALERT_CHANNEL_ID = "aismap_alerts"; + private static final int SAFETY_NOTIFICATION_ID_BASE = 2000; + + private Context context; + private SettingsManager settingsManager; + private Vibrator vibrator; + private ToneGenerator toneGenerator; + private boolean isInitialized = false; + + public NotificationService(Context context) { + this.context = context; + this.settingsManager = new SettingsManager(context); + initializeService(); + } + + /** + * Инициализирует сервис уведомлений + */ + private void initializeService() { + try { + createAlertChannel(); + // Инициализация вибратора + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + VibratorManager vibratorManager = (VibratorManager) context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE); + vibrator = vibratorManager.getDefaultVibrator(); + } else { + vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); + } + + // Инициализация генератора тонов + toneGenerator = new ToneGenerator(AudioManager.STREAM_NOTIFICATION, 100); + + isInitialized = true; + Log.i(TAG, "Сервис уведомлений инициализирован успешно"); + + } catch (Exception e) { + Log.e(TAG, "Ошибка инициализации сервиса уведомлений: " + e.getMessage(), e); + isInitialized = false; + } + } + + /** + * Создает канал уведомлений для предупреждений (Android O+) + */ + private void createAlertChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + ALERT_CHANNEL_ID, + "AIS Alerts", + NotificationManager.IMPORTANCE_HIGH + ); + channel.setDescription("Сообщения безопасности AIS и предупреждения"); + NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm != null) nm.createNotificationChannel(channel); + } + } + + /** + * Воспроизводит уведомление о новой AIS цели + */ + public void notifyNewAISTarget() { + if (!isInitialized) { + Log.w(TAG, "Сервис уведомлений не инициализирован"); + return; + } + + // Проверяем настройки и воспроизводим соответствующие уведомления + if (settingsManager.isVibrationEnabled()) { + playVibration(); + } + + if (settingsManager.isSoundEnabled()) { + playSound(); + } + + Log.i(TAG, "Уведомление о новой AIS цели воспроизведено"); + } + + /** + * Воспроизводит вибрацию + */ + private void playVibration() { + try { + if (vibrator != null && vibrator.hasVibrator()) { + // Паттерн вибрации: короткая пауза, длинная вибрация, короткая пауза, короткая вибрация + long[] pattern = {0, 200, 100, 100}; + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + VibrationEffect effect = VibrationEffect.createWaveform(pattern, -1); + vibrator.vibrate(effect); + } else { + vibrator.vibrate(pattern, -1); + } + + Log.d(TAG, "Вибрация воспроизведена"); + } else { + Log.w(TAG, "Вибратор недоступен"); + } + } catch (Exception e) { + Log.e(TAG, "Ошибка воспроизведения вибрации: " + e.getMessage(), e); + } + } + + /** + * Воспроизводит звуковое уведомление + */ + private void playSound() { + try { + if (toneGenerator != null) { + // Воспроизводим тон уведомления (TONE_CDMA_ALERT_CALL_GUARD) + toneGenerator.startTone(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, 500); + + Log.d(TAG, "Звуковое уведомление воспроизведено"); + } else { + Log.w(TAG, "Генератор тонов недоступен"); + } + } catch (Exception e) { + Log.e(TAG, "Ошибка воспроизведения звука: " + e.getMessage(), e); + } + } + + /** + * Проверяет, включены ли уведомления + */ + public boolean areNotificationsEnabled() { + return settingsManager.isVibrationEnabled() || settingsManager.isSoundEnabled(); + } + + /** + * Проверяет, включена ли вибрация + */ + public boolean isVibrationEnabled() { + return settingsManager.isVibrationEnabled(); + } + + /** + * Проверяет, включен ли звук + */ + public boolean isSoundEnabled() { + return settingsManager.isSoundEnabled(); + } + + /** + * Уведомление о сообщении безопасности (AIS 14) + */ + public void notifySafetyMessage(String mmsi, String text) { + if (!isInitialized) { + Log.w(TAG, "Сервис уведомлений не инициализирован"); + return; + } + // Подаем сигнал по настройкам (вибро/звук) + if (settingsManager.isVibrationEnabled()) { + playVibration(); + } + if (settingsManager.isSoundEnabled()) { + playSound(); + } + + // Показ системного уведомления в шторке + try { + createAlertChannel(); + Intent intent = new Intent(context, MainActivity.class); + int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT : 0; + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, flags); + + String title = "AIS Safety message"; + String content = (text != null && !text.isEmpty()) ? text : ("Сообщение от " + mmsi); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, ALERT_CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(content) + .setStyle(new NotificationCompat.BigTextStyle().bigText(content)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setContentIntent(pendingIntent) + .setAutoCancel(true); + + int notificationId = SAFETY_NOTIFICATION_ID_BASE + (mmsi != null ? (mmsi.hashCode() & 0x0FFF) : 0); + NotificationManagerCompat.from(context).notify(notificationId, builder.build()); + + Log.i(TAG, "Показано системное уведомление о safety-сообщении: MMSI=" + mmsi); + } catch (Exception e) { + Log.e(TAG, "Ошибка показа системного уведомления: " + e.getMessage(), e); + } + } + + /** + * Освобождает ресурсы сервиса + */ + public void cleanup() { + try { + if (toneGenerator != null) { + toneGenerator.release(); + toneGenerator = null; + } + + if (vibrator != null) { + vibrator.cancel(); + } + + isInitialized = false; + Log.i(TAG, "Ресурсы сервиса уведомлений освобождены"); + + } catch (Exception e) { + Log.e(TAG, "Ошибка при освобождении ресурсов сервиса уведомлений: " + e.getMessage(), e); + } + } +} diff --git a/app/src/main/java/com/grigowashere/aismap/utils/MIDToCountry.java b/app/src/main/java/com/grigowashere/aismap/utils/MIDToCountry.java new file mode 100644 index 0000000..216b3c3 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/utils/MIDToCountry.java @@ -0,0 +1,314 @@ +package com.grigowashere.aismap.utils; + +import java.util.HashMap; +import java.util.Map; + +/** + * Маппинг MID (первые 3 цифры MMSI) -> ISO 3166-1 alpha-2 кода страны + */ +public final class MIDToCountry { + + public static final Map MID_TO_COUNTRY; + + static { + MID_TO_COUNTRY = new HashMap<>(); + // Europe + MID_TO_COUNTRY.put("201", "AL"); // Albania + MID_TO_COUNTRY.put("202", "AD"); // Andorra + MID_TO_COUNTRY.put("203", "AT"); // Austria + MID_TO_COUNTRY.put("204", "PT"); // Portugal (Azores) + MID_TO_COUNTRY.put("205", "BE"); // Belgium + MID_TO_COUNTRY.put("206", "BY"); // Belarus + MID_TO_COUNTRY.put("207", "BG"); // Bulgaria + MID_TO_COUNTRY.put("208", "VA"); // Vatican City + MID_TO_COUNTRY.put("209", "CY"); // Cyprus + MID_TO_COUNTRY.put("210", "CY"); // Cyprus + MID_TO_COUNTRY.put("211", "DE"); // Germany + MID_TO_COUNTRY.put("212", "CY"); // Cyprus + MID_TO_COUNTRY.put("213", "GE"); // Georgia + MID_TO_COUNTRY.put("214", "MD"); // Moldova + MID_TO_COUNTRY.put("215", "MT"); // Malta + MID_TO_COUNTRY.put("216", "AM"); // Armenia + MID_TO_COUNTRY.put("218", "DE"); // Germany + MID_TO_COUNTRY.put("219", "DK"); // Denmark + MID_TO_COUNTRY.put("220", "DK"); // Denmark + MID_TO_COUNTRY.put("224", "ES"); // Spain + MID_TO_COUNTRY.put("225", "ES"); // Spain + MID_TO_COUNTRY.put("226", "FR"); // France + MID_TO_COUNTRY.put("227", "FR"); // France + MID_TO_COUNTRY.put("228", "FR"); // France + MID_TO_COUNTRY.put("229", "MT"); // Malta + MID_TO_COUNTRY.put("230", "FI"); // Finland + MID_TO_COUNTRY.put("231", "FO"); // Faroe Islands + MID_TO_COUNTRY.put("232", "GB"); // United Kingdom + MID_TO_COUNTRY.put("233", "GB"); // United Kingdom + MID_TO_COUNTRY.put("234", "GB"); // United Kingdom + MID_TO_COUNTRY.put("235", "GB"); // United Kingdom + MID_TO_COUNTRY.put("236", "GI"); // Gibraltar + MID_TO_COUNTRY.put("237", "GR"); // Greece + MID_TO_COUNTRY.put("238", "HR"); // Croatia + MID_TO_COUNTRY.put("239", "GR"); // Greece + MID_TO_COUNTRY.put("240", "GR"); // Greece + MID_TO_COUNTRY.put("241", "GR"); // Greece + MID_TO_COUNTRY.put("242", "MA"); // Morocco + MID_TO_COUNTRY.put("243", "HU"); // Hungary + MID_TO_COUNTRY.put("244", "NL"); // Netherlands + MID_TO_COUNTRY.put("245", "NL"); // Netherlands + MID_TO_COUNTRY.put("246", "NL"); // Netherlands + MID_TO_COUNTRY.put("247", "IT"); // Italy + MID_TO_COUNTRY.put("248", "MT"); // Malta + MID_TO_COUNTRY.put("249", "MT"); // Malta + MID_TO_COUNTRY.put("250", "IE"); // Ireland + MID_TO_COUNTRY.put("251", "IS"); // Iceland + MID_TO_COUNTRY.put("252", "LI"); // Liechtenstein + MID_TO_COUNTRY.put("253", "LU"); // Luxembourg + MID_TO_COUNTRY.put("254", "MC"); // Monaco + MID_TO_COUNTRY.put("255", "PT"); // Portugal (Madeira) + MID_TO_COUNTRY.put("256", "MT"); // Malta + MID_TO_COUNTRY.put("257", "NO"); // Norway + MID_TO_COUNTRY.put("258", "NO"); // Norway + MID_TO_COUNTRY.put("259", "NO"); // Norway + MID_TO_COUNTRY.put("261", "PL"); // Poland + MID_TO_COUNTRY.put("262", "ME"); // Montenegro + MID_TO_COUNTRY.put("263", "PT"); // Portugal + MID_TO_COUNTRY.put("264", "RO"); // Romania + MID_TO_COUNTRY.put("265", "SE"); // Sweden + MID_TO_COUNTRY.put("266", "SE"); // Sweden + MID_TO_COUNTRY.put("267", "SK"); // Slovakia + MID_TO_COUNTRY.put("268", "SM"); // San Marino + MID_TO_COUNTRY.put("269", "CH"); // Switzerland + MID_TO_COUNTRY.put("270", "CZ"); // Czech Republic + MID_TO_COUNTRY.put("271", "TR"); // Turkey + MID_TO_COUNTRY.put("272", "UA"); // Ukraine + MID_TO_COUNTRY.put("273", "RU"); // Russian Federation + MID_TO_COUNTRY.put("274", "MK"); // North Macedonia + MID_TO_COUNTRY.put("275", "LV"); // Latvia + MID_TO_COUNTRY.put("276", "EE"); // Estonia + MID_TO_COUNTRY.put("277", "LT"); // Lithuania + MID_TO_COUNTRY.put("278", "SI"); // Slovenia + MID_TO_COUNTRY.put("279", "RS"); // Serbia + + // North America & Caribbean + MID_TO_COUNTRY.put("301", "AI"); // Anguilla + MID_TO_COUNTRY.put("303", "US"); // USA (Alaska) + MID_TO_COUNTRY.put("304", "AG"); // Antigua and Barbuda + MID_TO_COUNTRY.put("305", "AG"); // Antigua and Barbuda + MID_TO_COUNTRY.put("306", "CW"); // Curaçao + MID_TO_COUNTRY.put("307", "AW"); // Aruba + MID_TO_COUNTRY.put("308", "BS"); // Bahamas + MID_TO_COUNTRY.put("309", "BS"); // Bahamas + MID_TO_COUNTRY.put("310", "BM"); // Bermuda + MID_TO_COUNTRY.put("311", "BS"); // Bahamas + MID_TO_COUNTRY.put("312", "BZ"); // Belize + MID_TO_COUNTRY.put("314", "BB"); // Barbados + MID_TO_COUNTRY.put("316", "CA"); // Canada + MID_TO_COUNTRY.put("319", "KY"); // Cayman Islands + MID_TO_COUNTRY.put("321", "CR"); // Costa Rica + MID_TO_COUNTRY.put("323", "CU"); // Cuba + MID_TO_COUNTRY.put("325", "DM"); // Dominica + MID_TO_COUNTRY.put("327", "DO"); // Dominican Republic + MID_TO_COUNTRY.put("329", "GP"); // Guadeloupe + MID_TO_COUNTRY.put("330", "GD"); // Grenada + MID_TO_COUNTRY.put("331", "GL"); // Greenland + MID_TO_COUNTRY.put("332", "GT"); // Guatemala + MID_TO_COUNTRY.put("334", "HN"); // Honduras + MID_TO_COUNTRY.put("336", "HT"); // Haiti + MID_TO_COUNTRY.put("338", "US"); // USA + MID_TO_COUNTRY.put("339", "JM"); // Jamaica + MID_TO_COUNTRY.put("341", "KN"); // Saint Kitts and Nevis + MID_TO_COUNTRY.put("343", "LC"); // Saint Lucia + MID_TO_COUNTRY.put("345", "MX"); // Mexico + MID_TO_COUNTRY.put("347", "MQ"); // Martinique + MID_TO_COUNTRY.put("348", "MS"); // Montserrat + MID_TO_COUNTRY.put("350", "NI"); // Nicaragua + MID_TO_COUNTRY.put("351", "PA"); // Panama + MID_TO_COUNTRY.put("352", "PA"); // Panama + MID_TO_COUNTRY.put("353", "PA"); // Panama + MID_TO_COUNTRY.put("354", "PA"); // Panama + MID_TO_COUNTRY.put("355", "PA"); // Panama + MID_TO_COUNTRY.put("356", "PA"); // Panama + MID_TO_COUNTRY.put("357", "PA"); // Panama + MID_TO_COUNTRY.put("358", "PR"); // Puerto Rico + MID_TO_COUNTRY.put("359", "SV"); // El Salvador + MID_TO_COUNTRY.put("361", "PM"); // Saint Pierre and Miquelon + MID_TO_COUNTRY.put("362", "TT"); // Trinidad and Tobago + MID_TO_COUNTRY.put("364", "TC"); // Turks and Caicos Islands + MID_TO_COUNTRY.put("366", "US"); // USA + MID_TO_COUNTRY.put("367", "US"); // USA + MID_TO_COUNTRY.put("368", "US"); // USA + MID_TO_COUNTRY.put("369", "US"); // USA + MID_TO_COUNTRY.put("370", "PA"); // Panama + MID_TO_COUNTRY.put("371", "PA"); // Panama + MID_TO_COUNTRY.put("372", "PA"); // Panama + MID_TO_COUNTRY.put("373", "PA"); // Panama + MID_TO_COUNTRY.put("375", "VC"); // Saint Vincent and the Grenadines + MID_TO_COUNTRY.put("376", "VC"); // Saint Vincent and the Grenadines + MID_TO_COUNTRY.put("377", "VC"); // Saint Vincent and the Grenadines + MID_TO_COUNTRY.put("378", "VG"); // British Virgin Islands + MID_TO_COUNTRY.put("379", "VI"); // U.S. Virgin Islands + + // Asia & Middle East + MID_TO_COUNTRY.put("401", "AF"); // Afghanistan + MID_TO_COUNTRY.put("403", "SA"); // Saudi Arabia + MID_TO_COUNTRY.put("405", "BD"); // Bangladesh + MID_TO_COUNTRY.put("408", "BH"); // Bahrain + MID_TO_COUNTRY.put("410", "BT"); // Bhutan + MID_TO_COUNTRY.put("412", "CN"); // China + MID_TO_COUNTRY.put("413", "CN"); // China + MID_TO_COUNTRY.put("414", "CN"); // China + MID_TO_COUNTRY.put("416", "TW"); // Taiwan + MID_TO_COUNTRY.put("417", "LK"); // Sri Lanka + MID_TO_COUNTRY.put("419", "IN"); // India + MID_TO_COUNTRY.put("422", "IR"); // Iran + MID_TO_COUNTRY.put("423", "AZ"); // Azerbaijan + MID_TO_COUNTRY.put("425", "IQ"); // Iraq + MID_TO_COUNTRY.put("428", "IL"); // Israel + MID_TO_COUNTRY.put("431", "JP"); // Japan + MID_TO_COUNTRY.put("432", "JP"); // Japan + MID_TO_COUNTRY.put("434", "TM"); // Turkmenistan + MID_TO_COUNTRY.put("436", "KZ"); // Kazakhstan + MID_TO_COUNTRY.put("437", "UZ"); // Uzbekistan + MID_TO_COUNTRY.put("438", "JO"); // Jordan + MID_TO_COUNTRY.put("440", "KR"); // South Korea + MID_TO_COUNTRY.put("441", "KR"); // South Korea + MID_TO_COUNTRY.put("443", "PS"); // Palestine + MID_TO_COUNTRY.put("445", "KP"); // North Korea + MID_TO_COUNTRY.put("447", "KW"); // Kuwait + MID_TO_COUNTRY.put("450", "LB"); // Lebanon + MID_TO_COUNTRY.put("451", "KG"); // Kyrgyzstan + MID_TO_COUNTRY.put("453", "MO"); // Macao + MID_TO_COUNTRY.put("455", "MV"); // Maldives + MID_TO_COUNTRY.put("457", "MN"); // Mongolia + MID_TO_COUNTRY.put("459", "NP"); // Nepal + MID_TO_COUNTRY.put("461", "OM"); // Oman + MID_TO_COUNTRY.put("463", "PK"); // Pakistan + MID_TO_COUNTRY.put("466", "QA"); // Qatar + MID_TO_COUNTRY.put("468", "SY"); // Syria + MID_TO_COUNTRY.put("470", "AE"); // United Arab Emirates + MID_TO_COUNTRY.put("471", "AE"); // United Arab Emirates + MID_TO_COUNTRY.put("472", "TJ"); // Tajikistan + MID_TO_COUNTRY.put("473", "YE"); // Yemen + MID_TO_COUNTRY.put("475", "YE"); // Yemen + MID_TO_COUNTRY.put("477", "HK"); // Hong Kong + MID_TO_COUNTRY.put("478", "BA"); // Bosnia and Herzegovina (legacy routing usage) + + // Oceania + MID_TO_COUNTRY.put("501", "AQ"); // Antarctica + MID_TO_COUNTRY.put("503", "AU"); // Australia + MID_TO_COUNTRY.put("506", "MM"); // Myanmar + MID_TO_COUNTRY.put("508", "BN"); // Brunei + MID_TO_COUNTRY.put("510", "FM"); // Micronesia + MID_TO_COUNTRY.put("511", "PW"); // Palau + MID_TO_COUNTRY.put("512", "NZ"); // New Zealand + MID_TO_COUNTRY.put("514", "KH"); // Cambodia + MID_TO_COUNTRY.put("515", "KH"); // Cambodia + MID_TO_COUNTRY.put("516", "CX"); // Christmas Island + MID_TO_COUNTRY.put("518", "CK"); // Cook Islands + MID_TO_COUNTRY.put("520", "FJ"); // Fiji + MID_TO_COUNTRY.put("523", "CC"); // Cocos (Keeling) Islands + MID_TO_COUNTRY.put("525", "ID"); // Indonesia + MID_TO_COUNTRY.put("529", "KI"); // Kiribati + MID_TO_COUNTRY.put("531", "LA"); // Laos + MID_TO_COUNTRY.put("533", "MY"); // Malaysia + MID_TO_COUNTRY.put("536", "MP"); // Northern Mariana Islands + MID_TO_COUNTRY.put("538", "MH"); // Marshall Islands + MID_TO_COUNTRY.put("540", "NC"); // New Caledonia + MID_TO_COUNTRY.put("542", "NU"); // Niue + MID_TO_COUNTRY.put("544", "NR"); // Nauru + MID_TO_COUNTRY.put("546", "PF"); // French Polynesia + MID_TO_COUNTRY.put("548", "PH"); // Philippines + MID_TO_COUNTRY.put("553", "PG"); // Papua New Guinea + MID_TO_COUNTRY.put("555", "PN"); // Pitcairn Islands + MID_TO_COUNTRY.put("557", "SB"); // Solomon Islands + MID_TO_COUNTRY.put("559", "AS"); // American Samoa + MID_TO_COUNTRY.put("561", "WS"); // Samoa + MID_TO_COUNTRY.put("563", "SG"); // Singapore + MID_TO_COUNTRY.put("564", "SG"); // Singapore + MID_TO_COUNTRY.put("565", "SG"); // Singapore + MID_TO_COUNTRY.put("566", "SG"); // Singapore + MID_TO_COUNTRY.put("567", "TH"); // Thailand + MID_TO_COUNTRY.put("570", "TO"); // Tonga + MID_TO_COUNTRY.put("572", "TV"); // Tuvalu + MID_TO_COUNTRY.put("574", "VN"); // Vietnam + MID_TO_COUNTRY.put("576", "VU"); // Vanuatu + MID_TO_COUNTRY.put("578", "WF"); // Wallis and Futuna + + // Africa + MID_TO_COUNTRY.put("601", "ZA"); // South Africa + MID_TO_COUNTRY.put("603", "AO"); // Angola + MID_TO_COUNTRY.put("605", "DZ"); // Algeria + MID_TO_COUNTRY.put("609", "BI"); // Burundi + MID_TO_COUNTRY.put("610", "BJ"); // Benin + MID_TO_COUNTRY.put("611", "BW"); // Botswana + MID_TO_COUNTRY.put("612", "CF"); // Central African Republic + MID_TO_COUNTRY.put("613", "CM"); // Cameroon + MID_TO_COUNTRY.put("615", "CG"); // Congo (Republic) + MID_TO_COUNTRY.put("616", "KM"); // Comoros + MID_TO_COUNTRY.put("617", "CV"); // Cabo Verde + MID_TO_COUNTRY.put("619", "CI"); // Côte d’Ivoire + MID_TO_COUNTRY.put("621", "DJ"); // Djibouti + MID_TO_COUNTRY.put("622", "EG"); // Egypt + MID_TO_COUNTRY.put("624", "ET"); // Ethiopia + MID_TO_COUNTRY.put("625", "ER"); // Eritrea + MID_TO_COUNTRY.put("626", "GA"); // Gabon + MID_TO_COUNTRY.put("627", "GH"); // Ghana + MID_TO_COUNTRY.put("629", "GM"); // Gambia + MID_TO_COUNTRY.put("630", "GW"); // Guinea-Bissau + MID_TO_COUNTRY.put("631", "GQ"); // Equatorial Guinea + MID_TO_COUNTRY.put("632", "GN"); // Guinea + MID_TO_COUNTRY.put("633", "BF"); // Burkina Faso + MID_TO_COUNTRY.put("634", "KE"); // Kenya + MID_TO_COUNTRY.put("636", "LR"); // Liberia + MID_TO_COUNTRY.put("637", "LR"); // Liberia + MID_TO_COUNTRY.put("642", "LY"); // Libya + MID_TO_COUNTRY.put("644", "LS"); // Lesotho + MID_TO_COUNTRY.put("645", "MU"); // Mauritius + MID_TO_COUNTRY.put("647", "MG"); // Madagascar + MID_TO_COUNTRY.put("649", "ML"); // Mali + MID_TO_COUNTRY.put("650", "MZ"); // Mozambique + MID_TO_COUNTRY.put("654", "MR"); // Mauritania + MID_TO_COUNTRY.put("655", "MW"); // Malawi + MID_TO_COUNTRY.put("656", "NE"); // Niger + MID_TO_COUNTRY.put("657", "NG"); // Nigeria + MID_TO_COUNTRY.put("659", "NA"); // Namibia + MID_TO_COUNTRY.put("660", "RE"); // Reunion (FR) + MID_TO_COUNTRY.put("661", "RW"); // Rwanda + MID_TO_COUNTRY.put("662", "SD"); // Sudan + MID_TO_COUNTRY.put("663", "SN"); // Senegal + MID_TO_COUNTRY.put("664", "SC"); // Seychelles + MID_TO_COUNTRY.put("665", "SH"); // Saint Helena + MID_TO_COUNTRY.put("666", "SO"); // Somalia + MID_TO_COUNTRY.put("667", "SL"); // Sierra Leone + MID_TO_COUNTRY.put("668", "ST"); // Sao Tome and Principe + MID_TO_COUNTRY.put("669", "SZ"); // Eswatini + MID_TO_COUNTRY.put("670", "TD"); // Chad + MID_TO_COUNTRY.put("671", "TG"); // Togo + MID_TO_COUNTRY.put("672", "TN"); // Tunisia + MID_TO_COUNTRY.put("674", "TZ"); // Tanzania + MID_TO_COUNTRY.put("675", "UG"); // Uganda + MID_TO_COUNTRY.put("676", "CD"); // DR Congo + MID_TO_COUNTRY.put("677", "TZ"); // Tanzania (alt) + MID_TO_COUNTRY.put("678", "ZM"); // Zambia + MID_TO_COUNTRY.put("679", "ZW"); // Zimbabwe + + // South America + MID_TO_COUNTRY.put("701", "AR"); // Argentina + MID_TO_COUNTRY.put("710", "BR"); // Brazil + MID_TO_COUNTRY.put("720", "BO"); // Bolivia + MID_TO_COUNTRY.put("725", "CL"); // Chile + MID_TO_COUNTRY.put("730", "CO"); // Colombia + MID_TO_COUNTRY.put("735", "EC"); // Ecuador + MID_TO_COUNTRY.put("740", "FK"); // Falkland Islands + MID_TO_COUNTRY.put("745", "GF"); // French Guiana + MID_TO_COUNTRY.put("750", "GY"); // Guyana + MID_TO_COUNTRY.put("755", "PY"); // Paraguay + MID_TO_COUNTRY.put("760", "PE"); // Peru + MID_TO_COUNTRY.put("765", "SR"); // Suriname + MID_TO_COUNTRY.put("770", "UY"); // Uruguay + MID_TO_COUNTRY.put("775", "VE"); // Venezuela + } + + private MIDToCountry() {} +} + + diff --git a/app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java b/app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java index 27bb514..138d9a4 100644 --- a/app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java +++ b/app/src/main/java/com/grigowashere/aismap/utils/SettingsManager.java @@ -19,6 +19,15 @@ public class SettingsManager { private static final String KEY_ANDROID_NMEA_ENABLED = "android_nmea_enabled"; private static final String KEY_UDP_NMEA_ENABLED = "udp_nmea_enabled"; private static final String KEY_DATA_MODE = "data_mode"; + private static final String KEY_DATA_STALE_WARNING_MINUTES = "data_stale_warning_minutes"; + private static final String KEY_DATA_STALE_REMOVE_MINUTES = "data_stale_remove_minutes"; + private static final String KEY_PATH_TRACKING_ENABLED = "path_tracking_enabled"; + private static final String KEY_PATH_COLOR = "path_color"; + private static final String KEY_PREDICTION_COLOR = "prediction_color"; + private static final String KEY_PATH_WIDTH = "path_width"; + private static final String KEY_PREDICTION_WIDTH = "prediction_width"; + private static final String KEY_VIBRATION_ENABLED = "vibration_enabled"; + private static final String KEY_SOUND_ENABLED = "sound_enabled"; // Значения по умолчанию private static final int DEFAULT_UDP_PORT = 10110; @@ -26,6 +35,15 @@ public class SettingsManager { private static final boolean DEFAULT_ANDROID_NMEA_ENABLED = true; private static final boolean DEFAULT_UDP_NMEA_ENABLED = true; private static final String DEFAULT_DATA_MODE = "hybrid"; + private static final int DEFAULT_DATA_STALE_WARNING_MINUTES = 5; // Показывать losingtarget.xml + private static final int DEFAULT_DATA_STALE_REMOVE_MINUTES = 7; // Удалять из списка + private static final boolean DEFAULT_PATH_TRACKING_ENABLED = true; + private static final int DEFAULT_PATH_COLOR = 0xFF00FFFF; // Голубой + private static final int DEFAULT_PREDICTION_COLOR = 0xFFFFFF00; // Желтый + private static final float DEFAULT_PATH_WIDTH = 3.0f; + private static final float DEFAULT_PREDICTION_WIDTH = 2.0f; + private static final boolean DEFAULT_VIBRATION_ENABLED = true; + private static final boolean DEFAULT_SOUND_ENABLED = true; // Режимы работы с данными public static final String DATA_MODE_HYBRID = "hybrid"; @@ -163,6 +181,10 @@ public class SettingsManager { .putBoolean(KEY_ANDROID_NMEA_ENABLED, DEFAULT_ANDROID_NMEA_ENABLED) .putBoolean(KEY_UDP_NMEA_ENABLED, DEFAULT_UDP_NMEA_ENABLED) .putString(KEY_DATA_MODE, DEFAULT_DATA_MODE) + .putInt(KEY_DATA_STALE_WARNING_MINUTES, DEFAULT_DATA_STALE_WARNING_MINUTES) + .putInt(KEY_DATA_STALE_REMOVE_MINUTES, DEFAULT_DATA_STALE_REMOVE_MINUTES) + .putBoolean(KEY_VIBRATION_ENABLED, DEFAULT_VIBRATION_ENABLED) + .putBoolean(KEY_SOUND_ENABLED, DEFAULT_SOUND_ENABLED) .apply(); Log.i(TAG, "Настройки сброшены к значениям по умолчанию"); } @@ -175,12 +197,15 @@ public class SettingsManager { "UDP: порт=%d, включен=%s\n" + "Android NMEA: %s\n" + "UDP NMEA: %s\n" + - "Режим данных: %s", + "Режим данных: %s\n" + + "Уведомления: вибрация=%s, звук=%s", getUDPPort(), isUDPEnabled() ? "да" : "нет", isAndroidNMEAEnabled() ? "включен" : "выключен", isUDPNMEAEnabled() ? "включен" : "выключен", - getDataMode() + getDataMode(), + isVibrationEnabled() ? "включена" : "выключена", + isSoundEnabled() ? "включен" : "выключен" ); } @@ -199,4 +224,155 @@ public class SettingsManager { isUDPNMEAEnabled() != currentUDPNMEA || !getDataMode().equals(currentDataMode); } + + /** + * Получает время предупреждения об устаревших данных (в минутах) + */ + public int getDataStaleWarningMinutes() { + return prefs.getInt(KEY_DATA_STALE_WARNING_MINUTES, DEFAULT_DATA_STALE_WARNING_MINUTES); + } + + /** + * Устанавливает время предупреждения об устаревших данных (в минутах) + */ + public void setDataStaleWarningMinutes(int minutes) { + if (minutes < 1 || minutes > 60) { + Log.w(TAG, "Некорректное время предупреждения: " + minutes + ", используем значение по умолчанию"); + minutes = DEFAULT_DATA_STALE_WARNING_MINUTES; + } + prefs.edit().putInt(KEY_DATA_STALE_WARNING_MINUTES, minutes).apply(); + Log.i(TAG, "Время предупреждения об устаревших данных установлено: " + minutes + " минут"); + } + + /** + * Получает время удаления устаревших данных (в минутах) + */ + public int getDataStaleRemoveMinutes() { + return prefs.getInt(KEY_DATA_STALE_REMOVE_MINUTES, DEFAULT_DATA_STALE_REMOVE_MINUTES); + } + + /** + * Устанавливает время удаления устаревших данных (в минутах) + */ + public void setDataStaleRemoveMinutes(int minutes) { + if (minutes < 1 || minutes > 60) { + Log.w(TAG, "Некорректное время удаления: " + minutes + ", используем значение по умолчанию"); + minutes = DEFAULT_DATA_STALE_REMOVE_MINUTES; + } + prefs.edit().putInt(KEY_DATA_STALE_REMOVE_MINUTES, minutes).apply(); + Log.i(TAG, "Время удаления устаревших данных установлено: " + minutes + " минут"); + } + + /** + * Проверяет, включено ли отслеживание путей + */ + public boolean isPathTrackingEnabled() { + return prefs.getBoolean(KEY_PATH_TRACKING_ENABLED, DEFAULT_PATH_TRACKING_ENABLED); + } + + /** + * Включает/выключает отслеживание путей + */ + public void setPathTrackingEnabled(boolean enabled) { + prefs.edit().putBoolean(KEY_PATH_TRACKING_ENABLED, enabled).apply(); + Log.i(TAG, "Отслеживание путей: " + (enabled ? "включено" : "выключено")); + } + + /** + * Получает цвет пройденного пути + */ + public int getPathColor() { + return prefs.getInt(KEY_PATH_COLOR, DEFAULT_PATH_COLOR); + } + + /** + * Устанавливает цвет пройденного пути + */ + public void setPathColor(int color) { + prefs.edit().putInt(KEY_PATH_COLOR, color).apply(); + Log.i(TAG, "Цвет пройденного пути установлен: " + String.format("#%08X", color)); + } + + /** + * Получает цвет прогнозируемого пути + */ + public int getPredictionColor() { + return prefs.getInt(KEY_PREDICTION_COLOR, DEFAULT_PREDICTION_COLOR); + } + + /** + * Устанавливает цвет прогнозируемого пути + */ + public void setPredictionColor(int color) { + prefs.edit().putInt(KEY_PREDICTION_COLOR, color).apply(); + Log.i(TAG, "Цвет прогнозируемого пути установлен: " + String.format("#%08X", color)); + } + + /** + * Получает ширину линии пройденного пути + */ + public float getPathWidth() { + return prefs.getFloat(KEY_PATH_WIDTH, DEFAULT_PATH_WIDTH); + } + + /** + * Устанавливает ширину линии пройденного пути + */ + public void setPathWidth(float width) { + if (width < 1.0f || width > 10.0f) { + Log.w(TAG, "Некорректная ширина пути: " + width + ", используем значение по умолчанию"); + width = DEFAULT_PATH_WIDTH; + } + prefs.edit().putFloat(KEY_PATH_WIDTH, width).apply(); + Log.i(TAG, "Ширина пройденного пути установлена: " + width); + } + + /** + * Получает ширину линии прогнозируемого пути + */ + public float getPredictionWidth() { + return prefs.getFloat(KEY_PREDICTION_WIDTH, DEFAULT_PREDICTION_WIDTH); + } + + /** + * Устанавливает ширину линии прогнозируемого пути + */ + public void setPredictionWidth(float width) { + if (width < 1.0f || width > 10.0f) { + Log.w(TAG, "Некорректная ширина прогноза: " + width + ", используем значение по умолчанию"); + width = DEFAULT_PREDICTION_WIDTH; + } + prefs.edit().putFloat(KEY_PREDICTION_WIDTH, width).apply(); + Log.i(TAG, "Ширина прогнозируемого пути установлена: " + width); + } + + /** + * Проверяет, включена ли вибрация при обнаружении новых AIS целей + */ + public boolean isVibrationEnabled() { + return prefs.getBoolean(KEY_VIBRATION_ENABLED, DEFAULT_VIBRATION_ENABLED); + } + + /** + * Включает/выключает вибрацию при обнаружении новых AIS целей + */ + public void setVibrationEnabled(boolean enabled) { + prefs.edit().putBoolean(KEY_VIBRATION_ENABLED, enabled).apply(); + Log.i(TAG, "Вибрация при обнаружении новых AIS целей: " + (enabled ? "включена" : "выключена")); + } + + /** + * Проверяет, включен ли звук при обнаружении новых AIS целей + */ + public boolean isSoundEnabled() { + return prefs.getBoolean(KEY_SOUND_ENABLED, DEFAULT_SOUND_ENABLED); + } + + /** + * Включает/выключает звук при обнаружении новых AIS целей + */ + public void setSoundEnabled(boolean enabled) { + prefs.edit().putBoolean(KEY_SOUND_ENABLED, enabled).apply(); + Log.i(TAG, "Звук при обнаружении новых AIS целей: " + (enabled ? "включен" : "выключен")); + } } diff --git a/app/src/main/res/drawable/target.xml b/app/src/main/res/drawable/target.xml index 7da4108..a401956 100644 --- a/app/src/main/res/drawable/target.xml +++ b/app/src/main/res/drawable/target.xml @@ -3,9 +3,16 @@ android:height="162.6dp" android:viewportWidth="91.38" android:viewportHeight="162.6"> + + android:strokeWidth="12" + android:fillColor="#000000" + android:strokeColor="#000000"/> + + diff --git a/app/src/main/res/drawable/targetclassa.xml b/app/src/main/res/drawable/targetclassa.xml new file mode 100644 index 0000000..4a2431c --- /dev/null +++ b/app/src/main/res/drawable/targetclassa.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_ais_targets.xml b/app/src/main/res/layout/activity_ais_targets.xml new file mode 100644 index 0000000..401cf1d --- /dev/null +++ b/app/src/main/res/layout/activity_ais_targets.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index a325d7d..a26a67d 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -54,6 +54,35 @@ android:minWidth="120dp" android:background="@android:color/white" /> +