Compare commits
5 Commits
e75f63cf30
..
v7
| Author | SHA1 | Date | |
|---|---|---|---|
| 81eaa95df3 | |||
| 253a7d74ca | |||
| ab7c214966 | |||
| cbcd3399b3 | |||
| 83d0353754 |
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -7,10 +7,8 @@ pipeline {
|
||||
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}"
|
||||
|
||||
TAIGA_PROJECT_ID = '2'
|
||||
TAIGA_URL = 'https://taiga.grigowashere.ru'
|
||||
GITEA_OWNER = 'Grigo'
|
||||
GITEA_REPO = 'TestingAndroidBuild' // Замените на нужный репозиторий
|
||||
GITEA_REPO = 'LoraMapTester' // Замените на нужный репозиторий
|
||||
GITEA_URL = 'https://git.grigowashere.ru' // Базовый URL Gitea
|
||||
GITEA_API_URL = "${GITEA_URL}/api/v1"
|
||||
GITEA_TOKEN_CREDENTIALS_ID = 'Gitea_Credentials' // ID ваших креденшлов для Gitea в Jenkins
|
||||
@@ -40,7 +38,11 @@ pipeline {
|
||||
writeFile file: 'gitea-release.sh', text: '''
|
||||
#!/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"
|
||||
|
||||
# Создаем релиз на 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 `http://<ваш-сервер>:7634`, включите telnet при наличии моста COM→telnet
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
./gradlew test
|
||||
```
|
||||
|
||||
Симуляция телнет-кадра: вкладка **Статистика** → «Симуляция телнет-кадра».
|
||||
|
||||
AT-команды: вкладка **AT** — быстрые кнопки и произвольная строка (добавляются префикс `AT` и `\r\n`). Нужен включённый telnet в **Настройках**.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,257 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
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" -> {
|
||||
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;
|
||||
}
|
||||
|
||||
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,101 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.activity.EdgeToEdge;
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.viewpager2.widget.ViewPager2;
|
||||
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
import com.google.android.material.tabs.TabLayoutMediator;
|
||||
import com.grigowashere.loratester.location.LocationTracker;
|
||||
import com.grigowashere.loratester.track.TrackRecorder;
|
||||
import com.grigowashere.loratester.ui.MainPagerAdapter;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private TelemetryUploader telemetryUploader;
|
||||
private LocationTracker locationTracker;
|
||||
|
||||
private final ActivityResultLauncher<String[]> locationPermissionLauncher =
|
||||
registerForActivityResult(
|
||||
new ActivityResultContracts.RequestMultiplePermissions(),
|
||||
result -> startLocationIfPermitted()
|
||||
);
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
EdgeToEdge.enable(this);
|
||||
setContentView(R.layout.activity_main);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
|
||||
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
|
||||
return insets;
|
||||
});
|
||||
|
||||
LoraApp app = (LoraApp) getApplication();
|
||||
telemetryUploader = app.getTelemetryUploader();
|
||||
SettingsRepository settings = app.getSettingsRepository();
|
||||
|
||||
ViewPager2 pager = findViewById(R.id.viewPager);
|
||||
TabLayout tabs = findViewById(R.id.tabLayout);
|
||||
pager.setAdapter(new MainPagerAdapter(this));
|
||||
new TabLayoutMediator(tabs, pager, (tab, position) -> {
|
||||
int titleRes = switch (position) {
|
||||
case 0 -> R.string.tab_map;
|
||||
case 1 -> R.string.tab_stats;
|
||||
case 2 -> R.string.tab_at;
|
||||
case 3 -> R.string.tab_chat;
|
||||
default -> R.string.tab_settings;
|
||||
};
|
||||
tab.setText(titleRes);
|
||||
}).attach();
|
||||
|
||||
TrackRecorder trackRecorder = app.getTrackRecorder();
|
||||
locationTracker = new LocationTracker(this, (lat, lon, alt) -> {
|
||||
telemetryUploader.updateLocation(lat, lon);
|
||||
trackRecorder.updateLocation(lat, lon, alt);
|
||||
});
|
||||
|
||||
requestLocationPermission();
|
||||
if (settings.isTelnetEnabled()) {
|
||||
telemetryUploader.startTelnet();
|
||||
}
|
||||
}
|
||||
|
||||
private void requestLocationPermission() {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
== PackageManager.PERMISSION_GRANTED) {
|
||||
startLocationIfPermitted();
|
||||
} else {
|
||||
locationPermissionLauncher.launch(new String[]{
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void startLocationIfPermitted() {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
== PackageManager.PERMISSION_GRANTED) {
|
||||
locationTracker.start();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
locationTracker.stop();
|
||||
telemetryUploader.stopTelnet();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,86 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
public class SettingsRepository {
|
||||
|
||||
private static final String PREFS = "loratester_settings";
|
||||
private static final String KEY_SERVER_URL = "server_url";
|
||||
private static final String KEY_TELNET_HOST = "telnet_host";
|
||||
private static final String KEY_TELNET_PORT = "telnet_port";
|
||||
private static final String KEY_RSSI_REGEX = "rssi_regex";
|
||||
private static final String KEY_RANGE_REGEX = "range_regex";
|
||||
private static final String KEY_TELNET_ENABLED = "telnet_enabled";
|
||||
private static final String KEY_DEVICE_ID = "device_id";
|
||||
|
||||
public static final String DEFAULT_SERVER = "http://grigowashere.ru:7634";
|
||||
public static final String DEFAULT_TELNET_HOST = "127.0.0.1";
|
||||
public static final int DEFAULT_TELNET_PORT = 2727;
|
||||
public static final String DEFAULT_RSSI_REGEX = "(?:RSSI|Power)[:\\s]*(-?\\d+(?:\\.\\d+)?)";
|
||||
public static final String DEFAULT_RANGE_REGEX = "range[:\\s]*([\\d.]+)";
|
||||
|
||||
private final SharedPreferences prefs;
|
||||
|
||||
public SettingsRepository(Context context) {
|
||||
prefs = context.getApplicationContext()
|
||||
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
public String getServerUrl() {
|
||||
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER);
|
||||
}
|
||||
|
||||
public void setServerUrl(String url) {
|
||||
prefs.edit().putString(KEY_SERVER_URL, url).apply();
|
||||
}
|
||||
|
||||
public String getTelnetHost() {
|
||||
return prefs.getString(KEY_TELNET_HOST, DEFAULT_TELNET_HOST);
|
||||
}
|
||||
|
||||
public void setTelnetHost(String host) {
|
||||
prefs.edit().putString(KEY_TELNET_HOST, host).apply();
|
||||
}
|
||||
|
||||
public int getTelnetPort() {
|
||||
return prefs.getInt(KEY_TELNET_PORT, DEFAULT_TELNET_PORT);
|
||||
}
|
||||
|
||||
public void setTelnetPort(int port) {
|
||||
prefs.edit().putInt(KEY_TELNET_PORT, port).apply();
|
||||
}
|
||||
|
||||
public String getRssiRegex() {
|
||||
return prefs.getString(KEY_RSSI_REGEX, DEFAULT_RSSI_REGEX);
|
||||
}
|
||||
|
||||
public void setRssiRegex(String regex) {
|
||||
prefs.edit().putString(KEY_RSSI_REGEX, regex).apply();
|
||||
}
|
||||
|
||||
public String getRangeRegex() {
|
||||
return prefs.getString(KEY_RANGE_REGEX, DEFAULT_RANGE_REGEX);
|
||||
}
|
||||
|
||||
public void setRangeRegex(String regex) {
|
||||
prefs.edit().putString(KEY_RANGE_REGEX, regex).apply();
|
||||
}
|
||||
|
||||
public boolean isTelnetEnabled() {
|
||||
return prefs.getBoolean(KEY_TELNET_ENABLED, false);
|
||||
}
|
||||
|
||||
public void setTelnetEnabled(boolean enabled) {
|
||||
prefs.edit().putBoolean(KEY_TELNET_ENABLED, enabled).apply();
|
||||
}
|
||||
|
||||
public String getOrCreateDeviceId() {
|
||||
String id = prefs.getString(KEY_DEVICE_ID, null);
|
||||
if (id == null || id.isEmpty()) {
|
||||
id = "android-" + java.util.UUID.randomUUID().toString().substring(0, 8);
|
||||
prefs.edit().putString(KEY_DEVICE_ID, id).apply();
|
||||
}
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import com.grigowashere.loratester.api.ServerApi;
|
||||
import com.grigowashere.loratester.api.TelemetryPayload;
|
||||
import com.grigowashere.loratester.api.UploadQueue;
|
||||
import com.grigowashere.loratester.net.NetworkMonitor;
|
||||
import com.grigowashere.loratester.location.GeoUtils;
|
||||
import com.grigowashere.loratester.telnet.AtCommandFormatter;
|
||||
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||
import com.grigowashere.loratester.telnet.TelnetClient;
|
||||
import com.grigowashere.loratester.telnet.TelnetFrameParser;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class TelemetryUploader implements TelnetClient.Listener {
|
||||
|
||||
private static final String TAG = "TelemetryUploader";
|
||||
|
||||
public interface AtSendCallback {
|
||||
void onResult(TelnetClient.SendResult result);
|
||||
}
|
||||
|
||||
public interface StatsListener {
|
||||
void onStatsUpdated(StatsExtractor.ExtractedStats stats);
|
||||
}
|
||||
|
||||
private final SettingsRepository settings;
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private final ExecutorService uploadExecutor = Executors.newSingleThreadExecutor();
|
||||
private final ExecutorService telnetExecutor = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "TelnetWorker");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
private TelnetClient telnetClient;
|
||||
private TelnetFrameParser frameParser;
|
||||
private StatsExtractor statsExtractor;
|
||||
private ServerApi serverApi;
|
||||
|
||||
private static final int CONSOLE_MAX_CHARS = 16_384;
|
||||
private static final long SNAPSHOT_INTERVAL_MS = 1000;
|
||||
|
||||
private final ScheduledExecutorService snapshotScheduler =
|
||||
Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "TelnetSnapshot");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
private volatile ScheduledFuture<?> snapshotFuture;
|
||||
|
||||
private volatile double lat = Double.NaN;
|
||||
private volatile double lon = Double.NaN;
|
||||
private volatile boolean connected;
|
||||
private final StringBuilder consoleLog = new StringBuilder();
|
||||
private volatile StatsExtractor.ExtractedStats lastStats;
|
||||
private volatile long lastStatsAtMs;
|
||||
private StatsListener statsListener;
|
||||
private final UploadQueue uploadQueue;
|
||||
private final NetworkMonitor networkMonitor;
|
||||
|
||||
public TelemetryUploader(
|
||||
Context context,
|
||||
SettingsRepository settings,
|
||||
NetworkMonitor networkMonitor
|
||||
) {
|
||||
this.settings = settings;
|
||||
this.networkMonitor = networkMonitor;
|
||||
uploadQueue = new UploadQueue(context.getApplicationContext());
|
||||
serverApi = new ServerApi(settings.getServerUrl());
|
||||
statsExtractor = StatsExtractor.withDefaults();
|
||||
frameParser = new TelnetFrameParser(this::onFrame);
|
||||
networkMonitor.addListener(online -> {
|
||||
if (online) {
|
||||
flushUploadQueue();
|
||||
}
|
||||
});
|
||||
if (networkMonitor.isOnline()) {
|
||||
uploadExecutor.execute(this::flushUploadQueue);
|
||||
}
|
||||
}
|
||||
|
||||
public int getPendingUploadCount() {
|
||||
return uploadQueue.size();
|
||||
}
|
||||
|
||||
public void refreshApi() {
|
||||
serverApi = new ServerApi(settings.getServerUrl());
|
||||
telnetExecutor.execute(() -> {
|
||||
statsExtractor = new StatsExtractor(
|
||||
settings.getRssiRegex(),
|
||||
settings.getRangeRegex()
|
||||
);
|
||||
frameParser = new TelnetFrameParser(TelemetryUploader.this::onFrame);
|
||||
});
|
||||
}
|
||||
|
||||
public void updateLocation(double lat, double lon) {
|
||||
if (GeoUtils.isValidCoordinate(lat, lon)) {
|
||||
this.lat = lat;
|
||||
this.lon = lon;
|
||||
}
|
||||
}
|
||||
|
||||
private Double validLat() {
|
||||
return GeoUtils.isValidCoordinate(lat, lon) ? lat : null;
|
||||
}
|
||||
|
||||
private Double validLon() {
|
||||
return GeoUtils.isValidCoordinate(lat, lon) ? lon : null;
|
||||
}
|
||||
|
||||
public void startTelnet() {
|
||||
serverApi = new ServerApi(settings.getServerUrl());
|
||||
telnetExecutor.execute(() -> {
|
||||
if (telnetClient != null) {
|
||||
telnetClient.stop();
|
||||
telnetClient = null;
|
||||
}
|
||||
statsExtractor = new StatsExtractor(
|
||||
settings.getRssiRegex(),
|
||||
settings.getRangeRegex()
|
||||
);
|
||||
frameParser = new TelnetFrameParser(TelemetryUploader.this::onFrame);
|
||||
telnetClient = new TelnetClient(
|
||||
settings.getTelnetHost(),
|
||||
settings.getTelnetPort(),
|
||||
TelemetryUploader.this
|
||||
);
|
||||
telnetClient.start();
|
||||
});
|
||||
}
|
||||
|
||||
public void stopTelnet() {
|
||||
stopSnapshotTicker();
|
||||
telnetExecutor.execute(() -> {
|
||||
if (telnetClient != null) {
|
||||
telnetClient.stop();
|
||||
telnetClient = null;
|
||||
}
|
||||
if (frameParser != null) {
|
||||
frameParser.flush();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public boolean isTelnetConnected() {
|
||||
return connected;
|
||||
}
|
||||
|
||||
/** Sends AT command on telnet worker thread (safe while receiving data). */
|
||||
public void sendAtCommand(String command, AtSendCallback callback) {
|
||||
telnetExecutor.execute(() -> {
|
||||
TelnetClient.SendResult result = sendAtCommandOnWorker(command);
|
||||
if (callback != null) {
|
||||
mainHandler.post(() -> callback.onResult(result));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private TelnetClient.SendResult sendAtCommandOnWorker(String command) {
|
||||
String normalized = AtCommandFormatter.normalize(command);
|
||||
appendConsole(">> " + normalized + "\n");
|
||||
if (telnetClient == null) {
|
||||
appendConsole("!! telnet not started\n");
|
||||
return TelnetClient.SendResult.NOT_CONNECTED;
|
||||
}
|
||||
TelnetClient.SendResult result = telnetClient.sendAtCommand(command);
|
||||
if (result != TelnetClient.SendResult.SENT) {
|
||||
appendConsole("!! send failed: " + result + "\n");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public synchronized String getConsoleLog() {
|
||||
return consoleLog.toString();
|
||||
}
|
||||
|
||||
public synchronized void clearConsoleLog() {
|
||||
consoleLog.setLength(0);
|
||||
}
|
||||
|
||||
private synchronized void appendConsole(String text) {
|
||||
consoleLog.append(text);
|
||||
if (consoleLog.length() > CONSOLE_MAX_CHARS) {
|
||||
consoleLog.delete(0, consoleLog.length() - CONSOLE_MAX_CHARS);
|
||||
}
|
||||
}
|
||||
|
||||
public void setStatsListener(StatsListener listener) {
|
||||
this.statsListener = listener;
|
||||
if (listener != null && lastStats != null) {
|
||||
mainHandler.post(() -> listener.onStatsUpdated(lastStats));
|
||||
}
|
||||
}
|
||||
|
||||
public StatsExtractor.ExtractedStats getLastStats() {
|
||||
return lastStats;
|
||||
}
|
||||
|
||||
public long getLastStatsAtMs() {
|
||||
return lastStatsAtMs;
|
||||
}
|
||||
|
||||
private void onFrame(String frame) {
|
||||
StatsExtractor.ExtractedStats stats = statsExtractor.extract(frame);
|
||||
if (!stats.hasRadioFrame()) {
|
||||
return;
|
||||
}
|
||||
lastStats = stats;
|
||||
lastStatsAtMs = System.currentTimeMillis();
|
||||
StatsListener listener = statsListener;
|
||||
if (listener != null) {
|
||||
mainHandler.post(() -> listener.onStatsUpdated(stats));
|
||||
}
|
||||
TelemetryPayload payload = new TelemetryPayload(
|
||||
settings.getOrCreateDeviceId(),
|
||||
validLat(),
|
||||
validLon(),
|
||||
stats.rssi,
|
||||
stats.rangeM,
|
||||
null,
|
||||
stats.metaJson,
|
||||
stats.role,
|
||||
System.currentTimeMillis() / 1000.0
|
||||
);
|
||||
uploadExecutor.execute(() -> uploadTelemetry(payload));
|
||||
}
|
||||
|
||||
private void uploadTelemetry(TelemetryPayload payload) {
|
||||
if (networkMonitor.isOnline()) {
|
||||
try {
|
||||
serverApi.postTelemetry(payload);
|
||||
flushUploadQueue();
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "upload failed", e);
|
||||
appendConsole("!! server upload: " + e.getMessage() + "\n");
|
||||
}
|
||||
}
|
||||
uploadQueue.enqueue(payload);
|
||||
int pending = uploadQueue.size();
|
||||
if (pending > 0 && pending % 10 == 0) {
|
||||
appendConsole("!! queued uploads: " + pending + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
private void flushUploadQueue() {
|
||||
if (!networkMonitor.isOnline()) {
|
||||
return;
|
||||
}
|
||||
int sent = uploadQueue.flushAll(serverApi);
|
||||
if (sent > 0) {
|
||||
appendConsole(">> uploaded " + sent + " queued frame(s)\n");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnected() {
|
||||
mainHandler.post(() -> connected = true);
|
||||
startSnapshotTicker();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected() {
|
||||
stopSnapshotTicker();
|
||||
mainHandler.post(() -> connected = false);
|
||||
}
|
||||
|
||||
private void startSnapshotTicker() {
|
||||
stopSnapshotTicker();
|
||||
snapshotFuture = snapshotScheduler.scheduleAtFixedRate(
|
||||
() -> telnetExecutor.execute(this::tickSnapshot),
|
||||
SNAPSHOT_INTERVAL_MS,
|
||||
SNAPSHOT_INTERVAL_MS,
|
||||
TimeUnit.MILLISECONDS
|
||||
);
|
||||
}
|
||||
|
||||
private void stopSnapshotTicker() {
|
||||
if (snapshotFuture != null) {
|
||||
snapshotFuture.cancel(false);
|
||||
snapshotFuture = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void tickSnapshot() {
|
||||
if (frameParser != null && connected) {
|
||||
frameParser.emitSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBytes(byte[] data, int length) {
|
||||
byte[] copy = Arrays.copyOf(data, length);
|
||||
telnetExecutor.execute(() -> {
|
||||
appendConsole(new String(copy, StandardCharsets.UTF_8));
|
||||
if (frameParser != null) {
|
||||
frameParser.append(copy);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String message) {
|
||||
Log.w(TAG, "telnet: " + message);
|
||||
telnetExecutor.execute(() -> appendConsole("!! " + message + "\n"));
|
||||
}
|
||||
|
||||
/** Feeds simulated telnet stream for testing without hardware. */
|
||||
public void simulateChunk(String text) {
|
||||
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
|
||||
telnetExecutor.execute(() -> {
|
||||
if (frameParser != null) {
|
||||
frameParser.append(bytes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ServerApi getServerApi() {
|
||||
return serverApi;
|
||||
}
|
||||
|
||||
public String getDeviceId() {
|
||||
return settings.getOrCreateDeviceId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.grigowashere.loratester.api;
|
||||
|
||||
public class ChatMessage {
|
||||
public long id;
|
||||
public String device_id;
|
||||
public String text;
|
||||
public double ts;
|
||||
}
|
||||
@@ -0,0 +1,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,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,250 @@
|
||||
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")
|
||||
private Map<String, Object> postJsonMap(String path, Map<String, Object> body, boolean android)
|
||||
throws IOException {
|
||||
Request.Builder builder = new Request.Builder()
|
||||
.url(baseUrl + path)
|
||||
.post(RequestBody.create(GSON.toJson(body), JSON));
|
||||
if (android) {
|
||||
builder.header(HEADER_LORA_CLIENT, CLIENT_ANDROID);
|
||||
}
|
||||
try (Response response = client.newCall(builder.build()).execute()) {
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
throw new IOException("HTTP " + response.code());
|
||||
}
|
||||
return GSON.fromJson(response.body().string(), Map.class);
|
||||
}
|
||||
}
|
||||
|
||||
private void postJson(String path, Map<String, Object> body) throws IOException {
|
||||
postJson(path, body, false);
|
||||
}
|
||||
|
||||
private void postJson(String path, Map<String, Object> body, boolean androidClient)
|
||||
throws IOException {
|
||||
Request.Builder builder = new Request.Builder()
|
||||
.url(baseUrl + path)
|
||||
.post(RequestBody.create(GSON.toJson(body), JSON));
|
||||
if (androidClient) {
|
||||
builder.header(HEADER_LORA_CLIENT, CLIENT_ANDROID);
|
||||
}
|
||||
execute(builder.build());
|
||||
}
|
||||
|
||||
private <T> T getJsonList(String path, Type type) throws IOException {
|
||||
Request request = new Request.Builder()
|
||||
.url(baseUrl + path)
|
||||
.get()
|
||||
.build();
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
throw new IOException("HTTP " + response.code());
|
||||
}
|
||||
return GSON.fromJson(response.body().string(), type);
|
||||
}
|
||||
}
|
||||
|
||||
private void execute(Request request) throws IOException {
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IOException("HTTP " + response.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.grigowashere.loratester.api;
|
||||
|
||||
public class TelemetryHistoryItem {
|
||||
public long id;
|
||||
public String device_id;
|
||||
public Double lat;
|
||||
public Double lon;
|
||||
public Double rssi;
|
||||
public Double range_m;
|
||||
public String meta;
|
||||
public String role;
|
||||
public double ts;
|
||||
public String source;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.grigowashere.loratester.api;
|
||||
|
||||
public class TelemetryPayload {
|
||||
public final String deviceId;
|
||||
public final Double lat;
|
||||
public final Double lon;
|
||||
public final Double rssi;
|
||||
public final Double rangeM;
|
||||
public final String rawFrame;
|
||||
public final String meta;
|
||||
/** TX = передатчик, RX = приёмник. */
|
||||
public final String role;
|
||||
public final Double ts;
|
||||
|
||||
public TelemetryPayload(
|
||||
String deviceId,
|
||||
Double lat,
|
||||
Double lon,
|
||||
Double rssi,
|
||||
Double rangeM,
|
||||
String rawFrame,
|
||||
String meta,
|
||||
String role,
|
||||
Double ts
|
||||
) {
|
||||
this.deviceId = deviceId;
|
||||
this.lat = lat;
|
||||
this.lon = lon;
|
||||
this.rssi = rssi;
|
||||
this.rangeM = rangeM;
|
||||
this.rawFrame = rawFrame;
|
||||
this.meta = meta;
|
||||
this.role = role;
|
||||
this.ts = ts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.grigowashere.loratester.api;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class TrackDetail {
|
||||
public long id;
|
||||
public String device_id;
|
||||
public double started_at;
|
||||
public Double ended_at;
|
||||
public String label;
|
||||
public List<TrackPoint> points;
|
||||
|
||||
public static class TrackPoint {
|
||||
public double ts;
|
||||
public double lat;
|
||||
public double lon;
|
||||
public Double altitude_gps;
|
||||
public Double elevation_m;
|
||||
public Double rssi;
|
||||
public String role;
|
||||
public String meta;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.grigowashere.loratester.api;
|
||||
|
||||
public class TrackInfo {
|
||||
public long id;
|
||||
public String device_id;
|
||||
public double started_at;
|
||||
public Double ended_at;
|
||||
public String label;
|
||||
public int point_count;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.grigowashere.loratester.api;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileReader;
|
||||
import java.io.FileWriter;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Persistent queue for telemetry uploads when the network is down. */
|
||||
public class UploadQueue {
|
||||
|
||||
private static final String TAG = "UploadQueue";
|
||||
private static final String FILE_NAME = "telemetry_upload_queue.json";
|
||||
private static final int MAX_ITEMS = 500;
|
||||
|
||||
private static final Gson GSON = new Gson();
|
||||
private static final Type LIST_TYPE = new TypeToken<List<QueuedItem>>() {}.getType();
|
||||
|
||||
private final File queueFile;
|
||||
private final List<QueuedItem> items = new ArrayList<>();
|
||||
private String lastMeta;
|
||||
|
||||
public UploadQueue(Context context) {
|
||||
queueFile = new File(context.getFilesDir(), FILE_NAME);
|
||||
load();
|
||||
}
|
||||
|
||||
public synchronized int size() {
|
||||
return items.size();
|
||||
}
|
||||
|
||||
public synchronized void enqueue(TelemetryPayload payload) {
|
||||
if (payload == null) {
|
||||
return;
|
||||
}
|
||||
if (payload.meta != null && payload.meta.equals(lastMeta)) {
|
||||
return;
|
||||
}
|
||||
lastMeta = payload.meta;
|
||||
items.add(QueuedItem.from(payload));
|
||||
trim();
|
||||
persist();
|
||||
}
|
||||
|
||||
public synchronized int flushAll(ServerApi api) {
|
||||
if (api == null || items.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int sent = 0;
|
||||
Iterator<QueuedItem> it = items.iterator();
|
||||
while (it.hasNext()) {
|
||||
QueuedItem item = it.next();
|
||||
try {
|
||||
api.postTelemetry(item.toPayload());
|
||||
it.remove();
|
||||
sent++;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "flush stopped at " + sent + " sent", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sent > 0) {
|
||||
persist();
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
private void trim() {
|
||||
while (items.size() > MAX_ITEMS) {
|
||||
items.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void load() {
|
||||
if (!queueFile.exists()) {
|
||||
return;
|
||||
}
|
||||
try (FileReader reader = new FileReader(queueFile)) {
|
||||
List<QueuedItem> loaded = GSON.fromJson(reader, LIST_TYPE);
|
||||
if (loaded != null) {
|
||||
items.clear();
|
||||
items.addAll(loaded);
|
||||
trim();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "load queue failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void persist() {
|
||||
try (FileWriter writer = new FileWriter(queueFile)) {
|
||||
GSON.toJson(items, writer);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "persist queue failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
static final class QueuedItem {
|
||||
String deviceId;
|
||||
Double lat;
|
||||
Double lon;
|
||||
Double rssi;
|
||||
Double rangeM;
|
||||
String rawFrame;
|
||||
String meta;
|
||||
String role;
|
||||
Double ts;
|
||||
|
||||
static QueuedItem from(TelemetryPayload p) {
|
||||
QueuedItem q = new QueuedItem();
|
||||
q.deviceId = p.deviceId;
|
||||
q.lat = p.lat;
|
||||
q.lon = p.lon;
|
||||
q.rssi = p.rssi;
|
||||
q.rangeM = p.rangeM;
|
||||
q.rawFrame = p.rawFrame;
|
||||
q.meta = p.meta;
|
||||
q.role = p.role;
|
||||
q.ts = p.ts;
|
||||
return q;
|
||||
}
|
||||
|
||||
TelemetryPayload toPayload() {
|
||||
return new TelemetryPayload(
|
||||
deviceId, lat, lon, rssi, rangeM, rawFrame, meta, role, ts);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof QueuedItem other)) {
|
||||
return false;
|
||||
}
|
||||
return Objects.equals(deviceId, other.deviceId)
|
||||
&& Objects.equals(meta, other.meta)
|
||||
&& Objects.equals(ts, other.ts);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(deviceId, meta, ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.grigowashere.loratester.location;
|
||||
|
||||
public final class GeoUtils {
|
||||
|
||||
private static final double ZERO_EPS = 1e-5;
|
||||
|
||||
private GeoUtils() {
|
||||
}
|
||||
|
||||
public static boolean isValidCoordinate(double lat, double lon) {
|
||||
if (Double.isNaN(lat) || Double.isNaN(lon) || Double.isInfinite(lat) || Double.isInfinite(lon)) {
|
||||
return false;
|
||||
}
|
||||
if (Math.abs(lat) < ZERO_EPS && Math.abs(lon) < ZERO_EPS) {
|
||||
return false;
|
||||
}
|
||||
return lat >= -90.0 && lat <= 90.0 && lon >= -180.0 && lon <= 180.0;
|
||||
}
|
||||
|
||||
public static boolean isValidCoordinate(Double lat, Double lon) {
|
||||
return lat != null && lon != null && isValidCoordinate(lat.doubleValue(), lon.doubleValue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.grigowashere.loratester.location;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.os.Looper;
|
||||
|
||||
import com.google.android.gms.location.FusedLocationProviderClient;
|
||||
import com.google.android.gms.location.LocationCallback;
|
||||
import com.google.android.gms.location.LocationRequest;
|
||||
import com.google.android.gms.location.LocationResult;
|
||||
import com.google.android.gms.location.LocationServices;
|
||||
import com.google.android.gms.location.Priority;
|
||||
|
||||
public class LocationTracker {
|
||||
|
||||
public interface Listener {
|
||||
void onLocation(double lat, double lon, double altitude);
|
||||
}
|
||||
|
||||
private final FusedLocationProviderClient client;
|
||||
private final Listener listener;
|
||||
private LocationCallback callback;
|
||||
|
||||
public LocationTracker(Context context, Listener listener) {
|
||||
this.client = LocationServices.getFusedLocationProviderClient(context);
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
public void start() {
|
||||
if (callback != null) {
|
||||
return;
|
||||
}
|
||||
LocationRequest request = new LocationRequest.Builder(
|
||||
Priority.PRIORITY_HIGH_ACCURACY, 10_000L
|
||||
).setMinUpdateIntervalMillis(5_000L).build();
|
||||
|
||||
callback = new LocationCallback() {
|
||||
@Override
|
||||
public void onLocationResult(LocationResult result) {
|
||||
if (result.getLastLocation() == null) {
|
||||
return;
|
||||
}
|
||||
double lat = result.getLastLocation().getLatitude();
|
||||
double lon = result.getLastLocation().getLongitude();
|
||||
double alt = result.getLastLocation().getAltitude();
|
||||
if (GeoUtils.isValidCoordinate(lat, lon)) {
|
||||
listener.onLocation(lat, lon, alt);
|
||||
}
|
||||
}
|
||||
};
|
||||
client.requestLocationUpdates(request, callback, Looper.getMainLooper());
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (callback != null) {
|
||||
client.removeLocationUpdates(callback);
|
||||
callback = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.grigowashere.loratester.net;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.net.NetworkRequest;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/** Observes validated internet connectivity. */
|
||||
public class NetworkMonitor {
|
||||
|
||||
public interface Listener {
|
||||
void onConnectivityChanged(boolean online);
|
||||
}
|
||||
|
||||
private final ConnectivityManager connectivityManager;
|
||||
private final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
|
||||
private volatile boolean online;
|
||||
private ConnectivityManager.NetworkCallback networkCallback;
|
||||
|
||||
public NetworkMonitor(@NonNull Context context) {
|
||||
connectivityManager =
|
||||
(ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
online = checkOnline();
|
||||
}
|
||||
|
||||
public boolean isOnline() {
|
||||
return online;
|
||||
}
|
||||
|
||||
public void addListener(@NonNull Listener listener) {
|
||||
listeners.add(listener);
|
||||
listener.onConnectivityChanged(online);
|
||||
}
|
||||
|
||||
public void removeListener(@NonNull Listener listener) {
|
||||
listeners.remove(listener);
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (networkCallback != null || connectivityManager == null) {
|
||||
return;
|
||||
}
|
||||
networkCallback = new ConnectivityManager.NetworkCallback() {
|
||||
@Override
|
||||
public void onAvailable(@NonNull Network network) {
|
||||
updateState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLost(@NonNull Network network) {
|
||||
updateState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCapabilitiesChanged(
|
||||
@NonNull Network network,
|
||||
@NonNull NetworkCapabilities caps
|
||||
) {
|
||||
updateState();
|
||||
}
|
||||
};
|
||||
NetworkRequest request = new NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
connectivityManager.registerDefaultNetworkCallback(networkCallback);
|
||||
} else {
|
||||
connectivityManager.registerNetworkCallback(request, networkCallback);
|
||||
}
|
||||
updateState();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (networkCallback != null && connectivityManager != null) {
|
||||
try {
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback);
|
||||
} catch (Exception ignored) {
|
||||
// ignore
|
||||
}
|
||||
networkCallback = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateState() {
|
||||
boolean now = checkOnline();
|
||||
if (now == online) {
|
||||
return;
|
||||
}
|
||||
online = now;
|
||||
for (Listener l : listeners) {
|
||||
l.onConnectivityChanged(online);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean checkOnline() {
|
||||
if (connectivityManager == null) {
|
||||
return false;
|
||||
}
|
||||
Network network = connectivityManager.getActiveNetwork();
|
||||
if (network == null) {
|
||||
return false;
|
||||
}
|
||||
NetworkCapabilities caps = connectivityManager.getNetworkCapabilities(network);
|
||||
return caps != null
|
||||
&& caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
&& caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.grigowashere.loratester.telnet;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Formats user input as an AT command line for serial/telnet bridges. */
|
||||
public final class AtCommandFormatter {
|
||||
|
||||
private AtCommandFormatter() {
|
||||
}
|
||||
|
||||
public static String normalize(String input) {
|
||||
if (input == null) {
|
||||
return "";
|
||||
}
|
||||
String line = input.trim();
|
||||
if (line.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if (!line.regionMatches(true, 0, "AT", 0, 2)) {
|
||||
line = "AT" + line;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
public static byte[] toWireBytes(String input) {
|
||||
String line = normalize(input);
|
||||
if (line.isEmpty()) {
|
||||
return new byte[0];
|
||||
}
|
||||
String wire = line + "\r\n";
|
||||
return wire.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.grigowashere.loratester.telnet;
|
||||
|
||||
/** Common AT commands for LoRa modules (via telnet bridge). */
|
||||
public final class AtCommands {
|
||||
|
||||
public static final String HELP = "AT+H";
|
||||
public static final String TRANSMIT = "AT+TX";
|
||||
public static final String RECEIVE = "AT+RX";
|
||||
public static final String STATUS = "AT+STATUS";
|
||||
public static final String RESET = "AT+RESET";
|
||||
public static final String BASIC = "AT";
|
||||
|
||||
private AtCommands() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.grigowashere.loratester.telnet;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public final class LoraStatsFormatter {
|
||||
|
||||
private LoraStatsFormatter() {
|
||||
}
|
||||
|
||||
/** Human-readable lines from telemetry meta JSON (fields first). */
|
||||
public static String formatMeta(String metaJson) {
|
||||
if (metaJson == null || metaJson.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
JsonObject o = JsonParser.parseString(metaJson).getAsJsonObject();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
Set<String> shown = new HashSet<>();
|
||||
|
||||
appendFieldsBlock(sb, o.get("fields"), shown);
|
||||
|
||||
String role = text(o, "role");
|
||||
if (role != null) {
|
||||
append(sb, "Роль", roleLabel(role));
|
||||
}
|
||||
append(sb, "Кадр", text(o, "frame"));
|
||||
append(sb, "Мощность TX", dbl(o, "power_dbm"), " dBm");
|
||||
append(sb, "RSSI", dbl(o, "rssi_dbm"), " dBm");
|
||||
append(sb, "SNR", dbl(o, "snr_db"), " dB");
|
||||
append(sb, "Частота", freqMhz(o), " MHz");
|
||||
append(sb, "SF", integer(o, "spreading_factor"));
|
||||
append(sb, "BW", integer(o, "bandwidth_khz"), " kHz");
|
||||
append(sb, "Пакет", integer(o, "packet"));
|
||||
append(sb, "Payload", text(o, "payload"));
|
||||
append(sb, "On Air", dbl(o, "on_air_ms"), " ms");
|
||||
append(sb, "TX Speed", dbl(o, "tx_pkt_per_s"), " pkt/s");
|
||||
append(sb, "RX Speed", dbl(o, "rx_pkt_per_s"), " pkt/s");
|
||||
append(sb, "PER", dbl(o, "per_percent"), " %");
|
||||
return sb.toString().trim();
|
||||
} catch (Exception ignored) {
|
||||
return metaJson;
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendFieldsBlock(StringBuilder sb, JsonElement fieldsEl, Set<String> shown) {
|
||||
if (fieldsEl == null || !fieldsEl.isJsonObject()) {
|
||||
return;
|
||||
}
|
||||
JsonObject fields = fieldsEl.getAsJsonObject();
|
||||
for (Map.Entry<String, JsonElement> e : fields.entrySet()) {
|
||||
String label = e.getKey();
|
||||
if (isSkippedFieldLabel(label)) {
|
||||
continue;
|
||||
}
|
||||
String norm = normalizeLabel(label);
|
||||
if (shown.contains(norm)) {
|
||||
continue;
|
||||
}
|
||||
shown.add(norm);
|
||||
append(sb, label, e.getValue().getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeLabel(String label) {
|
||||
return label.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ").trim();
|
||||
}
|
||||
|
||||
private static boolean isSkippedFieldLabel(String label) {
|
||||
String l = normalizeLabel(label);
|
||||
return l.equals("send") || l.equals("receive");
|
||||
}
|
||||
|
||||
public static String roleLabel(String role) {
|
||||
if (StatsExtractor.ROLE_TX.equals(role)) {
|
||||
return "Передатчик (TX)";
|
||||
}
|
||||
if (StatsExtractor.ROLE_RX.equals(role)) {
|
||||
return "Приёмник (RX)";
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
private static String freqMhz(JsonObject o) {
|
||||
if (!o.has("frequency_hz")) {
|
||||
return null;
|
||||
}
|
||||
long hz = o.get("frequency_hz").getAsLong();
|
||||
return String.format(Locale.US, "%.3f", hz / 1_000_000.0);
|
||||
}
|
||||
|
||||
private static void append(StringBuilder sb, String label, String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.append("\n");
|
||||
}
|
||||
sb.append(label).append(": ").append(value);
|
||||
}
|
||||
|
||||
private static void append(StringBuilder sb, String label, String value, String suffix) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
append(sb, label, value + suffix);
|
||||
}
|
||||
|
||||
private static String text(JsonObject o, String key) {
|
||||
JsonElement e = o.get(key);
|
||||
return e != null && !e.isJsonNull() ? e.getAsString() : null;
|
||||
}
|
||||
|
||||
private static String integer(JsonObject o, String key) {
|
||||
JsonElement e = o.get(key);
|
||||
return e != null && e.isJsonPrimitive() ? String.valueOf(e.getAsInt()) : null;
|
||||
}
|
||||
|
||||
private static String dbl(JsonObject o, String key) {
|
||||
JsonElement e = o.get(key);
|
||||
return e != null && e.isJsonPrimitive() ? String.valueOf(e.getAsDouble()) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package com.grigowashere.loratester.telnet;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Label-based regex parsing — order and count of fields may change on the device.
|
||||
*/
|
||||
public class StatsExtractor {
|
||||
|
||||
public static final String ROLE_TX = "TX";
|
||||
public static final String ROLE_RX = "RX";
|
||||
|
||||
private static final Gson GSON = new Gson();
|
||||
|
||||
private static final Pattern FRAME_SEND = Pattern.compile("(?m)^\\s*SEND\\b");
|
||||
private static final Pattern FRAME_RECEIVE = Pattern.compile("(?m)^\\s*RECEIVE\\b");
|
||||
private static final Pattern POWER = Pattern.compile("Power\\s*:\\s*(\\d+(?:\\.\\d+)?)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern RSSI = Pattern.compile("RSSI\\s*:\\s*(-?\\d+(?:\\.\\d+)?)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern SNR = Pattern.compile("SNR\\s*:\\s*(-?\\d+(?:\\.\\d+)?)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern FREQUENCY = Pattern.compile("Frequency\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern SPREADING = Pattern.compile("Spreading Factor\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern BANDWIDTH = Pattern.compile("Bandwidth\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PACKET = Pattern.compile("Packet\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PACKET_NUMBER = Pattern.compile("Packet Number\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PAYLOAD = Pattern.compile("Payload\\s*:\\s*(.+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern ON_AIR = Pattern.compile("On Air\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern TX_SPEED = Pattern.compile("TX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern RX_SPEED = Pattern.compile("RX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern PER = Pattern.compile("PER\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private final Pattern rssiPattern;
|
||||
private final Pattern rangePattern;
|
||||
|
||||
public StatsExtractor(String rssiRegex, String rangeRegex) {
|
||||
this.rssiPattern = Pattern.compile(rssiRegex, Pattern.CASE_INSENSITIVE);
|
||||
this.rangePattern = Pattern.compile(rangeRegex, Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
|
||||
public static StatsExtractor withDefaults() {
|
||||
return new StatsExtractor(
|
||||
"RSSI\\s*:\\s*(-?\\d+(?:\\.\\d+)?)",
|
||||
"range\\s*:\\s*([\\d.]+)"
|
||||
);
|
||||
}
|
||||
|
||||
public ExtractedStats extract(String frame) {
|
||||
if (frame == null || frame.isBlank()) {
|
||||
return empty(frame);
|
||||
}
|
||||
|
||||
String normalized = TelnetText.normalize(frame);
|
||||
|
||||
Map<String, Object> meta = new LinkedHashMap<>();
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
collectLabeledLines(normalized, fields);
|
||||
|
||||
String frameType = detectFrameType(normalized);
|
||||
String role = frameTypeToRole(frameType);
|
||||
if (frameType != null) {
|
||||
meta.put("frame", frameType);
|
||||
}
|
||||
if (role != null) {
|
||||
meta.put("role", role);
|
||||
}
|
||||
if (!fields.isEmpty()) {
|
||||
meta.put("fields", fields);
|
||||
}
|
||||
|
||||
Double rssiDbm = firstDouble(RSSI, normalized);
|
||||
if (rssiDbm == null) {
|
||||
rssiDbm = matchDouble(rssiPattern, normalized);
|
||||
}
|
||||
Double txPower = matchDouble(POWER, normalized);
|
||||
Double snrDb = matchDouble(SNR, normalized);
|
||||
|
||||
if (rssiDbm != null) {
|
||||
meta.put("rssi_dbm", rssiDbm);
|
||||
}
|
||||
if (txPower != null) {
|
||||
meta.put("power_dbm", txPower);
|
||||
}
|
||||
if (snrDb != null) {
|
||||
meta.put("snr_db", snrDb);
|
||||
}
|
||||
|
||||
putLong(meta, "frequency_hz", matchLong(FREQUENCY, normalized));
|
||||
putInt(meta, "spreading_factor", matchInt(SPREADING, normalized));
|
||||
putInt(meta, "bandwidth_khz", matchInt(BANDWIDTH, normalized));
|
||||
Integer packet = matchInt(PACKET_NUMBER, normalized);
|
||||
if (packet == null) {
|
||||
packet = matchInt(PACKET, normalized);
|
||||
}
|
||||
putInt(meta, "packet", packet);
|
||||
putString(meta, "payload", matchString(PAYLOAD, normalized));
|
||||
putDouble(meta, "on_air_ms", matchDouble(ON_AIR, normalized));
|
||||
putDouble(meta, "tx_pkt_per_s", matchDouble(TX_SPEED, normalized));
|
||||
putDouble(meta, "rx_pkt_per_s", matchDouble(RX_SPEED, normalized));
|
||||
putDouble(meta, "per_percent", matchDouble(PER, normalized));
|
||||
|
||||
enrichFieldsFromStructured(meta, fields);
|
||||
|
||||
Double rangeM = matchDouble(rangePattern, normalized);
|
||||
Double displayDbm = rssiDbm != null ? rssiDbm : txPower;
|
||||
|
||||
String metaJson = meta.isEmpty() ? null : GSON.toJson(meta);
|
||||
return new ExtractedStats(
|
||||
displayDbm, rangeM, frame, metaJson, frameType, role, txPower, rssiDbm, snrDb
|
||||
);
|
||||
}
|
||||
|
||||
private static void collectLabeledLines(String frame, Map<String, String> fields) {
|
||||
for (String line : frame.split("\n")) {
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
int colon = trimmed.indexOf(':');
|
||||
if (colon <= 0) {
|
||||
continue;
|
||||
}
|
||||
String label = trimmed.substring(0, colon).trim();
|
||||
String value = trimmed.substring(colon + 1).trim();
|
||||
if (label.isEmpty()
|
||||
|| label.equalsIgnoreCase("SEND")
|
||||
|| label.equalsIgnoreCase("RECEIVE")) {
|
||||
continue;
|
||||
}
|
||||
fields.put(label, value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure meta.fields has display lines even when line split missed some rows. */
|
||||
private static void enrichFieldsFromStructured(
|
||||
Map<String, Object> meta,
|
||||
Map<String, String> fields
|
||||
) {
|
||||
putFieldIfAbsent(fields, "Frequency", meta.get("frequency_hz"),
|
||||
v -> v + " Hz");
|
||||
putFieldIfAbsent(fields, "Power", meta.get("power_dbm"),
|
||||
v -> v + " dBm");
|
||||
putFieldIfAbsent(fields, "RSSI", meta.get("rssi_dbm"),
|
||||
v -> String.valueOf(v));
|
||||
putFieldIfAbsent(fields, "SNR", meta.get("snr_db"),
|
||||
v -> String.valueOf(v));
|
||||
putFieldIfAbsent(fields, "Spreading Factor", meta.get("spreading_factor"),
|
||||
String::valueOf);
|
||||
putFieldIfAbsent(fields, "Bandwidth", meta.get("bandwidth_khz"),
|
||||
v -> v + " kHz");
|
||||
Object packet = meta.get("packet");
|
||||
if (packet != null) {
|
||||
putFieldIfAbsent(fields, "Packet", packet, String::valueOf);
|
||||
putFieldIfAbsent(fields, "Packet Number", packet, String::valueOf);
|
||||
}
|
||||
putFieldIfAbsent(fields, "Payload", meta.get("payload"),
|
||||
v -> (String) v);
|
||||
putFieldIfAbsent(fields, "On Air", meta.get("on_air_ms"),
|
||||
v -> v + " ms");
|
||||
putFieldIfAbsent(fields, "TX Speed", meta.get("tx_pkt_per_s"),
|
||||
v -> v + " pkt/s");
|
||||
putFieldIfAbsent(fields, "RX Speed", meta.get("rx_pkt_per_s"),
|
||||
v -> v + " pkt/s");
|
||||
putFieldIfAbsent(fields, "PER", meta.get("per_percent"),
|
||||
v -> v + " %");
|
||||
}
|
||||
|
||||
private static void putFieldIfAbsent(
|
||||
Map<String, String> fields,
|
||||
String label,
|
||||
Object value,
|
||||
java.util.function.Function<Object, String> format
|
||||
) {
|
||||
if (value == null || fields.containsKey(label)) {
|
||||
return;
|
||||
}
|
||||
fields.put(label, format.apply(value));
|
||||
}
|
||||
|
||||
private static ExtractedStats empty(String frame) {
|
||||
return new ExtractedStats(null, null, frame, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
private static String detectFrameType(String frame) {
|
||||
boolean send = FRAME_SEND.matcher(frame).find();
|
||||
boolean recv = FRAME_RECEIVE.matcher(frame).find();
|
||||
if (send && !recv) {
|
||||
return "SEND";
|
||||
}
|
||||
if (recv && !send) {
|
||||
return "RECEIVE";
|
||||
}
|
||||
if (send) {
|
||||
return "SEND";
|
||||
}
|
||||
if (recv) {
|
||||
return "RECEIVE";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String frameTypeToRole(String frameType) {
|
||||
if ("SEND".equals(frameType)) {
|
||||
return ROLE_TX;
|
||||
}
|
||||
if ("RECEIVE".equals(frameType)) {
|
||||
return ROLE_RX;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Double firstDouble(Pattern pattern, String text) {
|
||||
return matchDouble(pattern, text);
|
||||
}
|
||||
|
||||
private static void putInt(Map<String, Object> meta, String key, Integer value) {
|
||||
if (value != null) {
|
||||
meta.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void putLong(Map<String, Object> meta, String key, Long value) {
|
||||
if (value != null) {
|
||||
meta.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void putDouble(Map<String, Object> meta, String key, Double value) {
|
||||
if (value != null) {
|
||||
meta.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void putString(Map<String, Object> meta, String key, String value) {
|
||||
if (value != null && !value.isEmpty()) {
|
||||
meta.put(key, value.trim());
|
||||
}
|
||||
}
|
||||
|
||||
private static Double matchDouble(Pattern pattern, String text) {
|
||||
Matcher m = pattern.matcher(text);
|
||||
if (m.find()) {
|
||||
try {
|
||||
return Double.parseDouble(m.group(1).trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Integer matchInt(Pattern pattern, String text) {
|
||||
Long v = matchLong(pattern, text);
|
||||
return v != null ? v.intValue() : null;
|
||||
}
|
||||
|
||||
private static Long matchLong(Pattern pattern, String text) {
|
||||
Matcher m = pattern.matcher(text);
|
||||
if (m.find()) {
|
||||
try {
|
||||
return Long.parseLong(m.group(1).trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String matchString(Pattern pattern, String text) {
|
||||
Matcher m = pattern.matcher(text);
|
||||
if (m.find()) {
|
||||
return m.group(1).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static final class ExtractedStats {
|
||||
public final Double rssi;
|
||||
public final Double rangeM;
|
||||
public final String rawFrame;
|
||||
public final String metaJson;
|
||||
public final String frameType;
|
||||
public final String role;
|
||||
public final Double txPowerDbm;
|
||||
public final Double rssiDbm;
|
||||
public final Double snrDb;
|
||||
|
||||
public boolean hasRadioFrame() {
|
||||
return frameType != null;
|
||||
}
|
||||
|
||||
public ExtractedStats(
|
||||
Double rssi,
|
||||
Double rangeM,
|
||||
String rawFrame,
|
||||
String metaJson,
|
||||
String frameType,
|
||||
String role,
|
||||
Double txPowerDbm,
|
||||
Double rssiDbm,
|
||||
Double snrDb
|
||||
) {
|
||||
this.rssi = rssi;
|
||||
this.rangeM = rangeM;
|
||||
this.rawFrame = rawFrame;
|
||||
this.metaJson = metaJson;
|
||||
this.frameType = frameType;
|
||||
this.role = role;
|
||||
this.txPowerDbm = txPowerDbm;
|
||||
this.rssiDbm = rssiDbm;
|
||||
this.snrDb = snrDb;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.grigowashere.loratester.telnet;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public class TelnetClient {
|
||||
|
||||
private static final String TAG = "TelnetClient";
|
||||
|
||||
public interface Listener {
|
||||
void onConnected();
|
||||
|
||||
void onDisconnected();
|
||||
|
||||
void onBytes(byte[] data, int length);
|
||||
|
||||
void onError(String message);
|
||||
}
|
||||
|
||||
public enum SendResult {
|
||||
SENT,
|
||||
NOT_CONNECTED,
|
||||
EMPTY,
|
||||
IO_ERROR
|
||||
}
|
||||
|
||||
private final String host;
|
||||
private final int port;
|
||||
private final Listener listener;
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
private final AtomicReference<Socket> activeSocket = new AtomicReference<>();
|
||||
private final Object sendLock = new Object();
|
||||
private Thread worker;
|
||||
|
||||
public TelnetClient(String host, int port, Listener listener) {
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (running.getAndSet(true)) {
|
||||
return;
|
||||
}
|
||||
worker = new Thread(this::runLoop, "TelnetClient");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
running.set(false);
|
||||
closeActiveSocket();
|
||||
if (worker != null) {
|
||||
worker.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isRunning() {
|
||||
return running.get();
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
Socket s = activeSocket.get();
|
||||
return s != null && s.isConnected() && !s.isClosed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an AT command. Adds AT prefix and CR+LF if missing.
|
||||
*/
|
||||
public SendResult sendAtCommand(String command) {
|
||||
byte[] wire = AtCommandFormatter.toWireBytes(command);
|
||||
if (wire.length == 0) {
|
||||
return SendResult.EMPTY;
|
||||
}
|
||||
Socket socket = activeSocket.get();
|
||||
if (socket == null || socket.isClosed()) {
|
||||
return SendResult.NOT_CONNECTED;
|
||||
}
|
||||
synchronized (sendLock) {
|
||||
try {
|
||||
OutputStream out = socket.getOutputStream();
|
||||
out.write(wire);
|
||||
out.flush();
|
||||
return SendResult.SENT;
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "send failed", e);
|
||||
return SendResult.IO_ERROR;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void runLoop() {
|
||||
int backoffMs = 1000;
|
||||
while (running.get()) {
|
||||
Socket socket = null;
|
||||
try {
|
||||
socket = new Socket();
|
||||
socket.connect(new InetSocketAddress(host, port), 5000);
|
||||
socket.setTcpNoDelay(true);
|
||||
activeSocket.set(socket);
|
||||
listener.onConnected();
|
||||
backoffMs = 1000;
|
||||
readStream(socket);
|
||||
} catch (IOException e) {
|
||||
if (running.get()) {
|
||||
listener.onError(e.getMessage());
|
||||
}
|
||||
} finally {
|
||||
activeSocket.compareAndSet(socket, null);
|
||||
listener.onDisconnected();
|
||||
if (socket != null) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!running.get()) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(backoffMs);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
backoffMs = Math.min(backoffMs * 2, 15000);
|
||||
}
|
||||
}
|
||||
|
||||
private void closeActiveSocket() {
|
||||
Socket s = activeSocket.getAndSet(null);
|
||||
if (s != null) {
|
||||
try {
|
||||
s.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void readStream(Socket socket) throws IOException {
|
||||
InputStream in = socket.getInputStream();
|
||||
byte[] buf = new byte[4096];
|
||||
while (running.get()) {
|
||||
int n = in.read(buf);
|
||||
if (n < 0) {
|
||||
break;
|
||||
}
|
||||
if (n > 0) {
|
||||
byte[] chunk = new byte[n];
|
||||
System.arraycopy(buf, 0, chunk, 0, n);
|
||||
listener.onBytes(chunk, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.grigowashere.loratester.telnet;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Splits telnet stream into LoRa screen frames by ESC clear and/or SEND/RECEIVE headers.
|
||||
*/
|
||||
public class TelnetFrameParser {
|
||||
|
||||
public interface FrameListener {
|
||||
void onFrame(String text);
|
||||
}
|
||||
|
||||
private static final Charset CHARSET = StandardCharsets.UTF_8;
|
||||
/** Start of a new radio screen (TX or RX). */
|
||||
private static final Pattern FRAME_HEADER =
|
||||
Pattern.compile("(?m)^\\s*(SEND|RECEIVE)\\b");
|
||||
|
||||
private final List<byte[]> delimiters;
|
||||
private final FrameListener listener;
|
||||
private final byte[] buffer = new byte[65536];
|
||||
private int bufferLen;
|
||||
|
||||
public TelnetFrameParser(FrameListener listener) {
|
||||
this(listener, defaultDelimiters());
|
||||
}
|
||||
|
||||
public TelnetFrameParser(FrameListener listener, List<byte[]> delimiters) {
|
||||
this.listener = listener;
|
||||
this.delimiters = new ArrayList<>(delimiters);
|
||||
}
|
||||
|
||||
public static List<byte[]> defaultDelimiters() {
|
||||
return Arrays.asList(
|
||||
new byte[] {0x1b, '[', '2', 'J'},
|
||||
new byte[] {0x1b, '[', 'H'},
|
||||
new byte[] {0x0c}
|
||||
);
|
||||
}
|
||||
|
||||
public void append(byte[] chunk, int offset, int length) {
|
||||
if (length <= 0) {
|
||||
return;
|
||||
}
|
||||
int splitAt = findEarliestDelimiter(chunk, offset, length);
|
||||
if (splitAt < 0) {
|
||||
appendToBuffer(chunk, offset, length);
|
||||
tryEmitByHeaders();
|
||||
return;
|
||||
}
|
||||
appendToBuffer(chunk, offset, splitAt - offset);
|
||||
emitFrame();
|
||||
clearBuffer();
|
||||
int delimLen = delimiterLengthAt(chunk, splitAt, offset, length);
|
||||
int tailOffset = splitAt + delimLen;
|
||||
int tailLen = offset + length - tailOffset;
|
||||
append(chunk, tailOffset, tailLen);
|
||||
tryEmitByHeaders();
|
||||
}
|
||||
|
||||
public void append(byte[] chunk) {
|
||||
append(chunk, 0, chunk.length);
|
||||
}
|
||||
|
||||
public void flush() {
|
||||
tryEmitByHeaders();
|
||||
if (bufferLen > 0) {
|
||||
emitFrame();
|
||||
clearBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit latest SEND/RECEIVE screen from buffer (in-place refresh without ESC).
|
||||
* Does not clear the buffer.
|
||||
*/
|
||||
public void emitSnapshot() {
|
||||
if (bufferLen == 0) {
|
||||
return;
|
||||
}
|
||||
String text = TelnetText.normalize(bufferText());
|
||||
Matcher m = FRAME_HEADER.matcher(text);
|
||||
int lastStart = -1;
|
||||
while (m.find()) {
|
||||
lastStart = m.start();
|
||||
}
|
||||
if (lastStart < 0) {
|
||||
return;
|
||||
}
|
||||
String frame = text.substring(lastStart).trim();
|
||||
if (!frame.isEmpty()) {
|
||||
listener.onFrame(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/** Emit all complete frames when buffer holds multiple SEND/RECEIVE blocks. */
|
||||
private void tryEmitByHeaders() {
|
||||
if (bufferLen == 0) {
|
||||
return;
|
||||
}
|
||||
String text = bufferText();
|
||||
Matcher m = FRAME_HEADER.matcher(text);
|
||||
List<Integer> starts = new ArrayList<>();
|
||||
while (m.find()) {
|
||||
starts.add(m.start());
|
||||
}
|
||||
if (starts.size() < 2) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < starts.size() - 1; i++) {
|
||||
String frame = text.substring(starts.get(i), starts.get(i + 1)).trim();
|
||||
if (!frame.isEmpty()) {
|
||||
listener.onFrame(frame);
|
||||
}
|
||||
}
|
||||
String tail = text.substring(starts.get(starts.size() - 1));
|
||||
clearBuffer();
|
||||
byte[] tailBytes = tail.getBytes(CHARSET);
|
||||
appendToBuffer(tailBytes, 0, tailBytes.length);
|
||||
}
|
||||
|
||||
private String bufferText() {
|
||||
return new String(buffer, 0, bufferLen, CHARSET);
|
||||
}
|
||||
|
||||
private void emitFrame() {
|
||||
if (bufferLen == 0) {
|
||||
return;
|
||||
}
|
||||
String text = bufferText().trim();
|
||||
if (!text.isEmpty()) {
|
||||
listener.onFrame(text);
|
||||
}
|
||||
}
|
||||
|
||||
private void clearBuffer() {
|
||||
bufferLen = 0;
|
||||
}
|
||||
|
||||
private void appendToBuffer(byte[] src, int offset, int length) {
|
||||
if (length <= 0) {
|
||||
return;
|
||||
}
|
||||
if (bufferLen + length > buffer.length) {
|
||||
tryEmitByHeaders();
|
||||
if (bufferLen + length > buffer.length) {
|
||||
emitFrame();
|
||||
clearBuffer();
|
||||
}
|
||||
}
|
||||
System.arraycopy(src, offset, buffer, bufferLen, length);
|
||||
bufferLen += length;
|
||||
}
|
||||
|
||||
private int findEarliestDelimiter(byte[] chunk, int offset, int length) {
|
||||
int best = -1;
|
||||
for (byte[] delim : delimiters) {
|
||||
int idx = indexOf(chunk, offset, length, delim);
|
||||
if (idx >= 0 && (best < 0 || idx < best)) {
|
||||
best = idx;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private int delimiterLengthAt(byte[] chunk, int pos, int offset, int length) {
|
||||
int end = offset + length;
|
||||
for (byte[] delim : delimiters) {
|
||||
if (pos + delim.length <= end && matchesAt(chunk, pos, delim)) {
|
||||
return delim.length;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int indexOf(byte[] haystack, int offset, int length, byte[] needle) {
|
||||
int end = offset + length - needle.length;
|
||||
for (int i = offset; i <= end; i++) {
|
||||
if (matchesAt(haystack, i, needle)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static boolean matchesAt(byte[] haystack, int pos, byte[] needle) {
|
||||
for (int i = 0; i < needle.length; i++) {
|
||||
if (haystack[pos + i] != needle[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.grigowashere.loratester.telnet;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Cleans telnet screen text before field extraction. */
|
||||
public final class TelnetText {
|
||||
|
||||
private static final Pattern ANSI = Pattern.compile(
|
||||
"\u001b\\[[0-9;?]*[ -/]*[@-~]|\u001b\\].*?\u0007|\u001b[@-Z\\\\-_]"
|
||||
);
|
||||
|
||||
private TelnetText() {
|
||||
}
|
||||
|
||||
public static String normalize(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String s = ANSI.matcher(text).replaceAll("");
|
||||
s = s.replace('\r', '\n');
|
||||
s = s.replace("\u000c", "\n");
|
||||
return s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.grigowashere.loratester.track;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import com.grigowashere.loratester.TelemetryUploader;
|
||||
import com.grigowashere.loratester.api.ServerApi;
|
||||
import com.grigowashere.loratester.location.GeoUtils;
|
||||
import com.grigowashere.loratester.net.NetworkMonitor;
|
||||
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class TrackRecorder {
|
||||
|
||||
private static final String TAG = "TrackRecorder";
|
||||
private static final long SAMPLE_MS = 1000;
|
||||
private static final long FLUSH_MS = 30_000;
|
||||
|
||||
public interface Listener {
|
||||
void onStateChanged(boolean recording, int pointCount, long trackId);
|
||||
void onError(String message);
|
||||
}
|
||||
|
||||
private final ServerApi serverApi;
|
||||
private final TelemetryUploader uploader;
|
||||
private final NetworkMonitor networkMonitor;
|
||||
private final String deviceId;
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private final ScheduledExecutorService scheduler =
|
||||
Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "TrackSampler");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
private volatile double lat = Double.NaN;
|
||||
private volatile double lon = Double.NaN;
|
||||
private volatile double altitude = Double.NaN;
|
||||
private volatile long trackId = -1;
|
||||
private volatile boolean recording;
|
||||
private final List<Map<String, Object>> buffer = new ArrayList<>();
|
||||
private int totalPoints;
|
||||
private ScheduledFuture<?> sampleTask;
|
||||
private ScheduledFuture<?> flushTask;
|
||||
private Listener listener;
|
||||
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();
|
||||
}
|
||||
|
||||
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,260 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import com.google.android.material.button.MaterialButtonToggleGroup;
|
||||
import com.google.android.material.chip.Chip;
|
||||
import com.google.android.material.chip.ChipGroup;
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
import com.grigowashere.loratester.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.telnet.AtCommands;
|
||||
import com.grigowashere.loratester.telnet.TelnetClient;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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 atConsole;
|
||||
private ScrollView atConsoleScroll;
|
||||
private TextInputEditText atCommandInput;
|
||||
private MaterialButtonToggleGroup atTargetGroup;
|
||||
private String lastConsole = "";
|
||||
|
||||
@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);
|
||||
atConsole = view.findViewById(R.id.atConsole);
|
||||
atConsoleScroll = view.findViewById(R.id.atConsoleScroll);
|
||||
atCommandInput = view.findViewById(R.id.atCommandInput);
|
||||
atTargetGroup = view.findViewById(R.id.atTargetGroup);
|
||||
ChipGroup chips = view.findViewById(R.id.atQuickChips);
|
||||
Button sendBtn = view.findViewById(R.id.atSendBtn);
|
||||
Button clearLog = view.findViewById(R.id.atClearLog);
|
||||
pollHelper = new FragmentPollHelper(this, this::refreshConsole);
|
||||
|
||||
if (atTargetGroup != null) {
|
||||
atTargetGroup.check(R.id.atTargetLocal);
|
||||
}
|
||||
|
||||
addQuickChip(chips, "AT+H", AtCommands.HELP);
|
||||
addQuickChip(chips, "AT+TX", AtCommands.TRANSMIT);
|
||||
addQuickChip(chips, "AT+RX", AtCommands.RECEIVE);
|
||||
addQuickChip(chips, "AT+STATUS", AtCommands.STATUS);
|
||||
addQuickChip(chips, "AT", AtCommands.BASIC);
|
||||
|
||||
sendBtn.setOnClickListener(v -> sendFromInput());
|
||||
clearLog.setOnClickListener(v -> {
|
||||
if (uploader != null) {
|
||||
uploader.clearConsoleLog();
|
||||
}
|
||||
lastConsole = "";
|
||||
if (atConsole != null) {
|
||||
atConsole.setText("");
|
||||
}
|
||||
});
|
||||
|
||||
if (atCommandInput != null) {
|
||||
atCommandInput.setOnEditorActionListener((textView, actionId, event) -> {
|
||||
sendFromInput();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPeerTarget() {
|
||||
return atTargetGroup != null && atTargetGroup.getCheckedButtonId() == R.id.atTargetPeer;
|
||||
}
|
||||
|
||||
private void addQuickChip(ChipGroup group, String label, String command) {
|
||||
Chip chip = new Chip(requireContext());
|
||||
chip.setText(label);
|
||||
chip.setCheckable(false);
|
||||
chip.setOnClickListener(v -> sendCommand(command));
|
||||
group.addView(chip);
|
||||
}
|
||||
|
||||
private void sendFromInput() {
|
||||
if (atCommandInput == null || atCommandInput.getText() == null) {
|
||||
return;
|
||||
}
|
||||
String cmd = atCommandInput.getText().toString().trim();
|
||||
if (cmd.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
sendCommand(cmd);
|
||||
atCommandInput.setText("");
|
||||
}
|
||||
|
||||
private void sendCommand(String command) {
|
||||
if (uploader == null || !isAdded()) {
|
||||
return;
|
||||
}
|
||||
if (isPeerTarget()) {
|
||||
sendToPeer(command);
|
||||
return;
|
||||
}
|
||||
uploader.sendAtCommand(command, result -> {
|
||||
if (!isAdded()) {
|
||||
return;
|
||||
}
|
||||
Context ctx = getContext();
|
||||
if (ctx == null) {
|
||||
return;
|
||||
}
|
||||
if (result == TelnetClient.SendResult.NOT_CONNECTED) {
|
||||
Toast.makeText(ctx, R.string.at_not_connected, Toast.LENGTH_SHORT).show();
|
||||
} else if (result == TelnetClient.SendResult.IO_ERROR) {
|
||||
Toast.makeText(ctx, R.string.at_send_error, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
updateConsoleView();
|
||||
});
|
||||
}
|
||||
|
||||
private void sendToPeer(String command) {
|
||||
if (commandPoller == null) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("line", command);
|
||||
commandPoller.postCommandToPeer(peer.peerId, "at", payload);
|
||||
showToast(getString(R.string.at_sent_to_peer, peer.peerId));
|
||||
} catch (Exception e) {
|
||||
showToast(R.string.stats_push_failed);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showToast(int resId) {
|
||||
if (isAdded()) {
|
||||
requireActivity().runOnUiThread(() ->
|
||||
Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show());
|
||||
}
|
||||
}
|
||||
|
||||
private void showToast(String msg) {
|
||||
if (isAdded()) {
|
||||
requireActivity().runOnUiThread(() ->
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show());
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshConsole() {
|
||||
if (!isAdded() || uploader == null || atStatus == null) {
|
||||
return;
|
||||
}
|
||||
boolean telnetOn = uploader.isTelnetConnected();
|
||||
atStatus.setText(getString(
|
||||
R.string.at_status,
|
||||
telnetOn ? getString(R.string.connected) : getString(R.string.disconnected)
|
||||
));
|
||||
updateConsoleView();
|
||||
if (pollHelper != null) {
|
||||
pollHelper.scheduleNext(400);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateConsoleView() {
|
||||
if (uploader == null || atConsole == null || atConsoleScroll == null) {
|
||||
return;
|
||||
}
|
||||
String log = uploader.getConsoleLog();
|
||||
if (!log.equals(lastConsole)) {
|
||||
lastConsole = log;
|
||||
atConsole.setText(log);
|
||||
atConsoleScroll.post(() -> {
|
||||
if (atConsoleScroll != null) {
|
||||
atConsoleScroll.fullScroll(View.FOCUS_DOWN);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
if (pollHelper != null) {
|
||||
pollHelper.start(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
if (pollHelper != null) {
|
||||
pollHelper.stop();
|
||||
}
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
if (pollHelper != null) {
|
||||
pollHelper.stop();
|
||||
}
|
||||
atStatus = null;
|
||||
atConsole = null;
|
||||
atConsoleScroll = null;
|
||||
atCommandInput = null;
|
||||
atTargetGroup = null;
|
||||
pollHelper = null;
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
executor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.grigowashere.loratester.R;
|
||||
import com.grigowashere.loratester.api.ChatMessage;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
|
||||
|
||||
private final List<ChatMessage> messages = new ArrayList<>();
|
||||
private final DateFormat timeFormat =
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault());
|
||||
|
||||
public void setMessages(List<ChatMessage> newMessages) {
|
||||
messages.clear();
|
||||
messages.addAll(newMessages);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void appendMessages(List<ChatMessage> more) {
|
||||
if (more.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int start = messages.size();
|
||||
messages.addAll(more);
|
||||
notifyItemRangeInserted(start, more.size());
|
||||
}
|
||||
|
||||
public double lastTs() {
|
||||
if (messages.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
return messages.get(messages.size() - 1).ts;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View v = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_chat, parent, false);
|
||||
return new Holder(v);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull Holder holder, int position) {
|
||||
ChatMessage m = messages.get(position);
|
||||
String time = timeFormat.format(new Date((long) (m.ts * 1000)));
|
||||
holder.text.setText(time + " " + m.device_id + ": " + m.text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return messages.size();
|
||||
}
|
||||
|
||||
static class Holder extends RecyclerView.ViewHolder {
|
||||
final TextView text;
|
||||
|
||||
Holder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
text = itemView.findViewById(R.id.chatItemText);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.VibrationEffect;
|
||||
import android.os.Vibrator;
|
||||
import android.os.VibratorManager;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.grigowashere.loratester.LoraApp;
|
||||
import com.grigowashere.loratester.R;
|
||||
import com.grigowashere.loratester.TelemetryUploader;
|
||||
import com.grigowashere.loratester.api.ChatMessage;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class ChatFragment extends Fragment {
|
||||
|
||||
private static final int VIBRATE_MS = 40;
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private FragmentPollHelper pollHelper;
|
||||
private TelemetryUploader uploader;
|
||||
private ChatAdapter adapter;
|
||||
private RecyclerView recycler;
|
||||
private double chatSince;
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull Context context) {
|
||||
super.onAttach(context);
|
||||
uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(
|
||||
@NonNull LayoutInflater inflater,
|
||||
@Nullable ViewGroup container,
|
||||
@Nullable Bundle savedInstanceState
|
||||
) {
|
||||
return inflater.inflate(R.layout.fragment_chat, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
recycler = view.findViewById(R.id.chatRecycler);
|
||||
EditText input = view.findViewById(R.id.chatInput);
|
||||
Button send = view.findViewById(R.id.chatSend);
|
||||
View inputBar = view.findViewById(R.id.chatInputBar);
|
||||
|
||||
adapter = new ChatAdapter();
|
||||
LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
|
||||
recycler.setLayoutManager(layoutManager);
|
||||
recycler.setAdapter(adapter);
|
||||
pollHelper = new FragmentPollHelper(this, this::pollChat);
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(inputBar, (v, windowInsets) -> {
|
||||
Insets ime = windowInsets.getInsets(WindowInsetsCompat.Type.ime());
|
||||
v.setPadding(v.getPaddingLeft(), v.getPaddingTop(), v.getPaddingRight(), ime.bottom);
|
||||
if (ime.bottom > 0 && adapter.getItemCount() > 0) {
|
||||
scrollToLatest();
|
||||
}
|
||||
return windowInsets;
|
||||
});
|
||||
|
||||
Runnable doSend = () -> sendMessage(input);
|
||||
send.setOnClickListener(v -> doSend.run());
|
||||
input.setOnEditorActionListener((v, actionId, event) -> {
|
||||
if (actionId == EditorInfo.IME_ACTION_SEND) {
|
||||
doSend.run();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
input.setOnFocusChangeListener((v, hasFocus) -> {
|
||||
if (hasFocus) {
|
||||
scrollToLatest();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void sendMessage(EditText input) {
|
||||
String text = input.getText().toString().trim();
|
||||
if (text.isEmpty() || uploader == null) {
|
||||
return;
|
||||
}
|
||||
input.setText("");
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
uploader.getServerApi().postChat(uploader.getDeviceId(), text);
|
||||
} catch (Exception ignored) {
|
||||
// ignore
|
||||
}
|
||||
if (pollHelper != null && pollHelper.canRun()) {
|
||||
pollChat();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void scrollToLatest() {
|
||||
if (recycler == null || adapter.getItemCount() == 0) {
|
||||
return;
|
||||
}
|
||||
int last = adapter.getItemCount() - 1;
|
||||
recycler.post(() -> recycler.smoothScrollToPosition(last));
|
||||
}
|
||||
|
||||
private void vibrateOnNewMessage() {
|
||||
Context ctx = getContext();
|
||||
if (ctx == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
VibratorManager vm = (VibratorManager) ctx.getSystemService(Context.VIBRATOR_MANAGER_SERVICE);
|
||||
if (vm != null) {
|
||||
Vibrator v = vm.getDefaultVibrator();
|
||||
if (v != null && v.hasVibrator()) {
|
||||
v.vibrate(VibrationEffect.createOneShot(
|
||||
VIBRATE_MS,
|
||||
VibrationEffect.DEFAULT_AMPLITUDE
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vibrator v = (Vibrator) ctx.getSystemService(Context.VIBRATOR_SERVICE);
|
||||
if (v != null && v.hasVibrator()) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
v.vibrate(VibrationEffect.createOneShot(
|
||||
VIBRATE_MS,
|
||||
VibrationEffect.DEFAULT_AMPLITUDE
|
||||
));
|
||||
} else {
|
||||
v.vibrate(VIBRATE_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// ignore missing vibrator
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
if (pollHelper != null) {
|
||||
pollHelper.start(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
if (pollHelper != null) {
|
||||
pollHelper.stop();
|
||||
}
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
if (pollHelper != null) {
|
||||
pollHelper.stop();
|
||||
}
|
||||
recycler = null;
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
executor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void pollChat() {
|
||||
if (!pollHelper.canRun() || uploader == null) {
|
||||
return;
|
||||
}
|
||||
String myId = uploader.getDeviceId();
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
List<ChatMessage> msgs = uploader.getServerApi().getChat(chatSince);
|
||||
for (ChatMessage m : msgs) {
|
||||
chatSince = Math.max(chatSince, m.ts);
|
||||
}
|
||||
if (!pollHelper.canRun() || msgs.isEmpty()) {
|
||||
if (pollHelper.canRun()) {
|
||||
pollHelper.scheduleNext(2500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
boolean vibrate = false;
|
||||
for (ChatMessage m : msgs) {
|
||||
if (m.device_id != null && !m.device_id.equals(myId)) {
|
||||
vibrate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
boolean shouldVibrate = vibrate;
|
||||
requireActivity().runOnUiThread(() -> {
|
||||
if (!pollHelper.canRun()) {
|
||||
return;
|
||||
}
|
||||
if (adapter.getItemCount() == 0) {
|
||||
adapter.setMessages(msgs);
|
||||
} else {
|
||||
adapter.appendMessages(msgs);
|
||||
}
|
||||
scrollToLatest();
|
||||
if (shouldVibrate) {
|
||||
vibrateOnNewMessage();
|
||||
}
|
||||
});
|
||||
} catch (Exception ignored) {
|
||||
// ignore
|
||||
}
|
||||
pollHelper.scheduleNext(2500);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
|
||||
/**
|
||||
* Safe periodic polling: no callbacks after onPause / when fragment is detached.
|
||||
*/
|
||||
public final class FragmentPollHelper {
|
||||
|
||||
private final Fragment fragment;
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable task;
|
||||
private boolean active;
|
||||
|
||||
public FragmentPollHelper(@NonNull Fragment fragment, @NonNull Runnable task) {
|
||||
this.fragment = fragment;
|
||||
this.task = task;
|
||||
}
|
||||
|
||||
public void start(long initialDelayMs) {
|
||||
active = true;
|
||||
handler.removeCallbacks(task);
|
||||
handler.postDelayed(task, initialDelayMs);
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
active = false;
|
||||
handler.removeCallbacks(task);
|
||||
}
|
||||
|
||||
public void scheduleNext(long delayMs) {
|
||||
if (!active || !fragment.isAdded()) {
|
||||
return;
|
||||
}
|
||||
handler.removeCallbacks(task);
|
||||
handler.postDelayed(task, delayMs);
|
||||
}
|
||||
|
||||
public boolean canRun() {
|
||||
return active
|
||||
&& fragment.isAdded()
|
||||
&& fragment.getView() != null
|
||||
&& fragment.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.grigowashere.loratester.R;
|
||||
import com.grigowashere.loratester.api.TelemetryHistoryItem;
|
||||
import com.grigowashere.loratester.telnet.LoraStatsFormatter;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
public class HistoryAdapter extends RecyclerView.Adapter<HistoryAdapter.Holder> {
|
||||
|
||||
private final DateFormat timeFormat =
|
||||
DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.getDefault());
|
||||
private final List<String> lines = new ArrayList<>();
|
||||
|
||||
public void setItems(List<TelemetryHistoryItem> items) {
|
||||
lines.clear();
|
||||
if (items == null) {
|
||||
notifyDataSetChanged();
|
||||
return;
|
||||
}
|
||||
for (TelemetryHistoryItem item : items) {
|
||||
String time = timeFormat.format(new Date((long) (item.ts * 1000)));
|
||||
String role = item.role != null ? item.role : "—";
|
||||
String rssi = item.rssi != null ? item.rssi + " dBm" : "—";
|
||||
String summary = LoraStatsFormatter.formatMeta(item.meta);
|
||||
String shortMeta = summary.length() > 80
|
||||
? summary.substring(0, 80) + "…"
|
||||
: summary;
|
||||
lines.add(time + " · " + role + " · " + rssi
|
||||
+ (shortMeta.isEmpty() ? "" : "\n" + shortMeta));
|
||||
}
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View v = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_history, parent, false);
|
||||
return new Holder(v);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull Holder holder, int position) {
|
||||
holder.line.setText(lines.get(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return lines.size();
|
||||
}
|
||||
|
||||
static class Holder extends RecyclerView.ViewHolder {
|
||||
final TextView line;
|
||||
|
||||
Holder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
line = itemView.findViewById(R.id.historyLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter;
|
||||
|
||||
public class MainPagerAdapter extends FragmentStateAdapter {
|
||||
|
||||
public MainPagerAdapter(@NonNull FragmentActivity activity) {
|
||||
super(activity);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Fragment createFragment(int position) {
|
||||
return switch (position) {
|
||||
case 0 -> new MapFragment();
|
||||
case 1 -> new StatsFragment();
|
||||
case 2 -> new AtFragment();
|
||||
case 3 -> new ChatFragment();
|
||||
default -> new SettingsFragment();
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
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.api.ServerApi;
|
||||
import com.grigowashere.loratester.api.TrackDetail;
|
||||
import com.grigowashere.loratester.api.TrackInfo;
|
||||
import com.grigowashere.loratester.location.GeoUtils;
|
||||
import com.grigowashere.loratester.net.NetworkMonitor;
|
||||
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||
import com.grigowashere.loratester.track.TrackRecorder;
|
||||
|
||||
import org.mapsforge.core.graphics.Bitmap;
|
||||
import org.mapsforge.core.graphics.Color;
|
||||
import org.mapsforge.core.model.BoundingBox;
|
||||
import org.mapsforge.core.model.LatLong;
|
||||
import org.mapsforge.map.android.graphics.AndroidGraphicFactory;
|
||||
import org.mapsforge.map.android.util.AndroidUtil;
|
||||
import org.mapsforge.map.android.view.MapView;
|
||||
import org.mapsforge.map.layer.Layer;
|
||||
import org.mapsforge.map.layer.cache.TileCache;
|
||||
import org.mapsforge.map.layer.download.TileDownloadLayer;
|
||||
import org.mapsforge.map.layer.download.tilesource.OpenStreetMapMapnik;
|
||||
import org.mapsforge.map.layer.overlay.Marker;
|
||||
import org.mapsforge.map.layer.overlay.Polyline;
|
||||
import org.mapsforge.map.model.MapViewPosition;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class MapFragment extends Fragment {
|
||||
|
||||
private static final int TILE_SIZE_PX = 256;
|
||||
private static final long DEVICE_POLL_MS = 5000;
|
||||
/** Ignore GPS jitter smaller than ~11 m. */
|
||||
private static final double POSITION_EPS = 0.0001;
|
||||
private static final float USER_PAN_THRESHOLD_PX = 12f;
|
||||
|
||||
private static final int ARGB_TX = 0xFFE94560;
|
||||
private static final int ARGB_RX = 0xFF4FC3F7;
|
||||
private static final int ARGB_TRACK = 0xFF00FF88;
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private final DateFormat timeFormat =
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault());
|
||||
private final Map<String, Marker> deviceMarkers = new HashMap<>();
|
||||
private final List<Layer> trackLayers = new ArrayList<>();
|
||||
|
||||
private FragmentPollHelper pollHelper;
|
||||
private TelemetryUploader uploader;
|
||||
private TrackRecorder trackRecorder;
|
||||
private CommandPoller commandPoller;
|
||||
private MapView mapView;
|
||||
private TileDownloadLayer downloadLayer;
|
||||
private TileCache tileCache;
|
||||
private TextView mapStatus;
|
||||
private TextView trackStatus;
|
||||
private Button btnTrack;
|
||||
private Button btnPairedTrack;
|
||||
private Spinner trackSpinner;
|
||||
private List<TrackInfo> savedTracks = new ArrayList<>();
|
||||
private boolean mapResumed;
|
||||
private boolean mapInitialized;
|
||||
private NetworkMonitor networkMonitor;
|
||||
private NetworkMonitor.Listener networkListener;
|
||||
private boolean networkOnline = true;
|
||||
private boolean initialFitDone;
|
||||
private boolean userMovedMap;
|
||||
private boolean suppressTrackSpinner;
|
||||
private Bitmap bitmapTx;
|
||||
private Bitmap bitmapRx;
|
||||
private Bitmap bitmapTrackPoint;
|
||||
private float touchDownX;
|
||||
private float touchDownY;
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull Context context) {
|
||||
super.onAttach(context);
|
||||
LoraApp app = (LoraApp) context.getApplicationContext();
|
||||
uploader = app.getTelemetryUploader();
|
||||
trackRecorder = app.getTrackRecorder();
|
||||
commandPoller = app.getCommandPoller();
|
||||
networkMonitor = app.getNetworkMonitor();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(
|
||||
@NonNull LayoutInflater inflater,
|
||||
@Nullable ViewGroup container,
|
||||
@Nullable Bundle savedInstanceState
|
||||
) {
|
||||
return inflater.inflate(R.layout.fragment_map, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
mapView = view.findViewById(R.id.mapView);
|
||||
mapStatus = view.findViewById(R.id.mapStatus);
|
||||
trackStatus = view.findViewById(R.id.trackStatus);
|
||||
btnTrack = view.findViewById(R.id.btnTrack);
|
||||
btnPairedTrack = view.findViewById(R.id.btnPairedTrack);
|
||||
trackSpinner = view.findViewById(R.id.trackSpinner);
|
||||
pollHelper = new FragmentPollHelper(this, this::refreshDevices);
|
||||
|
||||
networkOnline = networkMonitor != null && networkMonitor.isOnline();
|
||||
networkListener = online -> {
|
||||
networkOnline = online;
|
||||
if (isAdded() && mapStatus != null) {
|
||||
requireActivity().runOnUiThread(this::updateNetworkStatusLine);
|
||||
}
|
||||
if (online && downloadLayer != null && mapResumed) {
|
||||
downloadLayer.onResume();
|
||||
}
|
||||
};
|
||||
if (networkMonitor != null) {
|
||||
networkMonitor.addListener(networkListener);
|
||||
}
|
||||
|
||||
setupMapView();
|
||||
btnTrack.setOnClickListener(v -> toggleTracking());
|
||||
if (btnPairedTrack != null) {
|
||||
btnPairedTrack.setOnClickListener(v -> startPairedTracking());
|
||||
}
|
||||
setupTrackRecorderListener();
|
||||
setupTrackSpinnerListener();
|
||||
}
|
||||
|
||||
private void setupTrackSpinnerListener() {
|
||||
trackSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||
@Override
|
||||
public void onItemSelected(AdapterView<?> parent, View v, int pos, long id) {
|
||||
if (suppressTrackSpinner || pos <= 0 || pos - 1 >= savedTracks.size()) {
|
||||
return;
|
||||
}
|
||||
showTrack(savedTracks.get(pos - 1).id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNothingSelected(AdapterView<?> parent) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setupMapView() {
|
||||
mapView.setClickable(true);
|
||||
mapView.getMapScaleBar().setVisible(true);
|
||||
mapView.setBuiltInZoomControls(false);
|
||||
|
||||
mapView.setOnTouchListener((v, event) -> {
|
||||
int action = event.getActionMasked();
|
||||
if (action == MotionEvent.ACTION_DOWN) {
|
||||
touchDownX = event.getX();
|
||||
touchDownY = event.getY();
|
||||
v.getParent().requestDisallowInterceptTouchEvent(true);
|
||||
} else if (action == MotionEvent.ACTION_MOVE) {
|
||||
v.getParent().requestDisallowInterceptTouchEvent(true);
|
||||
float dx = event.getX() - touchDownX;
|
||||
float dy = event.getY() - touchDownY;
|
||||
if (dx * dx + dy * dy > USER_PAN_THRESHOLD_PX * USER_PAN_THRESHOLD_PX) {
|
||||
userMovedMap = true;
|
||||
}
|
||||
} else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
|
||||
v.getParent().requestDisallowInterceptTouchEvent(false);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
mapView.getModel().displayModel.setFixedTileSize(TILE_SIZE_PX);
|
||||
tileCache = AndroidUtil.createTileCache(
|
||||
requireContext(),
|
||||
"loratester-tiles",
|
||||
TILE_SIZE_PX,
|
||||
1f,
|
||||
mapView.getModel().frameBufferModel.getOverdrawFactor()
|
||||
);
|
||||
|
||||
OpenStreetMapMapnik tileSource = OpenStreetMapMapnik.INSTANCE;
|
||||
tileSource.setUserAgent("LoraTester/1.0");
|
||||
|
||||
downloadLayer = new TileDownloadLayer(
|
||||
tileCache,
|
||||
mapView.getModel().mapViewPosition,
|
||||
tileSource,
|
||||
AndroidGraphicFactory.INSTANCE
|
||||
);
|
||||
mapView.getLayerManager().getLayers().add(downloadLayer);
|
||||
mapView.setZoomLevelMin(tileSource.getZoomLevelMin());
|
||||
mapView.setZoomLevelMax(tileSource.getZoomLevelMax());
|
||||
downloadLayer.start();
|
||||
|
||||
MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition;
|
||||
position.setCenter(new LatLong(55.75, 37.62));
|
||||
position.setZoomLevel((byte) 10);
|
||||
|
||||
bitmapTx = MapsforgeBitmaps.dot(ARGB_TX, 20);
|
||||
bitmapRx = MapsforgeBitmaps.dot(ARGB_RX, 20);
|
||||
bitmapTrackPoint = MapsforgeBitmaps.dot(ARGB_TRACK, 12);
|
||||
|
||||
mapInitialized = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
mapResumed = true;
|
||||
if (downloadLayer != null) {
|
||||
downloadLayer.onResume();
|
||||
}
|
||||
if (pollHelper != null) {
|
||||
pollHelper.start(0);
|
||||
}
|
||||
if (trackRecorder != null && btnTrack != null) {
|
||||
setupTrackRecorderListener();
|
||||
}
|
||||
loadTrackList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
mapResumed = false;
|
||||
if (pollHelper != null) {
|
||||
pollHelper.stop();
|
||||
}
|
||||
if (downloadLayer != null) {
|
||||
downloadLayer.onPause();
|
||||
}
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
mapResumed = false;
|
||||
mapInitialized = false;
|
||||
initialFitDone = false;
|
||||
if (pollHelper != null) {
|
||||
pollHelper.stop();
|
||||
}
|
||||
if (networkMonitor != null && networkListener != null) {
|
||||
networkMonitor.removeListener(networkListener);
|
||||
}
|
||||
networkListener = null;
|
||||
if (trackRecorder != null) {
|
||||
trackRecorder.setListener(null);
|
||||
}
|
||||
removeAllDeviceMarkers();
|
||||
clearTrackLayers();
|
||||
deviceMarkers.clear();
|
||||
if (downloadLayer != null) {
|
||||
downloadLayer.onDestroy();
|
||||
downloadLayer = null;
|
||||
}
|
||||
if (mapView != null) {
|
||||
mapView.destroy();
|
||||
}
|
||||
mapView = null;
|
||||
tileCache = null;
|
||||
bitmapTx = null;
|
||||
bitmapRx = null;
|
||||
bitmapTrackPoint = null;
|
||||
mapStatus = null;
|
||||
trackStatus = null;
|
||||
btnTrack = null;
|
||||
trackSpinner = null;
|
||||
pollHelper = null;
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
executor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void updateNetworkStatusLine() {
|
||||
if (mapStatus == null) {
|
||||
return;
|
||||
}
|
||||
CharSequence current = mapStatus.getText();
|
||||
String net = networkStatusSuffix();
|
||||
if (current != null && current.toString().contains(" · ")) {
|
||||
int idx = current.toString().lastIndexOf(" · ");
|
||||
mapStatus.setText(current.subSequence(0, idx) + " · " + net);
|
||||
}
|
||||
}
|
||||
|
||||
private String networkStatusSuffix() {
|
||||
return getString(networkOnline
|
||||
? R.string.map_network_online
|
||||
: R.string.map_network_offline);
|
||||
}
|
||||
|
||||
private void setupTrackRecorderListener() {
|
||||
trackRecorder.setListener(new TrackRecorder.Listener() {
|
||||
@Override
|
||||
public void onStateChanged(boolean recording, int pointCount, long trackId) {
|
||||
if (!isAdded() || btnTrack == null) {
|
||||
return;
|
||||
}
|
||||
btnTrack.setText(recording ? R.string.track_stop : R.string.track_start);
|
||||
if (trackStatus != null) {
|
||||
trackStatus.setText(getString(R.string.track_status, pointCount));
|
||||
}
|
||||
if (!recording && trackId > 0) {
|
||||
loadTrackList();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String message) {
|
||||
if (isAdded() && trackStatus != null) {
|
||||
trackStatus.setText(getString(R.string.track_error, message));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void toggleTracking() {
|
||||
if (trackRecorder.isRecording()) {
|
||||
trackRecorder.stop();
|
||||
} else {
|
||||
trackRecorder.start();
|
||||
}
|
||||
}
|
||||
|
||||
private void startPairedTracking() {
|
||||
if (commandPoller == null || uploader == null || !isAdded()) {
|
||||
return;
|
||||
}
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
|
||||
PeerDevices.Result peer = PeerDevices.resolve(devices, uploader.getDeviceId());
|
||||
if (!peer.bothOnline()) {
|
||||
requireActivity().runOnUiThread(() ->
|
||||
Toast.makeText(requireContext(), R.string.track_paired_need_two,
|
||||
Toast.LENGTH_SHORT).show());
|
||||
return;
|
||||
}
|
||||
commandPoller.startPairedTrack(
|
||||
() -> {
|
||||
if (isAdded()) {
|
||||
Toast.makeText(requireContext(), R.string.track_paired_started,
|
||||
Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
},
|
||||
msg -> {
|
||||
if (isAdded()) {
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
if (isAdded()) {
|
||||
requireActivity().runOnUiThread(() ->
|
||||
Toast.makeText(requireContext(), R.string.track_paired_need_two,
|
||||
Toast.LENGTH_SHORT).show());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void loadTrackList() {
|
||||
if (uploader == null || !mapResumed) {
|
||||
return;
|
||||
}
|
||||
String deviceId = uploader.getDeviceId();
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
List<TrackInfo> tracks = uploader.getServerApi().listTracks(deviceId);
|
||||
if (!isAdded() || !mapResumed) {
|
||||
return;
|
||||
}
|
||||
requireActivity().runOnUiThread(() -> {
|
||||
if (mapResumed) {
|
||||
updateTrackSpinner(tracks);
|
||||
}
|
||||
});
|
||||
} catch (Exception ignored) {
|
||||
// optional
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateTrackSpinner(List<TrackInfo> tracks) {
|
||||
if (trackSpinner == null) {
|
||||
return;
|
||||
}
|
||||
savedTracks = tracks != null ? tracks : new ArrayList<>();
|
||||
List<String> labels = new ArrayList<>();
|
||||
labels.add(getString(R.string.track_spinner_hint));
|
||||
for (TrackInfo t : savedTracks) {
|
||||
String start = timeFormat.format(new Date((long) (t.started_at * 1000)));
|
||||
labels.add("#" + t.id + " " + start + " (" + t.point_count + " pts)");
|
||||
}
|
||||
if (savedTracks.isEmpty()) {
|
||||
labels.set(0, getString(R.string.track_none));
|
||||
}
|
||||
suppressTrackSpinner = true;
|
||||
trackSpinner.setAdapter(new ArrayAdapter<>(
|
||||
requireContext(),
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
labels
|
||||
));
|
||||
trackSpinner.setSelection(0, false);
|
||||
suppressTrackSpinner = false;
|
||||
}
|
||||
|
||||
private void showTrack(long trackId) {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
TrackDetail detail = uploader.getServerApi().getTrack(trackId);
|
||||
if (!isAdded()) {
|
||||
return;
|
||||
}
|
||||
requireActivity().runOnUiThread(() ->
|
||||
runWhenMapReady(() -> drawTrack(detail)));
|
||||
} catch (Exception e) {
|
||||
if (isAdded() && mapStatus != null) {
|
||||
requireActivity().runOnUiThread(() ->
|
||||
mapStatus.setText(getString(R.string.track_error, e.getMessage())));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void drawTrack(TrackDetail detail) {
|
||||
if (!isMapReady() || detail.points == null || detail.points.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
clearTrackLayers();
|
||||
|
||||
List<LatLong> line = new ArrayList<>();
|
||||
List<LatLong> boundsPoints = new ArrayList<>();
|
||||
|
||||
for (TrackDetail.TrackPoint p : detail.points) {
|
||||
LatLong latLong = new LatLong(p.lat, p.lon);
|
||||
line.add(latLong);
|
||||
boundsPoints.add(latLong);
|
||||
Marker marker = new Marker(latLong, bitmapTrackPoint, 0, 0);
|
||||
addTrackLayer(marker);
|
||||
}
|
||||
|
||||
if (line.size() >= 2) {
|
||||
Polyline polyline = new Polyline(
|
||||
MapsforgeBitmaps.linePaint(Color.GREEN, 4f),
|
||||
AndroidGraphicFactory.INSTANCE
|
||||
);
|
||||
polyline.getLatLongs().addAll(line);
|
||||
addTrackLayer(polyline);
|
||||
}
|
||||
|
||||
fitBoundsOnce(boundsPoints, detail.points.size() == 1, true);
|
||||
}
|
||||
|
||||
private void addTrackLayer(Layer layer) {
|
||||
mapView.getLayerManager().getLayers().add(layer);
|
||||
trackLayers.add(layer);
|
||||
}
|
||||
|
||||
private void clearTrackLayers() {
|
||||
for (Layer layer : trackLayers) {
|
||||
mapView.getLayerManager().getLayers().remove(layer);
|
||||
}
|
||||
trackLayers.clear();
|
||||
}
|
||||
|
||||
private void removeAllDeviceMarkers() {
|
||||
if (mapView == null) {
|
||||
return;
|
||||
}
|
||||
for (Marker marker : deviceMarkers.values()) {
|
||||
mapView.getLayerManager().getLayers().remove(marker);
|
||||
}
|
||||
}
|
||||
|
||||
private Bitmap roleBitmap(String role) {
|
||||
return StatsExtractor.ROLE_RX.equals(role) ? bitmapRx : bitmapTx;
|
||||
}
|
||||
|
||||
private static boolean samePosition(LatLong a, LatLong b) {
|
||||
return Math.abs(a.latitude - b.latitude) < POSITION_EPS
|
||||
&& Math.abs(a.longitude - b.longitude) < POSITION_EPS;
|
||||
}
|
||||
|
||||
private boolean isMapReady() {
|
||||
return mapResumed
|
||||
&& isAdded()
|
||||
&& getView() != null
|
||||
&& mapView != null
|
||||
&& mapInitialized
|
||||
&& pollHelper != null
|
||||
&& pollHelper.canRun();
|
||||
}
|
||||
|
||||
private void runWhenMapReady(Runnable action) {
|
||||
if (isMapReady()) {
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshDevices() {
|
||||
if (!mapResumed || uploader == null) {
|
||||
return;
|
||||
}
|
||||
ServerApi api = uploader.getServerApi();
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
List<DeviceInfo> devices = api.getDevices();
|
||||
if (!isAdded() || !mapResumed) {
|
||||
return;
|
||||
}
|
||||
requireActivity().runOnUiThread(() ->
|
||||
runWhenMapReady(() -> updateMap(devices)));
|
||||
} catch (Exception e) {
|
||||
if (!isAdded() || !mapResumed) {
|
||||
return;
|
||||
}
|
||||
requireActivity().runOnUiThread(() -> {
|
||||
if (mapStatus != null && pollHelper != null && pollHelper.canRun()) {
|
||||
mapStatus.setText(getString(R.string.map_error, e.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
if (pollHelper != null && pollHelper.canRun()) {
|
||||
pollHelper.scheduleNext(DEVICE_POLL_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateMap(List<DeviceInfo> devices) {
|
||||
if (!isMapReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int txCount = 0;
|
||||
int rxCount = 0;
|
||||
int onMap = 0;
|
||||
List<LatLong> boundsPoints = new ArrayList<>();
|
||||
Set<String> seen = new HashSet<>();
|
||||
|
||||
for (DeviceInfo d : devices) {
|
||||
if (StatsExtractor.ROLE_TX.equals(d.role)) {
|
||||
txCount++;
|
||||
} else if (StatsExtractor.ROLE_RX.equals(d.role)) {
|
||||
rxCount++;
|
||||
}
|
||||
if (!GeoUtils.isValidCoordinate(d.lat, d.lon)) {
|
||||
continue;
|
||||
}
|
||||
onMap++;
|
||||
seen.add(d.device_id);
|
||||
LatLong pos = new LatLong(d.lat, d.lon);
|
||||
boundsPoints.add(pos);
|
||||
|
||||
Marker marker = deviceMarkers.get(d.device_id);
|
||||
if (marker == null) {
|
||||
marker = new Marker(pos, roleBitmap(d.role), 0, 0);
|
||||
deviceMarkers.put(d.device_id, marker);
|
||||
mapView.getLayerManager().getLayers().add(marker);
|
||||
} else if (!samePosition(marker.getLatLong(), pos)) {
|
||||
marker.setLatLong(pos);
|
||||
}
|
||||
}
|
||||
|
||||
Iterator<Map.Entry<String, Marker>> it = deviceMarkers.entrySet().iterator();
|
||||
while (it.hasNext()) {
|
||||
Map.Entry<String, Marker> entry = it.next();
|
||||
if (!seen.contains(entry.getKey())) {
|
||||
mapView.getLayerManager().getLayers().remove(entry.getValue());
|
||||
it.remove();
|
||||
}
|
||||
}
|
||||
|
||||
if (mapStatus != null) {
|
||||
mapStatus.setText(getString(
|
||||
R.string.map_status_roles,
|
||||
onMap,
|
||||
txCount,
|
||||
rxCount,
|
||||
networkStatusSuffix()
|
||||
));
|
||||
}
|
||||
|
||||
if (!boundsPoints.isEmpty() && !userMovedMap && !initialFitDone) {
|
||||
fitBoundsOnce(boundsPoints, onMap == 1, false);
|
||||
initialFitDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Adjust camera only on first device load or when user picks a saved track. */
|
||||
private void fitBoundsOnce(List<LatLong> points, boolean singlePoint, boolean force) {
|
||||
if (!isMapReady() || points.isEmpty() || (!force && userMovedMap)) {
|
||||
return;
|
||||
}
|
||||
MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition;
|
||||
Runnable apply = () -> {
|
||||
if (!isMapReady()) {
|
||||
return;
|
||||
}
|
||||
if (singlePoint) {
|
||||
position.setCenter(points.get(0));
|
||||
position.setZoomLevel((byte) 13);
|
||||
return;
|
||||
}
|
||||
double minLat = Double.MAX_VALUE;
|
||||
double maxLat = -Double.MAX_VALUE;
|
||||
double minLon = Double.MAX_VALUE;
|
||||
double maxLon = -Double.MAX_VALUE;
|
||||
for (LatLong p : points) {
|
||||
minLat = Math.min(minLat, p.latitude);
|
||||
maxLat = Math.max(maxLat, p.latitude);
|
||||
minLon = Math.min(minLon, p.longitude);
|
||||
maxLon = Math.max(maxLon, p.longitude);
|
||||
}
|
||||
double padLat = Math.max((maxLat - minLat) * 0.2, 0.003);
|
||||
double padLon = Math.max((maxLon - minLon) * 0.2, 0.003);
|
||||
BoundingBox box = new BoundingBox(
|
||||
minLat - padLat, minLon - padLon, maxLat + padLat, maxLon + padLon);
|
||||
position.setCenter(box.getCenterPoint());
|
||||
int w = Math.max(mapView.getWidth(), 1);
|
||||
int h = Math.max(mapView.getHeight(), 1);
|
||||
double latSpan = Math.max(box.maxLatitude - box.minLatitude, 0.001);
|
||||
double lonSpan = Math.max(box.maxLongitude - box.minLongitude, 0.001);
|
||||
double latZoom = Math.log(h / (double) TILE_SIZE_PX / latSpan) / Math.log(2);
|
||||
double lonZoom = Math.log(w / (double) TILE_SIZE_PX / lonSpan) / Math.log(2);
|
||||
byte zoom = (byte) Math.max(8, Math.min(15, Math.floor(Math.min(latZoom, lonZoom))));
|
||||
position.setZoomLevel(zoom);
|
||||
};
|
||||
if (mapView.getWidth() > 0 && mapView.getHeight() > 0) {
|
||||
apply.run();
|
||||
} else {
|
||||
mapView.post(apply);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
|
||||
import org.mapsforge.core.graphics.Color;
|
||||
import org.mapsforge.map.android.graphics.AndroidBitmap;
|
||||
import org.mapsforge.map.android.graphics.AndroidGraphicFactory;
|
||||
|
||||
/** Small colored bitmaps for device/track markers on Mapsforge. */
|
||||
final class MapsforgeBitmaps {
|
||||
|
||||
private MapsforgeBitmaps() {
|
||||
}
|
||||
|
||||
static org.mapsforge.core.graphics.Bitmap dot(int argb, int sizePx) {
|
||||
Bitmap androidBitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888);
|
||||
Canvas canvas = new Canvas(androidBitmap);
|
||||
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
paint.setColor(argb);
|
||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 1f, paint);
|
||||
Paint stroke = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
stroke.setStyle(Paint.Style.STROKE);
|
||||
stroke.setColor(0xFFFFFFFF);
|
||||
stroke.setStrokeWidth(2f);
|
||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 2f, stroke);
|
||||
return new AndroidBitmap(androidBitmap);
|
||||
}
|
||||
|
||||
static org.mapsforge.core.graphics.Paint linePaint(Color color, float strokeWidth) {
|
||||
org.mapsforge.core.graphics.Paint paint = AndroidGraphicFactory.INSTANCE.createPaint();
|
||||
paint.setColor(color);
|
||||
paint.setStrokeWidth(strokeWidth);
|
||||
paint.setStyle(org.mapsforge.core.graphics.Style.STROKE);
|
||||
return paint;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.grigowashere.loratester.ui;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial;
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
import com.grigowashere.loratester.LoraApp;
|
||||
import com.grigowashere.loratester.R;
|
||||
import com.grigowashere.loratester.SettingsRepository;
|
||||
import com.grigowashere.loratester.TelemetryUploader;
|
||||
|
||||
public class SettingsFragment extends Fragment {
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(
|
||||
@NonNull LayoutInflater inflater,
|
||||
@Nullable ViewGroup container,
|
||||
@Nullable Bundle savedInstanceState
|
||||
) {
|
||||
return inflater.inflate(R.layout.fragment_settings, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
LoraApp app = (LoraApp) requireActivity().getApplication();
|
||||
SettingsRepository settings = app.getSettingsRepository();
|
||||
TelemetryUploader uploader = app.getTelemetryUploader();
|
||||
|
||||
TextInputEditText editServer = view.findViewById(R.id.editServerUrl);
|
||||
TextInputEditText editHost = view.findViewById(R.id.editTelnetHost);
|
||||
TextInputEditText editPort = view.findViewById(R.id.editTelnetPort);
|
||||
TextInputEditText editRssi = view.findViewById(R.id.editRssiRegex);
|
||||
TextInputEditText editRange = view.findViewById(R.id.editRangeRegex);
|
||||
SwitchMaterial switchTelnet = view.findViewById(R.id.switchTelnet);
|
||||
TextView deviceIdLabel = view.findViewById(R.id.deviceIdLabel);
|
||||
Button save = view.findViewById(R.id.btnSaveSettings);
|
||||
|
||||
editServer.setText(settings.getServerUrl());
|
||||
editHost.setText(settings.getTelnetHost());
|
||||
editPort.setText(String.valueOf(settings.getTelnetPort()));
|
||||
editRssi.setText(settings.getRssiRegex());
|
||||
editRange.setText(settings.getRangeRegex());
|
||||
switchTelnet.setChecked(settings.isTelnetEnabled());
|
||||
deviceIdLabel.setText(getString(R.string.device_id_label, settings.getOrCreateDeviceId()));
|
||||
|
||||
save.setOnClickListener(v -> {
|
||||
settings.setServerUrl(textOf(editServer, SettingsRepository.DEFAULT_SERVER));
|
||||
settings.setTelnetHost(textOf(editHost, SettingsRepository.DEFAULT_TELNET_HOST));
|
||||
try {
|
||||
settings.setTelnetPort(Integer.parseInt(textOf(editPort, "2727")));
|
||||
} catch (NumberFormatException e) {
|
||||
settings.setTelnetPort(SettingsRepository.DEFAULT_TELNET_PORT);
|
||||
}
|
||||
settings.setRssiRegex(textOf(editRssi, SettingsRepository.DEFAULT_RSSI_REGEX));
|
||||
settings.setRangeRegex(textOf(editRange, SettingsRepository.DEFAULT_RANGE_REGEX));
|
||||
settings.setTelnetEnabled(switchTelnet.isChecked());
|
||||
uploader.refreshApi();
|
||||
if (switchTelnet.isChecked()) {
|
||||
uploader.startTelnet();
|
||||
} else {
|
||||
uploader.stopTelnet();
|
||||
}
|
||||
Toast.makeText(requireContext(), R.string.saved, Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
}
|
||||
|
||||
private static String textOf(TextInputEditText edit, String fallback) {
|
||||
if (edit.getText() == null || edit.getText().toString().trim().isEmpty()) {
|
||||
return fallback;
|
||||
}
|
||||
return edit.getText().toString().trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
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.location.GeoUtils;
|
||||
import com.grigowashere.loratester.telnet.LoraStatsFormatter;
|
||||
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
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 final DateFormat timeFormat =
|
||||
DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.getDefault());
|
||||
private FragmentPollHelper pollHelper;
|
||||
private TelemetryUploader uploader;
|
||||
private CommandPoller commandPoller;
|
||||
private PeerStatsCache peerStatsCache;
|
||||
private TextView statsStatus;
|
||||
private TextView statsPeerWarning;
|
||||
private TextView statsLocalDetails;
|
||||
private TextView statsPeerDetails;
|
||||
private RecyclerView statsHistoryList;
|
||||
private final HistoryAdapter historyAdapter = new HistoryAdapter();
|
||||
|
||||
private StatsExtractor.ExtractedStats cachedLocal;
|
||||
private DeviceInfo cachedServer;
|
||||
private DeviceInfo cachedPeer;
|
||||
private int cachedDeviceCount;
|
||||
private String cachedPeerId;
|
||||
private String cachedPeerError;
|
||||
private String cachedError;
|
||||
|
||||
private final TelemetryUploader.StatsListener statsListener = stats -> {
|
||||
cachedLocal = stats;
|
||||
cachedError = null;
|
||||
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);
|
||||
statsLocalDetails = view.findViewById(R.id.statsLocalDetails);
|
||||
statsPeerDetails = view.findViewById(R.id.statsPeerDetails);
|
||||
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);
|
||||
Button btnModeTx = view.findViewById(R.id.btnModeTxPeer);
|
||||
Button btnModeRx = view.findViewById(R.id.btnModeRxPeer);
|
||||
pollHelper = new FragmentPollHelper(this, this::refresh);
|
||||
|
||||
btnSimulate.setOnClickListener(v -> {
|
||||
String chunk = """
|
||||
SEND
|
||||
Frequency: 433000000 Hz
|
||||
Power: 22 dBm
|
||||
Packet: 1
|
||||
Payload: Sim TX
|
||||
\u001b[2J""";
|
||||
uploader.simulateChunk(chunk);
|
||||
if (pollHelper.canRun()) {
|
||||
statsStatus.setText(R.string.simulate_sent);
|
||||
}
|
||||
});
|
||||
|
||||
btnPushStats.setOnClickListener(v -> pushStatsToPeer());
|
||||
btnModeTx.setOnClickListener(v -> sendModeToPeer("TX"));
|
||||
btnModeRx.setOnClickListener(v -> sendModeToPeer("RX"));
|
||||
}
|
||||
|
||||
private void pushStatsToPeer() {
|
||||
if (commandPoller == null || cachedPeerId == null) {
|
||||
toast(R.string.at_peer_unavailable);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
String meta = pickMetaJson(true);
|
||||
if (meta != null) {
|
||||
payload.put("meta", meta);
|
||||
}
|
||||
Double rssi = pickRssi(true);
|
||||
if (rssi != null) {
|
||||
payload.put("rssi", rssi);
|
||||
}
|
||||
if (cachedLocal != null && cachedLocal.role != null) {
|
||||
payload.put("role", cachedLocal.role);
|
||||
} else if (cachedServer != null && cachedServer.role != null) {
|
||||
payload.put("role", cachedServer.role);
|
||||
}
|
||||
commandPoller.postCommandToPeer(cachedPeerId, "stats_push", payload);
|
||||
toast(R.string.stats_pushed);
|
||||
}
|
||||
|
||||
private void sendModeToPeer(String role) {
|
||||
if (commandPoller == null || cachedPeerId == null) {
|
||||
toast(R.string.at_peer_unavailable);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("role", role);
|
||||
commandPoller.postCommandToPeer(cachedPeerId, "mode", 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);
|
||||
cachedLocal = uploader.getLastStats();
|
||||
postRender();
|
||||
}
|
||||
if (pollHelper != null) {
|
||||
pollHelper.start(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
if (uploader != null) {
|
||||
uploader.setStatsListener(null);
|
||||
}
|
||||
if (pollHelper != null) {
|
||||
pollHelper.stop();
|
||||
}
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
if (pollHelper != null) {
|
||||
pollHelper.stop();
|
||||
}
|
||||
statsStatus = null;
|
||||
statsPeerWarning = null;
|
||||
statsLocalDetails = null;
|
||||
statsPeerDetails = null;
|
||||
statsHistoryList = null;
|
||||
pollHelper = null;
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
executor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void postRender() {
|
||||
if (!isAdded() || statsLocalDetails == null) {
|
||||
return;
|
||||
}
|
||||
requireActivity().runOnUiThread(this::renderDetails);
|
||||
}
|
||||
|
||||
private void refresh() {
|
||||
if (!pollHelper.canRun() || uploader == null || statsStatus == null) {
|
||||
return;
|
||||
}
|
||||
String deviceId = uploader.getDeviceId();
|
||||
boolean telnet = uploader.isTelnetConnected();
|
||||
statsStatus.setText(getString(
|
||||
R.string.stats_status,
|
||||
deviceId,
|
||||
telnet ? getString(R.string.connected) : getString(R.string.disconnected)
|
||||
));
|
||||
|
||||
executor.execute(() -> {
|
||||
List<TelemetryHistoryItem> history = null;
|
||||
try {
|
||||
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
|
||||
cachedDeviceCount = devices.size();
|
||||
PeerDevices.Result peer = PeerDevices.resolve(devices, deviceId);
|
||||
cachedPeerId = peer.peerId;
|
||||
cachedPeerError = peer.error;
|
||||
cachedPeer = null;
|
||||
cachedServer = null;
|
||||
for (DeviceInfo d : devices) {
|
||||
if (deviceId.equals(d.device_id)) {
|
||||
cachedServer = d;
|
||||
} else if (peer.peerId != null && peer.peerId.equals(d.device_id)) {
|
||||
cachedPeer = d;
|
||||
}
|
||||
}
|
||||
cachedError = null;
|
||||
history = uploader.getServerApi().getTelemetryHistory(deviceId, 30);
|
||||
} catch (Exception e) {
|
||||
cachedError = e.getMessage() != null ? e.getMessage() : "error";
|
||||
}
|
||||
List<TelemetryHistoryItem> finalHistory = history;
|
||||
if (isAdded()) {
|
||||
requireActivity().runOnUiThread(() -> {
|
||||
if (historyAdapter != null) {
|
||||
historyAdapter.setItems(finalHistory);
|
||||
}
|
||||
});
|
||||
}
|
||||
postRender();
|
||||
if (pollHelper != null) {
|
||||
pollHelper.scheduleNext(SERVER_POLL_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderDetails() {
|
||||
if (!isAdded() || statsLocalDetails == 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);
|
||||
}
|
||||
}
|
||||
|
||||
statsLocalDetails.setText(formatDeviceBlock(true));
|
||||
statsPeerDetails.setText(formatDeviceBlock(false));
|
||||
}
|
||||
|
||||
private CharSequence formatDeviceBlock(boolean local) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (local) {
|
||||
sb.append(uploader.isTelnetConnected()
|
||||
? getString(R.string.telnet_connected)
|
||||
: getString(R.string.telnet_disconnected));
|
||||
long at = uploader.getLastStatsAtMs();
|
||||
if (at > 0) {
|
||||
sb.append(" · ").append(timeFormat.format(new Date(at)));
|
||||
}
|
||||
sb.append("\n\n");
|
||||
appendStatsBody(sb, pickMetaJson(true), pickRssi(true), cachedServer, true);
|
||||
} else {
|
||||
if (cachedPeerId == null) {
|
||||
sb.append(getString(R.string.stats_peer_absent));
|
||||
return sb;
|
||||
}
|
||||
sb.append(cachedPeerId);
|
||||
if (cachedPeer != null && cachedPeer.role != null) {
|
||||
sb.append(" · ").append(cachedPeer.role);
|
||||
}
|
||||
sb.append("\n\n");
|
||||
PeerStatsCache.Snapshot push = peerStatsCache != null ? peerStatsCache.get() : null;
|
||||
if (push != null && push.meta != null) {
|
||||
appendStatsBody(sb, push.meta, push.rssi, cachedPeer, false);
|
||||
} else if (cachedPeer != null) {
|
||||
appendStatsBody(sb, cachedPeer.meta, cachedPeer.rssi, cachedPeer, false);
|
||||
} else {
|
||||
sb.append(getString(R.string.no_telemetry_yet));
|
||||
}
|
||||
}
|
||||
return sb;
|
||||
}
|
||||
|
||||
private void appendStatsBody(
|
||||
StringBuilder sb,
|
||||
String meta,
|
||||
Double rssi,
|
||||
DeviceInfo gpsSource,
|
||||
boolean local
|
||||
) {
|
||||
if (meta != null && !meta.isEmpty()) {
|
||||
String fields = LoraStatsFormatter.formatMeta(meta);
|
||||
if (!fields.isEmpty()) {
|
||||
sb.append(fields).append("\n");
|
||||
}
|
||||
} else if (local && cachedError != null) {
|
||||
sb.append(getString(R.string.stats_error, cachedError)).append("\n");
|
||||
} else if (local) {
|
||||
sb.append(getString(R.string.no_telemetry_yet)).append("\n");
|
||||
}
|
||||
sb.append("\nСигнал (dBm): ").append(rssi != null ? rssi : "—").append("\n");
|
||||
Double lat = null;
|
||||
Double lon = null;
|
||||
if (gpsSource != null) {
|
||||
lat = gpsSource.lat;
|
||||
lon = gpsSource.lon;
|
||||
}
|
||||
if (GeoUtils.isValidCoordinate(lat, lon)) {
|
||||
sb.append("GPS: ").append(lat).append(", ").append(lon).append("\n");
|
||||
} else {
|
||||
sb.append(getString(R.string.gps_waiting)).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
private String pickMetaJson(boolean local) {
|
||||
if (local) {
|
||||
boolean telnet = uploader.isTelnetConnected();
|
||||
if (telnet && cachedLocal != null && cachedLocal.metaJson != null) {
|
||||
return cachedLocal.metaJson;
|
||||
}
|
||||
if (cachedServer != null && cachedServer.meta != null && !cachedServer.meta.isEmpty()) {
|
||||
return cachedServer.meta;
|
||||
}
|
||||
if (cachedLocal != null && cachedLocal.metaJson != null) {
|
||||
return cachedLocal.metaJson;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Double pickRssi(boolean local) {
|
||||
if (local) {
|
||||
boolean telnet = uploader.isTelnetConnected();
|
||||
if (telnet && cachedLocal != null && cachedLocal.rssi != null) {
|
||||
return cachedLocal.rssi;
|
||||
}
|
||||
if (cachedServer != null && cachedServer.rssi != null) {
|
||||
return cachedServer.rssi;
|
||||
}
|
||||
if (cachedLocal != null) {
|
||||
return cachedLocal.rssi;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -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,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>
|
||||
@@ -0,0 +1,109 @@
|
||||
<?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" />
|
||||
|
||||
<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:singleSelection="true"
|
||||
app:selectionRequired="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"
|
||||
android:checked="true" />
|
||||
|
||||
<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>
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/atQuickChips"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/at_command_hint">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/atCommandInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:inputType="text"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/atSendBtn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/send" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/atClearLog"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/at_clear_log" />
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/atConsoleScroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_weight="1"
|
||||
android:background="#0D1117"
|
||||
android:padding="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/atConsole"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:textColor="#C9D1D9"
|
||||
android:textIsSelectable="true"
|
||||
android:textSize="11sp" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<org.mapsforge.map.android.view.MapView
|
||||
android:id="@+id/mapView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/mapSidePanel"
|
||||
android:layout_width="152dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end|top"
|
||||
android:layout_margin="6dp"
|
||||
android:background="#CC0F3460"
|
||||
android:elevation="4dp"
|
||||
android:fillViewport="false"
|
||||
android:scrollbars="none">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="6dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mapStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mapLegend"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="@string/map_legend"
|
||||
android:textColor="#CCCCCC"
|
||||
android:textSize="9sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTrack"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:minHeight="36dp"
|
||||
android:text="@string/track_start"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnPairedTrack"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:minHeight="36dp"
|
||||
android:text="@string/track_paired_start"
|
||||
android:textSize="11sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/trackStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textColor="#CCCCCC"
|
||||
android:textSize="9sp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/trackSpinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/server_url">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/editServerUrl"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textUri" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="@string/telnet_host">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/editTelnetHost"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="@string/telnet_port">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/editTelnetPort"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="@string/rssi_regex">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/editRssiRegex"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="@string/range_regex">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/editRangeRegex"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switchTelnet"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/telnet_enabled" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/deviceIdLabel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSaveSettings"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/save" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,133 @@
|
||||
<?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" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSimulate"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/simulate_telnet" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:paddingEnd="6dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/stats_local_title"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statsLocalDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="6dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/stats_peer_title"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statsPeerDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnPushStats"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/stats_push_peer" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnModeTxPeer"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="TX→" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnModeRxPeer"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="RX→" />
|
||||
</LinearLayout>
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/chatItemText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="6dp"
|
||||
android:textSize="13sp" />
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/historyLine"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:paddingVertical="4dp"
|
||||
android:textSize="11sp" />
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,7 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.LoraTester" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your dark theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,66 @@
|
||||
<resources>
|
||||
<string name="app_name">LoraTester</string>
|
||||
<string name="tab_map">Карта</string>
|
||||
<string name="tab_stats">Статистика</string>
|
||||
<string name="tab_at">AT</string>
|
||||
<string name="tab_chat">Чат</string>
|
||||
<string name="tab_settings">Настройки</string>
|
||||
<string name="at_status">Telnet: %1$s</string>
|
||||
<string name="at_command_hint">AT+TX или +H …</string>
|
||||
<string name="at_clear_log">Очистить лог</string>
|
||||
<string name="at_not_connected">Telnet не подключён. Включите в Настройках.</string>
|
||||
<string name="at_send_error">Ошибка отправки команды</string>
|
||||
<string name="server_url">URL сервера</string>
|
||||
<string name="telnet_host">Telnet host</string>
|
||||
<string name="telnet_port">Telnet port</string>
|
||||
<string name="rssi_regex">RSSI regex</string>
|
||||
<string name="range_regex">Range regex</string>
|
||||
<string name="telnet_enabled">Подключить telnet</string>
|
||||
<string name="device_id_label">ID устройства: %1$s</string>
|
||||
<string name="save">Сохранить</string>
|
||||
<string name="saved">Сохранено</string>
|
||||
<string name="chat_hint">Сообщение…</string>
|
||||
<string name="send">Отправить</string>
|
||||
<string name="simulate_telnet">Симуляция телнет-кадра</string>
|
||||
<string name="simulate_sent">Кадр отправлен на сервер</string>
|
||||
<string name="stats_status">%1$s · Telnet: %2$s</string>
|
||||
<string name="connected">подключён</string>
|
||||
<string name="disconnected">нет</string>
|
||||
<string name="devices_on_server">Устройств на сервере: %1$d</string>
|
||||
<string name="no_telemetry_yet">Телеметрия ещё не получена. Нажмите «Симуляция» или включите telnet.</string>
|
||||
<string name="stats_error">Ошибка: %1$s</string>
|
||||
<string name="map_error">Ошибка карты: %1$s</string>
|
||||
<string name="devices_label">устройств</string>
|
||||
<string name="map_legend">● красный — передатчик (TX) ● голубой — приёмник (RX)</string>
|
||||
<string name="map_status_roles">На карте: %1$d · TX: %2$d · RX: %3$d · %4$s</string>
|
||||
<string name="map_network_online">онлайн</string>
|
||||
<string name="map_network_offline">офлайн (кэш)</string>
|
||||
<string name="track_need_network">Нужна сеть для начала трека</string>
|
||||
<string name="upload_queue_pending">В очереди: %1$d</string>
|
||||
<string name="gps_waiting">GPS: ожидание фикса…</string>
|
||||
<string name="stats_updated_at">обновлено %1$s</string>
|
||||
<string name="telnet_connected">Telnet: подключён</string>
|
||||
<string name="telnet_disconnected">Telnet: нет</string>
|
||||
<string name="stats_history_title">История (сервер)</string>
|
||||
<string name="track_start">Начать трекинг пути</string>
|
||||
<string name="track_stop">Остановить трекинг</string>
|
||||
<string name="track_status">Трекинг: %1$d точек</string>
|
||||
<string name="track_saved">Трек #%1$d сохранён (%2$d точек)</string>
|
||||
<string name="track_error">Трек: %1$s</string>
|
||||
<string name="track_spinner_hint">Сохранённые треки</string>
|
||||
<string name="track_none">— нет треков —</string>
|
||||
<string name="stats_local_title">Это устройство</string>
|
||||
<string name="stats_peer_title">Другое устройство</string>
|
||||
<string name="stats_peer_absent">Нет данных (ожидается 2 устройства online)</string>
|
||||
<string name="stats_two_devices_required">Нужно ровно 2 устройства на сервере (сейчас %1$d)</string>
|
||||
<string name="stats_push_peer">Отправить статистику на другое</string>
|
||||
<string name="stats_pushed">Статистика отправлена</string>
|
||||
<string name="stats_push_failed">Не удалось отправить</string>
|
||||
<string name="at_target_local">Локально</string>
|
||||
<string name="at_target_peer">На другое устройство</string>
|
||||
<string name="at_sent_to_peer">Команда отправлена на %1$s</string>
|
||||
<string name="at_peer_unavailable">Нет второго устройства</string>
|
||||
<string name="track_paired_start">Старт трека (оба)</string>
|
||||
<string name="track_paired_started">Синхронный старт запланирован</string>
|
||||
<string name="track_paired_need_two">Нужны 2 устройства online</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.LoraTester" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your light theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
|
||||
</style>
|
||||
|
||||
<style name="Theme.LoraTester" parent="Base.Theme.LoraTester" />
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">grigowashere.ru</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import com.grigowashere.loratester.telnet.AtCommandFormatter;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class AtCommandFormatterTest {
|
||||
|
||||
@Test
|
||||
public void addsAtPrefix() {
|
||||
assertEquals("AT+H", AtCommandFormatter.normalize("+H"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void keepsExistingAt() {
|
||||
assertEquals("AT+TX", AtCommandFormatter.normalize("AT+TX"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void wireEndsWithCrLf() {
|
||||
byte[] wire = AtCommandFormatter.toWireBytes("AT+H");
|
||||
assertEquals("AT+H\r\n", new String(wire, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
@Test
|
||||
public void addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import com.grigowashere.loratester.location.GeoUtils;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class GeoUtilsTest {
|
||||
|
||||
@Test
|
||||
public void rejectsNullIsland() {
|
||||
assertFalse(GeoUtils.isValidCoordinate(0.0, 0.0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsRealCoords() {
|
||||
assertTrue(GeoUtils.isValidCoordinate(55.75, 37.62));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||
import com.grigowashere.loratester.telnet.TelnetFrameParser;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class LoraFrameExtractTest {
|
||||
|
||||
private static final String SEND_FRAME = """
|
||||
SEND
|
||||
Frequency: 433000000 Hz
|
||||
Power: 22 dBm
|
||||
Spreading Factor: 12
|
||||
Bandwidth: 125 kHz
|
||||
Packet: 304
|
||||
Payload: Test TX!
|
||||
TX Speed: 0.60 pkt/s, 155 bit/s
|
||||
""";
|
||||
|
||||
private static final String RECEIVE_FRAME = """
|
||||
RECEIVE
|
||||
Frequency: 433000000 Hz
|
||||
Power: 0 dBm
|
||||
Packet Number: 0
|
||||
Payload: test
|
||||
RSSI: -78
|
||||
SNR: 10.5
|
||||
RX Speed: 0.45 pkt/s, 120 bit/s
|
||||
PER: 0.00 %
|
||||
""";
|
||||
|
||||
private static final String FULL_SEND = """
|
||||
SEND
|
||||
Frequency: 433000000 Hz
|
||||
Power: 22 dBm
|
||||
Spreading Factor: 12
|
||||
Bandwidth: 125 kHz
|
||||
Code Rate: 4/5
|
||||
Packet: 304
|
||||
Payload: Test TX!
|
||||
TX Speed: 0.60 pkt/s, 155 bit/s
|
||||
""";
|
||||
|
||||
@Test
|
||||
public void parsesAllLabeledLinesFromSendScreen() {
|
||||
StatsExtractor extractor = StatsExtractor.withDefaults();
|
||||
StatsExtractor.ExtractedStats stats = extractor.extract(FULL_SEND);
|
||||
assertTrue(stats.metaJson.contains("\"fields\""));
|
||||
assertTrue(stats.metaJson.contains("Frequency"));
|
||||
assertTrue(stats.metaJson.contains("Spreading Factor"));
|
||||
assertTrue(stats.metaJson.contains("Packet"));
|
||||
assertTrue(stats.metaJson.contains("Payload"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parsesSendFrameAsTransmitter() {
|
||||
StatsExtractor extractor = StatsExtractor.withDefaults();
|
||||
StatsExtractor.ExtractedStats stats = extractor.extract(FULL_SEND);
|
||||
|
||||
assertEquals("SEND", stats.frameType);
|
||||
assertEquals(StatsExtractor.ROLE_TX, stats.role);
|
||||
assertEquals(22.0, stats.rssi, 0.01);
|
||||
assertNotNull(stats.metaJson);
|
||||
assertTrue(stats.metaJson.contains("\"role\":\"TX\""));
|
||||
assertTrue(stats.metaJson.contains("Test TX!"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parsesReceiveFrameAsReceiver() {
|
||||
StatsExtractor extractor = StatsExtractor.withDefaults();
|
||||
StatsExtractor.ExtractedStats stats = extractor.extract(RECEIVE_FRAME);
|
||||
|
||||
assertEquals("RECEIVE", stats.frameType);
|
||||
assertEquals(StatsExtractor.ROLE_RX, stats.role);
|
||||
assertEquals(-78.0, stats.rssi, 0.01);
|
||||
assertEquals(10.5, stats.snrDb, 0.01);
|
||||
assertTrue(stats.metaJson.contains("\"fields\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void splitsTwoFramesByReceiveHeaderWithoutEsc() {
|
||||
List<String> frames = new ArrayList<>();
|
||||
TelnetFrameParser parser = new TelnetFrameParser(frames::add);
|
||||
String stream = SEND_FRAME + "\n" + RECEIVE_FRAME;
|
||||
parser.append(stream.getBytes(StandardCharsets.UTF_8));
|
||||
parser.flush();
|
||||
assertTrue(frames.size() >= 1);
|
||||
boolean hasSend = false;
|
||||
boolean hasReceive = false;
|
||||
for (String f : frames) {
|
||||
if (f.contains("SEND")) hasSend = true;
|
||||
if (f.contains("RECEIVE")) hasReceive = true;
|
||||
}
|
||||
assertTrue(hasSend || frames.get(0).contains("SEND"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import com.grigowashere.loratester.api.ServerApi;
|
||||
import com.grigowashere.loratester.api.TelemetryPayload;
|
||||
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||
import com.grigowashere.loratester.telnet.TelnetFrameParser;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
|
||||
public class TelemetryUploadTest {
|
||||
|
||||
@Test
|
||||
public void frameTriggersTelemetryPost() throws Exception {
|
||||
try (MockWebServer server = new MockWebServer()) {
|
||||
server.enqueue(new MockResponse().setBody("{\"ok\":true}"));
|
||||
server.start();
|
||||
|
||||
String baseUrl = server.url("/").toString();
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
|
||||
}
|
||||
|
||||
ServerApi api = new ServerApi(baseUrl);
|
||||
StatsExtractor extractor = StatsExtractor.withDefaults();
|
||||
AtomicReference<String> postedMeta = new AtomicReference<>();
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
TelnetFrameParser parser = new TelnetFrameParser(frame -> {
|
||||
StatsExtractor.ExtractedStats stats = extractor.extract(frame);
|
||||
TelemetryPayload payload = new TelemetryPayload(
|
||||
"test-device",
|
||||
55.75,
|
||||
37.62,
|
||||
stats.rssi,
|
||||
stats.rangeM,
|
||||
null,
|
||||
stats.metaJson,
|
||||
stats.role,
|
||||
System.currentTimeMillis() / 1000.0
|
||||
);
|
||||
try {
|
||||
api.postTelemetry(payload);
|
||||
postedMeta.set(stats.metaJson);
|
||||
latch.countDown();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
|
||||
String chunk = """
|
||||
SEND
|
||||
Power: 22 dBm
|
||||
RSSI: -72
|
||||
range: 1200
|
||||
\u001b[2J""";
|
||||
parser.append(chunk.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertTrue(latch.await(5, TimeUnit.SECONDS));
|
||||
|
||||
RecordedRequest req = server.takeRequest(5, TimeUnit.SECONDS);
|
||||
assertEquals("POST", req.getMethod());
|
||||
assertEquals("android", req.getHeader(ServerApi.HEADER_LORA_CLIENT));
|
||||
assertTrue(req.getPath().contains("/api/telemetry"));
|
||||
String body = req.getBody().readUtf8();
|
||||
assertTrue(body.contains("test-device"));
|
||||
assertTrue(body.contains("\"meta\""));
|
||||
assertTrue(body.contains("\"fields\""));
|
||||
assertTrue(postedMeta.get().contains("SEND"));
|
||||
assertTrue(postedMeta.get().contains("TX"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import com.grigowashere.loratester.telnet.TelnetClient;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public class TelnetClientSendTest {
|
||||
|
||||
@Test
|
||||
public void sendAtCommandWritesCrLfLine() throws Exception {
|
||||
AtomicReference<String> received = new AtomicReference<>();
|
||||
CountDownLatch connectedLatch = new CountDownLatch(1);
|
||||
|
||||
try (ServerSocket serverSocket = new ServerSocket(0)) {
|
||||
int port = serverSocket.getLocalPort();
|
||||
Thread acceptThread = new Thread(() -> {
|
||||
try (Socket client = serverSocket.accept()) {
|
||||
connectedLatch.countDown();
|
||||
InputStream in = client.getInputStream();
|
||||
OutputStream out = client.getOutputStream();
|
||||
byte[] buf = new byte[256];
|
||||
int n = in.read(buf);
|
||||
if (n > 0) {
|
||||
received.set(new String(buf, 0, n, StandardCharsets.UTF_8));
|
||||
}
|
||||
out.write("OK\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
Thread.sleep(300);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
});
|
||||
acceptThread.start();
|
||||
|
||||
TelnetClient telnet = new TelnetClient("127.0.0.1", port, new TelnetClient.Listener() {
|
||||
@Override
|
||||
public void onConnected() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBytes(byte[] data, int length) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String message) {
|
||||
}
|
||||
});
|
||||
telnet.start();
|
||||
assertTrue(connectedLatch.await(5, TimeUnit.SECONDS));
|
||||
Thread.sleep(300);
|
||||
|
||||
TelnetClient.SendResult result = telnet.sendAtCommand("AT+H");
|
||||
assertEquals(TelnetClient.SendResult.SENT, result);
|
||||
Thread.sleep(400);
|
||||
|
||||
telnet.stop();
|
||||
acceptThread.join(2000);
|
||||
assertEquals("AT+H\r\n", received.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.grigowashere.loratester;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import com.grigowashere.loratester.telnet.TelnetFrameParser;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class TelnetFrameParserTest {
|
||||
|
||||
private final List<String> frames = new ArrayList<>();
|
||||
private TelnetFrameParser parser;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
frames.clear();
|
||||
parser = new TelnetFrameParser(frames::add);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void splitsOnAnsiClear() {
|
||||
byte[] data = "frame1\u001b[2Jframe2".getBytes(StandardCharsets.UTF_8);
|
||||
parser.append(data);
|
||||
assertEquals(1, frames.size());
|
||||
assertEquals("frame1", frames.get(0));
|
||||
parser.flush();
|
||||
assertEquals(2, frames.size());
|
||||
assertEquals("frame2", frames.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void splitsOnFormFeed() {
|
||||
byte[] data = "alpha\u000cbeta".getBytes(StandardCharsets.UTF_8);
|
||||
parser.append(data);
|
||||
parser.flush();
|
||||
assertEquals(2, frames.size());
|
||||
assertEquals("alpha", frames.get(0));
|
||||
assertEquals("beta", frames.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void twoFramesInOneChunk() {
|
||||
byte[] data = "A\u001b[2JB\u001b[2JC".getBytes(StandardCharsets.UTF_8);
|
||||
parser.append(data);
|
||||
parser.flush();
|
||||
assertEquals(3, frames.size());
|
||||
assertEquals("A", frames.get(0));
|
||||
assertEquals("B", frames.get(1));
|
||||
assertEquals("C", frames.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flushEmitsTrailingBuffer() {
|
||||
parser.append("tail only".getBytes(StandardCharsets.UTF_8));
|
||||
assertEquals(0, frames.size());
|
||||
parser.flush();
|
||||
assertEquals(1, frames.size());
|
||||
assertTrue(frames.get(0).contains("tail"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. For more details, visit
|
||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -0,0 +1,42 @@
|
||||
[versions]
|
||||
agp = "8.9.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
appcompat = "1.7.1"
|
||||
material = "1.13.0"
|
||||
activity = "1.9.3"
|
||||
constraintlayout = "2.2.1"
|
||||
okhttp = "4.12.0"
|
||||
gson = "2.11.0"
|
||||
playServicesLocation = "21.3.0"
|
||||
mapsforge = "0.21.0"
|
||||
viewpager2 = "1.1.0"
|
||||
fragment = "1.8.5"
|
||||
recyclerview = "1.3.2"
|
||||
mockwebserver = "4.12.0"
|
||||
|
||||
[libraries]
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
|
||||
constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
|
||||
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
|
||||
mapsforge-core = { group = "org.mapsforge", name = "mapsforge-core", version.ref = "mapsforge" }
|
||||
mapsforge-map = { group = "org.mapsforge", name = "mapsforge-map", version.ref = "mapsforge" }
|
||||
mapsforge-map-android = { group = "org.mapsforge", name = "mapsforge-map-android", version.ref = "mapsforge" }
|
||||
mapsforge-map-reader = { group = "org.mapsforge", name = "mapsforge-map-reader", version.ref = "mapsforge" }
|
||||
mapsforge-themes = { group = "org.mapsforge", name = "mapsforge-themes", version.ref = "mapsforge" }
|
||||
viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version.ref = "viewpager2" }
|
||||
fragment = { group = "androidx.fragment", name = "fragment", version.ref = "fragment" }
|
||||
recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
|
||||
mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "mockwebserver" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#Thu Jun 04 08:48:49 MSK 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||