diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt index d08a6dbe..36e7d5b2 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt @@ -18,11 +18,15 @@ package com.rtbishop.look4sat.core.data.repository import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository +import com.rtbishop.look4sat.core.domain.repository.LoTWResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.net.HttpURLConnection +import java.net.SocketTimeoutException import java.net.URL import java.net.URLEncoder +import java.io.IOException +import javax.net.ssl.SSLException /** * Fetches confirmed gridsquares directly from ARRL LoTW via the official report endpoint: @@ -34,74 +38,50 @@ import java.net.URLEncoder * value; VUCC_GRIDS ("EN52en,EN53fa") also yields 4-char fields. All values are * truncated/expanded to the 4-char form used by the map overlay. * - * ARRL rate-limits the report endpoint (roughly once per hour per account), which is - * fine for a manual sync button. + * ARRL rate-limits the report endpoint: one download in progress per user id, and + * frequent full-report pulls get refused. Failures are reported with an explicit + * cause (see LoTWResult) so the UI can tell the user what to do next. */ class LoTWRepository : ILoTWRepository { - override suspend fun fetchConfirmedGrids(callsign: String, password: String): Set? = - withContext(Dispatchers.IO) { - val call = callsign.trim().uppercase() - val pwd = password.trim() - if (call.isBlank() || pwd.isBlank()) return@withContext null - // qso_qslsince with an early date forces a FULL confirmed-QSL report. - // Without it, LoTW applies a "system supplied default" since-date and - // only returns confirmations newer than the account's last query — - // subsequent syncs would return an empty/incremental report. - val since = "2000-01-01" - val query = buildString { - append("login=").append(URLEncoder.encode(call, "UTF-8")) - append("&password=").append(URLEncoder.encode(pwd, "UTF-8")) - append("&qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes") - append("&qso_qslsince=").append(URLEncoder.encode(since, "UTF-8")) - } - try { - val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection - // ARRL can be slow to accept connections from mobile networks - // (long TLS handshakes across the Pacific, occasional server-side - // queueing). 30s connect + 120s read gives the request enough - // headroom; the sync button stays disabled meanwhile so users - // see progress rather than a hung dialog. - connection.connectTimeout = 30_000 - connection.readTimeout = 120_000 - connection.requestMethod = "GET" - connection.setRequestProperty("Accept-Encoding", "gzip") - connection.instanceFollowRedirects = true - val code = connection.responseCode - if (code !in 200..299) { - connection.disconnect() - return@withContext null - } - val stream = connection.inputStream - val body = ("gzip".equals(connection.contentEncoding, ignoreCase = true)) - .let { gz -> if (gz) java.util.zip.GZIPInputStream(stream) else stream } - .bufferedReader().use { it.readText() } - connection.disconnect() - if (body.contains(" password=") && !body.startsWith("ARRL")) return@withContext null - parseConfirmedGrids(body) - } catch (e: Exception) { - println("LoTWRepository fetch failure: $e") - null - } - } + override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult = + fetchReportBody(callsign, password).fold( + onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) } + ?: LoTWResult.RateLimited }, + onFailure = { toResult(it) } + ) override suspend fun fetchConfirmedGridQsos( callsign: String, password: String - ): Pair, Map>>? { - // Single report fetch: grids are derived from the same body as the - // per-QSO detail (avoids a second ARRL hit and keeps the two datasets - // perfectly consistent). - val body = fetchReportBody(callsign, password) ?: return null + ): LoTWResult = fetchReportBody(callsign, password).fold( + onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) } + ?: LoTWResult.RateLimited }, + onFailure = { toResult(it) } + ) + + internal fun toResult(e: Throwable): LoTWResult = when (e) { + is CredentialsException -> LoTWResult.BadCredentials + is RateLimitException -> LoTWResult.RateLimited + is TimeoutException -> LoTWResult.Timeout + else -> LoTWResult.NetworkError(e.message ?: e.javaClass.simpleName) + } + + /** Single report fetch feeding both the grid set and the per-QSO detail. */ + private fun parseBoth(body: String): Pair, Map>>? { val grids = parseConfirmedGrids(body) ?: return null val qsos = parseConfirmedGridQsos(body) ?: return null return grids to qsos } - private fun fetchReportBody(callsign: String, password: String): String? { + private fun fetchReportBody(callsign: String, password: String): Result { val call = callsign.trim().uppercase() val pwd = password.trim() - if (call.isBlank() || pwd.isBlank()) return null + if (call.isBlank() || pwd.isBlank()) return Result.failure(IOException("empty credentials")) + // qso_qslsince with an early date forces a FULL confirmed-QSL report. + // Without it, LoTW applies a "system supplied default" since-date and + // only returns confirmations newer than the account's last query — + // subsequent syncs would return an empty/incremental report. val since = "2000-01-01" val query = buildString { append("login=").append(URLEncoder.encode(call, "UTF-8")) @@ -111,6 +91,11 @@ class LoTWRepository : ILoTWRepository { } return try { val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection + // ARRL can be slow to accept connections from mobile networks + // (long TLS handshakes across the Pacific, occasional server-side + // queueing). 30s connect + 120s read gives the request enough + // headroom; the sync button stays disabled meanwhile so users + // see progress rather than a hung dialog. connection.connectTimeout = 30_000 connection.readTimeout = 120_000 connection.requestMethod = "GET" @@ -119,21 +104,38 @@ class LoTWRepository : ILoTWRepository { val code = connection.responseCode if (code !in 200..299) { connection.disconnect() - return null + // Observed in the wild (CQRLOG #2422): LoTW answers a throttled + // report pull with HTTP 503 "Page request limit". + return if (code == 503 || code == 429) Result.failure(RateLimitException()) + else Result.failure(IOException("HTTP $code")) } val stream = connection.inputStream val body = ("gzip".equals(connection.contentEncoding, ignoreCase = true)) .let { gz -> if (gz) java.util.zip.GZIPInputStream(stream) else stream } .bufferedReader().use { it.readText() } connection.disconnect() - if (body.contains(" password=") && !body.startsWith("ARRL")) return null - body + when { + // Login failure: HTTP 200 + HTML login page with the error text. + body.contains("incorrect", ignoreCase = true) && + body.contains("assword", ignoreCase = true) -> Result.failure(CredentialsException()) + // Any other non-ADIF body: rate-limit refusal / server error page. + !body.contains("", ignoreCase = true) -> Result.failure(RateLimitException()) + else -> Result.success(body) + } + } catch (e: SocketTimeoutException) { + Result.failure(TimeoutException(e.message ?: "timed out")) + } catch (e: SSLException) { + Result.failure(IOException("TLS: ${e.message ?: "handshake failed"}")) } catch (e: Exception) { println("LoTWRepository fetch failure: $e") - null + Result.failure(IOException(e.message ?: e.javaClass.simpleName)) } } + internal class CredentialsException : Exception("bad callsign/password") + internal class RateLimitException : Exception("report refused (rate limit / server error)") + internal class TimeoutException(message: String) : Exception(message) + internal fun parseConfirmedGridQsos( body: String ): Map>? { diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt index 67453d0e..02577b16 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt @@ -1,5 +1,6 @@ package com.rtbishop.look4sat.core.data.repository +import com.rtbishop.look4sat.core.domain.repository.LoTWResult import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -128,4 +129,28 @@ class LoTWRepositoryTest { } // endregion + + // region failure classification (fetchReportBody -> LoTWResult mapping) + + @Test + fun failureClassificationMapsEachException() { + // LoTWRepository.toResult() must keep each failure cause distinct so + // the UI can show a specific message per cause. + assertEquals( + LoTWResult.BadCredentials, + repo.toResult(LoTWRepository.CredentialsException()) + ) + assertEquals( + LoTWResult.RateLimited, + repo.toResult(LoTWRepository.RateLimitException()) + ) + assertEquals( + LoTWResult.Timeout, + repo.toResult(LoTWRepository.TimeoutException("read timed out")) + ) + val net = repo.toResult(java.io.IOException("HTTP 500")) + assertEquals(LoTWResult.NetworkError("HTTP 500"), net) + } + + // endregion } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt index d78a547a..7a66cd4d 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt @@ -22,17 +22,17 @@ interface ILoTWRepository { /** * Fetch all confirmed (QSL_RCVD=Y) gridsquares for the given LoTW account. - * Returns the 4-char grid set, or null on any failure (network / bad credentials). + * Returns the 4-char grid set with an explicit failure cause on error. */ - suspend fun fetchConfirmedGrids(callsign: String, password: String): Set? + suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult /** * Same report, but keeps the per-QSO detail of every confirmed satellite * QSO (call / time / satellite / mode / bands), grouped by worked 4-char - * gridsquare. Returns null on any failure. + * gridsquare. Failure cause is kept explicit. */ suspend fun fetchConfirmedGridQsos( callsign: String, password: String - ): Pair, Map>>? + ): LoTWResult } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWResult.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWResult.kt new file mode 100644 index 00000000..46c266c2 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/LoTWResult.kt @@ -0,0 +1,45 @@ +/* + * 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.repository + +/** Result of a LoTW report fetch, with the failure cause kept explicit. */ +sealed class LoTWResult { + + /** Report downloaded and parsed successfully. */ + data class Success( + val grids: Set, + val qsos: Map> + ) : LoTWResult() + + /** HTTP 200 but LoTW replied with its login-error page (bad callsign/password). */ + data object BadCredentials : LoTWResult() + + /** + * Report endpoint refused the request without a usable error page. LoTW + * limits downloads to one in progress per user id and flags accounts that + * pull the full report too often; the server answers those with a bare + * non-ADIF body (no ). + */ + data object RateLimited : LoTWResult() + + /** Connect/read timed out — typically a slow route to the ARRL servers. */ + data object Timeout : LoTWResult() + + /** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */ + data class NetworkError(val detail: String) : LoTWResult() +} diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index 9d8fa30f..0e8a789b 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -201,18 +201,47 @@ class SettingsViewModel( settingsRepo.updateLoTWSettings(settings) _uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) } viewModelScope.launch { - val lotwResult = lotwRepo.fetchConfirmedGridQsos(settings.callsign, settings.password) - _uiState.update { state -> - if (lotwResult == null) { - state.copy(lotwSyncing = false, lotwMessage = "LoTW sync failed — check callsign/password/network") - } else { + when (val result = lotwRepo.fetchConfirmedGridQsos(settings.callsign, settings.password)) { + is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Success -> { // Wavelog entry removed: LoTW is now the only source, so the // synced set fully replaces the stored worked grids. - val (grids, qsos) = lotwResult - settingsRepo.setWorkedGrids(grids) - settingsRepo.setWorkedGridQsos(qsos) - state.copy(lotwSyncing = false, workedGridsCount = grids.size, lotwMessage = null) + settingsRepo.setWorkedGrids(result.grids) + settingsRepo.setWorkedGridQsos(result.qsos) + _uiState.update { state -> + state.copy( + lotwSyncing = false, workedGridsCount = result.grids.size, + lotwMessage = null + ) + } } + is com.rtbishop.look4sat.core.domain.repository.LoTWResult.BadCredentials -> + _uiState.update { state -> + state.copy( + lotwSyncing = false, + lotwMessage = "LoTW sync failed — callsign or password incorrect" + ) + } + is com.rtbishop.look4sat.core.domain.repository.LoTWResult.RateLimited -> + _uiState.update { state -> + state.copy( + lotwSyncing = false, + lotwMessage = "LoTW sync failed — rate limited by server, wait a few minutes and retry" + ) + } + is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Timeout -> + _uiState.update { state -> + state.copy( + lotwSyncing = false, + lotwMessage = "LoTW sync failed — connection timed out, try another network" + ) + } + is com.rtbishop.look4sat.core.domain.repository.LoTWResult.NetworkError -> + _uiState.update { state -> + state.copy( + lotwSyncing = false, + lotwMessage = "LoTW sync failed — network error (${result.detail})" + ) + } } } }