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 7c62be8d..0cf12751 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 @@ -1,17 +1,12 @@ /* - * WaveLogApi.kt — WaveLog 日志服务器 API v2 客户端(4.5.2, 覆盖修复)。 + * WaveLogApi.kt — WaveLog 日志服务器 API 客户端(4.5.2 覆盖修复 2)。 * - * 端点(文档 /temporary/wavelog_docs/docs/developer/api-v2/): - * GET {base}/api/v2/token whoami(测试连接,任意有效 token) - * GET {base}/api/v2/station/{id} 站点信息(id/name/gridsquare) - * POST {base}/api/v2/qso 创建 QSO(qso:write) - * 认证: Authorization: Bearer wl2_xxx(v2 token) - * - * URL 处理(用户实测 404 修复): - * 1. normalizeUrl 去掉用户输入的尾部 /index.php(我们自己拼, 避免重复) - * 2. 先试带 index.php 的路径; 404 时自动回退到不带 index.php(服务器重写配置) - * 3. 409 conflict = 重复 QSO(已存在) → 视为成功(移出队列) - * 4. 失败信息带实际 URL + HTTP 码, 便于排查 + * 同时支持 v1 与 v2(用户服务器实测只有 v1, v2 返回 404): + * v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON 字段) + * v1: POST {base}/index.php/api/qso (key 在 JSON body + ADIF 字符串) + * 策略: 优先 v2, 404 自动降级 v1。 + * 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif。 + * 站点网格: 仅 v2 有 GET api/v2/station/{id}; v1 无此端点 → 降级用用户 QTH。 */ package com.rtbishop.look4sat.core.domain.wavelog @@ -23,6 +18,7 @@ import java.io.InputStreamReader import java.io.OutputStreamWriter import java.net.HttpURLConnection import java.net.URL +import java.util.Locale /** 站点信息(GET /api/v2/station/{id} 结果) */ data class WavelogStation( @@ -50,29 +46,47 @@ object WaveLogApi { return u } - /** 测试连接: GET /api/v2/token */ - suspend fun testToken(url: String, apiKey: String): WavelogResult = withContext(Dispatchers.IO) { - execute( - url = url, - apiKey = apiKey, - relPath = "api/v2/token", - method = "GET", - onSuccess = { _ -> WavelogResult.Success("连接成功") }, - onFailure = { code, body, usedUrl -> - WavelogResult.Failure("HTTP $code: ${shortError(body)} ($usedUrl)") - } - ) + /** 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif */ + suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) { + val base = normalizeUrl(url) + if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空") + + // v2: GET /index.php/api/v2/token + val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null) + if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)") + + // v1: POST /index.php/api/get_contacts_adif(key 在 body) + if (stationId.isNotBlank()) { + val body = JSONObject().apply { + put("key", apiKey) + put("station_id", stationId) + put("fetchfromid", 0) + }.toString() + val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body) + if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)") + if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)") + } + // 无 index.php 的 v1 尝试 + val body = JSONObject().apply { + put("key", apiKey) + put("station_id", stationId) + put("fetchfromid", 0) + }.toString() + val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body) + if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)") + if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)") + + WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确") } - /** 站点信息: GET /api/v2/station/{id} */ + /** 站点信息: 仅 v2; v1 无此端点返回 null 标记(网格检测降级用用户 QTH) */ suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) { - execute( - url = url, - apiKey = apiKey, - relPath = "api/v2/station/$stationId", - method = "GET", - onSuccess = { body -> - val obj = JSONObject(body) + val base = normalizeUrl(url) + if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空") + val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null) + if (code in 200..299) { + return@withContext try { + val obj = JSONObject(resp) val data = obj.optJSONObject("data") ?: obj val station = WavelogStation( id = data.optInt("id"), @@ -84,14 +98,15 @@ object WaveLogApi { put("id", station.id); put("name", station.name) put("callsign", station.callsign); put("gridsquare", station.gridsquare) }.toString()) - }, - onFailure = { code, body, usedUrl -> - WavelogResult.Failure("HTTP $code: ${shortError(body)} ($usedUrl)") + } catch (e: Exception) { + WavelogResult.Failure("解析失败: ${e.message}") } - ) + } + // v1 无 station 端点 → 返回 Success 空(调用方降级用用户 QTH) + WavelogResult.Success("") } - /** 创建 QSO: POST /api/v2/qso(单条 JSON) */ + /** 创建 QSO: 优先 v2, 404 降级 v1(ADIF) */ suspend fun postQso( url: String, apiKey: String, @@ -99,7 +114,11 @@ object WaveLogApi { qso: WavelogQso, gridsquare: String ): WavelogResult = withContext(Dispatchers.IO) { - val body = JSONObject().apply { + val base = normalizeUrl(url) + if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空") + + // v2: POST /index.php/api/v2/qso(JSON 字段) + val v2Body = JSONObject().apply { put("station_profile_id", stationProfileId.toIntOrNull() ?: 0) put("call", qso.call) put("band", "SAT") @@ -111,93 +130,84 @@ object WaveLogApi { put("gridsquare", gridsquare) put("sat_name", qso.satName) } - execute( - url = url, - apiKey = apiKey, - relPath = "api/v2/qso", - method = "POST", - jsonBody = body.toString(), - onSuccess = { _ -> WavelogResult.Success("已上传") }, - onFailure = { code, resp, usedUrl -> - if (code == 409) { - // 重复 QSO(已存在) → 视为成功, 移出队列 - WavelogResult.Success("重复(已存在)") - } else { - WavelogResult.Failure("HTTP $code: ${shortError(resp)} ($usedUrl)") - } - } - ) - } + 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("重复(已存在)") - /** - * 统一请求执行: 先试带 index.php 的路径; 404 时回退到不带 index.php。 - * 返回 2xx/409 以外的状态都交给 onFailure(带实际使用的 URL)。 - */ - private fun execute( - url: String, - apiKey: String, - relPath: String, - method: String, - jsonBody: String? = null, - onSuccess: (String) -> WavelogResult, - onFailure: (Int, String, String) -> WavelogResult - ): WavelogResult { - val base = normalizeUrl(url) - if (base.isBlank()) return WavelogResult.Failure("服务器地址为空") - - // 候选路径: 带 index.php → 不带(服务器重写配置) - val candidates = listOf( - "$base/index.php/$relPath", - "$base/$relPath" - ) - var lastError: WavelogResult? = null - for (candidate in candidates) { - try { - val conn = openConn(candidate, method, apiKey) - if (jsonBody != null) { - conn.doOutput = true - conn.setRequestProperty("Content-Type", "application/json") - OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) } - } - val code = conn.responseCode - val resp = readBody(conn, code) - if (code in 200..299) return onSuccess(resp) - if (code == 409) return onFailure(code, resp, candidate) // 409 特殊处理在调用方 - if (code == 404) { - // 路径不存在 → 试下一个候选(去掉 index.php) - lastError = WavelogResult.Failure("HTTP 404: ${shortError(resp)} ($candidate)") - continue - } - return onFailure(code, resp, candidate) - } catch (e: Exception) { - lastError = WavelogResult.Failure("网络错误: ${e.message ?: e.javaClass.simpleName}") - return lastError // 网络错误重试没意义 - } + // v1: POST /index.php/api/qso(key 在 body + ADIF) + val v1Body = JSONObject().apply { + put("key", apiKey) + put("station_profile_id", stationProfileId) + put("type", "adif") + put("string", toAdif(qso, gridsquare)) } - return lastError ?: WavelogResult.Failure("请求失败") + val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString()) + if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)") + + // 无 index.php 的 v1 + val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString()) + if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)") + + WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}") } - private fun openConn(url: String, method: String, apiKey: String): HttpURLConnection { - val conn = URL(url).openConnection() as HttpURLConnection - conn.requestMethod = method - conn.connectTimeout = TIMEOUT_MS - conn.readTimeout = TIMEOUT_MS - if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey") - return conn + /** v1 ADIF 字符串(频率 MHz, 长度=UTF-8 字节数) */ + private fun toAdif(qso: WavelogQso, gridsquare: String): String { + fun field(name: String, value: String): String { + val bytes = value.toByteArray(Charsets.UTF_8).size + return "<$name:$bytes>$value" + } + return buildString { + append(field("call", qso.call)) + append(field("band", "SAT")) + append(field("mode", qso.mode)) + append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0))) + if (qso.freqRxHz > 0) { + append(field("freq_rx", String.format(Locale.ENGLISH, "%.6f", qso.freqRxHz / 1_000_000.0))) + } + append(field("qso_date", utcDateCompact(qso.timeUtcMs))) + append(field("time_on", utcTimeCompact(qso.timeUtcMs))) + if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4))) + if (qso.satName.isNotBlank()) { + append(field("sat_name", qso.satName)) + append(field("prop_mode", "SAT")) + } + append("") + } } - private fun readBody(conn: HttpURLConnection, code: Int): String { - val stream = if (code in 200..299) conn.inputStream else conn.errorStream - ?: return "" - return BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() } + /** 通用 HTTP 请求(返回 code + body) */ + private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair { + return try { + val conn = URL(url).openConnection() as HttpURLConnection + conn.requestMethod = method + conn.connectTimeout = TIMEOUT_MS + conn.readTimeout = TIMEOUT_MS + if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey") + if (jsonBody != null) { + conn.doOutput = true + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("Accept", "application/json") + OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) } + } + val code = conn.responseCode + val stream = if (code in 200..299) conn.inputStream else conn.errorStream + val body = if (stream != null) { + BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() } + } else "" + code to body + } catch (e: Exception) { + -1 to (e.message ?: e.javaClass.simpleName) + } } private fun shortError(body: String): String { + if (body.startsWith("<")) return body.take(80) // HTML 错误页 return try { val obj = JSONObject(body) val err = obj.optJSONObject("error") err?.optString("message")?.ifBlank { body.take(120) } - ?: obj.optString("message").ifBlank { body.take(120) } + ?: obj.optString("reason").ifBlank { obj.optString("message").ifBlank { body.take(120) } } } catch (_: Exception) { body.take(120) } @@ -220,4 +230,22 @@ object WaveLogApi { cal.get(java.util.Calendar.SECOND) ) } + + private fun utcDateCompact(ms: Long): String { + val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) + cal.timeInMillis = ms + return "%04d%02d%02d".format( + cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1, + cal.get(java.util.Calendar.DAY_OF_MONTH) + ) + } + + private fun utcTimeCompact(ms: Long): String { + val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")) + cal.timeInMillis = ms + return "%02d%02d%02d".format( + cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE), + cal.get(java.util.Calendar.SECOND) + ) + } } 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 ac6939db..40599871 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,7 +13,12 @@ import org.json.JSONObject sealed class UploadOutcome { data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome() - data class Done(val successCount: Int, val failedCount: Int, val message: String) : UploadOutcome() + data class Done( + val successCount: Int, + val failedCount: Int, + val message: String, + val firstError: String = "" + ) : UploadOutcome() } class WavelogUploader( @@ -34,12 +39,13 @@ class WavelogUploader( return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器") } - // 1. 拉站点信息(拿台站网格) - val stationGrid = getStationGrid(url, apiKey, stationId) ?: return UploadOutcome.Done( - 0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)" - ) + // 1. 拉站点信息(拿台站网格); v1 无此端点时降级用用户 QTH + val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid() + if (stationGrid.isNullOrBlank()) { + return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)") + } - // 2. 网格检测: 用户当前 QTH 前 4 位 vs 台站网格前 4 位 + // 2. 网格检测: 用户当前 QTH 前 4 位 vs 台站网格前 4 位(v1 降级时网格相同, 跳过) if (!force) { val userGrid = userQthGrid() if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) { @@ -51,6 +57,7 @@ class WavelogUploader( 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) @@ -59,10 +66,11 @@ class WavelogUploader( queue.markUploaded(qso.id) } else { fail++ + if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: "" } } val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)" - return UploadOutcome.Done(ok, fail, message) + return UploadOutcome.Done(ok, fail, message, firstError) } private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? { diff --git a/core/presentation/src/main/res/values-in/strings.xml b/core/presentation/src/main/res/values-in/strings.xml index e0444208..9847463d 100644 --- a/core/presentation/src/main/res/values-in/strings.xml +++ b/core/presentation/src/main/res/values-in/strings.xml @@ -49,8 +49,8 @@ \n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat. Apa yang baru di Look4Sat - * Perbaiki kesalahan 404 unggah WaveLog (penanganan URL + percobaan jalur otomatis) - * Pemilih transponder kini dropdown; swipe-to-delete diperbaiki; halaman Log baru (tampilan tabel) di menu Lainnya + * WaveLog kini mendukung API v1 dan v2 (terdeteksi otomatis); dialog kesalahan dengan tombol salin + * Frekuensi log cocok dengan panel Doppler; transponder linier menampilkan rentang penuh Kembali Beri tahu Azimuth @@ -195,6 +195,7 @@ Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah? Abaikan dan unggah Batal + Salin Urungkan Waktu Frek. diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index 81d53708..6ce7a184 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -54,8 +54,8 @@ \n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin. What\'s new in Look4Sat - * WaveLog yükleme 404 hatası düzeltildi (URL işleme + otomatik yol denemesi) - * Transponder seçici artık açılır menü; kaydır-sil düzeltildi; Daha Fazla altında yeni Günlük sayfası (tablo görünümü) + * WaveLog artık hem API v1 hem v2 destekliyor (otomatik algılama); kopyalama butonlu hata penceresi + * Günlük frekansları Doppler paneliyle eşleşiyor; doğrusal transponderler tam aralığı gösteriyor Geri @@ -205,6 +205,7 @@ QTH ızgarası (%1$s) istasyon ızgarasıyla (%2$s) eşleşmiyor. Yine de yüklensin mi? Yoksay ve yükle İptal + Kopyala Geri al Zaman Frekans diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 7e2b1616..b1eb5fbc 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -44,8 +44,8 @@ 请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性 Look4Sat Pro 更新内容 - * 修复 WaveLog 上传 404(URL 处理 + 自动路径重试) - * 转发器改为下拉选择框; 修复左滑删除; 更多菜单新增「日志」页(表格视图) + * WaveLog 同时支持 API v1 和 v2(自动识别); 错误弹窗带一键复制 + * 日志频率与多普勒面板一致; 线性转发器显示完整频段区间 后退 @@ -188,6 +188,7 @@ 当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传? 忽略并上传 取消 + 复制 撤销 时间 频率 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index d2258aec..d3221a4f 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -56,8 +56,8 @@ \n\nPlease update the database at least weekly to get accurate predictions. What\'s new in Look4Sat - * Fixed WaveLog upload 404 (URL handling + automatic path retry) - * Transponder picker is now a dropdown; swipe-to-delete fixed; new Log page (table view) under More + * WaveLog now supports both API v1 and v2 (auto-detected); error dialog with copy button + * Log frequencies match the Doppler panel; linear transponders show the full range Back @@ -219,6 +219,7 @@ QTH grid (%1$s) does not match station grid (%2$s). Upload anyway? Ignore and upload Cancel + Copy Undo Time Freq 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 d2e25327..33fbdebb 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 @@ -65,6 +65,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.predict.OrbitalPos +import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue import com.rtbishop.look4sat.core.presentation.R @@ -86,6 +87,7 @@ fun LogTab( queue: WavelogQueue, wavelogConfigured: Boolean, showToast: (String) -> Unit, + txBaseFrequencyHz: Long? = null, modifier: Modifier = Modifier ) { var selectedUuid by remember { mutableStateOf(null) } @@ -155,6 +157,7 @@ fun LogTab( satelliteName = satelliteName, queue = queue, showToast = showToast, + txBaseFrequencyHz = txBaseFrequencyHz, onSaved = { refreshTick++ } ) } @@ -220,17 +223,23 @@ private fun ExpandedLogInput( satelliteName: String, queue: WavelogQueue, showToast: (String) -> Unit, + txBaseFrequencyHz: Long? = null, onSaved: () -> Unit ) { var callsign by remember { mutableStateOf("") } var mode by remember { mutableStateOf(radio.uplinkMode ?: "FM") } - // 回车那一刻的多普勒修正频率(UTC 时间一并采样) + // 回车那一刻的频率(与雷达页 Doppler 面板一致): 优先用当前调谐 TX 频率 fun currentFreqs(): Pair { val uplinkBase = radio.uplinkLow ?: radio.downlinkLow ?: 0L val downlinkBase = radio.downlinkLow ?: radio.uplinkLow ?: 0L - val tx = if (orbitalPos != null) orbitalPos.getUplinkFreq(uplinkBase) else uplinkBase - val rx = if (orbitalPos != null) orbitalPos.getDownlinkFreq(downlinkBase) else downlinkBase + // 调谐基准(用户在当前转发器上调的)优先; 否则转发器标称下限 + val tx = txBaseFrequencyHz ?: (if (orbitalPos != null) orbitalPos.getUplinkFreq(uplinkBase) else uplinkBase) + val rx = if (orbitalPos != null) { + // 线性转发器: 按映射算下行; FM: 标称下行加多普勒 + DopplerFrequencyCalculator.computeDownlinkFromUplink(tx, radio, orbitalPos) + ?: orbitalPos.getDownlinkFreq(downlinkBase) + } else downlinkBase return tx to rx } @@ -257,15 +266,31 @@ private fun ExpandedLogInput( } val (txHz, rxHz) = currentFreqs() + // 线性转发器: 显示频段区间(多普勒修正); FM: 单频 + val isLinear = DopplerFrequencyCalculator.isLinearTransponder(radio) + val uplinkLow = radio.uplinkLow + val uplinkHigh = radio.uplinkHigh + val downlinkLow = radio.downlinkLow + val downlinkHigh = radio.downlinkHigh + val txRange = if (isLinear && uplinkLow != null && uplinkHigh != null && orbitalPos != null) { + val lo = orbitalPos.getUplinkFreq(uplinkLow) + val hi = orbitalPos.getUplinkFreq(uplinkHigh) + "${formatFrequency(minOf(lo, hi))}–${formatFrequency(maxOf(lo, hi))}" + } else null + val rxRange = if (isLinear && downlinkLow != null && downlinkHigh != null && orbitalPos != null) { + val lo = orbitalPos.getDownlinkFreq(downlinkLow) + val hi = orbitalPos.getDownlinkFreq(downlinkHigh) + "${formatFrequency(minOf(lo, hi))}–${formatFrequency(maxOf(lo, hi))}" + } else null Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = "TX ${formatFrequency(txHz)}", + text = "TX ${txRange ?: formatFrequency(txHz)}", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary ) Text( - text = "RX ${formatFrequency(rxHz)}", + text = "RX ${rxRange ?: formatFrequency(rxHz)}", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary ) 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 652d58ce..45565113 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 @@ -275,7 +275,8 @@ private fun PagerCard( satelliteName = uiState.currentPass?.name ?: "", queue = wavelogQueue, wavelogConfigured = wavelogConfigured, - showToast = showToast + showToast = showToast, + txBaseFrequencyHz = uiState.radioControl.txBaseFrequencyHz ) } } 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 28ce4f32..1fd46d15 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 @@ -44,6 +44,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.grid.GridCells +import android.content.Context import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material3.AlertDialog @@ -144,6 +145,35 @@ fun SettingsDestination() { } ) } + + // WaveLog 错误详情弹窗(可一键复制) + val wavelogError = viewModel.wavelogError + if (wavelogError != null) { + AlertDialog( + onDismissRequest = { viewModel.wavelogError = null }, + title = { Text(text = stringResource(id = R.string.wavelog_title)) }, + text = { + Text( + text = wavelogError, + fontSize = 13.sp + ) + }, + confirmButton = { + TextButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("WaveLog error", wavelogError)) + viewModel.wavelogError = null + }) { + Text(text = stringResource(id = R.string.wavelog_copy)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.wavelogError = null }) { + Text(text = stringResource(id = R.string.btn_cancel)) + } + } + ) + } } @Composable 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 b3bec5bc..04f014bd 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 @@ -232,32 +232,39 @@ class SettingsViewModel( // region WaveLog(4.5.2) - private fun testWavelogConnection() { - viewModelScope.launch { - val s = settingsRepo.otherSettings.value - val result = WaveLogApi.testToken(s.wavelogUrl, s.wavelogApiKey) - showToast( - when (result) { - is WavelogResult.Success -> "WaveLog: 连接成功" - is WavelogResult.Failure -> "WaveLog: ${result.message}" - } - ) - } - } - // 由 SettingsScreen 注入的共享 uploader(容器级单例) var pendingUploader: WavelogUploader? = null // 网格不一致确认弹窗状态(UI 观察后弹 AlertDialog) var gridConfirm by mutableStateOf(null) + // WaveLog 错误详情弹窗(UI 观察后弹 AlertDialog, 可一键复制) + var wavelogError by mutableStateOf(null) + data class GridConfirmData(val stationGrid: String, val userGrid: String) + private fun testWavelogConnection() { + viewModelScope.launch { + val s = settingsRepo.otherSettings.value + val result = WaveLogApi.testToken(s.wavelogUrl, s.wavelogApiKey, s.wavelogStationId) + when (result) { + is WavelogResult.Success -> showToast("WaveLog: ${result.message}") + is WavelogResult.Failure -> wavelogError = result.message + } + } + } + private fun uploadWavelogQueue() { viewModelScope.launch { val result = pendingUploader?.uploadQueue() when (result) { - is UploadOutcome.Done -> showToast("WaveLog: ${result.message}") + is UploadOutcome.Done -> { + if (result.failedCount > 0 && result.firstError.isNotBlank()) { + wavelogError = result.firstError + } else { + showToast("WaveLog: ${result.message}") + } + } is UploadOutcome.NeedConfirm -> { gridConfirm = GridConfirmData(result.stationGrid, result.userGrid) }