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 new file mode 100644 index 00000000..ae140e5a --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/qrz/QrzGridSource.kt @@ -0,0 +1,100 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.data.qrz + +import com.rtbishop.look4sat.core.domain.qrz.QrzGrid +import com.rtbishop.look4sat.core.domain.qrz.QrzGridParser +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * Reads a station's Maidenhead locator off its QRZ.com page. + * + * QRZ has no free lookup API for this, so the page is fetched with the operator's own session + * cookie and parsed. The cookie is pasted by the operator in settings and never built into the + * app. Parsing lives in [QrzGridParser] so it can be tested without a network; this class only + * fetches and retries. + */ +class QrzGridSource( + private val httpClient: OkHttpClient, + private val dispatcher: CoroutineDispatcher +) { + + /** + * Look up [callsign]'s locator. + * + * Retried because this runs on a phone, mid-pass, often on mobile data - a single timeout + * used to mean the QSO was logged without a grid and the operator was never told. Retries + * are bounded and backed off so a genuinely unreachable QRZ costs at most a few seconds: + * only transport failures are retried, since a page that loaded and parsed will not parse + * differently on a second attempt. + */ + suspend fun lookupGrid(callsign: String, cookieHeader: String): QrzGrid = + withContext(dispatcher) { + if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext QrzGrid.SignedOut + val url = "$DB_URL${callsign.trim().uppercase()}" + fetchWithRetry(url, cookieHeader)?.let(QrzGridParser::parseGrid) + ?: QrzGrid.Unreachable(MAX_ATTEMPTS) + } + + /** + * The callsign the pasted cookie is signed in as, so settings can show the operator whose + * account it belongs to rather than just claiming success. + */ + suspend fun lookupOwnCallsign(cookieHeader: String): String? = withContext(dispatcher) { + if (cookieHeader.isBlank()) return@withContext null + fetchWithRetry(DB_URL, cookieHeader)?.let(QrzGridParser::parseOwnCallsign) + } + + /** Fetch [url], retrying transport failures with backoff. Null when every attempt failed. */ + private suspend fun fetchWithRetry(url: String, cookieHeader: String): String? { + repeat(MAX_ATTEMPTS) { attempt -> + try { + val request = Request.Builder().url(url) + .header("User-Agent", USER_AGENT) + .header("Cookie", cookieHeader) + .build() + httpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) return response.body.string() + // A 4xx will repeat identically, so only server-side faults are worth retrying. + if (response.code < 500) return null + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + println("QrzGridSource attempt ${attempt + 1} failed: $exception") + } + if (attempt < MAX_ATTEMPTS - 1) delay(BACKOFF_MS[attempt]) + } + return null + } + + private companion object { + /** Bare form is the signed-in home page; a callsign appended is that station's page. */ + const val DB_URL = "https://www.qrz.com/db/" + const val USER_AGENT = "Mozilla/5.0 (Linux; Android 13) Look4Sat" + const val MAX_ATTEMPTS = 3 + + /** Waits before the second and third attempt. Short enough to finish inside a pass. */ + val BACKOFF_MS = longArrayOf(700L, 2_000L) + } +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/QrzGrid.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/QrzGrid.kt new file mode 100644 index 00000000..90c58fe7 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/QrzGrid.kt @@ -0,0 +1,107 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.domain.qrz + +/** + * Outcome of a QRZ grid lookup. + * + * Four outcomes rather than a nullable string, because the previous null meant any of "the + * station has no grid on file", "the cookie expired", "the request timed out" and "QRZ changed + * its markup" - and the operator saw the same blank either way, with no way to tell that + * re-pasting the cookie would fix it. + */ +sealed interface QrzGrid { + + /** The station's Maidenhead locator, as QRZ has it. */ + data class Found(val locator: String) : QrzGrid + + /** The page was read and the station has no locator published. Not an error. */ + data object NotOnFile : QrzGrid + + /** The detail table was absent, which is what QRZ serves when the cookie is not valid. */ + data object SignedOut : QrzGrid + + /** The request never completed. [attempts] is how many tries were made before giving up. */ + data class Unreachable(val attempts: Int) : QrzGrid +} + +/** + * Parsing of QRZ's callsign page, separate from the fetch so it can be tested without a + * network. Pure string work over already-downloaded markup. + */ +object QrzGridParser { + + /** The detail row QRZ renders for a station that published a locator. */ + private val gridRow = Regex("""Grid Square\s*([^<]+)""") + + /** + * QRZ's own words on a callsign page served to a visitor who is not signed in. + * + * Classified on this positive notice rather than on the detail table being absent: measured + * against live responses, a callsign QRZ has never heard of also returns HTTP 200 with zero + * detail rows, because QRZ serves its search form instead of a callsign page. Keying on + * absence therefore reported a mistyped callsign as an expired cookie, and would have sent + * the operator off to re-paste a cookie that was never broken. + */ + private val signedOutNotice = Regex("""Login is required for additional detail""") + + /** The account menu on a signed-in page, used to read back whose cookie this is. */ + private val accountCallsign = Regex("""
  • ]*>\s*([A-Z0-9/]+)\s* + CallsignBG7NTA + Grid SquareOL72 + CountryChina + + """.trimIndent() + + /** Signed in, but this station has no Grid Square row at all. */ + private val withoutGrid = """ + + + +
    CallsignW1AW
    CountryUnited States
    + """.trimIndent() + + /** + * What QRZ actually serves for a real callsign when nobody is signed in: no detail table, + * plus its own notice saying why. Transcribed from a live response rather than invented. + */ + private val signedOut = """ +
    W1AW
    +
    Login is required for additional detail.
    +
    Email: Login required to view
    + """.trimIndent() + + /** + * A callsign QRZ has never heard of. Also HTTP 200, also zero detail rows, but no login + * notice - QRZ serves its search form instead of a callsign page. + */ + private val unknownCallsign = """ + + """.trimIndent() + + @Test + fun `reads the locator when the station published one`() { + assertEquals(QrzGrid.Found("OL72"), QrzGridParser.parseGrid(withGrid)) + } + + /** + * The distinction that matters: a station with no grid must not look like an expired + * cookie, because the fix for one is nothing and the fix for the other is re-pasting it. + */ + @Test + fun `a station with no grid is not confused with being signed out`() { + assertEquals(QrzGrid.NotOnFile, QrzGridParser.parseGrid(withoutGrid)) + assertEquals(QrzGrid.SignedOut, QrzGridParser.parseGrid(signedOut)) + } + + /** + * The cookie prompt must fire only when the cookie is the problem. Measured against live + * responses, a mistyped callsign returns a page with no detail rows either - so classifying + * on their absence would blame the cookie and send the operator off to re-paste a working + * one, mid-pass, over a satellite that is about to set. + */ + @Test + fun `an unknown callsign does not read as an expired cookie`() { + assertEquals(QrzGrid.NotOnFile, QrzGridParser.parseGrid(unknownCallsign)) + } + + @Test + fun `an empty grid cell counts as not on file`() { + val blank = withGrid.replace(">OL72<", "><") + assertEquals(QrzGrid.NotOnFile, QrzGridParser.parseGrid(blank)) + } + + @Test + fun `whitespace around the locator is trimmed`() { + val padded = withGrid.replace(">OL72<", "> OL72 <") + assertEquals(QrzGrid.Found("OL72"), QrzGridParser.parseGrid(padded)) + } + + /** Six-character locators are as common as four; nothing may truncate them. */ + @Test + fun `a six character locator survives intact`() { + val six = withGrid.replace(">OL72<", ">OL72ab<") + assertEquals(QrzGrid.Found("OL72ab"), QrzGridParser.parseGrid(six)) + } + + /** QRZ puts the two cells adjacent, but whitespace between them must not break the match. */ + @Test + fun `whitespace between the two table cells is tolerated`() { + val spaced = withGrid.replace( + "Grid SquareOL72", + "Grid Square\n OL72" + ) + assertEquals(QrzGrid.Found("OL72"), QrzGridParser.parseGrid(spaced)) + } + + @Test + fun `reads back which callsign the cookie belongs to`() { + val menu = """
  • BG7NTA
      """ + assertEquals("BG7NTA", QrzGridParser.parseOwnCallsign(menu)) + assertNull(QrzGridParser.parseOwnCallsign(signedOut)) + } + + @Test + fun `a raw cookie header is passed through unchanged`() { + val raw = "qz_userid=1266043; qz_sess=abc123" + assertEquals(raw, QrzGridParser.cookieHeader(raw)) + } + + /** The shape a cookie-export extension produces. */ + @Test + fun `a json cookie export is flattened into a header`() { + val json = """ + [{"domain":".qrz.com","name":"qz_userid","value":"1266043"}, + {"domain":".qrz.com","name":"qz_sess","value":"abc123"}] + """.trimIndent() + assertEquals("qz_userid=1266043; qz_sess=abc123", QrzGridParser.cookieHeader(json)) + } + + @Test + fun `an empty cookie yields an empty header`() { + assertEquals("", QrzGridParser.cookieHeader(" ")) + } + + /** Malformed JSON is handed over as-is rather than silently becoming empty. */ + @Test + fun `unparseable json is passed through rather than dropped`() { + val broken = """[{"nope":"nothing here"}]""" + assertEquals(broken, QrzGridParser.cookieHeader(broken)) + } +}