From fe6d0af8b7532e7e0455853eaee93070d8003906 Mon Sep 17 00:00:00 2001 From: QIU Date: Wed, 26 Aug 2026 00:21:11 +0000 Subject: [PATCH] fix: two regressions this rebuild introduced for non-APRS users Both found by an auditor comparing behaviour against the released build rather than against the intent of the change. Prefix-first portable callsigns were rejected. CallsignEntry took the first segment - `call.substringBefore('/')` - and required a letter and a digit in it. A portable call can be written prefix-first, DL/W1AW or ZL/JA1ABC or OH/W1AW/MM, where the leading token is a country prefix with no digit at all. Measured: five such forms were refused where the old length-only check had accepted them. Any segment may now carry the callsign. JSON cookie exports stopped working for QRZ. QrzGridParser.cookieHeader converts the JSON array a browser extension produces into a Cookie header, and it had zero production callers - the raw pasted text went straight into the header. The old client normalised it. So an operator whose export had been working would see their cookie sent as a literal JSON blob, QRZ would serve its signed-out page, and the app would tell them the cookie had expired when it was perfectly good. A raw `k=v; k=v` paste was unaffected, which is why this survived review. Both are cases where a rewrite lost behaviour the old code had. Neither had a test. --- .../look4sat/core/data/qrz/QrzGridSource.kt | 14 +++++++++++++- .../core/domain/wavelog/CallsignEntry.kt | 11 +++++++++-- .../core/domain/wavelog/CallsignEntryTest.kt | 17 +++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/qrz/QrzGridSource.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/qrz/QrzGridSource.kt index ae140e5a..8405ab1a 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/qrz/QrzGridSource.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/qrz/QrzGridSource.kt @@ -66,7 +66,19 @@ class QrzGridSource( } /** Fetch [url], retrying transport failures with backoff. Null when every attempt failed. */ - private suspend fun fetchWithRetry(url: String, cookieHeader: String): String? { + /** + * 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) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntry.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntry.kt index 71cb85f7..72a56817 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntry.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntry.kt @@ -92,8 +92,11 @@ object CallsignEntry { 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) - val core = call.substringBefore('/').substringBefore('-') - if (core.none { it.isDigit() } || core.none { it.isLetter() }) { + // 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 { @@ -104,6 +107,10 @@ object CallsignEntry { 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. * diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntryTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntryTest.kt index 40d54b22..bda092c2 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntryTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/CallsignEntryTest.kt @@ -17,6 +17,8 @@ class CallsignEntryTest { 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", @@ -65,6 +67,21 @@ class CallsignEntryTest { } /** 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(