mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
LoTW sync: distinguish failure causes (bad credentials / rate limit / timeout / network)
Rate limit classification per ARRL policy (one download in progress per user id; HTTP 503 'Page request limit' observed on throttled pulls) and the HTML login-error page returned for wrong credentials. Settings screen now shows a specific message per cause instead of one generic 'check callsign/password/network'.
This commit is contained in:
1 parent
ab8b072f1f
commit
cf1d8e6539
5 files changed
+172
-71
No files matched your search
+60
-58
@@ -18,11 +18,15 @@
|
|||||||
package com.rtbishop.look4sat.core.data.repository
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.net.URLEncoder
|
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:
|
* 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
|
* 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.
|
* 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
|
* ARRL rate-limits the report endpoint: one download in progress per user id, and
|
||||||
* fine for a manual sync button.
|
* 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 {
|
class LoTWRepository : ILoTWRepository {
|
||||||
|
|
||||||
override suspend fun fetchConfirmedGrids(callsign: String, password: String): Set<String>? =
|
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
|
||||||
withContext(Dispatchers.IO) {
|
fetchReportBody(callsign, password).fold(
|
||||||
val call = callsign.trim().uppercase()
|
onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) }
|
||||||
val pwd = password.trim()
|
?: LoTWResult.RateLimited },
|
||||||
if (call.isBlank() || pwd.isBlank()) return@withContext null
|
onFailure = { toResult(it) }
|
||||||
// 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 fetchConfirmedGridQsos(
|
override suspend fun fetchConfirmedGridQsos(
|
||||||
callsign: String,
|
callsign: String,
|
||||||
password: String
|
password: String
|
||||||
): Pair<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>>? {
|
): LoTWResult = fetchReportBody(callsign, password).fold(
|
||||||
// Single report fetch: grids are derived from the same body as the
|
onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) }
|
||||||
// per-QSO detail (avoids a second ARRL hit and keeps the two datasets
|
?: LoTWResult.RateLimited },
|
||||||
// perfectly consistent).
|
onFailure = { toResult(it) }
|
||||||
val body = fetchReportBody(callsign, password) ?: return null
|
)
|
||||||
|
|
||||||
|
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<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>>? {
|
||||||
val grids = parseConfirmedGrids(body) ?: return null
|
val grids = parseConfirmedGrids(body) ?: return null
|
||||||
val qsos = parseConfirmedGridQsos(body) ?: return null
|
val qsos = parseConfirmedGridQsos(body) ?: return null
|
||||||
return grids to qsos
|
return grids to qsos
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchReportBody(callsign: String, password: String): String? {
|
private fun fetchReportBody(callsign: String, password: String): Result<String> {
|
||||||
val call = callsign.trim().uppercase()
|
val call = callsign.trim().uppercase()
|
||||||
val pwd = password.trim()
|
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 since = "2000-01-01"
|
||||||
val query = buildString {
|
val query = buildString {
|
||||||
append("login=").append(URLEncoder.encode(call, "UTF-8"))
|
append("login=").append(URLEncoder.encode(call, "UTF-8"))
|
||||||
@@ -111,6 +91,11 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
}
|
}
|
||||||
return try {
|
return try {
|
||||||
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
|
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.connectTimeout = 30_000
|
||||||
connection.readTimeout = 120_000
|
connection.readTimeout = 120_000
|
||||||
connection.requestMethod = "GET"
|
connection.requestMethod = "GET"
|
||||||
@@ -119,21 +104,38 @@ class LoTWRepository : ILoTWRepository {
|
|||||||
val code = connection.responseCode
|
val code = connection.responseCode
|
||||||
if (code !in 200..299) {
|
if (code !in 200..299) {
|
||||||
connection.disconnect()
|
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 stream = connection.inputStream
|
||||||
val body = ("gzip".equals(connection.contentEncoding, ignoreCase = true))
|
val body = ("gzip".equals(connection.contentEncoding, ignoreCase = true))
|
||||||
.let { gz -> if (gz) java.util.zip.GZIPInputStream(stream) else stream }
|
.let { gz -> if (gz) java.util.zip.GZIPInputStream(stream) else stream }
|
||||||
.bufferedReader().use { it.readText() }
|
.bufferedReader().use { it.readText() }
|
||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
if (body.contains(" password=") && !body.startsWith("ARRL")) return null
|
when {
|
||||||
body
|
// 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("<eoh>", 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) {
|
} catch (e: Exception) {
|
||||||
println("LoTWRepository fetch failure: $e")
|
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(
|
internal fun parseConfirmedGridQsos(
|
||||||
body: String
|
body: String
|
||||||
): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>? {
|
): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>? {
|
||||||
|
|||||||
+25
@@ -1,5 +1,6 @@
|
|||||||
package com.rtbishop.look4sat.core.data.repository
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -128,4 +129,28 @@ class LoTWRepositoryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// endregion
|
// 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
|
||||||
}
|
}
|
||||||
+4
-4
@@ -22,17 +22,17 @@ interface ILoTWRepository {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all confirmed (QSL_RCVD=Y) gridsquares for the given LoTW account.
|
* 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<String>?
|
suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same report, but keeps the per-QSO detail of every confirmed satellite
|
* Same report, but keeps the per-QSO detail of every confirmed satellite
|
||||||
* QSO (call / time / satellite / mode / bands), grouped by worked 4-char
|
* 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(
|
suspend fun fetchConfirmedGridQsos(
|
||||||
callsign: String,
|
callsign: String,
|
||||||
password: String
|
password: String
|
||||||
): Pair<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>>?
|
): LoTWResult
|
||||||
}
|
}
|
||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
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<String>,
|
||||||
|
val qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
|
||||||
|
) : 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 <eoh>).
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
}
|
||||||
+38
-9
@@ -201,18 +201,47 @@ class SettingsViewModel(
|
|||||||
settingsRepo.updateLoTWSettings(settings)
|
settingsRepo.updateLoTWSettings(settings)
|
||||||
_uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) }
|
_uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val lotwResult = lotwRepo.fetchConfirmedGridQsos(settings.callsign, settings.password)
|
when (val result = lotwRepo.fetchConfirmedGridQsos(settings.callsign, settings.password)) {
|
||||||
_uiState.update { state ->
|
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Success -> {
|
||||||
if (lotwResult == null) {
|
|
||||||
state.copy(lotwSyncing = false, lotwMessage = "LoTW sync failed — check callsign/password/network")
|
|
||||||
} else {
|
|
||||||
// Wavelog entry removed: LoTW is now the only source, so the
|
// Wavelog entry removed: LoTW is now the only source, so the
|
||||||
// synced set fully replaces the stored worked grids.
|
// synced set fully replaces the stored worked grids.
|
||||||
val (grids, qsos) = lotwResult
|
settingsRepo.setWorkedGrids(result.grids)
|
||||||
settingsRepo.setWorkedGrids(grids)
|
settingsRepo.setWorkedGridQsos(result.qsos)
|
||||||
settingsRepo.setWorkedGridQsos(qsos)
|
_uiState.update { state ->
|
||||||
state.copy(lotwSyncing = false, workedGridsCount = grids.size, lotwMessage = null)
|
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})"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user