Compare commits
49
Commits
v4.5.9
...
feat/ios-kmp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ca122a734 | ||
|
|
904d1ffbea | ||
|
|
9deb517874 | ||
|
|
fa91e89024 | ||
|
|
1a3c94f1e0 | ||
|
|
abb73ffef7 | ||
|
|
cc4f156c83 | ||
|
|
3810e76ea2 | ||
|
|
5ed76dba31 | ||
|
|
78a6f270bf | ||
|
|
8445c17033 | ||
|
|
e315c87f05 | ||
|
|
e27d692e8f | ||
|
|
6c67aa2718 | ||
|
|
cb3ebe7870 | ||
|
|
e2263668ce | ||
|
|
91263674a9 | ||
|
|
9576607fc6 | ||
|
|
00b7b25cc1 | ||
|
|
a3e3932f73 | ||
|
|
3d3db784a3 | ||
|
|
08f9106749 | ||
|
|
6db10b5b72 | ||
|
|
cd70e3654c | ||
|
|
a25f0fe2cf | ||
|
|
b1dce9c518 | ||
|
|
13c5fc9584 | ||
|
|
e8e67b74cd | ||
|
|
758dc6d567 | ||
|
|
4567f46867 | ||
|
|
fe6d0af8b7 | ||
|
|
3a8086eb05 | ||
|
|
e8ad51fd1b | ||
|
|
6859c825d7 | ||
|
|
271488a43e | ||
|
|
321cd8f2fa | ||
|
|
0208a577c4 | ||
|
|
eb66a77cae | ||
|
|
0a67f74369 | ||
|
|
e0900778f0 | ||
|
|
2ff8643988 | ||
|
|
b19c78441c | ||
|
|
262ae45432 | ||
|
|
7ac54f0a37 | ||
|
|
ce68f48765 | ||
|
|
38f939bd49 | ||
|
|
bdc6db5aff | ||
|
|
92499b1cf1 | ||
|
|
828fd0fb6f |
No files matched your search
@@ -0,0 +1,58 @@
|
||||
# First step of the Kotlin Multiplatform port: core:domain becomes shareable code that compiles
|
||||
# and runs on iOS as well as Android. This workflow is the evidence for that claim - the same
|
||||
# orbital math (SGP4/SDP4), models and repository contracts are compiled by the Kotlin/Native
|
||||
# compiler for iOS and their unit tests run on an iOS simulator. It deliberately does not touch
|
||||
# the Android build rules: the second job only proves the existing app still builds.
|
||||
name: ios-kmp
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- feat/ios-kmp
|
||||
- ios-kmp
|
||||
|
||||
jobs:
|
||||
ios:
|
||||
name: iOS shared module
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- name: Compile shared module for iOS
|
||||
run: ./gradlew :core:domain:compileKotlinIosSimulatorArm64 --console=plain
|
||||
- name: Unit tests on the iOS simulator
|
||||
run: ./gradlew :core:domain:iosSimulatorArm64Test --console=plain
|
||||
- name: Upload iOS test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-domain-test-report
|
||||
path: core/domain/build/reports/tests/
|
||||
|
||||
android:
|
||||
name: Android regression
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- name: JVM unit tests (domain and data)
|
||||
run: ./gradlew :core:domain:jvmTest :core:data:testDebugUnitTest --console=plain
|
||||
- name: Assemble debug APK
|
||||
run: ./gradlew :app:assembleDebug --console=plain
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-reports
|
||||
path: |
|
||||
core/domain/build/reports/tests/
|
||||
core/data/build/reports/tests/
|
||||
@@ -70,4 +70,6 @@ fastlane/readme.md
|
||||
/app/release/output-metadata.json
|
||||
/app/release/
|
||||
/.kotlin/sessions/
|
||||
# Kotlin build caches in any module, not only the root - build-logic produces one too.
|
||||
.kotlin/
|
||||
.hermes/
|
||||
@@ -25,7 +25,7 @@ no tracking, no network required after initial data download.
|
||||
|----------------------|---------------------------------------------------------------------|
|
||||
| `app` | Entry point. Aggregates all modules |
|
||||
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
|
||||
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
|
||||
| `core:domain` | Multiplatform: JVM + iOS. Orbital math (SGP4/SDP4), models, contracts |
|
||||
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
|
||||
| `feature:map` | OSMDroid map with ground tracks |
|
||||
| `feature:passes` | Pass predictions and upcoming events |
|
||||
@@ -50,7 +50,7 @@ no tracking, no network required after initial data download.
|
||||
./gradlew test
|
||||
```
|
||||
|
||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 21 (`jdkVersion` in the version catalog)
|
||||
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
|
||||
|
||||
## Tech Stack
|
||||
@@ -71,7 +71,7 @@ Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
|
||||
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
|
||||
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
|
||||
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
|
||||
- `DataParser.kt` supports both via `parseTLE()` and `parseCSV()`, each taking the file text
|
||||
- Downloads auto-detect format; both produce identical `OrbitalData` objects
|
||||
- Existing code already supports transparent source transition without feature changes
|
||||
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
|
||||
@@ -103,11 +103,31 @@ Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
|
||||
- **KMP migration**: `core:domain` is now a Kotlin Multiplatform module (jvm + iosArm64/iosSimulatorArm64),
|
||||
so the orbital math, models and repository contracts are compiled once and shared with the iOS app; Android
|
||||
modules consume its jvm target. `commonMain` must stay free of JVM-only APIs (no `java.*`, `org.json`,
|
||||
`String.format`, `Locale`, `InputStream`) - `formatString` in `utility/CommonFormat.kt` covers printf.
|
||||
- **iOS app**: next step - an iOS shell that consumes the `Look4SatCore` framework plus the `expect`/`actual`
|
||||
platform pieces (map, location, sensors, notifications).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
|
||||
- `core:domain` is compiled for iOS too: anything added to its `commonMain` must exist in Kotlin/Native.
|
||||
`.github/workflows/ios-kmp.yml` compiles it for iOS and runs the shared tests on an iOS simulator.
|
||||
- Kotlin/JVM-only declarations still *resolve* in `commonMain` and only fail when the iOS target compiles:
|
||||
`@Synchronized` and `@Volatile` (the `kotlin.jvm` ones) are errors in common code since Kotlin 2.1, as are
|
||||
`toUpperCase`/`toLowerCase`/`capitalize` and `BigDecimal`. Use `kotlin.concurrent.Volatile`, and
|
||||
`utility/SynchronizedOn.kt` (a platform actual) when a monitor is needed. `check-multiplatform.sh` in the
|
||||
working copy's parent directory flags the rest.
|
||||
- Source sets: `commonTest` runs on both jvm and iOS, so no JUnit4, no `javaClass.classLoader` and no bare
|
||||
`assert()` there - a build without `-ea` skips those silently, and `-ea` is a JVM flag. Use `kotlin.test`.
|
||||
JVM-only tests (classpath resources, `Locale.setDefault`) belong in `jvmTest`; platform code in
|
||||
`jvmMain`/`iosMain`.
|
||||
- `formatString` has to match `java.lang.String.format` exactly, and that rounds the *shortest decimal
|
||||
representation* of a double half-up: `"%.3f"` of 0.5005 is `"0.501"`, even though the stored double is
|
||||
0.50049999999999994493. `CommonFormatOracleTest` (jvmTest) compares against real `String.format` over
|
||||
sampled doubles; `CommonFormatRoundingTest` (commonTest) pins literals so iOS checks the same digits.
|
||||
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
|
||||
- AMSAT status colours are ARGB literals in `core:data` (`AmSatRepository.statusColorOf`) and duplicated in
|
||||
|
||||
@@ -15,7 +15,22 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!--
|
||||
dataSync rather than location. The location type is refused outright unless a location
|
||||
runtime permission has already been granted - startForeground throws SecurityException -
|
||||
and this service reads the station position the operator typed into settings, so demanding
|
||||
location access to beacon a fixed QTH is both wrong and a way to fail silently for anyone
|
||||
who declined it. The dataSync six-hour cap that prompted the earlier switch applies only
|
||||
when targetSdk is 35 or higher, which this project does not declare.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!--
|
||||
Doze suspends network access and ignores wake locks even for a foreground service, so a
|
||||
coroutine delay wakes up on time and then cannot reach the network. An exact alarm with
|
||||
setExactAndAllowWhileIdle is the only scheduling that survives Doze, and at a five-minute
|
||||
floor the system's one-alarm-per-nine-minutes throttle is not a problem.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.rtbishop.look4sat.app
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
@@ -11,7 +12,10 @@ import android.content.SharedPreferences
|
||||
import android.widget.Toast
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.IBinder
|
||||
import com.rtbishop.look4sat.BuildConfig
|
||||
import com.rtbishop.look4sat.MainApplication
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
|
||||
@@ -35,10 +39,25 @@ class AprsForegroundService : Service() {
|
||||
const val ACTION_REPORT_NOW = AprsStore.ACTION_REPORT_NOW
|
||||
const val CHANNEL_ID = "aprs_service"
|
||||
const val NOTIF_ID = 101
|
||||
|
||||
/** Alarm-driven tick, kept separate so a manual report stays distinguishable. */
|
||||
const val ACTION_ALARM_TICK = "com.rtbishop.look4sat.APRS_ALARM_TICK"
|
||||
|
||||
private const val ALARM_REQUEST = 4101
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var reporter: AprsReporter? = null
|
||||
|
||||
/**
|
||||
* Handler on the main looper, for anything that must not run on the reporter's IO thread.
|
||||
*
|
||||
* onReport is invoked from AprsReporter's Dispatchers.IO scope, and Toast construction there
|
||||
* throws because that thread has no Looper - an exception the surrounding runCatching then
|
||||
* swallowed, so every report notice was silently discarded. The messages existed and no
|
||||
* operator ever saw one.
|
||||
*/
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var lastState: AprsState = AprsState.Idle
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
@@ -51,6 +70,12 @@ class AprsForegroundService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> stopReporting()
|
||||
ACTION_ALARM_TICK -> {
|
||||
// Woken by the exact alarm. Reporting once and then booking the next tick, rather
|
||||
// than using a repeating alarm, means a changed interval takes effect at once.
|
||||
if (reporter == null) startReporting() else reporter?.reportNow()
|
||||
scheduleNextTick()
|
||||
}
|
||||
ACTION_REPORT_NOW -> {
|
||||
if (reporter == null) {
|
||||
// Service not running: start it first (Toast hint when not configured)
|
||||
@@ -85,18 +110,33 @@ class AprsForegroundService : Service() {
|
||||
startForegroundWithNotification(cfg)
|
||||
val rep = AprsReporter(
|
||||
configProvider = { AprsStore.loadConfig(this) },
|
||||
// The real version, so the login line cannot drift from the build again.
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
positionProvider = { stationPosition() },
|
||||
onState = { lastState = it },
|
||||
onReport = { report ->
|
||||
AprsStore.saveLastReport(this, report.ok, report.detail)
|
||||
// Derived from this report rather than read from lastState: onState fires AFTER
|
||||
// onReport, so the notification was being rebuilt from the previous cycle's
|
||||
// verdict. For an alarm-driven report at 03:00 the notification is the only
|
||||
// surface that survives, and it was showing the wrong one.
|
||||
lastState = if (report.ok) AprsState.Connected else AprsState.Error
|
||||
updateNotification(cfg)
|
||||
// Report result always surfaces: success = short Toast, failure = long Toast + reason
|
||||
val msg = if (report.ok) {
|
||||
getString(R.string.aprs_toast_ok)
|
||||
} else {
|
||||
getString(R.string.aprs_toast_fail, report.detail)
|
||||
// An unverified login needs its own message: the write succeeded, so a bare
|
||||
// failure notice would send the operator looking at their network when the
|
||||
// problem is the passcode - and APRS-IS is dropping every packet meanwhile.
|
||||
// No receive-only notice. APRS-IS lets an unverified station connect and then
|
||||
// discards its packets, but this app only reports its own position - there is no
|
||||
// receiving side to it - so telling the operator they are "in receive-only mode"
|
||||
// named a state that does not exist here. Without a passcode the packet does not
|
||||
// arrive, and that is what the failure notice says.
|
||||
val msg = when {
|
||||
report.ok -> getString(R.string.aprs_toast_ok)
|
||||
!report.verified -> getString(R.string.aprs_toast_unverified)
|
||||
else -> getString(R.string.aprs_toast_fail, report.detail)
|
||||
}
|
||||
runCatching {
|
||||
mainHandler.post {
|
||||
Toast.makeText(this, msg,
|
||||
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
|
||||
}
|
||||
@@ -104,9 +144,12 @@ class AprsForegroundService : Service() {
|
||||
)
|
||||
reporter = rep
|
||||
rep.start()
|
||||
// The reporter beacons once on start; the alarm carries every one after that.
|
||||
scheduleNextTick()
|
||||
}
|
||||
|
||||
private fun stopReporting() {
|
||||
cancelTicks()
|
||||
reporter?.stop()
|
||||
reporter = null
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
@@ -117,6 +160,11 @@ class AprsForegroundService : Service() {
|
||||
try {
|
||||
val notif = buildNotification(cfg)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Must match the manifest attribute exactly: AOSP checks the passed type is a
|
||||
// subset of the declared one and throws otherwise, which the catch below turns
|
||||
// into a silent stopSelf(). Declaring location instead would additionally require
|
||||
// a granted location permission before this call, and the settings card asks only
|
||||
// for notifications - so that combination fails silently too.
|
||||
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, notif)
|
||||
@@ -197,4 +245,43 @@ class AprsForegroundService : Service() {
|
||||
reporter = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Book the next beacon with an exact alarm.
|
||||
*
|
||||
* A coroutine delay was used before, which Doze defeats: the timer fires but network access is
|
||||
* suspended and wake locks are ignored, even inside a foreground service. Only
|
||||
* setExactAndAllowWhileIdle survives that, and the five-minute floor keeps this well clear of
|
||||
* the system's throttle on how often such an alarm may repeat.
|
||||
*/
|
||||
private fun scheduleNextTick() {
|
||||
val cfg = AprsStore.loadConfig(this)
|
||||
if (!cfg.enabled) return
|
||||
val alarms = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val minutes = cfg.intervalMin.coerceAtLeast(AprsReporter.MIN_INTERVAL_MIN)
|
||||
val at = System.currentTimeMillis() + minutes * 60_000L
|
||||
runCatching {
|
||||
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()
|
||||
if (exact) {
|
||||
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, tickIntent())
|
||||
} else {
|
||||
// The operator revoked exact alarms. An inexact one still beacons, just whenever
|
||||
// the system decides, which beats not beaconing at all.
|
||||
alarms.set(AlarmManager.RTC_WAKEUP, at, tickIntent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelTicks() {
|
||||
val alarms = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
runCatching { alarms.cancel(tickIntent()) }
|
||||
}
|
||||
|
||||
private fun tickIntent(): PendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
ALARM_REQUEST,
|
||||
Intent(this, AprsForegroundService::class.java).setAction(ACTION_ALARM_TICK),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
}
|
||||
+39
-6
@@ -19,17 +19,50 @@ package com.rtbishop.look4sat.convention
|
||||
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.dependencies
|
||||
import org.gradle.kotlin.dsl.configure
|
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
|
||||
|
||||
/**
|
||||
* core:domain is the one module shared by every platform: it holds the orbital math, the data
|
||||
* models and the repository contracts, and none of it touches Android APIs. It is a Kotlin
|
||||
* Multiplatform module (JVM for Android, Kotlin/Native for iOS) rather than a JVM one so the
|
||||
* same compiled logic runs on both platforms instead of being reimplemented.
|
||||
*
|
||||
* Android modules consume the jvm target; the iOS app consumes the framework built from the
|
||||
* ios targets. Anything JVM-only - org.json, java.net, java.io, java.util.Locale,
|
||||
* String.format - cannot live in commonMain, because Kotlin/Native has none of them.
|
||||
*/
|
||||
@Suppress("Unused")
|
||||
internal class CoreDomainPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) = with(target) {
|
||||
applyPlugin(libs.plugins.kotlin.jvm)
|
||||
applyPlugin(libs.plugins.kotlin.multiplatform)
|
||||
applyPlugin(libs.plugins.kotlin.serialization)
|
||||
setupKotlin()
|
||||
dependencies {
|
||||
implementation(libs.kotlin.coroutines)
|
||||
implementation(libs.kotlin.serialization)
|
||||
extensions.configure<KotlinMultiplatformExtension> {
|
||||
jvmToolchain(libs.versions.jdkVersion.get().toInt())
|
||||
jvm()
|
||||
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
|
||||
iosTarget.binaries.framework {
|
||||
baseName = "Look4SatCore"
|
||||
isStatic = true
|
||||
}
|
||||
}
|
||||
// The bare name accessors (commonMain, jvmTest, ...) are script-only syntax;
|
||||
// plugin source has to resolve through the container members, so configure each
|
||||
// source set by name. getByName is safe here: jvm() above has just created the
|
||||
// jvm source sets, the same pattern the local probe build script relies on.
|
||||
sourceSets.getByName("commonMain").dependencies {
|
||||
implementation(libs.kotlin.coroutines)
|
||||
implementation(libs.kotlin.serialization)
|
||||
}
|
||||
sourceSets.getByName("commonTest").dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
implementation(libs.test.coroutines)
|
||||
}
|
||||
// JVM-only tests live here: the AndroidManifest check reads the file system, and
|
||||
// the formatter oracle tests compare against java.lang.String.format.
|
||||
sourceSets.getByName("jvmTest").dependencies {
|
||||
implementation(libs.test.junit4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -62,6 +62,12 @@ internal fun Project.setupAndroidApp() {
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
versionName = libs.versions.appVersionName.get()
|
||||
}
|
||||
// The APRS login line reports the app version to every station on the network. It used
|
||||
// to be a literal in core:data and drifted twice, so the app module now reads
|
||||
// BuildConfig.VERSION_NAME - which AGP 8 only generates when asked.
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
package com.rtbishop.look4sat.core.data.aprs
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsLogin
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||
import java.io.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.io.PrintWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
/**
|
||||
* APRS-IS TCP client (reverse-ported from APRSdroid TcpUploader.scala).
|
||||
* Plain-text protocol: one login line + one packet per line; 30 s reconnect after drop.
|
||||
* APRS-IS TCP client. Plain text: the server greets, the client logs in, then one packet per line.
|
||||
*
|
||||
* Two things here decide whether the operator can trust the app at all. A packet sent on a dead
|
||||
* socket must not report success, and a login the server refused to verify must not look like a
|
||||
* working connection - an unverified client stays connected while the server silently drops
|
||||
* everything it sends.
|
||||
*/
|
||||
class AprsIsClient(
|
||||
private val host: String,
|
||||
@@ -18,6 +25,7 @@ class AprsIsClient(
|
||||
private val callsign: String,
|
||||
private val ssid: String,
|
||||
private val passcode: Int,
|
||||
private val softwareName: String,
|
||||
private val version: String,
|
||||
private val filter: String = "",
|
||||
private val timeoutSec: Int = 120
|
||||
@@ -27,86 +35,188 @@ class AprsIsClient(
|
||||
private var reader: BufferedReader? = null
|
||||
private val lock = Any()
|
||||
|
||||
val isConnected: Boolean
|
||||
get() = synchronized(lock) { socket?.isConnected == true && !socket!!.isClosed }
|
||||
/**
|
||||
* What the server said about this login, or null before a login has been attempted.
|
||||
*
|
||||
* Kept as state rather than only thrown, because [AprsLogin.Outcome.Unverified] is not a
|
||||
* connection error: the socket is up and writes succeed. The operator has to be told, or
|
||||
* they will watch reports "succeed" for hours while nothing reaches the network.
|
||||
*/
|
||||
@Volatile
|
||||
var loginOutcome: AprsLogin.Outcome? = null
|
||||
private set
|
||||
|
||||
/** Connect + login (synchronous/blocking; call from a background thread) */
|
||||
val isConnected: Boolean
|
||||
get() = synchronized(lock) { socket?.isConnected == true && socket?.isClosed == false }
|
||||
|
||||
/** True when the server verified the passcode, so packets from this client are accepted. */
|
||||
val isVerified: Boolean get() = loginOutcome is AprsLogin.Outcome.Verified
|
||||
|
||||
/**
|
||||
* True only when the server told us it did NOT verify the login.
|
||||
*
|
||||
* Distinct from `!isVerified` on purpose. [AprsLogin.Outcome.Unverified] means the server
|
||||
* said so and really is discarding our packets. [AprsLogin.Outcome.Unknown] means we could
|
||||
* not recognise its answer - the packets may well be landing - so blaming the operator's
|
||||
* passcode for that would send them to fix something that is not broken.
|
||||
*/
|
||||
val isRefusedByServer: Boolean get() = loginOutcome is AprsLogin.Outcome.Unverified
|
||||
|
||||
/**
|
||||
* Connect and log in. Blocking; call from a background thread.
|
||||
*
|
||||
* Throws when the connection cannot be made or the server rejected the login outright.
|
||||
* A login the server accepted but did not verify returns normally and leaves
|
||||
* [loginOutcome] as [AprsLogin.Outcome.Unverified] for the caller to surface.
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
fun connect() {
|
||||
disconnect()
|
||||
loginOutcome = null
|
||||
val s = Socket()
|
||||
try {
|
||||
s.connect(InetSocketAddress(host, port), 30_000)
|
||||
s.soTimeout = timeoutSec * 1000
|
||||
s.connect(InetSocketAddress(host, port), CONNECT_MS)
|
||||
s.tcpNoDelay = true
|
||||
synchronized(lock) {
|
||||
socket = s
|
||||
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
|
||||
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
|
||||
}
|
||||
// Login line
|
||||
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
|
||||
writer?.println(login)
|
||||
// Read the login response (aprsc replies # logresp ... verified/unverified)
|
||||
runCatching {
|
||||
s.soTimeout = 8000
|
||||
val resp = reader?.readLine()
|
||||
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||
resp.contains("unverified", ignoreCase = true))) {
|
||||
throw IllegalArgumentException(resp.trim())
|
||||
}
|
||||
// Restore timeout
|
||||
s.soTimeout = timeoutSec * 1000
|
||||
s.soTimeout = LOGIN_MS
|
||||
// The spec has the client log in AFTER the server's identification line, so read the
|
||||
// greeting first. Anything starting with # is a comment and may be skipped.
|
||||
readGreeting(s)?.let { refusal ->
|
||||
// The server refused before we even logged in. Previously this verdict was
|
||||
// computed and then overwritten by readLoginResponse, so the branch was a lie.
|
||||
loginOutcome = refusal
|
||||
throw IllegalArgumentException(refusal.detail)
|
||||
}
|
||||
val login = AprsLogin.line(callsign, ssid, passcode, softwareName, version, filter)
|
||||
writer?.print(login)
|
||||
writer?.print(CRLF)
|
||||
writer?.flush()
|
||||
loginOutcome = readLoginResponse()
|
||||
s.soTimeout = timeoutSec * 1000
|
||||
val outcome = loginOutcome
|
||||
if (outcome is AprsLogin.Outcome.Rejected) throw IllegalArgumentException(outcome.detail)
|
||||
} catch (e: Exception) {
|
||||
// Close the local socket before re-throwing, so it does not leak when
|
||||
// an exception is raised after s.connect() but before socket = s.
|
||||
// Otherwise periodic reconnect attempts (AprsReporter every 1–60 min)
|
||||
// accumulate leaked fds until the process cannot open any more files.
|
||||
// Close the local socket before re-throwing so it does not leak when the failure
|
||||
// lands after connect() but before the field assignment - otherwise periodic
|
||||
// reconnects accumulate file descriptors until no more can be opened.
|
||||
runCatching { s.close() }
|
||||
synchronized(lock) {
|
||||
writer = null
|
||||
reader = null
|
||||
socket = null
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one APRS packet (one line) and tries to read the server ack.
|
||||
* Returns null=failed to send; Pair(ok, detail)=result (server error text lives in detail)
|
||||
* Consume the server's greeting comment, returning a refusal when it is not one.
|
||||
*
|
||||
* Absence is tolerated because some servers send none, but on a short probe window rather
|
||||
* than the full login timeout: waiting LOGIN_MS for a greeting that will never come cost
|
||||
* eight seconds on every single connect to such a server.
|
||||
*/
|
||||
private fun readGreeting(socket: Socket): AprsLogin.Outcome.Rejected? {
|
||||
val previous = socket.soTimeout
|
||||
return try {
|
||||
socket.soTimeout = GREETING_MS
|
||||
val line = reader?.readLine() ?: return null
|
||||
// A server that opens with anything but a comment is refusing us.
|
||||
if (line.startsWith("#")) null else AprsLogin.Outcome.Rejected(line.trim())
|
||||
} catch (ignored: IOException) {
|
||||
null
|
||||
} finally {
|
||||
runCatching { socket.soTimeout = previous }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read lines until the login verdict arrives, skipping keepalive comments.
|
||||
*
|
||||
* Bounded by the read timeout, so an unresponsive server cannot hang the caller.
|
||||
*/
|
||||
private fun readLoginResponse(): AprsLogin.Outcome {
|
||||
// Bounded by a deadline, not a line count: comments are free to skip, and a chatty
|
||||
// server that sent six of them before its verdict used to exhaust a fixed budget and
|
||||
// turn an accepted login into Unknown - telling the operator their passcode was wrong
|
||||
// when it had just been accepted.
|
||||
val deadline = System.currentTimeMillis() + LOGIN_MS
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val line = try {
|
||||
reader?.readLine()
|
||||
} catch (timeout: SocketTimeoutException) {
|
||||
return AprsLogin.Outcome.Unknown("no response within ${LOGIN_MS}ms")
|
||||
} catch (failure: IOException) {
|
||||
return AprsLogin.Outcome.Rejected(failure.message ?: "login read failed")
|
||||
} ?: return AprsLogin.Outcome.Rejected("connection closed during login")
|
||||
AprsLogin.parse(line)?.let { return it }
|
||||
}
|
||||
return AprsLogin.Outcome.Unknown("no login response recognised")
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one packet and report what happened.
|
||||
*
|
||||
* Returns null when there is no connection to write to. Otherwise a pair of whether the
|
||||
* packet went out and a detail string for the operator.
|
||||
*/
|
||||
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
|
||||
synchronized(lock) {
|
||||
val w = writer ?: return null
|
||||
w.println(packetLine)
|
||||
// Reading the response inside the same lock: disconnect() may run concurrently and
|
||||
// null these fields, and reading outside the lock raced with that - the read could
|
||||
// hit a just-closed socket and be reported as a successful send.
|
||||
// CRLF explicitly rather than println: the spec requires "TNC2 format terminated by
|
||||
// a carriage return, line feed sequence", and println emits the platform separator,
|
||||
// a bare LF on Android. An earlier draft of this method sent no terminator at all,
|
||||
// which leaves the server's line reader waiting forever while every send reports
|
||||
// success - exactly the failure this class exists to prevent.
|
||||
w.print(packetLine)
|
||||
w.print(CRLF)
|
||||
w.flush()
|
||||
if (w.checkError()) return Pair(false, "write failed")
|
||||
// Read the server response inside the same lock: disconnect() (called
|
||||
// concurrently from stop()/reconnect on another thread) nulls
|
||||
// writer/reader/socket and closes them. Reading outside the lock raced
|
||||
// with that: the response read could hit a just-closed socket and the
|
||||
// swallowing runCatching reported Pair(true,"OK") for a packet that
|
||||
// never left, or read through a stale reference. Serialising keeps
|
||||
// the read on the connection this thread just wrote to. The 3 s read
|
||||
// timeout bounds how long a concurrent disconnect waits.
|
||||
return runCatching {
|
||||
val s = socket ?: return@runCatching Pair(true, "OK")
|
||||
val oldTimeout = s.soTimeout
|
||||
s.soTimeout = 3000
|
||||
try {
|
||||
val resp = reader?.readLine()
|
||||
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||
resp.contains("error", ignoreCase = true))) {
|
||||
Pair(false, resp.trim())
|
||||
} else {
|
||||
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
|
||||
}
|
||||
} finally {
|
||||
s.soTimeout = oldTimeout
|
||||
}
|
||||
}.getOrElse { Pair(true, "OK") }
|
||||
val s = socket ?: return Pair(false, "not connected")
|
||||
val previousTimeout = s.soTimeout
|
||||
return try {
|
||||
s.soTimeout = ACK_MS
|
||||
classifyAck(reader?.readLine())
|
||||
} catch (timeout: SocketTimeoutException) {
|
||||
// Silence is the normal case: APRS-IS does not acknowledge a position report, so
|
||||
// nothing arriving means the line went out and the server had nothing to say.
|
||||
// Telling this apart from a broken connection is the point of this method - the
|
||||
// previous version treated EVERY exception as success, so a dead socket reported
|
||||
// "sent OK" and made every real failure invisible, including a rejected login.
|
||||
Pair(true, "sent")
|
||||
} catch (failure: IOException) {
|
||||
Pair(false, failure.message ?: "read failed")
|
||||
} finally {
|
||||
runCatching { s.soTimeout = previousTimeout }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one line (server response; throws on timeout) */
|
||||
fun readLine(): String? {
|
||||
return reader?.readLine()
|
||||
/**
|
||||
* Interpret whatever the server sent back after a packet.
|
||||
*
|
||||
* Shares AprsLogin's judgement rather than keeping its own, because the two disagreed in a way
|
||||
* that mattered: treating any leading `#` as harmless meant `# Port full` and `# Login by user
|
||||
* not allowed` - both of which mean the server is about to drop us - were reported as a
|
||||
* successful send. APRS-IS does not acknowledge position reports, so a harmless comment still
|
||||
* counts as sent; anything the server says that is not harmless does not.
|
||||
*/
|
||||
private fun classifyAck(response: String?): Pair<Boolean, String> {
|
||||
if (response == null) return Pair(false, "connection closed by server")
|
||||
return when (val verdict = AprsLogin.parse(response)) {
|
||||
// A keepalive or identification comment: no verdict, so the write stands.
|
||||
null -> Pair(true, "sent")
|
||||
is AprsLogin.Outcome.Rejected -> Pair(false, verdict.detail)
|
||||
// A late login verdict is not about this packet, and loginOutcome already carries it.
|
||||
else -> Pair(true, "sent")
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
@@ -119,4 +229,16 @@ class AprsIsClient(
|
||||
socket = null
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Line terminator the protocol requires, independent of the platform's own. */
|
||||
const val CRLF = "\r\n"
|
||||
|
||||
const val CONNECT_MS = 30_000
|
||||
const val LOGIN_MS = 8_000
|
||||
const val ACK_MS = 3_000
|
||||
|
||||
/** Probe window for the greeting, short because its absence is legitimate. */
|
||||
const val GREETING_MS = 2_000
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
package com.rtbishop.look4sat.core.data.aprs
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPosition
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsBeacon
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPasscode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -34,12 +33,39 @@ data class AprsReport(
|
||||
val timestamp: Long,
|
||||
val packet: String,
|
||||
val ok: Boolean,
|
||||
val detail: String
|
||||
val detail: String,
|
||||
/**
|
||||
* False only when the server told us it did not verify the login.
|
||||
*
|
||||
* Carried separately from [ok] because the two are independent: a refused client's writes
|
||||
* still succeed, so the packet leaves the phone and looks sent, while aprsc discards every
|
||||
* one of them. Without surfacing it the operator can watch reports succeed for hours with
|
||||
* nothing reaching the network. A login whose response we simply could not parse leaves this
|
||||
* true, since the packets may be landing and the passcode is not at fault.
|
||||
*/
|
||||
val verified: Boolean = true,
|
||||
/**
|
||||
* True when the operator asked for a receive-only connection.
|
||||
*
|
||||
* Distinguished from a refused login because both log in with -1 and the server answers
|
||||
* "unverified" to each: without this, deliberately choosing receive-only - the one way to
|
||||
* test a setup without putting anything on the network - was reported as a wrong passcode
|
||||
* and sent the operator to fix something they had set on purpose.
|
||||
*/
|
||||
|
||||
)
|
||||
|
||||
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
|
||||
class AprsReporter(
|
||||
private val configProvider: () -> AprsConfig,
|
||||
/**
|
||||
* The app's own version, reported to APRS-IS in the login line.
|
||||
*
|
||||
* Passed in because core:data has no BuildConfig. It used to be a literal here and drifted
|
||||
* exactly as predicted: it still read 4.6.0 two releases later, so every station on the
|
||||
* network was told the wrong version. A caller in the app module can read the real one.
|
||||
*/
|
||||
private val appVersion: String,
|
||||
private val positionProvider: () -> Pair<Double, Double>? = { null },
|
||||
private val onState: (AprsState) -> Unit = {},
|
||||
private val onReport: (AprsReport) -> Unit = {}
|
||||
@@ -59,12 +85,11 @@ class AprsReporter(
|
||||
onState(AprsState.Error)
|
||||
return
|
||||
}
|
||||
job = scope.launch {
|
||||
while (isActive) {
|
||||
reportOnce()
|
||||
delay(cfg.intervalMin.coerceAtLeast(1) * 60_000L)
|
||||
}
|
||||
}
|
||||
// One report now; the service's exact alarm drives every one after this. The loop that
|
||||
// used to live here relied on a coroutine delay, which Doze defeats - the timer fires on
|
||||
// schedule and then finds network access suspended, so the beacon stopped whenever the
|
||||
// screen locked while the notification still claimed it was running.
|
||||
job = scope.launch { reportOnce() }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
@@ -86,41 +111,92 @@ class AprsReporter(
|
||||
if (!cfg.enabled || cfg.callsign.isBlank()) return
|
||||
onState(AprsState.Connecting)
|
||||
try {
|
||||
// The packet is built BEFORE connecting: there is no reason to open a session and log
|
||||
// in only to discover there is nothing to send, which happened every five minutes for
|
||||
// an operator whose QTH was unset.
|
||||
val pos = positionProvider()
|
||||
val beacon = AprsBeacon.build(
|
||||
callsign = cfg.callsign,
|
||||
ssid = cfg.ssid,
|
||||
latitude = pos?.first,
|
||||
longitude = pos?.second,
|
||||
symbolTable = cfg.symbolTable,
|
||||
symbolCode = cfg.symbolCode,
|
||||
comment = cfg.statusText
|
||||
)
|
||||
// Nothing goes out without a position. Substituting 0,0 put this station in the Gulf
|
||||
// of Guinea on the global network, under the operator's own callsign.
|
||||
if (beacon is AprsBeacon.Result.Blocked) {
|
||||
onState(AprsState.Error)
|
||||
onReport(
|
||||
AprsReport(System.currentTimeMillis(), "", false, refusalDetail(beacon.refusal))
|
||||
)
|
||||
return
|
||||
}
|
||||
val packetLine = (beacon as AprsBeacon.Result.Line).text
|
||||
|
||||
val c = client ?: AprsIsClient(
|
||||
host = cfg.server,
|
||||
port = cfg.port,
|
||||
callsign = cfg.callsign,
|
||||
ssid = cfg.ssid,
|
||||
passcode = cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(cfg.callsign),
|
||||
version = "Look4Sat 4.5.4"
|
||||
// Never derives one: a blank or wrong entry logs in receive-only rather than
|
||||
// transmitting under a passcode the app invented for an unchecked licence.
|
||||
passcode = AprsPasscode.loginValue(cfg.callsign, cfg.passcode),
|
||||
// Two fields, because APRS-IS wants `vers <name> <version>` as separate tokens.
|
||||
softwareName = "Look4Sat",
|
||||
version = appVersion
|
||||
).also { client = it }
|
||||
if (!c.isConnected) c.connect()
|
||||
onState(AprsState.Connected)
|
||||
|
||||
val pos = positionProvider()
|
||||
val packetLine = buildPositionPacket(cfg, pos?.first, pos?.second)
|
||||
val result = c.sendPacket(packetLine)
|
||||
val ok = result?.first == true
|
||||
val sent = result?.first == true
|
||||
val detail = result?.second ?: "no connection"
|
||||
onReport(AprsReport(System.currentTimeMillis(), packetLine, ok, detail))
|
||||
// A write that succeeded on a login the server refused to verify is not a delivered
|
||||
// packet: aprsc takes it and drops it, which is what let every real failure hide.
|
||||
// Only an explicit refusal counts against us though - a login whose response we
|
||||
// could not parse may be working fine, and blaming the passcode for that would send
|
||||
// the operator to fix something that is not broken.
|
||||
val refused = c.isRefusedByServer
|
||||
val ok = sent && !refused
|
||||
// "sent" is the write's own verdict and reads as nonsense next to a failure - the card
|
||||
// showed "failed - sent" for a refused login. When the refusal is what failed the
|
||||
// report, say that instead.
|
||||
val reported = when {
|
||||
ok -> detail
|
||||
refused -> "login not verified"
|
||||
else -> detail
|
||||
}
|
||||
onReport(
|
||||
AprsReport(
|
||||
System.currentTimeMillis(), packetLine, ok, reported,
|
||||
verified = !refused
|
||||
)
|
||||
)
|
||||
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
|
||||
} catch (e: Exception) {
|
||||
runCatching { client?.disconnect() }
|
||||
client = null
|
||||
onState(AprsState.Error)
|
||||
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error"))
|
||||
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error", false))
|
||||
}
|
||||
}
|
||||
|
||||
/** Build position packet: BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME<status text */
|
||||
private fun buildPositionPacket(cfg: AprsConfig, lat: Double? = null, lon: Double? = null): String {
|
||||
val source = AprsPacket.formatCallSsid(cfg.callsign, cfg.ssid)
|
||||
val pos = AprsPosition(
|
||||
latitude = lat ?: 0.0,
|
||||
longitude = lon ?: 0.0,
|
||||
symbolTable = cfg.symbolTable.firstOrNull() ?: '/',
|
||||
symbolCode = cfg.symbolCode.firstOrNull() ?: '>'
|
||||
)
|
||||
return "$source>APRS:=${pos.toUncompressedString()}${cfg.statusText}"
|
||||
|
||||
/** A short reason for a refusal, for the operator's last-report line. */
|
||||
private fun refusalDetail(refusal: AprsBeacon.Refusal): String = when (refusal) {
|
||||
AprsBeacon.Refusal.NoPosition -> "no position yet"
|
||||
AprsBeacon.Refusal.NoCallsign -> "no callsign set"
|
||||
is AprsBeacon.Refusal.ImpossiblePosition -> "position out of range"
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
/** Floor for the reporting interval, in minutes. */
|
||||
const val MIN_INTERVAL_MIN = 5
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,6 +24,15 @@ object AprsStore {
|
||||
private const val KEY_STATUS = "status"
|
||||
private const val KEY_SYMBOL_TABLE = "symbol_table"
|
||||
private const val KEY_SYMBOL_CODE = "symbol_code"
|
||||
|
||||
/**
|
||||
* A house, not a car.
|
||||
*
|
||||
* Only fresh installs see this: saveConfig writes every key unconditionally and the enable
|
||||
* switch calls it, so anyone who has ever turned APRS on has both symbol keys on disk and this
|
||||
* fallback cannot reach them.
|
||||
*/
|
||||
private const val DEFAULT_SYMBOL_CODE = "-"
|
||||
private const val KEY_LAST_TIME = "last_report_time"
|
||||
private const val KEY_LAST_OK = "last_report_ok"
|
||||
private const val KEY_LAST_DETAIL = "last_report_detail"
|
||||
@@ -41,7 +50,7 @@ object AprsStore {
|
||||
intervalMin = p.getInt(KEY_INTERVAL, 5),
|
||||
statusText = p.getString(KEY_STATUS, "Look4Sat APRS") ?: "Look4Sat APRS",
|
||||
symbolTable = p.getString(KEY_SYMBOL_TABLE, "/") ?: "/",
|
||||
symbolCode = p.getString(KEY_SYMBOL_CODE, ">") ?: ">"
|
||||
symbolCode = p.getString(KEY_SYMBOL_CODE, DEFAULT_SYMBOL_CODE) ?: DEFAULT_SYMBOL_CODE
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwAntiAlias
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDetectionPool
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwShiftDecider
|
||||
@@ -35,6 +36,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.nio.FloatBuffer
|
||||
@@ -64,13 +66,28 @@ class CwDeepDecoder(
|
||||
private val isToneShiftEnabled: () -> Boolean = { false }
|
||||
) : ICwDecoder {
|
||||
|
||||
private companion object {
|
||||
// internal, not private: the archive timing is user-visible behaviour and its test asserts
|
||||
// against these constants directly rather than a copy that could silently drift.
|
||||
internal companion object {
|
||||
const val TAG = "CwDeepDecoder"
|
||||
const val MODEL_ASSET = "deepcw/model.onnx"
|
||||
const val METADATA_ASSET = "deepcw/model.onnx.json"
|
||||
|
||||
/** Evicted audio is decoded into permanent history once this much accumulates. */
|
||||
const val ARCHIVE_SECONDS = 15.0
|
||||
/**
|
||||
* Evicted audio is decoded into permanent history once this much accumulates.
|
||||
*
|
||||
* This is the delay before decoded text reaches the record, and it is additive with
|
||||
* the 20 s live window: at the old 15 s the record showed nothing for the first 35 s
|
||||
* of a session, and thereafter text that had scrolled out of the live window sat
|
||||
* invisible for up to 15 s before landing - the record appeared to stall and, when
|
||||
* it was still concatenating the live window, to delete what it had just shown.
|
||||
*
|
||||
* Each batch is one full inference, so this trades CPU for latency. 4 s holds the gap
|
||||
* under the ~4.7 s a seven-character call sign takes at 18 WPM - the record must not
|
||||
* stall for longer than the one thing an operator most needs to read back - while the
|
||||
* archive path still fires less than half as often as the 1.5 s live redecode cycle.
|
||||
*/
|
||||
const val ARCHIVE_SECONDS = 4.0
|
||||
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
|
||||
|
||||
/**
|
||||
@@ -103,9 +120,20 @@ class CwDeepDecoder(
|
||||
private val _decodedText = MutableStateFlow("")
|
||||
override val decodedText: StateFlow<String> = _decodedText.asStateFlow()
|
||||
|
||||
/**
|
||||
* Archived text: only appended to, so a pane bound to it never loses what it showed.
|
||||
*
|
||||
* This once carried a provisional tail meant to cover the gap while audio waited to be
|
||||
* archived, but the decode feeding that tail was never wired up, so the tail was always
|
||||
* empty and the gap stayed. It is closed instead by archiving in [ARCHIVE_SECONDS]
|
||||
* batches, which no longer concatenate anything that gets rewritten.
|
||||
*/
|
||||
private val _historyText = MutableStateFlow("")
|
||||
override val historyText: StateFlow<String> = _historyText.asStateFlow()
|
||||
|
||||
/** Permanently archived text; the provisional tail is appended to this for display. */
|
||||
private var committedText = ""
|
||||
|
||||
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
|
||||
|
||||
@@ -127,18 +155,54 @@ class CwDeepDecoder(
|
||||
private val buffer = CwDeepBuffer()
|
||||
|
||||
/**
|
||||
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then
|
||||
* is decoded once and appended to [historyText]. Archiving in ~15 s chunks
|
||||
* keeps the extra inference cheap (short window) while long enough to be
|
||||
* decoded accurately — the content has already been through the 20 s window
|
||||
* many times, so a slightly shorter archive decode loses almost nothing.
|
||||
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then is decoded
|
||||
* once and appended to [historyText]. The batch stays short enough that the record does
|
||||
* not visibly stall, and accuracy barely suffers: this content has already been through
|
||||
* the 20 s window many times, so the archive decode is a confirmation, not a first look.
|
||||
*
|
||||
* Sized to the full window rather than the batch: [flush] hands over whatever the live
|
||||
* window holds, which can be the whole 20 s.
|
||||
*/
|
||||
private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE)
|
||||
private var archiveSize = 0
|
||||
|
||||
/**
|
||||
* Window contents retired by a change of shift, waiting to be archived.
|
||||
*
|
||||
* The live window is the only route into the archive - audio gets there by being pushed
|
||||
* out - so clearing the window used to mean its audio was never decoded at all. When the
|
||||
* shift changed more often than the window took to fill, that was every sample: modelled
|
||||
* at 18 WPM with a drift every 20 s, five minutes of listening archived nothing whatever.
|
||||
* Synchronised on itself: written from the capture path and drained from both there and
|
||||
* [flush], which run on different coroutines. Capped, because a signal drifting on every
|
||||
* detection scan would otherwise queue windows faster than they can be decoded and grow
|
||||
* without bound; past the cap the oldest goes, since newer audio is what is being read.
|
||||
*/
|
||||
private val retiredAudio = ArrayDeque<FloatArray>()
|
||||
|
||||
/** Windows held awaiting archival before the oldest is dropped. */
|
||||
private val retiredAudioLimit = 4
|
||||
|
||||
/** Held while inference runs so slow devices skip work instead of queuing it. */
|
||||
private val inferenceLock = Mutex()
|
||||
|
||||
/**
|
||||
* Serialises the archive path, which mutates state the capture coroutine also touches.
|
||||
*
|
||||
* [flush] runs on a different coroutine from [processBuffer] - on pause, and from the app
|
||||
* scope as the screen leaves - and both append to [committedText], which is a read, an
|
||||
* inference lasting hundreds of milliseconds, and only then a write. Interleaved, the
|
||||
* later write wins and a whole batch of text is lost, precisely at the moment the
|
||||
* operator stops listening and starts reading. They also both touch [archiveBuffer] and
|
||||
* [archiveSize]: a reset of the index under a snapshot that already covered those slots
|
||||
* makes the same audio decode twice.
|
||||
*
|
||||
* Not [inferenceLock]: that one is a tryLock, dropping work when contended, which is
|
||||
* right for the live window (another decode is 1.5 s away) and wrong here (dropping an
|
||||
* archive batch discards the audio for good).
|
||||
*/
|
||||
private val archiveLock = Mutex()
|
||||
|
||||
/** Decides what shift to apply from successive tone estimates. */
|
||||
private val shiftDecider = CwShiftDecider(SHIFT_HYSTERESIS_HZ)
|
||||
|
||||
@@ -157,6 +221,17 @@ class CwDeepDecoder(
|
||||
/** Carries Hilbert filter history and mixer phase across capture chunks. */
|
||||
private val streamingShifter = CwToneShifter.Streaming()
|
||||
|
||||
/**
|
||||
* Anti-alias filter for the decimation to [CwDeepSpectrogram.SAMPLE_RATE].
|
||||
*
|
||||
* Built on the first chunk because the capture rate is not known until then. Without
|
||||
* it everything above 1600 Hz folds into the window: a 3000 Hz tone reappeared at
|
||||
* 200 Hz at 119 times the spectral mean, and the whole 1600-22050 Hz band of hiss
|
||||
* folded down on top of the signal.
|
||||
*/
|
||||
private var antiAlias: CwAntiAlias.Streaming? = null
|
||||
private var antiAliasRate = 0
|
||||
|
||||
/**
|
||||
* Previous value of the setting, so a toggle can invalidate buffered audio.
|
||||
* Null until the first chunk: a decoder created while the setting is already on
|
||||
@@ -240,10 +315,35 @@ class CwDeepDecoder(
|
||||
if (samples.isEmpty()) return
|
||||
if (!ensureLoaded()) return
|
||||
|
||||
// Filter before decimating. resampleLinear interpolates without removing anything
|
||||
// above the new Nyquist, so this has to happen first or the fold is already baked in.
|
||||
if (antiAlias == null || antiAliasRate != sampleRate) {
|
||||
antiAlias = CwAntiAlias.Streaming(sampleRate, CwDeepSpectrogram.SAMPLE_RATE)
|
||||
antiAliasRate = sampleRate
|
||||
}
|
||||
val bandLimited = antiAlias?.process(samples) ?: samples
|
||||
// The filter holds back its group delay, so the first call returns nothing.
|
||||
if (bandLimited.isEmpty()) return
|
||||
|
||||
val resampled = CwDeepSpectrogram.resampleLinear(
|
||||
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
bandLimited, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
)
|
||||
val prepared = applyToneShift(resampled)
|
||||
|
||||
// Anything applyToneShift just retired from the window is older than what follows, so
|
||||
// it is archived before the new audio is buffered - otherwise the record comes out
|
||||
// with its text transposed. Archived whole rather than accumulated: it is already a
|
||||
// full window's worth, and holding it back would only expose it to the next retirement.
|
||||
val retired = drainRetiredAudio()
|
||||
for (batch in retired) {
|
||||
try {
|
||||
archiveDecode(batch)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
Log.e(TAG, "retired audio decode failed", t)
|
||||
}
|
||||
}
|
||||
|
||||
val shouldRedecode = buffer.append(prepared)
|
||||
|
||||
// Archive audio that scrolled out of the live window. It is decoded once
|
||||
@@ -370,9 +470,51 @@ class CwDeepDecoder(
|
||||
* cannot be un-shifted, so they are dropped rather than decoded against the new
|
||||
* shift. Text already committed to [historyText] stays: it was correct when decoded.
|
||||
*/
|
||||
/**
|
||||
* Drop audio that was shifted by a setting no longer in force - but keep what can be kept.
|
||||
*
|
||||
* The live window has to go: it holds samples moved by two different amounts, and one
|
||||
* spectrogram over both smears the tone. The pending archive batch does not. Those samples
|
||||
* already left the window, they were shifted consistently, and they are complete, so
|
||||
* discarding them threw away decodable audio for no reason. They are left in place here to
|
||||
* be archived by the normal path, which keeps this function non-suspending: its two
|
||||
* callers sit on the synchronous capture path, and making them suspend to run an inference
|
||||
* here would put a decode inside the tone-detection scan.
|
||||
*
|
||||
* It mattered because this runs on every change of shift, which tracks the detected tone,
|
||||
* which drifts across a pass. Modelled at 18 WPM, a drift every 20 s left the record
|
||||
* permanently empty however long the operator listened: the window was wiped before any
|
||||
* batch could complete, so nothing was ever committed. With the record still concatenating
|
||||
* the live decode at the time, each wipe visibly cut the transcript short as well - text
|
||||
* going backwards, then never accumulating at all.
|
||||
*/
|
||||
/** Take every retired window, oldest first, leaving the queue empty. */
|
||||
private fun drainRetiredAudio(): List<FloatArray> = synchronized(retiredAudio) {
|
||||
if (retiredAudio.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
retiredAudio.toList().also { retiredAudio.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropBufferedAudio() {
|
||||
// Retired, not discarded. The samples cannot stay in the window - mixing two shifts
|
||||
// in one spectrogram smears the tone - but they are internally consistent and
|
||||
// complete, so they decode fine on their own. Handed to the archive path rather than
|
||||
// decoded here, because both callers sit on the synchronous capture path.
|
||||
val retiring = buffer.snapshot()
|
||||
if (retiring.isNotEmpty()) {
|
||||
synchronized(retiredAudio) {
|
||||
while (retiredAudio.size >= retiredAudioLimit) {
|
||||
Log.w(TAG, "retired audio queue full, dropping the oldest window")
|
||||
retiredAudio.removeFirst()
|
||||
}
|
||||
retiredAudio.addLast(retiring)
|
||||
}
|
||||
}
|
||||
buffer.reset()
|
||||
archiveSize = 0
|
||||
// Committed text stays: it was correct for audio that really was archived.
|
||||
_historyText.value = committedText
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -456,20 +598,61 @@ class CwDeepDecoder(
|
||||
updateSignalMetrics(spectrogram)
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive whatever audio is still in the pipeline, so stopping does not discard it.
|
||||
*
|
||||
* Two places hold audio that would otherwise never be decoded into the record: the
|
||||
* batch accumulating towards [ARCHIVE_THRESHOLD], and the live window itself, whose
|
||||
* contents only ever reach the archive by being pushed out by newer audio. Together
|
||||
* that is the last [CwDeepBuffer.DEFAULT_MAX_SECONDS] + [ARCHIVE_SECONDS] of a session
|
||||
* - which includes the end of every transmission, the part with the call sign in it.
|
||||
*
|
||||
* Order matters: the pending batch left the window before anything still in it, so it
|
||||
* has to be archived first or the record comes out with its text transposed.
|
||||
*/
|
||||
override suspend fun flush() = archiveLock.withLock {
|
||||
// Oldest first, all the way down: retired window contents, then the batch accumulating
|
||||
// towards the threshold, then what is still live.
|
||||
for (batch in drainRetiredAudio()) {
|
||||
runCatching { archiveDecodeLocked(batch) }
|
||||
.onFailure { if (it is CancellationException) throw it }
|
||||
}
|
||||
if (archiveSize > 0) {
|
||||
val pending = archiveBuffer.copyOf(archiveSize)
|
||||
archiveSize = 0
|
||||
runCatching { archiveDecodeLocked(pending) }
|
||||
.onFailure { if (it is CancellationException) throw it }
|
||||
}
|
||||
// Draining the window empties it, so a second flush cannot double-archive the tail.
|
||||
val window = buffer.snapshot()
|
||||
if (window.isNotEmpty()) {
|
||||
buffer.reset()
|
||||
runCatching { archiveDecodeLocked(window) }
|
||||
.onFailure { if (it is CancellationException) throw it }
|
||||
}
|
||||
// The live line described audio that is now in the record; leaving it would show the
|
||||
// same characters twice, in two places, one of them stale.
|
||||
_decodedText.value = ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a chunk of audio that has scrolled out of the live window and
|
||||
* append it to [historyText]. Unlike the live window this never replaces —
|
||||
* the archived audio is final, so its text is permanent.
|
||||
*/
|
||||
private suspend fun archiveDecode(audio: FloatArray) = withContext(Dispatchers.Default) {
|
||||
private suspend fun archiveDecode(audio: FloatArray) = archiveLock.withLock {
|
||||
archiveDecodeLocked(audio)
|
||||
}
|
||||
|
||||
/** [archiveDecode] without the lock, for callers already holding [archiveLock]. */
|
||||
private suspend fun archiveDecodeLocked(audio: FloatArray) = withContext(Dispatchers.Default) {
|
||||
val activeSession = session ?: return@withContext
|
||||
val activeEnvironment = environment ?: return@withContext
|
||||
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
|
||||
val spectrogram = CwDeepSpectrogram.compute(audio)
|
||||
val text = runInference(activeSession, activeEnvironment, spectrogram)
|
||||
if (text.isNotEmpty()) {
|
||||
_historyText.value += text
|
||||
}
|
||||
committedText += text
|
||||
_historyText.value = committedText
|
||||
}
|
||||
|
||||
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
|
||||
@@ -546,10 +729,19 @@ class CwDeepDecoder(
|
||||
_signalStrength.value = if (decodable) prominence else 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear everything. Not serialised against [archiveLock]: an archive decode already in
|
||||
* flight can land its batch after this returns, leaving a few characters behind. The
|
||||
* operator asked to clear and can ask again; making this suspend to close that window
|
||||
* would push it onto every caller, including a synchronous button handler.
|
||||
*/
|
||||
override fun reset() {
|
||||
antiAlias?.reset()
|
||||
buffer.reset()
|
||||
synchronized(retiredAudio) { retiredAudio.clear() }
|
||||
_decodedText.value = ""
|
||||
_historyText.value = ""
|
||||
committedText = ""
|
||||
archiveSize = 0
|
||||
_estimatedPitch.value = null
|
||||
_detectedToneHz.value = null
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||
import com.rtbishop.look4sat.core.data.source.OkHttpHttpClient
|
||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||
@@ -69,13 +70,25 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import com.rtbishop.look4sat.core.data.qrz.QrzGridLookup
|
||||
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
|
||||
import okhttp3.OkHttpClient
|
||||
import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.WaveLogApi
|
||||
|
||||
class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
private val localSource = provideLocalSource()
|
||||
private val remoteSource by lazy { provideRemoteSource() }
|
||||
|
||||
/**
|
||||
* WaveLogApi is a plain object in core:domain, and shared code has no socket API of its own on
|
||||
* iOS, so the container hands it the platform client. Its requests used to be made by an
|
||||
* HttpURLConnection built inside WaveLogApi, which only ever existed on the JVM.
|
||||
*/
|
||||
init {
|
||||
WaveLogApi.installHttpClient(OkHttpHttpClient(OkHttpClient.Builder().build()))
|
||||
}
|
||||
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
||||
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
||||
override val settingsRepo = provideSettingsRepo()
|
||||
@@ -134,6 +147,22 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
override fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo = lotwRepo
|
||||
|
||||
/**
|
||||
* QRZ grid lookup. Holds the cookie read so no composable has to: the log screen used to pull
|
||||
* it out of SharedPreferences through LocalContext, putting disk access inside composition.
|
||||
*/
|
||||
private val qrzGridLookup: QrzGridLookup by lazy {
|
||||
QrzGridLookup(
|
||||
context.getSharedPreferences(QrzGridLookup.PREFS_NAME, Context.MODE_PRIVATE),
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
override fun provideQrzGridLookup(): IQrzGridLookup = qrzGridLookup
|
||||
|
||||
override fun provideBluetoothReporter(): IReporter {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val rc = settingsRepo.rcSettings.value
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.qrz
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
|
||||
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Grid lookup backed by the cookie the operator pasted into settings.
|
||||
*
|
||||
* Owns the cookie read so the log screen does not: a composable used to pull it out of
|
||||
* SharedPreferences through LocalContext on every submission, which put disk access inside
|
||||
* composition and went around the repository layer.
|
||||
*/
|
||||
class QrzGridLookup(
|
||||
private val preferences: SharedPreferences,
|
||||
httpClient: OkHttpClient,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
) : IQrzGridLookup {
|
||||
|
||||
private val source = QrzGridSource(httpClient, dispatcher)
|
||||
|
||||
override suspend fun lookup(callsign: String): QrzGrid {
|
||||
val cookie = preferences.getString(COOKIE_KEY, "").orEmpty()
|
||||
// No cookie and an expired one call for the same thing from the operator, so they report
|
||||
// the same way rather than adding a fourth outcome nobody could act on differently.
|
||||
if (cookie.isBlank()) return QrzGrid.SignedOut
|
||||
return source.lookupGrid(callsign, cookie)
|
||||
}
|
||||
|
||||
override suspend fun signedInAs(): String? {
|
||||
val cookie = preferences.getString(COOKIE_KEY, "").orEmpty()
|
||||
if (cookie.isBlank()) return null
|
||||
return source.lookupOwnCallsign(cookie)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Where the settings screen stores what the operator pasted. */
|
||||
const val PREFS_NAME = "qrz_cookie"
|
||||
const val COOKIE_KEY = "cookie"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.qrz
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
|
||||
import com.rtbishop.look4sat.core.domain.qrz.QrzGridParser
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* Reads a station's Maidenhead locator off its QRZ.com page.
|
||||
*
|
||||
* QRZ has no free lookup API for this, so the page is fetched with the operator's own session
|
||||
* cookie and parsed. The cookie is pasted by the operator in settings and never built into the
|
||||
* app. Parsing lives in [QrzGridParser] so it can be tested without a network; this class only
|
||||
* fetches and retries.
|
||||
*/
|
||||
class QrzGridSource(
|
||||
private val httpClient: OkHttpClient,
|
||||
private val dispatcher: CoroutineDispatcher
|
||||
) {
|
||||
|
||||
/**
|
||||
* Look up [callsign]'s locator.
|
||||
*
|
||||
* Retried because this runs on a phone, mid-pass, often on mobile data - a single timeout
|
||||
* used to mean the QSO was logged without a grid and the operator was never told. Retries
|
||||
* are bounded and backed off so a genuinely unreachable QRZ costs at most a few seconds:
|
||||
* only transport failures are retried, since a page that loaded and parsed will not parse
|
||||
* differently on a second attempt.
|
||||
*/
|
||||
suspend fun lookupGrid(callsign: String, cookieHeader: String): QrzGrid =
|
||||
withContext(dispatcher) {
|
||||
if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext QrzGrid.SignedOut
|
||||
val url = "$DB_URL${callsign.trim().uppercase()}"
|
||||
fetchWithRetry(url, cookieHeader)?.let(QrzGridParser::parseGrid)
|
||||
?: QrzGrid.Unreachable(MAX_ATTEMPTS)
|
||||
}
|
||||
|
||||
/**
|
||||
* The callsign the pasted cookie is signed in as, so settings can show the operator whose
|
||||
* account it belongs to rather than just claiming success.
|
||||
*/
|
||||
suspend fun lookupOwnCallsign(cookieHeader: String): String? = withContext(dispatcher) {
|
||||
if (cookieHeader.isBlank()) return@withContext null
|
||||
fetchWithRetry(DB_URL, cookieHeader)?.let(QrzGridParser::parseOwnCallsign)
|
||||
}
|
||||
|
||||
/** Fetch [url], retrying transport failures with backoff. Null when every attempt failed. */
|
||||
/**
|
||||
* Normalise whatever the operator pasted into a Cookie header value.
|
||||
*
|
||||
* They paste either a raw `k=v; k=v` header or the JSON array a cookie-export extension
|
||||
* produces. The old client normalised this and the rewrite dropped it, so a JSON export that
|
||||
* used to work went out as a literal JSON blob, QRZ served its signed-out page, and the app
|
||||
* told the operator their cookie had expired when it was perfectly good.
|
||||
*/
|
||||
private fun normalise(raw: String): String = QrzGridParser.cookieHeader(raw)
|
||||
|
||||
private suspend fun fetchWithRetry(url: String, rawCookie: String): String? {
|
||||
val cookieHeader = normalise(rawCookie)
|
||||
if (cookieHeader.isBlank()) return null
|
||||
repeat(MAX_ATTEMPTS) { attempt ->
|
||||
try {
|
||||
val request = Request.Builder().url(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Cookie", cookieHeader)
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) return response.body.string()
|
||||
// A 4xx will repeat identically, so only server-side faults are worth retrying.
|
||||
if (response.code < 500) return null
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("QrzGridSource attempt ${attempt + 1} failed: $exception")
|
||||
}
|
||||
if (attempt < MAX_ATTEMPTS - 1) delay(BACKOFF_MS[attempt])
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Bare form is the signed-in home page; a callsign appended is that station's page. */
|
||||
const val DB_URL = "https://www.qrz.com/db/"
|
||||
const val USER_AGENT = "Mozilla/5.0 (Linux; Android 13) Look4Sat"
|
||||
const val MAX_ATTEMPTS = 3
|
||||
|
||||
/** Waits before the second and third attempt. Short enough to finish inside a pass. */
|
||||
val BACKOFF_MS = longArrayOf(700L, 2_000L)
|
||||
}
|
||||
}
|
||||
+70
-40
@@ -29,7 +29,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
class DatabaseRepo(
|
||||
@@ -42,10 +42,21 @@ class DatabaseRepo(
|
||||
|
||||
private val customSourceType = "Other"
|
||||
|
||||
/**
|
||||
* Type key for satellites fetched from a custom URL.
|
||||
*
|
||||
* Separate from customSourceType because setSatelliteTypeIds overwrites rather than merges, so
|
||||
* sharing "Other" with manual file import meant each wiped the other's type index. The
|
||||
* satellites stayed in the database and stayed selectable either way - only their grouping in
|
||||
* the type filter was lost - but the two sources are different things and deserve different
|
||||
* keys.
|
||||
*/
|
||||
private val customUrlType = "Custom"
|
||||
|
||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
||||
remoteSource.getFileBytes(uri)?.let { data ->
|
||||
val entries = parseSatelliteData(uri, unwrapIfZipped(uri, data))
|
||||
localSource.insertEntries(entries)
|
||||
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
||||
importedCount = entries.size
|
||||
@@ -56,8 +67,8 @@ class DatabaseRepo(
|
||||
|
||||
override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) {
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||
remoteSource.getFileBytes(uri)?.let { data ->
|
||||
val transceivers = dataParser.parseJSON(unwrapIfZipped(uri, data).decodeToString())
|
||||
localSource.insertRadios(transceivers)
|
||||
importedCount = transceivers.size
|
||||
}
|
||||
@@ -67,36 +78,56 @@ class DatabaseRepo(
|
||||
|
||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||
val tleUrls = buildMap {
|
||||
putAll(Sources.satelliteDataUrls)
|
||||
// Switch on + non-empty URL -> All uses the custom URL; otherwise the default URL (online-update default source)
|
||||
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
|
||||
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
val radioUrls = buildMap {
|
||||
putAll(Sources.transceiversDataUrls)
|
||||
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
|
||||
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
// A custom URL REPLACES the built-in sources rather than joining them. The previous map
|
||||
// overwrote only the "All" value and still fetched the other 26, so switching this on meant
|
||||
// "my source AND yours" - which defeats the reasons for setting one: a mirror, a filtered
|
||||
// subset, an offline server, or a network where Celestrak is unreachable. On a blocked link
|
||||
// the real behaviour was 26 failing requests.
|
||||
//
|
||||
// Satellites already stored do not disappear: insertEntries is OnConflictStrategy.REPLACE
|
||||
// and nothing is deleted before the insert, so rows the new source does not mention survive.
|
||||
// The type index for the skipped keys goes stale rather than empty, which is the honest
|
||||
// outcome - it is the last known membership, not a claim about this fetch.
|
||||
//
|
||||
// The key is customUrlType, not "All": setSatelliteTypeIds early-returns on "All", so
|
||||
// indexing under it was always a no-op and satellites from a custom URL were never
|
||||
// reachable by the type filter at all. They now are.
|
||||
val tleUrls = if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank()) {
|
||||
mapOf(customUrlType to dataSourcesSettings.tleUrl)
|
||||
} else {
|
||||
Sources.satelliteDataUrls.filterValues { it.isNotBlank() }
|
||||
}
|
||||
val radioUrls = if (dataSourcesSettings.useCustomTransceivers &&
|
||||
dataSourcesSettings.transceiversUrl.isNotBlank()
|
||||
) {
|
||||
mapOf("SatNOGS" to dataSourcesSettings.transceiversUrl)
|
||||
} else {
|
||||
Sources.transceiversDataUrls.filterValues { it.isNotBlank() }
|
||||
}
|
||||
// launch all network requests concurrently
|
||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
// Count successful sources: zero successes = update failed (timestamp untouched, exception surfaced in the UI)
|
||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkBytes(url) } }
|
||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkBytes(url) } }
|
||||
val tleResults = tleJobs.awaitAll()
|
||||
val radioResults = radioJobs.awaitAll()
|
||||
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
|
||||
if (successCount == 0) {
|
||||
throw java.io.IOException("All data sources failed to download")
|
||||
// Orbital elements are counted on their own. A combined count let a successful transceivers
|
||||
// fetch stand in for a failed orbital one: with a custom TLE URL there are two requests
|
||||
// rather than 28, so if that URL was down and SatNOGS answered, the total was 1, no
|
||||
// exception was raised, and setUpdateSuccessful stamped a fresh timestamp for an update
|
||||
// that refreshed no orbital data at all - which also suppressed the 48-hour auto-update
|
||||
// retry that keys off that timestamp. The failure existed before but 26 other sources hid
|
||||
// it; replacing them made it easy to hit.
|
||||
if (tleResults.none { it.second != null }) {
|
||||
throw java.io.IOException("No orbital data source could be downloaded")
|
||||
}
|
||||
// parse fetched data concurrently and associate with types
|
||||
val importedEntries = tleResults.flatMap { (url, stream) ->
|
||||
val importedEntries = tleResults.flatMap { (url, data) ->
|
||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||
data?.let { parseSatelliteData(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
||||
}
|
||||
}
|
||||
val importedRadios = radioResults.flatMap { (url, stream) ->
|
||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||
val importedRadios = radioResults.flatMap { (url, data) ->
|
||||
data?.let { dataParser.parseJSON(unwrapIfZipped(url, it).decodeToString()) }.orEmpty()
|
||||
}
|
||||
// insert parsed data into the database
|
||||
localSource.insertEntries(importedEntries)
|
||||
@@ -110,11 +141,11 @@ class DatabaseRepo(
|
||||
setUpdateSuccessful(0L)
|
||||
}
|
||||
|
||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||
val bufferedStream = stream.buffered()
|
||||
private suspend fun parseSatelliteData(url: String, data: ByteArray): List<OrbitalData> {
|
||||
val text = data.decodeToString()
|
||||
return when {
|
||||
hasCsvHint(url) || looksLikeCsv(bufferedStream) -> dataParser.parseCSVStream(bufferedStream)
|
||||
else -> dataParser.parseTLEStream(bufferedStream)
|
||||
hasCsvHint(url) || looksLikeCsv(text) -> dataParser.parseCSV(text)
|
||||
else -> dataParser.parseTLE(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,14 +155,9 @@ class DatabaseRepo(
|
||||
url.endsWith(".csv.zip", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun looksLikeCsv(stream: InputStream): Boolean {
|
||||
if (!stream.markSupported()) return false
|
||||
stream.mark(4096)
|
||||
val preview = ByteArray(4096)
|
||||
val length = stream.read(preview)
|
||||
stream.reset()
|
||||
if (length <= 0) return false
|
||||
val line = preview.decodeToString(0, length).lineSequence().firstOrNull()?.trim().orEmpty()
|
||||
private fun looksLikeCsv(text: String): Boolean {
|
||||
val line = text.lineSequence().firstOrNull()?.trim().orEmpty()
|
||||
if (line.isEmpty()) return false
|
||||
return line.contains("OBJECT_NAME", ignoreCase = true) ||
|
||||
line.contains("NORAD_CAT_ID", ignoreCase = true) ||
|
||||
line.count { it == ',' } >= 4
|
||||
@@ -143,6 +169,10 @@ class DatabaseRepo(
|
||||
)
|
||||
}
|
||||
|
||||
private fun unwrapIfZipped(url: String, stream: InputStream): InputStream =
|
||||
if (url.endsWith(".zip", ignoreCase = true)) ZipInputStream(stream).apply { nextEntry } else stream
|
||||
private fun unwrapIfZipped(url: String, data: ByteArray): ByteArray =
|
||||
if (url.endsWith(".zip", ignoreCase = true)) {
|
||||
ZipInputStream(ByteArrayInputStream(data)).apply { nextEntry }.readBytes()
|
||||
} else {
|
||||
data
|
||||
}
|
||||
}
|
||||
+36
-10
@@ -455,18 +455,44 @@ class SettingsRepo(
|
||||
_dataSourcesSettings.value = settings
|
||||
}
|
||||
|
||||
/** Placeholders a 4.4.7-era build could persist. Neither is a reachable address. */
|
||||
private val placeholderTleUrl = "https://example.com/tle.txt"
|
||||
private val placeholderRadioUrl = "https://example.com/radio.json"
|
||||
private val keyPlaceholderUrlsMigrated = "placeholderUrlsMigrated"
|
||||
|
||||
/**
|
||||
* Replace the example.com placeholders an old build could store.
|
||||
*
|
||||
* Runs once, following the pattern of migrateRCFormats. This used to be a rewrite applied on
|
||||
* every read, so the stored value and the returned value disagreed indefinitely and nothing
|
||||
* ever settled the difference.
|
||||
*/
|
||||
private fun migratePlaceholderUrls() {
|
||||
if (preferences.getBoolean(keyPlaceholderUrlsMigrated, false)) return
|
||||
preferences.edit {
|
||||
if (preferences.getString(keyTleUrl, null) == placeholderTleUrl) {
|
||||
putString(keyTleUrl, Sources.defaultTleUrl)
|
||||
putBoolean(keyUseCustomTle, false)
|
||||
}
|
||||
if (preferences.getString(keyTransceiversUrl, null) == placeholderRadioUrl) {
|
||||
putString(keyTransceiversUrl, Sources.defaultTransceiversUrl)
|
||||
putBoolean(keyUseCustomTransceivers, false)
|
||||
}
|
||||
putBoolean(keyPlaceholderUrlsMigrated, true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||
// 4.4.8 fix: legacy example.com placeholder URLs count as unconfigured -> replaced with the real default URL and the switch forced off,
|
||||
// otherwise the online All/SatNOGS sources would point at the wrong address and fail to update
|
||||
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
|
||||
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
|
||||
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
|
||||
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
|
||||
migratePlaceholderUrls()
|
||||
// The switch is reported as the operator set it. It used to be ANDed with
|
||||
// `url != default`, so typing the default URL by hand switched custom sources off by
|
||||
// itself and the settings screen showed a state nobody had chosen.
|
||||
return DataSourcesSettings(
|
||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
|
||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
|
||||
tleUrl = tleUrl,
|
||||
transceiversUrl = txUrl
|
||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
||||
tleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl,
|
||||
transceiversUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl)
|
||||
?: Sources.defaultTransceiversUrl
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.source
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.source.HttpResult
|
||||
import com.rtbishop.look4sat.core.domain.source.IHttpClient
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Android [IHttpClient] for the shared Wavelog/QRZ code, which cannot reach java.net on iOS.
|
||||
*
|
||||
* Connect and read timeouts are the 15 s the previous HttpURLConnection client used, applied to
|
||||
* the passed client so the caller keeps one connection pool. The response body is returned for
|
||||
* error codes as well, which is what reading errorStream did.
|
||||
*/
|
||||
class OkHttpHttpClient(
|
||||
baseClient: OkHttpClient,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
) : IHttpClient {
|
||||
|
||||
private val dispatcher = dispatcher
|
||||
|
||||
private val client = baseClient.newBuilder()
|
||||
.connectTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
override suspend fun post(url: String, headers: Map<String, String>, body: String): HttpResult =
|
||||
execute {
|
||||
Request.Builder().url(url).post(body.toRequestBody(JSON_MEDIA_TYPE)).withHeaders(headers).build()
|
||||
}
|
||||
|
||||
override suspend fun get(url: String, headers: Map<String, String>): HttpResult =
|
||||
execute { Request.Builder().url(url).withHeaders(headers).build() }
|
||||
|
||||
private suspend fun execute(buildRequest: () -> Request): HttpResult = withContext(dispatcher) {
|
||||
try {
|
||||
// Built in here, not by the caller: a URL OkHttp refuses to parse has to come back as
|
||||
// the HTTP -1 the old client reported, not as an exception thrown at the caller.
|
||||
val request = buildRequest()
|
||||
client.newCall(request).execute().use { response ->
|
||||
HttpResult(response.code, response.body.string())
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
HttpResult(NO_RESPONSE, "", exception.message ?: exception.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Request.Builder.withHeaders(headers: Map<String, String>): Request.Builder {
|
||||
headers.forEach { (name, value) -> header(name, value) }
|
||||
return this
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Matches HttpURLConnection's connect/read timeout in the original WaveLog client. */
|
||||
const val TIMEOUT_MS = 15_000L
|
||||
|
||||
/** What HttpURLConnection's responseCode() reported when a request never got a response. */
|
||||
const val NO_RESPONSE = -1
|
||||
|
||||
/** WaveLog's v2 and v1 endpoints take application/json in both directions. */
|
||||
val JSON_MEDIA_TYPE = "application/json".toMediaType()
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.InputStream
|
||||
|
||||
class RemoteSource(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
@@ -33,10 +32,10 @@ class RemoteSource(
|
||||
private val httpClient: OkHttpClient
|
||||
) : IRemoteSource {
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = withContext(dispatcher) {
|
||||
override suspend fun getFileBytes(uri: String): ByteArray? = withContext(dispatcher) {
|
||||
try {
|
||||
val fileUri = uri.toUri()
|
||||
contentResolver.openInputStream(fileUri)?.buffered()
|
||||
contentResolver.openInputStream(fileUri)?.use { it.readBytes() }
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
@@ -45,17 +44,14 @@ class RemoteSource(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||
override suspend fun getNetworkBytes(url: String): ByteArray? = withContext(dispatcher) {
|
||||
try {
|
||||
val networkRequest = Request.Builder().url(url).build()
|
||||
val response = httpClient.newCall(networkRequest).execute()
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
return@withContext null
|
||||
// The whole body is read here, which also returns the connection to OkHttp's pool
|
||||
httpClient.newCall(networkRequest).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body.bytes()
|
||||
}
|
||||
// Return the body stream directly as the caller is responsible for closing it
|
||||
// That returns the connection to OkHttp's pool
|
||||
response.body.byteStream().buffered()
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
|
||||
@@ -29,4 +29,8 @@ class ShowToast(private val context: Context) : IShowToast {
|
||||
override fun invoke(resId: Int) {
|
||||
invoke(context.getString(resId))
|
||||
}
|
||||
|
||||
override fun invoke(resId: Int, vararg formatArgs: Any) {
|
||||
invoke(context.getString(resId, *formatArgs))
|
||||
}
|
||||
}
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
package com.rtbishop.look4sat.core.data.aprs
|
||||
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.PrintWriter
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.thread
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Socket-level tests against a stand-in APRS-IS server.
|
||||
*
|
||||
* These exist because the pure-logic tests could not catch the failures that matter here. A draft
|
||||
* of [AprsIsClient.sendPacket] once wrote the packet with no line terminator at all: every send
|
||||
* reported success, the server received a single unterminated stream, and nothing anywhere went
|
||||
* red. APRS-IS is a line protocol and does not acknowledge position reports, so silence is the
|
||||
* normal case - which means only a test that reads the bytes off a real socket can tell a
|
||||
* delivered packet from a lost one.
|
||||
*/
|
||||
class AprsIsClientSocketTest {
|
||||
|
||||
/**
|
||||
* A minimal APRS-IS server. Greets, answers the login as instructed, then records whatever
|
||||
* lines arrive without acknowledging them, which is what the real network does.
|
||||
*/
|
||||
private class FakeServer(
|
||||
private val greeting: String? = "# aprsc 2.1.19-g730c5c0",
|
||||
private val loginResponse: String? = "# logresp TEST verified, server FAKE",
|
||||
private val chatter: List<String> = emptyList(),
|
||||
/** Sent right after the first packet arrives, to exercise the ack read. */
|
||||
private val afterPacket: String? = null
|
||||
) : AutoCloseable {
|
||||
private val server = ServerSocket(0)
|
||||
private val ready = CountDownLatch(1)
|
||||
|
||||
/**
|
||||
* The accepted connection. Held because closing the ServerSocket only stops it listening
|
||||
* - an established connection survives, so a test that wants a dead peer has to close
|
||||
* this one. Getting that wrong made a correct implementation look broken.
|
||||
*/
|
||||
@Volatile
|
||||
private var peer: Socket? = null
|
||||
|
||||
val port: Int get() = server.localPort
|
||||
|
||||
/** Every complete line the client sent after logging in. */
|
||||
val received = mutableListOf<String>()
|
||||
|
||||
/** Raw bytes of the client's traffic, so a missing terminator is visible. */
|
||||
val rawAfterLogin = StringBuilder()
|
||||
|
||||
@Volatile
|
||||
var loginLine: String? = null
|
||||
|
||||
fun start() {
|
||||
thread(isDaemon = true) {
|
||||
runCatching {
|
||||
server.accept().use { client ->
|
||||
peer = client
|
||||
val out = PrintWriter(client.getOutputStream(), true)
|
||||
val input = BufferedReader(InputStreamReader(client.getInputStream()))
|
||||
greeting?.let { out.print(it + "\r\n"); out.flush() }
|
||||
loginLine = input.readLine()
|
||||
chatter.forEach { out.print(it + "\r\n"); out.flush() }
|
||||
loginResponse?.let { out.print(it + "\r\n"); out.flush() }
|
||||
ready.countDown()
|
||||
// Read lines but never acknowledge, exactly as APRS-IS treats positions.
|
||||
var firstPacket = true
|
||||
while (true) {
|
||||
val line = input.readLine() ?: break
|
||||
synchronized(received) {
|
||||
received += line
|
||||
rawAfterLogin.append(line)
|
||||
}
|
||||
if (firstPacket) {
|
||||
firstPacket = false
|
||||
afterPacket?.let { out.print(it + "\r\n"); out.flush() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ready.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
fun awaitLogin(): Boolean = ready.await(5, TimeUnit.SECONDS)
|
||||
|
||||
fun lines(): List<String> = synchronized(received) { received.toList() }
|
||||
|
||||
/** Close the established connection, so the client is talking to a dead peer. */
|
||||
fun dropClient() {
|
||||
runCatching { peer?.close() }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { server.close() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun client(port: Int, passcode: Int = 12345) = AprsIsClient(
|
||||
host = "127.0.0.1",
|
||||
port = port,
|
||||
callsign = "TEST",
|
||||
ssid = "",
|
||||
passcode = passcode,
|
||||
softwareName = "Look4Sat",
|
||||
version = "test"
|
||||
)
|
||||
|
||||
/**
|
||||
* The regression that motivated this file. Two packets must arrive as two lines; without a
|
||||
* terminator they concatenate into one stream the server can never parse, while both sends
|
||||
* report success.
|
||||
*/
|
||||
@Test
|
||||
fun `each packet arrives as its own line`() {
|
||||
FakeServer().use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
val first = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>one")
|
||||
val second = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>two")
|
||||
Thread.sleep(300)
|
||||
c.disconnect()
|
||||
|
||||
assertEquals(true, first?.first)
|
||||
assertEquals(true, second?.first)
|
||||
val lines = server.lines()
|
||||
assertEquals("both packets must reach the server as separate lines", 2, lines.size)
|
||||
assertTrue(lines[0].endsWith(">one"))
|
||||
assertTrue(lines[1].endsWith(">two"))
|
||||
}
|
||||
}
|
||||
|
||||
/** The login line has to be terminated too, or the server never reads it. */
|
||||
@Test
|
||||
fun `the server receives a complete login line`() {
|
||||
FakeServer().use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
c.disconnect()
|
||||
assertEquals("user TEST pass 12345 vers Look4Sat test", server.loginLine)
|
||||
}
|
||||
}
|
||||
|
||||
/** A verified login is recognised and lets reports count as delivered. */
|
||||
@Test
|
||||
fun `a verified login is not reported as refused`() {
|
||||
FakeServer().use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
assertTrue(c.isVerified)
|
||||
assertFalse(c.isRefusedByServer)
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The case the rewrite exists for: the server accepts the connection, the write succeeds,
|
||||
* and every packet is discarded. The client must say so rather than report success.
|
||||
*/
|
||||
@Test
|
||||
fun `an unverified login is flagged while the connection stays up`() {
|
||||
FakeServer(loginResponse = "# logresp TEST unverified, server FAKE").use { server ->
|
||||
server.start()
|
||||
val c = client(server.port, passcode = -1)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
assertFalse("unverified must not read as verified", c.isVerified)
|
||||
assertTrue("the server explicitly refused", c.isRefusedByServer)
|
||||
// Not a connection error: a receive-only login is legitimate and stays connected.
|
||||
assertTrue(c.isConnected)
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A chatty server used to exhaust a fixed line budget, turning an accepted login into
|
||||
* Unknown and telling the operator their passcode was wrong when it had been accepted.
|
||||
*/
|
||||
@Test
|
||||
fun `keepalive chatter before the verdict does not hide it`() {
|
||||
// The real keepalive repeats the server identification with a timestamp, captured from
|
||||
// euro.aprs2.net. A made-up "# keepalive N" would now read as a refusal, correctly - only
|
||||
// greetings and verdicts are treated as harmless.
|
||||
val chatter = List(8) { "# aprsc 2.1.21-gbfc2090 25 Aug 2026 16:41:0$it GMT T2UK 1.2.3.4:14580" }
|
||||
FakeServer(chatter = chatter).use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
assertTrue("the verdict must be found past the comments", c.isVerified)
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A server that sends no greeting is legitimate, and must not cost the full login window on
|
||||
* every connect - that was eight seconds per attempt.
|
||||
*/
|
||||
@Test
|
||||
fun `a server without a greeting connects promptly`() {
|
||||
FakeServer(greeting = null).use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
val started = System.currentTimeMillis()
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
val elapsed = System.currentTimeMillis() - started
|
||||
c.disconnect()
|
||||
assertTrue("connect took ${elapsed}ms, expected well under the login window",
|
||||
elapsed < 6_000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure a live server actually produced, and the one that mattered most.
|
||||
*
|
||||
* aprsc answers `# Invalid login: ...` and closes. That is a comment but not a logresp, so it
|
||||
* was skipped as chatter, the login timed out into Unknown - treated as "may be working" - and
|
||||
* every send afterwards reported success. Measured against euro.aprs2.net before the fix:
|
||||
* loginOutcome=Unknown, isRefusedByServer=false, sendPacket=(true, "sent").
|
||||
*/
|
||||
@Test
|
||||
fun `a refused login is not reported as a successful send`() {
|
||||
FakeServer(loginResponse = "# Invalid login: bad software version").use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
// An outright refusal throws from connect(), which is the correct outcome.
|
||||
val threw = runCatching { c.connect() }.exceptionOrNull()
|
||||
assertTrue(server.awaitLogin())
|
||||
assertFalse("a refused login must not read as verified", c.isVerified)
|
||||
val sentOk = runCatching {
|
||||
c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")?.first
|
||||
}.getOrNull()
|
||||
assertTrue(
|
||||
"the refusal must surface: threw=$threw sentOk=$sentOk",
|
||||
threw != null || sentOk != true
|
||||
)
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A server saying it is about to drop us must not read as a successful send.
|
||||
*
|
||||
* The ack read used to treat any leading `#` as harmless chatter, so `# Port full` - which
|
||||
* means the server is closing the connection - was reported as sent. It now shares the login
|
||||
* parser's judgement, so only a greeting or keepalive counts as harmless.
|
||||
*/
|
||||
@Test
|
||||
fun `a server refusal after the write is not reported as sent`() {
|
||||
FakeServer(afterPacket = "# Port full").use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
val result = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")
|
||||
c.disconnect()
|
||||
assertEquals("a server refusal must fail the report", false, result?.first)
|
||||
}
|
||||
}
|
||||
|
||||
/** The real keepalive must still count as sent, since APRS-IS never acknowledges a position. */
|
||||
@Test
|
||||
fun `a keepalive after the write still counts as sent`() {
|
||||
val keepalive = "# aprsc 2.1.21-gbfc2090 25 Aug 2026 16:41:07 GMT T2UK 1.2.3.4:14580"
|
||||
FakeServer(afterPacket = keepalive).use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
val result = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")
|
||||
c.disconnect()
|
||||
assertEquals("a keepalive must not fail the report", true, result?.first)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sending after the server has gone must report failure, not success. */
|
||||
@Test
|
||||
fun `a send after the server closes is reported as failed`() {
|
||||
val server = FakeServer()
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
// Closing the ServerSocket alone would leave this connection alive.
|
||||
server.dropClient()
|
||||
Thread.sleep(200)
|
||||
// The first write may still land in the socket buffer; by the second the loss is certain.
|
||||
c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>one")
|
||||
val second = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>two")
|
||||
c.disconnect()
|
||||
assertEquals("a send on a dead connection must not report success", false, second?.first)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.cw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* How long audio waits before its text can reach the record pane.
|
||||
*
|
||||
* The record binds to archived text only. The live decode is rewritten from scratch every
|
||||
* cycle, so a pane that concatenated it lost characters the operator had already read - the
|
||||
* decoder appeared to delete its own output while still running. Binding to archived text
|
||||
* makes the pane monotonic, and the cost is latency, which is additive: audio must first be
|
||||
* pushed out of the live window, then accumulate into a full archive batch.
|
||||
*
|
||||
* [CwDeepDecoder] needs a Context and a loaded ONNX model, so it cannot be constructed here.
|
||||
* These tests read its real constants rather than copies, so retuning one without
|
||||
* reconsidering the user-visible delay fails here.
|
||||
*/
|
||||
class CwArchiveTimingTest {
|
||||
|
||||
/** Sending speed for the character counts, typical for satellite CW. */
|
||||
private val wpm = 18.0
|
||||
|
||||
/** PARIS standard: one word is five characters. */
|
||||
private val charsPerSecond = wpm * 5 / 60.0
|
||||
|
||||
private val window = CwDeepBuffer.DEFAULT_MAX_SECONDS
|
||||
private val batch = CwDeepDecoder.ARCHIVE_SECONDS
|
||||
|
||||
/** Seconds of audio that have reached the archive after listening for [elapsed]. */
|
||||
private fun archivedSeconds(elapsed: Double): Double {
|
||||
val evicted = elapsed - window
|
||||
if (evicted <= 0.0) return 0.0
|
||||
return floor(evicted / batch) * batch
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thresholdMatchesTheDeclaredBatchLength() {
|
||||
assertEquals(
|
||||
"threshold must be the batch length in samples",
|
||||
(CwDeepSpectrogram.SAMPLE_RATE * batch).toInt(),
|
||||
CwDeepDecoder.ARCHIVE_THRESHOLD
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveBatchIsShorterThanACallSign() {
|
||||
// A seven-character call sign at 18 WPM takes about 4.7 s. A batch longer than that
|
||||
// means the record can stall for longer than the single most important thing being
|
||||
// sent, which is what made the stall read as deletion.
|
||||
val callSignSeconds = 7 / charsPerSecond
|
||||
assertTrue(
|
||||
"batch $batch s must not exceed a call sign at $wpm WPM " +
|
||||
"(${"%.1f".format(callSignSeconds)} s)",
|
||||
batch <= callSignSeconds
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstTextReachesTheRecordWithinHalfAMinute() {
|
||||
// At the previous 15 s batch this was 35 s, so a short exchange ended with the record
|
||||
// still completely empty: every decoded character had only ever been in the live line,
|
||||
// which shows 64 characters and overwrites them.
|
||||
val firstArchive = window + batch
|
||||
assertTrue("first archived text must appear within 30 s, got $firstArchive s", firstArchive <= 30.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aThirtySecondSessionStillProducesARecord() {
|
||||
val archived = archivedSeconds(30.0)
|
||||
assertTrue("30 s of listening must archive something, got $archived s", archived > 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theRecordNeverStallsForLongerThanOneBatch() {
|
||||
var longestStall = 0.0
|
||||
var lastGrowthAt = window
|
||||
var previous = 0.0
|
||||
var t = window
|
||||
while (t <= 600.0) {
|
||||
val archived = archivedSeconds(t)
|
||||
if (archived > previous) {
|
||||
longestStall = maxOf(longestStall, t - lastGrowthAt)
|
||||
lastGrowthAt = t
|
||||
previous = archived
|
||||
}
|
||||
t += 0.1
|
||||
}
|
||||
assertTrue(
|
||||
"record stalled ${"%.1f".format(longestStall)} s, one batch is $batch s",
|
||||
longestStall <= batch + 0.11
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioInFlightWhenCaptureStopsWouldLoseTheEndOfTheTransmission() {
|
||||
// Why flush() exists. Neither holding place drains on its own: the live window only
|
||||
// reaches the archive by being pushed out by newer audio, and the pending batch only
|
||||
// by filling up. Both hold the end of the transmission, where the call sign is.
|
||||
val worstCase = window + batch
|
||||
val lostCharacters = worstCase * charsPerSecond
|
||||
assertTrue(
|
||||
"flush() must exist: ${"%.0f".format(lostCharacters)} characters would be lost",
|
||||
lostCharacters > 20
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archivingStaysRarerThanTheLiveDecode() {
|
||||
// Each batch is one inference. Shortening the batch trades CPU for latency, so it must
|
||||
// stay rarer than the live redecode or the archive path becomes the dominant cost.
|
||||
val liveIntervalSeconds = CwDeepBuffer.DEFAULT_REDECODE_INTERVAL_MS / 1000.0
|
||||
assertTrue(
|
||||
"batch $batch s must stay longer than the live cycle $liveIntervalSeconds s",
|
||||
batch > liveIntervalSeconds
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBatchIsLongEnoughToDecode() {
|
||||
// compute() rejects audio shorter than one FFT frame, so a batch below that would be
|
||||
// silently dropped by archiveDecode's size guard and its text lost outright.
|
||||
assertTrue(
|
||||
"batch of ${CwDeepDecoder.ARCHIVE_THRESHOLD} samples must exceed " +
|
||||
"FFT_LENGTH ${CwDeepSpectrogram.FFT_LENGTH}",
|
||||
CwDeepDecoder.ARCHIVE_THRESHOLD > CwDeepSpectrogram.FFT_LENGTH
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -5,7 +5,6 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
import java.util.Calendar
|
||||
import java.util.GregorianCalendar
|
||||
import java.util.Locale
|
||||
@@ -18,8 +17,8 @@ import java.util.TimeZone
|
||||
class AmSatAuditTest {
|
||||
|
||||
private object UnusedSource : IRemoteSource {
|
||||
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = null
|
||||
override suspend fun getFileBytes(uri: String): ByteArray? = null
|
||||
override suspend fun getNetworkBytes(url: String): ByteArray? = null
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||
|
||||
+2
-3
@@ -4,7 +4,6 @@ import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
import java.util.Calendar
|
||||
import java.util.TimeZone
|
||||
|
||||
@@ -24,8 +23,8 @@ import java.util.TimeZone
|
||||
class AmSatSlotBuildTest {
|
||||
|
||||
private object UnusedSource : IRemoteSource {
|
||||
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = null
|
||||
override suspend fun getFileBytes(uri: String): ByteArray? = null
|
||||
override suspend fun getNetworkBytes(url: String): ByteArray? = null
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||
|
||||
+108
-14
@@ -18,6 +18,7 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
@@ -40,7 +41,6 @@ import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DatabaseRepoTest {
|
||||
@@ -53,7 +53,7 @@ class DatabaseRepoTest {
|
||||
val uri = "content://look4sat/import/satellites"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
fileStreams[uri] = { validCsvStream() }
|
||||
fileData[uri] = { validCsvBytes() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo()
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
@@ -71,7 +71,7 @@ class DatabaseRepoTest {
|
||||
val uri = "content://look4sat/import/legacy"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
fileStreams[uri] = { validTleStream() }
|
||||
fileData[uri] = { validTleBytes() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo()
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
@@ -87,7 +87,7 @@ class DatabaseRepoTest {
|
||||
val customCsvUrl = "https://example.com/custom-omm.csv"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[customCsvUrl] = { validCsvStream() }
|
||||
networkData[customCsvUrl] = { validCsvBytes() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
@@ -102,29 +102,123 @@ class DatabaseRepoTest {
|
||||
repository.updateFromRemote()
|
||||
|
||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||
// New semantics: switch on + non-empty URL -> the All source uses the custom URL, data lands in the All type
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
||||
// A custom URL replaces the built-in TLE sources: none of them is requested. The
|
||||
// transceivers group is separate and its own switch is off here, so it still fetches.
|
||||
val builtInTle = Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
|
||||
assertTrue(
|
||||
"no built-in TLE source may be fetched, got " + remoteSource.requestedUrls,
|
||||
builtInTle.none { it in remoteSource.requestedUrls }
|
||||
)
|
||||
assertTrue(
|
||||
"the operator's URL must be fetched",
|
||||
customCsvUrl in remoteSource.requestedUrls
|
||||
)
|
||||
// Indexed under "Custom". It used to go under "All", where setSatelliteTypeIds
|
||||
// early-returns, so the type filter never saw these satellites and the old assertion here
|
||||
// was checking a no-op.
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Custom"])
|
||||
assertEquals(null, settingsRepo.satelliteTypeIdsByType["All"])
|
||||
// NOT "Other": that key belongs to manual file import, and setSatelliteTypeIds overwrites
|
||||
// rather than merges, so sharing it would have each source wipe the other's index.
|
||||
assertEquals(null, settingsRepo.satelliteTypeIdsByType["Other"])
|
||||
}
|
||||
|
||||
private fun validCsvStream(): InputStream = """
|
||||
/** The switch-off path must be untouched: all built-in sources, exactly as before. */
|
||||
@Test
|
||||
fun `without a custom source every built-in source is fetched`() = runTest(dispatcher) {
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
// Every built-in TLE source has to answer, or updateFromRemote throws because all of
|
||||
// them failed, which would mask what this test checks. The transceivers group is left
|
||||
// unanswered on purpose: org.json is compileOnly in core:domain, so DataParser cannot
|
||||
// parse a radio payload on the JVM anyway.
|
||||
Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
|
||||
.forEach { networkData[it] = { validCsvBytes() } }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
useCustomTLE = false,
|
||||
useCustomTransceivers = false,
|
||||
tleUrl = "https://example.com/ignored.csv",
|
||||
transceiversUrl = ""
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
repository.updateFromRemote()
|
||||
|
||||
val expected = Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
|
||||
assertTrue(
|
||||
"expected all built-in sources, got " + remoteSource.requestedUrls.size,
|
||||
expected.all { it in remoteSource.requestedUrls }
|
||||
)
|
||||
assertTrue(
|
||||
"the custom URL must not be fetched when the switch is off",
|
||||
"https://example.com/ignored.csv" !in remoteSource.requestedUrls
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A dead custom URL must fail the update even when the transceivers source answers.
|
||||
*
|
||||
* The counts used to be added together, so one transceivers success covered a total orbital
|
||||
* failure: no exception, and a fresh "updated successfully" timestamp for an update that
|
||||
* refreshed nothing. Replacing the built-in sources shrank the denominator from 28 to 2 and
|
||||
* made that easy to hit.
|
||||
*/
|
||||
@Test
|
||||
fun `a dead custom url fails the update even if transceivers succeed`() = runTest(dispatcher) {
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
Sources.transceiversDataUrls.values.filter { it.isNotBlank() }
|
||||
.forEach { networkData[it] = { "[]".encodeToByteArray() } }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
useCustomTLE = true,
|
||||
useCustomTransceivers = false,
|
||||
tleUrl = "https://example.com/dead.csv",
|
||||
transceiversUrl = ""
|
||||
)
|
||||
)
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
|
||||
var threw = false
|
||||
try {
|
||||
repository.updateFromRemote()
|
||||
} catch (_: java.io.IOException) {
|
||||
threw = true
|
||||
}
|
||||
|
||||
assertTrue("a total orbital failure must raise", threw)
|
||||
assertTrue("no entries may be inserted", localSource.insertedEntries.isEmpty())
|
||||
}
|
||||
|
||||
private fun validCsvBytes(): ByteArray = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
""".trimIndent().encodeToByteArray()
|
||||
|
||||
private fun validTleStream(): InputStream = """
|
||||
private fun validTleBytes(): ByteArray = """
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
""".trimIndent().encodeToByteArray()
|
||||
}
|
||||
|
||||
private class FakeRemoteSource : IRemoteSource {
|
||||
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||
val fileData: MutableMap<String, () -> ByteArray> = mutableMapOf()
|
||||
val networkData: MutableMap<String, () -> ByteArray> = mutableMapOf()
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||
/** Every URL asked for, so a test can assert WHICH sources were fetched, not just the result. */
|
||||
val requestedUrls = mutableListOf<String>()
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
||||
override suspend fun getFileBytes(uri: String): ByteArray? = fileData[uri]?.invoke()
|
||||
|
||||
override suspend fun getNetworkBytes(url: String): ByteArray? {
|
||||
requestedUrls += url
|
||||
return networkData[url]?.invoke()
|
||||
}
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.coreDomainPlugin)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
|
||||
compileOnly("org.json:json:20240303")
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
/**
|
||||
* Builds the position line this station puts on APRS-IS, or refuses to.
|
||||
*
|
||||
* Separated from the reporter so the packet can be tested without a socket. Every rule here
|
||||
* comes from aprs-is.net/connecting.aspx, and each of them was being broken:
|
||||
*
|
||||
* - the path must be exactly `TCPIP*`, and was absent entirely
|
||||
* - the line must not exceed 512 bytes including CRLF, and had no cap
|
||||
* - the comment must not contain a line break, or it injects a second packet
|
||||
* - a station with no position must not transmit, where 0.0 was substituted so 0 degrees north,
|
||||
* 0 degrees east - a point in the Gulf of Guinea - went out under the operator's callsign
|
||||
*/
|
||||
object AprsBeacon {
|
||||
|
||||
/** The only path a client-originated packet may carry. */
|
||||
const val PATH = "TCPIP*"
|
||||
|
||||
/** Destination for a position report with no addressee. */
|
||||
const val DESTINATION = "APRS"
|
||||
|
||||
/** Maximum line length including the CRLF the caller appends. */
|
||||
const val MAX_LINE_BYTES = 512
|
||||
|
||||
/** Comment limit for this position format, per the APRS specification. */
|
||||
const val MAX_COMMENT = 43
|
||||
|
||||
/** Why a beacon could not be built. */
|
||||
sealed interface Refusal {
|
||||
|
||||
/** No position was available. Transmitting 0,0 would claim the Gulf of Guinea. */
|
||||
data object NoPosition : Refusal
|
||||
|
||||
/** The callsign is missing, so the packet would have no valid source. */
|
||||
data object NoCallsign : Refusal
|
||||
|
||||
/** Latitude or longitude outside the possible range. */
|
||||
data class ImpossiblePosition(val latitude: Double, val longitude: Double) : Refusal
|
||||
}
|
||||
|
||||
/** Either a line ready to send, or the reason there is none. */
|
||||
sealed interface Result {
|
||||
data class Line(val text: String) : Result
|
||||
data class Blocked(val refusal: Refusal) : Result
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the position line.
|
||||
*
|
||||
* Returns [Result.Blocked] rather than a placeholder: a beacon is a claim about where the
|
||||
* operator is, and there is no honest default for "nowhere".
|
||||
*/
|
||||
fun build(
|
||||
callsign: String,
|
||||
ssid: String,
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
symbolTable: String,
|
||||
symbolCode: String,
|
||||
comment: String
|
||||
): Result {
|
||||
if (callsign.isBlank()) return Result.Blocked(Refusal.NoCallsign)
|
||||
if (latitude == null || longitude == null) return Result.Blocked(Refusal.NoPosition)
|
||||
if (latitude !in -90.0..90.0 || longitude !in -180.0..180.0) {
|
||||
return Result.Blocked(Refusal.ImpossiblePosition(latitude, longitude))
|
||||
}
|
||||
val source = AprsPacket.formatCallSsid(callsign.trim().uppercase(), ssid.trim())
|
||||
val position = AprsPosition(
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
symbolTable = tableOf(symbolTable),
|
||||
symbolCode = codeOf(symbolCode)
|
||||
)
|
||||
val header = "$source>$DESTINATION,$PATH:="
|
||||
val body = position.toUncompressedString()
|
||||
val room = MAX_LINE_BYTES - CRLF_BYTES - header.encodeToByteArray().size - body.encodeToByteArray().size
|
||||
return Result.Line(header + body + sanitiseComment(comment, room))
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip anything that would break the line, then trim to fit.
|
||||
*
|
||||
* A newline typed into the comment field used to end the packet early and start a second one
|
||||
* from the remaining text - an injection the operator could trigger by accident.
|
||||
*/
|
||||
fun sanitiseComment(comment: String, room: Int = MAX_COMMENT): String {
|
||||
if (room <= 0) return ""
|
||||
val cleaned = comment.asSequence()
|
||||
// Printable ASCII only: line breaks split the packet, and control characters have no
|
||||
// meaning in a comment while being able to confuse a parser.
|
||||
.filter { it.code in 0x20..0x7E }
|
||||
.joinToString("")
|
||||
.trim()
|
||||
val limit = minOf(MAX_COMMENT, room)
|
||||
return if (cleaned.length <= limit) cleaned else cleaned.take(limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* The symbol table byte, defaulting to the primary table.
|
||||
*
|
||||
* Must be `/`, `\` or an overlay character. It was previously whatever the operator typed
|
||||
* first - any character at all, including one that breaks the fixed-width parse. aprs.fi
|
||||
* names symbol misconfiguration as the most common reason a station never appears on the map.
|
||||
*/
|
||||
fun tableOf(entry: String): Char {
|
||||
val candidate = entry.trim().firstOrNull() ?: return TABLE_PRIMARY
|
||||
return when {
|
||||
candidate == TABLE_PRIMARY || candidate == TABLE_ALTERNATE -> candidate
|
||||
candidate.isDigit() -> candidate
|
||||
candidate in 'A'..'Z' -> candidate
|
||||
else -> TABLE_PRIMARY
|
||||
}
|
||||
}
|
||||
|
||||
/** The symbol byte. Any printable character is a valid symbol; anything else is not. */
|
||||
fun codeOf(entry: String): Char {
|
||||
val candidate = entry.trim().firstOrNull() ?: return DEFAULT_SYMBOL
|
||||
return if (candidate.code in 0x21..0x7E) candidate else DEFAULT_SYMBOL
|
||||
}
|
||||
|
||||
private const val TABLE_PRIMARY = '/'
|
||||
private const val TABLE_ALTERNATE = '\\'
|
||||
|
||||
/** Bytes the caller adds after the line. */
|
||||
private const val CRLF_BYTES = 2
|
||||
|
||||
/** Fallback symbol. `>` is a car on the primary table - a reasonable stand-in for a phone. */
|
||||
/**
|
||||
* Substituted when the stored code is unusable.
|
||||
*
|
||||
* A house, not a car. The old default was '>' (CAR) with a comment conceding it was "a
|
||||
* reasonable stand-in for a phone" - but a station beaconing from a handset showed up as a
|
||||
* vehicle for every operator who was not driving, and aprs.fi names transmit-side symbol
|
||||
* misconfiguration among the first things to check when a station looks wrong. A house is
|
||||
* correct for most users and obviously wrong rather than misleading for the rest.
|
||||
*/
|
||||
private const val DEFAULT_SYMBOL = '-'
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
/**
|
||||
* The APRS-IS login line and the verdict the server returns for it.
|
||||
*
|
||||
* Kept apart from the socket so it can be tested: whether a login was verified decides whether
|
||||
* anything this app sends reaches the network, and that distinction used to be made by a
|
||||
* substring check inside a runCatching whose result was discarded, so it could not fail loudly.
|
||||
*
|
||||
* Format per aprs-is.net/connecting.aspx:
|
||||
* `user mycall[-ss] pass passcode [vers softwarename softwarevers [filter ...]]`
|
||||
*/
|
||||
object AprsLogin {
|
||||
|
||||
/** What the server decided about a login attempt. */
|
||||
sealed interface Outcome {
|
||||
|
||||
/** The passcode matched the callsign. Packets from this client are accepted. */
|
||||
data class Verified(val callsign: String) : Outcome
|
||||
|
||||
/**
|
||||
* The server accepted the connection but did not verify the login.
|
||||
*
|
||||
* Not an error at the socket level, which is exactly why it needs surfacing: writes keep
|
||||
* succeeding while the server discards every packet. A receive-only login (passcode -1)
|
||||
* lands here legitimately.
|
||||
*/
|
||||
data class Unverified(val callsign: String) : Outcome
|
||||
|
||||
/** The server refused the login outright. */
|
||||
data class Rejected(val detail: String) : Outcome
|
||||
|
||||
/** Nothing recognisable arrived. The connection may still work; we simply do not know. */
|
||||
data class Unknown(val detail: String) : Outcome
|
||||
}
|
||||
|
||||
/** Any run of whitespace, collapsed to a hyphen inside a single token. */
|
||||
private val WHITESPACE = Regex("""\s+""")
|
||||
|
||||
/** Passcode value that asks for a receive-only connection. */
|
||||
const val RECEIVE_ONLY_PASSCODE = -1
|
||||
|
||||
/**
|
||||
* Build the login line.
|
||||
*
|
||||
* `vers` takes TWO tokens - a software name and a version, separated by a space. An earlier
|
||||
* version of this replaced that space with a hyphen, reading the rule "softwarename must not
|
||||
* contain a space" as "the field must be a single token". Live aprsc 2.1.21 rejects the result:
|
||||
*
|
||||
* sent: user N0CALL pass -1 vers Look4Sat-4.5.4
|
||||
* got: # Invalid login: software name and version are not separated by a space
|
||||
*
|
||||
* So name and version stay apart, and whitespace is collapsed WITHIN each of them instead.
|
||||
*/
|
||||
fun line(
|
||||
callsign: String,
|
||||
ssid: String,
|
||||
passcode: Int,
|
||||
name: String,
|
||||
version: String,
|
||||
filter: String = ""
|
||||
): String {
|
||||
val callSsid = AprsPacket.formatCallSsid(callsign, ssid)
|
||||
val safeName = name.trim().replace(WHITESPACE, "-").ifEmpty { "Look4Sat" }
|
||||
val safeVersion = version.trim().replace(WHITESPACE, "-").ifEmpty { "0" }
|
||||
val base = "user $callSsid pass $passcode vers $safeName $safeVersion"
|
||||
val trimmedFilter = filter.trim()
|
||||
return if (trimmedFilter.isEmpty()) base else "$base $trimmedFilter"
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret one line of server output, or null when it carries no verdict.
|
||||
*
|
||||
* Classified by what is KNOWN HARMLESS rather than by a list of known refusals, because that
|
||||
* list was incomplete and the failure is silent. aprsc refuses with `# Invalid login: ...` but
|
||||
* also `# Login by user not allowed` - observed live on rotate.aprs2.net - and `# Port full`
|
||||
* and `# Server full`. Each was skipped as chatter, the login timed out into Unknown, Unknown
|
||||
* is deliberately read as "may be working", and every send afterwards reported success to an
|
||||
* operator the server had refused.
|
||||
*
|
||||
* So identification and keepalive comments return null, a logresp is parsed, and anything else
|
||||
* the server bothers to say during login counts as it objecting.
|
||||
*
|
||||
* Note that "unverified" contains "verified", so the negative is tested first - a naive
|
||||
* contains("verified") reports every refusal as acceptance.
|
||||
*/
|
||||
fun parse(line: String): Outcome? {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
if (!trimmed.startsWith("#")) {
|
||||
// A non-comment line during login is the server objecting in plain text.
|
||||
return Outcome.Rejected(trimmed)
|
||||
}
|
||||
val lower = trimmed.lowercase()
|
||||
if (lower.contains("logresp")) {
|
||||
val callsign = callsignFrom(trimmed)
|
||||
return when {
|
||||
lower.contains("unverified") -> Outcome.Unverified(callsign)
|
||||
lower.contains("verified") -> Outcome.Verified(callsign)
|
||||
else -> Outcome.Unknown(trimmed)
|
||||
}
|
||||
}
|
||||
// Identification and keepalives are the only comments that mean "keep reading".
|
||||
if (HARMLESS.any { lower.startsWith(it) }) return null
|
||||
return Outcome.Rejected(trimmed.removePrefix("#").trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment prefixes that carry no verdict.
|
||||
*
|
||||
* Matching a prefix rather than searching for refusal words means a refusal nobody anticipated
|
||||
* is treated as a refusal instead of being ignored.
|
||||
*/
|
||||
private val HARMLESS = listOf("# aprsc", "# javaprssrvr", "# aprsis", "# filter")
|
||||
|
||||
/** The callsign token in `# logresp CALL verified, ...`, or empty when absent. */
|
||||
private fun callsignFrom(response: String): String {
|
||||
val tokens = response.removePrefix("#").trim().split(Regex("\\s+"))
|
||||
val index = tokens.indexOfFirst { it.equals("logresp", ignoreCase = true) }
|
||||
if (index < 0) return ""
|
||||
return tokens.getOrNull(index + 1)?.trimEnd(',') ?: ""
|
||||
}
|
||||
}
|
||||
+10
-16
@@ -2,7 +2,7 @@ package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.round
|
||||
import java.util.Locale
|
||||
import com.rtbishop.look4sat.core.domain.utility.formatString
|
||||
|
||||
/**
|
||||
* APRS-IS protocol core (pure Kotlin, no Android dependencies).
|
||||
@@ -22,12 +22,6 @@ object AprsPacket {
|
||||
return hash and 0x7FFF
|
||||
}
|
||||
|
||||
/** Login line: user CALL-SSID pass XXXX vers XXXX */
|
||||
fun formatLogin(callsign: String, ssid: String, passcode: Int, version: String): String {
|
||||
val callSsid = formatCallSsid(callsign, ssid)
|
||||
return "user $callSsid pass $passcode vers $version"
|
||||
}
|
||||
|
||||
/** Callsign-SSID join (BG7NTA + 5 -> BG7NTA-5) */
|
||||
fun formatCallSsid(callsign: String, ssid: String): String {
|
||||
if (ssid.isNullOrEmpty()) return callsign
|
||||
@@ -36,7 +30,7 @@ object AprsPacket {
|
||||
|
||||
/** Optional distance filter: filter r/lat/lon/dist */
|
||||
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
|
||||
return String.format(Locale.ROOT, "r/%.3f/%.3f/%d", latitude, longitude, distKm)
|
||||
return formatString("r/%.3f/%.3f/%d", latitude, longitude, distKm)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +42,7 @@ object AprsPacket {
|
||||
fun formatAltitude(altitudeMeters: Double?): String {
|
||||
if (altitudeMeters == null) return ""
|
||||
val feet = (altitudeMeters * 3.2808399).toInt().coerceIn(0, 999999)
|
||||
return String.format(Locale.ROOT, "/A=%06d", feet)
|
||||
return formatString("/A=%06d", feet)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,7 +54,7 @@ object AprsPacket {
|
||||
if (speedMps == null || bearing == null) return ""
|
||||
val knots = (speedMps * 1.94384449).toInt().coerceIn(0, 999)
|
||||
val course = ((bearing.toInt() % 360) + 360) % 360
|
||||
return String.format(Locale.ROOT, "/%03d/%03d", course, knots)
|
||||
return formatString("/%03d/%03d", course, knots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,17 +106,17 @@ class AprsPosition(
|
||||
val hundredths = iRound % 100
|
||||
val frac = when (positionAmbiguity) {
|
||||
1 -> " . "
|
||||
2 -> String.format(Locale.ROOT, "%d . ", minutes / 10)
|
||||
3 -> String.format(Locale.ROOT, "%02d. ", minutes)
|
||||
4 -> String.format(Locale.ROOT, "%02d.%d ", minutes, hundredths / 10)
|
||||
else -> String.format(Locale.ROOT, "%02d.%02d", minutes, hundredths)
|
||||
2 -> formatString("%d . ", minutes / 10)
|
||||
3 -> formatString("%02d. ", minutes)
|
||||
4 -> formatString("%02d.%d ", minutes, hundredths / 10)
|
||||
else -> formatString("%02d.%02d", minutes, hundredths)
|
||||
}
|
||||
return if (isLat) {
|
||||
val ns = if (value >= 0) 'N' else 'S'
|
||||
String.format(Locale.ROOT, "%02d%s%c", degrees, frac, ns)
|
||||
formatString("%02d%s%c", degrees, frac, ns)
|
||||
} else {
|
||||
val ew = if (value >= 0) 'E' else 'W'
|
||||
String.format(Locale.ROOT, "%03d%s%c", degrees, frac, ew)
|
||||
formatString("%03d%s%c", degrees, frac, ew)
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
/**
|
||||
* Decides what passcode to present to APRS-IS, and whether the operator's entry is usable.
|
||||
*
|
||||
* The app must not derive a transmit passcode for the operator. APRS-IS states that supplying
|
||||
* the correct passcode to a user is the software author's responsibility, and the passcode
|
||||
* exists as a licence check - deriving it in-app and shipping the algorithm defeats the point.
|
||||
* APRSdroid has the same algorithm in the same file and deliberately does not use it for this
|
||||
* reason, validating the operator's entry instead and linking out to request one.
|
||||
*
|
||||
* So this validates. [AprsPacket.passcode] stays, because checking an entry means recomputing
|
||||
* the expected value, but nothing here substitutes a derived code for a missing one.
|
||||
*/
|
||||
object AprsPasscode {
|
||||
|
||||
/** Value that asks APRS-IS for a receive-only connection. Always legitimate. */
|
||||
const val RECEIVE_ONLY = -1
|
||||
|
||||
/** What the operator's passcode entry amounts to. */
|
||||
sealed interface Entry {
|
||||
|
||||
/** A passcode that matches the callsign. Reports will be forwarded. */
|
||||
data class Transmit(val passcode: Int) : Entry
|
||||
|
||||
/**
|
||||
* An explicit -1, or a blank entry.
|
||||
*
|
||||
* A blank entry lands here rather than being filled in with a derived code: connecting
|
||||
* receive-only is honest about what an operator without a passcode can do, where a
|
||||
* derived code silently claims a licence check that was never performed.
|
||||
*/
|
||||
data object ReceiveOnly : Entry
|
||||
|
||||
/** Something was typed but it is not this callsign's passcode. */
|
||||
data class Mismatch(val expectedFor: String) : Entry
|
||||
|
||||
/** Something was typed that is not a number at all. */
|
||||
data object NotANumber : Entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify what the operator typed.
|
||||
*
|
||||
* A mismatch is reported rather than corrected, so the UI can refuse to save and say why.
|
||||
* Silently swapping in a derived code is how an operator ends up believing they are
|
||||
* transmitting under a passcode they never obtained.
|
||||
*/
|
||||
fun classify(callsign: String, entry: String): Entry {
|
||||
val trimmed = entry.trim()
|
||||
if (trimmed.isEmpty()) return Entry.ReceiveOnly
|
||||
val value = trimmed.toIntOrNull() ?: return Entry.NotANumber
|
||||
// Checked before the callsign comparison: -1 is the documented receive-only value and
|
||||
// is never anyone's passcode, so comparing it would report a deliberate choice as a typo.
|
||||
if (value == RECEIVE_ONLY) return Entry.ReceiveOnly
|
||||
val call = callsign.trim()
|
||||
if (call.isEmpty()) return Entry.Mismatch("")
|
||||
return if (value == AprsPacket.passcode(call)) {
|
||||
Entry.Transmit(value)
|
||||
} else {
|
||||
Entry.Mismatch(call.uppercase())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The number to send in the login line for this entry.
|
||||
*
|
||||
* Anything not usable becomes [RECEIVE_ONLY]: the connection still works, the operator is
|
||||
* told separately that their reports are not being forwarded, and no packet goes out under
|
||||
* a passcode the app invented. The previous code sent a derived transmit passcode here,
|
||||
* and `takeIf { it >= 0 }` additionally made an explicit -1 impossible to use - which also
|
||||
* blocked the one safe way to test a setup, since a receive-only login is how you confirm
|
||||
* the connection works without putting anything on the network.
|
||||
*/
|
||||
fun loginValue(callsign: String, entry: String): Int =
|
||||
when (val classified = classify(callsign, entry)) {
|
||||
is Entry.Transmit -> classified.passcode
|
||||
else -> RECEIVE_ONLY
|
||||
}
|
||||
|
||||
/** True when this entry lets the operator's reports reach the network. */
|
||||
fun canTransmit(callsign: String, entry: String): Boolean =
|
||||
classify(callsign, entry) is Entry.Transmit
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
/**
|
||||
* One APRS symbol an operator might plausibly want.
|
||||
*
|
||||
* [table] and [code] are the two characters that go into the packet. [descriptionKey] names a
|
||||
* string resource rather than holding text, because core:domain has no access to resources and
|
||||
* hardcoding English here would put wording outside the locale files.
|
||||
*/
|
||||
data class AprsSymbol(val table: Char, val code: Char, val descriptionKey: String)
|
||||
|
||||
/**
|
||||
* A short list of symbols worth offering, instead of two free-text fields.
|
||||
*
|
||||
* The fields accepted anything and used only the first character, so typing "satellite" into the
|
||||
* table field persisted the whole word and beaconed as `/` - the field lied about what it did.
|
||||
* aprs.fi's own troubleshooting guidance puts transmit-side symbol misconfiguration among the first
|
||||
* things to check when a station does not appear as expected.
|
||||
*
|
||||
* The strongest single argument for a list: `\S` is Satellite/Pacsat but `/S` is SHUTTLE. One
|
||||
* keystroke apart, and both look correct to someone typing from memory.
|
||||
*
|
||||
* Renderings are from aprs.org/symbols/symbolsX.txt (WB4APR, 25 Nov 2015). The list is deliberately
|
||||
* short - it covers fixed, portable, vehicle and satellite postures, not all 400-odd symbols.
|
||||
*/
|
||||
object AprsSymbols {
|
||||
|
||||
/** Fixed home station. Correct for most users, and wrong in an obvious way for the rest. */
|
||||
val HOUSE = AprsSymbol('/', '-', "aprs_symbol_house")
|
||||
|
||||
val curated = listOf(
|
||||
HOUSE,
|
||||
AprsSymbol('\\', '-', "aprs_symbol_house_alt"),
|
||||
AprsSymbol('/', '[', "aprs_symbol_person"),
|
||||
AprsSymbol('/', 'y', "aprs_symbol_yagi"),
|
||||
// Alternate table. /S is SHUTTLE, which is not what anyone means here.
|
||||
AprsSymbol('\\', 'S', "aprs_symbol_satellite"),
|
||||
AprsSymbol('/', ';', "aprs_symbol_portable"),
|
||||
AprsSymbol('/', '$', "aprs_symbol_phone"),
|
||||
AprsSymbol('/', 'I', "aprs_symbol_tcpip"),
|
||||
AprsSymbol('\\', 'K', "aprs_symbol_ht"),
|
||||
AprsSymbol('/', '>', "aprs_symbol_car"),
|
||||
AprsSymbol('/', 'k', "aprs_symbol_truck"),
|
||||
AprsSymbol('/', 'v', "aprs_symbol_van"),
|
||||
AprsSymbol('/', 'R', "aprs_symbol_rv"),
|
||||
AprsSymbol('/', 'b', "aprs_symbol_bike")
|
||||
)
|
||||
|
||||
/**
|
||||
* Find the curated entry matching a stored pair, or null when it is not on the list.
|
||||
*
|
||||
* Null matters: an operator may have set a symbol this list does not offer, and the picker must
|
||||
* show it as-is rather than silently substituting the nearest entry.
|
||||
*/
|
||||
fun find(table: Char, code: Char): AprsSymbol? =
|
||||
curated.firstOrNull { it.table == table && it.code == code }
|
||||
|
||||
/** Same, from whatever strings the settings screen holds. Blank means the shipped default. */
|
||||
fun find(table: String, code: String): AprsSymbol? {
|
||||
val t = table.firstOrNull() ?: HOUSE.table
|
||||
val c = code.firstOrNull() ?: HOUSE.code
|
||||
return find(t, c)
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Low-pass filter applied before decimating to the model's sample rate.
|
||||
*
|
||||
* [CwDeepSpectrogram.resampleLinear] drops from the capture rate to 3200 Hz by
|
||||
* interpolating between samples, with nothing removing the content above the new
|
||||
* Nyquist of 1600 Hz first. Everything higher folds back into the audible window,
|
||||
* which is not a subtle degradation - measured on 44100 Hz input a 3000 Hz tone
|
||||
* reappears at 200 Hz at 119 times the spectral mean, indistinguishable from a real
|
||||
* signal, and 1800 Hz lands on 1400 Hz. Worse for copy, the whole 1600-22050 Hz band
|
||||
* of hiss folds down on top of the signal and lifts the noise floor across the entire
|
||||
* display.
|
||||
*
|
||||
* The resampler itself is deliberately left alone: its comment notes it matches the
|
||||
* reference implementation the model was trained against, so changing its arithmetic
|
||||
* would move the spectrogram away from what DeepCW expects. Filtering first fixes the
|
||||
* aliasing without touching that contract.
|
||||
*
|
||||
* A windowed-sinc FIR rather than a biquad cascade: the transition band has to be
|
||||
* steep to keep 1600 Hz while rejecting 1800 Hz, and a linear-phase FIR does not
|
||||
* smear the keying envelope the way a high-order IIR would.
|
||||
*/
|
||||
object CwAntiAlias {
|
||||
|
||||
/**
|
||||
* Cut-off as a fraction of the target Nyquist.
|
||||
*
|
||||
* Below 1.0 so the transition band lands inside the discarded region rather than
|
||||
* straddling it. At 0.92 the response is flat to 1470 Hz, which still covers the
|
||||
* model's 1200 Hz window and the shifter's detection range with room to spare.
|
||||
*/
|
||||
private const val CUTOFF_FRACTION = 0.92
|
||||
|
||||
/**
|
||||
* Filter length. Odd so the group delay is a whole number of samples.
|
||||
*
|
||||
* 127 taps at 44100 Hz gives roughly a 700 Hz transition width - enough to put
|
||||
* 1800 Hz down by more than 40 dB while passing 1470 Hz unattenuated. Longer would
|
||||
* be sharper and slower; this runs on a phone during a pass.
|
||||
*/
|
||||
private const val TAPS = 127
|
||||
|
||||
/** Delay introduced by [TAPS], for callers that need to align another path. */
|
||||
const val GROUP_DELAY_SAMPLES = TAPS / 2
|
||||
|
||||
/**
|
||||
* Filter [audio] so that decimating to [targetRate] cannot alias.
|
||||
*
|
||||
* A no-op when [sourceRate] is at or below [targetRate], since there is nothing
|
||||
* above the target Nyquist to remove. Returns a new array; [audio] is unchanged.
|
||||
*/
|
||||
fun prepareForDecimation(audio: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
|
||||
if (audio.isEmpty() || sourceRate <= targetRate) return audio
|
||||
val cutoffHz = targetRate / 2.0 * CUTOFF_FRACTION
|
||||
return applyFir(audio, kernelFor(cutoffHz, sourceRate))
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowed-sinc low-pass kernel, normalised to unity gain at DC.
|
||||
*
|
||||
* Blackman window: its sidelobes are around -58 dB against the Hamming window's
|
||||
* -41 dB, and sidelobe level is exactly what decides how much of the folded band
|
||||
* survives.
|
||||
*/
|
||||
private fun kernelFor(cutoffHz: Double, sampleRate: Int): FloatArray {
|
||||
val normalised = cutoffHz / sampleRate
|
||||
val half = TAPS / 2
|
||||
val raw = DoubleArray(TAPS) { i ->
|
||||
val n = i - half
|
||||
val sinc = if (n == 0) {
|
||||
2.0 * normalised
|
||||
} else {
|
||||
sin(2.0 * PI * normalised * n) / (PI * n)
|
||||
}
|
||||
val window = 0.42 -
|
||||
0.5 * cos(2.0 * PI * i / (TAPS - 1)) +
|
||||
0.08 * cos(4.0 * PI * i / (TAPS - 1))
|
||||
sinc * window
|
||||
}
|
||||
val sum = raw.sum()
|
||||
// Unity DC gain, so filtering does not change the level the model was trained on.
|
||||
return FloatArray(TAPS) { i -> (raw[i] / sum).toFloat() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convolve, compensating for the filter's own delay so the output lines up with
|
||||
* the input. Edge taps that fall outside the buffer see zeros, which costs the
|
||||
* first and last [GROUP_DELAY_SAMPLES] samples of an isolated buffer.
|
||||
*/
|
||||
private fun applyFir(audio: FloatArray, kernel: FloatArray): FloatArray {
|
||||
val out = FloatArray(audio.size)
|
||||
for (i in audio.indices) {
|
||||
var sum = 0f
|
||||
for (k in kernel.indices) {
|
||||
val j = i - k + GROUP_DELAY_SAMPLES
|
||||
if (j >= 0 && j < audio.size) sum += kernel[k] * audio[j]
|
||||
}
|
||||
out[i] = sum
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk-by-chunk filter that carries the state [prepareForDecimation] cannot.
|
||||
*
|
||||
* Two things are needed for concatenated chunks to match a whole-buffer filter.
|
||||
* History is the obvious one: the FIR spans [TAPS] samples, so a chunk's first
|
||||
* outputs need the tail of the one before it.
|
||||
*
|
||||
* The second is less obvious and was measured rather than reasoned about. A
|
||||
* linear-phase FIR is centred, so output sample `i` needs input up to
|
||||
* `i + GROUP_DELAY_SAMPLES` - samples that have not been captured yet when the
|
||||
* chunk arrives. A first attempt let those taps fall off the end of the buffer and
|
||||
* read as zeros; against a whole-buffer filter that diverged by 0.134 across the
|
||||
* last 44 samples of every chunk, which is a click at each boundary rather than a
|
||||
* rounding difference.
|
||||
*
|
||||
* So output is held back by [GROUP_DELAY_SAMPLES] samples: each call emits the
|
||||
* samples whose lookahead has now arrived, and keeps the rest until the next chunk
|
||||
* completes them. The cost is a fixed 63-sample delay, about 1.4 ms at 44100 Hz,
|
||||
* against a 20 WPM dot of roughly 60 ms.
|
||||
*
|
||||
* Not thread-safe: driven from the single capture coroutine.
|
||||
*/
|
||||
class Streaming(sourceRate: Int, targetRate: Int) {
|
||||
|
||||
private val kernel: FloatArray? =
|
||||
if (sourceRate <= targetRate) {
|
||||
null
|
||||
} else {
|
||||
kernelFor(targetRate / 2.0 * CUTOFF_FRACTION, sourceRate)
|
||||
}
|
||||
|
||||
/** Samples not yet emitted: filter history plus the lookahead still owed. */
|
||||
private var pending = FloatArray(0)
|
||||
|
||||
/** Filter one chunk, continuing from the previous call. */
|
||||
fun process(chunk: FloatArray): FloatArray {
|
||||
val k = kernel ?: return chunk
|
||||
if (chunk.isEmpty()) return chunk
|
||||
|
||||
val combined = FloatArray(pending.size + chunk.size)
|
||||
pending.copyInto(combined)
|
||||
chunk.copyInto(combined, pending.size)
|
||||
|
||||
// Only samples with a full window on both sides are ready. Everything from
|
||||
// here on still needs input that has not arrived.
|
||||
val ready = combined.size - TAPS + 1
|
||||
if (ready <= 0) {
|
||||
pending = combined
|
||||
return FloatArray(0)
|
||||
}
|
||||
|
||||
val out = FloatArray(ready)
|
||||
for (i in 0 until ready) {
|
||||
var sum = 0f
|
||||
for (t in k.indices) {
|
||||
sum += k[t] * combined[i + TAPS - 1 - t]
|
||||
}
|
||||
out[i] = sum
|
||||
}
|
||||
|
||||
// Carry the tail that the next chunk will complete.
|
||||
pending = combined.copyOfRange(ready, combined.size)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Clear pending state, e.g. after a decoder reset. */
|
||||
fun reset() {
|
||||
pending = FloatArray(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+16
-3
@@ -37,9 +37,11 @@ interface ICwDecoder {
|
||||
val decodedText: StateFlow<String>
|
||||
|
||||
/**
|
||||
* Permanent transcript of everything that has scrolled out of the live
|
||||
* window. Unlike [decodedText] this only ever grows (until [reset]); it is
|
||||
* what the user reads back after a signal has passed.
|
||||
* Transcript of audio that has been archived, and will not be revised.
|
||||
*
|
||||
* Only ever grows until [reset]. [decodedText] is rewritten from scratch on every
|
||||
* redecode, so a pane that concatenates it loses text the operator has already read -
|
||||
* which a paper log does not do. This is the flow such a pane must bind to.
|
||||
*/
|
||||
val historyText: StateFlow<String>
|
||||
|
||||
@@ -74,6 +76,17 @@ interface ICwDecoder {
|
||||
/** Non-null when the decoder cannot run, for example the model failed to load. */
|
||||
val errorMessage: StateFlow<String?>
|
||||
|
||||
/**
|
||||
* Decode whatever audio is still held in the pipeline into [historyText].
|
||||
*
|
||||
* Nothing reaches [historyText] until audio has been pushed out of the live window and
|
||||
* then accumulated into a full archive batch, so the last stretch of a session is always
|
||||
* still in flight when capture stops - and neither holding place drains on its own. That
|
||||
* stretch is the end of the transmission, the part with the call sign in it. Call on
|
||||
* pause and before [close].
|
||||
*/
|
||||
suspend fun flush()
|
||||
|
||||
/** Feed captured mono PCM in -1..1. Safe to call from a capture thread. */
|
||||
suspend fun processBuffer(samples: FloatArray, sampleRate: Int)
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.qrz
|
||||
|
||||
/**
|
||||
* Looks up a station's grid square on QRZ.
|
||||
*
|
||||
* An interface so the log screen can ask for a grid without reaching into core:data, and without
|
||||
* reading the stored cookie itself - a composable was fetching it straight out of
|
||||
* SharedPreferences through LocalContext, which put disk access in composition and bypassed the
|
||||
* repository layer entirely.
|
||||
*/
|
||||
interface IQrzGridLookup {
|
||||
|
||||
/**
|
||||
* Look up [callsign].
|
||||
*
|
||||
* Returns [QrzGrid.SignedOut] when no cookie is stored, since the operator's remedy is the
|
||||
* same either way: put a valid cookie in settings.
|
||||
*/
|
||||
suspend fun lookup(callsign: String): QrzGrid
|
||||
|
||||
/**
|
||||
* Check the stored cookie by asking QRZ whose account it belongs to.
|
||||
*
|
||||
* Returns the callsign QRZ reports, or null when the cookie is absent or no longer valid. Lets
|
||||
* settings tell the operator which account they pasted rather than only claiming success.
|
||||
*/
|
||||
suspend fun signedInAs(): String?
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.qrz
|
||||
|
||||
/**
|
||||
* Outcome of a QRZ grid lookup.
|
||||
*
|
||||
* Four outcomes rather than a nullable string, because the previous null meant any of "the
|
||||
* station has no grid on file", "the cookie expired", "the request timed out" and "QRZ changed
|
||||
* its markup" - and the operator saw the same blank either way, with no way to tell that
|
||||
* re-pasting the cookie would fix it.
|
||||
*/
|
||||
sealed interface QrzGrid {
|
||||
|
||||
/** The station's Maidenhead locator, as QRZ has it. */
|
||||
data class Found(val locator: String) : QrzGrid
|
||||
|
||||
/** The page was read and the station has no locator published. Not an error. */
|
||||
data object NotOnFile : QrzGrid
|
||||
|
||||
/** The detail table was absent, which is what QRZ serves when the cookie is not valid. */
|
||||
data object SignedOut : QrzGrid
|
||||
|
||||
/** The request never completed. [attempts] is how many tries were made before giving up. */
|
||||
data class Unreachable(val attempts: Int) : QrzGrid
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsing of QRZ's callsign page, separate from the fetch so it can be tested without a
|
||||
* network. Pure string work over already-downloaded markup.
|
||||
*/
|
||||
object QrzGridParser {
|
||||
|
||||
/** The detail row QRZ renders for a station that published a locator. */
|
||||
private val gridRow = Regex("""<td class="dh">Grid Square</td>\s*<td class="di">([^<]+)</td>""")
|
||||
|
||||
/**
|
||||
* QRZ's own words on a callsign page served to a visitor who is not signed in.
|
||||
*
|
||||
* Classified on this positive notice rather than on the detail table being absent: measured
|
||||
* against live responses, a callsign QRZ has never heard of also returns HTTP 200 with zero
|
||||
* detail rows, because QRZ serves its search form instead of a callsign page. Keying on
|
||||
* absence therefore reported a mistyped callsign as an expired cookie, and would have sent
|
||||
* the operator off to re-paste a cookie that was never broken.
|
||||
*/
|
||||
private val signedOutNotice = Regex("""Login is required for additional detail""")
|
||||
|
||||
/** The account menu on a signed-in page, used to read back whose cookie this is. */
|
||||
private val accountCallsign = Regex("""<li class="leaf last"[^>]*>\s*([A-Z0-9/]+)\s*<ul""")
|
||||
|
||||
/** A cookie name=value pair inside a browser extension's JSON export. */
|
||||
private val jsonCookie = Regex(""""name"\s*:\s*"([^"]+)"\s*,\s*"value"\s*:\s*"([^"]*)"""")
|
||||
|
||||
/**
|
||||
* Interpret a callsign page.
|
||||
*
|
||||
* Only QRZ explicitly saying that a login is required counts as signed out, so an absent
|
||||
* locator degrades to the harmless [QrzGrid.NotOnFile] and only a genuinely stale cookie
|
||||
* sends the operator back to settings. Getting this wrong in either direction misdirects
|
||||
* them: the old client returned null for everything, and keying on the detail table being
|
||||
* absent would have blamed the cookie for a mistyped callsign.
|
||||
*/
|
||||
fun parseGrid(html: String): QrzGrid {
|
||||
val locator = gridRow.find(html)?.groupValues?.get(1)?.trim()
|
||||
if (!locator.isNullOrBlank()) return QrzGrid.Found(locator)
|
||||
if (signedOutNotice.containsMatchIn(html)) return QrzGrid.SignedOut
|
||||
return QrzGrid.NotOnFile
|
||||
}
|
||||
|
||||
/** The callsign this cookie is signed in as, or null when it is not signed in. */
|
||||
fun parseOwnCallsign(html: String): String? =
|
||||
accountCallsign.find(html)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* Normalise the pasted cookie into a Cookie header value.
|
||||
*
|
||||
* Accepts a raw `k=v; k=v` header or the JSON array a cookie-export extension produces,
|
||||
* since the operator pastes whatever their browser handed them. Parsed by regex rather
|
||||
* than a JSON library so it needs no extra dependency and stays testable as plain text.
|
||||
*/
|
||||
fun cookieHeader(raw: String): String {
|
||||
val text = raw.trim()
|
||||
if (text.isEmpty()) return ""
|
||||
if (!text.startsWith("[")) return text
|
||||
val pairs = jsonCookie.findAll(text)
|
||||
.map { it.groupValues[1] to it.groupValues[2] }
|
||||
.filter { it.first.isNotBlank() }
|
||||
.toList()
|
||||
return if (pairs.isEmpty()) text else pairs.joinToString("; ") { "${it.first}=${it.second}" }
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
+3
@@ -50,6 +50,9 @@ interface IMainContainer {
|
||||
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
||||
|
||||
fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
|
||||
|
||||
/** QRZ grid lookup, so the log screen never touches the stored cookie itself. */
|
||||
fun provideQrzGridLookup(): com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
|
||||
}
|
||||
|
||||
data class MutualPassData(
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+7
-1
@@ -19,10 +19,16 @@ package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
interface ISensorsRepo {
|
||||
val sensorData: StateFlow<Pair<Float, Float>>
|
||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||
|
||||
// The default used to be System.currentTimeMillis(), which Kotlin/Native does not have;
|
||||
// kotlin.time.Clock is the multiplatform equivalent.
|
||||
fun getMagDeclination(geoPos: GeoPos, time: Long = Clock.System.now().toEpochMilliseconds()): Float
|
||||
fun enableSensor()
|
||||
fun disableSensor()
|
||||
}
|
||||
File renamed without changes.
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
/** Minimal platform HTTP client used by Wavelog/QRZ features. Implemented per platform
|
||||
* (OkHttp on Android, NSURLSession on iOS). */
|
||||
interface IHttpClient {
|
||||
suspend fun post(url: String, headers: Map<String, String>, body: String): HttpResult
|
||||
suspend fun get(url: String, headers: Map<String, String>): HttpResult
|
||||
}
|
||||
|
||||
/** [code] is the HTTP status code, or 0 when the request could not be sent at all. */
|
||||
data class HttpResult(val code: Int, val body: String, val failure: String? = null)
|
||||
File renamed without changes.
+2
-4
@@ -17,11 +17,9 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
interface IRemoteSource {
|
||||
suspend fun getFileStream(uri: String): InputStream?
|
||||
suspend fun getNetworkStream(url: String): InputStream?
|
||||
suspend fun getFileBytes(uri: String): ByteArray?
|
||||
suspend fun getNetworkBytes(url: String): ByteArray?
|
||||
|
||||
/** Fetch AMSAT API catalog (JSON string; null on failure) */
|
||||
suspend fun getAmSatCatalog(): String?
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+8
@@ -22,4 +22,12 @@ interface IShowToast {
|
||||
|
||||
/** Show by resource ID (four-language text) */
|
||||
operator fun invoke(resId: Int)
|
||||
|
||||
/**
|
||||
* Show a resource with format arguments, so a count can appear in a localised message.
|
||||
*
|
||||
* The alternative is building the string in a view model, which puts wording outside the
|
||||
* resource files and hardcodes one language.
|
||||
*/
|
||||
operator fun invoke(resId: Int, vararg formatArgs: Any)
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Dependency-free replacement for jvm/Android `java.lang.String.format`, required because
|
||||
* JVM formatting APIs do not exist on Kotlin/Native (iOS).
|
||||
*
|
||||
* Supported conversions: `%d` `%x` `%X` `%f` `%s` `%c` `%%`, plus the `0` flag, a width and
|
||||
* `.precision` (for `%f`). Anything else throws, so an unsupported pattern never silently
|
||||
* produces a wrong string.
|
||||
*
|
||||
* `%f` rounding matches java.lang.String.format (half-up on the exact double value) whenever
|
||||
* the scaled value fits in a Long (< 2^53), which covers every frequency/coordinate string
|
||||
* the app builds. Negative zero is preserved like the JVM does ("-0.000").
|
||||
*/
|
||||
fun formatString(pattern: String, vararg args: Any?): String {
|
||||
val out = StringBuilder(pattern.length + 16)
|
||||
var argIndex = 0
|
||||
var i = 0
|
||||
while (i < pattern.length) {
|
||||
val ch = pattern[i]
|
||||
if (ch != '%') {
|
||||
out.append(ch); i++; continue
|
||||
}
|
||||
i++
|
||||
if (i >= pattern.length) throw IllegalArgumentException("dangling '%' in pattern: $pattern")
|
||||
if (pattern[i] == '%') {
|
||||
out.append('%'); i++; continue
|
||||
}
|
||||
var zeroPadded = false
|
||||
if (pattern[i] == '0') {
|
||||
zeroPadded = true; i++
|
||||
}
|
||||
var width = 0
|
||||
while (i < pattern.length && pattern[i].isDigit()) {
|
||||
width = width * 10 + (pattern[i] - '0'); i++
|
||||
}
|
||||
// java.lang.String.format throws MissingFormatWidthException for this; an illegal
|
||||
// pattern must not quietly format one way on Android and another way on iOS.
|
||||
if (zeroPadded && width == 0) {
|
||||
throw IllegalArgumentException("'0' flag without a width in pattern: $pattern")
|
||||
}
|
||||
var precision = -1 // java.lang.String.format defaults %f to 6 decimals
|
||||
if (i < pattern.length && pattern[i] == '.') {
|
||||
i++
|
||||
precision = 0 // the digits accumulate from zero; -1 means "not specified"
|
||||
while (i < pattern.length && pattern[i].isDigit()) {
|
||||
precision = precision * 10 + (pattern[i] - '0'); i++
|
||||
}
|
||||
}
|
||||
if (i >= pattern.length) throw IllegalArgumentException("truncated conversion in pattern: $pattern")
|
||||
val conversion = pattern[i]
|
||||
i++
|
||||
val arg = if (argIndex < args.size) args[argIndex++] else null
|
||||
val rendered = when (conversion) {
|
||||
'd' -> longArg(arg, conversion, pattern).toString()
|
||||
'x' -> longArg(arg, conversion, pattern).toString(16)
|
||||
'X' -> longArg(arg, conversion, pattern).toString(16).uppercase()
|
||||
'f' -> formatFixed(doubleArg(arg, pattern), if (precision < 0) 6 else precision, pattern)
|
||||
's' -> arg?.toString() ?: "null"
|
||||
'c' -> when (arg) {
|
||||
is Char -> arg.toString()
|
||||
is Int -> arg.toChar().toString()
|
||||
else -> throw IllegalArgumentException("unsupported %c argument: $arg in pattern: $pattern")
|
||||
}
|
||||
else -> throw IllegalArgumentException("unsupported conversion %$conversion in pattern: $pattern")
|
||||
}
|
||||
if (width <= rendered.length) {
|
||||
out.append(rendered)
|
||||
} else if (zeroPadded && !rendered.startsWith("-") && !rendered.startsWith("+")) {
|
||||
repeat(width - rendered.length) { out.append('0') }
|
||||
out.append(rendered)
|
||||
} else if (zeroPadded) {
|
||||
out.append(rendered[0])
|
||||
repeat(width - rendered.length) { out.append('0') }
|
||||
out.append(rendered.substring(1))
|
||||
} else {
|
||||
repeat(width - rendered.length) { out.append(' ') }
|
||||
out.append(rendered)
|
||||
}
|
||||
}
|
||||
// Extra arguments are ignored, exactly like java.lang.String.format: call sites already
|
||||
// pass what they pass and a port should not turn a latent extra argument into a crash.
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/** `"%.3f".format(1.2345)` -> `"1.235"` */
|
||||
fun String.format(vararg args: Any?): String = formatString(this, *args)
|
||||
|
||||
private val POWERS_OF_TEN = longArrayOf(1, 10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000)
|
||||
private const val MAX_LONG_DIGITS = 18 // the most decimal digits that still fit in a Long
|
||||
|
||||
private fun longArg(arg: Any?, conversion: Char, pattern: String): Long = when (arg) {
|
||||
is Int -> arg.toLong()
|
||||
is Long -> arg
|
||||
is Short -> arg.toLong()
|
||||
is Byte -> arg.toLong()
|
||||
else -> throw IllegalArgumentException("unsupported %$conversion argument: $arg in pattern: $pattern")
|
||||
}
|
||||
|
||||
private fun doubleArg(arg: Any?, pattern: String): Double = when (arg) {
|
||||
is Double -> arg
|
||||
is Float -> arg.toDouble()
|
||||
is Int -> arg.toDouble()
|
||||
is Long -> arg.toDouble()
|
||||
else -> throw IllegalArgumentException("unsupported %f argument: $arg in pattern: $pattern")
|
||||
}
|
||||
|
||||
private fun formatFixed(value: Double, precision: Int, pattern: String): String {
|
||||
if (precision !in 0..8) throw IllegalArgumentException("precision $precision too large in pattern: $pattern")
|
||||
if (value.isNaN()) return "NaN"
|
||||
if (value.isInfinite()) return if (value > 0.0) "Infinity" else "-Infinity"
|
||||
val negative = value < 0.0 || (value == 0.0 && 1.0 / value < 0.0)
|
||||
val rounded = roundHalfUp(abs(value), precision, value, pattern)
|
||||
val power = POWERS_OF_TEN[precision]
|
||||
val integerPart = rounded / power
|
||||
val fractionPart = rounded % power
|
||||
val result = StringBuilder()
|
||||
if (negative) result.append('-')
|
||||
result.append(integerPart)
|
||||
if (precision > 0) {
|
||||
result.append('.')
|
||||
result.append(fractionPart.toString().padStart(precision, '0'))
|
||||
}
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds to [precision] decimals the way java.lang.String.format does: it rounds the shortest
|
||||
* decimal representation of the double half-up, not its binary value. `"%.3f"` of 0.5005 is
|
||||
* therefore `"0.501"`, even though the double holds 0.50049999999999994493.
|
||||
*
|
||||
* Scaling in binary first - floor(magnitude * 10^precision + 0.5) - loses exactly that and printed
|
||||
* "0.500", so the digits come from the decimal representation and are rounded by integer
|
||||
* arithmetic instead. Returns the value scaled by 10^precision.
|
||||
*/
|
||||
private fun roundHalfUp(magnitude: Double, precision: Int, value: Double, pattern: String): Long {
|
||||
val text = magnitude.toString() // shortest representation that still round-trips
|
||||
val exponentIndex = text.indexOfFirst { it == 'E' || it == 'e' }
|
||||
val mantissa = if (exponentIndex < 0) text else text.substring(0, exponentIndex)
|
||||
val exponent = if (exponentIndex < 0) 0 else text.substring(exponentIndex + 1).toInt()
|
||||
val pointIndex = mantissa.indexOf('.')
|
||||
val integerDigits = if (pointIndex < 0) mantissa else mantissa.substring(0, pointIndex)
|
||||
val fractionDigits = if (pointIndex < 0) "" else mantissa.substring(pointIndex + 1)
|
||||
val digits = integerDigits + fractionDigits
|
||||
// magnitude == digits * 10^scale, so digits * 10^(scale + precision) is the scaled value.
|
||||
val shift = exponent - fractionDigits.length + precision
|
||||
val unscaled = digits.toLong()
|
||||
if (shift >= 0) {
|
||||
if (digits.length + shift > MAX_LONG_DIGITS) throw ValueTooLarge(value, precision, pattern)
|
||||
var scaled = unscaled
|
||||
repeat(shift) { scaled *= 10 }
|
||||
return scaled
|
||||
}
|
||||
// Below half of the last printed digit everything rounds to zero, and 10^divisorDigits would
|
||||
// no longer fit in a Long, so stop before building it.
|
||||
val divisorDigits = -shift
|
||||
if (divisorDigits > MAX_LONG_DIGITS) return 0L
|
||||
var divisor = 1L
|
||||
repeat(divisorDigits) { divisor *= 10 }
|
||||
val quotient = unscaled / divisor
|
||||
val remainder = unscaled % divisor
|
||||
return if (2 * remainder >= divisor) quotient + 1 else quotient
|
||||
}
|
||||
|
||||
// Values this large are never produced by the app; avoid silently wrong digits.
|
||||
private class ValueTooLarge(value: Double, precision: Int, pattern: String) :
|
||||
IllegalArgumentException("value $value too large for %.$precision" + "f in pattern: $pattern")
|
||||
+6
-9
@@ -24,7 +24,6 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import java.io.InputStream
|
||||
import kotlin.math.pow
|
||||
|
||||
class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
@@ -34,22 +33,20 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||
stream.bufferedReader().useLines { lines ->
|
||||
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
|
||||
}
|
||||
suspend fun parseCSV(data: String): List<OrbitalData> = withContext(dispatcher) {
|
||||
data.lineSequence().drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
|
||||
}
|
||||
|
||||
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||
stream.bufferedReader().readLines()
|
||||
suspend fun parseTLE(data: String): List<OrbitalData> = withContext(dispatcher) {
|
||||
data.lineSequence().toList()
|
||||
.chunked(3)
|
||||
.filter { it.size == 3 && it[1].startsWith("1") && it[2].startsWith("2") }
|
||||
.mapNotNull { parseTLE(it) }
|
||||
}
|
||||
|
||||
suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) {
|
||||
suspend fun parseJSON(data: String): List<SatRadio> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
val root = json.parseToJsonElement(stream.bufferedReader().readText())
|
||||
val root = json.parseToJsonElement(data)
|
||||
(root as? JsonArray)?.mapNotNull { element ->
|
||||
runCatching { json.decodeFromJsonElement<SatRadio>(element) }
|
||||
.onFailure { println("JSON parsing exception: $it") }
|
||||
+2
-3
@@ -11,7 +11,6 @@ package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||
@@ -110,10 +109,10 @@ object DopplerFrequencyCalculator {
|
||||
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
||||
if (!isLinearTransponder(transponder)) return false
|
||||
|
||||
val info = transponder.info.lowercase(Locale.ENGLISH)
|
||||
val info = transponder.info.lowercase()
|
||||
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||
.joinToString(separator = " ")
|
||||
.lowercase(Locale.ENGLISH)
|
||||
.lowercase()
|
||||
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
|
||||
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||
info.contains("xponder") || info.contains("xpdr")
|
||||
+4
-7
@@ -17,16 +17,13 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
fun Long.toTimerString(): String {
|
||||
val millis = coerceAtLeast(0L)
|
||||
val format = "%02d:%02d:%02d"
|
||||
val hours = TimeUnit.MILLISECONDS.toHours(millis)
|
||||
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) % 60
|
||||
val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) % 60
|
||||
return String.format(Locale.ENGLISH, format, hours, minutes, seconds)
|
||||
val hours = millis / 3_600_000L
|
||||
val minutes = millis / 60_000L % 60
|
||||
val seconds = millis / 1_000L % 60
|
||||
return formatString(format, hours, minutes, seconds)
|
||||
}
|
||||
|
||||
fun Float.round(decimals: Int): Float {
|
||||
File renamed without changes.
File renamed without changes.
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
/**
|
||||
* Runs [block] while holding [lock]'s monitor, the way kotlin.jvm.Synchronized used to hold it
|
||||
* before core:domain became a multiplatform module.
|
||||
*
|
||||
* The annotation survives in common code as an optional expectation, but the stdlib deprecated it
|
||||
* there in Kotlin 1.8 and made it an error in 2.1: "Synchronizing methods on a class instance is
|
||||
* not supported on platforms other than JVM." The monitor therefore moves behind a platform
|
||||
* actual, which keeps the JVM semantics exactly and lets the iOS side say what it does instead.
|
||||
*/
|
||||
internal expect fun <T> synchronizedOn(lock: Any, block: () -> T): T
|
||||
File renamed without changes.
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
/**
|
||||
* Whether a typed callsign can be logged, and what to warn about if it looks odd.
|
||||
*
|
||||
* Deliberately permissive. A survey of real callsigns against a typical strict pattern rejected
|
||||
* 16 of 28 valid ones - `W1AW/4`, `2E0ABC`, `9A1CCY`, `SV2ASP/A` among them - while a pattern
|
||||
* loose enough to accept those also accepts a Maidenhead locator as a callsign. There is no
|
||||
* regex that catches typos without discarding legitimate calls, so anything plausible is
|
||||
* accepted and doubt is reported rather than enforced.
|
||||
*
|
||||
* The previous behaviour was `if (call.length < 3) return`, which discarded the entry with no
|
||||
* message: the operator pressed done during a pass and nothing happened, with no way to tell
|
||||
* that the app had decided against them. None of the logging software surveyed - N1MM+, DXLog,
|
||||
* PoLo, HAMRS - silently drops a submission.
|
||||
*/
|
||||
object CallsignEntry {
|
||||
|
||||
/** Shortest real callsign. Two characters occur in special event calls. */
|
||||
private const val MIN_LENGTH = 2
|
||||
|
||||
/** Longest plausible entry, allowing a portable suffix such as `OH/W1AW/MM`. */
|
||||
private const val MAX_LENGTH = 16
|
||||
|
||||
/** Characters a callsign may contain. */
|
||||
private val allowed = Regex("^[A-Z0-9/-]+$")
|
||||
|
||||
/** The outcome of checking an entry. */
|
||||
sealed interface Verdict {
|
||||
|
||||
/** Log it. [warning] is non-null when the entry is unusual but still plausible. */
|
||||
data class Acceptable(val callsign: String, val warning: Warning? = null) : Verdict
|
||||
|
||||
/** Do not log it, and say why. */
|
||||
data class Rejected(val reason: Reason) : Verdict
|
||||
}
|
||||
|
||||
/** Why an entry cannot be logged at all. */
|
||||
enum class Reason {
|
||||
/** Nothing was typed. */
|
||||
EMPTY,
|
||||
|
||||
/** Too short to be any callsign. */
|
||||
TOO_SHORT,
|
||||
|
||||
/** Longer than any real callsign with a portable suffix. */
|
||||
TOO_LONG,
|
||||
|
||||
/** Contains something a callsign cannot: punctuation, spaces, non-ASCII. */
|
||||
ILLEGAL_CHARACTERS,
|
||||
|
||||
/** Digits only, or letters only - no callsign is either. */
|
||||
NOT_A_CALLSIGN
|
||||
}
|
||||
|
||||
/** Something worth mentioning without blocking the entry. */
|
||||
enum class Warning {
|
||||
/** Looks like a Maidenhead locator rather than a callsign, e.g. `GG77DH`. */
|
||||
LOOKS_LIKE_A_GRID,
|
||||
|
||||
/** Already logged in this session - fine on a later pass, likely a slip on this one. */
|
||||
ALREADY_WORKED
|
||||
}
|
||||
|
||||
/**
|
||||
* Check an entry, optionally against calls already logged in this pass.
|
||||
*
|
||||
* A repeat is a warning rather than a rejection: the same station on a later pass is a
|
||||
* legitimate new contact, and contest loggers default to allowing duplicates - DXLog
|
||||
* describes refusing them as an outdated habit.
|
||||
*/
|
||||
fun check(entry: String, workedThisSession: Set<String> = emptySet()): Verdict {
|
||||
val call = entry.trim().uppercase()
|
||||
if (call.isEmpty()) return Verdict.Rejected(Reason.EMPTY)
|
||||
if (call.length < MIN_LENGTH) return Verdict.Rejected(Reason.TOO_SHORT)
|
||||
if (call.length > MAX_LENGTH) return Verdict.Rejected(Reason.TOO_LONG)
|
||||
if (!allowed.matches(call)) return Verdict.Rejected(Reason.ILLEGAL_CHARACTERS)
|
||||
// Any segment may be the callsign, not just the first. A portable call can be written
|
||||
// prefix-first - DL/W1AW, ZL/JA1ABC, OH/W1AW/MM - where the leading token is a country
|
||||
// prefix with no digit in it. Testing only the first segment rejected all of those, which
|
||||
// the old length-only check had accepted.
|
||||
if (call.split('/', '-').none(::looksLikeCallsign)) {
|
||||
return Verdict.Rejected(Reason.NOT_A_CALLSIGN)
|
||||
}
|
||||
val warning = when {
|
||||
call in workedThisSession -> Warning.ALREADY_WORKED
|
||||
looksLikeGrid(call) -> Warning.LOOKS_LIKE_A_GRID
|
||||
else -> null
|
||||
}
|
||||
return Verdict.Acceptable(call, warning)
|
||||
}
|
||||
|
||||
/** A segment that could be a callsign: contains both a letter and a digit. */
|
||||
private fun looksLikeCallsign(segment: String): Boolean =
|
||||
segment.any { it.isDigit() } && segment.any { it.isLetter() }
|
||||
|
||||
/**
|
||||
* Whether this looks like a Maidenhead locator typed into the wrong field.
|
||||
*
|
||||
* Six characters of letter-letter-digit-digit-letter-letter. Worth mentioning because grid
|
||||
* and callsign are exchanged together on FM satellites and the fields sit side by side.
|
||||
*/
|
||||
private fun looksLikeGrid(call: String): Boolean =
|
||||
call.length == 6 &&
|
||||
call[0].isLetter() && call[1].isLetter() &&
|
||||
call[2].isDigit() && call[3].isDigit() &&
|
||||
call[4].isLetter() && call[5].isLetter()
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
/**
|
||||
* Checks a typed counterpart grid before it reaches the log.
|
||||
*
|
||||
* Separate from qthToPosition's validator, which requires six characters and is private. Plenty of
|
||||
* satellite operators exchange only the four-character square, so requiring six would reject
|
||||
* perfectly good entries - and this follows the same rule the callsign field settled on: refuse only
|
||||
* what is certainly wrong, warn about the rest, never silently discard.
|
||||
*
|
||||
* The reason to check at all is that an unchecked value goes into the ADIF GRIDSQUARE field, and a
|
||||
* malformed one is stored by Wavelog as-is. It then pollutes grid statistics and VUCC award
|
||||
* tracking, where a wrong square is worse than a missing one.
|
||||
*/
|
||||
object GridEntry {
|
||||
|
||||
/** What a typed grid amounts to. */
|
||||
sealed interface Verdict {
|
||||
|
||||
/** Usable. [normalised] is what should be logged - upper case for the pair, lower for the subsquare. */
|
||||
data class Acceptable(val normalised: String, val warning: Warning? = null) : Verdict
|
||||
|
||||
/** Certainly not a grid. [reason] says which rule it broke. */
|
||||
data class Unusable(val reason: Reason) : Verdict
|
||||
|
||||
/** Nothing typed. The QRZ lookup should run instead. */
|
||||
data object Empty : Verdict
|
||||
}
|
||||
|
||||
/** Worth mentioning but not worth refusing. */
|
||||
enum class Warning {
|
||||
/**
|
||||
* Two characters. Legal per ADIF, but a field is 20 by 10 degrees - close to useless for a
|
||||
* satellite contact, so it is worth saying rather than refusing.
|
||||
*/
|
||||
FIELD_ONLY,
|
||||
|
||||
/** Four characters, so the location is only accurate to about 100km. */
|
||||
SQUARE_ONLY
|
||||
}
|
||||
|
||||
/** Why an entry cannot be a grid. */
|
||||
enum class Reason {
|
||||
/** Not 4, 6 or 8 characters. Maidenhead has no other lengths. */
|
||||
WRONG_LENGTH,
|
||||
|
||||
/** First pair outside A-R. S-X would decode past the poles. */
|
||||
FIELD_OUT_OF_RANGE,
|
||||
|
||||
/** Second pair is not two digits. */
|
||||
SQUARE_NOT_DIGITS,
|
||||
|
||||
/** Third pair outside A-X. */
|
||||
SUBSQUARE_OUT_OF_RANGE
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge a typed entry.
|
||||
*
|
||||
* Case is normalised on the way out rather than demanded on the way in - an operator typing
|
||||
* one-handed outdoors should not have to care, and the conventional rendering is upper case for
|
||||
* the field, digits, then lower case for the subsquare.
|
||||
*/
|
||||
fun check(entry: String): Verdict {
|
||||
val text = entry.trim()
|
||||
if (text.isEmpty()) return Verdict.Empty
|
||||
if (text.length !in VALID_LENGTHS) return Verdict.Unusable(Reason.WRONG_LENGTH)
|
||||
|
||||
val upper = text.uppercase()
|
||||
if (upper[0] !in FIELD_RANGE || upper[1] !in FIELD_RANGE) {
|
||||
return Verdict.Unusable(Reason.FIELD_OUT_OF_RANGE)
|
||||
}
|
||||
// ASCII digits only. Char.isDigit() is Unicode-aware and covers the whole Nd category, so
|
||||
// it accepted Arabic-Indic, Devanagari and fullwidth digits - which a localised keypad can
|
||||
// produce without the operator seeing any difference. ADIF 3.1.7 defines Digit as "an ASCII
|
||||
// character whose code lies in the range of 48 through 57", and Wavelog stores GRIDSQUARE
|
||||
// verbatim, so such a value would never match a real grid in any statistics query.
|
||||
// Guarded on length: a 2-character locator has no square pair, and reading index 2 of it
|
||||
// would throw.
|
||||
if (text.length >= SQUARE_LENGTH && (!upper[2].isAsciiDigit() || !upper[3].isAsciiDigit())) {
|
||||
return Verdict.Unusable(Reason.SQUARE_NOT_DIGITS)
|
||||
}
|
||||
if (text.length >= SUBSQUARE_LENGTH) {
|
||||
if (upper[4] !in SUBSQUARE_RANGE || upper[5] !in SUBSQUARE_RANGE) {
|
||||
return Verdict.Unusable(Reason.SUBSQUARE_OUT_OF_RANGE)
|
||||
}
|
||||
}
|
||||
if (text.length == EXTENDED_LENGTH && (!upper[6].isAsciiDigit() || !upper[7].isAsciiDigit())) {
|
||||
return Verdict.Unusable(Reason.SQUARE_NOT_DIGITS)
|
||||
}
|
||||
|
||||
return Verdict.Acceptable(
|
||||
normalised = normalise(upper),
|
||||
warning = when (text.length) {
|
||||
FIELD_LENGTH -> Warning.FIELD_ONLY
|
||||
SQUARE_LENGTH -> Warning.SQUARE_ONLY
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** `OL72ap` - upper case field, digits, lower case subsquare, as the convention renders it. */
|
||||
private fun normalise(upper: String): String = buildString {
|
||||
append(upper.take(minOf(upper.length, SQUARE_LENGTH)))
|
||||
if (upper.length >= SUBSQUARE_LENGTH) append(upper.substring(4, 6).lowercase())
|
||||
if (upper.length == EXTENDED_LENGTH) append(upper.substring(6, 8))
|
||||
}
|
||||
|
||||
/** ADIF 3.1.7 defines Digit as ASCII 48-57. Kotlin's isDigit() is far wider. */
|
||||
private fun Char.isAsciiDigit(): Boolean = this in '0'..'9'
|
||||
|
||||
private const val FIELD_LENGTH = 2
|
||||
private const val SQUARE_LENGTH = 4
|
||||
private const val SUBSQUARE_LENGTH = 6
|
||||
private const val EXTENDED_LENGTH = 8
|
||||
|
||||
/**
|
||||
* ADIF 3.1.7: GRIDSQUARE takes "2-character, 4-character, 6-character, or 8-character" locators.
|
||||
* A 10 or 12 character locator stores its first 8 here and the rest in GRIDSQUARE_EXT, which
|
||||
* neither WavelogQso nor Wavelog's own field list carries - so the extra pair has nowhere to go
|
||||
* and the UI clips at 8, which produces the spec-correct GRIDSQUARE value.
|
||||
*/
|
||||
private val VALID_LENGTHS = setOf(FIELD_LENGTH, SQUARE_LENGTH, SUBSQUARE_LENGTH, EXTENDED_LENGTH)
|
||||
private val FIELD_RANGE = 'A'..'R'
|
||||
private val SUBSQUARE_RANGE = 'A'..'X'
|
||||
}
|
||||
File renamed without changes.
+90
@@ -0,0 +1,90 @@
|
||||
/* LotwSatelliteIds.kt - NORAD catalogue number to LoTW satellite name.
|
||||
*
|
||||
* Why the catalogue number and not the name: the same satellite carries different names in
|
||||
* different TLE sources, so a name-keyed table misses whenever the user switches source.
|
||||
* Measured across Celestrak amateur and AMSAT nasabare, 33 of the 49 satellites present in
|
||||
* both are named differently - NORAD 43017 is "RADFXSAT (FOX-1B)" in one and "AO-91" in the
|
||||
* other, 43700 is "ES'HAIL 2" against "QO-100". The catalogue number is identical in every
|
||||
* source, so it is the only stable key.
|
||||
*
|
||||
* LoTW rejects a QSO whose SAT_NAME is not spelled exactly as in its accepted list
|
||||
* (https://lotw.arrl.org/lotw-help/satellite-qsos: "if you enter the satellite name as AO7
|
||||
* instead of AO-7 the data will be rejected"), which is why this maps to the exact spelling
|
||||
* held in LotwSatellites rather than to whatever the TLE happens to say.
|
||||
*
|
||||
* Every number here was read out of live TLE data, never typed from memory. Entries cover the
|
||||
* satellites that both appear in the app's own sources (Sources.satelliteDataUrls) and are in
|
||||
* the LoTW list; the rest of that list is satellites no source still carries, so no user can
|
||||
* track them and no mapping is needed for them.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
object LotwSatelliteIds {
|
||||
|
||||
/**
|
||||
* NORAD catalogue number to the LoTW spelling. The trailing comment is one name the
|
||||
* satellite goes by in the sources, kept so a reader can recognise the entry.
|
||||
*
|
||||
* Three numbers had to be decided rather than derived, because one name matched several
|
||||
* catalogued objects. Each was settled by which object the amateur-specific sources carry:
|
||||
* - ARISS is 25544, the station itself. Celestrak's full catalogue also lists ISS (UNITY),
|
||||
* (ZVEZDA), (DESTINY) and (NAUKA), which are modules rather than stations you work.
|
||||
* - IO-117 is 53109: four sources name that number GREENCUBE (IO-117) and only R4UAB calls
|
||||
* it ROBUSTA 1F, which is a different satellite.
|
||||
* - TO-108 is 44881, present in all three amateur sources; 44879 is TIANQIN 1 and appears
|
||||
* only in the general catalogue.
|
||||
*/
|
||||
private val idToName: Map<Int, String> = mapOf(
|
||||
7530 to "AO-7", // AO-07
|
||||
14129 to "AO-10", // PHASE 3B (AO-10)
|
||||
20439 to "AO-16", // OSCAR 16 (PACSAT)
|
||||
20442 to "LO-19", // LO-19
|
||||
22825 to "AO-27", // AO-27
|
||||
23439 to "RS-15", // RADIO ROSTO (RS-15)
|
||||
24278 to "FO-29", // FO-29
|
||||
25544 to "ARISS", // ISS (ZARYA)
|
||||
26609 to "AO-40", // PHASE 3D (AO-40)
|
||||
26931 to "NO-44", // NO-44
|
||||
27607 to "SO-50", // SAUDISAT 1C (SO-50)
|
||||
28650 to "VO-52", // HAMSAT (VO-52)
|
||||
39444 to "AO-73", // AO-73
|
||||
40025 to "EO-79", // FUNCUBE-3 (EO-79)/QB50P1
|
||||
40074 to "UKUBE1", // UKUBE-1
|
||||
40908 to "CAS-3H", // LILACSAT-2
|
||||
40931 to "IO-86", // IO-86
|
||||
40967 to "AO-85", // FOX-1A (AO-85)
|
||||
41847 to "CAS-2T", // CAS-2T
|
||||
43017 to "AO-91", // AO-91
|
||||
43678 to "PO-101", // DIWATA-2B
|
||||
43700 to "QO-100", // ES'HAIL 2
|
||||
43803 to "JO-97", // JO-97
|
||||
44530 to "TAURUS", // TAURUS-1
|
||||
44881 to "TO-108", // CAS-6 (TO-108)
|
||||
44909 to "RS-44", // DOSAAF-85 (RS-44)
|
||||
50466 to "HO-113", // CAMSAT XW-3 (CAS-9)
|
||||
53109 to "IO-117", // GREENCUBE (IO-117)
|
||||
61781 to "AO-123", // AO-123
|
||||
// The TEVEL-2 constellation. Every source writes these TEVEL2-N while LoTW has TEV2-N,
|
||||
// and no amount of separator-stripping bridges that - TEVEL21 is not TEV21 - so without
|
||||
// these nine rows their QSOs upload under a name LoTW refuses. Note the numbering is not
|
||||
// sequential: 63217 is TEVEL2-1 and 63213 is TEVEL2-4.
|
||||
63213 to "TEV2-4",
|
||||
63214 to "TEV2-5",
|
||||
63215 to "TEV2-6",
|
||||
63217 to "TEV2-1",
|
||||
63218 to "TEV2-3",
|
||||
63219 to "TEV2-2",
|
||||
63237 to "TEV2-9",
|
||||
63238 to "TEV2-7",
|
||||
63239 to "TEV2-8"
|
||||
)
|
||||
|
||||
/** The LoTW spelling for [catnum], or null when this satellite is not in the LoTW list. */
|
||||
fun nameFor(catnum: Int): String? = idToName[catnum]
|
||||
|
||||
/** True when [catnum] names a satellite LoTW accepts, so a QSO on it can be confirmed. */
|
||||
fun isKnown(catnum: Int): Boolean = catnum in idToName
|
||||
|
||||
/** Entry count, so a test can catch the table being emptied by a bad edit. */
|
||||
val size: Int get() = idToName.size
|
||||
}
|
||||
+4
@@ -5,6 +5,10 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
// kotlin.jvm.Volatile 是 common 里 2.1 起的编译错误;kotlin.concurrent.Volatile 才是多平台的那个,
|
||||
// 且在 JVM 与 Kotlin/Native 上都生效。
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
object LotwSatellites {
|
||||
private val staticNames: Set<String> = setOf("AISAT1", "AO-10", "AO-109", "AO-123", "AO-13", "AO-16", "AO-21", "AO-27", "AO-3", "AO-4", "AO-40", "AO-51", "AO-6", "AO-7", "AO-73", "AO-8", "AO-85", "AO-91", "AO-92", "ARISS", "Arsene", "BO-102", "BY70-1", "CAS-2T", "CAS-3H", "CAS-4A", "CAS-4B", "DO-64", "EO-79", "EO-88", "FO-118", "FO-12", "FO-20", "FO-29", "FO-99", "FS-3", "HO-107", "HO-113", "HO-119", "HO-68", "INSPR7", "IO-117", "IO-86", "JO-97", "KEDR", "LEDSAT", "LO-19", "LO-78", "LO-87", "LO-90", "MAYA-3", "MAYA-4", "MIREX", "MO-112", "MO-122", "NO-103", "NO-104", "NO-44", "NO-83", "NO-84", "PO-101", "QO-100", "RS-1", "RS-10", "RS-11", "RS-12", "RS-13", "RS-15", "RS-2", "RS-44", "RS-5", "RS-6", "RS-7", "RS-8", "SAREX", "SO-121", "SO-124", "SO-125", "SO-35", "SO-41", "SO-50", "SO-67", "SONATE", "TAURUS", "TEVEL1", "TEVEL2", "TEVEL3", "TEVEL4", "TEVEL5", "TEVEL6", "TEVEL7", "TEVEL8", "TO-108", "UKUBE1", "UO-14", "UVSQ", "VO-52", "XW-2A", "XW-2B", "XW-2C", "XW-2D", "XW-2E", "XW-2F", "TEV2-1", "TEV2-2", "TEV2-3", "TEV2-4", "TEV2-5", "TEV2-6", "TEV2-7", "TEV2-8", "TEV2-9")
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
/**
|
||||
* Works out what time a contact should carry.
|
||||
*
|
||||
* The logging screen used to stamp System.currentTimeMillis() and offer no way to change it. That
|
||||
* assumes contacts are typed as they happen, and serious satellite operators do not work that way:
|
||||
* the documented practice is to record the pass and transcribe it afterwards, because during eight
|
||||
* minutes of a linear transponder there is no spare attention for a keyboard. A fixed clock makes
|
||||
* every transcribed contact wrong by however long the transcription took.
|
||||
*
|
||||
* Two ways to say when: an absolute UTC time of day, or an offset from now. Both are typed into the
|
||||
* same field, because a separate widget for each is more to reach for than an operator wants while
|
||||
* holding an antenna.
|
||||
*/
|
||||
object PassClock {
|
||||
|
||||
/** What a typed token meant. */
|
||||
sealed interface Command {
|
||||
|
||||
/** Log at this UTC time of day. [minuteOfDay] is minutes since 00:00 UTC. */
|
||||
data class At(val minuteOfDay: Int) : Command
|
||||
|
||||
/** Log this many minutes from now. Negative counts backwards. */
|
||||
data class Shift(val minutes: Int) : Command
|
||||
|
||||
/** Back to the current time. */
|
||||
data object Live : Command
|
||||
|
||||
/** Not a time instruction. The caller should leave the clock alone. */
|
||||
data object Unrecognised : Command
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret a typed token.
|
||||
*
|
||||
* Deliberately narrow. Anything that is not clearly a time is [Command.Unrecognised] rather than
|
||||
* a guess, because a mis-parsed time silently backdates a contact and nothing downstream would
|
||||
* catch it.
|
||||
*
|
||||
* Accepted: `14:55` or `1455` for a UTC time of day; `+3` or `-2` for a shift in minutes, with
|
||||
* an optional `m`; empty or `now` to return to live time.
|
||||
*/
|
||||
fun parse(entry: String): Command {
|
||||
val text = entry.trim().lowercase()
|
||||
if (text.isEmpty() || text == "now") return Command.Live
|
||||
if (text.startsWith("+") || text.startsWith("-")) return parseShift(text)
|
||||
return parseTimeOfDay(text)
|
||||
}
|
||||
|
||||
/** `+3`, `-2m`, `+15`. */
|
||||
private fun parseShift(text: String): Command {
|
||||
val negative = text.startsWith("-")
|
||||
val digits = text.drop(1).removeSuffix("m")
|
||||
val minutes = digits.toIntOrNull() ?: return Command.Unrecognised
|
||||
if (minutes > MAX_SHIFT_MINUTES) return Command.Unrecognised
|
||||
return Command.Shift(if (negative) -minutes else minutes)
|
||||
}
|
||||
|
||||
/** `14:55` or `1455`. */
|
||||
private fun parseTimeOfDay(text: String): Command {
|
||||
val digits = text.replace(":", "")
|
||||
if (digits.length != TIME_DIGITS || digits.any { !it.isDigit() }) return Command.Unrecognised
|
||||
val hours = digits.take(2).toInt()
|
||||
val minutes = digits.drop(2).toInt()
|
||||
if (hours > MAX_HOUR || minutes > MAX_MINUTE) return Command.Unrecognised
|
||||
return Command.At(hours * MINUTES_PER_HOUR + minutes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a command, returning the timestamp a contact should carry.
|
||||
*
|
||||
* [now] is the current UTC time in milliseconds and [dayStart] is midnight UTC of the day [now]
|
||||
* falls in - passed in rather than computed, because core:domain holds no calendar and the
|
||||
* caller already knows which day it is working with.
|
||||
*
|
||||
* An absolute time later than [now] is read as belonging to the previous day: transcription
|
||||
* happens after the pass, so a pass that ran across midnight UTC is the common case, not an
|
||||
* error. Without this a contact logged at 23:58 while transcribing at 00:05 would land a full
|
||||
* day in the future.
|
||||
*/
|
||||
fun resolve(command: Command, now: Long, dayStart: Long): Long = when (command) {
|
||||
is Command.At -> {
|
||||
val candidate = dayStart + command.minuteOfDay * MILLIS_PER_MINUTE
|
||||
if (candidate > now) candidate - MILLIS_PER_DAY else candidate
|
||||
}
|
||||
is Command.Shift -> now + command.minutes * MILLIS_PER_MINUTE
|
||||
Command.Live, Command.Unrecognised -> now
|
||||
}
|
||||
|
||||
/** Whether a command moves the clock off live time, so the UI can show that it has. */
|
||||
fun isHolding(command: Command): Boolean =
|
||||
command is Command.At || (command is Command.Shift && command.minutes != 0)
|
||||
|
||||
private const val TIME_DIGITS = 4
|
||||
private const val MAX_HOUR = 23
|
||||
private const val MAX_MINUTE = 59
|
||||
private const val MINUTES_PER_HOUR = 60
|
||||
|
||||
/** A pass lasts minutes. Anything larger is a typo, not an intention. */
|
||||
private const val MAX_SHIFT_MINUTES = 720
|
||||
|
||||
private const val MILLIS_PER_MINUTE = 60_000L
|
||||
private const val MILLIS_PER_DAY = 86_400_000L
|
||||
}
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
* WaveLogApi.kt - WaveLog log server API client (4.5.2 override fix 2).
|
||||
*
|
||||
* Supports both v1 and v2 (user's server only has v1 in practice; v2 returns 404):
|
||||
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON fields)
|
||||
* v1: POST {base}/index.php/api/qso (key in JSON body + ADIF string)
|
||||
* Strategy: try v2 first, auto-fallback to v1 on 404.
|
||||
* Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif.
|
||||
* Station grid: only v2 has GET api/v2/station/{id}; v1 lacks it -> fall back to user QTH.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.source.HttpResult
|
||||
import com.rtbishop.look4sat.core.domain.source.IHttpClient
|
||||
import com.rtbishop.look4sat.core.domain.utility.formatString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/** Station info (GET /api/v2/station/{id} result) */
|
||||
data class WavelogStation(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val callsign: String,
|
||||
val gridsquare: String
|
||||
)
|
||||
|
||||
sealed class WavelogResult {
|
||||
data class Success(val message: String) : WavelogResult()
|
||||
data class Failure(val message: String) : WavelogResult()
|
||||
}
|
||||
|
||||
object WaveLogApi {
|
||||
|
||||
/** Milliseconds in a day; date fields are derived from the QSO timestamp without Calendar. */
|
||||
private const val MS_PER_DAY = 86_400_000L
|
||||
|
||||
/**
|
||||
* Platform HTTP client for every request below. Handed over by the DI container because an
|
||||
* object has no constructor for it, and a shared object has no platform socket API to use
|
||||
* on its own.
|
||||
*/
|
||||
private var httpClient: IHttpClient? = null
|
||||
|
||||
fun installHttpClient(client: IHttpClient) {
|
||||
httpClient = client
|
||||
}
|
||||
|
||||
/** Normalize server URL: strip trailing slash/index.php; prepend https:// when missing */
|
||||
fun normalizeUrl(raw: String): String {
|
||||
var u = raw.trim().trimEnd('/')
|
||||
if (u.isBlank()) return ""
|
||||
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
|
||||
if (u.endsWith("/index.php")) u = u.removeSuffix("/index.php")
|
||||
return u
|
||||
}
|
||||
|
||||
/** Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif */
|
||||
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult {
|
||||
val base = normalizeUrl(url)
|
||||
if (base.isBlank()) return WavelogResult.Failure("服务器地址为空")
|
||||
|
||||
// v2: GET /index.php/api/v2/token
|
||||
val v2 = httpRequest("$base/index.php/api/v2/token", apiKey, null)
|
||||
if (v2.code in 200..299) return WavelogResult.Success("连接成功 (API v2)")
|
||||
|
||||
// v1: POST /index.php/api/get_contacts_adif (key in body)
|
||||
if (stationId.isNotBlank()) {
|
||||
val body = buildJsonObject {
|
||||
put("key", apiKey)
|
||||
put("station_id", stationId)
|
||||
put("fetchfromid", 0)
|
||||
}.toString()
|
||||
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", apiKey, body)
|
||||
if (v1.code in 200..299) return WavelogResult.Success("连接成功 (API v1)")
|
||||
if (v1.code == 401) return WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||
}
|
||||
// v1 attempt without index.php
|
||||
val body = buildJsonObject {
|
||||
put("key", apiKey)
|
||||
put("station_id", stationId)
|
||||
put("fetchfromid", 0)
|
||||
}.toString()
|
||||
val v1b = httpRequest("$base/api/get_contacts_adif", apiKey, body)
|
||||
if (v1b.code in 200..299) return WavelogResult.Success("连接成功 (API v1)")
|
||||
if (v1b.code == 401) return WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||
|
||||
return WavelogResult.Failure("连接失败: v2 HTTP ${v2.code}, v1 HTTP ${v1b.code} — 请确认服务器地址/密钥正确")
|
||||
}
|
||||
|
||||
/** Station info: v2 only; v1 lacks the endpoint (grid check falls back to user QTH) */
|
||||
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult {
|
||||
val base = normalizeUrl(url)
|
||||
if (base.isBlank()) return WavelogResult.Failure("服务器地址为空")
|
||||
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", apiKey, null)
|
||||
if (code in 200..299) {
|
||||
return try {
|
||||
val obj = Json.parseToJsonElement(resp).jsonObject
|
||||
val data = obj["data"] as? JsonObject ?: obj
|
||||
val station = WavelogStation(
|
||||
id = data["id"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
name = data["name"]?.jsonPrimitive?.contentOrNull.orEmpty(),
|
||||
callsign = data["callsign"]?.jsonPrimitive?.contentOrNull.orEmpty(),
|
||||
gridsquare = data["gridsquare"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
)
|
||||
WavelogResult.Success(buildJsonObject {
|
||||
put("id", station.id); put("name", station.name)
|
||||
put("callsign", station.callsign); put("gridsquare", station.gridsquare)
|
||||
}.toString())
|
||||
} catch (e: Exception) {
|
||||
WavelogResult.Failure("解析失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
// v1 has no station endpoint -> return empty Success (caller falls back to user QTH)
|
||||
return WavelogResult.Success("")
|
||||
}
|
||||
|
||||
/**
|
||||
* ADIF band code from a frequency in Hz. "SAT" is NOT a legal ADIF band
|
||||
* value (the Band enumeration is 160M/80M/.../2M/70CM/23CM...); a logger
|
||||
* that fails to parse an illegal band falls back to a default such as
|
||||
* 160m. Satellite QSOs must carry the real band of the TX frequency.
|
||||
*/
|
||||
fun bandFromHz(freqHz: Long): String = when {
|
||||
freqHz >= 1240_000_000 -> "23CM"
|
||||
freqHz >= 902_000_000 -> "33CM"
|
||||
freqHz >= 420_000_000 -> "70CM"
|
||||
freqHz >= 222_000_000 -> "1.25M"
|
||||
freqHz >= 144_000_000 -> "2M"
|
||||
freqHz >= 50_000_000 -> "6M"
|
||||
freqHz >= 28_000_000 -> "10M"
|
||||
freqHz >= 24_890_000 -> "12M"
|
||||
freqHz >= 21_000_000 -> "15M"
|
||||
freqHz >= 18_068_000 -> "17M"
|
||||
freqHz >= 14_000_000 -> "20M"
|
||||
freqHz >= 10_000_000 -> "30M"
|
||||
freqHz >= 7_000_000 -> "40M"
|
||||
freqHz >= 5_102_000 -> "60M"
|
||||
freqHz >= 3_500_000 -> "80M"
|
||||
freqHz >= 1_800_000 -> "160M"
|
||||
else -> "160M"
|
||||
}
|
||||
|
||||
/** Band class letter for satellite mode derivation: VHF=V, UHF=U, SHF=S. */
|
||||
private fun bandLetter(freqHz: Long): String = when {
|
||||
freqHz >= 1_240_000_000 -> "S"
|
||||
freqHz >= 420_000_000 -> "U"
|
||||
freqHz >= 144_000_000 -> "V"
|
||||
else -> "V"
|
||||
}
|
||||
|
||||
/**
|
||||
* ADIF SAT_MODE (free text, satellite convention): "V/U" = VHF up /
|
||||
* UHF down, "U/V", "V/S", "U/S"... Derived from the actual TX/RX bands.
|
||||
*/
|
||||
fun satModeFrom(txFreqHz: Long, rxFreqHz: Long): String {
|
||||
if (rxFreqHz <= 0) return ""
|
||||
val up = bandLetter(txFreqHz)
|
||||
val down = bandLetter(rxFreqHz)
|
||||
return if (up == down) "" else "$up/$down"
|
||||
}
|
||||
|
||||
/**
|
||||
* The name LoTW accepts for this satellite, resolved from its catalogue number when known.
|
||||
*
|
||||
* LoTW rejects a QSO whose SAT_NAME is not spelled as its accepted list has it - its help
|
||||
* page gives AO7 against AO-7 as an example - so this has to produce the exact spelling or
|
||||
* nothing useful at all.
|
||||
*
|
||||
* [catnum] is preferred because the name alone cannot decide it: TLE sources disagree, and
|
||||
* of the 49 satellites carried by both Celestrak amateur and AMSAT nasabare, 33 are named
|
||||
* differently. NORAD 43017 is "RADFXSAT (FOX-1B)" in one and "AO-91" in the other, 43700 is
|
||||
* "ES'HAIL 2" against "QO-100". Deriving the name from the TLE text resolved 0 of 96
|
||||
* satellites to something LoTW accepts, because the descriptive part of a TLE name is never
|
||||
* the OSCAR designator.
|
||||
*
|
||||
* The name path remains as a fallback for QSOs logged before the catalogue number was
|
||||
* recorded. It tries the whole name, then either side of the parentheses, since which side
|
||||
* carries the designator varies - "SAUDISAT 1C (SO-50)" has it inside, "ISS (ZARYA)" does not.
|
||||
*/
|
||||
fun normalizeSatName(raw: String, catnum: Int? = null): String {
|
||||
catnum?.let { LotwSatelliteIds.nameFor(it) }?.let { return it }
|
||||
val trimmed = raw.trim()
|
||||
for (candidate in nameCandidates(trimmed)) {
|
||||
LotwSatellites.names.firstOrNull { it.equals(candidate, ignoreCase = true) }
|
||||
?.let { return it }
|
||||
}
|
||||
// Tolerate a missing or extra hyphen: sources write RS15 where LoTW has RS-15.
|
||||
for (candidate in nameCandidates(trimmed)) {
|
||||
val squashed = candidate.squashSeparators()
|
||||
LotwSatellites.names.firstOrNull { it.squashSeparators() == squashed }
|
||||
?.let { return it }
|
||||
}
|
||||
return trimmed.uppercase()
|
||||
}
|
||||
|
||||
/** True when [normalizeSatName] produced a name LoTW will accept rather than a guess. */
|
||||
fun isLotwSatellite(name: String, catnum: Int? = null): Boolean {
|
||||
val resolved = normalizeSatName(name, catnum)
|
||||
return LotwSatellites.names.any { it.equals(resolved, ignoreCase = true) }
|
||||
}
|
||||
|
||||
/** The whole name plus either side of the parentheses, longest first. */
|
||||
private fun nameCandidates(raw: String): List<String> {
|
||||
if (raw.isEmpty()) return emptyList()
|
||||
val parts = mutableListOf(raw)
|
||||
val open = raw.indexOf('(')
|
||||
val close = raw.lastIndexOf(')')
|
||||
if (open in 0..<close) {
|
||||
parts += raw.substring(open + 1, close).trim()
|
||||
parts += raw.substring(0, open).trim()
|
||||
}
|
||||
// Formation launches are catalogued as "RS-44 & BREEZE-KM R/B".
|
||||
if ('&' in raw) parts += raw.substringBefore('&').trim()
|
||||
return parts.filter { it.isNotEmpty() }.distinct()
|
||||
}
|
||||
|
||||
private fun String.squashSeparators() = replace(Regex("[-\\s._/]"), "").uppercase()
|
||||
|
||||
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
|
||||
suspend fun postQso(
|
||||
url: String,
|
||||
apiKey: String,
|
||||
stationProfileId: String,
|
||||
qso: WavelogQso,
|
||||
gridsquare: String
|
||||
): WavelogResult {
|
||||
val base = normalizeUrl(url)
|
||||
if (base.isBlank()) return WavelogResult.Failure("服务器地址为空")
|
||||
|
||||
val satName = normalizeSatName(qso.satName, qso.catnum.takeIf { it > 0 })
|
||||
|
||||
// v2: POST /index.php/api/v2/qso (JSON fields)
|
||||
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
|
||||
val v2Body = buildJsonObject {
|
||||
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
|
||||
put("call", qso.call)
|
||||
put("band", bandFromHz(qso.freqTxHz))
|
||||
put("mode", qso.mode)
|
||||
put("qso_date", utcDate(qso.timeUtcMs))
|
||||
put("time_on", utcTime(qso.timeUtcMs))
|
||||
put("freq", formatString("%.6fM", qso.freqTxHz / 1_000_000.0))
|
||||
put("freq_rx", formatString("%.6fM", qso.freqRxHz / 1_000_000.0))
|
||||
put("gridsquare", gridsquare)
|
||||
put("rst_sent", "59")
|
||||
put("rst_rcvd", "59")
|
||||
put("sat_name", satName)
|
||||
if (satMode.isNotBlank()) put("sat_mode", satMode)
|
||||
}
|
||||
// The body decides, not the status code: Wavelog validates after responding, so a rejected
|
||||
// QSO arrives as HTTP 200 with {"status":"failed"}. Trusting the code marked it uploaded
|
||||
// and dropped it from the queue.
|
||||
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", apiKey, v2Body.toString())
|
||||
val v2Verdict = WavelogResponse.verdict(code, resp)
|
||||
when (v2Verdict) {
|
||||
is WavelogResponse.Verdict.Accepted -> return WavelogResult.Success("v2")
|
||||
WavelogResponse.Verdict.Duplicate -> return WavelogResult.Success("duplicate")
|
||||
// Anything else falls through to v1. A rejection here is NOT final: v2 refuses a legacy
|
||||
// v1 key with 401 invalid_token, and returning at that point stopped a v1-only operator
|
||||
// from uploading at all. The v1 attempt below is the one that can speak for them.
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
// v1: POST /index.php/api/qso (key in body + ADIF)
|
||||
val v1Body = buildJsonObject {
|
||||
put("key", apiKey)
|
||||
put("station_profile_id", stationProfileId)
|
||||
put("type", "adif")
|
||||
put("string", toAdif(qso, gridsquare, satName))
|
||||
}
|
||||
val (code1, resp1) = httpRequest("$base/index.php/api/qso", apiKey, v1Body.toString())
|
||||
val v1Verdict = WavelogResponse.verdict(code1, resp1)
|
||||
when (v1Verdict) {
|
||||
is WavelogResponse.Verdict.Accepted -> return WavelogResult.Success("v1")
|
||||
WavelogResponse.Verdict.Duplicate -> return WavelogResult.Success("duplicate")
|
||||
// Also falls through: a server with different rewrite rules answers this path with a
|
||||
// 404 page, which is a rejection but says nothing about whether the QSO can be stored.
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
// v1 without index.php, for a server whose rewrite rules differ
|
||||
val (code1b, resp1b) = httpRequest("$base/api/qso", apiKey, v1Body.toString())
|
||||
when (val verdict = WavelogResponse.verdict(code1b, resp1b)) {
|
||||
is WavelogResponse.Verdict.Accepted -> return WavelogResult.Success("v1")
|
||||
WavelogResponse.Verdict.Duplicate -> return WavelogResult.Success("duplicate")
|
||||
is WavelogResponse.Verdict.Rejected ->
|
||||
return WavelogResult.Failure(verdict.reason)
|
||||
is WavelogResponse.Verdict.Unreadable -> Unit
|
||||
}
|
||||
|
||||
// Every endpoint answered something we could not read. Keeping the QSO queued is the only
|
||||
// honest outcome: it may have been stored, and dropping it would lose the contact.
|
||||
// No endpoint accepted it. The v2 reason is preferred when it explained itself, since a 401
|
||||
// invalid_token is the most actionable thing an operator can be told; otherwise all three
|
||||
// status codes go out, because the third was previously dropped from this message.
|
||||
val reasons = listOfNotNull(
|
||||
(v1Verdict as? WavelogResponse.Verdict.Rejected)?.reason,
|
||||
(v2Verdict as? WavelogResponse.Verdict.Rejected)?.reason
|
||||
).filter { it.isNotBlank() }
|
||||
return WavelogResult.Failure(
|
||||
reasons.firstOrNull()
|
||||
?: ("no endpoint accepted it: v2 HTTP $code, v1 HTTP $code1, v1-alt HTTP $code1b" +
|
||||
" - " + shortError(resp1.ifBlank { resp1b }))
|
||||
)
|
||||
}
|
||||
|
||||
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
|
||||
internal fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
|
||||
fun field(name: String, value: String): String {
|
||||
val bytes = value.encodeToByteArray().size
|
||||
return "<$name:$bytes>$value"
|
||||
}
|
||||
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
|
||||
return buildString {
|
||||
append(field("call", qso.call))
|
||||
append(field("band", bandFromHz(qso.freqTxHz)))
|
||||
append(field("mode", qso.mode))
|
||||
append(field("freq", formatString("%.6f", qso.freqTxHz / 1_000_000.0)))
|
||||
if (qso.freqRxHz > 0) {
|
||||
append(field("freq_rx", formatString("%.6f", qso.freqRxHz / 1_000_000.0)))
|
||||
}
|
||||
append(field("qso_date", utcDateCompact(qso.timeUtcMs)))
|
||||
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
|
||||
append(field("rst_sent", "59"))
|
||||
append(field("rst_rcvd", "59"))
|
||||
// Send the grid at full precision. Truncating to 4 characters threw
|
||||
// away the 6-character locator the QRZ lookup provides, coarsening the
|
||||
// stored position from ~4.6 km to ~100 km and making a QSO logged via
|
||||
// v1 disagree with the same QSO logged via v2 (which sends it whole).
|
||||
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare))
|
||||
if (satName.isNotBlank()) {
|
||||
append(field("sat_name", satName))
|
||||
if (satMode.isNotBlank()) append(field("sat_mode", satMode))
|
||||
append(field("prop_mode", "SAT"))
|
||||
}
|
||||
append("<eor>")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST [jsonBody] when one is given, GET otherwise. A request that could not be sent at all
|
||||
* comes back as code 0, so callers only have to look at the code.
|
||||
*/
|
||||
private suspend fun httpRequest(url: String, apiKey: String, jsonBody: String?): HttpResult {
|
||||
val client = httpClient ?: error("WaveLogApi has no HTTP client installed")
|
||||
val headers = buildMap {
|
||||
if (apiKey.isNotBlank()) put("Authorization", "Bearer $apiKey")
|
||||
if (jsonBody != null) {
|
||||
put("Content-Type", "application/json")
|
||||
put("Accept", "application/json")
|
||||
}
|
||||
}
|
||||
val result = if (jsonBody != null) client.post(url, headers, jsonBody) else client.get(url, headers)
|
||||
// A request that never left the phone used to report the exception text where the body
|
||||
// goes, which is what the failure messages below read; keep it there.
|
||||
val failure = result.failure
|
||||
return if (result.body.isEmpty() && failure != null) result.copy(body = failure) else result
|
||||
}
|
||||
|
||||
private fun shortError(body: String): String {
|
||||
if (body.startsWith("<")) return body.take(80) // HTML error page
|
||||
return try {
|
||||
val obj = Json.parseToJsonElement(body).jsonObject
|
||||
val err = obj["error"] as? JsonObject
|
||||
if (err != null) {
|
||||
err["message"]?.jsonPrimitive?.contentOrNull.orEmpty().ifBlank { body.take(120) }
|
||||
} else {
|
||||
obj["reason"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
.ifBlank { obj["message"]?.jsonPrimitive?.contentOrNull.orEmpty().ifBlank { body.take(120) } }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
body.take(120)
|
||||
}
|
||||
}
|
||||
|
||||
/** UTC civil time of a Unix millisecond stamp; the JVM Calendar is not multiplatform. */
|
||||
private data class UtcFields(val year: Int, val month: Int, val day: Int, val hour: Int, val minute: Int, val second: Int)
|
||||
|
||||
private fun utcFieldsOf(ms: Long): UtcFields {
|
||||
val days = ms.floorDiv(MS_PER_DAY)
|
||||
val millisOfDay = ms.mod(MS_PER_DAY)
|
||||
val shifted = days + 719_468
|
||||
val era = shifted.floorDiv(146_097)
|
||||
val dayOfEra = shifted - era * 146_097
|
||||
val yearOfEra = (dayOfEra - dayOfEra / 1_460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365
|
||||
val dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100)
|
||||
val monthPart = (5 * dayOfYear + 2) / 153
|
||||
val day = (dayOfYear - (153 * monthPart + 2) / 5 + 1).toInt()
|
||||
val month = (if (monthPart < 10) monthPart + 3 else monthPart - 9).toInt()
|
||||
val year = yearOfEra.toInt() + era.toInt() * 400 + (if (month <= 2) 1 else 0)
|
||||
val secondOfDay = (millisOfDay / 1000).toInt()
|
||||
return UtcFields(year, month, day, secondOfDay / 3_600, secondOfDay / 60 % 60, secondOfDay % 60)
|
||||
}
|
||||
|
||||
private fun utcDate(ms: Long): String {
|
||||
val utc = utcFieldsOf(ms)
|
||||
return formatString("%04d-%02d-%02d", utc.year, utc.month, utc.day)
|
||||
}
|
||||
|
||||
private fun utcTime(ms: Long): String {
|
||||
val utc = utcFieldsOf(ms)
|
||||
return formatString("%02d:%02d:%02d", utc.hour, utc.minute, utc.second)
|
||||
}
|
||||
|
||||
private fun utcDateCompact(ms: Long): String {
|
||||
val utc = utcFieldsOf(ms)
|
||||
return formatString("%04d%02d%02d", utc.year, utc.month, utc.day)
|
||||
}
|
||||
|
||||
private fun utcTimeCompact(ms: Long): String {
|
||||
val utc = utcFieldsOf(ms)
|
||||
return formatString("%02d%02d%02d", utc.hour, utc.minute, utc.second)
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* WavelogQueue.kt - WaveLog local log queue (4.5.2).
|
||||
*
|
||||
* Pure Kotlin (no Android deps): storage goes through the IWavelogQueueStore interface,
|
||||
* implemented with SharedPreferences in core/data.
|
||||
* Queue capped at 500 entries (oldest dropped beyond that).
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.synchronizedOn
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/** Storage abstraction (SharedPreferences impl lives in core/data) */
|
||||
interface IWavelogQueueStore {
|
||||
fun load(): String
|
||||
fun save(json: String)
|
||||
}
|
||||
|
||||
/** QSO entry awaiting upload (local queue element, mirrors POST /api/v2/qso fields) */
|
||||
data class WavelogQso(
|
||||
val id: String, // 本地唯一 id(UUID)
|
||||
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
|
||||
val call: String,
|
||||
val mode: String,
|
||||
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
|
||||
val freqRxHz: Long, // 下行
|
||||
val satName: String,
|
||||
/**
|
||||
* NORAD catalogue number of the satellite, or 0 when it was not recorded.
|
||||
*
|
||||
* Carried because the name alone cannot decide the LoTW spelling - sources disagree, and
|
||||
* the same satellite named two ways would upload two ways. Zero means a QSO logged before
|
||||
* this field existed; those fall back to resolving from the name.
|
||||
*/
|
||||
val catnum: Int = 0,
|
||||
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
|
||||
val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到
|
||||
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
|
||||
)
|
||||
|
||||
/**
|
||||
* Every mutator is a read-modify-write over the single stored blob and serialises on a private
|
||||
* monitor, so the Compose thread and the upload coroutine cannot drop each other's entries. The
|
||||
* monitor is a platform actual (utility/SynchronizedOn.kt) because kotlin.jvm.Synchronized is an
|
||||
* error in common code since Kotlin 2.1.
|
||||
*/
|
||||
class WavelogQueue(private val store: IWavelogQueueStore) {
|
||||
|
||||
private val key = "wavelog_queue"
|
||||
private val lock = Any()
|
||||
|
||||
fun all(): List<WavelogQso> {
|
||||
val raw = store.load()
|
||||
return try {
|
||||
Json.parseToJsonElement(raw).jsonArray.map { element ->
|
||||
val o = element.jsonObject
|
||||
WavelogQso(
|
||||
id = o.getValue("id").jsonPrimitive.content,
|
||||
timeUtcMs = o["timeUtcMs"].readLong(),
|
||||
call = o["call"].readString(),
|
||||
mode = o["mode"].readString(),
|
||||
freqTxHz = o["freqTxHz"].readLong(),
|
||||
freqRxHz = o["freqRxHz"].readLong(),
|
||||
satName = o["satName"].readString(),
|
||||
catnum = o["catnum"].readInt(),
|
||||
sessionId = o["sessionId"].readString(),
|
||||
gridsquare = o["gridsquare"].readString(),
|
||||
uploaded = o["uploaded"].readBoolean()
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun add(qso: WavelogQso) {
|
||||
synchronizedOn(lock) {
|
||||
val list = all().toMutableList()
|
||||
list.add(0, qso) // 最新在前
|
||||
if (list.size > 500) list.removeAt(list.size - 1)
|
||||
save(list)
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(id: String) {
|
||||
synchronizedOn(lock) { save(all().filter { it.id != id }) }
|
||||
}
|
||||
|
||||
fun removeAll(ids: Set<String>) {
|
||||
synchronizedOn(lock) { save(all().filter { it.id !in ids }) }
|
||||
}
|
||||
|
||||
/** Mark as uploaded (kept in the queue; checkmark in the table) */
|
||||
fun markUploaded(id: String) {
|
||||
synchronizedOn(lock) { save(all().map { if (it.id == id) it.copy(uploaded = true) else it }) }
|
||||
}
|
||||
|
||||
/** Update a QSO's counterpart grid (async backfill from the QRZ scraper, 4.5.5) */
|
||||
fun updateGridsquare(id: String, grid: String) {
|
||||
synchronizedOn(lock) { save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it }) }
|
||||
}
|
||||
|
||||
/** Remove all uploaded entries (optional; keeps the queue lean) */
|
||||
fun removeUploaded() {
|
||||
synchronizedOn(lock) { save(all().filter { !it.uploaded }) }
|
||||
}
|
||||
|
||||
private fun save(list: List<WavelogQso>) {
|
||||
val arr = buildJsonArray {
|
||||
list.forEach { q ->
|
||||
add(buildJsonObject {
|
||||
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
|
||||
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
|
||||
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
|
||||
put("catnum", q.catnum)
|
||||
put("sessionId", q.sessionId)
|
||||
put("gridsquare", q.gridsquare)
|
||||
put("uploaded", q.uploaded)
|
||||
})
|
||||
}
|
||||
}
|
||||
store.save(arr.toString())
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* org.json's opt* readers never threw: a decimal ("1234.0", or the string "1234.0") was coerced to
|
||||
* a whole number, a missing or mismatched field fell back to the default, and a field holding an
|
||||
* object was stringified. kotlinx answers null for the first two - which turned a readable
|
||||
* timestamp into 0L, i.e. a QSO uploaded as 1970 - and throws for the third, which took the whole
|
||||
* list down with it. These keep the old behaviour, except that a JSON null becomes the empty
|
||||
* string or 0 instead of the literal "null".
|
||||
*/
|
||||
private fun JsonElement?.readLong(default: Long = 0L): Long {
|
||||
val primitive = this as? JsonPrimitive ?: return default
|
||||
primitive.longOrNull?.let { return it }
|
||||
return primitive.content.toDoubleOrNull()?.takeIf { it.isFinite() }?.toLong() ?: default
|
||||
}
|
||||
|
||||
private fun JsonElement?.readInt(default: Int = 0): Int {
|
||||
val primitive = this as? JsonPrimitive ?: return default
|
||||
primitive.intOrNull?.let { return it }
|
||||
return primitive.content.toDoubleOrNull()?.takeIf { it.isFinite() }?.toInt() ?: default
|
||||
}
|
||||
|
||||
private fun JsonElement?.readString(default: String = ""): String =
|
||||
(this as? JsonPrimitive)?.contentOrNull ?: default
|
||||
|
||||
private fun JsonElement?.readBoolean(default: Boolean = false): Boolean =
|
||||
(this as? JsonPrimitive)?.booleanOrNull ?: default
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
/**
|
||||
* Decides whether Wavelog actually accepted a QSO.
|
||||
*
|
||||
* The status code is not the answer. Wavelog validates after responding 200 and reports the
|
||||
* outcome in the body, so a rejected QSO arrives as HTTP 200 with `{"status":"failed"}`. Trusting
|
||||
* the code alone marked it uploaded and dropped it from the queue - the same class of defect as
|
||||
* the APRS reporter claiming a send succeeded when nothing had left the phone.
|
||||
*
|
||||
* Parsed as text rather than through a JSON library on purpose: what Wavelog answers is sparse and
|
||||
* freely worded, and matching it as text keeps the verdict testable without a parser.
|
||||
*/
|
||||
object WavelogResponse {
|
||||
|
||||
/** What the server said about one QSO. */
|
||||
sealed interface Verdict {
|
||||
|
||||
/** Stored. Safe to drop from the queue. */
|
||||
data class Accepted(val detail: String) : Verdict
|
||||
|
||||
/** Wavelog already has it. Also safe to drop - the log is correct either way. */
|
||||
data object Duplicate : Verdict
|
||||
|
||||
/** Rejected. [reason] carries the server's own wording when it gave one. */
|
||||
data class Rejected(val reason: String) : Verdict
|
||||
|
||||
/** Unreadable, so keep the QSO queued rather than guess in either direction. */
|
||||
data class Unreadable(val detail: String) : Verdict
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret a response.
|
||||
*
|
||||
* A body that reports failure beats a success code, because that is exactly the case the code
|
||||
* alone gets wrong. An empty body with a success code counts as accepted, since the v1
|
||||
* endpoint answers that way.
|
||||
*/
|
||||
fun verdict(statusCode: Int, body: String): Verdict {
|
||||
val text = body.trim()
|
||||
// Whitespace around separators is collapsed before matching, rather than listing every
|
||||
// spacing a server might use: `{ "status" : "failed" }` is as valid as
|
||||
// `{"status":"failed"}` and enumerating the combinations is endless.
|
||||
val lower = text.lowercase().replace(AROUND_SEPARATORS, "")
|
||||
// Duplicate reported three ways: a 409, the word in a message, or Wavelog's own
|
||||
// `{"status":"dupe"}` - which arrives with HTTP 200 and is documented, not guessed.
|
||||
// An HTML body is a proxy or a PHP fatal, never a verdict. Checked first because such a
|
||||
// page carries no status token and would otherwise read as an acceptance, so a
|
||||
// misconfigured reverse proxy answering 200 would eat contacts.
|
||||
if (lower.startsWith("<")) return Verdict.Unreadable(text.take(MAX_DETAIL))
|
||||
|
||||
// Duplicate only when the STATUS says so, or on a 409. The word alone is not enough: the
|
||||
// server's own rejection text is "Duplicate for <call>", and Api_v2 surfaces that inside
|
||||
// validation_error bodies - so matching the bare substring classified a hard rejection as
|
||||
// a duplicate, which maps to success and drops the QSO from the queue. That is the very
|
||||
// defect this class was written to prevent, reached through a different door.
|
||||
if (statusCode == DUPLICATE_CODE || lower.contains(DUPE_STATUS)) return Verdict.Duplicate
|
||||
if (statusCode !in SUCCESS_CODES) {
|
||||
return Verdict.Rejected(reasonFrom(text).ifBlank { "HTTP $statusCode" })
|
||||
}
|
||||
if (FAILURE_MARKERS.any { lower.contains(it) }) {
|
||||
return Verdict.Rejected(reasonFrom(text).ifBlank { "server reported failure" })
|
||||
}
|
||||
// The v2 endpoint reports errors in an envelope with no status key at all.
|
||||
if (lower.contains("\"error\":")) {
|
||||
return Verdict.Rejected(reasonFrom(text).ifBlank { "server reported an error" })
|
||||
}
|
||||
// A status field we cannot read is not a success. Saying so keeps the QSO queued.
|
||||
if (lower.contains("\"status\"") && SUCCESS_MARKERS.none { lower.contains(it) }) {
|
||||
return Verdict.Unreadable(text.take(MAX_DETAIL))
|
||||
}
|
||||
// A bulk reply that stored nothing is not an acceptance, whatever its status says. Look4Sat
|
||||
// posts one QSO per request so this is latent today, but `imported:0` reading as success
|
||||
// would silently clear the queue if that ever changes.
|
||||
if (importedZero(lower)) return Verdict.Rejected("server imported nothing")
|
||||
return Verdict.Accepted(text.take(MAX_DETAIL))
|
||||
}
|
||||
|
||||
/** Whether a bulk reply reports that no record was stored. */
|
||||
private fun importedZero(lower: String): Boolean =
|
||||
IMPORT_COUNT_KEYS.any { lower.contains("\"" + it + "\":0") }
|
||||
|
||||
/**
|
||||
* The server's own explanation, when it gave one.
|
||||
*
|
||||
* Reads `reason`, `message` or `error` out of the JSON by hand. Crude, but it only has to work
|
||||
* well enough to show the operator something more useful than a status code.
|
||||
*/
|
||||
private fun reasonFrom(body: String): String {
|
||||
for (key in REASON_KEYS) {
|
||||
Regex("\"$key\"\\s*:\\s*\"([^\"]*)\"").find(body)
|
||||
?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { return it }
|
||||
}
|
||||
// An array of messages, which the v1 endpoint returns for a malformed ADIF record.
|
||||
Regex("\"messages\"\\s*:\\s*\\[\\s*\"([^\"]*)\"").find(body)
|
||||
?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { return it }
|
||||
return ""
|
||||
}
|
||||
|
||||
private val SUCCESS_CODES = 200..299
|
||||
private const val DUPLICATE_CODE = 409
|
||||
private const val MAX_DETAIL = 200
|
||||
|
||||
/** Matched against the whitespace-collapsed body, so one spelling of each suffices. */
|
||||
private val FAILURE_MARKERS = listOf(
|
||||
"\"status\":\"failed\"",
|
||||
"\"status\":\"error\"",
|
||||
"\"result\":\"failed\""
|
||||
)
|
||||
|
||||
/**
|
||||
* Wavelog's documented success wordings, plus the ones its other endpoints use.
|
||||
*
|
||||
* `successful` matters as much as `success`: the API reference uses both, and treating one as
|
||||
* unrecognised would leave a stored QSO queued forever.
|
||||
*/
|
||||
/**
|
||||
* Success wordings, taken from the Wavelog server source rather than its documentation.
|
||||
*
|
||||
* The QSO endpoint answers `created`; `success` and `successful` come from other endpoints and
|
||||
* are kept because a future version may use them. `abort` is NOT here - v1 uses it when any
|
||||
* record in a batch failed, and it arrives with a 400.
|
||||
*/
|
||||
private val SUCCESS_MARKERS = listOf(
|
||||
"\"status\":\"created\"",
|
||||
"\"status\":\"success\"",
|
||||
"\"status\":\"successful\"",
|
||||
"\"status\":\"ok\""
|
||||
)
|
||||
|
||||
/** `dupe` is Wavelog's own wording and arrives with a 200. */
|
||||
/** Wavelog's own duplicate status. The bare word "duplicate" is deliberately NOT a marker. */
|
||||
private const val DUPE_STATUS = "\"status\":\"dupe\""
|
||||
|
||||
/** Whitespace next to a colon or comma, which JSON allows and servers use inconsistently. */
|
||||
private val AROUND_SEPARATORS = Regex("""\s*(?=[:,])|(?<=[:,])\s*""")
|
||||
|
||||
/** Count fields a bulk reply uses to say how many records it stored. */
|
||||
private val IMPORT_COUNT_KEYS = listOf("imported", "adif_count")
|
||||
|
||||
private val REASON_KEYS = listOf("reason", "message", "error")
|
||||
}
|
||||
+37
-12
@@ -9,16 +9,39 @@
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
sealed class UploadOutcome {
|
||||
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
|
||||
|
||||
/**
|
||||
* The upload finished. [failedCount] entries stay in the queue.
|
||||
*
|
||||
* [reason] is machine-readable so the UI can pick its own wording; [firstError] carries the
|
||||
* server's own explanation for the first failure, which is worth showing verbatim because it
|
||||
* is the only thing that says WHY Wavelog refused a QSO.
|
||||
*/
|
||||
data class Done(
|
||||
val successCount: Int,
|
||||
val failedCount: Int,
|
||||
val message: String,
|
||||
val reason: Reason = Reason.COMPLETED,
|
||||
val firstError: String = ""
|
||||
) : UploadOutcome()
|
||||
|
||||
/** Why an upload ended, for the UI to phrase. */
|
||||
enum class Reason {
|
||||
/** Ran to completion. Check the counts. */
|
||||
COMPLETED,
|
||||
|
||||
/** No server, key or station id configured. */
|
||||
NOT_CONFIGURED,
|
||||
|
||||
/** The station profile could not be read - wrong id, or a key without permission. */
|
||||
NO_STATION_INFO
|
||||
}
|
||||
}
|
||||
|
||||
class WavelogUploader(
|
||||
@@ -36,13 +59,13 @@ class WavelogUploader(
|
||||
val apiKey = settings.wavelogApiKey
|
||||
val stationId = settings.wavelogStationId
|
||||
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
|
||||
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
|
||||
return UploadOutcome.Done(0, queue.all().size, UploadOutcome.Reason.NOT_CONFIGURED)
|
||||
}
|
||||
|
||||
// 1. Fetch station info (station grid); fall back to user QTH when v1 lacks the endpoint
|
||||
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
|
||||
if (stationGrid.isNullOrBlank()) {
|
||||
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
|
||||
return UploadOutcome.Done(0, queue.all().size, UploadOutcome.Reason.NO_STATION_INFO)
|
||||
}
|
||||
|
||||
// 2. Grid check: cloud station grid first 4 chars vs current station QTH first 4 chars
|
||||
@@ -56,29 +79,31 @@ class WavelogUploader(
|
||||
|
||||
// 3. Upload one by one. ADIF gridsquare = counterpart grid (the QSO partner); blank until the scraper lands
|
||||
val entries = queue.all()
|
||||
var ok = 0
|
||||
var fail = 0
|
||||
var uploaded = 0
|
||||
var failed = 0
|
||||
var firstError = ""
|
||||
for (qso in entries) {
|
||||
if (qso.uploaded) { ok++; continue }
|
||||
// Entries already confirmed by the server are skipped, and NOT counted: adding them to
|
||||
// the total made a re-run report "N uploaded" for QSOs that went up days ago.
|
||||
if (qso.uploaded) continue
|
||||
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, qso.gridsquare)
|
||||
if (result is WavelogResult.Success) {
|
||||
ok++
|
||||
uploaded++
|
||||
queue.markUploaded(qso.id)
|
||||
} else {
|
||||
fail++
|
||||
failed++
|
||||
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
|
||||
}
|
||||
}
|
||||
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
|
||||
return UploadOutcome.Done(ok, fail, message, firstError)
|
||||
return UploadOutcome.Done(uploaded, failed, UploadOutcome.Reason.COMPLETED, firstError)
|
||||
}
|
||||
|
||||
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
|
||||
val result = WaveLogApi.getStation(url, apiKey, stationId)
|
||||
if (result is WavelogResult.Success) {
|
||||
return try {
|
||||
JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() }
|
||||
Json.parseToJsonElement(result.message).jsonObject["gridsquare"]
|
||||
?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
|
||||
?: cachedStationGrid
|
||||
} catch (_: Exception) { cachedStationGrid }
|
||||
}
|
||||
+113
-109
@@ -19,104 +19,108 @@ package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@ExperimentalCoroutinesApi
|
||||
class DataParserTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val dataParser = DataParser(testDispatcher)
|
||||
private val validCSVStream = """
|
||||
private val validCSV = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
ISS (ZARYA),1998-067A,2024-03-09T05:45:04.737024,15.49756209,.0005741,51.6418,90.7424,343.9724,92.8274,0,U,25544,999,44305,.25016E-3,.1373E-3,0
|
||||
""".trimIndent().byteInputStream()
|
||||
private val invalidCSVStream = """
|
||||
""".trimIndent()
|
||||
private val invalidCSV = """
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
""".trimIndent().byteInputStream()
|
||||
private val validTLEStream = """
|
||||
""".trimIndent()
|
||||
private val validTLE = """
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A 24069.23963816 .00013730 00000+0 25016-3 0 9999
|
||||
2 25544 51.6418 90.7424 0005741 343.9724 92.8274 15.49756209443058
|
||||
""".trimIndent().byteInputStream()
|
||||
private val invalidTLEStream = """
|
||||
""".trimIndent()
|
||||
private val invalidTLE = """
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
private val validJSONStream = """
|
||||
""".trimIndent()
|
||||
private val validJSON = """
|
||||
[{"uuid":"UzPz4gcsNBPKPKAFPmer7g","description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":136658500,"downlink_high":null,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED - https://xkcd.com/285/","service":"Unknown","coordination":"","coordination_url":""}]
|
||||
""".trimIndent().byteInputStream()
|
||||
private val invalidJSONStream = """
|
||||
""".trimIndent()
|
||||
private val invalidJSON = """
|
||||
[{"description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":136658500,"downlink_high":null,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED - https://xkcd.com/285/","service":"Unknown","coordination":"","coordination_url":""}]
|
||||
""".trimIndent().byteInputStream()
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
||||
assert(parsedList.size == 2)
|
||||
assert(parsedList[0].epoch == 21320.51955234)
|
||||
assert(parsedList[1].epoch == 24069.23963816)
|
||||
val parsedList = dataParser.parseCSV(validCSV)
|
||||
assertTrue(parsedList.size == 2)
|
||||
assertTrue(parsedList[0].epoch == 21320.51955234)
|
||||
assertTrue(parsedList[1].epoch == 24069.23963816)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid CSV stream all orbital fields are parsed correctly`() = runTest(testDispatcher) {
|
||||
val csvStream = """
|
||||
val csv = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||
assert(sat.name == "ISS (ZARYA)")
|
||||
assert(sat.catnum == 25544)
|
||||
assert(sat.meanmo == 15.48582035)
|
||||
assert(sat.eccn == 0.0004694)
|
||||
assert(sat.incl == 51.6447)
|
||||
assert(sat.raan == 309.4881)
|
||||
assert(sat.argper == 203.6966)
|
||||
assert(sat.meanan == 299.8876)
|
||||
assert(sat.bstar == 0.31985E-4)
|
||||
assert(sat.ndot == 0.1288E-4)
|
||||
""".trimIndent()
|
||||
val sat = dataParser.parseCSV(csv)[0]
|
||||
assertTrue(sat.name == "ISS (ZARYA)")
|
||||
assertTrue(sat.catnum == 25544)
|
||||
assertTrue(sat.meanmo == 15.48582035)
|
||||
assertTrue(sat.eccn == 0.0004694)
|
||||
assertTrue(sat.incl == 51.6447)
|
||||
assertTrue(sat.raan == 309.4881)
|
||||
assertTrue(sat.argper == 203.6966)
|
||||
assertTrue(sat.meanan == 299.8876)
|
||||
assertTrue(sat.bstar == 0.31985E-4)
|
||||
assertTrue(sat.ndot == 0.1288E-4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid CSV stream ndot is parsed for decay detection`() = runTest(testDispatcher) {
|
||||
val csvStream = """
|
||||
val csv = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||
""".trimIndent()
|
||||
val sat = dataParser.parseCSV(csv)[0]
|
||||
// ISS is healthy, should not be decayed even years later
|
||||
assert(!sat.hasDecayed(System.currentTimeMillis()))
|
||||
assertTrue(!sat.hasDecayed(Clock.System.now().toEpochMilliseconds()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV with high drag satellite detects decay`() = runTest(testDispatcher) {
|
||||
// Simulate a satellite with high drag and old epoch that should have decayed
|
||||
val csvStream = """
|
||||
val csv = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
DEBRIS,2020-001A,2020-01-15T00:00:00.000000,15.9,.001,51.0,100.0,200.0,300.0,0,U,99999,1,100,.5E-3,.05,0
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||
""".trimIndent()
|
||||
val sat = dataParser.parseCSV(csv)[0]
|
||||
// High mean motion (15.9) + high drag (.05) + old epoch → should be decayed by now
|
||||
assert(sat.hasDecayed(System.currentTimeMillis()))
|
||||
assertTrue(sat.hasDecayed(Clock.System.now().toEpochMilliseconds()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid CSV stream returns empty list`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
|
||||
assertTrue(dataParser.parseCSV(invalidCSV).isEmpty())
|
||||
}
|
||||
|
||||
private fun csvWithEpoch(epoch: String) = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,$epoch,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch one minute past midnight the day fraction is correct`() = runTest(testDispatcher) {
|
||||
@@ -125,41 +129,41 @@ class DataParserTest {
|
||||
// notation below 1e-3, so the leading significant digit was truncated.
|
||||
// 00:01:00 produced "25001.944444444444445E-4" -> 2.50019..., an epoch
|
||||
// roughly 26 years off, with no exception to reveal it.
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:01:00.000000"))[0]
|
||||
assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, 1e-9)
|
||||
val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T00:01:00.000000"))[0]
|
||||
assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, absoluteTolerance = 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch one second past midnight the day fraction is correct`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:01.000000"))[0]
|
||||
assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, 1e-9)
|
||||
val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T00:00:01.000000"))[0]
|
||||
assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, absoluteTolerance = 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch exactly at midnight the day fraction is zero`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:00.000000"))[0]
|
||||
assertEquals(25001.0, sat.epoch, 1e-9)
|
||||
val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T00:00:00.000000"))[0]
|
||||
assertEquals(25001.0, sat.epoch, absoluteTolerance = 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch at midday the day fraction is one half`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T12:00:00.000000"))[0]
|
||||
assertEquals(25001.5, sat.epoch, 1e-9)
|
||||
val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T12:00:00.000000"))[0]
|
||||
assertEquals(25001.5, sat.epoch, absoluteTolerance = 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch late in the day the day fraction stays below one`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T23:59:59.999000"))[0]
|
||||
assert(sat.epoch > 25001.999) { "expected almost a full day, got ${sat.epoch}" }
|
||||
assert(sat.epoch < 25002.0) { "day fraction must not roll into the next day, got ${sat.epoch}" }
|
||||
val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T23:59:59.999000"))[0]
|
||||
assertTrue(sat.epoch > 25001.999, "expected almost a full day, got ${sat.epoch}")
|
||||
assertTrue(sat.epoch < 25002.0, "day fraction must not roll into the next day, got ${sat.epoch}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseTLEStream(validTLEStream)
|
||||
assert(parsedList.size == 2)
|
||||
assert(parsedList[0].epoch == 21320.51955234)
|
||||
assert(parsedList[1].epoch == 24069.23963816)
|
||||
val parsedList = dataParser.parseTLE(validTLE)
|
||||
assertTrue(parsedList.size == 2)
|
||||
assertTrue(parsedList[0].epoch == 21320.51955234)
|
||||
assertTrue(parsedList[1].epoch == 24069.23963816)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -168,17 +172,17 @@ class DataParserTest {
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||
assert(sat.name == "ISS (ZARYA)")
|
||||
assert(sat.catnum == 25544)
|
||||
assert(sat.meanmo == 15.48582035)
|
||||
assert(sat.eccn == 0.0004694)
|
||||
assert(sat.incl == 51.6447)
|
||||
assert(sat.raan == 309.4881)
|
||||
assert(sat.argper == 203.6966)
|
||||
assert(sat.meanan == 299.8876)
|
||||
assert(sat.ndot == 0.00001288)
|
||||
""".trimIndent()
|
||||
val sat = dataParser.parseTLE(tleStream)[0]
|
||||
assertTrue(sat.name == "ISS (ZARYA)")
|
||||
assertTrue(sat.catnum == 25544)
|
||||
assertTrue(sat.meanmo == 15.48582035)
|
||||
assertTrue(sat.eccn == 0.0004694)
|
||||
assertTrue(sat.incl == 51.6447)
|
||||
assertTrue(sat.raan == 309.4881)
|
||||
assertTrue(sat.argper == 203.6966)
|
||||
assertTrue(sat.meanan == 299.8876)
|
||||
assertTrue(sat.ndot == 0.00001288)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -187,66 +191,66 @@ class DataParserTest {
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||
assert(!sat.hasDecayed(System.currentTimeMillis()))
|
||||
""".trimIndent()
|
||||
val sat = dataParser.parseTLE(tleStream)[0]
|
||||
assertTrue(!sat.hasDecayed(Clock.System.now().toEpochMilliseconds()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseTLEStream(invalidTLEStream).isEmpty())
|
||||
assertTrue(dataParser.parseTLE(invalidTLE).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid JSON stream returns valid data`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseJSONStream(validJSONStream)[0].downlinkLow == 136658500L)
|
||||
assertTrue(dataParser.parseJSON(validJSON)[0].downlinkLow == 136658500L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid JSON stream all radio fields are parsed correctly`() = runTest(testDispatcher) {
|
||||
val jsonStream = """
|
||||
[{"uuid":"UzPz4gcsNBPKPKAFPmer7g","description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":145900000,"uplink_high":146000000,"uplink_drift":null,"downlink_low":136658500,"downlink_high":136700000,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":"FM","invert":true,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED","service":"Unknown","coordination":"","coordination_url":""}]
|
||||
""".trimIndent().byteInputStream()
|
||||
val radio = dataParser.parseJSONStream(jsonStream)[0]
|
||||
assert(radio.uuid == "UzPz4gcsNBPKPKAFPmer7g")
|
||||
assert(radio.info == "Upper side band (drifting)")
|
||||
assert(radio.isAlive)
|
||||
assert(radio.downlinkLow == 136658500L)
|
||||
assert(radio.downlinkHigh == 136700000L)
|
||||
assert(radio.downlinkMode == "USB")
|
||||
assert(radio.uplinkLow == 145900000L)
|
||||
assert(radio.uplinkHigh == 146000000L)
|
||||
assert(radio.uplinkMode == "FM")
|
||||
assert(radio.isInverted)
|
||||
assert(radio.catnum == 965)
|
||||
""".trimIndent()
|
||||
val radio = dataParser.parseJSON(jsonStream)[0]
|
||||
assertTrue(radio.uuid == "UzPz4gcsNBPKPKAFPmer7g")
|
||||
assertTrue(radio.info == "Upper side band (drifting)")
|
||||
assertTrue(radio.isAlive)
|
||||
assertTrue(radio.downlinkLow == 136658500L)
|
||||
assertTrue(radio.downlinkHigh == 136700000L)
|
||||
assertTrue(radio.downlinkMode == "USB")
|
||||
assertTrue(radio.uplinkLow == 145900000L)
|
||||
assertTrue(radio.uplinkHigh == 146000000L)
|
||||
assertTrue(radio.uplinkMode == "FM")
|
||||
assertTrue(radio.isInverted)
|
||||
assertTrue(radio.catnum == 965)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given JSON with null optional fields parses without error`() = runTest(testDispatcher) {
|
||||
val jsonStream = """
|
||||
[{"uuid":"abc123","description":"Beacon","alive":false,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":145800000,"downlink_high":null,"downlink_drift":null,"mode":null,"mode_id":null,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"TEST","norad_cat_id":12345,"status":"active","updated":"2024-01-01T00:00:00Z","citation":"","service":"Unknown","coordination":"","coordination_url":""}]
|
||||
""".trimIndent().byteInputStream()
|
||||
val radio = dataParser.parseJSONStream(jsonStream)[0]
|
||||
assert(radio.uuid == "abc123")
|
||||
assert(!radio.isAlive)
|
||||
assert(radio.downlinkLow == 145800000L)
|
||||
assert(radio.downlinkHigh == null)
|
||||
assert(radio.downlinkMode == null)
|
||||
assert(radio.uplinkLow == null)
|
||||
assert(radio.uplinkHigh == null)
|
||||
assert(radio.uplinkMode == null)
|
||||
assert(!radio.isInverted)
|
||||
assert(radio.catnum == 12345)
|
||||
""".trimIndent()
|
||||
val radio = dataParser.parseJSON(jsonStream)[0]
|
||||
assertTrue(radio.uuid == "abc123")
|
||||
assertTrue(!radio.isAlive)
|
||||
assertTrue(radio.downlinkLow == 145800000L)
|
||||
assertTrue(radio.downlinkHigh == null)
|
||||
assertTrue(radio.downlinkMode == null)
|
||||
assertTrue(radio.uplinkLow == null)
|
||||
assertTrue(radio.uplinkHigh == null)
|
||||
assertTrue(radio.uplinkMode == null)
|
||||
assertTrue(!radio.isInverted)
|
||||
assertTrue(radio.catnum == 12345)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseJSONStream(invalidJSONStream).isEmpty())
|
||||
assertTrue(dataParser.parseJSON(invalidJSON).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid data streams parsed results match`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream))
|
||||
assertTrue(dataParser.parseCSV(validCSV) == dataParser.parseTLE(validTLE))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -254,38 +258,38 @@ class DataParserTest {
|
||||
val years = listOf(1900, 1984, 1994, 2000, 2016, 2022, 2024, 2042, 2048, 2100)
|
||||
val expected = listOf(false, true, false, true, true, false, true, false, true, false)
|
||||
val results = years.map { dataParser.isLeapYear(it) }
|
||||
assert(results == expected)
|
||||
assertTrue(results == expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDayOfYear returns correct day for January 1st`() {
|
||||
assert(dataParser.getDayOfYear(2024, 1, 1) == 1)
|
||||
assert(dataParser.getDayOfYear(2023, 1, 1) == 1)
|
||||
assertTrue(dataParser.getDayOfYear(2024, 1, 1) == 1)
|
||||
assertTrue(dataParser.getDayOfYear(2023, 1, 1) == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDayOfYear returns correct day for March 1st in leap and non-leap years`() {
|
||||
// 2024 is leap: Jan(31) + Feb(29) + 1 = 61
|
||||
assert(dataParser.getDayOfYear(2024, 3, 1) == 61)
|
||||
assertTrue(dataParser.getDayOfYear(2024, 3, 1) == 61)
|
||||
// 2023 is not leap: Jan(31) + Feb(28) + 1 = 60
|
||||
assert(dataParser.getDayOfYear(2023, 3, 1) == 60)
|
||||
assertTrue(dataParser.getDayOfYear(2023, 3, 1) == 60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDayOfYear returns correct day for December 31st`() {
|
||||
assert(dataParser.getDayOfYear(2024, 12, 31) == 366) // leap year
|
||||
assert(dataParser.getDayOfYear(2023, 12, 31) == 365) // non-leap year
|
||||
assertTrue(dataParser.getDayOfYear(2024, 12, 31) == 366) // leap year
|
||||
assertTrue(dataParser.getDayOfYear(2023, 12, 31) == 365) // non-leap year
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDayOfYear returns correct day for November 16th`() {
|
||||
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||
assertTrue(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check APRS passcode calculation`() {
|
||||
assert("M7LNB".aprsPasscode() == 12443)
|
||||
assert("N0CALL".aprsPasscode() == 13023)
|
||||
assertTrue("M7LNB".aprsPasscode() == 12443)
|
||||
assertTrue("N0CALL".aprsPasscode() == 13023)
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -3,12 +3,12 @@ package com.rtbishop.look4sat.core.domain
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DopplerFrequencyCalculatorTest {
|
||||
|
||||
@@ -268,6 +268,6 @@ class DopplerFrequencyCalculatorTest {
|
||||
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
||||
assertNotNull(roundTripDownlink)
|
||||
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
||||
assertTrue("Round-trip error too large: $error", error < 10000)
|
||||
assertTrue(error < 10000, "Round-trip error too large: $error")
|
||||
}
|
||||
}
|
||||
+52
-47
@@ -21,59 +21,60 @@ import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class QthConverterTest {
|
||||
|
||||
@Test
|
||||
fun `Given valid QTH returns correct POS`() {
|
||||
var result = qthToPosition("io91VL39FX")
|
||||
assert(result?.latitude == 51.499913 && result.longitude == -0.22309)
|
||||
assertTrue(result?.latitude == 51.499913 && result.longitude == -0.22309)
|
||||
result = qthToPosition("gf15vc")
|
||||
assert(result?.latitude == -34.895833 && result.longitude == -56.208333)
|
||||
assertTrue(result?.latitude == -34.895833 && result.longitude == -56.208333)
|
||||
// 8-char locators: finer 30" x 15" cell center
|
||||
result = qthToPosition("io91vl47")
|
||||
assert(result?.latitude == 51.489583 && result.longitude == -0.2125)
|
||||
assertTrue(result?.latitude == 51.489583 && result.longitude == -0.2125)
|
||||
result = qthToPosition("jn58td25")
|
||||
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
|
||||
assertTrue(result?.latitude == 48.147917 && result.longitude == 11.604167)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid QTH returns null`() {
|
||||
assert(qthToPosition("ZZ00zz") == null)
|
||||
assert(qthToPosition("JN58") == null)
|
||||
assert(qthToPosition("io9") == null)
|
||||
assert(qthToPosition("IO91VL7") == null)
|
||||
assert(qthToPosition("IO91VL4X") == null)
|
||||
assertTrue(qthToPosition("ZZ00zz") == null)
|
||||
assertTrue(qthToPosition("JN58") == null)
|
||||
assertTrue(qthToPosition("io9") == null)
|
||||
assertTrue(qthToPosition("IO91VL7") == null)
|
||||
assertTrue(qthToPosition("IO91VL4X") == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid POS returns correct QTH`() {
|
||||
// default precision is 8 chars
|
||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl47")
|
||||
assert(positionToQth(48.1466, 11.6083) == "JN58td25")
|
||||
assertTrue(positionToQth(51.4878, -0.2146) == "IO91vl47")
|
||||
assertTrue(positionToQth(48.1466, 11.6083) == "JN58td25")
|
||||
// 6-char precision still available for backwards compatibility
|
||||
assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
|
||||
assert(positionToQth(48.1466, 11.6083, 6) == "JN58td")
|
||||
assertTrue(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
|
||||
assertTrue(positionToQth(48.1466, 11.6083, 6) == "JN58td")
|
||||
// 10-char precision
|
||||
assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
|
||||
assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
|
||||
assertTrue(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
|
||||
assertTrue(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid POS returns null`() {
|
||||
assert(positionToQth(91.0542, -170.1142) == null)
|
||||
assert(positionToQth(89.0542, -240.1142) == null)
|
||||
assertTrue(positionToQth(91.0542, -170.1142) == null)
|
||||
assertTrue(positionToQth(89.0542, -240.1142) == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given boundary POS stays in valid grid`() {
|
||||
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
|
||||
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
|
||||
assertTrue(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
|
||||
// Exact positive bounds belong to the final cell, not a modulo-wrapped
|
||||
// R-field/0-square combination that decodes 10°/20° away.
|
||||
assert(positionToQth(90.0, 180.0, 8) == "RR99xx99")
|
||||
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
|
||||
assertTrue(positionToQth(90.0, 180.0, 8) == "RR99xx99")
|
||||
assertTrue(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
|
||||
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
|
||||
val positions = listOf(
|
||||
Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093),
|
||||
@@ -83,7 +84,7 @@ class QthConverterTest {
|
||||
val qth = positionToQth(lat, lon, 8)
|
||||
val pos = qthToPosition(qth!!)
|
||||
val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8)
|
||||
assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" }
|
||||
assertTrue(qth == qth2, "Roundtrip failed for ($lat, $lon): $qth -> $qth2")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,64 +114,68 @@ class QthConverterTest {
|
||||
}
|
||||
lat += 0.5
|
||||
}
|
||||
assert(worstLat <= 0.01 && worstLon <= 0.01) {
|
||||
assertTrue(
|
||||
worstLat <= 0.01 && worstLon <= 0.01,
|
||||
"roundtrip drifted by (${worstLat}, ${worstLon}) deg, worst: $worst"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given out of range longitude returns null`() {
|
||||
// Maidenhead only covers -180..180; 181..360 used to be accepted and
|
||||
// encoded into a plausible-looking locator 20-200 deg away.
|
||||
assert(positionToQth(0.0, 181.0) == null)
|
||||
assert(positionToQth(0.0, 270.0) == null)
|
||||
assert(positionToQth(0.0, 360.0) == null)
|
||||
assertTrue(positionToQth(0.0, 181.0) == null)
|
||||
assertTrue(positionToQth(0.0, 270.0) == null)
|
||||
assertTrue(positionToQth(0.0, 360.0) == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given locator with out of range field returns null`() {
|
||||
// Fields run A-R; S-X in the first pair decoded past the poles.
|
||||
assert(qthToPosition("SS00aa") == null)
|
||||
assert(qthToPosition("XX99xx") == null)
|
||||
assert(qthToPosition("AS00aa") == null)
|
||||
assert(qthToPosition("AX99xx") == null)
|
||||
assertTrue(qthToPosition("SS00aa") == null)
|
||||
assertTrue(qthToPosition("XX99xx") == null)
|
||||
assertTrue(qthToPosition("AS00aa") == null)
|
||||
assertTrue(qthToPosition("AX99xx") == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given square returns correct 3x3 neighbors`() {
|
||||
// Reference grid from the QTH Locator screenshot: OL42
|
||||
val neighbors = qthNeighbors("OL42")
|
||||
assert(neighbors == listOf(
|
||||
"OL33", "OL43", "OL53",
|
||||
"OL32", "OL42", "OL52",
|
||||
"OL31", "OL41", "OL51"
|
||||
)) { "OL42 grid mismatch: $neighbors" }
|
||||
assertTrue(
|
||||
neighbors == listOf(
|
||||
"OL33", "OL43", "OL53",
|
||||
"OL32", "OL42", "OL52",
|
||||
"OL31", "OL41", "OL51"
|
||||
),
|
||||
"OL42 grid mismatch: $neighbors"
|
||||
)
|
||||
// Center cell must be the input itself
|
||||
assert(neighbors[4] == "OL42")
|
||||
assertTrue(neighbors[4] == "OL42")
|
||||
// 9 cells, all distinct
|
||||
assert(neighbors.size == 9 && neighbors.toSet().size == 9)
|
||||
assertTrue(neighbors.size == 9 && neighbors.toSet().size == 9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given boundary square wraps fields correctly`() {
|
||||
// South-west corner: AA00 neighbors wrap to RR99 / RA90 etc.
|
||||
val sw = qthNeighbors("AA00")
|
||||
assert(sw.size == 9 && sw.toSet().size == 9)
|
||||
assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
|
||||
assertTrue(sw.size == 9 && sw.toSet().size == 9)
|
||||
assertTrue(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
|
||||
// North-east corner: RR99 wraps to AA00
|
||||
val ne = qthNeighbors("RR99")
|
||||
assert(ne.size == 9 && ne.toSet().size == 9)
|
||||
assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
|
||||
assertTrue(ne.size == 9 && ne.toSet().size == 9)
|
||||
assertTrue(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
|
||||
// Field boundary: IO91's east neighbors cross into J field
|
||||
val london = qthNeighbors("IO91")
|
||||
assert(london[2] == "JO02" && london[5] == "JO01")
|
||||
assertTrue(london[2] == "JO02" && london[5] == "JO01")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given full locator returns square part`() {
|
||||
assert(qthToSquare("OL42ih45") == "OL42")
|
||||
assert(qthToSquare("io91VL39FX") == "IO91")
|
||||
assert(qthToSquare("JN58") == "JN58")
|
||||
assert(qthToSquare("garbage!!") == "----")
|
||||
assertTrue(qthToSquare("OL42ih45") == "OL42")
|
||||
assertTrue(qthToSquare("io91VL39FX") == "IO91")
|
||||
assertTrue(qthToSquare("JN58") == "JN58")
|
||||
assertTrue(qthToSquare("garbage!!") == "----")
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -2,9 +2,9 @@ package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class TransponderMapperTest {
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* These pin the rules that decide whether a packet is legal on APRS-IS, each of which was being
|
||||
* broken by the previous one-line string template.
|
||||
*/
|
||||
class AprsBeaconTest {
|
||||
|
||||
private fun line(
|
||||
latitude: Double? = 51.5,
|
||||
longitude: Double? = -0.12,
|
||||
callsign: String = "BG7NTA",
|
||||
ssid: String = "5",
|
||||
table: String = "/",
|
||||
code: String = "[",
|
||||
comment: String = "Look4Sat"
|
||||
): String {
|
||||
val result = AprsBeacon.build(callsign, ssid, latitude, longitude, table, code, comment)
|
||||
assertTrue(result is AprsBeacon.Result.Line, "expected a line, got $result")
|
||||
return (result as AprsBeacon.Result.Line).text
|
||||
}
|
||||
|
||||
/**
|
||||
* The specification is explicit: a client-originated packet carries TCPIP* in the path,
|
||||
* "nothing more or less". The old template emitted `CALL>APRS:=...` with no path at all.
|
||||
*/
|
||||
@Test
|
||||
fun `the path is exactly TCPIP star`() {
|
||||
val text = line()
|
||||
assertTrue(text.startsWith("BG7NTA-5>APRS,TCPIP*:="), text)
|
||||
assertEquals(1, Regex(Regex.escape("TCPIP*")).findAll(text).count())
|
||||
}
|
||||
|
||||
/**
|
||||
* No position means no beacon. Substituting 0.0 put the station at 0N 0E - the Gulf of
|
||||
* Guinea - on the global network, under the operator's own callsign.
|
||||
*/
|
||||
@Test
|
||||
fun `a missing position refuses to build rather than claiming zero`() {
|
||||
assertEquals(
|
||||
AprsBeacon.Result.Blocked(AprsBeacon.Refusal.NoPosition),
|
||||
AprsBeacon.build("BG7NTA", "5", null, null, "/", "[", "x")
|
||||
)
|
||||
assertEquals(
|
||||
AprsBeacon.Result.Blocked(AprsBeacon.Refusal.NoPosition),
|
||||
AprsBeacon.build("BG7NTA", "5", 51.5, null, "/", "[", "x")
|
||||
)
|
||||
assertEquals(
|
||||
AprsBeacon.Result.Blocked(AprsBeacon.Refusal.NoPosition),
|
||||
AprsBeacon.build("BG7NTA", "5", null, -0.12, "/", "[", "x")
|
||||
)
|
||||
}
|
||||
|
||||
/** A genuine 0,0 fix is legal and must still be sent - the refusal is about absence. */
|
||||
@Test
|
||||
fun `an actual zero position is still a position`() {
|
||||
val result = AprsBeacon.build("BG7NTA", "5", 0.0, 0.0, "/", "[", "x")
|
||||
assertTrue(result is AprsBeacon.Result.Line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an impossible position is refused`() {
|
||||
val result = AprsBeacon.build("BG7NTA", "5", 91.0, 0.0, "/", "[", "x")
|
||||
assertEquals(
|
||||
AprsBeacon.Result.Blocked(AprsBeacon.Refusal.ImpossiblePosition(91.0, 0.0)),
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no callsign means no packet`() {
|
||||
assertEquals(
|
||||
AprsBeacon.Result.Blocked(AprsBeacon.Refusal.NoCallsign),
|
||||
AprsBeacon.build("", "5", 51.5, -0.12, "/", "[", "x")
|
||||
)
|
||||
assertEquals(
|
||||
AprsBeacon.Result.Blocked(AprsBeacon.Refusal.NoCallsign),
|
||||
AprsBeacon.build(" ", "5", 51.5, -0.12, "/", "[", "x")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A newline in the comment ended the packet early and started a second one from the rest.
|
||||
* The operator could trigger that by pressing return in a text field.
|
||||
*/
|
||||
@Test
|
||||
fun `a line break in the comment cannot split the packet`() {
|
||||
val text = line(comment = "hello\r\nCALL>APRS,TCPIP*:=injected")
|
||||
// Only the line break matters. The text of a second packet surviving inside the comment
|
||||
// is harmless - without a terminator the server reads one line, and a comment is free to
|
||||
// contain any printable characters the operator likes.
|
||||
assertFalse(text.contains('\n'), text)
|
||||
assertFalse(text.contains('\r'), text)
|
||||
assertEquals(1, text.lines().size, "must remain a single line")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `control characters are stripped from the comment`() {
|
||||
val text = line(comment = "a\tb\u0000c")
|
||||
assertTrue(text.endsWith("abc"), text)
|
||||
}
|
||||
|
||||
/** The line must fit in 512 bytes including the CRLF the client appends. */
|
||||
@Test
|
||||
fun `an over-long comment is trimmed to keep the line legal`() {
|
||||
val text = line(comment = "x".repeat(600))
|
||||
assertTrue(text.encodeToByteArray().size + 2 <= 512, "line was ${text.encodeToByteArray().size} bytes")
|
||||
}
|
||||
|
||||
/** The comment limit for this format is 43 characters. */
|
||||
@Test
|
||||
fun `the comment respects the format's own limit`() {
|
||||
assertEquals(AprsBeacon.MAX_COMMENT, AprsBeacon.sanitiseComment("y".repeat(100)).length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Symbol misconfiguration is, per aprs.fi, the most common reason a station never appears.
|
||||
* The table byte was previously the first character of whatever was typed.
|
||||
*/
|
||||
@Test
|
||||
fun `the symbol table falls back to the primary table when invalid`() {
|
||||
assertEquals('/', AprsBeacon.tableOf(""))
|
||||
assertEquals('/', AprsBeacon.tableOf("!"))
|
||||
assertEquals('/', AprsBeacon.tableOf(" "))
|
||||
assertEquals('/', AprsBeacon.tableOf("/"))
|
||||
assertEquals('\\', AprsBeacon.tableOf("\\"))
|
||||
// Overlays are legal: a digit or an upper-case letter selects the alternate table.
|
||||
assertEquals('7', AprsBeacon.tableOf("7"))
|
||||
assertEquals('S', AprsBeacon.tableOf("S"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the symbol code falls back when unprintable`() {
|
||||
// A house, not a car. The old fallback was '>' (CAR), which showed a phone-based beacon as
|
||||
// a vehicle for every operator who was not driving.
|
||||
assertEquals('-', AprsBeacon.codeOf(""))
|
||||
assertEquals('-', AprsBeacon.codeOf(" "))
|
||||
assertEquals('[', AprsBeacon.codeOf("["))
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates are fixed-width digits. A locale that formats decimals with a comma would
|
||||
* corrupt every position, and a Turkish locale additionally lower-cases I to a dotless i.
|
||||
*
|
||||
* The three-locale sweep this replaced needed Locale.setDefault, which is JVM-only and so
|
||||
* cannot be compiled for iOS. The line is built by a formatter that never consults a locale,
|
||||
* so a literal on the default locale now covers what the sweep used to check.
|
||||
*/
|
||||
@Test
|
||||
fun `a comma-decimal locale does not corrupt the coordinates`() {
|
||||
assertEquals("BG7NTA-5>APRS,TCPIP*:=5130.00N/00007.20W[Look4Sat", line())
|
||||
}
|
||||
|
||||
/** The whole line must be ASCII: APRS-IS is a byte protocol with no encoding negotiation. */
|
||||
@Test
|
||||
fun `the line is pure ascii`() {
|
||||
val text = line(comment = "café 北京")
|
||||
assertTrue(text.all { it.code in 0x20..0x7E }, text)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user