16 Commits

Author SHA1 Message Date
Grigo c5805eaa5c added rx Quality 2026-06-15 11:57:23 +03:00
Grigo 012947fd99 added rx Quality 2026-06-15 11:41:39 +03:00
Grigo 23eb7ffb91 added rx Quality 2026-06-15 11:17:10 +03:00
Grigo e20b81c817 fixed modal 2026-06-15 11:04:34 +03:00
Grigo 2f303134c1 added linear slider 2026-06-15 08:40:27 +03:00
Grigo ab2a3bb035 added linear slider 2026-06-15 07:50:41 +03:00
Grigo d28391c71f added grid 2026-06-11 10:22:36 +03:00
Grigo c2f26c8ec3 added subprox 2026-06-11 09:32:33 +03:00
Grigo 94e2b772e8 added subproxy 2026-06-11 09:09:28 +03:00
Grigo 17d383ddc6 added bind 2026-06-11 08:46:49 +03:00
Grigo 8fd7e85c83 added local api 2026-06-11 08:38:08 +03:00
Grigo 81eaa95df3 Initial commit: LoraTester Android + server 2026-06-04 14:39:14 +03:00
Grigo 253a7d74ca Initial commit: LoraTester Android + server 2026-06-04 13:19:28 +03:00
Grigo ab7c214966 Initial commit: LoraTester Android + server 2026-06-04 13:15:42 +03:00
Grigo cbcd3399b3 Merge branch 'main' of https://git.grigowashere.ru/Grigo/LoraMapTester 2026-06-04 13:06:46 +03:00
Grigo 83d0353754 Initial commit: LoraTester Android + server 2026-06-04 13:05:21 +03:00
157 changed files with 14496 additions and 98 deletions
+15
View File
@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
Vendored
+6 -98
View File
@@ -7,10 +7,8 @@ pipeline {
ANDROID_SDK_ROOT = '/opt/android-sdk' ANDROID_SDK_ROOT = '/opt/android-sdk'
PATH = "/usr/lib/jvm/java-21-openjdk-amd64/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:${env.PATH}" PATH = "/usr/lib/jvm/java-21-openjdk-amd64/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:${env.PATH}"
TAIGA_PROJECT_ID = '2'
TAIGA_URL = 'https://taiga.grigowashere.ru'
GITEA_OWNER = 'Grigo' GITEA_OWNER = 'Grigo'
GITEA_REPO = 'TestingAndroidBuild' // Замените на нужный репозиторий GITEA_REPO = 'LoraMapTester' // Замените на нужный репозиторий
GITEA_URL = 'https://git.grigowashere.ru' // Базовый URL Gitea GITEA_URL = 'https://git.grigowashere.ru' // Базовый URL Gitea
GITEA_API_URL = "${GITEA_URL}/api/v1" GITEA_API_URL = "${GITEA_URL}/api/v1"
GITEA_TOKEN_CREDENTIALS_ID = 'Gitea_Credentials' // ID ваших креденшлов для Gitea в Jenkins GITEA_TOKEN_CREDENTIALS_ID = 'Gitea_Credentials' // ID ваших креденшлов для Gitea в Jenkins
@@ -40,7 +38,11 @@ pipeline {
writeFile file: 'gitea-release.sh', text: ''' writeFile file: 'gitea-release.sh', text: '''
#!/bin/bash #!/bin/bash
apkPath="build/outputs/apk/debug/app-debug.apk" apkPath=$(find . -path '*/build/outputs/apk/debug/*.apk' -type f | head -1)
if [ -z "$apkPath" ]; then
echo "APK not found under */build/outputs/apk/debug/"
exit 1
fi
headers="Authorization: token $GITEA_TOKEN" headers="Authorization: token $GITEA_TOKEN"
# Создаем релиз на Gitea # Создаем релиз на Gitea
@@ -80,98 +82,4 @@ fi
} }
} }
} }
post {
always {
script {
def result = currentBuild.currentResult ?: 'UNKNOWN'
withCredentials([string(credentialsId: 'TAIGA_TOKEN', variable: 'TAIGA_TOKEN')]) {
sh(returnStatus: true, script: """
set +e
REF=\$(git log -1 --pretty=%B | grep -oE 'TG-[0-9]+' | head -1 | cut -d- -f2 || true)
if [ -z "\$REF" ]; then
echo "No TG-* reference found"
exit 0
fi
export REF
export BUILD_RESULT="${result}"
python3 - <<'PY'
import json
import os
import urllib.request
taiga_url = os.environ["TAIGA_URL"]
project_id = os.environ["TAIGA_PROJECT_ID"]
token = os.environ["TAIGA_TOKEN"]
ref = os.environ["REF"]
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
def get_json(path):
url = f"{taiga_url}{path}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode("utf-8"))
except Exception:
return None
targets = [
("userstories", "User Story"),
("issues", "Issue"),
("tasks", "Task"),
]
found = None
for endpoint, label in targets:
data = get_json(f"/api/v1/{endpoint}/by_ref?project={project_id}&ref={ref}")
if data and "id" in data:
found = (endpoint, label, data)
break
if not found:
print(f"Taiga TG-{ref} not found")
raise SystemExit(0)
endpoint, label, data = found
comment = (
f"Jenkins Android build #{os.environ['BUILD_NUMBER']}: {os.environ['BUILD_RESULT']}\\n"
f"{os.environ['BUILD_URL']}"
)
payload = json.dumps({
"comment": comment,
"version": data["version"],
}).encode("utf-8")
url = f"{taiga_url}/api/v1/{endpoint}/{data['id']}"
req = urllib.request.Request(
url,
data=payload,
headers=headers,
method="PATCH",
)
try:
with urllib.request.urlopen(req) as r:
print(f"Commented Taiga TG-{ref} ({label}), HTTP {r.status}")
except Exception as e:
print(f"Taiga comment warning: {e}")
raise SystemExit(0)
PY
""".stripIndent())
}
}
}
}
} }
+24
View File
@@ -0,0 +1,24 @@
# LoraTester
Android-клиент и Python-сервер для мониторинга LoRa приёмопередатчика (telnet → парсинг кадров), GPS-позиций, карты и чата между устройствами.
## Компоненты
- **Android** (`app/`) — telnet на `127.0.0.1:2727`, AT-команды (AT+H, AT+TX, …), отправка телеметрии на сервер, карта OSMDroid, чат, настройки.
- **Server** (`server/`) — Flask (основной) + FastAPI, веб-карта Leaflet, REST API. См. [server/README.md](server/README.md).
## Быстрый старт
1. Запустите сервер: `cd server && pip install -r requirements.txt && python flask_app.py`
2. Соберите APK в Android Studio или `./gradlew assembleDebug`
3. В приложении: Настройки → URL `https://lora.grigowashere.ru` (или свой сервер), включите telnet при наличии моста COM→telnet
## Тесты
```bash
./gradlew test
```
Симуляция телнет-кадра: вкладка **Статистика** → «Симуляция телнет-кадра».
AT-команды: вкладка **AT** — быстрые кнопки и произвольная строка (добавляются префикс `AT` и `\r\n`). Нужен включённый telnet в **Настройках**.
+1
View File
@@ -0,0 +1 @@
/build
+57
View File
@@ -0,0 +1,57 @@
plugins {
alias(libs.plugins.android.application)
}
android {
namespace = "com.grigowashere.loratester"
compileSdk = 35
defaultConfig {
applicationId = "com.grigowashere.loratester"
minSdk = 30
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(libs.appcompat)
implementation(libs.material)
implementation(libs.activity)
implementation(libs.constraintlayout)
implementation(libs.okhttp)
implementation(libs.gson)
implementation(libs.play.services.location)
implementation(libs.mapsforge.core)
implementation(libs.mapsforge.map)
implementation(libs.mapsforge.map.android)
implementation(libs.mapsforge.map.reader)
implementation(libs.mapsforge.themes)
implementation(libs.viewpager2)
implementation(libs.fragment)
implementation(libs.recyclerview)
testImplementation(libs.junit)
testImplementation(libs.okhttp)
testImplementation(libs.gson)
testImplementation(libs.mockwebserver)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package com.grigowashere.loratester;
import android.content.Context;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.grigowashere.loratester", appContext.getPackageName());
}
}
+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,292 @@
package com.grigowashere.loratester;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.annotation.Nullable;
import com.grigowashere.loratester.api.DeviceCommand;
import com.grigowashere.loratester.api.PairedTrackSession;
import com.grigowashere.loratester.api.ServerApi;
import com.grigowashere.loratester.telnet.AtCommands;
import com.grigowashere.loratester.telnet.TelnetClient;
import com.grigowashere.loratester.track.TrackRecorder;
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.atomic.AtomicBoolean;
public class CommandPoller {
private static final String TAG = "CommandPoller";
private static final long COMMAND_POLL_MS = 2000;
private static final long PAIRED_POLL_MS = 1500;
private final ServerApi serverApi;
private final String deviceId;
private final TelemetryUploader uploader;
private final TrackRecorder trackRecorder;
private final PeerStatsCache peerStatsCache;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private final AtomicBoolean running = new AtomicBoolean(false);
private volatile long pendingAckSessionId = -1;
private volatile long startedSessionId = -1;
public CommandPoller(
ServerApi serverApi,
String deviceId,
TelemetryUploader uploader,
TrackRecorder trackRecorder,
PeerStatsCache peerStatsCache
) {
this.serverApi = serverApi;
this.deviceId = deviceId;
this.uploader = uploader;
this.trackRecorder = trackRecorder;
this.peerStatsCache = peerStatsCache;
trackRecorder.setPairedListener(new TrackRecorder.Listener() {
@Override
public void onStateChanged(boolean recording, int pointCount, long trackId) {
if (recording && trackId > 0 && pendingAckSessionId > 0) {
long sid = pendingAckSessionId;
pendingAckSessionId = -1;
executor.execute(() -> ackSession(sid, trackId));
}
}
@Override
public void onError(String message) {
Log.w(TAG, "track: " + message);
}
});
}
public PeerStatsCache getPeerStatsCache() {
return peerStatsCache;
}
public void start() {
if (!running.compareAndSet(false, true)) {
return;
}
scheduleCommandPoll();
schedulePairedPoll();
}
public void stop() {
running.set(false);
}
private void scheduleCommandPoll() {
executor.execute(() -> {
if (running.get()) {
pollCommands();
}
if (running.get()) {
mainHandler.postDelayed(this::scheduleCommandPoll, COMMAND_POLL_MS);
}
});
}
private void schedulePairedPoll() {
executor.execute(() -> {
if (running.get()) {
pollPairedSession();
}
if (running.get()) {
mainHandler.postDelayed(this::schedulePairedPoll, PAIRED_POLL_MS);
}
});
}
private void pollCommands() {
try {
List<DeviceCommand> cmds = serverApi.pollPendingCommands(deviceId);
for (DeviceCommand cmd : cmds) {
execute(cmd);
}
} catch (Exception e) {
Log.w(TAG, "command poll failed", e);
}
}
private void execute(DeviceCommand cmd) {
if (cmd == null || cmd.kind == null) {
return;
}
switch (cmd.kind) {
case "at" -> {
List<String> lines = extractLines(cmd.payload);
if (lines != null && !lines.isEmpty()) {
uploader.sendMacroSequence(lines, r ->
Log.i(TAG, "remote macro " + lines + " -> " + r));
} else {
String line = cmd.payload != null && cmd.payload.get("line") != null
? String.valueOf(cmd.payload.get("line")) : null;
if (line != null) {
uploader.sendAtCommand(line, r ->
Log.i(TAG, "remote AT " + line + " -> " + r));
}
}
}
case "mode" -> {
String role = cmd.payload != null && cmd.payload.get("role") != null
? String.valueOf(cmd.payload.get("role")) : null;
if ("TX".equalsIgnoreCase(role)) {
uploader.sendAtCommand(AtCommands.TRANSMIT, r -> {});
} else if ("RX".equalsIgnoreCase(role)) {
uploader.sendAtCommand(AtCommands.RECEIVE, r -> {});
}
}
case "stats_push" -> peerStatsCache.updateFromPayload(cmd.payload);
default -> Log.w(TAG, "unknown kind " + cmd.kind);
}
}
private void pollPairedSession() {
try {
Map<String, Object> resp = serverApi.getActivePairedTrack();
Object sessionObj = resp.get("session");
if (!(sessionObj instanceof Map)) {
return;
}
@SuppressWarnings("unchecked")
Map<String, Object> m = (Map<String, Object>) sessionObj;
PairedTrackSession session = mapSession(m);
if (session == null) {
return;
}
boolean inSession = deviceId.equals(session.device_a)
|| deviceId.equals(session.device_b);
if (!inSession) {
return;
}
if (!session.ready || trackRecorder.isRecording()) {
return;
}
if (startedSessionId == session.id) {
return;
}
Long myTrack = deviceId.equals(session.device_a)
? session.track_id_a : session.track_id_b;
if (myTrack != null && myTrack > 0) {
startedSessionId = session.id;
return;
}
startedSessionId = session.id;
pendingAckSessionId = session.id;
mainHandler.post(trackRecorder::start);
} catch (Exception e) {
Log.w(TAG, "paired poll failed", e);
}
}
private void ackSession(long sessionId, long trackId) {
try {
serverApi.ackPairedTrack(sessionId, deviceId, trackId);
Log.i(TAG, "paired ack session=" + sessionId + " track=" + trackId);
} catch (Exception e) {
Log.e(TAG, "paired ack failed", e);
pendingAckSessionId = sessionId;
}
}
private static PairedTrackSession mapSession(Map<String, Object> m) {
if (m == null) {
return null;
}
PairedTrackSession s = new PairedTrackSession();
Object id = m.get("id");
if (id instanceof Number) {
s.id = ((Number) id).longValue();
}
s.device_a = str(m.get("device_a"));
s.device_b = str(m.get("device_b"));
s.initiator = str(m.get("initiator"));
s.status = str(m.get("status"));
s.start_at = num(m.get("start_at"));
s.created_at = num(m.get("created_at"));
s.server_time = num(m.get("server_time"));
Object ready = m.get("ready");
s.ready = ready instanceof Boolean && (Boolean) ready;
s.track_id_a = longOrNull(m.get("track_id_a"));
s.track_id_b = longOrNull(m.get("track_id_b"));
return s;
}
private static String str(Object o) {
return o != null ? String.valueOf(o) : null;
}
private static double num(Object o) {
return o instanceof Number ? ((Number) o).doubleValue() : 0;
}
private static Long longOrNull(Object o) {
return o instanceof Number ? ((Number) o).longValue() : null;
}
@Nullable
private static List<String> extractLines(Map<String, Object> payload) {
if (payload == null) {
return null;
}
Object raw = payload.get("lines");
if (!(raw instanceof List<?> list) || list.isEmpty()) {
return null;
}
List<String> lines = new java.util.ArrayList<>();
for (Object item : list) {
if (item != null) {
String line = String.valueOf(item).trim();
if (!line.isEmpty()) {
lines.add(line);
}
}
}
return lines.isEmpty() ? null : lines;
}
public void postMacroToPeer(String peerId, List<String> lines) {
Map<String, Object> payload = new HashMap<>();
payload.put("lines", lines);
postCommandToPeer(peerId, "at", payload);
}
public void postCommandToPeer(String peerId, String kind, Map<String, Object> payload) {
executor.execute(() -> {
try {
serverApi.postCommand(deviceId, peerId, kind, payload);
} catch (Exception e) {
Log.e(TAG, "post command failed", e);
}
});
}
public void startPairedTrack(Runnable onDone, java.util.function.Consumer<String> onError) {
executor.execute(() -> {
try {
Map<String, Object> body = new HashMap<>();
body.put("device_id", deviceId);
serverApi.startPairedTrack(body);
startedSessionId = -1;
pendingAckSessionId = -1;
if (onDone != null) {
mainHandler.post(onDone);
}
} catch (Exception e) {
Log.e(TAG, "start paired failed", e);
if (onError != null) {
mainHandler.post(() -> onError.accept(
e.getMessage() != null ? e.getMessage() : "error"));
}
}
});
}
}
@@ -0,0 +1,94 @@
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;
private PeerStatsCache peerStatsCache;
private CommandPoller commandPoller;
@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);
peerStatsCache = new PeerStatsCache();
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
String deviceId = settingsRepository.getOrCreateDeviceId();
trackRecorder = new TrackRecorder(
serverApi,
telemetryUploader,
deviceId,
networkMonitor
);
commandPoller = new CommandPoller(
serverApi,
deviceId,
telemetryUploader,
trackRecorder,
peerStatsCache
);
commandPoller.start();
}
public NetworkMonitor getNetworkMonitor() {
return networkMonitor;
}
public TelemetryUploader getTelemetryUploader() {
return telemetryUploader;
}
public SettingsRepository getSettingsRepository() {
return settingsRepository;
}
public TrackRecorder getTrackRecorder() {
return trackRecorder;
}
public PeerStatsCache getPeerStatsCache() {
return peerStatsCache;
}
public CommandPoller getCommandPoller() {
return commandPoller;
}
public void refreshTrackRecorder() {
if (commandPoller != null) {
commandPoller.stop();
}
if (peerStatsCache == null) {
peerStatsCache = new PeerStatsCache();
}
ServerApi serverApi = new ServerApi(settingsRepository.getServerUrl());
trackRecorder = new TrackRecorder(
serverApi,
telemetryUploader,
settingsRepository.getOrCreateDeviceId(),
networkMonitor
);
commandPoller = new CommandPoller(
serverApi,
settingsRepository.getOrCreateDeviceId(),
telemetryUploader,
trackRecorder,
peerStatsCache
);
commandPoller.start();
}
}
@@ -0,0 +1,102 @@
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.setOffscreenPageLimit(1);
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,77 @@
package com.grigowashere.loratester;
import com.grigowashere.loratester.api.DeviceInfo;
import java.util.ArrayList;
import java.util.List;
public final class PeerDevices {
private static final long ONLINE_MS = 30_000;
private PeerDevices() {
}
public static Result resolve(List<DeviceInfo> devices, String selfId) {
if (devices == null || selfId == null) {
return Result.error("no_devices");
}
List<DeviceInfo> android = new ArrayList<>();
long now = System.currentTimeMillis();
for (DeviceInfo d : devices) {
if (d.device_id != null && d.device_id.startsWith("android-")) {
android.add(d);
}
}
if (android.size() != 2) {
return Result.error("expected_two");
}
String peer = null;
int online = 0;
for (DeviceInfo d : android) {
if (d.last_seen > 0 && (now / 1000.0 - d.last_seen) <= 30) {
online++;
}
if (!selfId.equals(d.device_id)) {
peer = d.device_id;
}
}
if (peer == null) {
return Result.error("peer_missing");
}
return new Result(peer, android.size(), online);
}
public static final class Result {
public final String peerId;
public final int deviceCount;
public final int onlineCount;
public final String error;
private Result(String peerId, int deviceCount, int onlineCount) {
this.peerId = peerId;
this.deviceCount = deviceCount;
this.onlineCount = onlineCount;
this.error = null;
}
private Result(String error) {
this.peerId = null;
this.deviceCount = 0;
this.onlineCount = 0;
this.error = error;
}
static Result error(String code) {
return new Result(code);
}
public boolean ok() {
return peerId != null;
}
public boolean bothOnline() {
return ok() && onlineCount >= 2;
}
}
}
@@ -0,0 +1,48 @@
package com.grigowashere.loratester;
import androidx.annotation.Nullable;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
public class PeerStatsCache {
public static final class Snapshot {
public final String meta;
public final String role;
public final Double rssi;
public final long atMs;
public Snapshot(String meta, String role, Double rssi, long atMs) {
this.meta = meta;
this.role = role;
this.rssi = rssi;
this.atMs = atMs;
}
}
private final AtomicReference<Snapshot> snapshot = new AtomicReference<>();
public void updateFromPayload(Map<String, Object> payload) {
if (payload == null) {
return;
}
String meta = payload.get("meta") != null ? String.valueOf(payload.get("meta")) : null;
String role = payload.get("role") != null ? String.valueOf(payload.get("role")) : null;
Double rssi = null;
Object r = payload.get("rssi");
if (r instanceof Number) {
rssi = ((Number) r).doubleValue();
}
snapshot.set(new Snapshot(meta, role, rssi, System.currentTimeMillis()));
}
@Nullable
public Snapshot get() {
return snapshot.get();
}
public void clear() {
snapshot.set(null);
}
}
@@ -0,0 +1,109 @@
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 = "https://lora.grigowashere.ru";
private static final String LEGACY_SERVER_HTTP = "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);
migrateLegacyServerUrl();
}
private void migrateLegacyServerUrl() {
String current = prefs.getString(KEY_SERVER_URL, null);
if (current == null || !isLegacyServerUrl(current)) {
return;
}
prefs.edit().putString(KEY_SERVER_URL, DEFAULT_SERVER).apply();
}
static boolean isLegacyServerUrl(String url) {
if (url == null) {
return false;
}
String u = url.trim().toLowerCase();
while (u.endsWith("/")) {
u = u.substring(0, u.length() - 1);
}
return u.equals(LEGACY_SERVER_HTTP)
|| u.equals("http://grigowashere.ru")
|| u.equals("https://grigowashere.ru:7634");
}
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,397 @@
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.RadioMacroBuilder;
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.List;
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;
}
}
public boolean hasGpsFix() {
return GeoUtils.isValidCoordinate(lat, lon);
}
public double getGpsLat() {
return lat;
}
public double getGpsLon() {
return lon;
}
private Double validLat() {
return GeoUtils.isValidCoordinate(lat, lon) ? lat : null;
}
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));
}
});
}
public void sendMacroSequence(List<String> lines, AtSendCallback callback) {
telnetExecutor.execute(() -> {
TelnetClient.SendResult last = TelnetClient.SendResult.SENT;
if (lines != null) {
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
if (line == null || line.isEmpty()) {
continue;
}
last = sendLineOnWorker(line);
if (last != TelnetClient.SendResult.SENT) {
break;
}
if (i < lines.size() - 1) {
try {
Thread.sleep(150);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
break;
}
}
}
}
if (callback != null) {
TelnetClient.SendResult r = last;
mainHandler.post(() -> callback.onResult(r));
}
});
}
private TelnetClient.SendResult sendLineOnWorker(String line) {
if (RadioMacroBuilder.STOP.equals(line)) {
appendConsole(">> S\n");
if (telnetClient == null) {
appendConsole("!! telnet not started\n");
return TelnetClient.SendResult.NOT_CONNECTED;
}
TelnetClient.SendResult result = telnetClient.sendRawLine(line);
if (result != TelnetClient.SendResult.SENT) {
appendConsole("!! send failed: " + result + "\n");
}
return result;
}
return sendAtCommandOnWorker(line);
}
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,13 @@
package com.grigowashere.loratester.api;
import java.util.Map;
public class DeviceCommand {
public long id;
public String from_device_id;
public String to_device_id;
public String kind;
public Map<String, Object> payload;
public double created_at;
public Double delivered_at;
}
@@ -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,30 @@
package com.grigowashere.loratester.api;
import java.util.List;
public class ElevationGridResult {
public boolean ok;
public String error;
public Center center;
public double radius_m;
public double step_m;
public double min_delta_m;
public double max_delta_m;
public List<GridPoint> points;
public static class Center {
public double lat;
public double lon;
public double elevation_m;
}
public static class GridPoint {
public int i;
public int j;
public double lat;
public double lon;
public double dist_m;
public Double elevation_m;
public double delta_m;
}
}
@@ -0,0 +1,19 @@
package com.grigowashere.loratester.api;
public class NearestHillResult {
public boolean ok;
public String error;
public HillPoint center;
public HillPoint hill;
public double radius_m;
public int candidates;
public static class HillPoint {
public double lat;
public double lon;
public Double elevation_m;
public Double dist_m;
public Double prominence_m;
public Boolean is_local_max;
}
}
@@ -0,0 +1,15 @@
package com.grigowashere.loratester.api;
public class PairedTrackSession {
public long id;
public String device_a;
public String device_b;
public String initiator;
public String status;
public double start_at;
public Long track_id_a;
public Long track_id_b;
public double created_at;
public double server_time;
public boolean ready;
}
@@ -0,0 +1,306 @@
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 static final Type COMMAND_LIST = new TypeToken<List<DeviceCommand>>() {}.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 void postCommand(
String fromDeviceId,
String toDeviceId,
String kind,
Map<String, Object> payload
) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("from_device_id", fromDeviceId);
body.put("to_device_id", toDeviceId);
body.put("kind", kind);
if (payload != null) {
body.put("payload", payload);
}
postJson("/api/commands", body, true);
}
public List<DeviceCommand> pollPendingCommands(String deviceId) throws IOException {
String path = "/api/commands/pending?device_id="
+ java.net.URLEncoder.encode(deviceId, "UTF-8") + "&limit=20";
Request request = new Request.Builder()
.url(baseUrl + path)
.header(HEADER_LORA_CLIENT, CLIENT_ANDROID)
.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(), COMMAND_LIST);
}
}
@SuppressWarnings("unchecked")
public Map<String, Object> startPairedTrack(Map<String, Object> body) throws IOException {
return postJsonMap("/api/paired-tracks/start", body, true);
}
@SuppressWarnings("unchecked")
public Map<String, Object> getActivePairedTrack() throws IOException {
Request request = new Request.Builder()
.url(baseUrl + "/api/paired-tracks/active")
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), Map.class);
}
}
public void ackPairedTrack(long sessionId, String deviceId, long trackId) throws IOException {
Map<String, Object> body = new HashMap<>();
body.put("session_id", sessionId);
body.put("device_id", deviceId);
body.put("track_id", trackId);
postJson("/api/paired-tracks/ack", body, true);
}
public void cancelPairedTrack(Long sessionId) throws IOException {
Map<String, Object> body = new HashMap<>();
if (sessionId != null) {
body.put("session_id", sessionId);
}
postJson("/api/paired-tracks/cancel", body, false);
}
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")
public Map<String, Object> getHealth() throws IOException {
Request request = new Request.Builder()
.url(baseUrl + "/api/health")
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), Map.class);
}
}
public NearestHillResult findNearestHill(double lat, double lon, int radiusM)
throws IOException {
String path = "/api/elevation/nearest-hill?lat="
+ lat
+ "&lon="
+ lon
+ "&radius_m="
+ radiusM;
Request request = new Request.Builder()
.url(baseUrl + path)
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
throw new IOException("HTTP " + response.code());
}
return GSON.fromJson(response.body().string(), NearestHillResult.class);
}
}
public ElevationGridResult getElevationGrid(double lat, double lon, int radiusM, int stepM)
throws IOException {
String path = "/api/elevation/grid?lat="
+ lat
+ "&lon="
+ lon
+ "&radius_m="
+ radiusM
+ "&step_m="
+ stepM;
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(), ElevationGridResult.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,33 @@
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());
}
public static double haversineMeters(double lat1, double lon1, double lat2, double lon2) {
final double r = 6_371_000;
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
return r * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
}
@@ -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,200 @@
package com.grigowashere.loratester.model;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.grigowashere.loratester.telnet.StatsExtractor;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** Normalized radio stats from telemetry meta JSON (no duplicate fields). */
public final class RadioSnapshot {
public final String role;
public final String frame;
public final Double frequencyMhz;
public final Integer sf;
public final Integer bwKhz;
public final Double powerDbm;
public final Double rssiDbm;
public final Double snrDb;
public final Integer packet;
public final String payload;
public final Double onAirMs;
public final Double txPktPerS;
public final Double rxPktPerS;
public final Double perPercent;
public final Double rxQualityPercent;
public final Map<String, String> extraFields;
public RadioSnapshot(
String role,
String frame,
Double frequencyMhz,
Integer sf,
Integer bwKhz,
Double powerDbm,
Double rssiDbm,
Double snrDb,
Integer packet,
String payload,
Double onAirMs,
Double txPktPerS,
Double rxPktPerS,
Double perPercent,
Double rxQualityPercent,
Map<String, String> extraFields
) {
this.role = role;
this.frame = frame;
this.frequencyMhz = frequencyMhz;
this.sf = sf;
this.bwKhz = bwKhz;
this.powerDbm = powerDbm;
this.rssiDbm = rssiDbm;
this.snrDb = snrDb;
this.packet = packet;
this.payload = payload;
this.onAirMs = onAirMs;
this.txPktPerS = txPktPerS;
this.rxPktPerS = rxPktPerS;
this.perPercent = perPercent;
this.rxQualityPercent = rxQualityPercent;
this.extraFields = extraFields != null ? extraFields : Map.of();
}
public static RadioSnapshot empty() {
return new RadioSnapshot(null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, Map.of());
}
public static RadioSnapshot fromMeta(String metaJson, String roleFallback, Double rssiFallback) {
if (metaJson == null || metaJson.isBlank()) {
RadioSnapshot snap = empty();
if (roleFallback != null || rssiFallback != null) {
return new RadioSnapshot(roleFallback, null, null, null, null, null,
rssiFallback, null, null, null, null, null, null, null, null, Map.of());
}
return snap;
}
try {
JsonObject o = JsonParser.parseString(metaJson).getAsJsonObject();
String role = text(o, "role");
if (role == null) {
role = roleFallback;
}
Double rssi = dbl(o, "rssi_dbm");
if (rssi == null) {
rssi = rssiFallback;
}
Map<String, String> extra = new LinkedHashMap<>();
JsonElement fieldsEl = o.get("fields");
if (fieldsEl != null && fieldsEl.isJsonObject()) {
for (Map.Entry<String, JsonElement> e : fieldsEl.getAsJsonObject().entrySet()) {
String label = e.getKey();
if (isKnownFieldLabel(label)) {
continue;
}
extra.put(label, e.getValue().getAsString());
}
}
return new RadioSnapshot(
role,
text(o, "frame"),
hzToMhz(lng(o, "frequency_hz")),
integer(o, "spreading_factor"),
integer(o, "bandwidth_khz"),
dbl(o, "power_dbm"),
rssi,
dbl(o, "snr_db"),
integer(o, "packet"),
text(o, "payload"),
dbl(o, "on_air_ms"),
dbl(o, "tx_pkt_per_s"),
dbl(o, "rx_pkt_per_s"),
dbl(o, "per_percent"),
dbl(o, "rx_quality_percent"),
extra
);
} catch (Exception ignored) {
return new RadioSnapshot(roleFallback, null, null, null, null, null,
rssiFallback, null, null, null, null, null, null, null, null, Map.of());
}
}
public static RadioSnapshot fromExtracted(StatsExtractor.ExtractedStats stats) {
if (stats == null) {
return empty();
}
return fromMeta(stats.metaJson, stats.role, stats.rssiDbm != null ? stats.rssiDbm : stats.rssi);
}
public Set<String> diff(RadioSnapshot prev) {
Set<String> changed = new HashSet<>();
if (prev == null) {
return changed;
}
cmp(changed, "role", role, prev.role);
cmp(changed, "rssi", rssiDbm, prev.rssiDbm);
cmp(changed, "snr", snrDb, prev.snrDb);
cmp(changed, "packet", packet, prev.packet);
cmp(changed, "payload", payload, prev.payload);
cmp(changed, "per", perPercent, prev.perPercent);
cmp(changed, "rxQuality", rxQualityPercent, prev.rxQualityPercent);
cmp(changed, "txSpeed", txPktPerS, prev.txPktPerS);
cmp(changed, "rxSpeed", rxPktPerS, prev.rxPktPerS);
cmp(changed, "frequency", frequencyMhz, prev.frequencyMhz);
cmp(changed, "sf", sf, prev.sf);
cmp(changed, "bw", bwKhz, prev.bwKhz);
cmp(changed, "power", powerDbm, prev.powerDbm);
return changed;
}
private static void cmp(Set<String> changed, String key, Object a, Object b) {
if (!Objects.equals(a, b)) {
changed.add(key);
}
}
private static boolean isKnownFieldLabel(String label) {
String n = label.toLowerCase(Locale.ROOT).trim();
return n.equals("send") || n.equals("receive")
|| n.contains("frequency") || n.equals("power") || n.equals("rssi")
|| n.equals("snr") || n.contains("spreading") || n.contains("bandwidth")
|| n.equals("packet") || n.contains("packet number") || n.equals("payload")
|| n.contains("on air") || n.contains("tx speed") || n.contains("rx speed")
|| n.equals("per") || n.contains("rx quality");
}
private static String text(JsonObject o, String key) {
JsonElement e = o.get(key);
return e != null && !e.isJsonNull() ? e.getAsString() : null;
}
private static Integer integer(JsonObject o, String key) {
JsonElement e = o.get(key);
return e != null && e.isJsonPrimitive() ? e.getAsInt() : null;
}
private static Double dbl(JsonObject o, String key) {
JsonElement e = o.get(key);
return e != null && e.isJsonPrimitive() ? e.getAsDouble() : null;
}
private static Long lng(JsonObject o, String key) {
JsonElement e = o.get(key);
return e != null && e.isJsonPrimitive() ? e.getAsLong() : null;
}
private static Double hzToMhz(Long hz) {
if (hz == null) {
return null;
}
return hz / 1_000_000.0;
}
}
@@ -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,41 @@
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);
}
/** Literal line (e.g. screen reset "S") without AT prefix. */
public static byte[] toWireBytesLiteral(String line) {
if (line == null || line.isEmpty()) {
return new byte[0];
}
return (line + "\r\n").getBytes(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,33 @@
package com.grigowashere.loratester.telnet;
/** LoRa module AT commands (telnet bridge). */
public final class AtCommands {
public static final String TRANSMIT = "AT+TX";
public static final String RECEIVE = "AT+RX";
/** Stop TX or RX test. */
public static final String STOP = "S";
public static final String TIMEOUT_MS = "AT+TM=";
public static final String FREQUENCY_HZ = "AT+FQ=";
public static final String POWER_DBM = "AT+PW=";
public static final String SPREADING_FACTOR = "AT+SF=";
public static final String BANDWIDTH = "AT+BW=";
public static final String CODE_RATE = "AT+CR=";
public static final String PREAMBLE = "AT+PL=";
/** Legacy / bridge helpers (if supported by firmware). */
public static final String HELP = "AT+H";
public static final String STATUS = "AT+STATUS";
public static final String RESET = "AT+RESET";
public static final String BASIC = "AT";
public static final String[] BW_KHZ = {
"7.81", "10.42", "15.63", "20.83", "31.25",
"41.67", "62.5", "125", "250", "500"
};
public static final String[] CODE_RATES = {"4/5", "4/6", "4/7", "4/8"};
private AtCommands() {
}
}
@@ -0,0 +1,121 @@
package com.grigowashere.loratester.telnet;
import com.grigowashere.loratester.model.RadioSnapshot;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
public final class LoraStatsFormatter {
private LoraStatsFormatter() {
}
@Deprecated
public static String formatMeta(String metaJson) {
RadioSnapshot snap = RadioSnapshot.fromMeta(metaJson, null, null);
StringBuilder sb = new StringBuilder();
String dynamic = formatDynamic(snap, Set.of());
if (!dynamic.isEmpty()) {
sb.append(dynamic);
}
String stat = formatStatic(snap, Set.of());
if (!stat.isEmpty()) {
if (sb.length() > 0) {
sb.append("\n");
}
sb.append(stat);
}
return sb.toString().trim();
}
public static String formatDynamic(RadioSnapshot s, Set<String> changed) {
if (s == null) {
return "";
}
StringBuilder sb = new StringBuilder();
appendLine(sb, "RSSI", fmtDbm(s.rssiDbm), "rssi", changed);
appendLine(sb, "SNR", fmtSuffix(s.snrDb, " dB"), "snr", changed);
appendLine(sb, "RX Quality", fmtSuffix(s.rxQualityPercent, " %"), "rxQuality", changed);
appendLine(sb, "Пакет", fmtInt(s.packet), "packet", changed);
appendLine(sb, "Payload", s.payload, "payload", changed);
appendLine(sb, "PER", fmtSuffix(s.perPercent, " %"), "per", changed);
appendLine(sb, "TX Speed", fmtSuffix(s.txPktPerS, " pkt/s"), "txSpeed", changed);
appendLine(sb, "RX Speed", fmtSuffix(s.rxPktPerS, " pkt/s"), "rxSpeed", changed);
for (Map.Entry<String, String> e : s.extraFields.entrySet()) {
append(sb, e.getKey(), e.getValue());
}
return sb.toString().trim();
}
public static String formatStatic(RadioSnapshot s, Set<String> changed) {
if (s == null) {
return "";
}
StringBuilder sb = new StringBuilder();
if (s.role != null) {
appendLine(sb, "Роль", roleLabel(s.role), "role", changed);
}
appendLine(sb, "Частота", fmtSuffix(s.frequencyMhz, " MHz"), "frequency", changed);
appendLine(sb, "SF", fmtInt(s.sf), "sf", changed);
appendLine(sb, "BW", fmtSuffix(s.bwKhz, " kHz"), "bw", changed);
appendLine(sb, "Мощность TX", fmtDbm(s.powerDbm), "power", changed);
appendLine(sb, "On Air", fmtSuffix(s.onAirMs, " ms"), "onAir", changed);
return sb.toString().trim();
}
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 void appendLine(
StringBuilder sb,
String label,
String value,
String changeKey,
Set<String> changed
) {
if (value == null || value.isEmpty()) {
return;
}
if (sb.length() > 0) {
sb.append("\n");
}
if (changed != null && changed.contains(changeKey)) {
sb.append("");
}
sb.append(label).append(": ").append(value);
}
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 String fmtDbm(Double v) {
return v != null ? String.format(Locale.US, "%.0f dBm", v) : null;
}
private static String fmtInt(Integer v) {
return v != null ? String.valueOf(v) : null;
}
private static String fmtSuffix(Double v, String suffix) {
return v != null ? String.format(Locale.US, "%s%s", v, suffix) : null;
}
private static String fmtSuffix(Integer v, String suffix) {
return v != null ? v + suffix : null;
}
}
@@ -0,0 +1,74 @@
package com.grigowashere.loratester.telnet;
import java.util.ArrayList;
import java.util.List;
/** Builds macro: S (stop) then configuration AT commands, then TX/RX if requested. */
public final class RadioMacroBuilder {
/** Stop TX or RX before applying new settings. */
public static final String STOP = AtCommands.STOP;
/** @deprecated use {@link #STOP} */
@Deprecated
public static final String SCREEN_RESET = STOP;
public static final class Params {
public Long frequencyHz;
public Integer powerDbm;
public Integer sf;
public String bwKhz;
public String codeRate;
public Integer preamble;
public Integer sendTimeoutMs;
public String role;
}
private RadioMacroBuilder() {
}
public static List<String> apply(Integer sf, Integer bwKhz, String role) {
Params p = new Params();
p.sf = sf;
if (bwKhz != null) {
p.bwKhz = String.valueOf(bwKhz);
}
p.role = role;
return apply(p);
}
public static List<String> apply(Params p) {
List<String> lines = new ArrayList<>();
lines.add(STOP);
if (p == null) {
return lines;
}
if (p.frequencyHz != null && p.frequencyHz >= 430_000_000L && p.frequencyHz <= 470_000_000L) {
lines.add(AtCommands.FREQUENCY_HZ + p.frequencyHz);
}
if (p.powerDbm != null && p.powerDbm >= -9 && p.powerDbm <= 22) {
lines.add(AtCommands.POWER_DBM + p.powerDbm);
}
if (p.sf != null && p.sf >= 5 && p.sf <= 12) {
lines.add(AtCommands.SPREADING_FACTOR + p.sf);
}
if (p.bwKhz != null && !p.bwKhz.isBlank()) {
lines.add(AtCommands.BANDWIDTH + p.bwKhz.trim());
}
if (p.codeRate != null && !p.codeRate.isBlank()) {
lines.add(AtCommands.CODE_RATE + p.codeRate.trim());
}
if (p.preamble != null && p.preamble >= 1 && p.preamble <= 64) {
lines.add(AtCommands.PREAMBLE + p.preamble);
}
if (p.sendTimeoutMs != null && p.sendTimeoutMs >= 0 && p.sendTimeoutMs <= 60_000) {
lines.add(AtCommands.TIMEOUT_MS + p.sendTimeoutMs);
}
if (StatsExtractor.ROLE_TX.equals(p.role)) {
lines.add(AtCommands.TRANSMIT);
} else if (StatsExtractor.ROLE_RX.equals(p.role)) {
lines.add(AtCommands.RECEIVE);
}
return lines;
}
}
@@ -0,0 +1,283 @@
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 static final Pattern RX_QUALITY = Pattern.compile(
"RX Quality\\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);
}
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));
putDouble(meta, "rx_quality_percent", matchDouble(RX_QUALITY, normalized));
if (!fields.isEmpty()) {
meta.put("fields", 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")
|| isStructuredLabel(label)) {
continue;
}
fields.put(label, value);
}
}
private static boolean isStructuredLabel(String label) {
String n = label.toLowerCase(Locale.ROOT).trim();
return n.equals("frequency") || n.equals("power") || n.equals("rssi")
|| n.equals("snr") || n.contains("spreading factor") || n.equals("bandwidth")
|| n.equals("packet") || n.contains("packet number") || n.equals("payload")
|| n.contains("on air") || n.contains("tx speed") || n.contains("rx speed")
|| n.equals("per") || n.contains("rx quality");
}
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,175 @@
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 sendRawLine(String line) {
byte[] wire = AtCommandFormatter.toWireBytesLiteral(line);
if (wire.length == 0) {
return SendResult.EMPTY;
}
return writeWire(wire);
}
public SendResult sendAtCommand(String command) {
byte[] wire = AtCommandFormatter.toWireBytes(command);
if (wire.length == 0) {
return SendResult.EMPTY;
}
return writeWire(wire);
}
private SendResult writeWire(byte[] wire) {
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,274 @@
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);
default void onPointRecorded(double lat, double lon) {
}
}
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;
private Listener pairedListener;
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 setPairedListener(Listener pairedListener) {
this.pairedListener = pairedListener;
}
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();
notifyPoint(lat, lon);
}
private void notifyPoint(double lat, double lon) {
mainHandler.post(() -> {
if (listener != null) {
listener.onPointRecorded(lat, lon);
}
if (pairedListener != null) {
pairedListener.onPointRecorded(lat, lon);
}
});
}
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() {
mainHandler.post(() -> {
if (listener != null) {
listener.onStateChanged(recording, totalPoints, trackId);
}
if (pairedListener != null) {
pairedListener.onStateChanged(recording, totalPoints, trackId);
}
});
}
private void notifyError(String msg) {
mainHandler.post(() -> {
if (listener != null) {
listener.onError(msg);
}
if (pairedListener != null) {
pairedListener.onError(msg);
}
});
}
}
@@ -0,0 +1,313 @@
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.ArrayAdapter;
import android.widget.Button;
import android.widget.ScrollView;
import android.widget.Spinner;
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.button.MaterialButton;
import com.google.android.material.button.MaterialButtonToggleGroup;
import com.google.android.material.textfield.TextInputEditText;
import com.grigowashere.loratester.CommandPoller;
import com.grigowashere.loratester.LoraApp;
import com.grigowashere.loratester.PeerDevices;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.DeviceInfo;
import com.grigowashere.loratester.model.RadioSnapshot;
import com.grigowashere.loratester.telnet.AtCommands;
import com.grigowashere.loratester.telnet.LoraStatsFormatter;
import com.grigowashere.loratester.telnet.RadioMacroBuilder;
import com.grigowashere.loratester.telnet.StatsExtractor;
import com.grigowashere.loratester.telnet.TelnetClient;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class AtFragment extends Fragment {
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private FragmentPollHelper pollHelper;
private TelemetryUploader uploader;
private CommandPoller commandPoller;
private TextView atStatus;
private TextView atCurrentSnapshot;
private TextInputEditText atInputFq;
private TextInputEditText atInputPw;
private TextInputEditText atInputSf;
private TextInputEditText atInputPl;
private TextInputEditText atInputTm;
private Spinner atBwSpinner;
private Spinner atCrSpinner;
private Spinner atRoleSpinner;
private MaterialButtonToggleGroup atTargetGroup;
private ScrollView atConsoleScroll;
private TextView atConsole;
private String lastConsole = "";
private boolean consoleVisible;
private boolean formInitialized;
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
LoraApp app = (LoraApp) context.getApplicationContext();
uploader = app.getTelemetryUploader();
commandPoller = app.getCommandPoller();
}
@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);
atCurrentSnapshot = view.findViewById(R.id.atCurrentSnapshot);
atInputFq = view.findViewById(R.id.atInputFq);
atInputPw = view.findViewById(R.id.atInputPw);
atInputSf = view.findViewById(R.id.atInputSf);
atInputPl = view.findViewById(R.id.atInputPl);
atInputTm = view.findViewById(R.id.atInputTm);
atBwSpinner = view.findViewById(R.id.atBwSpinner);
atCrSpinner = view.findViewById(R.id.atCrSpinner);
atRoleSpinner = view.findViewById(R.id.atRoleSpinner);
atTargetGroup = view.findViewById(R.id.atTargetGroup);
atConsoleScroll = view.findViewById(R.id.atConsoleScroll);
atConsole = view.findViewById(R.id.atConsole);
Button applyBtn = view.findViewById(R.id.atApplyBtn);
Button stopBtn = view.findViewById(R.id.atStopBtn);
MaterialButton consoleToggle = view.findViewById(R.id.atConsoleToggle);
pollHelper = new FragmentPollHelper(this, this::refresh);
atTargetGroup.check(R.id.atTargetLocal);
atBwSpinner.setAdapter(new ArrayAdapter<>(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
AtCommands.BW_KHZ
));
atCrSpinner.setAdapter(new ArrayAdapter<>(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
AtCommands.CODE_RATES
));
atRoleSpinner.setAdapter(new ArrayAdapter<>(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
new String[]{"", "TX", "RX"}
));
applyBtn.setOnClickListener(v -> applyMacro());
stopBtn.setOnClickListener(v -> sendLines(List.of(AtCommands.STOP)));
consoleToggle.setOnClickListener(v -> {
consoleVisible = !consoleVisible;
atConsoleScroll.setVisibility(consoleVisible ? View.VISIBLE : View.GONE);
consoleToggle.setText(consoleVisible
? getString(R.string.at_console_hide)
: getString(R.string.at_console_toggle));
});
}
private RadioMacroBuilder.Params buildParams() {
RadioMacroBuilder.Params p = new RadioMacroBuilder.Params();
Double fqMhz = parseDouble(atInputFq);
if (fqMhz != null) {
p.frequencyHz = Math.round(fqMhz * 1_000_000L);
}
p.powerDbm = parseInt(atInputPw);
p.sf = parseInt(atInputSf);
int bwPos = atBwSpinner != null ? atBwSpinner.getSelectedItemPosition() : -1;
if (bwPos >= 0 && bwPos < AtCommands.BW_KHZ.length) {
p.bwKhz = AtCommands.BW_KHZ[bwPos];
}
if (atCrSpinner != null && atCrSpinner.getSelectedItem() != null) {
p.codeRate = atCrSpinner.getSelectedItem().toString();
}
p.preamble = parseInt(atInputPl);
p.sendTimeoutMs = parseInt(atInputTm);
if (atRoleSpinner != null && atRoleSpinner.getSelectedItem() != null) {
String role = atRoleSpinner.getSelectedItem().toString();
if (!"".equals(role)) {
p.role = role;
}
}
return p;
}
private void applyMacro() {
sendLines(RadioMacroBuilder.apply(buildParams()));
}
private void sendLines(List<String> lines) {
if (isPeerTarget()) {
sendMacroToPeer(lines);
} else {
uploader.sendMacroSequence(lines, this::onSendResult);
}
}
private void sendMacroToPeer(List<String> lines) {
executor.execute(() -> {
try {
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
PeerDevices.Result peer = PeerDevices.resolve(devices, uploader.getDeviceId());
if (!peer.ok()) {
showToast(R.string.at_peer_unavailable);
return;
}
commandPoller.postMacroToPeer(peer.peerId, lines);
showToast(getString(R.string.at_sent_to_peer, peer.peerId));
} catch (Exception e) {
showToast(R.string.stats_push_failed);
}
});
}
private void onSendResult(TelnetClient.SendResult result) {
if (!isAdded()) return;
if (result == TelnetClient.SendResult.NOT_CONNECTED) {
showToast(R.string.at_not_connected);
} else if (result == TelnetClient.SendResult.IO_ERROR) {
showToast(R.string.at_send_error);
}
updateConsoleView();
}
private Integer parseInt(TextInputEditText field) {
if (field == null || field.getText() == null) return null;
String s = field.getText().toString().trim();
if (s.isEmpty()) return null;
try {
return Integer.parseInt(s);
} catch (NumberFormatException e) {
return null;
}
}
private Double parseDouble(TextInputEditText field) {
if (field == null || field.getText() == null) return null;
String s = field.getText().toString().trim();
if (s.isEmpty()) return null;
try {
return Double.parseDouble(s);
} catch (NumberFormatException e) {
return null;
}
}
private boolean isPeerTarget() {
return atTargetGroup != null && atTargetGroup.getCheckedButtonId() == R.id.atTargetPeer;
}
private void refresh() {
if (!isAdded() || uploader == null || atStatus == null) return;
atStatus.setText(getString(
R.string.at_status,
uploader.isTelnetConnected()
? getString(R.string.connected) : getString(R.string.disconnected)
));
RadioSnapshot snap = RadioSnapshot.fromExtracted(uploader.getLastStats());
atCurrentSnapshot.setText(LoraStatsFormatter.formatStatic(snap, java.util.Set.of())
+ "\n" + LoraStatsFormatter.formatDynamic(snap, java.util.Set.of()));
if (!formInitialized) {
if (snap.frequencyMhz != null && isEmpty(atInputFq)) {
atInputFq.setText(String.format(Locale.US, "%.3f", snap.frequencyMhz));
}
if (snap.powerDbm != null && isEmpty(atInputPw)) {
atInputPw.setText(String.valueOf(snap.powerDbm.intValue()));
}
if (snap.sf != null && isEmpty(atInputSf)) {
atInputSf.setText(String.valueOf(snap.sf));
}
if (snap.bwKhz != null) {
selectBw(String.valueOf(snap.bwKhz));
}
if (snap.role != null && atRoleSpinner != null) {
atRoleSpinner.setSelection(StatsExtractor.ROLE_RX.equals(snap.role) ? 2 : 1);
}
formInitialized = true;
}
updateConsoleView();
if (pollHelper != null) {
pollHelper.scheduleNext(400);
}
}
private static boolean isEmpty(TextInputEditText field) {
return field == null || field.getText() == null || field.getText().length() == 0;
}
private void selectBw(String bw) {
if (atBwSpinner == null || bw == null) return;
for (int i = 0; i < AtCommands.BW_KHZ.length; i++) {
if (AtCommands.BW_KHZ[i].equals(bw) || AtCommands.BW_KHZ[i].equals(bw.replace(".0", ""))) {
atBwSpinner.setSelection(i);
return;
}
}
}
private void updateConsoleView() {
if (uploader == null || atConsole == null || atConsoleScroll == null) return;
String log = uploader.getConsoleLog();
if (!log.equals(lastConsole)) {
lastConsole = log;
atConsole.setText(log);
if (consoleVisible) {
atConsoleScroll.post(() -> atConsoleScroll.fullScroll(View.FOCUS_DOWN));
}
}
}
private void showToast(int resId) {
if (isAdded()) Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show();
}
private void showToast(String msg) {
if (isAdded()) Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show();
}
@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();
pollHelper = null;
formInitialized = false;
super.onDestroyView();
}
@Override
public void onDestroy() {
executor.shutdownNow();
super.onDestroy();
}
}
@@ -0,0 +1,161 @@
package com.grigowashere.loratester.ui;
import android.graphics.Color;
import android.os.Handler;
import android.os.Looper;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
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.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
private static final int COLOR_SELF_BG = 0xFF16213E;
private static final int COLOR_OTHER_BG = 0xFF1A4A6E;
private static final int COLOR_NEW_HIGHLIGHT = 0x33E94560;
private final List<ChatMessage> messages = new ArrayList<>();
private final DateFormat timeFormat =
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault());
private final Handler handler = new Handler(Looper.getMainLooper());
private final Set<Integer> highlightedPositions = new HashSet<>();
private String selfDeviceId;
private double lastSeenTs;
public void setSelfDeviceId(String selfDeviceId) {
this.selfDeviceId = selfDeviceId;
}
public void setLastSeenTs(double lastSeenTs) {
this.lastSeenTs = lastSeenTs;
}
public void setMessages(List<ChatMessage> newMessages) {
messages.clear();
highlightedPositions.clear();
messages.addAll(newMessages);
notifyDataSetChanged();
}
public void appendMessages(List<ChatMessage> more) {
if (more.isEmpty()) {
return;
}
int start = messages.size();
messages.addAll(more);
for (int i = 0; i < more.size(); i++) {
if (more.get(i).ts > lastSeenTs) {
highlightedPositions.add(start + i);
}
}
notifyItemRangeInserted(start, more.size());
for (int i = 0; i < more.size(); i++) {
int pos = start + i;
if (highlightedPositions.contains(pos)) {
scheduleClearHighlight(pos);
}
}
}
private void scheduleClearHighlight(int position) {
handler.postDelayed(() -> {
if (highlightedPositions.remove(position)) {
notifyItemChanged(position);
}
}, 3000);
}
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);
boolean self = selfDeviceId != null && selfDeviceId.equals(m.device_id);
String time = timeFormat.format(new Date((long) (m.ts * 1000)));
FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) holder.bubble.getLayoutParams();
lp.gravity = self ? Gravity.END : Gravity.START;
holder.bubble.setLayoutParams(lp);
holder.bubble.setBackgroundColor(self ? COLOR_SELF_BG : COLOR_OTHER_BG);
holder.author.setText(self
? holder.itemView.getContext().getString(R.string.chat_self_label)
: m.device_id);
holder.text.setText(m.text);
holder.time.setText(time);
int bg = self ? COLOR_SELF_BG : COLOR_OTHER_BG;
if (highlightedPositions.contains(position)) {
holder.bubble.setBackgroundColor(blend(bg, COLOR_NEW_HIGHLIGHT));
} else {
holder.bubble.setBackgroundColor(bg);
}
}
private static int blend(int base, int overlay) {
int ba = Color.alpha(base);
int br = Color.red(base);
int bg = Color.green(base);
int bb = Color.blue(base);
int oa = Color.alpha(overlay);
int or = Color.red(overlay);
int og = Color.green(overlay);
int ob = Color.blue(overlay);
float ratio = oa / 255f;
int r = (int) (br * (1 - ratio) + or * ratio);
int g = (int) (bg * (1 - ratio) + og * ratio);
int b = (int) (bb * (1 - ratio) + ob * ratio);
return Color.argb(ba, r, g, b);
}
@Override
public int getItemCount() {
return messages.size();
}
static class Holder extends RecyclerView.ViewHolder {
final LinearLayout bubble;
final TextView author;
final TextView text;
final TextView time;
Holder(@NonNull View itemView) {
super(itemView);
bubble = itemView.findViewById(R.id.chatBubble);
author = itemView.findViewById(R.id.chatAuthor);
text = itemView.findViewById(R.id.chatText);
time = itemView.findViewById(R.id.chatTime);
}
}
}
@@ -0,0 +1,244 @@
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();
if (uploader != null) {
adapter.setSelfDeviceId(uploader.getDeviceId());
}
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 (adapter != null && uploader != null) {
adapter.setSelfDeviceId(uploader.getDeviceId());
adapter.setLastSeenTs(chatSince);
}
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,39 @@
package com.grigowashere.loratester.ui;
/** Diverging color ramp: blue = below, green = level, brown = above. */
final class ElevationColorRamp {
private static final int ALPHA = 0x8C;
private ElevationColorRamp() {
}
static int deltaToArgb(double deltaM) {
if (deltaM <= -8.0) {
return argb(0x1A, 0x4A, 0x8C);
}
if (deltaM <= -2.0) {
return lerp(argb(0x1A, 0x4A, 0x8C), argb(0x4F, 0xC3, 0xF7), (deltaM + 8.0) / 6.0);
}
if (deltaM <= 2.0) {
return lerp(argb(0x4F, 0xC3, 0xF7), argb(0x00, 0xFF, 0x88), (deltaM + 2.0) / 4.0);
}
if (deltaM <= 8.0) {
return lerp(argb(0x00, 0xFF, 0x88), argb(0xFF, 0xC1, 0x07), (deltaM - 2.0) / 6.0);
}
return argb(0x8B, 0x5A, 0x2B);
}
private static int argb(int r, int g, int b) {
return (ALPHA << 24) | (r << 16) | (g << 8) | b;
}
private static int lerp(int from, int to, double t) {
t = Math.max(0.0, Math.min(1.0, t));
int a = (int) Math.round(((from >>> 24) & 0xFF) + t * (((to >>> 24) & 0xFF) - ((from >>> 24) & 0xFF)));
int r = (int) Math.round(((from >> 16) & 0xFF) + t * (((to >> 16) & 0xFF) - ((from >> 16) & 0xFF)));
int g = (int) Math.round(((from >> 8) & 0xFF) + t * (((to >> 8) & 0xFF) - ((from >> 8) & 0xFF)));
int b = (int) Math.round((from & 0xFF) + t * ((to & 0xFF) - (from & 0xFF)));
return (a << 24) | (r << 16) | (g << 8) | b;
}
}
@@ -0,0 +1,100 @@
package com.grigowashere.loratester.ui;
import android.graphics.Bitmap;
import androidx.annotation.Nullable;
import com.grigowashere.loratester.api.ElevationGridResult;
import org.mapsforge.core.model.LatLong;
import java.util.HashMap;
import java.util.Map;
/** Builds a geo-referenced raster from elevation grid API response. */
final class ElevationHeatmapBitmap {
static final class Raster {
final Bitmap bitmap;
final LatLong northWest;
final LatLong southEast;
Raster(Bitmap bitmap, LatLong northWest, LatLong southEast) {
this.bitmap = bitmap;
this.northWest = northWest;
this.southEast = southEast;
}
}
private ElevationHeatmapBitmap() {
}
@Nullable
static Raster build(ElevationGridResult grid) {
if (grid == null || !grid.ok || grid.points == null || grid.points.isEmpty()) {
return null;
}
int minI = Integer.MAX_VALUE;
int maxI = Integer.MIN_VALUE;
int minJ = Integer.MAX_VALUE;
int maxJ = Integer.MIN_VALUE;
double minLat = Double.MAX_VALUE;
double maxLat = -Double.MAX_VALUE;
double minLon = Double.MAX_VALUE;
double maxLon = -Double.MAX_VALUE;
Map<Long, ElevationGridResult.GridPoint> byIndex = new HashMap<>();
for (ElevationGridResult.GridPoint p : grid.points) {
minI = Math.min(minI, p.i);
maxI = Math.max(maxI, p.i);
minJ = Math.min(minJ, p.j);
maxJ = Math.max(maxJ, p.j);
minLat = Math.min(minLat, p.lat);
maxLat = Math.max(maxLat, p.lat);
minLon = Math.min(minLon, p.lon);
maxLon = Math.max(maxLon, p.lon);
byIndex.put(pack(p.i, p.j), p);
}
int cols = maxJ - minJ + 1;
int rows = maxI - minI + 1;
if (cols < 1 || rows < 1) {
return null;
}
int[] pixels = new int[cols * rows];
double radiusM = grid.radius_m > 0 ? grid.radius_m : 200.0;
double stepM = grid.step_m > 0 ? grid.step_m : 15.0;
for (int row = 0; row < rows; row++) {
int i = maxI - row;
for (int col = 0; col < cols; col++) {
int j = minJ + col;
double dist = Math.hypot(i * stepM, j * stepM);
if (dist > radiusM + stepM * 0.5) {
continue;
}
ElevationGridResult.GridPoint p = byIndex.get(pack(i, j));
if (p != null && p.elevation_m != null) {
pixels[row * cols + col] = ElevationColorRamp.deltaToArgb(p.delta_m);
}
}
}
Bitmap bitmap = Bitmap.createBitmap(cols, rows, Bitmap.Config.ARGB_8888);
bitmap.setPixels(pixels, 0, cols, 0, 0, cols, rows);
double halfStepLat = (stepM / 2.0) / 111_320.0;
double halfStepLon = (stepM / 2.0)
/ (111_320.0 * Math.max(Math.cos(Math.toRadians((minLat + maxLat) / 2.0)), 1e-6));
LatLong northWest = new LatLong(maxLat + halfStepLat, minLon - halfStepLon);
LatLong southEast = new LatLong(minLat - halfStepLat, maxLon + halfStepLon);
return new Raster(bitmap, northWest, southEast);
}
private static long pack(int i, int j) {
return ((long) i << 32) ^ (j & 0xFFFFFFFFL);
}
}
@@ -0,0 +1,85 @@
package com.grigowashere.loratester.ui;
import android.graphics.Bitmap;
import com.grigowashere.loratester.api.ElevationGridResult;
import org.mapsforge.core.graphics.Canvas;
import org.mapsforge.core.model.BoundingBox;
import org.mapsforge.core.model.LatLong;
import org.mapsforge.core.model.Point;
import org.mapsforge.core.util.MercatorProjection;
import org.mapsforge.map.android.graphics.AndroidBitmap;
import org.mapsforge.map.layer.Layer;
/** Geo-referenced elevation heatmap overlay for Mapsforge. */
public class ElevationHeatmapLayer extends Layer {
private static final int TILE_SIZE = 256;
private Bitmap sourceBitmap;
private org.mapsforge.core.graphics.Bitmap mapsforgeBitmap;
private LatLong northWest;
private LatLong southEast;
public void setGrid(ElevationGridResult grid) {
sourceBitmap = null;
mapsforgeBitmap = null;
ElevationHeatmapBitmap.Raster raster = ElevationHeatmapBitmap.build(grid);
if (raster == null) {
northWest = null;
southEast = null;
return;
}
sourceBitmap = raster.bitmap;
northWest = raster.northWest;
southEast = raster.southEast;
}
public boolean hasData() {
return sourceBitmap != null && northWest != null && southEast != null;
}
@Override
public void draw(BoundingBox boundingBox, byte zoomLevel, Canvas canvas, Point topLeftPoint) {
if (!hasData()) {
return;
}
BoundingBox rasterBox = new BoundingBox(
southEast.latitude,
northWest.longitude,
northWest.latitude,
southEast.longitude
);
if (!boundingBox.intersects(rasterBox)) {
return;
}
long mapSize = MercatorProjection.getMapSize(zoomLevel, TILE_SIZE);
int left = (int) Math.round(
MercatorProjection.longitudeToPixelX(northWest.longitude, mapSize) - topLeftPoint.x);
int top = (int) Math.round(
MercatorProjection.latitudeToPixelY(northWest.latitude, mapSize) - topLeftPoint.y);
int right = (int) Math.round(
MercatorProjection.longitudeToPixelX(southEast.longitude, mapSize) - topLeftPoint.x);
int bottom = (int) Math.round(
MercatorProjection.latitudeToPixelY(southEast.latitude, mapSize) - topLeftPoint.y);
int width = right - left;
int height = bottom - top;
if (width <= 0 || height <= 0) {
return;
}
if (mapsforgeBitmap == null) {
mapsforgeBitmap = new AndroidBitmap(sourceBitmap);
}
canvas.drawBitmap(
mapsforgeBitmap,
0, 0, sourceBitmap.getWidth(), sourceBitmap.getHeight(),
left, top, right, bottom);
}
}
@@ -0,0 +1,125 @@
package com.grigowashere.loratester.ui;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.Shader;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.Nullable;
import com.grigowashere.loratester.R;
/** Color scale for elevation heatmap (relative to GPS center). */
public class ElevationHeatmapLegendView extends View {
private static final double DELTA_MIN = -10.0;
private static final double DELTA_MAX = 10.0;
private final Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint barPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint titlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF barRect = new RectF();
private final RectF bgRect = new RectF();
public ElevationHeatmapLegendView(Context context) {
super(context);
init();
}
public ElevationHeatmapLegendView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public ElevationHeatmapLegendView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
bgPaint.setColor(0xCC0F3460);
labelPaint.setColor(0xFFEEEEEE);
labelPaint.setTextSize(sp(9));
titlePaint.setColor(0xFFFFFFFF);
titlePaint.setTextSize(sp(9));
titlePaint.setFakeBoldText(true);
setLayerType(LAYER_TYPE_SOFTWARE, null);
}
private float sp(float value) {
return value * getResources().getDisplayMetrics().scaledDensity;
}
private float dp(float value) {
return value * getResources().getDisplayMetrics().density;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int w = (int) dp(118);
int h = (int) dp(118);
setMeasuredDimension(
resolveSize(w, widthMeasureSpec),
resolveSize(h, heightMeasureSpec));
}
@Override
protected void onDraw(Canvas canvas) {
float pad = dp(6);
bgRect.set(pad, pad, getWidth() - pad, getHeight() - pad);
canvas.drawRoundRect(bgRect, dp(4), dp(4), bgPaint);
float titleY = bgRect.top + dp(12);
canvas.drawText(getContext().getString(R.string.map_heatmap_legend_title),
bgRect.left + dp(6), titleY, titlePaint);
float barLeft = bgRect.left + dp(8);
float barTop = titleY + dp(6);
float barBottom = bgRect.bottom - dp(8);
float barRight = barLeft + dp(14);
barRect.set(barLeft, barTop, barRight, barBottom);
int[] colors = sampleRampColors(24);
float[] positions = new float[colors.length];
for (int i = 0; i < colors.length; i++) {
positions[i] = i / (float) (colors.length - 1);
}
barPaint.setShader(new LinearGradient(
barRect.left, barRect.top, barRect.left, barRect.bottom,
colors, positions, Shader.TileMode.CLAMP));
canvas.drawRoundRect(barRect, dp(2), dp(2), barPaint);
barPaint.setShader(null);
float textX = barRect.right + dp(6);
drawLegendRow(canvas, textX, barRect.top + dp(4),
getContext().getString(R.string.map_heatmap_legend_high),
"+8 m");
drawLegendRow(canvas, textX, (barRect.top + barRect.bottom) / 2f,
getContext().getString(R.string.map_heatmap_legend_level),
"0 m");
drawLegendRow(canvas, textX, barRect.bottom - dp(4),
getContext().getString(R.string.map_heatmap_legend_low),
"-8 m");
}
private void drawLegendRow(Canvas canvas, float x, float centerY, String label, String meters) {
float lineH = labelPaint.getTextSize();
canvas.drawText(label, x, centerY - dp(1), labelPaint);
canvas.drawText(meters, x, centerY + lineH - dp(2), labelPaint);
}
private static int[] sampleRampColors(int steps) {
int[] colors = new int[steps];
for (int i = 0; i < steps; i++) {
double t = i / (double) (steps - 1);
double delta = DELTA_MAX + t * (DELTA_MIN - DELTA_MAX);
colors[i] = ElevationColorRamp.deltaToArgb(delta) | 0xFF000000;
}
return colors;
}
}
@@ -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;
}
}
File diff suppressed because it is too large Load Diff
@@ -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,189 @@
package com.grigowashere.loratester.ui;
import android.content.Context;
import android.graphics.Color;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.TableLayout;
import android.widget.TableRow;
import android.widget.TextView;
import androidx.annotation.Nullable;
import com.google.android.material.button.MaterialButton;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.model.RadioSnapshot;
import com.grigowashere.loratester.telnet.LoraStatsFormatter;
import com.grigowashere.loratester.telnet.StatsExtractor;
import java.util.Locale;
import java.util.Set;
public class RadioComparePanel extends LinearLayout {
private static final int COLOR_TX = 0xFFE94560;
private static final int COLOR_RX = 0xFF4FC3F7;
private static final int COLOR_CHANGED = 0x33E94560;
private TextView txHeader;
private TextView rxHeader;
private TableLayout dynamicTable;
private TableLayout staticTable;
private MaterialButton staticToggle;
private boolean staticExpanded;
public RadioComparePanel(Context context) {
super(context);
init(context);
}
public RadioComparePanel(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(context);
}
private void init(Context context) {
setOrientation(VERTICAL);
LayoutInflater.from(context).inflate(R.layout.view_radio_compare, this, true);
txHeader = findViewById(R.id.compareTxHeader);
rxHeader = findViewById(R.id.compareRxHeader);
dynamicTable = findViewById(R.id.compareDynamicTable);
staticTable = findViewById(R.id.compareStaticTable);
staticToggle = findViewById(R.id.compareStaticToggle);
staticToggle.setOnClickListener(v -> {
staticExpanded = !staticExpanded;
staticTable.setVisibility(staticExpanded ? VISIBLE : GONE);
staticToggle.setText(staticExpanded
? getContext().getString(R.string.stats_static_hide)
: getContext().getString(R.string.stats_static_toggle));
});
}
public void bind(
RadioSnapshot txSnap,
RadioSnapshot rxSnap,
String txDeviceId,
String rxDeviceId,
Set<String> changedTx,
Set<String> changedRx
) {
txHeader.setText("TX · " + (txDeviceId != null ? txDeviceId : ""));
rxHeader.setText("RX · " + (rxDeviceId != null ? rxDeviceId : ""));
fillTable(dynamicTable, true, txSnap, rxSnap, changedTx, changedRx);
fillTable(staticTable, false, txSnap, rxSnap, changedTx, changedRx);
}
private void fillTable(
TableLayout table,
boolean dynamic,
RadioSnapshot tx,
RadioSnapshot rx,
Set<String> changedTx,
Set<String> changedRx
) {
table.removeAllViews();
if (dynamic) {
addRow(table, "RSSI", fmtDbm(tx.rssiDbm), fmtDbm(rx.rssiDbm), "rssi", changedTx, changedRx);
addRow(table, "SNR", fmtSuffix(tx.snrDb, " dB"), fmtSuffix(rx.snrDb, " dB"), "snr", changedTx, changedRx);
addRow(table, "RX Quality", fmtSuffix(tx.rxQualityPercent, " %"), fmtSuffix(rx.rxQualityPercent, " %"),
"rxQuality", changedTx, changedRx);
addRow(table, "Пакет", fmtInt(tx.packet), fmtInt(rx.packet), "packet", changedTx, changedRx);
addRow(table, "Payload", str(tx.payload), str(rx.payload), "payload", changedTx, changedRx);
addRow(table, "PER", fmtSuffix(tx.perPercent, " %"), fmtSuffix(rx.perPercent, " %"), "per", changedTx, changedRx);
addRow(table, "TX spd", fmtSuffix(tx.txPktPerS, " p/s"), fmtSuffix(rx.txPktPerS, " p/s"), "txSpeed", changedTx, changedRx);
addRow(table, "RX spd", fmtSuffix(tx.rxPktPerS, " p/s"), fmtSuffix(rx.rxPktPerS, " p/s"), "rxSpeed", changedTx, changedRx);
} else {
addRow(table, "Роль", LoraStatsFormatter.roleLabel(tx.role), LoraStatsFormatter.roleLabel(rx.role), "role", changedTx, changedRx);
addRow(table, "Частота", fmtMhz(tx.frequencyMhz), fmtMhz(rx.frequencyMhz), "frequency", changedTx, changedRx);
addRow(table, "SF", fmtInt(tx.sf), fmtInt(rx.sf), "sf", changedTx, changedRx);
addRow(table, "BW", fmtSuffixInt(tx.bwKhz, " kHz"), fmtSuffixInt(rx.bwKhz, " kHz"), "bw", changedTx, changedRx);
addRow(table, "Мощн.", fmtDbm(tx.powerDbm), fmtDbm(rx.powerDbm), "power", changedTx, changedRx);
}
}
private void addRow(
TableLayout table,
String label,
String txVal,
String rxVal,
String changeKey,
Set<String> changedTx,
Set<String> changedRx
) {
TableRow row = new TableRow(getContext());
TextView lbl = cell(label, 0xFFAAAAAA, false);
TextView tx = cell(txVal, COLOR_TX, changedTx != null && changedTx.contains(changeKey));
TextView rx = cell(rxVal, COLOR_RX, changedRx != null && changedRx.contains(changeKey));
row.addView(lbl);
row.addView(tx);
row.addView(rx);
table.addView(row);
}
private TextView cell(String text, int color, boolean changed) {
TextView tv = new TextView(getContext());
tv.setText(text != null ? text : "");
tv.setTextColor(color);
tv.setTextSize(11f);
tv.setPadding(4, 2, 4, 2);
if (changed) {
tv.setBackgroundColor(COLOR_CHANGED);
}
return tv;
}
/** Assign TX/RX snapshots by device role. */
public static void bindByRole(
RadioComparePanel panel,
RadioSnapshot local,
RadioSnapshot peer,
String localId,
String peerId,
Set<String> changedLocal,
Set<String> changedPeer
) {
RadioSnapshot tx = local;
RadioSnapshot rx = peer;
String txId = localId;
String rxId = peerId;
Set<String> chTx = changedLocal;
Set<String> chRx = changedPeer;
if (StatsExtractor.ROLE_RX.equals(local != null ? local.role : null)) {
tx = peer;
rx = local;
txId = peerId;
rxId = localId;
chTx = changedPeer;
chRx = changedLocal;
}
if (tx == null) tx = RadioSnapshot.empty();
if (rx == null) rx = RadioSnapshot.empty();
panel.bind(tx, rx, txId, rxId, chTx, chRx);
}
private static String str(String v) {
return v != null && !v.isEmpty() ? v : "";
}
private static String fmtInt(Integer v) {
return v != null ? String.valueOf(v) : "";
}
private static String fmtDbm(Double v) {
return v != null ? String.format(Locale.US, "%.0f dBm", v) : "";
}
private static String fmtMhz(Double v) {
return v != null ? String.format(Locale.US, "%.3f MHz", v) : "";
}
private static String fmtSuffix(Double v, String suffix) {
return v != null ? v + suffix : "";
}
private static String fmtSuffixInt(Integer v, String suffix) {
return v != null ? v + suffix : "";
}
}
@@ -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,273 @@
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 android.widget.Toast;
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.CommandPoller;
import com.grigowashere.loratester.LoraApp;
import com.grigowashere.loratester.PeerDevices;
import com.grigowashere.loratester.PeerStatsCache;
import com.grigowashere.loratester.R;
import com.grigowashere.loratester.TelemetryUploader;
import com.grigowashere.loratester.api.DeviceInfo;
import com.grigowashere.loratester.api.TelemetryHistoryItem;
import com.grigowashere.loratester.model.RadioSnapshot;
import com.grigowashere.loratester.telnet.StatsExtractor;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
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 FragmentPollHelper pollHelper;
private TelemetryUploader uploader;
private CommandPoller commandPoller;
private PeerStatsCache peerStatsCache;
private TextView statsStatus;
private TextView statsPeerWarning;
private RadioComparePanel radioComparePanel;
private RecyclerView statsHistoryList;
private final HistoryAdapter historyAdapter = new HistoryAdapter();
private RadioSnapshot prevLocal = RadioSnapshot.empty();
private RadioSnapshot prevPeer = RadioSnapshot.empty();
private RadioSnapshot snapLocal = RadioSnapshot.empty();
private RadioSnapshot snapPeer = RadioSnapshot.empty();
private String cachedPeerId;
private String cachedPeerError;
private int cachedDeviceCount;
private final TelemetryUploader.StatsListener statsListener = stats -> postRender();
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
LoraApp app = (LoraApp) context.getApplicationContext();
uploader = app.getTelemetryUploader();
commandPoller = app.getCommandPoller();
peerStatsCache = app.getPeerStatsCache();
}
@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);
statsPeerWarning = view.findViewById(R.id.statsPeerWarning);
radioComparePanel = view.findViewById(R.id.radioComparePanel);
statsHistoryList = view.findViewById(R.id.statsHistoryList);
statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext()));
statsHistoryList.setAdapter(historyAdapter);
Button btnSimulate = view.findViewById(R.id.btnSimulate);
Button btnPushStats = view.findViewById(R.id.btnPushStats);
pollHelper = new FragmentPollHelper(this, this::refresh);
btnSimulate.setOnClickListener(v -> {
String chunk = """
SEND
Frequency: 433000000 Hz
Power: 22 dBm
Spreading Factor: 7
Bandwidth: 125 kHz
Packet: 1
Payload: Sim TX
\u001b[2J""";
uploader.simulateChunk(chunk);
});
btnPushStats.setOnClickListener(v -> pushStatsToPeer());
}
private void pushStatsToPeer() {
if (commandPoller == null || cachedPeerId == null) {
toast(R.string.at_peer_unavailable);
return;
}
Map<String, Object> payload = new HashMap<>();
StatsExtractor.ExtractedStats localStats = uploader.getLastStats();
if (localStats != null && localStats.metaJson != null) {
payload.put("meta", localStats.metaJson);
}
if (snapLocal.role != null) payload.put("role", snapLocal.role);
if (snapLocal.rssiDbm != null) payload.put("rssi", snapLocal.rssiDbm);
if (snapLocal.sf != null) payload.put("sf", snapLocal.sf);
if (snapLocal.bwKhz != null) payload.put("bw", snapLocal.bwKhz);
commandPoller.postCommandToPeer(cachedPeerId, "stats_push", payload);
toast(R.string.stats_pushed);
}
private void toast(int resId) {
if (isAdded()) {
Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show();
}
}
@Override
public void onResume() {
super.onResume();
if (uploader != null) {
uploader.setStatsListener(statsListener);
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;
statsPeerWarning = null;
radioComparePanel = null;
statsHistoryList = null;
pollHelper = null;
super.onDestroyView();
}
@Override
public void onDestroy() {
executor.shutdownNow();
super.onDestroy();
}
private void postRender() {
if (!isAdded() || radioComparePanel == null) {
return;
}
requireActivity().runOnUiThread(this::render);
}
private void refresh() {
if (!pollHelper.canRun() || uploader == null || statsStatus == null) {
return;
}
String deviceId = uploader.getDeviceId();
statsStatus.setText(getString(
R.string.stats_status,
deviceId,
uploader.isTelnetConnected()
? getString(R.string.connected) : getString(R.string.disconnected)
));
executor.execute(() -> {
List<TelemetryHistoryItem> history = null;
try {
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
cachedDeviceCount = devices.size();
PeerDevices.Result peer = PeerDevices.resolve(devices, deviceId);
cachedPeerId = peer.peerId;
cachedPeerError = peer.error;
DeviceInfo self = null;
DeviceInfo peerDev = null;
for (DeviceInfo d : devices) {
if (deviceId.equals(d.device_id)) {
self = d;
} else if (peer.peerId != null && peer.peerId.equals(d.device_id)) {
peerDev = d;
}
}
StatsExtractor.ExtractedStats localStats = uploader.getLastStats();
snapLocal = localStats != null
? RadioSnapshot.fromExtracted(localStats)
: RadioSnapshot.fromMeta(
self != null ? self.meta : null,
self != null ? self.role : null,
self != null ? self.rssi : null);
PeerStatsCache.Snapshot push = peerStatsCache != null ? peerStatsCache.get() : null;
if (push != null && push.meta != null) {
snapPeer = RadioSnapshot.fromMeta(push.meta, push.role, push.rssi);
} else {
snapPeer = RadioSnapshot.fromMeta(
peerDev != null ? peerDev.meta : null,
peerDev != null ? peerDev.role : null,
peerDev != null ? peerDev.rssi : null);
}
history = uploader.getServerApi().getTelemetryHistory(deviceId, 30);
} catch (Exception ignored) {
}
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 render() {
if (!isAdded() || radioComparePanel == null || uploader == null) {
return;
}
if (statsPeerWarning != null) {
if (cachedPeerError != null) {
statsPeerWarning.setVisibility(View.VISIBLE);
statsPeerWarning.setText(
getString(R.string.stats_two_devices_required, cachedDeviceCount));
} else {
statsPeerWarning.setVisibility(View.GONE);
}
}
var chLocal = snapLocal.diff(prevLocal);
var chPeer = snapPeer.diff(prevPeer);
RadioComparePanel.bindByRole(
radioComparePanel,
snapLocal,
snapPeer,
uploader.getDeviceId(),
cachedPeerId,
chLocal,
chPeer
);
prevLocal = snapLocal;
prevPeer = snapPeer;
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#00FF88" android:state_activated="true" />
<item android:color="#FFFFFF" />
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#CC0F3460" />
<corners android:radius="12dp" />
</shape>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M3,17h2v-3H3v3zM3,12h2V9H3v3zM7,17h2v-5H7v5zM7,7h2V5H7v2zM11,17h2V7h-2v10zM15,17h2v-3h-2v3zM15,12h2V9h-2v3zM19,17h2v-7h-2v7zM19,8h2V5h-2v3z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M14,6l-3.75,5 2.75,3.5L9,18H3l8.5,-10.5L14,6zM17.5,10.5L14,15h6l-2.5,-4.5z" />
</vector>
@@ -0,0 +1,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>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,3C7.03,3 3,7.03 3,12h2c0,-3.87 3.13,-7 7,-7s7,3.13 7,7h2c0,-4.97 -4.03,-9 -9,-9zM12,7c-2.76,0 -5,2.24 -5,5h2c0,-1.66 1.34,-3 3,-3s3,1.34 3,3h2c0,-2.76 -2.24,-5 -5,-5zM12,11c-0.55,0 -1,0.45 -1,1h2c0,-0.55 -0.45,-1 -1,-1zM4.5,14.5L2,17l2.5,2.5 1.4,-1.4L4.8,17l1.1,-1.1 -1.4,-1.4zM19.5,14.5l-1.4,1.4 1.1,1.1 -1.1,1.1 1.4,1.4L22,17l-2.5,-2.5z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M4,18h16v2H4v-2zM6,15h2v2H6v-2zM16,15h2v2h-2v-2zM8,12h8v2H8v-2zM10,9h4v2h-4V9zM12,6c-2.2,0 -4,1.8 -4,4h2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2h2c0,-2.2 -1.8,-4 -4,-4z" />
</vector>
+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>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,14c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,20c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5V19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45V19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
</vector>
+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>
+228
View File
@@ -0,0 +1,228 @@
<?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: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" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/at_current_values"
android:textStyle="bold" />
<TextView
android:id="@+id/atCurrentSnapshot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textSize="11sp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/at_hint_fq_mhz">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atInputFq"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
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_hint_power">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atInputPw"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberSigned" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:hint="SF">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atInputSf"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/at_hint_bw"
android:textSize="12sp" />
<Spinner
android:id="@+id/atBwSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/at_hint_cr"
android:textSize="12sp" />
<Spinner
android:id="@+id/atCrSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
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_hint_pl">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atInputPl"
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="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:hint="@string/at_hint_tm">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/atInputTm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/at_hint_role"
android:textSize="12sp" />
<Spinner
android:id="@+id/atRoleSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/atTargetGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
app:selectionRequired="true"
app:singleSelection="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/atTargetLocal"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/at_target_local" />
<com.google.android.material.button.MaterialButton
android:id="@+id/atTargetPeer"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/at_target_peer" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:orientation="horizontal">
<Button
android:id="@+id/atApplyBtn"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/at_apply" />
<Button
android:id="@+id/atStopBtn"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:text="S" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<com.google.android.material.button.MaterialButton
android:id="@+id/atConsoleToggle"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/at_console_toggle" />
<ScrollView
android:id="@+id/atConsoleScroll"
android:layout_width="match_parent"
android:layout_height="120dp"
android:background="#0D1117"
android:padding="8dp"
android:visibility="gone">
<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>
+293
View File
@@ -0,0 +1,293 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
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" />
<LinearLayout
android:id="@+id/mapStatusChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="56dp"
android:background="@drawable/bg_map_panel"
android:elevation="6dp"
android:orientation="vertical"
android:paddingStart="8dp"
android:paddingTop="6dp"
android:paddingEnd="8dp"
android:paddingBottom="6dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/iconServer"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@string/status_server"
android:src="@drawable/ic_link_server" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="3dp"
android:text="@string/status_server_short"
android:textColor="#AAAAAA"
android:textSize="9sp" />
<ImageView
android:id="@+id/iconLora"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="8dp"
android:contentDescription="@string/status_lora"
android:src="@drawable/ic_link_lora" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="3dp"
android:text="@string/status_lora_short"
android:textColor="#AAAAAA"
android:textSize="9sp" />
</LinearLayout>
<TextView
android:id="@+id/mapStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:maxLines="2"
android:textColor="#FFFFFF"
android:textSize="10sp" />
<TextView
android:id="@+id/mapDistance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#00FF88"
android:textSize="9sp"
android:visibility="gone" />
</LinearLayout>
<LinearLayout
android:id="@+id/mapToolRail"
android:layout_width="48dp"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="6dp"
android:background="@drawable/bg_map_panel"
android:elevation="6dp"
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<ImageButton
android:id="@+id/btnToolCenter"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_center"
android:src="@drawable/ic_center"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnFindHill"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_find_hill"
android:src="@drawable/ic_hill"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnHeatmap"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_heatmap"
android:src="@drawable/ic_heatmap"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnTrack"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_track"
android:src="@drawable/ic_track"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnPairedTrack"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_paired"
android:src="@drawable/ic_paired_track"
app:tint="@color/map_tool_icon_tint" />
<ImageButton
android:id="@+id/btnToolMore"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/map_tool_more"
android:src="@drawable/ic_more_vert"
app:tint="@color/map_tool_icon_tint" />
</LinearLayout>
<ScrollView
android:id="@+id/mapToolDrawer"
android:layout_width="148dp"
android:layout_height="wrap_content"
android:layout_gravity="end|top"
android:layout_marginTop="8dp"
android:layout_marginEnd="56dp"
android:background="@drawable/bg_map_panel"
android:elevation="6dp"
android:scrollbars="none"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/map_center_mode"
android:textColor="#00FF88"
android:textSize="10sp"
android:textStyle="bold" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/mapCenterMode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:orientation="vertical"
app:selectionRequired="false"
app:singleSelection="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/centerMe"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="32dp"
android:text="@string/map_center_me"
android:textSize="10sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/centerTx"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="32dp"
android:text="@string/map_center_tx"
android:textSize="10sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/centerRx"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="32dp"
android:text="@string/map_center_rx"
android:textSize="10sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/centerBoth"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="32dp"
android:text="@string/map_center_both"
android:textSize="10sp" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/map_heatmap_radius"
android:textColor="#CCCCCC"
android:textSize="9sp" />
<Spinner
android:id="@+id/mapHeatmapRadius"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp" />
<TextView
android:id="@+id/mapHeatmapStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#AAAAAA"
android:textSize="9sp"
android:visibility="gone" />
<TextView
android:id="@+id/mapHillStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#FFC107"
android:textSize="9sp"
android:visibility="gone" />
<TextView
android:id="@+id/mapLegend"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/map_legend"
android:textColor="#CCCCCC"
android:textSize="9sp" />
<Spinner
android:id="@+id/trackSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp" />
<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" />
</LinearLayout>
</ScrollView>
<com.grigowashere.loratester.ui.ElevationHeatmapLegendView
android:id="@+id/mapHeatmapLegend"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_marginStart="10dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="10dp"
android:elevation="4dp"
android:visibility="gone" />
</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,63 @@
<?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" />
<TextView
android:id="@+id/statsPeerWarning"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#FF9800"
android:textSize="12sp"
android:visibility="gone" />
<com.grigowashere.loratester.ui.RadioComparePanel
android:id="@+id/radioComparePanel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp" />
<Button
android:id="@+id/btnPushStats"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/stats_push_peer" />
<Button
android:id="@+id/btnSimulate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/simulate_telnet" />
<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>
+44
View File
@@ -0,0 +1,44 @@
<?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="wrap_content"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<LinearLayout
android:id="@+id/chatBubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:background="#1A4A6E"
android:maxWidth="280dp"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/chatAuthor"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#AAAAAA"
android:textSize="10sp"
android:textStyle="bold" />
<TextView
android:id="@+id/chatText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#EEEEEE"
android:textSize="13sp" />
<TextView
android:id="@+id/chatTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#888888"
android:textSize="9sp" />
</LinearLayout>
</FrameLayout>
+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,55 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="4dp">
<TextView
android:id="@+id/compareTxHeader"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="#E94560"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/compareRxHeader"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="#4FC3F7"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
<TableLayout
android:id="@+id/compareDynamicTable"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:stretchColumns="1,2" />
<com.google.android.material.button.MaterialButton
android:id="@+id/compareStaticToggle"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:padding="0dp"
android:text="@string/stats_static_toggle"
android:textSize="12sp" />
<TableLayout
android:id="@+id/compareStaticTable"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:stretchColumns="1,2"
android:visibility="gone" />
</LinearLayout>
@@ -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

Some files were not shown because too many files have changed in this diff Show More