added subproxy

This commit is contained in:
2026-06-11 09:09:28 +03:00
parent 17d383ddc6
commit 94e2b772e8
17 changed files with 573 additions and 1 deletions
@@ -114,6 +114,18 @@ public class TelemetryUploader implements TelnetClient.Listener {
}
}
public boolean hasGpsFix() {
return GeoUtils.isValidCoordinate(lat, lon);
}
public double getGpsLat() {
return lat;
}
public double getGpsLon() {
return lon;
}
private Double validLat() {
return GeoUtils.isValidCoordinate(lat, lon) ? lat : null;
}
@@ -0,0 +1,19 @@
package com.grigowashere.loratester.api;
public class NearestHillResult {
public boolean ok;
public String error;
public HillPoint center;
public HillPoint hill;
public double radius_m;
public int candidates;
public static class HillPoint {
public double lat;
public double lon;
public Double elevation_m;
public Double dist_m;
public Double prominence_m;
public Boolean is_local_max;
}
}
@@ -195,6 +195,40 @@ public class ServerApi {
}
}
@SuppressWarnings("unchecked")
public Map<String, Object> getHealth() throws IOException {
Request request = new Request.Builder()
.url(baseUrl + "/api/health")
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), Map.class);
}
}
public NearestHillResult findNearestHill(double lat, double lon, int radiusM)
throws IOException {
String path = "/api/elevation/nearest-hill?lat="
+ lat
+ "&lon="
+ lon
+ "&radius_m="
+ radiusM;
Request request = new Request.Builder()
.url(baseUrl + path)
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), NearestHillResult.class);
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> postJsonMap(String path, Map<String, Object> body, boolean android)
throws IOException {
@@ -1,6 +1,7 @@
package com.grigowashere.loratester.ui;
import android.content.Context;
import android.graphics.PorterDuff;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.MotionEvent;
@@ -9,20 +10,24 @@ import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.Spinner;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import com.grigowashere.loratester.CommandPoller;
import com.grigowashere.loratester.LoraApp;
import com.grigowashere.loratester.PeerDevices;
import com.grigowashere.loratester.PeerStatsCache;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.DeviceInfo;
import com.grigowashere.loratester.api.NearestHillResult;
import com.grigowashere.loratester.api.ServerApi;
import com.grigowashere.loratester.api.TrackDetail;
import com.grigowashere.loratester.api.TrackInfo;
@@ -74,6 +79,9 @@ public class MapFragment extends Fragment {
private static final int ARGB_TX = 0xFFE94560;
private static final int ARGB_RX = 0xFF4FC3F7;
private static final int ARGB_TRACK = 0xFF00FF88;
private static final int ARGB_HILL = 0xFFFFC107;
private static final int HILL_SEARCH_RADIUS_M = 5000;
private static final long LORA_STATS_FRESH_MS = 120_000;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final DateFormat timeFormat =
@@ -91,9 +99,13 @@ public class MapFragment extends Fragment {
private TileCache tileCache;
private TextView mapStatus;
private TextView mapDistance;
private TextView mapHillStatus;
private TextView trackStatus;
private ImageView iconServer;
private ImageView iconLora;
private Button btnTrack;
private Button btnPairedTrack;
private Button btnFindHill;
private Button btnCenterMe;
private Button btnCenterTx;
private Button btnCenterRx;
@@ -104,7 +116,9 @@ public class MapFragment extends Fragment {
private boolean mapInitialized;
private NetworkMonitor networkMonitor;
private NetworkMonitor.Listener networkListener;
private PeerStatsCache peerStatsCache;
private boolean networkOnline = true;
private boolean serverConnected;
private boolean userMovedMap;
private List<DeviceInfo> lastDevices = new ArrayList<>();
private Polyline liveTrackPolyline;
@@ -113,6 +127,9 @@ public class MapFragment extends Fragment {
private Bitmap bitmapTx;
private Bitmap bitmapRx;
private Bitmap bitmapTrackPoint;
private Bitmap bitmapHill;
private Marker hillMarker;
private Polyline hillPathLine;
private float touchDownX;
private float touchDownY;
@@ -124,6 +141,7 @@ public class MapFragment extends Fragment {
trackRecorder = app.getTrackRecorder();
commandPoller = app.getCommandPoller();
networkMonitor = app.getNetworkMonitor();
peerStatsCache = app.getPeerStatsCache();
}
@Nullable
@@ -141,9 +159,13 @@ public class MapFragment extends Fragment {
mapView = view.findViewById(R.id.mapView);
mapStatus = view.findViewById(R.id.mapStatus);
mapDistance = view.findViewById(R.id.mapDistance);
mapHillStatus = view.findViewById(R.id.mapHillStatus);
iconServer = view.findViewById(R.id.iconServer);
iconLora = view.findViewById(R.id.iconLora);
trackStatus = view.findViewById(R.id.trackStatus);
btnTrack = view.findViewById(R.id.btnTrack);
btnPairedTrack = view.findViewById(R.id.btnPairedTrack);
btnFindHill = view.findViewById(R.id.btnFindHill);
btnCenterMe = view.findViewById(R.id.btnCenterMe);
btnCenterTx = view.findViewById(R.id.btnCenterTx);
btnCenterRx = view.findViewById(R.id.btnCenterRx);
@@ -163,6 +185,11 @@ public class MapFragment extends Fragment {
if (btnCenterBoth != null) {
btnCenterBoth.setOnClickListener(v -> centerOnBoth());
}
if (btnFindHill != null) {
btnFindHill.setOnClickListener(v -> findNearestHill());
}
updateConnectionIcons(lastDevices, serverConnected);
networkOnline = networkMonitor != null && networkMonitor.isOnline();
networkListener = online -> {
@@ -257,6 +284,7 @@ public class MapFragment extends Fragment {
bitmapTx = MapsforgeBitmaps.dot(ARGB_TX, 20);
bitmapRx = MapsforgeBitmaps.dot(ARGB_RX, 20);
bitmapTrackPoint = MapsforgeBitmaps.dot(ARGB_TRACK, 12);
bitmapHill = MapsforgeBitmaps.dot(ARGB_HILL, 22);
mapInitialized = true;
}
@@ -306,6 +334,7 @@ public class MapFragment extends Fragment {
removeAllDeviceMarkers();
clearTrackLayers();
clearLiveTrackLayers();
clearHillLayers();
deviceMarkers.clear();
if (downloadLayer != null) {
downloadLayer.onDestroy();
@@ -319,6 +348,11 @@ public class MapFragment extends Fragment {
bitmapTx = null;
bitmapRx = null;
bitmapTrackPoint = null;
bitmapHill = null;
iconServer = null;
iconLora = null;
btnFindHill = null;
mapHillStatus = null;
mapStatus = null;
mapDistance = null;
trackStatus = null;
@@ -635,12 +669,17 @@ public class MapFragment extends Fragment {
return;
}
requireActivity().runOnUiThread(() ->
runWhenMapReady(() -> updateMap(devices)));
runWhenMapReady(() -> {
serverConnected = true;
updateMap(devices);
}));
} catch (Exception e) {
serverConnected = false;
if (!isAdded() || !mapResumed) {
return;
}
requireActivity().runOnUiThread(() -> {
updateConnectionIcons(lastDevices, false);
if (mapStatus != null && pollHelper != null && pollHelper.canRun()) {
mapStatus.setText(getString(R.string.map_error, e.getMessage()));
}
@@ -652,6 +691,186 @@ public class MapFragment extends Fragment {
});
}
private void tintIcon(@Nullable ImageView view, int color) {
if (view == null) {
return;
}
view.setColorFilter(color, PorterDuff.Mode.SRC_IN);
}
private void updateConnectionIcons(List<DeviceInfo> devices, boolean serverOk) {
tintIcon(
iconServer,
ContextCompat.getColor(requireContext(), serverOk ? R.color.status_ok : R.color.status_off)
);
tintIcon(iconLora, resolveLoraLinkColor(devices));
}
private int resolveLoraLinkColor(List<DeviceInfo> devices) {
if (uploader == null || !uploader.isTelnetConnected()) {
return ContextCompat.getColor(requireContext(), R.color.status_off);
}
PeerDevices.Result peer = PeerDevices.resolve(devices, uploader.getDeviceId());
if (!peer.bothOnline()) {
return ContextCompat.getColor(requireContext(), R.color.status_off);
}
if (hasLoraLinkData(devices, peer)) {
return ContextCompat.getColor(requireContext(), R.color.status_ok);
}
return ContextCompat.getColor(requireContext(), R.color.status_warn);
}
private boolean hasLoraLinkData(List<DeviceInfo> devices, PeerDevices.Result peer) {
if (peer.peerId != null) {
for (DeviceInfo d : devices) {
if (peer.peerId.equals(d.device_id)) {
if (d.rssi != null || d.range_m != null) {
return true;
}
}
}
}
StatsExtractor.ExtractedStats stats = uploader.getLastStats();
if (stats != null && (stats.rssiDbm != null || stats.rssi != null || stats.rangeM != null)) {
return true;
}
if (peerStatsCache != null) {
PeerStatsCache.Snapshot snap = peerStatsCache.get();
if (snap != null
&& System.currentTimeMillis() - snap.atMs < LORA_STATS_FRESH_MS
&& snap.rssi != null) {
return true;
}
}
return false;
}
@Nullable
private LatLong resolveSelfPosition() {
if (uploader != null && uploader.hasGpsFix()) {
return new LatLong(uploader.getGpsLat(), uploader.getGpsLon());
}
if (uploader != null) {
String myId = uploader.getDeviceId();
for (DeviceInfo d : lastDevices) {
if (myId.equals(d.device_id) && GeoUtils.isValidCoordinate(d.lat, d.lon)) {
return new LatLong(d.lat, d.lon);
}
}
}
return null;
}
private void findNearestHill() {
if (uploader == null || !isAdded()) {
return;
}
LatLong self = resolveSelfPosition();
if (self == null) {
Toast.makeText(requireContext(), R.string.map_find_hill_no_gps, Toast.LENGTH_SHORT).show();
return;
}
if (mapHillStatus != null) {
mapHillStatus.setVisibility(View.VISIBLE);
mapHillStatus.setText(R.string.map_find_hill_search);
}
if (btnFindHill != null) {
btnFindHill.setEnabled(false);
}
final double lat = self.latitude;
final double lon = self.longitude;
executor.execute(() -> {
try {
NearestHillResult result = uploader.getServerApi()
.findNearestHill(lat, lon, HILL_SEARCH_RADIUS_M);
if (!isAdded()) {
return;
}
requireActivity().runOnUiThread(() -> {
if (btnFindHill != null) {
btnFindHill.setEnabled(true);
}
if (result == null || !result.ok || result.hill == null) {
if (mapHillStatus != null) {
mapHillStatus.setVisibility(View.VISIBLE);
String err = result != null && result.error != null
? result.error
: getString(R.string.map_find_hill_none);
mapHillStatus.setText(getString(R.string.map_find_hill_error, err));
}
return;
}
showHillOnMap(lat, lon, result);
});
} catch (Exception e) {
if (!isAdded()) {
return;
}
requireActivity().runOnUiThread(() -> {
if (btnFindHill != null) {
btnFindHill.setEnabled(true);
}
if (mapHillStatus != null) {
mapHillStatus.setVisibility(View.VISIBLE);
mapHillStatus.setText(getString(R.string.map_find_hill_error, e.getMessage()));
}
});
}
});
}
private void showHillOnMap(double fromLat, double fromLon, NearestHillResult result) {
if (!isMapReady() || result.hill == null) {
return;
}
NearestHillResult.HillPoint hill = result.hill;
LatLong hillPos = new LatLong(hill.lat, hill.lon);
clearHillLayers();
hillMarker = new Marker(hillPos, bitmapHill, 0, 0);
mapView.getLayerManager().getLayers().add(hillMarker);
List<LatLong> path = new ArrayList<>();
path.add(new LatLong(fromLat, fromLon));
path.add(hillPos);
hillPathLine = new Polyline(
MapsforgeBitmaps.linePaint(Color.RED, 3f),
AndroidGraphicFactory.INSTANCE
);
hillPathLine.getLatLongs().addAll(path);
mapView.getLayerManager().getLayers().add(hillPathLine);
double elev = hill.elevation_m != null ? hill.elevation_m : 0.0;
double gain = hill.prominence_m != null ? hill.prominence_m : 0.0;
double dist = hill.dist_m != null ? hill.dist_m : GeoUtils.haversineMeters(fromLat, fromLon, hill.lat, hill.lon);
if (mapHillStatus != null) {
mapHillStatus.setVisibility(View.VISIBLE);
mapHillStatus.setText(getString(R.string.map_find_hill_result, elev, gain, dist));
}
List<LatLong> bounds = new ArrayList<>();
bounds.add(new LatLong(fromLat, fromLon));
bounds.add(hillPos);
fitBoundsOnce(bounds, false, true);
mapView.invalidate();
}
private void clearHillLayers() {
if (mapView == null) {
hillMarker = null;
hillPathLine = null;
return;
}
if (hillMarker != null) {
mapView.getLayerManager().getLayers().remove(hillMarker);
}
if (hillPathLine != null) {
mapView.getLayerManager().getLayers().remove(hillPathLine);
}
hillMarker = null;
hillPathLine = null;
}
private void updateMap(List<DeviceInfo> devices) {
if (!isMapReady()) {
return;
@@ -707,6 +926,7 @@ public class MapFragment extends Fragment {
));
}
updateGpsDistance();
updateConnectionIcons(lastDevices, serverConnected);
if (!boundsPoints.isEmpty() && !userMovedMap && !MapSessionState.initialFitDone) {
fitBoundsOnce(boundsPoints, onMap == 1, false);
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M14,6l-3.75,5 2.75,3.5L9,18H3l8.5,-10.5L14,6zM17.5,10.5L14,15h6l-2.5,-4.5z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,3C7.03,3 3,7.03 3,12h2c0,-3.87 3.13,-7 7,-7s7,3.13 7,7h2c0,-4.97 -4.03,-9 -9,-9zM12,7c-2.76,0 -5,2.24 -5,5h2c0,-1.66 1.34,-3 3,-3s3,1.34 3,3h2c0,-2.76 -2.24,-5 -5,-5zM12,11c-0.55,0 -1,0.45 -1,1h2c0,-0.55 -0.45,-1 -1,-1zM4.5,14.5L2,17l2.5,2.5 1.4,-1.4L4.8,17l1.1,-1.1 -1.4,-1.4zM19.5,14.5l-1.4,1.4 1.1,1.1 -1.1,1.1 1.4,1.4L22,17l-2.5,-2.5z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M4,18h16v2H4v-2zM6,15h2v2H6v-2zM16,15h2v2h-2v-2zM8,12h8v2H8v-2zM10,9h4v2h-4V9zM12,6c-2.2,0 -4,1.8 -4,4h2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2h2c0,-2.2 -1.8,-4 -4,-4z" />
</vector>
+59
View File
@@ -25,6 +25,44 @@
android:orientation="vertical"
android:padding="6dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/iconServer"
android:layout_width="20dp"
android:layout_height="20dp"
android:contentDescription="@string/status_server"
android:src="@drawable/ic_link_server" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/status_server_short"
android:textColor="#AAAAAA"
android:textSize="9sp" />
<ImageView
android:id="@+id/iconLora"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="10dp"
android:contentDescription="@string/status_lora"
android:src="@drawable/ic_link_lora" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/status_lora_short"
android:textColor="#AAAAAA"
android:textSize="9sp" />
</LinearLayout>
<TextView
android:id="@+id/mapStatus"
android:layout_width="match_parent"
@@ -91,6 +129,27 @@
android:textSize="10sp" />
</LinearLayout>
<Button
android:id="@+id/btnFindHill"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:drawableStart="@drawable/ic_hill"
android:drawablePadding="6dp"
android:minHeight="34dp"
android:text="@string/map_find_hill"
android:textSize="10sp" />
<TextView
android:id="@+id/mapHillStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#FFC107"
android:textSize="9sp"
android:visibility="gone" />
<TextView
android:id="@+id/mapLegend"
android:layout_width="match_parent"
+3
View File
@@ -2,4 +2,7 @@
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="status_ok">#FF00FF88</color>
<color name="status_warn">#FFFFC107</color>
<color name="status_off">#FF888888</color>
</resources>
+10
View File
@@ -81,5 +81,15 @@
<string name="map_center_rx">RX</string>
<string name="map_center_both">Оба</string>
<string name="map_gps_distance">GPS между устройствами: %1$s m</string>
<string name="status_server">Связь с сервером</string>
<string name="status_server_short">сервер</string>
<string name="status_lora">Связь LoRa с другим устройством</string>
<string name="status_lora_short">LoRa</string>
<string name="map_find_hill">Ближайшая возвышенность</string>
<string name="map_find_hill_search">Поиск возвышенности…</string>
<string name="map_find_hill_result">Возвышенность: %1$.0f m · +%2$.0f m · %3$.0f m</string>
<string name="map_find_hill_none">В радиусе 5 км возвышенностей не найдено</string>
<string name="map_find_hill_no_gps">Нужен GPS для поиска</string>
<string name="map_find_hill_error">Ошибка: %1$s</string>
<string name="chat_self_label">Вы</string>
</resources>