offline track

This commit is contained in:
2026-06-19 11:09:20 +03:00
parent 4891933879
commit 8812cf9b40
23 changed files with 924 additions and 57 deletions
@@ -31,6 +31,7 @@ public class LoraApp extends Application {
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
String deviceId = settingsRepository.getOrCreateDeviceId();
trackRecorder = new TrackRecorder(
this,
serverApi,
telemetryUploader,
deviceId,
@@ -103,6 +104,7 @@ public class LoraApp extends Application {
}
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
trackRecorder = new TrackRecorder(
this,
serverApi,
telemetryUploader,
settingsRepository.getOrCreateDeviceId(),
@@ -114,6 +114,28 @@ public class ServerApi {
postJson("/api/tracks/" + trackId + "/finish", new HashMap<>(), true);
}
public long syncTrack(
String deviceId,
Long trackId,
double startedAt,
List<Map<String, Object>> points,
boolean finish
) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("device_id", deviceId);
if (trackId != null && trackId > 0) {
body.put("track_id", trackId);
}
if (startedAt > 0) {
body.put("started_at", startedAt);
}
body.put("points", points != null ? points : List.of());
body.put("finish", finish);
Map<String, Object> resp = postJsonMap("/api/tracks/sync", body, true);
Number id = (Number) resp.get("track_id");
return id != null ? id.longValue() : (trackId != null ? trackId : -1);
}
public List<TrackInfo> listTracks(String deviceId) throws IOException {
return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST);
}
@@ -32,10 +32,10 @@ public class LocationTracker {
return;
}
LocationRequest request = new LocationRequest.Builder(
Priority.PRIORITY_HIGH_ACCURACY, 10_000L
Priority.PRIORITY_HIGH_ACCURACY, 1_000L
)
.setMinUpdateIntervalMillis(5_000L)
.setMaxUpdateDelayMillis(15_000L)
.setMinUpdateIntervalMillis(1_000L)
.setMaxUpdateDelayMillis(2_000L)
.setWaitForAccurateLocation(false)
.build();
@@ -0,0 +1,161 @@
package com.grigowashere.loratester.track;
import android.content.Context;
import android.util.Log;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Persists pending track points and recording state across network outages / app restarts. */
public class TrackPointQueue {
private static final String TAG = "TrackPointQueue";
private static final String FILE_NAME = "track_pending_points.json";
private static final Gson GSON = new Gson();
private static final Type SNAPSHOT_TYPE = new TypeToken<Snapshot>() {}.getType();
private final File queueFile;
public TrackPointQueue(Context context) {
queueFile = new File(context.getFilesDir(), FILE_NAME);
}
public static final class Snapshot {
public long trackId = -1;
public boolean recording;
/** Stopped locally; waiting for upload + finish. */
public boolean pendingSync;
public int totalPoints;
public double startedAt;
public String deviceId;
public List<PendingPoint> pending = new ArrayList<>();
}
public static final class PendingPoint {
public double ts;
public double lat;
public double lon;
public Double altitude_gps;
public Double rssi;
public String role;
public String meta;
static PendingPoint fromMap(Map<String, Object> point) {
PendingPoint p = new PendingPoint();
p.ts = toDouble(point.get("ts"));
p.lat = toDouble(point.get("lat"));
p.lon = toDouble(point.get("lon"));
Object alt = point.get("altitude_gps");
if (alt instanceof Number) {
p.altitude_gps = ((Number) alt).doubleValue();
}
Object rssi = point.get("rssi");
if (rssi instanceof Number) {
p.rssi = ((Number) rssi).doubleValue();
}
Object role = point.get("role");
if (role != null) {
p.role = String.valueOf(role);
}
Object meta = point.get("meta");
if (meta != null) {
p.meta = String.valueOf(meta);
}
return p;
}
Map<String, Object> toMap() {
Map<String, Object> point = new HashMap<>();
point.put("ts", ts);
point.put("lat", lat);
point.put("lon", lon);
if (altitude_gps != null) {
point.put("altitude_gps", altitude_gps);
}
if (rssi != null) {
point.put("rssi", rssi);
}
if (role != null) {
point.put("role", role);
}
if (meta != null) {
point.put("meta", meta);
}
return point;
}
private static double toDouble(Object value) {
if (value instanceof Number) {
return ((Number) value).doubleValue();
}
return 0.0;
}
}
public synchronized Snapshot load() {
if (!queueFile.exists()) {
return null;
}
try (FileReader reader = new FileReader(queueFile)) {
Snapshot snap = GSON.fromJson(reader, SNAPSHOT_TYPE);
if (snap == null) {
return null;
}
if (snap.pending == null) {
snap.pending = new ArrayList<>();
}
return snap;
} catch (Exception e) {
Log.w(TAG, "load failed", e);
return null;
}
}
public synchronized void save(
String deviceId,
long trackId,
boolean recording,
boolean pendingSync,
int totalPoints,
double startedAt,
List<Map<String, Object>> pending
) {
Snapshot snap = new Snapshot();
snap.deviceId = deviceId;
snap.trackId = trackId;
snap.recording = recording;
snap.pendingSync = pendingSync;
snap.totalPoints = totalPoints;
snap.startedAt = startedAt;
snap.pending = new ArrayList<>();
if (pending != null) {
for (Map<String, Object> point : pending) {
snap.pending.add(PendingPoint.fromMap(point));
}
}
persist(snap);
}
public synchronized void clear() {
if (queueFile.exists() && !queueFile.delete()) {
Log.w(TAG, "clear failed");
}
}
private void persist(Snapshot snap) {
try (FileWriter writer = new FileWriter(queueFile)) {
GSON.toJson(snap, writer);
} catch (Exception e) {
Log.w(TAG, "persist failed", e);
}
}
}
@@ -1,9 +1,11 @@
package com.grigowashere.loratester.track;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.ServerApi;
import com.grigowashere.loratester.location.GeoUtils;
@@ -19,19 +21,26 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public class TrackRecorder {
private static final String TAG = "TrackRecorder";
private static final long SAMPLE_MS = 1000;
private static final long FLUSH_MS = 30_000;
private static final long FLUSH_MS = 10_000;
/** Local-only track before first server sync (offline start). */
private static final long LOCAL_TRACK_ID = 0;
/** No time limit — pending points stay on disk until track stop or successful upload. */
public static final int MAX_OFFLINE_BUFFER_POINTS = 500_000;
public interface Listener {
void onStateChanged(boolean recording, int pointCount, long trackId);
void onError(String message);
default void onSyncComplete(long trackId, int pointCount) {
}
default void onPointRecorded(double lat, double lon) {
}
}
@@ -39,6 +48,8 @@ public class TrackRecorder {
private final ServerApi serverApi;
private final TelemetryUploader uploader;
private final NetworkMonitor networkMonitor;
private final TrackPointQueue pendingQueue;
private final Context appContext;
private final String deviceId;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
@@ -53,7 +64,9 @@ public class TrackRecorder {
private volatile double lon = Double.NaN;
private volatile double altitude = Double.NaN;
private volatile long trackId = -1;
private volatile double localStartedAt;
private volatile boolean recording;
private volatile boolean pendingSync;
private final List<Map<String, Object>> buffer = new ArrayList<>();
private int totalPoints;
private ScheduledFuture<?> sampleTask;
@@ -62,6 +75,7 @@ public class TrackRecorder {
private Listener pairedListener;
public TrackRecorder(
Context context,
ServerApi serverApi,
TelemetryUploader uploader,
String deviceId,
@@ -71,11 +85,14 @@ public class TrackRecorder {
this.uploader = uploader;
this.deviceId = deviceId;
this.networkMonitor = networkMonitor;
this.appContext = context.getApplicationContext();
this.pendingQueue = new TrackPointQueue(this.appContext);
networkMonitor.addListener(online -> {
if (online && recording) {
executor.execute(this::flushBuffer);
if (online) {
executor.execute(this::syncWhenOnline);
}
});
restoreIfNeeded();
}
public void setListener(Listener listener) {
@@ -100,6 +117,10 @@ public class TrackRecorder {
return recording;
}
public boolean hasPendingSync() {
return pendingSync;
}
public int getPointCount() {
return totalPoints;
}
@@ -108,15 +129,21 @@ public class TrackRecorder {
return trackId;
}
public void start() {
if (recording) {
return;
public int getPendingFlushCount() {
synchronized (buffer) {
return buffer.size();
}
if (!networkMonitor.isOnline()) {
notifyError("Нужна сеть для начала трека");
}
public void start() {
if (recording || pendingSync) {
return;
}
executor.execute(() -> {
if (!networkMonitor.isOnline()) {
startLocalRecording();
return;
}
try {
long id = serverApi.startTrack(deviceId);
synchronized (buffer) {
@@ -124,7 +151,10 @@ public class TrackRecorder {
}
totalPoints = 0;
trackId = id;
localStartedAt = System.currentTimeMillis() / 1000.0;
recording = true;
pendingSync = false;
persistState(false);
startTimers();
notifyState();
} catch (Exception e) {
@@ -134,6 +164,21 @@ public class TrackRecorder {
});
}
private void startLocalRecording() {
synchronized (buffer) {
buffer.clear();
}
totalPoints = 0;
trackId = LOCAL_TRACK_ID;
localStartedAt = System.currentTimeMillis() / 1000.0;
recording = true;
pendingSync = false;
persistState(false);
startTimers();
notifyState();
notifyError(appContext.getString(R.string.track_offline_started));
}
public void stop() {
if (!recording) {
return;
@@ -141,22 +186,145 @@ public class TrackRecorder {
recording = false;
stopTimers();
executor.execute(() -> {
try {
flushBuffer();
if (trackId > 0) {
serverApi.finishTrack(trackId);
}
} catch (Exception e) {
Log.e(TAG, "stop track failed", e);
notifyError(e.getMessage());
} finally {
trackId = -1;
notifyState();
boolean synced = completeTrackUpload(true);
if (!synced) {
pendingSync = true;
persistState(true);
notifyError(appContext.getString(R.string.track_sync_pending));
} else {
resetAfterSuccessfulSync(-1);
}
notifyState();
});
}
private void restoreIfNeeded() {
TrackPointQueue.Snapshot snap = pendingQueue.load();
if (snap == null) {
return;
}
if (snap.deviceId != null && !snap.deviceId.equals(deviceId)) {
return;
}
trackId = snap.trackId;
totalPoints = snap.totalPoints;
localStartedAt = snap.startedAt > 0 ? snap.startedAt : System.currentTimeMillis() / 1000.0;
pendingSync = snap.pendingSync;
synchronized (buffer) {
buffer.clear();
if (snap.pending != null) {
for (TrackPointQueue.PendingPoint point : snap.pending) {
buffer.add(point.toMap());
}
}
}
if (snap.recording) {
recording = true;
Log.i(TAG, "restored active track " + trackId + ", pending=" + buffer.size());
startTimers();
executor.execute(this::syncWhenOnline);
notifyState();
return;
}
if (snap.pendingSync) {
Log.i(TAG, "restored pending sync track " + trackId + ", points=" + buffer.size());
executor.execute(this::syncWhenOnline);
notifyState();
}
}
private void syncWhenOnline() {
if (!networkMonitor.isOnline()) {
return;
}
if (recording) {
if (trackId == LOCAL_TRACK_ID) {
promoteLocalTrackToServer();
} else if (trackId > 0) {
flushBuffer();
}
return;
}
if (pendingSync) {
if (completeTrackUpload(true)) {
long finishedId = trackId > 0 ? trackId : -1;
int count = totalPoints;
resetAfterSuccessfulSync(-1);
notifySyncComplete(finishedId, count);
notifyState();
}
}
}
private void promoteLocalTrackToServer() {
if (trackId != LOCAL_TRACK_ID || !recording) {
return;
}
try {
long id = serverApi.startTrack(deviceId);
trackId = id;
flushBuffer();
persistState(false);
Log.i(TAG, "promoted local track to server id " + id);
} catch (Exception e) {
Log.w(TAG, "promote local track failed", e);
}
}
private boolean completeTrackUpload(boolean finish) {
if (!networkMonitor.isOnline()) {
return false;
}
List<Map<String, Object>> batch;
synchronized (buffer) {
if (buffer.isEmpty() && !finish) {
return true;
}
batch = new ArrayList<>(buffer);
buffer.clear();
}
try {
Long serverTrackId = trackId > 0 ? trackId : null;
long resultId = serverApi.syncTrack(
deviceId,
serverTrackId,
localStartedAt,
batch,
finish
);
if (trackId <= 0) {
trackId = resultId;
}
persistState(false);
if (finish && batch.isEmpty()) {
return true;
}
return true;
} catch (Exception e) {
Log.e(TAG, "track sync failed", e);
synchronized (buffer) {
buffer.addAll(0, batch);
}
persistState(finish);
notifyError(e.getMessage());
return false;
}
}
private void resetAfterSuccessfulSync(long finishedTrackId) {
trackId = -1;
pendingSync = false;
recording = false;
synchronized (buffer) {
buffer.clear();
}
pendingQueue.clear();
}
private void startTimers() {
if (sampleTask != null || flushTask != null) {
return;
}
sampleTask = scheduler.scheduleAtFixedRate(
() -> executor.execute(this::samplePoint),
SAMPLE_MS,
@@ -210,12 +378,58 @@ public class TrackRecorder {
}
synchronized (buffer) {
buffer.add(point);
if (buffer.size() > MAX_OFFLINE_BUFFER_POINTS) {
buffer.remove(0);
Log.w(TAG, "offline buffer trimmed at " + MAX_OFFLINE_BUFFER_POINTS);
}
}
totalPoints++;
persistState(false);
notifyState();
notifyPoint(lat, lon);
}
private void persistState(boolean markPendingSync) {
List<Map<String, Object>> copy;
synchronized (buffer) {
copy = new ArrayList<>(buffer);
}
pendingQueue.save(
deviceId,
trackId,
recording,
markPendingSync || pendingSync,
totalPoints,
localStartedAt,
copy
);
}
private void flushBuffer() {
if (!recording || trackId <= 0 || !networkMonitor.isOnline()) {
return;
}
List<Map<String, Object>> batch;
synchronized (buffer) {
if (buffer.isEmpty()) {
return;
}
batch = new ArrayList<>(buffer);
buffer.clear();
}
try {
serverApi.addTrackPoints(trackId, batch);
persistState(false);
} catch (Exception e) {
Log.e(TAG, "flush points failed", e);
synchronized (buffer) {
buffer.addAll(0, batch);
}
persistState(false);
notifyError(e.getMessage());
}
}
private void notifyPoint(double lat, double lon) {
mainHandler.post(() -> {
if (listener != null) {
@@ -227,27 +441,15 @@ public class TrackRecorder {
});
}
private void flushBuffer() {
if (trackId < 0) {
return;
}
List<Map<String, Object>> batch;
synchronized (buffer) {
if (buffer.isEmpty()) {
return;
private void notifySyncComplete(long trackId, int pointCount) {
mainHandler.post(() -> {
if (listener != null) {
listener.onSyncComplete(trackId, pointCount);
}
batch = new ArrayList<>(buffer);
buffer.clear();
}
try {
serverApi.addTrackPoints(trackId, batch);
} catch (Exception e) {
Log.e(TAG, "flush points failed", e);
synchronized (buffer) {
buffer.addAll(0, batch);
if (pairedListener != null) {
pairedListener.onSyncComplete(trackId, pointCount);
}
notifyError(e.getMessage());
}
});
}
private void notifyState() {
@@ -0,0 +1,25 @@
package com.grigowashere.loratester.ui;
import com.grigowashere.loratester.api.DeviceInfo;
/** Human-readable device labels (matches web deviceDisplayName). */
public final class DeviceNames {
private DeviceNames() {
}
public static String displayName(DeviceInfo device, String fallbackId) {
if (device != null && device.label != null) {
String label = device.label.trim();
if (!label.isEmpty()
&& device.device_id != null
&& !label.equals(device.device_id)) {
return label;
}
}
if (fallbackId != null && !fallbackId.isEmpty()) {
return fallbackId;
}
return "";
}
}
@@ -111,6 +111,8 @@ public class MapFragment extends Fragment {
private TileCache tileCache;
private TextView mapStatus;
private TextView mapDistance;
private TextView mapRxQuality;
private TextView mapTrackStatus;
private TextView mapHillStatus;
private TextView trackStatus;
private ImageView iconServer;
@@ -189,6 +191,8 @@ public class MapFragment extends Fragment {
mapView = view.findViewById(R.id.mapView);
mapStatus = view.findViewById(R.id.mapStatus);
mapDistance = view.findViewById(R.id.mapDistance);
mapRxQuality = view.findViewById(R.id.mapRxQuality);
mapTrackStatus = view.findViewById(R.id.mapTrackStatus);
mapHillStatus = view.findViewById(R.id.mapHillStatus);
iconServer = view.findViewById(R.id.iconServer);
iconLora = view.findViewById(R.id.iconLora);
@@ -221,9 +225,29 @@ public class MapFragment extends Fragment {
networkOnline = networkMonitor != null && networkMonitor.isOnline();
networkListener = online -> {
boolean wasOnline = networkOnline;
networkOnline = online;
if (isAdded() && mapStatus != null) {
requireActivity().runOnUiThread(this::updateNetworkStatusLine);
requireActivity().runOnUiThread(() -> {
updateNetworkStatusLine();
updateTrackStatusUi();
});
}
if (isAdded() && trackRecorder != null && trackRecorder.isRecording()) {
int pending = trackRecorder.getPendingFlushCount();
if (!online && wasOnline) {
requireActivity().runOnUiThread(() -> Toast.makeText(
requireContext(),
getString(R.string.track_offline_toast, pending),
Toast.LENGTH_LONG
).show());
} else if (online && !wasOnline && pending > 0) {
requireActivity().runOnUiThread(() -> Toast.makeText(
requireContext(),
getString(R.string.track_online_toast, pending),
Toast.LENGTH_SHORT
).show());
}
}
};
if (networkMonitor != null) {
@@ -465,6 +489,8 @@ public class MapFragment extends Fragment {
heatmapActive = false;
mapStatus = null;
mapDistance = null;
mapRxQuality = null;
mapTrackStatus = null;
trackStatus = null;
btnTrack = null;
btnPairedTrack = null;
@@ -507,9 +533,7 @@ public class MapFragment extends Fragment {
btnTrack.setActivated(recording);
btnTrack.setContentDescription(getString(
recording ? R.string.track_stop : R.string.track_start));
if (trackStatus != null) {
trackStatus.setText(getString(R.string.track_status, pointCount));
}
updateTrackStatusUi();
if (recording && pointCount <= 1) {
clearLiveTrackLayers();
}
@@ -523,9 +547,26 @@ public class MapFragment extends Fragment {
@Override
public void onError(String message) {
if (isAdded() && trackStatus != null) {
if (!isAdded()) {
return;
}
if (trackStatus != null) {
trackStatus.setText(getString(R.string.track_error, message));
}
Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show();
}
@Override
public void onSyncComplete(long trackId, int pointCount) {
if (!isAdded()) {
return;
}
Toast.makeText(
requireContext(),
getString(R.string.track_sync_done, trackId, pointCount),
Toast.LENGTH_LONG
).show();
loadTrackList();
}
@Override
@@ -601,10 +642,48 @@ public class MapFragment extends Fragment {
liveTrackMarker = null;
}
private void updateTrackStatusUi() {
if (trackRecorder == null) {
return;
}
if (!trackRecorder.isRecording()) {
if (trackStatus != null) {
trackStatus.setText("");
}
if (mapTrackStatus != null) {
mapTrackStatus.setVisibility(View.GONE);
}
return;
}
int total = trackRecorder.getPointCount();
int pending = trackRecorder.getPendingFlushCount();
CharSequence line;
if (!networkOnline && pending > 0) {
line = getString(R.string.track_status_offline, total, pending);
} else {
line = getString(R.string.track_status_online, total);
}
if (trackStatus != null) {
trackStatus.setText(line);
}
if (mapTrackStatus != null) {
mapTrackStatus.setVisibility(View.VISIBLE);
if (!networkOnline && pending > 0) {
mapTrackStatus.setText(getString(R.string.map_track_status_offline, total, pending));
} else {
mapTrackStatus.setText(getString(R.string.map_track_status_online, total));
}
}
}
private void toggleTracking() {
if (trackRecorder.isRecording()) {
trackRecorder.stop();
} else {
if (trackRecorder.hasPendingSync()) {
Toast.makeText(requireContext(), R.string.track_sync_pending, Toast.LENGTH_SHORT).show();
return;
}
trackRecorder.start();
}
}
@@ -1414,6 +1493,7 @@ public class MapFragment extends Fragment {
));
}
updateGpsDistance();
updateRxQuality();
updateConnectionIcons(lastDevices, serverConnected);
checkHeatmapGpsFollow();
@@ -1423,6 +1503,53 @@ public class MapFragment extends Fragment {
}
}
private void updateRxQuality() {
if (mapRxQuality == null) {
return;
}
Double quality = resolveRxQualityPercent();
if (quality != null) {
mapRxQuality.setVisibility(View.VISIBLE);
mapRxQuality.setText(getString(
R.string.map_rx_quality,
String.format(Locale.US, "%.0f", quality)));
} else {
mapRxQuality.setVisibility(View.VISIBLE);
mapRxQuality.setText(R.string.map_rx_quality_unknown);
}
}
private Double resolveRxQualityPercent() {
StatsExtractor.ExtractedStats localStats =
uploader != null ? uploader.getLastStats() : null;
if (localStats != null) {
RadioSnapshot localSnap = RadioSnapshot.fromExtracted(localStats);
if (StatsExtractor.ROLE_RX.equals(localSnap.role)
&& localSnap.rxQualityPercent != null) {
return localSnap.rxQualityPercent;
}
}
for (DeviceInfo d : lastDevices) {
if (!StatsExtractor.ROLE_RX.equals(d.role)) {
continue;
}
RadioSnapshot snap = RadioSnapshot.fromMeta(d.meta, d.role, d.rssi);
if (snap.rxQualityPercent != null) {
return snap.rxQualityPercent;
}
}
if (peerStatsCache != null) {
PeerStatsCache.Snapshot push = peerStatsCache.get();
if (push != null && StatsExtractor.ROLE_RX.equals(push.role) && push.meta != null) {
RadioSnapshot snap = RadioSnapshot.fromMeta(push.meta, push.role, push.rssi);
if (snap.rxQualityPercent != null) {
return snap.rxQualityPercent;
}
}
}
return null;
}
private void updateGpsDistance() {
if (mapDistance == null) {
return;
@@ -159,24 +159,48 @@ public class RadioComparePanel extends LinearLayout {
String peerId,
Set<String> changedLocal,
Set<String> changedPeer
) {
bindByRole(
panel,
local,
peer,
localId,
peerId,
localId,
peerId,
changedLocal,
changedPeer
);
}
public static void bindByRole(
RadioComparePanel panel,
RadioSnapshot local,
RadioSnapshot peer,
String localId,
String peerId,
String localDisplayName,
String peerDisplayName,
Set<String> changedLocal,
Set<String> changedPeer
) {
RadioSnapshot tx = local;
RadioSnapshot rx = peer;
String txId = localId;
String rxId = peerId;
String txName = localDisplayName != null ? localDisplayName : localId;
String rxName = peerDisplayName != null ? peerDisplayName : peerId;
Set<String> chTx = changedLocal;
Set<String> chRx = changedPeer;
if (StatsExtractor.ROLE_RX.equals(local != null ? local.role : null)) {
tx = peer;
rx = local;
txId = peerId;
rxId = localId;
txName = peerDisplayName != null ? peerDisplayName : peerId;
rxName = localDisplayName != null ? localDisplayName : localId;
chTx = changedPeer;
chRx = changedLocal;
}
if (tx == null) tx = RadioSnapshot.empty();
if (rx == null) rx = RadioSnapshot.empty();
panel.bind(tx, rx, txId, rxId, chTx, chRx);
panel.bind(tx, rx, txName, rxName, chTx, chRx);
}
private static String str(String v) {
@@ -23,11 +23,13 @@ import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.DeviceInfo;
import com.grigowashere.loratester.api.TelemetryHistoryItem;
import com.grigowashere.loratester.location.GeoUtils;
import com.grigowashere.loratester.model.RadioSnapshot;
import com.grigowashere.loratester.telnet.StatsExtractor;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -43,6 +45,7 @@ public class StatsFragment extends Fragment {
private PeerStatsCache peerStatsCache;
private TextView statsStatus;
private TextView statsPeerWarning;
private TextView statsDistance;
private RadioComparePanel radioComparePanel;
private RecyclerView statsHistoryList;
private final HistoryAdapter historyAdapter = new HistoryAdapter();
@@ -54,6 +57,10 @@ public class StatsFragment extends Fragment {
private String cachedPeerId;
private String cachedPeerError;
private int cachedDeviceCount;
private String cachedSelfDisplayName;
private String cachedPeerDisplayName;
private DeviceInfo cachedTxDev;
private DeviceInfo cachedRxDev;
private final TelemetryUploader.StatsListener statsListener = stats -> postRender();
@@ -80,6 +87,7 @@ public class StatsFragment extends Fragment {
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
statsStatus = view.findViewById(R.id.statsStatus);
statsPeerWarning = view.findViewById(R.id.statsPeerWarning);
statsDistance = view.findViewById(R.id.statsDistance);
radioComparePanel = view.findViewById(R.id.radioComparePanel);
statsHistoryList = view.findViewById(R.id.statsHistoryList);
statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext()));
@@ -157,6 +165,7 @@ public class StatsFragment extends Fragment {
}
statsStatus = null;
statsPeerWarning = null;
statsDistance = null;
radioComparePanel = null;
statsHistoryList = null;
pollHelper = null;
@@ -183,13 +192,17 @@ public class StatsFragment extends Fragment {
String deviceId = uploader.getDeviceId();
statsStatus.setText(getString(
R.string.stats_status,
deviceId,
cachedSelfDisplayName != null ? cachedSelfDisplayName : deviceId,
uploader.isTelnetConnected()
? getString(R.string.connected) : getString(R.string.disconnected)
));
executor.execute(() -> {
List<TelemetryHistoryItem> history = null;
DeviceInfo txDev = null;
DeviceInfo rxDev = null;
String selfName = deviceId;
String peerName = cachedPeerId;
try {
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
cachedDeviceCount = devices.size();
@@ -200,12 +213,23 @@ public class StatsFragment extends Fragment {
DeviceInfo self = null;
DeviceInfo peerDev = null;
for (DeviceInfo d : devices) {
if (StatsExtractor.ROLE_TX.equals(d.role)) {
txDev = d;
} else if (StatsExtractor.ROLE_RX.equals(d.role)) {
rxDev = d;
}
if (deviceId.equals(d.device_id)) {
self = d;
} else if (peer.peerId != null && peer.peerId.equals(d.device_id)) {
peerDev = d;
}
}
selfName = DeviceNames.displayName(self, deviceId);
peerName = DeviceNames.displayName(peerDev, peer.peerId);
cachedSelfDisplayName = selfName;
cachedPeerDisplayName = peerName;
cachedTxDev = txDev;
cachedRxDev = rxDev;
StatsExtractor.ExtractedStats localStats = uploader.getLastStats();
snapLocal = localStats != null
@@ -264,10 +288,32 @@ public class StatsFragment extends Fragment {
snapPeer,
uploader.getDeviceId(),
cachedPeerId,
cachedSelfDisplayName,
cachedPeerDisplayName,
chLocal,
chPeer
);
updateStatsDistance();
prevLocal = snapLocal;
prevPeer = snapPeer;
}
private void updateStatsDistance() {
if (statsDistance == null) {
return;
}
DeviceInfo tx = cachedTxDev;
DeviceInfo rx = cachedRxDev;
if (tx != null && rx != null
&& GeoUtils.isValidCoordinate(tx.lat, tx.lon)
&& GeoUtils.isValidCoordinate(rx.lat, rx.lon)) {
double dist = GeoUtils.haversineMeters(tx.lat, tx.lon, rx.lat, rx.lon);
statsDistance.setVisibility(View.VISIBLE);
statsDistance.setText(getString(
R.string.stats_gps_distance,
String.format(Locale.US, "%.0f", dist)));
} else {
statsDistance.setVisibility(View.GONE);
}
}
}
+18
View File
@@ -80,6 +80,24 @@
android:textColor="#00FF88"
android:textSize="9sp"
android:visibility="gone" />
<TextView
android:id="@+id/mapRxQuality"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#4FC3F7"
android:textSize="9sp"
android:visibility="gone" />
<TextView
android:id="@+id/mapTrackStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#FF9800"
android:textSize="9sp"
android:visibility="gone" />
</LinearLayout>
<LinearLayout
@@ -24,6 +24,15 @@
android:textSize="12sp"
android:visibility="gone" />
<TextView
android:id="@+id/statsDistance"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#00FF88"
android:textSize="13sp"
android:visibility="gone" />
<com.grigowashere.loratester.ui.RadioComparePanel
android:id="@+id/radioComparePanel"
android:layout_width="match_parent"
+12
View File
@@ -37,6 +37,18 @@
<string name="map_network_online">онлайн</string>
<string name="map_network_offline">офлайн (кэш)</string>
<string name="track_need_network">Нужна сеть для начала трека</string>
<string name="track_offline_started">Запись без сети — точки сохраняются на телефоне</string>
<string name="track_sync_pending">Трек сохранён локально — отправится при появлении сети</string>
<string name="track_sync_done">Трек #%1$d загружен (%2$d точек)</string>
<string name="track_status_online">Трекинг: %1$d точек</string>
<string name="track_status_offline">Трекинг: %1$d точек · буфер %2$d · офлайн</string>
<string name="track_offline_toast">Сеть пропала — точки сохраняются на устройстве (буфер %1$d). Без ограничения по времени, до ~500 тыс. точек.</string>
<string name="track_online_toast">Сеть восстановлена — отправка буфера (%1$d точек)</string>
<string name="map_track_status_online">Трек: %1$d точек</string>
<string name="map_track_status_offline">Трек: %1$d точек · буфер %2$d</string>
<string name="map_rx_quality">RX Quality: %1$s%%</string>
<string name="map_rx_quality_unknown">RX Quality: —</string>
<string name="stats_gps_distance">Расстояние TX↔RX: %1$s m</string>
<string name="upload_queue_pending">В очереди: %1$d</string>
<string name="gps_waiting">GPS: ожидание фикса…</string>
<string name="stats_updated_at">обновлено %1$s</string>
@@ -0,0 +1,27 @@
package com.grigowashere.loratester;
import com.grigowashere.loratester.api.DeviceInfo;
import com.grigowashere.loratester.ui.DeviceNames;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
public class DeviceNamesTest {
@Test
public void prefersLabelOverDeviceId() {
DeviceInfo d = new DeviceInfo();
d.device_id = "android-abc123";
d.label = "Pixel 7 TX";
assertEquals("Pixel 7 TX", DeviceNames.displayName(d, d.device_id));
}
@Test
public void fallsBackToDeviceId() {
DeviceInfo d = new DeviceInfo();
d.device_id = "android-abc123";
d.label = "android-abc123";
assertEquals("android-abc123", DeviceNames.displayName(d, d.device_id));
}
}
@@ -0,0 +1,45 @@
package com.grigowashere.loratester.track;
import org.junit.Test;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class TrackPointQueueTest {
@Test
public void pendingPointRoundTrip() {
Map<String, Object> point = new HashMap<>();
point.put("ts", 1710000000.5);
point.put("lat", 59.93);
point.put("lon", 30.33);
point.put("altitude_gps", 11.0);
point.put("rssi", -90.0);
point.put("role", "RX");
point.put("meta", "{\"quality\":80}");
TrackPointQueue.PendingPoint pending = TrackPointQueue.PendingPoint.fromMap(point);
Map<String, Object> back = pending.toMap();
assertEquals(59.93, (Double) back.get("lat"), 1e-6);
assertEquals(30.33, (Double) back.get("lon"), 1e-6);
assertEquals(11.0, (Double) back.get("altitude_gps"), 1e-6);
assertEquals(-90.0, (Double) back.get("rssi"), 1e-6);
assertEquals("RX", back.get("role"));
assertEquals("{\"quality\":80}", back.get("meta"));
}
@Test
public void snapshotDefaults() {
TrackPointQueue.Snapshot snap = new TrackPointQueue.Snapshot();
assertEquals(-1, snap.trackId);
assertFalse(snap.recording);
assertEquals(0, snap.totalPoints);
assertTrue(snap.pending.isEmpty());
}
}
+2 -1
View File
@@ -22,7 +22,7 @@ python flask_app.py
| `LORATESTER_PORT` | `7634` |
| `LORATESTER_DB` | `./loratester.db` |
| `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) |
| `LORATESTER_TRACK_POINTS_LIMIT` | `10000` (точек на один трек) |
| `LORATESTER_TRACK_POINTS_LIMIT` | `500000` (точек на один трек) |
| `LORATESTER_ELEVATION_URL` | `http://192.168.1.109:8085/v1/elevation` |
| `LORATESTER_ELEVATION_PROBE_TTL` | `60` (сек, кэш проверки доступности) |
| `LORATESTER_ELEVATION_TIMEOUT` | `8` (сек, таймаут HTTP к сервису высот) |
@@ -99,6 +99,7 @@ curl http://127.0.0.1:7634/api/health
### Треки (запись с Android)
- `POST /api/tracks/sync``{device_id, track_id?, started_at?, points[], finish?}` — офлайн-догрузка точек и завершение трека
- `POST /api/tracks/start``{device_id}``{track_id}`
- `POST /api/tracks/{id}/points``{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}`
- `POST /api/tracks/{id}/finish`
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -8,7 +8,7 @@ DATABASE_PATH = os.environ.get(
HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0")
PORT = int(os.environ.get("LORATESTER_PORT", "7634"))
TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000"))
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000"))
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "500000"))
ELEVATION_OPENTOPO_URL = os.environ.get(
"LORATESTER_ELEVATION_OPENTOPO_URL",
"http://grigowashere.ru:5300/v1/srtm30",
+73
View File
@@ -353,6 +353,79 @@ def finish_track(track_id: int) -> dict[str, Any]:
return {"ok": True, "track_id": track_id, "ended_at": ts, "point_count": count}
def sync_track(
device_id: str,
points: list[dict[str, Any]],
track_id: Optional[int] = None,
started_at: Optional[float] = None,
finish: bool = False,
label: Optional[str] = None,
) -> dict[str, Any]:
"""Upload buffered points after offline recording; optionally create track and finish."""
if not is_valid_device_id(device_id):
raise ValueError(f"invalid device_id '{device_id}'")
points = points or []
if track_id is not None:
with _db() as conn:
track = conn.execute(
"SELECT id, device_id, ended_at FROM tracks WHERE id = ?",
(track_id,),
).fetchone()
if not track:
raise ValueError(f"track {track_id} not found")
if track["device_id"] != device_id:
raise ValueError("device_id does not match track owner")
if track["ended_at"] is not None:
raise ValueError(f"track {track_id} already finished")
else:
if not points and not finish:
raise ValueError("points required when creating a new track")
ts = float(started_at) if started_at is not None else time.time()
with _db() as conn:
cur = conn.execute(
"""
INSERT INTO tracks (device_id, started_at, label)
VALUES (?, ?, ?)
""",
(device_id, ts, label),
)
track_id = int(cur.lastrowid)
added = 0
batch_size = 100
for i in range(0, len(points), batch_size):
chunk = points[i : i + batch_size]
if not chunk:
continue
result = add_track_points(track_id, chunk)
added += int(result.get("added") or 0)
finished = False
ended_at = None
point_count = added
if finish:
fin = finish_track(track_id)
finished = True
ended_at = fin.get("ended_at")
point_count = int(fin.get("point_count") or 0)
else:
with _db() as conn:
point_count = conn.execute(
"SELECT COUNT(*) FROM track_points WHERE track_id = ?",
(track_id,),
).fetchone()[0]
return {
"ok": True,
"track_id": track_id,
"added": added,
"point_count": point_count,
"finished": finished,
"ended_at": ended_at,
}
def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[str, Any]]:
limit = min(max(1, limit), 200)
with _db() as conn:
+1 -1
View File
@@ -16,7 +16,7 @@ services:
LORATESTER_ELEVATION_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60}
LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8}
LORATESTER_TELEMETRY_LIMIT: ${LORATESTER_TELEMETRY_LIMIT:-5000}
LORATESTER_TRACK_POINTS_LIMIT: ${LORATESTER_TRACK_POINTS_LIMIT:-10000}
LORATESTER_TRACK_POINTS_LIMIT: ${LORATESTER_TRACK_POINTS_LIMIT:-500000}
volumes:
loratester-data:
+30 -1
View File
@@ -72,6 +72,15 @@ class TrackPointsBody(BaseModel):
points: list[TrackPoint] = Field(default_factory=list)
class TrackSyncBody(BaseModel):
device_id: str
track_id: Optional[int] = None
started_at: Optional[float] = None
points: list[TrackPoint] = Field(default_factory=list)
finish: bool = False
label: Optional[str] = None
class CommandBody(BaseModel):
from_device_id: str
to_device_id: str
@@ -193,6 +202,26 @@ def tracks_finish(
raise HTTPException(400, detail=str(e)) from e
@app.post("/api/tracks/sync")
def tracks_sync(
body: TrackSyncBody,
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
):
_require_android(x_lora_client)
try:
points = [p.model_dump(exclude_none=True) for p in body.points]
return storage.sync_track(
body.device_id,
points,
track_id=body.track_id,
started_at=body.started_at,
finish=body.finish,
label=body.label,
)
except ValueError as e:
raise HTTPException(400, detail=str(e)) from e
@app.get("/api/tracks")
def tracks_list(
device_id: Optional[str] = None,
@@ -379,7 +408,7 @@ def health():
return {
"ok": status["db_ok"],
"ts": time.time(),
"api_build": "2026-06-16i",
"api_build": "2026-06-19a",
**status,
**elevation_status(),
}
+44
View File
@@ -45,6 +45,50 @@ def test_old_telemetry_without_meta_gets_migrated(temp_db):
conn.close()
def test_sync_track_offline_upload(temp_db, monkeypatch):
storage.init_db()
monkeypatch.setattr(storage, "fetch_elevation_m", lambda lat, lon: 100.0)
start = storage.start_track("android-12345678")
tid = start["track_id"]
result = storage.sync_track(
"android-12345678",
[
{"ts": 1.0, "lat": 55.75, "lon": 37.62, "role": "TX"},
{"ts": 2.0, "lat": 55.751, "lon": 37.621, "role": "TX"},
],
track_id=tid,
finish=True,
)
assert result["added"] == 2
assert result["finished"] is True
assert result["point_count"] == 2
track = storage.get_track(tid)
assert len(track["points"]) == 2
assert track["ended_at"] is not None
def test_sync_track_create_offline(temp_db, monkeypatch):
storage.init_db()
monkeypatch.setattr(storage, "fetch_elevation_m", lambda lat, lon: 50.0)
result = storage.sync_track(
"android-abcdef01",
[
{"ts": 10.0, "lat": 59.93, "lon": 30.33, "role": "RX"},
],
track_id=None,
started_at=10.0,
finish=True,
)
assert result["track_id"] > 0
assert result["point_count"] == 1
track = storage.get_track(result["track_id"])
assert track["started_at"] == 10.0
def test_tracks_crud(temp_db):
storage.init_db()
start = storage.start_track("android-12345678")