mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
fix(wavelog,radar): 4.5.2 second overwrite - v1 API support, error dialog with copy, frequency sync
Background: user's WaveLog server has NO v2 API (all /api/v2/* return
404; /api/qso v1 works - verified with curl). First fix only tried v2
paths, so uploads still failed with 404. Also: Log tab frequencies did
not match the Doppler panel, linear transponders showed a single
frequency instead of the passband range, and errors were toast-only
(no copy).
Changes:
- WaveLogApi: full v1 support with auto fallback
- v2 first (Bearer header + JSON fields), on 404 fall back to v1
(key inside JSON body + ADIF string) - both with and without
index.php prefix
- test connection: v2 GET api/v2/token -> v1 POST
api/get_contacts_adif (validates key + station id)
- station gridsquare is v2-only; on v1 the uploader falls back to
the user's QTH grid (grid check skipped/equal)
- v1 ADIF: call/band=SAT/mode/freq+freq_rx (MHz)/qso_date/time_on
(UTC, compact)/gridsquare(4)/sat_name/prop_mode=SAT, byte-length
field prefixes
- 409 duplicate counts as success in both versions
- Error dialog with copy: test/upload failures now open an AlertDialog
with the full error (incl. actual URL + HTTP code), Copy button
(ClipboardManager) and Cancel; uploader collects the first failure
message
- Log tab frequency sync: LogTab receives txBaseFrequencyHz from the
radar page (the tuned frequency shown in the transceiver panel);
RX is computed through the same Doppler mapping as the Doppler
panel; linear transponders show the full uplink/downlink range
(Doppler-corrected low-high) instead of a single frequency
- Restored uploadWavelogQueue (lost in an earlier patch)
Verified: check_strings.py OK (8 files, 452/4.5.2);
:app:compileDebugKotlin BUILD SUCCESSFUL.
This commit is contained in:
1 parent
5351afe508
commit
9d437a90c9
10 files changed
+250
-147
No files matched your search
+140
-112
@@ -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("<eor>")
|
||||
}
|
||||
}
|
||||
|
||||
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<Int, String> {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
+15
-7
@@ -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? {
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
||||
<string name="pass_whatsnew_title">Apa yang baru di Look4Sat</string>
|
||||
<string name="pass_whatsnew_message">
|
||||
* 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</string>
|
||||
* 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</string>
|
||||
<string name="radar_back">Kembali</string>
|
||||
<string name="radar_notify">Beri tahu</string>
|
||||
<string name="radar_az_text">Azimuth</string>
|
||||
@@ -195,6 +195,7 @@
|
||||
<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="wavelog_copy">Salin</string>
|
||||
<string name="wavelog_delete_undo">Urungkan</string>
|
||||
<string name="wavelog_col_time">Waktu</string>
|
||||
<string name="wavelog_col_freq">Frek.</string>
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* 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ü)</string>
|
||||
* 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</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Geri</string>
|
||||
@@ -205,6 +205,7 @@
|
||||
<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="wavelog_copy">Kopyala</string>
|
||||
<string name="wavelog_delete_undo">Geri al</string>
|
||||
<string name="wavelog_col_time">Zaman</string>
|
||||
<string name="wavelog_col_freq">Frekans</string>
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* 修复 WaveLog 上传 404(URL 处理 + 自动路径重试)
|
||||
* 转发器改为下拉选择框; 修复左滑删除; 更多菜单新增「日志」页(表格视图)</string>
|
||||
* WaveLog 同时支持 API v1 和 v2(自动识别); 错误弹窗带一键复制
|
||||
* 日志频率与多普勒面板一致; 线性转发器显示完整频段区间</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">后退</string>
|
||||
@@ -188,6 +188,7 @@
|
||||
<string name="wavelog_grid_mismatch">当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传?</string>
|
||||
<string name="wavelog_ignore">忽略并上传</string>
|
||||
<string name="wavelog_cancel">取消</string>
|
||||
<string name="wavelog_copy">复制</string>
|
||||
<string name="wavelog_delete_undo">撤销</string>
|
||||
<string name="wavelog_col_time">时间</string>
|
||||
<string name="wavelog_col_freq">频率</string>
|
||||
|
||||
@@ -56,8 +56,8 @@
|
||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* 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</string>
|
||||
* 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</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Back</string>
|
||||
@@ -219,6 +219,7 @@
|
||||
<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="wavelog_copy">Copy</string>
|
||||
<string name="wavelog_delete_undo">Undo</string>
|
||||
<string name="wavelog_col_time">Time</string>
|
||||
<string name="wavelog_col_freq">Freq</string>
|
||||
|
||||
@@ -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<String?>(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<Long, Long> {
|
||||
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
|
||||
)
|
||||
|
||||
@@ -275,7 +275,8 @@ private fun PagerCard(
|
||||
satelliteName = uiState.currentPass?.name ?: "",
|
||||
queue = wavelogQueue,
|
||||
wavelogConfigured = wavelogConfigured,
|
||||
showToast = showToast
|
||||
showToast = showToast,
|
||||
txBaseFrequencyHz = uiState.radioControl.txBaseFrequencyHz
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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
|
||||
|
||||
+21
-14
@@ -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<GridConfirmData?>(null)
|
||||
|
||||
// WaveLog 错误详情弹窗(UI 观察后弹 AlertDialog, 可一键复制)
|
||||
var wavelogError by mutableStateOf<String?>(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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user