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)
}
}
}