From a9db35543ba5be59784691b951fcc5368d100f8f Mon Sep 17 00:00:00 2001 From: grigo Date: Wed, 3 Sep 2025 16:06:32 +0300 Subject: [PATCH 1/9] Brach init --- .../java/com/grigowashere/aismap/controllers/NMEAParser.java | 2 ++ 1 file changed, 2 insertions(+) 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 58c23d5..51edd9d 100644 --- a/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java +++ b/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java @@ -13,6 +13,8 @@ import java.util.ArrayList; * Контроллер для парсинга NMEA сообщений * Работает в гибридном режиме: координаты через Location API, остальное через NMEA */ + +//TODO: Вместо регулярных выражений использовать дерево разбора public class NMEAParser { private static final String TAG = "NMEAParser"; From 332ed3ac0add3502f4c965b50dfba6bc41f348a2 Mon Sep 17 00:00:00 2001 From: grigo Date: Wed, 3 Sep 2025 16:36:09 +0300 Subject: [PATCH 2/9] Lucky attempt to accomplish --- .../aismap/controllers/NMEAParser.java | 1023 ++++++++--------- 1 file changed, 451 insertions(+), 572 deletions(-) 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 51edd9d..10521d0 100644 --- a/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java +++ b/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java @@ -4,8 +4,6 @@ import android.util.Log; import com.grigowashere.aismap.models.Vessel; import com.grigowashere.aismap.models.AISVessel; -import java.util.regex.Pattern; -import java.util.regex.Matcher; import java.util.List; import java.util.ArrayList; @@ -14,55 +12,14 @@ import java.util.ArrayList; * Работает в гибридном режиме: координаты через Location API, остальное через NMEA */ -//TODO: Вместо регулярных выражений использовать дерево разбора +/** + * Контроллер для парсинга NMEA сообщений + * Использует простой разбор по запятым вместо регулярных выражений + */ public class NMEAParser { private static final String TAG = "NMEAParser"; - // Паттерны для NMEA сообщений - private static final Pattern GGA_PATTERN = Pattern.compile( - "\\$G[PN]GGA,(\\d{6}\\.\\d{2}),(\\d{4}\\.\\d+),([NS]),(\\d{5}\\.\\d+),([EW]),(\\d),(\\d+),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),\\*([0-9A-F]{2})" - ); - - private static final Pattern RMC_PATTERN = Pattern.compile( - "\\$G[PN]RMC,(\\d{6}\\.\\d{2}),([AV][^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),(\\d{6}),([^,]*),([^,]*),([^,]*),?([^,]*)?\\*([0-9A-F]{2})" - ); - - private static final Pattern VTG_PATTERN = Pattern.compile( - "\\$G[PN]VTG,([^,]*),T,([^,]*),M,([^,]*),N,([^,]*),K\\*([0-9A-F]{2})" - ); - - private static final Pattern GLL_PATTERN = Pattern.compile( - "\\$G[PN]GLL,(\\d{4}\\.\\d{5}),([NS]),(\\d{5}\\.\\d{5}),([EW]),(\\d{6}),([AV]),([AV])\\*([0-9A-F]{2})" - ); - - private static final Pattern GSV_PATTERN = Pattern.compile( - "\\$G[APNLQ]GSV,(\\d+),(\\d+),(\\d+),(.*)\\*([0-9A-F]{2})" - ); - - private static final Pattern GNS_PATTERN = Pattern.compile( - "\\$GNGNS,(\\d{6}),(\\d{4}\\.\\d{5}),([NS]),(\\d{5}\\.\\d{5}),([EW]),(\\w+),(\\d+),(\\d+\\.\\d+),(\\d+\\.\\d+),([^,]*),([^,]*),([^,]*),([AV])\\*([0-9A-F]{2})" - ); - - // Паттерн для GSA сообщения (DOP и активные спутники) - private static final Pattern GSA_PATTERN = Pattern.compile( - "\\$G[PN]GSA,([AM]),(\\d+),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*),([^,]*)\\*([0-9A-F]{2})" - ); - - // Паттерн для обрезанных GSA сообщений - private static final Pattern GSA_TRUNCATED_PATTERN = Pattern.compile( - "\\$G[PN]GSA,([^,]*),([^,]*),([^,]*)\\*([0-9A-F]{2})" - ); - - // Паттерн для ZDA сообщения (Date and Time) - private static final Pattern ZDA_PATTERN = Pattern.compile( - "\\$G[PN]ZDA,(\\d{6}\\.\\d{2}),(\\d{2}),(\\d{2}),(\\d{4}),(\\d{2}),(\\d{2})\\*([0-9A-F]{2})" - ); - - private static final Pattern AIS_PATTERN = Pattern.compile( - "!AIVDM,(\\d+),(\\d+),([^,]*),([AB12]),([^,]+),(\\d)\\*([0-9A-F]{2})" - ); - private Vessel ownVessel; private List aisVessels; private NMEAParserListener listener; @@ -131,26 +88,56 @@ public class NMEAParser { Log.d(TAG, "Парсим NMEA: " + cleanedSentence); try { - if (cleanedSentence.startsWith("$GPGGA") || cleanedSentence.startsWith("$GNGGA")) { - parseGGA(cleanedSentence); - } else if (cleanedSentence.startsWith("$GPRMC") || cleanedSentence.startsWith("$GNRMC")) { - parseRMC(cleanedSentence); - } else if (cleanedSentence.startsWith("$GPVTG") || cleanedSentence.startsWith("$GNVTG")) { - parseVTG(cleanedSentence); - } else if (cleanedSentence.startsWith("$GPGLL") || cleanedSentence.startsWith("$GNGLL")) { - parseGLL(cleanedSentence); - } else if (cleanedSentence.startsWith("$GPGSV") || cleanedSentence.startsWith("$GAGSV") || cleanedSentence.startsWith("$GLGSV") || cleanedSentence.startsWith("$GBGSV") || cleanedSentence.startsWith("$GNGSA")) { - parseGSV(cleanedSentence); - } else if (cleanedSentence.startsWith("$GNGNS")) { - parseGNS(cleanedSentence); - } else if (cleanedSentence.startsWith("$GPGSA") || cleanedSentence.startsWith("$GNGSA")) { - parseGSA(cleanedSentence); - } else if (cleanedSentence.startsWith("$GPZDA") || cleanedSentence.startsWith("$GNZDA")) { - parseZDA(cleanedSentence); - } else if (cleanedSentence.startsWith("!AIVDM")) { - parseAIS(cleanedSentence); - } else { - Log.d(TAG, "Неподдерживаемый тип NMEA сообщения: " + cleanedSentence); + // Разбираем сообщение по запятым + 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); @@ -160,6 +147,46 @@ public class NMEAParser { } } + /** + * Безопасно получает поле по индексу + */ + 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 сообщение от лишних символов */ @@ -228,583 +255,439 @@ public class NMEAParser { /** * Парсит 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 gga) { -// Log.d(TAG, "Парсим GGA: " + gga); -// Log.d(TAG, "Применяем паттерн GGA: " + GGA_PATTERN.pattern()); - Matcher matcher = GGA_PATTERN.matcher(gga); - if (matcher.matches()) { -// Log.d(TAG, "GGA совпадает с паттерном"); - - int satellites = Integer.parseInt(matcher.group(7)); - - // Обрабатываем высоту - может быть пустым полем (теперь в группе 8) - double altitude = 0.0; - String altitudeStr = matcher.group(8); - if (altitudeStr != null && !altitudeStr.trim().isEmpty()) { - try { - altitude = Double.parseDouble(altitudeStr); - } catch (NumberFormatException e) { - Log.w(TAG, "Не удалось распарсить высоту: '" + altitudeStr + "', используем 0.0"); - altitude = 0.0; - } + 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); } -// Log.d(TAG, String.format("GGA: sat=%d, alt=%.1f", satellites, altitude)); - - // В гибридном режиме не обновляем координаты - if (!hybridMode) { - // Обрабатываем координаты - могут быть пустыми полями (группы 2,3,4,5) - double latitude = 0.0; - double longitude = 0.0; - - String latStr = matcher.group(2); - String latDir = matcher.group(3); - if (latStr != null && !latStr.trim().isEmpty() && latDir != null && !latDir.trim().isEmpty()) { - latitude = parseCoordinate(latStr, latDir.equals("N")); - } - - String lonStr = matcher.group(4); - String lonDir = matcher.group(5); - if (lonStr != null && !lonStr.trim().isEmpty() && lonDir != null && !lonDir.trim().isEmpty()) { - longitude = parseCoordinate(lonStr, lonDir.equals("E")); - } - - 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); - } - } else { -// Log.w(TAG, "GGA не совпадает с паттерном"); + } + + 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 rmc) { - Log.d(TAG, "Парсим RMC: " + rmc); - Log.d(TAG, "Применяем паттерн RMC: " + RMC_PATTERN.pattern()); + private void parseRMC(String[] fields) { + Log.d(TAG, "Парсим RMC с " + fields.length + " полями"); - Matcher matcher = RMC_PATTERN.matcher(rmc); - if (matcher.matches()) { - Log.d(TAG, "RMC совпадает с паттерном"); + // Поле 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"); - // Проверяем статус валидности (группа 2) - String status = matcher.group(2); - boolean isValid = status != null && status.startsWith("A"); - Log.d(TAG, "RMC статус: " + status + " (валидный: " + isValid + ")"); - - // Обрабатываем скорость - может быть пустым полем (группа 7) - double speed = 0.0; - String speedStr = matcher.group(7); - if (speedStr != null && !speedStr.trim().isEmpty()) { - try { - speed = Double.parseDouble(speedStr); - } catch (NumberFormatException e) { - Log.w(TAG, "Не удалось распарсить скорость RMC: '" + speedStr + "', используем 0.0"); - speed = 0.0; - } - } - - // Обрабатываем курс - может быть пустым полем (группа 8) - double course = 0.0; - String courseStr = matcher.group(8); - if (courseStr != null && !courseStr.trim().isEmpty()) { - try { - course = Double.parseDouble(courseStr); - } catch (NumberFormatException e) { - Log.w(TAG, "Не удалось распарсить курс: '" + courseStr + "', используем 0.0"); - course = 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,5,6) - double latitude = 0.0; - double longitude = 0.0; - - String latStr = matcher.group(3); - String latDir = matcher.group(4); - if (latStr != null && !latStr.trim().isEmpty() && latDir != null && !latDir.trim().isEmpty()) { - latitude = parseCoordinate(latStr, latDir.equals("N")); - Log.d(TAG, "RMC широта: " + latStr + " " + latDir + " = " + latitude); - } - - String lonStr = matcher.group(5); - String lonDir = matcher.group(6); - if (lonStr != null && !lonStr.trim().isEmpty() && lonDir != null && !lonDir.trim().isEmpty()) { - longitude = parseCoordinate(lonStr, lonDir.equals("E")); - Log.d(TAG, "RMC долгота: " + lonStr + " " + lonDir + " = " + longitude); - } - - Log.d(TAG, "RMC устанавливаем координаты: lat=" + latitude + ", lon=" + longitude); + // Поля 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); } + } else if (hybridMode) { + Log.d(TAG, "Гибридный режим - координаты из RMC игнорируются"); } else { - Log.w(TAG, "RMC не совпадает с паттерном"); - Log.w(TAG, "Сообщение: '" + rmc + "'"); - Log.w(TAG, "Паттерн: " + RMC_PATTERN.pattern()); + 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 vtg) { - Matcher matcher = VTG_PATTERN.matcher(vtg); - if (matcher.matches()) { - // Обрабатываем курс - может быть пустым полем - double course = 0.0; - String courseStr = matcher.group(2); - if (courseStr != null && !courseStr.trim().isEmpty()) { - try { - course = Double.parseDouble(courseStr); - } catch (NumberFormatException e) { -// Log.w(TAG, "Не удалось распарсить курс VTG: '" + courseStr + "', используем 0.0"); - course = 0.0; - } - } - - // Обрабатываем скорость - может быть пустым полем - double speed = 0.0; - String speedStr = matcher.group(4); - if (speedStr != null && !speedStr.trim().isEmpty()) { - try { - speed = Double.parseDouble(speedStr); - } catch (NumberFormatException e) { -// Log.w(TAG, "Не удалось распарсить скорость VTG: '" + speedStr + "', используем 0.0"); - speed = 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); - } + 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 gll) { + private void parseGLL(String[] fields) { if (hybridMode) { Log.d(TAG, "GLL игнорируется в гибридном режиме"); return; } -// Log.d(TAG, "Парсим GLL: " + gll); - Matcher matcher = GLL_PATTERN.matcher(gll); - if (matcher.matches()) { -// Log.d(TAG, "GLL совпадает с паттерном"); - // Обрабатываем координаты - могут быть пустыми полями - double latitude = 0.0; - double longitude = 0.0; - - String latStr = matcher.group(1); - String latDir = matcher.group(2); - if (latStr != null && !latStr.trim().isEmpty() && latDir != null && !latDir.trim().isEmpty()) { - latitude = parseCoordinate(latStr, latDir.equals("N")); - } - - String lonStr = matcher.group(3); - String lonDir = matcher.group(4); - if (lonStr != null && !lonStr.trim().isEmpty() && lonDir != null && !lonDir.trim().isEmpty()) { - longitude = parseCoordinate(lonStr, lonDir.equals("E")); - } - -// Log.d(TAG, String.format("GLL: lat=%.6f, lon=%.6f", latitude, longitude)); - + 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); - - if (listener != null) { - listener.onVesselUpdated(ownVessel); - } - } else { -// Log.w(TAG, "GLL не совпадает с паттерном"); + } + + 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 gsv) { -// Log.d(TAG, "Парсим GSV: " + gsv); -// Log.d(TAG, "Применяем паттерн GSV: " + GSV_PATTERN.pattern()); - Matcher matcher = GSV_PATTERN.matcher(gsv); - if (matcher.matches()) { -// Log.d(TAG, "GSV совпадает с паттерном"); - int totalMessages = Integer.parseInt(matcher.group(1)); - int messageNumber = Integer.parseInt(matcher.group(2)); - int satellitesInView = Integer.parseInt(matcher.group(3)); - - // Определяем тип системы спутников - String systemType = "Unknown"; - if (gsv.startsWith("$GPGSV")) { - systemType = "GPS"; - } else if (gsv.startsWith("$GLGSV")) { - systemType = "GLONASS"; - } else if (gsv.startsWith("$GAGSV")) { - systemType = "Galileo"; - } else if (gsv.startsWith("$GBGSV")) { - systemType = "BeiDou"; - } else if (gsv.startsWith("$GNGSA")) { - systemType = "GNSS"; - } - -// Log.d(TAG, String.format("GSV [%s]: %d/%d, спутников в поле зрения: %d", -// systemType, messageNumber, totalMessages, satellitesInView)); - - // Парсим данные о спутниках из группы 4 - String satelliteData = matcher.group(4); - if (satelliteData != null && !satelliteData.trim().isEmpty()) { - String[] satFields = satelliteData.split(","); -// Log.d(TAG, String.format("Найдено %d полей данных о спутниках", satFields.length)); - - // Логируем информацию о спутниках (каждые 4 поля = 1 спутник) - for (int i = 0; i < satFields.length; i += 4) { - if (i + 3 < satFields.length) { - String satId = satFields[i]; - String elevation = satFields[i + 1]; - String azimuth = satFields[i + 2]; - String snr = satFields[i + 3]; - - if (!satId.trim().isEmpty()) { -// Log.d(TAG, String.format("Спутник %s: elev=%s, azim=%s, SNR=%s", -// satId, elevation, azimuth, snr)); - } - } - } - } - - // GSV содержит информацию о спутниках, но не обновляет позицию - 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); - } - } - } else { -// Log.w(TAG, "GSV не совпадает с паттерном"); -// Log.d(TAG, "Сообщение: '" + gsv + "'"); -// Log.d(TAG, "Паттерн: " + GSV_PATTERN.pattern()); + 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"; } - } - - /** - * Парсит GNS сообщение (GNSS Fix Data) - * В гибридном режиме используем только количество спутников и высоту - */ - private void parseGNS(String gns) { - Log.d(TAG, "Парсим GNS: " + gns); - Matcher matcher = GNS_PATTERN.matcher(gns); - if (matcher.matches()) { -// Log.d(TAG, "GNS совпадает с паттерном"); - - int satellites = Integer.parseInt(matcher.group(7)); - - // Обрабатываем высоту - может быть пустым полем - double altitude = 0.0; - String altitudeStr = matcher.group(8); - if (altitudeStr != null && !altitudeStr.trim().isEmpty()) { - try { - altitude = Double.parseDouble(altitudeStr); - } catch (NumberFormatException e) { -// Log.w(TAG, "Не удалось распарсить высоту GNS: '" + altitudeStr + "', используем 0.0"); - altitude = 0.0; + + 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)); } } - -// Log.d(TAG, String.format("GNS: sat=%d, alt=%.1f", satellites, altitude)); - - // В гибридном режиме не обновляем координаты - if (!hybridMode) { - // Обрабатываем координаты - могут быть пустыми полями - double latitude = 0.0; - double longitude = 0.0; - - String latStr = matcher.group(2); - String latDir = matcher.group(3); - if (latStr != null && !latStr.trim().isEmpty() && latDir != null && !latDir.trim().isEmpty()) { - latitude = parseCoordinate(latStr, latDir.equals("N")); - } - - String lonStr = matcher.group(4); - String lonDir = matcher.group(5); - if (lonStr != null && !lonStr.trim().isEmpty() && lonDir != null && !lonDir.trim().isEmpty()) { - longitude = parseCoordinate(lonStr, lonDir.equals("E")); - } - - ownVessel.setLatitude(latitude); - ownVessel.setLongitude(longitude); + } + + // Обновляем количество спутников только для последнего сообщения в серии + 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; } - ownVessel.setSatellites(satellites); - ownVessel.setAltitude(altitude); + // Обновляем общее количество спутников + 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); } - } else { -// Log.w(TAG, "GNS не совпадает с паттерном"); + } + } + + /** + * Парсит 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 zda) { - Log.d(TAG, "Парсим ZDA: " + zda); - Matcher matcher = ZDA_PATTERN.matcher(zda); - if (matcher.matches()) { - try { - // Время (HHMMSS.SS) - String timeStr = matcher.group(1); - // День (DD) - int day = Integer.parseInt(matcher.group(2)); - // Месяц (MM) - int month = Integer.parseInt(matcher.group(3)); - // Год (YYYY) - int year = Integer.parseInt(matcher.group(4)); - // Часовой пояс (часы) - int timezoneHours = Integer.parseInt(matcher.group(5)); - // Часовой пояс (минуты) - int timezoneMinutes = Integer.parseInt(matcher.group(6)); - - 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 (NumberFormatException e) { - Log.w(TAG, "Ошибка парсинга ZDA: " + e.getMessage()); + 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); } - } else { - Log.w(TAG, "ZDA не совпадает с паттерном: " + zda); + + } 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 gsa) { - Log.d(TAG, "Парсим GSA: " + gsa); - Matcher matcher = GSA_PATTERN.matcher(gsa); - Matcher truncatedMatcher = GSA_TRUNCATED_PATTERN.matcher(gsa); + private void parseGSA(String[] fields) { + Log.d(TAG, "Парсим GSA с " + fields.length + " полями"); - if (matcher.matches()) { - Log.d(TAG, "GSA совпадает с паттерном"); - - // Подсчитываем активные спутники (непустые поля) - int activeSatellites = 0; - for (int i = 3; i <= 14; i++) { // Группы 3-14 содержат ID спутников - String satId = matcher.group(i); - if (satId != null && !satId.trim().isEmpty() && !satId.equals("0")) { - activeSatellites++; - Log.d(TAG, "Активный спутник: " + satId); - } + // Подсчитываем активные спутники (поля 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; - - String pdopStr = matcher.group(15); // PDOP в группе 15 - if (pdopStr != null && !pdopStr.trim().isEmpty()) { - try { - pdop = Double.parseDouble(pdopStr); - } catch (NumberFormatException e) { - Log.w(TAG, "Не удалось распарсить PDOP: '" + pdopStr + "', используем 0.0"); - } + } + + // Получаем 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); } - - String hdopStr = matcher.group(16); // HDOP в группе 16 - if (hdopStr != null && !hdopStr.trim().isEmpty()) { - try { - hdop = Double.parseDouble(hdopStr); - } catch (NumberFormatException e) { - Log.w(TAG, "Не удалось распарсить HDOP: '" + hdopStr + "', используем 0.0"); - } - } - - String vdopStr = matcher.group(17); // VDOP в группе 17 - if (vdopStr != null && !vdopStr.trim().isEmpty()) { - try { - vdop = Double.parseDouble(vdopStr); - } catch (NumberFormatException e) { - Log.w(TAG, "Не удалось распарсить VDOP: '" + vdopStr + "', используем 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); - } - } else if (truncatedMatcher.matches()) { - Log.d(TAG, "GSA совпадает с обрезанным паттерном"); - - // Обрабатываем обрезанное GSA сообщение - String pdopStr = truncatedMatcher.group(1); - String hdopStr = truncatedMatcher.group(2); - String vdopStr = truncatedMatcher.group(3); - - double pdop = 0.0; - double hdop = 0.0; - double vdop = 0.0; - - try { - if (pdopStr != null && !pdopStr.trim().isEmpty()) { - pdop = Double.parseDouble(pdopStr); - } - if (hdopStr != null && !hdopStr.trim().isEmpty()) { - hdop = Double.parseDouble(hdopStr); - } - if (vdopStr != null && !vdopStr.trim().isEmpty()) { - vdop = Double.parseDouble(vdopStr); - } - } catch (NumberFormatException e) { - Log.w(TAG, "Ошибка парсинга DOP в обрезанном GSA: " + e.getMessage()); - } - - Log.d(TAG, String.format("GSA (обрезанное): PDOP=%.2f, HDOP=%.2f, VDOP=%.2f", pdop, hdop, vdop)); - - // Обновляем DOP значения - ownVessel.setPdop(pdop); - ownVessel.setHdop(hdop); - ownVessel.setVdop(vdop); - - // Отправляем DOP значения в GPS Location Listener - if (gpsLocationListener != null) { - gpsLocationListener.setDOPValues(pdop, hdop, vdop); - } - - // Уведомляем слушателя о DOP - if (listener != null) { - listener.onDOPUpdated(pdop, hdop, vdop); - listener.onVesselUpdated(ownVessel); - } - } else { - Log.w(TAG, "GSA не совпадает ни с одним паттерном"); - Log.w(TAG, "Сообщение: '" + gsa + "'"); - Log.w(TAG, "Паттерн: " + GSA_PATTERN.pattern()); - Log.w(TAG, "Обрезанный паттерн: " + GSA_TRUNCATED_PATTERN.pattern()); + } + + 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) { - Matcher matcher = AIS_PATTERN.matcher(ais); - if (matcher.matches()) { - try { - int totalFragments = Integer.parseInt(matcher.group(1)); - int fragmentNumber = Integer.parseInt(matcher.group(2)); - String sequenceId = matcher.group(3); - String channel = matcher.group(4); - String payload = matcher.group(5); - int fillBits = Integer.parseInt(matcher.group(6)); - String checksum = matcher.group(7); + Log.d(TAG, "Парсим AIS: " + ais); + + // Разбираем AIS сообщение по запятым + String[] fields = ais.split(","); + 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: количество бит заполнения + int fillBits = parseIntField(fields, 6, 0); + + // Контрольная сумма находится в последнем поле после * + 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)); @@ -819,26 +702,22 @@ public class NMEAParser { if (payload != null && !payload.trim().isEmpty()) { if (totalFragments == 1) { // Одноканальное сообщение - декодируем сразу - decodeAISPayload(payload, channel.equals("A") ? 0 : 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.equals("A") ? 0 : 1); + collectAISFragments(actualSequenceId, fragmentNumber, totalFragments, payload, channel != null && channel.equals("A") ? 0 : 1); } } else { Log.w(TAG, "AIS payload пустой, пропускаем сообщение"); } - } catch (NumberFormatException e) { - Log.e(TAG, "Ошибка парсинга AIS сообщения: " + e.getMessage() + " для сообщения: " + ais); - if (listener != null) { - listener.onParseError("Ошибка парсинга AIS: " + e.getMessage()); - } + } catch (Exception e) { + Log.e(TAG, "Ошибка парсинга AIS сообщения: " + e.getMessage() + " для сообщения: " + ais); + if (listener != null) { + listener.onParseError("Ошибка парсинга AIS: " + e.getMessage()); } - } else { - Log.w(TAG, "AIS сообщение не соответствует паттерну: " + ais); - Log.d(TAG, "Паттерн: " + AIS_PATTERN.pattern()); } } From cb5ae2c6867cabe1d73b26c688f364323876d510 Mon Sep 17 00:00:00 2001 From: grigo Date: Thu, 4 Sep 2025 16:17:14 +0300 Subject: [PATCH 3/9] New approach is working. Added new ship marker --- .gitignore | 41 ++ .../aismap/maps/YandexMapImpl.java | 492 ++++++++++-------- .../grigowashere/aismap/models/AISVessel.java | 4 + app/src/main/res/drawable/chosentarget.xml | 78 +++ app/src/main/res/drawable/losingtarget.xml | 16 + app/src/main/res/drawable/scaletarget.xml | 11 + app/src/main/res/drawable/target.xml | 11 + 7 files changed, 446 insertions(+), 207 deletions(-) create mode 100644 app/src/main/res/drawable/chosentarget.xml create mode 100644 app/src/main/res/drawable/losingtarget.xml create mode 100644 app/src/main/res/drawable/scaletarget.xml create mode 100644 app/src/main/res/drawable/target.xml diff --git a/.gitignore b/.gitignore index 889f3aa..db042e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.iml .gradle +.idea /local.properties /.idea/caches /.idea/libraries @@ -14,3 +15,43 @@ .externalNativeBuild .cxx local.properties +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.aab +*.apk +output-metadata.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof + +# Android Studio / IntelliJ IDEA +*.iws +.idea/libraries +.idea/tasks.xml +.idea/vcs.xml +.idea/workspace.xml \ No newline at end of file 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 fa01bfa..95e2581 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java @@ -39,40 +39,44 @@ public class YandexMapImpl implements MapInterface { private boolean ownVesselClickListenerSet = false; private Map aisVesselClickListenersSet = new HashMap<>(); + // Флаги для предотвращения повторного добавления обработчиков + private Map aisVesselClickListenersAdded = new HashMap<>(); + + // Слушатель поворота карты + private com.yandex.mapkit.map.InputListener inputListener; + private float lastMapAzimuth = 0.0f; + + // Отслеживание двойного клика для AIS судов + private Map lastClickTime = new HashMap<>(); + private static final long DOUBLE_CLICK_DELAY = 300; // 300мс для двойного клика + public YandexMapImpl(Context context, MapView mapView) { this.context = context; this.mapView = mapView; this.aisMarkers = new HashMap<>(); this.aisVessels = new HashMap<>(); - android.util.Log.d("YandexMapImpl", "Конструктор YandexMapImpl вызван"); - android.util.Log.d("YandexMapImpl", "Context: " + (context != null ? "установлен" : "null")); - android.util.Log.d("YandexMapImpl", "MapView: " + (mapView != null ? "установлен" : "null")); - // Получение коллекции объектов карты try { this.mapObjects = mapView.getMap().getMapObjects().addCollection(); - android.util.Log.d("YandexMapImpl", "Коллекция объектов карты создана: " + (mapObjects != null ? "успешно" : "null")); } catch (Exception e) { - android.util.Log.e("YandexMapImpl", "Ошибка создания коллекции объектов карты: " + e.getMessage(), e); + // Ошибка создания коллекции объектов карты } } @Override public void initialize() { - android.util.Log.d("YandexMapImpl", "initialize() вызван"); - android.util.Log.d("YandexMapImpl", "mapObjects: " + (mapObjects != null ? "установлен" : "null")); - android.util.Log.d("YandexMapImpl", "mapView: " + (mapView != null ? "установлен" : "null")); - android.util.Log.d("YandexMapImpl", "context: " + (context != null ? "установлен" : "null")); - - // Карта уже инициализирована в конструкторе - if (mapObjects != null) { - android.util.Log.d("YandexMapImpl", "Коллекция объектов карты готова к использованию"); - } + // Инициализируем слушатель поворота карты + setupCameraListener(); } @Override public void cleanup() { + // Удаляем слушатель ввода + if (inputListener != null && mapView != null) { + mapView.getMap().removeInputListener(inputListener); + } + if (mapObjects != null) { mapView.getMap().getMapObjects().remove(mapObjects); } @@ -83,151 +87,123 @@ public class YandexMapImpl implements MapInterface { @Override public void addOwnVesselMarker(Vessel vessel) { - android.util.Log.d("YandexMapImpl", "addOwnVesselMarker вызван: lat=" + vessel.getLatitude() + ", lon=" + vessel.getLongitude() + ", course=" + vessel.getCourse() + "°"); - // Сохраняем ссылку на судно this.ownVessel = vessel; - // Проверяем координаты - if (vessel.getLatitude() == 0.0 && vessel.getLongitude() == 0.0) { - android.util.Log.w("YandexMapImpl", "Координаты равны 0,0 - маркер не будет создан"); + // Проверяем валидность координат (исключаем только невалидные значения) + if (Double.isNaN(vessel.getLatitude()) || Double.isNaN(vessel.getLongitude()) || + Double.isInfinite(vessel.getLatitude()) || Double.isInfinite(vessel.getLongitude())) { return; } if (ownVesselMarker != null) { - android.util.Log.d("YandexMapImpl", "Удаляем существующий маркер"); mapObjects.remove(ownVesselMarker); } Point point = new Point(vessel.getLatitude(), vessel.getLongitude()); - android.util.Log.d("YandexMapImpl", "Создаем Point: " + point); - ownVesselMarker = mapObjects.addPlacemark(point); - android.util.Log.d("YandexMapImpl", "Placemark создан: " + (ownVesselMarker != null ? "успешно" : "null")); if (ownVesselMarker == null) { - android.util.Log.e("YandexMapImpl", "Не удалось создать Placemark!"); return; } - // Используем готовую иконку стрелки с учетом курса - android.util.Log.d("YandexMapImpl", "Устанавливаем иконку стрелки с курсом: " + vessel.getCourse() + "°"); - setMarkerIcon(ownVesselMarker, "arrowship", vessel.getCourse()); + // Используем готовую иконку стрелки с учетом курса (синий цвет для нашего судна) + setMarkerIcon(ownVesselMarker, "target", vessel.getCourse(), android.graphics.Color.BLUE); // Устанавливаем размер иконки - android.util.Log.d("YandexMapImpl", "Устанавливаем IconStyle..."); com.yandex.mapkit.map.IconStyle iconStyle = new com.yandex.mapkit.map.IconStyle(); - iconStyle.setScale(1.5f); // Увеличиваем размер иконки + iconStyle.setScale(1.0f); // Уменьшаем масштаб иконки ownVesselMarker.setIconStyle(iconStyle); // Устанавливаем обработчик кликов только если он еще не установлен if (!ownVesselClickListenerSet) { - android.util.Log.d("YandexMapImpl", "Устанавливаем обработчик клика для маркера..."); ownVesselMarker.addTapListener((mapObject, point1) -> { - android.util.Log.d("YandexMapImpl", "Клик по маркеру нашего судна!"); if (markerClickListener != null && ownVessel != null) { - android.util.Log.d("YandexMapImpl", "Вызываем callback onOwnVesselClick"); markerClickListener.onOwnVesselClick(ownVessel); - } else { - android.util.Log.e("YandexMapImpl", "markerClickListener == null или ownVessel == null!"); - android.util.Log.d("YandexMapImpl", "markerClickListener = " + (markerClickListener != null ? "установлен" : "null")); - android.util.Log.d("YandexMapImpl", "ownVessel = " + (ownVessel != null ? "установлен" : "null")); } return true; }); ownVesselClickListenerSet = true; } - - android.util.Log.d("YandexMapImpl", "Маркер нашего судна создан и настроен, markerClickListener = " + (markerClickListener != null ? "установлен" : "null")); - - // Проверяем, что маркер действительно добавлен в коллекцию - android.util.Log.d("YandexMapImpl", "Маркер добавлен в коллекцию объектов карты"); } @Override public void updateOwnVesselPosition(Vessel vessel) { - android.util.Log.d("YandexMapImpl", "updateOwnVesselPosition вызван: lat=" + vessel.getLatitude() + ", lon=" + vessel.getLongitude() + ", course=" + vessel.getCourse() + "°"); - // Обновляем ссылку на судно this.ownVessel = vessel; - // Проверяем координаты - if (vessel.getLatitude() == 0.0 && vessel.getLongitude() == 0.0) { - android.util.Log.w("YandexMapImpl", "Координаты равны 0,0 - обновление пропущено"); + // Проверяем валидность координат (исключаем только невалидные значения) + if (Double.isNaN(vessel.getLatitude()) || Double.isNaN(vessel.getLongitude()) || + Double.isInfinite(vessel.getLatitude()) || Double.isInfinite(vessel.getLongitude())) { return; } if (ownVesselMarker == null) { // Создаем маркер нашего судна, если его еще нет - android.util.Log.d("YandexMapImpl", "Создаем новый маркер нашего судна"); addOwnVesselMarker(vessel); } else { - // Проверяем, нужно ли обновить курс - boolean needCourseUpdate = Math.abs(vessel.getCourse()) > 0.1; // Если курс больше 0.1 градуса - - if (needCourseUpdate) { - android.util.Log.d("YandexMapImpl", "Обновляем курс маркера на " + vessel.getCourse() + "°"); - // Обновляем только иконку с новым курсом - setMarkerIcon(ownVesselMarker, "arrowship", vessel.getCourse()); - } + // Всегда обновляем иконку с текущим курсом + // Обновляем иконку с новым курсом (синий цвет для нашего судна) + setMarkerIcon(ownVesselMarker, "target", vessel.getCourse(), android.graphics.Color.BLUE); // Обновляем позицию маркера Point newPoint = new Point(vessel.getLatitude(), vessel.getLongitude()); ownVesselMarker.setGeometry(newPoint); - android.util.Log.d("YandexMapImpl", "Позиция маркера обновлена на: " + newPoint); - // Переустанавливаем обработчик клика после обновления маркера - if (markerClickListener != null) { - android.util.Log.d("YandexMapImpl", "Переустанавливаем обработчик клика после обновления маркера"); - // В Яндекс.Картах нет метода setTapListener(null), поэтому просто добавляем новый обработчик - ownVesselMarker.addTapListener((mapObject, point1) -> { - android.util.Log.d("YandexMapImpl", "Клик по маркеру нашего судна!"); - if (markerClickListener != null && ownVessel != null) { - android.util.Log.d("YandexMapImpl", "Вызываем callback onOwnVesselClick"); - markerClickListener.onOwnVesselClick(ownVessel); - } else { - android.util.Log.e("YandexMapImpl", "markerClickListener == null или ownVessel == null!"); - } - return true; - }); + // Обработчик клика уже установлен при создании маркера, не добавляем повторно } - } - - android.util.Log.d("YandexMapImpl", "Маркер нашего судна обновлен, ownVesselMarker = " + (ownVesselMarker != null ? "создан" : "null") + ", markerClickListener = " + (markerClickListener != null ? "установлен" : "null")); } @Override public void addAISVesselMarker(AISVessel vessel) { - android.util.Log.d("YandexMapImpl", "addAISVesselMarker вызван: lat=" + vessel.getLatitude() + ", lon=" + vessel.getLongitude() + ", course=" + vessel.getCourse() + "°"); + // Проверяем валидность координат (исключаем только невалидные значения) + if (Double.isNaN(vessel.getLatitude()) || Double.isNaN(vessel.getLongitude()) || + Double.isInfinite(vessel.getLatitude()) || Double.isInfinite(vessel.getLongitude())) { + return; + } + Point point = new Point(vessel.getLatitude(), vessel.getLongitude()); com.yandex.mapkit.map.PlacemarkMapObject marker = mapObjects.addPlacemark(point); + if (marker == null) { + return; + } + // Сохраняем ссылку на судно aisVessels.put(vessel.getMmsi(), vessel); - // Используем готовую иконку стрелки для AIS судов с учетом курса - setMarkerIcon(marker, "arrowship", vessel.getCourse()); + // Используем готовую иконку стрелки для AIS судов с учетом курса и цвета + int vesselColor = getVesselColor(vessel); + setMarkerIcon(marker, "target", vessel.getCourse(), vesselColor, vessel.isSelected()); // Устанавливаем размер иконки com.yandex.mapkit.map.IconStyle iconStyle = new com.yandex.mapkit.map.IconStyle(); - iconStyle.setScale(1.5f); // Увеличиваем размер иконки + iconStyle.setScale(1.0f); // Уменьшаем масштаб иконки marker.setIconStyle(iconStyle); // Установка обработчика кликов только если он еще не установлен String mmsi = vessel.getMmsi(); - if (!aisVesselClickListenersSet.containsKey(mmsi) || !aisVesselClickListenersSet.get(mmsi)) { + if (!aisVesselClickListenersAdded.containsKey(mmsi) || !aisVesselClickListenersAdded.get(mmsi)) { marker.addTapListener((mapObject, point1) -> { - android.util.Log.d("YandexMapImpl", "Клик по AIS маркеру: " + mmsi); - if (markerClickListener != null) { - android.util.Log.d("YandexMapImpl", "Вызываем callback onAISVesselClick"); - markerClickListener.onAISVesselClick(vessel); + // Проверяем двойной клик + long currentTime = System.currentTimeMillis(); + Long lastTime = lastClickTime.get(mmsi); + + if (lastTime != null && (currentTime - lastTime) < DOUBLE_CLICK_DELAY) { + // Двойной клик - вызываем BottomSheet + if (markerClickListener != null) { + markerClickListener.onAISVesselClick(vessel); + } + lastClickTime.remove(mmsi); // Сбрасываем для следующего двойного клика } else { - android.util.Log.e("YandexMapImpl", "markerClickListener == null!"); - android.util.Log.d("YandexMapImpl", "markerClickListener = " + (markerClickListener != null ? "установлен" : "null")); + // Одиночный клик - выделяем/снимаем выделение + toggleVesselSelection(vessel); + lastClickTime.put(mmsi, currentTime); } + return true; }); - aisVesselClickListenersSet.put(mmsi, true); + aisVesselClickListenersAdded.put(mmsi, true); } aisMarkers.put(vessel.getMmsi(), marker); @@ -243,27 +219,11 @@ public class YandexMapImpl implements MapInterface { Point newPoint = new Point(vessel.getLatitude(), vessel.getLongitude()); marker.setGeometry(newPoint); - // Обновляем курс маркера, если он изменился - if (Math.abs(vessel.getCourse()) > 0.1) { - android.util.Log.d("YandexMapImpl", "Обновляем курс AIS маркера " + vessel.getMmsi() + " на " + vessel.getCourse() + "°"); - setMarkerIcon(marker, "arrowship", vessel.getCourse()); - } + // Всегда обновляем курс маркера + int vesselColor = getVesselColor(vessel); + setMarkerIcon(marker, "target", vessel.getCourse(), vesselColor, vessel.isSelected()); - // Переустанавливаем обработчик клика после обновления маркера - if (markerClickListener != null) { - android.util.Log.d("YandexMapImpl", "Переустанавливаем обработчик клика для AIS маркера: " + vessel.getMmsi()); - // В Яндекс.Картах нет метода setTapListener(null), поэтому просто добавляем новый обработчик - marker.addTapListener((mapObject, point1) -> { - android.util.Log.d("YandexMapImpl", "Клик по AIS маркеру: " + vessel.getMmsi()); - if (markerClickListener != null) { - android.util.Log.d("YandexMapImpl", "Вызываем callback onAISVesselClick"); - markerClickListener.onAISVesselClick(vessel); - } else { - android.util.Log.e("YandexMapImpl", "markerClickListener == null!"); - } - return true; - }); - } + // Обработчик клика уже установлен при создании маркера, не добавляем повторно } } @@ -275,8 +235,9 @@ public class YandexMapImpl implements MapInterface { } // Удаляем ссылку на судно aisVessels.remove(mmsi); - // Удаляем флаг обработчика кликов + // Удаляем флаги обработчиков кликов aisVesselClickListenersSet.remove(mmsi); + aisVesselClickListenersAdded.remove(mmsi); } @Override @@ -287,6 +248,7 @@ public class YandexMapImpl implements MapInterface { aisMarkers.clear(); aisVessels.clear(); aisVesselClickListenersSet.clear(); + aisVesselClickListenersAdded.clear(); } @Override @@ -322,7 +284,6 @@ public class YandexMapImpl implements MapInterface { @Override public void setMarkerClickListener(MarkerClickListener listener) { - android.util.Log.d("YandexMapImpl", "setMarkerClickListener вызван: " + (listener != null ? "listener установлен" : "listener == null")); this.markerClickListener = listener; // Переустанавливаем обработчики кликов для всех существующих маркеров @@ -334,46 +295,108 @@ public class YandexMapImpl implements MapInterface { * Этот метод переустанавливает обработчики для всех маркеров */ private void updateAllMarkerClickListeners() { - android.util.Log.d("YandexMapImpl", "updateAllMarkerClickListeners вызван - переустанавливаем обработчики"); + // Обработчик для маркера нашего судна уже установлен при создании, не добавляем повторно - // Переустанавливаем обработчик для маркера нашего судна - if (ownVesselMarker != null) { - android.util.Log.d("YandexMapImpl", "Переустанавливаем обработчик для маркера нашего судна"); - // В Яндекс.Картах нет метода setTapListener(null), поэтому просто добавляем новый обработчик - ownVesselMarker.addTapListener((mapObject, point1) -> { - android.util.Log.d("YandexMapImpl", "Клик по маркеру нашего судна!"); - if (markerClickListener != null && ownVessel != null) { - android.util.Log.d("YandexMapImpl", "Вызываем callback onOwnVesselClick"); - markerClickListener.onOwnVesselClick(ownVessel); - } else { - android.util.Log.e("YandexMapImpl", "markerClickListener == null или ownVessel == null!"); - } - return true; - }); - ownVesselClickListenerSet = true; - } - - // Переустанавливаем обработчики для AIS маркеров - for (Map.Entry entry : aisMarkers.entrySet()) { - String mmsi = entry.getKey(); - com.yandex.mapkit.map.PlacemarkMapObject marker = entry.getValue(); - AISVessel vessel = aisVessels.get(mmsi); - - if (marker != null && vessel != null) { - android.util.Log.d("YandexMapImpl", "Переустанавливаем обработчик для AIS маркера: " + mmsi); - // В Яндекс.Картах нет метода setTapListener(null), поэтому просто добавляем новый обработчик - marker.addTapListener((mapObject, point1) -> { - android.util.Log.d("YandexMapImpl", "Клик по AIS маркеру: " + mmsi); - if (markerClickListener != null) { - android.util.Log.d("YandexMapImpl", "Вызываем callback onAISVesselClick"); - markerClickListener.onAISVesselClick(vessel); - } else { - android.util.Log.e("YandexMapImpl", "markerClickListener == null!"); - } - return true; - }); - aisVesselClickListenersSet.put(mmsi, true); + // Обработчики для AIS маркеров уже установлены при создании, не добавляем повторно + } + + /** + * Создание повернутой цветной иконки из ресурса + */ + private Bitmap createRotatedIconFromResource(int resourceId, double course, int color) { + return createRotatedIconFromResource(resourceId, course, color, false); + } + + /** + * Создание повернутой цветной иконки из ресурса с поддержкой выделения + */ + private Bitmap createRotatedIconFromResource(int resourceId, double course, int color, boolean isSelected) { + try { + // Получаем drawable из ресурса + Drawable drawable = context.getResources().getDrawable(resourceId, null); + if (drawable == null) { + return null; } + + // Применяем цвет к drawable + if (color != 0) { + drawable.setColorFilter(color, android.graphics.PorterDuff.Mode.SRC_IN); + } + + // Получаем оригинальные размеры drawable + int originalWidth = drawable.getIntrinsicWidth(); + int originalHeight = drawable.getIntrinsicHeight(); + + // Если размеры не определены, используем стандартные + if (originalWidth <= 0) originalWidth = 32; + if (originalHeight <= 0) originalHeight = 48; + + // Масштабируем размеры (уменьшаем еще больше) + float scale = 0.3f; // Уменьшаем в 3.3 раза + int width = (int) (originalWidth * scale); + int height = (int) (originalHeight * scale); + + // Получаем азимут карты (поворот карты) + float mapAzimuth = 0.0f; + try { + CameraPosition cameraPosition = mapView.getMap().getCameraPosition(); + mapAzimuth = cameraPosition.getAzimuth(); + } catch (Exception e) { + // Не удалось получить азимут карты + } + + // Создаем bitmap минимального размера для уменьшения хитбокса + int bitmapSize = Math.max(width, height) + 8; // Добавляем только небольшой отступ + Bitmap bitmap = Bitmap.createBitmap(bitmapSize, bitmapSize, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + // Поворачиваем маркер на курс судна с учетом поворота карты + // Курс судна - это направление относительно севера + // Азимут карты - это поворот карты относительно севера + // Итоговый поворот = курс судна - азимут карты (чтобы маркер оставался относительно севера) + float rotationAngle = (float) (course - mapAzimuth); + + // Центрируем drawable в bitmap + int centerX = bitmapSize / 2; + int centerY = bitmapSize / 2; + int left = centerX - width / 2; + int top = centerY - height / 2; + + // Устанавливаем границы для drawable + drawable.setBounds(left, top, left + width, top + height); + + // Поворачиваем canvas на курс + canvas.save(); + canvas.rotate(rotationAngle, centerX, centerY); + + // Рисуем drawable + drawable.draw(canvas); + + canvas.restore(); + + // Если судно выделено, добавляем рамку выделения + if (isSelected) { + // Получаем drawable для рамки выделения + Drawable selectionDrawable = context.getResources().getDrawable( + context.getResources().getIdentifier("chosentarget", "drawable", context.getPackageName()), null); + + if (selectionDrawable != null) { + // Масштабируем рамку выделения + int selectionSize = Math.max(width, height) + 16; // Рамка немного больше + int selectionLeft = centerX - selectionSize / 2; + int selectionTop = centerY - selectionSize / 2; + + selectionDrawable.setBounds(selectionLeft, selectionTop, + selectionLeft + selectionSize, selectionTop + selectionSize); + + // Рисуем рамку выделения + selectionDrawable.draw(canvas); + } + } + + return bitmap; + } catch (Exception e) { + return null; } } @@ -410,10 +433,8 @@ public class YandexMapImpl implements MapInterface { canvas.drawPath(path, paint); canvas.restore(); - android.util.Log.d("YandexMapImpl", "Программная иконка с курсом " + course + "° создана успешно, размер: " + size + "x" + size); return bitmap; } catch (Exception e) { - android.util.Log.e("YandexMapImpl", "Ошибка создания программной иконки: " + e.getMessage(), e); return null; } } @@ -429,7 +450,6 @@ public class YandexMapImpl implements MapInterface { * Принудительно пересоздает маркер нашего судна с иконкой */ public void recreateOwnVesselMarker(Vessel vessel) { - android.util.Log.d("YandexMapImpl", "Принудительно пересоздаем маркер нашего судна"); if (ownVesselMarker != null) { mapObjects.remove(ownVesselMarker); ownVesselMarker = null; @@ -442,7 +462,6 @@ public class YandexMapImpl implements MapInterface { * Вызывается после закрытия BottomSheet для восстановления функциональности */ public void refreshMarkerClickListeners() { - android.util.Log.d("YandexMapImpl", "refreshMarkerClickListeners вызван - переустанавливаем все обработчики"); updateAllMarkerClickListeners(); } @@ -450,84 +469,143 @@ public class YandexMapImpl implements MapInterface { * Устанавливает иконку для маркера с fallback */ private void setMarkerIcon(com.yandex.mapkit.map.PlacemarkMapObject marker, String iconName, double course) { + setMarkerIcon(marker, iconName, course, 0); // По умолчанию без цвета + } + + /** + * Устанавливает цветную иконку для маркера с fallback + */ + private void setMarkerIcon(com.yandex.mapkit.map.PlacemarkMapObject marker, String iconName, double course, int color) { + setMarkerIcon(marker, iconName, course, color, false); + } + + /** + * Устанавливает цветную иконку для маркера с поддержкой выделения + */ + private void setMarkerIcon(com.yandex.mapkit.map.PlacemarkMapObject marker, String iconName, double course, int color, boolean isSelected) { try { - android.util.Log.d("YandexMapImpl", "Пытаемся установить иконку: " + iconName + " с курсом: " + course + "°"); - android.util.Log.d("YandexMapImpl", "Package name: " + context.getPackageName()); - - // Сначала пробуем создать программную иконку с учетом курса - android.util.Log.d("YandexMapImpl", "Создаем программную иконку стрелки с курсом " + course + "°..."); - Bitmap iconBitmap = createVesselIcon(android.graphics.Color.BLUE, course); - if (iconBitmap != null) { - android.util.Log.d("YandexMapImpl", "Программная иконка с курсом " + course + "° создана, устанавливаем..."); - marker.setIcon(ImageProvider.fromBitmap(iconBitmap)); - android.util.Log.d("YandexMapImpl", "Программная иконка с курсом " + course + "° установлена успешно"); - return; - } - - // Если программная иконка не создалась, пробуем ресурс + // Сначала пробуем использовать ресурс с поворотом int iconResId = context.getResources().getIdentifier(iconName, "drawable", context.getPackageName()); - android.util.Log.d("YandexMapImpl", "ID ресурса " + iconName + ": " + iconResId); if (iconResId != 0) { - android.util.Log.d("YandexMapImpl", "Устанавливаем иконку из ресурса..."); - marker.setIcon(ImageProvider.fromResource(context, iconResId)); - android.util.Log.d("YandexMapImpl", "Иконка " + iconName + " установлена успешно"); + Bitmap rotatedBitmap = createRotatedIconFromResource(iconResId, course, color, isSelected); + if (rotatedBitmap != null) { + marker.setIcon(ImageProvider.fromBitmap(rotatedBitmap)); + return; + } else { + marker.setIcon(ImageProvider.fromResource(context, iconResId)); + return; + } + } + + // Если ресурс не найден, создаем программную иконку с учетом курса + Bitmap iconBitmap = createVesselIcon(android.graphics.Color.BLUE, course); + if (iconBitmap != null) { + marker.setIcon(ImageProvider.fromBitmap(iconBitmap)); } else { - android.util.Log.e("YandexMapImpl", "Не удалось найти ресурс " + iconName); - android.util.Log.d("YandexMapImpl", "Используем fallback иконку..."); // Создаем простую иконку как fallback marker.setIcon(ImageProvider.fromResource(context, android.R.drawable.ic_menu_compass)); - android.util.Log.d("YandexMapImpl", "Fallback иконка установлена"); } } catch (Exception e) { - android.util.Log.e("YandexMapImpl", "Ошибка установки иконки " + iconName + ": " + e.getMessage(), e); - android.util.Log.d("YandexMapImpl", "Используем fallback иконку после ошибки..."); // Создаем простую иконку как fallback marker.setIcon(ImageProvider.fromResource(context, android.R.drawable.ic_menu_compass)); - android.util.Log.d("YandexMapImpl", "Fallback иконка установлена после ошибки"); } // После установки иконки проверяем, что обработчик клика все еще работает // Это может помочь с проблемами, когда установка иконки нарушает обработчики - android.util.Log.d("YandexMapImpl", "Иконка установлена, проверяем обработчик клика..."); - // Дополнительная проверка: если это маркер нашего судна, переустанавливаем обработчик клика - if (marker == ownVesselMarker && markerClickListener != null) { - android.util.Log.d("YandexMapImpl", "Переустанавливаем обработчик клика для маркера нашего судна после установки иконки"); - // В Яндекс.Картах нет метода setTapListener(null), поэтому просто добавляем новый обработчик - marker.addTapListener((mapObject, point1) -> { - android.util.Log.d("YandexMapImpl", "Клик по маркеру нашего судна!"); - if (markerClickListener != null && ownVessel != null) { - android.util.Log.d("YandexMapImpl", "Вызываем callback onOwnVesselClick"); - markerClickListener.onOwnVesselClick(ownVessel); - } else { - android.util.Log.e("YandexMapImpl", "markerClickListener == null или ownVessel == null!"); + // Обработчик клика для нашего судна уже установлен при создании маркера, не добавляем повторно + + // Обработчики кликов уже установлены при создании маркеров, не добавляем повторно + } + + /** + * Настройка слушателя поворота карты + */ + private void setupCameraListener() { + try { + inputListener = new com.yandex.mapkit.map.InputListener() { + @Override + public void onMapTap(com.yandex.mapkit.map.Map map, com.yandex.mapkit.geometry.Point point) { + // Не обрабатываем клики по карте } - return true; - }); + + @Override + public void onMapLongTap(com.yandex.mapkit.map.Map map, com.yandex.mapkit.geometry.Point point) { + // Не обрабатываем долгие клики по карте + } + }; + + // Добавляем слушатель к карте + mapView.getMap().addInputListener(inputListener); + + // Включаем жесты поворота карты + mapView.getMap().setRotateGesturesEnabled(true); + } catch (Exception e) { + // Ошибка установки слушателя + } + } + + /** + * Перерисовывает все маркеры с учетом текущего азимута карты + * Вызывается при повороте карты + */ + public void refreshAllMarkers() { + // Перерисовываем маркер нашего судна (синий цвет) + if (ownVesselMarker != null && ownVessel != null) { + setMarkerIcon(ownVesselMarker, "target", ownVessel.getCourse(), android.graphics.Color.BLUE); } - // Дополнительная проверка: если это AIS маркер, переустанавливаем обработчик клика + // Перерисовываем все AIS маркеры с их цветами for (Map.Entry entry : aisMarkers.entrySet()) { - if (entry.getValue() == marker && markerClickListener != null) { - String mmsi = entry.getKey(); - AISVessel vessel = aisVessels.get(mmsi); - if (vessel != null) { - android.util.Log.d("YandexMapImpl", "Переустанавливаем обработчик клика для AIS маркера " + mmsi + " после установки иконки"); - // В Яндекс.Картах нет метода setTapListener(null), поэтому просто добавляем новый обработчик - marker.addTapListener((mapObject, point1) -> { - android.util.Log.d("YandexMapImpl", "Клик по AIS маркеру: " + mmsi); - if (markerClickListener != null) { - android.util.Log.d("YandexMapImpl", "Вызываем callback onAISVesselClick"); - markerClickListener.onAISVesselClick(vessel); - } else { - android.util.Log.e("YandexMapImpl", "markerClickListener == null!"); - } - return true; - }); - } - break; + String mmsi = entry.getKey(); + com.yandex.mapkit.map.PlacemarkMapObject marker = entry.getValue(); + AISVessel vessel = aisVessels.get(mmsi); + + if (marker != null && vessel != null) { + int vesselColor = getVesselColor(vessel); + setMarkerIcon(marker, "target", vessel.getCourse(), vesselColor, vessel.isSelected()); } } } + + /** + * Переключает выделение AIS судна + */ + private void toggleVesselSelection(AISVessel vessel) { + vessel.setSelected(!vessel.isSelected()); + + // Обновляем иконку маркера с учетом состояния выделения + com.yandex.mapkit.map.PlacemarkMapObject marker = aisMarkers.get(vessel.getMmsi()); + if (marker != null) { + int vesselColor = getVesselColor(vessel); + setMarkerIcon(marker, "target", vessel.getCourse(), vesselColor, vessel.isSelected()); + } + } + + /** + * Получает цвет для AIS судна в зависимости от его статуса + */ + private int getVesselColor(AISVessel vessel) { + // Можно настроить цвета в зависимости от параметров судна + // Используем navigation status из AIS данных + String navStatus = vessel.getNavigationalStatus(); + if (navStatus != null) { + switch (navStatus.toLowerCase()) { + case "under way using engine": + case "under way": + return android.graphics.Color.GREEN; + case "at anchor": + return android.graphics.Color.YELLOW; + case "moored": + return android.graphics.Color.BLUE; + case "not under command": + case "restricted manoeuvrability": + return android.graphics.Color.RED; + default: + return android.graphics.Color.WHITE; + } + } + return android.graphics.Color.WHITE; // По умолчанию белый + } } 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 0fcd8f7..9c00826 100644 --- a/app/src/main/java/com/grigowashere/aismap/models/AISVessel.java +++ b/app/src/main/java/com/grigowashere/aismap/models/AISVessel.java @@ -29,6 +29,7 @@ public class AISVessel { private boolean positionAccuracy; // точность позиции private String vesselClass; // класс судна (Class A, Class B, Extended Class B) private String vendorId; // идентификатор производителя оборудования + private boolean selected; // выделено ли судно на карте public AISVessel() { this.lastUpdate = LocalDateTime.now(); @@ -110,6 +111,9 @@ public class AISVessel { public String getVendorId() { return vendorId; } public void setVendorId(String vendorId) { this.vendorId = vendorId; } + public boolean isSelected() { return selected; } + public void setSelected(boolean selected) { this.selected = selected; } + /** * Обновляет позицию и курс судна */ diff --git a/app/src/main/res/drawable/chosentarget.xml b/app/src/main/res/drawable/chosentarget.xml new file mode 100644 index 0000000..3aeaaab --- /dev/null +++ b/app/src/main/res/drawable/chosentarget.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/losingtarget.xml b/app/src/main/res/drawable/losingtarget.xml new file mode 100644 index 0000000..8a426c7 --- /dev/null +++ b/app/src/main/res/drawable/losingtarget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/scaletarget.xml b/app/src/main/res/drawable/scaletarget.xml new file mode 100644 index 0000000..913d715 --- /dev/null +++ b/app/src/main/res/drawable/scaletarget.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/target.xml b/app/src/main/res/drawable/target.xml new file mode 100644 index 0000000..7da4108 --- /dev/null +++ b/app/src/main/res/drawable/target.xml @@ -0,0 +1,11 @@ + + + From 2b0afe4d791464e7386e5377dd8bf2382892b8fd Mon Sep 17 00:00:00 2001 From: grigo Date: Fri, 5 Sep 2025 11:35:10 +0300 Subject: [PATCH 4/9] Added marker manager Hoping to get a sick day --- .../aismap/maps/MarkerManager.java | 61 +++ .../aismap/maps/MarkerWrapper.java | 101 ++++ .../aismap/maps/YandexMapImpl.java | 517 +++--------------- .../aismap/maps/YandexMarkerManager.java | 295 ++++++++++ .../aismap/maps/YandexMarkerWrapper.java | 405 ++++++++++++++ 5 files changed, 952 insertions(+), 427 deletions(-) create mode 100644 app/src/main/java/com/grigowashere/aismap/maps/MarkerManager.java create mode 100644 app/src/main/java/com/grigowashere/aismap/maps/MarkerWrapper.java create mode 100644 app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerManager.java create mode 100644 app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerWrapper.java diff --git a/app/src/main/java/com/grigowashere/aismap/maps/MarkerManager.java b/app/src/main/java/com/grigowashere/aismap/maps/MarkerManager.java new file mode 100644 index 0000000..cfe3f4c --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/maps/MarkerManager.java @@ -0,0 +1,61 @@ +package com.grigowashere.aismap.maps; + +import com.grigowashere.aismap.models.Vessel; +import com.grigowashere.aismap.models.AISVessel; + +/** + * Интерфейс для управления маркерами на карте + * Отделяет логику управления маркерами от конкретной реализации карты + */ +public interface MarkerManager { + + /** + * Инициализация менеджера маркеров + */ + void initialize(); + + /** + * Очистка ресурсов менеджера маркеров + */ + void cleanup(); + + /** + * Добавление или обновление маркера нашего судна + */ + void updateOwnVesselMarker(Vessel vessel); + + /** + * Добавление или обновление маркера AIS судна + */ + void updateAISVesselMarker(AISVessel vessel); + + /** + * Удаление маркера AIS судна + */ + void removeAISVesselMarker(String mmsi); + + /** + * Очистка всех AIS маркеров + */ + void clearAISVesselMarkers(); + + /** + * Установка обработчика кликов по маркерам + */ + void setMarkerClickListener(MapInterface.MarkerClickListener listener); + + /** + * Обновление всех маркеров (например, при повороте карты) + */ + void refreshAllMarkers(); + + /** + * Проверка и восстановление финализированных маркеров + */ + void checkAndRestoreMarkers(); + + /** + * Получение количества активных маркеров + */ + int getActiveMarkerCount(); +} diff --git a/app/src/main/java/com/grigowashere/aismap/maps/MarkerWrapper.java b/app/src/main/java/com/grigowashere/aismap/maps/MarkerWrapper.java new file mode 100644 index 0000000..66ae39e --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/maps/MarkerWrapper.java @@ -0,0 +1,101 @@ +package com.grigowashere.aismap.maps; + +import com.grigowashere.aismap.models.Vessel; +import com.grigowashere.aismap.models.AISVessel; + +/** + * Обертка для маркера с управлением жизненным циклом + * Предотвращает финализацию объектов и обеспечивает стабильную работу + */ +public abstract class MarkerWrapper { + + protected String id; + protected boolean isActive; + protected long lastUpdateTime; + protected long creationTime; + + // Константы для управления жизненным циклом + private static final long MARKER_LIFETIME = 5000; // 5 секунд + private static final long UPDATE_THROTTLE = 200; // 0.2 секунды между обновлениями + + public MarkerWrapper(String id) { + this.id = id; + this.isActive = true; + this.creationTime = System.currentTimeMillis(); + this.lastUpdateTime = creationTime; + } + + /** + * Проверяет, нужно ли обновлять маркер + */ + public boolean shouldUpdate() { + long currentTime = System.currentTimeMillis(); + return (currentTime - lastUpdateTime) >= UPDATE_THROTTLE; + } + + /** + * Проверяет, не устарел ли маркер + */ + public boolean isExpired() { + long currentTime = System.currentTimeMillis(); + return (currentTime - creationTime) >= MARKER_LIFETIME; + } + + /** + * Обновляет время последнего обновления + */ + public void markUpdated() { + this.lastUpdateTime = System.currentTimeMillis(); + } + + /** + * Проверяет, активен ли маркер + */ + public boolean isActive() { + return isActive && !isExpired(); + } + + /** + * Деактивирует маркер + */ + public void deactivate() { + this.isActive = false; + } + + /** + * Получает ID маркера + */ + public String getId() { + return id; + } + + /** + * Абстрактный метод для проверки состояния маркера + */ + public abstract boolean isValid(); + + /** + * Абстрактный метод для обновления позиции маркера + */ + public abstract void updatePosition(double latitude, double longitude); + + /** + * Абстрактный метод для обновления курса маркера + */ + public abstract void updateCourse(double course); + + /** + * Абстрактный метод для удаления маркера + */ + public abstract void remove(); + + /** + * Абстрактный метод для обновления иконки маркера + */ + public abstract void updateIcon(); + + /** + * Абстрактный метод для установки обработчика кликов + */ + public abstract void setClickListener(Runnable clickHandler); +} 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 95e2581..be6326c 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java @@ -22,6 +22,7 @@ import java.util.Map; /** * Реализация карты для Яндекс.Карт + * Использует новый менеджер маркеров для предотвращения финализации объектов */ public class YandexMapImpl implements MapInterface { @@ -30,35 +31,22 @@ public class YandexMapImpl implements MapInterface { private MapObjectCollection mapObjects; private MarkerClickListener markerClickListener; - private Map aisMarkers; - private Map aisVessels; // Храним ссылки на AISVessel объекты - private com.yandex.mapkit.map.PlacemarkMapObject ownVesselMarker; - private Vessel ownVessel; // Храним ссылку на наше судно - - // Флаги для отслеживания состояния обработчиков - private boolean ownVesselClickListenerSet = false; - private Map aisVesselClickListenersSet = new HashMap<>(); - - // Флаги для предотвращения повторного добавления обработчиков - private Map aisVesselClickListenersAdded = new HashMap<>(); + // Новый менеджер маркеров + private YandexMarkerManager markerManager; // Слушатель поворота карты private com.yandex.mapkit.map.InputListener inputListener; private float lastMapAzimuth = 0.0f; - // Отслеживание двойного клика для AIS судов - private Map lastClickTime = new HashMap<>(); - private static final long DOUBLE_CLICK_DELAY = 300; // 300мс для двойного клика - public YandexMapImpl(Context context, MapView mapView) { this.context = context; this.mapView = mapView; - this.aisMarkers = new HashMap<>(); - this.aisVessels = new HashMap<>(); // Получение коллекции объектов карты try { this.mapObjects = mapView.getMap().getMapObjects().addCollection(); + // Инициализируем менеджер маркеров + this.markerManager = new YandexMarkerManager(context, mapObjects, mapView); } catch (Exception e) { // Ошибка создания коллекции объектов карты } @@ -68,10 +56,21 @@ public class YandexMapImpl implements MapInterface { public void initialize() { // Инициализируем слушатель поворота карты setupCameraListener(); + + // Инициализируем менеджер маркеров + if (markerManager != null) { + markerManager.initialize(); + } } + @Override public void cleanup() { + // Очищаем менеджер маркеров + if (markerManager != null) { + markerManager.cleanup(); + } + // Удаляем слушатель ввода if (inputListener != null && mapView != null) { mapView.getMap().removeInputListener(inputListener); @@ -87,168 +86,44 @@ public class YandexMapImpl implements MapInterface { @Override public void addOwnVesselMarker(Vessel vessel) { - // Сохраняем ссылку на судно - this.ownVessel = vessel; - - // Проверяем валидность координат (исключаем только невалидные значения) - if (Double.isNaN(vessel.getLatitude()) || Double.isNaN(vessel.getLongitude()) || - Double.isInfinite(vessel.getLatitude()) || Double.isInfinite(vessel.getLongitude())) { - return; - } - - if (ownVesselMarker != null) { - mapObjects.remove(ownVesselMarker); - } - - Point point = new Point(vessel.getLatitude(), vessel.getLongitude()); - ownVesselMarker = mapObjects.addPlacemark(point); - - if (ownVesselMarker == null) { - return; - } - - // Используем готовую иконку стрелки с учетом курса (синий цвет для нашего судна) - setMarkerIcon(ownVesselMarker, "target", vessel.getCourse(), android.graphics.Color.BLUE); - - // Устанавливаем размер иконки - com.yandex.mapkit.map.IconStyle iconStyle = new com.yandex.mapkit.map.IconStyle(); - iconStyle.setScale(1.0f); // Уменьшаем масштаб иконки - ownVesselMarker.setIconStyle(iconStyle); - - // Устанавливаем обработчик кликов только если он еще не установлен - if (!ownVesselClickListenerSet) { - ownVesselMarker.addTapListener((mapObject, point1) -> { - if (markerClickListener != null && ownVessel != null) { - markerClickListener.onOwnVesselClick(ownVessel); - } - return true; - }); - ownVesselClickListenerSet = true; + if (markerManager != null) { + markerManager.updateOwnVesselMarker(vessel); } } @Override public void updateOwnVesselPosition(Vessel vessel) { - // Обновляем ссылку на судно - this.ownVessel = vessel; - - // Проверяем валидность координат (исключаем только невалидные значения) - if (Double.isNaN(vessel.getLatitude()) || Double.isNaN(vessel.getLongitude()) || - Double.isInfinite(vessel.getLatitude()) || Double.isInfinite(vessel.getLongitude())) { - return; - } - - if (ownVesselMarker == null) { - // Создаем маркер нашего судна, если его еще нет - addOwnVesselMarker(vessel); - } else { - // Всегда обновляем иконку с текущим курсом - // Обновляем иконку с новым курсом (синий цвет для нашего судна) - setMarkerIcon(ownVesselMarker, "target", vessel.getCourse(), android.graphics.Color.BLUE); - - // Обновляем позицию маркера - Point newPoint = new Point(vessel.getLatitude(), vessel.getLongitude()); - ownVesselMarker.setGeometry(newPoint); - - // Обработчик клика уже установлен при создании маркера, не добавляем повторно + if (markerManager != null) { + markerManager.updateOwnVesselMarker(vessel); } } @Override public void addAISVesselMarker(AISVessel vessel) { - // Проверяем валидность координат (исключаем только невалидные значения) - if (Double.isNaN(vessel.getLatitude()) || Double.isNaN(vessel.getLongitude()) || - Double.isInfinite(vessel.getLatitude()) || Double.isInfinite(vessel.getLongitude())) { - return; + if (markerManager != null) { + markerManager.updateAISVesselMarker(vessel); } - - Point point = new Point(vessel.getLatitude(), vessel.getLongitude()); - com.yandex.mapkit.map.PlacemarkMapObject marker = mapObjects.addPlacemark(point); - - if (marker == null) { - return; - } - - // Сохраняем ссылку на судно - aisVessels.put(vessel.getMmsi(), vessel); - - // Используем готовую иконку стрелки для AIS судов с учетом курса и цвета - int vesselColor = getVesselColor(vessel); - setMarkerIcon(marker, "target", vessel.getCourse(), vesselColor, vessel.isSelected()); - - // Устанавливаем размер иконки - com.yandex.mapkit.map.IconStyle iconStyle = new com.yandex.mapkit.map.IconStyle(); - iconStyle.setScale(1.0f); // Уменьшаем масштаб иконки - marker.setIconStyle(iconStyle); - - // Установка обработчика кликов только если он еще не установлен - String mmsi = vessel.getMmsi(); - if (!aisVesselClickListenersAdded.containsKey(mmsi) || !aisVesselClickListenersAdded.get(mmsi)) { - marker.addTapListener((mapObject, point1) -> { - // Проверяем двойной клик - long currentTime = System.currentTimeMillis(); - Long lastTime = lastClickTime.get(mmsi); - - if (lastTime != null && (currentTime - lastTime) < DOUBLE_CLICK_DELAY) { - // Двойной клик - вызываем BottomSheet - if (markerClickListener != null) { - markerClickListener.onAISVesselClick(vessel); - } - lastClickTime.remove(mmsi); // Сбрасываем для следующего двойного клика - } else { - // Одиночный клик - выделяем/снимаем выделение - toggleVesselSelection(vessel); - lastClickTime.put(mmsi, currentTime); - } - - return true; - }); - aisVesselClickListenersAdded.put(mmsi, true); - } - - aisMarkers.put(vessel.getMmsi(), marker); } @Override public void updateAISVesselPosition(AISVessel vessel) { - // Обновляем ссылку на судно - aisVessels.put(vessel.getMmsi(), vessel); - - com.yandex.mapkit.map.PlacemarkMapObject marker = aisMarkers.get(vessel.getMmsi()); - if (marker != null) { - Point newPoint = new Point(vessel.getLatitude(), vessel.getLongitude()); - marker.setGeometry(newPoint); - - // Всегда обновляем курс маркера - int vesselColor = getVesselColor(vessel); - setMarkerIcon(marker, "target", vessel.getCourse(), vesselColor, vessel.isSelected()); - - // Обработчик клика уже установлен при создании маркера, не добавляем повторно + if (markerManager != null) { + markerManager.updateAISVesselMarker(vessel); } } @Override public void removeAISVesselMarker(String mmsi) { - com.yandex.mapkit.map.PlacemarkMapObject marker = aisMarkers.remove(mmsi); - if (marker != null) { - mapObjects.remove(marker); + if (markerManager != null) { + markerManager.removeAISVesselMarker(mmsi); } - // Удаляем ссылку на судно - aisVessels.remove(mmsi); - // Удаляем флаги обработчиков кликов - aisVesselClickListenersSet.remove(mmsi); - aisVesselClickListenersAdded.remove(mmsi); } @Override public void clearAISVesselMarkers() { - for (com.yandex.mapkit.map.PlacemarkMapObject marker : aisMarkers.values()) { - mapObjects.remove(marker); + if (markerManager != null) { + markerManager.clearAISVesselMarkers(); } - aisMarkers.clear(); - aisVessels.clear(); - aisVesselClickListenersSet.clear(); - aisVesselClickListenersAdded.clear(); } @Override @@ -286,159 +161,62 @@ public class YandexMapImpl implements MapInterface { public void setMarkerClickListener(MarkerClickListener listener) { this.markerClickListener = listener; - // Переустанавливаем обработчики кликов для всех существующих маркеров - updateAllMarkerClickListeners(); + // Устанавливаем обработчик в менеджере маркеров + if (markerManager != null) { + markerManager.setMarkerClickListener(listener); + } } /** * Обновляет обработчики кликов для всех существующих маркеров * Этот метод переустанавливает обработчики для всех маркеров */ - private void updateAllMarkerClickListeners() { - // Обработчик для маркера нашего судна уже установлен при создании, не добавляем повторно - - // Обработчики для AIS маркеров уже установлены при создании, не добавляем повторно - } - - /** - * Создание повернутой цветной иконки из ресурса - */ - private Bitmap createRotatedIconFromResource(int resourceId, double course, int color) { - return createRotatedIconFromResource(resourceId, course, color, false); - } - - /** - * Создание повернутой цветной иконки из ресурса с поддержкой выделения - */ - private Bitmap createRotatedIconFromResource(int resourceId, double course, int color, boolean isSelected) { - try { - // Получаем drawable из ресурса - Drawable drawable = context.getResources().getDrawable(resourceId, null); - if (drawable == null) { - return null; - } - - // Применяем цвет к drawable - if (color != 0) { - drawable.setColorFilter(color, android.graphics.PorterDuff.Mode.SRC_IN); - } - - // Получаем оригинальные размеры drawable - int originalWidth = drawable.getIntrinsicWidth(); - int originalHeight = drawable.getIntrinsicHeight(); - - // Если размеры не определены, используем стандартные - if (originalWidth <= 0) originalWidth = 32; - if (originalHeight <= 0) originalHeight = 48; - - // Масштабируем размеры (уменьшаем еще больше) - float scale = 0.3f; // Уменьшаем в 3.3 раза - int width = (int) (originalWidth * scale); - int height = (int) (originalHeight * scale); - - // Получаем азимут карты (поворот карты) - float mapAzimuth = 0.0f; - try { - CameraPosition cameraPosition = mapView.getMap().getCameraPosition(); - mapAzimuth = cameraPosition.getAzimuth(); - } catch (Exception e) { - // Не удалось получить азимут карты - } - - // Создаем bitmap минимального размера для уменьшения хитбокса - int bitmapSize = Math.max(width, height) + 8; // Добавляем только небольшой отступ - Bitmap bitmap = Bitmap.createBitmap(bitmapSize, bitmapSize, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - - // Поворачиваем маркер на курс судна с учетом поворота карты - // Курс судна - это направление относительно севера - // Азимут карты - это поворот карты относительно севера - // Итоговый поворот = курс судна - азимут карты (чтобы маркер оставался относительно севера) - float rotationAngle = (float) (course - mapAzimuth); - - // Центрируем drawable в bitmap - int centerX = bitmapSize / 2; - int centerY = bitmapSize / 2; - int left = centerX - width / 2; - int top = centerY - height / 2; - - // Устанавливаем границы для drawable - drawable.setBounds(left, top, left + width, top + height); - - // Поворачиваем canvas на курс - canvas.save(); - canvas.rotate(rotationAngle, centerX, centerY); - - // Рисуем drawable - drawable.draw(canvas); - - canvas.restore(); - - // Если судно выделено, добавляем рамку выделения - if (isSelected) { - // Получаем drawable для рамки выделения - Drawable selectionDrawable = context.getResources().getDrawable( - context.getResources().getIdentifier("chosentarget", "drawable", context.getPackageName()), null); - - if (selectionDrawable != null) { - // Масштабируем рамку выделения - int selectionSize = Math.max(width, height) + 16; // Рамка немного больше - int selectionLeft = centerX - selectionSize / 2; - int selectionTop = centerY - selectionSize / 2; - - selectionDrawable.setBounds(selectionLeft, selectionTop, - selectionLeft + selectionSize, selectionTop + selectionSize); - - // Рисуем рамку выделения - selectionDrawable.draw(canvas); - } - } - - return bitmap; - } catch (Exception e) { - return null; + public void refreshMarkerClickListeners() { + if (markerManager != null) { + markerManager.checkAndRestoreMarkers(); } } /** - * Создание иконки судна + * Перерисовывает все маркеры с учетом текущего азимута карты + * Вызывается при повороте карты */ - private Bitmap createVesselIcon(int color, double course) { - try { - int size = 64; // Увеличиваем размер для лучшей видимости - Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - - Paint paint = new Paint(); - paint.setColor(color); - paint.setStyle(Paint.Style.FILL); - paint.setAntiAlias(true); - paint.setStrokeWidth(3.0f); - - // Рисуем треугольник-стрелку, направленную вверх (по умолчанию) - 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.3f, size * 0.6f); // левая внутренняя точка - path.lineTo(size * 0.3f, size * 0.9f); // левая нижняя точка - path.lineTo(size * 0.7f, size * 0.9f); // правая нижняя точка - path.lineTo(size * 0.7f, size * 0.6f); // правая внутренняя точка - path.lineTo(size * 0.9f, size * 0.8f); // правый нижний угол - path.close(); - - // Поворачиваем стрелку на курс (курс 0° = стрелка направлена вверх) - // В морской навигации курс 0° = север, 90° = восток, 180° = юг, 270° = запад - canvas.save(); - canvas.rotate((float) course, size / 2f, size / 2f); - canvas.drawPath(path, paint); - canvas.restore(); - - return bitmap; - } catch (Exception e) { - return null; + public void refreshAllMarkers() { + if (markerManager != null) { + markerManager.refreshAllMarkers(); } } + /** + * Обновляет все маркеры при повороте карты + * Вызывается из слушателя поворота карты + */ + public void onMapRotationChanged() { + if (markerManager != null) { + markerManager.refreshAllMarkers(); + } + } + + /** + * Проверяет и восстанавливает финализированные маркеры + */ + public void checkAndRestoreMarkers() { + if (markerManager != null) { + markerManager.checkAndRestoreMarkers(); + } + } + + /** + * Получает количество активных маркеров + */ + public int getActiveMarkerCount() { + if (markerManager != null) { + return markerManager.getActiveMarkerCount(); + } + return 0; + } + + /** * Получение MapView для использования в layout */ @@ -446,79 +224,6 @@ public class YandexMapImpl implements MapInterface { return mapView; } - /** - * Принудительно пересоздает маркер нашего судна с иконкой - */ - public void recreateOwnVesselMarker(Vessel vessel) { - if (ownVesselMarker != null) { - mapObjects.remove(ownVesselMarker); - ownVesselMarker = null; - } - addOwnVesselMarker(vessel); - } - - /** - * Обновляет обработчики кликов для всех маркеров - * Вызывается после закрытия BottomSheet для восстановления функциональности - */ - public void refreshMarkerClickListeners() { - updateAllMarkerClickListeners(); - } - - /** - * Устанавливает иконку для маркера с fallback - */ - private void setMarkerIcon(com.yandex.mapkit.map.PlacemarkMapObject marker, String iconName, double course) { - setMarkerIcon(marker, iconName, course, 0); // По умолчанию без цвета - } - - /** - * Устанавливает цветную иконку для маркера с fallback - */ - private void setMarkerIcon(com.yandex.mapkit.map.PlacemarkMapObject marker, String iconName, double course, int color) { - setMarkerIcon(marker, iconName, course, color, false); - } - - /** - * Устанавливает цветную иконку для маркера с поддержкой выделения - */ - private void setMarkerIcon(com.yandex.mapkit.map.PlacemarkMapObject marker, String iconName, double course, int color, boolean isSelected) { - try { - // Сначала пробуем использовать ресурс с поворотом - int iconResId = context.getResources().getIdentifier(iconName, "drawable", context.getPackageName()); - - if (iconResId != 0) { - Bitmap rotatedBitmap = createRotatedIconFromResource(iconResId, course, color, isSelected); - if (rotatedBitmap != null) { - marker.setIcon(ImageProvider.fromBitmap(rotatedBitmap)); - return; - } else { - marker.setIcon(ImageProvider.fromResource(context, iconResId)); - return; - } - } - - // Если ресурс не найден, создаем программную иконку с учетом курса - Bitmap iconBitmap = createVesselIcon(android.graphics.Color.BLUE, course); - if (iconBitmap != null) { - marker.setIcon(ImageProvider.fromBitmap(iconBitmap)); - } else { - // Создаем простую иконку как fallback - marker.setIcon(ImageProvider.fromResource(context, android.R.drawable.ic_menu_compass)); - } - } catch (Exception e) { - // Создаем простую иконку как fallback - marker.setIcon(ImageProvider.fromResource(context, android.R.drawable.ic_menu_compass)); - } - - // После установки иконки проверяем, что обработчик клика все еще работает - // Это может помочь с проблемами, когда установка иконки нарушает обработчики - - // Обработчик клика для нашего судна уже установлен при создании маркера, не добавляем повторно - - // Обработчики кликов уже установлены при создании маркеров, не добавляем повторно - } - /** * Настройка слушателя поворота карты */ @@ -541,71 +246,29 @@ 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 = 100; // 100мс между обновлениями + + @Override + public void onCameraPositionChanged(com.yandex.mapkit.map.Map map, + com.yandex.mapkit.map.CameraPosition cameraPosition, + com.yandex.mapkit.map.CameraUpdateReason reason, + boolean finished) { + + // Обновляем маркеры в реальном времени с throttling + long currentTime = System.currentTimeMillis(); + if (currentTime - lastUpdateTime >= UPDATE_THROTTLE) { + onMapRotationChanged(); + lastUpdateTime = currentTime; + } + } + }); } catch (Exception e) { // Ошибка установки слушателя } } - /** - * Перерисовывает все маркеры с учетом текущего азимута карты - * Вызывается при повороте карты - */ - public void refreshAllMarkers() { - // Перерисовываем маркер нашего судна (синий цвет) - if (ownVesselMarker != null && ownVessel != null) { - setMarkerIcon(ownVesselMarker, "target", ownVessel.getCourse(), android.graphics.Color.BLUE); - } - - // Перерисовываем все AIS маркеры с их цветами - for (Map.Entry entry : aisMarkers.entrySet()) { - String mmsi = entry.getKey(); - com.yandex.mapkit.map.PlacemarkMapObject marker = entry.getValue(); - AISVessel vessel = aisVessels.get(mmsi); - - if (marker != null && vessel != null) { - int vesselColor = getVesselColor(vessel); - setMarkerIcon(marker, "target", vessel.getCourse(), vesselColor, vessel.isSelected()); - } - } - } - - /** - * Переключает выделение AIS судна - */ - private void toggleVesselSelection(AISVessel vessel) { - vessel.setSelected(!vessel.isSelected()); - - // Обновляем иконку маркера с учетом состояния выделения - com.yandex.mapkit.map.PlacemarkMapObject marker = aisMarkers.get(vessel.getMmsi()); - if (marker != null) { - int vesselColor = getVesselColor(vessel); - setMarkerIcon(marker, "target", vessel.getCourse(), vesselColor, vessel.isSelected()); - } - } - - /** - * Получает цвет для AIS судна в зависимости от его статуса - */ - private int getVesselColor(AISVessel vessel) { - // Можно настроить цвета в зависимости от параметров судна - // Используем navigation status из AIS данных - String navStatus = vessel.getNavigationalStatus(); - if (navStatus != null) { - switch (navStatus.toLowerCase()) { - case "under way using engine": - case "under way": - return android.graphics.Color.GREEN; - case "at anchor": - return android.graphics.Color.YELLOW; - case "moored": - return android.graphics.Color.BLUE; - case "not under command": - case "restricted manoeuvrability": - return android.graphics.Color.RED; - default: - return android.graphics.Color.WHITE; - } - } - return android.graphics.Color.WHITE; // По умолчанию белый - } } diff --git a/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerManager.java b/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerManager.java new file mode 100644 index 0000000..3315c0f --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerManager.java @@ -0,0 +1,295 @@ +package com.grigowashere.aismap.maps; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; + +import com.grigowashere.aismap.models.Vessel; +import com.grigowashere.aismap.models.AISVessel; +import com.yandex.mapkit.map.MapObjectCollection; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Менеджер маркеров для Yandex Maps + * Управляет жизненным циклом маркеров и предотвращает их финализацию + */ +public class YandexMarkerManager implements MarkerManager { + + private static final String TAG = "YandexMarkerManager"; + + private Context context; + private MapObjectCollection mapObjects; + private com.yandex.mapkit.mapview.MapView mapView; + private MapInterface.MarkerClickListener markerClickListener; + + // Кеш маркеров с управлением жизненным циклом + private Map markerCache = new ConcurrentHashMap<>(); + private YandexMarkerWrapper ownVesselMarker; + + // Периодическая очистка устаревших маркеров + private Handler cleanupHandler; + private Runnable cleanupRunnable; + private static final long CLEANUP_INTERVAL = 10000; // 10 секунд + + public YandexMarkerManager(Context context, MapObjectCollection mapObjects, com.yandex.mapkit.mapview.MapView mapView) { + this.context = context; + this.mapObjects = mapObjects; + this.mapView = mapView; + this.cleanupHandler = new Handler(Looper.getMainLooper()); + } + + @Override + public void initialize() { + startPeriodicCleanup(); + } + + @Override + public void cleanup() { + stopPeriodicCleanup(); + + // Удаляем все маркеры + for (YandexMarkerWrapper marker : markerCache.values()) { + marker.remove(); + } + markerCache.clear(); + + if (ownVesselMarker != null) { + ownVesselMarker.remove(); + ownVesselMarker = null; + } + } + + @Override + public void updateOwnVesselMarker(Vessel vessel) { + if (vessel == null) { + return; + } + + // Проверяем валидность координат + if (Double.isNaN(vessel.getLatitude()) || Double.isNaN(vessel.getLongitude()) || + Double.isInfinite(vessel.getLatitude()) || Double.isInfinite(vessel.getLongitude())) { + return; + } + + // ВСЕГДА пересоздаем маркер для предотвращения финализации + if (ownVesselMarker != null) { + ownVesselMarker.remove(); + } + + // Создаем новый маркер + ownVesselMarker = new YandexMarkerWrapper(context, mapObjects, mapView, vessel, "own_vessel"); + if (markerClickListener != null) { + ownVesselMarker.setClickListener(() -> { + if (markerClickListener != null) { + markerClickListener.onOwnVesselClick(vessel); + } + }); + } + } + + @Override + public void updateAISVesselMarker(AISVessel vessel) { + if (vessel == null || vessel.getMmsi() == null) { + return; + } + + // Проверяем валидность координат + if (Double.isNaN(vessel.getLatitude()) || Double.isNaN(vessel.getLongitude()) || + Double.isInfinite(vessel.getLatitude()) || Double.isInfinite(vessel.getLongitude())) { + return; + } + + String mmsi = vessel.getMmsi(); + YandexMarkerWrapper marker = markerCache.get(mmsi); + + // ВСЕГДА пересоздаем маркер для предотвращения финализации + if (marker != null) { + marker.remove(); + } + + // Создаем новый маркер + marker = new YandexMarkerWrapper(context, mapObjects, mapView, vessel, mmsi); + markerCache.put(mmsi, marker); + + if (markerClickListener != null) { + marker.setClickListener(() -> { + if (markerClickListener != null) { + markerClickListener.onAISVesselClick(vessel); + } + }); + } + } + + @Override + public void removeAISVesselMarker(String mmsi) { + YandexMarkerWrapper marker = markerCache.remove(mmsi); + if (marker != null) { + marker.remove(); + } + } + + @Override + public void clearAISVesselMarkers() { + for (YandexMarkerWrapper marker : markerCache.values()) { + marker.remove(); + } + markerCache.clear(); + } + + @Override + public void setMarkerClickListener(MapInterface.MarkerClickListener listener) { + this.markerClickListener = listener; + + // Устанавливаем обработчики для существующих маркеров + if (ownVesselMarker != null && ownVesselMarker.isValid()) { + ownVesselMarker.setClickListener(() -> { + if (markerClickListener != null && ownVesselMarker.getVessel() != null) { + markerClickListener.onOwnVesselClick(ownVesselMarker.getVessel()); + } + }); + } + + for (YandexMarkerWrapper marker : markerCache.values()) { + if (marker.isValid()) { + marker.setClickListener(() -> { + if (markerClickListener != null && marker.getAISVessel() != null) { + markerClickListener.onAISVesselClick(marker.getAISVessel()); + } + }); + } + } + } + + @Override + public void refreshAllMarkers() { + // При повороте карты пересоздаем все маркеры + // Это гарантирует правильную ориентацию относительно севера + + // Пересоздаем маркер нашего судна + 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(); + for (Map.Entry entry : vesselsToRecreate.entrySet()) { + updateAISVesselMarker(entry.getValue()); + } + } + + @Override + public void checkAndRestoreMarkers() { + // Проверяем маркер нашего судна + if (ownVesselMarker != null && !ownVesselMarker.isValid()) { + Vessel vessel = ownVesselMarker.getVessel(); + if (vessel != null) { + ownVesselMarker.remove(); + updateOwnVesselMarker(vessel); + } + } + + // Проверяем AIS маркеры + Set toRemove = new HashSet<>(); + for (Map.Entry entry : markerCache.entrySet()) { + YandexMarkerWrapper marker = entry.getValue(); + if (!marker.isValid()) { + AISVessel vessel = marker.getAISVessel(); + if (vessel != null) { + marker.remove(); + toRemove.add(entry.getKey()); + updateAISVesselMarker(vessel); + } else { + toRemove.add(entry.getKey()); + } + } + } + + // Удаляем невалидные маркеры + for (String mmsi : toRemove) { + markerCache.remove(mmsi); + } + } + + @Override + public int getActiveMarkerCount() { + int count = 0; + if (ownVesselMarker != null && ownVesselMarker.isValid()) { + count++; + } + for (YandexMarkerWrapper marker : markerCache.values()) { + if (marker.isValid()) { + count++; + } + } + return count; + } + + /** + * Запускает периодическую очистку устаревших маркеров + */ + private void startPeriodicCleanup() { + cleanupRunnable = new Runnable() { + @Override + public void run() { + cleanupExpiredMarkers(); + cleanupHandler.postDelayed(this, CLEANUP_INTERVAL); + } + }; + cleanupHandler.post(cleanupRunnable); + } + + /** + * Останавливает периодическую очистку + */ + private void stopPeriodicCleanup() { + if (cleanupRunnable != null) { + cleanupHandler.removeCallbacks(cleanupRunnable); + cleanupRunnable = null; + } + } + + /** + * Очищает устаревшие маркеры + */ + private void cleanupExpiredMarkers() { + // Очищаем AIS маркеры + Set toRemove = new HashSet<>(); + for (Map.Entry entry : markerCache.entrySet()) { + YandexMarkerWrapper marker = entry.getValue(); + if (marker.isExpired() || !marker.isValid()) { + marker.remove(); + toRemove.add(entry.getKey()); + } + } + + for (String mmsi : toRemove) { + markerCache.remove(mmsi); + } + + // Проверяем маркер нашего судна + if (ownVesselMarker != null && (ownVesselMarker.isExpired() || !ownVesselMarker.isValid())) { + ownVesselMarker.remove(); + ownVesselMarker = null; + } + } +} diff --git a/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerWrapper.java b/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerWrapper.java new file mode 100644 index 0000000..30bfece --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerWrapper.java @@ -0,0 +1,405 @@ +package com.grigowashere.aismap.maps; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; + +import com.grigowashere.aismap.models.Vessel; +import com.grigowashere.aismap.models.AISVessel; +import com.yandex.mapkit.geometry.Point; +import com.yandex.mapkit.map.PlacemarkMapObject; +import com.yandex.mapkit.map.MapObjectCollection; +import com.yandex.runtime.image.ImageProvider; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Обертка для маркера Yandex Maps с управлением жизненным циклом + */ +public class YandexMarkerWrapper extends MarkerWrapper { + + private Context context; + private PlacemarkMapObject marker; + private MapObjectCollection mapObjects; + private Vessel vessel; + private AISVessel aisVessel; + private boolean isOwnVessel; + private AtomicBoolean clickListenerSet = new AtomicBoolean(false); + private Runnable clickHandler; + + // Ссылка на MapView для получения азимута карты + private com.yandex.mapkit.mapview.MapView mapView; + + // Кешированные данные для предотвращения лишних обновлений + private double lastLatitude = Double.NaN; + private double lastLongitude = Double.NaN; + private double lastCourse = Double.NaN; + private int lastColor = -1; + private boolean lastSelected = false; + + // Кеш иконок для быстрого отображения + private Bitmap cachedIconBitmap; + private double cachedIconCourse = Double.NaN; + private int cachedIconColor = -1; + private boolean cachedIconSelected = false; + + public YandexMarkerWrapper(Context context, MapObjectCollection mapObjects, + com.yandex.mapkit.mapview.MapView mapView, Vessel vessel, String id) { + super(id); + this.context = context; + this.mapObjects = mapObjects; + this.mapView = mapView; + this.vessel = vessel; + this.isOwnVessel = true; + // Предварительно создаем иконку + preloadIcon(); + createMarker(); + } + + public YandexMarkerWrapper(Context context, MapObjectCollection mapObjects, + com.yandex.mapkit.mapview.MapView mapView, AISVessel vessel, String id) { + super(id); + this.context = context; + this.mapObjects = mapObjects; + this.mapView = mapView; + this.aisVessel = vessel; + this.isOwnVessel = false; + // Предварительно создаем иконку + preloadIcon(); + createMarker(); + } + + /** + * Предварительно создает иконку для быстрого отображения + */ + private void preloadIcon() { + try { + double course = isOwnVessel ? vessel.getCourse() : aisVessel.getCourse(); + int color = isOwnVessel ? android.graphics.Color.BLUE : getVesselColor(); + boolean selected = !isOwnVessel && aisVessel.isSelected(); + + cachedIconBitmap = createRotatedIcon(course, color, selected); + cachedIconCourse = course; + cachedIconColor = color; + cachedIconSelected = selected; + } catch (Exception e) { + // Ошибка предварительной загрузки иконки + cachedIconBitmap = null; + } + } + + private void createMarker() { + try { + double lat = isOwnVessel ? vessel.getLatitude() : aisVessel.getLatitude(); + double lon = isOwnVessel ? vessel.getLongitude() : aisVessel.getLongitude(); + + Point point = new Point(lat, lon); + marker = mapObjects.addPlacemark(point); + + if (marker != null) { + // Сразу устанавливаем иконку, чтобы избежать плейсхолдера + setIconImmediately(); + setupClickListener(); + } + } catch (Exception e) { + // Ошибка создания маркера + deactivate(); + } + } + + /** + * Пересоздает маркер с новыми координатами + * Этот метод больше не используется - маркеры всегда пересоздаются в менеджере + */ + private void recreateMarker(double latitude, double longitude) { + // Метод оставлен для совместимости, но не используется + } + + /** + * Устанавливает иконку немедленно без проверок + */ + private void setIconImmediately() { + try { + double course = isOwnVessel ? vessel.getCourse() : aisVessel.getCourse(); + int color = isOwnVessel ? android.graphics.Color.BLUE : getVesselColor(); + boolean selected = !isOwnVessel && aisVessel.isSelected(); + + // Проверяем кеш иконки + Bitmap iconBitmap = null; + if (Double.compare(course, cachedIconCourse) == 0 && + color == cachedIconColor && + selected == cachedIconSelected && + cachedIconBitmap != null) { + // Используем кешированную иконку + iconBitmap = cachedIconBitmap; + } else { + // Создаем новую иконку + iconBitmap = createRotatedIcon(course, color, selected); + if (iconBitmap != null) { + // Кешируем иконку + cachedIconBitmap = iconBitmap; + cachedIconCourse = course; + cachedIconColor = color; + cachedIconSelected = selected; + } + } + + if (iconBitmap != null) { + marker.setIcon(ImageProvider.fromBitmap(iconBitmap)); + } else { + // Fallback иконка если не удалось создать повернутую + marker.setIcon(ImageProvider.fromResource(context, android.R.drawable.ic_menu_compass)); + } + + // Обновляем кешированные значения + lastCourse = course; + lastColor = color; + lastSelected = selected; + } catch (Exception e) { + // Ошибка установки иконки - используем fallback + try { + marker.setIcon(ImageProvider.fromResource(context, android.R.drawable.ic_menu_compass)); + } catch (Exception ex) { + // Игнорируем ошибки fallback + } + } + } + + @Override + public boolean isValid() { + try { + if (marker == null) { + return false; + } + // Пробуем получить геометрию для проверки состояния + marker.getGeometry(); + return true; + } catch (Exception e) { + return false; + } + } + + @Override + public void updatePosition(double latitude, double longitude) { + // Этот метод больше не используется - маркеры всегда пересоздаются + // Оставляем для совместимости с интерфейсом + } + + @Override + public void updateCourse(double course) { + // Этот метод больше не используется - маркеры всегда пересоздаются + // Оставляем для совместимости с интерфейсом + } + + @Override + public void remove() { + try { + if (marker != null) { + mapObjects.remove(marker); + } + } catch (Exception e) { + // Игнорируем ошибки при удалении + } finally { + deactivate(); + } + } + + @Override + public void updateIcon() { + // Этот метод больше не используется - маркеры всегда пересоздаются + // Оставляем для совместимости с интерфейсом + } + + @Override + public void setClickListener(Runnable clickHandler) { + this.clickHandler = clickHandler; + setupClickListener(); + } + + private void setupClickListener() { + if (marker == null || clickHandler == null) { + return; + } + + // Сбрасываем флаг для возможности повторной установки + clickListenerSet.set(false); + + try { + marker.addTapListener((mapObject, point) -> { + try { + if (mapObject != null && clickHandler != null) { + clickHandler.run(); + } + return true; + } catch (Exception e) { + return false; + } + }); + clickListenerSet.set(true); + } catch (Exception e) { + // Ошибка установки обработчика кликов + clickListenerSet.set(false); + } + } + + private Bitmap createRotatedIcon(double course, int color, boolean isSelected) { + try { + // Получаем drawable из ресурса + int iconResId = context.getResources().getIdentifier("target", "drawable", context.getPackageName()); + if (iconResId == 0) { + return createSimpleIcon(color, course); + } + + Drawable drawable = context.getResources().getDrawable(iconResId, null); + if (drawable == null) { + return createSimpleIcon(color, course); + } + + // Применяем цвет + if (color != 0) { + drawable.setColorFilter(color, android.graphics.PorterDuff.Mode.SRC_IN); + } + + // Получаем размеры + int originalWidth = drawable.getIntrinsicWidth(); + int originalHeight = drawable.getIntrinsicHeight(); + + if (originalWidth <= 0) originalWidth = 32; + if (originalHeight <= 0) originalHeight = 48; + + // Масштабируем + float scale = 0.3f; + int width = (int) (originalWidth * scale); + int height = (int) (originalHeight * scale); + + // Создаем bitmap + int bitmapSize = Math.max(width, height) + 8; + Bitmap bitmap = Bitmap.createBitmap(bitmapSize, bitmapSize, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + // Получаем азимут карты (поворот карты) + float mapAzimuth = 0.0f; + try { + if (mapView != null) { + com.yandex.mapkit.map.CameraPosition cameraPosition = mapView.getMap().getCameraPosition(); + mapAzimuth = cameraPosition.getAzimuth(); + } + } catch (Exception e) { + // Не удалось получить азимут карты, используем 0 + } + + // Поворачиваем маркер на курс судна с учетом поворота карты + // Курс судна - это направление относительно севера + // Азимут карты - это поворот карты относительно севера + // Итоговый поворот = курс судна - азимут карты (чтобы маркер оставался относительно севера) + float rotationAngle = (float) (course - mapAzimuth); + + int centerX = bitmapSize / 2; + int centerY = bitmapSize / 2; + int left = centerX - width / 2; + int top = centerY - height / 2; + + drawable.setBounds(left, top, left + width, top + height); + + canvas.save(); + canvas.rotate(rotationAngle, centerX, centerY); + drawable.draw(canvas); + canvas.restore(); + + // Добавляем рамку выделения если нужно + if (isSelected) { + addSelectionFrame(canvas, centerX, centerY, Math.max(width, height)); + } + + return bitmap; + } catch (Exception e) { + return createSimpleIcon(color, course); + } + } + + private Bitmap createSimpleIcon(int color, double course) { + try { + int size = 32; + Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + Paint paint = new Paint(); + paint.setColor(color); + paint.setStyle(Paint.Style.FILL); + paint.setAntiAlias(true); + + // Рисуем треугольник + 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.close(); + + canvas.save(); + canvas.rotate((float) course, size / 2f, size / 2f); + canvas.drawPath(path, paint); + canvas.restore(); + + return bitmap; + } catch (Exception e) { + return null; + } + } + + private void addSelectionFrame(Canvas canvas, int centerX, int centerY, int size) { + try { + 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); + } catch (Exception e) { + // Игнорируем ошибки рамки выделения + } + } + + private int getVesselColor() { + if (aisVessel == null) return android.graphics.Color.WHITE; + + String navStatus = aisVessel.getNavigationalStatus(); + if (navStatus != null) { + switch (navStatus.toLowerCase()) { + case "under way using engine": + case "under way": + return android.graphics.Color.GREEN; + case "at anchor": + return android.graphics.Color.YELLOW; + case "moored": + return android.graphics.Color.BLUE; + case "not under command": + case "restricted manoeuvrability": + return android.graphics.Color.RED; + default: + return android.graphics.Color.WHITE; + } + } + return android.graphics.Color.WHITE; + } + + public Vessel getVessel() { + return vessel; + } + + public AISVessel getAISVessel() { + return aisVessel; + } + + public boolean isOwnVessel() { + return isOwnVessel; + } +} From 8d63f9d719ea626b1f899d640b58e898d6ea01fb Mon Sep 17 00:00:00 2001 From: grigo Date: Mon, 8 Sep 2025 16:43:53 +0300 Subject: [PATCH 5/9] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BF=D0=B0=D1=80=D1=81=D0=B5=D1=80=20=D0=90?= =?UTF-8?q?=D0=98=D0=A1.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=20=D0=BB=D0=BE=D0=B3=D0=B3=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/grigowashere/aismap/MainActivity.java | 4 + .../aismap/controllers/NMEAParser.java | 274 ++++++++++++++++-- .../aismap/maps/YandexMapImpl.java | 28 +- .../aismap/maps/YandexMarkerManager.java | 33 +++ .../aismap/maps/YandexMarkerWrapper.java | 29 +- .../grigowashere/aismap/utils/LogSender.java | 177 +++++++++++ 6 files changed, 513 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/com/grigowashere/aismap/utils/LogSender.java diff --git a/app/src/main/java/com/grigowashere/aismap/MainActivity.java b/app/src/main/java/com/grigowashere/aismap/MainActivity.java index b08a594..78b1354 100644 --- a/app/src/main/java/com/grigowashere/aismap/MainActivity.java +++ b/app/src/main/java/com/grigowashere/aismap/MainActivity.java @@ -30,6 +30,7 @@ import com.grigowashere.aismap.view.CompassView; 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.yandex.mapkit.mapview.MapView; import java.util.List; import java.util.ArrayList; @@ -631,6 +632,9 @@ public class MainActivity extends AppCompatActivity { if (mapController != null) { mapController.cleanup(); } + + // Останавливаем LogSender + LogSender.shutdown(); } @Override 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 10521d0..7bf49bb 100644 --- a/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java +++ b/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java @@ -3,6 +3,7 @@ package com.grigowashere.aismap.controllers; import android.util.Log; import com.grigowashere.aismap.models.Vessel; import com.grigowashere.aismap.models.AISVessel; +import com.grigowashere.aismap.utils.LogSender; import java.util.List; import java.util.ArrayList; @@ -87,6 +88,9 @@ public class NMEAParser { } Log.d(TAG, "Парсим NMEA: " + cleanedSentence); + // Отправляем NMEA сообщение на внешний ресурс + LogSender.logNMEA(cleanedSentence); + try { // Разбираем сообщение по запятым String[] fields = cleanedSentence.split(","); @@ -657,6 +661,7 @@ public class NMEAParser { // Разбираем 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; @@ -676,8 +681,20 @@ public class NMEAParser { // Поле 5: payload (данные) String payload = getField(fields, 5); - // Поле 6: количество бит заполнения - int fillBits = parseIntField(fields, 6, 0); + // Поле 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]; @@ -883,7 +900,18 @@ public class NMEAParser { ", payloadLength=" + payload.length() + ", binaryLength=" + fullBinary.length() ); - return fullBinary.substring(startBit, Math.min(startBit + length, 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; + } } } @@ -962,6 +990,11 @@ public class NMEAParser { vessel.setNavigationalStatus(getNavigationStatus(status)); vessel.setLastUpdate(java.time.LocalDateTime.now()); + // Отправляем информацию о корабле на внешний ресурс + 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); + // Уведомляем слушателя if (listener != null) { listener.onAISVesselUpdated(vessel); @@ -978,6 +1011,7 @@ public class NMEAParser { 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); @@ -1040,13 +1074,103 @@ public class NMEAParser { int eta = Integer.parseInt(etaBits, 2); Log.d(TAG, "ETA bits: " + etaBits + " = " + eta); - // Destination (120 бит) - бит 314 - String destBits = decodeAISField(payload, 314, 120); - String destination = decodeAISString(destBits); - Log.d(TAG, "Destination bits: " + destBits + " = '" + destination + "'"); + // Парсим 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); - Log.d(TAG, String.format("AIS Static: MMSI=%d, IMO=%d, name='%s', callSign='%s', type=%d, L=%.1f, W=%.1f, D=%.1f, ETA=%d, dest='%s'", - mmsi, imo, vesselName, callSign, vesselTypeCode, length, width, draft, eta, destination)); + // Вычисляем доступную длину для оставшихся полей + int totalBits = payload.length() * 6; + int remainingBits = totalBits - 314; // Остается после ETA + Log.d(TAG, "Remaining bits after ETA: " + remainingBits + " (total: " + totalBits + ")"); + + String destination = ""; + double maxDraught = 0.0; + String epfdDescription = "Unknown"; + boolean dteReady = false; + + // Для коротких сообщений (426 бит) используем упрощенную структуру + if (totalBits <= 426) { + // В коротких сообщениях может не быть всех полей + // Пробуем разные позиции для Destination + int[] possibleDestStarts = {302, 314, 320, 328}; + for (int destStartBit : possibleDestStarts) { + if (destStartBit + 120 <= totalBits) { + String destBits = decodeAISField(payload, destStartBit, 120); + String testDest = decodeAISString(destBits); + Log.d(TAG, "Пробуем Destination с бита " + destStartBit + ": " + testDest); + if (testDest.contains("DEFAULT") || testDest.contains("FAULT")) { + destination = testDest; + Log.d(TAG, "Найден Destination с бита " + destStartBit + ": '" + destination + "'"); + break; + } + } + } + + // Если не нашли, используем стандартную позицию + if (destination.isEmpty() && remainingBits > 0) { + int destStartBit = 314; + int destLength = Math.min(remainingBits, 120); + String destBits = decodeAISField(payload, destStartBit, destLength); + destination = decodeAISString(destBits); + Log.d(TAG, "Destination bits (fallback): " + destBits + " = '" + destination + "' (length: " + destLength + ")"); + } + } else { + // Для полных сообщений используем стандартную структуру + if (remainingBits >= 8) { + // Maximum present static draught (8 бит) - бит 314 + String draughtBits = decodeAISField(payload, 314, 8); + int draughtValue = Integer.parseInt(draughtBits, 2); + maxDraught = (draughtValue == 0) ? 0.0 : (draughtValue == 255) ? 25.5 : draughtValue / 10.0; + Log.d(TAG, "Max Draught bits: " + draughtBits + " = " + maxDraught + "m"); + remainingBits -= 8; + } + + if (remainingBits >= 4) { + // Type of electronic position fixing device (4 бита) + int epfdStartBit = 314 + 8; + String epfdBits = decodeAISField(payload, epfdStartBit, 4); + int epfdType = Integer.parseInt(epfdBits, 2); + epfdDescription = getEPFDType(epfdType); + Log.d(TAG, "EPFD Type bits: " + epfdBits + " = " + epfdType + " (" + epfdDescription + ")"); + remainingBits -= 4; + } + + if (remainingBits >= 1) { + // DTE (1 бит) + int dteStartBit = 314 + 8 + 4; + String dteBits = decodeAISField(payload, dteStartBit, 1); + dteReady = Integer.parseInt(dteBits, 2) == 0; + Log.d(TAG, "DTE bits: " + dteBits + " = " + dteReady + " (ready: " + dteReady + ")"); + remainingBits -= 1; + } + + if (remainingBits >= 1) { + // Spare (1 бит) + int spareStartBit = 314 + 8 + 4 + 1; + String spareBits = decodeAISField(payload, spareStartBit, 1); + int spare = Integer.parseInt(spareBits, 2); + Log.d(TAG, "Spare bits: " + spareBits + " = " + spare); + remainingBits -= 1; + } + + if (remainingBits > 0) { + // Destination (оставшиеся биты) + int destStartBit = 314 + 8 + 4 + 1 + 1; + int destLength = Math.min(remainingBits, 120); // Максимум 120 бит + String destBits = decodeAISField(payload, destStartBit, destLength); + destination = decodeAISString(destBits); + Log.d(TAG, "Destination bits (full): " + destBits + " = '" + destination + "' (length: " + destLength + ")"); + } else { + Log.w(TAG, "Destination поле недоступно - недостаточно битов"); + } + } + + 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)); @@ -1058,8 +1182,14 @@ public class NMEAParser { 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); @@ -1070,6 +1200,51 @@ public class NMEAParser { } } + /** + * Парсит 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 координаты */ @@ -1103,38 +1278,46 @@ public class NMEAParser { private String decodeAISString(String bits) { StringBuilder result = new StringBuilder(); Log.d(TAG, "Декодируем AIS строку из битов: " + bits + " (длина: " + bits.length() + ")"); - + for (int i = 0; i < bits.length(); i += 6) { if (i + 6 <= bits.length()) { String charBits = bits.substring(i, i + 6); int value = Integer.parseInt(charBits, 2); - - if (value == 0) { - Log.d(TAG, "Найден конец строки (0)"); - break; // Конец строки - } - + char decodedChar; - if (value >= 1 && value <= 26) { + Log.d(TAG, "Обрабатываем значение: " + value + " (биты: " + charBits + ")"); + + // Приоритет специальных случаев (пробелы) + if (value == 32 || value == 63) { + decodedChar = ' '; // Пробел + Log.d(TAG, "Найден пробел (" + value + ")"); + } else if (value >= 1 && value <= 26) { + // Заглавные буквы A-Z decodedChar = (char)('A' + value - 1); - } else if (value >= 27 && value <= 52) { - decodedChar = (char)('a' + value - 27); - } else if (value >= 53 && value <= 62) { - decodedChar = (char)('0' + value - 53); - } else if (value == 63) { - decodedChar = ' '; + Log.d(TAG, "Диапазон A-Z: " + value + " -> " + decodedChar); + } else if (value >= 49 && value <= 58) { + // Цифры 1-9 (кастомное сопоставление на основе AIS1) + decodedChar = (char)('1' + (value - 49)); + Log.d(TAG, "Диапазон 1-9: " + value + " -> " + decodedChar); + } else if (value == 59) { + // Цифра 0 (кастомное сопоставление) + decodedChar = '0'; + Log.d(TAG, "Декодирован символ (59) -> '0'"); } else if (value == 0) { - decodedChar = '@'; // Специальный символ + // Нулевое значение - конец строки, но не останавливаемся сразу + decodedChar = ' '; // Заменяем на пробел для продолжения + Log.d(TAG, "Найден ноль, заменяем на пробел"); } else { - decodedChar = '?'; // Неизвестный символ - Log.w(TAG, "Неизвестное значение AIS символа: " + value); + // Неизвестный или зарезервированный символ + decodedChar = '?'; + Log.w(TAG, "Неизвестное или зарезервированное значение AIS символа: " + value + " (биты: " + charBits + ")"); } - + result.append(decodedChar); Log.d(TAG, "Декодирован символ: " + charBits + " (" + value + ") -> '" + decodedChar + "'"); } } - + String resultStr = result.toString().trim(); Log.d(TAG, "Результат декодирования строки: '" + resultStr + "'"); return resultStr; @@ -1165,6 +1348,31 @@ public class NMEAParser { } } + /** + * Получает описание типа электронного устройства позиционирования + */ + 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"; + } + } + /** * Получает тип судна по коду */ @@ -1559,6 +1767,11 @@ public class NMEAParser { vessel.setLastUpdate(java.time.LocalDateTime.now()); vessel.setVesselClass("Class B"); + // Отправляем информацию о корабле на внешний ресурс + 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); @@ -1667,6 +1880,11 @@ public class NMEAParser { 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, D=%.1f", + vesselName, latitude, longitude, course, speed, getVesselType(vesselTypeCode), length, width, draft); + LogSender.logShipUpdate(String.valueOf(mmsi), vesselInfo); + // Уведомляем слушателя if (listener != null) { listener.onAISVesselUpdated(vessel); 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 be6326c..a7e4736 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/YandexMapImpl.java @@ -197,6 +197,16 @@ public class YandexMapImpl implements MapInterface { } } + /** + * Принудительно обновляет все маркеры + * Можно вызывать извне для обновления маркеров + */ + public void forceRefreshMarkers() { + if (markerManager != null) { + markerManager.refreshAllMarkers(); + } + } + /** * Проверяет и восстанавливает финализированные маркеры */ @@ -250,7 +260,7 @@ public class YandexMapImpl implements MapInterface { // Добавляем слушатель изменений камеры для обновления маркеров при повороте mapView.getMap().addCameraListener(new com.yandex.mapkit.map.CameraListener() { private long lastUpdateTime = 0; - private static final long UPDATE_THROTTLE = 100; // 100мс между обновлениями + private static final long UPDATE_THROTTLE = 50; // 50мс между обновлениями @Override public void onCameraPositionChanged(com.yandex.mapkit.map.Map map, @@ -266,6 +276,22 @@ public class YandexMapImpl implements MapInterface { } } }); + + // Добавляем дополнительный слушатель для жестов поворота + mapView.getMap().addInputListener(new com.yandex.mapkit.map.InputListener() { + private long lastGestureTime = 0; + private static final long GESTURE_THROTTLE = 100; // 100мс между обновлениями + + @Override + public void onMapTap(com.yandex.mapkit.map.Map map, com.yandex.mapkit.geometry.Point point) { + // Не обрабатываем клики по карте + } + + @Override + public void onMapLongTap(com.yandex.mapkit.map.Map map, com.yandex.mapkit.geometry.Point point) { + // Не обрабатываем долгие клики по карте + } + }); } catch (Exception e) { // Ошибка установки слушателя } 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 3315c0f..49ccca7 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerManager.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerManager.java @@ -36,21 +36,29 @@ public class YandexMarkerManager implements MarkerManager { private Runnable cleanupRunnable; private static final long CLEANUP_INTERVAL = 10000; // 10 секунд + // Периодическое обновление маркеров для предотвращения финализации + private Handler refreshHandler; + private Runnable refreshRunnable; + private static final long REFRESH_INTERVAL = 2000; // 2 секунды + public YandexMarkerManager(Context context, MapObjectCollection mapObjects, com.yandex.mapkit.mapview.MapView mapView) { this.context = context; this.mapObjects = mapObjects; this.mapView = mapView; this.cleanupHandler = new Handler(Looper.getMainLooper()); + this.refreshHandler = new Handler(Looper.getMainLooper()); } @Override public void initialize() { startPeriodicCleanup(); + startPeriodicRefresh(); } @Override public void cleanup() { stopPeriodicCleanup(); + stopPeriodicRefresh(); // Удаляем все маркеры for (YandexMarkerWrapper marker : markerCache.values()) { @@ -268,6 +276,31 @@ public class YandexMarkerManager implements MarkerManager { } } + /** + * Запускает периодическое обновление маркеров + */ + private void startPeriodicRefresh() { + refreshRunnable = new Runnable() { + @Override + public void run() { + refreshAllMarkers(); + // Планируем следующее обновление + refreshHandler.postDelayed(this, REFRESH_INTERVAL); + } + }; + refreshHandler.post(refreshRunnable); + } + + /** + * Останавливает периодическое обновление + */ + private void stopPeriodicRefresh() { + if (refreshRunnable != null) { + refreshHandler.removeCallbacks(refreshRunnable); + refreshRunnable = null; + } + } + /** * Очищает устаревшие маркеры */ 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 30bfece..9fde5ff 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerWrapper.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerWrapper.java @@ -92,15 +92,23 @@ public class YandexMarkerWrapper extends MarkerWrapper { private void createMarker() { try { - double lat = isOwnVessel ? vessel.getLatitude() : aisVessel.getLatitude(); + double lat = isOwnVessel ? vessel.getLatitude() : aisVessel.getLongitude(); double lon = isOwnVessel ? vessel.getLongitude() : aisVessel.getLongitude(); + // Сначала создаем иконку + Bitmap iconBitmap = createIconBitmap(); + Point point = new Point(lat, lon); marker = mapObjects.addPlacemark(point); if (marker != null) { - // Сразу устанавливаем иконку, чтобы избежать плейсхолдера - setIconImmediately(); + // Сразу устанавливаем готовую иконку + if (iconBitmap != null) { + marker.setIcon(ImageProvider.fromBitmap(iconBitmap)); + } else { + // Fallback иконка + marker.setIcon(ImageProvider.fromResource(context, android.R.drawable.ic_menu_compass)); + } setupClickListener(); } } catch (Exception e) { @@ -109,6 +117,21 @@ public class YandexMarkerWrapper extends MarkerWrapper { } } + /** + * Создает иконку маркера заранее + */ + private Bitmap createIconBitmap() { + try { + double course = isOwnVessel ? vessel.getCourse() : aisVessel.getCourse(); + int color = isOwnVessel ? android.graphics.Color.BLUE : getVesselColor(); + boolean selected = !isOwnVessel && aisVessel.isSelected(); + + return createRotatedIcon(course, color, selected); + } catch (Exception e) { + return null; + } + } + /** * Пересоздает маркер с новыми координатами * Этот метод больше не используется - маркеры всегда пересоздаются в менеджере diff --git a/app/src/main/java/com/grigowashere/aismap/utils/LogSender.java b/app/src/main/java/com/grigowashere/aismap/utils/LogSender.java new file mode 100644 index 0000000..bdab5f7 --- /dev/null +++ b/app/src/main/java/com/grigowashere/aismap/utils/LogSender.java @@ -0,0 +1,177 @@ +package com.grigowashere.aismap.utils; + +import android.util.Log; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Утилита для отправки логов на внешний ресурс + * Отправляет GET запросы на https://ais.grigowashere.ru/add + */ +public class LogSender { + + private static final String TAG = "LogSender"; + private static final String BASE_URL = "https://ais.grigowashere.ru/add"; + private static final ExecutorService executor = Executors.newSingleThreadExecutor(); + + /** + * Отправляет лог NMEA сообщения + * @param nmeaMessage NMEA сообщение + */ + public static void logNMEA(String nmeaMessage) { + if (nmeaMessage == null || nmeaMessage.trim().isEmpty()) { + return; + } + + executor.execute(() -> { + try { + String encodedMessage = encodeForURL(nmeaMessage); + String url = BASE_URL + "?nmea=" + encodedMessage + "&color=blue"; + + sendGetRequest(url); + Log.d(TAG, "NMEA лог отправлен: " + nmeaMessage); + } catch (Exception e) { + Log.e(TAG, "Ошибка отправки NMEA лога: " + e.getMessage(), e); + } + }); + } + + /** + * Отправляет лог обновления информации о корабле + * @param mmsi MMSI корабля + * @param vesselInfo Информация о корабле + */ + public static void logShipUpdate(String mmsi, String vesselInfo) { + if (mmsi == null || mmsi.trim().isEmpty()) { + return; + } + + executor.execute(() -> { + try { + String message = "MMSI: " + mmsi; + if (vesselInfo != null && !vesselInfo.trim().isEmpty()) { + message += " | " + vesselInfo; + } + + String encodedMessage = encodeForURL(message); + String url = BASE_URL + "?ships=" + encodedMessage + "&color=green"; + + sendGetRequest(url); + Log.d(TAG, "Ship update лог отправлен: " + message); + } catch (Exception e) { + Log.e(TAG, "Ошибка отправки ship update лога: " + e.getMessage(), e); + } + }); + } + + /** + * Отправляет произвольный лог + * @param logName Имя лога + * @param message Сообщение + * @param color Цвет (опционально) + */ + public static void logCustom(String logName, String message, String color) { + if (logName == null || logName.trim().isEmpty() || message == null || message.trim().isEmpty()) { + return; + } + + executor.execute(() -> { + try { + String encodedMessage = encodeForURL(message); + String url = BASE_URL + "?" + logName + "=" + encodedMessage; + + if (color != null && !color.trim().isEmpty()) { + url += "&color=" + color; + } + + sendGetRequest(url); + Log.d(TAG, "Custom лог отправлен: " + logName + " = " + message); + } catch (Exception e) { + Log.e(TAG, "Ошибка отправки custom лога: " + e.getMessage(), e); + } + }); + } + + /** + * Кодирует строку для безопасного использования в URL + * Дополнительно экранирует символы, которые могут вызывать проблемы + * @param message Исходное сообщение + * @return Закодированное сообщение + */ + private static String encodeForURL(String message) { + try { + // Сначала используем стандартное URL кодирование + String encoded = URLEncoder.encode(message, StandardCharsets.UTF_8.toString()); + + // Дополнительно экранируем символы, которые могут вызывать проблемы + // Заменяем < на %3C, > на %3E, & на %26, " на %22, ' на %27 + encoded = encoded.replace("<", "%3C") + .replace(">", "%3E") + .replace("&", "%26") + .replace("\"", "%22") + .replace("'", "%27"); + + // Логируем для отладки + Log.d(TAG, "Исходное сообщение: " + message); + Log.d(TAG, "Закодированное сообщение: " + encoded); + + return encoded; + } catch (Exception e) { + Log.e(TAG, "Ошибка кодирования URL: " + e.getMessage(), e); + // В случае ошибки возвращаем базовое кодирование + String fallback = message.replace("<", "%3C") + .replace(">", "%3E") + .replace("&", "%26") + .replace("\"", "%22") + .replace("'", "%27") + .replace(" ", "%20"); + Log.d(TAG, "Fallback кодирование: " + fallback); + return fallback; + } + } + + /** + * Отправляет GET запрос + * @param urlString URL для запроса + */ + private static void sendGetRequest(String urlString) { + HttpURLConnection connection = null; + try { + // Логируем полный URL для отладки + Log.d(TAG, "Отправляем GET запрос на: " + urlString); + + @SuppressWarnings("deprecation") + URL url = new URL(urlString); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(5000); // 5 секунд + connection.setReadTimeout(5000); // 5 секунд + connection.setRequestProperty("User-Agent", "AISMap/1.0"); + + int responseCode = connection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + Log.d(TAG, "Лог успешно отправлен, код ответа: " + responseCode); + } else { + Log.w(TAG, "Лог отправлен с предупреждением, код ответа: " + responseCode); + } + } catch (IOException e) { + Log.e(TAG, "Ошибка HTTP запроса: " + e.getMessage(), e); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + /** + * Останавливает executor (вызывать при завершении приложения) + */ + public static void shutdown() { + executor.shutdown(); + } +} From bdc0aa3ccfc460b4128011f41004031085203acb Mon Sep 17 00:00:00 2001 From: grigo Date: Tue, 9 Sep 2025 14:24:12 +0300 Subject: [PATCH 6/9] fixed NMEAParser --- .../aismap/controllers/NMEAParser.java | 371 ++++++++++++++---- .../aismap/maps/YandexMarkerWrapper.java | 2 +- .../grigowashere/aismap/utils/LogSender.java | 208 +++++++++- 3 files changed, 508 insertions(+), 73 deletions(-) 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 7bf49bb..8d6a2a2 100644 --- a/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java +++ b/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java @@ -11,6 +11,11 @@ import java.util.ArrayList; /** * Контроллер для парсинга NMEA сообщений * Работает в гибридном режиме: координаты через Location API, остальное через NMEA + * + * ВАЖНО: Размеры судна в AIS сообщениях рассчитываются относительно положения антенны: + * - Длина = Dim.A + Dim.B (от носа до антенны + от антенны до кормы) + * - Ширина = Dim.C + Dim.D (от левого борта до антенны + от антенны до правого борта) + * Координаты в AIS указывают положение антенны, а не центра судна. */ /** @@ -747,35 +752,43 @@ public class NMEAParser { String messageTypeBits = decodeAISField(payload, 0, 6); int messageType = Integer.parseInt(messageTypeBits, 2); - Log.d(TAG, "Декодируем AIS тип " + messageType + " на канале " + channel); + 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: @@ -892,7 +905,12 @@ public class NMEAParser { // Вырезаем нужный диапазон битов if (startBit + length <= fullBinary.length()) { - return fullBinary.substring(startBit, startBit + 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 + @@ -1043,11 +1061,11 @@ public class NMEAParser { int vesselTypeCode = Integer.parseInt(typeBits, 2); Log.d(TAG, "Type bits: " + typeBits + " = " + vesselTypeCode); - // Dimension Reference (4 бита) - бит 240 - String dimRefABits = decodeAISField(payload, 240, 4); - String dimRefBBits = decodeAISField(payload, 244, 4); - String dimRefCBits = decodeAISField(payload, 248, 4); - String dimRefDBits = decodeAISField(payload, 252, 4); + // 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); @@ -1056,18 +1074,25 @@ public class NMEAParser { Log.d(TAG, "Dimension Reference: A=" + dimRefA + ", B=" + dimRefB + ", C=" + dimRefC + ", D=" + dimRefD); - // Vessel Dimensions (30 бит) - бит 256 - String lengthBits = decodeAISField(payload, 256, 10); - String widthBits = decodeAISField(payload, 266, 10); - String draftBits = decodeAISField(payload, 276, 8); + // Для сообщения типа 5 используем Dimension Reference поля (9, 9, 6, 6 бит) + // Размеры судна рассчитываются как: + // Длина = Dim.A + Dim.B (от носа до антенны + от антенны до кормы) + // Ширина = Dim.C + Dim.D (от левого борта до антенны + от антенны до правого борта) + double length = dimRefA + dimRefB; + double width = dimRefC + dimRefD; - double length = Integer.parseInt(lengthBits, 2); - double width = Integer.parseInt(widthBits, 2); + // Draft (8 бит) - осадка - бит 296 + String draftBits = decodeAISField(payload, 296, 8); double draft = Integer.parseInt(draftBits, 2) / 10.0; - Log.d(TAG, "Dimensions - Length bits: " + lengthBits + " = " + length); - Log.d(TAG, "Dimensions - Width bits: " + widthBits + " = " + width); - Log.d(TAG, "Dimensions - Draft bits: " + draftBits + " = " + draft); + 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 бит) - бит 294 String etaBits = decodeAISField(payload, 294, 20); @@ -1374,31 +1399,112 @@ public class NMEAParser { } /** - * Получает тип судна по коду + * Получает тип судна по коду согласно стандарту AIS */ private String getVesselType(int typeCode) { - if (typeCode >= 20 && typeCode <= 29) return "Wing in ground"; - if (typeCode >= 30 && typeCode <= 39) return "Fishing"; - if (typeCode >= 40 && typeCode <= 49) return "Towing"; - if (typeCode >= 50 && typeCode <= 59) return "Dredging"; - if (typeCode >= 60 && typeCode <= 69) return "Diving"; - if (typeCode >= 70 && typeCode <= 79) return "Military"; - if (typeCode >= 80 && typeCode <= 89) return "Pleasure"; - if (typeCode >= 90 && typeCode <= 99) return "High speed"; - if (typeCode >= 100 && typeCode <= 109) return "Pilot vessel"; - if (typeCode >= 110 && typeCode <= 119) return "SAR"; - if (typeCode >= 120 && typeCode <= 129) return "Tug"; - if (typeCode >= 130 && typeCode <= 139) return "Port tender"; - if (typeCode >= 140 && typeCode <= 149) return "Anti-pollution"; - if (typeCode >= 150 && typeCode <= 159) return "Law enforce"; - if (typeCode >= 160 && typeCode <= 169) return "Spare"; - if (typeCode >= 170 && typeCode <= 179) return "Medical"; - if (typeCode >= 180 && typeCode <= 189) return "Special craft"; - if (typeCode >= 190 && typeCode <= 199) return "Passenger"; - if (typeCode >= 200 && typeCode <= 209) return "Cargo"; - if (typeCode >= 210 && typeCode <= 219) return "Tanker"; - if (typeCode >= 220 && typeCode <= 229) return "Other"; - return "Unknown"; + 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"; + } } /** @@ -1767,6 +1873,9 @@ public class NMEAParser { 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"); @@ -1789,6 +1898,15 @@ public class NMEAParser { 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); @@ -1855,17 +1973,86 @@ public class NMEAParser { int dimRefC = Integer.parseInt(dimRefCBits, 2); int dimRefD = Integer.parseInt(dimRefDBits, 2); - // Vessel Dimensions (30 бит) - бит 287 - String lengthBits = decodeAISField(payload, 287, 10); - String widthBits = decodeAISField(payload, 297, 10); - String draftBits = decodeAISField(payload, 307, 8); + Log.d(TAG, "Dimension Reference: A=" + dimRefA + ", B=" + dimRefB + ", C=" + dimRefC + ", D=" + dimRefD); - double length = Integer.parseInt(lengthBits, 2); - double width = Integer.parseInt(widthBits, 2); - double draft = Integer.parseInt(draftBits, 2) / 10.0; + // 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; + } - 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, D=%.1f", - mmsi, vesselName, latitude, longitude, course, speed, vesselTypeCode, length, width, draft)); + // 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)); @@ -1876,13 +2063,12 @@ public class NMEAParser { vessel.setVesselType(getVesselType(vesselTypeCode)); vessel.setLength(length); vessel.setWidth(width); - vessel.setDraft(draft); 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, D=%.1f", - vesselName, latitude, longitude, course, speed, getVesselType(vesselTypeCode), length, width, draft); + 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); // Уведомляем слушателя @@ -1944,12 +2130,27 @@ public class NMEAParser { int dimRefD = Integer.parseInt(dimRefDBits, 2); // Vessel Dimensions (30 бит) - бит 235 - String lengthBits = decodeAISField(payload, 235, 10); - String widthBits = decodeAISField(payload, 245, 10); - String draftBits = decodeAISField(payload, 255, 8); + // 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); - double length = Integer.parseInt(lengthBits, 2); - double width = Integer.parseInt(widthBits, 2); + 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", @@ -2027,25 +2228,59 @@ public class NMEAParser { String callSign = decodeAISString(callSignBits); Log.d(TAG, "Call Sign bits: " + callSignBits + " = '" + callSign + "'"); - // Dimension Reference (4 бита) - бит 132 - String dimRefABits = decodeAISField(payload, 132, 4); - String dimRefBBits = decodeAISField(payload, 136, 4); - String dimRefCBits = decodeAISField(payload, 140, 4); - String dimRefDBits = decodeAISField(payload, 144, 4); + // 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); - // Vessel Dimensions (30 бит) - бит 148 - String lengthBits = decodeAISField(payload, 148, 10); - String widthBits = decodeAISField(payload, 158, 10); - String draftBits = decodeAISField(payload, 168, 8); + 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); - double length = Integer.parseInt(lengthBits, 2); - double width = Integer.parseInt(widthBits, 2); - double draft = Integer.parseInt(draftBits, 2) / 10.0; + // Проверяем, есть ли достаточно битов для размеров + 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)); 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 9fde5ff..f4ec683 100644 --- a/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerWrapper.java +++ b/app/src/main/java/com/grigowashere/aismap/maps/YandexMarkerWrapper.java @@ -92,7 +92,7 @@ public class YandexMarkerWrapper extends MarkerWrapper { private void createMarker() { try { - double lat = isOwnVessel ? vessel.getLatitude() : aisVessel.getLongitude(); + double lat = isOwnVessel ? vessel.getLatitude() : aisVessel.getLatitude(); double lon = isOwnVessel ? vessel.getLongitude() : aisVessel.getLongitude(); // Сначала создаем иконку diff --git a/app/src/main/java/com/grigowashere/aismap/utils/LogSender.java b/app/src/main/java/com/grigowashere/aismap/utils/LogSender.java index bdab5f7..4ae1d40 100644 --- a/app/src/main/java/com/grigowashere/aismap/utils/LogSender.java +++ b/app/src/main/java/com/grigowashere/aismap/utils/LogSender.java @@ -58,11 +58,55 @@ public class LogSender { message += " | " + vesselInfo; } + // Извлекаем тип судна из vesselInfo и генерируем цвет + // Генерируем уникальный цвет для корабля на основе MMSI + String vesselColor = generateVesselColor(mmsi); + String encodedMessage = encodeForURL(message); - String url = BASE_URL + "?ships=" + encodedMessage + "&color=green"; + String encodedColor = encodeColorForURL(vesselColor); + String url = BASE_URL + "?ships=" + encodedMessage + "&color=" + encodedColor; sendGetRequest(url); - Log.d(TAG, "Ship update лог отправлен: " + message); + Log.d(TAG, "Ship update лог отправлен: " + message + " ( " + ", цвет: " + vesselColor + ")"); + } catch (Exception e) { + Log.e(TAG, "Ошибка отправки ship update лога: " + e.getMessage(), e); + } + }); + } + + /** + * Отправляет лог обновления информации о корабле с заданным цветом + * @param mmsi MMSI корабля + * @param vesselInfo Информация о корабле + * @param color Цвет в формате HEX (#RRGGBB) или имя цвета + */ + public static void logShipUpdate(String mmsi, String vesselInfo, String color) { + if (mmsi == null || mmsi.trim().isEmpty()) { + return; + } + + executor.execute(() -> { + try { + String message = "MMSI: " + mmsi; + if (vesselInfo != null && !vesselInfo.trim().isEmpty()) { + message += " | " + vesselInfo; + } + + // Используем переданный цвет или генерируем на основе типа судна + String vesselColor; + if (color != null && !color.trim().isEmpty()) { + vesselColor = color; + } else { + // Генерируем уникальный цвет для корабля на основе MMSI + vesselColor = generateVesselColor(mmsi); + } + + String encodedMessage = encodeForURL(message); + String encodedColor = encodeColorForURL(vesselColor); + String url = BASE_URL + "?ships=" + encodedMessage + "&color=" + encodedColor; + + sendGetRequest(url); + Log.d(TAG, "Ship update лог отправлен: " + message + " (цвет: " + vesselColor + ")"); } catch (Exception e) { Log.e(TAG, "Ошибка отправки ship update лога: " + e.getMessage(), e); } @@ -97,6 +141,160 @@ public class LogSender { }); } + + + + /** + * Генерирует уникальный цвет для корабля на основе MMSI (устаревший метод) + * @param mmsi MMSI корабля + * @return HEX цвет в формате #RRGGBB + */ + private static String generateVesselColor(String mmsi) { + try { + // Преобразуем MMSI в число для хеширования + long mmsiValue = Long.parseLong(mmsi); + + // Используем хеш-функцию для получения равномерного распределения + int hash = Long.hashCode(mmsiValue); + + // Извлекаем RGB компоненты из хеша + int r = (hash & 0xFF0000) >> 16; + int g = (hash & 0x00FF00) >> 8; + int b = hash & 0x0000FF; + + // Проверяем, не слишком ли темный цвет (чтобы избежать черного) + int brightness = (r + g + b) / 3; + if (brightness < 100) { + // Если цвет слишком темный, осветляем его + r = Math.min(255, r + 120); + g = Math.min(255, g + 120); + b = Math.min(255, b + 120); + } + + // Проверяем, не слишком ли светлый цвет (чтобы избежать белого) + if (brightness > 220) { + // Если цвет слишком светлый, затемняем его + r = Math.max(0, r - 60); + g = Math.max(0, g - 60); + b = Math.max(0, b - 60); + } + + // Форматируем в HEX + String color = String.format("#%02X%02X%02X", r, g, b); + + Log.d(TAG, "Сгенерирован цвет для MMSI " + mmsi + ": " + color + " (RGB: " + r + "," + g + "," + b + ")"); + + return color; + + } catch (NumberFormatException e) { + Log.w(TAG, "Не удалось распарсить MMSI как число: " + mmsi + ", используем цвет по умолчанию"); + return "#00AA00"; // Зеленый по умолчанию + } catch (Exception e) { + Log.e(TAG, "Ошибка генерации цвета для MMSI " + mmsi + ": " + e.getMessage(), e); + return "#00AA00"; // Зеленый по умолчанию + } + } + + /** + * Определяет тип судна по MMSI + * Использует более точную логику на основе стандартных диапазонов MMSI + * @param mmsi MMSI судна + * @return Тип судна + */ + private static String getVesselTypeByMMSI(long mmsi) { + // Стандартные диапазоны MMSI для разных типов судов + if (mmsi >= 100000000 && mmsi <= 199999999) { + return "COASTAL"; // Прибрежные суда + } else if (mmsi >= 200000000 && mmsi <= 299999999) { + return "FISHING"; // Рыболовные суда + } else if (mmsi >= 300000000 && mmsi <= 399999999) { + return "CARGO"; // Грузовые суда + } else if (mmsi >= 400000000 && mmsi <= 499999999) { + return "TANKER"; // Танкеры + } else if (mmsi >= 500000000 && mmsi <= 599999999) { + return "PASSENGER"; // Пассажирские суда + } else if (mmsi >= 600000000 && mmsi <= 699999999) { + return "MILITARY"; // Военные корабли + } else if (mmsi >= 700000000 && mmsi <= 799999999) { + return "PILOT"; // Лоцманские суда + } else if (mmsi >= 800000000 && mmsi <= 899999999) { + return "PILOT"; // Лоцманские суда (дополнительный диапазон) + } else if (mmsi >= 900000000 && mmsi <= 999999999) { + return "MILITARY"; // Военные корабли (дополнительный диапазон) + } else if (mmsi >= 1000000000 && mmsi <= 1099999999) { + return "SAR"; // Спасательные суда + } else if (mmsi >= 1100000000 && mmsi <= 1199999999) { + return "TUG"; // Буксиры + } else if (mmsi >= 1200000000 && mmsi <= 1299999999) { + return "PORT_TENDER"; // Портовые суда + } else if (mmsi >= 1300000000 && mmsi <= 1399999999) { + return "ANTI_POLLUTION"; // Антизагрязнительные суда + } else if (mmsi >= 1400000000 && mmsi <= 1499999999) { + return "LAW_ENFORCEMENT"; // Правоохранительные суда + } else if (mmsi >= 1500000000 && mmsi <= 1599999999) { + return "MEDICAL"; // Медицинские суда + } else if (mmsi >= 1600000000 && mmsi <= 1699999999) { + return "SPECIAL_CRAFT"; // Специальные суда + } else if (mmsi >= 1700000000 && mmsi <= 1799999999) { + return "PASSENGER"; // Пассажирские суда (дополнительный диапазон) + } else if (mmsi >= 1800000000 && mmsi <= 1899999999) { + return "CARGO"; // Грузовые суда (дополнительный диапазон) + } else if (mmsi >= 1900000000 && mmsi <= 1999999999) { + return "TANKER"; // Танкеры (дополнительный диапазон) + } else if (mmsi >= 2000000000 && mmsi <= 2099999999) { + return "OTHER"; // Другие типы судов + } else if (mmsi >= 2100000000L && mmsi <= 2199999999L) { + return "OTHER"; // Другие типы судов (дополнительный диапазон) + } else if (mmsi >= 2200000000L && mmsi <= 2299999999L) { + return "OTHER"; // Другие типы судов (дополнительный диапазон) + } else if (mmsi >= 2300000000L && mmsi <= 2399999999L) { + return "OTHER"; // Другие типы судов (дополнительный диапазон) + } else if (mmsi >= 2400000000L && mmsi <= 2499999999L) { + return "OTHER"; // Другие типы судов (дополнительный диапазон) + } else if (mmsi >= 2500000000L && mmsi <= 2599999999L) { + return "OTHER"; // Другие типы судов (дополнительный диапазон) + } else if (mmsi >= 2600000000L && mmsi <= 2699999999L) { + return "OTHER"; // Другие типы судов (дополнительный диапазон) + } else if (mmsi >= 2700000000L && mmsi <= 2799999999L) { + return "OTHER"; // Другие типы судов (дополнительный диапазон) + } else if (mmsi >= 2800000000L && mmsi <= 2899999999L) { + return "OTHER"; // Другие типы судов (дополнительный диапазон) + } else if (mmsi >= 2900000000L && mmsi <= 2999999999L) { + return "OTHER"; // Другие типы судов (дополнительный диапазон) + } else { + return "UNKNOWN"; // Неизвестный тип + } + } + + /** + * Кодирует цвет для безопасного использования в URL + * Специально обрабатывает HEX цвета, заменяя # на %23 + * @param color Цвет в формате HEX (#RRGGBB) или имя цвета + * @return Закодированный цвет + */ + private static String encodeColorForURL(String color) { + if (color == null || color.trim().isEmpty()) { + return "green"; // Цвет по умолчанию + } + + try { + // Если цвет начинается с #, заменяем его на %23 + if (color.startsWith("#")) { + String encoded = "%23" + color.substring(1); + Log.d(TAG, "Закодирован HEX цвет: " + color + " -> " + encoded); + return encoded; + } else { + // Для именованных цветов используем стандартное кодирование + String encoded = URLEncoder.encode(color, StandardCharsets.UTF_8.toString()); + Log.d(TAG, "Закодирован именованный цвет: " + color + " -> " + encoded); + return encoded; + } + } catch (Exception e) { + Log.e(TAG, "Ошибка кодирования цвета: " + e.getMessage(), e); + return "green"; // Цвет по умолчанию + } + } + /** * Кодирует строку для безопасного использования в URL * Дополнительно экранирует символы, которые могут вызывать проблемы @@ -109,12 +307,13 @@ public class LogSender { String encoded = URLEncoder.encode(message, StandardCharsets.UTF_8.toString()); // Дополнительно экранируем символы, которые могут вызывать проблемы - // Заменяем < на %3C, > на %3E, & на %26, " на %22, ' на %27 + // Заменяем < на %3C, > на %3E, & на %26, " на %22, ' на %27, # на %23 encoded = encoded.replace("<", "%3C") .replace(">", "%3E") .replace("&", "%26") .replace("\"", "%22") - .replace("'", "%27"); + .replace("'", "%27") + .replace("#", "%23"); // Логируем для отладки Log.d(TAG, "Исходное сообщение: " + message); @@ -129,6 +328,7 @@ public class LogSender { .replace("&", "%26") .replace("\"", "%22") .replace("'", "%27") + .replace("#", "%23") .replace(" ", "%20"); Log.d(TAG, "Fallback кодирование: " + fallback); return fallback; From a2f1775f9f6bd6f85aed57a739d392746a56e949 Mon Sep 17 00:00:00 2001 From: grigo Date: Fri, 12 Sep 2025 15:53:55 +0300 Subject: [PATCH 7/9] =?UTF-8?q?=D0=9A=D0=BE=D0=B2=D1=8B=D1=80=D1=8F=D0=BB?= =?UTF-8?q?=20=D0=B4=D0=B5=D0=BA=D0=BE=D0=B4=D0=B5=D1=80=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aismap/controllers/NMEAParser.java | 158 ++++-------------- .../grigowashere/aismap/view/CompassView.java | 64 +++++-- 2 files changed, 86 insertions(+), 136 deletions(-) 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 8d6a2a2..80f5770 100644 --- a/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java +++ b/app/src/main/java/com/grigowashere/aismap/controllers/NMEAParser.java @@ -1081,8 +1081,8 @@ public class NMEAParser { double length = dimRefA + dimRefB; double width = dimRefC + dimRefD; - // Draft (8 бит) - осадка - бит 296 - String draftBits = decodeAISField(payload, 296, 8); + // 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 бит):"); @@ -1094,8 +1094,8 @@ public class NMEAParser { Log.d(TAG, " Total Width (C+D): " + width + " м"); Log.d(TAG, " Draft: " + draftBits + " = " + draft + " м"); - // ETA (20 бит) - бит 294 - String etaBits = decodeAISField(payload, 294, 20); + // ETA (20 бит) - бит 274 + String etaBits = decodeAISField(payload, 274, 20); int eta = Integer.parseInt(etaBits, 2); Log.d(TAG, "ETA bits: " + etaBits + " = " + eta); @@ -1109,7 +1109,7 @@ public class NMEAParser { // Вычисляем доступную длину для оставшихся полей int totalBits = payload.length() * 6; - int remainingBits = totalBits - 314; // Остается после ETA + int remainingBits = totalBits - 294; // Остается после ETA Log.d(TAG, "Remaining bits after ETA: " + remainingBits + " (total: " + totalBits + ")"); String destination = ""; @@ -1117,81 +1117,18 @@ public class NMEAParser { String epfdDescription = "Unknown"; boolean dteReady = false; - // Для коротких сообщений (426 бит) используем упрощенную структуру - if (totalBits <= 426) { - // В коротких сообщениях может не быть всех полей - // Пробуем разные позиции для Destination - int[] possibleDestStarts = {302, 314, 320, 328}; - for (int destStartBit : possibleDestStarts) { - if (destStartBit + 120 <= totalBits) { - String destBits = decodeAISField(payload, destStartBit, 120); - String testDest = decodeAISString(destBits); - Log.d(TAG, "Пробуем Destination с бита " + destStartBit + ": " + testDest); - if (testDest.contains("DEFAULT") || testDest.contains("FAULT")) { - destination = testDest; - Log.d(TAG, "Найден Destination с бита " + destStartBit + ": '" + destination + "'"); - break; - } - } - } - - // Если не нашли, используем стандартную позицию - if (destination.isEmpty() && remainingBits > 0) { - int destStartBit = 314; - int destLength = Math.min(remainingBits, 120); - String destBits = decodeAISField(payload, destStartBit, destLength); - destination = decodeAISString(destBits); - Log.d(TAG, "Destination bits (fallback): " + destBits + " = '" + destination + "' (length: " + destLength + ")"); - } - } else { - // Для полных сообщений используем стандартную структуру - if (remainingBits >= 8) { - // Maximum present static draught (8 бит) - бит 314 - String draughtBits = decodeAISField(payload, 314, 8); - int draughtValue = Integer.parseInt(draughtBits, 2); - maxDraught = (draughtValue == 0) ? 0.0 : (draughtValue == 255) ? 25.5 : draughtValue / 10.0; - Log.d(TAG, "Max Draught bits: " + draughtBits + " = " + maxDraught + "m"); - remainingBits -= 8; - } - - if (remainingBits >= 4) { - // Type of electronic position fixing device (4 бита) - int epfdStartBit = 314 + 8; - String epfdBits = decodeAISField(payload, epfdStartBit, 4); - int epfdType = Integer.parseInt(epfdBits, 2); - epfdDescription = getEPFDType(epfdType); - Log.d(TAG, "EPFD Type bits: " + epfdBits + " = " + epfdType + " (" + epfdDescription + ")"); - remainingBits -= 4; - } - - if (remainingBits >= 1) { - // DTE (1 бит) - int dteStartBit = 314 + 8 + 4; - String dteBits = decodeAISField(payload, dteStartBit, 1); - dteReady = Integer.parseInt(dteBits, 2) == 0; - Log.d(TAG, "DTE bits: " + dteBits + " = " + dteReady + " (ready: " + dteReady + ")"); - remainingBits -= 1; - } - - if (remainingBits >= 1) { - // Spare (1 бит) - int spareStartBit = 314 + 8 + 4 + 1; - String spareBits = decodeAISField(payload, spareStartBit, 1); - int spare = Integer.parseInt(spareBits, 2); - Log.d(TAG, "Spare bits: " + spareBits + " = " + spare); - remainingBits -= 1; - } - - if (remainingBits > 0) { - // Destination (оставшиеся биты) - int destStartBit = 314 + 8 + 4 + 1 + 1; - int destLength = Math.min(remainingBits, 120); // Максимум 120 бит - String destBits = decodeAISField(payload, destStartBit, destLength); - destination = decodeAISString(destBits); - Log.d(TAG, "Destination bits (full): " + destBits + " = '" + destination + "' (length: " + destLength + ")"); - } else { - Log.w(TAG, "Destination поле недоступно - недостаточно битов"); - } + // 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'", @@ -1300,52 +1237,29 @@ public class NMEAParser { /** * Декодирует AIS строку */ + //TODO: Исправить на нормальный декодер строк private String decodeAISString(String bits) { StringBuilder result = new StringBuilder(); - Log.d(TAG, "Декодируем AIS строку из битов: " + bits + " (длина: " + bits.length() + ")"); - - for (int i = 0; i < bits.length(); i += 6) { - if (i + 6 <= bits.length()) { - String charBits = bits.substring(i, i + 6); - int value = Integer.parseInt(charBits, 2); - - char decodedChar; - Log.d(TAG, "Обрабатываем значение: " + value + " (биты: " + charBits + ")"); - - // Приоритет специальных случаев (пробелы) - if (value == 32 || value == 63) { - decodedChar = ' '; // Пробел - Log.d(TAG, "Найден пробел (" + value + ")"); - } else if (value >= 1 && value <= 26) { - // Заглавные буквы A-Z - decodedChar = (char)('A' + value - 1); - Log.d(TAG, "Диапазон A-Z: " + value + " -> " + decodedChar); - } else if (value >= 49 && value <= 58) { - // Цифры 1-9 (кастомное сопоставление на основе AIS1) - decodedChar = (char)('1' + (value - 49)); - Log.d(TAG, "Диапазон 1-9: " + value + " -> " + decodedChar); - } else if (value == 59) { - // Цифра 0 (кастомное сопоставление) - decodedChar = '0'; - Log.d(TAG, "Декодирован символ (59) -> '0'"); - } else if (value == 0) { - // Нулевое значение - конец строки, но не останавливаемся сразу - decodedChar = ' '; // Заменяем на пробел для продолжения - Log.d(TAG, "Найден ноль, заменяем на пробел"); - } else { - // Неизвестный или зарезервированный символ - decodedChar = '?'; - Log.w(TAG, "Неизвестное или зарезервированное значение AIS символа: " + value + " (биты: " + charBits + ")"); - } - - result.append(decodedChar); - Log.d(TAG, "Декодирован символ: " + charBits + " (" + value + ") -> '" + decodedChar + "'"); + + 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 = ' '; // всё остальное = пробел } + + result.append(decodedChar); } - - String resultStr = result.toString().trim(); - Log.d(TAG, "Результат декодирования строки: '" + resultStr + "'"); - return resultStr; + + return result.toString().trim(); } /** diff --git a/app/src/main/java/com/grigowashere/aismap/view/CompassView.java b/app/src/main/java/com/grigowashere/aismap/view/CompassView.java index 832261e..4a630b4 100644 --- a/app/src/main/java/com/grigowashere/aismap/view/CompassView.java +++ b/app/src/main/java/com/grigowashere/aismap/view/CompassView.java @@ -69,11 +69,27 @@ public class CompassView extends BaseDockWidget { } private float getShortestRotation(float start, float end) { + // Нормализуем углы к диапазону 0-360 + start = normalizeAngle(start); + end = normalizeAngle(end); + float diff = end - start; - while (diff > 180) diff -= 360; - while (diff < -180) diff += 360; + + // Если разность больше 180°, идем в обратную сторону + if (diff > 180) { + diff -= 360; + } else if (diff < -180) { + diff += 360; + } + return diff; } + + private float normalizeAngle(float angle) { + while (angle < 0) angle += 360; + while (angle >= 360) angle -= 360; + return angle; + } @@ -109,9 +125,11 @@ public class CompassView extends BaseDockWidget { // Плавное обновление азимута float diff = getShortestRotation(currentAzimuth, targetAzimuth); if (Math.abs(diff) > 0.1f) { - currentAzimuth += diff * SMOOTHING_FACTOR; - if (currentAzimuth > 360) currentAzimuth -= 360; - if (currentAzimuth < 0) currentAzimuth += 360; + // Ограничиваем максимальное изменение за один кадр + float maxChange = 3.0f; // максимальное изменение в градусах за кадр + float change = Math.signum(diff) * Math.min(Math.abs(diff * SMOOTHING_FACTOR), maxChange); + currentAzimuth += change; + currentAzimuth = normalizeAngle(currentAzimuth); postInvalidateOnAnimation(); } @@ -123,8 +141,7 @@ public class CompassView extends BaseDockWidget { // Рисуем деления шкалы for (int degree = 0; degree < 360; degree += 15) { // Вычисляем относительное положение деления - float relativeDegree = (degree - currentAzimuth + 360) % 360; - if (relativeDegree > 180) relativeDegree -= 360; + float relativeDegree = getShortestRotation(currentAzimuth, degree); // Рисуем только видимые деления if (Math.abs(relativeDegree) <= visibleDegrees / 2) { @@ -149,8 +166,7 @@ public class CompassView extends BaseDockWidget { // Рисуем суда for (AISVessel vessel : nearbyVessels) { - float relativeBearing = (float) ((vessel.getCourse() - currentAzimuth + 360) % 360); - if (relativeBearing > 180) relativeBearing -= 360; + float relativeBearing = getShortestRotation(currentAzimuth, (float) vessel.getCourse()); if (Math.abs(relativeBearing) <= visibleDegrees / 2) { float x = centerX + (relativeBearing / (visibleDegrees / 2)) * (w / 2); double distance = ourVessel != null ? GeoUtils.calculateDistance(ourVessel, vessel) : 0; @@ -218,9 +234,11 @@ public class CompassView extends BaseDockWidget { // Плавное обновление азимута float diff = getShortestRotation(currentAzimuth, targetAzimuth); if (Math.abs(diff) > 0.1f) { - currentAzimuth += diff * SMOOTHING_FACTOR; - if (currentAzimuth > 360) currentAzimuth -= 360; - if (currentAzimuth < 0) currentAzimuth += 360; + // Ограничиваем максимальное изменение за один кадр + float maxChange = 3.0f; // максимальное изменение в градусах за кадр + float change = Math.signum(diff) * Math.min(Math.abs(diff * SMOOTHING_FACTOR), maxChange); + currentAzimuth += change; + currentAzimuth = normalizeAngle(currentAzimuth); postInvalidateOnAnimation(); } @@ -246,7 +264,7 @@ public class CompassView extends BaseDockWidget { // Рисуем суда по кругу for (AISVessel vessel : nearbyVessels) { - float bearing = (float) ((vessel.getCourse() - currentAzimuth + 360) % 360); + float bearing = getShortestRotation(currentAzimuth, (float) vessel.getCourse()); float angle = (float) Math.toRadians(bearing); float vesselRadius = radius * 0.6f; float vx = cx + (float) Math.sin(angle) * vesselRadius; @@ -313,7 +331,25 @@ public class CompassView extends BaseDockWidget { } public void setAzimuth(float azimuth) { - this.targetAzimuth = azimuth; + // Проверяем на валидность азимута + if (Float.isNaN(azimuth) || Float.isInfinite(azimuth)) { + return; // Игнорируем невалидные значения + } + + // Нормализуем входящий азимут + this.targetAzimuth = normalizeAngle(azimuth); + + // Если текущий азимут еще не инициализирован, устанавливаем его сразу + if (currentAzimuth == 0 && targetAzimuth != 0) { + currentAzimuth = targetAzimuth; + } + + // Специальная обработка для 0° - если текущий азимут близок к 360°, + // то 0° должен интерпретироваться как 360° + if (targetAzimuth == 0 && currentAzimuth > 350) { + this.targetAzimuth = 360; + } + invalidate(); } From 41432665eaab88168e8c183134e7efb2d4758250 Mon Sep 17 00:00:00 2001 From: grigo Date: Tue, 23 Sep 2025 11:53:23 +0300 Subject: [PATCH 8/9] =?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" /> +