Compare commits

..
67 Commits
Author SHA1 Message Date
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 d6b61eaa4b license: switch the root LICENSE to AGPL-3.0 for the combined work
用户指出 GitHub 上显示的许可证仍是 GPL-3.0,未反映合并 AGPL 组件的事实。

根因: 之前只在 README 加了 License 章节,根 LICENSE 文件仍是 GPL-3.0 原文,
GitHub 的自动检测(只扫 LICENSE 文件)自然还是 GPL-3.0。

修复: 合并作品按 GPL-3.0 §13 / AGPL-3.0 §13 处理,根 LICENSE 改为:
- 顶部 LICENSING NOTICE 说明构成 (Look4Sat 代码 GPL-3.0 + DeepCW 模型
  AGPL-3.0-only, 合并作品按 AGPL-3.0 分发)
- 正文为 AGPL-3.0 原文 (从 DeepCW-AGPL-3.0.txt 复制)

原 GPL-3.0 原文保留在 feature/cw/licenses/Look4Sat-GPL-3.0.txt。
README License 章节同步更新,与新 LICENSE 一致。

此后 GitHub 侧边栏将识别为 AGPL-3.0。
2026-08-13 10:19:57 +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
mckero f42d1f6c57 docs(cw): add DEEPCW.md recording DeepCW architecture, pitfalls, verification
记录 feature:cw 模块的完整集成知识, 供后续维护者免于重走弯路:
- 架构分层 (core:domain 纯 Kotlin 前后处理 / core:data ONNX 推理 / feature:cw UI)
- 模型规格与 int8/fp32 双版本取舍 (APK 内置 int8, fp32 作为 release 资产)
- 真机 release-only 的五个坑 (R8 keep / 惰性加载 / 线程 / 跨线程状态 / 双录音)
- 验证结果 (golden vector 79 测试 / 服务器端到端逐字符一致 / 真机 40WPM)

该文档与 skill 的 §7.55 根因坑互为参照: skill 记方法论, 这里记本模块实况。
2026-08-13 04:14:01 +00:00
mckero 3e25e1aa2f fix(cw): keep ai.onnxruntime.** through R8 to stop native crash
根因定位(确定性): release 构建开启 R8 混淆 (convention 插件的
PluginSetupUtils 设 isMinifyEnabled=true), 但 onnxruntime-android 的 AAR
不含 proguard consumer rules (仅 aar-metadata.properties)。于是 R8 把
ai.onnxruntime.* 的类重命名/裁剪, 而 ONNX Runtime 的 Java 绑定走 JNI、
native 侧按原始类名/方法名解析 —— 类名对不上即 native 崩溃, 不产生
任何 Java 堆栈, 正好解释"闪退回桌面、无任何日志"。

此前的所有内存/线程修复都无效, 因为崩溃点根本不在 Java 层。

修复:
1. app/proguard-rules.pro 新增 (ONNX Runtime 官方文档要求):
   -keep class ai.onnxruntime.** { *; }
   -dontwarn ai.onnxruntime.**
2. app/build.gradle.kts 的 release buildType 引用该规则文件。
3. onnxruntime 1.28.0 -> 1.25.1: 1.28.0 为 2026-07-25 发布(仅三周),
   1.25.1 更成熟; 同时排除 SIGILL 类骁龙 bug (该 bug 在 1.24.4 修复,
   Android AAR 对应 1.25.0+)。

验证: 服务器完整管线 (真实20s音频->频谱->ONNX->CTC) 解码逐字符正确,
与手机端同一份代码逻辑。R8 keep 规则为官方文档明确要求项。
2026-08-13 03:46:14 +00:00
mckero e0405b541f fix(cw): prevent double AudioRecord when toggling capture
CwDecodeScreen 的 LaunchedEffect 原先用内层 launch{} 启动音频采集。
isListening 在权限授予后由 false->true->(授予回调再次置 true), 加上
LaunchedEffect(isListening, permissionGranted) 的双 key 重启, 会造成
前一个 audioFlow().collect 尚未取消、新的又启动 —— 两个 AudioRecord
同时录音, 内存翻倍、音频混叠, 是低内存机 OOM 崩溃的实锤级嫌疑。

改为在 LaunchedEffect 协程体内直接 collect (取消时随父协程及时停止),
并保持 (isListening, permissionGranted) 双 key 以正确处理权限授予后
需要重启采集的情形。

同时清理不再使用的 kotlinx.coroutines.launch import。

验证: :feature:cw:compileDebugKotlin => BUILD SUCCESSFUL
2026-08-13 03:16:36 +00:00
mckero c374058e1d perf(cw): ship int8-quantized DeepCW model (15MB -> 4MB)
用户真机闪退回桌面且无任何 Java 日志 => 高度怀疑进程被系统 LMK 杀掉
(内存不足) 或 native 层崩溃。模型加载 + ONNX 引擎初始化存在瞬时内存峰值,
fp32 模型 15MB 会放大这个峰值。

用 onnxruntime.quantization.quantize_dynamic 把模型权重动态量化为 QUInt8
(激活保持 float32):

- 体积: 15,139,839 -> 4,248,808 字节 (缩小 71%)
- 输入/输出名、形状、dtype 完全不变 -> Kotlin 代码无需改动
- 实测 CER (合成 CW 音频, fp32 vs int8 逐字符对比):
  SNR>=0dB 完全一致; -4dB 起两者一同劣化, 无显著差异
- onnx.checker 校验通过

AGPL 合规: NOTICE.md 记录派生信息 (原文件 SHA/量化后 SHA/转换步骤),
量化属模型修改, 按 AGPL 要求如实声明。复现命令已更新。

验证: :core:domain:test => 79 tests 全绿 (golden vector 测试锁定的是
频谱图前处理, 与模型文件无关)
2026-08-13 01:02:04 +00: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
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 37300fb2a6 revert: remove build-check workflow, use release.yml for testing 2026-08-12 13:18:08 +00:00
mckero fc8096bdd5 ci: add build-check workflow asserting DeepCW packaging facts
本机 2GB 内存带不动 assembleRelease, 构建移交 GitHub Actions。
现有 release.yml 只在打 tag 时触发, 补一个可手动触发 + CW 分支推送即跑的
工作流, 便于装机实测而无需发布 tag。

断言的打包事实 (任一不满足即失败):
- lib/arm64-v8a/ 存在 —— 验证 abiFilters 解除生效, 应用恢复 64 位
- libnativedecoderjni 不存在 —— 验证被删的逆向 JNI 库未残留
- assets/deepcw/model.onnx 打包尺寸恰为 15139839 字节 —— 验证 noCompress
  生效, 未被压缩 (压缩会导致 ONNX Runtime mmap 失败)
- values-in / values-id 存在 —— 验证印尼语未被资源压缩丢弃

同时跑 :core:domain:test 与 assembleRelease (R8 检查), 产物上传为 artifact,
配置了签名 secrets 时对 release APK 签名。
2026-08-12 13:12:15 +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
mckero 86ab1b26b7 feat(cw): vendor DeepCW ONNX model with full AGPL-3.0 compliance
背景:
DeepCW (e04/deepcw-engine) 是基于 CTC 的神经网络 CW 解码模型。本地实测
弱信号解码率显著优于传统 DSP 方案: SNR<0dB 时 CER 16.0% vs ggmorse 52.6%;
SNR>=0dB 时两者均接近 0%。模型 400-1200Hz 固定频窗自带定频, 无需频谱峰值
跟踪与噪声门。

改动:
- assets/deepcw/model.onnx      15,139,839 字节, 原样收录未作修改
- assets/deepcw/model.onnx.json 模型元数据 (采样率 3200, FFT 256, hop 48,
  65 个频段, log1p 归一化, 42 类含 CTC blank)
- licenses/DeepCW-AGPL-3.0.txt  AGPL-3.0 许可证全文
- licenses/NOTICE.md            来源仓库/作者/取用 commit/SHA-256/
  许可证兼容性说明/复现步骤
- app/build.gradle.kts: androidResources.noCompress += "onnx"

关于 noCompress:
ONNX Runtime 通过 mmap 直接读取 assets, 压缩后无法内存映射, createSession
会失败。该配置只在打包 APK 的 app 模块生效, 在 feature 库模块声明无效。

许可证:
Look4Sat 为 GPL-3.0-or-later, DeepCW 模型为 AGPL-3.0-only。GPL v3 第 13 条
明确允许两者合并分发。推理完全在本地设备进行, 不通过网络向远程用户提供
模型功能, 故 AGPL 第 13 条的网络源码提供义务不被触发。本仓库公开, 完整
对应源码提供义务已满足。

上游取用 commit: 8e264d243bbd4467bd19f3f28292219405b47e0e
模型 SHA-256: ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02
2026-08-12 11:47:02 +00:00
mckero de6c964136 build!: drop armeabi-v7a abiFilters, restore 64-bit builds
背景:
abiFilters=armeabi-v7a 的唯一成因是 Morse Expert 的 32 位
libnativedecoderjni.so, 该 .so 已在上一提交删除。此前整个应用被迫以
32 位运行: 64 位设备走兼容层、寻址受限, 且不满足应用市场的 64 位要求。
原注释称"用户设备为 32 位软件"已与实际不符 (测试机为 64 位)。

改动:
- app/build.gradle.kts: 移除 defaultConfig.ndk.abiFilters 及相关注释
- feature/cw/build.gradle.kts: 移除 abiFilters; 移除 constraintlayout 依赖
  (仅被已删除的 activity_main.xml 使用); 移除 UTF-8 JavaCompile 配置
  (模块已无 Java 源码)
- feature/cw 改为依赖 core:domain + core:data (DeepCW 前后处理落在 core)

验证:
grep -rn 'abiFilters|jniLibs' --include=*.kts . (排除 build/) => 无匹配
APK ABI 覆盖将在 CI 产物中验证 (预期含 arm64-v8a)
2026-08-12 11:44:54 +00:00
mckero e477851adf refactor(cw)!: remove reverse-engineered Morse Expert engine and 32-bit JNI blob
背景:
feature/cw 内含 Morse Expert 1.15 (VE3NEA, 闭源免费软件) 的 jadx 反编译产物,
外加仅 armeabi-v7a 的 libnativedecoderjni.so (1.4MB)。该 .so 无 64 位版本,
迫使 app 模块设置 abiFilters=armeabi-v7a, 把整个应用锁死在 32 位。
闭源软件的反编译产物不具备任何分发授权。

