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 |
No files matched your search
@@ -70,4 +70,6 @@ fastlane/readme.md
|
||||
/app/release/output-metadata.json
|
||||
/app/release/
|
||||
/.kotlin/sessions/
|
||||
# Kotlin build caches in any module, not only the root - build-logic produces one too.
|
||||
.kotlin/
|
||||
.hermes/
|
||||
@@ -15,7 +15,22 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!--
|
||||
dataSync rather than location. The location type is refused outright unless a location
|
||||
runtime permission has already been granted - startForeground throws SecurityException -
|
||||
and this service reads the station position the operator typed into settings, so demanding
|
||||
location access to beacon a fixed QTH is both wrong and a way to fail silently for anyone
|
||||
who declined it. The dataSync six-hour cap that prompted the earlier switch applies only
|
||||
when targetSdk is 35 or higher, which this project does not declare.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!--
|
||||
Doze suspends network access and ignores wake locks even for a foreground service, so a
|
||||
coroutine delay wakes up on time and then cannot reach the network. An exact alarm with
|
||||
setExactAndAllowWhileIdle is the only scheduling that survives Doze, and at a five-minute
|
||||
floor the system's one-alarm-per-nine-minutes throttle is not a problem.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.rtbishop.look4sat.app
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
@@ -11,7 +12,10 @@ import android.content.SharedPreferences
|
||||
import android.widget.Toast
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.IBinder
|
||||
import com.rtbishop.look4sat.BuildConfig
|
||||
import com.rtbishop.look4sat.MainApplication
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
|
||||
@@ -35,10 +39,25 @@ class AprsForegroundService : Service() {
|
||||
const val ACTION_REPORT_NOW = AprsStore.ACTION_REPORT_NOW
|
||||
const val CHANNEL_ID = "aprs_service"
|
||||
const val NOTIF_ID = 101
|
||||
|
||||
/** Alarm-driven tick, kept separate so a manual report stays distinguishable. */
|
||||
const val ACTION_ALARM_TICK = "com.rtbishop.look4sat.APRS_ALARM_TICK"
|
||||
|
||||
private const val ALARM_REQUEST = 4101
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var reporter: AprsReporter? = null
|
||||
|
||||
/**
|
||||
* Handler on the main looper, for anything that must not run on the reporter's IO thread.
|
||||
*
|
||||
* onReport is invoked from AprsReporter's Dispatchers.IO scope, and Toast construction there
|
||||
* throws because that thread has no Looper - an exception the surrounding runCatching then
|
||||
* swallowed, so every report notice was silently discarded. The messages existed and no
|
||||
* operator ever saw one.
|
||||
*/
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var lastState: AprsState = AprsState.Idle
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
@@ -51,6 +70,12 @@ class AprsForegroundService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> stopReporting()
|
||||
ACTION_ALARM_TICK -> {
|
||||
// Woken by the exact alarm. Reporting once and then booking the next tick, rather
|
||||
// than using a repeating alarm, means a changed interval takes effect at once.
|
||||
if (reporter == null) startReporting() else reporter?.reportNow()
|
||||
scheduleNextTick()
|
||||
}
|
||||
ACTION_REPORT_NOW -> {
|
||||
if (reporter == null) {
|
||||
// Service not running: start it first (Toast hint when not configured)
|
||||
@@ -85,18 +110,33 @@ class AprsForegroundService : Service() {
|
||||
startForegroundWithNotification(cfg)
|
||||
val rep = AprsReporter(
|
||||
configProvider = { AprsStore.loadConfig(this) },
|
||||
// The real version, so the login line cannot drift from the build again.
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
positionProvider = { stationPosition() },
|
||||
onState = { lastState = it },
|
||||
onReport = { report ->
|
||||
AprsStore.saveLastReport(this, report.ok, report.detail)
|
||||
// Derived from this report rather than read from lastState: onState fires AFTER
|
||||
// onReport, so the notification was being rebuilt from the previous cycle's
|
||||
// verdict. For an alarm-driven report at 03:00 the notification is the only
|
||||
// surface that survives, and it was showing the wrong one.
|
||||
lastState = if (report.ok) AprsState.Connected else AprsState.Error
|
||||
updateNotification(cfg)
|
||||
// Report result always surfaces: success = short Toast, failure = long Toast + reason
|
||||
val msg = if (report.ok) {
|
||||
getString(R.string.aprs_toast_ok)
|
||||
} else {
|
||||
getString(R.string.aprs_toast_fail, report.detail)
|
||||
// An unverified login needs its own message: the write succeeded, so a bare
|
||||
// failure notice would send the operator looking at their network when the
|
||||
// problem is the passcode - and APRS-IS is dropping every packet meanwhile.
|
||||
// No receive-only notice. APRS-IS lets an unverified station connect and then
|
||||
// discards its packets, but this app only reports its own position - there is no
|
||||
// receiving side to it - so telling the operator they are "in receive-only mode"
|
||||
// named a state that does not exist here. Without a passcode the packet does not
|
||||
// arrive, and that is what the failure notice says.
|
||||
val msg = when {
|
||||
report.ok -> getString(R.string.aprs_toast_ok)
|
||||
!report.verified -> getString(R.string.aprs_toast_unverified)
|
||||
else -> getString(R.string.aprs_toast_fail, report.detail)
|
||||
}
|
||||
runCatching {
|
||||
mainHandler.post {
|
||||
Toast.makeText(this, msg,
|
||||
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
|
||||
}
|
||||
@@ -104,9 +144,12 @@ class AprsForegroundService : Service() {
|
||||
)
|
||||
reporter = rep
|
||||
rep.start()
|
||||
// The reporter beacons once on start; the alarm carries every one after that.
|
||||
scheduleNextTick()
|
||||
}
|
||||
|
||||
private fun stopReporting() {
|
||||
cancelTicks()
|
||||
reporter?.stop()
|
||||
reporter = null
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
@@ -117,6 +160,11 @@ class AprsForegroundService : Service() {
|
||||
try {
|
||||
val notif = buildNotification(cfg)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Must match the manifest attribute exactly: AOSP checks the passed type is a
|
||||
// subset of the declared one and throws otherwise, which the catch below turns
|
||||
// into a silent stopSelf(). Declaring location instead would additionally require
|
||||
// a granted location permission before this call, and the settings card asks only
|
||||
// for notifications - so that combination fails silently too.
|
||||
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, notif)
|
||||
@@ -197,4 +245,43 @@ class AprsForegroundService : Service() {
|
||||
reporter = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Book the next beacon with an exact alarm.
|
||||
*
|
||||
* A coroutine delay was used before, which Doze defeats: the timer fires but network access is
|
||||
* suspended and wake locks are ignored, even inside a foreground service. Only
|
||||
* setExactAndAllowWhileIdle survives that, and the five-minute floor keeps this well clear of
|
||||
* the system's throttle on how often such an alarm may repeat.
|
||||
*/
|
||||
private fun scheduleNextTick() {
|
||||
val cfg = AprsStore.loadConfig(this)
|
||||
if (!cfg.enabled) return
|
||||
val alarms = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val minutes = cfg.intervalMin.coerceAtLeast(AprsReporter.MIN_INTERVAL_MIN)
|
||||
val at = System.currentTimeMillis() + minutes * 60_000L
|
||||
runCatching {
|
||||
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()
|
||||
if (exact) {
|
||||
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, tickIntent())
|
||||
} else {
|
||||
// The operator revoked exact alarms. An inexact one still beacons, just whenever
|
||||
// the system decides, which beats not beaconing at all.
|
||||
alarms.set(AlarmManager.RTC_WAKEUP, at, tickIntent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelTicks() {
|
||||
val alarms = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
runCatching { alarms.cancel(tickIntent()) }
|
||||
}
|
||||
|
||||
private fun tickIntent(): PendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
ALARM_REQUEST,
|
||||
Intent(this, AprsForegroundService::class.java).setAction(ACTION_ALARM_TICK),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
}
|
||||
+6
@@ -62,6 +62,12 @@ internal fun Project.setupAndroidApp() {
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
versionName = libs.versions.appVersionName.get()
|
||||
}
|
||||
// The APRS login line reports the app version to every station on the network. It used
|
||||
// to be a literal in core:data and drifted twice, so the app module now reads
|
||||
// BuildConfig.VERSION_NAME - which AGP 8 only generates when asked.
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
package com.rtbishop.look4sat.core.data.aprs
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsLogin
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||
import java.io.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.io.PrintWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
/**
|
||||
* APRS-IS TCP client (reverse-ported from APRSdroid TcpUploader.scala).
|
||||
* Plain-text protocol: one login line + one packet per line; 30 s reconnect after drop.
|
||||
* APRS-IS TCP client. Plain text: the server greets, the client logs in, then one packet per line.
|
||||
*
|
||||
* Two things here decide whether the operator can trust the app at all. A packet sent on a dead
|
||||
* socket must not report success, and a login the server refused to verify must not look like a
|
||||
* working connection - an unverified client stays connected while the server silently drops
|
||||
* everything it sends.
|
||||
*/
|
||||
class AprsIsClient(
|
||||
private val host: String,
|
||||
@@ -18,6 +25,7 @@ class AprsIsClient(
|
||||
private val callsign: String,
|
||||
private val ssid: String,
|
||||
private val passcode: Int,
|
||||
private val softwareName: String,
|
||||
private val version: String,
|
||||
private val filter: String = "",
|
||||
private val timeoutSec: Int = 120
|
||||
@@ -27,86 +35,188 @@ class AprsIsClient(
|
||||
private var reader: BufferedReader? = null
|
||||
private val lock = Any()
|
||||
|
||||
val isConnected: Boolean
|
||||
get() = synchronized(lock) { socket?.isConnected == true && !socket!!.isClosed }
|
||||
/**
|
||||
* What the server said about this login, or null before a login has been attempted.
|
||||
*
|
||||
* Kept as state rather than only thrown, because [AprsLogin.Outcome.Unverified] is not a
|
||||
* connection error: the socket is up and writes succeed. The operator has to be told, or
|
||||
* they will watch reports "succeed" for hours while nothing reaches the network.
|
||||
*/
|
||||
@Volatile
|
||||
var loginOutcome: AprsLogin.Outcome? = null
|
||||
private set
|
||||
|
||||
/** Connect + login (synchronous/blocking; call from a background thread) */
|
||||
val isConnected: Boolean
|
||||
get() = synchronized(lock) { socket?.isConnected == true && socket?.isClosed == false }
|
||||
|
||||
/** True when the server verified the passcode, so packets from this client are accepted. */
|
||||
val isVerified: Boolean get() = loginOutcome is AprsLogin.Outcome.Verified
|
||||
|
||||
/**
|
||||
* True only when the server told us it did NOT verify the login.
|
||||
*
|
||||
* Distinct from `!isVerified` on purpose. [AprsLogin.Outcome.Unverified] means the server
|
||||
* said so and really is discarding our packets. [AprsLogin.Outcome.Unknown] means we could
|
||||
* not recognise its answer - the packets may well be landing - so blaming the operator's
|
||||
* passcode for that would send them to fix something that is not broken.
|
||||
*/
|
||||
val isRefusedByServer: Boolean get() = loginOutcome is AprsLogin.Outcome.Unverified
|
||||
|
||||
/**
|
||||
* Connect and log in. Blocking; call from a background thread.
|
||||
*
|
||||
* Throws when the connection cannot be made or the server rejected the login outright.
|
||||
* A login the server accepted but did not verify returns normally and leaves
|
||||
* [loginOutcome] as [AprsLogin.Outcome.Unverified] for the caller to surface.
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
fun connect() {
|
||||
disconnect()
|
||||
loginOutcome = null
|
||||
val s = Socket()
|
||||
try {
|
||||
s.connect(InetSocketAddress(host, port), 30_000)
|
||||
s.soTimeout = timeoutSec * 1000
|
||||
s.connect(InetSocketAddress(host, port), CONNECT_MS)
|
||||
s.tcpNoDelay = true
|
||||
synchronized(lock) {
|
||||
socket = s
|
||||
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
|
||||
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
|
||||
}
|
||||
// Login line
|
||||
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
|
||||
writer?.println(login)
|
||||
// Read the login response (aprsc replies # logresp ... verified/unverified)
|
||||
runCatching {
|
||||
s.soTimeout = 8000
|
||||
val resp = reader?.readLine()
|
||||
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||
resp.contains("unverified", ignoreCase = true))) {
|
||||
throw IllegalArgumentException(resp.trim())
|
||||
}
|
||||
// Restore timeout
|
||||
s.soTimeout = timeoutSec * 1000
|
||||
s.soTimeout = LOGIN_MS
|
||||
// The spec has the client log in AFTER the server's identification line, so read the
|
||||
// greeting first. Anything starting with # is a comment and may be skipped.
|
||||
readGreeting(s)?.let { refusal ->
|
||||
// The server refused before we even logged in. Previously this verdict was
|
||||
// computed and then overwritten by readLoginResponse, so the branch was a lie.
|
||||
loginOutcome = refusal
|
||||
throw IllegalArgumentException(refusal.detail)
|
||||
}
|
||||
val login = AprsLogin.line(callsign, ssid, passcode, softwareName, version, filter)
|
||||
writer?.print(login)
|
||||
writer?.print(CRLF)
|
||||
writer?.flush()
|
||||
loginOutcome = readLoginResponse()
|
||||
s.soTimeout = timeoutSec * 1000
|
||||
val outcome = loginOutcome
|
||||
if (outcome is AprsLogin.Outcome.Rejected) throw IllegalArgumentException(outcome.detail)
|
||||
} catch (e: Exception) {
|
||||
// Close the local socket before re-throwing, so it does not leak when
|
||||
// an exception is raised after s.connect() but before socket = s.
|
||||
// Otherwise periodic reconnect attempts (AprsReporter every 1–60 min)
|
||||
// accumulate leaked fds until the process cannot open any more files.
|
||||
// Close the local socket before re-throwing so it does not leak when the failure
|
||||
// lands after connect() but before the field assignment - otherwise periodic
|
||||
// reconnects accumulate file descriptors until no more can be opened.
|
||||
runCatching { s.close() }
|
||||
synchronized(lock) {
|
||||
writer = null
|
||||
reader = null
|
||||
socket = null
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one APRS packet (one line) and tries to read the server ack.
|
||||
* Returns null=failed to send; Pair(ok, detail)=result (server error text lives in detail)
|
||||
* Consume the server's greeting comment, returning a refusal when it is not one.
|
||||
*
|
||||
* Absence is tolerated because some servers send none, but on a short probe window rather
|
||||
* than the full login timeout: waiting LOGIN_MS for a greeting that will never come cost
|
||||
* eight seconds on every single connect to such a server.
|
||||
*/
|
||||
private fun readGreeting(socket: Socket): AprsLogin.Outcome.Rejected? {
|
||||
val previous = socket.soTimeout
|
||||
return try {
|
||||
socket.soTimeout = GREETING_MS
|
||||
val line = reader?.readLine() ?: return null
|
||||
// A server that opens with anything but a comment is refusing us.
|
||||
if (line.startsWith("#")) null else AprsLogin.Outcome.Rejected(line.trim())
|
||||
} catch (ignored: IOException) {
|
||||
null
|
||||
} finally {
|
||||
runCatching { socket.soTimeout = previous }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read lines until the login verdict arrives, skipping keepalive comments.
|
||||
*
|
||||
* Bounded by the read timeout, so an unresponsive server cannot hang the caller.
|
||||
*/
|
||||
private fun readLoginResponse(): AprsLogin.Outcome {
|
||||
// Bounded by a deadline, not a line count: comments are free to skip, and a chatty
|
||||
// server that sent six of them before its verdict used to exhaust a fixed budget and
|
||||
// turn an accepted login into Unknown - telling the operator their passcode was wrong
|
||||
// when it had just been accepted.
|
||||
val deadline = System.currentTimeMillis() + LOGIN_MS
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val line = try {
|
||||
reader?.readLine()
|
||||
} catch (timeout: SocketTimeoutException) {
|
||||
return AprsLogin.Outcome.Unknown("no response within ${LOGIN_MS}ms")
|
||||
} catch (failure: IOException) {
|
||||
return AprsLogin.Outcome.Rejected(failure.message ?: "login read failed")
|
||||
} ?: return AprsLogin.Outcome.Rejected("connection closed during login")
|
||||
AprsLogin.parse(line)?.let { return it }
|
||||
}
|
||||
return AprsLogin.Outcome.Unknown("no login response recognised")
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one packet and report what happened.
|
||||
*
|
||||
* Returns null when there is no connection to write to. Otherwise a pair of whether the
|
||||
* packet went out and a detail string for the operator.
|
||||
*/
|
||||
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
|
||||
synchronized(lock) {
|
||||
val w = writer ?: return null
|
||||
w.println(packetLine)
|
||||
// Reading the response inside the same lock: disconnect() may run concurrently and
|
||||
// null these fields, and reading outside the lock raced with that - the read could
|
||||
// hit a just-closed socket and be reported as a successful send.
|
||||
// CRLF explicitly rather than println: the spec requires "TNC2 format terminated by
|
||||
// a carriage return, line feed sequence", and println emits the platform separator,
|
||||
// a bare LF on Android. An earlier draft of this method sent no terminator at all,
|
||||
// which leaves the server's line reader waiting forever while every send reports
|
||||
// success - exactly the failure this class exists to prevent.
|
||||
w.print(packetLine)
|
||||
w.print(CRLF)
|
||||
w.flush()
|
||||
if (w.checkError()) return Pair(false, "write failed")
|
||||
// Read the server response inside the same lock: disconnect() (called
|
||||
// concurrently from stop()/reconnect on another thread) nulls
|
||||
// writer/reader/socket and closes them. Reading outside the lock raced
|
||||
// with that: the response read could hit a just-closed socket and the
|
||||
// swallowing runCatching reported Pair(true,"OK") for a packet that
|
||||
// never left, or read through a stale reference. Serialising keeps
|
||||
// the read on the connection this thread just wrote to. The 3 s read
|
||||
// timeout bounds how long a concurrent disconnect waits.
|
||||
return runCatching {
|
||||
val s = socket ?: return@runCatching Pair(true, "OK")
|
||||
val oldTimeout = s.soTimeout
|
||||
s.soTimeout = 3000
|
||||
try {
|
||||
val resp = reader?.readLine()
|
||||
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||
resp.contains("error", ignoreCase = true))) {
|
||||
Pair(false, resp.trim())
|
||||
} else {
|
||||
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
|
||||
}
|
||||
} finally {
|
||||
s.soTimeout = oldTimeout
|
||||
}
|
||||
}.getOrElse { Pair(true, "OK") }
|
||||
val s = socket ?: return Pair(false, "not connected")
|
||||
val previousTimeout = s.soTimeout
|
||||
return try {
|
||||
s.soTimeout = ACK_MS
|
||||
classifyAck(reader?.readLine())
|
||||
} catch (timeout: SocketTimeoutException) {
|
||||
// Silence is the normal case: APRS-IS does not acknowledge a position report, so
|
||||
// nothing arriving means the line went out and the server had nothing to say.
|
||||
// Telling this apart from a broken connection is the point of this method - the
|
||||
// previous version treated EVERY exception as success, so a dead socket reported
|
||||
// "sent OK" and made every real failure invisible, including a rejected login.
|
||||
Pair(true, "sent")
|
||||
} catch (failure: IOException) {
|
||||
Pair(false, failure.message ?: "read failed")
|
||||
} finally {
|
||||
runCatching { s.soTimeout = previousTimeout }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one line (server response; throws on timeout) */
|
||||
fun readLine(): String? {
|
||||
return reader?.readLine()
|
||||
/**
|
||||
* Interpret whatever the server sent back after a packet.
|
||||
*
|
||||
* Shares AprsLogin's judgement rather than keeping its own, because the two disagreed in a way
|
||||
* that mattered: treating any leading `#` as harmless meant `# Port full` and `# Login by user
|
||||
* not allowed` - both of which mean the server is about to drop us - were reported as a
|
||||
* successful send. APRS-IS does not acknowledge position reports, so a harmless comment still
|
||||
* counts as sent; anything the server says that is not harmless does not.
|
||||
*/
|
||||
private fun classifyAck(response: String?): Pair<Boolean, String> {
|
||||
if (response == null) return Pair(false, "connection closed by server")
|
||||
return when (val verdict = AprsLogin.parse(response)) {
|
||||
// A keepalive or identification comment: no verdict, so the write stands.
|
||||
null -> Pair(true, "sent")
|
||||
is AprsLogin.Outcome.Rejected -> Pair(false, verdict.detail)
|
||||
// A late login verdict is not about this packet, and loginOutcome already carries it.
|
||||
else -> Pair(true, "sent")
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
@@ -119,4 +229,16 @@ class AprsIsClient(
|
||||
socket = null
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Line terminator the protocol requires, independent of the platform's own. */
|
||||
const val CRLF = "\r\n"
|
||||
|
||||
const val CONNECT_MS = 30_000
|
||||
const val LOGIN_MS = 8_000
|
||||
const val ACK_MS = 3_000
|
||||
|
||||
/** Probe window for the greeting, short because its absence is legitimate. */
|
||||
const val GREETING_MS = 2_000
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
package com.rtbishop.look4sat.core.data.aprs
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPosition
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsBeacon
|
||||
import com.rtbishop.look4sat.core.domain.aprs.AprsPasscode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -34,12 +33,39 @@ data class AprsReport(
|
||||
val timestamp: Long,
|
||||
val packet: String,
|
||||
val ok: Boolean,
|
||||
val detail: String
|
||||
val detail: String,
|
||||
/**
|
||||
* False only when the server told us it did not verify the login.
|
||||
*
|
||||
* Carried separately from [ok] because the two are independent: a refused client's writes
|
||||
* still succeed, so the packet leaves the phone and looks sent, while aprsc discards every
|
||||
* one of them. Without surfacing it the operator can watch reports succeed for hours with
|
||||
* nothing reaching the network. A login whose response we simply could not parse leaves this
|
||||
* true, since the packets may be landing and the passcode is not at fault.
|
||||
*/
|
||||
val verified: Boolean = true,
|
||||
/**
|
||||
* True when the operator asked for a receive-only connection.
|
||||
*
|
||||
* Distinguished from a refused login because both log in with -1 and the server answers
|
||||
* "unverified" to each: without this, deliberately choosing receive-only - the one way to
|
||||
* test a setup without putting anything on the network - was reported as a wrong passcode
|
||||
* and sent the operator to fix something they had set on purpose.
|
||||
*/
|
||||
|
||||
)
|
||||
|
||||
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
|
||||
class AprsReporter(
|
||||
private val configProvider: () -> AprsConfig,
|
||||
/**
|
||||
* The app's own version, reported to APRS-IS in the login line.
|
||||
*
|
||||
* Passed in because core:data has no BuildConfig. It used to be a literal here and drifted
|
||||
* exactly as predicted: it still read 4.6.0 two releases later, so every station on the
|
||||
* network was told the wrong version. A caller in the app module can read the real one.
|
||||
*/
|
||||
private val appVersion: String,
|
||||
private val positionProvider: () -> Pair<Double, Double>? = { null },
|
||||
private val onState: (AprsState) -> Unit = {},
|
||||
private val onReport: (AprsReport) -> Unit = {}
|
||||
@@ -59,12 +85,11 @@ class AprsReporter(
|
||||
onState(AprsState.Error)
|
||||
return
|
||||
}
|
||||
job = scope.launch {
|
||||
while (isActive) {
|
||||
reportOnce()
|
||||
delay(cfg.intervalMin.coerceAtLeast(1) * 60_000L)
|
||||
}
|
||||
}
|
||||
// One report now; the service's exact alarm drives every one after this. The loop that
|
||||
// used to live here relied on a coroutine delay, which Doze defeats - the timer fires on
|
||||
// schedule and then finds network access suspended, so the beacon stopped whenever the
|
||||
// screen locked while the notification still claimed it was running.
|
||||
job = scope.launch { reportOnce() }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
@@ -86,41 +111,92 @@ class AprsReporter(
|
||||
if (!cfg.enabled || cfg.callsign.isBlank()) return
|
||||
onState(AprsState.Connecting)
|
||||
try {
|
||||
// The packet is built BEFORE connecting: there is no reason to open a session and log
|
||||
// in only to discover there is nothing to send, which happened every five minutes for
|
||||
// an operator whose QTH was unset.
|
||||
val pos = positionProvider()
|
||||
val beacon = AprsBeacon.build(
|
||||
callsign = cfg.callsign,
|
||||
ssid = cfg.ssid,
|
||||
latitude = pos?.first,
|
||||
longitude = pos?.second,
|
||||
symbolTable = cfg.symbolTable,
|
||||
symbolCode = cfg.symbolCode,
|
||||
comment = cfg.statusText
|
||||
)
|
||||
// Nothing goes out without a position. Substituting 0,0 put this station in the Gulf
|
||||
// of Guinea on the global network, under the operator's own callsign.
|
||||
if (beacon is AprsBeacon.Result.Blocked) {
|
||||
onState(AprsState.Error)
|
||||
onReport(
|
||||
AprsReport(System.currentTimeMillis(), "", false, refusalDetail(beacon.refusal))
|
||||
)
|
||||
return
|
||||
}
|
||||
val packetLine = (beacon as AprsBeacon.Result.Line).text
|
||||
|
||||
val c = client ?: AprsIsClient(
|
||||
host = cfg.server,
|
||||
port = cfg.port,
|
||||
callsign = cfg.callsign,
|
||||
ssid = cfg.ssid,
|
||||
passcode = cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(cfg.callsign),
|
||||
version = "Look4Sat 4.5.4"
|
||||
// Never derives one: a blank or wrong entry logs in receive-only rather than
|
||||
// transmitting under a passcode the app invented for an unchecked licence.
|
||||
passcode = AprsPasscode.loginValue(cfg.callsign, cfg.passcode),
|
||||
// Two fields, because APRS-IS wants `vers <name> <version>` as separate tokens.
|
||||
softwareName = "Look4Sat",
|
||||
version = appVersion
|
||||
).also { client = it }
|
||||
if (!c.isConnected) c.connect()
|
||||
onState(AprsState.Connected)
|
||||
|
||||
val pos = positionProvider()
|
||||
val packetLine = buildPositionPacket(cfg, pos?.first, pos?.second)
|
||||
val result = c.sendPacket(packetLine)
|
||||
val ok = result?.first == true
|
||||
val sent = result?.first == true
|
||||
val detail = result?.second ?: "no connection"
|
||||
onReport(AprsReport(System.currentTimeMillis(), packetLine, ok, detail))
|
||||
// A write that succeeded on a login the server refused to verify is not a delivered
|
||||
// packet: aprsc takes it and drops it, which is what let every real failure hide.
|
||||
// Only an explicit refusal counts against us though - a login whose response we
|
||||
// could not parse may be working fine, and blaming the passcode for that would send
|
||||
// the operator to fix something that is not broken.
|
||||
val refused = c.isRefusedByServer
|
||||
val ok = sent && !refused
|
||||
// "sent" is the write's own verdict and reads as nonsense next to a failure - the card
|
||||
// showed "failed - sent" for a refused login. When the refusal is what failed the
|
||||
// report, say that instead.
|
||||
val reported = when {
|
||||
ok -> detail
|
||||
refused -> "login not verified"
|
||||
else -> detail
|
||||
}
|
||||
onReport(
|
||||
AprsReport(
|
||||
System.currentTimeMillis(), packetLine, ok, reported,
|
||||
verified = !refused
|
||||
)
|
||||
)
|
||||
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
|
||||
} catch (e: Exception) {
|
||||
runCatching { client?.disconnect() }
|
||||
client = null
|
||||
onState(AprsState.Error)
|
||||
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error"))
|
||||
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error", false))
|
||||
}
|
||||
}
|
||||
|
||||
/** Build position packet: BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME<status text */
|
||||
private fun buildPositionPacket(cfg: AprsConfig, lat: Double? = null, lon: Double? = null): String {
|
||||
val source = AprsPacket.formatCallSsid(cfg.callsign, cfg.ssid)
|
||||
val pos = AprsPosition(
|
||||
latitude = lat ?: 0.0,
|
||||
longitude = lon ?: 0.0,
|
||||
symbolTable = cfg.symbolTable.firstOrNull() ?: '/',
|
||||
symbolCode = cfg.symbolCode.firstOrNull() ?: '>'
|
||||
)
|
||||
return "$source>APRS:=${pos.toUncompressedString()}${cfg.statusText}"
|
||||
|
||||
/** A short reason for a refusal, for the operator's last-report line. */
|
||||
private fun refusalDetail(refusal: AprsBeacon.Refusal): String = when (refusal) {
|
||||
AprsBeacon.Refusal.NoPosition -> "no position yet"
|
||||
AprsBeacon.Refusal.NoCallsign -> "no callsign set"
|
||||
is AprsBeacon.Refusal.ImpossiblePosition -> "position out of range"
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
/** Floor for the reporting interval, in minutes. */
|
||||
const val MIN_INTERVAL_MIN = 5
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,6 +24,15 @@ object AprsStore {
|
||||
private const val KEY_STATUS = "status"
|
||||
private const val KEY_SYMBOL_TABLE = "symbol_table"
|
||||
private const val KEY_SYMBOL_CODE = "symbol_code"
|
||||
|
||||
/**
|
||||
* A house, not a car.
|
||||
*
|
||||
* Only fresh installs see this: saveConfig writes every key unconditionally and the enable
|
||||
* switch calls it, so anyone who has ever turned APRS on has both symbol keys on disk and this
|
||||
* fallback cannot reach them.
|
||||
*/
|
||||
private const val DEFAULT_SYMBOL_CODE = "-"
|
||||
private const val KEY_LAST_TIME = "last_report_time"
|
||||
private const val KEY_LAST_OK = "last_report_ok"
|
||||
private const val KEY_LAST_DETAIL = "last_report_detail"
|
||||
@@ -41,7 +50,7 @@ object AprsStore {
|
||||
intervalMin = p.getInt(KEY_INTERVAL, 5),
|
||||
statusText = p.getString(KEY_STATUS, "Look4Sat APRS") ?: "Look4Sat APRS",
|
||||
symbolTable = p.getString(KEY_SYMBOL_TABLE, "/") ?: "/",
|
||||
symbolCode = p.getString(KEY_SYMBOL_CODE, ">") ?: ">"
|
||||
symbolCode = p.getString(KEY_SYMBOL_CODE, DEFAULT_SYMBOL_CODE) ?: DEFAULT_SYMBOL_CODE
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwAntiAlias
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDetectionPool
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwShiftDecider
|
||||
@@ -35,6 +36,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.nio.FloatBuffer
|
||||
@@ -64,13 +66,28 @@ class CwDeepDecoder(
|
||||
private val isToneShiftEnabled: () -> Boolean = { false }
|
||||
) : ICwDecoder {
|
||||
|
||||
private companion object {
|
||||
// internal, not private: the archive timing is user-visible behaviour and its test asserts
|
||||
// against these constants directly rather than a copy that could silently drift.
|
||||
internal companion object {
|
||||
const val TAG = "CwDeepDecoder"
|
||||
const val MODEL_ASSET = "deepcw/model.onnx"
|
||||
const val METADATA_ASSET = "deepcw/model.onnx.json"
|
||||
|
||||
/** Evicted audio is decoded into permanent history once this much accumulates. */
|
||||
const val ARCHIVE_SECONDS = 15.0
|
||||
/**
|
||||
* Evicted audio is decoded into permanent history once this much accumulates.
|
||||
*
|
||||
* This is the delay before decoded text reaches the record, and it is additive with
|
||||
* the 20 s live window: at the old 15 s the record showed nothing for the first 35 s
|
||||
* of a session, and thereafter text that had scrolled out of the live window sat
|
||||
* invisible for up to 15 s before landing - the record appeared to stall and, when
|
||||
* it was still concatenating the live window, to delete what it had just shown.
|
||||
*
|
||||
* Each batch is one full inference, so this trades CPU for latency. 4 s holds the gap
|
||||
* under the ~4.7 s a seven-character call sign takes at 18 WPM - the record must not
|
||||
* stall for longer than the one thing an operator most needs to read back - while the
|
||||
* archive path still fires less than half as often as the 1.5 s live redecode cycle.
|
||||
*/
|
||||
const val ARCHIVE_SECONDS = 4.0
|
||||
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
|
||||
|
||||
/**
|
||||
@@ -104,13 +121,12 @@ class CwDeepDecoder(
|
||||
override val decodedText: StateFlow<String> = _decodedText.asStateFlow()
|
||||
|
||||
/**
|
||||
* Archived text, plus a provisional decode of audio not yet archived.
|
||||
* Archived text: only appended to, so a pane bound to it never loses what it showed.
|
||||
*
|
||||
* Kept as one flow of the two parts concatenated. Without the provisional part the
|
||||
* transcript visibly shrank: audio leaving the 20 s window waits for a full
|
||||
* [ARCHIVE_SECONDS] batch before it is decoded into the archive, so for up to 15 s its
|
||||
* characters were in neither place - measured, up to 30 characters at 20 WPM would
|
||||
* vanish and reappear later, which reads as the box deleting text.
|
||||
* This once carried a provisional tail meant to cover the gap while audio waited to be
|
||||
* archived, but the decode feeding that tail was never wired up, so the tail was always
|
||||
* empty and the gap stayed. It is closed instead by archiving in [ARCHIVE_SECONDS]
|
||||
* batches, which no longer concatenate anything that gets rewritten.
|
||||
*/
|
||||
private val _historyText = MutableStateFlow("")
|
||||
override val historyText: StateFlow<String> = _historyText.asStateFlow()
|
||||
@@ -118,9 +134,6 @@ class CwDeepDecoder(
|
||||
/** Permanently archived text; the provisional tail is appended to this for display. */
|
||||
private var committedText = ""
|
||||
|
||||
/** Provisional decode of the pending archive batch, replaced when it is archived. */
|
||||
private var pendingText = ""
|
||||
|
||||
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
|
||||
|
||||
@@ -142,18 +155,54 @@ class CwDeepDecoder(
|
||||
private val buffer = CwDeepBuffer()
|
||||
|
||||
/**
|
||||
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then
|
||||
* is decoded once and appended to [historyText]. Archiving in ~15 s chunks
|
||||
* keeps the extra inference cheap (short window) while long enough to be
|
||||
* decoded accurately — the content has already been through the 20 s window
|
||||
* many times, so a slightly shorter archive decode loses almost nothing.
|
||||
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then is decoded
|
||||
* once and appended to [historyText]. The batch stays short enough that the record does
|
||||
* not visibly stall, and accuracy barely suffers: this content has already been through
|
||||
* the 20 s window many times, so the archive decode is a confirmation, not a first look.
|
||||
*
|
||||
* Sized to the full window rather than the batch: [flush] hands over whatever the live
|
||||
* window holds, which can be the whole 20 s.
|
||||
*/
|
||||
private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE)
|
||||
private var archiveSize = 0
|
||||
|
||||
/**
|
||||
* Window contents retired by a change of shift, waiting to be archived.
|
||||
*
|
||||
* The live window is the only route into the archive - audio gets there by being pushed
|
||||
* out - so clearing the window used to mean its audio was never decoded at all. When the
|
||||
* shift changed more often than the window took to fill, that was every sample: modelled
|
||||
* at 18 WPM with a drift every 20 s, five minutes of listening archived nothing whatever.
|
||||
* Synchronised on itself: written from the capture path and drained from both there and
|
||||
* [flush], which run on different coroutines. Capped, because a signal drifting on every
|
||||
* detection scan would otherwise queue windows faster than they can be decoded and grow
|
||||
* without bound; past the cap the oldest goes, since newer audio is what is being read.
|
||||
*/
|
||||
private val retiredAudio = ArrayDeque<FloatArray>()
|
||||
|
||||
/** Windows held awaiting archival before the oldest is dropped. */
|
||||
private val retiredAudioLimit = 4
|
||||
|
||||
/** Held while inference runs so slow devices skip work instead of queuing it. */
|
||||
private val inferenceLock = Mutex()
|
||||
|
||||
/**
|
||||
* Serialises the archive path, which mutates state the capture coroutine also touches.
|
||||
*
|
||||
* [flush] runs on a different coroutine from [processBuffer] - on pause, and from the app
|
||||
* scope as the screen leaves - and both append to [committedText], which is a read, an
|
||||
* inference lasting hundreds of milliseconds, and only then a write. Interleaved, the
|
||||
* later write wins and a whole batch of text is lost, precisely at the moment the
|
||||
* operator stops listening and starts reading. They also both touch [archiveBuffer] and
|
||||
* [archiveSize]: a reset of the index under a snapshot that already covered those slots
|
||||
* makes the same audio decode twice.
|
||||
*
|
||||
* Not [inferenceLock]: that one is a tryLock, dropping work when contended, which is
|
||||
* right for the live window (another decode is 1.5 s away) and wrong here (dropping an
|
||||
* archive batch discards the audio for good).
|
||||
*/
|
||||
private val archiveLock = Mutex()
|
||||
|
||||
/** Decides what shift to apply from successive tone estimates. */
|
||||
private val shiftDecider = CwShiftDecider(SHIFT_HYSTERESIS_HZ)
|
||||
|
||||
@@ -172,6 +221,17 @@ class CwDeepDecoder(
|
||||
/** Carries Hilbert filter history and mixer phase across capture chunks. */
|
||||
private val streamingShifter = CwToneShifter.Streaming()
|
||||
|
||||
/**
|
||||
* Anti-alias filter for the decimation to [CwDeepSpectrogram.SAMPLE_RATE].
|
||||
*
|
||||
* Built on the first chunk because the capture rate is not known until then. Without
|
||||
* it everything above 1600 Hz folds into the window: a 3000 Hz tone reappeared at
|
||||
* 200 Hz at 119 times the spectral mean, and the whole 1600-22050 Hz band of hiss
|
||||
* folded down on top of the signal.
|
||||
*/
|
||||
private var antiAlias: CwAntiAlias.Streaming? = null
|
||||
private var antiAliasRate = 0
|
||||
|
||||
/**
|
||||
* Previous value of the setting, so a toggle can invalidate buffered audio.
|
||||
* Null until the first chunk: a decoder created while the setting is already on
|
||||
@@ -255,10 +315,35 @@ class CwDeepDecoder(
|
||||
if (samples.isEmpty()) return
|
||||
if (!ensureLoaded()) return
|
||||
|
||||
// Filter before decimating. resampleLinear interpolates without removing anything
|
||||
// above the new Nyquist, so this has to happen first or the fold is already baked in.
|
||||
if (antiAlias == null || antiAliasRate != sampleRate) {
|
||||
antiAlias = CwAntiAlias.Streaming(sampleRate, CwDeepSpectrogram.SAMPLE_RATE)
|
||||
antiAliasRate = sampleRate
|
||||
}
|
||||
val bandLimited = antiAlias?.process(samples) ?: samples
|
||||
// The filter holds back its group delay, so the first call returns nothing.
|
||||
if (bandLimited.isEmpty()) return
|
||||
|
||||
val resampled = CwDeepSpectrogram.resampleLinear(
|
||||
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
bandLimited, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
)
|
||||
val prepared = applyToneShift(resampled)
|
||||
|
||||
// Anything applyToneShift just retired from the window is older than what follows, so
|
||||
// it is archived before the new audio is buffered - otherwise the record comes out
|
||||
// with its text transposed. Archived whole rather than accumulated: it is already a
|
||||
// full window's worth, and holding it back would only expose it to the next retirement.
|
||||
val retired = drainRetiredAudio()
|
||||
for (batch in retired) {
|
||||
try {
|
||||
archiveDecode(batch)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
Log.e(TAG, "retired audio decode failed", t)
|
||||
}
|
||||
}
|
||||
|
||||
val shouldRedecode = buffer.append(prepared)
|
||||
|
||||
// Archive audio that scrolled out of the live window. It is decoded once
|
||||
@@ -385,12 +470,50 @@ class CwDeepDecoder(
|
||||
* cannot be un-shifted, so they are dropped rather than decoded against the new
|
||||
* shift. Text already committed to [historyText] stays: it was correct when decoded.
|
||||
*/
|
||||
/**
|
||||
* Drop audio that was shifted by a setting no longer in force - but keep what can be kept.
|
||||
*
|
||||
* The live window has to go: it holds samples moved by two different amounts, and one
|
||||
* spectrogram over both smears the tone. The pending archive batch does not. Those samples
|
||||
* already left the window, they were shifted consistently, and they are complete, so
|
||||
* discarding them threw away decodable audio for no reason. They are left in place here to
|
||||
* be archived by the normal path, which keeps this function non-suspending: its two
|
||||
* callers sit on the synchronous capture path, and making them suspend to run an inference
|
||||
* here would put a decode inside the tone-detection scan.
|
||||
*
|
||||
* It mattered because this runs on every change of shift, which tracks the detected tone,
|
||||
* which drifts across a pass. Modelled at 18 WPM, a drift every 20 s left the record
|
||||
* permanently empty however long the operator listened: the window was wiped before any
|
||||
* batch could complete, so nothing was ever committed. With the record still concatenating
|
||||
* the live decode at the time, each wipe visibly cut the transcript short as well - text
|
||||
* going backwards, then never accumulating at all.
|
||||
*/
|
||||
/** Take every retired window, oldest first, leaving the queue empty. */
|
||||
private fun drainRetiredAudio(): List<FloatArray> = synchronized(retiredAudio) {
|
||||
if (retiredAudio.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
retiredAudio.toList().also { retiredAudio.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropBufferedAudio() {
|
||||
// Retired, not discarded. The samples cannot stay in the window - mixing two shifts
|
||||
// in one spectrogram smears the tone - but they are internally consistent and
|
||||
// complete, so they decode fine on their own. Handed to the archive path rather than
|
||||
// decoded here, because both callers sit on the synchronous capture path.
|
||||
val retiring = buffer.snapshot()
|
||||
if (retiring.isNotEmpty()) {
|
||||
synchronized(retiredAudio) {
|
||||
while (retiredAudio.size >= retiredAudioLimit) {
|
||||
Log.w(TAG, "retired audio queue full, dropping the oldest window")
|
||||
retiredAudio.removeFirst()
|
||||
}
|
||||
retiredAudio.addLast(retiring)
|
||||
}
|
||||
}
|
||||
buffer.reset()
|
||||
archiveSize = 0
|
||||
// The provisional text describes audio being discarded, so it goes with it.
|
||||
// Committed text stays: it was correct for audio that really was archived.
|
||||
pendingText = ""
|
||||
_historyText.value = committedText
|
||||
}
|
||||
|
||||
@@ -475,40 +598,63 @@ class CwDeepDecoder(
|
||||
updateSignalMetrics(spectrogram)
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive whatever audio is still in the pipeline, so stopping does not discard it.
|
||||
*
|
||||
* Two places hold audio that would otherwise never be decoded into the record: the
|
||||
* batch accumulating towards [ARCHIVE_THRESHOLD], and the live window itself, whose
|
||||
* contents only ever reach the archive by being pushed out by newer audio. Together
|
||||
* that is the last [CwDeepBuffer.DEFAULT_MAX_SECONDS] + [ARCHIVE_SECONDS] of a session
|
||||
* - which includes the end of every transmission, the part with the call sign in it.
|
||||
*
|
||||
* Order matters: the pending batch left the window before anything still in it, so it
|
||||
* has to be archived first or the record comes out with its text transposed.
|
||||
*/
|
||||
override suspend fun flush() = archiveLock.withLock {
|
||||
// Oldest first, all the way down: retired window contents, then the batch accumulating
|
||||
// towards the threshold, then what is still live.
|
||||
for (batch in drainRetiredAudio()) {
|
||||
runCatching { archiveDecodeLocked(batch) }
|
||||
.onFailure { if (it is CancellationException) throw it }
|
||||
}
|
||||
if (archiveSize > 0) {
|
||||
val pending = archiveBuffer.copyOf(archiveSize)
|
||||
archiveSize = 0
|
||||
runCatching { archiveDecodeLocked(pending) }
|
||||
.onFailure { if (it is CancellationException) throw it }
|
||||
}
|
||||
// Draining the window empties it, so a second flush cannot double-archive the tail.
|
||||
val window = buffer.snapshot()
|
||||
if (window.isNotEmpty()) {
|
||||
buffer.reset()
|
||||
runCatching { archiveDecodeLocked(window) }
|
||||
.onFailure { if (it is CancellationException) throw it }
|
||||
}
|
||||
// The live line described audio that is now in the record; leaving it would show the
|
||||
// same characters twice, in two places, one of them stale.
|
||||
_decodedText.value = ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a chunk of audio that has scrolled out of the live window and
|
||||
* append it to [historyText]. Unlike the live window this never replaces —
|
||||
* the archived audio is final, so its text is permanent.
|
||||
*/
|
||||
private suspend fun archiveDecode(audio: FloatArray) = withContext(Dispatchers.Default) {
|
||||
private suspend fun archiveDecode(audio: FloatArray) = archiveLock.withLock {
|
||||
archiveDecodeLocked(audio)
|
||||
}
|
||||
|
||||
/** [archiveDecode] without the lock, for callers already holding [archiveLock]. */
|
||||
private suspend fun archiveDecodeLocked(audio: FloatArray) = withContext(Dispatchers.Default) {
|
||||
val activeSession = session ?: return@withContext
|
||||
val activeEnvironment = environment ?: return@withContext
|
||||
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
|
||||
val spectrogram = CwDeepSpectrogram.compute(audio)
|
||||
val text = runInference(activeSession, activeEnvironment, spectrogram)
|
||||
// This batch is final, so its provisional decode is superseded rather than kept.
|
||||
committedText += text
|
||||
pendingText = ""
|
||||
_historyText.value = committedText
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the audio waiting to be archived, so it stays on screen until it is.
|
||||
*
|
||||
* Provisional: the batch is still growing, and the final decode sees all of it at once
|
||||
* with more context. Runs on the redecode cycle rather than per capture chunk -
|
||||
* decoding every 100 ms chunk separately measured 14x the inference load, over 250% of
|
||||
* one core, and a chunk that short carries under two dot-lengths of context anyway.
|
||||
*/
|
||||
private suspend fun decodePending(audio: FloatArray) = withContext(Dispatchers.Default) {
|
||||
val activeSession = session ?: return@withContext
|
||||
val activeEnvironment = environment ?: return@withContext
|
||||
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
|
||||
val spectrogram = CwDeepSpectrogram.compute(audio)
|
||||
pendingText = runInference(activeSession, activeEnvironment, spectrogram)
|
||||
_historyText.value = committedText + pendingText
|
||||
}
|
||||
|
||||
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
|
||||
private fun runInference(
|
||||
activeSession: OrtSession,
|
||||
@@ -583,12 +729,19 @@ class CwDeepDecoder(
|
||||
_signalStrength.value = if (decodable) prominence else 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear everything. Not serialised against [archiveLock]: an archive decode already in
|
||||
* flight can land its batch after this returns, leaving a few characters behind. The
|
||||
* operator asked to clear and can ask again; making this suspend to close that window
|
||||
* would push it onto every caller, including a synchronous button handler.
|
||||
*/
|
||||
override fun reset() {
|
||||
antiAlias?.reset()
|
||||
buffer.reset()
|
||||
synchronized(retiredAudio) { retiredAudio.clear() }
|
||||
_decodedText.value = ""
|
||||
_historyText.value = ""
|
||||
committedText = ""
|
||||
pendingText = ""
|
||||
archiveSize = 0
|
||||
_estimatedPitch.value = null
|
||||
_detectedToneHz.value = null
|
||||
|
||||
@@ -69,6 +69,8 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import com.rtbishop.look4sat.core.data.qrz.QrzGridLookup
|
||||
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
|
||||
import okhttp3.OkHttpClient
|
||||
import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo
|
||||
|
||||
@@ -134,6 +136,22 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
override fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo = lotwRepo
|
||||
|
||||
/**
|
||||
* QRZ grid lookup. Holds the cookie read so no composable has to: the log screen used to pull
|
||||
* it out of SharedPreferences through LocalContext, putting disk access inside composition.
|
||||
*/
|
||||
private val qrzGridLookup: QrzGridLookup by lazy {
|
||||
QrzGridLookup(
|
||||
context.getSharedPreferences(QrzGridLookup.PREFS_NAME, Context.MODE_PRIVATE),
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
override fun provideQrzGridLookup(): IQrzGridLookup = qrzGridLookup
|
||||
|
||||
override fun provideBluetoothReporter(): IReporter {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val rc = settingsRepo.rcSettings.value
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.qrz
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
|
||||
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Grid lookup backed by the cookie the operator pasted into settings.
|
||||
*
|
||||
* Owns the cookie read so the log screen does not: a composable used to pull it out of
|
||||
* SharedPreferences through LocalContext on every submission, which put disk access inside
|
||||
* composition and went around the repository layer.
|
||||
*/
|
||||
class QrzGridLookup(
|
||||
private val preferences: SharedPreferences,
|
||||
httpClient: OkHttpClient,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
) : IQrzGridLookup {
|
||||
|
||||
private val source = QrzGridSource(httpClient, dispatcher)
|
||||
|
||||
override suspend fun lookup(callsign: String): QrzGrid {
|
||||
val cookie = preferences.getString(COOKIE_KEY, "").orEmpty()
|
||||
// No cookie and an expired one call for the same thing from the operator, so they report
|
||||
// the same way rather than adding a fourth outcome nobody could act on differently.
|
||||
if (cookie.isBlank()) return QrzGrid.SignedOut
|
||||
return source.lookupGrid(callsign, cookie)
|
||||
}
|
||||
|
||||
override suspend fun signedInAs(): String? {
|
||||
val cookie = preferences.getString(COOKIE_KEY, "").orEmpty()
|
||||
if (cookie.isBlank()) return null
|
||||
return source.lookupOwnCallsign(cookie)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Where the settings screen stores what the operator pasted. */
|
||||
const val PREFS_NAME = "qrz_cookie"
|
||||
const val COOKIE_KEY = "cookie"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.qrz
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
|
||||
import com.rtbishop.look4sat.core.domain.qrz.QrzGridParser
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* Reads a station's Maidenhead locator off its QRZ.com page.
|
||||
*
|
||||
* QRZ has no free lookup API for this, so the page is fetched with the operator's own session
|
||||
* cookie and parsed. The cookie is pasted by the operator in settings and never built into the
|
||||
* app. Parsing lives in [QrzGridParser] so it can be tested without a network; this class only
|
||||
* fetches and retries.
|
||||
*/
|
||||
class QrzGridSource(
|
||||
private val httpClient: OkHttpClient,
|
||||
private val dispatcher: CoroutineDispatcher
|
||||
) {
|
||||
|
||||
/**
|
||||
* Look up [callsign]'s locator.
|
||||
*
|
||||
* Retried because this runs on a phone, mid-pass, often on mobile data - a single timeout
|
||||
* used to mean the QSO was logged without a grid and the operator was never told. Retries
|
||||
* are bounded and backed off so a genuinely unreachable QRZ costs at most a few seconds:
|
||||
* only transport failures are retried, since a page that loaded and parsed will not parse
|
||||
* differently on a second attempt.
|
||||
*/
|
||||
suspend fun lookupGrid(callsign: String, cookieHeader: String): QrzGrid =
|
||||
withContext(dispatcher) {
|
||||
if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext QrzGrid.SignedOut
|
||||
val url = "$DB_URL${callsign.trim().uppercase()}"
|
||||
fetchWithRetry(url, cookieHeader)?.let(QrzGridParser::parseGrid)
|
||||
?: QrzGrid.Unreachable(MAX_ATTEMPTS)
|
||||
}
|
||||
|
||||
/**
|
||||
* The callsign the pasted cookie is signed in as, so settings can show the operator whose
|
||||
* account it belongs to rather than just claiming success.
|
||||
*/
|
||||
suspend fun lookupOwnCallsign(cookieHeader: String): String? = withContext(dispatcher) {
|
||||
if (cookieHeader.isBlank()) return@withContext null
|
||||
fetchWithRetry(DB_URL, cookieHeader)?.let(QrzGridParser::parseOwnCallsign)
|
||||
}
|
||||
|
||||
/** Fetch [url], retrying transport failures with backoff. Null when every attempt failed. */
|
||||
/**
|
||||
* Normalise whatever the operator pasted into a Cookie header value.
|
||||
*
|
||||
* They paste either a raw `k=v; k=v` header or the JSON array a cookie-export extension
|
||||
* produces. The old client normalised this and the rewrite dropped it, so a JSON export that
|
||||
* used to work went out as a literal JSON blob, QRZ served its signed-out page, and the app
|
||||
* told the operator their cookie had expired when it was perfectly good.
|
||||
*/
|
||||
private fun normalise(raw: String): String = QrzGridParser.cookieHeader(raw)
|
||||
|
||||
private suspend fun fetchWithRetry(url: String, rawCookie: String): String? {
|
||||
val cookieHeader = normalise(rawCookie)
|
||||
if (cookieHeader.isBlank()) return null
|
||||
repeat(MAX_ATTEMPTS) { attempt ->
|
||||
try {
|
||||
val request = Request.Builder().url(url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Cookie", cookieHeader)
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) return response.body.string()
|
||||
// A 4xx will repeat identically, so only server-side faults are worth retrying.
|
||||
if (response.code < 500) return null
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
println("QrzGridSource attempt ${attempt + 1} failed: $exception")
|
||||
}
|
||||
if (attempt < MAX_ATTEMPTS - 1) delay(BACKOFF_MS[attempt])
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Bare form is the signed-in home page; a callsign appended is that station's page. */
|
||||
const val DB_URL = "https://www.qrz.com/db/"
|
||||
const val USER_AGENT = "Mozilla/5.0 (Linux; Android 13) Look4Sat"
|
||||
const val MAX_ATTEMPTS = 3
|
||||
|
||||
/** Waits before the second and third attempt. Short enough to finish inside a pass. */
|
||||
val BACKOFF_MS = longArrayOf(700L, 2_000L)
|
||||
}
|
||||
}
|
||||
+46
-15
@@ -42,6 +42,17 @@ class DatabaseRepo(
|
||||
|
||||
private val customSourceType = "Other"
|
||||
|
||||
/**
|
||||
* Type key for satellites fetched from a custom URL.
|
||||
*
|
||||
* Separate from customSourceType because setSatelliteTypeIds overwrites rather than merges, so
|
||||
* sharing "Other" with manual file import meant each wiped the other's type index. The
|
||||
* satellites stayed in the database and stayed selectable either way - only their grouping in
|
||||
* the type filter was lost - but the two sources are different things and deserve different
|
||||
* keys.
|
||||
*/
|
||||
private val customUrlType = "Custom"
|
||||
|
||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
@@ -67,26 +78,46 @@ class DatabaseRepo(
|
||||
|
||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||
val tleUrls = buildMap {
|
||||
putAll(Sources.satelliteDataUrls)
|
||||
// Switch on + non-empty URL -> All uses the custom URL; otherwise the default URL (online-update default source)
|
||||
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
|
||||
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
val radioUrls = buildMap {
|
||||
putAll(Sources.transceiversDataUrls)
|
||||
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
|
||||
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
// A custom URL REPLACES the built-in sources rather than joining them. The previous map
|
||||
// overwrote only the "All" value and still fetched the other 26, so switching this on meant
|
||||
// "my source AND yours" - which defeats the reasons for setting one: a mirror, a filtered
|
||||
// subset, an offline server, or a network where Celestrak is unreachable. On a blocked link
|
||||
// the real behaviour was 26 failing requests.
|
||||
//
|
||||
// Satellites already stored do not disappear: insertEntries is OnConflictStrategy.REPLACE
|
||||
// and nothing is deleted before the insert, so rows the new source does not mention survive.
|
||||
// The type index for the skipped keys goes stale rather than empty, which is the honest
|
||||
// outcome - it is the last known membership, not a claim about this fetch.
|
||||
//
|
||||
// The key is customUrlType, not "All": setSatelliteTypeIds early-returns on "All", so
|
||||
// indexing under it was always a no-op and satellites from a custom URL were never
|
||||
// reachable by the type filter at all. They now are.
|
||||
val tleUrls = if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank()) {
|
||||
mapOf(customUrlType to dataSourcesSettings.tleUrl)
|
||||
} else {
|
||||
Sources.satelliteDataUrls.filterValues { it.isNotBlank() }
|
||||
}
|
||||
val radioUrls = if (dataSourcesSettings.useCustomTransceivers &&
|
||||
dataSourcesSettings.transceiversUrl.isNotBlank()
|
||||
) {
|
||||
mapOf("SatNOGS" to dataSourcesSettings.transceiversUrl)
|
||||
} else {
|
||||
Sources.transceiversDataUrls.filterValues { it.isNotBlank() }
|
||||
}
|
||||
// launch all network requests concurrently
|
||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
// Count successful sources: zero successes = update failed (timestamp untouched, exception surfaced in the UI)
|
||||
val tleResults = tleJobs.awaitAll()
|
||||
val radioResults = radioJobs.awaitAll()
|
||||
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
|
||||
if (successCount == 0) {
|
||||
throw java.io.IOException("All data sources failed to download")
|
||||
// Orbital elements are counted on their own. A combined count let a successful transceivers
|
||||
// fetch stand in for a failed orbital one: with a custom TLE URL there are two requests
|
||||
// rather than 28, so if that URL was down and SatNOGS answered, the total was 1, no
|
||||
// exception was raised, and setUpdateSuccessful stamped a fresh timestamp for an update
|
||||
// that refreshed no orbital data at all - which also suppressed the 48-hour auto-update
|
||||
// retry that keys off that timestamp. The failure existed before but 26 other sources hid
|
||||
// it; replacing them made it easy to hit.
|
||||
if (tleResults.none { it.second != null }) {
|
||||
throw java.io.IOException("No orbital data source could be downloaded")
|
||||
}
|
||||
// parse fetched data concurrently and associate with types
|
||||
val importedEntries = tleResults.flatMap { (url, stream) ->
|
||||
|
||||
+36
-10
@@ -455,18 +455,44 @@ class SettingsRepo(
|
||||
_dataSourcesSettings.value = settings
|
||||
}
|
||||
|
||||
/** Placeholders a 4.4.7-era build could persist. Neither is a reachable address. */
|
||||
private val placeholderTleUrl = "https://example.com/tle.txt"
|
||||
private val placeholderRadioUrl = "https://example.com/radio.json"
|
||||
private val keyPlaceholderUrlsMigrated = "placeholderUrlsMigrated"
|
||||
|
||||
/**
|
||||
* Replace the example.com placeholders an old build could store.
|
||||
*
|
||||
* Runs once, following the pattern of migrateRCFormats. This used to be a rewrite applied on
|
||||
* every read, so the stored value and the returned value disagreed indefinitely and nothing
|
||||
* ever settled the difference.
|
||||
*/
|
||||
private fun migratePlaceholderUrls() {
|
||||
if (preferences.getBoolean(keyPlaceholderUrlsMigrated, false)) return
|
||||
preferences.edit {
|
||||
if (preferences.getString(keyTleUrl, null) == placeholderTleUrl) {
|
||||
putString(keyTleUrl, Sources.defaultTleUrl)
|
||||
putBoolean(keyUseCustomTle, false)
|
||||
}
|
||||
if (preferences.getString(keyTransceiversUrl, null) == placeholderRadioUrl) {
|
||||
putString(keyTransceiversUrl, Sources.defaultTransceiversUrl)
|
||||
putBoolean(keyUseCustomTransceivers, false)
|
||||
}
|
||||
putBoolean(keyPlaceholderUrlsMigrated, true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||
// 4.4.8 fix: legacy example.com placeholder URLs count as unconfigured -> replaced with the real default URL and the switch forced off,
|
||||
// otherwise the online All/SatNOGS sources would point at the wrong address and fail to update
|
||||
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
|
||||
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
|
||||
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
|
||||
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
|
||||
migratePlaceholderUrls()
|
||||
// The switch is reported as the operator set it. It used to be ANDed with
|
||||
// `url != default`, so typing the default URL by hand switched custom sources off by
|
||||
// itself and the settings screen showed a state nobody had chosen.
|
||||
return DataSourcesSettings(
|
||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
|
||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
|
||||
tleUrl = tleUrl,
|
||||
transceiversUrl = txUrl
|
||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
||||
tleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl,
|
||||
transceiversUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl)
|
||||
?: Sources.defaultTransceiversUrl
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,4 +29,8 @@ class ShowToast(private val context: Context) : IShowToast {
|
||||
override fun invoke(resId: Int) {
|
||||
invoke(context.getString(resId))
|
||||
}
|
||||
|
||||
override fun invoke(resId: Int, vararg formatArgs: Any) {
|
||||
invoke(context.getString(resId, *formatArgs))
|
||||
}
|
||||
}
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
package com.rtbishop.look4sat.core.data.aprs
|
||||
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.PrintWriter
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.thread
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Socket-level tests against a stand-in APRS-IS server.
|
||||
*
|
||||
* These exist because the pure-logic tests could not catch the failures that matter here. A draft
|
||||
* of [AprsIsClient.sendPacket] once wrote the packet with no line terminator at all: every send
|
||||
* reported success, the server received a single unterminated stream, and nothing anywhere went
|
||||
* red. APRS-IS is a line protocol and does not acknowledge position reports, so silence is the
|
||||
* normal case - which means only a test that reads the bytes off a real socket can tell a
|
||||
* delivered packet from a lost one.
|
||||
*/
|
||||
class AprsIsClientSocketTest {
|
||||
|
||||
/**
|
||||
* A minimal APRS-IS server. Greets, answers the login as instructed, then records whatever
|
||||
* lines arrive without acknowledging them, which is what the real network does.
|
||||
*/
|
||||
private class FakeServer(
|
||||
private val greeting: String? = "# aprsc 2.1.19-g730c5c0",
|
||||
private val loginResponse: String? = "# logresp TEST verified, server FAKE",
|
||||
private val chatter: List<String> = emptyList(),
|
||||
/** Sent right after the first packet arrives, to exercise the ack read. */
|
||||
private val afterPacket: String? = null
|
||||
) : AutoCloseable {
|
||||
private val server = ServerSocket(0)
|
||||
private val ready = CountDownLatch(1)
|
||||
|
||||
/**
|
||||
* The accepted connection. Held because closing the ServerSocket only stops it listening
|
||||
* - an established connection survives, so a test that wants a dead peer has to close
|
||||
* this one. Getting that wrong made a correct implementation look broken.
|
||||
*/
|
||||
@Volatile
|
||||
private var peer: Socket? = null
|
||||
|
||||
val port: Int get() = server.localPort
|
||||
|
||||
/** Every complete line the client sent after logging in. */
|
||||
val received = mutableListOf<String>()
|
||||
|
||||
/** Raw bytes of the client's traffic, so a missing terminator is visible. */
|
||||
val rawAfterLogin = StringBuilder()
|
||||
|
||||
@Volatile
|
||||
var loginLine: String? = null
|
||||
|
||||
fun start() {
|
||||
thread(isDaemon = true) {
|
||||
runCatching {
|
||||
server.accept().use { client ->
|
||||
peer = client
|
||||
val out = PrintWriter(client.getOutputStream(), true)
|
||||
val input = BufferedReader(InputStreamReader(client.getInputStream()))
|
||||
greeting?.let { out.print(it + "\r\n"); out.flush() }
|
||||
loginLine = input.readLine()
|
||||
chatter.forEach { out.print(it + "\r\n"); out.flush() }
|
||||
loginResponse?.let { out.print(it + "\r\n"); out.flush() }
|
||||
ready.countDown()
|
||||
// Read lines but never acknowledge, exactly as APRS-IS treats positions.
|
||||
var firstPacket = true
|
||||
while (true) {
|
||||
val line = input.readLine() ?: break
|
||||
synchronized(received) {
|
||||
received += line
|
||||
rawAfterLogin.append(line)
|
||||
}
|
||||
if (firstPacket) {
|
||||
firstPacket = false
|
||||
afterPacket?.let { out.print(it + "\r\n"); out.flush() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ready.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
fun awaitLogin(): Boolean = ready.await(5, TimeUnit.SECONDS)
|
||||
|
||||
fun lines(): List<String> = synchronized(received) { received.toList() }
|
||||
|
||||
/** Close the established connection, so the client is talking to a dead peer. */
|
||||
fun dropClient() {
|
||||
runCatching { peer?.close() }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { server.close() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun client(port: Int, passcode: Int = 12345) = AprsIsClient(
|
||||
host = "127.0.0.1",
|
||||
port = port,
|
||||
callsign = "TEST",
|
||||
ssid = "",
|
||||
passcode = passcode,
|
||||
softwareName = "Look4Sat",
|
||||
version = "test"
|
||||
)
|
||||
|
||||
/**
|
||||
* The regression that motivated this file. Two packets must arrive as two lines; without a
|
||||
* terminator they concatenate into one stream the server can never parse, while both sends
|
||||
* report success.
|
||||
*/
|
||||
@Test
|
||||
fun `each packet arrives as its own line`() {
|
||||
FakeServer().use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
val first = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>one")
|
||||
val second = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>two")
|
||||
Thread.sleep(300)
|
||||
c.disconnect()
|
||||
|
||||
assertEquals(true, first?.first)
|
||||
assertEquals(true, second?.first)
|
||||
val lines = server.lines()
|
||||
assertEquals("both packets must reach the server as separate lines", 2, lines.size)
|
||||
assertTrue(lines[0].endsWith(">one"))
|
||||
assertTrue(lines[1].endsWith(">two"))
|
||||
}
|
||||
}
|
||||
|
||||
/** The login line has to be terminated too, or the server never reads it. */
|
||||
@Test
|
||||
fun `the server receives a complete login line`() {
|
||||
FakeServer().use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
c.disconnect()
|
||||
assertEquals("user TEST pass 12345 vers Look4Sat test", server.loginLine)
|
||||
}
|
||||
}
|
||||
|
||||
/** A verified login is recognised and lets reports count as delivered. */
|
||||
@Test
|
||||
fun `a verified login is not reported as refused`() {
|
||||
FakeServer().use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
assertTrue(c.isVerified)
|
||||
assertFalse(c.isRefusedByServer)
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The case the rewrite exists for: the server accepts the connection, the write succeeds,
|
||||
* and every packet is discarded. The client must say so rather than report success.
|
||||
*/
|
||||
@Test
|
||||
fun `an unverified login is flagged while the connection stays up`() {
|
||||
FakeServer(loginResponse = "# logresp TEST unverified, server FAKE").use { server ->
|
||||
server.start()
|
||||
val c = client(server.port, passcode = -1)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
assertFalse("unverified must not read as verified", c.isVerified)
|
||||
assertTrue("the server explicitly refused", c.isRefusedByServer)
|
||||
// Not a connection error: a receive-only login is legitimate and stays connected.
|
||||
assertTrue(c.isConnected)
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A chatty server used to exhaust a fixed line budget, turning an accepted login into
|
||||
* Unknown and telling the operator their passcode was wrong when it had been accepted.
|
||||
*/
|
||||
@Test
|
||||
fun `keepalive chatter before the verdict does not hide it`() {
|
||||
// The real keepalive repeats the server identification with a timestamp, captured from
|
||||
// euro.aprs2.net. A made-up "# keepalive N" would now read as a refusal, correctly - only
|
||||
// greetings and verdicts are treated as harmless.
|
||||
val chatter = List(8) { "# aprsc 2.1.21-gbfc2090 25 Aug 2026 16:41:0$it GMT T2UK 1.2.3.4:14580" }
|
||||
FakeServer(chatter = chatter).use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
assertTrue("the verdict must be found past the comments", c.isVerified)
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A server that sends no greeting is legitimate, and must not cost the full login window on
|
||||
* every connect - that was eight seconds per attempt.
|
||||
*/
|
||||
@Test
|
||||
fun `a server without a greeting connects promptly`() {
|
||||
FakeServer(greeting = null).use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
val started = System.currentTimeMillis()
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
val elapsed = System.currentTimeMillis() - started
|
||||
c.disconnect()
|
||||
assertTrue("connect took ${elapsed}ms, expected well under the login window",
|
||||
elapsed < 6_000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure a live server actually produced, and the one that mattered most.
|
||||
*
|
||||
* aprsc answers `# Invalid login: ...` and closes. That is a comment but not a logresp, so it
|
||||
* was skipped as chatter, the login timed out into Unknown - treated as "may be working" - and
|
||||
* every send afterwards reported success. Measured against euro.aprs2.net before the fix:
|
||||
* loginOutcome=Unknown, isRefusedByServer=false, sendPacket=(true, "sent").
|
||||
*/
|
||||
@Test
|
||||
fun `a refused login is not reported as a successful send`() {
|
||||
FakeServer(loginResponse = "# Invalid login: bad software version").use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
// An outright refusal throws from connect(), which is the correct outcome.
|
||||
val threw = runCatching { c.connect() }.exceptionOrNull()
|
||||
assertTrue(server.awaitLogin())
|
||||
assertFalse("a refused login must not read as verified", c.isVerified)
|
||||
val sentOk = runCatching {
|
||||
c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")?.first
|
||||
}.getOrNull()
|
||||
assertTrue(
|
||||
"the refusal must surface: threw=$threw sentOk=$sentOk",
|
||||
threw != null || sentOk != true
|
||||
)
|
||||
c.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A server saying it is about to drop us must not read as a successful send.
|
||||
*
|
||||
* The ack read used to treat any leading `#` as harmless chatter, so `# Port full` - which
|
||||
* means the server is closing the connection - was reported as sent. It now shares the login
|
||||
* parser's judgement, so only a greeting or keepalive counts as harmless.
|
||||
*/
|
||||
@Test
|
||||
fun `a server refusal after the write is not reported as sent`() {
|
||||
FakeServer(afterPacket = "# Port full").use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
val result = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")
|
||||
c.disconnect()
|
||||
assertEquals("a server refusal must fail the report", false, result?.first)
|
||||
}
|
||||
}
|
||||
|
||||
/** The real keepalive must still count as sent, since APRS-IS never acknowledges a position. */
|
||||
@Test
|
||||
fun `a keepalive after the write still counts as sent`() {
|
||||
val keepalive = "# aprsc 2.1.21-gbfc2090 25 Aug 2026 16:41:07 GMT T2UK 1.2.3.4:14580"
|
||||
FakeServer(afterPacket = keepalive).use { server ->
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
val result = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>x")
|
||||
c.disconnect()
|
||||
assertEquals("a keepalive must not fail the report", true, result?.first)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sending after the server has gone must report failure, not success. */
|
||||
@Test
|
||||
fun `a send after the server closes is reported as failed`() {
|
||||
val server = FakeServer()
|
||||
server.start()
|
||||
val c = client(server.port)
|
||||
c.connect()
|
||||
assertTrue(server.awaitLogin())
|
||||
// Closing the ServerSocket alone would leave this connection alive.
|
||||
server.dropClient()
|
||||
Thread.sleep(200)
|
||||
// The first write may still land in the socket buffer; by the second the loss is certain.
|
||||
c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>one")
|
||||
val second = c.sendPacket("TEST>APRS,TCPIP*:=0000.00N/00000.00E>two")
|
||||
c.disconnect()
|
||||
assertEquals("a send on a dead connection must not report success", false, second?.first)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.cw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* How long audio waits before its text can reach the record pane.
|
||||
*
|
||||
* The record binds to archived text only. The live decode is rewritten from scratch every
|
||||
* cycle, so a pane that concatenated it lost characters the operator had already read - the
|
||||
* decoder appeared to delete its own output while still running. Binding to archived text
|
||||
* makes the pane monotonic, and the cost is latency, which is additive: audio must first be
|
||||
* pushed out of the live window, then accumulate into a full archive batch.
|
||||
*
|
||||
* [CwDeepDecoder] needs a Context and a loaded ONNX model, so it cannot be constructed here.
|
||||
* These tests read its real constants rather than copies, so retuning one without
|
||||
* reconsidering the user-visible delay fails here.
|
||||
*/
|
||||
class CwArchiveTimingTest {
|
||||
|
||||
/** Sending speed for the character counts, typical for satellite CW. */
|
||||
private val wpm = 18.0
|
||||
|
||||
/** PARIS standard: one word is five characters. */
|
||||
private val charsPerSecond = wpm * 5 / 60.0
|
||||
|
||||
private val window = CwDeepBuffer.DEFAULT_MAX_SECONDS
|
||||
private val batch = CwDeepDecoder.ARCHIVE_SECONDS
|
||||
|
||||
/** Seconds of audio that have reached the archive after listening for [elapsed]. */
|
||||
private fun archivedSeconds(elapsed: Double): Double {
|
||||
val evicted = elapsed - window
|
||||
if (evicted <= 0.0) return 0.0
|
||||
return floor(evicted / batch) * batch
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thresholdMatchesTheDeclaredBatchLength() {
|
||||
assertEquals(
|
||||
"threshold must be the batch length in samples",
|
||||
(CwDeepSpectrogram.SAMPLE_RATE * batch).toInt(),
|
||||
CwDeepDecoder.ARCHIVE_THRESHOLD
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveBatchIsShorterThanACallSign() {
|
||||
// A seven-character call sign at 18 WPM takes about 4.7 s. A batch longer than that
|
||||
// means the record can stall for longer than the single most important thing being
|
||||
// sent, which is what made the stall read as deletion.
|
||||
val callSignSeconds = 7 / charsPerSecond
|
||||
assertTrue(
|
||||
"batch $batch s must not exceed a call sign at $wpm WPM " +
|
||||
"(${"%.1f".format(callSignSeconds)} s)",
|
||||
batch <= callSignSeconds
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstTextReachesTheRecordWithinHalfAMinute() {
|
||||
// At the previous 15 s batch this was 35 s, so a short exchange ended with the record
|
||||
// still completely empty: every decoded character had only ever been in the live line,
|
||||
// which shows 64 characters and overwrites them.
|
||||
val firstArchive = window + batch
|
||||
assertTrue("first archived text must appear within 30 s, got $firstArchive s", firstArchive <= 30.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aThirtySecondSessionStillProducesARecord() {
|
||||
val archived = archivedSeconds(30.0)
|
||||
assertTrue("30 s of listening must archive something, got $archived s", archived > 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theRecordNeverStallsForLongerThanOneBatch() {
|
||||
var longestStall = 0.0
|
||||
var lastGrowthAt = window
|
||||
var previous = 0.0
|
||||
var t = window
|
||||
while (t <= 600.0) {
|
||||
val archived = archivedSeconds(t)
|
||||
if (archived > previous) {
|
||||
longestStall = maxOf(longestStall, t - lastGrowthAt)
|
||||
lastGrowthAt = t
|
||||
previous = archived
|
||||
}
|
||||
t += 0.1
|
||||
}
|
||||
assertTrue(
|
||||
"record stalled ${"%.1f".format(longestStall)} s, one batch is $batch s",
|
||||
longestStall <= batch + 0.11
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioInFlightWhenCaptureStopsWouldLoseTheEndOfTheTransmission() {
|
||||
// Why flush() exists. Neither holding place drains on its own: the live window only
|
||||
// reaches the archive by being pushed out by newer audio, and the pending batch only
|
||||
// by filling up. Both hold the end of the transmission, where the call sign is.
|
||||
val worstCase = window + batch
|
||||
val lostCharacters = worstCase * charsPerSecond
|
||||
assertTrue(
|
||||
"flush() must exist: ${"%.0f".format(lostCharacters)} characters would be lost",
|
||||
lostCharacters > 20
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archivingStaysRarerThanTheLiveDecode() {
|
||||
// Each batch is one inference. Shortening the batch trades CPU for latency, so it must
|
||||
// stay rarer than the live redecode or the archive path becomes the dominant cost.
|
||||
val liveIntervalSeconds = CwDeepBuffer.DEFAULT_REDECODE_INTERVAL_MS / 1000.0
|
||||
assertTrue(
|
||||
"batch $batch s must stay longer than the live cycle $liveIntervalSeconds s",
|
||||
batch > liveIntervalSeconds
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBatchIsLongEnoughToDecode() {
|
||||
// compute() rejects audio shorter than one FFT frame, so a batch below that would be
|
||||
// silently dropped by archiveDecode's size guard and its text lost outright.
|
||||
assertTrue(
|
||||
"batch of ${CwDeepDecoder.ARCHIVE_THRESHOLD} samples must exceed " +
|
||||
"FFT_LENGTH ${CwDeepSpectrogram.FFT_LENGTH}",
|
||||
CwDeepDecoder.ARCHIVE_THRESHOLD > CwDeepSpectrogram.FFT_LENGTH
|
||||
)
|
||||
}
|
||||
}
|
||||
+98
-3
@@ -18,6 +18,7 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
@@ -102,8 +103,96 @@ class DatabaseRepoTest {
|
||||
repository.updateFromRemote()
|
||||
|
||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||
// New semantics: switch on + non-empty URL -> the All source uses the custom URL, data lands in the All type
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
||||
// A custom URL replaces the built-in TLE sources: none of them is requested. The
|
||||
// transceivers group is separate and its own switch is off here, so it still fetches.
|
||||
val builtInTle = Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
|
||||
assertTrue(
|
||||
"no built-in TLE source may be fetched, got " + remoteSource.requestedUrls,
|
||||
builtInTle.none { it in remoteSource.requestedUrls }
|
||||
)
|
||||
assertTrue(
|
||||
"the operator's URL must be fetched",
|
||||
customCsvUrl in remoteSource.requestedUrls
|
||||
)
|
||||
// Indexed under "Custom". It used to go under "All", where setSatelliteTypeIds
|
||||
// early-returns, so the type filter never saw these satellites and the old assertion here
|
||||
// was checking a no-op.
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Custom"])
|
||||
assertEquals(null, settingsRepo.satelliteTypeIdsByType["All"])
|
||||
// NOT "Other": that key belongs to manual file import, and setSatelliteTypeIds overwrites
|
||||
// rather than merges, so sharing it would have each source wipe the other's index.
|
||||
assertEquals(null, settingsRepo.satelliteTypeIdsByType["Other"])
|
||||
}
|
||||
|
||||
/** 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 = """
|
||||
@@ -122,9 +211,15 @@ private class FakeRemoteSource : IRemoteSource {
|
||||
val fileStreams: 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 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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/** Login line: user CALL-SSID pass XXXX vers XXXX */
|
||||
fun formatLogin(callsign: String, ssid: String, passcode: Int, version: String): String {
|
||||
val callSsid = formatCallSsid(callsign, ssid)
|
||||
return "user $callSsid pass $passcode vers $version"
|
||||
}
|
||||
|
||||
/** Callsign-SSID join (BG7NTA + 5 -> BG7NTA-5) */
|
||||
fun formatCallSsid(callsign: String, ssid: String): String {
|
||||
if (ssid.isNullOrEmpty()) return callsign
|
||||
|
||||
@@ -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>
|
||||
|
||||
/**
|
||||
* Permanent transcript of everything that has scrolled out of the live
|
||||
* window. Unlike [decodedText] this only ever grows (until [reset]); it is
|
||||
* what the user reads back after a signal has passed.
|
||||
* Transcript of audio that has been archived, and will not be revised.
|
||||
*
|
||||
* Only ever grows until [reset]. [decodedText] is rewritten from scratch on every
|
||||
* redecode, so a pane that concatenates it loses text the operator has already read -
|
||||
* which a paper log does not do. This is the flow such a pane must bind to.
|
||||
*/
|
||||
val historyText: StateFlow<String>
|
||||
|
||||
@@ -74,6 +76,17 @@ interface ICwDecoder {
|
||||
/** Non-null when the decoder cannot run, for example the model failed to load. */
|
||||
val errorMessage: StateFlow<String?>
|
||||
|
||||
/**
|
||||
* Decode whatever audio is still held in the pipeline into [historyText].
|
||||
*
|
||||
* Nothing reaches [historyText] until audio has been pushed out of the live window and
|
||||
* then accumulated into a full archive batch, so the last stretch of a session is always
|
||||
* still in flight when capture stops - and neither holding place drains on its own. That
|
||||
* stretch is the end of the transmission, the part with the call sign in it. Call on
|
||||
* pause and before [close].
|
||||
*/
|
||||
suspend fun flush()
|
||||
|
||||
/** Feed captured mono PCM in -1..1. Safe to call from a capture thread. */
|
||||
suspend fun processBuffer(samples: FloatArray, sampleRate: Int)
|
||||
|
||||
|
||||
@@ -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 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(
|
||||
|
||||
@@ -22,4 +22,12 @@ interface IShowToast {
|
||||
|
||||
/** Show by resource ID (four-language text) */
|
||||
operator fun invoke(resId: Int)
|
||||
|
||||
/**
|
||||
* Show a resource with format arguments, so a count can appear in a localised message.
|
||||
*
|
||||
* The alternative is building the string in a view model, which puts wording outside the
|
||||
* resource files and hardcodes one language.
|
||||
*/
|
||||
operator fun invoke(resId: Int, vararg formatArgs: Any)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
+14
-1
@@ -63,7 +63,20 @@ object LotwSatelliteIds {
|
||||
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
|
||||
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. */
|
||||
|
||||
@@ -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,28 +151,63 @@ object WaveLogApi {
|
||||
return if (up == down) "" else "$up/$down"
|
||||
}
|
||||
|
||||
/** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */
|
||||
fun normalizeSatName(raw: String): String {
|
||||
val main = raw.substringBefore('(').trim()
|
||||
.ifBlank { raw.trim() }
|
||||
.uppercase(Locale.ENGLISH)
|
||||
// Matching logic (mirrors WaveLog satellite table name/displayname matching + LoTW list):
|
||||
// 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
|
||||
// 3. Still unmatched -> return as-is (uploads are not blocked; QSO is still saved)
|
||||
val commonName = mapOf(
|
||||
"ZARYA" to "ARISS",
|
||||
"ARISS" to "ARISS",
|
||||
"FUNCUBE-1" to "AO-73",
|
||||
"DIWATA-2B" to "PO-101",
|
||||
"SAUDISAT-1C" to "SO-50",
|
||||
"SAUDISAT 1C" to "SO-50",
|
||||
"DIWATA-2A" to "PO-101"
|
||||
)
|
||||
val candidate = commonName[main] ?: main
|
||||
return if (candidate in LotwSatellites.names) candidate else main
|
||||
/**
|
||||
* The name LoTW accepts for this satellite, resolved from its catalogue number when known.
|
||||
*
|
||||
* LoTW rejects a QSO whose SAT_NAME is not spelled as its accepted list has it - its help
|
||||
* page gives AO7 against AO-7 as an example - so this has to produce the exact spelling or
|
||||
* nothing useful at all.
|
||||
*
|
||||
* [catnum] is preferred because the name alone cannot decide it: TLE sources disagree, and
|
||||
* of the 49 satellites carried by both Celestrak amateur and AMSAT nasabare, 33 are named
|
||||
* differently. NORAD 43017 is "RADFXSAT (FOX-1B)" in one and "AO-91" in the other, 43700 is
|
||||
* "ES'HAIL 2" against "QO-100". Deriving the name from the TLE text resolved 0 of 96
|
||||
* satellites to something LoTW accepts, because the descriptive part of a TLE name is never
|
||||
* the OSCAR designator.
|
||||
*
|
||||
* The name path remains as a fallback for QSOs logged before the catalogue number was
|
||||
* recorded. It tries the whole name, then either side of the parentheses, since which side
|
||||
* carries the designator varies - "SAUDISAT 1C (SO-50)" has it inside, "ISS (ZARYA)" does not.
|
||||
*/
|
||||
fun normalizeSatName(raw: String, catnum: Int? = null): String {
|
||||
catnum?.let { LotwSatelliteIds.nameFor(it) }?.let { return it }
|
||||
val trimmed = raw.trim()
|
||||
for (candidate in nameCandidates(trimmed)) {
|
||||
LotwSatellites.names.firstOrNull { it.equals(candidate, ignoreCase = true) }
|
||||
?.let { return it }
|
||||
}
|
||||
// Tolerate a missing or extra hyphen: sources write RS15 where LoTW has RS-15.
|
||||
for (candidate in nameCandidates(trimmed)) {
|
||||
val squashed = candidate.squashSeparators()
|
||||
LotwSatellites.names.firstOrNull { it.squashSeparators() == squashed }
|
||||
?.let { return it }
|
||||
}
|
||||
return trimmed.uppercase(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 */
|
||||
suspend fun postQso(
|
||||
url: String,
|
||||
@@ -184,7 +219,7 @@ object WaveLogApi {
|
||||
val base = normalizeUrl(url)
|
||||
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)
|
||||
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
|
||||
@@ -203,9 +238,19 @@ object WaveLogApi {
|
||||
put("sat_name", satName)
|
||||
if (satMode.isNotBlank()) put("sat_mode", satMode)
|
||||
}
|
||||
// The body decides, not the status code: Wavelog validates after responding, so a rejected
|
||||
// QSO arrives as HTTP 200 with {"status":"failed"}. Trusting the code marked it uploaded
|
||||
// and dropped it from the queue.
|
||||
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
|
||||
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
|
||||
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
|
||||
val v2Verdict = WavelogResponse.verdict(code, resp)
|
||||
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)
|
||||
val v1Body = JSONObject().apply {
|
||||
@@ -215,13 +260,39 @@ object WaveLogApi {
|
||||
put("string", toAdif(qso, gridsquare, satName))
|
||||
}
|
||||
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())
|
||||
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) */
|
||||
|
||||
@@ -25,6 +25,14 @@ data class WavelogQso(
|
||||
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
|
||||
val freqRxHz: Long, // 下行
|
||||
val satName: String,
|
||||
/**
|
||||
* NORAD catalogue number of the satellite, or 0 when it was not recorded.
|
||||
*
|
||||
* Carried because the name alone cannot decide the LoTW spelling - sources disagree, and
|
||||
* the same satellite named two ways would upload two ways. Zero means a QSO logged before
|
||||
* this field existed; those fall back to resolving from the name.
|
||||
*/
|
||||
val catnum: Int = 0,
|
||||
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
|
||||
val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到
|
||||
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
|
||||
@@ -48,6 +56,7 @@ class WavelogQueue(private val store: IWavelogQueueStore) {
|
||||
freqTxHz = o.optLong("freqTxHz"),
|
||||
freqRxHz = o.optLong("freqRxHz"),
|
||||
satName = o.optString("satName"),
|
||||
catnum = o.optInt("catnum", 0),
|
||||
sessionId = o.optString("sessionId"),
|
||||
gridsquare = o.optString("gridsquare"),
|
||||
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("mode", q.mode); put("freqTxHz", q.freqTxHz)
|
||||
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
|
||||
put("catnum", q.catnum)
|
||||
put("sessionId", q.sessionId)
|
||||
put("gridsquare", q.gridsquare)
|
||||
put("uploaded", q.uploaded)
|
||||
|
||||
+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 {
|
||||
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
|
||||
|
||||
/**
|
||||
* The upload finished. [failedCount] entries stay in the queue.
|
||||
*
|
||||
* [reason] is machine-readable so the UI can pick its own wording; [firstError] carries the
|
||||
* server's own explanation for the first failure, which is worth showing verbatim because it
|
||||
* is the only thing that says WHY Wavelog refused a QSO.
|
||||
*/
|
||||
data class Done(
|
||||
val successCount: Int,
|
||||
val failedCount: Int,
|
||||
val message: String,
|
||||
val reason: Reason = Reason.COMPLETED,
|
||||
val firstError: String = ""
|
||||
) : UploadOutcome()
|
||||
|
||||
/** Why an upload ended, for the UI to phrase. */
|
||||
enum class Reason {
|
||||
/** Ran to completion. Check the counts. */
|
||||
COMPLETED,
|
||||
|
||||
/** No server, key or station id configured. */
|
||||
NOT_CONFIGURED,
|
||||
|
||||
/** The station profile could not be read - wrong id, or a key without permission. */
|
||||
NO_STATION_INFO
|
||||
}
|
||||
}
|
||||
|
||||
class WavelogUploader(
|
||||
@@ -36,13 +56,13 @@ class WavelogUploader(
|
||||
val apiKey = settings.wavelogApiKey
|
||||
val stationId = settings.wavelogStationId
|
||||
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
|
||||
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
|
||||
return UploadOutcome.Done(0, queue.all().size, UploadOutcome.Reason.NOT_CONFIGURED)
|
||||
}
|
||||
|
||||
// 1. Fetch station info (station grid); fall back to user QTH when v1 lacks the endpoint
|
||||
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
|
||||
if (stationGrid.isNullOrBlank()) {
|
||||
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
|
||||
return UploadOutcome.Done(0, queue.all().size, UploadOutcome.Reason.NO_STATION_INFO)
|
||||
}
|
||||
|
||||
// 2. Grid check: cloud station grid first 4 chars vs current station QTH first 4 chars
|
||||
@@ -56,22 +76,23 @@ class WavelogUploader(
|
||||
|
||||
// 3. Upload one by one. ADIF gridsquare = counterpart grid (the QSO partner); blank until the scraper lands
|
||||
val entries = queue.all()
|
||||
var ok = 0
|
||||
var fail = 0
|
||||
var uploaded = 0
|
||||
var failed = 0
|
||||
var firstError = ""
|
||||
for (qso in entries) {
|
||||
if (qso.uploaded) { ok++; continue }
|
||||
// Entries already confirmed by the server are skipped, and NOT counted: adding them to
|
||||
// the total made a re-run report "N uploaded" for QSOs that went up days ago.
|
||||
if (qso.uploaded) continue
|
||||
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, qso.gridsquare)
|
||||
if (result is WavelogResult.Success) {
|
||||
ok++
|
||||
uploaded++
|
||||
queue.markUploaded(qso.id)
|
||||
} else {
|
||||
fail++
|
||||
failed++
|
||||
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
|
||||
}
|
||||
}
|
||||
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
|
||||
return UploadOutcome.Done(ok, fail, message, firstError)
|
||||
return UploadOutcome.Done(uploaded, failed, UploadOutcome.Reason.COMPLETED, firstError)
|
||||
}
|
||||
|
||||
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -86,9 +86,30 @@ class LotwSatelliteIdsTest {
|
||||
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
|
||||
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_ssid">SSID</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_status">Teks status</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_error">Kesalahan koneksi</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_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="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
|
||||
<string name="prefs_aprs_last_ok">OK</string>
|
||||
@@ -93,35 +94,30 @@
|
||||
\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_message" translatable="false">
|
||||
* PERBAIKAN: teks CW tidak lagi terlihat menghapus tulisan saat audio menunggu didekode
|
||||
* BARU: air terjun CW menampilkan seluruh pita audio, nada di luar jangkauan dekoder tetap terlihat
|
||||
* BARU: teks CW mengikuti isi baru, dan berhenti mengikuti saat Anda menggulir ke atas
|
||||
* BARU: pembaca layar kini menjelaskan air terjun CW dan sel hari AMSAT
|
||||
* BARU: air terjun CW menandai posisi nada sebenarnya, bahkan saat di luar rentang dekoder
|
||||
* BARU: satu baris di bawah air terjun menyebut nada dan apakah nada dipindahkan ke rentang
|
||||
* BARU: meter sinyal CW tidak lagi tinggi untuk nada yang tak terdengar dekoder
|
||||
* BARU: pilih gaya hari AMSAT di Pengaturan - dua belas garis, atau satu warna dengan jumlah laporan
|
||||
* BARU: Status AMSAT menampilkan 12 garis dua jam per hari — pemadaman dalam hari kini terlihat
|
||||
* BARU: Status AMSAT menggunakan hari kalender UTC, cocok dengan halaman resmi
|
||||
* BARU: dua abu-abu membedakan apakah slot tidak dilaporkan atau tidak pernah diambil
|
||||
* BARU: penanda cakupan data menunjukkan saat satelit yang lebih sepi terdesak keluar
|
||||
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
|
||||
* BARU: menyertakan model fp32 penuh untuk akurasi tertinggi
|
||||
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
|
||||
* Waterfall CW: skema warna inferno baru dengan gradasi halus
|
||||
* Menu Lainnya: panel ramping di kanan, tanpa lapisan gelap
|
||||
* Halaman CW: tombol kembali yang tidak berfungsi dan baris status yang menyesatkan dihapus
|
||||
* Dukungan 64-bit (arm64) dipulihkan
|
||||
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
|
||||
* Perbaikan: memindahkan halaman ke menu utama tidak lagi menyembunyikan Pengaturan secara permanen
|
||||
* Perbaikan: AMSAT/WavelogLog kini dapat dipindahkan ke menu utama
|
||||
* Perbaikan: penguraian epoch satelit untuk waktu dalam 86 detik pertama tengah malam UTC
|
||||
* Perbaikan: halaman radar kini otomatis beralih ke pass berikutnya setelah pass saat ini berakhir
|
||||
* 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>
|
||||
* FIX: APRS reported every beacon as sent even when nothing left the phone
|
||||
* FIX: APRS could not start at all on Android 10 and later
|
||||
* FIX: the APRS login line was malformed and real servers refused it
|
||||
* FIX: a refused APRS login is now reported instead of being read as success
|
||||
* FIX: APRS no longer invents a transmit passcode - request one from the link in settings
|
||||
* FIX: a mistyped passcode is no longer described as receive-only mode
|
||||
* FIX: APRS refuses to beacon without a position instead of sending 0N 0E
|
||||
* NEW: pick your APRS map symbol from a list; the default is a house, not a car
|
||||
* NEW: APRS beacons survive a locked screen
|
||||
* FIX: WaveLog uploads read the reply from the server - a rejected contact stayed reported as sent
|
||||
* FIX: a rejected contact now stays in the queue instead of being dropped
|
||||
* FIX: the satellite name sent to WaveLog is resolved from the catalogue number
|
||||
* FIX: the upload count no longer includes contacts sent days ago
|
||||
* NEW: the contact time can be set, for logging a pass after it ends
|
||||
* NEW: the counterpart grid can be typed, and is checked before it is logged
|
||||
* FIX: the mode no longer keeps the value from the previous transponder
|
||||
* FIX: a callsign that cannot be logged says why instead of vanishing
|
||||
* FIX: a custom TLE URL now replaces the built-in sources instead of adding to them
|
||||
* FIX: the custom source switch no longer turns itself off
|
||||
* FIX: a dead custom URL no longer counts as a successful update
|
||||
* FIX: QRZ tells an expired cookie apart from a station with no grid on file
|
||||
* FIX: contacts can be deleted with a screen reader
|
||||
* FIX: the log table grid and the sent column are readable in sunlight and at night
|
||||
</string>
|
||||
<string name="radar_back">Kembali</string>
|
||||
<string name="radar_notify">Beri tahu</string>
|
||||
<string name="radar_az_text">Azimuth</string>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<string name="prefs_aprs_callsign">Tanda panggilan</string>
|
||||
<string name="prefs_aprs_ssid">SSID</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_status">Teks status</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_error">Kesalahan koneksi</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_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="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
|
||||
<string name="prefs_aprs_last_ok">OK</string>
|
||||
@@ -92,35 +93,30 @@
|
||||
\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_message" translatable="false">
|
||||
* PERBAIKAN: teks CW tidak lagi terlihat menghapus tulisan saat audio menunggu didekode
|
||||
* BARU: air terjun CW menampilkan seluruh pita audio, nada di luar jangkauan dekoder tetap terlihat
|
||||
* BARU: teks CW mengikuti isi baru, dan berhenti mengikuti saat Anda menggulir ke atas
|
||||
* BARU: pembaca layar kini menjelaskan air terjun CW dan sel hari AMSAT
|
||||
* BARU: air terjun CW menandai posisi nada sebenarnya, bahkan saat di luar rentang dekoder
|
||||
* BARU: satu baris di bawah air terjun menyebut nada dan apakah nada dipindahkan ke rentang
|
||||
* BARU: meter sinyal CW tidak lagi tinggi untuk nada yang tak terdengar dekoder
|
||||
* BARU: pilih gaya hari AMSAT di Pengaturan - dua belas garis, atau satu warna dengan jumlah laporan
|
||||
* BARU: Status AMSAT menampilkan 12 garis dua jam per hari — pemadaman dalam hari kini terlihat
|
||||
* BARU: Status AMSAT menggunakan hari kalender UTC, cocok dengan halaman resmi
|
||||
* BARU: dua abu-abu membedakan apakah slot tidak dilaporkan atau tidak pernah diambil
|
||||
* BARU: penanda cakupan data menunjukkan saat satelit yang lebih sepi terdesak keluar
|
||||
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
|
||||
* BARU: menyertakan model fp32 penuh untuk akurasi tertinggi
|
||||
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
|
||||
* Waterfall CW: skema warna inferno baru dengan gradasi halus
|
||||
* Menu Lainnya: panel ramping di kanan, tanpa lapisan gelap
|
||||
* Halaman CW: tombol kembali yang tidak berfungsi dan baris status yang menyesatkan dihapus
|
||||
* Dukungan 64-bit (arm64) dipulihkan
|
||||
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
|
||||
* Perbaikan: memindahkan halaman ke menu utama tidak lagi menyembunyikan Pengaturan secara permanen
|
||||
* Perbaikan: AMSAT/WavelogLog kini dapat dipindahkan ke menu utama
|
||||
* Perbaikan: penguraian epoch satelit untuk waktu dalam 86 detik pertama tengah malam UTC
|
||||
* Perbaikan: halaman radar kini otomatis beralih ke pass berikutnya setelah pass saat ini berakhir
|
||||
* 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>
|
||||
* FIX: APRS reported every beacon as sent even when nothing left the phone
|
||||
* FIX: APRS could not start at all on Android 10 and later
|
||||
* FIX: the APRS login line was malformed and real servers refused it
|
||||
* FIX: a refused APRS login is now reported instead of being read as success
|
||||
* FIX: APRS no longer invents a transmit passcode - request one from the link in settings
|
||||
* FIX: a mistyped passcode is no longer described as receive-only mode
|
||||
* FIX: APRS refuses to beacon without a position instead of sending 0N 0E
|
||||
* NEW: pick your APRS map symbol from a list; the default is a house, not a car
|
||||
* NEW: APRS beacons survive a locked screen
|
||||
* FIX: WaveLog uploads read the reply from the server - a rejected contact stayed reported as sent
|
||||
* FIX: a rejected contact now stays in the queue instead of being dropped
|
||||
* FIX: the satellite name sent to WaveLog is resolved from the catalogue number
|
||||
* FIX: the upload count no longer includes contacts sent days ago
|
||||
* NEW: the contact time can be set, for logging a pass after it ends
|
||||
* NEW: the counterpart grid can be typed, and is checked before it is logged
|
||||
* FIX: the mode no longer keeps the value from the previous transponder
|
||||
* FIX: a callsign that cannot be logged says why instead of vanishing
|
||||
* FIX: a custom TLE URL now replaces the built-in sources instead of adding to them
|
||||
* FIX: the custom source switch no longer turns itself off
|
||||
* FIX: a dead custom URL no longer counts as a successful update
|
||||
* FIX: QRZ tells an expired cookie apart from a station with no grid on file
|
||||
* FIX: contacts can be deleted with a screen reader
|
||||
* FIX: the log table grid and the sent column are readable in sunlight and at night
|
||||
</string>
|
||||
<string name="radar_back">Kembali</string>
|
||||
<string name="radar_notify">Beri tahu</string>
|
||||
<string name="radar_az_text">Azimuth</string>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<string name="prefs_aprs_callsign">Çağrı işareti</string>
|
||||
<string name="prefs_aprs_ssid">SSID</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_status">Durum metni</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_error">Bağlantı hatası</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_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="prefs_aprs_last_report">Son rapor: %1$s %2$s</string>
|
||||
<string name="prefs_aprs_last_ok">OK</string>
|
||||
@@ -97,35 +98,30 @@
|
||||
\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_message" translatable="false">
|
||||
* DÜZELTME: CW metni artık kod çözme beklerken yazıyı siliyormuş gibi görünmüyor
|
||||
* YENİ: CW şelalesi tüm ses bandını gösterir, çözücünün ulaşamadığı ton da görünür
|
||||
* YENİ: CW metni yeni içeriği takip eder, geri kaydırdığınızda takibi bırakır
|
||||
* YENİ: ekran okuyucular artık CW şelalesini ve AMSAT gün hücrelerini okuyor
|
||||
* YENİ: CW şelalesi, tonunuz çözücü aralığının dışında olsa bile gerçek yerini işaretler
|
||||
* YENİ: şelalenin altındaki satır tonu adlandırır ve aralığa taşınıp taşınmadığını söyler
|
||||
* YENİ: çözücünün duyamadığı bir ton için CW sinyal göstergesi artık yüksek okumuyor
|
||||
* YENİ: AMSAT gün stilini Ayarlar\'dan seçin - on iki şerit veya rapor sayılı tek renk
|
||||
* YENİ: AMSAT durumu günde 12 iki saatlik şerit gösterir — gün içi kesintiler görünür
|
||||
* YENİ: AMSAT durumu UTC takvim günlerini kullanır, resmi sayfayla eşleşir
|
||||
* YENİ: iki gri, bir yuvanın rapor edilmediğini mi yoksa hiç alınmadığını mı ayırt eder
|
||||
* YENİ: veri kapsamı işareti, daha sessiz uyduların küresel çekimden dışlandığını gösterir
|
||||
* YENİ: CW çözücü artık DeepCW sinir ağını kullanıyor — zayıf sinyal Morse çözümü çok daha iyi
|
||||
* YENİ: en yüksek doğruluk için tam fp32 DeepCW modeli dahil
|
||||
* YENİ: CW çözüm geçmişi kalıcı olarak saklanır (metin artık kaybolmaz)
|
||||
* CW şelale: yeni inferno renk şeması ve yumuşak geçişler
|
||||
* Daha Fazla menüsü: sağda ince panel, karartma yok
|
||||
* CW sayfası: işlevsiz geri düğmesi ve yanıltıcı durum satırı kaldırıldı
|
||||
* 64-bit (arm64) desteği geri geldi
|
||||
* Düzeltme: duraklat/devam artık hatalı "çözümleme başarısız" mesajı göstermiyor
|
||||
* Düzeltme: sayfaları ana menüye taşımak artık Ayarlar\'ı kalıcı olarak gizlemiyor
|
||||
* Düzeltme: AMSAT/WavelogLog artık ana menüye taşınabiliyor
|
||||
* Düzeltme: UTC gece yarısından sonraki ilk 86 saniye içinde uydu epoch ayrıştırma hatası
|
||||
* Düzeltme: radar sayfası mevcut geçiş sona erdiğinde otomatik olarak sonraki geçişe geçiyor
|
||||
* 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>
|
||||
* FIX: APRS reported every beacon as sent even when nothing left the phone
|
||||
* FIX: APRS could not start at all on Android 10 and later
|
||||
* FIX: the APRS login line was malformed and real servers refused it
|
||||
* FIX: a refused APRS login is now reported instead of being read as success
|
||||
* FIX: APRS no longer invents a transmit passcode - request one from the link in settings
|
||||
* FIX: a mistyped passcode is no longer described as receive-only mode
|
||||
* FIX: APRS refuses to beacon without a position instead of sending 0N 0E
|
||||
* NEW: pick your APRS map symbol from a list; the default is a house, not a car
|
||||
* NEW: APRS beacons survive a locked screen
|
||||
* FIX: WaveLog uploads read the reply from the server - a rejected contact stayed reported as sent
|
||||
* FIX: a rejected contact now stays in the queue instead of being dropped
|
||||
* FIX: the satellite name sent to WaveLog is resolved from the catalogue number
|
||||
* FIX: the upload count no longer includes contacts sent days ago
|
||||
* NEW: the contact time can be set, for logging a pass after it ends
|
||||
* NEW: the counterpart grid can be typed, and is checked before it is logged
|
||||
* FIX: the mode no longer keeps the value from the previous transponder
|
||||
* FIX: a callsign that cannot be logged says why instead of vanishing
|
||||
* FIX: a custom TLE URL now replaces the built-in sources instead of adding to them
|
||||
* FIX: the custom source switch no longer turns itself off
|
||||
* FIX: a dead custom URL no longer counts as a successful update
|
||||
* FIX: QRZ tells an expired cookie apart from a station with no grid on file
|
||||
* FIX: contacts can be deleted with a screen reader
|
||||
* FIX: the log table grid and the sent column are readable in sunlight and at night
|
||||
</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Geri</string>
|
||||
|
||||
@@ -24,10 +24,27 @@
|
||||
<string name="prefs_aprs_callsign">呼号</string>
|
||||
<string name="prefs_aprs_ssid">SSID</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_status">状态文本</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_save">保存</string>
|
||||
<string name="prefs_aprs_cancel">取消</string>
|
||||
@@ -37,9 +54,10 @@
|
||||
<string name="aprs_notif_connected">已连接 APRS-IS</string>
|
||||
<string name="aprs_notif_error">连接错误</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_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="prefs_aprs_last_report">上次上报: %1$s %2$s</string>
|
||||
<string name="prefs_aprs_last_ok">成功</string>
|
||||
@@ -89,35 +107,30 @@
|
||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* 修复: CW 文本框不再出现文字消失一段又补回来的现象
|
||||
* 新: CW 瀑布图显示完整音频频段, 解码范围外的音调也看得见
|
||||
* 新: CW 文本框自动跟随新内容, 往上翻阅时自动停止跟随
|
||||
* 新: 读屏软件现在可以播报 CW 瀑布图与 AMSAT 日格内容
|
||||
* 新: CW 瀑布图标出音调真实位置, 即使它在解码范围之外
|
||||
* 新: 瀑布图下方一行文字说明音调频率, 以及是否已搬入解码范围
|
||||
* 新: 解码范围外的音调不再让信号强度条显示高值
|
||||
* 新: 设置里可选 AMSAT 日格样式 —— 12 条纹, 或单色加报告数
|
||||
* 新: AMSAT 状态页每天显示 12 条两小时条纹,当日内的中断一目了然
|
||||
* 新: AMSAT 状态页改用 UTC 日历日, 与官方页面一致
|
||||
* 新: 两种灰色区分"无人上报"和"无数据"
|
||||
* 新: 数据覆盖标记, 标明被挤掉的卫星数据
|
||||
* 新: CW 解码改用 DeepCW 神经网络,弱信号摩尔斯码解码率大幅提升
|
||||
* 新: 内置完整版 fp32 模型,解码精度最高
|
||||
* 新: CW 解码历史永久保留(文字不再消失)
|
||||
* CW 瀑布图: 全新 inferno 配色与平滑渐变
|
||||
* 更多菜单: 改为靠右窄面板,不再压暗页面
|
||||
* CW 页面: 移除无效的返回键与误导性状态提示
|
||||
* 恢复 64 位(arm64)支持
|
||||
* 修复: 暂停/恢复不再误报"解码失败"
|
||||
* 修复: 移动页面到主菜单不再导致设置入口永久消失
|
||||
* 修复: AMSAT/WavelogLog 现在可以正常移到主菜单
|
||||
* 修复: UTC 午夜后 86 秒内的卫星历元解析错误
|
||||
* 修复: 雷达页面在当前过境结束后自动切换到下一个过境
|
||||
* 修复: 雷达多普勒计算现在使用实时的偏移滑块值
|
||||
* 修复: 过境进度计算防除零崩溃
|
||||
* 修复: 并发 calculatePasses 调用不再触发重复计算
|
||||
* 修复: WaveLog 重复提交 QSO 与网格更新竞态
|
||||
</string>
|
||||
* 修复: APRS 无论是否真的发出都报告成功
|
||||
* 修复: APRS 在 Android 10 及以上完全无法启动
|
||||
* 修复: APRS 登录行格式错误, 真实服务器会拒绝
|
||||
* 修复: 服务器拒绝登录时不再当作成功
|
||||
* 修复: 不再代算 APRS 验证码 - 请从设置里的链接申请
|
||||
* 修复: 验证码打错不再被说成只收模式
|
||||
* 修复: 没有位置时拒绝上报, 不再发送 0N 0E
|
||||
* 新: APRS 地图符号改为列表选择, 默认房屋而非汽车
|
||||
* 新: APRS 上报在锁屏后继续
|
||||
* 修复: WaveLog 上传会读服务器回复 - 被拒的联络原先仍报成功
|
||||
* 修复: 被拒的联络保留在队列里, 不再被丢弃
|
||||
* 修复: 上传给 WaveLog 的卫星名改由编号解析
|
||||
* 修复: 上传计数不再把几天前传过的算进来
|
||||
* 新: 联络时间可以设置, 便于过境结束后再记录
|
||||
* 新: 对方网格可以手动输入, 并在记录前校验
|
||||
* 修复: 切换转发器后模式不再保留上一个的值
|
||||
* 修复: 无法记录的呼号会说明原因, 不再消失
|
||||
* 修复: 自定义 TLE 地址现在替换内置源, 而不是额外增加
|
||||
* 修复: 自定义源开关不再自己关掉
|
||||
* 修复: 自定义地址失效时不再算作更新成功
|
||||
* 修复: QRZ 能区分 Cookie 过期与对方未填网格
|
||||
* 修复: 使用读屏软件也能删除联络
|
||||
* 修复: 日志表格线与已传列在阳光下和夜间都看得清
|
||||
</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">后退</string>
|
||||
@@ -257,8 +270,43 @@
|
||||
<string name="wavelog_title">日志</string>
|
||||
<string name="wavelog_select_transponder">选择转发器</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_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_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_empty">暂无日志记录</string>
|
||||
<string name="wavelog_grid_mismatch">当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传?</string>
|
||||
@@ -269,6 +317,7 @@
|
||||
<string name="prefs_wavelog_qrz_label">Cookie</string>
|
||||
<string name="wavelog_copy">复制</string>
|
||||
<string name="wavelog_delete_undo">撤销</string>
|
||||
<string name="wavelog_delete_action">删除这条联络</string>
|
||||
<string name="wavelog_col_time">时间</string>
|
||||
<string name="wavelog_col_freq">频率</string>
|
||||
<string name="wavelog_col_sat">卫星</string>
|
||||
|
||||
@@ -25,10 +25,27 @@
|
||||
<string name="prefs_aprs_callsign">Callsign</string>
|
||||
<string name="prefs_aprs_ssid">SSID</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_status">Status text</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_save">Save</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_error">Connection error</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_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="prefs_aprs_last_report">Last report: %1$s %2$s</string>
|
||||
<string name="prefs_aprs_last_ok">OK</string>
|
||||
@@ -101,35 +119,30 @@
|
||||
\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_message" translatable="false">
|
||||
* FIX: the CW transcript no longer appears to delete text while audio waits to be decoded
|
||||
* NEW: the CW waterfall shows the whole audio band, so a tone the decoder cannot reach is still visible
|
||||
* NEW: the CW transcript follows new text, and stops following as soon as you scroll back
|
||||
* NEW: screen readers now describe the CW waterfall and the AMSAT day cells
|
||||
* NEW: the CW waterfall marks where your tone really is, even when it sits outside the decoder\'s range
|
||||
* NEW: a line under the CW waterfall names the tone and says whether it is being moved into range
|
||||
* NEW: the CW signal meter no longer reads high for a tone the decoder cannot actually hear
|
||||
* NEW: choose the AMSAT day style in Settings - twelve stripes, or one colour with a report count
|
||||
* NEW: AMSAT status shows 12 two-hour stripes per day — outages inside a day are now visible
|
||||
* NEW: AMSAT status uses UTC calendar days, matching the official page
|
||||
* NEW: two greys tell you whether a slot had no report or was never fetched
|
||||
* NEW: a data-coverage marker shows when quieter satellites are crowded out of the global pull
|
||||
* NEW: CW decoder now uses the DeepCW neural network — far better weak-signal Morse decoding
|
||||
* NEW: ships the full fp32 DeepCW model for maximum decode accuracy
|
||||
* NEW: CW decode history is kept permanently (text no longer disappears)
|
||||
* CW waterfall: new inferno colour scheme with smooth gradients
|
||||
* More menu: slim right-hand panel, no dimming overlay
|
||||
* CW page: removed the dead back button and the misleading status line
|
||||
* Restored 64-bit (arm64) support
|
||||
* Fixed: pause/resume no longer shows a spurious "decode failed" error
|
||||
* Fixed: moving pages to main menu no longer hides Settings permanently
|
||||
* Fixed: AMSAT/WavelogLog can now be moved to main menu
|
||||
* Fixed: satellite epoch parsing for times within first 86 seconds of UTC midnight
|
||||
* Fixed: radar page now auto-switches to next pass after current pass ends
|
||||
* 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>
|
||||
* FIX: APRS reported every beacon as sent even when nothing left the phone
|
||||
* FIX: APRS could not start at all on Android 10 and later
|
||||
* FIX: the APRS login line was malformed and real servers refused it
|
||||
* FIX: a refused APRS login is now reported instead of being read as success
|
||||
* FIX: APRS no longer invents a transmit passcode - request one from the link in settings
|
||||
* FIX: a mistyped passcode is no longer described as receive-only mode
|
||||
* FIX: APRS refuses to beacon without a position instead of sending 0N 0E
|
||||
* NEW: pick your APRS map symbol from a list; the default is a house, not a car
|
||||
* NEW: APRS beacons survive a locked screen
|
||||
* FIX: WaveLog uploads read the reply from the server - a rejected contact stayed reported as sent
|
||||
* FIX: a rejected contact now stays in the queue instead of being dropped
|
||||
* FIX: the satellite name sent to WaveLog is resolved from the catalogue number
|
||||
* FIX: the upload count no longer includes contacts sent days ago
|
||||
* NEW: the contact time can be set, for logging a pass after it ends
|
||||
* NEW: the counterpart grid can be typed, and is checked before it is logged
|
||||
* FIX: the mode no longer keeps the value from the previous transponder
|
||||
* FIX: a callsign that cannot be logged says why instead of vanishing
|
||||
* FIX: a custom TLE URL now replaces the built-in sources instead of adding to them
|
||||
* FIX: the custom source switch no longer turns itself off
|
||||
* FIX: a dead custom URL no longer counts as a successful update
|
||||
* FIX: QRZ tells an expired cookie apart from a station with no grid on file
|
||||
* FIX: contacts can be deleted with a screen reader
|
||||
* FIX: the log table grid and the sent column are readable in sunlight and at night
|
||||
</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Back</string>
|
||||
@@ -288,8 +301,43 @@
|
||||
<string name="wavelog_title">Log</string>
|
||||
<string name="wavelog_select_transponder">Select transponder</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_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_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_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>
|
||||
@@ -300,6 +348,7 @@
|
||||
<string name="prefs_wavelog_qrz_label">Cookie</string>
|
||||
<string name="wavelog_copy">Copy</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_freq">Freq</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.mutableStateOf
|
||||
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.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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 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.
|
||||
*
|
||||
@@ -103,6 +99,16 @@ fun CwDecodeScreen() {
|
||||
}
|
||||
var permanentlyDenied 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 historyText by decoder.historyText.collectAsState()
|
||||
@@ -125,7 +131,13 @@ fun CwDecodeScreen() {
|
||||
// permission so granting the permission mid-flow restarts collection
|
||||
// (isListening may already be true when the launcher returns).
|
||||
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) {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
return@LaunchedEffect
|
||||
@@ -138,8 +150,14 @@ fun CwDecodeScreen() {
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
// OrtSession holds native memory and must be released explicitly.
|
||||
decoder.close()
|
||||
// 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.
|
||||
decoder.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +175,41 @@ fun CwDecodeScreen() {
|
||||
.weight(1f)
|
||||
.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 }) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
@@ -223,6 +276,16 @@ fun CwDecodeScreen() {
|
||||
.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(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
@@ -231,35 +294,25 @@ fun CwDecodeScreen() {
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
||||
) {
|
||||
val transcript = (historyText + decodedText).ifEmpty { "…" }
|
||||
val scroll = rememberScrollState()
|
||||
// Follow the newest text, but stop as soon as the operator scrolls away, so
|
||||
// reading back over earlier traffic is not undone by the next decode.
|
||||
// The record shows settled text only. decodedText is the live 20-second window,
|
||||
// which the model rewrites from scratch every 1.5 seconds as more context arrives -
|
||||
// right for the line above, where the newest guess is what you want, but it meant the
|
||||
// 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
|
||||
// written during layout, after the composition that would read it, so such a
|
||||
// comparison tests the previous frame's height and drifts short of the true
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
// It used to auto-scroll to the bottom on every decode, dropping out of follow mode
|
||||
// only once the operator had already fought it by scrolling away. Now it stays where
|
||||
// it was put, and the button below jumps to the newest text on request.
|
||||
Text(
|
||||
text = transcript,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scroll)
|
||||
.verticalScroll(transcriptScroll)
|
||||
.padding(8.dp),
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
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.CwToneShifter
|
||||
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.
|
||||
// ArrayDeque is not thread-safe: concurrent removeFirst()/toList() throws.
|
||||
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 pending = ArrayList<Float>(CwDeepSpectrogram.SAMPLE_RATE)
|
||||
// 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) {
|
||||
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(
|
||||
chunk, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
bandLimited, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
)
|
||||
val audio: FloatArray
|
||||
val generationAtStart: Long
|
||||
@@ -120,6 +138,7 @@ class CwWaterfallState(private val historyRows: Int = 96) {
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
antiAlias?.reset()
|
||||
synchronized(lock) {
|
||||
generation++
|
||||
rows.clear()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<string name="cw_pause">Jeda pendekodean</string>
|
||||
<string name="cw_resume">Lanjutkan pendekodean</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_grant_permission">Berikan izin</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_outside">nada %1$d hertz, di luar rentang dekode</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>
|
||||
@@ -3,6 +3,7 @@
|
||||
<string name="cw_pause">Jeda pendekodean</string>
|
||||
<string name="cw_resume">Lanjutkan pendekodean</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_grant_permission">Berikan izin</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_outside">nada %1$d hertz, di luar rentang dekode</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>
|
||||
@@ -3,6 +3,7 @@
|
||||
<string name="cw_pause">Çözmeyi duraklat</string>
|
||||
<string name="cw_resume">Çözmeyi sürdür</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_grant_permission">İzin ver</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_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_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>
|
||||
@@ -3,6 +3,7 @@
|
||||
<string name="cw_pause">暂停解码</string>
|
||||
<string name="cw_resume">继续解码</string>
|
||||
<string name="cw_clear">清空解码文本</string>
|
||||
<string name="cw_scroll_newest">跳到最新</string>
|
||||
<string name="cw_mic_permission">CW 解码需要麦克风权限</string>
|
||||
<string name="cw_grant_permission">授予权限</string>
|
||||
<string name="cw_open_settings">打开应用设置</string>
|
||||
@@ -13,4 +14,8 @@
|
||||
<string name="cw_waterfall_shifted">音调 %1$d 赫兹,已搬入解码范围</string>
|
||||
<string name="cw_waterfall_outside">音调 %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>
|
||||
@@ -3,6 +3,7 @@
|
||||
<string name="cw_pause">Pause decoding</string>
|
||||
<string name="cw_resume">Resume decoding</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_grant_permission">Grant permission</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_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_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>
|
||||
@@ -16,6 +16,11 @@ import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -38,7 +43,7 @@ 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 androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -52,9 +57,14 @@ import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.draw.clip
|
||||
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.predict.OrbitalPos
|
||||
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.WavelogQueue
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
@@ -84,7 +98,6 @@ import kotlin.math.roundToInt
|
||||
private val WaveLogYellow = Color(0xFFFFC107)
|
||||
|
||||
/** 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)
|
||||
@Composable
|
||||
@@ -92,11 +105,14 @@ fun LogTab(
|
||||
transceivers: List<SatRadio>,
|
||||
orbitalPos: OrbitalPos?,
|
||||
satelliteName: String,
|
||||
satelliteCatnum: Int,
|
||||
queue: WavelogQueue,
|
||||
wavelogConfigured: Boolean,
|
||||
showToast: (String) -> Unit,
|
||||
txBaseFrequencyHz: Long? = null,
|
||||
aosTimeMs: Long = 0L,
|
||||
onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit,
|
||||
onAutoUpload: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
@@ -136,7 +152,7 @@ fun LogTab(
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = menuExpanded) },
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
@@ -167,10 +183,13 @@ fun LogTab(
|
||||
radio = radio,
|
||||
orbitalPos = orbitalPos,
|
||||
satelliteName = satelliteName,
|
||||
satelliteCatnum = satelliteCatnum,
|
||||
queue = queue,
|
||||
showToast = showToast,
|
||||
txBaseFrequencyHz = txBaseFrequencyHz,
|
||||
aosTimeMs = aosTimeMs,
|
||||
onLookupGrid = onLookupGrid,
|
||||
onAutoUpload = onAutoUpload,
|
||||
onSaved = { refreshTick++ }
|
||||
)
|
||||
}
|
||||
@@ -253,61 +272,123 @@ private fun ExpandedLogInput(
|
||||
radio: SatRadio,
|
||||
orbitalPos: OrbitalPos?,
|
||||
satelliteName: String,
|
||||
satelliteCatnum: Int,
|
||||
queue: WavelogQueue,
|
||||
showToast: (String) -> Unit,
|
||||
txBaseFrequencyHz: Long? = null,
|
||||
aosTimeMs: Long = 0L,
|
||||
onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit,
|
||||
onAutoUpload: () -> Unit,
|
||||
onSaved: () -> Unit
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var callsign by remember { mutableStateOf("") }
|
||||
var mode by remember { mutableStateOf(radio.uplinkMode ?: "FM") }
|
||||
// Last accepted callsign + timestamp, used to swallow duplicate IME "done" events.
|
||||
// A plain in-flight flag cannot help: submit() is synchronous, so it always
|
||||
// clears before the next key event arrives.
|
||||
var lastSaved by remember { mutableStateOf("" to 0L) }
|
||||
// Keyed on the transponder. Without the key, switching transponder mid-session kept the
|
||||
// previous one's mode and uploaded a value the operator never chose.
|
||||
var mode by remember(radio.uuid) { mutableStateOf(radio.uplinkMode ?: "FM") }
|
||||
var modeEditable by remember(radio.uuid) { mutableStateOf(false) }
|
||||
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 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() {
|
||||
val call = callsign.trim().uppercase()
|
||||
if (call.length < 3) return
|
||||
val now = System.currentTimeMillis()
|
||||
val (lastCall, lastAt) = lastSaved
|
||||
if (call == lastCall && now - lastAt < DUPLICATE_WINDOW_MS) return
|
||||
lastSaved = call to now
|
||||
// Every outcome says something. The previous `if (call.length < 3) return` discarded the
|
||||
// entry in silence, so pressing done mid-pass appeared to do nothing, and none of the
|
||||
// logging software surveyed drops a submission that way.
|
||||
val verdict = CallsignEntry.check(callsign, workedThisSession)
|
||||
if (verdict is CallsignEntry.Verdict.Rejected) {
|
||||
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)
|
||||
val tx = radio.uplinkLow ?: radio.downlinkLow ?: 0L
|
||||
val rx = radio.downlinkLow ?: radio.uplinkLow ?: 0L
|
||||
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(
|
||||
WavelogQso(
|
||||
id = qsoId,
|
||||
timeUtcMs = System.currentTimeMillis(),
|
||||
timeUtcMs = PassClock.resolve(
|
||||
PassClock.parse(timeEntry),
|
||||
System.currentTimeMillis(),
|
||||
utcDayStart(System.currentTimeMillis())
|
||||
),
|
||||
call = call,
|
||||
mode = mode.trim().ifBlank { "FM" }.uppercase(),
|
||||
freqTxHz = tx,
|
||||
freqRxHz = rx,
|
||||
satName = satelliteName,
|
||||
catnum = satelliteCatnum,
|
||||
gridsquare = typedGrid,
|
||||
sessionId = buildSessionId(satelliteName, aosTimeMs)
|
||||
)
|
||||
)
|
||||
callsign = ""
|
||||
gridEntry = ""
|
||||
workedThisSession = workedThisSession + call
|
||||
onSaved()
|
||||
showToast(savedMsg)
|
||||
// QRZ counterpart grid async backfill (4.5.5): only queried when Cookie is set; silent on failure
|
||||
scope.launch {
|
||||
val prefs = context.getSharedPreferences("qrz_cookie", android.content.Context.MODE_PRIVATE)
|
||||
val rawCookie = prefs.getString("cookie", "") ?: ""
|
||||
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++ 刷新列表
|
||||
showToast(
|
||||
when (accepted.warning) {
|
||||
CallsignEntry.Warning.LOOKS_LIKE_A_GRID -> gridWarnMsg
|
||||
CallsignEntry.Warning.ALREADY_WORKED -> workedWarnMsg.format(call)
|
||||
null -> savedMsg
|
||||
}
|
||||
)
|
||||
// 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) {
|
||||
Text(
|
||||
text = " (${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
|
||||
fontSize = 11.sp,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
@@ -358,7 +439,7 @@ private fun ExpandedLogInput(
|
||||
if (downLow != null && downHigh != null && downLow != downHigh) {
|
||||
Text(
|
||||
text = " (${formatFrequency(downLow)} – ${formatFrequency(downHigh)})",
|
||||
fontSize = 11.sp,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
@@ -373,14 +454,141 @@ private fun ExpandedLogInput(
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
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 = mode,
|
||||
onValueChange = { mode = it.take(8) },
|
||||
label = { Text(stringResource(id = R.string.wavelog_mode_hint)) },
|
||||
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(
|
||||
value = mode,
|
||||
onValueChange = { mode = it.take(8) },
|
||||
label = { Text(stringResource(id = R.string.wavelog_mode_hint)) },
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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)
|
||||
Box(
|
||||
@@ -452,22 +683,34 @@ internal fun SwipeDeleteRow(
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = WaveLogYellow,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.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
|
||||
offsetX = 0f
|
||||
}
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
.wrapContentHeight(Alignment.CenterVertically)
|
||||
.padding(horizontal = 10.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Trash icon (always visible while swiping, yellow)
|
||||
Text(
|
||||
text = "🗑",
|
||||
fontSize = 20.sp,
|
||||
modifier = Modifier.padding(end = 16.dp)
|
||||
// A real icon, not an emoji: emoji render differently on every device and font, and
|
||||
// this project forbids them as icons. contentDescription is null because the row
|
||||
// already exposes delete as a custom accessibility action.
|
||||
Icon(
|
||||
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"
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.repository.IContainerProvider
|
||||
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.utility.DopplerFrequencyCalculator
|
||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||
@@ -92,6 +93,19 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||
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 navigateUpAndClearMutual = {
|
||||
if (container.mutualPassData.value.endTime > 0L) {
|
||||
@@ -124,21 +138,11 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
||||
}
|
||||
// WaveLog (4.5.2): auto-upload - retries the local queue every 10 min (when the switch is on)
|
||||
val wavelogUploader = remember { container.provideWavelogUploader() }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(10 * 60 * 1000L)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Auto-upload happens when a QSO is saved, not on a timer. The ten-minute polling loop that
|
||||
// used to live here uploaded in the background with no way to tell the operator what happened:
|
||||
// a grid mismatch was skipped by an empty if, and any other failure just retried silently
|
||||
// forever. A QSO that cannot be uploaded now stays in the queue for a manual upload from
|
||||
// settings, where the result is actually shown.
|
||||
RadarScreen(
|
||||
uiState,
|
||||
viewModel::onAction,
|
||||
@@ -149,7 +153,9 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
wavelogConfigured = container.settingsRepo.otherSettings.value.let {
|
||||
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,
|
||||
wavelogQueue: WavelogQueue,
|
||||
wavelogConfigured: Boolean,
|
||||
showToast: (String) -> Unit
|
||||
showToast: (String) -> Unit,
|
||||
onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit,
|
||||
onAutoUpload: () -> Unit
|
||||
) {
|
||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||
val addToCalendar: () -> Unit = {
|
||||
@@ -216,11 +224,11 @@ private fun RadarScreen(
|
||||
}
|
||||
if (isVertical) {
|
||||
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 {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
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,
|
||||
wavelogConfigured: Boolean,
|
||||
showToast: (String) -> Unit,
|
||||
onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit,
|
||||
onAutoUpload: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
|
||||
@@ -297,11 +307,14 @@ private fun PagerCard(
|
||||
transceivers = uiState.transceivers.transmitters,
|
||||
orbitalPos = uiState.orbitalPos,
|
||||
satelliteName = uiState.currentPass?.name ?: "",
|
||||
satelliteCatnum = uiState.currentPass?.catNum ?: 0,
|
||||
queue = wavelogQueue,
|
||||
wavelogConfigured = wavelogConfigured,
|
||||
showToast = showToast,
|
||||
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.OrbitalPass
|
||||
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.IRadioTrackingService
|
||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||
@@ -64,7 +68,9 @@ class RadarViewModel(
|
||||
private val audioCapture: IAudioCapture,
|
||||
private val cwDecoderFactory: () -> ICwDecoder,
|
||||
private val saveImage: ISaveImage,
|
||||
private val showToast: IShowToast
|
||||
private val showToast: IShowToast,
|
||||
private val qrzGridLookup: IQrzGridLookup,
|
||||
private val wavelogUploader: WavelogUploader
|
||||
) : ViewModel() {
|
||||
|
||||
private var stationPos = settingsRepo.stationPosition.value
|
||||
@@ -544,6 +550,42 @@ class RadarViewModel(
|
||||
_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 {
|
||||
// SSTV Decoder Tuning Parameters
|
||||
// ==============================
|
||||
@@ -599,7 +641,9 @@ class RadarViewModel(
|
||||
audioCapture = container.provideAudioCapture(),
|
||||
cwDecoderFactory = { container.provideCwDecoder() },
|
||||
saveImage = container.provideSaveImage(),
|
||||
showToast = container.provideShowToast()
|
||||
showToast = container.provideShowToast(),
|
||||
qrzGridLookup = container.provideQrzGridLookup(),
|
||||
wavelogUploader = container.provideWavelogUploader()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+23
-9
@@ -44,7 +44,15 @@ import java.util.Calendar
|
||||
import java.util.TimeZone
|
||||
|
||||
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
|
||||
fun WavelogLogScreen(
|
||||
@@ -139,15 +147,21 @@ fun WavelogLogScreen(
|
||||
GridVLine()
|
||||
Cell(entry.call, 0.dp, weight = 1f)
|
||||
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)) {
|
||||
if (entry.uploaded) {
|
||||
Text(
|
||||
text = "✓",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = CheckGreen
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (entry.uploaded) "OK" else "...",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (entry.uploaded) {
|
||||
CheckGreen
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
// Row divider
|
||||
|
||||
+109
-24
@@ -2,6 +2,7 @@ package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.text.KeyboardOptions
|
||||
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.IconButton
|
||||
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.sp
|
||||
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.presentation.CardButton
|
||||
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 */
|
||||
/**
|
||||
* 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
|
||||
fun AprsCard() {
|
||||
val context = LocalContext.current
|
||||
@@ -171,6 +185,7 @@ fun AprsCard() {
|
||||
}
|
||||
|
||||
/** APRS settings dialog (plenty of room, full config) */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AprsSettingsDialog(
|
||||
config: AprsConfig,
|
||||
@@ -186,7 +201,9 @@ private fun AprsSettingsDialog(
|
||||
var status by remember { mutableStateOf(config.statusText) }
|
||||
var symbolTable by remember { mutableStateOf(config.symbolTable) }
|
||||
var symbolCode by remember { mutableStateOf(config.symbolCode) }
|
||||
var symbolMenuExpanded by remember { mutableStateOf(false) }
|
||||
val textStyle = LocalTextStyle.current.copy(fontSize = 14.sp)
|
||||
val context = LocalContext.current
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -215,16 +232,19 @@ private fun AprsSettingsDialog(
|
||||
singleLine = true,
|
||||
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(
|
||||
onClick = {
|
||||
val call = callsign.trim().uppercase()
|
||||
if (call.isNotBlank()) {
|
||||
passcode = AprsPacket.passcode(call).toString()
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(PASSCODE_REQUEST_URL))
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = callsign.trim().isNotBlank()
|
||||
) { Text(stringResource(id = R.string.prefs_aprs_calc_passcode)) }
|
||||
}
|
||||
) { Text(stringResource(id = R.string.prefs_aprs_request_passcode)) }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = ssid,
|
||||
@@ -262,24 +282,62 @@ private fun AprsSettingsDialog(
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// A list rather than two free-text fields. The fields accepted any string and used
|
||||
// only the first character, so typing "satellite" persisted the word and beaconed
|
||||
// as `/`. The pair that makes this worth doing: \S is Satellite but /S is SHUTTLE.
|
||||
val selected = AprsSymbols.find(symbolTable, symbolCode)
|
||||
val customLabel = stringResource(
|
||||
id = R.string.prefs_aprs_symbol_custom,
|
||||
symbolTable.take(1),
|
||||
symbolCode.take(1)
|
||||
)
|
||||
val selectedLabel = selected?.let { symbolLabel(it.descriptionKey) } ?: customLabel
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = symbolMenuExpanded,
|
||||
onExpandedChange = { symbolMenuExpanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = symbolTable,
|
||||
onValueChange = { symbolTable = it },
|
||||
label = { Text(stringResource(id = R.string.prefs_aprs_symbol_table)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
textStyle = textStyle
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = symbolCode,
|
||||
onValueChange = { symbolCode = it },
|
||||
label = { Text(stringResource(id = R.string.prefs_aprs_symbol_code)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
textStyle = textStyle
|
||||
value = selectedLabel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(id = R.string.prefs_aprs_symbol)) },
|
||||
trailingIcon = {
|
||||
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 = stringResource(id = R.string.prefs_aprs_passcode_hint),
|
||||
fontSize = 12.sp,
|
||||
@@ -304,7 +362,7 @@ private fun AprsSettingsDialog(
|
||||
intervalMin = interval.toIntOrNull() ?: 5,
|
||||
statusText = status,
|
||||
symbolTable = symbolTable.ifBlank { "/" },
|
||||
symbolCode = symbolCode.ifBlank { ">" }
|
||||
symbolCode = symbolCode.ifBlank { "-" }
|
||||
)
|
||||
)
|
||||
}) { 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.OtherSettings
|
||||
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.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
@@ -123,7 +125,12 @@ fun SettingsDestination() {
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
|
||||
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)
|
||||
val gridConfirm = viewModel.gridConfirm
|
||||
@@ -187,7 +194,8 @@ fun SettingsDestination() {
|
||||
private fun SettingsScreen(
|
||||
uiState: SettingsState,
|
||||
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 pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
@@ -405,7 +413,7 @@ private fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
item { OtherCard(uiState.otherSettings, onAction) }
|
||||
item { WavelogCard(uiState.otherSettings, onAction, lotwRepo) }
|
||||
item { WavelogCard(uiState.otherSettings, onAction, lotwRepo, qrzLookup) }
|
||||
item {
|
||||
UiSettingsCard(
|
||||
hiddenScreens = uiState.otherSettings.hiddenScreens,
|
||||
@@ -683,7 +691,8 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
||||
private fun WavelogCard(
|
||||
settings: OtherSettings,
|
||||
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
|
||||
var showQrzDialog by remember { mutableStateOf(false) }
|
||||
@@ -694,6 +703,11 @@ private fun WavelogCard(
|
||||
)
|
||||
}
|
||||
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) }
|
||||
val qrzScope = rememberCoroutineScope()
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -795,21 +809,22 @@ private fun WavelogCard(
|
||||
qrzTesting = true
|
||||
qrzTestResult = null
|
||||
qrzScope.launch {
|
||||
val header = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.parseCookies(qrzCookie)
|
||||
if (header.isBlank()) {
|
||||
qrzTestResult = "Cookie 为空, 请先粘贴"
|
||||
qrzTesting = false
|
||||
return@launch
|
||||
}
|
||||
val own = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.fetchOwnCallsign(header)
|
||||
if (own == null) {
|
||||
qrzTestResult = "Cookie 无效或已过期(无法识别登录呼号)"
|
||||
// Goes through the repository rather than scraping from the UI, and
|
||||
// reports each outcome distinctly - the old version could not tell
|
||||
// an expired cookie from a station with no grid on file.
|
||||
qrzTestResult = if (qrzCookie.isBlank()) {
|
||||
qrzEmptyMsg
|
||||
} else {
|
||||
val grid = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.lookupGrid(own, header)
|
||||
qrzTestResult = if (grid != null) {
|
||||
"登录呼号: $own 网格: $grid ✅"
|
||||
val own = qrzLookup.signedInAs()
|
||||
if (own == null) {
|
||||
qrzSignedOutMsg
|
||||
} else {
|
||||
"登录呼号: $own 网格: 未填写"
|
||||
when (val grid = qrzLookup.lookup(own)) {
|
||||
is QrzGrid.Found -> qrzOkMsg.format(own, grid.locator)
|
||||
QrzGrid.NotOnFile -> qrzNoGridMsg.format(own)
|
||||
QrzGrid.SignedOut -> qrzSignedOutMsg
|
||||
is QrzGrid.Unreachable -> qrzUnreachableMsg
|
||||
}
|
||||
}
|
||||
}
|
||||
qrzTesting = false
|
||||
|
||||
+30
-12
@@ -275,9 +275,11 @@ class SettingsViewModel(
|
||||
viewModelScope.launch {
|
||||
when (val r = lotwRepo.refresh()) {
|
||||
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 ->
|
||||
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 {
|
||||
val result = pendingUploader?.uploadQueue()
|
||||
when (result) {
|
||||
is UploadOutcome.Done -> {
|
||||
if (result.failedCount > 0 && result.firstError.isNotBlank()) {
|
||||
wavelogError = result.firstError
|
||||
} else {
|
||||
showToast("WaveLog: ${result.message}")
|
||||
}
|
||||
}
|
||||
is UploadOutcome.Done -> reportUpload(result)
|
||||
is UploadOutcome.NeedConfirm -> {
|
||||
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 */
|
||||
fun resolveGridConfirm(ignoreAndUpload: Boolean) {
|
||||
val confirm = gridConfirm ?: return
|
||||
@@ -309,8 +327,8 @@ class SettingsViewModel(
|
||||
viewModelScope.launch {
|
||||
val result = pendingUploader?.uploadQueue(force = true)
|
||||
when (result) {
|
||||
is UploadOutcome.Done -> showToast("WaveLog: ${result.message}")
|
||||
else -> showToast("WaveLog: 上传失败")
|
||||
is UploadOutcome.Done -> reportUpload(result)
|
||||
else -> showToast(R.string.wavelog_upload_failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "467"
|
||||
appVersionCode = "469"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.6.0"
|
||||
appVersionName = "4.6.2"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user