This commit is contained in:
2026-06-16 10:36:18 +03:00
parent c5805eaa5c
commit 0571291b69
15 changed files with 447 additions and 105 deletions
@@ -1,6 +1,7 @@
package com.grigowashere.loratester;
import android.content.Context;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
@@ -285,6 +286,7 @@ public class TelemetryUploader implements TelnetClient.Listener {
}
TelemetryPayload payload = new TelemetryPayload(
settings.getOrCreateDeviceId(),
phoneLabel(),
validLat(),
validLon(),
stats.rssi,
@@ -297,6 +299,13 @@ public class TelemetryUploader implements TelnetClient.Listener {
uploadExecutor.execute(() -> uploadTelemetry(payload));
}
private static String phoneLabel() {
String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER : "";
String model = Build.MODEL != null ? Build.MODEL : "";
String label = (manufacturer + " " + model).trim();
return label.isEmpty() ? null : label;
}
private void uploadTelemetry(TelemetryPayload payload) {
if (networkMonitor.isOnline()) {
try {
@@ -2,6 +2,7 @@ package com.grigowashere.loratester.api;
public class DeviceInfo {
public String device_id;
public String label;
public double last_seen;
public Double lat;
public Double lon;
@@ -51,6 +51,9 @@ public class ServerApi {
public void postTelemetry(TelemetryPayload payload) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("device_id", payload.deviceId);
if (payload.deviceLabel != null && !payload.deviceLabel.isBlank()) {
body.put("device_label", payload.deviceLabel);
}
if (payload.lat != null) body.put("lat", payload.lat);
if (payload.lon != null) body.put("lon", payload.lon);
if (payload.rssi != null) body.put("rssi", payload.rssi);
@@ -2,6 +2,7 @@ package com.grigowashere.loratester.api;
public class TelemetryPayload {
public final String deviceId;
public final String deviceLabel;
public final Double lat;
public final Double lon;
public final Double rssi;
@@ -22,8 +23,24 @@ public class TelemetryPayload {
String meta,
String role,
Double ts
) {
this(deviceId, null, lat, lon, rssi, rangeM, rawFrame, meta, role, ts);
}
public TelemetryPayload(
String deviceId,
String deviceLabel,
Double lat,
Double lon,
Double rssi,
Double rangeM,
String rawFrame,
String meta,
String role,
Double ts
) {
this.deviceId = deviceId;
this.deviceLabel = deviceLabel;
this.lat = lat;
this.lon = lon;
this.rssi = rssi;
@@ -0,0 +1,37 @@
package com.grigowashere.loratester.map;
import org.mapsforge.core.model.Tile;
import org.mapsforge.map.layer.download.tilesource.OnlineTileSource;
import java.net.MalformedURLException;
import java.net.URL;
/** Esri World Imagery — tile path is zoom/y/x (not OSM zoom/x/y). */
public final class EsriWorldImagery extends OnlineTileSource {
public static final EsriWorldImagery INSTANCE = new EsriWorldImagery();
private EsriWorldImagery() {
super(new String[]{"server.arcgisonline.com"}, 443);
setName("Esri.WorldImagery")
.setAlpha(false)
.setBaseUrl("/ArcGIS/rest/services/World_Imagery/MapServer/tile/")
.setExtension("png")
.setParallelRequestsLimit(4)
.setProtocol("https")
.setTileSize(256)
.setZoomLevelMax((byte) 18)
.setZoomLevelMin((byte) 0);
setUserAgent("LoraTester/1.0");
}
@Override
public URL getTileUrl(Tile tile) throws MalformedURLException {
StringBuilder path = new StringBuilder(48);
path.append(getBaseUrl());
path.append(tile.zoomLevel).append('/');
path.append(tile.tileY).append('/');
path.append(tile.tileX).append('.').append(getExtension());
return new URL(getProtocol(), getHostName(), 443, path.toString());
}
}
@@ -37,6 +37,8 @@ import com.grigowashere.loratester.api.TrackDetail;
import com.grigowashere.loratester.api.TrackInfo;
import com.grigowashere.loratester.location.GeoUtils;
import com.grigowashere.loratester.net.NetworkMonitor;
import com.grigowashere.loratester.map.EsriWorldImagery;
import com.grigowashere.loratester.model.RadioSnapshot;
import com.grigowashere.loratester.telnet.StatsExtractor;
import com.grigowashere.loratester.track.TrackRecorder;
@@ -51,6 +53,7 @@ import org.mapsforge.map.layer.Layer;
import org.mapsforge.map.layer.cache.TileCache;
import org.mapsforge.map.layer.download.TileDownloadLayer;
import org.mapsforge.map.layer.download.tilesource.OpenStreetMapMapnik;
import org.mapsforge.map.layer.download.tilesource.TileSource;
import org.mapsforge.map.layer.overlay.Marker;
import org.mapsforge.map.layer.overlay.Polyline;
import org.mapsforge.map.model.MapViewPosition;
@@ -122,6 +125,7 @@ public class MapFragment extends Fragment {
private MaterialButtonToggleGroup mapCenterMode;
private Spinner trackSpinner;
private Spinner mapHeatmapRadius;
private Spinner mapBasemap;
private TextView mapHeatmapStatus;
private View mapHeatmapLegend;
private List<TrackInfo> savedTracks = new ArrayList<>();
@@ -150,6 +154,8 @@ public class MapFragment extends Fragment {
private double lastHeatmapLat = Double.NaN;
private double lastHeatmapLon = Double.NaN;
private boolean suppressHeatmapSpinner;
private boolean suppressBasemapSpinner;
private TileSource currentTileSource = OpenStreetMapMapnik.INSTANCE;
private Runnable heatmapReloadRunnable;
private boolean suppressCenterToggle;
private boolean mapGestureActive;
@@ -196,6 +202,7 @@ public class MapFragment extends Fragment {
mapToolDrawer = view.findViewById(R.id.mapToolDrawer);
mapCenterMode = view.findViewById(R.id.mapCenterMode);
mapHeatmapRadius = view.findViewById(R.id.mapHeatmapRadius);
mapBasemap = view.findViewById(R.id.mapBasemap);
mapHeatmapStatus = view.findViewById(R.id.mapHeatmapStatus);
mapHeatmapLegend = view.findViewById(R.id.mapHeatmapLegend);
trackSpinner = view.findViewById(R.id.trackSpinner);
@@ -208,6 +215,7 @@ public class MapFragment extends Fragment {
btnFindHill.setOnClickListener(v -> toggleHill());
}
setupHeatmapUi();
setupBasemapUi();
updateConnectionIcons(lastDevices, serverConnected);
@@ -347,18 +355,18 @@ public class MapFragment extends Fragment {
mapView.getModel().frameBufferModel.getOverdrawFactor()
);
OpenStreetMapMapnik tileSource = OpenStreetMapMapnik.INSTANCE;
tileSource.setUserAgent("LoraTester/1.0");
OpenStreetMapMapnik.INSTANCE.setUserAgent("LoraTester/1.0");
currentTileSource = OpenStreetMapMapnik.INSTANCE;
downloadLayer = new TileDownloadLayer(
tileCache,
mapView.getModel().mapViewPosition,
tileSource,
currentTileSource,
AndroidGraphicFactory.INSTANCE
);
mapView.getLayerManager().getLayers().add(downloadLayer);
mapView.setZoomLevelMin(tileSource.getZoomLevelMin());
mapView.setZoomLevelMax(tileSource.getZoomLevelMax());
mapView.setZoomLevelMin(currentTileSource.getZoomLevelMin());
mapView.setZoomLevelMax(currentTileSource.getZoomLevelMax());
downloadLayer.start();
MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition;
@@ -707,29 +715,76 @@ public class MapFragment extends Fragment {
}
clearTrackLayers();
List<LatLong> line = new ArrayList<>();
List<LatLong> boundsPoints = new ArrayList<>();
boolean colorByQuality = isRxTrack(detail);
int defaultColor = colorByQuality ? ARGB_RX : ARGB_TRACK;
for (TrackDetail.TrackPoint p : detail.points) {
for (int i = 0; i < detail.points.size(); i++) {
TrackDetail.TrackPoint p = detail.points.get(i);
LatLong latLong = new LatLong(p.lat, p.lon);
line.add(latLong);
boundsPoints.add(latLong);
Marker marker = new Marker(latLong, bitmapTrackPoint, 0, 0);
int pointColor = trackPointColor(p.meta, colorByQuality, defaultColor);
Marker marker = new Marker(latLong, MapsforgeBitmaps.dot(pointColor, 12), 0, 0);
addTrackLayer(marker);
}
if (line.size() >= 2) {
Polyline polyline = new Polyline(
MapsforgeBitmaps.linePaint(Color.GREEN, 4f),
AndroidGraphicFactory.INSTANCE
);
polyline.getLatLongs().addAll(line);
addTrackLayer(polyline);
if (i > 0) {
TrackDetail.TrackPoint prev = detail.points.get(i - 1);
Double qa = rxQualityFromMeta(prev.meta);
Double qb = rxQualityFromMeta(p.meta);
Double q = qa != null && qb != null ? (qa + qb) / 2.0 : (qa != null ? qa : qb);
int segColor = colorByQuality && q != null ? qualityArgb(q) : defaultColor;
Polyline segment = new Polyline(
MapsforgeBitmaps.linePaint(segColor, 4f),
AndroidGraphicFactory.INSTANCE
);
segment.getLatLongs().add(new LatLong(prev.lat, prev.lon));
segment.getLatLongs().add(latLong);
addTrackLayer(segment);
}
}
fitBoundsOnce(boundsPoints, detail.points.size() == 1, true);
}
private static boolean isRxTrack(TrackDetail detail) {
if (detail.points == null) {
return false;
}
for (TrackDetail.TrackPoint p : detail.points) {
if (StatsExtractor.ROLE_RX.equals(p.role)) {
return true;
}
RadioSnapshot snap = RadioSnapshot.fromMeta(p.meta, null, null);
if (StatsExtractor.ROLE_RX.equals(snap.role)) {
return true;
}
}
return false;
}
private static Double rxQualityFromMeta(String meta) {
if (meta == null || meta.isBlank()) {
return null;
}
RadioSnapshot snap = RadioSnapshot.fromMeta(meta, null, null);
return snap.rxQualityPercent;
}
private static int qualityArgb(double pct) {
double p = Math.max(0.0, Math.min(100.0, pct));
int r = p < 50.0 ? (int) Math.round(255.0 * (p / 50.0)) : 255;
int g = p < 50.0 ? 255 : (int) Math.round(255.0 * (1.0 - (p - 50.0) / 50.0));
return 0xFF000000 | (r << 16) | (g << 8);
}
private static int trackPointColor(String meta, boolean colorByQuality, int fallback) {
if (!colorByQuality) {
return fallback;
}
Double q = rxQualityFromMeta(meta);
return q != null ? qualityArgb(q) : fallback;
}
private void addTrackLayer(Layer layer) {
mapView.getLayerManager().getLayers().add(layer);
trackLayers.add(layer);
@@ -1015,6 +1070,68 @@ public class MapFragment extends Fragment {
hillPathLine = null;
}
private void setupBasemapUi() {
if (mapBasemap == null) {
return;
}
List<String> labels = List.of(
getString(R.string.map_layer_scheme),
getString(R.string.map_layer_satellite)
);
suppressBasemapSpinner = true;
mapBasemap.setAdapter(new ArrayAdapter<>(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
labels
));
mapBasemap.setSelection(0, false);
suppressBasemapSpinner = false;
mapBasemap.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View v, int pos, long id) {
if (suppressBasemapSpinner) {
return;
}
TileSource next = pos == 1 ? EsriWorldImagery.INSTANCE : OpenStreetMapMapnik.INSTANCE;
if (next != currentTileSource) {
switchBasemap(next);
}
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
}
});
}
private void switchBasemap(TileSource tileSource) {
if (!isMapReady() || tileSource == null || tileSource == currentTileSource) {
return;
}
if (tileSource instanceof OpenStreetMapMapnik osm) {
osm.setUserAgent("LoraTester/1.0");
}
if (downloadLayer != null) {
downloadLayer.onPause();
mapView.getLayerManager().getLayers().remove(downloadLayer);
}
currentTileSource = tileSource;
downloadLayer = new TileDownloadLayer(
tileCache,
mapView.getModel().mapViewPosition,
currentTileSource,
AndroidGraphicFactory.INSTANCE
);
mapView.getLayerManager().getLayers().add(0, downloadLayer);
mapView.setZoomLevelMin(currentTileSource.getZoomLevelMin());
mapView.setZoomLevelMax(currentTileSource.getZoomLevelMax());
downloadLayer.start();
if (mapResumed) {
downloadLayer.onResume();
}
requestMapInvalidate();
}
private void setupHeatmapUi() {
if (mapHeatmapRadius == null) {
return;
@@ -35,4 +35,12 @@ final class MapsforgeBitmaps {
paint.setStyle(org.mapsforge.core.graphics.Style.STROKE);
return paint;
}
static org.mapsforge.core.graphics.Paint linePaint(int argb, float strokeWidth) {
int a = (argb >> 24) & 0xFF;
int r = (argb >> 16) & 0xFF;
int g = (argb >> 8) & 0xFF;
int b = argb & 0xFF;
return linePaint(AndroidGraphicFactory.INSTANCE.createColor(r, g, b, a), strokeWidth);
}
}
+14
View File
@@ -222,6 +222,20 @@
android:textSize="10sp" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/map_layer"
android:textColor="#CCCCCC"
android:textSize="9sp" />
<Spinner
android:id="@+id/mapBasemap"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
+3
View File
@@ -81,6 +81,9 @@
<string name="map_center_rx">RX</string>
<string name="map_center_both">Оба</string>
<string name="map_center_mode">Центр карты</string>
<string name="map_layer">Слой карты</string>
<string name="map_layer_scheme">Схема</string>
<string name="map_layer_satellite">Спутник</string>
<string name="map_center_unavailable">Нет координат для выбранного режима</string>
<string name="map_tool_center">Центрировать карту</string>
<string name="map_tool_track">Трекинг пути</string>