Compare commits

...
9 changed files with 97 additions and 66 deletions

No files matched your search

@@ -20,64 +20,68 @@ package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
/**
* Fetches the list of worked gridsquares from a self-hosted Wavelog instance.
*
* Uses the Wavelog API v2 endpoint (Wavelog >= 3.1.0):
* GET {base}/index.php/api/v2/lookup?grid=all
* Authorization: Bearer wl2_...
* Response: {"data": {"grids": ["JN47", "JO30"], "count": 2}, ...}
* Uses the Wavelog v1 API endpoint (verified against Wavelog 3.0.2):
* POST {base}/index.php/api/logbook_get_worked_grids
* Body: {"key": "<api key>", "logbook_id": "<logbook id>"}
* Response 201: JSON array of 4-char gridsquares, e.g. ["JN47","JO30"]
* (see Wavelog Api.php logbook_get_worked_grids / Api_model::get_grids_worked_in_logbook)
*
* Uses a plain HttpURLConnection (bearer auth header) — IRemoteSource does not
* support custom headers, and no feature module touches this class directly.
* The token field carries "<api_key>:<logbook_id>" so one credential line
* configures both values (the dialog explains this to the user).
*/
class WavelogRepository : IWavelogRepository {
/**
* Fetch worked grids. Returns the grid set, or null on any failure
* (network error, non-200, malformed response, bad token).
*/
override suspend fun fetchWorkedGrids(url: String, token: String): Set<String>? = withContext(Dispatchers.IO) {
if (url.isBlank() || token.isBlank()) return@withContext null
val parts = token.trim().split(":")
val apiKey = parts.getOrNull(0)?.trim().orEmpty()
val logbookId = parts.getOrNull(1)?.trim().orEmpty()
if (url.isBlank() || apiKey.isBlank() || logbookId.isBlank()) return@withContext null
val base = url.trim().trimEnd('/')
val requestUrl = "$base/index.php/api/v2/lookup?grid=all"
val requestUrl = "$base/index.php/api/logbook_get_worked_grids"
try {
// IRemoteSource.getNetworkStream does not support custom headers, so
// run the bearer-token request directly here via java.net (simple GET,
// no OkHttp dependency added to this module's public API).
val connection = java.net.URL(requestUrl).openConnection() as java.net.HttpURLConnection
connectionTimeouts(connection)
connection.setRequestProperty("Authorization", "Bearer ${token.trim()}")
connection.requestMethod = "GET"
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
connection.requestMethod = "POST"
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
val body = JSONObject().apply {
put("key", apiKey)
put("logbook_id", logbookId)
}.toString().toByteArray()
connection.outputStream.use { it.write(body) }
val code = connection.responseCode
if (code != 200) {
if (code !in 200..299) {
connection.disconnect()
return@withContext null
}
val body = connection.inputStream.bufferedReader().use { it.readText() }
val response = connection.inputStream.bufferedReader().use { it.readText() }
connection.disconnect()
parseWorkedGrids(body)
parseWorkedGrids(response)
} catch (e: Exception) {
println("WavelogRepository fetch failure: $e")
null
}
}
private fun connectionTimeouts(connection: java.net.HttpURLConnection) {
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
}
internal fun parseWorkedGrids(body: String): Set<String>? {
return try {
val obj = JSONObject(body)
val data = obj.optJSONObject("data") ?: return null
val grids = data.optJSONArray("grids") ?: return null
val array = when {
body.trimStart().startsWith("[") -> JSONArray(body)
else -> {
// Tolerate {"grids": [...]} wrappers too
JSONObject(body).optJSONArray("grids") ?: return null
}
}
val result = mutableSetOf<String>()
for (i in 0 until grids.length()) {
val grid = grids.optString(i, "").trim().uppercase()
for (i in 0 until array.length()) {
val grid = array.optString(i, "").trim().uppercase()
// Keep only well-formed 4-character Maidenhead squares (e.g. OL62)
if (grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R'
&& grid[2] in '0'..'9' && grid[3] in '0'..'9'
@@ -2,11 +2,12 @@ package com.rtbishop.look4sat.core.domain.utility
/**
* Compares two version strings of the form "<major>.<minor>.<patch>[-<build>]" used by the
* BA7OPF fork releases (e.g. "4.4.6-ba7opf.6" or "v4.4.6-ba7opf.7").
* BA7OPF fork releases (e.g. "4.4.6-ba7opf.6" or "v4.4.6-ba7opf.9.1").
*
* The comparison is done on the numeric version segments first; when the base versions are
* equal, the trailing build number (if any) decides. This lets a "4.4.6-ba7opf.7" release be
* detected as newer than "4.4.6-ba7opf.6" while "4.5.0" trumps any "4.4.x" build.
* equal, the trailing build numbers of the suffix decide. All numeric segments of the suffix
* are compared as a sequence, so multi-level suffixes work correctly:
* "4.4.6-ba7opf.9.1" > "4.4.6-ba7opf.9" > "4.4.6-ba7opf.8".
*/
object VersionComparator {
@@ -20,25 +21,28 @@ object VersionComparator {
val k = curr.first.getOrElse(i) { 0 }
if (c != k) return c > k
}
return cand.second > curr.second
val maxBuild = maxOf(cand.second.size, curr.second.size)
for (i in 0 until maxBuild) {
val c = cand.second.getOrElse(i) { 0 }
val k = curr.second.getOrElse(i) { 0 }
if (c != k) return c > k
}
return false
}
/**
* Parses a version string into (numeric base segments, trailing build number).
* "v4.4.6-ba7opf.7" -> ([4,4,6], 7); "4.4.6" -> ([4,4,6], 0); "4.4.6-ba7opf" -> ([4,4,6], 0).
* Parses a version string into (numeric base segments, numeric suffix segments).
* "v4.4.6-ba7opf.9.1" -> ([4,4,6], [9,1]); "4.4.6-ba7opf.8" -> ([4,4,6], [8]);
* "4.4.6" -> ([4,4,6], []); "4.4.6-ba7opf" -> ([4,4,6], []).
*/
fun parse(version: String): Pair<List<Int>, Int> {
fun parse(version: String): Pair<List<Int>, List<Int>> {
val cleaned = version.trim().removePrefix("v")
val dashIndex = cleaned.indexOf('-')
val basePart = if (dashIndex >= 0) cleaned.substring(0, dashIndex) else cleaned
val suffixPart = if (dashIndex >= 0) cleaned.substring(dashIndex + 1) else ""
val base = basePart.split('.').mapNotNull { it.toIntOrNull() }
// The build number is the trailing numeric segment of the suffix (e.g. "ba7opf.7" -> 7).
val build = suffixPart
.split('.')
.lastOrNull()
?.toIntOrNull()
?: 0
// All numeric segments of the suffix, in order (e.g. "ba7opf.9.1" -> [9, 1]).
val build = suffixPart.split('.').mapNotNull { it.toIntOrNull() }
return base to build
}
}
@@ -40,9 +40,19 @@ class VersionComparatorTest {
}
@Test
fun `parse extracts base and build number`() {
assertEquals(listOf(4, 4, 6) to 7, VersionComparator.parse("v4.4.6-ba7opf.7"))
assertEquals(listOf(4, 4, 6) to 0, VersionComparator.parse("4.4.6"))
assertEquals(listOf(4, 4, 6) to 0, VersionComparator.parse("4.4.6-ba7opf"))
fun `multi level suffixes compare correctly`() {
// The .9.1 hotfix case that exposed the old single-build-number logic.
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.9.1", "4.4.6-ba7opf.9"))
assertTrue(VersionComparator.isNewer("v4.4.6-ba7opf.9.1", "4.4.6-ba7opf.8"))
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.9", "4.4.6-ba7opf.9.1"))
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.10", "4.4.6-ba7opf.9.1"))
}
@Test
fun `parse extracts base and suffix segments`() {
assertEquals(listOf(4, 4, 6) to listOf(7), VersionComparator.parse("v4.4.6-ba7opf.7"))
assertEquals(listOf(4, 4, 6) to listOf(9, 1), VersionComparator.parse("v4.4.6-ba7opf.9.1"))
assertEquals(listOf(4, 4, 6) to emptyList<Int>(), VersionComparator.parse("4.4.6"))
assertEquals(listOf(4, 4, 6) to emptyList<Int>(), VersionComparator.parse("4.4.6-ba7opf"))
}
}
@@ -144,8 +144,8 @@
<string name="prefs_wavelog_configured">已从 Wavelog 同步 — 通联 %1$d 个网格</string>
<string name="prefs_wavelog_not_configured">未配置 — 连接你的 Wavelog 日志以在地图上高亮已通联网格</string>
<string name="prefs_wavelog_url">Wavelog 地址</string>
<string name="prefs_wavelog_token">API 令牌</string>
<string name="prefs_wavelog_hint">已同步网格数:%1$d。请先保存,再点同步。</string>
<string name="prefs_wavelog_token">API key :日志 ID</string>
<string name="prefs_wavelog_hint">已同步网格数:%1$d。格式:API key:日志ID。请先保存,再点同步。</string>
<string name="prefs_wavelog_sync">立即同步</string>
<string name="prefs_wavelog_syncing">同步中…</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
@@ -179,8 +179,8 @@
<string name="prefs_wavelog_configured">Synced from Wavelog — %1$d grids worked</string>
<string name="prefs_wavelog_not_configured">Not configured — connect your Wavelog logbook to highlight worked grids on the map</string>
<string name="prefs_wavelog_url">Wavelog URL</string>
<string name="prefs_wavelog_token">API token</string>
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Save first, then tap Sync.</string>
<string name="prefs_wavelog_token">API key : logbook ID</string>
<string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Save first, then tap Sync.</string>
<string name="prefs_wavelog_sync">Sync now</string>
<string name="prefs_wavelog_syncing">Syncing…</string>
<string name="prefs_locator_text">Set station\'s position using locator</string>
@@ -25,6 +25,8 @@ import org.osmdroid.views.Projection
import org.osmdroid.views.overlay.Overlay
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
/**
* Maidenhead locator grid overlay, drawn directly on the map canvas.
@@ -71,10 +73,10 @@ class MaidenheadGridOverlay : Overlay() {
// Visible bounding box in geographic coordinates
val north = projection.fromPixels(0, 0)
val south = projection.fromPixels(canvas.width, canvas.height)
val topLat = north.latitude.coerceIn(-90.0, 90.0)
val bottomLat = south.latitude.coerceIn(-90.0, 90.0)
val leftLon = south.longitude
val rightLon = north.longitude
val topLat = max(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
val bottomLat = min(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
val leftLon = min(north.longitude, south.longitude)
val rightLon = max(north.longitude, south.longitude)
val firstRow = floor(bottomLat / cellLat).toInt()
val lastRow = ceil(topLat / cellLat).toInt()
@@ -82,6 +84,7 @@ class MaidenheadGridOverlay : Overlay() {
// crosses the antimeridian; index arithmetic still works (PMxx at 360° == same grid)
val firstCol = floor(leftLon / cellLon).toInt()
val lastCol = ceil(rightLon / cellLon).toInt()
val centerLon = (leftLon + rightLon) / 2.0
// Worked-grid highlight fills (only meaningful at sub-square zoom)
if (workedGrids.isNotEmpty() && zoom >= GRID_ZOOM_SUB) {
@@ -95,8 +98,8 @@ class MaidenheadGridOverlay : Overlay() {
if (yTop == null || yBottom == null) continue
for (col in firstCol..lastCol) {
val lon = col * cellLon
val xLeft = projectionToX(projection, lon) ?: continue
val xRight = projectionToX(projection, lon + cellLon) ?: continue
val xLeft = projectionToX(projection, lon, centerLon) ?: continue
val xRight = projectionToX(projection, lon + cellLon, centerLon) ?: continue
if (xRight < 0f || xLeft > canvas.width) continue
if (cellLabel(lat, lon, zoom) in workedGrids) {
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
@@ -108,7 +111,7 @@ class MaidenheadGridOverlay : Overlay() {
// Vertical lines (meridians)
for (col in firstCol..lastCol) {
val lon = col * cellLon
val x = projectionToX(projection, lon)
val x = projectionToX(projection, lon, centerLon)
if (x == null) continue
canvas.drawLine(x, 0f, x, canvas.height.toFloat(), linePaint)
}
@@ -147,7 +150,7 @@ class MaidenheadGridOverlay : Overlay() {
if (y < -labelPaint.textSize || y > canvas.height) continue
for (col in firstCol..lastCol) {
val lon = col * cellLon
val x = projectionToX(projection, lon) ?: continue
val x = projectionToX(projection, lon, centerLon) ?: continue
if (x < -200f || x > canvas.width) continue
val label = cellLabel(lat, lon, zoom)
canvas.drawText(label, x + labelOffset, y + labelPaint.textSize + labelOffset, labelPaint)
@@ -156,8 +159,16 @@ class MaidenheadGridOverlay : Overlay() {
}
/** X pixel for a longitude (meridians are vertical in Web Mercator). */
private fun projectionToX(projection: Projection, lon: Double): Float? {
val geo = org.osmdroid.util.GeoPoint(0.0, lon)
private fun projectionToX(projection: Projection, lon: Double, centerLon: Double): Float? {
// osmdroid clips Mercator X to [0, mapSize], which destroys the projection of
// longitudes outside the [0, 360) window of the current view (visible at low
// zoom where the whole world is narrower than the viewport). Normalizing the
// longitude to the equivalent value closest to the view center keeps every
// meridian's X near the center, safely inside the clip window.
var normalized = lon
while (normalized < centerLon - 180.0) normalized += 360.0
while (normalized > centerLon + 180.0) normalized -= 360.0
val geo = org.osmdroid.util.GeoPoint(0.0, normalized)
val p = projection.toPixels(geo, null)
return if (p.x in -MAX_OVERSHOOT_PX..MAX_OVERSHOOT_PX + 4096) p.x.toFloat() else null
}
@@ -173,7 +173,9 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
view.invalidate()
}
uiState.mapData?.let { mapData ->
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
if (!uiState.isGridMode) {
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
}
}
}
}
@@ -188,7 +188,7 @@ fun WavelogDialog(
value = token.value,
onValueChange = { token.value = it },
label = { Text(text = stringResource(id = R.string.prefs_wavelog_token)) },
placeholder = { Text(text = "wl2_...") },
placeholder = { Text(text = "abcdef123456:1") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "452"
appVersionCode = "454"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.6-ba7opf.9"
appVersionName = "4.4.6-ba7opf.9.2"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry