Commit Graph
308 Commits
Author SHA1 Message Date
mckero e1233dceaa fix(wavelog): preserve six-character grids in v1 ADIF upload
WaveLog v1 truncated every grid to four characters with gridsquare.take(4),
while v2 sent the same grid at full precision. QRZ backfill provides six-character
locators (e.g. OM89ab / FN31pr), so the v1 path - the one used by the user's
server in practice - degraded position precision from roughly 4.6 km to around
100 km and stored different data depending on which API version answered.

Send the complete grid through v1 as well. The ADIF length field is already
computed from the actual value, so six/eight-character locators need no special
handling.

TDD proof:
  old take(4): v1_adif_preservesSixCharacterGrid FAILED
  fixed:       WaveLogApiPayloadTest BUILD SUCCESSFUL
  full suite:  :core:domain:test BUILD SUCCESSFUL
2026-08-14 14:56:23 +00:00
mckero 09e728fa1b fix(data): close sockets when connect() fails after the handshake
All five connect paths opened a socket, completed the TCP/RFCOMM handshake,
and only afterwards stored it in a field. Any exception in between leaked the
socket: the catch block just flipped a boolean, and disconnect() can only close
what already reached the fields.

Leak windows (statements that can throw after the handshake succeeded):
  AprsIsClient.connect        soTimeout / tcpNoDelay / getOutputStream / getInputStream
  Ic705Controller.connect     outputStream / inputStream / sendAndWaitAck
  Ft817Controller.connect     outputStream / inputStream
  BluetoothReporter x2        outputStream

AprsIsClient is the worst case because AprsReporter retries on a timer
(intervalMin, minimum 1 minute) and nulls out the client after each failure,
so every failed attempt permanently loses one fd:

  failure rate   leaked fds/hour   time to exhaust 1024 fds
       5%              3.0              ~14.2 days
      20%             12.0               ~3.6 days
      50%             30.0               ~1.4 days
     100%             60.0              ~17 hours

Typical trigger is a weak link where the TCP handshake succeeds but the peer
immediately RSTs (overloaded or rate-limiting APRS-IS server). Once fds run out
nothing in the process can open a socket or file any more: TLE updates, AMSAT
status and WaveLog uploads all start failing with no obvious cause.

Each path now keeps a local reference to the socket it opened and closes it in
the catch block, also clearing the stream/socket fields so a half-initialised
connection is not mistaken for a live one.

Verified: :core:data:compileReleaseKotlin BUILD SUCCESSFUL; grep confirms all
five close calls are present.
2026-08-14 14:09:42 +00:00
mckero c1895506e0 fix(cw): flush archive buffer inside loop to stop dropping audio
CwDeepDecoder appended evicted samples with a bare bounds check:

    for (v in overflow) {
        if (archiveSize < archiveBuffer.size) archiveBuffer[archiveSize++] = v
    }
    if (archiveSize >= ARCHIVE_THRESHOLD) { flush() }

Once archiveBuffer (64000 samples / 20 s) filled up mid-batch the remaining
samples were silently discarded, because the flush only ran after the loop.

Worst measured case: 47999 samples already accumulated (just under the 48000
flush threshold, so no flush) plus a 64000-sample overflow batch means 111999
samples pushed into a 64000 buffer -> 47999 dropped, i.e. 15 s of audio missing
from the permanently archived CW history.

Now the buffer is flushed as soon as it is full and before appending, so every
sample reaches archiveDecode. Simulation over five batch patterns: dropped
count goes 47999 -> 0 for the worst case and all 111999 samples are archived.

Bounds: single append() can evict at most capacity samples, so drainOverflow()
returns at most 64000 - archiveBuffer never needs to grow.
2026-08-14 13:28:55 +00:00
mckero 066fafbb81 fix(data): use Mutex to queue calculatePasses, not drop calls
The previous guard (if (_isCalculating.value) return) silently dropped
concurrent calls. Every call carries filter settings the user just applied,
so a dropped one left the list showing results for the previous filter:

  User clicks 'Apply' with elevation>=5
  -> UI updates to show elevation>=5
  -> calculatePasses(elevation>=5) called
  -> but if _isCalculating=true, return immediately
  -> list still shows elevation>=30 results

The guard window is wide: delay(1000) + real calculation time (hundreds
of ms to seconds), exactly when the progress indicator spins and users
naturally interact again.

Mutex serializes calls instead: the second one queues and eventually runs
with its own parameters. This also fixes the original concurrency issue
(duplicate parallel calculations) and adds finally {} so a thrown exception
cannot leave isCalculating stuck at true (frozen progress indicator).

Reverts the regression introduced in the previous attempt to add concurrency
protection.
2026-08-14 13:07:53 +00:00
mckero 74902cddce fix(i18n): escape single quote in Turkish whatsnew string
Android AAPT requires single quotes in string resources to be
escaped as \' to avoid being interpreted as the start of an
escape sequence. The unescaped Ayarlar'ı triggered:
'Invalid unicode escape sequence in string'

values-tr/strings.xml:108 Ayarlar'ı → Ayarlar\'ı
2026-08-14 11:44:16 +00:00
mckero 8324903104 chore(release): bump versionCode to 462 and refresh whatsnew for v4.5.7
Increment versionCode 461 → 462 to allow reinstallation over the existing
v4.5.7 APK (required for覆盖发行版 to work on user devices).

Update whatsnew in all 4 locales (en/zh/tr/id+in) to document the 10 bug
fixes shipped in this release:
- Menu layout: Settings永久消失, AMSAT/WavelogLog forced migration
- DataParser: epoch parsing for UTC 00:00:01–00:01:26
- Radar: auto-switch to next pass, live Doppler offset
- Passes: division by zero in progress calculation
- SatelliteRepo: concurrent calculatePasses race
- WaveLog: duplicate QSO submission, grid square update race

Release notes now include both the DeepCW fp32 migration and the 10 fixes.
2026-08-14 11:38:59 +00:00
mckero 29ca4c29bb fix(data): 拒绝 calculatePasses 并发调用防止重复计算
根因:
SatelliteRepo.calculatePasses() 在耗时计算期间如果被重复调用,
会启动多个协程同时遍历卫星列表,导致:
1. 重复的 SGP4 轨道计算(CPU/电池浪费)
2. passes MutableStateFlow 被多次更新,触发下游 UI 重组风暴
3. _isCalculating 标志被后续调用覆盖,可能提前置 false

修复:
在 _isCalculating.value = true 之前检查当前值,如果已在计算中则
直接 return,拒绝并发调用。

影响:
防止用户快速切换过滤条件、旋转屏幕等场景下的重复计算。
_isCalculating 现在是真正的互斥信号量(简化版,无等待队列)。
2026-08-14 11:22:07 +00:00
mckero d749c5ab48 fix(domain): WavelogQueue.updateGridsquare 添加同步锁
问题:updateGridsquare 是唯一没有 @Synchronized 的修改方法,与 add() 并发(LogTab 主线程 add + 后台协程 updateGridsquare)会触发 read-modify-write 竞态,导致新 QSO 丢失。
修复:给 updateGridsquare 加 @Synchronized,与其他修改方法保持一致。
影响:消除数据丢失风险。
2026-08-14 09:19:41 +00:00
mckero cf93ca9f71 fix(ui): 修复菜单布局的三个严重 bug
Bug #1: moveToMain 误驱逐页面
- 根因:每次调用 moveToMain 都执行驱逐逻辑,即使页面本来就在主菜单
- 场景:拖拽主菜单内部顺序 → SettingsViewModel 遍历新顺序逐个调 moveToMain
  → 每次都判断 main.size > 5 → 误驱逐最后一个页面
- 修复:只在真正从 More 移到主菜单时才驱逐(加 wasInMore 标志位)

Bug #2: onReorder 传 resolve 输出污染状态
- 根因:SettingsScreen 拖拽回调传的是 resolve 输出(mainItems/subItems)
  而不是持久化输入(screenOrder/subMenuOrder)
- 场景:拖拽主菜单 → onReorder 传 [Radar, ..., Settings](完整列表)
  → Settings 被显式存入 screenOrder → 下次 resolve 当作用户手动放置 → 参与驱逐逻辑
- 修复:
  1. 拖拽主菜单时过滤掉 Settings(锁定页面用默认位置,不存持久化)
  2. 另一个菜单用输入的 screenOrder/subMenuOrder,不用 resolve 输出

Bug #3: onReorder More 菜单传错参数
- 根因:拖拽 More 菜单时传的是 mainItems(resolve 输出),不是 screenOrder
- 修复:改用输入的 screenOrder

影响:修复前,拖拽主菜单会丢页面,移页面到主菜单可能导致 Settings 消失
2026-08-14 00:43:23 +00:00
mckero 7cadded6ca fix(data): compute the OMM epoch day fraction numerically, not by string surgery
排查页面顺序问题时顺带审计发现: OMM/CSV 历元在子夜后约 86 秒内会被解析成
完全错误的值, 且不抛异常 —— 静默的错误数据。

## 根因

DataParser.parseCSV 用字符串拼接构造历元:

    val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
    val epoch = "${year.substring(2)}$day$frac".toDouble()

substring(1) 的意图是切掉 "0.123" 的前导 0。但 Double.toString() 在数值
小于 1e-3 时切换为科学计数法, 于是被切掉的是【有效数字】, 剩下的指数后缀
让整个字符串重新变成一个合法但语义完全错误的 double。

Kotlin 侧实测(单测失败信息):

    00:00:01.000  期望 25001.000011574073  实得 0.2500115740740741
    00:01:00.000  期望 25001.000694444443  实得 2.5001944444444444

与 JDK 侧独立验证逐位一致。00:01:26.4 之后 frac >= 0.001, 不再用科学计数法,
所以这个 bug 只在每天前 86.4 秒的历元上出现(约占 0.1%), Celestrak OMM 数据
里整分历元并不罕见。

## 影响

runCatching 抓不到(没有异常), 该卫星的 juliandDateOfEpoch 会推出 year=2000
day≈0, tsince 偏差约 26 年 —— 方位/仰角/过境预报彻底失效, 不是精度下降。
且用户无从察觉。

## 修复

改为数值相加, 不经过字符串:

    val dayFraction = (hour + min + sec + ms) / 86400000.0
    val epoch = "${year.substring(2)}$day".toDouble() + dayFraction

"25001".toDouble() + 0.0000115 = 25001.0000115, 无科学计数法风险。

## 验证

DataParserTest 新增 5 个历元回归测试(子夜整点/子夜后 1 秒/子夜后 1 分钟/
正午/当日最后一毫秒)。先确认前两个在旧实现下失败(failures=2), 修复后:

- DataParserTest 24 个测试全绿(原有 19 个无回归)
- :core:domain:test 全量 105 个测试 0 失败 0 错误
2026-08-13 16:20:07 +00:00
mckero 6fc2f560b7 fix(nav): resolve the menu layout in core:domain so page order actually applies
用户报告"把 AMSAT 从更多菜单移到主菜单没生效"。排查后发现这是两个互相
掩盖的 bug, 其中一个会让用户永久无法进入设置页。

## Bug 1 (致命): 移任意页进主菜单 -> 设置入口从所有菜单消失

对"主菜单上限 5"的定义两处不一致:
- SettingsScreen.kt:892 判断 mainItems.filter { it != "Settings" }.size >= 5,
  上限是【不含 Settings 的 5 个】, 于是认为 [Satellites,Passes,Radar,Map]
  还有空位, 直接追加 -> 共 6 项
- MainScreen.kt:165 的 .take(5) 上限是【含 Settings 的前 5 个】, 而 Settings
  排在最后 -> 被截断丢弃

结果 Settings 既不在底栏也不在更多菜单(它不在 subMenuOrder 里), 用户再也
进不去设置页, 无法自行改回, 只能清数据或重装。

## Bug 2 (用户报告): AMSAT / WavelogLog 移到主菜单被静默撤销

