fix: Wavelog sync via v1 API logbook_get_worked_grids (3.0.2 has no API v2)

This commit is contained in:
atsunatsu committed 2026-09-07 02:11:40 +08:00
1 parent eb1a5d51a2
commit 13df0dfcec
4 files changed
+40 -36

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'
@@ -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>
@@ -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),
)