fix(wavelog): a 200 is not an acceptance
WaveLogApi decided an upload had succeeded from the HTTP status alone. Wavelog validates
after responding, so a rejected QSO comes back as 200 with `{"status":"failed","reason":
"..."}` - and the uploader then called markUploaded and dropped it from the queue. The
contact was lost and the operator was told the upload succeeded.
Response shapes are transcribed from the Wavelog API reference, not guessed: success is
`status: success` or `successful`, a duplicate is `status: dupe` with a 200, failures are
`status: failed` with `reason` or `status: error` with `message`.
WavelogResponse reads the body. Four outcomes: accepted and duplicate both clear the
queue entry, because the log holds the QSO either way; rejected keeps it and surfaces the
server's own explanation; and a status field we cannot recognise also keeps it, since
costing a retry beats losing a contact. Parsed as text rather than with JSONObject because
org.json is compileOnly in core:domain and a JVM test would otherwise assert against a
stub. Whitespace around separators is collapsed before matching - a first attempt listed
spacings and missed `{ "status" : "failed" }`, which a probe caught.
Two other things in the same area.
The ten-minute auto-upload loop is gone. It retried the queue in the background with no
way to tell the operator anything: a grid mismatch was swallowed by an empty if block and
every other failure retried silently forever. A QSO that cannot be uploaded now waits for
a manual upload from settings, where the result is actually shown.
The upload path no longer builds user-facing text in Kotlin. UploadOutcome carried a
pre-formatted Chinese string, so the message ignored the device language whatever the
locale files said. It now reports a Reason the view model maps to resources, which needed
a format-argument overload on IShowToast to get a count into a localised message.
This commit is contained in:
1 parent
fe6d0af8b7
commit
4567f46867
10 files changed
+392
-39
No files matched your search
@@ -29,4 +29,8 @@ class ShowToast(private val context: Context) : IShowToast {
|
|||||||
override fun invoke(resId: Int) {
|
override fun invoke(resId: Int) {
|
||||||
invoke(context.getString(resId))
|
invoke(context.getString(resId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun invoke(resId: Int, vararg formatArgs: Any) {
|
||||||
|
invoke(context.getString(resId, *formatArgs))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -22,4 +22,12 @@ interface IShowToast {
|
|||||||
|
|
||||||
/** Show by resource ID (four-language text) */
|
/** Show by resource ID (four-language text) */
|
||||||
operator fun invoke(resId: Int)
|
operator fun invoke(resId: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a resource with format arguments, so a count can appear in a localised message.
|
||||||
|
*
|
||||||
|
* The alternative is building the string in a view model, which puts wording outside the
|
||||||
|
* resource files and hardcodes one language.
|
||||||
|
*/
|
||||||
|
operator fun invoke(resId: Int, vararg formatArgs: Any)
|
||||||
}
|
}
|
||||||
@@ -238,9 +238,18 @@ object WaveLogApi {
|
|||||||
put("sat_name", satName)
|
put("sat_name", satName)
|
||||||
if (satMode.isNotBlank()) put("sat_mode", satMode)
|
if (satMode.isNotBlank()) put("sat_mode", satMode)
|
||||||
}
|
}
|
||||||
|
// The body decides, not the status code: Wavelog validates after responding, so a rejected
|
||||||
|
// QSO arrives as HTTP 200 with {"status":"failed"}. Trusting the code marked it uploaded
|
||||||
|
// and dropped it from the queue.
|
||||||
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
|
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
|
||||||
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
|
when (val verdict = WavelogResponse.verdict(code, resp)) {
|
||||||
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
|
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v2")
|
||||||
|
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
|
||||||
|
is WavelogResponse.Verdict.Rejected ->
|
||||||
|
return@withContext WavelogResult.Failure(verdict.reason)
|
||||||
|
// Unreadable falls through to v1: an older server may not have the v2 endpoint at all.
|
||||||
|
is WavelogResponse.Verdict.Unreadable -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
// v1: POST /index.php/api/qso (key in body + ADIF)
|
// v1: POST /index.php/api/qso (key in body + ADIF)
|
||||||
val v1Body = JSONObject().apply {
|
val v1Body = JSONObject().apply {
|
||||||
@@ -250,13 +259,29 @@ object WaveLogApi {
|
|||||||
put("string", toAdif(qso, gridsquare, satName))
|
put("string", toAdif(qso, gridsquare, satName))
|
||||||
}
|
}
|
||||||
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
|
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
|
||||||
if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
when (val verdict = WavelogResponse.verdict(code1, resp1)) {
|
||||||
|
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1")
|
||||||
|
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
|
||||||
|
is WavelogResponse.Verdict.Rejected ->
|
||||||
|
return@withContext WavelogResult.Failure(verdict.reason)
|
||||||
|
is WavelogResponse.Verdict.Unreadable -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
// v1 without index.php
|
// v1 without index.php, for a server whose rewrite rules differ
|
||||||
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
|
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
|
||||||
if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
when (val verdict = WavelogResponse.verdict(code1b, resp1b)) {
|
||||||
|
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1")
|
||||||
|
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
|
||||||
|
is WavelogResponse.Verdict.Rejected ->
|
||||||
|
return@withContext WavelogResult.Failure(verdict.reason)
|
||||||
|
is WavelogResponse.Verdict.Unreadable -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}")
|
// Every endpoint answered something we could not read. Keeping the QSO queued is the only
|
||||||
|
// honest outcome: it may have been stored, and dropping it would lose the contact.
|
||||||
|
WavelogResult.Failure(
|
||||||
|
"unreadable response: v2 HTTP $code, v1 HTTP $code1 - ${shortError(resp1.ifBlank { resp1b })}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
|
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
|
||||||
|
|||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
/*
|
||||||
|
* 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.wavelog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether Wavelog actually accepted a QSO.
|
||||||
|
*
|
||||||
|
* The status code is not the answer. Wavelog validates after responding 200 and reports the
|
||||||
|
* outcome in the body, so a rejected QSO arrives as HTTP 200 with `{"status":"failed"}`. Trusting
|
||||||
|
* the code alone marked it uploaded and dropped it from the queue - the same class of defect as
|
||||||
|
* the APRS reporter claiming a send succeeded when nothing had left the phone.
|
||||||
|
*
|
||||||
|
* Parsed as text rather than with JSONObject on purpose: org.json is compileOnly in core:domain,
|
||||||
|
* so a JVM unit test gets the stub and every assertion against it would be vacuous.
|
||||||
|
*/
|
||||||
|
object WavelogResponse {
|
||||||
|
|
||||||
|
/** What the server said about one QSO. */
|
||||||
|
sealed interface Verdict {
|
||||||
|
|
||||||
|
/** Stored. Safe to drop from the queue. */
|
||||||
|
data class Accepted(val detail: String) : Verdict
|
||||||
|
|
||||||
|
/** Wavelog already has it. Also safe to drop - the log is correct either way. */
|
||||||
|
data object Duplicate : Verdict
|
||||||
|
|
||||||
|
/** Rejected. [reason] carries the server's own wording when it gave one. */
|
||||||
|
data class Rejected(val reason: String) : Verdict
|
||||||
|
|
||||||
|
/** Unreadable, so keep the QSO queued rather than guess in either direction. */
|
||||||
|
data class Unreadable(val detail: String) : Verdict
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpret a response.
|
||||||
|
*
|
||||||
|
* A body that reports failure beats a success code, because that is exactly the case the code
|
||||||
|
* alone gets wrong. An empty body with a success code counts as accepted, since the v1
|
||||||
|
* endpoint answers that way.
|
||||||
|
*/
|
||||||
|
fun verdict(statusCode: Int, body: String): Verdict {
|
||||||
|
val text = body.trim()
|
||||||
|
// Whitespace around separators is collapsed before matching, rather than listing every
|
||||||
|
// spacing a server might use: `{ "status" : "failed" }` is as valid as
|
||||||
|
// `{"status":"failed"}` and enumerating the combinations is endless.
|
||||||
|
val lower = text.lowercase().replace(AROUND_SEPARATORS, "")
|
||||||
|
// Duplicate reported three ways: a 409, the word in a message, or Wavelog's own
|
||||||
|
// `{"status":"dupe"}` - which arrives with HTTP 200 and is documented, not guessed.
|
||||||
|
if (statusCode == DUPLICATE_CODE ||
|
||||||
|
DUPLICATE_MARKERS.any { lower.contains(it) }
|
||||||
|
) {
|
||||||
|
return Verdict.Duplicate
|
||||||
|
}
|
||||||
|
if (statusCode !in SUCCESS_CODES) {
|
||||||
|
return Verdict.Rejected(reasonFrom(text).ifBlank { "HTTP $statusCode" })
|
||||||
|
}
|
||||||
|
if (FAILURE_MARKERS.any { lower.contains(it) }) {
|
||||||
|
return Verdict.Rejected(reasonFrom(text).ifBlank { "server reported failure" })
|
||||||
|
}
|
||||||
|
// A status field we cannot read is not a success. Saying so keeps the QSO queued.
|
||||||
|
if (lower.contains("\"status\"") && SUCCESS_MARKERS.none { lower.contains(it) }) {
|
||||||
|
return Verdict.Unreadable(text.take(MAX_DETAIL))
|
||||||
|
}
|
||||||
|
return Verdict.Accepted(text.take(MAX_DETAIL))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's own explanation, when it gave one.
|
||||||
|
*
|
||||||
|
* Reads `reason`, `message` or `error` out of the JSON by hand. Crude, but it only has to work
|
||||||
|
* well enough to show the operator something more useful than a status code.
|
||||||
|
*/
|
||||||
|
private fun reasonFrom(body: String): String {
|
||||||
|
for (key in REASON_KEYS) {
|
||||||
|
Regex("\"$key\"\\s*:\\s*\"([^\"]*)\"").find(body)
|
||||||
|
?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { return it }
|
||||||
|
}
|
||||||
|
// An array of messages, which the v1 endpoint returns for a malformed ADIF record.
|
||||||
|
Regex("\"messages\"\\s*:\\s*\\[\\s*\"([^\"]*)\"").find(body)
|
||||||
|
?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { return it }
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SUCCESS_CODES = 200..299
|
||||||
|
private const val DUPLICATE_CODE = 409
|
||||||
|
private const val MAX_DETAIL = 200
|
||||||
|
|
||||||
|
/** Matched against the whitespace-collapsed body, so one spelling of each suffices. */
|
||||||
|
private val FAILURE_MARKERS = listOf(
|
||||||
|
"\"status\":\"failed\"",
|
||||||
|
"\"status\":\"error\"",
|
||||||
|
"\"result\":\"failed\""
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wavelog's documented success wordings, plus the ones its other endpoints use.
|
||||||
|
*
|
||||||
|
* `successful` matters as much as `success`: the API reference uses both, and treating one as
|
||||||
|
* unrecognised would leave a stored QSO queued forever.
|
||||||
|
*/
|
||||||
|
private val SUCCESS_MARKERS = listOf(
|
||||||
|
"\"status\":\"success\"",
|
||||||
|
"\"status\":\"successful\"",
|
||||||
|
"\"status\":\"created\"",
|
||||||
|
"\"status\":\"ok\""
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `dupe` is Wavelog's own wording and arrives with a 200. */
|
||||||
|
private val DUPLICATE_MARKERS = listOf("\"status\":\"dupe\"", "duplicate")
|
||||||
|
|
||||||
|
/** Whitespace next to a colon or comma, which JSON allows and servers use inconsistently. */
|
||||||
|
private val AROUND_SEPARATORS = Regex("""\s*(?=[:,])|(?<=[:,])\s*""")
|
||||||
|
|
||||||
|
private val REASON_KEYS = listOf("reason", "message", "error")
|
||||||
|
}
|
||||||
+24
-5
@@ -13,12 +13,32 @@ import org.json.JSONObject
|
|||||||
|
|
||||||
sealed class UploadOutcome {
|
sealed class UploadOutcome {
|
||||||
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
|
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The upload finished. [failedCount] entries stay in the queue.
|
||||||
|
*
|
||||||
|
* [reason] is machine-readable so the UI can pick its own wording; [firstError] carries the
|
||||||
|
* server's own explanation for the first failure, which is worth showing verbatim because it
|
||||||
|
* is the only thing that says WHY Wavelog refused a QSO.
|
||||||
|
*/
|
||||||
data class Done(
|
data class Done(
|
||||||
val successCount: Int,
|
val successCount: Int,
|
||||||
val failedCount: Int,
|
val failedCount: Int,
|
||||||
val message: String,
|
val reason: Reason = Reason.COMPLETED,
|
||||||
val firstError: String = ""
|
val firstError: String = ""
|
||||||
) : UploadOutcome()
|
) : UploadOutcome()
|
||||||
|
|
||||||
|
/** Why an upload ended, for the UI to phrase. */
|
||||||
|
enum class Reason {
|
||||||
|
/** Ran to completion. Check the counts. */
|
||||||
|
COMPLETED,
|
||||||
|
|
||||||
|
/** No server, key or station id configured. */
|
||||||
|
NOT_CONFIGURED,
|
||||||
|
|
||||||
|
/** The station profile could not be read - wrong id, or a key without permission. */
|
||||||
|
NO_STATION_INFO
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class WavelogUploader(
|
class WavelogUploader(
|
||||||
@@ -36,13 +56,13 @@ class WavelogUploader(
|
|||||||
val apiKey = settings.wavelogApiKey
|
val apiKey = settings.wavelogApiKey
|
||||||
val stationId = settings.wavelogStationId
|
val stationId = settings.wavelogStationId
|
||||||
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
|
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
|
||||||
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
|
return UploadOutcome.Done(0, queue.all().size, UploadOutcome.Reason.NOT_CONFIGURED)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Fetch station info (station grid); fall back to user QTH when v1 lacks the endpoint
|
// 1. Fetch station info (station grid); fall back to user QTH when v1 lacks the endpoint
|
||||||
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
|
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
|
||||||
if (stationGrid.isNullOrBlank()) {
|
if (stationGrid.isNullOrBlank()) {
|
||||||
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
|
return UploadOutcome.Done(0, queue.all().size, UploadOutcome.Reason.NO_STATION_INFO)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Grid check: cloud station grid first 4 chars vs current station QTH first 4 chars
|
// 2. Grid check: cloud station grid first 4 chars vs current station QTH first 4 chars
|
||||||
@@ -70,8 +90,7 @@ class WavelogUploader(
|
|||||||
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
|
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
|
return UploadOutcome.Done(ok, fail, UploadOutcome.Reason.COMPLETED, firstError)
|
||||||
return UploadOutcome.Done(ok, fail, message, firstError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
|
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
|
||||||
|
|||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule these pin down: a 200 is not an acceptance.
|
||||||
|
*
|
||||||
|
* Wavelog validates after responding and reports the outcome in the body, so a rejected QSO comes
|
||||||
|
* back as HTTP 200 with `{"status":"failed"}`. The uploader trusted the code alone, marked the QSO
|
||||||
|
* uploaded and dropped it from the queue - the same failure as the APRS reporter claiming a send
|
||||||
|
* succeeded when nothing had left the phone.
|
||||||
|
*
|
||||||
|
* The response shapes are transcribed from the Wavelog API reference at
|
||||||
|
* docs.wavelog.org/developer/api, not invented.
|
||||||
|
*/
|
||||||
|
class WavelogResponseTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a success body is accepted`() {
|
||||||
|
assertTrue(
|
||||||
|
WavelogResponse.verdict(201, """{"status":"success","message":"1 locations imported."}""")
|
||||||
|
is WavelogResponse.Verdict.Accepted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reference uses both spellings; treating one as unknown would requeue a stored QSO. */
|
||||||
|
@Test
|
||||||
|
fun `successful is recognised as well as success`() {
|
||||||
|
assertTrue(
|
||||||
|
WavelogResponse.verdict(200, """{"status":"successful","members":[]}""")
|
||||||
|
is WavelogResponse.Verdict.Accepted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The defect this class exists for. */
|
||||||
|
@Test
|
||||||
|
fun `a failure body beats a success code`() {
|
||||||
|
val verdict = WavelogResponse.verdict(200, """{"status":"failed","reason":"No club members found"}""")
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Rejected("No club members found"),
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an error status with a message is rejected with that message`() {
|
||||||
|
val verdict = WavelogResponse.verdict(401, """{"status":"error","message":"Auth Error, invalid key"}""")
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Rejected("Auth Error, invalid key"),
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wavelog's own duplicate wording, which arrives with a 200 rather than a 409. */
|
||||||
|
@Test
|
||||||
|
fun `a dupe status is a duplicate not a failure`() {
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Duplicate,
|
||||||
|
WavelogResponse.verdict(200, """{"status":"dupe","message":"0 locations imported."}""")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a 409 is a duplicate whatever the body says`() {
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Duplicate,
|
||||||
|
WavelogResponse.verdict(409, "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A duplicate is safe to drop from the queue: the log holds the QSO either way. */
|
||||||
|
@Test
|
||||||
|
fun `a duplicate is not treated as an error`() {
|
||||||
|
val verdict = WavelogResponse.verdict(200, """{"status":"dupe"}""")
|
||||||
|
assertTrue(verdict !is WavelogResponse.Verdict.Rejected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The v1 endpoint answers an accepted ADIF record with a success code and nothing else. */
|
||||||
|
@Test
|
||||||
|
fun `an empty body with a success code is accepted`() {
|
||||||
|
assertTrue(
|
||||||
|
WavelogResponse.verdict(200, "") is WavelogResponse.Verdict.Accepted
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
WavelogResponse.verdict(200, " ") is WavelogResponse.Verdict.Accepted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An unrecognised status must not read as success. Keeping the QSO queued costs a retry;
|
||||||
|
* dropping it loses the contact.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an unrecognised status keeps the QSO queued`() {
|
||||||
|
val verdict = WavelogResponse.verdict(200, """{"status":"something-new"}""")
|
||||||
|
assertTrue("got $verdict", verdict is WavelogResponse.Verdict.Unreadable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a non-success code without a body reports the code`() {
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Rejected("HTTP 500"),
|
||||||
|
WavelogResponse.verdict(500, "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whitespace in the JSON must not change the verdict - servers format differently. */
|
||||||
|
@Test
|
||||||
|
fun `spacing in the json does not change the verdict`() {
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Rejected("nope"),
|
||||||
|
WavelogResponse.verdict(200, """{ "status" : "failed" , "reason" : "nope" }""")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A messages array, which the ADIF endpoint returns for a malformed record. */
|
||||||
|
@Test
|
||||||
|
fun `a messages array supplies the reason`() {
|
||||||
|
val verdict = WavelogResponse.verdict(200, """{"status":"failed","messages":["Bad ADIF field"]}""")
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Rejected("Bad ADIF field"),
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case must not matter: the marker comparison lower-cases the body. */
|
||||||
|
@Test
|
||||||
|
fun `an upper case status is still understood`() {
|
||||||
|
assertTrue(
|
||||||
|
WavelogResponse.verdict(200, """{"STATUS":"FAILED","reason":"x"}""")
|
||||||
|
is WavelogResponse.Verdict.Rejected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A failure with no explanation still has to say something usable. */
|
||||||
|
@Test
|
||||||
|
fun `a failure without a reason still reports one`() {
|
||||||
|
val verdict = WavelogResponse.verdict(200, """{"status":"failed"}""")
|
||||||
|
assertEquals(
|
||||||
|
WavelogResponse.Verdict.Rejected("server reported failure"),
|
||||||
|
verdict
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -261,6 +261,13 @@
|
|||||||
<string name="wavelog_call_hint">对方呼号</string>
|
<string name="wavelog_call_hint">对方呼号</string>
|
||||||
<string name="wavelog_mode_hint">模式</string>
|
<string name="wavelog_mode_hint">模式</string>
|
||||||
<string name="wavelog_saved">已存入本地日志</string>
|
<string name="wavelog_saved">已存入本地日志</string>
|
||||||
|
<string name="wavelog_upload_done">已上传 %1$d 条, %2$d 条仍在队列</string>
|
||||||
|
<string name="wavelog_upload_all_ok">已上传 %1$d 条</string>
|
||||||
|
<string name="wavelog_not_configured">未配置 WaveLog 服务器</string>
|
||||||
|
<string name="wavelog_no_station">读不到站点信息 - 请检查站点 ID 与密钥权限</string>
|
||||||
|
<string name="wavelog_upload_failed">上传失败</string>
|
||||||
|
<string name="lotw_list_updated">LoTW 卫星列表已更新: %1$d 个</string>
|
||||||
|
<string name="lotw_list_failed">LoTW 列表更新失败: %1$s</string>
|
||||||
<string name="log_call_too_short">呼号太短</string>
|
<string name="log_call_too_short">呼号太短</string>
|
||||||
<string name="log_call_illegal">呼号里不能有这个字符</string>
|
<string name="log_call_illegal">呼号里不能有这个字符</string>
|
||||||
<string name="log_call_not_a_call">这看起来不像呼号</string>
|
<string name="log_call_not_a_call">这看起来不像呼号</string>
|
||||||
|
|||||||
@@ -292,6 +292,13 @@
|
|||||||
<string name="wavelog_call_hint">Callsign</string>
|
<string name="wavelog_call_hint">Callsign</string>
|
||||||
<string name="wavelog_mode_hint">Mode</string>
|
<string name="wavelog_mode_hint">Mode</string>
|
||||||
<string name="wavelog_saved">Saved to local log</string>
|
<string name="wavelog_saved">Saved to local log</string>
|
||||||
|
<string name="wavelog_upload_done">Uploaded %1$d, %2$d still queued</string>
|
||||||
|
<string name="wavelog_upload_all_ok">Uploaded %1$d QSO</string>
|
||||||
|
<string name="wavelog_not_configured">WaveLog server not configured</string>
|
||||||
|
<string name="wavelog_no_station">Cannot read the station profile - check the station ID and key permissions</string>
|
||||||
|
<string name="wavelog_upload_failed">Upload failed</string>
|
||||||
|
<string name="lotw_list_updated">LoTW satellite list updated: %1$d entries</string>
|
||||||
|
<string name="lotw_list_failed">LoTW list update failed: %1$s</string>
|
||||||
<string name="log_call_too_short">Callsign too short</string>
|
<string name="log_call_too_short">Callsign too short</string>
|
||||||
<string name="log_call_illegal">A callsign cannot contain that</string>
|
<string name="log_call_illegal">A callsign cannot contain that</string>
|
||||||
<string name="log_call_not_a_call">That does not look like a callsign</string>
|
<string name="log_call_not_a_call">That does not look like a callsign</string>
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ import com.rtbishop.look4sat.core.domain.qrz.QrzGrid
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
|
|
||||||
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
@@ -128,21 +127,11 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
|||||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||||
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
||||||
}
|
}
|
||||||
// WaveLog (4.5.2): auto-upload - retries the local queue every 10 min (when the switch is on)
|
// Auto-upload happens when a QSO is saved, not on a timer. The ten-minute polling loop that
|
||||||
val wavelogUploader = remember { container.provideWavelogUploader() }
|
// used to live here uploaded in the background with no way to tell the operator what happened:
|
||||||
LaunchedEffect(Unit) {
|
// a grid mismatch was skipped by an empty if, and any other failure just retried silently
|
||||||
while (true) {
|
// forever. A QSO that cannot be uploaded now stays in the queue for a manual upload from
|
||||||
delay(10 * 60 * 1000L)
|
// settings, where the result is actually shown.
|
||||||
val s = container.settingsRepo.otherSettings.value
|
|
||||||
if (s.wavelogAutoUpload && s.wavelogUrl.isNotBlank()) {
|
|
||||||
// Grid mismatch is skipped silently (left for manual-upload confirmation); other failures retry on the next tick
|
|
||||||
val outcome = wavelogUploader.uploadQueue()
|
|
||||||
if (outcome is UploadOutcome.NeedConfirm) {
|
|
||||||
// Skip this run; the user can upload and confirm manually in settings
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RadarScreen(
|
RadarScreen(
|
||||||
uiState,
|
uiState,
|
||||||
viewModel::onAction,
|
viewModel::onAction,
|
||||||
|
|||||||
+28
-12
@@ -275,9 +275,11 @@ class SettingsViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (val r = lotwRepo.refresh()) {
|
when (val r = lotwRepo.refresh()) {
|
||||||
is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Ok ->
|
is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Ok ->
|
||||||
showToast("LoTW 卫星列表已更新: ${r.count} 个")
|
showToast(R.string.lotw_list_updated, r.count)
|
||||||
is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Error ->
|
is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Error ->
|
||||||
wavelogError = "LoTW 卫星列表更新失败: ${r.message}"
|
// Server's own words only: the dialog supplies the framing, so the wording
|
||||||
|
// stays in the resource files rather than being built here in one language.
|
||||||
|
wavelogError = r.message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,21 +288,35 @@ class SettingsViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = pendingUploader?.uploadQueue()
|
val result = pendingUploader?.uploadQueue()
|
||||||
when (result) {
|
when (result) {
|
||||||
is UploadOutcome.Done -> {
|
is UploadOutcome.Done -> reportUpload(result)
|
||||||
if (result.failedCount > 0 && result.firstError.isNotBlank()) {
|
|
||||||
wavelogError = result.firstError
|
|
||||||
} else {
|
|
||||||
showToast("WaveLog: ${result.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is UploadOutcome.NeedConfirm -> {
|
is UploadOutcome.NeedConfirm -> {
|
||||||
gridConfirm = GridConfirmData(result.stationGrid, result.userGrid)
|
gridConfirm = GridConfirmData(result.stationGrid, result.userGrid)
|
||||||
}
|
}
|
||||||
null -> showToast("WaveLog: 上传失败")
|
null -> showToast(R.string.wavelog_upload_failed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report an upload outcome, keeping the wording in the resource files.
|
||||||
|
*
|
||||||
|
* The server's own explanation goes to [wavelogError] rather than a Toast, because it is the
|
||||||
|
* only thing that says why Wavelog refused a QSO and a Toast is gone in three seconds.
|
||||||
|
*/
|
||||||
|
private fun reportUpload(result: UploadOutcome.Done) {
|
||||||
|
when {
|
||||||
|
result.failedCount > 0 && result.firstError.isNotBlank() ->
|
||||||
|
wavelogError = result.firstError
|
||||||
|
result.reason == UploadOutcome.Reason.NOT_CONFIGURED ->
|
||||||
|
showToast(R.string.wavelog_not_configured)
|
||||||
|
result.reason == UploadOutcome.Reason.NO_STATION_INFO ->
|
||||||
|
showToast(R.string.wavelog_no_station)
|
||||||
|
result.failedCount > 0 ->
|
||||||
|
showToast(R.string.wavelog_upload_done, result.successCount, result.failedCount)
|
||||||
|
else -> showToast(R.string.wavelog_upload_all_ok, result.successCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Grid-confirm result: ignore and upload / cancel */
|
/** Grid-confirm result: ignore and upload / cancel */
|
||||||
fun resolveGridConfirm(ignoreAndUpload: Boolean) {
|
fun resolveGridConfirm(ignoreAndUpload: Boolean) {
|
||||||
val confirm = gridConfirm ?: return
|
val confirm = gridConfirm ?: return
|
||||||
@@ -309,8 +325,8 @@ class SettingsViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = pendingUploader?.uploadQueue(force = true)
|
val result = pendingUploader?.uploadQueue(force = true)
|
||||||
when (result) {
|
when (result) {
|
||||||
is UploadOutcome.Done -> showToast("WaveLog: ${result.message}")
|
is UploadOutcome.Done -> reportUpload(result)
|
||||||
else -> showToast("WaveLog: 上传失败")
|
else -> showToast(R.string.wavelog_upload_failed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user