diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/ShowToast.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/ShowToast.kt index 83f7e2ff..29b20ee6 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/ShowToast.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/usecase/ShowToast.kt @@ -29,4 +29,8 @@ class ShowToast(private val context: Context) : IShowToast { override fun invoke(resId: Int) { invoke(context.getString(resId)) } + + override fun invoke(resId: Int, vararg formatArgs: Any) { + invoke(context.getString(resId, *formatArgs)) + } } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IShowToast.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IShowToast.kt index 983fad50..9f0c6e17 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IShowToast.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/usecase/IShowToast.kt @@ -22,4 +22,12 @@ interface IShowToast { /** Show by resource ID (four-language text) */ 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) } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt index de6c20a4..cef7bbe3 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WaveLogApi.kt @@ -238,9 +238,18 @@ object WaveLogApi { put("sat_name", satName) 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()) - if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)") - if (code == 409) return@withContext WavelogResult.Success("重复(已存在)") + when (val verdict = WavelogResponse.verdict(code, resp)) { + 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) val v1Body = JSONObject().apply { @@ -250,13 +259,29 @@ object WaveLogApi { put("string", toAdif(qso, gridsquare, satName)) } 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()) - 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) */ diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponse.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponse.kt new file mode 100644 index 00000000..aa6af9a2 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponse.kt @@ -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 . + */ +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") +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt index 0bcf8908..61581cf9 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogUploader.kt @@ -13,12 +13,32 @@ import org.json.JSONObject sealed class 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( val successCount: Int, val failedCount: Int, - val message: String, + val reason: Reason = Reason.COMPLETED, val firstError: String = "" ) : 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( @@ -36,13 +56,13 @@ class WavelogUploader( val apiKey = settings.wavelogApiKey val stationId = settings.wavelogStationId 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 val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid() 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 @@ -70,8 +90,7 @@ class WavelogUploader( if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: "" } } - val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)" - return UploadOutcome.Done(ok, fail, message, firstError) + return UploadOutcome.Done(ok, fail, UploadOutcome.Reason.COMPLETED, firstError) } private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? { diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponseTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponseTest.kt new file mode 100644 index 00000000..db11bb8c --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogResponseTest.kt @@ -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 + ) + } +} diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index eb922022..c627b671 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -261,6 +261,13 @@ 对方呼号 模式 已存入本地日志 + 已上传 %1$d 条, %2$d 条仍在队列 + 已上传 %1$d 条 + 未配置 WaveLog 服务器 + 读不到站点信息 - 请检查站点 ID 与密钥权限 + 上传失败 + LoTW 卫星列表已更新: %1$d 个 + LoTW 列表更新失败: %1$s 呼号太短 呼号里不能有这个字符 这看起来不像呼号 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 98cf35b3..8a28ca34 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -292,6 +292,13 @@ Callsign Mode Saved to local log + Uploaded %1$d, %2$d still queued + Uploaded %1$d QSO + WaveLog server not configured + Cannot read the station profile - check the station ID and key permissions + Upload failed + LoTW satellite list updated: %1$d entries + LoTW list update failed: %1$s Callsign too short A callsign cannot contain that That does not look like a callsign diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index fcac2636..963b22cb 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -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.repository.IContainerProvider 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.utility.DopplerFrequencyCalculator import com.rtbishop.look4sat.core.domain.utility.toDegrees @@ -128,21 +127,11 @@ fun RadarDestination(navigateUp: () -> Unit) { viewModel.onAction(RadarAction.SstvPermissionResult(granted)) viewModel.onAction(RadarAction.CwPermissionResult(granted)) } - // WaveLog (4.5.2): auto-upload - retries the local queue every 10 min (when the switch is on) - val wavelogUploader = remember { container.provideWavelogUploader() } - LaunchedEffect(Unit) { - while (true) { - delay(10 * 60 * 1000L) - 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 - } - } - } - } + // Auto-upload happens when a QSO is saved, not on a timer. The ten-minute polling loop that + // used to live here uploaded in the background with no way to tell the operator what happened: + // a grid mismatch was skipped by an empty if, and any other failure just retried silently + // forever. A QSO that cannot be uploaded now stays in the queue for a manual upload from + // settings, where the result is actually shown. RadarScreen( uiState, viewModel::onAction, diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index 1b52a007..adc82c0e 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -275,9 +275,11 @@ class SettingsViewModel( viewModelScope.launch { when (val r = lotwRepo.refresh()) { 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 -> - 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 { val result = pendingUploader?.uploadQueue() when (result) { - is UploadOutcome.Done -> { - if (result.failedCount > 0 && result.firstError.isNotBlank()) { - wavelogError = result.firstError - } else { - showToast("WaveLog: ${result.message}") - } - } + is UploadOutcome.Done -> reportUpload(result) is UploadOutcome.NeedConfirm -> { 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 */ fun resolveGridConfirm(ignoreAndUpload: Boolean) { val confirm = gridConfirm ?: return @@ -309,8 +325,8 @@ class SettingsViewModel( viewModelScope.launch { val result = pendingUploader?.uploadQueue(force = true) when (result) { - is UploadOutcome.Done -> showToast("WaveLog: ${result.message}") - else -> showToast("WaveLog: 上传失败") + is UploadOutcome.Done -> reportUpload(result) + else -> showToast(R.string.wavelog_upload_failed) } } }