MainScreen.kt:161-163 给老用户补新页面的迁移逻辑【无条件执行】:

    .let { list -> if ("AMSAT" in list) list else list + "AMSAT" }

用户把 AMSAT 移出子菜单后 subMenuOrder 里没有它, 这段又加回去, 第 165 行
filter { it.screenId !in subOrder } 于是永远过滤掉 AMSAT。

副作用: 这个 bug 恰好把主菜单拉回 5 项内, 反而掩盖了 Bug 1 —— 所以用户只
看到"AMSAT 移不过去", 没触发"设置锁死"。

## Bug 3: 溢出页面凭空消失而非落入更多菜单

.take(5) 直接丢弃超出的页面, 它们既不在底栏也不在更多菜单。

## Bug 4: 设置页展示顺序与底栏实际顺序不一致 (WYSIWYG 失效)

两处各自实现排序: 设置页不做 take(5)、用 sortedWith 把 Settings 钉最后;
MainScreen 做 take(5)、Settings 位置由 screenOrder 决定。

## Bug 5: 更多菜单里 AMSAT / Roaming 当前页不高亮

MoreMenuPopup.kt:69-79 的 when (currentKey) 缺 AmSat 与 Roaming 分支,
MainScreen.kt:188-198 缺 Roaming 分支, 靠 else -> false 兜底, 新增页面时
不会有编译错误提醒。Screen 子类都是 data object, 直接用 currentKey == screen
即可, 新增页面自动生效。

## Bug 6: 设置页主菜单区不过滤 hiddenScreens

MainScreen 会过滤隐藏页, 设置页不过滤, 于是隐藏的页面仍占据设置页的位置且
"移出主菜单"按钮可点, 与底栏实际情况不符。

## 改动

新增 core/domain/navigation/MenuLayout.kt (纯 Kotlin, 为 KMP 就绪) 作为菜单
布局的唯一入口:
- resolve(): 持久化偏好 -> 底栏 + 更多菜单。先给 Settings 预留名额再截断,
  溢出页面落入更多菜单而非丢弃; 两个列表都没提到的页面按默认归位(升级不丢页)
- moveToMain() / moveToMore(): 移动语义集中一处, 拒绝把 Settings 移出

MainScreen 与 SettingsScreen 改为共用它, 删除双份实现与无条件迁移逻辑。

## 死代码清理 (每项已 grep 全仓库确认零引用)

- SettingsAction.ResetScreenOrder: 无任何派发点, 仅定义与 when 分支
- UiSettingsCard 的 onReorder 参数: 函数体内两个 DragOrderList 的 onReorder
  实参都调 onUpdateMenu, 从未调用该参数
- SettingsAction.ReorderScreens: 唯一引用是上面那个死参数
- MainScreen 的 navigateToRadar 参数: 函数体内唯一出现是注释掉的一行
- Navigation.kt 的 defaultScreenOrder / defaultSubMenuOrder: 迁移后零引用,
  默认值已在 MenuLayout 内

保留 entry<RadarDestination> 分支不动 —— RadarDestination NavKey 被
PassDetailsMatcher deeplink 使用。

## 验证

- MenuLayoutTest 13 个新测试全绿, 每个对应上面一个 bug 场景
- :core:domain:test 全量 100 个测试 0 失败 0 错误
  (DataParser 19 / Doppler 17 / Qth 8 / Transponder 7 / CwCtc 7 /
   CwDeepBuffer 13 / CwGolden 3 / CwSpectrogram 8 / MenuLayout 13 /
   WaveLogApi 5)
- :core:domain:compileKotlin + :core:presentation + :app +
  :feature:settings compileDebugKotlin => BUILD SUCCESSFUL
- 用 Python 复刻新规则重跑当初失败的全部场景: 5 个页面逐一移入主菜单,
  设置入口全部保住、页面无丢失; 隐藏页 + 移动组合无页面丢失; 幂等性通过
2026-08-13 16:10:41 +00:00
mckero 8adf861ed7 chore(release): refresh 4.5.7 whats-new and bump versionCode to 461
版本名保持 4.5.7 不变(用户要求覆盖同一个发行版, APK 文件名仍为
Look4Sat-Pro-4.5.7.apk)。

versionCode 460 -> 461: 手机上已安装 versionCode 460 的 4.5.7, 若 code
不变则覆盖安装会被系统拒绝。versionCode 对用户不可见。

pass_whatsnew_message 五语(en/zh/tr/in/id)补全本次全部改动, 之前只写了
DeepCW 那批, 本轮 UI 改动与 fp32 模型未包含:
- 内置完整版 fp32 模型
- 更多菜单改靠右窄面板
- CW 页移除无效返回键与误导性状态提示
2026-08-13 14:49:36 +00:00
mckero 21f14848da Merge origin/main (resolve CW fldigi vs DeepCW conflicts, keep DeepCW) 2026-08-13 13:39:13 +00:00
mckero d1668f1c27 docs(cw): update in-app "what's new" for the DeepCW release
发版漏的一步: App 内检测到新版本时弹出的更新内容
(pass_whatsnew_message) 还停留在上一版 AMSAT/WaveLog 的说明,没写进本次
DeepCW CW 解码器的改动。

补上五语 (en/zh/tr/in/id) 的更新说明:
- DeepCW 神经网络 CW 解码 (弱信号解码率大幅提升)
- CW 解码历史永久保留
- CW 瀑布图 inferno 配色 + 平滑渐变
- 恢复 64 位 (arm64)
- 暂停/恢复误报修复
2026-08-13 10:20:30 +00:00
mckero 26c714fc24 fix(cw): stop swallowing coroutine cancellation on pause/resume
用户反馈: 暂停再恢复时经常弹出 "CW decode failed: The coroutine scope
left the composition"。

根因: 暂停 (isListening=false) 使 LaunchedEffect 重启、旧的采集协程被
取消。若此刻 decodeWindow 正在 withContext(Dispatchers.Default) 里做 ONNX
推理, 取消传播时会抛出 LeftCompositionCancellationException
(message 即 "The coroutine scope left the composition", 是 CancellationException
的子类)。processBuffer 的 catch (Throwable) 把它当成解码失败吞掉, 既误报了
错误横幅, 又破坏了协程取消的正常传播。

修复:
- processBuffer 的 decodeWindow catch 里, CancellationException 直接
  rethrow (暂停导致的中断是正常流程, 不是错误)。
- archiveDecode 调用同样包 try-catch, CancellationException rethrow,
  其余异常只记日志不崩溃。

这是协程的标准纪律: 永远不要把 CancellationException 当业务异常吞掉。

版本: 4.5.5 -> 4.5.7 (versionCode 460)。此前多次删 v4.5.5 tag 重打导致
release 页面累积 12 个 draft 草稿, 已全部清除。
2026-08-13 09:55:15 +00:00
mckero 2e1d8b9c00 feat(cw): archive decoded history, polish the waterfall, document licensing
三个用户反馈一并解决:

1. 解码文字不再消失 (核心)
   旧实现: 20 秒环形缓冲满了就静默覆盖最旧样本, 文字随之从屏幕消失。
   新实现: CwDeepBuffer 新增 overflow —— 满时被覆盖的旧样本先进 overflow,
   解码器累积到 15 秒就单独解码一次, 结果追加到 historyText (只增不减)。
   UI 记录区显示 historyText + decodedText (历史稳定 + 当前窗口实时)。
   ICwDecoder 接口新增 historyText StateFlow。

   归档音频已离开主窗口, 不再被 CTC 修正, 故其文本是"最终版", 追加安全。
   归档窗口 15 秒: 内容已在 20 秒窗口里解过多次, 短一点几乎无损, 且推理
   开销小。

2. 瀑布图更好看
   配色从"深蓝->青->黄"换成 matplotlib inferno (黑->紫->品红->橙->黄),
   与静态频谱图保持一致。相邻 bin 之间用水平渐变做线性插值, 消除 65 列
   离散方块的像素感。

3. AGPL 合规补漏 (用户提醒: 仓库许可证没体现 AGPL 组件)
   README 新增 License 章节: 声明项目主体 GPL-3.0 + feature/cw 的 DeepCW
   模型 AGPL-3.0-only, 并说明合并作品按 GPL-3.0 §13 / AGPL-3.0 §13 处理。

验证:
- :core:domain:test => 31 个 CW 测试全绿 (CwDeepBufferTest 新增 3 个
  overflow 归档测试: 顺序/清空/reset)
- :core:domain:compileKotlin + :core:data + :feature:cw:compileDebugKotlin
  => BUILD SUCCESSFUL
2026-08-13 08:04:05 +00:00
Arty Bishop 7cdc2952dc v4.4.6 - AMSAT status page, fully customizable data sources 2026-08-13 09:47:26 +02:00
mckero ac5cbbc49a fix(cw): add crash probes to localize adb-less flash-crash
闪回桌面且无任何 Java 日志 => 崩溃发生在 native 层 (ONNX 库内部段错误)
或进程被系统 LMK 直接杀掉 (内存不足) —— 两种情况 Java 的
uncaughtExceptionHandler 都不会触发, crash_log.txt 自然为空。

新增 CwProbe: 在关键节点向 files/probe_cw.txt 追加时间戳行:
  decoder_constructed -> load_begin -> load_session_ok / load_failed:<类名>
  -> infer_begin -> infer_done
进程若中途死亡, 最后一行的节点就是崩溃/被杀位置, 无需 adb 即可定位。

验证: :core:data:compileDebugKotlin => BUILD SUCCESSFUL
2026-08-13 00:46:56 +00:00
Arty Bishop bd51044690 Added custom frequency offset setting to network reporting 2026-08-12 20:17:26 +02:00
mckero cf49dcfa01 fix(cw): persist decoder failures to files for adb-less diagnostics
在服务器上完整复现了手机端流程 (真实 20s 含噪音频 -> 重采样 -> 频谱 ->
ONNX -> 贪心 CTC), 解码逐字符正确:
  decoded: [CQ CQ DE BG7NTA BG7NTA K 5NN TU 73]   (与参考完全一致)
整条链路逻辑无问题, 闪退属环境/平台层。

应用内已有全局崩溃捕获器 (MainApplication.installCrashHandler) 会把堆栈
写入 files/crash_log.txt —— 无需 adb 即可取到崩溃原因。

本提交为加载与推理两处异常补充落盘:
- 模型加载失败 -> files/deepcw_load_error.txt
- 推理异常 -> files/deepcw_infer_error.txt

用户可在文件管理器按
Android/data/com.rtbishop.look4sat.bg7nta/files/ 路径取回这些日志。

验证: :core:data:compileDebugKotlin => BUILD SUCCESSFUL
2026-08-12 16:43:52 +00:00
mckero 5f297f9233 fix(cw): stop the waterfall crashing and cut APK size by 87MB
三个真机实测暴露的问题:

1. 闪退 (给权限后 3-4 秒必崩, 且注入日志抓不到堆栈)
   CwWaterfallState 用 mutableIntStateOf 记录重绘版本号, 却从音频采集线程
   写入。Compose 快照状态只能在合成线程修改, 从后台线程写会在运行时崩溃 ——
   崩在 Compose 内部, 所以业务类的日志注入抓不到。
   改用 MutableStateFlow (本身线程安全), Canvas 侧 collectAsState 读取。

2. 同一状态的跨线程数据竞争
   pushSamples 在采集线程写 rows/pending, snapshot() 在绘制线程读, 而
   ArrayDeque 非线程安全 —— 并发 removeFirst()/toList() 会抛
   ConcurrentModificationException 或 IndexOutOfBoundsException。
   两侧统一加锁; FFT 计算放在锁外, 只有队列追加持锁。

3. APK 从 8.4MB 暴涨到 135MB
   onnxruntime-android 的 AAR 自带 4 个架构原生库: arm64 28M + armv7 20M +
   x86 33M + x86_64 34M = 115MB。上次提交移除 abiFilters 时把 x86 系列也
   打包了进去 (仅模拟器需要)。
   重新加上 abiFilters, 保留 arm64-v8a + armeabi-v7a 两个真机 ABI,
   预计降至约 43MB。注意这与上次"恢复 64 位"不冲突: 那次删的是只留
   armeabi-v7a 的限制, 这次是排除 x86 系列。

另外两处加固:
- CwDeepDecoder 的模型加载从 init{} 移入惰性 ensureLoaded(): 加载会触发
  ONNX Runtime 原生库装载, 失败时抛 UnsatisfiedLinkError; 在构造函数中抛出
  会连带崩掉创建它的 composable, try-catch 也救不回来。移到首次使用时执行,
  失败经 errorMessage 上报给 UI。
- ONNX 会话限制 intraOp 线程数为 (核数-1) 且上限 4, 给音频采集和 UI 留出
  余量, 默认行为会铺满所有核心。

验证:
./gradlew :feature:cw:compileDebugKotlin :core:data:compileDebugKotlin => 通过
./gradlew :core:domain:test => 79 个测试全绿
2026-08-12 13:55:03 +00:00
mckero d93e3b4028 feat(settings): credit DeepCW (AGPL-3.0) in the about section
AGPL-3.0 要求署名。在设置页"关于"卡片的许可说明下方显示 DeepCW 模型的
作者 (e04)、许可证 (AGPL-3.0-only) 与上游仓库链接。

字符串标 translatable="false": 署名与许可证名称不应翻译。
完整声明见 feature/cw/licenses/NOTICE.md。

验证: ./gradlew :feature:settings:compileDebugKotlin => BUILD SUCCESSFUL
2026-08-12 13:03:59 +00:00
mckero 9b0d543f12 refactor(cw)!: replace legacy CW engine with DeepCW in both entry points
DeepCW 成为唯一 CW 解码内核, 不保留旧引擎作兜底 (用户决定: 完全移植)。

删除旧内核 (1298 行):
- CwDecoder.kt / CwBayesianDecoder.kt / CwChannelTracker.kt / CwSpectrogram.kt
- CwDsp / CwFFT / CwSTFFT / CwGoertzel / CwFilter / CwResampler.kt
- CwDecoderTest.kt
旧内核的自动定频与 squelch 门控由 DeepCW 的 400-1200Hz 固定频窗替代 ——
模型自带定频, 不再需要频谱峰值跟踪和噪声门。

两个 CW 入口都改接 DeepCW:
1. feature:cw 独立整页 CwDecodeScreen.kt 重写为纯 Compose (瀑布图 -> 实时行
   -> 历史区), 移除 AndroidView/LayoutInflater 和被删的 activity_main 布局;
   删除 CwSettingsDialog.kt (旧引擎的手动音调/带宽面板, DeepCW 全自动无需)
2. feature:radar 内嵌可折叠面板改走 ViewModel 的 CW state/action, 移除
   Morse Expert 控制器与 cw_panel_main 布局
   (该面板此前注释写明 "no longer feeds it", ViewModel 里的 CW 通路是死代码)

新增 CwWaterfall.kt: 复用 CwDeepSpectrogram 绘制瀑布图, 显示的正是模型
分析的 400-1200Hz 频段与同一批幅值。

接口接线:
- IMainContainer.provideCwDecoder() 提供 ICwDecoder (实现需 Context 读 assets)
- RadarViewModel 构造新增 cwDecoderFactory, onCleared() 中 close() 释放
  OrtSession 避免原生内存泄漏
- 删除已无调用方的 RadarAction.CwSetToneFreq (DeepCW 定频固定, 无参数可调);
  CwSubState.cwToneFreq 语义改为只读显示检测到的音调

依赖清理:
- feature:radar 不再依赖 feature:cw 与 constraintlayout
- feature:cw 不再依赖 constraintlayout

字符串: feature:cw 清理 Morse Expert 遗留串 (premium/rate/help/
tap_back_again_to_close/purple_500), 按 en/zh/tr/in/id 五语补全新串;
core:presentation 补 radar_cw_tone / radar_cw_waiting 五语。

验证:
./gradlew :core:domain:test => 4 个测试类全绿
./gradlew :feature:cw:compileDebugKotlin => BUILD SUCCESSFUL
./gradlew :feature:radar:compileDebugKotlin => BUILD SUCCESSFUL
./gradlew :core:data:compileDebugKotlin => BUILD SUCCESSFUL
2026-08-12 13:00:25 +00:00
mckero 57762613a8 feat(cw): add CwDeepDecoder wiring ONNX Runtime to the CW pipeline
组装完整解码链路: 麦克风 PCM -> 重采样 3200Hz -> 20 秒滚动缓冲 ->
频谱图 -> ONNX 推理 -> 贪心 CTC -> 文本。

ICwDecoder 放 core:domain (纯 Kotlin, UI 可直接依赖), 实现放 core:data
(需要 Android Context 读 assets 及 ONNX Runtime 原生库)。

关键设计:
- decodedText 是替换语义不是追加: 模型会随上下文增加改写先前字符, 追加
  会把中间态 (BM -> BG7 -> BG7NTA) 永久留在屏幕上
- 推理用 Mutex.tryLock 串行化: 上一次未完成时直接跳过本周期, 慢设备不会
  堆积任务导致 OOM
- 推理在 Dispatchers.Default, 不阻塞音频采集线程
- 模型元数据从 model.onnx.json 读取 (字符表/blank 索引/输入输出名), 不在
  代码里硬编码, 模型更新时无需改代码
- lastInferenceMs 打点: 手机实际推理耗时需真机实测 (估算系数待验证)
- estimatedPitch 由频谱峰值 bin 反算, 仅供 UI 显示 —— 模型 400-1200Hz
  固定窗自带定频, 不需要频谱峰值跟踪参与解码
- 模型加载失败时 errorMessage 置位 (旧内核已删, 无兜底可退)
- close() 释放 OrtSession, 否则原生内存泄漏

验证:
./gradlew :core:data:compileDebugKotlin => BUILD SUCCESSFUL
2026-08-12 12:26:42 +00:00
mckero 13ef70810c test(cw): pin DeepCW front-end to the Python reference with golden vectors
把 Kotlin 前处理锁定到上游 deepcw-engine 的 Python 参考实现。

