feat(wavelog): QRZ grid lookup, LoTW satellite list refresh, RST 59

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.
This commit is contained in:
mckero committed 2026-08-05 06:23:47 +00:00
1 parent 2c26272e0b
commit 0e767f4250
18 files changed
+375 -20

No files matched your search

@@ -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
@@ -0,0 +1,62 @@
/* LotwSatellitesRepo.kt — LoTW 卫星列表运行时刷新(4.5.5)。
* 下载 ARRL 官方 config.tq6(gzip XML), 解析 <satellite name="...">,
* 更新 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() }
// 解析 <satellite name="XXX" ...> — 格式固定(ARRL 官方 XML)
val names = Regex("""<satellite name="([^"]+)""").findAll(text)
.map { it.groupValues[1].uppercase() }
.toSet()
if (names.isEmpty()) return@withContext ILotwSatellitesRepo.RefreshResult.Error("列表为空(响应异常)")
LotwSatellites.updateNames(names)
prefs.edit()
.putString(KEY_NAMES, names.joinToString(","))
.putLong(KEY_UPDATED, System.currentTimeMillis())
.apply()
ILotwSatellitesRepo.RefreshResult.Ok(names.size)
} catch (e: Exception) {
ILotwSatellitesRepo.RefreshResult.Error(e.message ?: "未知错误")
}
}
companion object {
private const val KEY_NAMES = "names"
private const val KEY_UPDATED = "updated"
}
}
@@ -0,0 +1,71 @@
/* QrzGridClient.kt — QRZ 呼号网格爬虫(4.5.5, domain 层纯 JVM)。
* 原理(已实测): 带用户 QRZ 登录 Cookie GET https://www.qrz.com/db/{呼号},
* 页面 Detail 表格里有 <td class="dh">Grid Square</td><td class="di">XXX</td>。
* 匿名无 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<String>()
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() }
// 登录账号菜单(实测结构): <li class="leaf last" onclick="return true">BG7NTA <ul class="sub">
val pattern = Regex("<li class=\"leaf last\"[^>]*>\\s*([A-Z0-9/]+)\\s*<ul")
pattern.find(html)?.groupValues?.get(1)?.trim()
} catch (_: Exception) { null }
}
/** 查询呼号的网格。返回 null = 未填/查不到(静默, 不阻塞录入) */
suspend fun lookupGrid(callsign: String, cookieHeader: String): String? = withContext(Dispatchers.IO) {
if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext null
try {
val url = URL("https://www.qrz.com/db/${callsign.trim().uppercase()}")
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() }
// Detail 表格 Grid Square 行(已实测格式)
val m = Regex("""<td class="dh">Grid Square</td><td class="di">([^<]+)</td>""")
.find(html)
m?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
} catch (_: Exception) { null }
}
}
@@ -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(
@@ -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
}
@@ -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<String> = 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<String> = emptySet()
/** 当前生效的卫星名: 静态内置 ∪ 运行时更新 */
val names: Set<String>
get() = if (dynamicNames.isEmpty()) staticNames else staticNames + dynamicNames
/** 运行时更新(设置页按钮 → LotwSatellitesRepo.refresh() 后调用) */
fun updateNames(newNames: Set<String>) {
if (newNames.isNotEmpty()) dynamicNames = newNames
}
}
@@ -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))
@@ -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() {
@@ -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)
@@ -227,6 +227,7 @@
<string name="prefs_wavelog_station_hint">ID stasiun</string>
<string name="prefs_wavelog_auto">Unggah otomatis</string>
<string name="prefs_wavelog_test">Uji koneksi</string>
<string name="prefs_wavelog_update_sats">Perbarui satelit</string>
<string name="prefs_wavelog_upload">Unggah sekarang</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_select_transponder">Pilih transponder</string>
@@ -238,6 +239,9 @@
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
<string name="wavelog_ignore">Abaikan dan unggah</string>
<string name="wavelog_cancel">Batal</string>
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
<string name="prefs_wavelog_qrz_hint">Tempel cookie login QRZ Anda di sini (ekspor EditThisCookie atau string "k=v; k=v"). Digunakan untuk mengambil grid square stasiun lawan saat unggah.</string>
<string name="prefs_wavelog_qrz_label">Cookie</string>
<string name="wavelog_copy">Salin</string>
<string name="wavelog_delete_undo">Urungkan</string>
<string name="wavelog_col_time">Waktu</string>
@@ -226,6 +226,7 @@
<string name="prefs_wavelog_station_hint">ID stasiun</string>
<string name="prefs_wavelog_auto">Unggah otomatis</string>
<string name="prefs_wavelog_test">Uji koneksi</string>
<string name="prefs_wavelog_update_sats">Perbarui satelit</string>
<string name="prefs_wavelog_upload">Unggah sekarang</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_select_transponder">Pilih transponder</string>
@@ -237,6 +238,9 @@
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
<string name="wavelog_ignore">Abaikan dan unggah</string>
<string name="wavelog_cancel">Batal</string>
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
<string name="prefs_wavelog_qrz_hint">Tempel cookie login QRZ Anda di sini (ekspor EditThisCookie atau string "k=v; k=v"). Digunakan untuk mengambil grid square stasiun lawan saat unggah.</string>
<string name="prefs_wavelog_qrz_label">Cookie</string>
<string name="wavelog_copy">Salin</string>
<string name="wavelog_delete_undo">Urungkan</string>
<string name="wavelog_col_time">Waktu</string>
@@ -236,6 +236,7 @@
<string name="prefs_wavelog_station_hint">İstasyon kimliği</string>
<string name="prefs_wavelog_auto">Otomatik yükleme</string>
<string name="prefs_wavelog_test">Bağlantıyı test et</string>
<string name="prefs_wavelog_update_sats">Uydu güncelle</string>
<string name="prefs_wavelog_upload">Şimdi yükle</string>
<string name="wavelog_title">Günlük</string>
<string name="wavelog_select_transponder">Transponder seç</string>
@@ -247,6 +248,9 @@
<string name="wavelog_grid_mismatch">QTH ızgarası (%1$s) istasyon ızgarasıyla (%2$s) eşleşmiyor. Yine de yüklensin mi?</string>
<string name="wavelog_ignore">Yoksay ve yükle</string>
<string name="wavelog_cancel">İptal</string>
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
<string name="prefs_wavelog_qrz_hint">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.</string>
<string name="prefs_wavelog_qrz_label">Çerez</string>
<string name="wavelog_copy">Kopyala</string>
<string name="wavelog_delete_undo">Geri al</string>
<string name="wavelog_col_time">Zaman</string>
@@ -219,6 +219,7 @@
<string name="prefs_wavelog_station_hint">站点 ID</string>
<string name="prefs_wavelog_auto">自动上传</string>
<string name="prefs_wavelog_test">测试连接</string>
<string name="prefs_wavelog_update_sats">更新卫星列表</string>
<string name="prefs_wavelog_upload">手动上传</string>
<string name="wavelog_title">日志</string>
<string name="wavelog_select_transponder">选择转发器</string>
@@ -230,6 +231,9 @@
<string name="wavelog_grid_mismatch">当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传?</string>
<string name="wavelog_ignore">忽略并上传</string>
<string name="wavelog_cancel">取消</string>
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
<string name="prefs_wavelog_qrz_hint">在此粘贴 QRZ 登录 Cookie(EditThisCookie 导出 或 "k=v; k=v" 格式)。用于上传时查询对方网格。</string>
<string name="prefs_wavelog_qrz_label">Cookie</string>
<string name="wavelog_copy">复制</string>
<string name="wavelog_delete_undo">撤销</string>
<string name="wavelog_col_time">时间</string>
@@ -250,6 +250,7 @@
<string name="prefs_wavelog_station_hint">Station ID</string>
<string name="prefs_wavelog_auto">Auto upload</string>
<string name="prefs_wavelog_test">Test connection</string>
<string name="prefs_wavelog_update_sats">Update sats</string>
<string name="prefs_wavelog_upload">Upload now</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_select_transponder">Select transponder</string>
@@ -261,6 +262,9 @@
<string name="wavelog_grid_mismatch">QTH grid (%1$s) does not match station grid (%2$s). Upload anyway?</string>
<string name="wavelog_ignore">Ignore and upload</string>
<string name="wavelog_cancel">Cancel</string>
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
<string name="prefs_wavelog_qrz_hint">Paste your QRZ login cookies here (EditThisCookie export or "k=v; k=v" string). Used to fetch the other station grid square for uploads.</string>
<string name="prefs_wavelog_qrz_label">Cookie</string>
<string name="wavelog_copy">Copy</string>
<string name="wavelog_delete_undo">Undo</string>
<string name="wavelog_col_time">Time</string>
@@ -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<String?>(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 来自每秒多普勒重算, 实时刷新)
@@ -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<String?>(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
@@ -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
@@ -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() }
}
}