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