golden_spec.txt 由参考实现自身的预处理代码生成 (numpy reflect padding,
np.hanning(N+1)[:-1], np.fft.rfft, log1p), 覆盖一段确定性测试音频:
700Hz 方波键控的字母 C, 20WPM, 源采样率 8000Hz。两侧用同一个确定性公式
各自合成音频, 因此只需锁定频谱图结果, 无需落盘音频。

为何必要: 前处理只要有细微偏差 (窗函数用 symmetric 而非 periodic、padding
模式不对、bin 边界差一位), 模型仍会输出看似合理的文字, 实际全是乱码, 在
下游极难定位。此测试守住整条链路的入口。

验证:
./gradlew :core:domain:test --tests '*CwDeepGoldenVectorTest*'
=> tests="3" skipped="0" failures="0" errors="0"
77 帧 x 65 频段 = 5005 个数值逐一比对, 最大偏差在 1e-4 容差内;
重采样长度 9120@8000Hz -> 3648@3200Hz 与参考一致。
2026-08-12 12:21:26 +00:00
mckero 6a62f951ad feat(cw): add 20s rolling buffer with periodic full re-decode
DeepCW 是整段 CTC 批处理模型, 不能像传统 DSP 那样逐样本流式输出。

实测否决了滑动窗口+增量拼接方案。整段批处理 CER 0.0%, 而所有窗口/步进
组合最好仅 67.6%, 最差 294.1% (呼号被重复三遍)。根因是模型每获得更多
上下文就重写已输出内容 —— 同一音频逐渐加长时 11 次采样有 6 次修正前缀
(BM -> BG7 -> BG7NTI -> BG7NTA), 任何"只追加增量"策略都会把这些中间态
永久留在屏幕上。

改为: 固定 20 秒滚动缓冲 + 每 1.5 秒重解全量 + 整体替换显示。

参数由实测定案, 是两条约束的交点:
- 准确率侧: 20s 是达到 0.0% CER 的最小窗口 (16s 仍有 5.9%)
- 算力侧: 耗时超线性增长, 60s 音频推理耗时约为 20s 的 13 倍, 实时余量
  不足; 20s 时余量约 13 倍, 安全

实现: 环形缓冲, snapshot() 返回按时序展开的副本 (调用方无法污染内部
存储), append() 返回是否该触发重解。

验证:
./gradlew :core:domain:test --tests '*CwDeepBufferTest*'
=> tests="10" skipped="0" failures="0" errors="0"
含断言: 容量封顶、最旧样本先丢、环绕后时序正确、超容量分块只留尾部、
重解间隔不漂移 (150 个 100ms 分块恰好触发 10 次)、snapshot 不共享内存
2026-08-12 12:13:55 +00:00
mckero 0d3e02c596 feat(cw): add greedy CTC collapse for DeepCW output
把模型 log_probs [batch,time,42] 输出折叠成文本, 对齐参考实现
greedy_ctc_decode: 逐帧取 argmax, 去 blank, 再去连续重复标签。

blank 的作用: 两个相同字母之间的 blank 是保住真实双字母的关键
(例如 "5NN" 的两个 N), 否则会被折叠成一个。

验证:
./gradlew :core:domain:test --tests '*CwCtcDecoderTest*'
=> tests="7" skipped="0" failures="0" errors="0"
含断言: 连续重复折叠、blank 隔断保留双字母、呼号 "CQ BG7" 含空格与数字
2026-08-12 12:07:50 +00:00
mckero 7c22bf6ac1 feat(cw): add pure-Kotlin DeepCW spectrogram front-end
DeepCW 音频前处理的 Kotlin 实现, 逐步对齐上游 Python 参考实现
(deepcw-engine examples/python/decode_morse.py):

  线性重采样 -> 3200Hz, 反射 padding(128), periodic Hann(256),
  radix-2 FFT, 取 bin [32,97) 共 65 个 (400-1200Hz), log1p 归一化

实现要点:
- Hann 窗用 periodic 变体 (numpy np.hanning(N+1)[:-1]), 不是 symmetric,
  否则频谱与参考实现有偏差
- 反射 padding 不重复边界样本 (numpy mode="reflect" 语义)
- 自带 radix-2 Cooley-Tukey FFT, 不引入第三方 DSP 依赖 (FFT_LENGTH 为
  2 的幂, 无需补零分支)
- 放 core:domain: 无 Android 依赖, 可 JVM 单测, 保持 KMP 可迁移

模型 400-1200Hz 固定频窗意味着定频内建, 无需频谱峰值跟踪与 squelch 门控 ——
这正是旧内核最大的两个坑。

验证:
./gradlew :core:domain:test --tests '*CwDeepSpectrogramTest*'
=> tests="8" skipped="0" failures="0" errors="0"
含断言: 700Hz 正弦峰值落在相对 bin 24; 8000->3200Hz 重采样后频率不变
2026-08-12 12:05:13 +00:00
mckero b8113fc757 build(cw): add onnxruntime-android 1.28.0 to core:data
DeepCW 模型推理需要 ONNX Runtime。该依赖属 Android 平台依赖 (AAR 内含
各 ABI 原生库), 因此放 core:data 而非 core:domain —— 后者按 AGENTS.md
须保持纯 Kotlin/JVM 以留 KMP 迁移余地。

版本固定为 1.28.0 不使用范围区间: release 构建走 R8, 避免依赖意外升级
引入行为变化。

