mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-03 11:17:25 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd3eded77c | ||
|
|
7d7f8f1eda | ||
|
|
11b46c6710 | ||
|
|
da1facac64 | ||
|
|
cb1f341ed9 | ||
|
|
c2e496c406 | ||
|
|
7afce7ee68 | ||
|
|
86048ee1fd | ||
|
|
42a6ffa4c9 | ||
|
|
cf1d8e6539 | ||
|
|
ab8b072f1f | ||
|
|
1d4036e3d0 | ||
|
|
ab22244f86 |
No files matched your search
+62
-56
@@ -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,54 @@ 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<String>? =
|
||||
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
|
||||
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
|
||||
}
|
||||
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<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>>? {
|
||||
// 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 = withContext(Dispatchers.IO) {
|
||||
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<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>>? {
|
||||
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<String> {
|
||||
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 +95,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 +108,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("<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) {
|
||||
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<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>? {
|
||||
|
||||
+25
@@ -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
|
||||
}
|
||||
+4
-4
@@ -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<String>?
|
||||
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<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()
|
||||
}
|
||||
+122
-36
@@ -87,36 +87,32 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
val cellLat = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LAT else FIELD_LAT
|
||||
val cellLon = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LON else FIELD_LON
|
||||
|
||||
// Visible bounding box in geographic coordinates
|
||||
// Visible bounding box. Do NOT derive longitude bounds from the left/
|
||||
// right edge pixels: osmdroid normalizes them to [-180,180), which both
|
||||
// garbles antimeridian-crossing views AND makes any global view (span
|
||||
// > 180°) look like an antimeridian crossing — unwrapping then produced
|
||||
// an inverted range and ALL meridians vanished at low zoom. Instead
|
||||
// anchor on the view-center longitude and expand by the half-width in
|
||||
// degrees; the range is continuous (may exceed ±180) and cell indices
|
||||
// beyond 18 wrap correctly via normalizeLon().
|
||||
val north = projection.fromPixels(0, 0)
|
||||
val south = projection.fromPixels(canvas.width, canvas.height)
|
||||
val topLat = max(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
|
||||
val bottomLat = min(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
|
||||
// Longitude: osmdroid normalizes to [-180, 180), so when the view
|
||||
// straddles the antimeridian (e.g. left edge 170°E, right edge 170°W)
|
||||
// the raw min/max swap sides and the bounding box spans the wrong way
|
||||
// (center on the Pacific → grid painted 180° off-screen). Unwrap by
|
||||
// shifting one edge by +360° so left < right again.
|
||||
var lonA = north.longitude
|
||||
var lonB = south.longitude
|
||||
if (lonA > lonB) {
|
||||
val t = lonA; lonA = lonB; lonB = t
|
||||
}
|
||||
if (lonB - lonA > 180.0) lonA += 360.0
|
||||
val leftLon = lonA
|
||||
val rightLon = lonB
|
||||
val worldWidthPx = 256.0 * Math.pow(2.0, zoom)
|
||||
val viewCenter = projection.fromPixels(canvas.width / 2, canvas.height / 2)
|
||||
val centerLon = viewCenter.longitude
|
||||
val halfSpanDeg = (canvas.width / 2.0) / worldWidthPx * 360.0
|
||||
val leftLon = centerLon - halfSpanDeg
|
||||
val rightLon = centerLon + halfSpanDeg
|
||||
|
||||
val firstRow = floor(bottomLat / cellLat).toInt()
|
||||
val lastRow = ceil(topLat / cellLat).toInt()
|
||||
// Longitude cell indices may exceed the [-180, 180) range when the view
|
||||
// crosses the antimeridian; index arithmetic still works (PMxx at 360° == same grid)
|
||||
// Longitude cell indices are in the continuous unwrapped space and may
|
||||
// exceed the [-180, 180) range when the view crosses the antimeridian
|
||||
// or spans world repeats; labels normalize each cell back.
|
||||
val firstCol = floor(leftLon / cellLon).toInt()
|
||||
val lastCol = ceil(rightLon / cellLon).toInt()
|
||||
val centerLon = (leftLon + rightLon) / 2.0
|
||||
// World width in screen pixels for the current zoom (256 px per tile,
|
||||
// 2^zoom tiles across the whole 360° world). Used by the custom
|
||||
// Mercator-X computation in projectionToX.
|
||||
val worldWidthPx = 256.0 * Math.pow(2.0, zoom)
|
||||
// At low zoom the world is narrower than the viewport and osmdroid shows
|
||||
// repeating copies on both sides. The visible bounding box spans more
|
||||
// than 360° of longitude there; extend the column range by whole world
|
||||
@@ -146,8 +142,13 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
if (yTop == null || yBottom == null) continue
|
||||
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
// World-repeat copies: keep the turn offset in pixels
|
||||
// (colRepeats is 0 at this zoom today, but the same
|
||||
// alpha-stacking fix applies if it ever becomes > 0).
|
||||
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
if (cellLabel(lat, lon, zoom) in workedGrids) {
|
||||
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
|
||||
@@ -157,14 +158,22 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
} else {
|
||||
for (grid in workedGrids) {
|
||||
val cell = gridCellBounds(grid) ?: continue
|
||||
// World-repeat copies: a 360° turn shifts the cell by a
|
||||
// full world width in pixels. projectionToX() normalizes
|
||||
// longitude back into [-180,180), so WITHOUT adding the
|
||||
// turn*worldWidthPx offset every copy lands on the SAME
|
||||
// screen x and the cell gets painted 2*colRepeats+1 times
|
||||
// in place — alpha stacks and the green turns brighter and
|
||||
// more opaque. Keep the world-width offset on the x.
|
||||
for (turn in -colRepeats..colRepeats) {
|
||||
// Shift the cell by whole world turns to cover repeats.
|
||||
val dLon = turn * 360.0
|
||||
if (cell.lonRight + dLon <= leftLon || cell.lonLeft + dLon >= rightLon) continue
|
||||
val yTop = projectionToY(projection, cell.latTop) ?: continue
|
||||
val yBottom = projectionToY(projection, cell.latBottom) ?: continue
|
||||
val xLeft = projectionToX(projection, cell.lonLeft + dLon, centerLon, worldWidthPx) ?: continue
|
||||
val xRight = projectionToX(projection, cell.lonRight + dLon, centerLon, worldWidthPx) ?: continue
|
||||
val xLeftBase = projectionToX(projection, cell.lonLeft, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, cell.lonRight, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
if (yBottom < 0f || yTop > canvas.height) continue
|
||||
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
|
||||
@@ -173,20 +182,93 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical lines (meridians)
|
||||
// Vertical lines (meridians), segmented per sub-square row so that
|
||||
// worked grid cells stay line-free at BOTH zoom levels. At sub-square
|
||||
// zoom cellLat == SUB_SQUARE_LAT so each segment is one cell edge; at
|
||||
// field zoom a single 10° cell row spans ten 1° sub-rows, and the
|
||||
// worked cells are 2°x1° squares, so we must iterate the finer
|
||||
// sub-square grid to decide which field-line segments to skip.
|
||||
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
val x = projectionToX(projection, lon, centerLon, worldWidthPx)
|
||||
if (x == null) continue
|
||||
canvas.drawLine(x, 0f, x, canvas.height.toFloat(), linePaint)
|
||||
// World-repeat copies: keep the turn offset in pixels, otherwise
|
||||
// every turn normalizes to the same x and the line paints over
|
||||
// itself 2*colRepeats+1 times (alpha stacking).
|
||||
val xBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val x = xBase + turn * worldWidthPx.toFloat()
|
||||
for (row in firstRow..lastRow) {
|
||||
val lat = row * cellLat
|
||||
if (lat < -90.0 || lat >= 90.0) continue
|
||||
val topLatCell = lat + cellLat
|
||||
if (topLatCell > 90.0) continue
|
||||
val yTop = projectionToY(projection, topLatCell) ?: continue
|
||||
val yBottom = projectionToY(projection, lat) ?: continue
|
||||
if (cellLon == SUB_SQUARE_LON && cellLat == SUB_SQUARE_LAT) {
|
||||
// Sub-square zoom: one segment == one cell edge.
|
||||
val workedHere =
|
||||
cellLabel(lat, lon, zoom) in workedGrids ||
|
||||
cellLabel(lat, lon - cellLon, zoom) in workedGrids
|
||||
if (workedHere) continue
|
||||
canvas.drawLine(x, yTop, x, yBottom, linePaint)
|
||||
} else {
|
||||
// Field zoom: split the 10° row into 1° sub-rows and skip
|
||||
// the segments bordering a worked 2°x1° square.
|
||||
for (sub in 0 until (cellLat / SUB_SQUARE_LAT).toInt()) {
|
||||
val subLat = lat + sub * SUB_SQUARE_LAT
|
||||
val ySubTop = projectionToY(projection, subLat + SUB_SQUARE_LAT) ?: continue
|
||||
val ySubBottom = projectionToY(projection, subLat) ?: continue
|
||||
// Force 4-char labels: at field zoom cellLabel(zoom)
|
||||
// returns 2-char fields which never match workedGrids.
|
||||
val workedHere =
|
||||
cellLabel(subLat, lon, GRID_ZOOM_SUB) in workedGrids ||
|
||||
cellLabel(subLat, lon - SUB_SQUARE_LON, GRID_ZOOM_SUB) in workedGrids
|
||||
if (workedHere) continue
|
||||
canvas.drawLine(x, ySubTop, x, ySubBottom, linePaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Horizontal lines (parallels)
|
||||
// Horizontal lines (parallels), segmented per sub-square column so
|
||||
// worked cells have no lines through them at either zoom level.
|
||||
for (row in firstRow..lastRow) {
|
||||
val lat = row * cellLat
|
||||
if (lat <= -90.0 || lat >= 90.0) continue
|
||||
val y = projectionToY(projection, lat)
|
||||
if (y == null) continue
|
||||
canvas.drawLine(0f, y, canvas.width.toFloat(), y, linePaint)
|
||||
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
// World-repeat copies: keep the turn offset in pixels (same
|
||||
// alpha-stacking fix as the vertical lines above).
|
||||
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
if (cellLon == SUB_SQUARE_LON && cellLat == SUB_SQUARE_LAT) {
|
||||
// Sub-square zoom: one segment == one cell edge.
|
||||
val workedHere =
|
||||
cellLabel(lat, lon, zoom) in workedGrids ||
|
||||
cellLabel(lat - cellLat, lon, zoom) in workedGrids
|
||||
if (workedHere) continue
|
||||
canvas.drawLine(xLeft, y, xRight, y, linePaint)
|
||||
} else {
|
||||
// Field zoom: split the 20° field column into 2° sub-columns
|
||||
// and skip the segments bordering a worked 2°x1° square.
|
||||
for (sub in 0 until (cellLon / SUB_SQUARE_LON).toInt()) {
|
||||
val subLon = lon + sub * SUB_SQUARE_LON
|
||||
val xSubLeftBase = projectionToX(projection, subLon, centerLon, worldWidthPx) ?: continue
|
||||
val xSubRightBase = projectionToX(projection, subLon + SUB_SQUARE_LON, centerLon, worldWidthPx) ?: continue
|
||||
val xSubLeft = xSubLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xSubRight = xSubRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xSubRight < 0f || xSubLeft > canvas.width) continue
|
||||
// Force 4-char labels (see vertical lines above).
|
||||
val workedHere =
|
||||
cellLabel(lat, subLon, GRID_ZOOM_SUB) in workedGrids ||
|
||||
cellLabel(lat - SUB_SQUARE_LAT, subLon, GRID_ZOOM_SUB) in workedGrids
|
||||
if (workedHere) continue
|
||||
canvas.drawLine(xSubLeft, y, xSubRight, y, linePaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The station's own grid square: redraw its four borders thicker on top.
|
||||
@@ -241,8 +323,12 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
if (yBottom < 0f || yTop > canvas.height) continue
|
||||
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
// World-repeat copies: keep the turn offset in pixels (same
|
||||
// alpha-stacking fix as the lines above).
|
||||
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
val label = cellLabel(lat, lon, zoom)
|
||||
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
|
||||
@@ -329,8 +415,8 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
// on-screen size of each square.
|
||||
const val GRID_ZOOM_SUB = 6.0
|
||||
// Zoom at which the 4-char sub-square names appear (one level above the
|
||||
// grid lines: zoom 6 = lines only, zoom 7+ = lines + names).
|
||||
const val LABEL_ZOOM_SUB = 7.0
|
||||
// grid lines: zoom 6 = lines only, zoom 6.5+ = lines + names).
|
||||
const val LABEL_ZOOM_SUB = 6.5
|
||||
const val MIN_LABEL_CELL_PX = 48f
|
||||
const val MAX_OVERSHOOT_PX = 64
|
||||
}
|
||||
|
||||
@@ -153,7 +153,9 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
|
||||
if (!isGridMode || p == null) return false
|
||||
val zoom = mapView.zoomLevelDouble
|
||||
if (zoom < MaidenheadGridOverlay.LABEL_ZOOM_SUB) return false
|
||||
// Allow tapping worked cells as soon as the 4-char grid LINES
|
||||
// appear (GRID_ZOOM_SUB), not only when labels show (LABEL_ZOOM_SUB).
|
||||
if (zoom < MaidenheadGridOverlay.GRID_ZOOM_SUB) return false
|
||||
val grid = gridOfPoint(p.latitude, p.longitude) ?: return false
|
||||
if (grid !in workedGrids) return false
|
||||
selectedGrid = grid
|
||||
@@ -345,12 +347,13 @@ private fun WorkedGridCallRow(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 3.dp)
|
||||
)
|
||||
// Subsequent QSOs in this grid, revealed on expand.
|
||||
// Subsequent QSOs in this grid, revealed on expand. No ordinal prefix;
|
||||
// each line carries its own date (the first QSO's date already sits in
|
||||
// the header row).
|
||||
if (expanded && callQsos.size > 1) {
|
||||
callQsos.drop(1).forEachIndexed { index, qso ->
|
||||
val ordinal = "${index + 2}"
|
||||
callQsos.drop(1).forEach { qso ->
|
||||
Text(
|
||||
text = "$ordinal " + qsoSummary(qso, isUtc),
|
||||
text = formatDate(qso.epochMs, isUtc) + " · " + qsoSummary(qso, isUtc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 3.dp)
|
||||
@@ -503,10 +506,14 @@ private fun setGridMode(
|
||||
val pos = stationPosition ?: return
|
||||
val lat = pos.latitude
|
||||
val lon = pos.longitude
|
||||
// Maidenhead field indices: field 0 spans -90..-80° lat and
|
||||
// -180..-160° lon, so the field-center must be offset by those
|
||||
// bases — omitting them maps e.g. OL62's field center to
|
||||
// 115°N/290°E (an ocean a hemisphere away) instead of 25°N/110°E.
|
||||
val fieldLat = ((lat + 90.0) / 10.0).toInt().coerceIn(0, 17)
|
||||
val fieldLon = ((lon + 180.0) / 20.0).toInt().coerceIn(0, 17)
|
||||
val centerLat = fieldLat * 10.0 + 5.0
|
||||
val centerLon = fieldLon * 20.0 + 10.0
|
||||
val centerLat = fieldLat * 10.0 + 5.0 - 90.0
|
||||
val centerLon = fieldLon * 20.0 + 10.0 - 180.0
|
||||
mapView.post { mapView.controller.setCenter(GeoPoint(centerLat, centerLon)) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
+38
-9
@@ -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})"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "459"
|
||||
appVersionCode = "462"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.6-ba7opf.9.8"
|
||||
appVersionName = "4.4.6-ba7opf.9.11"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user