diff --git a/CHINESE_AUDIT.md b/CHINESE_AUDIT.md new file mode 100644 index 0000000..00d47bc --- /dev/null +++ b/CHINESE_AUDIT.md @@ -0,0 +1,101 @@ +# Fieldwatch 中文补齐审计与验收记录 + +日期:2026-10-04。仓库:smallclover/Fieldwatch;本地分支:codex/i18n;基础版本:1.1.18(28)。本轮只完成英文与简体中文,日语暂缓。本记录随中文基线一并本地提交,未推送或发布。 + +提交前已获取 origin 与 upstream 的远程更新:两者 main 均为 `2b22008a1ef542e7b12298ca647883f0a50ffc59`,与开发基础相同,没有需要合并的新提交。 + +## 实现结论 + +中文资源与应用生成的动态文字已按本轮清单补齐。匹配规则、目录原始内容、用户名称/备注、协议数据及交换格式保留原义。验收区分自动检查、模拟器页面测试、PDF 看图和手机听测;未实际覆盖的条件继续标为受限,不宣称全矩阵通过。 + +## 缺口与处理 + +| 场景及源文件 | 发现的问题 | 处理及证据 | +| --- | --- | --- | +| 新建识别特征;ui/FieldwatchViewModel.kt | 默认名称为 New Signature | 新建时生成中文“新建识别特征”;设备测试验证切换/重建/取消,不改旧名称 | +| 日期、时长、会话容量;domain/Sit.kt、data/SitStore.kt | 英文日期格式、单位及删除最旧会话提示 | 当前语言日期与中文时长/提示;领域测试与实际报告验证 | +| 实时信道/时长;ui/screen/LiveScreens.kt | ch、h/m/s 等应用文字残留 | 信道及秒/分/小时使用资源;MHz 等标准单位保留 | +| 接收强度范围;domain/Rssi.kt | 详情和 AI 导出残留 to;缺失值为 Not available | 显示值使用中文“至”“不可用”;数值筛选计算保持原行为 | +| CoD 解释;i18n/CodLabels.kt、domain/DeviceExplain.kt | 蓝牙类别标签含大量英文 | 显示层翻译已知类别、服务组合及未知类别前缀;原始 CodDecoder 分类与推断逻辑保留,测试验证 | +| 广播未知类型;domain/AdvPayloadDecoder.kt | type N 未抽取 | “类型 N”,原始类型字节不改 | +| 网络/缓存状态;i18n/TakText.kt、domain/DebriefReport.kt、data/LogStore.kt | Off 静态缓存、推送失败及选定位置错误残留 | 读取时按当前语言渲染;内部状态判断仍用原值 | +| 通知启动状态;radio/ScanService.kt | Starting radios… 未抽取 | 启动与稳定扫描提示统一本地化,频道沿用原 ID | +| 候选规则/逻辑;domain/SignatureCandidates.kt、识别特征编辑页 | mfg、AND/OR 为自有显示文字 | 厂商、且/或显示翻译;规则 ID 和机器值不改 | +| 内置解码编辑;ui/screen/DecodeFieldsScreen.kt、i18n/CatalogText.kt | 编辑页标签/枚举/备注英文;编辑标签可能改变字段 ID | 对原始值匹配的内置字段采用显示覆盖;标签编辑保留内置 ID;设备测试验证自定义内容和取消边界 | +| 草稿恢复;ui/FieldwatchViewModel.kt、ui/SavedDraft.kt | ViewModel 草稿门控未保存,进程恢复可能丢失编辑器 | SavedStateHandle 保存门控/草稿模型,Compose 保存编辑字段;旋转/语言测试及真实后台进程回收验证 | +| 未支持语言;i18n/AppLanguage.kt | 英文资源与其他语言日期/语音可能混用 | 未支持配置统一回退英文上下文;API 35 系统 LocaleManager 注入法语验证 | +| PDF 布局;domain/DebriefReport.kt、data/DebriefPdf.kt | 布局依据英文标题;关注卡长标题/超长备注可能越界 | 用结构枚举标识关注段;标题测量换行,超长卡拆为可分页块;改标题和极长备注样本实测 | +| 语音队列;alert/Alerter.kt、alert/VoiceOutput.kt | 缺失语言/初始化/发声失败提示不足;旧语言延时回调可能继续 | 失败提示、语言变化使旧队列票据失效、每次发声使用当前语言;3 项可控语音测试通过,真实听测单列 | +| 资源维护 | 5 个确认无用字符串 | 删除英中对应资源;保留仍用于路径图例的额外关注文字;显式资源绑定检查 | + +源码候选审计包括页面、领域说明、告警、服务、报告、导出及稀少错误分支。人工复核后的保留项:Fieldwatch/作者/厂商和产品品牌;MIT 与 NOTICE 原文;设备广播名、SSID、用户文字;MAC/UUID/OUI/CoD/RSSI/GPS/TAK/CoT/Wi-Fi/BLE/IEEE 等协议标识及标准登记名称;解码类型/字节序/十六进制;dBm、MHz、m、km 等单位;UTC/ISO 机器时间;日志标签、URL、JSON/CSV 键与稳定 ID。原文变化或用户修改不满足内置原文校验时,显示原值,以避免错误覆盖。 + +## 测试设施与回归 + +- E 盘 JDK 17、Android SDK、Gradle 缓存、临时目录继续使用已有配置。API 35 AVD 位于 E 盘,以 headless/WHPX 运行。 +- 新增独立 i18nQa 构建:app.fieldwatch.i18nqa,可调试,不覆盖手机 app.fieldwatch。仅该构建提供 QA Activity,测试时扫描、在线查询和告警关闭。 +- 正常 Debug 保留 app.fieldwatch、现有签名、1.1.18/28,以及 R8 混淆与资源压缩。普通 APK manifest 已检查,未包含 QA Activity,未启用 debuggable。 +- 最终全量 JVM 回归:39 个测试类、460 项,失败/错误/跳过均 0。API 35 设备测试:6 项通过,34.355 秒。Lint:0 错误、56 警告;没有添加忽略基线。 +- 中文字符串:2,902 条;数量资源:3 组。领域/内置显式绑定:2,114 项。英中覆盖与格式参数检查、绑定工具和 git diff --check 通过。 + +剩余 Lint 警告涉及既有图标、API 判断、排版建议及 application 配置上下文缓存;未为中文工作扩大无关修复。SDK 工具 XML 版本差异仍有构建提示,未阻止构建。 + +## PDF 与文本验收 + +使用真实 Android DebriefReport、SitDiff 和 DebriefPdf.write,在隔离应用内生成虚构样本,再通过正常调试访问取回本地。没有导出手机真实监测数据,也没有发送给联系人、云盘、WPS 或外部 AI。 + +| 每种语言样本 | 页数 | 检查 | +| --- | --- | --- | +| empty / ordinary / decoded | 2 / 2 / 2 | 空数据、普通信号源、DULT 字段/枚举/说明 | +| long / many-path / retitled | 5 / 5 / 5 | 长中文与混排、16 个模拟信号源、修改关注段标题仍保持卡片结构 | +| compare | 3 | 双会话列表、路径和柱状图 | +| overflow | 7 | 极长关注标题和 220 次重复备注,末尾 QA END OF NOTE 保留 | +| transit | 4 | 实际包含停留和途经的路径,图表与文字说明对应 | + +18 份 PDF,共 70 页;全部以 110 dpi 渲染,并在 26 张联系表中逐页看图。未发现中文缺字、重叠、正文越界或页脚覆盖。极端长备注可以跨页,分页为保留完整内容而可能留下空白。正文文本提取仅用于辅助核对末尾标记;没有用文本检查替代视觉验收。 + +英中详情分享及 AI 导出使用相同虚构数据。中文 AI 提示明确要求简体中文回答,保留标识符与观察备注原文;解码内容、假设语气和免责声明已检查。没有为了验收请求外部 AI。 + +本地可复查中间结果:`.gradle/i18n/pdfs/`、`.gradle/i18n/pdf-render/manifest.json`、逐页 PNG 与联系表;均在忽略目录,测试可以重新生成。 + +## 设备矩阵 + +| 项目 | 结论与范围 | +| --- | --- | +| 中文/English/跟随系统 | API 35 测试通过;手机 Android 10 第一轮已有切换及重启偏好证据,最终包中文页面补验通过 | +| Android 13+ 系统语言入口同步 | API 35 LocaleManager 与 AppCompat 双向同步测试通过;实际系统设置入口完整人工点击流程尚未覆盖 | +| 未支持语言 | 法语应用配置回退英文资源/日期/语音上下文通过;未修改用户手机系统语言 | +| 新建特征、预设、会话命名 | 实际页面旋转重建和语言切换保持未保存字段;取消不提交,通过 | +| 解码标签、枚举及备注 | 实际内置 DULT 编辑、重建、语言切换、取消后原始记录完全相等,通过 | +| 只改内置数字字段 | minPeers 改为 7 保存,名称/说明/解码原始值保持,随后还原 QA 记录,通过 | +| 真实后台进程回收 | API 35,进程 6255 → 回收 → 6516,New SignatureQAProcess 草稿恢复;config.json 不含 QAProcess,已取消,通过 | +| 其他编辑器全部进程回收组合 | 受限:没有逐一测完设备命名、解码、预设、会话的所有回收组合 | +| 小屏与较大字体 | 模拟器 360dp、1.3 倍字体,中文设置入口/按钮/说明换行正常;原 1080×2400/420dpi/1.0 已恢复;未覆盖所有页面与无障碍实际朗读 | +| 中文夜间模式 | 模拟器设置页检查,临时开关已恢复;完整页面矩阵受限 | +| 手动停止后切换语言 | Android 10 第一轮通过;扫描/冻结/真实关注告警全部组合仍受限 | +| TTS 可用、失败及缺失 | 可控替身验证语言负返回不发声、成功/失败/异常及旧回调失效;最终手机点击“测试告警”,用户确认“有蜂鸣,也听到清晰中文”。真实关注命中组句及播放中切换的完整实际听测未覆盖 | +| 通知 | 最终包实测“Fieldwatch 扫描中”“11 个 Wi-Fi · 18 个 BLE · 2 个已识别”及“停止”;关注告警/已有频道设置完整矩阵未覆盖 | +| 蓝牙关闭、系统挂起、扫描失败 | 文案与保护逻辑已检查;未逐个制造全部真实硬件/系统异常 | + +## 最终包与手机 + +手机为华为 HMA_AL00,Android 10 / API 29。最终普通包于 2026-10-04 18:27:30 覆盖安装成功;读取安装后的公开 APK 文件计算 SHA-256,与本地最终文件完全一致。用户在手机自行完成华为验证,没有提供密码。实际测试告警已触发,用户确认“有蜂鸣,也听到清晰中文”。这是测试短句听测证据,不替代所有真实关注命中及语言变化组合。 + +最终包中文设置、报告、新建名称“新建识别特征”、详情范围“-90 至 -30 dBm”及扫描通知实测通过。新建草稿已取消,没有保存规则、会话或修改设备备注。详情推断中的原始匹配名称(例如 Microsoft Device)作为匹配证据原文保留,对应目录说明采用中文显示覆盖。全过程不卸载、不清数据、不导出真实报告;原有告警/蜂鸣/语音开关均为开启,未修改;临时类别展开已收起,充电常亮恢复原值 0。 + +最终普通 APK:`app/build/outputs/apk/debug/app-debug.apk`;2026-10-04 18:12:42 生成,11,819,438 字节;SHA-256:`EC4BB1134674B9C31BDCC9CB51BD71AEF149BF4C42E3FC4F1C962631F699D3A1`。最后一处文字修复后构建/单元/Lint 通过,相关报告设备测试 1 项复验通过(9.244 秒);详情和 AI 样本已重新取回,18 份 PDF 校验值均未变化,因此已有 70 页视觉证据仍对应最终报告内容。 + +## 重现方式 + +```powershell +# 使用已配置的 E 盘开发环境;普通包与隔离包分别生成。 +.\gradlew.bat assembleDebug assembleI18nQa assembleI18nQaAndroidTest testDebugUnitTest lintDebug --console=plain --no-daemon +node tools/update-i18n-resources.mjs --check +git diff --check +# 明确使用隔离模拟器,避免装到真实手机同包名应用。 +E:\Android\Sdk\platform-tools\adb.exe -s emulator-5554 install -r app/build/outputs/apk/i18nQa/app-i18nQa.apk +E:\Android\Sdk\platform-tools\adb.exe -s emulator-5554 install -r app/build/outputs/apk/androidTest/i18nQa/app-i18nQa-androidTest.apk +E:\Android\Sdk\platform-tools\adb.exe -s emulator-5554 shell am instrument -w app.fieldwatch.i18nqa.test/androidx.test.runner.AndroidJUnitRunner +``` + +设备测试源:`app/src/androidTest/java/app/fieldwatch/i18n/ChineseDeviceTest.kt`;测试入口:`app/src/i18nQa/`;单元报告:`app/build/reports/tests/testDebugUnitTest/index.html`;Lint:`app/build/reports/lint-results-debug.html`。 diff --git a/CHINESE_COMPLETION_PLAN.md b/CHINESE_COMPLETION_PLAN.md new file mode 100644 index 0000000..726b07a --- /dev/null +++ b/CHINESE_COMPLETION_PLAN.md @@ -0,0 +1,126 @@ +# Fieldwatch 中文版补齐与验收计划 + +制定日期:2026-10-04。执行日期:2026-10-04。当前状态:本轮中文补齐、关键验收和手机交付已完成;完整异常/组合矩阵仍有未覆盖项目,详见根目录 `CHINESE_AUDIT.md`。日语暂缓。 + +| 阶段 | 执行结果 | +| --- | --- | +| C1 / C2 | 已补齐审计清单中的动态文字、默认名称、时间、类别/解码显示、缓存与用户数据边界;2,902 条中文资源,2,114 项显式绑定 | +| C3 | 独立 i18nQa 构建与 API 35 模拟器可运行;正常包没有 QA 入口 | +| C4 | 18 份英中真实 Android PDF、70 页完成全页视觉检查;最后文字修复后 PDF 文件校验值未变化,详情/AI 样本重新验证 | +| C5 | 6 项设备测试、真实进程回收草稿恢复、小屏/大字体及夜间设置页通过;手机测试告警听到蜂鸣与清晰中文;完整异常/组合矩阵仍有未覆盖项目 | +| C6 | 最终混淆/资源压缩构建、460 项单元测试及 Lint 通过;手机 18:27:30 安装成功,实际 APK 校验一致,中文页面/通知补验与听测通过 | + +## 目标和边界 + +把已有的英文/简体中文实现补齐到可验证、可维护的中文版:应用生成的用户可见文字正确使用中文,语言切换和编辑状态可靠,通知和语音采用正确语言,中文报告及 PDF 可读。 + +日语暂缓,不在本轮加入日语资源、入口或翻译工作;中文验收后另行决定。保留完整英文回退和已有用户数据,维持现有扫描、设备匹配及交换格式。正式应用及现有 Debug 包的包名、签名、版本号保持不变;不自动提交、推送或发布。 + +本轮“补齐”覆盖应用页面、动态说明、应用内帮助、报告、分享、通知和语音。设备广播名称、用户输入、品牌、协议标识及 MIT 原文按其语义保留。仓库 README 和独立手册全文翻译不属于本轮应用验收。 + +## 执行前已确认的起点(历史记录) + +| 项目 | 当前证据 | +| --- | --- | +| 资源与自动检查 | 2,772 条中文字符串、3 组数量资源;455 项单元测试通过,Lint 0 错误、61 警告 | +| 内置内容 | 252 条内置识别特征、6 个预设及内置解码字段已有显示翻译层;原始数据未整体翻译写回 | +| 手机 | 华为 HMA_AL00,Android 10;最终包已覆盖安装,中文页面和扫描通知已实测 | +| 生命周期 | 已测语言偏好重启保存、手动停止扫描后切换语言、新建草稿旋转重建;未测全部编辑器和进程回收场景 | +| PDF | 实际文件已生成;没有完成字体、图表、换行和分页验收 | +| 测试设施 | 现有测试为普通 JVM 单元测试;尚无 `androidTest` 测试设施;现有 Debug 包不可调试、启用混淆与资源压缩 | +| 模拟器 | E 盘 SDK 中有模拟器程序,本次检查未发现已安装的系统镜像和 AVD;Android 13+ 验收环境仍需准备 | + +已发现一处具体待复核文字:`DefaultCatalog.newBlankFleet()` 的默认名称仍是 `New Signature`,此前中文新建编辑页也显示该英文。还需审计其他默认名称及不常出现的错误分支。资源键齐全只能证明“已抽取的键有译文”,不能证明没有遗漏抽取或译文语义正确。 + +## 执行顺序 + +### C1:审计中文缺口和统一术语 + +先建立可逐项关闭的清单,再修改。扫描范围包括 UI、ViewModel、领域解释、服务、告警、报告与 PDF;排除代码注释、英文回退模板、稳定标识和原始目录数据后人工复核剩余文字。 + +按页面检查:首次说明与权限 → 实时及各视图 → 筛选/预设 → 识别特征/解码编辑 → 详情/寻踪 → 关注/命名 → 设置 → 会话/路径/对比/导出。每页同时检查正常、空列表、加载、错误、确认、无权限及长文本场景。 + +清单列明:源位置、实际显示场景、中文缺口或术语问题、是否允许保留英文、修复方式和验证结果。重点检查默认新建名称、解码默认值、运行时异常提示、系统权限说明,以及内容先生成后缓存导致的旧语言残留。统一“识别特征、信号源、监测会话、额外关注、关注告警”等术语,避免把推测翻译成确定结论。 + +通过条件:应用自有文字的候选遗漏全部有结论;关键页面不存在未说明的英文;品牌、广播和用户内容等例外有明确理由。形成根目录 `CHINESE_AUDIT.md`。 + +### C2:补漏并检查翻译与数据边界 + +按 C1 清单补中文和英文资源,修正不通顺、歧义及参数表达,复核数量、时间、日期和单位。只对新创建的记录生成本地化默认名称或占位提示,既有保存名称不追溯改写。 + +重点检查内置内容编辑:打开内置记录、只编辑其他字段后保存,不能把显示译文自动写回原始名称或说明;用户明确修改的内容应保持。检查自定义/导入规则、未知内置 ID、原文变化后的回退,以及切换语言后已缓存的解释、解码和报告是否正确刷新。 + +保持机器格式的字段名、ID、协议值和时间表达稳定。审查 PDF 等模块依据显示标题判断布局的分支,必要时改用已有结构信息,确保改译标题不会改变业务含义或报告结构。 + +测试以风险为依据:补充能验证默认名称、缓存更新、用户字段保护和交换兼容的测试;不为每条静态译文写重复测试。清理确认无用的翻译资源;与本轮无关的图标等历史警告不扩大修复范围。 + +通过条件:缺口清单已修复项关闭;全部资源键和格式参数检查通过;同一输入在英中语言下匹配结果及序列化保持兼容。 + +### C3:建立隔离的设备测试和本地 PDF 验收路径 + +为语言、草稿和实际 PDF 增加必要的设备测试。优先采用 AndroidX/Compose 的语义定位及页面空闲同步,避免用固定坐标和短暂等待编写长期测试。测试依赖与工程已有 Compose BOM 配套,不顺带升级整个工程。 + +现有 Debug 包与手机正式数据共用包名,且不可调试。设备自动测试采用独立的测试构建,例如 `i18nQa`,使用独立应用 ID、可调试配置和模拟数据;不改变现有 Debug/Release 配置。只在该构建中提供必要的测试入口,最终发布构建中不暴露入口。普通测试先保持扫描和联网关闭,避免依赖现场设备及外部地图服务。 + +使用与应用一致的报告生成器及 `DebriefPdf.write()`,在测试应用内生成模拟报告,保存至测试构建允许读取的本地目录后取回电脑。测试样本使用虚构名称、MAC 和坐标,不读取或导出手机真实监测数据;不经过微信、云盘、WPS 或打印服务。取回过程使用调试/测试的正常访问能力,不修改现有应用文件权限。 + +本地模拟器至少准备一套 Android 13+ 环境,优先 API 35,用于系统应用语言入口和重启验收。镜像、AVD、缓存和临时文件都使用 E 盘既有目录。先检查 E 盘空间和模拟器可启动条件;如果需要修改系统虚拟化设置或安装驱动,记录依赖后再处理,不在中文修复中直接改动系统配置。 + +通过条件:隔离测试包能够运行;模拟样本 PDF 可在本地取得;测试入口不会进入交付包;Android 13+ 环境可运行或已明确记录设备缺口。 + +### C4:完成中文报告和 PDF 验收 + +文本样本覆盖详情分享、监测总结、会话对比及 AI 导出。复核中文标题、说明、推断语气、解码值和免责声明;原始事实与用户内容不被替换。AI 的语言指令保留明确中文要求,检查生成的提示文本,不为了验收把真实监测数据发给外部 AI。 + +PDF 样本至少包括:空数据、普通设备、长中文名称与备注、中英混排、解码和额外关注、多页设备/对比列表、含停留与途经的 GPS 路径、图表与页脚。使用同一组模拟数据生成英文和中文文件。 + +将 PDF 全部页渲染为图片,逐页检查字体缺字、文本重叠、边界截断、图表标签、段落换行、标题与正文分页和页码;文本提取仅辅助检查内容,不替代看图。修正后重新渲染受影响样本。优先使用现有 Android 字体与可测量布局,确认有字体问题后再评估随包字体及授权。 + +通过条件:各样本能成功生成,所有页面无缺字、重叠或异常分页;英中主要内容及结构对应;本地保留可复查的模拟样本及检查记录。 + +### C5:验证语言、草稿、通知和语音 + +| 维度 | 要验证的场景与通过条件 | +| --- | --- | +| 应用语言 | 中文/English/跟随系统切换,未支持系统语言回退英文;冷启动、后台恢复和进程重建后偏好正确 | +| Android 13+ | 系统应用语言入口与应用入口相互同步,不重置用户配置 | +| 扫描与页面 | 正在扫描、手动停止、冻结显示时切换;页面、筛选、选中设备和告警状态符合原行为 | +| 编辑草稿 | 识别特征、解码字段、枚举/备注、预设、设备命名、会话命名;验证旋转、后台恢复、语言切换及系统回收恢复 | +| 保存边界 | 未保存内容保持未保存;取消不创建规则;切换或重建不意外提交数据 | +| 通知 | 扫描、停止按钮、关注告警、频道名称;已有频道设置保留,切换后标题与正文采用新语言 | +| TTS 可用 | 中文测试短句及真实告警组句;切换后停止旧语句并使用新语言,不同时播放旧语言队列 | +| TTS 缺失 | 无中文数据、语言不支持、初始化/发声失败;提示清楚,扫描正常,蜂鸣/振动保持既有设置行为 | +| 无障碍与布局 | 中文描述、小屏、大字体、长名称、夜间模式;按钮可点击、说明可读、内容不重叠 | + +语言切换与草稿测试分两类:可调试测试应用触发语言/生命周期变化,验证草稿模型;正常应用通过实际设置与系统入口走用户流程。区分主动结束进程、配置重建和系统回收,不用一种场景冒充另一种已通过。 + +TTS 返回成功只能证明引擎接受请求,实际语音的可理解性仍需听测;自动记录结果与人工听测结果分别标注。缺失语音分支先用可控测试替身验证,再在条件允许时补实机;不删除用户原有语音包来制造缺失。 + +手机主要负责 Android 10 与真实音频/通知验收;隔离模拟器负责 Android 13+、大字体、回收及异常场景。临时修改旋转、字体、语言或充电常亮设置,测试结束恢复原值。需要手机解锁时再请求用户操作。 + +通过条件:矩阵每项记为“通过/失败/受限”,附设备、输入、观察结果;发现失败则修复并只重跑相关场景。无法实际覆盖的项目继续标为待验收,不用自动测试代替实测结论。 + +### C6:最终回归与交付 + +运行最终正常 Debug 包构建(包含混淆与资源压缩)、全部单元测试、相关设备测试、Lint、资源绑定及修改格式检查。最终普通包仍需验证中文资源没有被压缩移除;测试构建成功不能代替交付构建成功。 + +在手机覆盖安装最终普通包,核对安装时间及中文主页面、设置、详情、报告、通知和语言保存。保留原应用数据,不卸载或清除数据;安装时留在华为安装流程中直到确认完成。 + +更新 `I18N_STATUS.md` 与 `CHINESE_AUDIT.md`,记录测试数量、Lint 结果、设备/API、APK 校验值、样本位置、人工听测和未覆盖场景。交付 APK 和可复查记录,不自动提交或推送。 + +## 完成判定 + +“中文版补齐完成”需要同时满足: + +1. 应用自有文字审计闭环,中文表达、参数和术语正确,用户内容及原始识别数据保持兼容。 +2. 语言切换、编辑草稿、暂停扫描、通知及语音关键行为有验证证据。 +3. 中文 PDF 使用真实 Android 生成器产出,并完成全页视觉检查。 +4. 最终普通包构建及必要回归通过,测试入口未进入交付包,手机已有最终包。 +5. 实测矩阵与限制如实记录。若 Android 13+ 或实际 TTS 条件缺失,只能交付“中文实现已补齐、部分验收受限”,不标记全量验收完成。 + +执行时按 C1 → C2 → C3 → C4 → C5 → C6 推进;每阶段完成后记录结果及下一步,不按运行次数反复全量测试。实施结果以 `CHINESE_AUDIT.md` 为准,计划中的要求不自动视为已验证。 + +## 参考 + +- [Android 应用语言与保存机制](https://developer.android.com/guide/topics/resources/app-languages):系统入口、AppCompat 与不同 Android 版本的处理依据。 +- [Compose UI 测试](https://developer.android.com/develop/ui/compose/testing):语义定位、同步及设备测试依赖的依据。依赖版本以工程已有 BOM 和官方兼容要求为准。 diff --git a/I18N_PLAN.md b/I18N_PLAN.md new file mode 100644 index 0000000..32f74a3 --- /dev/null +++ b/I18N_PLAN.md @@ -0,0 +1,248 @@ +# Fieldwatch 国际化实施方案 + +> 状态:英文与简体中文实现已完成,构建和自动测试通过;Android 10 已完成部分实机验证。PDF 排版、中文 TTS 和 Android 13+ 系统语言入口仍待验收。实际结果见根目录 `I18N_STATUS.md`。 +> 制定日期:2026-10-04。 +> 当前优先级:按用户最新要求,先补齐并验收中文版,日语暂缓。下一轮具体执行顺序与通过条件见根目录 `CHINESE_COMPLETION_PLAN.md`;本轮仅更新计划,不修改应用代码。 +> 基线:`smallclover/Fieldwatch`,版本 1.1.18,提交 `2b22008a1ef542e7b12298ca647883f0a50ffc59`,当前分支 `codex/i18n`。 + +## 1. 目标与范围 + +为自己的 fork 添加可持续维护的多语言支持,首批提供英文和简体中文。英文作为完整的默认资源与缺失翻译回退语言;中文资源随应用打包,使用时不依赖联网翻译。 + +第一阶段应用内提供三种语言选项:跟随系统、简体中文、English。Android 13 及以上同时支持系统的“应用语言”入口。日语列为明确的后续阶段:先完成英文与中文的全范围验收,再沿用同一资源和文本模型加入 `日本語`,不重新设计语言切换机制。将来增加繁体中文或其他语言时,也沿用该扩展方式。 + +分阶段能降低首次改造和排查问题的复杂度,但不会省掉日语翻译及验收本身的工作量。基础设计从一开始就支持多种语言,不能把逻辑写成“中文/非中文”二选一。 + +本轮实施范围包括:页面和对话框、无障碍描述、提示和错误、设备解释、内置内容的显示名称、通知、语音告警,以及应用生成的面向人的报告和分享文本。仓库 README、静态手册和发布介绍的翻译另行安排。 + +保持扫描、分类、匹配和数据交换行为兼容。保留原始设备广播内容、用户输入、品牌和协议标识,不把界面语言写进识别规则。包名、发布签名、版本号和第三方库升级不作为国际化的附带改动。 + +## 2. 实施前基线与主要风险 + +实施前项目使用 Kotlin 和 Jetpack Compose,最低支持 Android 10(API 29),编译和目标 SDK 为 35。`MainActivity` 继承 `ComponentActivity`,尚未引入 AppCompat;XML 基础主题为 `android:Theme.Material.NoActionBar`。下表保留基线风险,当前实现及验证结果见 `I18N_STATUS.md`。 + +实施前 `app/src/main/res/values/strings.xml` 只有应用名称,大部分界面和动态文本直接写在 Kotlin 文件中。原有英文表达既用于显示,也在少数地方参与判断,因此不能通过批量替换英文完成国际化。 + +| 位置 | 现状及风险 | 处理方向 | +| --- | --- | --- | +| `ui/screens/`、`FieldwatchAppUi` 和通用组件 | 页面、按钮、对话框及无障碍文字直接写在代码中 | 抽取完整英文资源,再提供中文资源 | +| `domain/Models.kt` | 枚举有英文 `label()`、`speechLabel()`;部分排序使用显示名称 | 保留枚举和序列化值,显示层提供本地化映射,按需本地化界面排序 | +| `domain/DeviceExplain.kt` | 使用规范英文特征名称推断设备;`listLabel` 中还检查解释标题的英文内容 | 将依赖显示句子的判断改为结构化状态,再翻译输出 | +| `DefaultCatalog`、`CatalogDecodes`、`ConfigStore` | 内置名称、说明和解码字段会保存、迁移、更新或导出 | 保留规范数据,添加受来源和原文校验约束的显示翻译层 | +| `ScanService`、`Alerter` | 通知、频道名称和语音短句为英文;TTS 使用系统默认语言 | 使用应用实际语言;更新已有频道名称;处理中文语音缺失 | +| `DeviceDetailText`、`DebriefReport`、`DebriefPdf` | 分享和报告有动态英文;PDF 有固定布局及日期格式 | 分离文本生成与机器数据,验证中文字体、宽度和分页 | +| `MainActivity`、编辑页面 | 切换语言会重建 Activity;当前启动流程可能重新开始扫描,部分草稿仅用 `remember` 保存 | 保留扫描状态和编辑草稿,避免语言切换造成业务副作用 | + +实施前继续按实际文件定位补齐清单,清单应包含字符串所在位置、使用场景、参数、是否对外交换和对应阶段。不得仅凭字符串看起来像英文,就认定它需要翻译。 + +## 3. 资源组织规范 + +采用 Android 原生资源系统: + +```text +app/src/main/res/ + values/strings.xml # 完整英文默认资源 + values-b+zh+Hans/strings.xml # 简体中文 + values-ja/strings.xml # 后续日语阶段添加 + xml/locales_config.xml # 首批 en、zh-Hans;日语验收后增加 ja +``` + +资源按功能命名,例如 `settings_language_title`、`scan_notification_title`、`device_explanation_generic_ble`。名称描述语义,不依赖原文、行号或中文翻译。相同英文但语境不同的文字可以使用不同资源;只有含义和参数契约相同的文字才共用资源。 + +- Compose 使用 `stringResource` 和 `pluralStringResource`;普通 Android 组件使用适合当前应用语言的资源上下文。 +- 动态句子作为完整模板保存,使用 `%1$s`、`%2$d` 等位置参数,不拼接可翻译的句子片段。 +- 数量句使用 `plurals`,英文区分单复数;中文仍保留正确的数量参数和资源类型。 +- 为有歧义的词、参数、长度限制和设备推断语气添加译者注释。 +- 将确实需要保留的品牌、协议名和技术标识标记为不可翻译,避免翻译检查产生噪声。 +- 不对已生成的英文段落执行替换来“翻译”。错误和动态解释应从状态及参数生成对应语言的句子。 +- 日期、时间、数量等面向人的显示按应用实际语言格式化;机器数据继续使用现有稳定格式。 + +默认英文资源必须覆盖所有使用的键。首批中文也应覆盖本次纳入范围的全部可翻译键;运行时英文回退用于防止崩溃,不作为遗漏翻译的验收标准。 + +日语复用相同的资源键、位置参数、复数契约和译者注释,从英文原文及功能语义翻译,中文作为术语参考,不机械地把中文转成日文。内置内容显示翻译层按语言索引,语音和报告读取实际语言,避免为每增加一种语言复制业务分支。 + +## 4. 应用语言切换 + +### 4.1 推荐实现 + +引入与当前工程兼容的 AppCompat 依赖,并将主 Activity 改为 `AppCompatActivity`。适配 XML 基础主题,使其满足 AppCompat 要求,同时保留现有 Compose 外观、深浅色和边到边显示行为。依赖版本在实施时核对官方兼容信息后固定,不顺带升级整个工程。 + +使用 `AppCompatDelegate.setApplicationLocales()` 设置应用语言,空语言列表表示跟随系统。通过 AppCompat 的 `autoStoreLocales` 元数据启用 Android 12 及以下的保存机制;Android 13 及以上由系统应用语言机制管理。应用页面和系统设置应反映同一份语言状态,不在 `AppSettings` 中再维护一份独立副本。 + +首批采用手写 `locales_config.xml`,显式声明 `en` 和 `zh-Hans`,并通过 Manifest 的 `android:localeConfig` 接入。这样支持列表可控,不会把依赖库中的其他语言误列为完整支持。此方案不同时启用 AGP 的自动 LocaleConfig 生成;以后语言较多时再评估改用自动生成。 + +语言名称采用自称:`简体中文`、`English`;“跟随系统”随当前界面语言显示。日语阶段完成覆盖和验收后,同步在应用语言选项及 `locales_config.xml` 中加入 `日本語` / `ja`,不提前展示未完成的语言。语言偏好默认不进入扫描配置、规则包和设置交换文件。 + +### 4.2 后台组件与状态保存 + +提供统一的应用语言读取和本地化上下文入口,例如 `AppLanguage`。通知、服务、导出和 TTS 使用该入口,尤其处理 Android 12 及以下后台 `ApplicationContext` 不一定跟随 Activity 语言的情况。不得把 `Locale.getDefault()` 当作应用语言的唯一来源,也不得长期缓存切换前的 `Resources` 或格式化器。 + +语言切换通常会重建 Activity,实施时必须: + +1. 检查 `MainActivity.onCreate` / `onResume` 的自动开始扫描逻辑,使用户暂停的扫描不会因切换语言而重新开始。 +2. 保留当前页面、筛选状态、选中设备和正在进行的监测会话。 +3. 将需要保留的编辑草稿从普通 `remember` 迁移到合适的 `rememberSaveable`、ViewModel 或状态保存机制;避免切换语言丢失未保存内容。 +4. 使后台通知及时反映新的语言,同时不清空扫描结果或重复发出告警。 +5. 验证应用内切换、系统入口切换、重启和进程重建后的行为一致。 + +## 5. 业务文本和内置内容 + +### 5.1 设备解释与领域模型 + +设备类别、识别特征、解码类型和判断结论保持稳定的 ID、枚举或结构化状态。渲染边界把它们映射成语言资源。 + +优先消除 `DeviceExplain.listLabel` 中依赖英文标题内容的判断,改用明确的“通用 BLE 广播设备”“通用 Wi-Fi 接入点”等状态。`signatureHints` 中用于匹配的规范英文名称继续保留;翻译后的展示名称不能作为匹配输入。 + +对于复杂设备解释和报告,采用“结构化结论 + 参数 + 文本格式化器”。领域逻辑不依赖 Android `Context`,继续能够在 JVM 单元测试中运行。格式化器可提供英文默认实现,以维持现有输出契约;Android 展示层接入资源实现。具体拆分粒度以实际调用链为准,避免为每个普通按钮引入额外文本模型。 + +界面若承诺按名称或类别字母顺序排列,可以在显示层按当前语言排序;识别规则的优先级、稳定 ID 顺序和导出顺序不能随界面语言变化。 + +### 5.2 内置目录、预设与用户内容 + +内置识别目录和筛选预设使用稳定 ID 关联翻译,但仅有 `builtIn=true` 不足以认定内容可以覆盖。翻译显示层还应确认相应字段仍是原始默认值,例如通过源版本、原文字段或内容指纹校验。 + +| 内容 | 显示策略 | 保存与交换策略 | +| --- | --- | --- | +| 未修改的内置名称、说明和关注提示 | 有对应且匹配原文的翻译时显示中文 | 保留规范原文、ID 和版本 | +| 未修改的内置解码字段说明 | 翻译展示标签和解释 | 不改解码规则、匹配值或协议枚举 | +| 用户修改过的内置字段 | 显示用户内容 | 原样保存与导出 | +| 自定义名称、备注和导入的用户内容 | 原样显示 | 原样保存与导出 | +| 远程目录更新后新增或变化的内容 | 无有效对应翻译时显示新原文 | 正常接收更新,不使用过期译文覆盖 | + +翻译覆盖按字段判定,例如用户只改了备注时,不应影响仍未修改的名称翻译。还要检查当前存储迁移中对英文名称的判断,确保其读取的是规范数据,而不是本地化显示结果。 + +### 5.3 保持不变的内容 + +以下内容默认不翻译:JSON 键、格式版本、序列化枚举值、CSV 列名、MAC、SSID、UUID、原始广播载荷、设备自行广播的名称、协议标识,以及机器读取的时间戳和数值格式。 + +不得为汉化重写已有数据文件或修改识别目录版本。切换语言前后,相同数据应产生相同的匹配结果,并继续能够与上游导出格式交换。 + +## 6. 通知、语音与报告 + +### 6.1 通知与语音告警 + +翻译扫描和关注列表的通知标题、正文、操作及频道名称/说明。对已存在的通知频道更新可更新的文字元数据,不通过删除重建频道实现语言切换,避免影响用户通知偏好。检查 `Alerter` 当前“频道存在就返回”的分支,使它能够处理改语言后的文字更新。 + +TTS 根据应用实际语言选择语音,语音短句也按同一语言生成。中文语音不可用时明确处理:提示用户安装语音资源,或在可用英文语音下使用英文句子回退;不能只换成英文引擎却继续朗读中文文本。保留已有告警节流、优先级和静音行为。 + +### 6.2 文本分享与 AI 导出 + +设备详情、监测总结和面向人的分享文本使用本地化模板。AI 导出中的人类说明可随应用语言变化,并说明希望得到的回答语言;其中的机器事实、字段键、协议值和数据结构保持兼容。 + +把界面错误、通知错误、报告标题及分享内容纳入同一文本清单。异常的内部诊断信息可以保留原文,但面向普通用户的提示应提供本地化解释。 + +### 6.3 PDF 报告 + +保留现有 PDF 生成机制,逐步替换硬编码标题、图例、时间区间、总结和页脚说明。导出开始时确定语言和格式化器,避免静态缓存的时间格式一直使用旧语言。 + +中文验收需要查看实际 PDF:检查字体缺字、图表标签宽度、长句换行、数字和单位、段落高度、页脚及分页。必要时将固定位置的长文本改为可测量和换行的布局。优先使用 Android 字体回退;只有确认缺字或可移植性问题时,才评估随包携带有合适授权的字体。 + +应用名称 Fieldwatch、作者署名和版权信息保留。法律原文保留英文,可另附明确标注为说明的中文内容。 + +## 7. 初始术语约定 + +| 原文 | 建议中文 | 使用约束 | +| --- | --- | --- | +| Signature | 识别特征 | 不是密码学签名 | +| Class | 设备类别 | 不译为编程中的“类” | +| Radio | 无线信号源 | 根据具体上下文区分信号源和无线协议 | +| Sit | 监测会话 | 结合当前功能核对,避免字面译为“坐下” | +| Debrief | 监测总结 | 用于会话结果与报告 | +| Watch / Watchlist | 关注 / 关注列表 | 告警动作可译为“告警”,不机械统一 | +| Hunt | 寻踪 | 对应追踪目标信号的页面 | +| Fleet | 识别目录或特征组 | 结合实际对象决定,避免字面译为“舰队” | + +“最可能”“可能”“也可能”等推断语气必须保留原有置信程度,不把不确定结论翻成确认结果。位置相关文案明确区分本机采集位置与被检测设备的真实位置。 + +术语表是起点。实施时结合页面截图、实际数据和原有功能语义修订,并让同一术语在界面、通知、语音和报告中保持一致。 + +## 8. 分阶段实施和交付 + +2026-10-04 实施记录:阶段 0–4 已实现;阶段 5 已实现文本与 PDF 生成,PDF 字体和分页待实测;阶段 6 的构建、455 项单元测试和 Lint 已通过,Android 10 实测已验证语言切换、偏好保存、暂停扫描及页面重建后的草稿保留,其他设备矩阵仍待验收;阶段 7 日语未开始,按用户最新要求暂缓,不自动启动。本轮修改仍在本地,尚未提交或推送。 + +每个阶段尽量形成可独立检查的提交,持续保留可构建的英文版本。先解决结构和兼容,再补翻译,不把业务行为修改混入大批文字变更。 + +| 阶段 | 工作 | 交付及通过条件 | +| --- | --- | --- | +| 0:基线与清单 | 保存测试基线;盘点文本、结构判断、内置数据和导出边界;确认术语 | 可追踪的字符串清单和兼容性清单 | +| 1:语言基础 | AppCompat 接入、主题适配、语言入口、LocaleConfig、保存及后台语言上下文 | 英文行为正常;选择语言能保存;扫描及草稿状态不受切换影响 | +| 2:英文抽取 | 按页面提取默认资源;替换使用显示文本的业务判断 | 英文内容和业务结果保持原义;默认资源完整;原有测试通过 | +| 3:中文界面 | 翻译页面、弹窗、帮助、错误和无障碍文字 | 英中两种界面可用;参数、数量和排版正确 | +| 4:动态文本 | 设备解释、枚举显示、内置内容覆盖、通知及 TTS | 识别结果不变;用户内容不被覆盖;后台使用应用语言 | +| 5:报告与分享 | 详情文本、监测总结、AI 说明和 PDF | 人类文本本地化;机器数据兼容;中文 PDF 可读 | +| 6:回归与验收 | 资源审计、单元测试、Lint、设备测试和遗漏修复 | 满足下一节标准,记录实测设备和未覆盖场景 | +| 7:日语扩展 | 在阶段 0–6 验收后添加日语资源、内置显示翻译、动态文案、通知、TTS 和报告;核对日语术语 | 日语覆盖同一功能范围;新增语言不引入业务判断分支;英中日回归通过后开放日语入口 | + +建议按常用页面先后推进:设置与语言入口 → 主页面/实时扫描 → 设备详情 → 筛选和寻踪 → 识别目录及编辑 → 报告。复杂动态文案可以独立于页面资源抽取实施。 + +日语阶段不重复抽取资源或改造扫描逻辑,主要交付日语翻译和语言适配。仍需独立检查:助数词和数量表达、推断语气、设备及无线协议术语、片假名长词、按钮宽度、日期/时间格式、日语 TTS 的可用与缺失情况,以及 PDF 汉字和假名的字体与换行。译文进入应用前完成术语和语义审校;未完成审校时明确记录限制。 + +实施完成后可更新此文件的阶段状态,另记录实际验证结果。未执行的检查不得标记通过。 + +## 9. 验证与验收标准 + +### 9.1 自动检查 + +已验证的 fork 基线:Debug APK 构建成功;37 个单元测试类、447 个测试全部通过,无失败、错误或跳过。后续新增测试数量可以增加,以实际结果为准。 + +执行与改动相关的构建、单元测试和 Android Lint,重点包括: + +- 英文与中文资源键的覆盖、位置参数数量和类型、复数资源及不可翻译标记。 +- 消除依赖英文显示句子的分类判断后,结果仍与原有分类一致。 +- 语言切换不改变数据序列化;规则、设置和设备数据能往返导入导出。 +- 内置翻译只覆盖符合来源条件的字段,用户修改和目录更新能正确保留或回退。 +- 文本模板、日期/数量格式及关键动态句子的参数输出正确。 +- 对残留英文进行代码扫描和人工分类,维护品牌、协议及诊断信息的合理例外。 + +复用现有测试,并为上述行为风险增加针对性测试。不单纯把原有英文断言替换成中文来掩盖行为变化。领域测试继续能够在普通 JVM 中运行。 + +参考执行入口(Windows,已配置 E 盘环境): + +```powershell +.\gradlew.bat :app:assembleDebug :app:testDebugUnitTest :app:lintDebug --console=plain +``` + +执行前确认当前终端的 JDK、SDK 和 Gradle 缓存变量使用已配置的 E 盘目录。若 Lint 报告已有问题,应区分基线问题与本次新增问题并如实记录。 + +### 9.2 设备验证矩阵 + +| 维度 | 至少覆盖的情况 | +| --- | --- | +| Android 版本 | API 29–32 中至少一台;API 33 及以上至少一台,分别验证保存机制及系统应用语言入口 | +| 系统与应用语言 | 英文系统选中文、中文系统选英文、跟随系统、无支持翻译的系统语言回退英文 | +| 生命周期 | 切换后重启、进程重建、后台恢复、正在扫描和手动暂停扫描 | +| 页面状态 | 编辑未保存的识别特征/解码字段、选中设备、当前标签和筛选条件 | +| 数据内容 | 默认内置数据、修改过的内置数据、自定义和导入数据、目录更新后未翻译内容 | +| 后台能力 | 扫描通知、关注告警、已有通知频道、可用及缺失中文 TTS 语音 | +| 显示 | 小屏幕、大字体、深浅色、长设备名称、无障碍描述 | +| 输出 | 英中详情分享和 PDF、AI 文本、JSON/CSV 机器格式兼容 | + +核心验收条件: + +1. 两种语言下全部纳入范围的用户可见文本可读,中文无关键页面遗留英文、参数错位或截断。 +2. 语言偏好与系统入口一致,重启有效;切换不重置扫描、告警或用户草稿。 +3. 相同输入在两种语言下识别与匹配结果一致;已有数据和交换格式兼容。 +4. 用户内容没有被翻译覆盖;不匹配的内置翻译安全回退到原文。 +5. 通知和语音采用正确语言,中文 PDF 无缺字、重叠或分页错误。 +6. 构建和必要检查通过,留下可复查的测试记录。设备不足时列明未验证项目,不将单元测试视为手机实测。 + +日语阶段扩展同一验证矩阵:日语系统跟随系统、非日语系统手动选日语、英中日相互切换、重启与系统应用语言入口,以及日语通知、语音、详情分享和 PDF。资源覆盖与参数检查同时比较三种语言;确认加入日语没有导致英文或中文回退、识别结果和机器数据格式退化。首阶段的英文/中文验收通过不代表日语已经验证。 + +现有 Debug 配置与正式应用共享 `app.fieldwatch`,且关闭可调试属性、启用压缩。若需要与原版同时安装或使用断点,可另行安排独立的开发包名和签名;这不是国际化必需步骤,也不应自动改动发布身份。 + +## 10. 协议、仓库与维护 + +所有后续改动在 `smallclover/Fieldwatch` 的工作分支完成。当前 `origin` 指向自己的 fork;原仓库作为 `upstream` 获取更新,其推送入口已禁用。本方案不包含向原作者仓库提交、创建 PR 或发布 APK 的操作。 + +保留根目录 `LICENSE` 中的 MIT 协议及原作者版权声明,保留 `NOTICE` 和第三方授权说明。IEEE、Bluetooth SIG 等数据来源不能仅因项目主代码采用 MIT 就认定为同样授权;本地化时继续保留现有来源与声明。若引入字体或其他资源,单独核对并附上其许可证。 + +以后同步上游时,对新增资源、内置目录原文变化、设备解释状态和报告布局执行同样的兼容检查。翻译覆盖未匹配的新内容暂时显示英文,不阻碍正常识别或目录更新。 + +## 11. 官方实现参考 + +- [Android 本地化和默认资源](https://developer.android.com/guide/topics/resources/localization) +- [Compose 字符串、参数和复数资源](https://developer.android.com/develop/ui/compose/resources) +- [应用语言、AppCompat、持久化与 LocaleConfig](https://developer.android.com/guide/topics/resources/app-languages) +- [MIT 协议](https://opensource.org/license/mit) + +实施时以当前工程的依赖版本和官方兼容要求核对具体 API。本文件确定实施方向,不替代每个阶段的编译、回归和手机验证。 diff --git a/I18N_STATUS.md b/I18N_STATUS.md new file mode 100644 index 0000000..e884f26 --- /dev/null +++ b/I18N_STATUS.md @@ -0,0 +1,67 @@ +# Fieldwatch 国际化实施与验证记录 + +日期:2026-10-04。基础 smallclover/Fieldwatch 1.1.18,本地分支 codex/i18n。本轮为 English 和简体中文,提供跟随系统;日语暂缓。本记录随中文基线一并本地提交,未推送或发布,上游推送仍为 DISABLED。 + +提交前获取 origin 与 upstream 后,两者 main 仍为 `2b22008`,与当前开发基础一致,无需合并更新。 + +## 当前成果 + +- 2,902 条中文字符串、3 组数量资源;2,114 项领域/内置显式资源绑定,支持混淆和资源压缩。 +- 252 条内置特征、6 个预设及解码标签、枚举/备注采用显示翻译层;稳定 ID 和原文匹配时才覆盖,用户内容与原始目录保留。 +- 页面、动态说明、默认名称、CoD 类别、日期/时长、信道、RSSI 范围、TAK 状态、错误、通知、分享、报告和 AI 文案已补齐。MIT/NOTICE、广播事实、协议标识与交换格式保持原义。 +- AppCompat 支持中文、English、跟随系统;API 35 语言服务同步已验证。未支持语言统一使用英文资源、日期及语音上下文。 +- 编辑草稿通过 Compose 可保存状态与 SavedStateHandle 恢复,切换不自动提交;内置解码标签编辑保留字段 ID。 +- TTS 使用当前语言,切换使旧延时任务失效;缺失语音、初始化或发声失败提示本地化,蜂鸣沿用设置。 +- PDF 关注卡依据结构信息渲染,长标题测量换行,超长备注分页。 +- 独立 i18nQa 应用和 API 35 AVD 已建立;JDK、SDK、AVD、Gradle 缓存和临时文件继续使用 E 盘。普通包保持原包名、版本和签名。 + +## 验证结果 + +| 检查 | 结果 | +| --- | --- | +| 正常 Debug 构建,含 R8 和资源压缩 | 通过;最后构建 2 分 28 秒 | +| 单元测试 | 39 类、460 项,失败/错误/跳过均 0 | +| API 35 设备测试 | 6 项通过;最后文字修复后相关报告测试 1 项单独复验通过 | +| Lint | 0 错误、56 警告;未加入忽略基线 | +| 资源覆盖、格式参数、绑定与修改格式 | 通过 | +| 特征/解码/枚举/备注/预设/会话草稿 | 实际页面重建及语言切换保持未保存内容;取消不提交 | +| 内置数字字段保存边界 | 修改 minPeers 保存,其他原始字段保持;随后还原 QA 记录 | +| 后台进程回收 | 新建草稿进程 6255 → 回收 → 6516,未保存名称恢复,配置没有写入;已取消 | +| 小屏、大字体、夜间 | 360dp/1.3 倍字体中文设置可读;夜间设置页已看图;设置已恢复 | +| PDF | 18 份英中真实 Android PDF、70 页全部渲染看图;无缺字、重叠或截断 | +| 详情及 AI 文本 | 同组虚构数据检查通过;中文指令、免责声明、解码正常,最终范围显示“-85 至 -40 dBm” | +| 最终手机覆盖安装 | 18:27:30 成功,手机 APK SHA-256 与交付包完全一致,保留原数据 | +| 最终手机中文页面/通知 | 设置、报告、新建默认名称、详情与扫描通知通过;范围实测“-90 至 -30 dBm” | +| 中文实际听测 | 手机“测试告警”,用户确认“有蜂鸣,也听到清晰中文”;原蜂鸣/语音开关保持开启 | + +详细清单、样本页数及矩阵见 `CHINESE_AUDIT.md`,阶段进度见 `CHINESE_COMPLETION_PLAN.md`。PDF 中间结果在忽略目录 `.gradle/i18n/pdfs/` 和 `.gradle/i18n/pdf-render/`;最后文字修复后 18 份 PDF 校验值未变化。没有读取或发送手机真实报告,没有请求外部 AI。 + +## 最终包与手机 + +- APK:`E:\programing\Fieldwatch\app\build\outputs\apk\debug\app-debug.apk`。 +- 生成:2026-10-04 18:12:42(北京时间),11,819,438 字节。 +- SHA-256:`EC4BB1134674B9C31BDCC9CB51BD71AEF149BF4C42E3FC4F1C962631F699D3A1`。 +- 包名 app.fieldwatch,版本 1.1.18 / 28。实际 APK manifest 不含 QA Activity,未启用 debuggable。 + +手机为华为 HMA_AL00 / Android 10。第一轮已验证语言切换与偏好重启保存、手动停止后切换及旋转草稿。最终包于 18:27:30 覆盖安装成功,读取公开 APK 文件计算校验值,与交付文件完全一致;最终包中文设置、报告、新建名称、详情和扫描通知补验通过。中文测试告警已播放,用户确认有蜂鸣和清晰中文。没有卸载、清除数据或保存测试草稿;临时充电常亮恢复为原值 0,原有告警/蜂鸣/语音开关未修改。 + +## 尚受限的验收 + +- 真实关注命中语音、切换时正在播放的完整听测,以及已有频道设置的完整通知矩阵;当前中文测试短句实际听测已通过。 +- 所有编辑器和所有字段的进程回收组合;实际回收证据针对新建特征草稿。 +- 蓝牙关闭、扫描失败、系统挂起等完整硬件异常;全部页面小屏/大字体、无障碍实际朗读。 +- 系统设置中完整人工语言入口流程;当前已验证 LocaleManager 与 AppCompat 同步。 + +可控 TTS 替身测试通过不能代替人工听测;没有因构建成功将上述项目标为通过。日语尚未开放。 + +## 维护和重现 + +界面使用原生字符串/数量资源,领域模块使用 `localized(key, english, args...)`。增加领域/内置文字后同时补英中资源并运行 `node tools/update-i18n-resources.mjs` 与 `--check`,译文不用于匹配判断。上游原文变化或用户修改不满足校验时显示原值。 + +```powershell +.\gradlew.bat assembleDebug assembleI18nQa assembleI18nQaAndroidTest testDebugUnitTest lintDebug --console=plain --no-daemon +node tools/update-i18n-resources.mjs --check +git diff --check +``` + +设备测试明确选择隔离模拟器,方法见审计记录。报告:`app/build/reports/tests/testDebugUnitTest/index.html`、`app/build/reports/lint-results-debug.html`。剩余 Lint 为已有图标、API 判断、排版及 application 配置上下文缓存等提示,未扩大无关修复;SDK XML 版本差异提示不阻止构建。 diff --git a/README.md b/README.md index 3c30087..609ea8d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Fieldwatch +[English](README.md) | [简体中文](README.zh-CN.md) + +This is the [smallclover fork](https://github.com/smallclover/Fieldwatch) of [OffGridPete/Fieldwatch](https://github.com/OffGridPete/Fieldwatch), with Simplified Chinese localization. For Chinese setup instructions and the fork's current APK status, see the [Chinese README](README.zh-CN.md). Report fork-specific issues [here](https://github.com/smallclover/Fieldwatch/issues). The installation links below refer to the original project's APK, not a Chinese build of this fork. + I built Fieldwatch as a personal tool to look at what Wi-Fi access points and Bluetooth LE ads my phone was able to pick up, so that I could better understand what devices were being used around me. It’s passive, it only listens, there’s no dongle, no account, and no backend server. I wanted something that would work offline in the field. My goals were to have a modern interface that was easy to use, flexible in how information was displayed so I could customize a view based on what I was trying to do, a filtering engine so I do not have to look at everything, an extensible signature library so I can identify as many radio sources as possible and add new ones on the fly, as well as create reports of what was seen. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..87d63fb --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,171 @@ +# Fieldwatch 中文版 + +[English](README.md) | 简体中文 + +Fieldwatch 是一款 Android 无线信号观察工具,通过手机接收周围的 Wi-Fi 接入点信息和蓝牙低功耗(BLE)广播,帮助你查看、筛选、识别和记录附近的信号源。基础扫描和识别可以离线使用,无需账号或额外硬件,也没有 Fieldwatch 后端服务器。 + +本仓库是 [smallclover/Fieldwatch](https://github.com/smallclover/Fieldwatch),基于 [OffGridPete/Fieldwatch](https://github.com/OffGridPete/Fieldwatch) 维护,已加入简体中文支持。原作者为 Off Grid Pete LLC。当前代码基于原项目 **1.1.18**,应用包名仍为 `app.fieldwatch`。 + +## 功能 + +- 查看 Wi-Fi 接入点和 BLE 广播,观察信号强度、广播信息及变化。 +- 使用筛选条件和预设整理列表,按内置识别特征解释可能的设备类型。 +- 新建、编辑、导入和导出识别特征,保留自定义规则。 +- 为信号源命名、添加关注,按设置使用通知、蜂鸣和语音告警。 +- 保存监测会话,查看路径和会话对比,导出文本及 PDF 报告。 +- 生成供外部 AI 工具使用的分析文本;是否分享、分享给谁由你决定。 +- 切换简体中文、English 或跟随系统。 + +设备类型和行为解释属于推测。手机能收到什么,取决于设备广播、距离、手机硬件、Android 扫描限制和厂商省电策略;识别特征命中不等于确认设备身份。 + +## 中文支持 + +打开应用的 **设置 → 语言 → 简体中文** 即可切换。也可以选择 **English** 或 **跟随系统**;系统语言未受支持时使用英文。日语暂未加入。 + +中文覆盖主要页面、设置、权限说明、内置识别特征和解码说明、通知、语音、分享文本、会话报告及 PDF。中文语音需要手机的文字转语音(TTS)引擎提供中文语音,可在设置里点击 **测试告警** 检查。 + +设备实际广播的名称、品牌和协议标识,以及你输入的名称、备注和自定义规则,会保留原内容。切换语言不会把保存的数据整体翻译或改写。 + +## 安装到手机 + +需要 **Android 10 或更新版本**,以及支持 Wi-Fi 和 BLE 的手机。 + +**当前仓库的 `dist/Fieldwatch.apk` 是从原项目继承的安装包,没有随本次中文代码更新。它不能用于验证本仓库最新的中文功能。** `dist/` 中的独立说明卡和 PDF 用户手册也沿用原项目文档,尚未完成中文翻译。 + +要体验当前中文版,请按下文从源码构建,使用生成的 `app/build/outputs/apk/debug/app-debug.apk`。后续发布的安装包可查看本仓库的 [Releases 页面](https://github.com/smallclover/Fieldwatch/releases),以发布说明为准。 + +安装有两种方式: + +1. 将构建出的 APK 复制到手机,通过文件管理器打开,按 Android 提示允许该文件管理器安装应用并完成确认。 +2. 开启手机开发者选项和 USB 调试,用 USB 连接电脑,在手机上允许调试后,通过 ADB 安装。 + +在项目根目录运行以下命令;ADB 需已加入 `PATH`: + +```powershell +adb devices +adb install -r .\app\build\outputs\apk\debug\app-debug.apk +``` + +如果同时连接了手机和模拟器,请明确选择目标设备。将下例的 `设备序列号` 替换为 `adb devices` 列出的实际值: + +```powershell +adb -s '设备序列号' install -r .\app\build\outputs\apk\debug\app-debug.apk +``` + +首次启动时阅读应用说明,按系统提示授予扫描所需的位置、附近设备及通知等权限。权限入口和要求因 Android 版本而异;请同时检查手机的蓝牙、Wi-Fi 和系统定位开关。 + +**升级与签名:** 本分支与原作者版本使用相同包名,但你自行构建的 APK 通常使用不同签名,可能无法覆盖原作者签名的安装包。同一签名的版本可以使用 `-r` 覆盖更新。如果提示签名不兼容,先在应用设置中导出识别特征和设置,并另行保存需要的日志、会话和报告,再决定是否卸载旧版。卸载会清除应用数据,识别特征和设置导出也不等于备份全部日志及位置记录。 + +## 从源码构建 + +### 环境要求 + +| 工具 | 当前项目要求 | +| --- | --- | +| JDK | 17 | +| Android SDK | Android 15 / API 35 平台,以及 SDK Platform-Tools、Build-Tools | +| Gradle | 使用仓库自带 Wrapper,版本 8.11.1 | +| Android Gradle Plugin | 8.7.3,由项目配置管理 | +| Kotlin | 2.0.21,由项目配置管理 | +| Node.js | 仅维护国际化资源绑定时需要,普通 APK 构建不需要 | + +可以使用 Android Studio 打开项目,也可以使用 Android SDK 命令行工具构建。首次构建需要联网下载 Gradle 和项目依赖。 + +### Windows 示例:工具和缓存放在 E 盘 + +以下是路径示例,假设 JDK 和 SDK 已安装在对应目录。按你的实际位置调整;环境变量只对当前 PowerShell 会话生效。 + +```powershell +git clone https://github.com/smallclover/Fieldwatch.git E:\programing\Fieldwatch +cd E:\programing\Fieldwatch + +New-Item -ItemType Directory -Force E:\gradle-home, E:\Android\User, E:\Android\User\avd, E:\Android\Temp | Out-Null +$env:JAVA_HOME = 'E:\Java\jdk-17' +$env:ANDROID_HOME = 'E:\Android\Sdk' +$env:ANDROID_USER_HOME = 'E:\Android\User' +$env:ANDROID_AVD_HOME = 'E:\Android\User\avd' +$env:GRADLE_USER_HOME = 'E:\gradle-home' +$env:TEMP = 'E:\Android\Temp' +$env:TMP = 'E:\Android\Temp' +$env:PATH = "$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:PATH" + +.\gradlew.bat assembleDebug --console=plain +``` + +已有仓库可直接进入目录,跳过克隆步骤。如果 Gradle 无法找到 SDK,可在根目录创建或编辑 `local.properties`,加入下列配置;该文件存放本机配置,不提交到 Git: + +```properties +sdk.dir=E:/Android/Sdk +``` + +构建成功后,安装包位于 `app/build/outputs/apk/debug/app-debug.apk`。macOS / Linux 使用 `./gradlew assembleDebug`,并配置对应的 JDK 和 SDK 路径。 + +本项目的普通 `debug` 构建启用了代码混淆及资源压缩,并关闭了 `debuggable`,可用于手机功能验证。需要调试和设备自动测试时使用独立的 `i18nQa` 构建,其包名为 `app.fieldwatch.i18nqa`。 + +正式发布使用 `assembleRelease`,并需要配置自己的发布签名。构建脚本从本机 `local.properties` 读取 `FIELDWATCH_STORE_FILE`、`FIELDWATCH_STORE_PASSWORD`、`FIELDWATCH_KEY_ALIAS` 和 `FIELDWATCH_KEY_PASSWORD`。妥善保管签名密钥,后续覆盖更新需要使用相同签名;密钥和密码不应提交到仓库。 + +## 测试与检查 + +在项目根目录运行单元测试和 Android Lint: + +```powershell +.\gradlew.bat testDebugUnitTest lintDebug --console=plain +``` + +报告位置: + +- 单元测试:`app/build/reports/tests/testDebugUnitTest/index.html` +- Lint:`app/build/reports/lint-results-debug.html` + +连接测试模拟器或专用测试设备后,可运行隔离构建的设备测试: + +```powershell +.\gradlew.bat connectedI18nQaAndroidTest --console=plain +``` + +设备测试使用独立应用包和虚构数据。只连接预期的测试设备,避免对日常使用的手机进行无意的测试安装。 + +2026-10-04 的中文基线验证包括:460 项单元测试通过、6 项 API 35 设备测试通过、Lint 0 错误(仍有 56 项警告)、18 份英中 PDF 共 70 页的渲染检查,以及 Android 10 实机中文页面和告警听测。这是该次基线的记录,后续修改仍需重新验证。完整记录及尚未覆盖的场景见 [中文审计记录](CHINESE_AUDIT.md)。 + +## 参与维护 + +本分支的问题和建议请提交到 [smallclover/Fieldwatch Issues](https://github.com/smallclover/Fieldwatch/issues)。原项目及其更新记录可查看 [上游仓库](https://github.com/OffGridPete/Fieldwatch) 和 [CHANGELOG.md](CHANGELOG.md);现有变更日志沿用原项目记录,本次中文工作的详情见下表。 + +| 文档 / 目录 | 内容 | +| --- | --- | +| [I18N_PLAN.md](I18N_PLAN.md) | 国际化方案 | +| [CHINESE_COMPLETION_PLAN.md](CHINESE_COMPLETION_PLAN.md) | 中文补齐与验收计划 | +| [CHINESE_AUDIT.md](CHINESE_AUDIT.md) | 验证证据与已知限制 | +| [I18N_STATUS.md](I18N_STATUS.md) | 中文基线实施与手机验证记录 | +| `app/src/main/res/values/` | 英文默认资源 | +| `app/src/main/res/values-b+zh+Hans/` | 简体中文资源 | +| `app/src/main/java/app/fieldwatch/i18n/` | 语言切换、显示翻译层与资源绑定 | +| `app/src/test/`、`app/src/androidTest/` | 单元测试与设备测试 | + +新增界面文字时补齐英中字符串资源;新增领域说明或内置内容时,还需更新显式资源绑定: + +```powershell +node tools/update-i18n-resources.mjs +node tools/update-i18n-resources.mjs --check +git diff --check +``` + +资源绑定由脚本生成,用于确保代码混淆和资源压缩后仍能正确显示。译文用于显示,匹配规则、稳定 ID、协议值和导入导出格式应保持兼容。 + +## 能力边界与隐私 + +Fieldwatch 观察 Android 提供的 Wi-Fi 扫描结果和 BLE 广播,不提供 Wi-Fi 客户端抓包、802.11 监听模式、经典蓝牙发现、蜂窝信号扫描或测向。设备未广播、休眠、超出范围或不被系统扫描接口暴露时,可能不会出现;信号强弱也不能直接代表精确距离。 + +位置标记记录的是**手机在接收时的位置**,不是被观察设备的位置。“同行”“可能尾随”等分析是线索,不能作为确认身份或行为的结论。 + +记录保存在手机上,但你主动使用分享、日志导出、报告或 AI 文本导出时,可能包含完整 MAC 地址、备注及位置。隐私模式主要控制部分显示和输出,不会从原始日志中删除已记录的完整坐标。 + +在线地名与地图功能会访问系统地理编码服务或地图服务;从 GitHub 更新内置目录也需要联网,目前仍获取原作者仓库的数据。配置 TAK 推送后,数据会发送到你指定的目标。基础离线扫描不要求这些联网功能。 + +## 许可证与致谢 + +原项目版权:Copyright (c) 2026 Off Grid Pete LLC。 + +项目源代码采用 [MIT License](LICENSE),本分支保留原始许可证和版权声明。修改、再分发时应继续保留适用的版权和许可文本。软件按原许可证“按原样”提供,不保证发现或正确识别任何特定设备。 + +AndroidX、Kotlin 等依赖及离线编号表有各自的许可或使用条款,详见 [NOTICE](NOTICE)。本 README 为中文使用说明,不替代原始许可证文本。 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 088f7b7..53b72e9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,6 +31,7 @@ android { versionCode = 28 versionName = "1.1.18" vectorDrawables.useSupportLibrary = true + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } val releaseStore = localProp("FIELDWATCH_STORE_FILE") @@ -77,7 +78,16 @@ android { "proguard-rules.pro", ) } + create("i18nQa") { + initWith(getByName("debug")) + applicationIdSuffix = ".i18nqa" + isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + matchingFallbacks += listOf("debug") + } } + testBuildType = "i18nQa" compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -93,17 +103,22 @@ android { packaging { resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" } + bundle { + language { enableSplit = false } + } } dependencies { val composeBom = platform("androidx.compose:compose-bom:2024.12.01") implementation(composeBom) + androidTestImplementation(composeBom) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-extended") implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.appcompat:appcompat:1.7.1") implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") @@ -116,4 +131,8 @@ dependencies { testImplementation("junit:junit:4.13.2") // Real org.json on the JVM test classpath; the android.jar bundled JSONObject is a stub. testImplementation("org.json:json:20240303") + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test:rules:1.6.1") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") } diff --git a/app/src/androidTest/java/app/fieldwatch/i18n/ChineseDeviceTest.kt b/app/src/androidTest/java/app/fieldwatch/i18n/ChineseDeviceTest.kt new file mode 100644 index 0000000..6bb8758 --- /dev/null +++ b/app/src/androidTest/java/app/fieldwatch/i18n/ChineseDeviceTest.kt @@ -0,0 +1,218 @@ +package app.fieldwatch.i18n + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule +import androidx.test.filters.SdkSuppress +import app.fieldwatch.FieldwatchApp +import app.fieldwatch.R +import app.fieldwatch.data.DebriefPdf +import app.fieldwatch.domain.* +import app.fieldwatch.qa.I18nQaActivity +import app.fieldwatch.radio.RadioPermissions +import org.junit.Assert.* +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import kotlinx.coroutines.runBlocking + +@RunWith(AndroidJUnit4::class) +class ChineseDeviceTest { + @get:Rule(order = 0) val permissions = GrantPermissionRule.grant(*RadioPermissions.required()) + @get:Rule(order = 1) val ui = createAndroidComposeRule() + + private fun language(tag: String) { + ui.activityRule.scenario.onActivity { AppLanguage.select(tag) } + val expected = if (tag.startsWith("zh")) "zh" else "en" + ui.waitUntil(15_000) { + runCatching { + AppLanguage.selection() == tag && + ui.activity.resources.configuration.locales[0].language == expected && + ui.onAllNodes(isRoot()).fetchSemanticsNodes().isNotEmpty() + }.getOrDefault(false) + } + ui.waitForIdle() + } + + @After fun reset() { + ui.activityRule.scenario.onActivity { it.vm.cancelDraft(); AppLanguage.select("en") } + } + + @Test fun newChineseDraftSurvivesRotationAndLanguageChangeWithoutBeingSaved() { + language("zh-Hans") + ui.onNodeWithText("识别特征", useUnmergedTree = true).performClick() + ui.activityRule.scenario.onActivity { it.vm.beginNewFleet() } + val field = hasSetTextAction() and hasText("新建识别特征") + ui.onNode(field).assertExists().performTextReplacement("未保存草稿 QA / 中文") + ui.activityRule.scenario.recreate() + ui.onNode(hasSetTextAction() and hasText("未保存草稿 QA / 中文")).assertExists() + language("en") + ui.onNode(hasSetTextAction() and hasText("未保存草稿 QA / 中文")).assertExists() + ui.waitUntil(10_000) { ui.onAllNodesWithText("Cancel").fetchSemanticsNodes().isNotEmpty() } + ui.onNodeWithText("Cancel").performClick() + val app = ui.activity.application as FieldwatchApp + assertFalse(app.config.fleets.any { it.name == "未保存草稿 QA / 中文" }) + ui.activityRule.scenario.onActivity { it.vm.beginNewFleet() } + ui.onNode(hasSetTextAction() and hasText("New Signature")).assertExists() + } + + @Test @SdkSuppress(minSdkVersion = 33) fun localeResourceContextAndSystemEntryStaySynchronized() { + language("zh-Hans") + assertEquals("zh", AppLanguage.locale().language) + assertEquals("新建识别特征", appText(R.string.new_signature_name)) + assertFalse(DebriefPlaces.Off.note == "off") + val manager = ui.activity.getSystemService(android.app.LocaleManager::class.java) + assertEquals("zh-Hans", manager.applicationLocales.toLanguageTags()) + ui.activityRule.scenario.onActivity { + manager.applicationLocales = android.os.LocaleList.forLanguageTags("en") + } + ui.waitForIdle() + ui.waitUntil(10_000) { AppLanguage.selection() == "en" } + assertEquals("New Signature", appText(R.string.new_signature_name)) + ui.activityRule.scenario.onActivity { + manager.applicationLocales = android.os.LocaleList.forLanguageTags("fr") + } + ui.waitUntil(10_000) { AppLanguage.selection() == "fr" && AppLanguage.locale().language == "en" } + assertEquals("New Signature", appText(R.string.new_signature_name)) + language("") + assertTrue(manager.applicationLocales.isEmpty) + } + + private fun verifyDraftRestoration(value: String) { + ui.activityRule.scenario.recreate() + ui.onNode(hasSetTextAction() and hasText(value)).assertExists() + language("en") + ui.onNode(hasSetTextAction() and hasText(value)).assertExists() + } + + @Test fun presetNameAndSessionNameRemainUncommittedAcrossRecreation() { + language("zh-Hans") + ui.onNodeWithText("筛选", useUnmergedTree = true).performClick() + ui.onNode(hasSetTextAction() and hasText(appText(R.string.filters_screen_save_current_as))) + .performScrollTo().performTextReplacement("QA 未保存预设") + verifyDraftRestoration("QA 未保存预设") + assertFalse((ui.activity.application as FieldwatchApp).config.presets.any { it.name == "QA 未保存预设" }) + language("zh-Hans") + ui.onNodeWithText("报告", useUnmergedTree = true).performClick() + ui.onAllNodesWithText("开始会话").onFirst().performClick() + ui.onNode(hasSetTextAction() and hasText(appText(R.string.reports_screen_name))) + .performTextReplacement("QA 未保存会话") + verifyDraftRestoration("QA 未保存会话") + ui.onNodeWithText("Cancel").performClick() + assertNull((ui.activity.application as FieldwatchApp).sits.ui.value.open) + } + + @Test fun decodeDraftRestoresAndTranslatedStockLabelsAreNotWrittenBack() { + language("zh-Hans") + ui.onNodeWithText("识别特征", useUnmergedTree = true).performClick() + val app = ui.activity.application as FieldwatchApp + val stock = app.config.fleets.first { it.id == "fleet-dult" } + ui.activityRule.scenario.onActivity { it.vm.editFleet(stock) } + ui.onNodeWithText(appText(R.string.fleets_screen_fields, stock.decode!!.fields.size)).performScrollTo().performClick() + ui.onNode(hasSetTextAction() and hasText("模式")).assertExists() + val label = hasSetTextAction() and hasText("模式") + ui.onNode(label).performScrollTo().performTextReplacement("QA 自定义模式") + ui.onNode(hasSetTextAction() and hasText("离开主人")).performScrollTo().performTextReplacement("QA 自定义枚举") + val mode = stock.decode!!.fields.first { it.id == "mode" } + val note = CatalogText.decodeField(stock, mode, "note", mode.enumNotes!!.getValue("0"), "0") + ui.onNode(hasSetTextAction() and hasText(note)).performScrollTo().performTextReplacement("QA 未保存备注") + verifyDraftRestoration("QA 自定义模式") + ui.onNode(hasSetTextAction() and hasText("QA 自定义枚举")).assertExists() + ui.onNode(hasSetTextAction() and hasText("QA 未保存备注")).assertExists() + ui.onNodeWithText("Back").performClick() + ui.onNodeWithText("Cancel").performClick() + assertEquals(stock, app.config.fleets.first { it.id == stock.id }) + } + + @Test fun editingOnlyNumericStockFieldPreservesCanonicalNamesAndNotes() { + language("zh-Hans") + ui.onNodeWithText("识别特征", useUnmergedTree = true).performClick() + val app = ui.activity.application as FieldwatchApp + val stock = app.config.fleets.first { it.displayName() != it.name } + ui.activityRule.scenario.onActivity { it.vm.editFleet(stock) } + ui.onNode(hasSetTextAction() and hasText(appText(R.string.fleets_screen_min_peers_0_off))) + .performScrollTo().performTextReplacement("7") + ui.onNodeWithText("保存").performClick() + ui.waitUntil(10_000) { app.config.fleets.first { it.id == stock.id }.minPeers == 7 } + assertEquals(stock.copy(minPeers = 7), app.config.fleets.first { it.id == stock.id }) + runBlocking { app.config.update { cfg -> cfg.copy(fleets = cfg.fleets.map { if (it.id == stock.id) stock else it }) } } + } + + @Test fun generatesRealAndroidPdfsFromSyntheticFixturesInBothLanguages() { + val dir = File(ui.activity.filesDir, "i18n-qa").apply { mkdirs() } + val now = 1_728_000_900_000L + val path = (0..30).map { i -> + val shift = if (i < 8) 0.0 else if (i > 22) .003 else (i - 8) * .0002 + GpsSample(now - 900_000 + i * 30_000, 35.0 + shift, 110.0 + shift) + } + val transitPath = listOf(GpsSample(now - 900_000, 35.0, 110.0), GpsSample(now - 840_000, 35.0, 110.0)) + + (0..11).map { i -> GpsSample(now - 780_000 + i * 5_000, 35.001 + i * .00027, 110.0) } + + listOf(GpsSample(now - 600_000, 35.004, 110.0), GpsSample(now, 35.004, 110.0)) + assertTrue(Geo.legs(transitPath).any { !it.stay }) + assertTrue(Geo.legs(transitPath).any { it.stay }) + val fleet = DefaultCatalog.newBlankFleet().copy( + id = "qa-attention", name = "Synthetic / 模拟识别特征", builtIn = false, + kind = SignatureClass.FINDER, attentionNote = "QA 模拟说明,匹配不代表身份。".repeat(40), + ) + val devices = (1..16).map { i -> + val mac = "02:00:00:00:00:%02X".format(i) + Sighting( + key = "BLE:$mac", kind = if (i % 3 == 0) RadioKind.WIFI else RadioKind.BLE, + mac = mac, name = if (i == 1) "QA 长中文名称 / Mixed text " + "监测样本".repeat(15) else "QA模拟设备-$i", + rssi = -40 - i, rssiMin = -85, rssiMax = -40, channel = if (i % 3 == 0) 6 else 0, + frequencyMhz = if (i % 3 == 0) 2437 else 0, vendor = "Synthetic vendor", randomized = false, + hiddenSsid = false, serviceUuids = listOf("180F"), manufacturerId = null, + manufacturerDataHex = "", rawHex = "", extras = "", firstSeen = now - 900_000, + lastSeen = now, hitCount = 31, fleetIds = if (i == 1) setOf(fleet.id) else emptySet(), + rssiHistory = path.map { RssiSample(it.at, -40 - i) }, presence = listOf(PresenceSpan(now - 900_000, now)), + gpsTrail = path.map { it.copy(rssi = -40 - i) }, latitude = path.last().lat, longitude = path.last().lon, + ) + } + for (tag in listOf("en", "zh-Hans")) { + language(tag) + val settings = AppSettings(tagLocation = true, onlineLookup = false) + val docs = linkedMapOf( + "empty" to DebriefReport.document(emptyList(), emptyList(), settings, emptyList(), now), + "ordinary" to DebriefReport.document(devices.takeLast(3), listOf(fleet), settings, emptyList(), now), + "long" to DebriefReport.document(devices.take(2), listOf(fleet), settings, path, now, + observerNotes = mapOf(devices[0].key to "QA 观察备注:" + "中文混排 long note。".repeat(80))), + "many-path" to DebriefReport.document(devices, listOf(fleet), settings, path, now), + "transit" to DebriefReport.document(devices.takeLast(3), listOf(fleet), settings, transitPath, now), + "compare" to SitDiff.document( + SitDiff.Side("QA 会话 A", false, devices.take(12).map { SitDiff.fromSighting(it, listOf(fleet), emptyMap()) }, path), + SitDiff.Side("QA 会话 B", false, devices.drop(4).map { SitDiff.fromSighting(it, listOf(fleet), emptyMap()) }, path), + showAllRadios = true), + ) + for ((name, doc) in docs) { + val file = File(dir, "$tag-$name.pdf") + DebriefPdf.write(doc, file) + assertTrue("$file", file.length() > 1000) + File(dir, "$tag-$name.txt").writeText(doc.toPlainText()) + } + val attention = docs.getValue("long") + assertTrue(attention.sections.any { it.kind == ReportSectionKind.EXTRA_ATTENTION }) + // Titles are presentation: renaming one must retain the structured attention card. + DebriefPdf.write(attention.copy(sections = attention.sections.map { + if (it.kind == ReportSectionKind.EXTRA_ATTENTION) it.copy(title = "QA 自定义标题") else it + }), File(dir, "$tag-retitled.pdf")) + DebriefPdf.write(attention.copy(extraAttention = listOf( + attention.extraAttention.first().copy( + signature = "QA 长识别特征 " + "混排中文 Mixed text。".repeat(25), + note = "QA 极长备注:" + "分页检验中文 mixed text 0123456789。".repeat(220) + " QA END OF NOTE", + ) + )), File(dir, "$tag-overflow.pdf")) + val dult = DefaultCatalog.fleets().first { it.id == "fleet-dult" } + val decodedDevice = devices[1].copy(fleetIds = setOf(dult.id), + facts = RadioFacts(serviceData = listOf(ServiceDataRecord("FCB2", "0000")), deviceClass = 0x020C)) + val decoded = DebriefReport.document(listOf(decodedDevice), listOf(dult), + settings.copy(debriefShowAllRadios = true), emptyList(), now) + assertTrue(decoded.toPlainText().contains(if (tag == "zh-Hans") "离开主人" else "separated")) + DebriefPdf.write(decoded, File(dir, "$tag-decoded.pdf")) + File(dir, "$tag-detail.txt").writeText(DeviceDetailText.build(devices[0], listOf(fleet.name), now, fleets = listOf(fleet))) + File(dir, "$tag-ai-detail.txt").writeText(DeviceDetailPrompt.build(decodedDevice, listOf(dult.name), settings, now = now, fleets = listOf(dult))) + } + } +} diff --git a/app/src/i18nQa/AndroidManifest.xml b/app/src/i18nQa/AndroidManifest.xml new file mode 100644 index 0000000..8e4f7bb --- /dev/null +++ b/app/src/i18nQa/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/i18nQa/java/app/fieldwatch/qa/I18nQaActivity.kt b/app/src/i18nQa/java/app/fieldwatch/qa/I18nQaActivity.kt new file mode 100644 index 0000000..4d66a63 --- /dev/null +++ b/app/src/i18nQa/java/app/fieldwatch/qa/I18nQaActivity.kt @@ -0,0 +1,38 @@ +package app.fieldwatch.qa + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import app.fieldwatch.FieldwatchApp +import app.fieldwatch.domain.DISCLAIMER_REV +import app.fieldwatch.i18n.AppLanguage +import app.fieldwatch.ui.FieldwatchRoot +import app.fieldwatch.ui.FieldwatchViewModel +import kotlinx.coroutines.launch + +/** Isolated test application only. Never reads the ordinary app's data or starts radios. */ +class I18nQaActivity : AppCompatActivity() { + val vm: FieldwatchViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + AppLanguage.refresh() + val app = application as FieldwatchApp + app.stopScanning() + // Do not block the main thread while another UI coroutine holds ConfigStore's mutex. + lifecycleScope.launch { + app.config.update { cfg -> + cfg.copy(settings = cfg.settings.copy( + disclaimerAccepted = true, disclaimerRev = DISCLAIMER_REV, + liveTourDone = true, onlineLookup = false, tagLocation = false, + loggingEnabled = false, alertsEnabled = false, alertVoice = false, + alertBeep = false, keepScreenOn = false, + )) + } + vm.refreshPermissions() + setContent { FieldwatchRoot(vm) {} } + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index da648a8..516a687 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,10 +31,18 @@ android:largeHeap="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:localeConfig="@xml/locales_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Fieldwatch"> + + + + Unit) { + val ticket = voiceEpoch.ticket() + main.postDelayed({ if (voiceEpoch.isCurrent(ticket)) action() }, delayMs) + } + + private fun voiceCallback(id: String?, action: () -> Unit) { + main.post { if (id != null && id == currentUtterance) action() } + } private val speakWatchdog = Runnable { if (!speaking) return@Runnable val retry = pendingSpeak @@ -96,7 +111,7 @@ class Alerter(private val context: Context) { beep(holdFocus = !speakClass) vibrate() } - if (speakClass) queueVoice(testWatchPhrase(voiceWhat), afterBeep = beepOn) + if (speakClass) queueVoice(app.fieldwatch.i18n.localizedTestWatchPhrase(voiceWhat), afterBeep = beepOn) } /** Warm the TTS engine on the main thread so the first alert is not silent. */ @@ -144,10 +159,12 @@ class Alerter(private val context: Context) { if (token in announced) continue if (!arrivalsOnly && !isNewAppearance(device, now)) continue announced.add(token) + val fleet = fleetNames[target.fleetId] + val targetLabel = if (target.deviceKey == null && fleet != null && + (target.label.isBlank() || target.label == fleet.name)) fleet.displayName() + else target.label.ifBlank { device.displayName } val label = MacUtil.redactMacIn( - target.label.ifBlank { - fleetNames[target.fleetId]?.name ?: device.displayName - }, + targetLabel, device.mac, demoMode, ) @@ -159,7 +176,7 @@ class Alerter(private val context: Context) { } if (beepOn || voiceOn) _flashes.tryEmit(device.key) if (voiceOn && voicePhrase == null) { - voicePhrase = spokenWatchPhrase(device, fleets, voiceWhat, target) + voicePhrase = app.fieldwatch.i18n.localizedWatchPhrase(device, fleets, voiceWhat, target) } } } @@ -172,7 +189,7 @@ class Alerter(private val context: Context) { if (afterBeep) { speakAfterBeep = phrase } else { - main.post { speakClassName(phrase) } + postVoice(0) { speakClassName(phrase) } } } @@ -195,11 +212,11 @@ class Alerter(private val context: Context) { ) val note = NotificationCompat.Builder(context, CHANNEL) .setSmallIcon(R.drawable.ic_stat_fieldwatch) - .setContentTitle("Fieldwatch watchlist") + .setContentTitle(localized("alerter_fieldwatch_watchlist", "Fieldwatch watchlist")) .setContentText("$label ${device.rssi} dBm ${MacUtil.screenMac(device.mac, demoMode)}") .setStyle( NotificationCompat.BigTextStyle().bigText( - "$label appeared\n${device.kind} ${MacUtil.screenMac(device.mac, demoMode)}\n${device.rssi} dBm ${MacUtil.redactMacIn(device.displayName, device.mac, demoMode)}", + localized("alerter_appeared_n_n_dbm", "%1\$s appeared\n%2\$s %3\$s\n%4\$s dBm %5\$s", label, device.kind, MacUtil.screenMac(device.mac, demoMode), device.rssi, MacUtil.redactMacIn(device.displayName, device.mac, demoMode)), ), ) .setPriority(NotificationCompat.PRIORITY_HIGH) @@ -294,7 +311,7 @@ class Alerter(private val context: Context) { val phrase = speakAfterBeep ?: return speakAfterBeep = null releaseFocus() - main.postDelayed({ speakClassName(phrase) }, VOICE_AFTER_TRACK_MS) + postVoice(VOICE_AFTER_TRACK_MS) { speakClassName(phrase) } } private fun writeSine(dest: ShortArray, offset: Int, n: Int, sampleRate: Int, freqHz: Double) { @@ -396,7 +413,14 @@ class Alerter(private val context: Context) { if (tts != null) return tts = TextToSpeech(context) { status -> ttsReady = status == TextToSpeech.SUCCESS - if (!ttsReady) return@TextToSpeech + if (!ttsReady) { + postVoice(0) { + pendingSpeak = null + releaseFocus() + android.widget.Toast.makeText(context, app.fieldwatch.i18n.appText(R.string.voice_initialization_failed), android.widget.Toast.LENGTH_LONG).show() + } + return@TextToSpeech + } val engine = tts ?: return@TextToSpeech engine.setAudioAttributes( AudioAttributes.Builder() @@ -404,42 +428,63 @@ class Alerter(private val context: Context) { .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build(), ) - runCatching { - val lang = engine.setLanguage(Locale.getDefault()) - if (lang == TextToSpeech.LANG_MISSING_DATA || lang == TextToSpeech.LANG_NOT_SUPPORTED) { - engine.language = Locale.US - } - } engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() { override fun onStart(utteranceId: String?) { + voiceCallback(utteranceId) { utteranceStarted = true speaking = true main.removeCallbacks(speakWatchdog) main.postDelayed(speakWatchdog, SPEAK_TIMEOUT_MS) + } } override fun onDone(utteranceId: String?) { + voiceCallback(utteranceId) { speaking = false utteranceStarted = false pendingSpeak = null ttsSpeakTries = 0 main.removeCallbacks(speakWatchdog) + } } @Deprecated("Deprecated in Java") override fun onError(utteranceId: String?) { + voiceCallback(utteranceId) { speaking = false utteranceStarted = false main.removeCallbacks(speakWatchdog) + } } override fun onError(utteranceId: String?, errorCode: Int) { + voiceCallback(utteranceId) { speaking = false utteranceStarted = false main.removeCallbacks(speakWatchdog) + } } }) // Samsung often drops the first speak() if it runs inside onInit. - main.postDelayed({ + postVoice(TTS_AFTER_INIT_MS) { pendingSpeak?.let { startSpeak(it) } - }, TTS_AFTER_INIT_MS) + } + } + } + + private var missingVoice: String? = null + + fun refreshLanguage() { + voiceEpoch.advance() + currentUtterance = null + ensureChannel() + main.post { + tts?.stop() + pendingSpeak = null + speakAfterBeep = null + speaking = false + utteranceStarted = false + ttsSpeakTries = 0 + main.removeCallbacks(speakWatchdog) + releaseFocus() + missingVoice = null } } @@ -450,20 +495,49 @@ class Alerter(private val context: Context) { return } if (speaking && utteranceStarted) return + val language = app.fieldwatch.i18n.AppLanguage.locale() + val id = "$UTTERANCE-${voiceEpoch.ticket()}-${++utteranceSerial}" + currentUtterance = id pendingSpeak = text ttsSpeakTries++ speaking = true utteranceStarted = false - val ok = engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, UTTERANCE) - if (ok != TextToSpeech.SUCCESS) { + val outcome = deliverVoice(object : VoiceOutput { + override fun setLanguage(locale: Locale) = engine.setLanguage(locale) + override fun speak(text: String, utteranceId: String) = + engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId) == TextToSpeech.SUCCESS + }, language, text, id) + if (outcome == VoiceOutcome.UNAVAILABLE) { + pendingSpeak = null + currentUtterance = null + speaking = false + utteranceStarted = false + ttsSpeakTries = 0 + main.removeCallbacks(speakWatchdog) + releaseFocus() + if (missingVoice != language.toLanguageTag()) { + missingVoice = language.toLanguageTag() + android.widget.Toast.makeText(context, app.fieldwatch.i18n.appText(R.string.voice_language_unavailable), android.widget.Toast.LENGTH_LONG).show() + } + return + } + missingVoice = null + if (outcome == VoiceOutcome.FAILED) { speaking = false if (ttsSpeakTries < 2) { - main.postDelayed({ startSpeak(text) }, TTS_AFTER_INIT_MS) + postVoice(TTS_AFTER_INIT_MS) { startSpeak(text) } + } else { + pendingSpeak = null + currentUtterance = null + ttsSpeakTries = 0 + releaseFocus() + android.widget.Toast.makeText(context, app.fieldwatch.i18n.appText(R.string.voice_playback_failed), android.widget.Toast.LENGTH_LONG).show() } return } main.removeCallbacks(speakWatchdog) - main.postDelayed({ + postVoice(TTS_START_WAIT_MS) { + if (currentUtterance != id) return@postVoice if (speaking && !utteranceStarted && ttsSpeakTries < 2) { speaking = false startSpeak(text) @@ -472,7 +546,7 @@ class Alerter(private val context: Context) { pendingSpeak = null ttsSpeakTries = 0 } - }, TTS_START_WAIT_MS) + } } private fun vibrate() { @@ -490,10 +564,16 @@ class Alerter(private val context: Context) { private fun ensureChannel() { if (Build.VERSION.SDK_INT < 26) return runCatching { manager.deleteNotificationChannel("fieldwatch_watch_v2") } - if (manager.getNotificationChannel(CHANNEL) != null) return + val existing = manager.getNotificationChannel(CHANNEL) + if (existing != null) { + existing.name = localized("alerter_watchlist", "Watchlist") + existing.description = localized("alerter_appearing_signatures_and_devices_on_your_watchlist", "Appearing signatures and devices on your watchlist. Beep is played separately.") + manager.createNotificationChannel(existing) + return + } manager.createNotificationChannel( - NotificationChannel(CHANNEL, "Watchlist", NotificationManager.IMPORTANCE_HIGH).apply { - description = "Appearing signatures and devices on your watchlist. Beep is played separately." + NotificationChannel(CHANNEL, localized("alerter_watchlist", "Watchlist"), NotificationManager.IMPORTANCE_HIGH).apply { + description = localized("alerter_appearing_signatures_and_devices_on_your_watchlist", "Appearing signatures and devices on your watchlist. Beep is played separately.") enableVibration(true) enableLights(true) setSound(null, null) diff --git a/app/src/main/java/app/fieldwatch/alert/VoiceOutput.kt b/app/src/main/java/app/fieldwatch/alert/VoiceOutput.kt new file mode 100644 index 0000000..8ab0db9 --- /dev/null +++ b/app/src/main/java/app/fieldwatch/alert/VoiceOutput.kt @@ -0,0 +1,26 @@ +package app.fieldwatch.alert + +import java.util.Locale +import java.util.concurrent.atomic.AtomicLong + +internal interface VoiceOutput { + fun setLanguage(locale: Locale): Int + fun speak(text: String, utteranceId: String): Boolean +} + +internal enum class VoiceOutcome { ACCEPTED, UNAVAILABLE, FAILED } + +/** Negative Android TTS language results require fallback; never queue text in another language. */ +internal fun deliverVoice(output: VoiceOutput, locale: Locale, text: String, id: String): VoiceOutcome { + val language = runCatching { output.setLanguage(locale) }.getOrDefault(-2) + if (language < 0) return VoiceOutcome.UNAVAILABLE + return if (runCatching { output.speak(text, id) }.getOrDefault(false)) VoiceOutcome.ACCEPTED else VoiceOutcome.FAILED +} + +/** A queued callback may outlive the Activity which changed the app language. */ +internal class VoiceEpoch { + private val generation = AtomicLong() + fun ticket(): Long = generation.get() + fun isCurrent(ticket: Long): Boolean = ticket == generation.get() + fun advance() { generation.incrementAndGet() } +} diff --git a/app/src/main/java/app/fieldwatch/data/DebriefPdf.kt b/app/src/main/java/app/fieldwatch/data/DebriefPdf.kt index 8ad673f..32e3f9e 100644 --- a/app/src/main/java/app/fieldwatch/data/DebriefPdf.kt +++ b/app/src/main/java/app/fieldwatch/data/DebriefPdf.kt @@ -1,5 +1,7 @@ package app.fieldwatch.data +import app.fieldwatch.i18n.localized + import android.graphics.Canvas import android.graphics.Color import android.graphics.DashPathEffect @@ -22,6 +24,7 @@ import androidx.compose.ui.graphics.vector.VectorNode import androidx.compose.ui.graphics.vector.VectorPath import androidx.compose.ui.graphics.vector.toPath import app.fieldwatch.domain.DebriefDoc +import app.fieldwatch.domain.ReportSectionKind import app.fieldwatch.domain.ExtraAttentionHit import app.fieldwatch.domain.ReportBar import app.fieldwatch.domain.ReportChart @@ -75,7 +78,7 @@ object DebriefPdf { private const val MARK_PILOT = 7 private const val MARK_ALERT = 8 private val CONTENT_W = (PAGE_W - 2 * MARGIN).toInt() - private val TIME_FMT = SimpleDateFormat("HH:mm", Locale.getDefault()) + private val TIME_FMT get() = SimpleDateFormat("HH:mm", app.fieldwatch.i18n.TextRuntime.localeProvider()) private val BODY_TOP = HEADER_H + 18f private val BODY_BOT = PAGE_H - FOOTER_H - 8f private val USABLE = BODY_BOT - BODY_TOP @@ -128,7 +131,7 @@ object DebriefPdf { val out = ArrayList() out += titleBlock(doc.pdfTitle) out += spacer(6f) - out += sectionHead("", "Disclaimer", alert = false) + out += sectionHead("", localized("debrief_pdf_disclaimer", "Disclaimer"), alert = false) doc.disclaimer.split("\n\n").forEach { para -> chunkText(para.trim().ifBlank { " " }, CONTENT_W, 9f, muted = true).forEach { sl -> out += textBlock(sl) @@ -153,11 +156,11 @@ object DebriefPdf { out += spacer(10f) } for (section in doc.sections) { - if (section.title == "Extra attention" && doc.extraAttention.isNotEmpty()) { + if (section.kind == ReportSectionKind.EXTRA_ATTENTION && doc.extraAttention.isNotEmpty()) { out += sectionHead(section.number, section.title, alert = true) out += spacer(4f) doc.extraAttention.forEach { hit -> - out += attentionNoteBlock(hit) + out += attentionNoteBlocks(hit) out += spacer(8f) } continue @@ -391,7 +394,7 @@ object DebriefPdf { canvas.drawRoundRect(panel, 7f, 7f, fill) canvas.drawRoundRect(panel, 7f, 7f, stroke) val kicker = Paint().apply { - color = if (fig.kicker == "AIRCRAFT") AIRCRAFT else PHOS + color = if (fig.kicker == localized("debrief_pdf_aircraft", "AIRCRAFT")) AIRCRAFT else PHOS textSize = 8f typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) isAntiAlias = true @@ -400,18 +403,18 @@ object DebriefPdf { canvas.drawText(fig.kicker, MARGIN + 8f, y + 15f, kicker) val stats = Paint().apply { color = MUTED; textSize = 8f; isAntiAlias = true } val span = if (fig.spanM >= 1000) { - "${"%.1f".format(Locale.US, fig.spanM / 1000)} km span" + localized("debrief_pdf_km_span", "%1\$s km span", "%.1f".format(Locale.US, fig.spanM / 1000)) } else { - "${fig.spanM.toInt()} m span" + localized("debrief_pdf_m_span", "%1\$s m span", fig.spanM.toInt()) } val len = if (fig.lengthM >= 1000) { - "${"%.1f".format(Locale.US, fig.lengthM / 1000)} km path" + localized("debrief_pdf_km_path", "%1\$s km path", "%.1f".format(Locale.US, fig.lengthM / 1000)) } else { - "${fig.lengthM.toInt()} m path" + localized("debrief_pdf_m_path", "%1\$s m path", fig.lengthM.toInt()) } val fixes = fig.tracks.filter { it.aircraft }.sumOf { it.samples.size } val right = if (fig.tracks.all { it.aircraft } && fixes == 1) { - "1 advertised fix" + localized("debrief_pdf_1_advertised_fix", "1 advertised fix") } else { "$len · $span" } @@ -664,8 +667,8 @@ object DebriefPdf { textSize = 7.5f isAntiAlias = true } - canvas.drawText("Start", ox(pts.first().x) + 6f, oy(pts.first().y) - 4f, lab) - canvas.drawText("End", ox(pts.last().x) + 6f, oy(pts.last().y) - 4f, lab) + canvas.drawText(localized("debrief_pdf_start", "Start"), ox(pts.first().x) + 6f, oy(pts.first().y) - 4f, lab) + canvas.drawText(localized("debrief_pdf_end", "End"), ox(pts.last().x) + 6f, oy(pts.last().y) - 4f, lab) } } @@ -676,20 +679,20 @@ object DebriefPdf { val out = ArrayList() when { phone.size >= 2 -> { - out += LegendSwatch("This sit", MARK_STAY) - out += LegendSwatch("Second sit", MARK_SLATE) + out += LegendSwatch(localized("debrief_pdf_this_sit", "This sit"), MARK_STAY) + out += LegendSwatch(localized("debrief_pdf_second_sit", "Second sit"), MARK_SLATE) } - phone.any { it.samples.size >= 2 } -> out += LegendSwatch("Stay", MARK_STAY) + phone.any { it.samples.size >= 2 } -> out += LegendSwatch(localized("debrief_pdf_stay", "Stay"), MARK_STAY) } val craft = fig.tracks.filter { it.aircraft } if (craft.isNotEmpty()) { - out += LegendSwatch("Advertised", MARK_AMBER) - if (craft.any { it.secondary }) out += LegendSwatch("Second advertised", MARK_AMBER_DASH) + out += LegendSwatch(localized("debrief_pdf_advertised", "Advertised"), MARK_AMBER) + if (craft.any { it.secondary }) out += LegendSwatch(localized("debrief_pdf_second_advertised", "Second advertised"), MARK_AMBER_DASH) } - if (fig.dots.any { it.extraAttention }) out += LegendSwatch("Extra attention", MARK_RED) - if (fig.dots.any { it.named }) out += LegendSwatch("MAC alert", MARK_BLUE) - if (fig.dots.any { !it.extraAttention && !it.named }) out += LegendSwatch("Signature alert", MARK_ALERT) - if (fig.pilots.isNotEmpty()) out += LegendSwatch("Pilot", MARK_PILOT) + if (fig.dots.any { it.extraAttention }) out += LegendSwatch(localized("debrief_pdf_extra_attention", "Extra attention"), MARK_RED) + if (fig.dots.any { it.named }) out += LegendSwatch(localized("debrief_pdf_mac_alert", "MAC alert"), MARK_BLUE) + if (fig.dots.any { !it.extraAttention && !it.named }) out += LegendSwatch(localized("debrief_pdf_signature_alert", "Signature alert"), MARK_ALERT) + if (fig.pilots.isNotEmpty()) out += LegendSwatch(localized("debrief_pdf_pilot", "Pilot"), MARK_PILOT) return out } @@ -934,7 +937,7 @@ object DebriefPdf { val craft = fig.craftKeys.filter { it.isNotBlank() } if (piles.isEmpty() && craft.isEmpty()) return emptyList() val out = ArrayList() - out += sectionHead("", "Path key", alert = false) + out += sectionHead("", localized("debrief_pdf_path_key", "Path key"), alert = false) out += spacer(4f) piles.forEachIndexed { i, pile -> out += pathKeyRow(i + 1, pile) @@ -951,16 +954,16 @@ object DebriefPdf { val radios = pile.members.joinToString(" · ") { m -> val d = m.dot val kind = if (d.kind.name == "WIFI") "WIFI" else "BLE" - val tag = if (d.extraAttention) "Extra attention" else null + val tag = if (d.extraAttention) localized("debrief_pdf_extra_attention", "Extra attention") else null val fleets = d.fleetNames.filter { it.isNotBlank() }.joinToString(", ") - val obs = d.observerNotes.trim().takeIf { it.isNotEmpty() }?.let { "Observer: $it" } + val obs = d.observerNotes.trim().takeIf { it.isNotEmpty() }?.let { localized("debrief_pdf_observer", "Observer: %1\$s", it) } val who = listOfNotNull(kind, d.label.ifBlank { d.mac }, fleets.ifBlank { null }, tag, obs) .joinToString(" ") val advertised = d.advertisedNote.trim() if (advertised.isEmpty()) who else "$who — $advertised" } return if (pile.stacked) { - "$n ${pile.members.size} radios at this stop — $radios" + localized("debrief_pdf_radios_at_this_stop", "%1\$s %2\$s radios at this stop — %3\$s", n, pile.members.size, radios) } else { "$n $radios" } @@ -975,7 +978,7 @@ object DebriefPdf { private fun isStayHead(line: String): Boolean { val t = line.trim() - return t.matches(Regex("""^\d+\.\s+(Stay|Transit)\b.*""")) || + return t.matches(Regex("""^\d+\.\s+(Stay|Transit|停留|途经)(?:\s|$).*""")) || t.startsWith("• ") } @@ -983,7 +986,7 @@ object DebriefPdf { val t = line.trim() if (t.startsWith("Phone GPS")) return true if (t.contains(". ")) return false - return t.matches(Regex("""^[A-Z][A-Za-z0-9 +/'()&.,-]{0,48}:(\s.*)?$""")) + return t.matches(Regex("""^[\p{L}][\p{L}\p{N} +/'()&.,,·-]{0,48}[::](\s.*)?$""")) } private fun isBullet(line: String): Boolean { @@ -1103,28 +1106,33 @@ object DebriefPdf { } } - private fun attentionNoteBlock(hit: ExtraAttentionHit): Block { + private fun attentionNoteBlocks(hit: ExtraAttentionHit): List { val innerW = CONTENT_W - 24 + val label = localized("debrief_pdf_extra_attention_2", "EXTRA ATTENTION · %1\$s", hit.signature).uppercase() + val kicker = layout(label, innerW, 8f, muted = false, bold = true).apply { paint.color = ALERT_BAR } val radio = layout(hit.radioLabel, innerW, 9f, muted = false, bold = true) val note = layout(hit.note, innerW, 9.5f, muted = false) - val foot = layout("Pattern match, not identity. Not a safety finding.", innerW, 8f, muted = true) - val h = 22f + radio.height + 6f + note.height + 8f + foot.height + 12f - return Block(h) { canvas, y -> + val foot = layout(localized("debrief_pdf_pattern_match_not_identity_not_a_safety", "Pattern match, not identity. Not a safety finding."), innerW, 8f, muted = true) + val headH = kicker.height + 8f + val h = headH + radio.height + 6f + note.height + 8f + foot.height + 12f + // A user note can exceed a whole page. Split it before pagination rather than clip it. + if (h > USABLE) return buildList { + chunkText(label, innerW, 8f, muted = false).forEach { add(bodyBlock(it, alert = true)) } + chunkText(hit.radioLabel, innerW, 9f, muted = false).forEach { add(bodyBlock(it, alert = true)) } + chunkText(hit.note, innerW, 9.5f, muted = false).forEach { add(bodyBlock(it, alert = true)) } + add(bodyBlock(foot, alert = true)) + } + return listOf(Block(h) { canvas, y -> val box = RectF(MARGIN - 6f, y, PAGE_W - MARGIN + 6f, y + h - 4f) val fill = Paint().apply { color = ALERT_BG; style = Paint.Style.FILL } val bar = Paint().apply { color = ALERT_BAR; style = Paint.Style.FILL } canvas.drawRoundRect(box, 4f, 4f, fill) canvas.drawRect(box.left, box.top, box.left + 4f, box.bottom, bar) - val kicker = Paint().apply { - color = ALERT_BAR - textSize = 8f - typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) - isAntiAlias = true - letterSpacing = 0.06f - } - val label = "EXTRA ATTENTION · ${hit.signature}".uppercase() - canvas.drawText(label, MARGIN + 10f, y + 14f, kicker) - var ty = y + 20f + canvas.save() + canvas.translate(MARGIN + 10f, y + 4f) + kicker.draw(canvas) + canvas.restore() + var ty = y + headH canvas.save() canvas.translate(MARGIN + 10f, ty) radio.draw(canvas) @@ -1139,7 +1147,7 @@ object DebriefPdf { canvas.translate(MARGIN + 10f, ty) foot.draw(canvas) canvas.restore() - } + }) } private fun takeawayBlock(text: String): Block { @@ -1157,7 +1165,7 @@ object DebriefPdf { typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) isAntiAlias = true } - canvas.drawText("TAKEAWAY", MARGIN + 10f, y + 14f, k) + canvas.drawText(localized("debrief_pdf_takeaway", "TAKEAWAY"), MARGIN + 10f, y + 14f, k) canvas.save() canvas.translate(MARGIN + 10f, y + 20f) body.draw(canvas) @@ -1195,7 +1203,7 @@ object DebriefPdf { textSize = 8f isAntiAlias = true } - canvas.drawText("Off Grid Pete LLC · operationally sensitive", MARGIN, PAGE_H - 18f, f) + canvas.drawText(localized("debrief_pdf_off_grid_pete_llc_operationally_sensitive", "Off Grid Pete LLC · operationally sensitive"), MARGIN, PAGE_H - 18f, f) val pn = "$page / $total" canvas.drawText(pn, PAGE_W - MARGIN - f.measureText(pn), PAGE_H - 18f, f) } diff --git a/app/src/main/java/app/fieldwatch/data/LogStore.kt b/app/src/main/java/app/fieldwatch/data/LogStore.kt index 235bccf..c2b507b 100644 --- a/app/src/main/java/app/fieldwatch/data/LogStore.kt +++ b/app/src/main/java/app/fieldwatch/data/LogStore.kt @@ -1,5 +1,7 @@ package app.fieldwatch.data +import app.fieldwatch.i18n.localized + import android.content.Context import app.fieldwatch.domain.Fleet import app.fieldwatch.domain.LogExportRadios @@ -158,7 +160,7 @@ class LogStore(context: Context) { withContext(Dispatchers.IO) { flushWriter() val stream = resolver.openOutputStream(uri) - ?: error("Could not open the selected location") + ?: error(localized("log_selected_location_unavailable", "Could not open the selected location")) stream.buffered(64 * 1024).use { dest -> writeExport(dest, asJsonl, radios, onProgress) } diff --git a/app/src/main/java/app/fieldwatch/data/PlaceLookup.kt b/app/src/main/java/app/fieldwatch/data/PlaceLookup.kt index 03b5591..0526ef4 100644 --- a/app/src/main/java/app/fieldwatch/data/PlaceLookup.kt +++ b/app/src/main/java/app/fieldwatch/data/PlaceLookup.kt @@ -1,5 +1,7 @@ package app.fieldwatch.data +import app.fieldwatch.i18n.localized + import android.content.Context import android.location.Address import android.location.Geocoder @@ -40,7 +42,7 @@ object PlaceLookup { onProgress: ((String) -> Unit)? = null, ): DebriefPlaces = withContext(Dispatchers.IO) { runCatching { lookupInner(context, path, devices, now, onProgress) } - .getOrElse { DebriefPlaces(attempted = true, available = false, note = "Online lookup failed silently. Coordinates only.") } + .getOrElse { DebriefPlaces(attempted = true, available = false, note = localized("place_lookup_online_lookup_failed_silently_coordinates_only", "Online lookup failed silently. Coordinates only.")) } } private suspend fun lookupInner( @@ -54,14 +56,14 @@ object PlaceLookup { return DebriefPlaces( attempted = true, available = false, - note = "Online lookup skipped: no working internet. Coordinates only.", + note = localized("place_lookup_online_lookup_skipped_no_working_internet_coordinates", "Online lookup skipped: no working internet. Coordinates only."), ) } if (!Geocoder.isPresent()) { return DebriefPlaces( attempted = true, available = false, - note = "Online lookup skipped: this phone has no system geocoder (needs Google Play / network location). Coordinates only.", + note = localized("place_lookup_online_lookup_skipped_this_phone_has_no", "Online lookup skipped: this phone has no system geocoder (needs Google Play / network location). Coordinates only."), ) } val fixes = collect(path, devices, now) @@ -69,19 +71,19 @@ object PlaceLookup { return DebriefPlaces( attempted = true, available = true, - note = "Online lookup on, but this sit had no GPS stamps to name.", + note = localized("place_lookup_online_lookup_on_but_this_sit_had", "Online lookup on, but this sit had no GPS stamps to name."), ) } val geocoder = Geocoder(context, Locale.getDefault()) val cache = LinkedHashMap() val lines = ArrayList(fixes.size) for ((i, fix) in fixes.withIndex()) { - onProgress?.invoke("Looking up place names (${i + 1} of ${fixes.size})…") + onProgress?.invoke(localized("place_lookup_looking_up_place_names_of", "Looking up place names (%1\$s of %2\$s)…", i + 1, fixes.size)) val key = app.fieldwatch.domain.Geo.cellKey(fix.lat, fix.lon) val name = cache[key] ?: reverse(geocoder, fix.lat, fix.lon)?.also { cache[key] = it } val coord = "%.5f, %.5f".format(Locale.US, fix.lat, fix.lon) lines += if (name.isNullOrBlank()) { - " · ${fix.label} $coord (no name returned)" + localized("place_lookup_no_name_returned", " · %1\$s %2\$s (no name returned)", fix.label, coord) } else { " · ${fix.label} $name ($coord)" } @@ -91,9 +93,9 @@ object PlaceLookup { attempted = true, available = named > 0, note = if (named > 0) { - "Online lookup: system geocoder named $named distinct GPS cell(s). Street names are from the phone’s network geocoder, not a Fieldwatch cloud. Approximate." + localized("place_lookup_online_lookup_system_geocoder_named_distinct_gps", "Online lookup: system geocoder named %1\$s distinct GPS cell(s). Street names are from the phone’s network geocoder, not a Fieldwatch cloud. Approximate.", named) } else { - "Online lookup ran, but the system geocoder returned no street names. Coordinates only." + localized("place_lookup_online_lookup_ran_but_the_system_geocoder", "Online lookup ran, but the system geocoder returned no street names. Coordinates only.") }, lines = lines, namesByCell = cache.toMap(), @@ -114,15 +116,15 @@ object PlaceLookup { legs.forEach { leg -> if (leg.stay) { stayN++ - add("Stay $stayN", leg.lat, leg.lon) + add(localized("place_lookup_stay", "Stay %1\$s", stayN), leg.lat, leg.lon) } else { - add("Transit from", leg.lat, leg.lon) - add("Transit to", leg.endLat, leg.endLon) + add(localized("place_lookup_transit_from", "Transit from"), leg.lat, leg.lon) + add(localized("place_lookup_transit_to", "Transit to"), leg.endLat, leg.endLon) } } if (out.isEmpty() && path.isNotEmpty()) { - add("Path start", path.first().lat, path.first().lon) - if (path.size > 1) add("Path end", path.last().lat, path.last().lon) + add(localized("place_lookup_path_start", "Path start"), path.first().lat, path.first().lon) + if (path.size > 1) add(localized("place_lookup_path_end", "Path end"), path.last().lat, path.last().lon) } if (out.size < 8) { devices.filter { it.gpsTrail.isNotEmpty() && it.rssi >= -70 } diff --git a/app/src/main/java/app/fieldwatch/data/SitStore.kt b/app/src/main/java/app/fieldwatch/data/SitStore.kt index 1275fdc..69cdb0b 100644 --- a/app/src/main/java/app/fieldwatch/data/SitStore.kt +++ b/app/src/main/java/app/fieldwatch/data/SitStore.kt @@ -1,5 +1,7 @@ package app.fieldwatch.data +import app.fieldwatch.i18n.localized + import android.app.ActivityManager import android.content.Context import app.fieldwatch.domain.Fleet @@ -157,7 +159,7 @@ class SitStore( selectedId = file.summary.id pruneClosedLocked() } - val notice = dropped?.let { "Dropped oldest sit “$it” (keep ${Sit.CLOSED_CAP})." } + val notice = dropped?.let { localized("sit_dropped_oldest", "Dropped oldest sit “%1\$s” (keep %2\$s).", it, Sit.CLOSED_CAP) } publishLocked(notice) return file.summary } diff --git a/app/src/main/java/app/fieldwatch/domain/AdvPayloadDecoder.kt b/app/src/main/java/app/fieldwatch/domain/AdvPayloadDecoder.kt index ea83004..30c7cc7 100644 --- a/app/src/main/java/app/fieldwatch/domain/AdvPayloadDecoder.kt +++ b/app/src/main/java/app/fieldwatch/domain/AdvPayloadDecoder.kt @@ -1,5 +1,7 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.localized + /** * Decode well-known BLE advertisement payloads: Apple Continuity / iBeacon, * Google Fast Pair, Eddystone, Microsoft CDP. After company ID / UUID the rest @@ -21,7 +23,7 @@ object AdvPayloadDecoder { 0x004C -> decodeApple(bytes) 0x0006 -> decodeMicrosoft(bytes) 0x0157 -> decodeAltBeacon(bytes) - 0x00E0 -> listOf(Field("Google manufacturer data", "${bytes.size} bytes")) + 0x00E0 -> listOf(Field(localized("adv_payload_decoder_google_manufacturer_data", "Google manufacturer data"), localized("adv_payload_decoder_bytes", "%1\$s bytes", bytes.size))) else -> emptyList() } } @@ -53,43 +55,43 @@ object AdvPayloadDecoder { if (hex.startsWith(teslaPrefix) || hex.startsWith(teslaPrefix.drop(4))) { out += RoleHint( "vehicle", - "a Tesla vehicle or phone-as-key", - "Tesla phone-key iBeacon UUID (iOS background find).", + localized("adv_payload_decoder_a_tesla_vehicle_or_phone_as_key", "a Tesla vehicle or phone-as-key"), + localized("adv_payload_decoder_tesla_phone_key_ibeacon_uuid_ios_background", "Tesla phone-key iBeacon UUID (iOS background find)."), 8, ) } else if (hex.startsWith(targetPrefix) || hex.startsWith(targetPrefix.drop(4))) { out += RoleHint( "beacon", - "a Target Atrius basket tag", - "Target / Atrius iBeacon UUID (shopping-basket asset tag).", + localized("adv_payload_decoder_a_target_atrius_basket_tag", "a Target Atrius basket tag"), + localized("adv_payload_decoder_target_atrius_ibeacon_uuid_shopping_basket_asset", "Target / Atrius iBeacon UUID (shopping-basket asset tag)."), 8, ) } else { - out += RoleHint("beacon", "an iBeacon", "Apple iBeacon payload.", 7) + out += RoleHint("beacon", localized("adv_payload_decoder_an_ibeacon", "an iBeacon"), localized("adv_payload_decoder_apple_ibeacon_payload", "Apple iBeacon payload."), 7) } } - 0x05 -> out += RoleHint("phone", "an iPhone or iPad offering AirDrop", "Apple AirDrop advertisement.", 5) + 0x05 -> out += RoleHint("phone", localized("adv_payload_decoder_an_iphone_or_ipad_offering_airdrop", "an iPhone or iPad offering AirDrop"), localized("adv_payload_decoder_apple_airdrop_advertisement", "Apple AirDrop advertisement."), 5) 0x07 -> { val model = airPodsModel(tlv.data) out += RoleHint( "audio-personal", - model ?: "AirPods or Beats headphones", - if (model != null) "Apple Proximity Pairing: $model." - else "Apple Proximity Pairing (AirPods / Beats).", + model ?: localized("adv_payload_decoder_airpods_or_beats_headphones", "AirPods or Beats headphones"), + if (model != null) localized("adv_payload_decoder_apple_proximity_pairing", "Apple Proximity Pairing: %1\$s.", model) + else localized("adv_payload_decoder_apple_proximity_pairing_airpods_beats", "Apple Proximity Pairing (AirPods / Beats)."), 8, ) } - 0x08 -> out += RoleHint("siri", "an Apple device that just heard “Hey Siri”", "Hey Siri advertisement.", 6) - 0x09 -> out += RoleHint("audio-speaker", "an AirPlay speaker or Apple TV", "AirPlay advertisement.", 5) - 0x0B -> out += RoleHint("phone", "an Apple device doing Handoff", "Handoff advertisement.", 4) - 0x0C -> out += RoleHint("phone", "an Apple device looking for Instant Hotspot", "Tethering-target advertisement.", 5) - 0x0D, 0x0E -> out += RoleHint("hotspot", "an iPhone/iPad offering Instant Hotspot", "Tethering-source advertisement.", 6) - 0x0F -> out += RoleHint("phone", "an Apple device (Nearby Action)", nearbyActionReason(tlv.data), 4) - 0x10 -> out += RoleHint("phone", "an iPhone / iPad / Mac (Nearby Info)", nearbyInfoReason(tlv.data), 5) + 0x08 -> out += RoleHint("siri", localized("adv_payload_decoder_an_apple_device_that_just_heard_hey", "an Apple device that just heard “Hey Siri”"), localized("adv_payload_decoder_hey_siri_advertisement", "Hey Siri advertisement."), 6) + 0x09 -> out += RoleHint("audio-speaker", localized("adv_payload_decoder_an_airplay_speaker_or_apple_tv", "an AirPlay speaker or Apple TV"), localized("adv_payload_decoder_airplay_advertisement", "AirPlay advertisement."), 5) + 0x0B -> out += RoleHint("phone", localized("adv_payload_decoder_an_apple_device_doing_handoff", "an Apple device doing Handoff"), localized("adv_payload_decoder_handoff_advertisement", "Handoff advertisement."), 4) + 0x0C -> out += RoleHint("phone", localized("adv_payload_decoder_an_apple_device_looking_for_instant_hotspot", "an Apple device looking for Instant Hotspot"), localized("adv_payload_decoder_tethering_target_advertisement", "Tethering-target advertisement."), 5) + 0x0D, 0x0E -> out += RoleHint("hotspot", localized("adv_payload_decoder_an_iphone_ipad_offering_instant_hotspot", "an iPhone/iPad offering Instant Hotspot"), localized("adv_payload_decoder_tethering_source_advertisement", "Tethering-source advertisement."), 6) + 0x0F -> out += RoleHint("phone", localized("adv_payload_decoder_an_apple_device_nearby_action", "an Apple device (Nearby Action)"), nearbyActionReason(tlv.data), 4) + 0x10 -> out += RoleHint("phone", localized("adv_payload_decoder_an_iphone_ipad_mac_nearby_info", "an iPhone / iPad / Mac (Nearby Info)"), nearbyInfoReason(tlv.data), 5) 0x12 -> out += RoleHint( "tag", - "a Find My network radio", - "Apple Offline Finding — AirTag, Find My accessory, or an Apple device locating itself.", + localized("adv_payload_decoder_a_find_my_network_radio", "a Find My network radio"), + localized("adv_payload_decoder_apple_offline_finding_airtag_find_my_accessory", "Apple Offline Finding — AirTag, Find My accessory, or an Apple device locating itself."), 4, ) } @@ -104,16 +106,16 @@ object AdvPayloadDecoder { val name = FastPairModels.name(id) out += RoleHint( "audio-personal", - name ?: "a Fast Pair accessory (often earbuds or a speaker)", - if (name != null) "Google Fast Pair model $name (0x%06X), in pairing mode.".format(id) - else "Google Fast Pair model 0x%06X, in pairing mode.".format(id), + name ?: localized("adv_payload_decoder_a_fast_pair_accessory_often_earbuds_or", "a Fast Pair accessory (often earbuds or a speaker)"), + if (name != null) localized("adv_payload_decoder_google_fast_pair_model_0x_06x_in", "Google Fast Pair model %1\$s (0x%%06X), in pairing mode.", name).format(id) + else localized("adv_payload_decoder_google_fast_pair_model_0x_06x_in_2", "Google Fast Pair model 0x%06X, in pairing mode.").format(id), if (name != null) 8 else 6, ) } else { out += RoleHint( "audio-personal", - "a Fast Pair accessory already paired to someone", - "Google Fast Pair account-key broadcast (not in pairing mode).", + localized("adv_payload_decoder_a_fast_pair_accessory_already_paired_to", "a Fast Pair accessory already paired to someone"), + localized("adv_payload_decoder_google_fast_pair_account_key_broadcast_not", "Google Fast Pair account-key broadcast (not in pairing mode)."), 4, ) } @@ -123,12 +125,12 @@ object AdvPayloadDecoder { when (frame) { 0x40, 0x41 -> out += RoleHint( "tag", - "a Google Find Hub tag", - if (frame == 0x41) "Find Hub separated (unwanted-tracking) frame." - else "Find Hub nearby frame.", + localized("adv_payload_decoder_a_google_find_hub_tag", "a Google Find Hub tag"), + if (frame == 0x41) localized("adv_payload_decoder_find_hub_separated_unwanted_tracking_frame", "Find Hub separated (unwanted-tracking) frame.") + else localized("adv_payload_decoder_find_hub_nearby_frame", "Find Hub nearby frame."), 8, ) - else -> out += RoleHint("beacon", "an Eddystone beacon", "Eddystone service data.", 6) + else -> out += RoleHint("beacon", localized("adv_payload_decoder_an_eddystone_beacon", "an Eddystone beacon"), localized("adv_payload_decoder_eddystone_service_data", "Eddystone service data."), 6) } } } @@ -140,25 +142,25 @@ object AdvPayloadDecoder { private fun decodeApple(bytes: ByteArray): List { val tlvs = appleTlvs(bytes) - if (tlvs.isEmpty()) return listOf(Field("Apple payload", "${bytes.size} bytes (unparsed)")) + if (tlvs.isEmpty()) return listOf(Field(localized("adv_payload_decoder_apple_payload", "Apple payload"), localized("adv_payload_decoder_bytes_unparsed", "%1\$s bytes (unparsed)", bytes.size))) val out = ArrayList(8) for (tlv in tlvs) { - out += Field("Apple Continuity type", "0x%02X · %s".format(tlv.type, appleTypeName(tlv.type))) + out += Field(localized("adv_payload_decoder_apple_continuity_type", "Apple Continuity type"), "0x%02X · %s".format(tlv.type, appleTypeName(tlv.type))) out += when (tlv.type) { 0x02 -> decodeIBeacon(tlv.data) 0x05 -> decodeAirDrop(tlv.data) - 0x06 -> listOf(Field("HomeKit", "${tlv.data.size} bytes of HomeKit setup data")) + 0x06 -> listOf(Field("HomeKit", localized("adv_payload_decoder_bytes_of_homekit_setup_data", "%1\$s bytes of HomeKit setup data", tlv.data.size))) 0x07 -> decodeAirPods(tlv.data) 0x08 -> decodeHeySiri(tlv.data) - 0x09 -> listOf(Field("AirPlay", "This device is advertising as an AirPlay source or target.")) - 0x0A -> listOf(Field("Magic Switch", "Apple Watch wrist / unlock related.")) + 0x09 -> listOf(Field("AirPlay", localized("adv_payload_decoder_this_device_is_advertising_as_an_airplay", "This device is advertising as an AirPlay source or target."))) + 0x0A -> listOf(Field("Magic Switch", localized("adv_payload_decoder_apple_watch_wrist_unlock_related", "Apple Watch wrist / unlock related."))) 0x0B -> decodeHandoff(tlv.data) 0x0C -> decodeHandoffOrTetherTarget(tlv.data) 0x0D, 0x0E -> decodeTetherSource(tlv.data) 0x0F -> decodeNearbyAction(tlv.data) 0x10 -> decodeNearbyInfo(tlv.data) 0x12 -> decodeFindMy(tlv.data) - else -> listOf(Field("Payload", "${tlv.data.size} bytes")) + else -> listOf(Field(localized("adv_payload_decoder_payload", "Payload"), localized("adv_payload_decoder_bytes_2", "%1\$s bytes", tlv.data.size))) } } return out @@ -182,20 +184,20 @@ object AdvPayloadDecoder { 0x03 -> "AirPrint" 0x05 -> "AirDrop" 0x06 -> "HomeKit" - 0x07 -> "Proximity Pairing (AirPods / Beats)" + 0x07 -> localized("adv_payload_decoder_proximity_pairing_airpods_beats", "Proximity Pairing (AirPods / Beats)") 0x08 -> "Hey Siri" 0x09 -> "AirPlay" 0x0A -> "Magic Switch (Watch)" 0x0B -> "Handoff" - 0x0C -> "Handoff or Instant Hotspot (target)" - 0x0D -> "Instant Hotspot (source)" - 0x0E -> "Instant Hotspot (source)" + 0x0C -> localized("adv_payload_decoder_handoff_or_instant_hotspot_target", "Handoff or Instant Hotspot (target)") + 0x0D -> localized("adv_payload_decoder_instant_hotspot_source", "Instant Hotspot (source)") + 0x0E -> localized("adv_payload_decoder_instant_hotspot_source", "Instant Hotspot (source)") 0x0F -> "Nearby Action" 0x10 -> "Nearby Info" - 0x12 -> "Find My / Offline Finding" - 0x13 -> "Nearby Action (extended)" + 0x12 -> localized("adv_payload_decoder_find_my_offline_finding", "Find My / Offline Finding") + 0x13 -> localized("adv_payload_decoder_nearby_action_extended", "Nearby Action (extended)") 0x16 -> "Nearby Info" - else -> "unlisted" + else -> localized("adv_payload_decoder_unlisted", "unlisted") } private fun decodeIBeacon(data: ByteArray): List { @@ -203,7 +205,7 @@ object AdvPayloadDecoder { val body = when { data.size >= 22 && data[0] == 0x15.toByte() -> data.copyOfRange(1, 22) data.size >= 21 -> data.copyOfRange(0, 21) - else -> return listOf(Field("iBeacon", "truncated (${data.size} bytes)")) + else -> return listOf(Field("iBeacon", localized("adv_payload_decoder_truncated_bytes", "truncated (%1\$s bytes)", data.size))) } val uuid = uuidFromBe(body, 0) val major = u16be(body, 16) @@ -216,49 +218,49 @@ object AdvPayloadDecoder { return listOf( Field( "iBeacon UUID", - if (teslaKey) "$uuid — Tesla phone-as-key (iOS background find). Not a mall beacon." else uuid, + if (teslaKey) localized("adv_payload_decoder_tesla_phone_as_key_ios_background_find", "%1\$s — Tesla phone-as-key (iOS background find). Not a mall beacon.", uuid) else uuid, ), - Field("iBeacon major / minor", "$major / $minor"), - Field("iBeacon calibrated TX", "$tx dBm at 1 m (used to estimate range)"), + Field(localized("adv_payload_decoder_ibeacon_major_minor", "iBeacon major / minor"), "$major / $minor"), + Field(localized("adv_payload_decoder_ibeacon_calibrated_tx", "iBeacon calibrated TX"), localized("adv_payload_decoder_dbm_at_1_m_used_to_estimate", "%1\$s dBm at 1 m (used to estimate range)", tx)), ) } private fun decodeAirDrop(data: ByteArray): List { // 8 zeros, version, appleID hash(2), phone(2), email(2), email2(2), 0 - if (data.size < 18) return listOf(Field("AirDrop", "Someone nearby is offering AirDrop (${data.size} bytes).")) + if (data.size < 18) return listOf(Field("AirDrop", localized("adv_payload_decoder_someone_nearby_is_offering_airdrop_bytes", "Someone nearby is offering AirDrop (%1\$s bytes).", data.size))) return listOf( - Field("AirDrop", "Someone nearby has AirDrop receiving on. Hashes are truncated IDs, not names."), - Field("Apple ID hash (2 bytes)", data.copyOfRange(9, 11).toHexUpper()), + Field("AirDrop", localized("adv_payload_decoder_someone_nearby_has_airdrop_receiving_on_hashes", "Someone nearby has AirDrop receiving on. Hashes are truncated IDs, not names.")), + Field(localized("adv_payload_decoder_apple_id_hash_2_bytes", "Apple ID hash (2 bytes)"), data.copyOfRange(9, 11).toHexUpper()), ) } private fun decodeAirPods(data: ByteArray): List { // prefix 0x01, model u16be, status, batt nibble, charge+case, lid, color, 0x00, enc 16 - if (data.size < 5) return listOf(Field("AirPods", "Proximity Pairing, truncated.")) + if (data.size < 5) return listOf(Field("AirPods", localized("adv_payload_decoder_proximity_pairing_truncated", "Proximity Pairing, truncated."))) val start = if (data[0] == 0x01.toByte()) 1 else 0 - if (data.size < start + 4) return listOf(Field("AirPods", "Proximity Pairing.")) + if (data.size < start + 4) return listOf(Field("AirPods", localized("adv_payload_decoder_proximity_pairing", "Proximity Pairing."))) val model = ((data[start].toInt() and 0xFF) shl 8) or (data[start + 1].toInt() and 0xFF) val status = data[start + 2].toInt() and 0xFF val batt = data[start + 3].toInt() and 0xFF val left = batt and 0x0F val right = (batt shr 4) and 0x0F val out = ArrayList(6) - out += Field("Product", airPodsModelName(model) ?: "Apple audio 0x%04X".format(model)) - out += Field("Pod position", airPodsStatus(status)) - out += Field("Battery (left / right)", "${nibblePct(left)} / ${nibblePct(right)}") + out += Field(localized("adv_payload_decoder_product", "Product"), airPodsModelName(model) ?: localized("adv_payload_decoder_apple_audio_0x_04x", "Apple audio 0x%04X").format(model)) + out += Field(localized("adv_payload_decoder_pod_position", "Pod position"), airPodsStatus(status)) + out += Field(localized("adv_payload_decoder_battery_left_right", "Battery (left / right)"), "${nibblePct(left)} / ${nibblePct(right)}") if (data.size > start + 4) { val ch = data[start + 4].toInt() and 0xFF val caseBatt = ch and 0x0F val charging = buildList { - if (ch and 0x10 != 0) add("case") - if (ch and 0x20 != 0) add("right") - if (ch and 0x40 != 0) add("left") + if (ch and 0x10 != 0) add(localized("adv_payload_decoder_case", "case")) + if (ch and 0x20 != 0) add(localized("adv_payload_decoder_right", "right")) + if (ch and 0x40 != 0) add(localized("adv_payload_decoder_left", "left")) } - out += Field("Case battery", nibblePct(caseBatt)) - if (charging.isNotEmpty()) out += Field("Charging", charging.joinToString(", ")) + out += Field(localized("adv_payload_decoder_case_battery", "Case battery"), nibblePct(caseBatt)) + if (charging.isNotEmpty()) out += Field(localized("adv_payload_decoder_charging", "Charging"), charging.joinToString(", ")) } if (data.size > start + 6) { - out += Field("Color", airPodsColor(data[start + 6].toInt() and 0xFF)) + out += Field(localized("adv_payload_decoder_color", "Color"), airPodsColor(data[start + 6].toInt() and 0xFF)) } return out } @@ -272,13 +274,13 @@ object AdvPayloadDecoder { } private fun airPodsModelName(id: Int): String? = when (id) { - 0x0220 -> "AirPods (1st generation)" - 0x0F20 -> "AirPods (2nd generation)" - 0x1320 -> "AirPods (3rd generation)" - 0x1920 -> "AirPods (4th generation)" + 0x0220 -> localized("adv_payload_decoder_airpods_1st_generation", "AirPods (1st generation)") + 0x0F20 -> localized("adv_payload_decoder_airpods_2nd_generation", "AirPods (2nd generation)") + 0x1320 -> localized("adv_payload_decoder_airpods_3rd_generation", "AirPods (3rd generation)") + 0x1920 -> localized("adv_payload_decoder_airpods_4th_generation", "AirPods (4th generation)") 0x1C20 -> "AirPods 4" 0x0E20 -> "AirPods Pro" - 0x1420 -> "AirPods Pro (2nd generation)" + 0x1420 -> localized("adv_payload_decoder_airpods_pro_2nd_generation", "AirPods Pro (2nd generation)") 0x2420 -> "AirPods Pro 2 (USB-C)" 0x1F20 -> "AirPods Max" 0x0A20 -> "Beats Solo3" @@ -303,69 +305,69 @@ object AdvPayloadDecoder { } private fun airPodsStatus(status: Int): String = when (status) { - 0x01 -> "One or both out of the case" - 0x02 -> "Case open" - 0x03 -> "Taken out / in-ear transition" - 0x05 -> "One in ear" - 0x09 -> "Both out, not in ear" - 0x0B -> "In-ear activity" - 0x11, 0x13 -> "Both in ear" - 0x21 -> "One in ear (sharing?)" - 0x51 -> "Both in case, lid open" - 0x55 -> "Both in case, lid closed" - 0x75 -> "In case" - else -> "Status 0x%02X".format(status) + 0x01 -> localized("adv_payload_decoder_one_or_both_out_of_the_case", "One or both out of the case") + 0x02 -> localized("adv_payload_decoder_case_open", "Case open") + 0x03 -> localized("adv_payload_decoder_taken_out_in_ear_transition", "Taken out / in-ear transition") + 0x05 -> localized("adv_payload_decoder_one_in_ear", "One in ear") + 0x09 -> localized("adv_payload_decoder_both_out_not_in_ear", "Both out, not in ear") + 0x0B -> localized("adv_payload_decoder_in_ear_activity", "In-ear activity") + 0x11, 0x13 -> localized("adv_payload_decoder_both_in_ear", "Both in ear") + 0x21 -> localized("adv_payload_decoder_one_in_ear_sharing", "One in ear (sharing?)") + 0x51 -> localized("adv_payload_decoder_both_in_case_lid_open", "Both in case, lid open") + 0x55 -> localized("adv_payload_decoder_both_in_case_lid_closed", "Both in case, lid closed") + 0x75 -> localized("adv_payload_decoder_in_case", "In case") + else -> localized("adv_payload_decoder_status_0x_02x", "Status 0x%02X").format(status) } private fun airPodsColor(v: Int): String = when (v) { - 0x00 -> "White" - 0x01 -> "Black" - 0x02 -> "Red" - 0x03 -> "Blue" - 0x04 -> "Pink" - 0x05 -> "Gray" - 0x06 -> "Silver" - 0x07 -> "Gold" - 0x08 -> "Rose gold" - 0x09 -> "Space gray" - 0x0A -> "Dark blue" - 0x0B -> "Light blue" - 0x0C -> "Yellow" + 0x00 -> localized("adv_payload_decoder_white", "White") + 0x01 -> localized("adv_payload_decoder_black", "Black") + 0x02 -> localized("adv_payload_decoder_red", "Red") + 0x03 -> localized("adv_payload_decoder_blue", "Blue") + 0x04 -> localized("adv_payload_decoder_pink", "Pink") + 0x05 -> localized("adv_payload_decoder_gray", "Gray") + 0x06 -> localized("adv_payload_decoder_silver", "Silver") + 0x07 -> localized("adv_payload_decoder_gold", "Gold") + 0x08 -> localized("adv_payload_decoder_rose_gold", "Rose gold") + 0x09 -> localized("adv_payload_decoder_space_gray", "Space gray") + 0x0A -> localized("adv_payload_decoder_dark_blue", "Dark blue") + 0x0B -> localized("adv_payload_decoder_light_blue", "Light blue") + 0x0C -> localized("adv_payload_decoder_yellow", "Yellow") else -> "0x%02X".format(v) } private fun nibblePct(n: Int): String = when (n) { in 0..9 -> "${n * 10}%" 10, 11, 12, 13, 14 -> "100%" - 15 -> "unknown / not present" + 15 -> localized("adv_payload_decoder_unknown_not_present", "unknown / not present") else -> "$n" } private fun decodeHeySiri(data: ByteArray): List { - if (data.size < 6) return listOf(Field("Hey Siri", "Siri was just triggered on a nearby Apple device.")) + if (data.size < 6) return listOf(Field("Hey Siri", localized("adv_payload_decoder_siri_was_just_triggered_on_a_nearby", "Siri was just triggered on a nearby Apple device."))) val klass = u16be(data, 4) val device = when (klass) { 0x0002 -> "iPhone" 0x0003 -> "iPad" 0x0007 -> "HomePod" 0x0009 -> "Mac" - 0x000A -> "Watch" - else -> "class 0x%04X".format(klass) + 0x000A -> localized("adv_payload_decoder_watch", "Watch") + else -> localized("adv_payload_decoder_class_0x_04x", "class 0x%04X").format(klass) } return listOf( - Field("Hey Siri", "A $device just heard a Siri trigger. The packet carries a short voice hash, not the words."), + Field("Hey Siri", localized("adv_payload_decoder_a_just_heard_a_siri_trigger_the", "A %1\$s just heard a Siri trigger. The packet carries a short voice hash, not the words.", device)), ) } private fun decodeHandoff(data: ByteArray): List = - listOf(Field("Handoff", "Continuity Handoff: a task can be continued on another Apple device. Payload is encrypted.")) + listOf(Field("Handoff", localized("adv_payload_decoder_continuity_handoff_a_task_can_be_continued", "Continuity Handoff: a task can be continued on another Apple device. Payload is encrypted."))) private fun decodeHandoffOrTetherTarget(data: ByteArray): List = if (data.size >= 14) decodeHandoff(data) - else listOf(Field("Instant Hotspot (looking)", "This Apple device is searching for a paired phone’s hotspot.")) + else listOf(Field(localized("adv_payload_decoder_instant_hotspot_looking", "Instant Hotspot (looking)"), localized("adv_payload_decoder_this_apple_device_is_searching_for_a", "This Apple device is searching for a paired phone’s hotspot."))) private fun decodeTetherSource(data: ByteArray): List { - if (data.size < 6) return listOf(Field("Instant Hotspot", "An iPhone/iPad is offering a personal hotspot.")) + if (data.size < 6) return listOf(Field("Instant Hotspot", localized("adv_payload_decoder_an_iphone_ipad_is_offering_a_personal", "An iPhone/iPad is offering a personal hotspot."))) val batt = data[2].toInt() and 0xFF val cell = if (data.size >= 5) u16be(data, 3) else -1 val bars = if (data.size >= 6) data[5].toInt() and 0xFF else -1 @@ -377,16 +379,16 @@ object AdvPayloadDecoder { 4, 5 -> "3G" 7 -> "LTE" 8 -> "5G" - else -> if (cell >= 0) "type $cell" else null + else -> if (cell >= 0) localized("adv_unknown_type", "type %1\$s", cell) else null } return listOf( Field( - "Instant Hotspot (offering)", + localized("adv_payload_decoder_instant_hotspot_offering", "Instant Hotspot (offering)"), buildString { - append("Paired iPhone/iPad hotspot") - if (batt in 0..100) append(" · phone battery $batt%") + append(localized("adv_payload_decoder_paired_iphone_ipad_hotspot", "Paired iPhone/iPad hotspot")) + if (batt in 0..100) append(localized("adv_payload_decoder_phone_battery", " · phone battery %1\$s%%", batt)) cellName?.let { append(" · $it") } - if (bars in 0..5) append(" · $bars/5 bars") + if (bars in 0..5) append(localized("adv_payload_decoder_5_bars", " · %1\$s/5 bars", bars)) }, ), ) @@ -400,58 +402,58 @@ object AdvPayloadDecoder { } private fun nearbyActionReason(data: ByteArray): String { - val action = if (data.size >= 2) data[1].toInt() and 0xFF else return "Nearby Action advertisement." - return "Nearby Action: ${nearbyActionName(action)}." + val action = if (data.size >= 2) data[1].toInt() and 0xFF else return localized("adv_payload_decoder_nearby_action_advertisement", "Nearby Action advertisement.") + return localized("adv_payload_decoder_nearby_action", "Nearby Action: %1\$s.", nearbyActionName(action)) } private fun nearbyActionName(action: Int): String = when (action) { - 0x01 -> "Apple TV setup" - 0x04 -> "Mobile backup" - 0x05 -> "Watch setup" - 0x06 -> "Apple TV pair" - 0x08 -> "Wi-Fi password sharing (prompting nearby iPhones)" - 0x09 -> "iOS setup" - 0x0A -> "Repair" - 0x0B -> "Speaker setup" + 0x01 -> localized("adv_payload_decoder_apple_tv_setup", "Apple TV setup") + 0x04 -> localized("adv_payload_decoder_mobile_backup", "Mobile backup") + 0x05 -> localized("adv_payload_decoder_watch_setup", "Watch setup") + 0x06 -> localized("adv_payload_decoder_apple_tv_pair", "Apple TV pair") + 0x08 -> localized("adv_payload_decoder_wi_fi_password_sharing_prompting_nearby_iphones", "Wi-Fi password sharing (prompting nearby iPhones)") + 0x09 -> localized("adv_payload_decoder_ios_setup", "iOS setup") + 0x0A -> localized("adv_payload_decoder_repair", "Repair") + 0x0B -> localized("adv_payload_decoder_speaker_setup", "Speaker setup") 0x0C -> "Apple Pay" - 0x0D -> "Whole-home audio setup" - 0x0F -> "Answered a call" - 0x10 -> "Ended a call" - 0x13 -> "Remote AutoFill" - 0x14 -> "Companion Link proximity" - 0x17 -> "Remote display" - else -> "action 0x%02X".format(action) + 0x0D -> localized("adv_payload_decoder_whole_home_audio_setup", "Whole-home audio setup") + 0x0F -> localized("adv_payload_decoder_answered_a_call", "Answered a call") + 0x10 -> localized("adv_payload_decoder_ended_a_call", "Ended a call") + 0x13 -> localized("adv_payload_decoder_remote_autofill", "Remote AutoFill") + 0x14 -> localized("adv_payload_decoder_companion_link_proximity", "Companion Link proximity") + 0x17 -> localized("adv_payload_decoder_remote_display", "Remote display") + else -> localized("adv_payload_decoder_action_0x_02x", "action 0x%02X").format(action) } private fun decodeNearbyInfo(data: ByteArray): List { - if (data.isEmpty()) return listOf(Field("Nearby Info", "Apple device usage state.")) + if (data.isEmpty()) return listOf(Field("Nearby Info", localized("adv_payload_decoder_apple_device_usage_state", "Apple device usage state."))) val status = data[0].toInt() and 0xFF val action = status and 0x0F val flagsHi = (status shr 4) and 0x0F val dataFlags = if (data.size > 1) data[1].toInt() and 0xFF else 0 val activity = when (action) { - 0x00 -> "activity unknown" - 0x01 -> "activity reporting off" - 0x03 -> "idle (screen locked)" - 0x05 -> "audio playing, screen locked" - 0x07 -> "active (screen on)" - 0x09 -> "screen on, video playing" - 0x0A -> "Watch on wrist and unlocked" - 0x0B -> "recent interaction" - 0x0D -> "user is driving" - 0x0E -> "phone or FaceTime call" - else -> "activity 0x%X".format(action) + 0x00 -> localized("adv_payload_decoder_activity_unknown", "activity unknown") + 0x01 -> localized("adv_payload_decoder_activity_reporting_off", "activity reporting off") + 0x03 -> localized("adv_payload_decoder_idle_screen_locked", "idle (screen locked)") + 0x05 -> localized("adv_payload_decoder_audio_playing_screen_locked", "audio playing, screen locked") + 0x07 -> localized("adv_payload_decoder_active_screen_on", "active (screen on)") + 0x09 -> localized("adv_payload_decoder_screen_on_video_playing", "screen on, video playing") + 0x0A -> localized("adv_payload_decoder_watch_on_wrist_and_unlocked", "Watch on wrist and unlocked") + 0x0B -> localized("adv_payload_decoder_recent_interaction", "recent interaction") + 0x0D -> localized("adv_payload_decoder_user_is_driving", "user is driving") + 0x0E -> localized("adv_payload_decoder_phone_or_facetime_call", "phone or FaceTime call") + else -> localized("adv_payload_decoder_activity_0x_x", "activity 0x%X").format(action) } val extras = buildList { - if (flagsHi and 0x1 != 0) add("primary iCloud device") - if (flagsHi and 0x4 != 0) add("AirDrop receiving on") - if (dataFlags and 0x04 != 0) add("Wi-Fi on") - if (dataFlags and 0x01 != 0) add("AirPods connected") - if (dataFlags and 0x20 != 0) add("Watch locked") + if (flagsHi and 0x1 != 0) add(localized("adv_payload_decoder_primary_icloud_device", "primary iCloud device")) + if (flagsHi and 0x4 != 0) add(localized("adv_payload_decoder_airdrop_receiving_on", "AirDrop receiving on")) + if (dataFlags and 0x04 != 0) add(localized("adv_payload_decoder_wi_fi_on", "Wi-Fi on")) + if (dataFlags and 0x01 != 0) add(localized("adv_payload_decoder_airpods_connected", "AirPods connected")) + if (dataFlags and 0x20 != 0) add(localized("adv_payload_decoder_watch_locked", "Watch locked")) } return listOf( Field( - "What the Apple device is doing", + localized("adv_payload_decoder_what_the_apple_device_is_doing", "What the Apple device is doing"), buildString { append(activity.replaceFirstChar { it.uppercase() }) if (extras.isNotEmpty()) { @@ -465,40 +467,40 @@ object AdvPayloadDecoder { } private fun nearbyInfoReason(data: ByteArray): String { - if (data.isEmpty()) return "Nearby Info advertisement." + if (data.isEmpty()) return localized("adv_payload_decoder_nearby_info_advertisement", "Nearby Info advertisement.") val action = data[0].toInt() and 0x0F return when (action) { - 0x03 -> "Phone is idle / locked." - 0x05 -> "Audio playing with the screen locked." - 0x07 -> "Screen is on — someone is using it." - 0x0D -> "Device reports the user is driving." - 0x0E -> "In a phone or FaceTime call." - else -> "Nearby Info advertisement." + 0x03 -> localized("adv_payload_decoder_phone_is_idle_locked", "Phone is idle / locked.") + 0x05 -> localized("adv_payload_decoder_audio_playing_with_the_screen_locked", "Audio playing with the screen locked.") + 0x07 -> localized("adv_payload_decoder_screen_is_on_someone_is_using_it", "Screen is on — someone is using it.") + 0x0D -> localized("adv_payload_decoder_device_reports_the_user_is_driving", "Device reports the user is driving.") + 0x0E -> localized("adv_payload_decoder_in_a_phone_or_facetime_call", "In a phone or FaceTime call.") + else -> localized("adv_payload_decoder_nearby_info_advertisement", "Nearby Info advertisement.") } } private fun decodeFindMy(data: ByteArray): List { - if (data.isEmpty()) return listOf(Field("Find My", "Offline Finding advertisement.")) + if (data.isEmpty()) return listOf(Field("Find My", localized("adv_payload_decoder_offline_finding_advertisement", "Offline Finding advertisement."))) val status = data[0].toInt() and 0xFF val maintained = status and 0x04 != 0 val batt = (status shr 6) and 0x3 val battName = when (batt) { - 0 -> "full" - 1 -> "medium" - 2 -> "low" - else -> "critical" + 0 -> localized("adv_payload_decoder_full", "full") + 1 -> localized("adv_payload_decoder_medium", "medium") + 2 -> localized("adv_payload_decoder_low", "low") + else -> localized("adv_payload_decoder_critical", "critical") } val keyLen = (data.size - 1).coerceAtLeast(0) return listOf( Field( - "Find My / Offline Finding", + localized("adv_payload_decoder_find_my_offline_finding", "Find My / Offline Finding"), buildString { - append("Broadcasting a public key so the Find My network can report a location. ") - append("Used by AirTags, Find My accessories, and Apple devices locating themselves. ") - if (maintained) append("Owner seen recently. ") - else append("Owner not seen in the current key window. ") - if (maintained || batt in 0..3) append("Battery $battName. ") - append("($keyLen-byte key fragment — not a serial number.)") + append(localized("adv_payload_decoder_broadcasting_a_public_key_so_the_find", "Broadcasting a public key so the Find My network can report a location. ")) + append(localized("adv_payload_decoder_used_by_airtags_find_my_accessories_and", "Used by AirTags, Find My accessories, and Apple devices locating themselves. ")) + if (maintained) append(localized("adv_payload_decoder_owner_seen_recently", "Owner seen recently. ")) + else append(localized("adv_payload_decoder_owner_not_seen_in_the_current_key", "Owner not seen in the current key window. ")) + if (maintained || batt in 0..3) append(localized("adv_payload_decoder_battery", "Battery %1\$s. ", battName)) + append(localized("adv_payload_decoder_byte_key_fragment_not_a_serial_number", "(%1\$s-byte key fragment — not a serial number.)", keyLen)) }, ), ) @@ -509,10 +511,10 @@ object AdvPayloadDecoder { val id = modelId24(bytes) val name = FastPairModels.name(id) return listOf( - Field("Google Fast Pair", "In pairing mode — Android will pop a tap-to-pair card."), + Field("Google Fast Pair", localized("adv_payload_decoder_in_pairing_mode_android_will_pop_a", "In pairing mode — Android will pop a tap-to-pair card.")), Field( - "Model ID", - if (name != null) "$name (0x%06X)".format(id) else "0x%06X (not in the local name list)".format(id), + localized("adv_payload_decoder_model_id", "Model ID"), + if (name != null) "$name (0x%06X)".format(id) else localized("adv_payload_decoder_0x_06x_not_in_the_local_name", "0x%06X (not in the local name list)").format(id), ), ) } @@ -523,15 +525,15 @@ object AdvPayloadDecoder { val lt = bytes[1].toInt() and 0xFF val type = lt and 0x0F when (type) { - 0x0 -> "wants to show a pairing card" - 0x2 -> "hiding the pairing card (e.g. buds back in the case)" - else -> "filter type $type" + 0x0 -> localized("adv_payload_decoder_wants_to_show_a_pairing_card", "wants to show a pairing card") + 0x2 -> localized("adv_payload_decoder_hiding_the_pairing_card_e_g_buds", "hiding the pairing card (e.g. buds back in the case)") + else -> localized("adv_payload_decoder_filter_type", "filter type %1\$s", type) } - } else "account-key bloom filter" + } else localized("adv_payload_decoder_account_key_bloom_filter", "account-key bloom filter") return listOf( Field( "Google Fast Pair", - "Already paired to an account (not in pairing mode). $ui. Version $version.", + localized("adv_payload_decoder_already_paired_to_an_account_not_in", "Already paired to an account (not in pairing mode). %1\$s. Version %2\$s.", ui, version), ), ) } @@ -540,17 +542,17 @@ object AdvPayloadDecoder { if (bytes.isEmpty()) return emptyList() return when (bytes[0].toInt() and 0xFF) { 0x00 -> { - if (bytes.size < 18) listOf(Field("Eddystone-UID", "truncated")) + if (bytes.size < 18) listOf(Field("Eddystone-UID", localized("adv_payload_decoder_truncated", "truncated"))) else listOf( - Field("Eddystone-UID namespace", bytes.copyOfRange(2, 12).toHexUpper()), - Field("Eddystone-UID instance", bytes.copyOfRange(12, 18).toHexUpper()), + Field(localized("adv_payload_decoder_eddystone_uid_namespace", "Eddystone-UID namespace"), bytes.copyOfRange(2, 12).toHexUpper()), + Field(localized("adv_payload_decoder_eddystone_uid_instance", "Eddystone-UID instance"), bytes.copyOfRange(12, 18).toHexUpper()), ) } - 0x10 -> listOf(Field("Eddystone-URL", eddystoneUrl(bytes) ?: "${bytes.size} bytes")) - 0x20 -> listOf(Field("Eddystone-TLM", "telemetry (battery / temperature / advert count)")) - 0x30 -> listOf(Field("Eddystone-EID", "ephemeral ID (rotating)")) + 0x10 -> listOf(Field("Eddystone-URL", eddystoneUrl(bytes) ?: localized("adv_payload_decoder_bytes", "%1\$s bytes", bytes.size))) + 0x20 -> listOf(Field("Eddystone-TLM", localized("adv_payload_decoder_telemetry_battery_temperature_advert_count", "telemetry (battery / temperature / advert count)"))) + 0x30 -> listOf(Field("Eddystone-EID", localized("adv_payload_decoder_ephemeral_id_rotating", "ephemeral ID (rotating)"))) 0x40, 0x41 -> { - val mode = if (bytes[0].toInt() and 0xFF == 0x41) "separated (unwanted-tracking mode)" else "nearby / with owner" + val mode = if (bytes[0].toInt() and 0xFF == 0x41) localized("adv_payload_decoder_separated_unwanted_tracking_mode", "separated (unwanted-tracking mode)") else localized("adv_payload_decoder_nearby_with_owner", "nearby / with owner") val eidLen = when { bytes.size >= 33 -> 32 bytes.size >= 21 -> 20 @@ -559,10 +561,10 @@ object AdvPayloadDecoder { val eid = if (eidLen > 0) bytes.copyOfRange(1, 1 + eidLen).toHexUpper() else "" listOf( Field("Find Hub", mode), - Field("Find Hub EID", eid.ifBlank { "${bytes.size} bytes" }), + Field("Find Hub EID", eid.ifBlank { localized("adv_payload_decoder_bytes", "%1\$s bytes", bytes.size) }), ) } - else -> listOf(Field("Eddystone", "frame 0x%02X".format(bytes[0]))) + else -> listOf(Field("Eddystone", localized("adv_payload_decoder_frame_0x_02x", "frame 0x%02X").format(bytes[0]))) } } @@ -596,25 +598,25 @@ object AdvPayloadDecoder { 6 -> "iPhone" 7 -> "iPad" 8 -> "Android" - 9 -> "Windows desktop" - 11 -> "Windows phone" + 9 -> localized("adv_payload_decoder_windows_desktop", "Windows desktop") + 11 -> localized("adv_payload_decoder_windows_phone", "Windows phone") 12 -> "Linux" 13 -> "Windows IoT" 14 -> "Surface Hub" - 15 -> "Windows laptop" - 16 -> "Windows tablet" + 15 -> localized("adv_payload_decoder_windows_laptop", "Windows laptop") + 16 -> localized("adv_payload_decoder_windows_tablet", "Windows tablet") else -> "type $type" } - return listOf(Field("Microsoft Nearby Sharing / Swift Pair", "A $kind is advertising for quick pairing or sharing.")) + return listOf(Field("Microsoft Nearby Sharing / Swift Pair", localized("adv_payload_decoder_a_is_advertising_for_quick_pairing_or", "A %1\$s is advertising for quick pairing or sharing.", kind))) } - return listOf(Field("Microsoft manufacturer data", "${bytes.size} bytes")) + return listOf(Field(localized("adv_payload_decoder_microsoft_manufacturer_data", "Microsoft manufacturer data"), localized("adv_payload_decoder_bytes", "%1\$s bytes", bytes.size))) } private fun decodeAltBeacon(bytes: ByteArray): List { if (bytes.size >= 22 && bytes[0] == 0xBE.toByte() && bytes[1] == 0xAC.toByte()) { return listOf( Field("AltBeacon UUID", uuidFromBe(bytes, 2)), - Field("AltBeacon major / minor", "${u16be(bytes, 18)} / ${u16be(bytes, 20)}"), + Field(localized("adv_payload_decoder_altbeacon_major_minor", "AltBeacon major / minor"), "${u16be(bytes, 18)} / ${u16be(bytes, 20)}"), ) } return emptyList() diff --git a/app/src/main/java/app/fieldwatch/domain/AircraftTrail.kt b/app/src/main/java/app/fieldwatch/domain/AircraftTrail.kt index afcd26a..2bdd8be 100644 --- a/app/src/main/java/app/fieldwatch/domain/AircraftTrail.kt +++ b/app/src/main/java/app/fieldwatch/domain/AircraftTrail.kt @@ -1,5 +1,7 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.localized + import kotlinx.serialization.Serializable /** One advertised position stored on a sit. Not the phone's GPS. */ @@ -86,7 +88,7 @@ object AircraftTrail { val near = walkReady && fixes.isNotEmpty() && fixes.all { fix -> path.any { sample -> Geo.meters(fix.lat, fix.lon, sample.lat, sample.lon) <= NEAR_M } } - val titled = uasId.ifBlank { latest.title.ifBlank { "Advertised position" } } + val titled = uasId.ifBlank { latest.title.ifBlank { localized("aircraft_trail_advertised_position", "Advertised position") } } val aircraft = rows.filter { it.aircraft.isNotBlank() }.maxByOrNull { it.lastSeen }?.aircraft.orEmpty() return Picture( uasId = uasId, @@ -151,26 +153,26 @@ object AircraftTrail { appendLine("• $head") if (pic.aircraft.isNotBlank()) appendLine(" ${pic.aircraft}") val last = pic.fixes.last() - appendLine(" Last ${fmtCoord(last.lat, last.lon)}") + appendLine(localized("aircraft_trail_last", " Last %1\$s", fmtCoord(last.lat, last.lon))) val motion = listOfNotNull( pic.alt?.let { "${fmtNum(it)} m" }, - pic.heading?.let { "course ${fmtNum(it)}°" }, + pic.heading?.let { localized("aircraft_trail_course", "course %1\$s°", fmtNum(it)) }, pic.speed?.let { "${fmtNum(it)} m/s" }, ) if (motion.isNotEmpty()) appendLine(" ${motion.joinToString(" · ")}") val length = lengthM(pic.fixes) val count = pic.fixes.size - val shape = if (count == 1) "1 advertised fix" else "$count advertised fixes, ${fmtDist(length)}" + val shape = if (count == 1) localized("aircraft_trail_1_advertised_fix", "1 advertised fix") else localized("aircraft_trail_advertised_fixes", "%1\$s advertised fixes, %2\$s", count, fmtDist(length)) appendLine(" $shape") if (pic.pilotLat != null && pic.pilotLon != null) { - appendLine(" Pilot ${fmtCoord(pic.pilotLat, pic.pilotLon)}") + appendLine(localized("aircraft_trail_pilot", " Pilot %1\$s", fmtCoord(pic.pilotLat, pic.pilotLon))) } } val hidden = (pictures.count { it.ownFigure } - MAX_OWN_FIGURES).coerceAtLeast(0) if (hidden > 0) { - appendLine("$hidden more aircraft tracks are listed here and left off the map.") + appendLine(localized("aircraft_trail_more_aircraft_tracks_are_listed_here_and", "%1\$s more aircraft tracks are listed here and left off the map.", hidden)) } - append("These positions were broadcast by the radio. They are not this phone's GPS.") + append(localized("aircraft_trail_these_positions_were_broadcast_by_the_radio", "These positions were broadcast by the radio. They are not this phone's GPS.")) }.trimEnd() } @@ -190,12 +192,12 @@ object AircraftTrail { val aStatus = a?.status.orEmpty() val bStatus = b?.status.orEmpty() if (aStatus.isNotBlank() && bStatus.isNotBlank() && aStatus != bStatus) { - appendLine(" Status changed: $aStatus → $bStatus") + appendLine(localized("aircraft_trail_status_changed", " Status changed: %1\$s → %2\$s", aStatus, bStatus)) } } - looseLeft.forEach { appendLine("$leftName, no UAS id: ${sideBit(it)} (${it.title})") } - looseRight.forEach { appendLine("$rightName, no UAS id: ${sideBit(it)} (${it.title})") } - append("These positions were broadcast by the radio. They are not this phone's GPS.") + looseLeft.forEach { appendLine(localized("aircraft_trail_no_uas_id", "%1\$s, no UAS id: %2\$s (%3\$s)", leftName, sideBit(it), it.title)) } + looseRight.forEach { appendLine(localized("aircraft_trail_no_uas_id_2", "%1\$s, no UAS id: %2\$s (%3\$s)", rightName, sideBit(it), it.title)) } + append(localized("aircraft_trail_these_positions_were_broadcast_by_the_radio", "These positions were broadcast by the radio. They are not this phone's GPS.")) }.trimEnd() } @@ -209,7 +211,7 @@ object AircraftTrail { fun pilotMark(pic: Picture): SitPathPlot.Mark? { if (!pic.pilotOnMap || pic.pilotLat == null || pic.pilotLon == null) return null - return SitPathPlot.Mark(pic.pilotLat, pic.pilotLon, "Pilot") + return SitPathPlot.Mark(pic.pilotLat, pic.pilotLon, localized("aircraft_trail_pilot_2", "Pilot")) } fun applyWalk(base: SitPathPlot.Figure?, pictures: List, secondary: Boolean): SitPathPlot.Figure? { @@ -226,15 +228,15 @@ object AircraftTrail { craftKeys = base.craftKeys + near.map { pathKeyLine(it) }, spanM = Geo.spanM(framed), caption = if (secondary) { - if ("second sit’s advertised track" in base.caption) { + if (localized("aircraft_trail_second_sit_s_advertised_track", "second sit’s advertised track") in base.caption) { base.caption } else { - base.caption + " A blue dotted line is the second sit’s advertised track within 2 km of this path." + base.caption + localized("aircraft_trail_a_blue_dotted_line_is_the_second", " A blue dotted line is the second sit’s advertised track within 2 km of this path.") } - } else if ("black dotted line is an advertised track" in base.caption) { + } else if (localized("aircraft_trail_black_dotted_line_is_an_advertised_track", "black dotted line is an advertised track") in base.caption) { base.caption } else { - base.caption + " A black dotted line is an advertised track within 2 km of this path." + base.caption + localized("aircraft_trail_a_black_dotted_line_is_an_advertised", " A black dotted line is an advertised track within 2 km of this path.") }, ) } @@ -245,7 +247,7 @@ object AircraftTrail { private fun pictureFigure(pic: Picture, secondary: Boolean = false): SitPathPlot.Figure { val samples = samples(pic.fixes) return SitPathPlot.Figure( - kicker = "AIRCRAFT", + kicker = localized("aircraft_trail_aircraft", "AIRCRAFT"), tracks = listOf(track(pic, secondary)), dots = emptyList(), lengthM = lengthM(pic.fixes), @@ -265,16 +267,16 @@ object AircraftTrail { val lenA = a?.let { lengthM(it.fixes) } ?: 0.0 val lenB = b?.let { lengthM(it.fixes) } ?: 0.0 SitPathPlot.Figure( - kicker = "AIRCRAFT", + kicker = localized("aircraft_trail_aircraft", "AIRCRAFT"), tracks = tracks, dots = emptyList(), lengthM = maxOf(lenA, lenB), spanM = Geo.spanM(samples), caption = when { a != null && b != null -> - "North-up. Black dots are this sit. Blue dots are the second sit. The marker is the last advertised position." + localized("aircraft_trail_north_up_black_dots_are_this_sit", "North-up. Black dots are this sit. Blue dots are the second sit. The marker is the last advertised position.") b != null -> - "North-up. The blue dotted line is the advertised track for ${b.title}. The marker is the last advertised position." + localized("aircraft_trail_north_up_the_blue_dotted_line_is", "North-up. The blue dotted line is the advertised track for %1\$s. The marker is the last advertised position.", b.title) else -> aircraftCaption(a!!) }, pilots = listOfNotNull(a?.let { pilotMark(it) }, b?.let { pilotMark(it) }), @@ -331,7 +333,7 @@ object AircraftTrail { val tracks = fig.tracks.filter { it.aircraft }.ifEmpty { fig.tracks } val fixes = tracks.flatMap { it.samples } val frames = fixes + fig.pilots.map { GpsSample(0L, it.lat, it.lon, 0) } - val title = tracks.firstOrNull()?.name?.takeIf { it.isNotBlank() } ?: "Aircraft" + val title = tracks.firstOrNull()?.name?.takeIf { it.isNotBlank() } ?: localized("aircraft_trail_aircraft_2", "Aircraft") return SitPathPlot.Model( samples = emptyList(), dots = dots, @@ -360,9 +362,9 @@ object AircraftTrail { private fun aircraftCaption(pic: Picture): String { return if (pic.fixes.size < 2) { - "Last advertised position for ${pic.title}. The marker is that position." + localized("aircraft_trail_last_advertised_position_for_the_marker_is", "Last advertised position for %1\$s. The marker is that position.", pic.title) } else { - "North-up. The black dotted line is the advertised track for ${pic.title}. The marker is the last advertised position." + localized("aircraft_trail_north_up_the_black_dotted_line_is", "North-up. The black dotted line is the advertised track for %1\$s. The marker is the last advertised position.", pic.title) } } @@ -390,12 +392,12 @@ object AircraftTrail { if (state.isNotEmpty()) bits += state val id = uasId.trim() if (id.isNotEmpty() && !id.equals(label.trim(), ignoreCase = true)) bits += "UAS $id" - bits += "last ${fmtCoord(lat, lon)}" + bits += localized("aircraft_trail_last_2", "last %1\$s", fmtCoord(lat, lon)) alt?.let { bits += "${fmtNum(it)} m" } - heading?.let { bits += "course ${fmtNum(it)}°" } + heading?.let { bits += localized("aircraft_trail_course", "course %1\$s°", fmtNum(it)) } speed?.let { bits += "${fmtNum(it)} m/s" } if (PayloadLocation.validCoord(pilotLat, pilotLon)) { - bits += "pilot ${fmtCoord(pilotLat!!, pilotLon!!)}" + bits += localized("aircraft_trail_pilot_3", "pilot %1\$s", fmtCoord(pilotLat!!, pilotLon!!)) } return bits.joinToString(" · ") } @@ -408,7 +410,7 @@ object AircraftTrail { .filter { it.isNotEmpty() } .distinct() .joinToString(" ") - .ifBlank { "Advertised position" } + .ifBlank { localized("aircraft_trail_advertised_position", "Advertised position") } if (last == null) return head val note = advertisedNote( status = pic.status, @@ -430,8 +432,8 @@ object AircraftTrail { val craft = if (pic.aircraft.isBlank()) "" else "${pic.aircraft}, " val status = if (pic.status.isBlank()) "" else "${pic.status}, " val last = pic.fixes.lastOrNull() - val where = if (last == null) "" else " last ${fmtCoord(last.lat, last.lon)}" - val count = if (pic.fixes.size == 1) "1 fix" else "${pic.fixes.size} fixes" + val where = if (last == null) "" else localized("aircraft_trail_last_3", " last %1\$s", fmtCoord(last.lat, last.lon)) + val count = if (pic.fixes.size == 1) localized("aircraft_trail_1_fix", "1 fix") else localized("aircraft_trail_fixes", "%1\$s fixes", pic.fixes.size) return "$craft$status$count$where".trim() } diff --git a/app/src/main/java/app/fieldwatch/domain/DebriefPrompt.kt b/app/src/main/java/app/fieldwatch/domain/DebriefPrompt.kt index a77aec0..a25352f 100644 --- a/app/src/main/java/app/fieldwatch/domain/DebriefPrompt.kt +++ b/app/src/main/java/app/fieldwatch/domain/DebriefPrompt.kt @@ -1,5 +1,9 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.displayLabel + +import app.fieldwatch.i18n.localized + import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -71,62 +75,63 @@ object DebriefPrompt { val body = buildString { append(experimentalDisclaimerMarkdown()) appendLine() - appendLine("You are a field RF analyst for the operator who collected this sit. Fieldwatch is a stock-Android, receive-only Wi-Fi access-point + BLE-advertiser listener.") + appendLine(localized("ai_response_language", "Write your response in English. Preserve verbatim identifiers and observer notes.")) + appendLine(localized("debrief_prompt_you_are_a_field_rf_analyst_for", "You are a field RF analyst for the operator who collected this sit. Fieldwatch is a stock-Android, receive-only Wi-Fi access-point + BLE-advertiser listener.")) appendLine() - appendLine("The **onboard Debrief** (verbatim below) already tabulated the sit: counts, Where you were, tracking callouts, inventories, Extra attention, takeaway. **Do not rewrite that report. Do not reprint inventories or stay lists.** Your job is an addendum the phone cannot write: rates, competing hypotheses, and a stress-test of the onboard tracking callouts.") + appendLine(localized("debrief_prompt_the_onboard_debrief_verbatim_below_already_tabulated", "The **onboard Debrief** (verbatim below) already tabulated the sit: counts, Where you were, tracking callouts, inventories, Extra attention, takeaway. **Do not rewrite that report. Do not reprint inventories or stay lists.** Your job is an addendum the phone cannot write: rates, competing hypotheses, and a stress-test of the onboard tracking callouts.")) appendLine() - appendLine("Constraints you must respect:") - appendLine("- Hear-only. Wi-Fi rows are access points only. BLE rows are advertisers. Kind + MAC. BLE rotation is a new row and will not stitch.") - appendLine("- Signature / OUI / company matches are hypotheses, not identity, not a person or vehicle.") - appendLine("- GPS stamps (if present) are this phone at hear-time, not the other radio. Do not place a camera or tag at the GPS pin.") - appendLine("- Place names (if present) are system reverse-geocode of those stamps.") - appendLine("- RSSI is loudness at the phone, not meters.") - appendLine("- Live RAM cap is about 400 radios; unnamed BLE evicts after ~3 min. A named sit keeps more. This is not a complete capture.") - appendLine("- Do not claim a tracker is following unless the onboard GPS co-travel section supports it. A radio with you the whole sit is not automatically yours — it may be planted. Do not dismiss it. Do not invent a tail the onboard test did not flag. Do not treat a retail beacon as a Find My tail.") - appendLine("- A radio marked mine was claimed by the operator. Do not treat it as an unexplained follower.") - appendLine("- A flood note is a burst of new addresses, not a follower.") - appendLine("- A decoded live value on a tracking row is catalog text for that advertisement. Quote the catalog sentence when the onboard report includes one. Do not stitch that value onto a different MAC.") - appendLine("- An aircraft block and an amber track are positions the radio advertised. Trails with the same UAS id are one aircraft. They are not this phone's GPS and they are not a finding that the aircraft followed the operator.") - appendLine("- Do not give safety advice. Do not tell the operator they are safe or in danger.") - appendLine("- Treat this paste as operationally sensitive.") + appendLine(localized("debrief_prompt_constraints_you_must_respect", "Constraints you must respect:")) + appendLine(localized("debrief_prompt_hear_only_wi_fi_rows_are_access", "- Hear-only. Wi-Fi rows are access points only. BLE rows are advertisers. Kind + MAC. BLE rotation is a new row and will not stitch.")) + appendLine(localized("debrief_prompt_signature_oui_company_matches_are_hypotheses_not", "- Signature / OUI / company matches are hypotheses, not identity, not a person or vehicle.")) + appendLine(localized("debrief_prompt_gps_stamps_if_present_are_this_phone", "- GPS stamps (if present) are this phone at hear-time, not the other radio. Do not place a camera or tag at the GPS pin.")) + appendLine(localized("debrief_prompt_place_names_if_present_are_system_reverse", "- Place names (if present) are system reverse-geocode of those stamps.")) + appendLine(localized("debrief_prompt_rssi_is_loudness_at_the_phone_not", "- RSSI is loudness at the phone, not meters.")) + appendLine(localized("debrief_prompt_live_ram_cap_is_about_400_radios", "- Live RAM cap is about 400 radios; unnamed BLE evicts after ~3 min. A named sit keeps more. This is not a complete capture.")) + appendLine(localized("debrief_prompt_do_not_claim_a_tracker_is_following", "- Do not claim a tracker is following unless the onboard GPS co-travel section supports it. A radio with you the whole sit is not automatically yours — it may be planted. Do not dismiss it. Do not invent a tail the onboard test did not flag. Do not treat a retail beacon as a Find My tail.")) + appendLine(localized("debrief_prompt_a_radio_marked_mine_was_claimed_by", "- A radio marked mine was claimed by the operator. Do not treat it as an unexplained follower.")) + appendLine(localized("debrief_prompt_a_flood_note_is_a_burst_of", "- A flood note is a burst of new addresses, not a follower.")) + appendLine(localized("debrief_prompt_a_decoded_live_value_on_a_tracking", "- A decoded live value on a tracking row is catalog text for that advertisement. Quote the catalog sentence when the onboard report includes one. Do not stitch that value onto a different MAC.")) + appendLine(localized("debrief_prompt_an_aircraft_block_and_an_amber_track", "- An aircraft block and an amber track are positions the radio advertised. Trails with the same UAS id are one aircraft. They are not this phone's GPS and they are not a finding that the aircraft followed the operator.")) + appendLine(localized("debrief_prompt_do_not_give_safety_advice_do_not", "- Do not give safety advice. Do not tell the operator they are safe or in danger.")) + appendLine(localized("debrief_prompt_treat_this_paste_as_operationally_sensitive", "- Treat this paste as operationally sensitive.")) appendLine() - appendLine("## Your output (required — this is the addendum the operator reads)") - appendLine("Write complete sentences. Headings as below. Short bullets only for Extra attention and tracking rows from the working table. No markdown tables. No code fences. No dump of the onboard inventories.") + appendLine(localized("debrief_prompt_your_output_required_this_is_the_addendum", "## Your output (required — this is the addendum the operator reads)")) + appendLine(localized("debrief_prompt_write_complete_sentences_headings_as_below_short", "Write complete sentences. Headings as below. Short bullets only for Extra attention and tracking rows from the working table. No markdown tables. No code fences. No dump of the onboard inventories.")) appendLine() - appendLine("1. **Disclaimer** — Repeat the experimental-use disclaimer first.") - appendLine("2. **What the onboard Debrief already established** — 3–5 sentences. Counts, distance, tracking callouts, Extra attention hits, Observer notes if any, Marked mine if any, Flood if any. Do not reprint inventories.") - appendLine("3. **What the numbers add** — 5- vs 15-minute counts, RSSI bands, RAND BLE percent, arrivals per minute, persistent vs gone, signature-family mix. Say street vs dwelling vs retail vs vehicle, and 5-minute vs 15-minute change (denser, quieter, stable). Confidence. If GPS ran, path length/span from the working table — do not pin a radio to a stay.") - appendLine("4. **Extra attention and tracking callouts** — Full identifiers from the working table (complete MAC, name, RSSI min/max, signatures, dwell). Stress-test onboard Possible trackers with you / Possible tail / Retail beacons / Wearables. Agree, qualify, or say the data are too thin. Pattern match, not identity. If none, say none.") - appendLine("5. **What another sit or Hunt would shrink** — Concrete in-app next steps only (Hunt on one Extra attention row, a longer GPS path, Compare sits, Filters). No safety advice. No “call the police.”") + appendLine(localized("debrief_prompt_1_disclaimer_repeat_the_experimental_use_disclaimer", "1. **Disclaimer** — Repeat the experimental-use disclaimer first.")) + appendLine(localized("debrief_prompt_2_what_the_onboard_debrief_already_established", "2. **What the onboard Debrief already established** — 3–5 sentences. Counts, distance, tracking callouts, Extra attention hits, Observer notes if any, Marked mine if any, Flood if any. Do not reprint inventories.")) + appendLine(localized("debrief_prompt_3_what_the_numbers_add_5_vs", "3. **What the numbers add** — 5- vs 15-minute counts, RSSI bands, RAND BLE percent, arrivals per minute, persistent vs gone, signature-family mix. Say street vs dwelling vs retail vs vehicle, and 5-minute vs 15-minute change (denser, quieter, stable). Confidence. If GPS ran, path length/span from the working table — do not pin a radio to a stay.")) + appendLine(localized("debrief_prompt_4_extra_attention_and_tracking_callouts_full", "4. **Extra attention and tracking callouts** — Full identifiers from the working table (complete MAC, name, RSSI min/max, signatures, dwell). Stress-test onboard Possible trackers with you / Possible tail / Retail beacons / Wearables. Agree, qualify, or say the data are too thin. Pattern match, not identity. If none, say none.")) + appendLine(localized("debrief_prompt_5_what_another_sit_or_hunt_would", "5. **What another sit or Hunt would shrink** — Concrete in-app next steps only (Hunt on one Extra attention row, a longer GPS path, Compare sits, Filters). No safety advice. No “call the police.”")) appendLine() - appendLine("**Takeaway (required, last line).** One sentence starting with `Takeaway:` that adds *one number the onboard takeaway does not already say* (a rate, RAND percent, 5- vs 15-minute change, path span). Not a moral. Not a threat level.") + appendLine(localized("debrief_prompt_takeaway_required_last_line_one_sentence_starting", "**Takeaway (required, last line).** One sentence starting with `Takeaway:` that adds *one number the onboard takeaway does not already say* (a rate, RAND percent, 5- vs 15-minute change, path span). Not a moral. Not a threat level.")) appendLine() - appendLine("## Collection context") - appendLine("- Tool: Fieldwatch (app.fieldwatch), receive-only, no association / injection / cloud.") + appendLine(localized("debrief_prompt_collection_context", "## Collection context")) + appendLine(localized("debrief_prompt_tool_fieldwatch_app_fieldwatch_receive_only_no", "- Tool: Fieldwatch (app.fieldwatch), receive-only, no association / injection / cloud.")) appendLine( if (win.sitName != null) { - "- Window: sit **${win.sitName}** ($start → $iso UTC), with a 5-minute recent slice." + localized("debrief_prompt_window_sit_utc_with_a_5_minute", "- Window: sit **%1\$s** (%2\$s → %3\$s UTC), with a 5-minute recent slice.", win.sitName, start, iso) } else { - "- Window: last **15 minutes** ($start → $iso UTC), with a **5-minute** recent slice." + localized("debrief_prompt_window_last_15_minutes_utc_with_a", "- Window: last **15 minutes** (%1\$s → %2\$s UTC), with a **5-minute** recent slice.", start, iso) }, ) - appendLine("- Scan intensity: ${settings.intensity.name.lowercase()}. Stale after ${settings.staleSec}s.") - appendLine("- Location tags: ${if (settings.tagLocation) "on" else "off"}. Online place names: ${if (settings.onlineLookup) "on" else "off"}.") + appendLine(localized("debrief_prompt_scan_intensity_stale_after_s", "- Scan intensity: %1\$s. Stale after %2\$ss.", settings.intensity.displayLabel(), settings.staleSec)) + appendLine(localized("debrief_prompt_location_tags_online_place_names", "- Location tags: %1\$s. Online place names: %2\$s.", if (settings.tagLocation) localized("debrief_prompt_on", "on") else localized("debrief_prompt_off", "off"), if (settings.onlineLookup) localized("debrief_prompt_on", "on") else localized("debrief_prompt_off", "off"))) appendLine() - appendLine("## Onboard Debrief (verbatim — already shown to the operator; do not rewrite)") + appendLine(localized("debrief_prompt_onboard_debrief_verbatim_already_shown_to_the", "## Onboard Debrief (verbatim — already shown to the operator; do not rewrite)")) appendLine() appendLine(onboard.trimEnd()) appendLine() - appendLine("## Working data (for the addendum — do not copy rosters into the answer)") + appendLine(localized("debrief_prompt_working_data_for_the_addendum_do_not", "## Working data (for the addendum — do not copy rosters into the answer)")) appendLine() appendLine( - "15 min: Wi-Fi ${wifi.size} BLE ${ble.size} signed ${signed.size} hidden SSIDs ${wifi.count { it.hiddenSsid }} " + - "RAND BLE $randomized/${ble.size} (${pct(randomized, ble.size)}%) " + - "first-seen ${arrived.size} (${perMin(arrived.size)}/min) persistent ${persistent.size} gone/quiet ${departed.size}", + localized("debrief_prompt_15_min_wi_fi_ble_signed_hidden", "15 min: Wi-Fi %1\$s BLE %2\$s signed %3\$s hidden SSIDs %4\$s ", wifi.size, ble.size, signed.size, wifi.count { it.hiddenSsid }) + + localized("debrief_prompt_rand_ble", "RAND BLE %1\$s/%2\$s (%3\$s%%) ", randomized, ble.size, pct(randomized, ble.size)) + + localized("debrief_prompt_first_seen_min_persistent_gone_quiet", "first-seen %1\$s (%2\$s/min) persistent %3\$s gone/quiet %4\$s", arrived.size, perMin(arrived.size), persistent.size, departed.size), ) appendLine( - "5 min: Wi-Fi ${in5.count { it.kind == RadioKind.WIFI }} BLE ${in5.count { it.kind == RadioKind.BLE }} " + - "signed ${in5.count { it.fleetIds.isNotEmpty() }} first-seen ${in5.count { it.firstSeen >= shortStart }}", + localized("debrief_prompt_5_min_wi_fi_ble", "5 min: Wi-Fi %1\$s BLE %2\$s ", in5.count { it.kind == RadioKind.WIFI }, in5.count { it.kind == RadioKind.BLE }) + + localized("debrief_prompt_signed_first_seen", "signed %1\$s first-seen %2\$s", in5.count { it.fleetIds.isNotEmpty() }, in5.count { it.firstSeen >= shortStart }), ) appendLine( "BLE RSSI (n=${ble.size}): ≥−50 ${bandGe(bleRssi, -50)} −51..−70 ${band(bleRssi, -70, -51)} " + @@ -137,35 +142,35 @@ object DebriefPrompt { "−71..−85 ${band(wifiRssi, -85, -71)} <−85 ${bandLt(wifiRssi, -85)}", ) if (sigFamilies.isEmpty()) { - appendLine("Signature families: none.") + appendLine(localized("debrief_prompt_signature_families_none", "Signature families: none.")) } else { - appendLine("Signature families (count): " + sigFamilies.take(12).joinToString { "${it.first}=${it.second}" }) + appendLine(localized("debrief_prompt_signature_families_count", "Signature families (count): ") + sigFamilies.take(12).joinToString { "${it.first}=${it.second}" }) } appendLine( - "GPS path: tagging ${if (settings.tagLocation) "on" else "off"} " + - "fixes ${path.size} length ${pathLen.toInt()} m span ${pathSpan.toInt()} m " + - "places ${if (places.attempted) places.note else "off"}", + localized("debrief_prompt_gps_path_tagging", "GPS path: tagging %1\$s ", if (settings.tagLocation) localized("debrief_prompt_on", "on") else localized("debrief_prompt_off", "off")) + + localized("debrief_prompt_fixes_length_m_span_m", "fixes %1\$s length %2\$s m span %3\$s m ", path.size, pathLen.toInt(), pathSpan.toInt()) + + localized("debrief_prompt_places", "places %1\$s", if (places.attempted) places.note else localized("debrief_prompt_off", "off")), ) appendLine() - appendLine("Extra attention:") + appendLine(localized("debrief_prompt_extra_attention", "Extra attention:")) if (extraHits.isEmpty()) { - appendLine("- None.") + appendLine(localized("debrief_prompt_none", "- None.")) } else { extraHits.forEach { (d, sig, note) -> append("- ").append(row(d, names, now, windowStart, customNames, observerNotes)) - if (d.key in mineKeys) append(" Marked mine") + if (d.key in mineKeys) append(localized("debrief_prompt_marked_mine", " Marked mine")) append(" | ").append(sig).append(": ").append(note) appendLine() } } appendLine() - appendLine("Observer notes:") + appendLine(localized("debrief_prompt_observer_notes", "Observer notes:")) val observed = in15.mapNotNull { d -> val note = observerNotes[d.key]?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null d to note } if (observed.isEmpty()) { - appendLine("- None.") + appendLine(localized("debrief_prompt_none", "- None.")) } else { observed.sortedByDescending { it.first.rssi }.forEach { (d, note) -> append("- ").append(row(d, names, now, windowStart, customNames, emptyMap())) @@ -174,9 +179,9 @@ object DebriefPrompt { } } appendLine() - appendLine("Finder-tag-like radios (for stress-test of onboard tracking; not a tail list):") + appendLine(localized("debrief_prompt_finder_tag_like_radios_for_stress_test", "Finder-tag-like radios (for stress-test of onboard tracking; not a tail list):")) if (finders.isEmpty()) { - appendLine("- None.") + appendLine(localized("debrief_prompt_none", "- None.")) } else { finders.sortedByDescending { it.rssi }.take(20).forEach { d -> append("- ").append(row(d, names, now, windowStart, customNames, observerNotes)) @@ -185,11 +190,11 @@ object DebriefPrompt { } } appendLine() - appendLine("## End of working data") - appendLine("Write the addendum now, following **Your output** at the top. Do not rewrite the onboard Debrief.") + appendLine(localized("debrief_prompt_end_of_working_data", "## End of working data")) + appendLine(localized("debrief_prompt_write_the_addendum_now_following_your_output", "Write the addendum now, following **Your output** at the top. Do not rewrite the onboard Debrief.")) } return if (body.length <= MAX_CHARS) body - else body.take(MAX_CHARS) + "\n\n[truncated for share-sheet size]\n" + else body.take(MAX_CHARS) + localized("debrief_prompt_n_n_truncated_for_share_sheet_size", "\n\n[truncated for share-sheet size]\n") } fun experimentalDisclaimerMarkdown(): String = FieldwatchDisclaimer.experimentalMarkdown() @@ -208,7 +213,7 @@ object DebriefPrompt { if (label.isNotEmpty() && !label.equals(d.mac, ignoreCase = true)) { append(" ").append(label.take(32)) } - observerNotes[d.key]?.let { append(" Observer: ").append(it.take(80)) } + observerNotes[d.key]?.let { append(localized("debrief_prompt_observer", " Observer: ")).append(it.take(80)) } append(" rssi=").append(d.rssi).append("dBm") if (d.randomized) append(" RAND") if (d.fleetIds.isNotEmpty()) { diff --git a/app/src/main/java/app/fieldwatch/domain/DebriefReport.kt b/app/src/main/java/app/fieldwatch/domain/DebriefReport.kt index 3f3bf21..02bc8e7 100644 --- a/app/src/main/java/app/fieldwatch/domain/DebriefReport.kt +++ b/app/src/main/java/app/fieldwatch/domain/DebriefReport.kt @@ -1,5 +1,17 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.displayLiveDecode + +import app.fieldwatch.i18n.displayName + +import app.fieldwatch.i18n.forDisplay + +import app.fieldwatch.i18n.displayAttentionNotes + +import app.fieldwatch.i18n.displayLabel + +import app.fieldwatch.i18n.localized + import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -34,6 +46,8 @@ data class ReportChart( }.trimEnd() } +enum class ReportSectionKind { TEXT, EXTRA_ATTENTION } + data class DebriefSection( val number: String, val title: String, @@ -41,6 +55,7 @@ data class DebriefSection( val alert: Boolean = false, val chart: ReportChart? = null, val after: String = "", + val kind: ReportSectionKind = ReportSectionKind.TEXT, ) data class DebriefPlaces( @@ -70,14 +85,14 @@ data class DebriefPlaces( } fun areaLine(): String { - if (!attempted) return "off" + if (!attempted) return localized("debrief_report_off", "off") val named = namesByCell.values.map { it.trim() }.filter { it.isNotEmpty() }.distinct() if (named.isEmpty()) return note return named.joinToString(" · ") } companion object { - val Off = DebriefPlaces(false, false, "off") + val Off get() = DebriefPlaces(false, false, localized("debrief_report_off", "off")) } } @@ -96,16 +111,16 @@ data class DebriefDoc( val takeaway: String, val sections: List, val extraAttention: List = emptyList(), - val heading: String = "FIELDWATCH FIELD DEBRIEF", - val pdfKicker: String = "FIELD DEBRIEF", - val pdfTitle: String = "Field debrief", + val heading: String = localized("debrief_report_fieldwatch_field_debrief", "FIELDWATCH FIELD DEBRIEF"), + val pdfKicker: String = localized("debrief_report_field_debrief", "FIELD DEBRIEF"), + val pdfTitle: String = localized("debrief_report_field_debrief_2", "Field debrief"), val pathFigure: SitPathPlot.Figure? = null, val extraFigures: List = emptyList(), ) { fun toPlainText(): String = buildString { appendLine(heading) appendLine() - appendLine("DISCLAIMER") + appendLine(localized("debrief_report_disclaimer", "DISCLAIMER")) appendLine(disclaimer) appendLine() meta.forEach { (k, v) -> appendLine("${k.padEnd(14)}$v") } @@ -120,15 +135,15 @@ data class DebriefDoc( appendLine() } appendLine("—") - appendLine("Takeaway: $takeaway") + appendLine(localized("debrief_report_takeaway", "Takeaway: %1\$s", takeaway)) } fun withDemoMacs(macs: Collection, demo: Boolean): DebriefDoc { if (!demo) return this fun t(s: String) = Geo.redactCoordsIn(MacUtil.redactMacsIn(s, macs, true), true) - val note = "MAC tails (**:**:**) and GPS coordinates masked. Logs on the phone are unchanged." + val note = localized("debrief_report_mac_tails_and_gps_coordinates_masked_logs", "MAC tails (**:**:**) and GPS coordinates masked. Logs on the phone are unchanged.") return copy( - meta = listOf("Privacy" to note) + meta.map { it.first to t(it.second) }, + meta = listOf(localized("debrief_report_privacy", "Privacy") to note) + meta.map { it.first to t(it.second) }, disclaimer = t(disclaimer), takeaway = t(takeaway), sections = sections.map { @@ -206,7 +221,9 @@ object DebriefReport { mineKeys: Set = emptySet(), floods: List = emptyList(), ): DebriefDoc { + val devices = devices.map { it.copy(liveDecode = it.displayLiveDecode(fleets)) } val names = fleets.associate { it.id to it.name } + val displayNames = fleets.associate { it.id to it.displayName() } val win = window ?: DebriefWindow(now - WINDOW_MS, now) val windowStart = win.startAt val windowEnd = win.endAt @@ -253,30 +270,30 @@ object DebriefReport { } else { ReportChart( rows = byCh.map { (ch, list) -> - val label = if (ch == 0) "unknown" else "ch $ch" - ReportBar(label, list.size, detail = "strongest ${list.maxOf { it.rssi }} dBm") + val label = if (ch == 0) localized("debrief_report_unknown", "unknown") else localized("debrief_report_ch", "ch %1\$s", ch) + ReportBar(label, list.size, detail = localized("debrief_report_strongest_dbm", "strongest %1\$s dBm", list.maxOf { it.rssi })) }, ) } - val networks = "Heard ${wifi.size} AP(s); ${hidden.size} hidden SSID; ${persistent.count { it.kind == RadioKind.WIFI }} sat most of the window." + val networks = localized("debrief_report_heard_ap_s_hidden_ssid_sat_most", "Heard %1\$s AP(s); %2\$s hidden SSID; %3\$s sat most of the window.", wifi.size, hidden.size, persistent.count { it.kind == RadioKind.WIFI }) val networksAfter = if (!showAll) { wifiLines( wifi.filter { it.listedWhenShort() && it.fleetIds.isEmpty() }, - names, windowStart, now, customNames, fleets, mineKeys, + displayNames, windowStart, now, customNames, fleets, mineKeys, ) } else { buildString { - appendLine("Loudest APs:") + appendLine(localized("debrief_report_loudest_aps", "Loudest APs:")) wifi.take(12).forEach { d -> - appendLine(" · ${wifiLine(d, names, windowStart, now, customNames)}") - if (d.key in mineKeys) appendLine(" Marked mine") - d.attentionNotes(fleets).forEach { (sig, note) -> - appendLine(" extra attention ($sig): $note") + appendLine(" · ${wifiLine(d, displayNames, windowStart, now, customNames)}") + if (d.key in mineKeys) appendLine(localized("debrief_report_marked_mine_2", " Marked mine")) + d.displayAttentionNotes(fleets).forEach { (sig, note) -> + appendLine(localized("debrief_report_extra_attention_2", " extra attention (%1\$s): %2\$s", sig, note)) } } if (hidden.isNotEmpty()) { - appendLine("Hidden SSIDs:") - hidden.forEach { appendLine(" · ${it.mac} ${it.vendor ?: ""} ${it.rssi} dBm ch ${it.channel}") } + appendLine(localized("debrief_report_hidden_ssids", "Hidden SSIDs:")) + hidden.forEach { appendLine(localized("debrief_report_dbm_ch", " · %1\$s %2\$s %3\$s dBm ch %4\$s", it.mac, it.vendor ?: "", it.rssi, it.channel)) } } }.trimEnd() } @@ -286,22 +303,22 @@ object DebriefReport { }.sortedByDescending { it.rssi }.take(20) val omittedRand = ble.count { !inventoryKeep(it, settings, bookmarkedKeys) } val bleBody = buildString { - appendLine("Heard ${ble.size} advertiser(s); $randomized with randomized addresses; ${named.count { it.kind == RadioKind.BLE }} signature-matched.") + appendLine(localized("debrief_report_heard_advertiser_s_with_randomized_addresses_signature", "Heard %1\$s advertiser(s); %2\$s with randomized addresses; %3\$s signature-matched.", ble.size, randomized, named.count { it.kind == RadioKind.BLE })) if (omittedRand > 0) { - appendLine("Unmatched rotating BLE omitted from lists ($omittedRand). Counts include them. Sit export has every radio.") + appendLine(localized("debrief_report_unmatched_rotating_ble_omitted_from_lists_counts", "Unmatched rotating BLE omitted from lists (%1\$s). Counts include them. Sit export has every radio.", omittedRand)) } }.trimEnd() val bleAfter = if (showAll && notable.isNotEmpty()) { buildString { - appendLine("Notable BLE:") + appendLine(localized("debrief_report_notable_ble", "Notable BLE:")) notable.forEach { d -> val guess = DeviceExplain.guess(d, d.fleetIds.map { names[it] ?: it }) - appendLine(" · ${bleLine(d, names, windowStart, now, customNames)} | ${guess.headline}") - if (d.key in mineKeys) appendLine(" Marked mine") - d.attentionNotes(fleets).forEach { (sig, note) -> - appendLine(" extra attention ($sig): $note") + appendLine(" · ${bleLine(d, displayNames, windowStart, now, customNames)} | ${guess.headline}") + if (d.key in mineKeys) appendLine(localized("debrief_report_marked_mine_2", " Marked mine")) + d.displayAttentionNotes(fleets).forEach { (sig, note) -> + appendLine(localized("debrief_report_extra_attention_2", " extra attention (%1\$s): %2\$s", sig, note)) } - val decoded = SignatureFieldDecoder.decodeSighting(d, fleets) + val decoded = SignatureFieldDecoder.decodeSighting(d, fleets).forDisplay(fleets) if (decoded.isNotEmpty()) { decoded.forEach { row -> appendLine(" ${row.label}: ${row.display}") @@ -319,40 +336,40 @@ object DebriefReport { } else if (!showAll) { bleLines( ble.filter { it.listedWhenShort() && it.fleetIds.isEmpty() }, - names, windowStart, now, customNames, mineKeys, + displayNames, windowStart, now, customNames, mineKeys, ) } else { "" } - val sigChart = signatureChart(named, names) - val sigBody = if (named.isEmpty()) "None in this window." else "" + val sigChart = signatureChart(named, displayNames) + val sigBody = if (named.isEmpty()) localized("debrief_report_none_in_this_window", "None in this window.") else "" val sigAfter = if (named.isEmpty()) { "" } else if (showAll) { buildString { - named.groupBy { it.fleetIds.joinToString("+") { id -> names[id] ?: id } } + named.groupBy { it.fleetIds.joinToString("+") { id -> displayNames[id] ?: id } } .toList().sortedByDescending { it.second.size } .forEach { (sig, list) -> appendLine(sig) list.sortedByDescending { it.rssi }.take(8).forEach { d -> append(" · ${d.reportName(customNames)} ${d.mac} ${d.rssi} dBm") - if (d.key in mineKeys) append(" Marked mine") + if (d.key in mineKeys) append(localized("debrief_report_marked_mine_3", " Marked mine")) val labels = d.liveDecode.reportLabels() if (labels.isNotEmpty()) append(" ").append(labels.joinToString(", ")) appendLine() } - list.flatMap { it.attentionNotes(fleets) }.distinct().forEach { (name, note) -> - appendLine(" extra attention ($name): $note") + list.flatMap { it.displayAttentionNotes(fleets) }.distinct().forEach { (name, note) -> + appendLine(localized("debrief_report_extra_attention_3", " extra attention (%1\$s): %2\$s", name, note)) } } }.trimEnd() } else { buildString { val marked = named.filter { it.listedWhenShort() }.sortedByDescending { it.rssi } - if (marked.isNotEmpty()) appendLine("Named or marked:") + if (marked.isNotEmpty()) appendLine(localized("debrief_report_named_or_marked", "Named or marked:")) marked.forEach { d -> append(" · ${d.reportName(customNames)} ${d.mac} ${d.rssi} dBm") - if (d.key in mineKeys) append(" Marked mine") + if (d.key in mineKeys) append(localized("debrief_report_marked_mine_3", " Marked mine")) val labels = d.liveDecode.reportLabels() if (labels.isNotEmpty()) append(" ").append(labels.joinToString(", ")) appendLine() @@ -360,18 +377,18 @@ object DebriefReport { }.trimEnd() } val persistBody = buildString { - appendLine("Sat most of this window: ${persistent.size}") - append("First seen in this window: ${arrived.size}") + appendLine(localized("debrief_report_sat_most_of_this_window", "Sat most of this window: %1\$s", persistent.size)) + append(localized("debrief_report_first_seen_in_this_window", "First seen in this window: %1\$s", arrived.size)) }.trimEnd() val persistAfter = if (!showAll) { "" } else { buildString { persistent.filter { inventoryKeep(it, settings, bookmarkedKeys) }.take(15).forEach { - appendLine(" · ${it.reportName(customNames)} ${it.mac} dwell ${fmtDur(dwellMs(it, windowStart, now))}") + appendLine(localized("debrief_report_dwell", " · %1\$s %2\$s dwell %3\$s", it.reportName(customNames), it.mac, fmtDur(dwellMs(it, windowStart, now)))) } - if (persistent.isEmpty()) appendLine(" · None.") - appendLine("Loudest first seen:") + if (persistent.isEmpty()) appendLine(localized("debrief_report_none", " · None.")) + appendLine(localized("debrief_report_loudest_first_seen", "Loudest first seen:")) arrived.filter { inventoryKeep(it, settings, bookmarkedKeys) }.sortedByDescending { it.rssi }.take(8).forEach { appendLine(" · ${it.reportName(customNames)} ${it.mac} ${it.rssi} dBm") } @@ -379,21 +396,21 @@ object DebriefReport { } val flags = anomalyLines(inWin, customNames, settings, bookmarkedKeys, showAll) val anomalyBody = if (flags.isEmpty()) { - "No extra flags. Signature hits, Extra attention, and tracking callouts already cover named pattern matches." + localized("debrief_report_no_extra_flags_signature_hits_extra_attention", "No extra flags. Signature hits, Extra attention, and tracking callouts already cover named pattern matches.") } else flags.joinToString("\n") { " · $it" } val attentionHits = inWin.flatMap { d -> - d.attentionNotes(fleets).map { (sig, note) -> Triple(d, sig, note) } + d.displayAttentionNotes(fleets).map { (sig, note) -> Triple(d, sig, note) } } val actionBody = actions(following, withYou, ownLikely, beaconsWithYou, wearablesWithYou, settings, pathSpan) .joinToString("\n") { " · $it" } val distanceLine = when { - !settings.tagLocation -> "GPS tagging off — no path" - path.size < 2 -> "GPS tagging on, fewer than 2 fixes in this window" - else -> "traveled ${fmtDist(pathLen)} along path · span ${fmtDist(pathSpan)} · ${path.size} fixes" + !settings.tagLocation -> localized("debrief_report_gps_tagging_off_no_path", "GPS tagging off — no path") + path.size < 2 -> localized("debrief_report_gps_tagging_on_fewer_than_2_fixes", "GPS tagging on, fewer than 2 fixes in this window") + else -> localized("debrief_report_traveled_along_path_span_fixes", "traveled %1\$s along path · span %2\$s · %3\$s fixes", fmtDist(pathLen), fmtDist(pathSpan), path.size) } val lookupLine = when { - !places.attempted -> "off" + !places.attempted -> localized("debrief_report_off", "off") places.namesByCell.isNotEmpty() -> places.areaLine() else -> places.note } @@ -409,24 +426,24 @@ object DebriefReport { val sections = buildList { add(DebriefSection( next(), - "Executive summary", + localized("debrief_report_executive_summary", "Executive summary"), execSummary(wifi, ble, named, hidden, randomized, pathSpan, pathLen, following, withYou, ownLikely, beaconsWithYou, wearablesWithYou, settings, places, win, mineHeard) + craftSentence(pictures), chart = classChart(inWin, fleets), )) - add(DebriefSection(next(), "Where you were", whereYouWere(settings, path, pathLen, pathSpan, inWin, names, places, windowEnd, customNames, bookmarkedKeys))) + add(DebriefSection(next(), localized("debrief_report_where_you_were", "Where you were"), whereYouWere(settings, path, pathLen, pathSpan, inWin, displayNames, places, windowEnd, customNames, bookmarkedKeys))) if (aircraftBody.isNotEmpty()) { - add(DebriefSection(next(), "Aircraft", aircraftBody)) + add(DebriefSection(next(), localized("debrief_report_aircraft", "Aircraft"), aircraftBody)) } observerNotesSection(inWin, customNames, observerNotes)?.let { body -> - add(DebriefSection(next(), "Observer notes", body)) + add(DebriefSection(next(), localized("debrief_report_observer_notes", "Observer notes"), body)) } markedMineSection(inWin, customNames, mineKeys, assessed)?.let { body -> - add(DebriefSection(next(), "Marked mine", body)) + add(DebriefSection(next(), localized("debrief_report_marked_mine", "Marked mine"), body)) } add( DebriefSection( next(), - "Tracking assessment", + localized("debrief_report_tracking_assessment", "Tracking assessment"), trackingSection(settings, path, pathSpan, pathLen, following, wholeSit, beaconsWithYou, wearablesWithYou), ), ) @@ -434,12 +451,9 @@ object DebriefReport { add( DebriefSection( next(), - "Possible trackers with you", + localized("debrief_report_possible_trackers_with_you", "Possible trackers with you"), trackerCallout( - "Finder tags (AirTag / Find My, SmartTag, Tile, Chipolo, Pebblebee) and loud pocket Apple BLE. " + - "These radios stayed with your GPS path for this sit. " + - "Fieldwatch cannot tell your own tag or phone from a tracker planted in the car, bag, or on you before you started. " + - "Account for each MAC. Not a finding and not identity.", + localized("debrief_report_finder_tags_airtag_find_my_smarttag_tile", "Finder tags (AirTag / Find My, SmartTag, Tile, Chipolo, Pebblebee) and loud pocket Apple BLE. These radios stayed with your GPS path for this sit. Fieldwatch cannot tell your own tag or phone from a tracker planted in the car, bag, or on you before you started. Account for each MAC. Not a finding and not identity."), wholeSit, customNames, ), @@ -451,11 +465,9 @@ object DebriefReport { add( DebriefSection( next(), - "Possible tail", + localized("debrief_report_possible_tail", "Possible tail"), trackerCallout( - "Finder tags that were not heard when this sit started, then stayed with your path. " + - "That can mean someone started following you (their phone or tag), or a device was added during the trip. " + - "Not a finding and not identity.", + localized("debrief_report_finder_tags_that_were_not_heard_when", "Finder tags that were not heard when this sit started, then stayed with your path. That can mean someone started following you (their phone or tag), or a device was added during the trip. Not a finding and not identity."), following, customNames, ), @@ -467,12 +479,9 @@ object DebriefReport { add( DebriefSection( next(), - "Retail beacons with you", + localized("debrief_report_retail_beacons_with_you", "Retail beacons with you"), trackerCallout( - "iBeacon / Minew / Estimote / Kontakt.io / Target Atrius basket radios that stayed with your GPS path. " + - "Location beacons are usually fixtures in a store or venue — they do not typically move with you. " + - "If one did, account for it (a Target basket you pushed, your own test tag, a badge, or a short path that still overlaps a fixture). " + - "Not the same as a Find My tail. Not a finding and not identity.", + localized("debrief_report_ibeacon_minew_estimote_kontakt_io_target_atrius", "iBeacon / Minew / Estimote / Kontakt.io / Target Atrius basket radios that stayed with your GPS path. Location beacons are usually fixtures in a store or venue — they do not typically move with you. If one did, account for it (a Target basket you pushed, your own test tag, a badge, or a short path that still overlaps a fixture). Not the same as a Find My tail. Not a finding and not identity."), beaconsWithYou, customNames, ), @@ -484,11 +493,9 @@ object DebriefReport { add( DebriefSection( next(), - "Wearables with you", + localized("debrief_report_wearables_with_you", "Wearables with you"), trackerCallout( - "Garmin / Fitbit / Oura radios that stayed with your GPS path. " + - "Watches and rings usually move with the person wearing them — often your own kit or someone walking with you. " + - "They are not typically planted trackers. Account for each MAC. Not a finding and not identity.", + localized("debrief_report_garmin_fitbit_oura_radios_that_stayed_with", "Garmin / Fitbit / Oura radios that stayed with your GPS path. Watches and rings usually move with the person wearing them — often your own kit or someone walking with you. They are not typically planted trackers. Account for each MAC. Not a finding and not identity."), wearablesWithYou, customNames, ), @@ -496,68 +503,69 @@ object DebriefReport { ), ) } - add(DebriefSection(next(), "Environment", environment(wifi, ble, randomized, persistent, pathSpan, pathLen))) + add(DebriefSection(next(), localized("debrief_report_environment", "Environment"), environment(wifi, ble, randomized, persistent, pathSpan, pathLen))) add(DebriefSection( next(), - "Networks (Wi-Fi access points)", + localized("debrief_report_networks_wi_fi_access_points", "Networks (Wi-Fi access points)"), networks.trimEnd(), chart = channelChart, after = networksAfter.trimEnd(), )) - add(DebriefSection(next(), "Bluetooth LE", bleBody.trimEnd(), after = bleAfter.trimEnd())) - add(DebriefSection(next(), "Signature hits", sigBody.trimEnd(), chart = sigChart, after = sigAfter.trimEnd())) - add(DebriefSection(next(), "Persistence", persistBody.trimEnd(), after = persistAfter.trimEnd())) + add(DebriefSection(next(), localized("debrief_report_bluetooth_le", "Bluetooth LE"), bleBody.trimEnd(), after = bleAfter.trimEnd())) + add(DebriefSection(next(), localized("debrief_report_signature_hits", "Signature hits"), sigBody.trimEnd(), chart = sigChart, after = sigAfter.trimEnd())) + add(DebriefSection(next(), localized("debrief_report_persistence", "Persistence"), persistBody.trimEnd(), after = persistAfter.trimEnd())) if (attentionHits.isNotEmpty()) { add( DebriefSection( next(), - "Extra attention", + localized("debrief_report_extra_attention", "Extra attention"), buildString { - appendLine("Pattern match, not identity, not a skimmer detector, not a safety finding.") + appendLine(localized("debrief_report_pattern_match_not_identity_not_a_skimmer", "Pattern match, not identity, not a skimmer detector, not a safety finding.")) attentionHits.forEach { (d, sig, note) -> append(" · ${d.reportName(customNames)} ${d.mac} ${d.rssi} dBm [$sig]") - if (d.key in mineKeys) append(" Marked mine") + if (d.key in mineKeys) append(localized("debrief_report_marked_mine_3", " Marked mine")) appendLine() appendLine(" $note") } }.trimEnd(), alert = true, + kind = ReportSectionKind.EXTRA_ATTENTION, ), ) } - add(DebriefSection(next(), "Anomalies", anomalyBody)) + add(DebriefSection(next(), localized("debrief_report_anomalies", "Anomalies"), anomalyBody)) floodBody(floods, windowStart, windowEnd)?.let { body -> - add(DebriefSection(next(), "Flood", body)) + add(DebriefSection(next(), localized("debrief_report_flood", "Flood"), body)) } - add(DebriefSection(next(), "Privacy", privacy(wifi, ble, randomized, hidden, settings, places, pictures.isNotEmpty()))) - add(DebriefSection(next(), "Recommended actions", actionBody)) + add(DebriefSection(next(), localized("debrief_report_privacy", "Privacy"), privacy(wifi, ble, randomized, hidden, settings, places, pictures.isNotEmpty()))) + add(DebriefSection(next(), localized("debrief_report_recommended_actions", "Recommended actions"), actionBody)) } val windowLine = if (win.sitName != null) { - "sit ${win.sitName} (${utc(windowStart)} → ${utc(windowEnd)} UTC)" + localized("debrief_report_sit_utc", "sit %1\$s (%2\$s → %3\$s UTC)", win.sitName, utc(windowStart), utc(windowEnd)) } else { - "last 15 minutes (${utc(windowStart)} → ${utc(windowEnd)} UTC)" + localized("debrief_report_last_15_minutes_utc", "last 15 minutes (%1\$s → %2\$s UTC)", utc(windowStart), utc(windowEnd)) } val heading = if (win.sitName != null) { - "FIELDWATCH SIT — ${win.sitName}" + localized("debrief_report_fieldwatch_sit", "FIELDWATCH SIT — %1\$s", win.sitName) } else { - "FIELDWATCH FIELD DEBRIEF" + localized("debrief_report_fieldwatch_field_debrief", "FIELDWATCH FIELD DEBRIEF") } val meta = buildList { - add("Generated" to "${utc(now)} UTC") - if (win.sitName != null) add("Sit" to win.sitName) - add("Window" to windowLine) - add("Radios" to "${inWin.size}") - add("Tool" to "Fieldwatch (app.fieldwatch) · stock Android · receive-only Wi-Fi AP + BLE advertiser") - add("Scan" to "${settings.intensity.name.lowercase()} · stale ${settings.staleSec}s · brief hold ${settings.decaySec}s") - add("GPS tag" to if (settings.tagLocation) "on" else "off") - add("Distance" to distanceLine) - add("Places" to lookupLine) + add(localized("debrief_report_generated", "Generated") to "${utc(now)} UTC") + if (win.sitName != null) add(localized("debrief_report_sit", "Sit") to win.sitName) + add(localized("debrief_report_window", "Window") to windowLine) + add(localized("debrief_report_radios", "Radios") to "${inWin.size}") + add(localized("debrief_report_tool", "Tool") to localized("debrief_report_fieldwatch_app_fieldwatch_stock_android_receive_only", "Fieldwatch (app.fieldwatch) · stock Android · receive-only Wi-Fi AP + BLE advertiser")) + add(localized("debrief_report_scan", "Scan") to localized("debrief_report_stale_s_brief_hold_s", "%1\$s · stale %2\$ss · brief hold %3\$ss", settings.intensity.displayLabel(), settings.staleSec, settings.decaySec)) + add(localized("debrief_report_gps_tag", "GPS tag") to if (settings.tagLocation) localized("debrief_report_on", "on") else localized("debrief_report_off", "off")) + add(localized("debrief_report_distance", "Distance") to distanceLine) + add(localized("debrief_report_places", "Places") to lookupLine) add( - "Classification" to if (pictures.isNotEmpty()) { - "Operationally sensitive — neighbor SSIDs, MACs, operator GPS, advertised aircraft track" + localized("debrief_report_classification", "Classification") to if (pictures.isNotEmpty()) { + localized("debrief_report_operationally_sensitive_neighbor_ssids_macs_operator_gps", "Operationally sensitive — neighbor SSIDs, MACs, operator GPS, advertised aircraft track") } else { - "Operationally sensitive — neighbor SSIDs, MACs, operator GPS" + localized("debrief_report_operationally_sensitive_neighbor_ssids_macs_operator_gps_2", "Operationally sensitive — neighbor SSIDs, MACs, operator GPS") }, ) } @@ -574,17 +582,17 @@ object DebriefReport { signature = sig, radioLabel = buildString { append("${d.reportName(customNames)} ${d.mac} ${d.rssi} dBm") - if (d.key in mineKeys) append(" Marked mine") + if (d.key in mineKeys) append(localized("debrief_report_marked_mine_3", " Marked mine")) }, note = note, ) }, heading = heading, - pdfKicker = if (win.sitName != null) "SIT" else "FIELD DEBRIEF", - pdfTitle = if (win.sitName != null) "Sit — ${win.sitName}" else "Field debrief", + pdfKicker = if (win.sitName != null) localized("debrief_report_sit_2", "SIT") else localized("debrief_report_field_debrief", "FIELD DEBRIEF"), + pdfTitle = if (win.sitName != null) localized("debrief_report_sit_3", "Sit — %1\$s", win.sitName) else localized("debrief_report_field_debrief_2", "Field debrief"), pathFigure = AircraftTrail.applyWalk( pathFigure( - win.sitName ?: "Last 15 minutes", path, inWin, fleets, + win.sitName ?: localized("debrief_report_last_15_minutes", "Last 15 minutes"), path, inWin, fleets, customNames, observerNotes, bookmarkedKeys, watchedFleetIds, mineKeys, ), pictures, @@ -616,12 +624,12 @@ object DebriefReport { mineKeys = mineKeys, ) return SitPathPlot.Figure( - kicker = "OPERATOR PATH", + kicker = localized("debrief_report_operator_path", "OPERATOR PATH"), tracks = listOf(SitPathPlot.FigureTrack(title, path)), dots = plot.points, lengthM = Geo.pathLengthM(path), spanM = Geo.spanM(path), - caption = "North-up. Line is this phone (${path.lengthM()}). A MAC alert or a signature alert is drawn once. A decoded latitude and longitude is the last advertised position. Anything else is the strongest hear. A number is that place (Path key).", + caption = localized("debrief_report_north_up_line_is_this_phone_a", "North-up. Line is this phone (%1\$s). A MAC alert or a signature alert is drawn once. A decoded latitude and longitude is the last advertised position. Anything else is the strongest hear. A number is that place (Path key).", path.lengthM()), ) } @@ -646,7 +654,9 @@ object DebriefReport { bookmarkedKeys: Set = emptySet(), mineKeys: Set = emptySet(), ): String = buildString { + val devices = devices.map { it.copy(liveDecode = it.displayLiveDecode(fleets)) } val names = fleets.associate { it.id to it.name } + val displayNames = fleets.associate { it.id to it.displayName() } val win = window ?: DebriefWindow(now - WINDOW_MS, now) val windowStart = win.startAt val windowEnd = win.endAt @@ -669,31 +679,23 @@ object DebriefReport { ), ) - appendLine("## Where you were (operator GPS)") - appendLine("- Tag detections with GPS: ${if (settings.tagLocation) "on" else "off"}.") + appendLine(localized("debrief_report_where_you_were_operator_gps", "## Where you were (operator GPS)")) + appendLine(localized("debrief_report_tag_detections_with_gps", "- Tag detections with GPS: %1\$s.", if (settings.tagLocation) localized("debrief_report_on", "on") else localized("debrief_report_off", "off"))) appendLine( - "- Online place names: " + + localized("debrief_report_online_place_names", "- Online place names: ") + if (places.attempted) places.note - else "off (Settings → Online place names in Debrief). No reverse-geocode this export.", + else localized("debrief_report_off_settings_online_place_names_in_debrief", "off (Settings → Online place names in Debrief). No reverse-geocode this export."), ) - append(whereYouWere(settings, path, pathLen, pathSpan, inWin, names, places, windowEnd, customNames, bookmarkedKeys).trimEnd()) + append(whereYouWere(settings, path, pathLen, pathSpan, inWin, displayNames, places, windowEnd, customNames, bookmarkedKeys).trimEnd()) appendLine() appendLine() if (path.size < 2 || pathSpan < MOVE_M) { - appendLine("- Following test: insufficient movement (need ~45 m span). Do not infer a tail.") + appendLine(localized("debrief_report_following_test_insufficient_movement_need_45_m", "- Following test: insufficient movement (need ~45 m span). Do not infer a tail.")) appendLine() } - appendLine("## GPS co-travel") + appendLine(localized("debrief_report_gps_co_travel", "## GPS co-travel")) appendLine( - "Only radios that stayed with the operator path are listed. " + - "House tags and other radios the operator only passed are omitted — they are not tracking. " + - "Not identity. Find My MAC rotation will not stitch a tail that changes address. " + - "Possible tail extra gates (walks): trail covers ≥ half the operator path, " + - "≥ 2/3 of GPS stamps at −75 dBm or louder, last stamp not 12 dB below loudest. " + - "Fail any one → omit (pass-by), not a tail. " + - "Finder tags (AirTag / SmartTag / Tile / Chipolo / Pebblebee / Find My / loud pocket Apple) " + - "are the tracking test. Retail beacons and wearables that co-travel are listed separately — " + - "they do not typically move with you (beacons) or are usually own kit (wearables).", + localized("debrief_report_only_radios_that_stayed_with_the_operator", "Only radios that stayed with the operator path are listed. House tags and other radios the operator only passed are omitted — they are not tracking. Not identity. Find My MAC rotation will not stitch a tail that changes address. Possible tail extra gates (walks): trail covers ≥ half the operator path, ≥ 2/3 of GPS stamps at −75 dBm or louder, last stamp not 12 dB below loudest. Fail any one → omit (pass-by), not a tail. Finder tags (AirTag / SmartTag / Tile / Chipolo / Pebblebee / Find My / loud pocket Apple) are the tracking test. Retail beacons and wearables that co-travel are listed separately — they do not typically move with you (beacons) or are usually own kit (wearables)."), ) fun List.open(): List = filter { it.device.key !in mineKeys } val followingMd = follow.filter { it.verdict == Verdict.FOLLOWING }.open() @@ -703,7 +705,7 @@ object DebriefReport { val beaconsOpen = beaconsMd.open() val wearablesOpen = wearablesMd.open() if (followingMd.isEmpty() && wholeSitMd.isEmpty() && beaconsOpen.isEmpty() && wearablesOpen.isEmpty()) { - appendLine("- None stayed with the path.") + appendLine(localized("debrief_report_none_stayed_with_the_path", "- None stayed with the path.")) } else { fun dump(title: String, rows: List) { if (rows.isEmpty()) return @@ -712,26 +714,25 @@ object DebriefReport { rows.forEach { h -> val d = h.device appendLine( - "- ${h.label} ${d.reportName(customNames)} ${d.mac} RSSI ${d.rssi} dBm " + - "(min ${d.rssiMin} / max ${d.rssiMax}) trail ${h.samples} fixes, span ${h.spanM.toInt()} m", + localized("debrief_report_rssi_dbm_min_max_trail_fixes_span", "- %1\$s %2\$s %3\$s RSSI %4\$s dBm (min %5\$s / max %6\$s) trail %7\$s fixes, span %8\$s m", h.label, d.reportName(customNames), d.mac, d.rssi, d.rssiMin, d.rssiMax, h.samples, h.spanM.toInt()), ) appendLine(" ${h.detail}") } } dump( - "Possible trackers with you (finder tags, whole sit — yours or planted before you started)", + localized("debrief_report_possible_trackers_with_you_finder_tags_whole", "Possible trackers with you (finder tags, whole sit — yours or planted before you started)"), wholeSitMd, ) dump( - "Possible tail (finder tags, first heard after this sit started, then stayed)", + localized("debrief_report_possible_tail_finder_tags_first_heard_after", "Possible tail (finder tags, first heard after this sit started, then stayed)"), followingMd, ) dump( - "Retail beacons with you (iBeacon / Minew / Estimote / Kontakt.io / Target Atrius basket — fixtures; a pushed cart will co-travel)", + localized("debrief_report_retail_beacons_with_you_ibeacon_minew_estimote", "Retail beacons with you (iBeacon / Minew / Estimote / Kontakt.io / Target Atrius basket — fixtures; a pushed cart will co-travel)"), beaconsOpen, ) dump( - "Wearables with you (Garmin / Fitbit / Oura — usually own kit or a companion)", + localized("debrief_report_wearables_with_you_garmin_fitbit_oura_usually", "Wearables with you (Garmin / Fitbit / Oura — usually own kit or a companion)"), wearablesOpen, ) } @@ -777,37 +778,37 @@ object DebriefReport { val cover = opLen > 0.0 && trailLen >= COVER_FRAC * opLen val (verdict, detail) = when { operatorPath.size < 2 || opSpan < MOVE_M -> - Verdict.INSUFFICIENT to "Operator GPS path too short (${opSpan.toInt()} m) to test following." + Verdict.INSUFFICIENT to localized("debrief_report_operator_gps_path_too_short_m_to", "Operator GPS path too short (%1\$s m) to test following.", opSpan.toInt()) trail.size < 2 -> - Verdict.INSUFFICIENT to "Heard, but not at two GPS points. Cannot test co-travel." + Verdict.INSUFFICIENT to localized("debrief_report_heard_but_not_at_two_gps_points", "Heard, but not at two GPS points. Cannot test co-travel.") onBody && ownHere -> Verdict.OWN_LIKELY to onBodyLine(kind, d, trail.size) cover && ownHere && d.rssiMax >= ON_BODY_MAX -> Verdict.OWN_LIKELY to - "Heard along ${trailLen.toInt()} m of your ${opLen.toInt()} m path and still loud (${d.rssiMax} dBm). " + + localized("debrief_report_heard_along_m_of_your_m_path", "Heard along %1\$s m of your %2\$s m path and still loud (%3\$s dBm). ", trailLen.toInt(), opLen.toInt(), d.rssiMax) + withYouNote(kind, d) span < MOVE_M * 0.6 -> - Verdict.STATIONARY to "Heard near one place (${span.toInt()} m span) while you moved ${opSpan.toInt()} m. Looks stationary — you walked away from it." + Verdict.STATIONARY to localized("debrief_report_heard_near_one_place_m_span_while", "Heard near one place (%1\$s m span) while you moved %2\$s m. Looks stationary — you walked away from it.", span.toInt(), opSpan.toInt()) presentAtStart && stillHere && d.rssiMax >= ON_BODY_MAX -> Verdict.OWN_LIKELY to - "Moved ${span.toInt()} m with you, already on the air when this 15-minute window opened, strong (${d.rssi} dBm). " + + localized("debrief_report_moved_m_with_you_already_on_the", "Moved %1\$s m with you, already on the air when this 15-minute window opened, strong (%2\$s dBm). ", span.toInt(), d.rssi) + withYouNote(kind, d) presentAtStart && stillHere -> Verdict.MOVED_WITH_YOU to - "GPS samples span ${span.toInt()} m along your path (${trail.size} fixes). Already on the air when this window opened and still here. " + + localized("debrief_report_gps_samples_span_m_along_your_path", "GPS samples span %1\$s m along your path (%2\$s fixes). Already on the air when this window opened and still here. ", span.toInt(), trail.size) + withYouNote(kind, d) !presentAtStart && span >= MOVE_M && trail.size >= 3 -> possibleTail(trail, span, opLen, kind, d) else -> Verdict.STATIONARY to - "Heard along ${span.toInt()} m (${trail.size} GPS stamps) but did not stay loud on you. Neighborhood arc / pass-by, not a tail." + localized("debrief_report_heard_along_m_gps_stamps_but_did", "Heard along %1\$s m (%2\$s GPS stamps) but did not stay loud on you. Neighborhood arc / pass-by, not a tail.", span.toInt(), trail.size) } FollowHit(d, label, verdict, detail, span, trail.size) }.sortedBy { it.verdict.ordinal } } private fun onBodyLine(kind: TrackerMatch.Kind, d: Sighting, stamps: Int): String { - val loud = "Stayed loud with you the whole sit (${d.rssiMax} to ${d.rssiMin} dBm, $stamps GPS stamps). " + val loud = localized("debrief_report_stayed_loud_with_you_the_whole_sit", "Stayed loud with you the whole sit (%1\$s to %2\$s dBm, %3\$s GPS stamps). ", d.rssiMax, d.rssiMin, stamps) return loud + withYouNote(kind, d) } @@ -822,23 +823,23 @@ object DebriefReport { if (notes.isNotEmpty()) return notes.joinToString(" ") val labels = chips.reportLabels() if (labels.isEmpty()) return null - return "Decoded: ${labels.joinToString(", ")}." + return localized("debrief_report_decoded", "Decoded: %1\$s.", labels.joinToString(", ")) } private fun withYouNote(kind: TrackerMatch.Kind, device: Sighting): String { val decoded = liveDecodeSentence(device) val base = when (kind) { TrackerMatch.Kind.FINDER -> - "With you the whole sit — yours or planted before you started. Account for it." + localized("debrief_report_with_you_the_whole_sit_yours_or", "With you the whole sit — yours or planted before you started. Account for it.") TrackerMatch.Kind.BEACON -> - "Location beacons do not typically move with you. Account for it (own test tag, badge, or a short overlap with a fixture)." + localized("debrief_report_location_beacons_do_not_typically_move_with", "Location beacons do not typically move with you. Account for it (own test tag, badge, or a short overlap with a fixture).") TrackerMatch.Kind.WEARABLE -> - "Typical of a watch or ring you or a companion are wearing. Not typically a planted tracker." + localized("debrief_report_typical_of_a_watch_or_ring_you", "Typical of a watch or ring you or a companion are wearing. Not typically a planted tracker.") } return when { decoded != null -> "$base $decoded" kind == TrackerMatch.Kind.FINDER -> - "$base Find My / iPhone addresses rotate; this MAC is this session." + localized("debrief_report_find_my_iphone_addresses_rotate_this_mac", "%1\$s Find My / iPhone addresses rotate; this MAC is this session.", base) else -> base } } @@ -866,25 +867,23 @@ object DebriefReport { return when { fade >= FADE_DB -> Verdict.STATIONARY to - "Appeared after the sit started, but last GPS stamp was $last dBm after a loudest of $peak dBm (−${fade} dB). Looks like you walked away from a fixture, not a tail." + localized("debrief_report_appeared_after_the_sit_started_but_last", "Appeared after the sit started, but last GPS stamp was %1\$s dBm after a loudest of %2\$s dBm (−%3\$s dB). Looks like you walked away from a fixture, not a tail.", last, peak, fade) loudN < loudNeed -> Verdict.STATIONARY to - "Appeared after the sit started and GPS span was ${span.toInt()} m, but only $loudN/${trail.size} stamps were loud (−75 dBm+). Looks like a pass-by, not a tail." + localized("debrief_report_appeared_after_the_sit_started_and_gps", "Appeared after the sit started and GPS span was %1\$s m, but only %2\$s/%3\$s stamps were loud (−75 dBm+). Looks like a pass-by, not a tail.", span.toInt(), loudN, trail.size) trailLen < coverNeed -> Verdict.STATIONARY to - "Appeared after the sit started, but was only heard along ${trailLen.toInt()} m of your ${opLen.toInt()} m path ($coverPct%). Neighborhood arc / pass-by, not a tail." + localized("debrief_report_appeared_after_the_sit_started_but_was", "Appeared after the sit started, but was only heard along %1\$s m of your %2\$s m path (%3\$s%%). Neighborhood arc / pass-by, not a tail.", trailLen.toInt(), opLen.toInt(), coverPct) else -> { val stats = - "Appeared after the sit started, then stayed loud with you across ${span.toInt()} m " + - "(${trailLen.toInt()} m of your ${opLen.toInt()} m path, $coverPct%; " + - "$loudN/${trail.size} GPS stamps ≥ −75 dBm). " + localized("debrief_report_appeared_after_the_sit_started_then_stayed", "Appeared after the sit started, then stayed loud with you across %1\$s m (%2\$s m of your %3\$s m path, %4\$s%%; %5\$s/%6\$s GPS stamps ≥ −75 dBm). ", span.toInt(), trailLen.toInt(), opLen.toInt(), coverPct, loudN, trail.size) val note = when (kind) { TrackerMatch.Kind.FINDER -> - "Treat as a possible tail until you visually account for it." + localized("debrief_report_treat_as_a_possible_tail_until_you", "Treat as a possible tail until you visually account for it.") TrackerMatch.Kind.BEACON -> - "Unusual for a retail/location beacon — they do not typically move with you. Account for it; not the same as a Find My tail." + localized("debrief_report_unusual_for_a_retail_location_beacon_they", "Unusual for a retail/location beacon — they do not typically move with you. Account for it; not the same as a Find My tail.") TrackerMatch.Kind.WEARABLE -> - "Typical of a watch that joined the sit (you put it on, or someone walking with you). Not typically a planted tracker." + localized("debrief_report_typical_of_a_watch_that_joined_the", "Typical of a watch that joined the sit (you put it on, or someone walking with you). Not typically a planted tracker.") } Verdict.FOLLOWING to stats + note } @@ -913,53 +912,53 @@ object DebriefReport { mineHeard: Int = 0, ): String = buildString { val whenPhrase = if (window.sitName != null) { - "In sit ${window.sitName}" + localized("debrief_report_in_sit", "In sit %1\$s", window.sitName) } else { - "In the last 15 minutes" + localized("debrief_report_in_the_last_15_minutes", "In the last 15 minutes") } - append("$whenPhrase Fieldwatch heard ${wifi.size} Wi-Fi access points and ${ble.size} BLE advertisers") - append(" (${named.size} signature-matched") - if (mineHeard > 0) append(", $mineHeard marked mine") - append(", ${hidden.size} hidden SSIDs, $randomized randomized BLE). ") + append(localized("debrief_report_fieldwatch_heard_wi_fi_access_points_and", "%1\$s Fieldwatch heard %2\$s Wi-Fi access points and %3\$s BLE advertisers", whenPhrase, wifi.size, ble.size)) + append(localized("debrief_report_signature_matched", " (%1\$s signature-matched", named.size)) + if (mineHeard > 0) append(localized("debrief_report_marked_mine_4", ", %1\$s marked mine", mineHeard)) + append(localized("debrief_report_hidden_ssids_randomized_ble", ", %1\$s hidden SSIDs, %2\$s randomized BLE). ", hidden.size, randomized)) if (settings.tagLocation && pathLen > 0) { - append("Overall distance traveled: ${fmtDist(pathLen)} along the GPS path (straight-line span ${fmtDist(pathSpan)}). ") + append(localized("debrief_report_overall_distance_traveled_along_the_gps_path", "Overall distance traveled: %1\$s along the GPS path (straight-line span %2\$s). ", fmtDist(pathLen), fmtDist(pathSpan))) } if (places.namesByCell.isNotEmpty()) { - append("Stops / area: ${places.areaLine()}. ") + append(localized("debrief_report_stops_area", "Stops / area: %1\$s. ", places.areaLine())) } else if (places.attempted && settings.tagLocation) { append("${places.note} ") } val wholeSit = ownLikely + withYou when { following.isNotEmpty() || wholeSit.isNotEmpty() -> { - append("TRACKING NOTE. ") + append(localized("debrief_report_tracking_note", "TRACKING NOTE. ")) if (wholeSit.isNotEmpty()) { - append("${wholeSit.size} finder tag(s) with you the whole sit (your kit or planted before you started): ") + append(localized("debrief_report_finder_tag_s_with_you_the_whole", "%1\$s finder tag(s) with you the whole sit (your kit or planted before you started): ", wholeSit.size)) append(wholeSit.joinToString { trackId(it) }) append(". ") } if (following.isNotEmpty()) { - append("${following.size} possible tail(s) first heard after this sit started: ") + append(localized("debrief_report_possible_tail_s_first_heard_after_this", "%1\$s possible tail(s) first heard after this sit started: ", following.size)) append(following.joinToString { trackId(it) }) append(". ") } - append("Account for every MAC — Fieldwatch cannot tell yours from a plant. ") + append(localized("debrief_report_account_for_every_mac_fieldwatch_cannot_tell", "Account for every MAC — Fieldwatch cannot tell yours from a plant. ")) } !settings.tagLocation -> { - append("GPS tagging is off, so a following test was not performed. Enable “Tag detections with GPS” and walk to test. ") + append(localized("debrief_report_gps_tagging_is_off_so_a_following", "GPS tagging is off, so a following test was not performed. Enable “Tag detections with GPS” and walk to test. ")) } pathSpan < MOVE_M -> { - append("GPS displacement was only ${pathSpan.toInt()} m — too short to test whether a tracker is following. Walk farther with tagging on. ") + append(localized("debrief_report_gps_displacement_was_only_m_too_short", "GPS displacement was only %1\$s m — too short to test whether a tracker is following. Walk farther with tagging on. ", pathSpan.toInt())) } - else -> append("No finder tag clearly stayed with the GPS path in this window. ") + else -> append(localized("debrief_report_no_finder_tag_clearly_stayed_with_the", "No finder tag clearly stayed with the GPS path in this window. ")) } if (beaconsWithYou.isNotEmpty()) { - append("Retail beacon(s) also stayed with the path (unusual — fixtures do not typically move with you): ") + append(localized("debrief_report_retail_beacon_s_also_stayed_with_the", "Retail beacon(s) also stayed with the path (unusual — fixtures do not typically move with you): ")) append(beaconsWithYou.joinToString { "${it.label} ${it.device.mac}" }) append(". ") } if (wearablesWithYou.isNotEmpty()) { - append("Wearable(s) stayed with the path (usually your watch/ring or a companion): ") + append(localized("debrief_report_wearable_s_stayed_with_the_path_usually", "Wearable(s) stayed with the path (usually your watch/ring or a companion): ")) append(wearablesWithYou.joinToString { "${it.label} ${it.device.mac}" }) append(".") } @@ -976,20 +975,20 @@ object DebriefReport { wearablesWithYou: List, ): String = buildString { if (!settings.tagLocation) { - appendLine("GPS tagging is OFF. Fieldwatch cannot test whether a radio moved with you.") - appendLine("Turn on Settings → Tag detections with GPS, walk or drive 50+ m, then run Debrief again.") + appendLine(localized("debrief_report_gps_tagging_is_off_fieldwatch_cannot_test", "GPS tagging is OFF. Fieldwatch cannot test whether a radio moved with you.")) + appendLine(localized("debrief_report_turn_on_settings_tag_detections_with_gps", "Turn on Settings → Tag detections with GPS, walk or drive 50+ m, then run Debrief again.")) return@buildString } - appendLine("Overall distance traveled: ${fmtDist(pathLen)} along the GPS path (${path.size} samples). Straight-line span ${fmtDist(pathSpan)}.") - appendLine("Co-travel is split by class: finder tags (AirTag / Find My, SmartTag, Tile, Chipolo, Pebblebee, loud pocket Apple), retail beacons (iBeacon, Minew, Estimote, Kontakt.io, Target Atrius basket), and wearables (Garmin, Fitbit, Oura).") + appendLine(localized("debrief_report_overall_distance_traveled_along_the_gps_path_2", "Overall distance traveled: %1\$s along the GPS path (%2\$s samples). Straight-line span %3\$s.", fmtDist(pathLen), path.size, fmtDist(pathSpan))) + appendLine(localized("debrief_report_co_travel_is_split_by_class_finder", "Co-travel is split by class: finder tags (AirTag / Find My, SmartTag, Tile, Chipolo, Pebblebee, loud pocket Apple), retail beacons (iBeacon, Minew, Estimote, Kontakt.io, Target Atrius basket), and wearables (Garmin, Fitbit, Oura).")) if (path.size < 2 || pathSpan < MOVE_M) { - appendLine("Insufficient movement to distinguish a radio that stayed with you from one you passed. Walk or drive farther and re-run.") + appendLine(localized("debrief_report_insufficient_movement_to_distinguish_a_radio_that", "Insufficient movement to distinguish a radio that stayed with you from one you passed. Walk or drive farther and re-run.")) return@buildString } if (following.isEmpty() && wholeSit.isEmpty() && beaconsWithYou.isEmpty() && wearablesWithYou.isEmpty()) { - appendLine("No finder tag, retail beacon, or wearable stayed with you. House tags and other radios you only passed are not listed.") + appendLine(localized("debrief_report_no_finder_tag_retail_beacon_or_wearable", "No finder tag, retail beacon, or wearable stayed with you. House tags and other radios you only passed are not listed.")) } else { - appendLine("Callouts below are only radios that stayed with the path. Radios you passed (store fixtures, house tags) are omitted.") + appendLine(localized("debrief_report_callouts_below_are_only_radios_that_stayed", "Callouts below are only radios that stayed with the path. Radios you passed (store fixtures, house tags) are omitted.")) } } @@ -1002,7 +1001,7 @@ object DebriefReport { val hits = devices.filter { it.key in mineKeys } if (hits.isEmpty()) return null return buildString { - appendLine("Radios you marked mine. Heard in this window. Still listed. No beep while the mark is on.") + appendLine(localized("debrief_report_radios_you_marked_mine_heard_in_this", "Radios you marked mine. Heard in this window. Still listed. No beep while the mark is on.")) hits.sortedWith( compareByDescending { it.rssi }.thenBy { it.mac }, ).forEach { d -> @@ -1011,10 +1010,10 @@ object DebriefReport { val verdict = assessed.firstOrNull { it.device.key == d.key }?.verdict val line = when (verdict) { Verdict.FOLLOWING -> - "Marked mine. First heard after the sit started and stayed with the path." + localized("debrief_report_marked_mine_first_heard_after_the_sit", "Marked mine. First heard after the sit started and stayed with the path.") Verdict.OWN_LIKELY, Verdict.MOVED_WITH_YOU -> - "Marked mine. With you the whole sit." - else -> "Marked mine." + localized("debrief_report_marked_mine_with_you_the_whole_sit", "Marked mine. With you the whole sit.") + else -> localized("debrief_report_marked_mine_5", "Marked mine.") } appendLine(" $line") } @@ -1032,7 +1031,7 @@ object DebriefReport { } if (hits.isEmpty()) return null return buildString { - appendLine("Your captions on radios heard in this window. Same KIND+MAC as Named radios. Not catalog Notes.") + appendLine(localized("debrief_report_your_captions_on_radios_heard_in_this", "Your captions on radios heard in this window. Same KIND+MAC as Named radios. Not catalog Notes.")) hits.sortedWith( compareByDescending> { it.first.rssi }.thenBy { it.first.mac }, ).forEach { (d, note) -> @@ -1053,7 +1052,7 @@ object DebriefReport { rows.forEach { h -> val d = h.device appendLine(" • ${h.label}") - appendLine(" ${d.reportName(customNames)} ${d.mac} RSSI ${d.rssi} dBm (min ${d.rssiMin} / max ${d.rssiMax})") + appendLine(localized("debrief_report_rssi_dbm_min_max", " %1\$s %2\$s RSSI %3\$s dBm (min %4\$s / max %5\$s)", d.reportName(customNames), d.mac, d.rssi, d.rssiMin, d.rssiMax)) appendLine(" ${h.detail}") } }.trimEnd() @@ -1070,52 +1069,51 @@ object DebriefReport { customNames: Map = emptyMap(), bookmarkedKeys: Set = emptySet(), ): String = buildString { - appendLine("Phone GPS at hear-time, not the other radio’s location and not a camera pole. Stays are clusters within about 40 m; hops between them are transit. Coordinates are not repeated on every Wi-Fi/BLE line.") + appendLine(localized("debrief_report_phone_gps_at_hear_time_not_the", "Phone GPS at hear-time, not the other radio’s location and not a camera pole. Stays are clusters within about 40 m; hops between them are transit. Coordinates are not repeated on every Wi-Fi/BLE line.")) if (!settings.tagLocation) { - appendLine("GPS tagging is OFF. Turn on Settings → Tag detections with GPS to record where you were when radios were heard.") + appendLine(localized("debrief_report_gps_tagging_is_off_turn_on_settings", "GPS tagging is OFF. Turn on Settings → Tag detections with GPS to record where you were when radios were heard.")) return@buildString } if (path.isEmpty()) { - appendLine("GPS tagging is on, but this window has no fixes yet.") + appendLine(localized("debrief_report_gps_tagging_is_on_but_this_window", "GPS tagging is on, but this window has no fixes yet.")) return@buildString } - appendLine("Overall: ${fmtDist(pathLen)} along-track, span ${fmtDist(pathSpan)}, ${path.size} fixes.") + appendLine(localized("debrief_report_overall_along_track_span_fixes", "Overall: %1\$s along-track, span %2\$s, %3\$s fixes.", fmtDist(pathLen), fmtDist(pathSpan), path.size)) if (places.attempted) { appendLine(places.note) - appendLine("Street names are approximate. Do not treat a street as the location of a matched camera or tag.") + appendLine(localized("debrief_report_street_names_are_approximate_do_not_treat", "Street names are approximate. Do not treat a street as the location of a matched camera or tag.")) } val legs = Geo.legs(path, now = now) if (legs.isEmpty()) { - appendLine("No path legs.") + appendLine(localized("debrief_report_no_path_legs", "No path legs.")) return@buildString } val stopNames = legs.filter { it.stay }.mapNotNull { places.nameNear(it.lat, it.lon) } if (stopNames.isNotEmpty()) { - appendLine("Stops: " + stopNames.joinToString(" → ")) + appendLine(localized("debrief_report_stops", "Stops: ") + stopNames.joinToString(" → ")) } var stayN = 0 legs.forEachIndexed { i, leg -> if (leg.stay) { stayN++ appendLine() - appendLine("${i + 1}. Stay ${clock(leg.startAt)}–${clock(leg.endAt)} UTC (${fmtDur(leg.durationMs)})") + appendLine(localized("debrief_report_stay_utc", "%1\$s. Stay %2\$s–%3\$s UTC (%4\$s)", i + 1, clock(leg.startAt), clock(leg.endAt), fmtDur(leg.durationMs))) appendLine(" ${placeAndGps(leg.lat, leg.lon, places)}") val here = devices.filter { heardAt(it, leg) } val aps = here.count { it.kind == RadioKind.WIFI } val ble = here.count { it.kind == RadioKind.BLE } val sigs = here.flatMap { d -> d.fleetIds.map { names[it] ?: it } }.distinct() - append(" Heard here: $aps AP(s), $ble BLE") + append(localized("debrief_report_heard_here_ap_s_ble", " Heard here: %1\$s AP(s), %2\$s BLE", aps, ble)) if (sigs.isNotEmpty()) append(" · ${sigs.take(6).joinToString(", ")}") appendLine() here.filter { inventoryKeep(it, settings, bookmarkedKeys) }.sortedByDescending { it.rssi }.take(4).forEach { d -> appendLine(" · ${d.reportName(customNames)} ${d.mac} ${d.rssi} dBm") } - if (here.isEmpty()) appendLine(" · No GPS-stamped radios tied to this stay (tagging may have started after they were first heard).") + if (here.isEmpty()) appendLine(localized("debrief_report_no_gps_stamped_radios_tied_to_this", " · No GPS-stamped radios tied to this stay (tagging may have started after they were first heard).")) } else { appendLine() appendLine( - "${i + 1}. Transit ${clock(leg.startAt)}–${clock(leg.endAt)} UTC " + - "${fmtDist(leg.pathM)} along track", + localized("debrief_report_transit_utc_along_track", "%1\$s. Transit %2\$s–%3\$s UTC %4\$s along track", i + 1, clock(leg.startAt), clock(leg.endAt), fmtDist(leg.pathM)), ) appendLine(" ${placeAndGps(leg.lat, leg.lon, places)}") appendLine(" → ${placeAndGps(leg.endLat, leg.endLon, places)}") @@ -1124,7 +1122,7 @@ object DebriefReport { val stays = legs.count { it.stay } if (stays == 1 && pathSpan < MOVE_M) { appendLine() - appendLine("One stay — you did not move far enough in this window to split locations.") + appendLine(localized("debrief_report_one_stay_you_did_not_move_far", "One stay — you did not move far enough in this window to split locations.")) } } @@ -1151,13 +1149,13 @@ object DebriefReport { val ap = wifi.size val persistAp = persistent.count { it.kind == RadioKind.WIFI } val guess = when { - pathSpan > 200 && ap in 1..25 -> "In motion (walk/vehicle) through mixed RF." - ap <= 4 && ble.size < 30 && persistAp >= 1 -> "Likely a dwelling or small office — few sitting APs, limited BLE." - ap >= 15 && randomized >= 40 -> "Dense public / retail / street: many APs and phone-like randomized BLE." - ap >= 8 && persistAp >= 4 -> "Likely a building with standing infrastructure APs plus patrons." - else -> "Mixed or under-sampled environment." + pathSpan > 200 && ap in 1..25 -> localized("debrief_report_in_motion_walk_vehicle_through_mixed_rf", "In motion (walk/vehicle) through mixed RF.") + ap <= 4 && ble.size < 30 && persistAp >= 1 -> localized("debrief_report_likely_a_dwelling_or_small_office_few", "Likely a dwelling or small office — few sitting APs, limited BLE.") + ap >= 15 && randomized >= 40 -> localized("debrief_report_dense_public_retail_street_many_aps_and", "Dense public / retail / street: many APs and phone-like randomized BLE.") + ap >= 8 && persistAp >= 4 -> localized("debrief_report_likely_a_building_with_standing_infrastructure_aps", "Likely a building with standing infrastructure APs plus patrons.") + else -> localized("debrief_report_mixed_or_under_sampled_environment", "Mixed or under-sampled environment.") } - return "$guess (${ap} APs, ${ble.size} BLE, ${persistAp} persistent APs, traveled ${fmtDist(pathLen)}, span ${fmtDist(pathSpan)}.)" + return localized("debrief_report_aps_ble_persistent_aps_traveled_span", "%1\$s (%2\$s APs, %3\$s BLE, %4\$s persistent APs, traveled %5\$s, span %6\$s.)", guess, ap, ble.size, persistAp, fmtDist(pathLen), fmtDist(pathSpan)) } private fun wifiLine( @@ -1170,10 +1168,10 @@ object DebriefReport { append(d.reportName(customNames)).append(" ").append(d.mac) d.vendor?.let { append(" ").append(it) } append(" ").append(d.rssi).append(" dBm") - if (d.channel != 0) append(" ch ").append(d.channel) - if (d.hiddenSsid) append(" hidden") + if (d.channel != 0) append(localized("debrief_report_ch_2", " ch ")).append(d.channel) + if (d.hiddenSsid) append(localized("debrief_report_hidden", " hidden")) if (d.fleetIds.isNotEmpty()) append(" ").append(d.fleetIds.joinToString("+") { names[it] ?: it }) - append(" dwell ").append(fmtDur(dwellMs(d, from, now))) + append(localized("debrief_report_dwell_2", " dwell ")).append(fmtDur(dwellMs(d, from, now))) } private fun bleLine( @@ -1184,10 +1182,10 @@ object DebriefReport { customNames: Map = emptyMap(), ): String = buildString { append(d.reportName(customNames)).append(" ").append(d.mac) - if (d.randomized) append(" RAND") + if (d.randomized) append(localized("debrief_report_rand", " RAND")) append(" ").append(d.rssi).append(" dBm") if (d.fleetIds.isNotEmpty()) append(" ").append(d.fleetIds.joinToString("+") { names[it] ?: it }) - append(" dwell ").append(fmtDur(dwellMs(d, from, now))) + append(localized("debrief_report_dwell_2", " dwell ")).append(fmtDur(dwellMs(d, from, now))) } /** Unmatched rotating BLE stays in counts/export; inventories omit it unless Extra attention, named, bookmark, or payload. */ @@ -1222,13 +1220,13 @@ object DebriefReport { } val rows = SignatureClass.entries.mapNotNull { kind -> val n = counts[kind] ?: return@mapNotNull null - ReportBar(kind.label(), n) + ReportBar(kind.displayLabel(), n) }.toMutableList() - if (unmatched > 0) rows += ReportBar("Unmatched", unmatched) + if (unmatched > 0) rows += ReportBar(localized("debrief_report_unmatched", "Unmatched"), unmatched) if (rows.isEmpty()) return null return ReportChart( rows = rows, - caption = if (multi) "A radio in two classes counts in each." else "", + caption = if (multi) localized("debrief_report_a_radio_in_two_classes_counts_in", "A radio in two classes counts in each.") else "", ) } @@ -1237,7 +1235,7 @@ object DebriefReport { val counts = linkedMapOf() for (d in named) { val sigs = d.fleetIds.map { names[it] ?: it }.filter { it.isNotBlank() }.distinct() - val key = if (sigs.isEmpty()) "Unmatched" else sigs.joinToString(" + ") + val key = if (sigs.isEmpty()) localized("debrief_report_unmatched", "Unmatched") else sigs.joinToString(" + ") counts[key] = (counts[key] ?: 0) + 1 } val rows = counts.entries @@ -1257,12 +1255,12 @@ object DebriefReport { ): String { if (rows.isEmpty()) return "" return buildString { - appendLine("Named or marked:") + appendLine(localized("debrief_report_named_or_marked", "Named or marked:")) rows.sortedByDescending { it.rssi }.forEach { d -> appendLine(" · ${wifiLine(d, names, from, now, customNames)}") - if (d.key in mineKeys) appendLine(" Marked mine") - d.attentionNotes(fleets).forEach { (sig, note) -> - appendLine(" extra attention ($sig): $note") + if (d.key in mineKeys) appendLine(localized("debrief_report_marked_mine_2", " Marked mine")) + d.displayAttentionNotes(fleets).forEach { (sig, note) -> + appendLine(localized("debrief_report_extra_attention_2", " extra attention (%1\$s): %2\$s", sig, note)) } } }.trimEnd() @@ -1278,10 +1276,10 @@ object DebriefReport { ): String { if (rows.isEmpty()) return "" return buildString { - appendLine("Named or marked:") + appendLine(localized("debrief_report_named_or_marked", "Named or marked:")) rows.sortedByDescending { it.rssi }.forEach { d -> appendLine(" · ${bleLine(d, names, from, now, customNames)}") - if (d.key in mineKeys) appendLine(" Marked mine") + if (d.key in mineKeys) appendLine(localized("debrief_report_marked_mine_2", " Marked mine")) } }.trimEnd() } @@ -1309,10 +1307,10 @@ object DebriefReport { } if (pairing.isNotEmpty()) { out += if (showAll || pairing.size <= 8) { - "Google Fast Pair in pairing mode: " + + localized("debrief_report_google_fast_pair_in_pairing_mode", "Google Fast Pair in pairing mode: ") + pairing.joinToString { "${it.reportName(customNames)} ${it.mac}" } } else { - "Google Fast Pair in pairing mode: ${pairing.size} radios." + localized("debrief_report_google_fast_pair_in_pairing_mode_radios", "Google Fast Pair in pairing mode: %1\$s radios.", pairing.size) } } val loudUnknown = devices.filter { @@ -1320,12 +1318,12 @@ object DebriefReport { inventoryKeep(it, settings, bookmarkedKeys) } if (loudUnknown.isNotEmpty()) { - out += "Very strong unnamed radios (≥ −50 dBm): " + + out += localized("debrief_report_very_strong_unnamed_radios_50_dbm", "Very strong unnamed radios (≥ −50 dBm): ") + loudUnknown.take(8).joinToString { "${it.mac} ${it.rssi} dBm" } } val rand = devices.count { it.kind == RadioKind.BLE && it.randomized } if (rand >= 20) { - out += "High randomized BLE ($rand) — typical of phones, not a tracking finding." + out += localized("debrief_report_high_randomized_ble_typical_of_phones_not", "High randomized BLE (%1\$s) — typical of phones, not a tracking finding.", rand) } return out } @@ -1339,17 +1337,17 @@ object DebriefReport { places: DebriefPlaces, includeAircraft: Boolean, ): String = buildString { - append("A passive observer with the same radios would see ${wifi.size} named/hidden APs ") - append("and ${ble.size} BLE advertisers ($randomized randomized). ") - if (hidden.isNotEmpty()) append("Hidden SSIDs still beacon and identify the AP by BSSID. ") - if (settings.tagLocation) append("This debrief includes operator GPS samples used for distance and the following test. ") + append(localized("debrief_report_a_passive_observer_with_the_same_radios", "A passive observer with the same radios would see %1\$s named/hidden APs ", wifi.size)) + append(localized("debrief_report_and_ble_advertisers_randomized", "and %1\$s BLE advertisers (%2\$s randomized). ", ble.size, randomized)) + if (hidden.isNotEmpty()) append(localized("debrief_report_hidden_ssids_still_beacon_and_identify_the", "Hidden SSIDs still beacon and identify the AP by BSSID. ")) + if (settings.tagLocation) append(localized("debrief_report_this_debrief_includes_operator_gps_samples_used", "This debrief includes operator GPS samples used for distance and the following test. ")) if (includeAircraft) { - append("This debrief includes advertised aircraft positions from radios that broadcast a latitude and longitude. ") + append(localized("debrief_report_this_debrief_includes_advertised_aircraft_positions_from", "This debrief includes advertised aircraft positions from radios that broadcast a latitude and longitude. ")) } if (places.attempted && places.available) { - append("Street names came from the phone’s system geocoder while online. ") + append(localized("debrief_report_street_names_came_from_the_phone_s", "Street names came from the phone’s system geocoder while online. ")) } - append("Do not share this file off-device without redaction.") + append(localized("debrief_report_do_not_share_this_file_off_device", "Do not share this file off-device without redaction.")) } private fun actions( @@ -1362,32 +1360,31 @@ object DebriefReport { pathSpan: Double, ): List = buildList { if (following.isNotEmpty()) { - add("Possible tail (appeared after this sit started): ${following.joinToString { trackId(it) }}. Pause Live, open detail, note RSSI while you walk a dog-leg. Do not disable someone else’s tag.") + add(localized("debrief_report_possible_tail_appeared_after_this_sit_started", "Possible tail (appeared after this sit started): %1\$s. Pause Live, open detail, note RSSI while you walk a dog-leg. Do not disable someone else’s tag.", following.joinToString { trackId(it) })) } if (ownLikely.isNotEmpty() || withYou.isNotEmpty()) { add( - "Possible trackers with you: ${(ownLikely + withYou).joinToString { trackId(it) }}. " + - "Could be yours or planted in the car/bag/on you before you started. Account for each MAC — do not dismiss as yours.", + localized("debrief_report_possible_trackers_with_you_could_be_yours", "Possible trackers with you: %1\$s. Could be yours or planted in the car/bag/on you before you started. Account for each MAC — do not dismiss as yours.", (ownLikely + withYou).joinToString { trackId(it) }), ) } if (beaconsWithYou.isNotEmpty()) { add( - "Retail beacons with you (unusual — fixtures do not typically move with you): " + + localized("debrief_report_retail_beacons_with_you_unusual_fixtures_do", "Retail beacons with you (unusual — fixtures do not typically move with you): ") + beaconsWithYou.joinToString { it.label + " " + it.device.mac } + - ". Account for a test tag or badge before treating it as a follower.", + localized("debrief_report_account_for_a_test_tag_or_badge", ". Account for a test tag or badge before treating it as a follower."), ) } if (wearablesWithYou.isNotEmpty()) { add( - "Wearables with you (usually own kit or a companion): " + + localized("debrief_report_wearables_with_you_usually_own_kit_or", "Wearables with you (usually own kit or a companion): ") + wearablesWithYou.joinToString { it.label + " " + it.device.mac } + ".", ) } - if (!settings.tagLocation) add("Enable Tag detections with GPS and walk 50+ m, then run Debrief again for a following test.") - else if (pathSpan < MOVE_M) add("Walk farther (50+ m) with GPS tagging on, then re-run Debrief.") - add("Use Live → Pause to inspect a busy list. Watch tracker signatures if this sit was noisy.") - add("Station-side Wi-Fi (probes/clients) still needs a dedicated sniffer — Fieldwatch cannot see them.") + if (!settings.tagLocation) add(localized("debrief_report_enable_tag_detections_with_gps_and_walk", "Enable Tag detections with GPS and walk 50+ m, then run Debrief again for a following test.")) + else if (pathSpan < MOVE_M) add(localized("debrief_report_walk_farther_50_m_with_gps_tagging", "Walk farther (50+ m) with GPS tagging on, then re-run Debrief.")) + add(localized("debrief_report_use_live_pause_to_inspect_a_busy", "Use Live → Pause to inspect a busy list. Watch tracker signatures if this sit was noisy.")) + add(localized("debrief_report_station_side_wi_fi_probes_clients_still", "Station-side Wi-Fi (probes/clients) still needs a dedicated sniffer — Fieldwatch cannot see them.")) } private fun takeaway( @@ -1402,34 +1399,33 @@ object DebriefReport { ): String { val extra = buildString { if (beaconsWithYou.isNotEmpty()) { - append(" Retail beacon(s) also with the path (unusual): ") + append(localized("debrief_report_retail_beacon_s_also_with_the_path", " Retail beacon(s) also with the path (unusual): ")) append(beaconsWithYou.joinToString { it.label + " (" + it.device.mac + ")" }) append(".") } if (wearablesWithYou.isNotEmpty()) { - append(" Wearable(s) with the path (usually own kit): ") + append(localized("debrief_report_wearable_s_with_the_path_usually_own", " Wearable(s) with the path (usually own kit): ")) append(wearablesWithYou.joinToString { it.label + " (" + it.device.mac + ")" }) append(".") } } val core = when { following.isNotEmpty() && (ownLikely.isNotEmpty() || withYou.isNotEmpty()) -> - "Possible tail (appeared after sit started): ${following.joinToString { trackId(it) }}. " + - "Also finder tags with you (yours or planted before): ${(ownLikely + withYou).joinToString { trackId(it) }}. Account for every MAC." + localized("debrief_report_possible_tail_appeared_after_sit_started_also", "Possible tail (appeared after sit started): %1\$s. Also finder tags with you (yours or planted before): %2\$s. Account for every MAC.", following.joinToString { trackId(it) }, (ownLikely + withYou).joinToString { trackId(it) }) following.isNotEmpty() -> - "Possible tail (appeared after this sit started): ${following.joinToString { trackId(it) }}. Account for it on the person/vehicle." + localized("debrief_report_possible_tail_appeared_after_this_sit_started_2", "Possible tail (appeared after this sit started): %1\$s. Account for it on the person/vehicle.", following.joinToString { trackId(it) }) !settings.tagLocation -> - "Turn on GPS tagging and walk before you can test whether a tracker is following you." + localized("debrief_report_turn_on_gps_tagging_and_walk_before", "Turn on GPS tagging and walk before you can test whether a tracker is following you.") pathSpan < MOVE_M -> - "Not enough GPS movement (${pathSpan.toInt()} m) to test following; walk and re-run Debrief." + localized("debrief_report_not_enough_gps_movement_m_to_test", "Not enough GPS movement (%1\$s m) to test following; walk and re-run Debrief.", pathSpan.toInt()) ownLikely.isNotEmpty() || withYou.isNotEmpty() -> - "Finder tags with you (yours or planted before you started): ${(ownLikely + withYou).joinToString { trackId(it) }}. No new arrival this window. Account for each MAC — do not dismiss as yours." + localized("debrief_report_finder_tags_with_you_yours_or_planted", "Finder tags with you (yours or planted before you started): %1\$s. No new arrival this window. Account for each MAC — do not dismiss as yours.", (ownLikely + withYou).joinToString { trackId(it) }) beaconsWithYou.isNotEmpty() || wearablesWithYou.isNotEmpty() -> - "No finder tag stayed with the path." + localized("debrief_report_no_finder_tag_stayed_with_the_path", "No finder tag stayed with the path.") named.isEmpty() -> - "No signature hits and no GPS co-travel of trackers in this 15-minute window." + localized("debrief_report_no_signature_hits_and_no_gps_co", "No signature hits and no GPS co-travel of trackers in this 15-minute window.") else -> - "No finder tag, retail beacon, or wearable clearly stayed with your GPS path in this window." + localized("debrief_report_no_finder_tag_retail_beacon_or_wearable_2", "No finder tag, retail beacon, or wearable clearly stayed with your GPS path in this window.") } return (core + extra).trim() } @@ -1446,8 +1442,8 @@ object DebriefReport { val bits = pictures.take(3).joinToString { pic -> if (pic.status.isBlank()) pic.title else "${pic.title} (${pic.status})" } - val more = if (pictures.size > 3) " and ${pictures.size - 3} more" else "" - return " Advertised position: $bits$more." + val more = if (pictures.size > 3) localized("debrief_report_and_more", " and %1\$s more", pictures.size - 3) else "" + return localized("debrief_report_advertised_position", " Advertised position: %1\$s%2\$s.", bits, more) } private fun dwellMs(d: Sighting, from: Long, to: Long): Long { @@ -1485,11 +1481,11 @@ object DebriefReport { val gps = fmtCoord(GpsSample(0L, lat, lon)) val name = places.nameNear(lat, lon) return if (!name.isNullOrBlank()) { - "$name ($gps, operator phone)" + localized("debrief_report_operator_phone", "%1\$s (%2\$s, operator phone)", name, gps) } else if (places.attempted) { - "$gps (operator phone; no street name this export)" + localized("debrief_report_operator_phone_no_street_name_this_export", "%1\$s (operator phone; no street name this export)", gps) } else { - "$gps (operator phone)" + localized("debrief_report_operator_phone_2", "%1\$s (operator phone)", gps) } } @@ -1497,6 +1493,8 @@ object DebriefReport { val s = (ms / 1000).coerceAtLeast(0) val m = s / 60 val r = s % 60 - return if (m >= 60) "${m / 60}h${m % 60}m" else if (m > 0) "${m}m${r}s" else "${r}s" + return if (m >= 60) localized("report_duration_hours", "%1\$sh%2\$sm", m / 60, m % 60) + else if (m > 0) localized("report_duration_minutes", "%1\$sm%2\$ss", m, r) + else localized("report_duration_seconds", "%1\$ss", r) } } diff --git a/app/src/main/java/app/fieldwatch/domain/DeviceDetailPrompt.kt b/app/src/main/java/app/fieldwatch/domain/DeviceDetailPrompt.kt index 6a97387..71549e9 100644 --- a/app/src/main/java/app/fieldwatch/domain/DeviceDetailPrompt.kt +++ b/app/src/main/java/app/fieldwatch/domain/DeviceDetailPrompt.kt @@ -1,5 +1,9 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.displayLabel + +import app.fieldwatch.i18n.localized + /** * Paste-ready analyst prompt for **one** radio from the device-detail screen. * One-tap share; no Fieldwatch cloud. @@ -17,60 +21,61 @@ object DeviceDetailPrompt { mine: Boolean = false, ): String { val title = device.listTitle(signatureNames) - val kind = if (device.kind == RadioKind.WIFI) "Wi-Fi access point" else "Bluetooth LE advertiser" + val kind = if (device.kind == RadioKind.WIFI) localized("device_detail_prompt_wi_fi_access_point", "Wi-Fi access point") else localized("device_detail_prompt_bluetooth_le_advertiser", "Bluetooth LE advertiser") return buildString { append(DebriefPrompt.experimentalDisclaimerMarkdown()) appendLine() - appendLine("You are a field RF / privacy analyst with deep knowledge of IEEE OUI, Bluetooth SIG assigned numbers, GAP Appearance, known advertisement formats (iBeacon, Eddystone, Apple Continuity / Find My, Google Fast Pair, Microsoft), and common consumer products.") + appendLine(localized("ai_response_language", "Write your response in English. Preserve verbatim identifiers and observer notes.")) + appendLine(localized("device_detail_prompt_you_are_a_field_rf_privacy_analyst", "You are a field RF / privacy analyst with deep knowledge of IEEE OUI, Bluetooth SIG assigned numbers, GAP Appearance, known advertisement formats (iBeacon, Eddystone, Apple Continuity / Find My, Google Fast Pair, Microsoft), and common consumer products.")) appendLine() - appendLine("The user wants **as much information as possible** about **this one radio** from a Fieldwatch observation. Fieldwatch is a stock-Android, receive-only Wi-Fi + BLE listener. Use the dump below **and** your public knowledge of registries and formats. Cite which field or byte pattern supports each claim.") + appendLine(localized("device_detail_prompt_the_user_wants_as_much_information_as", "The user wants **as much information as possible** about **this one radio** from a Fieldwatch observation. Fieldwatch is a stock-Android, receive-only Wi-Fi + BLE listener. Use the dump below **and** your public knowledge of registries and formats. Cite which field or byte pattern supports each claim.")) appendLine() - appendLine("Constraints you must respect:") - appendLine("- This is **one** advertised radio, not a person, vehicle, or legal identity.") - appendLine("- Wi-Fi rows are **access points only**. Associated clients are invisible. Stock Android cannot promiscuously capture stations or probe requests.") - appendLine("- BLE rows are advertisers. Randomized MACs are not stable identities and will not stitch across rotations.") - appendLine("- Signature / OUI / company / UUID matches are **hypotheses**, not proof of a serial, owner, or that a tracker is present.") - appendLine("- GPS stamps (if present) are the **operator phone** at hear-time, not this radio’s location.") - appendLine("- Place names (if present) are system reverse-geocode of those stamps. Approximate.") - appendLine("- RSSI is loudness at the phone, not a measured distance.") - appendLine("- Do not invent fields that are not in the dump. If data is thin, say so and say what would help.") - appendLine("- Do not claim this radio is following anyone. Do not give safety advice.") - appendLine("- Treat this paste as operationally sensitive (MAC, SSID, payload, GPS).") + appendLine(localized("device_detail_prompt_constraints_you_must_respect", "Constraints you must respect:")) + appendLine(localized("device_detail_prompt_this_is_one_advertised_radio_not_a", "- This is **one** advertised radio, not a person, vehicle, or legal identity.")) + appendLine(localized("device_detail_prompt_wi_fi_rows_are_access_points_only", "- Wi-Fi rows are **access points only**. Associated clients are invisible. Stock Android cannot promiscuously capture stations or probe requests.")) + appendLine(localized("device_detail_prompt_ble_rows_are_advertisers_randomized_macs_are", "- BLE rows are advertisers. Randomized MACs are not stable identities and will not stitch across rotations.")) + appendLine(localized("device_detail_prompt_signature_oui_company_uuid_matches_are_hypotheses", "- Signature / OUI / company / UUID matches are **hypotheses**, not proof of a serial, owner, or that a tracker is present.")) + appendLine(localized("device_detail_prompt_gps_stamps_if_present_are_the_operator", "- GPS stamps (if present) are the **operator phone** at hear-time, not this radio’s location.")) + appendLine(localized("device_detail_prompt_place_names_if_present_are_system_reverse", "- Place names (if present) are system reverse-geocode of those stamps. Approximate.")) + appendLine(localized("device_detail_prompt_rssi_is_loudness_at_the_phone_not", "- RSSI is loudness at the phone, not a measured distance.")) + appendLine(localized("device_detail_prompt_do_not_invent_fields_that_are_not", "- Do not invent fields that are not in the dump. If data is thin, say so and say what would help.")) + appendLine(localized("device_detail_prompt_do_not_claim_this_radio_is_following", "- Do not claim this radio is following anyone. Do not give safety advice.")) + appendLine(localized("device_detail_prompt_treat_this_paste_as_operationally_sensitive_mac", "- Treat this paste as operationally sensitive (MAC, SSID, payload, GPS).")) appendLine() - appendLine("## Collection context") - appendLine("- Tool: Fieldwatch (app.fieldwatch), receive-only, no association / injection / cloud.") - appendLine("- Subject: $kind titled “$title”.") - appendLine("- Scan intensity: ${settings.intensity.name.lowercase()}. Stale after ${settings.staleSec}s. Brief hold ${settings.decaySec}s.") - appendLine("- Location tags: ${if (settings.tagLocation) "on" else "off"}.") - appendLine("- Online place names: ${if (settings.onlineLookup) "on" else "off"}.") - appendLine("- Randomized MAC flag: ${if (device.randomized) "yes" else "no"}.") - appendLine("- Hits this session: ${device.hitCount}. Gone: ${device.gone}.") + appendLine(localized("device_detail_prompt_collection_context", "## Collection context")) + appendLine(localized("device_detail_prompt_tool_fieldwatch_app_fieldwatch_receive_only_no", "- Tool: Fieldwatch (app.fieldwatch), receive-only, no association / injection / cloud.")) + appendLine(localized("device_detail_prompt_subject_titled", "- Subject: %1\$s titled “%2\$s”.", kind, title)) + appendLine(localized("device_detail_prompt_scan_intensity_stale_after_s_brief_hold", "- Scan intensity: %1\$s. Stale after %2\$ss. Brief hold %3\$ss.", settings.intensity.displayLabel(), settings.staleSec, settings.decaySec)) + appendLine(localized("device_detail_prompt_location_tags", "- Location tags: %1\$s.", if (settings.tagLocation) localized("device_detail_prompt_on", "on") else localized("device_detail_prompt_off", "off"))) + appendLine(localized("device_detail_prompt_online_place_names", "- Online place names: %1\$s.", if (settings.onlineLookup) localized("device_detail_prompt_on", "on") else localized("device_detail_prompt_off", "off"))) + appendLine(localized("device_detail_prompt_randomized_mac_flag", "- Randomized MAC flag: %1\$s.", if (device.randomized) localized("decode_boolean_yes", "yes") else localized("decode_boolean_no", "no"))) + appendLine(localized("device_detail_prompt_hits_this_session_gone", "- Hits this session: %1\$s. Gone: %2\$s.", device.hitCount, if (device.gone) localized("decode_boolean_yes", "yes") else localized("decode_boolean_no", "no"))) if (device.gpsTrail.isNotEmpty()) { - appendLine("- Operator GPS trail samples on this radio: ${device.gpsTrail.size} (phone path while it was heard).") + appendLine(localized("device_detail_prompt_operator_gps_trail_samples_on_this_radio", "- Operator GPS trail samples on this radio: %1\$s (phone path while it was heard).", device.gpsTrail.size)) } appendLine() if (places.attempted) { - appendLine("## Places (operator GPS, optional)") + appendLine(localized("device_detail_prompt_places_operator_gps_optional", "## Places (operator GPS, optional)")) appendLine(places.note) places.lines.forEach { appendLine(it) } appendLine() } - appendLine("## Observation dump (verbatim from the detail page)") + appendLine(localized("device_detail_prompt_observation_dump_verbatim_from_the_detail_page", "## Observation dump (verbatim from the detail page)")) appendLine() append(DeviceDetailText.build(device, signatureNames, now, attentionNotes, signatureNotes, fleets, mine).trimEnd()) appendLine() appendLine() - appendLine("## Your analysis (required sections)") - appendLine("1. **What it likely is** — Product class, likely brand/family, possible model. Confidence 0–100. Hedge (Most likely / Probably / Could be). List the evidence (name, OUI, company ID, Appearance, services, payload). Competing hypotheses if the data fits more than one product.") - appendLine("2. **Registry / format decode** — IEEE OUI or CID; Bluetooth SIG company; GAP Appearance; 16-bit UUIDs; iBeacon UUID/major/minor if present; Fast Pair model ID if present; Apple Continuity type if present. Quote the hex you used. If you recognize a well-known UUID or company from public lists, say so and say the list.") - appendLine("3. **What that product typically does** — Phone, tag, speaker, car, AP, camera, mesh node, accessory, etc. Typical radio behavior (always-on beacon vs intermittent).") - appendLine("4. **What Fieldwatch actually saw vs what it cannot see** — Stock Android limits (no station/probe capture, no cellular, no DF). Randomized address implications.") - appendLine("5. **Signal and presence** — Loud/quiet here; RSSI range this session; on-air windows. Do not convert RSSI to meters.") - appendLine("6. **Signature match** — If Fieldwatch matched a signature, treat it as a filter hit, not identity. Say whether the payload also supports that family. If Notes are in the dump, use them as catalog context for that family. If Extra attention is in the dump, quote it and treat it as an operator caution on a pattern, not proof — separate from Notes.") - appendLine("7. **Open questions** — What extra observation (another packet, name, GPS path, a second radio) would raise or lower confidence.") - appendLine("8. **Must not conclude** — One short list of claims the dump does **not** support (owner, following, legal ID, distance).") + appendLine(localized("device_detail_prompt_your_analysis_required_sections", "## Your analysis (required sections)")) + appendLine(localized("device_detail_prompt_1_what_it_likely_is_product_class", "1. **What it likely is** — Product class, likely brand/family, possible model. Confidence 0–100. Hedge (Most likely / Probably / Could be). List the evidence (name, OUI, company ID, Appearance, services, payload). Competing hypotheses if the data fits more than one product.")) + appendLine(localized("device_detail_prompt_2_registry_format_decode_ieee_oui_or", "2. **Registry / format decode** — IEEE OUI or CID; Bluetooth SIG company; GAP Appearance; 16-bit UUIDs; iBeacon UUID/major/minor if present; Fast Pair model ID if present; Apple Continuity type if present. Quote the hex you used. If you recognize a well-known UUID or company from public lists, say so and say the list.")) + appendLine(localized("device_detail_prompt_3_what_that_product_typically_does_phone", "3. **What that product typically does** — Phone, tag, speaker, car, AP, camera, mesh node, accessory, etc. Typical radio behavior (always-on beacon vs intermittent).")) + appendLine(localized("device_detail_prompt_4_what_fieldwatch_actually_saw_vs_what", "4. **What Fieldwatch actually saw vs what it cannot see** — Stock Android limits (no station/probe capture, no cellular, no DF). Randomized address implications.")) + appendLine(localized("device_detail_prompt_5_signal_and_presence_loud_quiet_here", "5. **Signal and presence** — Loud/quiet here; RSSI range this session; on-air windows. Do not convert RSSI to meters.")) + appendLine(localized("device_detail_prompt_6_signature_match_if_fieldwatch_matched_a", "6. **Signature match** — If Fieldwatch matched a signature, treat it as a filter hit, not identity. Say whether the payload also supports that family. If Notes are in the dump, use them as catalog context for that family. If Extra attention is in the dump, quote it and treat it as an operator caution on a pattern, not proof — separate from Notes.")) + appendLine(localized("device_detail_prompt_7_open_questions_what_extra_observation_another", "7. **Open questions** — What extra observation (another packet, name, GPS path, a second radio) would raise or lower confidence.")) + appendLine(localized("device_detail_prompt_8_must_not_conclude_one_short_list", "8. **Must not conclude** — One short list of claims the dump does **not** support (owner, following, legal ID, distance).")) appendLine() - appendLine("End with a single one-line **takeaway** (what this radio most likely is, and one thing to check next). No safety advice.") + appendLine(localized("device_detail_prompt_end_with_a_single_one_line_takeaway", "End with a single one-line **takeaway** (what this radio most likely is, and one thing to check next). No safety advice.")) } } } diff --git a/app/src/main/java/app/fieldwatch/domain/DeviceDetailText.kt b/app/src/main/java/app/fieldwatch/domain/DeviceDetailText.kt index 13e55ef..b9c1216 100644 --- a/app/src/main/java/app/fieldwatch/domain/DeviceDetailText.kt +++ b/app/src/main/java/app/fieldwatch/domain/DeviceDetailText.kt @@ -1,5 +1,12 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.codLabel +import app.fieldwatch.i18n.TextRuntime + +import app.fieldwatch.i18n.forDisplay + +import app.fieldwatch.i18n.localized + import app.fieldwatch.radio.BleAdParser import java.text.SimpleDateFormat import java.util.Date @@ -19,8 +26,8 @@ object DeviceDetailText { fleets: List = emptyList(), mine: Boolean = false, ): String { - val fmt = SimpleDateFormat("HH:mm:ss", Locale.US) - val iso = SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US) + val fmt = SimpleDateFormat("HH:mm:ss", TextRuntime.localeProvider()) + val iso = SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", TextRuntime.localeProvider()) val facts = device.facts val title = device.listTitle(signatureNames) val guess = DeviceExplain.guess(device, signatureNames) @@ -33,81 +40,80 @@ object DeviceDetailText { out.append('\n').append("## ").append(title).append('\n') } - out.append("Fieldwatch device detail\n") + out.append(localized("device_detail_text_fieldwatch_device_detail_n", "Fieldwatch device detail\n")) out.append(iso.format(Date(now))).append('\n') out.append( - "Experimental. Not a legal identity. Stock Android radios — this is what the OS " + - "exposed, not a guarantee a tracker or camera is present.\n", + localized("device_detail_text_experimental_not_a_legal_identity_stock_android", "Experimental. Not a legal identity. Stock Android radios — this is what the OS exposed, not a guarantee a tracker or camera is present.\n"), ) out.append('\n') out.append(title).append('\n') line("MAC", device.mac) - if (device.name.isNotBlank()) line("Advertised name", device.name) - if (mine) line("Marked mine", "No beep while this is on. Still listed.") + if (device.name.isNotBlank()) line(localized("device_detail_text_advertised_name", "Advertised name"), device.name) + if (mine) line(localized("device_detail_text_marked_mine", "Marked mine"), localized("device_detail_text_no_beep_while_this_is_on_still", "No beep while this is on. Still listed.")) out.append('\n') - out.append("What this looks like: ").append(guess.headline).append('\n') + out.append(localized("device_detail_text_what_this_looks_like", "What this looks like: ")).append(guess.headline).append('\n') out.append(guess.because).append('\n') if (attentionNotes.isNotEmpty()) { - section("Extra attention") + section(localized("device_detail_text_extra_attention", "Extra attention")) attentionNotes.forEach { (name, note) -> - out.append("EXTRA ATTENTION ($name): ").append(note.trim()).append('\n') + out.append(localized("device_detail_text_extra_attention_2", "EXTRA ATTENTION (%1\$s): ", name)).append(note.trim()).append('\n') } - out.append("Pattern match, not identity. Not a safety finding.\n") + out.append(localized("device_detail_text_pattern_match_not_identity_not_a_safety", "Pattern match, not identity. Not a safety finding.\n")) } if (signatureNotes.isNotEmpty()) { - section("Notes") + section(localized("device_detail_text_notes", "Notes")) signatureNotes.forEach { (name, note) -> out.append(name).append(": ").append(note.trim()).append('\n') } } - section("Identity") + section(localized("device_detail_text_identity", "Identity")) line( - "Radio", + localized("device_detail_text_radio", "Radio"), if (device.kind == RadioKind.WIFI) { - "Wi-Fi access point (beaconing a network)" + localized("device_detail_text_wi_fi_access_point_beaconing_a_network", "Wi-Fi access point (beaconing a network)") } else { - "Bluetooth Low Energy advertiser" + localized("device_detail_text_bluetooth_low_energy_advertiser", "Bluetooth Low Energy advertiser") }, ) - line("Address", DeviceExplain.addressExplain(device)) - vendorLine(device)?.let { line("Who made it", it.replace('\n', ' ')) } - ?: line("OUI (vendor prefix)", "${device.oui} — no IEEE match; randomized addresses usually have none") + line(localized("device_detail_text_address", "Address"), DeviceExplain.addressExplain(device)) + vendorLine(device)?.let { line(localized("device_detail_text_who_made_it", "Who made it"), it.replace('\n', ' ')) } + ?: line(localized("device_detail_text_oui_vendor_prefix", "OUI (vendor prefix)"), localized("device_detail_text_no_ieee_match_randomized_addresses_usually_have", "%1\$s — no IEEE match; randomized addresses usually have none", device.oui)) if (device.hiddenSsid) { - line("Network name (SSID)", "Hidden — the AP is beaconing but not publishing a name") + line(localized("device_detail_text_network_name_ssid", "Network name (SSID)"), localized("device_detail_text_hidden_the_ap_is_beaconing_but_not", "Hidden — the AP is beaconing but not publishing a name")) } - section("Signal") + section(localized("device_detail_text_signal", "Signal")) if (device.gone) { - line("How loud here (RSSI)", "Not available") + line(localized("device_detail_text_how_loud_here_rssi", "How loud here (RSSI)"), localized("device_detail_text_not_available", "Not available")) val last = Rssi.lastMeasured(device.rssi, device.rssiHistory) - line("Last heard", last?.let { "$it dBm" } ?: "Not available") + line(localized("device_detail_text_last_heard", "Last heard"), last?.let { "$it dBm" } ?: localized("device_detail_text_not_available", "Not available")) } else { - line("How loud here (RSSI)", DeviceExplain.rssiExplain(device.rssi)) - out.append("Closer to 0 dBm is louder here, not a distance.\n") + line(localized("device_detail_text_how_loud_here_rssi", "How loud here (RSSI)"), DeviceExplain.rssiExplain(device.rssi)) + out.append(localized("device_detail_text_closer_to_0_dbm_is_louder_here", "Closer to 0 dBm is louder here, not a distance.\n")) } - line("Heard range this session", Rssi.sessionRange(device.rssiMin, device.rssiMax, device.rssiHistory)) + line(localized("device_detail_text_heard_range_this_session", "Heard range this session"), Rssi.sessionRange(device.rssiMin, device.rssiMax, device.rssiHistory)) facts.txPowerDbm?.let { - line("Claimed transmit power", "$it dBm — how loud it says it transmits, not a distance") + line(localized("device_detail_text_claimed_transmit_power", "Claimed transmit power"), localized("device_detail_text_dbm_how_loud_it_says_it_transmits", "%1\$s dBm — how loud it says it transmits, not a distance", it)) } if (device.channel != 0 || device.frequencyMhz != 0) { line( - "Channel / frequency", + localized("device_detail_text_channel_frequency", "Channel / frequency"), buildString { - if (device.channel != 0) append("channel ${device.channel}") + if (device.channel != 0) append(localized("device_detail_text_channel", "channel %1\$s", device.channel)) if (device.frequencyMhz != 0) { if (isNotEmpty()) append(" · ") append("${device.frequencyMhz} MHz") } - facts.channelWidth?.let { append(" · $it wide") } + facts.channelWidth?.let { append(localized("device_detail_text_wide", " · %1\$s wide", it)) } }, ) } - facts.wifiStandard?.let { line("Wi-Fi generation", it) } + facts.wifiStandard?.let { line(localized("device_detail_text_wi_fi_generation", "Wi-Fi generation"), it) } if (facts.centerFreq0 != null || facts.centerFreq1 != null) { line( - "Center frequencies", + localized("device_detail_text_center_frequencies", "Center frequencies"), listOfNotNull( facts.centerFreq0?.let { "$it MHz" }, facts.centerFreq1?.let { "$it MHz" }, @@ -117,49 +123,49 @@ object DeviceDetailText { val rssiTail = device.rssiHistory.filter { Rssi.measured(it.rssi) }.takeLast(24) if (rssiTail.isNotEmpty()) { line( - "Recent RSSI (oldest → newest)", + localized("device_detail_text_recent_rssi_oldest_newest", "Recent RSSI (oldest → newest)"), rssiTail.joinToString(", ") { it.rssi.toString() }, ) } if (device.kind == RadioKind.BLE) { - section("Bluetooth advertisement") + section(localized("device_detail_text_bluetooth_advertisement", "Bluetooth advertisement")) facts.primaryPhy?.let { val phys = listOfNotNull(it, facts.secondaryPhy).distinct() - line("Radio PHY", phys.joinToString(" / ") { phy -> DeviceExplain.phyExplain(phy) }) + line(localized("device_detail_text_radio_phy", "Radio PHY"), phys.joinToString(" / ") { phy -> DeviceExplain.phyExplain(phy) }) } facts.connectable?.let { line( - "Connectable", - if (it) "Yes — a phone could open a BLE connection" - else "No — broadcast-only (you can hear it, not join it from this scan)", + localized("device_detail_text_connectable", "Connectable"), + if (it) localized("device_detail_text_yes_a_phone_could_open_a_ble", "Yes — a phone could open a BLE connection") + else localized("device_detail_text_no_broadcast_only_you_can_hear_it", "No — broadcast-only (you can hear it, not join it from this scan)"), ) } facts.advertisingIntervalMs?.let { - line("How often it advertises", "%.0f ms between bursts (smaller = chattier on the air)".format(it)) + line(localized("device_detail_text_how_often_it_advertises", "How often it advertises"), localized("device_detail_text_0f_ms_between_bursts_smaller_chattier_on", "%.0f ms between bursts (smaller = chattier on the air)").format(it)) } - facts.periodicIntervalMs?.let { line("Periodic advertising", "%.0f ms".format(it)) } + facts.periodicIntervalMs?.let { line(localized("device_detail_text_periodic_advertising", "Periodic advertising"), "%.0f ms".format(it)) } facts.advFlags?.let { flags -> - line("Discoverability", DeviceExplain.flagsExplain(flags)) - line("Flags (raw)", "0x%02X".format(flags)) + line(localized("device_detail_text_discoverability", "Discoverability"), DeviceExplain.flagsExplain(flags)) + line(localized("device_detail_text_flags_raw", "Flags (raw)"), "0x%02X".format(flags)) } facts.appearance?.let { value -> val name = RadioDb.appearance(value) line( - "What it says it is (Appearance)", - name ?: "Unlisted Appearance 0x%04X".format(value), + localized("device_detail_text_what_it_says_it_is_appearance", "What it says it is (Appearance)"), + name ?: localized("device_detail_text_unlisted_appearance_0x_04x", "Unlisted Appearance 0x%04X").format(value), ) - line("Appearance code", "0x%04X".format(value)) + line(localized("device_detail_text_appearance_code", "Appearance code"), "0x%04X".format(value)) } CodDecoder.decodeOrNull(facts.deviceClass)?.let { cod -> line( - "Classic Bluetooth class", + localized("device_detail_text_classic_bluetooth_class", "Classic Bluetooth class"), buildString { - append(cod.major) - if (cod.minor.isNotBlank()) append(" / ").append(cod.minor) + append(codLabel(cod.major)) + if (cod.minor.isNotBlank()) append(" / ").append(codLabel(cod.minor)) if (cod.services.isNotEmpty()) { - append(". Also offers: ") - append(cod.services.joinToString(", ")) + append(localized("device_detail_text_also_offers", ". Also offers: ")) + append(cod.services.joinToString(", ") { codLabel(it) }) } }, ) @@ -167,34 +173,34 @@ object DeviceDetailText { } if (device.kind == RadioKind.WIFI) { - section("Wi-Fi access point") + section(localized("device_detail_text_wi_fi_access_point", "Wi-Fi access point")) facts.security?.let { - line("Encryption / login", DeviceExplain.wifiSecurityExplain(it)) - if (it.isNotBlank()) line("Security string", it) + line(localized("device_detail_text_encryption_login", "Encryption / login"), DeviceExplain.wifiSecurityExplain(it)) + if (it.isNotBlank()) line(localized("device_detail_text_security_string", "Security string"), it) } - facts.supportedRates?.let { line("Supported rates", "$it Mbps (* = required basic rate)") } + facts.supportedRates?.let { line(localized("device_detail_text_supported_rates", "Supported rates"), localized("device_detail_text_mbps_required_basic_rate", "%1\$s Mbps (* = required basic rate)", it)) } facts.capabilities?.takeIf { it.isNotBlank() && it != facts.security }?.let { - line("Capability string", it) + line(localized("device_detail_text_capability_string", "Capability string"), it) } } - device.payloadAircraft?.trim()?.takeIf { it.isNotEmpty() }?.let { line("Aircraft", it) } + device.payloadAircraft?.trim()?.takeIf { it.isNotEmpty() }?.let { line(localized("device_detail_text_aircraft", "Aircraft"), it) } if (fleets.isNotEmpty() && (device.kind == RadioKind.BLE || device.kind == RadioKind.WIFI)) { - val decoded = SignatureFieldDecoder.decodeSighting(device, fleets) + val decoded = SignatureFieldDecoder.decodeSighting(device, fleets).forDisplay(fleets) if (decoded.isNotEmpty()) { - section("Decoded fields") + section(localized("device_detail_text_decoded_fields", "Decoded fields")) decoded.forEach { row -> line(row.label, row.display) - if (row.note.isNotBlank()) line("Note", row.note) + if (row.note.isNotBlank()) line(localized("device_detail_text_note", "Note"), row.note) } } } if (device.serviceUuids.isNotEmpty()) { - section("Services it offers") + section(localized("device_detail_text_services_it_offers", "Services it offers")) line( - "Service IDs", + localized("device_detail_text_service_ids", "Service IDs"), device.serviceUuids.joinToString("; ") { uuid -> DeviceExplain.uuidGloss(uuid)?.let { "$uuid · $it" } ?: uuid }, @@ -205,8 +211,8 @@ object DeviceDetailText { val named = RadioDb.serviceUuid(sd.uuid)?.let { " ($it)" } ?: "" AdvPayloadDecoder.decodeService(sd).forEach { field -> line(field.label, field.value) } line( - "Service data ${uuidShort(sd.uuid)}$named", - sd.dataHex.hexSpaced().ifBlank { "(empty)" }, + localized("device_detail_text_service_data", "Service data %1\$s%2\$s", uuidShort(sd.uuid), named), + sd.dataHex.hexSpaced().ifBlank { localized("device_detail_text_empty", "(empty)") }, ) } } @@ -217,30 +223,30 @@ object DeviceDetailText { } ?: emptyList() } if (mfg.isNotEmpty()) { - section("Maker data inside the ad") + section(localized("device_detail_text_maker_data_inside_the_ad", "Maker data inside the ad")) mfg.forEach { rec -> - val company = RadioDb.company(rec.companyId) ?: "Not in the Bluetooth company list" - line("Bluetooth company 0x%04X".format(rec.companyId), company) + val company = RadioDb.company(rec.companyId) ?: localized("device_detail_text_not_in_the_bluetooth_company_list", "Not in the Bluetooth company list") + line(localized("device_detail_text_bluetooth_company_0x_04x", "Bluetooth company 0x%04X").format(rec.companyId), company) BleAdParser.mfgDecodedFields(rec).forEach { (k, v) -> line(k, v) } if (rec.dataHex.isNotBlank()) { - line("Raw payload (${rec.dataHex.length / 2} bytes)", rec.dataHex.hexSpaced()) + line(localized("device_detail_text_raw_payload_bytes", "Raw payload (%1\$s bytes)", rec.dataHex.length / 2), rec.dataHex.hexSpaced()) } } } if (facts.vendorIes.isNotEmpty() || device.vendorIeOuis.isNotEmpty()) { - section("Wi-Fi vendor tags") + section(localized("device_detail_text_wi_fi_vendor_tags", "Wi-Fi vendor tags")) val rows = facts.vendorIes.ifEmpty { device.vendorIeOuis.map { VendorIeRecord(it, -1, "") } } rows.forEach { ie -> val org = RadioDb.vendorForOui24(ie.oui) - val type = if (ie.type >= 0) " type %d".format(ie.type) else "" + val type = if (ie.type >= 0) localized("device_detail_text_type_d", " type %d").format(ie.type) else "" line( - "Vendor OUI ${ie.oui}$type", + localized("device_detail_text_vendor_oui", "Vendor OUI %1\$s%2\$s", ie.oui, type), buildString { - append(org ?: "Unknown IEEE OUI") - append(" — extra AP information element, not the SSID.") + append(org ?: localized("device_detail_text_unknown_ieee_oui", "Unknown IEEE OUI")) + append(localized("device_detail_text_extra_ap_information_element_not_the_ssid", " — extra AP information element, not the SSID.")) if (ie.dataHex.isNotBlank()) { append(" ") append(ie.dataHex.hexSpaced()) @@ -250,35 +256,35 @@ object DeviceDetailText { } } - section("Session") - line("First seen", fmt.format(Date(device.firstSeen))) - line("Last seen", fmt.format(Date(device.lastSeen))) - line("Hits", device.hitCount.toString()) + section(localized("device_detail_text_session", "Session")) + line(localized("device_detail_text_first_seen", "First seen"), fmt.format(Date(device.firstSeen))) + line(localized("device_detail_text_last_seen", "Last seen"), fmt.format(Date(device.lastSeen))) + line(localized("device_detail_text_hits", "Hits"), device.hitCount.toString()) Geo.screenCoord(device.latitude, device.longitude, false)?.let { - line("Last fix", it) - out.append("Last fix is the phone’s GPS at hear-time, not a fix on this radio.\n") + line(localized("device_detail_text_last_fix", "Last fix"), it) + out.append(localized("device_detail_text_last_fix_is_the_phone_s_gps", "Last fix is the phone’s GPS at hear-time, not a fix on this radio.\n")) } if (device.fleetIds.isNotEmpty()) { - line("Matched signatures", signatureNames.joinToString("; ").ifBlank { + line(localized("device_detail_text_matched_signatures", "Matched signatures"), signatureNames.joinToString("; ").ifBlank { device.fleetIds.joinToString("; ") }) } if (device.rawHex.isNotBlank() && device.kind == RadioKind.BLE) { - line("Raw advertisement", device.rawHex.hexSpaced()) + line(localized("device_detail_text_raw_advertisement", "Raw advertisement"), device.rawHex.hexSpaced()) } - presenceLine(device, now, fmt)?.let { line("Presence (15 min)", it) } + presenceLine(device, now, fmt)?.let { line(localized("device_detail_text_presence_15_min", "Presence (15 min)"), it) } return out.toString().trimEnd() + "\n" } private fun vendorLine(device: Sighting): String? { val parts = ArrayList(3) device.vendor?.let { - parts += "IEEE board/chip vendor: $it (${device.oui}). This is who owns the MAC prefix, not always the product brand." + parts += localized("device_detail_text_ieee_board_chip_vendor_this_is_who", "IEEE board/chip vendor: %1\$s (%2\$s). This is who owns the MAC prefix, not always the product brand.", it, device.oui) } val mfgId = device.facts.mfgRecords.firstOrNull()?.companyId ?: device.manufacturerId if (mfgId != null) { val company = RadioDb.company(mfgId) - parts += "Bluetooth company in the ad: ${company ?: "unlisted"} (0x%04X).".format(mfgId) + parts += localized("device_detail_text_bluetooth_company_in_the_ad_0x_04x", "Bluetooth company in the ad: %1\$s (0x%%04X).", company ?: localized("device_detail_text_unlisted", "unlisted")).format(mfgId) } return parts.joinToString(" ").ifBlank { null } } @@ -295,7 +301,7 @@ object DeviceDetailText { if (spans.isEmpty()) return null return spans.joinToString("; ") { span -> val start = fmt.format(Date(span.start.coerceAtLeast(from))) - val end = span.end?.let { fmt.format(Date(it)) } ?: "now" + val end = span.end?.let { fmt.format(Date(it)) } ?: localized("device_detail_text_now", "now") "$start–$end" } } diff --git a/app/src/main/java/app/fieldwatch/domain/DeviceExplain.kt b/app/src/main/java/app/fieldwatch/domain/DeviceExplain.kt index f1ba351..081f781 100644 --- a/app/src/main/java/app/fieldwatch/domain/DeviceExplain.kt +++ b/app/src/main/java/app/fieldwatch/domain/DeviceExplain.kt @@ -1,5 +1,9 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.codLabel + +import app.fieldwatch.i18n.localized + /** * Plain-language decode of advertised identity. Guesses are what the radio * is broadcasting, not a visual identification. @@ -9,6 +13,8 @@ object DeviceExplain { val headline: String, val because: String, val confidence: Confidence, + /** Semantic row label; never derive classification from a translated headline. */ + val listCore: String? = null, ) enum class Confidence { HIGH, MEDIUM, LOW } @@ -28,12 +34,11 @@ object DeviceExplain { if (hints.isEmpty()) { return Guess( headline = if (device.kind == RadioKind.WIFI) { - "Wi-Fi access point" + localized("device_explain_wi_fi_access_point", "Wi-Fi access point") } else { - "Bluetooth LE advertiser" + localized("device_explain_bluetooth_le_advertiser", "Bluetooth LE advertiser") }, - because = "It is on the air, but it did not advertise a product class " + - "(no Appearance, Class of Device, or well-known service that names a type).", + because = localized("device_explain_it_is_on_the_air_but_it", "It is on the air, but it did not advertise a product class (no Appearance, Class of Device, or well-known service that names a type)."), confidence = Confidence.LOW, ) } @@ -54,15 +59,16 @@ object DeviceExplain { else -> Confidence.LOW } val hedge = when (confidence) { - Confidence.HIGH -> "Most likely" - Confidence.MEDIUM -> "Probably" - Confidence.LOW -> "Could be" + Confidence.HIGH -> localized("device_explain_most_likely", "Most likely") + Confidence.MEDIUM -> localized("device_explain_probably", "Probably") + Confidence.LOW -> localized("device_explain_could_be", "Could be") } return Guess( - headline = "$hedge ${best.label}", + headline = localized("device_explain_", "%1\$s %2\$s", hedge, best.label), because = support.joinToString(" ") + - " This is what the device is advertising, not a visual ID.", + localized("device_explain_this_is_what_the_device_is_advertising", " This is what the device is advertising, not a visual ID."), confidence = confidence, + listCore = if (best.generic) null else tidyHeadline(best.label), ) } @@ -72,9 +78,7 @@ object DeviceExplain { */ fun listLabel(device: Sighting, signatureNames: List = emptyList()): String? { val guess = guess(device, signatureNames) - val generic = guess.headline.contains("Bluetooth LE advertiser", ignoreCase = true) || - guess.headline.contains("Wi-Fi access point", ignoreCase = true) - val core = if (generic) null else tidyHeadline(guess.headline) + val core = guess.listCore val vendor = device.vendor?.trim()?.takeIf { it.isNotBlank() && it.length <= 24 } if (core != null) { return if (vendor != null && !core.contains(vendor, ignoreCase = true)) { @@ -83,7 +87,7 @@ object DeviceExplain { core } } - if (vendor != null) return "$vendor device" + if (vendor != null) return localized("device_explain_device", "%1\$s device", vendor) return null } @@ -101,25 +105,25 @@ object DeviceExplain { fun flagsExplain(flags: Int): String = buildList { if (flags and 0x01 != 0) { - add("Limited-discoverable: briefly looking for a nearby connection.") + add(localized("device_explain_limited_discoverable_briefly_looking_for_a_nearby", "Limited-discoverable: briefly looking for a nearby connection.")) } if (flags and 0x02 != 0) { - add("Discoverable: other BLE devices can find it.") + add(localized("device_explain_discoverable_other_ble_devices_can_find_it", "Discoverable: other BLE devices can find it.")) } if (flags and 0x04 != 0) { - add("BLE-only: no classic Bluetooth (headsets/file-send radio).") + add(localized("device_explain_ble_only_no_classic_bluetooth_headsets_file", "BLE-only: no classic Bluetooth (headsets/file-send radio).")) } else { - add("May also do classic Bluetooth (BR/EDR) as well as BLE.") + add(localized("device_explain_may_also_do_classic_bluetooth_br_edr", "May also do classic Bluetooth (BR/EDR) as well as BLE.")) } if (flags and 0x08 != 0 || flags and 0x10 != 0) { - add("Dual-mode chip: BLE and classic can run together.") + add(localized("device_explain_dual_mode_chip_ble_and_classic_can", "Dual-mode chip: BLE and classic can run together.")) } }.joinToString(" ") fun phyExplain(label: String): String = when { - label.contains("Coded") -> "$label — long-range BLE (slower, farther)" - label.contains("2M") -> "$label — faster BLE (Bluetooth 5)" - label.contains("1M") -> "$label — standard BLE radio" + label.contains("Coded") -> localized("device_explain_long_range_ble_slower_farther", "%1\$s — long-range BLE (slower, farther)", label) + label.contains("2M") -> localized("device_explain_faster_ble_bluetooth_5", "%1\$s — faster BLE (Bluetooth 5)", label) + label.contains("1M") -> localized("device_explain_standard_ble_radio", "%1\$s — standard BLE radio", label) else -> label } @@ -127,53 +131,53 @@ object DeviceExplain { val type = device.facts.addressType return when { device.kind == RadioKind.WIFI && device.randomized -> - "Locally administered BSSID. Vehicle, mesh, and guest APs often keep this address. Not a rotating phone MAC." + localized("device_explain_locally_administered_bssid_vehicle_mesh_and_guest", "Locally administered BSSID. Vehicle, mesh, and guest APs often keep this address. Not a rotating phone MAC.") MacUtil.isLocallyAdministered(device.mac) && !device.randomized -> - "Locally administered address. The local bit is set, so this is not an IEEE factory assignment." + localized("device_explain_locally_administered_address_the_local_bit_is", "Locally administered address. The local bit is set, so this is not an IEEE factory assignment.") type.equals("Public", true) && !device.randomized -> - "Public factory address (stable, IEEE-assigned)." + localized("device_explain_public_factory_address_stable_ieee_assigned", "Public factory address (stable, IEEE-assigned).") type.equals("Random", true) || device.randomized -> - "Random / privacy address. The MAC can change, so this is not a lasting identity." + localized("device_explain_random_privacy_address_the_mac_can_change", "Random / privacy address. The MAC can change, so this is not a lasting identity.") type.equals("Anonymous", true) -> - "Anonymous: the stack hid the address." + localized("device_explain_anonymous_the_stack_hid_the_address", "Anonymous: the stack hid the address.") else -> - listOfNotNull(type, "Universal IEEE address (stable OUI).").joinToString(" · ") + listOfNotNull(type, localized("device_explain_universal_ieee_address_stable_oui", "Universal IEEE address (stable OUI).")).joinToString(" · ") } } fun rssiBand(rssi: Int): String = when { - !Rssi.measured(rssi) -> "not available" - rssi >= -45 -> "very strong" - rssi >= -60 -> "strong" - rssi >= -75 -> "medium" - rssi >= -88 -> "weak" - else -> "very weak" + !Rssi.measured(rssi) -> localized("device_explain_not_available_2", "not available") + rssi >= -45 -> localized("device_explain_very_strong", "very strong") + rssi >= -60 -> localized("device_explain_strong", "strong") + rssi >= -75 -> localized("device_explain_medium", "medium") + rssi >= -88 -> localized("device_explain_weak", "weak") + else -> localized("device_explain_very_weak", "very weak") } fun rssiExplain(rssi: Int): String = - if (!Rssi.measured(rssi)) "Not available" + if (!Rssi.measured(rssi)) localized("device_explain_not_available", "Not available") else "%d dBm · %s".format(rssi, rssiBand(rssi)) fun wifiSecurityExplain(raw: String): String { val bits = ArrayList(4) val u = raw.uppercase() when { - "SAE" in u || "WPA3" in u -> bits += "WPA3 password (SAE handshake)" - "OWE" in u -> bits += "Enhanced Open (encrypted, no password)" - "PSK" in u && "WPA2" in u -> bits += "WPA2 password (PSK)" - "PSK" in u || "WPA" in u -> bits += "Wi-Fi password (WPA/PSK)" - "802.1X" in u || "EAP" in u -> bits += "Enterprise login (802.1X)" - "WEP" in u -> bits += "WEP (old, weak)" - "ESS" in u && bits.isEmpty() -> bits += "Open or encryption not parsed" + "SAE" in u || "WPA3" in u -> bits += localized("device_explain_wpa3_password_sae_handshake", "WPA3 password (SAE handshake)") + "OWE" in u -> bits += localized("device_explain_enhanced_open_encrypted_no_password", "Enhanced Open (encrypted, no password)") + "PSK" in u && "WPA2" in u -> bits += localized("device_explain_wpa2_password_psk", "WPA2 password (PSK)") + "PSK" in u || "WPA" in u -> bits += localized("device_explain_wi_fi_password_wpa_psk", "Wi-Fi password (WPA/PSK)") + "802.1X" in u || "EAP" in u -> bits += localized("device_explain_enterprise_login_802_1x", "Enterprise login (802.1X)") + "WEP" in u -> bits += localized("device_explain_wep_old_weak", "WEP (old, weak)") + "ESS" in u && bits.isEmpty() -> bits += localized("device_explain_open_or_encryption_not_parsed", "Open or encryption not parsed") } when { - "CCMP" in u || "GCMP" in u -> bits += "AES encryption" - "TKIP" in u -> bits += "TKIP (older, weaker cipher)" + "CCMP" in u || "GCMP" in u -> bits += localized("device_explain_aes_encryption", "AES encryption") + "TKIP" in u -> bits += localized("device_explain_tkip_older_weaker_cipher", "TKIP (older, weaker cipher)") } - if ("WPS" in u) bits += "WPS setup is enabled" - if ("MESH" in u) bits += "mesh node" - if ("IBSS" in u) bits += "ad-hoc network" - if ("ESS" in u) bits += "infrastructure access point" + if ("WPS" in u) bits += localized("device_explain_wps_setup_is_enabled", "WPS setup is enabled") + if ("MESH" in u) bits += localized("device_explain_mesh_node", "mesh node") + if ("IBSS" in u) bits += localized("device_explain_ad_hoc_network", "ad-hoc network") + if ("ESS" in u) bits += localized("device_explain_infrastructure_access_point", "infrastructure access point") return if (bits.isEmpty()) raw else bits.distinct().joinToString(". ") + "." } @@ -181,29 +185,29 @@ object DeviceExplain { val name = RadioDb.serviceUuid(uuid) val short = uuid16(uuid) ?: return name val extra = when (short) { - 0x1800 -> "connection basics" - 0x1801 -> "attribute protocol" - 0x180A -> "model / serial / firmware" - 0x180F -> "battery level" - 0x1812 -> "keyboard, mouse, or gamepad" - 0x180D -> "heart-rate sensor" - 0x1810 -> "blood-pressure sensor" - 0x181A -> "temperature / humidity style sensor" - 0x1844, 0x1845, 0x1846 -> "LE cycling power/speed" + 0x1800 -> localized("device_explain_connection_basics", "connection basics") + 0x1801 -> localized("device_explain_attribute_protocol", "attribute protocol") + 0x180A -> localized("device_explain_model_serial_firmware", "model / serial / firmware") + 0x180F -> localized("device_explain_battery_level", "battery level") + 0x1812 -> localized("device_explain_keyboard_mouse_or_gamepad", "keyboard, mouse, or gamepad") + 0x180D -> localized("device_explain_heart_rate_sensor", "heart-rate sensor") + 0x1810 -> localized("device_explain_blood_pressure_sensor", "blood-pressure sensor") + 0x181A -> localized("device_explain_temperature_humidity_style_sensor", "temperature / humidity style sensor") + 0x1844, 0x1845, 0x1846 -> localized("device_explain_le_cycling_power_speed", "LE cycling power/speed") 0x1850, 0x184E, 0x184F -> "LE Audio" - 0xFE2C -> "Google Fast Pair (often buds/speakers)" + 0xFE2C -> localized("device_explain_google_fast_pair_often_buds_speakers", "Google Fast Pair (often buds/speakers)") 0xFD5A -> "Samsung SmartTag" - 0xFD44 -> "Apple Find My related" - 0xFEED, 0xFEDD -> "Tile tracker" + 0xFD44 -> localized("device_explain_apple_find_my_related", "Apple Find My related") + 0xFEED, 0xFEDD -> localized("device_explain_tile_tracker", "Tile tracker") 0xFD50 -> "Tuya IoT" 0xFEBE, 0xFE21 -> "Bose" - 0xFE78 -> "HP printer" - 0xFE07 -> "Sonos speaker" + 0xFE78 -> localized("device_explain_hp_printer", "HP printer") + 0xFE07 -> localized("device_explain_sonos_speaker", "Sonos speaker") 0xFEAF, 0xFEB0 -> "Nest Weave" 0xFCBF -> "ASSA ABLOY Opening Solutions" - 0xFE24 -> "August Home lock" + 0xFE24 -> localized("device_explain_august_home_lock", "August Home lock") 0xFCF4 -> "Allegion / Schlage" - 0xFCB2 -> "Apple (not ASSA ABLOY)" + 0xFCB2 -> localized("device_explain_apple_not_assa_abloy", "Apple (not ASSA ABLOY)") else -> null } return when { @@ -219,6 +223,7 @@ object DeviceExplain { val label: String, val reason: String, val weight: Int, + val generic: Boolean = false, ) private fun appearanceHint(name: String?): Hint? { @@ -226,28 +231,28 @@ object DeviceExplain { val n = name.lowercase() val (bucket, label, w) = when { "ear" in n || "headphone" in n || "headset" in n || "hearable" in n || "hearing" in n -> - Triple("audio-personal", "earbuds or headphones", 7) + Triple("audio-personal", localized("device_explain_earbuds_or_headphones", "earbuds or headphones"), 7) "speaker" in n || "loudspeaker" in n || "hifi" in n -> - Triple("audio-speaker", "a speaker", 7) - "mouse" in n -> Triple("mouse", "a mouse", 8) - "keyboard" in n -> Triple("keyboard", "a keyboard", 8) - "gamepad" in n || "joystick" in n -> Triple("gamepad", "a game controller", 7) - "watch" in n -> Triple("watch", "a watch or wrist wearable", 7) - "phone" in n -> Triple("phone", "a phone", 6) + Triple("audio-speaker", localized("device_explain_a_speaker", "a speaker"), 7) + "mouse" in n -> Triple("mouse", localized("device_explain_a_mouse", "a mouse"), 8) + "keyboard" in n -> Triple("keyboard", localized("device_explain_a_keyboard", "a keyboard"), 8) + "gamepad" in n || "joystick" in n -> Triple("gamepad", localized("device_explain_a_game_controller", "a game controller"), 7) + "watch" in n -> Triple("watch", localized("device_explain_a_watch_or_wrist_wearable", "a watch or wrist wearable"), 7) + "phone" in n -> Triple("phone", localized("device_explain_a_phone", "a phone"), 6) "laptop" in n || "computer" in n || "desktop" in n || "tablet" in n -> - Triple("computer", "a computer or tablet", 6) - "tag" in n || "keyring" in n -> Triple("tag", "a finder tag / tracker", 6) - "remote" in n -> Triple("remote", "a remote control", 6) - "hid" in n -> Triple("hid", "an input device (keyboard, mouse, or similar)", 4) - "heart" in n -> Triple("health", "a heart-rate monitor", 7) + Triple("computer", localized("device_explain_a_computer_or_tablet", "a computer or tablet"), 6) + "tag" in n || "keyring" in n -> Triple("tag", localized("device_explain_a_finder_tag_tracker", "a finder tag / tracker"), 6) + "remote" in n -> Triple("remote", localized("device_explain_a_remote_control", "a remote control"), 6) + "hid" in n -> Triple("hid", localized("device_explain_an_input_device_keyboard_mouse_or_similar", "an input device (keyboard, mouse, or similar)"), 4) + "heart" in n -> Triple("health", localized("device_explain_a_heart_rate_monitor", "a heart-rate monitor"), 7) "glucose" in n || "oximeter" in n || "blood pressure" in n || "thermometer" in n -> - Triple("health", "a health sensor", 6) - "display" in n || "monitor" in n -> Triple("display", "a display or TV stick", 4) - "clock" in n -> Triple("clock", "a clock", 5) - "glasses" in n -> Triple("glasses", "smart glasses", 6) + Triple("health", localized("device_explain_a_health_sensor", "a health sensor"), 6) + "display" in n || "monitor" in n -> Triple("display", localized("device_explain_a_display_or_tv_stick", "a display or TV stick"), 4) + "clock" in n -> Triple("clock", localized("device_explain_a_clock", "a clock"), 5) + "glasses" in n -> Triple("glasses", localized("device_explain_smart_glasses", "smart glasses"), 6) else -> Triple("other", name, 3) } - return Hint(bucket, label, "It advertises Appearance as $name.", w) + return Hint(bucket, label, localized("device_explain_it_advertises_appearance_as", "It advertises Appearance as %1\$s.", name), w) } private fun codHint(cod: CodDecoder.Decoded): Hint? { @@ -255,30 +260,30 @@ object DeviceExplain { val major = cod.major.lowercase() val (bucket, label, w) = when { "headphone" in minor || "headset" in minor || "hands-free" in minor -> - Triple("audio-personal", "earbuds or a headset", 6) + Triple("audio-personal", localized("device_explain_earbuds_or_a_headset", "earbuds or a headset"), 6) "loudspeaker" in minor || "portable audio" in minor || "hifi" in minor || "car audio" in minor -> - Triple("audio-speaker", "a speaker", 6) - "pointing" in minor || minor == "mouse" -> Triple("mouse", "a mouse", 7) - "keyboard" in minor -> Triple("keyboard", "a keyboard", 7) - "gamepad" in minor || "joystick" in minor -> Triple("gamepad", "a game controller", 6) + Triple("audio-speaker", localized("device_explain_a_speaker", "a speaker"), 6) + "pointing" in minor || minor == "mouse" -> Triple("mouse", localized("device_explain_a_mouse", "a mouse"), 7) + "keyboard" in minor -> Triple("keyboard", localized("device_explain_a_keyboard", "a keyboard"), 7) + "gamepad" in minor || "joystick" in minor -> Triple("gamepad", localized("device_explain_a_game_controller", "a game controller"), 6) "smartphone" in minor || (major == "phone" && "uncategorized" !in minor) -> - Triple("phone", "a phone", 5) + Triple("phone", localized("device_explain_a_phone", "a phone"), 5) "laptop" in minor || "tablet" in minor || "desktop" in minor -> - Triple("computer", "a computer", 5) - "wristwatch" in minor -> Triple("watch", "a watch", 6) + Triple("computer", localized("device_explain_a_computer", "a computer"), 5) + "wristwatch" in minor -> Triple("watch", localized("device_explain_a_watch", "a watch"), 6) "heart" in minor || "pulse" in minor || "glucose" in minor || "oximeter" in minor -> - Triple("health", "a health sensor", 6) - "audio" in major -> Triple("audio-personal", "an audio device", 3) - "peripheral" in major -> Triple("hid", "an input accessory", 3) + Triple("health", localized("device_explain_a_health_sensor", "a health sensor"), 6) + "audio" in major -> Triple("audio-personal", localized("device_explain_an_audio_device", "an audio device"), 3) + "peripheral" in major -> Triple("hid", localized("device_explain_an_input_accessory", "an input accessory"), 3) "uncategorized" in major || "miscellaneous" in major -> return null else -> return null } val shown = if (cod.minor.isNotBlank() && cod.minor != "Uncategorized") { - "${cod.major} / ${cod.minor}" + "${codLabel(cod.major)} / ${codLabel(cod.minor)}" } else { - cod.major + codLabel(cod.major) } - return Hint(bucket, label, "Class of Device says $shown.", w) + return Hint(bucket, label, localized("device_explain_class_of_device_says", "Class of Device says %1\$s.", shown), w) } private fun uuidHints(uuids: List): List { @@ -286,18 +291,18 @@ object DeviceExplain { for (uuid in uuids) { val id = uuid16(uuid) ?: continue when (id) { - 0x1812 -> out += Hint("hid", "a keyboard, mouse, or gamepad", "It offers the HID (human-interface) service.", 5) + 0x1812 -> out += Hint("hid", localized("device_explain_a_keyboard_mouse_or_gamepad", "a keyboard, mouse, or gamepad"), localized("device_explain_it_offers_the_hid_human_interface_service", "It offers the HID (human-interface) service."), 5) 0x1108, 0x1112, 0x111E, 0x110B, 0x110A, 0x1131, 0x1203 -> - out += Hint("audio-personal", "headphones, a headset, or a speaker", "It offers a classic audio / headset service.", 5) + out += Hint("audio-personal", localized("device_explain_headphones_a_headset_or_a_speaker", "headphones, a headset, or a speaker"), localized("device_explain_it_offers_a_classic_audio_headset_service", "It offers a classic audio / headset service."), 5) 0x184E, 0x184F, 0x1850, 0x1851 -> - out += Hint("audio-personal", "LE Audio earbuds or a speaker", "It offers Bluetooth LE Audio services.", 6) - 0x180D -> out += Hint("health", "a heart-rate monitor", "It offers the Heart Rate service.", 6) - 0x1810 -> out += Hint("health", "a blood-pressure monitor", "It offers the Blood Pressure service.", 6) - 0x181A -> out += Hint("sensor", "an environmental sensor", "It offers Environmental Sensing.", 4) - 0xFE2C -> out += Hint("audio-personal", "earbuds or a speaker", "Google Fast Pair is present (common on buds and speakers).", 4) - 0xFD5A -> out += Hint("tag", "a Samsung SmartTag", "SmartTag service UUID.", 7) - 0xFD44 -> out += Hint("tag", "an Apple Find My accessory", "Find My related UUID.", 6) - 0xFEED, 0xFEDD -> out += Hint("tag", "a Tile tracker", "Tile service UUID.", 7) + out += Hint("audio-personal", localized("device_explain_le_audio_earbuds_or_a_speaker", "LE Audio earbuds or a speaker"), localized("device_explain_it_offers_bluetooth_le_audio_services", "It offers Bluetooth LE Audio services."), 6) + 0x180D -> out += Hint("health", localized("device_explain_a_heart_rate_monitor", "a heart-rate monitor"), localized("device_explain_it_offers_the_heart_rate_service", "It offers the Heart Rate service."), 6) + 0x1810 -> out += Hint("health", localized("device_explain_a_blood_pressure_monitor", "a blood-pressure monitor"), localized("device_explain_it_offers_the_blood_pressure_service", "It offers the Blood Pressure service."), 6) + 0x181A -> out += Hint("sensor", localized("device_explain_an_environmental_sensor", "an environmental sensor"), localized("device_explain_it_offers_environmental_sensing", "It offers Environmental Sensing."), 4) + 0xFE2C -> out += Hint("audio-personal", localized("device_explain_earbuds_or_a_speaker", "earbuds or a speaker"), localized("device_explain_google_fast_pair_is_present_common_on", "Google Fast Pair is present (common on buds and speakers)."), 4) + 0xFD5A -> out += Hint("tag", localized("device_explain_a_samsung_smarttag", "a Samsung SmartTag"), localized("device_explain_smarttag_service_uuid", "SmartTag service UUID."), 7) + 0xFD44 -> out += Hint("tag", localized("device_explain_an_apple_find_my_accessory", "an Apple Find My accessory"), localized("device_explain_find_my_related_uuid", "Find My related UUID."), 6) + 0xFEED, 0xFEDD -> out += Hint("tag", localized("device_explain_a_tile_tracker", "a Tile tracker"), localized("device_explain_tile_service_uuid", "Tile service UUID."), 7) } } return out @@ -312,408 +317,408 @@ object DeviceExplain { Hint( "tag", when { - "dult" in n -> "a DULT finder tag" - "find hub" in n -> "a Google Find Hub tag" - else -> "an Apple AirTag / Find My tag" + "dult" in n -> localized("device_explain_a_dult_finder_tag", "a DULT finder tag") + "find hub" in n -> localized("device_explain_a_google_find_hub_tag", "a Google Find Hub tag") + else -> localized("device_explain_an_apple_airtag_find_my_tag", "an Apple AirTag / Find My tag") }, - "Matched signature $raw.", + localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8, ) "apple device" in n -> - Hint("phone", "an iPhone, iPad, or Mac", "Matched signature $raw.", 7) + Hint("phone", localized("device_explain_an_iphone_ipad_or_mac", "an iPhone, iPad, or Mac"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) "apple audio" in n -> - Hint("audio-personal", "AirPods, Beats, or AirPlay", "Matched signature $raw.", 7) + Hint("audio-personal", localized("device_explain_airpods_beats_or_airplay", "AirPods, Beats, or AirPlay"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) "microsoft" in n -> - Hint("computer", "a Windows / Surface / Xbox radio", "Matched signature $raw.", 6) + Hint("computer", localized("device_explain_a_windows_surface_xbox_radio", "a Windows / Surface / Xbox radio"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "tesla tstpms" -> - Hint("vehicle", "a Tesla BLE tire sensor", "Matched signature $raw.", 7) + Hint("vehicle", localized("device_explain_a_tesla_ble_tire_sensor", "a Tesla BLE tire sensor"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) "tpms" in n || n == "tirecheck" || n == "sytpms" -> - Hint("vehicle", "a BLE tire-pressure sensor", "Matched signature $raw.", 7) + Hint("vehicle", localized("device_explain_a_ble_tire_pressure_sensor", "a BLE tire-pressure sensor"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "vuzix" -> - Hint("glasses", "Vuzix smart glasses", "Matched signature $raw.", 7) + Hint("glasses", localized("device_explain_vuzix_smart_glasses", "Vuzix smart glasses"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "tesla" -> - Hint("vehicle", "a Tesla vehicle (including Cybertruck) or phone-as-key", "Matched signature $raw.", 7) + Hint("vehicle", localized("device_explain_a_tesla_vehicle_including_cybertruck_or_phone", "a Tesla vehicle (including Cybertruck) or phone-as-key"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "google" -> - Hint("phone", "a Pixel or other Google radio", "Matched signature $raw.", 6) + Hint("phone", localized("device_explain_a_pixel_or_other_google_radio", "a Pixel or other Google radio"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "sony" -> - Hint("audio-personal", "Sony headphones, a TV, or a camera", "Matched signature $raw.", 6) + Hint("audio-personal", localized("device_explain_sony_headphones_a_tv_or_a_camera", "Sony headphones, a TV, or a camera"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "bose" -> - Hint("audio-personal", "Bose headphones or a speaker", "Matched signature $raw.", 7) + Hint("audio-personal", localized("device_explain_bose_headphones_or_a_speaker", "Bose headphones or a speaker"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "garmin" -> - Hint("watch", "a Garmin watch or inReach", "Matched signature $raw.", 7) + Hint("watch", localized("device_explain_a_garmin_watch_or_inreach", "a Garmin watch or inReach"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "amazon" -> - Hint("speaker", "an Echo, Fire, or other Amazon radio", "Matched signature $raw.", 6) + Hint("speaker", localized("device_explain_an_echo_fire_or_other_amazon_radio", "an Echo, Fire, or other Amazon radio"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "fitbit" -> - Hint("watch", "a Fitbit", "Matched signature $raw.", 7) + Hint("watch", localized("device_explain_a_fitbit", "a Fitbit"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "oura" -> - Hint("wearable", "an Oura ring", "Matched signature $raw.", 7) + Hint("wearable", localized("device_explain_an_oura_ring", "an Oura ring"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "logitech" -> - Hint("hid", "a Logitech mouse, keyboard, or webcam", "Matched signature $raw.", 6) + Hint("hid", localized("device_explain_a_logitech_mouse_keyboard_or_webcam", "a Logitech mouse, keyboard, or webcam"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) "jbl" in n || n == "harman" -> - Hint("audio-personal", "JBL or Harman audio", "Matched signature $raw.", 6) + Hint("audio-personal", localized("device_explain_jbl_or_harman_audio", "JBL or Harman audio"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "sonos" -> - Hint("audio-speaker", "a Sonos speaker", "Matched signature $raw.", 7) + Hint("audio-speaker", localized("device_explain_a_sonos_speaker", "a Sonos speaker"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "gopro" -> - Hint("camera", "a GoPro", "Matched signature $raw.", 7) + Hint("camera", localized("device_explain_a_gopro", "a GoPro"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "osmo" -> - Hint("camera", "a DJI Osmo action camera", "Matched signature $raw.", 7) + Hint("camera", localized("device_explain_a_dji_osmo_action_camera", "a DJI Osmo action camera"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "insta360" -> - Hint("camera", "an Insta360 camera", "Matched signature $raw.", 7) + Hint("camera", localized("device_explain_an_insta360_camera", "an Insta360 camera"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "dji" -> - Hint("drone", "a DJI drone or controller", "Matched signature $raw.", 7) + Hint("drone", localized("device_explain_a_dji_drone_or_controller", "a DJI drone or controller"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "remote id" -> - Hint("drone", "a drone broadcasting ASTM Remote ID", "Matched signature $raw.", 8) + Hint("drone", localized("device_explain_a_drone_broadcasting_astm_remote_id", "a drone broadcasting ASTM Remote ID"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) n == "skydio" -> - Hint("drone", "a Skydio drone", "Matched signature $raw.", 7) + Hint("drone", localized("device_explain_a_skydio_drone", "a Skydio drone"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "autel" -> - Hint("drone", "an Autel drone", "Matched signature $raw.", 7) + Hint("drone", localized("device_explain_an_autel_drone", "an Autel drone"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "parrot" -> - Hint("drone", "a Parrot ANAFI or Bebop drone", "Matched signature $raw.", 7) + Hint("drone", localized("device_explain_a_parrot_anafi_or_bebop_drone", "a Parrot ANAFI or Bebop drone"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "hoverair" -> - Hint("drone", "a HOVERAir flying camera", "Matched signature $raw.", 7) + Hint("drone", localized("device_explain_a_hoverair_flying_camera", "a HOVERAir flying camera"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "netgear" || n == "orbi" -> - Hint("ap", "a NETGEAR or Orbi access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_netgear_or_orbi_access_point", "a NETGEAR or Orbi access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "tp-link" -> - Hint("ap", "a TP-Link access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_tp_link_access_point", "a TP-Link access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "asus" -> - Hint("ap", "an ASUS access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_an_asus_access_point", "an ASUS access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "linksys" -> - Hint("ap", "a Linksys or Velop access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_linksys_or_velop_access_point", "a Linksys or Velop access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "eero" -> - Hint("ap", "an Eero mesh node", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_an_eero_mesh_node", "an Eero mesh node"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "google wifi" -> - Hint("ap", "a Google Wifi or Nest Wifi point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_google_wifi_or_nest_wifi_point", "a Google Wifi or Nest Wifi point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "d-link" -> - Hint("ap", "a D-Link access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_d_link_access_point", "a D-Link access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "belkin" -> - Hint("ap", "a Belkin access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_belkin_access_point", "a Belkin access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "xfinity" -> - Hint("ap", "an Xfinity gateway or hotspot", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_an_xfinity_gateway_or_hotspot", "an Xfinity gateway or hotspot"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "spectrum" -> - Hint("ap", "a Spectrum gateway or Spectrum Mobile hotspot", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_spectrum_gateway_or_spectrum_mobile_hotspot", "a Spectrum gateway or Spectrum Mobile hotspot"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "at&t" -> - Hint("ap", "an AT&T gateway or attwifi hotspot", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_an_at_t_gateway_or_attwifi_hotspot", "an AT&T gateway or attwifi hotspot"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "verizon" -> - Hint("ap", "a Verizon or Fios gateway", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_verizon_or_fios_gateway", "a Verizon or Fios gateway"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "starlink" -> - Hint("ap", "a Starlink router", "Matched signature $raw.", 7) + Hint("ap", localized("device_explain_a_starlink_router", "a Starlink router"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "meraki" -> - Hint("ap", "a Cisco Meraki access point", "Matched signature $raw.", 7) + Hint("ap", localized("device_explain_a_cisco_meraki_access_point", "a Cisco Meraki access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "cisco" -> - Hint("ap", "a Cisco Aironet, Catalyst, Business, RV, or SPVTG access point", "Matched signature $raw.", 7) + Hint("ap", localized("device_explain_a_cisco_aironet_catalyst_business_rv_or", "a Cisco Aironet, Catalyst, Business, RV, or SPVTG access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "mist" -> - Hint("ap", "a Juniper Mist access point", "Matched signature $raw.", 7) + Hint("ap", localized("device_explain_a_juniper_mist_access_point", "a Juniper Mist access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "t-mobile" -> - Hint("ap", "a T-Mobile Home Internet gateway or hotspot", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_t_mobile_home_internet_gateway_or", "a T-Mobile Home Internet gateway or hotspot"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "humax" -> - Hint("ap", "a HUMAX gateway (often T-Mobile Home Internet)", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_humax_gateway_often_t_mobile_home", "a HUMAX gateway (often T-Mobile Home Internet)"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "sagemcom" -> - Hint("ap", "a Sagemcom ISP gateway", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_sagemcom_isp_gateway", "a Sagemcom ISP gateway"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "arcadyan" -> - Hint("ap", "an Arcadyan ISP gateway", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_an_arcadyan_isp_gateway", "an Arcadyan ISP gateway"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "askey" -> - Hint("ap", "an Askey ISP / 5G gateway", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_an_askey_isp_5g_gateway", "an Askey ISP / 5G gateway"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "calix" -> - Hint("ap", "a Calix fiber gateway", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_calix_fiber_gateway", "a Calix fiber gateway"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "nokia" -> - Hint("ap", "a Nokia Solutions and Networks gateway", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_nokia_solutions_and_networks_gateway", "a Nokia Solutions and Networks gateway"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "airties" -> - Hint("ap", "an AirTies ISP mesh node", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_an_airties_isp_mesh_node", "an AirTies ISP mesh node"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "tenda" -> - Hint("ap", "a Tenda access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_tenda_access_point", "a Tenda access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "ruijie" -> - Hint("ap", "a Ruijie or Reyee access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_ruijie_or_reyee_access_point", "a Ruijie or Reyee access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "dwnet" -> - Hint("ap", "a DWnet access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_dwnet_access_point", "a DWnet access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "wavlink" -> - Hint("ap", "a WAVLINK access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_wavlink_access_point", "a WAVLINK access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "sercomm" -> - Hint("ap", "a Sercomm ISP gateway", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_sercomm_isp_gateway", "a Sercomm ISP gateway"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "luxul" -> - Hint("ap", "a Luxul access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_luxul_access_point", "a Luxul access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "sophos" -> - Hint("ap", "a Sophos firewall or access point", "Matched signature $raw.", 7) + Hint("ap", localized("device_explain_a_sophos_firewall_or_access_point", "a Sophos firewall or access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "aumovio" -> - Hint("hotspot", "an AUMOVIO / Continental vehicle Wi-Fi radio", "Matched signature $raw.", 6) + Hint("hotspot", localized("device_explain_an_aumovio_continental_vehicle_wi_fi_radio", "an AUMOVIO / Continental vehicle Wi-Fi radio"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "centurylink" -> - Hint("ap", "a CenturyLink gateway", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_centurylink_gateway", "a CenturyLink gateway"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "gm hotspot" -> - Hint("hotspot", "a GM in-car hotspot (Cadillac / GMC / Buick / Chevrolet)", "Matched signature $raw.", 6) + Hint("hotspot", localized("device_explain_a_gm_in_car_hotspot_cadillac_gmc", "a GM in-car hotspot (Cadillac / GMC / Buick / Chevrolet)"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "audi mmi" -> - Hint("hotspot", "an Audi MMI in-car hotspot", "Matched signature $raw.", 6) + Hint("hotspot", localized("device_explain_an_audi_mmi_in_car_hotspot", "an Audi MMI in-car hotspot"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "extreme" -> - Hint("ap", "an Extreme Networks access point", "Matched signature $raw.", 7) + Hint("ap", localized("device_explain_an_extreme_networks_access_point", "an Extreme Networks access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "adtran" -> - Hint("ap", "an Adtran fiber gateway (often CenturyLink / Quantum Fiber OEM)", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_an_adtran_fiber_gateway_often_centurylink_quantum", "an Adtran fiber gateway (often CenturyLink / Quantum Fiber OEM)"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "cambium" -> - Hint("ap", "a Cambium or IgniteNet access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_cambium_or_ignitenet_access_point", "a Cambium or IgniteNet access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "trendnet" -> - Hint("ap", "a TRENDnet access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_trendnet_access_point", "a TRENDnet access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "cudy" -> - Hint("ap", "a Cudy travel or home router", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_cudy_travel_or_home_router", "a Cudy travel or home router"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "snapav" -> - Hint("ap", "a SnapAV / Control4 / Wattbox access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_snapav_control4_wattbox_access_point", "a SnapAV / Control4 / Wattbox access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "arlo" -> - Hint("camera", "an Arlo camera or VMB base station", "Matched signature $raw.", 6) + Hint("camera", localized("device_explain_an_arlo_camera_or_vmb_base_station", "an Arlo camera or VMB base station"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "vantiva" -> - Hint("ap", "a Vantiva or Technicolor ISP gateway", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_vantiva_or_technicolor_isp_gateway", "a Vantiva or Technicolor ISP gateway"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "hitron" -> - Hint("ap", "a Hitron cable gateway (often Xfinity OEM)", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_hitron_cable_gateway_often_xfinity_oem", "a Hitron cable gateway (often Xfinity OEM)"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "actiontec" -> - Hint("ap", "an Actiontec FiOS or Frontier gateway", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_an_actiontec_fios_or_frontier_gateway", "an Actiontec FiOS or Frontier gateway"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "buffalo" -> - Hint("ap", "a Buffalo AirStation or router", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_buffalo_airstation_or_router", "a Buffalo AirStation or router"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "grandstream" -> - Hint("ap", "a Grandstream GWN access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_grandstream_gwn_access_point", "a Grandstream GWN access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "edgecore" -> - Hint("ap", "an Edgecore access point", "Matched signature $raw.", 7) + Hint("ap", localized("device_explain_an_edgecore_access_point", "an Edgecore access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "watchguard ap" -> - Hint("ap", "a WatchGuard firewall or access point", "Matched signature $raw.", 7) + Hint("ap", localized("device_explain_a_watchguard_firewall_or_access_point", "a WatchGuard firewall or access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "mojo" -> - Hint("ap", "a Mojo Networks / Arista Cognitive Wi-Fi access point", "Matched signature $raw.", 7) + Hint("ap", localized("device_explain_a_mojo_networks_arista_cognitive_wi_fi", "a Mojo Networks / Arista Cognitive Wi-Fi access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "winegard" -> - Hint("hotspot", "a Winegard RV or marine Wi-Fi radio", "Matched signature $raw.", 6) + Hint("hotspot", localized("device_explain_a_winegard_rv_or_marine_wi_fi", "a Winegard RV or marine Wi-Fi radio"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "inseego" -> - Hint("ap", "an Inseego 5G or MiFi hotspot", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_an_inseego_5g_or_mifi_hotspot", "an Inseego 5G or MiFi hotspot"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "franklin" -> - Hint("ap", "a Franklin Technology 5G home-internet gateway (RG3100 class)", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_franklin_technology_5g_home_internet_gateway", "a Franklin Technology 5G home-internet gateway (RG3100 class)"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "synology" -> - Hint("ap", "a Synology NAS or router access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_synology_nas_or_router_access_point", "a Synology NAS or router access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "aruba" -> - Hint("ap", "an HPE Aruba Instant or Instant On access point", "Matched signature $raw.", 7) + Hint("ap", localized("device_explain_an_hpe_aruba_instant_or_instant_on", "an HPE Aruba Instant or Instant On access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "ruckus" -> - Hint("ap", "a RUCKUS access point", "Matched signature $raw.", 7) + Hint("ap", localized("device_explain_a_ruckus_access_point", "a RUCKUS access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "fortinet" -> - Hint("ap", "a Fortinet FortiAP or FortiWiFi", "Matched signature $raw.", 7) + Hint("ap", localized("device_explain_a_fortinet_fortiap_or_fortiwifi", "a Fortinet FortiAP or FortiWiFi"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "mikrotik" -> - Hint("ap", "a MikroTik router or access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_mikrotik_router_or_access_point", "a MikroTik router or access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "engenius" -> - Hint("ap", "an EnGenius access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_an_engenius_access_point", "an EnGenius access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "zyxel" -> - Hint("ap", "a Zyxel gateway or access point", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_zyxel_gateway_or_access_point", "a Zyxel gateway or access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "peplink" -> - Hint("ap", "a Peplink or Pepwave router", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_peplink_or_pepwave_router", "a Peplink or Pepwave router"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "openwrt" -> - Hint("ap", "an OpenWrt router", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_an_openwrt_router", "an OpenWrt router"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "arris" -> - Hint("ap", "an Arris or SURFboard cable gateway", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_an_arris_or_surfboard_cable_gateway", "an Arris or SURFboard cable gateway"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "unifi ap" -> - Hint("ap", "a Ubiquiti UniFi access point", "Matched signature $raw.", 7) + Hint("ap", localized("device_explain_a_ubiquiti_unifi_access_point", "a Ubiquiti UniFi access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "unifi protect" -> - Hint("camera", "a UniFi Protect Instant camera", "Matched signature $raw.", 7) + Hint("camera", localized("device_explain_a_unifi_protect_instant_camera", "a UniFi Protect Instant camera"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "unifi" -> - Hint("ap", "a UniFi / Ubiquiti name", "Matched signature $raw.", 5) + Hint("ap", localized("device_explain_a_unifi_ubiquiti_name", "a UniFi / Ubiquiti name"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 5) n == "ecobee" -> - Hint("thermostat", "an ecobee thermostat", "Matched signature $raw.", 7) + Hint("thermostat", localized("device_explain_an_ecobee_thermostat", "an ecobee thermostat"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "sensi" -> - Hint("thermostat", "a Sensi thermostat", "Matched signature $raw.", 6) + Hint("thermostat", localized("device_explain_a_sensi_thermostat", "a Sensi thermostat"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "honeywell home" -> - Hint("thermostat", "a Honeywell Home or Lyric thermostat", "Matched signature $raw.", 6) + Hint("thermostat", localized("device_explain_a_honeywell_home_or_lyric_thermostat", "a Honeywell Home or Lyric thermostat"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) "honeywell xenon" in n -> - Hint("health", "a Honeywell Xenon healthcare barcode scanner", "Matched signature $raw.", 7) + Hint("health", localized("device_explain_a_honeywell_xenon_healthcare_barcode_scanner", "a Honeywell Xenon healthcare barcode scanner"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "omron" -> - Hint("health", "an Omron blood-pressure cuff or scale", "Matched signature $raw.", 7) + Hint("health", localized("device_explain_an_omron_blood_pressure_cuff_or_scale", "an Omron blood-pressure cuff or scale"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "withings" -> - Hint("health", "a Withings scale or blood-pressure monitor", "Matched signature $raw.", 7) + Hint("health", localized("device_explain_a_withings_scale_or_blood_pressure_monitor", "a Withings scale or blood-pressure monitor"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "dexcom" -> - Hint("health", "a Dexcom glucose sensor", "Matched signature $raw.", 7) + Hint("health", localized("device_explain_a_dexcom_glucose_sensor", "a Dexcom glucose sensor"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "nest thermostat" -> - Hint("thermostat", "a Nest thermostat or Nest Labs BLE sensor", "Matched signature $raw.", 6) + Hint("thermostat", localized("device_explain_a_nest_thermostat_or_nest_labs_ble", "a Nest thermostat or Nest Labs BLE sensor"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "nest weave" -> - Hint("sensor", "a Nest Protect, camera, or other Weave BLE device", "Matched signature $raw.", 7) + Hint("sensor", localized("device_explain_a_nest_protect_camera_or_other_weave", "a Nest Protect, camera, or other Weave BLE device"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "haiku fan" || n == "haiku" -> - Hint("fan", "a Haiku or Mammoth ceiling fan", "Matched signature $raw.", 7) + Hint("fan", localized("device_explain_a_haiku_or_mammoth_ceiling_fan", "a Haiku or Mammoth ceiling fan"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "tuya" -> - Hint("iot", "a Tuya BLE gadget (plug, light, camera, sensor)", "Matched signature $raw.", 6) + Hint("iot", localized("device_explain_a_tuya_ble_gadget_plug_light_camera", "a Tuya BLE gadget (plug, light, camera, sensor)"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "seos" || n == "assa abloy" -> - Hint("access", "an ASSA ABLOY lock, Yale lock, HID reader, or Seos credential", "Matched signature $raw.", 7) + Hint("access", localized("device_explain_an_assa_abloy_lock_yale_lock_hid", "an ASSA ABLOY lock, Yale lock, HID reader, or Seos credential"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "august" -> - Hint("lock", "an August smart lock", "Matched signature $raw.", 7) + Hint("lock", localized("device_explain_an_august_smart_lock", "an August smart lock"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "schlage" -> - Hint("lock", "a Schlage or Allegion lock", "Matched signature $raw.", 7) + Hint("lock", localized("device_explain_a_schlage_or_allegion_lock", "a Schlage or Allegion lock"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "nuki" -> - Hint("lock", "a Nuki lock or opener", "Matched signature $raw.", 7) + Hint("lock", localized("device_explain_a_nuki_lock_or_opener", "a Nuki lock or opener"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "salto" -> - Hint("access", "a SALTO access reader or lock", "Matched signature $raw.", 7) + Hint("access", localized("device_explain_a_salto_access_reader_or_lock", "a SALTO access reader or lock"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "dormakaba" -> - Hint("access", "a dormakaba, Saflok, or Oracode lock", "Matched signature $raw.", 7) + Hint("access", localized("device_explain_a_dormakaba_saflok_or_oracode_lock", "a dormakaba, Saflok, or Oracode lock"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "lockly" -> - Hint("lock", "a Lockly smart lock", "Matched signature $raw.", 6) + Hint("lock", localized("device_explain_a_lockly_smart_lock", "a Lockly smart lock"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "kevo" -> - Hint("lock", "a Kwikset Kevo or Unikey lock", "Matched signature $raw.", 7) + Hint("lock", localized("device_explain_a_kwikset_kevo_or_unikey_lock", "a Kwikset Kevo or Unikey lock"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "master lock" -> - Hint("lock", "a Master Lock padlock", "Matched signature $raw.", 7) + Hint("lock", localized("device_explain_a_master_lock_padlock", "a Master Lock padlock"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "igloohome" -> - Hint("lock", "an igloohome lock or keybox", "Matched signature $raw.", 7) + Hint("lock", localized("device_explain_an_igloohome_lock_or_keybox", "an igloohome lock or keybox"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "tedee" -> - Hint("lock", "a Tedee smart lock", "Matched signature $raw.", 7) + Hint("lock", localized("device_explain_a_tedee_smart_lock", "a Tedee smart lock"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "paxton" -> - Hint("access", "a Paxton reader or Net2 access point", "Matched signature $raw.", 7) + Hint("access", localized("device_explain_a_paxton_reader_or_net2_access_point", "a Paxton reader or Net2 access point"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "kwikset" -> - Hint("lock", "a Kwikset lock", "Matched signature $raw.", 6) + Hint("lock", localized("device_explain_a_kwikset_lock", "a Kwikset lock"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "myq" -> - Hint("garage", "a Chamberlain myQ garage hub", "Matched signature $raw.", 7) + Hint("garage", localized("device_explain_a_chamberlain_myq_garage_hub", "a Chamberlain myQ garage hub"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "chevrolet hotspot" -> - Hint("hotspot", "a Chevrolet in-car Wi-Fi hotspot", "Matched signature $raw.", 7) + Hint("hotspot", localized("device_explain_a_chevrolet_in_car_wi_fi_hotspot", "a Chevrolet in-car Wi-Fi hotspot"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "rivian" -> - Hint("vehicle", "a Rivian vehicle, phone key, or sensor", "Matched signature $raw.", 7) + Hint("vehicle", localized("device_explain_a_rivian_vehicle_phone_key_or_sensor", "a Rivian vehicle, phone key, or sensor"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "ford" -> - Hint("vehicle", "a Ford or Lincoln vehicle or phone-as-key", "Matched signature $raw.", 7) + Hint("vehicle", localized("device_explain_a_ford_or_lincoln_vehicle_or_phone", "a Ford or Lincoln vehicle or phone-as-key"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "honda" -> - Hint("vehicle", "a Honda or Acura vehicle or phone-as-key", "Matched signature $raw.", 7) + Hint("vehicle", localized("device_explain_a_honda_or_acura_vehicle_or_phone", "a Honda or Acura vehicle or phone-as-key"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "hyundai" -> - Hint("vehicle", "a Hyundai or Genesis vehicle or phone-as-key", "Matched signature $raw.", 7) + Hint("vehicle", localized("device_explain_a_hyundai_or_genesis_vehicle_or_phone", "a Hyundai or Genesis vehicle or phone-as-key"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "toyota" -> - Hint("vehicle", "a Toyota or Lexus vehicle or phone-as-key", "Matched signature $raw.", 7) + Hint("vehicle", localized("device_explain_a_toyota_or_lexus_vehicle_or_phone", "a Toyota or Lexus vehicle or phone-as-key"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "nissan" -> - Hint("vehicle", "a Nissan or Infiniti vehicle or phone-as-key", "Matched signature $raw.", 7) + Hint("vehicle", localized("device_explain_a_nissan_or_infiniti_vehicle_or_phone", "a Nissan or Infiniti vehicle or phone-as-key"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "subaru" -> - Hint("vehicle", "a Subaru vehicle or phone-as-key", "Matched signature $raw.", 7) + Hint("vehicle", localized("device_explain_a_subaru_vehicle_or_phone_as_key", "a Subaru vehicle or phone-as-key"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "bmw" -> - Hint("vehicle", "a BMW vehicle, phone-as-key, or factory hotspot", "Matched signature $raw.", 7) + Hint("vehicle", localized("device_explain_a_bmw_vehicle_phone_as_key_or", "a BMW vehicle, phone-as-key, or factory hotspot"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "volkswagen" -> - Hint("vehicle", "a Volkswagen vehicle or phone-as-key", "Matched signature $raw.", 7) + Hint("vehicle", localized("device_explain_a_volkswagen_vehicle_or_phone_as_key", "a Volkswagen vehicle or phone-as-key"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "porsche" -> - Hint("vehicle", "a Porsche vehicle or phone-as-key", "Matched signature $raw.", 7) + Hint("vehicle", localized("device_explain_a_porsche_vehicle_or_phone_as_key", "a Porsche vehicle or phone-as-key"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "jaguar land rover" -> - Hint("vehicle", "a Jaguar, Land Rover, or Range Rover", "Matched signature $raw.", 7) + Hint("vehicle", localized("device_explain_a_jaguar_land_rover_or_range_rover", "a Jaguar, Land Rover, or Range Rover"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "byd" -> - Hint("vehicle", "a BYD vehicle or phone-as-key", "Matched signature $raw.", 7) + Hint("vehicle", localized("device_explain_a_byd_vehicle_or_phone_as_key", "a BYD vehicle or phone-as-key"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "govee" -> - Hint("light", "a Govee light or sensor", "Matched signature $raw.", 6) + Hint("light", localized("device_explain_a_govee_light_or_sensor", "a Govee light or sensor"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "hp" -> - Hint("printer", "an HP printer", "Matched signature $raw.", 6) + Hint("printer", localized("device_explain_an_hp_printer", "an HP printer"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "epson" -> - Hint("printer", "an Epson EcoTank or WorkForce printer", "Matched signature $raw.", 6) + Hint("printer", localized("device_explain_an_epson_ecotank_or_workforce_printer", "an Epson EcoTank or WorkForce printer"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "lg webos tv" -> - Hint("tv", "an LG webOS TV", "Matched signature $raw.", 7) + Hint("tv", localized("device_explain_an_lg_webos_tv", "an LG webOS TV"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "roku" -> - Hint("tv", "a Roku streaming stick or Roku TV (often a hidden Wi-Fi Direct remote AP)", "Matched signature $raw.", 7) + Hint("tv", localized("device_explain_a_roku_streaming_stick_or_roku_tv", "a Roku streaming stick or Roku TV (often a hidden Wi-Fi Direct remote AP)"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "samsung appliance" -> - Hint("iot", "a Samsung fridge, range, oven, or cooktop (setup AP)", "Matched signature $raw.", 6) + Hint("iot", localized("device_explain_a_samsung_fridge_range_oven_or_cooktop", "a Samsung fridge, range, oven, or cooktop (setup AP)"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "ecowater" -> - Hint("iot", "an EcoWater water softener (setup AP)", "Matched signature $raw.", 6) + Hint("iot", localized("device_explain_an_ecowater_water_softener_setup_ap", "an EcoWater water softener (setup AP)"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "nespresso" -> - Hint("iot", "a Nespresso coffee machine", "Matched signature $raw.", 7) + Hint("iot", localized("device_explain_a_nespresso_coffee_machine", "a Nespresso coffee machine"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "radiacode" -> - Hint("sensor", "a RadiaCode radiation detector", "Matched signature $raw.", 7) + Hint("sensor", localized("device_explain_a_radiacode_radiation_detector", "a RadiaCode radiation detector"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "shokz" -> - Hint("audio-personal", "Shokz OpenRun or OpenFit headphones", "Matched signature $raw.", 7) + Hint("audio-personal", localized("device_explain_shokz_openrun_or_openfit_headphones", "Shokz OpenRun or OpenFit headphones"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "mercedes mbux" -> - Hint("hotspot", "a Mercedes MBUX in-car hotspot", "Matched signature $raw.", 7) + Hint("hotspot", localized("device_explain_a_mercedes_mbux_in_car_hotspot", "a Mercedes MBUX in-car hotspot"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "motive" -> - Hint("hotspot", "a Motive / KeepTruckin fleet ELD hotspot", "Matched signature $raw.", 6) + Hint("hotspot", localized("device_explain_a_motive_keeptruckin_fleet_eld_hotspot", "a Motive / KeepTruckin fleet ELD hotspot"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "peoplenet" -> - Hint("hotspot", "a PeopleNet fleet ELD hotspot", "Matched signature $raw.", 6) + Hint("hotspot", localized("device_explain_a_peoplenet_fleet_eld_hotspot", "a PeopleNet fleet ELD hotspot"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "uconnect" -> - Hint("hotspot", "a Uconnect in-car hotspot", "Matched signature $raw.", 6) + Hint("hotspot", localized("device_explain_a_uconnect_in_car_hotspot", "a Uconnect in-car hotspot"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "carplay" -> - Hint("hotspot", "a CarPlay in-car hotspot", "Matched signature $raw.", 6) + Hint("hotspot", localized("device_explain_a_carplay_in_car_hotspot", "a CarPlay in-car hotspot"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "cradlepoint" -> - Hint("hotspot", "a Cradlepoint vehicle router (often public-safety / fleet)", "Matched signature $raw.", 7) + Hint("hotspot", localized("device_explain_a_cradlepoint_vehicle_router_often_public_safety", "a Cradlepoint vehicle router (often public-safety / fleet)"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "airlink" -> - Hint("hotspot", "a Sierra Wireless AirLink vehicle gateway", "Matched signature $raw.", 7) + Hint("hotspot", localized("device_explain_a_sierra_wireless_airlink_vehicle_gateway", "a Sierra Wireless AirLink vehicle gateway"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "compex" -> - Hint("hotspot", "a Compex access point (sometimes public-safety / fleet)", "Matched signature $raw.", 6) + Hint("hotspot", localized("device_explain_a_compex_access_point_sometimes_public_safety", "a Compex access point (sometimes public-safety / fleet)"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "novatel wireless" -> - Hint("hotspot", "a Novatel Wireless / Inseego vehicle radio", "Matched signature $raw.", 6) + Hint("hotspot", localized("device_explain_a_novatel_wireless_inseego_vehicle_radio", "a Novatel Wireless / Inseego vehicle radio"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) n == "utility inc" -> - Hint("hotspot", "a Utility, Inc vehicle or public-safety radio", "Matched signature $raw.", 6) + Hint("hotspot", localized("device_explain_a_utility_inc_vehicle_or_public_safety", "a Utility, Inc vehicle or public-safety radio"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) "gl.inet" in n || n == "glinet" -> - Hint("ap", "a GL.iNet travel router", "Matched signature $raw.", 6) + Hint("ap", localized("device_explain_a_gl_inet_travel_router", "a GL.iNet travel router"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 6) "smarttag" in n -> - Hint("tag", "a Samsung SmartTag", "Matched signature $raw.", 8) + Hint("tag", localized("device_explain_a_samsung_smarttag", "a Samsung SmartTag"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) "tile" in n -> - Hint("tag", "a Tile tracker", "Matched signature $raw.", 8) + Hint("tag", localized("device_explain_a_tile_tracker", "a Tile tracker"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) n == "ibeacon" -> - Hint("beacon", "an iBeacon", "Matched signature $raw.", 7) + Hint("beacon", localized("device_explain_an_ibeacon", "an iBeacon"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) "target atrius" in n -> - Hint("beacon", "a Target Atrius basket tag", "Matched signature $raw.", 8) + Hint("beacon", localized("device_explain_a_target_atrius_basket_tag", "a Target Atrius basket tag"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) n == "minew" -> - Hint("beacon", "a Minew BLE beacon or sensor", "Matched signature $raw.", 7) + Hint("beacon", localized("device_explain_a_minew_ble_beacon_or_sensor", "a Minew BLE beacon or sensor"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "estimote" -> - Hint("beacon", "an Estimote beacon", "Matched signature $raw.", 7) + Hint("beacon", localized("device_explain_an_estimote_beacon", "an Estimote beacon"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "kontakt.io" || n == "kontakt" -> - Hint("beacon", "a Kontakt.io beacon", "Matched signature $raw.", 7) + Hint("beacon", localized("device_explain_a_kontakt_io_beacon", "a Kontakt.io beacon"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) "bluetoad" in n -> Hint( "roadside", - "an Iteris BlueTOAD / Vantage Velocity roadside Bluetooth travel-time reader", - "Matched signature $raw.", + localized("device_explain_an_iteris_bluetoad_vantage_velocity_roadside_bluetooth", "an Iteris BlueTOAD / Vantage Velocity roadside Bluetooth travel-time reader"), + localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7, ) "bliptrack" in n -> Hint( "roadside", - "a BLIP Systems BlipTrack roadside travel-time sensor", - "Matched signature $raw.", + localized("device_explain_a_blip_systems_bliptrack_roadside_travel_time", "a BLIP Systems BlipTrack roadside travel-time sensor"), + localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7, ) "raven" in n || "shotspotter" in n || "soundthinking" in n -> Hint( "acoustic", - "a Flock Raven or ShotSpotter acoustic gunshot sensor", - "Matched signature $raw.", + localized("device_explain_a_flock_raven_or_shotspotter_acoustic_gunshot", "a Flock Raven or ShotSpotter acoustic gunshot sensor"), + localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8, ) "digital ally" in n -> - Hint("camera", "a Digital Ally body-worn or in-car camera", "Matched signature $raw.", 8) + Hint("camera", localized("device_explain_a_digital_ally_body_worn_or_in", "a Digital Ally body-worn or in-car camera"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) "reveal media" in n || "bodyworn" in n -> - Hint("camera", "a Reveal Media body-worn camera", "Matched signature $raw.", 8) + Hint("camera", localized("device_explain_a_reveal_media_body_worn_camera", "a Reveal Media body-worn camera"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) n == "wolfcom" -> - Hint("camera", "a Wolfcom body-worn or in-car camera", "Matched signature $raw.", 8) + Hint("camera", localized("device_explain_a_wolfcom_body_worn_or_in_car", "a Wolfcom body-worn or in-car camera"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) "i-pro" in n || "arbitrator" in n -> - Hint("camera", "a Panasonic i-PRO camera or Arbitrator in-car system", "Matched signature $raw.", 8) + Hint("camera", localized("device_explain_a_panasonic_i_pro_camera_or_arbitrator", "a Panasonic i-PRO camera or Arbitrator in-car system"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) "limitless" in n -> - Hint("wearable", "a Limitless Pendant conversation recorder", "Matched signature $raw.", 8) + Hint("wearable", localized("device_explain_a_limitless_pendant_conversation_recorder", "a Limitless Pendant conversation recorder"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) n == "bee pendant" || "bee pioneer" in n -> - Hint("wearable", "a Bee Pioneer wearable recorder", "Matched signature $raw.", 8) + Hint("wearable", localized("device_explain_a_bee_pioneer_wearable_recorder", "a Bee Pioneer wearable recorder"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) n == "omi" || "openglass" in n -> - Hint("wearable", "an Omi pendant or OpenGlass camera glasses", "Matched signature $raw.", 8) + Hint("wearable", localized("device_explain_an_omi_pendant_or_openglass_camera_glasses", "an Omi pendant or OpenGlass camera glasses"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) "friend pendant" in n -> - Hint("wearable", "a Friend Pendant necklace", "Matched signature $raw.", 8) + Hint("wearable", localized("device_explain_a_friend_pendant_necklace", "a Friend Pendant necklace"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) "brilliant frame" in n -> - Hint("glasses", "Brilliant Labs Frame AR glasses", "Matched signature $raw.", 8) + Hint("glasses", localized("device_explain_brilliant_labs_frame_ar_glasses", "Brilliant Labs Frame AR glasses"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) n == "even g1" -> - Hint("glasses", "Even Realities G1 glasses", "Matched signature $raw.", 8) + Hint("glasses", localized("device_explain_even_realities_g1_glasses", "Even Realities G1 glasses"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) "hayden" in n -> - Hint("camera", "a Hayden AI bus- or vehicle-mounted camera", "Matched signature $raw.", 8) + Hint("camera", localized("device_explain_a_hayden_ai_bus_or_vehicle_mounted", "a Hayden AI bus- or vehicle-mounted camera"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) "miovision" in n -> - Hint("camera", "a Miovision intersection traffic camera", "Matched signature $raw.", 8) + Hint("camera", localized("device_explain_a_miovision_intersection_traffic_camera", "a Miovision intersection traffic camera"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) n == "tattile" -> - Hint("camera", "a Tattile plate reader", "Matched signature $raw.", 8) + Hint("camera", localized("device_explain_a_tattile_plate_reader", "a Tattile plate reader"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) "lvt" in n || "liveview" in n -> - Hint("camera", "an LVT / LiveView solar surveillance trailer", "Matched signature $raw.", 8) + Hint("camera", localized("device_explain_an_lvt_liveview_solar_surveillance_trailer", "an LVT / LiveView solar surveillance trailer"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) "hanwha" in n || "wisenet" in n -> - Hint("camera", "a Hanwha Vision / Wisenet camera", "Matched signature $raw.", 7) + Hint("camera", localized("device_explain_a_hanwha_vision_wisenet_camera", "a Hanwha Vision / Wisenet camera"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "uniview" -> - Hint("camera", "a Uniview / UNV camera", "Matched signature $raw.", 7) + Hint("camera", localized("device_explain_a_uniview_unv_camera", "a Uniview / UNV camera"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "rhombus" -> - Hint("camera", "a Rhombus cloud camera", "Matched signature $raw.", 7) + Hint("camera", localized("device_explain_a_rhombus_cloud_camera", "a Rhombus cloud camera"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "meshcore" -> - Hint("mesh", "a MeshCore LoRa companion radio", "Matched signature $raw.", 7) + Hint("mesh", localized("device_explain_a_meshcore_lora_companion_radio", "a MeshCore LoRa companion radio"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) "gotenna" in n -> - Hint("mesh", "a goTenna Mesh or Pro radio", "Matched signature $raw.", 7) + Hint("mesh", localized("device_explain_a_gotenna_mesh_or_pro_radio", "a goTenna Mesh or Pro radio"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "sensecap" -> - Hint("mesh", "a SenseCAP LoRaWAN / Helium gateway", "Matched signature $raw.", 7) + Hint("mesh", localized("device_explain_a_sensecap_lorawan_helium_gateway", "a SenseCAP LoRaWAN / Helium gateway"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) "wisgate" in n || n == "rak wisgate" -> - Hint("mesh", "a RAK WisGate LoRaWAN gateway", "Matched signature $raw.", 7) + Hint("mesh", localized("device_explain_a_rak_wisgate_lorawan_gateway", "a RAK WisGate LoRaWAN gateway"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "ghostesp" -> - Hint("pentest", "a GhostESP ESP32 audit board", "Matched signature $raw.", 7) + Hint("pentest", localized("device_explain_a_ghostesp_esp32_audit_board", "a GhostESP ESP32 audit board"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "bruce" -> - Hint("pentest", "a Bruce ESP32 pentest board", "Matched signature $raw.", 7) + Hint("pentest", localized("device_explain_a_bruce_esp32_pentest_board", "a Bruce ESP32 pentest board"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) n == "liteon camera radio" -> Hint( "module", - "a camera-module radio (LiteOn or similar)", - "Matched signature $raw.", + localized("device_explain_a_camera_module_radio_liteon_or_similar", "a camera-module radio (LiteOn or similar)"), + localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 3, ) "chipolo" in n || "pebblebee" in n || "moto tag" in n -> - Hint("tag", "a finder tag", "Matched signature $raw.", 7) + Hint("tag", localized("device_explain_a_finder_tag", "a finder tag"), localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) "airpods" in n -> - Hint("audio-personal", "AirPods", "Matched signature $raw.", 8) - else -> Hint("named", raw, "Matched signature $raw.", 7) + Hint("audio-personal", "AirPods", localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 8) + else -> Hint("named", raw, localized("device_explain_matched_signature", "Matched signature %1\$s.", raw), 7) } } } @@ -732,21 +737,21 @@ object DeviceExplain { when { name.startsWith("DIRECT-", true) -> out += if (specific) { - Hint("wifi-direct", "a Wi-Fi Direct access point", "SSID starts with DIRECT-.", 4) + Hint("wifi-direct", localized("device_explain_a_wi_fi_direct_access_point", "a Wi-Fi Direct access point"), localized("device_explain_ssid_starts_with_direct", "SSID starts with DIRECT-."), 4) } else { - Hint("wifi-direct", "a phone or TV using Wi-Fi Direct", "SSID starts with DIRECT-.", 6) + Hint("wifi-direct", localized("device_explain_a_phone_or_tv_using_wi_fi", "a phone or TV using Wi-Fi Direct"), localized("device_explain_ssid_starts_with_direct", "SSID starts with DIRECT-."), 6) } name.startsWith("ANDROID-", true) || name.contains("hotspot", true) -> if (!specific) { - out += Hint("hotspot", "a phone hotspot", "SSID looks like a phone hotspot.", 6) + out += Hint("hotspot", localized("device_explain_a_phone_hotspot", "a phone hotspot"), localized("device_explain_ssid_looks_like_a_phone_hotspot", "SSID looks like a phone hotspot."), 6) } "MESH" in caps -> - out += Hint("mesh", "a mesh Wi-Fi node", "Capability list includes mesh.", 5) + out += Hint("mesh", localized("device_explain_a_mesh_wi_fi_node", "a mesh Wi-Fi node"), localized("device_explain_capability_list_includes_mesh", "Capability list includes mesh."), 5) device.hiddenSsid -> - out += Hint("ap", "a hidden Wi-Fi access point", "SSID is hidden; the radio is still beaconing.", 4) + out += Hint("ap", localized("device_explain_a_hidden_wi_fi_access_point", "a hidden Wi-Fi access point"), localized("device_explain_ssid_is_hidden_the_radio_is_still", "SSID is hidden; the radio is still beaconing."), 4, generic = true) else -> if (!specific) { - out += Hint("ap", "a Wi-Fi access point", "Stock Android only reports beaconing APs.", 3) + out += Hint("ap", localized("device_explain_a_wi_fi_access_point", "a Wi-Fi access point"), localized("device_explain_stock_android_only_reports_beaconing_aps", "Stock Android only reports beaconing APs."), 3, generic = true) } } return out diff --git a/app/src/main/java/app/fieldwatch/domain/FastPair.kt b/app/src/main/java/app/fieldwatch/domain/FastPair.kt index e8ea9a7..dd991da 100644 --- a/app/src/main/java/app/fieldwatch/domain/FastPair.kt +++ b/app/src/main/java/app/fieldwatch/domain/FastPair.kt @@ -1,5 +1,7 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.localized + /** * Google Fast Pair (UUID FE2C) has two Live-relevant shapes: * 3-byte model ID = pairing mode; longer = account-key plaza noise. @@ -20,7 +22,7 @@ object FastPair { } fun liveLabel(pairing: Boolean): String = - if (pairing) "Fast Pair pairing" else "Fast Pair" + if (pairing) localized("fast_pair_fast_pair_pairing", "Fast Pair pairing") else "Fast Pair" fun isFastPairUuid(uuid: String): Boolean { val hex = uuid.filter { it.isLetterOrDigit() }.uppercase() diff --git a/app/src/main/java/app/fieldwatch/domain/FieldwatchDisclaimer.kt b/app/src/main/java/app/fieldwatch/domain/FieldwatchDisclaimer.kt index a666668..4899640 100644 --- a/app/src/main/java/app/fieldwatch/domain/FieldwatchDisclaimer.kt +++ b/app/src/main/java/app/fieldwatch/domain/FieldwatchDisclaimer.kt @@ -1,86 +1,57 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.localized + /** Operator-facing disclaimer. First-run, Debrief, and AI Export share the same core. */ object FieldwatchDisclaimer { - const val HOBBY = - "This is a hobby project, provided as-is under the MIT License. Use at your own risk." + val HOBBY: String get() = + localized("fieldwatch_disclaimer_this_is_a_hobby_project_provided_as", "This is a hobby project, provided as-is under the MIT License. Use at your own risk.") - const val HYPOTHESES = - "Detections, pattern matches, “Moving with you” / “possible tail,” Debrief text, " + - "and AI Export are hypotheses — not identity, not a legal finding, and not a " + - "complete RF capture. Radios that are off, asleep, randomized, cellular-only, " + - "or hidden by the OS will not appear." + val HYPOTHESES: String get() = + localized("fieldwatch_disclaimer_detections_pattern_matches_moving_with_you_possible", "Detections, pattern matches, “Moving with you” / “possible tail,” Debrief text, and AI Export are hypotheses — not identity, not a legal finding, and not a complete RF capture. Radios that are off, asleep, randomized, cellular-only, or hidden by the OS will not appear.") - const val LIABILITY = - "You are solely responsible for how you use this app and for following local law. " + - "To the maximum extent permitted by law, Off Grid Pete LLC is not liable for " + - "indirect, incidental, special, consequential, or punitive damages arising from its use." + val LIABILITY: String get() = + localized("fieldwatch_disclaimer_you_are_solely_responsible_for_how_you", "You are solely responsible for how you use this app and for following local law. To the maximum extent permitted by law, Off Grid Pete LLC is not liable for indirect, incidental, special, consequential, or punitive damages arising from its use.") - const val LOCATION = - "GPS stamps are this phone at hear-time, not the other radio, unless a decode map " + - "advertises its own latitude/longitude (Remote ID Location). Sharing a Debrief, " + - "sit compare, AI Export (sit or one radio), radio-detail Share as text, or log can take that " + - "path off the device. A TAK/CoT feed, when you turn it on, sends markers onto " + - "the LAN; that is on the operator." + val LOCATION: String get() = + localized("fieldwatch_disclaimer_gps_stamps_are_this_phone_at_hear", "GPS stamps are this phone at hear-time, not the other radio, unless a decode map advertises its own latitude/longitude (Remote ID Location). Sharing a Debrief, sit compare, AI Export (sit or one radio), radio-detail Share as text, or log can take that path off the device. A TAK/CoT feed, when you turn it on, sends markers onto the LAN; that is on the operator.") - const val ACCEPT = - "By checking the box and continuing, you accept these terms and the MIT License." + val ACCEPT: String get() = + localized("fieldwatch_disclaimer_by_checking_the_box_and_continuing_you", "By checking the box and continuing, you accept these terms and the MIT License.") const val LICENSE_TITLE = "MIT License" /** Body of LICENSE in the repository, without the title line. */ const val LICENSE_BODY = - "Copyright (c) 2026 Off Grid Pete LLC\n" + - "\n" + - "Permission is hereby granted, free of charge, to any person obtaining a copy " + - "of this software and associated documentation files (the \"Software\"), to deal " + - "in the Software without restriction, including without limitation the rights " + - "to use, copy, modify, merge, publish, distribute, sublicense, and/or sell " + - "copies of the Software, and to permit persons to whom the Software is " + - "furnished to do so, subject to the following conditions:\n" + - "\n" + - "The above copyright notice and this permission notice shall be included in all " + - "copies or substantial portions of the Software.\n" + - "\n" + - "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR " + - "IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, " + - "FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE " + - "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER " + - "LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, " + - "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE " + - "SOFTWARE." + "Copyright (c) 2026 Off Grid Pete LLC\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE." val LICENSE_TEXT = "$LICENSE_TITLE\n\n$LICENSE_BODY" - val firstRunDisclaimer: String = "$HOBBY\n\n$HYPOTHESES\n\n$LIABILITY" + val firstRunDisclaimer: String get() = "$HOBBY\n\n$HYPOTHESES\n\n$LIABILITY" - val firstRun: String = + val firstRun: String get() = "$firstRunDisclaimer\n\n$LICENSE_TEXT\n\n$ACCEPT" /** Debrief text / PDF. Same core as first-run, without the click-through line. */ fun report(window: DebriefWindow? = null): String { val source = if (window?.sitName != null) { - "named sit “${window.sitName}” (radios heard in that window; Live list cap still applied while watching). " + localized("fieldwatch_disclaimer_named_sit_radios_heard_in_that_window", "named sit “%1\$s” (radios heard in that window; Live list cap still applied while watching). ", window.sitName) } else { - "the in-memory live set (last 15 minutes, cap about 400). " + localized("fieldwatch_disclaimer_the_in_memory_live_set_last_15", "the in-memory live set (last 15 minutes, cap about 400). ") } - return "$HOBBY\n\n$HYPOTHESES\n\n$LIABILITY\n\n$LOCATION\n\n" + - "This sit report is from $source" + - "Do not use it in any situation where safety is in question." + return localized("fieldwatch_disclaimer_n_n_n_n_n_n_n", "%1\$s\n\n%2\$s\n\n%3\$s\n\n%4\$s\n\nThis sit report is from %5\$sDo not use it in any situation where safety is in question.", HOBBY, HYPOTHESES, LIABILITY, LOCATION, source) } fun compare(): String = - "$HOBBY\n\n$HYPOTHESES\n\n$LIABILITY\n\n$LOCATION\n\n" + - "This compare is two windows of radios this phone heard (kind + MAC). " + - "BLE rotation is a new row. Do not use it in any situation where safety is in question." + localized("fieldwatch_disclaimer_n_n_n_n_n_n_n_2", "%1\$s\n\n%2\$s\n\n%3\$s\n\n%4\$s\n\nThis compare is two windows of radios this phone heard (kind + MAC). BLE rotation is a new row. Do not use it in any situation where safety is in question.", HOBBY, HYPOTHESES, LIABILITY, LOCATION) fun experimentalMarkdown(): String = buildString { - appendLine("## Disclaimer (repeat this in your answer)") + appendLine(localized("fieldwatch_disclaimer_disclaimer_repeat_this_in_your_answer", "## Disclaimer (repeat this in your answer)")) appendLine(HOBBY) appendLine(HYPOTHESES) appendLine(LIABILITY) appendLine(LOCATION) - appendLine("Do not use Fieldwatch, this paste, or your analysis in any situation where safety is in question.") - appendLine("Begin your reply with this disclaimer. Do not give safety advice.") + appendLine(localized("fieldwatch_disclaimer_do_not_use_fieldwatch_this_paste_or", "Do not use Fieldwatch, this paste, or your analysis in any situation where safety is in question.")) + appendLine(localized("fieldwatch_disclaimer_begin_your_reply_with_this_disclaimer_do", "Begin your reply with this disclaimer. Do not give safety advice.")) } } diff --git a/app/src/main/java/app/fieldwatch/domain/Geo.kt b/app/src/main/java/app/fieldwatch/domain/Geo.kt index 140a59b..c2fc121 100644 --- a/app/src/main/java/app/fieldwatch/domain/Geo.kt +++ b/app/src/main/java/app/fieldwatch/domain/Geo.kt @@ -1,5 +1,7 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.localized + import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.max @@ -441,8 +443,8 @@ object TrackerMatch { if (beacon.isNotEmpty()) return beacon.joinToString(" + ") val wear = nameHits(device, names, wearableTokens) if (wear.isNotEmpty()) return wear.joinToString(" + ") - if (isFindMyPayload(device)) return "Apple Find My / Offline Finding" - if (isCarriedApple(device, names)) return "Apple BLE (phone / Continuity)" + if (isFindMyPayload(device)) return localized("geo_apple_find_my_offline_finding", "Apple Find My / Offline Finding") + if (isCarriedApple(device, names)) return localized("geo_apple_ble_phone_continuity", "Apple BLE (phone / Continuity)") return "tracker-like" } } diff --git a/app/src/main/java/app/fieldwatch/domain/Hunt.kt b/app/src/main/java/app/fieldwatch/domain/Hunt.kt index c5bbda7..b1ada9f 100644 --- a/app/src/main/java/app/fieldwatch/domain/Hunt.kt +++ b/app/src/main/java/app/fieldwatch/domain/Hunt.kt @@ -1,5 +1,7 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.localized + /** * Relative-loudness hunt for one BLE advertiser. RSSI is not distance * and not a bearing. Quiet / gone is as important as closer / further. @@ -56,23 +58,23 @@ object Hunt { } fun label(cue: HuntCue): String = when (cue) { - HuntCue.VERY_CLOSE -> "Very Close" - HuntCue.CLOSER -> "Closer" - HuntCue.FURTHER -> "Further" - HuntCue.SAME -> "About the same" - HuntCue.WAITING -> "Listening…" - HuntCue.QUIET -> "Quiet" - HuntCue.GONE -> "Gone" + HuntCue.VERY_CLOSE -> localized("hunt_very_close", "Very Close") + HuntCue.CLOSER -> localized("hunt_closer", "Closer") + HuntCue.FURTHER -> localized("hunt_further", "Further") + HuntCue.SAME -> localized("hunt_about_the_same", "About the same") + HuntCue.WAITING -> localized("hunt_listening", "Listening…") + HuntCue.QUIET -> localized("hunt_quiet", "Quiet") + HuntCue.GONE -> localized("hunt_gone", "Gone") } fun hint(cue: HuntCue): String = when (cue) { - HuntCue.VERY_CLOSE -> "Screaming loud here. Look around — usually in-hand, pocket, or the same bag. Not meters." - HuntCue.CLOSER -> "Louder than a few seconds ago. Keep walking that way." - HuntCue.FURTHER -> "Quieter than a few seconds ago. Turn or back up." - HuntCue.SAME -> "No clear change yet. Slow down; hold the phone still." - HuntCue.WAITING -> "Need a few seconds of packets to compare." - HuntCue.QUIET -> "No packet for a few seconds. Silent, or behind a wall." - HuntCue.GONE -> "Left the live set. Randomized BLE often vanishes mid-hunt." + HuntCue.VERY_CLOSE -> localized("hunt_screaming_loud_here_look_around_usually_in", "Screaming loud here. Look around — usually in-hand, pocket, or the same bag. Not meters.") + HuntCue.CLOSER -> localized("hunt_louder_than_a_few_seconds_ago_keep", "Louder than a few seconds ago. Keep walking that way.") + HuntCue.FURTHER -> localized("hunt_quieter_than_a_few_seconds_ago_turn", "Quieter than a few seconds ago. Turn or back up.") + HuntCue.SAME -> localized("hunt_no_clear_change_yet_slow_down_hold", "No clear change yet. Slow down; hold the phone still.") + HuntCue.WAITING -> localized("hunt_need_a_few_seconds_of_packets_to", "Need a few seconds of packets to compare.") + HuntCue.QUIET -> localized("hunt_no_packet_for_a_few_seconds_silent", "No packet for a few seconds. Silent, or behind a wall.") + HuntCue.GONE -> localized("hunt_left_the_live_set_randomized_ble_often", "Left the live set. Randomized BLE often vanishes mid-hunt.") } /** diff --git a/app/src/main/java/app/fieldwatch/domain/Models.kt b/app/src/main/java/app/fieldwatch/domain/Models.kt index fec5f9c..388e4e5 100644 --- a/app/src/main/java/app/fieldwatch/domain/Models.kt +++ b/app/src/main/java/app/fieldwatch/domain/Models.kt @@ -1,5 +1,7 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.localized + import kotlinx.serialization.EncodeDefault import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName @@ -755,7 +757,7 @@ data class Sighting( val liveDecode: List = emptyList(), ) { val displayName: String - get() = name.ifBlank { if (hiddenSsid) "" else mac } + get() = name.ifBlank { if (hiddenSsid) localized("radio_hidden", "") else mac } /** Custom name from Named radios, else advertised / hidden / MAC. */ fun reportName(customNames: Map): String { @@ -771,16 +773,16 @@ data class Sighting( fun listTitle(signatureNames: List = emptyList()): String { val advertised = name.trim() if (advertised.isNotEmpty() && !advertised.equals(mac, ignoreCase = true)) return advertised - if (kind == RadioKind.WIFI) return if (hiddenSsid) "" else mac - return DeviceExplain.listLabel(this, signatureNames) ?: "unnamed LE" + if (kind == RadioKind.WIFI) return if (hiddenSsid) localized("radio_hidden", "") else mac + return DeviceExplain.listLabel(this, signatureNames) ?: localized("radio_unnamed", "unnamed LE") } /** SSID or BLE local name; placeholders if blank. */ fun advertisedName(): String { val advertised = name.trim() if (advertised.isNotEmpty() && !advertised.equals(mac, ignoreCase = true)) return advertised - if (kind == RadioKind.WIFI) return if (hiddenSsid) "" else mac - return "unnamed LE" + if (kind == RadioKind.WIFI) return if (hiddenSsid) localized("radio_hidden", "") else mac + return localized("radio_unnamed", "unnamed LE") } fun listLineText( @@ -800,14 +802,14 @@ data class Sighting( fun radioKindTag(): String = if (kind == RadioKind.WIFI) "AP" else "LE" fun statusCrumbs(): String = buildString { - if (randomized) append("rand") + if (randomized) append(localized("radio_random", "rand")) if (fastPairPairing) { if (isNotEmpty()) append(" ") - append("pair") + append(localized("radio_pairing", "pair")) } if (gone) { if (isNotEmpty()) append(" ") - append("gone") + append(localized("radio_gone", "gone")) } } diff --git a/app/src/main/java/app/fieldwatch/domain/PairingFlood.kt b/app/src/main/java/app/fieldwatch/domain/PairingFlood.kt index 943152b..38639d9 100644 --- a/app/src/main/java/app/fieldwatch/domain/PairingFlood.kt +++ b/app/src/main/java/app/fieldwatch/domain/PairingFlood.kt @@ -1,5 +1,7 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.localized + import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -26,33 +28,35 @@ data class FloodBurst( fun reportLine(clock: String): String { val pairing = !wifi && popupCount >= PairingFlood.POPUP_MIN val head = when { - wifi -> "Wi-Fi beacon flood" - pairing -> "Pairing flood" - else -> "Name flood" + wifi -> localized("pairing_flood_wi_fi_beacon_flood", "Wi-Fi beacon flood") + pairing -> localized("pairing_flood_pairing_flood", "Pairing flood") + else -> localized("pairing_flood_name_flood", "Name flood") } val count = when { wifi -> popupCount pairing -> popupCount else -> nameCount } - val unit = if (wifi) "new names" else "new addresses" + val unit = if (wifi) localized("pairing_flood_new_names", "new names") else localized("pairing_flood_new_addresses", "new addresses") val fam = if (pairing && families.isNotEmpty()) ": ${families.joinToString(", ")}" else "" - val extra = if (pairing && nameCount >= PairingFlood.NAME_MIN) " · $nameCount named" else "" - val loud = if (medianRssi != null) " · about $medianRssi dBm" else "" + val extra = if (pairing && nameCount >= PairingFlood.NAME_MIN) localized("pairing_flood_named", " · %1\$s named", nameCount) else "" + val loud = if (medianRssi != null) localized("pairing_flood_about_dbm", " · about %1\$s dBm", medianRssi) else "" val aside = when (keys.size) { 0 -> "" - 1 -> " 1 address from this burst is left out of the counts and lists below." - else -> " ${keys.size} addresses from this burst are left out of the counts and lists below." + 1 -> localized("pairing_flood_1_address_from_this_burst_is_left", " 1 address from this burst is left out of the counts and lists below.") + else -> localized("pairing_flood_addresses_from_this_burst_are_left_out", " %1\$s addresses from this burst are left out of the counts and lists below.", keys.size) } return "$clock UTC. $head. $count $unit$fam$extra$loud.$aside" } companion object { - const val INTRO = - "A burst of new Bluetooth addresses in a few seconds. A handheld can do this by advertising a pairing request or a new name and changing the address every packet. The advertisement does not name the tool." + val INTRO: String + get() = + localized("pairing_flood_a_burst_of_new_bluetooth_addresses_in", "A burst of new Bluetooth addresses in a few seconds. A handheld can do this by advertising a pairing request or a new name and changing the address every packet. The advertisement does not name the tool.") - const val WIFI_INTRO = - "A burst of new Wi-Fi names in one scan, about the same loudness, gone by the next scan. A repeated name, a mesh, an extender, or a guest network is not counted. The advertisement does not name the tool." + val WIFI_INTRO: String + get() = + localized("pairing_flood_a_burst_of_new_wi_fi_names", "A burst of new Wi-Fi names in one scan, about the same loudness, gone by the next scan. A repeated name, a mesh, an extender, or a guest network is not counted. The advertisement does not name the tool.") /** Device keys counted in these bursts. A burst with no keys contributes nothing. */ fun keysOf(floods: List): Set { @@ -115,35 +119,35 @@ class PairingFlood { val wifi: Boolean = false, ) { fun title(): String = when { - wifi -> "Wi-Fi beacon flood" - popupCount >= POPUP_MIN -> "Pairing flood" - else -> "Name flood" + wifi -> localized("pairing_flood_wi_fi_beacon_flood", "Wi-Fi beacon flood") + popupCount >= POPUP_MIN -> localized("pairing_flood_pairing_flood", "Pairing flood") + else -> localized("pairing_flood_name_flood", "Name flood") } fun line(): String { if (wifi) { - val loud = if (medianRssi != null) " · about $medianRssi dBm" else "" - return "Wi-Fi beacon flood · $popupCount new names$loud" + val loud = if (medianRssi != null) localized("pairing_flood_about_dbm", " · about %1\$s dBm", medianRssi) else "" + return localized("pairing_flood_wi_fi_beacon_flood_new_names", "Wi-Fi beacon flood · %1\$s new names%2\$s", popupCount, loud) } val popupHot = popupCount >= POPUP_MIN val nameHot = nameCount >= NAME_MIN - val head = if (popupHot) "Pairing flood" else "Name flood" + val head = if (popupHot) localized("pairing_flood_pairing_flood", "Pairing flood") else localized("pairing_flood_name_flood", "Name flood") val count = if (popupHot) popupCount else nameCount - val extra = if (popupHot && nameHot) " · $nameCount named" else "" - val loud = if (medianRssi != null) " · about $medianRssi dBm" else "" - return "$head · $count new addresses$extra$loud" + val extra = if (popupHot && nameHot) localized("pairing_flood_named", " · %1\$s named", nameCount) else "" + val loud = if (medianRssi != null) localized("pairing_flood_about_dbm", " · about %1\$s dBm", medianRssi) else "" + return localized("pairing_flood_new_addresses_2", "%1\$s · %2\$s new addresses%3\$s%4\$s", head, count, extra, loud) } fun body(): String { if (wifi) { val loud = if (medianRssi != null) { - ", about the same loudness, about $medianRssi dBm," + localized("pairing_flood_about_the_same_loudness_about_dbm", ", about the same loudness, about %1\$s dBm,", medianRssi) } else { "" } - val what = "$popupCount new Wi-Fi names showed up in one scan$loud and they were gone on the next scan. " + - "A repeated name, a mesh, an extender, or a guest network is not counted." - val how = "A handheld such as a Flipper Zero, or an ESP32 running Marauder or Bruce, does this by advertising many network names. The advertisement does not name the tool." + val what = localized("pairing_flood_new_wi_fi_names_showed_up_in", "%1\$s new Wi-Fi names showed up in one scan%2\$s and they were gone on the next scan. ", popupCount, loud) + + localized("pairing_flood_a_repeated_name_a_mesh_an_extender", "A repeated name, a mesh, an extender, or a guest network is not counted.") + val how = localized("pairing_flood_a_handheld_such_as_a_flipper_zero", "A handheld such as a Flipper Zero, or an ESP32 running Marauder or Bruce, does this by advertising many network names. The advertisement does not name the tool.") return what + "\n\n" + how } val popupHot = popupCount >= POPUP_MIN @@ -151,7 +155,7 @@ class PairingFlood { val what = buildString { if (popupHot) { append(popupCount) - append(" new addresses sent pairing advertisements in the last few seconds") + append(localized("pairing_flood_new_addresses_sent_pairing_advertisements_in_the", " new addresses sent pairing advertisements in the last few seconds")) if (families.isNotEmpty()) { append(": ") append(families.joinToString(", ")) @@ -160,22 +164,22 @@ class PairingFlood { if (nameHot) { append(' ') append(nameCount) - append(" more each advertised a Bluetooth name.") + append(localized("pairing_flood_more_each_advertised_a_bluetooth_name", " more each advertised a Bluetooth name.")) } } else { append(nameCount) - append(" new addresses each advertised a Bluetooth name in the last few seconds.") + append(localized("pairing_flood_new_addresses_each_advertised_a_bluetooth_name", " new addresses each advertised a Bluetooth name in the last few seconds.")) } if (medianRssi != null) { - append(" They are about the same loudness, about ") + append(localized("pairing_flood_they_are_about_the_same_loudness_about", " They are about the same loudness, about ")) append(medianRssi) - append(" dBm, which reads as one nearby radio.") + append(localized("pairing_flood_dbm_which_reads_as_one_nearby_radio", " dBm, which reads as one nearby radio.")) } } val how = if (popupHot) { - "A handheld such as a Flipper Zero, or an ESP32 running Marauder or Bruce, does this by advertising a pairing request and changing the address every packet. The advertisement does not name the tool." + localized("pairing_flood_a_handheld_such_as_a_flipper_zero_2", "A handheld such as a Flipper Zero, or an ESP32 running Marauder or Bruce, does this by advertising a pairing request and changing the address every packet. The advertisement does not name the tool.") } else { - "A handheld such as a Flipper Zero, or an ESP32 running Marauder or Bruce, does this by advertising a new name and changing the address every packet. The advertisement does not name the tool." + localized("pairing_flood_a_handheld_such_as_a_flipper_zero_3", "A handheld such as a Flipper Zero, or an ESP32 running Marauder or Bruce, does this by advertising a new name and changing the address every packet. The advertisement does not name the tool.") } return what + "\n\n" + how } diff --git a/app/src/main/java/app/fieldwatch/domain/RadioBookmarks.kt b/app/src/main/java/app/fieldwatch/domain/RadioBookmarks.kt index 6f91e55..41bf77b 100644 --- a/app/src/main/java/app/fieldwatch/domain/RadioBookmarks.kt +++ b/app/src/main/java/app/fieldwatch/domain/RadioBookmarks.kt @@ -1,5 +1,7 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.localized + import java.util.UUID object RadioBookmarks { @@ -29,7 +31,7 @@ object RadioBookmarks { fun clipNotes(text: String): String = text.trim().take(MAX_NOTES) fun observerNotesHint(): String = - "Pinned to this MAC. Shows on Debrief, Compare, Path, and AI Export. Does not turn Alert on." + localized("radio_bookmarks_pinned_to_this_mac_shows_on_debrief", "Pinned to this MAC. Shows on Debrief, Compare, Path, and AI Export. Does not turn Alert on.") fun watchedFleetIds(watchlist: List): Set = watchlist.mapNotNull { row -> @@ -56,8 +58,8 @@ object RadioBookmarks { fun pathNote(bookmarked: Boolean, observerNotes: String, mine: Boolean): String { val obs = if (bookmarked) observerNotes.trim() else "" return when { - obs.isNotEmpty() && mine -> "$obs\nMarked mine" - mine -> "Marked mine" + obs.isNotEmpty() && mine -> localized("radio_bookmarks_nmarked_mine", "%1\$s\nMarked mine", obs) + mine -> localized("radio_bookmarks_marked_mine", "Marked mine") else -> obs } } @@ -81,10 +83,10 @@ object RadioBookmarks { if (title.isNotEmpty() && !title.equals(device.mac, ignoreCase = true)) { return clip(title) } - return if (device.kind == RadioKind.BLE) "unnamed LE" else clip(device.mac.takeLast(8)) + return if (device.kind == RadioKind.BLE) localized("radio_bookmarks_unnamed_le", "unnamed LE") else clip(device.mac.takeLast(8)) } - fun clip(name: String): String = name.trim().take(MAX_NAME).ifBlank { "Radio" } + fun clip(name: String): String = name.trim().take(MAX_NAME).ifBlank { localized("radio_bookmarks_radio", "Radio") } /** * BLE privacy addresses rotate; a name would stick to a dead key. @@ -97,9 +99,9 @@ object RadioBookmarks { } fun customNameHint(device: Sighting): String { - val base = "Shows on Live. Bookmark (top-right) is the alert; this does not turn it on." + val base = localized("radio_bookmarks_shows_on_live_bookmark_top_right_is", "Shows on Live. Bookmark (top-right) is the alert; this does not turn it on.") return if (device.kind == RadioKind.WIFI && device.randomized) { - "$base Pinned to this BSSID. Vehicle, mesh, and guest APs often keep a locally administered address." + localized("radio_bookmarks_pinned_to_this_bssid_vehicle_mesh_and", "%1\$s Pinned to this BSSID. Vehicle, mesh, and guest APs often keep a locally administered address.", base) } else { base } @@ -173,7 +175,7 @@ object RadioBookmarks { val key = row.deviceKey ?: return watchlist val suggest = row.label.trim().ifBlank { val mac = key.substringAfter(':') - if (key.startsWith("BLE:")) "unnamed LE" else mac.takeLast(8) + if (key.startsWith("BLE:")) localized("radio_bookmarks_unnamed_le", "unnamed LE") else mac.takeLast(8) } return setMine(watchlist, key, on, suggest) } diff --git a/app/src/main/java/app/fieldwatch/domain/Rssi.kt b/app/src/main/java/app/fieldwatch/domain/Rssi.kt index f5170a6..a1ecc6c 100644 --- a/app/src/main/java/app/fieldwatch/domain/Rssi.kt +++ b/app/src/main/java/app/fieldwatch/domain/Rssi.kt @@ -1,5 +1,7 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.localized + /** * BLE/Wi-Fi RSSI as this phone received it (dBm). Bluetooth uses 127 for * “not available”; that is not transmit power and not a real hear. @@ -12,10 +14,10 @@ object Rssi { if (measured(min)) vals += min if (measured(max)) vals += max for (s in history) if (measured(s.rssi)) vals += s.rssi - if (vals.isEmpty()) return "Not available" + if (vals.isEmpty()) return localized("device_detail_text_not_available", "Not available") val lo = vals.min() val hi = vals.max() - return if (lo == hi) "$lo dBm" else "$lo to $hi dBm" + return if (lo == hi) "$lo dBm" else localized("rssi_session_range", "%1\$s to %2\$s dBm", lo, hi) } fun lastMeasured(rssi: Int, history: List): Int? { diff --git a/app/src/main/java/app/fieldwatch/domain/SettingsExchange.kt b/app/src/main/java/app/fieldwatch/domain/SettingsExchange.kt index 9ae8747..5835fed 100644 --- a/app/src/main/java/app/fieldwatch/domain/SettingsExchange.kt +++ b/app/src/main/java/app/fieldwatch/domain/SettingsExchange.kt @@ -1,5 +1,7 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.localized + import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -29,11 +31,13 @@ data class SettingsImportResult( ) { fun summary(): String { error?.let { return it } - val presetWord = if (presets == 1) "preset" else "presets" - val radioWord = if (namedRadios == 1) "named radio" else "named radios" - val watchWord = if (signatureWatches == 1) "signature watch" else "signature watches" - return "Restored Settings, the current filter, and $presets $presetWord. " + - "$namedRadios $radioWord, $signatureWatches $watchWord." + val preset = if (presets == 1) localized("restore_preset_one", "Restored Settings, the current filter, and %1\$s preset.", presets) + else localized("restore_preset_many", "Restored Settings, the current filter, and %1\$s presets.", presets) + val radio = if (namedRadios == 1) localized("restore_radio_one", "%1\$s named radio", namedRadios) + else localized("restore_radio_many", "%1\$s named radios", namedRadios) + val watch = if (signatureWatches == 1) localized("restore_watch_one", "%1\$s signature watch", signatureWatches) + else localized("restore_watch_many", "%1\$s signature watches", signatureWatches) + return "$preset $radio, $watch." } } @@ -69,24 +73,24 @@ object SettingsExchange { fun parse(text: String): SettingsPack { val trimmed = text.trim().trimStart('\uFEFF') if (trimmed.isEmpty()) { - throw IllegalArgumentException("This file is empty.") + throw IllegalArgumentException(localized("settings_exchange_this_file_is_empty", "This file is empty.")) } val pack = try { json.decodeFromString(SettingsPack.serializer(), trimmed) } catch (e: Exception) { throw IllegalArgumentException( - "Not a Fieldwatch settings pack. Export from Settings → Export settings.", + localized("settings_exchange_not_a_fieldwatch_settings_pack_export_from", "Not a Fieldwatch settings pack. Export from Settings → Export settings."), e, ) } if (pack.format == SignaturePack.FORMAT || pack.format == SignaturePack.LEGACY_FORMAT) { throw IllegalArgumentException( - "That is a signature pack. Use Import signatures.", + localized("settings_exchange_that_is_a_signature_pack_use_import", "That is a signature pack. Use Import signatures."), ) } if (pack.format != SettingsPack.FORMAT) { throw IllegalArgumentException( - "Not a Fieldwatch settings pack (open a fieldwatch-settings JSON file).", + localized("settings_exchange_not_a_fieldwatch_settings_pack_open_a", "Not a Fieldwatch settings pack (open a fieldwatch-settings JSON file)."), ) } return pack diff --git a/app/src/main/java/app/fieldwatch/domain/SignatureCandidates.kt b/app/src/main/java/app/fieldwatch/domain/SignatureCandidates.kt index 89fc80c..bd36074 100644 --- a/app/src/main/java/app/fieldwatch/domain/SignatureCandidates.kt +++ b/app/src/main/java/app/fieldwatch/domain/SignatureCandidates.kt @@ -1,5 +1,7 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.localized + import java.util.UUID data class SignatureCandidate( @@ -26,7 +28,7 @@ data class CandidateReport( val skippedRandomized: Int, val skippedHouseLike: Int, val skippedOther: Int, - val sourceLabel: String = "Rotating log", + val sourceLabel: String = localized("signature_candidates_rotating_log", "Rotating log"), ) enum class FamilyVerdict { STRONG, POSSIBLE, SINGLE, TAGGED } @@ -44,14 +46,14 @@ data class SignatureFamilyHint( internal fun ruleShortLabel(rule: MatchRule): String = when (rule.kind) { RuleKind.NAME_GLOB, RuleKind.NAME_CONTAINS -> rule.text - RuleKind.VENDOR_IE_OUI -> "vendor IE ${rule.text}" + RuleKind.VENDOR_IE_OUI -> localized("signature_candidates_vendor_ie_2", "vendor IE %1\$s", rule.text) RuleKind.OUI, RuleKind.MAC_PREFIX -> "OUI ${rule.text}" RuleKind.SERVICE_UUID -> "UUID ${rule.text}" RuleKind.SERVICE_DATA -> - if (rule.text.isBlank()) "svc contains ${rule.dataPrefixHex}" + if (rule.text.isBlank()) localized("signature_candidates_svc_contains", "svc contains %1\$s", rule.dataPrefixHex) else "UUID ${rule.text} ${rule.dataPrefixHex}" - RuleKind.MANUFACTURER_DATA -> "mfg 0x%04X %s".format(rule.companyId, rule.dataPrefixHex) - RuleKind.MANUFACTURER_ID -> "mfg 0x%04X".format(rule.companyId) + RuleKind.MANUFACTURER_DATA -> localized("signature_candidates_mfg_data", "mfg %1\$s %2\$s", "0x%04X".format(rule.companyId), rule.dataPrefixHex) + RuleKind.MANUFACTURER_ID -> localized("signature_candidates_mfg_id", "mfg %1\$s", "0x%04X".format(rule.companyId)) else -> rule.kind.name } @@ -68,7 +70,7 @@ object SignatureCandidates { radios: List, fleets: List, engine: SignatureEngine = SignatureEngine(), - sourceLabel: String = "Rotating log", + sourceLabel: String = localized("signature_candidates_rotating_log", "Rotating log"), ): CandidateReport { val sightings = radios.map { it.toSighting() } val hits = if (sightings.isEmpty()) emptyMap() else engine.match(sightings, fleets) @@ -123,11 +125,11 @@ object SignatureCandidates { ): SignatureFamilyHint { val tagged = fleets.filter { it.id in device.fleetIds }.map { it.name.trim() }.filter { it.isNotEmpty() } if (device.fleetIds.isNotEmpty()) { - val names = tagged.ifEmpty { listOf("a catalog signature") } + val names = tagged.ifEmpty { listOf(localized("signature_candidates_a_catalog_signature", "a catalog signature")) } return SignatureFamilyHint( verdict = FamilyVerdict.TAGGED, - title = "Already tagged", - body = "Matched ${names.joinToString(", ")}. A second signature can still dual-label this radio (store UUID, product OUI).", + title = localized("signature_candidates_already_tagged", "Already tagged"), + body = localized("signature_candidates_matched_a_second_signature_can_still_dual", "Matched %1\$s. A second signature can still dual-label this radio (store UUID, product OUI).", names.joinToString(", ")), logCount = 0, liveCount = 0, displayCount = 0, @@ -140,8 +142,8 @@ object SignatureCandidates { if (prints.isEmpty()) { return SignatureFamilyHint( verdict = FamilyVerdict.SINGLE, - title = "This radio only", - body = "No unique on-air ID to cluster on. Randomized addresses, house-like names, and generic chips are skipped.", + title = localized("signature_candidates_this_radio_only", "This radio only"), + body = localized("signature_candidates_no_unique_on_air_id_to_cluster", "No unique on-air ID to cluster on. Randomized addresses, house-like names, and generic chips are skipped."), logCount = 0, liveCount = 0, displayCount = 0, @@ -166,8 +168,8 @@ object SignatureCandidates { if (best == null) { return SignatureFamilyHint( verdict = FamilyVerdict.SINGLE, - title = "This radio only", - body = "No unique on-air ID to cluster on. Randomized addresses, house-like names, and generic chips are skipped.", + title = localized("signature_candidates_this_radio_only", "This radio only"), + body = localized("signature_candidates_no_unique_on_air_id_to_cluster", "No unique on-air ID to cluster on. Randomized addresses, house-like names, and generic chips are skipped."), logCount = 0, liveCount = 0, displayCount = 0, @@ -185,8 +187,8 @@ object SignatureCandidates { if (familyN < MIN_RADIOS) { return SignatureFamilyHint( verdict = FamilyVerdict.SINGLE, - title = "This radio only", - body = "No other MAC in the log or on the air shares this $kindLabel. A signature from here will mostly tag this address.", + title = localized("signature_candidates_this_radio_only", "This radio only"), + body = localized("signature_candidates_no_other_mac_in_the_log_or", "No other MAC in the log or on the air shares this %1\$s. A signature from here will mostly tag this address.", kindLabel), logCount = logN, liveCount = liveN, displayCount = 0, @@ -197,11 +199,11 @@ object SignatureCandidates { val strong = familyN >= STRONG_MIN_RADIOS return SignatureFamilyHint( verdict = if (strong) FamilyVerdict.STRONG else FamilyVerdict.POSSIBLE, - title = if (strong) "Strong family" else "Possible family", + title = if (strong) localized("signature_candidates_strong_family", "Strong family") else localized("signature_candidates_possible_family", "Possible family"), body = if (strong) { - "Same $kindLabel on $clause. That is a catalog pattern, not this MAC." + localized("signature_candidates_same_on_that_is_a_catalog_pattern", "Same %1\$s on %2\$s. That is a catalog pattern, not this MAC.", kindLabel, clause) } else { - "Same $kindLabel on $clause. Thin sample — a possible catalog family." + localized("signature_candidates_same_on_thin_sample_a_possible_catalog", "Same %1\$s on %2\$s. Thin sample — a possible catalog family.", kindLabel, clause) }, logCount = logN, liveCount = liveN, @@ -311,7 +313,7 @@ object SignatureCandidates { radios = distinct, proposedName = glob.takeWhile { it != '*' && it != '?' && it != '-' && it != '_' } .ifBlank { glob.trimEnd('*') }, - why = "Same name glob on ${distinct.size} ${kind.radioWord(distinct.size)} — not a house SSID.", + why = localized("signature_candidates_same_name_glob_on_not_a_house", "Same name glob on %1\$s %2\$s — not a house SSID.", distinct.size, kind.radioWord(distinct.size)), ) } RuleKind.VENDOR_IE_OUI -> { @@ -324,8 +326,8 @@ object SignatureCandidates { radioKind = RadioKind.WIFI, members = members, radios = distinct, - proposedName = vendor?.take(22) ?: "Vendor IE $oui", - why = "Same vendor IE on ${distinct.size} BSSIDs. Not WPS / P2P.", + proposedName = vendor?.take(22) ?: localized("signature_candidates_vendor_ie_3", "Vendor IE %1\$s", oui), + why = localized("signature_candidates_same_vendor_ie_on_bssids_not_wps", "Same vendor IE on %1\$s BSSIDs. Not WPS / P2P.", distinct.size), ) } RuleKind.SERVICE_UUID -> { @@ -338,7 +340,7 @@ object SignatureCandidates { members = members, radios = distinct, proposedName = named?.take(22) ?: "UUID $short", - why = "Same service UUID on ${distinct.size} BLE advertisers.", + why = localized("signature_candidates_same_service_uuid_on_ble_advertisers", "Same service UUID on %1\$s BLE advertisers.", distinct.size), ) } RuleKind.MANUFACTURER_DATA, RuleKind.MANUFACTURER_ID -> { @@ -350,8 +352,8 @@ object SignatureCandidates { radioKind = RadioKind.BLE, members = members, radios = distinct, - proposedName = named?.take(22) ?: "Company 0x%04X".format(company), - why = "Same manufacturer data prefix on ${distinct.size} BLE advertisers.", + proposedName = named?.take(22) ?: localized("signature_candidates_company_0x_04x", "Company 0x%04X").format(company), + why = localized("signature_candidates_same_manufacturer_data_prefix_on_ble_advertisers", "Same manufacturer data prefix on %1\$s BLE advertisers.", distinct.size), ) } RuleKind.OUI, RuleKind.MAC_PREFIX -> { @@ -365,7 +367,7 @@ object SignatureCandidates { members = members, radios = distinct, proposedName = vendor?.take(22) ?: oui, - why = "Same IEEE OUI on ${distinct.size} stable BSSIDs. Not a chip-module prefix.", + why = localized("signature_candidates_same_ieee_oui_on_stable_bssids_not", "Same IEEE OUI on %1\$s stable BSSIDs. Not a chip-module prefix.", distinct.size), ) } else -> null @@ -440,21 +442,21 @@ object SignatureCandidates { } private fun idKindLabel(rule: MatchRule): String = when (rule.kind) { - RuleKind.NAME_GLOB, RuleKind.NAME_CONTAINS -> "name glob" - RuleKind.VENDOR_IE_OUI -> "vendor IE" - RuleKind.SERVICE_UUID -> "service UUID" - RuleKind.MANUFACTURER_DATA, RuleKind.MANUFACTURER_ID -> "manufacturer data prefix" - RuleKind.OUI, RuleKind.MAC_PREFIX -> "IEEE OUI" - else -> "on-air ID" + RuleKind.NAME_GLOB, RuleKind.NAME_CONTAINS -> localized("signature_candidates_name_glob", "name glob") + RuleKind.VENDOR_IE_OUI -> localized("signature_candidates_vendor_ie", "vendor IE") + RuleKind.SERVICE_UUID -> localized("signature_candidates_service_uuid", "service UUID") + RuleKind.MANUFACTURER_DATA, RuleKind.MANUFACTURER_ID -> localized("signature_candidates_manufacturer_data_prefix", "manufacturer data prefix") + RuleKind.OUI, RuleKind.MAC_PREFIX -> localized("signature_candidates_ieee_oui", "IEEE OUI") + else -> localized("signature_candidates_on_air_id", "on-air ID") } private fun countClause(kind: RadioKind, logN: Int, liveN: Int): String { val logWord = kind.radioWord(logN) val liveWord = kind.radioWord(liveN) return when { - logN > 0 && liveN > 0 -> "$logN $logWord in the log ($liveN on the air now)" - logN > 0 -> "$logN $logWord in the log" - liveN > 0 -> "$liveN $liveWord on the air now" + logN > 0 && liveN > 0 -> localized("signature_candidates_in_the_log_on_the_air_now", "%1\$s %2\$s in the log (%3\$s on the air now)", logN, logWord, liveN) + logN > 0 -> localized("signature_candidates_in_the_log", "%1\$s %2\$s in the log", logN, logWord) + liveN > 0 -> localized("signature_candidates_on_the_air_now", "%1\$s %2\$s on the air now", liveN, liveWord) else -> "1 ${kind.radioWord(1)}" } } @@ -522,7 +524,7 @@ object SignatureCandidates { why = why, examples = shown, extraCount = extraN, - notes = "${members.size} $word in the log matched ${ruleShortLabel(primary)}. Shared on-air ID, not a one-radio MAC. Change the name or class, then Save.", + notes = localized("signature_candidates_in_the_log_matched_shared_on_air", "%1\$s %2\$s in the log matched %3\$s. Shared on-air ID, not a one-radio MAC. Change the name or class, then Save.", members.size, word, ruleShortLabel(primary)), colorIndex = colorFor(kind), ) } @@ -627,10 +629,10 @@ object SignatureCandidates { } private fun RadioKind.radioWord(n: Int): String = when { - this == RadioKind.WIFI && n == 1 -> "AP" - this == RadioKind.WIFI -> "APs" - n == 1 -> "advertiser" - else -> "advertisers" + this == RadioKind.WIFI && n == 1 -> localized("signature_candidates_ap", "AP") + this == RadioKind.WIFI -> localized("signature_candidates_aps", "APs") + n == 1 -> localized("signature_candidates_advertiser", "advertiser") + else -> localized("signature_candidates_advertisers", "advertisers") } private data class Fingerprint( diff --git a/app/src/main/java/app/fieldwatch/domain/SignatureExchange.kt b/app/src/main/java/app/fieldwatch/domain/SignatureExchange.kt index 50fd38c..e373795 100644 --- a/app/src/main/java/app/fieldwatch/domain/SignatureExchange.kt +++ b/app/src/main/java/app/fieldwatch/domain/SignatureExchange.kt @@ -1,5 +1,7 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.localized + import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.util.UUID @@ -45,15 +47,15 @@ data class SignatureImportResult( fun summary(): String { error?.let { return it } if (added == 0 && merged == 0) { - return if (skipped == 0) "Nothing to import." - else "Nothing new. $skipped already on this phone." + return if (skipped == 0) localized("signature_exchange_nothing_to_import", "Nothing to import.") + else localized("signature_exchange_nothing_new_already_on_this_phone", "Nothing new. %1\$s already on this phone.", skipped) } val parts = mutableListOf() if (added > 0) { - parts += if (renamed > 0) "Added $added ($renamed renamed)" else "Added $added" + parts += if (renamed > 0) localized("signature_exchange_added_renamed", "Added %1\$s (%2\$s renamed)", added, renamed) else localized("signature_exchange_added", "Added %1\$s", added) } - if (merged > 0) parts += "merged extra rules on $merged" - if (skipped > 0) parts += "skipped $skipped already present" + if (merged > 0) parts += localized("signature_exchange_merged_extra_rules_on", "merged extra rules on %1\$s", merged) + if (skipped > 0) parts += localized("signature_exchange_skipped_already_present", "skipped %1\$s already present", skipped) return parts.joinToString(" · ").replaceFirstChar { it.uppercase() } + "." } } @@ -90,23 +92,23 @@ object SignatureExchange { fun parsePack(text: String): ParsedSignaturePack { val trimmed = text.trim().trimStart('\uFEFF') if (trimmed.isEmpty()) { - throw IllegalArgumentException("This file is empty.") + throw IllegalArgumentException(localized("signature_exchange_this_file_is_empty", "This file is empty.")) } val pack = try { json.decodeFromString(SignaturePack.serializer(), trimmed) } catch (e: Exception) { throw IllegalArgumentException( - "Not a Fieldwatch signature pack. Export from Settings → Export signatures.", + localized("signature_exchange_not_a_fieldwatch_signature_pack_export_from", "Not a Fieldwatch signature pack. Export from Settings → Export signatures."), e, ) } if (pack.format != SignaturePack.FORMAT && pack.format != SignaturePack.LEGACY_FORMAT) { throw IllegalArgumentException( - "Not a Fieldwatch signature pack (open a fieldwatch-signatures JSON file; spectre-signatures still imports).", + localized("signature_exchange_not_a_fieldwatch_signature_pack_open_a", "Not a Fieldwatch signature pack (open a fieldwatch-signatures JSON file; spectre-signatures still imports)."), ) } if (pack.fleets.isEmpty()) { - throw IllegalArgumentException("This pack has no signatures.") + throw IllegalArgumentException(localized("signature_exchange_this_pack_has_no_signatures", "This pack has no signatures.")) } var skipped = 0 val fleets = pack.fleets.map { fleet -> @@ -122,7 +124,7 @@ object SignatureExchange { fun merge(existing: List, incoming: List): Pair, SignatureImportResult> { if (incoming.isEmpty()) { - return existing to SignatureImportResult(error = "This pack has no signatures.") + return existing to SignatureImportResult(error = localized("signature_exchange_this_pack_has_no_signatures", "This pack has no signatures.")) } val stockIds = DefaultCatalog.fleets().map { it.id }.toSet() val next = existing.toMutableList() @@ -230,7 +232,7 @@ object SignatureExchange { .filter { it.rules.isNotEmpty() } .map { it.copy(kind = it.kind.folded(), builtIn = true) } if (stockIn.isEmpty()) { - return existing to StockCatalogUpdateResult(error = "This pack has no stock signatures.") + return existing to StockCatalogUpdateResult(error = localized("signature_exchange_this_pack_has_no_stock_signatures", "This pack has no stock signatures.")) } val byId = existing.mapIndexed { index, fleet -> fleet.id to index }.toMap().toMutableMap() val next = existing.toMutableList() diff --git a/app/src/main/java/app/fieldwatch/domain/Sit.kt b/app/src/main/java/app/fieldwatch/domain/Sit.kt index 39881b5..08b1c27 100644 --- a/app/src/main/java/app/fieldwatch/domain/Sit.kt +++ b/app/src/main/java/app/fieldwatch/domain/Sit.kt @@ -7,6 +7,8 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.UUID +import app.fieldwatch.i18n.localized +import app.fieldwatch.i18n.TextRuntime import kotlin.math.max import kotlin.math.min @@ -25,10 +27,10 @@ object Sit { fun clipName(raw: String): String = raw.trim().replace('\n', ' ').replace('\r', ' ').take(NAME_MAX) - fun defaultName(at: Long, locale: Locale = Locale.US): String = - SimpleDateFormat("d MMM HH:mm", locale).format(Date(at)) + fun defaultName(at: Long, locale: Locale = TextRuntime.localeProvider()): String = + SimpleDateFormat(localized("sit_default_name_pattern", "d MMM HH:mm"), locale).format(Date(at)) - fun resolveName(raw: String, at: Long, locale: Locale = Locale.US): String = + fun resolveName(raw: String, at: Long, locale: Locale = TextRuntime.localeProvider()): String = clipName(raw).ifBlank { defaultName(at, locale) } fun fmtDuration(ms: Long): String { @@ -36,9 +38,9 @@ object Sit { val h = s / 3600L val m = (s % 3600L) / 60L return when { - h > 0L -> "$h h $m min" - m > 0L -> "$m min" - else -> "$s s" + h > 0L -> localized("sit_duration_hours", "%1\$s h %2\$s min", h, m) + m > 0L -> localized("sit_duration_minutes", "%1\$s min", m) + else -> localized("sit_duration_seconds", "%1\$s s", s) } } @@ -48,7 +50,7 @@ object Sit { fun dropWarning(closed: List): String? { if (closed.size < CLOSED_CAP) return null val oldest = closed.lastOrNull() ?: return null - return "You already have $CLOSED_CAP saved sits. When you end this one, the oldest (“${oldest.name}”) will be deleted." + return localized("sit_drop_warning", "You already have %1\$s saved sits. When you end this one, the oldest (“%2\$s”) will be deleted.", CLOSED_CAP, oldest.name) } fun pinned( diff --git a/app/src/main/java/app/fieldwatch/domain/SitDiff.kt b/app/src/main/java/app/fieldwatch/domain/SitDiff.kt index 354bf16..ed935a8 100644 --- a/app/src/main/java/app/fieldwatch/domain/SitDiff.kt +++ b/app/src/main/java/app/fieldwatch/domain/SitDiff.kt @@ -1,5 +1,11 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.displayAttentionNotes +import app.fieldwatch.i18n.displayName +import app.fieldwatch.i18n.displayLiveDecode + +import app.fieldwatch.i18n.localized + /** Two sit windows, keyed kind + MAC. BLE rotation is a new row. */ object SitDiff { data class Radio( @@ -31,7 +37,12 @@ object SitDiff { val payloadTrail: List = emptyList(), val payloadAircraft: String = "", val mine: Boolean = false, - ) + val displayFleetNames: List? = null, + val displayDecode: List? = null, + ) { + val renderedFleetNames: List get() = displayFleetNames ?: fleetNames + val renderedDecode: List get() = displayDecode ?: liveDecode + } data class Side( val name: String, @@ -75,10 +86,12 @@ object SitDiff { kind = device.kind, mac = device.mac, name = device.reportName(customNames), - extraAttention = device.attentionNotes(fleets).isNotEmpty(), + extraAttention = device.displayAttentionNotes(fleets).isNotEmpty(), named = device.key in customNames, bookmarked = device.key in bookmarkedKeys, fleetNames = device.fleetIds.map { id -> fleets.firstOrNull { it.id == id }?.name ?: id }, + displayFleetNames = device.fleetIds.map { id -> fleets.firstOrNull { it.id == id }?.displayName() ?: id }, + displayDecode = device.displayLiveDecode(fleets), fleetIds = device.fleetIds.toList(), randomized = device.randomized, lat = SitPathPlot.loudestFix(device)?.lat ?: device.latitude, @@ -117,6 +130,8 @@ object SitDiff { named = row.key in customNames, bookmarked = row.key in bookmarkedKeys, fleetNames = row.fleetIds.map { id -> fleets.firstOrNull { it.id == id }?.name ?: id }, + displayFleetNames = row.fleetIds.map { id -> fleets.firstOrNull { it.id == id }?.displayName() ?: id }, + displayDecode = row.toSighting().displayLiveDecode(fleets), fleetIds = row.fleetIds.toList(), randomized = row.randomized, lat = SitPathPlot.loudestFix(row.gpsTrail)?.lat, @@ -167,8 +182,7 @@ object SitDiff { val onlySecond = secondKeys.minus(thisKeys) val both = thisKeys.intersect(secondKeys) val ramNote = if (thisSit.ram || second.ram) { - "Last 15 minutes is the Live RAM set (about 400 radios). " + - "A named sit keeps up to ${Sit.RADIO_CAP}. Counts are not the same net." + localized("sit_diff_last_15_minutes_is_the_live_ram", "Last 15 minutes is the Live RAM set (about 400 radios). A named sit keeps up to %1\$s. Counts are not the same net.", Sit.RADIO_CAP) } else { null } @@ -178,15 +192,15 @@ object SitDiff { fun next() = n++.toString() sections += DebriefSection( next(), - "Windows", + localized("sit_diff_windows", "Windows"), buildString { - append(sideBlock("This sit", thisSit)) - append(sideBlock("Second sit", second)) + append(sideBlock(localized("sit_diff_this_sit", "This sit"), thisSit)) + append(sideBlock(localized("sit_diff_second_sit", "Second sit"), second)) if (ramNote != null) { appendLine(ramNote) appendLine() } - append("Radios this phone heard. Kind + MAC. BLE rotation is a new row. Not a radio fix.") + append(localized("sit_diff_radios_this_phone_heard_kind_mac_ble", "Radios this phone heard. Kind + MAC. BLE rotation is a new row. Not a radio fix.")) }, chart = presenceChart(onlyThis, onlySecond, both, byKey), ) @@ -194,47 +208,47 @@ object SitDiff { val secondCraft = AircraftTrail.pictures(second.radios.mapNotNull { it.toCraftSource() }, second.path) val aircraftBody = AircraftTrail.compareBody(thisSit.name, thisCraft, second.name, secondCraft) if (aircraftBody.isNotEmpty()) { - sections += DebriefSection(next(), "Aircraft", aircraftBody) + sections += DebriefSection(next(), localized("sit_diff_aircraft", "Aircraft"), aircraftBody) } observerNotesSection(thisSit, second)?.let { body -> - sections += DebriefSection(next(), "Observer notes", body) + sections += DebriefSection(next(), localized("sit_diff_observer_notes", "Observer notes"), body) } markedMineSection(thisSit, second)?.let { body -> - sections += DebriefSection(next(), "Marked mine", body) + sections += DebriefSection(next(), localized("sit_diff_marked_mine", "Marked mine"), body) } if (extraHits.isNotEmpty()) { sections += DebriefSection( next(), - "Extra attention", + localized("sit_diff_extra_attention", "Extra attention"), extraHits.joinToString("\n") { "${it.radioLabel}\n${it.note}" }, alert = true, ) } floodSection(thisSit, second)?.let { body -> - sections += DebriefSection(next(), "Flood", body) + sections += DebriefSection(next(), localized("sit_diff_flood", "Flood"), body) } - sections += rosterSection(next(), "Only in this sit (${onlyThis.size})", onlyThis, byKey, showAllRadios) - sections += rosterSection(next(), "Only in second sit (${onlySecond.size})", onlySecond, byKey, showAllRadios) - sections += bothSection(next(), "In both (${both.size})", both, thisSit, second, byKey, showAllRadios) + sections += rosterSection(next(), localized("sit_diff_only_in_this_sit", "Only in this sit (%1\$s)", onlyThis.size), onlyThis, byKey, showAllRadios) + sections += rosterSection(next(), localized("sit_diff_only_in_second_sit", "Only in second sit (%1\$s)", onlySecond.size), onlySecond, byKey, showAllRadios) + sections += bothSection(next(), localized("sit_diff_in_both", "In both (%1\$s)", both.size), both, thisSit, second, byKey, showAllRadios) val meta = buildList { - add("This sit" to thisSit.name) - add("Second sit" to second.name) - add("This radios" to thisSit.radios.size.toString()) - add("Second radios" to second.radios.size.toString()) - if (ramNote != null) add("Caps" to "RAM ~400 vs sit ${Sit.RADIO_CAP}") + add(localized("sit_diff_this_sit", "This sit") to thisSit.name) + add(localized("sit_diff_second_sit", "Second sit") to second.name) + add(localized("sit_diff_this_radios", "This radios") to thisSit.radios.size.toString()) + add(localized("sit_diff_second_radios", "Second radios") to second.radios.size.toString()) + if (ramNote != null) add(localized("sit_diff_caps", "Caps") to localized("sit_diff_ram_400_vs_sit", "RAM ~400 vs sit %1\$s", Sit.RADIO_CAP)) } return DebriefDoc( generatedUtc = "", - windowLine = "${thisSit.name} vs ${second.name}", + windowLine = localized("sit_diff_vs", "%1\$s vs %2\$s", thisSit.name, second.name), meta = meta, disclaimer = FieldwatchDisclaimer.compare(), trackingAlert = extraHits.isNotEmpty(), takeaway = compareTakeaway(onlyThis.size, onlySecond.size, both.size, mineCount(thisSit, second)), sections = sections, extraAttention = extraHits, - heading = "FIELDWATCH SIT COMPARE", - pdfKicker = "SIT COMPARE", - pdfTitle = "Sit compare", + heading = localized("sit_diff_fieldwatch_sit_compare", "FIELDWATCH SIT COMPARE"), + pdfKicker = localized("sit_diff_sit_compare_2", "SIT COMPARE"), + pdfTitle = localized("sit_diff_sit_compare", "Sit compare"), pathFigure = AircraftTrail.applyWalk( AircraftTrail.applyWalk( pathFigure(thisSit, second, watchedFleetIds), @@ -262,7 +276,7 @@ object SitDiff { }, ) if (tracks.isEmpty()) return null - val pinNote = "A MAC alert or a signature alert is drawn once. A decoded latitude and longitude is the last advertised position. Anything else is the strongest hear. A number is that place (Path key)." + val pinNote = localized("sit_diff_a_mac_alert_or_a_signature_alert", "A MAC alert or a signature alert is drawn once. A decoded latitude and longitude is the last advertised position. Anything else is the strongest hear. A number is that place (Path key).") val points = (thisSit.radios + second.radios) .filter { it.bookmarked || it.fleetIds.any { id -> id in watchedFleetIds } } .distinctBy { it.key } @@ -308,12 +322,12 @@ object SitDiff { val dots = points val all = tracks.flatMap { it.samples } val cap = if (tracks.size == 2) { - "Two walks on one north-up frame. Green = this sit. Slate = second sit. $pinNote" + localized("sit_diff_two_walks_on_one_north_up_frame", "Two walks on one north-up frame. Green = this sit. Slate = second sit. %1\$s", pinNote) } else { - "North-up. Line is this phone. $pinNote" + localized("sit_diff_north_up_line_is_this_phone", "North-up. Line is this phone. %1\$s", pinNote) } return SitPathPlot.Figure( - kicker = if (tracks.size == 2) "OPERATOR PATHS" else "OPERATOR PATH", + kicker = if (tracks.size == 2) localized("sit_diff_operator_paths", "OPERATOR PATHS") else localized("sit_diff_operator_path", "OPERATOR PATH"), tracks = tracks, dots = dots, lengthM = Geo.pathLengthM(all), @@ -362,11 +376,11 @@ object SitDiff { private fun sideBlock(heading: String, side: Side): String { val net = if (side.ram) { - "last 15 minutes in memory (about 400 radios)" + localized("sit_diff_last_15_minutes_in_memory_about_400", "last 15 minutes in memory (about 400 radios)") } else { - "named window (up to ${Sit.RADIO_CAP})" + localized("sit_diff_named_window_up_to", "named window (up to %1\$s)", Sit.RADIO_CAP) } - return "$heading: ${side.name}\n${side.radios.size} radios · $net\n" + return localized("sit_diff_n_radios_n", "%1\$s: %2\$s\n%3\$s radios · %4\$s\n", heading, side.name, side.radios.size, net) } private fun exclusiveExtra( @@ -378,12 +392,12 @@ object SitDiff { val row = byKey[key] ?: return@mapNotNull null if (!row.extraAttention) return@mapNotNull null ExtraAttentionHit( - signature = row.fleetNames.firstOrNull() ?: "Extra attention", + signature = row.renderedFleetNames.firstOrNull() ?: localized("sit_diff_extra_attention", "Extra attention"), radioLabel = line(row), note = where, ) } - return hits(onlyThis, "Only in this sit.") + hits(onlySecond, "Only in second sit.") + return hits(onlyThis, localized("sit_diff_only_in_this_sit_2", "Only in this sit.")) + hits(onlySecond, localized("sit_diff_only_in_second_sit_2", "Only in second sit.")) } private fun presenceChart( @@ -404,9 +418,9 @@ object SitDiff { return ReportChart( split = true, rows = listOf( - row("Only in this sit", onlyThis), - row("In both", both), - row("Only in second sit", onlySecond), + row(localized("sit_diff_only_in_this_sit_3", "Only in this sit"), onlyThis), + row(localized("sit_diff_in_both_2", "In both"), both), + row(localized("sit_diff_only_in_second_sit_3", "Only in second sit"), onlySecond), ), ) } @@ -418,7 +432,7 @@ object SitDiff { byKey: Map, showAll: Boolean, ): DebriefSection { - if (keys.isEmpty()) return DebriefSection(number, title, "(none)") + if (keys.isEmpty()) return DebriefSection(number, title, localized("sit_diff_none", "(none)")) val radios = keys.mapNotNull { byKey[it] } val shown = if (showAll) keys else keys.filter { byKey[it]?.keepShort() == true }.toSet() return DebriefSection( @@ -439,7 +453,7 @@ object SitDiff { byKey: Map, showAll: Boolean, ): DebriefSection { - if (keys.isEmpty()) return DebriefSection(number, title, "(none)") + if (keys.isEmpty()) return DebriefSection(number, title, localized("sit_diff_none", "(none)")) val earlier = thisSit.radios.associateBy { it.key } val later = second.radios.associateBy { it.key } val shown = if (showAll) { @@ -464,8 +478,8 @@ object SitDiff { if (radios.isEmpty()) return null val counts = linkedMapOf() for (radio in radios) { - val sigs = radio.fleetNames.filter { it.isNotBlank() }.distinct() - val key = if (sigs.isEmpty()) "Unmatched" else sigs.joinToString(" + ") + val sigs = radio.renderedFleetNames.filter { it.isNotBlank() }.distinct() + val key = if (sigs.isEmpty()) localized("sit_diff_unmatched", "Unmatched") else sigs.joinToString(" + ") counts[key] = (counts[key] ?: 0) + 1 } val rows = counts.entries @@ -484,7 +498,7 @@ object SitDiff { } private fun listBody(keys: Set, byKey: Map): String { - if (keys.isEmpty()) return "(none)" + if (keys.isEmpty()) return localized("sit_diff_none", "(none)") return keys.mapNotNull { byKey[it] } .sortedWith( compareByDescending { it.extraAttention } @@ -497,7 +511,7 @@ object SitDiff { /** Kind + MAC is the same radio. A live label can still change between windows. */ private fun bothBody(keys: Set, thisSit: Side, second: Side): String { - if (keys.isEmpty()) return "(none)" + if (keys.isEmpty()) return localized("sit_diff_none", "(none)") val earlier = thisSit.radios.associateBy { it.key } val later = second.radios.associateBy { it.key } return keys.mapNotNull { key -> @@ -518,12 +532,12 @@ object SitDiff { val right = later.liveDecode.reportLabels() when { left.isNotEmpty() && right.isNotEmpty() && left != right -> { - append(" decoded value changed: ") - append(left.joinToString(", ")) + append(localized("sit_diff_decoded_value_changed", " decoded value changed: ")) + append(earlier.renderedDecode.reportLabels().joinToString(", ")) append(" → ") - append(right.joinToString(", ")) + append(later.renderedDecode.reportLabels().joinToString(", ")) } - else -> append(chipSuffix(if (right.isNotEmpty()) later.liveDecode else earlier.liveDecode)) + else -> append(chipSuffix(if (right.isNotEmpty()) later.renderedDecode else earlier.renderedDecode)) } } @@ -536,13 +550,13 @@ object SitDiff { append(" ") append(label) } - if (row.mine) append(" Marked mine") - row.fleetNames.filter { it.isNotBlank() }.forEach { name -> + if (row.mine) append(localized("sit_diff_marked_mine_2", " Marked mine")) + row.renderedFleetNames.filter { it.isNotBlank() }.forEach { name -> append(" ") append(name) } - if (row.extraAttention) append(" Extra attention") - if (chips) append(chipSuffix(row.liveDecode)) + if (row.extraAttention) append(localized("sit_diff_extra_attention_2", " Extra attention")) + if (chips) append(chipSuffix(row.renderedDecode)) } private fun chipSuffix(chips: List): String { @@ -565,8 +579,8 @@ object SitDiff { (thisSit.radios + second.radios).distinctBy { it.key }.count { it.mine } private fun compareTakeaway(onlyThis: Int, onlySecond: Int, both: Int, mine: Int): String { - val counts = "$onlyThis only in this sit · $onlySecond only in the second · $both in both." - return if (mine > 0) "$counts · $mine marked mine." else counts + val counts = localized("sit_diff_only_in_this_sit_only_in_the", "%1\$s only in this sit · %2\$s only in the second · %3\$s in both.", onlyThis, onlySecond, both) + return if (mine > 0) localized("sit_diff_marked_mine_3", "%1\$s · %2\$s marked mine.", counts, mine) else counts } private fun floodSection(thisSit: Side, second: Side): String? { @@ -590,13 +604,13 @@ object SitDiff { private fun markedMineSection(thisSit: Side, second: Side): String? { fun where(key: String): String = when { key in thisSit.keys && key in second.keys -> "both" - key in thisSit.keys -> "this sit" - else -> "second sit" + key in thisSit.keys -> localized("sit_diff_this_sit_2", "this sit") + else -> localized("sit_diff_second_sit_2", "second sit") } val rows = (thisSit.radios + second.radios).distinctBy { it.key }.filter { it.mine } if (rows.isEmpty()) return null return buildString { - appendLine("Radios you marked mine. Heard in either window. Still listed.") + appendLine(localized("sit_diff_radios_you_marked_mine_heard_in_either", "Radios you marked mine. Heard in either window. Still listed.")) rows.sortedWith( compareBy { where(it.key) }.thenBy { it.mac }, ).forEach { r -> @@ -613,8 +627,8 @@ object SitDiff { private fun observerNotesSection(thisSit: Side, second: Side): String? { fun where(key: String): String = when { key in thisSit.keys && key in second.keys -> "both" - key in thisSit.keys -> "this sit" - else -> "second sit" + key in thisSit.keys -> localized("sit_diff_this_sit_2", "this sit") + else -> localized("sit_diff_second_sit_2", "second sit") } val rows = (thisSit.radios + second.radios) .distinctBy { it.key } @@ -624,7 +638,7 @@ object SitDiff { } if (rows.isEmpty()) return null return buildString { - appendLine("Your captions on radios heard in either window. Same KIND+MAC as Named radios. Not catalog Notes.") + appendLine(localized("sit_diff_your_captions_on_radios_heard_in_either", "Your captions on radios heard in either window. Same KIND+MAC as Named radios. Not catalog Notes.")) rows.sortedWith( compareBy> { where(it.first.key) }.thenBy { it.first.mac }, ).forEach { (r, note) -> diff --git a/app/src/main/java/app/fieldwatch/domain/SitDiffPrompt.kt b/app/src/main/java/app/fieldwatch/domain/SitDiffPrompt.kt index 1d71766..b56766b 100644 --- a/app/src/main/java/app/fieldwatch/domain/SitDiffPrompt.kt +++ b/app/src/main/java/app/fieldwatch/domain/SitDiffPrompt.kt @@ -1,5 +1,7 @@ package app.fieldwatch.domain +import app.fieldwatch.i18n.localized + /** * Paste-ready addendum prompt for Reports → Compare sits → AI Export. * Onboard compare is verbatim. Working data is overlap + exclusive Extra attention / @@ -42,107 +44,108 @@ object SitDiffPrompt { if (label.isNotEmpty() && !label.equals(row.mac, ignoreCase = true)) { append(" ").append(label) } - if (row.mine) append(" Marked mine") - row.fleetNames.filter { it.isNotBlank() }.forEach { append(" ").append(it) } - if (row.extraAttention) append(" Extra attention") - val labels = row.liveDecode.reportLabels() + if (row.mine) append(localized("sit_diff_prompt_marked_mine", " Marked mine")) + row.renderedFleetNames.filter { it.isNotBlank() }.forEach { append(" ").append(it) } + if (row.extraAttention) append(localized("sit_diff_prompt_extra_attention", " Extra attention")) + val labels = row.renderedDecode.reportLabels() if (labels.isNotEmpty()) append(" ").append(labels.joinToString(", ")) - if (row.kind == RadioKind.BLE && row.randomized) append(" RAND") + if (row.kind == RadioKind.BLE && row.randomized) append(localized("sit_diff_prompt_rand", " RAND")) } fun exclusive(keys: Set, where: String, pred: (SitDiff.Radio) -> Boolean) = keys.mapNotNull { byKey[it] }.filter(pred).map { "$where ${line(it)}" } val extraRows = - exclusive(onlyThis, "Only in this sit", { it.extraAttention }) + - exclusive(onlySecond, "Only in second sit", { it.extraAttention }) + exclusive(onlyThis, localized("sit_diff_prompt_only_in_this_sit", "Only in this sit"), { it.extraAttention }) + + exclusive(onlySecond, localized("sit_diff_prompt_only_in_second_sit", "Only in second sit"), { it.extraAttention }) val namedRows = - exclusive(onlyThis, "Only in this sit", { it.named }) + - exclusive(onlySecond, "Only in second sit", { it.named }) + exclusive(onlyThis, localized("sit_diff_prompt_only_in_this_sit", "Only in this sit"), { it.named }) + + exclusive(onlySecond, localized("sit_diff_prompt_only_in_second_sit", "Only in second sit"), { it.named }) val body = buildString { append(FieldwatchDisclaimer.experimentalMarkdown()) appendLine() - appendLine("You are a field RF analyst for the operator who compared two Fieldwatch sits. Fieldwatch is a stock-Android, receive-only Wi-Fi access-point + BLE-advertiser listener.") + appendLine(localized("ai_response_language", "Write your response in English. Preserve verbatim identifiers and observer notes.")) + appendLine(localized("sit_diff_prompt_you_are_a_field_rf_analyst_for", "You are a field RF analyst for the operator who compared two Fieldwatch sits. Fieldwatch is a stock-Android, receive-only Wi-Fi access-point + BLE-advertiser listener.")) appendLine() - appendLine("The **onboard Compare** (verbatim below) already split presence: only in this sit, only in the second, in both. **Do not rewrite that report. Do not reprint those lists.** Your job is an addendum the phone cannot write: what kind of change this is, and how much of it is real.") + appendLine(localized("sit_diff_prompt_the_onboard_compare_verbatim_below_already_split", "The **onboard Compare** (verbatim below) already split presence: only in this sit, only in the second, in both. **Do not rewrite that report. Do not reprint those lists.** Your job is an addendum the phone cannot write: what kind of change this is, and how much of it is real.")) appendLine() - appendLine("Constraints you must respect:") - appendLine("- Hear-only. Kind + MAC. BLE rotation is a new row and will not stitch.") - appendLine("- Wi-Fi rows are access points only. Associated clients are invisible.") - appendLine("- Extra attention / signature matches are hypotheses, not identity, not a person or vehicle.") - appendLine("- GPS stamps (if present) are this phone at hear-time, not the other radio.") - appendLine("- Last 15 minutes vs a named sit is not the same net (RAM about 400 vs sit ${Sit.RADIO_CAP}). Missing BLE on the RAM side can be eviction, not gone.") - appendLine("- Presence is not co-travel. Do not invent a tail, a follower, or a camera location.") - appendLine("- A radio marked mine was claimed by the operator. Do not treat it as an unexplained follower.") - appendLine("- A flood note is a burst of new addresses, not a follower.") - appendLine("- A decoded live value on a row is catalog text for that kind + MAC. If the onboard compare says that value changed, state the change. Do not stitch that value onto a different MAC.") - appendLine("- An aircraft block is positions the radio advertised, joined by UAS id. If the onboard compare says the status changed, state the change. That track is not this phone's GPS.") - appendLine("- Do not give safety advice. Do not tell the operator they are safe or in danger.") - appendLine("- Treat this paste as operationally sensitive.") + appendLine(localized("sit_diff_prompt_constraints_you_must_respect", "Constraints you must respect:")) + appendLine(localized("sit_diff_prompt_hear_only_kind_mac_ble_rotation_is", "- Hear-only. Kind + MAC. BLE rotation is a new row and will not stitch.")) + appendLine(localized("sit_diff_prompt_wi_fi_rows_are_access_points_only", "- Wi-Fi rows are access points only. Associated clients are invisible.")) + appendLine(localized("sit_diff_prompt_extra_attention_signature_matches_are_hypotheses_not", "- Extra attention / signature matches are hypotheses, not identity, not a person or vehicle.")) + appendLine(localized("sit_diff_prompt_gps_stamps_if_present_are_this_phone", "- GPS stamps (if present) are this phone at hear-time, not the other radio.")) + appendLine(localized("sit_diff_prompt_last_15_minutes_vs_a_named_sit", "- Last 15 minutes vs a named sit is not the same net (RAM about 400 vs sit %1\$s). Missing BLE on the RAM side can be eviction, not gone.", Sit.RADIO_CAP)) + appendLine(localized("sit_diff_prompt_presence_is_not_co_travel_do_not", "- Presence is not co-travel. Do not invent a tail, a follower, or a camera location.")) + appendLine(localized("sit_diff_prompt_a_radio_marked_mine_was_claimed_by", "- A radio marked mine was claimed by the operator. Do not treat it as an unexplained follower.")) + appendLine(localized("sit_diff_prompt_a_flood_note_is_a_burst_of", "- A flood note is a burst of new addresses, not a follower.")) + appendLine(localized("sit_diff_prompt_a_decoded_live_value_on_a_row", "- A decoded live value on a row is catalog text for that kind + MAC. If the onboard compare says that value changed, state the change. Do not stitch that value onto a different MAC.")) + appendLine(localized("sit_diff_prompt_an_aircraft_block_is_positions_the_radio", "- An aircraft block is positions the radio advertised, joined by UAS id. If the onboard compare says the status changed, state the change. That track is not this phone's GPS.")) + appendLine(localized("sit_diff_prompt_do_not_give_safety_advice_do_not", "- Do not give safety advice. Do not tell the operator they are safe or in danger.")) + appendLine(localized("sit_diff_prompt_treat_this_paste_as_operationally_sensitive", "- Treat this paste as operationally sensitive.")) appendLine() - appendLine("## Your output (required — this is the addendum the operator reads)") - appendLine("Write complete sentences. Headings as below. Short bullets only for exclusive Extra attention / Named radios. No markdown tables. No code fences. No dump of the onboard lists.") + appendLine(localized("sit_diff_prompt_your_output_required_this_is_the_addendum", "## Your output (required — this is the addendum the operator reads)")) + appendLine(localized("sit_diff_prompt_write_complete_sentences_headings_as_below_short", "Write complete sentences. Headings as below. Short bullets only for exclusive Extra attention / Named radios. No markdown tables. No code fences. No dump of the onboard lists.")) appendLine() - appendLine("1. **Disclaimer** — Repeat the experimental-use disclaimer first.") - appendLine("2. **What the onboard compare already established** — 3–5 sentences. Window names, counts, Extra attention exclusives, Observer notes if any, Marked mine if any, Flood if any. Do not reprint inventories.") - appendLine("3. **What the numbers add** — Overlap (both/union as a percent), Wi-Fi vs BLE in each bucket, how much exclusive BLE is RAND. Say whether this looks like fixtures, a different stall/hour, or a cap artifact. Confidence. Use the working table; do not invent rates.") - appendLine("4. **Exclusive Extra attention and Named radios** — Full identifiers from the working table (complete MAC, name, signatures, which window). Pattern match, not identity. If none, say none.") - appendLine("5. **What another sit or Hunt would shrink** — Concrete in-app next steps only (a third sit at the same stall, Hunt on one exclusive Extra attention row, Filters). No safety advice. No “call the police.”") + appendLine(localized("sit_diff_prompt_1_disclaimer_repeat_the_experimental_use_disclaimer", "1. **Disclaimer** — Repeat the experimental-use disclaimer first.")) + appendLine(localized("sit_diff_prompt_2_what_the_onboard_compare_already_established", "2. **What the onboard compare already established** — 3–5 sentences. Window names, counts, Extra attention exclusives, Observer notes if any, Marked mine if any, Flood if any. Do not reprint inventories.")) + appendLine(localized("sit_diff_prompt_3_what_the_numbers_add_overlap_both", "3. **What the numbers add** — Overlap (both/union as a percent), Wi-Fi vs BLE in each bucket, how much exclusive BLE is RAND. Say whether this looks like fixtures, a different stall/hour, or a cap artifact. Confidence. Use the working table; do not invent rates.")) + appendLine(localized("sit_diff_prompt_4_exclusive_extra_attention_and_named_radios", "4. **Exclusive Extra attention and Named radios** — Full identifiers from the working table (complete MAC, name, signatures, which window). Pattern match, not identity. If none, say none.")) + appendLine(localized("sit_diff_prompt_5_what_another_sit_or_hunt_would", "5. **What another sit or Hunt would shrink** — Concrete in-app next steps only (a third sit at the same stall, Hunt on one exclusive Extra attention row, Filters). No safety advice. No “call the police.”")) appendLine() - appendLine("**Takeaway (required, last line).** One sentence starting with `Takeaway:` that adds *one number the onboard takeaway does not already say* (overlap percent, exclusive Extra attention count, or RAND fraction of exclusive BLE). Not a moral. Not a threat level.") + appendLine(localized("sit_diff_prompt_takeaway_required_last_line_one_sentence_starting", "**Takeaway (required, last line).** One sentence starting with `Takeaway:` that adds *one number the onboard takeaway does not already say* (overlap percent, exclusive Extra attention count, or RAND fraction of exclusive BLE). Not a moral. Not a threat level.")) appendLine() - appendLine("## Onboard Compare (verbatim — already shown to the operator; do not rewrite)") + appendLine(localized("sit_diff_prompt_onboard_compare_verbatim_already_shown_to_the", "## Onboard Compare (verbatim — already shown to the operator; do not rewrite)")) appendLine() appendLine(onboard.toPlainText().trimEnd()) appendLine() - appendLine("## Working data (for the addendum — do not copy rosters into the answer)") + appendLine(localized("sit_diff_prompt_working_data_for_the_addendum_do_not", "## Working data (for the addendum — do not copy rosters into the answer)")) appendLine() - appendLine("This sit: ${thisSit.name} (${thisSit.radios.size} radios${if (thisSit.ram) ", RAM ~400" else ", named sit up to ${Sit.RADIO_CAP}"})") - appendLine("Second sit: ${second.name} (${second.radios.size} radios${if (second.ram) ", RAM ~400" else ", named sit up to ${Sit.RADIO_CAP}"})") - appendLine("Only in this sit: ${onlyThis.size} Only in second: ${onlySecond.size} In both: ${both.size} Union: ${union.size} Overlap: $overlapPct%") - appendLine("Only in this sit by radio: Wi-Fi ${onlyThisB.first} BLE ${onlyThisB.second} RAND BLE ${onlyThisB.third}") - appendLine("Only in second sit by radio: Wi-Fi ${onlySecondB.first} BLE ${onlySecondB.second} RAND BLE ${onlySecondB.third}") - appendLine("In both by radio: Wi-Fi ${bothB.first} BLE ${bothB.second} RAND BLE ${bothB.third}") + appendLine(localized("sit_diff_prompt_this_sit_radios", "This sit: %1\$s (%2\$s radios%3\$s)", thisSit.name, thisSit.radios.size, if (thisSit.ram) localized("sit_diff_prompt_ram_400", ", RAM ~400") else localized("sit_diff_prompt_named_sit_up_to", ", named sit up to %1\$s", Sit.RADIO_CAP))) + appendLine(localized("sit_diff_prompt_second_sit_radios", "Second sit: %1\$s (%2\$s radios%3\$s)", second.name, second.radios.size, if (second.ram) localized("sit_diff_prompt_ram_400", ", RAM ~400") else localized("sit_diff_prompt_named_sit_up_to", ", named sit up to %1\$s", Sit.RADIO_CAP))) + appendLine(localized("sit_diff_prompt_only_in_this_sit_only_in_second", "Only in this sit: %1\$s Only in second: %2\$s In both: %3\$s Union: %4\$s Overlap: %5\$s%%", onlyThis.size, onlySecond.size, both.size, union.size, overlapPct)) + appendLine(localized("sit_diff_prompt_only_in_this_sit_by_radio_wi", "Only in this sit by radio: Wi-Fi %1\$s BLE %2\$s RAND BLE %3\$s", onlyThisB.first, onlyThisB.second, onlyThisB.third)) + appendLine(localized("sit_diff_prompt_only_in_second_sit_by_radio_wi", "Only in second sit by radio: Wi-Fi %1\$s BLE %2\$s RAND BLE %3\$s", onlySecondB.first, onlySecondB.second, onlySecondB.third)) + appendLine(localized("sit_diff_prompt_in_both_by_radio_wi_fi_ble", "In both by radio: Wi-Fi %1\$s BLE %2\$s RAND BLE %3\$s", bothB.first, bothB.second, bothB.third)) if (thisSit.ram || second.ram) { - appendLine("Cap note: last 15 minutes is Live RAM (about 400). A named sit keeps up to ${Sit.RADIO_CAP}. Counts are not the same net.") + appendLine(localized("sit_diff_prompt_cap_note_last_15_minutes_is_live", "Cap note: last 15 minutes is Live RAM (about 400). A named sit keeps up to %1\$s. Counts are not the same net.", Sit.RADIO_CAP)) } appendLine() - appendLine("Exclusive Extra attention:") - if (extraRows.isEmpty()) appendLine("- None.") + appendLine(localized("sit_diff_prompt_exclusive_extra_attention", "Exclusive Extra attention:")) + if (extraRows.isEmpty()) appendLine(localized("sit_diff_prompt_none", "- None.")) else extraRows.forEach { appendLine("- $it") } appendLine() - appendLine("Exclusive Named radios:") - if (namedRows.isEmpty()) appendLine("- None.") + appendLine(localized("sit_diff_prompt_exclusive_named_radios", "Exclusive Named radios:")) + if (namedRows.isEmpty()) appendLine(localized("sit_diff_prompt_none", "- None.")) else namedRows.forEach { appendLine("- $it") } appendLine() - appendLine("Observer notes:") + appendLine(localized("sit_diff_prompt_observer_notes", "Observer notes:")) val observed = (thisSit.radios + second.radios) .distinctBy { it.key } .mapNotNull { r -> val note = r.observerNotes.trim().takeIf { it.isNotEmpty() } ?: return@mapNotNull null r to note } - if (observed.isEmpty()) appendLine("- None.") + if (observed.isEmpty()) appendLine(localized("sit_diff_prompt_none", "- None.")) else observed.forEach { (r, note) -> val where = when { - r.key in onlyThis -> "Only in this sit" - r.key in onlySecond -> "Only in second sit" - else -> "In both" + r.key in onlyThis -> localized("sit_diff_prompt_only_in_this_sit", "Only in this sit") + r.key in onlySecond -> localized("sit_diff_prompt_only_in_second_sit", "Only in second sit") + else -> localized("sit_diff_prompt_in_both", "In both") } appendLine("- $where ${line(r)}") appendLine(" $note") } appendLine() - appendLine("## End of working data") - appendLine("Write the addendum now, following **Your output** at the top. Do not rewrite the onboard Compare.") + appendLine(localized("sit_diff_prompt_end_of_working_data", "## End of working data")) + appendLine(localized("sit_diff_prompt_write_the_addendum_now_following_your_output", "Write the addendum now, following **Your output** at the top. Do not rewrite the onboard Compare.")) } val masked = MacUtil.redactMacsIn(body, macs, demoMode) val withPrivacy = if (demoMode) { - "Privacy mode: MAC tails are **:**:**. GPS coordinates are masked. Logs on the phone are unchanged.\n\n$masked" + localized("sit_diff_prompt_privacy_mode_mac_tails_are_gps_coordinates", "Privacy mode: MAC tails are **:**:**. GPS coordinates are masked. Logs on the phone are unchanged.\n\n%1\$s", masked) } else { masked } return if (withPrivacy.length <= MAX_CHARS) withPrivacy - else withPrivacy.take(MAX_CHARS) + "\n\n[truncated for share-sheet size]\n" + else withPrivacy.take(MAX_CHARS) + localized("sit_diff_prompt_n_n_truncated_for_share_sheet_size", "\n\n[truncated for share-sheet size]\n") } } diff --git a/app/src/main/java/app/fieldwatch/i18n/AppLanguage.kt b/app/src/main/java/app/fieldwatch/i18n/AppLanguage.kt new file mode 100644 index 0000000..ca13699 --- /dev/null +++ b/app/src/main/java/app/fieldwatch/i18n/AppLanguage.kt @@ -0,0 +1,66 @@ +package app.fieldwatch.i18n + +import android.content.Context +import android.content.res.Configuration +import android.os.LocaleList +import androidx.annotation.StringRes +import androidx.annotation.PluralsRes +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.ContextCompat +import androidx.core.os.LocaleListCompat +import java.util.Locale + +/** One locale source for activities, services, notifications and exports. */ +object AppLanguage { + private lateinit var application: Context + @Volatile private var activityReady = false + private var cachedConfiguration: Configuration? = null + private var cachedContext: Context? = null + + fun initialize(context: Context) { + application = context.applicationContext + TextRuntime.localeProvider = ::locale + TextRuntime.renderer = { key, english, args -> + TextResources.ids[key]?.let { appText(it, *args) } ?: TextRuntime.english(english, args) + } + } + + /** Called after AppCompat restores its language, without retaining an Activity. */ + fun refresh() { + activityReady = true + } + + @Synchronized + fun context(): Context { + val base = if (activityReady) application else ContextCompat.getContextForLanguage(application) + val configuration = Configuration(base.resources.configuration) + val selected = AppCompatDelegate.getApplicationLocales().toLanguageTags() + if (selected.isNotEmpty()) configuration.setLocales(LocaleList.forLanguageTags(selected)) + // Default English resources also require English dates and speech on unsupported systems. + val requested = configuration.locales[0] + val simplifiedChinese = requested.language == "zh" && requested.script != "Hant" && + requested.country !in listOf("TW", "HK", "MO") + if (requested.language != "en" && !simplifiedChinese) configuration.setLocales(LocaleList(Locale.US)) + if (configuration != cachedConfiguration) { + cachedConfiguration = configuration + cachedContext = application.createConfigurationContext(configuration) + } + return cachedContext ?: application + } + + fun locale(): Locale = context().resources.configuration.locales[0] + + fun selection(): String = AppCompatDelegate.getApplicationLocales().toLanguageTags() + + fun select(languageTag: String) { + require(languageTag in listOf("", "en", "zh-Hans")) + AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(languageTag)) + } +} + +/** Also usable from callbacks and services, where Compose stringResource is unavailable. */ +fun appText(@StringRes id: Int, vararg args: Any?): String = + AppLanguage.context().resources.let { if (args.isEmpty()) it.getString(id) else it.getString(id, *args) } + +fun appQuantity(@PluralsRes id: Int, count: Int): String = + AppLanguage.context().resources.getQuantityString(id, count, count) diff --git a/app/src/main/java/app/fieldwatch/i18n/CatalogText.kt b/app/src/main/java/app/fieldwatch/i18n/CatalogText.kt new file mode 100644 index 0000000..849900f --- /dev/null +++ b/app/src/main/java/app/fieldwatch/i18n/CatalogText.kt @@ -0,0 +1,88 @@ +package app.fieldwatch.i18n + +import app.fieldwatch.domain.* + +/** An overlay on untouched stock fields; no translated text is persisted as a rule. */ +object CatalogText { + private val stock by lazy { DefaultCatalog.fleets().associateBy { it.id } } + private val presets by lazy { FilterEngine().defaultPresets().associateBy { it.id } } + + fun field(fleet: Fleet, field: String, value: String): String { + if (!fleet.builtIn) return value + val original = stock[fleet.id] ?: return value + val expected = when (field) { + "name" -> original.name + "notes" -> original.notes + "attention" -> original.attentionNote + else -> return value + } + if (value != expected) return value + return localized("catalog_${fleet.id.replace('-', '_')}_$field", value) + } + + fun preset(preset: FilterPreset): String { + val original = presets[preset.id] ?: return preset.name + if (preset.name != original.name) return preset.name + return localized("preset_${preset.id.replace('-', '_')}", preset.name) + } + + /** Editors render stock text without replacing the underlying editable map. */ + fun decodeField(fleet: Fleet, field: DecodeField, part: String, value: String, enumKey: String = ""): String { + if (!fleet.builtIn) return value + val source = stock[fleet.id]?.decode?.fields?.firstOrNull { it.id == field.id } ?: return value + val expected = when (part) { + "label" -> source.label + "enum" -> source.enumLabels?.get(enumKey) + "note" -> source.enumNotes?.get(enumKey) + else -> return value + } + if (value != expected) return value + val base = "catalog_${fleet.id.replace('-', '_')}_decode_${field.id.replace('-', '_')}_$part" + return localized(if (part == "label") base else "${base}_${resourceKey(enumKey)}", value) + } + + fun decoded(row: DecodedFieldValue, fleets: List): DecodedFieldValue { + val fleet = fleets.firstOrNull { it.id == row.fleetId && it.builtIn } ?: return row + val original = stock[fleet.id] ?: return row + val field = fleet.decode?.fields?.firstOrNull { it.id == row.id } ?: return row + val source = original.decode?.fields?.firstOrNull { it.id == row.id } ?: return row + val key = "catalog_${fleet.id.replace('-', '_')}_decode_${row.id.replace('-', '_')}" + val label = if (row.label == source.label) localized("${key}_label", row.label) else row.label + val unit = field.unit?.trim().orEmpty() + val unitSuffix = if (unit.isEmpty()) "" else " $unit" + val value = row.display.removeSuffix(unitSuffix) + val named = source.enumLabels?.entries?.firstOrNull { (id, text) -> + text == value && field.enumLabels?.get(id) == text + } + val display = if (named != null) localized("${key}_enum_${resourceKey(named.key)}", value) + unitSuffix + else if (field.type == DecodeType.BOOL && field.enumLabels.isNullOrEmpty() && value in listOf("yes", "no")) + localized("decode_boolean_$value", value) + unitSuffix + else row.display + val note = source.enumNotes?.entries?.firstOrNull { (id, text) -> + text.trim() == row.note && field.enumNotes?.get(id)?.trim() == row.note + }?.let { localized("${key}_note_${resourceKey(it.key)}", row.note) } ?: row.note + return row.copy(fleetName = fleet.displayName(), label = label, display = display, note = note) + } + + private fun resourceKey(value: String): String = value.replace('-', '_').replace(Regex("[^a-zA-Z0-9_]"), "_") +} + +fun List.forDisplay(fleets: List): List = map { CatalogText.decoded(it, fleets) } + +/** Re-render cached parse results, keeping recorded payloads and user decode maps intact. */ +fun Sighting.displayLiveDecode(fleets: List): List { + val parsed = SignatureFieldDecoder.decodeSighting(this, fleets).forDisplay(fleets) + if (parsed.isEmpty()) return liveDecode + return parsed.filter { it.live && it.display.isNotBlank() }.distinctBy { it.display.lowercase() } + .map { LiveDecodeChip(it.display.trim(), it.emphasis, it.note.trim()) } +} + +fun Fleet.displayName(): String = CatalogText.field(this, "name", name) +fun Fleet.displayNotes(): String = CatalogText.field(this, "notes", notes) +fun Fleet.displayAttention(): String = CatalogText.field(this, "attention", attentionNote) + +fun Sighting.displayAttentionNotes(fleets: List): List> = + fleetIds.mapNotNull { id -> fleets.firstOrNull { it.id == id && it.attentionNote.isNotBlank() }?.let { it.displayName() to it.displayAttention() } } + +fun Sighting.displaySignatureNotes(fleets: List): List> = + fleetIds.mapNotNull { id -> fleets.firstOrNull { it.id == id && it.notes.isNotBlank() }?.let { it.displayName() to it.displayNotes() } } diff --git a/app/src/main/java/app/fieldwatch/i18n/CodLabels.kt b/app/src/main/java/app/fieldwatch/i18n/CodLabels.kt new file mode 100644 index 0000000..b4271d0 --- /dev/null +++ b/app/src/main/java/app/fieldwatch/i18n/CodLabels.kt @@ -0,0 +1,132 @@ +package app.fieldwatch.i18n + +import app.fieldwatch.domain.CodDecoder + +/** Display translation only; CodDecoder keeps canonical words used by device inference. */ +fun codLabel(value: String): String { + if (" + " in value) return value.split(" + ").joinToString(" + ") { codLabel(it) } + return when (value) { + "Miscellaneous" -> localized("cod_miscellaneous", "Miscellaneous") + "Computer" -> localized("cod_computer", "Computer") + "Phone" -> localized("cod_phone", "Phone") + "LAN / Network AP" -> localized("cod_lan_network_ap", "LAN / Network AP") + "Audio / Video" -> localized("cod_audio_video", "Audio / Video") + "Peripheral" -> localized("cod_peripheral", "Peripheral") + "Imaging" -> localized("cod_imaging", "Imaging") + "Wearable" -> localized("cod_wearable", "Wearable") + "Toy" -> localized("cod_toy", "Toy") + "Health" -> localized("cod_health", "Health") + "Uncategorized" -> localized("cod_uncategorized", "Uncategorized") + "Desktop" -> localized("cod_desktop", "Desktop") + "Server" -> localized("cod_server", "Server") + "Laptop" -> localized("cod_laptop", "Laptop") + "Handheld PC/PDA" -> localized("cod_handheld_pc_pda", "Handheld PC/PDA") + "Palm-size PDA" -> localized("cod_palm_size_pda", "Palm-size PDA") + "Wearable computer" -> localized("cod_wearable_computer", "Wearable computer") + "Tablet" -> localized("cod_tablet", "Tablet") + "Cellular" -> localized("cod_cellular", "Cellular") + "Cordless" -> localized("cod_cordless", "Cordless") + "Smartphone" -> localized("cod_smartphone", "Smartphone") + "Wired modem / voice gateway" -> localized("cod_wired_modem_voice_gateway", "Wired modem / voice gateway") + "Common ISDN access" -> localized("cod_common_isdn_access", "Common ISDN access") + "Fully available" -> localized("cod_fully_available", "Fully available") + "1–17% utilized" -> localized("cod_1_17_utilized", "1–17% utilized") + "17–33% utilized" -> localized("cod_17_33_utilized", "17–33% utilized") + "33–50% utilized" -> localized("cod_33_50_utilized", "33–50% utilized") + "50–67% utilized" -> localized("cod_50_67_utilized", "50–67% utilized") + "67–83% utilized" -> localized("cod_67_83_utilized", "67–83% utilized") + "83–99% utilized" -> localized("cod_83_99_utilized", "83–99% utilized") + "No service available" -> localized("cod_no_service_available", "No service available") + "Wearable headset" -> localized("cod_wearable_headset", "Wearable headset") + "Hands-free" -> localized("cod_hands_free", "Hands-free") + "Microphone" -> localized("cod_microphone", "Microphone") + "Loudspeaker" -> localized("cod_loudspeaker", "Loudspeaker") + "Headphones" -> localized("cod_headphones", "Headphones") + "Portable audio" -> localized("cod_portable_audio", "Portable audio") + "Car audio" -> localized("cod_car_audio", "Car audio") + "Set-top box" -> localized("cod_set_top_box", "Set-top box") + "HiFi audio" -> localized("cod_hifi_audio", "HiFi audio") + "VCR" -> localized("cod_vcr", "VCR") + "Video camera" -> localized("cod_video_camera", "Video camera") + "Camcorder" -> localized("cod_camcorder", "Camcorder") + "Video monitor" -> localized("cod_video_monitor", "Video monitor") + "Video display and loudspeaker" -> localized("cod_video_display_and_loudspeaker", "Video display and loudspeaker") + "Video conferencing" -> localized("cod_video_conferencing", "Video conferencing") + "Gaming / toy" -> localized("cod_gaming_toy", "Gaming / toy") + "Joystick" -> localized("cod_joystick", "Joystick") + "Gamepad" -> localized("cod_gamepad", "Gamepad") + "Remote control" -> localized("cod_remote_control", "Remote control") + "Sensing device" -> localized("cod_sensing_device", "Sensing device") + "Digitizer tablet" -> localized("cod_digitizer_tablet", "Digitizer tablet") + "Card reader" -> localized("cod_card_reader", "Card reader") + "Digital pen" -> localized("cod_digital_pen", "Digital pen") + "Handheld scanner" -> localized("cod_handheld_scanner", "Handheld scanner") + "Handheld gestural input" -> localized("cod_handheld_gestural_input", "Handheld gestural input") + "keyboard" -> localized("cod_keyboard", "keyboard") + "pointing" -> localized("cod_pointing", "pointing") + "keyboard/pointing" -> localized("cod_keyboard_pointing", "keyboard/pointing") + "Keyboard" -> localized("cod_keyboard_capital", "Keyboard") + "Pointing" -> localized("cod_pointing_capital", "Pointing") + "Keyboard/pointing" -> localized("cod_keyboard_pointing_capital", "Keyboard/pointing") + "Display" -> localized("cod_display", "Display") + "Camera" -> localized("cod_camera", "Camera") + "Scanner" -> localized("cod_scanner", "Scanner") + "Printer" -> localized("cod_printer", "Printer") + "Wristwatch" -> localized("cod_wristwatch", "Wristwatch") + "Pager" -> localized("cod_pager", "Pager") + "Jacket" -> localized("cod_jacket", "Jacket") + "Helmet" -> localized("cod_helmet", "Helmet") + "Glasses" -> localized("cod_glasses", "Glasses") + "Robot" -> localized("cod_robot", "Robot") + "Vehicle" -> localized("cod_vehicle", "Vehicle") + "Doll / action figure" -> localized("cod_doll_action_figure", "Doll / action figure") + "Controller" -> localized("cod_controller", "Controller") + "Game" -> localized("cod_game", "Game") + "Blood pressure monitor" -> localized("cod_blood_pressure_monitor", "Blood pressure monitor") + "Thermometer" -> localized("cod_thermometer", "Thermometer") + "Weighing scale" -> localized("cod_weighing_scale", "Weighing scale") + "Glucose meter" -> localized("cod_glucose_meter", "Glucose meter") + "Pulse oximeter" -> localized("cod_pulse_oximeter", "Pulse oximeter") + "Heart / pulse rate monitor" -> localized("cod_heart_pulse_rate_monitor", "Heart / pulse rate monitor") + "Health data display" -> localized("cod_health_data_display", "Health data display") + "Step counter" -> localized("cod_step_counter", "Step counter") + "Body composition analyzer" -> localized("cod_body_composition_analyzer", "Body composition analyzer") + "Peak flow monitor" -> localized("cod_peak_flow_monitor", "Peak flow monitor") + "Medication monitor" -> localized("cod_medication_monitor", "Medication monitor") + "Knee prosthesis" -> localized("cod_knee_prosthesis", "Knee prosthesis") + "Ankle prosthesis" -> localized("cod_ankle_prosthesis", "Ankle prosthesis") + "Generic health manager" -> localized("cod_generic_health_manager", "Generic health manager") + "Personal mobility device" -> localized("cod_personal_mobility_device", "Personal mobility device") + "Limited Discoverable" -> localized("cod_limited_discoverable", "Limited Discoverable") + "Positioning" -> localized("cod_positioning", "Positioning") + "Networking" -> localized("cod_networking", "Networking") + "Rendering" -> localized("cod_rendering", "Rendering") + "Capturing" -> localized("cod_capturing", "Capturing") + "Object Transfer" -> localized("cod_object_transfer", "Object Transfer") + "Audio" -> localized("cod_audio", "Audio") + "Telephony" -> localized("cod_telephony", "Telephony") + "Information" -> localized("cod_information", "Information") + else -> { + val prefix = value.substringBeforeLast(' ', "") + val suffix = value.substringAfterLast(' ') + when (prefix) { + "format" -> localized("cod_unknown_format", "format %1\$s", suffix) + "Major" -> localized("cod_unknown_major", "Major %1\$s", suffix) + "Computer" -> localized("cod_unknown_computer", "Computer %1\$s", suffix) + "Phone" -> localized("cod_unknown_phone", "Phone %1\$s", suffix) + "A/V" -> localized("cod_unknown_a_v", "A/V %1\$s", suffix) + "Peripheral" -> localized("cod_unknown_peripheral", "Peripheral %1\$s", suffix) + "Wearable" -> localized("cod_unknown_wearable", "Wearable %1\$s", suffix) + "Toy" -> localized("cod_unknown_toy", "Toy %1\$s", suffix) + "Health" -> localized("cod_unknown_health", "Health %1\$s", suffix) + else -> value + } + } + } +} + +fun CodDecoder.Decoded.displaySummary(): String = buildString { + append(codLabel(major)) + if (minor.isNotBlank() && minor != "Uncategorized") append(" / ").append(codLabel(minor)) + if (services.isNotEmpty()) append(" · ").append(services.joinToString(", ") { codLabel(it) }) +} diff --git a/app/src/main/java/app/fieldwatch/i18n/EnumLabels.kt b/app/src/main/java/app/fieldwatch/i18n/EnumLabels.kt new file mode 100644 index 0000000..cd0f660 --- /dev/null +++ b/app/src/main/java/app/fieldwatch/i18n/EnumLabels.kt @@ -0,0 +1,61 @@ +package app.fieldwatch.i18n + +import app.fieldwatch.domain.* + +/** Display labels are separate from persisted enums and matching inputs. */ + +fun SignatureClass.displayLabel(): String = when (this) { + SignatureClass.FINDER -> localized("label_signatureclass_finder", "Finder tags") + SignatureClass.BEACON -> localized("label_signatureclass_beacon", "Retail beacons") + SignatureClass.SIGNAGE -> localized("label_signatureclass_signage", "Signage") + SignatureClass.WEARABLE -> localized("label_signatureclass_wearable", "Wearables") + SignatureClass.SURVEILLANCE -> localized("label_signatureclass_surveillance", "Surveillance") + SignatureClass.DRONE -> localized("label_signatureclass_drone", "Drones") + SignatureClass.HACKING -> localized("label_signatureclass_hacking", "Pentest") + SignatureClass.BODYWORN -> localized("label_signatureclass_bodyworn", "Body-worn") + SignatureClass.LAW_ENFORCEMENT -> localized("label_signatureclass_law_enforcement", "Public safety") + SignatureClass.VEHICLE -> localized("label_signatureclass_vehicle", "Vehicle") + SignatureClass.GLASSES -> localized("label_signatureclass_glasses", "Glasses") + SignatureClass.AUDIO -> localized("label_signatureclass_audio", "Audio") + SignatureClass.CAMERA -> localized("label_signatureclass_camera", "Cameras") + SignatureClass.THERMOSTAT -> localized("label_signatureclass_thermostat", "Thermostats") + SignatureClass.LOCK -> localized("label_signatureclass_lock", "Access control") + SignatureClass.HEALTH -> localized("label_signatureclass_health", "Health") + SignatureClass.HOME -> localized("label_signatureclass_home", "Home IoT") + SignatureClass.ISP -> localized("label_signatureclass_isp", "ISP / routers") + SignatureClass.MESH -> localized("label_signatureclass_mesh", "Mesh") + SignatureClass.PHONE -> localized("label_signatureclass_phone", "Phones / PCs") + SignatureClass.OTHER -> localized("label_signatureclass_other", "Other") +} + +fun ViewMode.displayLabel(): String = when (this) { + ViewMode.RADAR -> localized("label_viewmode_radar", "Classic radar") + ViewMode.LIST -> localized("label_viewmode_list", "Strength list") + ViewMode.TIMELINE -> localized("label_viewmode_timeline", "Timeline") + ViewMode.HYBRID -> localized("label_viewmode_hybrid", "Hybrid + sparklines") + ViewMode.BY_CLASS -> localized("label_viewmode_by_class", "By class") +} + +fun AlertVoiceWhat.displayLabel(): String = when (this) { + AlertVoiceWhat.CLASS -> localized("label_alertvoicewhat_class", "Class") + AlertVoiceWhat.SIGNATURE -> localized("label_alertvoicewhat_signature", "Signature") + AlertVoiceWhat.BOTH -> localized("label_alertvoicewhat_both", "Class + signature") +} + +fun SignatureListSort.displayLabel(): String = when (this) { + SignatureListSort.NAME -> localized("label_signaturelistsort_name", "Name A–Z") + SignatureListSort.CLASS -> localized("label_signaturelistsort_class", "Class A–Z") +} + +fun RadioKind.displayLabel(): String = when (this) { + RadioKind.WIFI -> localized("label_radiokind_wifi", "Wi-Fi") + RadioKind.BLE -> localized("label_radiokind_ble", "BLE") +} + +fun ScanIntensity.displayLabel(): String = when (this) { + ScanIntensity.SAVER -> localized("label_scanintensity_saver", "Battery saver") + ScanIntensity.BALANCED -> localized("label_scanintensity_balanced", "Balanced") + ScanIntensity.PERFORMANCE -> localized("label_scanintensity_performance", "High performance") +} + +fun ClassSlice.displayLabel(): String = kind?.displayLabel() ?: localized("label_unmatched", "Unmatched") diff --git a/app/src/main/java/app/fieldwatch/i18n/TakText.kt b/app/src/main/java/app/fieldwatch/i18n/TakText.kt new file mode 100644 index 0000000..f87139c --- /dev/null +++ b/app/src/main/java/app/fieldwatch/i18n/TakText.kt @@ -0,0 +1,13 @@ +package app.fieldwatch.i18n + +/** Stored/transport status remains canonical; display strings follow the current language. */ +fun takDetail(value: String): String = when { + value == "Off" -> localized("tak_off", "Off") + value == "send failed" -> localized("tak_send_failed", "send failed") + value == "Privacy mode — feed paused" -> localized("tak_privacy_paused", "Privacy mode — feed paused") + value == "0 eligible radios (need Extra attention / payload latlon / GPS stamp)" -> + localized("tak_no_eligible", "0 eligible radios (need Extra attention / payload latlon / GPS stamp)") + value.startsWith("Host ") && value.endsWith(" did not resolve") -> + localized("tak_host_unresolved", "Host %1\$s did not resolve", value.removePrefix("Host ").removeSuffix(" did not resolve")) + else -> value +} diff --git a/app/src/main/java/app/fieldwatch/i18n/TextResources.kt b/app/src/main/java/app/fieldwatch/i18n/TextResources.kt new file mode 100644 index 0000000..c8a15c6 --- /dev/null +++ b/app/src/main/java/app/fieldwatch/i18n/TextResources.kt @@ -0,0 +1,2188 @@ +package app.fieldwatch.i18n + +import app.fieldwatch.R + +// Generated by tools/update-i18n-resources.mjs. Explicit references survive resource shrinking. +internal object TextResources { + val ids: Map = group0() + group1() + group2() + group3() + group4() + group5() + group6() + group7() + group8() + group9() + group10() + group11() + group12() + group13() + group14() + group15() + group16() + group17() + group18() + group19() + group20() + group21() + + private fun group0(): Map = mapOf( + "adv_payload_decoder_0x_06x_not_in_the_local_name" to R.string.adv_payload_decoder_0x_06x_not_in_the_local_name, + "adv_payload_decoder_5_bars" to R.string.adv_payload_decoder_5_bars, + "adv_payload_decoder_a_fast_pair_accessory_already_paired_to" to R.string.adv_payload_decoder_a_fast_pair_accessory_already_paired_to, + "adv_payload_decoder_a_fast_pair_accessory_often_earbuds_or" to R.string.adv_payload_decoder_a_fast_pair_accessory_often_earbuds_or, + "adv_payload_decoder_a_find_my_network_radio" to R.string.adv_payload_decoder_a_find_my_network_radio, + "adv_payload_decoder_a_google_find_hub_tag" to R.string.adv_payload_decoder_a_google_find_hub_tag, + "adv_payload_decoder_a_is_advertising_for_quick_pairing_or" to R.string.adv_payload_decoder_a_is_advertising_for_quick_pairing_or, + "adv_payload_decoder_a_just_heard_a_siri_trigger_the" to R.string.adv_payload_decoder_a_just_heard_a_siri_trigger_the, + "adv_payload_decoder_a_target_atrius_basket_tag" to R.string.adv_payload_decoder_a_target_atrius_basket_tag, + "adv_payload_decoder_a_tesla_vehicle_or_phone_as_key" to R.string.adv_payload_decoder_a_tesla_vehicle_or_phone_as_key, + "adv_payload_decoder_account_key_bloom_filter" to R.string.adv_payload_decoder_account_key_bloom_filter, + "adv_payload_decoder_action_0x_02x" to R.string.adv_payload_decoder_action_0x_02x, + "adv_payload_decoder_active_screen_on" to R.string.adv_payload_decoder_active_screen_on, + "adv_payload_decoder_activity_0x_x" to R.string.adv_payload_decoder_activity_0x_x, + "adv_payload_decoder_activity_reporting_off" to R.string.adv_payload_decoder_activity_reporting_off, + "adv_payload_decoder_activity_unknown" to R.string.adv_payload_decoder_activity_unknown, + "adv_payload_decoder_airdrop_receiving_on" to R.string.adv_payload_decoder_airdrop_receiving_on, + "adv_payload_decoder_airplay_advertisement" to R.string.adv_payload_decoder_airplay_advertisement, + "adv_payload_decoder_airpods_1st_generation" to R.string.adv_payload_decoder_airpods_1st_generation, + "adv_payload_decoder_airpods_2nd_generation" to R.string.adv_payload_decoder_airpods_2nd_generation, + "adv_payload_decoder_airpods_3rd_generation" to R.string.adv_payload_decoder_airpods_3rd_generation, + "adv_payload_decoder_airpods_4th_generation" to R.string.adv_payload_decoder_airpods_4th_generation, + "adv_payload_decoder_airpods_connected" to R.string.adv_payload_decoder_airpods_connected, + "adv_payload_decoder_airpods_or_beats_headphones" to R.string.adv_payload_decoder_airpods_or_beats_headphones, + "adv_payload_decoder_airpods_pro_2nd_generation" to R.string.adv_payload_decoder_airpods_pro_2nd_generation, + "adv_payload_decoder_already_paired_to_an_account_not_in" to R.string.adv_payload_decoder_already_paired_to_an_account_not_in, + "adv_payload_decoder_altbeacon_major_minor" to R.string.adv_payload_decoder_altbeacon_major_minor, + "adv_payload_decoder_an_airplay_speaker_or_apple_tv" to R.string.adv_payload_decoder_an_airplay_speaker_or_apple_tv, + "adv_payload_decoder_an_apple_device_doing_handoff" to R.string.adv_payload_decoder_an_apple_device_doing_handoff, + "adv_payload_decoder_an_apple_device_looking_for_instant_hotspot" to R.string.adv_payload_decoder_an_apple_device_looking_for_instant_hotspot, + "adv_payload_decoder_an_apple_device_nearby_action" to R.string.adv_payload_decoder_an_apple_device_nearby_action, + "adv_payload_decoder_an_apple_device_that_just_heard_hey" to R.string.adv_payload_decoder_an_apple_device_that_just_heard_hey, + "adv_payload_decoder_an_eddystone_beacon" to R.string.adv_payload_decoder_an_eddystone_beacon, + "adv_payload_decoder_an_ibeacon" to R.string.adv_payload_decoder_an_ibeacon, + "adv_payload_decoder_an_iphone_ipad_is_offering_a_personal" to R.string.adv_payload_decoder_an_iphone_ipad_is_offering_a_personal, + "adv_payload_decoder_an_iphone_ipad_mac_nearby_info" to R.string.adv_payload_decoder_an_iphone_ipad_mac_nearby_info, + "adv_payload_decoder_an_iphone_ipad_offering_instant_hotspot" to R.string.adv_payload_decoder_an_iphone_ipad_offering_instant_hotspot, + "adv_payload_decoder_an_iphone_or_ipad_offering_airdrop" to R.string.adv_payload_decoder_an_iphone_or_ipad_offering_airdrop, + "adv_payload_decoder_answered_a_call" to R.string.adv_payload_decoder_answered_a_call, + "adv_payload_decoder_apple_airdrop_advertisement" to R.string.adv_payload_decoder_apple_airdrop_advertisement, + "adv_payload_decoder_apple_audio_0x_04x" to R.string.adv_payload_decoder_apple_audio_0x_04x, + "adv_payload_decoder_apple_continuity_type" to R.string.adv_payload_decoder_apple_continuity_type, + "adv_payload_decoder_apple_device_usage_state" to R.string.adv_payload_decoder_apple_device_usage_state, + "adv_payload_decoder_apple_ibeacon_payload" to R.string.adv_payload_decoder_apple_ibeacon_payload, + "adv_payload_decoder_apple_id_hash_2_bytes" to R.string.adv_payload_decoder_apple_id_hash_2_bytes, + "adv_payload_decoder_apple_offline_finding_airtag_find_my_accessory" to R.string.adv_payload_decoder_apple_offline_finding_airtag_find_my_accessory, + "adv_payload_decoder_apple_payload" to R.string.adv_payload_decoder_apple_payload, + "adv_payload_decoder_apple_proximity_pairing" to R.string.adv_payload_decoder_apple_proximity_pairing, + "adv_payload_decoder_apple_proximity_pairing_airpods_beats" to R.string.adv_payload_decoder_apple_proximity_pairing_airpods_beats, + "adv_payload_decoder_apple_tv_pair" to R.string.adv_payload_decoder_apple_tv_pair, + "adv_payload_decoder_apple_tv_setup" to R.string.adv_payload_decoder_apple_tv_setup, + "adv_payload_decoder_apple_watch_wrist_unlock_related" to R.string.adv_payload_decoder_apple_watch_wrist_unlock_related, + "adv_payload_decoder_audio_playing_screen_locked" to R.string.adv_payload_decoder_audio_playing_screen_locked, + "adv_payload_decoder_audio_playing_with_the_screen_locked" to R.string.adv_payload_decoder_audio_playing_with_the_screen_locked, + "adv_payload_decoder_battery" to R.string.adv_payload_decoder_battery, + "adv_payload_decoder_battery_left_right" to R.string.adv_payload_decoder_battery_left_right, + "adv_payload_decoder_black" to R.string.adv_payload_decoder_black, + "adv_payload_decoder_blue" to R.string.adv_payload_decoder_blue, + "adv_payload_decoder_both_in_case_lid_closed" to R.string.adv_payload_decoder_both_in_case_lid_closed, + "adv_payload_decoder_both_in_case_lid_open" to R.string.adv_payload_decoder_both_in_case_lid_open, + "adv_payload_decoder_both_in_ear" to R.string.adv_payload_decoder_both_in_ear, + "adv_payload_decoder_both_out_not_in_ear" to R.string.adv_payload_decoder_both_out_not_in_ear, + "adv_payload_decoder_broadcasting_a_public_key_so_the_find" to R.string.adv_payload_decoder_broadcasting_a_public_key_so_the_find, + "adv_payload_decoder_byte_key_fragment_not_a_serial_number" to R.string.adv_payload_decoder_byte_key_fragment_not_a_serial_number, + "adv_payload_decoder_bytes" to R.string.adv_payload_decoder_bytes, + "adv_payload_decoder_bytes_2" to R.string.adv_payload_decoder_bytes_2, + "adv_payload_decoder_bytes_of_homekit_setup_data" to R.string.adv_payload_decoder_bytes_of_homekit_setup_data, + "adv_payload_decoder_bytes_unparsed" to R.string.adv_payload_decoder_bytes_unparsed, + "adv_payload_decoder_case" to R.string.adv_payload_decoder_case, + "adv_payload_decoder_case_battery" to R.string.adv_payload_decoder_case_battery, + "adv_payload_decoder_case_open" to R.string.adv_payload_decoder_case_open, + "adv_payload_decoder_charging" to R.string.adv_payload_decoder_charging, + "adv_payload_decoder_class_0x_04x" to R.string.adv_payload_decoder_class_0x_04x, + "adv_payload_decoder_color" to R.string.adv_payload_decoder_color, + "adv_payload_decoder_companion_link_proximity" to R.string.adv_payload_decoder_companion_link_proximity, + "adv_payload_decoder_continuity_handoff_a_task_can_be_continued" to R.string.adv_payload_decoder_continuity_handoff_a_task_can_be_continued, + "adv_payload_decoder_critical" to R.string.adv_payload_decoder_critical, + "adv_payload_decoder_dark_blue" to R.string.adv_payload_decoder_dark_blue, + "adv_payload_decoder_dbm_at_1_m_used_to_estimate" to R.string.adv_payload_decoder_dbm_at_1_m_used_to_estimate, + "adv_payload_decoder_device_reports_the_user_is_driving" to R.string.adv_payload_decoder_device_reports_the_user_is_driving, + "adv_payload_decoder_eddystone_service_data" to R.string.adv_payload_decoder_eddystone_service_data, + "adv_payload_decoder_eddystone_uid_instance" to R.string.adv_payload_decoder_eddystone_uid_instance, + "adv_payload_decoder_eddystone_uid_namespace" to R.string.adv_payload_decoder_eddystone_uid_namespace, + "adv_payload_decoder_ended_a_call" to R.string.adv_payload_decoder_ended_a_call, + "adv_payload_decoder_ephemeral_id_rotating" to R.string.adv_payload_decoder_ephemeral_id_rotating, + "adv_payload_decoder_filter_type" to R.string.adv_payload_decoder_filter_type, + "adv_payload_decoder_find_hub_nearby_frame" to R.string.adv_payload_decoder_find_hub_nearby_frame, + "adv_payload_decoder_find_hub_separated_unwanted_tracking_frame" to R.string.adv_payload_decoder_find_hub_separated_unwanted_tracking_frame, + "adv_payload_decoder_find_my_offline_finding" to R.string.adv_payload_decoder_find_my_offline_finding, + "adv_payload_decoder_frame_0x_02x" to R.string.adv_payload_decoder_frame_0x_02x, + "adv_payload_decoder_full" to R.string.adv_payload_decoder_full, + "adv_payload_decoder_gold" to R.string.adv_payload_decoder_gold, + "adv_payload_decoder_google_fast_pair_account_key_broadcast_not" to R.string.adv_payload_decoder_google_fast_pair_account_key_broadcast_not, + "adv_payload_decoder_google_fast_pair_model_0x_06x_in" to R.string.adv_payload_decoder_google_fast_pair_model_0x_06x_in, + "adv_payload_decoder_google_fast_pair_model_0x_06x_in_2" to R.string.adv_payload_decoder_google_fast_pair_model_0x_06x_in_2, + "adv_payload_decoder_google_manufacturer_data" to R.string.adv_payload_decoder_google_manufacturer_data, + "adv_payload_decoder_gray" to R.string.adv_payload_decoder_gray, + "adv_payload_decoder_handoff_advertisement" to R.string.adv_payload_decoder_handoff_advertisement, + "adv_payload_decoder_handoff_or_instant_hotspot_target" to R.string.adv_payload_decoder_handoff_or_instant_hotspot_target, + "adv_payload_decoder_hey_siri_advertisement" to R.string.adv_payload_decoder_hey_siri_advertisement, + ) + + private fun group1(): Map = mapOf( + "adv_payload_decoder_hiding_the_pairing_card_e_g_buds" to R.string.adv_payload_decoder_hiding_the_pairing_card_e_g_buds, + "adv_payload_decoder_ibeacon_calibrated_tx" to R.string.adv_payload_decoder_ibeacon_calibrated_tx, + "adv_payload_decoder_ibeacon_major_minor" to R.string.adv_payload_decoder_ibeacon_major_minor, + "adv_payload_decoder_idle_screen_locked" to R.string.adv_payload_decoder_idle_screen_locked, + "adv_payload_decoder_in_a_phone_or_facetime_call" to R.string.adv_payload_decoder_in_a_phone_or_facetime_call, + "adv_payload_decoder_in_case" to R.string.adv_payload_decoder_in_case, + "adv_payload_decoder_in_ear_activity" to R.string.adv_payload_decoder_in_ear_activity, + "adv_payload_decoder_in_pairing_mode_android_will_pop_a" to R.string.adv_payload_decoder_in_pairing_mode_android_will_pop_a, + "adv_payload_decoder_instant_hotspot_looking" to R.string.adv_payload_decoder_instant_hotspot_looking, + "adv_payload_decoder_instant_hotspot_offering" to R.string.adv_payload_decoder_instant_hotspot_offering, + "adv_payload_decoder_instant_hotspot_source" to R.string.adv_payload_decoder_instant_hotspot_source, + "adv_payload_decoder_ios_setup" to R.string.adv_payload_decoder_ios_setup, + "adv_payload_decoder_left" to R.string.adv_payload_decoder_left, + "adv_payload_decoder_light_blue" to R.string.adv_payload_decoder_light_blue, + "adv_payload_decoder_low" to R.string.adv_payload_decoder_low, + "adv_payload_decoder_medium" to R.string.adv_payload_decoder_medium, + "adv_payload_decoder_microsoft_manufacturer_data" to R.string.adv_payload_decoder_microsoft_manufacturer_data, + "adv_payload_decoder_mobile_backup" to R.string.adv_payload_decoder_mobile_backup, + "adv_payload_decoder_model_id" to R.string.adv_payload_decoder_model_id, + "adv_payload_decoder_nearby_action" to R.string.adv_payload_decoder_nearby_action, + "adv_payload_decoder_nearby_action_advertisement" to R.string.adv_payload_decoder_nearby_action_advertisement, + "adv_payload_decoder_nearby_action_extended" to R.string.adv_payload_decoder_nearby_action_extended, + "adv_payload_decoder_nearby_info_advertisement" to R.string.adv_payload_decoder_nearby_info_advertisement, + "adv_payload_decoder_nearby_with_owner" to R.string.adv_payload_decoder_nearby_with_owner, + "adv_payload_decoder_offline_finding_advertisement" to R.string.adv_payload_decoder_offline_finding_advertisement, + "adv_payload_decoder_one_in_ear" to R.string.adv_payload_decoder_one_in_ear, + "adv_payload_decoder_one_in_ear_sharing" to R.string.adv_payload_decoder_one_in_ear_sharing, + "adv_payload_decoder_one_or_both_out_of_the_case" to R.string.adv_payload_decoder_one_or_both_out_of_the_case, + "adv_payload_decoder_owner_not_seen_in_the_current_key" to R.string.adv_payload_decoder_owner_not_seen_in_the_current_key, + "adv_payload_decoder_owner_seen_recently" to R.string.adv_payload_decoder_owner_seen_recently, + "adv_payload_decoder_paired_iphone_ipad_hotspot" to R.string.adv_payload_decoder_paired_iphone_ipad_hotspot, + "adv_payload_decoder_payload" to R.string.adv_payload_decoder_payload, + "adv_payload_decoder_phone_battery" to R.string.adv_payload_decoder_phone_battery, + "adv_payload_decoder_phone_is_idle_locked" to R.string.adv_payload_decoder_phone_is_idle_locked, + "adv_payload_decoder_phone_or_facetime_call" to R.string.adv_payload_decoder_phone_or_facetime_call, + "adv_payload_decoder_pink" to R.string.adv_payload_decoder_pink, + "adv_payload_decoder_pod_position" to R.string.adv_payload_decoder_pod_position, + "adv_payload_decoder_primary_icloud_device" to R.string.adv_payload_decoder_primary_icloud_device, + "adv_payload_decoder_product" to R.string.adv_payload_decoder_product, + "adv_payload_decoder_proximity_pairing" to R.string.adv_payload_decoder_proximity_pairing, + "adv_payload_decoder_proximity_pairing_airpods_beats" to R.string.adv_payload_decoder_proximity_pairing_airpods_beats, + "adv_payload_decoder_proximity_pairing_truncated" to R.string.adv_payload_decoder_proximity_pairing_truncated, + "adv_payload_decoder_recent_interaction" to R.string.adv_payload_decoder_recent_interaction, + "adv_payload_decoder_red" to R.string.adv_payload_decoder_red, + "adv_payload_decoder_remote_autofill" to R.string.adv_payload_decoder_remote_autofill, + "adv_payload_decoder_remote_display" to R.string.adv_payload_decoder_remote_display, + "adv_payload_decoder_repair" to R.string.adv_payload_decoder_repair, + "adv_payload_decoder_right" to R.string.adv_payload_decoder_right, + "adv_payload_decoder_rose_gold" to R.string.adv_payload_decoder_rose_gold, + "adv_payload_decoder_screen_is_on_someone_is_using_it" to R.string.adv_payload_decoder_screen_is_on_someone_is_using_it, + "adv_payload_decoder_screen_on_video_playing" to R.string.adv_payload_decoder_screen_on_video_playing, + "adv_payload_decoder_separated_unwanted_tracking_mode" to R.string.adv_payload_decoder_separated_unwanted_tracking_mode, + "adv_payload_decoder_silver" to R.string.adv_payload_decoder_silver, + "adv_payload_decoder_siri_was_just_triggered_on_a_nearby" to R.string.adv_payload_decoder_siri_was_just_triggered_on_a_nearby, + "adv_payload_decoder_someone_nearby_has_airdrop_receiving_on_hashes" to R.string.adv_payload_decoder_someone_nearby_has_airdrop_receiving_on_hashes, + "adv_payload_decoder_someone_nearby_is_offering_airdrop_bytes" to R.string.adv_payload_decoder_someone_nearby_is_offering_airdrop_bytes, + "adv_payload_decoder_space_gray" to R.string.adv_payload_decoder_space_gray, + "adv_payload_decoder_speaker_setup" to R.string.adv_payload_decoder_speaker_setup, + "adv_payload_decoder_status_0x_02x" to R.string.adv_payload_decoder_status_0x_02x, + "adv_payload_decoder_taken_out_in_ear_transition" to R.string.adv_payload_decoder_taken_out_in_ear_transition, + "adv_payload_decoder_target_atrius_ibeacon_uuid_shopping_basket_asset" to R.string.adv_payload_decoder_target_atrius_ibeacon_uuid_shopping_basket_asset, + "adv_payload_decoder_telemetry_battery_temperature_advert_count" to R.string.adv_payload_decoder_telemetry_battery_temperature_advert_count, + "adv_payload_decoder_tesla_phone_as_key_ios_background_find" to R.string.adv_payload_decoder_tesla_phone_as_key_ios_background_find, + "adv_payload_decoder_tesla_phone_key_ibeacon_uuid_ios_background" to R.string.adv_payload_decoder_tesla_phone_key_ibeacon_uuid_ios_background, + "adv_payload_decoder_tethering_source_advertisement" to R.string.adv_payload_decoder_tethering_source_advertisement, + "adv_payload_decoder_tethering_target_advertisement" to R.string.adv_payload_decoder_tethering_target_advertisement, + "adv_payload_decoder_this_apple_device_is_searching_for_a" to R.string.adv_payload_decoder_this_apple_device_is_searching_for_a, + "adv_payload_decoder_this_device_is_advertising_as_an_airplay" to R.string.adv_payload_decoder_this_device_is_advertising_as_an_airplay, + "adv_payload_decoder_truncated" to R.string.adv_payload_decoder_truncated, + "adv_payload_decoder_truncated_bytes" to R.string.adv_payload_decoder_truncated_bytes, + "adv_payload_decoder_unknown_not_present" to R.string.adv_payload_decoder_unknown_not_present, + "adv_payload_decoder_unlisted" to R.string.adv_payload_decoder_unlisted, + "adv_payload_decoder_used_by_airtags_find_my_accessories_and" to R.string.adv_payload_decoder_used_by_airtags_find_my_accessories_and, + "adv_payload_decoder_user_is_driving" to R.string.adv_payload_decoder_user_is_driving, + "adv_payload_decoder_wants_to_show_a_pairing_card" to R.string.adv_payload_decoder_wants_to_show_a_pairing_card, + "adv_payload_decoder_watch" to R.string.adv_payload_decoder_watch, + "adv_payload_decoder_watch_locked" to R.string.adv_payload_decoder_watch_locked, + "adv_payload_decoder_watch_on_wrist_and_unlocked" to R.string.adv_payload_decoder_watch_on_wrist_and_unlocked, + "adv_payload_decoder_watch_setup" to R.string.adv_payload_decoder_watch_setup, + "adv_payload_decoder_what_the_apple_device_is_doing" to R.string.adv_payload_decoder_what_the_apple_device_is_doing, + "adv_payload_decoder_white" to R.string.adv_payload_decoder_white, + "adv_payload_decoder_whole_home_audio_setup" to R.string.adv_payload_decoder_whole_home_audio_setup, + "adv_payload_decoder_wi_fi_on" to R.string.adv_payload_decoder_wi_fi_on, + "adv_payload_decoder_wi_fi_password_sharing_prompting_nearby_iphones" to R.string.adv_payload_decoder_wi_fi_password_sharing_prompting_nearby_iphones, + "adv_payload_decoder_windows_desktop" to R.string.adv_payload_decoder_windows_desktop, + "adv_payload_decoder_windows_laptop" to R.string.adv_payload_decoder_windows_laptop, + "adv_payload_decoder_windows_phone" to R.string.adv_payload_decoder_windows_phone, + "adv_payload_decoder_windows_tablet" to R.string.adv_payload_decoder_windows_tablet, + "adv_payload_decoder_yellow" to R.string.adv_payload_decoder_yellow, + "adv_unknown_type" to R.string.adv_unknown_type, + "ai_response_language" to R.string.ai_response_language, + "aircraft_trail_1_advertised_fix" to R.string.aircraft_trail_1_advertised_fix, + "aircraft_trail_1_fix" to R.string.aircraft_trail_1_fix, + "aircraft_trail_a_black_dotted_line_is_an_advertised" to R.string.aircraft_trail_a_black_dotted_line_is_an_advertised, + "aircraft_trail_a_blue_dotted_line_is_the_second" to R.string.aircraft_trail_a_blue_dotted_line_is_the_second, + "aircraft_trail_advertised_fixes" to R.string.aircraft_trail_advertised_fixes, + "aircraft_trail_advertised_position" to R.string.aircraft_trail_advertised_position, + "aircraft_trail_aircraft" to R.string.aircraft_trail_aircraft, + "aircraft_trail_aircraft_2" to R.string.aircraft_trail_aircraft_2, + "aircraft_trail_black_dotted_line_is_an_advertised_track" to R.string.aircraft_trail_black_dotted_line_is_an_advertised_track, + ) + + private fun group2(): Map = mapOf( + "aircraft_trail_course" to R.string.aircraft_trail_course, + "aircraft_trail_fixes" to R.string.aircraft_trail_fixes, + "aircraft_trail_last" to R.string.aircraft_trail_last, + "aircraft_trail_last_2" to R.string.aircraft_trail_last_2, + "aircraft_trail_last_3" to R.string.aircraft_trail_last_3, + "aircraft_trail_last_advertised_position_for_the_marker_is" to R.string.aircraft_trail_last_advertised_position_for_the_marker_is, + "aircraft_trail_more_aircraft_tracks_are_listed_here_and" to R.string.aircraft_trail_more_aircraft_tracks_are_listed_here_and, + "aircraft_trail_no_uas_id" to R.string.aircraft_trail_no_uas_id, + "aircraft_trail_no_uas_id_2" to R.string.aircraft_trail_no_uas_id_2, + "aircraft_trail_north_up_black_dots_are_this_sit" to R.string.aircraft_trail_north_up_black_dots_are_this_sit, + "aircraft_trail_north_up_the_black_dotted_line_is" to R.string.aircraft_trail_north_up_the_black_dotted_line_is, + "aircraft_trail_north_up_the_blue_dotted_line_is" to R.string.aircraft_trail_north_up_the_blue_dotted_line_is, + "aircraft_trail_pilot" to R.string.aircraft_trail_pilot, + "aircraft_trail_pilot_2" to R.string.aircraft_trail_pilot_2, + "aircraft_trail_pilot_3" to R.string.aircraft_trail_pilot_3, + "aircraft_trail_second_sit_s_advertised_track" to R.string.aircraft_trail_second_sit_s_advertised_track, + "aircraft_trail_status_changed" to R.string.aircraft_trail_status_changed, + "aircraft_trail_these_positions_were_broadcast_by_the_radio" to R.string.aircraft_trail_these_positions_were_broadcast_by_the_radio, + "alerter_appeared_n_n_dbm" to R.string.alerter_appeared_n_n_dbm, + "alerter_appearing_signatures_and_devices_on_your_watchlist" to R.string.alerter_appearing_signatures_and_devices_on_your_watchlist, + "alerter_fieldwatch_watchlist" to R.string.alerter_fieldwatch_watchlist, + "alerter_watchlist" to R.string.alerter_watchlist, + "ble_radio_bluetooth_off" to R.string.ble_radio_bluetooth_off, + "ble_radio_cycling" to R.string.ble_radio_cycling, + "ble_radio_restarting" to R.string.ble_radio_restarting, + "ble_radio_retrying" to R.string.ble_radio_retrying, + "ble_radio_scan_failed" to R.string.ble_radio_scan_failed, + "ble_radio_scanner_unavailable" to R.string.ble_radio_scanner_unavailable, + "ble_radio_start_failed" to R.string.ble_radio_start_failed, + "ble_radio_unavailable" to R.string.ble_radio_unavailable, + "catalog_fleet_actiontec_name" to R.string.catalog_fleet_actiontec_name, + "catalog_fleet_actiontec_notes" to R.string.catalog_fleet_actiontec_notes, + "catalog_fleet_adtran_name" to R.string.catalog_fleet_adtran_name, + "catalog_fleet_adtran_notes" to R.string.catalog_fleet_adtran_notes, + "catalog_fleet_airlink_attention" to R.string.catalog_fleet_airlink_attention, + "catalog_fleet_airlink_name" to R.string.catalog_fleet_airlink_name, + "catalog_fleet_airlink_notes" to R.string.catalog_fleet_airlink_notes, + "catalog_fleet_airtag_name" to R.string.catalog_fleet_airtag_name, + "catalog_fleet_airtag_notes" to R.string.catalog_fleet_airtag_notes, + "catalog_fleet_airties_name" to R.string.catalog_fleet_airties_name, + "catalog_fleet_airties_notes" to R.string.catalog_fleet_airties_notes, + "catalog_fleet_amazon_name" to R.string.catalog_fleet_amazon_name, + "catalog_fleet_amazon_notes" to R.string.catalog_fleet_amazon_notes, + "catalog_fleet_apple_audio_name" to R.string.catalog_fleet_apple_audio_name, + "catalog_fleet_apple_audio_notes" to R.string.catalog_fleet_apple_audio_notes, + "catalog_fleet_apple_device_name" to R.string.catalog_fleet_apple_device_name, + "catalog_fleet_apple_device_notes" to R.string.catalog_fleet_apple_device_notes, + "catalog_fleet_arcadyan_name" to R.string.catalog_fleet_arcadyan_name, + "catalog_fleet_arcadyan_notes" to R.string.catalog_fleet_arcadyan_notes, + "catalog_fleet_arlo_name" to R.string.catalog_fleet_arlo_name, + "catalog_fleet_arlo_notes" to R.string.catalog_fleet_arlo_notes, + "catalog_fleet_arris_name" to R.string.catalog_fleet_arris_name, + "catalog_fleet_arris_notes" to R.string.catalog_fleet_arris_notes, + "catalog_fleet_aruba_name" to R.string.catalog_fleet_aruba_name, + "catalog_fleet_aruba_notes" to R.string.catalog_fleet_aruba_notes, + "catalog_fleet_askey_name" to R.string.catalog_fleet_askey_name, + "catalog_fleet_askey_notes" to R.string.catalog_fleet_askey_notes, + "catalog_fleet_asus_name" to R.string.catalog_fleet_asus_name, + "catalog_fleet_asus_notes" to R.string.catalog_fleet_asus_notes, + "catalog_fleet_att_name" to R.string.catalog_fleet_att_name, + "catalog_fleet_att_notes" to R.string.catalog_fleet_att_notes, + "catalog_fleet_audi_mmi_name" to R.string.catalog_fleet_audi_mmi_name, + "catalog_fleet_audi_mmi_notes" to R.string.catalog_fleet_audi_mmi_notes, + "catalog_fleet_august_name" to R.string.catalog_fleet_august_name, + "catalog_fleet_august_notes" to R.string.catalog_fleet_august_notes, + "catalog_fleet_aumovio_name" to R.string.catalog_fleet_aumovio_name, + "catalog_fleet_aumovio_notes" to R.string.catalog_fleet_aumovio_notes, + "catalog_fleet_autel_name" to R.string.catalog_fleet_autel_name, + "catalog_fleet_autel_notes" to R.string.catalog_fleet_autel_notes, + "catalog_fleet_avigilon_attention" to R.string.catalog_fleet_avigilon_attention, + "catalog_fleet_avigilon_name" to R.string.catalog_fleet_avigilon_name, + "catalog_fleet_avigilon_notes" to R.string.catalog_fleet_avigilon_notes, + "catalog_fleet_axis_attention" to R.string.catalog_fleet_axis_attention, + "catalog_fleet_axis_name" to R.string.catalog_fleet_axis_name, + "catalog_fleet_axis_notes" to R.string.catalog_fleet_axis_notes, + "catalog_fleet_axon_attention" to R.string.catalog_fleet_axon_attention, + "catalog_fleet_axon_name" to R.string.catalog_fleet_axon_name, + "catalog_fleet_axon_notes" to R.string.catalog_fleet_axon_notes, + "catalog_fleet_bee_attention" to R.string.catalog_fleet_bee_attention, + "catalog_fleet_bee_name" to R.string.catalog_fleet_bee_name, + "catalog_fleet_bee_notes" to R.string.catalog_fleet_bee_notes, + "catalog_fleet_belkin_name" to R.string.catalog_fleet_belkin_name, + "catalog_fleet_belkin_notes" to R.string.catalog_fleet_belkin_notes, + "catalog_fleet_bhyve_name" to R.string.catalog_fleet_bhyve_name, + "catalog_fleet_bhyve_notes" to R.string.catalog_fleet_bhyve_notes, + "catalog_fleet_bliptrack_name" to R.string.catalog_fleet_bliptrack_name, + "catalog_fleet_bliptrack_notes" to R.string.catalog_fleet_bliptrack_notes, + "catalog_fleet_bluemaestro_decode_battery_label" to R.string.catalog_fleet_bluemaestro_decode_battery_label, + "catalog_fleet_bluemaestro_decode_humidity_label" to R.string.catalog_fleet_bluemaestro_decode_humidity_label, + "catalog_fleet_bluemaestro_decode_interval_label" to R.string.catalog_fleet_bluemaestro_decode_interval_label, + "catalog_fleet_bluemaestro_decode_logs_label" to R.string.catalog_fleet_bluemaestro_decode_logs_label, + "catalog_fleet_bluemaestro_decode_temperature_label" to R.string.catalog_fleet_bluemaestro_decode_temperature_label, + "catalog_fleet_bluemaestro_decode_version_label" to R.string.catalog_fleet_bluemaestro_decode_version_label, + "catalog_fleet_bluemaestro_name" to R.string.catalog_fleet_bluemaestro_name, + "catalog_fleet_bluemaestro_notes" to R.string.catalog_fleet_bluemaestro_notes, + "catalog_fleet_bluetoad_name" to R.string.catalog_fleet_bluetoad_name, + "catalog_fleet_bluetoad_notes" to R.string.catalog_fleet_bluetoad_notes, + "catalog_fleet_bmw_name" to R.string.catalog_fleet_bmw_name, + "catalog_fleet_bmw_notes" to R.string.catalog_fleet_bmw_notes, + "catalog_fleet_bose_name" to R.string.catalog_fleet_bose_name, + ) + + private fun group3(): Map = mapOf( + "catalog_fleet_bose_notes" to R.string.catalog_fleet_bose_notes, + "catalog_fleet_brilliant_frame_attention" to R.string.catalog_fleet_brilliant_frame_attention, + "catalog_fleet_brilliant_frame_name" to R.string.catalog_fleet_brilliant_frame_name, + "catalog_fleet_brilliant_frame_notes" to R.string.catalog_fleet_brilliant_frame_notes, + "catalog_fleet_bruce_attention" to R.string.catalog_fleet_bruce_attention, + "catalog_fleet_bruce_name" to R.string.catalog_fleet_bruce_name, + "catalog_fleet_bruce_notes" to R.string.catalog_fleet_bruce_notes, + "catalog_fleet_buffalo_name" to R.string.catalog_fleet_buffalo_name, + "catalog_fleet_buffalo_notes" to R.string.catalog_fleet_buffalo_notes, + "catalog_fleet_byd_name" to R.string.catalog_fleet_byd_name, + "catalog_fleet_byd_notes" to R.string.catalog_fleet_byd_notes, + "catalog_fleet_calix_name" to R.string.catalog_fleet_calix_name, + "catalog_fleet_calix_notes" to R.string.catalog_fleet_calix_notes, + "catalog_fleet_cambium_name" to R.string.catalog_fleet_cambium_name, + "catalog_fleet_cambium_notes" to R.string.catalog_fleet_cambium_notes, + "catalog_fleet_carlink_name" to R.string.catalog_fleet_carlink_name, + "catalog_fleet_carlink_notes" to R.string.catalog_fleet_carlink_notes, + "catalog_fleet_carplay_name" to R.string.catalog_fleet_carplay_name, + "catalog_fleet_carplay_notes" to R.string.catalog_fleet_carplay_notes, + "catalog_fleet_centurylink_name" to R.string.catalog_fleet_centurylink_name, + "catalog_fleet_centurylink_notes" to R.string.catalog_fleet_centurylink_notes, + "catalog_fleet_chevrolet_name" to R.string.catalog_fleet_chevrolet_name, + "catalog_fleet_chevrolet_notes" to R.string.catalog_fleet_chevrolet_notes, + "catalog_fleet_chipolo_name" to R.string.catalog_fleet_chipolo_name, + "catalog_fleet_chipolo_notes" to R.string.catalog_fleet_chipolo_notes, + "catalog_fleet_cisco_name" to R.string.catalog_fleet_cisco_name, + "catalog_fleet_cisco_notes" to R.string.catalog_fleet_cisco_notes, + "catalog_fleet_compex_attention" to R.string.catalog_fleet_compex_attention, + "catalog_fleet_compex_name" to R.string.catalog_fleet_compex_name, + "catalog_fleet_compex_notes" to R.string.catalog_fleet_compex_notes, + "catalog_fleet_cradlepoint_attention" to R.string.catalog_fleet_cradlepoint_attention, + "catalog_fleet_cradlepoint_name" to R.string.catalog_fleet_cradlepoint_name, + "catalog_fleet_cradlepoint_notes" to R.string.catalog_fleet_cradlepoint_notes, + "catalog_fleet_crazyflie_name" to R.string.catalog_fleet_crazyflie_name, + "catalog_fleet_crazyflie_notes" to R.string.catalog_fleet_crazyflie_notes, + "catalog_fleet_cudy_name" to R.string.catalog_fleet_cudy_name, + "catalog_fleet_cudy_notes" to R.string.catalog_fleet_cudy_notes, + "catalog_fleet_dahua_attention" to R.string.catalog_fleet_dahua_attention, + "catalog_fleet_dahua_name" to R.string.catalog_fleet_dahua_name, + "catalog_fleet_dahua_notes" to R.string.catalog_fleet_dahua_notes, + "catalog_fleet_dexcom_name" to R.string.catalog_fleet_dexcom_name, + "catalog_fleet_dexcom_notes" to R.string.catalog_fleet_dexcom_notes, + "catalog_fleet_digital_ally_attention" to R.string.catalog_fleet_digital_ally_attention, + "catalog_fleet_digital_ally_name" to R.string.catalog_fleet_digital_ally_name, + "catalog_fleet_digital_ally_notes" to R.string.catalog_fleet_digital_ally_notes, + "catalog_fleet_dji_decode_model_enum_112" to R.string.catalog_fleet_dji_decode_model_enum_112, + "catalog_fleet_dji_decode_model_enum_126" to R.string.catalog_fleet_dji_decode_model_enum_126, + "catalog_fleet_dji_decode_model_enum_16" to R.string.catalog_fleet_dji_decode_model_enum_16, + "catalog_fleet_dji_decode_model_enum_18" to R.string.catalog_fleet_dji_decode_model_enum_18, + "catalog_fleet_dji_decode_model_enum_20" to R.string.catalog_fleet_dji_decode_model_enum_20, + "catalog_fleet_dji_decode_model_enum_21" to R.string.catalog_fleet_dji_decode_model_enum_21, + "catalog_fleet_dji_decode_model_enum_23" to R.string.catalog_fleet_dji_decode_model_enum_23, + "catalog_fleet_dji_decode_model_enum_24" to R.string.catalog_fleet_dji_decode_model_enum_24, + "catalog_fleet_dji_decode_model_enum_25" to R.string.catalog_fleet_dji_decode_model_enum_25, + "catalog_fleet_dji_decode_model_enum_32" to R.string.catalog_fleet_dji_decode_model_enum_32, + "catalog_fleet_dji_decode_model_enum_33" to R.string.catalog_fleet_dji_decode_model_enum_33, + "catalog_fleet_dji_decode_model_enum_34" to R.string.catalog_fleet_dji_decode_model_enum_34, + "catalog_fleet_dji_decode_model_enum_6" to R.string.catalog_fleet_dji_decode_model_enum_6, + "catalog_fleet_dji_decode_model_label" to R.string.catalog_fleet_dji_decode_model_label, + "catalog_fleet_dji_name" to R.string.catalog_fleet_dji_name, + "catalog_fleet_dji_notes" to R.string.catalog_fleet_dji_notes, + "catalog_fleet_dlink_name" to R.string.catalog_fleet_dlink_name, + "catalog_fleet_dlink_notes" to R.string.catalog_fleet_dlink_notes, + "catalog_fleet_dormakaba_name" to R.string.catalog_fleet_dormakaba_name, + "catalog_fleet_dormakaba_notes" to R.string.catalog_fleet_dormakaba_notes, + "catalog_fleet_dult_decode_mode_enum_0" to R.string.catalog_fleet_dult_decode_mode_enum_0, + "catalog_fleet_dult_decode_mode_enum_1" to R.string.catalog_fleet_dult_decode_mode_enum_1, + "catalog_fleet_dult_decode_mode_label" to R.string.catalog_fleet_dult_decode_mode_label, + "catalog_fleet_dult_decode_mode_note_0" to R.string.catalog_fleet_dult_decode_mode_note_0, + "catalog_fleet_dult_decode_mode_note_1" to R.string.catalog_fleet_dult_decode_mode_note_1, + "catalog_fleet_dult_decode_network_id_label" to R.string.catalog_fleet_dult_decode_network_id_label, + "catalog_fleet_dult_name" to R.string.catalog_fleet_dult_name, + "catalog_fleet_dult_notes" to R.string.catalog_fleet_dult_notes, + "catalog_fleet_dwnet_name" to R.string.catalog_fleet_dwnet_name, + "catalog_fleet_dwnet_notes" to R.string.catalog_fleet_dwnet_notes, + "catalog_fleet_ecobee_name" to R.string.catalog_fleet_ecobee_name, + "catalog_fleet_ecobee_notes" to R.string.catalog_fleet_ecobee_notes, + "catalog_fleet_ecowater_name" to R.string.catalog_fleet_ecowater_name, + "catalog_fleet_ecowater_notes" to R.string.catalog_fleet_ecowater_notes, + "catalog_fleet_edgecore_name" to R.string.catalog_fleet_edgecore_name, + "catalog_fleet_edgecore_notes" to R.string.catalog_fleet_edgecore_notes, + "catalog_fleet_eero_name" to R.string.catalog_fleet_eero_name, + "catalog_fleet_eero_notes" to R.string.catalog_fleet_eero_notes, + "catalog_fleet_engenius_name" to R.string.catalog_fleet_engenius_name, + "catalog_fleet_engenius_notes" to R.string.catalog_fleet_engenius_notes, + "catalog_fleet_epson_name" to R.string.catalog_fleet_epson_name, + "catalog_fleet_epson_notes" to R.string.catalog_fleet_epson_notes, + "catalog_fleet_esl_name" to R.string.catalog_fleet_esl_name, + "catalog_fleet_esl_notes" to R.string.catalog_fleet_esl_notes, + "catalog_fleet_estimote_decode_frame_enum_1" to R.string.catalog_fleet_estimote_decode_frame_enum_1, + "catalog_fleet_estimote_decode_frame_enum_2" to R.string.catalog_fleet_estimote_decode_frame_enum_2, + "catalog_fleet_estimote_decode_frame_label" to R.string.catalog_fleet_estimote_decode_frame_label, + "catalog_fleet_estimote_name" to R.string.catalog_fleet_estimote_name, + "catalog_fleet_estimote_notes" to R.string.catalog_fleet_estimote_notes, + "catalog_fleet_eufy_name" to R.string.catalog_fleet_eufy_name, + "catalog_fleet_eufy_notes" to R.string.catalog_fleet_eufy_notes, + "catalog_fleet_even_g1_attention" to R.string.catalog_fleet_even_g1_attention, + "catalog_fleet_even_g1_name" to R.string.catalog_fleet_even_g1_name, + "catalog_fleet_even_g1_notes" to R.string.catalog_fleet_even_g1_notes, + "catalog_fleet_extreme_name" to R.string.catalog_fleet_extreme_name, + ) + + private fun group4(): Map = mapOf( + "catalog_fleet_extreme_notes" to R.string.catalog_fleet_extreme_notes, + "catalog_fleet_fast_pair_name" to R.string.catalog_fleet_fast_pair_name, + "catalog_fleet_fast_pair_notes" to R.string.catalog_fleet_fast_pair_notes, + "catalog_fleet_fieldy_attention" to R.string.catalog_fleet_fieldy_attention, + "catalog_fleet_fieldy_name" to R.string.catalog_fleet_fieldy_name, + "catalog_fleet_fieldy_notes" to R.string.catalog_fleet_fieldy_notes, + "catalog_fleet_find_hub_decode_eid_label" to R.string.catalog_fleet_find_hub_decode_eid_label, + "catalog_fleet_find_hub_decode_mode_enum_64" to R.string.catalog_fleet_find_hub_decode_mode_enum_64, + "catalog_fleet_find_hub_decode_mode_enum_65" to R.string.catalog_fleet_find_hub_decode_mode_enum_65, + "catalog_fleet_find_hub_decode_mode_label" to R.string.catalog_fleet_find_hub_decode_mode_label, + "catalog_fleet_find_hub_decode_mode_note_64" to R.string.catalog_fleet_find_hub_decode_mode_note_64, + "catalog_fleet_find_hub_decode_mode_note_65" to R.string.catalog_fleet_find_hub_decode_mode_note_65, + "catalog_fleet_find_hub_name" to R.string.catalog_fleet_find_hub_name, + "catalog_fleet_find_hub_notes" to R.string.catalog_fleet_find_hub_notes, + "catalog_fleet_fitbit_name" to R.string.catalog_fleet_fitbit_name, + "catalog_fleet_fitbit_notes" to R.string.catalog_fleet_fitbit_notes, + "catalog_fleet_flipper_attention" to R.string.catalog_fleet_flipper_attention, + "catalog_fleet_flipper_name" to R.string.catalog_fleet_flipper_name, + "catalog_fleet_flipper_notes" to R.string.catalog_fleet_flipper_notes, + "catalog_fleet_flock_cameras_attention" to R.string.catalog_fleet_flock_cameras_attention, + "catalog_fleet_flock_cameras_name" to R.string.catalog_fleet_flock_cameras_name, + "catalog_fleet_flock_cameras_notes" to R.string.catalog_fleet_flock_cameras_notes, + "catalog_fleet_fobo_name" to R.string.catalog_fleet_fobo_name, + "catalog_fleet_fobo_notes" to R.string.catalog_fleet_fobo_notes, + "catalog_fleet_ford_name" to R.string.catalog_fleet_ford_name, + "catalog_fleet_ford_notes" to R.string.catalog_fleet_ford_notes, + "catalog_fleet_fortinet_name" to R.string.catalog_fleet_fortinet_name, + "catalog_fleet_fortinet_notes" to R.string.catalog_fleet_fortinet_notes, + "catalog_fleet_franklin_name" to R.string.catalog_fleet_franklin_name, + "catalog_fleet_franklin_notes" to R.string.catalog_fleet_franklin_notes, + "catalog_fleet_friend_pendant_attention" to R.string.catalog_fleet_friend_pendant_attention, + "catalog_fleet_friend_pendant_name" to R.string.catalog_fleet_friend_pendant_name, + "catalog_fleet_friend_pendant_notes" to R.string.catalog_fleet_friend_pendant_notes, + "catalog_fleet_fs_ext_battery_attention" to R.string.catalog_fleet_fs_ext_battery_attention, + "catalog_fleet_fs_ext_battery_name" to R.string.catalog_fleet_fs_ext_battery_name, + "catalog_fleet_fs_ext_battery_notes" to R.string.catalog_fleet_fs_ext_battery_notes, + "catalog_fleet_garmin_name" to R.string.catalog_fleet_garmin_name, + "catalog_fleet_garmin_notes" to R.string.catalog_fleet_garmin_notes, + "catalog_fleet_genetec_attention" to R.string.catalog_fleet_genetec_attention, + "catalog_fleet_genetec_name" to R.string.catalog_fleet_genetec_name, + "catalog_fleet_genetec_notes" to R.string.catalog_fleet_genetec_notes, + "catalog_fleet_ghostesp_attention" to R.string.catalog_fleet_ghostesp_attention, + "catalog_fleet_ghostesp_name" to R.string.catalog_fleet_ghostesp_name, + "catalog_fleet_ghostesp_notes" to R.string.catalog_fleet_ghostesp_notes, + "catalog_fleet_glinet_name" to R.string.catalog_fleet_glinet_name, + "catalog_fleet_glinet_notes" to R.string.catalog_fleet_glinet_notes, + "catalog_fleet_gm_hotspot_name" to R.string.catalog_fleet_gm_hotspot_name, + "catalog_fleet_gm_hotspot_notes" to R.string.catalog_fleet_gm_hotspot_notes, + "catalog_fleet_goodyear_name" to R.string.catalog_fleet_goodyear_name, + "catalog_fleet_goodyear_notes" to R.string.catalog_fleet_goodyear_notes, + "catalog_fleet_google_name" to R.string.catalog_fleet_google_name, + "catalog_fleet_google_notes" to R.string.catalog_fleet_google_notes, + "catalog_fleet_google_wifi_name" to R.string.catalog_fleet_google_wifi_name, + "catalog_fleet_google_wifi_notes" to R.string.catalog_fleet_google_wifi_notes, + "catalog_fleet_gopro_decode_awake_enum_0" to R.string.catalog_fleet_gopro_decode_awake_enum_0, + "catalog_fleet_gopro_decode_awake_enum_1" to R.string.catalog_fleet_gopro_decode_awake_enum_1, + "catalog_fleet_gopro_decode_awake_label" to R.string.catalog_fleet_gopro_decode_awake_label, + "catalog_fleet_gopro_decode_model_enum_55" to R.string.catalog_fleet_gopro_decode_model_enum_55, + "catalog_fleet_gopro_decode_model_enum_57" to R.string.catalog_fleet_gopro_decode_model_enum_57, + "catalog_fleet_gopro_decode_model_enum_58" to R.string.catalog_fleet_gopro_decode_model_enum_58, + "catalog_fleet_gopro_decode_model_enum_60" to R.string.catalog_fleet_gopro_decode_model_enum_60, + "catalog_fleet_gopro_decode_model_enum_62" to R.string.catalog_fleet_gopro_decode_model_enum_62, + "catalog_fleet_gopro_decode_model_enum_64" to R.string.catalog_fleet_gopro_decode_model_enum_64, + "catalog_fleet_gopro_decode_model_enum_65" to R.string.catalog_fleet_gopro_decode_model_enum_65, + "catalog_fleet_gopro_decode_model_enum_66" to R.string.catalog_fleet_gopro_decode_model_enum_66, + "catalog_fleet_gopro_decode_model_enum_69" to R.string.catalog_fleet_gopro_decode_model_enum_69, + "catalog_fleet_gopro_decode_model_enum_70" to R.string.catalog_fleet_gopro_decode_model_enum_70, + "catalog_fleet_gopro_decode_model_enum_71" to R.string.catalog_fleet_gopro_decode_model_enum_71, + "catalog_fleet_gopro_decode_model_label" to R.string.catalog_fleet_gopro_decode_model_label, + "catalog_fleet_gopro_decode_new_media_enum_0" to R.string.catalog_fleet_gopro_decode_new_media_enum_0, + "catalog_fleet_gopro_decode_new_media_enum_1" to R.string.catalog_fleet_gopro_decode_new_media_enum_1, + "catalog_fleet_gopro_decode_new_media_label" to R.string.catalog_fleet_gopro_decode_new_media_label, + "catalog_fleet_gopro_decode_offload_enum_0" to R.string.catalog_fleet_gopro_decode_offload_enum_0, + "catalog_fleet_gopro_decode_offload_enum_1" to R.string.catalog_fleet_gopro_decode_offload_enum_1, + "catalog_fleet_gopro_decode_offload_label" to R.string.catalog_fleet_gopro_decode_offload_label, + "catalog_fleet_gopro_decode_pairing_enum_0" to R.string.catalog_fleet_gopro_decode_pairing_enum_0, + "catalog_fleet_gopro_decode_pairing_enum_1" to R.string.catalog_fleet_gopro_decode_pairing_enum_1, + "catalog_fleet_gopro_decode_pairing_label" to R.string.catalog_fleet_gopro_decode_pairing_label, + "catalog_fleet_gopro_decode_schema_label" to R.string.catalog_fleet_gopro_decode_schema_label, + "catalog_fleet_gopro_decode_wifi_ap_enum_0" to R.string.catalog_fleet_gopro_decode_wifi_ap_enum_0, + "catalog_fleet_gopro_decode_wifi_ap_enum_1" to R.string.catalog_fleet_gopro_decode_wifi_ap_enum_1, + "catalog_fleet_gopro_decode_wifi_ap_label" to R.string.catalog_fleet_gopro_decode_wifi_ap_label, + "catalog_fleet_gopro_name" to R.string.catalog_fleet_gopro_name, + "catalog_fleet_gopro_notes" to R.string.catalog_fleet_gopro_notes, + "catalog_fleet_gotenna_name" to R.string.catalog_fleet_gotenna_name, + "catalog_fleet_gotenna_notes" to R.string.catalog_fleet_gotenna_notes, + "catalog_fleet_govee_decode_battery_label" to R.string.catalog_fleet_govee_decode_battery_label, + "catalog_fleet_govee_decode_humidity_label" to R.string.catalog_fleet_govee_decode_humidity_label, + "catalog_fleet_govee_decode_temperature_label" to R.string.catalog_fleet_govee_decode_temperature_label, + "catalog_fleet_govee_name" to R.string.catalog_fleet_govee_name, + "catalog_fleet_govee_notes" to R.string.catalog_fleet_govee_notes, + "catalog_fleet_grandstream_name" to R.string.catalog_fleet_grandstream_name, + "catalog_fleet_grandstream_notes" to R.string.catalog_fleet_grandstream_notes, + "catalog_fleet_haiku_name" to R.string.catalog_fleet_haiku_name, + "catalog_fleet_haiku_notes" to R.string.catalog_fleet_haiku_notes, + "catalog_fleet_hak5_pineapple_attention" to R.string.catalog_fleet_hak5_pineapple_attention, + "catalog_fleet_hak5_pineapple_name" to R.string.catalog_fleet_hak5_pineapple_name, + "catalog_fleet_hak5_pineapple_notes" to R.string.catalog_fleet_hak5_pineapple_notes, + "catalog_fleet_hanwha_wisenet_attention" to R.string.catalog_fleet_hanwha_wisenet_attention, + "catalog_fleet_hanwha_wisenet_name" to R.string.catalog_fleet_hanwha_wisenet_name, + ) + + private fun group5(): Map = mapOf( + "catalog_fleet_hanwha_wisenet_notes" to R.string.catalog_fleet_hanwha_wisenet_notes, + "catalog_fleet_hatch_name" to R.string.catalog_fleet_hatch_name, + "catalog_fleet_hatch_notes" to R.string.catalog_fleet_hatch_notes, + "catalog_fleet_hayden_ai_attention" to R.string.catalog_fleet_hayden_ai_attention, + "catalog_fleet_hayden_ai_name" to R.string.catalog_fleet_hayden_ai_name, + "catalog_fleet_hayden_ai_notes" to R.string.catalog_fleet_hayden_ai_notes, + "catalog_fleet_helium_name" to R.string.catalog_fleet_helium_name, + "catalog_fleet_helium_notes" to R.string.catalog_fleet_helium_notes, + "catalog_fleet_hikvision_attention" to R.string.catalog_fleet_hikvision_attention, + "catalog_fleet_hikvision_name" to R.string.catalog_fleet_hikvision_name, + "catalog_fleet_hikvision_notes" to R.string.catalog_fleet_hikvision_notes, + "catalog_fleet_hitron_name" to R.string.catalog_fleet_hitron_name, + "catalog_fleet_hitron_notes" to R.string.catalog_fleet_hitron_notes, + "catalog_fleet_hobby_ble_attention" to R.string.catalog_fleet_hobby_ble_attention, + "catalog_fleet_hobby_ble_name" to R.string.catalog_fleet_hobby_ble_name, + "catalog_fleet_hobby_ble_notes" to R.string.catalog_fleet_hobby_ble_notes, + "catalog_fleet_holystone_name" to R.string.catalog_fleet_holystone_name, + "catalog_fleet_holystone_notes" to R.string.catalog_fleet_holystone_notes, + "catalog_fleet_honda_name" to R.string.catalog_fleet_honda_name, + "catalog_fleet_honda_notes" to R.string.catalog_fleet_honda_notes, + "catalog_fleet_honeywell_home_name" to R.string.catalog_fleet_honeywell_home_name, + "catalog_fleet_honeywell_home_notes" to R.string.catalog_fleet_honeywell_home_notes, + "catalog_fleet_honeywell_xenon_hc_name" to R.string.catalog_fleet_honeywell_xenon_hc_name, + "catalog_fleet_honeywell_xenon_hc_notes" to R.string.catalog_fleet_honeywell_xenon_hc_notes, + "catalog_fleet_hoverair_name" to R.string.catalog_fleet_hoverair_name, + "catalog_fleet_hoverair_notes" to R.string.catalog_fleet_hoverair_notes, + "catalog_fleet_hp_name" to R.string.catalog_fleet_hp_name, + "catalog_fleet_hp_notes" to R.string.catalog_fleet_hp_notes, + "catalog_fleet_huawei_name" to R.string.catalog_fleet_huawei_name, + "catalog_fleet_huawei_notes" to R.string.catalog_fleet_huawei_notes, + "catalog_fleet_hubsan_name" to R.string.catalog_fleet_hubsan_name, + "catalog_fleet_hubsan_notes" to R.string.catalog_fleet_hubsan_notes, + "catalog_fleet_huf_name" to R.string.catalog_fleet_huf_name, + "catalog_fleet_huf_notes" to R.string.catalog_fleet_huf_notes, + "catalog_fleet_humax_name" to R.string.catalog_fleet_humax_name, + "catalog_fleet_humax_notes" to R.string.catalog_fleet_humax_notes, + "catalog_fleet_hyundai_name" to R.string.catalog_fleet_hyundai_name, + "catalog_fleet_hyundai_notes" to R.string.catalog_fleet_hyundai_notes, + "catalog_fleet_ibeacon_name" to R.string.catalog_fleet_ibeacon_name, + "catalog_fleet_ibeacon_notes" to R.string.catalog_fleet_ibeacon_notes, + "catalog_fleet_igloohome_name" to R.string.catalog_fleet_igloohome_name, + "catalog_fleet_igloohome_notes" to R.string.catalog_fleet_igloohome_notes, + "catalog_fleet_inseego_name" to R.string.catalog_fleet_inseego_name, + "catalog_fleet_inseego_notes" to R.string.catalog_fleet_inseego_notes, + "catalog_fleet_insta360_name" to R.string.catalog_fleet_insta360_name, + "catalog_fleet_insta360_notes" to R.string.catalog_fleet_insta360_notes, + "catalog_fleet_jbl_name" to R.string.catalog_fleet_jbl_name, + "catalog_fleet_jbl_notes" to R.string.catalog_fleet_jbl_notes, + "catalog_fleet_jlr_name" to R.string.catalog_fleet_jlr_name, + "catalog_fleet_jlr_notes" to R.string.catalog_fleet_jlr_notes, + "catalog_fleet_kevo_name" to R.string.catalog_fleet_kevo_name, + "catalog_fleet_kevo_notes" to R.string.catalog_fleet_kevo_notes, + "catalog_fleet_kontakt_decode_battery_label" to R.string.catalog_fleet_kontakt_decode_battery_label, + "catalog_fleet_kontakt_decode_channel_label" to R.string.catalog_fleet_kontakt_decode_channel_label, + "catalog_fleet_kontakt_decode_kind_enum_7" to R.string.catalog_fleet_kontakt_decode_kind_enum_7, + "catalog_fleet_kontakt_decode_kind_label" to R.string.catalog_fleet_kontakt_decode_kind_label, + "catalog_fleet_kontakt_decode_model_label" to R.string.catalog_fleet_kontakt_decode_model_label, + "catalog_fleet_kontakt_decode_moving_enum_0" to R.string.catalog_fleet_kontakt_decode_moving_enum_0, + "catalog_fleet_kontakt_decode_moving_enum_1" to R.string.catalog_fleet_kontakt_decode_moving_enum_1, + "catalog_fleet_kontakt_decode_moving_label" to R.string.catalog_fleet_kontakt_decode_moving_label, + "catalog_fleet_kontakt_decode_tx_power_label" to R.string.catalog_fleet_kontakt_decode_tx_power_label, + "catalog_fleet_kontakt_name" to R.string.catalog_fleet_kontakt_name, + "catalog_fleet_kontakt_notes" to R.string.catalog_fleet_kontakt_notes, + "catalog_fleet_kwikset_name" to R.string.catalog_fleet_kwikset_name, + "catalog_fleet_kwikset_notes" to R.string.catalog_fleet_kwikset_notes, + "catalog_fleet_lg_webos_name" to R.string.catalog_fleet_lg_webos_name, + "catalog_fleet_lg_webos_notes" to R.string.catalog_fleet_lg_webos_notes, + "catalog_fleet_limitless_attention" to R.string.catalog_fleet_limitless_attention, + "catalog_fleet_limitless_name" to R.string.catalog_fleet_limitless_name, + "catalog_fleet_limitless_notes" to R.string.catalog_fleet_limitless_notes, + "catalog_fleet_linksys_name" to R.string.catalog_fleet_linksys_name, + "catalog_fleet_linksys_notes" to R.string.catalog_fleet_linksys_notes, + "catalog_fleet_liteon_camera_radio_name" to R.string.catalog_fleet_liteon_camera_radio_name, + "catalog_fleet_liteon_camera_radio_notes" to R.string.catalog_fleet_liteon_camera_radio_notes, + "catalog_fleet_lockly_name" to R.string.catalog_fleet_lockly_name, + "catalog_fleet_lockly_notes" to R.string.catalog_fleet_lockly_notes, + "catalog_fleet_logitech_name" to R.string.catalog_fleet_logitech_name, + "catalog_fleet_logitech_notes" to R.string.catalog_fleet_logitech_notes, + "catalog_fleet_luxul_name" to R.string.catalog_fleet_luxul_name, + "catalog_fleet_luxul_notes" to R.string.catalog_fleet_luxul_notes, + "catalog_fleet_lvt_attention" to R.string.catalog_fleet_lvt_attention, + "catalog_fleet_lvt_name" to R.string.catalog_fleet_lvt_name, + "catalog_fleet_lvt_notes" to R.string.catalog_fleet_lvt_notes, + "catalog_fleet_marauder_attention" to R.string.catalog_fleet_marauder_attention, + "catalog_fleet_marauder_name" to R.string.catalog_fleet_marauder_name, + "catalog_fleet_marauder_notes" to R.string.catalog_fleet_marauder_notes, + "catalog_fleet_master_lock_name" to R.string.catalog_fleet_master_lock_name, + "catalog_fleet_master_lock_notes" to R.string.catalog_fleet_master_lock_notes, + "catalog_fleet_mbux_name" to R.string.catalog_fleet_mbux_name, + "catalog_fleet_mbux_notes" to R.string.catalog_fleet_mbux_notes, + "catalog_fleet_meraki_name" to R.string.catalog_fleet_meraki_name, + "catalog_fleet_meraki_notes" to R.string.catalog_fleet_meraki_notes, + "catalog_fleet_meshcore_name" to R.string.catalog_fleet_meshcore_name, + "catalog_fleet_meshcore_notes" to R.string.catalog_fleet_meshcore_notes, + "catalog_fleet_meshtastic_name" to R.string.catalog_fleet_meshtastic_name, + "catalog_fleet_meshtastic_notes" to R.string.catalog_fleet_meshtastic_notes, + "catalog_fleet_meta_glasses_attention" to R.string.catalog_fleet_meta_glasses_attention, + "catalog_fleet_meta_glasses_name" to R.string.catalog_fleet_meta_glasses_name, + "catalog_fleet_meta_glasses_notes" to R.string.catalog_fleet_meta_glasses_notes, + "catalog_fleet_microsoft_name" to R.string.catalog_fleet_microsoft_name, + ) + + private fun group6(): Map = mapOf( + "catalog_fleet_microsoft_notes" to R.string.catalog_fleet_microsoft_notes, + "catalog_fleet_mikrotik_name" to R.string.catalog_fleet_mikrotik_name, + "catalog_fleet_mikrotik_notes" to R.string.catalog_fleet_mikrotik_notes, + "catalog_fleet_minew_name" to R.string.catalog_fleet_minew_name, + "catalog_fleet_minew_notes" to R.string.catalog_fleet_minew_notes, + "catalog_fleet_miovision_attention" to R.string.catalog_fleet_miovision_attention, + "catalog_fleet_miovision_name" to R.string.catalog_fleet_miovision_name, + "catalog_fleet_miovision_notes" to R.string.catalog_fleet_miovision_notes, + "catalog_fleet_mist_name" to R.string.catalog_fleet_mist_name, + "catalog_fleet_mist_notes" to R.string.catalog_fleet_mist_notes, + "catalog_fleet_mojo_name" to R.string.catalog_fleet_mojo_name, + "catalog_fleet_mojo_notes" to R.string.catalog_fleet_mojo_notes, + "catalog_fleet_motive_name" to R.string.catalog_fleet_motive_name, + "catalog_fleet_motive_notes" to R.string.catalog_fleet_motive_notes, + "catalog_fleet_myq_name" to R.string.catalog_fleet_myq_name, + "catalog_fleet_myq_notes" to R.string.catalog_fleet_myq_notes, + "catalog_fleet_nespresso_name" to R.string.catalog_fleet_nespresso_name, + "catalog_fleet_nespresso_notes" to R.string.catalog_fleet_nespresso_notes, + "catalog_fleet_nest_name" to R.string.catalog_fleet_nest_name, + "catalog_fleet_nest_notes" to R.string.catalog_fleet_nest_notes, + "catalog_fleet_nest_thermostat_name" to R.string.catalog_fleet_nest_thermostat_name, + "catalog_fleet_nest_thermostat_notes" to R.string.catalog_fleet_nest_thermostat_notes, + "catalog_fleet_nest_weave_decode_device_id_label" to R.string.catalog_fleet_nest_weave_decode_device_id_label, + "catalog_fleet_nest_weave_decode_pairing_enum_0" to R.string.catalog_fleet_nest_weave_decode_pairing_enum_0, + "catalog_fleet_nest_weave_decode_pairing_enum_1" to R.string.catalog_fleet_nest_weave_decode_pairing_enum_1, + "catalog_fleet_nest_weave_decode_pairing_label" to R.string.catalog_fleet_nest_weave_decode_pairing_label, + "catalog_fleet_nest_weave_decode_product_enum_1" to R.string.catalog_fleet_nest_weave_decode_product_enum_1, + "catalog_fleet_nest_weave_decode_product_enum_10" to R.string.catalog_fleet_nest_weave_decode_product_enum_10, + "catalog_fleet_nest_weave_decode_product_enum_11" to R.string.catalog_fleet_nest_weave_decode_product_enum_11, + "catalog_fleet_nest_weave_decode_product_enum_12" to R.string.catalog_fleet_nest_weave_decode_product_enum_12, + "catalog_fleet_nest_weave_decode_product_enum_13" to R.string.catalog_fleet_nest_weave_decode_product_enum_13, + "catalog_fleet_nest_weave_decode_product_enum_14" to R.string.catalog_fleet_nest_weave_decode_product_enum_14, + "catalog_fleet_nest_weave_decode_product_enum_15" to R.string.catalog_fleet_nest_weave_decode_product_enum_15, + "catalog_fleet_nest_weave_decode_product_enum_16" to R.string.catalog_fleet_nest_weave_decode_product_enum_16, + "catalog_fleet_nest_weave_decode_product_enum_17" to R.string.catalog_fleet_nest_weave_decode_product_enum_17, + "catalog_fleet_nest_weave_decode_product_enum_18" to R.string.catalog_fleet_nest_weave_decode_product_enum_18, + "catalog_fleet_nest_weave_decode_product_enum_19" to R.string.catalog_fleet_nest_weave_decode_product_enum_19, + "catalog_fleet_nest_weave_decode_product_enum_2" to R.string.catalog_fleet_nest_weave_decode_product_enum_2, + "catalog_fleet_nest_weave_decode_product_enum_20" to R.string.catalog_fleet_nest_weave_decode_product_enum_20, + "catalog_fleet_nest_weave_decode_product_enum_21" to R.string.catalog_fleet_nest_weave_decode_product_enum_21, + "catalog_fleet_nest_weave_decode_product_enum_22" to R.string.catalog_fleet_nest_weave_decode_product_enum_22, + "catalog_fleet_nest_weave_decode_product_enum_23" to R.string.catalog_fleet_nest_weave_decode_product_enum_23, + "catalog_fleet_nest_weave_decode_product_enum_24" to R.string.catalog_fleet_nest_weave_decode_product_enum_24, + "catalog_fleet_nest_weave_decode_product_enum_26" to R.string.catalog_fleet_nest_weave_decode_product_enum_26, + "catalog_fleet_nest_weave_decode_product_enum_3" to R.string.catalog_fleet_nest_weave_decode_product_enum_3, + "catalog_fleet_nest_weave_decode_product_enum_4" to R.string.catalog_fleet_nest_weave_decode_product_enum_4, + "catalog_fleet_nest_weave_decode_product_enum_5" to R.string.catalog_fleet_nest_weave_decode_product_enum_5, + "catalog_fleet_nest_weave_decode_product_enum_6" to R.string.catalog_fleet_nest_weave_decode_product_enum_6, + "catalog_fleet_nest_weave_decode_product_enum_7" to R.string.catalog_fleet_nest_weave_decode_product_enum_7, + "catalog_fleet_nest_weave_decode_product_enum_8" to R.string.catalog_fleet_nest_weave_decode_product_enum_8, + "catalog_fleet_nest_weave_decode_product_enum_9" to R.string.catalog_fleet_nest_weave_decode_product_enum_9, + "catalog_fleet_nest_weave_decode_product_label" to R.string.catalog_fleet_nest_weave_decode_product_label, + "catalog_fleet_nest_weave_decode_vendor_enum_59175" to R.string.catalog_fleet_nest_weave_decode_vendor_enum_59175, + "catalog_fleet_nest_weave_decode_vendor_enum_9050" to R.string.catalog_fleet_nest_weave_decode_vendor_enum_9050, + "catalog_fleet_nest_weave_decode_vendor_label" to R.string.catalog_fleet_nest_weave_decode_vendor_label, + "catalog_fleet_nest_weave_name" to R.string.catalog_fleet_nest_weave_name, + "catalog_fleet_nest_weave_notes" to R.string.catalog_fleet_nest_weave_notes, + "catalog_fleet_netgear_name" to R.string.catalog_fleet_netgear_name, + "catalog_fleet_netgear_notes" to R.string.catalog_fleet_netgear_notes, + "catalog_fleet_nissan_name" to R.string.catalog_fleet_nissan_name, + "catalog_fleet_nissan_notes" to R.string.catalog_fleet_nissan_notes, + "catalog_fleet_nokia_name" to R.string.catalog_fleet_nokia_name, + "catalog_fleet_nokia_notes" to R.string.catalog_fleet_nokia_notes, + "catalog_fleet_novatel_attention" to R.string.catalog_fleet_novatel_attention, + "catalog_fleet_novatel_name" to R.string.catalog_fleet_novatel_name, + "catalog_fleet_novatel_notes" to R.string.catalog_fleet_novatel_notes, + "catalog_fleet_nuki_name" to R.string.catalog_fleet_nuki_name, + "catalog_fleet_nuki_notes" to R.string.catalog_fleet_nuki_notes, + "catalog_fleet_omi_attention" to R.string.catalog_fleet_omi_attention, + "catalog_fleet_omi_name" to R.string.catalog_fleet_omi_name, + "catalog_fleet_omi_notes" to R.string.catalog_fleet_omi_notes, + "catalog_fleet_omron_name" to R.string.catalog_fleet_omron_name, + "catalog_fleet_omron_notes" to R.string.catalog_fleet_omron_notes, + "catalog_fleet_openwrt_name" to R.string.catalog_fleet_openwrt_name, + "catalog_fleet_openwrt_notes" to R.string.catalog_fleet_openwrt_notes, + "catalog_fleet_osmo_decode_model_enum_112" to R.string.catalog_fleet_osmo_decode_model_enum_112, + "catalog_fleet_osmo_decode_model_enum_126" to R.string.catalog_fleet_osmo_decode_model_enum_126, + "catalog_fleet_osmo_decode_model_enum_16" to R.string.catalog_fleet_osmo_decode_model_enum_16, + "catalog_fleet_osmo_decode_model_enum_18" to R.string.catalog_fleet_osmo_decode_model_enum_18, + "catalog_fleet_osmo_decode_model_enum_20" to R.string.catalog_fleet_osmo_decode_model_enum_20, + "catalog_fleet_osmo_decode_model_enum_21" to R.string.catalog_fleet_osmo_decode_model_enum_21, + "catalog_fleet_osmo_decode_model_enum_23" to R.string.catalog_fleet_osmo_decode_model_enum_23, + "catalog_fleet_osmo_decode_model_enum_24" to R.string.catalog_fleet_osmo_decode_model_enum_24, + "catalog_fleet_osmo_decode_model_enum_25" to R.string.catalog_fleet_osmo_decode_model_enum_25, + "catalog_fleet_osmo_decode_model_enum_32" to R.string.catalog_fleet_osmo_decode_model_enum_32, + "catalog_fleet_osmo_decode_model_enum_33" to R.string.catalog_fleet_osmo_decode_model_enum_33, + "catalog_fleet_osmo_decode_model_enum_34" to R.string.catalog_fleet_osmo_decode_model_enum_34, + "catalog_fleet_osmo_decode_model_enum_6" to R.string.catalog_fleet_osmo_decode_model_enum_6, + "catalog_fleet_osmo_decode_model_label" to R.string.catalog_fleet_osmo_decode_model_label, + "catalog_fleet_osmo_name" to R.string.catalog_fleet_osmo_name, + "catalog_fleet_osmo_notes" to R.string.catalog_fleet_osmo_notes, + "catalog_fleet_oura_name" to R.string.catalog_fleet_oura_name, + "catalog_fleet_oura_notes" to R.string.catalog_fleet_oura_notes, + "catalog_fleet_pacific_tpms_name" to R.string.catalog_fleet_pacific_tpms_name, + "catalog_fleet_pacific_tpms_notes" to R.string.catalog_fleet_pacific_tpms_notes, + "catalog_fleet_panasonic_ipro_attention" to R.string.catalog_fleet_panasonic_ipro_attention, + "catalog_fleet_panasonic_ipro_name" to R.string.catalog_fleet_panasonic_ipro_name, + "catalog_fleet_panasonic_ipro_notes" to R.string.catalog_fleet_panasonic_ipro_notes, + "catalog_fleet_parrot_name" to R.string.catalog_fleet_parrot_name, + "catalog_fleet_parrot_notes" to R.string.catalog_fleet_parrot_notes, + ) + + private fun group7(): Map = mapOf( + "catalog_fleet_paxton_name" to R.string.catalog_fleet_paxton_name, + "catalog_fleet_paxton_notes" to R.string.catalog_fleet_paxton_notes, + "catalog_fleet_pebblebee_name" to R.string.catalog_fleet_pebblebee_name, + "catalog_fleet_pebblebee_notes" to R.string.catalog_fleet_pebblebee_notes, + "catalog_fleet_penguin_attention" to R.string.catalog_fleet_penguin_attention, + "catalog_fleet_penguin_decode_adv_mac_label" to R.string.catalog_fleet_penguin_decode_adv_mac_label, + "catalog_fleet_penguin_decode_serial_label" to R.string.catalog_fleet_penguin_decode_serial_label, + "catalog_fleet_penguin_name" to R.string.catalog_fleet_penguin_name, + "catalog_fleet_penguin_notes" to R.string.catalog_fleet_penguin_notes, + "catalog_fleet_peoplenet_name" to R.string.catalog_fleet_peoplenet_name, + "catalog_fleet_peoplenet_notes" to R.string.catalog_fleet_peoplenet_notes, + "catalog_fleet_peplink_name" to R.string.catalog_fleet_peplink_name, + "catalog_fleet_peplink_notes" to R.string.catalog_fleet_peplink_notes, + "catalog_fleet_phone_hotspot_name" to R.string.catalog_fleet_phone_hotspot_name, + "catalog_fleet_phone_hotspot_notes" to R.string.catalog_fleet_phone_hotspot_notes, + "catalog_fleet_pigvision_attention" to R.string.catalog_fleet_pigvision_attention, + "catalog_fleet_pigvision_name" to R.string.catalog_fleet_pigvision_name, + "catalog_fleet_pigvision_notes" to R.string.catalog_fleet_pigvision_notes, + "catalog_fleet_plaud_attention" to R.string.catalog_fleet_plaud_attention, + "catalog_fleet_plaud_name" to R.string.catalog_fleet_plaud_name, + "catalog_fleet_plaud_notes" to R.string.catalog_fleet_plaud_notes, + "catalog_fleet_plume_name" to R.string.catalog_fleet_plume_name, + "catalog_fleet_plume_notes" to R.string.catalog_fleet_plume_notes, + "catalog_fleet_pokemon_go_plus_name" to R.string.catalog_fleet_pokemon_go_plus_name, + "catalog_fleet_pokemon_go_plus_notes" to R.string.catalog_fleet_pokemon_go_plus_notes, + "catalog_fleet_porkchop_attention" to R.string.catalog_fleet_porkchop_attention, + "catalog_fleet_porkchop_name" to R.string.catalog_fleet_porkchop_name, + "catalog_fleet_porkchop_notes" to R.string.catalog_fleet_porkchop_notes, + "catalog_fleet_porsche_name" to R.string.catalog_fleet_porsche_name, + "catalog_fleet_porsche_notes" to R.string.catalog_fleet_porsche_notes, + "catalog_fleet_potensic_name" to R.string.catalog_fleet_potensic_name, + "catalog_fleet_potensic_notes" to R.string.catalog_fleet_potensic_notes, + "catalog_fleet_pwnagotchi_attention" to R.string.catalog_fleet_pwnagotchi_attention, + "catalog_fleet_pwnagotchi_name" to R.string.catalog_fleet_pwnagotchi_name, + "catalog_fleet_pwnagotchi_notes" to R.string.catalog_fleet_pwnagotchi_notes, + "catalog_fleet_radiacode_name" to R.string.catalog_fleet_radiacode_name, + "catalog_fleet_radiacode_notes" to R.string.catalog_fleet_radiacode_notes, + "catalog_fleet_rak_wisgate_name" to R.string.catalog_fleet_rak_wisgate_name, + "catalog_fleet_rak_wisgate_notes" to R.string.catalog_fleet_rak_wisgate_notes, + "catalog_fleet_raven_name" to R.string.catalog_fleet_raven_name, + "catalog_fleet_raven_notes" to R.string.catalog_fleet_raven_notes, + "catalog_fleet_rayneo_attention" to R.string.catalog_fleet_rayneo_attention, + "catalog_fleet_rayneo_name" to R.string.catalog_fleet_rayneo_name, + "catalog_fleet_rayneo_notes" to R.string.catalog_fleet_rayneo_notes, + "catalog_fleet_rekor_attention" to R.string.catalog_fleet_rekor_attention, + "catalog_fleet_rekor_name" to R.string.catalog_fleet_rekor_name, + "catalog_fleet_rekor_notes" to R.string.catalog_fleet_rekor_notes, + "catalog_fleet_remote_id_decode_alt_baro_label" to R.string.catalog_fleet_remote_id_decode_alt_baro_label, + "catalog_fleet_remote_id_decode_alt_geo_label" to R.string.catalog_fleet_remote_id_decode_alt_geo_label, + "catalog_fleet_remote_id_decode_app_enum_13" to R.string.catalog_fleet_remote_id_decode_app_enum_13, + "catalog_fleet_remote_id_decode_app_label" to R.string.catalog_fleet_remote_id_decode_app_label, + "catalog_fleet_remote_id_decode_counter_label" to R.string.catalog_fleet_remote_id_decode_counter_label, + "catalog_fleet_remote_id_decode_heading_label" to R.string.catalog_fleet_remote_id_decode_heading_label, + "catalog_fleet_remote_id_decode_height_label" to R.string.catalog_fleet_remote_id_decode_height_label, + "catalog_fleet_remote_id_decode_hspeed_label" to R.string.catalog_fleet_remote_id_decode_hspeed_label, + "catalog_fleet_remote_id_decode_id_type_enum_0" to R.string.catalog_fleet_remote_id_decode_id_type_enum_0, + "catalog_fleet_remote_id_decode_id_type_enum_1" to R.string.catalog_fleet_remote_id_decode_id_type_enum_1, + "catalog_fleet_remote_id_decode_id_type_enum_2" to R.string.catalog_fleet_remote_id_decode_id_type_enum_2, + "catalog_fleet_remote_id_decode_id_type_enum_3" to R.string.catalog_fleet_remote_id_decode_id_type_enum_3, + "catalog_fleet_remote_id_decode_id_type_enum_4" to R.string.catalog_fleet_remote_id_decode_id_type_enum_4, + "catalog_fleet_remote_id_decode_id_type_label" to R.string.catalog_fleet_remote_id_decode_id_type_label, + "catalog_fleet_remote_id_decode_latitude_label" to R.string.catalog_fleet_remote_id_decode_latitude_label, + "catalog_fleet_remote_id_decode_longitude_label" to R.string.catalog_fleet_remote_id_decode_longitude_label, + "catalog_fleet_remote_id_decode_msg_type_enum_0" to R.string.catalog_fleet_remote_id_decode_msg_type_enum_0, + "catalog_fleet_remote_id_decode_msg_type_enum_1" to R.string.catalog_fleet_remote_id_decode_msg_type_enum_1, + "catalog_fleet_remote_id_decode_msg_type_enum_15" to R.string.catalog_fleet_remote_id_decode_msg_type_enum_15, + "catalog_fleet_remote_id_decode_msg_type_enum_2" to R.string.catalog_fleet_remote_id_decode_msg_type_enum_2, + "catalog_fleet_remote_id_decode_msg_type_enum_3" to R.string.catalog_fleet_remote_id_decode_msg_type_enum_3, + "catalog_fleet_remote_id_decode_msg_type_enum_4" to R.string.catalog_fleet_remote_id_decode_msg_type_enum_4, + "catalog_fleet_remote_id_decode_msg_type_enum_5" to R.string.catalog_fleet_remote_id_decode_msg_type_enum_5, + "catalog_fleet_remote_id_decode_msg_type_label" to R.string.catalog_fleet_remote_id_decode_msg_type_label, + "catalog_fleet_remote_id_decode_op_lat_label" to R.string.catalog_fleet_remote_id_decode_op_lat_label, + "catalog_fleet_remote_id_decode_op_lon_label" to R.string.catalog_fleet_remote_id_decode_op_lon_label, + "catalog_fleet_remote_id_decode_operator_id_label" to R.string.catalog_fleet_remote_id_decode_operator_id_label, + "catalog_fleet_remote_id_decode_proto_label" to R.string.catalog_fleet_remote_id_decode_proto_label, + "catalog_fleet_remote_id_decode_self_id_label" to R.string.catalog_fleet_remote_id_decode_self_id_label, + "catalog_fleet_remote_id_decode_status_enum_0" to R.string.catalog_fleet_remote_id_decode_status_enum_0, + "catalog_fleet_remote_id_decode_status_enum_1" to R.string.catalog_fleet_remote_id_decode_status_enum_1, + "catalog_fleet_remote_id_decode_status_enum_2" to R.string.catalog_fleet_remote_id_decode_status_enum_2, + "catalog_fleet_remote_id_decode_status_enum_3" to R.string.catalog_fleet_remote_id_decode_status_enum_3, + "catalog_fleet_remote_id_decode_status_enum_4" to R.string.catalog_fleet_remote_id_decode_status_enum_4, + "catalog_fleet_remote_id_decode_status_label" to R.string.catalog_fleet_remote_id_decode_status_label, + "catalog_fleet_remote_id_decode_ua_type_enum_0" to R.string.catalog_fleet_remote_id_decode_ua_type_enum_0, + "catalog_fleet_remote_id_decode_ua_type_enum_1" to R.string.catalog_fleet_remote_id_decode_ua_type_enum_1, + "catalog_fleet_remote_id_decode_ua_type_enum_10" to R.string.catalog_fleet_remote_id_decode_ua_type_enum_10, + "catalog_fleet_remote_id_decode_ua_type_enum_15" to R.string.catalog_fleet_remote_id_decode_ua_type_enum_15, + "catalog_fleet_remote_id_decode_ua_type_enum_2" to R.string.catalog_fleet_remote_id_decode_ua_type_enum_2, + "catalog_fleet_remote_id_decode_ua_type_enum_3" to R.string.catalog_fleet_remote_id_decode_ua_type_enum_3, + "catalog_fleet_remote_id_decode_ua_type_enum_4" to R.string.catalog_fleet_remote_id_decode_ua_type_enum_4, + "catalog_fleet_remote_id_decode_ua_type_enum_6" to R.string.catalog_fleet_remote_id_decode_ua_type_enum_6, + "catalog_fleet_remote_id_decode_ua_type_label" to R.string.catalog_fleet_remote_id_decode_ua_type_label, + "catalog_fleet_remote_id_decode_uas_id_label" to R.string.catalog_fleet_remote_id_decode_uas_id_label, + "catalog_fleet_remote_id_decode_vspeed_label" to R.string.catalog_fleet_remote_id_decode_vspeed_label, + "catalog_fleet_remote_id_name" to R.string.catalog_fleet_remote_id_name, + "catalog_fleet_remote_id_notes" to R.string.catalog_fleet_remote_id_notes, + "catalog_fleet_reolink_name" to R.string.catalog_fleet_reolink_name, + "catalog_fleet_reolink_notes" to R.string.catalog_fleet_reolink_notes, + "catalog_fleet_retail_led_sign_name" to R.string.catalog_fleet_retail_led_sign_name, + "catalog_fleet_retail_led_sign_notes" to R.string.catalog_fleet_retail_led_sign_notes, + "catalog_fleet_reveal_media_attention" to R.string.catalog_fleet_reveal_media_attention, + ) + + private fun group8(): Map = mapOf( + "catalog_fleet_reveal_media_name" to R.string.catalog_fleet_reveal_media_name, + "catalog_fleet_reveal_media_notes" to R.string.catalog_fleet_reveal_media_notes, + "catalog_fleet_rhombus_attention" to R.string.catalog_fleet_rhombus_attention, + "catalog_fleet_rhombus_name" to R.string.catalog_fleet_rhombus_name, + "catalog_fleet_rhombus_notes" to R.string.catalog_fleet_rhombus_notes, + "catalog_fleet_ring_name" to R.string.catalog_fleet_ring_name, + "catalog_fleet_ring_notes" to R.string.catalog_fleet_ring_notes, + "catalog_fleet_rivian_name" to R.string.catalog_fleet_rivian_name, + "catalog_fleet_rivian_notes" to R.string.catalog_fleet_rivian_notes, + "catalog_fleet_roku_name" to R.string.catalog_fleet_roku_name, + "catalog_fleet_roku_notes" to R.string.catalog_fleet_roku_notes, + "catalog_fleet_ruckus_name" to R.string.catalog_fleet_ruckus_name, + "catalog_fleet_ruckus_notes" to R.string.catalog_fleet_ruckus_notes, + "catalog_fleet_ruijie_name" to R.string.catalog_fleet_ruijie_name, + "catalog_fleet_ruijie_notes" to R.string.catalog_fleet_ruijie_notes, + "catalog_fleet_ruuvi_decode_acc_x_label" to R.string.catalog_fleet_ruuvi_decode_acc_x_label, + "catalog_fleet_ruuvi_decode_acc_y_label" to R.string.catalog_fleet_ruuvi_decode_acc_y_label, + "catalog_fleet_ruuvi_decode_acc_z_label" to R.string.catalog_fleet_ruuvi_decode_acc_z_label, + "catalog_fleet_ruuvi_decode_battery_label" to R.string.catalog_fleet_ruuvi_decode_battery_label, + "catalog_fleet_ruuvi_decode_format_label" to R.string.catalog_fleet_ruuvi_decode_format_label, + "catalog_fleet_ruuvi_decode_humidity_label" to R.string.catalog_fleet_ruuvi_decode_humidity_label, + "catalog_fleet_ruuvi_decode_mac_label" to R.string.catalog_fleet_ruuvi_decode_mac_label, + "catalog_fleet_ruuvi_decode_movement_label" to R.string.catalog_fleet_ruuvi_decode_movement_label, + "catalog_fleet_ruuvi_decode_pressure_label" to R.string.catalog_fleet_ruuvi_decode_pressure_label, + "catalog_fleet_ruuvi_decode_sequence_label" to R.string.catalog_fleet_ruuvi_decode_sequence_label, + "catalog_fleet_ruuvi_decode_temperature_label" to R.string.catalog_fleet_ruuvi_decode_temperature_label, + "catalog_fleet_ruuvi_decode_tx_power_label" to R.string.catalog_fleet_ruuvi_decode_tx_power_label, + "catalog_fleet_ruuvi_name" to R.string.catalog_fleet_ruuvi_name, + "catalog_fleet_ruuvi_notes" to R.string.catalog_fleet_ruuvi_notes, + "catalog_fleet_sagemcom_name" to R.string.catalog_fleet_sagemcom_name, + "catalog_fleet_sagemcom_notes" to R.string.catalog_fleet_sagemcom_notes, + "catalog_fleet_salto_name" to R.string.catalog_fleet_salto_name, + "catalog_fleet_salto_notes" to R.string.catalog_fleet_salto_notes, + "catalog_fleet_samsara_name" to R.string.catalog_fleet_samsara_name, + "catalog_fleet_samsara_notes" to R.string.catalog_fleet_samsara_notes, + "catalog_fleet_samsung_appliance_name" to R.string.catalog_fleet_samsung_appliance_name, + "catalog_fleet_samsung_appliance_notes" to R.string.catalog_fleet_samsung_appliance_notes, + "catalog_fleet_schlage_name" to R.string.catalog_fleet_schlage_name, + "catalog_fleet_schlage_notes" to R.string.catalog_fleet_schlage_notes, + "catalog_fleet_schrader_name" to R.string.catalog_fleet_schrader_name, + "catalog_fleet_schrader_notes" to R.string.catalog_fleet_schrader_notes, + "catalog_fleet_sensecap_name" to R.string.catalog_fleet_sensecap_name, + "catalog_fleet_sensecap_notes" to R.string.catalog_fleet_sensecap_notes, + "catalog_fleet_sensi_name" to R.string.catalog_fleet_sensi_name, + "catalog_fleet_sensi_notes" to R.string.catalog_fleet_sensi_notes, + "catalog_fleet_sensorpush_name" to R.string.catalog_fleet_sensorpush_name, + "catalog_fleet_sensorpush_notes" to R.string.catalog_fleet_sensorpush_notes, + "catalog_fleet_seos_name" to R.string.catalog_fleet_seos_name, + "catalog_fleet_seos_notes" to R.string.catalog_fleet_seos_notes, + "catalog_fleet_sercomm_name" to R.string.catalog_fleet_sercomm_name, + "catalog_fleet_sercomm_notes" to R.string.catalog_fleet_sercomm_notes, + "catalog_fleet_shokz_name" to R.string.catalog_fleet_shokz_name, + "catalog_fleet_shokz_notes" to R.string.catalog_fleet_shokz_notes, + "catalog_fleet_skydio_name" to R.string.catalog_fleet_skydio_name, + "catalog_fleet_skydio_notes" to R.string.catalog_fleet_skydio_notes, + "catalog_fleet_smarttag_name" to R.string.catalog_fleet_smarttag_name, + "catalog_fleet_smarttag_notes" to R.string.catalog_fleet_smarttag_notes, + "catalog_fleet_snap_spectacles_attention" to R.string.catalog_fleet_snap_spectacles_attention, + "catalog_fleet_snap_spectacles_name" to R.string.catalog_fleet_snap_spectacles_name, + "catalog_fleet_snap_spectacles_notes" to R.string.catalog_fleet_snap_spectacles_notes, + "catalog_fleet_snapav_name" to R.string.catalog_fleet_snapav_name, + "catalog_fleet_snapav_notes" to R.string.catalog_fleet_snapav_notes, + "catalog_fleet_sonos_name" to R.string.catalog_fleet_sonos_name, + "catalog_fleet_sonos_notes" to R.string.catalog_fleet_sonos_notes, + "catalog_fleet_sony_name" to R.string.catalog_fleet_sony_name, + "catalog_fleet_sony_notes" to R.string.catalog_fleet_sony_notes, + "catalog_fleet_sophos_name" to R.string.catalog_fleet_sophos_name, + "catalog_fleet_sophos_notes" to R.string.catalog_fleet_sophos_notes, + "catalog_fleet_spectrum_name" to R.string.catalog_fleet_spectrum_name, + "catalog_fleet_spectrum_notes" to R.string.catalog_fleet_spectrum_notes, + "catalog_fleet_starlink_name" to R.string.catalog_fleet_starlink_name, + "catalog_fleet_starlink_notes" to R.string.catalog_fleet_starlink_notes, + "catalog_fleet_subaru_name" to R.string.catalog_fleet_subaru_name, + "catalog_fleet_subaru_notes" to R.string.catalog_fleet_subaru_notes, + "catalog_fleet_swellpro_name" to R.string.catalog_fleet_swellpro_name, + "catalog_fleet_swellpro_notes" to R.string.catalog_fleet_swellpro_notes, + "catalog_fleet_synology_name" to R.string.catalog_fleet_synology_name, + "catalog_fleet_synology_notes" to R.string.catalog_fleet_synology_notes, + "catalog_fleet_sytpms_decode_alarm_enum_0" to R.string.catalog_fleet_sytpms_decode_alarm_enum_0, + "catalog_fleet_sytpms_decode_alarm_enum_1" to R.string.catalog_fleet_sytpms_decode_alarm_enum_1, + "catalog_fleet_sytpms_decode_alarm_label" to R.string.catalog_fleet_sytpms_decode_alarm_label, + "catalog_fleet_sytpms_decode_battery_label" to R.string.catalog_fleet_sytpms_decode_battery_label, + "catalog_fleet_sytpms_decode_pressure_label" to R.string.catalog_fleet_sytpms_decode_pressure_label, + "catalog_fleet_sytpms_decode_rotating_enum_0" to R.string.catalog_fleet_sytpms_decode_rotating_enum_0, + "catalog_fleet_sytpms_decode_rotating_enum_1" to R.string.catalog_fleet_sytpms_decode_rotating_enum_1, + "catalog_fleet_sytpms_decode_rotating_label" to R.string.catalog_fleet_sytpms_decode_rotating_label, + "catalog_fleet_sytpms_decode_still_enum_0" to R.string.catalog_fleet_sytpms_decode_still_enum_0, + "catalog_fleet_sytpms_decode_still_enum_1" to R.string.catalog_fleet_sytpms_decode_still_enum_1, + "catalog_fleet_sytpms_decode_still_label" to R.string.catalog_fleet_sytpms_decode_still_label, + "catalog_fleet_sytpms_decode_temperature_label" to R.string.catalog_fleet_sytpms_decode_temperature_label, + "catalog_fleet_sytpms_name" to R.string.catalog_fleet_sytpms_name, + "catalog_fleet_sytpms_notes" to R.string.catalog_fleet_sytpms_notes, + "catalog_fleet_tapo_name" to R.string.catalog_fleet_tapo_name, + "catalog_fleet_tapo_notes" to R.string.catalog_fleet_tapo_notes, + "catalog_fleet_target_atrius_name" to R.string.catalog_fleet_target_atrius_name, + "catalog_fleet_target_atrius_notes" to R.string.catalog_fleet_target_atrius_notes, + "catalog_fleet_tattile_attention" to R.string.catalog_fleet_tattile_attention, + "catalog_fleet_tattile_name" to R.string.catalog_fleet_tattile_name, + "catalog_fleet_tattile_notes" to R.string.catalog_fleet_tattile_notes, + "catalog_fleet_tedee_name" to R.string.catalog_fleet_tedee_name, + ) + + private fun group9(): Map = mapOf( + "catalog_fleet_tedee_notes" to R.string.catalog_fleet_tedee_notes, + "catalog_fleet_tello_name" to R.string.catalog_fleet_tello_name, + "catalog_fleet_tello_notes" to R.string.catalog_fleet_tello_notes, + "catalog_fleet_tenda_name" to R.string.catalog_fleet_tenda_name, + "catalog_fleet_tenda_notes" to R.string.catalog_fleet_tenda_notes, + "catalog_fleet_tesla_name" to R.string.catalog_fleet_tesla_name, + "catalog_fleet_tesla_notes" to R.string.catalog_fleet_tesla_notes, + "catalog_fleet_tesla_tstpms_decode_battery_label" to R.string.catalog_fleet_tesla_tstpms_decode_battery_label, + "catalog_fleet_tesla_tstpms_decode_mode_enum_0" to R.string.catalog_fleet_tesla_tstpms_decode_mode_enum_0, + "catalog_fleet_tesla_tstpms_decode_mode_enum_1" to R.string.catalog_fleet_tesla_tstpms_decode_mode_enum_1, + "catalog_fleet_tesla_tstpms_decode_mode_enum_2" to R.string.catalog_fleet_tesla_tstpms_decode_mode_enum_2, + "catalog_fleet_tesla_tstpms_decode_mode_enum_3" to R.string.catalog_fleet_tesla_tstpms_decode_mode_enum_3, + "catalog_fleet_tesla_tstpms_decode_mode_enum_4" to R.string.catalog_fleet_tesla_tstpms_decode_mode_enum_4, + "catalog_fleet_tesla_tstpms_decode_mode_label" to R.string.catalog_fleet_tesla_tstpms_decode_mode_label, + "catalog_fleet_tesla_tstpms_decode_pressure_label" to R.string.catalog_fleet_tesla_tstpms_decode_pressure_label, + "catalog_fleet_tesla_tstpms_decode_temperature_label" to R.string.catalog_fleet_tesla_tstpms_decode_temperature_label, + "catalog_fleet_tesla_tstpms_name" to R.string.catalog_fleet_tesla_tstpms_name, + "catalog_fleet_tesla_tstpms_notes" to R.string.catalog_fleet_tesla_tstpms_notes, + "catalog_fleet_tile_decode_private_id_label" to R.string.catalog_fleet_tile_decode_private_id_label, + "catalog_fleet_tile_name" to R.string.catalog_fleet_tile_name, + "catalog_fleet_tile_notes" to R.string.catalog_fleet_tile_notes, + "catalog_fleet_tirecheck_name" to R.string.catalog_fleet_tirecheck_name, + "catalog_fleet_tirecheck_notes" to R.string.catalog_fleet_tirecheck_notes, + "catalog_fleet_tmobile_name" to R.string.catalog_fleet_tmobile_name, + "catalog_fleet_tmobile_notes" to R.string.catalog_fleet_tmobile_notes, + "catalog_fleet_toyota_name" to R.string.catalog_fleet_toyota_name, + "catalog_fleet_toyota_notes" to R.string.catalog_fleet_toyota_notes, + "catalog_fleet_tplink_name" to R.string.catalog_fleet_tplink_name, + "catalog_fleet_tplink_notes" to R.string.catalog_fleet_tplink_notes, + "catalog_fleet_tpms_ble_decode_alarm_enum_0" to R.string.catalog_fleet_tpms_ble_decode_alarm_enum_0, + "catalog_fleet_tpms_ble_decode_alarm_enum_1" to R.string.catalog_fleet_tpms_ble_decode_alarm_enum_1, + "catalog_fleet_tpms_ble_decode_alarm_label" to R.string.catalog_fleet_tpms_ble_decode_alarm_label, + "catalog_fleet_tpms_ble_decode_battery_label" to R.string.catalog_fleet_tpms_ble_decode_battery_label, + "catalog_fleet_tpms_ble_decode_pressure_label" to R.string.catalog_fleet_tpms_ble_decode_pressure_label, + "catalog_fleet_tpms_ble_decode_sensor_id_label" to R.string.catalog_fleet_tpms_ble_decode_sensor_id_label, + "catalog_fleet_tpms_ble_decode_temperature_label" to R.string.catalog_fleet_tpms_ble_decode_temperature_label, + "catalog_fleet_tpms_ble_decode_wheel_enum_128" to R.string.catalog_fleet_tpms_ble_decode_wheel_enum_128, + "catalog_fleet_tpms_ble_decode_wheel_enum_129" to R.string.catalog_fleet_tpms_ble_decode_wheel_enum_129, + "catalog_fleet_tpms_ble_decode_wheel_enum_130" to R.string.catalog_fleet_tpms_ble_decode_wheel_enum_130, + "catalog_fleet_tpms_ble_decode_wheel_enum_131" to R.string.catalog_fleet_tpms_ble_decode_wheel_enum_131, + "catalog_fleet_tpms_ble_decode_wheel_label" to R.string.catalog_fleet_tpms_ble_decode_wheel_label, + "catalog_fleet_tpms_ble_name" to R.string.catalog_fleet_tpms_ble_name, + "catalog_fleet_tpms_ble_notes" to R.string.catalog_fleet_tpms_ble_notes, + "catalog_fleet_tpms_service_name" to R.string.catalog_fleet_tpms_service_name, + "catalog_fleet_tpms_service_notes" to R.string.catalog_fleet_tpms_service_notes, + "catalog_fleet_trendnet_name" to R.string.catalog_fleet_trendnet_name, + "catalog_fleet_trendnet_notes" to R.string.catalog_fleet_trendnet_notes, + "catalog_fleet_tuya_decode_bound_enum_0" to R.string.catalog_fleet_tuya_decode_bound_enum_0, + "catalog_fleet_tuya_decode_bound_enum_1" to R.string.catalog_fleet_tuya_decode_bound_enum_1, + "catalog_fleet_tuya_decode_bound_label" to R.string.catalog_fleet_tuya_decode_bound_label, + "catalog_fleet_tuya_decode_protocol_label" to R.string.catalog_fleet_tuya_decode_protocol_label, + "catalog_fleet_tuya_name" to R.string.catalog_fleet_tuya_name, + "catalog_fleet_tuya_notes" to R.string.catalog_fleet_tuya_notes, + "catalog_fleet_uconnect_name" to R.string.catalog_fleet_uconnect_name, + "catalog_fleet_uconnect_notes" to R.string.catalog_fleet_uconnect_notes, + "catalog_fleet_unifi_ap_name" to R.string.catalog_fleet_unifi_ap_name, + "catalog_fleet_unifi_ap_notes" to R.string.catalog_fleet_unifi_ap_notes, + "catalog_fleet_unifi_name" to R.string.catalog_fleet_unifi_name, + "catalog_fleet_unifi_notes" to R.string.catalog_fleet_unifi_notes, + "catalog_fleet_unifi_protect_name" to R.string.catalog_fleet_unifi_protect_name, + "catalog_fleet_unifi_protect_notes" to R.string.catalog_fleet_unifi_protect_notes, + "catalog_fleet_uniview_attention" to R.string.catalog_fleet_uniview_attention, + "catalog_fleet_uniview_name" to R.string.catalog_fleet_uniview_name, + "catalog_fleet_uniview_notes" to R.string.catalog_fleet_uniview_notes, + "catalog_fleet_utility_inc_attention" to R.string.catalog_fleet_utility_inc_attention, + "catalog_fleet_utility_inc_name" to R.string.catalog_fleet_utility_inc_name, + "catalog_fleet_utility_inc_notes" to R.string.catalog_fleet_utility_inc_notes, + "catalog_fleet_vantiva_name" to R.string.catalog_fleet_vantiva_name, + "catalog_fleet_vantiva_notes" to R.string.catalog_fleet_vantiva_notes, + "catalog_fleet_verizon_name" to R.string.catalog_fleet_verizon_name, + "catalog_fleet_verizon_notes" to R.string.catalog_fleet_verizon_notes, + "catalog_fleet_verkada_attention" to R.string.catalog_fleet_verkada_attention, + "catalog_fleet_verkada_name" to R.string.catalog_fleet_verkada_name, + "catalog_fleet_verkada_notes" to R.string.catalog_fleet_verkada_notes, + "catalog_fleet_vigilant_attention" to R.string.catalog_fleet_vigilant_attention, + "catalog_fleet_vigilant_name" to R.string.catalog_fleet_vigilant_name, + "catalog_fleet_vigilant_notes" to R.string.catalog_fleet_vigilant_notes, + "catalog_fleet_volkswagen_name" to R.string.catalog_fleet_volkswagen_name, + "catalog_fleet_volkswagen_notes" to R.string.catalog_fleet_volkswagen_notes, + "catalog_fleet_vuzix_attention" to R.string.catalog_fleet_vuzix_attention, + "catalog_fleet_vuzix_name" to R.string.catalog_fleet_vuzix_name, + "catalog_fleet_vuzix_notes" to R.string.catalog_fleet_vuzix_notes, + "catalog_fleet_watchguard_ap_name" to R.string.catalog_fleet_watchguard_ap_name, + "catalog_fleet_watchguard_ap_notes" to R.string.catalog_fleet_watchguard_ap_notes, + "catalog_fleet_watchguard_attention" to R.string.catalog_fleet_watchguard_attention, + "catalog_fleet_watchguard_name" to R.string.catalog_fleet_watchguard_name, + "catalog_fleet_watchguard_notes" to R.string.catalog_fleet_watchguard_notes, + "catalog_fleet_wavlink_name" to R.string.catalog_fleet_wavlink_name, + "catalog_fleet_wavlink_notes" to R.string.catalog_fleet_wavlink_notes, + "catalog_fleet_winegard_name" to R.string.catalog_fleet_winegard_name, + "catalog_fleet_winegard_notes" to R.string.catalog_fleet_winegard_notes, + "catalog_fleet_withings_name" to R.string.catalog_fleet_withings_name, + "catalog_fleet_withings_notes" to R.string.catalog_fleet_withings_notes, + "catalog_fleet_wolfcom_attention" to R.string.catalog_fleet_wolfcom_attention, + "catalog_fleet_wolfcom_name" to R.string.catalog_fleet_wolfcom_name, + "catalog_fleet_wolfcom_notes" to R.string.catalog_fleet_wolfcom_notes, + "catalog_fleet_wyze_name" to R.string.catalog_fleet_wyze_name, + "catalog_fleet_wyze_notes" to R.string.catalog_fleet_wyze_notes, + "catalog_fleet_xfinity_name" to R.string.catalog_fleet_xfinity_name, + "catalog_fleet_xfinity_notes" to R.string.catalog_fleet_xfinity_notes, + ) + + private fun group10(): Map = mapOf( + "catalog_fleet_yuneec_name" to R.string.catalog_fleet_yuneec_name, + "catalog_fleet_yuneec_notes" to R.string.catalog_fleet_yuneec_notes, + "catalog_fleet_zyxel_name" to R.string.catalog_fleet_zyxel_name, + "catalog_fleet_zyxel_notes" to R.string.catalog_fleet_zyxel_notes, + "cod_17_33_utilized" to R.string.cod_17_33_utilized, + "cod_1_17_utilized" to R.string.cod_1_17_utilized, + "cod_33_50_utilized" to R.string.cod_33_50_utilized, + "cod_50_67_utilized" to R.string.cod_50_67_utilized, + "cod_67_83_utilized" to R.string.cod_67_83_utilized, + "cod_83_99_utilized" to R.string.cod_83_99_utilized, + "cod_ankle_prosthesis" to R.string.cod_ankle_prosthesis, + "cod_audio" to R.string.cod_audio, + "cod_audio_video" to R.string.cod_audio_video, + "cod_blood_pressure_monitor" to R.string.cod_blood_pressure_monitor, + "cod_body_composition_analyzer" to R.string.cod_body_composition_analyzer, + "cod_camcorder" to R.string.cod_camcorder, + "cod_camera" to R.string.cod_camera, + "cod_capturing" to R.string.cod_capturing, + "cod_car_audio" to R.string.cod_car_audio, + "cod_card_reader" to R.string.cod_card_reader, + "cod_cellular" to R.string.cod_cellular, + "cod_common_isdn_access" to R.string.cod_common_isdn_access, + "cod_computer" to R.string.cod_computer, + "cod_controller" to R.string.cod_controller, + "cod_cordless" to R.string.cod_cordless, + "cod_desktop" to R.string.cod_desktop, + "cod_digital_pen" to R.string.cod_digital_pen, + "cod_digitizer_tablet" to R.string.cod_digitizer_tablet, + "cod_display" to R.string.cod_display, + "cod_doll_action_figure" to R.string.cod_doll_action_figure, + "cod_fully_available" to R.string.cod_fully_available, + "cod_game" to R.string.cod_game, + "cod_gamepad" to R.string.cod_gamepad, + "cod_gaming_toy" to R.string.cod_gaming_toy, + "cod_generic_health_manager" to R.string.cod_generic_health_manager, + "cod_glasses" to R.string.cod_glasses, + "cod_glucose_meter" to R.string.cod_glucose_meter, + "cod_handheld_gestural_input" to R.string.cod_handheld_gestural_input, + "cod_handheld_pc_pda" to R.string.cod_handheld_pc_pda, + "cod_handheld_scanner" to R.string.cod_handheld_scanner, + "cod_hands_free" to R.string.cod_hands_free, + "cod_headphones" to R.string.cod_headphones, + "cod_health" to R.string.cod_health, + "cod_health_data_display" to R.string.cod_health_data_display, + "cod_heart_pulse_rate_monitor" to R.string.cod_heart_pulse_rate_monitor, + "cod_helmet" to R.string.cod_helmet, + "cod_hifi_audio" to R.string.cod_hifi_audio, + "cod_imaging" to R.string.cod_imaging, + "cod_information" to R.string.cod_information, + "cod_jacket" to R.string.cod_jacket, + "cod_joystick" to R.string.cod_joystick, + "cod_keyboard" to R.string.cod_keyboard, + "cod_keyboard_capital" to R.string.cod_keyboard_capital, + "cod_keyboard_pointing" to R.string.cod_keyboard_pointing, + "cod_keyboard_pointing_capital" to R.string.cod_keyboard_pointing_capital, + "cod_knee_prosthesis" to R.string.cod_knee_prosthesis, + "cod_lan_network_ap" to R.string.cod_lan_network_ap, + "cod_laptop" to R.string.cod_laptop, + "cod_limited_discoverable" to R.string.cod_limited_discoverable, + "cod_loudspeaker" to R.string.cod_loudspeaker, + "cod_medication_monitor" to R.string.cod_medication_monitor, + "cod_microphone" to R.string.cod_microphone, + "cod_miscellaneous" to R.string.cod_miscellaneous, + "cod_networking" to R.string.cod_networking, + "cod_no_service_available" to R.string.cod_no_service_available, + "cod_object_transfer" to R.string.cod_object_transfer, + "cod_pager" to R.string.cod_pager, + "cod_palm_size_pda" to R.string.cod_palm_size_pda, + "cod_peak_flow_monitor" to R.string.cod_peak_flow_monitor, + "cod_peripheral" to R.string.cod_peripheral, + "cod_personal_mobility_device" to R.string.cod_personal_mobility_device, + "cod_phone" to R.string.cod_phone, + "cod_pointing" to R.string.cod_pointing, + "cod_pointing_capital" to R.string.cod_pointing_capital, + "cod_portable_audio" to R.string.cod_portable_audio, + "cod_positioning" to R.string.cod_positioning, + "cod_printer" to R.string.cod_printer, + "cod_pulse_oximeter" to R.string.cod_pulse_oximeter, + "cod_remote_control" to R.string.cod_remote_control, + "cod_rendering" to R.string.cod_rendering, + "cod_robot" to R.string.cod_robot, + "cod_scanner" to R.string.cod_scanner, + "cod_sensing_device" to R.string.cod_sensing_device, + "cod_server" to R.string.cod_server, + "cod_set_top_box" to R.string.cod_set_top_box, + "cod_smartphone" to R.string.cod_smartphone, + "cod_step_counter" to R.string.cod_step_counter, + "cod_tablet" to R.string.cod_tablet, + "cod_telephony" to R.string.cod_telephony, + "cod_thermometer" to R.string.cod_thermometer, + "cod_toy" to R.string.cod_toy, + "cod_uncategorized" to R.string.cod_uncategorized, + "cod_unknown_a_v" to R.string.cod_unknown_a_v, + "cod_unknown_computer" to R.string.cod_unknown_computer, + "cod_unknown_format" to R.string.cod_unknown_format, + "cod_unknown_health" to R.string.cod_unknown_health, + "cod_unknown_major" to R.string.cod_unknown_major, + "cod_unknown_peripheral" to R.string.cod_unknown_peripheral, + "cod_unknown_phone" to R.string.cod_unknown_phone, + "cod_unknown_toy" to R.string.cod_unknown_toy, + ) + + private fun group11(): Map = mapOf( + "cod_unknown_wearable" to R.string.cod_unknown_wearable, + "cod_vcr" to R.string.cod_vcr, + "cod_vehicle" to R.string.cod_vehicle, + "cod_video_camera" to R.string.cod_video_camera, + "cod_video_conferencing" to R.string.cod_video_conferencing, + "cod_video_display_and_loudspeaker" to R.string.cod_video_display_and_loudspeaker, + "cod_video_monitor" to R.string.cod_video_monitor, + "cod_wearable" to R.string.cod_wearable, + "cod_wearable_computer" to R.string.cod_wearable_computer, + "cod_wearable_headset" to R.string.cod_wearable_headset, + "cod_weighing_scale" to R.string.cod_weighing_scale, + "cod_wired_modem_voice_gateway" to R.string.cod_wired_modem_voice_gateway, + "cod_wristwatch" to R.string.cod_wristwatch, + "debrief_pdf_1_advertised_fix" to R.string.debrief_pdf_1_advertised_fix, + "debrief_pdf_advertised" to R.string.debrief_pdf_advertised, + "debrief_pdf_aircraft" to R.string.debrief_pdf_aircraft, + "debrief_pdf_disclaimer" to R.string.debrief_pdf_disclaimer, + "debrief_pdf_end" to R.string.debrief_pdf_end, + "debrief_pdf_extra_attention" to R.string.debrief_pdf_extra_attention, + "debrief_pdf_extra_attention_2" to R.string.debrief_pdf_extra_attention_2, + "debrief_pdf_km_path" to R.string.debrief_pdf_km_path, + "debrief_pdf_km_span" to R.string.debrief_pdf_km_span, + "debrief_pdf_m_path" to R.string.debrief_pdf_m_path, + "debrief_pdf_m_span" to R.string.debrief_pdf_m_span, + "debrief_pdf_mac_alert" to R.string.debrief_pdf_mac_alert, + "debrief_pdf_observer" to R.string.debrief_pdf_observer, + "debrief_pdf_off_grid_pete_llc_operationally_sensitive" to R.string.debrief_pdf_off_grid_pete_llc_operationally_sensitive, + "debrief_pdf_path_key" to R.string.debrief_pdf_path_key, + "debrief_pdf_pattern_match_not_identity_not_a_safety" to R.string.debrief_pdf_pattern_match_not_identity_not_a_safety, + "debrief_pdf_pilot" to R.string.debrief_pdf_pilot, + "debrief_pdf_radios_at_this_stop" to R.string.debrief_pdf_radios_at_this_stop, + "debrief_pdf_second_advertised" to R.string.debrief_pdf_second_advertised, + "debrief_pdf_second_sit" to R.string.debrief_pdf_second_sit, + "debrief_pdf_signature_alert" to R.string.debrief_pdf_signature_alert, + "debrief_pdf_start" to R.string.debrief_pdf_start, + "debrief_pdf_stay" to R.string.debrief_pdf_stay, + "debrief_pdf_takeaway" to R.string.debrief_pdf_takeaway, + "debrief_pdf_this_sit" to R.string.debrief_pdf_this_sit, + "debrief_prompt_15_min_wi_fi_ble_signed_hidden" to R.string.debrief_prompt_15_min_wi_fi_ble_signed_hidden, + "debrief_prompt_1_disclaimer_repeat_the_experimental_use_disclaimer" to R.string.debrief_prompt_1_disclaimer_repeat_the_experimental_use_disclaimer, + "debrief_prompt_2_what_the_onboard_debrief_already_established" to R.string.debrief_prompt_2_what_the_onboard_debrief_already_established, + "debrief_prompt_3_what_the_numbers_add_5_vs" to R.string.debrief_prompt_3_what_the_numbers_add_5_vs, + "debrief_prompt_4_extra_attention_and_tracking_callouts_full" to R.string.debrief_prompt_4_extra_attention_and_tracking_callouts_full, + "debrief_prompt_5_min_wi_fi_ble" to R.string.debrief_prompt_5_min_wi_fi_ble, + "debrief_prompt_5_what_another_sit_or_hunt_would" to R.string.debrief_prompt_5_what_another_sit_or_hunt_would, + "debrief_prompt_a_decoded_live_value_on_a_tracking" to R.string.debrief_prompt_a_decoded_live_value_on_a_tracking, + "debrief_prompt_a_flood_note_is_a_burst_of" to R.string.debrief_prompt_a_flood_note_is_a_burst_of, + "debrief_prompt_a_radio_marked_mine_was_claimed_by" to R.string.debrief_prompt_a_radio_marked_mine_was_claimed_by, + "debrief_prompt_an_aircraft_block_and_an_amber_track" to R.string.debrief_prompt_an_aircraft_block_and_an_amber_track, + "debrief_prompt_collection_context" to R.string.debrief_prompt_collection_context, + "debrief_prompt_constraints_you_must_respect" to R.string.debrief_prompt_constraints_you_must_respect, + "debrief_prompt_do_not_claim_a_tracker_is_following" to R.string.debrief_prompt_do_not_claim_a_tracker_is_following, + "debrief_prompt_do_not_give_safety_advice_do_not" to R.string.debrief_prompt_do_not_give_safety_advice_do_not, + "debrief_prompt_end_of_working_data" to R.string.debrief_prompt_end_of_working_data, + "debrief_prompt_extra_attention" to R.string.debrief_prompt_extra_attention, + "debrief_prompt_finder_tag_like_radios_for_stress_test" to R.string.debrief_prompt_finder_tag_like_radios_for_stress_test, + "debrief_prompt_first_seen_min_persistent_gone_quiet" to R.string.debrief_prompt_first_seen_min_persistent_gone_quiet, + "debrief_prompt_fixes_length_m_span_m" to R.string.debrief_prompt_fixes_length_m_span_m, + "debrief_prompt_gps_path_tagging" to R.string.debrief_prompt_gps_path_tagging, + "debrief_prompt_gps_stamps_if_present_are_this_phone" to R.string.debrief_prompt_gps_stamps_if_present_are_this_phone, + "debrief_prompt_hear_only_wi_fi_rows_are_access" to R.string.debrief_prompt_hear_only_wi_fi_rows_are_access, + "debrief_prompt_live_ram_cap_is_about_400_radios" to R.string.debrief_prompt_live_ram_cap_is_about_400_radios, + "debrief_prompt_location_tags_online_place_names" to R.string.debrief_prompt_location_tags_online_place_names, + "debrief_prompt_marked_mine" to R.string.debrief_prompt_marked_mine, + "debrief_prompt_n_n_truncated_for_share_sheet_size" to R.string.debrief_prompt_n_n_truncated_for_share_sheet_size, + "debrief_prompt_none" to R.string.debrief_prompt_none, + "debrief_prompt_observer" to R.string.debrief_prompt_observer, + "debrief_prompt_observer_notes" to R.string.debrief_prompt_observer_notes, + "debrief_prompt_off" to R.string.debrief_prompt_off, + "debrief_prompt_on" to R.string.debrief_prompt_on, + "debrief_prompt_onboard_debrief_verbatim_already_shown_to_the" to R.string.debrief_prompt_onboard_debrief_verbatim_already_shown_to_the, + "debrief_prompt_place_names_if_present_are_system_reverse" to R.string.debrief_prompt_place_names_if_present_are_system_reverse, + "debrief_prompt_places" to R.string.debrief_prompt_places, + "debrief_prompt_rand_ble" to R.string.debrief_prompt_rand_ble, + "debrief_prompt_rssi_is_loudness_at_the_phone_not" to R.string.debrief_prompt_rssi_is_loudness_at_the_phone_not, + "debrief_prompt_scan_intensity_stale_after_s" to R.string.debrief_prompt_scan_intensity_stale_after_s, + "debrief_prompt_signature_families_count" to R.string.debrief_prompt_signature_families_count, + "debrief_prompt_signature_families_none" to R.string.debrief_prompt_signature_families_none, + "debrief_prompt_signature_oui_company_matches_are_hypotheses_not" to R.string.debrief_prompt_signature_oui_company_matches_are_hypotheses_not, + "debrief_prompt_signed_first_seen" to R.string.debrief_prompt_signed_first_seen, + "debrief_prompt_takeaway_required_last_line_one_sentence_starting" to R.string.debrief_prompt_takeaway_required_last_line_one_sentence_starting, + "debrief_prompt_the_onboard_debrief_verbatim_below_already_tabulated" to R.string.debrief_prompt_the_onboard_debrief_verbatim_below_already_tabulated, + "debrief_prompt_tool_fieldwatch_app_fieldwatch_receive_only_no" to R.string.debrief_prompt_tool_fieldwatch_app_fieldwatch_receive_only_no, + "debrief_prompt_treat_this_paste_as_operationally_sensitive" to R.string.debrief_prompt_treat_this_paste_as_operationally_sensitive, + "debrief_prompt_window_last_15_minutes_utc_with_a" to R.string.debrief_prompt_window_last_15_minutes_utc_with_a, + "debrief_prompt_window_sit_utc_with_a_5_minute" to R.string.debrief_prompt_window_sit_utc_with_a_5_minute, + "debrief_prompt_working_data_for_the_addendum_do_not" to R.string.debrief_prompt_working_data_for_the_addendum_do_not, + "debrief_prompt_write_complete_sentences_headings_as_below_short" to R.string.debrief_prompt_write_complete_sentences_headings_as_below_short, + "debrief_prompt_write_the_addendum_now_following_your_output" to R.string.debrief_prompt_write_the_addendum_now_following_your_output, + "debrief_prompt_you_are_a_field_rf_analyst_for" to R.string.debrief_prompt_you_are_a_field_rf_analyst_for, + "debrief_prompt_your_output_required_this_is_the_addendum" to R.string.debrief_prompt_your_output_required_this_is_the_addendum, + "debrief_report_a_passive_observer_with_the_same_radios" to R.string.debrief_report_a_passive_observer_with_the_same_radios, + "debrief_report_a_radio_in_two_classes_counts_in" to R.string.debrief_report_a_radio_in_two_classes_counts_in, + "debrief_report_account_for_a_test_tag_or_badge" to R.string.debrief_report_account_for_a_test_tag_or_badge, + "debrief_report_account_for_every_mac_fieldwatch_cannot_tell" to R.string.debrief_report_account_for_every_mac_fieldwatch_cannot_tell, + "debrief_report_advertised_position" to R.string.debrief_report_advertised_position, + "debrief_report_aircraft" to R.string.debrief_report_aircraft, + "debrief_report_and_ble_advertisers_randomized" to R.string.debrief_report_and_ble_advertisers_randomized, + "debrief_report_and_more" to R.string.debrief_report_and_more, + "debrief_report_anomalies" to R.string.debrief_report_anomalies, + ) + + private fun group12(): Map = mapOf( + "debrief_report_appeared_after_the_sit_started_and_gps" to R.string.debrief_report_appeared_after_the_sit_started_and_gps, + "debrief_report_appeared_after_the_sit_started_but_last" to R.string.debrief_report_appeared_after_the_sit_started_but_last, + "debrief_report_appeared_after_the_sit_started_but_was" to R.string.debrief_report_appeared_after_the_sit_started_but_was, + "debrief_report_appeared_after_the_sit_started_then_stayed" to R.string.debrief_report_appeared_after_the_sit_started_then_stayed, + "debrief_report_aps_ble_persistent_aps_traveled_span" to R.string.debrief_report_aps_ble_persistent_aps_traveled_span, + "debrief_report_bluetooth_le" to R.string.debrief_report_bluetooth_le, + "debrief_report_callouts_below_are_only_radios_that_stayed" to R.string.debrief_report_callouts_below_are_only_radios_that_stayed, + "debrief_report_ch" to R.string.debrief_report_ch, + "debrief_report_ch_2" to R.string.debrief_report_ch_2, + "debrief_report_classification" to R.string.debrief_report_classification, + "debrief_report_co_travel_is_split_by_class_finder" to R.string.debrief_report_co_travel_is_split_by_class_finder, + "debrief_report_dbm_ch" to R.string.debrief_report_dbm_ch, + "debrief_report_decoded" to R.string.debrief_report_decoded, + "debrief_report_dense_public_retail_street_many_aps_and" to R.string.debrief_report_dense_public_retail_street_many_aps_and, + "debrief_report_disclaimer" to R.string.debrief_report_disclaimer, + "debrief_report_distance" to R.string.debrief_report_distance, + "debrief_report_do_not_share_this_file_off_device" to R.string.debrief_report_do_not_share_this_file_off_device, + "debrief_report_dwell" to R.string.debrief_report_dwell, + "debrief_report_dwell_2" to R.string.debrief_report_dwell_2, + "debrief_report_enable_tag_detections_with_gps_and_walk" to R.string.debrief_report_enable_tag_detections_with_gps_and_walk, + "debrief_report_environment" to R.string.debrief_report_environment, + "debrief_report_executive_summary" to R.string.debrief_report_executive_summary, + "debrief_report_extra_attention" to R.string.debrief_report_extra_attention, + "debrief_report_extra_attention_2" to R.string.debrief_report_extra_attention_2, + "debrief_report_extra_attention_3" to R.string.debrief_report_extra_attention_3, + "debrief_report_field_debrief" to R.string.debrief_report_field_debrief, + "debrief_report_field_debrief_2" to R.string.debrief_report_field_debrief_2, + "debrief_report_fieldwatch_app_fieldwatch_stock_android_receive_only" to R.string.debrief_report_fieldwatch_app_fieldwatch_stock_android_receive_only, + "debrief_report_fieldwatch_field_debrief" to R.string.debrief_report_fieldwatch_field_debrief, + "debrief_report_fieldwatch_heard_wi_fi_access_points_and" to R.string.debrief_report_fieldwatch_heard_wi_fi_access_points_and, + "debrief_report_fieldwatch_sit" to R.string.debrief_report_fieldwatch_sit, + "debrief_report_find_my_iphone_addresses_rotate_this_mac" to R.string.debrief_report_find_my_iphone_addresses_rotate_this_mac, + "debrief_report_finder_tag_s_with_you_the_whole" to R.string.debrief_report_finder_tag_s_with_you_the_whole, + "debrief_report_finder_tags_airtag_find_my_smarttag_tile" to R.string.debrief_report_finder_tags_airtag_find_my_smarttag_tile, + "debrief_report_finder_tags_that_were_not_heard_when" to R.string.debrief_report_finder_tags_that_were_not_heard_when, + "debrief_report_finder_tags_with_you_yours_or_planted" to R.string.debrief_report_finder_tags_with_you_yours_or_planted, + "debrief_report_first_seen_in_this_window" to R.string.debrief_report_first_seen_in_this_window, + "debrief_report_flood" to R.string.debrief_report_flood, + "debrief_report_following_test_insufficient_movement_need_45_m" to R.string.debrief_report_following_test_insufficient_movement_need_45_m, + "debrief_report_garmin_fitbit_oura_radios_that_stayed_with" to R.string.debrief_report_garmin_fitbit_oura_radios_that_stayed_with, + "debrief_report_generated" to R.string.debrief_report_generated, + "debrief_report_google_fast_pair_in_pairing_mode" to R.string.debrief_report_google_fast_pair_in_pairing_mode, + "debrief_report_google_fast_pair_in_pairing_mode_radios" to R.string.debrief_report_google_fast_pair_in_pairing_mode_radios, + "debrief_report_gps_co_travel" to R.string.debrief_report_gps_co_travel, + "debrief_report_gps_displacement_was_only_m_too_short" to R.string.debrief_report_gps_displacement_was_only_m_too_short, + "debrief_report_gps_samples_span_m_along_your_path" to R.string.debrief_report_gps_samples_span_m_along_your_path, + "debrief_report_gps_tag" to R.string.debrief_report_gps_tag, + "debrief_report_gps_tagging_is_off_fieldwatch_cannot_test" to R.string.debrief_report_gps_tagging_is_off_fieldwatch_cannot_test, + "debrief_report_gps_tagging_is_off_so_a_following" to R.string.debrief_report_gps_tagging_is_off_so_a_following, + "debrief_report_gps_tagging_is_off_turn_on_settings" to R.string.debrief_report_gps_tagging_is_off_turn_on_settings, + "debrief_report_gps_tagging_is_on_but_this_window" to R.string.debrief_report_gps_tagging_is_on_but_this_window, + "debrief_report_gps_tagging_off_no_path" to R.string.debrief_report_gps_tagging_off_no_path, + "debrief_report_gps_tagging_on_fewer_than_2_fixes" to R.string.debrief_report_gps_tagging_on_fewer_than_2_fixes, + "debrief_report_heard_advertiser_s_with_randomized_addresses_signature" to R.string.debrief_report_heard_advertiser_s_with_randomized_addresses_signature, + "debrief_report_heard_along_m_gps_stamps_but_did" to R.string.debrief_report_heard_along_m_gps_stamps_but_did, + "debrief_report_heard_along_m_of_your_m_path" to R.string.debrief_report_heard_along_m_of_your_m_path, + "debrief_report_heard_ap_s_hidden_ssid_sat_most" to R.string.debrief_report_heard_ap_s_hidden_ssid_sat_most, + "debrief_report_heard_but_not_at_two_gps_points" to R.string.debrief_report_heard_but_not_at_two_gps_points, + "debrief_report_heard_here_ap_s_ble" to R.string.debrief_report_heard_here_ap_s_ble, + "debrief_report_heard_near_one_place_m_span_while" to R.string.debrief_report_heard_near_one_place_m_span_while, + "debrief_report_hidden" to R.string.debrief_report_hidden, + "debrief_report_hidden_ssids" to R.string.debrief_report_hidden_ssids, + "debrief_report_hidden_ssids_randomized_ble" to R.string.debrief_report_hidden_ssids_randomized_ble, + "debrief_report_hidden_ssids_still_beacon_and_identify_the" to R.string.debrief_report_hidden_ssids_still_beacon_and_identify_the, + "debrief_report_high_randomized_ble_typical_of_phones_not" to R.string.debrief_report_high_randomized_ble_typical_of_phones_not, + "debrief_report_ibeacon_minew_estimote_kontakt_io_target_atrius" to R.string.debrief_report_ibeacon_minew_estimote_kontakt_io_target_atrius, + "debrief_report_in_motion_walk_vehicle_through_mixed_rf" to R.string.debrief_report_in_motion_walk_vehicle_through_mixed_rf, + "debrief_report_in_sit" to R.string.debrief_report_in_sit, + "debrief_report_in_the_last_15_minutes" to R.string.debrief_report_in_the_last_15_minutes, + "debrief_report_insufficient_movement_to_distinguish_a_radio_that" to R.string.debrief_report_insufficient_movement_to_distinguish_a_radio_that, + "debrief_report_last_15_minutes" to R.string.debrief_report_last_15_minutes, + "debrief_report_last_15_minutes_utc" to R.string.debrief_report_last_15_minutes_utc, + "debrief_report_likely_a_building_with_standing_infrastructure_aps" to R.string.debrief_report_likely_a_building_with_standing_infrastructure_aps, + "debrief_report_likely_a_dwelling_or_small_office_few" to R.string.debrief_report_likely_a_dwelling_or_small_office_few, + "debrief_report_location_beacons_do_not_typically_move_with" to R.string.debrief_report_location_beacons_do_not_typically_move_with, + "debrief_report_loudest_aps" to R.string.debrief_report_loudest_aps, + "debrief_report_loudest_first_seen" to R.string.debrief_report_loudest_first_seen, + "debrief_report_mac_tails_and_gps_coordinates_masked_logs" to R.string.debrief_report_mac_tails_and_gps_coordinates_masked_logs, + "debrief_report_marked_mine" to R.string.debrief_report_marked_mine, + "debrief_report_marked_mine_2" to R.string.debrief_report_marked_mine_2, + "debrief_report_marked_mine_3" to R.string.debrief_report_marked_mine_3, + "debrief_report_marked_mine_4" to R.string.debrief_report_marked_mine_4, + "debrief_report_marked_mine_5" to R.string.debrief_report_marked_mine_5, + "debrief_report_marked_mine_first_heard_after_the_sit" to R.string.debrief_report_marked_mine_first_heard_after_the_sit, + "debrief_report_marked_mine_with_you_the_whole_sit" to R.string.debrief_report_marked_mine_with_you_the_whole_sit, + "debrief_report_mixed_or_under_sampled_environment" to R.string.debrief_report_mixed_or_under_sampled_environment, + "debrief_report_moved_m_with_you_already_on_the" to R.string.debrief_report_moved_m_with_you_already_on_the, + "debrief_report_named_or_marked" to R.string.debrief_report_named_or_marked, + "debrief_report_networks_wi_fi_access_points" to R.string.debrief_report_networks_wi_fi_access_points, + "debrief_report_no_extra_flags_signature_hits_extra_attention" to R.string.debrief_report_no_extra_flags_signature_hits_extra_attention, + "debrief_report_no_finder_tag_clearly_stayed_with_the" to R.string.debrief_report_no_finder_tag_clearly_stayed_with_the, + "debrief_report_no_finder_tag_retail_beacon_or_wearable" to R.string.debrief_report_no_finder_tag_retail_beacon_or_wearable, + "debrief_report_no_finder_tag_retail_beacon_or_wearable_2" to R.string.debrief_report_no_finder_tag_retail_beacon_or_wearable_2, + "debrief_report_no_finder_tag_stayed_with_the_path" to R.string.debrief_report_no_finder_tag_stayed_with_the_path, + "debrief_report_no_gps_stamped_radios_tied_to_this" to R.string.debrief_report_no_gps_stamped_radios_tied_to_this, + "debrief_report_no_path_legs" to R.string.debrief_report_no_path_legs, + "debrief_report_no_signature_hits_and_no_gps_co" to R.string.debrief_report_no_signature_hits_and_no_gps_co, + "debrief_report_none" to R.string.debrief_report_none, + "debrief_report_none_in_this_window" to R.string.debrief_report_none_in_this_window, + "debrief_report_none_stayed_with_the_path" to R.string.debrief_report_none_stayed_with_the_path, + ) + + private fun group13(): Map = mapOf( + "debrief_report_north_up_line_is_this_phone_a" to R.string.debrief_report_north_up_line_is_this_phone_a, + "debrief_report_not_enough_gps_movement_m_to_test" to R.string.debrief_report_not_enough_gps_movement_m_to_test, + "debrief_report_notable_ble" to R.string.debrief_report_notable_ble, + "debrief_report_observer_notes" to R.string.debrief_report_observer_notes, + "debrief_report_off" to R.string.debrief_report_off, + "debrief_report_off_settings_online_place_names_in_debrief" to R.string.debrief_report_off_settings_online_place_names_in_debrief, + "debrief_report_on" to R.string.debrief_report_on, + "debrief_report_one_stay_you_did_not_move_far" to R.string.debrief_report_one_stay_you_did_not_move_far, + "debrief_report_online_place_names" to R.string.debrief_report_online_place_names, + "debrief_report_only_radios_that_stayed_with_the_operator" to R.string.debrief_report_only_radios_that_stayed_with_the_operator, + "debrief_report_operationally_sensitive_neighbor_ssids_macs_operator_gps" to R.string.debrief_report_operationally_sensitive_neighbor_ssids_macs_operator_gps, + "debrief_report_operationally_sensitive_neighbor_ssids_macs_operator_gps_2" to R.string.debrief_report_operationally_sensitive_neighbor_ssids_macs_operator_gps_2, + "debrief_report_operator_gps_path_too_short_m_to" to R.string.debrief_report_operator_gps_path_too_short_m_to, + "debrief_report_operator_path" to R.string.debrief_report_operator_path, + "debrief_report_operator_phone" to R.string.debrief_report_operator_phone, + "debrief_report_operator_phone_2" to R.string.debrief_report_operator_phone_2, + "debrief_report_operator_phone_no_street_name_this_export" to R.string.debrief_report_operator_phone_no_street_name_this_export, + "debrief_report_overall_along_track_span_fixes" to R.string.debrief_report_overall_along_track_span_fixes, + "debrief_report_overall_distance_traveled_along_the_gps_path" to R.string.debrief_report_overall_distance_traveled_along_the_gps_path, + "debrief_report_overall_distance_traveled_along_the_gps_path_2" to R.string.debrief_report_overall_distance_traveled_along_the_gps_path_2, + "debrief_report_pattern_match_not_identity_not_a_skimmer" to R.string.debrief_report_pattern_match_not_identity_not_a_skimmer, + "debrief_report_persistence" to R.string.debrief_report_persistence, + "debrief_report_phone_gps_at_hear_time_not_the" to R.string.debrief_report_phone_gps_at_hear_time_not_the, + "debrief_report_places" to R.string.debrief_report_places, + "debrief_report_possible_tail" to R.string.debrief_report_possible_tail, + "debrief_report_possible_tail_appeared_after_sit_started_also" to R.string.debrief_report_possible_tail_appeared_after_sit_started_also, + "debrief_report_possible_tail_appeared_after_this_sit_started" to R.string.debrief_report_possible_tail_appeared_after_this_sit_started, + "debrief_report_possible_tail_appeared_after_this_sit_started_2" to R.string.debrief_report_possible_tail_appeared_after_this_sit_started_2, + "debrief_report_possible_tail_finder_tags_first_heard_after" to R.string.debrief_report_possible_tail_finder_tags_first_heard_after, + "debrief_report_possible_tail_s_first_heard_after_this" to R.string.debrief_report_possible_tail_s_first_heard_after_this, + "debrief_report_possible_trackers_with_you" to R.string.debrief_report_possible_trackers_with_you, + "debrief_report_possible_trackers_with_you_could_be_yours" to R.string.debrief_report_possible_trackers_with_you_could_be_yours, + "debrief_report_possible_trackers_with_you_finder_tags_whole" to R.string.debrief_report_possible_trackers_with_you_finder_tags_whole, + "debrief_report_privacy" to R.string.debrief_report_privacy, + "debrief_report_radios" to R.string.debrief_report_radios, + "debrief_report_radios_you_marked_mine_heard_in_this" to R.string.debrief_report_radios_you_marked_mine_heard_in_this, + "debrief_report_rand" to R.string.debrief_report_rand, + "debrief_report_recommended_actions" to R.string.debrief_report_recommended_actions, + "debrief_report_retail_beacon_s_also_stayed_with_the" to R.string.debrief_report_retail_beacon_s_also_stayed_with_the, + "debrief_report_retail_beacon_s_also_with_the_path" to R.string.debrief_report_retail_beacon_s_also_with_the_path, + "debrief_report_retail_beacons_with_you" to R.string.debrief_report_retail_beacons_with_you, + "debrief_report_retail_beacons_with_you_ibeacon_minew_estimote" to R.string.debrief_report_retail_beacons_with_you_ibeacon_minew_estimote, + "debrief_report_retail_beacons_with_you_unusual_fixtures_do" to R.string.debrief_report_retail_beacons_with_you_unusual_fixtures_do, + "debrief_report_rssi_dbm_min_max" to R.string.debrief_report_rssi_dbm_min_max, + "debrief_report_rssi_dbm_min_max_trail_fixes_span" to R.string.debrief_report_rssi_dbm_min_max_trail_fixes_span, + "debrief_report_sat_most_of_this_window" to R.string.debrief_report_sat_most_of_this_window, + "debrief_report_scan" to R.string.debrief_report_scan, + "debrief_report_signature_hits" to R.string.debrief_report_signature_hits, + "debrief_report_signature_matched" to R.string.debrief_report_signature_matched, + "debrief_report_sit" to R.string.debrief_report_sit, + "debrief_report_sit_2" to R.string.debrief_report_sit_2, + "debrief_report_sit_3" to R.string.debrief_report_sit_3, + "debrief_report_sit_utc" to R.string.debrief_report_sit_utc, + "debrief_report_stale_s_brief_hold_s" to R.string.debrief_report_stale_s_brief_hold_s, + "debrief_report_station_side_wi_fi_probes_clients_still" to R.string.debrief_report_station_side_wi_fi_probes_clients_still, + "debrief_report_stay_utc" to R.string.debrief_report_stay_utc, + "debrief_report_stayed_loud_with_you_the_whole_sit" to R.string.debrief_report_stayed_loud_with_you_the_whole_sit, + "debrief_report_stops" to R.string.debrief_report_stops, + "debrief_report_stops_area" to R.string.debrief_report_stops_area, + "debrief_report_street_names_are_approximate_do_not_treat" to R.string.debrief_report_street_names_are_approximate_do_not_treat, + "debrief_report_street_names_came_from_the_phone_s" to R.string.debrief_report_street_names_came_from_the_phone_s, + "debrief_report_strongest_dbm" to R.string.debrief_report_strongest_dbm, + "debrief_report_tag_detections_with_gps" to R.string.debrief_report_tag_detections_with_gps, + "debrief_report_takeaway" to R.string.debrief_report_takeaway, + "debrief_report_this_debrief_includes_advertised_aircraft_positions_from" to R.string.debrief_report_this_debrief_includes_advertised_aircraft_positions_from, + "debrief_report_this_debrief_includes_operator_gps_samples_used" to R.string.debrief_report_this_debrief_includes_operator_gps_samples_used, + "debrief_report_tool" to R.string.debrief_report_tool, + "debrief_report_tracking_assessment" to R.string.debrief_report_tracking_assessment, + "debrief_report_tracking_note" to R.string.debrief_report_tracking_note, + "debrief_report_transit_utc_along_track" to R.string.debrief_report_transit_utc_along_track, + "debrief_report_traveled_along_path_span_fixes" to R.string.debrief_report_traveled_along_path_span_fixes, + "debrief_report_treat_as_a_possible_tail_until_you" to R.string.debrief_report_treat_as_a_possible_tail_until_you, + "debrief_report_turn_on_gps_tagging_and_walk_before" to R.string.debrief_report_turn_on_gps_tagging_and_walk_before, + "debrief_report_turn_on_settings_tag_detections_with_gps" to R.string.debrief_report_turn_on_settings_tag_detections_with_gps, + "debrief_report_typical_of_a_watch_or_ring_you" to R.string.debrief_report_typical_of_a_watch_or_ring_you, + "debrief_report_typical_of_a_watch_that_joined_the" to R.string.debrief_report_typical_of_a_watch_that_joined_the, + "debrief_report_unknown" to R.string.debrief_report_unknown, + "debrief_report_unmatched" to R.string.debrief_report_unmatched, + "debrief_report_unmatched_rotating_ble_omitted_from_lists_counts" to R.string.debrief_report_unmatched_rotating_ble_omitted_from_lists_counts, + "debrief_report_unusual_for_a_retail_location_beacon_they" to R.string.debrief_report_unusual_for_a_retail_location_beacon_they, + "debrief_report_use_live_pause_to_inspect_a_busy" to R.string.debrief_report_use_live_pause_to_inspect_a_busy, + "debrief_report_very_strong_unnamed_radios_50_dbm" to R.string.debrief_report_very_strong_unnamed_radios_50_dbm, + "debrief_report_walk_farther_50_m_with_gps_tagging" to R.string.debrief_report_walk_farther_50_m_with_gps_tagging, + "debrief_report_wearable_s_stayed_with_the_path_usually" to R.string.debrief_report_wearable_s_stayed_with_the_path_usually, + "debrief_report_wearable_s_with_the_path_usually_own" to R.string.debrief_report_wearable_s_with_the_path_usually_own, + "debrief_report_wearables_with_you" to R.string.debrief_report_wearables_with_you, + "debrief_report_wearables_with_you_garmin_fitbit_oura_usually" to R.string.debrief_report_wearables_with_you_garmin_fitbit_oura_usually, + "debrief_report_wearables_with_you_usually_own_kit_or" to R.string.debrief_report_wearables_with_you_usually_own_kit_or, + "debrief_report_where_you_were" to R.string.debrief_report_where_you_were, + "debrief_report_where_you_were_operator_gps" to R.string.debrief_report_where_you_were_operator_gps, + "debrief_report_window" to R.string.debrief_report_window, + "debrief_report_with_you_the_whole_sit_yours_or" to R.string.debrief_report_with_you_the_whole_sit_yours_or, + "debrief_report_your_captions_on_radios_heard_in_this" to R.string.debrief_report_your_captions_on_radios_heard_in_this, + "decode_boolean_no" to R.string.decode_boolean_no, + "decode_boolean_yes" to R.string.decode_boolean_yes, + "device_detail_prompt_1_what_it_likely_is_product_class" to R.string.device_detail_prompt_1_what_it_likely_is_product_class, + "device_detail_prompt_2_registry_format_decode_ieee_oui_or" to R.string.device_detail_prompt_2_registry_format_decode_ieee_oui_or, + "device_detail_prompt_3_what_that_product_typically_does_phone" to R.string.device_detail_prompt_3_what_that_product_typically_does_phone, + "device_detail_prompt_4_what_fieldwatch_actually_saw_vs_what" to R.string.device_detail_prompt_4_what_fieldwatch_actually_saw_vs_what, + "device_detail_prompt_5_signal_and_presence_loud_quiet_here" to R.string.device_detail_prompt_5_signal_and_presence_loud_quiet_here, + ) + + private fun group14(): Map = mapOf( + "device_detail_prompt_6_signature_match_if_fieldwatch_matched_a" to R.string.device_detail_prompt_6_signature_match_if_fieldwatch_matched_a, + "device_detail_prompt_7_open_questions_what_extra_observation_another" to R.string.device_detail_prompt_7_open_questions_what_extra_observation_another, + "device_detail_prompt_8_must_not_conclude_one_short_list" to R.string.device_detail_prompt_8_must_not_conclude_one_short_list, + "device_detail_prompt_ble_rows_are_advertisers_randomized_macs_are" to R.string.device_detail_prompt_ble_rows_are_advertisers_randomized_macs_are, + "device_detail_prompt_bluetooth_le_advertiser" to R.string.device_detail_prompt_bluetooth_le_advertiser, + "device_detail_prompt_collection_context" to R.string.device_detail_prompt_collection_context, + "device_detail_prompt_constraints_you_must_respect" to R.string.device_detail_prompt_constraints_you_must_respect, + "device_detail_prompt_do_not_claim_this_radio_is_following" to R.string.device_detail_prompt_do_not_claim_this_radio_is_following, + "device_detail_prompt_do_not_invent_fields_that_are_not" to R.string.device_detail_prompt_do_not_invent_fields_that_are_not, + "device_detail_prompt_end_with_a_single_one_line_takeaway" to R.string.device_detail_prompt_end_with_a_single_one_line_takeaway, + "device_detail_prompt_gps_stamps_if_present_are_the_operator" to R.string.device_detail_prompt_gps_stamps_if_present_are_the_operator, + "device_detail_prompt_hits_this_session_gone" to R.string.device_detail_prompt_hits_this_session_gone, + "device_detail_prompt_location_tags" to R.string.device_detail_prompt_location_tags, + "device_detail_prompt_observation_dump_verbatim_from_the_detail_page" to R.string.device_detail_prompt_observation_dump_verbatim_from_the_detail_page, + "device_detail_prompt_off" to R.string.device_detail_prompt_off, + "device_detail_prompt_on" to R.string.device_detail_prompt_on, + "device_detail_prompt_online_place_names" to R.string.device_detail_prompt_online_place_names, + "device_detail_prompt_operator_gps_trail_samples_on_this_radio" to R.string.device_detail_prompt_operator_gps_trail_samples_on_this_radio, + "device_detail_prompt_place_names_if_present_are_system_reverse" to R.string.device_detail_prompt_place_names_if_present_are_system_reverse, + "device_detail_prompt_places_operator_gps_optional" to R.string.device_detail_prompt_places_operator_gps_optional, + "device_detail_prompt_randomized_mac_flag" to R.string.device_detail_prompt_randomized_mac_flag, + "device_detail_prompt_rssi_is_loudness_at_the_phone_not" to R.string.device_detail_prompt_rssi_is_loudness_at_the_phone_not, + "device_detail_prompt_scan_intensity_stale_after_s_brief_hold" to R.string.device_detail_prompt_scan_intensity_stale_after_s_brief_hold, + "device_detail_prompt_signature_oui_company_uuid_matches_are_hypotheses" to R.string.device_detail_prompt_signature_oui_company_uuid_matches_are_hypotheses, + "device_detail_prompt_subject_titled" to R.string.device_detail_prompt_subject_titled, + "device_detail_prompt_the_user_wants_as_much_information_as" to R.string.device_detail_prompt_the_user_wants_as_much_information_as, + "device_detail_prompt_this_is_one_advertised_radio_not_a" to R.string.device_detail_prompt_this_is_one_advertised_radio_not_a, + "device_detail_prompt_tool_fieldwatch_app_fieldwatch_receive_only_no" to R.string.device_detail_prompt_tool_fieldwatch_app_fieldwatch_receive_only_no, + "device_detail_prompt_treat_this_paste_as_operationally_sensitive_mac" to R.string.device_detail_prompt_treat_this_paste_as_operationally_sensitive_mac, + "device_detail_prompt_wi_fi_access_point" to R.string.device_detail_prompt_wi_fi_access_point, + "device_detail_prompt_wi_fi_rows_are_access_points_only" to R.string.device_detail_prompt_wi_fi_rows_are_access_points_only, + "device_detail_prompt_you_are_a_field_rf_privacy_analyst" to R.string.device_detail_prompt_you_are_a_field_rf_privacy_analyst, + "device_detail_prompt_your_analysis_required_sections" to R.string.device_detail_prompt_your_analysis_required_sections, + "device_detail_text_0f_ms_between_bursts_smaller_chattier_on" to R.string.device_detail_text_0f_ms_between_bursts_smaller_chattier_on, + "device_detail_text_address" to R.string.device_detail_text_address, + "device_detail_text_advertised_name" to R.string.device_detail_text_advertised_name, + "device_detail_text_aircraft" to R.string.device_detail_text_aircraft, + "device_detail_text_also_offers" to R.string.device_detail_text_also_offers, + "device_detail_text_appearance_code" to R.string.device_detail_text_appearance_code, + "device_detail_text_bluetooth_advertisement" to R.string.device_detail_text_bluetooth_advertisement, + "device_detail_text_bluetooth_company_0x_04x" to R.string.device_detail_text_bluetooth_company_0x_04x, + "device_detail_text_bluetooth_company_in_the_ad_0x_04x" to R.string.device_detail_text_bluetooth_company_in_the_ad_0x_04x, + "device_detail_text_bluetooth_low_energy_advertiser" to R.string.device_detail_text_bluetooth_low_energy_advertiser, + "device_detail_text_capability_string" to R.string.device_detail_text_capability_string, + "device_detail_text_center_frequencies" to R.string.device_detail_text_center_frequencies, + "device_detail_text_channel" to R.string.device_detail_text_channel, + "device_detail_text_channel_frequency" to R.string.device_detail_text_channel_frequency, + "device_detail_text_claimed_transmit_power" to R.string.device_detail_text_claimed_transmit_power, + "device_detail_text_classic_bluetooth_class" to R.string.device_detail_text_classic_bluetooth_class, + "device_detail_text_closer_to_0_dbm_is_louder_here" to R.string.device_detail_text_closer_to_0_dbm_is_louder_here, + "device_detail_text_connectable" to R.string.device_detail_text_connectable, + "device_detail_text_dbm_how_loud_it_says_it_transmits" to R.string.device_detail_text_dbm_how_loud_it_says_it_transmits, + "device_detail_text_decoded_fields" to R.string.device_detail_text_decoded_fields, + "device_detail_text_discoverability" to R.string.device_detail_text_discoverability, + "device_detail_text_empty" to R.string.device_detail_text_empty, + "device_detail_text_encryption_login" to R.string.device_detail_text_encryption_login, + "device_detail_text_experimental_not_a_legal_identity_stock_android" to R.string.device_detail_text_experimental_not_a_legal_identity_stock_android, + "device_detail_text_extra_ap_information_element_not_the_ssid" to R.string.device_detail_text_extra_ap_information_element_not_the_ssid, + "device_detail_text_extra_attention" to R.string.device_detail_text_extra_attention, + "device_detail_text_extra_attention_2" to R.string.device_detail_text_extra_attention_2, + "device_detail_text_fieldwatch_device_detail_n" to R.string.device_detail_text_fieldwatch_device_detail_n, + "device_detail_text_first_seen" to R.string.device_detail_text_first_seen, + "device_detail_text_flags_raw" to R.string.device_detail_text_flags_raw, + "device_detail_text_heard_range_this_session" to R.string.device_detail_text_heard_range_this_session, + "device_detail_text_hidden_the_ap_is_beaconing_but_not" to R.string.device_detail_text_hidden_the_ap_is_beaconing_but_not, + "device_detail_text_hits" to R.string.device_detail_text_hits, + "device_detail_text_how_loud_here_rssi" to R.string.device_detail_text_how_loud_here_rssi, + "device_detail_text_how_often_it_advertises" to R.string.device_detail_text_how_often_it_advertises, + "device_detail_text_identity" to R.string.device_detail_text_identity, + "device_detail_text_ieee_board_chip_vendor_this_is_who" to R.string.device_detail_text_ieee_board_chip_vendor_this_is_who, + "device_detail_text_last_fix" to R.string.device_detail_text_last_fix, + "device_detail_text_last_fix_is_the_phone_s_gps" to R.string.device_detail_text_last_fix_is_the_phone_s_gps, + "device_detail_text_last_heard" to R.string.device_detail_text_last_heard, + "device_detail_text_last_seen" to R.string.device_detail_text_last_seen, + "device_detail_text_maker_data_inside_the_ad" to R.string.device_detail_text_maker_data_inside_the_ad, + "device_detail_text_marked_mine" to R.string.device_detail_text_marked_mine, + "device_detail_text_matched_signatures" to R.string.device_detail_text_matched_signatures, + "device_detail_text_mbps_required_basic_rate" to R.string.device_detail_text_mbps_required_basic_rate, + "device_detail_text_network_name_ssid" to R.string.device_detail_text_network_name_ssid, + "device_detail_text_no_beep_while_this_is_on_still" to R.string.device_detail_text_no_beep_while_this_is_on_still, + "device_detail_text_no_broadcast_only_you_can_hear_it" to R.string.device_detail_text_no_broadcast_only_you_can_hear_it, + "device_detail_text_no_ieee_match_randomized_addresses_usually_have" to R.string.device_detail_text_no_ieee_match_randomized_addresses_usually_have, + "device_detail_text_not_available" to R.string.device_detail_text_not_available, + "device_detail_text_not_in_the_bluetooth_company_list" to R.string.device_detail_text_not_in_the_bluetooth_company_list, + "device_detail_text_note" to R.string.device_detail_text_note, + "device_detail_text_notes" to R.string.device_detail_text_notes, + "device_detail_text_now" to R.string.device_detail_text_now, + "device_detail_text_oui_vendor_prefix" to R.string.device_detail_text_oui_vendor_prefix, + "device_detail_text_pattern_match_not_identity_not_a_safety" to R.string.device_detail_text_pattern_match_not_identity_not_a_safety, + "device_detail_text_periodic_advertising" to R.string.device_detail_text_periodic_advertising, + "device_detail_text_presence_15_min" to R.string.device_detail_text_presence_15_min, + "device_detail_text_radio" to R.string.device_detail_text_radio, + "device_detail_text_radio_phy" to R.string.device_detail_text_radio_phy, + "device_detail_text_raw_advertisement" to R.string.device_detail_text_raw_advertisement, + "device_detail_text_raw_payload_bytes" to R.string.device_detail_text_raw_payload_bytes, + "device_detail_text_recent_rssi_oldest_newest" to R.string.device_detail_text_recent_rssi_oldest_newest, + "device_detail_text_security_string" to R.string.device_detail_text_security_string, + "device_detail_text_service_data" to R.string.device_detail_text_service_data, + "device_detail_text_service_ids" to R.string.device_detail_text_service_ids, + "device_detail_text_services_it_offers" to R.string.device_detail_text_services_it_offers, + ) + + private fun group15(): Map = mapOf( + "device_detail_text_session" to R.string.device_detail_text_session, + "device_detail_text_signal" to R.string.device_detail_text_signal, + "device_detail_text_supported_rates" to R.string.device_detail_text_supported_rates, + "device_detail_text_type_d" to R.string.device_detail_text_type_d, + "device_detail_text_unknown_ieee_oui" to R.string.device_detail_text_unknown_ieee_oui, + "device_detail_text_unlisted" to R.string.device_detail_text_unlisted, + "device_detail_text_unlisted_appearance_0x_04x" to R.string.device_detail_text_unlisted_appearance_0x_04x, + "device_detail_text_vendor_oui" to R.string.device_detail_text_vendor_oui, + "device_detail_text_what_it_says_it_is_appearance" to R.string.device_detail_text_what_it_says_it_is_appearance, + "device_detail_text_what_this_looks_like" to R.string.device_detail_text_what_this_looks_like, + "device_detail_text_who_made_it" to R.string.device_detail_text_who_made_it, + "device_detail_text_wi_fi_access_point" to R.string.device_detail_text_wi_fi_access_point, + "device_detail_text_wi_fi_access_point_beaconing_a_network" to R.string.device_detail_text_wi_fi_access_point_beaconing_a_network, + "device_detail_text_wi_fi_generation" to R.string.device_detail_text_wi_fi_generation, + "device_detail_text_wi_fi_vendor_tags" to R.string.device_detail_text_wi_fi_vendor_tags, + "device_detail_text_wide" to R.string.device_detail_text_wide, + "device_detail_text_yes_a_phone_could_open_a_ble" to R.string.device_detail_text_yes_a_phone_could_open_a_ble, + "device_explain_" to R.string.device_explain_, + "device_explain_a_bee_pioneer_wearable_recorder" to R.string.device_explain_a_bee_pioneer_wearable_recorder, + "device_explain_a_belkin_access_point" to R.string.device_explain_a_belkin_access_point, + "device_explain_a_ble_tire_pressure_sensor" to R.string.device_explain_a_ble_tire_pressure_sensor, + "device_explain_a_blip_systems_bliptrack_roadside_travel_time" to R.string.device_explain_a_blip_systems_bliptrack_roadside_travel_time, + "device_explain_a_blood_pressure_monitor" to R.string.device_explain_a_blood_pressure_monitor, + "device_explain_a_bmw_vehicle_phone_as_key_or" to R.string.device_explain_a_bmw_vehicle_phone_as_key_or, + "device_explain_a_bruce_esp32_pentest_board" to R.string.device_explain_a_bruce_esp32_pentest_board, + "device_explain_a_buffalo_airstation_or_router" to R.string.device_explain_a_buffalo_airstation_or_router, + "device_explain_a_byd_vehicle_or_phone_as_key" to R.string.device_explain_a_byd_vehicle_or_phone_as_key, + "device_explain_a_calix_fiber_gateway" to R.string.device_explain_a_calix_fiber_gateway, + "device_explain_a_cambium_or_ignitenet_access_point" to R.string.device_explain_a_cambium_or_ignitenet_access_point, + "device_explain_a_camera_module_radio_liteon_or_similar" to R.string.device_explain_a_camera_module_radio_liteon_or_similar, + "device_explain_a_carplay_in_car_hotspot" to R.string.device_explain_a_carplay_in_car_hotspot, + "device_explain_a_centurylink_gateway" to R.string.device_explain_a_centurylink_gateway, + "device_explain_a_chamberlain_myq_garage_hub" to R.string.device_explain_a_chamberlain_myq_garage_hub, + "device_explain_a_chevrolet_in_car_wi_fi_hotspot" to R.string.device_explain_a_chevrolet_in_car_wi_fi_hotspot, + "device_explain_a_cisco_aironet_catalyst_business_rv_or" to R.string.device_explain_a_cisco_aironet_catalyst_business_rv_or, + "device_explain_a_cisco_meraki_access_point" to R.string.device_explain_a_cisco_meraki_access_point, + "device_explain_a_clock" to R.string.device_explain_a_clock, + "device_explain_a_compex_access_point_sometimes_public_safety" to R.string.device_explain_a_compex_access_point_sometimes_public_safety, + "device_explain_a_computer" to R.string.device_explain_a_computer, + "device_explain_a_computer_or_tablet" to R.string.device_explain_a_computer_or_tablet, + "device_explain_a_cradlepoint_vehicle_router_often_public_safety" to R.string.device_explain_a_cradlepoint_vehicle_router_often_public_safety, + "device_explain_a_cudy_travel_or_home_router" to R.string.device_explain_a_cudy_travel_or_home_router, + "device_explain_a_d_link_access_point" to R.string.device_explain_a_d_link_access_point, + "device_explain_a_dexcom_glucose_sensor" to R.string.device_explain_a_dexcom_glucose_sensor, + "device_explain_a_digital_ally_body_worn_or_in" to R.string.device_explain_a_digital_ally_body_worn_or_in, + "device_explain_a_display_or_tv_stick" to R.string.device_explain_a_display_or_tv_stick, + "device_explain_a_dji_drone_or_controller" to R.string.device_explain_a_dji_drone_or_controller, + "device_explain_a_dji_osmo_action_camera" to R.string.device_explain_a_dji_osmo_action_camera, + "device_explain_a_dormakaba_saflok_or_oracode_lock" to R.string.device_explain_a_dormakaba_saflok_or_oracode_lock, + "device_explain_a_drone_broadcasting_astm_remote_id" to R.string.device_explain_a_drone_broadcasting_astm_remote_id, + "device_explain_a_dult_finder_tag" to R.string.device_explain_a_dult_finder_tag, + "device_explain_a_dwnet_access_point" to R.string.device_explain_a_dwnet_access_point, + "device_explain_a_finder_tag" to R.string.device_explain_a_finder_tag, + "device_explain_a_finder_tag_tracker" to R.string.device_explain_a_finder_tag_tracker, + "device_explain_a_fitbit" to R.string.device_explain_a_fitbit, + "device_explain_a_flock_raven_or_shotspotter_acoustic_gunshot" to R.string.device_explain_a_flock_raven_or_shotspotter_acoustic_gunshot, + "device_explain_a_ford_or_lincoln_vehicle_or_phone" to R.string.device_explain_a_ford_or_lincoln_vehicle_or_phone, + "device_explain_a_fortinet_fortiap_or_fortiwifi" to R.string.device_explain_a_fortinet_fortiap_or_fortiwifi, + "device_explain_a_franklin_technology_5g_home_internet_gateway" to R.string.device_explain_a_franklin_technology_5g_home_internet_gateway, + "device_explain_a_friend_pendant_necklace" to R.string.device_explain_a_friend_pendant_necklace, + "device_explain_a_game_controller" to R.string.device_explain_a_game_controller, + "device_explain_a_garmin_watch_or_inreach" to R.string.device_explain_a_garmin_watch_or_inreach, + "device_explain_a_ghostesp_esp32_audit_board" to R.string.device_explain_a_ghostesp_esp32_audit_board, + "device_explain_a_gl_inet_travel_router" to R.string.device_explain_a_gl_inet_travel_router, + "device_explain_a_gm_in_car_hotspot_cadillac_gmc" to R.string.device_explain_a_gm_in_car_hotspot_cadillac_gmc, + "device_explain_a_google_find_hub_tag" to R.string.device_explain_a_google_find_hub_tag, + "device_explain_a_google_wifi_or_nest_wifi_point" to R.string.device_explain_a_google_wifi_or_nest_wifi_point, + "device_explain_a_gopro" to R.string.device_explain_a_gopro, + "device_explain_a_gotenna_mesh_or_pro_radio" to R.string.device_explain_a_gotenna_mesh_or_pro_radio, + "device_explain_a_govee_light_or_sensor" to R.string.device_explain_a_govee_light_or_sensor, + "device_explain_a_grandstream_gwn_access_point" to R.string.device_explain_a_grandstream_gwn_access_point, + "device_explain_a_haiku_or_mammoth_ceiling_fan" to R.string.device_explain_a_haiku_or_mammoth_ceiling_fan, + "device_explain_a_hanwha_vision_wisenet_camera" to R.string.device_explain_a_hanwha_vision_wisenet_camera, + "device_explain_a_hayden_ai_bus_or_vehicle_mounted" to R.string.device_explain_a_hayden_ai_bus_or_vehicle_mounted, + "device_explain_a_health_sensor" to R.string.device_explain_a_health_sensor, + "device_explain_a_heart_rate_monitor" to R.string.device_explain_a_heart_rate_monitor, + "device_explain_a_hidden_wi_fi_access_point" to R.string.device_explain_a_hidden_wi_fi_access_point, + "device_explain_a_hitron_cable_gateway_often_xfinity_oem" to R.string.device_explain_a_hitron_cable_gateway_often_xfinity_oem, + "device_explain_a_honda_or_acura_vehicle_or_phone" to R.string.device_explain_a_honda_or_acura_vehicle_or_phone, + "device_explain_a_honeywell_home_or_lyric_thermostat" to R.string.device_explain_a_honeywell_home_or_lyric_thermostat, + "device_explain_a_honeywell_xenon_healthcare_barcode_scanner" to R.string.device_explain_a_honeywell_xenon_healthcare_barcode_scanner, + "device_explain_a_hoverair_flying_camera" to R.string.device_explain_a_hoverair_flying_camera, + "device_explain_a_humax_gateway_often_t_mobile_home" to R.string.device_explain_a_humax_gateway_often_t_mobile_home, + "device_explain_a_hyundai_or_genesis_vehicle_or_phone" to R.string.device_explain_a_hyundai_or_genesis_vehicle_or_phone, + "device_explain_a_jaguar_land_rover_or_range_rover" to R.string.device_explain_a_jaguar_land_rover_or_range_rover, + "device_explain_a_juniper_mist_access_point" to R.string.device_explain_a_juniper_mist_access_point, + "device_explain_a_keyboard" to R.string.device_explain_a_keyboard, + "device_explain_a_keyboard_mouse_or_gamepad" to R.string.device_explain_a_keyboard_mouse_or_gamepad, + "device_explain_a_kontakt_io_beacon" to R.string.device_explain_a_kontakt_io_beacon, + "device_explain_a_kwikset_kevo_or_unikey_lock" to R.string.device_explain_a_kwikset_kevo_or_unikey_lock, + "device_explain_a_kwikset_lock" to R.string.device_explain_a_kwikset_lock, + "device_explain_a_limitless_pendant_conversation_recorder" to R.string.device_explain_a_limitless_pendant_conversation_recorder, + "device_explain_a_linksys_or_velop_access_point" to R.string.device_explain_a_linksys_or_velop_access_point, + "device_explain_a_lockly_smart_lock" to R.string.device_explain_a_lockly_smart_lock, + "device_explain_a_logitech_mouse_keyboard_or_webcam" to R.string.device_explain_a_logitech_mouse_keyboard_or_webcam, + "device_explain_a_luxul_access_point" to R.string.device_explain_a_luxul_access_point, + "device_explain_a_master_lock_padlock" to R.string.device_explain_a_master_lock_padlock, + "device_explain_a_mercedes_mbux_in_car_hotspot" to R.string.device_explain_a_mercedes_mbux_in_car_hotspot, + "device_explain_a_mesh_wi_fi_node" to R.string.device_explain_a_mesh_wi_fi_node, + "device_explain_a_meshcore_lora_companion_radio" to R.string.device_explain_a_meshcore_lora_companion_radio, + ) + + private fun group16(): Map = mapOf( + "device_explain_a_mikrotik_router_or_access_point" to R.string.device_explain_a_mikrotik_router_or_access_point, + "device_explain_a_minew_ble_beacon_or_sensor" to R.string.device_explain_a_minew_ble_beacon_or_sensor, + "device_explain_a_miovision_intersection_traffic_camera" to R.string.device_explain_a_miovision_intersection_traffic_camera, + "device_explain_a_mojo_networks_arista_cognitive_wi_fi" to R.string.device_explain_a_mojo_networks_arista_cognitive_wi_fi, + "device_explain_a_motive_keeptruckin_fleet_eld_hotspot" to R.string.device_explain_a_motive_keeptruckin_fleet_eld_hotspot, + "device_explain_a_mouse" to R.string.device_explain_a_mouse, + "device_explain_a_nespresso_coffee_machine" to R.string.device_explain_a_nespresso_coffee_machine, + "device_explain_a_nest_protect_camera_or_other_weave" to R.string.device_explain_a_nest_protect_camera_or_other_weave, + "device_explain_a_nest_thermostat_or_nest_labs_ble" to R.string.device_explain_a_nest_thermostat_or_nest_labs_ble, + "device_explain_a_netgear_or_orbi_access_point" to R.string.device_explain_a_netgear_or_orbi_access_point, + "device_explain_a_nissan_or_infiniti_vehicle_or_phone" to R.string.device_explain_a_nissan_or_infiniti_vehicle_or_phone, + "device_explain_a_nokia_solutions_and_networks_gateway" to R.string.device_explain_a_nokia_solutions_and_networks_gateway, + "device_explain_a_novatel_wireless_inseego_vehicle_radio" to R.string.device_explain_a_novatel_wireless_inseego_vehicle_radio, + "device_explain_a_nuki_lock_or_opener" to R.string.device_explain_a_nuki_lock_or_opener, + "device_explain_a_panasonic_i_pro_camera_or_arbitrator" to R.string.device_explain_a_panasonic_i_pro_camera_or_arbitrator, + "device_explain_a_parrot_anafi_or_bebop_drone" to R.string.device_explain_a_parrot_anafi_or_bebop_drone, + "device_explain_a_paxton_reader_or_net2_access_point" to R.string.device_explain_a_paxton_reader_or_net2_access_point, + "device_explain_a_peoplenet_fleet_eld_hotspot" to R.string.device_explain_a_peoplenet_fleet_eld_hotspot, + "device_explain_a_peplink_or_pepwave_router" to R.string.device_explain_a_peplink_or_pepwave_router, + "device_explain_a_phone" to R.string.device_explain_a_phone, + "device_explain_a_phone_hotspot" to R.string.device_explain_a_phone_hotspot, + "device_explain_a_phone_or_tv_using_wi_fi" to R.string.device_explain_a_phone_or_tv_using_wi_fi, + "device_explain_a_pixel_or_other_google_radio" to R.string.device_explain_a_pixel_or_other_google_radio, + "device_explain_a_porsche_vehicle_or_phone_as_key" to R.string.device_explain_a_porsche_vehicle_or_phone_as_key, + "device_explain_a_radiacode_radiation_detector" to R.string.device_explain_a_radiacode_radiation_detector, + "device_explain_a_rak_wisgate_lorawan_gateway" to R.string.device_explain_a_rak_wisgate_lorawan_gateway, + "device_explain_a_remote_control" to R.string.device_explain_a_remote_control, + "device_explain_a_reveal_media_body_worn_camera" to R.string.device_explain_a_reveal_media_body_worn_camera, + "device_explain_a_rhombus_cloud_camera" to R.string.device_explain_a_rhombus_cloud_camera, + "device_explain_a_rivian_vehicle_phone_key_or_sensor" to R.string.device_explain_a_rivian_vehicle_phone_key_or_sensor, + "device_explain_a_roku_streaming_stick_or_roku_tv" to R.string.device_explain_a_roku_streaming_stick_or_roku_tv, + "device_explain_a_ruckus_access_point" to R.string.device_explain_a_ruckus_access_point, + "device_explain_a_ruijie_or_reyee_access_point" to R.string.device_explain_a_ruijie_or_reyee_access_point, + "device_explain_a_sagemcom_isp_gateway" to R.string.device_explain_a_sagemcom_isp_gateway, + "device_explain_a_salto_access_reader_or_lock" to R.string.device_explain_a_salto_access_reader_or_lock, + "device_explain_a_samsung_fridge_range_oven_or_cooktop" to R.string.device_explain_a_samsung_fridge_range_oven_or_cooktop, + "device_explain_a_samsung_smarttag" to R.string.device_explain_a_samsung_smarttag, + "device_explain_a_schlage_or_allegion_lock" to R.string.device_explain_a_schlage_or_allegion_lock, + "device_explain_a_sensecap_lorawan_helium_gateway" to R.string.device_explain_a_sensecap_lorawan_helium_gateway, + "device_explain_a_sensi_thermostat" to R.string.device_explain_a_sensi_thermostat, + "device_explain_a_sercomm_isp_gateway" to R.string.device_explain_a_sercomm_isp_gateway, + "device_explain_a_sierra_wireless_airlink_vehicle_gateway" to R.string.device_explain_a_sierra_wireless_airlink_vehicle_gateway, + "device_explain_a_skydio_drone" to R.string.device_explain_a_skydio_drone, + "device_explain_a_snapav_control4_wattbox_access_point" to R.string.device_explain_a_snapav_control4_wattbox_access_point, + "device_explain_a_sonos_speaker" to R.string.device_explain_a_sonos_speaker, + "device_explain_a_sophos_firewall_or_access_point" to R.string.device_explain_a_sophos_firewall_or_access_point, + "device_explain_a_speaker" to R.string.device_explain_a_speaker, + "device_explain_a_spectrum_gateway_or_spectrum_mobile_hotspot" to R.string.device_explain_a_spectrum_gateway_or_spectrum_mobile_hotspot, + "device_explain_a_starlink_router" to R.string.device_explain_a_starlink_router, + "device_explain_a_subaru_vehicle_or_phone_as_key" to R.string.device_explain_a_subaru_vehicle_or_phone_as_key, + "device_explain_a_synology_nas_or_router_access_point" to R.string.device_explain_a_synology_nas_or_router_access_point, + "device_explain_a_t_mobile_home_internet_gateway_or" to R.string.device_explain_a_t_mobile_home_internet_gateway_or, + "device_explain_a_target_atrius_basket_tag" to R.string.device_explain_a_target_atrius_basket_tag, + "device_explain_a_tattile_plate_reader" to R.string.device_explain_a_tattile_plate_reader, + "device_explain_a_tedee_smart_lock" to R.string.device_explain_a_tedee_smart_lock, + "device_explain_a_tenda_access_point" to R.string.device_explain_a_tenda_access_point, + "device_explain_a_tesla_ble_tire_sensor" to R.string.device_explain_a_tesla_ble_tire_sensor, + "device_explain_a_tesla_vehicle_including_cybertruck_or_phone" to R.string.device_explain_a_tesla_vehicle_including_cybertruck_or_phone, + "device_explain_a_tile_tracker" to R.string.device_explain_a_tile_tracker, + "device_explain_a_toyota_or_lexus_vehicle_or_phone" to R.string.device_explain_a_toyota_or_lexus_vehicle_or_phone, + "device_explain_a_tp_link_access_point" to R.string.device_explain_a_tp_link_access_point, + "device_explain_a_trendnet_access_point" to R.string.device_explain_a_trendnet_access_point, + "device_explain_a_tuya_ble_gadget_plug_light_camera" to R.string.device_explain_a_tuya_ble_gadget_plug_light_camera, + "device_explain_a_ubiquiti_unifi_access_point" to R.string.device_explain_a_ubiquiti_unifi_access_point, + "device_explain_a_uconnect_in_car_hotspot" to R.string.device_explain_a_uconnect_in_car_hotspot, + "device_explain_a_unifi_protect_instant_camera" to R.string.device_explain_a_unifi_protect_instant_camera, + "device_explain_a_unifi_ubiquiti_name" to R.string.device_explain_a_unifi_ubiquiti_name, + "device_explain_a_uniview_unv_camera" to R.string.device_explain_a_uniview_unv_camera, + "device_explain_a_utility_inc_vehicle_or_public_safety" to R.string.device_explain_a_utility_inc_vehicle_or_public_safety, + "device_explain_a_vantiva_or_technicolor_isp_gateway" to R.string.device_explain_a_vantiva_or_technicolor_isp_gateway, + "device_explain_a_verizon_or_fios_gateway" to R.string.device_explain_a_verizon_or_fios_gateway, + "device_explain_a_volkswagen_vehicle_or_phone_as_key" to R.string.device_explain_a_volkswagen_vehicle_or_phone_as_key, + "device_explain_a_watch" to R.string.device_explain_a_watch, + "device_explain_a_watch_or_wrist_wearable" to R.string.device_explain_a_watch_or_wrist_wearable, + "device_explain_a_watchguard_firewall_or_access_point" to R.string.device_explain_a_watchguard_firewall_or_access_point, + "device_explain_a_wavlink_access_point" to R.string.device_explain_a_wavlink_access_point, + "device_explain_a_wi_fi_access_point" to R.string.device_explain_a_wi_fi_access_point, + "device_explain_a_wi_fi_direct_access_point" to R.string.device_explain_a_wi_fi_direct_access_point, + "device_explain_a_windows_surface_xbox_radio" to R.string.device_explain_a_windows_surface_xbox_radio, + "device_explain_a_winegard_rv_or_marine_wi_fi" to R.string.device_explain_a_winegard_rv_or_marine_wi_fi, + "device_explain_a_withings_scale_or_blood_pressure_monitor" to R.string.device_explain_a_withings_scale_or_blood_pressure_monitor, + "device_explain_a_wolfcom_body_worn_or_in_car" to R.string.device_explain_a_wolfcom_body_worn_or_in_car, + "device_explain_a_zyxel_gateway_or_access_point" to R.string.device_explain_a_zyxel_gateway_or_access_point, + "device_explain_ad_hoc_network" to R.string.device_explain_ad_hoc_network, + "device_explain_aes_encryption" to R.string.device_explain_aes_encryption, + "device_explain_airpods_beats_or_airplay" to R.string.device_explain_airpods_beats_or_airplay, + "device_explain_an_actiontec_fios_or_frontier_gateway" to R.string.device_explain_an_actiontec_fios_or_frontier_gateway, + "device_explain_an_adtran_fiber_gateway_often_centurylink_quantum" to R.string.device_explain_an_adtran_fiber_gateway_often_centurylink_quantum, + "device_explain_an_airties_isp_mesh_node" to R.string.device_explain_an_airties_isp_mesh_node, + "device_explain_an_apple_airtag_find_my_tag" to R.string.device_explain_an_apple_airtag_find_my_tag, + "device_explain_an_apple_find_my_accessory" to R.string.device_explain_an_apple_find_my_accessory, + "device_explain_an_arcadyan_isp_gateway" to R.string.device_explain_an_arcadyan_isp_gateway, + "device_explain_an_arlo_camera_or_vmb_base_station" to R.string.device_explain_an_arlo_camera_or_vmb_base_station, + "device_explain_an_arris_or_surfboard_cable_gateway" to R.string.device_explain_an_arris_or_surfboard_cable_gateway, + "device_explain_an_askey_isp_5g_gateway" to R.string.device_explain_an_askey_isp_5g_gateway, + "device_explain_an_assa_abloy_lock_yale_lock_hid" to R.string.device_explain_an_assa_abloy_lock_yale_lock_hid, + "device_explain_an_asus_access_point" to R.string.device_explain_an_asus_access_point, + "device_explain_an_at_t_gateway_or_attwifi_hotspot" to R.string.device_explain_an_at_t_gateway_or_attwifi_hotspot, + "device_explain_an_audi_mmi_in_car_hotspot" to R.string.device_explain_an_audi_mmi_in_car_hotspot, + "device_explain_an_audio_device" to R.string.device_explain_an_audio_device, + ) + + private fun group17(): Map = mapOf( + "device_explain_an_august_smart_lock" to R.string.device_explain_an_august_smart_lock, + "device_explain_an_aumovio_continental_vehicle_wi_fi_radio" to R.string.device_explain_an_aumovio_continental_vehicle_wi_fi_radio, + "device_explain_an_autel_drone" to R.string.device_explain_an_autel_drone, + "device_explain_an_echo_fire_or_other_amazon_radio" to R.string.device_explain_an_echo_fire_or_other_amazon_radio, + "device_explain_an_ecobee_thermostat" to R.string.device_explain_an_ecobee_thermostat, + "device_explain_an_ecowater_water_softener_setup_ap" to R.string.device_explain_an_ecowater_water_softener_setup_ap, + "device_explain_an_edgecore_access_point" to R.string.device_explain_an_edgecore_access_point, + "device_explain_an_eero_mesh_node" to R.string.device_explain_an_eero_mesh_node, + "device_explain_an_engenius_access_point" to R.string.device_explain_an_engenius_access_point, + "device_explain_an_environmental_sensor" to R.string.device_explain_an_environmental_sensor, + "device_explain_an_epson_ecotank_or_workforce_printer" to R.string.device_explain_an_epson_ecotank_or_workforce_printer, + "device_explain_an_estimote_beacon" to R.string.device_explain_an_estimote_beacon, + "device_explain_an_extreme_networks_access_point" to R.string.device_explain_an_extreme_networks_access_point, + "device_explain_an_hp_printer" to R.string.device_explain_an_hp_printer, + "device_explain_an_hpe_aruba_instant_or_instant_on" to R.string.device_explain_an_hpe_aruba_instant_or_instant_on, + "device_explain_an_ibeacon" to R.string.device_explain_an_ibeacon, + "device_explain_an_igloohome_lock_or_keybox" to R.string.device_explain_an_igloohome_lock_or_keybox, + "device_explain_an_input_accessory" to R.string.device_explain_an_input_accessory, + "device_explain_an_input_device_keyboard_mouse_or_similar" to R.string.device_explain_an_input_device_keyboard_mouse_or_similar, + "device_explain_an_inseego_5g_or_mifi_hotspot" to R.string.device_explain_an_inseego_5g_or_mifi_hotspot, + "device_explain_an_insta360_camera" to R.string.device_explain_an_insta360_camera, + "device_explain_an_iphone_ipad_or_mac" to R.string.device_explain_an_iphone_ipad_or_mac, + "device_explain_an_iteris_bluetoad_vantage_velocity_roadside_bluetooth" to R.string.device_explain_an_iteris_bluetoad_vantage_velocity_roadside_bluetooth, + "device_explain_an_lg_webos_tv" to R.string.device_explain_an_lg_webos_tv, + "device_explain_an_lvt_liveview_solar_surveillance_trailer" to R.string.device_explain_an_lvt_liveview_solar_surveillance_trailer, + "device_explain_an_omi_pendant_or_openglass_camera_glasses" to R.string.device_explain_an_omi_pendant_or_openglass_camera_glasses, + "device_explain_an_omron_blood_pressure_cuff_or_scale" to R.string.device_explain_an_omron_blood_pressure_cuff_or_scale, + "device_explain_an_openwrt_router" to R.string.device_explain_an_openwrt_router, + "device_explain_an_oura_ring" to R.string.device_explain_an_oura_ring, + "device_explain_an_xfinity_gateway_or_hotspot" to R.string.device_explain_an_xfinity_gateway_or_hotspot, + "device_explain_anonymous_the_stack_hid_the_address" to R.string.device_explain_anonymous_the_stack_hid_the_address, + "device_explain_apple_find_my_related" to R.string.device_explain_apple_find_my_related, + "device_explain_apple_not_assa_abloy" to R.string.device_explain_apple_not_assa_abloy, + "device_explain_attribute_protocol" to R.string.device_explain_attribute_protocol, + "device_explain_august_home_lock" to R.string.device_explain_august_home_lock, + "device_explain_battery_level" to R.string.device_explain_battery_level, + "device_explain_ble_only_no_classic_bluetooth_headsets_file" to R.string.device_explain_ble_only_no_classic_bluetooth_headsets_file, + "device_explain_blood_pressure_sensor" to R.string.device_explain_blood_pressure_sensor, + "device_explain_bluetooth_le_advertiser" to R.string.device_explain_bluetooth_le_advertiser, + "device_explain_bose_headphones_or_a_speaker" to R.string.device_explain_bose_headphones_or_a_speaker, + "device_explain_brilliant_labs_frame_ar_glasses" to R.string.device_explain_brilliant_labs_frame_ar_glasses, + "device_explain_capability_list_includes_mesh" to R.string.device_explain_capability_list_includes_mesh, + "device_explain_class_of_device_says" to R.string.device_explain_class_of_device_says, + "device_explain_connection_basics" to R.string.device_explain_connection_basics, + "device_explain_could_be" to R.string.device_explain_could_be, + "device_explain_device" to R.string.device_explain_device, + "device_explain_discoverable_other_ble_devices_can_find_it" to R.string.device_explain_discoverable_other_ble_devices_can_find_it, + "device_explain_dual_mode_chip_ble_and_classic_can" to R.string.device_explain_dual_mode_chip_ble_and_classic_can, + "device_explain_earbuds_or_a_headset" to R.string.device_explain_earbuds_or_a_headset, + "device_explain_earbuds_or_a_speaker" to R.string.device_explain_earbuds_or_a_speaker, + "device_explain_earbuds_or_headphones" to R.string.device_explain_earbuds_or_headphones, + "device_explain_enhanced_open_encrypted_no_password" to R.string.device_explain_enhanced_open_encrypted_no_password, + "device_explain_enterprise_login_802_1x" to R.string.device_explain_enterprise_login_802_1x, + "device_explain_even_realities_g1_glasses" to R.string.device_explain_even_realities_g1_glasses, + "device_explain_faster_ble_bluetooth_5" to R.string.device_explain_faster_ble_bluetooth_5, + "device_explain_find_my_related_uuid" to R.string.device_explain_find_my_related_uuid, + "device_explain_google_fast_pair_is_present_common_on" to R.string.device_explain_google_fast_pair_is_present_common_on, + "device_explain_google_fast_pair_often_buds_speakers" to R.string.device_explain_google_fast_pair_often_buds_speakers, + "device_explain_headphones_a_headset_or_a_speaker" to R.string.device_explain_headphones_a_headset_or_a_speaker, + "device_explain_heart_rate_sensor" to R.string.device_explain_heart_rate_sensor, + "device_explain_hp_printer" to R.string.device_explain_hp_printer, + "device_explain_infrastructure_access_point" to R.string.device_explain_infrastructure_access_point, + "device_explain_it_advertises_appearance_as" to R.string.device_explain_it_advertises_appearance_as, + "device_explain_it_is_on_the_air_but_it" to R.string.device_explain_it_is_on_the_air_but_it, + "device_explain_it_offers_a_classic_audio_headset_service" to R.string.device_explain_it_offers_a_classic_audio_headset_service, + "device_explain_it_offers_bluetooth_le_audio_services" to R.string.device_explain_it_offers_bluetooth_le_audio_services, + "device_explain_it_offers_environmental_sensing" to R.string.device_explain_it_offers_environmental_sensing, + "device_explain_it_offers_the_blood_pressure_service" to R.string.device_explain_it_offers_the_blood_pressure_service, + "device_explain_it_offers_the_heart_rate_service" to R.string.device_explain_it_offers_the_heart_rate_service, + "device_explain_it_offers_the_hid_human_interface_service" to R.string.device_explain_it_offers_the_hid_human_interface_service, + "device_explain_jbl_or_harman_audio" to R.string.device_explain_jbl_or_harman_audio, + "device_explain_keyboard_mouse_or_gamepad" to R.string.device_explain_keyboard_mouse_or_gamepad, + "device_explain_le_audio_earbuds_or_a_speaker" to R.string.device_explain_le_audio_earbuds_or_a_speaker, + "device_explain_le_cycling_power_speed" to R.string.device_explain_le_cycling_power_speed, + "device_explain_limited_discoverable_briefly_looking_for_a_nearby" to R.string.device_explain_limited_discoverable_briefly_looking_for_a_nearby, + "device_explain_locally_administered_address_the_local_bit_is" to R.string.device_explain_locally_administered_address_the_local_bit_is, + "device_explain_locally_administered_bssid_vehicle_mesh_and_guest" to R.string.device_explain_locally_administered_bssid_vehicle_mesh_and_guest, + "device_explain_long_range_ble_slower_farther" to R.string.device_explain_long_range_ble_slower_farther, + "device_explain_matched_signature" to R.string.device_explain_matched_signature, + "device_explain_may_also_do_classic_bluetooth_br_edr" to R.string.device_explain_may_also_do_classic_bluetooth_br_edr, + "device_explain_medium" to R.string.device_explain_medium, + "device_explain_mesh_node" to R.string.device_explain_mesh_node, + "device_explain_model_serial_firmware" to R.string.device_explain_model_serial_firmware, + "device_explain_most_likely" to R.string.device_explain_most_likely, + "device_explain_not_available" to R.string.device_explain_not_available, + "device_explain_not_available_2" to R.string.device_explain_not_available_2, + "device_explain_open_or_encryption_not_parsed" to R.string.device_explain_open_or_encryption_not_parsed, + "device_explain_probably" to R.string.device_explain_probably, + "device_explain_public_factory_address_stable_ieee_assigned" to R.string.device_explain_public_factory_address_stable_ieee_assigned, + "device_explain_random_privacy_address_the_mac_can_change" to R.string.device_explain_random_privacy_address_the_mac_can_change, + "device_explain_shokz_openrun_or_openfit_headphones" to R.string.device_explain_shokz_openrun_or_openfit_headphones, + "device_explain_smart_glasses" to R.string.device_explain_smart_glasses, + "device_explain_smarttag_service_uuid" to R.string.device_explain_smarttag_service_uuid, + "device_explain_sonos_speaker" to R.string.device_explain_sonos_speaker, + "device_explain_sony_headphones_a_tv_or_a_camera" to R.string.device_explain_sony_headphones_a_tv_or_a_camera, + "device_explain_ssid_is_hidden_the_radio_is_still" to R.string.device_explain_ssid_is_hidden_the_radio_is_still, + "device_explain_ssid_looks_like_a_phone_hotspot" to R.string.device_explain_ssid_looks_like_a_phone_hotspot, + "device_explain_ssid_starts_with_direct" to R.string.device_explain_ssid_starts_with_direct, + "device_explain_standard_ble_radio" to R.string.device_explain_standard_ble_radio, + "device_explain_stock_android_only_reports_beaconing_aps" to R.string.device_explain_stock_android_only_reports_beaconing_aps, + ) + + private fun group18(): Map = mapOf( + "device_explain_strong" to R.string.device_explain_strong, + "device_explain_temperature_humidity_style_sensor" to R.string.device_explain_temperature_humidity_style_sensor, + "device_explain_this_is_what_the_device_is_advertising" to R.string.device_explain_this_is_what_the_device_is_advertising, + "device_explain_tile_service_uuid" to R.string.device_explain_tile_service_uuid, + "device_explain_tile_tracker" to R.string.device_explain_tile_tracker, + "device_explain_tkip_older_weaker_cipher" to R.string.device_explain_tkip_older_weaker_cipher, + "device_explain_universal_ieee_address_stable_oui" to R.string.device_explain_universal_ieee_address_stable_oui, + "device_explain_very_strong" to R.string.device_explain_very_strong, + "device_explain_very_weak" to R.string.device_explain_very_weak, + "device_explain_vuzix_smart_glasses" to R.string.device_explain_vuzix_smart_glasses, + "device_explain_weak" to R.string.device_explain_weak, + "device_explain_wep_old_weak" to R.string.device_explain_wep_old_weak, + "device_explain_wi_fi_access_point" to R.string.device_explain_wi_fi_access_point, + "device_explain_wi_fi_password_wpa_psk" to R.string.device_explain_wi_fi_password_wpa_psk, + "device_explain_wpa2_password_psk" to R.string.device_explain_wpa2_password_psk, + "device_explain_wpa3_password_sae_handshake" to R.string.device_explain_wpa3_password_sae_handshake, + "device_explain_wps_setup_is_enabled" to R.string.device_explain_wps_setup_is_enabled, + "fast_pair_fast_pair_pairing" to R.string.fast_pair_fast_pair_pairing, + "fieldwatch_disclaimer_begin_your_reply_with_this_disclaimer_do" to R.string.fieldwatch_disclaimer_begin_your_reply_with_this_disclaimer_do, + "fieldwatch_disclaimer_by_checking_the_box_and_continuing_you" to R.string.fieldwatch_disclaimer_by_checking_the_box_and_continuing_you, + "fieldwatch_disclaimer_detections_pattern_matches_moving_with_you_possible" to R.string.fieldwatch_disclaimer_detections_pattern_matches_moving_with_you_possible, + "fieldwatch_disclaimer_disclaimer_repeat_this_in_your_answer" to R.string.fieldwatch_disclaimer_disclaimer_repeat_this_in_your_answer, + "fieldwatch_disclaimer_do_not_use_fieldwatch_this_paste_or" to R.string.fieldwatch_disclaimer_do_not_use_fieldwatch_this_paste_or, + "fieldwatch_disclaimer_gps_stamps_are_this_phone_at_hear" to R.string.fieldwatch_disclaimer_gps_stamps_are_this_phone_at_hear, + "fieldwatch_disclaimer_n_n_n_n_n_n_n" to R.string.fieldwatch_disclaimer_n_n_n_n_n_n_n, + "fieldwatch_disclaimer_n_n_n_n_n_n_n_2" to R.string.fieldwatch_disclaimer_n_n_n_n_n_n_n_2, + "fieldwatch_disclaimer_named_sit_radios_heard_in_that_window" to R.string.fieldwatch_disclaimer_named_sit_radios_heard_in_that_window, + "fieldwatch_disclaimer_the_in_memory_live_set_last_15" to R.string.fieldwatch_disclaimer_the_in_memory_live_set_last_15, + "fieldwatch_disclaimer_this_is_a_hobby_project_provided_as" to R.string.fieldwatch_disclaimer_this_is_a_hobby_project_provided_as, + "fieldwatch_disclaimer_you_are_solely_responsible_for_how_you" to R.string.fieldwatch_disclaimer_you_are_solely_responsible_for_how_you, + "geo_apple_ble_phone_continuity" to R.string.geo_apple_ble_phone_continuity, + "geo_apple_find_my_offline_finding" to R.string.geo_apple_find_my_offline_finding, + "hunt_about_the_same" to R.string.hunt_about_the_same, + "hunt_closer" to R.string.hunt_closer, + "hunt_further" to R.string.hunt_further, + "hunt_gone" to R.string.hunt_gone, + "hunt_left_the_live_set_randomized_ble_often" to R.string.hunt_left_the_live_set_randomized_ble_often, + "hunt_listening" to R.string.hunt_listening, + "hunt_louder_than_a_few_seconds_ago_keep" to R.string.hunt_louder_than_a_few_seconds_ago_keep, + "hunt_need_a_few_seconds_of_packets_to" to R.string.hunt_need_a_few_seconds_of_packets_to, + "hunt_no_clear_change_yet_slow_down_hold" to R.string.hunt_no_clear_change_yet_slow_down_hold, + "hunt_no_packet_for_a_few_seconds_silent" to R.string.hunt_no_packet_for_a_few_seconds_silent, + "hunt_quiet" to R.string.hunt_quiet, + "hunt_quieter_than_a_few_seconds_ago_turn" to R.string.hunt_quieter_than_a_few_seconds_ago_turn, + "hunt_screaming_loud_here_look_around_usually_in" to R.string.hunt_screaming_loud_here_look_around_usually_in, + "hunt_very_close" to R.string.hunt_very_close, + "label_alertvoicewhat_both" to R.string.label_alertvoicewhat_both, + "label_alertvoicewhat_class" to R.string.label_alertvoicewhat_class, + "label_alertvoicewhat_signature" to R.string.label_alertvoicewhat_signature, + "label_radiokind_ble" to R.string.label_radiokind_ble, + "label_radiokind_wifi" to R.string.label_radiokind_wifi, + "label_scanintensity_balanced" to R.string.label_scanintensity_balanced, + "label_scanintensity_performance" to R.string.label_scanintensity_performance, + "label_scanintensity_saver" to R.string.label_scanintensity_saver, + "label_signatureclass_audio" to R.string.label_signatureclass_audio, + "label_signatureclass_beacon" to R.string.label_signatureclass_beacon, + "label_signatureclass_bodyworn" to R.string.label_signatureclass_bodyworn, + "label_signatureclass_camera" to R.string.label_signatureclass_camera, + "label_signatureclass_drone" to R.string.label_signatureclass_drone, + "label_signatureclass_finder" to R.string.label_signatureclass_finder, + "label_signatureclass_glasses" to R.string.label_signatureclass_glasses, + "label_signatureclass_hacking" to R.string.label_signatureclass_hacking, + "label_signatureclass_health" to R.string.label_signatureclass_health, + "label_signatureclass_home" to R.string.label_signatureclass_home, + "label_signatureclass_isp" to R.string.label_signatureclass_isp, + "label_signatureclass_law_enforcement" to R.string.label_signatureclass_law_enforcement, + "label_signatureclass_lock" to R.string.label_signatureclass_lock, + "label_signatureclass_mesh" to R.string.label_signatureclass_mesh, + "label_signatureclass_other" to R.string.label_signatureclass_other, + "label_signatureclass_phone" to R.string.label_signatureclass_phone, + "label_signatureclass_signage" to R.string.label_signatureclass_signage, + "label_signatureclass_surveillance" to R.string.label_signatureclass_surveillance, + "label_signatureclass_thermostat" to R.string.label_signatureclass_thermostat, + "label_signatureclass_vehicle" to R.string.label_signatureclass_vehicle, + "label_signatureclass_wearable" to R.string.label_signatureclass_wearable, + "label_signaturelistsort_class" to R.string.label_signaturelistsort_class, + "label_signaturelistsort_name" to R.string.label_signaturelistsort_name, + "label_unmatched" to R.string.label_unmatched, + "label_viewmode_by_class" to R.string.label_viewmode_by_class, + "label_viewmode_hybrid" to R.string.label_viewmode_hybrid, + "label_viewmode_list" to R.string.label_viewmode_list, + "label_viewmode_radar" to R.string.label_viewmode_radar, + "label_viewmode_timeline" to R.string.label_viewmode_timeline, + "log_selected_location_unavailable" to R.string.log_selected_location_unavailable, + "pairing_flood_1_address_from_this_burst_is_left" to R.string.pairing_flood_1_address_from_this_burst_is_left, + "pairing_flood_a_burst_of_new_bluetooth_addresses_in" to R.string.pairing_flood_a_burst_of_new_bluetooth_addresses_in, + "pairing_flood_a_burst_of_new_wi_fi_names" to R.string.pairing_flood_a_burst_of_new_wi_fi_names, + "pairing_flood_a_handheld_such_as_a_flipper_zero" to R.string.pairing_flood_a_handheld_such_as_a_flipper_zero, + "pairing_flood_a_handheld_such_as_a_flipper_zero_2" to R.string.pairing_flood_a_handheld_such_as_a_flipper_zero_2, + "pairing_flood_a_handheld_such_as_a_flipper_zero_3" to R.string.pairing_flood_a_handheld_such_as_a_flipper_zero_3, + "pairing_flood_a_repeated_name_a_mesh_an_extender" to R.string.pairing_flood_a_repeated_name_a_mesh_an_extender, + "pairing_flood_about_dbm" to R.string.pairing_flood_about_dbm, + "pairing_flood_about_the_same_loudness_about_dbm" to R.string.pairing_flood_about_the_same_loudness_about_dbm, + "pairing_flood_addresses_from_this_burst_are_left_out" to R.string.pairing_flood_addresses_from_this_burst_are_left_out, + "pairing_flood_dbm_which_reads_as_one_nearby_radio" to R.string.pairing_flood_dbm_which_reads_as_one_nearby_radio, + "pairing_flood_more_each_advertised_a_bluetooth_name" to R.string.pairing_flood_more_each_advertised_a_bluetooth_name, + "pairing_flood_name_flood" to R.string.pairing_flood_name_flood, + "pairing_flood_named" to R.string.pairing_flood_named, + "pairing_flood_new_addresses" to R.string.pairing_flood_new_addresses, + "pairing_flood_new_addresses_2" to R.string.pairing_flood_new_addresses_2, + ) + + private fun group19(): Map = mapOf( + "pairing_flood_new_addresses_each_advertised_a_bluetooth_name" to R.string.pairing_flood_new_addresses_each_advertised_a_bluetooth_name, + "pairing_flood_new_addresses_sent_pairing_advertisements_in_the" to R.string.pairing_flood_new_addresses_sent_pairing_advertisements_in_the, + "pairing_flood_new_names" to R.string.pairing_flood_new_names, + "pairing_flood_new_wi_fi_names_showed_up_in" to R.string.pairing_flood_new_wi_fi_names_showed_up_in, + "pairing_flood_pairing_flood" to R.string.pairing_flood_pairing_flood, + "pairing_flood_they_are_about_the_same_loudness_about" to R.string.pairing_flood_they_are_about_the_same_loudness_about, + "pairing_flood_wi_fi_beacon_flood" to R.string.pairing_flood_wi_fi_beacon_flood, + "pairing_flood_wi_fi_beacon_flood_new_names" to R.string.pairing_flood_wi_fi_beacon_flood_new_names, + "place_lookup_looking_up_place_names_of" to R.string.place_lookup_looking_up_place_names_of, + "place_lookup_no_name_returned" to R.string.place_lookup_no_name_returned, + "place_lookup_online_lookup_failed_silently_coordinates_only" to R.string.place_lookup_online_lookup_failed_silently_coordinates_only, + "place_lookup_online_lookup_on_but_this_sit_had" to R.string.place_lookup_online_lookup_on_but_this_sit_had, + "place_lookup_online_lookup_ran_but_the_system_geocoder" to R.string.place_lookup_online_lookup_ran_but_the_system_geocoder, + "place_lookup_online_lookup_skipped_no_working_internet_coordinates" to R.string.place_lookup_online_lookup_skipped_no_working_internet_coordinates, + "place_lookup_online_lookup_skipped_this_phone_has_no" to R.string.place_lookup_online_lookup_skipped_this_phone_has_no, + "place_lookup_online_lookup_system_geocoder_named_distinct_gps" to R.string.place_lookup_online_lookup_system_geocoder_named_distinct_gps, + "place_lookup_path_end" to R.string.place_lookup_path_end, + "place_lookup_path_start" to R.string.place_lookup_path_start, + "place_lookup_stay" to R.string.place_lookup_stay, + "place_lookup_transit_from" to R.string.place_lookup_transit_from, + "place_lookup_transit_to" to R.string.place_lookup_transit_to, + "preset_all" to R.string.preset_all, + "preset_ble" to R.string.preset_ble, + "preset_strong" to R.string.preset_strong, + "preset_watched" to R.string.preset_watched, + "preset_wifi" to R.string.preset_wifi, + "preset_with_you" to R.string.preset_with_you, + "radio_bookmarks_marked_mine" to R.string.radio_bookmarks_marked_mine, + "radio_bookmarks_nmarked_mine" to R.string.radio_bookmarks_nmarked_mine, + "radio_bookmarks_pinned_to_this_bssid_vehicle_mesh_and" to R.string.radio_bookmarks_pinned_to_this_bssid_vehicle_mesh_and, + "radio_bookmarks_pinned_to_this_mac_shows_on_debrief" to R.string.radio_bookmarks_pinned_to_this_mac_shows_on_debrief, + "radio_bookmarks_radio" to R.string.radio_bookmarks_radio, + "radio_bookmarks_shows_on_live_bookmark_top_right_is" to R.string.radio_bookmarks_shows_on_live_bookmark_top_right_is, + "radio_bookmarks_unnamed_le" to R.string.radio_bookmarks_unnamed_le, + "radio_gone" to R.string.radio_gone, + "radio_hidden" to R.string.radio_hidden, + "radio_pairing" to R.string.radio_pairing, + "radio_random" to R.string.radio_random, + "radio_unnamed" to R.string.radio_unnamed, + "report_duration_hours" to R.string.report_duration_hours, + "report_duration_minutes" to R.string.report_duration_minutes, + "report_duration_seconds" to R.string.report_duration_seconds, + "restore_preset_many" to R.string.restore_preset_many, + "restore_preset_one" to R.string.restore_preset_one, + "restore_radio_many" to R.string.restore_radio_many, + "restore_radio_one" to R.string.restore_radio_one, + "restore_watch_many" to R.string.restore_watch_many, + "restore_watch_one" to R.string.restore_watch_one, + "rssi_session_range" to R.string.rssi_session_range, + "scan_service_fast_scan_developer_options" to R.string.scan_service_fast_scan_developer_options, + "scan_service_fieldwatch_scanning" to R.string.scan_service_fieldwatch_scanning, + "scan_service_passive_wi_fi_and_bluetooth_scan_status" to R.string.scan_service_passive_wi_fi_and_bluetooth_scan_status, + "scan_service_scanning" to R.string.scan_service_scanning, + "scan_service_starting_radios" to R.string.scan_service_starting_radios, + "scan_service_stop" to R.string.scan_service_stop, + "scan_service_wi_fi_ble_signatures" to R.string.scan_service_wi_fi_ble_signatures, + "settings_exchange_not_a_fieldwatch_settings_pack_export_from" to R.string.settings_exchange_not_a_fieldwatch_settings_pack_export_from, + "settings_exchange_not_a_fieldwatch_settings_pack_open_a" to R.string.settings_exchange_not_a_fieldwatch_settings_pack_open_a, + "settings_exchange_that_is_a_signature_pack_use_import" to R.string.settings_exchange_that_is_a_signature_pack_use_import, + "settings_exchange_this_file_is_empty" to R.string.settings_exchange_this_file_is_empty, + "signature_candidates_a_catalog_signature" to R.string.signature_candidates_a_catalog_signature, + "signature_candidates_advertiser" to R.string.signature_candidates_advertiser, + "signature_candidates_advertisers" to R.string.signature_candidates_advertisers, + "signature_candidates_already_tagged" to R.string.signature_candidates_already_tagged, + "signature_candidates_ap" to R.string.signature_candidates_ap, + "signature_candidates_aps" to R.string.signature_candidates_aps, + "signature_candidates_company_0x_04x" to R.string.signature_candidates_company_0x_04x, + "signature_candidates_ieee_oui" to R.string.signature_candidates_ieee_oui, + "signature_candidates_in_the_log" to R.string.signature_candidates_in_the_log, + "signature_candidates_in_the_log_matched_shared_on_air" to R.string.signature_candidates_in_the_log_matched_shared_on_air, + "signature_candidates_in_the_log_on_the_air_now" to R.string.signature_candidates_in_the_log_on_the_air_now, + "signature_candidates_manufacturer_data_prefix" to R.string.signature_candidates_manufacturer_data_prefix, + "signature_candidates_matched_a_second_signature_can_still_dual" to R.string.signature_candidates_matched_a_second_signature_can_still_dual, + "signature_candidates_mfg_data" to R.string.signature_candidates_mfg_data, + "signature_candidates_mfg_id" to R.string.signature_candidates_mfg_id, + "signature_candidates_name_glob" to R.string.signature_candidates_name_glob, + "signature_candidates_no_other_mac_in_the_log_or" to R.string.signature_candidates_no_other_mac_in_the_log_or, + "signature_candidates_no_unique_on_air_id_to_cluster" to R.string.signature_candidates_no_unique_on_air_id_to_cluster, + "signature_candidates_on_air_id" to R.string.signature_candidates_on_air_id, + "signature_candidates_on_the_air_now" to R.string.signature_candidates_on_the_air_now, + "signature_candidates_possible_family" to R.string.signature_candidates_possible_family, + "signature_candidates_rotating_log" to R.string.signature_candidates_rotating_log, + "signature_candidates_same_ieee_oui_on_stable_bssids_not" to R.string.signature_candidates_same_ieee_oui_on_stable_bssids_not, + "signature_candidates_same_manufacturer_data_prefix_on_ble_advertisers" to R.string.signature_candidates_same_manufacturer_data_prefix_on_ble_advertisers, + "signature_candidates_same_name_glob_on_not_a_house" to R.string.signature_candidates_same_name_glob_on_not_a_house, + "signature_candidates_same_on_that_is_a_catalog_pattern" to R.string.signature_candidates_same_on_that_is_a_catalog_pattern, + "signature_candidates_same_on_thin_sample_a_possible_catalog" to R.string.signature_candidates_same_on_thin_sample_a_possible_catalog, + "signature_candidates_same_service_uuid_on_ble_advertisers" to R.string.signature_candidates_same_service_uuid_on_ble_advertisers, + "signature_candidates_same_vendor_ie_on_bssids_not_wps" to R.string.signature_candidates_same_vendor_ie_on_bssids_not_wps, + "signature_candidates_service_uuid" to R.string.signature_candidates_service_uuid, + "signature_candidates_strong_family" to R.string.signature_candidates_strong_family, + "signature_candidates_svc_contains" to R.string.signature_candidates_svc_contains, + "signature_candidates_this_radio_only" to R.string.signature_candidates_this_radio_only, + "signature_candidates_vendor_ie" to R.string.signature_candidates_vendor_ie, + "signature_candidates_vendor_ie_2" to R.string.signature_candidates_vendor_ie_2, + "signature_candidates_vendor_ie_3" to R.string.signature_candidates_vendor_ie_3, + "signature_exchange_added" to R.string.signature_exchange_added, + "signature_exchange_added_renamed" to R.string.signature_exchange_added_renamed, + "signature_exchange_merged_extra_rules_on" to R.string.signature_exchange_merged_extra_rules_on, + "signature_exchange_not_a_fieldwatch_signature_pack_export_from" to R.string.signature_exchange_not_a_fieldwatch_signature_pack_export_from, + ) + + private fun group20(): Map = mapOf( + "signature_exchange_not_a_fieldwatch_signature_pack_open_a" to R.string.signature_exchange_not_a_fieldwatch_signature_pack_open_a, + "signature_exchange_nothing_new_already_on_this_phone" to R.string.signature_exchange_nothing_new_already_on_this_phone, + "signature_exchange_nothing_to_import" to R.string.signature_exchange_nothing_to_import, + "signature_exchange_skipped_already_present" to R.string.signature_exchange_skipped_already_present, + "signature_exchange_this_file_is_empty" to R.string.signature_exchange_this_file_is_empty, + "signature_exchange_this_pack_has_no_signatures" to R.string.signature_exchange_this_pack_has_no_signatures, + "signature_exchange_this_pack_has_no_stock_signatures" to R.string.signature_exchange_this_pack_has_no_stock_signatures, + "sit_default_name_pattern" to R.string.sit_default_name_pattern, + "sit_diff_a_mac_alert_or_a_signature_alert" to R.string.sit_diff_a_mac_alert_or_a_signature_alert, + "sit_diff_aircraft" to R.string.sit_diff_aircraft, + "sit_diff_caps" to R.string.sit_diff_caps, + "sit_diff_decoded_value_changed" to R.string.sit_diff_decoded_value_changed, + "sit_diff_extra_attention" to R.string.sit_diff_extra_attention, + "sit_diff_extra_attention_2" to R.string.sit_diff_extra_attention_2, + "sit_diff_fieldwatch_sit_compare" to R.string.sit_diff_fieldwatch_sit_compare, + "sit_diff_flood" to R.string.sit_diff_flood, + "sit_diff_in_both" to R.string.sit_diff_in_both, + "sit_diff_in_both_2" to R.string.sit_diff_in_both_2, + "sit_diff_last_15_minutes_in_memory_about_400" to R.string.sit_diff_last_15_minutes_in_memory_about_400, + "sit_diff_last_15_minutes_is_the_live_ram" to R.string.sit_diff_last_15_minutes_is_the_live_ram, + "sit_diff_marked_mine" to R.string.sit_diff_marked_mine, + "sit_diff_marked_mine_2" to R.string.sit_diff_marked_mine_2, + "sit_diff_marked_mine_3" to R.string.sit_diff_marked_mine_3, + "sit_diff_n_radios_n" to R.string.sit_diff_n_radios_n, + "sit_diff_named_window_up_to" to R.string.sit_diff_named_window_up_to, + "sit_diff_none" to R.string.sit_diff_none, + "sit_diff_north_up_line_is_this_phone" to R.string.sit_diff_north_up_line_is_this_phone, + "sit_diff_observer_notes" to R.string.sit_diff_observer_notes, + "sit_diff_only_in_second_sit" to R.string.sit_diff_only_in_second_sit, + "sit_diff_only_in_second_sit_2" to R.string.sit_diff_only_in_second_sit_2, + "sit_diff_only_in_second_sit_3" to R.string.sit_diff_only_in_second_sit_3, + "sit_diff_only_in_this_sit" to R.string.sit_diff_only_in_this_sit, + "sit_diff_only_in_this_sit_2" to R.string.sit_diff_only_in_this_sit_2, + "sit_diff_only_in_this_sit_3" to R.string.sit_diff_only_in_this_sit_3, + "sit_diff_only_in_this_sit_only_in_the" to R.string.sit_diff_only_in_this_sit_only_in_the, + "sit_diff_operator_path" to R.string.sit_diff_operator_path, + "sit_diff_operator_paths" to R.string.sit_diff_operator_paths, + "sit_diff_prompt_1_disclaimer_repeat_the_experimental_use_disclaimer" to R.string.sit_diff_prompt_1_disclaimer_repeat_the_experimental_use_disclaimer, + "sit_diff_prompt_2_what_the_onboard_compare_already_established" to R.string.sit_diff_prompt_2_what_the_onboard_compare_already_established, + "sit_diff_prompt_3_what_the_numbers_add_overlap_both" to R.string.sit_diff_prompt_3_what_the_numbers_add_overlap_both, + "sit_diff_prompt_4_exclusive_extra_attention_and_named_radios" to R.string.sit_diff_prompt_4_exclusive_extra_attention_and_named_radios, + "sit_diff_prompt_5_what_another_sit_or_hunt_would" to R.string.sit_diff_prompt_5_what_another_sit_or_hunt_would, + "sit_diff_prompt_a_decoded_live_value_on_a_row" to R.string.sit_diff_prompt_a_decoded_live_value_on_a_row, + "sit_diff_prompt_a_flood_note_is_a_burst_of" to R.string.sit_diff_prompt_a_flood_note_is_a_burst_of, + "sit_diff_prompt_a_radio_marked_mine_was_claimed_by" to R.string.sit_diff_prompt_a_radio_marked_mine_was_claimed_by, + "sit_diff_prompt_an_aircraft_block_is_positions_the_radio" to R.string.sit_diff_prompt_an_aircraft_block_is_positions_the_radio, + "sit_diff_prompt_cap_note_last_15_minutes_is_live" to R.string.sit_diff_prompt_cap_note_last_15_minutes_is_live, + "sit_diff_prompt_constraints_you_must_respect" to R.string.sit_diff_prompt_constraints_you_must_respect, + "sit_diff_prompt_do_not_give_safety_advice_do_not" to R.string.sit_diff_prompt_do_not_give_safety_advice_do_not, + "sit_diff_prompt_end_of_working_data" to R.string.sit_diff_prompt_end_of_working_data, + "sit_diff_prompt_exclusive_extra_attention" to R.string.sit_diff_prompt_exclusive_extra_attention, + "sit_diff_prompt_exclusive_named_radios" to R.string.sit_diff_prompt_exclusive_named_radios, + "sit_diff_prompt_extra_attention" to R.string.sit_diff_prompt_extra_attention, + "sit_diff_prompt_extra_attention_signature_matches_are_hypotheses_not" to R.string.sit_diff_prompt_extra_attention_signature_matches_are_hypotheses_not, + "sit_diff_prompt_gps_stamps_if_present_are_this_phone" to R.string.sit_diff_prompt_gps_stamps_if_present_are_this_phone, + "sit_diff_prompt_hear_only_kind_mac_ble_rotation_is" to R.string.sit_diff_prompt_hear_only_kind_mac_ble_rotation_is, + "sit_diff_prompt_in_both" to R.string.sit_diff_prompt_in_both, + "sit_diff_prompt_in_both_by_radio_wi_fi_ble" to R.string.sit_diff_prompt_in_both_by_radio_wi_fi_ble, + "sit_diff_prompt_last_15_minutes_vs_a_named_sit" to R.string.sit_diff_prompt_last_15_minutes_vs_a_named_sit, + "sit_diff_prompt_marked_mine" to R.string.sit_diff_prompt_marked_mine, + "sit_diff_prompt_n_n_truncated_for_share_sheet_size" to R.string.sit_diff_prompt_n_n_truncated_for_share_sheet_size, + "sit_diff_prompt_named_sit_up_to" to R.string.sit_diff_prompt_named_sit_up_to, + "sit_diff_prompt_none" to R.string.sit_diff_prompt_none, + "sit_diff_prompt_observer_notes" to R.string.sit_diff_prompt_observer_notes, + "sit_diff_prompt_onboard_compare_verbatim_already_shown_to_the" to R.string.sit_diff_prompt_onboard_compare_verbatim_already_shown_to_the, + "sit_diff_prompt_only_in_second_sit" to R.string.sit_diff_prompt_only_in_second_sit, + "sit_diff_prompt_only_in_second_sit_by_radio_wi" to R.string.sit_diff_prompt_only_in_second_sit_by_radio_wi, + "sit_diff_prompt_only_in_this_sit" to R.string.sit_diff_prompt_only_in_this_sit, + "sit_diff_prompt_only_in_this_sit_by_radio_wi" to R.string.sit_diff_prompt_only_in_this_sit_by_radio_wi, + "sit_diff_prompt_only_in_this_sit_only_in_second" to R.string.sit_diff_prompt_only_in_this_sit_only_in_second, + "sit_diff_prompt_presence_is_not_co_travel_do_not" to R.string.sit_diff_prompt_presence_is_not_co_travel_do_not, + "sit_diff_prompt_privacy_mode_mac_tails_are_gps_coordinates" to R.string.sit_diff_prompt_privacy_mode_mac_tails_are_gps_coordinates, + "sit_diff_prompt_ram_400" to R.string.sit_diff_prompt_ram_400, + "sit_diff_prompt_rand" to R.string.sit_diff_prompt_rand, + "sit_diff_prompt_second_sit_radios" to R.string.sit_diff_prompt_second_sit_radios, + "sit_diff_prompt_takeaway_required_last_line_one_sentence_starting" to R.string.sit_diff_prompt_takeaway_required_last_line_one_sentence_starting, + "sit_diff_prompt_the_onboard_compare_verbatim_below_already_split" to R.string.sit_diff_prompt_the_onboard_compare_verbatim_below_already_split, + "sit_diff_prompt_this_sit_radios" to R.string.sit_diff_prompt_this_sit_radios, + "sit_diff_prompt_treat_this_paste_as_operationally_sensitive" to R.string.sit_diff_prompt_treat_this_paste_as_operationally_sensitive, + "sit_diff_prompt_wi_fi_rows_are_access_points_only" to R.string.sit_diff_prompt_wi_fi_rows_are_access_points_only, + "sit_diff_prompt_working_data_for_the_addendum_do_not" to R.string.sit_diff_prompt_working_data_for_the_addendum_do_not, + "sit_diff_prompt_write_complete_sentences_headings_as_below_short" to R.string.sit_diff_prompt_write_complete_sentences_headings_as_below_short, + "sit_diff_prompt_write_the_addendum_now_following_your_output" to R.string.sit_diff_prompt_write_the_addendum_now_following_your_output, + "sit_diff_prompt_you_are_a_field_rf_analyst_for" to R.string.sit_diff_prompt_you_are_a_field_rf_analyst_for, + "sit_diff_prompt_your_output_required_this_is_the_addendum" to R.string.sit_diff_prompt_your_output_required_this_is_the_addendum, + "sit_diff_radios_this_phone_heard_kind_mac_ble" to R.string.sit_diff_radios_this_phone_heard_kind_mac_ble, + "sit_diff_radios_you_marked_mine_heard_in_either" to R.string.sit_diff_radios_you_marked_mine_heard_in_either, + "sit_diff_ram_400_vs_sit" to R.string.sit_diff_ram_400_vs_sit, + "sit_diff_second_radios" to R.string.sit_diff_second_radios, + "sit_diff_second_sit" to R.string.sit_diff_second_sit, + "sit_diff_second_sit_2" to R.string.sit_diff_second_sit_2, + "sit_diff_sit_compare" to R.string.sit_diff_sit_compare, + "sit_diff_sit_compare_2" to R.string.sit_diff_sit_compare_2, + "sit_diff_this_radios" to R.string.sit_diff_this_radios, + "sit_diff_this_sit" to R.string.sit_diff_this_sit, + "sit_diff_this_sit_2" to R.string.sit_diff_this_sit_2, + "sit_diff_two_walks_on_one_north_up_frame" to R.string.sit_diff_two_walks_on_one_north_up_frame, + "sit_diff_unmatched" to R.string.sit_diff_unmatched, + "sit_diff_vs" to R.string.sit_diff_vs, + "sit_diff_windows" to R.string.sit_diff_windows, + ) + + private fun group21(): Map = mapOf( + "sit_diff_your_captions_on_radios_heard_in_either" to R.string.sit_diff_your_captions_on_radios_heard_in_either, + "sit_drop_warning" to R.string.sit_drop_warning, + "sit_dropped_oldest" to R.string.sit_dropped_oldest, + "sit_duration_hours" to R.string.sit_duration_hours, + "sit_duration_minutes" to R.string.sit_duration_minutes, + "sit_duration_seconds" to R.string.sit_duration_seconds, + "tak_host_unresolved" to R.string.tak_host_unresolved, + "tak_no_eligible" to R.string.tak_no_eligible, + "tak_off" to R.string.tak_off, + "tak_privacy_paused" to R.string.tak_privacy_paused, + "tak_send_failed" to R.string.tak_send_failed, + "wifi_radio_wi_fi_next_s" to R.string.wifi_radio_wi_fi_next_s, + "wifi_radio_wi_fi_scanning" to R.string.wifi_radio_wi_fi_scanning, + "wifi_radio_wi_fi_waiting_on_os" to R.string.wifi_radio_wi_fi_waiting_on_os, + ) +} diff --git a/app/src/main/java/app/fieldwatch/i18n/TextRuntime.kt b/app/src/main/java/app/fieldwatch/i18n/TextRuntime.kt new file mode 100644 index 0000000..4d266c1 --- /dev/null +++ b/app/src/main/java/app/fieldwatch/i18n/TextRuntime.kt @@ -0,0 +1,17 @@ +package app.fieldwatch.i18n + +import java.util.Locale + +/** Android installs a resource renderer; JVM/domain callers retain the English contract. */ +object TextRuntime { + @Volatile + var renderer: ((String, String, Array) -> String)? = null + @Volatile + var localeProvider: () -> Locale = { Locale.US } + + fun english(pattern: String, args: Array): String = + if (args.isEmpty()) pattern else String.format(Locale.US, pattern, *args) +} + +fun localized(key: String, english: String, vararg args: Any?): String = + TextRuntime.renderer?.invoke(key, english, args) ?: TextRuntime.english(english, args) diff --git a/app/src/main/java/app/fieldwatch/i18n/WatchSpeech.kt b/app/src/main/java/app/fieldwatch/i18n/WatchSpeech.kt new file mode 100644 index 0000000..c65c81c --- /dev/null +++ b/app/src/main/java/app/fieldwatch/i18n/WatchSpeech.kt @@ -0,0 +1,22 @@ +package app.fieldwatch.i18n + +import app.fieldwatch.domain.* + +fun localizedTestWatchPhrase(what: AlertVoiceWhat): String = when (what) { + AlertVoiceWhat.CLASS -> SignatureClass.FINDER.displayLabel() + AlertVoiceWhat.SIGNATURE -> DefaultCatalog.fleets().first { it.id == "fleet-airtag" }.displayName() + AlertVoiceWhat.BOTH -> "${SignatureClass.FINDER.displayLabel()}, ${localizedTestWatchPhrase(AlertVoiceWhat.SIGNATURE)}" +} + +fun localizedWatchPhrase(device: Sighting, fleets: List, what: AlertVoiceWhat, target: WatchTarget?): String { + if (target?.deviceKey != null) return spokenWatchPhrase(device, fleets, what, target) + val id = target?.fleetId?.takeIf { it in device.fleetIds } ?: device.fleetIds.firstOrNull() + val fleet = fleets.firstOrNull { it.id == id } ?: return localized("label_unmatched", "Unmatched") + val cls = fleet.kind.displayLabel() + val name = speakableWatchName(fleet.displayName()) + return when (what) { + AlertVoiceWhat.CLASS -> cls + AlertVoiceWhat.SIGNATURE -> name + AlertVoiceWhat.BOTH -> if (cls.equals(name, true)) cls else "$cls, $name" + } +} diff --git a/app/src/main/java/app/fieldwatch/radio/BleAdParser.kt b/app/src/main/java/app/fieldwatch/radio/BleAdParser.kt index a51e02a..c28613f 100644 --- a/app/src/main/java/app/fieldwatch/radio/BleAdParser.kt +++ b/app/src/main/java/app/fieldwatch/radio/BleAdParser.kt @@ -28,7 +28,11 @@ object BleAdParser { val raw = result.periodicAdvertisingInterval if (raw > 0) raw * 1.25 else null } else null - val deviceClass = parsed.deviceClass ?: result.device?.bluetoothClass?.let { btClass24(it) } + val deviceClass = parsed.deviceClass ?: try { + result.device?.bluetoothClass?.let { btClass24(it) } + } catch (_: SecurityException) { + null + } return RadioFacts( txPowerDbm = txScan ?: txAdv, advFlags = flags, diff --git a/app/src/main/java/app/fieldwatch/radio/BleRadio.kt b/app/src/main/java/app/fieldwatch/radio/BleRadio.kt index 8993e7f..11e5745 100644 --- a/app/src/main/java/app/fieldwatch/radio/BleRadio.kt +++ b/app/src/main/java/app/fieldwatch/radio/BleRadio.kt @@ -14,6 +14,7 @@ import android.util.Log import app.fieldwatch.domain.Observation import app.fieldwatch.domain.RadioKind import app.fieldwatch.domain.ScanIntensity +import app.fieldwatch.i18n.localized import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong @@ -67,7 +68,7 @@ class BleRadio( failStreak = (failStreak + 1).coerceAtMost(5) val backoff = (4_000L * (1L shl (failStreak - 1))).coerceAtMost(30_000L) nextRetryAt.set(System.currentTimeMillis() + backoff) - lastError = "BLE scan failed ($errorCode)" + lastError = localized("ble_radio_scan_failed", "BLE scan failed (%1\$s)", errorCode) hint = "BLE retrying" demoted = true onError(lastError!!) @@ -81,7 +82,7 @@ class BleRadio( val adapter = manager.adapter if (adapter == null || !adapter.isEnabled) { running.set(false) - lastError = "Bluetooth is off" + lastError = localized("ble_radio_bluetooth_off", "Bluetooth is off") hint = "Bluetooth is off" onError(lastError!!) nextRetryAt.set(now + 8_000L) @@ -90,7 +91,7 @@ class BleRadio( val next = adapter.bluetoothLeScanner if (next == null) { running.set(false) - lastError = "BLE scanner unavailable" + lastError = localized("ble_radio_scanner_unavailable", "BLE scanner unavailable") hint = "BLE unavailable" onError(lastError!!) nextRetryAt.set(now + 8_000L) @@ -121,7 +122,7 @@ class BleRadio( failStreak = (failStreak + 1).coerceAtMost(5) val backoff = (4_000L * (1L shl (failStreak - 1))).coerceAtMost(30_000L) nextRetryAt.set(System.currentTimeMillis() + backoff) - lastError = ok.exceptionOrNull()?.message ?: "BLE start failed" + lastError = ok.exceptionOrNull()?.message ?: localized("ble_radio_start_failed", "BLE start failed") hint = "BLE retrying" demoted = true onError(lastError!!) @@ -140,7 +141,15 @@ class BleRadio( fun holding(): Boolean = !running.get() - fun statusHint(): String = hint + // Keep the radio state independent of translated text and render the current language on read. + fun statusHint(): String = when (hint) { + "BLE retrying" -> localized("ble_radio_retrying", "BLE retrying") + "Bluetooth is off" -> localized("ble_radio_bluetooth_off", "Bluetooth is off") + "BLE unavailable" -> localized("ble_radio_unavailable", "BLE unavailable") + "BLE cycling" -> localized("ble_radio_cycling", "BLE cycling") + "BLE parked · restarting" -> localized("ble_radio_restarting", "BLE parked · restarting") + else -> hint + } fun restartBackoffMs(): Long = restMs diff --git a/app/src/main/java/app/fieldwatch/radio/ScanService.kt b/app/src/main/java/app/fieldwatch/radio/ScanService.kt index 6fe7f81..b5966f7 100644 --- a/app/src/main/java/app/fieldwatch/radio/ScanService.kt +++ b/app/src/main/java/app/fieldwatch/radio/ScanService.kt @@ -1,5 +1,7 @@ package app.fieldwatch.radio +import app.fieldwatch.i18n.localized + import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -99,7 +101,7 @@ class ScanService : LifecycleService() { val hint = listOf( wifi.throttleHint(), ble.statusHint(), - if (fastBlocked) "Wi-Fi fast scan needs Developer options" else "", + if (fastBlocked) localized("scan_service_fast_scan_developer_options", "Wi-Fi fast scan needs Developer options") else "", ) .filter { it.isNotBlank() } .joinToString(" · ") @@ -257,7 +259,7 @@ class ScanService : LifecycleService() { } private fun startAsForeground() { - val notification = buildNotification("Starting radios…") + val notification = buildNotification(localized("scan_service_starting_radios", "Starting radios…")) if (Build.VERSION.SDK_INT >= 34) { startForeground( NOTIF_ID, @@ -280,8 +282,9 @@ class ScanService : LifecycleService() { val now = System.currentTimeMillis() if (now - lastNotifAt < 2_500L) return lastNotifAt = now + ensureChannel() val stats = (application as FieldwatchApp).devices.stats.value - val text = "${stats.wifiNow} Wi-Fi · ${stats.bleNow} BLE · ${stats.namedNow} signatures" + val text = localized("scan_service_wi_fi_ble_signatures", "%1\$s Wi-Fi · %2\$s BLE · %3\$s signatures", stats.wifiNow, stats.bleNow, stats.namedNow) val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager nm.notify(NOTIF_ID, buildNotification(text)) } @@ -301,12 +304,12 @@ class ScanService : LifecycleService() { ) return NotificationCompat.Builder(this, CHANNEL) .setSmallIcon(R.drawable.ic_stat_fieldwatch) - .setContentTitle("Fieldwatch scanning") + .setContentTitle(localized("scan_service_fieldwatch_scanning", "Fieldwatch scanning")) .setContentText(text) .setOngoing(true) .setOnlyAlertOnce(true) .setContentIntent(launch) - .addAction(0, "Stop", stop) + .addAction(0, localized("scan_service_stop", "Stop"), stop) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .build() } @@ -361,8 +364,8 @@ class ScanService : LifecycleService() { if (Build.VERSION.SDK_INT < 26) return val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager nm.createNotificationChannel( - NotificationChannel(CHANNEL, "Scanning", NotificationManager.IMPORTANCE_LOW).apply { - description = "Passive Wi-Fi and Bluetooth scan status" + NotificationChannel(CHANNEL, localized("scan_service_scanning", "Scanning"), NotificationManager.IMPORTANCE_LOW).apply { + description = localized("scan_service_passive_wi_fi_and_bluetooth_scan_status", "Passive Wi-Fi and Bluetooth scan status") setShowBadge(false) }, ) diff --git a/app/src/main/java/app/fieldwatch/radio/WifiIeParser.kt b/app/src/main/java/app/fieldwatch/radio/WifiIeParser.kt index 428735f..9da25ef 100644 --- a/app/src/main/java/app/fieldwatch/radio/WifiIeParser.kt +++ b/app/src/main/java/app/fieldwatch/radio/WifiIeParser.kt @@ -210,6 +210,7 @@ object WifiIeParser { return (bytes[offset].toInt() and 0xFF) or ((bytes[offset + 1].toInt() and 0xFF) shl 8) } + @androidx.annotation.RequiresApi(30) private fun ieBytes(ie: ScanResult.InformationElement): ByteArray? { val raw = runCatching { ie.bytes }.getOrNull() ?: return null val copy = ByteArray(raw.remaining()) diff --git a/app/src/main/java/app/fieldwatch/radio/WifiRadio.kt b/app/src/main/java/app/fieldwatch/radio/WifiRadio.kt index 3fd19fc..db50e76 100644 --- a/app/src/main/java/app/fieldwatch/radio/WifiRadio.kt +++ b/app/src/main/java/app/fieldwatch/radio/WifiRadio.kt @@ -1,5 +1,7 @@ package app.fieldwatch.radio +import app.fieldwatch.i18n.localized + import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context @@ -120,11 +122,11 @@ class WifiRadio( fun throttleHint(): String { val now = System.currentTimeMillis() return when { - failStreak.get() > 0 -> "Wi-Fi waiting on OS" - awaitingScan.get() -> "Wi-Fi scanning" + failStreak.get() > 0 -> localized("wifi_radio_wi_fi_waiting_on_os", "Wi-Fi waiting on OS") + awaitingScan.get() -> localized("wifi_radio_wi_fi_scanning", "Wi-Fi scanning") nextAllowedAt.get() - now > 1_500L -> { val sec = ((nextAllowedAt.get() - now + 999L) / 1000L).toInt().coerceAtLeast(1) - "Wi-Fi next ${sec}s" + localized("wifi_radio_wi_fi_next_s", "Wi-Fi next %1\$ss", sec) } else -> "" } diff --git a/app/src/main/java/app/fieldwatch/ui/ClassGlyphs.kt b/app/src/main/java/app/fieldwatch/ui/ClassGlyphs.kt index ff90b04..d0edc77 100644 --- a/app/src/main/java/app/fieldwatch/ui/ClassGlyphs.kt +++ b/app/src/main/java/app/fieldwatch/ui/ClassGlyphs.kt @@ -1,5 +1,11 @@ package app.fieldwatch.ui +import app.fieldwatch.i18n.displayLabel + +import app.fieldwatch.i18n.appText + +import app.fieldwatch.R + import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -83,7 +89,7 @@ fun RadioClassBadge( Box(contentAlignment = Alignment.Center) { Icon( ClassGlyphs.of(classKind), - contentDescription = classKind?.label() ?: "Unmatched", + contentDescription = classKind?.displayLabel() ?: appText(R.string.class_glyphs_unmatched), modifier = Modifier.size(if (compact) 14.dp else 16.dp), tint = accent, ) @@ -99,7 +105,7 @@ fun RadioKindMark( ) { Icon( if (kind == RadioKind.WIFI) Icons.Outlined.Wifi else Icons.Outlined.Bluetooth, - contentDescription = if (kind == RadioKind.WIFI) "Wi-Fi access point" else "BLE advertiser", + contentDescription = if (kind == RadioKind.WIFI) appText(R.string.class_glyphs_wi_fi_access_point) else appText(R.string.class_glyphs_ble_advertiser), modifier = modifier.size(size), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/app/fieldwatch/ui/FieldwatchAppUi.kt b/app/src/main/java/app/fieldwatch/ui/FieldwatchAppUi.kt index b971439..5c6f49d 100644 --- a/app/src/main/java/app/fieldwatch/ui/FieldwatchAppUi.kt +++ b/app/src/main/java/app/fieldwatch/ui/FieldwatchAppUi.kt @@ -1,5 +1,11 @@ package app.fieldwatch.ui +import app.fieldwatch.i18n.displayLabel + +import app.fieldwatch.i18n.appText + +import app.fieldwatch.R + import android.content.Intent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -154,14 +160,15 @@ private fun DisclaimerGate(onAccept: () -> Unit) { .verticalScroll(rememberScrollState()) .padding(horizontal = 28.dp, vertical = 24.dp), ) { + app.fieldwatch.ui.component.LanguagePicker() Text( - "Disclaimer and license", + appText(R.string.fieldwatch_app_ui_disclaimer_and_license), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold, color = ink, ) Text( - "Disclaimer", + appText(R.string.fieldwatch_app_ui_disclaimer), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = ink, @@ -205,7 +212,7 @@ private fun DisclaimerGate(onAccept: () -> Unit) { ) { Checkbox(checked = agreed, onCheckedChange = null) Text( - "I have read this and I agree", + appText(R.string.fieldwatch_app_ui_i_have_read_this_and_i_agree), style = MaterialTheme.typography.bodyMedium, color = ink, modifier = Modifier.padding(start = 8.dp), @@ -217,7 +224,7 @@ private fun DisclaimerGate(onAccept: () -> Unit) { modifier = Modifier .fillMaxWidth() .padding(top = 16.dp), - ) { Text("Continue") } + ) { Text(appText(R.string.fieldwatch_app_ui_continue)) } } } @@ -228,14 +235,14 @@ private fun PermissionGate(onRequest: () -> Unit) { verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("Fieldwatch needs the radios", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + Text(appText(R.string.fieldwatch_app_ui_fieldwatch_needs_the_radios), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) Text( - "Location, nearby Wi-Fi, Bluetooth scan, and notifications let Fieldwatch passively watch advertised networks and BLE devices. Nothing is transmitted.", + appText(R.string.fieldwatch_app_ui_location_nearby_wi_fi_bluetooth_scan_and), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 12.dp, bottom = 20.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Button(onClick = onRequest) { Text("Grant permissions") } + Button(onClick = onRequest) { Text(appText(R.string.fieldwatch_app_ui_grant_permissions)) } } } @@ -266,29 +273,13 @@ private fun FieldwatchShell(state: FieldwatchUi, vm: FieldwatchViewModel) { if (export.active) { AlertDialog( onDismissRequest = { }, - title = { - val m = export.message.lowercase() - Text( - when { - "sit compare" in m && "pdf" in m -> "Sit compare PDF" - "sit compare" in m && "ai" in m -> "Sit compare AI Export" - "sit compare" in m -> "Sit compare" - "ai export" in m || "ai export" in m -> "AI Export" - "pdf" in m -> "Debrief PDF" - "debrief" in m -> "Debrief" - else -> "Export" - }, - ) - }, + title = { Text(appText(export.job.title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(export.message.ifBlank { "Please wait…" }) - if ("debrief" !in export.message.lowercase() && - "ai export" !in export.message.lowercase() && - "sit compare" !in export.message.lowercase() - ) { + Text(export.message.ifBlank { appText(R.string.fieldwatch_app_ui_please_wait) }) + if (export.job.pausesLogging) { Text( - "Live logging is paused until this finishes. Scanning continues.", + appText(R.string.fieldwatch_app_ui_live_logging_is_paused_until_this_finishes), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -310,32 +301,32 @@ private fun FieldwatchShell(state: FieldwatchUi, vm: FieldwatchViewModel) { if (export.error != null) { AlertDialog( onDismissRequest = vm::consumeExportNotice, - title = { Text(export.errorTitle ?: "Could not export") }, + title = { Text(export.errorTitle ?: appText(R.string.fieldwatch_app_ui_could_not_export)) }, text = { Text(export.error ?: "") }, confirmButton = { - TextButton(onClick = vm::consumeExportNotice) { Text("OK") } + TextButton(onClick = vm::consumeExportNotice) { Text(appText(R.string.fieldwatch_app_ui_ok)) } }, ) } if (export.saved) { AlertDialog( onDismissRequest = vm::consumeExportNotice, - title = { Text("Log saved") }, + title = { Text(appText(R.string.fieldwatch_app_ui_log_saved)) }, text = { - Text("The file was written to the folder you picked. In the system picker, use the menu to choose the SD card if you want it off internal storage.") + Text(appText(R.string.fieldwatch_app_ui_the_file_was_written_to_the_folder)) }, confirmButton = { - TextButton(onClick = vm::consumeExportNotice) { Text("OK") } + TextButton(onClick = vm::consumeExportNotice) { Text(appText(R.string.fieldwatch_app_ui_ok)) } }, ) } if (export.cleared) { AlertDialog( onDismissRequest = vm::consumeExportNotice, - title = { Text("Log cleared") }, - text = { Text("Rotated files were deleted. New detections will start a fresh log.") }, + title = { Text(appText(R.string.fieldwatch_app_ui_log_cleared)) }, + text = { Text(appText(R.string.fieldwatch_app_ui_rotated_files_were_deleted_new_detections_will)) }, confirmButton = { - TextButton(onClick = vm::consumeExportNotice) { Text("OK") } + TextButton(onClick = vm::consumeExportNotice) { Text(appText(R.string.fieldwatch_app_ui_ok)) } }, ) } @@ -345,7 +336,7 @@ private fun FieldwatchShell(state: FieldwatchUi, vm: FieldwatchViewModel) { title = { Text(export.noticeTitle ?: "") }, text = { Text(export.noticeMessage.orEmpty()) }, confirmButton = { - TextButton(onClick = vm::consumeExportNotice) { Text("OK") } + TextButton(onClick = vm::consumeExportNotice) { Text(appText(R.string.fieldwatch_app_ui_ok)) } }, ) } @@ -396,9 +387,9 @@ private fun FieldwatchShell(state: FieldwatchUi, vm: FieldwatchViewModel) { Text( when { state.sit.open != null && state.displayPaused -> - "FIELDWATCH · SIT · PAUSED" - state.sit.open != null -> "FIELDWATCH · SIT" - state.displayPaused -> "FIELDWATCH · PAUSED" + appText(R.string.fieldwatch_app_ui_fieldwatch_sit_paused) + state.sit.open != null -> appText(R.string.fieldwatch_app_ui_fieldwatch_sit) + state.displayPaused -> appText(R.string.fieldwatch_app_ui_fieldwatch_paused) else -> "FIELDWATCH" }, style = MaterialTheme.typography.titleMedium, @@ -415,7 +406,7 @@ private fun FieldwatchShell(state: FieldwatchUi, vm: FieldwatchViewModel) { val muted = MaterialTheme.colorScheme.onSurfaceVariant HeaderCount(state.wifiNow, Icons.Outlined.Wifi, "Wi-Fi") HeaderCount(state.bleNow, Icons.Outlined.Bluetooth, "BLE") - HeaderCount(state.namedNow, Icons.Outlined.Hub, "signatures") + HeaderCount(state.namedNow, Icons.Outlined.Hub, appText(R.string.fieldwatch_app_ui_signatures)) if (state.throttleHint.isNotBlank()) { Text( "· ${state.throttleHint}", @@ -445,9 +436,9 @@ private fun FieldwatchShell(state: FieldwatchUi, vm: FieldwatchViewModel) { Icons.Outlined.Tune }, if (state.settings.scanControlsExpanded) { - "Hide scan options" + appText(R.string.fieldwatch_app_ui_hide_scan_options) } else { - "Show scan options" + appText(R.string.fieldwatch_app_ui_show_scan_options) }, modifier = Modifier.onGloballyPositioned { tourTargets = tourTargets.copy(tune = it.boundsInRoot()) @@ -503,10 +494,10 @@ private fun FieldwatchShell(state: FieldwatchUi, vm: FieldwatchViewModel) { route == "live" && state.displayPaused -> Icons.Outlined.PlayArrow else -> Icons.Outlined.CellTower }, - if (route == "live" && !state.displayPaused) "Pause display" else "Live", + if (route == "live" && !state.displayPaused) appText(R.string.fieldwatch_app_ui_pause_display) else appText(R.string.fieldwatch_app_ui_live), ) }, - label = if (route == "live" && !state.displayPaused) "Pause" else "Live", + label = if (route == "live" && !state.displayPaused) appText(R.string.fieldwatch_app_ui_pause) else appText(R.string.fieldwatch_app_ui_live), ) FieldwatchNavTab( weight = 1f, @@ -514,7 +505,7 @@ private fun FieldwatchShell(state: FieldwatchUi, vm: FieldwatchViewModel) { onBounds = { tourTargets = tourTargets.copy(filters = it) }, onClick = { nav.navigate("filters") { launchSingleTop = true } }, icon = { Icon(Icons.Outlined.FilterAlt, null) }, - label = "Filters", + label = appText(R.string.fieldwatch_app_ui_filters), ) FieldwatchNavTab( weight = 1.45f, @@ -522,7 +513,7 @@ private fun FieldwatchShell(state: FieldwatchUi, vm: FieldwatchViewModel) { onBounds = { tourTargets = tourTargets.copy(signatures = it) }, onClick = { nav.navigate("fleets") { launchSingleTop = true } }, icon = { Icon(Icons.Outlined.Hub, null) }, - label = "Signatures", + label = appText(R.string.fieldwatch_app_ui_signatures), ) FieldwatchNavTab( weight = 1f, @@ -530,7 +521,7 @@ private fun FieldwatchShell(state: FieldwatchUi, vm: FieldwatchViewModel) { onBounds = { tourTargets = tourTargets.copy(reports = it) }, onClick = { nav.navigate("reports") { launchSingleTop = true } }, icon = { Icon(Icons.Outlined.Description, null) }, - label = "Reports", + label = appText(R.string.fieldwatch_app_ui_reports), ) FieldwatchNavTab( weight = 1.05f, @@ -538,7 +529,7 @@ private fun FieldwatchShell(state: FieldwatchUi, vm: FieldwatchViewModel) { onBounds = { tourTargets = tourTargets.copy(settings = it) }, onClick = { nav.navigate("settings") { launchSingleTop = true } }, icon = { Icon(Icons.Outlined.Settings, null) }, - label = "Settings", + label = appText(R.string.fieldwatch_app_ui_settings), ) } } @@ -683,17 +674,17 @@ private fun FieldwatchShell(state: FieldwatchUi, vm: FieldwatchViewModel) { Scaffold( topBar = { TopAppBar( - title = { Text("Detail") }, + title = { Text(appText(R.string.fieldwatch_app_ui_detail)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, appText(R.string.fieldwatch_app_ui_back)) } }, ) }, ) { pad -> Text( - "No radio selected.", + appText(R.string.fieldwatch_app_ui_no_radio_selected), Modifier.padding(pad).padding(24.dp), ) } @@ -825,20 +816,20 @@ private fun ViewPicker( ) { var openView by remember { mutableStateOf(false) } var openSort by remember { mutableStateOf(false) } - val viewLabel = mode.label() + val viewLabel = mode.displayLabel() var openDecay by remember { mutableStateOf(false) } var openTitle by remember { mutableStateOf(false) } var openSubtitle by remember { mutableStateOf(false) } val sortLabel = when (listSort) { - ListSort.STRENGTH -> if (sort == StrengthSort.AVERAGE) "Strongest · avg ${windowSec}s" else "Strongest" - ListSort.NEWEST -> "Newest heard" - ListSort.NEWEST_ALERT -> "Newest alert" - ListSort.FIRST_SEEN -> "Newest arrival" - ListSort.ARRIVAL -> "New at bottom" - ListSort.NAME -> "Name A–Z" - ListSort.SIGNATURES -> "Signatures first" + ListSort.STRENGTH -> if (sort == StrengthSort.AVERAGE) appText(R.string.fieldwatch_app_ui_strongest_avg_s, windowSec) else appText(R.string.fieldwatch_app_ui_strongest) + ListSort.NEWEST -> appText(R.string.fieldwatch_app_ui_newest_heard) + ListSort.NEWEST_ALERT -> appText(R.string.fieldwatch_app_ui_newest_alert) + ListSort.FIRST_SEEN -> appText(R.string.fieldwatch_app_ui_newest_arrival) + ListSort.ARRIVAL -> appText(R.string.fieldwatch_app_ui_new_at_bottom) + ListSort.NAME -> appText(R.string.fieldwatch_app_ui_name_a_z) + ListSort.SIGNATURES -> appText(R.string.fieldwatch_app_ui_signatures_first) } - val decayLabel = if (decaySec <= 0) "Off" else "Hold ${decaySec}s" + val decayLabel = if (decaySec <= 0) appText(R.string.fieldwatch_app_ui_off) else appText(R.string.fieldwatch_app_ui_hold_s, decaySec) val scroll = rememberScrollState() val panelMax = (maxHeight - 8.dp).coerceAtLeast(140.dp) val surfaceColor = MaterialTheme.colorScheme.surface @@ -861,76 +852,76 @@ private fun ViewPicker( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - "Display", + appText(R.string.fieldwatch_app_ui_display), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) val dropdownPad = Modifier.fillMaxWidth().padding(vertical = 6.dp) ExposedDropdownMenuBox(openView, { openView = it }, dropdownPad) { - FieldwatchDropdownField("View", viewLabel, openView) + FieldwatchDropdownField(appText(R.string.fieldwatch_app_ui_view), viewLabel, openView) ExposedDropdownMenu(openView, { openView = false }) { ViewMode.entries.forEach { item -> DropdownMenuItem( - text = { Text(item.label()) }, + text = { Text(item.displayLabel()) }, onClick = { onChangeView(item); openView = false }, ) } } } ExposedDropdownMenuBox(openSort, { openSort = it }, dropdownPad) { - FieldwatchDropdownField("Sort", sortLabel, openSort) + FieldwatchDropdownField(appText(R.string.fieldwatch_app_ui_sort), sortLabel, openSort) ExposedDropdownMenu(openSort, { openSort = false }) { DropdownMenuItem( - text = { Text("Strongest signal") }, + text = { Text(appText(R.string.fieldwatch_app_ui_strongest_signal)) }, onClick = { onChangeSort(StrengthSort.INSTANT, null); openSort = false }, ) DropdownMenuItem( - text = { Text("Strongest (avg 30s)") }, + text = { Text(appText(R.string.fieldwatch_app_ui_strongest_avg_30s)) }, onClick = { onChangeSort(StrengthSort.AVERAGE, 30); openSort = false }, ) DropdownMenuItem( - text = { Text("Newest heard") }, + text = { Text(appText(R.string.fieldwatch_app_ui_newest_heard)) }, onClick = { onChangeListSort(ListSort.NEWEST); openSort = false }, ) DropdownMenuItem( - text = { Text("Newest alert") }, + text = { Text(appText(R.string.fieldwatch_app_ui_newest_alert)) }, onClick = { onChangeListSort(ListSort.NEWEST_ALERT); openSort = false }, ) DropdownMenuItem( - text = { Text("Newest arrival") }, + text = { Text(appText(R.string.fieldwatch_app_ui_newest_arrival)) }, onClick = { onChangeListSort(ListSort.FIRST_SEEN); openSort = false }, ) DropdownMenuItem( - text = { Text("New at bottom") }, + text = { Text(appText(R.string.fieldwatch_app_ui_new_at_bottom)) }, onClick = { onChangeListSort(ListSort.ARRIVAL); openSort = false }, ) DropdownMenuItem( - text = { Text("Name A–Z") }, + text = { Text(appText(R.string.fieldwatch_app_ui_name_a_z)) }, onClick = { onChangeListSort(ListSort.NAME); openSort = false }, ) DropdownMenuItem( - text = { Text("Signatures first") }, + text = { Text(appText(R.string.fieldwatch_app_ui_signatures_first)) }, onClick = { onChangeListSort(ListSort.SIGNATURES); openSort = false }, ) } } ExposedDropdownMenuBox(openDecay, { openDecay = it }, dropdownPad) { - FieldwatchDropdownField("Brief hold", decayLabel, openDecay) + FieldwatchDropdownField(appText(R.string.fieldwatch_app_ui_brief_hold), decayLabel, openDecay) ExposedDropdownMenu(openDecay, { openDecay = false }) { DropdownMenuItem( - text = { Text("Off — Stale after only") }, + text = { Text(appText(R.string.fieldwatch_app_ui_off_stale_after_only)) }, onClick = { onChangeDecay(0); openDecay = false }, ) listOf(10, 30, 60).forEach { sec -> DropdownMenuItem( - text = { Text("Hold ${sec}s after last packet") }, + text = { Text(appText(R.string.fieldwatch_app_ui_hold_s_after_last_packet, sec)) }, onClick = { onChangeDecay(sec); openDecay = false }, ) } } } ExposedDropdownMenuBox(openTitle, { openTitle = it }, dropdownPad) { - FieldwatchDropdownField("Title line", listLineLabel(titleLine), openTitle) + FieldwatchDropdownField(appText(R.string.fieldwatch_app_ui_title_line), listLineLabel(titleLine), openTitle) ExposedDropdownMenu(openTitle, { openTitle = false }) { listOf(ListLine.ADVERTISED_NAME, ListLine.NAME_AND_TYPE, ListLine.MAC).forEach { item -> DropdownMenuItem( @@ -941,7 +932,7 @@ private fun ViewPicker( } } ExposedDropdownMenuBox(openSubtitle, { openSubtitle = it }, dropdownPad) { - FieldwatchDropdownField("Subtitle line", listLineLabel(subtitleLine), openSubtitle) + FieldwatchDropdownField(appText(R.string.fieldwatch_app_ui_subtitle_line), listLineLabel(subtitleLine), openSubtitle) ExposedDropdownMenu(openSubtitle, { openSubtitle = false }) { listOf( ListLine.ADVERTISED_NAME, @@ -956,10 +947,10 @@ private fun ViewPicker( } } } - OptionSwitch("RSSI bars", showBar, onToggleBar) - OptionSwitch("Signature names", showFleet, onToggleFleet) - OptionSwitch("Frequency", showFrequency, onToggleFrequency) - OptionSwitch("First / last seen", showSeenTimes, onToggleSeenTimes) + OptionSwitch(appText(R.string.fieldwatch_app_ui_rssi_bars), showBar, onToggleBar) + OptionSwitch(appText(R.string.fieldwatch_app_ui_signature_names), showFleet, onToggleFleet) + OptionSwitch(appText(R.string.fieldwatch_app_ui_frequency), showFrequency, onToggleFrequency) + OptionSwitch(appText(R.string.fieldwatch_app_ui_first_last_seen), showSeenTimes, onToggleSeenTimes) } if (scroll.canScrollForward) { Box( @@ -976,7 +967,7 @@ private fun ViewPicker( ) { Icon( Icons.Outlined.ExpandMore, - contentDescription = "More display options below", + contentDescription = appText(R.string.fieldwatch_app_ui_more_display_options_below), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp), ) @@ -987,10 +978,10 @@ private fun ViewPicker( } private fun listLineLabel(line: ListLine): String = when (line) { - ListLine.ADVERTISED_NAME -> "Advertised name" - ListLine.NAME_AND_TYPE -> "Name + type" - ListLine.MAC -> "MAC address" - ListLine.NONE -> "None" + ListLine.ADVERTISED_NAME -> appText(R.string.fieldwatch_app_ui_advertised_name) + ListLine.NAME_AND_TYPE -> appText(R.string.fieldwatch_app_ui_name_type) + ListLine.MAC -> appText(R.string.fieldwatch_app_ui_mac_address) + ListLine.NONE -> appText(R.string.fieldwatch_app_ui_none) } @Composable @@ -1021,12 +1012,12 @@ private fun LiveSessionBar( onClick = onMarkSeen, modifier = Modifier.weight(1f), contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), - ) { Text("Mark seen") } + ) { Text(appText(R.string.fieldwatch_app_ui_mark_seen)) } FieldwatchActionButton( onClick = onResetSeen, modifier = Modifier.weight(1f), contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), - ) { Text("Reset seen") } + ) { Text(appText(R.string.fieldwatch_app_ui_reset_seen)) } } } if (movingWithYou) { @@ -1034,7 +1025,7 @@ private fun LiveSessionBar( onClick = onStartOverFollow, modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), - ) { Text("Start over") } + ) { Text(appText(R.string.fieldwatch_app_ui_start_over)) } } } } diff --git a/app/src/main/java/app/fieldwatch/ui/FieldwatchViewModel.kt b/app/src/main/java/app/fieldwatch/ui/FieldwatchViewModel.kt index c822d5a..bcf8c67 100644 --- a/app/src/main/java/app/fieldwatch/ui/FieldwatchViewModel.kt +++ b/app/src/main/java/app/fieldwatch/ui/FieldwatchViewModel.kt @@ -1,11 +1,23 @@ package app.fieldwatch.ui +import app.fieldwatch.i18n.displayName +import app.fieldwatch.i18n.displayAttentionNotes +import app.fieldwatch.i18n.displaySignatureNotes +import app.fieldwatch.i18n.CatalogText + +import app.fieldwatch.i18n.appText + +import app.fieldwatch.R + import android.app.Application import android.content.ClipData import android.content.Intent import android.net.Uri import androidx.core.content.FileProvider import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import androidx.lifecycle.viewModelScope import app.fieldwatch.BuildConfig import app.fieldwatch.FieldwatchApp @@ -98,14 +110,27 @@ private data class FamilyLogSnap( val loaded: Boolean = false, ) +enum class ExportJob(val title: Int, val pausesLogging: Boolean = false) { + LOG(R.string.fieldwatch_app_ui_export, true), + SIT(R.string.reports_screen_sit_export), + COMPARE(R.string.fieldwatch_app_ui_sit_compare), + COMPARE_PDF(R.string.fieldwatch_app_ui_sit_compare_pdf), + COMPARE_AI(R.string.fieldwatch_app_ui_sit_compare_ai_export), + DEBRIEF(R.string.fieldwatch_app_ui_debrief), + DEBRIEF_PDF(R.string.fieldwatch_app_ui_debrief_pdf), + AI(R.string.fieldwatch_app_ui_ai_export), + CATALOG(R.string.reports_screen_catalog), +} + data class ExportUi( + val job: ExportJob = ExportJob.LOG, val active: Boolean = false, val progress: Float = 0f, /** True: spinning wait (Debrief / AI Export). False: determinate bar (log). */ val spinner: Boolean = false, val message: String = "", val share: Intent? = null, - val shareTitle: String = "Export Fieldwatch logs", + val shareTitle: String = appText(R.string.fieldwatch_view_model_export_fieldwatch_logs), val error: String? = null, val errorTitle: String? = null, val cleared: Boolean = false, @@ -143,13 +168,23 @@ data class FieldwatchUi( val catalogVersion: Int = 0, ) -class FieldwatchViewModel(application: Application) : AndroidViewModel(application) { +class FieldwatchViewModel(application: Application, private val savedState: SavedStateHandle) : AndroidViewModel(application) { private val app = application as FieldwatchApp private val filters = FilterEngine() private val signatures = SignatureEngine() private val selectedKey = MutableStateFlow(null) - private val draft = MutableStateFlow(null) - private var draftFromCandidates = false + private val draft = MutableStateFlow(savedState.get("fleetDraft")?.let { + runCatching { Json.decodeFromString(it) }.getOrNull() + }) + private var draftFromCandidates: Boolean + get() = savedState["draftFromCandidates"] ?: false + set(value) { savedState["draftFromCandidates"] = value } + + private fun setDraft(fleet: Fleet?) { + // Restore the editor's existence after process recreation; editable fields have their own savers. + savedState["fleetDraft"] = fleet?.let { Json.encodeToString(it) } + draft.value = fleet + } private val _export = MutableStateFlow(ExportUi()) val export: StateFlow = _export private val _candidates = MutableStateFlow(CandidatesUi()) @@ -402,7 +437,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati HuntUi( active = true, device = device, - title = device?.listTitle() ?: "Hunt", + title = device?.listTitle() ?: appText(R.string.fieldwatch_view_model_hunt), cue = Hunt.cue(samples, now, device?.lastSeen, device == null && started > 0L), peakRssi = peak, samples = samples, @@ -766,10 +801,10 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati fun startSitCompare() { if (_export.value.active) return viewModelScope.launch { - publishExport(0.08f, "Writing sit compare…") + publishExport(0.08f, appText(R.string.fieldwatch_view_model_writing_sit_compare), ExportJob.COMPARE) runCatching { val doc = sitCompareDoc() - publishExport(0.85f, "Writing sit compare…") + publishExport(0.85f, appText(R.string.fieldwatch_view_model_writing_sit_compare)) Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_SUBJECT, compareSubject(doc)) @@ -780,10 +815,10 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati active = false, progress = 1f, share = intent, - shareTitle = "Sit compare", + shareTitle = appText(R.string.fieldwatch_view_model_sit_compare), ) }.onFailure { err -> - _export.value = ExportUi(error = err.message ?: "Could not write sit compare") + _export.value = ExportUi(error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_write_sit_compare)) } } } @@ -791,21 +826,21 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati fun startSitComparePdf() { if (_export.value.active) return viewModelScope.launch { - publishExport(0.06f, "Writing sit compare PDF…") + publishExport(0.06f, appText(R.string.fieldwatch_view_model_writing_sit_compare_pdf), ExportJob.COMPARE_PDF) runCatching { val doc = sitCompareDoc() - publishExport(0.35f, "Laying out sit compare PDF…") + publishExport(0.35f, appText(R.string.fieldwatch_view_model_laying_out_sit_compare_pdf)) val dir = File(app.cacheDir, "debrief").apply { mkdirs() } val stamp = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US) .format(java.util.Date()) val file = File(dir, "fieldwatch-sit-compare-$stamp.pdf") - publishExport(0.32f, "Loading map tiles…") + publishExport(0.32f, appText(R.string.fieldwatch_view_model_loading_map_tiles)) val tiles = pathTilesForFigure(doc.pathFigure) val extraTiles = doc.extraFigures.map { pathTilesForFigure(it) } withContext(Dispatchers.Default) { DebriefPdf.write(doc, file, tiles, extraTiles) { p -> kotlinx.coroutines.runBlocking { - publishExport(0.38f + 0.55f * p, "Writing sit compare PDF…") + publishExport(0.38f + 0.55f * p, appText(R.string.fieldwatch_view_model_writing_sit_compare_pdf)) } } } @@ -822,25 +857,25 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati active = false, progress = 1f, share = intent, - shareTitle = "Sit compare PDF", + shareTitle = appText(R.string.fieldwatch_view_model_sit_compare_pdf), ) }.onFailure { err -> - _export.value = ExportUi(error = err.message ?: "Could not write sit compare PDF") + _export.value = ExportUi(error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_write_sit_compare_pdf)) } } } private fun compareSubject(doc: DebriefDoc): String = - if (doc.windowLine.isNotBlank()) "Fieldwatch sit compare — ${doc.windowLine}" - else "Fieldwatch sit compare" + if (doc.windowLine.isNotBlank()) appText(R.string.fieldwatch_view_model_fieldwatch_sit_compare, doc.windowLine) + else appText(R.string.fieldwatch_view_model_fieldwatch_sit_compare_2) fun startSitCompareAiExport() { if (_export.value.active) return viewModelScope.launch { - publishExport(0.08f, "Building compare AI export…") + publishExport(0.08f, appText(R.string.fieldwatch_view_model_building_compare_ai_export), ExportJob.COMPARE_AI) runCatching { val (thisSide, second) = compareSides() - publishExport(0.45f, "Building compare AI export…") + publishExport(0.45f, appText(R.string.fieldwatch_view_model_building_compare_ai_export)) val text = withContext(Dispatchers.Default) { SitDiffPrompt.build(thisSide, second, app.config.settings.demoMode) } @@ -848,7 +883,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati type = "text/plain" putExtra( Intent.EXTRA_SUBJECT, - "Fieldwatch sit compare AI export — ${thisSide.name} vs ${second.name}", + appText(R.string.fieldwatch_view_model_fieldwatch_sit_compare_ai_export_vs, thisSide.name, second.name), ) putExtra(Intent.EXTRA_TEXT, text) } @@ -857,10 +892,10 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati active = false, progress = 1f, share = intent, - shareTitle = "Sit compare AI Export", + shareTitle = appText(R.string.fieldwatch_view_model_sit_compare_ai_export), ) }.onFailure { err -> - _export.value = ExportUi(error = err.message ?: "Could not write compare AI export") + _export.value = ExportUi(error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_write_compare_ai_export)) } } } @@ -879,9 +914,9 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati private suspend fun compareSides(): Pair { val sit = app.sits.ui.value - val otherId = sit.compareId ?: error("Pick a second sit.") + val otherId = sit.compareId ?: error(appText(R.string.fieldwatch_view_model_pick_a_second_sit)) val otherFile = withContext(Dispatchers.IO) { app.sits.sitFile(otherId) } - ?: error("Could not read that sit.") + ?: error(appText(R.string.fieldwatch_view_model_could_not_read_that_sit)) val fleets = app.config.fleets val customNames = RadioBookmarks.labels(app.config.watchlist) val observerNotes = RadioBookmarks.notes(app.config.watchlist) @@ -924,7 +959,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati val selected = sit.closed.firstOrNull { it.id == sit.selectedId } if (selected != null) { val file = withContext(Dispatchers.IO) { app.sits.sitFile(selected.id) } - ?: error("Could not read this sit.") + ?: error(appText(R.string.fieldwatch_view_model_could_not_read_this_sit)) return SitDiff.Side( name = selected.name, ram = false, @@ -938,7 +973,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati val now = System.currentTimeMillis() val since = now - DebriefPrompt.WINDOW_MS return SitDiff.Side( - name = "Last 15 minutes", + name = appText(R.string.fieldwatch_view_model_last_15_minutes), ram = true, radios = app.devices.devices.value.map { SitDiff.fromSighting(it, fleets, customNames, observerNotes, bookmarkedKeys, mineKeys) @@ -950,7 +985,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati private fun publishSitNotice() { val notice = app.sits.ui.value.notice ?: return - _export.value = ExportUi(noticeTitle = "Sits", noticeMessage = notice) + _export.value = ExportUi(noticeTitle = appText(R.string.fieldwatch_view_model_sits), noticeMessage = notice) app.sits.consumeNotice() } @@ -1003,7 +1038,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati if (existing >= 0) next[existing] = fleet else next += fleet cfg.copy(fleets = next) } - draft.value = null + setDraft(null) app.devices.refresh( app.config.fleets, app.config.settings.staleSec, @@ -1026,7 +1061,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati ), ) } - draft.value = null + setDraft(null) app.devices.refresh( app.config.fleets, app.config.settings.staleSec, @@ -1038,12 +1073,12 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati fun beginCreateFrom(device: Sighting) { draftFromCandidates = false - draft.value = signatures.suggestFleet(device) + setDraft(signatures.suggestFleet(device)) } fun beginCreateFromCandidate(candidate: SignatureCandidate) { draftFromCandidates = true - draft.value = SignatureCandidates.suggestFleet(candidate) + setDraft(SignatureCandidates.suggestFleet(candidate)) } fun takeDraftFromCandidates(): Boolean { @@ -1064,19 +1099,19 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati }.onSuccess { report -> _candidates.value = CandidatesUi(report = report) }.onFailure { err -> - _candidates.value = CandidatesUi(error = err.message ?: "Could not read the log") + _candidates.value = CandidatesUi(error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_read_the_log)) } } } fun beginNewFleet() { draftFromCandidates = false - draft.value = DefaultCatalog.newBlankFleet() + setDraft(DefaultCatalog.newBlankFleet().copy(name = appText(R.string.new_signature_name))) } fun editFleet(fleet: Fleet) { draftFromCandidates = false - draft.value = fleet + setDraft(fleet) } fun saveFleetKeepDraft(fleet: Fleet) { @@ -1087,12 +1122,12 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati if (existing >= 0) next[existing] = fleet else next += fleet cfg.copy(fleets = next) } - draft.value = fleet + setDraft(fleet) } } fun cancelDraft() { - draft.value = null + setDraft(null) } fun updateSettings(transform: (AppSettings) -> AppSettings) { @@ -1279,15 +1314,15 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati type = "application/json" clipData = ClipData.newRawUri("signatures", uri) putExtra(Intent.EXTRA_STREAM, uri) - putExtra(Intent.EXTRA_SUBJECT, "Fieldwatch signatures") + putExtra(Intent.EXTRA_SUBJECT, appText(R.string.fieldwatch_view_model_fieldwatch_signatures)) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } }.onSuccess { intent -> - _export.value = ExportUi(share = intent, shareTitle = "Fieldwatch signatures") + _export.value = ExportUi(share = intent, shareTitle = appText(R.string.fieldwatch_view_model_fieldwatch_signatures)) }.onFailure { err -> _export.value = ExportUi( - error = err.message ?: "Could not export signatures", - errorTitle = "Could not export signatures", + error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_export_signatures), + errorTitle = appText(R.string.fieldwatch_view_model_could_not_export_signatures), ) } } @@ -1300,17 +1335,17 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati withContext(Dispatchers.IO) { app.contentResolver.openOutputStream(uri)?.use { out -> out.write(json.toByteArray(Charsets.UTF_8)) - } ?: error("Could not write to the location you picked.") + } ?: error(appText(R.string.fieldwatch_view_model_could_not_write_to_the_location_you)) } }.onSuccess { _export.value = ExportUi( - noticeTitle = "Signatures saved", - noticeMessage = "The pack was written to the folder you picked. Share it with another Fieldwatch or keep it as a backup before Restore defaults.", + noticeTitle = appText(R.string.fieldwatch_view_model_signatures_saved), + noticeMessage = appText(R.string.fieldwatch_view_model_the_pack_was_written_to_the_folder), ) }.onFailure { err -> _export.value = ExportUi( - error = err.message ?: "Could not save signatures", - errorTitle = "Could not save signatures", + error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_save_signatures), + errorTitle = appText(R.string.fieldwatch_view_model_could_not_save_signatures), ) } } @@ -1321,15 +1356,16 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati viewModelScope.launch { if (!PlaceLookup.online(app)) { _export.value = ExportUi( - errorTitle = "No internet", - error = "No internet. Use Import signatures from a file.", + errorTitle = appText(R.string.fieldwatch_view_model_no_internet), + error = appText(R.string.fieldwatch_view_model_no_internet_use_import_signatures_from_a), ) return@launch } _export.value = ExportUi( active = true, spinner = true, - message = "Updating stock catalog…", + job = ExportJob.CATALOG, + message = appText(R.string.fieldwatch_view_model_updating_stock_catalog), ) runCatching { val text = withContext(Dispatchers.IO) { @@ -1353,24 +1389,22 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati }.onSuccess { result -> _export.value = if (result.alreadyLatest) { ExportUi( - noticeTitle = "Already on the latest catalog", - noticeMessage = "Already on catalog ${result.catalogVersion}. Nothing to update.", + noticeTitle = appText(R.string.fieldwatch_view_model_already_on_the_latest_catalog), + noticeMessage = appText(R.string.fieldwatch_view_model_already_on_catalog_nothing_to_update, result.catalogVersion), ) } else { val bits = mutableListOf() - if (result.updated > 0) bits += "updated ${result.updated}" - if (result.added > 0) bits += "added ${result.added}" - val change = if (bits.isEmpty()) "No stock rows changed." + if (result.updated > 0) bits += appText(R.string.fieldwatch_view_model_updated, result.updated) + if (result.added > 0) bits += appText(R.string.fieldwatch_view_model_added, result.added) + val change = if (bits.isEmpty()) appText(R.string.fieldwatch_view_model_no_stock_rows_changed) else bits.joinToString(" · ").replaceFirstChar { it.uppercase() } + "." val skip = result.skippedDecode > 0 ExportUi( - noticeTitle = "Catalog updated", - noticeMessage = "Stock catalog is now ${result.catalogVersion}. $change " + - "Bookmarks and Settings were not changed.", - followUpTitle = if (skip) "Signature decoding skipped" else null, + noticeTitle = appText(R.string.fieldwatch_view_model_catalog_updated), + noticeMessage = appText(R.string.fieldwatch_view_model_stock_catalog_is_now_bookmarks_and_settings, result.catalogVersion, change), + followUpTitle = if (skip) appText(R.string.fieldwatch_view_model_signature_decoding_skipped) else null, followUpMessage = if (skip) { - "Some signature field maps in this catalog need a newer Fieldwatch. " + - "Signatures still match. Install a newer APK to decode those fields." + appText(R.string.fieldwatch_view_model_some_signature_field_maps_in_this_catalog) } else { null }, @@ -1385,13 +1419,13 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati msg.contains("GitHub", ignoreCase = true) _export.value = if (access) { ExportUi( - errorTitle = "Could not reach GitHub", - error = "Could not reach the catalog on GitHub. Try again later, or use Import signatures from a file.", + errorTitle = appText(R.string.fieldwatch_view_model_could_not_reach_github), + error = appText(R.string.fieldwatch_view_model_could_not_reach_the_catalog_on_github), ) } else { ExportUi( - errorTitle = "Could not import catalog", - error = err.message ?: "Could not import catalog.", + errorTitle = appText(R.string.fieldwatch_view_model_could_not_import_catalog), + error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_import_catalog_2), ) } } @@ -1404,7 +1438,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati val text = withContext(Dispatchers.IO) { app.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) - } ?: error("Could not read that file.") + } ?: error(appText(R.string.fieldwatch_view_model_could_not_read_that_file)) } val pack = SignatureExchange.parse(text) val result = app.config.importFleets(pack.fleets) @@ -1418,13 +1452,13 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati result.summary() }.onSuccess { summary -> _export.value = ExportUi( - noticeTitle = "Signatures imported", + noticeTitle = appText(R.string.fieldwatch_view_model_signatures_imported), noticeMessage = summary, ) }.onFailure { err -> _export.value = ExportUi( - error = err.message ?: "Could not import signatures", - errorTitle = "Could not import signatures", + error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_import_signatures), + errorTitle = appText(R.string.fieldwatch_view_model_could_not_import_signatures), ) } } @@ -1463,15 +1497,15 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati type = "application/json" clipData = ClipData.newRawUri("settings", uri) putExtra(Intent.EXTRA_STREAM, uri) - putExtra(Intent.EXTRA_SUBJECT, "Fieldwatch settings") + putExtra(Intent.EXTRA_SUBJECT, appText(R.string.fieldwatch_view_model_fieldwatch_settings)) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } }.onSuccess { intent -> - _export.value = ExportUi(share = intent, shareTitle = "Fieldwatch settings") + _export.value = ExportUi(share = intent, shareTitle = appText(R.string.fieldwatch_view_model_fieldwatch_settings)) }.onFailure { err -> _export.value = ExportUi( - error = err.message ?: "Could not export settings", - errorTitle = "Could not export settings", + error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_export_settings), + errorTitle = appText(R.string.fieldwatch_view_model_could_not_export_settings), ) } } @@ -1484,17 +1518,17 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati withContext(Dispatchers.IO) { app.contentResolver.openOutputStream(uri)?.use { out -> out.write(json.toByteArray(Charsets.UTF_8)) - } ?: error("Could not write to the location you picked.") + } ?: error(appText(R.string.fieldwatch_view_model_could_not_write_to_the_location_you)) } }.onSuccess { _export.value = ExportUi( - noticeTitle = "Settings saved", - noticeMessage = "The pack was written to the folder you picked. Keep it for a factory reset or a new phone. Import settings on the new install. Signatures are a separate pack.", + noticeTitle = appText(R.string.fieldwatch_view_model_settings_saved), + noticeMessage = appText(R.string.fieldwatch_view_model_the_pack_was_written_to_the_folder_2), ) }.onFailure { err -> _export.value = ExportUi( - error = err.message ?: "Could not save settings", - errorTitle = "Could not save settings", + error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_save_settings), + errorTitle = appText(R.string.fieldwatch_view_model_could_not_save_settings), ) } } @@ -1506,7 +1540,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati val text = withContext(Dispatchers.IO) { app.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) - } ?: error("Could not read that file.") + } ?: error(appText(R.string.fieldwatch_view_model_could_not_read_that_file)) } val pack = SettingsExchange.parse(text) val prev = app.config.settings @@ -1528,13 +1562,13 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati result.summary() }.onSuccess { summary -> _export.value = ExportUi( - noticeTitle = "Settings imported", + noticeTitle = appText(R.string.fieldwatch_view_model_settings_imported), noticeMessage = summary, ) }.onFailure { err -> _export.value = ExportUi( - error = err.message ?: "Could not import settings", - errorTitle = "Could not import settings", + error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_import_settings), + errorTitle = appText(R.string.fieldwatch_view_model_could_not_import_settings), ) } } @@ -1628,8 +1662,8 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati mineKeys = RadioBookmarks.mineKeys(app.config.watchlist), ) val empty = when { - !tagging -> "Tag detections with GPS (Settings) to record a path." - samples.isEmpty() -> "Walk with tagging on. Path needs a GPS fix." + !tagging -> appText(R.string.fieldwatch_view_model_tag_detections_with_gps_settings_to_record) + samples.isEmpty() -> appText(R.string.fieldwatch_view_model_walk_with_tagging_on_path_needs_a) else -> null } pathRadios = source.devices @@ -1659,8 +1693,8 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati val samples = Geo.despikePath(app.operatorPathCopy().filter { it.at >= start }) val devices = app.devices.devices.value.filter { it.lastSeen >= start || it.firstSeen >= start } val empty = when { - !tagging -> "Tag detections with GPS (Settings) to record a path." - samples.isEmpty() -> "Last 15 minutes. Walk with tagging on, or Start sit to keep a longer path." + !tagging -> appText(R.string.fieldwatch_view_model_tag_detections_with_gps_settings_to_record) + samples.isEmpty() -> appText(R.string.fieldwatch_view_model_last_15_minutes_walk_with_tagging_on) else -> null } pathRadios = devices @@ -1683,7 +1717,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati dots = plot.points, lengthM = Geo.pathLengthM(samples), spanM = Geo.spanM(samples), - title = "Last 15 minutes", + title = appText(R.string.fieldwatch_view_model_last_15_minutes), emptyHint = empty, live = true, ), @@ -1726,21 +1760,21 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati fun startFieldDebriefPdf() { if (_export.value.active) return viewModelScope.launch { - publishExport(0.05f, "Writing debrief PDF…") + publishExport(0.05f, appText(R.string.fieldwatch_view_model_writing_debrief_pdf), ExportJob.DEBRIEF_PDF) runCatching { val doc = fieldDebriefDoc() - publishExport(0.4f, "Laying out debrief PDF…") + publishExport(0.4f, appText(R.string.fieldwatch_view_model_laying_out_debrief_pdf)) val dir = File(app.cacheDir, "debrief").apply { mkdirs() } val stamp = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US) .format(java.util.Date()) val file = File(dir, "fieldwatch-debrief-$stamp.pdf") - publishExport(0.32f, "Loading map tiles…") + publishExport(0.32f, appText(R.string.fieldwatch_view_model_loading_map_tiles)) val tiles = pathTilesForFigure(doc.pathFigure) val extraTiles = doc.extraFigures.map { pathTilesForFigure(it) } withContext(Dispatchers.Default) { DebriefPdf.write(doc, file, tiles, extraTiles) { p -> kotlinx.coroutines.runBlocking { - publishExport(0.4f + 0.55f * p, "Writing debrief PDF…") + publishExport(0.4f + 0.55f * p, appText(R.string.fieldwatch_view_model_writing_debrief_pdf)) } } } @@ -1757,10 +1791,10 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati active = false, progress = 1f, share = intent, - shareTitle = "Debrief PDF", + shareTitle = appText(R.string.fieldwatch_view_model_debrief_pdf), ) }.onFailure { err -> - _export.value = ExportUi(error = err.message ?: "Could not write debrief PDF") + _export.value = ExportUi(error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_write_debrief_pdf)) } } } @@ -1768,10 +1802,10 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati fun startFieldDebrief() { if (_export.value.active) return viewModelScope.launch { - publishExport(0.08f, "Writing debrief…") + publishExport(0.08f, appText(R.string.fieldwatch_view_model_writing_debrief), ExportJob.DEBRIEF) runCatching { val doc = fieldDebriefDoc() - publishExport(0.9f, "Writing debrief…") + publishExport(0.9f, appText(R.string.fieldwatch_view_model_writing_debrief)) Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_SUBJECT, debriefSubject(doc)) @@ -1782,19 +1816,19 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati active = false, progress = 1f, share = intent, - shareTitle = "Debrief", + shareTitle = appText(R.string.fieldwatch_view_model_debrief), ) }.onFailure { err -> - _export.value = ExportUi(error = err.message ?: "Could not write debrief") + _export.value = ExportUi(error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_write_debrief)) } } } private fun debriefSubject(doc: DebriefDoc): String = - if (doc.heading.startsWith("FIELDWATCH SIT")) doc.heading else "Fieldwatch field debrief — last 15 minutes" + if (doc.heading.startsWith(appText(R.string.fieldwatch_view_model_fieldwatch_sit))) doc.heading else appText(R.string.fieldwatch_view_model_fieldwatch_field_debrief_last_15_minutes) private suspend fun fieldDebriefDoc(): DebriefDoc { - publishExport(0.08f, "Gathering sit…") + publishExport(0.08f, appText(R.string.fieldwatch_view_model_gathering_sit)) val settings = app.config.settings val fleets = app.config.fleets val now = System.currentTimeMillis() @@ -1805,7 +1839,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati val places = if (settings.demoMode) { DebriefPlaces.Off } else if (settings.onlineLookup) { - publishExport(0.14f, "Looking up place names…") + publishExport(0.14f, appText(R.string.fieldwatch_view_model_looking_up_place_names)) val found = PlaceLookup.lookup(app, path, devices, now, onProgress = { msg -> kotlinx.coroutines.runBlocking { publishExport(0.18f, msg) } }) @@ -1813,7 +1847,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati } else { DebriefPlaces.Off } - publishExport(0.32f, "Building debrief…") + publishExport(0.32f, appText(R.string.fieldwatch_view_model_building_debrief)) return withContext(Dispatchers.Default) { DebriefReport.document( devices = devices, @@ -1836,7 +1870,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati fun startAiExport() { if (_export.value.active) return viewModelScope.launch { - publishExport(0.06f, "Building AI export prompt…") + publishExport(0.06f, appText(R.string.fieldwatch_view_model_building_ai_export_prompt), ExportJob.AI) runCatching { val settings = app.config.settings val fleets = app.config.fleets @@ -1848,16 +1882,16 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati val places = if (settings.demoMode) { DebriefPlaces.Off } else if (settings.onlineLookup) { - publishExport(0.12f, "Looking up place names…") + publishExport(0.12f, appText(R.string.fieldwatch_view_model_looking_up_place_names)) val found = PlaceLookup.lookup(app, path, devices, now) { msg -> kotlinx.coroutines.runBlocking { publishExport(0.16f, msg) } } - publishExport(0.35f, "Building AI export prompt…") + publishExport(0.35f, appText(R.string.fieldwatch_view_model_building_ai_export_prompt)) found } else { DebriefPlaces.Off } - publishExport(0.4f, "Building AI export prompt…") + publishExport(0.4f, appText(R.string.fieldwatch_view_model_building_ai_export_prompt)) val text = withContext(Dispatchers.Default) { val raw = DebriefPrompt.build( devices = devices, @@ -1878,7 +1912,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati settings.demoMode, ) if (settings.demoMode) { - "Privacy mode: MAC tails are **:**:**. GPS coordinates are masked. Logs on the phone are unchanged.\n\n$masked" + appText(R.string.fieldwatch_view_model_privacy_mode_mac_tails_are_gps_coordinates, masked) } else { masked } @@ -1887,8 +1921,8 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati type = "text/plain" putExtra( Intent.EXTRA_SUBJECT, - if (window != null) "Fieldwatch AI export — sit ${window.sitName}" - else "Fieldwatch AI export — last 15 minutes", + if (window != null) appText(R.string.fieldwatch_view_model_fieldwatch_ai_export_sit, window.sitName) + else appText(R.string.fieldwatch_view_model_fieldwatch_ai_export_last_15_minutes), ) putExtra(Intent.EXTRA_TEXT, text) } @@ -1897,10 +1931,10 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati active = false, progress = 1f, share = intent, - shareTitle = "AI Export", + shareTitle = appText(R.string.fieldwatch_view_model_ai_export), ) }.onFailure { err -> - _export.value = ExportUi(error = err.message ?: "Could not build AI export") + _export.value = ExportUi(error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_build_ai_export)) } } } @@ -1908,7 +1942,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati fun startDeviceDetailAiExport(device: Sighting) { if (_export.value.active) return viewModelScope.launch { - publishExport(0.08f, "Building AI export prompt…") + publishExport(0.08f, appText(R.string.fieldwatch_view_model_building_ai_export_prompt), ExportJob.AI) runCatching { val settings = app.config.settings val names = device.fleetIds.map { fleetName(it) } @@ -1917,16 +1951,16 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati val places = if (settings.demoMode) { DebriefPlaces.Off } else if (settings.onlineLookup && (settings.tagLocation || device.latitude != null)) { - publishExport(0.15f, "Looking up place names…") + publishExport(0.15f, appText(R.string.fieldwatch_view_model_looking_up_place_names)) val found = PlaceLookup.lookup(app, app.operatorPathCopy(), listOf(device), System.currentTimeMillis()) { msg -> kotlinx.coroutines.runBlocking { publishExport(0.2f, msg) } } - publishExport(0.4f, "Building AI export prompt…") + publishExport(0.4f, appText(R.string.fieldwatch_view_model_building_ai_export_prompt)) found } else { DebriefPlaces.Off } - publishExport(0.45f, "Building AI export prompt…") + publishExport(0.45f, appText(R.string.fieldwatch_view_model_building_ai_export_prompt)) val text = withContext(Dispatchers.Default) { val raw = DeviceDetailPrompt.build( device, names, settings, places, attentionNotes = attention, @@ -1939,7 +1973,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati settings.demoMode, ) if (settings.demoMode) { - "Privacy mode: MAC tails are **:**:**. GPS coordinates are masked. Logs on the phone are unchanged.\n\n$masked" + appText(R.string.fieldwatch_view_model_privacy_mode_mac_tails_are_gps_coordinates, masked) } else { masked } @@ -1947,7 +1981,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati val title = MacUtil.redactMacIn(device.listTitle(names), device.mac, settings.demoMode) Intent(Intent.ACTION_SEND).apply { type = "text/plain" - putExtra(Intent.EXTRA_SUBJECT, "Fieldwatch AI export — $title") + putExtra(Intent.EXTRA_SUBJECT, appText(R.string.fieldwatch_view_model_fieldwatch_ai_export, title)) putExtra(Intent.EXTRA_TEXT, text) } }.onSuccess { intent -> @@ -1955,10 +1989,10 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati active = false, progress = 1f, share = intent, - shareTitle = "AI Export", + shareTitle = appText(R.string.fieldwatch_view_model_ai_export), ) }.onFailure { err -> - _export.value = ExportUi(error = err.message ?: "Could not build AI export") + _export.value = ExportUi(error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_build_ai_export)) } } } @@ -1982,7 +2016,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati settings.demoMode, ) if (settings.demoMode) { - "Privacy mode: MAC tails are **:**:**. GPS coordinates are masked. Logs on the phone are unchanged.\n\n$masked" + appText(R.string.fieldwatch_view_model_privacy_mode_mac_tails_are_gps_coordinates, masked) } else { masked } @@ -1990,13 +2024,13 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati val title = MacUtil.redactMacIn(device.listTitle(names), device.mac, settings.demoMode) Intent(Intent.ACTION_SEND).apply { type = "text/plain" - putExtra(Intent.EXTRA_SUBJECT, "Fieldwatch device detail — $title") + putExtra(Intent.EXTRA_SUBJECT, appText(R.string.fieldwatch_view_model_fieldwatch_device_detail, title)) putExtra(Intent.EXTRA_TEXT, text) } }.onSuccess { intent -> - _export.value = ExportUi(share = intent, shareTitle = "Device detail") + _export.value = ExportUi(share = intent, shareTitle = appText(R.string.fieldwatch_view_model_device_detail)) }.onFailure { err -> - _export.value = ExportUi(error = err.message ?: "Could not share device detail") + _export.value = ExportUi(error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_share_device_detail)) } } } @@ -2012,7 +2046,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati fun startSitExport() { if (_export.value.active) return viewModelScope.launch { - runExport("Preparing sit export…") { + runExport(appText(R.string.fieldwatch_view_model_preparing_sit_export), ExportJob.SIT) { val kind = _sitExportKind.value val (file, sitName) = writeSitExport(kind) val uri: Uri = FileProvider.getUriForFile(app, "${app.packageName}.files", file) @@ -2024,7 +2058,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } }.onSuccess { intent -> - _export.value = ExportUi(active = false, progress = 1f, share = intent, shareTitle = "Sit export") + _export.value = ExportUi(active = false, progress = 1f, share = intent, shareTitle = appText(R.string.fieldwatch_view_model_sit_export)) } } } @@ -2032,12 +2066,12 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati fun startSitSaveToUri(uri: Uri) { if (_export.value.active) return viewModelScope.launch { - runExport("Saving sit export…") { + runExport(appText(R.string.fieldwatch_view_model_saving_sit_export), ExportJob.SIT) { val kind = _sitExportKind.value val text = sitExportText(kind) withContext(Dispatchers.IO) { app.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) } - ?: error("Could not open the selected location") + ?: error(appText(R.string.fieldwatch_view_model_could_not_open_the_selected_location)) } }.onSuccess { _export.value = ExportUi(active = false, progress = 1f, saved = true) @@ -2058,7 +2092,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati } val start = now - DebriefPrompt.WINDOW_MS return SitExportWindow( - "Last 15 minutes", + appText(R.string.fieldwatch_view_model_last_15_minutes), app.devices.devices.value.filter { it.lastSeen >= start || it.firstSeen >= start }, app.operatorPathCopy().filter { it.at >= start }, ) @@ -2081,7 +2115,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati } private suspend fun sitExportText(kind: LogExportKind): String { - publishExport(0.08f, "Gathering sit…") + publishExport(0.08f, appText(R.string.fieldwatch_view_model_gathering_sit)) val win = sitExportWindow() val radios = _sitExportRadios.value val custom = RadioBookmarks.labels(app.config.watchlist) @@ -2092,7 +2126,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati val rows = SitExport.rows(win.devices, radios) if (kind == LogExportKind.LOG_CSV || kind == LogExportKind.LOG_JSONL) { if (rows.isEmpty()) error(SitExport.emptyHint(kind, radios)) - publishExport(0.4f, "Writing ${kind.label} · ${rows.size} radios") + publishExport(0.4f, appText(R.string.fieldwatch_view_model_writing_radios, kind.label, rows.size)) return withContext(Dispatchers.Default) { if (kind == LogExportKind.LOG_CSV) { SitExport.csv(win.devices, radios, custom, notes, extra, fleets, mine) @@ -2103,13 +2137,13 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati } val pins = SitExport.mapRadios(win.devices, radios) if (pins.isEmpty()) error(SitExport.emptyHint(kind, radios)) - val fmt = GeoExport.formatOf(kind) ?: error("Pick a map format.") + val fmt = GeoExport.formatOf(kind) ?: error(appText(R.string.fieldwatch_view_model_pick_a_map_format)) val info = listOf( "model=${android.os.Build.MODEL}", "release=${android.os.Build.VERSION.RELEASE}", "device=${android.os.Build.DEVICE}", ).joinToString(",") - publishExport(0.45f, "Writing ${kind.label} · ${pins.size} pins") + publishExport(0.45f, appText(R.string.fieldwatch_view_model_writing_pins, kind.label, pins.size)) return withContext(Dispatchers.Default) { GeoExport.render( fmt, pins, emptyMap(), BuildConfig.VERSION_NAME, info, @@ -2121,7 +2155,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati if (done == total || done % 250 == 0) { val pct = 0.50f + 0.45f * done.toFloat() / total.toFloat() kotlinx.coroutines.runBlocking { - publishExport(pct, "Writing ${kind.label} · $done of $total") + publishExport(pct, appText(R.string.fieldwatch_view_model_writing_of, kind.label, done, total)) } } }, @@ -2132,7 +2166,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati fun startExport() { if (_export.value.active) return viewModelScope.launch { - runExport("Logging paused · preparing file…") { + runExport(appText(R.string.fieldwatch_view_model_logging_paused_preparing_file)) { val kind = _logExportKind.value val radios = _logExportRadios.value val file = when (kind) { @@ -2162,7 +2196,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati fun startSaveToUri(uri: Uri) { if (_export.value.active) return viewModelScope.launch { - runExport("Logging paused · saving to the location you picked…") { + runExport(appText(R.string.fieldwatch_view_model_logging_paused_saving_to_the_location_you)) { val kind = _logExportKind.value val radios = _logExportRadios.value when (kind) { @@ -2178,7 +2212,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati val text = mapExportText(kind, radios) withContext(Dispatchers.IO) { app.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) } - ?: error("Could not open the selected location") + ?: error(appText(R.string.fieldwatch_view_model_could_not_open_the_selected_location)) } } } @@ -2189,8 +2223,8 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati } private fun exportSubject(kind: LogExportKind): String = when (kind) { - LogExportKind.LOG_CSV -> "Fieldwatch log (CSV)" - LogExportKind.LOG_JSONL -> "Fieldwatch log (JSON lines)" + LogExportKind.LOG_CSV -> appText(R.string.fieldwatch_view_model_fieldwatch_log_csv) + LogExportKind.LOG_JSONL -> appText(R.string.fieldwatch_view_model_fieldwatch_log_json_lines) LogExportKind.GPX -> "Fieldwatch GPX" LogExportKind.KML -> "Fieldwatch KML" LogExportKind.WIGLE -> "Fieldwatch WiGLE CSV" @@ -2198,7 +2232,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati private suspend fun writeMapExport(kind: LogExportKind, radios: LogExportRadios): File { val text = mapExportText(kind, radios) - val fmt = GeoExport.formatOf(kind) ?: error("Pick a map format.") + val fmt = GeoExport.formatOf(kind) ?: error(appText(R.string.fieldwatch_view_model_pick_a_map_format)) val dir = File(app.cacheDir, "export").apply { mkdirs() } val stamp = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US) .format(java.util.Date()) @@ -2213,19 +2247,19 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati } private suspend fun mapExportText(kind: LogExportKind, radios: LogExportRadios): String { - val fmt = GeoExport.formatOf(kind) ?: error("Pick a map format.") - publishExport(0.02f, "Reading log…") + val fmt = GeoExport.formatOf(kind) ?: error(appText(R.string.fieldwatch_view_model_pick_a_map_format)) + publishExport(0.02f, appText(R.string.fieldwatch_view_model_reading_log)) val pins = app.logs.readRadios { copied, total -> val pct = 0.05f + 0.40f * copied.toFloat() / total.toFloat().coerceAtLeast(1f) - publishExport(pct, "Reading log · ${copied / 1024} KB of ${total / 1024} KB") + publishExport(pct, appText(R.string.fieldwatch_view_model_reading_log_kb_of_kb, copied / 1024, total / 1024)) }.filter { it.hasPosition && radios.matches(it.kind) } if (pins.isEmpty()) { val which = when (radios) { - LogExportRadios.BOTH -> "radios" - LogExportRadios.WIFI -> "Wi-Fi radios" - LogExportRadios.BLE -> "BLE radios" + LogExportRadios.BOTH -> appText(R.string.fieldwatch_view_model_radios) + LogExportRadios.WIFI -> appText(R.string.fieldwatch_view_model_wi_fi_radios) + LogExportRadios.BLE -> appText(R.string.fieldwatch_view_model_ble_radios) } - error("No GPS-tagged $which. Settings → Tag detections with GPS, logging on, then sit.") + error(appText(R.string.fieldwatch_view_model_no_gps_tagged_settings_tag_detections_with, which)) } val info = listOf( "model=${android.os.Build.MODEL}", @@ -2235,7 +2269,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati "board=${android.os.Build.BOARD}", "brand=${android.os.Build.BRAND}", ).joinToString(",") - publishExport(0.48f, "Writing ${kind.label} · ${pins.size} pins") + publishExport(0.48f, appText(R.string.fieldwatch_view_model_writing_pins, kind.label, pins.size)) return withContext(Dispatchers.Default) { GeoExport.render( fmt, pins, emptyMap(), BuildConfig.VERSION_NAME, info, @@ -2245,7 +2279,7 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati if (done == total || done % 250 == 0) { val pct = 0.50f + 0.45f * done.toFloat() / total.toFloat() kotlinx.coroutines.runBlocking { - publishExport(pct, "Writing ${kind.label} · $done of $total") + publishExport(pct, appText(R.string.fieldwatch_view_model_writing_of, kind.label, done, total)) } } }, @@ -2256,14 +2290,14 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati fun clearLogs() { if (_export.value.active) return viewModelScope.launch { - _export.value = ExportUi(active = true, progress = 0f, message = "Clearing log…") + _export.value = ExportUi(active = true, progress = 0f, message = appText(R.string.fieldwatch_view_model_clearing_log)) runCatching { app.logs.clear() } .onSuccess { count -> app.devices.bumpLogs(count) - _export.value = ExportUi(cleared = true, message = "Log cleared") + _export.value = ExportUi(cleared = true, message = appText(R.string.fieldwatch_view_model_log_cleared)) } .onFailure { err -> - _export.value = ExportUi(error = err.message ?: "Could not clear log") + _export.value = ExportUi(error = err.message ?: appText(R.string.fieldwatch_view_model_could_not_clear_log)) } } } @@ -2286,24 +2320,25 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati private suspend fun reportCopy(copied: Long, total: Long) { val pct = (copied.toFloat() / total.toFloat()).coerceIn(0f, 1f) - publishExport(pct, "Writing ${(copied / 1024)} KB of ${(total / 1024)} KB") + publishExport(pct, appText(R.string.fieldwatch_view_model_writing_kb_of_kb, (copied / 1024), (total / 1024))) } - private suspend fun publishExport(progress: Float, message: String) { + private suspend fun publishExport(progress: Float, message: String, job: ExportJob? = null) { withContext(Dispatchers.Main.immediate) { - _export.value = ExportUi( + _export.update { it.copy( active = true, progress = progress.coerceIn(0f, 1f), message = message, - ) + job = job ?: it.job, + ) } } yield() } - private suspend fun runExport(startMessage: String, block: suspend () -> T): Result { - _export.value = ExportUi(active = true, progress = 0f, message = startMessage) + private suspend fun runExport(startMessage: String, job: ExportJob = ExportJob.LOG, block: suspend () -> T): Result { + _export.value = ExportUi(active = true, progress = 0f, message = startMessage, job = job) return runCatching { block() }.onFailure { err -> - _export.value = ExportUi(active = false, error = err.message ?: "Export failed") + _export.value = ExportUi(active = false, error = err.message ?: appText(R.string.fieldwatch_view_model_export_failed)) } } @@ -2311,6 +2346,8 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati fun fleetName(id: String): String = app.config.fleets.firstOrNull { it.id == id }?.name ?: id + fun fleetDisplayName(id: String): String = app.config.fleets.firstOrNull { it.id == id }?.displayName() ?: id + fun fleetKind(id: String): SignatureClass? = app.config.fleets.firstOrNull { it.id == id }?.kind fun fleetColor(id: String): Int = @@ -2329,10 +2366,10 @@ class FieldwatchViewModel(application: Application) : AndroidViewModel(applicati app.config.fleets.firstOrNull { it.id == id }?.decode != null fun attentionNotesFor(device: Sighting): List> = - device.attentionNotes(app.config.fleets) + device.displayAttentionNotes(app.config.fleets) fun signatureNotesFor(device: Sighting): List> = - device.signatureNotes(app.config.fleets) + device.displaySignatureNotes(app.config.fleets) fun isWatched(deviceKey: String): Boolean = app.config.watchlist.any { it.deviceKey == deviceKey && it.alert } diff --git a/app/src/main/java/app/fieldwatch/ui/LiveChromeTour.kt b/app/src/main/java/app/fieldwatch/ui/LiveChromeTour.kt index 5a509e5..3860a32 100644 --- a/app/src/main/java/app/fieldwatch/ui/LiveChromeTour.kt +++ b/app/src/main/java/app/fieldwatch/ui/LiveChromeTour.kt @@ -1,5 +1,9 @@ package app.fieldwatch.ui +import app.fieldwatch.i18n.appText + +import app.fieldwatch.R + import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.BoxWithConstraints @@ -81,7 +85,7 @@ fun LiveChromeTour( val fitted = spots.map { spot -> val h = measuredH[spot.title] ?: return@map spot val box = Rect(spot.box.left, spot.box.top, spot.box.right, spot.box.top + h) - val fromY = if (spot.title == "Tune") box.top else box.bottom + val fromY = if (spot.title == appText(R.string.live_chrome_tour_tune)) box.top else box.bottom spot.copy(box = box, from = Offset(spot.from.x, fromY)) } val placed = separateBubbles(fitted, pad, with(density) { 8.dp.toPx() }) @@ -128,7 +132,7 @@ fun LiveChromeTour( modifier = Modifier .align(Alignment.TopCenter) .offset { IntOffset(0, gotItY.roundToInt()) }, - ) { Text("Got it") } + ) { Text(appText(R.string.live_chrome_tour_got_it)) } } } @@ -179,11 +183,11 @@ private fun layoutSpots( val tune = targets.tune!! val tabs = listOf( - Triple(targets.pause!!, "Pause", "Freeze the picture. Tap Live again to run."), - Triple(targets.filters!!, "Filters", "Who is shown."), - Triple(targets.signatures!!, "Signatures", "Pattern catalog."), - Triple(targets.reports!!, "Reports", "Debrief, sits, log."), - Triple(targets.settings!!, "Settings", "Scan, GPS, TAK."), + Triple(targets.pause!!, appText(R.string.live_chrome_tour_pause), appText(R.string.live_chrome_tour_freeze_the_picture_tap_live_again_to)), + Triple(targets.filters!!, appText(R.string.live_chrome_tour_filters), appText(R.string.live_chrome_tour_who_is_shown)), + Triple(targets.signatures!!, appText(R.string.live_chrome_tour_signatures), appText(R.string.live_chrome_tour_pattern_catalog)), + Triple(targets.reports!!, appText(R.string.live_chrome_tour_reports), appText(R.string.live_chrome_tour_debrief_sits_log)), + Triple(targets.settings!!, appText(R.string.live_chrome_tour_settings), appText(R.string.live_chrome_tour_scan_gps_tak)), ) val heights = floatArrayOf(topH, bodyH, bodyH, bodyH, topH) val tabTop = tabs.minOf { it.first.top } @@ -279,7 +283,7 @@ private fun layoutSpots( } val tuneFromX = tune.center.x.coerceIn(tuneBox.left + inset, tuneBox.right - inset) return listOf( - Spot(tune, "Tune", "Display — Radar, list, timeline, hybrid, By class.", tuneBox, Offset(tuneFromX, tuneBox.top)), + Spot(tune, appText(R.string.live_chrome_tour_tune), appText(R.string.live_chrome_tour_display_radar_list_timeline_hybrid_by_class), tuneBox, Offset(tuneFromX, tuneBox.top)), ) + tabs.mapIndexed { i, t -> Spot(t.first, t.second, t.third, boxes[i], fromOn(boxes[i], t.first, i)) } @@ -306,7 +310,7 @@ private fun separateBubbles(spots: List, pad: Float, edge: Float): List val b = boxes[i] - val fromY = if (s.title == "Tune") b.top else b.bottom + val fromY = if (s.title == appText(R.string.live_chrome_tour_tune)) b.top else b.bottom s.copy(box = b, from = Offset(s.from.x, fromY)) } } diff --git a/app/src/main/java/app/fieldwatch/ui/SavedDraft.kt b/app/src/main/java/app/fieldwatch/ui/SavedDraft.kt new file mode 100644 index 0000000..0f732e5 --- /dev/null +++ b/app/src/main/java/app/fieldwatch/ui/SavedDraft.kt @@ -0,0 +1,11 @@ +package app.fieldwatch.ui + +import androidx.compose.runtime.saveable.Saver +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** Keep editable models through recreation without committing an unfinished edit. */ +inline fun jsonDraftSaver(): Saver = Saver( + save = { Json.encodeToString(it) }, + restore = { Json.decodeFromString(it) }, +) diff --git a/app/src/main/java/app/fieldwatch/ui/component/DecodeGlyph.kt b/app/src/main/java/app/fieldwatch/ui/component/DecodeGlyph.kt index 98cfb47..b2b0f63 100644 --- a/app/src/main/java/app/fieldwatch/ui/component/DecodeGlyph.kt +++ b/app/src/main/java/app/fieldwatch/ui/component/DecodeGlyph.kt @@ -1,5 +1,9 @@ package app.fieldwatch.ui.component +import app.fieldwatch.i18n.appText + +import app.fieldwatch.R + import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -25,7 +29,7 @@ fun DecodeGlyph( tint: Color, modifier: Modifier = Modifier, size: Dp = 12.dp, - contentDescription: String = "Decode fields", + contentDescription: String = appText(R.string.decode_glyph_decode_fields), ) { Canvas( modifier diff --git a/app/src/main/java/app/fieldwatch/ui/component/LanguagePicker.kt b/app/src/main/java/app/fieldwatch/ui/component/LanguagePicker.kt new file mode 100644 index 0000000..967dfb3 --- /dev/null +++ b/app/src/main/java/app/fieldwatch/ui/component/LanguagePicker.kt @@ -0,0 +1,32 @@ +package app.fieldwatch.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.fieldwatch.R +import app.fieldwatch.i18n.AppLanguage + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun LanguagePicker() { + val selected = AppLanguage.selection() + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.settings_language), style = MaterialTheme.typography.titleMedium) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("" to stringResource(R.string.settings_language_system), "zh-Hans" to "简体中文", "en" to "English") + .forEach { (tag, label) -> + FieldwatchFilterChip( + selected = if (tag.isEmpty()) selected.isEmpty() else selected.startsWith(tag), + onClick = { AppLanguage.select(tag) }, + label = { Text(label) }, + ) + } + } + } +} diff --git a/app/src/main/java/app/fieldwatch/ui/component/SitPathCanvas.kt b/app/src/main/java/app/fieldwatch/ui/component/SitPathCanvas.kt index f5a761a..a79ad34 100644 --- a/app/src/main/java/app/fieldwatch/ui/component/SitPathCanvas.kt +++ b/app/src/main/java/app/fieldwatch/ui/component/SitPathCanvas.kt @@ -1,5 +1,15 @@ package app.fieldwatch.ui.component +import app.fieldwatch.i18n.displayLiveDecode + +import app.fieldwatch.i18n.forDisplay + +import app.fieldwatch.i18n.appQuantity + +import app.fieldwatch.i18n.appText + +import app.fieldwatch.R + import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -198,9 +208,9 @@ fun SitPathCanvas( val start = lay.path.first() val end = lay.path.last() drawStartDot(start.x, start.y) - val startT = measurer.measure("Start", labelStyle) + val startT = measurer.measure(appText(R.string.sit_path_canvas_start), labelStyle) drawText(startT, topLeft = Offset((start.x + 8f).coerceAtMost(size.width - startT.size.width), start.y - 6f)) - val endLabel = if (model.live) "Now" else "End" + val endLabel = if (model.live) appText(R.string.sit_path_canvas_now) else appText(R.string.sit_path_canvas_end) val endT = measurer.measure(endLabel, labelStyle) drawText( endT, @@ -265,7 +275,7 @@ fun SitPathCanvas( .padding(8.dp), ) { Text( - if (selected.members.size == 1) "1 alert here" else "${selected.members.size} alerts here", + appQuantity(R.plurals.alerts_here, selected.members.size), style = MaterialTheme.typography.labelSmall, color = muted, ) @@ -341,8 +351,8 @@ private fun pathMarkers( if (lay.path.size < 2 || width < 8f) return emptyList() val start = lay.path.first() val end = lay.path.last() - val startText = measurer.measure("Start", labelStyle) - val endText = measurer.measure(if (live) "Now" else "End", labelStyle) + val startText = measurer.measure(appText(R.string.sit_path_canvas_start), labelStyle) + val endText = measurer.measure(if (live) appText(R.string.sit_path_canvas_now) else appText(R.string.sit_path_canvas_end), labelStyle) return listOf( hitMarker(start, 12f, startText, width, dx = 8f, dy = -6f), hitMarker(end, 12f, endText, width, dx = 10f, dy = -(endText.size.height + 4f).toFloat()), @@ -478,4 +488,5 @@ private fun DrawScope.drawPilotMark(center: Offset, painter: Painter) { private fun scaleLabel(m: Double): String = if (m >= 1000) "${(m / 1000).toInt()} km" else "${m.toInt()} m" -private val TIME_FMT = SimpleDateFormat("HH:mm", Locale.getDefault()) +private val TIME_FMT: SimpleDateFormat + get() = SimpleDateFormat("HH:mm", app.fieldwatch.i18n.TextRuntime.localeProvider()) diff --git a/app/src/main/java/app/fieldwatch/ui/screen/CandidatesScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/CandidatesScreen.kt index a9af91c..fb63ae4 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/CandidatesScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/CandidatesScreen.kt @@ -1,5 +1,17 @@ package app.fieldwatch.ui.screen +import app.fieldwatch.i18n.displayLiveDecode + +import app.fieldwatch.i18n.forDisplay + +import app.fieldwatch.i18n.appQuantity + +import app.fieldwatch.i18n.displayLabel + +import app.fieldwatch.i18n.appText + +import app.fieldwatch.R + import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -64,10 +76,10 @@ fun CandidatesScreen( contentWindowInsets = NestedTabInsets, topBar = { NestedTopBar( - title = "Signature candidates", + title = appText(R.string.candidates_screen_signature_candidates), navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, appText(R.string.candidates_screen_back)) } }, ) @@ -83,7 +95,7 @@ fun CandidatesScreen( CircularProgressIndicator() Spacer(Modifier.height(16.dp)) Text( - "Reading the log and re-matching the catalog…", + appText(R.string.candidates_screen_reading_the_log_and_re_matching_the), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -103,9 +115,9 @@ fun CandidatesScreen( ) { item { Text( - (report?.sourceLabel ?: "Rotating log") + - " · re-matched now" + - (report?.let { " · ${it.families.size} ${if (it.families.size == 1) "family" else "families"}" } ?: ""), + (report?.sourceLabel ?: appText(R.string.candidates_screen_rotating_log)) + + appText(R.string.candidates_screen_re_matched_now) + + (report?.let { appQuantity(R.plurals.candidate_families, it.families.size) } ?: ""), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -129,7 +141,7 @@ fun CandidatesScreen( if (report == null || report.families.isEmpty()) { item { Text( - "No signature families in this log. Randomized addresses and house-like names are skipped. A candidate needs a unique on-air ID on two or more radios.", + appText(R.string.candidates_screen_no_signature_families_in_this_log_randomized), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp), @@ -176,7 +188,7 @@ private fun CandidateCard( modifier = Modifier.padding(top = 4.dp), ) { Text( - cand.kind.label(), + cand.kind.displayLabel(), style = compact(11.sp, 13.sp, FontWeight.SemiBold), color = accent, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), @@ -205,7 +217,7 @@ private fun CandidateCard( ) val examples = cand.examples.map { MacUtil.redactMacIn(it, it, demoMode && looksLikeMac(it)) } if (examples.isNotEmpty()) { - val extra = if (cand.extraCount > 0) "\nand ${cand.extraCount} more" else "" + val extra = if (cand.extraCount > 0) appText(R.string.candidates_screen_nand_more, cand.extraCount) else "" Text( examples.joinToString(" · ") + extra, style = compact(12.sp, 15.sp), @@ -220,7 +232,7 @@ private fun CandidateCard( FieldwatchActionButton(onClick = { onCreate(cand) }) { Icon(Icons.Outlined.GroupAdd, null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - Text("Create signature") + Text(appText(R.string.candidates_screen_create_signature)) } } } @@ -228,12 +240,12 @@ private fun CandidateCard( } private fun skipLine(report: app.fieldwatch.domain.CandidateReport): String = buildString { - append("Skipped ") + append(appText(R.string.candidates_screen_skipped)) val bits = ArrayList(2) - if (report.skippedRandomized > 0) bits += "${report.skippedRandomized} randomized addresses" - if (report.skippedHouseLike > 0) bits += "${report.skippedHouseLike} house-like names" - append(bits.joinToString(" and ")) - append(". Those are not catalog families.") + if (report.skippedRandomized > 0) bits += appText(R.string.candidates_screen_randomized_addresses, report.skippedRandomized) + if (report.skippedHouseLike > 0) bits += appText(R.string.candidates_screen_house_like_names, report.skippedHouseLike) + append(bits.joinToString(appText(R.string.candidates_screen_and))) + append(appText(R.string.candidates_screen_those_are_not_catalog_families)) } private fun looksLikeMac(text: String): Boolean = diff --git a/app/src/main/java/app/fieldwatch/ui/screen/DecodeFieldsScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/DecodeFieldsScreen.kt index 43eeb69..adbd60d 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/DecodeFieldsScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/DecodeFieldsScreen.kt @@ -1,5 +1,12 @@ package app.fieldwatch.ui.screen +import app.fieldwatch.i18n.CatalogText +import app.fieldwatch.i18n.forDisplay + +import app.fieldwatch.i18n.appText + +import app.fieldwatch.R + import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -36,6 +43,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -59,6 +67,7 @@ import app.fieldwatch.domain.normalized import app.fieldwatch.domain.resolvedLength import app.fieldwatch.ui.NestedTabInsets import app.fieldwatch.ui.NestedTopBar +import app.fieldwatch.ui.jsonDraftSaver import app.fieldwatch.ui.component.SectionCard import app.fieldwatch.ui.component.spectreTileFill @@ -71,12 +80,12 @@ fun DecodeFieldsScreen( onBack: () -> Unit, ) { val initial = fleet.decode - var source by remember { mutableStateOf(initial?.source ?: DecodeSource.MANUFACTURER_DATA) } - var companyText by remember { + var source by androidx.compose.runtime.saveable.rememberSaveable(fleet.id) { mutableStateOf(initial?.source ?: DecodeSource.MANUFACTURER_DATA) } + var companyText by androidx.compose.runtime.saveable.rememberSaveable(fleet.id) { mutableStateOf(initial?.companyId?.takeIf { it != 0 }?.let { "0x%04X".format(it) }.orEmpty()) } - var serviceUuid by remember { mutableStateOf(initial?.serviceUuid.orEmpty()) } - var fields by remember { mutableStateOf(initial?.fields ?: emptyList()) } + var serviceUuid by androidx.compose.runtime.saveable.rememberSaveable(fleet.id) { mutableStateOf(initial?.serviceUuid.orEmpty()) } + var fields by androidx.compose.runtime.saveable.rememberSaveable(fleet.id, stateSaver = app.fieldwatch.ui.jsonDraftSaver>()) { mutableStateOf(initial?.fields ?: emptyList()) } var confirmRemove by remember { mutableStateOf(false) } val includeCompanyId = initial?.includeCompanyId == true @@ -96,6 +105,7 @@ fun DecodeFieldsScreen( val previewRows = remember(previewDecode, previewDevice) { if (previewDecode == null || previewDevice == null) emptyList() else SignatureFieldDecoder.decodeFleet(fleet.copy(decode = previewDecode), previewDecode, previewDevice) + .forDisplay(listOf(fleet.copy(decode = previewDecode))) } val previewHex = remember(previewDecode, previewDevice) { if (previewDecode == null || previewDevice == null) null @@ -106,9 +116,9 @@ fun DecodeFieldsScreen( contentWindowInsets = NestedTabInsets, topBar = { NestedTopBar( - title = "Decode fields", - navigationIcon = { TextButton(onClick = onBack) { Text("Back") } }, - actions = { TextButton(onClick = { onSave(currentDecode()) }) { Text("Save") } }, + title = appText(R.string.decode_fields_screen_decode_fields), + navigationIcon = { TextButton(onClick = onBack) { Text(appText(R.string.decode_fields_screen_back)) } }, + actions = { TextButton(onClick = { onSave(currentDecode()) }) { Text(appText(R.string.decode_fields_screen_save)) } }, ) }, ) { pad -> @@ -120,9 +130,9 @@ fun DecodeFieldsScreen( .padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - SectionCard("Source") { + SectionCard(appText(R.string.decode_fields_screen_source)) { Text( - "Map cleartext BLE bytes after this signature matches. Encrypted ads stay hex.", + appText(R.string.decode_fields_screen_map_cleartext_ble_bytes_after_this_signature), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -130,23 +140,23 @@ fun DecodeFieldsScreen( FieldwatchFilterChip( selected = source == DecodeSource.MANUFACTURER_DATA, onClick = { source = DecodeSource.MANUFACTURER_DATA }, - label = { Text("Manufacturer") }, + label = { Text(appText(R.string.decode_fields_screen_manufacturer)) }, ) FieldwatchFilterChip( selected = source == DecodeSource.SERVICE_DATA, onClick = { source = DecodeSource.SERVICE_DATA }, - label = { Text("Service data") }, + label = { Text(appText(R.string.decode_fields_screen_service_data)) }, ) } if (source == DecodeSource.MANUFACTURER_DATA) { CompactField( companyText, { companyText = it }, - "Company ID", + appText(R.string.decode_fields_screen_company_id), modifier = Modifier.fillMaxWidth(), ) Text( - "Optional. Byte 0 is the first byte after the company ID. Empty = any record.", + appText(R.string.decode_fields_screen_optional_byte_0_is_the_first_byte), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -154,21 +164,23 @@ fun DecodeFieldsScreen( CompactField( serviceUuid, { serviceUuid = it }, - "Service UUID", + appText(R.string.decode_fields_screen_service_uuid), modifier = Modifier.fillMaxWidth(), ) Text( - "Required. 16-bit (FEAA) or full UUID.", + appText(R.string.decode_fields_screen_required_16_bit_feaa_or_full_uuid), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - SectionCard("Fields") { + SectionCard(appText(R.string.decode_fields_screen_fields)) { fields.forEachIndexed { index, field -> FieldCard( field = field, + preserveId = fleet.builtIn, + displayText = { part, value, raw -> CatalogText.decodeField(fleet, field, part, value, raw) }, onChange = { next -> fields = fields.toMutableList().also { it[index] = next } }, @@ -183,27 +195,27 @@ fun DecodeFieldsScreen( val n = fields.size + 1 fields = fields + DecodeField( id = "field_$n", - label = "Field $n", + label = appText(R.string.decode_fields_screen_field, n), offset = nextOffset, type = DecodeType.U8, ) }, modifier = Modifier.fillMaxWidth(), - ) { Text("Add field") } + ) { Text(appText(R.string.decode_fields_screen_add_field)) } if (initial != null || fields.isNotEmpty()) { FieldwatchActionButton( onClick = { confirmRemove = true }, modifier = Modifier.fillMaxWidth(), - ) { Text("Remove decode map") } + ) { Text(appText(R.string.decode_fields_screen_remove_decode_map)) } Text( - "Removes every field and the Live code mark. Save after adding fields still keeps the map.", + appText(R.string.decode_fields_screen_removes_every_field_and_the_live_code), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - SectionCard("Preview") { + SectionCard(appText(R.string.decode_fields_screen_preview)) { PreviewBlock( previewDevice = previewDevice, previewHex = previewHex, @@ -215,9 +227,9 @@ fun DecodeFieldsScreen( if (confirmRemove) { AlertDialog( onDismissRequest = { confirmRemove = false }, - title = { Text("Remove decode map?") }, + title = { Text(appText(R.string.decode_fields_screen_remove_decode_map_2)) }, text = { - Text("Clears all fields on this signature. Live no longer shows the code mark. Raw advertisements stay. This cannot be undone except by adding fields again.") + Text(appText(R.string.decode_fields_screen_clears_all_fields_on_this_signature_live)) }, confirmButton = { TextButton( @@ -225,10 +237,10 @@ fun DecodeFieldsScreen( confirmRemove = false onSave(null) }, - ) { Text("Remove") } + ) { Text(appText(R.string.decode_fields_screen_remove)) } }, dismissButton = { - TextButton(onClick = { confirmRemove = false }) { Text("Cancel") } + TextButton(onClick = { confirmRemove = false }) { Text(appText(R.string.decode_fields_screen_cancel)) } }, ) } @@ -243,14 +255,14 @@ private fun PreviewBlock( when { previewDevice == null -> { Text( - "No matching radio on the air. Save anyway; detail will fill in when one is heard.", + appText(R.string.decode_fields_screen_no_matching_radio_on_the_air_save), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } previewHex.isNullOrBlank() -> { Text( - "A matching radio is on the air, but this advertisement has no bytes for the source above.", + appText(R.string.decode_fields_screen_a_matching_radio_is_on_the_air), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -264,7 +276,7 @@ private fun PreviewBlock( ) if (previewRows.isEmpty()) { Text( - "Nothing parsed. Check offset, length, and that byte 0 is after the company ID.", + appText(R.string.decode_fields_screen_nothing_parsed_check_offset_length_and_that), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -292,6 +304,8 @@ private fun PreviewBlock( @Composable private fun FieldCard( field: DecodeField, + preserveId: Boolean, + displayText: (String, String, String) -> String, onChange: (DecodeField) -> Unit, onDelete: () -> Unit, ) { @@ -317,36 +331,36 @@ private fun FieldCard( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { CompactField( - field.label, + displayText("label", field.label, ""), { next -> - val id = if (looksGeneratedId(field.id, field.label)) slugId(next) else field.id + val id = if (!preserveId && looksGeneratedId(field.id, field.label)) slugId(next) else field.id onChange(field.copy(label = next, id = id)) }, - "Label", + appText(R.string.decode_fields_screen_label), modifier = Modifier.weight(1f), ) TypeMenu(field.type, Modifier.width(112.dp)) { onChange(field.copy(type = it)) } - IconButton(onClick = onDelete) { Icon(Icons.Outlined.Delete, "Delete field") } + IconButton(onClick = onDelete) { Icon(Icons.Outlined.Delete, appText(R.string.decode_fields_screen_delete_field)) } } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { CompactField( field.offset.toString(), { onChange(field.copy(offset = it.toIntOrNull() ?: 0)) }, - "Offset", + appText(R.string.decode_fields_screen_offset), keyboard = KeyboardType.Number, modifier = Modifier.weight(1f), ) CompactField( (field.length ?: field.type.defaultLength()).toString(), { onChange(field.copy(length = it.toIntOrNull()?.coerceAtLeast(1))) }, - "Length", + appText(R.string.decode_fields_screen_length), keyboard = KeyboardType.Number, modifier = Modifier.weight(1f), ) CompactField( field.unit.orEmpty(), { onChange(field.copy(unit = it.ifBlank { null })) }, - "Unit", + appText(R.string.decode_fields_screen_unit), modifier = Modifier.width(72.dp), ) if (needsEndian) { @@ -358,14 +372,14 @@ private fun FieldCard( CompactField( (field.bitOffset ?: 0).toString(), { onChange(field.copy(bitOffset = it.toIntOrNull() ?: 0)) }, - "Bit offset", + appText(R.string.decode_fields_screen_bit_offset), keyboard = KeyboardType.Number, modifier = Modifier.weight(1f), ) CompactField( (field.bitWidth ?: 1).toString(), { onChange(field.copy(bitWidth = it.toIntOrNull()?.coerceAtLeast(1) ?: 1)) }, - "Bit width", + appText(R.string.decode_fields_screen_bit_width), keyboard = KeyboardType.Number, modifier = Modifier.weight(1f), ) @@ -376,14 +390,14 @@ private fun FieldCard( CompactField( field.scale?.toString().orEmpty(), { onChange(field.copy(scale = it.toDoubleOrNull())) }, - "Scale", + appText(R.string.decode_fields_screen_scale), keyboard = KeyboardType.Decimal, modifier = Modifier.weight(1f), ) CompactField( field.offsetAdd?.toString().orEmpty(), { onChange(field.copy(offsetAdd = it.toDoubleOrNull())) }, - "Add", + appText(R.string.decode_fields_screen_add), keyboard = KeyboardType.Decimal, modifier = Modifier.weight(1f), ) @@ -395,9 +409,9 @@ private fun FieldCard( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Column(Modifier.weight(1f)) { - Text("Live row", style = MaterialTheme.typography.titleSmall) + Text(appText(R.string.decode_fields_screen_live_row), style = MaterialTheme.typography.titleSmall) Text( - "Show this value next to the signature name. Strong values use a heavier chip.", + appText(R.string.decode_fields_screen_show_this_value_next_to_the_signature), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -407,9 +421,9 @@ private fun FieldCard( onCheckedChange = { onChange(field.copy(live = it)) }, ) } - NamedValuesBlock(field, onChange) + NamedValuesBlock(field, onChange, displayText) if (!more) { - TextButton(onClick = { more = true }) { Text("More") } + TextButton(onClick = { more = true }) { Text(appText(R.string.decode_fields_screen_more)) } } else { CompactField( field.id, @@ -421,12 +435,12 @@ private fun FieldCard( CompactField( field.modulo?.toString().orEmpty(), { onChange(field.copy(modulo = it.toDoubleOrNull())) }, - "Modulo", + appText(R.string.decode_fields_screen_modulo), keyboard = KeyboardType.Decimal, modifier = Modifier.fillMaxWidth(), ) } - TextButton(onClick = { more = false }) { Text("Hide extra") } + TextButton(onClick = { more = false }) { Text(appText(R.string.decode_fields_screen_hide_extra)) } } } } @@ -438,10 +452,10 @@ private fun OnlyIfBlock(gate: DecodeWhen?, onChange: (DecodeWhen?) -> Unit) { if (gate == null) { TextButton(onClick = { onChange(DecodeWhen(offset = 0, length = 1, op = DecodeWhenOp.EQ, valueHex = "")) - }) { Text("Only if…") } + }) { Text(appText(R.string.decode_fields_screen_only_if)) } return } - Text("Only if", style = MaterialTheme.typography.titleSmall) + Text(appText(R.string.decode_fields_screen_only_if_2), style = MaterialTheme.typography.titleSmall) if (gate.op == DecodeWhenOp.LEN) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -451,7 +465,7 @@ private fun OnlyIfBlock(gate: DecodeWhen?, onChange: (DecodeWhen?) -> Unit) { CompactField( gate.length.toString(), { onChange(gate.copy(length = it.toIntOrNull()?.coerceAtLeast(1) ?: 1)) }, - "Bytes", + appText(R.string.decode_fields_screen_bytes), keyboard = KeyboardType.Number, modifier = Modifier.weight(1f), ) @@ -461,14 +475,14 @@ private fun OnlyIfBlock(gate: DecodeWhen?, onChange: (DecodeWhen?) -> Unit) { CompactField( gate.offset.toString(), { onChange(gate.copy(offset = it.toIntOrNull() ?: 0)) }, - "Offset", + appText(R.string.decode_fields_screen_offset), keyboard = KeyboardType.Number, modifier = Modifier.weight(1f), ) CompactField( gate.length.toString(), { onChange(gate.copy(length = it.toIntOrNull()?.coerceAtLeast(1) ?: 1)) }, - "Length", + appText(R.string.decode_fields_screen_length), keyboard = KeyboardType.Number, modifier = Modifier.weight(1f), ) @@ -481,25 +495,26 @@ private fun OnlyIfBlock(gate: DecodeWhen?, onChange: (DecodeWhen?) -> Unit) { CompactField( gate.valueHex, { onChange(gate.copy(valueHex = it)) }, - "Hex", + appText(R.string.decode_fields_screen_hex), modifier = Modifier.width(96.dp), ) } } - TextButton(onClick = { onChange(null) }) { Text("Remove") } + TextButton(onClick = { onChange(null) }) { Text(appText(R.string.decode_fields_screen_remove)) } } @Composable private fun NamedValuesBlock( field: DecodeField, onChange: (DecodeField) -> Unit, + displayText: (String, String, String) -> String, ) { val fieldId = field.id val labels = field.enumLabels - var rows by remember(fieldId) { + var rows by rememberSaveable(fieldId, stateSaver = jsonDraftSaver>>()) { mutableStateOf(labels?.toList() ?: emptyList()) } - var open by remember(fieldId) { mutableStateOf(rows.isNotEmpty()) } + var open by rememberSaveable(fieldId) { mutableStateOf(rows.isNotEmpty()) } fun publish( nextRows: List>, emphasis: List = field.liveEmphasis, @@ -517,10 +532,10 @@ private fun NamedValuesBlock( TextButton(onClick = { open = true rows = listOf("" to "") - }) { Text("Named values…") } + }) { Text(appText(R.string.decode_fields_screen_named_values)) } return } - Text("Named values", style = MaterialTheme.typography.titleSmall) + Text(appText(R.string.decode_fields_screen_named_values_2), style = MaterialTheme.typography.titleSmall) rows.forEachIndexed { index, (raw, shown) -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Row( @@ -536,17 +551,17 @@ private fun NamedValuesBlock( val notes = field.enumNotes?.mapKeys { (key, _) -> if (key == raw) next else key } publish(nextRows, emphasis, notes) }, - "Raw", + appText(R.string.decode_fields_screen_raw), modifier = Modifier.width(88.dp), ) CompactField( - shown, + displayText("enum", shown, raw), { next -> val nextRows = rows.toMutableList().also { it[index] = raw to next } rows = nextRows publish(nextRows) }, - "Show as", + appText(R.string.decode_fields_screen_show_as), modifier = Modifier.weight(1f), ) IconButton( @@ -562,7 +577,7 @@ private fun NamedValuesBlock( publish(nextRows, emphasis, notes) } }, - ) { Icon(Icons.Outlined.Delete, "Delete value") } + ) { Icon(Icons.Outlined.Delete, appText(R.string.decode_fields_screen_delete_value)) } } if (field.live && raw.isNotBlank()) { Row( @@ -580,16 +595,16 @@ private fun NamedValuesBlock( } publish(rows, emphasis) }, - label = { Text("Strong") }, + label = { Text(appText(R.string.decode_fields_screen_strong)) }, ) CompactField( - field.enumNotes?.get(raw).orEmpty(), + displayText("note", field.enumNotes?.get(raw).orEmpty(), raw), { note -> val notes = (field.enumNotes ?: emptyMap()).toMutableMap() if (note.isBlank()) notes.remove(raw) else notes[raw] = note publish(rows, notes = notes) }, - "Note", + appText(R.string.decode_fields_screen_note), modifier = Modifier.weight(1f), ) } @@ -597,14 +612,14 @@ private fun NamedValuesBlock( } } Row { - TextButton(onClick = { rows = rows + ("" to "") }) { Text("Add value") } + TextButton(onClick = { rows = rows + ("" to "") }) { Text(appText(R.string.decode_fields_screen_add_value)) } TextButton( onClick = { open = false rows = emptyList() onChange(field.copy(enumLabels = null, liveEmphasis = emptyList(), enumNotes = null)) }, - ) { Text("Remove") } + ) { Text(appText(R.string.decode_fields_screen_remove)) } } } @@ -630,20 +645,20 @@ private fun CompactField( private fun WhenOpMenu(op: DecodeWhenOp, modifier: Modifier, onChange: (DecodeWhenOp) -> Unit) { var open by remember { mutableStateOf(false) } val label = when (op) { - DecodeWhenOp.EQ -> "equals" - DecodeWhenOp.NEQ -> "not equals" - DecodeWhenOp.MASK -> "mask" - DecodeWhenOp.NMASK -> "none of bits" - DecodeWhenOp.LEN -> "length" + DecodeWhenOp.EQ -> appText(R.string.decode_fields_screen_equals) + DecodeWhenOp.NEQ -> appText(R.string.decode_fields_screen_not_equals) + DecodeWhenOp.MASK -> appText(R.string.decode_fields_screen_mask) + DecodeWhenOp.NMASK -> appText(R.string.decode_fields_screen_none_of_bits) + DecodeWhenOp.LEN -> appText(R.string.decode_fields_screen_length_2) } ExposedDropdownMenuBox(open, { open = it }, modifier) { - FieldwatchDropdownField("When", label, open) + FieldwatchDropdownField(appText(R.string.decode_fields_screen_when), label, open) ExposedDropdownMenu(open, { open = false }) { - DropdownMenuItem(text = { Text("equals") }, onClick = { onChange(DecodeWhenOp.EQ); open = false }) - DropdownMenuItem(text = { Text("not equals") }, onClick = { onChange(DecodeWhenOp.NEQ); open = false }) - DropdownMenuItem(text = { Text("mask") }, onClick = { onChange(DecodeWhenOp.MASK); open = false }) - DropdownMenuItem(text = { Text("none of bits") }, onClick = { onChange(DecodeWhenOp.NMASK); open = false }) - DropdownMenuItem(text = { Text("payload length") }, onClick = { onChange(DecodeWhenOp.LEN); open = false }) + DropdownMenuItem(text = { Text(appText(R.string.decode_fields_screen_equals)) }, onClick = { onChange(DecodeWhenOp.EQ); open = false }) + DropdownMenuItem(text = { Text(appText(R.string.decode_fields_screen_not_equals)) }, onClick = { onChange(DecodeWhenOp.NEQ); open = false }) + DropdownMenuItem(text = { Text(appText(R.string.decode_fields_screen_mask)) }, onClick = { onChange(DecodeWhenOp.MASK); open = false }) + DropdownMenuItem(text = { Text(appText(R.string.decode_fields_screen_none_of_bits)) }, onClick = { onChange(DecodeWhenOp.NMASK); open = false }) + DropdownMenuItem(text = { Text(appText(R.string.decode_fields_screen_payload_length)) }, onClick = { onChange(DecodeWhenOp.LEN); open = false }) } } } @@ -664,7 +679,7 @@ private fun List>.toEnumMap(): Map? { private fun TypeMenu(type: DecodeType, modifier: Modifier, onChange: (DecodeType) -> Unit) { var open by remember { mutableStateOf(false) } ExposedDropdownMenuBox(open, { open = it }, modifier) { - FieldwatchDropdownField("Type", type.name.lowercase(), open) + FieldwatchDropdownField(appText(R.string.decode_fields_screen_type), type.name.lowercase(), open) ExposedDropdownMenu(open, { open = false }) { DecodeType.entries.forEach { t -> DropdownMenuItem( @@ -681,10 +696,10 @@ private fun TypeMenu(type: DecodeType, modifier: Modifier, onChange: (DecodeType private fun EndianMenu(endian: DecodeEndian, modifier: Modifier, onChange: (DecodeEndian) -> Unit) { var open by remember { mutableStateOf(false) } ExposedDropdownMenuBox(open, { open = it }, modifier) { - FieldwatchDropdownField("Endian", if (endian == DecodeEndian.BE) "BE" else "LE", open) + FieldwatchDropdownField(appText(R.string.decode_fields_screen_endian), if (endian == DecodeEndian.BE) "BE" else "LE", open) ExposedDropdownMenu(open, { open = false }) { - DropdownMenuItem(text = { Text("little") }, onClick = { onChange(DecodeEndian.LE); open = false }) - DropdownMenuItem(text = { Text("big") }, onClick = { onChange(DecodeEndian.BE); open = false }) + DropdownMenuItem(text = { Text(appText(R.string.decode_fields_screen_little)) }, onClick = { onChange(DecodeEndian.LE); open = false }) + DropdownMenuItem(text = { Text(appText(R.string.decode_fields_screen_big)) }, onClick = { onChange(DecodeEndian.BE); open = false }) } } } diff --git a/app/src/main/java/app/fieldwatch/ui/screen/DeviceDetailScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/DeviceDetailScreen.kt index c474015..e14da09 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/DeviceDetailScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/DeviceDetailScreen.kt @@ -1,5 +1,13 @@ package app.fieldwatch.ui.screen +import app.fieldwatch.i18n.codLabel + +import app.fieldwatch.i18n.forDisplay + +import app.fieldwatch.i18n.appText + +import app.fieldwatch.R + import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -101,6 +109,7 @@ fun DeviceDetailScreen( .nightIf(LocalNightMode.current) val facts = device.facts val familyHint by vm.familyHint.collectAsStateWithLifecycle() + val currentUi by vm.ui.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() Scaffold( @@ -114,11 +123,11 @@ fun DeviceDetailScreen( Text(MacUtil.redactMacIn(title, device.mac, demoMode), maxLines = 1) }, navigationIcon = { - IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, appText(R.string.device_detail_screen_back)) } }, actions = { IconButton(onClick = { vm.toggleWatchDevice(device) }) { - Icon(if (watched) Icons.Outlined.Bookmark else Icons.Outlined.BookmarkBorder, "Watch") + Icon(if (watched) Icons.Outlined.Bookmark else Icons.Outlined.BookmarkBorder, appText(R.string.device_detail_screen_watch)) } }, ) @@ -135,18 +144,18 @@ fun DeviceDetailScreen( Text(MacUtil.screenMac(device.mac, demoMode), fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.titleMedium) if (device.gone) { Text( - "Not on the air. This is the last detail we heard.", + appText(R.string.device_detail_screen_not_on_the_air_this_is_the), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - var nameDraft by remember(device.key) { + var nameDraft by androidx.compose.runtime.saveable.rememberSaveable(device.key) { mutableStateOf(vm.watchLabelFor(device.key).orEmpty()) } - var lastSaved by remember(device.key) { + var lastSaved by androidx.compose.runtime.saveable.rememberSaveable(device.key) { mutableStateOf(vm.watchLabelFor(device.key).orEmpty()) } - var editingName by remember(device.key) { mutableStateOf(false) } + var editingName by androidx.compose.runtime.saveable.rememberSaveable(device.key) { mutableStateOf(false) } val draftLabel = RadioBookmarks.clip(nameDraft) val nameIsSaved = lastSaved.isNotBlank() && draftLabel == lastSaved val canName = RadioBookmarks.canSetCustomName(device) @@ -154,37 +163,37 @@ fun DeviceDetailScreen( Column(Modifier.weight(1f)) { if (lastSaved.isNotBlank()) { Text( - "Custom name", + appText(R.string.device_detail_screen_custom_name), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text(lastSaved, style = MaterialTheme.typography.titleLarge) Text( - "Advertised", + appText(R.string.device_detail_screen_advertised), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp), ) Text( - device.name.ifBlank { "No advertised name" }, + device.name.ifBlank { appText(R.string.device_detail_screen_no_advertised_name) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else if (device.name.isNotBlank()) { Text( - "Advertised name", + appText(R.string.device_detail_screen_advertised_name), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text(device.name, style = MaterialTheme.typography.bodyMedium) } else { Text( - "Name", + appText(R.string.device_detail_screen_name), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - "No advertised name", + appText(R.string.device_detail_screen_no_advertised_name), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -194,7 +203,7 @@ fun DeviceDetailScreen( IconButton(onClick = { editingName = !editingName }) { Icon( Icons.Outlined.Edit, - if (editingName) "Hide custom name" else "Custom name", + if (editingName) appText(R.string.device_detail_screen_hide_custom_name) else appText(R.string.device_detail_screen_custom_name), ) } } @@ -203,7 +212,7 @@ fun DeviceDetailScreen( FieldwatchOutlinedField( value = nameDraft, onValueChange = { nameDraft = it.take(RadioBookmarks.MAX_NAME) }, - label = "Custom name", + label = appText(R.string.device_detail_screen_custom_name), supportingText = RadioBookmarks.customNameHint(device), ) FieldwatchActionButton( @@ -212,7 +221,7 @@ fun DeviceDetailScreen( nameDraft = draftLabel lastSaved = draftLabel scope.launch { - snackbarHostState.showSnackbar("Saved as $draftLabel") + snackbarHostState.showSnackbar(appText(R.string.device_detail_screen_saved_as, draftLabel)) } }, modifier = Modifier.fillMaxWidth(), @@ -221,20 +230,20 @@ fun DeviceDetailScreen( if (nameIsSaved) { Icon(Icons.Outlined.Check, null, modifier = Modifier.size(18.dp)) Spacer(Modifier.padding(4.dp)) - Text("Saved") + Text(appText(R.string.device_detail_screen_saved)) } else { - Text("Save name") + Text(appText(R.string.device_detail_screen_save_name)) } } } - var notesDraft by remember(device.key) { + var notesDraft by androidx.compose.runtime.saveable.rememberSaveable(device.key) { mutableStateOf(vm.watchObserverNoteFor(device.key).orEmpty()) } - var lastSavedNotes by remember(device.key) { + var lastSavedNotes by androidx.compose.runtime.saveable.rememberSaveable(device.key) { mutableStateOf(vm.watchObserverNoteFor(device.key).orEmpty()) } - var editingNotes by remember(device.key) { mutableStateOf(false) } + var editingNotes by androidx.compose.runtime.saveable.rememberSaveable(device.key) { mutableStateOf(false) } val draftNotes = RadioBookmarks.clipNotes(notesDraft) val notesIsSaved = draftNotes == lastSavedNotes if (canName || lastSavedNotes.isNotBlank()) { @@ -260,7 +269,7 @@ fun DeviceDetailScreen( editingNotes = false scope.launch { snackbarHostState.showSnackbar( - if (draftNotes.isBlank()) "Observer notes cleared" else "Observer notes saved", + if (draftNotes.isBlank()) appText(R.string.device_detail_screen_observer_notes_cleared) else appText(R.string.device_detail_screen_observer_notes_saved), ) } }, @@ -281,9 +290,9 @@ fun DeviceDetailScreen( verticalAlignment = Alignment.CenterVertically, ) { Column(Modifier.weight(1f)) { - Text("Mine", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text(appText(R.string.device_detail_screen_mine), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) Text( - "While on, this radio stays listed and does not beep.", + appText(R.string.device_detail_screen_while_on_this_radio_stays_listed_and), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -304,7 +313,7 @@ fun DeviceDetailScreen( if (on) { scope.launch { snackbarHostState.showSnackbar( - "Marked as yours. No beep while this is on. Still listed.", + appText(R.string.device_detail_screen_marked_as_yours_no_beep_while_this), ) } } @@ -326,68 +335,68 @@ fun DeviceDetailScreen( } StickyHeight(device.key to "identity") { - Section("Identity") + Section(appText(R.string.device_detail_screen_identity)) Meta( - "Radio", + appText(R.string.device_detail_screen_radio), if (device.kind == RadioKind.WIFI) { - "Wi-Fi access point (beaconing a network)" + appText(R.string.device_detail_screen_wi_fi_access_point_beaconing_a_network) } else { - "Bluetooth Low Energy advertiser" + appText(R.string.device_detail_screen_bluetooth_low_energy_advertiser) }, ) - Meta("Address", DeviceExplain.addressExplain(device)) - vendorLine(device)?.let { Meta("Who made it", it) } - ?: Meta("OUI (vendor prefix)", "${device.oui} — no IEEE match; randomized addresses usually have none") + Meta(appText(R.string.device_detail_screen_address), DeviceExplain.addressExplain(device)) + vendorLine(device)?.let { Meta(appText(R.string.device_detail_screen_who_made_it), it) } + ?: Meta(appText(R.string.device_detail_screen_oui_vendor_prefix), appText(R.string.device_detail_screen_no_ieee_match_randomized_addresses_usually_have, device.oui)) if (device.hiddenSsid) { - Meta("Network name (SSID)", "Hidden — the AP is beaconing but not publishing a name") + Meta(appText(R.string.device_detail_screen_network_name_ssid), appText(R.string.device_detail_screen_hidden_the_ap_is_beaconing_but_not)) } } StickyHeight(device.key to "signal") { - Section("Signal") + Section(appText(R.string.device_detail_screen_signal)) if (device.gone) { - Meta("How loud here (RSSI)", "Not available") + Meta(appText(R.string.device_detail_screen_how_loud_here_rssi), appText(R.string.device_detail_screen_not_available)) Meta( - "Last heard", + appText(R.string.device_detail_screen_last_heard), buildString { append(fmt.format(Date(device.lastSeen))) Rssi.lastMeasured(device.rssi, device.rssiHistory)?.let { - append(" at $it dBm") + append(appText(R.string.device_detail_screen_at_dbm, it)) } }, ) } else { - Meta("How loud here (RSSI)", DeviceExplain.rssiExplain(device.rssi)) + Meta(appText(R.string.device_detail_screen_how_loud_here_rssi), DeviceExplain.rssiExplain(device.rssi)) Text( - "Closer to 0 dBm is louder here, not a distance.", + appText(R.string.device_detail_screen_closer_to_0_dbm_is_louder_here), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Meta( - "Heard range this session", + appText(R.string.device_detail_screen_heard_range_this_session), Rssi.sessionRange(device.rssiMin, device.rssiMax, device.rssiHistory), ) facts.txPowerDbm?.let { - Meta("Claimed transmit power", "$it dBm — how loud it says it transmits, not a distance") + Meta(appText(R.string.device_detail_screen_claimed_transmit_power), appText(R.string.device_detail_screen_dbm_how_loud_it_says_it_transmits, it)) } if (device.channel != 0 || device.frequencyMhz != 0) { Meta( - "Channel / frequency", + appText(R.string.device_detail_screen_channel_frequency), buildString { - if (device.channel != 0) append("channel ${device.channel}") + if (device.channel != 0) append(appText(R.string.device_detail_screen_channel, device.channel)) if (device.frequencyMhz != 0) { if (isNotEmpty()) append(" · ") append("${device.frequencyMhz} MHz") } - facts.channelWidth?.let { append(" · $it wide") } + facts.channelWidth?.let { append(appText(R.string.device_detail_screen_wide, it)) } }, ) } - facts.wifiStandard?.let { Meta("Wi-Fi generation", it) } + facts.wifiStandard?.let { Meta(appText(R.string.device_detail_screen_wi_fi_generation), it) } if (facts.centerFreq0 != null || facts.centerFreq1 != null) { Meta( - "Center frequencies", + appText(R.string.device_detail_screen_center_frequencies), listOfNotNull( facts.centerFreq0?.let { "$it MHz" }, facts.centerFreq1?.let { "$it MHz" }, @@ -398,50 +407,50 @@ fun DeviceDetailScreen( if (device.kind == RadioKind.BLE) { StickyHeight(device.key to "ble") { - Section("Bluetooth advertisement") + Section(appText(R.string.device_detail_screen_bluetooth_advertisement)) facts.primaryPhy?.let { val phys = listOfNotNull(it, facts.secondaryPhy).distinct() - Meta("Radio PHY", phys.joinToString(" / ") { phy -> DeviceExplain.phyExplain(phy) }) + Meta(appText(R.string.device_detail_screen_radio_phy), phys.joinToString(" / ") { phy -> DeviceExplain.phyExplain(phy) }) } facts.connectable?.let { Meta( - "Connectable", - if (it) "Yes — a phone could open a BLE connection" - else "No — broadcast-only (you can hear it, not join it from this scan)", + appText(R.string.device_detail_screen_connectable), + if (it) appText(R.string.device_detail_screen_yes_a_phone_could_open_a_ble) + else appText(R.string.device_detail_screen_no_broadcast_only_you_can_hear_it), ) } facts.advertisingIntervalMs?.let { Meta( - "How often it advertises", - "%.0f ms between bursts (smaller = chattier on the air)".format(it), + appText(R.string.device_detail_screen_how_often_it_advertises), + appText(R.string.device_detail_screen_0f_ms_between_bursts_smaller_chattier_on).format(it), ) } facts.periodicIntervalMs?.let { - Meta("Periodic advertising", "%.0f ms".format(it)) + Meta(appText(R.string.device_detail_screen_periodic_advertising), "%.0f ms".format(it)) } facts.advFlags?.let { flags -> - Meta("Discoverability", DeviceExplain.flagsExplain(flags)) - Meta("Flags (raw)", "0x%02X".format(flags), mono = true) + Meta(appText(R.string.device_detail_screen_discoverability), DeviceExplain.flagsExplain(flags)) + Meta(appText(R.string.device_detail_screen_flags_raw), "0x%02X".format(flags), mono = true) } facts.appearance?.let { value -> val name = RadioDb.appearance(value) Meta( - "What it says it is (Appearance)", - name?.let { "$it\nThe device publishes this GAP Appearance code to describe itself." } - ?: "Unlisted Appearance 0x%04X".format(value), + appText(R.string.device_detail_screen_what_it_says_it_is_appearance), + name?.let { appText(R.string.device_detail_screen_nthe_device_publishes_this_gap_appearance_code, it) } + ?: appText(R.string.device_detail_screen_unlisted_appearance_0x_04x).format(value), ) - Meta("Appearance code", "0x%04X".format(value), mono = true) + Meta(appText(R.string.device_detail_screen_appearance_code), "0x%04X".format(value), mono = true) } CodDecoder.decodeOrNull(facts.deviceClass)?.let { cod -> Meta( - "Classic Bluetooth class", + appText(R.string.device_detail_screen_classic_bluetooth_class), buildString { - append(cod.major) - if (cod.minor.isNotBlank()) append(" / ").append(cod.minor) - append("\nThis is the Class of Device bitfield used by classic Bluetooth.") + append(codLabel(cod.major)) + if (cod.minor.isNotBlank()) append(" / ").append(codLabel(cod.minor)) + append(appText(R.string.device_detail_screen_nthis_is_the_class_of_device_bitfield)) if (cod.services.isNotEmpty()) { - append("\nAlso offers: ") - append(cod.services.joinToString(", ")) + append(appText(R.string.device_detail_screen_nalso_offers)) + append(cod.services.joinToString(", ") { codLabel(it) }) } }, ) @@ -451,16 +460,16 @@ fun DeviceDetailScreen( if (device.kind == RadioKind.WIFI) { StickyHeight(device.key to "wifi") { - Section("Wi-Fi access point") + Section(appText(R.string.device_detail_screen_wi_fi_access_point)) facts.security?.let { - Meta("Encryption / login", DeviceExplain.wifiSecurityExplain(it)) - if (it.isNotBlank()) Meta("Security string", it, mono = true) + Meta(appText(R.string.device_detail_screen_encryption_login), DeviceExplain.wifiSecurityExplain(it)) + if (it.isNotBlank()) Meta(appText(R.string.device_detail_screen_security_string), it, mono = true) } facts.supportedRates?.let { - Meta("Supported rates", "$it Mbps (* = required basic rate)") + Meta(appText(R.string.device_detail_screen_supported_rates), appText(R.string.device_detail_screen_mbps_required_basic_rate, it)) } facts.capabilities?.takeIf { it.isNotBlank() && it != facts.security }?.let { - Meta("Capability string", it, mono = true) + Meta(appText(R.string.device_detail_screen_capability_string), it, mono = true) } } } @@ -468,9 +477,9 @@ fun DeviceDetailScreen( if (device.serviceUuids.isNotEmpty() || facts.serviceData.isNotEmpty()) { StickyHeight(device.key to "services") { if (device.serviceUuids.isNotEmpty()) { - Section("Services it offers") + Section(appText(R.string.device_detail_screen_services_it_offers)) Meta( - "Service IDs", + appText(R.string.device_detail_screen_service_ids), device.serviceUuids.joinToString("\n") { uuid -> DeviceExplain.uuidGloss(uuid)?.let { "$uuid · $it" } ?: uuid }, @@ -482,7 +491,7 @@ fun DeviceDetailScreen( decoded.forEach { field -> Meta(field.label, field.value) } Meta( serviceDataHeading(sd), - sd.dataHex.hexSpaced().ifBlank { "(empty)" }, + sd.dataHex.hexSpaced().ifBlank { appText(R.string.device_detail_screen_empty) }, mono = true, ) } @@ -491,10 +500,10 @@ fun DeviceDetailScreen( if (device.kind == RadioKind.BLE || device.kind == RadioKind.WIFI) { val aircraft = device.payloadAircraft?.trim().orEmpty() - if (aircraft.isNotEmpty()) Meta("Aircraft", aircraft) - val fleets = vm.ui.value.fleets - val decoded = remember(device.key, device.facts, device.fleetIds) { - SignatureFieldDecoder.decodeSighting(device, fleets) + if (aircraft.isNotEmpty()) Meta(appText(R.string.device_detail_screen_aircraft), aircraft) + val fleets = currentUi.fleets + val decoded = remember(device.key, device.facts, device.fleetIds, fleets) { + SignatureFieldDecoder.decodeSighting(device, fleets).forDisplay(fleets) } val mapped = device.fleetIds.mapNotNull { id -> fleets.find { it.id == id && it.decode != null } } val hasPayload = device.facts.mfgRecords.isNotEmpty() || @@ -511,7 +520,7 @@ fun DeviceDetailScreen( size = 16.dp, ) Text( - "Decoded fields", + appText(R.string.device_detail_screen_decoded_fields), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, ) @@ -532,13 +541,13 @@ fun DeviceDetailScreen( Text( when { hasPayload && govee -> - "Decode fields did not apply to this advertisement (short payload, a different company ID, or a different layout). Govee lights usually only send a name; hygrometers are H5074/H5075/H510x. Raw bytes are below." + appText(R.string.device_detail_screen_decode_fields_did_not_apply_to_this) hasPayload -> - "Decode fields did not apply to this advertisement (short payload, a different company ID, or a different layout). Raw bytes are below." + appText(R.string.device_detail_screen_decode_fields_did_not_apply_to_this_2) govee -> - "This signature has a decode map, but this advertisement has no manufacturer or service payload to parse. Many Govee lights only broadcast a name." + appText(R.string.device_detail_screen_this_signature_has_a_decode_map_but) else -> - "This signature has a decode map, but this advertisement has no manufacturer or service payload to parse." + appText(R.string.device_detail_screen_this_signature_has_a_decode_map_but_2) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -553,17 +562,17 @@ fun DeviceDetailScreen( } if (mfg.isNotEmpty()) { StickyHeight(device.key to "mfg") { - Section("Maker data inside the ad") + Section(appText(R.string.device_detail_screen_maker_data_inside_the_ad)) mfg.forEach { rec -> - val company = RadioDb.company(rec.companyId) ?: "Not in the Bluetooth company list" + val company = RadioDb.company(rec.companyId) ?: appText(R.string.device_detail_screen_not_in_the_bluetooth_company_list) Meta( - "Bluetooth company 0x%04X".format(rec.companyId), - "$company\nThis ID is assigned by the Bluetooth SIG and is carried in manufacturer-specific data.", + appText(R.string.device_detail_screen_bluetooth_company_0x_04x).format(rec.companyId), + appText(R.string.device_detail_screen_nthis_id_is_assigned_by_the_bluetooth, company), ) val decoded = BleAdParser.mfgDecodedFields(rec) decoded.forEach { (k, v) -> Meta(k, v) } if (rec.dataHex.isNotBlank()) { - Meta("Raw payload (${rec.dataHex.length / 2} bytes)", rec.dataHex.hexSpaced(), mono = true) + Meta(appText(R.string.device_detail_screen_raw_payload_bytes, rec.dataHex.length / 2), rec.dataHex.hexSpaced(), mono = true) } } } @@ -571,18 +580,18 @@ fun DeviceDetailScreen( if (facts.vendorIes.isNotEmpty() || device.vendorIeOuis.isNotEmpty()) { StickyHeight(device.key to "ies") { - Section("Wi-Fi vendor tags") + Section(appText(R.string.device_detail_screen_wi_fi_vendor_tags)) val rows = facts.vendorIes.ifEmpty { device.vendorIeOuis.map { app.fieldwatch.domain.VendorIeRecord(it, -1, "") } } rows.forEach { ie -> val org = RadioDb.vendorForOui24(ie.oui) - val type = if (ie.type >= 0) " type %d".format(ie.type) else "" + val type = if (ie.type >= 0) appText(R.string.device_detail_screen_type_d).format(ie.type) else "" Meta( - "Vendor OUI ${ie.oui}$type", + appText(R.string.device_detail_screen_vendor_oui, ie.oui, type), buildString { - append(org ?: "Unknown IEEE OUI") - append(" — extra AP information element, not the SSID.") + append(org ?: appText(R.string.device_detail_screen_unknown_ieee_oui)) + append(appText(R.string.device_detail_screen_extra_ap_information_element_not_the_ssid)) if (ie.dataHex.isNotBlank()) { append("\n") append(ie.dataHex.hexSpaced()) @@ -594,28 +603,28 @@ fun DeviceDetailScreen( } StickyHeight(device.key to "session") { - Section("Session") - Meta("First seen", fmt.format(Date(device.firstSeen))) - Meta("Last seen", fmt.format(Date(device.lastSeen))) - Meta("Hits", device.hitCount.toString()) - Geo.screenCoord(device.latitude, device.longitude, demoMode)?.let { Meta("Last fix", it) } + Section(appText(R.string.device_detail_screen_session)) + Meta(appText(R.string.device_detail_screen_first_seen), fmt.format(Date(device.firstSeen))) + Meta(appText(R.string.device_detail_screen_last_seen), fmt.format(Date(device.lastSeen))) + Meta(appText(R.string.device_detail_screen_hits), device.hitCount.toString()) + Geo.screenCoord(device.latitude, device.longitude, demoMode)?.let { Meta(appText(R.string.device_detail_screen_last_fix), it) } if (device.fleetIds.isNotEmpty()) { Meta( - "Matched signatures", + appText(R.string.device_detail_screen_matched_signatures), device.fleetIds.joinToString("\n") { id -> - val name = vm.fleetName(id) + val name = vm.fleetDisplayName(id) if (vm.fleetHasDecode(id)) "$name ⬡" else name }, ) } if (device.rawHex.isNotBlank() && device.kind == RadioKind.BLE) { - Meta("Raw advertisement", device.rawHex.hexSpaced()) + Meta(appText(R.string.device_detail_screen_raw_advertisement), device.rawHex.hexSpaced()) } } - Text("Signal trend", style = MaterialTheme.typography.titleSmall) + Text(appText(R.string.device_detail_screen_signal_trend), style = MaterialTheme.typography.titleSmall) Sparkline(device.rssiHistory, accent, modifier = Modifier.fillMaxWidth().height(56.dp)) - Text("Presence (15 min)", style = MaterialTheme.typography.titleSmall) + Text(appText(R.string.device_detail_screen_presence_15_min), style = MaterialTheme.typography.titleSmall) PresenceTrack(device, System.currentTimeMillis(), 15 * 60 * 1000L, accent) if (device.kind == RadioKind.BLE) { FieldwatchActionButton( @@ -624,11 +633,11 @@ fun DeviceDetailScreen( ) { Icon(Icons.Outlined.NearMe, null) Spacer(Modifier.padding(4.dp)) - Text("Hunt") + Text(appText(R.string.device_detail_screen_hunt)) } } else { Text( - "Hunt is BLE only. Wi-Fi access points update too slowly on stock Android to walk toward.", + appText(R.string.device_detail_screen_hunt_is_ble_only_wi_fi_access), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -642,7 +651,7 @@ fun DeviceDetailScreen( ) { Icon(Icons.Outlined.GroupAdd, null) Spacer(Modifier.padding(4.dp)) - Text("Create signature from device") + Text(appText(R.string.device_detail_screen_create_signature_from_device)) } FieldwatchActionButton( onClick = { vm.startDeviceDetailShare(device) }, @@ -650,7 +659,7 @@ fun DeviceDetailScreen( ) { Icon(Icons.Outlined.Share, null) Spacer(Modifier.padding(4.dp)) - Text("Share as text") + Text(appText(R.string.device_detail_screen_share_as_text)) } FieldwatchActionButton( onClick = { vm.startDeviceDetailAiExport(device) }, @@ -658,10 +667,10 @@ fun DeviceDetailScreen( ) { Icon(Icons.Outlined.AutoAwesome, null) Spacer(Modifier.padding(4.dp)) - Text("AI Export") + Text(appText(R.string.device_detail_screen_ai_export)) } Text( - "Opens a paste-ready prompt for a chat: decode this radio, look up OUI/company/UUIDs, and say what it most likely is. Same experimental disclaimer as Settings → AI Export. One device only — not identity.", + appText(R.string.device_detail_screen_opens_a_paste_ready_prompt_for_a), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -691,7 +700,7 @@ private fun FamilyCard(hint: SignatureFamilyHint) { Row(verticalAlignment = Alignment.Top) { Column(Modifier.weight(1f)) { Text( - "Signature family", + appText(R.string.device_detail_screen_signature_family), style = MaterialTheme.typography.labelSmall, color = muted, ) @@ -734,7 +743,7 @@ private fun SignatureNotesCard(notes: List>) { ) { Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( - "Notes", + appText(R.string.device_detail_screen_notes), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -771,7 +780,7 @@ private fun ObserverNotesCard( ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - "Observer notes", + appText(R.string.device_detail_screen_observer_notes), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = ink, @@ -781,7 +790,7 @@ private fun ObserverNotesCard( IconButton(onClick = onToggleEdit) { Icon( Icons.Outlined.Edit, - if (editing) "Hide observer notes" else "Observer notes", + if (editing) appText(R.string.device_detail_screen_hide_observer_notes) else appText(R.string.device_detail_screen_observer_notes), ) } } @@ -791,7 +800,7 @@ private fun ObserverNotesCard( Text(notes, style = MaterialTheme.typography.bodyMedium) } else { Text( - "No observer notes", + appText(R.string.device_detail_screen_no_observer_notes), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -800,7 +809,7 @@ private fun ObserverNotesCard( FieldwatchOutlinedField( value = draft, onValueChange = onDraftChange, - label = "Observer notes", + label = appText(R.string.device_detail_screen_observer_notes), singleLine = false, minLines = 3, supportingText = "${draft.trim().length}/${RadioBookmarks.MAX_NOTES}. ${RadioBookmarks.observerNotesHint()}", @@ -813,9 +822,9 @@ private fun ObserverNotesCard( if (saved) { Icon(Icons.Outlined.Check, null, modifier = Modifier.size(18.dp)) Spacer(Modifier.padding(4.dp)) - Text("Saved") + Text(appText(R.string.device_detail_screen_saved)) } else { - Text("Save notes") + Text(appText(R.string.device_detail_screen_save_notes)) } } } @@ -842,7 +851,7 @@ private fun ExtraAttentionCard(notes: List>) { modifier = Modifier.padding(end = 8.dp), ) Text( - "Extra attention", + appText(R.string.device_detail_screen_extra_attention), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = warn, @@ -853,7 +862,7 @@ private fun ExtraAttentionCard(notes: List>) { Text(note, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) } Text( - "Pattern match, not identity. Not a safety finding.", + appText(R.string.device_detail_screen_pattern_match_not_identity_not_a_safety), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -870,7 +879,7 @@ private fun GuessCard(guess: DeviceExplain.Guess) { ) { Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - "What this looks like", + appText(R.string.device_detail_screen_what_this_looks_like), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -905,12 +914,12 @@ private fun Meta(label: String, value: String, mono: Boolean = false) { private fun vendorLine(device: Sighting): String? { val parts = ArrayList(3) device.vendor?.let { - parts += "IEEE board/chip vendor: $it (${device.oui}). This is who owns the MAC prefix, not always the product brand." + parts += appText(R.string.device_detail_screen_ieee_board_chip_vendor_this_is_who, it, device.oui) } val mfgId = device.facts.mfgRecords.firstOrNull()?.companyId ?: device.manufacturerId if (mfgId != null) { val company = RadioDb.company(mfgId) - parts += "Bluetooth company in the ad: ${company ?: "unlisted"} (0x%04X).".format(mfgId) + parts += appText(R.string.device_detail_screen_bluetooth_company_in_the_ad_0x_04x, company ?: appText(R.string.device_detail_screen_unlisted)).format(mfgId) } return parts.joinToString("\n").ifBlank { null } } @@ -923,7 +932,7 @@ private fun uuidShort(uuid: String): String { private fun serviceDataHeading(sd: ServiceDataRecord): String { val named = RadioDb.serviceUuid(sd.uuid)?.let { " ($it)" } ?: "" val frame = eddystoneFrameTag(sd)?.let { " · $it" } ?: "" - return "Service data ${uuidShort(sd.uuid)}$named$frame" + return appText(R.string.device_detail_screen_service_data, uuidShort(sd.uuid), named, frame) } private fun eddystoneFrameTag(sd: ServiceDataRecord): String? { diff --git a/app/src/main/java/app/fieldwatch/ui/screen/FiltersScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/FiltersScreen.kt index c6d3beb..f44096d 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/FiltersScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/FiltersScreen.kt @@ -1,5 +1,16 @@ package app.fieldwatch.ui.screen +import app.fieldwatch.i18n.displayName +import app.fieldwatch.i18n.displayAttentionNotes +import app.fieldwatch.i18n.displaySignatureNotes +import app.fieldwatch.i18n.CatalogText + +import app.fieldwatch.i18n.displayLabel + +import app.fieldwatch.i18n.appText + +import app.fieldwatch.R + import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable @@ -65,13 +76,13 @@ import app.fieldwatch.ui.theme.nightIf @OptIn(ExperimentalMaterial3Api::class) @Composable fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { - var presetName by remember { mutableStateOf("") } + var presetName by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf("") } var pendingDelete by remember { mutableStateOf(null) } var confirmReset by remember { mutableStateOf(false) } val filter = state.filter Scaffold( contentWindowInsets = NestedTabInsets, - topBar = { NestedTopBar("Filters") }, + topBar = { NestedTopBar(appText(R.string.filters_screen_filters)) }, ) { pad -> Column( Modifier @@ -81,11 +92,9 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { .padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - SectionCard("Presets") { + SectionCard(appText(R.string.filters_screen_presets)) { Text( - "Tap to replace the whole filter. Long-press a chip to delete it. " + - "A short stock set ships; Save current as… adds your own (Cameras, plaza −80, …). " + - "Stock chips you delete come back with Settings → Restore default signatures & presets.", + appText(R.string.filters_screen_tap_to_replace_the_whole_filter_long), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -98,7 +107,7 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { ) { row.forEach { preset -> PresetChip( - name = preset.name, + name = CatalogText.preset(preset), selected = preset.filter == filter, onApply = { vm.applyPreset(preset) }, onLongPress = { pendingDelete = preset }, @@ -114,7 +123,7 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { FieldwatchOutlinedField( presetName, { presetName = it }, - "Save current as…", + appText(R.string.filters_screen_save_current_as), modifier = Modifier.weight(1f), ) TextButton(onClick = { @@ -122,16 +131,16 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { vm.savePreset(presetName.trim()) presetName = "" } - }) { Text("Save") } + }) { Text(appText(R.string.filters_screen_save)) } } } - SectionCard("Radios") { + SectionCard(appText(R.string.filters_screen_radios)) { Text( if (filter.movingWithYou) { - "Moving with you is BLE only. Both and Wi-Fi only stay off until you turn that switch off." + appText(R.string.filters_screen_moving_with_you_is_ble_only_both) } else { - "These are include switches. Turn both on to see Wi-Fi and BLE together. A single device is never both." + appText(R.string.filters_screen_these_are_include_switches_turn_both_on) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -141,25 +150,25 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { selected = !filter.movingWithYou && filter.showWifi && filter.showBle, onClick = { vm.updateFilter { it.copy(showWifi = true, showBle = true) } }, enabled = !filter.movingWithYou, - label = { Text("Both") }, + label = { Text(appText(R.string.filters_screen_both)) }, ) FieldwatchFilterChip( selected = !filter.movingWithYou && filter.showWifi && !filter.showBle, onClick = { vm.updateFilter { it.copy(showWifi = true, showBle = false) } }, enabled = !filter.movingWithYou, - label = { Text("Wi-Fi only") }, + label = { Text(appText(R.string.filters_screen_wi_fi_only)) }, ) FieldwatchFilterChip( selected = filter.movingWithYou || (filter.showBle && !filter.showWifi), onClick = { vm.updateFilter { it.copy(showWifi = false, showBle = true) } }, - label = { Text("BLE only") }, + label = { Text(appText(R.string.filters_screen_ble_only)) }, ) } } - SectionCard("Moving with you") { + SectionCard(appText(R.string.filters_screen_moving_with_you)) { Row(verticalAlignment = Alignment.CenterVertically) { - Text("Moving with you", Modifier.weight(1f)) + Text(appText(R.string.filters_screen_moving_with_you), Modifier.weight(1f)) FieldwatchSwitch( filter.movingWithYou, { on -> @@ -189,53 +198,40 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { Text( when { !state.settings.tagLocation -> - "Turn on Settings → Tag detections with GPS, then walk or drive. " + - "Only loud BLE advertisers that stay with you along the path. " + - "Wi-Fi access points stay off — a loud AP you drive past paints your path. " + - "The switch starts a BLE follow test (clears Signatures only / Show only / Named radios only / Watched only). " + - "Or tap the Moving with you preset at the top." + appText(R.string.filters_screen_turn_on_settings_tag_detections_with_gps) state.operatorSpanM < 45.0 -> - "GPS path so far ${state.operatorSpanM.toInt()} m. Keep moving (~50 m). " + - "If this stays 0 while you drive, Location is not giving a live fix " + - "(set Location to high accuracy). Last-known-only is not enough. " + - "A second iPhone usually will not match: BLE MAC rotation starts a new radio." + + appText(R.string.filters_screen_gps_path_so_far_m_keep_moving, state.operatorSpanM.toInt()) + when { filter.customNamesOnly -> - " Named radios only is also on — unlabeled radios stay hidden." + appText(R.string.filters_screen_named_radios_only_is_also_on_unlabeled) filter.watchedOnly -> - " Watched only is also on — unwatched radios stay hidden." + appText(R.string.filters_screen_watched_only_is_also_on_unwatched_radios) filter.namedOnly || filter.namedOnlyImplied() -> - " Signatures only / Show only is also on — unmatched radios stay hidden." + appText(R.string.filters_screen_signatures_only_show_only_is_also_on) else -> "" } + - if (filter.hideMine) " Hide my radios is on — those stay off this list." else "" + if (filter.hideMine) appText(R.string.filters_screen_hide_my_radios_is_on_those_stay) else "" filter.customNamesOnly || filter.watchedOnly || filter.namedOnly || filter.namedOnlyImplied() -> - "GPS path ${state.operatorSpanM.toInt()} m. Signatures only, class Show only, " + - "Named radios only, or Watched only is also on, so only those radios can co-travel. " + - "Tap the Moving with you preset to test BLE. A tag in your bag or car should match. Wi-Fi access points stay hidden." + - if (filter.hideMine) " Hide my radios is on — those stay off this list." else "" + appText(R.string.filters_screen_gps_path_m_signatures_only_class_show, state.operatorSpanM.toInt()) + + if (filter.hideMine) appText(R.string.filters_screen_hide_my_radios_is_on_those_stay) else "" else -> - "GPS path ${state.operatorSpanM.toInt()} m. Loud BLE heard along that " + - "path at a fairly steady level — not ones that only appear when you " + - "arrive. " + + appText(R.string.filters_screen_gps_path_m_loud_ble_heard_along, state.operatorSpanM.toInt()) + (if (filter.hideMine) { - "Hide my radios is on — those stay off this list. " + appText(R.string.filters_screen_hide_my_radios_is_on_those_stay_2) } else { - "A tag in your bag or car will match. " + appText(R.string.filters_screen_a_tag_in_your_bag_or_car) }) + - "Wi-Fi access points stay hidden " + - "(range looks like co-travel). A phone’s rotating BLE address will not stitch as one follower. " + - "Live → Start over clears the path and trails so you can test again." + appText(R.string.filters_screen_wi_fi_access_points_stay_hidden_range) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - SectionCard("New detections") { + SectionCard(appText(R.string.filters_screen_new_detections)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - if (state.arrivalsLearning) "New detections only · learning" else "New detections only", + if (state.arrivalsLearning) appText(R.string.filters_screen_new_detections_only_learning) else appText(R.string.filters_screen_new_detections_only), Modifier.weight(1f), ) FieldwatchSwitch( @@ -245,31 +241,28 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { } Text( if (filter.arrivalsOnly) { - "Mark seen and Reset seen sit on Live, above the tabs. " + + appText(R.string.filters_screen_mark_seen_and_reset_seen_sit_on) + when { state.arrivalsLearning -> - "Learning sitting Wi-Fi into already-seen." + appText(R.string.filters_screen_learning_sitting_wi_fi_into_already_seen) state.hiddenKnown > 0 -> - "${state.hiddenKnown} already seen are hidden." + appText(R.string.filters_screen_already_seen_are_hidden, state.hiddenKnown) else -> - "Already seen is 0." + appText(R.string.filters_screen_already_seen_is_0) } } else { - "Hide radios already here so only new ones show on Live. " + - "Mark seen / Reset seen appear above the tabs on Live while this is on. " + - "Brief hold still sets how long a new radio stays after the last packet. " + - "Randomized BLE addresses look new." + appText(R.string.filters_screen_hide_radios_already_here_so_only_new) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - SectionCard("Who stays") { + SectionCard(appText(R.string.filters_screen_who_stays)) { val namedImplied = filter.namedOnlyImplied() Row(verticalAlignment = Alignment.CenterVertically) { Text( - "Signatures only (hide unmatched)", + appText(R.string.filters_screen_signatures_only_hide_unmatched), Modifier.weight(1f), color = if (namedImplied) { MaterialTheme.colorScheme.onSurfaceVariant @@ -287,75 +280,68 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { } if (namedImplied) { Text( - "Show only already hides unmatched radios. Turn Show only (class or selected signatures) off to use this switch.", + appText(R.string.filters_screen_show_only_already_hides_unmatched_radios_turn), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Row(verticalAlignment = Alignment.CenterVertically) { - Text("Watched only", Modifier.weight(1f)) + Text(appText(R.string.filters_screen_watched_only), Modifier.weight(1f)) FieldwatchSwitch( filter.watchedOnly, { on -> vm.updateFilter { it.copy(watchedOnly = on) } }, ) } Text( - "Only radios that match a bookmarked signature, or a Named radio with Alert on. " + - "Hide these still applies (Watched only + Hide Surveillance drops bookmarked cameras). " + - "Label-only names stay on Named radios only. Bookmark on Signatures; Alert on detail.", + appText(R.string.filters_screen_only_radios_that_match_a_bookmarked_signature), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Row(verticalAlignment = Alignment.CenterVertically) { - Text("Named radios only", Modifier.weight(1f)) + Text(appText(R.string.filters_screen_named_radios_only), Modifier.weight(1f)) FieldwatchSwitch( filter.customNamesOnly, { on -> vm.updateFilter { it.copy(customNamesOnly = on) } }, ) } Text( - "Only radios you gave a custom name. Alert can still be off. Settings → Named radios. " + - "A random / privacy MAC will not follow a rotation.", + appText(R.string.filters_screen_only_radios_you_gave_a_custom_name), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Row(verticalAlignment = Alignment.CenterVertically) { - Text("Hide my radios", Modifier.weight(1f)) + Text(appText(R.string.filters_screen_hide_my_radios), Modifier.weight(1f)) FieldwatchSwitch( filter.hideMine, { on -> vm.updateFilter { it.copy(hideMine = on) } }, ) } Text( - "Radios marked Mine stay off Live. The sit and Debrief still have them. " + - "Turning on Moving with you leaves this on.", + appText(R.string.filters_screen_radios_marked_mine_stay_off_live_the), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Row(verticalAlignment = Alignment.CenterVertically) { - Text("Hide Fast Pair account-key", Modifier.weight(1f)) + Text(appText(R.string.filters_screen_hide_fast_pair_account_key), Modifier.weight(1f)) FieldwatchSwitch( filter.hideFastPairAccountKey, { on -> vm.updateFilter { it.copy(hideFastPairAccountKey = on) } }, ) } Text( - "Plaza noise: already-paired Fast Pair chips with no other signature. " + - "Keeps pairing-mode (tap-to-pair model ID). Hide selected Fast Pair still drops pairing-mode too.", + appText(R.string.filters_screen_plaza_noise_already_paired_fast_pair_chips), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - SectionCard("Signature classes") { + SectionCard(appText(R.string.filters_screen_signature_classes)) { Text( - "Live only — signatures still label, log, and can beep. " + - "Cameras, Drones, Finder tags, and the rest are these chips — Show only, then Save current as… if you want a preset. " + - "Show only with no class picked leaves Live unchanged.", + appText(R.string.filters_screen_live_only_signatures_still_label_log_and), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -368,7 +354,7 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { it.copy(useClassFilter = on, excludeClasses = false) } }, - label = { Text("Show only") }, + label = { Text(appText(R.string.filters_screen_show_only)) }, ) FieldwatchFilterChip( selected = filter.useClassFilter && filter.excludeClasses, @@ -378,12 +364,12 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { it.copy(useClassFilter = on, excludeClasses = on) } }, - label = { Text("Hide these") }, + label = { Text(appText(R.string.filters_screen_hide_these)) }, ) } CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - SignatureClass.visible.sortedBy { it.label().lowercase() }.chunked(2).forEach { row -> + SignatureClass.visible.sortedBy { it.displayLabel().lowercase() }.chunked(2).forEach { row -> Row( horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth(), @@ -411,7 +397,7 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { }, label = { Text( - kind.label(), + kind.displayLabel(), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -426,9 +412,9 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { } } - SectionCard("Selected signatures") { + SectionCard(appText(R.string.filters_screen_selected_signatures)) { Row(verticalAlignment = Alignment.CenterVertically) { - Text("Show only selected signatures", Modifier.weight(1f)) + Text(appText(R.string.filters_screen_show_only_selected_signatures), Modifier.weight(1f)) FieldwatchSwitch( filter.includeSignatures, { on -> vm.updateFilter { it.copy(includeSignatures = on) } }, @@ -438,8 +424,7 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { SignaturePickList( fleets = state.fleets, selected = filter.includeFleetIds, - help = "Tap a class to open its signatures. Only radios matching a signature you turn on below stay on Live. " + - "Empty list = no extra include (Live unchanged). Picks stay if you turn this off and on again.", + help = appText(R.string.filters_screen_tap_a_class_to_open_its_signatures), onToggle = { id, checked -> vm.updateFilter { current -> val next = current.includeFleetIds.toMutableSet() @@ -451,7 +436,7 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { } Row(verticalAlignment = Alignment.CenterVertically) { - Text("Hide selected signatures", Modifier.weight(1f)) + Text(appText(R.string.filters_screen_hide_selected_signatures), Modifier.weight(1f)) FieldwatchSwitch( filter.excludeSignatures, { on -> vm.updateFilter { it.copy(excludeSignatures = on) } }, @@ -461,8 +446,7 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { SignaturePickList( fleets = state.fleets, selected = filter.fleetIds, - help = "Tap a class to open its signatures. Devices matching a signature you turn on below stay off the Live list. " + - "Your picks stay if you turn this off and on again.", + help = appText(R.string.filters_screen_tap_a_class_to_open_its_signatures_2), onToggle = { id, checked -> vm.updateFilter { current -> val next = current.fleetIds.toMutableSet() @@ -474,13 +458,13 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { } } - SectionCard("Fine filter") { + SectionCard(appText(R.string.filters_screen_fine_filter)) { var rssiDrag by remember { mutableIntStateOf(filter.rssiMin) } var rssiDragging by remember { mutableStateOf(false) } LaunchedEffect(filter.rssiMin) { if (!rssiDragging) rssiDrag = filter.rssiMin } - Text("Minimum RSSI $rssiDrag dBm", style = MaterialTheme.typography.labelLarge) + Text(appText(R.string.filters_screen_minimum_rssi_dbm, rssiDrag), style = MaterialTheme.typography.labelLarge) FieldwatchSlider( value = rssiDrag.toFloat(), onValueChange = { v -> @@ -497,17 +481,17 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { FieldwatchOutlinedField( filter.nameQuery, { value -> vm.updateFilter { it.copy(nameQuery = value) } }, - "Name / MAC contains", + appText(R.string.filters_screen_name_mac_contains), ) FieldwatchOutlinedField( filter.ouiQuery, { value -> vm.updateFilter { it.copy(ouiQuery = value) } }, - "OUI / vendor contains", + appText(R.string.filters_screen_oui_vendor_contains), ) - Text("Extra filter logic", style = MaterialTheme.typography.labelLarge) + Text(appText(R.string.filters_screen_extra_filter_logic), style = MaterialTheme.typography.labelLarge) Text( - "AND/OR applies to name, OUI, RSSI, and class include — not to radios, Named radios only, Watched only, Hide my radios, Hide Fast Pair account-key, or hide lists.", + appText(R.string.filters_screen_and_or_applies_to_name_oui_rssi), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -515,17 +499,17 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { FieldwatchFilterChip( selected = filter.logic == FilterLogic.AND, onClick = { vm.updateFilter { it.copy(logic = FilterLogic.AND) } }, - label = { Text("AND") }, + label = { Text(appText(R.string.logical_and)) }, ) FieldwatchFilterChip( selected = filter.logic == FilterLogic.OR, onClick = { vm.updateFilter { it.copy(logic = FilterLogic.OR) } }, - label = { Text("OR") }, + label = { Text(appText(R.string.logical_or)) }, ) } FieldwatchActionButton(onClick = { confirmReset = true }) { - Text("Reset filter") + Text(appText(R.string.filters_screen_reset_filter)) } } } @@ -533,11 +517,10 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { if (confirmReset) { AlertDialog( onDismissRequest = { confirmReset = false }, - title = { Text("Reset filter?") }, + title = { Text(appText(R.string.filters_screen_reset_filter_2)) }, text = { Text( - "Clears every switch and pick on this tab (radios, classes, selected signatures, RSSI, name/OUI). " + - "Presets you saved stay. The Live display goes back to the unfiltered set. This is not undo.", + appText(R.string.filters_screen_clears_every_switch_and_pick_on_this), ) }, confirmButton = { @@ -546,23 +529,23 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { confirmReset = false vm.updateFilter { app.fieldwatch.domain.FilterState() } }, - ) { Text("Reset") } + ) { Text(appText(R.string.filters_screen_reset)) } }, dismissButton = { - TextButton(onClick = { confirmReset = false }) { Text("Cancel") } + TextButton(onClick = { confirmReset = false }) { Text(appText(R.string.filters_screen_cancel)) } }, ) } pendingDelete?.let { preset -> AlertDialog( onDismissRequest = { pendingDelete = null }, - title = { Text("Delete preset?") }, + title = { Text(appText(R.string.filters_screen_delete_preset)) }, text = { Text( if (preset.isBuiltIn()) { - "Remove stock chip “${preset.name}” from this list? Catalog updates will not put it back. Settings → Restore default signatures & presets restores all stock chips. The filter on Live does not change until you apply another chip or Reset filter." + appText(R.string.filters_screen_remove_stock_chip_from_this_list_catalog, CatalogText.preset(preset)) } else { - "Delete preset “${preset.name}”? This cannot be undone. The filter on Live does not change until you apply another chip or Reset filter." + appText(R.string.filters_screen_delete_preset_this_cannot_be_undone_the, CatalogText.preset(preset)) }, ) }, @@ -570,10 +553,10 @@ fun FiltersScreen(state: FieldwatchUi, vm: FieldwatchViewModel) { TextButton(onClick = { vm.deletePreset(preset.id) pendingDelete = null - }) { Text("Delete") } + }) { Text(appText(R.string.filters_screen_delete)) } }, dismissButton = { - TextButton(onClick = { pendingDelete = null }) { Text("Cancel") } + TextButton(onClick = { pendingDelete = null }) { Text(appText(R.string.filters_screen_cancel)) } }, ) } @@ -589,7 +572,7 @@ private fun SignaturePickList( val groups = remember(fleets) { fleets.groupBy { it.kind.folded() } .toList() - .sortedBy { it.first.label().lowercase() } + .sortedBy { it.first.displayLabel().lowercase() } .map { (kind, rows) -> kind to rows.sortedBy { it.name.lowercase() } } } var open by remember { @@ -629,7 +612,7 @@ private fun SignaturePickList( ) Spacer(Modifier.width(8.dp)) Text( - kind.label(), + kind.displayLabel(), modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleSmall, maxLines = 1, @@ -656,7 +639,7 @@ private fun SignaturePickList( modifier = Modifier.padding(start = 24.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text(fleet.name, Modifier.weight(1f)) + Text(fleet.displayName(), Modifier.weight(1f)) FieldwatchSwitch(on, { checked -> onToggle(fleet.id, checked) }) } } diff --git a/app/src/main/java/app/fieldwatch/ui/screen/FleetsScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/FleetsScreen.kt index 5bcc05e..21b5f97 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/FleetsScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/FleetsScreen.kt @@ -1,5 +1,15 @@ package app.fieldwatch.ui.screen +import app.fieldwatch.i18n.displayName +import app.fieldwatch.i18n.displayNotes +import app.fieldwatch.i18n.displayAttention + +import app.fieldwatch.i18n.displayLabel + +import app.fieldwatch.i18n.appText + +import app.fieldwatch.R + import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -96,8 +106,8 @@ fun FleetsScreen( val draft = state.draftFleet if (draft != null) { val isNew = state.fleets.none { it.id == draft.id } - var working by remember(draft.id) { mutableStateOf(draft) } - var editingDecode by remember(draft.id) { mutableStateOf(false) } + var working by androidx.compose.runtime.saveable.rememberSaveable(draft.id, stateSaver = app.fieldwatch.ui.jsonDraftSaver()) { mutableStateOf(draft) } + var editingDecode by androidx.compose.runtime.saveable.rememberSaveable(draft.id) { mutableStateOf(false) } if (editingDecode) { val preview = state.devices.firstOrNull { working.id in it.fleetIds } ?: state.selected?.takeIf { working.id in it.fleetIds } @@ -141,10 +151,10 @@ fun FleetsScreen( } Scaffold( contentWindowInsets = NestedTabInsets, - topBar = { NestedTopBar("Signatures (${state.fleets.size})") }, + topBar = { NestedTopBar(appText(R.string.fleets_screen_signatures, state.fleets.size)) }, floatingActionButton = { FloatingActionButton(onClick = vm::beginNewFleet) { - Icon(Icons.Outlined.Add, "New signature") + Icon(Icons.Outlined.Add, appText(R.string.fleets_screen_new_signature)) } }, ) { pad -> @@ -158,9 +168,9 @@ fun FleetsScreen( item { Text( if (sort == SignatureListSort.CLASS) { - "Tap a class to open its signatures. Bookmark = beep. Hide a family on Filters, not here." + appText(R.string.fleets_screen_tap_a_class_to_open_its_signatures) } else { - "Tap to edit. Bookmark = beep when that family appears. Hide a family on Filters, not here." + appText(R.string.fleets_screen_tap_to_edit_bookmark_beep_when_that) }, style = compactLine(12.sp, 14.sp), color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -173,12 +183,12 @@ fun FleetsScreen( FieldwatchFilterChip( selected = sort == SignatureListSort.NAME, onClick = { vm.setSignatureListSort(SignatureListSort.NAME) }, - label = { Text("Name A–Z") }, + label = { Text(appText(R.string.fleets_screen_name_a_z)) }, ) FieldwatchFilterChip( selected = sort == SignatureListSort.CLASS, onClick = { vm.setSignatureListSort(SignatureListSort.CLASS) }, - label = { Text("Class A–Z") }, + label = { Text(appText(R.string.fleets_screen_class_a_z)) }, ) } } @@ -235,7 +245,7 @@ private fun SignatureClassHeader( RadioClassBadge(classKind = kind, accent = accent) Spacer(Modifier.width(10.dp)) Text( - kind.label(), + kind.displayLabel(), style = compactLine(16.sp, 18.sp, FontWeight.SemiBold), modifier = Modifier.weight(1f), maxLines = 1, @@ -280,7 +290,7 @@ private fun SignatureRow(fleet: Fleet, state: FieldwatchUi, vm: FieldwatchViewMo horizontalArrangement = Arrangement.spacedBy(6.dp), ) { Text( - fleet.name, + fleet.displayName(), maxLines = 1, overflow = TextOverflow.Ellipsis, style = compactLine(16.sp, 18.sp, FontWeight.SemiBold), @@ -294,7 +304,7 @@ private fun SignatureRow(fleet: Fleet, state: FieldwatchUi, vm: FieldwatchViewMo } } Text( - "${fleet.kind.label()} · ${fleet.rules.size} rules · $liveHits live · ${if (fleet.matchAny) "OR" else "AND"}", + appText(R.string.fleets_screen_rules_live, fleet.kind.displayLabel(), fleet.rules.size, liveHits, appText(if (fleet.matchAny) R.string.logical_or else R.string.logical_and)), maxLines = 1, overflow = TextOverflow.Ellipsis, style = compactLine(11.sp, 13.sp), @@ -307,7 +317,7 @@ private fun SignatureRow(fleet: Fleet, state: FieldwatchUi, vm: FieldwatchViewMo ) { Icon( if (vm.isFleetWatched(fleet.id)) Icons.Outlined.Bookmark else Icons.Outlined.BookmarkBorder, - "Beep when this signature appears", + appText(R.string.fleets_screen_beep_when_this_signature_appears), modifier = Modifier.size(20.dp), ) } @@ -325,7 +335,7 @@ fun FleetEditor( onDelete: (() -> Unit)? = null, onOpenDecode: (Fleet) -> Unit = {}, ) { - var fleet by remember(initial.id) { mutableStateOf(initial) } + var fleet by androidx.compose.runtime.saveable.rememberSaveable(initial.id, stateSaver = app.fieldwatch.ui.jsonDraftSaver()) { mutableStateOf(initial) } var confirmDelete by remember { mutableStateOf(false) } LaunchedEffect(initial.decode) { fleet = fleet.copy(decode = initial.decode) @@ -334,9 +344,9 @@ fun FleetEditor( contentWindowInsets = NestedTabInsets, topBar = { NestedTopBar( - title = if (isNew) "New signature" else "Edit signature", - navigationIcon = { TextButton(onClick = onCancel) { Text("Cancel") } }, - actions = { TextButton(onClick = { onSave(fleet) }) { Text("Save") } }, + title = if (isNew) appText(R.string.fleets_screen_new_signature) else appText(R.string.fleets_screen_edit_signature), + navigationIcon = { TextButton(onClick = onCancel) { Text(appText(R.string.fleets_screen_cancel)) } }, + actions = { TextButton(onClick = { onSave(fleet) }) { Text(appText(R.string.fleets_screen_save)) } }, ) }, ) { pad -> @@ -348,34 +358,34 @@ fun FleetEditor( .padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - SectionCard("Identity") { - FieldwatchOutlinedField(fleet.name, { fleet = fleet.copy(name = it) }, "Name") + SectionCard(appText(R.string.fleets_screen_identity)) { + FieldwatchOutlinedField(fleet.displayName(), { fleet = fleet.copy(name = it) }, appText(R.string.fleets_screen_name)) FieldwatchOutlinedField( - fleet.notes, + fleet.displayNotes(), { fleet = fleet.copy(notes = it) }, - "Notes", - supportingText = "Shows on radio detail for matching radios, and in Share / AI Export. Not Extra attention — no Live “!” and not the amber card.", + appText(R.string.fleets_screen_notes), + supportingText = appText(R.string.fleets_screen_shows_on_radio_detail_for_matching_radios), singleLine = false, minLines = 2, ) FieldwatchOutlinedField( - fleet.attentionNote, + fleet.displayAttention(), { fleet = fleet.copy(attentionNote = it) }, - "Extra attention", - supportingText = "Optional. If this is not empty, matching radios get a “!” on Live, this amber card on detail, and a line in Debrief. Separate from Notes above.", + appText(R.string.fleets_screen_extra_attention), + supportingText = appText(R.string.fleets_screen_optional_if_this_is_not_empty_matching), singleLine = false, minLines = 3, ) } - SectionCard("Matching") { + SectionCard(appText(R.string.fleets_screen_matching)) { var classMenu by remember { mutableStateOf(false) } ExposedDropdownMenuBox(classMenu, { classMenu = it }) { - FieldwatchDropdownField("Class", fleet.kind.label(), classMenu) + FieldwatchDropdownField(appText(R.string.fleets_screen_class), fleet.kind.displayLabel(), classMenu) ExposedDropdownMenu(classMenu, { classMenu = false }) { - SignatureClass.visible.sortedBy { it.label().lowercase() }.forEach { kind -> + SignatureClass.visible.sortedBy { it.displayLabel().lowercase() }.forEach { kind -> DropdownMenuItem( - text = { Text(kind.label()) }, + text = { Text(kind.displayLabel()) }, onClick = { fleet = fleet.copy(kind = kind) classMenu = false @@ -385,47 +395,46 @@ fun FleetEditor( } } Text( - "Filters → Show only / Hide these. Class sits (Finder tags, Cameras, …) are those chips — Save current as… if you want a preset.", + appText(R.string.fleets_screen_filters_show_only_hide_these_class_sits), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Row(verticalAlignment = Alignment.CenterVertically) { - Text("Match any rule (OR)", Modifier.weight(1f)) + Text(appText(R.string.fleets_screen_match_any_rule_or), Modifier.weight(1f)) FieldwatchSwitch(fleet.matchAny, { fleet = fleet.copy(matchAny = it) }) } Row(verticalAlignment = Alignment.CenterVertically) { - Text("Cluster by OUI", Modifier.weight(1f)) + Text(appText(R.string.fleets_screen_cluster_by_oui), Modifier.weight(1f)) FieldwatchSwitch(fleet.clusterByOui, { fleet = fleet.copy(clusterByOui = it) }) } Row(verticalAlignment = Alignment.CenterVertically) { - Text("Sequential MACs", Modifier.weight(1f)) + Text(appText(R.string.fleets_screen_sequential_macs), Modifier.weight(1f)) FieldwatchSwitch(fleet.sequentialMac, { fleet = fleet.copy(sequentialMac = it) }) } FieldwatchOutlinedField( fleet.minPeers.toString(), { fleet = fleet.copy(minPeers = it.toIntOrNull() ?: 0) }, - "Min peers (0 = off)", + appText(R.string.fleets_screen_min_peers_0_off), ) FieldwatchOutlinedField( fleet.peerWindowSec.toString(), { fleet = fleet.copy(peerWindowSec = it.toIntOrNull() ?: 60) }, - "Peer window (seconds)", + appText(R.string.fleets_screen_peer_window_seconds), ) } - SectionCard("Color") { + SectionCard(appText(R.string.fleets_screen_color)) { ColorPicker(fleet.colorIndex) { fleet = fleet.copy(colorIndex = it) } Text( - "Stock colors are by class (red pentest, amber cameras/ALPR, purple phones/tags, cyan wearables, green mesh, orange audio/glasses, teal in-car/vehicle). Change any row.", + appText(R.string.fleets_screen_stock_colors_are_by_class_red_pentest), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - SectionCard("Rules") { + SectionCard(appText(R.string.fleets_screen_rules)) { Text( - "Each rule has its own switch. Off keeps the rule but it does not match. " + - "Use that to mute noisy OUIs or names on one signature without deleting them.", + appText(R.string.fleets_screen_each_rule_has_its_own_switch_off), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -447,12 +456,12 @@ fun FleetEditor( fleet = fleet.copy(rules = fleet.rules + MatchRule(RuleKind.OUI, text = "")) }, modifier = Modifier.fillMaxWidth(), - ) { Text("Add rule") } + ) { Text(appText(R.string.fleets_screen_add_rule)) } } if (fleet.canHaveBleDecode()) { val decodeCount = fleet.decode?.fields?.size ?: 0 - SectionCard("Decode fields") { + SectionCard(appText(R.string.fleets_screen_decode_fields)) { Row( modifier = Modifier .fillMaxWidth() @@ -460,7 +469,7 @@ fun FleetEditor( verticalAlignment = Alignment.CenterVertically, ) { Text( - if (decodeCount == 0) "None" else "$decodeCount fields", + if (decodeCount == 0) appText(R.string.fleets_screen_none) else appText(R.string.fleets_screen_fields, decodeCount), Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge, ) @@ -471,7 +480,7 @@ fun FleetEditor( ) } Text( - "Optional. After this signature matches, map cleartext BLE bytes to labels. Encrypted payloads stay hex.", + appText(R.string.fleets_screen_optional_after_this_signature_matches_map_cleartext), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -484,7 +493,7 @@ fun FleetEditor( ) { Icon(Icons.Outlined.Delete, null) Spacer(Modifier.padding(4.dp)) - Text("Delete signature") + Text(appText(R.string.fleets_screen_delete_signature)) } } } @@ -492,13 +501,13 @@ fun FleetEditor( if (confirmDelete && onDelete != null) { AlertDialog( onDismissRequest = { confirmDelete = false }, - title = { Text("Delete this signature?") }, + title = { Text(appText(R.string.fleets_screen_delete_this_signature)) }, text = { Text( if (initial.builtIn) { - "“${fleet.name}” is a built-in signature. Deleting it removes matching, its bookmark, and filter chips. Restore default signatures in Settings will bring the stock set back." + appText(R.string.fleets_screen_is_a_built_in_signature_deleting_it, fleet.displayName()) } else { - "“${fleet.name}” will be removed. Matching, its bookmark, and filter chips go with it. This cannot be undone." + appText(R.string.fleets_screen_will_be_removed_matching_its_bookmark_and, fleet.displayName()) }, ) }, @@ -506,10 +515,10 @@ fun FleetEditor( TextButton(onClick = { confirmDelete = false onDelete() - }) { Text("Delete") } + }) { Text(appText(R.string.fleets_screen_delete)) } }, dismissButton = { - TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } + TextButton(onClick = { confirmDelete = false }) { Text(appText(R.string.fleets_screen_cancel)) } }, ) } @@ -545,7 +554,7 @@ private fun ColorPicker(selected: Int, onSelect: (Int) -> Unit) { if (on) { Icon( Icons.Outlined.Check, - contentDescription = "Selected color", + contentDescription = appText(R.string.fleets_screen_selected_color), tint = if (fill.luminance() > 0.45f) { Color(0xFF12171C) } else { @@ -582,7 +591,7 @@ private fun RuleEditor(rule: MatchRule, onChange: (MatchRule) -> Unit, onDelete: .weight(1f) .padding(start = 8.dp, end = 4.dp), ) { - FieldwatchDropdownField("Kind", ruleKindLabel(rule.kind), expanded) + FieldwatchDropdownField(appText(R.string.fleets_screen_kind), ruleKindLabel(rule.kind), expanded) ExposedDropdownMenu(expanded, { expanded = false }) { RuleKind.entries.forEach { kind -> DropdownMenuItem(text = { Text(ruleKindLabel(kind)) }, onClick = { @@ -592,7 +601,7 @@ private fun RuleEditor(rule: MatchRule, onChange: (MatchRule) -> Unit, onDelete: } } } - IconButton(onClick = onDelete) { Icon(Icons.Outlined.Delete, "Delete rule") } + IconButton(onClick = onDelete) { Icon(Icons.Outlined.Delete, appText(R.string.fleets_screen_delete_rule)) } } Column( Modifier.padding(top = 12.dp, start = 8.dp), @@ -603,7 +612,7 @@ private fun RuleEditor(rule: MatchRule, onChange: (MatchRule) -> Unit, onDelete: FieldwatchOutlinedField( rule.text, { onChange(rule.copy(text = it)) }, - "Value", + appText(R.string.fleets_screen_value), ) } RuleKind.MANUFACTURER_ID -> { @@ -613,7 +622,7 @@ private fun RuleEditor(rule: MatchRule, onChange: (MatchRule) -> Unit, onDelete: val parsed = it.removePrefix("0x").removePrefix("0X").toIntOrNull(16) ?: 0 onChange(rule.copy(companyId = parsed)) }, - "Company ID hex", + appText(R.string.fleets_screen_company_id_hex), ) } RuleKind.MANUFACTURER_DATA -> { @@ -623,24 +632,24 @@ private fun RuleEditor(rule: MatchRule, onChange: (MatchRule) -> Unit, onDelete: val parsed = it.removePrefix("0x").removePrefix("0X").toIntOrNull(16) ?: 0 onChange(rule.copy(companyId = parsed)) }, - "Company ID hex", + appText(R.string.fleets_screen_company_id_hex), ) FieldwatchOutlinedField( rule.dataPrefixHex, { onChange(rule.copy(dataPrefixHex = it)) }, - "Data prefix hex", + appText(R.string.fleets_screen_data_prefix_hex), ) } RuleKind.SERVICE_DATA -> { FieldwatchOutlinedField( rule.text, { onChange(rule.copy(text = it)) }, - "Service UUID (empty = any, contains)", + appText(R.string.fleets_screen_service_uuid_empty_any_contains), ) FieldwatchOutlinedField( rule.dataPrefixHex, { onChange(rule.copy(dataPrefixHex = it)) }, - if (rule.text.isBlank()) "Contains hex" else "Data prefix hex", + if (rule.text.isBlank()) appText(R.string.fleets_screen_contains_hex) else appText(R.string.fleets_screen_data_prefix_hex), ) } RuleKind.RADIO_KIND -> { @@ -649,7 +658,7 @@ private fun RuleEditor(rule: MatchRule, onChange: (MatchRule) -> Unit, onDelete: FieldwatchSwitch(rule.radio != RadioKind.BLE, { onChange(rule.copy(radio = if (it) RadioKind.WIFI else RadioKind.BLE)) }) } } - RuleKind.HIDDEN_SSID -> Text("Matches hidden SSIDs", style = MaterialTheme.typography.bodySmall) + RuleKind.HIDDEN_SSID -> Text(appText(R.string.fleets_screen_matches_hidden_ssids), style = MaterialTheme.typography.bodySmall) } } } @@ -657,16 +666,16 @@ private fun RuleEditor(rule: MatchRule, onChange: (MatchRule) -> Unit, onDelete: private fun ruleKindLabel(kind: RuleKind): String = when (kind) { RuleKind.OUI -> "OUI" - RuleKind.MAC_PREFIX -> "MAC prefix" - RuleKind.NAME_CONTAINS -> "Name contains" - RuleKind.NAME_GLOB -> "Name glob" - RuleKind.SERVICE_UUID -> "Service UUID" - RuleKind.SERVICE_DATA -> "Service data" - RuleKind.MANUFACTURER_ID -> "Manufacturer ID" - RuleKind.MANUFACTURER_DATA -> "Manufacturer data" - RuleKind.RADIO_KIND -> "Radio kind" - RuleKind.HIDDEN_SSID -> "Hidden SSID" - RuleKind.VENDOR_IE_OUI -> "Vendor IE OUI" + RuleKind.MAC_PREFIX -> appText(R.string.fleets_screen_mac_prefix) + RuleKind.NAME_CONTAINS -> appText(R.string.fleets_screen_name_contains) + RuleKind.NAME_GLOB -> appText(R.string.fleets_screen_name_glob) + RuleKind.SERVICE_UUID -> appText(R.string.fleets_screen_service_uuid) + RuleKind.SERVICE_DATA -> appText(R.string.fleets_screen_service_data) + RuleKind.MANUFACTURER_ID -> appText(R.string.fleets_screen_manufacturer_id) + RuleKind.MANUFACTURER_DATA -> appText(R.string.fleets_screen_manufacturer_data) + RuleKind.RADIO_KIND -> appText(R.string.fleets_screen_radio_kind) + RuleKind.HIDDEN_SSID -> appText(R.string.fleets_screen_hidden_ssid) + RuleKind.VENDOR_IE_OUI -> appText(R.string.fleets_screen_vendor_ie_oui) } private fun compactLine( diff --git a/app/src/main/java/app/fieldwatch/ui/screen/HuntScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/HuntScreen.kt index 3c2106b..697034e 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/HuntScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/HuntScreen.kt @@ -1,5 +1,9 @@ package app.fieldwatch.ui.screen +import app.fieldwatch.i18n.appText + +import app.fieldwatch.R + import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -112,11 +116,11 @@ fun HuntScreen( TopAppBar( title = { val shown = hunt.device?.let { MacUtil.redactMacIn(hunt.title, it.mac, demoMode) } ?: hunt.title - Text(shown.ifBlank { "Hunt" }, maxLines = 1) + Text(shown.ifBlank { appText(R.string.hunt_screen_hunt) }, maxLines = 1) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, appText(R.string.hunt_screen_back)) } }, ) @@ -134,13 +138,13 @@ fun HuntScreen( modifier = Modifier.fillMaxWidth(), enabled = hunt.active, ) { - Text("Reset this hunt") + Text(appText(R.string.hunt_screen_reset_this_hunt)) } FieldwatchActionButton( onClick = onBack, modifier = Modifier.fillMaxWidth(), ) { - Text("Back to detail") + Text(appText(R.string.hunt_screen_back_to_detail)) } Row( Modifier.fillMaxWidth(), @@ -151,7 +155,7 @@ fun HuntScreen( Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, ) { - Text("Beep", Modifier.weight(1f)) + Text(appText(R.string.hunt_screen_beep), Modifier.weight(1f)) FieldwatchSwitch( huntBeep, { on -> @@ -164,7 +168,7 @@ fun HuntScreen( Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, ) { - Text("Vibrate", Modifier.weight(1f)) + Text(appText(R.string.hunt_screen_vibrate), Modifier.weight(1f)) FieldwatchSwitch( huntVibrate, { on -> @@ -226,24 +230,24 @@ fun HuntScreen( color = accent, ) Text( - rssi?.let { DeviceExplain.rssiExplain(it) } ?: "no live RSSI", + rssi?.let { DeviceExplain.rssiExplain(it) } ?: appText(R.string.hunt_screen_no_live_rssi), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( if (hunt.peakRssi > -127) { - "Loudest this hunt ${hunt.peakRssi} dBm" + appText(R.string.hunt_screen_loudest_this_hunt_dbm, hunt.peakRssi) } else { - "Loudest this hunt —" + appText(R.string.hunt_screen_loudest_this_hunt) }, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodyMedium, ) Text( when { - heardAgo == null -> "last heard —" - heardAgo < 60L -> "last heard ${heardAgo}s ago" - else -> "last heard ${heardAgo / 60L}m ago" + heardAgo == null -> appText(R.string.hunt_screen_last_heard) + heardAgo < 60L -> appText(R.string.hunt_screen_last_heard_s_ago, heardAgo) + else -> appText(R.string.hunt_screen_last_heard_m_ago, heardAgo / 60L) }, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelMedium, @@ -344,7 +348,7 @@ private fun HuntNeedle( HuntCue.GONE -> pulse(0.88f, 0.28f, 2.2f) } drawCircle(color.copy(alpha = 0.95f), radius = 5.5f, center = c) - val you = measurer.measure("YOU", youStyle) + val you = measurer.measure(appText(R.string.hunt_screen_you), youStyle) drawText( you, topLeft = Offset(c.x - you.size.width / 2f, c.y + 10f), diff --git a/app/src/main/java/app/fieldwatch/ui/screen/LiveScreens.kt b/app/src/main/java/app/fieldwatch/ui/screen/LiveScreens.kt index 97462dc..e7221d4 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/LiveScreens.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/LiveScreens.kt @@ -1,5 +1,13 @@ package app.fieldwatch.ui.screen +import app.fieldwatch.i18n.displayLiveDecode + +import app.fieldwatch.i18n.displayLabel + +import app.fieldwatch.i18n.appText + +import app.fieldwatch.R + import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween @@ -130,8 +138,8 @@ fun LivePane( val alertedKeys by vm.alertedKeys.collectAsStateWithLifecycle() val flood by vm.floodNotice.collectAsStateWithLifecycle() val floodHide by vm.floodHide.collectAsStateWithLifecycle() - var renameSit by remember { mutableStateOf(false) } - var renameDraft by remember { mutableStateOf("") } + var renameSit by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(false) } + var renameDraft by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf("") } val floodNotice = flood Column(Modifier.fillMaxSize()) { if (floodNotice != null && !floodNotice.showDialog) { @@ -146,7 +154,7 @@ fun LivePane( modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp), ) { Text( - "Hide this burst", + appText(R.string.live_screens_hide_this_burst), style = MaterialTheme.typography.labelSmall, modifier = Modifier.weight(1f), ) @@ -159,7 +167,7 @@ fun LivePane( modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp), ) { Text( - if (floodHide.keys.size == 1) "Hiding 1 flood radio" else "Hiding ${floodHide.keys.size} flood radios", + if (floodHide.keys.size == 1) appText(R.string.live_screens_hiding_1_flood_radio) else appText(R.string.live_screens_hiding_flood_radios, floodHide.keys.size), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), @@ -173,16 +181,16 @@ fun LivePane( title = { Text(floodNotice.title()) }, text = { Text(floodNotice.body()) }, confirmButton = { - TextButton(onClick = { vm.dismissPairingFlood() }) { Text("Continue") } + TextButton(onClick = { vm.dismissPairingFlood() }) { Text(appText(R.string.live_screens_continue)) } }, dismissButton = { - TextButton(onClick = { vm.hidePairingFlood() }) { Text("Hide these") } + TextButton(onClick = { vm.hidePairingFlood() }) { Text(appText(R.string.live_screens_hide_these)) } }, ) } if (state.displayPaused) { Text( - "Display paused · radios still scanning and logging. Filters still apply when you run again. Tap Live to run the list again.", + appText(R.string.live_screens_display_paused_radios_still_scanning_and_logging), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), @@ -191,9 +199,9 @@ fun LivePane( if (state.filter.arrivalsOnly) { Text( when { - state.arrivalsLearning -> "New only · learning sitting Wi-Fi" - state.hiddenKnown > 0 -> "New only · ${state.hiddenKnown} hidden" - else -> "New only" + state.arrivalsLearning -> appText(R.string.live_screens_new_only_learning_sitting_wi_fi) + state.hiddenKnown > 0 -> appText(R.string.live_screens_new_only_hidden, state.hiddenKnown) + else -> appText(R.string.live_screens_new_only) }, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -202,7 +210,7 @@ fun LivePane( } if (state.filter.movingWithYou) { Text( - "Follow · path ${state.operatorSpanM.toInt()} m · Start over clears the path", + appText(R.string.live_screens_follow_path_m_start_over_clears_the, state.operatorSpanM.toInt()), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), @@ -210,7 +218,7 @@ fun LivePane( } if (state.filter.watchedOnly) { Text( - "Watched only", + appText(R.string.live_screens_watched_only), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), @@ -218,7 +226,7 @@ fun LivePane( } if (state.filter.customNamesOnly) { Text( - "Named radios only", + appText(R.string.live_screens_named_radios_only), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), @@ -229,8 +237,8 @@ fun LivePane( val now = System.currentTimeMillis() val dur = Sit.fmtDuration(openSit.durationMs(now)) val cap = when { - state.sit.memoryTight -> " · memory cap" - state.sit.atCap -> " · ${Sit.RADIO_CAP} cap" + state.sit.memoryTight -> appText(R.string.live_screens_memory_cap) + state.sit.atCap -> appText(R.string.live_screens_cap, Sit.RADIO_CAP) else -> "" } Row( @@ -240,7 +248,7 @@ fun LivePane( verticalAlignment = Alignment.CenterVertically, ) { Text( - "Sit · ${openSit.name} · $dur · ${state.sit.radioCount} radios$cap", + appText(R.string.live_screens_sit_radios, openSit.name, dur, state.sit.radioCount, cap), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, maxLines = 1, @@ -257,12 +265,12 @@ fun LivePane( if (renameSit && openSit != null) { AlertDialog( onDismissRequest = { renameSit = false }, - title = { Text("Rename sit") }, + title = { Text(appText(R.string.live_screens_rename_sit)) }, text = { FieldwatchOutlinedField( value = renameDraft, onValueChange = { renameDraft = it.take(Sit.NAME_MAX) }, - label = "Name", + label = appText(R.string.live_screens_name), ) }, confirmButton = { @@ -271,10 +279,10 @@ fun LivePane( renameSit = false vm.renameSit(openSit.id, renameDraft) }, - ) { Text("Save") } + ) { Text(appText(R.string.live_screens_save)) } }, dismissButton = { - TextButton(onClick = { renameSit = false }) { Text("Cancel") } + TextButton(onClick = { renameSit = false }) { Text(appText(R.string.live_screens_cancel)) } }, ) } @@ -302,36 +310,34 @@ private fun arrivalsEmpty(state: FieldwatchUi): String? { if (state.filter.movingWithYou) { return when { !state.settings.tagLocation -> - "Moving with you needs Settings → Tag detections with GPS, then a walk or drive." + appText(R.string.live_screens_moving_with_you_needs_settings_tag_detections) state.operatorSpanM < 45.0 -> - "GPS path ${state.operatorSpanM.toInt()} m — too short. Keep moving. " + - "If this stays 0, Location is not updating (use high accuracy)." + appText(R.string.live_screens_gps_path_m_too_short_keep_moving, state.operatorSpanM.toInt()) state.filter.customNamesOnly || state.filter.watchedOnly || state.filter.namedOnly || state.filter.namedOnlyImplied() -> - "No loud BLE has stayed with you among the radios still allowed. " + - "Tap the Moving with you preset to test BLE, or turn off Signatures only / Show only / Named radios only / Watched only." + appText(R.string.live_screens_no_loud_ble_has_stayed_with_you) else -> if (state.filter.hideMine) { - "No loud BLE has stayed with you along this path. Wi-Fi access points stay hidden. Radios marked Mine stay off this list." + appText(R.string.live_screens_no_loud_ble_has_stayed_with_you_2) } else { - "No loud BLE has stayed with you along this path. Wi-Fi access points stay hidden. A tag in your bag or car should show. Find My MAC rotation will not stitch." + appText(R.string.live_screens_no_loud_ble_has_stayed_with_you_3) } } } if (state.filter.arrivalsOnly) { return when { - state.arrivalsLearning -> "Hiding sitting access points until the next Wi-Fi scan. New Bluetooth still shows right away." + state.arrivalsLearning -> appText(R.string.live_screens_hiding_sitting_access_points_until_the_next) state.hiddenKnown > 0 -> - "${state.hiddenKnown} already seen are hidden. A new radio stays while we hear it, then at least as long as Brief hold after the last packet." + appText(R.string.live_screens_already_seen_are_hidden_a_new_radio, state.hiddenKnown) else -> - "Waiting for a new Wi-Fi or BLE radio. It stays while we hear it, then at least as long as Brief hold after the last packet." + appText(R.string.live_screens_waiting_for_a_new_wi_fi_or) } } if (state.filter.watchedOnly) { - return "No watched radios on the air. Bookmark a signature, turn Alert on a Named radio, or turn off Filters → Watched only." + return appText(R.string.live_screens_no_watched_radios_on_the_air_bookmark) } if (state.filter.customNamesOnly) { - return "No named radios on the air. Set a custom name on detail, or turn off Filters → Named radios only." + return appText(R.string.live_screens_no_named_radios_on_the_air_set) } return null } @@ -410,13 +416,13 @@ private fun ClassOutlineView( FieldwatchFilterChip( selected = !hideEmpty, onClick = { vm.updateSettings { it.copy(outlineHideEmpty = false) } }, - label = { Text("Show all") }, + label = { Text(appText(R.string.live_screens_show_all)) }, modifier = Modifier.weight(1f), ) FieldwatchFilterChip( selected = hideEmpty, onClick = { vm.updateSettings { it.copy(outlineHideEmpty = true) } }, - label = { Text("Collapse empty") }, + label = { Text(appText(R.string.live_screens_collapse_empty)) }, modifier = Modifier.weight(1f), ) } @@ -425,7 +431,7 @@ private fun ClassOutlineView( item { Text( emptyHint - ?: "No live emitters match the current filter. If this just emptied, the OS may be between scan windows — the last set is held and should return without a burst.", + ?: appText(R.string.live_screens_no_live_emitters_match_the_current_filter), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(24.dp), @@ -436,10 +442,10 @@ private fun ClassOutlineView( item(key = "outline-summary") { val hidden = slices.count { it.radios.isEmpty() } val summary = buildString { - append("${devices.size} radios") - if (unmatched > 0) append(" · $unmatched unmatched") - if (multi > 0) append(" · $multi in more than one class") - if (hideEmpty && hidden > 0) append(" · $hidden empty hidden") + append(appText(R.string.live_screens_radios, devices.size)) + if (unmatched > 0) append(appText(R.string.live_screens_unmatched, unmatched)) + if (multi > 0) append(appText(R.string.live_screens_in_more_than_one_class, multi)) + if (hideEmpty && hidden > 0) append(appText(R.string.live_screens_empty_hidden, hidden)) } Text( summary, @@ -450,13 +456,13 @@ private fun ClassOutlineView( visible.forEach { slice -> item(key = "class-${slice.id}") { OutlineGroupRow( - title = slice.label(), + title = slice.displayLabel(), count = slice.radios.size, subtitle = when { slice.radios.isEmpty() -> null - slice.kind == null -> "no signature" - slice.signatures.size == 1 -> vm.fleetName(slice.signatures.first().fleetId) - else -> "${slice.signatures.size} signatures" + slice.kind == null -> appText(R.string.live_screens_no_signature) + slice.signatures.size == 1 -> vm.fleetDisplayName(slice.signatures.first().fleetId) + else -> appText(R.string.live_screens_signatures, slice.signatures.size) }, accent = slice.accent(vm).nightIf(LocalNightMode.current), expanded = slice.id in openClasses, @@ -482,7 +488,7 @@ private fun ClassOutlineView( val sigKey = "${slice.id}/${sig.fleetId}" item(key = "sig-$sigKey") { OutlineGroupRow( - title = vm.fleetName(sig.fleetId), + title = vm.fleetDisplayName(sig.fleetId), count = sig.radios.size, subtitle = null, accent = Color(Palette.color(vm.fleetColor(sig.fleetId))) @@ -913,7 +919,7 @@ private fun RadarView( } val rad = if (named) 11f else 7f val label = when { - showFleet && named -> device.fleetIds.firstOrNull()?.let { vm.fleetName(it) }?.take(14) + showFleet && named -> device.fleetIds.firstOrNull()?.let { vm.fleetDisplayName(it) }?.take(14) ?: MacUtil.redactMacIn(device.rowTitle(vm), device.mac, demoMode).take(14) named -> MacUtil.redactMacIn(device.rowTitle(vm), device.mac, demoMode).take(14) devices.size <= 24 -> MacUtil.redactMacIn(device.rowTitle(vm), device.mac, demoMode).take(12) @@ -945,7 +951,7 @@ private fun RadarView( drawCircle(youColor, radius = 7f, center = c) drawCircle(youColor.copy(alpha = 0.2f), radius = 16f, center = c) - val you = measurer.measure("YOU", ringStyle.copy(color = youColor, fontWeight = FontWeight.Bold)) + val you = measurer.measure(appText(R.string.live_screens_you), ringStyle.copy(color = youColor, fontWeight = FontWeight.Bold)) drawText(you, topLeft = Offset(c.x - you.size.width / 2f, c.y + 12f)) } @@ -956,14 +962,14 @@ private fun RadarView( ) { Text( if (devices.isEmpty()) { - emptyHint ?: "No devices match the current filter" + emptyHint ?: appText(R.string.live_screens_no_devices_match_the_current_filter) } else { val zoomBit = if (zoom > 1.04f) { - " · ×${"%.1f".format(Locale.US, zoom)} · double-tap reset" + appText(R.string.live_screens_double_tap_reset, "%.1f".format(Locale.US, zoom)) } else { - " · pinch to zoom" + appText(R.string.live_screens_pinch_to_zoom) } - "$onAir on-air · ${devices.size} in filter · dim = gone · tap a blip$zoomBit" + appText(R.string.live_screens_on_air_in_filter_dim_gone_tap, onAir, devices.size, zoomBit) }, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -1129,7 +1135,7 @@ private fun RankedList( item { Text( emptyHint - ?: "No live emitters match the current filter. If this just emptied, the OS may be between scan windows — the last set is held and should return without a burst. Use Timeline for recent disappearances.", + ?: appText(R.string.live_screens_no_live_emitters_match_the_current_filter_2), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(24.dp), @@ -1258,13 +1264,13 @@ fun DeviceRow( if (showNewAge) { val ageSec = ((now - device.firstSeen) / 1000L).coerceAtLeast(0L) Text( - if (ageSec < 60L) "new ${ageSec}s" else "new ${ageSec / 60L}m", + if (ageSec < 60L) appText(R.string.live_screens_new_s, ageSec) else appText(R.string.live_screens_new_m, ageSec / 60L), style = compactLine(10.sp, 11.sp).copy(fontFamily = FontFamily.Monospace), color = MaterialTheme.colorScheme.primary, ) } else if (sort == StrengthSort.AVERAGE) { Text( - "avg $rankRssi", + appText(R.string.live_screens_avg, rankRssi), style = compactLine(10.sp, 11.sp).copy(fontFamily = FontFamily.Monospace), color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1339,7 +1345,7 @@ private fun FleetNameChips( color = ink.copy(alpha = 0.12f), ) { Text( - "Mine", + appText(R.string.live_screens_mine), modifier = Modifier.padding(horizontal = 6.dp, vertical = 0.dp), maxLines = 1, style = TextStyle( @@ -1364,7 +1370,7 @@ private fun FleetNameChips( ) { Icon( Icons.AutoMirrored.Outlined.Notes, - contentDescription = "Observer notes", + contentDescription = appText(R.string.live_screens_observer_notes), modifier = Modifier .padding(horizontal = 5.dp, vertical = 1.dp) .size(11.dp), @@ -1380,7 +1386,7 @@ private fun FleetNameChips( ) { Icon( Icons.Outlined.Notifications, - contentDescription = "Alerted this session", + contentDescription = appText(R.string.live_screens_alerted_this_session), modifier = Modifier .padding(horizontal = 5.dp, vertical = 1.dp) .size(11.dp), @@ -1403,7 +1409,7 @@ private fun FleetNameChips( ) { Text( if (id == FastPair.FLEET_ID) FastPair.liveLabel(device.fastPairPairing) - else vm.fleetName(id), + else vm.fleetDisplayName(id), maxLines = 1, overflow = TextOverflow.Ellipsis, style = TextStyle( @@ -1423,7 +1429,8 @@ private fun FleetNameChips( } } } - device.liveDecode.forEach { chip -> + val currentUi by vm.ui.collectAsStateWithLifecycle() + device.displayLiveDecode(currentUi.fleets).forEach { chip -> val id = device.fleetIds.firstOrNull() val color = (id?.let { Color(Palette.color(vm.fleetColor(it))) } ?: MaterialTheme.colorScheme.primary) @@ -1524,7 +1531,7 @@ private fun TimelineView( ) { item { Text( - "Last 15 minutes · solid bars are on-air windows", + appText(R.string.live_screens_last_15_minutes_solid_bars_are_on), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1664,7 +1671,7 @@ private fun listSubtitle( watchName: String? = null, ): String { val raw = MacUtil.redactMacIn(device.listLineText(subtitleLine, names, watchName), device.mac, demoMode) - val body = if (raw.equals("unnamed LE", ignoreCase = true)) "unnamed" else raw + val body = if (raw.equals(appText(R.string.live_screens_unnamed_le), ignoreCase = true)) appText(R.string.live_screens_unnamed) else raw val crumbs = device.statusCrumbs() return buildString { if (body.isNotEmpty()) append(body) @@ -1676,7 +1683,7 @@ private fun listSubtitle( } private fun radioFactLine(device: Sighting): String? { - val ch = if (device.channel != 0) "ch${device.channel}" else null + val ch = if (device.channel != 0) appText(R.string.live_channel_compact, device.channel) else null val mhz = if (device.frequencyMhz != 0) "${device.frequencyMhz}MHz" else null return listOfNotNull(ch, mhz).takeIf { it.isNotEmpty() }?.joinToString(" · ") } @@ -1684,23 +1691,23 @@ private fun radioFactLine(device: Sighting): String? { private fun seenTimesLabel(device: Sighting, now: Long): String { val first = formatAge(now - device.firstSeen) val lastMs = now - device.lastSeen - val last = if (lastMs < 1_000L) "now" else formatAge(lastMs) - return "first $first · last $last" + val last = if (lastMs < 1_000L) appText(R.string.live_screens_now) else formatAge(lastMs) + return appText(R.string.live_screens_first_last, first, last) } private fun formatAge(ms: Long): String { val sec = (ms / 1000L).coerceAtLeast(0L) return when { - sec < 60L -> "${sec}s" + sec < 60L -> appText(R.string.report_duration_seconds, sec) sec < 3600L -> { val m = sec / 60L val s = sec % 60L - if (s == 0L) "${m}m" else "${m}m ${s}s" + appText(R.string.report_duration_minutes, m, s) } else -> { val h = sec / 3600L val m = (sec % 3600L) / 60L - if (m == 0L) "${h}h" else "${h}h ${m}m" + appText(R.string.report_duration_hours, h, m) } } } diff --git a/app/src/main/java/app/fieldwatch/ui/screen/RadioBookmarksScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/RadioBookmarksScreen.kt index b0c91b0..b6630e6 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/RadioBookmarksScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/RadioBookmarksScreen.kt @@ -1,5 +1,9 @@ package app.fieldwatch.ui.screen +import app.fieldwatch.i18n.appText + +import app.fieldwatch.R + import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -67,17 +71,17 @@ fun RadioBookmarksScreen( val liveKeys = state.devices.filter { !it.gone }.map { it.key }.toSet() val demoMode = state.settings.demoMode var clearAll by remember { mutableStateOf(false) } - var renameId by remember { mutableStateOf(null) } + var renameId by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(null) } val renameTarget = radios.firstOrNull { it.id == renameId } Scaffold( contentWindowInsets = NestedTabInsets, topBar = { NestedTopBar( - title = "Named radios (${radios.size})", + title = appText(R.string.radio_bookmarks_screen_named_radios, radios.size), navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, appText(R.string.radio_bookmarks_screen_back)) } }, ) @@ -90,7 +94,7 @@ fun RadioBookmarksScreen( ) { item { Text( - "One MAC each. Custom name shows on Live. Mine marks it as yours: no beep while on, still listed. Observer notes show on detail and reports. Alert is optional (pip / voice / flash). Filters → Named radios only hides everything else. Signature watches stay on Signatures.", + appText(R.string.radio_bookmarks_screen_one_mac_each_custom_name_shows_on), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -98,7 +102,7 @@ fun RadioBookmarksScreen( if (radios.isEmpty()) { item { Text( - "No named radios. Set a custom name on detail, or bookmark a radio (top-right) to watch it.", + appText(R.string.radio_bookmarks_screen_no_named_radios_set_a_custom_name), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp), @@ -131,7 +135,7 @@ fun RadioBookmarksScreen( onClick = { clearAll = true }, modifier = Modifier.fillMaxWidth(), ) { - Text("Clear all ${radios.size} named radios") + Text(appText(R.string.radio_bookmarks_screen_clear_all_named_radios, radios.size)) } } } @@ -141,9 +145,9 @@ fun RadioBookmarksScreen( if (clearAll) { AlertDialog( onDismissRequest = { clearAll = false }, - title = { Text("Clear named radios?") }, + title = { Text(appText(R.string.radio_bookmarks_screen_clear_named_radios)) }, text = { - Text("Remove ${radios.size} named radios. Signature watches stay.") + Text(appText(R.string.radio_bookmarks_screen_remove_named_radios_signature_watches_stay, radios.size)) }, confirmButton = { TextButton( @@ -151,30 +155,30 @@ fun RadioBookmarksScreen( vm.clearRadioBookmarks() clearAll = false }, - ) { Text("Clear") } + ) { Text(appText(R.string.radio_bookmarks_screen_clear)) } }, dismissButton = { - TextButton(onClick = { clearAll = false }) { Text("Cancel") } + TextButton(onClick = { clearAll = false }) { Text(appText(R.string.radio_bookmarks_screen_cancel)) } }, ) } if (renameTarget != null) { - var draft by remember(renameTarget.id) { mutableStateOf(renameTarget.label) } - var notesDraft by remember(renameTarget.id) { mutableStateOf(renameTarget.observerNotes) } + var draft by androidx.compose.runtime.saveable.rememberSaveable(renameTarget.id) { mutableStateOf(renameTarget.label) } + var notesDraft by androidx.compose.runtime.saveable.rememberSaveable(renameTarget.id) { mutableStateOf(renameTarget.observerNotes) } AlertDialog( onDismissRequest = { renameId = null }, - title = { Text("Named radio") }, + title = { Text(appText(R.string.radio_bookmarks_screen_named_radio)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { FieldwatchOutlinedField( value = draft, onValueChange = { draft = it.take(RadioBookmarks.MAX_NAME) }, - label = "Custom name", + label = appText(R.string.radio_bookmarks_screen_custom_name), ) FieldwatchOutlinedField( value = notesDraft, onValueChange = { notesDraft = it.take(RadioBookmarks.MAX_NOTES) }, - label = "Observer notes", + label = appText(R.string.radio_bookmarks_screen_observer_notes), singleLine = false, minLines = 3, supportingText = "${notesDraft.trim().length}/${RadioBookmarks.MAX_NOTES}", @@ -187,10 +191,10 @@ fun RadioBookmarksScreen( vm.updateNamedRadio(renameTarget.id, draft, notesDraft) renameId = null }, - ) { Text("Save") } + ) { Text(appText(R.string.radio_bookmarks_screen_save)) } }, dismissButton = { - TextButton(onClick = { renameId = null }) { Text("Cancel") } + TextButton(onClick = { renameId = null }) { Text(appText(R.string.radio_bookmarks_screen_cancel)) } }, ) } @@ -230,10 +234,10 @@ private fun BookmarkCard( ) CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { IconButton(onClick = onRename, modifier = Modifier.size(36.dp)) { - Icon(Icons.Outlined.Edit, "Edit", modifier = Modifier.size(18.dp)) + Icon(Icons.Outlined.Edit, appText(R.string.radio_bookmarks_screen_edit), modifier = Modifier.size(18.dp)) } IconButton(onClick = onRemove, modifier = Modifier.size(36.dp)) { - Icon(Icons.Outlined.Delete, "Remove", modifier = Modifier.size(18.dp)) + Icon(Icons.Outlined.Delete, appText(R.string.radio_bookmarks_screen_remove), modifier = Modifier.size(18.dp)) } } } @@ -249,7 +253,7 @@ private fun BookmarkCard( ) } Text( - if (onAir) "On the air — tap to open detail" else "Not this session", + if (onAir) appText(R.string.radio_bookmarks_screen_on_the_air_tap_to_open_detail) else appText(R.string.radio_bookmarks_screen_not_this_session), modifier = underName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -272,7 +276,7 @@ private fun BookmarkCard( verticalAlignment = Alignment.CenterVertically, ) { Text( - "Mine", + appText(R.string.radio_bookmarks_screen_mine), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -280,7 +284,7 @@ private fun BookmarkCard( FieldwatchSwitch(checked = row.mine, onCheckedChange = onMine) Spacer(Modifier.width(16.dp)) Text( - "Alert", + appText(R.string.radio_bookmarks_screen_alert), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/app/fieldwatch/ui/screen/ReportsScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/ReportsScreen.kt index 4f743e0..57d4d10 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/ReportsScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/ReportsScreen.kt @@ -1,5 +1,15 @@ package app.fieldwatch.ui.screen +import app.fieldwatch.i18n.displayLiveDecode + +import app.fieldwatch.i18n.forDisplay + +import app.fieldwatch.i18n.appQuantity + +import app.fieldwatch.i18n.appText + +import app.fieldwatch.R + import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -85,15 +95,15 @@ fun ReportsScreen( ) { val settings = state.settings var confirmClear by remember { mutableStateOf(false) } - var startSit by remember { mutableStateOf(false) } - var sitNameDraft by remember { mutableStateOf("") } - var renameSitId by remember { mutableStateOf(null) } - var renameDraft by remember { mutableStateOf("") } - var deleteSitId by remember { mutableStateOf(null) } - var confirmDeleteAll by remember { mutableStateOf(false) } + var startSit by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(false) } + var sitNameDraft by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf("") } + var renameSitId by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(null) } + var renameDraft by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf("") } + var deleteSitId by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(null) } + var confirmDeleteAll by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(false) } Scaffold( contentWindowInsets = NestedTabInsets, - topBar = { NestedTopBar("Reports") }, + topBar = { NestedTopBar(appText(R.string.reports_screen_reports)) }, ) { pad -> Column( Modifier @@ -105,15 +115,15 @@ fun ReportsScreen( ) { if (settings.demoMode) { Text( - "Privacy mode is on. MAC tails in Debrief, sit compare, AI Export (sit or compare), and detail Share are **:**:**. GPS coordinates are masked. The log file, sit export, and GPX / KML / WiGLE files still have full addresses and lat/lon.", + appText(R.string.reports_screen_privacy_mode_is_on_mac_tails_in), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, ) } - SectionCard("Sits") { + SectionCard(appText(R.string.reports_screen_sits)) { Text( - "A sit is a named window of radios heard here. The selection below drives Path, Debrief, and Compare’s this-sit side: open sit, a selected saved sit, or last 15 minutes if you never start one.", + appText(R.string.reports_screen_a_sit_is_a_named_window_of), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -121,14 +131,14 @@ fun ReportsScreen( if (open != null) { val dur = Sit.fmtDuration(open.durationMs()) Text( - "This sit: ${open.name} · $dur · ${state.sit.radioCount} radios", + appText(R.string.reports_screen_this_sit_radios, open.name, dur, state.sit.radioCount), style = MaterialTheme.typography.bodyMedium, ) FieldwatchActionButton( onClick = vm::endSit, enabled = !exporting, modifier = Modifier.fillMaxWidth(), - ) { Text("End sit") } + ) { Text(appText(R.string.reports_screen_end_sit)) } } else { FieldwatchActionButton( onClick = { @@ -137,12 +147,12 @@ fun ReportsScreen( }, enabled = !exporting, modifier = Modifier.fillMaxWidth(), - ) { Text("Start sit") } + ) { Text(appText(R.string.reports_screen_start_sit)) } Text( if (state.sit.closed.isEmpty()) { - "No sit running. Start sit here. Path and Debrief stay last 15 minutes until you do." + appText(R.string.reports_screen_no_sit_running_start_sit_here_path) } else { - "No sit running. Start sit here. Path and Debrief use the selected sit." + appText(R.string.reports_screen_no_sit_running_start_sit_here_path_2) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -150,7 +160,7 @@ fun ReportsScreen( } if (state.sit.closed.isEmpty() && open == null) { Text( - "No saved sits.", + appText(R.string.reports_screen_no_saved_sits), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -160,14 +170,14 @@ fun ReportsScreen( SitChoiceRow( selected = state.sit.selectedId == null, enabled = pickEnabled, - title = "Last 15 minutes", - subtitle = "Path and Debrief use RAM, not a saved sit.", + title = appText(R.string.reports_screen_last_15_minutes), + subtitle = appText(R.string.reports_screen_path_and_debrief_use_ram_not_a), onSelect = { vm.selectSit(null) }, ) state.sit.closed.forEach { row -> val dur = Sit.fmtDuration(row.durationMs()) val extra = if (row.extraAttentionCount > 0) { - " · Extra attention ${row.extraAttentionCount}" + appText(R.string.reports_screen_extra_attention, row.extraAttentionCount) } else { "" } @@ -175,13 +185,13 @@ fun ReportsScreen( selected = state.sit.selectedId == row.id, enabled = pickEnabled, title = row.name, - subtitle = "${Sit.defaultName(row.startAt)} · $dur · ${row.radioCount} radios$extra", + subtitle = appText(R.string.reports_screen_radios, Sit.defaultName(row.startAt), dur, row.radioCount, extra), onSelect = { vm.selectSit(row.id) }, ) } if (open != null) { Text( - "End sit to pick a saved one for Path and Debrief.", + appText(R.string.reports_screen_end_sit_to_pick_a_saved_one), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -200,18 +210,18 @@ fun ReportsScreen( }, enabled = !exporting && picked != null, modifier = Modifier.weight(1f), - ) { Text("Rename") } + ) { Text(appText(R.string.reports_screen_rename)) } FieldwatchActionButton( onClick = { if (picked != null) deleteSitId = picked.id }, enabled = !exporting && picked != null, modifier = Modifier.weight(1f), - ) { Text("Delete") } + ) { Text(appText(R.string.reports_screen_delete)) } } FieldwatchActionButton( onClick = { confirmDeleteAll = true }, enabled = !exporting, modifier = Modifier.fillMaxWidth(), - ) { Text("Delete all sits") } + ) { Text(appText(R.string.reports_screen_delete_all_sits)) } } } @@ -222,9 +232,9 @@ fun ReportsScreen( kotlinx.coroutines.delay(3_000L) } } - SectionCard("Path") { + SectionCard(appText(R.string.reports_screen_path)) { Text( - "North up. The line is this phone. The black dot is the start. The blue dot is you, at the last point. A MAC or signature alert is one class icon. A decoded latitude and longitude uses the last position that radio sent. A count is several in one spot. Thick green is a stay. Time ticks along the path.", + appText(R.string.reports_screen_north_up_the_line_is_this_phone), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -233,7 +243,7 @@ fun ReportsScreen( val showAircraft = model != null && model.aircraftCards.isNotEmpty() if (model == null || (!showWalk && !showAircraft)) { Text( - model?.emptyHint ?: "Tag detections with GPS and walk, or open a sit that recorded a path.", + model?.emptyHint ?: appText(R.string.reports_screen_tag_detections_with_gps_and_walk_or), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -242,7 +252,7 @@ fun ReportsScreen( val aircraftTiles by vm.pathAircraftTiles.collectAsStateWithLifecycle() if (!showWalk) { Text( - model.emptyHint ?: "Tag detections with GPS and walk, or open a sit that recorded a path.", + model.emptyHint ?: appText(R.string.reports_screen_tag_detections_with_gps_and_walk_or), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -251,9 +261,9 @@ fun ReportsScreen( val stopN = model.dots.size Text( buildString { - append("${model.title} · ${model.lengthM.toInt()} m path · ${model.spanM.toInt()} m span") + append(appText(R.string.reports_screen_m_path_m_span, model.title, model.lengthM.toInt(), model.spanM.toInt())) if (stopN > 0) { - append(" · $stopN alert") + append(appText(R.string.reports_screen_alert, stopN)) if (stopN != 1) append("s") } }, @@ -261,7 +271,7 @@ fun ReportsScreen( ) SitPathCanvas(model, tiles = pathTiles, onOpenRadio = onOpenPathRadio) Text( - "Tap a count for the radios there. Tap a single icon for that one radio. Tap again to close. Tap a row in that list, or a row below, to open that radio.", + appText(R.string.reports_screen_tap_a_count_for_the_radios_there), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -276,9 +286,9 @@ fun ReportsScreen( if (multi) AdvertisedTrackSwatch() else AdvertisedRingSwatch() Text( if (multi) { - "= advertised track within 2 km of this path" + appText(R.string.reports_screen_advertised_track_within_2_km_of_this) } else { - "= one advertised position within 2 km of this path" + appText(R.string.reports_screen_one_advertised_position_within_2_km_of) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, @@ -292,7 +302,7 @@ fun ReportsScreen( ) { PilotSwatch() Text( - "= pilot", + appText(R.string.reports_screen_pilot), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -303,7 +313,7 @@ fun ReportsScreen( val alertsOnACard = model.aircraftCards.any { it.dots.isNotEmpty() } if (model.dots.isEmpty() && !alertsOnACard) { Text( - "No MAC or signature alerts with a GPS stamp on this path.", + appText(R.string.reports_screen_no_mac_or_signature_alerts_with_a), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -331,7 +341,7 @@ fun ReportsScreen( Text( buildString { append( - if (fixes == 1) "1 advertised fix" else "$fixes advertised fixes", + appQuantity(R.plurals.advertised_fixes, fixes), ) if (card.lengthM >= 1.0) append(" · ${card.lengthM.toInt()} m") }, @@ -339,7 +349,7 @@ fun ReportsScreen( ) if (card.dots.isNotEmpty()) { Text( - if (card.dots.size == 1) "1 alert" else "${card.dots.size} alerts", + if (card.dots.size == 1) appText(R.string.reports_screen_1_alert) else appText(R.string.reports_screen_alerts, card.dots.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -372,9 +382,9 @@ fun ReportsScreen( if (model.looseAdvertised > 0) { Text( if (model.looseAdvertised == 1) { - "An advertised position with no UAS id is in the sit report." + appText(R.string.reports_screen_an_advertised_position_with_no_uas_id) } else { - "Advertised positions with no UAS id are in the sit report." + appText(R.string.reports_screen_advertised_positions_with_no_uas_id_are) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -383,7 +393,7 @@ fun ReportsScreen( } } - SectionCard("Sit report") { + SectionCard(appText(R.string.reports_screen_sit_report)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -392,12 +402,12 @@ fun ReportsScreen( onClick = vm::startFieldDebrief, enabled = !exporting, modifier = Modifier.weight(1f), - ) { Text("Debrief (text)") } + ) { Text(appText(R.string.reports_screen_debrief_text)) } FieldwatchActionButton( onClick = vm::startFieldDebriefPdf, enabled = !exporting, modifier = Modifier.weight(1f), - ) { Text("Debrief (PDF)") } + ) { Text(appText(R.string.reports_screen_debrief_pdf)) } } Text( sitReportCaption(state), @@ -409,7 +419,7 @@ fun ReportsScreen( verticalAlignment = Alignment.CenterVertically, ) { Text( - "Show unmatched rotating BLE", + appText(R.string.reports_screen_show_unmatched_rotating_ble), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, ) @@ -419,7 +429,7 @@ fun ReportsScreen( ) } Text( - "Off (default): Debrief text/PDF lists skip unmatched RAND BLE. Counts still include them. Extra attention, named signatures, bookmarks, and payload pins stay. Sit export has every radio.", + appText(R.string.reports_screen_off_default_debrief_text_pdf_lists_skip), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -428,7 +438,7 @@ fun ReportsScreen( verticalAlignment = Alignment.CenterVertically, ) { Text( - "Show all radios", + appText(R.string.reports_screen_show_all_radios), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, ) @@ -438,7 +448,7 @@ fun ReportsScreen( ) } Text( - "Off (default): Debrief and Compare lead with counts. A radio is listed when it is Extra attention, has a custom name, is marked Mine, or is bookmarked. Compare also lists a decoded value that changed. On: the full rosters return. Show unmatched rotating BLE applies to those lists. The PDF draws the counts as bars. Sit export has every radio.", + appText(R.string.reports_screen_off_default_debrief_and_compare_lead_with), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -446,15 +456,15 @@ fun ReportsScreen( onClick = vm::startAiExport, enabled = !exporting, modifier = Modifier.fillMaxWidth(), - ) { Text("AI Export") } + ) { Text(appText(R.string.reports_screen_ai_export)) } Text( - "Paste-ready addendum: rates, RSSI bands, Extra attention and tracking IDs. Does not reprint Debrief inventories. One-radio AI Export is on detail.", + appText(R.string.reports_screen_paste_ready_addendum_rates_rssi_bands_extra), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - SectionCard("Sit export") { + SectionCard(appText(R.string.reports_screen_sit_export)) { val sitKind by vm.sitExportKind.collectAsStateWithLifecycle() val sitRadios by vm.sitExportRadios.collectAsStateWithLifecycle() ExportFormatBlock( @@ -465,11 +475,11 @@ fun ReportsScreen( onRadios = vm::setSitExportRadios, onShare = vm::startSitExport, onSave = onSaveSitToStorage, - hint = "One row per unique radio in this sit (or last 15 minutes). CSV / JSON lines include matched signatures and Extra attention families. Not the rotating log. GPX / KML include this phone’s path as a track plus hear-points. Fieldwatch does not upload. Privacy mode does not mask this file.", + hint = appText(R.string.reports_screen_one_row_per_unique_radio_in_this), ) } - SectionCard("Compare sits") { + SectionCard(appText(R.string.reports_screen_compare_sits)) { Text( compareThisCaption(state), style = MaterialTheme.typography.bodySmall, @@ -479,13 +489,13 @@ fun ReportsScreen( val choices = SitDiff.secondSitChoices(state.sit.closed, thisSaved) if (choices.isEmpty()) { Text( - "Save a second sit to compare. Start sit, then End sit. Last 15 minutes can be this sit.", + appText(R.string.reports_screen_save_a_second_sit_to_compare_start), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { Text( - "Second sit", + appText(R.string.reports_screen_second_sit), style = MaterialTheme.typography.bodyMedium, ) choices.forEach { row -> @@ -494,7 +504,7 @@ fun ReportsScreen( selected = state.sit.compareId == row.id, enabled = !exporting, title = row.name, - subtitle = "${Sit.defaultName(row.startAt)} · $dur · ${row.radioCount} radios", + subtitle = appText(R.string.reports_screen_radios_2, Sit.defaultName(row.startAt), dur, row.radioCount), onSelect = { vm.selectCompareSit(row.id) }, ) } @@ -507,15 +517,15 @@ fun ReportsScreen( onClick = vm::startSitCompare, enabled = !exporting && state.sit.compareId != null, modifier = Modifier.weight(1f), - ) { Text("Compare (text)") } + ) { Text(appText(R.string.reports_screen_compare_text)) } FieldwatchActionButton( onClick = vm::startSitComparePdf, enabled = !exporting && state.sit.compareId != null, modifier = Modifier.weight(1f), - ) { Text("Compare (PDF)") } + ) { Text(appText(R.string.reports_screen_compare_pdf)) } } Text( - "Same report, two formats. Presence only — only in this sit, only in the second, in both. Kind + MAC. Extra attention and Named radios are marked. Not a radio fix.", + appText(R.string.reports_screen_same_report_two_formats_presence_only_only), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -523,31 +533,31 @@ fun ReportsScreen( onClick = vm::startSitCompareAiExport, enabled = !exporting && state.sit.compareId != null, modifier = Modifier.fillMaxWidth(), - ) { Text("AI Export") } + ) { Text(appText(R.string.reports_screen_ai_export)) } Text( - "Paste-ready addendum: overlap, exclusive Extra attention / Named radios, what another sit would shrink. Does not reprint the compare lists. Sit report AI Export stays this window only.", + appText(R.string.reports_screen_paste_ready_addendum_overlap_exclusive_extra_attention), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - SectionCard("Catalog") { + SectionCard(appText(R.string.reports_screen_catalog)) { FieldwatchActionButton( onClick = onSignatureCandidates, enabled = !exporting, modifier = Modifier.fillMaxWidth(), - ) { Text("Signature candidates") } + ) { Text(appText(R.string.reports_screen_signature_candidates)) } Text( - "Unmatched radios in the log that share a unique ID — not every unknown. You review; nothing is added until you Save.", + appText(R.string.reports_screen_unmatched_radios_in_the_log_that_share), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - SectionCard("Log export") { + SectionCard(appText(R.string.reports_screen_log_export)) { Text( - "${state.logLines} lines this session · ${vm.logBytes() / 1024} KB on disk" + - if (settings.loggingEnabled) "" else " · logging off", + appText(R.string.reports_screen_lines_this_session_kb_on_disk, state.logLines, vm.logBytes() / 1024) + + if (settings.loggingEnabled) "" else appText(R.string.reports_screen_logging_off), style = MaterialTheme.typography.bodySmall, ) val logKind by vm.logExportKind.collectAsStateWithLifecycle() @@ -560,30 +570,30 @@ fun ReportsScreen( onRadios = vm::setLogExportRadios, onShare = vm::startExport, onSave = onSaveToStorage, - hint = "The rotating file is JSON lines. CSV is the same rows as a spreadsheet. GPX — GPS Exchange, KML — Google Earth, and WiGLE CSV — wigle.net are hear-points: where this phone was when it heard each radio, not a radio fix. Tag detections with GPS and logging on. Share uses the Android share sheet — Fieldwatch does not upload.", + hint = appText(R.string.reports_screen_the_rotating_file_is_json_lines_csv), ) FieldwatchActionButton( onClick = { confirmClear = true }, enabled = !exporting, modifier = Modifier.fillMaxWidth(), ) { - Text("Reset / clear log") + Text(appText(R.string.reports_screen_reset_clear_log)) } if (confirmClear) { AlertDialog( onDismissRequest = { confirmClear = false }, - title = { Text("Clear the log?") }, + title = { Text(appText(R.string.reports_screen_clear_the_log)) }, text = { - Text("This deletes all rotated CSV/JSON files on the phone. It cannot be undone. Live scanning will start a new empty log.") + Text(appText(R.string.reports_screen_this_deletes_all_rotated_csv_json_files)) }, confirmButton = { TextButton(onClick = { confirmClear = false vm.clearLogs() - }) { Text("Clear log") } + }) { Text(appText(R.string.reports_screen_clear_log)) } }, dismissButton = { - TextButton(onClick = { confirmClear = false }) { Text("Cancel") } + TextButton(onClick = { confirmClear = false }) { Text(appText(R.string.reports_screen_cancel)) } }, ) } @@ -593,16 +603,16 @@ fun ReportsScreen( if (startSit) { AlertDialog( onDismissRequest = { startSit = false }, - title = { Text("Start sit") }, + title = { Text(appText(R.string.reports_screen_start_sit)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { FieldwatchOutlinedField( value = sitNameDraft, onValueChange = { sitNameDraft = it.take(Sit.NAME_MAX) }, - label = "Name", + label = appText(R.string.reports_screen_name), ) Text( - "Debrief and AI Export use this window until you end it. The Live list is unchanged.", + appText(R.string.reports_screen_debrief_and_ai_export_use_this_window), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -619,10 +629,10 @@ fun ReportsScreen( TextButton(onClick = { startSit = false vm.startSit(sitNameDraft) - }) { Text("Start") } + }) { Text(appText(R.string.reports_screen_start)) } }, dismissButton = { - TextButton(onClick = { startSit = false }) { Text("Cancel") } + TextButton(onClick = { startSit = false }) { Text(appText(R.string.reports_screen_cancel)) } }, ) } @@ -630,22 +640,22 @@ fun ReportsScreen( if (renaming != null) { AlertDialog( onDismissRequest = { renameSitId = null }, - title = { Text("Rename sit") }, + title = { Text(appText(R.string.reports_screen_rename_sit)) }, text = { FieldwatchOutlinedField( value = renameDraft, onValueChange = { renameDraft = it.take(Sit.NAME_MAX) }, - label = "Name", + label = appText(R.string.reports_screen_name), ) }, confirmButton = { TextButton(onClick = { renameSitId = null vm.renameSit(renaming, renameDraft) - }) { Text("Save") } + }) { Text(appText(R.string.reports_screen_save)) } }, dismissButton = { - TextButton(onClick = { renameSitId = null }) { Text("Cancel") } + TextButton(onClick = { renameSitId = null }) { Text(appText(R.string.reports_screen_cancel)) } }, ) } @@ -653,32 +663,32 @@ fun ReportsScreen( if (deleting != null) { AlertDialog( onDismissRequest = { deleteSitId = null }, - title = { Text("Delete this sit?") }, - text = { Text("Removes the saved sit from this phone. The log is unchanged.") }, + title = { Text(appText(R.string.reports_screen_delete_this_sit)) }, + text = { Text(appText(R.string.reports_screen_removes_the_saved_sit_from_this_phone)) }, confirmButton = { TextButton(onClick = { deleteSitId = null vm.deleteSit(deleting) - }) { Text("Delete") } + }) { Text(appText(R.string.reports_screen_delete)) } }, dismissButton = { - TextButton(onClick = { deleteSitId = null }) { Text("Cancel") } + TextButton(onClick = { deleteSitId = null }) { Text(appText(R.string.reports_screen_cancel)) } }, ) } if (confirmDeleteAll) { AlertDialog( onDismissRequest = { confirmDeleteAll = false }, - title = { Text("Delete all sits?") }, - text = { Text("Removes saved sits from this phone. An open sit is not deleted. The log is unchanged.") }, + title = { Text(appText(R.string.reports_screen_delete_all_sits_2)) }, + text = { Text(appText(R.string.reports_screen_removes_saved_sits_from_this_phone_an)) }, confirmButton = { TextButton(onClick = { confirmDeleteAll = false vm.deleteAllSits() - }) { Text("Delete all") } + }) { Text(appText(R.string.reports_screen_delete_all)) } }, dismissButton = { - TextButton(onClick = { confirmDeleteAll = false }) { Text("Cancel") } + TextButton(onClick = { confirmDeleteAll = false }) { Text(appText(R.string.reports_screen_cancel)) } }, ) } @@ -846,13 +856,13 @@ private fun PathRadioRow( private fun compareThisCaption(state: FieldwatchUi): String { val open = state.sit.open if (open != null) { - return "This sit: ${open.name} — named window (up to ${Sit.RADIO_CAP}). Same as Debrief." + return appText(R.string.reports_screen_this_sit_named_window_up_to_same, open.name, Sit.RADIO_CAP) } val selected = state.sit.closed.firstOrNull { it.id == state.sit.selectedId } if (selected != null) { - return "This sit: ${selected.name} — named window (up to ${Sit.RADIO_CAP}). Same as Debrief." + return appText(R.string.reports_screen_this_sit_named_window_up_to_same_2, selected.name, Sit.RADIO_CAP) } - return "This sit: last 15 minutes in memory (about 400 radios). Same as Debrief." + return appText(R.string.reports_screen_this_sit_last_15_minutes_in_memory) } @OptIn(ExperimentalMaterial3Api::class) @@ -873,7 +883,7 @@ private fun ExportFormatBlock( onExpandedChange = { openFormat = it }, modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), ) { - FieldwatchDropdownField("Format", kind.label, openFormat) + FieldwatchDropdownField(appText(R.string.reports_screen_format), kind.label, openFormat) ExposedDropdownMenu(openFormat, { openFormat = false }) { LogExportKind.entries.forEach { item -> DropdownMenuItem( @@ -914,12 +924,12 @@ private fun ExportFormatBlock( onClick = onShare, enabled = !exporting, modifier = Modifier.fillMaxWidth(), - ) { Text("Share") } + ) { Text(appText(R.string.reports_screen_share)) } FieldwatchActionButton( onClick = onSave, enabled = !exporting, modifier = Modifier.fillMaxWidth(), - ) { Text("Save to SD card / storage…") } + ) { Text(appText(R.string.reports_screen_save_to_sd_card_storage)) } Text( hint, style = MaterialTheme.typography.bodySmall, @@ -930,11 +940,11 @@ private fun ExportFormatBlock( private fun sitReportCaption(state: FieldwatchUi): String { val open = state.sit.open if (open != null) { - return "This sit (${open.name}) — same window as Path. GPS following test when tagging is on and you have moved. Not a legal finding." + return appText(R.string.reports_screen_this_sit_same_window_as_path_gps, open.name) } val selected = state.sit.closed.firstOrNull { it.id == state.sit.selectedId } if (selected != null) { - return "Sit: ${selected.name} — same window as Path. GPS following test when tagging is on and you have moved. Not a legal finding." + return appText(R.string.reports_screen_sit_same_window_as_path_gps_following, selected.name) } - return "Last 15 minutes in memory — same window as Path. Two formats. GPS following test when tagging is on and you have moved. Not a legal finding." + return appText(R.string.reports_screen_last_15_minutes_in_memory_same_window) } diff --git a/app/src/main/java/app/fieldwatch/ui/screen/SettingsScreen.kt b/app/src/main/java/app/fieldwatch/ui/screen/SettingsScreen.kt index 940f8ee..48be366 100644 --- a/app/src/main/java/app/fieldwatch/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/app/fieldwatch/ui/screen/SettingsScreen.kt @@ -1,5 +1,11 @@ package app.fieldwatch.ui.screen +import app.fieldwatch.i18n.takDetail + +import app.fieldwatch.i18n.displayLabel + +import app.fieldwatch.i18n.appText + import android.app.ActivityManager import android.content.Context import android.content.Intent @@ -104,7 +110,7 @@ fun SettingsScreen( var confirmRestore by remember { mutableStateOf(false) } Scaffold( contentWindowInsets = NestedTabInsets, - topBar = { NestedTopBar("Settings") }, + topBar = { NestedTopBar(appText(R.string.settings_screen_settings)) }, ) { pad -> Column( Modifier @@ -114,45 +120,46 @@ fun SettingsScreen( .padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - SectionCard("Appearance") { + SectionCard(androidx.compose.ui.res.stringResource(R.string.settings_language)) { + app.fieldwatch.ui.component.LanguagePicker() + } + SectionCard(appText(R.string.settings_screen_appearance)) { Row(verticalAlignment = Alignment.CenterVertically) { - Text("Night mode", Modifier.weight(1f)) + Text(appText(R.string.settings_screen_night_mode), Modifier.weight(1f)) FieldwatchSwitch(settings.nightMode, { on -> vm.updateSettings { it.copy(nightMode = on) } }) } Text( - "Off by default. Red-on-black field display so chips, text, and signal marks " + - "do not dump green or blue into a dark sit. Background stays dark. " + - "Phone brightness is unchanged.", + appText(R.string.settings_screen_off_by_default_red_on_black_field), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Row(verticalAlignment = Alignment.CenterVertically) { - Text("Keep screen on", Modifier.weight(1f)) + Text(appText(R.string.settings_screen_keep_screen_on), Modifier.weight(1f)) FieldwatchSwitch(settings.keepScreenOn, { on -> vm.updateSettings { it.copy(keepScreenOn = on) } }) } Text( - "On by default. Stops the display from sleeping while Fieldwatch is open so BLE is not parked when the phone blanks. Scanning still runs in the notification if you leave the app. Turn it off when you pocket the phone.", + appText(R.string.settings_screen_on_by_default_stops_the_display_from), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Row(verticalAlignment = Alignment.CenterVertically) { - Text("Privacy mode", Modifier.weight(1f)) + Text(appText(R.string.settings_screen_privacy_mode), Modifier.weight(1f)) FieldwatchSwitch(settings.demoMode, { on -> vm.updateSettings { it.copy(demoMode = on) } }) } Text( - "Hides the last three octets of every MAC on Live, radar, timeline, detail, Hunt, Named radios, and watchlist cards as **:**:** so the screen and sit reports do not show full addresses. GPS last-fix and Debrief / AI Export / detail Share coordinates become “masked”; street names are omitted from those sit reports. The first three octets (OUI / vendor prefix) stay. Off by default. The map on Reports → Path still loads when Online place names and maps is on. Logs, matching, filters, Hunt math, Moving with you, and saved signatures still use the real MAC and GPS. A TAK / CoT feed, if you turned it on, is paused while this is on so full MACs and coordinates are not sent onto the LAN. Turn this off when you need the full address or coordinates on screen.", + appText(R.string.settings_screen_hides_the_last_three_octets_of_every), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - SectionCard("Scanning") { + SectionCard(appText(R.string.settings_screen_scanning)) { val label = when (settings.intensity) { - ScanIntensity.SAVER -> "Battery saver" - ScanIntensity.BALANCED -> "Balanced" - ScanIntensity.PERFORMANCE -> "High performance" + ScanIntensity.SAVER -> appText(R.string.settings_screen_battery_saver) + ScanIntensity.BALANCED -> appText(R.string.settings_screen_balanced) + ScanIntensity.PERFORMANCE -> appText(R.string.settings_screen_high_performance) } - Text("Scan intensity · $label") + Text(appText(R.string.settings_screen_scan_intensity, label)) FieldwatchSlider( value = settings.intensity.ordinal.toFloat(), onValueChange = { v -> @@ -163,15 +170,15 @@ fun SettingsScreen( steps = 1, ) Text( - "Wi-Fi is a batch radio: the phone grabs every AP at once, then must wait. High performance asks about every 30s — that is the fastest cadence that stays under the OS limit of four scans per two minutes. BLE still streams in between.", + appText(R.string.settings_screen_wi_fi_is_a_batch_radio_the), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) StableCaption( state.throttleHint.ifBlank { " " }, - "Wi-Fi waiting on OS", - "Wi-Fi scanning", - "Wi-Fi next 99s", + appText(R.string.settings_screen_wi_fi_waiting_on_os), + appText(R.string.settings_screen_wi_fi_scanning), + appText(R.string.settings_screen_wi_fi_next_99s), " ", ) @@ -194,7 +201,7 @@ fun SettingsScreen( } val fastActive = settings.wifiFastScan && !osThrottled Row(verticalAlignment = Alignment.CenterVertically) { - Text("Faster Wi-Fi AP scans", Modifier.weight(1f)) + Text(appText(R.string.settings_screen_faster_wi_fi_ap_scans), Modifier.weight(1f)) FieldwatchSwitch( checked = settings.wifiFastScan, onCheckedChange = { on -> @@ -211,30 +218,29 @@ fun SettingsScreen( StableCaption( when { Build.VERSION.SDK_INT < 30 -> - "Needs Android 11+ so Fieldwatch can read whether the OS is still throttling scans. This phone cannot confirm that, so the switch stays off." + appText(R.string.settings_screen_needs_android_11_so_fieldwatch_can_read) fastActive -> - "On. Fieldwatch asks for a new AP list about every 8 seconds. Uses more battery and heat. If the OS starts refusing scans, it backs off." + appText(R.string.settings_screen_on_fieldwatch_asks_for_a_new_ap) settings.wifiFastScan && osThrottled -> - "Saved on, but not in effect — Android Wi-Fi scan throttling is still on. Turn that off in Developer options, then return here." + appText(R.string.settings_screen_saved_on_but_not_in_effect_android) else -> - "Stock Android allows about four AP scans per two minutes. Faster scans only run after you turn off Wi-Fi scan throttling in Developer options. Fieldwatch checks that OS switch before turning this on, and cannot change it for you." + appText(R.string.settings_screen_stock_android_allows_about_four_ap_scans) }, - "Needs Android 11+ so Fieldwatch can read whether the OS is still throttling scans. This phone cannot confirm that, so the switch stays off.", - "On. Fieldwatch asks for a new AP list about every 8 seconds. Uses more battery and heat. If the OS starts refusing scans, it backs off.", - "Saved on, but not in effect — Android Wi-Fi scan throttling is still on. Turn that off in Developer options, then return here.", - "Stock Android allows about four AP scans per two minutes. Faster scans only run after you turn off Wi-Fi scan throttling in Developer options. Fieldwatch checks that OS switch before turning this on, and cannot change it for you.", + appText(R.string.settings_screen_needs_android_11_so_fieldwatch_can_read), + appText(R.string.settings_screen_on_fieldwatch_asks_for_a_new_ap), + appText(R.string.settings_screen_saved_on_but_not_in_effect_android), + appText(R.string.settings_screen_stock_android_allows_about_four_ap_scans), ) if (needDevOptions) { AlertDialog( onDismissRequest = { needDevOptions = false }, - title = { Text("Developer options required") }, + title = { Text(appText(R.string.settings_screen_developer_options_required)) }, text = { Text( if (Build.VERSION.SDK_INT < 30) { - "This phone is older than Android 11, so Fieldwatch cannot read the OS Wi-Fi scan-throttle switch. Faster AP scanning stays off." + appText(R.string.settings_screen_this_phone_is_older_than_android_11) } else { - "Android is still throttling Wi-Fi scans (about four per two minutes). Fieldwatch will not turn Faster Wi-Fi AP scans on until that is off.\n\n" + - "Enable Developer options (tap Build number seven times in About phone), then Settings → Developer options → Wi-Fi scan throttling → Off. Come back and flip this switch again." + appText(R.string.settings_screen_android_is_still_throttling_wi_fi_scans) }, ) }, @@ -247,43 +253,39 @@ fun SettingsScreen( context.startActivity(Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS)) } }, - ) { Text("Open developer options") } + ) { Text(appText(R.string.settings_screen_open_developer_options)) } } else { - TextButton(onClick = { needDevOptions = false }) { Text("OK") } + TextButton(onClick = { needDevOptions = false }) { Text(appText(R.string.settings_screen_ok)) } } }, dismissButton = { if (Build.VERSION.SDK_INT >= 30) { - TextButton(onClick = { needDevOptions = false }) { Text("Not now") } + TextButton(onClick = { needDevOptions = false }) { Text(appText(R.string.settings_screen_not_now)) } } }, ) } Row(verticalAlignment = Alignment.CenterVertically) { - Text("Allow background usage", Modifier.weight(1f)) + Text(appText(R.string.settings_screen_allow_background_usage), Modifier.weight(1f)) FieldwatchSwitch( checked = backgroundAllowed, onCheckedChange = { batteryGate = BatteryAndroidGate.BACKGROUND }, ) } Text( - "Mirrors Android Allow background usage. Tap to open Fieldwatch’s Battery page and " + - "use that switch. Fieldwatch updates when you return. Off: the OS can kill the scan " + - "as soon as you leave. Not Keep screen on.", + appText(R.string.settings_screen_mirrors_android_allow_background_usage_tap_to), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Row(verticalAlignment = Alignment.CenterVertically) { - Text("Unrestricted battery", Modifier.weight(1f)) + Text(appText(R.string.settings_screen_unrestricted_battery), Modifier.weight(1f)) FieldwatchSwitch( checked = unrestricted, onCheckedChange = { batteryGate = BatteryAndroidGate.UNRESTRICTED }, ) } Text( - "Mirrors Android Unrestricted (not Optimized). Some phones (Samsung among them) do not " + - "open onto that choice. If you only see Allow background usage, tap that row to " + - "click through and select Unrestricted. Fieldwatch updates when you return.", + appText(R.string.settings_screen_mirrors_android_unrestricted_not_optimized_some_phones), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -292,18 +294,14 @@ fun SettingsScreen( AlertDialog( onDismissRequest = { batteryGate = null }, title = { - Text(if (background) "Allow background usage" else "Unrestricted battery") + Text(if (background) appText(R.string.settings_screen_allow_background_usage) else appText(R.string.settings_screen_unrestricted_battery)) }, text = { Text( if (background) { - "The next screen is Fieldwatch’s Battery page. Use the Allow background usage switch. " + - "Fieldwatch will match that setting when you return." + appText(R.string.settings_screen_the_next_screen_is_fieldwatch_s_battery) } else { - "Some phones (Samsung among them) do not open onto Unrestricted / " + - "Optimized / Restricted. If you only see Allow background usage, " + - "tap that row (the words, not the blue switch) to click through, " + - "then select Unrestricted. Fieldwatch will match that when you return." + appText(R.string.settings_screen_some_phones_samsung_among_them_do_not) }, ) }, @@ -317,22 +315,22 @@ fun SettingsScreen( highlightBackground = gate == BatteryAndroidGate.BACKGROUND, ) }, - ) { Text("Open Android settings") } + ) { Text(appText(R.string.settings_screen_open_android_settings)) } }, dismissButton = { - TextButton(onClick = { batteryGate = null }) { Text("Not now") } + TextButton(onClick = { batteryGate = null }) { Text(appText(R.string.settings_screen_not_now)) } }, ) } } - SectionCard("Watchlist") { + SectionCard(appText(R.string.settings_screen_watchlist)) { Row(verticalAlignment = Alignment.CenterVertically) { - Text("Watchlist alerts", Modifier.weight(1f)) + Text(appText(R.string.settings_screen_watchlist_alerts), Modifier.weight(1f)) FieldwatchSwitch(settings.alertsEnabled, { on -> vm.updateSettings { it.copy(alertsEnabled = on) } }) } Text( - "On by default. Master switch for bookmarked signatures and devices. Off: no beep, vibration, flash, jump, or shade card. Bookmarking still works — you just will not be told when that radio appears.", + appText(R.string.settings_screen_on_by_default_master_switch_for_bookmarked), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -340,14 +338,14 @@ fun SettingsScreen( FieldwatchActionButton( onClick = onRadioBookmarks, modifier = Modifier.fillMaxWidth(), - ) { Text("Named radios ($radioWatchN)") } + ) { Text(appText(R.string.settings_screen_named_radios, radioWatchN)) } Text( - "Custom names for one MAC. Alert is optional. Filters → Named radios only shows them on Live. Signature watches stay on Signatures.", + appText(R.string.settings_screen_custom_names_for_one_mac_alert_is), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Row(verticalAlignment = Alignment.CenterVertically) { - Text("Beep on watched signature", Modifier.weight(1f)) + Text(appText(R.string.settings_screen_beep_on_watched_signature), Modifier.weight(1f)) FieldwatchSwitch( settings.alertBeep, { on -> vm.updateSettings { it.copy(alertBeep = on) } }, @@ -355,12 +353,12 @@ fun SettingsScreen( ) } Text( - "The double pip on media volume when a bookmarked signature or device first appears, or returns after leaving. Sitting detections do not beep again. Independent of Voice — use beep, voice, or both. Raise media volume if you hear nothing, then tap Test alert.", + appText(R.string.settings_screen_the_double_pip_on_media_volume_when), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Row(verticalAlignment = Alignment.CenterVertically) { - Text("Voice on watched signature", Modifier.weight(1f)) + Text(appText(R.string.settings_screen_voice_on_watched_signature), Modifier.weight(1f)) FieldwatchSwitch( settings.alertVoice, { on -> vm.updateSettings { it.copy(alertVoice = on) } }, @@ -368,23 +366,23 @@ fun SettingsScreen( ) } Text( - "On by default. Speaks on the same media volume as the pip. Independent of Beep: with Beep on, voice follows the pip; with Beep off, voice only. Not Hunt. If a phrase is already being spoken, a second hit is skipped. Phones with no text-to-speech still beep if Beep is on.", + appText(R.string.settings_screen_on_by_default_speaks_on_the_same), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Text("What to say", style = MaterialTheme.typography.labelLarge) + Text(appText(R.string.settings_screen_what_to_say), style = MaterialTheme.typography.labelLarge) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { AlertVoiceWhat.entries.forEach { item -> FieldwatchFilterChip( selected = settings.alertVoiceWhat == item, onClick = { vm.updateSettings { it.copy(alertVoiceWhat = item) } }, enabled = settings.alertsEnabled && settings.alertVoice, - label = { Text(item.label()) }, + label = { Text(item.displayLabel()) }, ) } } Text( - "For signature watches: Class is the Live glyph bucket (finder tags, audio, …). Signature is the catalog row (Apple AirTags, Axon, …). Class + signature (default) says both. A named radio with Alert on always says its custom name, even if it has no class. Test alert plays the signature mix you have on.", + appText(R.string.settings_screen_for_signature_watches_class_is_the_live), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -392,9 +390,9 @@ fun SettingsScreen( onClick = vm::testWatchBeep, modifier = Modifier.fillMaxWidth(), enabled = settings.alertsEnabled && (settings.alertBeep || settings.alertVoice), - ) { Text("Test alert") } + ) { Text(appText(R.string.settings_screen_test_alert)) } Row(verticalAlignment = Alignment.CenterVertically) { - Text("Jump to new watched detection", Modifier.weight(1f)) + Text(appText(R.string.settings_screen_jump_to_new_watched_detection), Modifier.weight(1f)) FieldwatchSwitch( settings.snapToBeep, { on -> vm.updateSettings { it.copy(snapToBeep = on) } }, @@ -402,12 +400,12 @@ fun SettingsScreen( ) } Text( - "When a new watched signature or device appears, Live scrolls to that row so you can see the flash. Works with beep, voice, or both. Weak hits sit at the bottom of a strength-ranked list. Turn this off if you do not want the list to move.", + appText(R.string.settings_screen_when_a_new_watched_signature_or_device), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Row(verticalAlignment = Alignment.CenterVertically) { - Text("System notification", Modifier.weight(1f)) + Text(appText(R.string.settings_screen_system_notification), Modifier.weight(1f)) FieldwatchSwitch( settings.alertShade, { on -> vm.updateSettings { it.copy(alertShade = on) } }, @@ -415,36 +413,28 @@ fun SettingsScreen( ) } Text( - "Optional. Posts a silent shade card when a watched radio appears. Off by default — the beep and flash are enough, and skipping the card keeps the scan loop lighter.", + appText(R.string.settings_screen_optional_posts_a_silent_shade_card_when), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - SectionCard("Location") { + SectionCard(appText(R.string.settings_screen_location)) { Row(verticalAlignment = Alignment.CenterVertically) { - Text("Tag detections with GPS", Modifier.weight(1f)) + Text(appText(R.string.settings_screen_tag_detections_with_gps), Modifier.weight(1f)) FieldwatchSwitch(settings.tagLocation, { on -> vm.updateSettings { it.copy(tagLocation = on) } }) } Text( - "On by default. Requests live GPS/network updates and stamps each hear (Live detail, Moving with you, " + - "Debrief, and lat/lon on new log rows). Last-known-only is ignored if older than 30 s. " + - "That is your GPS at hear-time, not an independent fix on the other radio. " + - "Use high-accuracy Location or the path stays 0. Turn off if you do not want operator coordinates on logs. " + - "Heard-here TAK pins also need this; advertised payload coordinates (Remote ID) do not.", + appText(R.string.settings_screen_on_by_default_requests_live_gps_network), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Row(verticalAlignment = Alignment.CenterVertically) { - Text("Online place names and maps", Modifier.weight(1f)) + Text(appText(R.string.settings_screen_online_place_names_and_maps), Modifier.weight(1f)) FieldwatchSwitch(settings.onlineLookup, { on -> vm.updateSettings { it.copy(onlineLookup = on) } }) } Text( - "On by default. When the phone has internet, Debrief / AI Export reverse-geocode GPS stamps " + - "to street/city, and Reports → Path loads OpenStreetMap tiles under the trace. " + - "No Fieldwatch cloud, no API key. Offline or no geocoder: Debrief uses coordinates only and Path stays the current north-up trace — no error dialog. " + - "Turn off to keep streets and map tiles out of reports and Path. " + - "Debrief, Sit export, Log export, and Reset / clear log are on the Reports tab.", + appText(R.string.settings_screen_on_by_default_when_the_phone_has), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -452,27 +442,17 @@ fun SettingsScreen( SectionCard("TAK / CoT") { Row(verticalAlignment = Alignment.CenterVertically) { - Text("TAK / CoT feed", Modifier.weight(1f)) + Text(appText(R.string.settings_screen_tak_cot_feed), Modifier.weight(1f)) FieldwatchSwitch(settings.takEnabled, { on -> vm.updateSettings { it.copy(takEnabled = on) } }) } Text( - "Off by default. Sends Cursor-on-Target UDP markers to ATAK, WinTAK, or iTAK. " + - "This phone (${TakDefaults.LOOPBACK}:${TakDefaults.PORT}) is ATAK CIV on this handset. " + - "LAN multicast is ${TakDefaults.SA_HOST}:${TakDefaults.SA_PORT}. " + - "Custom is a unicast IPv4 or hostname. UDP only — a TAK server’s TCP 8087 is not this feed. " + - "Heard-here pins sit at this phone’s GPS at the loudest hear (closest approach) and are labeled (here). " + - "Walking away does not drag the pin; a louder hear moves it. Keep-alives refresh the same lat/lon every ~10 s so ATAK does not drop it. " + - "Advertised lat/lon (stock Remote ID) sit on the aircraft; the same Remote ID " + - "keeps one marker that moves (UAS ID, not the rotating BLE MAC). " + - "A decoded pilot location is a second pin. Gone radios are dropped on ATAK instead of sitting 120 s. " + - "Tap a marker in ATAK for remarks (name, MAC, RSSI, signatures). " + - "Not direction-finding. Not a Remote ID plugin. Privacy mode pauses the feed.", + appText(R.string.settings_screen_off_by_default_sends_cursor_on_target, TakDefaults.LOOPBACK, TakDefaults.PORT, TakDefaults.SA_HOST, TakDefaults.SA_PORT), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) if (settings.takEnabled && settings.demoMode) { Text( - "Privacy mode is on — the feed is paused so full MACs and coordinates are not sent. Turn Privacy mode off to publish.", + appText(R.string.settings_screen_privacy_mode_is_on_the_feed_is), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, ) @@ -482,22 +462,22 @@ fun SettingsScreen( } } - SectionCard("Logging") { + SectionCard(appText(R.string.settings_screen_logging)) { Row(verticalAlignment = Alignment.CenterVertically) { - Text("Write detections to disk", Modifier.weight(1f)) + Text(appText(R.string.settings_screen_write_detections_to_disk), Modifier.weight(1f)) FieldwatchSwitch(settings.loggingEnabled, { on -> vm.updateSettings { it.copy(loggingEnabled = on) } }) } StableCaption( if (settings.loggingEnabled) { - "Logging is on. New detections are appended to the rotating file." + appText(R.string.settings_screen_logging_is_on_new_detections_are_appended) } else { - "Logging is off. Scanning still runs; nothing new is written until you turn this back on." + appText(R.string.settings_screen_logging_is_off_scanning_still_runs_nothing) }, - "Logging is on. New detections are appended to the rotating file.", - "Logging is off. Scanning still runs; nothing new is written until you turn this back on.", + appText(R.string.settings_screen_logging_is_on_new_detections_are_appended), + appText(R.string.settings_screen_logging_is_off_scanning_still_runs_nothing), ) Text( - "The rotating file is JSON lines (one hear per line). Reports → Log → Format writes CSV, JSON lines, GPX, KML, or WiGLE when you Share or Save.", + appText(R.string.settings_screen_the_rotating_file_is_json_lines_one), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -506,7 +486,7 @@ fun SettingsScreen( LaunchedEffect(settings.logRotateKb) { if (!rotateDragging) rotateDrag = settings.logRotateKb } - Text("Rotate at $rotateDrag KB") + Text(appText(R.string.settings_screen_rotate_at_kb, rotateDrag)) FieldwatchSlider( value = rotateDrag.toFloat(), onValueChange = { @@ -524,7 +504,7 @@ fun SettingsScreen( LaunchedEffect(settings.staleSec) { if (!staleDragging) staleDrag = settings.staleSec } - Text("Stale after ${staleDrag}s") + Text(appText(R.string.settings_screen_stale_after_s, staleDrag)) FieldwatchSlider( value = staleDrag.toFloat(), onValueChange = { @@ -539,89 +519,84 @@ fun SettingsScreen( ) StickyHeight("log-stats") { Text( - "${state.logLines} lines this session · ${vm.logBytes() / 1024} KB on disk. " + - "Share, Save, and Reset / clear log are on the Reports tab.", + appText(R.string.settings_screen_lines_this_session_kb_on_disk_share, state.logLines, vm.logBytes() / 1024), style = MaterialTheme.typography.bodySmall, ) } } - SectionCard("Signatures") { + SectionCard(appText(R.string.settings_screen_signatures)) { Text( - "Export the catalog (stock plus any you added or edited) to share with another Fieldwatch or as a backup. Import adds new rows and extra rules; it does not delete anything. Same id or the same match rules are skipped so a pack can be imported twice. Update stock catalog from GitHub replaces stock rows (including Extra attention) from the v2 pack on the repo; bookmarks, Settings, and signatures you added stay. Needs internet. Offline: Import signatures from a file. Restore defaults below still wipes customs.", + appText(R.string.settings_screen_export_the_catalog_stock_plus_any_you), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) FieldwatchActionButton( onClick = vm::startSignatureShare, modifier = Modifier.fillMaxWidth(), - ) { Text("Export signatures") } + ) { Text(appText(R.string.settings_screen_export_signatures)) } FieldwatchActionButton( onClick = { saveSignatures.launch(vm.suggestedSignaturesName()) }, modifier = Modifier.fillMaxWidth(), - ) { Text("Save signatures to SD card / storage…") } + ) { Text(appText(R.string.settings_screen_save_signatures_to_sd_card_storage)) } FieldwatchActionButton( onClick = { importSignatures.launch(arrayOf("application/json", "text/plain", "*/*")) }, modifier = Modifier.fillMaxWidth(), - ) { Text("Import signatures…") } + ) { Text(appText(R.string.settings_screen_import_signatures)) } FieldwatchActionButton( onClick = vm::updateStockCatalogFromGitHub, modifier = Modifier.fillMaxWidth(), - ) { Text("Update stock catalog from GitHub") } + ) { Text(appText(R.string.settings_screen_update_stock_catalog_from_github)) } FieldwatchActionButton( onClick = { confirmRestore = true }, modifier = Modifier.fillMaxWidth(), ) { - Text("Restore default signatures & presets") + Text(appText(R.string.settings_screen_restore_default_signatures_presets)) } } - SectionCard("Settings backup") { + SectionCard(appText(R.string.settings_screen_settings_backup)) { Text( - "Settings switches, the current filter, filter presets, named radios, and signature watches. " + - "Not the catalog — that is Export signatures. Not logs or GPS. " + - "Import replaces those on this phone; the catalog stays. " + - "Use this after a factory reset or on a new phone.", + appText(R.string.settings_screen_settings_switches_the_current_filter_filter_presets), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) FieldwatchActionButton( onClick = vm::startSettingsShare, modifier = Modifier.fillMaxWidth(), - ) { Text("Export settings") } + ) { Text(appText(R.string.settings_screen_export_settings)) } FieldwatchActionButton( onClick = { saveSettings.launch(vm.suggestedSettingsName()) }, modifier = Modifier.fillMaxWidth(), - ) { Text("Save settings to SD card / storage…") } + ) { Text(appText(R.string.settings_screen_save_settings_to_sd_card_storage)) } FieldwatchActionButton( onClick = { importSettings.launch(arrayOf("application/json", "text/plain", "*/*")) }, modifier = Modifier.fillMaxWidth(), - ) { Text("Import settings…") } + ) { Text(appText(R.string.settings_screen_import_settings)) } } FieldwatchActionButton( onClick = onShowLiveTour, modifier = Modifier.fillMaxWidth(), - ) { Text("Show Live tour") } + ) { Text(appText(R.string.settings_screen_show_live_tour)) } Text( - "Chrome overlay on Live: Tune is Display (Radar, list, By class), Pause, Filters, Signatures, Reports, Settings. First-run after the license; this button shows it again.", + appText(R.string.settings_screen_chrome_overlay_on_live_tune_is_display), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - "Fieldwatch ${app.fieldwatch.BuildConfig.VERSION_NAME} · Catalog ${state.catalogVersion}", + appText(R.string.settings_screen_fieldwatch_catalog, app.fieldwatch.BuildConfig.VERSION_NAME, state.catalogVersion), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - "Passive Wi-Fi + BLE only. " + - "Stock Android cannot promiscuously capture Wi-Fi stations; access points and BLE advertisers are what the radios expose.", + appText(R.string.settings_screen_passive_wi_fi_ble_only_stock_android), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -636,9 +611,9 @@ fun SettingsScreen( } Text( if (ipv4.isEmpty()) { - "This phone’s IPv4 · none" + appText(R.string.settings_screen_this_phone_s_ipv4_none) } else { - "This phone’s IPv4 · ${ipv4.joinToString(" · ")}" + appText(R.string.settings_screen_this_phone_s_ipv4, ipv4.joinToString(" · ")) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -651,13 +626,10 @@ fun SettingsScreen( if (confirmRestore) { AlertDialog( onDismissRequest = { confirmRestore = false }, - title = { Text("Restore defaults?") }, + title = { Text(appText(R.string.settings_screen_restore_defaults)) }, text = { Text( - "Rewrites the catalog (stock rows, class colors, Decode fields), stock bookmarks, " + - "stock filter chips, and default Settings switches. Custom signatures and chips you " + - "saved are wiped. Export signatures and Export settings first if you want a backup. " + - "This cannot be undone.", + appText(R.string.settings_screen_rewrites_the_catalog_stock_rows_class_colors), ) }, confirmButton = { @@ -666,10 +638,10 @@ fun SettingsScreen( confirmRestore = false vm.restoreDefaults() }, - ) { Text("Restore") } + ) { Text(appText(R.string.settings_screen_restore)) } }, dismissButton = { - TextButton(onClick = { confirmRestore = false }) { Text("Cancel") } + TextButton(onClick = { confirmRestore = false }) { Text(appText(R.string.settings_screen_cancel)) } }, ) } @@ -748,7 +720,7 @@ private fun TakFeedSettings(settings: AppSettings, vm: FieldwatchViewModel, stat LaunchedEffect(settings.takHost) { hostText = settings.takHost } LaunchedEffect(settings.takPort) { portText = settings.takPort.toString() } val preset = TakPublish.udpPreset(settings.takHost, settings.takPort) - Text("Destination", style = MaterialTheme.typography.labelLarge) + Text(appText(R.string.settings_screen_destination), style = MaterialTheme.typography.labelLarge) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FieldwatchFilterChip( selected = preset == TakUdpPreset.THIS_PHONE, @@ -757,7 +729,7 @@ private fun TakFeedSettings(settings: AppSettings, vm: FieldwatchViewModel, stat vm.updateSettings { it.copy(takHost = host, takPort = port) } }, enabled = !settings.demoMode, - label = { Text("This phone") }, + label = { Text(appText(R.string.settings_screen_this_phone)) }, ) FieldwatchFilterChip( selected = preset == TakUdpPreset.LAN_MULTICAST, @@ -766,7 +738,7 @@ private fun TakFeedSettings(settings: AppSettings, vm: FieldwatchViewModel, stat vm.updateSettings { it.copy(takHost = host, takPort = port) } }, enabled = !settings.demoMode, - label = { Text("LAN multicast") }, + label = { Text(appText(R.string.settings_screen_lan_multicast)) }, ) FieldwatchFilterChip( selected = preset == TakUdpPreset.CUSTOM, @@ -777,14 +749,11 @@ private fun TakFeedSettings(settings: AppSettings, vm: FieldwatchViewModel, stat } }, enabled = !settings.demoMode, - label = { Text("Custom") }, + label = { Text(appText(R.string.settings_screen_custom)) }, ) } Text( - "This phone: ${TakDefaults.LOOPBACK}:${TakDefaults.PORT} (ATAK CIV on this handset). " + - "LAN multicast: ${TakDefaults.SA_HOST}:${TakDefaults.SA_PORT} (other ATAKs on this Wi-Fi). " + - "Custom: type a unicast IPv4 or hostname. UDP only. A TAK server’s TCP 8087 is not this feed. " + - "If This phone does not plot, use Custom with this phone’s Wi-Fi IPv4 from the footer and port ${TakDefaults.PORT}.", + appText(R.string.settings_screen_this_phone_atak_civ_on_this_handset, TakDefaults.LOOPBACK, TakDefaults.PORT, TakDefaults.SA_HOST, TakDefaults.SA_PORT, TakDefaults.PORT), style = MaterialTheme.typography.bodySmall, color = muted, ) @@ -797,7 +766,7 @@ private fun TakFeedSettings(settings: AppSettings, vm: FieldwatchViewModel, stat vm.updateSettings { it.copy(takHost = trimmed) } } }, - label = "Host", + label = appText(R.string.settings_screen_host), placeholder = TakDefaults.HOST, enabled = !settings.demoMode, ) @@ -812,74 +781,68 @@ private fun TakFeedSettings(settings: AppSettings, vm: FieldwatchViewModel, stat } } }, - label = "Port", + label = appText(R.string.settings_screen_port), placeholder = TakDefaults.PORT.toString(), - supportingText = "UDP. ATAK CIV ${TakDefaults.PORT}. SA multicast ${TakDefaults.SA_PORT}. Not TCP 8087.", + supportingText = appText(R.string.settings_screen_udp_atak_civ_sa_multicast_not_tcp, TakDefaults.PORT, TakDefaults.SA_PORT), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), enabled = !settings.demoMode, ) Text(takStatusLine(status), style = MaterialTheme.typography.bodySmall, color = muted) - Text("What to send", style = MaterialTheme.typography.labelLarge) + Text(appText(R.string.settings_screen_what_to_send), style = MaterialTheme.typography.labelLarge) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FieldwatchFilterChip( selected = settings.takAttention, onClick = { vm.updateSettings { it.copy(takAttention = !it.takAttention) } }, enabled = !settings.demoMode, - label = { Text("Extra attention") }, + label = { Text(appText(R.string.settings_screen_extra_attention)) }, ) FieldwatchFilterChip( selected = settings.takPayloadFix, onClick = { vm.updateSettings { it.copy(takPayloadFix = !it.takPayloadFix) } }, enabled = !settings.demoMode, - label = { Text("Payload location") }, + label = { Text(appText(R.string.settings_screen_payload_location)) }, ) FieldwatchFilterChip( selected = settings.takWatchlist, onClick = { vm.updateSettings { it.copy(takWatchlist = !it.takWatchlist) } }, enabled = !settings.demoMode, - label = { Text("Watchlist") }, + label = { Text(appText(R.string.settings_screen_watchlist)) }, ) FieldwatchFilterChip( selected = settings.takAllSignatures, onClick = { vm.updateSettings { it.copy(takAllSignatures = !it.takAllSignatures) } }, enabled = !settings.demoMode, - label = { Text("All signatures") }, + label = { Text(appText(R.string.settings_screen_all_signatures)) }, ) } Text( - "Independent chips. Extra attention (on): body-cam, glasses, recording wearables, pentest, public-safety APs. " + - "Payload location (on): advertised lat/lon from a decode map — required for stock Remote ID, which has no Extra attention mark. " + - "Watchlist (off): bookmarked signatures and named radios with Alert on. " + - "All signatures (off): every labeled radio — noisy in a plaza. Unmatched radios never go. " + - "A pin still needs coordinates: advertised payload, or GPS tagging with a live fix. " + - "Heard-here holds the loudest hear, not the last, and callsigns end in (here). " + - "Remote ID keeps one aircraft marker (UAS ID) plus a pilot pin when that location decoded.", + appText(R.string.settings_screen_independent_chips_extra_attention_on_body_cam), style = MaterialTheme.typography.bodySmall, color = muted, ) } private fun takStatusLine(status: TakFeedStatus): String { - if (status.paused) return "Feed status · paused (Privacy mode)" + if (status.paused) return appText(R.string.settings_screen_feed_status_paused_privacy_mode) if (status.error != null) { val whenAt = takStatusWhen(status.at) - return "Feed status · error: ${status.error}" + if (whenAt.isNotEmpty()) " · $whenAt" else "" + return appText(R.string.settings_screen_feed_status_error, takDetail(status.error)) + if (whenAt.isNotEmpty()) " · $whenAt" else "" } if (status.at <= 0L) { - return "Feed status · no send yet this session" + return appText(R.string.settings_screen_feed_status_no_send_yet_this_session) } val bits = ArrayList(5) - bits += "on the feed ${status.onFeed}" - bits += "sent ${status.sent}" + bits += appText(R.string.settings_screen_on_the_feed, status.onFeed) + bits += appText(R.string.settings_screen_sent, status.sent) if (status.gone > 0) { - bits += if (status.gone == 1) "1 gone" else "${status.gone} gone" + bits += if (status.gone == 1) appText(R.string.settings_screen_1_gone) else appText(R.string.settings_screen_gone, status.gone) } if (status.dest.isNotBlank()) bits += status.dest val whenAt = takStatusWhen(status.at) if (whenAt.isNotEmpty()) bits += whenAt - val head = "Feed status · ${bits.joinToString(" · ")}" + val head = appText(R.string.settings_screen_feed_status, bits.joinToString(" · ")) return if (status.detail.isNotBlank() && status.sent == 0 && status.gone == 0) { - "$head · ${status.detail}" + "$head · ${takDetail(status.detail)}" } else { head } diff --git a/app/src/main/res/values-b+zh+Hans/plurals.xml b/app/src/main/res/values-b+zh+Hans/plurals.xml new file mode 100644 index 0000000..3cb3995 --- /dev/null +++ b/app/src/main/res/values-b+zh+Hans/plurals.xml @@ -0,0 +1,6 @@ + + + %1$d 个特征组 + %1$d 个广播定位点 + 此处有 %1$d 条警报 + diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml new file mode 100644 index 0000000..e9d0a63 --- /dev/null +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -0,0 +1,2905 @@ + + + "语言" + "跟随系统" + "确定" + "识别特征" + "设置" + "识别特征" + "设置" + "取消" + "额外关注" + "取消" + "取消" + "额外关注" + "取消" + "取消" + "取消" + "设置" + "外观" + "夜间模式" + "默认关闭。采用黑底红色的户外监测界面,避免按钮、文字和信号标记在暗处发出绿色或蓝色光。背景保持深色,不改变手机亮度。" + "保持屏幕常亮" + "默认开启。Fieldwatch 打开时保持屏幕常亮,避免熄屏后 BLE 扫描受限。离开应用后,扫描仍通过通知在后台运行。把手机放入口袋时可关闭此项。" + "隐私模式" + "在实时、雷达、时间线、详情、寻踪、命名信号源和关注列表中,将 MAC 地址后三段隐藏为 **:**:**,避免屏幕和监测报告显示完整地址。最近 GPS 定位及监测总结、AI 导出和详情分享中的坐标将显示为“已隐藏”,监测报告也会省略街道名称。前三段(OUI/厂商前缀)保留。默认关闭。开启“在线地名与地图”时,报告 → 路径仍会加载地图。日志、匹配、筛选、寻踪计算、随行检测和保存的识别特征继续使用真实 MAC 和 GPS。已开启的 TAK/CoT 推送会暂停,避免向局域网发送完整地址和坐标。需要在屏幕上查看完整地址或坐标时请关闭。" + "扫描" + "省电" + "均衡" + "高性能" + "扫描强度 · %1$s" + "Wi-Fi 按批次扫描:手机一次获取全部接入点,然后等待。高性能模式约每 30 秒请求一次,符合系统每两分钟最多四次扫描的限制。等待期间 BLE 仍持续接收广播。" + "Wi-Fi 等待系统" + "Wi-Fi 扫描中" + "Wi-Fi 下次扫描:99 秒" + "加快 Wi-Fi 接入点扫描" + "需要 Android 11 或更新版本才能读取系统扫描限流状态。此手机无法确认该状态,因此开关保持关闭。" + "已开启。约每 8 秒请求新的接入点列表,会增加耗电和发热。如果系统开始拒绝扫描,应用会降低频率。" + "已保存为开启,但尚未生效:Android 的 Wi-Fi 扫描限流仍开启。请在开发者选项中关闭限流后返回。" + "原生 Android 每两分钟约允许四次接入点扫描。只有在开发者选项中关闭“Wi-Fi 扫描限制”后,才能加快扫描。Fieldwatch 会先检查该系统开关,无法替你修改它。" + "需要开发者选项" + "此手机系统低于 Android 11,Fieldwatch 无法读取 Wi-Fi 扫描限流开关,因此加速扫描保持关闭。" + "Android 仍在限制 Wi-Fi 扫描(约每两分钟四次)。关闭系统限流前,无法开启加速扫描。\n\n请在“关于手机”中连续点击版本号七次以启用开发者选项,再打开 设置 → 开发者选项 → Wi-Fi 扫描限制 → 关闭。然后返回此处重新开启。" + "打开开发者选项" + "确定" + "暂不" + "允许后台运行" + "显示 Android“允许后台运行”的状态。点击打开 Fieldwatch 的电池设置并修改,返回后自动更新。关闭时,离开应用后系统可能立即终止扫描。此项与屏幕常亮不同。" + "电池使用不受限制" + "显示 Android“不受限制”的电池使用状态。部分手机(包括三星)不会直接显示此选项。如果只看到“允许后台运行”,请点击该行进入并选择“不受限制”。返回后自动更新。" + "接下来打开 Fieldwatch 的电池设置,请修改“允许后台运行”开关。返回后应用会同步该状态。" + "部分手机(包括三星)不会直接显示“不受限制/优化/限制”。如果只看到“允许后台运行”,请点击该行文字(不是蓝色开关)进入,然后选择“不受限制”。返回后同步状态。" + "打开 Android 设置" + "关注列表" + "关注列表告警" + "默认开启。控制已关注的识别特征和设备的全部告警。关闭后不再蜂鸣、振动、闪烁、跳转或发送通知,但仍可添加关注;信号源出现时不会提醒。" + "命名信号源(%1$s)" + "为单个 MAC 地址设置自定义名称,可选择是否告警。“筛选 → 仅命名信号源”可只在实时页面显示这些设备。识别特征的关注仍在“识别特征”页面管理。" + "关注目标蜂鸣提醒" + "已关注的识别特征或设备首次出现或离开后再次出现时,按媒体音量发出两声提示音。持续存在的设备不会重复蜂鸣。可独立开启蜂鸣和语音。听不到时请调高媒体音量,再点击“测试告警”。" + "关注目标语音提醒" + "默认开启,使用媒体音量。与蜂鸣独立:同时开启时先蜂鸣后播报;关闭蜂鸣时只播报。不用于寻踪。正在播报时跳过第二个命中。手机没有文字转语音功能时,若蜂鸣已开启仍会发出提示音。" + "播报内容" + "关注识别特征时:“设备类别”对应实时页面的图标分类(定位标签、音频等);“识别特征”对应目录条目(Apple AirTags、Axon 等)。“类别+识别特征”默认播报两者。命名信号源开启告警后始终播报自定义名称,即使没有类别。“测试告警”使用当前播报设置。" + "测试告警" + "跳转到新出现的关注目标" + "关注的识别特征或设备出现时,实时列表滚动到对应条目以显示闪烁。可配合蜂鸣或语音使用。按信号强度排序时,弱信号通常在列表底部。不希望列表自动移动时可关闭。" + "系统通知" + "可选。关注信号源出现时发送静默通知。默认关闭;蜂鸣和闪烁通常足够,关闭通知可减少扫描处理负担。" + "位置" + "用 GPS 标记检测记录" + "默认开启。请求实时 GPS/网络定位,为每次接收标记位置(用于详情、随行检测、监测总结及新日志行)。超过 30 秒的最近定位会被忽略。这是手机接收信号时的位置,并非对方设备的独立定位。请使用高精度定位,否则路径可能保持为 0。不希望日志记录操作者坐标时可关闭。TAK 的接收点标记也依赖此项;广播载荷中的坐标(Remote ID)不依赖。" + "在线地名与地图" + "默认开启。有网络时,监测总结/AI 导出将 GPS 坐标转换为街道和城市,“报告 → 路径”加载 OpenStreetMap 地图。无 Fieldwatch 云服务,也无需 API 密钥。离线或无地理编码服务时,报告只显示坐标,路径仅显示北向上的轨迹,不弹出错误。关闭可避免加载地名和地图。监测总结、会话导出、日志导出和清空日志在“报告”页面。" + "TAK/CoT 推送" + "默认关闭。通过 UDP 向 ATAK、WinTAK 或 iTAK 发送 Cursor-on-Target 标记。“本机”(%1$s:%2$s)指此手机上的 ATAK CIV,局域网组播为 %3$s:%4$s;自定义目标可填写单播 IPv4 或主机名。仅支持 UDP,与 TAK 服务器的 TCP 8087 不同。接收点标记位于信号最强时手机的 GPS 位置,并注明“(here)”;走远不会拖动标记,更强的接收会更新位置。约每 10 秒刷新同一坐标以保持标记。广播的经纬度(内置 Remote ID)位于飞行器处,同一 UAS ID 保持一个移动标记,不依赖轮换的 BLE MAC。解码出的操作者位置另作标记。消失的信号源在 ATAK 中立即移除,不等待 120 秒。点击 ATAK 标记可查看名称、MAC、RSSI 和识别特征。此功能不是测向,也不是 Remote ID 插件;隐私模式会暂停推送。" + "隐私模式已开启,推送已暂停,避免发送完整 MAC 和坐标。关闭隐私模式后可恢复推送。" + "日志" + "将检测记录写入磁盘" + "日志已开启,新检测记录追加到轮转文件。" + "日志已关闭,扫描继续运行;重新开启前不写入新记录。" + "轮转文件采用 JSON Lines 格式,每次接收一行。“报告 → 日志 → 格式”可在分享或保存时输出 CSV、JSON Lines、GPX、KML 或 WiGLE。" + "达到 %1$s KB 时轮转" + "%1$s 秒后视为过期" + "本次会话 %1$s 行 · 磁盘占用 %2$s KB。分享、保存和清空日志在“报告”页面。" + "识别特征" + "导出目录(含内置及新增或修改的条目)以分享或备份。导入只添加新条目和规则,不删除内容;相同 ID 或匹配规则会跳过,可重复导入。从 GitHub 更新内置目录会用仓库中的 v2 数据包替换内置条目(含“额外关注”),保留关注、设置和自定义识别特征。此操作需要网络;离线可从文件导入。下方“恢复默认”仍会清除自定义内容。" + "导出识别特征" + "保存识别特征到 SD 卡/存储…" + "导入识别特征…" + "从 GitHub 更新内置目录" + "恢复默认识别特征和预设" + "设置备份" + "备份设置开关、当前筛选、筛选预设、命名信号源和关注的识别特征。不包括目录(请使用“导出识别特征”)、日志和 GPS。导入会替换此手机的上述设置,保留目录。适用于恢复出厂设置后或迁移到新手机。" + "导出设置" + "保存设置到 SD 卡/存储…" + "导入设置…" + "显示实时页面引导" + "介绍实时页面的显示方式(雷达、列表、按类别)、暂停、筛选、识别特征、报告和设置。首次接受许可后显示;此按钮可再次打开引导。" + "Fieldwatch %1$s · 目录 %2$s" + "仅被动接收 Wi-Fi 和 BLE。原生 Android 无法以混杂模式捕获 Wi-Fi 客户端,只能显示系统提供的接入点和 BLE 广播设备。" + "本机 IPv4 · 无" + "本机 IPv4 · %1$s" + "恢复默认设置?" + "重置目录(内置条目、类别颜色、解码字段)、内置关注、筛选预设和设置开关。保存的自定义识别特征和筛选项将被清除。需要备份时,请先导出识别特征和设置。此操作无法撤销。" + "恢复" + "取消" + "目标" + "本机" + "局域网组播" + "自定义" + "本机:%1$s:%2$s(此手机上的 ATAK CIV)。局域网组播:%3$s:%4$s(同一 Wi-Fi 下的其他 ATAK)。自定义:填写单播 IPv4 或主机名。仅支持 UDP,不是 TAK 服务器的 TCP 8087。如果“本机”未显示标记,请选择“自定义”,填写页脚的本机 Wi-Fi IPv4 和端口 %5$s。" + "主机" + "端口" + "UDP:ATAK CIV 使用 %1$s,SA 组播使用 %2$s。不是 TCP 8087。" + "发送内容" + "额外关注" + "载荷位置" + "全部识别特征" + "各项独立。“额外关注”(开启):执法记录仪、眼镜、录音穿戴设备、渗透测试设备和公共安全接入点。“载荷位置”(开启):解码映射中的广播经纬度,内置 Remote ID 需要此项,因其没有“额外关注”标记。“关注列表”(关闭):已关注的识别特征及开启告警的命名信号源。“全部识别特征”(关闭):所有已识别信号源,在广场等场所可能过多。未匹配设备不会发送。标记仍需广播坐标或 GPS 实时定位。接收点保留最强信号时的位置,呼号以“(here)”结尾。Remote ID 按 UAS ID 保持一个飞行器标记,解码到操作者位置时另加标记。" + "推送状态 · 已暂停(隐私模式)" + "推送状态 · 错误:%1$s" + "推送状态 · 本次会话尚未发送" + "当前推送 %1$s" + "已发送 %1$s" + "已消失 1 个" + "已消失 %1$s 个" + "推送状态 · %1$s" + "未匹配" + "Wi-Fi 接入点" + "BLE 广播设备" + "解码字段" + "开始" + "现在" + "结束" + "免责声明与许可" + "免责声明" + "我已阅读并同意" + "继续" + "Fieldwatch 需要无线扫描权限" + "位置、附近 Wi-Fi、蓝牙扫描和通知权限用于被动监测广播中的网络与 BLE 设备。应用不会通过扫描发送数据。" + "授予权限" + "会话对比 PDF" + "会话对比 AI 导出" + "会话对比" + "AI 导出" + "监测总结 PDF" + "监测总结" + "导出" + "请稍候…" + "完成前暂停实时日志写入,扫描继续运行。" + "无法导出" + "日志已保存" + "文件已写入所选文件夹。若希望保存到内部存储以外的位置,可在系统选择器的菜单中选择 SD 卡。" + "日志已清空" + "已删除轮转文件,新的检测记录将写入新日志。" + "FIELDWATCH · 监测会话 · 已暂停" + "FIELDWATCH · 监测会话" + "FIELDWATCH · 已暂停" + "收起扫描选项" + "展开扫描选项" + "暂停显示" + "实时" + "暂停" + "筛选" + "报告" + "详情" + "返回" + "未选择信号源。" + "最强信号 · %1$s 秒平均" + "最强信号" + "最近接收" + "最近告警" + "最近出现" + "新记录置底" + "按名称排序" + "优先显示已识别设备" + "关闭" + "保留 %1$s 秒" + "显示" + "视图" + "排序" + "最强信号" + "最强信号(30 秒平均)" + "短暂保留" + "关闭,仅使用过期时间" + "最后一个数据包后保留 %1$s 秒" + "标题行" + "副标题行" + "RSSI 信号条" + "识别特征名称" + "频率" + "首次/最近发现" + "更多显示选项见下方" + "广播名称" + "名称+类型" + "MAC 地址" + "无" + "标记为已见" + "重置已见记录" + "重新开始" + "会话对比" + "会话对比 PDF" + "会话对比 AI 导出" + "监测总结 PDF" + "监测总结" + "AI 导出" + "日志已清空" + "显示设置" + "知道了" + "暂停" + "冻结当前画面。再次点击“实时”恢复。" + "筛选" + "选择显示哪些设备。" + "识别特征目录。" + "报告" + "监测总结、会话和日志。" + "扫描、GPS 和 TAK。" + "显示方式:雷达、列表、时间线、混合或按类别。" + "返回" + "解码字段" + "返回" + "保存" + "服务数据" + "服务 UUID" + "返回" + "广播名称" + "名称" + "身份信息" + "Wi-Fi 接入点" + "AI 导出" + "备注" + "筛选" + "保存" + "删除" + "识别特征(%1$s)" + "新建识别特征" + "点击类别展开识别特征。添加关注可启用蜂鸣;请在“筛选”中隐藏特征组。" + "点击编辑。添加关注后,该特征组出现时会蜂鸣;请在“筛选”中隐藏。" + "按名称排序" + "按类别排序" + "%1$s · %2$s 条规则 · 实时 %3$s 个 · %4$s" + "此识别特征出现时蜂鸣" + "编辑识别特征" + "保存" + "身份信息" + "名称" + "备注" + "在匹配信号源的详情、分享和 AI 导出中显示。与“额外关注”不同,不显示实时感叹号或橙色卡片。" + "可选。填写后,匹配信号源将在实时页面显示“!”,详情显示橙色卡片,监测总结添加一行。与上方备注独立。" + "匹配" + "设备类别" + "在“筛选 → 仅显示/隐藏这些”中按类别选择(定位标签、摄像头等)。需要预设时可使用“将当前筛选另存为…”。" + "匹配任一规则(OR)" + "按 OUI 分组" + "连续 MAC 地址" + "最少同组设备数(0 为关闭)" + "同组时间窗口(秒)" + "颜色" + "内置颜色按类别设置:红色为渗透测试,琥珀色为摄像头/车牌识别,紫色为手机/标签,青色为穿戴设备,绿色为 Mesh,橙色为音频/眼镜,蓝绿色为车载设备。可修改任意条目。" + "规则" + "每条规则有独立开关。关闭后保留规则但不参与匹配,可暂时禁用容易误报的 OUI 或名称而无需删除。" + "添加规则" + "解码字段" + "无" + "%1$s 个字段" + "可选。匹配此识别特征后,将明文 BLE 字节映射为标签。加密载荷保持十六进制显示。" + "删除识别特征" + "删除此识别特征?" + "“%1$s”是内置识别特征。删除会移除匹配、关注和筛选项。可在“设置”中恢复默认识别特征。" + "将删除“%1$s”及其匹配、关注和筛选项。此操作无法撤销。" + "删除" + "已选颜色" + "类型" + "删除规则" + "值" + "公司 ID(十六进制)" + "数据前缀(十六进制)" + "服务 UUID(留空为任意,按包含匹配)" + "包含十六进制" + "匹配隐藏 SSID" + "MAC 前缀" + "名称包含" + "名称通配模式" + "服务 UUID" + "服务数据" + "制造商 ID" + "制造商数据" + "无线类型" + "隐藏 SSID" + "厂商 IE OUI" + "返回" + "继续" + "名称" + "保存" + "返回" + "保存" + "报告" + "删除" + "AI 导出" + "名称" + "开始" + "保存" + "寻踪" + "来源" + "匹配此识别特征后映射明文 BLE 字节。加密广播保持十六进制。" + "制造商" + "公司 ID" + "可选。字节 0 指公司 ID 之后的第一个字节。留空表示任意记录。" + "必填。可使用 16 位(如 FEAA)或完整 UUID。" + "字段" + "字段 %1$s" + "添加字段" + "移除解码映射" + "移除全部字段和实时页面的解码标记。添加字段后保存仍会保留映射。" + "预览" + "移除解码映射?" + "清除此识别特征的全部字段,实时页面不再显示解码标记,保留原始广播。只能重新添加字段,无法撤销。" + "移除" + "当前没有匹配的信号源。仍可保存,接收到匹配设备后详情会自动填充。" + "已接收到匹配信号源,但本次广播没有上述来源的字节数据。" + "未解析出数据。请检查偏移、长度,并确认字节 0 位于公司 ID 之后。" + "标签" + "删除字段" + "偏移" + "长度" + "单位" + "位偏移" + "位宽" + "缩放系数" + "添加" + "实时条目" + "在识别特征名称旁显示此值。重要值使用更醒目的标签。" + "更多" + "取模" + "收起附加选项" + "仅当…" + "仅当" + "字节" + "十六进制" + "命名值…" + "命名值" + "原始值" + "显示为" + "删除值" + "醒目" + "说明" + "添加值" + "等于" + "不等于" + "位掩码匹配" + "指定各位均未设置" + "长度" + "条件" + "载荷长度" + "类型" + "字节序" + "小端" + "大端" + "关注" + "当前未接收到信号,显示的是最后一次接收到的详情。" + "自定义名称" + "广播名称" + "无广播名称" + "收起自定义名称" + "已保存为 %1$s" + "已保存" + "保存名称" + "观察备注已清空" + "观察备注已保存" + "我的设备" + "开启后,此信号源仍在列表中,但不会蜂鸣告警。" + "已标记为你的设备。开启时不蜂鸣,但仍显示在列表中。" + "信号源" + "Wi-Fi 接入点(广播网络信标)" + "蓝牙低功耗广播设备" + "地址" + "制造商" + "OUI(厂商前缀)" + "%1$s — 无 IEEE 匹配;随机地址通常没有厂商记录" + "网络名称(SSID)" + "已隐藏:接入点正在广播信标,但未公开名称" + "信号" + "此处信号强度(RSSI)" + "不可用" + "最近接收" + ",强度 %1$s dBm" + "越接近 0 dBm,表示此处信号越强,并不代表距离。" + "本次会话接收强度范围" + "设备声明的发射功率" + "%1$s dBm — 设备声明的发射功率,不代表距离" + "信道/频率" + "信道 %1$s" + " · 带宽 %1$s" + "Wi-Fi 代际" + "中心频率" + "蓝牙广播" + "物理层(PHY)" + "可连接" + "是:手机可以建立 BLE 连接" + "否:仅广播(本次扫描可以接收,无法连接)" + "广播频率" + "广播间隔 %.0f 毫秒(越小,广播越频繁)" + "周期广播" + "可发现性" + "标志位(原始值)" + "设备声明的外观类型(Appearance)" + "%1$s\n设备通过 GAP Appearance 代码描述自身类型。" + "未收录的外观代码 0x%04X" + "外观代码" + "经典蓝牙设备类别" + "\n这是经典蓝牙使用的 Class of Device 位字段。" + "\n还提供:" + "加密/登录" + "安全信息字符串" + "支持的速率" + "%1$s Mbps(* 表示必需的基本速率)" + "能力字符串" + "提供的服务" + "服务 ID" + "(空)" + "飞行器" + "已解码字段" + "解码字段未适用于本次广播,可能是载荷过短、公司 ID 或布局不同。Govee 灯具通常只广播名称;温湿度计型号包括 H5074/H5075/H510x。原始字节见下方。" + "解码字段未适用于本次广播,可能是载荷过短、公司 ID 或布局不同。原始字节见下方。" + "此识别特征有解码映射,但本次广播没有可解析的制造商或服务载荷。许多 Govee 灯具只广播名称。" + "此识别特征有解码映射,但本次广播没有可解析的制造商或服务载荷。" + "广播中的制造商数据" + "未收录于蓝牙公司列表" + "蓝牙公司 0x%04X" + "%1$s\n此 ID 由 Bluetooth SIG 分配,位于制造商专用数据中。" + "原始载荷(%1$s 字节)" + "Wi-Fi 厂商标签" + " 类型 %d" + "厂商 OUI %1$s%2$s" + "未知 IEEE OUI" + " — 接入点的附加信息元素,并非 SSID。" + "会话" + "首次发现" + "最近发现" + "命中次数" + "最近定位" + "匹配的识别特征" + "原始广播" + "信号趋势" + "出现记录(15 分钟)" + "寻踪" + "寻踪仅支持 BLE。原生 Android 的 Wi-Fi 接入点更新过慢,不适合边走边寻找。" + "从设备创建识别特征" + "以文本分享" + "生成可粘贴到聊天中的提示词:解码此信号源,查询 OUI/公司/UUID,并说明最可能的设备类型。包含与“设置 → AI 导出”相同的实验性免责声明。仅针对一个设备,不是身份确认。" + "识别特征组" + "观察备注" + "收起观察备注" + "无观察备注" + "保存备注" + "仅为特征匹配,不代表身份确认,也不构成安全结论。" + "可能的设备类型" + "IEEE 板卡/芯片厂商:%1$s(%2$s)。这是 MAC 前缀的所有者,不一定是产品品牌。" + "广播中的蓝牙公司:%1$s(0x%%04X)。" + "服务数据 %1$s%2$s%3$s" + "寻踪" + "重置本次寻踪" + "返回详情" + "蜂鸣" + "振动" + "无实时 RSSI" + "本次寻踪最强信号 %1$s dBm" + "本次寻踪最强信号 —" + "最近接收 —" + "%1$s 秒前接收" + "%1$s 分钟前接收" + "你" + "你" + "我的设备" + "观察备注" + "命名信号源(%1$s)" + "每个条目对应一个 MAC。自定义名称显示在实时页面。“我的设备”表示归你所有,开启时不蜂鸣但仍显示。观察备注显示在详情和报告中。可选告警(蜂鸣/语音/闪烁)。“筛选 → 仅命名信号源”隐藏其他设备。识别特征的关注仍在“识别特征”页面管理。" + "暂无命名信号源。可在详情中设置自定义名称,或点击右上角添加关注。" + "清空全部 %1$s 个命名信号源" + "清空命名信号源?" + "移除 %1$s 个命名信号源。保留已关注的识别特征。" + "清空" + "命名信号源" + "自定义名称" + "观察备注" + "编辑" + "移除" + "正在广播,点击打开详情" + "本次会话未发现" + "我的设备" + "告警" + "预设" + "点击可替换整个筛选,长按可删除预设。应用内置少量预设,可用“将当前筛选另存为…”添加自己的预设(如摄像头、广场 −80 等)。删除的内置预设可在“设置 → 恢复默认识别特征和预设”中恢复。" + "将当前筛选另存为…" + "信号源" + "“随你移动”仅支持 BLE。关闭此项前,无法选择“两者”或“仅 Wi-Fi”。" + "这些开关用于选择包含的类型。全部开启可同时查看 Wi-Fi 和 BLE;单个信号源不会同时属于两者。" + "两者" + "仅 Wi-Fi" + "仅 BLE" + "随你移动" + "先开启“设置 → 用 GPS 标记检测记录”,再步行或驾车。仅显示沿途持续跟随且信号较强的 BLE 广播设备。Wi-Fi 接入点保持隐藏,因为路过的强信号接入点也会在路径上留下记录。开启后开始 BLE 随行测试,并清除“仅已识别/仅显示/仅命名/仅关注”筛选。也可点击顶部“随你移动”预设。" + "GPS 路径目前 %1$s 米,请继续移动(约 50 米)。驾车时仍为 0,说明定位未实时更新,请使用高精度定位;仅最近已知位置不够。另一台 iPhone 通常不会匹配,因为 BLE MAC 轮换会成为新信号源。" + " 同时开启了“仅命名信号源”,未命名设备仍隐藏。" + " 同时开启了“仅关注信号源”,未关注设备仍隐藏。" + " 同时开启了“仅已识别/仅显示”,未匹配设备仍隐藏。" + " 已开启“隐藏我的设备”,这些设备不会出现在列表中。" + "GPS 路径 %1$s 米。同时开启了“仅已识别”、类别“仅显示”、“仅命名”或“仅关注”,只有满足这些条件的设备参与随行检测。点击“随你移动”预设可测试 BLE,包内或车内的标签应能匹配。Wi-Fi 接入点仍隐藏。" + "GPS 路径 %1$s 米。筛选沿途以较稳定强度持续接收到的 BLE,不包括到达后才出现的设备。" + "已开启“隐藏我的设备”,这些设备不会显示。" + "包内或车内的标签会匹配。" + "Wi-Fi 接入点仍隐藏,其覆盖范围可能看起来像随行。手机轮换的 BLE 地址不会拼接为同一个跟随设备。“实时 → 重新开始”可清除路径和轨迹以重新测试。" + "新检测记录" + "仅新设备 · 学习中" + "仅新设备" + "“标记为已见”和“重置已见记录”位于实时页面的标签栏上方。" + "正在将当前 Wi-Fi 设备学习为已见设备。" + "已隐藏 %1$s 个已见设备。" + "已见设备为 0。" + "隐藏当前已存在的信号源,仅在实时页面显示新设备。开启后,“标记为已见/重置已见记录”显示在标签栏上方。“短暂保留”仍控制最后一个数据包后新设备的保留时间。随机 BLE 地址会被视为新设备。" + "保留哪些设备" + "仅已识别设备(隐藏未匹配)" + "“仅显示”已经隐藏未匹配设备。使用此开关前,请关闭类别或所选识别特征的“仅显示”。" + "仅关注信号源" + "仅显示匹配已关注识别特征的设备,或开启告警的命名信号源。“隐藏这些”仍有效,例如同时隐藏监控类别会隐藏已关注的摄像头。仅命名但未告警的设备请用“仅命名信号源”。可在识别特征页添加关注,在详情页开启告警。" + "仅命名信号源" + "仅显示设置过自定义名称的信号源,告警可以关闭。在“设置 → 命名信号源”管理。随机/隐私 MAC 轮换后不会继承名称。" + "隐藏我的设备" + "标记为“我的设备”的信号源不显示在实时页面,但仍包含在会话和监测总结中。开启“随你移动”不会关闭此项。" + "隐藏 Fast Pair 账户密钥广播" + "减少公共场所噪声:隐藏没有其他识别特征的已配对 Fast Pair 广播,保留配对模式(点击配对的型号 ID)。若在所选识别特征中隐藏 Fast Pair,配对模式也会隐藏。" + "识别特征类别" + "仅影响实时显示,识别特征仍可标注、记录和蜂鸣。摄像头、无人机、定位标签等类别可在此选择。需要预设时,选择“仅显示”再另存当前筛选。未选择类别时,“仅显示”不改变实时列表。" + "仅显示" + "隐藏这些" + "所选识别特征" + "仅显示所选识别特征" + "点击类别展开识别特征。实时页面仅保留匹配下方已选特征的信号源。未选择时不额外限制,实时列表不变。关闭再开启仍保留选择。" + "隐藏所选识别特征" + "点击类别展开识别特征。匹配下方已选特征的设备不显示在实时列表。关闭再开启仍保留选择。" + "精细筛选" + "最低 RSSI:%1$s dBm" + "名称/MAC 包含" + "OUI/厂商包含" + "附加筛选逻辑" + "AND/OR 适用于名称、OUI、RSSI 和类别包含条件,不适用于无线类型、仅命名、仅关注、隐藏我的设备、隐藏 Fast Pair 账户密钥或隐藏列表。" + "重置筛选" + "重置筛选?" + "清除此页所有开关和选择(无线类型、类别、识别特征、RSSI、名称/OUI),保留已保存预设。实时显示恢复为未筛选列表。这不是撤销操作。" + "重置" + "删除预设?" + "从列表移除内置预设“%1$s”?目录更新不会恢复它,可用“设置 → 恢复默认识别特征和预设”恢复。应用其他预设或重置筛选前,实时筛选保持不变。" + "删除预设“%1$s”?此操作无法撤销。应用其他预设或重置筛选前,实时筛选保持不变。" + "隐藏此突发广播" + "已隐藏 1 个泛洪信号源" + "已隐藏 %1$s 个泛洪信号源" + "隐藏这些" + "显示已暂停,扫描和日志继续运行。恢复后仍使用当前筛选。点击“实时”恢复列表。" + "仅新设备 · 学习当前 Wi-Fi" + "仅新设备 · 已隐藏 %1$s 个" + "仅新设备" + "随行 · 路径 %1$s 米 · “重新开始”清除路径" + "仅关注信号源" + "仅命名信号源" + " · 内存上限" + " · 上限 %1$s" + "会话 · %1$s · %2$s · %3$s 个信号源%4$s" + "重命名会话" + "“随你移动”需要开启“设置 → 用 GPS 标记检测记录”,然后步行或驾车。" + "GPS 路径 %1$s 米,距离过短,请继续移动。若始终为 0,定位未更新,请使用高精度定位。" + "当前允许的信号源中没有持续随行的强 BLE 信号。可点击“随你移动”预设测试,或关闭仅已识别/仅显示/仅命名/仅关注。" + "沿途没有持续随行的强 BLE 信号。Wi-Fi 接入点和“我的设备”仍隐藏。" + "沿途没有持续随行的强 BLE 信号。Wi-Fi 接入点仍隐藏;包内或车内标签应显示。Find My 的 MAC 轮换不会拼接为同一设备。" + "下次 Wi-Fi 扫描前隐藏当前接入点。新蓝牙设备仍立即显示。" + "已隐藏 %1$s 个已见设备。新设备在持续接收时保留,最后一个数据包后至少保留“短暂保留”设定的时间。" + "等待新 Wi-Fi 或 BLE 信号源。持续接收时保留,最后一个数据包后至少保留“短暂保留”设定的时间。" + "当前无关注信号源广播。可关注识别特征、为命名设备开启告警,或关闭“筛选 → 仅关注信号源”。" + "当前无命名信号源广播。可在详情设置自定义名称,或关闭“筛选 → 仅命名信号源”。" + "显示全部" + "收起空类别" + "当前无广播设备匹配筛选。如果刚变空,系统可能处于扫描间隔;上一组结果会保留,通常会自动恢复。" + "%1$s 个信号源" + " · %1$s 个未匹配" + " · %1$s 个属于多个类别" + " · 隐藏 %1$s 个空类别" + "无识别特征" + "%1$s 个识别特征" + "没有设备匹配当前筛选" + " · ×%1$s · 双击重置" + " · 双指缩放" + "%1$s 个广播中 · 筛选内 %2$s 个 · 暗色表示已消失 · 点击信号点%3$s" + "当前无广播设备匹配筛选。如果刚变空,可能处于系统扫描间隔;上一组结果会保留并自动恢复。可用“时间线”查看最近消失的设备。" + "新发现 %1$s 秒" + "新发现 %1$s 分钟" + "平均 %1$s" + "本次会话已告警" + "最近 15 分钟 · 实心条表示广播时段" + "未命名 BLE" + "未命名" + "现在" + "首次 %1$s · 最近 %2$s" + "重命名会话" + "最近 15 分钟" + "监测会话" + "会话导出" + "候选识别特征" + "正在读取日志并重新匹配目录…" + "轮转日志" + " · 已按当前目录重新匹配" + "此日志中没有候选特征组。随机地址和类似住户的名称会跳过。候选需要至少两个信号源共享唯一广播 ID。" + "\n另有 %1$s 个" + "创建识别特征" + "已跳过" + "%1$s 个随机地址" + "%1$s 个类似住户的名称" + "及" + "。这些不属于目录特征组。" + "隐私模式已开启。监测总结、会话对比、AI 导出和详情分享中的 MAC 后三段显示为 **:**:**,GPS 坐标被隐藏。日志、会话导出和 GPX/KML/WiGLE 文件仍包含完整地址和经纬度。" + "监测会话" + "监测会话是带名称的接收时间窗口。下方选择决定路径、监测总结和对比中的“本次会话”:正在进行的会话、选中的已保存会话,或未开始会话时的最近 15 分钟。" + "本次会话:%1$s · %2$s · %3$s 个信号源" + "结束会话" + "开始会话" + "当前无进行中的会话。可在此开始;开始前,路径和监测总结使用最近 15 分钟的数据。" + "当前无进行中的会话。可在此开始;路径和监测总结使用选中的会话。" + "暂无已保存会话。" + "最近 15 分钟" + "路径和监测总结使用内存中的数据,不使用已保存会话。" + " · 额外关注 %1$s" + "%1$s · %2$s · %3$s 个信号源%4$s" + "结束当前会话后,可选择已保存会话用于路径和监测总结。" + "重命名" + "删除全部会话" + "路径" + "北向上。线条表示本机路径,黑点是起点,蓝点是最后记录的位置。MAC 或识别特征告警显示为类别图标;解码的经纬度使用设备最后广播的位置。数字表示同一位置有多个设备,粗绿色段表示停留,时间刻度沿路径标注。" + "开启 GPS 标记后移动,或打开已记录路径的会话。" + "%1$s · 路径 %2$s 米 · 跨度 %3$s 米" + " · %1$s 次告警" + "点击数字查看该处的信号源,点击单个图标查看对应设备,再次点击关闭。点击弹出列表或下方条目可打开设备详情。" + "= 此路径 2 公里内的广播轨迹" + "= 此路径 2 公里内的单次广播位置" + "= 操作者" + "此路径上没有附带 GPS 位置的 MAC 或识别特征告警。" + "1 次告警" + "%1$s 次告警" + "会话报告包含一个没有 UAS ID 的广播位置。" + "会话报告包含没有 UAS ID 的广播位置。" + "会话报告" + "监测总结(文本)" + "监测总结(PDF)" + "显示未匹配的轮换地址 BLE" + "默认关闭:监测总结文本/PDF 列表略去未匹配的随机地址 BLE,但总数仍包含它们。保留额外关注、命名识别特征、关注和载荷位置标记。会话导出包含全部信号源。" + "显示全部信号源" + "默认关闭:监测总结和对比优先展示数量,仅列出额外关注、自定义名称、“我的设备”或已关注的信号源,对比还会列出解码值变化的设备。开启后恢复完整名单。“显示未匹配的轮换地址 BLE”也适用于这些列表。PDF 用条形图展示数量,会话导出始终包含全部信号源。" + "生成可直接粘贴的补充分析:速率、RSSI 区间、额外关注和追踪 ID,不重复监测总结清单。单个信号源的 AI 导出在详情页。" + "会话导出" + "本次会话(或最近 15 分钟)中每个唯一信号源一行。CSV/JSON Lines 包含匹配识别特征及额外关注特征组,不是轮转日志。GPX/KML 包含本机路径轨迹和接收点。Fieldwatch 不上传,此文件不受隐私模式遮蔽。" + "对比会话" + "先开始并结束会话,保存第二个会话用于对比。“本次会话”也可使用最近 15 分钟。" + "第二个会话" + "%1$s · %2$s · %3$s 个信号源" + "对比(文本)" + "对比(PDF)" + "同一报告提供两种格式,仅比较出现情况:仅本次、仅第二次、两次都有。按类型+MAC 区分,标记额外关注和命名信号源。这不是设备定位。" + "生成可粘贴的对比补充分析:重叠、各自独有的额外关注/命名设备,以及下一次会话能缩小的范围,不重复对比名单。会话报告的 AI 导出仍仅使用当前窗口。" + "识别目录" + "候选识别特征" + "日志中共享唯一 ID 的未匹配信号源,并非所有未知设备。由你审核,保存前不会添加到目录。" + "日志导出" + "本次会话 %1$s 行 · 磁盘占用 %2$s KB" + " · 日志已关闭" + "轮转文件采用 JSON Lines,CSV 将相同记录输出为表格。GPX、KML(Google Earth)和 WiGLE CSV(wigle.net)记录接收点,即手机接收到信号时的位置,并非对方设备的定位。需要开启 GPS 标记和日志。分享使用 Android 系统面板,Fieldwatch 不上传。" + "重置/清空日志" + "清空日志?" + "删除手机上全部轮转 CSV/JSON 文件,无法撤销。实时扫描会开始写入新的空日志。" + "清空日志" + "结束前,监测总结和 AI 导出使用此窗口。实时列表不变。" + "删除此会话?" + "从此手机移除已保存会话,日志不变。" + "删除全部会话?" + "从此手机移除已保存会话,不删除正在进行的会话,日志不变。" + "全部删除" + "本次会话:%1$s — 命名窗口(最多 %2$s 个),与监测总结一致。" + "本次会话:%1$s — 命名窗口(最多 %2$s 个),与监测总结一致。" + "本次会话:内存中的最近 15 分钟(约 400 个信号源),与监测总结一致。" + "格式" + "分享" + "保存到 SD 卡/存储…" + "本次会话(%1$s),与路径使用同一窗口。开启 GPS 标记并移动后执行随行检测,不构成法律结论。" + "会话:%1$s,与路径使用同一窗口。开启 GPS 标记并移动后执行随行检测,不构成法律结论。" + "内存中的最近 15 分钟,与路径使用同一窗口,提供两种格式。开启 GPS 标记并移动后执行随行检测,不构成法律结论。" + "导出 Fieldwatch 日志" + "正在生成会话对比…" + "无法生成会话对比" + "正在生成会话对比 PDF…" + "正在排版会话对比 PDF…" + "正在加载地图…" + "无法生成会话对比 PDF" + "Fieldwatch 会话对比 — %1$s" + "Fieldwatch 会话对比" + "正在生成对比 AI 导出…" + "Fieldwatch 会话对比 AI 导出 — %1$s 与 %2$s" + "无法生成对比 AI 导出" + "请选择第二个会话。" + "无法读取该会话。" + "无法读取本次会话。" + "无法读取日志" + "Fieldwatch 识别特征" + "无法导出识别特征" + "无法写入所选位置。" + "识别特征已保存" + "数据包已写入所选文件夹。可分享给其他 Fieldwatch 用户,或在恢复默认设置前保留备份。" + "无法保存识别特征" + "无网络连接" + "无网络连接。请从文件导入识别特征。" + "正在更新内置目录…" + "已是最新目录" + "当前目录为 %1$s,无需更新。" + "更新 %1$s 项" + "新增 %1$s 项" + "内置条目没有变化。" + "目录已更新" + "内置目录现为 %1$s。%2$s 关注和设置未改变。" + "已跳过识别特征解码" + "此目录的部分字段映射需要更新的 Fieldwatch。识别特征仍可匹配,请安装新版 APK 以解码这些字段。" + "无法连接 GitHub" + "无法获取 GitHub 上的目录。请稍后重试,或从文件导入识别特征。" + "无法导入目录" + "无法导入目录。" + "无法读取该文件。" + "识别特征已导入" + "无法导入识别特征" + "Fieldwatch 设置" + "无法导出设置" + "设置已保存" + "数据包已写入所选文件夹。可在恢复出厂设置或换手机后导入。识别特征需要另行导出数据包。" + "无法保存设置" + "设置已导入" + "无法导入设置" + "在设置中开启“用 GPS 标记检测记录”以记录路径。" + "开启 GPS 标记后移动,路径需要有效定位。" + "最近 15 分钟。开启 GPS 标记后移动,或开始会话以保存更长路径。" + "正在生成监测总结 PDF…" + "正在排版监测总结 PDF…" + "无法生成监测总结 PDF" + "正在生成监测总结…" + "无法生成监测总结" + "FIELDWATCH 监测会话" + "Fieldwatch 监测总结 — 最近 15 分钟" + "正在汇总会话…" + "正在查询地名…" + "正在生成监测总结…" + "正在生成 AI 导出提示词…" + "隐私模式:MAC 后三段为 **:**:**,GPS 坐标已隐藏。手机上的日志不变。\n\n%1$s" + "Fieldwatch AI 导出 — 会话 %1$s" + "Fieldwatch AI 导出 — 最近 15 分钟" + "无法生成 AI 导出" + "Fieldwatch AI 导出 — %1$s" + "Fieldwatch 设备详情 — %1$s" + "设备详情" + "无法分享设备详情" + "正在准备会话导出…" + "正在保存会话导出…" + "无法打开所选位置" + "正在写入 %1$s · %2$s 个信号源" + "请选择地图格式。" + "正在写入 %1$s · %2$s 个标记" + "正在写入 %1$s · %2$s/%3$s" + "日志已暂停 · 正在准备文件…" + "日志已暂停 · 正在保存到所选位置…" + "Fieldwatch 日志(CSV)" + "Fieldwatch 日志(JSON Lines)" + "正在读取日志…" + "正在读取日志 · %1$s/%2$s KB" + "信号源" + "Wi-Fi 信号源" + "BLE 信号源" + "没有带 GPS 标记的%1$s。请在设置中开启 GPS 标记和日志,再开始监测。" + "正在清空日志…" + "无法清空日志" + "正在写入 %1$s/%2$s KB" + "导出失败" + "关注列表" + "免责声明" + "1 次广播定位" + "开始" + "结束" + "第二个会话" + "广播名称" + "仅为特征匹配,不代表身份确认,也不构成安全结论。" + "飞行器" + "观察备注" + "额外关注" + "信号源" + "最近 15 分钟" + "未匹配" + "Wi-Fi 接入点" + "广播名称" + "额外关注" + "备注" + "身份信息" + "信号源" + "Wi-Fi 接入点(广播网络信标)" + "蓝牙低功耗广播设备" + "地址" + "制造商" + "OUI(厂商前缀)" + "%1$s — 无 IEEE 匹配;随机地址通常没有厂商记录" + "网络名称(SSID)" + "已隐藏:接入点正在广播信标,但未公开名称" + "信号" + "此处信号强度(RSSI)" + "不可用" + "最近接收" + "本次会话接收强度范围" + "设备声明的发射功率" + "%1$s dBm — 设备声明的发射功率,不代表距离" + "信道/频率" + "信道 %1$s" + " · 带宽 %1$s" + "Wi-Fi 代际" + "中心频率" + "蓝牙广播" + "物理层(PHY)" + "可连接" + "是:手机可以建立 BLE 连接" + "否:仅广播(本次扫描可以接收,无法连接)" + "广播频率" + "广播间隔 %.0f 毫秒(越小,广播越频繁)" + "周期广播" + "可发现性" + "标志位(原始值)" + "设备声明的外观类型(Appearance)" + "未收录的外观代码 0x%04X" + "外观代码" + "经典蓝牙设备类别" + "Wi-Fi 接入点" + "加密/登录" + "安全信息字符串" + "支持的速率" + "%1$s Mbps(* 表示必需的基本速率)" + "能力字符串" + "飞行器" + "已解码字段" + "说明" + "提供的服务" + "服务 ID" + "(空)" + "广播中的制造商数据" + "未收录于蓝牙公司列表" + "蓝牙公司 0x%04X" + "原始载荷(%1$s 字节)" + "Wi-Fi 厂商标签" + " 类型 %d" + "厂商 OUI %1$s%2$s" + "未知 IEEE OUI" + " — 接入点的附加信息元素,并非 SSID。" + "会话" + "首次发现" + "最近发现" + "命中次数" + "最近定位" + "匹配的识别特征" + "原始广播" + "出现记录(15 分钟)" + "IEEE 板卡/芯片厂商:%1$s(%2$s)。这是 MAC 前缀的所有者,不一定是产品品牌。" + "广播中的蓝牙公司:%1$s(0x%%04X)。" + "现在" + "Wi-Fi 接入点" + "不可用" + "未命名 BLE" + "信号源" + "轮转日志" + "第二个会话" + "飞行器" + "观察备注" + "额外关注" + "会话对比" + "未匹配" + "隐私模式:MAC 后三段为 **:**:**,GPS 坐标已隐藏。手机上的日志不变。\n\n%1$s" + "扫描" + "已标记为我的设备" + "蓝牙低功耗广播设备" + "Fieldwatch 设备详情\n" + "实验性结果,不构成法律上的身份确认。来自原生 Android 提供的无线信息,不保证现场存在追踪器或摄像头。\n" + "已标记为我的设备" + "标记开启时不蜂鸣,仍显示在列表中。" + "可能的设备类型:" + "额外关注(%1$s):" + "仅为特征匹配,不代表身份确认,也不构成安全结论。\n" + "越接近 0 dBm,表示此处信号越强,并不代表距离。\n" + "最近 RSSI(从旧到新)" + "。还提供:" + "服务数据 %1$s%2$s" + "最近定位是手机接收时的 GPS 位置,并非此信号源的位置。\n" + "蓝牙低功耗广播设备" + "设备正在广播,但未声明产品类别(没有外观代码、设备类别或可指示类型的常见服务)。" + "最可能是" + "可能是" + "也可能是" + "%1$s%2$s" + " 以上依据设备广播信息推断,并非目视确认。" + "%1$s 设备" + "有限可发现:短暂寻找附近连接。" + "可发现:其他 BLE 设备可以发现它。" + "仅 BLE:不支持经典蓝牙(耳机/文件传输等)。" + "除 BLE 外,也可能支持经典蓝牙(BR/EDR)。" + "双模芯片:BLE 和经典蓝牙可以同时运行。" + "%1$s — 远距离 BLE(速度较慢,覆盖更远)" + "%1$s — 更快的 BLE(蓝牙 5)" + "%1$s — 标准 BLE 无线链路" + "本地管理的 BSSID。车载、Mesh 和访客接入点常长期使用此地址,并非手机轮换的 MAC。" + "本地管理地址,本地位已设置,并非 IEEE 分配的出厂地址。" + "公开出厂地址(稳定,由 IEEE 分配)。" + "随机/隐私地址,MAC 可能变化,不能作为持久身份。" + "匿名:协议栈隐藏了地址。" + "通用 IEEE 地址(OUI 稳定)。" + "不可用" + "很强" + "强" + "中等" + "弱" + "很弱" + "WPA3 密码(SAE 握手)" + "增强开放网络(加密,无密码)" + "WPA2 密码(PSK)" + "Wi-Fi 密码(WPA/PSK)" + "企业登录(802.1X)" + "WEP(旧式,安全性弱)" + "开放网络或未解析加密方式" + "AES 加密" + "TKIP(较旧、较弱的加密)" + "已启用 WPS 配置" + "Mesh 节点" + "自组织网络" + "基础设施接入点" + "基础连接" + "属性协议" + "型号/序列号/固件" + "电池电量" + "键盘、鼠标或游戏手柄" + "心率传感器" + "血压传感器" + "温度/湿度类传感器" + "低功耗骑行功率/速度" + "Google Fast Pair(常见于耳机/音箱)" + "与 Apple Find My 相关" + "Tile 定位器" + "Sonos 音箱" + "August Home 门锁" + "Apple(不是 ASSA ABLOY)" + "耳塞或耳机" + "音箱" + "鼠标" + "键盘" + "游戏手柄" + "手表或腕戴设备" + "手机" + "电脑或平板" + "定位标签/追踪器" + "遥控器" + "输入设备(键盘、鼠标等)" + "心率监测器" + "健康传感器" + "显示设备或电视棒" + "时钟" + "智能眼镜" + "广播声明的外观类型为 %1$s。" + "耳塞或头戴耳机" + "电脑" + "手表" + "音频设备" + "输入配件" + "设备类别声明为 %1$s。" + "键盘、鼠标或游戏手柄" + "提供 HID(人机接口)服务。" + "耳机、头戴设备或音箱" + "提供经典蓝牙音频/耳机服务。" + "LE Audio 耳塞或音箱" + "提供蓝牙 LE Audio 服务。" + "提供心率服务。" + "血压计" + "提供血压服务。" + "环境传感器" + "提供环境感知服务。" + "耳塞或音箱" + "存在 Google Fast Pair(常见于耳塞和音箱)。" + "Samsung SmartTag 标签" + "SmartTag 服务 UUID。" + "Apple Find My 配件" + "与 Find My 相关的 UUID。" + "Tile 定位器" + "Tile 服务 UUID。" + "DULT 定位标签" + "Google Find Hub 标签" + "Apple AirTag/Find My 标签" + "匹配识别特征:%1$s。" + "iPhone、iPad 或 Mac" + "Windows/Surface/Xbox 无线设备" + "Tesla BLE 胎压传感器" + "BLE 胎压传感器" + "Vuzix 智能眼镜" + "Tesla 车辆(包括 Cybertruck)或手机钥匙" + "Pixel 或其他 Google 无线设备" + "Sony 耳机、电视或摄像机" + "Bose 耳机或音箱" + "Garmin 手表或 inReach" + "Echo、Fire 或其他 Amazon 无线设备" + "Oura 戒指" + "Logitech 鼠标、键盘或网络摄像头" + "JBL 或 Harman 音频设备" + "Sonos 音箱" + "DJI Osmo 运动相机" + "DJI 无人机或遥控器" + "广播 ASTM Remote ID 的无人机" + "Skydio 无人机" + "Autel 无人机" + "Parrot ANAFI 或 Bebop 无人机" + "HOVERAir 飞行相机" + "NETGEAR 或 Orbi 接入点" + "TP-Link 接入点" + "ASUS 接入点" + "Linksys 或 Velop 接入点" + "Eero Mesh 节点" + "Google Wifi 或 Nest Wifi 节点" + "D-Link 接入点" + "Belkin 接入点" + "Xfinity 网关或热点" + "Spectrum 网关或 Spectrum Mobile 热点" + "AT&T 网关或 attwifi 热点" + "Verizon 或 Fios 网关" + "Starlink 路由器" + "Cisco Meraki 接入点" + "Cisco Aironet、Catalyst、Business、RV 或 SPVTG 接入点" + "Juniper Mist 接入点" + "T-Mobile Home Internet 网关或热点" + "HUMAX 网关(常见于 T-Mobile Home Internet)" + "Sagemcom 运营商网关" + "Arcadyan 运营商网关" + "Askey 运营商/5G 网关" + "Calix 光纤网关" + "Nokia Solutions and Networks 网关" + "AirTies 运营商 Mesh 节点" + "Tenda 接入点" + "Ruijie 或 Reyee 接入点" + "DWnet 接入点" + "WAVLINK 接入点" + "Sercomm 运营商网关" + "Luxul 接入点" + "Sophos 防火墙或接入点" + "AUMOVIO/Continental 车载 Wi-Fi 设备" + "CenturyLink 网关" + "GM 车载热点(Cadillac/GMC/Buick/Chevrolet)" + "Audi MMI 车载热点" + "Extreme Networks 接入点" + "Adtran 光纤网关(常见于 CenturyLink/Quantum Fiber 代工产品)" + "Cambium 或 IgniteNet 接入点" + "TRENDnet 接入点" + "Cudy 旅行或家用路由器" + "SnapAV/Control4/Wattbox 接入点" + "Arlo 摄像头或 VMB 基站" + "Vantiva 或 Technicolor 运营商网关" + "Hitron 有线网关(常见于 Xfinity 代工产品)" + "Actiontec FiOS 或 Frontier 网关" + "Buffalo AirStation 或路由器" + "Grandstream GWN 接入点" + "Edgecore 接入点" + "WatchGuard 防火墙或接入点" + "Mojo Networks/Arista Cognitive Wi-Fi 接入点" + "Winegard 房车或船用 Wi-Fi 设备" + "Inseego 5G 或 MiFi 热点" + "Franklin Technology 5G 家庭网关(RG3100 类)" + "Synology NAS 或路由器接入点" + "HPE Aruba Instant 或 Instant On 接入点" + "RUCKUS 接入点" + "Fortinet FortiAP 或 FortiWiFi" + "MikroTik 路由器或接入点" + "EnGenius 接入点" + "Zyxel 网关或接入点" + "Peplink 或 Pepwave 路由器" + "OpenWrt 路由器" + "Arris 或 SURFboard 有线网关" + "Ubiquiti UniFi 接入点" + "UniFi Protect Instant 摄像头" + "UniFi/Ubiquiti 名称" + "ecobee 温控器" + "Sensi 温控器" + "Honeywell Home 或 Lyric 温控器" + "Honeywell Xenon 医疗条码扫描器" + "Omron 血压计或体重秤" + "Withings 体重秤或血压计" + "Dexcom 血糖传感器" + "Nest 温控器或 Nest Labs BLE 传感器" + "Nest Protect、摄像头或其他 Weave BLE 设备" + "Haiku 或 Mammoth 吊扇" + "Tuya BLE 设备(插座、灯、摄像头或传感器)" + "ASSA ABLOY/Yale 门锁、HID 读卡器或 Seos 凭证" + "August 智能门锁" + "Schlage 或 Allegion 门锁" + "Nuki 门锁或开门器" + "SALTO 门禁读卡器或门锁" + "dormakaba、Saflok 或 Oracode 门锁" + "Lockly 智能门锁" + "Kwikset Kevo 或 Unikey 门锁" + "Master Lock 挂锁" + "igloohome 门锁或钥匙盒" + "Tedee 智能门锁" + "Paxton 读卡器或 Net2 接入点" + "Kwikset 门锁" + "Chamberlain myQ 车库控制器" + "Chevrolet 车载 Wi-Fi 热点" + "Rivian 车辆、手机钥匙或传感器" + "Ford 或 Lincoln 车辆或手机钥匙" + "Honda 或 Acura 车辆或手机钥匙" + "Hyundai 或 Genesis 车辆或手机钥匙" + "Toyota 或 Lexus 车辆或手机钥匙" + "Nissan 或 Infiniti 车辆或手机钥匙" + "Subaru 车辆或手机钥匙" + "BMW 车辆、手机钥匙或出厂热点" + "Volkswagen 车辆或手机钥匙" + "Porsche 车辆或手机钥匙" + "Jaguar、Land Rover 或 Range Rover 车辆" + "BYD 车辆或手机钥匙" + "Govee 灯具或传感器" + "HP 打印机" + "Epson EcoTank 或 WorkForce 打印机" + "LG webOS 电视" + "Roku 电视棒或 Roku TV(常为隐藏的 Wi-Fi Direct 遥控接入点)" + "Samsung 冰箱、炉灶、烤箱或灶台(配置接入点)" + "EcoWater 软水机(配置接入点)" + "Nespresso 咖啡机" + "RadiaCode 辐射检测仪" + "Shokz OpenRun 或 OpenFit 耳机" + "Mercedes MBUX 车载热点" + "Motive/KeepTruckin 车队 ELD 热点" + "PeopleNet 车队 ELD 热点" + "Uconnect 车载热点" + "CarPlay 车载热点" + "Cradlepoint 车载路由器(常用于公共安全/车队)" + "Sierra Wireless AirLink 车载网关" + "Compex 接入点(有时用于公共安全/车队)" + "Novatel Wireless/Inseego 车载无线设备" + "Utility, Inc 车载或公共安全无线设备" + "GL.iNet 旅行路由器" + "Target Atrius 购物篮标签" + "Minew BLE 信标或传感器" + "Estimote 信标" + "Kontakt.io 信标" + "Iteris BlueTOAD/Vantage Velocity 路侧蓝牙行程时间读取器" + "BLIP Systems BlipTrack 路侧行程时间传感器" + "Flock Raven 或 ShotSpotter 声学枪声传感器" + "Digital Ally 随身或车载摄像头" + "Reveal Media 随身摄像头" + "Wolfcom 随身或车载摄像头" + "Panasonic i-PRO 摄像头或 Arbitrator 车载系统" + "Limitless Pendant 对话录音设备" + "Bee Pioneer 穿戴式录音设备" + "Omi 挂坠或 OpenGlass 摄像眼镜" + "Friend Pendant 项链" + "Brilliant Labs Frame AR 眼镜" + "Even Realities G1 眼镜" + "Hayden AI 公交或车载摄像头" + "Miovision 路口交通摄像头" + "Tattile 车牌读取器" + "LVT/LiveView 太阳能监控拖车" + "Hanwha Vision/Wisenet 摄像头" + "Uniview/UNV 摄像头" + "Rhombus 云摄像头" + "MeshCore LoRa 配套无线设备" + "goTenna Mesh 或 Pro 无线设备" + "SenseCAP LoRaWAN/Helium 网关" + "RAK WisGate LoRaWAN 网关" + "GhostESP ESP32 审计开发板" + "Bruce ESP32 渗透测试开发板" + "摄像头模块无线设备(LiteOn 或类似产品)" + "定位标签" + "Wi-Fi Direct 接入点" + "SSID 以 DIRECT- 开头。" + "使用 Wi-Fi Direct 的手机或电视" + "手机热点" + "SSID 看起来像手机热点。" + "Mesh Wi-Fi 节点" + "能力列表包含 Mesh。" + "隐藏的 Wi-Fi 接入点" + "SSID 已隐藏,但设备仍在广播信标。" + "Wi-Fi 接入点" + "原生 Android 仅报告广播信标的接入点。" + "很强/可能很近" + "信号增强" + "信号减弱" + "基本不变" + "接收中…" + "暂时无信号" + "已消失" + "此处信号极强。观察周围,通常在手中、口袋或同一包内。这不是以米表示的距离。" + "比几秒前更强,可继续沿该方向移动。" + "比几秒前更弱,可转向或后退。" + "尚无明显变化,请放慢速度并保持手机稳定。" + "需要接收几秒数据才能比较。" + "几秒未收到数据,可能停止广播或被墙体遮挡。" + "已离开实时列表。随机地址 BLE 常在寻踪中消失。" + "绑定此 MAC,在监测总结、对比、路径和 AI 导出中显示,不自动开启告警。" + "%1$s\n已标记为我的设备" + "已标记为我的设备" + "显示在实时页面。右上角关注按钮控制告警,此项不会开启告警。" + "%1$s 绑定此 BSSID。车载、Mesh 和访客接入点常长期使用本地管理地址。" + "已标记为我的设备" + " 已标记为我的设备" + "关闭" + "FIELDWATCH 监测总结" + "监测总结" + "监测总结" + "免责声明" + "要点:%1$s" + "MAC 后三段(**:**:**)和 GPS 坐标已隐藏。手机上的日志不变。" + "隐私" + "未知" + "信道 %1$s" + "最强 %1$s dBm" + "接收到 %1$s 个接入点,其中 %2$s 个隐藏 SSID;%3$s 个在窗口的大部分时间持续存在。" + "信号最强的接入点:" + " 已标记为我的设备" + " 额外关注(%1$s):%2$s" + "隐藏 SSID:" + " · %1$s %2$s %3$s dBm 信道 %4$s" + "接收到 %1$s 个 BLE 广播设备,其中 %2$s 个使用随机地址,%3$s 个匹配识别特征。" + "列表已略去 %1$s 个未匹配的轮换地址 BLE,总数仍包含它们。会话导出包含全部信号源。" + "值得留意的 BLE:" + "此窗口中没有。" + " 已标记为我的设备" + " 额外关注(%1$s):%2$s" + "已命名或标记:" + "窗口大部分时间持续存在:%1$s 个" + "此窗口首次发现:%1$s 个" + " · %1$s %2$s 持续 %3$s" + " · 无。" + "首次发现中信号最强的设备:" + "无额外标记。识别特征命中、额外关注和随行提示已覆盖已命名的特征匹配。" + "GPS 标记已关闭,无路径" + "GPS 标记已开启,此窗口不足 2 次定位" + "沿路径移动 %1$s · 跨度 %2$s · %3$s 次定位" + "概况" + "你所在的位置" + "随行评估" + "可能随你移动的定位器" + "定位标签(AirTag/Find My、SmartTag、Tile、Chipolo、Pebblebee)和较强的近身 Apple BLE。这些信号源在本次会话中持续伴随 GPS 路径。Fieldwatch 无法区分你自己的标签或手机与会话开始前放置在车内、包内或身上的定位器。请核对每个 MAC。这不是确认结论,也不是身份识别。" + "可能的跟随设备" + "会话开始时未接收到、之后持续伴随路径的定位标签。可能有人开始随行(其手机或标签),也可能中途加入了设备。这不是确认结论,也不是身份识别。" + "随行的零售信标" + "持续伴随 GPS 路径的 iBeacon/Minew/Estimote/Kontakt.io/Target Atrius 购物篮信号源。定位信标通常固定在商店或场地内,不会随人移动。若出现随行,请核对是否为购物篮、自用测试标签、徽章,或短路径仍在固定设备覆盖范围内。与 Find My 跟随不同,不是确认结论或身份识别。" + "随行的穿戴设备" + "持续伴随 GPS 路径的 Garmin/Fitbit/Oura。手表和戒指通常随佩戴者移动,往往是你或同行者的设备,通常不是暗中放置的定位器。请核对每个 MAC。这不是确认结论或身份识别。" + "环境" + "网络(Wi-Fi 接入点)" + "蓝牙低功耗" + "识别特征命中" + "持续存在情况" + "仅为特征匹配,不代表身份确认,不是盗刷设备检测器,也不构成安全结论。" + "异常情况" + "广播泛洪" + "建议操作" + "会话 %1$s(%2$s → %3$s UTC)" + "最近 15 分钟(%1$s → %2$s UTC)" + "FIELDWATCH 监测会话 — %1$s" + "生成时间" + "监测会话" + "时间窗口" + "工具" + "Fieldwatch(app.fieldwatch)· 原生 Android · 仅接收 Wi-Fi 接入点与 BLE 广播" + "扫描" + "%1$s · %2$s 秒后过期 · 短暂保留 %3$s 秒" + "GPS 标记" + "开启" + "距离" + "地名" + "信息分类" + "敏感监测信息:周围 SSID、MAC、操作者 GPS 和飞行器广播轨迹" + "敏感监测信息:周围 SSID、MAC 和操作者 GPS" + "监测会话" + "会话 — %1$s" + "操作者路径" + "北向上。线条表示本机路径(%1$s)。MAC 或识别特征告警各绘制一次。解码经纬度使用最后广播位置,其他标记使用最强接收点。数字对应路径图例中的位置。" + "## 你所在的位置(操作者 GPS)" + "- GPS 标记:%1$s。" + "- 在线地名:" + "已关闭(设置 → 在线地名)。本次导出不进行逆地理编码。" + "- 随行测试:移动不足(跨度需约 45 米),不能据此推断跟随。" + "## GPS 随行情况" + "仅列出持续伴随操作者路径的信号源。只是路过的住户标签或其他设备会略去,不能视为跟随;也不是身份确认。Find My 的 MAC 轮换不会拼接成同一跟随设备。步行时“可能跟随”的附加条件为:轨迹覆盖至少一半路径,至少三分之二 GPS 记录强度达到 −75 dBm,最后强度与峰值相差不超过 12 dB。任一不满足则视为路过并略去。定位标签(AirTag/SmartTag/Tile/Chipolo/Pebblebee/Find My/较强近身 Apple 信号)参与跟随测试。零售信标和穿戴设备单独列出:信标通常固定,穿戴设备通常为自用。" + "- 没有持续伴随路径的设备。" + "- %1$s %2$s %3$s RSSI %4$s dBm(最低 %5$s/最高 %6$s) 轨迹 %7$s 次定位,跨度 %8$s 米" + "可能随行的定位器(定位标签,整个会话持续存在;可能自用或开始前已放置)" + "可能的跟随设备(会话开始后首次接收并持续随行的定位标签)" + "随行零售信标(iBeacon/Minew/Estimote/Kontakt.io/Target Atrius 购物篮;通常固定,推行的购物车会随行)" + "随行穿戴设备(Garmin/Fitbit/Oura;通常为自用或同行者设备)" + "操作者 GPS 路径过短(%1$s 米),无法测试跟随。" + "已接收到,但没有两个 GPS 点,无法测试随行。" + "在你的 %2$s 米路径中沿 %1$s 米持续接收,信号仍强(%3$s dBm)。" + "你移动 %2$s 米时,该设备仅在一个地点附近被接收(跨度 %1$s 米)。看起来是固定设备,你正远离它。" + "随你移动 %1$s 米,15 分钟窗口开始时已在广播,信号强(%2$s dBm)。" + "沿路径 GPS 记录跨度 %1$s 米(%2$s 次定位)。窗口开始时已在广播,目前仍存在。" + "沿 %1$s 米接收(%2$s 个 GPS 记录),但没有持续强信号。属于周边覆盖/路过,不是跟随。" + "整个会话持续伴随强信号(%1$s 至 %2$s dBm,%3$s 个 GPS 记录)。" + "解码结果:%1$s。" + "整个会话持续随行,可能自用或开始前已放置,请核对。" + "定位信标通常不会随你移动,请核对是否为自用测试标签、徽章或与固定设备的短暂覆盖重叠。" + "通常是你或同行者佩戴的手表、戒指,而非暗中放置的定位器。" + "%1$s Find My/iPhone 地址会轮换;此 MAC 仅代表本次会话。" + "会话开始后出现,但最后 GPS 记录强度为 %1$s dBm,峰值为 %2$s dBm(降低 %3$s dB)。看起来是你远离了固定设备,而非跟随。" + "会话开始后出现,GPS 跨度 %1$s 米,但仅 %2$s/%3$s 个记录为强信号(至少 −75 dBm)。看起来是路过,而非跟随。" + "会话开始后出现,但仅覆盖你 %2$s 米路径中的 %1$s 米(%3$s%%)。属于周边覆盖/路过,而非跟随。" + "会话开始后出现,随后在 %1$s 米内持续强信号(覆盖总路径 %3$s 米中的 %2$s 米,占 %4$s%%;%5$s/%6$s 个 GPS 记录达到 −75 dBm)。" + "目视核对之前,仅视为可能的跟随设备。" + "对于通常固定的零售/定位信标,此情况不常见。请核对,与 Find My 跟随不同。" + "通常是中途加入的手表(你刚戴上或同行者佩戴),一般不是暗中放置的定位器。" + "在会话 %1$s 中," + "最近 15 分钟内," + "%1$sFieldwatch 接收到 %2$s 个 Wi-Fi 接入点和 %3$s 个 BLE 广播设备" + "(%1$s 个匹配识别特征" + ",%1$s 个标记为我的设备" + ",%1$s 个隐藏 SSID,%2$s 个随机地址 BLE)。" + "总移动距离:沿 GPS 路径 %1$s(直线跨度 %2$s)。" + "停留地点/区域:%1$s。" + "随行提示。" + "%1$s 个定位标签全程随行(自用或开始前已放置):" + "%1$s 个可能跟随的设备在会话开始后首次接收:" + "请核对每个 MAC,Fieldwatch 无法区分自用设备与暗中放置的设备。" + "GPS 标记已关闭,未进行随行测试。请开启 GPS 标记后步行测试。" + "GPS 位移仅 %1$s 米,过短,无法测试定位器是否随行。请开启标记后继续移动。" + "此窗口没有定位标签明确持续伴随 GPS 路径。" + "也有零售信标随行(不常见,固定设备通常不会移动):" + "穿戴设备随行(通常是你或同行者的手表/戒指):" + "GPS 标记已关闭,无法测试信号源是否随你移动。" + "请开启“设置 → 用 GPS 标记检测记录”,步行或驾车超过 50 米,再生成监测总结。" + "总移动距离:沿 GPS 路径 %1$s(%2$s 个记录),直线跨度 %3$s。" + "随行按类别区分:定位标签(AirTag/Find My、SmartTag、Tile、Chipolo、Pebblebee、较强近身 Apple 信号)、零售信标(iBeacon、Minew、Estimote、Kontakt.io、Target Atrius 购物篮)和穿戴设备(Garmin、Fitbit、Oura)。" + "移动不足,无法区分持续随行和只是路过的设备。请继续步行或驾车后重新生成。" + "没有定位标签、零售信标或穿戴设备持续随行。仅路过的住户标签和其他设备不列出。" + "下方仅提示持续伴随路径的信号源,路过的商店固定设备、住户标签等会略去。" + "本窗口接收到的“我的设备”仍列出,标记开启时不蜂鸣。" + "我的设备。会话开始后首次接收,并持续伴随路径。" + "我的设备,全程随行。" + "我的设备。" + "本窗口信号源的观察备注,与命名信号源采用相同的类型+MAC 对应关系,不是目录备注。" + " %1$s %2$s RSSI %3$s dBm(最低 %4$s/最高 %5$s)" + "手机接收时的 GPS 位置,并非对方设备或摄像头杆的位置。停留点为约 40 米内的聚集,点间移动为途经。不会在每条 Wi-Fi/BLE 记录中重复坐标。" + "GPS 标记已关闭。请在设置中开启,以记录接收到信号时你的位置。" + "GPS 标记已开启,但此窗口尚无定位。" + "总计:路径 %1$s,跨度 %2$s,%3$s 次定位。" + "街道名称为近似结果,不能视为匹配的摄像头或标签的位置。" + "没有路径段。" + "停留点:" + "%1$s. 停留 %2$s–%3$s UTC(%4$s)" + " 此处接收:%1$s 个接入点,%2$s 个 BLE" + " · 此停留点无带 GPS 标记的信号源(可能在首次接收后才开启标记)。" + "%1$s. 途经 %2$s–%3$s UTC,沿路径 %4$s" + "仅一个停留点,此窗口移动距离不足以区分不同地点。" + "在混合无线环境中移动(步行/乘车)。" + "可能是住宅或小办公室:固定接入点较少,BLE 有限。" + "密集公共/零售/街道环境:接入点及类似手机的随机 BLE 较多。" + "可能是有固定接入点及访客的建筑。" + "混合环境或采样不足。" + "%1$s(%2$s 个接入点,%3$s 个 BLE,%4$s 个持续接入点;移动 %5$s,跨度 %6$s。)" + " 信道" + " 隐藏" + " 持续" + " 随机地址" + "属于两个类别的信号源会在各类别分别计数。" + "Google Fast Pair 配对模式:" + "Google Fast Pair 配对模式:%1$s 个信号源。" + "未命名的极强信号(至少 −50 dBm):" + "随机 BLE 较多(%1$s),常见于手机,不能据此判断追踪。" + "隐藏 SSID 仍广播信标,可通过 BSSID 区分接入点。" + "此总结包含用于距离和随行测试的操作者 GPS 记录。" + "此总结包含广播经纬度的飞行器位置。" + "街道名称来自联网时的手机系统地理编码服务。" + "对外分享前请遮蔽敏感信息。" + "可能的跟随设备(会话开始后出现):%1$s。可暂停实时显示、打开详情,并在转折行走时观察 RSSI。不要禁用他人的标签。" + "可能随行的定位器:%1$s。可能自用,或开始前已在车内、包内、身上。请核对每个 MAC,不应直接认定为自用。" + "随行的零售信标(不常见,固定设备通常不移动):" + "。判断为跟随前,请核对是否为测试标签或徽章。" + "随行穿戴设备(通常自用或同行者佩戴):" + "开启 GPS 标记并步行超过 50 米,再生成监测总结进行随行测试。" + "开启 GPS 标记,继续移动超过 50 米,再生成监测总结。" + "列表繁忙时可用“实时 → 暂停”查看。会话信号较多时可关注定位器识别特征。" + "Wi-Fi 客户端侧(探测/客户端)仍需专用监听设备,Fieldwatch 无法看到。" + " 也有零售信标随行(不常见):" + " 穿戴设备随行(通常自用):" + "可能的跟随设备(会话开始后出现):%1$s。另有随行定位标签(自用或开始前已放置):%2$s。请核对每个 MAC。" + "可能的跟随设备(会话开始后出现):%1$s。请核对人员/车辆上的对应设备。" + "开启 GPS 标记并移动后,才能测试定位器是否随行。" + "GPS 移动不足(%1$s 米),无法测试随行,请继续移动后重新生成总结。" + "随行定位标签(自用或开始前已放置):%1$s。此窗口无新设备加入。请核对每个 MAC,不应直接认定为自用。" + "没有定位标签持续伴随路径。" + "此 15 分钟窗口没有识别特征命中,也没有定位器 GPS 随行。" + "此窗口没有定位标签、零售信标或穿戴设备明确持续伴随 GPS 路径。" + ",另有 %1$s 个" + " 广播位置:%1$s%2$s。" + "%1$s(%2$s,操作者手机)" + "%1$s(操作者手机,本次导出无街道名称)" + "%1$s(操作者手机)" + "广播泛洪" + "操作者路径" + " 已标记为我的设备" + " 已标记为我的设备" + " 随机地址" + "Fieldwatch 关注列表" + "%1$s 已出现\n%2$s %3$s\n%4$s dBm %5$s" + "飞行器" + "跨度 %1$s 公里" + "跨度 %1$s 米" + "路径 %1$s 公里" + "路径 %1$s 米" + "本次会话" + "停留" + "第二次广播位置" + "MAC 告警" + "识别特征告警" + "飞手" + "路径图例" + "观察备注:%1$s" + "%1$s 此停留点 %2$s 个信号源 — %3$s" + "额外关注 · %1$s" + "要点" + "Off Grid Pete LLC · 敏感监测信息" + "在线查询未成功,仅显示坐标。" + "无可用网络,跳过在线查询,仅显示坐标。" + "此手机无系统地理编码服务(需要 Google Play/网络定位),跳过在线查询,仅显示坐标。" + "在线查询已开启,但此会话没有可查询地名的 GPS 记录。" + "正在查询地名(%1$s/%2$s)…" + " · %1$s %2$s(未返回地名)" + "在线查询:系统地理编码服务返回 %1$s 个不同 GPS 网格的地名。街道名称来自手机网络地理编码服务,并非 Fieldwatch 云端,仅为近似结果。" + "已执行在线查询,但系统未返回街道名称,仅显示坐标。" + "停留 %1$s" + "途经起点" + "途经终点" + "路径起点" + "路径终点" + "一个目录识别特征" + "已标注" + "已匹配 %1$s。仍可用第二个识别特征进行双重标注(如商店 UUID、产品 OUI)。" + "仅此信号源" + "没有可分组的唯一广播 ID。随机地址、类似住户的名称和通用芯片会跳过。" + "日志或当前广播中没有其他 MAC 共享此%1$s。在此创建的识别特征主要会标注此地址。" + "特征组依据充分" + "可能的特征组" + "%2$s共享相同的%1$s。这是目录特征模式,并非只针对本 MAC。" + "%2$s共享相同的%1$s。样本较少,可能是目录特征组。" + "%1$s 个%2$s共享相同名称通配模式,并非住户 SSID。" + "%1$s 个 BSSID 共享相同厂商 IE,并非 WPS/P2P。" + "%1$s 个 BLE 广播设备共享相同服务 UUID。" + "%1$s 个 BLE 广播设备共享相同制造商数据前缀。" + "%1$s 个稳定 BSSID 共享相同 IEEE OUI,并非芯片模块前缀。" + "名称通配模式" + "厂商 IE" + "服务 UUID" + "制造商数据前缀" + "IEEE OUI" + "广播 ID" + "日志中 %1$s 个%2$s(当前广播 %3$s 个)" + "日志中 %1$s 个%2$s" + "当前广播 %1$s 个%2$s" + "日志中 %1$s 个%2$s匹配 %3$s。共享广播 ID,不是仅针对单个 MAC。修改名称或类别后保存。" + "接入点" + "接入点" + "广播设备" + "广播设备" + "最近 15 分钟使用实时内存集合(约 400 个信号源),命名会话最多保留 %1$s 个;两者统计范围不同。" + "时间窗口" + "本次会话" + "此手机接收到的信号源,按类型+MAC 区分。BLE 地址轮换会产生新条目,不是信号源定位。" + "仅本次会话(%1$s)" + "仅第二次会话(%1$s)" + "两次都有(%1$s)" + "本次信号源" + "第二次信号源" + "数量上限" + "实时内存约 400,会话 %1$s" + "%1$s 与 %2$s" + "FIELDWATCH 会话对比" + "会话对比" + "MAC 或识别特征告警各绘制一次。解码经纬度使用最后广播位置,其他标记使用最强接收点;数字对应路径图例中的位置。" + "两条路径叠加于北向上的图中。绿色为本次会话,灰蓝色为第二次。%1$s" + "北向上,线条表示本机路径。%1$s" + "操作者路径" + "内存中的最近 15 分钟(约 400 个信号源)" + "命名窗口(最多 %1$s 个)" + "%1$s:%2$s\n%3$s 个信号源 · %4$s\n" + "仅本次会话。" + "仅第二次会话。" + "仅本次会话" + "两次都有" + "仅第二次会话" + "(无)" + " 解码值发生变化:" + " 额外关注" + "%1$s 个仅本次 · %2$s 个仅第二次 · %3$s 个两次都有。" + "%1$s · %2$s 个标记为我的设备。" + "本次会话" + "第二次会话" + "任一窗口接收到的“我的设备”仍列出。" + "任一窗口信号源的观察备注,与命名信号源采用相同的类型+MAC 对应关系,不是目录备注。" + " 额外关注" + "仅本次会话" + "仅第二次会话" + "两次都有" + "Fieldwatch 扫描中" + "停止" + "被动 Wi-Fi 和蓝牙扫描状态" + "定位标签" + "零售信标" + "标牌" + "穿戴设备" + "监控设备" + "无人机" + "渗透测试设备" + "随身设备" + "公共安全设备" + "车辆" + "眼镜" + "音频设备" + "摄像头" + "温控器" + "门禁" + "健康设备" + "家居物联网" + "运营商/路由器" + "Mesh 网络" + "手机/电脑" + "其他" + "经典雷达" + "信号强度列表" + "时间线" + "混合视图+趋势图" + "按类别" + "设备类别" + "识别特征" + "类别+识别特征" + "按名称排序" + "按类别排序" + "Wi-Fi" + "BLE" + "省电" + "均衡" + "高性能" + "未匹配" + "关注列表中出现的识别特征和设备。蜂鸣单独播放。" + "这是一个业余项目,依据 MIT 许可按原样提供,使用风险由你承担。" + "检测、特征匹配、“随你移动”/“可能跟随”、监测总结和 AI 导出均为推测,不是身份确认、法律结论或完整无线捕获。关闭、休眠、使用随机地址、仅使用蜂窝网络或被系统隐藏的信号源可能不会出现。" + "你应自行负责应用的使用方式并遵守当地法律。在法律允许的最大范围内,Off Grid Pete LLC 不对使用本应用导致的间接、附带、特殊、后果性或惩罚性损害承担责任。" + "GPS 标记表示本机接收时的位置,并非对方信号源的位置,除非解码映射提供了设备自身广播的经纬度(Remote ID Location)。分享监测总结、会话对比、AI 导出、详情文本或日志可能将路径数据传出设备。开启 TAK/CoT 推送会向局域网发送标记,操作者需自行负责。" + "勾选并继续表示接受这些条款及 MIT 许可。中文用于说明,MIT 许可英文原文见下方。" + "命名会话“%1$s”(此窗口接收到的信号源;监测时仍受实时列表上限影响)。" + "内存中的实时集合(最近 15 分钟,上限约 400)。" + "%1$s\n\n%2$s\n\n%3$s\n\n%4$s\n\n本报告来自%5$s请勿将其用于涉及安全判断的场景。" + "%1$s\n\n%2$s\n\n%3$s\n\n%4$s\n\n本对比使用手机接收到的两个时间窗口,按类型+MAC 区分。BLE 地址轮换产生新条目。请勿用于涉及安全判断的场景。" + "## 免责声明(请在回答中复述)" + "请勿将 Fieldwatch、本段文本或分析用于涉及安全判断的场景。" + "请以此免责声明开始回答,不要给出安全建议。" + "%1$s 个 Wi-Fi · %2$s 个 BLE · %3$s 个已识别" + "所选语言暂无可用语音。请在 Android 文字转语音设置中安装对应语音数据。蜂鸣告警不受影响。" + "Actiontec" + "Actiontec FiOS / Frontier 网关。常见于 Verizon 设备,也可能匹配 Verizon 名称规则。" + "Adtran" + "Adtran 光纤网关。常见的 CenturyLink / Lumen / Quantum Fiber 代工设备。" + "后装胎压监测器" + "后装的 BLE 气门帽胎压传感器(TPMS1 / FBB0 系列)。解码字段可显示车轮、压力、温度、电量及警报。只是特征匹配,不能确认具体车辆。" + "车轮" + "1" + "2" + "3" + "4" + "传感器 ID" + "压力" + "温度" + "电量" + "警报" + "正常" + "无压力" + "AirLink" + "Sierra Wireless AirLink 车载或车队网关。常见于公共安全及商业车队。隐藏 SSID 仍可通过主板厂商匹配。" + "Sierra Wireless AirLink 车载网关。常见于美国警务、公共安全车队,也用于商业车队。隐藏或更名的 SSID 仍可匹配 Sierra Wireless 的 IEEE OUI。只是特征匹配,不能确认具体机构或车辆。请结合目视观察。" + "AirTies" + "AirTies 运营商 mesh 扩展器,用于运营商提供的家庭 mesh 网络。" + "Amazon" + "正在广播的 Amazon Echo、Fire 或 Kindle。并非所有 AmazonBasics 产品都会匹配。" + "Apple AirTag 标签" + "Apple AirTag 或“查找”配件。iPhone 也会发送“查找”广播以便被定位;除非名称为 AirTag,否则仍归入 Apple 设备。地址会轮换。" + "Apple 音频设备" + "AirPods、Beats,或广播 AirPlay 的 Apple TV / 音箱。不是 AirTag,也不是 iPhone 本身。" + "Apple 设备" + "广播连续互通功能(Nearby、Handoff、AirDrop、Instant Hotspot)的 iPhone、iPad 或 Mac。该无线电上的“查找”广播是手机自身的定位广播,不代表另一个 AirTag。不是 AirPods。" + "Arcadyan" + "Arcadyan 运营商网关或 mesh 设备。常见的 Verizon / T-Mobile 代工设备。" + "Arlo" + "Arlo 家用摄像头或其基站 Wi-Fi。属于消费级摄像头,不能据此判定为路边杆上的设备。SSID 更名后仍可通过主板厂商匹配。" + "Arris" + "Arris / SURFboard 有线网关。运营商更改 Wi-Fi 名称后,仍可通过主板厂商匹配。" + "Aruba" + "HPE Aruba 园区或 Instant On Wi-Fi 接入点。此处的 HPE BSSID 表示接入点,不能据此判定为服务器,也不是 HP 打印机。" + "Askey" + "Askey 5G / 运营商网关。常见的 T-Mobile Home Internet 代工设备。" + "ASSA ABLOY" + "ASSA ABLOY / HID Seos 或 Yale 门禁凭证。使用 HID Mobile Access 的手机可广播 Seos 名称。" + "ASUS" + "ASUS 家用路由器或 mesh 设备。ASUS 笔记本热点也可能匹配。" + "AT&T" + "AT&T 热点或网关(attwifi、ATT-GUEST、Pace 风格的出厂名称)。许多设备由 Pace / Arris 代工。" + "Audi MMI" + "Audi 车载 MMI 热点。车辆的出厂名称,不代表经销商。" + "August" + "August 智能门锁(现属 ASSA)。属于门锁,不能据此判定为摄像头。" + "AUMOVIO" + "AUMOVIO(原 Continental)模块提供的车载 Wi-Fi。属于车辆接入点,不能据此判定为运营商路由器。" + "Autel" + "Autel 无人机、遥控器或配置 Wi-Fi。飞行中的数字身份广播见 Remote ID 规则。" + "Avigilon" + "Avigilon 楼宇或市政摄像头,有些支持车牌识别。" + "Motorola Avigilon 摄像头 / 车牌识别设备。用于市政杆件和商业场所,可采集视频,部分设备还采集车牌。仅按名称匹配。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "Axis" + "Axis 摄像头,常见于市政杆件及公共视频监控。" + "Axis Communications 摄像头,常见于市政杆件及公共视频监控。仅按名称(AXIS-)匹配。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "Axon" + "Axon 执法记录仪、车载系统、底座或 TASER。未广播或仅使用 LTE 的设备不会出现。" + "Axon 执法记录仪、车载系统(Fleet)、底座或 TASER 设备。IEEE OUI 00:25:DF 属于 Axon Enterprise。Body 3/4 在佩戴时常通过该公共 OUI 广播 BLE。Axon Body 之类的名称只是特征,不能确认具体执法人员。未广播或仅使用 LTE 的设备不会出现。Axon 一词也可能匹配部分 ZTE 手机。请结合目视观察,不能据此确认身份。" + "Bee 录音挂件" + "Bee Pioneer 可穿戴录音设备(现属 Amazon),用于持续音频采集。" + "Bee Pioneer 可穿戴录音设备(Amazon),BLE 服务为 03d5d5c4。它用于记录对话,但匹配结果不能证明有人正在录制你。未匹配也不能排除录音(设备关闭或使用其他品牌时可能漏检)。请结合目视观察。" + "Belkin" + "Belkin 家用路由器。部分 Linksys Velop 设备也会归入此类。" + "BlipTrack" + "BLIP Systems BlipTrack 路边蓝牙 / Wi-Fi 行程时间传感器。用途与 BlueTOAD Spectra 类似:在两个地点采样路过手机和车载无线电,以估算速度。未广播或仅使用以太网的机柜可能不会出现。只是特征匹配,不能确认具体机柜。" + "Blue Maestro" + "Blue Maestro Tempo Disc 温湿度记录仪。解码字段可显示版本、电量及温度。" + "版本" + "电量" + "记录间隔" + "已存记录" + "温度" + "湿度" + "BlueTOAD Spectra" + "Iteris 路边蓝牙行程时间读取器(Vantage Velocity,现称 BlueTOAD Spectra / Spectra CV)。采样经过车辆中的手机、耳机和车载蓝牙,在两个地点匹配相同 ID 以估算速度,范围约 100 米。属于采样,不能得到完整车辆数量。未广播或仅使用以太网的机柜可能不会出现。Spectra CV 还使用 5.9 GHz C-V2X,Fieldwatch 无法接收。Iteris 的 IEEE OUI 也可能匹配该品牌的其他路边设备。只是特征匹配,不能确认具体机柜。" + "BMW" + "BMW 手机钥匙或车载热点。出厂的 BMW_ Wi-Fi 来自车辆,不能据此判定为经销商展厅。" + "Bose" + "Bose 耳机或音箱。Quiet Charge / QC 耳塞即使放在盒内也会广播。" + "Brilliant Frame" + "Brilliant Labs Frame AR 眼镜。" + "Brilliant Labs Frame AR 眼镜,BLE 服务为 7A230001。不能据此证明有人正在录制。未匹配也不能排除录制(设备关闭或使用其他品牌时可能漏检)。请结合目视观察。" + "Bruce" + "Bruce ESP32 渗透测试固件。默认配置接入点为 BruceNet。同类板卡也用于 DIY;更名的设备和恶意门户 SSID 可能漏检。" + "Bruce ESP32 渗透测试固件的默认接入点(BruceNet)。同类板卡也用于 DIY。恶意门户 SSID 看起来可能只是普通 Wi-Fi,无法匹配此规则。不能据此证明发生了攻击。请结合目视观察。" + "Buffalo" + "Buffalo AirStation 家用路由器。" + "BYD" + "BYD 手机钥匙或车载 BLE。只是特征匹配,不能确认具体车型。" + "Calix" + "Calix 光纤网关(GigaSpire 类)。名称更改后仍可通过主板厂商匹配。" + "Cambium" + "Cambium 或 IgniteNet 室外 / 无线运营商接入点。" + "CARLINK" + "后装 CarPlay / Android Auto 转接器热点(CARLINK-)。属于车机转接器,不能据此判定为车辆自身的调制解调器。" + "CarPlay" + "车载 CarPlay / Alpine 车机热点。属于仪表台设备的出厂名称,不能据此判定为运营商设备。" + "CenturyLink" + "CenturyLink 网关的出厂名称。更名的光纤 Wi-Fi 会漏过此规则。" + "Chevrolet 车载热点" + "Chevrolet 车载热点(myChevrolet)。Cadillac / GMC / Buick 归入 GM 热点规则。不能据此判定为经销商。" + "Chipolo" + "Chipolo 寻物标签(Find Hub /“查找”),可挂在钥匙或包上。" + "Cisco" + "Cisco Aironet / Catalyst / Business 接入点。Fieldwatch 接收接入点信标,不能据此发现手机或交换机。Meraki 有独立规则。" + "Compex" + "Compex 车载或机构 Wi-Fi 接入点。同类主板也用于其他 Compex 无线电设备。" + "Compex Wi-Fi 接入点。美国部分公共安全机构将其用于车辆。同样的 IEEE OUI 也出现在其他 Compex 无线电设备上。只是特征匹配,不能确认具体机构。请结合目视观察。" + "Cradlepoint" + "Cradlepoint 车载路由器(IBR / R 系列)。常见于警务、急救、公用事业及商业车队。隐藏 SSID 仍可通过主板厂商匹配。" + "Ericsson Cradlepoint 车载路由器(IBR / R 系列)。常见于美国警务和公共安全车队,也用于公用事业、急救和商业车队。隐藏或更名的 SSID 仍可匹配 CradlePoint 的 IEEE OUI。只是特征匹配,不能确认具体机构或车辆。请结合目视观察。" + "Crazyflie" + "Bitcraze Crazyflie 实验室无人机,名称以 Crazyflie 开头。Bitcraze 的公司 ID 未用作匹配规则。" + "Cudy" + "Cudy 便携或家用路由器。" + "D-Link" + "D-Link 家用路由器。更名的 SSID 仍可通过主板厂商匹配。" + "Dahua" + "Dahua 摄像头,常见于商业视频监控及部分公共杆件。" + "Dahua 摄像头,常见于商业视频监控及部分公共杆件。仅按名称匹配。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "Dexcom" + "Dexcom 连续血糖监测器(G6 / G7)。只是特征匹配,不能确认具体患者。" + "Digital Ally" + "Digital Ally 执法记录仪或车载摄像头(FirstVu / EVO)。未广播或仅使用 LTE 的设备不会出现。" + "Digital Ally 执法记录仪或车载设备(FirstVu / EVO 系列)。IEEE OUI 00:23:BD 属于 Digital Ally, Inc.,对应摄像设备厂商。巡逻设备可能仅使用 LTE 而不广播。只是特征匹配,不能确认具体执法人员。请结合目视观察,不能据此确认身份。" + "DJI" + "DJI 飞行器、遥控器或配置 Wi-Fi。手持 Osmo 摄像头有独立规则。飞行中的数字身份广播见 Remote ID 规则。" + "型号" + "Osmo Action 1" + "Osmo Action 2" + "Osmo Action 3" + "Osmo Action 4" + "Osmo Action 5 Pro" + "Osmo 360" + "Osmo Action 6" + "Osmo Nano" + "Osmo Pocket 3" + "Osmo Pocket 4" + "Osmo Pocket 4 Pro" + "Mavic 3" + "Neo 2" + "dormakaba" + "dormakaba、Saflok 或 Oracode 酒店 / 商用门锁。" + "DULT 定位标签" + "IETF DULT 位置广播(不受欢迎的定位追踪器检测)。Chipolo、Pebblebee、moto tag 及其他合作标签可能同时匹配两类规则。靠近主人与离开主人的状态由载荷中的一位表示。分离模式下 MAC 可保持约一天。只是特征匹配,不能确认具体包裹。" + "网络 ID" + "模式" + "离开主人" + "靠近主人" + "已与主人分离,地址可保持约一天。" + "靠近主人。若整个监测时段都与你一起,通常是你自己的标签;若在监测开始后才首次出现,更可能来自带着自己钥匙进入的人。" + "DWnet" + "DWnet 消费级 / 中小企业接入点。云管理 SSID 通常使用住宅名称,因此匹配依据是主板厂商。" + "ecobee" + "ecobee 温控器。房间传感器也可能匹配同一规则。Premium 型号使用 BLE 进行配置或 Spotify 连接。" + "EcoWater" + "EcoWater / 软水机配置 Wi-Fi,属于家庭管道物联网设备。" + "Edgecore" + "Edgecore 园区 / 开放 Wi-Fi 接入点。云管理 SSID 通常使用场所名称,因此匹配依据是主板厂商。" + "Eero" + "Amazon Eero mesh 设备,不能据此判定为 Echo 音箱。" + "电子价签" + "使用蓝牙 ESL 服务的电子货架标签(商店价签)。大部分 Hanshow / SES-imagotag 标签使用专有无线电,不会匹配此规则。" + "EnGenius" + "EnGenius Cloud / ECW 接入点。" + "Epson" + "Epson EcoTank 或 WorkForce 打印机(Wi-Fi Direct 或 BLE)。" + "Estimote" + "商店或场馆定位信标 / 贴片。解码字段可区分 Nearable 与遥测。" + "帧" + "Nearable" + "遥测" + "eufy Security" + "eufy 家用摄像头或标签,常见于住宅。属于消费级设备,不能据此判定为路边杆上的摄像头。" + "Even G1" + "Even Realities G1 眼镜,按 Even Realities 公司 ID 或 Even G1 名称匹配。Nordic UART 太普遍,未用作规则。" + "Even Realities G1 眼镜,按该公司的 ID 或 Even G1 名称匹配。不能据此证明正在录制。未匹配也不能排除录制(关闭、更名或配对后不广播时可能漏检)。请结合目视观察。" + "Extreme" + "Extreme Networks 园区接入点。云管理 SSID 通常使用场所名称,因此匹配依据是主板厂商。" + "快速配对设备" + "广播 Fast Pair 的 Android 配件(耳塞、手表、手机)。配对模式用于点按配对;较长的广播通常来自已经配对的附近设备。不能据此识别人。可通过筛选隐藏这些状态标签。" + "Fieldy" + "Fieldy 挂件,可穿戴 AI 笔记设备。" + "Fieldy 可穿戴 AI 笔记挂件,可记录对话并转写文字。不能据此证明有人正在录制你。未匹配也不能排除录制(关闭、配对后不广播或使用其他品牌时可能漏检)。请结合目视观察。" + "Fitbit" + "Fitbit 手表或运动追踪器。广播仅包含身份相关信息,不包含步数。" + "Flipper Zero" + "Flipper Zero(或其他 Flipper Devices 产品)。默认名称以 Flipper 开头,自定义固件可隐藏它。" + "Flipper Zero(或其他 Flipper Devices 产品)BLE。默认名称以 Flipper 开头,较新设备使用 IEEE OUI 0C:FA:22。自定义固件可更改名称及 MAC。不能据此证明发生了攻击。未匹配也不能排除设备存在(蓝牙关闭或更名时可能漏检)。请结合目视观察。" + "Flock Safety 摄像头" + "Flock 风格的路边自动车牌识别 / 摄像杆。匹配 IEEE B4:1E:52 或 Flock-* / FLCK / Condor / Falcon / Sparrow 名称。现有杆件通常不广播 Wi-Fi 和 BLE。LiteOn 模块前缀有独立规则,不列入“额外关注”。" + "Flock 风格的路边自动车牌识别 / 摄像杆,可读取车牌并用于定位车辆。IEEE B4:1E:52 或 Flock-* SSID 是较强线索。现有杆件通常不广播 Wi-Fi 和 BLE。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "FOBO TPMS" + "FOBO 后装 BLE 胎压传感器,可用于摩托车或汽车。只是特征匹配,不能确认具体车辆。" + "Ford" + "Ford 或 Lincoln 手机钥匙 / 车载信息娱乐系统,不能据此判定为经销商 Wi-Fi 名称。" + "Fortinet" + "Fortinet FortiAP / FortiWiFi 园区或分支机构接入点。" + "Franklin" + "Franklin 5G / LTE 家庭网络网关,通常由运营商提供。访客 SSID 仍可匹配。" + "Friend 挂件" + "Friend AI 项链,会聆听的可穿戴伴侣设备。" + "Friend 挂件 / 项链,BLE 服务为 1a3fd0e7。它会聆听对话,但不能据此证明有人正在录制你。未匹配也不能排除录制(关闭或使用其他品牌时可能漏检)。请结合目视观察。" + "FS 外置电池" + "通常与 Flock 风格摄像杆配套的外置电池组。名称匹配是较强线索。现有杆件通常不广播 Wi-Fi 和 BLE。" + "通常与 Flock 风格摄像头相关,属于杆件上的外置电池组。名称匹配是较强线索。现有杆件通常不广播 Wi-Fi 和 BLE。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "Garmin" + "Garmin 手表、自行车码表或 inReach 通讯器。" + "Genetec AutoVu" + "Genetec AutoVu 停车场或路边车牌读取器。" + "Genetec AutoVu 属于市政 / 停车场自动车牌识别系统,可读取停车场及路边的车牌。设备广播时仅按名称匹配。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "GhostESP" + "GhostESP ESP32 审计固件。默认配置接入点为 GhostNet。同类板卡也用于 DIY;更名的设备可能漏检。" + "GhostESP ESP32 审计固件的默认接入点(GhostNet)。同类板卡也用于 DIY。不能据此证明发生了攻击。未匹配也不能排除设备存在(更名时可能漏检)。请结合目视观察。" + "GL.iNet" + "GL.iNet 便携路由器。不带 GL 名称的芯片模块主板可能漏检。" + "GM 车载热点" + "GM 车载热点(Cadillac、GMC、Buick)。myChevrolet 有独立规则。BSSID 通常是随机地址。" + "Goodyear TPMS" + "Goodyear 智能轮胎 BLE,不是大多数汽车中使用 315/433 MHz 的气门杆胎压监测器。只是特征匹配,不能确认具体车辆。" + "Google" + "Pixel 手机或 Chromecast。Fast Pair 配件有独立规则。" + "Google Find Hub" + "Google Find Hub /“查找我的设备”网络标签(Chipolo、Pebblebee、moto tag 及其他合作品牌)。帧可表示靠近或分离状态。分离模式下 MAC 可保持约一天,其他情况下地址会轮换。只是特征匹配,不能确认具体包裹。" + "模式" + "附近" + "离开主人" + "靠近主人。若整个监测时段都与你一起,通常是你自己的标签;若在监测开始后才首次出现,更可能来自带着自己钥匙进入的人。" + "已与主人分离,地址可保持约一天。" + "临时 ID" + "Google Wifi" + "Google Wifi / Nest Wifi mesh 设备,不能据此判定为 Pixel 手机或 Nest 摄像头。" + "GoPro" + "GoPro 运动相机。解码字段可显示唤醒、Wi-Fi 接入点或配对状态。" + "数据结构" + "处理器" + "休眠" + "唤醒" + "Wi-Fi 接入点" + "关闭" + "开启" + "配对" + "否" + "是" + "新媒体内容" + "否" + "是" + "型号" + "HERO9 Black" + "HERO10 Black" + "HERO11 Black" + "HERO11 Black Mini" + "HERO12 Black" + "MAX 2" + "HERO13 Black" + "HERO (2024)" + "MISSION 1 Pro" + "LIT HERO" + "MISSION 1" + "媒体传输" + "否" + "可用" + "goTenna" + "goTenna Mesh 或 Pro 配套无线电,通过 BLE 配对。mesh 本身使用 UHF,Fieldwatch 无法接收。Pro 面向机构销售。只是特征匹配,不能确认具体操作人员。" + "Govee" + "Govee 灯或温湿度计。灯通常只发送名称;温湿度计可在下方解码温度、湿度及电量。" + "温度" + "湿度" + "电量" + "Grandstream" + "Grandstream GWN 办公室接入点。" + "Haiku 吊扇" + "Big Ass Fans Haiku 或 Mammoth 吊扇。" + "Hak5 Pineapple" + "Hak5 WiFi Pineapple 配置或管理接入点。PineAP 克隆的咖啡馆 SSID 看起来像普通 Wi-Fi,不会匹配此规则。" + "Hak5 WiFi Pineapple 配置或管理接入点(Pineapple_XXXX)。这是管理无线电,不能涵盖 PineAP 冒充的所有 SSID,后者看起来可能只是普通咖啡馆 Wi-Fi。未匹配也不能排除设备存在(更名或仅克隆网络时可能漏检)。只是特征匹配,不能确认身份。请结合目视观察。" + "Hanwha Wisenet" + "Hanwha Vision / Wisenet 摄像头(原 Samsung Techwin)。常见于商业视频监控及部分公共杆件。" + "Hanwha Vision / Wisenet 摄像头,常见于商业视频监控及部分公共杆件。*_WISENET 配置 SSID 是较强线索;IEEE 00:09:18 属于 Samsung Techwin。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "Hatch" + "Hatch Rest / Restore / Mini 白噪声机,通常用于婴儿房或卧室。" + "Hayden AI" + "安装在公交车或其他车辆上,用于停车与交通执法的 Hayden AI 摄像头。" + "Hayden AI 摄像头安装在公交车及市政车辆上,可采集视频及车牌。广播时仅按名称匹配。仅使用 LTE 的设备不会出现。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "Helium" + "正在广播名称的 Helium / LoRaWAN 热点。" + "Hikvision" + "Hikvision 摄像头,常见于商业视频监控及部分公共杆件。" + "Hikvision 摄像头,常见于商业视频监控及部分公共杆件。仅按名称匹配。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "Hitron" + "Hitron 有线网关,常见的 Xfinity 代工设备,也可能匹配 Xfinity 名称规则。" + "DIY BLE 串口模块" + "使用默认名称广播的廉价 DIY UART 模块(HM-10、JDY、CC41、ESP32 BLE)。常见于打印机、车辆和 DIY 项目。Fieldwatch 无法接收经典蓝牙 HC-05/HC-06。" + "这些默认 BLE 串口名称来自廉价 DIY 模块。一些油泵或 ATM 叠加装置曾使用类似板卡,使附近人员可通过蓝牙取走数据。同样的模块也用于打印机、车辆和 DIY 项目。如果此无线电在读卡器旁信号很强,请谨慎对待并结合目视观察。不能据此证明存在盗刷装置。未匹配也不能排除装置存在(更名、使用经典蓝牙 HC-05/HC-06 或蜂窝网络时可能漏检)。Fieldwatch 不会连接设备或尝试默认 PIN,也无法接收经典蓝牙 HC-05/HC-06。" + "Holy Stone" + "Holy Stone 无人机,名称以 HolyStone 或 Holy Stone 开头。通用摄像头名称不会匹配,更名的网络可能漏检。" + "Honda" + "Honda 或 Acura 手机钥匙 / 车载信息娱乐系统。" + "Honeywell Home" + "Honeywell Home / Lyric / Resideo 温控器(含 Amazon Smart Thermostat)。不能据此判定为 Honeywell 工业或烟雾设备。T9/T10 房间传感器使用 900 MHz,不是 BLE。" + "Honeywell Xenon HC" + "Honeywell Xenon 医疗条码扫描器或充电底座(白色、可消毒),用于诊所 / 医院。不是家用温控器,也不是仅供仓储使用的 Xenon 型号。" + "HOVERAir" + "HOVERAir 袖珍自拍无人机。飞行中的数字身份广播(PRO / PROMAX)见 Remote ID 规则。" + "HP" + "HP 家用 / 办公打印机(ENVY、HP-Print),通常属于办公设备广播。不是 HPE Aruba 园区 Wi-Fi。" + "Huawei" + "Huawei 家用路由器,或使用公共 Huawei 地址的手机热点。随机地址热点需要出厂 SSID 才能匹配。不是 Honor。" + "Hubsan" + "Hubsan 无人机,名称以 Hubsan 开头。更名的网络可能漏检,通常没有 Remote ID 身份广播。" + "Huf" + "Huf 胎压传感器或车辆门禁设备(门把手 / PEPS),不只限于气门杆。" + "HUMAX" + "HUMAX 5G 或有线网关,现场常见于 T-Mobile Home Internet。" + "Hyundai" + "Hyundai 或 Genesis 手机钥匙 / 车载信息娱乐系统,不能据此判定为经销商 Wi-Fi 名称。" + "iBeacon" + "通用近距离信标。商店、购物篮、电视和车辆都可能发送这种 Apple 格式。密集商场中可将其静音。" + "igloohome" + "igloohome 钥匙盒或智能门锁。" + "Inseego" + "Inseego 5G / MiFi 热点。" + "Insta360" + "Insta360 运动 / 全景相机。广播名称通常为型号加序列号。属于消费级相机,不能据此判定为杆件设备。" + "Jaguar Land Rover" + "Jaguar、Land Rover 或 Range Rover 手机钥匙 / 车载信息娱乐系统。" + "JBL / Harman" + "JBL、Harman Kardon 或部分车载音频 BLE,可能来自耳机、音箱或车机。" + "Kevo" + "Kwikset Kevo 智能门锁。" + "Kontakt.io" + "商店或场馆定位信标。解码字段可显示电量、发射功率及移动状态。" + "数据包" + "位置" + "电量" + "发射功率" + "信道" + "型号 ID" + "移动" + "静止" + "移动中" + "Kwikset" + "Kwikset 智能门锁。Kevo 有独立规则。" + "LG webOS TV" + "LG webOS 电视。未命名的 LG 无线电也可能匹配此规则。" + "Limitless 挂件" + "Limitless / Rewind AI 挂件,可穿戴对话录音设备。" + "Limitless 可穿戴录音挂件,BLE 服务为 632de001。它用于记录对话,但不能据此证明有人正在录制你。未匹配也不能排除录制(关闭、配对后不广播或使用其他品牌时可能漏检)。请结合目视观察。" + "Linksys" + "Linksys 或 Velop 家用 mesh 设备。部分 Velop 使用 Belkin 主板。" + "LiteOn 摄像头无线电" + "常见于摄像头主板的 Wi-Fi 模块前缀(LiteOn 及类似模块),不是 Flock 的 IEEE 地址块。门铃和其他代工设备也使用这些芯片。Flock 名称或 B4:1E:52 归入 Flock Safety 摄像头。" + "Lockly" + "Lockly 智能门锁,通常处于 BLE 配置状态。" + "Logitech" + "Logitech 鼠标、键盘或网络摄像头,通常属于办公设备广播。" + "Luxul" + "Luxul / Legrand 小型企业接入点。" + "LVT LiveView" + "LiveView Technologies(LVT)太阳能监控拖车。大多数设备仅使用蜂窝网络,不会出现。" + "LVT / LiveView 太阳能摄像拖车,用于停车场、工地及部分城市公园,可采集视频,部分还采集车牌。大多数使用蜂窝网络而不广播 Wi-Fi/BLE。LiveView 或 LVT- 名称只是特征,不能确认具体拖车。请结合目视观察。" + "Marauder / Deauther" + "使用默认名称的 ESP32 Marauder 或 Spacehuhn 风格 Wi-Fi 断连器。同类板卡也用于 DIY;更名的设备可能漏检。" + "ESP32 Marauder 或 Spacehuhn 风格 Wi-Fi 断连器的默认名称。同类板卡也用于 DIY。不能据此证明发生了攻击。未匹配也不能排除设备存在(更名时可能漏检)。请结合目视观察。" + "Master Lock" + "Master Lock 蓝牙挂锁。" + "Meraki" + "Cisco Meraki 园区 / 云管理接入点。场所 SSID 更名后仍可通过主板厂商匹配。不能据此判定为摄像杆。" + "Mercedes MBUX" + "Mercedes MBUX 车载热点,使用车辆的出厂名称。" + "MeshCore" + "MeshCore LoRa 配套无线电,用于离网文本 / 位置通信,不使用蜂窝网络。仅按名称匹配;Nordic UART UUID 广泛用于 ESP32 串口板卡,未用于此规则。" + "Meshtastic" + "Meshtastic LoRa mesh 节点,用于离网文本 / 位置通信,不使用蜂窝网络。" + "Microsoft 设备" + "广播 Swift Pair /“附近共享”的 Windows PC、Surface 或 Xbox。" + "MikroTik" + "MikroTik RouterOS 接入点或便携路由器。" + "Minew" + "Minew 定位或资产信标,通常还在同一无线电上发送 iBeacon 或 Eddystone。" + "Miovision" + "Miovision 路口 / 交通摄像头(SmartLink / Scout)。" + "Miovision 路口交通摄像头,可采集视频,部分还采集车牌。广播时仅按名称匹配。许多设备仅使用蜂窝网络而不广播。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "Mist" + "Juniper Mist 园区接入点。云管理 SSID 通常使用场所名称,因此匹配依据是主板厂商。" + "Mojo" + "Mojo / Arista Cognitive Wi-Fi 园区接入点。" + "Motive" + "卡车上的 Motive(KeepTruckin)电子行车日志设备 / 车队 Wi-Fi。" + "Motorola Vigilant" + "机构及停车场使用的 Motorola Vigilant 车牌读取器。" + "Motorola Vigilant 属于车牌识别设备,用于机构及停车场。广播时仅按名称匹配。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "myQ" + "Chamberlain myQ 车库门集线器。" + "Nespresso" + "Nespresso 咖啡机(Vertuo / Barista)。" + "Nest" + "Google Nest 家用摄像头,属于消费级设备。Nest 温控器有独立规则。" + "Nest 温控器" + "Nest 温控器。部分代际的温度传感器也可能匹配此规则。不能据此判定为 Nest 摄像头。" + "Nest Weave" + "Nest Protect、温控器或其他通过 BLE 使用 Weave 的家用设备,地址随机化。解码字段可显示产品及配对状态。" + "产品" + "Nest Learning 温控器(第 1/2 代)" + "Nest Learning 温控器底板(第 1/2 代)" + "Nest Learning 温控器(第 3 代)" + "Nest Learning 温控器底板(第 3 代)" + "Nest Protect(第 1 代)" + "Nest Heat Link 底板" + "Nest Heat Link" + "Nest Pinna" + "Nest Protect(第 2 代)" + "Nest Learning 温控器(第 3 代,欧洲版)" + "Nest Learning 温控器底板(第 3 代,欧洲版)" + "Nest Flintstone" + "Nest Cam IQ 室内版" + "Nest Hello" + "Nest Heat Link(第 2 代)" + "Nest Cam IQ 室外版" + "Nest Quartz2" + "Nest Black Quartz" + "Nest Detect" + "Nest Guard" + "Nest Guard 底板" + "Nest Antigua" + "Nest Rose Quartz" + "Nest Moonstone" + "Nest Kryptonite" + "厂商" + "Nest Labs" + "Yale" + "Weave 设备 ID" + "配对" + "未配对" + "已配对" + "NETGEAR" + "NETGEAR 或 Orbi 家用路由器 / mesh 设备。更名的 SSID 仍可通过主板厂商匹配。" + "Nissan" + "Nissan 或 Infiniti 手机钥匙 / 车载信息娱乐系统。" + "Nokia" + "Nokia 运营商网关或小型基站,不能据此判定为 Nokia 手机。" + "Novatel Wireless" + "Novatel Wireless / Inseego 车载无线电。同样的前缀也出现在部分消费级 MiFi 热点上。" + "Novatel Wireless / Inseego OUI 28:80:A2,曾在公共安全车载接入点研究中被报告。同样的前缀也出现在部分 Inseego 消费级 MiFi 无线电上。只是特征匹配,不能确认具体机构。请结合目视观察。" + "Nuki" + "Nuki 智能门锁或开门器,可改装在欧洲式锁芯上。" + "Omi" + "Omi / OpenGlass 可穿戴录音设备或摄像眼镜。Arduino 默认的 19B10000 太普遍,未用作规则。" + "Omi 挂件或 OpenGlass 摄像眼镜,按名称或 BLE 服务 23ba7924 匹配。它可录制音频,OpenGlass 还带有摄像头。不能据此证明有人正在录制你。未匹配也不能排除录制(关闭、更名或 DIY 板卡使用其他名称时可能漏检)。请结合目视观察。" + "Omron" + "Omron 血压计或体成分秤,属于家用 / 诊所健康设备,不能据此判定为 Omron 工业设备。" + "OpenWrt" + "刷入 OpenWrt 且从未更名的便携 / DIY 路由器出厂 SSID。" + "Orbit B-hyve" + "Orbit B-hyve 水管定时器 / 灌溉设备。" + "Osmo" + "DJI Osmo 手持 / 运动相机(Action、Pocket、360、Nano),不能据此判定为飞行中的 DJI 无人机。" + "型号" + "Osmo Action 1" + "Osmo Action 2" + "Osmo Action 3" + "Osmo Action 4" + "Osmo Action 5 Pro" + "Osmo 360" + "Osmo Action 6" + "Osmo Nano" + "Osmo Pocket 3" + "Osmo Pocket 4" + "Osmo Pocket 4 Pro" + "Mavic 3" + "Neo 2" + "Oura" + "Oura 健康戒指,佩戴时持续使用 BLE。只是特征匹配,不能确认具体人员。" + "Pacific TPMS" + "Pacific Industrial 原厂轮胎电子设备,部分较新车辆使用 BLE 胎压监测。" + "Panasonic i-PRO" + "Panasonic i-PRO 摄像头或 Arbitrator 车载系统。Panasonic 电视及手机使用其他名称。" + "Panasonic i-PRO 摄像头或 Arbitrator 车载视频系统,用于楼宇及部分巡逻车,可采集视频,部分还采集车牌。仅按名称(i-PRO / Arbitrator)匹配,并不涵盖所有 Panasonic 无线电。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "Parrot" + "Parrot ANAFI、Bebop 或 Skycontroller。飞行中的数字身份广播见 Remote ID 规则。不能据此判定为 Parrot 车载套件。" + "Paxton" + "Paxton / Net2 门禁读取器或控制面板。" + "Pebblebee / moto tag" + "Pebblebee 或 Motorola moto tag 寻物标签,可挂在钥匙或包上。" + "Penguin" + "Penguin Flock 系列外置电池。XUNTONG BLE 厂商 ID 是常见指纹,Penguin* 名称来自较旧固件。较新电池组通常广播 10 位数字名称。厂商数据中包含 TN 序列号时,解码字段可显示它。" + "Penguin 是 Flock 系列外置电池(XUNTONG 厂商 ID)。名称匹配通常来自较旧固件,唯一性较低。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "载荷中的 MAC" + "序列号" + "PeopleNet" + "PeopleNet 卡车电子行车日志设备 / 车队 Wi-Fi,与 Motive 分开归类。" + "Peplink" + "Peplink / Pepwave 便携或分支机构路由器。" + "手机热点" + "使用出厂名称的手机个人热点(AndroidAP、Galaxy、Pixel)。自定义热点名称可能漏检。iPhone 热点仍归入 Apple 设备。" + "Pigvision" + "Pigvision Flock 系列 / 路边摄像头名称。" + "Pigvision 是 Flock 系列 / 路边摄像头名称。仅按名称匹配。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "Plaud Note" + "Plaud Note / NotePin AI 会议录音设备。" + "Plaud Note / NotePin AI 录音设备,用于记录会议。不能据此证明有人正在录制你。未匹配也不能排除录制(关闭、更名或使用其他品牌时可能漏检)。请结合目视观察。" + "Plume" + "Plume SuperPod / HomePass mesh 设备。运营商品牌节点(如 xFi)通常使用运营商的代工主板。" + "Pokemon GO Plus" + "Nintendo Pokémon GO Plus 或 Plus + 腕部配件,不能据此判定为 Joy-Con 或 Switch。" + "Porkchop" + "Cardputer 或 Cheap Yellow Display 上的 Porkchop 渗透测试固件。默认接入点名称为 PORKCHOP。冒充 Apple / Android 的 BLE 垃圾广播不归入此规则。" + "M5PORKCHOP / Porkchop 袖珍 Wi-Fi 渗透测试固件(Cardputer 或 Cheap Yellow Display)。默认 CYD 远程接入点名为 PORKCHOP。BACON 模式伪造的接入点包含厂商 IE 50:52:4B。未匹配也不能排除设备存在(更名、仅被动监听或没有接入点时可能漏检)。只是特征匹配,不能确认身份或证明发生了攻击。请结合目视观察。" + "Porsche" + "Porsche 手机钥匙或出厂 Porsche_WLAN 热点,与 Volkswagen 分开归类。" + "Potensic" + "Potensic 无人机,含 ATOM。名称以 Potensic 开头,仅 ATOM 名称不会匹配。更名的网络可能漏检,通常没有 Remote ID 身份广播。" + "Pwnagotchi" + "Pwnagotchi 风格 Wi-Fi 握手采集器。经典设备使用特殊 BSSID 及 pwnagotchi 名称。" + "Pwnagotchi 风格 Wi-Fi 握手采集器,经典设备的信标使用 BSSID de:ad:be:ef:de:ad。pwnagotchi 名称只是特征,不能据此证明发生了攻击。请结合目视观察。" + "RadiaCode" + "RadiaCode 手持辐射探测器。" + "RAK WisGate" + "RAKwireless WisGate LoRaWAN 网关配置接入点(RAK7268_XXXX 等)。接入以太网后通常不再广播。" + "Raven / ShotSpotter" + "Flock Raven 或 ShotSpotter 风格声学枪声传感器,通常与摄像头一起安装在杆件上。BLE UUID 3100–3500 属于 Raven 无线电。XUNTONG 电池厂商 ID 归入 Penguin,不属于此规则。现有 Flock 系列杆件通常不广播 Wi-Fi 和 BLE。" + "Ray-Ban / Meta 眼镜" + "Ray-Ban Meta / Oakley Meta 智能眼镜,或 Quest / 其他 Meta 可穿戴设备。" + "Meta / Luxottica BLE,常见于 Ray-Ban Meta 智能眼镜。同样的公司 ID 也用于 Quest 头显及其他 Meta 可穿戴设备。不能据此证明有人正在录制。未匹配也不能排除录制(配对后不广播、休眠或使用其他品牌时可能漏检)。请结合目视观察。" + "RayNeo" + "RayNeo 智能眼镜。仅当名称以 RayNeo 开头且广播使用 TCL 公司 ID 时匹配。不带该名称的 TCL 手机不会匹配。" + "RayNeo 智能眼镜,名称必须以 RayNeo 开头,广播必须使用 TCL 公司 ID。不带该名称的 TCL 手机不会匹配。不能据此证明正在录制。未匹配也不能排除录制(关闭、更名或配对后不广播时可能漏检)。请结合目视观察。" + "Rekor" + "Rekor 公路或交通车牌读取器。" + "Rekor 属于公路 / 交通自动车牌识别设备,可读取道路及检查站的车牌。仅按名称匹配。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "Remote ID" + "飞行中无人机的数字身份广播(ASTM / FAA Remote ID)。解码字段可显示 ID、位置、航向及操作人员。法国 Direct Remote ID 厂商 IE 也会归入此规则,但该身份广播不提供位置或飞手信息。只是特征匹配,不能确认注册号。原生 Android 上的 Wi-Fi Remote ID 经常漏检。" + "应用代码" + "Open Drone ID" + "计数器" + "消息" + "基本 ID" + "位置" + "认证" + "自身 ID" + "系统" + "操作人员 ID" + "消息包" + "协议" + "ID 类型" + "无" + "序列号(CTA-2063)" + "民航注册号" + "UTM UUID" + "会话 ID" + "无人机类型" + "无" + "固定翼飞机" + "直升机 / 多旋翼" + "旋翼机" + "混合升力机" + "滑翔机" + "飞艇" + "其他" + "无人机系统 ID" + "状态" + "未声明" + "地面" + "空中" + "紧急状态" + "远程识别故障" + "航向" + "水平速度" + "垂直速度" + "纬度" + "经度" + "气压高度" + "椭球高度" + "高度" + "自身 ID" + "操作人员纬度" + "操作人员经度" + "操作人员 ID" + "Reolink" + "Reolink 家用 / 小型企业摄像头,属于消费级设备,不能据此判定为路边杆上的摄像头。" + "商用 LED 显示牌" + "BLE LED 消息显示屏。广播名称是显示的文字,不是产品名称。" + "Reveal Media" + "Reveal Media / BodyWorn 摄像头,常见于英国及部分美国机构。未广播的设备不会出现。" + "Reveal Media 执法记录仪(D 系列 / BodyWorn)。广播时仅按名称匹配。巡逻设备可能不广播。只是特征匹配,不能确认具体执法人员。请结合目视观察。" + "Rhombus" + "Rhombus 云摄像头。未注册或离线时 BLE 广播最明显。" + "安装在楼宇及部分公共场所的 Rhombus 云摄像头,按 IEEE CC:47:BD 或 Rhombus 名称匹配。BLE 通常只在未注册或离线时广播。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "Ring" + "Amazon Ring 门铃或摄像头,常见于住宅。属于消费级设备,不能据此判定为路边杆上的摄像头。" + "Rivian" + "Rivian 手机钥匙、露营音箱或传感器。手机也可广播 Rivian Sensor。" + "Roku" + "Roku 流媒体棒或 Roku 电视,通常具有供遥控器使用的隐藏 Wi-Fi Direct 接入点。不能据此判定为运营商路由器。" + "Ruckus" + "RUCKUS Unleashed / ZoneFlex 园区接入点,不能据此判定为 Arris 有线调制解调器系列。" + "Ruijie" + "Ruijie / Reyee 园区或中小企业接入点。" + "Ruuvi" + "Ruuvi 广播传感器标签(温度 / 湿度 / 压力 / 运动)。本页的解码字段解析传感器载荷。" + "格式" + "温度" + "湿度" + "压力" + "X 轴加速度" + "Y 轴加速度" + "Z 轴加速度" + "电量" + "发射功率" + "运动" + "序号" + "载荷中的 MAC" + "Sagemcom" + "Sagemcom 运营商网关,常见的 Comcast / 其他有线网络代工设备。" + "SALTO" + "SALTO 商用门禁锁或读取器。" + "Samsara" + "Samsara 车队定位器或车载 Wi-Fi,属于卡车 / 厢式车远程信息设备,与 Motive 用途相近。" + "Samsung 家电" + "Samsung Family Hub 冰箱、灶具或烤箱的配置 Wi-Fi,不能据此判定为 SmartTag。" + "Samsung SmartTags" + "Samsung SmartTag / SmartTag+ 寻物标签,地址通常会轮换。" + "Schlage" + "Schlage / Allegion 智能门锁(Encode 等)。" + "Schrader TPMS" + "Schrader 后装 BLE 胎压监测器(AirCheck、拖车、房车),不是使用 315/433 MHz 的原厂气门杆传感器。" + "SenseCAP" + "Seeed SenseCAP LoRaWAN / Helium 室内网关的配置接入点(SenseCAP_XXXXXX)。接入以太网后通常不再广播。使用 Helium 名称的设备也可能匹配 Helium 规则。" + "Sensi" + "处于 BLE 配置状态的 Sensi 温控器。" + "SensorPush" + "SensorPush 温湿度记录仪。" + "Sercomm" + "Sercomm 运营商网关,常见的有线 / 光纤网络代工设备。" + "Shokz" + "Shokz 骨传导耳机(OpenRun / OpenFit),佩戴在头部,不能据此判定为定位器。" + "Skydio" + "Skydio 无人机,常见于美国公共安全 / 企业。飞行中的 Remote ID 通常使用 Wi-Fi,容易漏检;Remote ID 规则对应数字身份广播。" + "Snap Spectacles" + "Snap Spectacles 或其他 Snap BLE 产品。" + "Snapchat BLE 公司 ID 用于 Snap Spectacles,其他 Snap BLE 产品也可能匹配。不能据此证明正在录制。请结合目视观察。" + "SnapAV" + "Control4 / Wattbox 家庭影音处理器或电源设备。" + "Sonos" + "Sonos 家用 / 办公音箱,在配置或空闲时广播。不能据此判定为定位器。" + "Sony" + "Sony 耳机、电视或摄像头。Bravia 电视也会为 Cast 发送通用 iBeacon;Fieldwatch 保留 Sony 归类。" + "Sophos" + "Sophos 防火墙或接入点,属于园区 / 中小企业 Wi-Fi,不能据此判定为摄像杆。" + "Spectrum" + "Charter Spectrum 配置或热点 Wi-Fi。设备通常由 Arris / Hitron / Technicolor 代工。" + "Starlink" + "Starlink 路由器。现在 BSSID 通常随机化,STARLINK 名称是常见匹配依据。" + "Subaru" + "Subaru 手机钥匙 / 车载信息娱乐系统,不能据此判定为 Starlink 卫星网络。" + "SwellPro" + "SwellPro 防水无人机,名称以 SwellPro 开头。更名的网络可能漏检,通常没有 Remote ID 身份广播。" + "Synology" + "Synology NAS 或路由器接入点。" + "SYTPMS" + "SYTPMS / BR 自行车或滑板车 BLE 胎压传感器。解码字段可显示表压、温度、电量及运动状态。只是特征匹配,不能确认具体车辆。" + "警报" + "正常" + "零压力" + "转动中" + "否" + "是" + "静止" + "否" + "是" + "电量" + "温度" + "压力" + "T-Mobile" + "T-Mobile Home Internet 或热点。网关通常由 HUMAX / Arcadyan / Askey 代工。" + "Tapo" + "TP-Link Tapo 家用摄像头,属于消费级设备,不能据此判定为路边杆上的摄像头。" + "Target Atrius 购物篮" + "Target 购物篮标签,会同时匹配通用 iBeacon;此规则表示商店购物篮。" + "Tattile" + "Tattile 自动车牌识别摄像头,常见于欧洲道路及部分美国场所。" + "道路及门口的 Tattile 车牌读取器。广播时仅按名称匹配。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "Tedee" + "Tedee 改装智能门锁。" + "Tello" + "Ryze Tello 或 Tello Talent,名称以 TELLO 或 RMTT 开头。更名的网络可能漏检,通常没有 Remote ID 身份广播。" + "Tenda" + "Tenda 消费级路由器或扩展器。" + "Tesla" + "Tesla 手机钥匙、车辆、Wall Connector 或 TeslaGW Wi-Fi。iOS 还会接收车辆发出的 iBeacon 格式广播;仍归入 Tesla,不能据此判定为商场信标。轮胎传感器有 tsTPMS 独立规则。" + "Tesla tsTPMS" + "Tesla BLE 胎压传感器。传感器唤醒时可解码压力、温度及电量。只是特征匹配,不能确认具体车辆。手机钥匙仍归入 Tesla。" + "模式" + "休眠" + "休眠" + "休眠" + "休眠" + "休眠" + "压力" + "温度" + "电量" + "Tile 定位标签" + "Tile 寻物标签。解码字段可显示轮换的私有 ID,不是序列号。" + "私有 ID" + "TireCheck" + "TireCheck BLE 胎压传感器。只是特征匹配,不能确认具体车辆。" + "Toyota" + "Toyota 或 Lexus 手机钥匙,或出厂 TOYOTA / LEXUS 热点。" + "TP-Link" + "TP-Link 或 Deco 家用路由器 / mesh 设备。使用 TP-Link 主板的 Tapo 摄像头也可能匹配此规则。" + "胎压监测服务" + "Bluetooth SIG 胎压监测系统服务,匹配广播该标准服务的传感器。" + "TRENDnet" + "TRENDnet 消费级接入点。" + "Tuya" + "Tuya 插座、灯、摄像头或传感器,在部分公寓中数量较多。解码字段可显示绑定状态。" + "绑定" + "未绑定" + "已绑定" + "协议" + "Uconnect" + "Stellantis Uconnect 车载热点(Chrysler、Jeep、Ram、Dodge、Fiat),BSSID 通常随机化。" + "UniFi" + "Wi-Fi 或 BLE 上的 UniFi / Ubiquiti 名称。BSSID 匹配优先归入 UniFi 接入点。Instant 摄像头仍归入 UniFi Protect。" + "UniFi 接入点" + "UniFi / Ubiquiti Wi-Fi 接入点(含 airMAX / AmpliFi)。更名的 SSID 仍可通过主板厂商或 Ubiquiti 厂商标签匹配。Instant 摄像头仍归入 UniFi Protect。" + "UniFi Protect" + "处于 BLE 配置状态的 UniFi Protect Instant 摄像头,不能据此判定为 UniFi Wi-Fi 接入点。" + "Uniview" + "Uniview / UNV / Uniarch 摄像头,常见于商业视频监控及部分公共杆件。" + "Uniview / UNV 摄像头,常见于商业视频监控及部分公共杆件。按 Zhejiang Uniview 的 IEEE OUI 或 Uniview / UNV- 名称匹配。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "Utility Inc" + "Utility, Inc 车载或公共安全接入点。" + "Utility, Inc 无线电,曾在公共安全车载接入点研究中被报告。只是特征匹配,不能确认具体机构。请结合目视观察。" + "Vantiva" + "Vantiva(原 Technicolor)运营商网关。运营商更改 Wi-Fi 名称后,仍可通过主板厂商匹配。" + "Verizon" + "Verizon 或 Fios 热点 / 网关名称。许多 FiOS 设备由 Actiontec 代工。" + "Verkada" + "用于楼宇及部分公共场所的 Verkada 云摄像头,含支持车牌识别的枪式摄像头。" + "Verkada 云摄像头,含支持车牌识别的枪式摄像头。用于楼宇及部分公共场所,可采集视频,部分还采集车牌。仅按名称匹配。只是特征匹配,不能确认具体摄像头。请结合目视观察。" + "Volkswagen" + "Volkswagen 手机钥匙或 My VW 热点。Skoda、SEAT 和 Porsche 有各自的规则。" + "Vuzix" + "Vuzix 智能眼镜或其他 Vuzix BLE 可穿戴设备。" + "Vuzix BLE 眼镜。不能据此证明正在录制。未匹配也不能排除录制(配对后不广播、休眠或使用其他品牌时可能漏检)。请结合目视观察。" + "WatchGuard 接入点" + "WatchGuard 防火墙或接入点,不能据此判定为 WatchGuard Video 执法记录仪。" + "WatchGuard Video" + "WatchGuard Video 执法记录仪或车载摄像头(现属 Motorola),不是 WatchGuard 防火墙公司。巡逻设备可能不广播。" + "WatchGuard Video 执法记录仪或车载设备(VISTA / V300 系列)。IEEE OUI 00:1D:96 属于 WatchGuard Video,不是 WatchGuard 防火墙公司。巡逻设备可能不广播。只是特征匹配,不能确认身份。请结合目视观察。" + "WAVLINK" + "WAVLINK 消费级便携 / 家用路由器。" + "Winegard" + "Winegard 房车或船用 Wi-Fi 天线 / 路由器。" + "Withings" + "Withings(Nokia Health)体重秤或 BPM Connect 血压计,不能据此判定为 Nokia 手机或 Nokia 运营商网关。" + "Wolfcom" + "Wolfcom 执法记录仪或车载摄像头。未广播的设备不会出现。" + "Wolfcom 执法记录仪或车载摄像头。广播时仅按名称匹配。巡逻设备可能不广播。只是特征匹配,不能确认具体执法人员。请结合目视观察。" + "Wyze" + "Wyze 家用摄像头,属于常见消费级设备,不能据此判定为路边杆上的摄像头。" + "Xfinity" + "Comcast xfinitywifi 热点或 Xfinity / XFSETUP 网关名称。大部分由 Arris / Hitron / Technicolor 代工,并会匹配对应代工厂商规则。" + "Yuneec" + "Yuneec 无人机,名称以 Yuneec 开头。更名的网络可能漏检,通常没有 Remote ID 身份广播。" + "Zyxel" + "Zyxel 家用或中小企业网关 / 接入点。" + "全部流量" + "仅 Wi-Fi" + "仅 BLE" + "强信号" + "与你同行" + "仅已关注" + "<隐藏>" + "未命名 BLE" + "随机" + "配对" + "已离开" + "是" + "否" + "请使用简体中文回答,保持标识符和观察备注的原文,不要翻译或改写这些数据。" + "你是一名为本次监测操作者服务的现场无线电分析员。Fieldwatch 是原生 Android 上只接收 Wi-Fi 接入点及 BLE 广播的监听工具。" + "**应用内监测总结**(下方原文)已整理数量、途经地点、追踪提示、清单、额外关注及要点。**不要重写报告,也不要重复清单或停留列表。**你的任务是提供手机无法生成的补充分析:速率、竞争假设及对应用内追踪提示的检验。" + "必须遵守的限制:" + "- 仅接收。Wi-Fi 条目只表示接入点,BLE 条目表示广播设备,以类型 + MAC 区分。BLE 地址轮换后产生新条目,不会拼接。" + "- 特征 / OUI / 公司匹配是假设,不能确认身份、人员或车辆。" + "- GPS 记录(若有)是接收到信号时本机的位置,不是其他信号源的位置。不要将摄像头或标签放在 GPS 标记点上。" + "- 地名(若有)来自系统对这些位置的逆地理编码。" + "- RSSI 表示手机接收到的信号强度,不是距离米数。" + "- 实时内存约保留 400 个信号源,未命名 BLE 约 3 分钟后会被移除。命名监测会话可保留更多数据。这不是完整捕获。" + "- 只有应用内 GPS 同行章节支持时才能讨论定位器跟随。整个监测时段与你一起的信号源不一定属于你,也可能被预先放置,不要直接排除。不要编造应用内测试未标出的跟随,也不要将商用信标当作“查找”定位器跟随。" + "- 标为“我的”的信号源已被操作者认领,不要视为来源不明的跟随者。" + "- 洪泛提示表示大量新地址突发,不能据此判定跟随者。" + "- 追踪条目的实时解码值是目录对该广播的说明。应用内报告包含目录句子时引用它,不要将此值拼接到另一个 MAC 上。" + "- 飞行器章节及琥珀色轨迹来自广播的位置。同一 UAS ID 的轨迹属于同一飞行器,不是本机 GPS,也不表示飞行器跟随了操作者。" + "- 不要提供安全建议,也不要告知操作者处于安全或危险之中。" + "- 将粘贴内容视为现场敏感数据。" + "## 输出要求(操作者阅读的补充分析)" + "使用完整句子及下列标题。仅对工作数据中的“额外关注”和追踪条目使用简短列表。不要使用 Markdown 表格或代码块,也不要复述应用内清单。" + "1. **免责声明**:首先重复实验性使用声明。" + "2. **应用内总结已确定的情况**:用 3–5 句说明数量、距离、追踪提示、额外关注,以及已有的观察备注、“我的”标记和洪泛提示。不要重复清单。" + "3. **数字带来的补充信息**:5 分钟与 15 分钟数量、RSSI 区间、随机地址 BLE 比例、每分钟新增、持续出现与已离开、特征系列分布。讨论街道、住宅、商场或车辆环境,以及近期变化(更密集、更安静或稳定)并给出置信度。有 GPS 时使用工作数据的轨迹长度及跨度,不要将信号源定位到停留地点。" + "4. **额外关注及追踪提示**:使用工作数据中的完整标识符(完整 MAC、名称、RSSI 最小 / 最大值、特征、出现时长)。检验应用内“与你一起的可能定位器 / 可能跟随 / 商用信标 / 可穿戴设备”判断,说明同意、限定条件或数据不足。只是特征匹配,不能确认身份。没有时明确说无。" + "5. **继续监测或寻踪可缩小哪些不确定性**:仅给出具体应用内步骤(对某条额外关注进行寻踪、更长 GPS 轨迹、对比会话、筛选)。不要提供安全建议,也不要建议报警。" + "**要点(必需,最后一行)**:用以“要点:”开头的一句话,补充一个应用内要点尚未提及的数字(速率、随机地址比例、5 分钟与 15 分钟变化、轨迹跨度)。不要作价值判断或给出威胁等级。" + "## 采集背景" + "- 工具:Fieldwatch(app.fieldwatch),仅接收,不关联、不注入、不使用云端。" + "## 应用内监测总结(操作者已看到的原文,不要重写)" + "## 工作数据(供补充分析使用,不要将清单复制进回答)" + "特征系列:无。" + "特征系列(数量):" + "额外关注:" + "- 无。" + "观察备注:" + "类似寻物标签的信号源(供检验应用内追踪判断,不是跟随者清单):" + "## 工作数据结束" + "现在按照开头的**输出要求**撰写补充分析,不要重写应用内监测总结。" + "\n\n[因分享大小限制而截断]\n" + "你是一名现场无线电 / 隐私分析员,熟悉 IEEE OUI、Bluetooth SIG 分配编号、GAP 外观、常见广播格式(iBeacon、Eddystone、Apple 连续互通 /“查找”、Google Fast Pair、Microsoft)及常见消费产品。" + "用户希望从 Fieldwatch 观察中尽可能了解**这一个无线信号源**。Fieldwatch 是原生 Android 上只接收 Wi-Fi + BLE 的监听工具。结合下方数据及你对公开登记表和格式的知识分析,并说明每项判断由哪个字段或字节模式支持。" + "必须遵守的限制:" + "- 这是**一个**广播的无线信号源,不能据此确认人员、车辆或法定身份。" + "- Wi-Fi 条目**仅表示接入点**,关联客户端不可见。原生 Android 无法混杂捕获终端或探测请求。" + "- BLE 条目表示广播设备。随机 MAC 不是稳定身份,地址轮换后不会自动拼接。" + "- 特征 / OUI / 公司 / UUID 匹配都是**假设**,不能证明序列号、所有者或定位器的存在。" + "- GPS 记录(若有)是接收到信号时**操作者手机**的位置,不是此信号源的位置。" + "- 地名(若有)来自系统对这些位置的逆地理编码,仅为近似结果。" + "- RSSI 表示手机接收到的信号强度,不是测量距离。" + "- 不要编造数据中没有的字段。数据不足时明确说明,并指出什么观察会有帮助。" + "- 不要声称此信号源正在跟随任何人,也不要提供安全建议。" + "- 将粘贴内容视为现场敏感数据(MAC、SSID、载荷、GPS)。" + "## 采集背景" + "- 工具:Fieldwatch(app.fieldwatch),仅接收,不关联、不注入、不使用云端。" + "- 对象:%1$s,标题“%2$s”。" + "- 扫描强度:%1$s。%2$s 秒后过期,短暂保留 %3$s 秒。" + "- 位置标记:%1$s。" + "- 在线地名:%1$s。" + "- 随机 MAC 标记:%1$s。" + "- 本次命中:%1$s。已离开:%2$s。" + "- 此信号源的操作者 GPS 轨迹样本:%1$s 个(接收到信号期间的手机轨迹)。" + "## 地点(操作者 GPS,可选)" + "## 观察数据(详情页原文)" + "## 分析(必需章节)" + "1. **可能是什么**:产品类别、可能的品牌 / 系列及型号。置信度 0–100,保留“最可能 / 大概 / 也可能”等限定。列出名称、OUI、公司 ID、外观、服务及载荷证据。数据符合多个产品时列出竞争假设。" + "2. **登记表 / 格式解码**:IEEE OUI 或 CID、Bluetooth SIG 公司、GAP 外观、16 位 UUID,以及已有的 iBeacon UUID/major/minor、Fast Pair 型号 ID、Apple 连续互通类型。引用使用的十六进制数据。从公开清单识别 UUID 或公司时说明依据及清单。" + "3. **产品的典型用途**:手机、标签、音箱、车辆、接入点、摄像头、mesh 节点或配件等。说明典型广播行为(持续信标或间歇广播)。" + "4. **Fieldwatch 实际看到什么及能力限制**:原生 Android 的限制(无法捕获终端 / 探测请求、蜂窝网络或测向),以及随机地址的影响。" + "5. **信号及出现情况**:此处信号强弱、本次 RSSI 范围及广播时段。不要将 RSSI 换算为米。" + "6. **特征匹配**:将匹配结果视为筛选命中,不能据此确认身份。说明载荷是否也支持该系列。若数据中有“说明”,用作目录背景;若有“额外关注”,引用并视为针对特征的提醒,不能作为证据,并与“说明”区分。" + "7. **待确认问题**:哪些额外观察(另一个数据包、名称、GPS 轨迹或第二个信号源)会提高或降低置信度。" + "8. **不能得出的结论**:简要列出数据**不支持**的判断(所有者、跟随、法定身份、距离)。" + "以一句**要点**结束:此信号源最可能是什么,以及下一步可检查的一件事。不要提供安全建议。" + "此文件为空。" + "这不是 Fieldwatch 设置包。请从“设置 → 导出设置”导出。" + "这是特征包,请使用“导入特征”。" + "这不是 Fieldwatch 设置包,请打开 fieldwatch-settings JSON 文件。" + "没有可导入的内容。" + "没有新增内容,此手机上已存在 %1$s 条。" + "已添加 %1$s 条(%2$s 条已重命名)" + "已添加 %1$s 条" + "已为 %1$s 条特征合并额外规则" + "已跳过 %1$s 条已有内容" + "此文件为空。" + "这不是 Fieldwatch 特征包。请从“设置 → 导出特征”导出。" + "这不是 Fieldwatch 特征包,请打开 fieldwatch-signatures JSON 文件;仍支持导入 spectre-signatures。" + "此包没有识别特征。" + "此包没有内置识别特征。" + "你是一名为对比两次 Fieldwatch 监测的操作者服务的现场无线电分析员。Fieldwatch 是原生 Android 上只接收 Wi-Fi 接入点及 BLE 广播的监听工具。" + "**应用内对比**(下方原文)已区分只在本次、只在第二次及两次都出现的信号源。**不要重写报告或重复这些清单。**你的任务是提供手机无法生成的补充分析:变化属于哪一类,以及其中多少可能是真实变化。" + "必须遵守的限制:" + "- 仅接收,以类型 + MAC 区分。BLE 地址轮换后产生新条目,不会拼接。" + "- Wi-Fi 条目只表示接入点,关联客户端不可见。" + "- 额外关注 / 特征匹配是假设,不能确认身份、人员或车辆。" + "- GPS 记录(若有)是接收到信号时本机的位置,不是其他信号源的位置。" + "- 最近 15 分钟与命名监测会话的保留范围不同(内存约 400 条,会话上限 %1$s 条)。内存侧缺少 BLE 可能是容量移除,不能据此判定设备离开。" + "- 出现不等于同行。不要编造跟随、跟随者或摄像头位置。" + "- 标为“我的”的信号源已被操作者认领,不要视为来源不明的跟随者。" + "- 洪泛提示表示大量新地址突发,不能据此判定跟随者。" + "- 条目的实时解码值是目录对该类型 + MAC 的说明。应用内对比指出变化时说明该变化,不要将值拼接到另一个 MAC 上。" + "- 飞行器章节使用广播的位置,并按 UAS ID 合并。应用内对比指出状态变化时说明该变化。该轨迹不是本机 GPS。" + "- 不要提供安全建议,也不要告知操作者处于安全或危险之中。" + "- 将粘贴内容视为现场敏感数据。" + "## 输出要求(操作者阅读的补充分析)" + "使用完整句子及下列标题。仅对单次出现的“额外关注 / 已命名信号源”使用简短列表。不要使用 Markdown 表格或代码块,也不要复述应用内清单。" + "1. **免责声明**:首先重复实验性使用声明。" + "2. **应用内对比已确定的情况**:用 3–5 句说明时间窗口名称、数量、单次出现的额外关注,以及已有的观察备注、“我的”标记和洪泛提示。不要重复清单。" + "3. **数字带来的补充信息**:重合比例(两次都有 / 并集)、各类 Wi-Fi 与 BLE 数量,以及单次出现 BLE 中的随机地址比例。讨论固定设施、不同地点 / 时段或容量限制等解释,并给出置信度。使用工作数据,不要编造速率。" + "4. **单次出现的额外关注及已命名信号源**:使用工作数据的完整标识符(完整 MAC、名称、特征及时间窗口)。只是特征匹配,不能确认身份。没有时明确说无。" + "5. **继续监测或寻踪可缩小哪些不确定性**:仅给出具体应用内步骤(同一地点第三次监测、对单次额外关注进行寻踪、筛选)。不要提供安全建议,也不要建议报警。" + "**要点(必需,最后一行)**:用以“要点:”开头的一句话,补充一个应用内要点尚未提及的数字(重合比例、单次额外关注数量或单次 BLE 的随机地址比例)。不要作价值判断或给出威胁等级。" + "## 应用内对比(操作者已看到的原文,不要重写)" + "## 工作数据(供补充分析使用,不要将清单复制进回答)" + "仅本次:%1$s 仅第二次:%2$s 两次都有:%3$s 并集:%4$s 重合:%5$s%%" + "仅本次按协议:Wi-Fi %1$s BLE %2$s 随机地址 BLE %3$s" + "仅第二次按协议:Wi-Fi %1$s BLE %2$s 随机地址 BLE %3$s" + "两次都有按协议:Wi-Fi %1$s BLE %2$s 随机地址 BLE %3$s" + "容量提示:最近 15 分钟使用实时内存(约 400 条),命名会话最多保留 %1$s 条。两者统计范围不同。" + "单次出现的额外关注:" + "- 无。" + "单次出现的已命名信号源:" + "观察备注:" + "## 工作数据结束" + "现在按照开头的**输出要求**撰写补充分析,不要重写应用内对比。" + "\n\n[因分享大小限制而截断]\n" + "已恢复设置、当前筛选及 %1$s 份预设。" + "已恢复设置、当前筛选及 %1$s 份预设。" + "%1$s 个已命名信号源" + "%1$s 个已命名信号源" + "%1$s 项特征关注" + "%1$s 项特征关注" + "未收录" + "- 时间窗口:会话 **%1$s**(%2$s → %3$s UTC),含最近 5 分钟切片。" + "- 时间窗口:最近 **15 分钟**(%1$s → %2$s UTC),含最近 **5 分钟**切片。" + "- 扫描强度:%1$s。%2$s 秒后过期。" + "- 位置标记:%1$s。在线地名:%2$s。" + "15 分钟:Wi-Fi %1$s BLE %2$s 特征匹配 %3$s 隐藏 SSID %4$s " + "随机地址 BLE %1$s/%2$s(%3$s%%) " + "首次出现 %1$s(%2$s/分钟) 持续出现 %3$s 已离开 / 未广播 %4$s" + "5 分钟:Wi-Fi %1$s BLE %2$s " + "特征匹配 %1$s 首次出现 %2$s" + "GPS 轨迹:位置标记 %1$s " + "定位点 %1$s 长度 %2$s m 跨度 %3$s m " + "地点 %1$s" + " 观察备注:" + "使用相同无线电的被动观察者可看到 %1$s 个有名称或隐藏的接入点," + "以及 %1$s 个 BLE 广播设备(%2$s 个地址随机化)。" + "开启" + "关闭" + "未收录" + "HP 打印机" + "AirPods、Beats 或 AirPlay" + "Fitbit 设备" + "GoPro 相机" + "Insta360 相机" + "iBeacon 信标" + "厂商 IE %1$s" + "服务包含 %1$s" + "厂商 IE %1$s" + "公司 0x%04X" + "本次会话:%1$s(%2$s 个信号源%3$s)" + "第二次会话:%1$s(%2$s 个信号源%3$s)" + "广播位置" + " 最后位置 %1$s" + "航向 %1$s°" + "1 次广播定位" + "%1$s 个广播定位点,%2$s" + " 飞手 %1$s" + "此处另外列出 %1$s 条未显示在地图上的飞行器轨迹。" + "这些位置来自无线电广播,不是本机 GPS。" + " 状态变化:%1$s → %2$s" + "%1$s,无 UAS ID:%2$s(%3$s)" + "%1$s,无 UAS ID:%2$s(%3$s)" + "飞手" + "第二次会话的广播轨迹" + " 蓝色点线表示第二次会话中位于此轨迹 2 km 范围内的广播轨迹。" + "黑色点线表示广播轨迹" + " 黑色点线表示位于此轨迹 2 km 范围内的广播轨迹。" + "飞行器" + "北向朝上。黑点表示本次会话,蓝点表示第二次会话。标记表示最后广播的位置。" + "北向朝上。蓝色点线表示 %1$s 的广播轨迹,标记表示最后广播的位置。" + "飞行器" + "%1$s 最后广播的位置,标记表示该位置。" + "北向朝上。黑色点线表示 %1$s 的广播轨迹,标记表示最后广播的位置。" + "最后位置 %1$s" + "飞手 %1$s" + " 最后位置 %1$s" + "1 个定位点" + "%1$s 个定位点" + "开启" + "关闭" + "Fast Pair 配对中" + "Apple“查找”/ 离线查找" + "Apple BLE(手机 / 连续互通)" + "Wi-Fi 信标洪泛" + "配对洪泛" + "名称洪泛" + "个新名称" + "个新地址" + " · %1$s 个有名称" + " · 约 %1$s dBm" + " 下方统计及列表已排除此突发中的 1 个地址。" + " 下方统计及列表已排除此突发中的 %1$s 个地址。" + "几秒内出现大量新蓝牙地址。手持设备可通过广播配对请求或新名称,并逐包更换地址来产生此现象。广播本身无法确认使用的工具。" + "一次扫描出现大量强度相近的新 Wi-Fi 名称,下一次扫描便消失。重复名称、mesh、扩展器和访客网络不计入。广播本身无法确认使用的工具。" + "Wi-Fi 信标洪泛 · %1$s 个新名称%2$s" + "%1$s · %2$s 个新地址%3$s%4$s" + ",强度相近,约 %1$s dBm," + "一次扫描中出现 %1$s 个新 Wi-Fi 名称%2$s,下一次扫描便消失。" + "重复名称、mesh、扩展器和访客网络不计入。" + "Flipper Zero,或运行 Marauder / Bruce 的 ESP32 等手持设备,可通过广播大量网络名称产生此现象。广播本身无法确认使用的工具。" + " 个新地址在最近几秒内发送了配对广播" + " 个额外地址各自广播了蓝牙名称。" + " 个新地址在最近几秒内各自广播了蓝牙名称。" + " 它们的强度相近,约为 " + " dBm,看起来可能来自附近的同一个无线信号源。" + "Flipper Zero,或运行 Marauder / Bruce 的 ESP32 等手持设备,可通过广播配对请求并逐包更换地址产生此现象。广播本身无法确认使用的工具。" + "Flipper Zero,或运行 Marauder / Bruce 的 ESP32 等手持设备,可通过广播新名称并逐包更换地址产生此现象。广播本身无法确认使用的工具。" + ",内存约 400 条" + ",命名会话最多 %1$s 条" + "Wi-Fi 等待系统" + "Wi-Fi 扫描中" + "Wi-Fi 将在 %1$s 秒后扫描" + "Google 厂商数据" + "%1$s 字节" + "Tesla 车辆或手机钥匙" + "Tesla 手机钥匙 iBeacon UUID(iOS 后台查找)。" + "Target Atrius 购物篮标签" + "Target / Atrius iBeacon UUID(购物篮资产标签)。" + "iBeacon 信标" + "Apple iBeacon 载荷。" + "提供 AirDrop 的 iPhone 或 iPad" + "Apple AirDrop 广播。" + "AirPods 或 Beats 耳机" + "Apple 近距离配对:%1$s。" + "Apple 近距离配对(AirPods / Beats)。" + "刚听到“Hey Siri”的 Apple 设备" + "Hey Siri 广播。" + "AirPlay 音箱或 Apple TV" + "AirPlay 广播。" + "进行 Handoff 的 Apple 设备" + "Handoff 广播。" + "查找 Instant Hotspot 的 Apple 设备" + "网络共享目标广播。" + "提供 Instant Hotspot 的 iPhone / iPad" + "网络共享来源广播。" + "Apple 设备(Nearby Action)" + "iPhone / iPad / Mac(Nearby Info)" + "“查找”网络信号源" + "Apple 离线查找,可能是 AirTag、“查找”配件或用于自身定位的 Apple 设备。" + "Fast Pair 配件(常见为耳塞或音箱)" + "Google Fast Pair 型号 %1$s(0x%%06X),处于配对模式。" + "Google Fast Pair 型号 0x%06X,处于配对模式。" + "已与某个账户配对的 Fast Pair 配件" + "Google Fast Pair 账户密钥广播(未处于配对模式)。" + "Google Find Hub 标签" + "Find Hub 分离状态帧(不受欢迎的追踪检测)。" + "Find Hub 附近状态帧。" + "Eddystone 信标" + "Eddystone 服务数据。" + "Apple 载荷" + "%1$s 字节(未解析)" + "Apple 连续互通类型" + "%1$s 字节 HomeKit 配置数据" + "此设备正作为 AirPlay 来源或目标广播。" + "与 Apple Watch 佩戴 / 解锁状态相关。" + "载荷" + "%1$s 字节" + "近距离配对(AirPods / Beats)" + "Handoff 或 Instant Hotspot(目标)" + "Instant Hotspot(来源)" + "“查找”/ 离线查找" + "Nearby Action(扩展)" + "未收录" + "已截断(%1$s 字节)" + "%1$s,Tesla 手机钥匙(iOS 后台查找),不能据此判定为商场信标。" + "iBeacon 主 / 次编号" + "iBeacon 校准发射功率" + "1 米处 %1$s dBm(用于估算范围)" + "附近有人正在提供 AirDrop(%1$s 字节)。" + "附近有人开启了 AirDrop 接收。哈希是截断的 ID,不是名称。" + "Apple ID 哈希(2 字节)" + "近距离配对,已截断。" + "近距离配对。" + "产品" + "Apple 音频设备 0x%04X" + "耳塞位置" + "电量(左 / 右)" + "充电盒" + "右" + "左" + "充电盒电量" + "充电中" + "颜色" + "AirPods(第 1 代)" + "AirPods(第 2 代)" + "AirPods(第 3 代)" + "AirPods(第 4 代)" + "AirPods Pro(第 2 代)" + "一个或两个耳塞已离盒" + "盒盖打开" + "取出 / 入耳状态转换" + "一个耳塞入耳" + "两个耳塞离盒但未入耳" + "入耳活动" + "两个耳塞入耳" + "一个耳塞入耳(可能在共享)" + "两个耳塞在盒内,盒盖打开" + "两个耳塞在盒内,盒盖关闭" + "在盒内" + "状态 0x%02X" + "白色" + "黑色" + "红色" + "蓝色" + "粉色" + "灰色" + "银色" + "金色" + "玫瑰金" + "深空灰" + "深蓝色" + "浅蓝色" + "黄色" + "未知 / 不存在" + "附近的 Apple 设备刚触发了 Siri。" + "关注" + "类别 0x%04X" + "%1$s 刚听到 Siri 触发词。数据包携带短语音哈希,不包含说出的文字。" + "连续互通 Handoff:任务可在另一台 Apple 设备上继续,载荷已加密。" + "Instant Hotspot(查找中)" + "此 Apple 设备正在查找已配对手机的热点。" + "iPhone / iPad 正在提供个人热点。" + "Instant Hotspot(提供中)" + "已配对的 iPhone / iPad 热点" + " · 手机电量 %1$s%%" + " · %1$s/5 格信号" + "Nearby Action 广播。" + "Nearby Action:%1$s。" + "Apple TV 配置" + "移动备份" + "Watch 配置" + "Apple TV 配对" + "Wi-Fi 密码共享(提示附近的 iPhone)" + "iOS 配置" + "修复" + "音箱配置" + "全屋音频配置" + "接听了电话" + "结束了通话" + "远程自动填充" + "Companion Link 近距离状态" + "远程显示" + "操作 0x%02X" + "Apple 设备使用状态。" + "活动未知" + "活动报告已关闭" + "空闲(屏幕锁定)" + "正在播放音频,屏幕锁定" + "活跃(屏幕开启)" + "屏幕开启,正在播放视频" + "Watch 已佩戴并解锁" + "近期有交互" + "用户正在驾驶" + "电话或 FaceTime 通话" + "活动 0x%X" + "主要 iCloud 设备" + "AirDrop 接收开启" + "Wi-Fi 开启" + "AirPods 已连接" + "Watch 已锁定" + "Apple 设备正在做什么" + "Nearby Info 广播。" + "手机空闲 / 已锁定。" + "屏幕锁定时正在播放音频。" + "屏幕开启,有人正在使用。" + "设备报告用户正在驾驶。" + "正在进行电话或 FaceTime 通话。" + "离线查找广播。" + "充足" + "中等" + "低" + "极低" + "正在广播公钥,使“查找”网络可以报告位置。" + "AirTag、“查找”配件及用于自身定位的 Apple 设备都使用这种广播。" + "近期检测到主人。" + "当前密钥时间窗口内未检测到主人。" + "电量 %1$s。" + "(%1$s 字节密钥片段,不是序列号。)" + "处于配对模式,Android 会弹出点按配对卡片。" + "型号 ID" + "0x%06X(不在本地名称清单中)" + "请求显示配对卡片" + "隐藏配对卡片(例如耳塞已放回盒内)" + "筛选类型 %1$s" + "账户密钥布隆过滤器" + "已与账户配对(未处于配对模式)。%1$s。版本 %2$s。" + "已截断" + "Eddystone-UID 命名空间" + "Eddystone-UID 实例" + "遥测(电量 / 温度 / 广播次数)" + "临时 ID(轮换)" + "与主人分离(不受欢迎的追踪检测模式)" + "附近 / 与主人一起" + "帧 0x%02X" + "Windows 台式机" + "Windows 手机" + "Windows 笔记本" + "Windows 平板" + "%1$s 正在广播以便快速配对或共享。" + "Microsoft 厂商数据" + "AltBeacon 主 / 次编号" + "BLE 扫描失败(%1$s)" + "蓝牙已关闭" + "BLE 扫描器不可用" + "BLE 启动失败" + "BLE 正在重试" + "BLE 不可用" + "BLE 正在轮换扫描" + "BLE 扫描被挂起,正在重启" + "Wi-Fi 快速扫描需要在开发者选项中设置" + "杂项" + "计算机" + "电话" + "局域网/网络接入点" + "音频/视频" + "外设" + "成像设备" + "穿戴设备" + "玩具" + "健康设备" + "未分类" + "台式计算机" + "服务器" + "笔记本计算机" + "手持计算机/PDA" + "掌上 PDA" + "穿戴式计算机" + "平板计算机" + "蜂窝电话" + "无绳电话" + "智能手机" + "有线调制解调器/语音网关" + "通用 ISDN 接入" + "完全可用" + "使用率 1–17%" + "使用率 17–33%" + "使用率 33–50%" + "使用率 50–67%" + "使用率 67–83%" + "使用率 83–99%" + "无可用服务" + "穿戴式耳麦" + "免提设备" + "麦克风" + "扬声器" + "耳机" + "便携式音频设备" + "车载音频设备" + "机顶盒" + "高保真音频设备" + "录像机" + "视频摄像头" + "摄录机" + "视频监视器" + "视频显示及扬声器" + "视频会议设备" + "游戏设备/玩具" + "操纵杆" + "游戏手柄" + "遥控器" + "传感设备" + "数位板" + "读卡器" + "数字笔" + "手持扫描器" + "手持手势输入设备" + "键盘" + "指点设备" + "键盘/指点设备" + "键盘" + "指点设备" + "键盘/指点设备" + "显示器" + "摄像头" + "扫描器" + "打印机" + "腕表" + "寻呼机" + "夹克" + "头盔" + "眼镜" + "机器人" + "车辆" + "玩偶/人偶" + "控制器" + "游戏设备" + "血压计" + "温度计" + "体重秤" + "血糖仪" + "脉搏血氧仪" + "心率/脉率监测仪" + "健康数据显示设备" + "计步器" + "体成分分析仪" + "峰值流量计" + "用药监测设备" + "膝关节假体" + "踝关节假体" + "通用健康管理设备" + "个人助行设备" + "受限可发现模式" + "定位" + "联网" + "呈现" + "采集" + "对象传输" + "音频" + "电话通信" + "信息服务" + "格式 %1$s" + "主类别 %1$s" + "计算机 %1$s" + "电话 %1$s" + "音频/视频 %1$s" + "外设 %1$s" + "穿戴设备 %1$s" + "玩具 %1$s" + "健康设备 %1$s" + "新建识别特征" + "%1$s 小时 %2$s 分钟" + "%1$s 分钟" + "%1$s 秒" + "已有 %1$s 个已保存会话。结束本次会话时,将删除最早的“%2$s”。" + "已删除最早的会话“%1$s”(保留 %2$s 个)。" + "无法打开所选位置" + "且" + "或" + "M月d日 HH:mm" + "正在启动无线扫描…" + "关闭" + "发送失败" + "隐私模式:已暂停发送" + "暂无符合条件的信号源(需要额外关注、广播坐标或 GPS 记录)" + "无法解析主机 %1$s" + "厂商 %1$s %2$s" + "厂商 %1$s" + "额外关注" + "%1$s 小时 %2$s 分钟" + "%1$s 分 %2$s 秒" + "%1$s 秒" + "无法初始化语音。请检查 Android 文字转语音引擎设置。蜂鸣告警不受影响。" + "语音播放失败。请检查 Android 文字转语音引擎。蜂鸣告警不受影响。" + "信道%1$s" + "类型 %1$s" + "%1$s 至 %2$s dBm" + diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml new file mode 100644 index 0000000..636e854 --- /dev/null +++ b/app/src/main/res/values/plurals.xml @@ -0,0 +1,15 @@ + + + + %1$d family + %1$d families + + + %1$d advertised fix + %1$d advertised fixes + + + %1$d alert here + %1$d alerts here + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d592106..0a33bf9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,2906 @@ + - Fieldwatch + Fieldwatch + "Language" + "Follow system" + "OK" + "Signatures" + "Settings" + "Signatures" + "Settings" + "Cancel" + "Extra attention" + "Cancel" + "Cancel" + "Extra attention" + "Cancel" + "Cancel" + "Cancel" + "Settings" + "Appearance" + "Night mode" + "Off by default. Red-on-black field display so chips, text, and signal marks do not dump green or blue into a dark sit. Background stays dark. Phone brightness is unchanged." + "Keep screen on" + "On by default. Stops the display from sleeping while Fieldwatch is open so BLE is not parked when the phone blanks. Scanning still runs in the notification if you leave the app. Turn it off when you pocket the phone." + "Privacy mode" + "Hides the last three octets of every MAC on Live, radar, timeline, detail, Hunt, Named radios, and watchlist cards as **:**:** so the screen and sit reports do not show full addresses. GPS last-fix and Debrief / AI Export / detail Share coordinates become “masked”; street names are omitted from those sit reports. The first three octets (OUI / vendor prefix) stay. Off by default. The map on Reports → Path still loads when Online place names and maps is on. Logs, matching, filters, Hunt math, Moving with you, and saved signatures still use the real MAC and GPS. A TAK / CoT feed, if you turned it on, is paused while this is on so full MACs and coordinates are not sent onto the LAN. Turn this off when you need the full address or coordinates on screen." + "Scanning" + "Battery saver" + "Balanced" + "High performance" + "Scan intensity · %1$s" + "Wi-Fi is a batch radio: the phone grabs every AP at once, then must wait. High performance asks about every 30s — that is the fastest cadence that stays under the OS limit of four scans per two minutes. BLE still streams in between." + "Wi-Fi waiting on OS" + "Wi-Fi scanning" + "Wi-Fi next 99s" + "Faster Wi-Fi AP scans" + "Needs Android 11+ so Fieldwatch can read whether the OS is still throttling scans. This phone cannot confirm that, so the switch stays off." + "On. Fieldwatch asks for a new AP list about every 8 seconds. Uses more battery and heat. If the OS starts refusing scans, it backs off." + "Saved on, but not in effect — Android Wi-Fi scan throttling is still on. Turn that off in Developer options, then return here." + "Stock Android allows about four AP scans per two minutes. Faster scans only run after you turn off Wi-Fi scan throttling in Developer options. Fieldwatch checks that OS switch before turning this on, and cannot change it for you." + "Developer options required" + "This phone is older than Android 11, so Fieldwatch cannot read the OS Wi-Fi scan-throttle switch. Faster AP scanning stays off." + "Android is still throttling Wi-Fi scans (about four per two minutes). Fieldwatch will not turn Faster Wi-Fi AP scans on until that is off.\n\nEnable Developer options (tap Build number seven times in About phone), then Settings → Developer options → Wi-Fi scan throttling → Off. Come back and flip this switch again." + "Open developer options" + "OK" + "Not now" + "Allow background usage" + "Mirrors Android Allow background usage. Tap to open Fieldwatch’s Battery page and use that switch. Fieldwatch updates when you return. Off: the OS can kill the scan as soon as you leave. Not Keep screen on." + "Unrestricted battery" + "Mirrors Android Unrestricted (not Optimized). Some phones (Samsung among them) do not open onto that choice. If you only see Allow background usage, tap that row to click through and select Unrestricted. Fieldwatch updates when you return." + "The next screen is Fieldwatch’s Battery page. Use the Allow background usage switch. Fieldwatch will match that setting when you return." + "Some phones (Samsung among them) do not open onto Unrestricted / Optimized / Restricted. If you only see Allow background usage, tap that row (the words, not the blue switch) to click through, then select Unrestricted. Fieldwatch will match that when you return." + "Open Android settings" + "Watchlist" + "Watchlist alerts" + "On by default. Master switch for bookmarked signatures and devices. Off: no beep, vibration, flash, jump, or shade card. Bookmarking still works — you just will not be told when that radio appears." + "Named radios (%1$s)" + "Custom names for one MAC. Alert is optional. Filters → Named radios only shows them on Live. Signature watches stay on Signatures." + "Beep on watched signature" + "The double pip on media volume when a bookmarked signature or device first appears, or returns after leaving. Sitting detections do not beep again. Independent of Voice — use beep, voice, or both. Raise media volume if you hear nothing, then tap Test alert." + "Voice on watched signature" + "On by default. Speaks on the same media volume as the pip. Independent of Beep: with Beep on, voice follows the pip; with Beep off, voice only. Not Hunt. If a phrase is already being spoken, a second hit is skipped. Phones with no text-to-speech still beep if Beep is on." + "What to say" + "For signature watches: Class is the Live glyph bucket (finder tags, audio, …). Signature is the catalog row (Apple AirTags, Axon, …). Class + signature (default) says both. A named radio with Alert on always says its custom name, even if it has no class. Test alert plays the signature mix you have on." + "Test alert" + "Jump to new watched detection" + "When a new watched signature or device appears, Live scrolls to that row so you can see the flash. Works with beep, voice, or both. Weak hits sit at the bottom of a strength-ranked list. Turn this off if you do not want the list to move." + "System notification" + "Optional. Posts a silent shade card when a watched radio appears. Off by default — the beep and flash are enough, and skipping the card keeps the scan loop lighter." + "Location" + "Tag detections with GPS" + "On by default. Requests live GPS/network updates and stamps each hear (Live detail, Moving with you, Debrief, and lat/lon on new log rows). Last-known-only is ignored if older than 30 s. That is your GPS at hear-time, not an independent fix on the other radio. Use high-accuracy Location or the path stays 0. Turn off if you do not want operator coordinates on logs. Heard-here TAK pins also need this; advertised payload coordinates (Remote ID) do not." + "Online place names and maps" + "On by default. When the phone has internet, Debrief / AI Export reverse-geocode GPS stamps to street/city, and Reports → Path loads OpenStreetMap tiles under the trace. No Fieldwatch cloud, no API key. Offline or no geocoder: Debrief uses coordinates only and Path stays the current north-up trace — no error dialog. Turn off to keep streets and map tiles out of reports and Path. Debrief, Sit export, Log export, and Reset / clear log are on the Reports tab." + "TAK / CoT feed" + "Off by default. Sends Cursor-on-Target UDP markers to ATAK, WinTAK, or iTAK. This phone (%1$s:%2$s) is ATAK CIV on this handset. LAN multicast is %3$s:%4$s. Custom is a unicast IPv4 or hostname. UDP only — a TAK server’s TCP 8087 is not this feed. Heard-here pins sit at this phone’s GPS at the loudest hear (closest approach) and are labeled (here). Walking away does not drag the pin; a louder hear moves it. Keep-alives refresh the same lat/lon every ~10 s so ATAK does not drop it. Advertised lat/lon (stock Remote ID) sit on the aircraft; the same Remote ID keeps one marker that moves (UAS ID, not the rotating BLE MAC). A decoded pilot location is a second pin. Gone radios are dropped on ATAK instead of sitting 120 s. Tap a marker in ATAK for remarks (name, MAC, RSSI, signatures). Not direction-finding. Not a Remote ID plugin. Privacy mode pauses the feed." + "Privacy mode is on — the feed is paused so full MACs and coordinates are not sent. Turn Privacy mode off to publish." + "Logging" + "Write detections to disk" + "Logging is on. New detections are appended to the rotating file." + "Logging is off. Scanning still runs; nothing new is written until you turn this back on." + "The rotating file is JSON lines (one hear per line). Reports → Log → Format writes CSV, JSON lines, GPX, KML, or WiGLE when you Share or Save." + "Rotate at %1$s KB" + "Stale after %1$ss" + "%1$s lines this session · %2$s KB on disk. Share, Save, and Reset / clear log are on the Reports tab." + "Signatures" + "Export the catalog (stock plus any you added or edited) to share with another Fieldwatch or as a backup. Import adds new rows and extra rules; it does not delete anything. Same id or the same match rules are skipped so a pack can be imported twice. Update stock catalog from GitHub replaces stock rows (including Extra attention) from the v2 pack on the repo; bookmarks, Settings, and signatures you added stay. Needs internet. Offline: Import signatures from a file. Restore defaults below still wipes customs." + "Export signatures" + "Save signatures to SD card / storage…" + "Import signatures…" + "Update stock catalog from GitHub" + "Restore default signatures & presets" + "Settings backup" + "Settings switches, the current filter, filter presets, named radios, and signature watches. Not the catalog — that is Export signatures. Not logs or GPS. Import replaces those on this phone; the catalog stays. Use this after a factory reset or on a new phone." + "Export settings" + "Save settings to SD card / storage…" + "Import settings…" + "Show Live tour" + "Chrome overlay on Live: Tune is Display (Radar, list, By class), Pause, Filters, Signatures, Reports, Settings. First-run after the license; this button shows it again." + "Fieldwatch %1$s · Catalog %2$s" + "Passive Wi-Fi + BLE only. Stock Android cannot promiscuously capture Wi-Fi stations; access points and BLE advertisers are what the radios expose." + "This phone’s IPv4 · none" + "This phone’s IPv4 · %1$s" + "Restore defaults?" + "Rewrites the catalog (stock rows, class colors, Decode fields), stock bookmarks, stock filter chips, and default Settings switches. Custom signatures and chips you saved are wiped. Export signatures and Export settings first if you want a backup. This cannot be undone." + "Restore" + "Cancel" + "Destination" + "This phone" + "LAN multicast" + "Custom" + "This phone: %1$s:%2$s (ATAK CIV on this handset). LAN multicast: %3$s:%4$s (other ATAKs on this Wi-Fi). Custom: type a unicast IPv4 or hostname. UDP only. A TAK server’s TCP 8087 is not this feed. If This phone does not plot, use Custom with this phone’s Wi-Fi IPv4 from the footer and port %5$s." + "Host" + "Port" + "UDP. ATAK CIV %1$s. SA multicast %2$s. Not TCP 8087." + "What to send" + "Extra attention" + "Payload location" + "All signatures" + "Independent chips. Extra attention (on): body-cam, glasses, recording wearables, pentest, public-safety APs. Payload location (on): advertised lat/lon from a decode map — required for stock Remote ID, which has no Extra attention mark. Watchlist (off): bookmarked signatures and named radios with Alert on. All signatures (off): every labeled radio — noisy in a plaza. Unmatched radios never go. A pin still needs coordinates: advertised payload, or GPS tagging with a live fix. Heard-here holds the loudest hear, not the last, and callsigns end in (here). Remote ID keeps one aircraft marker (UAS ID) plus a pilot pin when that location decoded." + "Feed status · paused (Privacy mode)" + "Feed status · error: %1$s" + "Feed status · no send yet this session" + "on the feed %1$s" + "sent %1$s" + "1 gone" + "%1$s gone" + "Feed status · %1$s" + "Unmatched" + "Wi-Fi access point" + "BLE advertiser" + "Decode fields" + "Start" + "Now" + "End" + "Disclaimer and license" + "Disclaimer" + "I have read this and I agree" + "Continue" + "Fieldwatch needs the radios" + "Location, nearby Wi-Fi, Bluetooth scan, and notifications let Fieldwatch passively watch advertised networks and BLE devices. Nothing is transmitted." + "Grant permissions" + "Sit compare PDF" + "Sit compare AI Export" + "Sit compare" + "AI Export" + "Debrief PDF" + "Debrief" + "Export" + "Please wait…" + "Live logging is paused until this finishes. Scanning continues." + "Could not export" + "Log saved" + "The file was written to the folder you picked. In the system picker, use the menu to choose the SD card if you want it off internal storage." + "Log cleared" + "Rotated files were deleted. New detections will start a fresh log." + "FIELDWATCH · SIT · PAUSED" + "FIELDWATCH · SIT" + "FIELDWATCH · PAUSED" + "Hide scan options" + "Show scan options" + "Pause display" + "Live" + "Pause" + "Filters" + "Reports" + "Detail" + "Back" + "No radio selected." + "Strongest · avg %1$ss" + "Strongest" + "Newest heard" + "Newest alert" + "Newest arrival" + "New at bottom" + "Name A–Z" + "Signatures first" + "Off" + "Hold %1$ss" + "Display" + "View" + "Sort" + "Strongest signal" + "Strongest (avg 30s)" + "Brief hold" + "Off — Stale after only" + "Hold %1$ss after last packet" + "Title line" + "Subtitle line" + "RSSI bars" + "Signature names" + "Frequency" + "First / last seen" + "More display options below" + "Advertised name" + "Name + type" + "MAC address" + "None" + "Mark seen" + "Reset seen" + "Start over" + "Sit compare" + "Sit compare PDF" + "Sit compare AI Export" + "Debrief PDF" + "Debrief" + "AI Export" + "Log cleared" + "Tune" + "Got it" + "Pause" + "Freeze the picture. Tap Live again to run." + "Filters" + "Who is shown." + "Pattern catalog." + "Reports" + "Debrief, sits, log." + "Scan, GPS, TAK." + "Display — Radar, list, timeline, hybrid, By class." + "Back" + "Decode fields" + "Back" + "Save" + "Service data" + "Service UUID" + "Back" + "Advertised name" + "Name" + "Identity" + "Wi-Fi access point" + "AI Export" + "Notes" + "Filters" + "Save" + "Delete" + "Signatures (%1$s)" + "New signature" + "Tap a class to open its signatures. Bookmark = beep. Hide a family on Filters, not here." + "Tap to edit. Bookmark = beep when that family appears. Hide a family on Filters, not here." + "Name A–Z" + "Class A–Z" + "%1$s · %2$s rules · %3$s live · %4$s" + "Beep when this signature appears" + "Edit signature" + "Save" + "Identity" + "Name" + "Notes" + "Shows on radio detail for matching radios, and in Share / AI Export. Not Extra attention — no Live “!” and not the amber card." + "Optional. If this is not empty, matching radios get a “!” on Live, this amber card on detail, and a line in Debrief. Separate from Notes above." + "Matching" + "Class" + "Filters → Show only / Hide these. Class sits (Finder tags, Cameras, …) are those chips — Save current as… if you want a preset." + "Match any rule (OR)" + "Cluster by OUI" + "Sequential MACs" + "Min peers (0 = off)" + "Peer window (seconds)" + "Color" + "Stock colors are by class (red pentest, amber cameras/ALPR, purple phones/tags, cyan wearables, green mesh, orange audio/glasses, teal in-car/vehicle). Change any row." + "Rules" + "Each rule has its own switch. Off keeps the rule but it does not match. Use that to mute noisy OUIs or names on one signature without deleting them." + "Add rule" + "Decode fields" + "None" + "%1$s fields" + "Optional. After this signature matches, map cleartext BLE bytes to labels. Encrypted payloads stay hex." + "Delete signature" + "Delete this signature?" + "“%1$s” is a built-in signature. Deleting it removes matching, its bookmark, and filter chips. Restore default signatures in Settings will bring the stock set back." + "“%1$s” will be removed. Matching, its bookmark, and filter chips go with it. This cannot be undone." + "Delete" + "Selected color" + "Kind" + "Delete rule" + "Value" + "Company ID hex" + "Data prefix hex" + "Service UUID (empty = any, contains)" + "Contains hex" + "Matches hidden SSIDs" + "MAC prefix" + "Name contains" + "Name glob" + "Service UUID" + "Service data" + "Manufacturer ID" + "Manufacturer data" + "Radio kind" + "Hidden SSID" + "Vendor IE OUI" + "Back" + "Continue" + "Name" + "Save" + "Back" + "Save" + "Reports" + "Delete" + "AI Export" + "Name" + "Start" + "Save" + "Hunt" + "Source" + "Map cleartext BLE bytes after this signature matches. Encrypted ads stay hex." + "Manufacturer" + "Company ID" + "Optional. Byte 0 is the first byte after the company ID. Empty = any record." + "Required. 16-bit (FEAA) or full UUID." + "Fields" + "Field %1$s" + "Add field" + "Remove decode map" + "Removes every field and the Live code mark. Save after adding fields still keeps the map." + "Preview" + "Remove decode map?" + "Clears all fields on this signature. Live no longer shows the code mark. Raw advertisements stay. This cannot be undone except by adding fields again." + "Remove" + "No matching radio on the air. Save anyway; detail will fill in when one is heard." + "A matching radio is on the air, but this advertisement has no bytes for the source above." + "Nothing parsed. Check offset, length, and that byte 0 is after the company ID." + "Label" + "Delete field" + "Offset" + "Length" + "Unit" + "Bit offset" + "Bit width" + "Scale" + "Add" + "Live row" + "Show this value next to the signature name. Strong values use a heavier chip." + "More" + "Modulo" + "Hide extra" + "Only if…" + "Only if" + "Bytes" + "Hex" + "Named values…" + "Named values" + "Raw" + "Show as" + "Delete value" + "Strong" + "Note" + "Add value" + "equals" + "not equals" + "mask" + "none of bits" + "length" + "When" + "payload length" + "Type" + "Endian" + "little" + "big" + "Watch" + "Not on the air. This is the last detail we heard." + "Custom name" + "Advertised" + "No advertised name" + "Hide custom name" + "Saved as %1$s" + "Saved" + "Save name" + "Observer notes cleared" + "Observer notes saved" + "Mine" + "While on, this radio stays listed and does not beep." + "Marked as yours. No beep while this is on. Still listed." + "Radio" + "Wi-Fi access point (beaconing a network)" + "Bluetooth Low Energy advertiser" + "Address" + "Who made it" + "OUI (vendor prefix)" + "%1$s — no IEEE match; randomized addresses usually have none" + "Network name (SSID)" + "Hidden — the AP is beaconing but not publishing a name" + "Signal" + "How loud here (RSSI)" + "Not available" + "Last heard" + " at %1$s dBm" + "Closer to 0 dBm is louder here, not a distance." + "Heard range this session" + "Claimed transmit power" + "%1$s dBm — how loud it says it transmits, not a distance" + "Channel / frequency" + "channel %1$s" + " · %1$s wide" + "Wi-Fi generation" + "Center frequencies" + "Bluetooth advertisement" + "Radio PHY" + "Connectable" + "Yes — a phone could open a BLE connection" + "No — broadcast-only (you can hear it, not join it from this scan)" + "How often it advertises" + "%.0f ms between bursts (smaller = chattier on the air)" + "Periodic advertising" + "Discoverability" + "Flags (raw)" + "What it says it is (Appearance)" + "%1$s\nThe device publishes this GAP Appearance code to describe itself." + "Unlisted Appearance 0x%04X" + "Appearance code" + "Classic Bluetooth class" + "\nThis is the Class of Device bitfield used by classic Bluetooth." + "\nAlso offers: " + "Encryption / login" + "Security string" + "Supported rates" + "%1$s Mbps (* = required basic rate)" + "Capability string" + "Services it offers" + "Service IDs" + "(empty)" + "Aircraft" + "Decoded fields" + "Decode fields did not apply to this advertisement (short payload, a different company ID, or a different layout). Govee lights usually only send a name; hygrometers are H5074/H5075/H510x. Raw bytes are below." + "Decode fields did not apply to this advertisement (short payload, a different company ID, or a different layout). Raw bytes are below." + "This signature has a decode map, but this advertisement has no manufacturer or service payload to parse. Many Govee lights only broadcast a name." + "This signature has a decode map, but this advertisement has no manufacturer or service payload to parse." + "Maker data inside the ad" + "Not in the Bluetooth company list" + "Bluetooth company 0x%04X" + "%1$s\nThis ID is assigned by the Bluetooth SIG and is carried in manufacturer-specific data." + "Raw payload (%1$s bytes)" + "Wi-Fi vendor tags" + " type %d" + "Vendor OUI %1$s%2$s" + "Unknown IEEE OUI" + " — extra AP information element, not the SSID." + "Session" + "First seen" + "Last seen" + "Hits" + "Last fix" + "Matched signatures" + "Raw advertisement" + "Signal trend" + "Presence (15 min)" + "Hunt" + "Hunt is BLE only. Wi-Fi access points update too slowly on stock Android to walk toward." + "Create signature from device" + "Share as text" + "Opens a paste-ready prompt for a chat: decode this radio, look up OUI/company/UUIDs, and say what it most likely is. Same experimental disclaimer as Settings → AI Export. One device only — not identity." + "Signature family" + "Observer notes" + "Hide observer notes" + "No observer notes" + "Save notes" + "Pattern match, not identity. Not a safety finding." + "What this looks like" + "IEEE board/chip vendor: %1$s (%2$s). This is who owns the MAC prefix, not always the product brand." + "Bluetooth company in the ad: %1$s (0x%%04X)." + "Service data %1$s%2$s%3$s" + "Hunt" + "Reset this hunt" + "Back to detail" + "Beep" + "Vibrate" + "no live RSSI" + "Loudest this hunt %1$s dBm" + "Loudest this hunt —" + "last heard —" + "last heard %1$ss ago" + "last heard %1$sm ago" + "YOU" + "YOU" + "Mine" + "Observer notes" + "Named radios (%1$s)" + "One MAC each. Custom name shows on Live. Mine marks it as yours: no beep while on, still listed. Observer notes show on detail and reports. Alert is optional (pip / voice / flash). Filters → Named radios only hides everything else. Signature watches stay on Signatures." + "No named radios. Set a custom name on detail, or bookmark a radio (top-right) to watch it." + "Clear all %1$s named radios" + "Clear named radios?" + "Remove %1$s named radios. Signature watches stay." + "Clear" + "Named radio" + "Custom name" + "Observer notes" + "Edit" + "Remove" + "On the air — tap to open detail" + "Not this session" + "Mine" + "Alert" + "Presets" + "Tap to replace the whole filter. Long-press a chip to delete it. A short stock set ships; Save current as… adds your own (Cameras, plaza −80, …). Stock chips you delete come back with Settings → Restore default signatures & presets." + "Save current as…" + "Radios" + "Moving with you is BLE only. Both and Wi-Fi only stay off until you turn that switch off." + "These are include switches. Turn both on to see Wi-Fi and BLE together. A single device is never both." + "Both" + "Wi-Fi only" + "BLE only" + "Moving with you" + "Turn on Settings → Tag detections with GPS, then walk or drive. Only loud BLE advertisers that stay with you along the path. Wi-Fi access points stay off — a loud AP you drive past paints your path. The switch starts a BLE follow test (clears Signatures only / Show only / Named radios only / Watched only). Or tap the Moving with you preset at the top." + "GPS path so far %1$s m. Keep moving (~50 m). If this stays 0 while you drive, Location is not giving a live fix (set Location to high accuracy). Last-known-only is not enough. A second iPhone usually will not match: BLE MAC rotation starts a new radio." + " Named radios only is also on — unlabeled radios stay hidden." + " Watched only is also on — unwatched radios stay hidden." + " Signatures only / Show only is also on — unmatched radios stay hidden." + " Hide my radios is on — those stay off this list." + "GPS path %1$s m. Signatures only, class Show only, Named radios only, or Watched only is also on, so only those radios can co-travel. Tap the Moving with you preset to test BLE. A tag in your bag or car should match. Wi-Fi access points stay hidden." + "GPS path %1$s m. Loud BLE heard along that path at a fairly steady level — not ones that only appear when you arrive. " + "Hide my radios is on — those stay off this list. " + "A tag in your bag or car will match. " + "Wi-Fi access points stay hidden (range looks like co-travel). A phone’s rotating BLE address will not stitch as one follower. Live → Start over clears the path and trails so you can test again." + "New detections" + "New detections only · learning" + "New detections only" + "Mark seen and Reset seen sit on Live, above the tabs. " + "Learning sitting Wi-Fi into already-seen." + "%1$s already seen are hidden." + "Already seen is 0." + "Hide radios already here so only new ones show on Live. Mark seen / Reset seen appear above the tabs on Live while this is on. Brief hold still sets how long a new radio stays after the last packet. Randomized BLE addresses look new." + "Who stays" + "Signatures only (hide unmatched)" + "Show only already hides unmatched radios. Turn Show only (class or selected signatures) off to use this switch." + "Watched only" + "Only radios that match a bookmarked signature, or a Named radio with Alert on. Hide these still applies (Watched only + Hide Surveillance drops bookmarked cameras). Label-only names stay on Named radios only. Bookmark on Signatures; Alert on detail." + "Named radios only" + "Only radios you gave a custom name. Alert can still be off. Settings → Named radios. A random / privacy MAC will not follow a rotation." + "Hide my radios" + "Radios marked Mine stay off Live. The sit and Debrief still have them. Turning on Moving with you leaves this on." + "Hide Fast Pair account-key" + "Plaza noise: already-paired Fast Pair chips with no other signature. Keeps pairing-mode (tap-to-pair model ID). Hide selected Fast Pair still drops pairing-mode too." + "Signature classes" + "Live only — signatures still label, log, and can beep. Cameras, Drones, Finder tags, and the rest are these chips — Show only, then Save current as… if you want a preset. Show only with no class picked leaves Live unchanged." + "Show only" + "Hide these" + "Selected signatures" + "Show only selected signatures" + "Tap a class to open its signatures. Only radios matching a signature you turn on below stay on Live. Empty list = no extra include (Live unchanged). Picks stay if you turn this off and on again." + "Hide selected signatures" + "Tap a class to open its signatures. Devices matching a signature you turn on below stay off the Live list. Your picks stay if you turn this off and on again." + "Fine filter" + "Minimum RSSI %1$s dBm" + "Name / MAC contains" + "OUI / vendor contains" + "Extra filter logic" + "AND/OR applies to name, OUI, RSSI, and class include — not to radios, Named radios only, Watched only, Hide my radios, Hide Fast Pair account-key, or hide lists." + "Reset filter" + "Reset filter?" + "Clears every switch and pick on this tab (radios, classes, selected signatures, RSSI, name/OUI). Presets you saved stay. The Live display goes back to the unfiltered set. This is not undo." + "Reset" + "Delete preset?" + "Remove stock chip “%1$s” from this list? Catalog updates will not put it back. Settings → Restore default signatures & presets restores all stock chips. The filter on Live does not change until you apply another chip or Reset filter." + "Delete preset “%1$s”? This cannot be undone. The filter on Live does not change until you apply another chip or Reset filter." + "Hide this burst" + "Hiding 1 flood radio" + "Hiding %1$s flood radios" + "Hide these" + "Display paused · radios still scanning and logging. Filters still apply when you run again. Tap Live to run the list again." + "New only · learning sitting Wi-Fi" + "New only · %1$s hidden" + "New only" + "Follow · path %1$s m · Start over clears the path" + "Watched only" + "Named radios only" + " · memory cap" + " · %1$s cap" + "Sit · %1$s · %2$s · %3$s radios%4$s" + "Rename sit" + "Moving with you needs Settings → Tag detections with GPS, then a walk or drive." + "GPS path %1$s m — too short. Keep moving. If this stays 0, Location is not updating (use high accuracy)." + "No loud BLE has stayed with you among the radios still allowed. Tap the Moving with you preset to test BLE, or turn off Signatures only / Show only / Named radios only / Watched only." + "No loud BLE has stayed with you along this path. Wi-Fi access points stay hidden. Radios marked Mine stay off this list." + "No loud BLE has stayed with you along this path. Wi-Fi access points stay hidden. A tag in your bag or car should show. Find My MAC rotation will not stitch." + "Hiding sitting access points until the next Wi-Fi scan. New Bluetooth still shows right away." + "%1$s already seen are hidden. A new radio stays while we hear it, then at least as long as Brief hold after the last packet." + "Waiting for a new Wi-Fi or BLE radio. It stays while we hear it, then at least as long as Brief hold after the last packet." + "No watched radios on the air. Bookmark a signature, turn Alert on a Named radio, or turn off Filters → Watched only." + "No named radios on the air. Set a custom name on detail, or turn off Filters → Named radios only." + "Show all" + "Collapse empty" + "No live emitters match the current filter. If this just emptied, the OS may be between scan windows — the last set is held and should return without a burst." + "%1$s radios" + " · %1$s unmatched" + " · %1$s in more than one class" + " · %1$s empty hidden" + "no signature" + "%1$s signatures" + "No devices match the current filter" + " · ×%1$s · double-tap reset" + " · pinch to zoom" + "%1$s on-air · %2$s in filter · dim = gone · tap a blip%3$s" + "No live emitters match the current filter. If this just emptied, the OS may be between scan windows — the last set is held and should return without a burst. Use Timeline for recent disappearances." + "new %1$ss" + "new %1$sm" + "avg %1$s" + "Alerted this session" + "Last 15 minutes · solid bars are on-air windows" + "unnamed LE" + "unnamed" + "now" + "first %1$s · last %2$s" + "Rename sit" + "Last 15 minutes" + "Sits" + "Sit export" + "Signature candidates" + "Reading the log and re-matching the catalog…" + "Rotating log" + " · re-matched now" + "No signature families in this log. Randomized addresses and house-like names are skipped. A candidate needs a unique on-air ID on two or more radios." + "\nand %1$s more" + "Create signature" + "Skipped " + "%1$s randomized addresses" + "%1$s house-like names" + " and " + ". Those are not catalog families." + "Privacy mode is on. MAC tails in Debrief, sit compare, AI Export (sit or compare), and detail Share are **:**:**. GPS coordinates are masked. The log file, sit export, and GPX / KML / WiGLE files still have full addresses and lat/lon." + "Sits" + "A sit is a named window of radios heard here. The selection below drives Path, Debrief, and Compare’s this-sit side: open sit, a selected saved sit, or last 15 minutes if you never start one." + "This sit: %1$s · %2$s · %3$s radios" + "End sit" + "Start sit" + "No sit running. Start sit here. Path and Debrief stay last 15 minutes until you do." + "No sit running. Start sit here. Path and Debrief use the selected sit." + "No saved sits." + "Last 15 minutes" + "Path and Debrief use RAM, not a saved sit." + " · Extra attention %1$s" + "%1$s · %2$s · %3$s radios%4$s" + "End sit to pick a saved one for Path and Debrief." + "Rename" + "Delete all sits" + "Path" + "North up. The line is this phone. The black dot is the start. The blue dot is you, at the last point. A MAC or signature alert is one class icon. A decoded latitude and longitude uses the last position that radio sent. A count is several in one spot. Thick green is a stay. Time ticks along the path." + "Tag detections with GPS and walk, or open a sit that recorded a path." + "%1$s · %2$s m path · %3$s m span" + " · %1$s alert" + "Tap a count for the radios there. Tap a single icon for that one radio. Tap again to close. Tap a row in that list, or a row below, to open that radio." + "= advertised track within 2 km of this path" + "= one advertised position within 2 km of this path" + "= pilot" + "No MAC or signature alerts with a GPS stamp on this path." + "1 alert" + "%1$s alerts" + "An advertised position with no UAS id is in the sit report." + "Advertised positions with no UAS id are in the sit report." + "Sit report" + "Debrief (text)" + "Debrief (PDF)" + "Show unmatched rotating BLE" + "Off (default): Debrief text/PDF lists skip unmatched RAND BLE. Counts still include them. Extra attention, named signatures, bookmarks, and payload pins stay. Sit export has every radio." + "Show all radios" + "Off (default): Debrief and Compare lead with counts. A radio is listed when it is Extra attention, has a custom name, is marked Mine, or is bookmarked. Compare also lists a decoded value that changed. On: the full rosters return. Show unmatched rotating BLE applies to those lists. The PDF draws the counts as bars. Sit export has every radio." + "Paste-ready addendum: rates, RSSI bands, Extra attention and tracking IDs. Does not reprint Debrief inventories. One-radio AI Export is on detail." + "Sit export" + "One row per unique radio in this sit (or last 15 minutes). CSV / JSON lines include matched signatures and Extra attention families. Not the rotating log. GPX / KML include this phone’s path as a track plus hear-points. Fieldwatch does not upload. Privacy mode does not mask this file." + "Compare sits" + "Save a second sit to compare. Start sit, then End sit. Last 15 minutes can be this sit." + "Second sit" + "%1$s · %2$s · %3$s radios" + "Compare (text)" + "Compare (PDF)" + "Same report, two formats. Presence only — only in this sit, only in the second, in both. Kind + MAC. Extra attention and Named radios are marked. Not a radio fix." + "Paste-ready addendum: overlap, exclusive Extra attention / Named radios, what another sit would shrink. Does not reprint the compare lists. Sit report AI Export stays this window only." + "Catalog" + "Signature candidates" + "Unmatched radios in the log that share a unique ID — not every unknown. You review; nothing is added until you Save." + "Log export" + "%1$s lines this session · %2$s KB on disk" + " · logging off" + "The rotating file is JSON lines. CSV is the same rows as a spreadsheet. GPX — GPS Exchange, KML — Google Earth, and WiGLE CSV — wigle.net are hear-points: where this phone was when it heard each radio, not a radio fix. Tag detections with GPS and logging on. Share uses the Android share sheet — Fieldwatch does not upload." + "Reset / clear log" + "Clear the log?" + "This deletes all rotated CSV/JSON files on the phone. It cannot be undone. Live scanning will start a new empty log." + "Clear log" + "Debrief and AI Export use this window until you end it. The Live list is unchanged." + "Delete this sit?" + "Removes the saved sit from this phone. The log is unchanged." + "Delete all sits?" + "Removes saved sits from this phone. An open sit is not deleted. The log is unchanged." + "Delete all" + "This sit: %1$s — named window (up to %2$s). Same as Debrief." + "This sit: %1$s — named window (up to %2$s). Same as Debrief." + "This sit: last 15 minutes in memory (about 400 radios). Same as Debrief." + "Format" + "Share" + "Save to SD card / storage…" + "This sit (%1$s) — same window as Path. GPS following test when tagging is on and you have moved. Not a legal finding." + "Sit: %1$s — same window as Path. GPS following test when tagging is on and you have moved. Not a legal finding." + "Last 15 minutes in memory — same window as Path. Two formats. GPS following test when tagging is on and you have moved. Not a legal finding." + "Export Fieldwatch logs" + "Writing sit compare…" + "Could not write sit compare" + "Writing sit compare PDF…" + "Laying out sit compare PDF…" + "Loading map tiles…" + "Could not write sit compare PDF" + "Fieldwatch sit compare — %1$s" + "Fieldwatch sit compare" + "Building compare AI export…" + "Fieldwatch sit compare AI export — %1$s vs %2$s" + "Could not write compare AI export" + "Pick a second sit." + "Could not read that sit." + "Could not read this sit." + "Could not read the log" + "Fieldwatch signatures" + "Could not export signatures" + "Could not write to the location you picked." + "Signatures saved" + "The pack was written to the folder you picked. Share it with another Fieldwatch or keep it as a backup before Restore defaults." + "Could not save signatures" + "No internet" + "No internet. Use Import signatures from a file." + "Updating stock catalog…" + "Already on the latest catalog" + "Already on catalog %1$s. Nothing to update." + "updated %1$s" + "added %1$s" + "No stock rows changed." + "Catalog updated" + "Stock catalog is now %1$s. %2$s Bookmarks and Settings were not changed." + "Signature decoding skipped" + "Some signature field maps in this catalog need a newer Fieldwatch. Signatures still match. Install a newer APK to decode those fields." + "Could not reach GitHub" + "Could not reach the catalog on GitHub. Try again later, or use Import signatures from a file." + "Could not import catalog" + "Could not import catalog." + "Could not read that file." + "Signatures imported" + "Could not import signatures" + "Fieldwatch settings" + "Could not export settings" + "Settings saved" + "The pack was written to the folder you picked. Keep it for a factory reset or a new phone. Import settings on the new install. Signatures are a separate pack." + "Could not save settings" + "Settings imported" + "Could not import settings" + "Tag detections with GPS (Settings) to record a path." + "Walk with tagging on. Path needs a GPS fix." + "Last 15 minutes. Walk with tagging on, or Start sit to keep a longer path." + "Writing debrief PDF…" + "Laying out debrief PDF…" + "Could not write debrief PDF" + "Writing debrief…" + "Could not write debrief" + "FIELDWATCH SIT" + "Fieldwatch field debrief — last 15 minutes" + "Gathering sit…" + "Looking up place names…" + "Building debrief…" + "Building AI export prompt…" + "Privacy mode: MAC tails are **:**:**. GPS coordinates are masked. Logs on the phone are unchanged.\n\n%1$s" + "Fieldwatch AI export — sit %1$s" + "Fieldwatch AI export — last 15 minutes" + "Could not build AI export" + "Fieldwatch AI export — %1$s" + "Fieldwatch device detail — %1$s" + "Device detail" + "Could not share device detail" + "Preparing sit export…" + "Saving sit export…" + "Could not open the selected location" + "Writing %1$s · %2$s radios" + "Pick a map format." + "Writing %1$s · %2$s pins" + "Writing %1$s · %2$s of %3$s" + "Logging paused · preparing file…" + "Logging paused · saving to the location you picked…" + "Fieldwatch log (CSV)" + "Fieldwatch log (JSON lines)" + "Reading log…" + "Reading log · %1$s KB of %2$s KB" + "radios" + "Wi-Fi radios" + "BLE radios" + "No GPS-tagged %1$s. Settings → Tag detections with GPS, logging on, then sit." + "Clearing log…" + "Could not clear log" + "Writing %1$s KB of %2$s KB" + "Export failed" + "Watchlist" + "Disclaimer" + "1 advertised fix" + "Start" + "End" + "Second sit" + "Advertised" + "Pattern match, not identity. Not a safety finding." + "Aircraft" + "Observer notes" + "Extra attention" + "Radios" + "Last 15 minutes" + "Unmatched" + "Wi-Fi access point" + "Advertised name" + "Extra attention" + "Notes" + "Identity" + "Radio" + "Wi-Fi access point (beaconing a network)" + "Bluetooth Low Energy advertiser" + "Address" + "Who made it" + "OUI (vendor prefix)" + "%1$s — no IEEE match; randomized addresses usually have none" + "Network name (SSID)" + "Hidden — the AP is beaconing but not publishing a name" + "Signal" + "How loud here (RSSI)" + "Not available" + "Last heard" + "Heard range this session" + "Claimed transmit power" + "%1$s dBm — how loud it says it transmits, not a distance" + "Channel / frequency" + "channel %1$s" + " · %1$s wide" + "Wi-Fi generation" + "Center frequencies" + "Bluetooth advertisement" + "Radio PHY" + "Connectable" + "Yes — a phone could open a BLE connection" + "No — broadcast-only (you can hear it, not join it from this scan)" + "How often it advertises" + "%.0f ms between bursts (smaller = chattier on the air)" + "Periodic advertising" + "Discoverability" + "Flags (raw)" + "What it says it is (Appearance)" + "Unlisted Appearance 0x%04X" + "Appearance code" + "Classic Bluetooth class" + "Wi-Fi access point" + "Encryption / login" + "Security string" + "Supported rates" + "%1$s Mbps (* = required basic rate)" + "Capability string" + "Aircraft" + "Decoded fields" + "Note" + "Services it offers" + "Service IDs" + "(empty)" + "Maker data inside the ad" + "Not in the Bluetooth company list" + "Bluetooth company 0x%04X" + "Raw payload (%1$s bytes)" + "Wi-Fi vendor tags" + " type %d" + "Vendor OUI %1$s%2$s" + "Unknown IEEE OUI" + " — extra AP information element, not the SSID." + "Session" + "First seen" + "Last seen" + "Hits" + "Last fix" + "Matched signatures" + "Raw advertisement" + "Presence (15 min)" + "IEEE board/chip vendor: %1$s (%2$s). This is who owns the MAC prefix, not always the product brand." + "Bluetooth company in the ad: %1$s (0x%%04X)." + "now" + "Wi-Fi access point" + "Not available" + "unnamed LE" + "Radio" + "Rotating log" + "Second sit" + "Aircraft" + "Observer notes" + "Extra attention" + "Sit compare" + "Unmatched" + "Privacy mode: MAC tails are **:**:**. GPS coordinates are masked. Logs on the phone are unchanged.\n\n%1$s" + "Scanning" + "Marked mine" + "Bluetooth LE advertiser" + "Fieldwatch device detail\n" + "Experimental. Not a legal identity. Stock Android radios — this is what the OS exposed, not a guarantee a tracker or camera is present.\n" + "Marked mine" + "No beep while this is on. Still listed." + "What this looks like: " + "EXTRA ATTENTION (%1$s): " + "Pattern match, not identity. Not a safety finding.\n" + "Closer to 0 dBm is louder here, not a distance.\n" + "Recent RSSI (oldest → newest)" + ". Also offers: " + "Service data %1$s%2$s" + "Last fix is the phone’s GPS at hear-time, not a fix on this radio.\n" + "Bluetooth LE advertiser" + "It is on the air, but it did not advertise a product class (no Appearance, Class of Device, or well-known service that names a type)." + "Most likely" + "Probably" + "Could be" + "%1$s %2$s" + " This is what the device is advertising, not a visual ID." + "%1$s device" + "Limited-discoverable: briefly looking for a nearby connection." + "Discoverable: other BLE devices can find it." + "BLE-only: no classic Bluetooth (headsets/file-send radio)." + "May also do classic Bluetooth (BR/EDR) as well as BLE." + "Dual-mode chip: BLE and classic can run together." + "%1$s — long-range BLE (slower, farther)" + "%1$s — faster BLE (Bluetooth 5)" + "%1$s — standard BLE radio" + "Locally administered BSSID. Vehicle, mesh, and guest APs often keep this address. Not a rotating phone MAC." + "Locally administered address. The local bit is set, so this is not an IEEE factory assignment." + "Public factory address (stable, IEEE-assigned)." + "Random / privacy address. The MAC can change, so this is not a lasting identity." + "Anonymous: the stack hid the address." + "Universal IEEE address (stable OUI)." + "not available" + "very strong" + "strong" + "medium" + "weak" + "very weak" + "WPA3 password (SAE handshake)" + "Enhanced Open (encrypted, no password)" + "WPA2 password (PSK)" + "Wi-Fi password (WPA/PSK)" + "Enterprise login (802.1X)" + "WEP (old, weak)" + "Open or encryption not parsed" + "AES encryption" + "TKIP (older, weaker cipher)" + "WPS setup is enabled" + "mesh node" + "ad-hoc network" + "infrastructure access point" + "connection basics" + "attribute protocol" + "model / serial / firmware" + "battery level" + "keyboard, mouse, or gamepad" + "heart-rate sensor" + "blood-pressure sensor" + "temperature / humidity style sensor" + "LE cycling power/speed" + "Google Fast Pair (often buds/speakers)" + "Apple Find My related" + "Tile tracker" + "Sonos speaker" + "August Home lock" + "Apple (not ASSA ABLOY)" + "earbuds or headphones" + "a speaker" + "a mouse" + "a keyboard" + "a game controller" + "a watch or wrist wearable" + "a phone" + "a computer or tablet" + "a finder tag / tracker" + "a remote control" + "an input device (keyboard, mouse, or similar)" + "a heart-rate monitor" + "a health sensor" + "a display or TV stick" + "a clock" + "smart glasses" + "It advertises Appearance as %1$s." + "earbuds or a headset" + "a computer" + "a watch" + "an audio device" + "an input accessory" + "Class of Device says %1$s." + "a keyboard, mouse, or gamepad" + "It offers the HID (human-interface) service." + "headphones, a headset, or a speaker" + "It offers a classic audio / headset service." + "LE Audio earbuds or a speaker" + "It offers Bluetooth LE Audio services." + "It offers the Heart Rate service." + "a blood-pressure monitor" + "It offers the Blood Pressure service." + "an environmental sensor" + "It offers Environmental Sensing." + "earbuds or a speaker" + "Google Fast Pair is present (common on buds and speakers)." + "a Samsung SmartTag" + "SmartTag service UUID." + "an Apple Find My accessory" + "Find My related UUID." + "a Tile tracker" + "Tile service UUID." + "a DULT finder tag" + "a Google Find Hub tag" + "an Apple AirTag / Find My tag" + "Matched signature %1$s." + "an iPhone, iPad, or Mac" + "a Windows / Surface / Xbox radio" + "a Tesla BLE tire sensor" + "a BLE tire-pressure sensor" + "Vuzix smart glasses" + "a Tesla vehicle (including Cybertruck) or phone-as-key" + "a Pixel or other Google radio" + "Sony headphones, a TV, or a camera" + "Bose headphones or a speaker" + "a Garmin watch or inReach" + "an Echo, Fire, or other Amazon radio" + "an Oura ring" + "a Logitech mouse, keyboard, or webcam" + "JBL or Harman audio" + "a Sonos speaker" + "a DJI Osmo action camera" + "a DJI drone or controller" + "a drone broadcasting ASTM Remote ID" + "a Skydio drone" + "an Autel drone" + "a Parrot ANAFI or Bebop drone" + "a HOVERAir flying camera" + "a NETGEAR or Orbi access point" + "a TP-Link access point" + "an ASUS access point" + "a Linksys or Velop access point" + "an Eero mesh node" + "a Google Wifi or Nest Wifi point" + "a D-Link access point" + "a Belkin access point" + "an Xfinity gateway or hotspot" + "a Spectrum gateway or Spectrum Mobile hotspot" + "an AT&T gateway or attwifi hotspot" + "a Verizon or Fios gateway" + "a Starlink router" + "a Cisco Meraki access point" + "a Cisco Aironet, Catalyst, Business, RV, or SPVTG access point" + "a Juniper Mist access point" + "a T-Mobile Home Internet gateway or hotspot" + "a HUMAX gateway (often T-Mobile Home Internet)" + "a Sagemcom ISP gateway" + "an Arcadyan ISP gateway" + "an Askey ISP / 5G gateway" + "a Calix fiber gateway" + "a Nokia Solutions and Networks gateway" + "an AirTies ISP mesh node" + "a Tenda access point" + "a Ruijie or Reyee access point" + "a DWnet access point" + "a WAVLINK access point" + "a Sercomm ISP gateway" + "a Luxul access point" + "a Sophos firewall or access point" + "an AUMOVIO / Continental vehicle Wi-Fi radio" + "a CenturyLink gateway" + "a GM in-car hotspot (Cadillac / GMC / Buick / Chevrolet)" + "an Audi MMI in-car hotspot" + "an Extreme Networks access point" + "an Adtran fiber gateway (often CenturyLink / Quantum Fiber OEM)" + "a Cambium or IgniteNet access point" + "a TRENDnet access point" + "a Cudy travel or home router" + "a SnapAV / Control4 / Wattbox access point" + "an Arlo camera or VMB base station" + "a Vantiva or Technicolor ISP gateway" + "a Hitron cable gateway (often Xfinity OEM)" + "an Actiontec FiOS or Frontier gateway" + "a Buffalo AirStation or router" + "a Grandstream GWN access point" + "an Edgecore access point" + "a WatchGuard firewall or access point" + "a Mojo Networks / Arista Cognitive Wi-Fi access point" + "a Winegard RV or marine Wi-Fi radio" + "an Inseego 5G or MiFi hotspot" + "a Franklin Technology 5G home-internet gateway (RG3100 class)" + "a Synology NAS or router access point" + "an HPE Aruba Instant or Instant On access point" + "a RUCKUS access point" + "a Fortinet FortiAP or FortiWiFi" + "a MikroTik router or access point" + "an EnGenius access point" + "a Zyxel gateway or access point" + "a Peplink or Pepwave router" + "an OpenWrt router" + "an Arris or SURFboard cable gateway" + "a Ubiquiti UniFi access point" + "a UniFi Protect Instant camera" + "a UniFi / Ubiquiti name" + "an ecobee thermostat" + "a Sensi thermostat" + "a Honeywell Home or Lyric thermostat" + "a Honeywell Xenon healthcare barcode scanner" + "an Omron blood-pressure cuff or scale" + "a Withings scale or blood-pressure monitor" + "a Dexcom glucose sensor" + "a Nest thermostat or Nest Labs BLE sensor" + "a Nest Protect, camera, or other Weave BLE device" + "a Haiku or Mammoth ceiling fan" + "a Tuya BLE gadget (plug, light, camera, sensor)" + "an ASSA ABLOY lock, Yale lock, HID reader, or Seos credential" + "an August smart lock" + "a Schlage or Allegion lock" + "a Nuki lock or opener" + "a SALTO access reader or lock" + "a dormakaba, Saflok, or Oracode lock" + "a Lockly smart lock" + "a Kwikset Kevo or Unikey lock" + "a Master Lock padlock" + "an igloohome lock or keybox" + "a Tedee smart lock" + "a Paxton reader or Net2 access point" + "a Kwikset lock" + "a Chamberlain myQ garage hub" + "a Chevrolet in-car Wi-Fi hotspot" + "a Rivian vehicle, phone key, or sensor" + "a Ford or Lincoln vehicle or phone-as-key" + "a Honda or Acura vehicle or phone-as-key" + "a Hyundai or Genesis vehicle or phone-as-key" + "a Toyota or Lexus vehicle or phone-as-key" + "a Nissan or Infiniti vehicle or phone-as-key" + "a Subaru vehicle or phone-as-key" + "a BMW vehicle, phone-as-key, or factory hotspot" + "a Volkswagen vehicle or phone-as-key" + "a Porsche vehicle or phone-as-key" + "a Jaguar, Land Rover, or Range Rover" + "a BYD vehicle or phone-as-key" + "a Govee light or sensor" + "an HP printer" + "an Epson EcoTank or WorkForce printer" + "an LG webOS TV" + "a Roku streaming stick or Roku TV (often a hidden Wi-Fi Direct remote AP)" + "a Samsung fridge, range, oven, or cooktop (setup AP)" + "an EcoWater water softener (setup AP)" + "a Nespresso coffee machine" + "a RadiaCode radiation detector" + "Shokz OpenRun or OpenFit headphones" + "a Mercedes MBUX in-car hotspot" + "a Motive / KeepTruckin fleet ELD hotspot" + "a PeopleNet fleet ELD hotspot" + "a Uconnect in-car hotspot" + "a CarPlay in-car hotspot" + "a Cradlepoint vehicle router (often public-safety / fleet)" + "a Sierra Wireless AirLink vehicle gateway" + "a Compex access point (sometimes public-safety / fleet)" + "a Novatel Wireless / Inseego vehicle radio" + "a Utility, Inc vehicle or public-safety radio" + "a GL.iNet travel router" + "a Target Atrius basket tag" + "a Minew BLE beacon or sensor" + "an Estimote beacon" + "a Kontakt.io beacon" + "an Iteris BlueTOAD / Vantage Velocity roadside Bluetooth travel-time reader" + "a BLIP Systems BlipTrack roadside travel-time sensor" + "a Flock Raven or ShotSpotter acoustic gunshot sensor" + "a Digital Ally body-worn or in-car camera" + "a Reveal Media body-worn camera" + "a Wolfcom body-worn or in-car camera" + "a Panasonic i-PRO camera or Arbitrator in-car system" + "a Limitless Pendant conversation recorder" + "a Bee Pioneer wearable recorder" + "an Omi pendant or OpenGlass camera glasses" + "a Friend Pendant necklace" + "Brilliant Labs Frame AR glasses" + "Even Realities G1 glasses" + "a Hayden AI bus- or vehicle-mounted camera" + "a Miovision intersection traffic camera" + "a Tattile plate reader" + "an LVT / LiveView solar surveillance trailer" + "a Hanwha Vision / Wisenet camera" + "a Uniview / UNV camera" + "a Rhombus cloud camera" + "a MeshCore LoRa companion radio" + "a goTenna Mesh or Pro radio" + "a SenseCAP LoRaWAN / Helium gateway" + "a RAK WisGate LoRaWAN gateway" + "a GhostESP ESP32 audit board" + "a Bruce ESP32 pentest board" + "a camera-module radio (LiteOn or similar)" + "a finder tag" + "a Wi-Fi Direct access point" + "SSID starts with DIRECT-." + "a phone or TV using Wi-Fi Direct" + "a phone hotspot" + "SSID looks like a phone hotspot." + "a mesh Wi-Fi node" + "Capability list includes mesh." + "a hidden Wi-Fi access point" + "SSID is hidden; the radio is still beaconing." + "a Wi-Fi access point" + "Stock Android only reports beaconing APs." + "Very Close" + "Closer" + "Further" + "About the same" + "Listening…" + "Quiet" + "Gone" + "Screaming loud here. Look around — usually in-hand, pocket, or the same bag. Not meters." + "Louder than a few seconds ago. Keep walking that way." + "Quieter than a few seconds ago. Turn or back up." + "No clear change yet. Slow down; hold the phone still." + "Need a few seconds of packets to compare." + "No packet for a few seconds. Silent, or behind a wall." + "Left the live set. Randomized BLE often vanishes mid-hunt." + "Pinned to this MAC. Shows on Debrief, Compare, Path, and AI Export. Does not turn Alert on." + "%1$s\nMarked mine" + "Marked mine" + "Shows on Live. Bookmark (top-right) is the alert; this does not turn it on." + "%1$s Pinned to this BSSID. Vehicle, mesh, and guest APs often keep a locally administered address." + "Marked mine" + " Marked mine" + "off" + "FIELDWATCH FIELD DEBRIEF" + "FIELD DEBRIEF" + "Field debrief" + "DISCLAIMER" + "Takeaway: %1$s" + "MAC tails (**:**:**) and GPS coordinates masked. Logs on the phone are unchanged." + "Privacy" + "unknown" + "ch %1$s" + "strongest %1$s dBm" + "Heard %1$s AP(s); %2$s hidden SSID; %3$s sat most of the window." + "Loudest APs:" + " Marked mine" + " extra attention (%1$s): %2$s" + "Hidden SSIDs:" + " · %1$s %2$s %3$s dBm ch %4$s" + "Heard %1$s advertiser(s); %2$s with randomized addresses; %3$s signature-matched." + "Unmatched rotating BLE omitted from lists (%1$s). Counts include them. Sit export has every radio." + "Notable BLE:" + "None in this window." + " Marked mine" + " extra attention (%1$s): %2$s" + "Named or marked:" + "Sat most of this window: %1$s" + "First seen in this window: %1$s" + " · %1$s %2$s dwell %3$s" + " · None." + "Loudest first seen:" + "No extra flags. Signature hits, Extra attention, and tracking callouts already cover named pattern matches." + "GPS tagging off — no path" + "GPS tagging on, fewer than 2 fixes in this window" + "traveled %1$s along path · span %2$s · %3$s fixes" + "Executive summary" + "Where you were" + "Tracking assessment" + "Possible trackers with you" + "Finder tags (AirTag / Find My, SmartTag, Tile, Chipolo, Pebblebee) and loud pocket Apple BLE. These radios stayed with your GPS path for this sit. Fieldwatch cannot tell your own tag or phone from a tracker planted in the car, bag, or on you before you started. Account for each MAC. Not a finding and not identity." + "Possible tail" + "Finder tags that were not heard when this sit started, then stayed with your path. That can mean someone started following you (their phone or tag), or a device was added during the trip. Not a finding and not identity." + "Retail beacons with you" + "iBeacon / Minew / Estimote / Kontakt.io / Target Atrius basket radios that stayed with your GPS path. Location beacons are usually fixtures in a store or venue — they do not typically move with you. If one did, account for it (a Target basket you pushed, your own test tag, a badge, or a short path that still overlaps a fixture). Not the same as a Find My tail. Not a finding and not identity." + "Wearables with you" + "Garmin / Fitbit / Oura radios that stayed with your GPS path. Watches and rings usually move with the person wearing them — often your own kit or someone walking with you. They are not typically planted trackers. Account for each MAC. Not a finding and not identity." + "Environment" + "Networks (Wi-Fi access points)" + "Bluetooth LE" + "Signature hits" + "Persistence" + "Pattern match, not identity, not a skimmer detector, not a safety finding." + "Anomalies" + "Flood" + "Recommended actions" + "sit %1$s (%2$s → %3$s UTC)" + "last 15 minutes (%1$s → %2$s UTC)" + "FIELDWATCH SIT — %1$s" + "Generated" + "Sit" + "Window" + "Tool" + "Fieldwatch (app.fieldwatch) · stock Android · receive-only Wi-Fi AP + BLE advertiser" + "Scan" + "%1$s · stale %2$ss · brief hold %3$ss" + "GPS tag" + "on" + "Distance" + "Places" + "Classification" + "Operationally sensitive — neighbor SSIDs, MACs, operator GPS, advertised aircraft track" + "Operationally sensitive — neighbor SSIDs, MACs, operator GPS" + "SIT" + "Sit — %1$s" + "OPERATOR PATH" + "North-up. Line is this phone (%1$s). A MAC alert or a signature alert is drawn once. A decoded latitude and longitude is the last advertised position. Anything else is the strongest hear. A number is that place (Path key)." + "## Where you were (operator GPS)" + "- Tag detections with GPS: %1$s." + "- Online place names: " + "off (Settings → Online place names in Debrief). No reverse-geocode this export." + "- Following test: insufficient movement (need ~45 m span). Do not infer a tail." + "## GPS co-travel" + "Only radios that stayed with the operator path are listed. House tags and other radios the operator only passed are omitted — they are not tracking. Not identity. Find My MAC rotation will not stitch a tail that changes address. Possible tail extra gates (walks): trail covers ≥ half the operator path, ≥ 2/3 of GPS stamps at −75 dBm or louder, last stamp not 12 dB below loudest. Fail any one → omit (pass-by), not a tail. Finder tags (AirTag / SmartTag / Tile / Chipolo / Pebblebee / Find My / loud pocket Apple) are the tracking test. Retail beacons and wearables that co-travel are listed separately — they do not typically move with you (beacons) or are usually own kit (wearables)." + "- None stayed with the path." + "- %1$s %2$s %3$s RSSI %4$s dBm (min %5$s / max %6$s) trail %7$s fixes, span %8$s m" + "Possible trackers with you (finder tags, whole sit — yours or planted before you started)" + "Possible tail (finder tags, first heard after this sit started, then stayed)" + "Retail beacons with you (iBeacon / Minew / Estimote / Kontakt.io / Target Atrius basket — fixtures; a pushed cart will co-travel)" + "Wearables with you (Garmin / Fitbit / Oura — usually own kit or a companion)" + "Operator GPS path too short (%1$s m) to test following." + "Heard, but not at two GPS points. Cannot test co-travel." + "Heard along %1$s m of your %2$s m path and still loud (%3$s dBm). " + "Heard near one place (%1$s m span) while you moved %2$s m. Looks stationary — you walked away from it." + "Moved %1$s m with you, already on the air when this 15-minute window opened, strong (%2$s dBm). " + "GPS samples span %1$s m along your path (%2$s fixes). Already on the air when this window opened and still here. " + "Heard along %1$s m (%2$s GPS stamps) but did not stay loud on you. Neighborhood arc / pass-by, not a tail." + "Stayed loud with you the whole sit (%1$s to %2$s dBm, %3$s GPS stamps). " + "Decoded: %1$s." + "With you the whole sit — yours or planted before you started. Account for it." + "Location beacons do not typically move with you. Account for it (own test tag, badge, or a short overlap with a fixture)." + "Typical of a watch or ring you or a companion are wearing. Not typically a planted tracker." + "%1$s Find My / iPhone addresses rotate; this MAC is this session." + "Appeared after the sit started, but last GPS stamp was %1$s dBm after a loudest of %2$s dBm (−%3$s dB). Looks like you walked away from a fixture, not a tail." + "Appeared after the sit started and GPS span was %1$s m, but only %2$s/%3$s stamps were loud (−75 dBm+). Looks like a pass-by, not a tail." + "Appeared after the sit started, but was only heard along %1$s m of your %2$s m path (%3$s%%). Neighborhood arc / pass-by, not a tail." + "Appeared after the sit started, then stayed loud with you across %1$s m (%2$s m of your %3$s m path, %4$s%%; %5$s/%6$s GPS stamps ≥ −75 dBm). " + "Treat as a possible tail until you visually account for it." + "Unusual for a retail/location beacon — they do not typically move with you. Account for it; not the same as a Find My tail." + "Typical of a watch that joined the sit (you put it on, or someone walking with you). Not typically a planted tracker." + "In sit %1$s" + "In the last 15 minutes" + "%1$s Fieldwatch heard %2$s Wi-Fi access points and %3$s BLE advertisers" + " (%1$s signature-matched" + ", %1$s marked mine" + ", %1$s hidden SSIDs, %2$s randomized BLE). " + "Overall distance traveled: %1$s along the GPS path (straight-line span %2$s). " + "Stops / area: %1$s. " + "TRACKING NOTE. " + "%1$s finder tag(s) with you the whole sit (your kit or planted before you started): " + "%1$s possible tail(s) first heard after this sit started: " + "Account for every MAC — Fieldwatch cannot tell yours from a plant. " + "GPS tagging is off, so a following test was not performed. Enable “Tag detections with GPS” and walk to test. " + "GPS displacement was only %1$s m — too short to test whether a tracker is following. Walk farther with tagging on. " + "No finder tag clearly stayed with the GPS path in this window. " + "Retail beacon(s) also stayed with the path (unusual — fixtures do not typically move with you): " + "Wearable(s) stayed with the path (usually your watch/ring or a companion): " + "GPS tagging is OFF. Fieldwatch cannot test whether a radio moved with you." + "Turn on Settings → Tag detections with GPS, walk or drive 50+ m, then run Debrief again." + "Overall distance traveled: %1$s along the GPS path (%2$s samples). Straight-line span %3$s." + "Co-travel is split by class: finder tags (AirTag / Find My, SmartTag, Tile, Chipolo, Pebblebee, loud pocket Apple), retail beacons (iBeacon, Minew, Estimote, Kontakt.io, Target Atrius basket), and wearables (Garmin, Fitbit, Oura)." + "Insufficient movement to distinguish a radio that stayed with you from one you passed. Walk or drive farther and re-run." + "No finder tag, retail beacon, or wearable stayed with you. House tags and other radios you only passed are not listed." + "Callouts below are only radios that stayed with the path. Radios you passed (store fixtures, house tags) are omitted." + "Radios you marked mine. Heard in this window. Still listed. No beep while the mark is on." + "Marked mine. First heard after the sit started and stayed with the path." + "Marked mine. With you the whole sit." + "Marked mine." + "Your captions on radios heard in this window. Same KIND+MAC as Named radios. Not catalog Notes." + " %1$s %2$s RSSI %3$s dBm (min %4$s / max %5$s)" + "Phone GPS at hear-time, not the other radio’s location and not a camera pole. Stays are clusters within about 40 m; hops between them are transit. Coordinates are not repeated on every Wi-Fi/BLE line." + "GPS tagging is OFF. Turn on Settings → Tag detections with GPS to record where you were when radios were heard." + "GPS tagging is on, but this window has no fixes yet." + "Overall: %1$s along-track, span %2$s, %3$s fixes." + "Street names are approximate. Do not treat a street as the location of a matched camera or tag." + "No path legs." + "Stops: " + "%1$s. Stay %2$s–%3$s UTC (%4$s)" + " Heard here: %1$s AP(s), %2$s BLE" + " · No GPS-stamped radios tied to this stay (tagging may have started after they were first heard)." + "%1$s. Transit %2$s–%3$s UTC %4$s along track" + "One stay — you did not move far enough in this window to split locations." + "In motion (walk/vehicle) through mixed RF." + "Likely a dwelling or small office — few sitting APs, limited BLE." + "Dense public / retail / street: many APs and phone-like randomized BLE." + "Likely a building with standing infrastructure APs plus patrons." + "Mixed or under-sampled environment." + "%1$s (%2$s APs, %3$s BLE, %4$s persistent APs, traveled %5$s, span %6$s.)" + " ch " + " hidden" + " dwell " + " RAND" + "A radio in two classes counts in each." + "Google Fast Pair in pairing mode: " + "Google Fast Pair in pairing mode: %1$s radios." + "Very strong unnamed radios (≥ −50 dBm): " + "High randomized BLE (%1$s) — typical of phones, not a tracking finding." + "Hidden SSIDs still beacon and identify the AP by BSSID. " + "This debrief includes operator GPS samples used for distance and the following test. " + "This debrief includes advertised aircraft positions from radios that broadcast a latitude and longitude. " + "Street names came from the phone’s system geocoder while online. " + "Do not share this file off-device without redaction." + "Possible tail (appeared after this sit started): %1$s. Pause Live, open detail, note RSSI while you walk a dog-leg. Do not disable someone else’s tag." + "Possible trackers with you: %1$s. Could be yours or planted in the car/bag/on you before you started. Account for each MAC — do not dismiss as yours." + "Retail beacons with you (unusual — fixtures do not typically move with you): " + ". Account for a test tag or badge before treating it as a follower." + "Wearables with you (usually own kit or a companion): " + "Enable Tag detections with GPS and walk 50+ m, then run Debrief again for a following test." + "Walk farther (50+ m) with GPS tagging on, then re-run Debrief." + "Use Live → Pause to inspect a busy list. Watch tracker signatures if this sit was noisy." + "Station-side Wi-Fi (probes/clients) still needs a dedicated sniffer — Fieldwatch cannot see them." + " Retail beacon(s) also with the path (unusual): " + " Wearable(s) with the path (usually own kit): " + "Possible tail (appeared after sit started): %1$s. Also finder tags with you (yours or planted before): %2$s. Account for every MAC." + "Possible tail (appeared after this sit started): %1$s. Account for it on the person/vehicle." + "Turn on GPS tagging and walk before you can test whether a tracker is following you." + "Not enough GPS movement (%1$s m) to test following; walk and re-run Debrief." + "Finder tags with you (yours or planted before you started): %1$s. No new arrival this window. Account for each MAC — do not dismiss as yours." + "No finder tag stayed with the path." + "No signature hits and no GPS co-travel of trackers in this 15-minute window." + "No finder tag, retail beacon, or wearable clearly stayed with your GPS path in this window." + " and %1$s more" + " Advertised position: %1$s%2$s." + "%1$s (%2$s, operator phone)" + "%1$s (operator phone; no street name this export)" + "%1$s (operator phone)" + "Flood" + "OPERATOR PATH" + " Marked mine" + " Marked mine" + " RAND" + "Fieldwatch watchlist" + "%1$s appeared\n%2$s %3$s\n%4$s dBm %5$s" + "AIRCRAFT" + "%1$s km span" + "%1$s m span" + "%1$s km path" + "%1$s m path" + "This sit" + "Stay" + "Second advertised" + "MAC alert" + "Signature alert" + "Pilot" + "Path key" + "Observer: %1$s" + "%1$s %2$s radios at this stop — %3$s" + "EXTRA ATTENTION · %1$s" + "TAKEAWAY" + "Off Grid Pete LLC · operationally sensitive" + "Online lookup failed silently. Coordinates only." + "Online lookup skipped: no working internet. Coordinates only." + "Online lookup skipped: this phone has no system geocoder (needs Google Play / network location). Coordinates only." + "Online lookup on, but this sit had no GPS stamps to name." + "Looking up place names (%1$s of %2$s)…" + " · %1$s %2$s (no name returned)" + "Online lookup: system geocoder named %1$s distinct GPS cell(s). Street names are from the phone’s network geocoder, not a Fieldwatch cloud. Approximate." + "Online lookup ran, but the system geocoder returned no street names. Coordinates only." + "Stay %1$s" + "Transit from" + "Transit to" + "Path start" + "Path end" + "a catalog signature" + "Already tagged" + "Matched %1$s. A second signature can still dual-label this radio (store UUID, product OUI)." + "This radio only" + "No unique on-air ID to cluster on. Randomized addresses, house-like names, and generic chips are skipped." + "No other MAC in the log or on the air shares this %1$s. A signature from here will mostly tag this address." + "Strong family" + "Possible family" + "Same %1$s on %2$s. That is a catalog pattern, not this MAC." + "Same %1$s on %2$s. Thin sample — a possible catalog family." + "Same name glob on %1$s %2$s — not a house SSID." + "Same vendor IE on %1$s BSSIDs. Not WPS / P2P." + "Same service UUID on %1$s BLE advertisers." + "Same manufacturer data prefix on %1$s BLE advertisers." + "Same IEEE OUI on %1$s stable BSSIDs. Not a chip-module prefix." + "name glob" + "vendor IE" + "service UUID" + "manufacturer data prefix" + "IEEE OUI" + "on-air ID" + "%1$s %2$s in the log (%3$s on the air now)" + "%1$s %2$s in the log" + "%1$s %2$s on the air now" + "%1$s %2$s in the log matched %3$s. Shared on-air ID, not a one-radio MAC. Change the name or class, then Save." + "AP" + "APs" + "advertiser" + "advertisers" + "Last 15 minutes is the Live RAM set (about 400 radios). A named sit keeps up to %1$s. Counts are not the same net." + "Windows" + "This sit" + "Radios this phone heard. Kind + MAC. BLE rotation is a new row. Not a radio fix." + "Only in this sit (%1$s)" + "Only in second sit (%1$s)" + "In both (%1$s)" + "This radios" + "Second radios" + "Caps" + "RAM ~400 vs sit %1$s" + "%1$s vs %2$s" + "FIELDWATCH SIT COMPARE" + "SIT COMPARE" + "A MAC alert or a signature alert is drawn once. A decoded latitude and longitude is the last advertised position. Anything else is the strongest hear. A number is that place (Path key)." + "Two walks on one north-up frame. Green = this sit. Slate = second sit. %1$s" + "North-up. Line is this phone. %1$s" + "OPERATOR PATHS" + "last 15 minutes in memory (about 400 radios)" + "named window (up to %1$s)" + "%1$s: %2$s\n%3$s radios · %4$s\n" + "Only in this sit." + "Only in second sit." + "Only in this sit" + "In both" + "Only in second sit" + "(none)" + " decoded value changed: " + " Extra attention" + "%1$s only in this sit · %2$s only in the second · %3$s in both." + "%1$s · %2$s marked mine." + "this sit" + "second sit" + "Radios you marked mine. Heard in either window. Still listed." + "Your captions on radios heard in either window. Same KIND+MAC as Named radios. Not catalog Notes." + " Extra attention" + "Only in this sit" + "Only in second sit" + "In both" + "Fieldwatch scanning" + "Stop" + "Passive Wi-Fi and Bluetooth scan status" + "Finder tags" + "Retail beacons" + "Signage" + "Wearables" + "Surveillance" + "Drones" + "Pentest" + "Body-worn" + "Public safety" + "Vehicle" + "Glasses" + "Audio" + "Cameras" + "Thermostats" + "Access control" + "Health" + "Home IoT" + "ISP / routers" + "Mesh" + "Phones / PCs" + "Other" + "Classic radar" + "Strength list" + "Timeline" + "Hybrid + sparklines" + "By class" + "Class" + "Signature" + "Class + signature" + "Name A–Z" + "Class A–Z" + "Wi-Fi" + "BLE" + "Battery saver" + "Balanced" + "High performance" + "Unmatched" + "Appearing signatures and devices on your watchlist. Beep is played separately." + "This is a hobby project, provided as-is under the MIT License. Use at your own risk." + "Detections, pattern matches, “Moving with you” / “possible tail,” Debrief text, and AI Export are hypotheses — not identity, not a legal finding, and not a complete RF capture. Radios that are off, asleep, randomized, cellular-only, or hidden by the OS will not appear." + "You are solely responsible for how you use this app and for following local law. To the maximum extent permitted by law, Off Grid Pete LLC is not liable for indirect, incidental, special, consequential, or punitive damages arising from its use." + "GPS stamps are this phone at hear-time, not the other radio, unless a decode map advertises its own latitude/longitude (Remote ID Location). Sharing a Debrief, sit compare, AI Export (sit or one radio), radio-detail Share as text, or log can take that path off the device. A TAK/CoT feed, when you turn it on, sends markers onto the LAN; that is on the operator." + "By checking the box and continuing, you accept these terms and the MIT License." + "named sit “%1$s” (radios heard in that window; Live list cap still applied while watching). " + "the in-memory live set (last 15 minutes, cap about 400). " + "%1$s\n\n%2$s\n\n%3$s\n\n%4$s\n\nThis sit report is from %5$sDo not use it in any situation where safety is in question." + "%1$s\n\n%2$s\n\n%3$s\n\n%4$s\n\nThis compare is two windows of radios this phone heard (kind + MAC). BLE rotation is a new row. Do not use it in any situation where safety is in question." + "## Disclaimer (repeat this in your answer)" + "Do not use Fieldwatch, this paste, or your analysis in any situation where safety is in question." + "Begin your reply with this disclaimer. Do not give safety advice." + "%1$s Wi-Fi · %2$s BLE · %3$s signatures" + "The selected language has no available text-to-speech voice. Install its voice data in Android text-to-speech settings. Beep alerts are unchanged." + "Actiontec" + "Actiontec FiOS / Frontier gateway. Often a Verizon box; the Verizon name row can also hit." + "Adtran" + "Adtran fiber gateway. Common CenturyLink / Lumen / Quantum Fiber OEM." + "Aftermarket TPMS" + "Aftermarket BLE valve-cap tire sensor (TPMS1 / FBB0 family). Decoded fields can show wheel, pressure, temperature, battery, and alarm. Pattern match, not that vehicle." + "Wheel" + "1" + "2" + "3" + "4" + "Sensor id" + "Pressure" + "Temperature" + "Battery" + "Alarm" + "ok" + "no pressure" + "AirLink" + "Sierra Wireless AirLink vehicle / fleet gateway. Common in public-safety and commercial fleets. Hidden SSIDs still hit on the board vendor." + "Sierra Wireless AirLink vehicle gateway. Common in US police and public-safety fleets; also commercial fleet. Hidden or renamed SSIDs still hit on the Sierra Wireless IEEE OUI. Pattern match, not that agency or that car. Look with your eyes." + "AirTies" + "AirTies ISP mesh extender. Carrier-issued home mesh." + "Amazon" + "Amazon Echo, Fire, or Kindle when it advertises. Not every AmazonBasics gadget." + "Apple AirTags" + "Apple AirTag or Find My accessory. iPhones also send Find My so they can be located — that stays on Apple Device unless the name is AirTag. Addresses rotate." + "Apple audio" + "AirPods, Beats, or an Apple TV/speaker advertising AirPlay. Not an AirTag and not the iPhone itself." + "Apple Device" + "iPhone, iPad, or Mac advertising Continuity (Nearby, Handoff, AirDrop, Instant Hotspot). Find My on this radio is the phone locating itself, not a second AirTag. Not AirPods." + "Arcadyan" + "Arcadyan ISP gateway or mesh. Common Verizon / T-Mobile OEM." + "Arlo" + "Arlo home camera or its base-station Wi-Fi. Consumer camera, not a roadside pole. Renamed SSIDs still hit on the board vendor." + "Arris" + "Arris / SURFboard cable gateway. After the ISP renames the Wi-Fi, the board vendor still hits." + "Aruba" + "HPE Aruba campus or Instant On Wi-Fi access point. An HPE BSSID here is an AP, not a server. Not an HP printer." + "Askey" + "Askey 5G / ISP gateway. Common T-Mobile Home Internet OEM." + "ASSA ABLOY" + "ASSA ABLOY / HID Seos or Yale access credential. Phones on HID Mobile Access can advertise a Seos name." + "ASUS" + "ASUS home router or mesh. An ASUS laptop hotspot can also hit." + "AT&T" + "AT&T hotspot or gateway (attwifi, ATT-GUEST, Pace-style factory names). Many boxes are Pace / Arris OEM." + "Audi MMI" + "Audi in-car MMI hotspot. Factory name from the car, not a dealer." + "August" + "August smart lock (now ASSA-owned). Door lock, not a camera." + "AUMOVIO" + "Vehicle Wi-Fi from an AUMOVIO (ex-Continental) module. The car’s AP, not an ISP router." + "Autel" + "Autel drone, controller, or setup Wi-Fi. In-flight digital license plate is the Remote ID row." + "Avigilon" + "Avigilon building or municipal camera, sometimes with LPR." + "Motorola Avigilon cameras / LPR. Used on municipal poles and commercial sites — video and sometimes plates. Name-only. Pattern match, not that camera. Look with your eyes." + "Axis" + "Axis camera, common on municipal poles and public CCTV." + "Axis Communications cameras, common on municipal poles and public CCTV. Name-only (AXIS-). Pattern match, not that camera. Look with your eyes." + "Axon" + "Axon body-worn camera, in-car system, dock, or TASER. Quiet or LTE-only units will not appear." + "Axon body-worn, in-car (Fleet), dock, or TASER gear. IEEE OUI 00:25:DF is Axon Enterprise. Body 3/4 often advertise BLE on that public OUI while worn. A name like Axon Body is a pattern, not that officer. Quiet or LTE-only units will not appear. The word Axon also hits some ZTE phones. Look with your eyes. Not identity." + "Bee Pendant" + "Bee Pioneer wearable recorder (now Amazon). Always-on audio capture." + "Bee Pioneer wearable recorder (Amazon). BLE service 03d5d5c4. It records conversations. Not proof someone is recording you. A miss is not a clean bill (off, or a different brand). Look with your eyes." + "Belkin" + "Belkin home router. Some Linksys Velop units land here too." + "BlipTrack" + "BLIP Systems BlipTrack roadside Bluetooth/Wi-Fi travel-time sensor. Same job as BlueTOAD Spectra: samples passing phones and in-car radios at two points for speed. Quiet or Ethernet-only cabinets may not advertise. Pattern match, not that cabinet." + "Blue Maestro" + "Blue Maestro Tempo Disc temperature / humidity logger. Decoded fields can show version, battery, and temperature." + "Version" + "Battery" + "Log interval" + "Stored logs" + "Temperature" + "Humidity" + "BlueTOAD Spectra" + "Iteris roadside Bluetooth travel-time reader (Vantage Velocity, now BlueTOAD Spectra / Spectra CV). Samples phones, headsets, and in-car Bluetooth as vehicles pass; matching the same ID at two points gives speed. About 100 m. A sample, not a full count. Quiet or Ethernet-only cabinets may not advertise. Spectra CV also uses 5.9 GHz C-V2X, which Fieldwatch cannot hear. IEEE Iteris OUI can also hit other Iteris roadside kit. Pattern match, not that cabinet." + "BMW" + "BMW phone-as-key or in-car hotspot. Factory BMW_ Wi-Fi is the car, not a dealer showroom." + "Bose" + "Bose headphones or speaker. Quiet Charge / QC buds advertise even when in the case." + "Brilliant Frame" + "Brilliant Labs Frame AR glasses." + "Brilliant Labs Frame AR glasses. BLE service 7A230001. Not proof someone is recording. A miss is not a clean bill (off, or a different brand). Look with your eyes." + "Bruce" + "Bruce ESP32 pentest firmware. Default setup AP is BruceNet. Same boards are DIY; renamed units and evil-portal SSIDs miss." + "Bruce ESP32 pentest firmware default AP (BruceNet). Same boards are DIY. Evil-portal SSIDs look like ordinary Wi-Fi and will not hit this row. Not proof of an attack. Look with your eyes." + "Buffalo" + "Buffalo AirStation home router." + "BYD" + "BYD phone-as-key or vehicle BLE. Pattern match, not a specific model." + "Calix" + "Calix fiber gateway (GigaSpire class). After rename, the board vendor still hits." + "Cambium" + "Cambium or IgniteNet outdoor / WISP access point." + "CARLINK" + "Aftermarket CarPlay / Android Auto adapter hotspot (CARLINK-). Head-unit dongle, not the car’s own modem." + "CarPlay" + "In-car CarPlay / Alpine head-unit hotspot. Factory name from the dash, not an ISP." + "CenturyLink" + "CenturyLink factory gateway name. Renamed fiber Wi-Fi misses this row." + "Chevrolet hotspot" + "Chevrolet in-car hotspot (myChevrolet). Cadillac / GMC / Buick are the GM hotspot row. Not a dealer." + "Chipolo" + "Chipolo item finder (Find Hub / Find My). A tag you clip to keys or a bag." + "Cisco" + "Cisco Aironet / Catalyst / Business access point. Fieldwatch sees AP beacons, not phones or switches. Meraki has its own row." + "Compex" + "Compex vehicle or agency Wi-Fi access point. Same boards appear on other Compex radios." + "Compex Wi-Fi AP. Some US public-safety agencies use these in vehicles. The same IEEE OUIs appear on other Compex radios. Pattern match, not that agency. Look with your eyes." + "Cradlepoint" + "Cradlepoint vehicle router (IBR / R-series). Common in police, EMS, utilities, and commercial fleets. Hidden SSIDs still hit on the board vendor." + "Ericsson Cradlepoint vehicle router (IBR / R-series). Common in US police and public-safety fleets; also utilities, EMS, and commercial fleet. Hidden or renamed SSIDs still hit on the CradlePoint IEEE OUI. Pattern match, not that agency or that car. Look with your eyes." + "Crazyflie" + "Bitcraze Crazyflie lab drone. The name starts with Crazyflie. The Bitcraze company id is not a rule." + "Cudy" + "Cudy travel or home router." + "D-Link" + "D-Link home router. Renamed SSIDs still hit on the board vendor." + "Dahua" + "Dahua camera. Common on commercial CCTV and some public poles." + "Dahua cameras. Common on commercial CCTV and some public poles. Name-only. Pattern match, not that camera. Look with your eyes." + "Dexcom" + "Dexcom continuous glucose monitor (G6 / G7). Pattern match, not a patient." + "Digital Ally" + "Digital Ally body-worn or in-car camera (FirstVu / EVO). Quiet or LTE-only units will not appear." + "Digital Ally body-worn or in-car (FirstVu / EVO family). IEEE OUI 00:23:BD is Digital Ally, Inc. — camera gear, not a chip vendor. Patrol units may stay on LTE and stay quiet. Pattern match, not that officer. Look with your eyes. Not identity." + "DJI" + "DJI aircraft, controller, or setup Wi-Fi. Handheld Osmo cameras are the Osmo row. In-flight digital license plate is the Remote ID row." + "Model" + "Osmo Action 1" + "Osmo Action 2" + "Osmo Action 3" + "Osmo Action 4" + "Osmo Action 5 Pro" + "Osmo 360" + "Osmo Action 6" + "Osmo Nano" + "Osmo Pocket 3" + "Osmo Pocket 4" + "Osmo Pocket 4 Pro" + "Mavic 3" + "Neo 2" + "dormakaba" + "dormakaba, Saflok, or Oracode hotel / commercial lock." + "DULT tracker" + "IETF DULT location-enabled advertisement (Detecting Unwanted Location Trackers). Chipolo, Pebblebee, moto tag, and other partner tags may dual-label. Near-owner vs separated is a bit in the payload. Separated mode can hold a MAC about a day. Pattern match, not that bag." + "Network ID" + "Mode" + "separated" + "near owner" + "Separated from its owner. The address can hold still for about a day." + "Near its owner. With you the whole sit, this is often your own tag. First heard after the sit started, it is more often someone who joined with their own keys." + "DWnet" + "DWnet consumer / SMB access point. Cloud SSIDs are house names, so the board vendor is the hit." + "ecobee" + "ecobee thermostat. Room sensors can hit the same row. Premium uses BLE for setup / Spotify." + "EcoWater" + "EcoWater / water-softener setup Wi-Fi. Home plumbing IoT." + "Edgecore" + "Edgecore campus / open Wi-Fi access point. Cloud SSIDs are site names; the board vendor is the hit." + "Eero" + "Amazon Eero mesh. Not an Echo speaker." + "Electronic shelf label" + "Electronic shelf label (store price tag) on the Bluetooth ESL service. Most Hanshow / SES-imagotag tags use a private radio and will not hit this." + "EnGenius" + "EnGenius Cloud / ECW access point." + "Epson" + "Epson EcoTank or WorkForce printer (Wi-Fi Direct or BLE)." + "Estimote" + "Store or venue location beacon / sticker. Decoded fields can show Nearable vs telemetry." + "Frame" + "Nearable" + "Telemetry" + "eufy Security" + "eufy home camera or tag. Common in houses. Consumer camera, not a roadside pole." + "Even G1" + "Even Realities G1 glasses. The Even Realities company id or an Even G1 name. Nordic UART is too common to use as a rule." + "Even Realities G1 glasses. The Even Realities company id or an Even G1 name. Not proof of recording. A miss is not a clean bill (off, renamed, or paired and quiet). Look with your eyes." + "Extreme" + "Extreme Networks campus access point. Cloud SSIDs are site names; the board vendor is the hit." + "Fast Pair" + "Android accessory (buds, watch, phone) advertising Fast Pair. Pairing-mode is tap-to-pair; longer ads are already-paired plaza noise. Not a person. Filters can hide the plaza chips." + "Fieldy" + "Fieldy pendant — wearable AI note-taker." + "Fieldy wearable AI note-taker (pendant). It records conversations and transcribes them. Not proof someone is recording you. A miss is not a clean bill (off, paired and quiet, or a different brand). Look with your eyes." + "Fitbit" + "Fitbit watch or tracker. Advertisements are identity-only; step counts are not in the broadcast." + "Flipper Zero" + "Flipper Zero (or other Flipper Devices). Default name starts with Flipper; custom firmware can hide it." + "Flipper Zero (or other Flipper Devices) BLE. Default name starts with Flipper; newer units use IEEE OUI 0C:FA:22. Custom firmware can change the name and MAC. Not proof of an attack. A miss is not a clean bill (Bluetooth off, or renamed). Look with your eyes." + "Flock Safety Cameras" + "Flock-style roadside ALPR / camera pole. IEEE B4:1E:52 or a Flock-* / FLCK / Condor / Falcon / Sparrow name. Current poles are often quiet on Wi-Fi and BLE. LiteOn module prefixes are a separate row, not Extra attention." + "Flock-style roadside ALPR / camera pole — reads plates and can be used to locate a vehicle. IEEE B4:1E:52 or a Flock-* SSID is the strong hit. Current poles are often quiet on Wi-Fi and BLE. Pattern match, not that camera. Look with your eyes." + "FOBO TPMS" + "FOBO aftermarket BLE tire-pressure sensor. Motorcycle or car. Pattern match, not that vehicle." + "Ford" + "Ford or Lincoln phone-as-key / infotainment. Not a dealer Wi-Fi name." + "Fortinet" + "Fortinet FortiAP / FortiWiFi campus or branch access point." + "Franklin" + "Franklin 5G / LTE home-internet gateway (often carrier-issued). Guest SSIDs still hit." + "Friend Pendant" + "Friend AI necklace — wearable companion that listens." + "Friend Pendant / necklace. BLE service 1a3fd0e7. It listens to conversations. Not proof someone is recording you. A miss is not a clean bill (off, or a different brand). Look with your eyes." + "FS Ext Battery" + "External battery pack usually associated with a Flock-style camera pole. Name hits are stronger. Current poles are often quiet on Wi-Fi and BLE." + "Usually associated with a Flock-style camera — an external battery pack on the pole. Name hits are stronger. Current poles are often quiet on Wi-Fi and BLE. Pattern match, not that camera. Look with your eyes." + "Garmin" + "Garmin watch, bike computer, or inReach messenger." + "Genetec AutoVu" + "Genetec AutoVu parking or roadside plate reader." + "Genetec AutoVu is municipal / parking ALPR — reads plates at lots and roadside. Name-only when advertised. Pattern match, not that camera. Look with your eyes." + "GhostESP" + "GhostESP ESP32 audit firmware. Default setup AP is GhostNet. Same boards are DIY; renamed units miss." + "GhostESP ESP32 audit firmware default AP (GhostNet). Same boards are DIY. Not proof of an attack. A miss is not a clean bill (renamed). Look with your eyes." + "GL.iNet" + "GL.iNet travel router. Chip-module boards without a GL name miss." + "GM hotspot" + "GM in-car hotspot (Cadillac, GMC, Buick). myChevrolet has its own row. BSSID is often randomized." + "Goodyear TPMS" + "Goodyear intelligent-tire BLE, not the 315/433 MHz valve-stem TPMS in most cars. Pattern match, not that car." + "Google" + "Pixel phone or Chromecast. Fast Pair accessories are the Fast Pair row." + "Google Find Hub" + "Google Find Hub / Find My Device network tag (Chipolo, Pebblebee, moto tag, and other partners). Nearby vs separated frames. Separated mode can hold a MAC about a day. Addresses otherwise rotate. Pattern match, not that bag." + "Mode" + "nearby" + "separated" + "Nearby its owner. With you the whole sit, this is often your own tag. First heard after the sit started, it is more often someone who joined with their own keys." + "Separated from its owner. The address can hold still for about a day." + "Ephemeral ID" + "Google Wifi" + "Google Wifi / Nest Wifi mesh. Not a Pixel phone and not a Nest camera." + "GoPro" + "GoPro action camera. Decoded fields can show whether it is awake, in Wi-Fi AP mode, or pairing." + "Schema" + "Processor" + "asleep" + "awake" + "Wi-Fi AP" + "off" + "on" + "Pairing" + "no" + "yes" + "New media" + "no" + "yes" + "Model" + "HERO9 Black" + "HERO10 Black" + "HERO11 Black" + "HERO11 Black Mini" + "HERO12 Black" + "MAX 2" + "HERO13 Black" + "HERO (2024)" + "MISSION 1 Pro" + "LIT HERO" + "MISSION 1" + "Media offload" + "no" + "available" + "goTenna" + "goTenna Mesh or Pro companion radio. Pairs over BLE; the mesh itself is UHF and Fieldwatch cannot hear it. Pro is sold to agencies. Pattern match, not that operator." + "Govee" + "Govee light or hygrometer. Lights usually only send a name; hygrometers can decode temp / humidity / battery below." + "Temperature" + "Humidity" + "Battery" + "Grandstream" + "Grandstream GWN office access point." + "Haiku Fan" + "Big Ass Fans Haiku or Mammoth ceiling fan." + "Hak5 Pineapple" + "Hak5 WiFi Pineapple setup or management AP. Cloned café SSIDs from PineAP look like ordinary Wi-Fi and will not hit this row." + "Hak5 WiFi Pineapple setup or management AP (Pineapple_XXXX). That is the admin radio, not every rogue SSID PineAP might impersonate — those look like ordinary café Wi-Fi. A miss is not a clean bill (renamed, or only cloning). Pattern match, not identity. Look with your eyes." + "Hanwha Wisenet" + "Hanwha Vision / Wisenet camera (ex-Samsung Techwin). Common on commercial CCTV and some public poles." + "Hanwha Vision / Wisenet cameras. Common on commercial CCTV and some public poles. A *_WISENET setup SSID is the stronger hit; IEEE 00:09:18 is Samsung Techwin. Pattern match, not that camera. Look with your eyes." + "Hatch" + "Hatch Rest / Restore / Mini sound machine. Nursery / bedroom noise." + "Hayden AI" + "Hayden AI bus- or vehicle-mounted camera used for parking and traffic enforcement." + "Hayden AI cameras ride on buses and city vehicles — video and plates. Name-only when advertised. LTE-only units will not appear. Pattern match, not that camera. Look with your eyes." + "Helium" + "Helium / LoRaWAN hotspot when it advertises a name." + "Hikvision" + "Hikvision camera. Common on commercial CCTV and some public poles." + "Hikvision cameras. Common on commercial CCTV and some public poles. Name-only. Pattern match, not that camera. Look with your eyes." + "Hitron" + "Hitron cable gateway. Common Xfinity OEM; the Xfinity name row can also hit." + "Hobby BLE serial" + "Cheap hobby UART module advertised under a default name (HM-10, JDY, CC41, ESP32 BLE). Common on printers, cars, and DIY. Fieldwatch does not see Classic HC-05/HC-06." + "These default BLE serial names are cheap hobby modules. Some pump/ATM overlays have used boards like this so someone nearby can pull data over Bluetooth. The same modules show up on printers, cars, and DIY. If this radio is loud next to a card reader, treat it with caution and look with your eyes. Not proof of a skimmer. A miss is not a clean bill (name changed, Classic HC-05/HC-06, or cellular). Fieldwatch does not connect and does not try default PINs. Fieldwatch does not see Classic HC-05/HC-06." + "Holy Stone" + "Holy Stone drone. The name starts with HolyStone or Holy Stone. A generic camera name does not match. A renamed network misses." + "Honda" + "Honda or Acura phone-as-key / infotainment." + "Honeywell Home" + "Honeywell Home / Lyric / Resideo thermostat (including Amazon Smart Thermostat). Not Honeywell industrial or smoke gear. T9/T10 room sensors are 900 MHz, not BLE." + "Honeywell Xenon HC" + "Honeywell Xenon healthcare barcode scanner or its charge base (white, disinfectant-ready). Clinic / hospital kit, not a home thermostat and not a warehouse-only Xenon." + "HOVERAir" + "HOVERAir pocket selfie drone. In-flight digital license plate (PRO / PROMAX) is the Remote ID row." + "HP" + "HP home/office printer (ENVY, HP-Print). Office noise. Not HPE Aruba campus Wi-Fi." + "Huawei" + "Huawei home router, or a phone hotspot on a public Huawei address. Randomized hotspots need the factory SSID. Not Honor." + "Hubsan" + "Hubsan drone. The name starts with Hubsan. A renamed network misses. Often no Remote ID plate." + "Huf" + "Huf tire sensor or vehicle access (door handle / PEPS). Not only a valve stem." + "HUMAX" + "HUMAX 5G or cable gateway. Often T-Mobile Home Internet in the field." + "Hyundai" + "Hyundai or Genesis phone-as-key / infotainment. Not a dealer Wi-Fi name." + "iBeacon" + "Generic proximity beacon. Stores, baskets, TVs, and cars can all send this Apple layout. Mute in a dense mall." + "igloohome" + "igloohome keybox or smart lock." + "Inseego" + "Inseego 5G / MiFi hotspot." + "Insta360" + "Insta360 action / 360 camera. The advertised name is often the model plus serial. Consumer camera, not a pole." + "Jaguar Land Rover" + "Jaguar, Land Rover, or Range Rover phone-as-key / infotainment." + "JBL / Harman" + "JBL, Harman Kardon, or some car-audio BLE. Headphones, speakers, or a head unit." + "Kevo" + "Kwikset Kevo smart lock." + "Kontakt.io" + "Store or venue location beacon. Decoded fields can show battery, TX, and whether it is moving." + "Packet" + "Location" + "Battery" + "TX power" + "Channel" + "Model id" + "Moving" + "still" + "moving" + "Kwikset" + "Kwikset smart lock. Kevo has its own row." + "LG webOS TV" + "LG webOS TV. Unnamed LG radios can also hit this row." + "Limitless Pendant" + "Limitless / Rewind AI pendant — wearable conversation recorder." + "Limitless Pendant wearable recorder. BLE service 632de001. It records conversations. Not proof someone is recording you. A miss is not a clean bill (off, paired and quiet, or a different brand). Look with your eyes." + "Linksys" + "Linksys or Velop home mesh. Some Velop units use Belkin boards." + "LiteOn camera radio" + "Wi-Fi module prefixes commonly seen on camera boards (LiteOn and similar). Not Flock\'s IEEE block. Doorbells and other OEM radios use these chips. A Flock name or B4:1E:52 is Flock Safety Cameras." + "Lockly" + "Lockly smart lock, usually while in BLE setup." + "Logitech" + "Logitech mouse, keyboard, or webcam. Office noise." + "Luxul" + "Luxul / Legrand small-business access point." + "LVT LiveView" + "LiveView Technologies (LVT) solar surveillance trailer. Most units are cellular-only and will not appear." + "LVT / LiveView solar camera trailer — parking lots, construction, some city parks. Video and sometimes plates. Most units use cellular and stay quiet on Wi-Fi/BLE. A LiveView or LVT- name is a pattern, not that trailer. Look with your eyes." + "Marauder / Deauther" + "ESP32 Marauder or Spacehuhn-style Wi-Fi deauther on a default name. Same boards are DIY; renamed units miss." + "ESP32 Marauder or Spacehuhn-style Wi-Fi deauther default names. Same boards are DIY. Not proof of an attack. A miss is not a clean bill (renamed). Look with your eyes." + "Master Lock" + "Master Lock Bluetooth padlock." + "Meraki" + "Cisco Meraki campus / cloud access point. Renamed site SSIDs still hit on the board vendor. Not a camera pole." + "Mercedes MBUX" + "Mercedes MBUX in-car hotspot. Factory name from the car." + "MeshCore" + "MeshCore LoRa companion radio. Off-grid text/location, not cellular. Name-only — Nordic UART UUID is every ESP32 serial board and is not this row." + "Meshtastic" + "Meshtastic LoRa mesh node. Off-grid text/location radios, not cellular." + "Microsoft Device" + "Windows PC, Surface, or Xbox advertising Swift Pair / Nearby Sharing." + "MikroTik" + "MikroTik RouterOS access point or travel router." + "Minew" + "Minew location or asset beacon. Often also sends iBeacon or Eddystone on the same radio." + "Miovision" + "Miovision intersection / traffic camera (SmartLink / Scout)." + "Miovision traffic cameras at intersections. Video and sometimes plates. Name-only when advertised. Many units are cellular-only and stay quiet. Pattern match, not that camera. Look with your eyes." + "Mist" + "Juniper Mist campus access point. Cloud SSIDs are site names; the board vendor is the hit." + "Mojo" + "Mojo / Arista Cognitive Wi-Fi campus access point." + "Motive" + "Motive (KeepTruckin) electronic logging device / fleet Wi-Fi in a truck." + "Motorola Vigilant" + "Motorola Vigilant plate reader used by agencies and parking." + "Motorola Vigilant is LPR — plate readers used by agencies and parking. Name-only when advertised. Pattern match, not that camera. Look with your eyes." + "myQ" + "Chamberlain myQ garage-door hub." + "Nespresso" + "Nespresso machine (Vertuo / Barista)." + "Nest" + "Google Nest home camera. Consumer camera, not the Nest thermostat row." + "Nest Thermostat" + "Nest thermostat. Temperature sensors on some generations can hit this too. Not a Nest camera." + "Nest Weave" + "Nest Protect, thermostat, or other Weave-over-BLE home device. Randomized address. Decoded fields can show product and pairing." + "Product" + "Nest Learning Thermostat (1st/2nd)" + "Nest Learning Thermostat (1st/2nd) backplate" + "Nest Learning Thermostat (3rd)" + "Nest Learning Thermostat (3rd) backplate" + "Nest Protect (1st gen)" + "Nest Heat Link backplate" + "Nest Heat Link" + "Nest Pinna" + "Nest Protect (2nd gen)" + "Nest Learning Thermostat (3rd, EU)" + "Nest Learning Thermostat (3rd, EU) backplate" + "Nest Flintstone" + "Nest Cam IQ indoor" + "Nest Hello" + "Nest Heat Link (2nd)" + "Nest Cam IQ outdoor" + "Nest Quartz2" + "Nest Black Quartz" + "Nest Detect" + "Nest Guard" + "Nest Guard backplate" + "Nest Antigua" + "Nest Rose Quartz" + "Nest Moonstone" + "Nest Kryptonite" + "Vendor" + "Nest Labs" + "Yale" + "Weave device id" + "Pairing" + "unpaired" + "paired" + "NETGEAR" + "NETGEAR or Orbi home router / mesh. Renamed SSIDs still hit on the board vendor." + "Nissan" + "Nissan or Infiniti phone-as-key / infotainment." + "Nokia" + "Nokia ISP gateway or small cell. Not a Nokia phone." + "Novatel Wireless" + "Novatel Wireless / Inseego vehicle radio. The same prefix is also on some consumer MiFi hotspots." + "Novatel Wireless / Inseego OUI 28:80:A2. Reported in public-safety vehicle AP work. Same prefix is also on some Inseego consumer MiFi radios. Pattern match, not that agency. Look with your eyes." + "Nuki" + "Nuki smart lock or opener (retrofit on a European cylinder)." + "Omi" + "Omi / OpenGlass wearable recorder or camera glasses. Arduino-default 19B10000 is too common to use as a rule." + "Omi pendant or OpenGlass camera glasses. Name or BLE service 23ba7924. It can record audio (OpenGlass also has a camera). Not proof someone is recording you. A miss is not a clean bill (off, renamed, or a DIY board using other names). Look with your eyes." + "Omron" + "Omron blood-pressure cuff or body-composition scale. Home / clinic health kit, not industrial Omron." + "OpenWrt" + "OpenWrt factory SSID on a flashed travel / DIY router that was never renamed." + "Orbit B-hyve" + "Orbit B-hyve hose timer / irrigation." + "Osmo" + "DJI Osmo handheld / action camera (Action, Pocket, 360, Nano). Not a flying DJI aircraft." + "Model" + "Osmo Action 1" + "Osmo Action 2" + "Osmo Action 3" + "Osmo Action 4" + "Osmo Action 5 Pro" + "Osmo 360" + "Osmo Action 6" + "Osmo Nano" + "Osmo Pocket 3" + "Osmo Pocket 4" + "Osmo Pocket 4 Pro" + "Mavic 3" + "Neo 2" + "Oura" + "Oura wellness ring. Always-on BLE while worn. Pattern match, not that person." + "Pacific TPMS" + "Pacific Industrial OEM tire electronics. BLE TPMS on some newer vehicles." + "Panasonic i-PRO" + "Panasonic i-PRO camera or Arbitrator in-car system. Panasonic TVs and phones use other names." + "Panasonic i-PRO camera or Arbitrator in-car video. Used on buildings and some patrol cars — video and sometimes plates. Name-only (i-PRO / Arbitrator). Not every Panasonic radio. Pattern match, not that camera. Look with your eyes." + "Parrot" + "Parrot ANAFI, Bebop, or Skycontroller. In-flight digital license plate is the Remote ID row. Not a Parrot car kit." + "Paxton" + "Paxton / Net2 door reader or access panel." + "Pebblebee / moto tag" + "Pebblebee or Motorola moto tag item finder. A tag you clip to keys or a bag." + "Penguin" + "Penguin Flock-family external battery. The XUNTONG BLE manufacturer ID is the usual fingerprint; Penguin* names are older firmware. Newer packs often advertise a 10-digit name. Decode fields show the TN serial from manufacturer data when present." + "Penguin is a Flock-family external battery (XUNTONG manufacturer ID). Name hits are older firmware and low uniqueness. Pattern match, not that camera. Look with your eyes." + "MAC in payload" + "Serial" + "PeopleNet" + "PeopleNet truck ELD / fleet Wi-Fi. Separate from Motive." + "Peplink" + "Peplink / Pepwave travel or branch router." + "Phone hotspot" + "Phone personal hotspot on a factory name (AndroidAP, Galaxy, Pixel). Custom hotspot names miss. iPhone hotspots stay on Apple Device." + "Pigvision" + "Pigvision Flock-family / roadside camera name." + "Pigvision is a Flock-family / roadside camera name. Name-only. Pattern match, not that camera. Look with your eyes." + "Plaud Note" + "Plaud Note / NotePin AI meeting recorder." + "Plaud Note / NotePin AI recorder. It records meetings. Not proof someone is recording you. A miss is not a clean bill (off, renamed, or a different brand). Look with your eyes." + "Plume" + "Plume SuperPod / HomePass mesh. ISP-branded pods (xFi and similar) often use the carrier OEM board instead." + "Pokemon GO Plus" + "Nintendo Pokémon GO Plus or Plus + wrist accessory. Not a Joy-Con or Switch." + "Porkchop" + "Porkchop pentest firmware on a Cardputer or Cheap Yellow Display. Default AP name PORKCHOP. BLE spam that spoofs Apple/Android is not this row." + "M5PORKCHOP / Porkchop — pocket Wi-Fi pentest firmware (Cardputer or Cheap Yellow Display). Default CYD remote AP is named PORKCHOP. BACON-mode fake APs brand vendor IE 50:52:4B. A miss is not a clean bill (renamed, passive only, or no AP). Pattern match, not identity, not proof of an attack. Look with your eyes." + "Porsche" + "Porsche phone-as-key or factory Porsche_WLAN hotspot. Separate from Volkswagen." + "Potensic" + "Potensic drone, including ATOM. The name starts with Potensic. A bare ATOM name does not match. A renamed network misses. Often no Remote ID plate." + "Pwnagotchi" + "Pwnagotchi-style Wi-Fi handshake collector. Classic units use a distinctive BSSID and the name pwnagotchi." + "Pwnagotchi-style Wi-Fi handshake collector. Classic units beacon BSSID de:ad:be:ef:de:ad. A pwnagotchi name is a pattern. Not proof of an attack. Look with your eyes." + "RadiaCode" + "RadiaCode handheld radiation detector." + "RAK WisGate" + "RAKwireless WisGate LoRaWAN gateway setup AP (RAK7268_XXXX and similar). Quiet once it is on Ethernet." + "Raven / ShotSpotter" + "Flock Raven or ShotSpotter-style acoustic gunshot sensor, usually on a pole with cameras. BLE UUIDs 3100–3500 are the Raven radio. The XUNTONG battery manufacturer ID is Penguin, not this row. Current Flock-family poles are often quiet on Wi-Fi and BLE." + "Ray-Ban / Meta glasses" + "Ray-Ban Meta / Oakley Meta smart glasses, or a Quest / other Meta wearable." + "Meta / Luxottica BLE — often Ray-Ban Meta smart glasses. The same company IDs show up on Quest headsets and other Meta wearables. Not proof someone is recording. A miss is not a clean bill (paired and quiet, asleep, or a different brand). Look with your eyes." + "RayNeo" + "RayNeo smart glasses. Matches only when the name starts with RayNeo and the advertisement uses the TCL company id. A TCL phone without that name does not match." + "RayNeo smart glasses. The name has to start with RayNeo, and the advertisement has to use the TCL company id. A TCL phone without that name does not match. Not proof of recording. A miss is not a clean bill (off, renamed, or paired and quiet). Look with your eyes." + "Rekor" + "Rekor highway or transit plate reader." + "Rekor is highway / transit ALPR — reads plates on roads and at checkpoints. Name-only. Pattern match, not that camera. Look with your eyes." + "Remote ID" + "In-flight drone digital license plate (ASTM / FAA Remote ID). Decoded fields can show ID, position, heading, and operator. The French Direct Remote ID vendor IE labels this row too. That plate does not fill position or pilot. Pattern match, not a tail number. Wi-Fi Remote ID often misses on stock Android." + "App code" + "Open Drone ID" + "Counter" + "Message" + "Basic ID" + "Location" + "Auth" + "Self ID" + "System" + "Operator ID" + "Message pack" + "Protocol" + "ID type" + "None" + "Serial (CTA-2063)" + "CAA registration" + "UTM UUID" + "Session ID" + "UA type" + "None" + "Aeroplane" + "Helicopter / multirotor" + "Gyroplane" + "Hybrid lift" + "Glider" + "Airship" + "Other" + "UAS ID" + "Status" + "Undeclared" + "Ground" + "Airborne" + "Emergency" + "RID failure" + "Heading" + "Horizontal speed" + "Vertical speed" + "Latitude" + "Longitude" + "Altitude (baro)" + "Altitude (HAE)" + "Height" + "Self ID" + "Operator lat" + "Operator lon" + "Operator ID" + "Reolink" + "Reolink home / small-business camera. Consumer camera, not a roadside pole." + "Retail LED sign" + "BLE LED message display. The advertised name is the sign text, not a product name." + "Reveal Media" + "Reveal Media / BodyWorn camera. Common in UK and some US agencies. Quiet units will not appear." + "Reveal Media body-worn camera (D-series / BodyWorn). Name-only when advertised. Patrol units may stay quiet. Pattern match, not that officer. Look with your eyes." + "Rhombus" + "Rhombus cloud camera. BLE is loudest when the camera is unregistered or offline." + "Rhombus cloud cameras on buildings and some public sites. IEEE CC:47:BD or a Rhombus name. BLE often only while unregistered or offline. Pattern match, not that camera. Look with your eyes." + "Ring" + "Amazon Ring doorbell or camera. Common on houses. Consumer camera, not a roadside pole." + "Rivian" + "Rivian phone-as-key, camp speaker, or sensor. Phones can advertise Rivian Sensor." + "Roku" + "Roku streaming stick or Roku TV. Often a hidden Wi-Fi Direct AP for the remote. Not an ISP router." + "Ruckus" + "RUCKUS Unleashed / ZoneFlex campus access point. Not the Arris cable-modem family." + "Ruijie" + "Ruijie / Reyee campus or SMB access point." + "Ruuvi" + "Ruuvi broadcast sensor tag (temp / humidity / pressure / motion). Decoded fields on this page parse the sensor payload." + "Format" + "Temperature" + "Humidity" + "Pressure" + "Accel X" + "Accel Y" + "Accel Z" + "Battery" + "TX power" + "Movement" + "Sequence" + "MAC in payload" + "Sagemcom" + "Sagemcom ISP gateway. Common Comcast / other cable OEM." + "SALTO" + "SALTO commercial access lock or reader." + "Samsara" + "Samsara fleet tracker or vehicle Wi-Fi. Truck / van telematics, adjacent to Motive." + "Samsung appliance" + "Samsung Family Hub fridge, range, or oven setup Wi-Fi. Not a SmartTag." + "Samsung SmartTags" + "Samsung SmartTag / SmartTag+ item finder. Addresses often rotate." + "Schlage" + "Schlage / Allegion smart lock (Encode and similar)." + "Schrader TPMS" + "Schrader aftermarket BLE TPMS (AirCheck, trailer, RV). Not the 315/433 MHz factory stems." + "SenseCAP" + "Seeed SenseCAP LoRaWAN / Helium indoor gateway setup AP (SenseCAP_XXXXXX). Quiet once it is on Ethernet. Helium-named units can also hit the Helium row." + "Sensi" + "Sensi thermostat in BLE setup." + "SensorPush" + "SensorPush temperature / humidity logger." + "Sercomm" + "Sercomm ISP gateway. Common cable / fiber OEM." + "Shokz" + "Shokz bone-conduction headphones (OpenRun / OpenFit). Worn on the head, not a tracker." + "Skydio" + "Skydio drone (common in US public-safety / enterprise). In-flight Remote ID is often Wi-Fi and easy to miss — the Remote ID row is the license plate." + "Snap Spectacles" + "Snap Spectacles or other Snap BLE product." + "Snapchat BLE company ID — used by Snap Spectacles. Other Snap BLE products could match. Not proof of recording. Look with your eyes." + "SnapAV" + "Control4 / Wattbox home-AV processor or power unit." + "Sonos" + "Sonos home/office speaker. Setup or idle ads. Not a tracker." + "Sony" + "Sony headphones, TV, or camera. Bravia TVs also send a generic iBeacon for Cast — Fieldwatch keeps Sony." + "Sophos" + "Sophos firewall or access point. Campus / SMB Wi-Fi, not a camera pole." + "Spectrum" + "Charter Spectrum setup or hotspot Wi-Fi. Boxes are usually Arris / Hitron / Technicolor OEM." + "Starlink" + "Starlink router. The BSSID is often randomized now — the STARLINK name is the usual hit." + "Subaru" + "Subaru phone-as-key / infotainment. Not Starlink satellite internet." + "SwellPro" + "SwellPro waterproof drone. The name starts with SwellPro. A renamed network misses. Often no Remote ID plate." + "Synology" + "Synology NAS or router access point." + "SYTPMS" + "SYTPMS / BR bicycle or scooter BLE tire sensor. Decoded fields can show gauge pressure, temperature, battery, and motion. Pattern match, not that vehicle." + "Alarm" + "ok" + "zero pressure" + "Rotating" + "no" + "yes" + "Standing still" + "no" + "yes" + "Battery" + "Temperature" + "Pressure" + "T-Mobile" + "T-Mobile Home Internet or hotspot. Gateways are often HUMAX / Arcadyan / Askey OEM." + "Tapo" + "TP-Link Tapo home camera. Consumer camera, not a roadside pole." + "Target Atrius basket" + "Target shopping-basket tag. Dual-labels with generic iBeacon; this row is the store basket." + "Tattile" + "Tattile ALPR camera, common on European roads and some US sites." + "Tattile plate readers on roads and at gates. Name-only when advertised. Pattern match, not that camera. Look with your eyes." + "Tedee" + "Tedee retrofit smart lock." + "Tello" + "Ryze Tello or Tello Talent. The name starts with TELLO or RMTT. A renamed network misses. Often no Remote ID plate." + "Tenda" + "Tenda consumer router or extender." + "Tesla" + "Tesla phone-as-key, vehicle, Wall Connector, or TeslaGW Wi-Fi. iOS also sees an iBeacon layout from the car — that is still Tesla, not a mall beacon. Tire sensors are the tsTPMS row." + "Tesla tsTPMS" + "Tesla BLE tire sensor. Decoded fields can show pressure, temperature, and battery when the sensor is awake. Pattern match, not that car. Phone-as-key stays on the Tesla row." + "Mode" + "sleep" + "sleep" + "sleep" + "sleep" + "sleep" + "Pressure" + "Temperature" + "Battery" + "Tile Trackers" + "Tile item finder. Decoded fields can show a rotating private id — not a serial." + "Private ID" + "TireCheck" + "TireCheck BLE tire-pressure sensor. Pattern match, not that vehicle." + "Toyota" + "Toyota or Lexus phone-as-key, or a factory TOYOTA / LEXUS hotspot." + "TP-Link" + "TP-Link or Deco home router / mesh. A Tapo camera on a TP-Link board can also hit this row." + "TPMS service" + "Bluetooth SIG Tire Pressure Monitoring System service. Any sensor that advertises that standard service." + "TRENDnet" + "TRENDnet consumer access point." + "Tuya" + "Tuya plug, light, camera, or sensor. Dense in some apartments. Decoded fields can show whether it is bound." + "Bound" + "unbound" + "bound" + "Protocol" + "Uconnect" + "Stellantis Uconnect in-car hotspot (Chrysler, Jeep, Ram, Dodge, Fiat). BSSID often randomized." + "UniFi" + "UniFi / Ubiquiti name on Wi-Fi or BLE. Prefer UniFi AP for BSSID hits. Instant cameras stay on UniFi Protect." + "UniFi AP" + "UniFi / Ubiquiti Wi-Fi access point (including airMAX / AmpliFi). Renamed SSIDs still hit on the board vendor or Ubiquiti vendor tag. Instant cameras stay on UniFi Protect." + "UniFi Protect" + "UniFi Protect Instant camera in BLE setup. Not a UniFi Wi-Fi access point." + "Uniview" + "Uniview / UNV / Uniarch camera. Common on commercial CCTV and some public poles." + "Uniview / UNV cameras. Common on commercial CCTV and some public poles. IEEE Zhejiang Uniview OUIs or a Uniview / UNV- name. Pattern match, not that camera. Look with your eyes." + "Utility Inc" + "Utility, Inc vehicle or public-safety access point." + "Utility, Inc radio. Reported in public-safety vehicle AP work. Pattern match, not that agency. Look with your eyes." + "Vantiva" + "Vantiva (ex-Technicolor) ISP gateway. After the ISP renames the Wi-Fi, the board vendor still hits." + "Verizon" + "Verizon or Fios hotspot / gateway name. Many FiOS boxes are Actiontec OEM." + "Verkada" + "Verkada cloud camera, including LPR-capable bullets, on buildings and some public sites." + "Verkada cloud cameras, including LPR-capable bullets. Used on buildings and some public sites — video and sometimes plates. Name-only. Pattern match, not that camera. Look with your eyes." + "Volkswagen" + "Volkswagen phone-as-key or My VW hotspot. Skoda, SEAT, and Porsche have their own rows." + "Vuzix" + "Vuzix smart glasses or other Vuzix BLE wearable." + "Vuzix BLE glasses. Not proof of recording. A miss is not a clean bill (paired and quiet, asleep, or a different brand). Look with your eyes." + "WatchGuard AP" + "WatchGuard firewall or access point. Not WatchGuard Video body-worn cameras." + "WatchGuard Video" + "WatchGuard Video body-worn or in-car camera (now Motorola). Not the WatchGuard firewall company. Patrol units may stay quiet." + "WatchGuard Video body-worn or in-car (VISTA / V300 family). IEEE OUI 00:1D:96 is WatchGuard Video, not the WatchGuard firewall company. Patrol units may stay quiet. Pattern match, not identity. Look with your eyes." + "WAVLINK" + "WAVLINK consumer travel / home router." + "Winegard" + "Winegard RV or marine Wi-Fi antenna / router." + "Withings" + "Withings (Nokia Health) scale or BPM Connect cuff. Not a Nokia phone and not a Nokia ISP gateway." + "Wolfcom" + "Wolfcom body-worn or in-car camera. Quiet units will not appear." + "Wolfcom body-worn or in-car camera. Name-only when advertised. Patrol units may stay quiet. Pattern match, not that officer. Look with your eyes." + "Wyze" + "Wyze home camera. Very common consumer gear, not a roadside pole." + "Xfinity" + "Comcast xfinitywifi hotspot or Xfinity / XFSETUP gateway name. Most boxes are Arris / Hitron / Technicolor OEM — those hit the OEM row." + "Yuneec" + "Yuneec drone. The name starts with Yuneec. A renamed network misses. Often no Remote ID plate." + "Zyxel" + "Zyxel home or SMB gateway / access point." + "All traffic" + "Wi-Fi only" + "BLE only" + "Strong signal" + "Moving with you" + "Watched only" + "<hidden>" + "unnamed LE" + "rand" + "pair" + "gone" + "yes" + "no" + "Write your response in English. Preserve verbatim identifiers and observer notes." + "You are a field RF analyst for the operator who collected this sit. Fieldwatch is a stock-Android, receive-only Wi-Fi access-point + BLE-advertiser listener." + "The **onboard Debrief** (verbatim below) already tabulated the sit: counts, Where you were, tracking callouts, inventories, Extra attention, takeaway. **Do not rewrite that report. Do not reprint inventories or stay lists.** Your job is an addendum the phone cannot write: rates, competing hypotheses, and a stress-test of the onboard tracking callouts." + "Constraints you must respect:" + "- Hear-only. Wi-Fi rows are access points only. BLE rows are advertisers. Kind + MAC. BLE rotation is a new row and will not stitch." + "- Signature / OUI / company matches are hypotheses, not identity, not a person or vehicle." + "- GPS stamps (if present) are this phone at hear-time, not the other radio. Do not place a camera or tag at the GPS pin." + "- Place names (if present) are system reverse-geocode of those stamps." + "- RSSI is loudness at the phone, not meters." + "- Live RAM cap is about 400 radios; unnamed BLE evicts after ~3 min. A named sit keeps more. This is not a complete capture." + "- Do not claim a tracker is following unless the onboard GPS co-travel section supports it. A radio with you the whole sit is not automatically yours — it may be planted. Do not dismiss it. Do not invent a tail the onboard test did not flag. Do not treat a retail beacon as a Find My tail." + "- A radio marked mine was claimed by the operator. Do not treat it as an unexplained follower." + "- A flood note is a burst of new addresses, not a follower." + "- A decoded live value on a tracking row is catalog text for that advertisement. Quote the catalog sentence when the onboard report includes one. Do not stitch that value onto a different MAC." + "- An aircraft block and an amber track are positions the radio advertised. Trails with the same UAS id are one aircraft. They are not this phone\'s GPS and they are not a finding that the aircraft followed the operator." + "- Do not give safety advice. Do not tell the operator they are safe or in danger." + "- Treat this paste as operationally sensitive." + "## Your output (required — this is the addendum the operator reads)" + "Write complete sentences. Headings as below. Short bullets only for Extra attention and tracking rows from the working table. No markdown tables. No code fences. No dump of the onboard inventories." + "1. **Disclaimer** — Repeat the experimental-use disclaimer first." + "2. **What the onboard Debrief already established** — 3–5 sentences. Counts, distance, tracking callouts, Extra attention hits, Observer notes if any, Marked mine if any, Flood if any. Do not reprint inventories." + "3. **What the numbers add** — 5- vs 15-minute counts, RSSI bands, RAND BLE percent, arrivals per minute, persistent vs gone, signature-family mix. Say street vs dwelling vs retail vs vehicle, and 5-minute vs 15-minute change (denser, quieter, stable). Confidence. If GPS ran, path length/span from the working table — do not pin a radio to a stay." + "4. **Extra attention and tracking callouts** — Full identifiers from the working table (complete MAC, name, RSSI min/max, signatures, dwell). Stress-test onboard Possible trackers with you / Possible tail / Retail beacons / Wearables. Agree, qualify, or say the data are too thin. Pattern match, not identity. If none, say none." + "5. **What another sit or Hunt would shrink** — Concrete in-app next steps only (Hunt on one Extra attention row, a longer GPS path, Compare sits, Filters). No safety advice. No “call the police.”" + "**Takeaway (required, last line).** One sentence starting with `Takeaway:` that adds *one number the onboard takeaway does not already say* (a rate, RAND percent, 5- vs 15-minute change, path span). Not a moral. Not a threat level." + "## Collection context" + "- Tool: Fieldwatch (app.fieldwatch), receive-only, no association / injection / cloud." + "## Onboard Debrief (verbatim — already shown to the operator; do not rewrite)" + "## Working data (for the addendum — do not copy rosters into the answer)" + "Signature families: none." + "Signature families (count): " + "Extra attention:" + "- None." + "Observer notes:" + "Finder-tag-like radios (for stress-test of onboard tracking; not a tail list):" + "## End of working data" + "Write the addendum now, following **Your output** at the top. Do not rewrite the onboard Debrief." + "\n\n[truncated for share-sheet size]\n" + "You are a field RF / privacy analyst with deep knowledge of IEEE OUI, Bluetooth SIG assigned numbers, GAP Appearance, known advertisement formats (iBeacon, Eddystone, Apple Continuity / Find My, Google Fast Pair, Microsoft), and common consumer products." + "The user wants **as much information as possible** about **this one radio** from a Fieldwatch observation. Fieldwatch is a stock-Android, receive-only Wi-Fi + BLE listener. Use the dump below **and** your public knowledge of registries and formats. Cite which field or byte pattern supports each claim." + "Constraints you must respect:" + "- This is **one** advertised radio, not a person, vehicle, or legal identity." + "- Wi-Fi rows are **access points only**. Associated clients are invisible. Stock Android cannot promiscuously capture stations or probe requests." + "- BLE rows are advertisers. Randomized MACs are not stable identities and will not stitch across rotations." + "- Signature / OUI / company / UUID matches are **hypotheses**, not proof of a serial, owner, or that a tracker is present." + "- GPS stamps (if present) are the **operator phone** at hear-time, not this radio’s location." + "- Place names (if present) are system reverse-geocode of those stamps. Approximate." + "- RSSI is loudness at the phone, not a measured distance." + "- Do not invent fields that are not in the dump. If data is thin, say so and say what would help." + "- Do not claim this radio is following anyone. Do not give safety advice." + "- Treat this paste as operationally sensitive (MAC, SSID, payload, GPS)." + "## Collection context" + "- Tool: Fieldwatch (app.fieldwatch), receive-only, no association / injection / cloud." + "- Subject: %1$s titled “%2$s”." + "- Scan intensity: %1$s. Stale after %2$ss. Brief hold %3$ss." + "- Location tags: %1$s." + "- Online place names: %1$s." + "- Randomized MAC flag: %1$s." + "- Hits this session: %1$s. Gone: %2$s." + "- Operator GPS trail samples on this radio: %1$s (phone path while it was heard)." + "## Places (operator GPS, optional)" + "## Observation dump (verbatim from the detail page)" + "## Your analysis (required sections)" + "1. **What it likely is** — Product class, likely brand/family, possible model. Confidence 0–100. Hedge (Most likely / Probably / Could be). List the evidence (name, OUI, company ID, Appearance, services, payload). Competing hypotheses if the data fits more than one product." + "2. **Registry / format decode** — IEEE OUI or CID; Bluetooth SIG company; GAP Appearance; 16-bit UUIDs; iBeacon UUID/major/minor if present; Fast Pair model ID if present; Apple Continuity type if present. Quote the hex you used. If you recognize a well-known UUID or company from public lists, say so and say the list." + "3. **What that product typically does** — Phone, tag, speaker, car, AP, camera, mesh node, accessory, etc. Typical radio behavior (always-on beacon vs intermittent)." + "4. **What Fieldwatch actually saw vs what it cannot see** — Stock Android limits (no station/probe capture, no cellular, no DF). Randomized address implications." + "5. **Signal and presence** — Loud/quiet here; RSSI range this session; on-air windows. Do not convert RSSI to meters." + "6. **Signature match** — If Fieldwatch matched a signature, treat it as a filter hit, not identity. Say whether the payload also supports that family. If Notes are in the dump, use them as catalog context for that family. If Extra attention is in the dump, quote it and treat it as an operator caution on a pattern, not proof — separate from Notes." + "7. **Open questions** — What extra observation (another packet, name, GPS path, a second radio) would raise or lower confidence." + "8. **Must not conclude** — One short list of claims the dump does **not** support (owner, following, legal ID, distance)." + "End with a single one-line **takeaway** (what this radio most likely is, and one thing to check next). No safety advice." + "This file is empty." + "Not a Fieldwatch settings pack. Export from Settings → Export settings." + "That is a signature pack. Use Import signatures." + "Not a Fieldwatch settings pack (open a fieldwatch-settings JSON file)." + "Nothing to import." + "Nothing new. %1$s already on this phone." + "Added %1$s (%2$s renamed)" + "Added %1$s" + "merged extra rules on %1$s" + "skipped %1$s already present" + "This file is empty." + "Not a Fieldwatch signature pack. Export from Settings → Export signatures." + "Not a Fieldwatch signature pack (open a fieldwatch-signatures JSON file; spectre-signatures still imports)." + "This pack has no signatures." + "This pack has no stock signatures." + "You are a field RF analyst for the operator who compared two Fieldwatch sits. Fieldwatch is a stock-Android, receive-only Wi-Fi access-point + BLE-advertiser listener." + "The **onboard Compare** (verbatim below) already split presence: only in this sit, only in the second, in both. **Do not rewrite that report. Do not reprint those lists.** Your job is an addendum the phone cannot write: what kind of change this is, and how much of it is real." + "Constraints you must respect:" + "- Hear-only. Kind + MAC. BLE rotation is a new row and will not stitch." + "- Wi-Fi rows are access points only. Associated clients are invisible." + "- Extra attention / signature matches are hypotheses, not identity, not a person or vehicle." + "- GPS stamps (if present) are this phone at hear-time, not the other radio." + "- Last 15 minutes vs a named sit is not the same net (RAM about 400 vs sit %1$s). Missing BLE on the RAM side can be eviction, not gone." + "- Presence is not co-travel. Do not invent a tail, a follower, or a camera location." + "- A radio marked mine was claimed by the operator. Do not treat it as an unexplained follower." + "- A flood note is a burst of new addresses, not a follower." + "- A decoded live value on a row is catalog text for that kind + MAC. If the onboard compare says that value changed, state the change. Do not stitch that value onto a different MAC." + "- An aircraft block is positions the radio advertised, joined by UAS id. If the onboard compare says the status changed, state the change. That track is not this phone\'s GPS." + "- Do not give safety advice. Do not tell the operator they are safe or in danger." + "- Treat this paste as operationally sensitive." + "## Your output (required — this is the addendum the operator reads)" + "Write complete sentences. Headings as below. Short bullets only for exclusive Extra attention / Named radios. No markdown tables. No code fences. No dump of the onboard lists." + "1. **Disclaimer** — Repeat the experimental-use disclaimer first." + "2. **What the onboard compare already established** — 3–5 sentences. Window names, counts, Extra attention exclusives, Observer notes if any, Marked mine if any, Flood if any. Do not reprint inventories." + "3. **What the numbers add** — Overlap (both/union as a percent), Wi-Fi vs BLE in each bucket, how much exclusive BLE is RAND. Say whether this looks like fixtures, a different stall/hour, or a cap artifact. Confidence. Use the working table; do not invent rates." + "4. **Exclusive Extra attention and Named radios** — Full identifiers from the working table (complete MAC, name, signatures, which window). Pattern match, not identity. If none, say none." + "5. **What another sit or Hunt would shrink** — Concrete in-app next steps only (a third sit at the same stall, Hunt on one exclusive Extra attention row, Filters). No safety advice. No “call the police.”" + "**Takeaway (required, last line).** One sentence starting with `Takeaway:` that adds *one number the onboard takeaway does not already say* (overlap percent, exclusive Extra attention count, or RAND fraction of exclusive BLE). Not a moral. Not a threat level." + "## Onboard Compare (verbatim — already shown to the operator; do not rewrite)" + "## Working data (for the addendum — do not copy rosters into the answer)" + "Only in this sit: %1$s Only in second: %2$s In both: %3$s Union: %4$s Overlap: %5$s%%" + "Only in this sit by radio: Wi-Fi %1$s BLE %2$s RAND BLE %3$s" + "Only in second sit by radio: Wi-Fi %1$s BLE %2$s RAND BLE %3$s" + "In both by radio: Wi-Fi %1$s BLE %2$s RAND BLE %3$s" + "Cap note: last 15 minutes is Live RAM (about 400). A named sit keeps up to %1$s. Counts are not the same net." + "Exclusive Extra attention:" + "- None." + "Exclusive Named radios:" + "Observer notes:" + "## End of working data" + "Write the addendum now, following **Your output** at the top. Do not rewrite the onboard Compare." + "\n\n[truncated for share-sheet size]\n" + "Restored Settings, the current filter, and %1$s preset." + "Restored Settings, the current filter, and %1$s presets." + "%1$s named radio" + "%1$s named radios" + "%1$s signature watch" + "%1$s signature watches" + "unlisted" + "- Window: sit **%1$s** (%2$s → %3$s UTC), with a 5-minute recent slice." + "- Window: last **15 minutes** (%1$s → %2$s UTC), with a **5-minute** recent slice." + "- Scan intensity: %1$s. Stale after %2$ss." + "- Location tags: %1$s. Online place names: %2$s." + "15 min: Wi-Fi %1$s BLE %2$s signed %3$s hidden SSIDs %4$s " + "RAND BLE %1$s/%2$s (%3$s%%) " + "first-seen %1$s (%2$s/min) persistent %3$s gone/quiet %4$s" + "5 min: Wi-Fi %1$s BLE %2$s " + "signed %1$s first-seen %2$s" + "GPS path: tagging %1$s " + "fixes %1$s length %2$s m span %3$s m " + "places %1$s" + " Observer: " + "A passive observer with the same radios would see %1$s named/hidden APs " + "and %1$s BLE advertisers (%2$s randomized). " + "on" + "off" + "unlisted" + "HP printer" + "AirPods, Beats, or AirPlay" + "a Fitbit" + "a GoPro" + "an Insta360 camera" + "an iBeacon" + "vendor IE %1$s" + "svc contains %1$s" + "Vendor IE %1$s" + "Company 0x%04X" + "This sit: %1$s (%2$s radios%3$s)" + "Second sit: %1$s (%2$s radios%3$s)" + "Advertised position" + " Last %1$s" + "course %1$s°" + "1 advertised fix" + "%1$s advertised fixes, %2$s" + " Pilot %1$s" + "%1$s more aircraft tracks are listed here and left off the map." + "These positions were broadcast by the radio. They are not this phone\'s GPS." + " Status changed: %1$s → %2$s" + "%1$s, no UAS id: %2$s (%3$s)" + "%1$s, no UAS id: %2$s (%3$s)" + "Pilot" + "second sit’s advertised track" + " A blue dotted line is the second sit’s advertised track within 2 km of this path." + "black dotted line is an advertised track" + " A black dotted line is an advertised track within 2 km of this path." + "AIRCRAFT" + "North-up. Black dots are this sit. Blue dots are the second sit. The marker is the last advertised position." + "North-up. The blue dotted line is the advertised track for %1$s. The marker is the last advertised position." + "Aircraft" + "Last advertised position for %1$s. The marker is that position." + "North-up. The black dotted line is the advertised track for %1$s. The marker is the last advertised position." + "last %1$s" + "pilot %1$s" + " last %1$s" + "1 fix" + "%1$s fixes" + "on" + "off" + "Fast Pair pairing" + "Apple Find My / Offline Finding" + "Apple BLE (phone / Continuity)" + "Wi-Fi beacon flood" + "Pairing flood" + "Name flood" + "new names" + "new addresses" + " · %1$s named" + " · about %1$s dBm" + " 1 address from this burst is left out of the counts and lists below." + " %1$s addresses from this burst are left out of the counts and lists below." + "A burst of new Bluetooth addresses in a few seconds. A handheld can do this by advertising a pairing request or a new name and changing the address every packet. The advertisement does not name the tool." + "A burst of new Wi-Fi names in one scan, about the same loudness, gone by the next scan. A repeated name, a mesh, an extender, or a guest network is not counted. The advertisement does not name the tool." + "Wi-Fi beacon flood · %1$s new names%2$s" + "%1$s · %2$s new addresses%3$s%4$s" + ", about the same loudness, about %1$s dBm," + "%1$s new Wi-Fi names showed up in one scan%2$s and they were gone on the next scan. " + "A repeated name, a mesh, an extender, or a guest network is not counted." + "A handheld such as a Flipper Zero, or an ESP32 running Marauder or Bruce, does this by advertising many network names. The advertisement does not name the tool." + " new addresses sent pairing advertisements in the last few seconds" + " more each advertised a Bluetooth name." + " new addresses each advertised a Bluetooth name in the last few seconds." + " They are about the same loudness, about " + " dBm, which reads as one nearby radio." + "A handheld such as a Flipper Zero, or an ESP32 running Marauder or Bruce, does this by advertising a pairing request and changing the address every packet. The advertisement does not name the tool." + "A handheld such as a Flipper Zero, or an ESP32 running Marauder or Bruce, does this by advertising a new name and changing the address every packet. The advertisement does not name the tool." + ", RAM ~400" + ", named sit up to %1$s" + "Wi-Fi waiting on OS" + "Wi-Fi scanning" + "Wi-Fi next %1$ss" + "Google manufacturer data" + "%1$s bytes" + "a Tesla vehicle or phone-as-key" + "Tesla phone-key iBeacon UUID (iOS background find)." + "a Target Atrius basket tag" + "Target / Atrius iBeacon UUID (shopping-basket asset tag)." + "an iBeacon" + "Apple iBeacon payload." + "an iPhone or iPad offering AirDrop" + "Apple AirDrop advertisement." + "AirPods or Beats headphones" + "Apple Proximity Pairing: %1$s." + "Apple Proximity Pairing (AirPods / Beats)." + "an Apple device that just heard “Hey Siri”" + "Hey Siri advertisement." + "an AirPlay speaker or Apple TV" + "AirPlay advertisement." + "an Apple device doing Handoff" + "Handoff advertisement." + "an Apple device looking for Instant Hotspot" + "Tethering-target advertisement." + "an iPhone/iPad offering Instant Hotspot" + "Tethering-source advertisement." + "an Apple device (Nearby Action)" + "an iPhone / iPad / Mac (Nearby Info)" + "a Find My network radio" + "Apple Offline Finding — AirTag, Find My accessory, or an Apple device locating itself." + "a Fast Pair accessory (often earbuds or a speaker)" + "Google Fast Pair model %1$s (0x%%06X), in pairing mode." + "Google Fast Pair model 0x%06X, in pairing mode." + "a Fast Pair accessory already paired to someone" + "Google Fast Pair account-key broadcast (not in pairing mode)." + "a Google Find Hub tag" + "Find Hub separated (unwanted-tracking) frame." + "Find Hub nearby frame." + "an Eddystone beacon" + "Eddystone service data." + "Apple payload" + "%1$s bytes (unparsed)" + "Apple Continuity type" + "%1$s bytes of HomeKit setup data" + "This device is advertising as an AirPlay source or target." + "Apple Watch wrist / unlock related." + "Payload" + "%1$s bytes" + "Proximity Pairing (AirPods / Beats)" + "Handoff or Instant Hotspot (target)" + "Instant Hotspot (source)" + "Find My / Offline Finding" + "Nearby Action (extended)" + "unlisted" + "truncated (%1$s bytes)" + "%1$s — Tesla phone-as-key (iOS background find). Not a mall beacon." + "iBeacon major / minor" + "iBeacon calibrated TX" + "%1$s dBm at 1 m (used to estimate range)" + "Someone nearby is offering AirDrop (%1$s bytes)." + "Someone nearby has AirDrop receiving on. Hashes are truncated IDs, not names." + "Apple ID hash (2 bytes)" + "Proximity Pairing, truncated." + "Proximity Pairing." + "Product" + "Apple audio 0x%04X" + "Pod position" + "Battery (left / right)" + "case" + "right" + "left" + "Case battery" + "Charging" + "Color" + "AirPods (1st generation)" + "AirPods (2nd generation)" + "AirPods (3rd generation)" + "AirPods (4th generation)" + "AirPods Pro (2nd generation)" + "One or both out of the case" + "Case open" + "Taken out / in-ear transition" + "One in ear" + "Both out, not in ear" + "In-ear activity" + "Both in ear" + "One in ear (sharing?)" + "Both in case, lid open" + "Both in case, lid closed" + "In case" + "Status 0x%02X" + "White" + "Black" + "Red" + "Blue" + "Pink" + "Gray" + "Silver" + "Gold" + "Rose gold" + "Space gray" + "Dark blue" + "Light blue" + "Yellow" + "unknown / not present" + "Siri was just triggered on a nearby Apple device." + "Watch" + "class 0x%04X" + "A %1$s just heard a Siri trigger. The packet carries a short voice hash, not the words." + "Continuity Handoff: a task can be continued on another Apple device. Payload is encrypted." + "Instant Hotspot (looking)" + "This Apple device is searching for a paired phone’s hotspot." + "An iPhone/iPad is offering a personal hotspot." + "Instant Hotspot (offering)" + "Paired iPhone/iPad hotspot" + " · phone battery %1$s%%" + " · %1$s/5 bars" + "Nearby Action advertisement." + "Nearby Action: %1$s." + "Apple TV setup" + "Mobile backup" + "Watch setup" + "Apple TV pair" + "Wi-Fi password sharing (prompting nearby iPhones)" + "iOS setup" + "Repair" + "Speaker setup" + "Whole-home audio setup" + "Answered a call" + "Ended a call" + "Remote AutoFill" + "Companion Link proximity" + "Remote display" + "action 0x%02X" + "Apple device usage state." + "activity unknown" + "activity reporting off" + "idle (screen locked)" + "audio playing, screen locked" + "active (screen on)" + "screen on, video playing" + "Watch on wrist and unlocked" + "recent interaction" + "user is driving" + "phone or FaceTime call" + "activity 0x%X" + "primary iCloud device" + "AirDrop receiving on" + "Wi-Fi on" + "AirPods connected" + "Watch locked" + "What the Apple device is doing" + "Nearby Info advertisement." + "Phone is idle / locked." + "Audio playing with the screen locked." + "Screen is on — someone is using it." + "Device reports the user is driving." + "In a phone or FaceTime call." + "Offline Finding advertisement." + "full" + "medium" + "low" + "critical" + "Broadcasting a public key so the Find My network can report a location. " + "Used by AirTags, Find My accessories, and Apple devices locating themselves. " + "Owner seen recently. " + "Owner not seen in the current key window. " + "Battery %1$s. " + "(%1$s-byte key fragment — not a serial number.)" + "In pairing mode — Android will pop a tap-to-pair card." + "Model ID" + "0x%06X (not in the local name list)" + "wants to show a pairing card" + "hiding the pairing card (e.g. buds back in the case)" + "filter type %1$s" + "account-key bloom filter" + "Already paired to an account (not in pairing mode). %1$s. Version %2$s." + "truncated" + "Eddystone-UID namespace" + "Eddystone-UID instance" + "telemetry (battery / temperature / advert count)" + "ephemeral ID (rotating)" + "separated (unwanted-tracking mode)" + "nearby / with owner" + "frame 0x%02X" + "Windows desktop" + "Windows phone" + "Windows laptop" + "Windows tablet" + "A %1$s is advertising for quick pairing or sharing." + "Microsoft manufacturer data" + "AltBeacon major / minor" + "BLE scan failed (%1$s)" + "Bluetooth is off" + "BLE scanner unavailable" + "BLE start failed" + "BLE retrying" + "BLE unavailable" + "BLE cycling" + "BLE parked · restarting" + "Wi-Fi fast scan needs Developer options" + "Miscellaneous" + "Computer" + "Phone" + "LAN / Network AP" + "Audio / Video" + "Peripheral" + "Imaging" + "Wearable" + "Toy" + "Health" + "Uncategorized" + "Desktop" + "Server" + "Laptop" + "Handheld PC/PDA" + "Palm-size PDA" + "Wearable computer" + "Tablet" + "Cellular" + "Cordless" + "Smartphone" + "Wired modem / voice gateway" + "Common ISDN access" + "Fully available" + "1–17% utilized" + "17–33% utilized" + "33–50% utilized" + "50–67% utilized" + "67–83% utilized" + "83–99% utilized" + "No service available" + "Wearable headset" + "Hands-free" + "Microphone" + "Loudspeaker" + "Headphones" + "Portable audio" + "Car audio" + "Set-top box" + "HiFi audio" + "VCR" + "Video camera" + "Camcorder" + "Video monitor" + "Video display and loudspeaker" + "Video conferencing" + "Gaming / toy" + "Joystick" + "Gamepad" + "Remote control" + "Sensing device" + "Digitizer tablet" + "Card reader" + "Digital pen" + "Handheld scanner" + "Handheld gestural input" + "keyboard" + "pointing" + "keyboard/pointing" + "Keyboard" + "Pointing" + "Keyboard/pointing" + "Display" + "Camera" + "Scanner" + "Printer" + "Wristwatch" + "Pager" + "Jacket" + "Helmet" + "Glasses" + "Robot" + "Vehicle" + "Doll / action figure" + "Controller" + "Game" + "Blood pressure monitor" + "Thermometer" + "Weighing scale" + "Glucose meter" + "Pulse oximeter" + "Heart / pulse rate monitor" + "Health data display" + "Step counter" + "Body composition analyzer" + "Peak flow monitor" + "Medication monitor" + "Knee prosthesis" + "Ankle prosthesis" + "Generic health manager" + "Personal mobility device" + "Limited Discoverable" + "Positioning" + "Networking" + "Rendering" + "Capturing" + "Object Transfer" + "Audio" + "Telephony" + "Information" + "format %1$s" + "Major %1$s" + "Computer %1$s" + "Phone %1$s" + "A/V %1$s" + "Peripheral %1$s" + "Wearable %1$s" + "Toy %1$s" + "Health %1$s" + "New Signature" + "%1$s h %2$s min" + "%1$s min" + "%1$s s" + "You already have %1$s saved sits. When you end this one, the oldest (“%2$s”) will be deleted." + "Dropped oldest sit “%1$s” (keep %2$s)." + "Could not open the selected location" + "AND" + "OR" + "d MMM HH:mm" + "Starting radios…" + "Off" + "send failed" + "Privacy mode — feed paused" + "0 eligible radios (need Extra attention / payload latlon / GPS stamp)" + "Host %1$s did not resolve" + "mfg %1$s %2$s" + "mfg %1$s" + "Extra attention" + "%1$sh%2$sm" + "%1$sm%2$ss" + "%1$ss" + "Could not initialize text-to-speech. Check the Android text-to-speech engine settings. Beep alerts are unaffected." + "Could not play speech. Check the Android text-to-speech engine. Beep alerts are unaffected." + "ch%1$s" + "type %1$s" + "%1$s to %2$s dBm" diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index bab0d80..9187a2c 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,5 @@ -