验证:
./gradlew :core:data:dependencies --configuration releaseRuntimeClasspath
=> \--- com.microsoft.onnxruntime:onnxruntime-android:1.28.0
=> BUILD SUCCESSFUL in 3m 48s
2026-08-12 11:52:47 +00:00
Arty Bishop 2f4e3f5802 Added the ability to fully customize data sources via import 2026-08-12 13:01:45 +02:00
Arty Bishop 24eebdef74 Consolidated app dialogs and tweaked bottom sheets 2026-08-11 14:14:14 +02:00
Arty Bishop 3f5b48f270 Integrated the AMSAT status page created by MCKero6423 2026-08-11 14:05:55 +02:00
atsunatsuandatsunatsu 060fa2dfcd Added remembering per-satellite doppler offset (#237)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-11 14:02:15 +02:00
mckero c42e1d7b4e fix(wavelog): send real ADIF band + sat_mode, not the illegal "SAT"
Satellite QSOs uploaded with BAND=SAT, which is not a legal ADIF Band
enumeration value (the legal values are concrete bands: 160M/80M/.../
2M/70CM/23CM...). Loggers that fail to parse an unknown band fall back
to a default — observed as QSOs landing in 160m. SAT is only legal as
PROP_MODE (propagation mode), which is already sent for v1.

Changes (WaveLogApi):
- bandFromHz(): map TX frequency to the real ADIF band (2M for VHF,
  70CM for UHF, etc.)
- satModeFrom(): derive the ADIF SAT_MODE convention string from TX/RX
  bands ("V/U" = VHF up / UHF down, "U/V", "V/S", "U/S"...; empty for
  same-band links)
- v2 JSON: band=<real band>, add sat_mode when non-empty
- v1 ADIF: <band:> real band, add <sat_mode:> when non-empty;
  PROP_MODE=SAT kept

Verification:
- New tests: SO-50 (145.850 up / 436.795 down) -> band 2M, sat_mode V/U;
  AO-73 (435.150 up / 145.950 down) -> band 70CM, sat_mode U/V;
  same-band -> empty sat_mode; satellite freqs never map to 160M.
- All wavelog payload tests + full domain suite green.
2026-08-09 07:48:15 +00:00
mckero 09ebf1f39a feat(cw): add spectral auto-tune so the decoder finds the CW tone
The fldigi port ran a fixed 600 Hz NCO, so any real signal not inside
600±75 Hz (the 150 Hz filter passband) decoded nothing — the decode rate
was effectively zero unless the tone happened to be on frequency. This
mirrors the behaviour of the removed channelTracker: a sliding spectral
peak detector now steers the NCO to the strongest tone.

Changes:
- Collect raw input, run a 512-pt Hann-windowed FFT every frame, find
  the strongest bin in 300..1500 Hz (CW range), smooth-track it.
- First strong peak locks immediately (no RX reset, so the triggering
  element survives); later large jumps (>120 Hz) retune and reset the
  fldigi state machine; small drifts are eased at 20%.
- Absolute energy floor (peak < 30) so silence/noise never steers.
- estimatedPitch now reflects the tracked tone frequency.

Verification:
- New unit test: 900 Hz "CQ" with decoder initialized at 600 Hz decodes
  correctly and pitch moves to ~900 Hz.
- All 9 decoder tests pass; full domain/cw/radar test suites green.
2026-08-09 05:04:48 +00:00
atsunatsuandatsunatsu 8b5960282f Broaden linear transponder detection, logic fixes (#236)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-08 21:31:44 +02:00
mckero 4b835bac5c feat(cw): replace reversed Morse Expert engine with a pure-Kotlin fldigi port
Background:
The CW decoder previously shipped a decompiled copy of the proprietary
Morse Expert 1.15 (com/ve3nea/morse_expert + obfuscated classes,
libnativedecoderjni.so, suncompat black-magic) — a copyright liability.
This removes all of it and reimplements the decoder on the open-source
fldigi (GPL v3) CW engine as a faithful pure-Kotlin port with no JNI.

Changes:
- Delete all Morse Expert reverse-engineered code: MainActivity,
  obfuscated packages (B/B0/D/E2/...), suncompat/, pas/nativedecoder,
  armeabi-v7a libnativedecoderjni.so, and the original View-based layouts
  (activity_main, cw_panel_main, options_menu).
- Add a full fldigi CW pipeline in core/domain/cw:
  - CwFldigiDsp: NCO down-conversion, FFT filter, movavg constants
  - CwFftFilt: overlap-add FFT band-pass filter (fftfilt port)
  - MorseTable + SomTable: full Morse code table + SOM codebook
  - CwFldigiDecoder: decode_stream AGC + hysteresis, state machine,
    adaptive speed tracking (5-55 WPM), SOM winner/normalize matching
- Rewrite CwDecodeScreen as pure Compose (DeepCW-style waterfall,
  live decode line, history, status cards) and CwSettingsDialog
  (speed/bandwidth/SOM) with no View interop.
- Replace the Morse Expert panel in TransceiversPage with a Compose
  panel driving the same decoder; mic capture at 8000 Hz.
- Drop the forced armeabi-v7a abiFilters now that no native lib exists.

Verification:
- 8 unit tests pass (CQ/HELLO at 18-20 wpm, A-J at 30 wpm with
  adaptive tracking, dot/dash/Farnsworth edge cases) — all decode
  correctly from synthesized CW.
- :feature:cw and :feature:radar compile; app assembleDebug succeeds.
- APK contains no ve3nea/nativedecoder/morse_expert classes.
2026-08-08 13:58:19 +00:00
mckero 10eb84690e Added AMSAT satellite status tracking page (#234) 2026-08-08 14:26:36 +02:00
bf25292bf8 Fixed recalculating Radar track on station position change (#235)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
Co-authored-by: wty2019wty <74123961+wty2019wty@users.noreply.github.com>
2026-08-08 14:10:55 +02:00
mckero 284183836e refactor(amsat): replace hand-rolled date parsing with SimpleDateFormat
Addresses @AlanCui4080's review feedback about the manual date calculation.
Uses SimpleDateFormat to eliminate the hand-written calendar math (regex +
days-from-epoch calculation). Avoids java.time since it requires desugaring
on minSdk 24, keeping dependencies minimal.

Before: 23 lines of manual day-from-epoch calculation
After: 7 lines using SimpleDateFormat

Ref: https://github.com/rt-bishop/Look4Sat/pull/233#discussion_r1868599947
2026-08-05 17:02:01 +00:00
mckero de85aa1e8b refactor(amsat): align with Material3 conventions per PR #233 review
Addresses feedback from rt-bishop/Look4Sat#233:

1. Migrate hardcoded colors to MainTheme colorScheme
   - Extend darkScheme: tertiary (Active 0xFF648FFF), tertiaryContainer (Telemetry 0xFFFFB000)
   - Extend lightScheme: tertiary (0xFF3C6FE0), tertiaryContainer (0xFFE09800) for contrast
   - Remove top-level Color() constants from SatStatusScreen.kt
   - Add statusColorOf() mapper using MaterialTheme.colorScheme

2. Move HTTP implementation from domain to data layer (Clean Architecture)
   - Delete AmSatApiClient.kt from core:domain (violates AGENTS.md: "Pure Kotlin, NO Android deps")
   - Migrate to IRemoteSource/RemoteSource in core:data (uses existing OkHttp3)
   - AmSatRepository now depends on IRemoteSource instead of AmSatApiClient

3. Inline JSON parsing (prepare for java.time migration)
   - Parse AMSAT API responses (names, reports) in AmSatRepository
   - Time parsing still uses manual logic (java.time desugaring in follow-up)

Before:
  - Hardcoded Color(0xFFXXXXXX) in UI + Repository (no theme support)
  - HttpURLConnection in domain layer (architecture violation)
  - AMSAT colors duplicated across modules

After:
  - MaterialTheme.colorScheme.tertiary/tertiaryContainer (light/dark adaptive)
  - HTTP via data layer RemoteSource (follows AGENTS.md architecture)
  - Single source of truth for AMSAT colors

Ref: https://github.com/rt-bishop/Look4Sat/pull/233#discussion_r1868599947
Ref: AGENTS.md "core:domain - Pure Kotlin (JVM). NO Android dependencies."
2026-08-05 15:26:42 +00:00
mckero 78049a29d9 fix(wavelog): persist gridsquare in the queue serialization
WavelogQueue serialized and deserialized every field except
gridsquare: updateGridsquare() wrote it, but save() skipped
put("gridsquare") and all() never read it back, so the QRZ-backfilled
grid was always empty at upload time (GRIDSQUARE never made it into
the ADIF). Add both directions.
2026-08-05 11:17:47 +00:00
mckero 3818c58eff fix(wavelog): send v2 extra freq fields as MHz strings with unit suffix
WaveLog's parse_frequency() (Logbook_model.php) treats integer input
as Hz but reads string suffixes ("145.852038M" -> 145852038 Hz).
The bare-integer freq/freq_rx values in the v2 JSON envelope could be
misread as MHz by older WaveLog versions, corrupting the band
derivation (145.852 MHz showed up as 160m). ADIF string (v1/v2) was
already correct; now the extra fields match the same semantics.
2026-08-05 11:09:51 +00:00
mckero 4c62b2dfb2 Revert "feat(nav): 4.5.6 AMSAT to bottom bar, Radar to More menu, tablet rail"
This reverts commit d8e1ee3266.
2026-08-05 10:07:45 +00:00
mckero d8e1ee3266 feat(nav): 4.5.6 AMSAT to bottom bar, Radar to More menu, tablet rail
Per upstream author feedback (PR #233) and tablet UX report:
- Bottom bar keeps max 5 primary destinations: Satellites/Passes/
  AMSAT/Map/Settings; Radar moves to the More menu (still reachable
  from Passes via item click)
- Legacy migration: persisted orders are rewritten in memory
  (main menu drops Radar + appends AMSAT; More menu drops AMSAT +
  appends Radar) so existing installs get the new layout
- Wide screens / tablets (width breakpoint) now use the side
  navigation rail instead of the bottom bar - fixes the wasted
  bottom strip ("big chin") in landscape/tablet layouts
- New AMSAT tab icon: MDI satellite-variant (Apache 2.0,
  https://pictogrammers.com)
- What's new updated in 5 locales; version 4.5.6 (457)
2026-08-05 09:46:54 +00:00
mckero f789a15338 fix(status): AMSAT page load failure, refresh spinner, retry button
- P0: fetchStatus() now runs on Dispatchers.IO - the previous
  synchronous URLConnection on the main thread threw
  NetworkOnMainThreadException and showed "load failed" on every open
- P1: refresh button rotates a vector icon (ic_refresh) instead of the
  "↻" text glyph, whose off-center font metrics made the spinner
  orbit around a shifted pivot
- P2: error state gains a Retry button (4 locales); amsat_refresh
  string added (5 locales)
- versionCode 456 (bump for reinstalling over 455), versionName stays 4.5.5
2026-08-05 08:50:01 +00:00
mckero 6221516893 feat(amsat): 4.5.5 official AMSAT API + edge-to-edge status page
- Replace HTML parsing with the official AMSAT Satellite Status API v1
  (catalog.php + reports.php, JSON): AmSatApiClient (pure JVM, hand
  rolled ISO-8601/epoch parsing for minSdk 24) + rewritten
  AmSatRepository (satellite list from catalog, reports slotted into
  6 days x 12 two-hour slots, status colors per report value)
- Fix edge-to-edge: status bar / navigation bar insets on the status
  page (refresh button and update time were unreachable)
- Version 4.5.5 (455), What's new in all 5 locales
2026-08-05 08:12:17 +00:00
mckero c9cab8457d chore(i18n): translate all code comments to English
All Chinese comments (//, /* */, KDoc) across core/app/feature/build-
logic translated to English (550 lines, 73 files after FT8 rollback).
Code logic untouched - comment text only. Verified: all modules
compileDebugKotlin BUILD SUCCESSFUL.
2026-08-05 07:59:35 +00:00
mckero 19926c58ac Revert "feat(ft8): 4.5.5 integrate FT8CN 0.93 (full port, original UI)"
This reverts commit 8a2b0a9aa3.
2026-08-05 07:47:18 +00:00
mckero bfcef27b90 Revert "docs(credits): thank BG7YOZ (FT8CN author) and BG7NIP in all locales"
This reverts commit 7dd256f65d.
2026-08-05 07:47:17 +00:00