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 Square | XXX | .
- * 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