diff --git a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt
index 334a9308..adea0394 100644
--- a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt
+++ b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt
@@ -74,7 +74,13 @@ internal fun Project.setupAndroidApp() {
}
androidResources {
generateLocaleConfig = true
- localeFilters.addAll(listOf("en", "es", "ru", "si", "tr", "uk", "zh"))
+ // "zh" alone is stripped down to the Hans config: without the Traditional
+ // region entries below, values-b+zh+Hant is filtered out of the APK and a
+ // zh-TW / zh-HK / zh-MO device falls back to English. aapt2 only accepts
+ // the legacy "zh-rTW" spelling here ("zh-Hant" fails the build).
+ localeFilters.addAll(
+ listOf("en", "es", "ru", "si", "tr", "uk", "zh", "zh-rTW", "zh-rHK", "zh-rMO")
+ )
}
packaging { resources { excludes += listOf("META-INF/*") } }
}
diff --git a/core/presentation/src/main/res/values-b+zh+Hant/strings.xml b/core/presentation/src/main/res/values-b+zh+Hant/strings.xml
new file mode 100644
index 00000000..9c2ab235
--- /dev/null
+++ b/core/presentation/src/main/res/values-b+zh+Hant/strings.xml
@@ -0,0 +1,529 @@
+
+
+
+
+ 确定
+ 取消
+ 没有数据可显示
+
+
+ 卫星
+ 过境
+ 雷达
+ 匹配
+ 地图
+ 设置
+
+
+ 类型:%s
+ 选择卫星类型
+ Id - 名称
+ 清空
+ 全部清空
+ 全选
+ 已选择
+ 可用
+ 已选择(%1$d)
+ 可用(%1$d)
+ 请确保您的搜索查询正确且数据库已更新
+ 警告\!
+ 此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星
+
+
+ 筛选过境
+ 仰角值
+ 提前时间
+ 历史过境时间
+ AOS 时间段
+ 反选 AOS 时间段
+ DeepSpace(周期 >225分钟)
+ 选择调制类型
+ 仰角:%.1f°
+ 高度:%d km
+ 请确保您的过滤器设置正确,并且已选择卫星
+ 没有即将过境的卫星
+ 欢迎来到 Look4Sat\!
+ 请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性
+
+
+
+AMSAT
+ AMSAT 卫星状态
+ 刷新
+ 重试
+ 更新于:
+ 活跃
+ 遥测/信标
+ 未听到
+ 冲突
+ 名称
+ 加载失败 - 检查网络后重试
+ 此时间段无报告
+ 关闭
+ 上传
+ 返回
+ 上传报告到 AMSAT
+ 状态
+ 呼号
+ 网格
+ 时间:当前 UTC %s
+ 提交到 AMSAT
+ 确认上传
+ 确认上传到 AMSAT
+ 这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s
+ 未填写
+ 报告已上传
+ 上传失败:%s
+ 请填写呼号
+ 网格应为 4 或 6 位 Maidenhead 定位符
+ 活跃
+ 遥测/信标
+ 未听到
+ ISS 语音
+
+
+ 后退
+ 通知
+ 方位角
+ 仰角
+ 高度
+ 距离
+ 地影中
+ 下行链路
+ 上行链路
+ 这颗卫星没有收发器
+ 添加
+ 卫星收发器:
+ 倒置:%s
+ 调制:%s
+ 否
+ 是
+ 日照中
+
+ 多普勒频率计算器
+ 输入上行频率 (MHz)
+ 输入下行频率 (MHz)
+ 偏移 (kHz)
+ 线性转发器:输入一个频率即可算出另一个
+
+ CW 解码器
+ 开始
+ 停止
+ 清空
+
+
+ 上一个
+ 下一个
+ 网格模式
+ 卫星模式
+ 首通呼号
+ 方位角:%.1f°
+ 仰角:%.1f°
+ 高度:%.0f km
+ 距离:%.0f km
+ 纬度:%.1f°
+ 经度:%.1f°
+ 相位:%.1f°
+ 地影中
+ 周期:% .0f min
+ 速度:%.2f km/s
+ 能见度:%s
+
+ %d 个呼号 · %d 次通联
+ 该区域暂无已确认通联
+ %d QSO
+ %d QSOs
+ 首通
+ 卫星未知
+ 匹配
+
+ 标记你想通联的电台
+ 呼号
+ 标记
+ 标记
+ 删除标记
+ 置顶
+ 日照中
+
+
+ 隐私政策
+
+ 更新:%s
+ yyyy年M月d日 HH:mm:ss
+
+ 站点位置
+ GPS 定位
+ 检查您的位置权限
+ 输入位置
+ 输入的位置无效
+ QTH 网格
+ 无效的 QTH 网格
+ 位置更新成功
+ 站点位置设置
+ 输入站点的纬度
+ 输入站点的经度
+ QTH 网格
+ 配置
+ Wavelog 地址
+ 同步中…
+ Wavelog 配置
+ 与 LoTW 二选一配置,两者不兼容。
+ 增量同步
+ 全量同步
+ 清除配置
+ 清除 Wavelog 配置?
+ 清除后将切回 LoTW 模式,LoTW 功能恢复可用。
+ 配置提醒
+ 已配置 LoTW。配置 Wavelog 后,App 内 LoTW 的上传与同步将不可用(两者二选一,不兼容)。继续?
+ 继续
+ Wavelog 模式中不可用(二选一)
+ 未配置 — 把本机通联记录上传到 Wavelog
+ 站点 %1$s — 可以上传
+ API key(读写权限)
+ 获取站点
+ 检测中…
+ 未选择站点 — 点「获取站点」
+ 密钥:读写 — 可上传
+ 密钥只读 — 请在 Wavelog → API 生成「读写」密钥
+ 点「获取站点」选择上传使用的台站档案;密钥需要「读写」权限。
+ LoTW 已确认网格
+ LoTW 呼号
+ LoTW 密码
+ 直接从 ARRL LoTW 拉取该呼号已确认 QSO 的网格并覆盖本地已存集合(当前 %1$d 个)。密码仅保存在手机本地。点"同步"会自动保存凭据并拉取。
+ 同步
+ 同步中…
+ 已从 LoTW 同步 — 共 %1$d 个网格
+ 未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器
+ LoTW 呼号/密码未配置
+ LoTW 同步失败——呼号或密码错误
+ LoTW 同步失败——服务器限流,请等几分钟后再试
+ LoTW 同步失败——连接超时,请更换网络重试
+ LoTW 同步失败——网络错误(%1$s)
+ 全量同步
+ 增量合并
+ 下载速度约每 100 条 QSO 需 8 秒(另有 5–40 秒服务器生成时间)
+ 正在连接 LoTW 服务器…
+ 正在下载报告…
+ 正在处理下载的数据…
+ 本次同步 %1$d 条 QSO…
+ 本次同步 %1$d 条 QSO,预计还需 %2$d 秒
+ 使用梅登黑德网格设置站点位置
+
+ 删除
+ 日志本
+ %1$d 条记录 — 本地通联与 LoTW 确认
+ 暂无记录 — 在雷达页 Log 标签记录通联
+ 关闭
+ 上传
+ 上传到 LoTW
+ 确认上传
+ 重传选中 (%1$d)
+ 已选 %1$d 条
+ 长按记录行可多选重传
+ 已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。
+ 其中 %1$d 条记录没有呼号,将按证书 %2$s 上传。
+ %1$d 条记录登记的不是证书 %2$s 的呼号。
+ 改写为 %1$s
+ 换一张证书
+ 台址
+ 全部
+ 上传到 Wavelog
+ 上传前核对
+ 修改台址
+ 忽略
+ 当前定位 %1$s 不在证书台址网格 %2$s 内——如果正在此处操作,请先更新台址再上传。
+ 当前定位 %1$s 不在证书台址网格 %2$s 内 · 点此更新
+ LoTW 上传证书
+ 证书已导入 · 台址网格 %1$s
+ 未配置 — 导入 TrustedQSL 证书后即可上传通联
+ 处理中…
+ 导入你的 TrustedQSL .p12 证书(与 TQSL 同款)用于签名上传通联。密码仅加密保存在手机本地。
+ 证书密码
+ 证书无密码时留空即可
+ 选择 .p12 文件
+ 导入
+ 证书密码错误,请检查 TQSL 导出 .p12 时设置的密码
+ 证书已过期或尚未生效
+ 不是有效的 LoTW 证书文件
+ 不支持的 .p12 加密,请用 TQSL 默认加密重新导出后再试
+ 不支持的 .p12 加密(%1$s),请用 TQSL 默认加密重新导出后再试
+ 导入失败,请重试
+ %1$s · DXCC %2$d · 有效期至 %3$s
+ 移除证书
+ 台址
+ 未选择
+ 省份
+ 导入证书后可选
+ 网格(逗号分隔,可多格)
+ 保存
+ 关闭
+
+ 卫星数据更新
+ 卫星:%s
+ 收发器:%s
+ 在线更新
+ 导入文件
+ 清空
+ 数据清空成功
+ 更新成功
+
+ 未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。
+ 未导入收发器。请选择有效的 SatNOGS (.json) 文件。
+
+ 自定义数据源
+ 轨道数据优先取列表最新星历,此顺序仅影响卫星名称
+ 靠前优先:收发器数据
+ 恢复默认数据源
+ 自定义TLE URL
+ 自定义收发器URL
+ URL
+ 卫星数据
+ 收发器数据
+
+ 数据输出
+ 网络
+ 蓝牙
+ CAT
+
+ 网络数据输出
+ 启用方位俯仰角输出
+ IP:端口
+ 数据格式
+ 启用多普勒频率输出
+ IP:端口
+ 数据格式
+ 频率偏移 (Hz)
+ 范围:-50000 到 50000 Hz。正值提高上报频率,负值降低上报频率。
+
+ 蓝牙数据输出
+ 启用方位俯仰角输出
+ 蓝牙设备 ID
+ 数据格式
+ 启用多普勒频率输出
+ 蓝牙设备 ID
+ 数据格式
+ 请检查蓝牙权限
+ 请检查网络权限
+
+
+ 电台控制
+ CAT 电台控制
+ 电台型号
+ 发射电台蓝牙地址
+ 接收电台蓝牙地址
+ 发射电台名称
+ 接收电台名称
+ 启用 CAT 控制
+
+ 其他设置
+ 地图设置
+ 配置地图
+ 地图来源
+ 离线 OSM 地图
+ 天地图(矢量)
+ 天地图(影像)
+ 天地图访问密钥
+ 使用天地图前,需先在天地图开发者控制台申请服务端应用 Key。
+ 选择天地图需要填写访问密钥;保存密钥前将继续使用离线 OSM 地图。
+ 以 UTC 时间显示
+ 启用卫星数据自动更新
+ 自动同步 LoTW 已确认网格
+ 启用雷达扫描动画
+ 使用传感器旋转雷达视图
+ 校准指南针
+ 指南针校准
+ 缓慢移动手机画“8”字,直到精度提高。请远离磁铁和金属物体。
+ 精度:%s
+ 高——校准完成
+ 中——请继续移动
+ 低——请继续移动
+ 不可靠——请开始“8”字动作
+ 设备没有可用的磁场传感器
+ 校正后航向:%.1f°
+ 手动航向偏置(°)
+ 范围 −180° 至 +180°;正值为顺时针偏移。
+ 偏置归零
+ 浅色主题
+ 启用红色夜间模式
+ 地图显示 Maidenhead 网格
+
+ 仰角高亮
+ 低 < %1$d°
+ 中 %1$d-%2$d°
+ 高 > %1$d°
+
+ 致谢与声明
+ Project Wiki
+ Look4Sat-BA7OPF 是基于 Look4Sat 的定制版,保留上游全部功能,并新增:
+ 卫星日志本与 LoTW
+ 卫星通联日志本——通联时一键记录呼号、时间、频率、模式、信号报告和网格。
+ LoTW 直传——导入 ARRL 证书后,直接在 App 内把日志上传到 LoTW,无需电脑。
+ 确认同步——一键同步 LoTW 官方确认,日志自动标记“已上传 / 已确认”;修改已上传的记录会自动新建一条,原记录不受影响。
+ 用法:通联后点“记录”→ 日志页点“上传”→ 设置里“同步”查看确认。
+ 双站过境匹配
+ 友台共视匹配——输入你和友台的位置,找出双方同时可见的卫星过境,点一下直接进雷达图。
+ 用法:Match 页 → 设置两站位置 → 查询。
+ 通联网格地图
+ 已通联网格——LoTW/Wavelog 同步的网格在地图上标绿,支持奖状边界和首通呼号标签;点格子看该网格的通联记录,还能一键去匹配友台。
+ 用法:地图页开启网格模式 → 设置里同步 LoTW/Wavelog。
+
+ • Look4Sat 所有用户及贡献者!
+ \n• David A. B. Johnson (predict4java)
+ \n• Dave Moten (predict4java)
+ \n• Alexandru Csete (Gpredict)
+ \n• Dr T.S. Kelso (Celestrak)
+ \n• Libre Space Foundation (SatNOGS)
+ \n• xdsopl 和 Robot36 贡献者!
+ \n• BG7NTA BG5JSU(CW解码功能)
+ \n• BH2VSQ BG5JSU(天地图渲染支持)
+ \n• BG5JSU(LoTW上传支持)
+
+ Look4Sat-BA7OPF 是基于 Look4Sat(GPL-3.0)的非官方修改版,由 BA7OPF 独立维护,与原项目无隶属关系,亦未获其背书。
+上游项目:https://github.com/rt-bishop/Look4Sat
+基于 GNU GPL v3 许可 · 本程序不提供任何保修
+
+
+ 过境匹配
+ 你的位置
+ 友台位置
+ 你
+ 友台
+ 正在计算双方共同可见窗口…
+ 请修正提示后重新查询
+ 找到 %d 个匹配 · 点击卡片展开曲线
+ 未找到共同可见窗口,调整时间或仰角后重试
+ 输入双方位置后点击查询
+ 计算中
+ 有错误
+ %d 个匹配
+ 无匹配
+ 待查询
+ 填入当前位置
+ 纬度
+ 经度
+ 网格
+ 支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度
+ 最小仰角:%d°
+ 时间范围
+ 转发器
+ FM
+ 线性
+ 计算中…
+ 查询匹配
+ 你:%d°
+ 友台:%d°
+ 双方轨迹
+ 查看雷达
+ 站点A
+ 站点B
+ 检查更新
+ 检查更新
+ 当前版本:%s
+ 最新版本:%s
+ 已是最新版本
+ 发现新版本
+ 更新内容
+ 下载并安装
+ 正在下载…
+ 检查更新失败,请检查网络连接。
+ 下载失败,请重试。
+ 该版本未附带 APK 安装包。
+
+
+ 编辑通联记录
+ 该通联已上传或已确认。保存将保留原记录不变,并新建一条记录。
+ 呼号
+ 日期 (UTC)
+ 时间 (UTC)
+ 上行频率 (MHz)
+ 下行频率 (MHz)
+ 频段:%1$s
+ 模式
+ 卫星
+ LoTW 只接受它的官方卫星名
+ 发送报告
+ 接收报告
+ 对方网格
+ 备注
+ 保存
+ 取消
+ 不在过境窗口
+ 当前时间不在该过境窗口内,将默认按过境中点 %1$s 录入;如需修改,请点击该条通联记录。
+ 录入
+ 呼号不能为空
+ 日期或时间格式不对 —— 应为 yyyy-MM-dd 与 HH:mm:ss(UTC)
+ 频率格式不对 —— 请输入 MHz,例如 145.850
+ 不是 LoTW 官方卫星名 —— 请从列表中选一个
+ 选择
+ LoTW 官方卫星名
+ 搜索卫星名或数据源别名
+ 保存并签名时使用 %1$s
+ 没有上行频率:LoTW 要求 BAND,补上频率前这条无法上传
+
+
+ 网格寻线
+ 位于网格点上
+ 位于网格线上
+ 在网格内部
+ 位置不可用
+ 正在获取定位…
+ 需要定位权限
+ 本机定位服务未开启
+ 已由 %1$d 次连续满足 20 英尺规则的定位确认
+ 尚未确认:精度达 6.1 m 的连续定位 %1$d/%2$d 次
+ 授予定位权限
+ 位置
+ 最近网格点引导
+ 最近网格线引导
+ 两条边界线
+ 近距离网格图
+ 定位质量
+ 罗盘
+ 此处可计分的网格
+ 网格 (4)
+ 网格 (6)
+ 网格 (8)
+ 纬度
+ 经度
+ 海拔
+ 水平精度
+ 定位时刻
+ 定位来源
+ 已开启
+ 未开启
+ 距离
+ 方位角
+ 方向
+ 网格
+ 纬线
+ 经线
+ 位于%2$s方向 %1$s
+ 这是纬度边界,东西走向
+ 这是经度边界,南北走向
+ 朝向
+ 罗盘精度
+ 设为我的台址
+ 台址已更新 —— 过境预报将使用这个位置
+ 校准罗盘
+ 中心点是你的位置,虚线是正北与正东,直线是两条网格边界,绿点是最近的网格点,圆环是最近网格线的垂足,红色指针是你的朝向
+ 设置里未开启罗盘,表盘按正北朝上显示。方位角数字仍可引导你
+ 本机没有磁力传感器 —— 请按方位角数字导航
+ 请平持手机,箭头指向应走的方向
+ VUCC 20 英尺规则:%1$.1f m
+ 北
+ 南
+ 东
+ 西
+ 设为 LoTW 上传台址(%1$s)
+ 预填上传台站网格:上传时 LoTW 按这些格记录(线上 2 格、角上 4 格);证书呼号保持不变。
+
+
diff --git a/core/presentation/src/test/java/com/rtbishop/look4sat/core/presentation/TraditionalChineseFallbackTest.kt b/core/presentation/src/test/java/com/rtbishop/look4sat/core/presentation/TraditionalChineseFallbackTest.kt
new file mode 100644
index 00000000..7fc9f22a
--- /dev/null
+++ b/core/presentation/src/test/java/com/rtbishop/look4sat/core/presentation/TraditionalChineseFallbackTest.kt
@@ -0,0 +1,91 @@
+/*
+ * 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 .
+ */
+package com.rtbishop.look4sat.core.presentation
+
+import java.io.File
+import javax.xml.parsers.DocumentBuilderFactory
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.w3c.dom.Element
+
+/**
+ * Android 7+ resolves Chinese by script, so an unqualified values-zh is treated as
+ * zh-Hans and a Traditional device (zh-TW / zh-HK / zh-MO, i.e. zh-Hant) never matches
+ * it — it used to fall back to the English default. values-b+zh+Hant carries the
+ * Simplified text for those devices, and the locale filter in the convention plugin
+ * keeps that config in the APK.
+ *
+ * Both halves fail silently at runtime (English strings, or a Hant config dropped from
+ * the resource table), so they are pinned here: keep the Hant copy verbatim and keep
+ * the Traditional locales in the locale filter.
+ */
+class TraditionalChineseFallbackTest {
+
+ private val simplified = stringEntries("src/main/res/values-zh/strings.xml")
+ private val traditional = stringEntries("src/main/res/values-b+zh+Hant/strings.xml")
+ private val conventionPlugin = generateSequence(File("").absoluteFile) { it.parentFile }
+ .map { File(it, "build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt") }
+ .first { it.isFile }
+
+ @Test
+ fun `hant fallback is a verbatim copy of the simplified strings`() {
+ assertTrue("values-zh/strings.xml looks empty or unparsable", simplified.size > 400)
+ val missing = (simplified.keys - traditional.keys).sorted()
+ val extra = (traditional.keys - simplified.keys).sorted()
+ val changed = simplified.keys.intersect(traditional.keys)
+ .filter { simplified[it] != traditional[it] }
+ .sorted()
+ assertEquals("keys missing from values-b+zh+Hant", emptyList(), missing)
+ assertEquals("keys only present in values-b+zh+Hant", emptyList(), extra)
+ assertEquals("values-b+zh+Hant diverged from values-zh (copy the change over)", emptyList(), changed)
+ }
+
+ @Test
+ fun `traditional locales survive the locale filter`() {
+ assertTrue("convention plugin not found: ${conventionPlugin.absolutePath}", conventionPlugin.isFile)
+ // Comments may mention either spelling, so only the code lines are inspected.
+ val filters = conventionPlugin.readLines()
+ .filterNot { it.trimStart().startsWith("//") }
+ .joinToString("\n")
+ val traditionalLocales = listOf("zh-rTW", "zh-rHK", "zh-rMO")
+ val dropped = traditionalLocales.filterNot { filters.contains("\"$it\"") }
+ assertEquals(
+ "localeFilters must list the Traditional locales, otherwise aapt2 strips" +
+ " values-b+zh+Hant from the APK and Traditional devices get English",
+ emptyList(),
+ dropped
+ )
+ // aapt2 rejects the script spelling, so it must not creep back into the filter.
+ assertTrue("localeFilters must use the aapt2 'zh-rTW' spelling, not 'zh-Hant'", !filters.contains("\"zh-Hant\""))
+ }
+
+ private fun stringEntries(path: String): Map {
+ val file = File(path)
+ assertTrue("resource file not found: ${file.absolutePath}", file.isFile)
+ val document = DocumentBuilderFactory.newInstance()
+ .apply { isNamespaceAware = false }
+ .newDocumentBuilder()
+ .parse(file)
+ val nodes = document.getElementsByTagName("string")
+ return (0 until nodes.length).associate {
+ val element = nodes.item(it) as Element
+ element.getAttribute("name") to element.textContent.trim()
+ }
+ }
+}