fix(aprs): treat any unrecognised login response as a refusal

The previous commit listed the refusal wordings it knew - "invalid login" and "login
denied" - and skipped everything else as chatter. That list was incomplete. Probing the
parser against responses captured from live servers found three it missed:

    # Login by user not allowed     observed on rotate.aprs2.net
    # Port full
    # Server full

Each was skipped as a keepalive, so 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. Exactly the failure the previous commit fixed, reached
by a different wording.

Inverted: identification and keepalive comments are recognised positively, and anything
else the server says during login counts as an objection. The trade is that an unforeseen
harmless comment would read as a refusal - but that errs towards reporting failure rather
than claiming success, which is the direction this feature has been wrong in throughout.

The keepalive prefixes come from a live capture rather than guesswork. aprsc repeats its
own identification with a timestamp every twenty seconds:

    # aprsc 2.1.21-gbfc2090 25 Aug 2026 16:41:07 GMT T2UK 195.201.15.71:14580

Two tests had invented a `# Tue Aug 25 ...` date line and a `# keepalive N`, neither of
which any server sends. Both now use the captured format.

Also here: the QRZ cookie test in settings goes through the repository instead of
scraping from the UI. It was the last caller of QrzGridClient, which is deleted, and it
built its result from hardcoded Chinese strings inside the composable - those move to
resources, and the four outcomes are now distinguished, where before an expired cookie
and a station with no grid on file produced the same message.
This commit is contained in:
mckero committed 2026-08-25 17:06:37 +00:00
1 parent 271488a43e
commit 6859c825d7
9 files changed
+130 -113

No files matched your search

@@ -47,6 +47,12 @@ class QrzGridLookup(
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"
@@ -184,7 +184,10 @@ class AprsIsClientSocketTest {
*/
@Test
fun `keepalive chatter before the verdict does not hide it`() {
val chatter = List(8) { "# keepalive $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)
@@ -88,12 +88,18 @@ object AprsLogin {
/**
* Interpret one line of server output, or null when it carries no verdict.
*
* Returning null for keepalives and identification comments lets the caller keep reading
* rather than treating the first comment it sees as an answer.
* 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.
*
* aprsc answers `# logresp CALL verified, server X` or `# logresp CALL unverified, server X`.
* Note that "unverified" contains "verified", so the negative has to be tested first - a
* naive contains("verified") reports every rejected login as accepted.
* 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()
@@ -103,27 +109,27 @@ object AprsLogin {
return Outcome.Rejected(trimmed)
}
val lower = trimmed.lowercase()
// A refusal arrives as a comment and is NOT a logresp: aprsc answers `# Invalid login: ...`
// and then closes. Treating that as chatter let the login time out into Unknown, which is
// deliberately optimistic - so every send afterwards reported success to an operator the
// server had refused. That was the defect this whole rebuild set out to remove, still live
// on the path every operator takes.
if (lower.contains("invalid login") || lower.contains("login denied")) {
return Outcome.Rejected(trimmed.removePrefix("#").trim())
}
if (!lower.contains("logresp")) {
// Server identification and keepalive comments carry no verdict.
return null
}
val callsign = callsignFrom(trimmed)
return when {
lower.contains("unverified") -> Outcome.Unverified(callsign)
lower.contains("verified") -> Outcome.Verified(callsign)
lower.contains("invalid") || lower.contains("error") -> Outcome.Rejected(trimmed)
else -> Outcome.Unknown(trimmed)
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+"))
@@ -34,4 +34,12 @@ interface IQrzGridLookup {
* 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?
}
@@ -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 }
}
}
@@ -100,6 +100,42 @@ class AprsLoginTest {
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.
@@ -123,7 +159,11 @@ class AprsLoginTest {
@Test
fun `comments without a verdict are skipped`() {
assertNull(AprsLogin.parse("# aprsc 2.1.21-gbfc2090"))
assertNull(AprsLogin.parse("# Tue Aug 25 08:00:00 UTC 2026"))
// 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(" "))
}
@@ -270,6 +270,11 @@
<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>
@@ -301,6 +301,11 @@
<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>
@@ -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