Compare commits
24 Commits
e75f63cf30
..
v9
| Author | SHA1 | Date | |
|---|---|---|---|
| 40a1ccab1e | |||
| e71b6eed2f | |||
| dbef86d2c9 | |||
| 6b34e75f35 | |||
| 0e1fa15a2f | |||
| 64607def4a | |||
| 3399e81447 | |||
| 0571291b69 | |||
| c5805eaa5c | |||
| 012947fd99 | |||
| 23eb7ffb91 | |||
| e20b81c817 | |||
| 2f303134c1 | |||
| ab2a3bb035 | |||
| d28391c71f | |||
| c2f26c8ec3 | |||
| 94e2b772e8 | |||
| 17d383ddc6 | |||
| 8fd7e85c83 | |||
| 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,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -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,9 @@
|
|||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectType">
|
||||||
|
<option name="id" value="Android" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -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'
|
ANDROID_SDK_ROOT = '/opt/android-sdk'
|
||||||
PATH = "/usr/lib/jvm/java-21-openjdk-amd64/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:${env.PATH}"
|
PATH = "/usr/lib/jvm/java-21-openjdk-amd64/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:${env.PATH}"
|
||||||
|
|
||||||
TAIGA_PROJECT_ID = '2'
|
|
||||||
TAIGA_URL = 'https://taiga.grigowashere.ru'
|
|
||||||
GITEA_OWNER = 'Grigo'
|
GITEA_OWNER = 'Grigo'
|
||||||
GITEA_REPO = 'TestingAndroidBuild' // Замените на нужный репозиторий
|
GITEA_REPO = 'LoraMapTester' // Замените на нужный репозиторий
|
||||||
GITEA_URL = 'https://git.grigowashere.ru' // Базовый URL Gitea
|
GITEA_URL = 'https://git.grigowashere.ru' // Базовый URL Gitea
|
||||||
GITEA_API_URL = "${GITEA_URL}/api/v1"
|
GITEA_API_URL = "${GITEA_URL}/api/v1"
|
||||||
GITEA_TOKEN_CREDENTIALS_ID = 'Gitea_Credentials' // ID ваших креденшлов для Gitea в Jenkins
|
GITEA_TOKEN_CREDENTIALS_ID = 'Gitea_Credentials' // ID ваших креденшлов для Gitea в Jenkins
|
||||||
@@ -40,7 +38,11 @@ pipeline {
|
|||||||
writeFile file: 'gitea-release.sh', text: '''
|
writeFile file: 'gitea-release.sh', text: '''
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
apkPath="build/outputs/apk/debug/app-debug.apk"
|
apkPath=$(find . -path '*/build/outputs/apk/debug/*.apk' -type f | head -1)
|
||||||
|
if [ -z "$apkPath" ]; then
|
||||||
|
echo "APK not found under */build/outputs/apk/debug/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
headers="Authorization: token $GITEA_TOKEN"
|
headers="Authorization: token $GITEA_TOKEN"
|
||||||
|
|
||||||
# Создаем релиз на Gitea
|
# Создаем релиз на Gitea
|
||||||
@@ -80,98 +82,4 @@ fi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
|
||||||
always {
|
|
||||||
script {
|
|
||||||
def result = currentBuild.currentResult ?: 'UNKNOWN'
|
|
||||||
|
|
||||||
withCredentials([string(credentialsId: 'TAIGA_TOKEN', variable: 'TAIGA_TOKEN')]) {
|
|
||||||
sh(returnStatus: true, script: """
|
|
||||||
set +e
|
|
||||||
|
|
||||||
REF=\$(git log -1 --pretty=%B | grep -oE 'TG-[0-9]+' | head -1 | cut -d- -f2 || true)
|
|
||||||
|
|
||||||
if [ -z "\$REF" ]; then
|
|
||||||
echo "No TG-* reference found"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
export REF
|
|
||||||
export BUILD_RESULT="${result}"
|
|
||||||
|
|
||||||
python3 - <<'PY'
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
taiga_url = os.environ["TAIGA_URL"]
|
|
||||||
project_id = os.environ["TAIGA_PROJECT_ID"]
|
|
||||||
token = os.environ["TAIGA_TOKEN"]
|
|
||||||
ref = os.environ["REF"]
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {token}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_json(path):
|
|
||||||
url = f"{taiga_url}{path}"
|
|
||||||
req = urllib.request.Request(url, headers=headers)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req) as r:
|
|
||||||
return json.loads(r.read().decode("utf-8"))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
targets = [
|
|
||||||
("userstories", "User Story"),
|
|
||||||
("issues", "Issue"),
|
|
||||||
("tasks", "Task"),
|
|
||||||
]
|
|
||||||
|
|
||||||
found = None
|
|
||||||
|
|
||||||
for endpoint, label in targets:
|
|
||||||
data = get_json(f"/api/v1/{endpoint}/by_ref?project={project_id}&ref={ref}")
|
|
||||||
if data and "id" in data:
|
|
||||||
found = (endpoint, label, data)
|
|
||||||
break
|
|
||||||
|
|
||||||
if not found:
|
|
||||||
print(f"Taiga TG-{ref} not found")
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
endpoint, label, data = found
|
|
||||||
|
|
||||||
comment = (
|
|
||||||
f"Jenkins Android build #{os.environ['BUILD_NUMBER']}: {os.environ['BUILD_RESULT']}\\n"
|
|
||||||
f"{os.environ['BUILD_URL']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = json.dumps({
|
|
||||||
"comment": comment,
|
|
||||||
"version": data["version"],
|
|
||||||
}).encode("utf-8")
|
|
||||||
|
|
||||||
url = f"{taiga_url}/api/v1/{endpoint}/{data['id']}"
|
|
||||||
req = urllib.request.Request(
|
|
||||||
url,
|
|
||||||
data=payload,
|
|
||||||
headers=headers,
|
|
||||||
method="PATCH",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req) as r:
|
|
||||||
print(f"Commented Taiga TG-{ref} ({label}), HTTP {r.status}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Taiga comment warning: {e}")
|
|
||||||
raise SystemExit(0)
|
|
||||||
PY
|
|
||||||
""".stripIndent())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# LoraTester
|
||||||
|
|
||||||
|
Android-клиент и Python-сервер для мониторинга LoRa приёмопередатчика (telnet → парсинг кадров), GPS-позиций, карты и чата между устройствами.
|
||||||
|
|
||||||
|
## Компоненты
|
||||||
|
|
||||||
|
- **Android** (`app/`) — telnet на `127.0.0.1:2727`, AT-команды (AT+H, AT+TX, …), отправка телеметрии на сервер, карта OSMDroid, чат, настройки.
|
||||||
|
- **Server** (`server/`) — Flask (основной) + FastAPI, веб-карта Leaflet, REST API. См. [server/README.md](server/README.md).
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
1. Запустите сервер: `cd server && pip install -r requirements.txt && python flask_app.py`
|
||||||
|
2. Соберите APK в Android Studio или `./gradlew assembleDebug`
|
||||||
|
3. В приложении: Настройки → URL `https://lora.grigowashere.ru` (или свой сервер), включите telnet при наличии моста COM→telnet
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
Симуляция телнет-кадра: вкладка **Статистика** → «Симуляция телнет-кадра».
|
||||||
|
|
||||||
|
AT-команды: вкладка **AT** — быстрые кнопки и произвольная строка (добавляются префикс `AT` и `\r\n`). Нужен включённый telnet в **Настройках**.
|
||||||
|
|||||||
@@ -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,45 @@
|
|||||||
|
<?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.ACCESS_BACKGROUND_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<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>
|
||||||
|
<service
|
||||||
|
android:name=".LoraForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="location|dataSync" />
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package com.grigowashere.loratester;
|
||||||
|
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import com.grigowashere.loratester.api.DeviceCommand;
|
||||||
|
import com.grigowashere.loratester.api.PairedTrackSession;
|
||||||
|
import com.grigowashere.loratester.api.ServerApi;
|
||||||
|
import com.grigowashere.loratester.telnet.AtCommands;
|
||||||
|
import com.grigowashere.loratester.telnet.TelnetClient;
|
||||||
|
import com.grigowashere.loratester.track.TrackRecorder;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
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 ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "CommandPoller");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
scheduler.scheduleWithFixedDelay(
|
||||||
|
this::pollCommandsSafe, 0, COMMAND_POLL_MS, TimeUnit.MILLISECONDS);
|
||||||
|
scheduler.scheduleWithFixedDelay(
|
||||||
|
this::pollPairedSafe, 0, PAIRED_POLL_MS, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
running.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pollCommandsSafe() {
|
||||||
|
if (!running.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pollCommands();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pollPairedSafe() {
|
||||||
|
if (!running.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pollPairedSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pollCommands() {
|
||||||
|
try {
|
||||||
|
List<DeviceCommand> cmds = serverApi.pollPendingCommands(deviceId);
|
||||||
|
for (DeviceCommand cmd : cmds) {
|
||||||
|
execute(cmd);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "command poll failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void execute(DeviceCommand cmd) {
|
||||||
|
if (cmd == null || cmd.kind == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (cmd.kind) {
|
||||||
|
case "at" -> {
|
||||||
|
List<String> lines = extractLines(cmd.payload);
|
||||||
|
if (lines != null && !lines.isEmpty()) {
|
||||||
|
uploader.sendMacroSequence(lines, r ->
|
||||||
|
Log.i(TAG, "remote macro " + lines + " -> " + r));
|
||||||
|
} else {
|
||||||
|
String line = cmd.payload != null && cmd.payload.get("line") != null
|
||||||
|
? String.valueOf(cmd.payload.get("line")) : null;
|
||||||
|
if (line != null) {
|
||||||
|
uploader.sendAtCommand(line, r ->
|
||||||
|
Log.i(TAG, "remote AT " + line + " -> " + r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "mode" -> {
|
||||||
|
String role = cmd.payload != null && cmd.payload.get("role") != null
|
||||||
|
? String.valueOf(cmd.payload.get("role")) : null;
|
||||||
|
if ("TX".equalsIgnoreCase(role)) {
|
||||||
|
uploader.sendAtCommand(AtCommands.TRANSMIT, r -> {});
|
||||||
|
} else if ("RX".equalsIgnoreCase(role)) {
|
||||||
|
uploader.sendAtCommand(AtCommands.RECEIVE, r -> {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "stats_push" -> peerStatsCache.updateFromPayload(cmd.payload);
|
||||||
|
default -> Log.w(TAG, "unknown kind " + cmd.kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pollPairedSession() {
|
||||||
|
try {
|
||||||
|
Map<String, Object> resp = serverApi.getActivePairedTrack();
|
||||||
|
Object sessionObj = resp.get("session");
|
||||||
|
if (!(sessionObj instanceof Map)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> m = (Map<String, Object>) sessionObj;
|
||||||
|
PairedTrackSession session = mapSession(m);
|
||||||
|
if (session == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean inSession = deviceId.equals(session.device_a)
|
||||||
|
|| deviceId.equals(session.device_b);
|
||||||
|
if (!inSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!session.ready || trackRecorder.isRecording()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (startedSessionId == session.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Long myTrack = deviceId.equals(session.device_a)
|
||||||
|
? session.track_id_a : session.track_id_b;
|
||||||
|
if (myTrack != null && myTrack > 0) {
|
||||||
|
startedSessionId = session.id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startedSessionId = session.id;
|
||||||
|
pendingAckSessionId = session.id;
|
||||||
|
trackRecorder.start();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "paired poll failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ackSession(long sessionId, long trackId) {
|
||||||
|
try {
|
||||||
|
serverApi.ackPairedTrack(sessionId, deviceId, trackId);
|
||||||
|
Log.i(TAG, "paired ack session=" + sessionId + " track=" + trackId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "paired ack failed", e);
|
||||||
|
pendingAckSessionId = sessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PairedTrackSession mapSession(Map<String, Object> m) {
|
||||||
|
if (m == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
PairedTrackSession s = new PairedTrackSession();
|
||||||
|
Object id = m.get("id");
|
||||||
|
if (id instanceof Number) {
|
||||||
|
s.id = ((Number) id).longValue();
|
||||||
|
}
|
||||||
|
s.device_a = str(m.get("device_a"));
|
||||||
|
s.device_b = str(m.get("device_b"));
|
||||||
|
s.initiator = str(m.get("initiator"));
|
||||||
|
s.status = str(m.get("status"));
|
||||||
|
s.start_at = num(m.get("start_at"));
|
||||||
|
s.created_at = num(m.get("created_at"));
|
||||||
|
s.server_time = num(m.get("server_time"));
|
||||||
|
Object ready = m.get("ready");
|
||||||
|
s.ready = ready instanceof Boolean && (Boolean) ready;
|
||||||
|
s.track_id_a = longOrNull(m.get("track_id_a"));
|
||||||
|
s.track_id_b = longOrNull(m.get("track_id_b"));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(Object o) {
|
||||||
|
return o != null ? String.valueOf(o) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double num(Object o) {
|
||||||
|
return o instanceof Number ? ((Number) o).doubleValue() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long longOrNull(Object o) {
|
||||||
|
return o instanceof Number ? ((Number) o).longValue() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static List<String> extractLines(Map<String, Object> payload) {
|
||||||
|
if (payload == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object raw = payload.get("lines");
|
||||||
|
if (!(raw instanceof List<?> list) || list.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<String> lines = new java.util.ArrayList<>();
|
||||||
|
for (Object item : list) {
|
||||||
|
if (item != null) {
|
||||||
|
String line = String.valueOf(item).trim();
|
||||||
|
if (!line.isEmpty()) {
|
||||||
|
lines.add(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.isEmpty() ? null : lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void postMacroToPeer(String peerId, List<String> lines) {
|
||||||
|
Map<String, Object> payload = new HashMap<>();
|
||||||
|
payload.put("lines", lines);
|
||||||
|
postCommandToPeer(peerId, "at", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void postCommandToPeer(String peerId, String kind, Map<String, Object> payload) {
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
serverApi.postCommand(deviceId, peerId, kind, payload);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "post command failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void startPairedTrack(Runnable onDone, java.util.function.Consumer<String> onError) {
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("device_id", deviceId);
|
||||||
|
serverApi.startPairedTrack(body);
|
||||||
|
startedSessionId = -1;
|
||||||
|
pendingAckSessionId = -1;
|
||||||
|
if (onDone != null) {
|
||||||
|
mainHandler.post(onDone);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "start paired failed", e);
|
||||||
|
if (onError != null) {
|
||||||
|
mainHandler.post(() -> onError.accept(
|
||||||
|
e.getMessage() != null ? e.getMessage() : "error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package com.grigowashere.loratester;
|
||||||
|
|
||||||
|
import android.app.Application;
|
||||||
|
|
||||||
|
import com.grigowashere.loratester.api.ServerApi;
|
||||||
|
import com.grigowashere.loratester.location.LocationTracker;
|
||||||
|
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;
|
||||||
|
private LocationTracker locationTracker;
|
||||||
|
|
||||||
|
@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();
|
||||||
|
telemetryUploader.registerPresence();
|
||||||
|
if (networkMonitor != null) {
|
||||||
|
networkMonitor.addListener(online -> {
|
||||||
|
if (online) {
|
||||||
|
telemetryUploader.registerPresence();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 synchronized void startLocationUpdates() {
|
||||||
|
if (locationTracker == null) {
|
||||||
|
locationTracker = new LocationTracker(this, (lat, lon, alt) -> {
|
||||||
|
telemetryUploader.updateLocation(lat, lon);
|
||||||
|
trackRecorder.updateLocation(lat, lon, alt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
locationTracker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void stopLocationUpdates() {
|
||||||
|
if (locationTracker != null) {
|
||||||
|
locationTracker.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,178 @@
|
|||||||
|
package com.grigowashere.loratester;
|
||||||
|
|
||||||
|
import android.app.Notification;
|
||||||
|
import android.app.NotificationChannel;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.app.Service;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.ServiceInfo;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.os.PowerManager;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.app.NotificationCompat;
|
||||||
|
|
||||||
|
import com.grigowashere.loratester.track.TrackRecorder;
|
||||||
|
|
||||||
|
public class LoraForegroundService extends Service {
|
||||||
|
|
||||||
|
private static final String CHANNEL_ID = "lora_background";
|
||||||
|
private static final int NOTIFICATION_ID = 1;
|
||||||
|
|
||||||
|
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||||
|
private PowerManager.WakeLock wakeLock;
|
||||||
|
private LoraApp app;
|
||||||
|
|
||||||
|
private final Runnable notificationTicker = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
updateNotification();
|
||||||
|
handler.postDelayed(this, 5000L);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public static void ensureRunning(Context context) {
|
||||||
|
Context appContext = context.getApplicationContext();
|
||||||
|
Intent intent = new Intent(appContext, LoraForegroundService.class);
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
appContext.startForegroundService(intent);
|
||||||
|
} else {
|
||||||
|
appContext.startService(intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
app = (LoraApp) getApplication();
|
||||||
|
createNotificationChannel();
|
||||||
|
acquireWakeLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
|
Notification notification = buildNotification();
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
||||||
|
| ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification);
|
||||||
|
}
|
||||||
|
app.startLocationUpdates();
|
||||||
|
handler.removeCallbacks(notificationTicker);
|
||||||
|
handler.post(notificationTicker);
|
||||||
|
return START_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
handler.removeCallbacks(notificationTicker);
|
||||||
|
releaseWakeLock();
|
||||||
|
app.stopLocationUpdates();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTaskRemoved(Intent rootIntent) {
|
||||||
|
TelemetryUploader uploader = app.getTelemetryUploader();
|
||||||
|
if (uploader != null) {
|
||||||
|
uploader.stopTelnet();
|
||||||
|
}
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||||
|
stopSelf();
|
||||||
|
super.onTaskRemoved(rootIntent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public IBinder onBind(Intent intent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void acquireWakeLock() {
|
||||||
|
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
|
||||||
|
if (pm == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "LoraTester::Background");
|
||||||
|
wakeLock.setReferenceCounted(false);
|
||||||
|
wakeLock.acquire();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void releaseWakeLock() {
|
||||||
|
if (wakeLock != null && wakeLock.isHeld()) {
|
||||||
|
wakeLock.release();
|
||||||
|
}
|
||||||
|
wakeLock = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NotificationChannel channel = new NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
getString(R.string.notification_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
);
|
||||||
|
channel.setDescription(getString(R.string.notification_channel_desc));
|
||||||
|
NotificationManager nm = getSystemService(NotificationManager.class);
|
||||||
|
if (nm != null) {
|
||||||
|
nm.createNotificationChannel(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Notification buildNotification() {
|
||||||
|
Intent open = new Intent(this, MainActivity.class);
|
||||||
|
open.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||||
|
PendingIntent pending = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
open,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||||
|
);
|
||||||
|
|
||||||
|
TelemetryUploader uploader = app.getTelemetryUploader();
|
||||||
|
TrackRecorder recorder = app.getTrackRecorder();
|
||||||
|
SettingsRepository settings = app.getSettingsRepository();
|
||||||
|
|
||||||
|
boolean telnetOn = settings.isTelnetEnabled();
|
||||||
|
boolean telnetConnected = uploader != null && uploader.isTelnetConnected();
|
||||||
|
boolean recording = recorder != null && recorder.isRecording();
|
||||||
|
int points = recorder != null ? recorder.getPointCount() : 0;
|
||||||
|
|
||||||
|
String telnetLine = telnetOn
|
||||||
|
? getString(telnetConnected ? R.string.telnet_connected : R.string.telnet_disconnected)
|
||||||
|
: getString(R.string.telnet_disabled_short);
|
||||||
|
String trackLine = recording
|
||||||
|
? getString(R.string.notification_track_recording, points)
|
||||||
|
: getString(R.string.notification_track_idle);
|
||||||
|
|
||||||
|
return new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_service)
|
||||||
|
.setContentTitle(getString(R.string.notification_title))
|
||||||
|
.setContentText(telnetLine + " · " + trackLine)
|
||||||
|
.setSubText(getString(R.string.notification_subtitle))
|
||||||
|
.setContentIntent(pending)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateNotification() {
|
||||||
|
NotificationManager nm = getSystemService(NotificationManager.class);
|
||||||
|
if (nm != null) {
|
||||||
|
nm.notify(NOTIFICATION_ID, buildNotification());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package com.grigowashere.loratester;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.PowerManager;
|
||||||
|
import android.provider.Settings;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
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.ui.MainPagerAdapter;
|
||||||
|
|
||||||
|
public class MainActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private LoraApp app;
|
||||||
|
private SettingsRepository settings;
|
||||||
|
private boolean backgroundLocationRequested;
|
||||||
|
|
||||||
|
private final ActivityResultLauncher<String[]> locationPermissionLauncher =
|
||||||
|
registerForActivityResult(
|
||||||
|
new ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
|
result -> onForegroundLocationReady()
|
||||||
|
);
|
||||||
|
|
||||||
|
private final ActivityResultLauncher<String> backgroundLocationLauncher =
|
||||||
|
registerForActivityResult(
|
||||||
|
new ActivityResultContracts.RequestPermission(),
|
||||||
|
granted -> startBackgroundWork()
|
||||||
|
);
|
||||||
|
|
||||||
|
private final ActivityResultLauncher<String> notificationPermissionLauncher =
|
||||||
|
registerForActivityResult(
|
||||||
|
new ActivityResultContracts.RequestPermission(),
|
||||||
|
granted -> startBackgroundWork()
|
||||||
|
);
|
||||||
|
|
||||||
|
@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;
|
||||||
|
});
|
||||||
|
|
||||||
|
app = (LoraApp) getApplication();
|
||||||
|
settings = app.getSettingsRepository();
|
||||||
|
|
||||||
|
ViewPager2 pager = findViewById(R.id.viewPager);
|
||||||
|
TabLayout tabs = findViewById(R.id.tabLayout);
|
||||||
|
pager.setOffscreenPageLimit(1);
|
||||||
|
pager.setAdapter(new MainPagerAdapter(this));
|
||||||
|
new TabLayoutMediator(tabs, pager, (tab, position) -> {
|
||||||
|
int titleRes = switch (position) {
|
||||||
|
case 0 -> R.string.tab_map;
|
||||||
|
case 1 -> R.string.tab_stats;
|
||||||
|
case 2 -> R.string.tab_at;
|
||||||
|
case 3 -> R.string.tab_chat;
|
||||||
|
default -> R.string.tab_settings;
|
||||||
|
};
|
||||||
|
tab.setText(titleRes);
|
||||||
|
}).attach();
|
||||||
|
|
||||||
|
requestStartupPermissions();
|
||||||
|
if (settings.isTelnetEnabled()) {
|
||||||
|
app.getTelemetryUploader().startTelnet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestStartupPermissions() {
|
||||||
|
if (hasForegroundLocation()) {
|
||||||
|
onForegroundLocationReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
locationPermissionLauncher.launch(new String[]{
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onForegroundLocationReady() {
|
||||||
|
if (!hasForegroundLocation()) {
|
||||||
|
Toast.makeText(this, R.string.background_location_required, Toast.LENGTH_LONG).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestNotificationPermissionIfNeeded();
|
||||||
|
requestBackgroundLocationIfNeeded();
|
||||||
|
startBackgroundWork();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestNotificationPermissionIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
== PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestBackgroundLocationIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasBackgroundLocation()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (backgroundLocationRequested) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
backgroundLocationRequested = true;
|
||||||
|
if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_BACKGROUND_LOCATION)) {
|
||||||
|
Toast.makeText(this, R.string.background_location_rationale, Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
backgroundLocationLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startBackgroundWork() {
|
||||||
|
LoraForegroundService.ensureRunning(this);
|
||||||
|
if (settings.isTelnetEnabled()) {
|
||||||
|
app.getTelemetryUploader().startTelnet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasForegroundLocation() {
|
||||||
|
return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
== PackageManager.PERMISSION_GRANTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasBackgroundLocation() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
|
return hasForegroundLocation();
|
||||||
|
}
|
||||||
|
return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
||||||
|
== PackageManager.PERMISSION_GRANTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void openBatteryOptimizationSettings(@NonNull android.content.Context context) {
|
||||||
|
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
|
||||||
|
intent.setData(Uri.parse("package:" + context.getPackageName()));
|
||||||
|
context.startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isIgnoringBatteryOptimizations(@NonNull android.content.Context context) {
|
||||||
|
PowerManager pm = (PowerManager) context.getSystemService(POWER_SERVICE);
|
||||||
|
if (pm == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return pm.isIgnoringBatteryOptimizations(context.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,122 @@
|
|||||||
|
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";
|
||||||
|
private static final String KEY_DEVICE_LABEL = "device_label";
|
||||||
|
|
||||||
|
public static final String DEFAULT_SERVER = "https://lora.grigowashere.ru";
|
||||||
|
private static final String LEGACY_SERVER_HTTP = "http://grigowashere.ru:7634";
|
||||||
|
public static final String DEFAULT_TELNET_HOST = "127.0.0.1";
|
||||||
|
public static final int DEFAULT_TELNET_PORT = 2727;
|
||||||
|
public static final String DEFAULT_RSSI_REGEX = "(?:RSSI|Power)[:\\s]*(-?\\d+(?:\\.\\d+)?)";
|
||||||
|
public static final String DEFAULT_RANGE_REGEX = "range[:\\s]*([\\d.]+)";
|
||||||
|
|
||||||
|
private final SharedPreferences prefs;
|
||||||
|
|
||||||
|
public SettingsRepository(Context context) {
|
||||||
|
prefs = context.getApplicationContext()
|
||||||
|
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
|
migrateLegacyServerUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void migrateLegacyServerUrl() {
|
||||||
|
String current = prefs.getString(KEY_SERVER_URL, null);
|
||||||
|
if (current == null || !isLegacyServerUrl(current)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prefs.edit().putString(KEY_SERVER_URL, DEFAULT_SERVER).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isLegacyServerUrl(String url) {
|
||||||
|
if (url == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String u = url.trim().toLowerCase();
|
||||||
|
while (u.endsWith("/")) {
|
||||||
|
u = u.substring(0, u.length() - 1);
|
||||||
|
}
|
||||||
|
return u.equals(LEGACY_SERVER_HTTP)
|
||||||
|
|| u.equals("http://grigowashere.ru")
|
||||||
|
|| u.equals("https://grigowashere.ru:7634");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getServerUrl() {
|
||||||
|
return prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setServerUrl(String url) {
|
||||||
|
prefs.edit().putString(KEY_SERVER_URL, url).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTelnetHost() {
|
||||||
|
return prefs.getString(KEY_TELNET_HOST, DEFAULT_TELNET_HOST);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTelnetHost(String host) {
|
||||||
|
prefs.edit().putString(KEY_TELNET_HOST, host).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTelnetPort() {
|
||||||
|
return prefs.getInt(KEY_TELNET_PORT, DEFAULT_TELNET_PORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTelnetPort(int port) {
|
||||||
|
prefs.edit().putInt(KEY_TELNET_PORT, port).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRssiRegex() {
|
||||||
|
return prefs.getString(KEY_RSSI_REGEX, DEFAULT_RSSI_REGEX);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRssiRegex(String regex) {
|
||||||
|
prefs.edit().putString(KEY_RSSI_REGEX, regex).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRangeRegex() {
|
||||||
|
return prefs.getString(KEY_RANGE_REGEX, DEFAULT_RANGE_REGEX);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRangeRegex(String regex) {
|
||||||
|
prefs.edit().putString(KEY_RANGE_REGEX, regex).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTelnetEnabled() {
|
||||||
|
return prefs.getBoolean(KEY_TELNET_ENABLED, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTelnetEnabled(boolean enabled) {
|
||||||
|
prefs.edit().putBoolean(KEY_TELNET_ENABLED, enabled).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOrCreateDeviceId() {
|
||||||
|
String id = prefs.getString(KEY_DEVICE_ID, null);
|
||||||
|
if (id == null || id.isEmpty()) {
|
||||||
|
id = "android-" + java.util.UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
prefs.edit().putString(KEY_DEVICE_ID, id).apply();
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeviceLabel() {
|
||||||
|
return prefs.getString(KEY_DEVICE_LABEL, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceLabel(String label) {
|
||||||
|
if (label == null) {
|
||||||
|
prefs.edit().remove(KEY_DEVICE_LABEL).apply();
|
||||||
|
} else {
|
||||||
|
prefs.edit().putString(KEY_DEVICE_LABEL, label.trim()).apply();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
package com.grigowashere.loratester;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.grigowashere.loratester.api.ServerApi;
|
||||||
|
import com.grigowashere.loratester.api.TelemetryPayload;
|
||||||
|
import com.grigowashere.loratester.api.UploadQueue;
|
||||||
|
import com.grigowashere.loratester.net.NetworkMonitor;
|
||||||
|
import com.grigowashere.loratester.location.GeoUtils;
|
||||||
|
import com.grigowashere.loratester.telnet.AtCommandFormatter;
|
||||||
|
import com.grigowashere.loratester.telnet.RadioMacroBuilder;
|
||||||
|
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||||
|
import com.grigowashere.loratester.telnet.TelnetClient;
|
||||||
|
import com.grigowashere.loratester.telnet.TelnetFrameParser;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
public class TelemetryUploader implements TelnetClient.Listener {
|
||||||
|
|
||||||
|
private static final String TAG = "TelemetryUploader";
|
||||||
|
|
||||||
|
public interface AtSendCallback {
|
||||||
|
void onResult(TelnetClient.SendResult result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface StatsListener {
|
||||||
|
void onStatsUpdated(StatsExtractor.ExtractedStats stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final SettingsRepository settings;
|
||||||
|
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
|
private final ExecutorService uploadExecutor = Executors.newSingleThreadExecutor();
|
||||||
|
private final ExecutorService telnetExecutor = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "TelnetWorker");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
private TelnetClient telnetClient;
|
||||||
|
private TelnetFrameParser frameParser;
|
||||||
|
private StatsExtractor statsExtractor;
|
||||||
|
private ServerApi serverApi;
|
||||||
|
|
||||||
|
private static final int CONSOLE_MAX_CHARS = 16_384;
|
||||||
|
private static final long SNAPSHOT_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
|
private final ScheduledExecutorService snapshotScheduler =
|
||||||
|
Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "TelnetSnapshot");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
private volatile ScheduledFuture<?> snapshotFuture;
|
||||||
|
|
||||||
|
private volatile double lat = Double.NaN;
|
||||||
|
private volatile double lon = Double.NaN;
|
||||||
|
private volatile boolean connected;
|
||||||
|
private final StringBuilder consoleLog = new StringBuilder();
|
||||||
|
private volatile StatsExtractor.ExtractedStats lastStats;
|
||||||
|
private volatile long lastStatsAtMs;
|
||||||
|
private StatsListener statsListener;
|
||||||
|
private final UploadQueue uploadQueue;
|
||||||
|
private final NetworkMonitor networkMonitor;
|
||||||
|
|
||||||
|
public TelemetryUploader(
|
||||||
|
Context context,
|
||||||
|
SettingsRepository settings,
|
||||||
|
NetworkMonitor networkMonitor
|
||||||
|
) {
|
||||||
|
this.settings = settings;
|
||||||
|
this.networkMonitor = networkMonitor;
|
||||||
|
uploadQueue = new UploadQueue(context.getApplicationContext());
|
||||||
|
serverApi = new ServerApi(settings.getServerUrl());
|
||||||
|
statsExtractor = StatsExtractor.withDefaults();
|
||||||
|
frameParser = new TelnetFrameParser(this::onFrame);
|
||||||
|
networkMonitor.addListener(online -> {
|
||||||
|
if (online) {
|
||||||
|
flushUploadQueue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (networkMonitor.isOnline()) {
|
||||||
|
uploadExecutor.execute(this::flushUploadQueue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPendingUploadCount() {
|
||||||
|
return uploadQueue.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void refreshApi() {
|
||||||
|
serverApi = new ServerApi(settings.getServerUrl());
|
||||||
|
telnetExecutor.execute(() -> {
|
||||||
|
statsExtractor = new StatsExtractor(
|
||||||
|
settings.getRssiRegex(),
|
||||||
|
settings.getRangeRegex()
|
||||||
|
);
|
||||||
|
frameParser = new TelnetFrameParser(TelemetryUploader.this::onFrame);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateLocation(double lat, double lon) {
|
||||||
|
if (GeoUtils.isValidCoordinate(lat, lon)) {
|
||||||
|
this.lat = lat;
|
||||||
|
this.lon = lon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasGpsFix() {
|
||||||
|
return GeoUtils.isValidCoordinate(lat, lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getGpsLat() {
|
||||||
|
return lat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getGpsLon() {
|
||||||
|
return lon;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Double validLat() {
|
||||||
|
return GeoUtils.isValidCoordinate(lat, lon) ? lat : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Double validLon() {
|
||||||
|
return GeoUtils.isValidCoordinate(lat, lon) ? lon : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void startTelnet() {
|
||||||
|
serverApi = new ServerApi(settings.getServerUrl());
|
||||||
|
telnetExecutor.execute(() -> {
|
||||||
|
if (telnetClient != null) {
|
||||||
|
telnetClient.stop();
|
||||||
|
telnetClient = null;
|
||||||
|
}
|
||||||
|
statsExtractor = new StatsExtractor(
|
||||||
|
settings.getRssiRegex(),
|
||||||
|
settings.getRangeRegex()
|
||||||
|
);
|
||||||
|
frameParser = new TelnetFrameParser(TelemetryUploader.this::onFrame);
|
||||||
|
telnetClient = new TelnetClient(
|
||||||
|
settings.getTelnetHost(),
|
||||||
|
settings.getTelnetPort(),
|
||||||
|
TelemetryUploader.this
|
||||||
|
);
|
||||||
|
telnetClient.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stopTelnet() {
|
||||||
|
stopSnapshotTicker();
|
||||||
|
telnetExecutor.execute(() -> {
|
||||||
|
if (telnetClient != null) {
|
||||||
|
telnetClient.stop();
|
||||||
|
telnetClient = null;
|
||||||
|
}
|
||||||
|
if (frameParser != null) {
|
||||||
|
frameParser.flush();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTelnetConnected() {
|
||||||
|
return connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends AT command on telnet worker thread (safe while receiving data). */
|
||||||
|
public void sendAtCommand(String command, AtSendCallback callback) {
|
||||||
|
telnetExecutor.execute(() -> {
|
||||||
|
TelnetClient.SendResult result = sendAtCommandOnWorker(command);
|
||||||
|
if (callback != null) {
|
||||||
|
mainHandler.post(() -> callback.onResult(result));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendMacroSequence(List<String> lines, AtSendCallback callback) {
|
||||||
|
telnetExecutor.execute(() -> {
|
||||||
|
TelnetClient.SendResult last = TelnetClient.SendResult.SENT;
|
||||||
|
if (lines != null) {
|
||||||
|
for (int i = 0; i < lines.size(); i++) {
|
||||||
|
String line = lines.get(i);
|
||||||
|
if (line == null || line.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
last = sendLineOnWorker(line);
|
||||||
|
if (last != TelnetClient.SendResult.SENT) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (i < lines.size() - 1) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(150);
|
||||||
|
} catch (InterruptedException ignored) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (callback != null) {
|
||||||
|
TelnetClient.SendResult r = last;
|
||||||
|
mainHandler.post(() -> callback.onResult(r));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private TelnetClient.SendResult sendLineOnWorker(String line) {
|
||||||
|
if (RadioMacroBuilder.STOP.equals(line)) {
|
||||||
|
appendConsole(">> S\n");
|
||||||
|
if (telnetClient == null) {
|
||||||
|
appendConsole("!! telnet not started\n");
|
||||||
|
return TelnetClient.SendResult.NOT_CONNECTED;
|
||||||
|
}
|
||||||
|
TelnetClient.SendResult result = telnetClient.sendRawLine(line);
|
||||||
|
if (result != TelnetClient.SendResult.SENT) {
|
||||||
|
appendConsole("!! send failed: " + result + "\n");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return sendAtCommandOnWorker(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TelnetClient.SendResult sendAtCommandOnWorker(String command) {
|
||||||
|
String normalized = AtCommandFormatter.normalize(command);
|
||||||
|
appendConsole(">> " + normalized + "\n");
|
||||||
|
if (telnetClient == null) {
|
||||||
|
appendConsole("!! telnet not started\n");
|
||||||
|
return TelnetClient.SendResult.NOT_CONNECTED;
|
||||||
|
}
|
||||||
|
TelnetClient.SendResult result = telnetClient.sendAtCommand(command);
|
||||||
|
if (result != TelnetClient.SendResult.SENT) {
|
||||||
|
appendConsole("!! send failed: " + result + "\n");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized String getConsoleLog() {
|
||||||
|
return consoleLog.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void clearConsoleLog() {
|
||||||
|
consoleLog.setLength(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void appendConsole(String text) {
|
||||||
|
consoleLog.append(text);
|
||||||
|
if (consoleLog.length() > CONSOLE_MAX_CHARS) {
|
||||||
|
consoleLog.delete(0, consoleLog.length() - CONSOLE_MAX_CHARS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatsListener(StatsListener listener) {
|
||||||
|
this.statsListener = listener;
|
||||||
|
if (listener != null && lastStats != null) {
|
||||||
|
mainHandler.post(() -> listener.onStatsUpdated(lastStats));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public StatsExtractor.ExtractedStats getLastStats() {
|
||||||
|
return lastStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getLastStatsAtMs() {
|
||||||
|
return lastStatsAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onFrame(String frame) {
|
||||||
|
StatsExtractor.ExtractedStats stats = statsExtractor.extract(frame);
|
||||||
|
if (!stats.hasRadioFrame()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastStats = stats;
|
||||||
|
lastStatsAtMs = System.currentTimeMillis();
|
||||||
|
StatsListener listener = statsListener;
|
||||||
|
if (listener != null) {
|
||||||
|
mainHandler.post(() -> listener.onStatsUpdated(stats));
|
||||||
|
}
|
||||||
|
TelemetryPayload payload = new TelemetryPayload(
|
||||||
|
settings.getOrCreateDeviceId(),
|
||||||
|
phoneLabel(),
|
||||||
|
validLat(),
|
||||||
|
validLon(),
|
||||||
|
stats.rssi,
|
||||||
|
stats.rangeM,
|
||||||
|
null,
|
||||||
|
stats.metaJson,
|
||||||
|
stats.role,
|
||||||
|
System.currentTimeMillis() / 1000.0
|
||||||
|
);
|
||||||
|
uploadExecutor.execute(() -> uploadTelemetry(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void registerPresence() {
|
||||||
|
uploadExecutor.execute(() -> {
|
||||||
|
TelemetryPayload payload = new TelemetryPayload(
|
||||||
|
settings.getOrCreateDeviceId(),
|
||||||
|
phoneLabel(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
System.currentTimeMillis() / 1000.0
|
||||||
|
);
|
||||||
|
uploadTelemetry(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private String phoneLabel() {
|
||||||
|
String custom = settings.getDeviceLabel();
|
||||||
|
if (custom != null && !custom.isBlank()) {
|
||||||
|
return custom.trim();
|
||||||
|
}
|
||||||
|
String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER : "";
|
||||||
|
String model = Build.MODEL != null ? Build.MODEL : "";
|
||||||
|
String label = (manufacturer + " " + model).trim();
|
||||||
|
return label.isEmpty() ? null : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,16 @@
|
|||||||
|
package com.grigowashere.loratester.api;
|
||||||
|
|
||||||
|
public class DeviceInfo {
|
||||||
|
public String device_id;
|
||||||
|
public String label;
|
||||||
|
public double last_seen;
|
||||||
|
public Double lat;
|
||||||
|
public Double lon;
|
||||||
|
public Double rssi;
|
||||||
|
public Double range_m;
|
||||||
|
public String raw_frame;
|
||||||
|
public String meta;
|
||||||
|
/** TX or RX from last telnet frame. */
|
||||||
|
public String role;
|
||||||
|
public Double ts;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.grigowashere.loratester.api;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class ElevationGridResult {
|
||||||
|
public boolean ok;
|
||||||
|
public String error;
|
||||||
|
public Center center;
|
||||||
|
public double radius_m;
|
||||||
|
public double step_m;
|
||||||
|
public double min_delta_m;
|
||||||
|
public double max_delta_m;
|
||||||
|
public List<GridPoint> points;
|
||||||
|
|
||||||
|
public static class Center {
|
||||||
|
public double lat;
|
||||||
|
public double lon;
|
||||||
|
public double elevation_m;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class GridPoint {
|
||||||
|
public int i;
|
||||||
|
public int j;
|
||||||
|
public double lat;
|
||||||
|
public double lon;
|
||||||
|
public double dist_m;
|
||||||
|
public Double elevation_m;
|
||||||
|
public double delta_m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.grigowashere.loratester.api;
|
||||||
|
|
||||||
|
public class NearestHillResult {
|
||||||
|
public boolean ok;
|
||||||
|
public String error;
|
||||||
|
public HillPoint center;
|
||||||
|
public HillPoint hill;
|
||||||
|
public double radius_m;
|
||||||
|
public int candidates;
|
||||||
|
|
||||||
|
public static class HillPoint {
|
||||||
|
public double lat;
|
||||||
|
public double lon;
|
||||||
|
public Double elevation_m;
|
||||||
|
public Double dist_m;
|
||||||
|
public Double prominence_m;
|
||||||
|
public Boolean is_local_max;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.grigowashere.loratester.api;
|
||||||
|
|
||||||
|
public class PairedTrackSession {
|
||||||
|
public long id;
|
||||||
|
public String device_a;
|
||||||
|
public String device_b;
|
||||||
|
public String initiator;
|
||||||
|
public String status;
|
||||||
|
public double start_at;
|
||||||
|
public Long track_id_a;
|
||||||
|
public Long track_id_b;
|
||||||
|
public double created_at;
|
||||||
|
public double server_time;
|
||||||
|
public boolean ready;
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
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.deviceLabel != null && !payload.deviceLabel.isBlank()) {
|
||||||
|
body.put("device_label", payload.deviceLabel);
|
||||||
|
}
|
||||||
|
if (payload.lat != null) body.put("lat", payload.lat);
|
||||||
|
if (payload.lon != null) body.put("lon", payload.lon);
|
||||||
|
if (payload.rssi != null) body.put("rssi", payload.rssi);
|
||||||
|
if (payload.rangeM != null) body.put("range_m", payload.rangeM);
|
||||||
|
if (payload.meta != null) {
|
||||||
|
try {
|
||||||
|
JsonObject meta = JsonParser.parseString(payload.meta).getAsJsonObject();
|
||||||
|
body.put("meta", GSON.fromJson(meta, Map.class));
|
||||||
|
if (meta.has("fields") && meta.get("fields").isJsonObject()) {
|
||||||
|
body.put("fields", GSON.fromJson(meta.get("fields"), Map.class));
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
body.put("meta", payload.meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload.role != null) body.put("role", payload.role);
|
||||||
|
if (payload.ts != null) body.put("ts", payload.ts);
|
||||||
|
postJson("/api/telemetry", body, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DeviceInfo> getDevices() throws IOException {
|
||||||
|
return getJsonList("/api/devices", DEVICE_LIST);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void postChat(String deviceId, String text) throws IOException {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("device_id", deviceId);
|
||||||
|
body.put("text", text);
|
||||||
|
postJson("/api/chat", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ChatMessage> getChat(double since) throws IOException {
|
||||||
|
String path = "/api/chat?since=" + since;
|
||||||
|
return getJsonList(path, CHAT_LIST);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TelemetryHistoryItem> getTelemetryHistory(String deviceId, int limit)
|
||||||
|
throws IOException {
|
||||||
|
String path = "/api/telemetry?device_id=" + deviceId + "&limit=" + limit;
|
||||||
|
return getJsonList(path, TELEMETRY_HISTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long startTrack(String deviceId) throws IOException {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("device_id", deviceId);
|
||||||
|
Map<String, Object> resp = postJsonMap("/api/tracks/start", body, true);
|
||||||
|
Number id = (Number) resp.get("track_id");
|
||||||
|
return id.longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addTrackPoints(long trackId, List<Map<String, Object>> points) throws IOException {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("points", points);
|
||||||
|
postJson("/api/tracks/" + trackId + "/points", body, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void finishTrack(long trackId) throws IOException {
|
||||||
|
postJson("/api/tracks/" + trackId + "/finish", new HashMap<>(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TrackInfo> listTracks(String deviceId) throws IOException {
|
||||||
|
return getJsonList("/api/tracks?device_id=" + deviceId + "&limit=50", TRACK_LIST);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void postCommand(
|
||||||
|
String fromDeviceId,
|
||||||
|
String toDeviceId,
|
||||||
|
String kind,
|
||||||
|
Map<String, Object> payload
|
||||||
|
) throws IOException {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("from_device_id", fromDeviceId);
|
||||||
|
body.put("to_device_id", toDeviceId);
|
||||||
|
body.put("kind", kind);
|
||||||
|
if (payload != null) {
|
||||||
|
body.put("payload", payload);
|
||||||
|
}
|
||||||
|
postJson("/api/commands", body, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DeviceCommand> pollPendingCommands(String deviceId) throws IOException {
|
||||||
|
String path = "/api/commands/pending?device_id="
|
||||||
|
+ java.net.URLEncoder.encode(deviceId, "UTF-8") + "&limit=20";
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(baseUrl + path)
|
||||||
|
.header(HEADER_LORA_CLIENT, CLIENT_ANDROID)
|
||||||
|
.get()
|
||||||
|
.build();
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
throw new IOException("HTTP " + response.code());
|
||||||
|
}
|
||||||
|
return GSON.fromJson(response.body().string(), COMMAND_LIST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> startPairedTrack(Map<String, Object> body) throws IOException {
|
||||||
|
return postJsonMap("/api/paired-tracks/start", body, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> getActivePairedTrack() throws IOException {
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(baseUrl + "/api/paired-tracks/active")
|
||||||
|
.get()
|
||||||
|
.build();
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
throw new IOException("HTTP " + response.code());
|
||||||
|
}
|
||||||
|
return GSON.fromJson(response.body().string(), Map.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ackPairedTrack(long sessionId, String deviceId, long trackId) throws IOException {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("session_id", sessionId);
|
||||||
|
body.put("device_id", deviceId);
|
||||||
|
body.put("track_id", trackId);
|
||||||
|
postJson("/api/paired-tracks/ack", body, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void cancelPairedTrack(Long sessionId) throws IOException {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
if (sessionId != null) {
|
||||||
|
body.put("session_id", sessionId);
|
||||||
|
}
|
||||||
|
postJson("/api/paired-tracks/cancel", body, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackDetail getTrack(long trackId) throws IOException {
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(baseUrl + "/api/tracks/" + trackId)
|
||||||
|
.get()
|
||||||
|
.build();
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
throw new IOException("HTTP " + response.code());
|
||||||
|
}
|
||||||
|
return GSON.fromJson(response.body().string(), TrackDetail.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> getHealth() throws IOException {
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(baseUrl + "/api/health")
|
||||||
|
.get()
|
||||||
|
.build();
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
throw new IOException("HTTP " + response.code());
|
||||||
|
}
|
||||||
|
return GSON.fromJson(response.body().string(), Map.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public NearestHillResult findNearestHill(double lat, double lon, int radiusM)
|
||||||
|
throws IOException {
|
||||||
|
String path = "/api/elevation/nearest-hill?lat="
|
||||||
|
+ lat
|
||||||
|
+ "&lon="
|
||||||
|
+ lon
|
||||||
|
+ "&radius_m="
|
||||||
|
+ radiusM;
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(baseUrl + path)
|
||||||
|
.get()
|
||||||
|
.build();
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
throw new IOException("HTTP " + response.code());
|
||||||
|
}
|
||||||
|
return GSON.fromJson(response.body().string(), NearestHillResult.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ElevationGridResult getElevationGrid(double lat, double lon, int radiusM, int stepM)
|
||||||
|
throws IOException {
|
||||||
|
String path = "/api/elevation/grid?lat="
|
||||||
|
+ lat
|
||||||
|
+ "&lon="
|
||||||
|
+ lon
|
||||||
|
+ "&radius_m="
|
||||||
|
+ radiusM
|
||||||
|
+ "&step_m="
|
||||||
|
+ stepM;
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(baseUrl + path)
|
||||||
|
.get()
|
||||||
|
.build();
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
throw new IOException("HTTP " + response.code());
|
||||||
|
}
|
||||||
|
return GSON.fromJson(response.body().string(), ElevationGridResult.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> postJsonMap(String path, Map<String, Object> body, boolean android)
|
||||||
|
throws IOException {
|
||||||
|
Request.Builder builder = new Request.Builder()
|
||||||
|
.url(baseUrl + path)
|
||||||
|
.post(RequestBody.create(GSON.toJson(body), JSON));
|
||||||
|
if (android) {
|
||||||
|
builder.header(HEADER_LORA_CLIENT, CLIENT_ANDROID);
|
||||||
|
}
|
||||||
|
try (Response response = client.newCall(builder.build()).execute()) {
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
throw new IOException("HTTP " + response.code());
|
||||||
|
}
|
||||||
|
return GSON.fromJson(response.body().string(), Map.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postJson(String path, Map<String, Object> body) throws IOException {
|
||||||
|
postJson(path, body, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postJson(String path, Map<String, Object> body, boolean androidClient)
|
||||||
|
throws IOException {
|
||||||
|
Request.Builder builder = new Request.Builder()
|
||||||
|
.url(baseUrl + path)
|
||||||
|
.post(RequestBody.create(GSON.toJson(body), JSON));
|
||||||
|
if (androidClient) {
|
||||||
|
builder.header(HEADER_LORA_CLIENT, CLIENT_ANDROID);
|
||||||
|
}
|
||||||
|
execute(builder.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T getJsonList(String path, Type type) throws IOException {
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(baseUrl + path)
|
||||||
|
.get()
|
||||||
|
.build();
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
throw new IOException("HTTP " + response.code());
|
||||||
|
}
|
||||||
|
return GSON.fromJson(response.body().string(), type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void execute(Request request) throws IOException {
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
if (!response.isSuccessful()) {
|
||||||
|
throw new IOException("HTTP " + response.code());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.grigowashere.loratester.api;
|
||||||
|
|
||||||
|
public class TelemetryHistoryItem {
|
||||||
|
public long id;
|
||||||
|
public String device_id;
|
||||||
|
public Double lat;
|
||||||
|
public Double lon;
|
||||||
|
public Double rssi;
|
||||||
|
public Double range_m;
|
||||||
|
public String meta;
|
||||||
|
public String role;
|
||||||
|
public double ts;
|
||||||
|
public String source;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.grigowashere.loratester.api;
|
||||||
|
|
||||||
|
public class TelemetryPayload {
|
||||||
|
public final String deviceId;
|
||||||
|
public final String deviceLabel;
|
||||||
|
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, null, lat, lon, rssi, rangeM, rawFrame, meta, role, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TelemetryPayload(
|
||||||
|
String deviceId,
|
||||||
|
String deviceLabel,
|
||||||
|
Double lat,
|
||||||
|
Double lon,
|
||||||
|
Double rssi,
|
||||||
|
Double rangeM,
|
||||||
|
String rawFrame,
|
||||||
|
String meta,
|
||||||
|
String role,
|
||||||
|
Double ts
|
||||||
|
) {
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
this.deviceLabel = deviceLabel;
|
||||||
|
this.lat = lat;
|
||||||
|
this.lon = lon;
|
||||||
|
this.rssi = rssi;
|
||||||
|
this.rangeM = rangeM;
|
||||||
|
this.rawFrame = rawFrame;
|
||||||
|
this.meta = meta;
|
||||||
|
this.role = role;
|
||||||
|
this.ts = ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.grigowashere.loratester.api;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class TrackDetail {
|
||||||
|
public long id;
|
||||||
|
public String device_id;
|
||||||
|
public double started_at;
|
||||||
|
public Double ended_at;
|
||||||
|
public String label;
|
||||||
|
public List<TrackPoint> points;
|
||||||
|
|
||||||
|
public static class TrackPoint {
|
||||||
|
public double ts;
|
||||||
|
public double lat;
|
||||||
|
public double lon;
|
||||||
|
public Double altitude_gps;
|
||||||
|
public Double elevation_m;
|
||||||
|
public Double rssi;
|
||||||
|
public String role;
|
||||||
|
public String meta;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.grigowashere.loratester.api;
|
||||||
|
|
||||||
|
public class TrackInfo {
|
||||||
|
public long id;
|
||||||
|
public String device_id;
|
||||||
|
public double started_at;
|
||||||
|
public Double ended_at;
|
||||||
|
public String label;
|
||||||
|
public int point_count;
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package com.grigowashere.loratester.api;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.reflect.TypeToken;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileReader;
|
||||||
|
import java.io.FileWriter;
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** Persistent queue for telemetry uploads when the network is down. */
|
||||||
|
public class UploadQueue {
|
||||||
|
|
||||||
|
private static final String TAG = "UploadQueue";
|
||||||
|
private static final String FILE_NAME = "telemetry_upload_queue.json";
|
||||||
|
private static final int MAX_ITEMS = 500;
|
||||||
|
|
||||||
|
private static final Gson GSON = new Gson();
|
||||||
|
private static final Type LIST_TYPE = new TypeToken<List<QueuedItem>>() {}.getType();
|
||||||
|
|
||||||
|
private final File queueFile;
|
||||||
|
private final List<QueuedItem> items = new ArrayList<>();
|
||||||
|
private String lastMeta;
|
||||||
|
|
||||||
|
public UploadQueue(Context context) {
|
||||||
|
queueFile = new File(context.getFilesDir(), FILE_NAME);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized int size() {
|
||||||
|
return items.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void enqueue(TelemetryPayload payload) {
|
||||||
|
if (payload == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.meta != null && payload.meta.equals(lastMeta)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastMeta = payload.meta;
|
||||||
|
items.add(QueuedItem.from(payload));
|
||||||
|
trim();
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized int flushAll(ServerApi api) {
|
||||||
|
if (api == null || items.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int sent = 0;
|
||||||
|
Iterator<QueuedItem> it = items.iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
QueuedItem item = it.next();
|
||||||
|
try {
|
||||||
|
api.postTelemetry(item.toPayload());
|
||||||
|
it.remove();
|
||||||
|
sent++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "flush stopped at " + sent + " sent", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sent > 0) {
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void trim() {
|
||||||
|
while (items.size() > MAX_ITEMS) {
|
||||||
|
items.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void load() {
|
||||||
|
if (!queueFile.exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (FileReader reader = new FileReader(queueFile)) {
|
||||||
|
List<QueuedItem> loaded = GSON.fromJson(reader, LIST_TYPE);
|
||||||
|
if (loaded != null) {
|
||||||
|
items.clear();
|
||||||
|
items.addAll(loaded);
|
||||||
|
trim();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "load queue failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void persist() {
|
||||||
|
try (FileWriter writer = new FileWriter(queueFile)) {
|
||||||
|
GSON.toJson(items, writer);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "persist queue failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class QueuedItem {
|
||||||
|
String deviceId;
|
||||||
|
Double lat;
|
||||||
|
Double lon;
|
||||||
|
Double rssi;
|
||||||
|
Double rangeM;
|
||||||
|
String rawFrame;
|
||||||
|
String meta;
|
||||||
|
String role;
|
||||||
|
Double ts;
|
||||||
|
|
||||||
|
static QueuedItem from(TelemetryPayload p) {
|
||||||
|
QueuedItem q = new QueuedItem();
|
||||||
|
q.deviceId = p.deviceId;
|
||||||
|
q.lat = p.lat;
|
||||||
|
q.lon = p.lon;
|
||||||
|
q.rssi = p.rssi;
|
||||||
|
q.rangeM = p.rangeM;
|
||||||
|
q.rawFrame = p.rawFrame;
|
||||||
|
q.meta = p.meta;
|
||||||
|
q.role = p.role;
|
||||||
|
q.ts = p.ts;
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
TelemetryPayload toPayload() {
|
||||||
|
return new TelemetryPayload(
|
||||||
|
deviceId, lat, lon, rssi, rangeM, rawFrame, meta, role, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (!(o instanceof QueuedItem other)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Objects.equals(deviceId, other.deviceId)
|
||||||
|
&& Objects.equals(meta, other.meta)
|
||||||
|
&& Objects.equals(ts, other.ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(deviceId, meta, ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.grigowashere.loratester.location;
|
||||||
|
|
||||||
|
public final class GeoUtils {
|
||||||
|
|
||||||
|
private static final double ZERO_EPS = 1e-5;
|
||||||
|
|
||||||
|
private GeoUtils() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isValidCoordinate(double lat, double lon) {
|
||||||
|
if (Double.isNaN(lat) || Double.isNaN(lon) || Double.isInfinite(lat) || Double.isInfinite(lon)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Math.abs(lat) < ZERO_EPS && Math.abs(lon) < ZERO_EPS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return lat >= -90.0 && lat <= 90.0 && lon >= -180.0 && lon <= 180.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isValidCoordinate(Double lat, Double lon) {
|
||||||
|
return lat != null && lon != null && isValidCoordinate(lat.doubleValue(), lon.doubleValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double haversineMeters(double lat1, double lon1, double lat2, double lon2) {
|
||||||
|
final double r = 6_371_000;
|
||||||
|
double dLat = Math.toRadians(lat2 - lat1);
|
||||||
|
double dLon = Math.toRadians(lon2 - lon1);
|
||||||
|
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
|
||||||
|
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
|
||||||
|
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||||
|
return r * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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)
|
||||||
|
.setMaxUpdateDelayMillis(15_000L)
|
||||||
|
.setWaitForAccurateLocation(false)
|
||||||
|
.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,37 @@
|
|||||||
|
package com.grigowashere.loratester.map;
|
||||||
|
|
||||||
|
import org.mapsforge.core.model.Tile;
|
||||||
|
import org.mapsforge.map.layer.download.tilesource.OnlineTileSource;
|
||||||
|
|
||||||
|
import java.net.MalformedURLException;
|
||||||
|
import java.net.URL;
|
||||||
|
|
||||||
|
/** Esri World Imagery — tile path is zoom/y/x (not OSM zoom/x/y). */
|
||||||
|
public final class EsriWorldImagery extends OnlineTileSource {
|
||||||
|
|
||||||
|
public static final EsriWorldImagery INSTANCE = new EsriWorldImagery();
|
||||||
|
|
||||||
|
private EsriWorldImagery() {
|
||||||
|
super(new String[]{"server.arcgisonline.com"}, 443);
|
||||||
|
setName("Esri.WorldImagery")
|
||||||
|
.setAlpha(false)
|
||||||
|
.setBaseUrl("/ArcGIS/rest/services/World_Imagery/MapServer/tile/")
|
||||||
|
.setExtension("png")
|
||||||
|
.setParallelRequestsLimit(4)
|
||||||
|
.setProtocol("https")
|
||||||
|
.setTileSize(256)
|
||||||
|
.setZoomLevelMax((byte) 18)
|
||||||
|
.setZoomLevelMin((byte) 0);
|
||||||
|
setUserAgent("LoraTester/1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public URL getTileUrl(Tile tile) throws MalformedURLException {
|
||||||
|
StringBuilder path = new StringBuilder(48);
|
||||||
|
path.append(getBaseUrl());
|
||||||
|
path.append(tile.zoomLevel).append('/');
|
||||||
|
path.append(tile.tileY).append('/');
|
||||||
|
path.append(tile.tileX).append('.').append(getExtension());
|
||||||
|
return new URL(getProtocol(), getHostName(), 443, path.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package com.grigowashere.loratester.model;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** Normalized radio stats from telemetry meta JSON (no duplicate fields). */
|
||||||
|
public final class RadioSnapshot {
|
||||||
|
|
||||||
|
public final String role;
|
||||||
|
public final String frame;
|
||||||
|
public final Double frequencyMhz;
|
||||||
|
public final Integer sf;
|
||||||
|
public final Double bwKhz;
|
||||||
|
public final Double powerDbm;
|
||||||
|
public final Double rssiDbm;
|
||||||
|
public final Double snrDb;
|
||||||
|
public final Integer packet;
|
||||||
|
public final String payload;
|
||||||
|
public final Double onAirMs;
|
||||||
|
public final Double txPktPerS;
|
||||||
|
public final Double rxPktPerS;
|
||||||
|
public final Double perPercent;
|
||||||
|
public final Double rxQualityPercent;
|
||||||
|
public final String codeRate;
|
||||||
|
public final Integer preambleLength;
|
||||||
|
public final String lowDataRateOpt;
|
||||||
|
public final Boolean crcEnabled;
|
||||||
|
public final Integer payloadLengthBytes;
|
||||||
|
public final Double txTimeoutMs;
|
||||||
|
public final Integer packetReceive;
|
||||||
|
public final Integer packetTotal;
|
||||||
|
public final Integer packetError;
|
||||||
|
public final Integer crcError;
|
||||||
|
public final Integer preambleDetected;
|
||||||
|
public final Integer headerValid;
|
||||||
|
public final Map<String, String> extraFields;
|
||||||
|
|
||||||
|
public RadioSnapshot(
|
||||||
|
String role,
|
||||||
|
String frame,
|
||||||
|
Double frequencyMhz,
|
||||||
|
Integer sf,
|
||||||
|
Double bwKhz,
|
||||||
|
Double powerDbm,
|
||||||
|
Double rssiDbm,
|
||||||
|
Double snrDb,
|
||||||
|
Integer packet,
|
||||||
|
String payload,
|
||||||
|
Double onAirMs,
|
||||||
|
Double txPktPerS,
|
||||||
|
Double rxPktPerS,
|
||||||
|
Double perPercent,
|
||||||
|
Double rxQualityPercent,
|
||||||
|
String codeRate,
|
||||||
|
Integer preambleLength,
|
||||||
|
String lowDataRateOpt,
|
||||||
|
Boolean crcEnabled,
|
||||||
|
Integer payloadLengthBytes,
|
||||||
|
Double txTimeoutMs,
|
||||||
|
Integer packetReceive,
|
||||||
|
Integer packetTotal,
|
||||||
|
Integer packetError,
|
||||||
|
Integer crcError,
|
||||||
|
Integer preambleDetected,
|
||||||
|
Integer headerValid,
|
||||||
|
Map<String, String> extraFields
|
||||||
|
) {
|
||||||
|
this.role = role;
|
||||||
|
this.frame = frame;
|
||||||
|
this.frequencyMhz = frequencyMhz;
|
||||||
|
this.sf = sf;
|
||||||
|
this.bwKhz = bwKhz;
|
||||||
|
this.powerDbm = powerDbm;
|
||||||
|
this.rssiDbm = rssiDbm;
|
||||||
|
this.snrDb = snrDb;
|
||||||
|
this.packet = packet;
|
||||||
|
this.payload = payload;
|
||||||
|
this.onAirMs = onAirMs;
|
||||||
|
this.txPktPerS = txPktPerS;
|
||||||
|
this.rxPktPerS = rxPktPerS;
|
||||||
|
this.perPercent = perPercent;
|
||||||
|
this.rxQualityPercent = rxQualityPercent;
|
||||||
|
this.codeRate = codeRate;
|
||||||
|
this.preambleLength = preambleLength;
|
||||||
|
this.lowDataRateOpt = lowDataRateOpt;
|
||||||
|
this.crcEnabled = crcEnabled;
|
||||||
|
this.payloadLengthBytes = payloadLengthBytes;
|
||||||
|
this.txTimeoutMs = txTimeoutMs;
|
||||||
|
this.packetReceive = packetReceive;
|
||||||
|
this.packetTotal = packetTotal;
|
||||||
|
this.packetError = packetError;
|
||||||
|
this.crcError = crcError;
|
||||||
|
this.preambleDetected = preambleDetected;
|
||||||
|
this.headerValid = headerValid;
|
||||||
|
this.extraFields = extraFields != null ? extraFields : Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RadioSnapshot empty() {
|
||||||
|
return new RadioSnapshot(null, null, null, null, null, null, null, null,
|
||||||
|
null, null, null, null, null, null, null,
|
||||||
|
null, null, null, null, null, null, null, null, null, null, null, null,
|
||||||
|
Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RadioSnapshot fromMeta(String metaJson, String roleFallback, Double rssiFallback) {
|
||||||
|
if (metaJson == null || metaJson.isBlank()) {
|
||||||
|
RadioSnapshot snap = empty();
|
||||||
|
if (roleFallback != null || rssiFallback != null) {
|
||||||
|
return new RadioSnapshot(roleFallback, null, null, null, null, null,
|
||||||
|
rssiFallback, null, null, null, null, null, null, null, null,
|
||||||
|
null, null, null, null, null, null, null, null, null, null, null, null,
|
||||||
|
Map.of());
|
||||||
|
}
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonObject o = JsonParser.parseString(metaJson).getAsJsonObject();
|
||||||
|
String role = text(o, "role");
|
||||||
|
if (role == null) {
|
||||||
|
role = roleFallback;
|
||||||
|
}
|
||||||
|
Double rssi = dbl(o, "rssi_dbm");
|
||||||
|
if (rssi == null) {
|
||||||
|
rssi = rssiFallback;
|
||||||
|
}
|
||||||
|
Map<String, String> extra = new LinkedHashMap<>();
|
||||||
|
JsonElement fieldsEl = o.get("fields");
|
||||||
|
if (fieldsEl != null && fieldsEl.isJsonObject()) {
|
||||||
|
for (Map.Entry<String, JsonElement> e : fieldsEl.getAsJsonObject().entrySet()) {
|
||||||
|
String label = e.getKey();
|
||||||
|
if (isKnownFieldLabel(label)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
extra.put(label, e.getValue().getAsString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new RadioSnapshot(
|
||||||
|
role,
|
||||||
|
text(o, "frame"),
|
||||||
|
hzToMhz(lng(o, "frequency_hz")),
|
||||||
|
integer(o, "spreading_factor"),
|
||||||
|
dbl(o, "bandwidth_khz"),
|
||||||
|
dbl(o, "power_dbm"),
|
||||||
|
rssi,
|
||||||
|
dbl(o, "snr_db"),
|
||||||
|
integer(o, "packet"),
|
||||||
|
text(o, "payload"),
|
||||||
|
dbl(o, "on_air_ms"),
|
||||||
|
dbl(o, "tx_pkt_per_s"),
|
||||||
|
dbl(o, "rx_pkt_per_s"),
|
||||||
|
dbl(o, "per_percent"),
|
||||||
|
dbl(o, "rx_quality_percent"),
|
||||||
|
text(o, "code_rate"),
|
||||||
|
integer(o, "preamble_length"),
|
||||||
|
text(o, "low_data_rate_opt"),
|
||||||
|
bool(o, "crc_enabled"),
|
||||||
|
integer(o, "payload_length_bytes"),
|
||||||
|
dbl(o, "tx_timeout_ms"),
|
||||||
|
integer(o, "packet_receive"),
|
||||||
|
integer(o, "packet_total"),
|
||||||
|
integer(o, "packet_error"),
|
||||||
|
integer(o, "crc_error"),
|
||||||
|
integer(o, "preamble_detected"),
|
||||||
|
integer(o, "header_valid"),
|
||||||
|
extra
|
||||||
|
);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return new RadioSnapshot(roleFallback, null, null, null, null, null,
|
||||||
|
rssiFallback, null, null, null, null, null, null, null, null,
|
||||||
|
null, null, null, null, null, null, null, null, null, null, null, null,
|
||||||
|
Map.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RadioSnapshot fromExtracted(StatsExtractor.ExtractedStats stats) {
|
||||||
|
if (stats == null) {
|
||||||
|
return empty();
|
||||||
|
}
|
||||||
|
return fromMeta(stats.metaJson, stats.role, stats.rssiDbm != null ? stats.rssiDbm : stats.rssi);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<String> diff(RadioSnapshot prev) {
|
||||||
|
Set<String> changed = new HashSet<>();
|
||||||
|
if (prev == null) {
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
cmp(changed, "role", role, prev.role);
|
||||||
|
cmp(changed, "rssi", rssiDbm, prev.rssiDbm);
|
||||||
|
cmp(changed, "snr", snrDb, prev.snrDb);
|
||||||
|
cmp(changed, "packet", packet, prev.packet);
|
||||||
|
cmp(changed, "payload", payload, prev.payload);
|
||||||
|
cmp(changed, "per", perPercent, prev.perPercent);
|
||||||
|
cmp(changed, "rxQuality", rxQualityPercent, prev.rxQualityPercent);
|
||||||
|
cmp(changed, "txSpeed", txPktPerS, prev.txPktPerS);
|
||||||
|
cmp(changed, "rxSpeed", rxPktPerS, prev.rxPktPerS);
|
||||||
|
cmp(changed, "frequency", frequencyMhz, prev.frequencyMhz);
|
||||||
|
cmp(changed, "sf", sf, prev.sf);
|
||||||
|
cmp(changed, "bw", bwKhz, prev.bwKhz);
|
||||||
|
cmp(changed, "power", powerDbm, prev.powerDbm);
|
||||||
|
cmp(changed, "packetReceive", packetReceive, prev.packetReceive);
|
||||||
|
cmp(changed, "packetTotal", packetTotal, prev.packetTotal);
|
||||||
|
cmp(changed, "packetError", packetError, prev.packetError);
|
||||||
|
cmp(changed, "crcError", crcError, prev.crcError);
|
||||||
|
cmp(changed, "preambleDetected", preambleDetected, prev.preambleDetected);
|
||||||
|
cmp(changed, "headerValid", headerValid, prev.headerValid);
|
||||||
|
cmp(changed, "codeRate", codeRate, prev.codeRate);
|
||||||
|
cmp(changed, "crc", crcEnabled, prev.crcEnabled);
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void cmp(Set<String> changed, String key, Object a, Object b) {
|
||||||
|
if (!Objects.equals(a, b)) {
|
||||||
|
changed.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isKnownFieldLabel(String label) {
|
||||||
|
String n = label.toLowerCase(Locale.ROOT).trim();
|
||||||
|
return n.equals("send") || n.equals("receive")
|
||||||
|
|| n.contains("frequency") || n.equals("power") || n.equals("rssi")
|
||||||
|
|| n.equals("snr") || n.contains("spreading") || n.contains("bandwidth")
|
||||||
|
|| n.equals("packet") || n.contains("packet number") || n.equals("payload")
|
||||||
|
|| n.contains("packet receive") || n.contains("packet total") || n.contains("packet error")
|
||||||
|
|| n.contains("crc error") || n.contains("preamble detected") || n.contains("header valid")
|
||||||
|
|| n.contains("on air") || n.contains("tx speed") || n.contains("rx speed")
|
||||||
|
|| n.equals("per") || n.contains("rx quality") || n.contains("tx timeout")
|
||||||
|
|| n.contains("code rate") || n.contains("preamble length")
|
||||||
|
|| n.contains("low data rate") || n.equals("crc") || n.contains("payload length");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(JsonObject o, String key) {
|
||||||
|
JsonElement e = o.get(key);
|
||||||
|
return e != null && !e.isJsonNull() ? e.getAsString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer integer(JsonObject o, String key) {
|
||||||
|
JsonElement e = o.get(key);
|
||||||
|
return e != null && e.isJsonPrimitive() ? e.getAsInt() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double dbl(JsonObject o, String key) {
|
||||||
|
JsonElement e = o.get(key);
|
||||||
|
return e != null && e.isJsonPrimitive() ? e.getAsDouble() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Boolean bool(JsonObject o, String key) {
|
||||||
|
JsonElement e = o.get(key);
|
||||||
|
return e != null && e.isJsonPrimitive() ? e.getAsBoolean() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long lng(JsonObject o, String key) {
|
||||||
|
JsonElement e = o.get(key);
|
||||||
|
return e != null && e.isJsonPrimitive() ? e.getAsLong() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double hzToMhz(Long hz) {
|
||||||
|
if (hz == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return hz / 1_000_000.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.grigowashere.loratester.net;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.net.ConnectivityManager;
|
||||||
|
import android.net.Network;
|
||||||
|
import android.net.NetworkCapabilities;
|
||||||
|
import android.net.NetworkRequest;
|
||||||
|
import android.os.Build;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
|
||||||
|
/** Observes validated internet connectivity. */
|
||||||
|
public class NetworkMonitor {
|
||||||
|
|
||||||
|
public interface Listener {
|
||||||
|
void onConnectivityChanged(boolean online);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ConnectivityManager connectivityManager;
|
||||||
|
private final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
|
||||||
|
private volatile boolean online;
|
||||||
|
private ConnectivityManager.NetworkCallback networkCallback;
|
||||||
|
|
||||||
|
public NetworkMonitor(@NonNull Context context) {
|
||||||
|
connectivityManager =
|
||||||
|
(ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||||
|
online = checkOnline();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isOnline() {
|
||||||
|
return online;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addListener(@NonNull Listener listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
listener.onConnectivityChanged(online);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeListener(@NonNull Listener listener) {
|
||||||
|
listeners.remove(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
if (networkCallback != null || connectivityManager == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
networkCallback = new ConnectivityManager.NetworkCallback() {
|
||||||
|
@Override
|
||||||
|
public void onAvailable(@NonNull Network network) {
|
||||||
|
updateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLost(@NonNull Network network) {
|
||||||
|
updateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCapabilitiesChanged(
|
||||||
|
@NonNull Network network,
|
||||||
|
@NonNull NetworkCapabilities caps
|
||||||
|
) {
|
||||||
|
updateState();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
NetworkRequest request = new NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.build();
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
connectivityManager.registerDefaultNetworkCallback(networkCallback);
|
||||||
|
} else {
|
||||||
|
connectivityManager.registerNetworkCallback(request, networkCallback);
|
||||||
|
}
|
||||||
|
updateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
if (networkCallback != null && connectivityManager != null) {
|
||||||
|
try {
|
||||||
|
connectivityManager.unregisterNetworkCallback(networkCallback);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
networkCallback = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateState() {
|
||||||
|
boolean now = checkOnline();
|
||||||
|
if (now == online) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
online = now;
|
||||||
|
for (Listener l : listeners) {
|
||||||
|
l.onConnectivityChanged(online);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean checkOnline() {
|
||||||
|
if (connectivityManager == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Network network = connectivityManager.getActiveNetwork();
|
||||||
|
if (network == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
NetworkCapabilities caps = connectivityManager.getNetworkCapabilities(network);
|
||||||
|
return caps != null
|
||||||
|
&& caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
&& caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.grigowashere.loratester.telnet;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/** Formats user input as an AT command line for serial/telnet bridges. */
|
||||||
|
public final class AtCommandFormatter {
|
||||||
|
|
||||||
|
private AtCommandFormatter() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String normalize(String input) {
|
||||||
|
if (input == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String line = input.trim();
|
||||||
|
if (line.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!line.regionMatches(true, 0, "AT", 0, 2)) {
|
||||||
|
line = "AT" + line;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] toWireBytes(String input) {
|
||||||
|
String line = normalize(input);
|
||||||
|
if (line.isEmpty()) {
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
String wire = line + "\r\n";
|
||||||
|
return wire.getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Literal line (e.g. screen reset "S") without AT prefix. */
|
||||||
|
public static byte[] toWireBytesLiteral(String line) {
|
||||||
|
if (line == null || line.isEmpty()) {
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
return (line + "\r\n").getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.grigowashere.loratester.telnet;
|
||||||
|
|
||||||
|
/** LoRa module AT commands (telnet bridge). */
|
||||||
|
public final class AtCommands {
|
||||||
|
|
||||||
|
public static final String TRANSMIT = "AT+TX";
|
||||||
|
public static final String RECEIVE = "AT+RX";
|
||||||
|
/** Stop TX or RX test. */
|
||||||
|
public static final String STOP = "S";
|
||||||
|
public static final String TIMEOUT_MS = "AT+TM=";
|
||||||
|
public static final String FREQUENCY_HZ = "AT+FQ=";
|
||||||
|
public static final String POWER_DBM = "AT+PW=";
|
||||||
|
public static final String SPREADING_FACTOR = "AT+SF=";
|
||||||
|
public static final String BANDWIDTH = "AT+BW=";
|
||||||
|
public static final String CODE_RATE = "AT+CR=";
|
||||||
|
public static final String PREAMBLE = "AT+PL=";
|
||||||
|
|
||||||
|
/** Legacy / bridge helpers (if supported by firmware). */
|
||||||
|
public static final String HELP = "AT+H";
|
||||||
|
public static final String STATUS = "AT+STATUS";
|
||||||
|
public static final String RESET = "AT+RESET";
|
||||||
|
public static final String BASIC = "AT";
|
||||||
|
|
||||||
|
public static final String[] BW_KHZ = {
|
||||||
|
"7.81", "10.42", "15.63", "20.83", "31.25",
|
||||||
|
"41.67", "62.5", "125", "250", "500"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static final String[] CODE_RATES = {"4/5", "4/6", "4/7", "4/8"};
|
||||||
|
|
||||||
|
private AtCommands() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package com.grigowashere.loratester.telnet;
|
||||||
|
|
||||||
|
import com.grigowashere.loratester.model.RadioSnapshot;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public final class LoraStatsFormatter {
|
||||||
|
|
||||||
|
private LoraStatsFormatter() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated
|
||||||
|
public static String formatMeta(String metaJson) {
|
||||||
|
RadioSnapshot snap = RadioSnapshot.fromMeta(metaJson, null, null);
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
String dynamic = formatDynamic(snap, Set.of());
|
||||||
|
if (!dynamic.isEmpty()) {
|
||||||
|
sb.append(dynamic);
|
||||||
|
}
|
||||||
|
String stat = formatStatic(snap, Set.of());
|
||||||
|
if (!stat.isEmpty()) {
|
||||||
|
if (sb.length() > 0) {
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
sb.append(stat);
|
||||||
|
}
|
||||||
|
return sb.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String formatDynamic(RadioSnapshot s, Set<String> changed) {
|
||||||
|
if (s == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
appendLine(sb, "RSSI", fmtDbm(s.rssiDbm), "rssi", changed);
|
||||||
|
appendLine(sb, "SNR", fmtSuffix(s.snrDb, " dB"), "snr", changed);
|
||||||
|
appendLine(sb, "RX Quality", fmtSuffix(s.rxQualityPercent, " %"), "rxQuality", changed);
|
||||||
|
appendLine(sb, "Пакет", fmtInt(s.packet), "packet", changed);
|
||||||
|
appendLine(sb, "Payload", s.payload, "payload", changed);
|
||||||
|
appendLine(sb, "PER", fmtSuffix(s.perPercent, " %"), "per", changed);
|
||||||
|
appendLine(sb, "Принято", fmtInt(s.packetReceive), "packetReceive", changed);
|
||||||
|
appendLine(sb, "Всего пакетов", fmtInt(s.packetTotal), "packetTotal", changed);
|
||||||
|
appendLine(sb, "Ошибки пакетов", fmtInt(s.packetError), "packetError", changed);
|
||||||
|
appendLine(sb, "CRC Error", fmtInt(s.crcError), "crcError", changed);
|
||||||
|
appendLine(sb, "Preamble Det.", fmtInt(s.preambleDetected), "preambleDetected", changed);
|
||||||
|
appendLine(sb, "Header Valid", fmtInt(s.headerValid), "headerValid", changed);
|
||||||
|
appendLine(sb, "TX Speed", fmtSuffix(s.txPktPerS, " pkt/s"), "txSpeed", changed);
|
||||||
|
appendLine(sb, "RX Speed", fmtSuffix(s.rxPktPerS, " pkt/s"), "rxSpeed", changed);
|
||||||
|
for (Map.Entry<String, String> e : s.extraFields.entrySet()) {
|
||||||
|
append(sb, e.getKey(), e.getValue());
|
||||||
|
}
|
||||||
|
return sb.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String formatStatic(RadioSnapshot s, Set<String> changed) {
|
||||||
|
if (s == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
if (s.role != null) {
|
||||||
|
appendLine(sb, "Роль", roleLabel(s.role), "role", changed);
|
||||||
|
}
|
||||||
|
appendLine(sb, "Частота", fmtSuffix(s.frequencyMhz, " MHz"), "frequency", changed);
|
||||||
|
appendLine(sb, "SF", fmtInt(s.sf), "sf", changed);
|
||||||
|
appendLine(sb, "BW", fmtBw(s.bwKhz), "bw", changed);
|
||||||
|
appendLine(sb, "Мощность TX", fmtDbm(s.powerDbm), "power", changed);
|
||||||
|
appendLine(sb, "Code Rate", s.codeRate, "codeRate", changed);
|
||||||
|
appendLine(sb, "Preamble Len", fmtInt(s.preambleLength), "preambleLength", changed);
|
||||||
|
appendLine(sb, "Low DR Opt", s.lowDataRateOpt, "lowDataRateOpt", changed);
|
||||||
|
appendLine(sb, "CRC", fmtCrc(s.crcEnabled), "crc", changed);
|
||||||
|
appendLine(sb, "Payload len", fmtSuffix(s.payloadLengthBytes, " byte"), "payloadLength", changed);
|
||||||
|
appendLine(sb, "TX Timeout", fmtSuffix(s.txTimeoutMs, " ms"), "txTimeout", changed);
|
||||||
|
appendLine(sb, "On Air", fmtSuffix(s.onAirMs, " ms"), "onAir", changed);
|
||||||
|
return sb.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String roleLabel(String role) {
|
||||||
|
if (StatsExtractor.ROLE_TX.equals(role)) {
|
||||||
|
return "Передатчик (TX)";
|
||||||
|
}
|
||||||
|
if (StatsExtractor.ROLE_RX.equals(role)) {
|
||||||
|
return "Приёмник (RX)";
|
||||||
|
}
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendLine(
|
||||||
|
StringBuilder sb,
|
||||||
|
String label,
|
||||||
|
String value,
|
||||||
|
String changeKey,
|
||||||
|
Set<String> changed
|
||||||
|
) {
|
||||||
|
if (value == null || value.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sb.length() > 0) {
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
if (changed != null && changed.contains(changeKey)) {
|
||||||
|
sb.append("▸ ");
|
||||||
|
}
|
||||||
|
sb.append(label).append(": ").append(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void append(StringBuilder sb, String label, String value) {
|
||||||
|
if (value == null || value.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sb.length() > 0) {
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
sb.append(label).append(": ").append(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmtDbm(Double v) {
|
||||||
|
return v != null ? String.format(Locale.US, "%.0f dBm", v) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmtInt(Integer v) {
|
||||||
|
return v != null ? String.valueOf(v) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmtBw(Double v) {
|
||||||
|
return v != null ? String.format(Locale.US, "%.2f kHz", v) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmtCrc(Boolean enabled) {
|
||||||
|
if (enabled == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return enabled ? "On" : "Off";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmtSuffix(Integer v, String suffix) {
|
||||||
|
return v != null ? v + suffix : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmtSuffix(Double v, String suffix) {
|
||||||
|
return v != null ? String.format(Locale.US, "%s%s", v, suffix) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.grigowashere.loratester.telnet;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Builds macro: S (stop) then configuration AT commands, then TX/RX if requested. */
|
||||||
|
public final class RadioMacroBuilder {
|
||||||
|
|
||||||
|
/** Stop TX or RX before applying new settings. */
|
||||||
|
public static final String STOP = AtCommands.STOP;
|
||||||
|
|
||||||
|
/** @deprecated use {@link #STOP} */
|
||||||
|
@Deprecated
|
||||||
|
public static final String SCREEN_RESET = STOP;
|
||||||
|
|
||||||
|
public static final class Params {
|
||||||
|
public Long frequencyHz;
|
||||||
|
public Integer powerDbm;
|
||||||
|
public Integer sf;
|
||||||
|
public String bwKhz;
|
||||||
|
public String codeRate;
|
||||||
|
public Integer preamble;
|
||||||
|
public Integer sendTimeoutMs;
|
||||||
|
public String role;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RadioMacroBuilder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> apply(Integer sf, Integer bwKhz, String role) {
|
||||||
|
Params p = new Params();
|
||||||
|
p.sf = sf;
|
||||||
|
if (bwKhz != null) {
|
||||||
|
p.bwKhz = String.valueOf(bwKhz);
|
||||||
|
}
|
||||||
|
p.role = role;
|
||||||
|
return apply(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> apply(Params p) {
|
||||||
|
List<String> lines = new ArrayList<>();
|
||||||
|
lines.add(STOP);
|
||||||
|
if (p == null) {
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
if (p.frequencyHz != null && p.frequencyHz >= 430_000_000L && p.frequencyHz <= 470_000_000L) {
|
||||||
|
lines.add(AtCommands.FREQUENCY_HZ + p.frequencyHz);
|
||||||
|
}
|
||||||
|
if (p.powerDbm != null && p.powerDbm >= -9 && p.powerDbm <= 22) {
|
||||||
|
lines.add(AtCommands.POWER_DBM + p.powerDbm);
|
||||||
|
}
|
||||||
|
if (p.sf != null && p.sf >= 5 && p.sf <= 12) {
|
||||||
|
lines.add(AtCommands.SPREADING_FACTOR + p.sf);
|
||||||
|
}
|
||||||
|
if (p.bwKhz != null && !p.bwKhz.isBlank()) {
|
||||||
|
lines.add(AtCommands.BANDWIDTH + p.bwKhz.trim());
|
||||||
|
}
|
||||||
|
if (p.codeRate != null && !p.codeRate.isBlank()) {
|
||||||
|
lines.add(AtCommands.CODE_RATE + p.codeRate.trim());
|
||||||
|
}
|
||||||
|
if (p.preamble != null && p.preamble >= 1 && p.preamble <= 64) {
|
||||||
|
lines.add(AtCommands.PREAMBLE + p.preamble);
|
||||||
|
}
|
||||||
|
if (p.sendTimeoutMs != null && p.sendTimeoutMs >= 0 && p.sendTimeoutMs <= 60_000) {
|
||||||
|
lines.add(AtCommands.TIMEOUT_MS + p.sendTimeoutMs);
|
||||||
|
}
|
||||||
|
if (StatsExtractor.ROLE_TX.equals(p.role)) {
|
||||||
|
lines.add(AtCommands.TRANSMIT);
|
||||||
|
} else if (StatsExtractor.ROLE_RX.equals(p.role)) {
|
||||||
|
lines.add(AtCommands.RECEIVE);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
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_TX = Pattern.compile("(?m)^\\s*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 PACKET_RECEIVE = Pattern.compile("Packet Receive\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern PACKET_TOTAL = Pattern.compile("Packet Total\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern PACKET_ERROR = Pattern.compile("Packet Error\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern CRC_ERROR = Pattern.compile("CRC Error\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern PREAMBLE_DETECTED = Pattern.compile(
|
||||||
|
"Preamble Detected\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern HEADER_VALID = Pattern.compile("Header Valid\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern CODE_RATE = Pattern.compile("Code Rate\\s*:\\s*(\\S+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern PREAMBLE_LENGTH = Pattern.compile(
|
||||||
|
"Preamble Length\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern LOW_DATA_RATE = Pattern.compile(
|
||||||
|
"Low Data Rate Opt\\s*:\\s*(\\S+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern CRC = Pattern.compile("CRC\\s*:\\s*(On|Off)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern PAYLOAD_LENGTH = Pattern.compile(
|
||||||
|
"Payload length\\s*:\\s*(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern TX_TIMEOUT = Pattern.compile(
|
||||||
|
"TX Timeout\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern PAYLOAD = Pattern.compile("Payload\\s*:\\s*(.+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern ON_AIR = Pattern.compile("On Air\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern TX_SPEED = Pattern.compile("TX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern RX_SPEED = Pattern.compile("RX Speed\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern PER = Pattern.compile("PER\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
private static final Pattern RX_QUALITY = Pattern.compile(
|
||||||
|
"RX Quality\\s*:\\s*([\\d.]+)", Pattern.CASE_INSENSITIVE);
|
||||||
|
|
||||||
|
private final Pattern rssiPattern;
|
||||||
|
private final Pattern rangePattern;
|
||||||
|
|
||||||
|
public StatsExtractor(String rssiRegex, String rangeRegex) {
|
||||||
|
this.rssiPattern = Pattern.compile(rssiRegex, Pattern.CASE_INSENSITIVE);
|
||||||
|
this.rangePattern = Pattern.compile(rangeRegex, Pattern.CASE_INSENSITIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StatsExtractor withDefaults() {
|
||||||
|
return new StatsExtractor(
|
||||||
|
"RSSI\\s*:\\s*(-?\\d+(?:\\.\\d+)?)",
|
||||||
|
"range\\s*:\\s*([\\d.]+)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExtractedStats extract(String frame) {
|
||||||
|
if (frame == null || frame.isBlank()) {
|
||||||
|
return empty(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalized = TelnetText.normalize(frame);
|
||||||
|
|
||||||
|
Map<String, Object> meta = new LinkedHashMap<>();
|
||||||
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
|
collectLabeledLines(normalized, fields);
|
||||||
|
|
||||||
|
String frameType = detectFrameType(normalized);
|
||||||
|
String role = frameTypeToRole(frameType);
|
||||||
|
if (frameType != null) {
|
||||||
|
meta.put("frame", frameType);
|
||||||
|
}
|
||||||
|
if (role != null) {
|
||||||
|
meta.put("role", role);
|
||||||
|
}
|
||||||
|
Double rssiDbm = firstDouble(RSSI, normalized);
|
||||||
|
if (rssiDbm == null) {
|
||||||
|
rssiDbm = matchDouble(rssiPattern, normalized);
|
||||||
|
}
|
||||||
|
Double txPower = matchDouble(POWER, normalized);
|
||||||
|
Double snrDb = matchDouble(SNR, normalized);
|
||||||
|
|
||||||
|
if (rssiDbm != null) {
|
||||||
|
meta.put("rssi_dbm", rssiDbm);
|
||||||
|
}
|
||||||
|
if (txPower != null) {
|
||||||
|
meta.put("power_dbm", txPower);
|
||||||
|
}
|
||||||
|
if (snrDb != null) {
|
||||||
|
meta.put("snr_db", snrDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
putLong(meta, "frequency_hz", matchLong(FREQUENCY, normalized));
|
||||||
|
putInt(meta, "spreading_factor", matchInt(SPREADING, normalized));
|
||||||
|
putDouble(meta, "bandwidth_khz", matchDouble(BANDWIDTH, normalized));
|
||||||
|
Integer packet = matchInt(PACKET_NUMBER, normalized);
|
||||||
|
if (packet == null) {
|
||||||
|
packet = matchInt(PACKET_TX, normalized);
|
||||||
|
}
|
||||||
|
putInt(meta, "packet", packet);
|
||||||
|
putString(meta, "payload", matchString(PAYLOAD, normalized));
|
||||||
|
putDouble(meta, "on_air_ms", matchDouble(ON_AIR, normalized));
|
||||||
|
putDouble(meta, "tx_pkt_per_s", matchDouble(TX_SPEED, normalized));
|
||||||
|
putDouble(meta, "rx_pkt_per_s", matchDouble(RX_SPEED, normalized));
|
||||||
|
putDouble(meta, "per_percent", matchDouble(PER, normalized));
|
||||||
|
putDouble(meta, "rx_quality_percent", matchDouble(RX_QUALITY, normalized));
|
||||||
|
putString(meta, "code_rate", matchString(CODE_RATE, normalized));
|
||||||
|
putInt(meta, "preamble_length", matchInt(PREAMBLE_LENGTH, normalized));
|
||||||
|
putString(meta, "low_data_rate_opt", matchString(LOW_DATA_RATE, normalized));
|
||||||
|
putBool(meta, "crc_enabled", matchBool(CRC, normalized));
|
||||||
|
putInt(meta, "payload_length_bytes", matchInt(PAYLOAD_LENGTH, normalized));
|
||||||
|
putDouble(meta, "tx_timeout_ms", matchDouble(TX_TIMEOUT, normalized));
|
||||||
|
putInt(meta, "packet_receive", matchInt(PACKET_RECEIVE, normalized));
|
||||||
|
putInt(meta, "packet_total", matchInt(PACKET_TOTAL, normalized));
|
||||||
|
putInt(meta, "packet_error", matchInt(PACKET_ERROR, normalized));
|
||||||
|
putInt(meta, "crc_error", matchInt(CRC_ERROR, normalized));
|
||||||
|
putInt(meta, "preamble_detected", matchInt(PREAMBLE_DETECTED, normalized));
|
||||||
|
putInt(meta, "header_valid", matchInt(HEADER_VALID, normalized));
|
||||||
|
|
||||||
|
if (!fields.isEmpty()) {
|
||||||
|
meta.put("fields", fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.put("stats_at", System.currentTimeMillis() / 1000.0);
|
||||||
|
|
||||||
|
Double rangeM = matchDouble(rangePattern, normalized);
|
||||||
|
Double displayDbm = rssiDbm != null ? rssiDbm : txPower;
|
||||||
|
|
||||||
|
String metaJson = meta.isEmpty() ? null : GSON.toJson(meta);
|
||||||
|
return new ExtractedStats(
|
||||||
|
displayDbm, rangeM, frame, metaJson, frameType, role, txPower, rssiDbm, snrDb
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void collectLabeledLines(String frame, Map<String, String> fields) {
|
||||||
|
for (String line : frame.split("\n")) {
|
||||||
|
String trimmed = line.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int colon = trimmed.indexOf(':');
|
||||||
|
if (colon <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String label = trimmed.substring(0, colon).trim();
|
||||||
|
String value = trimmed.substring(colon + 1).trim();
|
||||||
|
if (label.isEmpty()
|
||||||
|
|| label.equalsIgnoreCase("SEND")
|
||||||
|
|| label.equalsIgnoreCase("RECEIVE")
|
||||||
|
|| isStructuredLabel(label)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fields.put(label, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isStructuredLabel(String label) {
|
||||||
|
String n = label.toLowerCase(Locale.ROOT).trim();
|
||||||
|
return n.equals("frequency") || n.equals("power") || n.equals("rssi")
|
||||||
|
|| n.equals("snr") || n.contains("spreading factor") || n.equals("bandwidth")
|
||||||
|
|| n.equals("packet") || n.contains("packet number") || n.equals("payload")
|
||||||
|
|| n.contains("packet receive") || n.contains("packet total") || n.contains("packet error")
|
||||||
|
|| n.contains("crc error") || n.contains("preamble detected") || n.contains("header valid")
|
||||||
|
|| n.contains("on air") || n.contains("tx speed") || n.contains("rx speed")
|
||||||
|
|| n.equals("per") || n.contains("rx quality") || n.contains("tx timeout")
|
||||||
|
|| n.contains("code rate") || n.contains("preamble length")
|
||||||
|
|| n.contains("low data rate") || n.equals("crc") || n.contains("payload length");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 void putBool(Map<String, Object> meta, String key, Boolean value) {
|
||||||
|
if (value != null) {
|
||||||
|
meta.put(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Boolean matchBool(Pattern pattern, String text) {
|
||||||
|
Matcher m = pattern.matcher(text);
|
||||||
|
if (!m.find()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return "on".equalsIgnoreCase(m.group(1).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double matchDouble(Pattern pattern, String text) {
|
||||||
|
Matcher m = pattern.matcher(text);
|
||||||
|
if (m.find()) {
|
||||||
|
try {
|
||||||
|
return Double.parseDouble(m.group(1).trim());
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer matchInt(Pattern pattern, String text) {
|
||||||
|
Long v = matchLong(pattern, text);
|
||||||
|
return v != null ? v.intValue() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long matchLong(Pattern pattern, String text) {
|
||||||
|
Matcher m = pattern.matcher(text);
|
||||||
|
if (m.find()) {
|
||||||
|
try {
|
||||||
|
return Long.parseLong(m.group(1).trim());
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String matchString(Pattern pattern, String text) {
|
||||||
|
Matcher m = pattern.matcher(text);
|
||||||
|
if (m.find()) {
|
||||||
|
return m.group(1).trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final class ExtractedStats {
|
||||||
|
public final Double rssi;
|
||||||
|
public final Double rangeM;
|
||||||
|
public final String rawFrame;
|
||||||
|
public final String metaJson;
|
||||||
|
public final String frameType;
|
||||||
|
public final String role;
|
||||||
|
public final Double txPowerDbm;
|
||||||
|
public final Double rssiDbm;
|
||||||
|
public final Double snrDb;
|
||||||
|
|
||||||
|
public boolean hasRadioFrame() {
|
||||||
|
return frameType != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExtractedStats(
|
||||||
|
Double rssi,
|
||||||
|
Double rangeM,
|
||||||
|
String rawFrame,
|
||||||
|
String metaJson,
|
||||||
|
String frameType,
|
||||||
|
String role,
|
||||||
|
Double txPowerDbm,
|
||||||
|
Double rssiDbm,
|
||||||
|
Double snrDb
|
||||||
|
) {
|
||||||
|
this.rssi = rssi;
|
||||||
|
this.rangeM = rangeM;
|
||||||
|
this.rawFrame = rawFrame;
|
||||||
|
this.metaJson = metaJson;
|
||||||
|
this.frameType = frameType;
|
||||||
|
this.role = role;
|
||||||
|
this.txPowerDbm = txPowerDbm;
|
||||||
|
this.rssiDbm = rssiDbm;
|
||||||
|
this.snrDb = snrDb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.grigowashere.loratester.telnet;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
public class TelnetClient {
|
||||||
|
|
||||||
|
private static final String TAG = "TelnetClient";
|
||||||
|
|
||||||
|
public interface Listener {
|
||||||
|
void onConnected();
|
||||||
|
|
||||||
|
void onDisconnected();
|
||||||
|
|
||||||
|
void onBytes(byte[] data, int length);
|
||||||
|
|
||||||
|
void onError(String message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SendResult {
|
||||||
|
SENT,
|
||||||
|
NOT_CONNECTED,
|
||||||
|
EMPTY,
|
||||||
|
IO_ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
private final String host;
|
||||||
|
private final int port;
|
||||||
|
private final Listener listener;
|
||||||
|
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||||
|
private final AtomicReference<Socket> activeSocket = new AtomicReference<>();
|
||||||
|
private final Object sendLock = new Object();
|
||||||
|
private Thread worker;
|
||||||
|
|
||||||
|
public TelnetClient(String host, int port, Listener listener) {
|
||||||
|
this.host = host;
|
||||||
|
this.port = port;
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
if (running.getAndSet(true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
worker = new Thread(this::runLoop, "TelnetClient");
|
||||||
|
worker.setDaemon(true);
|
||||||
|
worker.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
running.set(false);
|
||||||
|
closeActiveSocket();
|
||||||
|
if (worker != null) {
|
||||||
|
worker.interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isRunning() {
|
||||||
|
return running.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isConnected() {
|
||||||
|
Socket s = activeSocket.get();
|
||||||
|
return s != null && s.isConnected() && !s.isClosed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an AT command. Adds AT prefix and CR+LF if missing.
|
||||||
|
*/
|
||||||
|
public SendResult sendRawLine(String line) {
|
||||||
|
byte[] wire = AtCommandFormatter.toWireBytesLiteral(line);
|
||||||
|
if (wire.length == 0) {
|
||||||
|
return SendResult.EMPTY;
|
||||||
|
}
|
||||||
|
return writeWire(wire);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SendResult sendAtCommand(String command) {
|
||||||
|
byte[] wire = AtCommandFormatter.toWireBytes(command);
|
||||||
|
if (wire.length == 0) {
|
||||||
|
return SendResult.EMPTY;
|
||||||
|
}
|
||||||
|
return writeWire(wire);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SendResult writeWire(byte[] wire) {
|
||||||
|
Socket socket = activeSocket.get();
|
||||||
|
if (socket == null || socket.isClosed()) {
|
||||||
|
return SendResult.NOT_CONNECTED;
|
||||||
|
}
|
||||||
|
synchronized (sendLock) {
|
||||||
|
try {
|
||||||
|
OutputStream out = socket.getOutputStream();
|
||||||
|
out.write(wire);
|
||||||
|
out.flush();
|
||||||
|
return SendResult.SENT;
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.e(TAG, "send failed", e);
|
||||||
|
return SendResult.IO_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runLoop() {
|
||||||
|
int backoffMs = 1000;
|
||||||
|
while (running.get()) {
|
||||||
|
Socket socket = null;
|
||||||
|
try {
|
||||||
|
socket = new Socket();
|
||||||
|
socket.connect(new InetSocketAddress(host, port), 5000);
|
||||||
|
socket.setTcpNoDelay(true);
|
||||||
|
activeSocket.set(socket);
|
||||||
|
listener.onConnected();
|
||||||
|
backoffMs = 1000;
|
||||||
|
readStream(socket);
|
||||||
|
} catch (IOException e) {
|
||||||
|
if (running.get()) {
|
||||||
|
listener.onError(e.getMessage());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeSocket.compareAndSet(socket, null);
|
||||||
|
listener.onDisconnected();
|
||||||
|
if (socket != null) {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!running.get()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(backoffMs);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
backoffMs = Math.min(backoffMs * 2, 15000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeActiveSocket() {
|
||||||
|
Socket s = activeSocket.getAndSet(null);
|
||||||
|
if (s != null) {
|
||||||
|
try {
|
||||||
|
s.close();
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void readStream(Socket socket) throws IOException {
|
||||||
|
InputStream in = socket.getInputStream();
|
||||||
|
byte[] buf = new byte[4096];
|
||||||
|
while (running.get()) {
|
||||||
|
int n = in.read(buf);
|
||||||
|
if (n < 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (n > 0) {
|
||||||
|
byte[] chunk = new byte[n];
|
||||||
|
System.arraycopy(buf, 0, chunk, 0, n);
|
||||||
|
listener.onBytes(chunk, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package com.grigowashere.loratester.telnet;
|
||||||
|
|
||||||
|
import java.nio.charset.Charset;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits telnet stream into LoRa screen frames by ESC clear and/or SEND/RECEIVE headers.
|
||||||
|
*/
|
||||||
|
public class TelnetFrameParser {
|
||||||
|
|
||||||
|
public interface FrameListener {
|
||||||
|
void onFrame(String text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Charset CHARSET = StandardCharsets.UTF_8;
|
||||||
|
/** Start of a new radio screen (TX or RX). */
|
||||||
|
private static final Pattern FRAME_HEADER =
|
||||||
|
Pattern.compile("(?m)^\\s*(SEND|RECEIVE)\\b");
|
||||||
|
|
||||||
|
private final List<byte[]> delimiters;
|
||||||
|
private final FrameListener listener;
|
||||||
|
private final byte[] buffer = new byte[65536];
|
||||||
|
private int bufferLen;
|
||||||
|
|
||||||
|
public TelnetFrameParser(FrameListener listener) {
|
||||||
|
this(listener, defaultDelimiters());
|
||||||
|
}
|
||||||
|
|
||||||
|
public TelnetFrameParser(FrameListener listener, List<byte[]> delimiters) {
|
||||||
|
this.listener = listener;
|
||||||
|
this.delimiters = new ArrayList<>(delimiters);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<byte[]> defaultDelimiters() {
|
||||||
|
return Arrays.asList(
|
||||||
|
new byte[] {0x1b, '[', '2', 'J'},
|
||||||
|
new byte[] {0x1b, '[', 'H'},
|
||||||
|
new byte[] {0x0c}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void append(byte[] chunk, int offset, int length) {
|
||||||
|
if (length <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int splitAt = findEarliestDelimiter(chunk, offset, length);
|
||||||
|
if (splitAt < 0) {
|
||||||
|
appendToBuffer(chunk, offset, length);
|
||||||
|
tryEmitByHeaders();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appendToBuffer(chunk, offset, splitAt - offset);
|
||||||
|
emitFrame();
|
||||||
|
clearBuffer();
|
||||||
|
int delimLen = delimiterLengthAt(chunk, splitAt, offset, length);
|
||||||
|
int tailOffset = splitAt + delimLen;
|
||||||
|
int tailLen = offset + length - tailOffset;
|
||||||
|
append(chunk, tailOffset, tailLen);
|
||||||
|
tryEmitByHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void append(byte[] chunk) {
|
||||||
|
append(chunk, 0, chunk.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void flush() {
|
||||||
|
tryEmitByHeaders();
|
||||||
|
if (bufferLen > 0) {
|
||||||
|
emitFrame();
|
||||||
|
clearBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit latest SEND/RECEIVE screen from buffer (in-place refresh without ESC).
|
||||||
|
* Does not clear the buffer.
|
||||||
|
*/
|
||||||
|
public void emitSnapshot() {
|
||||||
|
if (bufferLen == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String text = TelnetText.normalize(bufferText());
|
||||||
|
Matcher m = FRAME_HEADER.matcher(text);
|
||||||
|
int lastStart = -1;
|
||||||
|
while (m.find()) {
|
||||||
|
lastStart = m.start();
|
||||||
|
}
|
||||||
|
if (lastStart < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String frame = text.substring(lastStart).trim();
|
||||||
|
if (!frame.isEmpty()) {
|
||||||
|
listener.onFrame(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit all complete frames when buffer holds multiple SEND/RECEIVE blocks. */
|
||||||
|
private void tryEmitByHeaders() {
|
||||||
|
if (bufferLen == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String text = bufferText();
|
||||||
|
Matcher m = FRAME_HEADER.matcher(text);
|
||||||
|
List<Integer> starts = new ArrayList<>();
|
||||||
|
while (m.find()) {
|
||||||
|
starts.add(m.start());
|
||||||
|
}
|
||||||
|
if (starts.size() < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < starts.size() - 1; i++) {
|
||||||
|
String frame = text.substring(starts.get(i), starts.get(i + 1)).trim();
|
||||||
|
if (!frame.isEmpty()) {
|
||||||
|
listener.onFrame(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String tail = text.substring(starts.get(starts.size() - 1));
|
||||||
|
clearBuffer();
|
||||||
|
byte[] tailBytes = tail.getBytes(CHARSET);
|
||||||
|
appendToBuffer(tailBytes, 0, tailBytes.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String bufferText() {
|
||||||
|
return new String(buffer, 0, bufferLen, CHARSET);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emitFrame() {
|
||||||
|
if (bufferLen == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String text = bufferText().trim();
|
||||||
|
if (!text.isEmpty()) {
|
||||||
|
listener.onFrame(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearBuffer() {
|
||||||
|
bufferLen = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendToBuffer(byte[] src, int offset, int length) {
|
||||||
|
if (length <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bufferLen + length > buffer.length) {
|
||||||
|
tryEmitByHeaders();
|
||||||
|
if (bufferLen + length > buffer.length) {
|
||||||
|
emitFrame();
|
||||||
|
clearBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
System.arraycopy(src, offset, buffer, bufferLen, length);
|
||||||
|
bufferLen += length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int findEarliestDelimiter(byte[] chunk, int offset, int length) {
|
||||||
|
int best = -1;
|
||||||
|
for (byte[] delim : delimiters) {
|
||||||
|
int idx = indexOf(chunk, offset, length, delim);
|
||||||
|
if (idx >= 0 && (best < 0 || idx < best)) {
|
||||||
|
best = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int delimiterLengthAt(byte[] chunk, int pos, int offset, int length) {
|
||||||
|
int end = offset + length;
|
||||||
|
for (byte[] delim : delimiters) {
|
||||||
|
if (pos + delim.length <= end && matchesAt(chunk, pos, delim)) {
|
||||||
|
return delim.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int indexOf(byte[] haystack, int offset, int length, byte[] needle) {
|
||||||
|
int end = offset + length - needle.length;
|
||||||
|
for (int i = offset; i <= end; i++) {
|
||||||
|
if (matchesAt(haystack, i, needle)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean matchesAt(byte[] haystack, int pos, byte[] needle) {
|
||||||
|
for (int i = 0; i < needle.length; i++) {
|
||||||
|
if (haystack[pos + i] != needle[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.grigowashere.loratester.telnet;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/** Cleans telnet screen text before field extraction. */
|
||||||
|
public final class TelnetText {
|
||||||
|
|
||||||
|
private static final Pattern ANSI = Pattern.compile(
|
||||||
|
"\u001b\\[[0-9;?]*[ -/]*[@-~]|\u001b\\].*?\u0007|\u001b[@-Z\\\\-_]"
|
||||||
|
);
|
||||||
|
|
||||||
|
private TelnetText() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String normalize(String text) {
|
||||||
|
if (text == null || text.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String s = ANSI.matcher(text).replaceAll("");
|
||||||
|
s = s.replace('\r', '\n');
|
||||||
|
s = s.replace("\u000c", "\n");
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package com.grigowashere.loratester.track;
|
||||||
|
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.grigowashere.loratester.TelemetryUploader;
|
||||||
|
import com.grigowashere.loratester.api.ServerApi;
|
||||||
|
import com.grigowashere.loratester.location.GeoUtils;
|
||||||
|
import com.grigowashere.loratester.net.NetworkMonitor;
|
||||||
|
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
public class TrackRecorder {
|
||||||
|
|
||||||
|
private static final String TAG = "TrackRecorder";
|
||||||
|
private static final long SAMPLE_MS = 1000;
|
||||||
|
private static final long FLUSH_MS = 30_000;
|
||||||
|
|
||||||
|
public interface Listener {
|
||||||
|
void onStateChanged(boolean recording, int pointCount, long trackId);
|
||||||
|
|
||||||
|
void onError(String message);
|
||||||
|
|
||||||
|
default void onPointRecorded(double lat, double lon) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ServerApi serverApi;
|
||||||
|
private final TelemetryUploader uploader;
|
||||||
|
private final NetworkMonitor networkMonitor;
|
||||||
|
private final String deviceId;
|
||||||
|
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||||
|
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
|
private final ScheduledExecutorService scheduler =
|
||||||
|
Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "TrackSampler");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
private volatile double lat = Double.NaN;
|
||||||
|
private volatile double lon = Double.NaN;
|
||||||
|
private volatile double altitude = Double.NaN;
|
||||||
|
private volatile long trackId = -1;
|
||||||
|
private volatile boolean recording;
|
||||||
|
private final List<Map<String, Object>> buffer = new ArrayList<>();
|
||||||
|
private int totalPoints;
|
||||||
|
private ScheduledFuture<?> sampleTask;
|
||||||
|
private ScheduledFuture<?> flushTask;
|
||||||
|
private Listener listener;
|
||||||
|
private Listener pairedListener;
|
||||||
|
|
||||||
|
public TrackRecorder(
|
||||||
|
ServerApi serverApi,
|
||||||
|
TelemetryUploader uploader,
|
||||||
|
String deviceId,
|
||||||
|
NetworkMonitor networkMonitor
|
||||||
|
) {
|
||||||
|
this.serverApi = serverApi;
|
||||||
|
this.uploader = uploader;
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
this.networkMonitor = networkMonitor;
|
||||||
|
networkMonitor.addListener(online -> {
|
||||||
|
if (online && recording) {
|
||||||
|
executor.execute(this::flushBuffer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setListener(Listener listener) {
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPairedListener(Listener pairedListener) {
|
||||||
|
this.pairedListener = pairedListener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateLocation(double lat, double lon, double altitude) {
|
||||||
|
if (GeoUtils.isValidCoordinate(lat, lon)) {
|
||||||
|
this.lat = lat;
|
||||||
|
this.lon = lon;
|
||||||
|
}
|
||||||
|
if (!Double.isNaN(altitude) && altitude != 0.0) {
|
||||||
|
this.altitude = altitude;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isRecording() {
|
||||||
|
return recording;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPointCount() {
|
||||||
|
return totalPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTrackId() {
|
||||||
|
return trackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
if (recording) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!networkMonitor.isOnline()) {
|
||||||
|
notifyError("Нужна сеть для начала трека");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
long id = serverApi.startTrack(deviceId);
|
||||||
|
synchronized (buffer) {
|
||||||
|
buffer.clear();
|
||||||
|
}
|
||||||
|
totalPoints = 0;
|
||||||
|
trackId = id;
|
||||||
|
recording = true;
|
||||||
|
startTimers();
|
||||||
|
notifyState();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "start track failed", e);
|
||||||
|
notifyError(e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
if (!recording) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recording = false;
|
||||||
|
stopTimers();
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
flushBuffer();
|
||||||
|
if (trackId > 0) {
|
||||||
|
serverApi.finishTrack(trackId);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "stop track failed", e);
|
||||||
|
notifyError(e.getMessage());
|
||||||
|
} finally {
|
||||||
|
trackId = -1;
|
||||||
|
notifyState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startTimers() {
|
||||||
|
sampleTask = scheduler.scheduleAtFixedRate(
|
||||||
|
() -> executor.execute(this::samplePoint),
|
||||||
|
SAMPLE_MS,
|
||||||
|
SAMPLE_MS,
|
||||||
|
TimeUnit.MILLISECONDS
|
||||||
|
);
|
||||||
|
flushTask = scheduler.scheduleAtFixedRate(
|
||||||
|
() -> executor.execute(this::flushBuffer),
|
||||||
|
FLUSH_MS,
|
||||||
|
FLUSH_MS,
|
||||||
|
TimeUnit.MILLISECONDS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopTimers() {
|
||||||
|
if (sampleTask != null) {
|
||||||
|
sampleTask.cancel(false);
|
||||||
|
sampleTask = null;
|
||||||
|
}
|
||||||
|
if (flushTask != null) {
|
||||||
|
flushTask.cancel(false);
|
||||||
|
flushTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void samplePoint() {
|
||||||
|
if (!recording || trackId < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!GeoUtils.isValidCoordinate(lat, lon)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StatsExtractor.ExtractedStats stats = uploader.getLastStats();
|
||||||
|
Map<String, Object> point = new HashMap<>();
|
||||||
|
point.put("ts", System.currentTimeMillis() / 1000.0);
|
||||||
|
point.put("lat", lat);
|
||||||
|
point.put("lon", lon);
|
||||||
|
if (!Double.isNaN(altitude)) {
|
||||||
|
point.put("altitude_gps", altitude);
|
||||||
|
}
|
||||||
|
if (stats != null) {
|
||||||
|
if (stats.rssi != null) {
|
||||||
|
point.put("rssi", stats.rssi);
|
||||||
|
}
|
||||||
|
if (stats.role != null) {
|
||||||
|
point.put("role", stats.role);
|
||||||
|
}
|
||||||
|
if (stats.metaJson != null) {
|
||||||
|
point.put("meta", stats.metaJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synchronized (buffer) {
|
||||||
|
buffer.add(point);
|
||||||
|
}
|
||||||
|
totalPoints++;
|
||||||
|
notifyState();
|
||||||
|
notifyPoint(lat, lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyPoint(double lat, double lon) {
|
||||||
|
mainHandler.post(() -> {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onPointRecorded(lat, lon);
|
||||||
|
}
|
||||||
|
if (pairedListener != null) {
|
||||||
|
pairedListener.onPointRecorded(lat, lon);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void flushBuffer() {
|
||||||
|
if (trackId < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> batch;
|
||||||
|
synchronized (buffer) {
|
||||||
|
if (buffer.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
batch = new ArrayList<>(buffer);
|
||||||
|
buffer.clear();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
serverApi.addTrackPoints(trackId, batch);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "flush points failed", e);
|
||||||
|
synchronized (buffer) {
|
||||||
|
buffer.addAll(0, batch);
|
||||||
|
}
|
||||||
|
notifyError(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyState() {
|
||||||
|
mainHandler.post(() -> {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onStateChanged(recording, totalPoints, trackId);
|
||||||
|
}
|
||||||
|
if (pairedListener != null) {
|
||||||
|
pairedListener.onStateChanged(recording, totalPoints, trackId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyError(String msg) {
|
||||||
|
mainHandler.post(() -> {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onError(msg);
|
||||||
|
}
|
||||||
|
if (pairedListener != null) {
|
||||||
|
pairedListener.onError(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.ArrayAdapter;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.ScrollView;
|
||||||
|
import android.widget.Spinner;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
|
||||||
|
import com.google.android.material.button.MaterialButton;
|
||||||
|
import com.google.android.material.button.MaterialButtonToggleGroup;
|
||||||
|
import com.google.android.material.textfield.TextInputEditText;
|
||||||
|
import com.grigowashere.loratester.CommandPoller;
|
||||||
|
import com.grigowashere.loratester.LoraApp;
|
||||||
|
import com.grigowashere.loratester.PeerDevices;
|
||||||
|
import com.grigowashere.loratester.R;
|
||||||
|
import com.grigowashere.loratester.TelemetryUploader;
|
||||||
|
import com.grigowashere.loratester.api.DeviceInfo;
|
||||||
|
import com.grigowashere.loratester.model.RadioSnapshot;
|
||||||
|
import com.grigowashere.loratester.telnet.AtCommands;
|
||||||
|
import com.grigowashere.loratester.telnet.LoraStatsFormatter;
|
||||||
|
import com.grigowashere.loratester.telnet.RadioMacroBuilder;
|
||||||
|
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||||
|
import com.grigowashere.loratester.telnet.TelnetClient;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
public class AtFragment extends Fragment {
|
||||||
|
|
||||||
|
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||||
|
private FragmentPollHelper pollHelper;
|
||||||
|
private TelemetryUploader uploader;
|
||||||
|
private CommandPoller commandPoller;
|
||||||
|
private TextView atStatus;
|
||||||
|
private TextView atCurrentSnapshot;
|
||||||
|
private TextInputEditText atInputFq;
|
||||||
|
private TextInputEditText atInputPw;
|
||||||
|
private TextInputEditText atInputSf;
|
||||||
|
private TextInputEditText atInputPl;
|
||||||
|
private TextInputEditText atInputTm;
|
||||||
|
private Spinner atBwSpinner;
|
||||||
|
private Spinner atCrSpinner;
|
||||||
|
private Spinner atRoleSpinner;
|
||||||
|
private MaterialButtonToggleGroup atTargetGroup;
|
||||||
|
private ScrollView atConsoleScroll;
|
||||||
|
private TextView atConsole;
|
||||||
|
private String lastConsole = "";
|
||||||
|
private boolean consoleVisible;
|
||||||
|
private boolean formInitialized;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAttach(@NonNull Context context) {
|
||||||
|
super.onAttach(context);
|
||||||
|
LoraApp app = (LoraApp) context.getApplicationContext();
|
||||||
|
uploader = app.getTelemetryUploader();
|
||||||
|
commandPoller = app.getCommandPoller();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public View onCreateView(
|
||||||
|
@NonNull LayoutInflater inflater,
|
||||||
|
@Nullable ViewGroup container,
|
||||||
|
@Nullable Bundle savedInstanceState
|
||||||
|
) {
|
||||||
|
return inflater.inflate(R.layout.fragment_at, container, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
|
atStatus = view.findViewById(R.id.atStatus);
|
||||||
|
atCurrentSnapshot = view.findViewById(R.id.atCurrentSnapshot);
|
||||||
|
atInputFq = view.findViewById(R.id.atInputFq);
|
||||||
|
atInputPw = view.findViewById(R.id.atInputPw);
|
||||||
|
atInputSf = view.findViewById(R.id.atInputSf);
|
||||||
|
atInputPl = view.findViewById(R.id.atInputPl);
|
||||||
|
atInputTm = view.findViewById(R.id.atInputTm);
|
||||||
|
atBwSpinner = view.findViewById(R.id.atBwSpinner);
|
||||||
|
atCrSpinner = view.findViewById(R.id.atCrSpinner);
|
||||||
|
atRoleSpinner = view.findViewById(R.id.atRoleSpinner);
|
||||||
|
atTargetGroup = view.findViewById(R.id.atTargetGroup);
|
||||||
|
atConsoleScroll = view.findViewById(R.id.atConsoleScroll);
|
||||||
|
atConsole = view.findViewById(R.id.atConsole);
|
||||||
|
Button applyBtn = view.findViewById(R.id.atApplyBtn);
|
||||||
|
Button stopBtn = view.findViewById(R.id.atStopBtn);
|
||||||
|
MaterialButton consoleToggle = view.findViewById(R.id.atConsoleToggle);
|
||||||
|
pollHelper = new FragmentPollHelper(this, this::refresh);
|
||||||
|
|
||||||
|
atTargetGroup.check(R.id.atTargetLocal);
|
||||||
|
atBwSpinner.setAdapter(new ArrayAdapter<>(
|
||||||
|
requireContext(),
|
||||||
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
|
AtCommands.BW_KHZ
|
||||||
|
));
|
||||||
|
atCrSpinner.setAdapter(new ArrayAdapter<>(
|
||||||
|
requireContext(),
|
||||||
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
|
AtCommands.CODE_RATES
|
||||||
|
));
|
||||||
|
atRoleSpinner.setAdapter(new ArrayAdapter<>(
|
||||||
|
requireContext(),
|
||||||
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
|
new String[]{"—", "TX", "RX"}
|
||||||
|
));
|
||||||
|
|
||||||
|
applyBtn.setOnClickListener(v -> applyMacro());
|
||||||
|
stopBtn.setOnClickListener(v -> sendLines(List.of(AtCommands.STOP)));
|
||||||
|
consoleToggle.setOnClickListener(v -> {
|
||||||
|
consoleVisible = !consoleVisible;
|
||||||
|
atConsoleScroll.setVisibility(consoleVisible ? View.VISIBLE : View.GONE);
|
||||||
|
consoleToggle.setText(consoleVisible
|
||||||
|
? getString(R.string.at_console_hide)
|
||||||
|
: getString(R.string.at_console_toggle));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private RadioMacroBuilder.Params buildParams() {
|
||||||
|
RadioMacroBuilder.Params p = new RadioMacroBuilder.Params();
|
||||||
|
Double fqMhz = parseDouble(atInputFq);
|
||||||
|
if (fqMhz != null) {
|
||||||
|
p.frequencyHz = Math.round(fqMhz * 1_000_000L);
|
||||||
|
}
|
||||||
|
p.powerDbm = parseInt(atInputPw);
|
||||||
|
p.sf = parseInt(atInputSf);
|
||||||
|
int bwPos = atBwSpinner != null ? atBwSpinner.getSelectedItemPosition() : -1;
|
||||||
|
if (bwPos >= 0 && bwPos < AtCommands.BW_KHZ.length) {
|
||||||
|
p.bwKhz = AtCommands.BW_KHZ[bwPos];
|
||||||
|
}
|
||||||
|
if (atCrSpinner != null && atCrSpinner.getSelectedItem() != null) {
|
||||||
|
p.codeRate = atCrSpinner.getSelectedItem().toString();
|
||||||
|
}
|
||||||
|
p.preamble = parseInt(atInputPl);
|
||||||
|
p.sendTimeoutMs = parseInt(atInputTm);
|
||||||
|
if (atRoleSpinner != null && atRoleSpinner.getSelectedItem() != null) {
|
||||||
|
String role = atRoleSpinner.getSelectedItem().toString();
|
||||||
|
if (!"—".equals(role)) {
|
||||||
|
p.role = role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyMacro() {
|
||||||
|
sendLines(RadioMacroBuilder.apply(buildParams()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendLines(List<String> lines) {
|
||||||
|
if (isPeerTarget()) {
|
||||||
|
sendMacroToPeer(lines);
|
||||||
|
} else {
|
||||||
|
uploader.sendMacroSequence(lines, this::onSendResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendMacroToPeer(List<String> lines) {
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
|
||||||
|
PeerDevices.Result peer = PeerDevices.resolve(devices, uploader.getDeviceId());
|
||||||
|
if (!peer.ok()) {
|
||||||
|
showToast(R.string.at_peer_unavailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commandPoller.postMacroToPeer(peer.peerId, lines);
|
||||||
|
showToast(getString(R.string.at_sent_to_peer, peer.peerId));
|
||||||
|
} catch (Exception e) {
|
||||||
|
showToast(R.string.stats_push_failed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onSendResult(TelnetClient.SendResult result) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
if (result == TelnetClient.SendResult.NOT_CONNECTED) {
|
||||||
|
showToast(R.string.at_not_connected);
|
||||||
|
} else if (result == TelnetClient.SendResult.IO_ERROR) {
|
||||||
|
showToast(R.string.at_send_error);
|
||||||
|
}
|
||||||
|
updateConsoleView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer parseInt(TextInputEditText field) {
|
||||||
|
if (field == null || field.getText() == null) return null;
|
||||||
|
String s = field.getText().toString().trim();
|
||||||
|
if (s.isEmpty()) return null;
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(s);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Double parseDouble(TextInputEditText field) {
|
||||||
|
if (field == null || field.getText() == null) return null;
|
||||||
|
String s = field.getText().toString().trim();
|
||||||
|
if (s.isEmpty()) return null;
|
||||||
|
try {
|
||||||
|
return Double.parseDouble(s);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isPeerTarget() {
|
||||||
|
return atTargetGroup != null && atTargetGroup.getCheckedButtonId() == R.id.atTargetPeer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refresh() {
|
||||||
|
if (!isAdded() || uploader == null || atStatus == null) return;
|
||||||
|
atStatus.setText(getString(
|
||||||
|
R.string.at_status,
|
||||||
|
uploader.isTelnetConnected()
|
||||||
|
? getString(R.string.connected) : getString(R.string.disconnected)
|
||||||
|
));
|
||||||
|
RadioSnapshot snap = RadioSnapshot.fromExtracted(uploader.getLastStats());
|
||||||
|
atCurrentSnapshot.setText(LoraStatsFormatter.formatStatic(snap, java.util.Set.of())
|
||||||
|
+ "\n" + LoraStatsFormatter.formatDynamic(snap, java.util.Set.of()));
|
||||||
|
if (!formInitialized) {
|
||||||
|
if (snap.frequencyMhz != null && isEmpty(atInputFq)) {
|
||||||
|
atInputFq.setText(String.format(Locale.US, "%.3f", snap.frequencyMhz));
|
||||||
|
}
|
||||||
|
if (snap.powerDbm != null && isEmpty(atInputPw)) {
|
||||||
|
atInputPw.setText(String.valueOf(snap.powerDbm.intValue()));
|
||||||
|
}
|
||||||
|
if (snap.sf != null && isEmpty(atInputSf)) {
|
||||||
|
atInputSf.setText(String.valueOf(snap.sf));
|
||||||
|
}
|
||||||
|
if (snap.bwKhz != null) {
|
||||||
|
selectBw(String.valueOf(snap.bwKhz));
|
||||||
|
}
|
||||||
|
if (snap.role != null && atRoleSpinner != null) {
|
||||||
|
atRoleSpinner.setSelection(StatsExtractor.ROLE_RX.equals(snap.role) ? 2 : 1);
|
||||||
|
}
|
||||||
|
formInitialized = true;
|
||||||
|
}
|
||||||
|
updateConsoleView();
|
||||||
|
if (pollHelper != null) {
|
||||||
|
pollHelper.scheduleNext(400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isEmpty(TextInputEditText field) {
|
||||||
|
return field == null || field.getText() == null || field.getText().length() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void selectBw(String bw) {
|
||||||
|
if (atBwSpinner == null || bw == null) return;
|
||||||
|
for (int i = 0; i < AtCommands.BW_KHZ.length; i++) {
|
||||||
|
if (AtCommands.BW_KHZ[i].equals(bw) || AtCommands.BW_KHZ[i].equals(bw.replace(".0", ""))) {
|
||||||
|
atBwSpinner.setSelection(i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateConsoleView() {
|
||||||
|
if (uploader == null || atConsole == null || atConsoleScroll == null) return;
|
||||||
|
String log = uploader.getConsoleLog();
|
||||||
|
if (!log.equals(lastConsole)) {
|
||||||
|
lastConsole = log;
|
||||||
|
atConsole.setText(log);
|
||||||
|
if (consoleVisible) {
|
||||||
|
atConsoleScroll.post(() -> atConsoleScroll.fullScroll(View.FOCUS_DOWN));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showToast(int resId) {
|
||||||
|
if (isAdded()) Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showToast(String msg) {
|
||||||
|
if (isAdded()) Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
if (pollHelper != null) pollHelper.start(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPause() {
|
||||||
|
if (pollHelper != null) pollHelper.stop();
|
||||||
|
super.onPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroyView() {
|
||||||
|
if (pollHelper != null) pollHelper.stop();
|
||||||
|
pollHelper = null;
|
||||||
|
formInitialized = false;
|
||||||
|
super.onDestroyView();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
executor.shutdownNow();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.view.Gravity;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.FrameLayout;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.grigowashere.loratester.R;
|
||||||
|
import com.grigowashere.loratester.api.ChatMessage;
|
||||||
|
|
||||||
|
import java.text.DateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class ChatAdapter extends RecyclerView.Adapter<ChatAdapter.Holder> {
|
||||||
|
|
||||||
|
private static final int COLOR_SELF_BG = 0xFF16213E;
|
||||||
|
private static final int COLOR_OTHER_BG = 0xFF1A4A6E;
|
||||||
|
private static final int COLOR_NEW_HIGHLIGHT = 0x33E94560;
|
||||||
|
|
||||||
|
private final List<ChatMessage> messages = new ArrayList<>();
|
||||||
|
private final DateFormat timeFormat =
|
||||||
|
DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault());
|
||||||
|
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||||
|
private final Set<Integer> highlightedPositions = new HashSet<>();
|
||||||
|
|
||||||
|
private String selfDeviceId;
|
||||||
|
private double lastSeenTs;
|
||||||
|
|
||||||
|
public void setSelfDeviceId(String selfDeviceId) {
|
||||||
|
this.selfDeviceId = selfDeviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastSeenTs(double lastSeenTs) {
|
||||||
|
this.lastSeenTs = lastSeenTs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessages(List<ChatMessage> newMessages) {
|
||||||
|
messages.clear();
|
||||||
|
highlightedPositions.clear();
|
||||||
|
messages.addAll(newMessages);
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void appendMessages(List<ChatMessage> more) {
|
||||||
|
if (more.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int start = messages.size();
|
||||||
|
messages.addAll(more);
|
||||||
|
for (int i = 0; i < more.size(); i++) {
|
||||||
|
if (more.get(i).ts > lastSeenTs) {
|
||||||
|
highlightedPositions.add(start + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifyItemRangeInserted(start, more.size());
|
||||||
|
for (int i = 0; i < more.size(); i++) {
|
||||||
|
int pos = start + i;
|
||||||
|
if (highlightedPositions.contains(pos)) {
|
||||||
|
scheduleClearHighlight(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleClearHighlight(int position) {
|
||||||
|
handler.postDelayed(() -> {
|
||||||
|
if (highlightedPositions.remove(position)) {
|
||||||
|
notifyItemChanged(position);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double lastTs() {
|
||||||
|
if (messages.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return messages.get(messages.size() - 1).ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View v = LayoutInflater.from(parent.getContext())
|
||||||
|
.inflate(R.layout.item_chat, parent, false);
|
||||||
|
return new Holder(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull Holder holder, int position) {
|
||||||
|
ChatMessage m = messages.get(position);
|
||||||
|
boolean self = selfDeviceId != null && selfDeviceId.equals(m.device_id);
|
||||||
|
String time = timeFormat.format(new Date((long) (m.ts * 1000)));
|
||||||
|
|
||||||
|
FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) holder.bubble.getLayoutParams();
|
||||||
|
lp.gravity = self ? Gravity.END : Gravity.START;
|
||||||
|
holder.bubble.setLayoutParams(lp);
|
||||||
|
holder.bubble.setBackgroundColor(self ? COLOR_SELF_BG : COLOR_OTHER_BG);
|
||||||
|
|
||||||
|
holder.author.setText(self
|
||||||
|
? holder.itemView.getContext().getString(R.string.chat_self_label)
|
||||||
|
: m.device_id);
|
||||||
|
holder.text.setText(m.text);
|
||||||
|
holder.time.setText(time);
|
||||||
|
|
||||||
|
int bg = self ? COLOR_SELF_BG : COLOR_OTHER_BG;
|
||||||
|
if (highlightedPositions.contains(position)) {
|
||||||
|
holder.bubble.setBackgroundColor(blend(bg, COLOR_NEW_HIGHLIGHT));
|
||||||
|
} else {
|
||||||
|
holder.bubble.setBackgroundColor(bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int blend(int base, int overlay) {
|
||||||
|
int ba = Color.alpha(base);
|
||||||
|
int br = Color.red(base);
|
||||||
|
int bg = Color.green(base);
|
||||||
|
int bb = Color.blue(base);
|
||||||
|
int oa = Color.alpha(overlay);
|
||||||
|
int or = Color.red(overlay);
|
||||||
|
int og = Color.green(overlay);
|
||||||
|
int ob = Color.blue(overlay);
|
||||||
|
float ratio = oa / 255f;
|
||||||
|
int r = (int) (br * (1 - ratio) + or * ratio);
|
||||||
|
int g = (int) (bg * (1 - ratio) + og * ratio);
|
||||||
|
int b = (int) (bb * (1 - ratio) + ob * ratio);
|
||||||
|
return Color.argb(ba, r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return messages.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
static class Holder extends RecyclerView.ViewHolder {
|
||||||
|
final LinearLayout bubble;
|
||||||
|
final TextView author;
|
||||||
|
final TextView text;
|
||||||
|
final TextView time;
|
||||||
|
|
||||||
|
Holder(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
bubble = itemView.findViewById(R.id.chatBubble);
|
||||||
|
author = itemView.findViewById(R.id.chatAuthor);
|
||||||
|
text = itemView.findViewById(R.id.chatText);
|
||||||
|
time = itemView.findViewById(R.id.chatTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.VibrationEffect;
|
||||||
|
import android.os.Vibrator;
|
||||||
|
import android.os.VibratorManager;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.view.inputmethod.EditorInfo;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.EditText;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.graphics.Insets;
|
||||||
|
import androidx.core.view.ViewCompat;
|
||||||
|
import androidx.core.view.WindowInsetsCompat;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.grigowashere.loratester.LoraApp;
|
||||||
|
import com.grigowashere.loratester.R;
|
||||||
|
import com.grigowashere.loratester.TelemetryUploader;
|
||||||
|
import com.grigowashere.loratester.api.ChatMessage;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
public class ChatFragment extends Fragment {
|
||||||
|
|
||||||
|
private static final int VIBRATE_MS = 40;
|
||||||
|
|
||||||
|
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||||
|
private FragmentPollHelper pollHelper;
|
||||||
|
private TelemetryUploader uploader;
|
||||||
|
private ChatAdapter adapter;
|
||||||
|
private RecyclerView recycler;
|
||||||
|
private double chatSince;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAttach(@NonNull Context context) {
|
||||||
|
super.onAttach(context);
|
||||||
|
uploader = ((LoraApp) context.getApplicationContext()).getTelemetryUploader();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public View onCreateView(
|
||||||
|
@NonNull LayoutInflater inflater,
|
||||||
|
@Nullable ViewGroup container,
|
||||||
|
@Nullable Bundle savedInstanceState
|
||||||
|
) {
|
||||||
|
return inflater.inflate(R.layout.fragment_chat, container, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
|
recycler = view.findViewById(R.id.chatRecycler);
|
||||||
|
EditText input = view.findViewById(R.id.chatInput);
|
||||||
|
Button send = view.findViewById(R.id.chatSend);
|
||||||
|
View inputBar = view.findViewById(R.id.chatInputBar);
|
||||||
|
|
||||||
|
adapter = new ChatAdapter();
|
||||||
|
if (uploader != null) {
|
||||||
|
adapter.setSelfDeviceId(uploader.getDeviceId());
|
||||||
|
}
|
||||||
|
LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
|
||||||
|
recycler.setLayoutManager(layoutManager);
|
||||||
|
recycler.setAdapter(adapter);
|
||||||
|
pollHelper = new FragmentPollHelper(this, this::pollChat);
|
||||||
|
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(inputBar, (v, windowInsets) -> {
|
||||||
|
Insets ime = windowInsets.getInsets(WindowInsetsCompat.Type.ime());
|
||||||
|
v.setPadding(v.getPaddingLeft(), v.getPaddingTop(), v.getPaddingRight(), ime.bottom);
|
||||||
|
if (ime.bottom > 0 && adapter.getItemCount() > 0) {
|
||||||
|
scrollToLatest();
|
||||||
|
}
|
||||||
|
return windowInsets;
|
||||||
|
});
|
||||||
|
|
||||||
|
Runnable doSend = () -> sendMessage(input);
|
||||||
|
send.setOnClickListener(v -> doSend.run());
|
||||||
|
input.setOnEditorActionListener((v, actionId, event) -> {
|
||||||
|
if (actionId == EditorInfo.IME_ACTION_SEND) {
|
||||||
|
doSend.run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
input.setOnFocusChangeListener((v, hasFocus) -> {
|
||||||
|
if (hasFocus) {
|
||||||
|
scrollToLatest();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendMessage(EditText input) {
|
||||||
|
String text = input.getText().toString().trim();
|
||||||
|
if (text.isEmpty() || uploader == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.setText("");
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
uploader.getServerApi().postChat(uploader.getDeviceId(), text);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (pollHelper != null && pollHelper.canRun()) {
|
||||||
|
pollChat();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scrollToLatest() {
|
||||||
|
if (recycler == null || adapter.getItemCount() == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int last = adapter.getItemCount() - 1;
|
||||||
|
recycler.post(() -> recycler.smoothScrollToPosition(last));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void vibrateOnNewMessage() {
|
||||||
|
Context ctx = getContext();
|
||||||
|
if (ctx == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
VibratorManager vm = (VibratorManager) ctx.getSystemService(Context.VIBRATOR_MANAGER_SERVICE);
|
||||||
|
if (vm != null) {
|
||||||
|
Vibrator v = vm.getDefaultVibrator();
|
||||||
|
if (v != null && v.hasVibrator()) {
|
||||||
|
v.vibrate(VibrationEffect.createOneShot(
|
||||||
|
VIBRATE_MS,
|
||||||
|
VibrationEffect.DEFAULT_AMPLITUDE
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Vibrator v = (Vibrator) ctx.getSystemService(Context.VIBRATOR_SERVICE);
|
||||||
|
if (v != null && v.hasVibrator()) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
v.vibrate(VibrationEffect.createOneShot(
|
||||||
|
VIBRATE_MS,
|
||||||
|
VibrationEffect.DEFAULT_AMPLITUDE
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
v.vibrate(VIBRATE_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// ignore missing vibrator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
if (adapter != null && uploader != null) {
|
||||||
|
adapter.setSelfDeviceId(uploader.getDeviceId());
|
||||||
|
adapter.setLastSeenTs(chatSince);
|
||||||
|
}
|
||||||
|
if (pollHelper != null) {
|
||||||
|
pollHelper.start(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPause() {
|
||||||
|
if (pollHelper != null) {
|
||||||
|
pollHelper.stop();
|
||||||
|
}
|
||||||
|
super.onPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroyView() {
|
||||||
|
if (pollHelper != null) {
|
||||||
|
pollHelper.stop();
|
||||||
|
}
|
||||||
|
recycler = null;
|
||||||
|
super.onDestroyView();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
executor.shutdownNow();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pollChat() {
|
||||||
|
if (!pollHelper.canRun() || uploader == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String myId = uploader.getDeviceId();
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
List<ChatMessage> msgs = uploader.getServerApi().getChat(chatSince);
|
||||||
|
for (ChatMessage m : msgs) {
|
||||||
|
chatSince = Math.max(chatSince, m.ts);
|
||||||
|
}
|
||||||
|
if (!pollHelper.canRun() || msgs.isEmpty()) {
|
||||||
|
if (pollHelper.canRun()) {
|
||||||
|
pollHelper.scheduleNext(2500);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean vibrate = false;
|
||||||
|
for (ChatMessage m : msgs) {
|
||||||
|
if (m.device_id != null && !m.device_id.equals(myId)) {
|
||||||
|
vibrate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boolean shouldVibrate = vibrate;
|
||||||
|
requireActivity().runOnUiThread(() -> {
|
||||||
|
if (!pollHelper.canRun()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (adapter.getItemCount() == 0) {
|
||||||
|
adapter.setMessages(msgs);
|
||||||
|
} else {
|
||||||
|
adapter.appendMessages(msgs);
|
||||||
|
}
|
||||||
|
scrollToLatest();
|
||||||
|
if (shouldVibrate) {
|
||||||
|
vibrateOnNewMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
pollHelper.scheduleNext(2500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
|
/** Diverging color ramp: blue = below, green = level, brown = above. */
|
||||||
|
final class ElevationColorRamp {
|
||||||
|
|
||||||
|
private static final int ALPHA = 0x8C;
|
||||||
|
|
||||||
|
private ElevationColorRamp() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static int deltaToArgb(double deltaM) {
|
||||||
|
if (deltaM <= -8.0) {
|
||||||
|
return argb(0x1A, 0x4A, 0x8C);
|
||||||
|
}
|
||||||
|
if (deltaM <= -2.0) {
|
||||||
|
return lerp(argb(0x1A, 0x4A, 0x8C), argb(0x4F, 0xC3, 0xF7), (deltaM + 8.0) / 6.0);
|
||||||
|
}
|
||||||
|
if (deltaM <= 2.0) {
|
||||||
|
return lerp(argb(0x4F, 0xC3, 0xF7), argb(0x00, 0xFF, 0x88), (deltaM + 2.0) / 4.0);
|
||||||
|
}
|
||||||
|
if (deltaM <= 8.0) {
|
||||||
|
return lerp(argb(0x00, 0xFF, 0x88), argb(0xFF, 0xC1, 0x07), (deltaM - 2.0) / 6.0);
|
||||||
|
}
|
||||||
|
return argb(0x8B, 0x5A, 0x2B);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int argb(int r, int g, int b) {
|
||||||
|
return (ALPHA << 24) | (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int lerp(int from, int to, double t) {
|
||||||
|
t = Math.max(0.0, Math.min(1.0, t));
|
||||||
|
int a = (int) Math.round(((from >>> 24) & 0xFF) + t * (((to >>> 24) & 0xFF) - ((from >>> 24) & 0xFF)));
|
||||||
|
int r = (int) Math.round(((from >> 16) & 0xFF) + t * (((to >> 16) & 0xFF) - ((from >> 16) & 0xFF)));
|
||||||
|
int g = (int) Math.round(((from >> 8) & 0xFF) + t * (((to >> 8) & 0xFF) - ((from >> 8) & 0xFF)));
|
||||||
|
int b = (int) Math.round((from & 0xFF) + t * ((to & 0xFF) - (from & 0xFF)));
|
||||||
|
return (a << 24) | (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import com.grigowashere.loratester.api.ElevationGridResult;
|
||||||
|
|
||||||
|
import org.mapsforge.core.model.LatLong;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Builds a geo-referenced raster from elevation grid API response. */
|
||||||
|
final class ElevationHeatmapBitmap {
|
||||||
|
|
||||||
|
static final class Raster {
|
||||||
|
final Bitmap bitmap;
|
||||||
|
final LatLong northWest;
|
||||||
|
final LatLong southEast;
|
||||||
|
|
||||||
|
Raster(Bitmap bitmap, LatLong northWest, LatLong southEast) {
|
||||||
|
this.bitmap = bitmap;
|
||||||
|
this.northWest = northWest;
|
||||||
|
this.southEast = southEast;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ElevationHeatmapBitmap() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
static Raster build(ElevationGridResult grid) {
|
||||||
|
if (grid == null || !grid.ok || grid.points == null || grid.points.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int minI = Integer.MAX_VALUE;
|
||||||
|
int maxI = Integer.MIN_VALUE;
|
||||||
|
int minJ = Integer.MAX_VALUE;
|
||||||
|
int maxJ = Integer.MIN_VALUE;
|
||||||
|
double minLat = Double.MAX_VALUE;
|
||||||
|
double maxLat = -Double.MAX_VALUE;
|
||||||
|
double minLon = Double.MAX_VALUE;
|
||||||
|
double maxLon = -Double.MAX_VALUE;
|
||||||
|
|
||||||
|
Map<Long, ElevationGridResult.GridPoint> byIndex = new HashMap<>();
|
||||||
|
for (ElevationGridResult.GridPoint p : grid.points) {
|
||||||
|
minI = Math.min(minI, p.i);
|
||||||
|
maxI = Math.max(maxI, p.i);
|
||||||
|
minJ = Math.min(minJ, p.j);
|
||||||
|
maxJ = Math.max(maxJ, p.j);
|
||||||
|
minLat = Math.min(minLat, p.lat);
|
||||||
|
maxLat = Math.max(maxLat, p.lat);
|
||||||
|
minLon = Math.min(minLon, p.lon);
|
||||||
|
maxLon = Math.max(maxLon, p.lon);
|
||||||
|
byIndex.put(pack(p.i, p.j), p);
|
||||||
|
}
|
||||||
|
|
||||||
|
int cols = maxJ - minJ + 1;
|
||||||
|
int rows = maxI - minI + 1;
|
||||||
|
if (cols < 1 || rows < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int[] pixels = new int[cols * rows];
|
||||||
|
double radiusM = grid.radius_m > 0 ? grid.radius_m : 200.0;
|
||||||
|
double stepM = grid.step_m > 0 ? grid.step_m : 15.0;
|
||||||
|
|
||||||
|
for (int row = 0; row < rows; row++) {
|
||||||
|
int i = maxI - row;
|
||||||
|
for (int col = 0; col < cols; col++) {
|
||||||
|
int j = minJ + col;
|
||||||
|
double dist = Math.hypot(i * stepM, j * stepM);
|
||||||
|
if (dist > radiusM + stepM * 0.5) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ElevationGridResult.GridPoint p = byIndex.get(pack(i, j));
|
||||||
|
if (p != null && p.elevation_m != null) {
|
||||||
|
pixels[row * cols + col] = ElevationColorRamp.deltaToArgb(p.delta_m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Bitmap bitmap = Bitmap.createBitmap(cols, rows, Bitmap.Config.ARGB_8888);
|
||||||
|
bitmap.setPixels(pixels, 0, cols, 0, 0, cols, rows);
|
||||||
|
|
||||||
|
double halfStepLat = (stepM / 2.0) / 111_320.0;
|
||||||
|
double halfStepLon = (stepM / 2.0)
|
||||||
|
/ (111_320.0 * Math.max(Math.cos(Math.toRadians((minLat + maxLat) / 2.0)), 1e-6));
|
||||||
|
|
||||||
|
LatLong northWest = new LatLong(maxLat + halfStepLat, minLon - halfStepLon);
|
||||||
|
LatLong southEast = new LatLong(minLat - halfStepLat, maxLon + halfStepLon);
|
||||||
|
return new Raster(bitmap, northWest, southEast);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long pack(int i, int j) {
|
||||||
|
return ((long) i << 32) ^ (j & 0xFFFFFFFFL);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
|
||||||
|
import com.grigowashere.loratester.api.ElevationGridResult;
|
||||||
|
|
||||||
|
import org.mapsforge.core.graphics.Canvas;
|
||||||
|
import org.mapsforge.core.model.BoundingBox;
|
||||||
|
import org.mapsforge.core.model.LatLong;
|
||||||
|
import org.mapsforge.core.model.Point;
|
||||||
|
import org.mapsforge.core.util.MercatorProjection;
|
||||||
|
import org.mapsforge.map.android.graphics.AndroidBitmap;
|
||||||
|
import org.mapsforge.map.layer.Layer;
|
||||||
|
|
||||||
|
/** Geo-referenced elevation heatmap overlay for Mapsforge. */
|
||||||
|
public class ElevationHeatmapLayer extends Layer {
|
||||||
|
|
||||||
|
private static final int TILE_SIZE = 256;
|
||||||
|
|
||||||
|
private Bitmap sourceBitmap;
|
||||||
|
private org.mapsforge.core.graphics.Bitmap mapsforgeBitmap;
|
||||||
|
private LatLong northWest;
|
||||||
|
private LatLong southEast;
|
||||||
|
|
||||||
|
public void setGrid(ElevationGridResult grid) {
|
||||||
|
sourceBitmap = null;
|
||||||
|
mapsforgeBitmap = null;
|
||||||
|
|
||||||
|
ElevationHeatmapBitmap.Raster raster = ElevationHeatmapBitmap.build(grid);
|
||||||
|
if (raster == null) {
|
||||||
|
northWest = null;
|
||||||
|
southEast = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sourceBitmap = raster.bitmap;
|
||||||
|
northWest = raster.northWest;
|
||||||
|
southEast = raster.southEast;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasData() {
|
||||||
|
return sourceBitmap != null && northWest != null && southEast != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void draw(BoundingBox boundingBox, byte zoomLevel, Canvas canvas, Point topLeftPoint) {
|
||||||
|
if (!hasData()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BoundingBox rasterBox = new BoundingBox(
|
||||||
|
southEast.latitude,
|
||||||
|
northWest.longitude,
|
||||||
|
northWest.latitude,
|
||||||
|
southEast.longitude
|
||||||
|
);
|
||||||
|
if (!boundingBox.intersects(rasterBox)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long mapSize = MercatorProjection.getMapSize(zoomLevel, TILE_SIZE);
|
||||||
|
int left = (int) Math.round(
|
||||||
|
MercatorProjection.longitudeToPixelX(northWest.longitude, mapSize) - topLeftPoint.x);
|
||||||
|
int top = (int) Math.round(
|
||||||
|
MercatorProjection.latitudeToPixelY(northWest.latitude, mapSize) - topLeftPoint.y);
|
||||||
|
int right = (int) Math.round(
|
||||||
|
MercatorProjection.longitudeToPixelX(southEast.longitude, mapSize) - topLeftPoint.x);
|
||||||
|
int bottom = (int) Math.round(
|
||||||
|
MercatorProjection.latitudeToPixelY(southEast.latitude, mapSize) - topLeftPoint.y);
|
||||||
|
|
||||||
|
int width = right - left;
|
||||||
|
int height = bottom - top;
|
||||||
|
if (width <= 0 || height <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapsforgeBitmap == null) {
|
||||||
|
mapsforgeBitmap = new AndroidBitmap(sourceBitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.drawBitmap(
|
||||||
|
mapsforgeBitmap,
|
||||||
|
0, 0, sourceBitmap.getWidth(), sourceBitmap.getHeight(),
|
||||||
|
left, top, right, bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.LinearGradient;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.RectF;
|
||||||
|
import android.graphics.Shader;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import com.grigowashere.loratester.R;
|
||||||
|
|
||||||
|
/** Color scale for elevation heatmap (relative to GPS center). */
|
||||||
|
public class ElevationHeatmapLegendView extends View {
|
||||||
|
|
||||||
|
private static final double DELTA_MIN = -10.0;
|
||||||
|
private static final double DELTA_MAX = 10.0;
|
||||||
|
|
||||||
|
private final Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint barPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint titlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final RectF barRect = new RectF();
|
||||||
|
private final RectF bgRect = new RectF();
|
||||||
|
|
||||||
|
public ElevationHeatmapLegendView(Context context) {
|
||||||
|
super(context);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ElevationHeatmapLegendView(Context context, @Nullable AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ElevationHeatmapLegendView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||||
|
super(context, attrs, defStyleAttr);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void init() {
|
||||||
|
bgPaint.setColor(0xCC0F3460);
|
||||||
|
labelPaint.setColor(0xFFEEEEEE);
|
||||||
|
labelPaint.setTextSize(sp(9));
|
||||||
|
titlePaint.setColor(0xFFFFFFFF);
|
||||||
|
titlePaint.setTextSize(sp(9));
|
||||||
|
titlePaint.setFakeBoldText(true);
|
||||||
|
setLayerType(LAYER_TYPE_SOFTWARE, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private float sp(float value) {
|
||||||
|
return value * getResources().getDisplayMetrics().scaledDensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float dp(float value) {
|
||||||
|
return value * getResources().getDisplayMetrics().density;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||||
|
int w = (int) dp(118);
|
||||||
|
int h = (int) dp(118);
|
||||||
|
setMeasuredDimension(
|
||||||
|
resolveSize(w, widthMeasureSpec),
|
||||||
|
resolveSize(h, heightMeasureSpec));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas canvas) {
|
||||||
|
float pad = dp(6);
|
||||||
|
bgRect.set(pad, pad, getWidth() - pad, getHeight() - pad);
|
||||||
|
canvas.drawRoundRect(bgRect, dp(4), dp(4), bgPaint);
|
||||||
|
|
||||||
|
float titleY = bgRect.top + dp(12);
|
||||||
|
canvas.drawText(getContext().getString(R.string.map_heatmap_legend_title),
|
||||||
|
bgRect.left + dp(6), titleY, titlePaint);
|
||||||
|
|
||||||
|
float barLeft = bgRect.left + dp(8);
|
||||||
|
float barTop = titleY + dp(6);
|
||||||
|
float barBottom = bgRect.bottom - dp(8);
|
||||||
|
float barRight = barLeft + dp(14);
|
||||||
|
barRect.set(barLeft, barTop, barRight, barBottom);
|
||||||
|
|
||||||
|
int[] colors = sampleRampColors(24);
|
||||||
|
float[] positions = new float[colors.length];
|
||||||
|
for (int i = 0; i < colors.length; i++) {
|
||||||
|
positions[i] = i / (float) (colors.length - 1);
|
||||||
|
}
|
||||||
|
barPaint.setShader(new LinearGradient(
|
||||||
|
barRect.left, barRect.top, barRect.left, barRect.bottom,
|
||||||
|
colors, positions, Shader.TileMode.CLAMP));
|
||||||
|
canvas.drawRoundRect(barRect, dp(2), dp(2), barPaint);
|
||||||
|
barPaint.setShader(null);
|
||||||
|
|
||||||
|
float textX = barRect.right + dp(6);
|
||||||
|
drawLegendRow(canvas, textX, barRect.top + dp(4),
|
||||||
|
getContext().getString(R.string.map_heatmap_legend_high),
|
||||||
|
"+8 m");
|
||||||
|
drawLegendRow(canvas, textX, (barRect.top + barRect.bottom) / 2f,
|
||||||
|
getContext().getString(R.string.map_heatmap_legend_level),
|
||||||
|
"0 m");
|
||||||
|
drawLegendRow(canvas, textX, barRect.bottom - dp(4),
|
||||||
|
getContext().getString(R.string.map_heatmap_legend_low),
|
||||||
|
"-8 m");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawLegendRow(Canvas canvas, float x, float centerY, String label, String meters) {
|
||||||
|
float lineH = labelPaint.getTextSize();
|
||||||
|
canvas.drawText(label, x, centerY - dp(1), labelPaint);
|
||||||
|
canvas.drawText(meters, x, centerY + lineH - dp(2), labelPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int[] sampleRampColors(int steps) {
|
||||||
|
int[] colors = new int[steps];
|
||||||
|
for (int i = 0; i < steps; i++) {
|
||||||
|
double t = i / (double) (steps - 1);
|
||||||
|
double delta = DELTA_MAX + t * (DELTA_MIN - DELTA_MAX);
|
||||||
|
colors[i] = ElevationColorRamp.deltaToArgb(delta) | 0xFF000000;
|
||||||
|
}
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
import androidx.lifecycle.Lifecycle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe periodic polling: no callbacks after onPause / when fragment is detached.
|
||||||
|
*/
|
||||||
|
public final class FragmentPollHelper {
|
||||||
|
|
||||||
|
private final Fragment fragment;
|
||||||
|
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||||
|
private final Runnable task;
|
||||||
|
private boolean active;
|
||||||
|
|
||||||
|
public FragmentPollHelper(@NonNull Fragment fragment, @NonNull Runnable task) {
|
||||||
|
this.fragment = fragment;
|
||||||
|
this.task = task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start(long initialDelayMs) {
|
||||||
|
active = true;
|
||||||
|
handler.removeCallbacks(task);
|
||||||
|
handler.postDelayed(task, initialDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
active = false;
|
||||||
|
handler.removeCallbacks(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void scheduleNext(long delayMs) {
|
||||||
|
if (!active || !fragment.isAdded()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handler.removeCallbacks(task);
|
||||||
|
handler.postDelayed(task, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canRun() {
|
||||||
|
return active
|
||||||
|
&& fragment.isAdded()
|
||||||
|
&& fragment.getView() != null
|
||||||
|
&& fragment.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.grigowashere.loratester.R;
|
||||||
|
import com.grigowashere.loratester.api.TelemetryHistoryItem;
|
||||||
|
import com.grigowashere.loratester.telnet.LoraStatsFormatter;
|
||||||
|
|
||||||
|
import java.text.DateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
public class HistoryAdapter extends RecyclerView.Adapter<HistoryAdapter.Holder> {
|
||||||
|
|
||||||
|
private final DateFormat timeFormat =
|
||||||
|
DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.getDefault());
|
||||||
|
private final List<String> lines = new ArrayList<>();
|
||||||
|
|
||||||
|
public void setItems(List<TelemetryHistoryItem> items) {
|
||||||
|
lines.clear();
|
||||||
|
if (items == null) {
|
||||||
|
notifyDataSetChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (TelemetryHistoryItem item : items) {
|
||||||
|
String time = timeFormat.format(new Date((long) (item.ts * 1000)));
|
||||||
|
String role = item.role != null ? item.role : "—";
|
||||||
|
String rssi = item.rssi != null ? item.rssi + " dBm" : "—";
|
||||||
|
String summary = LoraStatsFormatter.formatMeta(item.meta);
|
||||||
|
String shortMeta = summary.length() > 80
|
||||||
|
? summary.substring(0, 80) + "…"
|
||||||
|
: summary;
|
||||||
|
lines.add(time + " · " + role + " · " + rssi
|
||||||
|
+ (shortMeta.isEmpty() ? "" : "\n" + shortMeta));
|
||||||
|
}
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View v = LayoutInflater.from(parent.getContext())
|
||||||
|
.inflate(R.layout.item_history, parent, false);
|
||||||
|
return new Holder(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull Holder holder, int position) {
|
||||||
|
holder.line.setText(lines.get(position));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return lines.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
static class Holder extends RecyclerView.ViewHolder {
|
||||||
|
final TextView line;
|
||||||
|
|
||||||
|
Holder(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
line = itemView.findViewById(R.id.historyLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
import androidx.fragment.app.FragmentActivity;
|
||||||
|
import androidx.viewpager2.adapter.FragmentStateAdapter;
|
||||||
|
|
||||||
|
public class MainPagerAdapter extends FragmentStateAdapter {
|
||||||
|
|
||||||
|
public MainPagerAdapter(@NonNull FragmentActivity activity) {
|
||||||
|
super(activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public Fragment createFragment(int position) {
|
||||||
|
return switch (position) {
|
||||||
|
case 0 -> new MapFragment();
|
||||||
|
case 1 -> new StatsFragment();
|
||||||
|
case 2 -> new AtFragment();
|
||||||
|
case 3 -> new ChatFragment();
|
||||||
|
default -> new SettingsFragment();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
static org.mapsforge.core.graphics.Paint linePaint(int argb, float strokeWidth) {
|
||||||
|
int a = (argb >> 24) & 0xFF;
|
||||||
|
int r = (argb >> 16) & 0xFF;
|
||||||
|
int g = (argb >> 8) & 0xFF;
|
||||||
|
int b = argb & 0xFF;
|
||||||
|
return linePaint(AndroidGraphicFactory.INSTANCE.createColor(r, g, b, a), strokeWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.TableLayout;
|
||||||
|
import android.widget.TableRow;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import com.google.android.material.button.MaterialButton;
|
||||||
|
import com.grigowashere.loratester.R;
|
||||||
|
import com.grigowashere.loratester.model.RadioSnapshot;
|
||||||
|
import com.grigowashere.loratester.telnet.LoraStatsFormatter;
|
||||||
|
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class RadioComparePanel extends LinearLayout {
|
||||||
|
|
||||||
|
private static final int COLOR_TX = 0xFFE94560;
|
||||||
|
private static final int COLOR_RX = 0xFF4FC3F7;
|
||||||
|
private static final int COLOR_CHANGED = 0x33E94560;
|
||||||
|
|
||||||
|
private TextView txHeader;
|
||||||
|
private TextView rxHeader;
|
||||||
|
private TableLayout dynamicTable;
|
||||||
|
private TableLayout staticTable;
|
||||||
|
private MaterialButton staticToggle;
|
||||||
|
private boolean staticExpanded;
|
||||||
|
|
||||||
|
public RadioComparePanel(Context context) {
|
||||||
|
super(context);
|
||||||
|
init(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RadioComparePanel(Context context, @Nullable AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
init(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void init(Context context) {
|
||||||
|
setOrientation(VERTICAL);
|
||||||
|
LayoutInflater.from(context).inflate(R.layout.view_radio_compare, this, true);
|
||||||
|
txHeader = findViewById(R.id.compareTxHeader);
|
||||||
|
rxHeader = findViewById(R.id.compareRxHeader);
|
||||||
|
dynamicTable = findViewById(R.id.compareDynamicTable);
|
||||||
|
staticTable = findViewById(R.id.compareStaticTable);
|
||||||
|
staticToggle = findViewById(R.id.compareStaticToggle);
|
||||||
|
staticToggle.setOnClickListener(v -> {
|
||||||
|
staticExpanded = !staticExpanded;
|
||||||
|
staticTable.setVisibility(staticExpanded ? VISIBLE : GONE);
|
||||||
|
staticToggle.setText(staticExpanded
|
||||||
|
? getContext().getString(R.string.stats_static_hide)
|
||||||
|
: getContext().getString(R.string.stats_static_toggle));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void bind(
|
||||||
|
RadioSnapshot txSnap,
|
||||||
|
RadioSnapshot rxSnap,
|
||||||
|
String txDeviceId,
|
||||||
|
String rxDeviceId,
|
||||||
|
Set<String> changedTx,
|
||||||
|
Set<String> changedRx
|
||||||
|
) {
|
||||||
|
txHeader.setText("TX · " + (txDeviceId != null ? txDeviceId : "—"));
|
||||||
|
rxHeader.setText("RX · " + (rxDeviceId != null ? rxDeviceId : "—"));
|
||||||
|
fillTable(dynamicTable, true, txSnap, rxSnap, changedTx, changedRx);
|
||||||
|
fillTable(staticTable, false, txSnap, rxSnap, changedTx, changedRx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fillTable(
|
||||||
|
TableLayout table,
|
||||||
|
boolean dynamic,
|
||||||
|
RadioSnapshot tx,
|
||||||
|
RadioSnapshot rx,
|
||||||
|
Set<String> changedTx,
|
||||||
|
Set<String> changedRx
|
||||||
|
) {
|
||||||
|
table.removeAllViews();
|
||||||
|
if (dynamic) {
|
||||||
|
addRow(table, "RSSI", fmtDbm(tx.rssiDbm), fmtDbm(rx.rssiDbm), "rssi", changedTx, changedRx);
|
||||||
|
addRow(table, "SNR", fmtSuffix(tx.snrDb, " dB"), fmtSuffix(rx.snrDb, " dB"), "snr", changedTx, changedRx);
|
||||||
|
addRow(table, "RX Quality", fmtSuffix(tx.rxQualityPercent, " %"), fmtSuffix(rx.rxQualityPercent, " %"),
|
||||||
|
"rxQuality", changedTx, changedRx);
|
||||||
|
addRow(table, "Пакет", fmtInt(tx.packet), fmtInt(rx.packet), "packet", changedTx, changedRx);
|
||||||
|
addRow(table, "Payload", str(tx.payload), str(rx.payload), "payload", changedTx, changedRx);
|
||||||
|
addRow(table, "PER", fmtSuffix(tx.perPercent, " %"), fmtSuffix(rx.perPercent, " %"), "per", changedTx, changedRx);
|
||||||
|
addRow(table, "Принято", fmtInt(tx.packetReceive), fmtInt(rx.packetReceive), "packetReceive", changedTx, changedRx);
|
||||||
|
addRow(table, "Всего", fmtInt(tx.packetTotal), fmtInt(rx.packetTotal), "packetTotal", changedTx, changedRx);
|
||||||
|
addRow(table, "Ошибки", fmtInt(tx.packetError), fmtInt(rx.packetError), "packetError", changedTx, changedRx);
|
||||||
|
addRow(table, "CRC err", fmtInt(tx.crcError), fmtInt(rx.crcError), "crcError", changedTx, changedRx);
|
||||||
|
addRow(table, "Preamble", fmtInt(tx.preambleDetected), fmtInt(rx.preambleDetected),
|
||||||
|
"preambleDetected", changedTx, changedRx);
|
||||||
|
addRow(table, "Header OK", fmtInt(tx.headerValid), fmtInt(rx.headerValid), "headerValid", changedTx, changedRx);
|
||||||
|
addRow(table, "TX spd", fmtSuffix(tx.txPktPerS, " p/s"), fmtSuffix(rx.txPktPerS, " p/s"), "txSpeed", changedTx, changedRx);
|
||||||
|
addRow(table, "RX spd", fmtSuffix(tx.rxPktPerS, " p/s"), fmtSuffix(rx.rxPktPerS, " p/s"), "rxSpeed", changedTx, changedRx);
|
||||||
|
} else {
|
||||||
|
addRow(table, "Роль", LoraStatsFormatter.roleLabel(tx.role), LoraStatsFormatter.roleLabel(rx.role), "role", changedTx, changedRx);
|
||||||
|
addRow(table, "Частота", fmtMhz(tx.frequencyMhz), fmtMhz(rx.frequencyMhz), "frequency", changedTx, changedRx);
|
||||||
|
addRow(table, "SF", fmtInt(tx.sf), fmtInt(rx.sf), "sf", changedTx, changedRx);
|
||||||
|
addRow(table, "BW", fmtBw(tx.bwKhz), fmtBw(rx.bwKhz), "bw", changedTx, changedRx);
|
||||||
|
addRow(table, "Мощн.", fmtDbm(tx.powerDbm), fmtDbm(rx.powerDbm), "power", changedTx, changedRx);
|
||||||
|
addRow(table, "Code Rate", str(tx.codeRate), str(rx.codeRate), "codeRate", changedTx, changedRx);
|
||||||
|
addRow(table, "Preamble", fmtInt(tx.preambleLength), fmtInt(rx.preambleLength), "preambleLength", changedTx, changedRx);
|
||||||
|
addRow(table, "LDR", str(tx.lowDataRateOpt), str(rx.lowDataRateOpt), "lowDataRateOpt", changedTx, changedRx);
|
||||||
|
addRow(table, "CRC", fmtCrc(tx.crcEnabled), fmtCrc(rx.crcEnabled), "crc", changedTx, changedRx);
|
||||||
|
addRow(table, "Payl.len", fmtSuffixInt(tx.payloadLengthBytes, " B"), fmtSuffixInt(rx.payloadLengthBytes, " B"),
|
||||||
|
"payloadLength", changedTx, changedRx);
|
||||||
|
addRow(table, "TX Timeout", fmtSuffix(tx.txTimeoutMs, " ms"), fmtSuffix(rx.txTimeoutMs, " ms"),
|
||||||
|
"txTimeout", changedTx, changedRx);
|
||||||
|
addRow(table, "On Air", fmtSuffix(tx.onAirMs, " ms"), fmtSuffix(rx.onAirMs, " ms"), "onAir", changedTx, changedRx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addRow(
|
||||||
|
TableLayout table,
|
||||||
|
String label,
|
||||||
|
String txVal,
|
||||||
|
String rxVal,
|
||||||
|
String changeKey,
|
||||||
|
Set<String> changedTx,
|
||||||
|
Set<String> changedRx
|
||||||
|
) {
|
||||||
|
TableRow row = new TableRow(getContext());
|
||||||
|
TextView lbl = cell(label, 0xFFAAAAAA, false);
|
||||||
|
TextView tx = cell(txVal, COLOR_TX, changedTx != null && changedTx.contains(changeKey));
|
||||||
|
TextView rx = cell(rxVal, COLOR_RX, changedRx != null && changedRx.contains(changeKey));
|
||||||
|
row.addView(lbl);
|
||||||
|
row.addView(tx);
|
||||||
|
row.addView(rx);
|
||||||
|
table.addView(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextView cell(String text, int color, boolean changed) {
|
||||||
|
TextView tv = new TextView(getContext());
|
||||||
|
tv.setText(text != null ? text : "—");
|
||||||
|
tv.setTextColor(color);
|
||||||
|
tv.setTextSize(11f);
|
||||||
|
tv.setPadding(4, 2, 4, 2);
|
||||||
|
if (changed) {
|
||||||
|
tv.setBackgroundColor(COLOR_CHANGED);
|
||||||
|
}
|
||||||
|
return tv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assign TX/RX snapshots by device role. */
|
||||||
|
public static void bindByRole(
|
||||||
|
RadioComparePanel panel,
|
||||||
|
RadioSnapshot local,
|
||||||
|
RadioSnapshot peer,
|
||||||
|
String localId,
|
||||||
|
String peerId,
|
||||||
|
Set<String> changedLocal,
|
||||||
|
Set<String> changedPeer
|
||||||
|
) {
|
||||||
|
RadioSnapshot tx = local;
|
||||||
|
RadioSnapshot rx = peer;
|
||||||
|
String txId = localId;
|
||||||
|
String rxId = peerId;
|
||||||
|
Set<String> chTx = changedLocal;
|
||||||
|
Set<String> chRx = changedPeer;
|
||||||
|
if (StatsExtractor.ROLE_RX.equals(local != null ? local.role : null)) {
|
||||||
|
tx = peer;
|
||||||
|
rx = local;
|
||||||
|
txId = peerId;
|
||||||
|
rxId = localId;
|
||||||
|
chTx = changedPeer;
|
||||||
|
chRx = changedLocal;
|
||||||
|
}
|
||||||
|
if (tx == null) tx = RadioSnapshot.empty();
|
||||||
|
if (rx == null) rx = RadioSnapshot.empty();
|
||||||
|
panel.bind(tx, rx, txId, rxId, chTx, chRx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(String v) {
|
||||||
|
return v != null && !v.isEmpty() ? v : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmtInt(Integer v) {
|
||||||
|
return v != null ? String.valueOf(v) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmtDbm(Double v) {
|
||||||
|
return v != null ? String.format(Locale.US, "%.0f dBm", v) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmtMhz(Double v) {
|
||||||
|
return v != null ? String.format(Locale.US, "%.3f MHz", v) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmtSuffix(Double v, String suffix) {
|
||||||
|
return v != null ? v + suffix : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmtBw(Double v) {
|
||||||
|
return v != null ? String.format(Locale.US, "%.2f kHz", v) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmtCrc(Boolean enabled) {
|
||||||
|
if (enabled == null) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
return enabled ? "On" : "Off";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmtSuffixInt(Integer v, String suffix) {
|
||||||
|
return v != null ? v + suffix : "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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.LoraForegroundService;
|
||||||
|
import com.grigowashere.loratester.MainActivity;
|
||||||
|
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);
|
||||||
|
TextInputEditText editDeviceLabel = view.findViewById(R.id.editDeviceLabel);
|
||||||
|
SwitchMaterial switchTelnet = view.findViewById(R.id.switchTelnet);
|
||||||
|
Button batteryBtn = view.findViewById(R.id.btnBatteryOptimization);
|
||||||
|
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());
|
||||||
|
String savedLabel = settings.getDeviceLabel();
|
||||||
|
if (savedLabel != null) {
|
||||||
|
editDeviceLabel.setText(savedLabel);
|
||||||
|
}
|
||||||
|
switchTelnet.setChecked(settings.isTelnetEnabled());
|
||||||
|
deviceIdLabel.setText(getString(R.string.device_id_label, settings.getOrCreateDeviceId()));
|
||||||
|
|
||||||
|
batteryBtn.setOnClickListener(v -> {
|
||||||
|
if (MainActivity.isIgnoringBatteryOptimizations(requireContext())) {
|
||||||
|
Toast.makeText(requireContext(), R.string.battery_optimization_done, Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MainActivity.openBatteryOptimizationSettings(requireContext());
|
||||||
|
});
|
||||||
|
|
||||||
|
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.setDeviceLabel(textOf(editDeviceLabel, ""));
|
||||||
|
settings.setTelnetEnabled(switchTelnet.isChecked());
|
||||||
|
uploader.refreshApi();
|
||||||
|
uploader.registerPresence();
|
||||||
|
if (switchTelnet.isChecked()) {
|
||||||
|
uploader.startTelnet();
|
||||||
|
} else {
|
||||||
|
uploader.stopTelnet();
|
||||||
|
}
|
||||||
|
LoraForegroundService.ensureRunning(requireContext());
|
||||||
|
Toast.makeText(requireContext(), R.string.saved, Toast.LENGTH_SHORT).show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String textOf(TextInputEditText edit, String fallback) {
|
||||||
|
if (edit.getText() == null || edit.getText().toString().trim().isEmpty()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return edit.getText().toString().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.grigowashere.loratester.CommandPoller;
|
||||||
|
import com.grigowashere.loratester.LoraApp;
|
||||||
|
import com.grigowashere.loratester.PeerDevices;
|
||||||
|
import com.grigowashere.loratester.PeerStatsCache;
|
||||||
|
import com.grigowashere.loratester.R;
|
||||||
|
import com.grigowashere.loratester.TelemetryUploader;
|
||||||
|
import com.grigowashere.loratester.api.DeviceInfo;
|
||||||
|
import com.grigowashere.loratester.api.TelemetryHistoryItem;
|
||||||
|
import com.grigowashere.loratester.model.RadioSnapshot;
|
||||||
|
import com.grigowashere.loratester.telnet.StatsExtractor;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
public class StatsFragment extends Fragment {
|
||||||
|
|
||||||
|
private static final long SERVER_POLL_MS = 1000;
|
||||||
|
|
||||||
|
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||||
|
private FragmentPollHelper pollHelper;
|
||||||
|
private TelemetryUploader uploader;
|
||||||
|
private CommandPoller commandPoller;
|
||||||
|
private PeerStatsCache peerStatsCache;
|
||||||
|
private TextView statsStatus;
|
||||||
|
private TextView statsPeerWarning;
|
||||||
|
private RadioComparePanel radioComparePanel;
|
||||||
|
private RecyclerView statsHistoryList;
|
||||||
|
private final HistoryAdapter historyAdapter = new HistoryAdapter();
|
||||||
|
|
||||||
|
private RadioSnapshot prevLocal = RadioSnapshot.empty();
|
||||||
|
private RadioSnapshot prevPeer = RadioSnapshot.empty();
|
||||||
|
private RadioSnapshot snapLocal = RadioSnapshot.empty();
|
||||||
|
private RadioSnapshot snapPeer = RadioSnapshot.empty();
|
||||||
|
private String cachedPeerId;
|
||||||
|
private String cachedPeerError;
|
||||||
|
private int cachedDeviceCount;
|
||||||
|
|
||||||
|
private final TelemetryUploader.StatsListener statsListener = stats -> postRender();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAttach(@NonNull Context context) {
|
||||||
|
super.onAttach(context);
|
||||||
|
LoraApp app = (LoraApp) context.getApplicationContext();
|
||||||
|
uploader = app.getTelemetryUploader();
|
||||||
|
commandPoller = app.getCommandPoller();
|
||||||
|
peerStatsCache = app.getPeerStatsCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public View onCreateView(
|
||||||
|
@NonNull LayoutInflater inflater,
|
||||||
|
@Nullable ViewGroup container,
|
||||||
|
@Nullable Bundle savedInstanceState
|
||||||
|
) {
|
||||||
|
return inflater.inflate(R.layout.fragment_stats, container, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
|
statsStatus = view.findViewById(R.id.statsStatus);
|
||||||
|
statsPeerWarning = view.findViewById(R.id.statsPeerWarning);
|
||||||
|
radioComparePanel = view.findViewById(R.id.radioComparePanel);
|
||||||
|
statsHistoryList = view.findViewById(R.id.statsHistoryList);
|
||||||
|
statsHistoryList.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||||
|
statsHistoryList.setAdapter(historyAdapter);
|
||||||
|
Button btnSimulate = view.findViewById(R.id.btnSimulate);
|
||||||
|
Button btnPushStats = view.findViewById(R.id.btnPushStats);
|
||||||
|
pollHelper = new FragmentPollHelper(this, this::refresh);
|
||||||
|
|
||||||
|
btnSimulate.setOnClickListener(v -> {
|
||||||
|
String chunk = """
|
||||||
|
SEND
|
||||||
|
Frequency: 433000000 Hz
|
||||||
|
Power: 22 dBm
|
||||||
|
Spreading Factor: 7
|
||||||
|
Bandwidth: 125 kHz
|
||||||
|
Packet: 1
|
||||||
|
Payload: Sim TX
|
||||||
|
\u001b[2J""";
|
||||||
|
uploader.simulateChunk(chunk);
|
||||||
|
});
|
||||||
|
btnPushStats.setOnClickListener(v -> pushStatsToPeer());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pushStatsToPeer() {
|
||||||
|
if (commandPoller == null || cachedPeerId == null) {
|
||||||
|
toast(R.string.at_peer_unavailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Object> payload = new HashMap<>();
|
||||||
|
StatsExtractor.ExtractedStats localStats = uploader.getLastStats();
|
||||||
|
if (localStats != null && localStats.metaJson != null) {
|
||||||
|
payload.put("meta", localStats.metaJson);
|
||||||
|
}
|
||||||
|
if (snapLocal.role != null) payload.put("role", snapLocal.role);
|
||||||
|
if (snapLocal.rssiDbm != null) payload.put("rssi", snapLocal.rssiDbm);
|
||||||
|
if (snapLocal.sf != null) payload.put("sf", snapLocal.sf);
|
||||||
|
if (snapLocal.bwKhz != null) payload.put("bw", snapLocal.bwKhz);
|
||||||
|
commandPoller.postCommandToPeer(cachedPeerId, "stats_push", payload);
|
||||||
|
toast(R.string.stats_pushed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void toast(int resId) {
|
||||||
|
if (isAdded()) {
|
||||||
|
Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
if (uploader != null) {
|
||||||
|
uploader.setStatsListener(statsListener);
|
||||||
|
postRender();
|
||||||
|
}
|
||||||
|
if (pollHelper != null) {
|
||||||
|
pollHelper.start(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPause() {
|
||||||
|
if (uploader != null) {
|
||||||
|
uploader.setStatsListener(null);
|
||||||
|
}
|
||||||
|
if (pollHelper != null) {
|
||||||
|
pollHelper.stop();
|
||||||
|
}
|
||||||
|
super.onPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroyView() {
|
||||||
|
if (pollHelper != null) {
|
||||||
|
pollHelper.stop();
|
||||||
|
}
|
||||||
|
statsStatus = null;
|
||||||
|
statsPeerWarning = null;
|
||||||
|
radioComparePanel = null;
|
||||||
|
statsHistoryList = null;
|
||||||
|
pollHelper = null;
|
||||||
|
super.onDestroyView();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
executor.shutdownNow();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postRender() {
|
||||||
|
if (!isAdded() || radioComparePanel == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requireActivity().runOnUiThread(this::render);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refresh() {
|
||||||
|
if (!pollHelper.canRun() || uploader == null || statsStatus == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String deviceId = uploader.getDeviceId();
|
||||||
|
statsStatus.setText(getString(
|
||||||
|
R.string.stats_status,
|
||||||
|
deviceId,
|
||||||
|
uploader.isTelnetConnected()
|
||||||
|
? getString(R.string.connected) : getString(R.string.disconnected)
|
||||||
|
));
|
||||||
|
|
||||||
|
executor.execute(() -> {
|
||||||
|
List<TelemetryHistoryItem> history = null;
|
||||||
|
try {
|
||||||
|
List<DeviceInfo> devices = uploader.getServerApi().getDevices();
|
||||||
|
cachedDeviceCount = devices.size();
|
||||||
|
PeerDevices.Result peer = PeerDevices.resolve(devices, deviceId);
|
||||||
|
cachedPeerId = peer.peerId;
|
||||||
|
cachedPeerError = peer.error;
|
||||||
|
|
||||||
|
DeviceInfo self = null;
|
||||||
|
DeviceInfo peerDev = null;
|
||||||
|
for (DeviceInfo d : devices) {
|
||||||
|
if (deviceId.equals(d.device_id)) {
|
||||||
|
self = d;
|
||||||
|
} else if (peer.peerId != null && peer.peerId.equals(d.device_id)) {
|
||||||
|
peerDev = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StatsExtractor.ExtractedStats localStats = uploader.getLastStats();
|
||||||
|
snapLocal = localStats != null
|
||||||
|
? RadioSnapshot.fromExtracted(localStats)
|
||||||
|
: RadioSnapshot.fromMeta(
|
||||||
|
self != null ? self.meta : null,
|
||||||
|
self != null ? self.role : null,
|
||||||
|
self != null ? self.rssi : null);
|
||||||
|
|
||||||
|
PeerStatsCache.Snapshot push = peerStatsCache != null ? peerStatsCache.get() : null;
|
||||||
|
if (push != null && push.meta != null) {
|
||||||
|
snapPeer = RadioSnapshot.fromMeta(push.meta, push.role, push.rssi);
|
||||||
|
} else {
|
||||||
|
snapPeer = RadioSnapshot.fromMeta(
|
||||||
|
peerDev != null ? peerDev.meta : null,
|
||||||
|
peerDev != null ? peerDev.role : null,
|
||||||
|
peerDev != null ? peerDev.rssi : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
history = uploader.getServerApi().getTelemetryHistory(deviceId, 30);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
List<TelemetryHistoryItem> finalHistory = history;
|
||||||
|
if (isAdded()) {
|
||||||
|
requireActivity().runOnUiThread(() -> {
|
||||||
|
if (historyAdapter != null) {
|
||||||
|
historyAdapter.setItems(finalHistory);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
postRender();
|
||||||
|
if (pollHelper != null) {
|
||||||
|
pollHelper.scheduleNext(SERVER_POLL_MS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void render() {
|
||||||
|
if (!isAdded() || radioComparePanel == null || uploader == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (statsPeerWarning != null) {
|
||||||
|
if (cachedPeerError != null) {
|
||||||
|
statsPeerWarning.setVisibility(View.VISIBLE);
|
||||||
|
statsPeerWarning.setText(
|
||||||
|
getString(R.string.stats_two_devices_required, cachedDeviceCount));
|
||||||
|
} else {
|
||||||
|
statsPeerWarning.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var chLocal = snapLocal.diff(prevLocal);
|
||||||
|
var chPeer = snapPeer.diff(prevPeer);
|
||||||
|
RadioComparePanel.bindByRole(
|
||||||
|
radioComparePanel,
|
||||||
|
snapLocal,
|
||||||
|
snapPeer,
|
||||||
|
uploader.getDeviceId(),
|
||||||
|
cachedPeerId,
|
||||||
|
chLocal,
|
||||||
|
chPeer
|
||||||
|
);
|
||||||
|
prevLocal = snapLocal;
|
||||||
|
prevPeer = snapPeer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:color="#00FF88" android:state_activated="true" />
|
||||||
|
<item android:color="#FFFFFF" />
|
||||||
|
</selector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#CC0F3460" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M3,17h2v-3H3v3zM3,12h2V9H3v3zM7,17h2v-5H7v5zM7,7h2V5H7v2zM11,17h2V7h-2v10zM15,17h2v-3h-2v3zM15,12h2V9h-2v3zM19,17h2v-7h-2v7zM19,8h2V5h-2v3z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M14,6l-3.75,5 2.75,3.5L9,18H3l8.5,-10.5L14,6zM17.5,10.5L14,15h6l-2.5,-4.5z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,3C7.03,3 3,7.03 3,12h2c0,-3.87 3.13,-7 7,-7s7,3.13 7,7h2c0,-4.97 -4.03,-9 -9,-9zM12,7c-2.76,0 -5,2.24 -5,5h2c0,-1.66 1.34,-3 3,-3s3,1.34 3,3h2c0,-2.76 -2.24,-5 -5,-5zM12,11c-0.55,0 -1,0.45 -1,1h2c0,-0.55 -0.45,-1 -1,-1zM4.5,14.5L2,17l2.5,2.5 1.4,-1.4L4.8,17l1.1,-1.1 -1.4,-1.4zM19.5,14.5l-1.4,1.4 1.1,1.1 -1.1,1.1 1.4,1.4L22,17l-2.5,-2.5z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M4,18h16v2H4v-2zM6,15h2v2H6v-2zM16,15h2v2h-2v-2zM8,12h8v2H8v-2zM10,9h4v2h-4V9zM12,6c-2.2,0 -4,1.8 -4,4h2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2h2c0,-2.2 -1.8,-4 -4,-4z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<size
|
||||||
|
android:width="16dp"
|
||||||
|
android:height="16dp" />
|
||||||
|
<solid android:color="#E94560" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<size
|
||||||
|
android:width="18dp"
|
||||||
|
android:height="18dp" />
|
||||||
|
<solid android:color="#4FC3F7" />
|
||||||
|
<stroke
|
||||||
|
android:width="2dp"
|
||||||
|
android:color="#FFFFFF" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<size
|
||||||
|
android:width="18dp"
|
||||||
|
android:height="18dp" />
|
||||||
|
<solid android:color="#E94560" />
|
||||||
|
<stroke
|
||||||
|
android:width="2dp"
|
||||||
|
android:color="#FFFFFF" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,14c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,20c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5V19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45V19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
|
||||||
|
</vector>
|
||||||
@@ -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,228 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/atStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/at_current_values"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/atCurrentSnapshot"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:hint="@string/at_hint_fq_mhz">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/atInputFq"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="numberDecimal" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:hint="@string/at_hint_power">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/atInputPw"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="numberSigned" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:hint="SF">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/atInputSf"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="number" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/at_hint_bw"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/atBwSpinner"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/at_hint_cr"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/atCrSpinner"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:hint="@string/at_hint_pl">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/atInputPl"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="number" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:hint="@string/at_hint_tm">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/atInputTm"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="number" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/at_hint_role"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/atRoleSpinner"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||||
|
android:id="@+id/atTargetGroup"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
app:selectionRequired="true"
|
||||||
|
app:singleSelection="true">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/atTargetLocal"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/at_target_local" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/atTargetPeer"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/at_target_peer" />
|
||||||
|
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/atApplyBtn"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/at_apply" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/atStopBtn"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:text="S" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/atConsoleToggle"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/at_console_toggle" />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/atConsoleScroll"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="120dp"
|
||||||
|
android:background="#0D1117"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/atConsole"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:textColor="#C9D1D9"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</ScrollView>
|
||||||
|
</LinearLayout>
|
||||||
@@ -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,307 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<org.mapsforge.map.android.view.MapView
|
||||||
|
android:id="@+id/mapView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/mapStatusChip"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="top|start"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginEnd="56dp"
|
||||||
|
android:background="@drawable/bg_map_panel"
|
||||||
|
android:elevation="6dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="8dp"
|
||||||
|
android:paddingTop="6dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingBottom="6dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/iconServer"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:contentDescription="@string/status_server"
|
||||||
|
android:src="@drawable/ic_link_server" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="3dp"
|
||||||
|
android:text="@string/status_server_short"
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/iconLora"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:contentDescription="@string/status_lora"
|
||||||
|
android:src="@drawable/ic_link_lora" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="3dp"
|
||||||
|
android:text="@string/status_lora_short"
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/mapStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/mapDistance"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textColor="#00FF88"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/mapToolRail"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="end|center_vertical"
|
||||||
|
android:layout_marginEnd="6dp"
|
||||||
|
android:background="@drawable/bg_map_panel"
|
||||||
|
android:elevation="6dp"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="4dp">
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnToolCenter"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:contentDescription="@string/map_tool_center"
|
||||||
|
android:src="@drawable/ic_center"
|
||||||
|
app:tint="@color/map_tool_icon_tint" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnFindHill"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:contentDescription="@string/map_find_hill"
|
||||||
|
android:src="@drawable/ic_hill"
|
||||||
|
app:tint="@color/map_tool_icon_tint" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnHeatmap"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:contentDescription="@string/map_heatmap"
|
||||||
|
android:src="@drawable/ic_heatmap"
|
||||||
|
app:tint="@color/map_tool_icon_tint" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnTrack"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:contentDescription="@string/map_tool_track"
|
||||||
|
android:src="@drawable/ic_track"
|
||||||
|
app:tint="@color/map_tool_icon_tint" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnPairedTrack"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:contentDescription="@string/map_tool_paired"
|
||||||
|
android:src="@drawable/ic_paired_track"
|
||||||
|
app:tint="@color/map_tool_icon_tint" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnToolMore"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:contentDescription="@string/map_tool_more"
|
||||||
|
android:src="@drawable/ic_more_vert"
|
||||||
|
app:tint="@color/map_tool_icon_tint" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/mapToolDrawer"
|
||||||
|
android:layout_width="148dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="end|top"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginEnd="56dp"
|
||||||
|
android:background="@drawable/bg_map_panel"
|
||||||
|
android:elevation="6dp"
|
||||||
|
android:scrollbars="none"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/map_center_mode"
|
||||||
|
android:textColor="#00FF88"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButtonToggleGroup
|
||||||
|
android:id="@+id/mapCenterMode"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
app:selectionRequired="false"
|
||||||
|
app:singleSelection="true">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/centerMe"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="32dp"
|
||||||
|
android:text="@string/map_center_me"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/centerTx"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="32dp"
|
||||||
|
android:text="@string/map_center_tx"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/centerRx"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="32dp"
|
||||||
|
android:text="@string/map_center_rx"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/centerBoth"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="32dp"
|
||||||
|
android:text="@string/map_center_both"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/map_layer"
|
||||||
|
android:textColor="#CCCCCC"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/mapBasemap"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/map_heatmap_radius"
|
||||||
|
android:textColor="#CCCCCC"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/mapHeatmapRadius"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/mapHeatmapStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/mapHillStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textColor="#FFC107"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/mapLegend"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:text="@string/map_legend"
|
||||||
|
android:textColor="#CCCCCC"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/trackSpinner"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/trackStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textColor="#CCCCCC"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<com.grigowashere.loratester.ui.ElevationHeatmapLegendView
|
||||||
|
android:id="@+id/mapHeatmapLegend"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom|start"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:layout_marginBottom="10dp"
|
||||||
|
android:elevation="4dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?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.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:hint="@string/device_display_name">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/editDeviceLabel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textCapWords" />
|
||||||
|
</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:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="@string/battery_optimization_hint"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnBatteryOptimization"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/battery_optimization" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/deviceIdLabel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnSaveSettings"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="@string/save" />
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/statsStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/statsPeerWarning"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textColor="#FF9800"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<com.grigowashere.loratester.ui.RadioComparePanel
|
||||||
|
android:id="@+id/radioComparePanel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnPushStats"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/stats_push_peer" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnSimulate"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/simulate_telnet" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="@string/stats_history_title"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/statsHistoryList"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:nestedScrollingEnabled="false" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingStart="8dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="4dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/chatBubble"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="start"
|
||||||
|
android:background="#1A4A6E"
|
||||||
|
android:maxWidth="280dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/chatAuthor"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/chatText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textColor="#EEEEEE"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/chatTime"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/historyLine"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:paddingVertical="4dp"
|
||||||
|
android:textSize="11sp" />
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingBottom="4dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/compareTxHeader"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:textColor="#E94560"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/compareRxHeader"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:textColor="#4FC3F7"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TableLayout
|
||||||
|
android:id="@+id/compareDynamicTable"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:stretchColumns="1,2" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/compareStaticToggle"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minHeight="0dp"
|
||||||
|
android:padding="0dp"
|
||||||
|
android:text="@string/stats_static_toggle"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<TableLayout
|
||||||
|
android:id="@+id/compareStaticTable"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:stretchColumns="1,2"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
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 |