改动 (共 58 项):
- 删除 pas/* (JNI 入口 nativedecoder/decoder/cwstru/system)
- 删除 com/ve3nea/morse_expert/* (MainActivity/DecodedTextView/ScaleView/
  WaterfallSurfaceView)
- 删除混淆包 B B0 D E2 F2 H2 I0 I2 J2 K1 d1 g3 i3 j1 j3 k3 s
- 删除 suncompat/* (sun.misc.Unsafe/Cleaner 存根)
- 删除 jniLibs/armeabi-v7a/libnativedecoderjni.so
- 删除配套 layout (activity_main, cw_panel_main)、menu、4 个 ic_baseline_* 图标
- proguard-rules.pro 清空 keep 规则 (已无 JNI 按类名注册依赖)

验证:
find feature/cw/src -name '*.java' | wc -l   => 0
find feature/cw/src -name '*.so'   | wc -l   => 0
git ls-files feature/cw 仅剩 build.gradle.kts, proguard-rules.pro,
CwDecodeScreen.kt, CwSettingsDialog.kt 及 5 份 app_values.xml

注意: 本提交后 CwDecodeScreen.kt 暂时无法编译 (它 inflate 了已删除的
R.layout.activity_main), 将在 DeepCW 内核接入后重写为纯 Compose。

后续: DeepCW ONNX 内核接入 (纯 Kotlin 前后处理), abiFilters 解除恢复 arm64。
2026-08-12 11:42:53 +00:00
mckero d2d4a6fe86 chore(git): ignore .keystore files and the Hermes workspace
*.jks 已被忽略, 补上 *.keystore 覆盖另一种签名库扩展名。
.hermes/ 是 agent 的本地计划目录, 不属于项目产物。

背景: BG7NTA.jks 位于仓库根目录, 此前仅依赖 *.jks 规则; 补充规则以防
其他扩展名的签名材料被误提交进公开仓库。
2026-08-12 11:40:54 +00: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 fb014a1183 chore(release): bump versionCode to 458 (4.5.5 upload fixes) 2026-08-05 11:28:53 +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 90bb584813 fix(status): drop appcompat tint attr from ic_refresh (library module has no such attr) 2026-08-05 08:53:05 +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 955871c27b Revert "fix(ft8): register FT8 in settings page-order list (UI settings)"
This reverts commit 4d7952d293.
2026-08-05 07:47:17 +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
mckero 449d1a9eec Revert "fix(ft8): unify osmdroid version to fix duplicate classes"
This reverts commit fc1bec1915.
2026-08-05 07:47:17 +00:00
mckero fe97339ece Revert "fix(ft8): repair FT8CN locale string resources for AGP9 strictness"
This reverts commit 2c26272e0b.
2026-08-05 07:47:17 +00:00
mckero 4d5a413fa8 Revert "fix(ft8): remove application tag from module manifest (merge conflict)"
This reverts commit f4a22d9779.
2026-08-05 07:47:17 +00:00
mckero 25078b3121 Revert "fix(ft8): remove application tag from module manifest (merge conflict)"
This reverts commit 84d11f289f.
2026-08-05 07:47:17 +00:00
mckero 96a4d15424 Revert "fix(ft8): restore Look4Sat Pro label/icon, add AppCompat theme"
This reverts commit bf4cd5c07c.
2026-08-05 07:47:17 +00:00
mckero bf4cd5c07c fix(ft8): restore Look4Sat Pro label/icon, add AppCompat theme
User reported the app installed as "FT8CN" with the FT8CN icon and
FT8 not opening. Root causes (verified by aapt badging on the release
APK):
1. module values/strings.xml carried <string name="app_name">FT8CN
   which won the resource merge -> application-label became FT8CN.
   Removed it; dialogs that referenced R.string.app_name now use a
   module-local ft8cn_app_name (HelpDialog/ClearCacheDataDialog).
2. module shipped its own mipmap ic_launcher* (8 files) which
   overrode our launcher icon -> deleted all of them.
3. MainActivity extends AppCompatActivity but the module's
   <application> lost android:theme when the conflicting attributes
   were stripped -> startup crash. Re-added android:theme=Theme.Ft8CN
   (MaterialComponents) on the module application element.
Verified: processReleaseManifest/Resources + ft8 javac + app kotlin
BUILD SUCCESSFUL. Plan: .hermes/plans/2026-08-05_150000-v455-fix-plan.md
2026-08-05 06:52:13 +00:00
mckero 84d11f289f fix(ft8): remove application tag from module manifest (merge conflict)
feature/ft8 module manifest carried an application element from the
FT8CN standalone app (allowBackup/icon/label/usesCleartextTraffic),
clashing with the main app manifest at merge (allowBackup false vs
true, icon, label). Removed the tag - components (activities, service,
permissions) remain and merge cleanly. Verified: processRelease-
MainManifest + mergeReleaseResources BUILD SUCCESSFUL.
2026-08-05 06:33:54 +00:00
mckero f4a22d9779 fix(ft8): remove application tag from module manifest (merge conflict)
feature/ft8 module manifest carried an application element from the
FT8CN standalone app (allowBackup/icon/label/usesCleartextTraffic),
clashing with the main app manifest at merge (allowBackup false vs
true, icon, label). Removed the tag - components (activities, service,
permissions) remain and merge cleanly. Verified: processRelease-
MainManifest + mergeReleaseResources BUILD SUCCESSFUL.
2026-08-05 06:29:21 +00:00
mckero 0e767f4250 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.
2026-08-05 06:23:47 +00:00
mckero 2c26272e0b fix(ft8): repair FT8CN locale string resources for AGP9 strictness
FT8CN 0.93 shipped malformed strings that old AGP tolerated but AGP9
rejects: 148 unclosed tags (</string>>), unclosed XML comments,
trailing garbage after tags, and non-positional multi-placeholder
formats. Fixed across all 8 locale files: closed tags/comments,
removed trailing garbage, numbered placeholders in argument order
(%1$s %2$d %.3$1f style). aapt2 compile of every values-* dir now
passes 0 errors; :feature:ft8 + :app compileDebugKotlin SUCCESSFUL.
2026-08-05 05:23:57 +00:00
mckero fc1bec1915 fix(ft8): unify osmdroid version to fix duplicate classes
CI build failed at :app:checkReleaseDuplicateClasses: local
osmdroid-android-6.1.14.aar (copied from FT8CN) clashed with the
project's osmdroid 6.1.20 (feature/map remote dependency). Switched
feature/ft8 to libs.other.osmdroid (6.1.20), removed the local aar.
Verified: compileDebugJavaWithJavac + :app:compileDebugKotlin
BUILD SUCCESSFUL.
2026-08-05 05:08:51 +00:00
mckero 7dd256f65d docs(credits): thank BG7YOZ (FT8CN author) and BG7NIP in all locales
Added to the prefs_outro_thanks list after BG7NTA in all 5 locale
files (values/zh/tr/in/id), keeping the original bullet style and the
24dp spacer before the license block (user: keep bottom spacing).
Verified: check_strings 9 files OK.
2026-08-05 05:01:19 +00:00
mckero 4d7952d293 fix(ft8): register FT8 in settings page-order list (UI settings)
User spot-check found FT8 missing from the settings screens list
(SettingsScreen.kt:703) - page order drag/hide UI could not show or
reorder it. Added "nav_ft8 to FT8" after AMSAT (name must match
Screen.screenId). Verified: :feature:settings + :app compileDebugKotlin
BUILD SUCCESSFUL.
2026-08-05 04:53:37 +00:00
mckero 8a2b0a9aa3 feat(ft8): 4.5.5 integrate FT8CN 0.93 (full port, original UI)
Full FT8 mode integration (user chose scope C: complete port, keep
original visual style):

- feature/ft8 module: 213 Java classes from FT8CN 0.93 source
  (com.bg7yoz.ft8cn namespace preserved so code needs zero changes),
  DataBinding enabled, BuildConfig fields (APPLICATION_ID/VERSION_NAME/
  apkBuildTime) restored for AGP9
- libft8cn.so from the user's FT8CN_0.93 APK (armeabi-v7a, 1.53MB;
  differs from repo copy, APK version used)
- 237 res files + 58 assets + 4 local libs (MPAndroidChart spectrum,
  commons-net, nanohttpd, osmdroid map) copied verbatim
- Proguard keep rules for JNI classes (RegisterNatives)
- Entry: More menu "FT8" (default sub menu, after AMSAT) launches
  MainActivity via Intent (external activity, LAUNCHER filter removed
  to avoid double launcher)
- Manifest merged via module (RECORD_AUDIO/BT/location perms)
- Version 4.5.5/455 + What's new 5 languages (REPLACED per rule)
Verified: :feature:ft8:compileDebugJavaWithJavac + :app:compileDebugKotlin
BUILD SUCCESSFUL; check_strings 9 files OK.
2026-08-05 04:49:35 +00:00
mckero c9d87be289 fix(aprs): upload feedback, manual report reliability, station position
User testing round 3 (4.5.4): no success feedback on upload, aprs.fi
shows nothing, passcode calculator OK, notification present.

- Manual report now works even when service not running: ACTION_REPORT_NOW
  starts the service first (Toast "not configured" if missing callsign)
- Upload result feedback guaranteed: Toast always shows (short OK /
  long fail+reason), last result persisted (time/ok/detail) and shown
  in the settings card "Last report: HH:mm:ss OK/failed - detail"
- Position source: station position from settingsRepo (user decision)
  with live GPS last-known as fallback
- sendPacket reads the server confirmation line (short 3s timeout);
  server error text (Invalid/error) surfaces in Toast + card
- Strings EN/ZH/TR/IN/ID +4 keys
Verified: compileDebugKotlin all modules BUILD SUCCESSFUL,
check_strings 9 files OK.
2026-08-05 03:29:36 +00:00
mckero 012ea1eeb6 fix(aprs): passcode compute button, crash log, notif permission, login verify
User feedback round 2 (4.5.4): no notification shown, report not
sending, no error visibility on crash. Diagnosis: APRS-IS port 14580
reachable (verified with real login test), 24580 SSL refused; login
format OK. Fixes:

- Settings dialog: "Compute passcode" button - fills passcode from
  callsign via the ported 0x73E2 algorithm (user can see the result)
- Global crash handler in MainApplication: stack trace appended to
  files/crash_log.txt so crashes are diagnosable (user: no crash logs
  were available before)
- POST_NOTIFICATIONS runtime permission requested when enabling APRS
  (Android 13+ otherwise silently hides the service notification)
- AprsIsClient: read the login response (aprsc "# logresp ...
  unverified"/"Invalid") and surface the server message as the error
- AprsForegroundService: Toast on manual report result (OK / failure
  with server reason)
- Strings EN/ZH/TR/IN/ID +3 keys
Verified: compileDebugKotlin all modules BUILD SUCCESSFUL,
check_strings 9 files OK.
2026-08-05 03:06:09 +00:00
mckero 920af7bde2 fix(aprs): crash when enabling APRS with callsign set (foreground service type)
Root cause: user reported crash on enabling APRS with a callsign set
(empty callsign worked because the service stops early and never
reaches startForeground). Android 14+ requires the service to declare
foregroundServiceType when startForeground passes a type; the service
had none -> process died on toggle.

- Manifest: add android:foregroundServiceType="dataSync"
- AprsCard: use startForegroundService() for start/report actions
  (Android 8+ standard for foreground services)
- AprsForegroundService: try-catch around startForeground, fallback
  stopSelf instead of killing the process
- AprsReporter: passcode "-1" (APRSdroid "no auth" convention) also
  auto-computes from callsign
- Merged upstream 418a05e3 (zh wording fix, no conflicts)
Verified: compileDebugKotlin all modules BUILD SUCCESSFUL,
check_strings 9 files OK.
2026-08-05 02:50:40 +00:00
mckero 88a8e795d1 Merge remote-tracking branch 'atsunatsu/main' 2026-08-05 02:50:28 +00:00
mckero 44ae5cef5d fix(aprs): settings crash from format arg type mismatch + service class name
- prefs_aprs_summary uses %2$d but AprsCard passed port.toString()
  (String) -> String.format threw IllegalFormatConversionException
  on every Settings screen open (crash). Pass Int now.
- Manifest service name was ".AprsForegroundService" (resolves under
  applicationId) but the class lives in com.rtbishop.look4sat.app ->
  ClassNotFoundException when toggling. Use full class name.
Verified: :feature:settings:compileDebugKotlin + :app:compileDebugKotlin
BUILD SUCCESSFUL.
2026-08-05 02:39:14 +00:00
mckero 518d9a4d22 feat(aprs): 4.5.4 APRS-IS network reporting (ported from APRSdroid 1.6.3d)
Core logic extracted from APRSdroid 1.6.3d (jadx reverse-engineered,
verified against APRS spec):
- core/domain/aprs/AprsPacket.kt: passcode algorithm (0x73E2 XOR),
  login line, uncompressed/compressed position encoding (ab0oo)
- core/data/aprs/AprsIsClient.kt: APRS-IS TCP client (login + packet
  lines, 30s reconnect strategy)
- core/data/aprs/AprsReporter.kt: periodic reporting scheduler
  (default 5 min, manual trigger support)
- core/data/aprs/AprsStore.kt: SharedPreferences persistence
  (fill once, saved)
- app/AprsForegroundService.kt: foreground service with notification
  (keeps APRS alive across pages), START_STICKY, last-known position
  provider
- feature/settings/AprsCard.kt: APRS card between DataCard and
  OutputCard, gear button opens settings dialog (server/port/
  callsign/ssid/passcode/interval/status/symbol), manual report button
- Manifest: service registration + FOREGROUND_SERVICE(_DATA_SYNC) +
  POST_NOTIFICATIONS
- Strings in EN/ZH/TR/IN/ID (23 keys x 5)
- Version 4.5.4/454 + What's new 5 languages
2026-08-05 02:07:11 +00:00
mckero 72f507163a fix(status): shrink day cells so long satellite names stay visible
User feedback: on narrow screens long names (e.g. AO-123_[FM]) were
truncated before the mode tag. Scaled the 6 day cells down
proportionally (weight 1f -> 0.8f, height 26dp -> 24dp) and widened
the name column (weight 1.4f -> 2f) in both header and rows.
Verified: :feature:status:compileDebugKotlin BUILD SUCCESSFUL.
2026-08-04 16:41:41 +00:00
mckero f7a3dbaa2d fix(i18n): use AGP9 localeFilters for language keep-list
resourceConfigurations is rejected by AGP 9.3 ("When localeFilters
are specified, resourceConfigurations cannot include locale
qualifiers"). Switch to androidResources.localeFilters.
Verified: :app:mergeReleaseResources + :app:processReleaseResources
BUILD SUCCESSFUL.
2026-08-04 16:34:39 +00:00
mckero c525f3a11f fix(i18n): force-keep in/id locale configs via resourceConfigurations
Root cause of Indonesian not matching: release build has
isShrinkResources=true and R8 was dropping the Indonesian resource
configs entirely - aapt dump badging showed locales only
'--_--' es ru si tr uk zh (no in, no id). values-in/values-id were
merged at merge time but stripped before packaging.

Fix: explicit resourceConfigurations in app/build.gradle.kts keeping
en zh tr in id es ru si uk. Verified: :app:mergeReleaseResources
BUILD SUCCESSFUL.
2026-08-04 16:28:39 +00:00
mckero 2777607ab5 fix(i18n): drop values-b+in (duplicates values-in under aapt2)
aapt2 treats in/b+in as the same locale config as id -> Duplicate
resources build failure. in and id are equivalent in Android's
resource matcher (both normalize to id), so values-in + values-id
is sufficient coverage for Indonesian devices.
2026-08-04 16:18:50 +00:00
mckero a222cbb839 fix(i18n): use BCP-47 values-b+in for Indonesian legacy locale
values-in-rID did not survive aapt2 linking (normalized away).
Switch to values-b+in (BCP-47) which compiles cleanly and keeps an
explicit "in" language config alongside values-id. Verified: aapt2
compile of values-in/values-id/values-b+in OK.
2026-08-04 16:16:40 +00:00
mckero 0ef8f05dc4 fix(i18n): keep legacy "in" locale config for old Indonesian devices
Root cause: aapt2 merges values-in into values-id (in is the legacy
alias of id), so the APK only carried the (id) config. New devices
report "id" and match; older devices report "in" and find no (in)
config -> fall back to English (friend's report: only system date
showed Indonesian).

Fixes:
- Add values-in-rID (core/presentation + feature/cw) so the APK
  keeps a real "in" language config; values-in and values-id both
  get translatable="false" on the 27 entries that English marks
  (aapt2 rejects those with multiple %-substitutions otherwise)
- Verified: aapt2 compile of values/values-in/values-id/values-in-rID
  OK; :core:presentation:mergeDebugResources + :feature:cw:mergeDebugResources
  BUILD SUCCESSFUL
2026-08-04 16:04:04 +00:00
mckero f7402a3531 feat(status): 4.5.3 AMSAT satellite status page + radar log pass divider
New feature/status module: fetches https://amsat.org/status/ and
renders a live status grid in the official site colors:
- Parser (AmSatParser): 47 satellites x 6 days x 12 two-hour slots,
  official colors (blue=Active, orange=TLM/Beacon, pink=Not Heard,
  deep-orange=Conflicting, gray=none); 598+ report details extracted
  from inline JS tooltips (callsign/date/time/grid)
- Three-level viz: color grid -> report count -> tap day cell opens
  report list dialog
- Manual refresh with spin animation + last-updated timestamp +
  legend row; loading/error states
- New "AMSAT" entry in the More menu (Screen.AmSat), integrated with
  page-order / hide-page settings (SettingsScreen screens list,
  defaultSubMenuOrder, allNavItems, migration for existing users)
- AmSatRepository via IRemoteSource.getStatusHtml() (UA header);
  shared remoteSource promoted to a lazy class property in MainContainer

Radar page Log tab: local entries now grouped by pass session with a
thick divider + satellite label between groups (matches the log page).

What's new updated in EN/ZH/TR/IN/ID. Version bumped to 4.5.3/453.
Not released (user gates all releases).
2026-08-04 15:57:41 +00:00
atsunatsu 418a05e343 fix(i18n): update Chinese station position wording 2026-08-04 21:03:35 +08:00
162 changed files with 6409 additions and 13942 deletions

No files matched your search

+4
View File
@@ -40,10 +40,14 @@ captures/
# Keystore files
*.jks
*.keystore
/*.properties
/keystore.properties
/app/keystore.jks
# Hermes agent workspace (plans, local notes)
.hermes/
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
+88 -77
View File
@@ -1,5 +1,29 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
LOOK4SAT (MCKERO6423 FORK) — LICENSING NOTICE
This repository combines two separately-licensed components:
1. Look4Sat application code (all modules except the DeepCW model)
— Copyright (C) 2019-2026 Arty Bishop (rt-bishop) and contributors
— Licensed under the GNU General Public License v3.0 (GPL-3.0)
2. The DeepCW neural decoding model in feature/cw/src/main/assets/deepcw/
— Copyright (C) e04 (https://github.com/e04/deepcw-engine)
— Licensed under the GNU Affero General Public License v3.0 only
(AGPL-3.0-only)
Because this combined work incorporates an AGPL-3.0 component, it is
distributed under the GNU Affero General Public License v3.0. GPL-3.0
Section 13 permits this combination; AGPL-3.0 Section 13 applies to the
combined work as a whole.
See feature/cw/licenses/NOTICE.md for model provenance, attribution, and
the applied int8 quantization. The original GPL-3.0 text for the Look4Sat
application code is preserved at feature/cw/licenses/Look4Sat-GPL-3.0.txt.
--------------------------------------------------------------------------------
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +31,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +48,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +84,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +561,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +657,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+15
View File
@@ -34,6 +34,21 @@ It is now and always will be completely ad-free and open-source.
* Custom TLE satellite data import is available via Three Line Element .txt files
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
## License
The Look4Sat application code is licensed under the GNU General Public License v3.0.
The CW decoder in `feature/cw` bundles the [DeepCW](https://github.com/e04/deepcw-engine)
neural decoding model, licensed under the GNU Affero General Public License v3.0 only
(AGPL-3.0-only). Because the combined work incorporates an AGPL-3.0 component, the
combined work is distributed under the
[GNU Affero General Public License v3.0](LICENSE) — GPL-3.0 Section 13 permits the
combination, and AGPL-3.0 Section 13 applies to the combined work as a whole.
Model provenance, attribution and the applied int8 quantization are documented in
[`feature/cw/licenses/NOTICE.md`](feature/cw/licenses/NOTICE.md); the original GPL-3.0
text is preserved at `feature/cw/licenses/Look4Sat-GPL-3.0.txt`. The CW model runs
locally on-device and does not provide services over a network.
## Star History
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
+17 -3
View File
@@ -11,13 +11,24 @@ val keystoreProperties = Properties().apply {
}
android {
// CW 解码 native 库仅 armeabi-v7a(照搬 Morse Expert 1.15): 全 ABI 打包会在
// arm64 设备 loadLibrary 失败, 强制 32 位兼容(用户设备为 32 位软件)
defaultConfig {
// ONNX Runtime 的 AAR 自带 4 个架构共 115MB 原生库(arm64 28M / armv7 20M /
// x86 33M / x86_64 34M)。x86 系列只有模拟器用得到, 全打包会让 APK 从 8MB
// 涨到 135MB。仅保留真机需要的两个 ABI。
ndk {
abiFilters += listOf("armeabi-v7a")
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
}
}
androidResources {
// 显式保留全部语言(防 shrinkResources 丢弃 in/id 印尼语配置); AGP 9 用 localeFilters
localeFilters += listOf(
"en", "zh", "tr", "in", "id", "es", "ru", "si", "uk"
)
// DeepCW 模型必须以未压缩形式打包: ONNX Runtime 通过 mmap 直接读取
// assets, 压缩后无法映射会导致 createSession 失败。noCompress 只在
// 打包 APK 的 app 模块生效, 在 feature 库模块声明无效。
noCompress += "onnx"
}
signingConfigs {
if (keystoreProperties["storeFile"] != null) {
create("release") {
@@ -31,6 +42,9 @@ android {
buildTypes {
release {
signingConfig = signingConfigs.findByName("release")
// ONNX Runtime 走 JNI, R8 混淆会重命名 ai.onnxruntime.* 类导致 native
// 崩溃。convention 插件已开启 isMinifyEnabled, 必须补 keep 规则。
proguardFiles("proguard-rules.pro")
}
}
}
+13
View File
@@ -0,0 +1,13 @@
# ProGuard / R8 rules for the Look4Sat application module.
#
# NOTE: release builds enable minification (isMinifyEnabled=true in the
# convention plugin), so anything whose classes are resolved reflectively or
# through JNI by name MUST be kept here.
# ONNX Runtime (ai.onnxruntime): the Java binding is backed by JNI. Native code
# resolves Java methods/classes by their original names; R8 renaming or
# stripping them causes a hard crash at runtime with no Java stack trace.
# This rule is required by the ONNX Runtime docs for minified Android builds.
# https://onnxruntime.ai/docs/get-started/with-java.html
-keep class ai.onnxruntime.** { *; }
-dontwarn ai.onnxruntime.**
+8 -1
View File
@@ -14,7 +14,10 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".MainApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
@@ -45,5 +48,9 @@
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
android:value="com.rtbishop.look4sat.bg7nta" />
<service
android:name="com.rtbishop.look4sat.app.AprsForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -0,0 +1,190 @@
package com.rtbishop.look4sat.app
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.widget.Toast
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import com.rtbishop.look4sat.MainApplication
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
import com.rtbishop.look4sat.core.data.aprs.AprsStore
import com.rtbishop.look4sat.core.data.aprs.AprsReporter
import com.rtbishop.look4sat.core.data.aprs.AprsState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* APRS foreground service: kept alive by a system notification; keeps working across pages.
* START_STICKY: auto-restarted after being killed by the system (same strategy as APRSdroid).
*/
class AprsForegroundService : Service() {
companion object {
const val ACTION_START = AprsStore.ACTION_START
const val ACTION_STOP = AprsStore.ACTION_STOP
const val ACTION_REPORT_NOW = AprsStore.ACTION_REPORT_NOW
const val CHANNEL_ID = "aprs_service"
const val NOTIF_ID = 101
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var reporter: AprsReporter? = null
private var lastState: AprsState = AprsState.Idle
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> stopReporting()
ACTION_REPORT_NOW -> {
if (reporter == null) {
// Service not running: start it first (Toast hint when not configured)
startReporting()
}
reporter?.reportNow()
}
else -> startReporting()
}
return START_STICKY
}
private fun startReporting() {
val cfg = AprsStore.loadConfig(this)
if (!cfg.enabled || cfg.callsign.isBlank()) {
runCatching {
Toast.makeText(this, getString(R.string.aprs_toast_not_configured), Toast.LENGTH_SHORT).show()
}
stopSelf()
return
}
startForegroundWithNotification(cfg)
val rep = AprsReporter(
configProvider = { AprsStore.loadConfig(this) },
positionProvider = { stationPosition() },
onState = { lastState = it },
onReport = { report ->
AprsStore.saveLastReport(this, report.ok, report.detail)
updateNotification(cfg)
// Report result always surfaces: success = short Toast, failure = long Toast + reason
val msg = if (report.ok) {
getString(R.string.aprs_toast_ok)
} else {
getString(R.string.aprs_toast_fail, report.detail)
}
runCatching {
Toast.makeText(this, msg,
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
}
}
)
reporter = rep
rep.start()
}
private fun stopReporting() {
reporter?.stop()
reporter = null
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun startForegroundWithNotification(cfg: AprsConfig) {
try {
val notif = buildNotification(cfg)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIF_ID, notif)
}
} catch (e: Exception) {
// Vendor ROM / old-system safety net: foreground-start failure only stops the service, never crashes the app
stopSelf()
}
}
private fun buildNotification(cfg: AprsConfig): Notification {
val pi = PendingIntent.getActivity(
this, 0, packageManager.getLaunchIntentForPackage(packageName),
PendingIntent.FLAG_IMMUTABLE
)
val stopPi = PendingIntent.getService(
this, 1, Intent(this, AprsForegroundService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE
)
val stateText = when (lastState) {
AprsState.Connected -> getString(R.string.aprs_notif_connected)
AprsState.Error -> getString(R.string.aprs_notif_error)
else -> getString(R.string.aprs_notif_running)
}
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_radios)
.setContentTitle(getString(R.string.aprs_notif_title, cfg.callsign))
.setContentText(stateText)
.setContentIntent(pi)
.setOngoing(true)
.addAction(0, getString(R.string.aprs_notif_stop), stopPi)
.build()
}
private fun updateNotification(cfg: AprsConfig) {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIF_ID, buildNotification(cfg))
}
/** Report position: station QTH from settings first (per user); live GPS as fallback when invalid */
private fun stationPosition(): Pair<Double, Double>? {
// 1. Station QTH (position set in settings)
val station = runCatching {
val container = (application as MainApplication).getMainContainer()
container.settingsRepo.stationPosition.value
}.getOrNull()
if (station != null && (station.latitude != 0.0 || station.longitude != 0.0)) {
return Pair(station.latitude, station.longitude)
}
// 2. Fallback: last live GPS position
return runCatching {
val lm = getSystemService(Context.LOCATION_SERVICE) as android.location.LocationManager
val providers = listOf(
android.location.LocationManager.GPS_PROVIDER,
android.location.LocationManager.NETWORK_PROVIDER
)
for (p in providers) {
val loc = lm.getLastKnownLocation(p) ?: continue
if (loc.latitude != 0.0 || loc.longitude != 0.0) {
return Pair(loc.latitude, loc.longitude)
}
}
null
}.getOrNull()
}
private fun createChannel() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
CHANNEL_ID, getString(R.string.aprs_notif_channel),
NotificationManager.IMPORTANCE_LOW
)
nm.createNotificationChannel(channel)
}
override fun onDestroy() {
reporter?.stop()
reporter = null
super.onDestroy()
}
}
@@ -30,10 +30,29 @@ class MainApplication : Application(), IContainerProvider {
private lateinit var container: IMainContainer
/** Global crash capture: stack written to files/crash_log.txt, viewable after restart (user asked for error reports) */
private fun installCrashHandler() {
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
runCatching {
val log = StringBuilder()
log.append("=== Crash ${System.currentTimeMillis()} ===\n")
log.append("Thread: ").append(thread.name).append("\n")
val sw = java.io.StringWriter()
throwable.printStackTrace(java.io.PrintWriter(sw))
log.append(sw.toString()).append("\n")
val file = java.io.File(filesDir, "crash_log.txt")
file.appendText(log.toString())
}
defaultHandler?.uncaughtException(thread, throwable)
}
}
override fun getMainContainer(): IMainContainer = container
override fun onCreate() {
super.onCreate()
installCrashHandler()
container = MainContainer(this)
// trigger automatic update every 48 hours
container.appScope.launch { checkAutoUpdate() }
@@ -97,6 +97,7 @@ import com.rtbishop.look4sat.feature.mutual.MutualViewModel
import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.radar.WavelogLogScreen
import com.rtbishop.look4sat.feature.status.SatStatusScreen
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination
@@ -147,10 +148,10 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
// UI 设置: 按 screenOrder 排序(空 = 默认顺序), 再按 hiddenScreens 过滤(设置页固定保留)
val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.WavelogLog, Screen.Map, Screen.Settings)
// UI settings: sort by screenOrder (empty = default order), then filter by hiddenScreens (Settings always kept)
val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings)
.sortedBy { screen ->
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置(漫游↔地图), 再兜底最后
// Unknown pages (e.g. CwDecode not in old persisted order): use default-order position (Roaming<->Map), then fall back to last
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
if (idx != -1) idx
else com.rtbishop.look4sat.core.presentation.defaultScreenOrder.indexOf(screen.screenId).let {
@@ -158,10 +159,11 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
}
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
// 4.5.1 折叠菜单: 主菜单(底部栏 5 槽) + 更多菜单(溢出页面)
// 老用户迁移: 已持久化的 subMenuOrder 不含新页面 WavelogLog → 追加到子菜单尾部
// 4.5.1 foldable menu: main menu (5 bottom-bar slots) + More menu (overflow page)
// Legacy migration: persisted subMenuOrder lacks new pages (WavelogLog) -> append to the sub-menu tail
val subOrder = (otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder })
.let { list -> if ("WavelogLog" in list) list else list + "WavelogLog" }
.let { list -> if ("AMSAT" in list) list else list + "AMSAT" }
val mainNavItems = remember(allNavItems, subOrder) {
allNavItems.filter { it.screenId !in subOrder }.take(5)
}
@@ -169,7 +171,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
subOrder.mapNotNull { id -> allNavItems.find { it.screenId == id } }
}
var moreExpanded by remember { mutableStateOf(false) }
// 更多菜单打开时拦截返回: 先关菜单
// Intercept Back while the More menu is open: close the menu first
BackHandler(enabled = moreExpanded) { moreExpanded = false }
// Activity-scoped so the mutual query results survive navigation to Radar and back
val mutualViewModel: MutualViewModel = viewModel(
@@ -193,6 +195,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
is Screen.Mutual -> screen is Screen.Mutual
is Screen.CwDecode -> screen is Screen.CwDecode
is Screen.WavelogLog -> screen is Screen.WavelogLog
is Screen.AmSat -> screen is Screen.AmSat
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
@@ -209,7 +212,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
)
}
// 更多菜单按钮(固定第 6 槽, 子菜单非空才显示)
// More-menu button (fixed slot 6; shown only when the sub-menu is non-empty)
if (moreNavItems.isNotEmpty()) {
item(
icon = {
@@ -283,6 +286,9 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
entry<Screen.CwDecode> {
CwDecodeScreen()
}
entry<Screen.AmSat> {
SatStatusScreen(container = container)
}
entry<Screen.WavelogLog> {
WavelogLogScreen(queue = container.wavelogQueue)
}
@@ -340,7 +346,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
}
}
// 更多菜单弹出面板(覆盖在内容上, 底部栏上方; spring 弹跳)
// More-menu popup panel (overlays content above the bottom bar; spring bounce)
AnimatedVisibility(
visible = moreExpanded,
modifier = Modifier.fillMaxSize(),
@@ -1,9 +1,9 @@
/*
* MoreMenuPopup.kt — 底部导航「更多」二级菜单弹出面板(4.5.1)。
* MoreMenuPopup.kt - bottom-nav "More" second-level menu popup panel (4.5.1).
*
* 覆盖在内容区上(底部栏上方, 右对齐), 竖排菜单项(图标+文本+箭头),
* 当前页高亮; 点击遮罩关闭, 点击项跳转。弹出/收起动画由调用处
* (MainScreen 的 AnimatedVisibility + spring)驱动。
* Overlays the content area (above the bottom bar, right-aligned), vertical menu items (icon+text+arrow),
* current page highlighted; tap the scrim to close, tap an item to navigate. Open/close animation is driven by the caller
* (MainScreen's AnimatedVisibility + spring).
*/
package com.rtbishop.look4sat
@@ -39,6 +39,7 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":feature:roaming"))
implementation(project(":feature:satellites"))
implementation(project(":feature:settings"))
implementation(project(":feature:status"))
implementation(libs.androidx.core.splashscreen)
implementation(libs.compose.material3.adaptive)
implementation(libs.compose.navigation3)
+6
View File
@@ -5,3 +5,9 @@ plugins {
android {
namespace = "com.rtbishop.look4sat.core.data"
}
dependencies {
// DeepCW 神经网络 CW 解码推理。ONNX 推理属 Android 平台依赖, 放此处而非
// core:domain —— 后者须保持纯 Kotlin/JVM 以留 KMP 迁移余地 (见 AGENTS.md)。
implementation(libs.other.onnxruntime)
}
@@ -0,0 +1,106 @@
package com.rtbishop.look4sat.core.data.aprs
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.net.InetSocketAddress
import java.net.Socket
/**
* APRS-IS TCP client (reverse-ported from APRSdroid TcpUploader.scala).
* Plain-text protocol: one login line + one packet per line; 30 s reconnect after drop.
*/
class AprsIsClient(
private val host: String,
private val port: Int,
private val callsign: String,
private val ssid: String,
private val passcode: Int,
private val version: String,
private val filter: String = "",
private val timeoutSec: Int = 120
) {
private var socket: Socket? = null
private var writer: PrintWriter? = null
private var reader: BufferedReader? = null
private val lock = Any()
val isConnected: Boolean
get() = synchronized(lock) { socket?.isConnected == true && !socket!!.isClosed }
/** Connect + login (synchronous/blocking; call from a background thread) */
@Throws(Exception::class)
fun connect() {
disconnect()
val s = Socket()
s.connect(InetSocketAddress(host, port), 30_000)
s.soTimeout = timeoutSec * 1000
s.tcpNoDelay = true
synchronized(lock) {
socket = s
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
}
// Login line
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
writer?.println(login)
// Read the login response (aprsc replies # logresp ... verified/unverified)
runCatching {
s.soTimeout = 8000
val resp = reader?.readLine()
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
resp.contains("unverified", ignoreCase = true))) {
throw IllegalArgumentException(resp.trim())
}
// Restore timeout
s.soTimeout = timeoutSec * 1000
}
}
/**
* Sends one APRS packet (one line) and tries to read the server ack.
* Returns null=failed to send; Pair(ok, detail)=result (server error text lives in detail)
*/
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
synchronized(lock) {
val w = writer ?: return null
w.println(packetLine)
if (w.checkError()) return Pair(false, "write failed")
}
// Try to read the server response (short 3 s timeout; APRS-IS replies with an error line on bad format)
return runCatching {
val s = socket ?: return@runCatching Pair(true, "OK")
val oldTimeout = s.soTimeout
s.soTimeout = 3000
try {
val resp = reader?.readLine()
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
resp.contains("error", ignoreCase = true))) {
Pair(false, resp.trim())
} else {
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
}
} finally {
s.soTimeout = oldTimeout
}
}.getOrElse { Pair(true, "OK") }
}
/** Read one line (server response; throws on timeout) */
fun readLine(): String? {
return reader?.readLine()
}
fun disconnect() {
synchronized(lock) {
runCatching { writer?.close() }
runCatching { reader?.close() }
runCatching { socket?.close() }
writer = null
reader = null
socket = null
}
}
}
@@ -0,0 +1,126 @@
package com.rtbishop.look4sat.core.data.aprs
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
import com.rtbishop.look4sat.core.domain.aprs.AprsPosition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/** APRS connection state */
enum class AprsState { Idle, Connecting, Connected, Disconnected, Error }
/** APRS config (persisted in SharedPreferences, saved as filled) */
data class AprsConfig(
val enabled: Boolean = false,
val server: String = "euro.aprs2.net",
val port: Int = 14580,
val callsign: String = "",
val ssid: String = "",
val passcode: String = "",
val intervalMin: Int = 5,
val statusText: String = "Look4Sat APRS",
val symbolTable: String = "/",
val symbolCode: String = ">",
val includeCourseSpeed: Boolean = true,
val includeAltitude: Boolean = true
)
/** APRS report result */
data class AprsReport(
val timestamp: Long,
val packet: String,
val ok: Boolean,
val detail: String
)
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
class AprsReporter(
private val configProvider: () -> AprsConfig,
private val positionProvider: () -> Pair<Double, Double>? = { null },
private val onState: (AprsState) -> Unit = {},
private val onReport: (AprsReport) -> Unit = {}
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var client: AprsIsClient? = null
private var job: Job? = null
private var manualJob: Job? = null
val isRunning: Boolean get() = job?.isActive == true
/** Start periodic reporting (called by the foreground service) */
fun start() {
stop()
val cfg = configProvider()
if (!cfg.enabled || cfg.callsign.isBlank()) {
onState(AprsState.Error)
return
}
job = scope.launch {
while (isActive) {
reportOnce()
delay(cfg.intervalMin.coerceAtLeast(1) * 60_000L)
}
}
}
fun stop() {
job?.cancel()
job = null
runCatching { client?.disconnect() }
client = null
onState(AprsState.Idle)
}
/** Trigger one report manually (immediately, without waiting for the cycle) */
fun reportNow() {
manualJob?.cancel()
manualJob = scope.launch { reportOnce() }
}
private suspend fun reportOnce() {
val cfg = configProvider()
if (!cfg.enabled || cfg.callsign.isBlank()) return
onState(AprsState.Connecting)
try {
val c = client ?: AprsIsClient(
host = cfg.server,
port = cfg.port,
callsign = cfg.callsign,
ssid = cfg.ssid,
passcode = cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(cfg.callsign),
version = "Look4Sat 4.5.4"
).also { client = it }
if (!c.isConnected) c.connect()
onState(AprsState.Connected)
val pos = positionProvider()
val packetLine = buildPositionPacket(cfg, pos?.first, pos?.second)
val result = c.sendPacket(packetLine)
val ok = result?.first == true
val detail = result?.second ?: "no connection"
onReport(AprsReport(System.currentTimeMillis(), packetLine, ok, detail))
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
} catch (e: Exception) {
runCatching { client?.disconnect() }
client = null
onState(AprsState.Error)
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error"))
}
}
/** Build position packet: BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME<status text */
private fun buildPositionPacket(cfg: AprsConfig, lat: Double? = null, lon: Double? = null): String {
val source = AprsPacket.formatCallSsid(cfg.callsign, cfg.ssid)
val pos = AprsPosition(
latitude = lat ?: 0.0,
longitude = lon ?: 0.0,
symbolTable = cfg.symbolTable.firstOrNull() ?: '/',
symbolCode = cfg.symbolCode.firstOrNull() ?: '>'
)
return "$source>APRS:=${pos.toUncompressedString()}${cfg.statusText}"
}
}
@@ -0,0 +1,83 @@
package com.rtbishop.look4sat.core.data.aprs
import android.content.Context
/**
* APRS config storage + service action constants (shared by feature/settings and the app service,
* so feature never depends on app).
*/
object AprsStore {
const val ACTION_START = "com.rtbishop.look4sat.aprs.START"
const val ACTION_STOP = "com.rtbishop.look4sat.aprs.STOP"
const val ACTION_REPORT_NOW = "com.rtbishop.look4sat.aprs.REPORT_NOW"
const val SERVICE_CLASS = "com.rtbishop.look4sat.app.AprsForegroundService"
private const val PREFS = "aprs_config"
private const val KEY_ENABLED = "enabled"
private const val KEY_SERVER = "server"
private const val KEY_PORT = "port"
private const val KEY_CALLSIGN = "callsign"
private const val KEY_SSID = "ssid"
private const val KEY_PASSCODE = "passcode"
private const val KEY_INTERVAL = "interval"
private const val KEY_STATUS = "status"
private const val KEY_SYMBOL_TABLE = "symbol_table"
private const val KEY_SYMBOL_CODE = "symbol_code"
private const val KEY_LAST_TIME = "last_report_time"
private const val KEY_LAST_OK = "last_report_ok"
private const val KEY_LAST_DETAIL = "last_report_detail"
/** Read config (saved as filled; no need to re-enter each time) */
fun loadConfig(context: Context): AprsConfig {
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return AprsConfig(
enabled = p.getBoolean(KEY_ENABLED, false),
server = p.getString(KEY_SERVER, "euro.aprs2.net") ?: "euro.aprs2.net",
port = p.getInt(KEY_PORT, 14580),
callsign = p.getString(KEY_CALLSIGN, "") ?: "",
ssid = p.getString(KEY_SSID, "") ?: "",
passcode = p.getString(KEY_PASSCODE, "") ?: "",
intervalMin = p.getInt(KEY_INTERVAL, 5),
statusText = p.getString(KEY_STATUS, "Look4Sat APRS") ?: "Look4Sat APRS",
symbolTable = p.getString(KEY_SYMBOL_TABLE, "/") ?: "/",
symbolCode = p.getString(KEY_SYMBOL_CODE, ">") ?: ">"
)
}
/** Last report result (shown on the settings card) */
data class LastReport(val time: Long = 0L, val ok: Boolean = false, val detail: String = "")
fun loadLastReport(context: Context): LastReport {
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return LastReport(
time = p.getLong(KEY_LAST_TIME, 0L),
ok = p.getBoolean(KEY_LAST_OK, false),
detail = p.getString(KEY_LAST_DETAIL, "") ?: ""
)
}
fun saveLastReport(context: Context, ok: Boolean, detail: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putLong(KEY_LAST_TIME, System.currentTimeMillis())
.putBoolean(KEY_LAST_OK, ok)
.putString(KEY_LAST_DETAIL, detail)
.apply()
}
/** Save config */
fun saveConfig(context: Context, cfg: AprsConfig) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putBoolean(KEY_ENABLED, cfg.enabled)
.putString(KEY_SERVER, cfg.server)
.putInt(KEY_PORT, cfg.port)
.putString(KEY_CALLSIGN, cfg.callsign)
.putString(KEY_SSID, cfg.ssid)
.putString(KEY_PASSCODE, cfg.passcode)
.putInt(KEY_INTERVAL, cfg.intervalMin)
.putString(KEY_STATUS, cfg.statusText)
.putString(KEY_SYMBOL_TABLE, cfg.symbolTable)
.putString(KEY_SYMBOL_CODE, cfg.symbolCode)
.apply()
}
}
@@ -0,0 +1,336 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.cw
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.content.Context
import android.util.Log
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import com.rtbishop.look4sat.core.domain.cw.ICwDecoder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.nio.FloatBuffer
/**
* CW decoder backed by the DeepCW neural network (AGPL-3.0, see
* `feature/cw/licenses/NOTICE.md`).
*
* The model classifies a whole audio segment at once rather than streaming
* sample by sample, and it revises earlier characters once more context
* arrives. Incremental stitching therefore produces duplicated callsigns —
* measured character error rates of 67-294% against 0% for whole-segment
* decoding. Instead a [CwDeepBuffer] holds the last 20 seconds and the whole
* window is re-decoded every 1.5 seconds, replacing [decodedText] outright.
*
* The model's fixed 400-1200 Hz analysis window means pitch detection is built
* in; no spectral peak tracking or squelch gating is needed.
*/
class CwDeepDecoder(context: Context) : ICwDecoder {
private companion object {
const val TAG = "CwDeepDecoder"
const val MODEL_ASSET = "deepcw/model.onnx"
const val METADATA_ASSET = "deepcw/model.onnx.json"
/** Evicted audio is decoded into permanent history once this much accumulates. */
const val ARCHIVE_SECONDS = 15.0
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
}
private val _decodedText = MutableStateFlow("")
override val decodedText: StateFlow<String> = _decodedText.asStateFlow()
private val _historyText = MutableStateFlow("")
override val historyText: StateFlow<String> = _historyText.asStateFlow()
private val _estimatedPitch = MutableStateFlow<Float?>(null)
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
private val _signalStrength = MutableStateFlow(0f)
override val signalStrength: StateFlow<Float> = _signalStrength.asStateFlow()
private val _lastInferenceMs = MutableStateFlow(0)
override val lastInferenceMs: StateFlow<Int> = _lastInferenceMs.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
override val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
private val buffer = CwDeepBuffer()
/**
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then
* is decoded once and appended to [historyText]. Archiving in ~15 s chunks
* keeps the extra inference cheap (short window) while long enough to be
* decoded accurately — the content has already been through the 20 s window
* many times, so a slightly shorter archive decode loses almost nothing.
*/
private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE)
private var archiveSize = 0
/** Held while inference runs so slow devices skip work instead of queuing it. */
private val inferenceLock = Mutex()
private var environment: OrtEnvironment? = null
private var session: OrtSession? = null
private var chars: List<String> = emptyList()
private var blankIndex = 41
private var inputName = "spectrogram"
private var outputName = "log_probs"
private val appContext = context.applicationContext
private var loadAttempted = false
init {
CwProbe.init(appContext)
CwProbe.step("decoder_constructed")
}
/**
* Loads metadata and the ONNX session on first use.
*
* Deliberately not done in `init`: loading pulls in ONNX Runtime's native
* library, and a failure there surfaces as [UnsatisfiedLinkError]. Thrown
* from a constructor it would take down the whole composable that created
* the decoder, so the work happens here where it can be reported through
* [errorMessage] instead.
*
* @return true when the session is ready to run.
*/
private fun ensureLoaded(): Boolean {
if (session != null) return true
if (loadAttempted) return false
loadAttempted = true
CwProbe.step("load_begin")
try {
val metadata = JSONObject(
appContext.assets.open(METADATA_ASSET).bufferedReader().use { it.readText() }
)
val charArray = metadata.getJSONArray("chars")
chars = List(charArray.length()) { charArray.getString(it) }
blankIndex = metadata.getInt("blank_index")
inputName = metadata.getString("onnx_input_name")
outputName = metadata.getString("onnx_output_name")
val modelBytes = appContext.assets.open(MODEL_ASSET).use { it.readBytes() }
val env = OrtEnvironment.getEnvironment()
environment = env
val options = OrtSession.SessionOptions().apply {
// Keep a core free for audio capture and the UI; the default
// would spread inference across every core on the device.
val threads = (Runtime.getRuntime().availableProcessors() - 1).coerceIn(1, 4)
setIntraOpNumThreads(threads)
}
session = env.createSession(modelBytes, options)
CwProbe.step("load_session_ok")
Log.i(TAG, "DeepCW ready: ${modelBytes.size} bytes, ${chars.size} classes")
return true
} catch (t: Throwable) {
// Catches UnsatisfiedLinkError (missing/mismatched .so) as well as
// asset and session failures.
CwProbe.step("load_failed:${t.javaClass.simpleName}")
Log.e(TAG, "DeepCW model failed to load", t)
_errorMessage.value =
"CW model failed to load: ${t.message ?: t.javaClass.simpleName}"
// Persist the failure for devices without logcat access.
runCatching {
val sw = java.io.StringWriter()
t.printStackTrace(java.io.PrintWriter(sw))
java.io.File(appContext.filesDir, "deepcw_load_error.txt")
.writeText("${t.javaClass.name}: ${t.message}\n${sw}\n")
}
return false
}
}
override suspend fun processBuffer(samples: FloatArray, sampleRate: Int) {
if (samples.isEmpty()) return
if (!ensureLoaded()) return
val resampled = CwDeepSpectrogram.resampleLinear(
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
)
val shouldRedecode = buffer.append(resampled)
// Archive audio that scrolled out of the live window. It is decoded once
// when a full archive chunk has accumulated, so old text does not vanish.
val overflow = buffer.drainOverflow()
if (overflow.isNotEmpty()) {
for (v in overflow) {
if (archiveSize < archiveBuffer.size) archiveBuffer[archiveSize++] = v
}
if (archiveSize >= ARCHIVE_THRESHOLD) {
val audio = archiveBuffer.copyOf(archiveSize)
archiveSize = 0
try {
archiveDecode(audio)
} catch (t: Throwable) {
if (t is CancellationException) throw t
Log.e(TAG, "archive decode failed", t)
}
}
}
if (!shouldRedecode || !buffer.hasEnoughAudio) return
// Drop this cycle rather than queue when the previous run is still going.
if (!inferenceLock.tryLock()) {
Log.d(TAG, "inference still running, skipping this interval")
return
}
try {
decodeWindow(buffer.snapshot())
} catch (t: Throwable) {
// Cancellation is normal when the user pauses: the capture coroutine
// is cancelled while an inference is in flight. Never swallow it as
// a decode error — rethrow so the coroutine machinery works, and do
// not flash a spurious "decode failed" banner.
if (t is CancellationException) throw t
Log.e(TAG, "inference failed", t)
_errorMessage.value = "CW decode failed: ${t.message ?: t.javaClass.simpleName}"
runCatching {
val sw = java.io.StringWriter()
t.printStackTrace(java.io.PrintWriter(sw))
java.io.File(appContext.filesDir, "deepcw_infer_error.txt")
.writeText("${t.javaClass.name}: ${t.message}\n${sw}\n")
}
} finally {
inferenceLock.unlock()
}
}
private suspend fun decodeWindow(window: FloatArray) = withContext(Dispatchers.Default) {
val activeSession = session ?: return@withContext
val activeEnvironment = environment ?: return@withContext
CwProbe.step("infer_begin frames=${window.size}")
val spectrogram = CwDeepSpectrogram.compute(window)
val text = runInference(activeSession, activeEnvironment, spectrogram)
// Replace, never append: the model rewrites earlier characters as more
// context arrives, so appending would leave stale guesses on screen.
_decodedText.value = text
updateSignalMetrics(spectrogram)
}
/**
* Decode a chunk of audio that has scrolled out of the live window and
* append it to [historyText]. Unlike the live window this never replaces —
* the archived audio is final, so its text is permanent.
*/
private suspend fun archiveDecode(audio: FloatArray) = withContext(Dispatchers.Default) {
val activeSession = session ?: return@withContext
val activeEnvironment = environment ?: return@withContext
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
val spectrogram = CwDeepSpectrogram.compute(audio)
val text = runInference(activeSession, activeEnvironment, spectrogram)
if (text.isNotEmpty()) {
_historyText.value += text
}
}
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
private fun runInference(
activeSession: OrtSession,
activeEnvironment: OrtEnvironment,
spectrogram: Array<FloatArray>
): String {
val frames = spectrogram.size
val bins = CwDeepSpectrogram.FREQUENCY_BINS
val flat = FloatBuffer.allocate(frames * bins)
for (frame in spectrogram) flat.put(frame)
flat.rewind()
val shape = longArrayOf(1, 1, frames.toLong(), bins.toLong())
val startedAt = System.currentTimeMillis()
val text: String
OnnxTensor.createTensor(activeEnvironment, flat, shape).use { input ->
activeSession.run(mapOf(inputName to input)).use { result ->
@Suppress("UNCHECKED_CAST")
val logits = result[outputName].get().value as Array<Array<FloatArray>>
text = CwCtcDecoder.greedy(logits, chars, blankIndex)
}
}
_lastInferenceMs.value = (System.currentTimeMillis() - startedAt).toInt()
CwProbe.step("infer_done ms=${_lastInferenceMs.value}")
return text
}
/**
* Report the loudest bin as the tone pitch and its prominence over the
* window mean as a 0..1 strength, purely for the UI readout.
*/
private fun updateSignalMetrics(spectrogram: Array<FloatArray>) {
if (spectrogram.isEmpty()) return
var bestBin = 0
var bestValue = 0f
var total = 0f
var count = 0
for (frame in spectrogram) {
for (bin in frame.indices) {
val value = frame[bin]
total += value
count++
if (value > bestValue) {
bestValue = value
bestBin = bin
}
}
}
if (count == 0 || bestValue <= 0f) return
val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH
// Relative bin 0 is 400 Hz; absolute bin index is 32 + bestBin.
val absoluteBin = 32 + bestBin
_estimatedPitch.value = (absoluteBin * binHz).toFloat()
val mean = total / count
_signalStrength.value = ((bestValue - mean) / bestValue).coerceIn(0f, 1f)
}
override fun reset() {
buffer.reset()
_decodedText.value = ""
_historyText.value = ""
archiveSize = 0
_estimatedPitch.value = null
_signalStrength.value = 0f
_lastInferenceMs.value = 0
}
override fun close() {
try {
session?.close()
} catch (t: Throwable) {
Log.w(TAG, "session close failed", t)
}
session = null
environment = null
}
}
@@ -15,34 +15,32 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
package com.rtbishop.look4sat.core.data.cw
import android.content.Context
import android.util.Log
/**
* First-order IIR filters.
* Ported from ggmorse/src/filter.h
* Minimal crash-probe logger for diagnosing crashes that produce no Java
* stack trace (native faults, low-memory kills). Each step appends one line
* to `files/probe_cw.txt`; if the process dies mid-way the last line shows
* exactly where. No adb or logcat required.
*/
internal class CwFilter {
private var z1 = 0f
internal object CwProbe {
companion object {
private const val PI_F = 3.141592653589793f
private var dir: java.io.File? = null
fun init(context: Context) {
dir = context.filesDir
}
fun highPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
val rc = 1.0f / (2f * PI_F * cutoffHz)
val dt = 1.0f / sampleRate
val alpha = dt / (rc + dt)
z1 = alpha * (z1 + sample - z1)
return sample - z1
fun step(label: String) {
val target = dir ?: return
runCatching {
val line = "${System.currentTimeMillis()} $label"
val file = java.io.File(target, "probe_cw.txt")
file.appendText("$line\n")
Log.i("CwProbe", line)
}
}
fun lowPass(sample: Float, cutoffHz: Float, sampleRate: Float): Float {
val rc = 1.0f / (2f * PI_F * cutoffHz)
val dt = 1.0f / sampleRate
val alpha = dt / (rc + dt)
z1 += alpha * (sample - z1)
return z1
}
fun reset() { z1 = 0f }
}
}
@@ -29,6 +29,7 @@ import com.rtbishop.look4sat.core.data.framework.Ft817Controller
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
@@ -69,16 +70,19 @@ 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 {
private val localSource = provideLocalSource()
private val remoteSource by lazy { provideRemoteSource() }
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
override val settingsRepo = provideSettingsRepo()
override val selectionRepo = provideSelectionRepo()
override val satelliteRepo = provideSatelliteRepo()
override val databaseRepo = provideDatabaseRepo()
override val amSatRepo by lazy { AmSatRepository(remoteSource) }
override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
@@ -97,9 +101,14 @@ class MainContainer(private val context: Context) : IMainContainer {
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
// 每次调用返回新实例: 调用方负责 close() 释放 OrtSession, 且 Radar 内嵌
// 面板与独立 CW 页各自持有自己的解码器
override fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder =
com.rtbishop.look4sat.core.data.cw.CwDeepDecoder(context)
override fun provideSaveImage(): ISaveImage = SaveImage(context)
// WaveLog 日志(4.5.2): 本地队列 + 上传器(共享实例)
// WaveLog logging (4.5.2): local queue + uploader (shared instance)
override val wavelogQueue: WavelogQueue by lazy {
val prefs = context.getSharedPreferences("wavelog", Context.MODE_PRIVATE)
WavelogQueue(object : IWavelogQueueStore {
@@ -109,6 +118,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
@@ -162,7 +177,6 @@ class MainContainer(private val context: Context) : IMainContainer {
private fun provideDatabaseRepo(): IDatabaseRepo {
val dbDispatcher = Dispatchers.Default
val dataParser = DataParser(dbDispatcher)
val remoteSource = provideRemoteSource()
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
}
@@ -0,0 +1,156 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import java.util.TimeZone
/** One report from the AMSAT API (data layer model). */
private data class ApiReport(
val id: String,
val name: String,
val callsign: String,
val report: String,
val gridSquare: String,
val reportedTimeUtcSec: Long
)
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
val reportsJson = remoteSource.getAmSatReports(hours = 168, limit = 500) ?: return@withContext null
val names = parseCatalog(catalogJson)
val reports = parseReports(reportsJson)
if (names.isEmpty() && reports.isEmpty()) return@withContext null
val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) }
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
}
/** Parse catalog JSON to list of satellite names */
private fun parseCatalog(json: String): List<String> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
} catch (e: Exception) {
emptyList()
}
}
/** Parse reports JSON to list of ApiReport domain objects */
private fun parseReports(json: String): List<ApiReport> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val iso = o.optString("reported_time", "")
if (iso.isEmpty()) null else ApiReport(
id = o.optString("id", ""),
name = o.optString("name", ""),
callsign = o.optString("callsign", ""),
report = o.optString("report", ""),
gridSquare = o.optString("grid_square", ""),
reportedTimeUtcSec = parseIsoUtcSec(iso)
)
}
} catch (e: Exception) {
emptyList()
}
}
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
private fun parseIsoUtcSec(iso: String): Long {
return try {
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
} catch (e: Exception) {
0L
}
}
/** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name }
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val labels = (0 until 6).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
}
return names.map { name ->
val slots = (0 until 72).map { slotIdx ->
val slotStart = nowSec - (slotIdx + 1) * 7200L
val slotEnd = nowSec - slotIdx * 7200L
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
SatSlot(
statusColor = statusColorOf(newest.report),
count = inSlot.size,
reportIds = inSlot.map { it.id }
)
}
}
val days = (0 until 6).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
}
SatStatus(name = name, days = days)
}
}
private fun toSatReport(r: ApiReport): SatReport {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = r.reportedTimeUtcSec * 1000
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
val y = cal.get(Calendar.YEAR)
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
return SatReport(
id = r.id,
statusText = r.report,
call = r.callsign,
grid = r.gridSquare,
dateUtc = "$y-$mo-$d",
timeUtc = "$hh:$mm UTC"
)
}
/** Map status text to color value (for UI rendering). */
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
"heard", "crew active" -> ACTIVE_BLUE
"telemetry only" -> TLM_ORANGE
"not heard" -> NOT_HEARD_PINK
else -> CONFLICT_DEEP_ORANGE
}
companion object {
// AMSAT official status colors (from amsat.org/status)
private const val ACTIVE_BLUE = 0xFF648FFF
private const val TLM_ORANGE = 0xFFFFB000
private const val NOT_HEARD_PINK = 0xFFDC267F
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
private const val NO_REPORT_GRAY = 0xFFC0C0C0
}
}
@@ -69,7 +69,7 @@ class DatabaseRepo(
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap {
putAll(Sources.satelliteDataUrls)
// 开关开且 URL 非空 -> All 用自定义 URL;否则用默认 URL(在线更新默认源)
// Switch on + non-empty URL -> All uses the custom URL; otherwise the default URL (online-update default source)
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
}.filterValues { it.isNotBlank() }
@@ -81,7 +81,7 @@ class DatabaseRepo(
// launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// 统计成功源数: 0 成功视为更新失败(不刷时间戳, 抛异常让 UI 提示)
// Count successful sources: zero successes = update failed (timestamp untouched, exception surfaced in the UI)
val tleResults = tleJobs.awaitAll()
val radioResults = radioJobs.awaitAll()
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
@@ -187,9 +187,9 @@ class SettingsRepo(
return true
}
/** GPS 定位: 一次性 getCurrentLocation(GPS 优先, 15 秒超时), 拿到位置才返回 true */
/** GPS fix: one-shot getCurrentLocation (GPS first, 15 s timeout); returns true only with a fix */
override suspend fun setStationPosition(): Boolean {
// 权限前置: 无定位权限直接失败(不吞异常)
// Permission gate: fail fast without location permission (no swallowed exceptions)
if (androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.ACCESS_FINE_LOCATION
) != android.content.pm.PackageManager.PERMISSION_GRANTED
@@ -202,7 +202,7 @@ class SettingsRepo(
val signal = android.os.CancellationSignal()
val handler = android.os.Handler(android.os.Looper.getMainLooper())
val executor = java.util.concurrent.Executor { handler.post(it) }
// 15 秒超时
// 15 s timeout
val timeout = handler.postDelayed({
signal.cancel()
if (cont.isActive) cont.resume(false) { }
@@ -432,8 +432,8 @@ class SettingsRepo(
}
private fun getDataSourcesSettings(): DataSourcesSettings {
// 4.4.8 修复: 旧版 example.com 占位 URL 视为未配置 -> 替换为真实默认 URL 且开关强制关闭,
// 否则在线更新的 All/SatNOGS 源会指向错误地址导致更新失败
// 4.4.8 fix: legacy example.com placeholder URLs count as unconfigured -> replaced with the real default URL and the switch forced off,
// otherwise the online All/SatNOGS sources would point at the wrong address and fail to update
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
@@ -0,0 +1,136 @@
package com.rtbishop.look4sat.core.data.source
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import java.util.regex.Pattern
/**
* AMSAT satellite status page parser (https://amsat.org/status/)
*
* Page structure (static HTML, verified 2026-08):
* - Status table: 48 rows in <table>, header = Name + 6 days (each colspan=12)
* Data row has 73 cells: [0]=satellite name, [1..72] = 6 days x 12 two-hour slots (new->old)
* Each cell: <td width=9 bgcolor="color">count</td>; when reports exist the count carries a
* docTips.show('id') link
* - Report details live in the page's inline JS:
* tips.a885153 = new Array(5,5,120,'status<br>callsign<br>grid<br>date<br>time span UTC')
*
* Status colors: #648fff=active, #ffb000=telemetry only, #dc267f=not heard, #fe6100=conflicting
*/
object AmSatParser {
private val STATUS_COLORS = mapOf(
"#648fff" to 0xFF648FFF.toLong(),
"#ffb000" to 0xFFFFB000.toLong(),
"#dc267f" to 0xFFDC267F.toLong(),
"#fe6100" to 0xFFFE6100.toLong()
)
private val GRAY = 0xFFC0C0C0.toLong()
private val rowRe = Pattern.compile("<tr>(.*?)</tr>", Pattern.DOTALL)
private val cellRe = Pattern.compile("(<t[dh][^>]*>.*?</t[dh]>)", Pattern.DOTALL)
private val bgRe = Pattern.compile("bgcolor=\"?(#[0-9a-fA-F]{6}|C0C0C0)\"?")
private val linkRe = Pattern.compile("docTips\\.show\\('(a\\d+)'\\)")
private val tipRe = Pattern.compile(
"tips\\.(a\\d+)\\s*=\\s*new\\s+Array\\(\\s*\\d+,\\s*\\d+,\\s*\\d+,\\s*'([^']*)'"
)
/** Parse the full page */
fun parse(html: String, fetchedAtUtcMs: Long): SatStatusPage {
val reports = parseReports(html)
val statuses = parseStatusTable(html, reports)
return SatStatusPage(fetchedAtUtcMs, statuses, reports)
}
/** Extract all reports (tips.* JS arrays) */
fun parseReports(html: String): Map<String, SatReport> {
val map = mutableMapOf<String, SatReport>()
val m = tipRe.matcher(html)
while (m.find()) {
val id = m.group(1)
val parts = m.group(2).split("<br>")
map[id] = SatReport(
id = id,
statusText = parts.getOrElse(0) { "" }.trim(),
call = parts.getOrElse(1) { "" }.trim(),
grid = parts.getOrElse(2) { "" }.trim(),
dateUtc = parts.getOrElse(3) { "" }.trim(),
timeUtc = parts.getOrElse(4) { "" }.trim()
)
}
return map
}
/** Parse the status table (48 satellite rows) */
fun parseStatusTable(html: String, reports: Map<String, SatReport>): List<SatStatus> {
val result = mutableListOf<SatStatus>()
val tables = extractTables(html)
for (table in tables) {
val rows = rowRe.matcher(table)
val parsed = mutableListOf<SatStatus>()
var rowIndex = 0
var dayHeaders: List<String> = emptyList()
while (rows.find()) {
val rowHtml = rows.group(1)
val cells = cellRe.matcher(rowHtml)
val cellList = mutableListOf<String>()
while (cells.find()) cellList.add(cells.group(1))
if (rowIndex == 0) {
// Header: Name + 6 days (each colspan=12)
dayHeaders = cellList.drop(1).take(6).map { stripHtml(it) }
rowIndex++
continue
}
if (cellList.size < 7) { rowIndex++; continue }
val name = stripHtml(cellList[0])
if (name.isBlank()) { rowIndex++; continue }
val days = mutableListOf<SatDay>()
for (d in 0 until 6) {
val start = 1 + d * 12
val end = start + 12
val slots = (start until end).mapNotNull { i ->
cellList.getOrNull(i)?.let { cell ->
val bg = bgRe.matcher(cell)
val color = if (bg.find()) {
STATUS_COLORS[bg.group(1).lowercase()] ?: GRAY
} else GRAY
val link = linkRe.matcher(cell)
val ids = mutableListOf<String>()
while (link.find()) ids.add(link.group(1))
val hasReport = ids.isNotEmpty()
val count = if (hasReport) {
stripHtml(cell).trim().toIntOrNull() ?: ids.size
} else 0
SatSlot(
statusColor = if (hasReport) color else GRAY,
count = count,
reportIds = ids
)
}
}
days.add(SatDay(dayHeaders.getOrElse(d) { "" }, slots))
}
parsed.add(SatStatus(name, days))
rowIndex++
}
if (parsed.isNotEmpty()) {
result.addAll(parsed)
break
}
}
return result
}
private fun extractTables(html: String): List<String> {
val result = mutableListOf<String>()
val m = Pattern.compile("<table.*?</table>", Pattern.DOTALL).matcher(html)
while (m.find()) result.add(m.group())
return result
}
private fun stripHtml(s: String): String =
s.replace(Regex("<[^>]+>"), "").trim()
}
@@ -43,6 +43,22 @@ class RemoteSource(
}
}
override suspend fun getStatusHtml(): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://amsat.org/status/")
.header("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat/4.5")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body?.string()
}
} catch (exception: Exception) {
println("RemoteSource amsat status exception: $exception")
null
}
}
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
try {
val networkRequest = Request.Builder().url(url).build()
@@ -55,4 +71,36 @@ class RemoteSource(
null
}
}
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/catalog.php")
.header("User-Agent", "Look4Sat/4.5.5")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body?.string()
}
} catch (exception: Exception) {
println("RemoteSource amsat catalog exception: $exception")
null
}
}
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
.header("User-Agent", "Look4Sat/4.5.5")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body?.string()
}
} catch (exception: Exception) {
println("RemoteSource amsat reports exception: $exception")
null
}
}
}
@@ -0,0 +1,62 @@
/* LotwSatellitesRepo.kt - runtime refresh of the LoTW satellite list (4.5.5).
* Downloads ARRL's official config.tq6 (gzip XML), parses <satellite name="...">,
* updates the LotwSatellites dynamic set + persists to SharedPreferences (survives restarts).
* Trigger: the "Update sats" button in the settings WaveLog section (user decided: manual).
*/
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)
/** Restore the last downloaded list from SharedPreferences (call at process start) */
override fun restore() {
val saved = prefs.getString(KEY_NAMES, null)
if (!saved.isNullOrBlank()) {
LotwSatellites.updateNames(saved.split(",").filter { it.isNotBlank() }.toSet())
}
}
/** Download config.tq6 -> parse -> update memory + persist. Result is surfaced in the 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() }
// Parse <satellite name="XXX" ...> - fixed format (official 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"
}
}
@@ -102,7 +102,7 @@ class DatabaseRepoTest {
repository.updateFromRemote()
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
// 新语义: 开关开 + URL 非空 -> All 源用自定义 URL, 数据归入 All 类型
// New semantics: switch on + non-empty URL -> the All source uses the custom URL, data lands in the All type
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
}
@@ -0,0 +1,116 @@
package com.rtbishop.look4sat.core.domain.aprs
import kotlin.math.abs
import kotlin.math.round
/**
* APRS-IS protocol core (pure Kotlin, no Android dependencies).
* Reverse-ported from APRSdroid 1.6.3d: AprsPacket$.scala + ab0oo Position.java.
*/
object AprsPacket {
/** APRS-IS passcode algorithm (standard): 0x73E2 seed, uppercase callsign + \0, XOR each char pair */
fun passcode(callsign: String): Int {
val s = callsign.split("-")[0].uppercase() + "\u0000"
var hash = 29666 // 0x73E2
var i = 0
while (i <= s.length - 2) {
hash = hash xor (s[i].code * 256 + s[i + 1].code)
i += 2
}
return hash and 0x7FFF
}
/** Login line: user CALL-SSID pass XXXX vers XXXX */
fun formatLogin(callsign: String, ssid: String, passcode: Int, version: String): String {
val callSsid = formatCallSsid(callsign, ssid)
return "user $callSsid pass $passcode vers $version"
}
/** Callsign-SSID join (BG7NTA + 5 -> BG7NTA-5) */
fun formatCallSsid(callsign: String, ssid: String): String {
if (ssid.isNullOrEmpty()) return callsign
return "$callsign-$ssid"
}
/** Optional distance filter: filter r/lat/lon/dist */
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
return String.format("r/%.3f/%.3f/%d", latitude, longitude, distKm)
}
/** Altitude extension /A=00000 (feet) */
fun formatAltitude(altitudeMeters: Double?): String {
if (altitudeMeters == null) return ""
return String.format("/A=%06d", (altitudeMeters * 3.2808399).toInt())
}
/** Speed/course extension (knots/degrees) */
fun formatCourseSpeed(speedMps: Double?, bearing: Float?): String {
if (speedMps == null || bearing == null) return ""
val knots = (speedMps * 1.94384449).toInt()
return String.format("/%03d/%03d", bearing.toInt(), knots)
}
}
/**
* APRS position encoding (reverse-ported from ab0oo Position.java).
* Uncompressed: DDMM.MMN/DDDMM.MME; compressed: base91.
*/
class AprsPosition(
val latitude: Double,
val longitude: Double,
val symbolTable: Char,
val symbolCode: Char,
val positionAmbiguity: Int = 0
) {
/** Uncompressed format (APRS-IS default reporting format) */
fun toUncompressedString(): String {
val lat = getDMS(latitude, true)
val lon = getDMS(longitude, false)
return "$lat$symbolTable$lon$symbolCode"
}
/** Compressed format (base91, standard APRS algorithm) */
fun toCompressedString(): String {
val jRound = round((90.0 - latitude) * 380926.0).toLong()
val j = jRound / 753571 + 33
val j2 = jRound % 753571
val j3 = j2 / 8281 + 33
val j4 = j2 % 8281
val i = (j4 % 91).toInt() + 33
val jRound2 = round((longitude + 180.0) * 190463.0).toLong()
val j5 = jRound2 / 753571 + 33
val j6 = jRound2 % 753571
val j7 = 33 + j6 / 8281
val j8 = j6 % 8281
return "" + symbolTable + j.toInt().toChar() + j3.toInt().toChar() +
((j4 / 91).toInt() + 33).toChar() + i.toChar() +
j5.toInt().toChar() + j7.toInt().toChar() +
((j8 / 91).toInt() + 33).toChar() +
((j8 % 91).toInt() + 33).toChar() + symbolCode
}
/** Single-axis DMS encoding (hundredths) */
private fun getDMS(value: Double, isLat: Boolean): String {
var iRound = round(value * 6000.0).toInt()
if (iRound < 0) iRound = -iRound
val degrees = iRound / 6000
val minutes = (iRound / 100) % 60
val hundredths = iRound % 100
val frac = when (positionAmbiguity) {
1 -> " . "
2 -> String.format("%d . ", minutes / 10)
3 -> String.format("%02d. ", minutes)
4 -> String.format("%02d.%d ", minutes, hundredths / 10)
else -> String.format("%02d.%02d", minutes, hundredths)
}
return if (isLat) {
val ns = if (value >= 0) 'N' else 'S'
String.format("%02d%s%c", degrees, frac, ns)
} else {
val ew = if (value >= 0) 'E' else 'W'
String.format("%03d%s%c", degrees, frac, ew)
}
}
}
@@ -1,154 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Bayesian Morse timing decoder.
* Replaces hard thresholds with probability-based decision making.
*
* Inspired by VE3NEA's CW Skimmer approach:
* "Instead of making a hard decision at every input sample whether the signal
* is present or not, compute the probability that the signal is present."
*
* Uses Gaussian probability density centered on expected durations:
* P(dit | duration) = exp(-(duration - dotMs)^2 / (2 * variance^2))
* P(dash | duration) = exp(-(duration - 3*dotMs)^2 / (2 * variance^2))
*/
internal class CwBayesianDecoder {
// Morse timing parameters
private var dotDurationMs = 60f // initial 20 WPM
private var speedWpm = 20f
// Current symbol being accumulated
private var currentSymbol = StringBuilder()
private var textBuffer = StringBuilder()
// Recent dit lengths for speed estimation
private val recentDits = mutableListOf<Float>()
// Output
private var _decodedText = ""
val decodedText: String get() = _decodedText
/** Gaussian probability. */
private fun gaussianProb(durationMs: Float, expectedMs: Float, varianceMs: Float): Float {
if (varianceMs <= 0f) return 0f
val diff = durationMs - expectedMs
return kotlin.math.exp(-(diff * diff) / (2 * varianceMs * varianceMs))
}
/** Process a tone duration. Returns the symbol type with highest probability. */
fun processTone(durationMs: Float): ToneResult {
val ditProb = gaussianProb(durationMs, dotDurationMs, dotDurationMs * 0.4f)
val dashProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
return if (ditProb > dashProb && ditProb > 0.05f) {
currentSymbol.append('0')
recentDits.add(durationMs)
updateSpeed()
ToneResult('0', ditProb)
} else if (dashProb > 0.05f) {
currentSymbol.append('1')
ToneResult('1', dashProb)
} else {
ToneResult(null, 0f)
}
}
/** Process a gap duration. Returns decoded character or null. */
fun processGap(durationMs: Float): Char? {
if (currentSymbol.isEmpty()) {
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
if (wordProb > 0.2f) {
textBuffer.append(' ')
_decodedText = textBuffer.toString()
return ' '
}
return null
}
val interCharProb = gaussianProb(durationMs, dotDurationMs * 3f, dotDurationMs * 0.6f)
val wordProb = gaussianProb(durationMs, dotDurationMs * 7f, dotDurationMs * 1.2f)
if (wordProb > interCharProb && wordProb > 0.2f) {
val char = flushSymbol()
textBuffer.append(' ')
_decodedText = textBuffer.toString()
return char
}
if (interCharProb > 0.15f) {
val char = flushSymbol()
_decodedText = textBuffer.toString()
return char
}
return null
}
private fun flushSymbol(): Char? {
if (currentSymbol.isEmpty()) return null
val morse = currentSymbol.toString()
currentSymbol.clear()
val char = morseToChar(morse)
if (char != null) textBuffer.append(char)
return char
}
private fun updateSpeed() {
if (recentDits.size < 3) return
val sorted = recentDits.sorted()
val median = sorted[sorted.size / 2]
if (median > 0f) {
dotDurationMs = dotDurationMs * 0.7f + median * 0.3f
val wpm = 60.0f / (50.0f * dotDurationMs / 1000.0f)
if (wpm in 5f..55f) speedWpm = wpm
}
}
fun getSpeed(): Float = speedWpm
fun reset() {
dotDurationMs = 60f
speedWpm = 20f
recentDits.clear()
currentSymbol.clear()
textBuffer.clear()
_decodedText = ""
}
companion object {
private val MORSE_TABLE = mapOf(
"01" to 'A', "1000" to 'B', "1010" to 'C', "100" to 'D', "0" to 'E',
"0010" to 'F', "110" to 'G', "0000" to 'H', "00" to 'I', "0111" to 'J',
"101" to 'K', "0100" to 'L', "11" to 'M', "10" to 'N', "111" to 'O',
"0110" to 'P', "1101" to 'Q', "010" to 'R', "000" to 'S', "1" to 'T',
"001" to 'U', "0001" to 'V', "011" to 'W', "1001" to 'X', "1011" to 'Y',
"1100" to 'Z', "01111" to '1', "00111" to '2', "00011" to '3',
"00001" to '4', "00000" to '5', "10000" to '6', "11000" to '7',
"11100" to '8', "11110" to '9', "11111" to '0',
"010101" to '.', "110011" to ',', "001100" to '?', "011110" to '\'',
"101011" to '!', "10010" to '/', "10110" to '(', "101101" to ')',
"01000" to '&', "111000" to ':', "101010" to ';', "10001" to '=',
"01010" to '+', "100001" to '-', "001101" to '_', "010010" to '"',
"0001001" to '$', "011010" to '@'
)
fun morseToChar(morse: String): Char? = MORSE_TABLE[morse]
}
}
data class ToneResult(val symbol: Char?, val probability: Float)
@@ -1,114 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Multi-channel CW signal tracker.
* Monitors the spectrogram for active frequency bins and extracts
* energy envelopes for each detected signal.
*
* Inspired by CW Skimmer's multi-channel approach:
* tracks all active signals in the passband simultaneously,
* selects the best one for decoded output.
*/
internal class CwChannelTracker(
private val spectrogram: CwSpectrogram,
private val maxChannels: Int = 3
) {
data class Channel(
val bin: Int,
val frequency: Float,
var active: Boolean = false,
var energy: Float = 0f,
val history: MutableList<Float> = mutableListOf(),
var confidence: Float = 0f
)
private val channels = Array(maxChannels) { Channel(0, 0f) }
/** Scan the current spectrogram column and update channel tracking. */
fun update(): List<Channel> {
val col = spectrogram.getCurrentColumn()
val peaks = findPeaks(col, threshold = 0.3f, minDistance = 2)
// Update existing channels
for (ch in channels) {
if (ch.active) {
if (peaks.contains(ch.bin)) {
ch.energy = col[ch.bin]
ch.history.add(ch.energy)
if (ch.history.size > 40) ch.history.removeAt(0)
ch.confidence = computeConfidence(ch.history)
} else {
// Signal lost — decay confidence
ch.history.add(0f)
if (ch.history.size > 40) ch.history.removeAt(0)
ch.confidence *= 0.9f
if (ch.confidence < 0.1f) ch.active = false
}
}
}
// Assign new peaks to inactive channels
var peakIdx = 0
for (ch in channels) {
if (!ch.active && peakIdx < peaks.size) {
val bin = peaks[peakIdx]
val freq = spectrogram.binToFreq(bin)
// Re-initialize channel
channels[peakIdx] = Channel(bin, freq, true, col[bin], mutableListOf(), 0.5f)
peakIdx++
}
}
return channels.filter { it.active }
}
/** Find peak bins in the spectrum. */
private fun findPeaks(spectrum: FloatArray, threshold: Float, minDistance: Int): List<Int> {
val peaks = mutableListOf<Int>()
for (i in 1 until spectrum.size - 1) {
if (spectrum[i] > spectrum[i - 1] && spectrum[i] > spectrum[i + 1] && spectrum[i] > threshold) {
if (peaks.isEmpty() || i - peaks.last() >= minDistance) {
peaks.add(i)
}
}
}
return peaks.sortedByDescending { spectrum[it] }
}
/** Compute confidence from energy history. Lower variance = higher confidence. */
private fun computeConfidence(history: List<Float>): Float {
if (history.size < 10) return 0.3f
val recent = history.takeLast(10)
val mean = recent.average().toFloat()
val variance = recent.map { (it - mean) * (it - mean) }.average().toFloat()
return if (mean > 0f) (mean / (mean + variance + 0.1f)).coerceIn(0f, 1f) else 0f
}
/** Get the channel with highest confidence. */
fun getBestChannel(): Channel? {
return channels.filter { it.active }.maxByOrNull { it.confidence }
}
fun reset() {
for (i in channels.indices) {
channels[i] = Channel(0, 0f)
}
}
}
@@ -0,0 +1,61 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Turns the model's `log_probs` output into text.
*
* Mirrors the reference `greedy_ctc_decode`: take the best class per frame,
* drop blanks, and collapse runs of the same label. A blank between two
* identical labels is what keeps a genuine double letter (for example the
* two N's in "5NN") from collapsing into one.
*/
object CwCtcDecoder {
/**
* @param logProbs `[batch, time, class]`; only batch 0 is read.
* @param chars class index to symbol, excluding the blank.
* @param blankIndex the CTC blank class (41 for this model).
*/
fun greedy(
logProbs: Array<Array<FloatArray>>,
chars: List<String>,
blankIndex: Int
): String {
if (logProbs.isEmpty()) return ""
val frames = logProbs[0]
val builder = StringBuilder()
var previous = -1
for (frame in frames) {
var best = 0
for (i in 1 until frame.size) {
if (frame[i] > frame[best]) best = i
}
if (best == blankIndex) {
previous = -1
continue
}
if (best != previous && best < chars.size) {
builder.append(chars[best])
}
previous = best
}
return builder.toString()
}
}
@@ -1,165 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* CW (Morse code) decoder v3 — Spectrogram-based multi-channel Bayesian decoder.
*
* Architecture inspired by Morse Expert / CW Skimmer (VE3NEA):
* 1. FFT spectrogram creates a frequency×time matrix
* 2. Multi-channel peak detector finds all active signals
* 3. Per-channel energy envelope extraction
* 4. Bayesian probability for symbol timing (Gaussian likelihood)
* 5. Best channel selected for output
*
* Timing analysis is performed per spectrogram column (hop).
* Each column represents hopSize/sampleRate seconds of audio.
*/
class CwDecoder(
val sampleRate: Int = 8000,
cwToneFreq: Float = -1f // ignored in v3 (auto-detect via spectrogram)
) {
companion object {
private const val FFT_SIZE = 256
private const val HOP_SIZE = 64
}
private val spectrogram = CwSpectrogram(
fftSize = FFT_SIZE,
hopSize = HOP_SIZE,
sampleRate = sampleRate,
minBin = 6,
maxBin = 38,
historyCols = 40
)
private val channelTracker = CwChannelTracker(spectrogram, maxChannels = 3)
private val bayesianDecoder = CwBayesianDecoder()
// Timing state per channel
private data class ChannelTiming(
var isSignal: Boolean = false,
var toneTicks: Int = 0,
var gapTicks: Int = 0
)
private val timingStates = Array(3) { ChannelTiming() }
// Time per spectrogram column in milliseconds
private val tickMs = 1000f * HOP_SIZE / sampleRate
// Output flows
private val _decodedTextFlow = MutableStateFlow("")
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
private val _signalStrength = MutableStateFlow(0f)
val signalStrength: StateFlow<Float> = _signalStrength
private val _estimatedPitch = MutableStateFlow<Float?>(null)
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
private var frameCount = 0
init {
if (cwToneFreq > 0f) {
_estimatedPitch.value = cwToneFreq
}
}
fun processBuffer(buffer: FloatArray) {
// 1. Feed samples to spectrogram
spectrogram.addSamples(buffer)
// 2. Get number of new columns generated
val newCols = spectrogram.getNewColumns()
if (newCols == 0) return
// 3. Update channel tracker (uses latest column for peak detection)
val activeChannels = channelTracker.update()
// 4. Process each new column for timing analysis
// Columns are indexed 0..historyCols-1, where historyCols-1 is the newest
val baseIdx = (spectrogram.historyCols - newCols).coerceAtLeast(0)
for (colOffset in 0 until newCols) {
val col = spectrogram.getColumn(baseIdx + colOffset)
for ((idx, channel) in activeChannels.withIndex()) {
if (idx >= timingStates.size) break
val state = timingStates[idx]
val energy = if (channel.bin in col.indices) col[channel.bin] else 0f
// Adaptive threshold
val threshold = 0.3f + (energy - 0.3f) * 0.3f
if (energy > threshold) {
if (!state.isSignal) {
if (state.gapTicks > 0) {
val gapMs = state.gapTicks * tickMs
bayesianDecoder.processGap(gapMs)
}
state.gapTicks = 0
state.isSignal = true
}
state.toneTicks++
} else {
if (state.isSignal) {
if (state.toneTicks > 0) {
val toneMs = state.toneTicks * tickMs
bayesianDecoder.processTone(toneMs)
}
state.toneTicks = 0
state.isSignal = false
}
state.gapTicks++
}
}
}
// 5. Update outputs
frameCount++
if (frameCount % 5 == 0) {
val bestChannel = channelTracker.getBestChannel()
if (bestChannel != null) {
_estimatedPitch.value = bestChannel.frequency
_signalStrength.value = bestChannel.confidence
_estimatedSpeed.value = bayesianDecoder.getSpeed()
}
_decodedTextFlow.value = bayesianDecoder.decodedText
}
}
fun resetDecoder() {
spectrogram.reset()
channelTracker.reset()
bayesianDecoder.reset()
for (state in timingStates) {
state.isSignal = false
state.toneTicks = 0
state.gapTicks = 0
}
frameCount = 0
_decodedTextFlow.value = ""
_signalStrength.value = 0f
_estimatedPitch.value = null
_estimatedSpeed.value = null
}
}
@@ -0,0 +1,140 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Bounded rolling audio buffer that drives periodic full re-decodes.
*
* DeepCW is a whole-segment CTC model. It rewrites earlier output as more
* context arrives — measured on one clip, 6 of 11 progressively longer reads
* revised the prefix (`BM` -> `BG7` -> `BG7NTI` -> `BG7NTA`). Appending only
* the newest fragment therefore leaves those intermediate guesses on screen
* forever; measured character error rate for sliding-window stitching ranged
* from 67% to 294%, against 0% for decoding the whole segment at once.
*
* So we keep a fixed window, re-run the model over all of it every
* [redecodeIntervalMs], and replace the displayed text outright.
*
* Defaults come from measurement: 20 s is the smallest window that reaches
* 0.0% CER (16 s still errs at 5.9%), while inference cost grows
* super-linearly — 60 s of audio needs roughly 13x longer to decode than
* 20 s does, leaving too little real-time headroom.
*/
class CwDeepBuffer(
sampleRate: Int = CwDeepSpectrogram.SAMPLE_RATE,
maxSeconds: Double = DEFAULT_MAX_SECONDS,
private val redecodeIntervalMs: Int = DEFAULT_REDECODE_INTERVAL_MS
) {
companion object {
const val DEFAULT_MAX_SECONDS = 20.0
const val DEFAULT_REDECODE_INTERVAL_MS = 1500
}
/** Maximum number of samples retained. */
val capacity: Int = (sampleRate * maxSeconds).toInt()
private val samplesPerInterval: Int = sampleRate * redecodeIntervalMs / 1000
private val ring = FloatArray(capacity)
private var writeIndex = 0
private var filled = 0
private var sinceLastRedecode = 0
/**
* Samples evicted from the ring once it is full. They are the audio that
* has scrolled out of the 20 s window, and are handed off (via
* [drainOverflow]) so the decoder can archive them into permanent history
* instead of silently dropping the corresponding text. Pre-allocated to
* [capacity]: overflow never exceeds one window before it is drained.
*/
private val overflow = FloatArray(capacity)
private var overflowSize = 0
/** Samples currently buffered, never above [capacity]. */
val size: Int get() = filled
/** Samples currently held in the overflow (awaiting archival). */
val overflowCount: Int get() = overflowSize
/** True once there is enough audio for the spectrogram to yield a frame. */
val hasEnoughAudio: Boolean get() = filled >= CwDeepSpectrogram.FFT_LENGTH
/**
* Append captured audio, overwriting the oldest samples when full.
*
* @return true when [redecodeIntervalMs] of audio has accumulated since
* the last time this returned true, meaning the caller should re-decode.
*/
fun append(chunk: FloatArray): Boolean {
if (chunk.isNotEmpty()) {
// A chunk longer than the window can only contribute its tail.
val start = maxOf(0, chunk.size - capacity)
for (i in start until chunk.size) {
if (filled == capacity) {
// The slot we are about to overwrite holds the oldest
// sample — move it to the overflow for archival.
overflow[overflowSize++] = ring[writeIndex]
}
ring[writeIndex] = chunk[i]
writeIndex = (writeIndex + 1) % capacity
if (filled < capacity) filled++
}
}
sinceLastRedecode += chunk.size
if (sinceLastRedecode >= samplesPerInterval) {
sinceLastRedecode -= samplesPerInterval
return true
}
return false
}
/** Buffered audio in chronological order, as a copy safe to hand off. */
fun snapshot(): FloatArray {
val out = FloatArray(filled)
if (filled == 0) return out
val start = (writeIndex - filled + capacity) % capacity
val firstRun = minOf(filled, capacity - start)
ring.copyInto(out, 0, start, start + firstRun)
if (firstRun < filled) {
ring.copyInto(out, firstRun, 0, filled - firstRun)
}
return out
}
/**
* Return the evicted samples (chronological order) and clear the overflow.
* Safe to call every append; returns an empty array when nothing has been
* evicted yet.
*/
fun drainOverflow(): FloatArray {
if (overflowSize == 0) return FloatArray(0)
val out = overflow.copyOf(overflowSize)
overflowSize = 0
return out
}
/** Drop all audio (including pending overflow) and restart the interval. */
fun reset() {
writeIndex = 0
filled = 0
sinceLastRedecode = 0
overflowSize = 0
ring.fill(0f)
overflow.fill(0f)
}
}
@@ -0,0 +1,208 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.ln1p
import kotlin.math.roundToInt
import kotlin.math.sqrt
/**
* Audio front-end for the DeepCW model: turns PCM samples into the
* `[time, frequency]` log-magnitude spectrogram the network expects.
*
* Mirrors the upstream Python reference (deepcw-engine
* `examples/python/decode_morse.py`) step for step:
*
* resample -> 3200 Hz, reflect-pad by fft/2, periodic Hann window of 256,
* real FFT, keep bins [32, 97) i.e. 400-1200 Hz, then log1p.
*
* The model's fixed 400-1200 Hz window means pitch detection is built in —
* no spectral peak tracking or squelch gating is needed on our side.
*/
object CwDeepSpectrogram {
/** Model input sample rate, from `model.onnx.json`. */
const val SAMPLE_RATE = 3200
/** FFT window length in samples. */
const val FFT_LENGTH = 256
/** Hop between consecutive frames; 48/3200 = 15.0 ms per frame. */
const val HOP_LENGTH = 48
private const val MIN_FREQ_HZ = 400.0
private const val MAX_FREQ_HZ = 1200.0
/** Number of frequency bins the model expects. */
const val FREQUENCY_BINS = 65
/** Milliseconds of audio represented by one output frame. */
const val MS_PER_FRAME = 1000.0 * HOP_LENGTH / SAMPLE_RATE
private val hannWindow: FloatArray = FloatArray(FFT_LENGTH) { i ->
// numpy: np.hanning(N + 1)[:-1] — the periodic (not symmetric) variant.
(0.5 - 0.5 * cos(2.0 * PI * i / FFT_LENGTH)).toFloat()
}
/**
* Inclusive-exclusive bin range covering [minHz, maxHz].
* Returns `start to stop`, matching the reference's `frequency_bin_range`.
*/
fun frequencyBinRange(
sampleRate: Int,
fftLength: Int,
minHz: Double,
maxHz: Double
): Pair<Int, Int> {
val binHz = sampleRate.toDouble() / fftLength
val start = ceil(minHz / binHz).toInt()
val stop = floor(maxHz / binHz).toInt() + 1
return start to stop
}
/**
* Linear-interpolation resampler. Deliberately dependency-light and
* identical to the reference implementation so spectrograms match.
*/
fun resampleLinear(audio: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
if (sourceRate == targetRate || audio.isEmpty()) return audio
val targetLength = (audio.size.toDouble() * targetRate / sourceRate).roundToInt()
val out = FloatArray(targetLength)
val ratio = sourceRate.toDouble() / targetRate
for (i in 0 until targetLength) {
val position = i * ratio
val left = floor(position).toInt()
val right = minOf(left + 1, audio.size - 1)
val fraction = (position - left).toFloat()
out[i] = audio[left] * (1f - fraction) + audio[right] * fraction
}
return out
}
/**
* Build the log-magnitude spectrogram. Input must already be at
* [SAMPLE_RATE]; use [resampleLinear] first when it is not.
*
* @return `[frames][FREQUENCY_BINS]` values, all non-negative.
*/
fun compute(audio: FloatArray): Array<FloatArray> {
require(audio.size >= FFT_LENGTH) {
"audio is too short for fftLength=$FFT_LENGTH, got ${audio.size}"
}
val (startBin, stopBin) = frequencyBinRange(
SAMPLE_RATE, FFT_LENGTH, MIN_FREQ_HZ, MAX_FREQ_HZ
)
val bins = stopBin - startBin
require(bins == FREQUENCY_BINS) {
"expected $FREQUENCY_BINS bins, computed $bins"
}
val padded = reflectPad(audio, FFT_LENGTH / 2)
val frames = 1 + (padded.size - FFT_LENGTH) / HOP_LENGTH
val result = Array(frames) { FloatArray(bins) }
val real = FloatArray(FFT_LENGTH)
val imag = FloatArray(FFT_LENGTH)
for (frame in 0 until frames) {
val offset = frame * HOP_LENGTH
for (i in 0 until FFT_LENGTH) {
real[i] = padded[offset + i] * hannWindow[i]
imag[i] = 0f
}
fftInPlace(real, imag)
val row = result[frame]
for (bin in startBin until stopBin) {
val magnitude = sqrt(real[bin] * real[bin] + imag[bin] * imag[bin])
row[bin - startBin] = ln1p(magnitude.toDouble()).toFloat()
}
}
return result
}
/**
* numpy `mode="reflect"`: mirrors around the edge samples without
* repeating them, so [1,2,3] padded by 2 becomes [3,2,1,2,3,2,1].
*/
private fun reflectPad(audio: FloatArray, pad: Int): FloatArray {
if (pad == 0) return audio
val out = FloatArray(audio.size + 2 * pad)
for (i in 0 until pad) out[i] = audio[pad - i]
audio.copyInto(out, pad)
val last = audio.size - 1
for (i in 0 until pad) out[pad + audio.size + i] = audio[last - 1 - i]
return out
}
/**
* Iterative radix-2 Cooley-Tukey FFT. [FFT_LENGTH] is a power of two, so
* no padding case is needed. Only the first half of the output is read by
* [compute], which is the real-input equivalent of numpy's `rfft`.
*/
private fun fftInPlace(real: FloatArray, imag: FloatArray) {
val n = real.size
// Bit-reversal permutation.
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) {
j = j xor bit
bit = bit shr 1
}
j = j or bit
if (i < j) {
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
tmp = imag[i]; imag[i] = imag[j]; imag[j] = tmp
}
}
var length = 2
while (length <= n) {
val angle = -2.0 * PI / length
val wReal = cos(angle).toFloat()
val wImag = kotlin.math.sin(angle).toFloat()
var i = 0
while (i < n) {
var curReal = 1f
var curImag = 0f
for (k in 0 until length / 2) {
val evenReal = real[i + k]
val evenImag = imag[i + k]
val oddReal = real[i + k + length / 2]
val oddImag = imag[i + k + length / 2]
val mulReal = oddReal * curReal - oddImag * curImag
val mulImag = oddReal * curImag + oddImag * curReal
real[i + k] = evenReal + mulReal
imag[i + k] = evenImag + mulImag
real[i + k + length / 2] = evenReal - mulReal
imag[i + k + length / 2] = evenImag - mulImag
val nextReal = curReal * wReal - curImag * wImag
curImag = curReal * wImag + curImag * wReal
curReal = nextReal
}
i += length
}
length = length shl 1
}
}
}
@@ -1,102 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* DSP utilities for CW (Morse code) decoding.
* Pure Kotlin, no NDK required.
*/
internal object CwDsp {
/**
* Design a simple bandpass FIR filter coefficients using windowed sinc method.
* @param lowCutoff lower cutoff frequency (Hz) as fraction of sampleRate
* @param highCutoff upper cutoff frequency (Hz) as fraction of sampleRate
* @param taps filter length (must be odd)
*/
fun bandpassFir(lowCutoff: Double, highCutoff: Double, taps: Int): FloatArray {
val n = if (taps % 2 == 0) taps + 1 else taps
val half = n / 2
val coeffs = FloatArray(n)
for (i in 0 until n) {
val idx = i - half
if (idx == 0) {
coeffs[i] = (2.0 * (highCutoff - lowCutoff)).toFloat()
} else {
val x = PI * idx
coeffs[i] = ((sin(2 * highCutoff * x) - sin(2 * lowCutoff * x)) / x).toFloat()
}
// Hamming window
coeffs[i] = (coeffs[i] * (0.54 - 0.46 * cos(2 * PI * i / (n - 1)))).toFloat()
}
// Normalize
val sum = coeffs.sum()
if (sum != 0f) for (i in 0 until n) coeffs[i] /= sum
return coeffs
}
/** Apply FIR filter to a buffer. */
fun applyFir(buffer: FloatArray, coeffs: FloatArray): FloatArray {
val out = FloatArray(buffer.size)
for (i in buffer.indices) {
var sum = 0f
for (j in coeffs.indices) {
val idx = i - j
if (idx >= 0) sum += buffer[idx] * coeffs[j]
}
out[i] = sum
}
return out
}
/** Simple envelope detector: abs + low-pass smoothing. */
fun envelope(signal: FloatArray, alpha: Float = 0.1f): FloatArray {
val env = FloatArray(signal.size)
var s = 0f
for (i in signal.indices) {
s = alpha * kotlin.math.abs(signal[i]) + (1 - alpha) * s
env[i] = s
}
return env
}
/** Estimate noise floor from envelope for adaptive thresholding. */
fun noiseFloor(env: FloatArray, fraction: Float = 0.3f): Float {
val sorted = env.sortedArray()
val median = sorted[sorted.size / 2]
return median + (sorted[sorted.size * 9 / 10] - median) * fraction
}
/** Simple Goertzel to detect a specific tone frequency. */
fun goertzel(buffer: FloatArray, targetFreq: Float, sampleRate: Int): Float {
val omega = 2.0 * PI * targetFreq / sampleRate
val coeff = 2.0 * cos(omega)
var s0 = 0.0; var s1 = 0.0; var s2 = 0.0
for (sample in buffer) {
s0 = sample.toDouble() + coeff * s1 - s2
s2 = s1; s1 = s0
}
val power = s2 * s2 + s1 * s1 - coeff * s1 * s2
return sqrt(kotlin.math.abs(power)).toFloat()
}
}
@@ -1,87 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.cos
import kotlin.math.sqrt
/**
* Radix-2 FFT for real-valued input.
* Produces magnitude spectrum for the first N/2+1 bins.
* Used by CwSpectrogram for time-frequency analysis.
*/
internal class CwFFT(private val n: Int) {
init {
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
}
private val cosTable = FloatArray(n / 2)
private val sinTable = FloatArray(n / 2)
init {
for (i in 0 until n / 2) {
val angle = -2.0 * kotlin.math.PI * i / n
cosTable[i] = cos(angle).toFloat()
sinTable[i] = kotlin.math.sin(angle).toFloat()
}
}
/** Compute magnitude spectrum for real input. Returns array of size n/2+1. */
fun magnitudeSpectrum(input: FloatArray): FloatArray {
require(input.size == n) { "Input size must be $n, got ${input.size}" }
val real = input.copyOf()
val imag = FloatArray(n)
// Bit-reversal permutation
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
j = j xor bit
if (i < j) {
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
}
}
// Radix-2 Cooley-Tukey FFT
var len = 2
while (len <= n) {
val half = len / 2
val step = n / len
for (i in 0 until n step len) {
for (k in 0 until half) {
val tReal = real[i + k + half] * cosTable[k * step] - imag[i + k + half] * sinTable[k * step]
val tImag = real[i + k + half] * sinTable[k * step] + imag[i + k + half] * cosTable[k * step]
real[i + k + half] = real[i + k] - tReal
imag[i + k + half] = imag[i + k] - tImag
real[i + k] += tReal
imag[i + k] += tImag
}
}
len = len shl 1
}
// Magnitude spectrum (first N/2+1 bins)
val mag = FloatArray(n / 2 + 1)
for (i in 0..n / 2) {
mag[i] = sqrt(real[i] * real[i] + imag[i] * imag[i]) / n
}
return mag
}
}
@@ -1,54 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.cos
import kotlin.math.sqrt
/**
* Running Goertzel filter for CW tone detection.
* Tracks a specific frequency over time with a sliding window.
* Ported from ggmorse/src/goertzel.h
*/
internal class CwGoertzel {
private var s1 = 0.0
private var s2 = 0.0
private var coeff = 0.0
fun init(sampleRate: Float, targetFreq: Float) {
val omega = 2.0 * kotlin.math.PI * targetFreq / sampleRate
coeff = 2.0 * cos(omega)
s1 = 0.0
s2 = 0.0
}
fun process(sample: Float) {
val s0 = sample.toDouble() + coeff * s1 - s2
s2 = s1
s1 = s0
}
fun getPower(): Float {
return sqrt(s2 * s2 + s1 * s1 - coeff * s1 * s2).toFloat()
}
fun reset() {
s1 = 0.0
s2 = 0.0
}
}
@@ -1,53 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Simple linear resampler.
* Downsamples from input sample rate to output sample rate.
* Ported from ggmorse/src/resampler.h
*/
internal class CwResampler(private val inputRate: Float, private val outputRate: Float) {
private val ratio = inputRate / outputRate
private var lastSample = 0f
fun process(input: FloatArray): FloatArray {
if (ratio <= 0f || input.isEmpty()) return input
val outputLen = (input.size / ratio).toInt() + 1
val output = FloatArray(outputLen)
var idx = 0f
for (i in output.indices) {
val intIdx = idx.toInt()
val frac = idx - intIdx
if (intIdx + 1 < input.size) {
output[i] = input[intIdx] * (1 - frac) + input[intIdx + 1] * frac
} else if (intIdx < input.size) {
output[i] = input[intIdx] * (1 - frac) + lastSample * frac
} else {
output[i] = lastSample
}
idx += ratio
}
lastSample = input.lastOrNull() ?: lastSample
return output
}
fun reset() {
lastSample = 0f
}
}
@@ -1,61 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Lightweight pitch detector using DFT at specific frequency bins.
* Only scans [200, 1200] Hz in configurable steps — much faster than full FFT.
* Ported from ggmorse/src/stfft.h (simplified for CW use case).
*/
internal class CwPitchDetector(
private val sampleRate: Float,
private val minFreq: Float = 200f,
private val maxFreq: Float = 1200f,
private val stepHz: Float = 10f
) {
/**
* Find the dominant pitch frequency in the buffer.
* Returns null if no significant pitch found.
*/
fun findPitch(buffer: FloatArray): Float? {
if (buffer.isEmpty()) return null
var bestFreq = 0f
var bestPower = 0f
var freq = minFreq
while (freq <= maxFreq) {
var real = 0.0
var imag = 0.0
val omega = 2.0 * kotlin.math.PI * freq / sampleRate
for (i in buffer.indices) {
real += buffer[i] * cos(omega * i)
imag += buffer[i] * -sin(omega * i)
}
val power = (real * real + imag * imag).toFloat()
if (power > bestPower) {
bestPower = power
bestFreq = freq
}
freq += stepHz
}
return if (bestPower > 0.001f) bestFreq else null
}
}
@@ -1,170 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Sliding-window spectrogram for CW decoding.
* Maintains a time-frequency matrix updated with each audio frame.
*
* FFT size: 256, hop size: 64, sample rate: 4000 (or native)
* Frequency bins: 6..38 (187-1187 Hz, covers typical CW range)
* History: 40 columns (320 ms window)
* Time resolution: 64/4000 = 16 ms, Frequency resolution: 4000/256 = 15.625 Hz
*/
internal class CwSpectrogram(
private val fftSize: Int = 256,
private val hopSize: Int = 64,
private val sampleRate: Int = 4000,
private val minBin: Int = 6,
private val maxBin: Int = 38,
val historyCols: Int = 40
) {
private val fft = CwFFT(fftSize)
val numBins: Int get() = maxBin - minBin + 1
// Hanning window
private val hanning = FloatArray(fftSize) {
(0.5 - 0.5 * kotlin.math.cos(2.0 * kotlin.math.PI * it / (fftSize - 1))).toFloat()
}
// Spectrogram data: [timeCol][freqBin]
private val spectrogram = Array(historyCols) { FloatArray(numBins) }
private var currentCol = 0
private var samplesBuffered = 0
private val buffer = FloatArray(fftSize)
// Per-bin running energy for normalization
private val binEnergy = FloatArray(numBins) { 1f }
private val alpha = 0.95f
// Counter for new columns generated since last check
private var newColumnCount = 0
/** Add audio samples, compute FFTs for each complete hop. */
fun addSamples(samples: FloatArray) {
var offset = 0
while (offset < samples.size) {
val needed = fftSize - samplesBuffered
val copyLen = minOf(needed, samples.size - offset)
System.arraycopy(samples, offset, buffer, samplesBuffered, copyLen)
samplesBuffered += copyLen
offset += copyLen
if (samplesBuffered >= fftSize) {
processFrame()
newColumnCount++
// Shift buffer: keep last (fftSize - hopSize) samples
System.arraycopy(buffer, hopSize, buffer, 0, fftSize - hopSize)
samplesBuffered = fftSize - hopSize
}
}
}
/** Get number of new columns generated since the last call to this method. */
fun getNewColumns(): Int {
val count = newColumnCount
newColumnCount = 0
return count
}
private fun processFrame() {
// Apply Hanning window
val windowed = FloatArray(fftSize) { buffer[it] * hanning[it] }
// Compute FFT magnitude spectrum
val mag = fft.magnitudeSpectrum(windowed)
// Update spectrogram column
val col = spectrogram[currentCol]
for (b in 0 until numBins) {
val binIdx = minBin + b
val rawMag = mag[binIdx]
// Running energy normalization
binEnergy[b] = alpha * binEnergy[b] + (1 - alpha) * rawMag
col[b] = if (binEnergy[b] > 1e-6f) rawMag / binEnergy[b] else 0f
}
currentCol = (currentCol + 1) % historyCols
}
/** Get the current spectrogram as a 2D array in chronological order. */
fun getSpectrogram(): Array<FloatArray> {
val result = Array(historyCols) { i ->
val srcIdx = (currentCol + i) % historyCols
spectrogram[srcIdx].copyOf()
}
return result
}
/** Get the most recent column (current energy across all frequencies). */
fun getCurrentColumn(): FloatArray {
val prevCol = (currentCol - 1 + historyCols) % historyCols
return spectrogram[prevCol].copyOf()
}
/** Get a column by index from the history (0 = oldest, historyCols-1 = newest). */
fun getColumn(index: Int): FloatArray {
val clamped = index.coerceIn(0, historyCols - 1)
val srcIdx = (currentCol - historyCols + clamped + historyCols) % historyCols
return spectrogram[srcIdx].copyOf()
}
/** Find the frequency bin with peak energy. Returns -1 if no significant signal. */
fun findPeakBin(): Int {
val col = getCurrentColumn()
var maxBin = -1
var maxVal = 0f
for (i in col.indices) {
if (col[i] > maxVal) {
maxVal = col[i]
maxBin = i
}
}
return if (maxVal > 0.3f) maxBin else -1
}
/** Get energy at a specific bin over the last N columns in chronological order. */
fun getBinEnergy(bin: Int, numCols: Int): FloatArray {
val clamped = minOf(numCols, historyCols)
val result = FloatArray(clamped)
for (i in 0 until clamped) {
val colIdx = (currentCol - clamped + i + historyCols) % historyCols
result[i] = spectrogram[colIdx][bin]
}
return result
}
/** Get the bin index for a frequency in Hz. */
fun freqToBin(freqHz: Float): Int {
val bin = (freqHz * fftSize / sampleRate).toInt()
return (bin - minBin).coerceIn(0, numBins - 1)
}
/** Get the center frequency for a bin. */
fun binToFreq(bin: Int): Float {
return (minBin + bin).toFloat() * sampleRate / fftSize
}
fun reset() {
for (col in spectrogram) col.fill(0f)
currentCol = 0
samplesBuffered = 0
buffer.fill(0f)
binEnergy.fill(1f)
}
}
@@ -0,0 +1,66 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlinx.coroutines.flow.StateFlow
/**
* A CW (Morse code) decoder fed with microphone PCM.
*
* Implementations live outside `core:domain` when they need platform APIs;
* this contract stays pure Kotlin so the UI can depend on it directly.
*/
interface ICwDecoder {
/**
* Decoded text for the *current* window.
*
* Note this is **replace** semantics, not append: a whole-segment model
* revises earlier characters as more audio arrives, so consumers must show
* the current value rather than accumulating emissions.
*/
val decodedText: StateFlow<String>
/**
* Permanent transcript of everything that has scrolled out of the live
* window. Unlike [decodedText] this only ever grows (until [reset]); it is
* what the user reads back after a signal has passed.
*/
val historyText: StateFlow<String>
/** Detected tone frequency in Hz, or null before a tone is found. */
val estimatedPitch: StateFlow<Float?>
/** Relative signal strength in 0..1 for level meters. */
val signalStrength: StateFlow<Float>
/** Most recent inference duration in milliseconds, for diagnostics. */
val lastInferenceMs: StateFlow<Int>
/** Non-null when the decoder cannot run, for example the model failed to load. */
val errorMessage: StateFlow<String?>
/** Feed captured mono PCM in -1..1. Safe to call from a capture thread. */
suspend fun processBuffer(samples: FloatArray, sampleRate: Int)
/** Clear decoded text and buffered audio. */
fun reset()
/** Release native resources. Must be called when the decoder goes away. */
fun close()
}
@@ -0,0 +1,37 @@
package com.rtbishop.look4sat.core.domain.model
/** One satellite status report (AMSAT site tooltip data) */
data class SatReport(
val id: String, // 报告 ID(a885153)
val statusText: String, // Heard / Telemetry Only / Not Heard ...
val call: String, // 呼号
val grid: String, // 网格坐标(可为空)
val dateUtc: String, // 2026-08-04
val timeUtc: String // 2:46-:59 UTC
)
/** State of one 2-hour slot */
data class SatSlot(
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
val count: Int, // 报告数量(0 = 无)
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
)
/** One satellite day (12 two-hour slots) */
data class SatDay(
val dateLabel: String, // "Aug 4"
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
)
/** One satellite, 6 days of state */
data class SatStatus(
val name: String, // "AO-123_[FM]"
val days: List<SatDay> // 6 天(新→旧)
)
/** Overall page parse result */
data class SatStatusPage(
val fetchedAtUtcMs: Long,
val statuses: List<SatStatus>,
val reports: Map<String, SatReport> // id → 报告
)
@@ -63,13 +63,13 @@ data class OtherSettings(
val sstvMode: String = "Auto",
val lowElevation: Double = 15.0,
val highElevation: Double = 45.0,
// UI 设置: 底部导航栏隐藏的页面(Screen simpleName 列表, 默认空 = 全部显示)
// UI settings: pages hidden from the bottom nav (Screen simpleName list; empty default = all shown)
val hiddenScreens: List<String> = emptyList(),
// UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/地图/设置)
// UI settings: page order (empty = default: Satellites/Passes/Radar/Map/Settings)
val screenOrder: List<String> = emptyList(),
// UI 设置: 更多菜单顺序(空 = 默认: 匹配/漫游/CW解码)
// UI settings: More-menu order (empty = default: Mutual/Roaming/CW decode)
val subMenuOrder: List<String> = emptyList(),
// WaveLog 日志系统(4.5.2): 服务器配置
// WaveLog logging (4.5.2): server config
val wavelogUrl: String = "",
val wavelogApiKey: String = "",
val wavelogStationId: String = "",
@@ -0,0 +1,71 @@
/* QrzGridClient.kt - QRZ callsign grid scraper (4.5.5, pure JVM in domain).
* How it works (verified): GET https://www.qrz.com/db/{callsign} with the user's QRZ login Cookie,
* the Detail table on the page holds <td class="dh">Grid Square</td><td class="di">XXX</td>.
* Without cookies the Detail is unavailable (confirmed); if the other station has no grid, the row is absent (null).
* The Cookie is pasted by the user in settings (EditThisCookie JSON export or a raw cookie string),
* never built into the app.
*/
package com.rtbishop.look4sat.core.domain.qrz
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL
object QrzGridClient {
/** Parse the pasted Cookie (both formats): EditThisCookie JSON array or raw "k=v; k=v" string */
fun parseCookies(raw: String): String {
val text = raw.trim()
if (text.isEmpty()) return ""
// JSON array format: [{"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
}
/** Detect the callsign logged in with these cookies (fetch db home, extract the account menu callsign). Null on failure */
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() }
// Logged-in account menu (verified): <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 }
}
/** Look up a callsign's grid. Null = not set / not found (silent, does not block logging) */
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 table Grid Square row (verified format)
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 }
}
}
@@ -0,0 +1,9 @@
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT satellite status data source */
interface IAmSatRepository {
/** Fetch and parse the AMSAT status page; null on failure */
suspend fun fetchStatus(): SatStatusPage?
}
@@ -30,6 +30,7 @@ interface IMainContainer {
val selectionRepo: ISelectionRepo
val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo
val amSatRepo: IAmSatRepository
val radioTrackingService: IRadioTrackingService
val mutualPassData: StateFlow<MutualPassData>
fun setMutualPassData(data: MutualPassData)
@@ -41,10 +42,13 @@ interface IMainContainer {
fun provideTxRadioController(): IRadioController
fun provideRxRadioController(): IRadioController
fun provideAudioCapture(): IAudioCapture
fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder
fun provideSaveImage(): ISaveImage
// WaveLog 日志(4.5.2)
// WaveLog logging (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(
@@ -45,7 +45,7 @@ interface ISettingsRepo {
//region # Station position settings
val stationPosition: StateFlow<GeoPos>
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
/** GPS 定位(挂起): 拿到位置才返回 true; 权限缺失/超时/无信号返回 false */
/** GPS fix (suspend): true only with a fix; false on missing permission/timeout/no signal */
suspend fun setStationPosition(): Boolean
fun setStationPosition(locator: String): Boolean
//endregion
@@ -22,4 +22,13 @@ import java.io.InputStream
interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream?
/** Fetch AMSAT status page HTML (with UA; null = failure) */
suspend fun getStatusHtml(): String?
/** Fetch AMSAT API catalog (JSON string; null on failure) */
suspend fun getAmSatCatalog(): String?
/** Fetch AMSAT API reports for the past N hours (JSON string; null on failure) */
suspend fun getAmSatReports(hours: Int, limit: Int): String?
}
@@ -18,7 +18,7 @@
package com.rtbishop.look4sat.core.domain.source
object Sources {
// 在线更新的默认 URL(用户可通过"自定义URL"对话框修改/重置)
// Default URL for online updates (user can change/reset it via the "Custom URL" dialog)
val defaultTleUrl = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv"
val defaultTransceiversUrl = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
@@ -20,6 +20,6 @@ package com.rtbishop.look4sat.core.domain.usecase
interface IShowToast {
operator fun invoke(message: String)
/** 按资源 ID 显示(四语文案) */
/** Show by resource ID (four-language text) */
operator fun invoke(resId: Int)
}
@@ -118,7 +118,7 @@ fun qthToSquare(locator: String): String {
* Builds the 3x3 grid of 4-char squares surrounding [square] (e.g. "OL42").
* Row 0 = north (lat +1), col 0 = west (lon -1). Handles field/square carry
* at boundaries (e.g. "AA00" wraps to "RR99" at the south-west corner).
* Mirrors the neighbor logic decompiled from the QTH定位器 app.
* Mirrors the neighbor logic decompiled from the QTH Locator app.
*/
fun qthNeighbors(square: String): List<String> {
if (square.length != 4) return emptyList()
@@ -0,0 +1,15 @@
/* ILotwSatellitesRepo.kt - LoTW satellite list refresh interface (domain layer, 4.5.5).
* Impl: LotwSatellitesRepo in core/data (downloads 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-supported satellite name list (112, statically embedded).
* Source: https://lotw.arrl.org/lotw/config.tq6 (gzip XML, official ARRL!)
* WaveLog updates its satellite table's lotw field from the same source (Update_model.php lotw_sats()).
* At runtime LotwSatellitesRepo can refresh it manually (settings WaveLog section button).
*/
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()
/** Effective satellite names: static embedded set union runtime updates */
val names: Set<String>
get() = if (dynamicNames.isEmpty()) staticNames else staticNames + dynamicNames
/** Runtime update (settings button -> called after LotwSatellitesRepo.refresh()) */
fun updateNames(newNames: Set<String>) {
if (newNames.isNotEmpty()) dynamicNames = newNames
}
}
@@ -1,12 +1,12 @@
/*
* WaveLogApi.kt — WaveLog 日志服务器 API 客户端(4.5.2 覆盖修复 2)。
* WaveLogApi.kt - WaveLog log server API client (4.5.2 override fix 2).
*
* 同时支持 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。
* Supports both v1 and v2 (user's server only has v1 in practice; v2 returns 404):
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON fields)
* v1: POST {base}/index.php/api/qso (key in JSON body + ADIF string)
* Strategy: try v2 first, auto-fallback to v1 on 404.
* Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif.
* Station grid: only v2 has GET api/v2/station/{id}; v1 lacks it -> fall back to user QTH.
*/
package com.rtbishop.look4sat.core.domain.wavelog
@@ -20,7 +20,7 @@ import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
/** 站点信息(GET /api/v2/station/{id} 结果) */
/** Station info (GET /api/v2/station/{id} result) */
data class WavelogStation(
val id: Int,
val name: String,
@@ -37,7 +37,7 @@ object WaveLogApi {
private const val TIMEOUT_MS = 15000
/** 规范化服务器地址: 去尾斜杠/去尾部 index.php; 无协议前缀时补 https:// */
/** Normalize server URL: strip trailing slash/index.php; prepend https:// when missing */
fun normalizeUrl(raw: String): String {
var u = raw.trim().trimEnd('/')
if (u.isBlank()) return ""
@@ -46,7 +46,7 @@ object WaveLogApi {
return u
}
/** 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif */
/** Test connection: v2 GET api/v2/token; on 404 use 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("服务器地址为空")
@@ -55,7 +55,7 @@ object WaveLogApi {
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)
// v1: POST /index.php/api/get_contacts_adif (key in body)
if (stationId.isNotBlank()) {
val body = JSONObject().apply {
put("key", apiKey)
@@ -66,7 +66,7 @@ object WaveLogApi {
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 尝试
// v1 attempt without index.php
val body = JSONObject().apply {
put("key", apiKey)
put("station_id", stationId)
@@ -79,7 +79,7 @@ object WaveLogApi {
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确")
}
/** 站点信息: 仅 v2; v1 无此端点返回 null 标记(网格检测降级用用户 QTH) */
/** Station info: v2 only; v1 lacks the endpoint (grid check falls back to user QTH) */
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
@@ -102,24 +102,33 @@ object WaveLogApi {
WavelogResult.Failure("解析失败: ${e.message}")
}
}
// v1 无 station 端点 → 返回 Success 空(调用方降级用用户 QTH)
// v1 has no station endpoint -> return empty Success (caller falls back to user QTH)
WavelogResult.Success("")
}
/** LoTW 认可的卫星名: 取括号前主名 + 大写(ISS 特判) */
/** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */
fun normalizeSatName(raw: String): String {
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
}
// Matching logic (mirrors WaveLog satellite table name/displayname matching + LoTW list):
// 1. Already in LoTW list (common TLE name == common name) -> return as-is
// 2. Not present -> check Celestrak alias map (SAUDISAT 1C -> SO-50 etc.); mapped name must be in LoTW list
// 3. Still unmatched -> return as-is (uploads are not blocked; QSO is still saved)
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) */
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
suspend fun postQso(
url: String,
apiKey: String,
@@ -132,7 +141,7 @@ object WaveLogApi {
val satName = normalizeSatName(qso.satName)
// v2: POST /index.php/api/v2/qso(JSON 字段)
// v2: POST /index.php/api/v2/qso (JSON fields)
val v2Body = JSONObject().apply {
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
put("call", qso.call)
@@ -140,16 +149,18 @@ object WaveLogApi {
put("mode", qso.mode)
put("qso_date", utcDate(qso.timeUtcMs))
put("time_on", utcTime(qso.timeUtcMs))
put("freq", qso.freqTxHz)
put("freq_rx", qso.freqRxHz)
put("freq", String.format(Locale.ENGLISH, "%.6fM", qso.freqTxHz / 1_000_000.0))
put("freq_rx", String.format(Locale.ENGLISH, "%.6fM", qso.freqRxHz / 1_000_000.0))
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())
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
// v1: POST /index.php/api/qso(key 在 body + ADIF)
// v1: POST /index.php/api/qso (key in body + ADIF)
val v1Body = JSONObject().apply {
put("key", apiKey)
put("station_profile_id", stationProfileId)
@@ -159,14 +170,14 @@ object WaveLogApi {
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
// v1 without index.php
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 })}")
}
/** v1 ADIF 字符串(频率 MHz, 长度=UTF-8 字节数, sat_name 已规范化) */
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
private fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
fun field(name: String, value: String): String {
val bytes = value.toByteArray(Charsets.UTF_8).size
@@ -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))
@@ -191,7 +204,7 @@ object WaveLogApi {
}
}
/** 通用 HTTP 请求(返回 code + body) */
/** Generic HTTP request (returns 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
@@ -217,7 +230,7 @@ object WaveLogApi {
}
private fun shortError(body: String): String {
if (body.startsWith("<")) return body.take(80) // HTML 错误页
if (body.startsWith("<")) return body.take(80) // HTML error page
return try {
val obj = JSONObject(body)
val err = obj.optJSONObject("error")
@@ -1,22 +1,22 @@
/*
* WavelogQueue.kt — WaveLog 本地日志队列(4.5.2)。
* WavelogQueue.kt - WaveLog local log queue (4.5.2).
*
* 纯 Kotlin(不依赖 Android): 存储走 IWavelogQueueStore 接口,
* 由 core/data 用 SharedPreferences 实现。
* 队列上限 500 条(超出丢最旧)。
* Pure Kotlin (no Android deps): storage goes through the IWavelogQueueStore interface,
* implemented with SharedPreferences in core/data.
* Queue capped at 500 entries (oldest dropped beyond that).
*/
package com.rtbishop.look4sat.core.domain.wavelog
import org.json.JSONArray
import org.json.JSONObject
/** 存储抽象(SharedPreferences 实现见 core/data) */
/** Storage abstraction (SharedPreferences impl lives in core/data) */
interface IWavelogQueueStore {
fun load(): String
fun save(json: String)
}
/** 待上传的 QSO 条目(本地队列元素,与 POST /api/v2/qso 字段对应) */
/** QSO entry awaiting upload (local queue element, mirrors POST /api/v2/qso fields) */
data class WavelogQso(
val id: String, // 本地唯一 id(UUID)
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
@@ -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 修复: 成功后保留标记, 表格打勾)
)
@@ -48,6 +49,7 @@ class WavelogQueue(private val store: IWavelogQueueStore) {
freqRxHz = o.optLong("freqRxHz"),
satName = o.optString("satName"),
sessionId = o.optString("sessionId"),
gridsquare = o.optString("gridsquare"),
uploaded = o.optBoolean("uploaded", false)
)
}
@@ -72,13 +74,18 @@ class WavelogQueue(private val store: IWavelogQueueStore) {
save(all().filter { it.id !in ids })
}
/** 标记为已上传(保留在队列, 表格打勾) */
/** Mark as uploaded (kept in the queue; checkmark in the table) */
@Synchronized
fun markUploaded(id: String) {
save(all().map { if (it.id == id) it.copy(uploaded = true) else it })
}
/** 移除所有已上传条目(可选项, 保持队列精简) */
/** Update a QSO's counterpart grid (async backfill from the QRZ scraper, 4.5.5) */
fun updateGridsquare(id: String, grid: String) {
save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it })
}
/** Remove all uploaded entries (optional; keeps the queue lean) */
@Synchronized
fun removeUploaded() {
save(all().filter { !it.uploaded })
@@ -92,6 +99,7 @@ class WavelogQueue(private val store: IWavelogQueueStore) {
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
put("sessionId", q.sessionId)
put("gridsquare", q.gridsquare)
put("uploaded", q.uploaded)
})
}
@@ -1,10 +1,10 @@
/*
* WavelogUploader.kt — WaveLog 队列上传调度(4.5.2)。
* WavelogUploader.kt - WaveLog queue upload scheduler (4.5.2).
*
* 手动/周期上传共用: 逐条网格检测(用户 QTH 前 4 位 vs 台站网格前 4 位)
* → POST /api/v2/qso → 成功移出队列。
* 网格不一致: 返回 NeedConfirm(由 UI 弹窗「忽略并上传/取消」),
* 确认后带 force=true 重试本批。
* Manual/periodic uploads share: per-batch grid check (user QTH first 4 chars vs station grid first 4 chars)
* -> POST /api/v2/qso -> success removes from the queue.
* Grid mismatch: returns NeedConfirm (UI dialog "Ignore and upload / Cancel"),
* retrying the batch with force=true once confirmed.
*/
package com.rtbishop.look4sat.core.domain.wavelog
@@ -26,10 +26,10 @@ class WavelogUploader(
private val queue: WavelogQueue
) {
// 台站网格缓存(每次上传前刷新; 失败用旧值)
// Station grid cache (refreshed before each upload; stale value kept on failure)
private var cachedStationGrid: String? = null
/** 上传整个队列。force=true 跳过网格确认(用户已选「忽略并上传」) */
/** Upload the whole queue. force=true skips the grid confirm (user chose "Ignore and upload") */
suspend fun uploadQueue(force: Boolean = false): UploadOutcome {
val settings = settingsRepo.otherSettings.value
val url = settings.wavelogUrl
@@ -39,13 +39,14 @@ class WavelogUploader(
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
}
// 1. 拉站点信息(拿台站网格); v1 无此端点时降级用用户 QTH
// 1. Fetch station info (station grid); fall back to user QTH when v1 lacks the endpoint
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
if (stationGrid.isNullOrBlank()) {
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
}
// 2. 网格检测: 用户当前 QTH 前 4 位 vs 台站网格前 4 位(v1 降级时网格相同, 跳过)
// 2. Grid check: cloud station grid first 4 chars vs current station QTH first 4 chars
// (guards against a misconfigured station; unrelated to the QSO counterpart grid - per user)
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. Upload one by one. ADIF gridsquare = counterpart grid (the QSO partner); blank until the scraper lands
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)
@@ -84,7 +85,7 @@ class WavelogUploader(
return cachedStationGrid
}
/** 用户当前 QTH 网格(前 4 位; 无 QTH 返回 null = 跳过检测) */
/** User's current QTH grid (first 4 chars; null when no QTH = check skipped) */
private fun userQthGrid(): String? {
return settingsRepo.stationPosition.value.qthLocator?.takeIf { it.length >= 4 }
}
@@ -87,7 +87,7 @@ class QthConverterTest {
@Test
fun `Given square returns correct 3x3 neighbors`() {
// Reference grid from the QTH定位器 screenshot: OL42
// Reference grid from the QTH Locator screenshot: OL42
val neighbors = qthNeighbors("OL42")
assert(neighbors == listOf(
"OL33", "OL43", "OL53",
@@ -0,0 +1,90 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Greedy CTC collapse, matching the reference implementation's
* `greedy_ctc_decode`: drop blanks, then drop runs of the same label.
*
* Alphabet from `model.onnx.json` — 41 symbols plus blank at index 41.
*/
class CwCtcDecoderTest {
private val chars = listOf(
",", ".", "/", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "?",
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", " "
)
private val blank = 41
/** Build a `[1, T, 42]` log-prob tensor whose argmax follows [path]. */
private fun logits(path: IntArray): Array<Array<FloatArray>> {
val frames = Array(path.size) { t ->
FloatArray(42) { -10f }.also { it[path[t]] = 0f }
}
return arrayOf(frames)
}
@Test
fun alphabetSizeMatchesModelMetadata() {
assertEquals("41 symbols + blank = 42 classes", 41, chars.size)
}
@Test
fun greedy_dropsRunsOfTheSameLabel() {
assertEquals("A", CwCtcDecoder.greedy(logits(intArrayOf(14, 14, 14)), chars, blank))
}
@Test
fun greedy_keepsRepeatsSeparatedByBlank() {
// A A <blank> A collapses to "AA": the blank breaks the run.
assertEquals("AA", CwCtcDecoder.greedy(logits(intArrayOf(14, 14, blank, 14)), chars, blank))
}
@Test
fun greedy_allBlanksYieldEmptyString() {
assertEquals("", CwCtcDecoder.greedy(logits(intArrayOf(blank, blank, blank)), chars, blank))
}
@Test
fun greedy_emptyInputYieldsEmptyString() {
assertEquals("", CwCtcDecoder.greedy(logits(intArrayOf()), chars, blank))
}
@Test
fun greedy_decodesCallsignWithSpaceAndDigits() {
// "CQ BG7" — C=16 Q=30 space=40 B=15 G=20 7=10
val path = intArrayOf(
blank, 16, 16, blank, 30, blank, 40,
15, blank, 20, blank, 10, blank
)
assertEquals("CQ BG7", CwCtcDecoder.greedy(logits(path), chars, blank))
}
@Test
fun greedy_picksHighestScoringClassPerFrame() {
// Frame favours S (32) over T (33); only S must survive.
val frame = FloatArray(42) { -10f }
frame[33] = -1f
frame[32] = -0.1f
assertEquals("S", CwCtcDecoder.greedy(arrayOf(arrayOf(frame)), chars, blank))
}
}
@@ -1,301 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.*
import org.junit.Test
import kotlin.math.PI
import kotlin.math.sin
class CwDecoderTest {
// --- Morse table ---
@Test
fun morseToChar_basicLetters() {
assertEquals('A', CwBayesianDecoder.morseToChar("01"))
assertEquals('S', CwBayesianDecoder.morseToChar("000"))
assertEquals('O', CwBayesianDecoder.morseToChar("111"))
}
@Test
fun morseToChar_numbers() {
assertEquals('1', CwBayesianDecoder.morseToChar("01111"))
assertEquals('0', CwBayesianDecoder.morseToChar("11111"))
}
@Test
fun morseToChar_unknown_returnsNull() {
assertNull(CwBayesianDecoder.morseToChar("......."))
assertNull(CwBayesianDecoder.morseToChar(""))
}
// --- FFT ---
@Test
fun fft_magnitudeSpectrum_detectsTone() {
val fft = CwFFT(256)
val sampleRate = 8000f
val freq = 700f
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / sampleRate)).toFloat() }
val mag = fft.magnitudeSpectrum(buffer)
// Peak should be at bin around 700 * 256 / 8000 ≈ 22.4
var maxBin = 0
var maxVal = 0f
for (i in mag.indices) {
if (mag[i] > maxVal) { maxVal = mag[i]; maxBin = i }
}
assertTrue("Peak bin $maxBin should be near 22", maxBin in 18..26)
assertTrue("Peak value $maxVal should be positive", maxVal > 0.01f)
}
@Test
fun fft_magnitudeSpectrum_silence_isFlat() {
val fft = CwFFT(256)
val buffer = FloatArray(256) { 0f }
val mag = fft.magnitudeSpectrum(buffer)
for (v in mag) assertEquals("Silence spectrum should be 0, got $v", 0f, v, 1e-6f)
}
@Test
fun fft_rejectsWrongSize() {
assertThrows(IllegalArgumentException::class.java) { CwFFT(100) }
}
// --- Spectrogram ---
@Test
fun spectrogram_addSamples_updatesEnergy() {
val spec = CwSpectrogram(sampleRate = 8000)
val freq = 700f
// Feed multiple frames to stabilize energy normalization
for (i in 0..5) {
val buffer = FloatArray(256) { (sin(2.0 * PI * freq * it / 8000.0)).toFloat() }
spec.addSamples(buffer)
}
val col = spec.getCurrentColumn()
val peakBin = spec.findPeakBin()
assertTrue("Peak bin $peakBin should be >= 0", peakBin >= 0)
}
@Test
fun spectrogram_findPeakBin_returnsValidBin() {
val spec = CwSpectrogram(sampleRate = 8000)
// Add multiple frames of 700 Hz tone
for (i in 0..5) {
val buffer = FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
spec.addSamples(buffer)
}
val peakBin = spec.findPeakBin()
assertTrue("Peak bin should be >= 0, got $peakBin", peakBin >= 0)
}
@Test
fun spectrogram_freqToBin_roundtrip() {
val spec = CwSpectrogram(sampleRate = 8000)
val freq = 700f
val bin = spec.freqToBin(freq)
val backFreq = spec.binToFreq(bin)
assertTrue("Freq $freq → bin $bin → freq $backFreq", backFreq > 600f && backFreq < 800f)
}
@Test
fun spectrogram_getBinEnergy_returnsCorrectLength() {
val spec = CwSpectrogram(sampleRate = 8000)
val energy = spec.getBinEnergy(0, 10)
assertEquals(10, energy.size)
}
@Test
fun spectrogram_reset() {
val spec = CwSpectrogram(sampleRate = 8000)
spec.addSamples(FloatArray(256) { 1f })
spec.reset()
assertEquals(-1, spec.findPeakBin())
}
// --- Bayesian decoder ---
@Test
fun bayesian_processTone_dit() {
val decoder = CwBayesianDecoder()
// At 20 WPM, dot = 60 ms
val result = decoder.processTone(60f)
assertEquals('0', result.symbol)
assertTrue("Dit probability should be positive", result.probability > 0.1f)
}
@Test
fun bayesian_processTone_dash() {
val decoder = CwBayesianDecoder()
// Dash = 3 * dot = 180 ms
val result = decoder.processTone(180f)
assertEquals('1', result.symbol)
assertTrue("Dash probability should be positive", result.probability > 0.1f)
}
@Test
fun bayesian_processTone_unknown_returnsNull() {
val decoder = CwBayesianDecoder()
// Very long tone — low probability for both dit and dash
val result = decoder.processTone(5000f)
assertNull(result.symbol)
}
@Test
fun bayesian_processGap_interChar_returnsChar() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f) // dit
decoder.processTone(60f) // dit
decoder.processTone(60f) // dit
// 3 dots = "000" = 'S'
val char = decoder.processGap(180f) // 3 * dot = inter-char gap
assertEquals('S', char)
}
@Test
fun bayesian_processGap_wordGap_addsSpace() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f) // dit = 'E'
decoder.processGap(180f) // inter-char gap
// Now word gap
val space = decoder.processGap(420f) // 7 * dot
assertEquals(' ', space)
}
@Test
fun bayesian_decodedText_accumulates() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f) // dit = 'E'
decoder.processGap(180f) // inter-char
assertTrue(decoder.decodedText.isNotEmpty())
}
@Test
fun bayesian_reset() {
val decoder = CwBayesianDecoder()
decoder.processTone(60f)
decoder.reset()
assertEquals("", decoder.decodedText)
}
@Test
fun bayesian_getSpeed() {
val decoder = CwBayesianDecoder()
// Send 3 dits at 20 WPM (60 ms each)
decoder.processTone(60f)
decoder.processTone(60f)
decoder.processTone(60f)
val speed = decoder.getSpeed()
assertTrue("Speed should be ~20 WPM, got $speed", speed > 15f && speed < 30f)
}
// --- Channel tracker ---
@Test
fun channelTracker_initialState() {
val spec = CwSpectrogram(sampleRate = 8000)
val tracker = CwChannelTracker(spec)
val channels = tracker.update()
assertTrue("No channels should be active initially", channels.isEmpty())
}
@Test
fun channelTracker_detectsTone() {
val spec = CwSpectrogram(sampleRate = 8000)
// Feed a tone
for (i in 0..5) {
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
val tracker = CwChannelTracker(spec)
val channels = tracker.update()
assertTrue("Should detect at least 1 channel", channels.isNotEmpty())
}
@Test
fun channelTracker_bestChannel() {
val spec = CwSpectrogram(sampleRate = 8000)
for (i in 0..5) {
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
val tracker = CwChannelTracker(spec)
tracker.update()
val best = tracker.getBestChannel()
assertNotNull("Best channel should exist", best)
if (best != null) assertTrue(best.frequency in 600f..800f)
}
@Test
fun channelTracker_reset() {
val spec = CwSpectrogram(sampleRate = 8000)
for (i in 0..5) {
spec.addSamples(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
val tracker = CwChannelTracker(spec)
tracker.update()
tracker.reset()
assertNull(tracker.getBestChannel())
}
// --- Full decoder ---
@Test
fun decoder_initialState() {
val decoder = CwDecoder()
assertEquals("", decoder.decodedTextFlow.value)
assertEquals(0f, decoder.signalStrength.value, 0.001f)
}
@Test
fun decoder_processSilence_doesNotCrash() {
val decoder = CwDecoder()
decoder.processBuffer(FloatArray(256) { 0f })
assertEquals("", decoder.decodedTextFlow.value)
}
@Test
fun decoder_processNoise_doesNotCrash() {
val decoder = CwDecoder()
decoder.processBuffer(FloatArray(256) { (Math.random() * 2 - 1).toFloat() * 0.1f })
assertNotNull(decoder.decodedTextFlow.value)
}
@Test
fun decoder_processTone_doesNotCrash() {
val decoder = CwDecoder()
for (i in 0..20) {
decoder.processBuffer(FloatArray(256) { (sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() })
}
assertNotNull(decoder.decodedTextFlow.value)
}
@Test
fun decoder_reset() {
val decoder = CwDecoder()
decoder.processBuffer(FloatArray(256) { 1f })
decoder.resetDecoder()
assertEquals("", decoder.decodedTextFlow.value)
assertEquals(0f, decoder.signalStrength.value, 0.001f)
}
@Test
fun decoder_withFixedPitch() {
val decoder = CwDecoder(sampleRate = 8000, cwToneFreq = 700f)
assertEquals(700f, decoder.estimatedPitch.value)
}
}
@@ -0,0 +1,152 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rolling audio buffer feeding DeepCW.
*
* DeepCW is a whole-segment CTC model, not a sample-by-sample decoder: it
* rewrites earlier output whenever more context arrives, so incremental
* stitching is impossible. Instead we keep a bounded window and re-decode all
* of it periodically, replacing the displayed text.
*/
class CwDeepBufferTest {
@Test
fun capacityIsCappedAtMaxSeconds() {
val buffer = CwDeepBuffer(sampleRate = 3200, maxSeconds = 20.0)
repeat(30) { buffer.append(FloatArray(3200)) }
assertEquals(3200 * 20, buffer.size)
}
@Test
fun oldestSamplesAreDiscardedFirst() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f))
buffer.append(floatArrayOf(4f, 5f))
assertArrayEquals(floatArrayOf(2f, 3f, 4f, 5f), buffer.snapshot(), 0f)
}
@Test
fun snapshotIsChronologicalAfterWrapAround() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f))
assertArrayEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
}
@Test
fun appendLargerThanCapacityKeepsOnlyTheTail() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f))
assertEquals(4, buffer.size)
assertArrayEquals(floatArrayOf(6f, 7f, 8f, 9f), buffer.snapshot(), 0f)
}
@Test
fun redecodeIsSignalledOncePerInterval() {
// 1.5 s at 3200 Hz is 4800 samples; 1600 samples is 0.5 s.
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
assertFalse("1.0s elapsed: interval not reached", buffer.append(FloatArray(3200)))
assertTrue("1.5s elapsed: first trigger", buffer.append(FloatArray(1600)))
assertFalse("2.0s: only 0.5s since trigger", buffer.append(FloatArray(1600)))
assertFalse("2.5s: only 1.0s since trigger", buffer.append(FloatArray(1600)))
assertTrue("3.0s: 1.5s since trigger, fires again", buffer.append(FloatArray(1600)))
}
@Test
fun redecodeIntervalDoesNotDriftOverManyChunks() {
// 100 ms chunks, as AudioCapture emits them: exactly 15 chunks per
// 1.5 s interval, so 150 chunks must fire exactly 10 times.
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
var fired = 0
repeat(150) { if (buffer.append(FloatArray(320))) fired++ }
assertEquals(10, fired)
}
@Test
fun snapshotDoesNotAliasInternalStorage() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
buffer.snapshot()[0] = 99f
assertEquals("caller must not be able to mutate the buffer", 1f, buffer.snapshot()[0], 0f)
}
@Test
fun resetClearsSamplesAndIntervalCounter() {
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
buffer.append(FloatArray(3200))
buffer.reset()
assertEquals(0, buffer.size)
assertFalse("counter restarted, 1.0s must not trigger", buffer.append(FloatArray(3200)))
}
@Test
fun hasEnoughAudioTracksTheModelMinimum() {
// compute() needs at least FFT_LENGTH samples to produce one frame.
val buffer = CwDeepBuffer(3200, 20.0)
buffer.append(FloatArray(100))
assertFalse(buffer.hasEnoughAudio)
buffer.append(FloatArray(200))
assertTrue(buffer.hasEnoughAudio)
}
@Test
fun defaultsMatchTheMeasuredOptimum() {
// 20s / 1.5s were chosen from measurements: 20s is the smallest window
// reaching 0.0% CER, and keeps inference well inside real time.
val buffer = CwDeepBuffer()
assertEquals(CwDeepSpectrogram.SAMPLE_RATE * 20, buffer.capacity)
}
@Test
fun overflowCollectsEvictedSamplesInOrder() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) // capacity 4
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
assertEquals("nothing evicted before the window is full", 0, buffer.overflowCount)
buffer.append(floatArrayOf(5f, 6f)) // overwrites 1, 2
assertArrayEquals("evicted samples, oldest first", floatArrayOf(1f, 2f), buffer.drainOverflow(), 0f)
assertArrayEquals("live window still correct", floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
}
@Test
fun drainOverflowClearsItself() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
buffer.append(floatArrayOf(5f))
assertEquals(1, buffer.overflowCount)
buffer.drainOverflow()
assertEquals(0, buffer.overflowCount)
assertArrayEquals(FloatArray(0), buffer.drainOverflow(), 0f)
}
@Test
fun resetClearsOverflow() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
buffer.append(floatArrayOf(5f))
assertEquals(1, buffer.overflowCount)
buffer.reset()
assertEquals(0, buffer.overflowCount)
}
}
@@ -0,0 +1,130 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
/**
* Pins the Kotlin front-end to the upstream Python reference implementation.
*
* `golden_spec.txt` was produced by deepcw-engine's own preprocessing code
* (numpy reflect padding, `np.hanning(N+1)[:-1]`, `np.fft.rfft`, `log1p`) over
* the audio [generateTestAudio] builds. Both sides synthesise the audio from
* the same deterministic formula, so only the spectrogram needs pinning.
*
* A mismatch here means the model would receive subtly wrong input and emit
* plausible-looking garbage, which is very hard to diagnose downstream — so
* this test guards the whole pipeline.
*
* Regenerate with `scripts/deepcw_gen_golden.py` from the skill library if the
* model metadata ever changes.
*/
class CwDeepGoldenVectorTest {
private companion object {
const val SRC_RATE = 8000
const val TONE_HZ = 700.0
const val AMPLITUDE = 0.6
const val DOT_SAMPLES = 480 // 20 WPM at 8000 Hz: 1.2/20*8000
const val PATTERN = "-.-." // the letter C
const val TOLERANCE = 1e-4f
}
/** Square-keyed 700 Hz tone: 4 dots of silence, "C", 4 dots of silence. */
private fun generateTestAudio(): FloatArray {
val keying = ArrayList<Int>()
repeat(4 * DOT_SAMPLES) { keying.add(0) }
for ((i, element) in PATTERN.withIndex()) {
val length = if (element == '-') 3 * DOT_SAMPLES else DOT_SAMPLES
repeat(length) { keying.add(1) }
if (i < PATTERN.length - 1) repeat(DOT_SAMPLES) { keying.add(0) }
}
repeat(4 * DOT_SAMPLES) { keying.add(0) }
return FloatArray(keying.size) { i ->
if (keying[i] == 1) {
(AMPLITUDE * sin(2.0 * PI * TONE_HZ * i / SRC_RATE)).toFloat()
} else {
0f
}
}
}
private fun readGoldenSpectrogram(): Array<FloatArray> {
val stream = javaClass.classLoader?.getResourceAsStream("cw/golden_spec.txt")
?: throw IllegalStateException("cw/golden_spec.txt missing from test resources")
stream.bufferedReader().use { reader ->
val (frames, bins) = reader.readLine().trim().split(" ").map(String::toInt)
return Array(frames) {
val row = reader.readLine().trim().split(" ")
require(row.size == bins) { "expected $bins values, got ${row.size}" }
FloatArray(bins) { i -> row[i].toFloat() }
}
}
}
@Test
fun spectrogramMatchesPythonReferenceFrameByFrame() {
val expected = readGoldenSpectrogram()
val audio = CwDeepSpectrogram.resampleLinear(
generateTestAudio(), SRC_RATE, CwDeepSpectrogram.SAMPLE_RATE
)
val actual = CwDeepSpectrogram.compute(audio)
assertEquals("frame count", expected.size, actual.size)
assertEquals("bin count", expected[0].size, actual[0].size)
var worstDelta = 0f
var worstAt = ""
for (t in expected.indices) {
for (f in expected[t].indices) {
val delta = abs(expected[t][f] - actual[t][f])
if (delta > worstDelta) {
worstDelta = delta
worstAt = "frame $t bin $f: expected ${expected[t][f]}, got ${actual[t][f]}"
}
}
}
assertTrue(
"front-end diverges from the Python reference — worst delta $worstDelta at $worstAt",
worstDelta <= TOLERANCE
)
}
@Test
fun resampledLengthMatchesReference() {
val audio = generateTestAudio()
assertEquals("source audio length", 9120, audio.size)
val resampled = CwDeepSpectrogram.resampleLinear(
audio, SRC_RATE, CwDeepSpectrogram.SAMPLE_RATE
)
assertEquals("resampled length", 3648, resampled.size)
}
@Test
fun goldenVectorHasExpectedShape() {
val golden = readGoldenSpectrogram()
assertEquals("frames", 77, golden.size)
assertEquals("bins", CwDeepSpectrogram.FREQUENCY_BINS, golden[0].size)
}
}
@@ -0,0 +1,110 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
/**
* Verifies the DeepCW front-end against the upstream Python reference
* implementation (deepcw-engine examples/python/decode_morse.py).
*
* Model metadata: sampleRate 3200, fftLength 256, hopLength 48,
* 400-1200 Hz -> 65 bins, log1p normalization.
*/
class CwDeepSpectrogramTest {
@Test
fun frequencyBinRange_matchesModelMetadata() {
// binHz = 3200/256 = 12.5; start = ceil(400/12.5) = 32; stop = floor(1200/12.5)+1 = 97
val (start, stop) = CwDeepSpectrogram.frequencyBinRange(3200, 256, 400.0, 1200.0)
assertEquals(32, start)
assertEquals(97, stop)
assertEquals("metadata declares 65 frequency bins", 65, stop - start)
}
@Test
fun compute_producesTimeBy65Matrix() {
// 1 second at 3200 Hz. Reflect padding adds fft/2 on both sides,
// so frames = 1 + (3200 + 256 - 256)/48 = 1 + 66 = 67
val spec = CwDeepSpectrogram.compute(FloatArray(3200))
assertEquals(67, spec.size)
assertEquals(65, spec[0].size)
}
@Test
fun compute_toneLandsInExpectedBin() {
// 700 Hz -> absolute bin 700/12.5 = 56 -> relative index 56 - 32 = 24
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 700.0 * it / 3200.0)).toFloat() }
val spec = CwDeepSpectrogram.compute(audio)
val middle = spec[spec.size / 2]
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
assertTrue("peak at index $peak, expected near 24", abs(peak - 24) <= 1)
}
@Test
fun compute_appliesLog1pSoValuesAreNonNegative() {
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 700.0 * it / 3200.0)).toFloat() }
val spec = CwDeepSpectrogram.compute(audio)
for (frame in spec) {
for (v in frame) {
assertTrue("log1p of a magnitude must be >= 0, got $v", v >= 0f)
}
}
}
@Test
fun resampleLinear_convertsRateAndLength() {
assertEquals(3200, CwDeepSpectrogram.resampleLinear(FloatArray(8000), 8000, 3200).size)
assertEquals(3200, CwDeepSpectrogram.resampleLinear(FloatArray(44100), 44100, 3200).size)
}
@Test
fun resampleLinear_sameRateIsIdentity() {
val input = floatArrayOf(0.1f, 0.2f, 0.3f)
val out = CwDeepSpectrogram.resampleLinear(input, 3200, 3200)
assertEquals(3, out.size)
assertEquals(0.2f, out[1], 1e-6f)
}
@Test
fun resampleLinear_preservesToneFrequency() {
// A 700 Hz tone sampled at 8000 Hz must still peak at bin 24 after
// resampling to 3200 Hz — this is the path real microphone audio takes.
val at8k = FloatArray(8000) { (0.6 * sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
val at3200 = CwDeepSpectrogram.resampleLinear(at8k, 8000, 3200)
val spec = CwDeepSpectrogram.compute(at3200)
val middle = spec[spec.size / 2]
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
assertTrue("resampled tone peak at $peak, expected near 24", abs(peak - 24) <= 1)
}
@Test
fun compute_rejectsAudioShorterThanFftLength() {
try {
CwDeepSpectrogram.compute(FloatArray(100))
throw AssertionError("expected an exception for audio shorter than fftLength")
} catch (expected: IllegalArgumentException) {
// desired path
}
}
}
@@ -0,0 +1,78 @@
77 65
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0.07889045 0.08153424 0.08419035 0.08701893 0.09030894 0.09436007 0.09932096 0.1050817 0.1113084 0.1176305 0.1239188 0.1305645 0.1386389 0.1497532 0.1654913 0.1866336 0.2127252 0.2422527 0.2731474 0.3032433 0.3305584 0.3534314 0.3705729 0.3810754 0.3844073 0.3804049 0.3692688 0.3515675 0.3282437 0.3006178 0.2703726 0.239492 0.2101091 0.1842086 0.1631677 0.1472881 0.1356851 0.126759 0.1189501 0.1112676 0.1034146 0.09563871 0.0884626 0.08238604 0.07764256 0.07410739 0.07139879 0.06908784 0.06687377 0.06464756 0.06245377 0.06040035 0.05856907 0.05696608 0.05552753 0.05416456 0.05281367 0.05146397 0.05015182 0.0489313 0.04783952 0.04687586 0.04600478 0.04517753 0.04435825
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.07889044 0.08153418 0.08419043 0.08701882 0.09030894 0.09435995 0.09932118 0.1050815 0.1113084 0.1176306 0.1239187 0.1305645 0.1386389 0.1497532 0.1654913 0.1866337 0.2127252 0.2422527 0.2731474 0.3032434 0.3305583 0.3534314 0.3705729 3.007106 3.643108 3.00695 0.3692689 0.3515675 0.3282436 0.300618 0.2703725 0.239492 0.210109 0.1842086 0.1631676 0.1472882 0.1356852 0.1267589 0.1189501 0.3679682 0.5516769 0.2577798 0.08846281 0.08238595 0.07764257 0.07410715 0.07139895 0.06908786 0.06687377 0.06464756 0.06245382 0.06040037 0.05856909 0.05696621 0.05552716 0.0541646 0.05281365 0.05146411 0.05015175 0.04893123 0.04783951 0.04687591 0.0460047 0.04517756 0.04435825
1.564492e-07 1.122884e-07 8.888702e-08 1.762498e-07 8.600421e-09 1.938033e-07 2.387622e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498516e-08 5.176826e-08 1.897711e-07 8.593484e-08 4.070753e-08 7.40209e-08 2.278317e-07 8.807671e-08 9.716474e-08 1.372644e-07 1.501705e-07 2.285518e-08 9.67386e-08 2.987679 3.6553 2.987679 1.266364e-07 1.456021e-08 5.034151e-08 2.597979e-07 1.838301e-07 6.343754e-08 8.150528e-08 5.003271e-09 9.525829e-08 2.182818e-07 1.843608e-07 1.385002e-08 6.420205e-09 0.3078006 0.542824 0.3078008 2.254644e-07 1.478402e-07 1.590096e-07 3.480243e-07 1.698739e-07 5.310058e-08 4.452434e-08 4.024338e-09 5.150024e-08 2.914611e-08 1.276424e-07 1.826463e-07 3.983407e-07 5.450444e-08 2.266521e-07 1.942693e-07 7.255422e-08 1.167992e-07 1.510635e-07 6.353517e-08 7.878904e-08 3.480655e-08 4.585462e-09
1.564492e-07 1.122884e-07 8.888694e-08 1.762498e-07 8.600284e-09 1.938033e-07 2.387623e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498505e-08 5.176823e-08 1.897709e-07 8.593484e-08 4.070745e-08 7.402096e-08 2.278317e-07 8.807665e-08 9.716481e-08 1.372644e-07 1.501703e-07 2.285522e-08 9.673848e-08 2.987679 3.6553 2.987679 1.266363e-07 1.456018e-08 5.034165e-08 2.597979e-07 1.8383e-07 6.34376e-08 8.150528e-08 5.003333e-09 9.525822e-08 2.182818e-07 1.843609e-07 1.385006e-08 6.420322e-09 0.3078006 0.542824 0.3078008 2.254643e-07 1.478403e-07 1.590095e-07 3.480243e-07 1.698739e-07 5.310051e-08 4.452434e-08 4.0244e-09 5.150032e-08 2.914612e-08 1.276426e-07 1.826462e-07 3.983408e-07 5.450447e-08 2.266521e-07 1.942694e-07 7.255434e-08 1.167993e-07 1.510637e-07 6.353518e-08 7.878896e-08 3.480649e-08 4.585462e-09
1.564492e-07 1.122884e-07 8.888694e-08 1.762499e-07 8.600289e-09 1.938033e-07 2.387623e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498504e-08 5.176823e-08 1.897709e-07 8.593484e-08 4.070745e-08 7.402096e-08 2.278317e-07 8.807665e-08 9.716482e-08 1.372644e-07 1.501703e-07 2.285522e-08 9.673848e-08 2.987679 3.6553 2.987679 1.266363e-07 1.456017e-08 5.034164e-08 2.597979e-07 1.8383e-07 6.34376e-08 8.150528e-08 5.003331e-09 9.525821e-08 2.182818e-07 1.843609e-07 1.385006e-08 6.420325e-09 0.3078006 0.542824 0.3078008 2.254643e-07 1.478403e-07 1.590095e-07 3.480243e-07 1.698739e-07 5.310052e-08 4.452434e-08 4.024398e-09 5.150032e-08 2.914612e-08 1.276426e-07 1.826462e-07 3.983408e-07 5.450447e-08 2.266521e-07 1.942694e-07 7.255434e-08 1.167993e-07 1.510637e-07 6.353518e-08 7.878896e-08 3.480649e-08 4.585462e-09
1.564492e-07 1.122884e-07 8.888702e-08 1.762499e-07 8.600421e-09 1.938034e-07 2.387622e-07 2.871271e-07 2.21893e-07 1.239546e-07 8.498516e-08 5.176833e-08 1.897711e-07 8.593477e-08 4.070753e-08 7.402097e-08 2.278317e-07 8.807664e-08 9.716474e-08 1.372644e-07 1.501705e-07 2.285512e-08 9.67386e-08 2.987679 3.6553 2.987679 1.266364e-07 1.456028e-08 5.034151e-08 2.59798e-07 1.838301e-07 6.343761e-08 8.150528e-08 5.003339e-09 9.525829e-08 2.182819e-07 1.843608e-07 1.384995e-08 6.420205e-09 0.3078006 0.542824 0.3078008 2.254644e-07 1.478402e-07 1.590096e-07 3.480242e-07 1.698739e-07 5.310051e-08 4.452434e-08 4.024406e-09 5.150024e-08 2.914618e-08 1.276424e-07 1.826463e-07 3.983407e-07 5.450437e-08 2.266521e-07 1.942693e-07 7.255422e-08 1.167992e-07 1.510635e-07 6.353524e-08 7.878904e-08 3.480648e-08 4.585462e-09
1.564492e-07 1.122885e-07 8.888715e-08 1.762501e-07 8.600158e-09 1.938033e-07 2.387623e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498502e-08 5.176819e-08 1.897708e-07 8.593463e-08 4.070766e-08 7.402083e-08 2.278317e-07 8.807678e-08 9.716461e-08 1.372642e-07 1.501702e-07 2.285526e-08 9.673845e-08 2.987679 3.6553 2.987679 1.266363e-07 1.456013e-08 5.034177e-08 2.597981e-07 1.838302e-07 6.343747e-08 8.150528e-08 5.003199e-09 9.525842e-08 2.18282e-07 1.843611e-07 1.38501e-08 6.420349e-09 0.3078006 0.542824 0.3078008 2.254643e-07 1.478403e-07 1.590094e-07 3.480241e-07 1.698737e-07 5.310065e-08 4.452434e-08 4.024266e-09 5.150011e-08 2.914633e-08 1.276427e-07 1.826462e-07 3.983409e-07 5.45045e-08 2.266521e-07 1.942694e-07 7.255437e-08 1.167993e-07 1.510638e-07 6.353538e-08 7.878917e-08 3.480662e-08 4.585462e-09
1.564492e-07 1.122885e-07 8.888713e-08 1.762501e-07 8.600153e-09 1.938034e-07 2.387623e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498504e-08 5.176827e-08 1.897708e-07 8.593455e-08 4.070764e-08 7.402088e-08 2.278317e-07 8.807672e-08 9.716463e-08 1.372642e-07 1.501702e-07 2.285517e-08 9.673848e-08 2.987679 3.6553 2.987679 1.266363e-07 1.456022e-08 5.034178e-08 2.597982e-07 1.838302e-07 6.343753e-08 8.150528e-08 5.003255e-09 9.52584e-08 2.182821e-07 1.843611e-07 1.385001e-08 6.420325e-09 0.3078006 0.542824 0.3078008 2.254643e-07 1.478402e-07 1.590094e-07 3.48024e-07 1.698737e-07 5.310059e-08 4.452434e-08 4.024322e-09 5.150013e-08 2.91464e-08 1.276427e-07 1.826463e-07 3.983408e-07 5.450445e-08 2.266521e-07 1.942693e-07 7.255434e-08 1.167992e-07 1.510638e-07 6.353546e-08 7.878916e-08 3.480656e-08 4.585462e-09
1.564492e-07 1.122884e-07 8.888702e-08 1.762501e-07 8.600201e-09 1.938033e-07 2.387621e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.49852e-08 5.176818e-08 1.897709e-07 8.59346e-08 4.070753e-08 7.402099e-08 2.278317e-07 8.807662e-08 9.716474e-08 1.372642e-07 1.501702e-07 2.285526e-08 9.673864e-08 2.987679 3.6553 2.987679 1.266365e-07 1.456013e-08 5.034173e-08 2.597982e-07 1.838301e-07 6.343763e-08 8.150528e-08 5.00336e-09 9.525829e-08 2.182821e-07 1.84361e-07 1.38501e-08 6.420163e-09 0.3078006 0.542824 0.3078008 2.254645e-07 1.478403e-07 1.590094e-07 3.480241e-07 1.698739e-07 5.310049e-08 4.452434e-08 4.024427e-09 5.150024e-08 2.914636e-08 1.276426e-07 1.826462e-07 3.983407e-07 5.450442e-08 2.266521e-07 1.942693e-07 7.255419e-08 1.167993e-07 1.510638e-07 6.353542e-08 7.878904e-08 3.480646e-08 4.585462e-09
0.07889044 0.08153418 0.08419043 0.08701882 0.09030894 0.09435995 0.09932118 0.1050815 0.1113084 0.1176306 0.1239187 0.1305645 0.1386389 0.1497532 0.1654913 0.1866337 0.2127252 0.2422527 0.2731474 0.3032434 0.3305583 0.3534314 0.3705729 3.007106 3.643108 3.00695 0.3692689 0.3515675 0.3282436 0.300618 0.2703725 0.239492 0.210109 0.1842086 0.1631676 0.1472882 0.1356852 0.1267589 0.1189501 0.3679682 0.5516769 0.2577798 0.08846281 0.08238595 0.07764257 0.07410715 0.07139895 0.06908786 0.06687377 0.06464756 0.06245382 0.06040037 0.05856909 0.05696621 0.05552716 0.0541646 0.05281365 0.05146411 0.05015175 0.04893123 0.04783951 0.04687591 0.0460047 0.04517756 0.04435825
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.0133671 0.103174 0.161102 0.1321454 0.01981753 0.1171069 0.1886141 0.1606799 0.03154613 0.1368369 0.230862 0.208004 0.05647711 0.1650847 0.3020293 0.298915 0.1252058 0.1979204 0.4401451 0.5311838 0.4472875 0.1536819 0.2990841 0.5738236 0.6613865 0.5702119 0.2925259 0.1586363 0.4468553 0.5274072 0.4351405 0.1946639 0.1232734 0.2943546 0.2986827 0.1671092 0.04655298 0.1966405 0.2236971 0.1398044 0.01435895 0.1384342 0.1696025 0.109466 0.009560954 0.1101514 0.1376043 0.08825757 0.01127213 0.09668601 0.1209162 0.07821064 0.008807627 0.0850097 0.1078746 0.07068323 0.006762736 0.07595089 0.09776526 0.06483996 0.005235632 0.0689388 0.08987227 0.0602516 0.004088645
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.01336695 0.1031739 0.161102 0.1321453 0.01981753 0.1171067 0.1886142 0.1606797 0.03154635 0.1368371 0.2308619 0.2080039 0.05647729 0.1650848 0.3020293 0.298915 0.1252055 0.1979204 0.440145 0.531184 0.4472874 0.1536818 0.299084 3.026 3.630765 3.02569 0.2925259 0.1586363 0.4468552 0.5274073 0.4351404 0.1946639 0.1232734 0.2943546 0.2986827 0.1671094 0.04655316 0.1966404 0.2236971 0.4124265 0.5511931 0.1922355 0.1696027 0.1094658 0.009561111 0.110151 0.1376044 0.08825763 0.01127217 0.09668601 0.1209163 0.07821067 0.008807753 0.08500987 0.1078743 0.07068328 0.00676251 0.07595107 0.0977652 0.06483985 0.005235482 0.06893886 0.0898722 0.06025163 0.004088641
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.0133671 0.103174 0.161102 0.1321454 0.01981753 0.1171069 0.1886141 0.1606799 0.03154613 0.1368369 0.230862 0.208004 0.05647711 0.1650847 0.3020293 0.298915 0.1252058 0.1979204 0.4401451 0.5311838 0.4472875 0.1536819 0.2990841 0.5738236 0.6613865 0.5702119 0.2925259 0.1586363 0.4468553 0.5274072 0.4351405 0.1946639 0.1232734 0.2943546 0.2986827 0.1671092 0.04655298 0.1966405 0.2236971 0.1398044 0.01435895 0.1384342 0.1696025 0.109466 0.009560954 0.1101514 0.1376043 0.08825757 0.01127213 0.09668601 0.1209162 0.07821064 0.008807627 0.0850097 0.1078746 0.07068323 0.006762736 0.07595089 0.09776526 0.06483996 0.005235632 0.0689388 0.08987227 0.0602516 0.004088645
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.07889044 0.08153418 0.08419043 0.08701882 0.09030894 0.09435995 0.09932118 0.1050815 0.1113084 0.1176306 0.1239187 0.1305645 0.1386389 0.1497532 0.1654913 0.1866337 0.2127252 0.2422527 0.2731474 0.3032434 0.3305583 0.3534314 0.3705729 3.007106 3.643108 3.00695 0.3692689 0.3515675 0.3282436 0.300618 0.2703725 0.239492 0.210109 0.1842086 0.1631676 0.1472882 0.1356852 0.1267589 0.1189501 0.3679682 0.5516769 0.2577798 0.08846281 0.08238595 0.07764257 0.07410715 0.07139895 0.06908786 0.06687377 0.06464756 0.06245382 0.06040037 0.05856909 0.05696621 0.05552716 0.0541646 0.05281365 0.05146411 0.05015175 0.04893123 0.04783951 0.04687591 0.0460047 0.04517756 0.04435825
1.564492e-07 1.122885e-07 8.888702e-08 1.762501e-07 8.600116e-09 1.938031e-07 2.387619e-07 2.871269e-07 2.21893e-07 1.239548e-07 8.498547e-08 5.176805e-08 1.897708e-07 8.59346e-08 4.070753e-08 7.402088e-08 2.278317e-07 8.807672e-08 9.716474e-08 1.372642e-07 1.501702e-07 2.28554e-08 9.673891e-08 2.987679 3.6553 2.987679 1.266367e-07 1.456e-08 5.034181e-08 2.597982e-07 1.838301e-07 6.343753e-08 8.150528e-08 5.003254e-09 9.525829e-08 2.182821e-07 1.843611e-07 1.385024e-08 6.419893e-09 0.3078006 0.542824 0.3078008 2.254647e-07 1.478404e-07 1.590093e-07 3.480241e-07 1.698739e-07 5.31006e-08 4.452434e-08 4.024321e-09 5.150024e-08 2.914635e-08 1.276427e-07 1.82646e-07 3.983404e-07 5.450418e-08 2.266521e-07 1.942691e-07 7.255392e-08 1.167995e-07 1.510639e-07 6.353541e-08 7.878904e-08 3.480657e-08 4.585462e-09
1.564492e-07 1.122886e-07 8.888708e-08 1.762499e-07 8.600304e-09 1.938035e-07 2.387625e-07 2.871271e-07 2.21893e-07 1.239546e-07 8.49849e-08 5.176842e-08 1.89771e-07 8.593476e-08 4.070759e-08 7.402079e-08 2.278317e-07 8.807682e-08 9.716468e-08 1.372644e-07 1.501704e-07 2.285502e-08 9.673834e-08 2.987679 3.6553 2.987679 1.266362e-07 1.456037e-08 5.034163e-08 2.59798e-07 1.838302e-07 6.343743e-08 8.150528e-08 5.003165e-09 9.525835e-08 2.182819e-07 1.843609e-07 1.384986e-08 6.420458e-09 0.3078006 0.542824 0.3078008 2.254642e-07 1.478401e-07 1.590095e-07 3.480242e-07 1.698738e-07 5.310068e-08 4.452434e-08 4.024231e-09 5.150018e-08 2.914619e-08 1.276425e-07 1.826464e-07 3.98341e-07 5.45043e-08 2.266521e-07 1.942692e-07 7.255448e-08 1.167991e-07 1.510637e-07 6.353525e-08 7.87891e-08 3.480666e-08 4.585462e-09
1.564492e-07 1.122883e-07 8.888687e-08 1.762498e-07 8.600378e-09 1.938033e-07 2.387621e-07 2.87127e-07 2.21893e-07 1.239547e-07 8.498525e-08 5.176826e-08 1.89771e-07 8.593487e-08 4.070738e-08 7.402101e-08 2.278317e-07 8.80766e-08 9.716489e-08 1.372645e-07 1.501704e-07 2.285518e-08 9.673868e-08 2.987679 3.6553 2.987679 1.266365e-07 1.456021e-08 5.034155e-08 2.597979e-07 1.8383e-07 6.343765e-08 8.150528e-08 5.003383e-09 9.525814e-08 2.182818e-07 1.843608e-07 1.385002e-08 6.420119e-09 0.3078006 0.542824 0.3078008 2.254645e-07 1.478402e-07 1.590096e-07 3.480243e-07 1.69874e-07 5.310046e-08 4.452434e-08 4.02445e-09 5.150039e-08 2.914608e-08 1.276425e-07 1.826463e-07 3.983406e-07 5.450425e-08 2.266521e-07 1.942691e-07 7.255414e-08 1.167992e-07 1.510636e-07 6.353514e-08 7.878889e-08 3.480644e-08 4.585462e-09
1.564492e-07 1.122883e-07 8.888674e-08 1.762497e-07 8.60019e-09 1.938031e-07 2.387623e-07 2.871273e-07 2.21893e-07 1.239544e-07 8.498511e-08 5.176805e-08 1.897708e-07 8.593494e-08 4.070725e-08 7.402105e-08 2.278317e-07 8.807655e-08 9.716502e-08 1.372645e-07 1.501702e-07 2.285539e-08 9.673855e-08 2.987679 3.6553 2.987679 1.266364e-07 1.456e-08 5.034174e-08 2.597978e-07 1.838298e-07 6.343769e-08 8.150528e-08 5.00342e-09 9.525801e-08 2.182817e-07 1.84361e-07 1.385023e-08 6.420252e-09 0.3078006 0.542824 0.3078008 2.254644e-07 1.478404e-07 1.590094e-07 3.480244e-07 1.698741e-07 5.310043e-08 4.452434e-08 4.024487e-09 5.150052e-08 2.914602e-08 1.276427e-07 1.82646e-07 3.983407e-07 5.450456e-08 2.266521e-07 1.942695e-07 7.255427e-08 1.167994e-07 1.510638e-07 6.353508e-08 7.878877e-08 3.48064e-08 4.585462e-09
1.564492e-07 1.122884e-07 8.888679e-08 1.762497e-07 8.600452e-09 1.938035e-07 2.387627e-07 2.871276e-07 2.21893e-07 1.239541e-07 8.49847e-08 5.176846e-08 1.897711e-07 8.593501e-08 4.07073e-08 7.402098e-08 2.278317e-07 8.807663e-08 9.716497e-08 1.372646e-07 1.501705e-07 2.285498e-08 9.673813e-08 2.987679 3.6553 2.987679 1.266359e-07 1.456041e-08 5.034148e-08 2.597978e-07 1.838299e-07 6.343762e-08 8.150528e-08 5.003356e-09 9.525806e-08 2.182816e-07 1.843608e-07 1.384982e-08 6.42067e-09 0.3078006 0.542824 0.3078008 2.254639e-07 1.4784e-07 1.590097e-07 3.480245e-07 1.698741e-07 5.310049e-08 4.452434e-08 4.024423e-09 5.150047e-08 2.914594e-08 1.276424e-07 1.826465e-07 3.983412e-07 5.450482e-08 2.266521e-07 1.942697e-07 7.255469e-08 1.16799e-07 1.510635e-07 6.3535e-08 7.878881e-08 3.480647e-08 4.585462e-09
1.564492e-07 1.122884e-07 8.888694e-08 1.762497e-07 8.600463e-09 1.938032e-07 2.387621e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498531e-08 5.17681e-08 1.897711e-07 8.593498e-08 4.070744e-08 7.402095e-08 2.278317e-07 8.807665e-08 9.716483e-08 1.372646e-07 1.501705e-07 2.285535e-08 9.673875e-08 2.987679 3.6553 2.987679 1.266366e-07 1.456004e-08 5.034147e-08 2.597978e-07 1.8383e-07 6.34376e-08 8.150528e-08 5.003324e-09 9.52582e-08 2.182817e-07 1.843607e-07 1.385019e-08 6.420059e-09 0.3078006 0.542824 0.3078008 2.254646e-07 1.478404e-07 1.590097e-07 3.480245e-07 1.698739e-07 5.310052e-08 4.452434e-08 4.024391e-09 5.150033e-08 2.914598e-08 1.276424e-07 1.826461e-07 3.983406e-07 5.450446e-08 2.266521e-07 1.942694e-07 7.255408e-08 1.167994e-07 1.510635e-07 6.353503e-08 7.878896e-08 3.48065e-08 4.585462e-09
1.564492e-07 1.122886e-07 8.888708e-08 1.762499e-07 8.600294e-09 1.938035e-07 2.387625e-07 2.871271e-07 2.21893e-07 1.239546e-07 8.498491e-08 5.176843e-08 1.89771e-07 8.593477e-08 4.070759e-08 7.40208e-08 2.278317e-07 8.807681e-08 9.716467e-08 1.372644e-07 1.501703e-07 2.285501e-08 9.673835e-08 2.987679 3.6553 2.987679 1.266362e-07 1.456038e-08 5.034164e-08 2.59798e-07 1.838302e-07 6.343744e-08 8.150528e-08 5.003168e-09 9.525836e-08 2.182819e-07 1.843609e-07 1.384985e-08 6.420451e-09 0.3078006 0.542824 0.3078008 2.254642e-07 1.478401e-07 1.590095e-07 3.480242e-07 1.698738e-07 5.310068e-08 4.452434e-08 4.024235e-09 5.150017e-08 2.914619e-08 1.276426e-07 1.826464e-07 3.983409e-07 5.450431e-08 2.266521e-07 1.942692e-07 7.255447e-08 1.167991e-07 1.510637e-07 6.353525e-08 7.878911e-08 3.480665e-08 4.585462e-09
0.07889044 0.08153418 0.08419043 0.08701882 0.09030894 0.09435995 0.09932118 0.1050815 0.1113084 0.1176306 0.1239187 0.1305645 0.1386389 0.1497532 0.1654913 0.1866337 0.2127252 0.2422527 0.2731474 0.3032434 0.3305583 0.3534314 0.3705729 3.007106 3.643108 3.00695 0.3692689 0.3515675 0.3282436 0.300618 0.2703725 0.239492 0.210109 0.1842086 0.1631676 0.1472882 0.1356852 0.1267589 0.1189501 0.3679682 0.5516769 0.2577798 0.08846281 0.08238595 0.07764257 0.07410715 0.07139895 0.06908786 0.06687377 0.06464756 0.06245382 0.06040037 0.05856909 0.05696621 0.05552716 0.0541646 0.05281365 0.05146411 0.05015175 0.04893123 0.04783951 0.04687591 0.0460047 0.04517756 0.04435825
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.0133671 0.103174 0.161102 0.1321454 0.01981753 0.1171069 0.1886141 0.1606799 0.03154613 0.1368369 0.230862 0.208004 0.05647711 0.1650847 0.3020293 0.298915 0.1252058 0.1979204 0.4401451 0.5311838 0.4472875 0.1536819 0.2990841 0.5738236 0.6613865 0.5702119 0.2925259 0.1586363 0.4468553 0.5274072 0.4351405 0.1946639 0.1232734 0.2943546 0.2986827 0.1671092 0.04655298 0.1966405 0.2236971 0.1398044 0.01435895 0.1384342 0.1696025 0.109466 0.009560954 0.1101514 0.1376043 0.08825757 0.01127213 0.09668601 0.1209162 0.07821064 0.008807627 0.0850097 0.1078746 0.07068323 0.006762736 0.07595089 0.09776526 0.06483996 0.005235632 0.0689388 0.08987227 0.0602516 0.004088645
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.01336695 0.1031739 0.161102 0.1321453 0.01981753 0.1171067 0.1886142 0.1606797 0.03154635 0.1368371 0.2308619 0.2080039 0.05647729 0.1650848 0.3020293 0.298915 0.1252055 0.1979204 0.440145 0.531184 0.4472874 0.1536818 0.299084 3.026 3.630765 3.02569 0.2925259 0.1586363 0.4468552 0.5274073 0.4351404 0.1946639 0.1232734 0.2943546 0.2986827 0.1671094 0.04655316 0.1966404 0.2236971 0.4124265 0.5511931 0.1922355 0.1696027 0.1094658 0.009561111 0.110151 0.1376044 0.08825763 0.01127217 0.09668601 0.1209163 0.07821067 0.008807753 0.08500987 0.1078743 0.07068328 0.00676251 0.07595107 0.0977652 0.06483985 0.005235482 0.06893886 0.0898722 0.06025163 0.004088641
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.07889045 0.08153424 0.08419035 0.08701893 0.09030894 0.09436007 0.09932096 0.1050817 0.1113084 0.1176305 0.1239188 0.1305645 0.1386389 0.1497532 0.1654913 0.1866336 0.2127252 0.2422527 0.2731474 0.3032433 0.3305584 0.3534314 0.3705729 0.3810754 0.3844073 0.3804049 0.3692688 0.3515675 0.3282437 0.3006178 0.2703726 0.239492 0.2101091 0.1842086 0.1631677 0.1472881 0.1356851 0.126759 0.1189501 0.1112676 0.1034146 0.09563871 0.0884626 0.08238604 0.07764256 0.07410739 0.07139879 0.06908784 0.06687377 0.06464756 0.06245377 0.06040035 0.05856907 0.05696608 0.05552753 0.05416456 0.05281367 0.05146397 0.05015182 0.0489313 0.04783952 0.04687586 0.04600478 0.04517753 0.04435825
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
@@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme(
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFF1E1BB),
onSecondaryContainer = Color(0xFF221B04),
tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme)
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker)
onTertiaryContainer = Color(0xFF000000),
background = Color(0xFFFFF8F0),
onBackground = Color(0xFF1E1B13),
surface = Color(0xFFFFF8F0),
@@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme(
onSecondary = Color(0xFF000000),
secondaryContainer = Color(0xFF404040), // navBar indicator,
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
// tertiary = Color(0xFF121212),
// onTertiary = Color(0xFF121212),
// tertiaryContainer = Color(0xFF121212),
// onTertiaryContainer = Color(0xFF121212),
tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status)
onTertiary = Color(0xFF000000),
tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry
onTertiaryContainer = Color(0xFF000000),
background = Color(0xFF121212),
onBackground = Color(0xFFE0E0E0),
surface = Color(0xFF202020), // card background
@@ -47,15 +47,18 @@ sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: Strin
@Serializable
data object WavelogLog : Screen(R.drawable.ic_log, R.string.nav_log, "WavelogLog")
@Serializable
data object AmSat : Screen(R.drawable.ic_satellites, R.string.nav_amsat, "AMSAT")
@Serializable
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
}
// UI 设置: 默认主菜单页面顺序(底部栏 5 槽)
// UI settings: default main-menu page order (5 bottom-bar slots)
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
// UI 设置: 默认更多菜单顺序(进「更多」的页面)
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog")
// UI settings: default More-menu order (pages behind "More")
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")
@Serializable
data object RadarDestination : NavKey
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Look4Sat Pro</string>
<string name="app_name" translatable="false">Look4Sat Pro</string>
<string name="btn_accept">Terima</string>
<string name="btn_cancel">Batal</string>
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
@@ -12,8 +12,52 @@
<string name="nav_cw">Dekoder CW</string>
<string name="nav_map">Peta</string>
<string name="nav_prefs">Pengaturan</string>
<string name="prefs_aprs_title">Pelaporan APRS</string>
<string name="prefs_aprs_enable">Aktifkan pelaporan APRS</string>
<string name="prefs_aprs_summary">Server: %1$s:%2$d Panggilan: %3$s</string>
<string name="prefs_aprs_report_now">Lapor Sekarang</string>
<string name="prefs_aprs_settings_title">Pengaturan APRS</string>
<string name="prefs_aprs_server">Server</string>
<string name="prefs_aprs_port">Port</string>
<string name="prefs_aprs_callsign">Tanda panggilan</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Kode sandi</string>
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string>
<string name="prefs_aprs_interval">Interval (menit)</string>
<string name="prefs_aprs_status">Teks status</string>
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
<string name="prefs_aprs_symbol_code">Kode simbol</string>
<string name="prefs_aprs_save">Simpan</string>
<string name="prefs_aprs_cancel">Batal</string>
<string name="aprs_notif_channel">Layanan APRS</string>
<string name="aprs_notif_title">APRS: %1$s</string>
<string name="aprs_notif_running">Pelaporan berjalan</string>
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
<string name="aprs_notif_error">Kesalahan koneksi</string>
<string name="aprs_notif_stop">Berhenti</string>
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">gagal - %1$s</string>
<string name="nav_more">Lainnya</string>
<string name="nav_log">Log</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">Status Satelit AMSAT</string>
<string name="amsat_refresh">Segarkan</string>
<string name="amsat_retry">Coba lagi</string>
<string name="amsat_updated">Diperbarui:</string>
<string name="amsat_active">Aktif</string>
<string name="amsat_tlm">TLM/beacon</string>
<string name="amsat_not_heard">Tidak terdengar</string>
<string name="amsat_conflict">Konflik</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
<string name="amsat_close">Tutup</string>
<string name="sat_type_hint">Jenis: %s</string>
<string name="sat_type_title">Pilih jenis satelit</string>
<string name="sat_search_hint">Id - Nama</string>
@@ -33,13 +77,13 @@
<string name="pass_filter_aos_time">Jendela AOS</string>
<string name="pass_filter_invert_time">Balik jendela AOS</string>
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
<string name="pass_time_placeholder"> -- : -- : -- </string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Pilih jenis modulasi</string>
<string name="pass_satId">%05d</string>
<string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Elevasi: %.1f°</string>
<string name="pass_deep_space">DeepSpace</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string>
<string name="pass_altitude">Ketinggian: %d km</string>
<string name="pass_aosLos">%03d° - %03d°</string>
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
<string name="pass_empty_list_message">
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
@@ -47,26 +91,30 @@
<string name="pass_welcome_message">
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
\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">
* 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="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
* Waterfall CW: skema warna inferno baru dengan gradasi halus
* Dukungan 64-bit (arm64) dipulihkan
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
</string>
<string name="radar_back">Kembali</string>
<string name="radar_notify">Beri tahu</string>
<string name="radar_az_text">Azimuth</string>
<string name="radar_az_value">%.1f°</string>
<string name="radar_az_value" translatable="false">%.1f°</string>
<string name="radar_el_text">Elevasi</string>
<string name="radar_el_value">%.1f°</string>
<string name="radar_el_value" translatable="false">%.1f°</string>
<string name="radar_alt_text">Ketinggian</string>
<string name="radar_alt_value">%.0f km</string>
<string name="radar_alt_value" translatable="false">%.0f km</string>
<string name="radar_dist_text">Jarak</string>
<string name="radar_dist_value">%.0f km</string>
<string name="radar_dist_value" translatable="false">%.0f km</string>
<string name="radar_eclipsed">Gerhana</string>
<string name="radar_downlink">Downlink</string>
<string name="radar_uplink">Uplink</string>
<string name="radar_link_low">%.4f</string>
<string name="radar_link_lowHigh">%.4f - %.4f</string>
<string name="radar_no_link">- - . - -</string>
<string name="radar_link_low" translatable="false">%.4f</string>
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
<string name="radar_no_link" translatable="false">- - . - -</string>
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
<string name="radar_add">Tambah</string>
<string name="radar_data">Transceiver satelit:</string>
@@ -84,34 +132,36 @@
<string name="radar_cw_start">Mulai</string>
<string name="radar_cw_stop">Berhenti</string>
<string name="radar_cw_reset">Bersihkan</string>
<string name="radar_cw_tone">Nada %1$d Hz</string>
<string name="radar_cw_waiting">Menunggu sinyal…</string>
<string name="map_prev">Sebelumnya</string>
<string name="map_next">Berikutnya</string>
<string name="map_copyright">© Kontributor OpenStreetMap</string>
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
<string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevasi: %.1f°</string>
<string name="map_altitude">Ketinggian: %.0f km</string>
<string name="map_distance">Jarak: %.0f km</string>
<string name="map_latitude">Lintang: %.1f°</string>
<string name="map_longitude">Bujur: %.1f°</string>
<string name="map_qth">QTH: %s</string>
<string name="map_qth" translatable="false">QTH: %s</string>
<string name="map_phase">Fase: %.1f°</string>
<string name="map_eclipsed">Gerhana</string>
<string name="map_period">Periode: %.0f mnt</string>
<string name="map_velocity">Kecepatan: %.2f km/d</string>
<string name="map_visibility">Visibilitas: %s</string>
<string name="map_visible">Terlihat</string>
<string name="prefs_app_title">Look4Sat Pro v%s</string>
<string name="prefs_app_url">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Donasi</string>
<string name="prefs_donate_url">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title">F-Droid</string>
<string name="prefs_fdroid_url">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
<string name="prefs_github_title">GitHub</string>
<string name="prefs_github_url">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_license_title">GPLv3</string>
<string name="prefs_license_url">https://www.gnu.org/licenses/gpl-3.0.html</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
<string name="prefs_github_title" translatable="false">GitHub</string>
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_license_title" translatable="false">GPLv3</string>
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
<string name="prefs_privacy_title">Kebijakan Privasi</string>
<string name="prefs_privacy_url">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_updated_title">Diperbarui: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
<string name="prefs_loc_title">Posisi stasiun</string>
@@ -141,7 +191,7 @@
<string name="prefs_data_sources_title">Sumber data kustom</string>
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
<string name="prefs_data_sources_url_title">URL (HTTPS)</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
<string name="prefs_data_output_title">Keluaran data</string>
<string name="prefs_net_output">Jaringan</string>
@@ -185,6 +235,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>
@@ -196,6 +247,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>
@@ -222,7 +276,7 @@
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
<string name="prefs_highlight_high">Tinggi &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA &amp; penulis asli mengucapkan terima kasih</string>
<string name="prefs_outro_thanks">
<string name="prefs_outro_thanks" translatable="false">
* Pengguna dan kontributor Look4Sat!
\n* David A. B. Johnson (predict4java)
\n* Dave Moten (predict4java)
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Look4Sat Pro</string>
<string name="app_name" translatable="false">Look4Sat Pro</string>
<string name="btn_accept">Terima</string>
<string name="btn_cancel">Batal</string>
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
@@ -12,8 +12,51 @@
<string name="nav_cw">Dekoder CW</string>
<string name="nav_map">Peta</string>
<string name="nav_prefs">Pengaturan</string>
<string name="prefs_aprs_title">Pelaporan APRS</string>
<string name="prefs_aprs_enable">Aktifkan pelaporan APRS</string>
<string name="prefs_aprs_summary">Server: %1$s:%2$d Panggilan: %3$s</string>
<string name="prefs_aprs_report_now">Lapor Sekarang</string>
<string name="prefs_aprs_settings_title">Pengaturan APRS</string>
<string name="prefs_aprs_server">Server</string>
<string name="prefs_aprs_port">Port</string>
<string name="prefs_aprs_callsign">Tanda panggilan</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Kode sandi</string>
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string>
<string name="prefs_aprs_interval">Interval (menit)</string>
<string name="prefs_aprs_status">Teks status</string>
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
<string name="prefs_aprs_symbol_code">Kode simbol</string>
<string name="prefs_aprs_save">Simpan</string>
<string name="prefs_aprs_cancel">Batal</string>
<string name="aprs_notif_channel">Layanan APRS</string>
<string name="aprs_notif_title">APRS: %1$s</string>
<string name="aprs_notif_running">Pelaporan berjalan</string>
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
<string name="aprs_notif_error">Kesalahan koneksi</string>
<string name="aprs_notif_stop">Berhenti</string>
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">gagal - %1$s</string>
<string name="nav_more">Lainnya</string>
<string name="nav_log">Log</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">Status Satelit AMSAT</string>
<string name="amsat_refresh">Segarkan</string>
<string name="amsat_retry">Coba lagi</string>
<string name="amsat_updated">Diperbarui:</string>
<string name="amsat_active">Aktif</string>
<string name="amsat_tlm">TLM/beacon</string>
<string name="amsat_not_heard">Tidak terdengar</string>
<string name="amsat_conflict">Konflik</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
<string name="amsat_close">Tutup</string>
<string name="sat_type_hint">Jenis: %s</string>
<string name="sat_type_title">Pilih jenis satelit</string>
<string name="sat_search_hint">Id - Nama</string>
@@ -33,13 +76,13 @@
<string name="pass_filter_aos_time">Jendela AOS</string>
<string name="pass_filter_invert_time">Balik jendela AOS</string>
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
<string name="pass_time_placeholder"> -- : -- : -- </string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Pilih jenis modulasi</string>
<string name="pass_satId">%05d</string>
<string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Elevasi: %.1f°</string>
<string name="pass_deep_space">DeepSpace</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string>
<string name="pass_altitude">Ketinggian: %d km</string>
<string name="pass_aosLos">%03d° - %03d°</string>
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
<string name="pass_empty_list_message">
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
@@ -47,26 +90,30 @@
<string name="pass_welcome_message">
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
\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">
* 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="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
* Waterfall CW: skema warna inferno baru dengan gradasi halus
* Dukungan 64-bit (arm64) dipulihkan
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
</string>
<string name="radar_back">Kembali</string>
<string name="radar_notify">Beri tahu</string>
<string name="radar_az_text">Azimuth</string>
<string name="radar_az_value">%.1f°</string>
<string name="radar_az_value" translatable="false">%.1f°</string>
<string name="radar_el_text">Elevasi</string>
<string name="radar_el_value">%.1f°</string>
<string name="radar_el_value" translatable="false">%.1f°</string>
<string name="radar_alt_text">Ketinggian</string>
<string name="radar_alt_value">%.0f km</string>
<string name="radar_alt_value" translatable="false">%.0f km</string>
<string name="radar_dist_text">Jarak</string>
<string name="radar_dist_value">%.0f km</string>
<string name="radar_dist_value" translatable="false">%.0f km</string>
<string name="radar_eclipsed">Gerhana</string>
<string name="radar_downlink">Downlink</string>
<string name="radar_uplink">Uplink</string>
<string name="radar_link_low">%.4f</string>
<string name="radar_link_lowHigh">%.4f - %.4f</string>
<string name="radar_no_link">- - . - -</string>
<string name="radar_link_low" translatable="false">%.4f</string>
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
<string name="radar_no_link" translatable="false">- - . - -</string>
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
<string name="radar_add">Tambah</string>
<string name="radar_data">Transceiver satelit:</string>
@@ -84,34 +131,36 @@
<string name="radar_cw_start">Mulai</string>
<string name="radar_cw_stop">Berhenti</string>
<string name="radar_cw_reset">Bersihkan</string>
<string name="radar_cw_tone">Nada %1$d Hz</string>
<string name="radar_cw_waiting">Menunggu sinyal…</string>
<string name="map_prev">Sebelumnya</string>
<string name="map_next">Berikutnya</string>
<string name="map_copyright">© Kontributor OpenStreetMap</string>
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
<string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevasi: %.1f°</string>
<string name="map_altitude">Ketinggian: %.0f km</string>
<string name="map_distance">Jarak: %.0f km</string>
<string name="map_latitude">Lintang: %.1f°</string>
<string name="map_longitude">Bujur: %.1f°</string>
<string name="map_qth">QTH: %s</string>
<string name="map_qth" translatable="false">QTH: %s</string>
<string name="map_phase">Fase: %.1f°</string>
<string name="map_eclipsed">Gerhana</string>
<string name="map_period">Periode: %.0f mnt</string>
<string name="map_velocity">Kecepatan: %.2f km/d</string>
<string name="map_visibility">Visibilitas: %s</string>
<string name="map_visible">Terlihat</string>
<string name="prefs_app_title">Look4Sat Pro v%s</string>
<string name="prefs_app_url">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Donasi</string>
<string name="prefs_donate_url">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title">F-Droid</string>
<string name="prefs_fdroid_url">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
<string name="prefs_github_title">GitHub</string>
<string name="prefs_github_url">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_license_title">GPLv3</string>
<string name="prefs_license_url">https://www.gnu.org/licenses/gpl-3.0.html</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
<string name="prefs_github_title" translatable="false">GitHub</string>
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_license_title" translatable="false">GPLv3</string>
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
<string name="prefs_privacy_title">Kebijakan Privasi</string>
<string name="prefs_privacy_url">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_updated_title">Diperbarui: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
<string name="prefs_loc_title">Posisi stasiun</string>
@@ -141,7 +190,7 @@
<string name="prefs_data_sources_title">Sumber data kustom</string>
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
<string name="prefs_data_sources_url_title">URL (HTTPS)</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
<string name="prefs_data_output_title">Keluaran data</string>
<string name="prefs_net_output">Jaringan</string>
@@ -185,6 +234,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>
@@ -196,6 +246,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>
@@ -223,7 +276,7 @@
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
<string name="prefs_highlight_high">Tinggi &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA &amp; penulis asli mengucapkan terima kasih</string>
<string name="prefs_outro_thanks">
<string name="prefs_outro_thanks" translatable="false">
* Pengguna dan kontributor Look4Sat!
\n* David A. B. Johnson (predict4java)
\n* Dave Moten (predict4java)
@@ -13,8 +13,51 @@
<string name="nav_cw">CW Dekoder</string>
<string name="nav_map">Harita</string>
<string name="nav_prefs">Ayarlar</string>
<string name="prefs_aprs_title">APRS Raporlama</string>
<string name="prefs_aprs_enable">APRS raporlamayı etkinleştir</string>
<string name="prefs_aprs_summary">Sunucu: %1$s:%2$d Çağrı: %3$s</string>
<string name="prefs_aprs_report_now">Şimdi Raporla</string>
<string name="prefs_aprs_settings_title">APRS Ayarları</string>
<string name="prefs_aprs_server">Sunucu</string>
<string name="prefs_aprs_port">Port</string>
<string name="prefs_aprs_callsign">Çağrı işareti</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Parola</string>
<string name="prefs_aprs_passcode_hint">Parola boş = çağrıdan otomatik hesaplanır</string>
<string name="prefs_aprs_interval">Aralık (dakika)</string>
<string name="prefs_aprs_status">Durum metni</string>
<string name="prefs_aprs_symbol_table">Sembol tablosu</string>
<string name="prefs_aprs_symbol_code">Sembol kodu</string>
<string name="prefs_aprs_save">Kaydet</string>
<string name="prefs_aprs_cancel">İptal</string>
<string name="aprs_notif_channel">APRS hizmeti</string>
<string name="aprs_notif_title">APRS: %1$s</string>
<string name="aprs_notif_running">Raporlama çalışıyor</string>
<string name="aprs_notif_connected">APRS-IS bağlı</string>
<string name="aprs_notif_error">Bağlantı hatası</string>
<string name="aprs_notif_stop">Durdur</string>
<string name="prefs_aprs_calc_passcode">Parolayı hesapla</string>
<string name="aprs_toast_ok">APRS: rapor gönderildi</string>
<string name="aprs_toast_fail">APRS: rapor başarısız - %1$s</string>
<string name="aprs_toast_not_configured">APRS yapılandırılmadı - önce çağrı girin</string>
<string name="prefs_aprs_last_report">Son rapor: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">başarısız - %1$s</string>
<string name="nav_more">Daha Fazla</string>
<string name="nav_log">Günlük</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT Uydu Durumu</string>
<string name="amsat_refresh">Yenile</string>
<string name="amsat_retry">Tekrar dene</string>
<string name="amsat_updated">Güncellendi:</string>
<string name="amsat_active">Aktif</string>
<string name="amsat_tlm">TLM/işaret</string>
<string name="amsat_not_heard">Duyulmadı</string>
<string name="amsat_conflict">Çelişkili</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Yükleme başarısız - ağı kontrol edip tekrar deneyin</string>
<string name="amsat_no_reports">Bu dönem için rapor yok</string>
<string name="amsat_close">Kapat</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Uydu türü: %s</string>
@@ -54,8 +97,12 @@
\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 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>
* YENİ: CW çözücü artık DeepCW sinir ağını kullanıyor — zayıf sinyal Morse çözümü çok daha iyi
* YENİ: CW çözüm geçmişi kalıcı olarak saklanır (metin artık kaybolmaz)
* CW şelale: yeni inferno renk şeması ve yumuşak geçişler
* 64-bit (arm64) desteği geri geldi
* Düzeltme: duraklat/devam artık hatalı "çözümleme başarısız" mesajı göstermiyor
</string>
<!-- Radar screen -->
<string name="radar_back">Geri</string>
@@ -195,6 +242,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>
@@ -206,6 +254,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>
@@ -245,5 +296,7 @@
\n• BA7OPF (pass matching feature)
\n• BG7NTA</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
<string name="radar_cw_tone">Ton %1$d Hz</string>
<string name="radar_cw_waiting">Sinyal bekleniyor…</string>
</resources>
@@ -14,8 +14,51 @@
<string name="nav_cw">CW解码</string>
<string name="nav_map">地图</string>
<string name="nav_prefs">设置</string>
<string name="prefs_aprs_title">APRS 上报</string>
<string name="prefs_aprs_enable">启用 APRS 上报</string>
<string name="prefs_aprs_summary">服务器: %1$s:%2$d 呼号: %3$s</string>
<string name="prefs_aprs_report_now">立即上报</string>
<string name="prefs_aprs_settings_title">APRS 设置</string>
<string name="prefs_aprs_server">服务器</string>
<string name="prefs_aprs_port">端口</string>
<string name="prefs_aprs_callsign">呼号</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">验证码</string>
<string name="prefs_aprs_passcode_hint">验证码留空 = 按呼号自动计算</string>
<string name="prefs_aprs_interval">周期(分钟)</string>
<string name="prefs_aprs_status">状态文本</string>
<string name="prefs_aprs_symbol_table">符号表</string>
<string name="prefs_aprs_symbol_code">符号码</string>
<string name="prefs_aprs_save">保存</string>
<string name="prefs_aprs_cancel">取消</string>
<string name="aprs_notif_channel">APRS 服务</string>
<string name="aprs_notif_title">APRS: %1$s</string>
<string name="aprs_notif_running">上报运行中</string>
<string name="aprs_notif_connected">已连接 APRS-IS</string>
<string name="aprs_notif_error">连接错误</string>
<string name="aprs_notif_stop">停止</string>
<string name="prefs_aprs_calc_passcode">计算验证码</string>
<string name="aprs_toast_ok">APRS: 上报成功</string>
<string name="aprs_toast_fail">APRS: 上报失败 - %1$s</string>
<string name="aprs_toast_not_configured">APRS 未配置,请先填呼号</string>
<string name="prefs_aprs_last_report">上次上报: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">成功</string>
<string name="prefs_aprs_last_fail">失败 - %1$s</string>
<string name="nav_more">更多</string>
<string name="nav_log">日志</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT 卫星状态</string>
<string name="amsat_refresh">刷新</string>
<string name="amsat_retry">重试</string>
<string name="amsat_updated">更新:</string>
<string name="amsat_active">活跃</string>
<string name="amsat_tlm">仅遥测</string>
<string name="amsat_not_heard">未听到</string>
<string name="amsat_conflict">矛盾</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">加载失败,请检查网络后重试</string>
<string name="amsat_no_reports">该时段无报告</string>
<string name="amsat_close">关闭</string>
<!-- Satellites screen -->
<string name="sat_type_hint">类型:%s</string>
@@ -44,8 +87,12 @@
<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 同时支持 API v1 和 v2(自动识别); 错误弹窗带一键复制
* 日志频率与多普勒面板一致; 线性转发器显示完整频段区间</string>
* 新: CW 解码改用 DeepCW 神经网络,弱信号摩尔斯码解码率大幅提升
* 新: CW 解码历史永久保留(文字不再消失)
* CW 瀑布图: 全新 inferno 配色与平滑渐变
* 恢复 64 位(arm64)支持
* 修复: 暂停/恢复不再误报"解码失败"
</string>
<!-- Radar screen -->
<string name="radar_back">后退</string>
@@ -76,6 +123,8 @@
<string name="radar_cw_start">开始</string>
<string name="radar_cw_stop">停止</string>
<string name="radar_cw_reset">清空</string>
<string name="radar_cw_tone">音调 %1$d Hz</string>
<string name="radar_cw_waiting">等待信号…</string>
<!-- Map screen -->
<string name="map_prev">上一个</string>
@@ -100,7 +149,7 @@
<string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
<string name="prefs_loc_title">站位</string>
<string name="prefs_loc_title">站点位置</string>
<string name="prefs_loc_gps_title">GPS 定位</string>
<string name="prefs_loc_gps_error">无法获取位置,请检查定位权限和GPS/网络信号</string>
<string name="prefs_loc_input_title">输入位置</string>
@@ -108,11 +157,11 @@
<string name="prefs_loc_qth_title">QTH 网格</string>
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
<string name="prefs_loc_success">位置更新成功</string>
<string name="prefs_station_title">站位设置</string>
<string name="prefs_station_lat_text">输入站位的纬度</string>
<string name="prefs_station_lon_text">输入站位的经度</string>
<string name="prefs_station_title">站点位置设置</string>
<string name="prefs_station_lat_text">输入站点的纬度</string>
<string name="prefs_station_lon_text">输入站点的经度</string>
<string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站位</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
<string name="prefs_data_title">卫星数据更新</string>
<string name="prefs_data_entries">卫星:%s</string>
@@ -178,6 +227,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>
@@ -189,6 +239,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>
@@ -15,8 +15,51 @@
<string name="nav_cw">CW Decoder</string>
<string name="nav_map">Map</string>
<string name="nav_prefs">Settings</string>
<string name="prefs_aprs_title">APRS Reporting</string>
<string name="prefs_aprs_enable">Enable APRS reporting</string>
<string name="prefs_aprs_summary">Server: %1$s:%2$d Callsign: %3$s</string>
<string name="prefs_aprs_report_now">Report Now</string>
<string name="prefs_aprs_settings_title">APRS Settings</string>
<string name="prefs_aprs_server">Server</string>
<string name="prefs_aprs_port">Port</string>
<string name="prefs_aprs_callsign">Callsign</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Passcode</string>
<string name="prefs_aprs_passcode_hint">Passcode empty = auto-computed from callsign</string>
<string name="prefs_aprs_interval">Interval (min)</string>
<string name="prefs_aprs_status">Status text</string>
<string name="prefs_aprs_symbol_table">Symbol table</string>
<string name="prefs_aprs_symbol_code">Symbol code</string>
<string name="prefs_aprs_save">Save</string>
<string name="prefs_aprs_cancel">Cancel</string>
<string name="aprs_notif_channel">APRS service</string>
<string name="aprs_notif_title">APRS: %1$s</string>
<string name="aprs_notif_running">Reporting running</string>
<string name="aprs_notif_connected">Connected to APRS-IS</string>
<string name="aprs_notif_error">Connection error</string>
<string name="aprs_notif_stop">Stop</string>
<string name="prefs_aprs_calc_passcode">Compute passcode</string>
<string name="aprs_toast_ok">APRS: report sent OK</string>
<string name="aprs_toast_fail">APRS: report failed - %1$s</string>
<string name="aprs_toast_not_configured">APRS not configured - set callsign first</string>
<string name="prefs_aprs_last_report">Last report: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">failed - %1$s</string>
<string name="nav_more">More</string>
<string name="nav_log">Log</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT Satellite Status</string>
<string name="amsat_refresh">Refresh</string>
<string name="amsat_retry">Retry</string>
<string name="amsat_updated">Updated:</string>
<string name="amsat_active">Active</string>
<string name="amsat_tlm">TLM/Beacon</string>
<string name="amsat_not_heard">Not Heard</string>
<string name="amsat_conflict">Conflicting</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Load failed - check network and retry</string>
<string name="amsat_no_reports">No reports for this period</string>
<string name="amsat_close">Close</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Type: %s</string>
@@ -56,8 +99,12 @@
\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">
* 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>
* NEW: CW decoder now uses the DeepCW neural network — far better weak-signal Morse decoding
* NEW: CW decode history is kept permanently (text no longer disappears)
* CW waterfall: new inferno colour scheme with smooth gradients
* Restored 64-bit (arm64) support
* Fixed: pause/resume no longer shows a spurious "decode failed" error
</string>
<!-- Radar screen -->
<string name="radar_back">Back</string>
@@ -94,6 +141,8 @@
<string name="radar_cw_start">Start</string>
<string name="radar_cw_stop">Stop</string>
<string name="radar_cw_reset">Clear</string>
<string name="radar_cw_tone">Tone %1$d Hz</string>
<string name="radar_cw_waiting">Waiting for signal…</string>
<!-- Map screen -->
<string name="map_prev">Prev</string>
@@ -209,6 +258,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>
@@ -220,6 +270,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>
@@ -261,5 +314,6 @@
\n* BG7NTA
</string>
<string name="prefs_outro_license">The app comes with no warranty</string>
<string name="prefs_outro_deepcw" translatable="false">CW decoding uses the DeepCW model by e04, licensed under AGPL-3.0-only.\nhttps://github.com/e04/deepcw-engine</string>
</resources>
+96
View File
@@ -0,0 +1,96 @@
# DeepCW Decoder — module documentation
The `feature:cw` module decodes Morse code (CW) with the DeepCW neural model
instead of a classical DSP detector. This document records the architecture,
the model provenance, the pitfalls we hit on real devices, and the verification
results. It exists so the next person does not have to re-derive any of it.
## Architecture
```
microphone (44.1 kHz PCM float)
│ AudioCapture.audioFlow() → ~100 ms chunks
▼
CwDecodeScreen (feature:cw, pure Compose)
│ decoder.processBuffer(chunk) waterfall.pushSamples(chunk)
▼ ▼
CwDeepDecoder (core:data) CwWaterfallState (feature:cw)
resample → 3200 Hz resample → 3200 Hz
CwDeepBuffer (20 s rolling) CwDeepSpectrogram.compute
CwDeepSpectrogram.compute → rolling spectrogram history
ONNX Runtime session.run → Canvas waterfall
CwCtcDecoder.greedy (400–1200 Hz band, same magnitudes
→ decodedText StateFlow the model sees)
```
**Why the split.** `core:domain` must stay pure Kotlin/JVM (KMP migration
headroom, per `AGENTS.md`), so the ONNX Runtime dependency — which ships native
libraries — lives in `core:data` behind the `ICwDecoder` interface. The
spectrogram front-end (`CwDeepSpectrogram`) and CTC collapse (`CwCtcDecoder`)
are pure Kotlin and live in `core:domain`, where they are unit-tested against
the Python reference with golden vectors.
**Streaming model, not incremental.** DeepCW is a whole-utterance CTC model. It
rewrites earlier characters as more context arrives, so incremental appending is
wrong. The decoder keeps a rolling buffer capped at 20 s and re-runs the whole
window every 1.5 s, replacing the displayed text outright. 20 s is the smallest
window that hits 0 % CER on the reference clip *and* the largest that keeps
inference comfortably faster than real time (measured ~14× headroom on a server
CPU; timed per-window on device and reported via `lastInferenceMs`).
## Model
| | fp32 (original) | int8 (shipped) |
|---|---|---|
| Size | 15,139,839 bytes | 4,248,808 bytes |
| Derivation | — | `quantize_dynamic` (weights → QUInt8, activations float32) |
| Input | `spectrogram` [1,1,T,65] float32 | unchanged |
| Output | `log_probs` [1,T,42] float32 | unchanged |
| In APK | no (available as a release asset) | yes (`assets/deepcw/model.onnx`) |
The int8 model ships inside the APK: it is ~4× smaller and measurably identical
to fp32 on synthetic CW at SNR ≥ −4 dB (both degrade together below that). The
fp32 model is published as a separate release asset for anyone who wants the
highest-fidelity reference. Both are AGPL-3.0-only — see
[`licenses/NOTICE.md`](licenses/NOTICE.md) for provenance, commit SHA and hashes.
Audio must be packaged **uncompressed** (`noCompress += "onnx"` in the app
module): ONNX Runtime mmap's assets and refuses compressed ones.
## Pitfalls fixed on real devices (release-only)
1. **R8 minification breaks the JNI binding.** `isMinifyEnabled = true` in the
convention plugin, but `onnxruntime-android` ships **no** consumer ProGuard
rules, so `ai.onnxruntime.*` got renamed and native code crashed with no Java
stack trace. Fixed with
`-keep class ai.onnxruntime.** { *; }` in `app/proguard-rules.pro`.
Symptom was the exact "flash to home screen, empty crash log" we chased.
2. **Model load in `init{}`** took down the whole composable on failure; moved
to lazy `ensureLoaded()` with an `errorMessage` path.
3. **Default thread count** saturated all cores and starved audio capture/UI;
capped `setIntraOpNumThreads` to `cores−1` (max 4).
4. **Compose snapshot state written from the audio thread** crashes at runtime
with no log from business-class instrumentation; the waterfall now uses a
`MutableStateFlow` revision counter, and both the row deque and the pending
buffer are lock-guarded.
5. **`LaunchedEffect { launch { collect() } }`** leaked a second `AudioRecord`
when toggling; collection now runs directly in the effect body keyed on
`(isListening, permissionGranted)`.
Diagnostics (no-adb): `MainApplication` already writes uncaught exceptions to
`files/crash_log.txt`. The decoder additionally persists load/inference failures
to `files/deepcw_load_error.txt` / `files/deepcw_infer_error.txt` and step
markers to `files/probe_cw.txt` (`load_begin → load_session_ok → infer_begin →
infer_done`).
## Verification
- **Golden vectors**: `CwDeepSpectrogramTest` + `CwDeepGoldenVectorTest` pin the
Kotlin spectrogram to the Python reference across 5005 values (tolerance
1e-4). 79 domain tests pass.
- **End-to-end on server**: real 20 s noisy CW audio → resample → spectrogram →
ONNX → greedy CTC reproduces `CQ CQ DE BG7NTA BG7NTA K 5NN TU 73`
character-for-character (~1 s inference, fp32).
- **On device (release APK)**: decodes 40 WPM CW at high accuracy. Some UI
latency is expected on the first decode cycle (model load + first inference);
sustained decoding stays real-time.
+2 -18
View File
@@ -1,4 +1,3 @@
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.CanProduceConsumerProguardFiles
plugins {
@@ -7,19 +6,8 @@ plugins {
android {
namespace = "com.rtbishop.look4sat.feature.cw"
compileOptions {
encoding = "UTF-8"
}
// 照搬 Morse Expert 1.15: native 解码器仅 armeabi-v7a
defaultConfig {
ndk {
abiFilters += listOf("armeabi-v7a")
}
}
}
// CW 类保持规则(JNI 按类名注册 + 照搬混淆类保逻辑), 由 app 的 R8 消费
// AGP 9: consumer 规则走 variant 级 CanProduceConsumerProguardFiles
androidComponents {
onVariants(selector().all()) { variant ->
(variant as? CanProduceConsumerProguardFiles)?.consumerProguardFiles?.add(
@@ -29,10 +17,6 @@ androidComponents {
}
dependencies {
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
}
// 照搬的 Java 源码含中文注释, 强制 UTF-8 编译(compileOptions 在部分 AGP 版本不生效)
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
implementation(project(":core:domain"))
implementation(project(":core:data"))
}
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+78
View File
@@ -0,0 +1,78 @@
# Third-Party Notices — CW Decode Module
## DeepCW neural CW decoding model
The CW (Morse code) decoder in this module performs inference with a neural
network model obtained from the DeepCW project.
| | |
|---|---|
| **Component** | `src/main/assets/deepcw/model.onnx` and `model.onnx.json` |
| **Upstream project** | DeepCW / deepcw-engine |
| **Source repository** | https://github.com/e04/deepcw-engine |
| **Author / copyright** | e04 |
| **License** | GNU Affero General Public License v3.0 only (AGPL-3.0-only) |
| **License text** | [`DeepCW-AGPL-3.0.txt`](DeepCW-AGPL-3.0.txt) |
| **Obtained at commit** | `8e264d243bbd4467bd19f3f28292219405b47e0e` |
| **Original file size** | 15,139,839 bytes |
| **Original SHA-256** | `ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02` |
| **Derived file size** | 4,248,808 bytes |
| **Derived SHA-256** | `cd48259be0ea8c30ecbfff4a718644f361cb27b9228b030771b0c94756dcab98` |
| **Derivation** | Dynamic int8 quantization (weights → QUInt8, activations stay float32) via `onnxruntime.quantization.quantize_dynamic`. Input/output names, shapes and dtypes are unchanged. Measured CER on synthetic CW audio is identical to the fp32 model at SNR >= -4 dB; at -6/-8 dB both models degrade similarly. |
Related upstream repositories by the same author (not vendored here):
- https://github.com/e04/web-deep-cw-decoder — reference web application
- https://github.com/e04/HamNoise — neural noise reduction (not used)
### License compatibility
Look4Sat is licensed under the GNU General Public License v3.0 or later
(GPL-3.0-or-later). The DeepCW model is licensed under AGPL-3.0-only.
Section 13 of the GPL version 3 expressly permits combining GPL-3.0 covered
work with AGPL-3.0 covered work; the resulting combination may be conveyed,
with the AGPL's additional network-interaction requirement applying to the
AGPL-covered portion. Accordingly:
- The Look4Sat source code remains under GPL-3.0-or-later.
- The DeepCW model remains under AGPL-3.0-only.
- Distributions of the combined application are accompanied by complete
corresponding source, satisfying both licenses.
### AGPL section 13 (network interaction)
Inference runs entirely on the local device via ONNX Runtime. The application
does not offer the model's functionality to users interacting with it remotely
over a network, so the additional network-source-offer requirement of AGPL-3.0
section 13 is not triggered by this usage. The complete corresponding source
for both the application and the vendored model remains publicly available at
the repository hosting this file.
### Reproducing the vendored files
```bash
# 1) Fetch the original fp32 model
SHA=8e264d243bbd4467bd19f3f28292219405b47e0e
curl -sLO https://raw.githubusercontent.com/e04/deepcw-engine/$SHA/model.onnx
curl -sLO https://raw.githubusercontent.com/e04/deepcw-engine/$SHA/model.onnx.json
curl -sL -o DeepCW-AGPL-3.0.txt \
https://raw.githubusercontent.com/e04/deepcw-engine/$SHA/LICENSE
sha256sum model.onnx
# expected: ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02
# 2) Reproduce the int8 quantization this repository ships
python - <<'PY'
from onnxruntime.quantization import quantize_dynamic, QuantType
quantize_dynamic("model.onnx", "model_int8.onnx", weight_type=QuantType.QUInt8)
PY
sha256sum model_int8.onnx
# expected: cd48259be0ea8c30ecbfff4a718644f361cb27b9228b030771b0c94756dcab98
# then copy model_int8.onnx over assets/deepcw/model.onnx
```
## ONNX Runtime
Inference engine: `com.microsoft.onnxruntime:onnxruntime-android`, licensed
under the MIT License. Consumed as a published Maven artifact; not vendored in
this repository.
+2 -31
View File
@@ -1,31 +1,2 @@
# Look4Sat Pro — CW 解码模块(照搬 Morse Expert 1.15)混淆规则
# 所有 CW 类保持原名: pas.* 通过 RegisterNatives 按类名注册 JNI(混淆即崩);
# 其余为照搬的混淆名类(命名已无混淆意义, 保持以保逻辑完整)。
# JNI 引擎(RegisterNatives 按类名/方法名查表)
-keep class pas.** { *; }
# 照搬的解码/UI 类(全部保持)
-keep class com.ve3nea.morse_expert.** { *; }
-keep class H2.** { *; }
-keep class I2.b { *; }
-keep class J2.** { *; }
-keep class k3.** { *; }
-keep class i3.** { *; }
-keep class g3.** { *; }
-keep class j3.** { *; }
-keep class s.** { *; }
-keep class d1.AbstractC1518b { *; }
-keep class B0.b { *; }
-keep class B.RunnableC0001b { *; }
-keep class D.n { *; }
-keep class E2.g { *; }
-keep class F2.a { *; }
-keep class I0.d { *; }
-keep class K1.a { *; }
-keep class j1.C1646n { *; }
# sun.misc stub(Android 无此类, 仅编译期; 防止 R8 异常处理)
-dontwarn sun.misc.**
-keep class sun.misc.Unsafe { *; }
-keep class sun.misc.Cleaner { *; }
# CW 模块无 native 代码、无按类名注册的 JNI,因此不需要任何 keep 规则。
# DeepCW 走 ONNX Runtime(自带 consumer proguard 规则),前后处理为普通 Kotlin。
Binary file not shown.
@@ -0,0 +1,70 @@
{
"chars": [
",",
".",
"/",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"?",
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
" "
],
"blank_index": 41,
"sample_rate": 3200,
"fft_length": 256,
"hop_length": 48,
"spectrogram_min_freq_hz": 400.0,
"spectrogram_max_freq_hz": 1200.0,
"spectrogram_frequency_bins": 65,
"normalization": "log1p",
"onnx_input_name": "spectrogram",
"onnx_output_name": "log_probs",
"onnx_input_layout": [
"batch",
"channel",
"time",
"frequency"
],
"onnx_output_layout": [
"batch",
"time",
"class"
],
"channel_count": 1,
"num_classes": 42,
"onnx_input_dtype": "float32",
"onnx_output_dtype": "float32"
}
@@ -1,63 +0,0 @@
package B;
import android.text.SpannableStringBuilder;
import com.ve3nea.morse_expert.DecodedTextView;
import com.ve3nea.morse_expert.MainActivity;
import H2.b;
/**
* 照搬自 Morse Expert 1.15 B.RunnableC0001b, 类名保持混淆原名。
* R8 合并 Runnable 手术: 构造按 smali 真实签名 5 参 (Object,Object,int,int,boolean),
* 字段 = i(Object)/h(Object)/g(int)/f(int); run() 仅保留 case 4(f==2 对应 pswitch_4:
* 解码文本 UI 更新, H2.b 调用), 其余 case(权限/Intent/通知等库代码)已裁剪。
* 注: jadx 4 参构造为错误还原, 已按 smali 修正。
*/
public final class RunnableC0001b implements Runnable {
public final /* synthetic */ int f;
/* renamed from: g, reason: collision with root package name */
public final int g;
/* renamed from: h, reason: collision with root package name */
public final Object h;
/* renamed from: i, reason: collision with root package name */
public final Object i;
public /* synthetic */ RunnableC0001b(Object obj, Object obj2, int i4, int i5, boolean z) {
this.i = obj;
this.h = obj2;
this.g = i4;
this.f = i5;
}
@Override // java.lang.Runnable
public final void run() {
if (this.f == 2) {
MainActivity mainActivity = ((b) this.i).f620p;
if (mainActivity != null) {
SpannableStringBuilder spannableStringBuilder = (SpannableStringBuilder) this.h;
int i5 = this.g;
DecodedTextView decodedTextView = (DecodedTextView) mainActivity.f11035D.f11888g;
if (!decodedTextView.f11032n && !decodedTextView.f11033o.get()) {
decodedTextView.beginBatchEdit();
try {
int length2 = decodedTextView.length();
int min = Math.min(length2, i5);
if (min > 0) {
decodedTextView.getEditableText().delete(length2 - min, length2);
}
decodedTextView.q(spannableStringBuilder);
decodedTextView.endBatchEdit();
return;
} catch (Throwable th) {
decodedTextView.endBatchEdit();
throw th;
}
}
return;
}
return;
}
}
}
-61
View File
@@ -1,61 +0,0 @@
package B0;
import android.widget.TextView;
import com.ve3nea.morse_expert.MainActivity;
import com.ve3nea.morse_expert.ScaleView;
import java.util.Locale;
import d1.AbstractC1518b;
/**
* 照搬自 Morse Expert 1.15 B0.b, 类名保持混淆原名。
* R8 合并 Runnable 手术: 构造按 smali 真实签名 (H2/b, String, [I) -> f=4;
* 只保留 case 4(状态栏更新: dBFS/Hz/dB/WPM + ScaleView 频率标记, H2.b 调用),
* 其余 case(WorkManager/动画等库代码)已裁剪。
* r3 = scaleView.getHeight()(smali 证实, jadx 寄存器未展开)。
*/
public final class b implements Runnable {
public final /* synthetic */ int f;
/* renamed from: g, reason: collision with root package name */
public final Object g;
/* renamed from: h, reason: collision with root package name */
public final Object h;
/* renamed from: i, reason: collision with root package name */
public final Object i;
public b(H2.b bVar, String str, int[] iArr) {
this.f = 4;
this.g = bVar;
this.h = iArr;
this.i = str;
}
@Override // java.lang.Runnable
public final void run() {
if (this.f == 4) {
MainActivity mainActivity = ((H2.b) this.g).f621q;
if (mainActivity != null) {
String str2 = (String) this.i;
int[] iArr = (int[]) this.h;
String concat;
String format = String.format(Locale.US, " %3d dBFS", Integer.valueOf(iArr[0]));
if (iArr[5] == 0) {
concat = format.concat(" Idle ");
} else {
concat = format.concat(String.format(Locale.US, " %4d Hz %2d dB %2d WPM ", Integer.valueOf(iArr[1]), Integer.valueOf(iArr[2]), Integer.valueOf(iArr[3])));
}
((TextView) mainActivity.f11035D.f11890i).setText(AbstractC1518b.e(concat, str2));
ScaleView scaleView = (ScaleView) mainActivity.f11035D.f11889h;
int i4 = iArr[1];
scaleView.f11041g = iArr[5];
// r3 = scaleView.getHeight()(smali 证实)
scaleView.f = (scaleView.getHeight() - 1) - Math.round(((Math.round(i4 * 0.128f) - H2.b.f606z) * scaleView.getHeight()) / H2.b.f603B);
scaleView.invalidate();
return;
}
return;
}
}
}
-69
View File
@@ -1,69 +0,0 @@
package D;
import android.graphics.Bitmap;
import android.opengl.GLES20;
import android.opengl.GLUtils;
import com.ve3nea.morse_expert.WaterfallSurfaceView;
/**
* 照搬自 Morse Expert 1.15, 类名保持混淆原名。
* R8 合并 Runnable 手术: 只保留 case 1(瀑布图频谱->OpenGL 纹理, H2.b 调用),
* 其余 case(库/广告)已裁剪。
*/
public final /* synthetic */ class n implements Runnable {
public final /* synthetic */ int f;
/* renamed from: g, reason: collision with root package name */
public final /* synthetic */ Object f224g;
/* renamed from: h, reason: collision with root package name */
public final /* synthetic */ Object f225h;
public /* synthetic */ n(Object obj, int i4, Object obj2) {
this.f = i4;
this.f224g = obj;
this.f225h = obj2;
}
@Override // java.lang.Runnable
public final void run() {
int i4 = this.f;
Object obj = this.f225h;
Object obj2 = this.f224g;
if (i4 == 1) {
float[] fArr = (float[]) obj;
J2.b bVar = ((WaterfallSurfaceView) obj2).f;
Bitmap bitmap = bVar.f733h;
int width = bVar.f730d.getWidth();
double d4 = width;
double b4 = (bVar.b() + 4.0d) % d4;
double d5 = bVar.f737l - b4;
if (d5 > d4 / 2.0d) {
d5 -= d4;
} else if (d5 < (-width) / 2.0d) {
d5 += d4;
}
if (Math.abs(d5) > 8.0d) {
bVar.f737l = (int) Math.floor(b4);
} else {
bVar.f732g = (d5 * 1.0E-5d) + bVar.f732g;
}
int height = bitmap.getHeight() - 1;
for (int i5 = 0; i5 <= height; i5++) {
J2.a aVar = bVar.f734i;
int round = Math.round((fArr[i5] * 4.0f) + 30.0f);
aVar.getClass();
bitmap.setPixel(0, height - i5, aVar.f727a[Math.max(0, Math.min(255, round))]);
}
int i6 = bVar.f737l;
GLES20.glBindTexture(3553, bVar.f728a[0]);
J2.b.a();
GLUtils.texSubImage2D(3553, 0, i6, 0, bitmap);
J2.b.a();
int i7 = bVar.f737l + 1;
bVar.f737l = i7;
bVar.f737l = i7 % width;
return;
}
}
}
-44
View File
@@ -1,44 +0,0 @@
package E2;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import com.ve3nea.morse_expert.MainActivity;
import com.ve3nea.morse_expert.ScaleView;
/**
* 照搬自 Morse Expert 1.15 (com.ve3nea.morse_expert), 类名保持混淆原名。
* R8 合并类手术: 只保留 case 1(瀑布图触摸选频率=应用逻辑),
* 其余 case(广告 P4 / appcompat 焦点 / 弹窗)为库代码, 已裁剪。
*/
public final class g implements View.OnTouchListener {
public final /* synthetic */ int f;
/* renamed from: g, reason: collision with root package name */
public final /* synthetic */ Object f411g;
public /* synthetic */ g(Object obj, int i4) {
this.f = i4;
this.f411g = obj;
}
@Override // android.view.View.OnTouchListener
public final boolean onTouch(View view, MotionEvent motionEvent) {
int i4 = this.f;
Object obj = this.f411g;
if (i4 == 1) {
MainActivity mainActivity = (MainActivity) obj;
if (motionEvent.getActionMasked() == 0 && mainActivity.f11037F != null) {
// smali 证实: p1 先被重赋值为 f11035D.h(ScaleView)再 getHeight;
// 公式 = round((f606z + ((scaleH-1-y)*f603B/scaleH)) / 0.128f)
ScaleView scaleView = (ScaleView) mainActivity.f11035D.f11889h;
float y3 = motionEvent.getY();
int round = Math.round((H2.b.f606z + ((((scaleView.getHeight() - 1) - y3) * H2.b.f603B) / scaleView.getHeight())) / 0.128f);
mainActivity.f11037F.f622r.set(round);
Log.i("Waterfall", String.format("Touch at %d Hz`", Integer.valueOf(round)));
}
return false;
}
return false;
}
}
-53
View File
@@ -1,53 +0,0 @@
package F2;
import com.rtbishop.look4sat.feature.cw.R;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuItem;
import com.ve3nea.morse_expert.DecodedTextView;
/* loaded from: classes.dex */
public final class a implements ActionMode.Callback {
/* renamed from: a, reason: collision with root package name */
public final /* synthetic */ DecodedTextView f564a;
public a(DecodedTextView decodedTextView) {
this.f564a = decodedTextView;
}
@Override // android.view.ActionMode.Callback
public final boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) {
int itemId = menuItem.getItemId();
DecodedTextView decodedTextView = this.f564a;
if (itemId != 73) {
if (itemId != 74) {
return false;
}
decodedTextView.s();
return true;
}
decodedTextView.r();
actionMode.finish();
return true;
}
@Override // android.view.ActionMode.Callback
public final boolean onCreateActionMode(ActionMode actionMode, Menu menu) {
this.f564a.f11032n = true;
// 原: if (menu.findItem(R.id.shareText) == null) 检查(shareText 为原 APK 资源, 简化后始终添加)
menu.add(0, 74, 0, "Share").setIcon(R.drawable.ic_baseline_share_24);
menu.add(0, 73, 0, "Save").setIcon(R.drawable.ic_baseline_save_24);
return true;
}
@Override // android.view.ActionMode.Callback
public final void onDestroyActionMode(ActionMode actionMode) {
this.f564a.f11032n = false;
}
@Override // android.view.ActionMode.Callback
public final boolean onPrepareActionMode(ActionMode actionMode, Menu menu) {
return false;
}
}
-82
View File
@@ -1,82 +0,0 @@
package H2;
import android.media.AudioRecord;
import android.util.Log;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/* loaded from: classes.dex */
public final class a implements Runnable {
public final int f;
/* renamed from: g, reason: collision with root package name */
public final AudioRecord f596g;
/* renamed from: h, reason: collision with root package name */
public final ExecutorService f597h;
/* renamed from: i, reason: collision with root package name */
public final AtomicBoolean f598i;
/* renamed from: j, reason: collision with root package name */
public final short[] f599j;
/* renamed from: k, reason: collision with root package name */
public final float[] f600k;
/* renamed from: l, reason: collision with root package name */
public b f601l;
public a() {
int minBufferSize = AudioRecord.getMinBufferSize(8000, 16, 2);
int i4 = b.f604C;
this.f = i4;
ExecutorService newSingleThreadExecutor = Executors.newSingleThreadExecutor();
this.f597h = newSingleThreadExecutor;
AtomicBoolean atomicBoolean = new AtomicBoolean(false);
this.f598i = atomicBoolean;
this.f599j = new short[i4];
this.f600k = new float[i4];
AudioRecord audioRecord = new AudioRecord(1, 8000, 16, 2, minBufferSize * 4);
this.f596g = audioRecord;
if (audioRecord.getState() == 1) {
audioRecord.startRecording();
if (audioRecord.getState() != 1) {
Log.e("AudioRecord", "cannot start");
}
atomicBoolean.set(true);
newSingleThreadExecutor.execute(this);
return;
}
Log.e("AudioInput", "Failed to initialize");
}
@Override // java.lang.Runnable
public final void run() {
while (true) {
try {
if (this.f598i.get()) {
float[] fArr = this.f600k;
AudioRecord audioRecord = this.f596g;
short[] sArr = this.f599j;
int i4 = this.f;
if (audioRecord.read(sArr, 0, i4) == i4) {
for (int i5 = 0; i5 < i4; i5++) {
fArr[i5] = sArr[i5] / 32768.0f;
}
b bVar = this.f601l;
if (bVar != null) {
bVar.a(fArr);
}
}
} else {
TimeUnit.MILLISECONDS.sleep(100L);
}
} catch (Exception e4) {
Log.e("Audio thread", "Exception: ", e4);
}
}
}
}
-582
View File
@@ -1,582 +0,0 @@
package H2;
import B.RunnableC0001b;
import D.n;
import android.content.Context;
import android.media.MediaScannerConnection;
import android.os.Handler;
import android.os.Looper;
import android.text.SpannableStringBuilder;
import android.text.format.DateFormat;
import android.text.style.ForegroundColorSpan;
import android.util.Log;
import com.ve3nea.morse_expert.MainActivity;
import com.ve3nea.morse_expert.WaterfallSurfaceView;
import i3.d;
import java.io.BufferedOutputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.Date;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import k3.s;
import pas.decoder;
import pas.nativedecoder;
/* loaded from: classes.dex */
public final class b {
/* renamed from: A, reason: collision with root package name */
public static final int f602A;
/* renamed from: B, reason: collision with root package name */
public static final int f603B;
/* renamed from: C, reason: collision with root package name */
public static final int f604C;
/* renamed from: D, reason: collision with root package name */
public static final d f605D;
/* renamed from: z, reason: collision with root package name */
public static final int f606z;
/* renamed from: a, reason: collision with root package name */
public final float[] f607a;
/* renamed from: b, reason: collision with root package name */
public final float[] f608b;
public final float[] c;
/* renamed from: d, reason: collision with root package name */
public final float[] f609d;
/* renamed from: e, reason: collision with root package name */
public final nativedecoder.TNativeDecoder f610e;
public int f;
/* renamed from: g, reason: collision with root package name */
public int f611g;
/* renamed from: h, reason: collision with root package name */
public int f612h;
/* renamed from: i, reason: collision with root package name */
public decoder.TDecoderState f613i;
/* renamed from: j, reason: collision with root package name */
public String f614j;
/* renamed from: k, reason: collision with root package name */
public final c f615k;
/* renamed from: l, reason: collision with root package name */
public int f616l;
/* renamed from: m, reason: collision with root package name */
public final I2.b f617m;
/* renamed from: n, reason: collision with root package name */
public final Handler f618n;
/* renamed from: o, reason: collision with root package name */
public WaterfallSurfaceView f619o;
/* renamed from: p, reason: collision with root package name */
public MainActivity f620p;
/* renamed from: q, reason: collision with root package name */
public MainActivity f621q;
/* renamed from: r, reason: collision with root package name */
public final AtomicInteger f622r;
/* renamed from: s, reason: collision with root package name */
public final AtomicBoolean f623s;
/* renamed from: t, reason: collision with root package name */
public final Context f624t;
/* renamed from: u, reason: collision with root package name */
public int f625u;
/* renamed from: v, reason: collision with root package name */
public final AtomicBoolean f626v;
/* renamed from: w, reason: collision with root package name */
public DataOutputStream f627w;
/* renamed from: x, reason: collision with root package name */
public int f628x;
/* renamed from: y, reason: collision with root package name */
public String f629y;
/* JADX WARN: Removed duplicated region for block: B:14:0x012d */
/* JADX WARN: Type inference failed for: r0v4, types: [i3.d, java.lang.Object] */
static {
boolean z3;
float f;
float f4;
float f5;
int round = Math.round(25.6f);
f606z = round;
int round2 = Math.round(153.6f);
f602A = round2;
f603B = round2 - round;
f604C = Math.round(85.333336f);
i3.d obj = new i3.d();
if (2048 > 1073741824) {
z3 = true;
} else {
z3 = false;
}
obj.f11709l = z3;
int i4 = (int) 1024;
obj.f11700a = i4;
obj.f11701b = 1024L;
if (!z3) {
obj.f11708k = 1;
int[] iArr = new int[((int) g3.c.a((1 << (((int) (g3.c.d(((float) 1024) + 0.5f) / g3.c.d(2.0d))) / 2)) + 2)) + 2];
obj.c = iArr;
float[] fArr = new float[i4];
obj.f11703e = fArr;
int i5 = (i4 * 2) >> 2;
obj.f11704g = i5;
iArr[0] = i5;
iArr[1] = 1;
if (i5 > 2) {
int i6 = i5 >> 1;
float f6 = 0.7853982f / i6;
float f7 = 2.0f * f6;
float b4 = (float) g3.c.b(i6 * f6); // r9 = i6 (smali)
fArr[0] = 1.0f;
fArr[1] = b4;
if (i6 == 4) {
double d4 = f7;
f4 = 0.5f;
fArr[2] = (float) g3.c.b(d4);
fArr[3] = (float) g3.c.f(d4);
} else {
f4 = 0.5f;
if (i6 > 4) {
iArr[2] = 0;
iArr[3] = 16;
int i7 = i5;
int i8 = 2;
f5 = 1.0f;
while (i7 > 32) {
int i9 = i8 << 1;
int i10 = i8 << 4;
for (int i11 = i8; i11 < i9; i11++) {
int i12 = iArr[i11] << 2;
iArr[i8 + i11] = i12;
iArr[i9 + i11] = i12 + i10;
}
i7 >>= 2;
i8 = i9;
}
fArr[2] = 0.5f / ((float) g3.c.b(f7));
fArr[3] = 0.5f / ((float) g3.c.b(6.0f * f6));
for (int i13 = 4; i13 < i6; i13 += 4) {
float f8 = i13 * f6;
double d5 = f8;
fArr[i13] = (float) g3.c.b(d5);
fArr[i13 + 1] = (float) g3.c.f(d5);
double d6 = 3.0f * f8;
fArr[i13 + 2] = (float) g3.c.b(d6);
fArr[i13 + 3] = -((float) g3.c.f(d6));
}
int i14 = 0;
while (i6 > 2) {
int i15 = i14 + i6;
i6 >>= 1;
fArr[i15] = f5;
fArr[i15 + 1] = b4;
if (i6 == 4) {
float f9 = fArr[i14 + 4];
float f10 = fArr[i14 + 5];
fArr[i15 + 2] = f9;
fArr[i15 + 3] = f10;
} else if (i6 > 4) {
float f11 = fArr[i14 + 4];
float f12 = fArr[i14 + 6];
fArr[i15 + 2] = f4 / f11;
fArr[i15 + 3] = f4 / f12;
for (int i16 = 4; i16 < i6; i16 += 4) {
int i17 = (i16 * 2) + i14;
int i18 = i15 + i16;
float f13 = fArr[i17];
float f14 = fArr[i17 + 1];
float f15 = fArr[i17 + 2];
float f16 = fArr[i17 + 3];
fArr[i18] = f13;
fArr[i18 + 1] = f14;
fArr[i18 + 2] = f15;
fArr[i18 + 3] = f16;
}
}
i14 = i15;
}
}
}
f5 = 1.0f;
int i142 = 0;
while (i6 > 2) {
}
}
int i19 = i4 >> 2;
obj.f11706i = i19;
iArr[1] = i19;
if (i19 > 1) {
int i20 = i19 >> 1;
float f17 = 0.7853982f / i20;
float b5 = (float) g3.c.b(i20 * f17); // r6 = i20 (smali)
fArr[i5] = b5;
fArr[i5 + i20] = b5 * 0.5f;
for (int i21 = 1; i21 < i20; i21++) {
double d7 = i21 * f17;
fArr[i5 + i21] = ((float) g3.c.b(d7)) * 0.5f;
fArr[(i5 + i19) - i21] = ((float) g3.c.f(d7)) * 0.5f;
}
}
} else {
obj.f11708k = 1;
s sVar = new s(((long) g3.c.a((1 << ((int) (((long) (g3.c.d(((float) 1024) + 0.5f) / g3.c.d(2.0d))) / 2))) + 2)) + 2, true);
obj.f11702d = sVar;
k3.d dVar = new k3.d(1024L, true);
obj.f = dVar;
obj.f11705h = 512L;
sVar.c(0L, 512L);
sVar.c(1L, 1L);
if (512 > 2) {
long j4 = 512 >> 1;
float f18 = 0.7853982f / ((float) j4);
float f19 = f18 * 2.0f;
float b6 = (float) g3.c.b(j4 * f18); // r14 = j4 (smali 同模式)
dVar.c(0L, 1.0f);
dVar.c(1L, b6);
if (j4 == 4) {
f = 0.5f;
double d8 = f19;
dVar.c(2L, (float) g3.c.b(d8));
dVar.c(3L, (float) g3.c.f(d8));
} else {
f = 0.5f;
if (j4 > 4) {
sVar.c(2L, 0L);
sVar.c(3L, 16L);
long j5 = 2;
long j6 = 512;
while (j6 > 32) {
long j7 = j5 << 1;
long j8 = j5 << 4;
long j9 = j5;
while (j9 < j7) {
long b7 = sVar.b(j9) << 2;
sVar.c(j5 + j9, b7);
sVar.c(j7 + j9, b7 + j8);
j9++;
f19 = f19;
}
j6 >>= 2;
j5 = j7;
}
dVar.c(2L, 0.5f / ((float) g3.c.b(f19)));
dVar.c(3L, 0.5f / ((float) g3.c.b(6.0f * f18)));
long j10 = 4;
while (j10 < j4) {
float f20 = ((float) j10) * f18;
double d9 = f20;
dVar.c(j10, (float) g3.c.b(d9));
dVar.c(j10 + 1, (float) g3.c.f(d9));
double d10 = 3.0f * f20;
long j11 = j10;
dVar.c(j10 + 2, (float) g3.c.b(d10));
dVar.c(j11 + 3, -((float) g3.c.f(d10)));
j10 = j11 + 4;
}
}
}
long j12 = 0;
long j13 = 2;
while (j4 > j13) {
long j14 = j12 + j4;
j4 >>= 1;
dVar.c(j14, 1.0f);
dVar.c(j14 + 1, b6);
if (j4 == 4) {
float b8 = dVar.b(j12 + 4);
float b9 = dVar.b(j12 + 5);
j13 = 2;
dVar.c(j14 + 2, b8);
dVar.c(j14 + 3, b9);
} else {
j13 = 2;
if (j4 > 4) {
float b10 = dVar.b(j12 + 4);
float b11 = dVar.b(6 + j12);
dVar.c(j14 + 2, f / b10);
dVar.c(j14 + 3, f / b11);
long j15 = 4;
while (j15 < j4) {
long j16 = (j15 * 2) + j12;
float f21 = b6;
long j17 = j14 + j15;
long j18 = j12;
float b12 = dVar.b(j16);
long j19 = j14;
float b13 = dVar.b(j16 + 1);
float b14 = dVar.b(j16 + 2);
float b15 = dVar.b(j16 + 3);
dVar.c(j17, b12);
dVar.c(j17 + 1, b13);
dVar.c(j17 + 2, b14);
dVar.c(j17 + 3, b15);
j15 += 4;
b6 = f21;
j12 = j18;
j14 = j19;
}
}
}
b6 = b6;
j12 = j14;
}
}
long j20 = 256;
obj.f11707j = 256L;
long j21 = 1;
sVar.c(1L, 256L);
if (256 > 1) {
long j22 = 256 >> 1;
float f22 = 0.7853982f / ((float) j22);
long j23 = 512;
dVar.c(512L, (float) g3.c.b(j22 * f22)); // r1 = j22 (smali)
dVar.c(512 + j22, dVar.b(512L) * 0.5f);
long j24 = 1;
while (j24 < j22) {
long j25 = j20;
long j26 = j21;
double d11 = ((float) j24) * f22;
long j27 = j23;
dVar.c(j23 + j24, ((float) g3.c.b(d11)) * 0.5f);
dVar.c((j27 + j25) - j24, ((float) g3.c.f(d11)) * 0.5f);
j24 += j26;
j20 = j25;
j21 = j26;
j23 = j27;
}
}
}
f605D = obj;
}
public b(Context context) {
float[] fArr = new float[1024];
fArr[512] = 1.0f;
for (int i4 = 1; i4 < 512; i4++) {
double d4 = (i4 * 6.283185307179586d) / 1024;
float cos = (float) ((Math.cos(d4 * 2.0d) * 0.08d) + (0.42d - (Math.cos(d4) * 0.5d)));
fArr[i4] = cos;
double d5 = 0.003125f * 6.283185307179586d * (i4 - 512);
float sin = (float) ((Math.sin(d5) / d5) * cos);
fArr[i4] = sin;
fArr[1024 - i4] = sin;
}
this.f607a = fArr;
this.f608b = new float[1024];
this.c = new float[1024];
this.f609d = new float[f603B];
this.f610e = nativedecoder.TNativeDecoder.Create();
this.f614j = "";
this.f615k = new c(4000, 0);
I2.b bVar = new I2.b();
this.f617m = bVar;
this.f618n = new Handler(Looper.getMainLooper());
this.f622r = new AtomicInteger();
this.f623s = new AtomicBoolean(false);
this.f625u = 0;
this.f626v = new AtomicBoolean(false);
this.f628x = 0;
this.f624t = context;
bVar.b(context);
}
/* JADX WARN: Removed duplicated region for block: B:57:0x01e2 */
/* JADX WARN: Removed duplicated region for block: B:63:0x020a A[LOOP:6: B:62:0x0208->B:63:0x020a, LOOP_END] */
/* JADX WARN: Removed duplicated region for block: B:66:0x0216 A[LOOP:7: B:65:0x0214->B:66:0x0216, LOOP_END] */
/* JADX WARN: Removed duplicated region for block: B:70:0x0232 */
/* JADX WARN: Removed duplicated region for block: B:73:? A[RETURN, SYNTHETIC] */
/*
Code decompiled incorrectly, please refer to instructions dump.
*/
public final void a(float[] fArr) {
c cVar;
float[] fArr2;
float[] fArr3;
b bVar;
Exception exc;
int i4;
float f;
int i5;
WaterfallSurfaceView waterfallSurfaceView;
int length = fArr.length;
int i6 = 0;
while (true) {
cVar = this.f615k;
if (i6 >= length) {
break;
}
cVar.c(Math.abs(fArr[i6]));
i6++;
}
this.f616l = Math.round(((float) Math.log10(Math.max(-0.5d, cVar.f633e))) * 20.0f);
int i7 = f604C;
int i8 = 1024 - i7;
float[] fArr4 = this.f608b;
System.arraycopy(fArr4, i7, fArr4, 0, i8);
System.arraycopy(fArr, 0, fArr4, i8, i7);
int i9 = 0;
while (true) {
fArr2 = this.c;
if (i9 >= 1024) {
break;
}
fArr2[i9] = fArr4[i9] * this.f607a[i9];
i9++;
}
f605D.k(fArr2, 0);
int i10 = f606z;
int i11 = i10;
while (true) {
int i12 = f602A;
float f4 = 0.0f;
fArr3 = this.f609d;
if (i11 < i12) {
int i13 = i11 * 2;
float f5 = fArr2[i13];
float f6 = fArr2[i13 + 1];
float f7 = ((f6 * f6) + (f5 * f5)) * 0.001f;
if (!Float.isNaN(f7)) {
f4 = f7;
}
fArr3[i11 - i10] = f4;
i11++;
} else {
// smali: 循环内无 try; 录音写入 try 在循环后(见下)
break;
}
}
DataOutputStream dataOutputStream = this.f627w;
Context context = this.f624t;
AtomicBoolean atomicBoolean = this.f626v;
try { // smali try_start_0: 录音写入整块(原 jadx 误把 try 放 break 处)
if (dataOutputStream == null && atomicBoolean.get()) {
File file = new File(context.getExternalFilesDir(null), ((String) DateFormat.format("yyyy-MM-dd_HH_mm_ss", new Date())) + "_spectra.bin");
this.f629y = file.getAbsoluteFile().toString();
this.f627w = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(file, false)));
this.f628x = 0;
this.f614j = " Recording...";
}
if (this.f627w != null) {
for (float f8 : fArr3) {
this.f627w.writeFloat(f8);
}
}
if (atomicBoolean.get()) {
int i14 = this.f628x + 1;
this.f628x = i14;
if (i14 >= 2048) {
atomicBoolean.set(false);
this.f627w.close();
this.f627w = null;
this.f614j = "";
MediaScannerConnection.scanFile(context, new String[]{this.f629y}, new String[]{"application/x-binary"}, null);
}
}
} catch (IOException e4) {
Log.e("File write", "Exception: ", e4);
}
nativedecoder.TNativeDecoder tNativeDecoder = this.f610e;
try {
this.f613i = tNativeDecoder.ProcessSpectrum(fArr3);
int i15 = this.f625u + 1;
this.f625u = i15;
Handler handler = this.f618n;
if (i15 == 15) {
try {
this.f625u = 0;
this.f = tNativeDecoder.getPitch();
this.f611g = tNativeDecoder.getSnr();
this.f612h = tNativeDecoder.getWpm();
handler.post(new B0.b(this, this.f614j, new int[]{this.f616l, this.f, this.f611g, this.f612h, tNativeDecoder.getSignalCount(), this.f613i.Value}));
AtomicInteger atomicInteger = this.f622r;
int i16 = atomicInteger.get();
if (i16 != 0) {
tNativeDecoder.LockToPitch(i16);
atomicInteger.set(0);
}
tNativeDecoder.setHamMode(this.f623s.get());
} catch (Exception e5) {
// jadx 幻觉区(空循环/空 if 为 catch 错位还原)已裁剪; 原 smali: 记录异常后跳回正常流程尾部
exc = e5;
bVar = this;
Log.e("Audio Processor", "Exception", exc);
}
}
int charCount = tNativeDecoder.getCharCount();
if (charCount > 0) {
SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder();
int i18 = 0;
while (i18 < charCount) {
decoder.TCharInfo GetChar = tNativeDecoder.GetChar(i18);
spannableStringBuilder.append(GetChar.getChar());
ForegroundColorSpan foregroundColorSpan = new ForegroundColorSpan(this.f617m.a(GetChar));
int i19 = i18 + 1;
spannableStringBuilder.setSpan(foregroundColorSpan, i18, i19, 33);
i18 = i19;
}
bVar = this;
try {
handler.post(new RunnableC0001b(bVar, spannableStringBuilder, tNativeDecoder.getDeleteCount(), 2, false));
} catch (Exception e6) {
// jadx 幻觉区已裁剪(同 catch e5); 原 smali: 记录异常后跳回正常流程尾部
exc = e6;
Log.e("Audio Processor", "Exception", exc);
}
} else {
bVar = this;
}
} catch (Exception e7) {
// jadx 幻觉(e 变量)已裁剪
bVar = this;
}
i4 = f603B;
int i1722 = i4 / 2;
float[] copyOfRange22 = Arrays.copyOfRange(fArr3, i1722 - 20, i1722 + 20);
Arrays.sort(copyOfRange22);
f = copyOfRange22[20];
if (f > 0.0f) {
for (int i20 = 0; i20 < i4; i20++) {
fArr3[i20] = fArr3[i20] / f;
}
}
float[] fArr522 = (float[]) fArr3.clone();
new c(12, 1).a(fArr522);
new c(12, 0).a(fArr522);
for (i5 = 0; i5 < i4; i5++) {
fArr3[i5] = fArr3[i5] - fArr522[i5];
}
for (int i21 = 0; i21 < i4; i21++) {
fArr3[i21] = ((float) Math.log10(Math.max(-0.5d, fArr3[i21]) + 1.0d)) * 10.0f;
}
waterfallSurfaceView = bVar.f619o;
if (waterfallSurfaceView != null) {
waterfallSurfaceView.queueEvent(new n(waterfallSurfaceView, 1, (float[]) fArr3.clone()));
}
}
}
-89
View File
@@ -1,89 +0,0 @@
package H2;
/* loaded from: classes.dex */
public final class c {
/* renamed from: a, reason: collision with root package name */
public final int f630a;
/* renamed from: b, reason: collision with root package name */
public float[] f631b;
public int c;
/* renamed from: d, reason: collision with root package name */
public float f632d;
/* renamed from: e, reason: collision with root package name */
public float f633e;
public final /* synthetic */ int f;
public c(int i4, int i5) {
this.f = i5;
this.f630a = i4 - 1;
}
public final void a(float[] fArr) {
int i4 = this.f630a / 2;
int length = fArr.length;
if (length > i4) {
this.f631b = null;
for (int i5 = 0; i5 < i4; i5++) {
c(fArr[i5]);
}
for (int i6 = i4; i6 < length; i6++) {
fArr[i6 - i4] = c(fArr[i6]);
}
for (int i7 = length; i7 < length + i4; i7++) {
fArr[i7 - i4] = c(fArr[length - 1]);
}
return;
}
throw new IllegalArgumentException("Sliding Filter error: input too short");
}
public final float b(float f, float f4) {
switch (this.f) {
case 0:
return Math.max(f, f4);
default:
return Math.min(f, f4);
}
}
public final float c(float f) {
float[] fArr = this.f631b;
int i4 = this.f630a;
if (fArr == null) {
this.f631b = new float[i4];
for (int i5 = 0; i5 < i4; i5++) {
this.f631b[i5] = f;
}
this.f632d = f;
return f;
}
float b4 = b(this.f632d, f);
this.f632d = b4;
this.f633e = b(this.f631b[this.c], b4);
float[] fArr2 = this.f631b;
int i6 = this.c;
int i7 = i6 + 1;
this.c = i7;
fArr2[i6] = f;
if (i7 == i4) {
int length = fArr2.length;
while (true) {
this.c = length - 1;
int i8 = this.c;
if (i8 <= 0) {
break;
}
float[] fArr3 = this.f631b;
fArr3[i8 - 1] = b(fArr3[i8 - 1], fArr3[i8]);
length = this.c;
}
this.c = 0;
this.f632d = f;
}
return this.f633e;
}
}
-42
View File
@@ -1,42 +0,0 @@
package I0;
/**
* 照搬自 Morse Expert 1.15 I0.d, 类名保持混淆原名。
* R8 合并类手术: 只保留 (i3.d, int, int, float[]) FFT 任务构造;
* run() 按 smali 原样还原: R8 已将任务体优化为死代码(aget 后 throw null,
* 永不执行), 保留原语义(行为与原 APK 完全一致)。
* SystemForegroundService(work 库)case 已裁剪。
*/
public final class d implements Runnable {
public final /* synthetic */ int f = 0;
/* renamed from: g, reason: collision with root package name */
public final /* synthetic */ int f653g;
/* renamed from: h, reason: collision with root package name */
public final /* synthetic */ int f654h;
/* renamed from: i, reason: collision with root package name */
public final /* synthetic */ Object f655i;
/* renamed from: j, reason: collision with root package name */
public final /* synthetic */ Object f656j;
public d(i3.d dVar, int i4, int i5, float[] fArr) {
this.f656j = dVar;
this.f653g = i4;
this.f654h = i5;
this.f655i = fArr;
}
@Override // java.lang.Runnable
public final void run() {
int i6 = this.f654h;
int i7 = this.f653g;
if (i7 >= i6) {
return;
}
float f = ((float[]) this.f655i)[i7 * 2];
// R8 原代码: aget 后 throw null(死代码, 永不执行) —— 照搬语义, 不执行任何计算
}
}
-73
View File
@@ -1,73 +0,0 @@
package I2;
import android.content.Context;
import android.content.SharedPreferences;
import pas.decoder;
/* loaded from: classes.dex */
public final class b {
/* renamed from: b, reason: collision with root package name */
public static final String[] f663b = {"bg_color", "text_color", "text_color_weak", "call_color", "call_color_weak", "cq_color", "cq_color_weak", "rst_color", "rst_color_weak"};
public static final String[] c = {"Background color", "Text color", "Text color, weak", "Callsign color", "Callsign color, weak", "CQ color", "CQ color, weak", "RST color", "RST color, weak"};
/* renamed from: d, reason: collision with root package name */
public static final int[] f664d = {-3084048, -16777216, -5592406, -65536, -30584, -16776961, -7829249, -65281, -30465};
/* renamed from: a, reason: collision with root package name */
public final int[] f665a = (int[]) f664d.clone();
public final int a(decoder.TCharInfo tCharInfo) {
boolean lowSnr = tCharInfo.getLowSnr();
int[] iArr = this.f665a;
if (lowSnr) {
int i4 = tCharInfo.getWordType().Value;
if (i4 != 0) {
if (i4 != 1) {
if (i4 != 2) {
if (i4 != 3) {
if (i4 != 4) {
if (i4 == 5) {
return iArr[4];
}
return -16777216;
}
return iArr[2];
}
return iArr[6];
}
return iArr[8];
}
return iArr[2];
}
return iArr[2];
}
int i5 = tCharInfo.getWordType().Value;
if (i5 != 0) {
if (i5 != 1) {
if (i5 != 2) {
if (i5 != 3) {
if (i5 != 4) {
if (i5 != 5) {
return -16777216;
}
return iArr[3];
}
return iArr[1];
}
return iArr[5];
}
return iArr[7];
}
return iArr[1];
}
return iArr[1];
}
public final void b(Context context) {
SharedPreferences sharedPreferences = context.getSharedPreferences(context.getPackageName() + "_preferences", 0); // 原 c0.x.a(context)
for (int i4 = 0; i4 < 9; i4++) {
this.f665a[i4] = sharedPreferences.getInt(f663b[i4], f664d[i4]);
}
}
}
-45
View File
@@ -1,45 +0,0 @@
package J2;
/* loaded from: classes.dex */
public final class a {
/* renamed from: b, reason: collision with root package name */
public static final int[] f726b = {16, 17749, 65535, 16776960, 16711680};
/* renamed from: a, reason: collision with root package name */
public final int[] f727a;
public a() {
int[] iArr = new int[256];
this.f727a = iArr;
int i4 = 4;
float f = 255.0f / 4;
int[] iArr2 = f726b;
int i5 = 0;
int i6 = -16777216;
iArr[0] = iArr2[0] | (-16777216);
int i7 = 1;
while (i7 <= i4) {
int round = Math.round(i7 * f);
iArr[round] = iArr2[i7] | i6;
int i8 = round - i5;
int i9 = iArr[i5];
int i10 = (i9 >> 16) & 255;
int i11 = (i9 >> 8) & 255;
int i12 = i9 & 255;
float f4 = i8;
// r10 = iArr2[i7] (下一个锚点色, smali: aget v10, v4, v8)
float f5 = (((iArr2[i7] >> 16) & 255) - i10) / f4;
float f6 = (((iArr2[i7] >> 8) & 255) - i11) / f4;
float f7 = ((iArr2[i7] & 255) - i12) / f4;
for (int i13 = 1; i13 < i8; i13++) {
float f8 = i13;
iArr[i5 + i13] = ((Math.round(f5 * f8) + i10) << 16) | (-16777216) | ((Math.round(f6 * f8) + i11) << 8) | (Math.round(f8 * f7) + i12);
}
i7++;
i5 = round;
i4 = 4;
i6 = -16777216;
}
}
}
-183
View File
@@ -1,183 +0,0 @@
package J2;
import android.content.Context;
import android.graphics.Bitmap;
import android.opengl.GLES20;
import android.opengl.GLSurfaceView;
import android.opengl.GLUtils;
import android.os.SystemClock;
import android.util.Log;
import java.nio.Buffer;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;
/* loaded from: classes.dex */
public final class b implements GLSurfaceView.Renderer {
/* renamed from: b, reason: collision with root package name */
public FloatBuffer f729b;
public int c;
/* renamed from: d, reason: collision with root package name */
public final Bitmap f730d;
public final Context f;
/* renamed from: g, reason: collision with root package name */
public double f732g;
/* renamed from: h, reason: collision with root package name */
public final Bitmap f733h;
/* renamed from: i, reason: collision with root package name */
public final a f734i;
/* renamed from: j, reason: collision with root package name */
public long f735j;
/* renamed from: k, reason: collision with root package name */
public double f736k;
/* renamed from: l, reason: collision with root package name */
public int f737l;
/* renamed from: a, reason: collision with root package name */
public final int[] f728a = new int[1];
/* renamed from: e, reason: collision with root package name */
public final float[] f731e = {-1.0f, -1.0f, 0.0f, 1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, -1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.0f};
public b(Context context, int i4) {
Bitmap.Config config = Bitmap.Config.ARGB_8888;
Bitmap createBitmap = Bitmap.createBitmap(2048, i4, config);
this.f730d = createBitmap;
createBitmap.eraseColor(16);
this.f732g = 0.09375d;
this.f734i = new a();
this.f735j = 0L;
this.f736k = 0.0d;
this.f737l = -1;
this.f = context;
this.f733h = Bitmap.createBitmap(1, i4, config);
}
public static void a() {
int glGetError = GLES20.glGetError();
if (glGetError != 0) {
StackTraceElement stackTraceElement = Thread.currentThread().getStackTrace()[3];
Log.e(stackTraceElement.getFileName() + ":" + stackTraceElement.getLineNumber(), ", GLES20 error: " + glGetError);
}
}
public final double b() {
long uptimeMillis = SystemClock.uptimeMillis();
double width = (((uptimeMillis - this.f735j) * this.f732g) + this.f736k) % this.f730d.getWidth();
this.f735j = uptimeMillis;
this.f736k = width;
return width;
}
public final void c(GL10 gl10) {
GLES20.glUseProgram(this.c);
a();
GLES20.glActiveTexture(33984);
a();
GLES20.glBindTexture(3553, this.f728a[0]);
a();
GLES20.glUniform1i(GLES20.glGetUniformLocation(this.c, "uTexture"), 0);
a();
int glGetAttribLocation = GLES20.glGetAttribLocation(this.c, "aPosition");
a();
int glGetAttribLocation2 = GLES20.glGetAttribLocation(this.c, "aTexPos");
a();
this.f729b.position(0);
a();
GLES20.glVertexAttribPointer(glGetAttribLocation, 2, 5126, false, 16, (Buffer) this.f729b);
a();
GLES20.glEnableVertexAttribArray(glGetAttribLocation);
a();
this.f729b.position(2);
a();
GLES20.glVertexAttribPointer(glGetAttribLocation2, 2, 5126, false, 16, (Buffer) this.f729b);
a();
GLES20.glEnableVertexAttribArray(glGetAttribLocation2);
a();
GLES20.glDrawArrays(5, 0, 4);
a();
}
public final void d(GL10 gl10, int i4, int i5) {
GLES20.glViewport(0, 0, i4, i5);
a();
}
@Override // android.opengl.GLSurfaceView.Renderer
public final void onDrawFrame(GL10 gl10) {
float b4 = (float) (b() / this.f730d.getWidth());
float f = 0.5f + b4;
float[] fArr = this.f731e;
fArr[6] = f;
fArr[2] = f;
float f4 = b4 + 1.0f;
fArr[14] = f4;
fArr[10] = f4;
this.f729b.position(0);
this.f729b.put(this.f731e);
c(gl10);
}
@Override // android.opengl.GLSurfaceView.Renderer
public final void onSurfaceChanged(GL10 gl10, int i4, int i5) {
d(gl10, i4, i5);
float width = 1.0f - (((this.f730d.getWidth() * 0.7f) / (i4 / this.f.getResources().getDisplayMetrics().density)) * 2.0f);
float[] fArr = this.f731e;
fArr[4] = width;
fArr[0] = width;
this.f729b.position(0);
this.f729b.put(this.f731e);
}
@Override // android.opengl.GLSurfaceView.Renderer
public final void onSurfaceCreated(GL10 gl10, EGLConfig eGLConfig) {
int glCreateShader = GLES20.glCreateShader(35633);
a();
GLES20.glShaderSource(glCreateShader, "attribute vec2 aPosition;\nattribute vec2 aTexPos;\nvarying vec2 vTexPos;\nvoid main() {\n vTexPos = aTexPos;\n gl_Position = vec4(aPosition.xy, 0.0, 1.0);\n}");
a();
GLES20.glCompileShader(glCreateShader);
a();
int glCreateShader2 = GLES20.glCreateShader(35632);
a();
GLES20.glShaderSource(glCreateShader2, "precision mediump float;\nuniform sampler2D uTexture;\nvarying vec2 vTexPos;\nvoid main(void)\n{\n gl_FragColor = texture2D(uTexture, vTexPos);\n}");
a();
GLES20.glCompileShader(glCreateShader2);
a();
this.c = GLES20.glCreateProgram();
a();
GLES20.glAttachShader(this.c, glCreateShader);
a();
GLES20.glAttachShader(this.c, glCreateShader2);
a();
GLES20.glLinkProgram(this.c);
a();
int[] iArr = this.f728a;
GLES20.glGenTextures(1, iArr, 0);
a();
GLES20.glBindTexture(3553, iArr[0]);
a();
GLES20.glTexParameteri(3553, 10240, 9729);
GLES20.glTexParameteri(3553, 10242, 10497);
GLES20.glTexParameteri(3553, 10241, 9729);
GLES20.glTexParameteri(3553, 10243, 10497);
a();
GLUtils.texImage2D(3553, 0, this.f730d, 0);
a();
float[] fArr = this.f731e;
ByteBuffer allocateDirect = ByteBuffer.allocateDirect(fArr.length * 4);
allocateDirect.order(ByteOrder.nativeOrder());
FloatBuffer asFloatBuffer = allocateDirect.asFloatBuffer();
this.f729b = asFloatBuffer;
asFloatBuffer.put(fArr);
}
}
-10
View File
@@ -1,10 +0,0 @@
package K1;
/**
* 照搬自 Morse Expert 1.15 K1.a 精简版: 仅保留 k3 FFT 库引用的内存统计静态字段
* (K1.a.c 长整型记账)。原 K1.a(828 行)的其余方法为状态栏/库代码, 已裁剪
* (状态栏逻辑已在 MainActivity 内联)。
*/
public class a {
public static long c = 0;
}
@@ -1,29 +1,28 @@
/*
* CwDecodeScreen.kt — Look4Sat 的 CW 解码页面(Compose 外壳)。
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* 照搬自 Morse Expert 1.15 的 com.ve3nea.morse_expert.MainActivity(已改为普通控制器类):
* - AndroidView 嵌入原版 activity_main.xml(ConstraintLayout 根, 含 decodedTextView/scaleView/
* statusTextView/verticalLayout/waterfallView);
* - 生命周期 onCreate/onResume/onPause/onDestroy 全部委托给控制器, 顺序与原 Activity 一致
* (onCreate 必须在 onResume 前, onDispose 先 onPause 再 onDestroy);
* - 麦克风权限(RECORD_AUDIO)由 Compose 侧管理, 授予后调用 onPermissionGranted()(= v()),
* 用 initialized 标记保证只调一次(v() 会 new a() 覆盖 f11036E);
* - 原 options_menu 的 清除/暂停/保存/录音/设置 改为顶部一行按钮(图标用模块内原版 drawable)。
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 注意: 控制器 onCreate 内的 setImmersive(window, false) 会把宿主窗口恢复为
* decorFitsSystemWindows(true)(原版行为照搬, 会对整个宿主 Activity 生效)。
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.cw
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import android.view.LayoutInflater
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@@ -36,169 +35,233 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.widget.ConstraintLayout
import com.ve3nea.morse_expert.MainActivity
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.R as CoreR
/**
* Full-page CW decoder backed by DeepCW.
*
* Layout follows the DeepCW reference app: waterfall on top, the live line
* under it, then the scrolling history. Pause/clear controls and the microphone
* permission flow carry over from the previous page.
*
* There is no settings dialog any more: the model analyses a fixed
* 400-1200 Hz window and tracks speed on its own, so there is nothing to tune.
*/
@Composable
fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
val context = LocalContext.current
val activity = remember(context) {
context as? Activity ?: error("CwDecodeScreen must be hosted in an Activity")
}
// 照搬的控制器(普通类, 非 Activity); 每次进入页面新建实例
val controller = remember { MainActivity() }
// 提前 inflate 原版布局, 供 AndroidView 与控制器 onCreate 共用同一根视图
val rootView = remember(context) {
LayoutInflater.from(context).inflate(R.layout.activity_main, null) as ConstraintLayout
}
val container = remember { (context.applicationContext as IContainerProvider).getMainContainer() }
val decoder = remember { container.provideCwDecoder() }
val audioCapture = remember { container.provideAudioCapture() }
val waterfall = remember { CwWaterfallState() }
var permissionGranted by remember {
mutableStateOf(
context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) ==
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
var permanentlyDenied by remember { mutableStateOf(false) }
// v() 会 new a() 覆盖 f11036E, 必须保证只初始化一次
var initialized by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
var isListening by remember { mutableStateOf(false) }
// 生命周期: onCreate 必须在 onResume 前; onDispose 先 onPause 再 onDestroy
DisposableEffect(Unit) {
controller.onCreate(activity, rootView)
controller.onResume()
onDispose {
controller.onPause()
controller.onDestroy()
}
}
// 权限授予后启动解码核心; "首次进入已授权" 与 "弹窗回调授权" 两条路径统一走这里
LaunchedEffect(permissionGranted) {
if (permissionGranted && !initialized) {
controller.onPermissionGranted() // = v(): 创建音频采集 + 解码核心
// v() 只创建核心; 页面此刻已处于 resumed, 需再走一次 onResume 立即启动录音
// (AudioRecord 是新建的, startRecording 不会重复; GLSurfaceView.onResume 幂等)
controller.onResume()
initialized = true
}
}
val decodedText by decoder.decodedText.collectAsState()
val historyText by decoder.historyText.collectAsState()
val estimatedPitch by decoder.estimatedPitch.collectAsState()
val signalStrength by decoder.signalStrength.collectAsState()
val inferenceMs by decoder.lastInferenceMs.collectAsState()
val errorMessage by decoder.errorMessage.collectAsState()
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
permissionGranted = granted
permanentlyDenied = !granted &&
val activity = context as? Activity
permanentlyDenied = !granted && activity != null &&
!activity.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)
if (granted) isListening = true
}
// 原版 tap_back_again_to_close: 单击返回只提示, 2 秒内再按才退出
BackHandler {
if (!controller.handleBackPress()) navigateUp()
// Capture runs only while listening. Keyed on both listening state and
// permission so granting the permission mid-flow restarts collection
// (isListening may already be true when the launcher returns).
LaunchedEffect(isListening, permissionGranted) {
if (!isListening) return@LaunchedEffect
if (!permissionGranted) {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
return@LaunchedEffect
}
audioCapture.audioFlow().collect { chunk ->
decoder.processBuffer(chunk, audioCapture.sampleRate)
waterfall.pushSamples(chunk, audioCapture.sampleRate)
}
}
if (showSettings) {
CwSettingsDialog(controller = controller, onDismiss = { showSettings = false })
DisposableEffect(Unit) {
onDispose {
// OrtSession holds native memory and must be released explicitly.
decoder.close()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
) {
// 原 options_menu: 暂停/清除/保存(原版图标) + 录音/设置(文字按钮)
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { controller.togglePause() }) {
IconButton(onClick = { isListening = false; navigateUp() }) {
Icon(
painter = painterResource(R.drawable.ic_baseline_pause_24),
contentDescription = stringResource(R.string.pause)
painter = painterResource(CoreR.drawable.ic_back),
contentDescription = stringResource(R.string.cw_back)
)
}
IconButton(onClick = { controller.clearDecoded() }) {
Icon(
painter = painterResource(R.drawable.ic_baseline_delete_24),
contentDescription = stringResource(R.string.clear)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(CoreR.string.nav_cw),
style = MaterialTheme.typography.titleMedium
)
Text(
text = if (estimatedPitch != null) {
stringResource(R.string.cw_status_tone, estimatedPitch!!.toInt(), inferenceMs)
} else {
stringResource(R.string.cw_status_listening)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { controller.saveText() }) {
IconButton(onClick = { isListening = !isListening }) {
Icon(
painter = painterResource(R.drawable.ic_baseline_save_24),
contentDescription = stringResource(R.string.save_text)
painter = painterResource(
if (isListening) CoreR.drawable.ic_pause else CoreR.drawable.ic_play
),
contentDescription = stringResource(
if (isListening) R.string.cw_pause else R.string.cw_resume
)
)
}
// recordSignals 需要解码核心(f11037F), 未初始化时禁用
TextButton(
onClick = { if (initialized) controller.recordSignals() },
enabled = initialized
) {
Text(stringResource(R.string.record_signals))
}
TextButton(onClick = { showSettings = true }) {
Text(stringResource(R.string.settings))
IconButton(onClick = { decoder.reset(); waterfall.clear() }) {
Icon(
painter = painterResource(CoreR.drawable.ic_delete),
contentDescription = stringResource(R.string.cw_clear)
)
}
}
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
AndroidView(
factory = { rootView },
modifier = Modifier.fillMaxSize()
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.padding(horizontal = 8.dp)
.clip(RoundedCornerShape(8.dp))
) {
CwWaterfallView(state = waterfall, signalStrength = signalStrength)
}
Text(
text = decodedText.takeLast(64).ifEmpty { "…" },
fontSize = 20.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp)
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
) {
Text(
text = (historyText + decodedText).ifEmpty { "…" },
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp),
fontSize = 16.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
if (!permissionGranted) {
// 未授予麦克风权限: 遮罩 + 提示文字 + 请求按钮
errorMessage?.let { message ->
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0x99000000)),
.background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.9f)),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = message,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp)
)
}
}
if (!permissionGranted) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = stringResource(R.string.cw_mic_permission),
color = Color.White,
color = MaterialTheme.colorScheme.inverseOnSurface,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Button(onClick = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}) {
Text(stringResource(R.string.cw_grant_permission))
}
if (permanentlyDenied) {
// 勾选了"不再询问": 引导去系统设置开启
TextButton(onClick = {
activity.startActivity(
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:${activity.packageName}")
Uri.fromParts("package", context.packageName, null)
)
)
}) {
@@ -209,5 +272,7 @@ fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
}
}
}
Spacer(modifier = Modifier.height(4.dp))
}
}
@@ -1,218 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.feature.cw
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.ve3nea.morse_expert.MainActivity
import kotlin.math.roundToInt
/** 键名/默认值照搬原 Morse Expert(root_preferences.xml + SettingsActivity 逻辑)。 */
private const val KEY_MESSAGE_TYPE = "message_type"
private const val KEY_TEXT_FONT_SIZE = "text_font_size"
private const val DEFAULT_MESSAGE_TYPE = "general_text"
private const val DEFAULT_TEXT_FONT_SIZE = 18
private const val MIN_FONT_SIZE = 7
private const val MAX_FONT_SIZE = 99
/** 预置色板(9 色, 3x3 网格);原 ColorPreferenceCompat 用第三方 colorpicker, 不引入。 */
private val PaletteColors = listOf(
0xFFD0F0F0.toInt(), 0xFF000000.toInt(), 0xFFFFFFFF.toInt(),
0xFFAAAAAA.toInt(), 0xFFFF0000.toInt(), 0xFF00FF00.toInt(),
0xFF0000FF.toInt(), 0xFFFFFF00.toInt(), 0xFFFF00FF.toInt(),
)
/**
* CW 设置弹窗(替代原 Morse Expert 的 SettingsActivity)。
* 设置项与键名照搬原 APK: message_type / text_font_size / 9 个颜色
* (I2.b.f663b 键名, I2.b.f664d 默认值, I2.b.c 英文标题)。
* 存储: SharedPreferences(getPackageName() + "_preferences")。
* 点击 OK 保存并调用 controller.onResume() 立即生效; Cancel/点外部仅关闭不保存。
*/
@Composable
fun CwSettingsDialog(controller: MainActivity, onDismiss: () -> Unit) {
val activity = controller.mActivity ?: return
val prefs = remember(activity) {
activity.getSharedPreferences(activity.packageName + "_preferences", Context.MODE_PRIVATE)
}
var messageType by remember {
mutableStateOf(prefs.getString(KEY_MESSAGE_TYPE, DEFAULT_MESSAGE_TYPE) ?: DEFAULT_MESSAGE_TYPE)
}
var fontSize by remember {
mutableStateOf(
prefs.getInt(KEY_TEXT_FONT_SIZE, DEFAULT_TEXT_FONT_SIZE)
.toFloat().coerceIn(MIN_FONT_SIZE.toFloat(), MAX_FONT_SIZE.toFloat())
)
}
var colorValues by remember {
mutableStateOf(IntArray(9) { i -> prefs.getInt(I2.b.f663b[i], I2.b.f664d[i]) })
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("CW Settings") },
text = {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(top = 4.dp)
) {
Text("Message type", style = MaterialTheme.typography.titleSmall)
MessageTypeRow("general_text", "General Text", messageType) { messageType = it }
MessageTypeRow("ham_radio_qso", "Ham Radio QSO", messageType) { messageType = it }
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("Text font size", style = MaterialTheme.typography.titleSmall)
Row(verticalAlignment = Alignment.CenterVertically) {
Slider(
value = fontSize,
onValueChange = { fontSize = it },
valueRange = MIN_FONT_SIZE.toFloat()..MAX_FONT_SIZE.toFloat(),
steps = MAX_FONT_SIZE - MIN_FONT_SIZE - 1,
modifier = Modifier.weight(1f),
)
Text(
text = "${fontSize.roundToInt()}",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.End,
modifier = Modifier.width(44.dp),
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("Colors", style = MaterialTheme.typography.titleSmall)
for (i in 0 until 9) {
ColorSettingRow(
title = I2.b.c[i],
value = colorValues[i],
onSelect = { selected ->
colorValues = colorValues.copyOf().also { it[i] = selected }
},
)
}
}
},
confirmButton = {
TextButton(onClick = {
val editor = prefs.edit()
editor.putString(KEY_MESSAGE_TYPE, messageType)
editor.putInt(KEY_TEXT_FONT_SIZE, fontSize.roundToInt())
for (i in 0 until 9) {
editor.putInt(I2.b.f663b[i], colorValues[i])
}
editor.apply()
// 原 SettingsActivity 返回时由 MainActivity.onResume() 重新读取全部设置; 照搬逻辑已就位
controller.onResume()
onDismiss()
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@Composable
private fun MessageTypeRow(option: String, label: String, selected: String, onSelect: (String) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.clickable { onSelect(option) }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = option == selected, onClick = { onSelect(option) })
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun ColorSettingRow(title: String, value: Int, onSelect: (Int) -> Unit) {
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(title, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Box(
Modifier
.size(20.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(value)),
)
Spacer(Modifier.width(6.dp))
Text(
text = String.format("#%06X", value and 0xFFFFFF),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(6.dp))
PaletteGrid(selected = value, onSelect = onSelect)
}
}
@Composable
private fun PaletteGrid(selected: Int, onSelect: (Int) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
PaletteColors.chunked(3).forEach { rowColors ->
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
rowColors.forEach { color ->
val isSelected = color == selected
Box(
modifier = Modifier
.size(36.dp)
.border(
width = if (isSelected) 3.dp else 1.dp,
color = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outlineVariant,
shape = RoundedCornerShape(6.dp),
)
.clip(RoundedCornerShape(6.dp))
.background(Color(color))
.clickable { onSelect(color) },
)
}
}
}
}
}
@@ -0,0 +1,182 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.cw
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Rolling spectrogram history for the waterfall display.
*
* Reuses [CwDeepSpectrogram] so the picture shows exactly the 400-1200 Hz band
* and the same magnitudes the model sees — what you look at is what it decodes.
*/
class CwWaterfallState(private val historyRows: Int = 96) {
// pushSamples runs on the audio capture thread while snapshot() runs on the
// Compose draw thread, so every touch of these two collections is guarded.
// ArrayDeque is not thread-safe: concurrent removeFirst()/toList() throws.
private val lock = Any()
private val rows = ArrayDeque<FloatArray>(historyRows)
private val pending = ArrayList<Float>(CwDeepSpectrogram.SAMPLE_RATE)
/**
* Bumped on every change so Compose knows to redraw.
*
* A StateFlow, not `mutableIntStateOf`: this is written from the audio
* capture thread, and Compose snapshot state must only be mutated from the
* composition thread — doing otherwise crashes at runtime.
*/
private val _revision = MutableStateFlow(0)
val revision: StateFlow<Int> = _revision.asStateFlow()
/** Snapshot for drawing, oldest row first. */
fun snapshot(): List<FloatArray> = synchronized(lock) { rows.toList() }
fun pushSamples(chunk: FloatArray, sampleRate: Int) {
if (chunk.isEmpty()) return
val resampled = CwDeepSpectrogram.resampleLinear(
chunk, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
)
val audio: FloatArray
synchronized(lock) {
pending.ensureCapacity(pending.size + resampled.size)
for (sample in resampled) pending.add(sample)
// Need at least one FFT window before a row can be produced.
if (pending.size < CwDeepSpectrogram.FFT_LENGTH) return
audio = FloatArray(pending.size) { pending[it] }
pending.clear()
}
// FFT outside the lock; only the append below needs exclusivity.
val computed = CwDeepSpectrogram.compute(audio)
synchronized(lock) {
for (row in computed) {
if (rows.size >= historyRows) rows.removeFirst()
rows.addLast(row)
}
}
_revision.value += 1
}
fun clear() {
synchronized(lock) {
rows.clear()
pending.clear()
}
_revision.value += 1
}
}
/**
* Draws the waterfall newest-row-last, one pixel column per frequency bin.
* Colour ramp is the inferno palette (black -> purple -> orange -> yellow).
*/
@Composable
internal fun CwWaterfallView(
state: CwWaterfallState,
signalStrength: Float,
modifier: Modifier = Modifier
) {
val revision by state.revision.collectAsState()
Canvas(modifier = modifier.fillMaxSize()) {
// Touch the revision inside the draw scope so a new spectrum triggers a
// redraw; without this read the canvas would only ever render once.
@Suppress("UNUSED_EXPRESSION") revision
drawRect(color = Color(0xFF00060F), size = size)
val rows = state.snapshot()
if (rows.isEmpty()) return@Canvas
// Scale to the loudest value on screen so quiet signals stay visible.
var peak = 0f
for (row in rows) for (v in row) if (v > peak) peak = v
if (peak <= 0f) return@Canvas
val rowHeight = size.height / rows.size
val binWidth = size.width / CwDeepSpectrogram.FREQUENCY_BINS
for ((index, row) in rows.withIndex()) {
val y = index * rowHeight
// Linear interpolation between adjacent bins via a horizontal
// gradient removes the blocky "pixel" look of 65 discrete columns.
for (bin in 0 until row.size - 1) {
val m0 = (row[bin] / peak).coerceIn(0f, 1f)
val m1 = (row[bin + 1] / peak).coerceIn(0f, 1f)
if (m0 < 0.06f && m1 < 0.06f) continue
drawRect(
brush = Brush.horizontalGradient(listOf(inferno(m0), inferno(m1))),
topLeft = Offset(bin * binWidth, y),
size = Size(binWidth + 1f, rowHeight + 1f)
)
}
}
if (signalStrength > 0f) {
drawRect(
color = Color(0xFF4CD964).copy(alpha = 0.8f),
topLeft = Offset(0f, size.height - 3f),
size = Size(size.width * signalStrength.coerceIn(0f, 1f), 3f)
)
}
}
}
/**
* matplotlib "inferno" colour map, approximated with piecewise-linear stops
* (black -> purple -> magenta-red -> orange -> pale yellow). The same palette
* used for the static spectrogram illustration, kept for visual consistency.
*/
private val INFERNO_STOPS = arrayOf(
floatArrayOf(0.00f, 0.000f, 0.000f, 0.016f), // black
floatArrayOf(0.25f, 0.231f, 0.059f, 0.439f), // deep purple
floatArrayOf(0.50f, 0.549f, 0.161f, 0.506f), // magenta
floatArrayOf(0.75f, 0.871f, 0.286f, 0.408f), // red-orange
floatArrayOf(1.00f, 0.988f, 1.000f, 0.643f) // pale yellow
)
private fun inferno(t: Float): Color {
val x = t.coerceIn(0f, 1f)
for (i in 0 until INFERNO_STOPS.size - 1) {
val a = INFERNO_STOPS[i]
val b = INFERNO_STOPS[i + 1]
if (x <= b[0]) {
val f = (x - a[0]) / (b[0] - a[0])
return Color(
red = a[1] + (b[1] - a[1]) * f,
green = a[2] + (b[2] - a[2]) * f,
blue = a[3] + (b[3] - a[3]) * f
)
}
}
return Color(0.988f, 1.0f, 0.643f)
}
@@ -1,14 +0,0 @@
package com.rtbishop.look4sat.feature.cw.suncompat;
/**
* 编译期 stub:Android 无 sun.misc.Cleaner(照搬 Morse Expert 1.15 k3 FFT 库的清理回调,
* 实际 FFT 1024 点不触发 native 路径)。
*/
public class Cleaner {
public static Cleaner create(Object referent, Runnable thunk) {
return null;
}
public void clean() {
}
}
@@ -1,83 +0,0 @@
package com.rtbishop.look4sat.feature.cw.suncompat;
/**
* 编译期 stub:Android 无 sun.misc.Unsafe(运行时通过反射获取,失败时 k3.r.f12109a 为 null)。
* 照搬自 Morse Expert 1.15(k3 FFT 库的大数组 native 路径 >1GB 不触发,实际 FFT 走 float[] 分支)。
*/
public class Unsafe {
public long allocateMemory(long bytes) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void freeMemory(long address) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public byte getByte(long address) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void putByte(long address, byte value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public short getShort(long address) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void putShort(long address, short value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public int getInt(long address) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void putInt(long address, int value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public long getLong(long address) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void putLong(long address, long value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public float getFloat(long address) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void putFloat(long address, float value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public double getDouble(long address) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void putDouble(long address, double value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void setMemory(long address, long bytes, byte value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void copyMemory(long src, long dst, long len) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public Object getObject(Object o, long offset) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public void putObject(Object o, long offset, Object value) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
public long objectFieldOffset(java.lang.reflect.Field field) {
throw new UnsupportedOperationException("sun.misc.Unsafe not available on Android");
}
}
@@ -1,138 +0,0 @@
package com.ve3nea.morse_expert;
import F2.a;
import android.content.Context;
import android.content.Intent;
import android.media.MediaScannerConnection;
import android.text.Layout;
import android.text.Selection;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.format.DateFormat;
import android.util.AttributeSet;
import android.util.Log;
import android.view.ActionMode;
import android.widget.TextView;
import android.widget.Toast;
import java.io.File;
import java.io.PrintWriter;
import java.util.Date;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 照搬自 Morse Expert 1.15 DecodedTextView, 类名保持混淆原名。
* 适配: 原 extends l.C1702e0(appcompat 混淆类) -> 直接 extends TextView
* (C1702e0 仅提供 TextView 兼容功能, 本类未使用其特有方法)。
*/
public class DecodedTextView extends TextView {
/* renamed from: m, reason: collision with root package name */
public final Context f11031m;
/* renamed from: n, reason: collision with root package name */
public boolean f11032n;
/* renamed from: o, reason: collision with root package name */
public final AtomicBoolean f11033o;
public DecodedTextView(Context context, AttributeSet attributeSet) {
super(context, attributeSet);
this.f11032n = false;
this.f11033o = new AtomicBoolean(false);
this.f11031m = context;
setCustomSelectionActionModeCallback(getActionModeCallback());
}
private ActionMode.Callback getActionModeCallback() {
return new a(this);
}
private String getDecodedText() {
int selectionStart = getSelectionStart();
int selectionEnd = getSelectionEnd();
int max = Math.max(0, Math.min(selectionStart, selectionEnd));
int max2 = Math.max(0, Math.max(selectionStart, selectionEnd));
if (max2 > max) {
return getText().subSequence(max, max2).toString();
}
return getText().toString();
}
@Override // android.widget.TextView
public final void onSelectionChanged(int i4, int i5) {
super.onSelectionChanged(i4, i5);
if (hasSelection()) {
this.f11032n = true;
}
}
public final void q(SpannableStringBuilder spannableStringBuilder) {
boolean z3;
try {
CharSequence text = getText();
Layout layout = getLayout();
int lineCount = getLineCount();
int length = text.length();
int lineHeight = getLineHeight();
int scrollY = getScrollY();
if (layout.getLineBottom(lineCount - 1) == getHeight() + scrollY) {
z3 = true;
} else {
z3 = false;
}
getSelectionStart();
getSelectionEnd();
hasSelection();
if (z3) {
Selection.setSelection((Spannable) text, length, length);
}
append(spannableStringBuilder);
int i4 = lineCount - 50;
if (i4 > 0) {
int i5 = lineHeight * i4;
getEditableText().delete(0, layout.getLineEnd(lineCount - 51));
if (scrollY >= i5) {
scrollBy(0, -i5);
}
}
} catch (Exception e4) {
Log.e("printChars()", "Exception: ", e4);
}
}
public final void r() {
String decodedText = getDecodedText();
boolean isEmpty = TextUtils.isEmpty(decodedText);
Context context = this.f11031m;
if (isEmpty) {
Toast.makeText(context, "No text to save", 0).show();
return;
}
try {
File file = new File(getContext().getExternalFilesDir(null), ((String) DateFormat.format("yyyy-MM-dd_HH_mm_ss", new Date())) + ".txt");
String absolutePath = file.getAbsolutePath();
PrintWriter printWriter = new PrintWriter(file);
try {
printWriter.println(decodedText);
printWriter.close();
MediaScannerConnection.scanFile(context, new String[]{absolutePath}, new String[]{"text/plain"}, null);
Toast.makeText(context, "Text saved to " + absolutePath, 1).show();
} catch (Throwable th) {
printWriter.close();
throw th;
}
} catch (Exception e4) {
Log.e("File Save", "Failed", e4);
Toast.makeText(context, "Unable to save: " + e4.getMessage(), 1).show();
}
}
public final void s() {
Intent intent = new Intent("android.intent.action.SEND");
intent.setFlags(268435456);
intent.putExtra("android.intent.extra.TEXT", getDecodedText());
intent.setType("text/plain");
this.f11031m.startActivity(Intent.createChooser(intent, "Share Decoded Text"));
}
}
@@ -1,232 +0,0 @@
package com.ve3nea.morse_expert;
import android.content.Context;
import android.os.Build;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.constraintlayout.widget.ConstraintLayout;
import com.rtbishop.look4sat.feature.cw.R;
import E2.g;
import H2.a;
import H2.b;
import j1.C1646n;
/**
* Ported verbatim from Morse Expert 1.15 MainActivity (com.ve3nea.morse_expert); obfuscated
* class/field names kept as-is. Surgery notes: originally extends e.AbstractActivityC1557l
* (obfuscated AppCompat base) -> now a plain controller class, Activity injected via
* onCreate(Activity, ConstraintLayout) for Compose AndroidView integration. Ads (d1.*),
* billing (I2.a), premium/rate menu removed per user. h3.b.r (recursive findViewById),
* K1.a.M (immersive status bar), B.i (permissions) replaced with equivalent standard APIs.
* Decoder logic (H2.a audio, H2.b core, pas engine) untouched.
*/
public class MainActivity {
public static final /* synthetic */ int f11034K = 0;
/* renamed from: D, reason: collision with root package name */
public C1646n f11035D;
/* renamed from: E, reason: collision with root package name */
public a f11036E;
/* renamed from: F, reason: collision with root package name */
public b f11037F;
/* renamed from: G, reason: collision with root package name */
public long f11038G = 0;
/* renamed from: J, reason: collision with root package name */
public final g f11040J = new g(this, 1);
/** Injected Activity context (replaces the original this Activity capabilities) */
public android.app.Activity mActivity;
public void onCreate(android.app.Activity activity, ConstraintLayout constraintLayout) {
onCreate(activity, constraintLayout, true);
}
/**
* applyImmersive=false 用于嵌入小容器(如转发器 CW 面板):
* 不触碰宿主窗口的系统栏标志(避免影响整个 Activity)。
*/
public void onCreate(android.app.Activity activity, ConstraintLayout constraintLayout, boolean applyImmersive) {
this.mActivity = activity;
if (applyImmersive) {
// K1.a.M(getWindow(), false): immersive status bar (nav/fullscreen flags, ported as-is)
setImmersive(activity.getWindow(), false);
}
View inflate = constraintLayout;
int i4 = R.id.decodedTextView;
DecodedTextView decodedTextView = (DecodedTextView) findViewRecursive(inflate, R.id.decodedTextView);
if (decodedTextView != null) {
i4 = R.id.scaleView;
ScaleView scaleView = (ScaleView) findViewRecursive(inflate, R.id.scaleView);
if (scaleView != null) {
i4 = R.id.statusTextView;
TextView textView = (TextView) findViewRecursive(inflate, R.id.statusTextView);
if (textView != null) {
i4 = R.id.verticalLayout;
LinearLayout linearLayout = (LinearLayout) findViewRecursive(inflate, R.id.verticalLayout);
if (linearLayout != null) {
i4 = R.id.waterfallView;
WaterfallSurfaceView waterfallSurfaceView = (WaterfallSurfaceView) findViewRecursive(inflate, R.id.waterfallView);
if (waterfallSurfaceView != null) {
this.f11035D = new C1646n(constraintLayout, decodedTextView, scaleView, textView, linearLayout, waterfallSurfaceView);
((WaterfallSurfaceView) this.f11035D.f11892k).setOnTouchListener(this.f11040J);
// Originally: permission check then v(); permission flow handled by the Compose page
// Originally: ad banner mounting (removed)
return;
}
}
}
}
}
throw new NullPointerException("Missing required view with ID: " + inflate.getResources().getResourceName(i4));
}
/** Original h3.b.r: recursive findViewById (ported behavior) */
private static View findViewRecursive(View view, int id) {
if (view instanceof ViewGroup) {
ViewGroup viewGroup = (ViewGroup) view;
int childCount = viewGroup.getChildCount();
for (int i = 0; i < childCount; i++) {
View found = viewGroup.getChildAt(i).findViewById(id);
if (found != null) {
return found;
}
}
return null;
}
return null;
}
/** Original K1.a.M(window, false): immersive (hide nav/fullscreen flags, ported as-is) */
private static void setImmersive(Window window, boolean immersive) {
if (Build.VERSION.SDK_INT >= 30) {
window.setDecorFitsSystemWindows(!immersive);
if (immersive) {
window.getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN);
} else {
window.getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN);
}
} else {
View decorView = window.getDecorView();
int systemUiVisibility = decorView.getSystemUiVisibility();
decorView.setSystemUiVisibility(immersive ? (systemUiVisibility & (-1793)) : (systemUiVisibility | 1792));
}
}
/** Original v(): audio capture + decoder core init (logic unchanged) */
public void v() {
this.f11036E = new a();
b bVar = new b(mActivity.getApplicationContext());
this.f11037F = bVar;
this.f11036E.f601l = bVar;
bVar.f619o = (WaterfallSurfaceView) this.f11035D.f11892k;
bVar.f620p = this;
bVar.f621q = this;
}
/** Original onResume: restore decoding (message type/font/background/record start/waterfall) */
public void onResume() {
android.content.SharedPreferences prefs = mActivity.getSharedPreferences(
mActivity.getPackageName() + "_preferences", Context.MODE_PRIVATE);
String messageType = prefs.getString("message_type", "general_text");
b bVar = this.f11037F;
if (bVar != null) {
bVar.f623s.set(messageType.equals("ham_radio_qso"));
}
((DecodedTextView) this.f11035D.f11888g).setTextSize(Math.max(7, prefs.getInt("text_font_size", 18)));
b bVar2 = this.f11037F;
if (bVar2 != null) {
bVar2.f617m.b(mActivity);
((DecodedTextView) this.f11035D.f11888g).setBackgroundColor(this.f11037F.f617m.f665a[0]);
}
a aVar = this.f11036E;
if (aVar != null) {
android.media.AudioRecord audioRecord = aVar.f596g;
audioRecord.startRecording();
if (audioRecord.getState() != 1) {
android.util.Log.e("AudioRecord", "cannot start");
}
aVar.f598i.set(true);
}
((WaterfallSurfaceView) this.f11035D.f11892k).onResume();
// Originally: ad lifecycle (removed)
}
/** Original onPause: stop recording + waterfall pause */
public void onPause() {
a aVar = this.f11036E;
if (aVar != null) {
aVar.f598i.set(false);
android.media.AudioRecord audioRecord = aVar.f596g;
audioRecord.stop();
if (audioRecord.getState() != 1) {
android.util.Log.e("AudioRecord", "cannot stop");
}
}
((WaterfallSurfaceView) this.f11035D.f11892k).onPause();
}
/** Original onDestroy: release audio/decoder (billing cleanup removed) */
public void onDestroy() {
a aVar = this.f11036E;
if (aVar != null) {
aVar.f598i.set(false);
try {
aVar.f596g.stop();
aVar.f596g.release();
} catch (Exception ignored) {
}
aVar.f597h.shutdownNow();
}
this.f11036E = null;
this.f11037F = null;
}
/** Menu: clear decoded text (original clear_mnu) */
public void clearDecoded() {
((DecodedTextView) this.f11035D.f11888g).setText("");
}
/** Menu: pause/resume decoding (original pause_mnu, red/white icon toggle logic) */
public boolean togglePause() {
java.util.concurrent.atomic.AtomicBoolean atomicBoolean = ((DecodedTextView) this.f11035D.f11888g).f11033o;
atomicBoolean.set(!atomicBoolean.get());
return atomicBoolean.get();
}
/** Menu: save text (original save_mnu -> DecodedTextView.r()) */
public void saveText() {
((DecodedTextView) this.f11035D.f11888g).r();
}
/** Menu: record signals (original record_signals_mnu) */
public void recordSignals() {
this.f11037F.f626v.set(true);
}
/** Original tap_back_again_to_close hint */
public boolean handleBackPress() {
if (System.currentTimeMillis() < this.f11038G + 2000) {
return true; // double-back: exit
}
this.f11038G = System.currentTimeMillis();
Toast.makeText(mActivity, R.string.tap_back_again_to_close, 0).show();
return false;
}
/** Start decoding after permission granted (original onRequestPermissionsResult success) */
public void onPermissionGranted() {
v();
}
}
@@ -1,76 +0,0 @@
package com.ve3nea.morse_expert;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.util.AttributeSet;
import android.view.View;
/* loaded from: classes.dex */
public class ScaleView extends View {
public int f;
/* renamed from: g, reason: collision with root package name */
public int f11041g;
/* renamed from: h, reason: collision with root package name */
public final Paint f11042h;
/* renamed from: i, reason: collision with root package name */
public final Paint f11043i;
/* renamed from: j, reason: collision with root package name */
public final Paint f11044j;
/* renamed from: k, reason: collision with root package name */
public final Paint f11045k;
public ScaleView(Context context, AttributeSet attributeSet) {
super(context, attributeSet);
Paint paint = new Paint();
this.f11042h = paint;
paint.setColor(-16711936);
Paint paint2 = new Paint();
this.f11043i = paint2;
paint2.setColor(-16744448);
Paint paint3 = new Paint();
this.f11044j = paint3;
paint3.setColor(-256);
Paint paint4 = new Paint();
this.f11045k = paint4;
paint4.setColor(-8355840);
}
@Override // android.view.View
public final void onDraw(Canvas canvas) {
Paint paint;
super.onDraw(canvas);
int i4 = this.f11041g;
if (i4 != 1) {
if (i4 != 2) {
if (i4 != 3) {
if (i4 != 4) {
return;
} else {
paint = this.f11044j;
}
} else {
paint = this.f11045k;
}
} else {
paint = this.f11042h;
}
} else {
paint = this.f11043i;
}
int round = (int) Math.round(getWidth() * 0.7d);
Path path = new Path();
path.moveTo(0.0f, this.f);
float f = round;
path.lineTo(f, this.f - round);
path.lineTo(f, this.f + round);
path.close();
canvas.drawPath(path, paint);
}
}
@@ -1,19 +0,0 @@
package com.ve3nea.morse_expert;
import J2.b;
import android.content.Context;
import android.opengl.GLSurfaceView;
import android.util.AttributeSet;
/* loaded from: classes.dex */
public class WaterfallSurfaceView extends GLSurfaceView {
public final b f;
public WaterfallSurfaceView(Context context, AttributeSet attributeSet) {
super(context, attributeSet);
setEGLContextClientVersion(2);
b bVar = new b(context, H2.b.f603B);
this.f = bVar;
setRenderer(bVar);
}
}
@@ -1,82 +0,0 @@
package d1;
/* renamed from: d1.b, reason: case insensitive filesystem */
/* 照搬自 Morse Expert 1.15, 混淆名保持。b(Parcel)/c(y2.d) 为库工具已裁剪(L1/y2 库依赖)。 */
public abstract /* synthetic */ class AbstractC1518b {
public static float a(float f, float f4, k3.d dVar, long j4, float f5, float f6) {
dVar.c(j4, f - f4);
return f5 + f6;
}
public static String d(String str, int i4) {
return str + i4;
}
public static String e(String str, String str2) {
return str + str2;
}
public static String f(String str, String str2, String str3) {
return str + str2 + str3;
}
public static String g(StringBuilder sb, int i4, String str) {
sb.append(i4);
sb.append(str);
return sb.toString();
}
public static String h(StringBuilder sb, String str, String str2) {
sb.append(str);
sb.append(str2);
return sb.toString();
}
public static /* synthetic */ boolean i(Object obj) {
return obj != null;
}
public static float j(float f, float f4, k3.d dVar, long j4, float f5, float f6) {
dVar.c(j4, f + f4);
return f5 + f6;
}
public static String k(String str, String str2) {
return str + str2;
}
public static float l(float f, float f4, k3.d dVar, long j4, float f5, float f6) {
dVar.c(j4, f - f4);
return f5 - f6;
}
public static float m(float f, float f4, k3.d dVar, long j4, float f5, float f6) {
dVar.c(j4, f + f4);
return f5 - f6;
}
public static /* synthetic */ String n(int i4) {
switch (i4) {
case 1:
return "NONE";
case 2:
return "LEFT";
case 3:
return "TOP";
case 4:
return "RIGHT";
case 5:
return "BOTTOM";
case 6:
return "BASELINE";
case 7:
return "CENTER";
case 8:
return "CENTER_X";
case 9:
return "CENTER_Y";
default:
throw null;
}
}
}
-34
View File
@@ -1,34 +0,0 @@
package g3;
/* loaded from: classes.dex */
public final class a {
/* renamed from: a, reason: collision with root package name */
public final int f11582a;
/* renamed from: b, reason: collision with root package name */
public final double f11583b;
public final double c;
public a(double d4) {
int i4 = (int) (0.6366197723675814d * d4);
while (true) {
double d5 = -i4;
double d6 = 1.570796251296997d * d5;
double d7 = d4 + d6;
double d8 = 7.549789948768648E-8d * d5;
double d9 = d8 + d7;
double d10 = (-((d7 - d4) - d6)) + (-((d9 - d7) - d8));
double d11 = d5 * 6.123233995736766E-17d;
double d12 = d11 + d9;
double d13 = d10 + (-((d12 - d9) - d11));
if (d12 > 0.0d) {
this.f11582a = i4;
this.f11583b = d12;
this.c = d13;
return;
}
i4--;
}
}
}
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more