From 6859c825d7025124b41b52148709c1d1cb4f474b Mon Sep 17 00:00:00 2001 From: QIU Date: Tue, 25 Aug 2026 17:06:37 +0000 Subject: [PATCH] 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. --- .../look4sat/core/data/qrz/QrzGridLookup.kt | 6 ++ .../core/data/aprs/AprsIsClientSocketTest.kt | 5 +- .../look4sat/core/domain/aprs/AprsLogin.kt | 52 ++++++++------ .../core/domain/qrz/IQrzGridLookup.kt | 8 +++ .../look4sat/core/domain/qrz/QrzGridClient.kt | 71 ------------------- .../core/domain/aprs/AprsLoginTest.kt | 42 ++++++++++- .../src/main/res/values-zh/strings.xml | 5 ++ .../src/main/res/values/strings.xml | 5 ++ .../feature/settings/SettingsScreen.kt | 49 ++++++++----- 9 files changed, 130 insertions(+), 113 deletions(-) delete mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/QrzGridClient.kt diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/qrz/QrzGridLookup.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/qrz/QrzGridLookup.kt index 9010e023..8926d2c3 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/qrz/QrzGridLookup.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/qrz/QrzGridLookup.kt @@ -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" diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/aprs/AprsIsClientSocketTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/aprs/AprsIsClientSocketTest.kt index 6977c569..122560ad 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/aprs/AprsIsClientSocketTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/aprs/AprsIsClientSocketTest.kt @@ -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) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsLogin.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsLogin.kt index ce222a2b..c22189ef 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsLogin.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/aprs/AprsLogin.kt @@ -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+")) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/IQrzGridLookup.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/IQrzGridLookup.kt index 104678d2..b08e5781 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/IQrzGridLookup.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/IQrzGridLookup.kt @@ -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? } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/QrzGridClient.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/QrzGridClient.kt deleted file mode 100644 index c6c9bb48..00000000 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/QrzGridClient.kt +++ /dev/null @@ -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 Grid SquareXXX. - * 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() - 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):
  • BG7NTA
      - val pattern = Regex("
    • ]*>\\s*([A-Z0-9/]+)\\s*Grid Square([^<]+)""") - .find(html) - m?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() } - } catch (_: Exception) { null } - } -} diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsLoginTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsLoginTest.kt index 24ebd9a1..35523a99 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsLoginTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/aprs/AprsLoginTest.kt @@ -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(" ")) } diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 76d49aac..eb922022 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -270,6 +270,11 @@ 查网格需要先在设置里填 QRZ Cookie QRZ Cookie 已过期 - 请在设置里重新粘贴 连不上 QRZ, 没查到网格 + 请先粘贴 Cookie + Cookie 无效或已过期 + 登录呼号 %1$s, 网格 %2$s + 登录呼号 %1$s, 未填写网格 + 连不上 QRZ 请先在设置中配置 WaveLog 暂无日志记录 当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传? diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 2acf5e0a..98cf35b3 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -301,6 +301,11 @@ Grid lookup needs a QRZ cookie in Settings QRZ cookie expired - paste a fresh one in Settings Could not reach QRZ for the grid + Paste a cookie first + Cookie is not valid or has expired + Signed in as %1$s, grid %2$s + Signed in as %1$s, no grid on file + Could not reach QRZ Configure WaveLog in Settings first No log entries yet QTH grid (%1$s) does not match station grid (%2$s). Upload anyway? diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index d6418503..e6f1cb27 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -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(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