Initial commit: LoraTester Android + server

This commit is contained in:
2026-06-04 13:05:21 +03:00
commit 83d0353754
124 changed files with 7892 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:name=".LoraApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.LoraTester"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,58 @@
package com.grigowashere.loratester;
import android.app.Application;
import com.grigowashere.loratester.api.ServerApi;
import com.grigowashere.loratester.net.NetworkMonitor;
import com.grigowashere.loratester.track.TrackRecorder;
import org.mapsforge.map.android.graphics.AndroidGraphicFactory;
public class LoraApp extends Application {
private TelemetryUploader telemetryUploader;
private SettingsRepository settingsRepository;
private TrackRecorder trackRecorder;
private NetworkMonitor networkMonitor;
@Override
public void onCreate() {
super.onCreate();
AndroidGraphicFactory.createInstance(this);
settingsRepository = new SettingsRepository(this);
networkMonitor = new NetworkMonitor(this);
networkMonitor.start();
telemetryUploader = new TelemetryUploader(this, settingsRepository, networkMonitor);
trackRecorder = new TrackRecorder(
new ServerApi(settingsRepository.getServerUrl()),
telemetryUploader,
settingsRepository.getOrCreateDeviceId(),
networkMonitor
);
}
public NetworkMonitor getNetworkMonitor() {
return networkMonitor;
}
public TelemetryUploader getTelemetryUploader() {
return telemetryUploader;
}
public SettingsRepository getSettingsRepository() {
return settingsRepository;
}
public TrackRecorder getTrackRecorder() {
return trackRecorder;
}
public void refreshTrackRecorder() {
trackRecorder = new TrackRecorder(
new ServerApi(settingsRepository.getServerUrl()),
telemetryUploader,
settingsRepository.getOrCreateDeviceId(),
networkMonitor
);
}
}
@@ -0,0 +1,101 @@
package com.grigowashere.loratester;
import android.Manifest;
import android.content.pm.PackageManager;
import android.os.Bundle;
import androidx.activity.EdgeToEdge;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.tabs.TabLayout;
import com.google.android.material.tabs.TabLayoutMediator;
import com.grigowashere.loratester.location.LocationTracker;
import com.grigowashere.loratester.track.TrackRecorder;
import com.grigowashere.loratester.ui.MainPagerAdapter;
public class MainActivity extends AppCompatActivity {
private TelemetryUploader telemetryUploader;
private LocationTracker locationTracker;
private final ActivityResultLauncher<String[]> locationPermissionLauncher =
registerForActivityResult(
new ActivityResultContracts.RequestMultiplePermissions(),
result -> startLocationIfPermitted()
);
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
setContentView(R.layout.activity_main);
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
});
LoraApp app = (LoraApp) getApplication();
telemetryUploader = app.getTelemetryUploader();
SettingsRepository settings = app.getSettingsRepository();
ViewPager2 pager = findViewById(R.id.viewPager);
TabLayout tabs = findViewById(R.id.tabLayout);
pager.setAdapter(new MainPagerAdapter(this));
new TabLayoutMediator(tabs, pager, (tab, position) -> {
int titleRes = switch (position) {
case 0 -> R.string.tab_map;
case 1 -> R.string.tab_stats;
case 2 -> R.string.tab_at;
case 3 -> R.string.tab_chat;
default -> R.string.tab_settings;
};
tab.setText(titleRes);
}).attach();
TrackRecorder trackRecorder = app.getTrackRecorder();
locationTracker = new LocationTracker(this, (lat, lon, alt) -> {
telemetryUploader.updateLocation(lat, lon);
trackRecorder.updateLocation(lat, lon, alt);
});
requestLocationPermission();
if (settings.isTelnetEnabled()) {
telemetryUploader.startTelnet();
}
}
private void requestLocationPermission() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
== PackageManager.PERMISSION_GRANTED) {
startLocationIfPermitted();
} else {
locationPermissionLauncher.launch(new String[]{
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
});
}
}
private void startLocationIfPermitted() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
== PackageManager.PERMISSION_GRANTED) {
locationTracker.start();
}
}
@Override
protected void onDestroy() {
locationTracker.stop();
telemetryUploader.stopTelnet();
super.onDestroy();
}
}
@@ -0,0 +1,86 @@
package com.grigowashere.loratester;
import android.content.Context;
import android.content.SharedPreferences;
public class SettingsRepository {
private static final String PREFS = "loratester_settings";
private static final String KEY_SERVER_URL = "server_url";
private static final String KEY_TELNET_HOST = "telnet_host";
private static final String KEY_TELNET_PORT = "telnet_port";
private static final String KEY_RSSI_REGEX = "rssi_regex";
private static final String KEY_RANGE_REGEX = "range_regex";
private static final String KEY_TELNET_ENABLED = "telnet_enabled";
private static final String KEY_DEVICE_ID = "device_id";
public static final String DEFAULT_SERVER = "http://grigowashere.ru:7634";
public static final String DEFAULT_TELNET_HOST = "127.0.0.1";
public static final int DEFAULT_TELNET_PORT = 2727;
public static final String DEFAULT_RSSI_REGEX = "(?:RSSI|Power)[:\\s]*(-?\\d+(?:\\.\\d+)?)";
public static final String DEFAULT_RANGE_REGEX = "range[:\\s]*([\\d.]+)";
private final SharedPreferences prefs;
public SettingsRepository(Context context) {
prefs = context.getApplicationContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
public String getServerUrl() {
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER);
}
public void setServerUrl(String url) {
prefs.edit().putString(KEY_SERVER_URL, url).apply();
}
public String getTelnetHost() {
return prefs.getString(KEY_TELNET_HOST, DEFAULT_TELNET_HOST);
}
public void setTelnetHost(String host) {
prefs.edit().putString(KEY_TELNET_HOST, host).apply();
}
public int getTelnetPort() {
return prefs.getInt(KEY_TELNET_PORT, DEFAULT_TELNET_PORT);
}
public void setTelnetPort(int port) {
prefs.edit().putInt(KEY_TELNET_PORT, port).apply();
}
public String getRssiRegex() {
return prefs.getString(KEY_RSSI_REGEX, DEFAULT_RSSI_REGEX);
}
public void setRssiRegex(String regex) {
prefs.edit().putString(KEY_RSSI_REGEX, regex).apply();
}
public String getRangeRegex() {
return prefs.getString(KEY_RANGE_REGEX, DEFAULT_RANGE_REGEX);
}
public void setRangeRegex(String regex) {
prefs.edit().putString(KEY_RANGE_REGEX, regex).apply();
}
public boolean isTelnetEnabled() {
return prefs.getBoolean(KEY_TELNET_ENABLED, false);
}
public void setTelnetEnabled(boolean enabled) {
prefs.edit().putBoolean(KEY_TELNET_ENABLED, enabled).apply();
}
public String getOrCreateDeviceId() {
String id = prefs.getString(KEY_DEVICE_ID, null);
if (id == null || id.isEmpty()) {
id = "android-" + java.util.UUID.randomUUID().toString().substring(0, 8);
prefs.edit().putString(KEY_DEVICE_ID, id).apply();
}
return id;
}
}
@@ -0,0 +1,337 @@
package com.grigowashere.loratester;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.grigowashere.loratester.api.ServerApi;
import com.grigowashere.loratester.api.TelemetryPayload;
import com.grigowashere.loratester.api.UploadQueue;
import com.grigowashere.loratester.net.NetworkMonitor;
import com.grigowashere.loratester.location.GeoUtils;
import com.grigowashere.loratester.telnet.AtCommandFormatter;
import com.grigowashere.loratester.telnet.StatsExtractor;
import com.grigowashere.loratester.telnet.TelnetClient;
import com.grigowashere.loratester.telnet.TelnetFrameParser;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
public class TelemetryUploader implements TelnetClient.Listener {
private static final String TAG = "TelemetryUploader";
public interface AtSendCallback {
void onResult(TelnetClient.SendResult result);
}
public interface StatsListener {
void onStatsUpdated(StatsExtractor.ExtractedStats stats);
}
private final SettingsRepository settings;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final ExecutorService uploadExecutor = Executors.newSingleThreadExecutor();
private final ExecutorService telnetExecutor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "TelnetWorker");
t.setDaemon(true);
return t;
});
private TelnetClient telnetClient;
private TelnetFrameParser frameParser;
private StatsExtractor statsExtractor;
private ServerApi serverApi;
private static final int CONSOLE_MAX_CHARS = 16_384;
private static final long SNAPSHOT_INTERVAL_MS = 1000;
private final ScheduledExecutorService snapshotScheduler =
Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "TelnetSnapshot");
t.setDaemon(true);
return t;
});
private volatile ScheduledFuture<?> snapshotFuture;
private volatile double lat = Double.NaN;
private volatile double lon = Double.NaN;
private volatile boolean connected;
private final StringBuilder consoleLog = new StringBuilder();
private volatile StatsExtractor.ExtractedStats lastStats;
private volatile long lastStatsAtMs;
private StatsListener statsListener;
private final UploadQueue uploadQueue;
private final NetworkMonitor networkMonitor;
public TelemetryUploader(
Context context,
SettingsRepository settings,
NetworkMonitor networkMonitor
) {
this.settings = settings;
this.networkMonitor = networkMonitor;
uploadQueue = new UploadQueue(context.getApplicationContext());
serverApi = new ServerApi(settings.getServerUrl());
statsExtractor = StatsExtractor.withDefaults();
frameParser = new TelnetFrameParser(this::onFrame);
networkMonitor.addListener(online -> {
if (online) {
flushUploadQueue();
}
});
if (networkMonitor.isOnline()) {
uploadExecutor.execute(this::flushUploadQueue);
}
}
public int getPendingUploadCount() {
return uploadQueue.size();
}
public void refreshApi() {
serverApi = new ServerApi(settings.getServerUrl());
telnetExecutor.execute(() -> {
statsExtractor = new StatsExtractor(
settings.getRssiRegex(),
settings.getRangeRegex()
);
frameParser = new TelnetFrameParser(TelemetryUploader.this::onFrame);
});
}
public void updateLocation(double lat, double lon) {
if (GeoUtils.isValidCoordinate(lat, lon)) {
this.lat = lat;
this.lon = lon;
}
}
private Double validLat() {
return GeoUtils.isValidCoordinate(lat, lon) ? lat : null;
}
private Double validLon() {
return GeoUtils.isValidCoordinate(lat, lon) ? lon : null;
}
public void startTelnet() {
serverApi = new ServerApi(settings.getServerUrl());
telnetExecutor.execute(() -> {
if (telnetClient != null) {
telnetClient.stop();
telnetClient = null;
}
statsExtractor = new StatsExtractor(
settings.getRssiRegex(),
settings.getRangeRegex()
);
frameParser = new TelnetFrameParser(TelemetryUploader.this::onFrame);
telnetClient = new TelnetClient(
settings.getTelnetHost(),
settings.getTelnetPort(),
TelemetryUploader.this
);
telnetClient.start();
});
}
public void stopTelnet() {
stopSnapshotTicker();
telnetExecutor.execute(() -> {
if (telnetClient != null) {
telnetClient.stop();
telnetClient = null;
}
if (frameParser != null) {
frameParser.flush();
}
});
}
public boolean isTelnetConnected() {
return connected;
}
/** Sends AT command on telnet worker thread (safe while receiving data). */
public void sendAtCommand(String command, AtSendCallback callback) {
telnetExecutor.execute(() -> {
TelnetClient.SendResult result = sendAtCommandOnWorker(command);
if (callback != null) {
mainHandler.post(() -> callback.onResult(result));
}
});
}
private TelnetClient.SendResult sendAtCommandOnWorker(String command) {
String normalized = AtCommandFormatter.normalize(command);
appendConsole(">> " + normalized + "\n");
if (telnetClient == null) {
appendConsole("!! telnet not started\n");
return TelnetClient.SendResult.NOT_CONNECTED;
}
TelnetClient.SendResult result = telnetClient.sendAtCommand(command);
if (result != TelnetClient.SendResult.SENT) {
appendConsole("!! send failed: " + result + "\n");
}
return result;
}
public synchronized String getConsoleLog() {
return consoleLog.toString();
}
public synchronized void clearConsoleLog() {
consoleLog.setLength(0);
}
private synchronized void appendConsole(String text) {
consoleLog.append(text);
if (consoleLog.length() > CONSOLE_MAX_CHARS) {
consoleLog.delete(0, consoleLog.length() - CONSOLE_MAX_CHARS);
}
}
public void setStatsListener(StatsListener listener) {
this.statsListener = listener;
if (listener != null && lastStats != null) {
mainHandler.post(() -> listener.onStatsUpdated(lastStats));
}
}
public StatsExtractor.ExtractedStats getLastStats() {
return lastStats;
}
public long getLastStatsAtMs() {
return lastStatsAtMs;
}
private void onFrame(String frame) {
StatsExtractor.ExtractedStats stats = statsExtractor.extract(frame);
if (!stats.hasRadioFrame()) {
return;
}
lastStats = stats;
lastStatsAtMs = System.currentTimeMillis();
StatsListener listener = statsListener;
if (listener != null) {
mainHandler.post(() -> listener.onStatsUpdated(stats));
}
TelemetryPayload payload = new TelemetryPayload(
settings.getOrCreateDeviceId(),
validLat(),
validLon(),
stats.rssi,
stats.rangeM,
null,
stats.metaJson,
stats.role,
System.currentTimeMillis() / 1000.0
);
uploadExecutor.execute(() -> uploadTelemetry(payload));
}
private void uploadTelemetry(TelemetryPayload payload) {
if (networkMonitor.isOnline()) {
try {
serverApi.postTelemetry(payload);
flushUploadQueue();
return;
} catch (Exception e) {
Log.e(TAG, "upload failed", e);
appendConsole("!! server upload: " + e.getMessage() + "\n");
}
}
uploadQueue.enqueue(payload);
int pending = uploadQueue.size();
if (pending > 0 && pending % 10 == 0) {
appendConsole("!! queued uploads: " + pending + "\n");
}
}
private void flushUploadQueue() {
if (!networkMonitor.isOnline()) {
return;
}
int sent = uploadQueue.flushAll(serverApi);
if (sent > 0) {
appendConsole(">> uploaded " + sent + " queued frame(s)\n");
}
}
@Override
public void onConnected() {
mainHandler.post(() -> connected = true);
startSnapshotTicker();
}
@Override
public void onDisconnected() {
stopSnapshotTicker();
mainHandler.post(() -> connected = false);
}
private void startSnapshotTicker() {
stopSnapshotTicker();
snapshotFuture = snapshotScheduler.scheduleAtFixedRate(
() -> telnetExecutor.execute(this::tickSnapshot),
SNAPSHOT_INTERVAL_MS,
SNAPSHOT_INTERVAL_MS,
TimeUnit.MILLISECONDS
);
}
private void stopSnapshotTicker() {
if (snapshotFuture != null) {
snapshotFuture.cancel(false);
snapshotFuture = null;
}
}
private void tickSnapshot() {
if (frameParser != null && connected) {
frameParser.emitSnapshot();
}
}
@Override
public void onBytes(byte[] data, int length) {
byte[] copy = Arrays.copyOf(data, length);
telnetExecutor.execute(() -> {
appendConsole(new String(copy, StandardCharsets.UTF_8));
if (frameParser != null) {
frameParser.append(copy);
}
});
}
@Override
public void onError(String message) {
Log.w(TAG, "telnet: " + message);
telnetExecutor.execute(() -> appendConsole("!! " + message + "\n"));
}
/** Feeds simulated telnet stream for testing without hardware. */
public void simulateChunk(String text) {
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
telnetExecutor.execute(() -> {
if (frameParser != null) {
frameParser.append(bytes);
}
});
}
public ServerApi getServerApi() {
return serverApi;
}
public String getDeviceId() {
return settings.getOrCreateDeviceId();
}
}
@@ -0,0 +1,8 @@
package com.grigowashere.loratester.api;
public class ChatMessage {
public long id;
public String device_id;
public String text;
public double ts;
}
@@ -0,0 +1,15 @@
package com.grigowashere.loratester.api;
public class DeviceInfo {
public String device_id;
public double last_seen;
public Double lat;
public Double lon;
public Double rssi;
public Double range_m;
public String raw_frame;
public String meta;
/** TX or RX from last telnet frame. */
public String role;
public Double ts;
}
@@ -0,0 +1,182 @@
package com.grigowashere.loratester.api;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.reflect.TypeToken;
import java.io.IOException;
import java.lang.reflect.Type;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
public class ServerApi {
public static final String HEADER_LORA_CLIENT = "X-Lora-Client";
public static final String CLIENT_ANDROID = "android";
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
private static final Gson GSON = new Gson();
private static final Type DEVICE_LIST = new TypeToken<List<DeviceInfo>>() {}.getType();
private static final Type CHAT_LIST = new TypeToken<List<ChatMessage>>() {}.getType();
private static final Type TELEMETRY_HISTORY =
new TypeToken<List<TelemetryHistoryItem>>() {}.getType();
private static final Type TRACK_LIST = new TypeToken<List<TrackInfo>>() {}.getType();
private final String baseUrl;
private final OkHttpClient client;
public ServerApi(String baseUrl) {
String url = baseUrl == null ? "" : baseUrl.trim();
while (url.endsWith("/")) {
url = url.substring(0, url.length() - 1);
}
this.baseUrl = url;
this.client = new OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build();
}
public void postTelemetry(TelemetryPayload payload) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("device_id", payload.deviceId);
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);
if (payload.rangeM != null) body.put("range_m", payload.rangeM);
if (payload.meta != null) {
try {
JsonObject meta = JsonParser.parseString(payload.meta).getAsJsonObject();
body.put("meta", GSON.fromJson(meta, Map.class));
if (meta.has("fields") && meta.get("fields").isJsonObject()) {
body.put("fields", GSON.fromJson(meta.get("fields"), Map.class));
}
} catch (Exception ignored) {
body.put("meta", payload.meta);
}
}
if (payload.role != null) body.put("role", payload.role);
if (payload.ts != null) body.put("ts", payload.ts);
postJson("/api/telemetry", body, true);
}
public List<DeviceInfo> getDevices() throws IOException {
return getJsonList("/api/devices", DEVICE_LIST);
}
public void postChat(String deviceId, String text) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("device_id", deviceId);
body.put("text", text);
postJson("/api/chat", body);
}
public List<ChatMessage> getChat(double since) throws IOException {
String path = "/api/chat?since=" + since;
return getJsonList(path, CHAT_LIST);
}
public List<TelemetryHistoryItem> getTelemetryHistory(String deviceId, int limit)
throws IOException {
String path = "/api/telemetry?device_id=" + deviceId + "&limit=" + limit;
return getJsonList(path, TELEMETRY_HISTORY);
}
public long startTrack(String deviceId) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("device_id", deviceId);
Map<String, Object> resp = postJsonMap("/api/tracks/start", body, true);
Number id = (Number) resp.get("track_id");
return id.longValue();
}
public void addTrackPoints(long trackId, List<Map<String, Object>> points) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("points", points);
postJson("/api/tracks/" + trackId + "/points", body, true);
}
public void finishTrack(long trackId) throws IOException {
postJson("/api/tracks/" + trackId + "/finish", new HashMap<>(), true);
}
public List<TrackInfo> listTracks(String deviceId) throws IOException {
return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST);
}
public TrackDetail getTrack(long trackId) throws IOException {
Request request = new Request.Builder()
.url(baseUrl + "/api/tracks/" + trackId)
.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(), TrackDetail.class);
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> postJsonMap(String path, Map<String, Object> body, boolean android)
throws IOException {
Request.Builder builder = new Request.Builder()
.url(baseUrl + path)
.post(RequestBody.create(GSON.toJson(body), JSON));
if (android) {
builder.header(HEADER_LORA_CLIENT, CLIENT_ANDROID);
}
try (Response response = client.newCall(builder.build()).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), Map.class);
}
}
private void postJson(String path, Map<String, Object> body) throws IOException {
postJson(path, body, false);
}
private void postJson(String path, Map<String, Object> body, boolean androidClient)
throws IOException {
Request.Builder builder = new Request.Builder()
.url(baseUrl + path)
.post(RequestBody.create(GSON.toJson(body), JSON));
if (androidClient) {
builder.header(HEADER_LORA_CLIENT, CLIENT_ANDROID);
}
execute(builder.build());
}
private <T> T getJsonList(String path, Type type) throws IOException {
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(), type);
}
}
private void execute(Request request) throws IOException {
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IOException("HTTP " + response.code());
}
}
}
}
@@ -0,0 +1,14 @@
package com.grigowashere.loratester.api;
public class TelemetryHistoryItem {
public long id;
public String device_id;
public Double lat;
public Double lon;
public Double rssi;
public Double range_m;
public String meta;
public String role;
public double ts;
public String source;
}
@@ -0,0 +1,36 @@
package com.grigowashere.loratester.api;
public class TelemetryPayload {
public final String deviceId;
public final Double lat;
public final Double lon;
public final Double rssi;
public final Double rangeM;
public final String rawFrame;
public final String meta;
/** TX = передатчик, RX = приёмник. */
public final String role;
public final Double ts;
public TelemetryPayload(
String deviceId,
Double lat,
Double lon,
Double rssi,
Double rangeM,
String rawFrame,
String meta,
String role,
Double ts
) {
this.deviceId = deviceId;
this.lat = lat;
this.lon = lon;
this.rssi = rssi;
this.rangeM = rangeM;
this.rawFrame = rawFrame;
this.meta = meta;
this.role = role;
this.ts = ts;
}
}
@@ -0,0 +1,23 @@
package com.grigowashere.loratester.api;
import java.util.List;
public class TrackDetail {
public long id;
public String device_id;
public double started_at;
public Double ended_at;
public String label;
public List<TrackPoint> points;
public static class TrackPoint {
public double ts;
public double lat;
public double lon;
public Double altitude_gps;
public Double elevation_m;
public Double rssi;
public String role;
public String meta;
}
}
@@ -0,0 +1,10 @@
package com.grigowashere.loratester.api;
public class TrackInfo {
public long id;
public String device_id;
public double started_at;
public Double ended_at;
public String label;
public int point_count;
}
@@ -0,0 +1,152 @@
package com.grigowashere.loratester.api;
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.Iterator;
import java.util.List;
import java.util.Objects;
/** Persistent queue for telemetry uploads when the network is down. */
public class UploadQueue {
private static final String TAG = "UploadQueue";
private static final String FILE_NAME = "telemetry_upload_queue.json";
private static final int MAX_ITEMS = 500;
private static final Gson GSON = new Gson();
private static final Type LIST_TYPE = new TypeToken<List<QueuedItem>>() {}.getType();
private final File queueFile;
private final List<QueuedItem> items = new ArrayList<>();
private String lastMeta;
public UploadQueue(Context context) {
queueFile = new File(context.getFilesDir(), FILE_NAME);
load();
}
public synchronized int size() {
return items.size();
}
public synchronized void enqueue(TelemetryPayload payload) {
if (payload == null) {
return;
}
if (payload.meta != null && payload.meta.equals(lastMeta)) {
return;
}
lastMeta = payload.meta;
items.add(QueuedItem.from(payload));
trim();
persist();
}
public synchronized int flushAll(ServerApi api) {
if (api == null || items.isEmpty()) {
return 0;
}
int sent = 0;
Iterator<QueuedItem> it = items.iterator();
while (it.hasNext()) {
QueuedItem item = it.next();
try {
api.postTelemetry(item.toPayload());
it.remove();
sent++;
} catch (Exception e) {
Log.w(TAG, "flush stopped at " + sent + " sent", e);
break;
}
}
if (sent > 0) {
persist();
}
return sent;
}
private void trim() {
while (items.size() > MAX_ITEMS) {
items.remove(0);
}
}
private void load() {
if (!queueFile.exists()) {
return;
}
try (FileReader reader = new FileReader(queueFile)) {
List<QueuedItem> loaded = GSON.fromJson(reader, LIST_TYPE);
if (loaded != null) {
items.clear();
items.addAll(loaded);
trim();
}
} catch (Exception e) {
Log.w(TAG, "load queue failed", e);
}
}
private void persist() {
try (FileWriter writer = new FileWriter(queueFile)) {
GSON.toJson(items, writer);
} catch (Exception e) {
Log.w(TAG, "persist queue failed", e);
}
}
static final class QueuedItem {
String deviceId;
Double lat;
Double lon;
Double rssi;
Double rangeM;
String rawFrame;
String meta;
String role;
Double ts;
static QueuedItem from(TelemetryPayload p) {
QueuedItem q = new QueuedItem();
q.deviceId = p.deviceId;
q.lat = p.lat;
q.lon = p.lon;
q.rssi = p.rssi;
q.rangeM = p.rangeM;
q.rawFrame = p.rawFrame;
q.meta = p.meta;
q.role = p.role;
q.ts = p.ts;
return q;
}
TelemetryPayload toPayload() {
return new TelemetryPayload(
deviceId, lat, lon, rssi, rangeM, rawFrame, meta, role, ts);
}
@Override
public boolean equals(Object o) {
if (!(o instanceof QueuedItem other)) {
return false;
}
return Objects.equals(deviceId, other.deviceId)
&& Objects.equals(meta, other.meta)
&& Objects.equals(ts, other.ts);
}
@Override
public int hashCode() {
return Objects.hash(deviceId, meta, ts);
}
}
}
@@ -0,0 +1,23 @@
package com.grigowashere.loratester.location;
public final class GeoUtils {
private static final double ZERO_EPS = 1e-5;
private GeoUtils() {
}
public static boolean isValidCoordinate(double lat, double lon) {
if (Double.isNaN(lat) || Double.isNaN(lon) || Double.isInfinite(lat) || Double.isInfinite(lon)) {
return false;
}
if (Math.abs(lat) < ZERO_EPS && Math.abs(lon) < ZERO_EPS) {
return false;
}
return lat >= -90.0 && lat <= 90.0 && lon >= -180.0 && lon <= 180.0;
}
public static boolean isValidCoordinate(Double lat, Double lon) {
return lat != null && lon != null && isValidCoordinate(lat.doubleValue(), lon.doubleValue());
}
}
@@ -0,0 +1,61 @@
package com.grigowashere.loratester.location;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Looper;
import com.google.android.gms.location.FusedLocationProviderClient;
import com.google.android.gms.location.LocationCallback;
import com.google.android.gms.location.LocationRequest;
import com.google.android.gms.location.LocationResult;
import com.google.android.gms.location.LocationServices;
import com.google.android.gms.location.Priority;
public class LocationTracker {
public interface Listener {
void onLocation(double lat, double lon, double altitude);
}
private final FusedLocationProviderClient client;
private final Listener listener;
private LocationCallback callback;
public LocationTracker(Context context, Listener listener) {
this.client = LocationServices.getFusedLocationProviderClient(context);
this.listener = listener;
}
@SuppressLint("MissingPermission")
public void start() {
if (callback != null) {
return;
}
LocationRequest request = new LocationRequest.Builder(
Priority.PRIORITY_HIGH_ACCURACY, 10_000L
).setMinUpdateIntervalMillis(5_000L).build();
callback = new LocationCallback() {
@Override
public void onLocationResult(LocationResult result) {
if (result.getLastLocation() == null) {
return;
}
double lat = result.getLastLocation().getLatitude();
double lon = result.getLastLocation().getLongitude();
double alt = result.getLastLocation().getAltitude();
if (GeoUtils.isValidCoordinate(lat, lon)) {
listener.onLocation(lat, lon, alt);
}
}
};
client.requestLocationUpdates(request, callback, Looper.getMainLooper());
}
public void stop() {
if (callback != null) {
client.removeLocationUpdates(callback);
callback = null;
}
}
}
@@ -0,0 +1,114 @@
package com.grigowashere.loratester.net;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.NetworkRequest;
import android.os.Build;
import androidx.annotation.NonNull;
import java.util.concurrent.CopyOnWriteArrayList;
/** Observes validated internet connectivity. */
public class NetworkMonitor {
public interface Listener {
void onConnectivityChanged(boolean online);
}
private final ConnectivityManager connectivityManager;
private final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
private volatile boolean online;
private ConnectivityManager.NetworkCallback networkCallback;
public NetworkMonitor(@NonNull Context context) {
connectivityManager =
(ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
online = checkOnline();
}
public boolean isOnline() {
return online;
}
public void addListener(@NonNull Listener listener) {
listeners.add(listener);
listener.onConnectivityChanged(online);
}
public void removeListener(@NonNull Listener listener) {
listeners.remove(listener);
}
public void start() {
if (networkCallback != null || connectivityManager == null) {
return;
}
networkCallback = new ConnectivityManager.NetworkCallback() {
@Override
public void onAvailable(@NonNull Network network) {
updateState();
}
@Override
public void onLost(@NonNull Network network) {
updateState();
}
@Override
public void onCapabilitiesChanged(
@NonNull Network network,
@NonNull NetworkCapabilities caps
) {
updateState();
}
};
NetworkRequest request = new NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
connectivityManager.registerDefaultNetworkCallback(networkCallback);
} else {
connectivityManager.registerNetworkCallback(request, networkCallback);
}
updateState();
}
public void stop() {
if (networkCallback != null && connectivityManager != null) {
try {
connectivityManager.unregisterNetworkCallback(networkCallback);
} catch (Exception ignored) {
// ignore
}
networkCallback = null;
}
}
private void updateState() {
boolean now = checkOnline();
if (now == online) {
return;
}
online = now;
for (Listener l : listeners) {
l.onConnectivityChanged(online);
}
}
private boolean checkOnline() {
if (connectivityManager == null) {
return false;
}
Network network = connectivityManager.getActiveNetwork();
if (network == null) {
return false;
}
NetworkCapabilities caps = connectivityManager.getNetworkCapabilities(network);
return caps != null
&& caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
&& caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED);
}
}
@@ -0,0 +1,33 @@
package com.grigowashere.loratester.telnet;
import java.nio.charset.StandardCharsets;
/** Formats user input as an AT command line for serial/telnet bridges. */
public final class AtCommandFormatter {
private AtCommandFormatter() {
}
public static String normalize(String input) {
if (input == null) {
return "";
}
String line = input.trim();
if (line.isEmpty()) {
return "";
}
if (!line.regionMatches(true, 0, "AT", 0, 2)) {
line = "AT" + line;
}
return line;
}
public static byte[] toWireBytes(String input) {
String line = normalize(input);
if (line.isEmpty()) {
return new byte[0];
}
String wire = line + "\r\n";
return wire.getBytes(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,15 @@
package com.grigowashere.loratester.telnet;
/** Common AT commands for LoRa modules (via telnet bridge). */
public final class AtCommands {
public static final String HELP = "AT+H";
public static final String TRANSMIT = "AT+TX";
public static final String RECEIVE = "AT+RX";
public static final String STATUS = "AT+STATUS";
public static final String RESET = "AT+RESET";
public static final String BASIC = "AT";
private AtCommands() {
}
}
@@ -0,0 +1,129 @@
package com.grigowashere.loratester.telnet;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
public final class LoraStatsFormatter {
private LoraStatsFormatter() {
}
/** Human-readable lines from telemetry meta JSON (fields first). */
public static String formatMeta(String metaJson) {
if (metaJson == null || metaJson.isEmpty()) {
return "";
}
try {
JsonObject o = JsonParser.parseString(metaJson).getAsJsonObject();
StringBuilder sb = new StringBuilder();
Set<String> shown = new HashSet<>();
appendFieldsBlock(sb, o.get("fields"), shown);
String role = text(o, "role");
if (role != null) {
append(sb, "Роль", roleLabel(role));
}
append(sb, "Кадр", text(o, "frame"));
append(sb, "Мощность TX", dbl(o, "power_dbm"), " dBm");
append(sb, "RSSI", dbl(o, "rssi_dbm"), " dBm");
append(sb, "SNR", dbl(o, "snr_db"), " dB");
append(sb, "Частота", freqMhz(o), " MHz");
append(sb, "SF", integer(o, "spreading_factor"));
append(sb, "BW", integer(o, "bandwidth_khz"), " kHz");
append(sb, "Пакет", integer(o, "packet"));
append(sb, "Payload", text(o, "payload"));
append(sb, "On Air", dbl(o, "on_air_ms"), " ms");
append(sb, "TX Speed", dbl(o, "tx_pkt_per_s"), " pkt/s");
append(sb, "RX Speed", dbl(o, "rx_pkt_per_s"), " pkt/s");
append(sb, "PER", dbl(o, "per_percent"), " %");
return sb.toString().trim();
} catch (Exception ignored) {
return metaJson;
}
}
private static void appendFieldsBlock(StringBuilder sb, JsonElement fieldsEl, Set<String> shown) {
if (fieldsEl == null || !fieldsEl.isJsonObject()) {
return;
}
JsonObject fields = fieldsEl.getAsJsonObject();
for (Map.Entry<String, JsonElement> e : fields.entrySet()) {
String label = e.getKey();
if (isSkippedFieldLabel(label)) {
continue;
}
String norm = normalizeLabel(label);
if (shown.contains(norm)) {
continue;
}
shown.add(norm);
append(sb, label, e.getValue().getAsString());
}
}
private static String normalizeLabel(String label) {
return label.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ").trim();
}
private static boolean isSkippedFieldLabel(String label) {
String l = normalizeLabel(label);
return l.equals("send") || l.equals("receive");
}
public static String roleLabel(String role) {
if (StatsExtractor.ROLE_TX.equals(role)) {
return "Передатчик (TX)";
}
if (StatsExtractor.ROLE_RX.equals(role)) {
return "Приёмник (RX)";
}
return role;
}
private static String freqMhz(JsonObject o) {
if (!o.has("frequency_hz")) {
return null;
}
long hz = o.get("frequency_hz").getAsLong();
return String.format(Locale.US, "%.3f", hz / 1_000_000.0);
}
private static void append(StringBuilder sb, String label, String value) {
if (value == null || value.isEmpty()) {
return;
}
if (sb.length() > 0) {
sb.append("\n");
}
sb.append(label).append(": ").append(value);
}
private static void append(StringBuilder sb, String label, String value, String suffix) {
if (value == null) {
return;
}
append(sb, label, value + suffix);
}
private static String text(JsonObject o, String key) {
JsonElement e = o.get(key);
return e != null && !e.isJsonNull() ? e.getAsString() : null;
}
private static String integer(JsonObject o, String key) {
JsonElement e = o.get(key);
return e != null && e.isJsonPrimitive() ? String.valueOf(e.getAsInt()) : null;
}
private static String dbl(JsonObject o, String key) {
JsonElement e = o.get(key);
return e != null && e.isJsonPrimitive() ? String.valueOf(e.getAsDouble()) : null;
}
}
@@ -0,0 +1,318 @@
package com.grigowashere.loratester.telnet;
import com.google.gson.Gson;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Label-based regex parsing — order and count of fields may change on the device.
*/
public class StatsExtractor {
public static final String ROLE_TX = "TX";
public static final String ROLE_RX = "RX";
private static final Gson GSON = new Gson();
private static final Pattern FRAME_SEND = Pattern.compile("(?m)^\\s*SEND\\b");
private static final Pattern FRAME_RECEIVE = Pattern.compile("(?m)^\\s*RECEIVE\\b");
private static final Pattern POWER = Pattern.compile("Power\\s*:\\s*(\\d+(?:\\.\\d+)?)", Pattern.CASE_INSENSITIVE);
private static final Pattern RSSI = Pattern.compile("RSSI\\s*:\\s*(-?\\d+(?:\\.\\d+)?)", Pattern.CASE_INSENSITIVE);
private static final Pattern SNR = Pattern.compile("SNR\\s*:\\s*(-?\\d+(?:\\.\\d+)?)", Pattern.CASE_INSENSITIVE);
private static final Pattern FREQUENCY = Pattern.compile("Frequency\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern SPREADING = Pattern.compile("Spreading Factor\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern BANDWIDTH = Pattern.compile("Bandwidth\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PACKET = Pattern.compile("Packet\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PACKET_NUMBER = Pattern.compile("Packet Number\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PAYLOAD = Pattern.compile("Payload\\s*:\\s*(.+)", Pattern.CASE_INSENSITIVE);
private static final Pattern ON_AIR = Pattern.compile("On Air\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern TX_SPEED = Pattern.compile("TX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern RX_SPEED = Pattern.compile("RX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private static final Pattern PER = Pattern.compile("PER\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
private final Pattern rssiPattern;
private final Pattern rangePattern;
public StatsExtractor(String rssiRegex, String rangeRegex) {
this.rssiPattern = Pattern.compile(rssiRegex, Pattern.CASE_INSENSITIVE);
this.rangePattern = Pattern.compile(rangeRegex, Pattern.CASE_INSENSITIVE);
}
public static StatsExtractor withDefaults() {
return new StatsExtractor(
"RSSI\\s*:\\s*(-?\\d+(?:\\.\\d+)?)",
"range\\s*:\\s*([\\d.]+)"
);
}
public ExtractedStats extract(String frame) {
if (frame == null || frame.isBlank()) {
return empty(frame);
}
String normalized = TelnetText.normalize(frame);
Map<String, Object> meta = new LinkedHashMap<>();
Map<String, String> fields = new LinkedHashMap<>();
collectLabeledLines(normalized, fields);
String frameType = detectFrameType(normalized);
String role = frameTypeToRole(frameType);
if (frameType != null) {
meta.put("frame", frameType);
}
if (role != null) {
meta.put("role", role);
}
if (!fields.isEmpty()) {
meta.put("fields", fields);
}
Double rssiDbm = firstDouble(RSSI, normalized);
if (rssiDbm == null) {
rssiDbm = matchDouble(rssiPattern, normalized);
}
Double txPower = matchDouble(POWER, normalized);
Double snrDb = matchDouble(SNR, normalized);
if (rssiDbm != null) {
meta.put("rssi_dbm", rssiDbm);
}
if (txPower != null) {
meta.put("power_dbm", txPower);
}
if (snrDb != null) {
meta.put("snr_db", snrDb);
}
putLong(meta, "frequency_hz", matchLong(FREQUENCY, normalized));
putInt(meta, "spreading_factor", matchInt(SPREADING, normalized));
putInt(meta, "bandwidth_khz", matchInt(BANDWIDTH, normalized));
Integer packet = matchInt(PACKET_NUMBER, normalized);
if (packet == null) {
packet = matchInt(PACKET, normalized);
}
putInt(meta, "packet", packet);
putString(meta, "payload", matchString(PAYLOAD, normalized));
putDouble(meta, "on_air_ms", matchDouble(ON_AIR, normalized));
putDouble(meta, "tx_pkt_per_s", matchDouble(TX_SPEED, normalized));
putDouble(meta, "rx_pkt_per_s", matchDouble(RX_SPEED, normalized));
putDouble(meta, "per_percent", matchDouble(PER, normalized));
enrichFieldsFromStructured(meta, fields);
Double rangeM = matchDouble(rangePattern, normalized);
Double displayDbm = rssiDbm != null ? rssiDbm : txPower;
String metaJson = meta.isEmpty() ? null : GSON.toJson(meta);
return new ExtractedStats(
displayDbm, rangeM, frame, metaJson, frameType, role, txPower, rssiDbm, snrDb
);
}
private static void collectLabeledLines(String frame, Map<String, String> fields) {
for (String line : frame.split("\n")) {
String trimmed = line.trim();
if (trimmed.isEmpty()) {
continue;
}
int colon = trimmed.indexOf(':');
if (colon <= 0) {
continue;
}
String label = trimmed.substring(0, colon).trim();
String value = trimmed.substring(colon + 1).trim();
if (label.isEmpty()
|| label.equalsIgnoreCase("SEND")
|| label.equalsIgnoreCase("RECEIVE")) {
continue;
}
fields.put(label, value);
}
}
/** Ensure meta.fields has display lines even when line split missed some rows. */
private static void enrichFieldsFromStructured(
Map<String, Object> meta,
Map<String, String> fields
) {
putFieldIfAbsent(fields, "Frequency", meta.get("frequency_hz"),
v -> v + " Hz");
putFieldIfAbsent(fields, "Power", meta.get("power_dbm"),
v -> v + " dBm");
putFieldIfAbsent(fields, "RSSI", meta.get("rssi_dbm"),
v -> String.valueOf(v));
putFieldIfAbsent(fields, "SNR", meta.get("snr_db"),
v -> String.valueOf(v));
putFieldIfAbsent(fields, "Spreading Factor", meta.get("spreading_factor"),
String::valueOf);
putFieldIfAbsent(fields, "Bandwidth", meta.get("bandwidth_khz"),
v -> v + " kHz");
Object packet = meta.get("packet");
if (packet != null) {
putFieldIfAbsent(fields, "Packet", packet, String::valueOf);
putFieldIfAbsent(fields, "Packet Number", packet, String::valueOf);
}
putFieldIfAbsent(fields, "Payload", meta.get("payload"),
v -> (String) v);
putFieldIfAbsent(fields, "On Air", meta.get("on_air_ms"),
v -> v + " ms");
putFieldIfAbsent(fields, "TX Speed", meta.get("tx_pkt_per_s"),
v -> v + " pkt/s");
putFieldIfAbsent(fields, "RX Speed", meta.get("rx_pkt_per_s"),
v -> v + " pkt/s");
putFieldIfAbsent(fields, "PER", meta.get("per_percent"),
v -> v + " %");
}
private static void putFieldIfAbsent(
Map<String, String> fields,
String label,
Object value,
java.util.function.Function<Object, String> format
) {
if (value == null || fields.containsKey(label)) {
return;
}
fields.put(label, format.apply(value));
}
private static ExtractedStats empty(String frame) {
return new ExtractedStats(null, null, frame, null, null, null, null, null, null);
}
private static String detectFrameType(String frame) {
boolean send = FRAME_SEND.matcher(frame).find();
boolean recv = FRAME_RECEIVE.matcher(frame).find();
if (send && !recv) {
return "SEND";
}
if (recv && !send) {
return "RECEIVE";
}
if (send) {
return "SEND";
}
if (recv) {
return "RECEIVE";
}
return null;
}
private static String frameTypeToRole(String frameType) {
if ("SEND".equals(frameType)) {
return ROLE_TX;
}
if ("RECEIVE".equals(frameType)) {
return ROLE_RX;
}
return null;
}
private static Double firstDouble(Pattern pattern, String text) {
return matchDouble(pattern, text);
}
private static void putInt(Map<String, Object> meta, String key, Integer value) {
if (value != null) {
meta.put(key, value);
}
}
private static void putLong(Map<String, Object> meta, String key, Long value) {
if (value != null) {
meta.put(key, value);
}
}
private static void putDouble(Map<String, Object> meta, String key, Double value) {
if (value != null) {
meta.put(key, value);
}
}
private static void putString(Map<String, Object> meta, String key, String value) {
if (value != null && !value.isEmpty()) {
meta.put(key, value.trim());
}
}
private static Double matchDouble(Pattern pattern, String text) {
Matcher m = pattern.matcher(text);
if (m.find()) {
try {
return Double.parseDouble(m.group(1).trim());
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private static Integer matchInt(Pattern pattern, String text) {
Long v = matchLong(pattern, text);
return v != null ? v.intValue() : null;
}
private static Long matchLong(Pattern pattern, String text) {
Matcher m = pattern.matcher(text);
if (m.find()) {
try {
return Long.parseLong(m.group(1).trim());
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private static String matchString(Pattern pattern, String text) {
Matcher m = pattern.matcher(text);
if (m.find()) {
return m.group(1).trim();
}
return null;
}
public static final class ExtractedStats {
public final Double rssi;
public final Double rangeM;
public final String rawFrame;
public final String metaJson;
public final String frameType;
public final String role;
public final Double txPowerDbm;
public final Double rssiDbm;
public final Double snrDb;
public boolean hasRadioFrame() {
return frameType != null;
}
public ExtractedStats(
Double rssi,
Double rangeM,
String rawFrame,
String metaJson,
String frameType,
String role,
Double txPowerDbm,
Double rssiDbm,
Double snrDb
) {
this.rssi = rssi;
this.rangeM = rangeM;
this.rawFrame = rawFrame;
this.metaJson = metaJson;
this.frameType = frameType;
this.role = role;
this.txPowerDbm = txPowerDbm;
this.rssiDbm = rssiDbm;
this.snrDb = snrDb;
}
}
}
@@ -0,0 +1,163 @@
package com.grigowashere.loratester.telnet;
import android.util.Log;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
public class TelnetClient {
private static final String TAG = "TelnetClient";
public interface Listener {
void onConnected();
void onDisconnected();
void onBytes(byte[] data, int length);
void onError(String message);
}
public enum SendResult {
SENT,
NOT_CONNECTED,
EMPTY,
IO_ERROR
}
private final String host;
private final int port;
private final Listener listener;
private final AtomicBoolean running = new AtomicBoolean(false);
private final AtomicReference<Socket> activeSocket = new AtomicReference<>();
private final Object sendLock = new Object();
private Thread worker;
public TelnetClient(String host, int port, Listener listener) {
this.host = host;
this.port = port;
this.listener = listener;
}
public void start() {
if (running.getAndSet(true)) {
return;
}
worker = new Thread(this::runLoop, "TelnetClient");
worker.setDaemon(true);
worker.start();
}
public void stop() {
running.set(false);
closeActiveSocket();
if (worker != null) {
worker.interrupt();
}
}
public boolean isRunning() {
return running.get();
}
public boolean isConnected() {
Socket s = activeSocket.get();
return s != null && s.isConnected() && !s.isClosed();
}
/**
* Sends an AT command. Adds AT prefix and CR+LF if missing.
*/
public SendResult sendAtCommand(String command) {
byte[] wire = AtCommandFormatter.toWireBytes(command);
if (wire.length == 0) {
return SendResult.EMPTY;
}
Socket socket = activeSocket.get();
if (socket == null || socket.isClosed()) {
return SendResult.NOT_CONNECTED;
}
synchronized (sendLock) {
try {
OutputStream out = socket.getOutputStream();
out.write(wire);
out.flush();
return SendResult.SENT;
} catch (IOException e) {
Log.e(TAG, "send failed", e);
return SendResult.IO_ERROR;
}
}
}
private void runLoop() {
int backoffMs = 1000;
while (running.get()) {
Socket socket = null;
try {
socket = new Socket();
socket.connect(new InetSocketAddress(host, port), 5000);
socket.setTcpNoDelay(true);
activeSocket.set(socket);
listener.onConnected();
backoffMs = 1000;
readStream(socket);
} catch (IOException e) {
if (running.get()) {
listener.onError(e.getMessage());
}
} finally {
activeSocket.compareAndSet(socket, null);
listener.onDisconnected();
if (socket != null) {
try {
socket.close();
} catch (IOException ignored) {
}
}
}
if (!running.get()) {
break;
}
try {
Thread.sleep(backoffMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
backoffMs = Math.min(backoffMs * 2, 15000);
}
}
private void closeActiveSocket() {
Socket s = activeSocket.getAndSet(null);
if (s != null) {
try {
s.close();
} catch (IOException ignored) {
}
}
}
private void readStream(Socket socket) throws IOException {
InputStream in = socket.getInputStream();
byte[] buf = new byte[4096];
while (running.get()) {
int n = in.read(buf);
if (n < 0) {
break;
}
if (n > 0) {
byte[] chunk = new byte[n];
System.arraycopy(buf, 0, chunk, 0, n);
listener.onBytes(chunk, n);
}
}
}
}
@@ -0,0 +1,200 @@
package com.grigowashere.loratester.telnet;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Splits telnet stream into LoRa screen frames by ESC clear and/or SEND/RECEIVE headers.
*/
public class TelnetFrameParser {
public interface FrameListener {
void onFrame(String text);
}
private static final Charset CHARSET = StandardCharsets.UTF_8;
/** Start of a new radio screen (TX or RX). */
private static final Pattern FRAME_HEADER =
Pattern.compile("(?m)^\\s*(SEND|RECEIVE)\\b");
private final List<byte[]> delimiters;
private final FrameListener listener;
private final byte[] buffer = new byte[65536];
private int bufferLen;
public TelnetFrameParser(FrameListener listener) {
this(listener, defaultDelimiters());
}
public TelnetFrameParser(FrameListener listener, List<byte[]> delimiters) {
this.listener = listener;
this.delimiters = new ArrayList<>(delimiters);
}
public static List<byte[]> defaultDelimiters() {
return Arrays.asList(
new byte[] {0x1b, '[', '2', 'J'},
new byte[] {0x1b, '[', 'H'},
new byte[] {0x0c}
);
}
public void append(byte[] chunk, int offset, int length) {
if (length <= 0) {
return;
}
int splitAt = findEarliestDelimiter(chunk, offset, length);
if (splitAt < 0) {
appendToBuffer(chunk, offset, length);
tryEmitByHeaders();
return;
}
appendToBuffer(chunk, offset, splitAt - offset);
emitFrame();
clearBuffer();
int delimLen = delimiterLengthAt(chunk, splitAt, offset, length);
int tailOffset = splitAt + delimLen;
int tailLen = offset + length - tailOffset;
append(chunk, tailOffset, tailLen);
tryEmitByHeaders();
}
public void append(byte[] chunk) {
append(chunk, 0, chunk.length);
}
public void flush() {
tryEmitByHeaders();
if (bufferLen > 0) {
emitFrame();
clearBuffer();
}
}
/**
* Emit latest SEND/RECEIVE screen from buffer (in-place refresh without ESC).
* Does not clear the buffer.
*/
public void emitSnapshot() {
if (bufferLen == 0) {
return;
}
String text = TelnetText.normalize(bufferText());
Matcher m = FRAME_HEADER.matcher(text);
int lastStart = -1;
while (m.find()) {
lastStart = m.start();
}
if (lastStart < 0) {
return;
}
String frame = text.substring(lastStart).trim();
if (!frame.isEmpty()) {
listener.onFrame(frame);
}
}
/** Emit all complete frames when buffer holds multiple SEND/RECEIVE blocks. */
private void tryEmitByHeaders() {
if (bufferLen == 0) {
return;
}
String text = bufferText();
Matcher m = FRAME_HEADER.matcher(text);
List<Integer> starts = new ArrayList<>();
while (m.find()) {
starts.add(m.start());
}
if (starts.size() < 2) {
return;
}
for (int i = 0; i < starts.size() - 1; i++) {
String frame = text.substring(starts.get(i), starts.get(i + 1)).trim();
if (!frame.isEmpty()) {
listener.onFrame(frame);
}
}
String tail = text.substring(starts.get(starts.size() - 1));
clearBuffer();
byte[] tailBytes = tail.getBytes(CHARSET);
appendToBuffer(tailBytes, 0, tailBytes.length);
}
private String bufferText() {
return new String(buffer, 0, bufferLen, CHARSET);
}
private void emitFrame() {
if (bufferLen == 0) {
return;
}
String text = bufferText().trim();
if (!text.isEmpty()) {
listener.onFrame(text);
}
}
private void clearBuffer() {
bufferLen = 0;
}
private void appendToBuffer(byte[] src, int offset, int length) {
if (length <= 0) {
return;
}
if (bufferLen + length > buffer.length) {
tryEmitByHeaders();
if (bufferLen + length > buffer.length) {
emitFrame();
clearBuffer();
}
}
System.arraycopy(src, offset, buffer, bufferLen, length);
bufferLen += length;
}
private int findEarliestDelimiter(byte[] chunk, int offset, int length) {
int best = -1;
for (byte[] delim : delimiters) {
int idx = indexOf(chunk, offset, length, delim);
if (idx >= 0 && (best < 0 || idx < best)) {
best = idx;
}
}
return best;
}
private int delimiterLengthAt(byte[] chunk, int pos, int offset, int length) {
int end = offset + length;
for (byte[] delim : delimiters) {
if (pos + delim.length <= end && matchesAt(chunk, pos, delim)) {
return delim.length;
}
}
return 0;
}
private static int indexOf(byte[] haystack, int offset, int length, byte[] needle) {
int end = offset + length - needle.length;
for (int i = offset; i <= end; i++) {
if (matchesAt(haystack, i, needle)) {
return i;
}
}
return -1;
}
private static boolean matchesAt(byte[] haystack, int pos, byte[] needle) {
for (int i = 0; i < needle.length; i++) {
if (haystack[pos + i] != needle[i]) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,24 @@
package com.grigowashere.loratester.telnet;
import java.util.regex.Pattern;
/** Cleans telnet screen text before field extraction. */
public final class TelnetText {
private static final Pattern ANSI = Pattern.compile(
"\u001b\\[[0-9;?]*[ -/]*[@-~]|\u001b\\].*?\u0007|\u001b[@-Z\\\\-_]"
);
private TelnetText() {
}
public static String normalize(String text) {
if (text == null || text.isEmpty()) {
return "";
}
String s = ANSI.matcher(text).replaceAll("");
s = s.replace('\r', '\n');
s = s.replace("\u000c", "\n");
return s;
}
}
@@ -0,0 +1,245 @@
package com.grigowashere.loratester.track;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.ServerApi;
import com.grigowashere.loratester.location.GeoUtils;
import com.grigowashere.loratester.net.NetworkMonitor;
import com.grigowashere.loratester.telnet.StatsExtractor;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
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;
public interface Listener {
void onStateChanged(boolean recording, int pointCount, long trackId);
void onError(String message);
}
private final ServerApi serverApi;
private final TelemetryUploader uploader;
private final NetworkMonitor networkMonitor;
private final String deviceId;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "TrackSampler");
t.setDaemon(true);
return t;
});
private volatile double lat = Double.NaN;
private volatile double lon = Double.NaN;
private volatile double altitude = Double.NaN;
private volatile long trackId = -1;
private volatile boolean recording;
private final List<Map<String, Object>> buffer = new ArrayList<>();
private int totalPoints;
private ScheduledFuture<?> sampleTask;
private ScheduledFuture<?> flushTask;
private Listener listener;
public TrackRecorder(
ServerApi serverApi,
TelemetryUploader uploader,
String deviceId,
NetworkMonitor networkMonitor
) {
this.serverApi = serverApi;
this.uploader = uploader;
this.deviceId = deviceId;
this.networkMonitor = networkMonitor;
networkMonitor.addListener(online -> {
if (online && recording) {
executor.execute(this::flushBuffer);
}
});
}
public void setListener(Listener listener) {
this.listener = listener;
}
public void updateLocation(double lat, double lon, double altitude) {
if (GeoUtils.isValidCoordinate(lat, lon)) {
this.lat = lat;
this.lon = lon;
}
if (!Double.isNaN(altitude) && altitude != 0.0) {
this.altitude = altitude;
}
}
public boolean isRecording() {
return recording;
}
public int getPointCount() {
return totalPoints;
}
public long getTrackId() {
return trackId;
}
public void start() {
if (recording) {
return;
}
if (!networkMonitor.isOnline()) {
notifyError("Нужна сеть для начала трека");
return;
}
executor.execute(() -> {
try {
long id = serverApi.startTrack(deviceId);
synchronized (buffer) {
buffer.clear();
}
totalPoints = 0;
trackId = id;
recording = true;
startTimers();
notifyState();
} catch (Exception e) {
Log.e(TAG, "start track failed", e);
notifyError(e.getMessage());
}
});
}
public void stop() {
if (!recording) {
return;
}
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();
}
});
}
private void startTimers() {
sampleTask = scheduler.scheduleAtFixedRate(
() -> executor.execute(this::samplePoint),
SAMPLE_MS,
SAMPLE_MS,
TimeUnit.MILLISECONDS
);
flushTask = scheduler.scheduleAtFixedRate(
() -> executor.execute(this::flushBuffer),
FLUSH_MS,
FLUSH_MS,
TimeUnit.MILLISECONDS
);
}
private void stopTimers() {
if (sampleTask != null) {
sampleTask.cancel(false);
sampleTask = null;
}
if (flushTask != null) {
flushTask.cancel(false);
flushTask = null;
}
}
private void samplePoint() {
if (!recording || trackId < 0) {
return;
}
if (!GeoUtils.isValidCoordinate(lat, lon)) {
return;
}
StatsExtractor.ExtractedStats stats = uploader.getLastStats();
Map<String, Object> point = new HashMap<>();
point.put("ts", System.currentTimeMillis() / 1000.0);
point.put("lat", lat);
point.put("lon", lon);
if (!Double.isNaN(altitude)) {
point.put("altitude_gps", altitude);
}
if (stats != null) {
if (stats.rssi != null) {
point.put("rssi", stats.rssi);
}
if (stats.role != null) {
point.put("role", stats.role);
}
if (stats.metaJson != null) {
point.put("meta", stats.metaJson);
}
}
synchronized (buffer) {
buffer.add(point);
}
totalPoints++;
notifyState();
}
private void flushBuffer() {
if (trackId < 0) {
return;
}
List<Map<String, Object>> batch;
synchronized (buffer) {
if (buffer.isEmpty()) {
return;
}
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);
}
notifyError(e.getMessage());
}
}
private void notifyState() {
if (listener == null) {
return;
}
mainHandler.post(() -> listener.onStateChanged(recording, totalPoints, trackId));
}
private void notifyError(String msg) {
if (listener == null) {
return;
}
mainHandler.post(() -> listener.onError(msg));
}
}
@@ -0,0 +1,188 @@
package com.grigowashere.loratester.ui;
import android.content.Context;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
import com.google.android.material.textfield.TextInputEditText;
import com.grigowashere.loratester.LoraApp;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.telnet.AtCommands;
import com.grigowashere.loratester.telnet.TelnetClient;
public class AtFragment extends Fragment {
private FragmentPollHelper pollHelper;
private TelemetryUploader uploader;
private TextView atStatus;
private TextView atConsole;
private ScrollView atConsoleScroll;
private TextInputEditText atCommandInput;
private String lastConsole = "";
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader();
}
@Nullable
@Override
public View onCreateView(
@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState
) {
return inflater.inflate(R.layout.fragment_at, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
atStatus = view.findViewById(R.id.atStatus);
atConsole = view.findViewById(R.id.atConsole);
atConsoleScroll = view.findViewById(R.id.atConsoleScroll);
atCommandInput = view.findViewById(R.id.atCommandInput);
ChipGroup chips = view.findViewById(R.id.atQuickChips);
Button sendBtn = view.findViewById(R.id.atSendBtn);
Button clearLog = view.findViewById(R.id.atClearLog);
pollHelper = new FragmentPollHelper(this, this::refreshConsole);
addQuickChip(chips, "AT+H", AtCommands.HELP);
addQuickChip(chips, "AT+TX", AtCommands.TRANSMIT);
addQuickChip(chips, "AT+RX", AtCommands.RECEIVE);
addQuickChip(chips, "AT+STATUS", AtCommands.STATUS);
addQuickChip(chips, "AT", AtCommands.BASIC);
sendBtn.setOnClickListener(v -> sendFromInput());
clearLog.setOnClickListener(v -> {
if (uploader != null) {
uploader.clearConsoleLog();
}
lastConsole = "";
if (atConsole != null) {
atConsole.setText("");
}
});
if (atCommandInput != null) {
atCommandInput.setOnEditorActionListener((textView, actionId, event) -> {
sendFromInput();
return true;
});
}
}
private void addQuickChip(ChipGroup group, String label, String command) {
Chip chip = new Chip(requireContext());
chip.setText(label);
chip.setCheckable(false);
chip.setOnClickListener(v -> sendCommand(command));
group.addView(chip);
}
private void sendFromInput() {
if (atCommandInput == null || atCommandInput.getText() == null) {
return;
}
String cmd = atCommandInput.getText().toString().trim();
if (cmd.isEmpty()) {
return;
}
sendCommand(cmd);
atCommandInput.setText("");
}
private void sendCommand(String command) {
if (uploader == null || !isAdded()) {
return;
}
uploader.sendAtCommand(command, result -> {
if (!isAdded()) {
return;
}
Context ctx = getContext();
if (ctx == null) {
return;
}
if (result == TelnetClient.SendResult.NOT_CONNECTED) {
Toast.makeText(ctx, R.string.at_not_connected, Toast.LENGTH_SHORT).show();
} else if (result == TelnetClient.SendResult.IO_ERROR) {
Toast.makeText(ctx, R.string.at_send_error, Toast.LENGTH_SHORT).show();
}
updateConsoleView();
});
}
private void refreshConsole() {
if (!isAdded() || uploader == null || atStatus == null) {
return;
}
boolean telnetOn = uploader.isTelnetConnected();
atStatus.setText(getString(
R.string.at_status,
telnetOn ? getString(R.string.connected) : getString(R.string.disconnected)
));
updateConsoleView();
if (pollHelper != null) {
pollHelper.scheduleNext(400);
}
}
private void updateConsoleView() {
if (uploader == null || atConsole == null || atConsoleScroll == null) {
return;
}
String log = uploader.getConsoleLog();
if (!log.equals(lastConsole)) {
lastConsole = log;
atConsole.setText(log);
atConsoleScroll.post(() -> {
if (atConsoleScroll != null) {
atConsoleScroll.fullScroll(View.FOCUS_DOWN);
}
});
}
}
@Override
public void onResume() {
super.onResume();
if (pollHelper != null) {
pollHelper.start(0);
}
}
@Override
public void onPause() {
if (pollHelper != null) {
pollHelper.stop();
}
super.onPause();
}
@Override
public void onDestroyView() {
if (pollHelper != null) {
pollHelper.stop();
}
atStatus = null;
atConsole = null;
atConsoleScroll = null;
atCommandInput = null;
pollHelper = null;
super.onDestroyView();
}
}
@@ -0,0 +1,76 @@
package com.grigowashere.loratester.ui;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.api.ChatMessage;
import java.text.DateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
private final List<ChatMessage> messages = new ArrayList<>();
private final DateFormat timeFormat =
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault());
public void setMessages(List<ChatMessage> newMessages) {
messages.clear();
messages.addAll(newMessages);
notifyDataSetChanged();
}
public void appendMessages(List<ChatMessage> more) {
if (more.isEmpty()) {
return;
}
int start = messages.size();
messages.addAll(more);
notifyItemRangeInserted(start, more.size());
}
public double lastTs() {
if (messages.isEmpty()) {
return 0;
}
return messages.get(messages.size() - 1).ts;
}
@NonNull
@Override
public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_chat, parent, false);
return new Holder(v);
}
@Override
public void onBindViewHolder(@NonNull Holder holder, int position) {
ChatMessage m = messages.get(position);
String time = timeFormat.format(new Date((long) (m.ts * 1000)));
holder.text.setText(time + " " + m.device_id + ": " + m.text);
}
@Override
public int getItemCount() {
return messages.size();
}
static class Holder extends RecyclerView.ViewHolder {
final TextView text;
Holder(@NonNull View itemView) {
super(itemView);
text = itemView.findViewById(R.id.chatItemText);
}
}
}
@@ -0,0 +1,237 @@
package com.grigowashere.loratester.ui;
import android.content.Context;
import android.os.Build;
import android.os.Bundle;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.os.VibratorManager;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.EditText;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.grigowashere.loratester.LoraApp;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.ChatMessage;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ChatFragment extends Fragment {
private static final int VIBRATE_MS = 40;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private FragmentPollHelper pollHelper;
private TelemetryUploader uploader;
private ChatAdapter adapter;
private RecyclerView recycler;
private double chatSince;
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader();
}
@Nullable
@Override
public View onCreateView(
@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState
) {
return inflater.inflate(R.layout.fragment_chat, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
recycler = view.findViewById(R.id.chatRecycler);
EditText input = view.findViewById(R.id.chatInput);
Button send = view.findViewById(R.id.chatSend);
View inputBar = view.findViewById(R.id.chatInputBar);
adapter = new ChatAdapter();
LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
recycler.setLayoutManager(layoutManager);
recycler.setAdapter(adapter);
pollHelper = new FragmentPollHelper(this, this::pollChat);
ViewCompat.setOnApplyWindowInsetsListener(inputBar, (v, windowInsets) -> {
Insets ime = windowInsets.getInsets(WindowInsetsCompat.Type.ime());
v.setPadding(v.getPaddingLeft(), v.getPaddingTop(), v.getPaddingRight(), ime.bottom);
if (ime.bottom > 0 && adapter.getItemCount() > 0) {
scrollToLatest();
}
return windowInsets;
});
Runnable doSend = () -> sendMessage(input);
send.setOnClickListener(v -> doSend.run());
input.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_SEND) {
doSend.run();
return true;
}
return false;
});
input.setOnFocusChangeListener((v, hasFocus) -> {
if (hasFocus) {
scrollToLatest();
}
});
}
private void sendMessage(EditText input) {
String text = input.getText().toString().trim();
if (text.isEmpty() || uploader == null) {
return;
}
input.setText("");
executor.execute(() -> {
try {
uploader.getServerApi().postChat(uploader.getDeviceId(), text);
} catch (Exception ignored) {
// ignore
}
if (pollHelper != null && pollHelper.canRun()) {
pollChat();
}
});
}
private void scrollToLatest() {
if (recycler == null || adapter.getItemCount() == 0) {
return;
}
int last = adapter.getItemCount() - 1;
recycler.post(() -> recycler.smoothScrollToPosition(last));
}
private void vibrateOnNewMessage() {
Context ctx = getContext();
if (ctx == null) {
return;
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
VibratorManager vm = (VibratorManager) ctx.getSystemService(Context.VIBRATOR_MANAGER_SERVICE);
if (vm != null) {
Vibrator v = vm.getDefaultVibrator();
if (v != null && v.hasVibrator()) {
v.vibrate(VibrationEffect.createOneShot(
VIBRATE_MS,
VibrationEffect.DEFAULT_AMPLITUDE
));
}
}
} else {
Vibrator v = (Vibrator) ctx.getSystemService(Context.VIBRATOR_SERVICE);
if (v != null && v.hasVibrator()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
v.vibrate(VibrationEffect.createOneShot(
VIBRATE_MS,
VibrationEffect.DEFAULT_AMPLITUDE
));
} else {
v.vibrate(VIBRATE_MS);
}
}
}
} catch (Exception ignored) {
// ignore missing vibrator
}
}
@Override
public void onResume() {
super.onResume();
if (pollHelper != null) {
pollHelper.start(0);
}
}
@Override
public void onPause() {
if (pollHelper != null) {
pollHelper.stop();
}
super.onPause();
}
@Override
public void onDestroyView() {
if (pollHelper != null) {
pollHelper.stop();
}
recycler = null;
super.onDestroyView();
}
@Override
public void onDestroy() {
executor.shutdownNow();
super.onDestroy();
}
private void pollChat() {
if (!pollHelper.canRun() || uploader == null) {
return;
}
String myId = uploader.getDeviceId();
executor.execute(() -> {
try {
List<ChatMessage> msgs = uploader.getServerApi().getChat(chatSince);
for (ChatMessage m : msgs) {
chatSince = Math.max(chatSince, m.ts);
}
if (!pollHelper.canRun() || msgs.isEmpty()) {
if (pollHelper.canRun()) {
pollHelper.scheduleNext(2500);
}
return;
}
boolean vibrate = false;
for (ChatMessage m : msgs) {
if (m.device_id != null && !m.device_id.equals(myId)) {
vibrate = true;
break;
}
}
boolean shouldVibrate = vibrate;
requireActivity().runOnUiThread(() -> {
if (!pollHelper.canRun()) {
return;
}
if (adapter.getItemCount() == 0) {
adapter.setMessages(msgs);
} else {
adapter.appendMessages(msgs);
}
scrollToLatest();
if (shouldVibrate) {
vibrateOnNewMessage();
}
});
} catch (Exception ignored) {
// ignore
}
pollHelper.scheduleNext(2500);
});
}
}
@@ -0,0 +1,50 @@
package com.grigowashere.loratester.ui;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.Lifecycle;
/**
* Safe periodic polling: no callbacks after onPause / when fragment is detached.
*/
public final class FragmentPollHelper {
private final Fragment fragment;
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable task;
private boolean active;
public FragmentPollHelper(@NonNull Fragment fragment, @NonNull Runnable task) {
this.fragment = fragment;
this.task = task;
}
public void start(long initialDelayMs) {
active = true;
handler.removeCallbacks(task);
handler.postDelayed(task, initialDelayMs);
}
public void stop() {
active = false;
handler.removeCallbacks(task);
}
public void scheduleNext(long delayMs) {
if (!active || !fragment.isAdded()) {
return;
}
handler.removeCallbacks(task);
handler.postDelayed(task, delayMs);
}
public boolean canRun() {
return active
&& fragment.isAdded()
&& fragment.getView() != null
&& fragment.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED);
}
}
@@ -0,0 +1,73 @@
package com.grigowashere.loratester.ui;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.api.TelemetryHistoryItem;
import com.grigowashere.loratester.telnet.LoraStatsFormatter;
import java.text.DateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public class HistoryAdapter extends RecyclerView.Adapter<HistoryAdapter.Holder> {
private final DateFormat timeFormat =
DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.getDefault());
private final List<String> lines = new ArrayList<>();
public void setItems(List<TelemetryHistoryItem> items) {
lines.clear();
if (items == null) {
notifyDataSetChanged();
return;
}
for (TelemetryHistoryItem item : items) {
String time = timeFormat.format(new Date((long) (item.ts * 1000)));
String role = item.role != null ? item.role : "";
String rssi = item.rssi != null ? item.rssi + " dBm" : "";
String summary = LoraStatsFormatter.formatMeta(item.meta);
String shortMeta = summary.length() > 80
? summary.substring(0, 80) + ""
: summary;
lines.add(time + " · " + role + " · " + rssi
+ (shortMeta.isEmpty() ? "" : "\n" + shortMeta));
}
notifyDataSetChanged();
}
@NonNull
@Override
public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_history, parent, false);
return new Holder(v);
}
@Override
public void onBindViewHolder(@NonNull Holder holder, int position) {
holder.line.setText(lines.get(position));
}
@Override
public int getItemCount() {
return lines.size();
}
static class Holder extends RecyclerView.ViewHolder {
final TextView line;
Holder(@NonNull View itemView) {
super(itemView);
line = itemView.findViewById(R.id.historyLine);
}
}
}
@@ -0,0 +1,30 @@
package com.grigowashere.loratester.ui;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.viewpager2.adapter.FragmentStateAdapter;
public class MainPagerAdapter extends FragmentStateAdapter {
public MainPagerAdapter(@NonNull FragmentActivity activity) {
super(activity);
}
@NonNull
@Override
public Fragment createFragment(int position) {
return switch (position) {
case 0 -> new MapFragment();
case 1 -> new StatsFragment();
case 2 -> new AtFragment();
case 3 -> new ChatFragment();
default -> new SettingsFragment();
};
}
@Override
public int getItemCount() {
return 5;
}
}
@@ -0,0 +1,620 @@
package com.grigowashere.loratester.ui;
import android.content.Context;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.Spinner;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.grigowashere.loratester.LoraApp;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.DeviceInfo;
import com.grigowashere.loratester.api.ServerApi;
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.telnet.StatsExtractor;
import com.grigowashere.loratester.track.TrackRecorder;
import org.mapsforge.core.graphics.Bitmap;
import org.mapsforge.core.graphics.Color;
import org.mapsforge.core.model.BoundingBox;
import org.mapsforge.core.model.LatLong;
import org.mapsforge.map.android.graphics.AndroidGraphicFactory;
import org.mapsforge.map.android.util.AndroidUtil;
import org.mapsforge.map.android.view.MapView;
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.overlay.Marker;
import org.mapsforge.map.layer.overlay.Polyline;
import org.mapsforge.map.model.MapViewPosition;
import java.text.DateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MapFragment extends Fragment {
private static final int TILE_SIZE_PX = 256;
private static final long DEVICE_POLL_MS = 5000;
/** Ignore GPS jitter smaller than ~11 m. */
private static final double POSITION_EPS = 0.0001;
private static final float USER_PAN_THRESHOLD_PX = 12f;
private static final int ARGB_TX = 0xFFE94560;
private static final int ARGB_RX = 0xFF4FC3F7;
private static final int ARGB_TRACK = 0xFF00FF88;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final DateFormat timeFormat =
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault());
private final Map<String, Marker> deviceMarkers = new HashMap<>();
private final List<Layer> trackLayers = new ArrayList<>();
private FragmentPollHelper pollHelper;
private TelemetryUploader uploader;
private TrackRecorder trackRecorder;
private MapView mapView;
private TileDownloadLayer downloadLayer;
private TileCache tileCache;
private TextView mapStatus;
private TextView trackStatus;
private Button btnTrack;
private Spinner trackSpinner;
private List<TrackInfo> savedTracks = new ArrayList<>();
private boolean mapResumed;
private boolean mapInitialized;
private NetworkMonitor networkMonitor;
private NetworkMonitor.Listener networkListener;
private boolean networkOnline = true;
private boolean initialFitDone;
private boolean userMovedMap;
private boolean suppressTrackSpinner;
private Bitmap bitmapTx;
private Bitmap bitmapRx;
private Bitmap bitmapTrackPoint;
private float touchDownX;
private float touchDownY;
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
LoraApp app = (LoraApp) context.getApplicationContext();
uploader = app.getTelemetryUploader();
trackRecorder = app.getTrackRecorder();
networkMonitor = app.getNetworkMonitor();
}
@Nullable
@Override
public View onCreateView(
@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState
) {
return inflater.inflate(R.layout.fragment_map, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
mapView = view.findViewById(R.id.mapView);
mapStatus = view.findViewById(R.id.mapStatus);
trackStatus = view.findViewById(R.id.trackStatus);
btnTrack = view.findViewById(R.id.btnTrack);
trackSpinner = view.findViewById(R.id.trackSpinner);
pollHelper = new FragmentPollHelper(this, this::refreshDevices);
networkOnline = networkMonitor != null && networkMonitor.isOnline();
networkListener = online -> {
networkOnline = online;
if (isAdded() && mapStatus != null) {
requireActivity().runOnUiThread(this::updateNetworkStatusLine);
}
if (online && downloadLayer != null && mapResumed) {
downloadLayer.onResume();
}
};
if (networkMonitor != null) {
networkMonitor.addListener(networkListener);
}
setupMapView();
btnTrack.setOnClickListener(v -> toggleTracking());
setupTrackRecorderListener();
setupTrackSpinnerListener();
}
private void setupTrackSpinnerListener() {
trackSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View v, int pos, long id) {
if (suppressTrackSpinner || pos <= 0 || pos - 1 >= savedTracks.size()) {
return;
}
showTrack(savedTracks.get(pos - 1).id);
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
}
});
}
private void setupMapView() {
mapView.setClickable(true);
mapView.getMapScaleBar().setVisible(true);
mapView.setBuiltInZoomControls(false);
mapView.setOnTouchListener((v, event) -> {
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
touchDownX = event.getX();
touchDownY = event.getY();
v.getParent().requestDisallowInterceptTouchEvent(true);
} else if (action == MotionEvent.ACTION_MOVE) {
v.getParent().requestDisallowInterceptTouchEvent(true);
float dx = event.getX() - touchDownX;
float dy = event.getY() - touchDownY;
if (dx * dx + dy * dy > USER_PAN_THRESHOLD_PX * USER_PAN_THRESHOLD_PX) {
userMovedMap = true;
}
} else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
v.getParent().requestDisallowInterceptTouchEvent(false);
}
return false;
});
mapView.getModel().displayModel.setFixedTileSize(TILE_SIZE_PX);
tileCache = AndroidUtil.createTileCache(
requireContext(),
"loratester-tiles",
TILE_SIZE_PX,
1f,
mapView.getModel().frameBufferModel.getOverdrawFactor()
);
OpenStreetMapMapnik tileSource = OpenStreetMapMapnik.INSTANCE;
tileSource.setUserAgent("LoraTester/1.0");
downloadLayer = new TileDownloadLayer(
tileCache,
mapView.getModel().mapViewPosition,
tileSource,
AndroidGraphicFactory.INSTANCE
);
mapView.getLayerManager().getLayers().add(downloadLayer);
mapView.setZoomLevelMin(tileSource.getZoomLevelMin());
mapView.setZoomLevelMax(tileSource.getZoomLevelMax());
downloadLayer.start();
MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition;
position.setCenter(new LatLong(55.75, 37.62));
position.setZoomLevel((byte) 10);
bitmapTx = MapsforgeBitmaps.dot(ARGB_TX, 20);
bitmapRx = MapsforgeBitmaps.dot(ARGB_RX, 20);
bitmapTrackPoint = MapsforgeBitmaps.dot(ARGB_TRACK, 12);
mapInitialized = true;
}
@Override
public void onResume() {
super.onResume();
mapResumed = true;
if (downloadLayer != null) {
downloadLayer.onResume();
}
if (pollHelper != null) {
pollHelper.start(0);
}
if (trackRecorder != null && btnTrack != null) {
setupTrackRecorderListener();
}
loadTrackList();
}
@Override
public void onPause() {
mapResumed = false;
if (pollHelper != null) {
pollHelper.stop();
}
if (downloadLayer != null) {
downloadLayer.onPause();
}
super.onPause();
}
@Override
public void onDestroyView() {
mapResumed = false;
mapInitialized = false;
initialFitDone = false;
if (pollHelper != null) {
pollHelper.stop();
}
if (networkMonitor != null && networkListener != null) {
networkMonitor.removeListener(networkListener);
}
networkListener = null;
if (trackRecorder != null) {
trackRecorder.setListener(null);
}
removeAllDeviceMarkers();
clearTrackLayers();
deviceMarkers.clear();
if (downloadLayer != null) {
downloadLayer.onDestroy();
downloadLayer = null;
}
if (mapView != null) {
mapView.destroy();
}
mapView = null;
tileCache = null;
bitmapTx = null;
bitmapRx = null;
bitmapTrackPoint = null;
mapStatus = null;
trackStatus = null;
btnTrack = null;
trackSpinner = null;
pollHelper = null;
super.onDestroyView();
}
@Override
public void onDestroy() {
executor.shutdownNow();
super.onDestroy();
}
private void updateNetworkStatusLine() {
if (mapStatus == null) {
return;
}
CharSequence current = mapStatus.getText();
String net = networkStatusSuffix();
if (current != null && current.toString().contains(" · ")) {
int idx = current.toString().lastIndexOf(" · ");
mapStatus.setText(current.subSequence(0, idx) + " · " + net);
}
}
private String networkStatusSuffix() {
return getString(networkOnline
? R.string.map_network_online
: R.string.map_network_offline);
}
private void setupTrackRecorderListener() {
trackRecorder.setListener(new TrackRecorder.Listener() {
@Override
public void onStateChanged(boolean recording, int pointCount, long trackId) {
if (!isAdded() || btnTrack == null) {
return;
}
btnTrack.setText(recording ? R.string.track_stop : R.string.track_start);
if (trackStatus != null) {
trackStatus.setText(getString(R.string.track_status, pointCount));
}
if (!recording && trackId > 0) {
loadTrackList();
}
}
@Override
public void onError(String message) {
if (isAdded() && trackStatus != null) {
trackStatus.setText(getString(R.string.track_error, message));
}
}
});
}
private void toggleTracking() {
if (trackRecorder.isRecording()) {
trackRecorder.stop();
} else {
trackRecorder.start();
}
}
private void loadTrackList() {
if (uploader == null || !mapResumed) {
return;
}
String deviceId = uploader.getDeviceId();
executor.execute(() -> {
try {
List<TrackInfo> tracks = uploader.getServerApi().listTracks(deviceId);
if (!isAdded() || !mapResumed) {
return;
}
requireActivity().runOnUiThread(() -> {
if (mapResumed) {
updateTrackSpinner(tracks);
}
});
} catch (Exception ignored) {
// optional
}
});
}
private void updateTrackSpinner(List<TrackInfo> tracks) {
if (trackSpinner == null) {
return;
}
savedTracks = tracks != null ? tracks : new ArrayList<>();
List<String> labels = new ArrayList<>();
labels.add(getString(R.string.track_spinner_hint));
for (TrackInfo t : savedTracks) {
String start = timeFormat.format(new Date((long) (t.started_at * 1000)));
labels.add("#" + t.id + " " + start + " (" + t.point_count + " pts)");
}
if (savedTracks.isEmpty()) {
labels.set(0, getString(R.string.track_none));
}
suppressTrackSpinner = true;
trackSpinner.setAdapter(new ArrayAdapter<>(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
labels
));
trackSpinner.setSelection(0, false);
suppressTrackSpinner = false;
}
private void showTrack(long trackId) {
executor.execute(() -> {
try {
TrackDetail detail = uploader.getServerApi().getTrack(trackId);
if (!isAdded()) {
return;
}
requireActivity().runOnUiThread(() ->
runWhenMapReady(() -> drawTrack(detail)));
} catch (Exception e) {
if (isAdded() && mapStatus != null) {
requireActivity().runOnUiThread(() ->
mapStatus.setText(getString(R.string.track_error, e.getMessage())));
}
}
});
}
private void drawTrack(TrackDetail detail) {
if (!isMapReady() || detail.points == null || detail.points.isEmpty()) {
return;
}
clearTrackLayers();
List<LatLong> line = new ArrayList<>();
List<LatLong> boundsPoints = new ArrayList<>();
for (TrackDetail.TrackPoint p : detail.points) {
LatLong latLong = new LatLong(p.lat, p.lon);
line.add(latLong);
boundsPoints.add(latLong);
Marker marker = new Marker(latLong, bitmapTrackPoint, 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);
}
fitBoundsOnce(boundsPoints, detail.points.size() == 1, true);
}
private void addTrackLayer(Layer layer) {
mapView.getLayerManager().getLayers().add(layer);
trackLayers.add(layer);
}
private void clearTrackLayers() {
for (Layer layer : trackLayers) {
mapView.getLayerManager().getLayers().remove(layer);
}
trackLayers.clear();
}
private void removeAllDeviceMarkers() {
if (mapView == null) {
return;
}
for (Marker marker : deviceMarkers.values()) {
mapView.getLayerManager().getLayers().remove(marker);
}
}
private Bitmap roleBitmap(String role) {
return StatsExtractor.ROLE_RX.equals(role) ? bitmapRx : bitmapTx;
}
private static boolean samePosition(LatLong a, LatLong b) {
return Math.abs(a.latitude - b.latitude) < POSITION_EPS
&& Math.abs(a.longitude - b.longitude) < POSITION_EPS;
}
private boolean isMapReady() {
return mapResumed
&& isAdded()
&& getView() != null
&& mapView != null
&& mapInitialized
&& pollHelper != null
&& pollHelper.canRun();
}
private void runWhenMapReady(Runnable action) {
if (isMapReady()) {
action.run();
}
}
private void refreshDevices() {
if (!mapResumed || uploader == null) {
return;
}
ServerApi api = uploader.getServerApi();
executor.execute(() -> {
try {
List<DeviceInfo> devices = api.getDevices();
if (!isAdded() || !mapResumed) {
return;
}
requireActivity().runOnUiThread(() ->
runWhenMapReady(() -> updateMap(devices)));
} catch (Exception e) {
if (!isAdded() || !mapResumed) {
return;
}
requireActivity().runOnUiThread(() -> {
if (mapStatus != null && pollHelper != null && pollHelper.canRun()) {
mapStatus.setText(getString(R.string.map_error, e.getMessage()));
}
});
}
if (pollHelper != null && pollHelper.canRun()) {
pollHelper.scheduleNext(DEVICE_POLL_MS);
}
});
}
private void updateMap(List<DeviceInfo> devices) {
if (!isMapReady()) {
return;
}
int txCount = 0;
int rxCount = 0;
int onMap = 0;
List<LatLong> boundsPoints = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (DeviceInfo d : devices) {
if (StatsExtractor.ROLE_TX.equals(d.role)) {
txCount++;
} else if (StatsExtractor.ROLE_RX.equals(d.role)) {
rxCount++;
}
if (!GeoUtils.isValidCoordinate(d.lat, d.lon)) {
continue;
}
onMap++;
seen.add(d.device_id);
LatLong pos = new LatLong(d.lat, d.lon);
boundsPoints.add(pos);
Marker marker = deviceMarkers.get(d.device_id);
if (marker == null) {
marker = new Marker(pos, roleBitmap(d.role), 0, 0);
deviceMarkers.put(d.device_id, marker);
mapView.getLayerManager().getLayers().add(marker);
} else if (!samePosition(marker.getLatLong(), pos)) {
marker.setLatLong(pos);
}
}
Iterator<Map.Entry<String, Marker>> it = deviceMarkers.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, Marker> entry = it.next();
if (!seen.contains(entry.getKey())) {
mapView.getLayerManager().getLayers().remove(entry.getValue());
it.remove();
}
}
if (mapStatus != null) {
mapStatus.setText(getString(
R.string.map_status_roles,
onMap,
txCount,
rxCount,
networkStatusSuffix()
));
}
if (!boundsPoints.isEmpty() && !userMovedMap && !initialFitDone) {
fitBoundsOnce(boundsPoints, onMap == 1, false);
initialFitDone = true;
}
}
/** Adjust camera only on first device load or when user picks a saved track. */
private void fitBoundsOnce(List<LatLong> points, boolean singlePoint, boolean force) {
if (!isMapReady() || points.isEmpty() || (!force && userMovedMap)) {
return;
}
MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition;
Runnable apply = () -> {
if (!isMapReady()) {
return;
}
if (singlePoint) {
position.setCenter(points.get(0));
position.setZoomLevel((byte) 13);
return;
}
double minLat = Double.MAX_VALUE;
double maxLat = -Double.MAX_VALUE;
double minLon = Double.MAX_VALUE;
double maxLon = -Double.MAX_VALUE;
for (LatLong p : points) {
minLat = Math.min(minLat, p.latitude);
maxLat = Math.max(maxLat, p.latitude);
minLon = Math.min(minLon, p.longitude);
maxLon = Math.max(maxLon, p.longitude);
}
double padLat = Math.max((maxLat - minLat) * 0.2, 0.003);
double padLon = Math.max((maxLon - minLon) * 0.2, 0.003);
BoundingBox box = new BoundingBox(
minLat - padLat, minLon - padLon, maxLat + padLat, maxLon + padLon);
position.setCenter(box.getCenterPoint());
int w = Math.max(mapView.getWidth(), 1);
int h = Math.max(mapView.getHeight(), 1);
double latSpan = Math.max(box.maxLatitude - box.minLatitude, 0.001);
double lonSpan = Math.max(box.maxLongitude - box.minLongitude, 0.001);
double latZoom = Math.log(h / (double) TILE_SIZE_PX / latSpan) / Math.log(2);
double lonZoom = Math.log(w / (double) TILE_SIZE_PX / lonSpan) / Math.log(2);
byte zoom = (byte) Math.max(8, Math.min(15, Math.floor(Math.min(latZoom, lonZoom))));
position.setZoomLevel(zoom);
};
if (mapView.getWidth() > 0 && mapView.getHeight() > 0) {
apply.run();
} else {
mapView.post(apply);
}
}
}
@@ -0,0 +1,38 @@
package com.grigowashere.loratester.ui;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import org.mapsforge.core.graphics.Color;
import org.mapsforge.map.android.graphics.AndroidBitmap;
import org.mapsforge.map.android.graphics.AndroidGraphicFactory;
/** Small colored bitmaps for device/track markers on Mapsforge. */
final class MapsforgeBitmaps {
private MapsforgeBitmaps() {
}
static org.mapsforge.core.graphics.Bitmap dot(int argb, int sizePx) {
Bitmap androidBitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(androidBitmap);
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(argb);
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 1f, paint);
Paint stroke = new Paint(Paint.ANTI_ALIAS_FLAG);
stroke.setStyle(Paint.Style.STROKE);
stroke.setColor(0xFFFFFFFF);
stroke.setStrokeWidth(2f);
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 2f, stroke);
return new AndroidBitmap(androidBitmap);
}
static org.mapsforge.core.graphics.Paint linePaint(Color color, float strokeWidth) {
org.mapsforge.core.graphics.Paint paint = AndroidGraphicFactory.INSTANCE.createPaint();
paint.setColor(color);
paint.setStrokeWidth(strokeWidth);
paint.setStyle(org.mapsforge.core.graphics.Style.STROKE);
return paint;
}
}
@@ -0,0 +1,84 @@
package com.grigowashere.loratester.ui;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.google.android.material.switchmaterial.SwitchMaterial;
import com.google.android.material.textfield.TextInputEditText;
import com.grigowashere.loratester.LoraApp;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.SettingsRepository;
import com.grigowashere.loratester.TelemetryUploader;
public class SettingsFragment extends Fragment {
@Nullable
@Override
public View onCreateView(
@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState
) {
return inflater.inflate(R.layout.fragment_settings, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
LoraApp app = (LoraApp) requireActivity().getApplication();
SettingsRepository settings = app.getSettingsRepository();
TelemetryUploader uploader = app.getTelemetryUploader();
TextInputEditText editServer = view.findViewById(R.id.editServerUrl);
TextInputEditText editHost = view.findViewById(R.id.editTelnetHost);
TextInputEditText editPort = view.findViewById(R.id.editTelnetPort);
TextInputEditText editRssi = view.findViewById(R.id.editRssiRegex);
TextInputEditText editRange = view.findViewById(R.id.editRangeRegex);
SwitchMaterial switchTelnet = view.findViewById(R.id.switchTelnet);
TextView deviceIdLabel = view.findViewById(R.id.deviceIdLabel);
Button save = view.findViewById(R.id.btnSaveSettings);
editServer.setText(settings.getServerUrl());
editHost.setText(settings.getTelnetHost());
editPort.setText(String.valueOf(settings.getTelnetPort()));
editRssi.setText(settings.getRssiRegex());
editRange.setText(settings.getRangeRegex());
switchTelnet.setChecked(settings.isTelnetEnabled());
deviceIdLabel.setText(getString(R.string.device_id_label, settings.getOrCreateDeviceId()));
save.setOnClickListener(v -> {
settings.setServerUrl(textOf(editServer, SettingsRepository.DEFAULT_SERVER));
settings.setTelnetHost(textOf(editHost, SettingsRepository.DEFAULT_TELNET_HOST));
try {
settings.setTelnetPort(Integer.parseInt(textOf(editPort, "2727")));
} catch (NumberFormatException e) {
settings.setTelnetPort(SettingsRepository.DEFAULT_TELNET_PORT);
}
settings.setRssiRegex(textOf(editRssi, SettingsRepository.DEFAULT_RSSI_REGEX));
settings.setRangeRegex(textOf(editRange, SettingsRepository.DEFAULT_RANGE_REGEX));
settings.setTelnetEnabled(switchTelnet.isChecked());
uploader.refreshApi();
if (switchTelnet.isChecked()) {
uploader.startTelnet();
} else {
uploader.stopTelnet();
}
Toast.makeText(requireContext(), R.string.saved, Toast.LENGTH_SHORT).show();
});
}
private static String textOf(TextInputEditText edit, String fallback) {
if (edit.getText() == null || edit.getText().toString().trim().isEmpty()) {
return fallback;
}
return edit.getText().toString().trim();
}
}
@@ -0,0 +1,266 @@
package com.grigowashere.loratester.ui;
import android.content.Context;
import android.os.Bundle;
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.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.grigowashere.loratester.LoraApp;
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.telnet.LoraStatsFormatter;
import com.grigowashere.loratester.telnet.StatsExtractor;
import java.text.DateFormat;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class StatsFragment extends Fragment {
private static final long SERVER_POLL_MS = 1000;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final DateFormat timeFormat =
DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.getDefault());
private FragmentPollHelper pollHelper;
private TelemetryUploader uploader;
private TextView statsStatus;
private TextView statsDetails;
private RecyclerView statsHistoryList;
private final HistoryAdapter historyAdapter = new HistoryAdapter();
private StatsExtractor.ExtractedStats cachedLocal;
private DeviceInfo cachedServer;
private int cachedDeviceCount;
private String cachedError;
private final TelemetryUploader.StatsListener statsListener = stats -> {
cachedLocal = stats;
cachedError = null;
postRender();
};
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader();
}
@Nullable
@Override
public View onCreateView(
@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState
) {
return inflater.inflate(R.layout.fragment_stats, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
statsStatus = view.findViewById(R.id.statsStatus);
statsDetails = view.findViewById(R.id.statsDetails);
statsHistoryList = view.findViewById(R.id.statsHistoryList);
statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext()));
statsHistoryList.setAdapter(historyAdapter);
Button btnSimulate = view.findViewById(R.id.btnSimulate);
pollHelper = new FragmentPollHelper(this, this::refresh);
btnSimulate.setOnClickListener(v -> {
String chunk = """
SEND
Frequency: 433000000 Hz
Power: 22 dBm
Packet: 1
Payload: Sim TX
\u001b[2J""";
uploader.simulateChunk(chunk);
if (pollHelper.canRun()) {
statsStatus.setText(R.string.simulate_sent);
}
});
}
@Override
public void onResume() {
super.onResume();
if (uploader != null) {
uploader.setStatsListener(statsListener);
cachedLocal = uploader.getLastStats();
postRender();
}
if (pollHelper != null) {
pollHelper.start(0);
}
}
@Override
public void onPause() {
if (uploader != null) {
uploader.setStatsListener(null);
}
if (pollHelper != null) {
pollHelper.stop();
}
super.onPause();
}
@Override
public void onDestroyView() {
if (pollHelper != null) {
pollHelper.stop();
}
statsStatus = null;
statsDetails = null;
statsHistoryList = null;
pollHelper = null;
super.onDestroyView();
}
@Override
public void onDestroy() {
executor.shutdownNow();
super.onDestroy();
}
private void postRender() {
if (!isAdded() || statsDetails == null) {
return;
}
requireActivity().runOnUiThread(this::renderDetails);
}
private void refresh() {
if (!pollHelper.canRun() || uploader == null || statsStatus == null) {
return;
}
String deviceId = uploader.getDeviceId();
boolean telnet = uploader.isTelnetConnected();
statsStatus.setText(getString(
R.string.stats_status,
deviceId,
telnet ? getString(R.string.connected) : getString(R.string.disconnected)
));
executor.execute(() -> {
List<TelemetryHistoryItem> history = null;
try {
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
DeviceInfo self = null;
for (DeviceInfo d : devices) {
if (deviceId.equals(d.device_id)) {
self = d;
break;
}
}
cachedServer = self;
cachedDeviceCount = devices.size();
cachedError = null;
history = uploader.getServerApi().getTelemetryHistory(deviceId, 30);
} catch (Exception e) {
cachedError = e.getMessage() != null ? e.getMessage() : "error";
}
List<TelemetryHistoryItem> finalHistory = history;
if (isAdded()) {
requireActivity().runOnUiThread(() -> {
if (historyAdapter != null) {
historyAdapter.setItems(finalHistory);
}
});
}
postRender();
if (pollHelper != null) {
pollHelper.scheduleNext(SERVER_POLL_MS);
}
});
}
private void renderDetails() {
if (!isAdded() || statsDetails == null || uploader == null) {
return;
}
StringBuilder sb = new StringBuilder();
sb.append(getString(R.string.devices_on_server, cachedDeviceCount)).append("\n");
sb.append(getString(
uploader.isTelnetConnected() ? R.string.telnet_connected : R.string.telnet_disconnected
));
long at = uploader.getLastStatsAtMs();
if (at > 0) {
sb.append(" · ").append(getString(R.string.stats_updated_at, timeFormat.format(new Date(at))));
}
sb.append("\n\n");
String meta = pickMetaJson();
if (meta != null && !meta.isEmpty()) {
String fields = LoraStatsFormatter.formatMeta(meta);
if (!fields.isEmpty()) {
sb.append(fields).append("\n");
}
} else if (cachedError != null) {
sb.append(getString(R.string.stats_error, cachedError)).append("\n");
} else {
sb.append(getString(R.string.no_telemetry_yet)).append("\n");
}
Double rssi = pickRssi();
sb.append("\nСигнал (dBm): ").append(rssi != null ? rssi : "").append("\n");
Double lat = null;
Double lon = null;
if (cachedServer != null) {
lat = cachedServer.lat;
lon = cachedServer.lon;
}
if (GeoUtils.isValidCoordinate(lat, lon)) {
sb.append("GPS: ").append(lat).append(", ").append(lon).append("\n");
} else {
sb.append(getString(R.string.gps_waiting)).append("\n");
}
statsDetails.setText(sb.toString());
}
private String pickMetaJson() {
boolean telnet = uploader.isTelnetConnected();
if (telnet && cachedLocal != null && cachedLocal.metaJson != null) {
return cachedLocal.metaJson;
}
if (cachedServer != null && cachedServer.meta != null && !cachedServer.meta.isEmpty()) {
return cachedServer.meta;
}
if (cachedLocal != null && cachedLocal.metaJson != null) {
return cachedLocal.metaJson;
}
return null;
}
private Double pickRssi() {
boolean telnet = uploader.isTelnetConnected();
if (telnet && cachedLocal != null && cachedLocal.rssi != null) {
return cachedLocal.rssi;
}
if (cachedServer != null && cachedServer.rssi != null) {
return cachedServer.rssi;
}
if (cachedLocal != null) {
return cachedLocal.rssi;
}
return null;
}
}
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size
android:width="16dp"
android:height="16dp" />
<solid android:color="#E94560" />
</shape>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size
android:width="18dp"
android:height="18dp" />
<solid android:color="#4FC3F7" />
<stroke
android:width="2dp"
android:color="#FFFFFF" />
</shape>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size
android:width="18dp"
android:height="18dp" />
<solid android:color="#E94560" />
<stroke
android:width="2dp"
android:color="#FFFFFF" />
</shape>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="scrollable" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
+82
View File
@@ -0,0 +1,82 @@
<?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"
android:padding="12dp">
<TextView
android:id="@+id/atStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/atQuickChips"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/at_command_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atCommandInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:inputType="text"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/atSendBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="8dp"
android:text="@string/send" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:orientation="horizontal">
<Button
android:id="@+id/atClearLog"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/at_clear_log" />
</LinearLayout>
<ScrollView
android:id="@+id/atConsoleScroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:background="#0D1117"
android:padding="8dp">
<TextView
android:id="@+id/atConsole"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textColor="#C9D1D9"
android:textIsSelectable="true"
android:textSize="11sp" />
</ScrollView>
</LinearLayout>
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/chatRoot"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingHorizontal="8dp"
android:paddingTop="8dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chatRecycler"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:paddingBottom="4dp" />
<LinearLayout
android:id="@+id/chatInputBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="8dp">
<EditText
android:id="@+id/chatInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/chat_hint"
android:imeOptions="actionSend"
android:inputType="textCapSentences" />
<Button
android:id="@+id/chatSend"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/send" />
</LinearLayout>
</LinearLayout>
+69
View File
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.mapsforge.map.android.view.MapView
android:id="@+id/mapView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ScrollView
android:id="@+id/mapSidePanel"
android:layout_width="152dp"
android:layout_height="wrap_content"
android:layout_gravity="end|top"
android:layout_margin="6dp"
android:background="#CC0F3460"
android:elevation="4dp"
android:fillViewport="false"
android:scrollbars="none">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="6dp">
<TextView
android:id="@+id/mapStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#FFFFFF"
android:textSize="10sp" />
<TextView
android:id="@+id/mapLegend"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/map_legend"
android:textColor="#CCCCCC"
android:textSize="9sp" />
<Button
android:id="@+id/btnTrack"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:minHeight="36dp"
android:text="@string/track_start"
android:textSize="11sp" />
<TextView
android:id="@+id/trackStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#CCCCCC"
android:textSize="9sp" />
<Spinner
android:id="@+id/trackSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp" />
</LinearLayout>
</ScrollView>
</FrameLayout>
@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/server_url">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editServerUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/telnet_host">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTelnetHost"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/telnet_port">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editTelnetPort"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/rssi_regex">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editRssiRegex"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/range_regex">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editRangeRegex"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchTelnet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/telnet_enabled" />
<TextView
android:id="@+id/deviceIdLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textSize="12sp" />
<Button
android:id="@+id/btnSaveSettings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/save" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/statsStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp" />
<Button
android:id="@+id/btnSimulate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/simulate_telnet" />
<TextView
android:id="@+id/statsDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:fontFamily="monospace"
android:textSize="12sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/stats_history_title"
android:textSize="14sp"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/statsHistoryList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false" />
</LinearLayout>
</ScrollView>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/chatItemText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="6dp"
android:textSize="13sp" />
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/historyLine"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:paddingVertical="4dp"
android:textSize="11sp" />
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+7
View File
@@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.LoraTester" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your dark theme here. -->
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
</style>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+52
View File
@@ -0,0 +1,52 @@
<resources>
<string name="app_name">LoraTester</string>
<string name="tab_map">Карта</string>
<string name="tab_stats">Статистика</string>
<string name="tab_at">AT</string>
<string name="tab_chat">Чат</string>
<string name="tab_settings">Настройки</string>
<string name="at_status">Telnet: %1$s</string>
<string name="at_command_hint">AT+TX или +H …</string>
<string name="at_clear_log">Очистить лог</string>
<string name="at_not_connected">Telnet не подключён. Включите в Настройках.</string>
<string name="at_send_error">Ошибка отправки команды</string>
<string name="server_url">URL сервера</string>
<string name="telnet_host">Telnet host</string>
<string name="telnet_port">Telnet port</string>
<string name="rssi_regex">RSSI regex</string>
<string name="range_regex">Range regex</string>
<string name="telnet_enabled">Подключить telnet</string>
<string name="device_id_label">ID устройства: %1$s</string>
<string name="save">Сохранить</string>
<string name="saved">Сохранено</string>
<string name="chat_hint">Сообщение…</string>
<string name="send">Отправить</string>
<string name="simulate_telnet">Симуляция телнет-кадра</string>
<string name="simulate_sent">Кадр отправлен на сервер</string>
<string name="stats_status">%1$s · Telnet: %2$s</string>
<string name="connected">подключён</string>
<string name="disconnected">нет</string>
<string name="devices_on_server">Устройств на сервере: %1$d</string>
<string name="no_telemetry_yet">Телеметрия ещё не получена. Нажмите «Симуляция» или включите telnet.</string>
<string name="stats_error">Ошибка: %1$s</string>
<string name="map_error">Ошибка карты: %1$s</string>
<string name="devices_label">устройств</string>
<string name="map_legend">● красный — передатчик (TX) ● голубой — приёмник (RX)</string>
<string name="map_status_roles">На карте: %1$d · TX: %2$d · RX: %3$d · %4$s</string>
<string name="map_network_online">онлайн</string>
<string name="map_network_offline">офлайн (кэш)</string>
<string name="track_need_network">Нужна сеть для начала трека</string>
<string name="upload_queue_pending">В очереди: %1$d</string>
<string name="gps_waiting">GPS: ожидание фикса…</string>
<string name="stats_updated_at">обновлено %1$s</string>
<string name="telnet_connected">Telnet: подключён</string>
<string name="telnet_disconnected">Telnet: нет</string>
<string name="stats_history_title">История (сервер)</string>
<string name="track_start">Начать трекинг пути</string>
<string name="track_stop">Остановить трекинг</string>
<string name="track_status">Трекинг: %1$d точек</string>
<string name="track_saved">Трек #%1$d сохранён (%2$d точек)</string>
<string name="track_error">Трек: %1$s</string>
<string name="track_spinner_hint">Сохранённые треки</string>
<string name="track_none">— нет треков —</string>
</resources>
+9
View File
@@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Base.Theme.LoraTester" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Customize your light theme here. -->
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
</style>
<style name="Theme.LoraTester" parent="Base.Theme.LoraTester" />
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">grigowashere.ru</domain>
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>