From 0e767f42506a9c33ae76b2405ef2c66ec8562724 Mon Sep 17 00:00:00 2001 From: QIU Date: Wed, 5 Aug 2026 06:23:47 +0000 Subject: [PATCH] feat(wavelog): QRZ grid lookup, LoTW satellite list refresh, RST 59 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-prioritized WaveLog fixes (4.5.5, commit-only per instruction): - QRZ 对方网格爬虫: QrzGridClient (domain, pure JVM) fetches https://www.qrz.com/db/{call} with user-supplied cookies (parses EditThisCookie JSON or raw "k=v; k=v"), extracts Grid Square from the Detail table. Cookies NEVER built in - entered in settings. - Settings: WaveLog card top-right gear opens QRZ cookie dialog with test query button (detects logged-in callsign from cookie, looks up its grid, shows result or failure in the dialog). - LogTab: on Enter, async lookup of the other station's grid -> queue.updateGridsquare -> uploaded with QSO (postQso gridsquare). - LoTW satellite list: 112 names embedded (lotw.arrl.org config.tq6), normalizeSatName maps Celestrak TLE names to LoTW names (SAUDISAT-1C -> SO-50, FUNCUBE-1 -> AO-73, DIWATA-2B -> PO-101, ZARYA/ARISS -> ARISS); "Update sats" button in WaveLog card downloads the live list (LotwSatellitesRepo, SharedPreferences persisted, never in build). - RST: rst_sent/rst_rcvd = 59/59 in both v1 ADIF and v2 JSON. - Grid mismatch dialog kept (cloud station grid vs station QTH); QSO gridsquare no longer uses station grid. - New strings in 5 locales; check_strings 9 files OK. Verified: core:domain/data + feature:settings/radar + app compileDebugKotlin BUILD SUCCESSFUL. --- .../core/data/injection/MainContainer.kt | 7 ++ .../core/data/wavelog/LotwSatellitesRepo.kt | 62 ++++++++++ .../look4sat/core/domain/qrz/QrzGridClient.kt | 71 +++++++++++ .../core/domain/repository/IMainContainer.kt | 2 + .../domain/wavelog/ILotwSatellitesRepo.kt | 15 +++ .../core/domain/wavelog/LotwSatellites.kt | 22 ++++ .../core/domain/wavelog/WaveLogApi.kt | 25 +++- .../core/domain/wavelog/WavelogQueue.kt | 6 + .../core/domain/wavelog/WavelogUploader.kt | 7 +- .../src/main/res/values-id/strings.xml | 4 + .../src/main/res/values-in/strings.xml | 4 + .../src/main/res/values-tr/strings.xml | 4 + .../src/main/res/values-zh/strings.xml | 4 + .../src/main/res/values/strings.xml | 4 + .../rtbishop/look4sat/feature/radar/LogTab.kt | 21 +++- .../feature/settings/SettingsScreen.kt | 117 ++++++++++++++++-- .../feature/settings/SettingsState.kt | 2 + .../feature/settings/SettingsViewModel.kt | 18 ++- 18 files changed, 375 insertions(+), 20 deletions(-) create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/wavelog/LotwSatellitesRepo.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/qrz/QrzGridClient.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/ILotwSatellitesRepo.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatellites.kt diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index ac93635d..d46e20e3 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -70,6 +70,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import okhttp3.OkHttpClient +import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo class MainContainer(private val context: Context) : IMainContainer { @@ -112,6 +113,12 @@ class MainContainer(private val context: Context) : IMainContainer { } override fun provideWavelogUploader(): WavelogUploader = WavelogUploader(settingsRepo, wavelogQueue) + private val lotwRepo: LotwSatellitesRepo by lazy { + LotwSatellitesRepo(context).also { it.restore() } + } + + override fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo = lotwRepo + override fun provideBluetoothReporter(): IReporter { val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val rc = settingsRepo.rcSettings.value diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/wavelog/LotwSatellitesRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/wavelog/LotwSatellitesRepo.kt new file mode 100644 index 00000000..5f8c10ff --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/wavelog/LotwSatellitesRepo.kt @@ -0,0 +1,62 @@ +/* LotwSatellitesRepo.kt — LoTW 卫星列表运行时刷新(4.5.5)。 + * 下载 ARRL 官方 config.tq6(gzip XML), 解析 , + * 更新 LotwSatellites 动态表 + SharedPreferences 持久化(重启不丢)。 + * 触发: 设置页 WaveLog 区「更新卫星列表」按钮(用户拍板: 手动触发)。 + */ +package com.rtbishop.look4sat.core.data.wavelog + +import android.content.Context +import com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo +import com.rtbishop.look4sat.core.domain.wavelog.LotwSatellites +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.URL +import java.util.zip.GZIPInputStream +import kotlin.random.Random + +class LotwSatellitesRepo(private val context: Context) : ILotwSatellitesRepo { + + private val prefs = context.getSharedPreferences("lotw_satellites", Context.MODE_PRIVATE) + + /** 从 SharedPreferences 恢复上次下载的表(进程启动时调用) */ + override fun restore() { + val saved = prefs.getString(KEY_NAMES, null) + if (!saved.isNullOrBlank()) { + LotwSatellites.updateNames(saved.split(",").filter { it.isNotBlank() }.toSet()) + } + } + + /** 下载 config.tq6 → 解析 → 更新内存 + 持久化。返回结果供 UI 提示 */ + override suspend fun refresh(): ILotwSatellitesRepo.RefreshResult = withContext(Dispatchers.IO) { + try { + val url = URL("https://lotw.arrl.org/lotw/config.tq6") + val conn = url.openConnection() + conn.connectTimeout = 10_000 + conn.readTimeout = 20_000 + conn.setRequestProperty("User-Agent", "Look4Sat-Pro/4.5.5") + val raw = conn.getInputStream() + val reader = BufferedReader(InputStreamReader(GZIPInputStream(raw), Charsets.UTF_8)) + val text = reader.use { it.readText() } + // 解析 — 格式固定(ARRL 官方 XML) + val names = Regex("""Grid SquareXXX。 + * 匿名无 Cookie 拿不到 Detail(已确认); 对方没填网格时表格无此行(返回 null)。 + * Cookie 由用户在设置页粘贴(EditThisCookie 导出的 JSON 数组 或 原始 Cookie 串), + * 绝不内置/进构建。 + */ +package com.rtbishop.look4sat.core.domain.qrz + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.URL + +object QrzGridClient { + + /** 解析用户粘贴的 Cookie(兼容两种格式): EditThisCookie JSON 数组 或 原始 "k=v; k=v" 串 */ + fun parseCookies(raw: String): String { + val text = raw.trim() + if (text.isEmpty()) return "" + // JSON 数组格式: [{"name":"qz_userid","value":"1266043",...}, ...] + if (text.startsWith("[")) { + return try { + val arr = org.json.JSONArray(text) + val parts = mutableListOf() + for (i in 0 until arr.length()) { + val o = arr.getJSONObject(i) + val name = o.optString("name") + val value = o.optString("value") + if (name.isNotBlank()) parts.add("$name=$value") + } + parts.joinToString("; ") + } catch (_: Exception) { text } + } + return text + } + + /** 检测 cookies 对应的登录呼号(请求 db 首页, 提取账号菜单呼号)。失败返回 null */ + suspend fun fetchOwnCallsign(cookieHeader: String): String? = withContext(Dispatchers.IO) { + if (cookieHeader.isBlank()) return@withContext null + try { + val url = URL("https://www.qrz.com/db/") + val conn = url.openConnection() + conn.connectTimeout = 10_000 + conn.readTimeout = 20_000 + conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro") + conn.setRequestProperty("Cookie", cookieHeader) + val html = conn.getInputStream().bufferedReader().use { it.readText() } + // 登录账号菜单(实测结构):
  • BG7NTA
      + val pattern = Regex("
    • ]*>\\s*([A-Z0-9/]+)\\s*Grid Square([^<]+)""") + .find(html) + m?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() } + } catch (_: Exception) { null } + } +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt index 1268f581..e019cedf 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt @@ -46,6 +46,8 @@ interface IMainContainer { // WaveLog 日志(4.5.2) val wavelogQueue: com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader + + fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo } data class MutualPassData( diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/ILotwSatellitesRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/ILotwSatellitesRepo.kt new file mode 100644 index 00000000..c793111b --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/ILotwSatellitesRepo.kt @@ -0,0 +1,15 @@ +/* ILotwSatellitesRepo.kt — LoTW 卫星列表刷新接口(domain 层, 4.5.5)。 + * 实现: core/data 的 LotwSatellitesRepo(下载 ARRL config.tq6)。 + */ +package com.rtbishop.look4sat.core.domain.wavelog + +interface ILotwSatellitesRepo { + sealed class RefreshResult { + data class Ok(val count: Int) : RefreshResult() + data class Error(val message: String) : RefreshResult() + } + + fun restore() + + suspend fun refresh(): RefreshResult +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatellites.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatellites.kt new file mode 100644 index 00000000..b00aba0f --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/LotwSatellites.kt @@ -0,0 +1,22 @@ +/* LotwSatellites.kt — LoTW 支持的卫星名列表(112 个, 静态内置)。 + * 来源: https://lotw.arrl.org/lotw/config.tq6 (gzip XML, ARRL 官方!) + * WaveLog 用同一来源更新 satellite 表 lotw 字段(Update_model.php lotw_sats())。 + * 运行时可由 LotwSatellitesRepo 手动刷新(设置页 WaveLog 区按钮触发)。 + */ +package com.rtbishop.look4sat.core.domain.wavelog + +object LotwSatellites { + private val staticNames: Set = setOf("AISAT1", "AO-10", "AO-109", "AO-123", "AO-13", "AO-16", "AO-21", "AO-27", "AO-3", "AO-4", "AO-40", "AO-51", "AO-6", "AO-7", "AO-73", "AO-8", "AO-85", "AO-91", "AO-92", "ARISS", "Arsene", "BO-102", "BY70-1", "CAS-2T", "CAS-3H", "CAS-4A", "CAS-4B", "DO-64", "EO-79", "EO-88", "FO-118", "FO-12", "FO-20", "FO-29", "FO-99", "FS-3", "HO-107", "HO-113", "HO-119", "HO-68", "INSPR7", "IO-117", "IO-86", "JO-97", "KEDR", "LEDSAT", "LO-19", "LO-78", "LO-87", "LO-90", "MAYA-3", "MAYA-4", "MIREX", "MO-112", "MO-122", "NO-103", "NO-104", "NO-44", "NO-83", "NO-84", "PO-101", "QO-100", "RS-1", "RS-10", "RS-11", "RS-12", "RS-13", "RS-15", "RS-2", "RS-44", "RS-5", "RS-6", "RS-7", "RS-8", "SAREX", "SO-121", "SO-124", "SO-125", "SO-35", "SO-41", "SO-50", "SO-67", "SONATE", "TAURUS", "TEVEL1", "TEVEL2", "TEVEL3", "TEVEL4", "TEVEL5", "TEVEL6", "TEVEL7", "TEVEL8", "TO-108", "UKUBE1", "UO-14", "UVSQ", "VO-52", "XW-2A", "XW-2B", "XW-2C", "XW-2D", "XW-2E", "XW-2F", "TEV2-1", "TEV2-2", "TEV2-3", "TEV2-4", "TEV2-5", "TEV2-6", "TEV2-7", "TEV2-8", "TEV2-9") + + @Volatile + private var dynamicNames: Set = emptySet() + + /** 当前生效的卫星名: 静态内置 ∪ 运行时更新 */ + val names: Set + get() = if (dynamicNames.isEmpty()) staticNames else staticNames + dynamicNames + + /** 运行时更新(设置页按钮 → LotwSatellitesRepo.refresh() 后调用) */ + fun updateNames(newNames: Set) { + if (newNames.isNotEmpty()) dynamicNames = newNames + } +} 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 6b1d6c25..f179b149 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 @@ -111,12 +111,21 @@ object WaveLogApi { val main = raw.substringBefore('(').trim() .ifBlank { raw.trim() } .uppercase(Locale.ENGLISH) - // ISS 特判: 主名是 ISS/ZARYA/ARISS 变体 → ISS(LoTW 认可名) - return when { - main == "ZARYA" || main.startsWith("ISS") -> "ISS" - main == "ARISS" -> "ISS" - else -> main - } + // 匹配逻辑(仿 WaveLog 卫星表 name/displayname 匹配 + LoTW 列表): + // 1. 已在 LoTW 列表(常见 TLE 名 == 通用名) → 原样返回 + // 2. 不在 → 查 Celestrak 别名映射(SAUDISAT 1C → SO-50 等) → 映射后必须在 LoTW 列表 + // 3. 仍不匹配 → 原样返回(不阻塞上传, QSO 仍保存) + val commonName = mapOf( + "ZARYA" to "ARISS", + "ARISS" to "ARISS", + "FUNCUBE-1" to "AO-73", + "DIWATA-2B" to "PO-101", + "SAUDISAT-1C" to "SO-50", + "SAUDISAT 1C" to "SO-50", + "DIWATA-2A" to "PO-101" + ) + val candidate = commonName[main] ?: main + return if (candidate in LotwSatellites.names) candidate else main } /** 创建 QSO: 优先 v2, 404 降级 v1(ADIF) */ @@ -143,6 +152,8 @@ object WaveLogApi { put("freq", qso.freqTxHz) put("freq_rx", qso.freqRxHz) put("gridsquare", gridsquare) + put("rst_sent", "59") + put("rst_rcvd", "59") put("sat_name", satName) } val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString()) @@ -182,6 +193,8 @@ object WaveLogApi { } append(field("qso_date", utcDateCompact(qso.timeUtcMs))) append(field("time_on", utcTimeCompact(qso.timeUtcMs))) + append(field("rst_sent", "59")) + append(field("rst_rcvd", "59")) if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4))) if (satName.isNotBlank()) { append(field("sat_name", satName)) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt index 3e728c21..8592df0d 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/wavelog/WavelogQueue.kt @@ -26,6 +26,7 @@ data class WavelogQso( val freqRxHz: Long, // 下行 val satName: String, val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据) + val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到 val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾) ) @@ -78,6 +79,11 @@ class WavelogQueue(private val store: IWavelogQueueStore) { save(all().map { if (it.id == id) it.copy(uploaded = true) else it }) } + /** 更新某条 QSO 的对方网格(QRZ 爬虫异步回填, 4.5.5) */ + fun updateGridsquare(id: String, grid: String) { + save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it }) + } + /** 移除所有已上传条目(可选项, 保持队列精简) */ @Synchronized fun removeUploaded() { 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 40599871..255d85a2 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 @@ -45,7 +45,8 @@ class WavelogUploader( return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)") } - // 2. 网格检测: 用户当前 QTH 前 4 位 vs 台站网格前 4 位(v1 降级时网格相同, 跳过) + // 2. 网格检测: 云端站点网格前 4 位 vs 当前站位网格前 4 位 + // (防站点配置错误; 与 QSO 对方网格无关 — 用户澄清) if (!force) { val userGrid = userQthGrid() if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) { @@ -53,14 +54,14 @@ class WavelogUploader( } } - // 3. 逐条上传(成功后标记 uploaded, 保留在本地供日志页打勾) + // 3. 逐条上传。ADIF 网格字段 = 对方网格(通联对象), 爬虫接入前留空 val entries = queue.all() var ok = 0 var fail = 0 var firstError = "" for (qso in entries) { if (qso.uploaded) { ok++; continue } - val result = WaveLogApi.postQso(url, apiKey, stationId, qso, stationGrid) + val result = WaveLogApi.postQso(url, apiKey, stationId, qso, qso.gridsquare) if (result is WavelogResult.Success) { ok++ queue.markUploaded(qso.id) diff --git a/core/presentation/src/main/res/values-id/strings.xml b/core/presentation/src/main/res/values-id/strings.xml index 1969402a..76d8f494 100644 --- a/core/presentation/src/main/res/values-id/strings.xml +++ b/core/presentation/src/main/res/values-id/strings.xml @@ -227,6 +227,7 @@ ID stasiun Unggah otomatis Uji koneksi + Perbarui satelit Unggah sekarang Log Pilih transponder @@ -238,6 +239,9 @@ Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah? Abaikan dan unggah Batal + QRZ Cookie + Tempel cookie login QRZ Anda di sini (ekspor EditThisCookie atau string "k=v; k=v"). Digunakan untuk mengambil grid square stasiun lawan saat unggah. + Cookie Salin Urungkan Waktu diff --git a/core/presentation/src/main/res/values-in/strings.xml b/core/presentation/src/main/res/values-in/strings.xml index 4a336ef7..83b3654a 100644 --- a/core/presentation/src/main/res/values-in/strings.xml +++ b/core/presentation/src/main/res/values-in/strings.xml @@ -226,6 +226,7 @@ ID stasiun Unggah otomatis Uji koneksi + Perbarui satelit Unggah sekarang Log Pilih transponder @@ -237,6 +238,9 @@ Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah? Abaikan dan unggah Batal + QRZ Cookie + Tempel cookie login QRZ Anda di sini (ekspor EditThisCookie atau string "k=v; k=v"). Digunakan untuk mengambil grid square stasiun lawan saat unggah. + Cookie Salin Urungkan Waktu diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index df605576..92bc8670 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -236,6 +236,7 @@ İstasyon kimliği Otomatik yükleme Bağlantıyı test et + Uydu güncelle Şimdi yükle Günlük Transponder seç @@ -247,6 +248,9 @@ QTH ızgarası (%1$s) istasyon ızgarasıyla (%2$s) eşleşmiyor. Yine de yüklensin mi? Yoksay ve yükle İptal + QRZ Cookie + QRZ giriş çerezlerinizi buraya yapıştırın (EditThisCookie dışa aktarımı veya "k=v; k=v" biçimi). Yükleme için karşı istasyonun grid karesini getirmekte kullanılır. + Çerez Kopyala Geri al Zaman diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index d0b5c7ed..f62461ac 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -219,6 +219,7 @@ 站点 ID 自动上传 测试连接 + 更新卫星列表 手动上传 日志 选择转发器 @@ -230,6 +231,9 @@ 当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传? 忽略并上传 取消 + QRZ Cookie + 在此粘贴 QRZ 登录 Cookie(EditThisCookie 导出 或 "k=v; k=v" 格式)。用于上传时查询对方网格。 + Cookie 复制 撤销 时间 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index fed0f6db..b93dea76 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -250,6 +250,7 @@ Station ID Auto upload Test connection + Update sats Upload now Log Select transponder @@ -261,6 +262,9 @@ QTH grid (%1$s) does not match station grid (%2$s). Upload anyway? Ignore and upload Cancel + QRZ Cookie + Paste your QRZ login cookies here (EditThisCookie export or "k=v; k=v" string). Used to fetch the other station grid square for uploads. + Cookie Copy Undo Time diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt index 89896d9e..127a9cd3 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt @@ -45,11 +45,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -72,6 +74,7 @@ import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.formatFrequency import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.util.Calendar import java.util.TimeZone import java.util.UUID @@ -92,6 +95,8 @@ fun LogTab( aosTimeMs: Long = 0L, modifier: Modifier = Modifier ) { + val context = androidx.compose.ui.platform.LocalContext.current + val scope = rememberCoroutineScope() var selectedUuid by remember { mutableStateOf(null) } var refreshTick by remember { mutableIntStateOf(0) } val entries = remember(refreshTick) { queue.all() } @@ -244,6 +249,8 @@ private fun ExpandedLogInput( aosTimeMs: Long = 0L, onSaved: () -> Unit ) { + val context = androidx.compose.ui.platform.LocalContext.current + val scope = rememberCoroutineScope() var callsign by remember { mutableStateOf("") } var mode by remember { mutableStateOf(radio.uplinkMode ?: "FM") } @@ -255,9 +262,10 @@ private fun ExpandedLogInput( // 频率直接取转发器栏的数字(radio 每秒多普勒修正, 回车那一秒的值) val tx = radio.uplinkLow ?: radio.downlinkLow ?: 0L val rx = radio.downlinkLow ?: radio.uplinkLow ?: 0L + val qsoId = UUID.randomUUID().toString() queue.add( WavelogQso( - id = UUID.randomUUID().toString(), + id = qsoId, timeUtcMs = System.currentTimeMillis(), call = call, mode = mode.trim().ifBlank { "FM" }.uppercase(), @@ -270,6 +278,17 @@ private fun ExpandedLogInput( callsign = "" onSaved() showToast(savedMsg) + // QRZ 对方网格异步回填(4.5.5): 设置页填了 Cookie 才查, 失败静默 + scope.launch { + val prefs = context.getSharedPreferences("qrz_cookie", android.content.Context.MODE_PRIVATE) + val rawCookie = prefs.getString("cookie", "") ?: "" + if (rawCookie.isNotBlank()) { + val grid = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.lookupGrid( + call, com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.parseCookies(rawCookie) + ) + if (grid != null) queue.updateGridsquare(qsoId, grid) + } + } } // 频率显示与转发器栏完全一致: TX 行 + RX 行(radio 来自每秒多普勒重算, 实时刷新) diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index b68a4086..e7113b4a 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material3.AlertDialog import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -64,6 +65,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable @@ -109,6 +111,7 @@ import com.rtbishop.look4sat.core.presentation.isVerticalLayout import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlinx.coroutines.launch @Composable fun SettingsDestination() { @@ -116,7 +119,7 @@ fun SettingsDestination() { val container = (context.applicationContext as IContainerProvider).getMainContainer() val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container)) val uiState by viewModel.uiState.collectAsStateWithLifecycle() - SettingsScreen(uiState, viewModel::onAction) + SettingsScreen(uiState, viewModel::onAction, container.provideLotwSatellitesRepo()) // WaveLog 网格不一致确认弹窗(4.5.2) val gridConfirm = viewModel.gridConfirm @@ -177,7 +180,11 @@ fun SettingsDestination() { } @Composable -private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) { +private fun SettingsScreen( + uiState: SettingsState, + onAction: (SettingsAction) -> Unit, + lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo +) { val dialogs = rememberDialogVisibility() val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) } val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) } @@ -392,7 +399,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> ) } item { OtherCard(uiState.otherSettings, onAction) } - item { WavelogCard(uiState.otherSettings, onAction) } + item { WavelogCard(uiState.otherSettings, onAction, lotwRepo) } item { UiSettingsCard( hiddenScreens = uiState.otherSettings.hiddenScreens, @@ -629,17 +636,35 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni @Composable private fun WavelogCard( settings: OtherSettings, - onAction: (SettingsAction) -> Unit + onAction: (SettingsAction) -> Unit, + lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo ) { + val qrzContext = LocalContext.current + var showQrzDialog by remember { mutableStateOf(false) } + var qrzCookie by remember { + mutableStateOf( + qrzContext.getSharedPreferences("qrz_cookie", android.content.Context.MODE_PRIVATE) + .getString("cookie", "") ?: "" + ) + } + var qrzTestResult by remember { mutableStateOf(null) } + var qrzTesting by remember { mutableStateOf(false) } + val qrzScope = rememberCoroutineScope() ElevatedCard(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - Text( - text = stringResource(id = R.string.prefs_wavelog_title), - color = MaterialTheme.colorScheme.primary - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(id = R.string.prefs_wavelog_title), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { showQrzDialog = true }) { + Text("\u2699") + } + } OutlinedTextField( value = settings.wavelogUrl, onValueChange = { @@ -680,6 +705,11 @@ private fun WavelogCard( text = stringResource(id = R.string.prefs_wavelog_test), modifier = Modifier.weight(1f) ) + CardButton( + onClick = { onAction(SettingsAction.UpdateLotwSatellites) }, + text = stringResource(id = R.string.prefs_wavelog_update_sats), + modifier = Modifier.weight(1f) + ) CardButton( onClick = { onAction(SettingsAction.UploadWavelogQueue) }, text = stringResource(id = R.string.prefs_wavelog_upload), @@ -688,6 +718,77 @@ private fun WavelogCard( } } } + if (showQrzDialog) { + AlertDialog( + onDismissRequest = { showQrzDialog = false }, + title = { Text(text = stringResource(id = R.string.prefs_wavelog_qrz_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = stringResource(id = R.string.prefs_wavelog_qrz_hint), + fontSize = 12.sp + ) + OutlinedTextField( + value = qrzCookie, + onValueChange = { qrzCookie = it }, + label = { Text(stringResource(id = R.string.prefs_wavelog_qrz_label)) }, + minLines = 3, + maxLines = 8, + textStyle = LocalTextStyle.current.copy(fontSize = 12.sp), + modifier = Modifier.fillMaxWidth() + ) + if (qrzTestResult != null) { + Text( + text = qrzTestResult!!, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.primary + ) + } + TextButton( + onClick = { + qrzTesting = true + qrzTestResult = null + qrzScope.launch { + val header = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.parseCookies(qrzCookie) + if (header.isBlank()) { + qrzTestResult = "Cookie 为空, 请先粘贴" + qrzTesting = false + return@launch + } + val own = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.fetchOwnCallsign(header) + if (own == null) { + qrzTestResult = "Cookie 无效或已过期(无法识别登录呼号)" + } else { + val grid = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.lookupGrid(own, header) + qrzTestResult = if (grid != null) { + "登录呼号: $own 网格: $grid ✅" + } else { + "登录呼号: $own 网格: 未填写" + } + } + qrzTesting = false + } + }, + enabled = !qrzTesting + ) { + Text(text = if (qrzTesting) "测试中…" else "测试查询") + } + } + }, + confirmButton = { + TextButton(onClick = { + qrzContext.getSharedPreferences("qrz_cookie", android.content.Context.MODE_PRIVATE) + .edit().putString("cookie", qrzCookie.trim()).apply() + showQrzDialog = false + }) { Text(text = stringResource(id = R.string.btn_accept)) } + }, + dismissButton = { + TextButton(onClick = { showQrzDialog = false }) { + Text(text = stringResource(id = R.string.wavelog_cancel)) + } + } + ) + } } @Composable diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index d29626a7..adc57d2c 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt @@ -82,6 +82,8 @@ sealed interface SettingsAction { data object TestWavelogConnection : SettingsAction // WaveLog: 手动上传队列 data object UploadWavelogQueue : SettingsAction + // WaveLog: 更新 LoTW 卫星列表 + data object UpdateLotwSatellites : SettingsAction // Remote control data class UpdateRC(val settings: RCSettings) : SettingsAction 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 8d835882..2c519c2c 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 @@ -41,7 +41,8 @@ import androidx.compose.runtime.setValue class SettingsViewModel( private val databaseRepo: IDatabaseRepo, private val settingsRepo: ISettingsRepo, - private val showToast: IShowToast + private val showToast: IShowToast, + private val lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo ) : ViewModel() { private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0) @@ -156,6 +157,7 @@ class SettingsViewModel( } SettingsAction.TestWavelogConnection -> testWavelogConnection() SettingsAction.UploadWavelogQueue -> uploadWavelogQueue() + SettingsAction.UpdateLotwSatellites -> updateLotwSatellites() // System is SettingsAction.ShowToast -> showToast(action.message) } @@ -264,6 +266,17 @@ class SettingsViewModel( } } + private fun updateLotwSatellites() { + viewModelScope.launch { + when (val r = lotwRepo.refresh()) { + is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Ok -> + showToast("LoTW 卫星列表已更新: ${r.count} 个") + is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Error -> + wavelogError = "LoTW 卫星列表更新失败: ${r.message}" + } + } + } + private fun uploadWavelogQueue() { viewModelScope.launch { val result = pendingUploader?.uploadQueue() @@ -306,7 +319,8 @@ class SettingsViewModel( SettingsViewModel( databaseRepo = container.databaseRepo, settingsRepo = container.settingsRepo, - showToast = container.provideShowToast() + showToast = container.provideShowToast(), + lotwRepo = container.provideLotwSatellitesRepo() ).apply { pendingUploader = container.provideWavelogUploader() } } }