Подготовка к крупным изменениям: карта, AIS и UI

- Яндекс/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 (не влияют на сборку)
This commit is contained in:
2025-09-23 11:53:23 +03:00
parent a2f1775f9f
commit 41432665ea
37 changed files with 6561 additions and 161 deletions
+8
View File
@@ -4,6 +4,14 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-09-15T06:25:47.522835900Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="Default" identifier="serial=192.168.22.44:5555;connection=ad165724" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>
File diff suppressed because it is too large Load Diff
+7
View File
@@ -46,6 +46,13 @@ dependencies {
implementation group: 'org.mapsforge', name: 'mapsforge-map-reader', version: '0.25.0' implementation group: 'org.mapsforge', name: 'mapsforge-map-reader', version: '0.25.0'
implementation group: 'org.mapsforge', name: 'mapsforge-core', 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' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+21 -1
View File
@@ -5,6 +5,11 @@
<!-- Разрешения для GPS --> <!-- Разрешения для GPS -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Для работы в фоне (Android 10+) -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Для ForegroundService с локацией (Android 9+) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!-- Разрешения для интернета (для Яндекс.Карт) --> <!-- Разрешения для интернета (для Яндекс.Карт) -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
@@ -13,6 +18,9 @@
<!-- Разрешения для UDP --> <!-- Разрешения для UDP -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Разрешения для вибрации -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Разрешения для записи в файл (для логирования) --> <!-- Разрешения для записи в файл (для логирования) -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" /> android:maxSdkVersion="28" />
@@ -31,7 +39,7 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.AISMap" android:theme="@style/Theme.AISMap"
tools:targetApi="31"> tools:targetApi="31">
<profileable android:shell="true" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -49,6 +57,18 @@
android:configChanges="orientation|screenSize|keyboardHidden" android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/Theme.AISMap" /> android:theme="@style/Theme.AISMap" />
<activity
android:name=".AisTargetsActivity"
android:exported="false"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/Theme.AISMap" />
<!-- Foreground Service для фоновых обновлений AIS/GPS -->
<service
android:name=".services.AISForegroundService"
android:exported="false"
android:foregroundServiceType="location" />
<!-- Мета-данные для Яндекс.Карт --> <!-- Мета-данные для Яндекс.Карт -->
<meta-data <meta-data
android:name="com.yandex.mapkit.ApiKey" android:name="com.yandex.mapkit.ApiKey"
@@ -0,0 +1,108 @@
package com.grigowashere.aismap;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.Observer;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.grigowashere.aismap.data.Repository;
import com.grigowashere.aismap.data.entity.AISVesselEntity;
import java.util.ArrayList;
import java.util.List;
public class AisTargetsActivity extends AppCompatActivity implements AisTargetsAdapter.OnItemClickListener {
private Repository repository;
private RecyclerView recyclerView;
private AisTargetsAdapter adapter;
private android.os.Handler tickerHandler;
private Runnable tickerRunnable;
private android.widget.TextView textEmptyState;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_ais_targets);
repository = new Repository(this);
recyclerView = findViewById(R.id.recycler_ais_targets);
textEmptyState = findViewById(R.id.text_empty_state);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
adapter = new AisTargetsAdapter(new ArrayList<>(), this);
recyclerView.setAdapter(adapter);
repository.observeAllAIS().observe(this, new Observer<List<AISVesselEntity>>() {
@Override
public void onChanged(List<AISVesselEntity> 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);
}
}
}
@@ -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<AISVesselEntity, AisTargetsAdapter.ViewHolder> {
interface OnItemClickListener {
void onMarinetrafficClick(String mmsi);
void onCenterOnMapClick(String mmsi, double lat, double lon);
}
private final OnItemClickListener listener;
protected AisTargetsAdapter(@NonNull DiffUtil.ItemCallback<AISVesselEntity> diffCallback, OnItemClickListener listener) {
super(diffCallback);
this.listener = listener;
}
public AisTargetsAdapter(java.util.List<AISVesselEntity> initial, OnItemClickListener listener) {
this(DIFF_CALLBACK, listener);
submitList(initial);
}
static final DiffUtil.ItemCallback<AISVesselEntity> DIFF_CALLBACK = new DiffUtil.ItemCallback<AISVesselEntity>() {
@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<Object> 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("-- сек назад");
}
}
}
}
@@ -14,6 +14,7 @@ import android.widget.LinearLayout;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.graphics.Color;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat; 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.view.BaseDockWidget;
import com.grigowashere.aismap.utils.SettingsManager; import com.grigowashere.aismap.utils.SettingsManager;
import com.grigowashere.aismap.utils.LogSender; import com.grigowashere.aismap.utils.LogSender;
import com.grigowashere.aismap.utils.MIDToCountry;
import com.yandex.mapkit.mapview.MapView; import com.yandex.mapkit.mapview.MapView;
import java.util.List; import java.util.List;
import java.util.ArrayList; import java.util.ArrayList;
@@ -53,10 +55,15 @@ public class MainActivity extends AppCompatActivity {
private Button btnCenterOnVessel; private Button btnCenterOnVessel;
private Button btnMapOrientation; private Button btnMapOrientation;
private Button btnSettings; private Button btnSettings;
private Button btnAisTargets;
private LinearLayout controlPanel; private LinearLayout controlPanel;
private CompassView compassView; private CompassView compassView;
private CompassSensor compassSensor; private CompassSensor compassSensor;
private CoordinatesDockWidget coordinatesWidget; private CoordinatesDockWidget coordinatesWidget;
private TextView tvGpsAge;
private TextView tvAisAge;
private android.os.Handler messageAgeHandler;
private Runnable messageAgeRunnable;
// BottomSheet для отображения информации о нашем судне // BottomSheet для отображения информации о нашем судне
private BottomSheetDialog ownVesselBottomSheet; private BottomSheetDialog ownVesselBottomSheet;
@@ -74,6 +81,10 @@ public class MainActivity extends AppCompatActivity {
private Runnable bottomSheetUpdateRunnable; // Runnable для обновления BottomSheet private Runnable bottomSheetUpdateRunnable; // Runnable для обновления BottomSheet
private static final int BOTTOM_SHEET_UPDATE_INTERVAL = 1000; // Обновление каждую секунду private static final int BOTTOM_SHEET_UPDATE_INTERVAL = 1000; // Обновление каждую секунду
// Отложенное центрирование из внешнего интента
private Double pendingCenterLat = null;
private Double pendingCenterLon = null;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@@ -93,9 +104,12 @@ public class MainActivity extends AppCompatActivity {
btnCenterOnVessel = findViewById(R.id.btn_center_vessel); btnCenterOnVessel = findViewById(R.id.btn_center_vessel);
btnMapOrientation = findViewById(R.id.btn_map_orientation); btnMapOrientation = findViewById(R.id.btn_map_orientation);
btnSettings = findViewById(R.id.btn_settings); btnSettings = findViewById(R.id.btn_settings);
btnAisTargets = findViewById(R.id.btn_ais_targets);
controlPanel = findViewById(R.id.control_panel); controlPanel = findViewById(R.id.control_panel);
compassView = findViewById(R.id.compass_view); compassView = findViewById(R.id.compass_view);
coordinatesWidget = findViewById(R.id.coordinates_widget); coordinatesWidget = findViewById(R.id.coordinates_widget);
tvGpsAge = findViewById(R.id.tv_gps_age);
tvAisAge = findViewById(R.id.tv_ais_age);
// Инициализируем магнитный компас // Инициализируем магнитный компас
compassSensor = new CompassSensor(this); compassSensor = new CompassSensor(this);
@@ -104,12 +118,16 @@ public class MainActivity extends AppCompatActivity {
setupButtonListeners(); setupButtonListeners();
setupCompass(); setupCompass();
setupCoordinatesWidget(); setupCoordinatesWidget();
setupMessageAgesUpdater();
} }
private void setupButtonListeners() { private void setupButtonListeners() {
btnCenterOnVessel.setOnClickListener(v -> centerOnVessel()); btnCenterOnVessel.setOnClickListener(v -> centerOnVessel());
btnMapOrientation.setOnClickListener(v -> toggleMapOrientation()); btnMapOrientation.setOnClickListener(v -> toggleMapOrientation());
btnSettings.setOnClickListener(v -> showSettings()); btnSettings.setOnClickListener(v -> showSettings());
if (btnAisTargets != null) {
btnAisTargets.setOnClickListener(v -> openAisTargets());
}
// Кнопка для показа информации о судне // Кнопка для показа информации о судне
// Button btnShowVesselInfo = findViewById(R.id.btn_show_vessel_info); // Button btnShowVesselInfo = findViewById(R.id.btn_show_vessel_info);
@@ -217,6 +235,46 @@ public class MainActivity extends AppCompatActivity {
}); });
} }
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<AISVessel> nearbyVessels) { private void onUpdateCompass(float azimuth, List<AISVessel> nearbyVessels) {
if (compassView != null) { if (compassView != null) {
compassView.setAzimuth(azimuth); compassView.setAzimuth(azimuth);
@@ -329,6 +387,18 @@ public class MainActivity extends AppCompatActivity {
mapController = new MapController(this); mapController = new MapController(this);
// Устанавливаем callback для обновления UI // Устанавливаем 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() { appController.setUIUpdateCallback(new AppController.ExtendedUIUpdateCallback() {
@Override @Override
public void onVesselPositionUpdated(Vessel vessel) { public void onVesselPositionUpdated(Vessel vessel) {
@@ -470,20 +540,45 @@ public class MainActivity extends AppCompatActivity {
Toast.makeText(this, "Переключение ориентации карты (в разработке)", Toast.LENGTH_SHORT).show(); 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() { private void showSettings() {
Intent intent = new Intent(this, SettingsActivity.class); Intent intent = new Intent(this, SettingsActivity.class);
startActivityForResult(intent, SETTINGS_REQUEST_CODE); startActivityForResult(intent, SETTINGS_REQUEST_CODE);
} }
private void openAisTargets() {
Intent intent = new Intent(this, AisTargetsActivity.class);
startActivity(intent);
}
/** /**
* Обновляет позицию панели управления в зависимости от состояния docked виджетов * Обновляет позицию панели управления в зависимости от состояния docked виджетов
*/ */
private void updateControlPanelPosition() { private void updateControlPanelPosition() {
if (controlPanel != null) { if (controlPanel != null) {
runOnUiThread(() -> { // Используем postDelayed для предотвращения частых обновлений layout
// Получаем текущие параметры layout controlPanel.postDelayed(() -> {
android.widget.RelativeLayout.LayoutParams params = try {
(android.widget.RelativeLayout.LayoutParams) controlPanel.getLayoutParams(); // Получаем текущие параметры layout
android.widget.RelativeLayout.LayoutParams params =
(android.widget.RelativeLayout.LayoutParams) controlPanel.getLayoutParams();
int topMargin = dpToPx(16); // По умолчанию отступ сверху int topMargin = dpToPx(16); // По умолчанию отступ сверху
int bottomMargin = dpToPx(16); // По умолчанию отступ снизу int bottomMargin = dpToPx(16); // По умолчанию отступ снизу
@@ -525,16 +620,19 @@ public class MainActivity extends AppCompatActivity {
// Применяем новые параметры // Применяем новые параметры
controlPanel.setLayoutParams(params); controlPanel.setLayoutParams(params);
Log.d(TAG, "Control panel position updated: " + Log.d(TAG, "Control panel position updated: " +
"topMargin=" + topMargin + "px, " + "topMargin=" + topMargin + "px, " +
"bottomMargin=" + bottomMargin + "px, " + "bottomMargin=" + bottomMargin + "px, " +
"totalTopHeight=" + totalTopHeight + "px, " + "totalTopHeight=" + totalTopHeight + "px, " +
"totalBottomHeight=" + totalBottomHeight + "px, " + "totalBottomHeight=" + totalBottomHeight + "px, " +
"compassDocked=" + (compassView != null ? compassView.isDocked() : false) + "compassDocked=" + (compassView != null ? compassView.isDocked() : false) +
", compassTop=" + (compassView != null ? compassView.isDockTop() : false) + ", compassTop=" + (compassView != null ? compassView.isDockTop() : false) +
", coordinatesDocked=" + (coordinatesWidget != null ? coordinatesWidget.isDocked() : false) + ", coordinatesDocked=" + (coordinatesWidget != null ? coordinatesWidget.isDocked() : false) +
", coordinatesTop=" + (coordinatesWidget != null ? coordinatesWidget.isDockTop() : false)); ", coordinatesTop=" + (coordinatesWidget != null ? coordinatesWidget.isDockTop() : false));
}); } catch (Exception e) {
Log.e(TAG, "Ошибка при обновлении позиции панели управления: " + e.getMessage(), e);
}
}, 50); // Задержка 50мс для throttling
} }
} }
@@ -575,6 +673,16 @@ public class MainActivity extends AppCompatActivity {
mapInterface.initialize(); mapInterface.initialize();
Log.i(TAG, "Карта инициализирована"); 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, "Проверяем настройку карты..."); Log.i(TAG, "Проверяем настройку карты...");
@@ -590,10 +698,54 @@ public class MainActivity extends AppCompatActivity {
} }
} }
// Обрабатываем возможный интент центрирования
handleCenterIntentIfAny(getIntent());
// Проверяем разрешения и запускаем контроллеры // Проверяем разрешения и запускаем контроллеры
checkPermissions(); 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 @Override
protected void onStop() { protected void onStop() {
super.onStop(); super.onStop();
@@ -603,10 +755,10 @@ public class MainActivity extends AppCompatActivity {
mapInterface.cleanup(); mapInterface.cleanup();
} }
// Останавливаем все слушатели // Не останавливаем слушатели здесь, чтобы UDP продолжал работать в фоне
if (appController != null) { // if (appController != null) {
appController.stopAllListeners(); // appController.stopAllListeners();
} // }
} }
@Override @Override
@@ -727,6 +879,12 @@ public class MainActivity extends AppCompatActivity {
udpItem.setTitle(appController.isUDPEnabled() ? "UDP ✓" : "UDP"); 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; return true;
} }
@@ -743,6 +901,9 @@ public class MainActivity extends AppCompatActivity {
} else if (id == R.id.menu_clear_ais) { } else if (id == R.id.menu_clear_ais) {
clearAIS(); clearAIS();
return true; return true;
} else if (id == R.id.menu_path_tracking) {
togglePathTracking();
return true;
} }
return super.onOptionsItemSelected(item); return super.onOptionsItemSelected(item);
@@ -996,12 +1157,13 @@ public class MainActivity extends AppCompatActivity {
// Обновляем все поля в AIS BottomSheet // Обновляем все поля в AIS BottomSheet
TextView tvTitle = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_title); TextView tvTitle = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_title);
TextView tvMmsi = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_mmsi); 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 tvCallsign = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_callsign);
TextView tvImo = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_imo); TextView tvImo = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_imo);
TextView tvType = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_type); TextView tvType = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_type);
TextView tvPosition = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_position); TextView tvPosition = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_position);
TextView tvCourse = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_course); 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 tvSpeed = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_speed);
TextView tvDimensions = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_dimensions); TextView tvDimensions = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_dimensions);
TextView tvDraft = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_draft); TextView tvDraft = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_draft);
@@ -1015,9 +1177,11 @@ public class MainActivity extends AppCompatActivity {
// Заголовок // Заголовок
if (tvTitle != null) { if (tvTitle != null) {
String title = vessel.getVesselName() != null && !vessel.getVesselName().isEmpty() String name = vessel.getVesselName() != null && !vessel.getVesselName().isEmpty()
? "🚢 " + vessel.getVesselName() ? vessel.getVesselName()
: "🚢 AIS СУДНО"; : "AIS СУДНО";
String flag = getFlagEmojiForMMSI(vessel.getMmsi());
String title = (flag != null ? flag + " " : "") + "🚢 " + name;
tvTitle.setText(title); 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) { if (tvCallsign != null) {
@@ -1057,13 +1219,34 @@ public class MainActivity extends AppCompatActivity {
} }
} }
// Курс // Курс (COG)
if (tvCourse != null) { if (tvCourse != null) {
if (vessel.getCourse() > 0) { if (vessel.getCourse() > 0) {
String courseText = String.format("🧭 Курс: %.1f°", vessel.getCourse()); String courseText = String.format("🧭 COG: %.1f°", vessel.getCourse());
tvCourse.setText(courseText); tvCourse.setText(courseText);
} else { } 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()); String signalText = String.format("📶 Сигнал: %d", vessel.getSignalStrength());
tvSignal.setText(signalText); tvSignal.setText(signalText);
} else { } else {
tvSignal.setText("📶 Сигнал: --"); // Показываем качество позиции по AIS Accuracy биту
String qualityText = vessel.isPositionAccuracy() ? "📶 Точность: высокая" : "📶 Точность: низкая";
tvSignal.setText(qualityText);
} }
} }
@@ -1176,6 +1361,26 @@ public class MainActivity extends AppCompatActivity {
} }
} }
/**
* Возвращает флаг-эмодзи по 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) { if (!isYandexMapsInitialized) {
try { 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); com.yandex.mapkit.MapKitFactory.initialize(this);
isYandexMapsInitialized = true; isYandexMapsInitialized = true;
@@ -33,6 +33,10 @@ public class SettingsActivity extends AppCompatActivity {
private RadioButton radioHybridMode; private RadioButton radioHybridMode;
private RadioButton radioNMEAOnly; private RadioButton radioNMEAOnly;
private RadioButton radioAndroidOnly; private RadioButton radioAndroidOnly;
private EditText etStaleWarningMinutes;
private EditText etStaleRemoveMinutes;
private SwitchMaterial switchVibrationEnabled;
private SwitchMaterial switchSoundEnabled;
private Button btnCancel; private Button btnCancel;
private Button btnSave; private Button btnSave;
@@ -42,6 +46,10 @@ public class SettingsActivity extends AppCompatActivity {
private boolean originalAndroidNMEAEnabled; private boolean originalAndroidNMEAEnabled;
private boolean originalUDPNMEAEnabled; private boolean originalUDPNMEAEnabled;
private String originalDataMode; private String originalDataMode;
private int originalStaleWarningMinutes;
private int originalStaleRemoveMinutes;
private boolean originalVibrationEnabled;
private boolean originalSoundEnabled;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
@@ -78,6 +86,10 @@ public class SettingsActivity extends AppCompatActivity {
radioHybridMode = findViewById(R.id.radio_hybrid_mode); radioHybridMode = findViewById(R.id.radio_hybrid_mode);
radioNMEAOnly = findViewById(R.id.radio_nmea_only); radioNMEAOnly = findViewById(R.id.radio_nmea_only);
radioAndroidOnly = findViewById(R.id.radio_android_only); radioAndroidOnly = findViewById(R.id.radio_android_only);
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); btnCancel = findViewById(R.id.btn_cancel);
btnSave = findViewById(R.id.btn_save); btnSave = findViewById(R.id.btn_save);
} }
@@ -108,6 +120,14 @@ public class SettingsActivity extends AppCompatActivity {
break; 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"); Log.i(TAG, "Настройки загружены в UI");
} }
@@ -120,6 +140,10 @@ public class SettingsActivity extends AppCompatActivity {
originalAndroidNMEAEnabled = settingsManager.isAndroidNMEAEnabled(); originalAndroidNMEAEnabled = settingsManager.isAndroidNMEAEnabled();
originalUDPNMEAEnabled = settingsManager.isUDPNMEAEnabled(); originalUDPNMEAEnabled = settingsManager.isUDPNMEAEnabled();
originalDataMode = settingsManager.getDataMode(); originalDataMode = settingsManager.getDataMode();
originalStaleWarningMinutes = settingsManager.getDataStaleWarningMinutes();
originalStaleRemoveMinutes = settingsManager.getDataStaleRemoveMinutes();
originalVibrationEnabled = settingsManager.isVibrationEnabled();
originalSoundEnabled = settingsManager.isSoundEnabled();
Log.i(TAG, "Оригинальные настройки сохранены"); Log.i(TAG, "Оригинальные настройки сохранены");
} }
@@ -221,12 +245,29 @@ public class SettingsActivity extends AppCompatActivity {
return; 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.setUDPPort(udpPort);
settingsManager.setUDPEnabled(switchUDPEnabled.isChecked()); settingsManager.setUDPEnabled(switchUDPEnabled.isChecked());
settingsManager.setAndroidNMEAEnabled(switchAndroidNMEAEnabled.isChecked()); settingsManager.setAndroidNMEAEnabled(switchAndroidNMEAEnabled.isChecked());
settingsManager.setUDPNMEAEnabled(switchUDPNMEAEnabled.isChecked()); settingsManager.setUDPNMEAEnabled(switchUDPNMEAEnabled.isChecked());
settingsManager.setDataMode(dataMode); settingsManager.setDataMode(dataMode);
settingsManager.setDataStaleWarningMinutes(staleWarningMinutes);
settingsManager.setDataStaleRemoveMinutes(staleRemoveMinutes);
settingsManager.setVibrationEnabled(switchVibrationEnabled.isChecked());
settingsManager.setSoundEnabled(switchSoundEnabled.isChecked());
Log.i(TAG, "Настройки сохранены: " + settingsManager.getSettingsSummary()); Log.i(TAG, "Настройки сохранены: " + settingsManager.getSettingsSummary());
@@ -307,6 +348,28 @@ public class SettingsActivity extends AppCompatActivity {
return true; 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;
}
}
/** /**
* Проверяет, нужно ли перезапустить сервисы * Проверяет, нужно ли перезапустить сервисы
*/ */
@@ -5,6 +5,9 @@ import android.util.Log;
import com.grigowashere.aismap.models.Vessel; import com.grigowashere.aismap.models.Vessel;
import com.grigowashere.aismap.models.AISVessel; import com.grigowashere.aismap.models.AISVessel;
import com.grigowashere.aismap.maps.MapInterface; 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.List;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
@@ -34,6 +37,8 @@ public class AppController implements
private Vessel ownVessel; private Vessel ownVessel;
private List<AISVessel> aisVessels; private List<AISVessel> aisVessels;
private ExecutorService executor; private ExecutorService executor;
private com.grigowashere.aismap.data.Repository repository;
private NotificationService notificationService;
private boolean isUDPEnabled; private boolean isUDPEnabled;
private boolean isAndroidNMEAEnabled; private boolean isAndroidNMEAEnabled;
@@ -42,6 +47,15 @@ public class AppController implements
private int udpPort; private int udpPort;
private String dataMode; 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 // Callback для обновления UI
private UIUpdateCallback uiUpdateCallback; private UIUpdateCallback uiUpdateCallback;
@@ -64,6 +78,12 @@ public class AppController implements
this.ownVessel = new Vessel(); this.ownVessel = new Vessel();
this.aisVessels = new ArrayList<>(); this.aisVessels = new ArrayList<>();
this.executor = Executors.newCachedThreadPool(); 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(); initializeControllers();
} }
@@ -92,6 +112,29 @@ public class AppController implements
// Инициализация Android NMEA слушателя (для курса, скорости, DOP) // Инициализация Android NMEA слушателя (для курса, скорости, DOP)
androidNmeaListener = new AndroidNMEAListener(context); androidNmeaListener = new AndroidNMEAListener(context);
androidNmeaListener.setCallback(this); 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<com.grigowashere.aismap.data.entity.AISVesselEntity> 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"); Log.i(TAG, "Устанавливаем MarkerClickListener в MapInterface");
mapInterface.setMarkerClickListener(this); mapInterface.setMarkerClickListener(this);
Log.i(TAG, "MarkerClickListener установлен, теперь можно создавать маркеры"); 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() { public void stopAllListeners() {
// Останавливаем периодическую очистку БД
stopDatabaseCleanup();
executor.execute(() -> { executor.execute(() -> {
udpListener.stop(); udpListener.stop();
androidNmeaListener.stopListening(); androidNmeaListener.stopListening();
@@ -279,15 +344,28 @@ public class AppController implements
ownVessel.setFixTime(vessel.getFixTime()); ownVessel.setFixTime(vessel.getFixTime());
ownVessel.setFixQuality(vessel.getFixQuality()); 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 // Обновляем UI через callback
if (uiUpdateCallback != null) { if (uiUpdateCallback != null) {
uiUpdateCallback.onVesselPositionUpdated(ownVessel); uiUpdateCallback.onVesselPositionUpdated(ownVessel);
} }
// Обновляем карту в главном потоке // Обновляем карту в главном потоке с throttling
if (mapInterface != null) { if (mapInterface != null) {
Log.i(TAG, "Обновляем позицию на карте..."); Log.i(TAG, "Обновляем позицию на карте...");
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> { // Используем postDelayed для предотвращения частых обновлений
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
try { try {
Log.i(TAG, "Вызываем mapInterface.updateOwnVesselPosition..."); Log.i(TAG, "Вызываем mapInterface.updateOwnVesselPosition...");
mapInterface.updateOwnVesselPosition(ownVessel); mapInterface.updateOwnVesselPosition(ownVessel);
@@ -295,7 +373,7 @@ public class AppController implements
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Ошибка обновления позиции на карте: " + e.getMessage(), e); Log.e(TAG, "Ошибка обновления позиции на карте: " + e.getMessage(), e);
} }
}); }, 100); // Задержка 100мс для throttling
} }
} }
@@ -381,6 +459,17 @@ public class AppController implements
AISVessel existingVessel = findAISVesselByMMSI(vessel.getMmsi()); AISVessel existingVessel = findAISVesselByMMSI(vessel.getMmsi());
if (existingVessel != null) { 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( existingVessel.updatePosition(
vessel.getLatitude(), vessel.getLatitude(),
@@ -388,6 +477,14 @@ public class AppController implements
vessel.getCourse(), vessel.getCourse(),
vessel.getSpeed() 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) { if (mapInterface != null) {
// Используем Handler для выполнения в главном потоке // Используем Handler для выполнения в главном потоке
@@ -403,6 +500,28 @@ public class AppController implements
// Добавляем новое судно // Добавляем новое судно
aisVessels.add(vessel); 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) { if (mapInterface != null) {
// Используем Handler для выполнения в главном потоке // Используем Handler для выполнения в главном потоке
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> { new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
@@ -465,6 +584,8 @@ public class AppController implements
// Парсим полученные данные как NMEA // Парсим полученные данные как NMEA
nmeaParser.parseNMEA(data); nmeaParser.parseNMEA(data);
// Обновляем метки времени по префиксу
updateLastMessageAgesFromRaw(data);
} }
@Override @Override
@@ -485,6 +606,18 @@ public class AppController implements
// Парсим полученные данные как NMEA // Парсим полученные данные как NMEA
nmeaParser.parseNMEA(message); 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 // Реализация 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() { public void cleanup() {
stopAllListeners(); stopAllListeners();
stopDatabaseCleanup();
if (udpListener != null) { if (udpListener != null) {
udpListener.cleanup(); udpListener.cleanup();
@@ -595,11 +773,51 @@ public class AppController implements
gpsLocationListener.cleanup(); gpsLocationListener.cleanup();
} }
if (notificationService != null) {
notificationService.cleanup();
}
if (executor != null && !executor.isShutdown()) { if (executor != null && !executor.isShutdown()) {
executor.shutdown(); 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);
}
// Методы для управления настройками // Методы для управления настройками
/** /**
@@ -952,8 +952,26 @@ public class NMEAParser {
// Rate of Turn (8 бит) - бит 42 // Rate of Turn (8 бит) - бит 42
String rotBits = decodeAISField(payload, 42, 8); String rotBits = decodeAISField(payload, 42, 8);
int rot = Integer.parseInt(rotBits, 2); int rotRaw = Integer.parseInt(rotBits, 2);
Log.d(TAG, "Rate of Turn bits: " + rotBits + " = " + rot); 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 // Speed Over Ground (10 бит) - бит 50
String speedBits = decodeAISField(payload, 50, 10); String speedBits = decodeAISField(payload, 50, 10);
@@ -998,20 +1016,44 @@ public class NMEAParser {
Log.w(TAG, "Долгота вне допустимых пределов: " + longitude); 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", 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)); mmsi, latitude, longitude, course, speed, status, heading, rateOfTurn));
// Создаем или обновляем AIS судно // Создаем или обновляем AIS судно
AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); 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.setHeading(heading);
vessel.setNavigationalStatus(getNavigationStatus(status)); vessel.setNavigationalStatus(getNavigationStatus(status));
vessel.setLastUpdate(java.time.LocalDateTime.now()); vessel.setLastUpdate(java.time.LocalDateTime.now());
// Помечаем класс судна как Class A, чтобы предотвратить дальнейшее перезаписывание Class B сообщениями
vessel.setVesselClass("Class A");
// Отправляем информацию о корабле на внешний ресурс // Отправляем информацию о корабле на внешний ресурс (помечаем как Class A и добавляем статические поля, если известны)
String vesselInfo = String.format("lat=%.6f, lon=%.6f, course=%.1f, speed=%.1f, status=%s", StringBuilder infoA = new StringBuilder(
latitude, longitude, course, speed, getNavigationStatus(status)); String.format(java.util.Locale.US,
LogSender.logShipUpdate(String.valueOf(mmsi), vesselInfo); "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) { if (listener != null) {
@@ -1147,8 +1189,9 @@ public class NMEAParser {
vessel.setEta(etaDateTime); // Добавляем ETA в модель vessel.setEta(etaDateTime); // Добавляем ETA в модель
vessel.setLastUpdate(java.time.LocalDateTime.now()); vessel.setLastUpdate(java.time.LocalDateTime.now());
// Отправляем информацию о корабле на внешний ресурс // Отправляем информацию о корабле на внешний ресурс (помечаем как Class A Static)
String vesselInfo = String.format("name='%s', callSign='%s', type=%s, L=%.1f, W=%.1f, D=%.1f, dest='%s'", 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); vesselName, callSign, getVesselType(vesselTypeCode), length, width, draft, destination);
LogSender.logShipUpdate(String.valueOf(mmsi), vesselInfo); 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 координаты * Парсит 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) { private String decodeAISString(String bits) {
StringBuilder result = new StringBuilder(); StringBuilder result = new StringBuilder();
Log.d(TAG, "Декодируем AIS строку, биты: " + bits + " (длина: " + bits.length() + ")");
for (int i = 0; i + 6 <= bits.length(); i += 6) { for (int i = 0; i + 6 <= bits.length(); i += 6) {
String charBits = bits.substring(i, i + 6); String charBits = bits.substring(i, i + 6);
int value = Integer.parseInt(charBits, 2); int value = Integer.parseInt(charBits, 2);
char decodedChar; char decodedChar;
if (value == 0) { // Простой switch case для всех 64 возможных значений
decodedChar = ' '; // 0 = пробел switch (value) {
} else if (value >= 1 && value <= 26) { case 0: decodedChar = ' '; break;
decodedChar = (char) ('A' + value - 1); // 1..26 = A..Z case 1: decodedChar = 'A'; break;
} else if (value >= 27 && value <= 36) { case 2: decodedChar = 'B'; break;
decodedChar = (char) ('0' + (value - 27)); // 27..36 = 0..9 case 3: decodedChar = 'C'; break;
} else { case 4: decodedChar = 'D'; break;
decodedChar = ' '; // всё остальное = пробел 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); 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, "Safety Text bits: " + textBits + " = '" + safetyText + "'");
Log.d(TAG, String.format("AIS Safety Broadcast: MMSI=%d, text='%s'", mmsi, 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 судно // Создаем или обновляем AIS судно
AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi));
@@ -1781,19 +1936,52 @@ public class NMEAParser {
// Создаем или обновляем AIS судно // Создаем или обновляем AIS судно
AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); 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.updatePosition(latitude, longitude, course, speed);
vessel.setHeading(heading); vessel.setHeading(heading);
vessel.setPositionAccuracy(accuracy == 1); vessel.setPositionAccuracy(accuracy == 1);
vessel.setLastUpdate(java.time.LocalDateTime.now()); vessel.setLastUpdate(java.time.LocalDateTime.now());
vessel.setVesselClass("Class B"); if (!keepExtended) {
vessel.setVesselClass("Class B");
}
// В Class B Position Report размеры не передаются, но мы сохраняем существующие // В Class B Position Report размеры не передаются, но мы сохраняем существующие
Log.d(TAG, "Class B Position Report - размеры не передаются, сохраняем существующие: L=" + vessel.getLength() + ", W=" + vessel.getWidth()); 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", // Добавляем статические поля, если они уже известны (из сообщений 24 и др.)
latitude, longitude, course, speed, heading, accuracy == 1 ? "high" : "low"); StringBuilder info = new StringBuilder(
LogSender.logShipUpdate(String.valueOf(mmsi), vesselInfo); 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) { if (listener != null) {
@@ -1895,6 +2083,12 @@ public class NMEAParser {
Log.w(TAG, "Extended Class B - недостаточно битов для размеров: " + totalBits + " < 327"); Log.w(TAG, "Extended Class B - недостаточно битов для размеров: " + totalBits + " < 327");
// Создаем судно без размеров // Создаем судно без размеров
AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); 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.updatePosition(latitude, longitude, course, speed);
vessel.setHeading(heading); vessel.setHeading(heading);
vessel.setPositionAccuracy(accuracy == 1); vessel.setPositionAccuracy(accuracy == 1);
@@ -1906,6 +2100,19 @@ public class NMEAParser {
if (listener != null) { if (listener != null) {
listener.onAISVesselUpdated(vessel); 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; return;
} }
@@ -1970,6 +2177,12 @@ public class NMEAParser {
// Создаем или обновляем AIS судно // Создаем или обновляем AIS судно
AISVessel vessel = findOrCreateAISVessel(String.valueOf(mmsi)); 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.updatePosition(latitude, longitude, course, speed);
vessel.setHeading(heading); vessel.setHeading(heading);
vessel.setPositionAccuracy(accuracy == 1); vessel.setPositionAccuracy(accuracy == 1);
@@ -2126,6 +2339,12 @@ public class NMEAParser {
listener.onAISVesselUpdated(vessel); 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) { } else if (partNumber == 1) {
// Part B: Vessel Type, Dimensions, etc. // Part B: Vessel Type, Dimensions, etc.
String typeBits = decodeAISField(payload, 40, 8); String typeBits = decodeAISField(payload, 40, 8);
@@ -2212,6 +2431,15 @@ public class NMEAParser {
if (listener != null) { if (listener != null) {
listener.onAISVesselUpdated(vessel); 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) { } catch (Exception e) {
@@ -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;
}
}
@@ -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<AISVesselEntity> getAllAISSync() {
return aisVesselDao.getAll();
}
public LiveData<List<AISVesselEntity>> 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();
}
}
@@ -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<AISVesselEntity> getAll();
@Query("SELECT * FROM ais_vessels")
LiveData<List<AISVesselEntity>> 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);
}
@@ -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();
}
@@ -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();
}
}
@@ -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;
}
@@ -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());
}
}
@@ -76,7 +76,9 @@ public class MapForgeImpl implements MapInterface {
@Override @Override
public void addAISVesselMarker(AISVessel vessel) { public void addAISVesselMarker(AISVessel vessel) {
LatLong position = new LatLong(vessel.getLatitude(), vessel.getLongitude()); 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); Marker marker = new Marker(position, icon, 0, 0);
// MapForge не поддерживает OnTapListener напрямую // MapForge не поддерживает OnTapListener напрямую
@@ -92,7 +94,9 @@ public class MapForgeImpl implements MapInterface {
LatLong newPosition = new LatLong(vessel.getLatitude(), vessel.getLongitude()); LatLong newPosition = new LatLong(vessel.getLatitude(), vessel.getLongitude());
marker.setLatLong(newPosition); 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); marker.setBitmap(icon);
} }
} }
@@ -58,4 +58,24 @@ public interface MarkerManager {
* Получение количества активных маркеров * Получение количества активных маркеров
*/ */
int getActiveMarkerCount(); int getActiveMarkerCount();
/**
* Включает/выключает отображение путей движения
*/
void setPathTrackingEnabled(boolean enabled);
/**
* Очищает путь конкретного судна
*/
void clearVesselPath(String mmsi);
/**
* Очищает все пути движения
*/
void clearAllPaths();
/**
* Обновляет настройки отображения путей
*/
void updatePathSettings(int pathColor, int predictionColor, float pathWidth, float predictionWidth);
} }
@@ -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<PathPoint> 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<Point> 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<Point> 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();
}
}
@@ -46,7 +46,9 @@ public class YandexMapImpl implements MapInterface {
try { try {
this.mapObjects = mapView.getMap().getMapObjects().addCollection(); 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) { } catch (Exception e) {
// Ошибка создания коллекции объектов карты // Ошибка создания коллекции объектов карты
} }
@@ -129,7 +131,7 @@ public class YandexMapImpl implements MapInterface {
@Override @Override
public void centerOnPosition(double latitude, double longitude) { public void centerOnPosition(double latitude, double longitude) {
Point point = new Point(latitude, 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); 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; 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 * Получение MapView для использования в layout
@@ -257,10 +304,11 @@ public class YandexMapImpl implements MapInterface {
// Включаем жесты поворота карты // Включаем жесты поворота карты
mapView.getMap().setRotateGesturesEnabled(true); mapView.getMap().setRotateGesturesEnabled(true);
// Добавляем слушатель изменений камеры для обновления маркеров при повороте // Добавляем слушатель изменений камеры для обновления маркеров при повороте и зуме
mapView.getMap().addCameraListener(new com.yandex.mapkit.map.CameraListener() { mapView.getMap().addCameraListener(new com.yandex.mapkit.map.CameraListener() {
private long lastUpdateTime = 0; 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 @Override
public void onCameraPositionChanged(com.yandex.mapkit.map.Map map, public void onCameraPositionChanged(com.yandex.mapkit.map.Map map,
@@ -270,9 +318,23 @@ public class YandexMapImpl implements MapInterface {
// Обновляем маркеры в реальном времени с throttling // Обновляем маркеры в реальном времени с throttling
long currentTime = System.currentTimeMillis(); long currentTime = System.currentTimeMillis();
if (currentTime - lastUpdateTime >= UPDATE_THROTTLE) { float currentZoom = cameraPosition.getZoom();
onMapRotationChanged();
// Проверяем, изменился ли зум значительно (больше чем на 1.0)
boolean zoomChanged = Math.abs(currentZoom - lastZoom) > 1.0f;
if (currentTime - lastUpdateTime >= UPDATE_THROTTLE || zoomChanged) {
//onMapRotationChanged();
// Обновляем маркеры только при значительных изменениях
if (zoomChanged) {
// При изменении зума принудительно обновляем все маркеры
forceRefreshMarkersOnZoomChange();
} else {
// При повороте только проверяем валидность маркеров
checkAndRestoreMarkers();
}
lastUpdateTime = currentTime; lastUpdateTime = currentTime;
lastZoom = currentZoom;
} }
} }
}); });
@@ -26,11 +26,17 @@ public class YandexMarkerManager implements MarkerManager {
private MapObjectCollection mapObjects; private MapObjectCollection mapObjects;
private com.yandex.mapkit.mapview.MapView mapView; private com.yandex.mapkit.mapview.MapView mapView;
private MapInterface.MarkerClickListener markerClickListener; private MapInterface.MarkerClickListener markerClickListener;
private com.grigowashere.aismap.utils.SettingsManager settingsManager;
// Кеш маркеров с управлением жизненным циклом // Кеш маркеров с управлением жизненным циклом
private Map<String, YandexMarkerWrapper> markerCache = new ConcurrentHashMap<>(); private Map<String, YandexMarkerWrapper> markerCache = new ConcurrentHashMap<>();
private YandexMarkerWrapper ownVesselMarker; private YandexMarkerWrapper ownVesselMarker;
// Трекеры путей движения судов
private Map<String, VesselPathTracker> pathTrackers = new ConcurrentHashMap<>();
private VesselPathTracker ownVesselPathTracker;
private boolean pathTrackingEnabled = true;
// Периодическая очистка устаревших маркеров // Периодическая очистка устаревших маркеров
private Handler cleanupHandler; private Handler cleanupHandler;
private Runnable cleanupRunnable; private Runnable cleanupRunnable;
@@ -41,10 +47,11 @@ public class YandexMarkerManager implements MarkerManager {
private Runnable refreshRunnable; private Runnable refreshRunnable;
private static final long REFRESH_INTERVAL = 2000; // 2 секунды 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.context = context;
this.mapObjects = mapObjects; this.mapObjects = mapObjects;
this.mapView = mapView; this.mapView = mapView;
this.settingsManager = settingsManager;
this.cleanupHandler = new Handler(Looper.getMainLooper()); this.cleanupHandler = new Handler(Looper.getMainLooper());
this.refreshHandler = new Handler(Looper.getMainLooper()); this.refreshHandler = new Handler(Looper.getMainLooper());
} }
@@ -53,6 +60,17 @@ public class YandexMarkerManager implements MarkerManager {
public void initialize() { public void initialize() {
startPeriodicCleanup(); startPeriodicCleanup();
startPeriodicRefresh(); startPeriodicRefresh();
// Инициализируем настройки путей из SettingsManager
if (settingsManager != null) {
pathTrackingEnabled = settingsManager.isPathTrackingEnabled();
updatePathSettings(
settingsManager.getPathColor(),
settingsManager.getPredictionColor(),
settingsManager.getPathWidth(),
settingsManager.getPredictionWidth()
);
}
} }
@Override @Override
@@ -70,6 +88,17 @@ public class YandexMarkerManager implements MarkerManager {
ownVesselMarker.remove(); ownVesselMarker.remove();
ownVesselMarker = null; ownVesselMarker = null;
} }
// Очищаем трекеры путей
for (VesselPathTracker tracker : pathTrackers.values()) {
tracker.remove();
}
pathTrackers.clear();
if (ownVesselPathTracker != null) {
ownVesselPathTracker.remove();
ownVesselPathTracker = null;
}
} }
@Override @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) { if (markerClickListener != null) {
ownVesselMarker.setClickListener(() -> { ownVesselMarker.setClickListener(() -> {
if (markerClickListener != null) { if (markerClickListener != null) {
@@ -98,6 +127,9 @@ public class YandexMarkerManager implements MarkerManager {
} }
}); });
} }
// Обновляем трекер пути для собственного судна
updateOwnVesselPath(vessel);
} }
@Override @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); markerCache.put(mmsi, marker);
if (markerClickListener != null) { if (markerClickListener != null) {
@@ -131,6 +163,9 @@ public class YandexMarkerManager implements MarkerManager {
} }
}); });
} }
// Обновляем трекер пути для AIS судна
updateAISVesselPath(vessel);
} }
@Override @Override
@@ -139,6 +174,12 @@ public class YandexMarkerManager implements MarkerManager {
if (marker != null) { if (marker != null) {
marker.remove(); marker.remove();
} }
// Удаляем трекер пути
VesselPathTracker pathTracker = pathTrackers.remove(mmsi);
if (pathTracker != null) {
pathTracker.remove();
}
} }
@Override @Override
@@ -147,6 +188,12 @@ public class YandexMarkerManager implements MarkerManager {
marker.remove(); marker.remove();
} }
markerCache.clear(); markerCache.clear();
// Очищаем все трекеры путей AIS судов
for (VesselPathTracker tracker : pathTrackers.values()) {
tracker.remove();
}
pathTrackers.clear();
} }
@Override @Override
@@ -177,6 +224,9 @@ public class YandexMarkerManager implements MarkerManager {
public void refreshAllMarkers() { public void refreshAllMarkers() {
// При повороте карты пересоздаем все маркеры // При повороте карты пересоздаем все маркеры
// Это гарантирует правильную ориентацию относительно севера // Это гарантирует правильную ориентацию относительно севера
if (mapObjects == null || mapView == null) {
return;
}
// Пересоздаем маркер нашего судна // Пересоздаем маркер нашего судна
if (ownVesselMarker != null) { if (ownVesselMarker != null) {
@@ -193,7 +243,11 @@ public class YandexMarkerManager implements MarkerManager {
YandexMarkerWrapper marker = entry.getValue(); YandexMarkerWrapper marker = entry.getValue();
AISVessel vessel = marker.getAISVessel(); AISVessel vessel = marker.getAISVessel();
if (vessel != null) { if (vessel != null) {
marker.remove(); try {
marker.remove();
} catch (RuntimeException ignored) {
// Игнорируем, если underlying объект недоступен
}
vesselsToRecreate.put(entry.getKey(), vessel); vesselsToRecreate.put(entry.getKey(), vessel);
} }
} }
@@ -201,7 +255,11 @@ public class YandexMarkerManager implements MarkerManager {
// Очищаем кеш и пересоздаем маркеры // Очищаем кеш и пересоздаем маркеры
markerCache.clear(); markerCache.clear();
for (Map.Entry<String, AISVessel> entry : vesselsToRecreate.entrySet()) { for (Map.Entry<String, AISVessel> entry : vesselsToRecreate.entrySet()) {
updateAISVesselMarker(entry.getValue()); try {
updateAISVesselMarker(entry.getValue());
} catch (RuntimeException ignored) {
// Пропускаем пересоздание при ошибке
}
} }
} }
@@ -284,6 +342,12 @@ public class YandexMarkerManager implements MarkerManager {
@Override @Override
public void run() { public void run() {
refreshAllMarkers(); refreshAllMarkers();
try {
// Проверяем только валидность маркеров, не пересоздаем их
checkAndRestoreMarkers();
} catch (Exception e) {
android.util.Log.e(TAG, "Ошибка при периодическом обновлении маркеров: " + e.getMessage(), e);
}
// Планируем следующее обновление // Планируем следующее обновление
refreshHandler.postDelayed(this, REFRESH_INTERVAL); refreshHandler.postDelayed(this, REFRESH_INTERVAL);
} }
@@ -309,20 +373,262 @@ public class YandexMarkerManager implements MarkerManager {
Set<String> toRemove = new HashSet<>(); Set<String> toRemove = new HashSet<>();
for (Map.Entry<String, YandexMarkerWrapper> entry : markerCache.entrySet()) { for (Map.Entry<String, YandexMarkerWrapper> entry : markerCache.entrySet()) {
YandexMarkerWrapper marker = entry.getValue(); YandexMarkerWrapper marker = entry.getValue();
if (marker.isExpired() || !marker.isValid()) { if (marker.isExpired() || !marker.isValid() || marker.shouldBeRemoved()) {
marker.remove(); marker.remove();
toRemove.add(entry.getKey()); toRemove.add(entry.getKey());
} }
} }
// Удаляем маркеры и их трекеры путей
for (String mmsi : toRemove) { for (String mmsi : toRemove) {
markerCache.remove(mmsi); markerCache.remove(mmsi);
// Удаляем трекер пути для этого судна
VesselPathTracker pathTracker = pathTrackers.remove(mmsi);
if (pathTracker != null) {
pathTracker.remove();
}
} }
// Проверяем маркер нашего судна // Проверяем маркер нашего судна
if (ownVesselMarker != null && (ownVesselMarker.isExpired() || !ownVesselMarker.isValid())) { if (ownVesselMarker != null && (ownVesselMarker.isExpired() || !ownVesselMarker.isValid())) {
ownVesselMarker.remove(); ownVesselMarker.remove();
ownVesselMarker = null; 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<String, AISVessel> vesselsToRecreate = new HashMap<>();
for (Map.Entry<String, YandexMarkerWrapper> 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<String, AISVessel> 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;
}
} }
@@ -44,28 +44,44 @@ public class YandexMarkerWrapper extends MarkerWrapper {
private double cachedIconCourse = Double.NaN; private double cachedIconCourse = Double.NaN;
private int cachedIconColor = -1; private int cachedIconColor = -1;
private boolean cachedIconSelected = false; 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, 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); super(id);
this.context = context; this.context = context;
this.mapObjects = mapObjects; this.mapObjects = mapObjects;
this.mapView = mapView; this.mapView = mapView;
this.vessel = vessel; this.vessel = vessel;
this.isOwnVessel = true; this.isOwnVessel = true;
this.settingsManager = settingsManager;
// Предварительно создаем иконку // Предварительно создаем иконку
preloadIcon(); preloadIcon();
createMarker(); createMarker();
} }
public YandexMarkerWrapper(Context context, MapObjectCollection mapObjects, 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); super(id);
this.context = context; this.context = context;
this.mapObjects = mapObjects; this.mapObjects = mapObjects;
this.mapView = mapView; this.mapView = mapView;
this.aisVessel = vessel; this.aisVessel = vessel;
this.isOwnVessel = false; this.isOwnVessel = false;
this.settingsManager = settingsManager;
// Предварительно создаем иконку // Предварительно создаем иконку
preloadIcon(); preloadIcon();
createMarker(); createMarker();
@@ -76,14 +92,17 @@ public class YandexMarkerWrapper extends MarkerWrapper {
*/ */
private void preloadIcon() { private void preloadIcon() {
try { try {
double course = isOwnVessel ? vessel.getCourse() : aisVessel.getCourse(); // Курс для поворота: HDG (0..359) если валиден, иначе COG
double course = getDisplayCourse();
int color = isOwnVessel ? android.graphics.Color.BLUE : getVesselColor(); int color = isOwnVessel ? android.graphics.Color.BLUE : getVesselColor();
boolean selected = !isOwnVessel && aisVessel.isSelected(); boolean selected = !isOwnVessel && aisVessel.isSelected();
boolean stale = isDataStale(); // Проверяем устаревание данных
cachedIconBitmap = createRotatedIcon(course, color, selected); cachedIconBitmap = createRotatedIcon(course, color, selected, stale);
cachedIconCourse = course; cachedIconCourse = course;
cachedIconColor = color; cachedIconColor = color;
cachedIconSelected = selected; cachedIconSelected = selected;
cachedIconStale = stale;
} catch (Exception e) { } catch (Exception e) {
// Ошибка предварительной загрузки иконки // Ошибка предварительной загрузки иконки
cachedIconBitmap = null; cachedIconBitmap = null;
@@ -122,11 +141,13 @@ public class YandexMarkerWrapper extends MarkerWrapper {
*/ */
private Bitmap createIconBitmap() { private Bitmap createIconBitmap() {
try { try {
double course = isOwnVessel ? vessel.getCourse() : aisVessel.getCourse(); // Курс для поворота: HDG (0..359) если валиден, иначе COG
double course = getDisplayCourse();
int color = isOwnVessel ? android.graphics.Color.BLUE : getVesselColor(); int color = isOwnVessel ? android.graphics.Color.BLUE : getVesselColor();
boolean selected = !isOwnVessel && aisVessel.isSelected(); boolean selected = !isOwnVessel && aisVessel.isSelected();
boolean stale = isDataStale(); // Проверяем устаревание данных
return createRotatedIcon(course, color, selected); return createRotatedIcon(course, color, selected, stale);
} catch (Exception e) { } catch (Exception e) {
return null; return null;
} }
@@ -145,27 +166,36 @@ public class YandexMarkerWrapper extends MarkerWrapper {
*/ */
private void setIconImmediately() { private void setIconImmediately() {
try { try {
double course = isOwnVessel ? vessel.getCourse() : aisVessel.getCourse(); // Курс для поворота: HDG (0..359) если валиден, иначе COG
double course = getDisplayCourse();
int color = isOwnVessel ? android.graphics.Color.BLUE : getVesselColor(); int color = isOwnVessel ? android.graphics.Color.BLUE : getVesselColor();
boolean selected = !isOwnVessel && aisVessel.isSelected(); boolean selected = !isOwnVessel && aisVessel.isSelected();
boolean stale = isDataStale(); // Проверяем устаревание данных
// Получаем текущий зум для проверки кеша
float currentZoom = getCurrentZoom();
// Проверяем кеш иконки // Проверяем кеш иконки
Bitmap iconBitmap = null; Bitmap iconBitmap = null;
if (Double.compare(course, cachedIconCourse) == 0 && if (Double.compare(course, cachedIconCourse) == 0 &&
color == cachedIconColor && color == cachedIconColor &&
selected == cachedIconSelected && selected == cachedIconSelected &&
stale == cachedIconStale &&
Float.compare(currentZoom, cachedIconZoom) == 0 &&
cachedIconBitmap != null) { cachedIconBitmap != null) {
// Используем кешированную иконку // Используем кешированную иконку
iconBitmap = cachedIconBitmap; iconBitmap = cachedIconBitmap;
} else { } else {
// Создаем новую иконку // Создаем новую иконку
iconBitmap = createRotatedIcon(course, color, selected); iconBitmap = createRotatedIcon(course, color, selected, stale);
if (iconBitmap != null) { if (iconBitmap != null) {
// Кешируем иконку // Кешируем иконку
cachedIconBitmap = iconBitmap; cachedIconBitmap = iconBitmap;
cachedIconCourse = course; cachedIconCourse = course;
cachedIconColor = color; cachedIconColor = color;
cachedIconSelected = selected; 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 { try {
// Получаем drawable из ресурса // Сначала выбираем базовую иконку: для AIS Class A используем targetclassa
int iconResId = context.getResources().getIdentifier("target", "drawable", context.getPackageName()); String baseIconName = (!isOwnVessel && isAISClassA()) ? "targetclassa" : "target";
if (iconResId == 0) { int targetIconResId = context.getResources().getIdentifier(baseIconName, "drawable", context.getPackageName());
return createSimpleIcon(color, course); if (targetIconResId == 0) {
return createSimpleIcon(color, course, currentZoom, isStale);
} }
Drawable drawable = context.getResources().getDrawable(iconResId, null); Drawable targetDrawable = context.getResources().getDrawable(targetIconResId, null);
if (drawable == null) { if (targetDrawable == null) {
return createSimpleIcon(color, course); 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) { 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 originalWidth = targetDrawable.getIntrinsicWidth();
int originalHeight = drawable.getIntrinsicHeight(); int originalHeight = targetDrawable.getIntrinsicHeight();
if (originalWidth <= 0) originalWidth = 32; if (originalWidth <= 0) originalWidth = 32;
if (originalHeight <= 0) originalHeight = 48; 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 width = (int) (originalWidth * scale);
int height = (int) (originalHeight * scale); int height = (int) (originalHeight * scale);
// Создаем bitmap // Создаем bitmap с дополнительным пространством для обводки и тени
int bitmapSize = Math.max(width, height) + 8; int padding = 12;
int bitmapSize = Math.max(width, height) + padding * 2;
Bitmap bitmap = Bitmap.createBitmap(bitmapSize, bitmapSize, Bitmap.Config.ARGB_8888); Bitmap bitmap = Bitmap.createBitmap(bitmapSize, bitmapSize, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap); Canvas canvas = new Canvas(bitmap);
@@ -313,7 +360,7 @@ public class YandexMarkerWrapper extends MarkerWrapper {
// Не удалось получить азимут карты, используем 0 // Не удалось получить азимут карты, используем 0
} }
// Поворачиваем маркер на курс судна с учетом поворота карты // Поворачиваем основную иконку на курс судна с учетом поворота карты
// Курс судна - это направление относительно севера // Курс судна - это направление относительно севера
// Азимут карты - это поворот карты относительно севера // Азимут карты - это поворот карты относительно севера
// Итоговый поворот = курс судна - азимут карты (чтобы маркер оставался относительно севера) // Итоговый поворот = курс судна - азимут карты (чтобы маркер оставался относительно севера)
@@ -324,13 +371,31 @@ public class YandexMarkerWrapper extends MarkerWrapper {
int left = centerX - width / 2; int left = centerX - width / 2;
int top = centerY - height / 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.save();
canvas.rotate(rotationAngle, centerX, centerY); canvas.rotate(rotationAngle, centerX, centerY);
drawable.draw(canvas); targetDrawable.draw(canvas);
canvas.restore(); 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) { if (isSelected) {
addSelectionFrame(canvas, centerX, centerY, Math.max(width, height)); addSelectionFrame(canvas, centerX, centerY, Math.max(width, height));
@@ -338,31 +403,139 @@ public class YandexMarkerWrapper extends MarkerWrapper {
return bitmap; return bitmap;
} catch (Exception e) { } catch (Exception e) {
return createSimpleIcon(color, course); return createSimpleIcon(color, course, currentZoom, isStale);
} }
} }
private Bitmap createSimpleIcon(int color, double course) { /**
* Возвращает курс для отображения маркера: валидный HDG (0..359), иначе COG
*/
private double getDisplayCourse() {
try { try {
int size = 32; if (isOwnVessel) {
Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); 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, float zoom, boolean isStale) {
try {
// Рассчитываем размер маркера на основе зума
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); Canvas canvas = new Canvas(bitmap);
Paint paint = new Paint(); // Смещаем координаты с учетом padding
paint.setColor(color); float centerX = bitmapSize / 2f;
paint.setStyle(Paint.Style.FILL); float centerY = bitmapSize / 2f;
paint.setAntiAlias(true);
// Рисуем треугольник // Создаем путь для треугольника
android.graphics.Path path = new android.graphics.Path(); android.graphics.Path path = new android.graphics.Path();
path.moveTo(size / 2f, 0); path.moveTo(centerX, padding);
path.lineTo(size * 0.1f, size * 0.8f); path.lineTo(padding + size * 0.1f, padding + size * 0.8f);
path.lineTo(size * 0.9f, size * 0.8f); path.lineTo(padding + size * 0.9f, padding + size * 0.8f);
path.close(); path.close();
// Рисуем тень (смещенную копию)
Paint shadowPaint = new Paint();
shadowPaint.setColor(0x80000000); // Полупрозрачный черный
shadowPaint.setStyle(Paint.Style.FILL);
shadowPaint.setAntiAlias(true);
canvas.save(); canvas.save();
canvas.rotate((float) course, size / 2f, size / 2f); canvas.translate(2, 2); // Смещение для тени
canvas.drawPath(path, paint); 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(); canvas.restore();
return bitmap; return bitmap;
@@ -373,19 +546,50 @@ public class YandexMarkerWrapper extends MarkerWrapper {
private void addSelectionFrame(Canvas canvas, int centerX, int centerY, int size) { private void addSelectionFrame(Canvas canvas, int centerX, int centerY, int size) {
try { 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()); int iconResId = context.getResources().getIdentifier("chosentarget", "drawable", context.getPackageName());
if (iconResId == 0) return; 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;
Drawable selectionDrawable = context.getResources().getDrawable(iconResId, null); selectionDrawable.setBounds(selectionLeft, selectionTop,
if (selectionDrawable == null) return; selectionLeft + selectionSize, selectionTop + selectionSize);
selectionDrawable.draw(canvas);
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) { } catch (Exception e) {
// Игнорируем ошибки рамки выделения // Игнорируем ошибки рамки выделения
} }
@@ -425,4 +629,93 @@ public class YandexMarkerWrapper extends MarkerWrapper {
public boolean isOwnVessel() { public boolean isOwnVessel() {
return 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));
}
} }
@@ -16,6 +16,7 @@ public class AISVessel {
private double course; // курс в градусах (0-360) private double course; // курс в градусах (0-360)
private double speed; // скорость в узлах private double speed; // скорость в узлах
private double heading; // направление движения в градусах private double heading; // направление движения в градусах
private double rateOfTurn; // скорость поворота в градусах/минуту
private double length; // длина судна в метрах private double length; // длина судна в метрах
private double width; // ширина судна в метрах private double width; // ширина судна в метрах
private double draft; // осадка в метрах private double draft; // осадка в метрах
@@ -72,6 +73,9 @@ public class AISVessel {
public double getHeading() { return heading; } public double getHeading() { return heading; }
public void setHeading(double heading) { this.heading = 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 double getLength() { return length; }
public void setLength(double length) { this.length = length; } public void setLength(double length) { this.length = length; }
@@ -126,12 +130,47 @@ public class AISVessel {
} }
/** /**
* Проверяет, не устарели ли данные (больше 10 минут) * Обновляет позицию, курс и скорость поворота судна
*/ */
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() { public boolean isDataStale() {
return LocalDateTime.now().minusMinutes(10).isAfter(lastUpdate); 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 @Override
public String toString() { public String toString() {
return "AISVessel{" + return "AISVessel{" +
@@ -141,6 +180,7 @@ public class AISVessel {
", lon=" + longitude + ", lon=" + longitude +
", course=" + course + ", course=" + course +
", speed=" + speed + ", speed=" + speed +
", rot=" + rateOfTurn +
'}'; '}';
} }
} }
@@ -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();
}
}
@@ -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);
}
}
}
@@ -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<String, String> 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 dIvoire
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() {}
}
@@ -19,6 +19,15 @@ public class SettingsManager {
private static final String KEY_ANDROID_NMEA_ENABLED = "android_nmea_enabled"; private static final String KEY_ANDROID_NMEA_ENABLED = "android_nmea_enabled";
private static final String KEY_UDP_NMEA_ENABLED = "udp_nmea_enabled"; private static final String KEY_UDP_NMEA_ENABLED = "udp_nmea_enabled";
private static final String KEY_DATA_MODE = "data_mode"; private static final String KEY_DATA_MODE = "data_mode";
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; 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_ANDROID_NMEA_ENABLED = true;
private static final boolean DEFAULT_UDP_NMEA_ENABLED = true; private static final boolean DEFAULT_UDP_NMEA_ENABLED = true;
private static final String DEFAULT_DATA_MODE = "hybrid"; 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"; 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_ANDROID_NMEA_ENABLED, DEFAULT_ANDROID_NMEA_ENABLED)
.putBoolean(KEY_UDP_NMEA_ENABLED, DEFAULT_UDP_NMEA_ENABLED) .putBoolean(KEY_UDP_NMEA_ENABLED, DEFAULT_UDP_NMEA_ENABLED)
.putString(KEY_DATA_MODE, DEFAULT_DATA_MODE) .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(); .apply();
Log.i(TAG, "Настройки сброшены к значениям по умолчанию"); Log.i(TAG, "Настройки сброшены к значениям по умолчанию");
} }
@@ -175,12 +197,15 @@ public class SettingsManager {
"UDP: порт=%d, включен=%s\n" + "UDP: порт=%d, включен=%s\n" +
"Android NMEA: %s\n" + "Android NMEA: %s\n" +
"UDP NMEA: %s\n" + "UDP NMEA: %s\n" +
"Режим данных: %s", "Режим данных: %s\n" +
"Уведомления: вибрация=%s, звук=%s",
getUDPPort(), getUDPPort(),
isUDPEnabled() ? "да" : "нет", isUDPEnabled() ? "да" : "нет",
isAndroidNMEAEnabled() ? "включен" : "выключен", isAndroidNMEAEnabled() ? "включен" : "выключен",
isUDPNMEAEnabled() ? "включен" : "выключен", isUDPNMEAEnabled() ? "включен" : "выключен",
getDataMode() getDataMode(),
isVibrationEnabled() ? "включена" : "выключена",
isSoundEnabled() ? "включен" : "выключен"
); );
} }
@@ -199,4 +224,155 @@ public class SettingsManager {
isUDPNMEAEnabled() != currentUDPNMEA || isUDPNMEAEnabled() != currentUDPNMEA ||
!getDataMode().equals(currentDataMode); !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 ? "включен" : "выключен"));
}
} }
+10 -3
View File
@@ -3,9 +3,16 @@
android:height="162.6dp" android:height="162.6dp"
android:viewportWidth="91.38" android:viewportWidth="91.38"
android:viewportHeight="162.6"> android:viewportHeight="162.6">
<!-- Внешняя обводка для контраста -->
<path <path
android:pathData="M45.69,16.63l-39.75,141.47l79.5,0l-39.75,-141.47z" android:pathData="M45.69,16.63l-39.75,141.47l79.5,0l-39.75,-141.47z"
android:strokeWidth="9" android:strokeWidth="12"
android:fillColor="#00000000" android:fillColor="#000000"
android:strokeColor="#000"/> android:strokeColor="#000000"/>
<!-- Основная форма с внутренней обводкой -->
<path
android:pathData="M45.69,18.63l-37.75,139.47l75.5,0l-37.75,-139.47z"
android:strokeWidth="3"
android:fillColor="#B5B8B1"
android:strokeColor="#FFFFFF"/>
</vector> </vector>
@@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="88.5dp"
android:height="152.73dp"
android:viewportWidth="88.5"
android:viewportHeight="152.73">
<!-- Внешняя обводка для контраста -->
<path
android:pathData="M44.25,6.77l-39.75,44.69l0,96.77l79.5,0l0,-96.77l-39.75,-44.69z"
android:strokeWidth="9"
android:fillColor="#000000"
android:strokeColor="#000000"/>
<!-- Основная форма с внутренней обводкой -->
<path
android:pathData="M44.25,6.77l-39.75,44.69l0,96.77l79.5,0l0,-96.77l-39.75,-44.69z"
android:strokeWidth="3"
android:fillColor="#B5B8B1"
android:strokeColor="#FFFFFF"/>
</vector>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_ais_targets"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<TextView
android:id="@+id/text_empty_state"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:text="Нет AIS целей\nВсе корабли уплыли"
android:textAlignment="center"
android:textSize="18sp"
android:textColor="@android:color/darker_gray"
android:gravity="center"
android:visibility="gone"/>
</LinearLayout>
+29
View File
@@ -54,6 +54,35 @@
android:minWidth="120dp" android:minWidth="120dp"
android:background="@android:color/white" /> android:background="@android:color/white" />
<Button
android:id="@+id/btn_ais_targets"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Цели AIS"
android:textSize="12sp"
android:minWidth="120dp"
android:background="@android:color/white"
android:layout_marginTop="8dp" />
<!-- Строки возраста последних сообщений GPS ($) и AIS (!) -->
<TextView
android:id="@+id/tv_gps_age"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="GPS: --"
android:textSize="11sp"
android:textColor="@android:color/white"
android:layout_marginTop="8dp"/>
<TextView
android:id="@+id/tv_ais_age"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="AIS: --"
android:textSize="11sp"
android:textColor="@android:color/white"
android:layout_marginTop="4dp"/>
</LinearLayout> </LinearLayout>
<!-- Компас --> <!-- Компас -->
@@ -209,6 +209,152 @@
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- Настройки устаревания данных -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⏰ Устаревание данных AIS"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Настройте время, через которое данные о судах считаются устаревшими:"
android:textSize="14sp"
android:textColor="@android:color/darker_gray"
android:layout_marginBottom="16dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="Время предупреждения (минуты)"
app:helperText="Судна старше этого времени будут помечены как устаревшие">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_stale_warning_minutes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:text="5" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="Время удаления (минуты)"
app:helperText="Судна старше этого времени будут удалены с карты">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_stale_remove_minutes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:text="7" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="💡 Совет: Устаревшие суда отображаются с иконкой losingtarget.xml"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:layout_marginTop="8dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Настройки уведомлений -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔔 Уведомления о новых целях AIS"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Настройте уведомления при обнаружении новых судов:"
android:textSize="14sp"
android:textColor="@android:color/darker_gray"
android:layout_marginBottom="16dp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_vibration_enabled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Вибрация"
android:textSize="16sp"
android:checked="true"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Вибрация устройства при обнаружении нового судна"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:layout_marginBottom="16dp"
android:layout_marginStart="16dp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switch_sound_enabled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Звуковое уведомление"
android:textSize="16sp"
android:checked="true"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Звуковой сигнал при обнаружении нового судна"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:layout_marginBottom="8dp"
android:layout_marginStart="16dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Кнопки --> <!-- Кнопки -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -46,6 +46,17 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:orientation="vertical">
<!-- Время назад -->
<TextView
android:id="@+id/bottom_sheet_ais_time_ago"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⏱️ Время назад: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
android:background="@android:color/transparent"
android:padding="8dp" />
<!-- MMSI --> <!-- MMSI -->
<TextView <TextView
android:id="@+id/bottom_sheet_ais_mmsi" android:id="@+id/bottom_sheet_ais_mmsi"
@@ -58,17 +69,6 @@
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:padding="8dp" /> android:padding="8dp" />
<!-- Название судна -->
<TextView
android:id="@+id/bottom_sheet_ais_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📛 Название: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
android:background="@android:color/transparent"
android:padding="8dp" />
<!-- Позывной --> <!-- Позывной -->
<TextView <TextView
@@ -119,17 +119,52 @@
android:padding="8dp" /> android:padding="8dp" />
<!-- Курс --> <!-- Курс -->
<TextView <LinearLayout
android:id="@+id/bottom_sheet_ais_course"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🧭 Курс: --°" android:orientation="horizontal">
<TextView
android:id="@+id/bottom_sheet_ais_course"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="🧭 COG: --°"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@android:color/black" android:textColor="@android:color/black"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:padding="8dp" /> android:padding="8dp" />
<!-- Направление -->
<TextView
android:id="@+id/bottom_sheet_ais_heading"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="🧭 HDG: --°"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
android:background="@android:color/transparent"
android:padding="8dp" />
<!-- Скорость поворота -->
<TextView
android:id="@+id/bottom_sheet_ais_rot"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="🔄 ROT: --°/мин"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
android:background="@android:color/transparent"
android:padding="8dp" />
</LinearLayout>
<!-- Скорость --> <!-- Скорость -->
<TextView <TextView
android:id="@+id/bottom_sheet_ais_speed" android:id="@+id/bottom_sheet_ais_speed"
@@ -238,17 +273,7 @@
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:padding="8dp" /> android:padding="8dp" />
<!-- Время назад -->
<TextView
android:id="@+id/bottom_sheet_ais_time_ago"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="⏱️ Время назад: --"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
android:background="@android:color/transparent"
android:padding="8dp" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textSize="16sp"
android:text="Vessel" />
<TextView
android:id="@+id/tv_mmsi"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="13sp"
android:text="MMSI" />
<TextView
android:id="@+id/tv_coords"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="13sp"
android:text="0, 0" />
<TextView
android:id="@+id/tv_course_speed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="13sp"
android:text="COG 0 • 0 kn" />
<TextView
android:id="@+id/tv_last_update"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:text="Обновлено: --" />
<TextView
android:id="@+id/tv_time_ago"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:text="N сек назад" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp">
<Button
android:id="@+id/btn_marine_traffic"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="MarineTraffic" />
<Button
android:id="@+id/btn_center_on_map"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="На карте"
android:layout_marginStart="12dp" />
</LinearLayout>
</LinearLayout>
+6
View File
@@ -20,4 +20,10 @@
android:icon="@android:drawable/ic_menu_delete" android:icon="@android:drawable/ic_menu_delete"
app:showAsAction="ifRoom" /> app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_path_tracking"
android:title="Пути"
android:icon="@android:drawable/ic_menu_directions"
app:showAsAction="ifRoom" />
</menu> </menu>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88.5 152.73">
<defs>
<style>
.cls-1 {
fill: none;
stroke: #000;
stroke-miterlimit: 10;
stroke-width: 9px;
}
</style>
</defs>
<g id="_Слой_7" data-name="Слой_7">
<polygon class="cls-1" points="44.25 6.77 4.5 51.46 4.5 148.23 84 148.23 84 51.46 44.25 6.77"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 472 B