Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -70,4 +70,6 @@ fastlane/readme.md
|
|||||||
/app/release/output-metadata.json
|
/app/release/output-metadata.json
|
||||||
/app/release/
|
/app/release/
|
||||||
/.kotlin/sessions/
|
/.kotlin/sessions/
|
||||||
|
# Kotlin build caches in any module, not only the root - build-logic produces one too.
|
||||||
|
.kotlin/
|
||||||
.hermes/
|
.hermes/
|
||||||
@@ -15,7 +15,22 @@
|
|||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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" />
|
<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" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.rtbishop.look4sat.app
|
package com.rtbishop.look4sat.app
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
@@ -11,7 +12,10 @@ import android.content.SharedPreferences
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import com.rtbishop.look4sat.BuildConfig
|
||||||
import com.rtbishop.look4sat.MainApplication
|
import com.rtbishop.look4sat.MainApplication
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
|
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 ACTION_REPORT_NOW = AprsStore.ACTION_REPORT_NOW
|
||||||
const val CHANNEL_ID = "aprs_service"
|
const val CHANNEL_ID = "aprs_service"
|
||||||
const val NOTIF_ID = 101
|
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 val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private var reporter: AprsReporter? = null
|
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
|
private var lastState: AprsState = AprsState.Idle
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
@@ -51,6 +70,12 @@ class AprsForegroundService : Service() {
|
|||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
ACTION_STOP -> stopReporting()
|
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 -> {
|
ACTION_REPORT_NOW -> {
|
||||||
if (reporter == null) {
|
if (reporter == null) {
|
||||||
// Service not running: start it first (Toast hint when not configured)
|
// Service not running: start it first (Toast hint when not configured)
|
||||||
@@ -85,18 +110,33 @@ class AprsForegroundService : Service() {
|
|||||||
startForegroundWithNotification(cfg)
|
startForegroundWithNotification(cfg)
|
||||||
val rep = AprsReporter(
|
val rep = AprsReporter(
|
||||||
configProvider = { AprsStore.loadConfig(this) },
|
configProvider = { AprsStore.loadConfig(this) },
|
||||||
|
// The real version, so the login line cannot drift from the build again.
|
||||||
|
appVersion = BuildConfig.VERSION_NAME,
|
||||||
positionProvider = { stationPosition() },
|
positionProvider = { stationPosition() },
|
||||||
onState = { lastState = it },
|
onState = { lastState = it },
|
||||||
onReport = { report ->
|
onReport = { report ->
|
||||||
AprsStore.saveLastReport(this, report.ok, report.detail)
|
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)
|
updateNotification(cfg)
|
||||||
// Report result always surfaces: success = short Toast, failure = long Toast + reason
|
// Report result always surfaces: success = short Toast, failure = long Toast + reason
|
||||||
val msg = if (report.ok) {
|
// An unverified login needs its own message: the write succeeded, so a bare
|
||||||
getString(R.string.aprs_toast_ok)
|
// failure notice would send the operator looking at their network when the
|
||||||
} else {
|
// problem is the passcode - and APRS-IS is dropping every packet meanwhile.
|
||||||
getString(R.string.aprs_toast_fail, report.detail)
|
// 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,
|
Toast.makeText(this, msg,
|
||||||
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
|
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
@@ -104,9 +144,12 @@ class AprsForegroundService : Service() {
|
|||||||
)
|
)
|
||||||
reporter = rep
|
reporter = rep
|
||||||
rep.start()
|
rep.start()
|
||||||
|
// The reporter beacons once on start; the alarm carries every one after that.
|
||||||
|
scheduleNextTick()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopReporting() {
|
private fun stopReporting() {
|
||||||
|
cancelTicks()
|
||||||
reporter?.stop()
|
reporter?.stop()
|
||||||
reporter = null
|
reporter = null
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
@@ -117,6 +160,11 @@ class AprsForegroundService : Service() {
|
|||||||
try {
|
try {
|
||||||
val notif = buildNotification(cfg)
|
val notif = buildNotification(cfg)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
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)
|
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
} else {
|
} else {
|
||||||
startForeground(NOTIF_ID, notif)
|
startForeground(NOTIF_ID, notif)
|
||||||
@@ -197,4 +245,43 @@ class AprsForegroundService : Service() {
|
|||||||
reporter = null
|
reporter = null
|
||||||
super.onDestroy()
|
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
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
+6
@@ -62,6 +62,12 @@ internal fun Project.setupAndroidApp() {
|
|||||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||||
versionName = libs.versions.appVersionName.get()
|
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 {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
package com.rtbishop.look4sat.core.data.aprs
|
package com.rtbishop.look4sat.core.data.aprs
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsLogin
|
||||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
|
import java.io.IOException
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.io.OutputStreamWriter
|
import java.io.OutputStreamWriter
|
||||||
import java.io.PrintWriter
|
import java.io.PrintWriter
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* APRS-IS TCP client (reverse-ported from APRSdroid TcpUploader.scala).
|
* APRS-IS TCP client. Plain text: the server greets, the client logs in, then one packet per line.
|
||||||
* Plain-text protocol: one login line + one packet per line; 30 s reconnect after drop.
|
*
|
||||||
|
* 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(
|
class AprsIsClient(
|
||||||
private val host: String,
|
private val host: String,
|
||||||
@@ -18,6 +25,7 @@ class AprsIsClient(
|
|||||||
private val callsign: String,
|
private val callsign: String,
|
||||||
private val ssid: String,
|
private val ssid: String,
|
||||||
private val passcode: Int,
|
private val passcode: Int,
|
||||||
|
private val softwareName: String,
|
||||||
private val version: String,
|
private val version: String,
|
||||||
private val filter: String = "",
|
private val filter: String = "",
|
||||||
private val timeoutSec: Int = 120
|
private val timeoutSec: Int = 120
|
||||||
@@ -27,86 +35,188 @@ class AprsIsClient(
|
|||||||
private var reader: BufferedReader? = null
|
private var reader: BufferedReader? = null
|
||||||
private val lock = Any()
|
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)
|
@Throws(Exception::class)
|
||||||
fun connect() {
|
fun connect() {
|
||||||
disconnect()
|
disconnect()
|
||||||
|
loginOutcome = null
|
||||||
val s = Socket()
|
val s = Socket()
|
||||||
try {
|
try {
|
||||||
s.connect(InetSocketAddress(host, port), 30_000)
|
s.connect(InetSocketAddress(host, port), CONNECT_MS)
|
||||||
s.soTimeout = timeoutSec * 1000
|
|
||||||
s.tcpNoDelay = true
|
s.tcpNoDelay = true
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
socket = s
|
socket = s
|
||||||
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
|
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
|
||||||
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
|
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
|
||||||
}
|
}
|
||||||
// Login line
|
s.soTimeout = LOGIN_MS
|
||||||
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
|
// The spec has the client log in AFTER the server's identification line, so read the
|
||||||
writer?.println(login)
|
// greeting first. Anything starting with # is a comment and may be skipped.
|
||||||
// Read the login response (aprsc replies # logresp ... verified/unverified)
|
readGreeting(s)?.let { refusal ->
|
||||||
runCatching {
|
// The server refused before we even logged in. Previously this verdict was
|
||||||
s.soTimeout = 8000
|
// computed and then overwritten by readLoginResponse, so the branch was a lie.
|
||||||
val resp = reader?.readLine()
|
loginOutcome = refusal
|
||||||
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
throw IllegalArgumentException(refusal.detail)
|
||||||
resp.contains("unverified", ignoreCase = true))) {
|
|
||||||
throw IllegalArgumentException(resp.trim())
|
|
||||||
}
|
}
|
||||||
// Restore timeout
|
val login = AprsLogin.line(callsign, ssid, passcode, softwareName, version, filter)
|
||||||
|
writer?.print(login)
|
||||||
|
writer?.print(CRLF)
|
||||||
|
writer?.flush()
|
||||||
|
loginOutcome = readLoginResponse()
|
||||||
s.soTimeout = timeoutSec * 1000
|
s.soTimeout = timeoutSec * 1000
|
||||||
}
|
val outcome = loginOutcome
|
||||||
|
if (outcome is AprsLogin.Outcome.Rejected) throw IllegalArgumentException(outcome.detail)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Close the local socket before re-throwing, so it does not leak when
|
// Close the local socket before re-throwing so it does not leak when the failure
|
||||||
// an exception is raised after s.connect() but before socket = s.
|
// lands after connect() but before the field assignment - otherwise periodic
|
||||||
// Otherwise periodic reconnect attempts (AprsReporter every 1–60 min)
|
// reconnects accumulate file descriptors until no more can be opened.
|
||||||
// accumulate leaked fds until the process cannot open any more files.
|
|
||||||
runCatching { s.close() }
|
runCatching { s.close() }
|
||||||
|
synchronized(lock) {
|
||||||
|
writer = null
|
||||||
|
reader = null
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends one APRS packet (one line) and tries to read the server ack.
|
* Consume the server's greeting comment, returning a refusal when it is not one.
|
||||||
* Returns null=failed to send; Pair(ok, detail)=result (server error text lives in detail)
|
*
|
||||||
|
* 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>? {
|
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
val w = writer ?: return null
|
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")
|
if (w.checkError()) return Pair(false, "write failed")
|
||||||
// Read the server response inside the same lock: disconnect() (called
|
val s = socket ?: return Pair(false, "not connected")
|
||||||
// concurrently from stop()/reconnect on another thread) nulls
|
val previousTimeout = s.soTimeout
|
||||||
// writer/reader/socket and closes them. Reading outside the lock raced
|
return try {
|
||||||
// with that: the response read could hit a just-closed socket and the
|
s.soTimeout = ACK_MS
|
||||||
// swallowing runCatching reported Pair(true,"OK") for a packet that
|
classifyAck(reader?.readLine())
|
||||||
// never left, or read through a stale reference. Serialising keeps
|
} catch (timeout: SocketTimeoutException) {
|
||||||
// the read on the connection this thread just wrote to. The 3 s read
|
// Silence is the normal case: APRS-IS does not acknowledge a position report, so
|
||||||
// timeout bounds how long a concurrent disconnect waits.
|
// nothing arriving means the line went out and the server had nothing to say.
|
||||||
return runCatching {
|
// Telling this apart from a broken connection is the point of this method - the
|
||||||
val s = socket ?: return@runCatching Pair(true, "OK")
|
// previous version treated EVERY exception as success, so a dead socket reported
|
||||||
val oldTimeout = s.soTimeout
|
// "sent OK" and made every real failure invisible, including a rejected login.
|
||||||
s.soTimeout = 3000
|
Pair(true, "sent")
|
||||||
try {
|
} catch (failure: IOException) {
|
||||||
val resp = reader?.readLine()
|
Pair(false, failure.message ?: "read failed")
|
||||||
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 {
|
} finally {
|
||||||
s.soTimeout = oldTimeout
|
runCatching { s.soTimeout = previousTimeout }
|
||||||
}
|
}
|
||||||
}.getOrElse { Pair(true, "OK") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read one line (server response; throws on timeout) */
|
/**
|
||||||
fun readLine(): String? {
|
* Interpret whatever the server sent back after a packet.
|
||||||
return reader?.readLine()
|
*
|
||||||
|
* 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() {
|
fun disconnect() {
|
||||||
@@ -119,4 +229,16 @@ class AprsIsClient(
|
|||||||
socket = null
|
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
|
package com.rtbishop.look4sat.core.data.aprs
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
import com.rtbishop.look4sat.core.domain.aprs.AprsBeacon
|
||||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPosition
|
import com.rtbishop.look4sat.core.domain.aprs.AprsPasscode
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -34,12 +33,39 @@ data class AprsReport(
|
|||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val packet: String,
|
val packet: String,
|
||||||
val ok: Boolean,
|
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 */
|
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
|
||||||
class AprsReporter(
|
class AprsReporter(
|
||||||
private val configProvider: () -> AprsConfig,
|
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 positionProvider: () -> Pair<Double, Double>? = { null },
|
||||||
private val onState: (AprsState) -> Unit = {},
|
private val onState: (AprsState) -> Unit = {},
|
||||||
private val onReport: (AprsReport) -> Unit = {}
|
private val onReport: (AprsReport) -> Unit = {}
|
||||||
@@ -59,12 +85,11 @@ class AprsReporter(
|
|||||||
onState(AprsState.Error)
|
onState(AprsState.Error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
job = scope.launch {
|
// One report now; the service's exact alarm drives every one after this. The loop that
|
||||||
while (isActive) {
|
// used to live here relied on a coroutine delay, which Doze defeats - the timer fires on
|
||||||
reportOnce()
|
// schedule and then finds network access suspended, so the beacon stopped whenever the
|
||||||
delay(cfg.intervalMin.coerceAtLeast(1) * 60_000L)
|
// screen locked while the notification still claimed it was running.
|
||||||
}
|
job = scope.launch { reportOnce() }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
@@ -86,41 +111,92 @@ class AprsReporter(
|
|||||||
if (!cfg.enabled || cfg.callsign.isBlank()) return
|
if (!cfg.enabled || cfg.callsign.isBlank()) return
|
||||||
onState(AprsState.Connecting)
|
onState(AprsState.Connecting)
|
||||||
try {
|
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(
|
val c = client ?: AprsIsClient(
|
||||||
host = cfg.server,
|
host = cfg.server,
|
||||||
port = cfg.port,
|
port = cfg.port,
|
||||||
callsign = cfg.callsign,
|
callsign = cfg.callsign,
|
||||||
ssid = cfg.ssid,
|
ssid = cfg.ssid,
|
||||||
passcode = cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(cfg.callsign),
|
// Never derives one: a blank or wrong entry logs in receive-only rather than
|
||||||
version = "Look4Sat 4.5.4"
|
// 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 }
|
).also { client = it }
|
||||||
if (!c.isConnected) c.connect()
|
if (!c.isConnected) c.connect()
|
||||||
onState(AprsState.Connected)
|
onState(AprsState.Connected)
|
||||||
|
|
||||||
val pos = positionProvider()
|
|
||||||
val packetLine = buildPositionPacket(cfg, pos?.first, pos?.second)
|
|
||||||
val result = c.sendPacket(packetLine)
|
val result = c.sendPacket(packetLine)
|
||||||
val ok = result?.first == true
|
val sent = result?.first == true
|
||||||
val detail = result?.second ?: "no connection"
|
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)
|
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
runCatching { client?.disconnect() }
|
runCatching { client?.disconnect() }
|
||||||
client = null
|
client = null
|
||||||
onState(AprsState.Error)
|
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 {
|
/** A short reason for a refusal, for the operator's last-report line. */
|
||||||
val source = AprsPacket.formatCallSsid(cfg.callsign, cfg.ssid)
|
private fun refusalDetail(refusal: AprsBeacon.Refusal): String = when (refusal) {
|
||||||
val pos = AprsPosition(
|
AprsBeacon.Refusal.NoPosition -> "no position yet"
|
||||||
latitude = lat ?: 0.0,
|
AprsBeacon.Refusal.NoCallsign -> "no callsign set"
|
||||||
longitude = lon ?: 0.0,
|
is AprsBeacon.Refusal.ImpossiblePosition -> "position out of range"
|
||||||
symbolTable = cfg.symbolTable.firstOrNull() ?: '/',
|
|
||||||
symbolCode = cfg.symbolCode.firstOrNull() ?: '>'
|
|
||||||
)
|
|
||||||
return "$source>APRS:=${pos.toUncompressedString()}${cfg.statusText}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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_STATUS = "status"
|
||||||
private const val KEY_SYMBOL_TABLE = "symbol_table"
|
private const val KEY_SYMBOL_TABLE = "symbol_table"
|
||||||
private const val KEY_SYMBOL_CODE = "symbol_code"
|
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_TIME = "last_report_time"
|
||||||
private const val KEY_LAST_OK = "last_report_ok"
|
private const val KEY_LAST_OK = "last_report_ok"
|
||||||
private const val KEY_LAST_DETAIL = "last_report_detail"
|
private const val KEY_LAST_DETAIL = "last_report_detail"
|
||||||
@@ -41,7 +50,7 @@ object AprsStore {
|
|||||||
intervalMin = p.getInt(KEY_INTERVAL, 5),
|
intervalMin = p.getInt(KEY_INTERVAL, 5),
|
||||||
statusText = p.getString(KEY_STATUS, "Look4Sat APRS") ?: "Look4Sat APRS",
|
statusText = p.getString(KEY_STATUS, "Look4Sat APRS") ?: "Look4Sat APRS",
|
||||||
symbolTable = p.getString(KEY_SYMBOL_TABLE, "/") ?: "/",
|
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 android.util.Log
|
||||||
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
|
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
|
||||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
|
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.CwDeepSpectrogram
|
||||||
import com.rtbishop.look4sat.core.domain.cw.CwDetectionPool
|
import com.rtbishop.look4sat.core.domain.cw.CwDetectionPool
|
||||||
import com.rtbishop.look4sat.core.domain.cw.CwShiftDecider
|
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.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.nio.FloatBuffer
|
import java.nio.FloatBuffer
|
||||||
@@ -64,13 +66,28 @@ class CwDeepDecoder(
|
|||||||
private val isToneShiftEnabled: () -> Boolean = { false }
|
private val isToneShiftEnabled: () -> Boolean = { false }
|
||||||
) : ICwDecoder {
|
) : 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 TAG = "CwDeepDecoder"
|
||||||
const val MODEL_ASSET = "deepcw/model.onnx"
|
const val MODEL_ASSET = "deepcw/model.onnx"
|
||||||
const val METADATA_ASSET = "deepcw/model.onnx.json"
|
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()
|
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,9 +120,20 @@ class CwDeepDecoder(
|
|||||||
private val _decodedText = MutableStateFlow("")
|
private val _decodedText = MutableStateFlow("")
|
||||||
override val decodedText: StateFlow<String> = _decodedText.asStateFlow()
|
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("")
|
private val _historyText = MutableStateFlow("")
|
||||||
override val historyText: StateFlow<String> = _historyText.asStateFlow()
|
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)
|
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||||
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
|
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
|
||||||
|
|
||||||
@@ -127,18 +155,54 @@ class CwDeepDecoder(
|
|||||||
private val buffer = CwDeepBuffer()
|
private val buffer = CwDeepBuffer()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then
|
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then is decoded
|
||||||
* is decoded once and appended to [historyText]. Archiving in ~15 s chunks
|
* once and appended to [historyText]. The batch stays short enough that the record does
|
||||||
* keeps the extra inference cheap (short window) while long enough to be
|
* not visibly stall, and accuracy barely suffers: this content has already been through
|
||||||
* decoded accurately — the content has already been through the 20 s window
|
* the 20 s window many times, so the archive decode is a confirmation, not a first look.
|
||||||
* many times, so a slightly shorter archive decode loses almost nothing.
|
*
|
||||||
|
* 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 val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE)
|
||||||
private var archiveSize = 0
|
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. */
|
/** Held while inference runs so slow devices skip work instead of queuing it. */
|
||||||
private val inferenceLock = Mutex()
|
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. */
|
/** Decides what shift to apply from successive tone estimates. */
|
||||||
private val shiftDecider = CwShiftDecider(SHIFT_HYSTERESIS_HZ)
|
private val shiftDecider = CwShiftDecider(SHIFT_HYSTERESIS_HZ)
|
||||||
|
|
||||||
@@ -157,6 +221,17 @@ class CwDeepDecoder(
|
|||||||
/** Carries Hilbert filter history and mixer phase across capture chunks. */
|
/** Carries Hilbert filter history and mixer phase across capture chunks. */
|
||||||
private val streamingShifter = CwToneShifter.Streaming()
|
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.
|
* 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
|
* 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 (samples.isEmpty()) return
|
||||||
if (!ensureLoaded()) 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(
|
val resampled = CwDeepSpectrogram.resampleLinear(
|
||||||
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
bandLimited, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||||
)
|
)
|
||||||
val prepared = applyToneShift(resampled)
|
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)
|
val shouldRedecode = buffer.append(prepared)
|
||||||
|
|
||||||
// Archive audio that scrolled out of the live window. It is decoded once
|
// 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
|
* 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.
|
* 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() {
|
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()
|
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)
|
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
|
* 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 —
|
* append it to [historyText]. Unlike the live window this never replaces —
|
||||||
* the archived audio is final, so its text is permanent.
|
* 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 activeSession = session ?: return@withContext
|
||||||
val activeEnvironment = environment ?: return@withContext
|
val activeEnvironment = environment ?: return@withContext
|
||||||
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
|
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
|
||||||
val spectrogram = CwDeepSpectrogram.compute(audio)
|
val spectrogram = CwDeepSpectrogram.compute(audio)
|
||||||
val text = runInference(activeSession, activeEnvironment, spectrogram)
|
val text = runInference(activeSession, activeEnvironment, spectrogram)
|
||||||
if (text.isNotEmpty()) {
|
committedText += text
|
||||||
_historyText.value += text
|
_historyText.value = committedText
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
|
/** 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
|
_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() {
|
override fun reset() {
|
||||||
|
antiAlias?.reset()
|
||||||
buffer.reset()
|
buffer.reset()
|
||||||
|
synchronized(retiredAudio) { retiredAudio.clear() }
|
||||||
_decodedText.value = ""
|
_decodedText.value = ""
|
||||||
_historyText.value = ""
|
_historyText.value = ""
|
||||||
|
committedText = ""
|
||||||
archiveSize = 0
|
archiveSize = 0
|
||||||
_estimatedPitch.value = null
|
_estimatedPitch.value = null
|
||||||
_detectedToneHz.value = null
|
_detectedToneHz.value = null
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
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 okhttp3.OkHttpClient
|
||||||
import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo
|
import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo
|
||||||
|
|
||||||
@@ -134,6 +136,22 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
|
|
||||||
override fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo = lotwRepo
|
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 {
|
override fun provideBluetoothReporter(): IReporter {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val rc = settingsRepo.rcSettings.value
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-15
@@ -42,6 +42,17 @@ class DatabaseRepo(
|
|||||||
|
|
||||||
private val customSourceType = "Other"
|
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) {
|
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||||
var importedCount = 0
|
var importedCount = 0
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
@@ -67,26 +78,46 @@ class DatabaseRepo(
|
|||||||
|
|
||||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||||
val tleUrls = buildMap {
|
// A custom URL REPLACES the built-in sources rather than joining them. The previous map
|
||||||
putAll(Sources.satelliteDataUrls)
|
// overwrote only the "All" value and still fetched the other 26, so switching this on meant
|
||||||
// Switch on + non-empty URL -> All uses the custom URL; otherwise the default URL (online-update default source)
|
// "my source AND yours" - which defeats the reasons for setting one: a mirror, a filtered
|
||||||
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
|
// subset, an offline server, or a network where Celestrak is unreachable. On a blocked link
|
||||||
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
|
// the real behaviour was 26 failing requests.
|
||||||
}.filterValues { it.isNotBlank() }
|
//
|
||||||
val radioUrls = buildMap {
|
// Satellites already stored do not disappear: insertEntries is OnConflictStrategy.REPLACE
|
||||||
putAll(Sources.transceiversDataUrls)
|
// and nothing is deleted before the insert, so rows the new source does not mention survive.
|
||||||
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
|
// The type index for the skipped keys goes stale rather than empty, which is the honest
|
||||||
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
|
// outcome - it is the last known membership, not a claim about this fetch.
|
||||||
}.filterValues { it.isNotBlank() }
|
//
|
||||||
|
// 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
|
// launch all network requests concurrently
|
||||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
val radioJobs = radioUrls.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 tleResults = tleJobs.awaitAll()
|
val tleResults = tleJobs.awaitAll()
|
||||||
val radioResults = radioJobs.awaitAll()
|
val radioResults = radioJobs.awaitAll()
|
||||||
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
|
// Orbital elements are counted on their own. A combined count let a successful transceivers
|
||||||
if (successCount == 0) {
|
// fetch stand in for a failed orbital one: with a custom TLE URL there are two requests
|
||||||
throw java.io.IOException("All data sources failed to download")
|
// 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
|
// parse fetched data concurrently and associate with types
|
||||||
val importedEntries = tleResults.flatMap { (url, stream) ->
|
val importedEntries = tleResults.flatMap { (url, stream) ->
|
||||||
|
|||||||
+36
-10
@@ -455,18 +455,44 @@ class SettingsRepo(
|
|||||||
_dataSourcesSettings.value = settings
|
_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 {
|
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,
|
migratePlaceholderUrls()
|
||||||
// otherwise the online All/SatNOGS sources would point at the wrong address and fail to update
|
// The switch is reported as the operator set it. It used to be ANDed with
|
||||||
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
|
// `url != default`, so typing the default URL by hand switched custom sources off by
|
||||||
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
|
// itself and the settings screen showed a state nobody had chosen.
|
||||||
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
|
|
||||||
return DataSourcesSettings(
|
return DataSourcesSettings(
|
||||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
|
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
||||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
|
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
||||||
tleUrl = tleUrl,
|
tleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl,
|
||||||
transceiversUrl = txUrl
|
transceiversUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl)
|
||||||
|
?: Sources.defaultTransceiversUrl
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,4 +29,8 @@ class ShowToast(private val context: Context) : IShowToast {
|
|||||||
override fun invoke(resId: Int) {
|
override fun invoke(resId: Int) {
|
||||||
invoke(context.getString(resId))
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
-3
@@ -18,6 +18,7 @@
|
|||||||
package com.rtbishop.look4sat.core.data.repository
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
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.DatabaseState
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
@@ -102,8 +103,96 @@ class DatabaseRepoTest {
|
|||||||
repository.updateFromRemote()
|
repository.updateFromRemote()
|
||||||
|
|
||||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
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
|
// A custom URL replaces the built-in TLE sources: none of them is requested. The
|
||||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
// 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"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 { networkStreams[it] = { validCsvStream() } }
|
||||||
|
}
|
||||||
|
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 { networkStreams[it] = { "[]".byteInputStream() } }
|
||||||
|
}
|
||||||
|
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 validCsvStream(): InputStream = """
|
private fun validCsvStream(): InputStream = """
|
||||||
@@ -122,9 +211,15 @@ private class FakeRemoteSource : IRemoteSource {
|
|||||||
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||||
|
|
||||||
|
/** Every URL asked for, so a test can assert WHICH sources were fetched, not just the result. */
|
||||||
|
val requestedUrls = mutableListOf<String>()
|
||||||
|
|
||||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
override suspend fun getNetworkStream(url: String): InputStream? {
|
||||||
|
requestedUrls += url
|
||||||
|
return networkStreams[url]?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getAmSatCatalog(): String? = null
|
override suspend fun getAmSatCatalog(): String? = null
|
||||||
|
|
||||||
|
|||||||
@@ -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.toByteArray().size - body.toByteArray().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 = '-'
|
||||||
|
}
|
||||||
@@ -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(',') ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,12 +22,6 @@ object AprsPacket {
|
|||||||
return hash and 0x7FFF
|
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) */
|
/** Callsign-SSID join (BG7NTA + 5 -> BG7NTA-5) */
|
||||||
fun formatCallSsid(callsign: String, ssid: String): String {
|
fun formatCallSsid(callsign: String, ssid: String): String {
|
||||||
if (ssid.isNullOrEmpty()) return callsign
|
if (ssid.isNullOrEmpty()) return callsign
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,9 +37,11 @@ interface ICwDecoder {
|
|||||||
val decodedText: StateFlow<String>
|
val decodedText: StateFlow<String>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permanent transcript of everything that has scrolled out of the live
|
* Transcript of audio that has been archived, and will not be revised.
|
||||||
* window. Unlike [decodedText] this only ever grows (until [reset]); it is
|
*
|
||||||
* what the user reads back after a signal has passed.
|
* 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>
|
val historyText: StateFlow<String>
|
||||||
|
|
||||||
@@ -74,6 +76,17 @@ interface ICwDecoder {
|
|||||||
/** Non-null when the decoder cannot run, for example the model failed to load. */
|
/** Non-null when the decoder cannot run, for example the model failed to load. */
|
||||||
val errorMessage: StateFlow<String?>
|
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. */
|
/** Feed captured mono PCM in -1..1. Safe to call from a capture thread. */
|
||||||
suspend fun processBuffer(samples: FloatArray, sampleRate: Int)
|
suspend fun processBuffer(samples: FloatArray, sampleRate: Int)
|
||||||
|
|
||||||
|
|||||||
@@ -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,107 @@
|
|||||||
|
/*
|
||||||
|
* 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 because org.json is compileOnly here - it is supplied by Android at
|
||||||
|
* runtime and absent from unit tests, so a JSON path could not be tested.
|
||||||
|
*/
|
||||||
|
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}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/* QrzGridClient.kt - QRZ callsign grid scraper (4.5.5, pure JVM in domain).
|
|
||||||
* How it works (verified): GET https://www.qrz.com/db/{callsign} with the user's QRZ login Cookie,
|
|
||||||
* the Detail table on the page holds <td class="dh">Grid Square</td><td class="di">XXX</td>.
|
|
||||||
* Without cookies the Detail is unavailable (confirmed); if the other station has no grid, the row is absent (null).
|
|
||||||
* The Cookie is pasted by the user in settings (EditThisCookie JSON export or a raw cookie string),
|
|
||||||
* never built into the app.
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.qrz
|
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.net.URL
|
|
||||||
|
|
||||||
object QrzGridClient {
|
|
||||||
|
|
||||||
/** Parse the pasted Cookie (both formats): EditThisCookie JSON array or raw "k=v; k=v" string */
|
|
||||||
fun parseCookies(raw: String): String {
|
|
||||||
val text = raw.trim()
|
|
||||||
if (text.isEmpty()) return ""
|
|
||||||
// JSON array format: [{"name":"qz_userid","value":"1266043",...}, ...]
|
|
||||||
if (text.startsWith("[")) {
|
|
||||||
return try {
|
|
||||||
val arr = org.json.JSONArray(text)
|
|
||||||
val parts = mutableListOf<String>()
|
|
||||||
for (i in 0 until arr.length()) {
|
|
||||||
val o = arr.getJSONObject(i)
|
|
||||||
val name = o.optString("name")
|
|
||||||
val value = o.optString("value")
|
|
||||||
if (name.isNotBlank()) parts.add("$name=$value")
|
|
||||||
}
|
|
||||||
parts.joinToString("; ")
|
|
||||||
} catch (_: Exception) { text }
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Detect the callsign logged in with these cookies (fetch db home, extract the account menu callsign). Null on failure */
|
|
||||||
suspend fun fetchOwnCallsign(cookieHeader: String): String? = withContext(Dispatchers.IO) {
|
|
||||||
if (cookieHeader.isBlank()) return@withContext null
|
|
||||||
try {
|
|
||||||
val url = URL("https://www.qrz.com/db/")
|
|
||||||
val conn = url.openConnection()
|
|
||||||
conn.connectTimeout = 10_000
|
|
||||||
conn.readTimeout = 20_000
|
|
||||||
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
|
|
||||||
conn.setRequestProperty("Cookie", cookieHeader)
|
|
||||||
val html = conn.getInputStream().bufferedReader().use { it.readText() }
|
|
||||||
// Logged-in account menu (verified): <li class="leaf last" onclick="return true">BG7NTA <ul class="sub">
|
|
||||||
val pattern = Regex("<li class=\"leaf last\"[^>]*>\\s*([A-Z0-9/]+)\\s*<ul")
|
|
||||||
pattern.find(html)?.groupValues?.get(1)?.trim()
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Look up a callsign's grid. Null = not set / not found (silent, does not block logging) */
|
|
||||||
suspend fun lookupGrid(callsign: String, cookieHeader: String): String? = withContext(Dispatchers.IO) {
|
|
||||||
if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext null
|
|
||||||
try {
|
|
||||||
val url = URL("https://www.qrz.com/db/${callsign.trim().uppercase()}")
|
|
||||||
val conn = url.openConnection()
|
|
||||||
conn.connectTimeout = 10_000
|
|
||||||
conn.readTimeout = 20_000
|
|
||||||
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
|
|
||||||
conn.setRequestProperty("Cookie", cookieHeader)
|
|
||||||
val html = conn.getInputStream().bufferedReader().use { it.readText() }
|
|
||||||
// Detail table Grid Square row (verified format)
|
|
||||||
val m = Regex("""<td class="dh">Grid Square</td><td class="di">([^<]+)</td>""")
|
|
||||||
.find(html)
|
|
||||||
m?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
@@ -50,6 +50,9 @@ interface IMainContainer {
|
|||||||
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
||||||
|
|
||||||
fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
|
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(
|
data class MutualPassData(
|
||||||
|
|||||||
@@ -22,4 +22,12 @@ interface IShowToast {
|
|||||||
|
|
||||||
/** Show by resource ID (four-language text) */
|
/** Show by resource ID (four-language text) */
|
||||||
operator fun invoke(resId: Int)
|
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)
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
+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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -151,27 +151,62 @@ object WaveLogApi {
|
|||||||
return if (up == down) "" else "$up/$down"
|
return if (up == down) "" else "$up/$down"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */
|
/**
|
||||||
fun normalizeSatName(raw: String): String {
|
* The name LoTW accepts for this satellite, resolved from its catalogue number when known.
|
||||||
val main = raw.substringBefore('(').trim()
|
*
|
||||||
.ifBlank { raw.trim() }
|
* LoTW rejects a QSO whose SAT_NAME is not spelled as its accepted list has it - its help
|
||||||
.uppercase(Locale.ENGLISH)
|
* page gives AO7 against AO-7 as an example - so this has to produce the exact spelling or
|
||||||
// Matching logic (mirrors WaveLog satellite table name/displayname matching + LoTW list):
|
* nothing useful at all.
|
||||||
// 1. Already in LoTW list (common TLE name == common name) -> return as-is
|
*
|
||||||
// 2. Not present -> check Celestrak alias map (SAUDISAT 1C -> SO-50 etc.); mapped name must be in LoTW list
|
* [catnum] is preferred because the name alone cannot decide it: TLE sources disagree, and
|
||||||
// 3. Still unmatched -> return as-is (uploads are not blocked; QSO is still saved)
|
* of the 49 satellites carried by both Celestrak amateur and AMSAT nasabare, 33 are named
|
||||||
val commonName = mapOf(
|
* differently. NORAD 43017 is "RADFXSAT (FOX-1B)" in one and "AO-91" in the other, 43700 is
|
||||||
"ZARYA" to "ARISS",
|
* "ES'HAIL 2" against "QO-100". Deriving the name from the TLE text resolved 0 of 96
|
||||||
"ARISS" to "ARISS",
|
* satellites to something LoTW accepts, because the descriptive part of a TLE name is never
|
||||||
"FUNCUBE-1" to "AO-73",
|
* the OSCAR designator.
|
||||||
"DIWATA-2B" to "PO-101",
|
*
|
||||||
"SAUDISAT-1C" to "SO-50",
|
* The name path remains as a fallback for QSOs logged before the catalogue number was
|
||||||
"SAUDISAT 1C" to "SO-50",
|
* recorded. It tries the whole name, then either side of the parentheses, since which side
|
||||||
"DIWATA-2A" to "PO-101"
|
* carries the designator varies - "SAUDISAT 1C (SO-50)" has it inside, "ISS (ZARYA)" does not.
|
||||||
)
|
*/
|
||||||
val candidate = commonName[main] ?: main
|
fun normalizeSatName(raw: String, catnum: Int? = null): String {
|
||||||
return if (candidate in LotwSatellites.names) candidate else main
|
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(Locale.ENGLISH)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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(Locale.ENGLISH)
|
||||||
|
|
||||||
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
|
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
|
||||||
suspend fun postQso(
|
suspend fun postQso(
|
||||||
@@ -184,7 +219,7 @@ object WaveLogApi {
|
|||||||
val base = normalizeUrl(url)
|
val base = normalizeUrl(url)
|
||||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||||
|
|
||||||
val satName = normalizeSatName(qso.satName)
|
val satName = normalizeSatName(qso.satName, qso.catnum.takeIf { it > 0 })
|
||||||
|
|
||||||
// v2: POST /index.php/api/v2/qso (JSON fields)
|
// v2: POST /index.php/api/v2/qso (JSON fields)
|
||||||
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
|
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
|
||||||
@@ -203,9 +238,19 @@ object WaveLogApi {
|
|||||||
put("sat_name", satName)
|
put("sat_name", satName)
|
||||||
if (satMode.isNotBlank()) put("sat_mode", satMode)
|
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", "POST", apiKey, v2Body.toString())
|
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
|
||||||
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
|
val v2Verdict = WavelogResponse.verdict(code, resp)
|
||||||
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
|
when (v2Verdict) {
|
||||||
|
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v2")
|
||||||
|
WavelogResponse.Verdict.Duplicate -> return@withContext 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)
|
// v1: POST /index.php/api/qso (key in body + ADIF)
|
||||||
val v1Body = JSONObject().apply {
|
val v1Body = JSONObject().apply {
|
||||||
@@ -215,13 +260,39 @@ object WaveLogApi {
|
|||||||
put("string", toAdif(qso, gridsquare, satName))
|
put("string", toAdif(qso, gridsquare, satName))
|
||||||
}
|
}
|
||||||
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
|
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
|
||||||
if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
val v1Verdict = WavelogResponse.verdict(code1, resp1)
|
||||||
|
when (v1Verdict) {
|
||||||
|
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1")
|
||||||
|
WavelogResponse.Verdict.Duplicate -> return@withContext 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
|
// v1 without index.php, for a server whose rewrite rules differ
|
||||||
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
|
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
|
||||||
if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
when (val verdict = WavelogResponse.verdict(code1b, resp1b)) {
|
||||||
|
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1")
|
||||||
|
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
|
||||||
|
is WavelogResponse.Verdict.Rejected ->
|
||||||
|
return@withContext WavelogResult.Failure(verdict.reason)
|
||||||
|
is WavelogResponse.Verdict.Unreadable -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}")
|
// 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() }
|
||||||
|
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) */
|
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ data class WavelogQso(
|
|||||||
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
|
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
|
||||||
val freqRxHz: Long, // 下行
|
val freqRxHz: Long, // 下行
|
||||||
val satName: String,
|
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 sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
|
||||||
val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到
|
val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到
|
||||||
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
|
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
|
||||||
@@ -48,6 +56,7 @@ class WavelogQueue(private val store: IWavelogQueueStore) {
|
|||||||
freqTxHz = o.optLong("freqTxHz"),
|
freqTxHz = o.optLong("freqTxHz"),
|
||||||
freqRxHz = o.optLong("freqRxHz"),
|
freqRxHz = o.optLong("freqRxHz"),
|
||||||
satName = o.optString("satName"),
|
satName = o.optString("satName"),
|
||||||
|
catnum = o.optInt("catnum", 0),
|
||||||
sessionId = o.optString("sessionId"),
|
sessionId = o.optString("sessionId"),
|
||||||
gridsquare = o.optString("gridsquare"),
|
gridsquare = o.optString("gridsquare"),
|
||||||
uploaded = o.optBoolean("uploaded", false)
|
uploaded = o.optBoolean("uploaded", false)
|
||||||
@@ -99,6 +108,7 @@ class WavelogQueue(private val store: IWavelogQueueStore) {
|
|||||||
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
|
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
|
||||||
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
|
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
|
||||||
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
|
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
|
||||||
|
put("catnum", q.catnum)
|
||||||
put("sessionId", q.sessionId)
|
put("sessionId", q.sessionId)
|
||||||
put("gridsquare", q.gridsquare)
|
put("gridsquare", q.gridsquare)
|
||||||
put("uploaded", q.uploaded)
|
put("uploaded", q.uploaded)
|
||||||
|
|||||||
+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 with JSONObject on purpose: org.json is compileOnly in core:domain,
|
||||||
|
* so a JVM unit test gets the stub and every assertion against it would be vacuous.
|
||||||
|
*/
|
||||||
|
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")
|
||||||
|
}
|
||||||
+31
-10
@@ -13,12 +13,32 @@ import org.json.JSONObject
|
|||||||
|
|
||||||
sealed class UploadOutcome {
|
sealed class UploadOutcome {
|
||||||
data class NeedConfirm(val stationGrid: String, val userGrid: String) : 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(
|
data class Done(
|
||||||
val successCount: Int,
|
val successCount: Int,
|
||||||
val failedCount: Int,
|
val failedCount: Int,
|
||||||
val message: String,
|
val reason: Reason = Reason.COMPLETED,
|
||||||
val firstError: String = ""
|
val firstError: String = ""
|
||||||
) : UploadOutcome()
|
) : 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(
|
class WavelogUploader(
|
||||||
@@ -36,13 +56,13 @@ class WavelogUploader(
|
|||||||
val apiKey = settings.wavelogApiKey
|
val apiKey = settings.wavelogApiKey
|
||||||
val stationId = settings.wavelogStationId
|
val stationId = settings.wavelogStationId
|
||||||
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
|
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
|
// 1. Fetch station info (station grid); fall back to user QTH when v1 lacks the endpoint
|
||||||
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
|
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
|
||||||
if (stationGrid.isNullOrBlank()) {
|
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
|
// 2. Grid check: cloud station grid first 4 chars vs current station QTH first 4 chars
|
||||||
@@ -56,22 +76,23 @@ class WavelogUploader(
|
|||||||
|
|
||||||
// 3. Upload one by one. ADIF gridsquare = counterpart grid (the QSO partner); blank until the scraper lands
|
// 3. Upload one by one. ADIF gridsquare = counterpart grid (the QSO partner); blank until the scraper lands
|
||||||
val entries = queue.all()
|
val entries = queue.all()
|
||||||
var ok = 0
|
var uploaded = 0
|
||||||
var fail = 0
|
var failed = 0
|
||||||
var firstError = ""
|
var firstError = ""
|
||||||
for (qso in entries) {
|
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)
|
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, qso.gridsquare)
|
||||||
if (result is WavelogResult.Success) {
|
if (result is WavelogResult.Success) {
|
||||||
ok++
|
uploaded++
|
||||||
queue.markUploaded(qso.id)
|
queue.markUploaded(qso.id)
|
||||||
} else {
|
} else {
|
||||||
fail++
|
failed++
|
||||||
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
|
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
|
return UploadOutcome.Done(uploaded, failed, UploadOutcome.Reason.COMPLETED, firstError)
|
||||||
return UploadOutcome.Done(ok, fail, message, firstError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
|
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.aprs
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 val original: Locale = Locale.getDefault()
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun restoreLocale() {
|
||||||
|
Locale.setDefault(original)
|
||||||
|
}
|
||||||
|
|
||||||
|
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("expected a line, got $result", result is AprsBeacon.Result.Line)
|
||||||
|
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, text.startsWith("BG7NTA-5>APRS,TCPIP*:="))
|
||||||
|
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, text.contains('\n'))
|
||||||
|
assertFalse(text, text.contains('\r'))
|
||||||
|
assertEquals("must remain a single line", 1, text.lines().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `control characters are stripped from the comment`() {
|
||||||
|
val text = line(comment = "a\tb\u0000c")
|
||||||
|
assertTrue(text, text.endsWith("abc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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("line was ${text.toByteArray().size} bytes", text.toByteArray().size + 2 <= 512)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a comma-decimal locale does not corrupt the coordinates`() {
|
||||||
|
val reference = line()
|
||||||
|
for (tag in listOf("de-DE", "tr-TR", "fr-FR")) {
|
||||||
|
Locale.setDefault(Locale.forLanguageTag(tag))
|
||||||
|
assertEquals("locale $tag changed the packet", reference, 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, text.all { it.code in 0x20..0x7E })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.aprs
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The verified/unverified distinction is the point of these tests: an unverified client stays
|
||||||
|
* connected and its writes keep succeeding while the server discards every packet, so getting this
|
||||||
|
* wrong means the operator watches reports "succeed" for hours with nothing landing.
|
||||||
|
*
|
||||||
|
* Several are transcriptions of live aprsc 2.1.21 traffic rather than guesses, because an earlier
|
||||||
|
* version of this file froze a wrong login format as correct and that defect survived nine commits
|
||||||
|
* and two audits.
|
||||||
|
*/
|
||||||
|
class AprsLoginTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `builds the line the specification asks for`() {
|
||||||
|
assertEquals(
|
||||||
|
"user BG7NTA-5 pass 12345 vers Look4Sat 4.6.0",
|
||||||
|
AprsLogin.line("BG7NTA", "5", 12345, "Look4Sat", "4.6.0")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The format a live server rejects, and the reason this file was rewritten.
|
||||||
|
*
|
||||||
|
* sent: user N0CALL pass -1 vers Look4Sat-4.5.4
|
||||||
|
* got: # Invalid login: software name and version are not separated by a space
|
||||||
|
*
|
||||||
|
* `vers` is two tokens. The previous code hyphenated the space between them to satisfy the rule
|
||||||
|
* that the software NAME contains no space, and the test asserted that as correct.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the software name and version stay separate tokens`() {
|
||||||
|
val line = AprsLogin.line("N0CALL", "", AprsLogin.RECEIVE_ONLY_PASSCODE, "Look4Sat", "4.5.4")
|
||||||
|
assertEquals("user N0CALL pass -1 vers Look4Sat 4.5.4", line)
|
||||||
|
assertEquals(7, line.split(" ").size)
|
||||||
|
assertTrue("name and version must not be joined", !line.contains("Look4Sat-4.5.4"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No SSID means no hyphen; the spec says never to write -0 explicitly. */
|
||||||
|
@Test
|
||||||
|
fun `omits the ssid when there is none`() {
|
||||||
|
assertEquals(
|
||||||
|
"user BG7NTA pass 12345 vers Look4Sat 4.6.0",
|
||||||
|
AprsLogin.line("BG7NTA", "", 12345, "Look4Sat", "4.6.0")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whitespace inside either token would shift every field after it, so it is collapsed. */
|
||||||
|
@Test
|
||||||
|
fun `whitespace within a token is collapsed rather than passed through`() {
|
||||||
|
val line = AprsLogin.line("BG7NTA", "5", 12345, "Look 4 Sat", "4.6.0 beta")
|
||||||
|
assertEquals("user BG7NTA-5 pass 12345 vers Look-4-Sat 4.6.0-beta", line)
|
||||||
|
assertEquals(7, line.split(" ").size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `appends a filter when one is given`() {
|
||||||
|
val line = AprsLogin.line("BG7NTA", "5", 12345, "Look4Sat", "4.6.0", "r/33.25/-96.5/50")
|
||||||
|
assertEquals("user BG7NTA-5 pass 12345 vers Look4Sat 4.6.0 r/33.25/-96.5/50", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `receive-only logins use the documented passcode`() {
|
||||||
|
val line = AprsLogin.line("BG7NTA", "", AprsLogin.RECEIVE_ONLY_PASSCODE, "Look4Sat", "4.6.0")
|
||||||
|
assertEquals("user BG7NTA pass -1 vers Look4Sat 4.6.0", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty tokens would produce a malformed line, so each has a fallback. */
|
||||||
|
@Test
|
||||||
|
fun `empty name or version does not produce a malformed line`() {
|
||||||
|
val line = AprsLogin.line("BG7NTA", "", 12345, "", "")
|
||||||
|
assertEquals("user BG7NTA pass 12345 vers Look4Sat 0", line)
|
||||||
|
assertEquals(7, line.split(" ").size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The refusal that used to be invisible. It is a comment but not a logresp, so it was skipped
|
||||||
|
* as chatter; the login then timed out into Unknown, which is treated as "may be working", and
|
||||||
|
* every send afterwards reported success against a server that had refused the login.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an invalid login comment is a refusal, not chatter`() {
|
||||||
|
val outcome = AprsLogin.parse(
|
||||||
|
"# Invalid login: software name and version are not separated by a space"
|
||||||
|
)
|
||||||
|
assertTrue("must be a refusal, got $outcome", outcome is AprsLogin.Outcome.Rejected)
|
||||||
|
assertEquals(
|
||||||
|
"Invalid login: software name and version are not separated by a space",
|
||||||
|
(outcome as AprsLogin.Outcome.Rejected).detail
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a login denied comment is also a refusal`() {
|
||||||
|
assertTrue(AprsLogin.parse("# Login denied") is AprsLogin.Outcome.Rejected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every refusal, not only the anticipated ones. A first attempt listed the wordings it knew and
|
||||||
|
* skipped everything else as chatter, which missed three - including `# Login by user not
|
||||||
|
* allowed`, observed live on rotate.aprs2.net, the most commonly used rotating hostname.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `refusals nobody anticipated are still refusals`() {
|
||||||
|
val refusals = listOf(
|
||||||
|
"# Login by user not allowed",
|
||||||
|
"# Port full",
|
||||||
|
"# Server full",
|
||||||
|
"# Invalid login: software name and version are not separated by a space",
|
||||||
|
"# Some wording nobody has seen yet"
|
||||||
|
)
|
||||||
|
for (line in refusals) {
|
||||||
|
assertTrue(
|
||||||
|
"must be a refusal: $line",
|
||||||
|
AprsLogin.parse(line) is AprsLogin.Outcome.Rejected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The greetings and keepalives that must NOT read as refusals under that rule. */
|
||||||
|
@Test
|
||||||
|
fun `greetings and keepalives are still skipped`() {
|
||||||
|
val harmless = listOf(
|
||||||
|
"# aprsc 2.1.21-gbfc2090",
|
||||||
|
"# aprsc 2.1.19-g730c5c0",
|
||||||
|
"# javAPRSSrvr 4.4.3b19",
|
||||||
|
"# filter myfilter active"
|
||||||
|
)
|
||||||
|
for (line in harmless) {
|
||||||
|
assertNull("must be skipped so the caller keeps reading: $line", AprsLogin.parse(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "unverified" contains "verified", so the negative has to be tested first. A naive
|
||||||
|
* contains("verified") reports every rejected login as accepted.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `unverified is not mistaken for verified`() {
|
||||||
|
val outcome = AprsLogin.parse("# logresp BG7NTA unverified, server T2SYDNEY")
|
||||||
|
assertEquals(AprsLogin.Outcome.Unverified("BG7NTA"), outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a verified login is recognised with its callsign`() {
|
||||||
|
val outcome = AprsLogin.parse("# logresp BG7NTA-5 verified, server T2SYDNEY")
|
||||||
|
assertEquals(AprsLogin.Outcome.Verified("BG7NTA-5"), outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identification and keepalives must return null so the caller keeps reading. The greeting here
|
||||||
|
* is verbatim from aprsc 2.1.21.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `comments without a verdict are skipped`() {
|
||||||
|
assertNull(AprsLogin.parse("# aprsc 2.1.21-gbfc2090"))
|
||||||
|
// The real keepalive, captured from euro.aprs2.net: it repeats the server identification
|
||||||
|
// with a timestamp rather than sending a bare date, so it carries the same prefix.
|
||||||
|
assertNull(
|
||||||
|
AprsLogin.parse("# aprsc 2.1.21-gbfc2090 25 Aug 2026 16:41:07 GMT T2UK 1.2.3.4:14580")
|
||||||
|
)
|
||||||
|
assertNull(AprsLogin.parse(""))
|
||||||
|
assertNull(AprsLogin.parse(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A non-comment line during login is the server objecting in plain text. */
|
||||||
|
@Test
|
||||||
|
fun `a plain text line during login is a rejection`() {
|
||||||
|
val outcome = AprsLogin.parse("Invalid callsign format")
|
||||||
|
assertTrue(outcome is AprsLogin.Outcome.Rejected)
|
||||||
|
assertEquals("Invalid callsign format", (outcome as AprsLogin.Outcome.Rejected).detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A logresp with no recognisable verdict is Unknown, not silently Verified. */
|
||||||
|
@Test
|
||||||
|
fun `an unrecognised logresp is not treated as success`() {
|
||||||
|
assertTrue(
|
||||||
|
AprsLogin.parse("# logresp BG7NTA something-new, server X")
|
||||||
|
is AprsLogin.Outcome.Unknown
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `case does not change the verdict`() {
|
||||||
|
assertEquals(
|
||||||
|
AprsLogin.Outcome.Unverified("BG7NTA"),
|
||||||
|
AprsLogin.parse("# LOGRESP BG7NTA UNVERIFIED, server X")
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AprsLogin.Outcome.Verified("BG7NTA"),
|
||||||
|
AprsLogin.parse("# LogResp BG7NTA Verified, server X")
|
||||||
|
)
|
||||||
|
assertTrue(AprsLogin.parse("# INVALID LOGIN: nope") is AprsLogin.Outcome.Rejected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The callsign is read back so the UI can show whose login the server acknowledged. */
|
||||||
|
@Test
|
||||||
|
fun `reads the callsign even when the line is oddly spaced`() {
|
||||||
|
assertEquals(
|
||||||
|
AprsLogin.Outcome.Verified("BG7NTA-9"),
|
||||||
|
AprsLogin.parse("# logresp BG7NTA-9 verified, server X")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.aprs
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule these pin down: the app never invents a transmit passcode.
|
||||||
|
*
|
||||||
|
* APRS-IS treats the passcode as a licence check and says supplying it to a user is the software
|
||||||
|
* author's job. The previous code derived one from the callsign whenever the entry was blank or
|
||||||
|
* unparseable, so an operator who had never obtained a passcode transmitted anyway - under a
|
||||||
|
* check that was never performed.
|
||||||
|
*/
|
||||||
|
class AprsPasscodeTest {
|
||||||
|
|
||||||
|
/** Recomputed rather than hardcoded: the point is agreement with the shipped algorithm. */
|
||||||
|
private val callsign = "BG7NTA"
|
||||||
|
private val correct = AprsPacket.passcode(callsign)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the callsign's own passcode allows transmitting`() {
|
||||||
|
assertEquals(
|
||||||
|
AprsPasscode.Entry.Transmit(correct),
|
||||||
|
AprsPasscode.classify(callsign, correct.toString())
|
||||||
|
)
|
||||||
|
assertTrue(AprsPasscode.canTransmit(callsign, correct.toString()))
|
||||||
|
assertEquals(correct, AprsPasscode.loginValue(callsign, correct.toString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A blank entry becomes receive-only rather than a derived code. This is the defect: the old
|
||||||
|
* line was `cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(callsign)`,
|
||||||
|
* so leaving the field empty transmitted under a computed passcode.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a blank entry connects receive-only instead of deriving one`() {
|
||||||
|
assertEquals(AprsPasscode.Entry.ReceiveOnly, AprsPasscode.classify(callsign, ""))
|
||||||
|
assertEquals(AprsPasscode.Entry.ReceiveOnly, AprsPasscode.classify(callsign, " "))
|
||||||
|
assertEquals(AprsPasscode.RECEIVE_ONLY, AprsPasscode.loginValue(callsign, ""))
|
||||||
|
assertFalse(AprsPasscode.canTransmit(callsign, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* -1 is the documented receive-only value and has to survive. `takeIf { it >= 0 }` used to
|
||||||
|
* discard it and substitute a transmit passcode, which removed the only safe way to test a
|
||||||
|
* setup - connecting receive-only puts nothing on the network.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an explicit -1 stays receive-only`() {
|
||||||
|
assertEquals(AprsPasscode.Entry.ReceiveOnly, AprsPasscode.classify(callsign, "-1"))
|
||||||
|
assertEquals(AprsPasscode.RECEIVE_ONLY, AprsPasscode.loginValue(callsign, "-1"))
|
||||||
|
assertEquals(AprsPasscode.RECEIVE_ONLY, AprsPasscode.loginValue(callsign, " -1 "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A wrong passcode is reported, not quietly corrected to the right one. */
|
||||||
|
@Test
|
||||||
|
fun `a mismatched passcode is reported rather than fixed`() {
|
||||||
|
val wrong = (correct + 1).toString()
|
||||||
|
assertEquals(AprsPasscode.Entry.Mismatch("BG7NTA"), AprsPasscode.classify(callsign, wrong))
|
||||||
|
assertFalse(AprsPasscode.canTransmit(callsign, wrong))
|
||||||
|
// And it must not be sent: a mismatch logs in receive-only, never as the derived value.
|
||||||
|
assertEquals(AprsPasscode.RECEIVE_ONLY, AprsPasscode.loginValue(callsign, wrong))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The three-way distinction the reporter depends on, and why it must not use canTransmit for
|
||||||
|
* it: that collapses a deliberate receive-only choice and a typo into one boolean. Telling an
|
||||||
|
* operator who mistyped their passcode that they are in receive-only mode is the same
|
||||||
|
* mis-diagnosis as telling a deliberate receive-only user their passcode is wrong, pointing
|
||||||
|
* the other way.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a deliberate receive-only entry is distinguishable from a typo`() {
|
||||||
|
val deliberate = AprsPasscode.classify(callsign, "-1")
|
||||||
|
val blank = AprsPasscode.classify(callsign, "")
|
||||||
|
val typo = AprsPasscode.classify(callsign, (correct + 1).toString())
|
||||||
|
val garbage = AprsPasscode.classify(callsign, "abcde")
|
||||||
|
|
||||||
|
assertTrue(deliberate is AprsPasscode.Entry.ReceiveOnly)
|
||||||
|
assertTrue(blank is AprsPasscode.Entry.ReceiveOnly)
|
||||||
|
assertFalse("a typo must not read as receive-only", typo is AprsPasscode.Entry.ReceiveOnly)
|
||||||
|
assertFalse("garbage must not read as receive-only", garbage is AprsPasscode.Entry.ReceiveOnly)
|
||||||
|
|
||||||
|
// All four are equally unable to transmit, which is why the boolean is not enough.
|
||||||
|
for (entry in listOf("-1", "", (correct + 1).toString(), "abcde")) {
|
||||||
|
assertFalse(AprsPasscode.canTransmit(callsign, entry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `something that is not a number is its own case`() {
|
||||||
|
assertEquals(AprsPasscode.Entry.NotANumber, AprsPasscode.classify(callsign, "abcde"))
|
||||||
|
assertEquals(AprsPasscode.Entry.NotANumber, AprsPasscode.classify(callsign, "12a45"))
|
||||||
|
assertEquals(AprsPasscode.RECEIVE_ONLY, AprsPasscode.loginValue(callsign, "abcde"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Surrounding whitespace from a paste must not turn a valid passcode into a mismatch. */
|
||||||
|
@Test
|
||||||
|
fun `whitespace around a valid passcode is tolerated`() {
|
||||||
|
assertTrue(AprsPasscode.canTransmit(callsign, " $correct "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The passcode depends on the callsign, so it must not validate against a different one. */
|
||||||
|
@Test
|
||||||
|
fun `a passcode for one callsign does not validate another`() {
|
||||||
|
assertFalse(AprsPasscode.canTransmit("W1AW", correct.toString()))
|
||||||
|
assertEquals(
|
||||||
|
AprsPasscode.Entry.Mismatch("W1AW"),
|
||||||
|
AprsPasscode.classify("W1AW", correct.toString())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case must not matter: the algorithm upper-cases before hashing. */
|
||||||
|
@Test
|
||||||
|
fun `a lower case callsign validates the same passcode`() {
|
||||||
|
assertTrue(AprsPasscode.canTransmit("bg7nta", correct.toString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The SSID is not part of the hash, so the base callsign's code is the right one. */
|
||||||
|
@Test
|
||||||
|
fun `no callsign cannot transmit`() {
|
||||||
|
assertEquals(AprsPasscode.Entry.Mismatch(""), AprsPasscode.classify("", "12345"))
|
||||||
|
assertEquals(AprsPasscode.RECEIVE_ONLY, AprsPasscode.loginValue("", "12345"))
|
||||||
|
// A blank entry with a blank callsign is still just receive-only, not a mismatch.
|
||||||
|
assertEquals(AprsPasscode.Entry.ReceiveOnly, AprsPasscode.classify("", ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.aprs
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list exists because two free-text fields accepted anything and used only the first character,
|
||||||
|
* so an operator could type a word, watch it persist, and beacon as something else entirely.
|
||||||
|
*/
|
||||||
|
class AprsSymbolsTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pair that justifies the whole feature. `\S` is Satellite/Pacsat; `/S` is SHUTTLE. One
|
||||||
|
* keystroke apart, and both look right to someone typing from memory.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the satellite symbol uses the alternate table`() {
|
||||||
|
val satellite = AprsSymbols.curated.single { it.descriptionKey == "aprs_symbol_satellite" }
|
||||||
|
assertEquals('\\', satellite.table)
|
||||||
|
assertEquals('S', satellite.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every entry must survive the sanitiser that runs at packet-build time. */
|
||||||
|
@Test
|
||||||
|
fun `every curated symbol passes the transmit sanitiser`() {
|
||||||
|
for (symbol in AprsSymbols.curated) {
|
||||||
|
assertEquals(
|
||||||
|
symbol.descriptionKey + " table must survive tableOf",
|
||||||
|
symbol.table,
|
||||||
|
AprsBeacon.tableOf(symbol.table.toString())
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
symbol.descriptionKey + " code must survive codeOf",
|
||||||
|
symbol.code,
|
||||||
|
AprsBeacon.codeOf(symbol.code.toString())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two entries rendering the same pair would be a UI ambiguity. */
|
||||||
|
@Test
|
||||||
|
fun `no two curated symbols are the same pair`() {
|
||||||
|
val pairs = AprsSymbols.curated.map { it.table to it.code }
|
||||||
|
assertEquals("pairs must be unique", pairs.size, pairs.toSet().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each needs its own description, or the list reads as duplicates. */
|
||||||
|
@Test
|
||||||
|
fun `every curated symbol has a distinct description key`() {
|
||||||
|
val keys = AprsSymbols.curated.map { it.descriptionKey }
|
||||||
|
assertEquals("description keys must be unique", keys.size, keys.toSet().size)
|
||||||
|
assertTrue("keys must be resource names", keys.all { it.startsWith("aprs_symbol_") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stored pair on the list is found`() {
|
||||||
|
assertEquals(AprsSymbols.HOUSE, AprsSymbols.find('/', '-'))
|
||||||
|
assertNotNull(AprsSymbols.find('\\', 'S'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pair the list does not offer must report as absent rather than resolving to something near
|
||||||
|
* it. The picker relies on this to show an existing setting untouched.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a stored pair off the list is not silently substituted`() {
|
||||||
|
assertNull(AprsSymbols.find('/', 'S'))
|
||||||
|
assertNull(AprsSymbols.find('/', '!'))
|
||||||
|
assertNull(AprsSymbols.find('\\', 'y'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The settings screen holds strings, and only the first character counts. */
|
||||||
|
@Test
|
||||||
|
fun `the string overload reads the first character`() {
|
||||||
|
assertEquals(AprsSymbols.HOUSE, AprsSymbols.find("/", "-"))
|
||||||
|
assertEquals(AprsSymbols.HOUSE, AprsSymbols.find("/junk", "-junk"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blank fields mean the shipped default, which is what the save path substitutes. */
|
||||||
|
@Test
|
||||||
|
fun `blank strings resolve to the default symbol`() {
|
||||||
|
assertEquals(AprsSymbols.HOUSE, AprsSymbols.find("", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The default has to be on the list, or the picker opens showing nothing selected. */
|
||||||
|
@Test
|
||||||
|
fun `the default symbol is on the curated list`() {
|
||||||
|
assertTrue(AprsSymbols.HOUSE in AprsSymbols.curated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short enough to scan in a bottom sheet during setup. */
|
||||||
|
@Test
|
||||||
|
fun `the list stays short`() {
|
||||||
|
assertTrue(
|
||||||
|
"a curated list of ${AprsSymbols.curated.size} defeats the point",
|
||||||
|
AprsSymbols.curated.size in 8..20
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.aprs
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards the one defect no other test in this project could catch.
|
||||||
|
*
|
||||||
|
* The APRS service passes a foreground service type to startForeground, and AOSP requires it to be
|
||||||
|
* a subset of the type declared in the manifest, throwing IllegalArgumentException otherwise -
|
||||||
|
* which the service's own catch turns into a silent stopSelf(). A commit changed the manifest from
|
||||||
|
* dataSync to location and left the code passing dataSync, so APRS started, died and reported
|
||||||
|
* nothing on every device running Android 10 or later, while the settings switch stayed on. Two
|
||||||
|
* auditors found it by reading constants against AOSP; the unit suite could not see it at all,
|
||||||
|
* because the service is untestable on the JVM and the app module has no test source set.
|
||||||
|
*
|
||||||
|
* So this reads both files as text from core:domain, which does have test infrastructure. Crude,
|
||||||
|
* and it says nothing about whether the service works - but it fails the moment those two files
|
||||||
|
* disagree, which is exactly the failure that shipped.
|
||||||
|
*/
|
||||||
|
class ForegroundServiceTypeTest {
|
||||||
|
|
||||||
|
/** Manifest attribute value to the ServiceInfo constant the code must pass. */
|
||||||
|
private val expectedConstant = mapOf(
|
||||||
|
"dataSync" to "FOREGROUND_SERVICE_TYPE_DATA_SYNC",
|
||||||
|
"location" to "FOREGROUND_SERVICE_TYPE_LOCATION",
|
||||||
|
"connectedDevice" to "FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE",
|
||||||
|
"mediaPlayback" to "FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK",
|
||||||
|
"shortService" to "FOREGROUND_SERVICE_TYPE_SHORT_SERVICE"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Manifest attribute value to the permission that must accompany it. */
|
||||||
|
private val requiredPermission = mapOf(
|
||||||
|
"dataSync" to "FOREGROUND_SERVICE_DATA_SYNC",
|
||||||
|
"location" to "FOREGROUND_SERVICE_LOCATION",
|
||||||
|
"connectedDevice" to "FOREGROUND_SERVICE_CONNECTED_DEVICE",
|
||||||
|
"mediaPlayback" to "FOREGROUND_SERVICE_MEDIA_PLAYBACK"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk up from the working directory to the repository root.
|
||||||
|
*
|
||||||
|
* A unit test's working directory is the module directory, but that is not guaranteed across
|
||||||
|
* Gradle versions, so the root is found by looking for settings.gradle.kts rather than assumed.
|
||||||
|
*/
|
||||||
|
private val repoRoot: File? by lazy {
|
||||||
|
var dir: File? = File("").absoluteFile
|
||||||
|
while (dir != null && !File(dir, "settings.gradle.kts").isFile) dir = dir.parentFile
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun read(relative: String): String? =
|
||||||
|
repoRoot?.let { File(it, relative) }?.takeIf { it.isFile }?.readText()
|
||||||
|
|
||||||
|
private val manifest: String? by lazy { read("app/src/main/AndroidManifest.xml") }
|
||||||
|
private val service: String? by lazy {
|
||||||
|
read("app/src/main/java/com/rtbishop/look4sat/AprsForegroundService.kt")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The type declared for the APRS service in the manifest. */
|
||||||
|
private fun declaredType(text: String): String {
|
||||||
|
val match = Regex("""android:foregroundServiceType="([^"]+)"""").find(text)
|
||||||
|
assertTrue("no foregroundServiceType found in the manifest", match != null)
|
||||||
|
return match!!.groupValues[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the service passes the type its manifest declares`() {
|
||||||
|
val manifestText = manifest
|
||||||
|
val serviceText = service
|
||||||
|
// Skipped rather than failed if the layout moves: a broken locator must not read as a
|
||||||
|
// broken app, and the assertion below is worthless without both files anyway.
|
||||||
|
assumeTrue("manifest or service source not found", manifestText != null && serviceText != null)
|
||||||
|
val declared = declaredType(manifestText!!)
|
||||||
|
val constant = expectedConstant[declared]
|
||||||
|
assertTrue(
|
||||||
|
"unrecognised foregroundServiceType '$declared' - add it to this test's map",
|
||||||
|
constant != null
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"manifest declares $declared, so startForeground must pass ServiceInfo.$constant",
|
||||||
|
serviceText!!.contains("ServiceInfo.$constant")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Passing any type the manifest does not declare is what AOSP rejects. */
|
||||||
|
@Test
|
||||||
|
fun `the service passes no other foreground service type`() {
|
||||||
|
val manifestText = manifest
|
||||||
|
val serviceText = service
|
||||||
|
assumeTrue("manifest or service source not found", manifestText != null && serviceText != null)
|
||||||
|
val passed = Regex("""ServiceInfo\.(FOREGROUND_SERVICE_TYPE_\w+)""")
|
||||||
|
.findAll(serviceText!!)
|
||||||
|
.map { it.groupValues[1] }
|
||||||
|
.toSet()
|
||||||
|
assertEquals(
|
||||||
|
"exactly one type may be passed, and it must match the manifest",
|
||||||
|
setOf(expectedConstant.getValue(declaredType(manifestText!!))),
|
||||||
|
passed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The matching permission must be declared, or startForeground throws on API 34 and up. */
|
||||||
|
@Test
|
||||||
|
fun `the manifest declares the permission its service type requires`() {
|
||||||
|
val manifestText = manifest
|
||||||
|
assumeTrue("manifest not found", manifestText != null)
|
||||||
|
val declared = declaredType(manifestText!!)
|
||||||
|
val permission = requiredPermission[declared]
|
||||||
|
assertTrue("no permission mapped for '$declared'", permission != null)
|
||||||
|
assertTrue(
|
||||||
|
"foregroundServiceType $declared requires android.permission.$permission",
|
||||||
|
manifestText.contains("android.permission.$permission")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A location-typed service additionally demands an already-granted location permission before
|
||||||
|
* startForeground. The settings card requests only notifications, so declaring location would
|
||||||
|
* fail silently for any operator who declined location access - the same class of defect this
|
||||||
|
* whole test exists to prevent.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a location typed service would need a runtime permission this app does not request`() {
|
||||||
|
val manifestText = manifest
|
||||||
|
assumeTrue("manifest not found", manifestText != null)
|
||||||
|
assumeTrue("only applies to a location-typed service", declaredType(manifestText!!) == "location")
|
||||||
|
val card = read(
|
||||||
|
"feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/AprsCard.kt"
|
||||||
|
)
|
||||||
|
assumeTrue("settings card source not found", card != null)
|
||||||
|
assertTrue(
|
||||||
|
"declaring location requires requesting ACCESS_COARSE_LOCATION or ACCESS_FINE_LOCATION",
|
||||||
|
card!!.contains("ACCESS_COARSE_LOCATION") || card.contains("ACCESS_FINE_LOCATION")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.cw
|
||||||
|
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.hypot
|
||||||
|
import kotlin.math.sin
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aliasing is not a subtle degradation here: without this filter a 3000 Hz tone reappeared at
|
||||||
|
* 200 Hz at 119 times the spectral mean, which reads as a real signal, and the whole band above
|
||||||
|
* 1600 Hz folded down and lifted the noise floor across the display.
|
||||||
|
*/
|
||||||
|
class CwAntiAliasTest {
|
||||||
|
|
||||||
|
private val captureRate = 44100
|
||||||
|
private val targetRate = CwDeepSpectrogram.SAMPLE_RATE
|
||||||
|
private val nyquist = targetRate / 2.0
|
||||||
|
|
||||||
|
/** Magnitude at one frequency, Hann-windowed so neighbours do not smear in. */
|
||||||
|
private fun magnitudeAt(signal: FloatArray, hz: Double, rate: Int): Double {
|
||||||
|
val n = signal.size
|
||||||
|
var real = 0.0
|
||||||
|
var imag = 0.0
|
||||||
|
val omega = 2.0 * PI * hz / rate
|
||||||
|
for (i in 0 until n) {
|
||||||
|
val window = 0.5 - 0.5 * kotlin.math.cos(2.0 * PI * i / (n - 1))
|
||||||
|
val value = signal[i] * window
|
||||||
|
real += value * kotlin.math.cos(omega * i)
|
||||||
|
imag -= value * sin(omega * i)
|
||||||
|
}
|
||||||
|
return hypot(real, imag) / n
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tone(hz: Double, rate: Int, seconds: Double = 0.4): FloatArray {
|
||||||
|
val n = (rate * seconds).toInt()
|
||||||
|
return FloatArray(n) { i -> sin(2.0 * PI * hz * i / rate).toFloat() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tone the model needs must survive the filter. */
|
||||||
|
@Test
|
||||||
|
fun `tones inside the window pass through`() {
|
||||||
|
for (hz in listOf(300.0, 700.0, 1000.0, 1200.0)) {
|
||||||
|
val clean = tone(hz, captureRate)
|
||||||
|
val filtered = CwAntiAlias.prepareForDecimation(clean, captureRate, targetRate)
|
||||||
|
val before = magnitudeAt(clean, hz, captureRate)
|
||||||
|
val after = magnitudeAt(filtered, hz, captureRate)
|
||||||
|
assertTrue(
|
||||||
|
"$hz Hz lost too much: $before -> $after",
|
||||||
|
after > before * 0.7
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The defect. 3000 Hz used to fold to 200 Hz and look like a strong signal; the filter has to
|
||||||
|
* remove it before the resampler can alias it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `tones that would alias are suppressed`() {
|
||||||
|
// Limits are the measured response, not aspirations. 1800 Hz sits just past the 1472 Hz
|
||||||
|
// cut-off, and 127 taps cannot give a steeper transition than that - a longer filter would
|
||||||
|
// be sharper and slower, and this runs on a phone during a pass. What matters is that the
|
||||||
|
// wideband hiss well above the window, which is what smears across the whole display, is
|
||||||
|
// gone: 2400 Hz and up measure below a thousandth.
|
||||||
|
val cases = listOf(
|
||||||
|
Triple(1800.0, 1400.0, 0.20),
|
||||||
|
Triple(2400.0, 800.0, 0.01),
|
||||||
|
Triple(3000.0, 200.0, 0.01),
|
||||||
|
Triple(5000.0, 1400.0, 0.01)
|
||||||
|
)
|
||||||
|
for ((source, foldedTo, limit) in cases) {
|
||||||
|
val raw = tone(source, captureRate)
|
||||||
|
val filtered = CwAntiAlias.prepareForDecimation(raw, captureRate, targetRate)
|
||||||
|
|
||||||
|
val aliasedRaw = CwDeepSpectrogram.resampleLinear(raw, captureRate, targetRate)
|
||||||
|
val aliasedFiltered = CwDeepSpectrogram.resampleLinear(filtered, captureRate, targetRate)
|
||||||
|
|
||||||
|
val ghostBefore = magnitudeAt(aliasedRaw, foldedTo, targetRate)
|
||||||
|
val ghostAfter = magnitudeAt(aliasedFiltered, foldedTo, targetRate)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
"$source Hz folds to $foldedTo Hz too strongly: $ghostBefore -> $ghostAfter",
|
||||||
|
ghostAfter < ghostBefore * limit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nothing above the target Nyquist means nothing to do. */
|
||||||
|
@Test
|
||||||
|
fun `no filtering when the rate is already low enough`() {
|
||||||
|
val audio = tone(700.0, targetRate)
|
||||||
|
assertSame(audio, CwAntiAlias.prepareForDecimation(audio, targetRate, targetRate))
|
||||||
|
assertSame(audio, CwAntiAlias.prepareForDecimation(audio, 2000, targetRate))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty buffer is returned unchanged`() {
|
||||||
|
val empty = FloatArray(0)
|
||||||
|
assertSame(empty, CwAntiAlias.prepareForDecimation(empty, captureRate, targetRate))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unity DC gain: filtering must not move the level the model was trained on. */
|
||||||
|
@Test
|
||||||
|
fun `a steady level is preserved`() {
|
||||||
|
val flat = FloatArray(4410) { 0.5f }
|
||||||
|
val filtered = CwAntiAlias.prepareForDecimation(flat, captureRate, targetRate)
|
||||||
|
// Skip the edges, where taps fall outside the buffer.
|
||||||
|
val middle = filtered.copyOfRange(
|
||||||
|
CwAntiAlias.GROUP_DELAY_SAMPLES + 1,
|
||||||
|
filtered.size - CwAntiAlias.GROUP_DELAY_SAMPLES - 1
|
||||||
|
)
|
||||||
|
for (v in middle) {
|
||||||
|
assertTrue("level drifted to $v", kotlin.math.abs(v - 0.5f) < 0.02f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunked filtering has to match whole-buffer filtering, or every chunk boundary becomes a
|
||||||
|
* click - the same failure the tone shifter's streaming path exists to prevent.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Chunked filtering must equal whole-buffer filtering exactly, or every chunk boundary is a
|
||||||
|
* click. A first attempt let the lookahead taps read zeros at the end of each chunk and
|
||||||
|
* diverged by 0.134 across the last 44 samples of every one; holding output back by the group
|
||||||
|
* delay makes the two identical.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `streaming matches whole-buffer filtering`() {
|
||||||
|
val audio = tone(700.0, captureRate, seconds = 0.3)
|
||||||
|
val whole = CwAntiAlias.prepareForDecimation(audio, captureRate, targetRate)
|
||||||
|
|
||||||
|
val streaming = CwAntiAlias.Streaming(captureRate, targetRate)
|
||||||
|
val chunkSize = captureRate / 10
|
||||||
|
val pieces = mutableListOf<Float>()
|
||||||
|
var offset = 0
|
||||||
|
while (offset < audio.size) {
|
||||||
|
val end = minOf(offset + chunkSize, audio.size)
|
||||||
|
pieces += streaming.process(audio.copyOfRange(offset, end)).toList()
|
||||||
|
offset = end
|
||||||
|
}
|
||||||
|
val streamed = pieces.toFloatArray()
|
||||||
|
|
||||||
|
// Output is delayed by the group delay, so streamed[i] corresponds to whole[i + delay].
|
||||||
|
var worst = 0f
|
||||||
|
for (i in streamed.indices) {
|
||||||
|
val j = i + CwAntiAlias.GROUP_DELAY_SAMPLES
|
||||||
|
if (j >= whole.size) break
|
||||||
|
val diff = kotlin.math.abs(streamed[i] - whole[j])
|
||||||
|
if (diff > worst) worst = diff
|
||||||
|
}
|
||||||
|
assertTrue("streaming diverges by $worst", worst < 1e-5f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Streaming must not lift the noise floor either. */
|
||||||
|
@Test
|
||||||
|
fun `streaming suppresses an aliasing tone too`() {
|
||||||
|
val raw = tone(3000.0, captureRate, seconds = 0.3)
|
||||||
|
val streaming = CwAntiAlias.Streaming(captureRate, targetRate)
|
||||||
|
val chunkSize = captureRate / 10
|
||||||
|
val pieces = mutableListOf<Float>()
|
||||||
|
var offset = 0
|
||||||
|
while (offset < raw.size) {
|
||||||
|
val end = minOf(offset + chunkSize, raw.size)
|
||||||
|
pieces += streaming.process(raw.copyOfRange(offset, end)).toList()
|
||||||
|
offset = end
|
||||||
|
}
|
||||||
|
val filtered = pieces.toFloatArray()
|
||||||
|
|
||||||
|
val ghostBefore = magnitudeAt(
|
||||||
|
CwDeepSpectrogram.resampleLinear(raw, captureRate, targetRate), 200.0, targetRate
|
||||||
|
)
|
||||||
|
val ghostAfter = magnitudeAt(
|
||||||
|
CwDeepSpectrogram.resampleLinear(filtered, captureRate, targetRate), 200.0, targetRate
|
||||||
|
)
|
||||||
|
assertTrue("ghost survived: $ghostBefore -> $ghostAfter", ghostAfter < ghostBefore * 0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset has to clear history, or the next session starts with the last one's tail. */
|
||||||
|
@Test
|
||||||
|
fun `reset clears the history`() {
|
||||||
|
val streaming = CwAntiAlias.Streaming(captureRate, targetRate)
|
||||||
|
val loud = FloatArray(4410) { 0.9f }
|
||||||
|
streaming.process(loud)
|
||||||
|
streaming.reset()
|
||||||
|
|
||||||
|
val silence = FloatArray(4410)
|
||||||
|
val after = streaming.process(silence)
|
||||||
|
for (v in after) {
|
||||||
|
assertTrue("history leaked into silence: $v", kotlin.math.abs(v) < 0.01f)
|
||||||
|
}
|
||||||
|
// And a second silent chunk, now that the pipeline is primed.
|
||||||
|
for (v in streaming.process(FloatArray(4410))) {
|
||||||
|
assertTrue("history still leaking: $v", kotlin.math.abs(v) < 0.01f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.qrz
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class QrzGridParserTest {
|
||||||
|
|
||||||
|
/** A signed-in page for a station that published a locator. */
|
||||||
|
private val withGrid = """
|
||||||
|
<table class="detail">
|
||||||
|
<tr><td class="dh">Callsign</td><td class="di">BG7NTA</td></tr>
|
||||||
|
<tr><td class="dh">Grid Square</td><td class="di">OL72</td></tr>
|
||||||
|
<tr><td class="dh">Country</td><td class="di">China</td></tr>
|
||||||
|
</table>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
/** Signed in, but this station has no Grid Square row at all. */
|
||||||
|
private val withoutGrid = """
|
||||||
|
<table class="detail">
|
||||||
|
<tr><td class="dh">Callsign</td><td class="di">W1AW</td></tr>
|
||||||
|
<tr><td class="dh">Country</td><td class="di">United States</td></tr>
|
||||||
|
</table>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What QRZ actually serves for a real callsign when nobody is signed in: no detail table,
|
||||||
|
* plus its own notice saying why. Transcribed from a live response rather than invented.
|
||||||
|
*/
|
||||||
|
private val signedOut = """
|
||||||
|
<div class="csgn">W1AW</div>
|
||||||
|
<div>Login is required for additional detail.</div>
|
||||||
|
<div>Email: Login required to view</div>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A callsign QRZ has never heard of. Also HTTP 200, also zero detail rows, but no login
|
||||||
|
* notice - QRZ serves its search form instead of a callsign page.
|
||||||
|
*/
|
||||||
|
private val unknownCallsign = """
|
||||||
|
<div class="search">The available search types are callsign, name, address.</div>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reads the locator when the station published one`() {
|
||||||
|
assertEquals(QrzGrid.Found("OL72"), QrzGridParser.parseGrid(withGrid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The distinction that matters: a station with no grid must not look like an expired
|
||||||
|
* cookie, because the fix for one is nothing and the fix for the other is re-pasting it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a station with no grid is not confused with being signed out`() {
|
||||||
|
assertEquals(QrzGrid.NotOnFile, QrzGridParser.parseGrid(withoutGrid))
|
||||||
|
assertEquals(QrzGrid.SignedOut, QrzGridParser.parseGrid(signedOut))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cookie prompt must fire only when the cookie is the problem. Measured against live
|
||||||
|
* responses, a mistyped callsign returns a page with no detail rows either - so classifying
|
||||||
|
* on their absence would blame the cookie and send the operator off to re-paste a working
|
||||||
|
* one, mid-pass, over a satellite that is about to set.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an unknown callsign does not read as an expired cookie`() {
|
||||||
|
assertEquals(QrzGrid.NotOnFile, QrzGridParser.parseGrid(unknownCallsign))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty grid cell counts as not on file`() {
|
||||||
|
val blank = withGrid.replace(">OL72<", "><")
|
||||||
|
assertEquals(QrzGrid.NotOnFile, QrzGridParser.parseGrid(blank))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `whitespace around the locator is trimmed`() {
|
||||||
|
val padded = withGrid.replace(">OL72<", "> OL72 <")
|
||||||
|
assertEquals(QrzGrid.Found("OL72"), QrzGridParser.parseGrid(padded))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Six-character locators are as common as four; nothing may truncate them. */
|
||||||
|
@Test
|
||||||
|
fun `a six character locator survives intact`() {
|
||||||
|
val six = withGrid.replace(">OL72<", ">OL72ab<")
|
||||||
|
assertEquals(QrzGrid.Found("OL72ab"), QrzGridParser.parseGrid(six))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** QRZ puts the two cells adjacent, but whitespace between them must not break the match. */
|
||||||
|
@Test
|
||||||
|
fun `whitespace between the two table cells is tolerated`() {
|
||||||
|
val spaced = withGrid.replace(
|
||||||
|
"<td class=\"dh\">Grid Square</td><td class=\"di\">OL72</td>",
|
||||||
|
"<td class=\"dh\">Grid Square</td>\n <td class=\"di\">OL72</td>"
|
||||||
|
)
|
||||||
|
assertEquals(QrzGrid.Found("OL72"), QrzGridParser.parseGrid(spaced))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reads back which callsign the cookie belongs to`() {
|
||||||
|
val menu = """<li class="leaf last" onclick="return true">BG7NTA <ul class="sub">"""
|
||||||
|
assertEquals("BG7NTA", QrzGridParser.parseOwnCallsign(menu))
|
||||||
|
assertNull(QrzGridParser.parseOwnCallsign(signedOut))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a raw cookie header is passed through unchanged`() {
|
||||||
|
val raw = "qz_userid=1266043; qz_sess=abc123"
|
||||||
|
assertEquals(raw, QrzGridParser.cookieHeader(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shape a cookie-export extension produces. */
|
||||||
|
@Test
|
||||||
|
fun `a json cookie export is flattened into a header`() {
|
||||||
|
val json = """
|
||||||
|
[{"domain":".qrz.com","name":"qz_userid","value":"1266043"},
|
||||||
|
{"domain":".qrz.com","name":"qz_sess","value":"abc123"}]
|
||||||
|
""".trimIndent()
|
||||||
|
assertEquals("qz_userid=1266043; qz_sess=abc123", QrzGridParser.cookieHeader(json))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty cookie yields an empty header`() {
|
||||||
|
assertEquals("", QrzGridParser.cookieHeader(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Malformed JSON is handed over as-is rather than silently becoming empty. */
|
||||||
|
@Test
|
||||||
|
fun `unparseable json is passed through rather than dropped`() {
|
||||||
|
val broken = """[{"nope":"nothing here"}]"""
|
||||||
|
assertEquals(broken, QrzGridParser.cookieHeader(broken))
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule under test: never discard a plausible callsign, and never discard anything silently.
|
||||||
|
*
|
||||||
|
* A strict pattern rejected 16 of these 28 real callsigns, so the check is deliberately loose and
|
||||||
|
* reports doubt as a warning instead of refusing.
|
||||||
|
*/
|
||||||
|
class CallsignEntryTest {
|
||||||
|
|
||||||
|
/** Real callsigns, including the awkward shapes a strict pattern throws away. */
|
||||||
|
private val realCallsigns = listOf(
|
||||||
|
"W1AW", "BG7NTA", "G0ABC", "VK2XYZ", "JA1ABC", "PY2ABC",
|
||||||
|
"W1AW/4", "K1ABC/P", "DL1ABC/M", "SV2ASP/A", "OH2ABC/MM",
|
||||||
|
// Prefix-first portable calls: the LEADING token is a country prefix with no digit in it.
|
||||||
|
"DL/W1AW", "ZL/JA1ABC", "OH/W1AW/MM", "PA/G0ABC/P", "F/BG7NTA",
|
||||||
|
"2E0ABC", "2M0XYZ", "9A1CCY", "4X4ABC", "3DA0ABC",
|
||||||
|
"VP2MDD", "ZS6ABC", "5B4ABC", "HB9ABC", "OE1ABC",
|
||||||
|
"LU1ABC", "CT1ABC", "EA8ABC", "TF3ABC", "VU2ABC",
|
||||||
|
"R1ABC", "UA9ABC"
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every real callsign is accepted`() {
|
||||||
|
val rejected = realCallsigns.filter { CallsignEntry.check(it) !is CallsignEntry.Verdict.Acceptable }
|
||||||
|
assertEquals("these real callsigns were rejected: $rejected", emptyList<String>(), rejected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The entry is upper-cased for logging, since ADIF and LoTW expect that. */
|
||||||
|
@Test
|
||||||
|
fun `entries are normalised to upper case and trimmed`() {
|
||||||
|
val verdict = CallsignEntry.check(" bg7nta ")
|
||||||
|
assertEquals(CallsignEntry.Verdict.Acceptable("BG7NTA"), verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The defect this replaces: `if (call.length < 3) return` discarded the entry with no message,
|
||||||
|
* so the operator pressed done mid-pass and nothing happened. Two characters is a real
|
||||||
|
* callsign length, and anything shorter now says so instead of vanishing.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a short entry is rejected with a reason rather than dropped`() {
|
||||||
|
assertEquals(
|
||||||
|
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.TOO_SHORT),
|
||||||
|
CallsignEntry.check("W")
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.EMPTY),
|
||||||
|
CallsignEntry.check(" ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `illegal characters are named as such`() {
|
||||||
|
for (bad in listOf("W1AW!", "W1 AW", "W1AW.", "БГ7НТА", "W1AW@")) {
|
||||||
|
assertEquals(
|
||||||
|
bad,
|
||||||
|
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.ILLEGAL_CHARACTERS),
|
||||||
|
CallsignEntry.check(bad)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No callsign is all digits or all letters. */
|
||||||
|
/**
|
||||||
|
* The regression this guards. Checking only the first segment rejected every prefix-first
|
||||||
|
* portable call, because a country prefix like DL or ZL has no digit in it. The old
|
||||||
|
* length-only check had accepted them, so this was a real loss for anyone logging one.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a prefix-first portable callsign is accepted`() {
|
||||||
|
for (call in listOf("DL/W1AW", "ZL/JA1ABC", "OH/W1AW/MM", "PA/G0ABC/P", "F/BG7NTA")) {
|
||||||
|
assertTrue(
|
||||||
|
"$call must be accepted",
|
||||||
|
CallsignEntry.check(call) is CallsignEntry.Verdict.Acceptable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `something with no digit or no letter is not a callsign`() {
|
||||||
|
assertEquals(
|
||||||
|
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.NOT_A_CALLSIGN),
|
||||||
|
CallsignEntry.check("ABCDE")
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.NOT_A_CALLSIGN),
|
||||||
|
CallsignEntry.check("12345")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an absurdly long entry is rejected`() {
|
||||||
|
assertEquals(
|
||||||
|
CallsignEntry.Verdict.Rejected(CallsignEntry.Reason.TOO_LONG),
|
||||||
|
CallsignEntry.check("W1AW/QRP/PORTABLE/EXTRA")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A grid typed into the callsign field is warned about, not refused: DXLog's own loose
|
||||||
|
* pattern accepts GG77DH as a callsign, and the two are exchanged together on FM satellites.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a grid-shaped entry is warned about but still accepted`() {
|
||||||
|
val verdict = CallsignEntry.check("GG77DH")
|
||||||
|
assertEquals(
|
||||||
|
CallsignEntry.Verdict.Acceptable("GG77DH", CallsignEntry.Warning.LOOKS_LIKE_A_GRID),
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A six-character callsign of the same shape as a grid is rare but real. The warning is advisory. */
|
||||||
|
@Test
|
||||||
|
fun `a normal six character callsign carries no warning`() {
|
||||||
|
val verdict = CallsignEntry.check("BG7NTA") as CallsignEntry.Verdict.Acceptable
|
||||||
|
assertNull(verdict.warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A repeat within the pass is flagged, never blocked: the same station on a later pass is a
|
||||||
|
* valid new contact, and contest loggers default to working duplicates.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a station already worked this session is flagged not blocked`() {
|
||||||
|
val verdict = CallsignEntry.check("W1AW", setOf("W1AW"))
|
||||||
|
assertEquals(
|
||||||
|
CallsignEntry.Verdict.Acceptable("W1AW", CallsignEntry.Warning.ALREADY_WORKED),
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a station worked on a previous pass carries no warning`() {
|
||||||
|
val verdict = CallsignEntry.check("W1AW", setOf("K1ABC")) as CallsignEntry.Verdict.Acceptable
|
||||||
|
assertNull(verdict.warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The repeat check compares normalised entries, so case cannot smuggle a duplicate past it. */
|
||||||
|
@Test
|
||||||
|
fun `the repeat check is case insensitive`() {
|
||||||
|
val verdict = CallsignEntry.check("w1aw", setOf("W1AW"))
|
||||||
|
assertTrue(verdict is CallsignEntry.Verdict.Acceptable)
|
||||||
|
assertEquals(
|
||||||
|
CallsignEntry.Warning.ALREADY_WORKED,
|
||||||
|
(verdict as CallsignEntry.Verdict.Acceptable).warning
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed grid goes into the ADIF GRIDSQUARE field and Wavelog stores whatever arrives, where a
|
||||||
|
* wrong square is worse than a missing one - it pollutes grid statistics and VUCC tracking.
|
||||||
|
*
|
||||||
|
* The rule follows the callsign field: refuse only what is certainly wrong. Four characters is a
|
||||||
|
* legitimate exchange on satellites, so requiring six would reject good entries.
|
||||||
|
*/
|
||||||
|
class GridEntryTest {
|
||||||
|
|
||||||
|
/** Real grids, from stations and from this project's own test data. */
|
||||||
|
private val realGrids = listOf(
|
||||||
|
"OL72AP", // Shenzhen, the user's own
|
||||||
|
"FN31pr", // W1AW, mixed case as conventionally written
|
||||||
|
"JO31", // four-character exchange
|
||||||
|
"PM95uq",
|
||||||
|
"IO91vl39", // eight-character extended
|
||||||
|
"gf15vc", // all lower case
|
||||||
|
"RR73" // the highest legal field pair
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `real grids are accepted`() {
|
||||||
|
for (grid in realGrids) {
|
||||||
|
val verdict = GridEntry.check(grid)
|
||||||
|
assertTrue("$grid must be usable, got $verdict", verdict is GridEntry.Verdict.Acceptable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-check against the app's own converter: anything this accepts at six characters or more
|
||||||
|
* must also decode to a real position, or the two disagree about what a grid is.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `accepted grids of six or more decode to a position`() {
|
||||||
|
for (grid in realGrids.filter { it.length >= 6 }) {
|
||||||
|
val verdict = GridEntry.check(grid)
|
||||||
|
assertTrue(verdict is GridEntry.Verdict.Acceptable)
|
||||||
|
assertNotNull(
|
||||||
|
"$grid was accepted here but qthToPosition rejects it",
|
||||||
|
qthToPosition((verdict as GridEntry.Verdict.Acceptable).normalised)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Four characters is a real exchange, flagged rather than refused. */
|
||||||
|
@Test
|
||||||
|
fun `a four character square is acceptable with a warning`() {
|
||||||
|
val verdict = GridEntry.check("JO31")
|
||||||
|
assertEquals(
|
||||||
|
GridEntry.Verdict.Acceptable("JO31", GridEntry.Warning.SQUARE_ONLY),
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `six characters carry no warning`() {
|
||||||
|
val verdict = GridEntry.check("OL72AP") as GridEntry.Verdict.Acceptable
|
||||||
|
assertNull(verdict.warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upper field, lower subsquare - the conventional rendering, whatever was typed. */
|
||||||
|
@Test
|
||||||
|
fun `case is normalised rather than demanded`() {
|
||||||
|
assertEquals("OL72ap", (GridEntry.check("ol72ap") as GridEntry.Verdict.Acceptable).normalised)
|
||||||
|
assertEquals("OL72ap", (GridEntry.check("OL72AP") as GridEntry.Verdict.Acceptable).normalised)
|
||||||
|
assertEquals("OL72ap", (GridEntry.check("oL72Ap") as GridEntry.Verdict.Acceptable).normalised)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `surrounding whitespace does not matter`() {
|
||||||
|
assertEquals("OL72ap", (GridEntry.check(" OL72AP ") as GridEntry.Verdict.Acceptable).normalised)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two characters is legal per ADIF, so it is accepted - but a field is 20 by 10 degrees, close
|
||||||
|
* to useless for a satellite contact, so it says so rather than refusing.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a two character field is acceptable with a warning`() {
|
||||||
|
assertEquals(
|
||||||
|
GridEntry.Verdict.Acceptable("OL", GridEntry.Warning.FIELD_ONLY),
|
||||||
|
GridEntry.check("ol")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A 2-character entry must not read past its own end. */
|
||||||
|
@Test
|
||||||
|
fun `a two character field does not crash`() {
|
||||||
|
for (grid in listOf("AA", "RR", "ol", "FN")) {
|
||||||
|
assertTrue(GridEntry.check(grid) is GridEntry.Verdict.Acceptable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kotlin's Char.isDigit() is Unicode-aware and accepted Arabic-Indic, Devanagari, Persian and
|
||||||
|
* fullwidth digits, which a localised keypad produces without the operator seeing a difference.
|
||||||
|
* ADIF 3.1.7 defines Digit as ASCII 48-57, and Wavelog stores GRIDSQUARE verbatim, so such a
|
||||||
|
* value would never match a real grid in any statistics query.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `non-ascii digits are not digits`() {
|
||||||
|
val nonAscii = listOf(
|
||||||
|
"OL\u0661\u0662ap",
|
||||||
|
"OL\u0968\u0968ap",
|
||||||
|
"OL\uff11\uff12ap",
|
||||||
|
"OL\u06f1\u06f2ap",
|
||||||
|
"IO91vl\u0663\u0669"
|
||||||
|
)
|
||||||
|
for (grid in nonAscii) {
|
||||||
|
assertTrue(
|
||||||
|
"must be refused: " + grid,
|
||||||
|
GridEntry.check(grid) is GridEntry.Verdict.Unusable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nothing typed means the QRZ lookup should run, which is a different outcome from bad input. */
|
||||||
|
@Test
|
||||||
|
fun `an empty entry is neither acceptable nor unusable`() {
|
||||||
|
assertEquals(GridEntry.Verdict.Empty, GridEntry.check(""))
|
||||||
|
assertEquals(GridEntry.Verdict.Empty, GridEntry.check(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADIF 3.1.7 permits 2, 4, 6 and 8 characters and nothing between them. A 10 or 12 character
|
||||||
|
* locator stores its first 8 here with the rest in GRIDSQUARE_EXT, which nothing in this app
|
||||||
|
* carries - the field clips at 8, which produces the spec-correct value.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `only 2 4 6 or 8 characters can be a grid`() {
|
||||||
|
for (bad in listOf("O", "OL7", "OL72A", "OL72APX", "OL72AP123")) {
|
||||||
|
val verdict = GridEntry.check(bad)
|
||||||
|
assertEquals(
|
||||||
|
"$bad must be refused for length, got $verdict",
|
||||||
|
GridEntry.Verdict.Unusable(GridEntry.Reason.WRONG_LENGTH),
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first pair runs A-R only. S-X decodes past the poles, which is how a plausible-looking
|
||||||
|
* entry produces a position that cannot exist.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a field pair past R is refused`() {
|
||||||
|
for (bad in listOf("SS12AA", "ZZ99ZZ", "TT34bb")) {
|
||||||
|
assertEquals(
|
||||||
|
"$bad decodes outside the world",
|
||||||
|
GridEntry.Verdict.Unusable(GridEntry.Reason.FIELD_OUT_OF_RANGE),
|
||||||
|
GridEntry.check(bad)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the square pair must be digits`() {
|
||||||
|
for (bad in listOf("OLAAAP", "OL7AAP", "ABCDEF")) {
|
||||||
|
assertEquals(
|
||||||
|
"$bad has no square digits",
|
||||||
|
GridEntry.Verdict.Unusable(GridEntry.Reason.SQUARE_NOT_DIGITS),
|
||||||
|
GridEntry.check(bad)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The subsquare runs A-X. Y and Z do not exist. */
|
||||||
|
@Test
|
||||||
|
fun `a subsquare past X is refused`() {
|
||||||
|
assertEquals(
|
||||||
|
GridEntry.Verdict.Unusable(GridEntry.Reason.SUBSQUARE_OUT_OF_RANGE),
|
||||||
|
GridEntry.check("OL72YZ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A callsign is the most likely wrong thing to be typed into this field. */
|
||||||
|
@Test
|
||||||
|
fun `a callsign is not a grid`() {
|
||||||
|
for (call in listOf("BG7NTA", "W1AW", "JA1ABC", "DL1ABC")) {
|
||||||
|
val verdict = GridEntry.check(call)
|
||||||
|
assertTrue(
|
||||||
|
"$call must not read as a grid, got $verdict",
|
||||||
|
verdict is GridEntry.Verdict.Unusable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Digits alone are a frequency or a report, not a grid. */
|
||||||
|
@Test
|
||||||
|
fun `numbers alone are not a grid`() {
|
||||||
|
for (bad in listOf("123456", "5959", "14550")) {
|
||||||
|
assertTrue(GridEntry.check(bad) is GridEntry.Verdict.Unusable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The extended form ends in two digits. */
|
||||||
|
@Test
|
||||||
|
fun `an eight character grid needs digits at the end`() {
|
||||||
|
assertTrue(GridEntry.check("IO91vl39") is GridEntry.Verdict.Acceptable)
|
||||||
|
assertEquals(
|
||||||
|
GridEntry.Verdict.Unusable(GridEntry.Reason.SQUARE_NOT_DIGITS),
|
||||||
|
GridEntry.check("IO91vlab")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LotwSatelliteIdsTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every name the table produces has to exist in the LoTW list verbatim, because LoTW
|
||||||
|
* rejects a QSO whose SAT_NAME is spelled differently - AO7 for AO-7 is refused.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `every mapped name is spelled exactly as LoTW has it`() {
|
||||||
|
val known = LotwSatellites.names
|
||||||
|
for (catnum in catnums) {
|
||||||
|
val name = LotwSatelliteIds.nameFor(catnum)
|
||||||
|
assertTrue("$catnum maps to $name, which LoTW does not list", name in known)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalogue number is the key precisely so that the source a user happens to fetch
|
||||||
|
* from cannot change the answer. These are the numbers whose names differ most between
|
||||||
|
* Celestrak and AMSAT, so they are the ones worth pinning.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `names that differ between sources resolve to the LoTW spelling`() {
|
||||||
|
assertEquals("AO-91", LotwSatelliteIds.nameFor(43017)) // RADFXSAT (FOX-1B) / AO-91
|
||||||
|
assertEquals("QO-100", LotwSatelliteIds.nameFor(43700)) // ES'HAIL 2 / QO-100
|
||||||
|
assertEquals("TO-108", LotwSatelliteIds.nameFor(44881)) // TIANYAN 01 / CAS-6 (TO-108)
|
||||||
|
assertEquals("SO-50", LotwSatelliteIds.nameFor(27607)) // SAUDISAT 1C (SO-50) / SO-50
|
||||||
|
assertEquals("AO-123", LotwSatelliteIds.nameFor(61781)) // ASRTU-1 (AO-123) / AO-123
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ARISS is the station, not its modules. Celestrak's full catalogue lists ISS (UNITY),
|
||||||
|
* (ZVEZDA), (DESTINY) and (NAUKA) as separate objects; matching on the name "ISS" pulled
|
||||||
|
* all of them in, and a QSO cannot be worked through a module.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `ARISS is the station and not one of its modules`() {
|
||||||
|
assertEquals("ARISS", LotwSatelliteIds.nameFor(25544))
|
||||||
|
for (module in listOf(25575, 26400, 26700, 49044)) {
|
||||||
|
assertNull("module $module must not map to a satellite", LotwSatelliteIds.nameFor(module))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One source (R4UAB) names 53109 as ROBUSTA 1F while four others call it GREENCUBE
|
||||||
|
* (IO-117), and 53106 appears in that one source alone. Trusting either would have put a
|
||||||
|
* second catalogue number on the same LoTW name.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `IO-117 resolves to the number the amateur sources agree on`() {
|
||||||
|
assertEquals("IO-117", LotwSatelliteIds.nameFor(53109))
|
||||||
|
assertNull(LotwSatelliteIds.nameFor(53106))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 44879 is TIANQIN 1, catalogued but not an amateur satellite carrying TO-108. */
|
||||||
|
@Test
|
||||||
|
fun `TO-108 does not also match TIANQIN 1`() {
|
||||||
|
assertEquals("TO-108", LotwSatelliteIds.nameFor(44881))
|
||||||
|
assertNull(LotwSatelliteIds.nameFor(44879))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No two numbers may share a name, or one of them is the wrong satellite. */
|
||||||
|
@Test
|
||||||
|
fun `each LoTW name is claimed by a single catalogue number`() {
|
||||||
|
val names = catnums.mapNotNull { LotwSatelliteIds.nameFor(it) }
|
||||||
|
assertEquals(names.size, names.toSet().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isKnown agrees with nameFor`() {
|
||||||
|
assertTrue(LotwSatelliteIds.isKnown(27607))
|
||||||
|
assertFalse(LotwSatelliteIds.isKnown(99999))
|
||||||
|
assertNull(LotwSatelliteIds.nameFor(99999))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guards against an edit that empties or truncates the table. */
|
||||||
|
@Test
|
||||||
|
fun `table holds every entry`() {
|
||||||
|
assertEquals(catnums.size, LotwSatelliteIds.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The TEVEL-2 constellation needs the table more than anything else does: every source
|
||||||
|
* writes these TEVEL2-N while LoTW has TEV2-N, and stripping separators does not bridge
|
||||||
|
* that - TEVEL21 is not TEV21 - so without these rows their QSOs upload under a name LoTW
|
||||||
|
* refuses. Nine satellites launched in 2025 and currently workable; they were missing from
|
||||||
|
* the first version of this table.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the TEVEL-2 constellation maps to the LoTW spelling`() {
|
||||||
|
assertEquals("TEV2-1", LotwSatelliteIds.nameFor(63217))
|
||||||
|
assertEquals("TEV2-4", LotwSatelliteIds.nameFor(63213))
|
||||||
|
assertEquals("TEV2-9", LotwSatelliteIds.nameFor(63237))
|
||||||
|
// Nine numbers, nine distinct names. The numbering is deliberately not sequential -
|
||||||
|
// 63217 is TEVEL2-1 while 63213 is TEVEL2-4 - so an off-by-one logs the wrong bird.
|
||||||
|
val tevel = listOf(63213, 63214, 63215, 63217, 63218, 63219, 63237, 63238, 63239)
|
||||||
|
val names = tevel.mapNotNull { LotwSatelliteIds.nameFor(it) }
|
||||||
|
assertEquals(tevel.size, names.size)
|
||||||
|
assertEquals(tevel.size, names.toSet().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val catnums = listOf(
|
||||||
|
7530, 14129, 20439, 20442, 22825, 23439, 24278, 25544, 26609, 26931,
|
||||||
|
27607, 28650, 39444, 40025, 40074, 40908, 40931, 40967, 41847, 43017,
|
||||||
|
43678, 43700, 43803, 44530, 44881, 44909, 50466, 53109, 61781,
|
||||||
|
63213, 63214, 63215, 63217, 63218, 63219, 63237, 63238, 63239
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The screen used to stamp the current time with no way to change it, which is wrong for the way
|
||||||
|
* satellite operators actually work: record the pass, transcribe it afterwards. Every transcribed
|
||||||
|
* contact was then off by however long the transcription took.
|
||||||
|
*
|
||||||
|
* The parse is deliberately narrow. A mis-parsed time silently backdates a contact and nothing
|
||||||
|
* downstream would notice, so anything unclear has to be rejected rather than guessed.
|
||||||
|
*/
|
||||||
|
class PassClockTest {
|
||||||
|
|
||||||
|
private val dayStart = 1_800_000_000_000L // some midnight UTC
|
||||||
|
private val minute = 60_000L
|
||||||
|
private val hour = 60 * minute
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a colon separated time is understood`() {
|
||||||
|
assertEquals(PassClock.Command.At(14 * 60 + 55), PassClock.parse("14:55"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bare four digit time is understood`() {
|
||||||
|
assertEquals(PassClock.Command.At(14 * 60 + 55), PassClock.parse("1455"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `midnight and the last minute of the day both parse`() {
|
||||||
|
assertEquals(PassClock.Command.At(0), PassClock.parse("00:00"))
|
||||||
|
assertEquals(PassClock.Command.At(23 * 60 + 59), PassClock.parse("23:59"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an impossible time is not guessed at`() {
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("24:00"))
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("12:60"))
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("99:99"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a callsign shaped entry is not a time`() {
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("BG7NTA"))
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("59"))
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("OL72AP"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shift in minutes is understood in both directions`() {
|
||||||
|
assertEquals(PassClock.Command.Shift(3), PassClock.parse("+3"))
|
||||||
|
assertEquals(PassClock.Command.Shift(-2), PassClock.parse("-2"))
|
||||||
|
assertEquals(PassClock.Command.Shift(15), PassClock.parse("+15m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pass lasts minutes, so a huge shift is a typo rather than an intention. */
|
||||||
|
@Test
|
||||||
|
fun `an absurd shift is rejected`() {
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("+99999"))
|
||||||
|
assertEquals(PassClock.Command.Unrecognised, PassClock.parse("+x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty or now returns to live time`() {
|
||||||
|
assertEquals(PassClock.Command.Live, PassClock.parse(""))
|
||||||
|
assertEquals(PassClock.Command.Live, PassClock.parse(" "))
|
||||||
|
assertEquals(PassClock.Command.Live, PassClock.parse("now"))
|
||||||
|
assertEquals(PassClock.Command.Live, PassClock.parse("NOW"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `live time resolves to now`() {
|
||||||
|
val now = dayStart + 10 * hour
|
||||||
|
assertEquals(now, PassClock.resolve(PassClock.Command.Live, now, dayStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unrecognised entry must not move the clock. */
|
||||||
|
@Test
|
||||||
|
fun `an unrecognised command leaves the time alone`() {
|
||||||
|
val now = dayStart + 10 * hour
|
||||||
|
assertEquals(now, PassClock.resolve(PassClock.Command.Unrecognised, now, dayStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an absolute time earlier today resolves to today`() {
|
||||||
|
val now = dayStart + 14 * hour
|
||||||
|
val resolved = PassClock.resolve(PassClock.Command.At(10 * 60 + 30), now, dayStart)
|
||||||
|
assertEquals(dayStart + 10 * hour + 30 * minute, resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The case that would otherwise put a contact a day in the future. Transcribing at 00:05 UTC a
|
||||||
|
* pass that ran at 23:58 is normal, not an error - passes cross midnight UTC routinely.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an absolute time later than now belongs to the previous day`() {
|
||||||
|
val now = dayStart + 5 * minute // 00:05 UTC
|
||||||
|
val resolved = PassClock.resolve(PassClock.Command.At(23 * 60 + 58), now, dayStart)
|
||||||
|
assertEquals(dayStart - 86_400_000L + 23 * hour + 58 * minute, resolved)
|
||||||
|
assertTrue("must be in the past", resolved < now)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shift moves the time by whole minutes`() {
|
||||||
|
val now = dayStart + 10 * hour
|
||||||
|
assertEquals(now + 3 * minute, PassClock.resolve(PassClock.Command.Shift(3), now, dayStart))
|
||||||
|
assertEquals(now - 2 * minute, PassClock.resolve(PassClock.Command.Shift(-2), now, dayStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The UI needs to show that the clock is no longer following real time. */
|
||||||
|
@Test
|
||||||
|
fun `holding is reported only when the clock has moved`() {
|
||||||
|
assertTrue(PassClock.isHolding(PassClock.Command.At(600)))
|
||||||
|
assertTrue(PassClock.isHolding(PassClock.Command.Shift(-5)))
|
||||||
|
assertFalse(PassClock.isHolding(PassClock.Command.Live))
|
||||||
|
assertFalse(PassClock.isHolding(PassClock.Command.Unrecognised))
|
||||||
|
assertFalse("a zero shift is still live", PassClock.isHolding(PassClock.Command.Shift(0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whitespace and case must not change the verdict - this is typed one-handed outdoors. */
|
||||||
|
@Test
|
||||||
|
fun `surrounding whitespace does not matter`() {
|
||||||
|
assertEquals(PassClock.Command.At(14 * 60 + 55), PassClock.parse(" 14:55 "))
|
||||||
|
assertEquals(PassClock.Command.Shift(3), PassClock.parse(" +3 "))
|
||||||
|
assertEquals(PassClock.Command.Shift(15), PassClock.parse("+15M"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LoTW rejects a QSO whose SAT_NAME is not spelled as its accepted list has it, so these pin
|
||||||
|
* the exact spellings against the names real TLE sources actually publish.
|
||||||
|
*/
|
||||||
|
class SatNameResolutionTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalogue number decides it whenever it is known, because sources disagree on the
|
||||||
|
* name for the same object and the descriptive part of a TLE name is never the designator.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the catalogue number wins over whatever the TLE calls it`() {
|
||||||
|
assertEquals("AO-123", WaveLogApi.normalizeSatName("ASRTU-1", 61781))
|
||||||
|
assertEquals("AO-91", WaveLogApi.normalizeSatName("RADFXSAT (FOX-1B)", 43017))
|
||||||
|
assertEquals("QO-100", WaveLogApi.normalizeSatName("ES'HAIL 2", 43700))
|
||||||
|
assertEquals("TO-108", WaveLogApi.normalizeSatName("TIANYAN 01", 44881))
|
||||||
|
assertEquals("CAS-3H", WaveLogApi.normalizeSatName("LILACSAT-2", 40908))
|
||||||
|
assertEquals("ARISS", WaveLogApi.normalizeSatName("ISS (ZARYA)", 25544))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two sources naming one satellite differently must still upload identically, or which
|
||||||
|
* source the operator fetched from would decide whether the QSO can be confirmed.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the same satellite resolves alike whichever source named it`() {
|
||||||
|
val fromCelestrak = WaveLogApi.normalizeSatName("SAUDISAT 1C (SO-50)", 27607)
|
||||||
|
val fromAmsat = WaveLogApi.normalizeSatName("SO-50", 27607)
|
||||||
|
assertEquals(fromCelestrak, fromAmsat)
|
||||||
|
assertEquals("SO-50", fromCelestrak)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Without a catalogue number - QSOs logged before it was recorded - the name is tried. */
|
||||||
|
@Test
|
||||||
|
fun `falls back to the name when no catalogue number is given`() {
|
||||||
|
assertEquals("SO-50", WaveLogApi.normalizeSatName("SAUDISAT 1C (SO-50)"))
|
||||||
|
assertEquals("AO-7", WaveLogApi.normalizeSatName("OSCAR 7 (AO-7)"))
|
||||||
|
assertEquals("AO-73", WaveLogApi.normalizeSatName("FUNCUBE-1 (AO-73)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which side of the parentheses holds the designator varies, so both are tried:
|
||||||
|
* "SAUDISAT 1C (SO-50)" has it inside, "ISS (ZARYA)" has the useful part outside.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `looks on both sides of the parentheses`() {
|
||||||
|
assertEquals("IO-117", WaveLogApi.normalizeSatName("GREENCUBE (IO-117)"))
|
||||||
|
assertEquals("ARISS", WaveLogApi.normalizeSatName("ARISS (ZARYA)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sources write RS15 where LoTW has RS-15; a hyphen must not decide confirmability. */
|
||||||
|
@Test
|
||||||
|
fun `a differing hyphen still matches`() {
|
||||||
|
assertEquals("RS-15", WaveLogApi.normalizeSatName("RADIO ROSTO (RS15)"))
|
||||||
|
assertEquals("UKUBE1", WaveLogApi.normalizeSatName("UKUBE-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formation launches are catalogued with a companion object appended. */
|
||||||
|
@Test
|
||||||
|
fun `a shared launch entry resolves to the satellite`() {
|
||||||
|
assertEquals("CAS-2T", WaveLogApi.normalizeSatName("CAS-2T & KS-1Q"))
|
||||||
|
assertEquals("RS-44", WaveLogApi.normalizeSatName("RS-44 & BREEZE-KM R/B"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LoTW's list is not all upper case - Arsene is written that way - and uploading ARSENE
|
||||||
|
* would be rejected just as AO7 is.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the spelling LoTW uses is preserved rather than upper-cased`() {
|
||||||
|
assertEquals("Arsene", WaveLogApi.normalizeSatName("ARSENE"))
|
||||||
|
assertEquals("Arsene", WaveLogApi.normalizeSatName("arsene"))
|
||||||
|
assertEquals("Arsene", WaveLogApi.normalizeSatName("Arsene"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An unknown satellite is passed through rather than dropped: the QSO is still worth
|
||||||
|
* saving, and the caller uses [WaveLogApi.isLotwSatellite] to tell the operator that
|
||||||
|
* this one will not be confirmed.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an unknown satellite is passed through and reported as unknown`() {
|
||||||
|
assertEquals("CUTE-1 (CO-55)", WaveLogApi.normalizeSatName("CUTE-1 (CO-55)"))
|
||||||
|
assertFalse(WaveLogApi.isLotwSatellite("CUTE-1 (CO-55)"))
|
||||||
|
assertFalse(WaveLogApi.isLotwSatellite("CUBESAT XI-V"))
|
||||||
|
assertFalse(WaveLogApi.isLotwSatellite("SWISSCUBE"))
|
||||||
|
assertTrue(WaveLogApi.isLotwSatellite("SAUDISAT 1C (SO-50)"))
|
||||||
|
assertTrue(WaveLogApi.isLotwSatellite("ASRTU-1", 61781))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty name does not crash`() {
|
||||||
|
assertEquals("", WaveLogApi.normalizeSatName(""))
|
||||||
|
assertEquals("", WaveLogApi.normalizeSatName(" "))
|
||||||
|
assertFalse(WaveLogApi.isLotwSatellite(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A catalogue number nobody mapped falls through to the name rather than returning null. */
|
||||||
|
@Test
|
||||||
|
fun `an unmapped catalogue number falls back to the name`() {
|
||||||
|
assertEquals("SO-50", WaveLogApi.normalizeSatName("SAUDISAT 1C (SO-50)", 99999))
|
||||||
|
}
|
||||||
|
}
|
||||||
+262
@@ -0,0 +1,262 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule these pin down: a 200 is not an acceptance.
|
||||||
|
*
|
||||||
|
* Wavelog validates after responding and reports the outcome in the body, so a rejected QSO comes
|
||||||
|
* back as HTTP 200 with `{"status":"failed"}`. The uploader trusted the code alone, marked the QSO
|
||||||
|
* uploaded and dropped it from the queue - the same failure as the APRS reporter claiming a send
|
||||||
|
* succeeded when nothing had left the phone.
|
||||||
|
*
|
||||||
|
* The response shapes are transcribed from the Wavelog API reference at
|
||||||
|
* docs.wavelog.org/developer/api, not invented.
|
||||||
|
*/
|
||||||
|
class WavelogResponseTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a success body is accepted`() {
|
||||||
|
assertTrue(
|
||||||
|
WavelogResponse.verdict(201, """{"status":"success","message":"1 locations imported."}""")
|
||||||
|
is WavelogResponse.Verdict.Accepted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reference uses both spellings; treating one as unknown would requeue a stored QSO. */
|
||||||
|
@Test
|
||||||
|
fun `successful is recognised as well as success`() {
|
||||||
|
assertTrue(
|
||||||
|
WavelogResponse.verdict(200, """{"status":"successful","members":[]}""")
|
||||||
|
is WavelogResponse.Verdict.Accepted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The defect this class exists for. */
|
||||||
|
@Test
|
||||||
|
fun `a failure body beats a success code`() {
|
||||||
|
val verdict = WavelogResponse.verdict(200, """{"status":"failed","reason":"No club members found"}""")
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Rejected("No club members found"),
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an error status with a message is rejected with that message`() {
|
||||||
|
val verdict = WavelogResponse.verdict(401, """{"status":"error","message":"Auth Error, invalid key"}""")
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Rejected("Auth Error, invalid key"),
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wavelog's own duplicate wording, which arrives with a 200 rather than a 409. */
|
||||||
|
@Test
|
||||||
|
fun `a dupe status is a duplicate not a failure`() {
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Duplicate,
|
||||||
|
WavelogResponse.verdict(200, """{"status":"dupe","message":"0 locations imported."}""")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a 409 is a duplicate whatever the body says`() {
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Duplicate,
|
||||||
|
WavelogResponse.verdict(409, "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A duplicate is safe to drop from the queue: the log holds the QSO either way. */
|
||||||
|
@Test
|
||||||
|
fun `a duplicate is not treated as an error`() {
|
||||||
|
val verdict = WavelogResponse.verdict(200, """{"status":"dupe"}""")
|
||||||
|
assertTrue(verdict !is WavelogResponse.Verdict.Rejected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The v1 endpoint answers an accepted ADIF record with a success code and nothing else. */
|
||||||
|
@Test
|
||||||
|
fun `an empty body with a success code is accepted`() {
|
||||||
|
assertTrue(
|
||||||
|
WavelogResponse.verdict(200, "") is WavelogResponse.Verdict.Accepted
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
WavelogResponse.verdict(200, " ") is WavelogResponse.Verdict.Accepted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An unrecognised status must not read as success. Keeping the QSO queued costs a retry;
|
||||||
|
* dropping it loses the contact.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an unrecognised status keeps the QSO queued`() {
|
||||||
|
val verdict = WavelogResponse.verdict(200, """{"status":"something-new"}""")
|
||||||
|
assertTrue("got $verdict", verdict is WavelogResponse.Verdict.Unreadable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a non-success code without a body reports the code`() {
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Rejected("HTTP 500"),
|
||||||
|
WavelogResponse.verdict(500, "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whitespace in the JSON must not change the verdict - servers format differently. */
|
||||||
|
@Test
|
||||||
|
fun `spacing in the json does not change the verdict`() {
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Rejected("nope"),
|
||||||
|
WavelogResponse.verdict(200, """{ "status" : "failed" , "reason" : "nope" }""")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A messages array, which the ADIF endpoint returns for a malformed record. */
|
||||||
|
@Test
|
||||||
|
fun `a messages array supplies the reason`() {
|
||||||
|
val verdict = WavelogResponse.verdict(200, """{"status":"failed","messages":["Bad ADIF field"]}""")
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Rejected("Bad ADIF field"),
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case must not matter: the marker comparison lower-cases the body. */
|
||||||
|
@Test
|
||||||
|
fun `an upper case status is still understood`() {
|
||||||
|
assertTrue(
|
||||||
|
WavelogResponse.verdict(200, """{"STATUS":"FAILED","reason":"x"}""")
|
||||||
|
is WavelogResponse.Verdict.Rejected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The defect that lost contacts. Wavelog's own rejection text is "Duplicate for <call>", and
|
||||||
|
* Api_v2 surfaces that inside validation_error bodies - so matching the bare word classified a
|
||||||
|
* hard rejection as a duplicate, which maps to success and drops the QSO from the queue.
|
||||||
|
*
|
||||||
|
* Bodies transcribed from the Wavelog server source, not from its documentation.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the word duplicate in a rejection does not make it a duplicate`() {
|
||||||
|
val bodies = listOf(
|
||||||
|
400 to """{"error":{"code":"validation_error","message":"duplicate submode is invalid"}}""",
|
||||||
|
400 to """{"status":"failed","reason":"duplicate key violation; QSO NOT stored"}""",
|
||||||
|
200 to """{"status":"failed","reason":"Duplicate for BG7NTA"}"""
|
||||||
|
)
|
||||||
|
for ((code, body) in bodies) {
|
||||||
|
val verdict = WavelogResponse.verdict(code, body)
|
||||||
|
assertTrue(
|
||||||
|
"must be a rejection, got " + verdict + " for " + body,
|
||||||
|
verdict is WavelogResponse.Verdict.Rejected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A success that mentions the word must still be a success. */
|
||||||
|
@Test
|
||||||
|
fun `the word duplicate in a success does not make it a failure`() {
|
||||||
|
val verdict = WavelogResponse.verdict(
|
||||||
|
201,
|
||||||
|
"""{"status":"created","adif_errors":0,"messages":["Removed duplicate mode entry"]}"""
|
||||||
|
)
|
||||||
|
assertTrue("got " + verdict, verdict is WavelogResponse.Verdict.Accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reverse proxy or a PHP fatal answers 200 with HTML. There is no status token, so it used to
|
||||||
|
* read as a stored QSO and a misconfigured proxy would eat contacts silently.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an html body is never an acceptance`() {
|
||||||
|
val pages = listOf(
|
||||||
|
"<!DOCTYPE html><html><head><title>502 Bad Gateway</title></head></html>",
|
||||||
|
"<html><body><h1>Maintenance</h1></body></html>",
|
||||||
|
"<br /><b>Fatal error</b>: Uncaught Error in /var/www/index.php"
|
||||||
|
)
|
||||||
|
for (page in pages) {
|
||||||
|
val verdict = WavelogResponse.verdict(200, page)
|
||||||
|
assertTrue(
|
||||||
|
"html must not be accepted, got " + verdict,
|
||||||
|
verdict is WavelogResponse.Verdict.Unreadable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The v2 endpoint reports errors in an envelope with no status key at all. */
|
||||||
|
@Test
|
||||||
|
fun `a v2 error envelope is a rejection`() {
|
||||||
|
val verdict = WavelogResponse.verdict(
|
||||||
|
401,
|
||||||
|
"""{"error":{"code":"invalid_token","message":"Bearer token must start with wl2_"}}"""
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Rejected("Bearer token must start with wl2_"),
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The QSO endpoint answers `created`, which is what the v1 source actually emits. */
|
||||||
|
@Test
|
||||||
|
fun `the created status the qso endpoint returns is accepted`() {
|
||||||
|
val verdict = WavelogResponse.verdict(
|
||||||
|
201,
|
||||||
|
"""{"status":"created","adif_count":1,"adif_errors":0,"messages":[""]}"""
|
||||||
|
)
|
||||||
|
assertTrue("got " + verdict, verdict is WavelogResponse.Verdict.Accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v1 uses `abort` with a 400 when any record in a batch failed. Not a success. */
|
||||||
|
@Test
|
||||||
|
fun `an abort status is a rejection`() {
|
||||||
|
val verdict = WavelogResponse.verdict(
|
||||||
|
400,
|
||||||
|
"""{"status":"abort","messages":["Bad ADIF field CALL"]}"""
|
||||||
|
)
|
||||||
|
assertTrue("got " + verdict, verdict is WavelogResponse.Verdict.Rejected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bulk reply that stored nothing is not an acceptance, whatever its status says. */
|
||||||
|
@Test
|
||||||
|
fun `a reply that imported nothing is a rejection`() {
|
||||||
|
for (body in listOf("""{"imported":0,"skipped":3}""",
|
||||||
|
"""{"status":"created","adif_count":0,"adif_errors":1,"messages":["bad"]}""")) {
|
||||||
|
assertTrue(
|
||||||
|
"must not be accepted: " + body,
|
||||||
|
WavelogResponse.verdict(200, body) is WavelogResponse.Verdict.Rejected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The count check must not misfire on a real success. v1 answers with `adif_errors:0` next to
|
||||||
|
* `adif_count:1`, and matching the wrong key would reject every stored QSO.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `adif_errors zero does not look like nothing imported`() {
|
||||||
|
for (body in listOf(
|
||||||
|
"""{"status":"created","adif_count":1,"adif_errors":0,"messages":[""]}""",
|
||||||
|
"""{"status":"created","adif_errors":0}""",
|
||||||
|
"""{"imported":2,"skipped":1}"""
|
||||||
|
)) {
|
||||||
|
assertTrue(
|
||||||
|
"must still be accepted: " + body,
|
||||||
|
WavelogResponse.verdict(200, body) is WavelogResponse.Verdict.Accepted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A failure with no explanation still has to say something usable. */
|
||||||
|
@Test
|
||||||
|
fun `a failure without a reason still reports one`() {
|
||||||
|
val verdict = WavelogResponse.verdict(200, """{"status":"failed"}""")
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Rejected("server reported failure"),
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<string name="prefs_aprs_callsign">Tanda panggilan</string>
|
<string name="prefs_aprs_callsign">Tanda panggilan</string>
|
||||||
<string name="prefs_aprs_ssid">SSID</string>
|
<string name="prefs_aprs_ssid">SSID</string>
|
||||||
<string name="prefs_aprs_passcode">Kode sandi</string>
|
<string name="prefs_aprs_passcode">Kode sandi</string>
|
||||||
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string>
|
<string name="prefs_aprs_passcode_hint">Passcode kosong = mode terima-saja, laporan tidak diteruskan</string>
|
||||||
<string name="prefs_aprs_interval">Interval (menit)</string>
|
<string name="prefs_aprs_interval">Interval (menit)</string>
|
||||||
<string name="prefs_aprs_status">Teks status</string>
|
<string name="prefs_aprs_status">Teks status</string>
|
||||||
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
|
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
|
||||||
@@ -35,9 +35,10 @@
|
|||||||
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
|
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
|
||||||
<string name="aprs_notif_error">Kesalahan koneksi</string>
|
<string name="aprs_notif_error">Kesalahan koneksi</string>
|
||||||
<string name="aprs_notif_stop">Berhenti</string>
|
<string name="aprs_notif_stop">Berhenti</string>
|
||||||
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
|
<string name="prefs_aprs_request_passcode">Minta kode sandi</string>
|
||||||
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
|
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
|
||||||
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
|
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
|
||||||
|
<string name="aprs_toast_unverified">APRS-IS tidak memverifikasi passcode Anda. Laporan Anda tidak diteruskan - periksa tanda panggil dan passcode di Pengaturan.</string>
|
||||||
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
|
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
|
||||||
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
|
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
|
||||||
<string name="prefs_aprs_last_ok">OK</string>
|
<string name="prefs_aprs_last_ok">OK</string>
|
||||||
@@ -93,33 +94,29 @@
|
|||||||
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
||||||
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* BARU: air terjun CW menampilkan seluruh pita audio, nada di luar jangkauan dekoder tetap terlihat
|
* FIX: APRS reported every beacon as sent even when nothing left the phone
|
||||||
* BARU: teks CW mengikuti isi baru, dan berhenti mengikuti saat Anda menggulir ke atas
|
* FIX: APRS could not start at all on Android 10 and later
|
||||||
* BARU: pembaca layar kini menjelaskan air terjun CW dan sel hari AMSAT
|
* FIX: the APRS login line was malformed and real servers refused it
|
||||||
* BARU: air terjun CW menandai posisi nada sebenarnya, bahkan saat di luar rentang dekoder
|
* FIX: a refused APRS login is now reported instead of being read as success
|
||||||
* BARU: satu baris di bawah air terjun menyebut nada dan apakah nada dipindahkan ke rentang
|
* FIX: APRS no longer invents a transmit passcode - request one from the link in settings
|
||||||
* BARU: meter sinyal CW tidak lagi tinggi untuk nada yang tak terdengar dekoder
|
* FIX: a mistyped passcode is no longer described as receive-only mode
|
||||||
* BARU: pilih gaya hari AMSAT di Pengaturan - dua belas garis, atau satu warna dengan jumlah laporan
|
* FIX: APRS refuses to beacon without a position instead of sending 0N 0E
|
||||||
* BARU: Status AMSAT menampilkan 12 garis dua jam per hari — pemadaman dalam hari kini terlihat
|
* NEW: pick your APRS map symbol from a list; the default is a house, not a car
|
||||||
* BARU: Status AMSAT menggunakan hari kalender UTC, cocok dengan halaman resmi
|
* NEW: APRS beacons survive a locked screen
|
||||||
* BARU: dua abu-abu membedakan apakah slot tidak dilaporkan atau tidak pernah diambil
|
* FIX: WaveLog uploads read the reply from the server - a rejected contact stayed reported as sent
|
||||||
* BARU: penanda cakupan data menunjukkan saat satelit yang lebih sepi terdesak keluar
|
* FIX: a rejected contact now stays in the queue instead of being dropped
|
||||||
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
|
* FIX: the satellite name sent to WaveLog is resolved from the catalogue number
|
||||||
* BARU: menyertakan model fp32 penuh untuk akurasi tertinggi
|
* FIX: the upload count no longer includes contacts sent days ago
|
||||||
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
|
* NEW: the contact time can be set, for logging a pass after it ends
|
||||||
* Waterfall CW: skema warna inferno baru dengan gradasi halus
|
* NEW: the counterpart grid can be typed, and is checked before it is logged
|
||||||
* Menu Lainnya: panel ramping di kanan, tanpa lapisan gelap
|
* FIX: the mode no longer keeps the value from the previous transponder
|
||||||
* Halaman CW: tombol kembali yang tidak berfungsi dan baris status yang menyesatkan dihapus
|
* FIX: a callsign that cannot be logged says why instead of vanishing
|
||||||
* Dukungan 64-bit (arm64) dipulihkan
|
* FIX: a custom TLE URL now replaces the built-in sources instead of adding to them
|
||||||
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
|
* FIX: the custom source switch no longer turns itself off
|
||||||
* Perbaikan: memindahkan halaman ke menu utama tidak lagi menyembunyikan Pengaturan secara permanen
|
* FIX: a dead custom URL no longer counts as a successful update
|
||||||
* Perbaikan: AMSAT/WavelogLog kini dapat dipindahkan ke menu utama
|
* FIX: QRZ tells an expired cookie apart from a station with no grid on file
|
||||||
* Perbaikan: penguraian epoch satelit untuk waktu dalam 86 detik pertama tengah malam UTC
|
* FIX: contacts can be deleted with a screen reader
|
||||||
* Perbaikan: halaman radar kini otomatis beralih ke pass berikutnya setelah pass saat ini berakhir
|
* FIX: the log table grid and the sent column are readable in sunlight and at night
|
||||||
* Perbaikan: perhitungan Doppler radar kini menggunakan nilai slider offset langsung
|
|
||||||
* Perbaikan: perhitungan kemajuan pass dijaga terhadap pembagian dengan nol
|
|
||||||
* Perbaikan: panggilan calculatePasses bersamaan tidak lagi memicu perhitungan duplikat
|
|
||||||
* Perbaikan: pengiriman QSO duplikat WaveLog dan kondisi balapan pembaruan grid square
|
|
||||||
</string>
|
</string>
|
||||||
<string name="radar_back">Kembali</string>
|
<string name="radar_back">Kembali</string>
|
||||||
<string name="radar_notify">Beri tahu</string>
|
<string name="radar_notify">Beri tahu</string>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<string name="prefs_aprs_callsign">Tanda panggilan</string>
|
<string name="prefs_aprs_callsign">Tanda panggilan</string>
|
||||||
<string name="prefs_aprs_ssid">SSID</string>
|
<string name="prefs_aprs_ssid">SSID</string>
|
||||||
<string name="prefs_aprs_passcode">Kode sandi</string>
|
<string name="prefs_aprs_passcode">Kode sandi</string>
|
||||||
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string>
|
<string name="prefs_aprs_passcode_hint">Passcode kosong = mode terima-saja, laporan tidak diteruskan</string>
|
||||||
<string name="prefs_aprs_interval">Interval (menit)</string>
|
<string name="prefs_aprs_interval">Interval (menit)</string>
|
||||||
<string name="prefs_aprs_status">Teks status</string>
|
<string name="prefs_aprs_status">Teks status</string>
|
||||||
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
|
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
|
||||||
@@ -35,9 +35,10 @@
|
|||||||
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
|
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
|
||||||
<string name="aprs_notif_error">Kesalahan koneksi</string>
|
<string name="aprs_notif_error">Kesalahan koneksi</string>
|
||||||
<string name="aprs_notif_stop">Berhenti</string>
|
<string name="aprs_notif_stop">Berhenti</string>
|
||||||
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
|
<string name="prefs_aprs_request_passcode">Minta kode sandi</string>
|
||||||
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
|
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
|
||||||
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
|
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
|
||||||
|
<string name="aprs_toast_unverified">APRS-IS tidak memverifikasi passcode Anda. Laporan Anda tidak diteruskan - periksa tanda panggil dan passcode di Pengaturan.</string>
|
||||||
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
|
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
|
||||||
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
|
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
|
||||||
<string name="prefs_aprs_last_ok">OK</string>
|
<string name="prefs_aprs_last_ok">OK</string>
|
||||||
@@ -92,33 +93,29 @@
|
|||||||
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
||||||
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* BARU: air terjun CW menampilkan seluruh pita audio, nada di luar jangkauan dekoder tetap terlihat
|
* FIX: APRS reported every beacon as sent even when nothing left the phone
|
||||||
* BARU: teks CW mengikuti isi baru, dan berhenti mengikuti saat Anda menggulir ke atas
|
* FIX: APRS could not start at all on Android 10 and later
|
||||||
* BARU: pembaca layar kini menjelaskan air terjun CW dan sel hari AMSAT
|
* FIX: the APRS login line was malformed and real servers refused it
|
||||||
* BARU: air terjun CW menandai posisi nada sebenarnya, bahkan saat di luar rentang dekoder
|
* FIX: a refused APRS login is now reported instead of being read as success
|
||||||
* BARU: satu baris di bawah air terjun menyebut nada dan apakah nada dipindahkan ke rentang
|
* FIX: APRS no longer invents a transmit passcode - request one from the link in settings
|
||||||
* BARU: meter sinyal CW tidak lagi tinggi untuk nada yang tak terdengar dekoder
|
* FIX: a mistyped passcode is no longer described as receive-only mode
|
||||||
* BARU: pilih gaya hari AMSAT di Pengaturan - dua belas garis, atau satu warna dengan jumlah laporan
|
* FIX: APRS refuses to beacon without a position instead of sending 0N 0E
|
||||||
* BARU: Status AMSAT menampilkan 12 garis dua jam per hari — pemadaman dalam hari kini terlihat
|
* NEW: pick your APRS map symbol from a list; the default is a house, not a car
|
||||||
* BARU: Status AMSAT menggunakan hari kalender UTC, cocok dengan halaman resmi
|
* NEW: APRS beacons survive a locked screen
|
||||||
* BARU: dua abu-abu membedakan apakah slot tidak dilaporkan atau tidak pernah diambil
|
* FIX: WaveLog uploads read the reply from the server - a rejected contact stayed reported as sent
|
||||||
* BARU: penanda cakupan data menunjukkan saat satelit yang lebih sepi terdesak keluar
|
* FIX: a rejected contact now stays in the queue instead of being dropped
|
||||||
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
|
* FIX: the satellite name sent to WaveLog is resolved from the catalogue number
|
||||||
* BARU: menyertakan model fp32 penuh untuk akurasi tertinggi
|
* FIX: the upload count no longer includes contacts sent days ago
|
||||||
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
|
* NEW: the contact time can be set, for logging a pass after it ends
|
||||||
* Waterfall CW: skema warna inferno baru dengan gradasi halus
|
* NEW: the counterpart grid can be typed, and is checked before it is logged
|
||||||
* Menu Lainnya: panel ramping di kanan, tanpa lapisan gelap
|
* FIX: the mode no longer keeps the value from the previous transponder
|
||||||
* Halaman CW: tombol kembali yang tidak berfungsi dan baris status yang menyesatkan dihapus
|
* FIX: a callsign that cannot be logged says why instead of vanishing
|
||||||
* Dukungan 64-bit (arm64) dipulihkan
|
* FIX: a custom TLE URL now replaces the built-in sources instead of adding to them
|
||||||
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
|
* FIX: the custom source switch no longer turns itself off
|
||||||
* Perbaikan: memindahkan halaman ke menu utama tidak lagi menyembunyikan Pengaturan secara permanen
|
* FIX: a dead custom URL no longer counts as a successful update
|
||||||
* Perbaikan: AMSAT/WavelogLog kini dapat dipindahkan ke menu utama
|
* FIX: QRZ tells an expired cookie apart from a station with no grid on file
|
||||||
* Perbaikan: penguraian epoch satelit untuk waktu dalam 86 detik pertama tengah malam UTC
|
* FIX: contacts can be deleted with a screen reader
|
||||||
* Perbaikan: halaman radar kini otomatis beralih ke pass berikutnya setelah pass saat ini berakhir
|
* FIX: the log table grid and the sent column are readable in sunlight and at night
|
||||||
* Perbaikan: perhitungan Doppler radar kini menggunakan nilai slider offset langsung
|
|
||||||
* Perbaikan: perhitungan kemajuan pass dijaga terhadap pembagian dengan nol
|
|
||||||
* Perbaikan: panggilan calculatePasses bersamaan tidak lagi memicu perhitungan duplikat
|
|
||||||
* Perbaikan: pengiriman QSO duplikat WaveLog dan kondisi balapan pembaruan grid square
|
|
||||||
</string>
|
</string>
|
||||||
<string name="radar_back">Kembali</string>
|
<string name="radar_back">Kembali</string>
|
||||||
<string name="radar_notify">Beri tahu</string>
|
<string name="radar_notify">Beri tahu</string>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<string name="prefs_aprs_callsign">Çağrı işareti</string>
|
<string name="prefs_aprs_callsign">Çağrı işareti</string>
|
||||||
<string name="prefs_aprs_ssid">SSID</string>
|
<string name="prefs_aprs_ssid">SSID</string>
|
||||||
<string name="prefs_aprs_passcode">Parola</string>
|
<string name="prefs_aprs_passcode">Parola</string>
|
||||||
<string name="prefs_aprs_passcode_hint">Parola boş = çağrıdan otomatik hesaplanır</string>
|
<string name="prefs_aprs_passcode_hint">Passcode bos = yalnizca alma modu, raporlar iletilmez</string>
|
||||||
<string name="prefs_aprs_interval">Aralık (dakika)</string>
|
<string name="prefs_aprs_interval">Aralık (dakika)</string>
|
||||||
<string name="prefs_aprs_status">Durum metni</string>
|
<string name="prefs_aprs_status">Durum metni</string>
|
||||||
<string name="prefs_aprs_symbol_table">Sembol tablosu</string>
|
<string name="prefs_aprs_symbol_table">Sembol tablosu</string>
|
||||||
@@ -36,9 +36,10 @@
|
|||||||
<string name="aprs_notif_connected">APRS-IS bağlı</string>
|
<string name="aprs_notif_connected">APRS-IS bağlı</string>
|
||||||
<string name="aprs_notif_error">Bağlantı hatası</string>
|
<string name="aprs_notif_error">Bağlantı hatası</string>
|
||||||
<string name="aprs_notif_stop">Durdur</string>
|
<string name="aprs_notif_stop">Durdur</string>
|
||||||
<string name="prefs_aprs_calc_passcode">Parolayı hesapla</string>
|
<string name="prefs_aprs_request_passcode">Passcode isteyin</string>
|
||||||
<string name="aprs_toast_ok">APRS: rapor gönderildi</string>
|
<string name="aprs_toast_ok">APRS: rapor gönderildi</string>
|
||||||
<string name="aprs_toast_fail">APRS: rapor başarısız - %1$s</string>
|
<string name="aprs_toast_fail">APRS: rapor başarısız - %1$s</string>
|
||||||
|
<string name="aprs_toast_unverified">APRS-IS passcode bilginizi doğrulamadı. Raporlarınız iletilmiyor - Ayarlar bölümünde çağrı işareti ve passcode bilgisini kontrol edin.</string>
|
||||||
<string name="aprs_toast_not_configured">APRS yapılandırılmadı - önce çağrı girin</string>
|
<string name="aprs_toast_not_configured">APRS yapılandırılmadı - önce çağrı girin</string>
|
||||||
<string name="prefs_aprs_last_report">Son rapor: %1$s %2$s</string>
|
<string name="prefs_aprs_last_report">Son rapor: %1$s %2$s</string>
|
||||||
<string name="prefs_aprs_last_ok">OK</string>
|
<string name="prefs_aprs_last_ok">OK</string>
|
||||||
@@ -97,33 +98,29 @@
|
|||||||
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
|
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
|
||||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* YENİ: CW şelalesi tüm ses bandını gösterir, çözücünün ulaşamadığı ton da görünür
|
* FIX: APRS reported every beacon as sent even when nothing left the phone
|
||||||
* YENİ: CW metni yeni içeriği takip eder, geri kaydırdığınızda takibi bırakır
|
* FIX: APRS could not start at all on Android 10 and later
|
||||||
* YENİ: ekran okuyucular artık CW şelalesini ve AMSAT gün hücrelerini okuyor
|
* FIX: the APRS login line was malformed and real servers refused it
|
||||||
* YENİ: CW şelalesi, tonunuz çözücü aralığının dışında olsa bile gerçek yerini işaretler
|
* FIX: a refused APRS login is now reported instead of being read as success
|
||||||
* YENİ: şelalenin altındaki satır tonu adlandırır ve aralığa taşınıp taşınmadığını söyler
|
* FIX: APRS no longer invents a transmit passcode - request one from the link in settings
|
||||||
* YENİ: çözücünün duyamadığı bir ton için CW sinyal göstergesi artık yüksek okumuyor
|
* FIX: a mistyped passcode is no longer described as receive-only mode
|
||||||
* YENİ: AMSAT gün stilini Ayarlar\'dan seçin - on iki şerit veya rapor sayılı tek renk
|
* FIX: APRS refuses to beacon without a position instead of sending 0N 0E
|
||||||
* YENİ: AMSAT durumu günde 12 iki saatlik şerit gösterir — gün içi kesintiler görünür
|
* NEW: pick your APRS map symbol from a list; the default is a house, not a car
|
||||||
* YENİ: AMSAT durumu UTC takvim günlerini kullanır, resmi sayfayla eşleşir
|
* NEW: APRS beacons survive a locked screen
|
||||||
* YENİ: iki gri, bir yuvanın rapor edilmediğini mi yoksa hiç alınmadığını mı ayırt eder
|
* FIX: WaveLog uploads read the reply from the server - a rejected contact stayed reported as sent
|
||||||
* YENİ: veri kapsamı işareti, daha sessiz uyduların küresel çekimden dışlandığını gösterir
|
* FIX: a rejected contact now stays in the queue instead of being dropped
|
||||||
* YENİ: CW çözücü artık DeepCW sinir ağını kullanıyor — zayıf sinyal Morse çözümü çok daha iyi
|
* FIX: the satellite name sent to WaveLog is resolved from the catalogue number
|
||||||
* YENİ: en yüksek doğruluk için tam fp32 DeepCW modeli dahil
|
* FIX: the upload count no longer includes contacts sent days ago
|
||||||
* YENİ: CW çözüm geçmişi kalıcı olarak saklanır (metin artık kaybolmaz)
|
* NEW: the contact time can be set, for logging a pass after it ends
|
||||||
* CW şelale: yeni inferno renk şeması ve yumuşak geçişler
|
* NEW: the counterpart grid can be typed, and is checked before it is logged
|
||||||
* Daha Fazla menüsü: sağda ince panel, karartma yok
|
* FIX: the mode no longer keeps the value from the previous transponder
|
||||||
* CW sayfası: işlevsiz geri düğmesi ve yanıltıcı durum satırı kaldırıldı
|
* FIX: a callsign that cannot be logged says why instead of vanishing
|
||||||
* 64-bit (arm64) desteği geri geldi
|
* FIX: a custom TLE URL now replaces the built-in sources instead of adding to them
|
||||||
* Düzeltme: duraklat/devam artık hatalı "çözümleme başarısız" mesajı göstermiyor
|
* FIX: the custom source switch no longer turns itself off
|
||||||
* Düzeltme: sayfaları ana menüye taşımak artık Ayarlar\'ı kalıcı olarak gizlemiyor
|
* FIX: a dead custom URL no longer counts as a successful update
|
||||||
* Düzeltme: AMSAT/WavelogLog artık ana menüye taşınabiliyor
|
* FIX: QRZ tells an expired cookie apart from a station with no grid on file
|
||||||
* Düzeltme: UTC gece yarısından sonraki ilk 86 saniye içinde uydu epoch ayrıştırma hatası
|
* FIX: contacts can be deleted with a screen reader
|
||||||
* Düzeltme: radar sayfası mevcut geçiş sona erdiğinde otomatik olarak sonraki geçişe geçiyor
|
* FIX: the log table grid and the sent column are readable in sunlight and at night
|
||||||
* Düzeltme: radar Doppler hesaplaması artık canlı kayma kaydırıcı değerini kullanıyor
|
|
||||||
* Düzeltme: geçiş ilerleme hesaplaması sıfıra bölme durumuna karşı korumalı
|
|
||||||
* Düzeltme: eşzamanlı calculatePasses çağrıları artık yinelenen hesaplamaları tetiklemiyor
|
|
||||||
* Düzeltme: WaveLog yinelenen QSO gönderimleri ve ızgara kare güncellemesi yarış durumları
|
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
|
|||||||
@@ -24,10 +24,27 @@
|
|||||||
<string name="prefs_aprs_callsign">呼号</string>
|
<string name="prefs_aprs_callsign">呼号</string>
|
||||||
<string name="prefs_aprs_ssid">SSID</string>
|
<string name="prefs_aprs_ssid">SSID</string>
|
||||||
<string name="prefs_aprs_passcode">验证码</string>
|
<string name="prefs_aprs_passcode">验证码</string>
|
||||||
<string name="prefs_aprs_passcode_hint">验证码留空 = 按呼号自动计算</string>
|
<string name="prefs_aprs_passcode_hint">验证码留空 = 只收模式, 上报不会被转发</string>
|
||||||
<string name="prefs_aprs_interval">周期(分钟)</string>
|
<string name="prefs_aprs_interval">周期(分钟)</string>
|
||||||
<string name="prefs_aprs_status">状态文本</string>
|
<string name="prefs_aprs_status">状态文本</string>
|
||||||
<string name="prefs_aprs_symbol_table">符号表</string>
|
<string name="prefs_aprs_symbol_table">符号表</string>
|
||||||
|
<string name="aprs_symbol_house">房屋 - 固定台站</string>
|
||||||
|
<string name="aprs_symbol_house_alt">房屋, 备用符号表</string>
|
||||||
|
<string name="aprs_symbol_person">步行操作员</string>
|
||||||
|
<string name="aprs_symbol_yagi">固定站八木天线</string>
|
||||||
|
<string name="aprs_symbol_satellite">卫星台站</string>
|
||||||
|
<string name="aprs_symbol_portable">便携 / 野外运行</string>
|
||||||
|
<string name="aprs_symbol_phone">手机</string>
|
||||||
|
<string name="aprs_symbol_tcpip">仅互联网台站</string>
|
||||||
|
<string name="aprs_symbol_ht">手持电台</string>
|
||||||
|
<string name="aprs_symbol_car">轿车</string>
|
||||||
|
<string name="aprs_symbol_truck">卡车</string>
|
||||||
|
<string name="aprs_symbol_van">厢式车</string>
|
||||||
|
<string name="aprs_symbol_rv">房车</string>
|
||||||
|
<string name="aprs_symbol_bike">自行车</string>
|
||||||
|
<string name="prefs_aprs_symbol">地图符号</string>
|
||||||
|
<string name="prefs_aprs_symbol_custom">自定义 (%1$s%2$s)</string>
|
||||||
|
<string name="prefs_aprs_symbol_help">你的台站在 APRS 地图上的显示方式</string>
|
||||||
<string name="prefs_aprs_symbol_code">符号码</string>
|
<string name="prefs_aprs_symbol_code">符号码</string>
|
||||||
<string name="prefs_aprs_save">保存</string>
|
<string name="prefs_aprs_save">保存</string>
|
||||||
<string name="prefs_aprs_cancel">取消</string>
|
<string name="prefs_aprs_cancel">取消</string>
|
||||||
@@ -37,9 +54,10 @@
|
|||||||
<string name="aprs_notif_connected">已连接 APRS-IS</string>
|
<string name="aprs_notif_connected">已连接 APRS-IS</string>
|
||||||
<string name="aprs_notif_error">连接错误</string>
|
<string name="aprs_notif_error">连接错误</string>
|
||||||
<string name="aprs_notif_stop">停止</string>
|
<string name="aprs_notif_stop">停止</string>
|
||||||
<string name="prefs_aprs_calc_passcode">计算验证码</string>
|
<string name="prefs_aprs_request_passcode">申请验证码</string>
|
||||||
<string name="aprs_toast_ok">APRS: 上报成功</string>
|
<string name="aprs_toast_ok">APRS: 上报成功</string>
|
||||||
<string name="aprs_toast_fail">APRS: 上报失败 - %1$s</string>
|
<string name="aprs_toast_fail">APRS: 上报失败 - %1$s</string>
|
||||||
|
<string name="aprs_toast_unverified">APRS-IS 未验证你的 passcode, 上报不会被转发 - 请在设置里检查呼号与 passcode。</string>
|
||||||
<string name="aprs_toast_not_configured">APRS 未配置,请先填呼号</string>
|
<string name="aprs_toast_not_configured">APRS 未配置,请先填呼号</string>
|
||||||
<string name="prefs_aprs_last_report">上次上报: %1$s %2$s</string>
|
<string name="prefs_aprs_last_report">上次上报: %1$s %2$s</string>
|
||||||
<string name="prefs_aprs_last_ok">成功</string>
|
<string name="prefs_aprs_last_ok">成功</string>
|
||||||
@@ -89,33 +107,29 @@
|
|||||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||||
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
|
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* 新: CW 瀑布图显示完整音频频段, 解码范围外的音调也看得见
|
* 修复: APRS 无论是否真的发出都报告成功
|
||||||
* 新: CW 文本框自动跟随新内容, 往上翻阅时自动停止跟随
|
* 修复: APRS 在 Android 10 及以上完全无法启动
|
||||||
* 新: 读屏软件现在可以播报 CW 瀑布图与 AMSAT 日格内容
|
* 修复: APRS 登录行格式错误, 真实服务器会拒绝
|
||||||
* 新: CW 瀑布图标出音调真实位置, 即使它在解码范围之外
|
* 修复: 服务器拒绝登录时不再当作成功
|
||||||
* 新: 瀑布图下方一行文字说明音调频率, 以及是否已搬入解码范围
|
* 修复: 不再代算 APRS 验证码 - 请从设置里的链接申请
|
||||||
* 新: 解码范围外的音调不再让信号强度条显示高值
|
* 修复: 验证码打错不再被说成只收模式
|
||||||
* 新: 设置里可选 AMSAT 日格样式 —— 12 条纹, 或单色加报告数
|
* 修复: 没有位置时拒绝上报, 不再发送 0N 0E
|
||||||
* 新: AMSAT 状态页每天显示 12 条两小时条纹,当日内的中断一目了然
|
* 新: APRS 地图符号改为列表选择, 默认房屋而非汽车
|
||||||
* 新: AMSAT 状态页改用 UTC 日历日, 与官方页面一致
|
* 新: APRS 上报在锁屏后继续
|
||||||
* 新: 两种灰色区分"无人上报"和"无数据"
|
* 修复: WaveLog 上传会读服务器回复 - 被拒的联络原先仍报成功
|
||||||
* 新: 数据覆盖标记, 标明被挤掉的卫星数据
|
* 修复: 被拒的联络保留在队列里, 不再被丢弃
|
||||||
* 新: CW 解码改用 DeepCW 神经网络,弱信号摩尔斯码解码率大幅提升
|
* 修复: 上传给 WaveLog 的卫星名改由编号解析
|
||||||
* 新: 内置完整版 fp32 模型,解码精度最高
|
* 修复: 上传计数不再把几天前传过的算进来
|
||||||
* 新: CW 解码历史永久保留(文字不再消失)
|
* 新: 联络时间可以设置, 便于过境结束后再记录
|
||||||
* CW 瀑布图: 全新 inferno 配色与平滑渐变
|
* 新: 对方网格可以手动输入, 并在记录前校验
|
||||||
* 更多菜单: 改为靠右窄面板,不再压暗页面
|
* 修复: 切换转发器后模式不再保留上一个的值
|
||||||
* CW 页面: 移除无效的返回键与误导性状态提示
|
* 修复: 无法记录的呼号会说明原因, 不再消失
|
||||||
* 恢复 64 位(arm64)支持
|
* 修复: 自定义 TLE 地址现在替换内置源, 而不是额外增加
|
||||||
* 修复: 暂停/恢复不再误报"解码失败"
|
* 修复: 自定义源开关不再自己关掉
|
||||||
* 修复: 移动页面到主菜单不再导致设置入口永久消失
|
* 修复: 自定义地址失效时不再算作更新成功
|
||||||
* 修复: AMSAT/WavelogLog 现在可以正常移到主菜单
|
* 修复: QRZ 能区分 Cookie 过期与对方未填网格
|
||||||
* 修复: UTC 午夜后 86 秒内的卫星历元解析错误
|
* 修复: 使用读屏软件也能删除联络
|
||||||
* 修复: 雷达页面在当前过境结束后自动切换到下一个过境
|
* 修复: 日志表格线与已传列在阳光下和夜间都看得清
|
||||||
* 修复: 雷达多普勒计算现在使用实时的偏移滑块值
|
|
||||||
* 修复: 过境进度计算防除零崩溃
|
|
||||||
* 修复: 并发 calculatePasses 调用不再触发重复计算
|
|
||||||
* 修复: WaveLog 重复提交 QSO 与网格更新竞态
|
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
@@ -256,8 +270,43 @@
|
|||||||
<string name="wavelog_title">日志</string>
|
<string name="wavelog_title">日志</string>
|
||||||
<string name="wavelog_select_transponder">选择转发器</string>
|
<string name="wavelog_select_transponder">选择转发器</string>
|
||||||
<string name="wavelog_call_hint">对方呼号</string>
|
<string name="wavelog_call_hint">对方呼号</string>
|
||||||
|
<string name="wavelog_grid_hint">网格 (可选)</string>
|
||||||
|
<string name="wavelog_grid_help">对方报给你的网格。留空则从 QRZ 查询。</string>
|
||||||
|
<string name="wavelog_grid_bad">不是有效网格</string>
|
||||||
|
<string name="wavelog_grid_square_only">仅方格 - 精度约 100 公里</string>
|
||||||
|
<string name="wavelog_grid_field_only">仅大方格 - 精度约 1000 公里</string>
|
||||||
<string name="wavelog_mode_hint">模式</string>
|
<string name="wavelog_mode_hint">模式</string>
|
||||||
|
<string name="wavelog_mode_edit">修改模式</string>
|
||||||
|
<string name="wavelog_time_hint">时间</string>
|
||||||
|
<string name="wavelog_time_live">当前</string>
|
||||||
|
<string name="wavelog_time_held">按住 %1$s</string>
|
||||||
|
<string name="wavelog_time_edit">设置记录时间</string>
|
||||||
|
<string name="wavelog_time_help">UTC 时间如 14:55, 或 +3 / -2 分钟。留空则用当前时间。</string>
|
||||||
|
<string name="wavelog_time_unrecognised">不是时间 - 将使用当前时间</string>
|
||||||
<string name="wavelog_saved">已存入本地日志</string>
|
<string name="wavelog_saved">已存入本地日志</string>
|
||||||
|
<string name="wavelog_upload_done">已上传 %1$d 条, %2$d 条仍在队列</string>
|
||||||
|
<string name="wavelog_upload_all_ok">已上传 %1$d 条</string>
|
||||||
|
<string name="wavelog_nothing_to_upload">没有待上传的联络</string>
|
||||||
|
<string name="wavelog_not_configured">未配置 WaveLog 服务器</string>
|
||||||
|
<string name="wavelog_no_station">读不到站点信息 - 请检查站点 ID 与密钥权限</string>
|
||||||
|
<string name="wavelog_upload_failed">上传失败</string>
|
||||||
|
<string name="wavelog_upload_queued">部分联络未能上传, 仍在队列中</string>
|
||||||
|
<string name="lotw_list_updated">LoTW 卫星列表已更新: %1$d 个</string>
|
||||||
|
<string name="lotw_list_failed">LoTW 列表更新失败: %1$s</string>
|
||||||
|
<string name="log_call_too_short">呼号太短</string>
|
||||||
|
<string name="log_call_illegal">呼号里不能有这个字符</string>
|
||||||
|
<string name="log_call_not_a_call">这看起来不像呼号</string>
|
||||||
|
<string name="log_call_too_long">呼号太长</string>
|
||||||
|
<string name="log_warn_grid">已保存 - 但这看起来像网格而不是呼号</string>
|
||||||
|
<string name="log_warn_worked">已保存 - %1$s 本圈已经通联过</string>
|
||||||
|
<string name="log_grid_no_cookie">查网格需要先在设置里填 QRZ Cookie</string>
|
||||||
|
<string name="log_grid_signed_out">QRZ Cookie 已过期 - 请在设置里重新粘贴</string>
|
||||||
|
<string name="log_grid_unreachable">连不上 QRZ, 没查到网格</string>
|
||||||
|
<string name="qrz_test_empty">请先粘贴 Cookie</string>
|
||||||
|
<string name="qrz_test_signed_out">Cookie 无效或已过期</string>
|
||||||
|
<string name="qrz_test_ok">登录呼号 %1$s, 网格 %2$s</string>
|
||||||
|
<string name="qrz_test_no_grid">登录呼号 %1$s, 未填写网格</string>
|
||||||
|
<string name="qrz_test_unreachable">连不上 QRZ</string>
|
||||||
<string name="wavelog_need_config">请先在设置中配置 WaveLog</string>
|
<string name="wavelog_need_config">请先在设置中配置 WaveLog</string>
|
||||||
<string name="wavelog_empty">暂无日志记录</string>
|
<string name="wavelog_empty">暂无日志记录</string>
|
||||||
<string name="wavelog_grid_mismatch">当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传?</string>
|
<string name="wavelog_grid_mismatch">当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传?</string>
|
||||||
@@ -268,6 +317,7 @@
|
|||||||
<string name="prefs_wavelog_qrz_label">Cookie</string>
|
<string name="prefs_wavelog_qrz_label">Cookie</string>
|
||||||
<string name="wavelog_copy">复制</string>
|
<string name="wavelog_copy">复制</string>
|
||||||
<string name="wavelog_delete_undo">撤销</string>
|
<string name="wavelog_delete_undo">撤销</string>
|
||||||
|
<string name="wavelog_delete_action">删除这条联络</string>
|
||||||
<string name="wavelog_col_time">时间</string>
|
<string name="wavelog_col_time">时间</string>
|
||||||
<string name="wavelog_col_freq">频率</string>
|
<string name="wavelog_col_freq">频率</string>
|
||||||
<string name="wavelog_col_sat">卫星</string>
|
<string name="wavelog_col_sat">卫星</string>
|
||||||
|
|||||||
@@ -25,10 +25,27 @@
|
|||||||
<string name="prefs_aprs_callsign">Callsign</string>
|
<string name="prefs_aprs_callsign">Callsign</string>
|
||||||
<string name="prefs_aprs_ssid">SSID</string>
|
<string name="prefs_aprs_ssid">SSID</string>
|
||||||
<string name="prefs_aprs_passcode">Passcode</string>
|
<string name="prefs_aprs_passcode">Passcode</string>
|
||||||
<string name="prefs_aprs_passcode_hint">Passcode empty = auto-computed from callsign</string>
|
<string name="prefs_aprs_passcode_hint">Passcode empty = receive-only, reports are not forwarded</string>
|
||||||
<string name="prefs_aprs_interval">Interval (min)</string>
|
<string name="prefs_aprs_interval">Interval (min)</string>
|
||||||
<string name="prefs_aprs_status">Status text</string>
|
<string name="prefs_aprs_status">Status text</string>
|
||||||
<string name="prefs_aprs_symbol_table">Symbol table</string>
|
<string name="prefs_aprs_symbol_table">Symbol table</string>
|
||||||
|
<string name="aprs_symbol_house">House - fixed station</string>
|
||||||
|
<string name="aprs_symbol_house_alt">House, alternate table</string>
|
||||||
|
<string name="aprs_symbol_person">Person on foot</string>
|
||||||
|
<string name="aprs_symbol_yagi">Yagi at a fixed site</string>
|
||||||
|
<string name="aprs_symbol_satellite">Satellite station</string>
|
||||||
|
<string name="aprs_symbol_portable">Portable / field operation</string>
|
||||||
|
<string name="aprs_symbol_phone">Phone</string>
|
||||||
|
<string name="aprs_symbol_tcpip">Internet-only station</string>
|
||||||
|
<string name="aprs_symbol_ht">Handheld radio</string>
|
||||||
|
<string name="aprs_symbol_car">Car</string>
|
||||||
|
<string name="aprs_symbol_truck">Truck</string>
|
||||||
|
<string name="aprs_symbol_van">Van</string>
|
||||||
|
<string name="aprs_symbol_rv">Motorhome</string>
|
||||||
|
<string name="aprs_symbol_bike">Bicycle</string>
|
||||||
|
<string name="prefs_aprs_symbol">Map symbol</string>
|
||||||
|
<string name="prefs_aprs_symbol_custom">Custom (%1$s%2$s)</string>
|
||||||
|
<string name="prefs_aprs_symbol_help">How your station appears on the APRS map</string>
|
||||||
<string name="prefs_aprs_symbol_code">Symbol code</string>
|
<string name="prefs_aprs_symbol_code">Symbol code</string>
|
||||||
<string name="prefs_aprs_save">Save</string>
|
<string name="prefs_aprs_save">Save</string>
|
||||||
<string name="prefs_aprs_cancel">Cancel</string>
|
<string name="prefs_aprs_cancel">Cancel</string>
|
||||||
@@ -38,9 +55,10 @@
|
|||||||
<string name="aprs_notif_connected">Connected to APRS-IS</string>
|
<string name="aprs_notif_connected">Connected to APRS-IS</string>
|
||||||
<string name="aprs_notif_error">Connection error</string>
|
<string name="aprs_notif_error">Connection error</string>
|
||||||
<string name="aprs_notif_stop">Stop</string>
|
<string name="aprs_notif_stop">Stop</string>
|
||||||
<string name="prefs_aprs_calc_passcode">Compute passcode</string>
|
<string name="prefs_aprs_request_passcode">Request a passcode</string>
|
||||||
<string name="aprs_toast_ok">APRS: report sent OK</string>
|
<string name="aprs_toast_ok">APRS: report sent OK</string>
|
||||||
<string name="aprs_toast_fail">APRS: report failed - %1$s</string>
|
<string name="aprs_toast_fail">APRS: report failed - %1$s</string>
|
||||||
|
<string name="aprs_toast_unverified">APRS-IS did not verify your passcode. Your reports are not being passed on - check the callsign and passcode in Settings.</string>
|
||||||
<string name="aprs_toast_not_configured">APRS not configured - set callsign first</string>
|
<string name="aprs_toast_not_configured">APRS not configured - set callsign first</string>
|
||||||
<string name="prefs_aprs_last_report">Last report: %1$s %2$s</string>
|
<string name="prefs_aprs_last_report">Last report: %1$s %2$s</string>
|
||||||
<string name="prefs_aprs_last_ok">OK</string>
|
<string name="prefs_aprs_last_ok">OK</string>
|
||||||
@@ -101,33 +119,29 @@
|
|||||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* NEW: the CW waterfall shows the whole audio band, so a tone the decoder cannot reach is still visible
|
* FIX: APRS reported every beacon as sent even when nothing left the phone
|
||||||
* NEW: the CW transcript follows new text, and stops following as soon as you scroll back
|
* FIX: APRS could not start at all on Android 10 and later
|
||||||
* NEW: screen readers now describe the CW waterfall and the AMSAT day cells
|
* FIX: the APRS login line was malformed and real servers refused it
|
||||||
* NEW: the CW waterfall marks where your tone really is, even when it sits outside the decoder\'s range
|
* FIX: a refused APRS login is now reported instead of being read as success
|
||||||
* NEW: a line under the CW waterfall names the tone and says whether it is being moved into range
|
* FIX: APRS no longer invents a transmit passcode - request one from the link in settings
|
||||||
* NEW: the CW signal meter no longer reads high for a tone the decoder cannot actually hear
|
* FIX: a mistyped passcode is no longer described as receive-only mode
|
||||||
* NEW: choose the AMSAT day style in Settings - twelve stripes, or one colour with a report count
|
* FIX: APRS refuses to beacon without a position instead of sending 0N 0E
|
||||||
* NEW: AMSAT status shows 12 two-hour stripes per day — outages inside a day are now visible
|
* NEW: pick your APRS map symbol from a list; the default is a house, not a car
|
||||||
* NEW: AMSAT status uses UTC calendar days, matching the official page
|
* NEW: APRS beacons survive a locked screen
|
||||||
* NEW: two greys tell you whether a slot had no report or was never fetched
|
* FIX: WaveLog uploads read the reply from the server - a rejected contact stayed reported as sent
|
||||||
* NEW: a data-coverage marker shows when quieter satellites are crowded out of the global pull
|
* FIX: a rejected contact now stays in the queue instead of being dropped
|
||||||
* NEW: CW decoder now uses the DeepCW neural network — far better weak-signal Morse decoding
|
* FIX: the satellite name sent to WaveLog is resolved from the catalogue number
|
||||||
* NEW: ships the full fp32 DeepCW model for maximum decode accuracy
|
* FIX: the upload count no longer includes contacts sent days ago
|
||||||
* NEW: CW decode history is kept permanently (text no longer disappears)
|
* NEW: the contact time can be set, for logging a pass after it ends
|
||||||
* CW waterfall: new inferno colour scheme with smooth gradients
|
* NEW: the counterpart grid can be typed, and is checked before it is logged
|
||||||
* More menu: slim right-hand panel, no dimming overlay
|
* FIX: the mode no longer keeps the value from the previous transponder
|
||||||
* CW page: removed the dead back button and the misleading status line
|
* FIX: a callsign that cannot be logged says why instead of vanishing
|
||||||
* Restored 64-bit (arm64) support
|
* FIX: a custom TLE URL now replaces the built-in sources instead of adding to them
|
||||||
* Fixed: pause/resume no longer shows a spurious "decode failed" error
|
* FIX: the custom source switch no longer turns itself off
|
||||||
* Fixed: moving pages to main menu no longer hides Settings permanently
|
* FIX: a dead custom URL no longer counts as a successful update
|
||||||
* Fixed: AMSAT/WavelogLog can now be moved to main menu
|
* FIX: QRZ tells an expired cookie apart from a station with no grid on file
|
||||||
* Fixed: satellite epoch parsing for times within first 86 seconds of UTC midnight
|
* FIX: contacts can be deleted with a screen reader
|
||||||
* Fixed: radar page now auto-switches to next pass after current pass ends
|
* FIX: the log table grid and the sent column are readable in sunlight and at night
|
||||||
* Fixed: radar Doppler calculation now uses live offset slider value
|
|
||||||
* Fixed: pass progress calculation guards against division by zero
|
|
||||||
* Fixed: concurrent calculatePasses calls no longer trigger duplicate calculations
|
|
||||||
* Fixed: WaveLog duplicate QSO submissions and grid square update race conditions
|
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
@@ -287,8 +301,43 @@
|
|||||||
<string name="wavelog_title">Log</string>
|
<string name="wavelog_title">Log</string>
|
||||||
<string name="wavelog_select_transponder">Select transponder</string>
|
<string name="wavelog_select_transponder">Select transponder</string>
|
||||||
<string name="wavelog_call_hint">Callsign</string>
|
<string name="wavelog_call_hint">Callsign</string>
|
||||||
|
<string name="wavelog_grid_hint">Grid (optional)</string>
|
||||||
|
<string name="wavelog_grid_help">What they sent you. Left empty, it is looked up on QRZ.</string>
|
||||||
|
<string name="wavelog_grid_bad">Not a grid square</string>
|
||||||
|
<string name="wavelog_grid_square_only">Square only - accurate to about 100 km</string>
|
||||||
|
<string name="wavelog_grid_field_only">Field only - accurate to about 1000 km</string>
|
||||||
<string name="wavelog_mode_hint">Mode</string>
|
<string name="wavelog_mode_hint">Mode</string>
|
||||||
|
<string name="wavelog_mode_edit">Edit the mode</string>
|
||||||
|
<string name="wavelog_time_hint">Time</string>
|
||||||
|
<string name="wavelog_time_live">Now</string>
|
||||||
|
<string name="wavelog_time_held">Held at %1$s</string>
|
||||||
|
<string name="wavelog_time_edit">Set the logging time</string>
|
||||||
|
<string name="wavelog_time_help">UTC time such as 14:55, or +3 / -2 minutes. Empty logs at the current time.</string>
|
||||||
|
<string name="wavelog_time_unrecognised">Not a time - the current time will be used</string>
|
||||||
<string name="wavelog_saved">Saved to local log</string>
|
<string name="wavelog_saved">Saved to local log</string>
|
||||||
|
<string name="wavelog_upload_done">Uploaded %1$d, %2$d still queued</string>
|
||||||
|
<string name="wavelog_upload_all_ok">Uploaded %1$d QSO</string>
|
||||||
|
<string name="wavelog_nothing_to_upload">Nothing waiting to upload</string>
|
||||||
|
<string name="wavelog_not_configured">WaveLog server not configured</string>
|
||||||
|
<string name="wavelog_no_station">Cannot read the station profile - check the station ID and key permissions</string>
|
||||||
|
<string name="wavelog_upload_failed">Upload failed</string>
|
||||||
|
<string name="wavelog_upload_queued">Some contacts could not be uploaded and stay in the queue</string>
|
||||||
|
<string name="lotw_list_updated">LoTW satellite list updated: %1$d entries</string>
|
||||||
|
<string name="lotw_list_failed">LoTW list update failed: %1$s</string>
|
||||||
|
<string name="log_call_too_short">Callsign too short</string>
|
||||||
|
<string name="log_call_illegal">A callsign cannot contain that</string>
|
||||||
|
<string name="log_call_not_a_call">That does not look like a callsign</string>
|
||||||
|
<string name="log_call_too_long">Callsign too long</string>
|
||||||
|
<string name="log_warn_grid">Saved - that looks like a grid, not a callsign</string>
|
||||||
|
<string name="log_warn_worked">Saved - %1$s was already worked this pass</string>
|
||||||
|
<string name="log_grid_no_cookie">Grid lookup needs a QRZ cookie in Settings</string>
|
||||||
|
<string name="log_grid_signed_out">QRZ cookie expired - paste a fresh one in Settings</string>
|
||||||
|
<string name="log_grid_unreachable">Could not reach QRZ for the grid</string>
|
||||||
|
<string name="qrz_test_empty">Paste a cookie first</string>
|
||||||
|
<string name="qrz_test_signed_out">Cookie is not valid or has expired</string>
|
||||||
|
<string name="qrz_test_ok">Signed in as %1$s, grid %2$s</string>
|
||||||
|
<string name="qrz_test_no_grid">Signed in as %1$s, no grid on file</string>
|
||||||
|
<string name="qrz_test_unreachable">Could not reach QRZ</string>
|
||||||
<string name="wavelog_need_config">Configure WaveLog in Settings first</string>
|
<string name="wavelog_need_config">Configure WaveLog in Settings first</string>
|
||||||
<string name="wavelog_empty">No log entries yet</string>
|
<string name="wavelog_empty">No log entries yet</string>
|
||||||
<string name="wavelog_grid_mismatch">QTH grid (%1$s) does not match station grid (%2$s). Upload anyway?</string>
|
<string name="wavelog_grid_mismatch">QTH grid (%1$s) does not match station grid (%2$s). Upload anyway?</string>
|
||||||
@@ -299,6 +348,7 @@
|
|||||||
<string name="prefs_wavelog_qrz_label">Cookie</string>
|
<string name="prefs_wavelog_qrz_label">Cookie</string>
|
||||||
<string name="wavelog_copy">Copy</string>
|
<string name="wavelog_copy">Copy</string>
|
||||||
<string name="wavelog_delete_undo">Undo</string>
|
<string name="wavelog_delete_undo">Undo</string>
|
||||||
|
<string name="wavelog_delete_action">Delete this contact</string>
|
||||||
<string name="wavelog_col_time">Time</string>
|
<string name="wavelog_col_time">Time</string>
|
||||||
<string name="wavelog_col_freq">Freq</string>
|
<string name="wavelog_col_freq">Freq</string>
|
||||||
<string name="wavelog_col_sat">Satellite</string>
|
<string name="wavelog_col_sat">Satellite</string>
|
||||||
|
|||||||
@@ -51,14 +51,18 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -69,14 +73,6 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
|||||||
import com.rtbishop.look4sat.core.presentation.R as CoreR
|
import com.rtbishop.look4sat.core.presentation.R as CoreR
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
|
||||||
* Scroll slack, in pixels, within which the transcript counts as being at the bottom.
|
|
||||||
*
|
|
||||||
* Not zero: an animated scroll settles a pixel or two short of the maximum, and an exact
|
|
||||||
* comparison would drop out of follow-mode the moment it did.
|
|
||||||
*/
|
|
||||||
private const val AUTOSCROLL_SLACK_PX = 4
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-page CW decoder backed by DeepCW.
|
* Full-page CW decoder backed by DeepCW.
|
||||||
*
|
*
|
||||||
@@ -103,6 +99,16 @@ fun CwDecodeScreen() {
|
|||||||
}
|
}
|
||||||
var permanentlyDenied by remember { mutableStateOf(false) }
|
var permanentlyDenied by remember { mutableStateOf(false) }
|
||||||
var isListening by remember { mutableStateOf(false) }
|
var isListening by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
// Hoisted so the toolbar button can reach it: the record below does not follow
|
||||||
|
// the decode, so jumping to the newest text has to be an explicit action.
|
||||||
|
val transcriptScroll = rememberScrollState()
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
val showToast = remember { container.provideShowToast() }
|
||||||
|
val copiedMessage = stringResource(R.string.cw_copied)
|
||||||
|
val emptyRecordMessage = stringResource(R.string.cw_record_empty)
|
||||||
|
// Archiving the tail on the way out has to outlive this composable's own scope.
|
||||||
|
val appScope = container.appScope
|
||||||
|
|
||||||
val decodedText by decoder.decodedText.collectAsState()
|
val decodedText by decoder.decodedText.collectAsState()
|
||||||
val historyText by decoder.historyText.collectAsState()
|
val historyText by decoder.historyText.collectAsState()
|
||||||
@@ -125,7 +131,13 @@ fun CwDecodeScreen() {
|
|||||||
// permission so granting the permission mid-flow restarts collection
|
// permission so granting the permission mid-flow restarts collection
|
||||||
// (isListening may already be true when the launcher returns).
|
// (isListening may already be true when the launcher returns).
|
||||||
LaunchedEffect(isListening, permissionGranted) {
|
LaunchedEffect(isListening, permissionGranted) {
|
||||||
if (!isListening) return@LaunchedEffect
|
if (!isListening) {
|
||||||
|
// Pausing must not throw away the tail. The live window plus the batch waiting to
|
||||||
|
// be archived are both still holding audio at this point, and nothing else ever
|
||||||
|
// decodes either of them, so the end of the transmission would be lost outright.
|
||||||
|
decoder.flush()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
if (!permissionGranted) {
|
if (!permissionGranted) {
|
||||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
@@ -138,10 +150,16 @@ fun CwDecodeScreen() {
|
|||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
|
// Archive the tail before tearing down, on a scope that outlives this composable:
|
||||||
|
// the screen's own scope is cancelled as it leaves, which would abort the decode.
|
||||||
|
// close() waits for it, because the inference needs the session it is closing.
|
||||||
|
appScope.launch {
|
||||||
|
runCatching { decoder.flush() }
|
||||||
// OrtSession holds native memory and must be released explicitly.
|
// OrtSession holds native memory and must be released explicitly.
|
||||||
decoder.close()
|
decoder.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Row(
|
Row(
|
||||||
@@ -157,6 +175,41 @@ fun CwDecodeScreen() {
|
|||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(start = 12.dp)
|
.padding(start = 12.dp)
|
||||||
)
|
)
|
||||||
|
// Copy everything decoded so far. The decoder is built fresh every time this screen
|
||||||
|
// is opened, so leaving loses the transcript, and until now there was no way at all
|
||||||
|
// to get the text off it: an operator who had just copied a call sign by ear had to
|
||||||
|
// transcribe it a second time by hand.
|
||||||
|
//
|
||||||
|
// Includes the live window, not just the record. The record only holds archived text,
|
||||||
|
// which needs the window to fill and then a batch to accumulate, so for the opening
|
||||||
|
// half-minute it is empty - and gating the button on it left the one control that
|
||||||
|
// rescues the text greyed out over exactly the short exchange most likely to be lost.
|
||||||
|
val copyable = (historyText + decodedText).trim()
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (copyable.isNotEmpty()) {
|
||||||
|
clipboard.setText(AnnotatedString(copyable))
|
||||||
|
showToast(copiedMessage)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = copyable.isNotEmpty()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(CoreR.drawable.ic_save),
|
||||||
|
contentDescription = stringResource(R.string.cw_copy)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Jump to the newest text. The record below deliberately does not follow the decode,
|
||||||
|
// so this is how you get back to the bottom after reading earlier traffic.
|
||||||
|
IconButton(onClick = { scope.launch { transcriptScroll.animateScrollTo(transcriptScroll.maxValue) } }) {
|
||||||
|
Icon(
|
||||||
|
// The shared arrow points right; rotated to point down. Adding a second
|
||||||
|
// drawable for the same shape is how icon sets start to drift.
|
||||||
|
painter = painterResource(CoreR.drawable.ic_arrow),
|
||||||
|
contentDescription = stringResource(R.string.cw_scroll_newest),
|
||||||
|
modifier = Modifier.rotate(90f)
|
||||||
|
)
|
||||||
|
}
|
||||||
IconButton(onClick = { isListening = !isListening }) {
|
IconButton(onClick = { isListening = !isListening }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(
|
painter = painterResource(
|
||||||
@@ -223,6 +276,16 @@ fun CwDecodeScreen() {
|
|||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The pane below was an unlabelled grey box showing a bare ellipsis, which reads as a
|
||||||
|
// disabled text field rather than a transcript. Naming it is what makes the split
|
||||||
|
// between the live line above and the record below legible at all.
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.cw_record_label),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 12.dp, top = 4.dp)
|
||||||
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -231,35 +294,25 @@ fun CwDecodeScreen() {
|
|||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
||||||
) {
|
) {
|
||||||
val transcript = (historyText + decodedText).ifEmpty { "…" }
|
// The record shows settled text only. decodedText is the live 20-second window,
|
||||||
val scroll = rememberScrollState()
|
// which the model rewrites from scratch every 1.5 seconds as more context arrives -
|
||||||
// Follow the newest text, but stop as soon as the operator scrolls away, so
|
// right for the line above, where the newest guess is what you want, but it meant the
|
||||||
// reading back over earlier traffic is not undone by the next decode.
|
// bottom of the record kept changing and sometimes got shorter. That reads as the
|
||||||
|
// decoder deleting what it just wrote, which for something whose whole purpose is to
|
||||||
|
// be read back is the one thing it must not do.
|
||||||
|
val transcript = historyText.ifEmpty { emptyRecordMessage }
|
||||||
|
// This pane does not follow the decode. The single line above it is where new
|
||||||
|
// characters appear; this is the record, and a record that scrolls itself is worse
|
||||||
|
// than paper - you cannot read back over what just arrived because it keeps moving.
|
||||||
//
|
//
|
||||||
// A boolean rather than comparing position against maxValue: maxValue is
|
// It used to auto-scroll to the bottom on every decode, dropping out of follow mode
|
||||||
// written during layout, after the composition that would read it, so such a
|
// only once the operator had already fought it by scrolling away. Now it stays where
|
||||||
// comparison tests the previous frame's height and drifts short of the true
|
// it was put, and the button below jumps to the newest text on request.
|
||||||
// bottom until it latches out of follow-mode altogether.
|
|
||||||
var following by remember { mutableStateOf(true) }
|
|
||||||
LaunchedEffect(scroll) {
|
|
||||||
snapshotFlow { scroll.isScrollInProgress to scroll.value }
|
|
||||||
.collect { (scrolling, value) ->
|
|
||||||
if (scrolling) following = value >= scroll.maxValue - AUTOSCROLL_SLACK_PX
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(transcript, following) {
|
|
||||||
if (!following) return@LaunchedEffect
|
|
||||||
// Twice: the first pass lands at the height known when it started, the
|
|
||||||
// second covers growth that arrived while it was animating.
|
|
||||||
repeat(2) {
|
|
||||||
if (scroll.value < scroll.maxValue) scroll.animateScrollTo(scroll.maxValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = transcript,
|
text = transcript,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(scroll)
|
.verticalScroll(transcriptScroll)
|
||||||
.padding(8.dp),
|
.padding(8.dp),
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.rtbishop.look4sat.core.domain.cw.CwAntiAlias
|
||||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||||
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
|
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -58,6 +59,10 @@ class CwWaterfallState(private val historyRows: Int = 96) {
|
|||||||
// Compose draw thread, so every touch of these two collections is guarded.
|
// Compose draw thread, so every touch of these two collections is guarded.
|
||||||
// ArrayDeque is not thread-safe: concurrent removeFirst()/toList() throws.
|
// ArrayDeque is not thread-safe: concurrent removeFirst()/toList() throws.
|
||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
|
|
||||||
|
/** Anti-alias filter for the decimation, built once the capture rate is known. */
|
||||||
|
private var antiAlias: CwAntiAlias.Streaming? = null
|
||||||
|
private var antiAliasRate = 0
|
||||||
private val rows = ArrayDeque<FloatArray>(historyRows)
|
private val rows = ArrayDeque<FloatArray>(historyRows)
|
||||||
private val pending = ArrayList<Float>(CwDeepSpectrogram.SAMPLE_RATE)
|
private val pending = ArrayList<Float>(CwDeepSpectrogram.SAMPLE_RATE)
|
||||||
// Incremented by clear(). A pushSamples call records the generation before
|
// Incremented by clear(). A pushSamples call records the generation before
|
||||||
@@ -80,8 +85,21 @@ class CwWaterfallState(private val historyRows: Int = 96) {
|
|||||||
|
|
||||||
fun pushSamples(chunk: FloatArray, sampleRate: Int) {
|
fun pushSamples(chunk: FloatArray, sampleRate: Int) {
|
||||||
if (chunk.isEmpty()) return
|
if (chunk.isEmpty()) return
|
||||||
|
// Band-limit before decimating, for the same reason the decoder does: without it
|
||||||
|
// everything above 1600 Hz folds into the display. That is not a cosmetic problem -
|
||||||
|
// the entire 1600-22050 Hz band of hiss lands on top of the signal and smears across
|
||||||
|
// the whole waterfall, and a strong out-of-band tone appears as a convincing ghost at
|
||||||
|
// a frequency nothing is transmitting on.
|
||||||
|
if (antiAlias == null || antiAliasRate != sampleRate) {
|
||||||
|
antiAlias = CwAntiAlias.Streaming(sampleRate, CwDeepSpectrogram.SAMPLE_RATE)
|
||||||
|
antiAliasRate = sampleRate
|
||||||
|
}
|
||||||
|
val bandLimited = antiAlias?.process(chunk) ?: chunk
|
||||||
|
// The filter holds back its group delay, so the first call yields nothing.
|
||||||
|
if (bandLimited.isEmpty()) return
|
||||||
|
|
||||||
val resampled = CwDeepSpectrogram.resampleLinear(
|
val resampled = CwDeepSpectrogram.resampleLinear(
|
||||||
chunk, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
bandLimited, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||||
)
|
)
|
||||||
val audio: FloatArray
|
val audio: FloatArray
|
||||||
val generationAtStart: Long
|
val generationAtStart: Long
|
||||||
@@ -120,6 +138,7 @@ class CwWaterfallState(private val historyRows: Int = 96) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
|
antiAlias?.reset()
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
generation++
|
generation++
|
||||||
rows.clear()
|
rows.clear()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<string name="cw_pause">Jeda pendekodean</string>
|
<string name="cw_pause">Jeda pendekodean</string>
|
||||||
<string name="cw_resume">Lanjutkan pendekodean</string>
|
<string name="cw_resume">Lanjutkan pendekodean</string>
|
||||||
<string name="cw_clear">Hapus teks hasil dekode</string>
|
<string name="cw_clear">Hapus teks hasil dekode</string>
|
||||||
|
<string name="cw_scroll_newest">Lompat ke teks terbaru</string>
|
||||||
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
|
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
|
||||||
<string name="cw_grant_permission">Berikan izin</string>
|
<string name="cw_grant_permission">Berikan izin</string>
|
||||||
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
||||||
@@ -13,4 +14,8 @@
|
|||||||
<string name="cw_waterfall_shifted">nada %1$d hertz, dipindahkan ke rentang dekode</string>
|
<string name="cw_waterfall_shifted">nada %1$d hertz, dipindahkan ke rentang dekode</string>
|
||||||
<string name="cw_waterfall_outside">nada %1$d hertz, di luar rentang dekode</string>
|
<string name="cw_waterfall_outside">nada %1$d hertz, di luar rentang dekode</string>
|
||||||
<string name="cw_waterfall_inside">nada %1$d hertz</string>
|
<string name="cw_waterfall_inside">nada %1$d hertz</string>
|
||||||
|
<string name="cw_copy">Salin catatan</string>
|
||||||
|
<string name="cw_copied">Catatan disalin</string>
|
||||||
|
<string name="cw_record_label">Catatan</string>
|
||||||
|
<string name="cw_record_empty">Teks muncul di sini setelah satu batch audio didekode. Baris di atas lebih cepat diperbarui. Salin sebelum keluar: catatan tidak disimpan.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<string name="cw_pause">Jeda pendekodean</string>
|
<string name="cw_pause">Jeda pendekodean</string>
|
||||||
<string name="cw_resume">Lanjutkan pendekodean</string>
|
<string name="cw_resume">Lanjutkan pendekodean</string>
|
||||||
<string name="cw_clear">Hapus teks hasil dekode</string>
|
<string name="cw_clear">Hapus teks hasil dekode</string>
|
||||||
|
<string name="cw_scroll_newest">Lompat ke teks terbaru</string>
|
||||||
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
|
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
|
||||||
<string name="cw_grant_permission">Berikan izin</string>
|
<string name="cw_grant_permission">Berikan izin</string>
|
||||||
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
||||||
@@ -13,4 +14,8 @@
|
|||||||
<string name="cw_waterfall_shifted">nada %1$d hertz, dipindahkan ke rentang dekode</string>
|
<string name="cw_waterfall_shifted">nada %1$d hertz, dipindahkan ke rentang dekode</string>
|
||||||
<string name="cw_waterfall_outside">nada %1$d hertz, di luar rentang dekode</string>
|
<string name="cw_waterfall_outside">nada %1$d hertz, di luar rentang dekode</string>
|
||||||
<string name="cw_waterfall_inside">nada %1$d hertz</string>
|
<string name="cw_waterfall_inside">nada %1$d hertz</string>
|
||||||
|
<string name="cw_copy">Salin catatan</string>
|
||||||
|
<string name="cw_copied">Catatan disalin</string>
|
||||||
|
<string name="cw_record_label">Catatan</string>
|
||||||
|
<string name="cw_record_empty">Teks muncul di sini setelah satu batch audio didekode. Baris di atas lebih cepat diperbarui. Salin sebelum keluar: catatan tidak disimpan.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<string name="cw_pause">Çözmeyi duraklat</string>
|
<string name="cw_pause">Çözmeyi duraklat</string>
|
||||||
<string name="cw_resume">Çözmeyi sürdür</string>
|
<string name="cw_resume">Çözmeyi sürdür</string>
|
||||||
<string name="cw_clear">Çözülen metni temizle</string>
|
<string name="cw_clear">Çözülen metni temizle</string>
|
||||||
|
<string name="cw_scroll_newest">En yeni metne git</string>
|
||||||
<string name="cw_mic_permission">CW çözümü için mikrofon izni gerekli</string>
|
<string name="cw_mic_permission">CW çözümü için mikrofon izni gerekli</string>
|
||||||
<string name="cw_grant_permission">İzin ver</string>
|
<string name="cw_grant_permission">İzin ver</string>
|
||||||
<string name="cw_open_settings">Uygulama ayarlarını aç</string>
|
<string name="cw_open_settings">Uygulama ayarlarını aç</string>
|
||||||
@@ -13,4 +14,8 @@
|
|||||||
<string name="cw_waterfall_shifted">ton %1$d hertz, kod çözme aralığına taşındı</string>
|
<string name="cw_waterfall_shifted">ton %1$d hertz, kod çözme aralığına taşındı</string>
|
||||||
<string name="cw_waterfall_outside">ton %1$d hertz, kod çözme aralığının dışında</string>
|
<string name="cw_waterfall_outside">ton %1$d hertz, kod çözme aralığının dışında</string>
|
||||||
<string name="cw_waterfall_inside">ton %1$d hertz</string>
|
<string name="cw_waterfall_inside">ton %1$d hertz</string>
|
||||||
|
<string name="cw_copy">Kaydı kopyala</string>
|
||||||
|
<string name="cw_copied">Kayıt kopyalandı</string>
|
||||||
|
<string name="cw_record_label">Kayıt</string>
|
||||||
|
<string name="cw_record_empty">Bir ses grubu çözüldükten sonra metin buraya düşer. Üstteki satır daha erken güncellenir. Çıkmadan önce kopyalayın: kayıt saklanmaz.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<string name="cw_pause">暂停解码</string>
|
<string name="cw_pause">暂停解码</string>
|
||||||
<string name="cw_resume">继续解码</string>
|
<string name="cw_resume">继续解码</string>
|
||||||
<string name="cw_clear">清空解码文本</string>
|
<string name="cw_clear">清空解码文本</string>
|
||||||
|
<string name="cw_scroll_newest">跳到最新</string>
|
||||||
<string name="cw_mic_permission">CW 解码需要麦克风权限</string>
|
<string name="cw_mic_permission">CW 解码需要麦克风权限</string>
|
||||||
<string name="cw_grant_permission">授予权限</string>
|
<string name="cw_grant_permission">授予权限</string>
|
||||||
<string name="cw_open_settings">打开应用设置</string>
|
<string name="cw_open_settings">打开应用设置</string>
|
||||||
@@ -13,4 +14,8 @@
|
|||||||
<string name="cw_waterfall_shifted">音调 %1$d 赫兹,已搬入解码范围</string>
|
<string name="cw_waterfall_shifted">音调 %1$d 赫兹,已搬入解码范围</string>
|
||||||
<string name="cw_waterfall_outside">音调 %1$d 赫兹,在解码范围外</string>
|
<string name="cw_waterfall_outside">音调 %1$d 赫兹,在解码范围外</string>
|
||||||
<string name="cw_waterfall_inside">音调 %1$d 赫兹</string>
|
<string name="cw_waterfall_inside">音调 %1$d 赫兹</string>
|
||||||
|
<string name="cw_copy">复制记录</string>
|
||||||
|
<string name="cw_copied">记录已复制</string>
|
||||||
|
<string name="cw_record_label">记录</string>
|
||||||
|
<string name="cw_record_empty">一批音频解码完成后文字会落到这里,上面那行更新更早。离开本页前请复制:记录不会被存下来。</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<string name="cw_pause">Pause decoding</string>
|
<string name="cw_pause">Pause decoding</string>
|
||||||
<string name="cw_resume">Resume decoding</string>
|
<string name="cw_resume">Resume decoding</string>
|
||||||
<string name="cw_clear">Clear decoded text</string>
|
<string name="cw_clear">Clear decoded text</string>
|
||||||
|
<string name="cw_scroll_newest">Jump to the newest text</string>
|
||||||
<string name="cw_mic_permission">Microphone permission is required for CW decoding</string>
|
<string name="cw_mic_permission">Microphone permission is required for CW decoding</string>
|
||||||
<string name="cw_grant_permission">Grant permission</string>
|
<string name="cw_grant_permission">Grant permission</string>
|
||||||
<string name="cw_open_settings">Open app settings</string>
|
<string name="cw_open_settings">Open app settings</string>
|
||||||
@@ -13,4 +14,8 @@
|
|||||||
<string name="cw_waterfall_shifted">tone at %1$d hertz, moved into the decoder range</string>
|
<string name="cw_waterfall_shifted">tone at %1$d hertz, moved into the decoder range</string>
|
||||||
<string name="cw_waterfall_outside">tone at %1$d hertz, outside the decoder range</string>
|
<string name="cw_waterfall_outside">tone at %1$d hertz, outside the decoder range</string>
|
||||||
<string name="cw_waterfall_inside">tone at %1$d hertz</string>
|
<string name="cw_waterfall_inside">tone at %1$d hertz</string>
|
||||||
|
<string name="cw_copy">Copy the record</string>
|
||||||
|
<string name="cw_copied">Record copied</string>
|
||||||
|
<string name="cw_record_label">Record</string>
|
||||||
|
<string name="cw_record_empty">Text lands here once a batch of audio has been decoded. The line above updates sooner. Copy before leaving: the record is not stored.</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -16,6 +16,11 @@ import androidx.compose.animation.core.tween
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -38,7 +43,7 @@ import androidx.compose.material3.DropdownMenuItem
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
import androidx.compose.material3.MenuAnchorType
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -52,9 +57,14 @@ import androidx.compose.runtime.key
|
|||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.customActions
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -70,6 +80,10 @@ import androidx.compose.ui.unit.sp
|
|||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
|
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.GridEntry
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.PassClock
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.CallsignEntry
|
||||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso
|
||||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
@@ -84,7 +98,6 @@ import kotlin.math.roundToInt
|
|||||||
private val WaveLogYellow = Color(0xFFFFC107)
|
private val WaveLogYellow = Color(0xFFFFC107)
|
||||||
|
|
||||||
/** Repeat "done" events for the same callsign inside this window are treated as one QSO. */
|
/** Repeat "done" events for the same callsign inside this window are treated as one QSO. */
|
||||||
private const val DUPLICATE_WINDOW_MS = 2000L
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -92,11 +105,14 @@ fun LogTab(
|
|||||||
transceivers: List<SatRadio>,
|
transceivers: List<SatRadio>,
|
||||||
orbitalPos: OrbitalPos?,
|
orbitalPos: OrbitalPos?,
|
||||||
satelliteName: String,
|
satelliteName: String,
|
||||||
|
satelliteCatnum: Int,
|
||||||
queue: WavelogQueue,
|
queue: WavelogQueue,
|
||||||
wavelogConfigured: Boolean,
|
wavelogConfigured: Boolean,
|
||||||
showToast: (String) -> Unit,
|
showToast: (String) -> Unit,
|
||||||
txBaseFrequencyHz: Long? = null,
|
txBaseFrequencyHz: Long? = null,
|
||||||
aosTimeMs: Long = 0L,
|
aosTimeMs: Long = 0L,
|
||||||
|
onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit,
|
||||||
|
onAutoUpload: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
@@ -136,7 +152,7 @@ fun LogTab(
|
|||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = menuExpanded) },
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = menuExpanded) },
|
||||||
textStyle = MaterialTheme.typography.bodyMedium,
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
)
|
)
|
||||||
ExposedDropdownMenu(
|
ExposedDropdownMenu(
|
||||||
@@ -167,10 +183,13 @@ fun LogTab(
|
|||||||
radio = radio,
|
radio = radio,
|
||||||
orbitalPos = orbitalPos,
|
orbitalPos = orbitalPos,
|
||||||
satelliteName = satelliteName,
|
satelliteName = satelliteName,
|
||||||
|
satelliteCatnum = satelliteCatnum,
|
||||||
queue = queue,
|
queue = queue,
|
||||||
showToast = showToast,
|
showToast = showToast,
|
||||||
txBaseFrequencyHz = txBaseFrequencyHz,
|
txBaseFrequencyHz = txBaseFrequencyHz,
|
||||||
aosTimeMs = aosTimeMs,
|
aosTimeMs = aosTimeMs,
|
||||||
|
onLookupGrid = onLookupGrid,
|
||||||
|
onAutoUpload = onAutoUpload,
|
||||||
onSaved = { refreshTick++ }
|
onSaved = { refreshTick++ }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -253,61 +272,123 @@ private fun ExpandedLogInput(
|
|||||||
radio: SatRadio,
|
radio: SatRadio,
|
||||||
orbitalPos: OrbitalPos?,
|
orbitalPos: OrbitalPos?,
|
||||||
satelliteName: String,
|
satelliteName: String,
|
||||||
|
satelliteCatnum: Int,
|
||||||
queue: WavelogQueue,
|
queue: WavelogQueue,
|
||||||
showToast: (String) -> Unit,
|
showToast: (String) -> Unit,
|
||||||
txBaseFrequencyHz: Long? = null,
|
txBaseFrequencyHz: Long? = null,
|
||||||
aosTimeMs: Long = 0L,
|
aosTimeMs: Long = 0L,
|
||||||
|
onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit,
|
||||||
|
onAutoUpload: () -> Unit,
|
||||||
onSaved: () -> Unit
|
onSaved: () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var callsign by remember { mutableStateOf("") }
|
var callsign by remember { mutableStateOf("") }
|
||||||
var mode by remember { mutableStateOf(radio.uplinkMode ?: "FM") }
|
// Keyed on the transponder. Without the key, switching transponder mid-session kept the
|
||||||
// Last accepted callsign + timestamp, used to swallow duplicate IME "done" events.
|
// previous one's mode and uploaded a value the operator never chose.
|
||||||
// A plain in-flight flag cannot help: submit() is synchronous, so it always
|
var mode by remember(radio.uuid) { mutableStateOf(radio.uplinkMode ?: "FM") }
|
||||||
// clears before the next key event arrives.
|
var modeEditable by remember(radio.uuid) { mutableStateOf(false) }
|
||||||
var lastSaved by remember { mutableStateOf("" to 0L) }
|
val editModeLabel = stringResource(id = R.string.wavelog_mode_edit)
|
||||||
|
val editTimeLabel = stringResource(id = R.string.wavelog_time_edit)
|
||||||
|
// rememberSaveable, not remember: this goes through the saved instance state, so a held clock
|
||||||
|
// survives rotation AND system-initiated process death. Losing it mid-transcription would put
|
||||||
|
// every remaining contact at the wrong time and on the wrong UTC day without saying so. It is
|
||||||
|
// deliberately NOT restored when the user swipes the app away - that is them ending the session.
|
||||||
|
var timeEntry by rememberSaveable { mutableStateOf("") }
|
||||||
|
var timeEditable by rememberSaveable { mutableStateOf(false) }
|
||||||
|
// Typed by the operator. On FM satellites the grid IS the exchange, and until now it could only
|
||||||
|
// arrive by scraping QRZ - which needs a cookie the operator may not have and cannot supply for
|
||||||
|
// a station that has no locator on file.
|
||||||
|
var gridEntry by rememberSaveable { mutableStateOf("") }
|
||||||
|
// Calls logged during this pass, so a repeat can be mentioned without being blocked: the same
|
||||||
|
// station on a later pass is a legitimate new contact. This replaces a 300ms window that
|
||||||
|
// swallowed what it guessed were accidental double submissions - a guess that could discard
|
||||||
|
// a real second contact instead.
|
||||||
|
var workedThisSession by rememberSaveable { mutableStateOf(emptySet<String>()) }
|
||||||
|
|
||||||
val savedMsg = stringResource(id = R.string.wavelog_saved)
|
val savedMsg = stringResource(id = R.string.wavelog_saved)
|
||||||
|
val gridWarnMsg = stringResource(id = R.string.log_warn_grid)
|
||||||
|
val workedWarnMsg = stringResource(id = R.string.log_warn_worked)
|
||||||
|
val tooShortMsg = stringResource(id = R.string.log_call_too_short)
|
||||||
|
val illegalMsg = stringResource(id = R.string.log_call_illegal)
|
||||||
|
val notACallMsg = stringResource(id = R.string.log_call_not_a_call)
|
||||||
|
val tooLongMsg = stringResource(id = R.string.log_call_too_long)
|
||||||
|
val gridSignedOutMsg = stringResource(id = R.string.log_grid_signed_out)
|
||||||
|
val gridUnreachableMsg = stringResource(id = R.string.log_grid_unreachable)
|
||||||
|
|
||||||
|
fun rejectionMessage(reason: CallsignEntry.Reason): String = when (reason) {
|
||||||
|
CallsignEntry.Reason.EMPTY, CallsignEntry.Reason.TOO_SHORT -> tooShortMsg
|
||||||
|
CallsignEntry.Reason.TOO_LONG -> tooLongMsg
|
||||||
|
CallsignEntry.Reason.ILLEGAL_CHARACTERS -> illegalMsg
|
||||||
|
CallsignEntry.Reason.NOT_A_CALLSIGN -> notACallMsg
|
||||||
|
}
|
||||||
|
|
||||||
fun submit() {
|
fun submit() {
|
||||||
val call = callsign.trim().uppercase()
|
// Every outcome says something. The previous `if (call.length < 3) return` discarded the
|
||||||
if (call.length < 3) return
|
// entry in silence, so pressing done mid-pass appeared to do nothing, and none of the
|
||||||
val now = System.currentTimeMillis()
|
// logging software surveyed drops a submission that way.
|
||||||
val (lastCall, lastAt) = lastSaved
|
val verdict = CallsignEntry.check(callsign, workedThisSession)
|
||||||
if (call == lastCall && now - lastAt < DUPLICATE_WINDOW_MS) return
|
if (verdict is CallsignEntry.Verdict.Rejected) {
|
||||||
lastSaved = call to now
|
showToast(rejectionMessage(verdict.reason))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val accepted = verdict as CallsignEntry.Verdict.Acceptable
|
||||||
|
val call = accepted.callsign
|
||||||
// Freq taken directly from the transponder bar (radio Doppler-corrected each second; value at the Enter moment)
|
// Freq taken directly from the transponder bar (radio Doppler-corrected each second; value at the Enter moment)
|
||||||
val tx = radio.uplinkLow ?: radio.downlinkLow ?: 0L
|
val tx = radio.uplinkLow ?: radio.downlinkLow ?: 0L
|
||||||
val rx = radio.downlinkLow ?: radio.uplinkLow ?: 0L
|
val rx = radio.downlinkLow ?: radio.uplinkLow ?: 0L
|
||||||
val qsoId = UUID.randomUUID().toString()
|
val qsoId = UUID.randomUUID().toString()
|
||||||
|
// Only a usable grid is logged. A malformed one is dropped rather than stored, and the
|
||||||
|
// field has been saying so while it was typed.
|
||||||
|
val typedGrid = (GridEntry.check(gridEntry) as? GridEntry.Verdict.Acceptable)?.normalised ?: ""
|
||||||
queue.add(
|
queue.add(
|
||||||
WavelogQso(
|
WavelogQso(
|
||||||
id = qsoId,
|
id = qsoId,
|
||||||
timeUtcMs = System.currentTimeMillis(),
|
timeUtcMs = PassClock.resolve(
|
||||||
|
PassClock.parse(timeEntry),
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
utcDayStart(System.currentTimeMillis())
|
||||||
|
),
|
||||||
call = call,
|
call = call,
|
||||||
mode = mode.trim().ifBlank { "FM" }.uppercase(),
|
mode = mode.trim().ifBlank { "FM" }.uppercase(),
|
||||||
freqTxHz = tx,
|
freqTxHz = tx,
|
||||||
freqRxHz = rx,
|
freqRxHz = rx,
|
||||||
satName = satelliteName,
|
satName = satelliteName,
|
||||||
|
catnum = satelliteCatnum,
|
||||||
|
gridsquare = typedGrid,
|
||||||
sessionId = buildSessionId(satelliteName, aosTimeMs)
|
sessionId = buildSessionId(satelliteName, aosTimeMs)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
callsign = ""
|
callsign = ""
|
||||||
|
gridEntry = ""
|
||||||
|
workedThisSession = workedThisSession + call
|
||||||
onSaved()
|
onSaved()
|
||||||
showToast(savedMsg)
|
showToast(
|
||||||
// QRZ counterpart grid async backfill (4.5.5): only queried when Cookie is set; silent on failure
|
when (accepted.warning) {
|
||||||
scope.launch {
|
CallsignEntry.Warning.LOOKS_LIKE_A_GRID -> gridWarnMsg
|
||||||
val prefs = context.getSharedPreferences("qrz_cookie", android.content.Context.MODE_PRIVATE)
|
CallsignEntry.Warning.ALREADY_WORKED -> workedWarnMsg.format(call)
|
||||||
val rawCookie = prefs.getString("cookie", "") ?: ""
|
null -> savedMsg
|
||||||
if (rawCookie.isNotBlank()) {
|
|
||||||
val grid = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.lookupGrid(
|
|
||||||
call, com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.parseCookies(rawCookie)
|
|
||||||
)
|
|
||||||
if (grid != null) {
|
|
||||||
queue.updateGridsquare(qsoId, grid)
|
|
||||||
onSaved() // 触发 refreshTick++ 刷新列表
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
// Upload now if the operator asked for it. Doing this as the contact is saved, rather than
|
||||||
|
// on a timer, means the result can actually be shown to somebody.
|
||||||
|
onAutoUpload()
|
||||||
|
// Grid backfill. The view model owns the cookie and the request; this used to read
|
||||||
|
// SharedPreferences through LocalContext right here, inside composition. Failures now say
|
||||||
|
// something: an expired cookie was indistinguishable from a station with no grid filed.
|
||||||
|
// A typed grid is better evidence than a scrape, so the lookup is skipped entirely rather
|
||||||
|
// than allowed to overwrite it.
|
||||||
|
if (typedGrid.isNotBlank()) return
|
||||||
|
onLookupGrid(call) { outcome ->
|
||||||
|
when (outcome) {
|
||||||
|
is QrzGrid.Found -> {
|
||||||
|
queue.updateGridsquare(qsoId, outcome.locator)
|
||||||
|
onSaved()
|
||||||
|
}
|
||||||
|
// Nothing to fetch and nothing wrong: the station has no locator on file.
|
||||||
|
QrzGrid.NotOnFile -> Unit
|
||||||
|
QrzGrid.SignedOut -> showToast(gridSignedOutMsg)
|
||||||
|
is QrzGrid.Unreachable -> showToast(gridUnreachableMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,7 +418,7 @@ private fun ExpandedLogInput(
|
|||||||
if (isLinearRange) {
|
if (isLinearRange) {
|
||||||
Text(
|
Text(
|
||||||
text = " (${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
|
text = " (${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
|
||||||
fontSize = 11.sp,
|
fontSize = 12.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -358,7 +439,7 @@ private fun ExpandedLogInput(
|
|||||||
if (downLow != null && downHigh != null && downLow != downHigh) {
|
if (downLow != null && downHigh != null && downLow != downHigh) {
|
||||||
Text(
|
Text(
|
||||||
text = " (${formatFrequency(downLow)} – ${formatFrequency(downHigh)})",
|
text = " (${formatFrequency(downLow)} – ${formatFrequency(downHigh)})",
|
||||||
fontSize = 11.sp,
|
fontSize = 12.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -373,6 +454,48 @@ private fun ExpandedLogInput(
|
|||||||
textStyle = MaterialTheme.typography.bodyMedium,
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
// The counterpart grid, optional and second. On FM satellites this is the exchange, and it
|
||||||
|
// could previously only arrive by scraping QRZ - which needs a cookie, and returns nothing
|
||||||
|
// for a station with no locator on file. Typed here it also skips the lookup entirely,
|
||||||
|
// because what the operator heard beats what a web page says.
|
||||||
|
val gridVerdict = GridEntry.check(gridEntry)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = gridEntry,
|
||||||
|
// 8 characters, not 6: the extended form exists and truncating it would silently
|
||||||
|
// change the location. Case is normalised on commit rather than while typing, so the
|
||||||
|
// cursor does not jump under the operator's thumb.
|
||||||
|
onValueChange = { gridEntry = it.take(8) },
|
||||||
|
label = { Text(stringResource(id = R.string.wavelog_grid_hint)) },
|
||||||
|
supportingText = {
|
||||||
|
Text(
|
||||||
|
// Says what is wrong while there is still time to fix it. A malformed grid is
|
||||||
|
// stored by Wavelog as-is and then pollutes grid statistics and VUCC tracking,
|
||||||
|
// where a wrong square is worse than a missing one.
|
||||||
|
text = when (gridVerdict) {
|
||||||
|
is GridEntry.Verdict.Unusable -> stringResource(id = R.string.wavelog_grid_bad)
|
||||||
|
is GridEntry.Verdict.Acceptable -> when (gridVerdict.warning) {
|
||||||
|
GridEntry.Warning.FIELD_ONLY ->
|
||||||
|
stringResource(id = R.string.wavelog_grid_field_only)
|
||||||
|
GridEntry.Warning.SQUARE_ONLY ->
|
||||||
|
stringResource(id = R.string.wavelog_grid_square_only)
|
||||||
|
null -> stringResource(id = R.string.wavelog_grid_help)
|
||||||
|
}
|
||||||
|
GridEntry.Verdict.Empty -> stringResource(id = R.string.wavelog_grid_help)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
},
|
||||||
|
isError = gridVerdict is GridEntry.Verdict.Unusable,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
// The mode comes from the transponder, so it is shown rather than typed - one less field
|
||||||
|
// during a pass that lasts eight minutes. Tapping it reveals the field, because a
|
||||||
|
// transponder record can be wrong and the operator has to be able to say so.
|
||||||
|
if (modeEditable) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = mode,
|
value = mode,
|
||||||
onValueChange = { mode = it.take(8) },
|
onValueChange = { mode = it.take(8) },
|
||||||
@@ -381,6 +504,91 @@ private fun ExpandedLogInput(
|
|||||||
textStyle = MaterialTheme.typography.bodyMedium,
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// 48dp is the Material Design minimum for anything tappable, and this row is
|
||||||
|
// tapped to reveal the mode field. Padding alone left it around 20dp.
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
// Role.Button so TalkBack announces it as interactive. A bare clickable
|
||||||
|
// declares no role, so the row read as two labels and the tap was undiscoverable.
|
||||||
|
.clickable(role = Role.Button, onClickLabel = editModeLabel) { modeEditable = true }
|
||||||
|
.padding(horizontal = LocalSpacing.current.extraExtraSmall)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.wavelog_mode_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(text = mode, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The clock. Serious operators record a pass and transcribe it afterwards, so a fixed
|
||||||
|
// timestamp made every transcribed contact wrong by however long the transcription took -
|
||||||
|
// and a pass crossing midnight UTC landed a whole day out.
|
||||||
|
val command = PassClock.parse(timeEntry)
|
||||||
|
val holding = PassClock.isHolding(command)
|
||||||
|
if (timeEditable) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = timeEntry,
|
||||||
|
onValueChange = { timeEntry = it.take(6) },
|
||||||
|
label = { Text(stringResource(id = R.string.wavelog_time_hint)) },
|
||||||
|
supportingText = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (command is PassClock.Command.Unrecognised) {
|
||||||
|
R.string.wavelog_time_unrecognised
|
||||||
|
} else {
|
||||||
|
R.string.wavelog_time_help
|
||||||
|
}
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
},
|
||||||
|
isError = command is PassClock.Command.Unrecognised,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.clickable(role = Role.Button, onClickLabel = editTimeLabel) {
|
||||||
|
timeEditable = true
|
||||||
|
}
|
||||||
|
.padding(horizontal = LocalSpacing.current.extraExtraSmall)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.wavelog_time_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
// A held clock is marked in words as well as colour. Material is explicit that
|
||||||
|
// colour must not be the only carrier of meaning, and roughly one man in twelve
|
||||||
|
// cannot reliably separate the primary colour from the default text here. The
|
||||||
|
// consequence of missing it is every remaining contact logged at the wrong time.
|
||||||
|
text = if (holding) {
|
||||||
|
stringResource(id = R.string.wavelog_time_held, timeEntry.trim())
|
||||||
|
} else {
|
||||||
|
stringResource(id = R.string.wavelog_time_live)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (holding) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,10 +631,33 @@ internal fun SwipeDeleteRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val deleteActionLabel = stringResource(id = R.string.wavelog_delete_action)
|
||||||
|
val undoActionLabel = stringResource(id = R.string.wavelog_delete_undo)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onSizeChanged { rowWidth = it.width }
|
.onSizeChanged { rowWidth = it.width }
|
||||||
|
// Deleting was reachable ONLY by dragging, so TalkBack, switch access and Voice Access
|
||||||
|
// users could not delete a record at all - not with difficulty, at all. The Compose
|
||||||
|
// accessibility guide names this exact case: swipe gestures should be exposed as custom
|
||||||
|
// actions because they are hard or impossible for users with motor impairments.
|
||||||
|
.semantics {
|
||||||
|
customActions = buildList {
|
||||||
|
if (pending) {
|
||||||
|
add(CustomAccessibilityAction(undoActionLabel) {
|
||||||
|
pending = false
|
||||||
|
offsetX = 0f
|
||||||
|
true
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
add(CustomAccessibilityAction(deleteActionLabel) {
|
||||||
|
pending = true
|
||||||
|
offsetX = -threshold
|
||||||
|
true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
// Background layer (right-side icons: trash / undo + countdown)
|
// Background layer (right-side icons: trash / undo + countdown)
|
||||||
Box(
|
Box(
|
||||||
@@ -452,22 +683,34 @@ internal fun SwipeDeleteRow(
|
|||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = WaveLogYellow,
|
color = WaveLogYellow,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(WaveLogYellow.copy(alpha = 0.15f))
|
.background(WaveLogYellow.copy(alpha = 0.15f))
|
||||||
.clickable {
|
// 48dp, the Material minimum. This was a 29dp target with a five-second
|
||||||
|
// countdown running behind it, so a miss was unrecoverable - the worst
|
||||||
|
// possible place in the screen to be hard to hit.
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.widthIn(min = 64.dp)
|
||||||
|
.clickable(role = Role.Button) {
|
||||||
pending = false
|
pending = false
|
||||||
offsetX = 0f
|
offsetX = 0f
|
||||||
}
|
}
|
||||||
.padding(horizontal = 10.dp, vertical = 6.dp)
|
.wrapContentHeight(Alignment.CenterVertically)
|
||||||
|
.padding(horizontal = 10.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Trash icon (always visible while swiping, yellow)
|
// A real icon, not an emoji: emoji render differently on every device and font, and
|
||||||
Text(
|
// this project forbids them as icons. contentDescription is null because the row
|
||||||
text = "🗑",
|
// already exposes delete as a custom accessibility action.
|
||||||
fontSize = 20.sp,
|
Icon(
|
||||||
modifier = Modifier.padding(end = 16.dp)
|
painter = painterResource(id = R.drawable.ic_delete),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = WaveLogYellow,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(end = 16.dp)
|
||||||
|
.size(24.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,3 +765,19 @@ private fun buildSessionId(satName: String, aosTimeMs: Long): String {
|
|||||||
)
|
)
|
||||||
return "$satName-$stamp"
|
return "$satName-$stamp"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Midnight UTC of the day a timestamp falls in.
|
||||||
|
*
|
||||||
|
* PassClock needs this passed in: core:domain holds no calendar, so the day boundary is computed
|
||||||
|
* here where java.util is available.
|
||||||
|
*/
|
||||||
|
private fun utcDayStart(nowMs: Long): Long {
|
||||||
|
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = nowMs
|
||||||
|
cal.set(java.util.Calendar.HOUR_OF_DAY, 0)
|
||||||
|
cal.set(java.util.Calendar.MINUTE, 0)
|
||||||
|
cal.set(java.util.Calendar.SECOND, 0)
|
||||||
|
cal.set(java.util.Calendar.MILLISECOND, 0)
|
||||||
|
return cal.timeInMillis
|
||||||
|
}
|
||||||
@@ -58,10 +58,11 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
|
|
||||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
@@ -92,6 +93,19 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
|||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
// Captured as a value because `viewModel` also names the composable factory function, so
|
||||||
|
// referring to it inside a lambda resolves to that instead of this local.
|
||||||
|
val uploadQueuedMsg = stringResource(id = R.string.wavelog_upload_queued)
|
||||||
|
val lookupGrid: (String, (QrzGrid) -> Unit) -> Unit = viewModel::lookupGrid
|
||||||
|
// Reports through the shared toast so an automatic upload is not silent the way the deleted
|
||||||
|
// ten-minute loop was.
|
||||||
|
val autoUpload: () -> Unit = {
|
||||||
|
viewModel.uploadIfAutomatic { outcome ->
|
||||||
|
if (outcome is UploadOutcome.Done && outcome.failedCount > 0) {
|
||||||
|
container.provideShowToast()(uploadQueuedMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
|
val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
|
||||||
val navigateUpAndClearMutual = {
|
val navigateUpAndClearMutual = {
|
||||||
if (container.mutualPassData.value.endTime > 0L) {
|
if (container.mutualPassData.value.endTime > 0L) {
|
||||||
@@ -124,21 +138,11 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
|||||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||||
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
||||||
}
|
}
|
||||||
// WaveLog (4.5.2): auto-upload - retries the local queue every 10 min (when the switch is on)
|
// Auto-upload happens when a QSO is saved, not on a timer. The ten-minute polling loop that
|
||||||
val wavelogUploader = remember { container.provideWavelogUploader() }
|
// used to live here uploaded in the background with no way to tell the operator what happened:
|
||||||
LaunchedEffect(Unit) {
|
// a grid mismatch was skipped by an empty if, and any other failure just retried silently
|
||||||
while (true) {
|
// forever. A QSO that cannot be uploaded now stays in the queue for a manual upload from
|
||||||
delay(10 * 60 * 1000L)
|
// settings, where the result is actually shown.
|
||||||
val s = container.settingsRepo.otherSettings.value
|
|
||||||
if (s.wavelogAutoUpload && s.wavelogUrl.isNotBlank()) {
|
|
||||||
// Grid mismatch is skipped silently (left for manual-upload confirmation); other failures retry on the next tick
|
|
||||||
val outcome = wavelogUploader.uploadQueue()
|
|
||||||
if (outcome is UploadOutcome.NeedConfirm) {
|
|
||||||
// Skip this run; the user can upload and confirm manually in settings
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RadarScreen(
|
RadarScreen(
|
||||||
uiState,
|
uiState,
|
||||||
viewModel::onAction,
|
viewModel::onAction,
|
||||||
@@ -149,7 +153,9 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
|||||||
wavelogConfigured = container.settingsRepo.otherSettings.value.let {
|
wavelogConfigured = container.settingsRepo.otherSettings.value.let {
|
||||||
it.wavelogUrl.isNotBlank() && it.wavelogApiKey.isNotBlank() && it.wavelogStationId.isNotBlank()
|
it.wavelogUrl.isNotBlank() && it.wavelogApiKey.isNotBlank() && it.wavelogStationId.isNotBlank()
|
||||||
},
|
},
|
||||||
showToast = { msg -> container.provideShowToast()(msg) }
|
showToast = { msg -> container.provideShowToast()(msg) },
|
||||||
|
onLookupGrid = lookupGrid,
|
||||||
|
onAutoUpload = autoUpload
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +168,9 @@ private fun RadarScreen(
|
|||||||
requestMicPermission: () -> Unit,
|
requestMicPermission: () -> Unit,
|
||||||
wavelogQueue: WavelogQueue,
|
wavelogQueue: WavelogQueue,
|
||||||
wavelogConfigured: Boolean,
|
wavelogConfigured: Boolean,
|
||||||
showToast: (String) -> Unit
|
showToast: (String) -> Unit,
|
||||||
|
onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit,
|
||||||
|
onAutoUpload: () -> Unit
|
||||||
) {
|
) {
|
||||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||||
val addToCalendar: () -> Unit = {
|
val addToCalendar: () -> Unit = {
|
||||||
@@ -216,11 +224,11 @@ private fun RadarScreen(
|
|||||||
}
|
}
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||||
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
|
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, onLookupGrid, onAutoUpload, Modifier.weight(1f))
|
||||||
} else {
|
} else {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||||
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
|
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, onLookupGrid, onAutoUpload, Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,6 +242,8 @@ private fun PagerCard(
|
|||||||
wavelogQueue: WavelogQueue,
|
wavelogQueue: WavelogQueue,
|
||||||
wavelogConfigured: Boolean,
|
wavelogConfigured: Boolean,
|
||||||
showToast: (String) -> Unit,
|
showToast: (String) -> Unit,
|
||||||
|
onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit,
|
||||||
|
onAutoUpload: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
|
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
|
||||||
@@ -297,11 +307,14 @@ private fun PagerCard(
|
|||||||
transceivers = uiState.transceivers.transmitters,
|
transceivers = uiState.transceivers.transmitters,
|
||||||
orbitalPos = uiState.orbitalPos,
|
orbitalPos = uiState.orbitalPos,
|
||||||
satelliteName = uiState.currentPass?.name ?: "",
|
satelliteName = uiState.currentPass?.name ?: "",
|
||||||
|
satelliteCatnum = uiState.currentPass?.catNum ?: 0,
|
||||||
queue = wavelogQueue,
|
queue = wavelogQueue,
|
||||||
wavelogConfigured = wavelogConfigured,
|
wavelogConfigured = wavelogConfigured,
|
||||||
showToast = showToast,
|
showToast = showToast,
|
||||||
txBaseFrequencyHz = uiState.radioControl.txBaseFrequencyHz,
|
txBaseFrequencyHz = uiState.radioControl.txBaseFrequencyHz,
|
||||||
aosTimeMs = uiState.currentPass?.aosTime ?: 0L
|
aosTimeMs = uiState.currentPass?.aosTime ?: 0L,
|
||||||
|
onLookupGrid = onLookupGrid,
|
||||||
|
onAutoUpload = onAutoUpload
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
|
||||||
|
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
@@ -64,7 +68,9 @@ class RadarViewModel(
|
|||||||
private val audioCapture: IAudioCapture,
|
private val audioCapture: IAudioCapture,
|
||||||
private val cwDecoderFactory: () -> ICwDecoder,
|
private val cwDecoderFactory: () -> ICwDecoder,
|
||||||
private val saveImage: ISaveImage,
|
private val saveImage: ISaveImage,
|
||||||
private val showToast: IShowToast
|
private val showToast: IShowToast,
|
||||||
|
private val qrzGridLookup: IQrzGridLookup,
|
||||||
|
private val wavelogUploader: WavelogUploader
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private var stationPos = settingsRepo.stationPosition.value
|
private var stationPos = settingsRepo.stationPosition.value
|
||||||
@@ -544,6 +550,42 @@ class RadarViewModel(
|
|||||||
_uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Idle)) }
|
_uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Idle)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload the queue now, if the operator asked for automatic uploads.
|
||||||
|
*
|
||||||
|
* Called when a contact is saved rather than on a timer. The ten-minute polling loop this
|
||||||
|
* replaces ran in the background with no way to report anything, so a grid mismatch was
|
||||||
|
* silently skipped and every other failure retried forever. Uploading as the contact is saved
|
||||||
|
* means somebody is present to see the result, and a QSO that cannot go now waits in the queue
|
||||||
|
* for a manual upload from settings.
|
||||||
|
*/
|
||||||
|
fun uploadIfAutomatic(onResult: (UploadOutcome?) -> Unit) {
|
||||||
|
val settings = settingsRepo.otherSettings.value
|
||||||
|
if (!settings.wavelogAutoUpload || settings.wavelogUrl.isBlank()) {
|
||||||
|
onResult(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
onResult(runCatching { wavelogUploader.uploadQueue() }.getOrNull())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a station's grid and hand the outcome back.
|
||||||
|
*
|
||||||
|
* Lives here rather than in the composable, which read the QRZ cookie straight out of
|
||||||
|
* SharedPreferences through LocalContext - disk access inside composition, around the
|
||||||
|
* repository layer. Every outcome is returned, because a lookup that failed used to be
|
||||||
|
* indistinguishable from a station that simply has no grid on file.
|
||||||
|
*/
|
||||||
|
fun lookupGrid(callsign: String, onResult: (QrzGrid) -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val outcome = runCatching { qrzGridLookup.lookup(callsign) }
|
||||||
|
.getOrElse { QrzGrid.Unreachable(1) }
|
||||||
|
onResult(outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// SSTV Decoder Tuning Parameters
|
// SSTV Decoder Tuning Parameters
|
||||||
// ==============================
|
// ==============================
|
||||||
@@ -599,7 +641,9 @@ class RadarViewModel(
|
|||||||
audioCapture = container.provideAudioCapture(),
|
audioCapture = container.provideAudioCapture(),
|
||||||
cwDecoderFactory = { container.provideCwDecoder() },
|
cwDecoderFactory = { container.provideCwDecoder() },
|
||||||
saveImage = container.provideSaveImage(),
|
saveImage = container.provideSaveImage(),
|
||||||
showToast = container.provideShowToast()
|
showToast = container.provideShowToast(),
|
||||||
|
qrzGridLookup = container.provideQrzGridLookup(),
|
||||||
|
wavelogUploader = container.provideWavelogUploader()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-6
@@ -44,7 +44,15 @@ import java.util.Calendar
|
|||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
|
||||||
private val CheckGreen = Color(0xFF4CAF50)
|
private val CheckGreen = Color(0xFF4CAF50)
|
||||||
private val GridLineColor = Color(0xFF3A3A3A)
|
/**
|
||||||
|
* Grid rules and column separators.
|
||||||
|
*
|
||||||
|
* Was 0xFF3A3A3A, which computes to 1.65:1 against the navBar background and 1.36:1 against a card,
|
||||||
|
* where Material asks 3:1 for non-text elements - the grid this page is built around was nearly
|
||||||
|
* invisible, and gone in sunlight. 0xFF6D6D6D is the lowest grey clearing 3:1 against both
|
||||||
|
* (3.62:1 and 3.00:1).
|
||||||
|
*/
|
||||||
|
private val GridLineColor = Color(0xFF6D6D6D)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WavelogLogScreen(
|
fun WavelogLogScreen(
|
||||||
@@ -139,15 +147,21 @@ fun WavelogLogScreen(
|
|||||||
GridVLine()
|
GridVLine()
|
||||||
Cell(entry.call, 0.dp, weight = 1f)
|
Cell(entry.call, 0.dp, weight = 1f)
|
||||||
GridVLine()
|
GridVLine()
|
||||||
|
// Upload state carried by the glyph, not by colour alone. The
|
||||||
|
// green tick collapses to 1.17:1 under this app's night filter,
|
||||||
|
// which zeroes green and blue, so the column disappeared entirely -
|
||||||
|
// and colour was the only thing separating sent from waiting.
|
||||||
Box(modifier = Modifier.width(44.dp)) {
|
Box(modifier = Modifier.width(44.dp)) {
|
||||||
if (entry.uploaded) {
|
|
||||||
Text(
|
Text(
|
||||||
text = "✓",
|
text = if (entry.uploaded) "OK" else "...",
|
||||||
fontSize = 15.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = CheckGreen
|
color = if (entry.uploaded) {
|
||||||
)
|
CheckGreen
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Row divider
|
// Row divider
|
||||||
|
|||||||
+108
-23
@@ -2,6 +2,7 @@ package com.rtbishop.look4sat.feature.settings
|
|||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -10,6 +11,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.MenuAnchorType
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsSymbols
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
@@ -33,12 +40,19 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
|
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
|
||||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
|
||||||
import com.rtbishop.look4sat.core.data.aprs.AprsStore
|
import com.rtbishop.look4sat.core.data.aprs.AprsStore
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
|
||||||
/** APRS card: sits between the satellite-data and data-output containers; settings live in a gear dialog to save space */
|
/** APRS card: sits between the satellite-data and data-output containers; settings live in a gear dialog to save space */
|
||||||
|
/**
|
||||||
|
* Where an operator obtains an APRS-IS passcode.
|
||||||
|
*
|
||||||
|
* The app links here rather than deriving one: the passcode is a licence check, and APRS-IS
|
||||||
|
* states that supplying it is the software author's responsibility.
|
||||||
|
*/
|
||||||
|
private const val PASSCODE_REQUEST_URL = "https://apps.magicbug.co.uk/passcode/"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AprsCard() {
|
fun AprsCard() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -171,6 +185,7 @@ fun AprsCard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** APRS settings dialog (plenty of room, full config) */
|
/** APRS settings dialog (plenty of room, full config) */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AprsSettingsDialog(
|
private fun AprsSettingsDialog(
|
||||||
config: AprsConfig,
|
config: AprsConfig,
|
||||||
@@ -186,7 +201,9 @@ private fun AprsSettingsDialog(
|
|||||||
var status by remember { mutableStateOf(config.statusText) }
|
var status by remember { mutableStateOf(config.statusText) }
|
||||||
var symbolTable by remember { mutableStateOf(config.symbolTable) }
|
var symbolTable by remember { mutableStateOf(config.symbolTable) }
|
||||||
var symbolCode by remember { mutableStateOf(config.symbolCode) }
|
var symbolCode by remember { mutableStateOf(config.symbolCode) }
|
||||||
|
var symbolMenuExpanded by remember { mutableStateOf(false) }
|
||||||
val textStyle = LocalTextStyle.current.copy(fontSize = 14.sp)
|
val textStyle = LocalTextStyle.current.copy(fontSize = 14.sp)
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -215,16 +232,19 @@ private fun AprsSettingsDialog(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
// Passcode compute button (user request: let the user see the computed result)
|
// Links out instead of computing it. APRS-IS treats the passcode as a licence
|
||||||
|
// check and says supplying it to a user is the software author's job; APRSdroid
|
||||||
|
// carries the same algorithm and deliberately does not use it here for that
|
||||||
|
// reason. Filling the field in claims a check that nobody performed.
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val call = callsign.trim().uppercase()
|
runCatching {
|
||||||
if (call.isNotBlank()) {
|
context.startActivity(
|
||||||
passcode = AprsPacket.passcode(call).toString()
|
Intent(Intent.ACTION_VIEW, Uri.parse(PASSCODE_REQUEST_URL))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
enabled = callsign.trim().isNotBlank()
|
) { Text(stringResource(id = R.string.prefs_aprs_request_passcode)) }
|
||||||
) { Text(stringResource(id = R.string.prefs_aprs_calc_passcode)) }
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = ssid,
|
value = ssid,
|
||||||
@@ -262,24 +282,62 @@ private fun AprsSettingsDialog(
|
|||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
// A list rather than two free-text fields. The fields accepted any string and used
|
||||||
OutlinedTextField(
|
// only the first character, so typing "satellite" persisted the word and beaconed
|
||||||
value = symbolTable,
|
// as `/`. The pair that makes this worth doing: \S is Satellite but /S is SHUTTLE.
|
||||||
onValueChange = { symbolTable = it },
|
val selected = AprsSymbols.find(symbolTable, symbolCode)
|
||||||
label = { Text(stringResource(id = R.string.prefs_aprs_symbol_table)) },
|
val customLabel = stringResource(
|
||||||
singleLine = true,
|
id = R.string.prefs_aprs_symbol_custom,
|
||||||
modifier = Modifier.weight(1f),
|
symbolTable.take(1),
|
||||||
textStyle = textStyle
|
symbolCode.take(1)
|
||||||
)
|
)
|
||||||
|
val selectedLabel = selected?.let { symbolLabel(it.descriptionKey) } ?: customLabel
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = symbolMenuExpanded,
|
||||||
|
onExpandedChange = { symbolMenuExpanded = it }
|
||||||
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = symbolCode,
|
value = selectedLabel,
|
||||||
onValueChange = { symbolCode = it },
|
onValueChange = {},
|
||||||
label = { Text(stringResource(id = R.string.prefs_aprs_symbol_code)) },
|
readOnly = true,
|
||||||
singleLine = true,
|
label = { Text(stringResource(id = R.string.prefs_aprs_symbol)) },
|
||||||
modifier = Modifier.weight(1f),
|
trailingIcon = {
|
||||||
textStyle = textStyle
|
ExposedDropdownMenuDefaults.TrailingIcon(expanded = symbolMenuExpanded)
|
||||||
|
},
|
||||||
|
textStyle = textStyle,
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = symbolMenuExpanded,
|
||||||
|
onDismissRequest = { symbolMenuExpanded = false }
|
||||||
|
) {
|
||||||
|
// An existing setting that is not on the list appears first and stays
|
||||||
|
// selectable, so opening the menu cannot silently change it.
|
||||||
|
if (selected == null) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(text = customLabel, maxLines = 1) },
|
||||||
|
onClick = { symbolMenuExpanded = false }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
AprsSymbols.curated.forEach { symbol ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(text = symbolLabel(symbol.descriptionKey), maxLines = 1) },
|
||||||
|
onClick = {
|
||||||
|
symbolTable = symbol.table.toString()
|
||||||
|
symbolCode = symbol.code.toString()
|
||||||
|
symbolMenuExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.prefs_aprs_symbol_help),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.prefs_aprs_passcode_hint),
|
text = stringResource(id = R.string.prefs_aprs_passcode_hint),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
@@ -304,7 +362,7 @@ private fun AprsSettingsDialog(
|
|||||||
intervalMin = interval.toIntOrNull() ?: 5,
|
intervalMin = interval.toIntOrNull() ?: 5,
|
||||||
statusText = status,
|
statusText = status,
|
||||||
symbolTable = symbolTable.ifBlank { "/" },
|
symbolTable = symbolTable.ifBlank { "/" },
|
||||||
symbolCode = symbolCode.ifBlank { ">" }
|
symbolCode = symbolCode.ifBlank { "-" }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}) { Text(stringResource(id = R.string.prefs_aprs_save)) }
|
}) { Text(stringResource(id = R.string.prefs_aprs_save)) }
|
||||||
@@ -327,3 +385,30 @@ private fun AprsSwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((
|
|||||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a symbol's description key to its localised text.
|
||||||
|
*
|
||||||
|
* AprsSymbols names a resource rather than holding text, because core:domain cannot reach resources
|
||||||
|
* and hardcoding English there would put wording outside the locale files. The mapping has to live
|
||||||
|
* on this side, and an unknown key falls back to the key itself rather than crashing - a missing
|
||||||
|
* translation should not take the settings screen down.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun symbolLabel(descriptionKey: String): String = when (descriptionKey) {
|
||||||
|
"aprs_symbol_house" -> stringResource(id = R.string.aprs_symbol_house)
|
||||||
|
"aprs_symbol_house_alt" -> stringResource(id = R.string.aprs_symbol_house_alt)
|
||||||
|
"aprs_symbol_person" -> stringResource(id = R.string.aprs_symbol_person)
|
||||||
|
"aprs_symbol_yagi" -> stringResource(id = R.string.aprs_symbol_yagi)
|
||||||
|
"aprs_symbol_satellite" -> stringResource(id = R.string.aprs_symbol_satellite)
|
||||||
|
"aprs_symbol_portable" -> stringResource(id = R.string.aprs_symbol_portable)
|
||||||
|
"aprs_symbol_phone" -> stringResource(id = R.string.aprs_symbol_phone)
|
||||||
|
"aprs_symbol_tcpip" -> stringResource(id = R.string.aprs_symbol_tcpip)
|
||||||
|
"aprs_symbol_ht" -> stringResource(id = R.string.aprs_symbol_ht)
|
||||||
|
"aprs_symbol_car" -> stringResource(id = R.string.aprs_symbol_car)
|
||||||
|
"aprs_symbol_truck" -> stringResource(id = R.string.aprs_symbol_truck)
|
||||||
|
"aprs_symbol_van" -> stringResource(id = R.string.aprs_symbol_van)
|
||||||
|
"aprs_symbol_rv" -> stringResource(id = R.string.aprs_symbol_rv)
|
||||||
|
"aprs_symbol_bike" -> stringResource(id = R.string.aprs_symbol_bike)
|
||||||
|
else -> descriptionKey
|
||||||
|
}
|
||||||
+32
-17
@@ -100,6 +100,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
|
||||||
|
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
@@ -123,7 +125,12 @@ fun SettingsDestination() {
|
|||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
|
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
SettingsScreen(uiState, viewModel::onAction, container.provideLotwSatellitesRepo())
|
SettingsScreen(
|
||||||
|
uiState,
|
||||||
|
viewModel::onAction,
|
||||||
|
container.provideLotwSatellitesRepo(),
|
||||||
|
container.provideQrzGridLookup()
|
||||||
|
)
|
||||||
|
|
||||||
// WaveLog grid mismatch confirmation dialog (4.5.2)
|
// WaveLog grid mismatch confirmation dialog (4.5.2)
|
||||||
val gridConfirm = viewModel.gridConfirm
|
val gridConfirm = viewModel.gridConfirm
|
||||||
@@ -187,7 +194,8 @@ fun SettingsDestination() {
|
|||||||
private fun SettingsScreen(
|
private fun SettingsScreen(
|
||||||
uiState: SettingsState,
|
uiState: SettingsState,
|
||||||
onAction: (SettingsAction) -> Unit,
|
onAction: (SettingsAction) -> Unit,
|
||||||
lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
|
lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo,
|
||||||
|
qrzLookup: IQrzGridLookup
|
||||||
) {
|
) {
|
||||||
val dialogs = rememberDialogVisibility()
|
val dialogs = rememberDialogVisibility()
|
||||||
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
@@ -405,7 +413,7 @@ private fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { OtherCard(uiState.otherSettings, onAction) }
|
item { OtherCard(uiState.otherSettings, onAction) }
|
||||||
item { WavelogCard(uiState.otherSettings, onAction, lotwRepo) }
|
item { WavelogCard(uiState.otherSettings, onAction, lotwRepo, qrzLookup) }
|
||||||
item {
|
item {
|
||||||
UiSettingsCard(
|
UiSettingsCard(
|
||||||
hiddenScreens = uiState.otherSettings.hiddenScreens,
|
hiddenScreens = uiState.otherSettings.hiddenScreens,
|
||||||
@@ -683,7 +691,8 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
|||||||
private fun WavelogCard(
|
private fun WavelogCard(
|
||||||
settings: OtherSettings,
|
settings: OtherSettings,
|
||||||
onAction: (SettingsAction) -> Unit,
|
onAction: (SettingsAction) -> Unit,
|
||||||
lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
|
lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo,
|
||||||
|
qrzLookup: IQrzGridLookup
|
||||||
) {
|
) {
|
||||||
val qrzContext = LocalContext.current
|
val qrzContext = LocalContext.current
|
||||||
var showQrzDialog by remember { mutableStateOf(false) }
|
var showQrzDialog by remember { mutableStateOf(false) }
|
||||||
@@ -694,6 +703,11 @@ private fun WavelogCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
var qrzTestResult by remember { mutableStateOf<String?>(null) }
|
var qrzTestResult by remember { mutableStateOf<String?>(null) }
|
||||||
|
val qrzEmptyMsg = stringResource(id = R.string.qrz_test_empty)
|
||||||
|
val qrzSignedOutMsg = stringResource(id = R.string.qrz_test_signed_out)
|
||||||
|
val qrzOkMsg = stringResource(id = R.string.qrz_test_ok)
|
||||||
|
val qrzNoGridMsg = stringResource(id = R.string.qrz_test_no_grid)
|
||||||
|
val qrzUnreachableMsg = stringResource(id = R.string.qrz_test_unreachable)
|
||||||
var qrzTesting by remember { mutableStateOf(false) }
|
var qrzTesting by remember { mutableStateOf(false) }
|
||||||
val qrzScope = rememberCoroutineScope()
|
val qrzScope = rememberCoroutineScope()
|
||||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -795,21 +809,22 @@ private fun WavelogCard(
|
|||||||
qrzTesting = true
|
qrzTesting = true
|
||||||
qrzTestResult = null
|
qrzTestResult = null
|
||||||
qrzScope.launch {
|
qrzScope.launch {
|
||||||
val header = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.parseCookies(qrzCookie)
|
// Goes through the repository rather than scraping from the UI, and
|
||||||
if (header.isBlank()) {
|
// reports each outcome distinctly - the old version could not tell
|
||||||
qrzTestResult = "Cookie 为空, 请先粘贴"
|
// an expired cookie from a station with no grid on file.
|
||||||
qrzTesting = false
|
qrzTestResult = if (qrzCookie.isBlank()) {
|
||||||
return@launch
|
qrzEmptyMsg
|
||||||
}
|
} else {
|
||||||
val own = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.fetchOwnCallsign(header)
|
val own = qrzLookup.signedInAs()
|
||||||
if (own == null) {
|
if (own == null) {
|
||||||
qrzTestResult = "Cookie 无效或已过期(无法识别登录呼号)"
|
qrzSignedOutMsg
|
||||||
} else {
|
} else {
|
||||||
val grid = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.lookupGrid(own, header)
|
when (val grid = qrzLookup.lookup(own)) {
|
||||||
qrzTestResult = if (grid != null) {
|
is QrzGrid.Found -> qrzOkMsg.format(own, grid.locator)
|
||||||
"登录呼号: $own 网格: $grid ✅"
|
QrzGrid.NotOnFile -> qrzNoGridMsg.format(own)
|
||||||
} else {
|
QrzGrid.SignedOut -> qrzSignedOutMsg
|
||||||
"登录呼号: $own 网格: 未填写"
|
is QrzGrid.Unreachable -> qrzUnreachableMsg
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
qrzTesting = false
|
qrzTesting = false
|
||||||
|
|||||||
+30
-12
@@ -275,9 +275,11 @@ class SettingsViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (val r = lotwRepo.refresh()) {
|
when (val r = lotwRepo.refresh()) {
|
||||||
is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Ok ->
|
is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Ok ->
|
||||||
showToast("LoTW 卫星列表已更新: ${r.count} 个")
|
showToast(R.string.lotw_list_updated, r.count)
|
||||||
is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Error ->
|
is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Error ->
|
||||||
wavelogError = "LoTW 卫星列表更新失败: ${r.message}"
|
// Server's own words only: the dialog supplies the framing, so the wording
|
||||||
|
// stays in the resource files rather than being built here in one language.
|
||||||
|
wavelogError = r.message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,21 +288,37 @@ class SettingsViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = pendingUploader?.uploadQueue()
|
val result = pendingUploader?.uploadQueue()
|
||||||
when (result) {
|
when (result) {
|
||||||
is UploadOutcome.Done -> {
|
is UploadOutcome.Done -> reportUpload(result)
|
||||||
if (result.failedCount > 0 && result.firstError.isNotBlank()) {
|
|
||||||
wavelogError = result.firstError
|
|
||||||
} else {
|
|
||||||
showToast("WaveLog: ${result.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is UploadOutcome.NeedConfirm -> {
|
is UploadOutcome.NeedConfirm -> {
|
||||||
gridConfirm = GridConfirmData(result.stationGrid, result.userGrid)
|
gridConfirm = GridConfirmData(result.stationGrid, result.userGrid)
|
||||||
}
|
}
|
||||||
null -> showToast("WaveLog: 上传失败")
|
null -> showToast(R.string.wavelog_upload_failed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report an upload outcome, keeping the wording in the resource files.
|
||||||
|
*
|
||||||
|
* The server's own explanation goes to [wavelogError] rather than a Toast, because it is the
|
||||||
|
* only thing that says why Wavelog refused a QSO and a Toast is gone in three seconds.
|
||||||
|
*/
|
||||||
|
private fun reportUpload(result: UploadOutcome.Done) {
|
||||||
|
when {
|
||||||
|
result.failedCount > 0 && result.firstError.isNotBlank() ->
|
||||||
|
wavelogError = result.firstError
|
||||||
|
result.reason == UploadOutcome.Reason.NOT_CONFIGURED ->
|
||||||
|
showToast(R.string.wavelog_not_configured)
|
||||||
|
result.reason == UploadOutcome.Reason.NO_STATION_INFO ->
|
||||||
|
showToast(R.string.wavelog_no_station)
|
||||||
|
result.failedCount > 0 ->
|
||||||
|
showToast(R.string.wavelog_upload_done, result.successCount, result.failedCount)
|
||||||
|
// "Uploaded 0" is accurate but reads oddly when the queue was already clear.
|
||||||
|
result.successCount == 0 -> showToast(R.string.wavelog_nothing_to_upload)
|
||||||
|
else -> showToast(R.string.wavelog_upload_all_ok, result.successCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Grid-confirm result: ignore and upload / cancel */
|
/** Grid-confirm result: ignore and upload / cancel */
|
||||||
fun resolveGridConfirm(ignoreAndUpload: Boolean) {
|
fun resolveGridConfirm(ignoreAndUpload: Boolean) {
|
||||||
val confirm = gridConfirm ?: return
|
val confirm = gridConfirm ?: return
|
||||||
@@ -309,8 +327,8 @@ class SettingsViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = pendingUploader?.uploadQueue(force = true)
|
val result = pendingUploader?.uploadQueue(force = true)
|
||||||
when (result) {
|
when (result) {
|
||||||
is UploadOutcome.Done -> showToast("WaveLog: ${result.message}")
|
is UploadOutcome.Done -> reportUpload(result)
|
||||||
else -> showToast("WaveLog: 上传失败")
|
else -> showToast(R.string.wavelog_upload_failed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "466"
|
appVersionCode = "469"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.5.9"
|
appVersionName = "4.6.2"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
|
|||||||
Reference in new issue
Block a user