From 267d0ec0b33254b4dc3b48a0d28c1a5f4514ae35 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 15 May 2026 18:17:40 +0800 Subject: [PATCH 1/3] chore(ci): nudge Actions to index release workflow on fork Co-authored-by: Cursor --- .github/workflows/release-tauri.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 8192bc96..0d11ebb9 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -1,4 +1,5 @@ name: Release Tauri (cross-platform) +# meta: ensure Actions indexes this workflow on forks (no behavior change). # 触发条件: # - 推 v*.*.*-tauri 形式的 tag(与老 Swift 版的 vX.Y.Z 区分开,不冲突) # - 手动 dispatch(用于测试构建,不发版) From 80ceb65c2027dbc0511739b9523aa0b41d6cc331 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 15 May 2026 19:11:02 +0800 Subject: [PATCH 2/3] feat(android): OpenLess Android port (squashed from codex/openless-android-port) Co-authored-by: Cursor --- openless-android/.gitignore | 1 + openless-android/AndroidManifest.xml | 81 + openless-android/ISSUES_FOR_NEXT_SESSION.md | 0 openless-android/PORT_STATUS.md | 168 ++ openless-android/QA_CHECKLIST.md | 77 + openless-android/README.md | 146 ++ openless-android/RELEASE.md | 83 + .../STORE_SUBMISSION_CHECKLIST.md | 54 + openless-android/build.ps1 | 126 ++ openless-android/deploy.ps1 | 77 + .../res/drawable/ic_launcher_foreground.xml | 19 + openless-android/res/values/colors.xml | 18 + openless-android/res/values/strings.xml | 15 + openless-android/res/values/styles.xml | 11 + .../res/xml/openless_input_method.xml | 4 + .../android/AndroidDictationCoordinator.java | 278 +++ .../android/AndroidPermissionDiagnostics.java | 135 ++ .../src/com/openless/android/AsrProvider.java | 5 + .../com/openless/android/AudioRecorder.java | 138 ++ .../com/openless/android/CapsuleState.java | 21 + .../com/openless/android/DictationPhase.java | 8 + .../openless/android/DictionaryActivity.java | 492 ++++++ .../com/openless/android/DictionaryStore.java | 232 +++ .../openless/android/ErrorDetailActivity.java | 259 +++ .../android/FloatingTriggerService.java | 389 +++++ .../android/HistoryDetailActivity.java | 323 ++++ .../com/openless/android/HistoryStore.java | 155 ++ .../com/openless/android/InsertStatus.java | 25 + .../com/openless/android/MainActivity.java | 1544 +++++++++++++++++ .../openless/android/ModelListActivity.java | 229 +++ .../android/OpenAiPolishProvider.java | 208 +++ .../com/openless/android/OpenLessClient.java | 16 + .../com/openless/android/OpenLessHttp.java | 43 + .../android/OpenLessInputMethodService.java | 72 + .../com/openless/android/OpenLessPrompts.java | 177 ++ .../openless/android/PermissionStatus.java | 23 + .../src/com/openless/android/PolishMode.java | 29 + .../com/openless/android/PolishProvider.java | 8 + .../openless/android/ProcessTextActivity.java | 37 + .../openless/android/ProviderDiagnostics.java | 163 ++ .../openless/android/QaAnswerProvider.java | 26 + .../com/openless/android/QaChatMessage.java | 11 + .../com/openless/android/QaPanelActivity.java | 754 ++++++++ .../com/openless/android/QaSessionStore.java | 73 + .../com/openless/android/RawTranscript.java | 11 + .../openless/android/SecureValueStore.java | 103 ++ .../openless/android/SettingsActivity.java | 1004 +++++++++++ .../com/openless/android/SettingsStore.java | 155 ++ .../com/openless/android/SimpleWebSocket.java | 208 +++ .../com/openless/android/TextInserter.java | 63 + .../android/VolcengineAsrProvider.java | 206 +++ .../android/VolcengineFrameCodec.java | 111 ++ .../android/VolcengineStreamingSession.java | 218 +++ .../src/com/openless/android/WavEncoder.java | 54 + .../openless/android/WhisperAsrProvider.java | 53 + openless-android/verify.ps1 | 109 ++ openless-android/version.ps1 | 50 + 57 files changed, 9098 insertions(+) create mode 100644 openless-android/.gitignore create mode 100644 openless-android/AndroidManifest.xml create mode 100644 openless-android/ISSUES_FOR_NEXT_SESSION.md create mode 100644 openless-android/PORT_STATUS.md create mode 100644 openless-android/QA_CHECKLIST.md create mode 100644 openless-android/README.md create mode 100644 openless-android/RELEASE.md create mode 100644 openless-android/STORE_SUBMISSION_CHECKLIST.md create mode 100644 openless-android/build.ps1 create mode 100644 openless-android/deploy.ps1 create mode 100644 openless-android/res/drawable/ic_launcher_foreground.xml create mode 100644 openless-android/res/values/colors.xml create mode 100644 openless-android/res/values/strings.xml create mode 100644 openless-android/res/values/styles.xml create mode 100644 openless-android/res/xml/openless_input_method.xml create mode 100644 openless-android/src/com/openless/android/AndroidDictationCoordinator.java create mode 100644 openless-android/src/com/openless/android/AndroidPermissionDiagnostics.java create mode 100644 openless-android/src/com/openless/android/AsrProvider.java create mode 100644 openless-android/src/com/openless/android/AudioRecorder.java create mode 100644 openless-android/src/com/openless/android/CapsuleState.java create mode 100644 openless-android/src/com/openless/android/DictationPhase.java create mode 100644 openless-android/src/com/openless/android/DictionaryActivity.java create mode 100644 openless-android/src/com/openless/android/DictionaryStore.java create mode 100644 openless-android/src/com/openless/android/ErrorDetailActivity.java create mode 100644 openless-android/src/com/openless/android/FloatingTriggerService.java create mode 100644 openless-android/src/com/openless/android/HistoryDetailActivity.java create mode 100644 openless-android/src/com/openless/android/HistoryStore.java create mode 100644 openless-android/src/com/openless/android/InsertStatus.java create mode 100644 openless-android/src/com/openless/android/MainActivity.java create mode 100644 openless-android/src/com/openless/android/ModelListActivity.java create mode 100644 openless-android/src/com/openless/android/OpenAiPolishProvider.java create mode 100644 openless-android/src/com/openless/android/OpenLessClient.java create mode 100644 openless-android/src/com/openless/android/OpenLessHttp.java create mode 100644 openless-android/src/com/openless/android/OpenLessInputMethodService.java create mode 100644 openless-android/src/com/openless/android/OpenLessPrompts.java create mode 100644 openless-android/src/com/openless/android/PermissionStatus.java create mode 100644 openless-android/src/com/openless/android/PolishMode.java create mode 100644 openless-android/src/com/openless/android/PolishProvider.java create mode 100644 openless-android/src/com/openless/android/ProcessTextActivity.java create mode 100644 openless-android/src/com/openless/android/ProviderDiagnostics.java create mode 100644 openless-android/src/com/openless/android/QaAnswerProvider.java create mode 100644 openless-android/src/com/openless/android/QaChatMessage.java create mode 100644 openless-android/src/com/openless/android/QaPanelActivity.java create mode 100644 openless-android/src/com/openless/android/QaSessionStore.java create mode 100644 openless-android/src/com/openless/android/RawTranscript.java create mode 100644 openless-android/src/com/openless/android/SecureValueStore.java create mode 100644 openless-android/src/com/openless/android/SettingsActivity.java create mode 100644 openless-android/src/com/openless/android/SettingsStore.java create mode 100644 openless-android/src/com/openless/android/SimpleWebSocket.java create mode 100644 openless-android/src/com/openless/android/TextInserter.java create mode 100644 openless-android/src/com/openless/android/VolcengineAsrProvider.java create mode 100644 openless-android/src/com/openless/android/VolcengineFrameCodec.java create mode 100644 openless-android/src/com/openless/android/VolcengineStreamingSession.java create mode 100644 openless-android/src/com/openless/android/WavEncoder.java create mode 100644 openless-android/src/com/openless/android/WhisperAsrProvider.java create mode 100644 openless-android/verify.ps1 create mode 100644 openless-android/version.ps1 diff --git a/openless-android/.gitignore b/openless-android/.gitignore new file mode 100644 index 00000000..567609b1 --- /dev/null +++ b/openless-android/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/openless-android/AndroidManifest.xml b/openless-android/AndroidManifest.xml new file mode 100644 index 00000000..58c758b0 --- /dev/null +++ b/openless-android/AndroidManifest.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openless-android/ISSUES_FOR_NEXT_SESSION.md b/openless-android/ISSUES_FOR_NEXT_SESSION.md new file mode 100644 index 00000000..e69de29b diff --git a/openless-android/PORT_STATUS.md b/openless-android/PORT_STATUS.md new file mode 100644 index 00000000..5ffe5416 --- /dev/null +++ b/openless-android/PORT_STATUS.md @@ -0,0 +1,168 @@ +# OpenLess Android 迁移状态 + +本模块是 OpenLess 桌面端听写链路的 Android 原生重写版本。它不是逐文件照抄,而是把协议形状、状态流转、提示词行为、持久化结构迁移到 Android Java 实现中。 + +实施计划: + +- `../docs/plans/2026-05-04-openless-android-full-port.md` +- `../docs/plans/2026-05-05-openless-android-ui-rebuild-execution.md` + +## 源码映射 + +| 原始源码 | Android 端 | 状态 | +| --- | --- | --- | +| `openless-all/app/src-tauri/src/coordinator.rs` | `src/com/openless/android/AndroidDictationCoordinator.java` | 已迁移核心状态流:idle / start / listen / process / cancel | +| `openless-all/app/src-tauri/src/asr/frame.rs` | `src/com/openless/android/VolcengineFrameCodec.java` | 已迁移二进制帧编解码 | +| `openless-all/app/src-tauri/src/asr/volcengine.rs` | `VolcengineStreamingSession.java`, `VolcengineAsrProvider.java`, `SimpleWebSocket.java` | 已迁移流式 SAUC 生命周期 | +| `openless-all/app/src-tauri/src/asr/whisper.rs` | `WhisperAsrProvider.java`, `WavEncoder.java` | 已迁移 OpenAI 兼容转写路径 | +| `openless-all/app/src-tauri/src/polish.rs` | `OpenAiPolishProvider.java`, `OpenLessHttp.java` | 已迁移 OpenAI 兼容润色/问答请求 | +| `openless-all/app/src-tauri/src/types.rs` `PolishMode` | `PolishMode.java` | 已迁移 `raw/light/structured/formal` | +| `openless-all/app/src-tauri/src/types.rs` `DictationSession` | `HistoryStore.java` | 已迁移历史结构到 Android SharedPreferences JSON | +| 桌面词典持久化 | `DictionaryStore.java` | 已迁移词条结构、热词启停、命中计数 | +| 桌面凭据存储 | `SecureValueStore.java` | 已映射到 Android Keystore AES-GCM | +| 桌面热键 + capsule | `FloatingTriggerService.java` | 已映射为 Android 悬浮窗前台服务 | +| 桌面插入层 | `OpenLessInputMethodService.java`, `TextInserter.java` | 已映射为 Android IME 直接插入 + 剪贴板兜底 | + +## 已完成 + +- 悬浮触发器已替代桌面端全局热键 +- 支持单击开始/结束听写、拖动定位、长按取消 +- Android 14 兼容的麦克风前台服务 +- 16 kHz 单声道实时录音 +- 火山 SAUC 流式 ASR +- Whisper 兼容批量 ASR +- OpenAI 兼容润色链路 +- `原文 / 轻润色 / 结构化 / 正式` 四种模式 +- 词典热词同时注入 ASR 上下文与润色提示词 +- 词条支持:`id / phrase / note / enabled / hits / createdAt` +- 历史记录支持清空、单条删除、点击复制、长按问答 +- 设置项已覆盖: + - ASR / LLM 配置 + - LLM 提供商预设(Ark / DeepSeek / SiliconFlow / OpenAI / 自定义) + - 工作语言 + - 翻译目标语言 + - 悬浮胶囊开关 + - 剪贴板兜底开关 + - 问答历史开关 +- 设置页已改为结构化控件: + - ASR provider 单选 + - LLM provider 单选预设 + - 模式多选 + - 布尔值开关 +- 主界面已具备: + - Android 诊断 + - 提供商诊断 + - 历史记录 + - 词典入口 + - 问答入口 + - 翻译一次 +- `显示悬浮胶囊` 设置已接入实际悬浮服务行为: + - 关闭后可隐藏悬浮气泡 + - 仍可通过常驻通知执行开始/停止听写、翻译、问答 +- 常驻通知已按状态动态裁剪 action: + - 空闲态显示 3 个核心入口 + - 录音/处理中切换为取消、问答、停止 + - 避免超过 Android 通知 action 的稳定显示上限 +- 翻译已接入实际链路,并写入历史元数据 +- OpenLess IME 激活时,可捕获目标包名并写入历史 +- 问答已支持: + - 粘贴上下文 + - 多轮会话 + - 流式回答 + - 语音提问 + - 历史转问答 + - Android `PROCESS_TEXT` +- 已吸收一轮新的 UI 视觉改造: + - `QaPanelActivity` 的卡片/气泡/间距样式已合入 + - `FloatingTriggerService` 的悬浮气泡绘制已合入 + - `MainActivity` 已手工吸收一部分安全的视觉细化(历史列表、权限诊断、按钮尺寸) +- 主界面已完成第一轮信息架构重组: + - 顶部增加分区导航:听写 / 历史 / 工具 + - 听写页新增概览卡片,集中展示 ASR / LLM / 模式 / 历史 / 翻译目标 + - 词典 / 问答快捷入口已收拢到工具页,减少主听写页按钮堆叠 +- 设置已完成第一轮页面化迁移: + - 新增 `SettingsActivity` + - 主界面设置入口不再依赖长 `AlertDialog` + - provider / 语言 / 听写 / 问答配置已按分区重组 +- 页面化重构继续推进: + - `QaPanelActivity` 已加入会话概览与设置/词典快捷入口 + - `DictionaryActivity` 已新增并接管主界面词典入口 + - 词典支持页面内新增、启停、删除、复制导出、剪贴板覆盖导入、清空 + - `SettingsActivity` 已支持按分区深链打开,并补充相关工具入口 + - `ModelListActivity` 已新增并接管模型列表展示,不再通过模型弹窗查看 + - `HistoryDetailActivity` 已新增并接管历史详情展示,不再通过详情弹窗查看 +- Manifest 对外标签已资源化 +- 版本号已与桌面源同步,并在以下文件间做一致性校验: + - `package.json` + - `tauri.conf.json` + - `Cargo.toml` +- `verify.ps1` 已覆盖 APK 基本校验 +- `verify.ps1` 现已支持自动发现 `adb` 并可通过 `-CheckDevice` 报告设备连接状态 +- `deploy.ps1` 已新增,可在有设备连接时执行 APK 安装与可选启动 +- `QA_CHECKLIST.md`、`RELEASE.md`、`STORE_SUBMISSION_CHECKLIST.md` 已建立并中文化 +- 历史项收尾迁移继续推进: + - 长按历史项不再弹系统菜单,统一进入 `HistoryDetailActivity` + - `HistoryDetailActivity` 已补删除动作 +- 错误展示收尾迁移继续推进: + - 新增 `ErrorDetailActivity` + - `MainActivity` / `QaPanelActivity` 的错误不再走 `AlertDialog` +- 页面反馈层收口继续推进: + - `MainActivity` 多处反馈已统一收敛到页内状态行 + - `QaPanelActivity` 的未输入、语音空识别、复制回答/对话等反馈已改为页内状态 + - `DictionaryActivity` / `HistoryDetailActivity` / `ModelListActivity` / `ErrorDetailActivity` 已补齐页内状态行 + - `SettingsActivity` 已拆分为页面状态与诊断状态,不再依赖 `Toast` 反馈保存/诊断结果 +- 已完成一轮模拟器页面验收: + - 已处理麦克风权限与通知权限弹窗 + - `MainActivity` 听写分区可正常显示 + - `MainActivity` 历史分区可正常显示筛选与空状态 + - `MainActivity` 工具分区可正常显示诊断卡与快捷工具卡 + - `SettingsActivity` 已修复启动时 `NullPointerException`,当前可正常打开 + - `DictionaryActivity` 可正常打开 + - `QaPanelActivity` 可正常打开 + +## 当前差距 + +- 尚未完成带真实凭据的完整听写/翻译/问答端到端录音验证 +- 已确认本机可用 `adb` 路径:`C:\Users\16014\AppData\Local\Android\Sdk\platform-tools\adb.exe` +- 已确认可用模拟器:`emulator-5554` +- 发布签名、商店元数据、最终图标素材、商店截图仍未完全收口 +- Android UI 已可用,但仍不是桌面 React UI 的完整视觉复刻 +- 主界面和设置页已明显接近工具化产品,但尚未完整复刻桌面端完整设置/导航面 +- 剩余 `Toast` 已只存在于 `FloatingTriggerService` / `AndroidDictationCoordinator` 的服务态无页面宿主链路 +- 选中文本问答依赖 Android `PROCESS_TEXT`,无法像桌面端那样做完全通用的跨应用选区捕获 +- Android 对跨应用上下文访问有限;非 IME 路径下的目标 app 元数据仍不稳定 +- 提供商诊断目前以配置有效性检查为主,还不是完整实录音 round-trip 验证 +- 火山流式链路仍需要带真实凭据的真机实测 + +## 当前构建 + +构建命令: + +```powershell +.\build.ps1 +``` + +输出: + +```text +build\OpenLessAndroid-debug.apk +``` + +当前最新本地构建结果: + +- 可成功编译 +- 2026-05-05 本轮在以下改动后再次通过: + - `MainActivity` 分区骨架 + - `SettingsActivity` 新增与 Manifest 注册 + - 听写页概览卡片与工具入口整理 + - `SettingsActivity` 初始化空指针修复 + - `ErrorDetailActivity` 新增与错误页接管 + - `HistoryDetailActivity` 删除动作补齐 + - 页面反馈层收口:设置页/错误页去 `Toast` 化 +- 通过 `apksigner verify` +- `aapt dump badging` 可确认: + - package:`com.openless.android` + - version:与桌面源同步 + - launchable activity:`com.openless.android.MainActivity` + - IME 组件存在 + - 麦克风 / 悬浮窗 / 前台服务 / 通知等权限已声明 diff --git a/openless-android/QA_CHECKLIST.md b/openless-android/QA_CHECKLIST.md new file mode 100644 index 00000000..e05f51d8 --- /dev/null +++ b/openless-android/QA_CHECKLIST.md @@ -0,0 +1,77 @@ +# OpenLess Android 验收清单 + +## 安装与启动 + +- 安装 `build/OpenLessAndroid-debug.apk` +- 启动应用,确认主界面可正常打开且不闪退 +- 确认可见以下卡片:悬浮触发器、Android 诊断、提供商诊断、模式、转写结果、历史记录 + +## 设置与提供商配置 + +- 打开“设置” +- 保存一组有效或占位的 ASR / LLM 配置 +- 重新打开“设置”,确认配置会持久化 +- 切换剪贴板兜底、问答历史、悬浮胶囊等开关,重新打开后确认状态持久化 + +## Android 诊断 + +- 授予或拒绝麦克风权限后,确认状态会更新 +- 打开悬浮窗设置后,确认 overlay 状态会更新 +- 在 Android 13+ 上确认通知权限状态会更新 +- 启用或切换键盘后,确认 IME 启用/激活状态会更新 + +## 悬浮触发器 + +- 授予悬浮窗权限 +- 启动悬浮触发器 +- 确认悬浮气泡出现 +- 拖动气泡并确认位置会持久化 +- 单击一次开始听写 +- 再单击一次结束听写 +- 长按取消本次听写 +- 打开前台通知,确认存在这些操作:`剪贴板问答`、`问答面板`、`翻译`、`取消`、`停止` + +## 听写 + +- 在有效的 ASR / LLM 配置下录一段短语音 +- 确认原文转写与处理结果会更新 +- 确认历史记录新增一条会话 +- 如果命中了词典词条,确认命中计数会写入 + +## 直接插入与剪贴板兜底 + +- 启用 OpenLess 键盘 +- 在目标输入框中切换到 OpenLess 输入法 +- 执行听写,确认直接插入成功 +- 关闭 IME、保留剪贴板兜底,再次听写,确认结果会复制到剪贴板 +- 关闭剪贴板兜底,确认 IME 未激活时会清晰地报失败,而不是静默失效 + +## 翻译 + +- 设置翻译目标语言 +- 使用“翻译一次”按钮或通知里的“翻译”操作 +- 录制一段短语音 +- 确认得到翻译后的输出,并写入历史记录 + +## 问答 + +- 打开“问答面板”,输入一个问题 +- 使用“按住提问”,说出一个问题并松开 +- 确认回答会流式更新 +- 使用“剪贴板问答”处理已复制文本 +- 从历史记录触发“问答” +- 在支持的应用中选中文本并选择 `OpenLess`,确认问答面板会带着该上下文打开 + +## 提供商诊断 + +- 运行“检测 LLM” +- 运行“检测 ASR” +- 运行“列出 LLM 模型” +- 确认报错会通过对话框、状态或 Toast 暴露,而不是静默失败 + +## 回归检查 + +- 将应用切后台后重新打开 +- 确认悬浮触发器仍能正常启动 +- 确认“清空历史”和“长按删除单条记录”都可用 +- 确认关闭问答历史保存时不会引发崩溃 diff --git a/openless-android/README.md b/openless-android/README.md new file mode 100644 index 00000000..15ec7268 --- /dev/null +++ b/openless-android/README.md @@ -0,0 +1,146 @@ +# OpenLess Android + +OpenLess 听写链路的 Android 原生重写版本。 + +桌面版 OpenLess 依赖全局热键与桌面插入 API。Android 不提供这类原语,因此本迁移采用 Android 等价机制: + +- 用可拖动悬浮触发器替代全局热键 +- 用麦克风前台服务替代后台录音 +- 用可选 OpenLess 输入法完成直接插入 +- 当 IME 不可用时,用剪贴板兜底 +- 在支持的应用中,用 Android `PROCESS_TEXT` 完成“选中文本 -> 问答”跳转 + +## 当前功能 + +- 火山 SAUC 流式 ASR 听写 +- Whisper 兼容 `/audio/transcriptions` 兜底 +- OpenAI 兼容润色链路 +- `原文 / 轻润色 / 结构化 / 正式` 四种模式 +- 可配置目标语言的一次性翻译流程 +- 词典热词同时注入 ASR 与润色提示词 +- 当 IME 上下文可用时,历史记录写入目标应用元数据 +- 提供商诊断:LLM、ASR 配置检查、模型列表 +- Android 诊断:麦克风、悬浮窗、通知、前台服务、IME 状态 +- 问答面板:文字提问、语音提问、剪贴板上下文、历史上下文、流式回答 +- 通过 Android 文本选中动作,把选中文本送入问答 + +## 构建 + +当前模块不依赖 Gradle,直接使用本地 Android SDK 工具构建: + +```powershell +cd openless-android +.\build.ps1 +``` + +调试 APK 输出: + +```text +openless-android\build\OpenLessAndroid-debug.apk +``` + +Android 版本元数据与桌面源同步,来源于: + +- `openless-all/app/package.json` +- `openless-all/app/src-tauri/tauri.conf.json` +- `openless-all/app/src-tauri/Cargo.toml` + +构建会校验这三处版本一致,并将其作为 `versionName`,同时按 `major * 10000 + minor * 100 + patch` 推导 `versionCode`。 + +## 配置 + +打开应用后,可在“设置”中填写: + +- ASR provider:`volcengine` 或 `whisper` +- 火山 ASR 应用 Key / 访问 Key / 资源 ID +- Whisper 兼容 ASR 服务地址 / API Key / 模型 +- LLM 服务地址 / API Key / 模型 +- 启用的润色模式 +- 工作语言 +- 翻译目标语言 +- 悬浮胶囊显示开关 +- 剪贴板兜底开关 +- 问答历史保存开关 + +主界面诊断入口: + +- `检测 LLM` +- `检测 ASR` +- `列出 LLM 模型` + +词典支持: + +- 启用 / 停用 +- 删除 +- 备注 +- 命中计数 + +启用的词条会同时进入润色提示词与火山热词上下文。 + +## 主要流程 + +### 听写 + +1. 授予悬浮窗权限 +2. 启动悬浮触发器 +3. 点击气泡开始录音 +4. 再点一次结束 +5. OpenLess 完成转写,必要时润色/翻译,然后通过 IME 插入或回退到剪贴板 + +通知动作: + +- `剪贴板问答` +- `问答面板` +- `翻译` +- `取消` +- `停止` + +### 直接插入 + +1. 点击 `启用键盘` +2. 在 Android 输入法设置中启用 OpenLess +3. 在目标输入框切换到 OpenLess 输入法 +4. 使用听写 + +如果 OpenLess IME 正在激活,文本会直接提交到当前输入框;否则只有在启用剪贴板兜底时才会复制结果。 + +### 问答 + +可从以下入口进入: + +- `打开问答面板` +- `剪贴板问答` +- 历史记录中的“问答” +- 悬浮通知中的 `问答面板` / `剪贴板问答` +- 在支持的应用里选中文本,再选择 `OpenLess` + +问答支持: + +- 粘贴或选中文本上下文 +- 内存中的多轮会话 +- 面板内语音提问 +- OpenAI 兼容流式回答 + +## 桌面到 Android 映射 + +| 桌面 OpenLess | Android 重写 | +| --- | --- | +| Tauri/Rust coordinator | `AndroidDictationCoordinator` | +| 全局热键 | 可拖动悬浮气泡 | +| Recorder | `AudioRecorder`,16 kHz 单声道 PCM | +| 火山流式 ASR | `VolcengineStreamingSession` + `VolcengineFrameCodec` + `SimpleWebSocket` | +| Whisper 批量 ASR | `WhisperAsrProvider` | +| LLM 润色与问答 | `OpenAiPolishProvider` + `OpenLessPrompts` | +| Capsule 状态事件 | `FloatingTriggerService` + `CapsuleState` | +| 插入层 | `TextInserter` + `OpenLessInputMethodService` | +| 历史存储 | `HistoryStore` | +| 词典 | `DictionaryStore` | +| 安全凭据 | `SecureValueStore` | + +## 状态文档 + +参见: + +- `PORT_STATUS.md` +- `QA_CHECKLIST.md` +- `RELEASE.md` diff --git a/openless-android/RELEASE.md b/openless-android/RELEASE.md new file mode 100644 index 00000000..55586b9f --- /dev/null +++ b/openless-android/RELEASE.md @@ -0,0 +1,83 @@ +# OpenLess Android 发布说明 + +## 构建目标 + +Android 版本号不单独维护,而是直接与桌面版 OpenLess 源码同步,并在以下文件之间做一致性校验: + +- `openless-all/app/package.json` +- `openless-all/app/src-tauri/tauri.conf.json` +- `openless-all/app/src-tauri/Cargo.toml` + +默认调试构建: + +```powershell +cd openless-android +.\build.ps1 +``` + +输出: + +```text +build\OpenLessAndroid-debug.apk +``` + +## 可选的 Release 签名 + +`build.ps1` 支持显式传入 release 签名参数: + +```powershell +.\build.ps1 ` + -Configuration release ` + -KeystorePath C:\path\to\release.keystore ` + -KeystoreAlias your_alias ` + -StorePass your_store_password ` + -KeyPass your_key_password +``` + +输出: + +```text +build\OpenLessAndroid-release.apk +``` + +如果未提供 release 签名参数,脚本会退回本地 debug keystore 流程。 + +## 工具链前置要求 + +- Android SDK platform `android-34` +- Android build-tools `34.0.0` +- `aapt2` +- `d8` +- `zipalign` +- `apksigner` +- `keytool` + +脚本会从 `ANDROID_HOME`、`ANDROID_SDK_ROOT` 或 `%LOCALAPPDATA%\\Android\\Sdk` 自动解析这些工具路径。 + +## 发布前检查 + +- 运行 `.\build.ps1` +- 运行 `.\verify.ps1` +- 确认 APK 已成功生成 +- 执行 `QA_CHECKLIST.md` +- 执行 `STORE_SUBMISSION_CHECKLIST.md` +- 确认提供商配置页仍能持久化保存 +- 在真机上验证悬浮触发器与 IME 流程 + +包含构建的校验命令: + +```powershell +.\verify.ps1 -BuildFirst +``` + +直接打印同步后的版本元数据: + +```powershell +.\version.ps1 +``` + +## 当前限制 + +- 当前工作区还没有包含 `adb` 实机验证证据 +- Play Store 元数据、商店截图、隐私文案、最终品牌素材仍属于独立发布任务 +- 与桌面系统强绑定的能力,例如桌面级全局热键诊断、开机自启等,仍需要按 Android 语义单独实现或映射,不能机械照搬 diff --git a/openless-android/STORE_SUBMISSION_CHECKLIST.md b/openless-android/STORE_SUBMISSION_CHECKLIST.md new file mode 100644 index 00000000..25a92d66 --- /dev/null +++ b/openless-android/STORE_SUBMISSION_CHECKLIST.md @@ -0,0 +1,54 @@ +# OpenLess Android 商店提交流程清单 + +## 应用标识 + +- 最终包名已确认 +- 最终应用名称已确认 +- 最终 `versionCode` 已确认 +- 最终 `versionName` 已确认 +- Release 签名 keystore 与 alias 已确认 + +## 商店素材 + +- 应用图标已导出为商店要求的尺寸 +- 如目标商店需要,Feature Graphic 已准备 +- 手机截图已采集 +- 如声明支持平板,平板截图已采集 +- 短描述已撰写 +- 完整描述已撰写 + +## 政策与披露 + +- 麦克风用途披露已准备 +- 悬浮窗/浮窗用途披露已准备 +- 剪贴板行为披露已准备 +- 网络/Provider 配置披露已准备 +- 隐私政策 URL 已准备 +- Data Safety / 隐私问卷已准备 + +## 功能验证 + +- 已在至少一台真机上完成 `QA_CHECKLIST.md` +- 已验证当前 Android 目标版本下的悬浮触发器 +- 已验证当前 Android 目标版本下的 IME 直接插入 +- 已验证剪贴板兜底 +- 已验证问答文本输入流程 +- 已验证问答语音流程 +- 已在至少一个支持应用中验证选中文本 `PROCESS_TEXT` 流程 +- 已验证翻译流程 +- 已用真实凭据验证火山 ASR 流程 +- 已用真实凭据验证 Whisper 兼容流程 + +## 打包验证 + +- 已完成 `.\build.ps1 -Configuration release ...` +- 已完成 `.\verify.ps1 -Configuration release` +- 已归档 APK/制品与对应发布说明 +- 已复核 Manifest 标签与所有可见文案 + +## 已知限制披露 + +- Android 端使用“悬浮窗 + IME”等价机制,而不是桌面端全局热键直接插入 +- 选中文本问答依赖目标应用是否支持 Android 文本操作 +- 直接插入依赖 OpenLess 输入法处于激活状态 +- 提供商诊断不能替代完整真机端到端验证 diff --git a/openless-android/build.ps1 b/openless-android/build.ps1 new file mode 100644 index 00000000..2959d39d --- /dev/null +++ b/openless-android/build.ps1 @@ -0,0 +1,126 @@ +param( + [string]$SdkPath = "", + [string]$Configuration = "debug", + [string]$KeystorePath = "", + [string]$KeystoreAlias = "", + [string]$StorePass = "", + [string]$KeyPass = "" +) + +$ErrorActionPreference = "Stop" + +if (-not $SdkPath) { + if ($env:ANDROID_HOME) { + $SdkPath = $env:ANDROID_HOME + } elseif ($env:ANDROID_SDK_ROOT) { + $SdkPath = $env:ANDROID_SDK_ROOT + } elseif (Test-Path "$env:LOCALAPPDATA\Android\Sdk") { + $SdkPath = "$env:LOCALAPPDATA\Android\Sdk" + } else { + throw "Android SDK not found. Pass -SdkPath or set ANDROID_HOME." + } +} + +$Root = Split-Path -Parent $MyInvocation.MyCommand.Path +$VersionInfo = & (Join-Path $Root "version.ps1") +$BuildTools = Join-Path $SdkPath "build-tools\34.0.0" +$AndroidJar = Join-Path $SdkPath "platforms\android-34\android.jar" +$Aapt2 = Join-Path $BuildTools "aapt2.exe" +$D8 = Join-Path $BuildTools "d8.bat" +$ZipAlign = Join-Path $BuildTools "zipalign.exe" +$ApkSigner = Join-Path $BuildTools "apksigner.bat" + +foreach ($Path in @($AndroidJar, $Aapt2, $D8, $ZipAlign, $ApkSigner)) { + if (-not (Test-Path $Path)) { + throw "Required Android tool missing: $Path" + } +} + +function Invoke-Checked { + param([scriptblock]$Command) + & $Command + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code $LASTEXITCODE" + } +} + +$OutDir = Join-Path $Root "build" +$Build = Join-Path $OutDir ("work-" + [System.DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()) +$Gen = Join-Path $Build "gen" +$Classes = Join-Path $Build "classes" +$Dex = Join-Path $Build "dex" +$Compiled = Join-Path $Build "compiled.zip" +$Unsigned = Join-Path $Build "unsigned.apk" +$Aligned = Join-Path $Build "aligned.apk" +$Signed = Join-Path $Build "signed.apk" +$FinalApk = Join-Path $OutDir "OpenLessAndroid-$Configuration.apk" +$Keystore = Join-Path $OutDir "debug.keystore" +$UseCustomKeystore = $Configuration -ieq "release" -and $KeystorePath -and $KeystoreAlias -and $StorePass -and $KeyPass + +New-Item -ItemType Directory -Force -Path $OutDir, $Gen, $Classes, $Dex | Out-Null + +Invoke-Checked { & $Aapt2 compile --dir (Join-Path $Root "res") -o $Compiled } +Invoke-Checked { & $Aapt2 link ` + -o $Unsigned ` + -I $AndroidJar ` + --manifest (Join-Path $Root "AndroidManifest.xml") ` + -R $Compiled ` + --java $Gen ` + --version-code $VersionInfo.VersionCode ` + --version-name $VersionInfo.VersionName ` + --auto-add-overlay } + +$Sources = Get-ChildItem -Path (Join-Path $Root "src") -Recurse -Filter *.java +$Generated = Get-ChildItem -Path $Gen -Recurse -Filter *.java +$SourceList = Join-Path $Build "sources.txt" +$Utf8NoBom = New-Object System.Text.UTF8Encoding($false) +[System.IO.File]::WriteAllLines($SourceList, (($Sources + $Generated) | ForEach-Object { $_.FullName }), $Utf8NoBom) + +Invoke-Checked { javac -encoding UTF-8 -source 8 -target 8 -classpath $AndroidJar -d $Classes "@$SourceList" } +Invoke-Checked { & $D8 --classpath $AndroidJar --output $Dex (Get-ChildItem -Path $Classes -Recurse -Filter *.class | ForEach-Object { $_.FullName }) } +Copy-Item $Unsigned $FinalApk +Add-Type -AssemblyName System.IO.Compression.FileSystem +$zip = [System.IO.Compression.ZipFile]::Open($FinalApk, "Update") +try { + $existing = $zip.GetEntry("classes.dex") + if ($existing) { $existing.Delete() } + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, (Join-Path $Dex "classes.dex"), "classes.dex") | Out-Null +} finally { + $zip.Dispose() +} + +Invoke-Checked { & $ZipAlign -f 4 $FinalApk $Aligned } + +if ($UseCustomKeystore) { + Invoke-Checked { & $ApkSigner sign ` + --ks $KeystorePath ` + --ks-key-alias $KeystoreAlias ` + --ks-pass ("pass:" + $StorePass) ` + --key-pass ("pass:" + $KeyPass) ` + --out $Signed ` + $Aligned } +} else { + if (-not (Test-Path $Keystore)) { + keytool -genkeypair ` + -keystore $Keystore ` + -storepass android ` + -keypass android ` + -alias androiddebugkey ` + -keyalg RSA ` + -keysize 2048 ` + -validity 10000 ` + -dname "CN=Android Debug,O=Android,C=US" | Out-Null + } + + Invoke-Checked { & $ApkSigner sign ` + --ks $Keystore ` + --ks-pass pass:android ` + --key-pass pass:android ` + --out $Signed ` + $Aligned } +} + +Invoke-Checked { & $ApkSigner verify $Signed } +Copy-Item -LiteralPath $Signed -Destination $FinalApk -Force +Write-Host "Built $FinalApk" +Write-Host ("Version " + $VersionInfo.VersionName + " (" + $VersionInfo.VersionCode + ")") diff --git a/openless-android/deploy.ps1 b/openless-android/deploy.ps1 new file mode 100644 index 00000000..adae15ab --- /dev/null +++ b/openless-android/deploy.ps1 @@ -0,0 +1,77 @@ +param( + [string]$SdkPath = "", + [string]$AdbPath = "", + [string]$Configuration = "debug", + [switch]$BuildFirst, + [switch]$LaunchAfterInstall +) + +$ErrorActionPreference = "Stop" + +if (-not $SdkPath) { + if ($env:ANDROID_HOME) { + $SdkPath = $env:ANDROID_HOME + } elseif ($env:ANDROID_SDK_ROOT) { + $SdkPath = $env:ANDROID_SDK_ROOT + } elseif (Test-Path "$env:LOCALAPPDATA\Android\Sdk") { + $SdkPath = "$env:LOCALAPPDATA\Android\Sdk" + } else { + throw "Android SDK not found. Pass -SdkPath or set ANDROID_HOME." + } +} + +if (-not $AdbPath) { + $adbCandidates = @( + (Join-Path $SdkPath "platform-tools\adb.exe"), + "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" + ) + foreach ($candidate in $adbCandidates) { + if ($candidate -and (Test-Path $candidate)) { + $AdbPath = $candidate + break + } + } +} + +if (-not $AdbPath -or -not (Test-Path $AdbPath)) { + throw "adb.exe not found. Pass -AdbPath or install Android platform-tools." +} + +$Root = Split-Path -Parent $MyInvocation.MyCommand.Path +$ApkPath = Join-Path $Root ("build\OpenLessAndroid-" + $Configuration + ".apk") + +if ($BuildFirst) { + & (Join-Path $Root "build.ps1") -SdkPath $SdkPath -Configuration $Configuration + if ($LASTEXITCODE -ne 0) { + throw "build.ps1 failed with exit code $LASTEXITCODE" + } +} + +if (-not (Test-Path $ApkPath)) { + throw "APK not found: $ApkPath" +} + +$deviceLines = & $AdbPath devices | Select-Object -Skip 1 | Where-Object { $_.Trim() -ne "" } +if ($deviceLines.Count -eq 0) { + throw "No adb device attached." +} + +Write-Host "Deploying $ApkPath" +foreach ($line in $deviceLines) { + Write-Host (" Device: " + $line.Trim()) +} + +& $AdbPath install -r $ApkPath +if ($LASTEXITCODE -ne 0) { + throw "adb install failed with exit code $LASTEXITCODE" +} + +Write-Host "Install complete: com.openless.android" + +if ($LaunchAfterInstall) { + & $AdbPath shell am start -n com.openless.android/.MainActivity + if ($LASTEXITCODE -ne 0) { + throw "adb shell am start failed with exit code $LASTEXITCODE" + } + Write-Host "Launch requested: com.openless.android/.MainActivity" +} diff --git a/openless-android/res/drawable/ic_launcher_foreground.xml b/openless-android/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..62f6fcac --- /dev/null +++ b/openless-android/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/openless-android/res/values/colors.xml b/openless-android/res/values/colors.xml new file mode 100644 index 00000000..3c9e928e --- /dev/null +++ b/openless-android/res/values/colors.xml @@ -0,0 +1,18 @@ + + + #F7F7F8 + #FFFFFF + #FAFAFA + #14000000 + #24000000 + #0A0A0B + #2A2A2D + #A0A0A3 + #6C6C70 + #2563EB + #1D4ED8 + #EFF4FF + #16A34A + #D97706 + #DC2626 + diff --git a/openless-android/res/values/strings.xml b/openless-android/res/values/strings.xml new file mode 100644 index 00000000..f247d8f3 --- /dev/null +++ b/openless-android/res/values/strings.xml @@ -0,0 +1,15 @@ + + OpenLess + 用 OpenLess 追问 + OpenLess 键盘 + OpenLess 悬浮触发器 + 保持 OpenLess 悬浮听写触发器可用。 + OpenLess + 悬浮听写触发器运行中 + 开始/停止听写 + 翻译 + 问答面板 + 剪贴板问答 + 取消 + 停止 + diff --git a/openless-android/res/values/styles.xml b/openless-android/res/values/styles.xml new file mode 100644 index 00000000..3535bc5e --- /dev/null +++ b/openless-android/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + diff --git a/openless-android/res/xml/openless_input_method.xml b/openless-android/res/xml/openless_input_method.xml new file mode 100644 index 00000000..12d3b366 --- /dev/null +++ b/openless-android/res/xml/openless_input_method.xml @@ -0,0 +1,4 @@ + diff --git a/openless-android/src/com/openless/android/AndroidDictationCoordinator.java b/openless-android/src/com/openless/android/AndroidDictationCoordinator.java new file mode 100644 index 00000000..0933da61 --- /dev/null +++ b/openless-android/src/com/openless/android/AndroidDictationCoordinator.java @@ -0,0 +1,278 @@ +package com.openless.android; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Handler; +import android.os.Looper; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +final class AndroidDictationCoordinator { + interface Listener { + void onCapsuleState(CapsuleState state, String message); + + void onRecordingLevel(float level); + + void onToast(String message); + } + + private final Context context; + private final SettingsStore settingsStore; + private final HistoryStore historyStore; + private final DictionaryStore dictionaryStore; + private final AudioRecorder recorder = new AudioRecorder(); + private final TextInserter inserter; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final Handler main = new Handler(Looper.getMainLooper()); + private final Listener listener; + private int phase = DictationPhase.IDLE; + private long sessionId; + private VolcengineStreamingSession volcengineSession; + private boolean cancelled; + private boolean translateNext; + + AndroidDictationCoordinator(Context context, SettingsStore settingsStore, HistoryStore historyStore, Listener listener) { + this.context = context.getApplicationContext(); + this.settingsStore = settingsStore; + this.historyStore = historyStore; + this.dictionaryStore = new DictionaryStore(context); + this.listener = listener; + this.inserter = new TextInserter(context); + } + + synchronized void toggle() { + if (phase == DictationPhase.IDLE) { + beginSession(false); + } else if (phase == DictationPhase.LISTENING) { + endSession(); + } else { + emitToast("OpenLess 正忙,请稍候。"); + } + } + + synchronized void startTranslation() { + if (phase != DictationPhase.IDLE) { + emitToast("OpenLess 正忙,请稍候。"); + return; + } + String target = settingsStore.get().translationTargetLanguage == null + ? "" + : settingsStore.get().translationTargetLanguage.trim(); + if (target.isEmpty()) { + emitToast("请先在设置里填写翻译目标语言。"); + return; + } + beginSession(true); + } + + synchronized void cancel() { + if (phase == DictationPhase.LISTENING) { + recorder.stop(); + } + if (volcengineSession != null) { + volcengineSession.close(); + volcengineSession = null; + } + cancelled = true; + translateNext = false; + phase = DictationPhase.IDLE; + sessionId++; + emit(CapsuleState.CANCELLED, null); + } + + void shutdown() { + cancel(); + executor.shutdownNow(); + } + + private void beginSession(boolean translate) { + if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + emitToast("请先在 OpenLess 中授予麦克风权限。"); + return; + } + phase = DictationPhase.STARTING; + cancelled = false; + translateNext = translate; + long currentSession = ++sessionId; + emit(CapsuleState.STARTING, null); + try { + SettingsStore.Settings settings = settingsStore.get(); + if (!"whisper".equals(settings.activeAsrProvider)) { + java.util.List hotwords = dictionaryStore.enabledPhrases(); + volcengineSession = new VolcengineStreamingSession(settings, hotwords); + volcengineSession.open(); + recorder.start((pcm, length) -> { + VolcengineStreamingSession session = volcengineSession; + if (session != null) { + session.consume(pcm, length); + } + }, listener::onRecordingLevel); + } else { + recorder.start(null, listener::onRecordingLevel); + } + synchronized (this) { + if (currentSession != sessionId) { + return; + } + phase = DictationPhase.LISTENING; + } + emit(CapsuleState.RECORDING, null); + } catch (Exception e) { + fail(currentSession, "录音失败:" + e.getMessage()); + } + } + + private void endSession() { + phase = DictationPhase.PROCESSING; + long currentSession = sessionId; + AudioRecorder.Recording recording = recorder.stop(); + if (recording.pcm.length < 1000) { + phase = DictationPhase.IDLE; + emit(CapsuleState.ERROR, "录音过短"); + return; + } + emit(CapsuleState.TRANSCRIBING, null); + VolcengineStreamingSession session = volcengineSession; + volcengineSession = null; + if (session != null) { + executor.execute(() -> finishVolcengine(currentSession, session, recording.durationMs)); + } else { + executor.execute(() -> processRecording(currentSession, recording)); + } + } + + private void finishVolcengine(long currentSession, VolcengineStreamingSession session, long fallbackDurationMs) { + try { + RawTranscript raw = session.finish(fallbackDurationMs); + session.close(); + processTranscript(currentSession, raw); + } catch (Exception e) { + session.close(); + main.post(() -> fail(currentSession, e.getMessage())); + } + } + + private void processRecording(long currentSession, AudioRecorder.Recording recording) { + try { + SettingsStore.Settings settings = settingsStore.get(); + java.util.List hotwords = dictionaryStore.enabledPhrases(); + AsrProvider asr = new WhisperAsrProvider(settings); + + RawTranscript raw = asr.transcribe(recording); + synchronized (this) { + if (cancelled || currentSession != sessionId) { + return; + } + } + processTranscript(currentSession, raw); + } catch (Exception e) { + main.post(() -> fail(currentSession, e.getMessage())); + } + } + + private void processTranscript(long currentSession, RawTranscript raw) throws Exception { + if (raw.text.trim().isEmpty()) { + throw new IllegalStateException("ASR 返回了空白转写结果。"); + } + synchronized (this) { + if (cancelled || currentSession != sessionId) { + return; + } + } + SettingsStore.Settings settings = settingsStore.get(); + java.util.List hotwords = dictionaryStore.enabledPhrases(); + PolishProvider polish = new OpenAiPolishProvider(settings); + boolean translating; + synchronized (this) { + translating = translateNext; + } + postState(translating ? CapsuleState.TRANSLATING : CapsuleState.POLISHING, null); + String computedText; + String computedErrorCode = null; + if (translating) { + try { + computedText = polish.translate(raw.text, settings.translationTargetLanguage, hotwords, settings.workingLanguages); + } catch (Exception e) { + computedText = raw.text; + computedErrorCode = "translation_failed"; + } + } else { + computedText = polish.polish(raw.text, settings.mode, hotwords); + } + final String finalText = computedText; + final String errorCode = computedErrorCode; + final String translationTarget = settings.translationTargetLanguage == null + ? "" + : settings.translationTargetLanguage.trim(); + synchronized (this) { + if (cancelled || currentSession != sessionId) { + return; + } + } + main.post(() -> { + synchronized (AndroidDictationCoordinator.this) { + if (cancelled || currentSession != sessionId) { + return; + } + TextInserter.Result insertion = inserter.insertOrCopy(finalText, settings.allowClipboardFallback); + InsertStatus insertStatus = insertion.status; + int dictionaryHits = dictionaryStore.recordHits(finalText); + String historyError = translating + ? (errorCode == null + ? "translation:" + translationTarget + : errorCode + ":" + translationTarget) + : errorCode; + historyStore.add(raw.text, finalText, settings.mode, insertion.appBundleId, insertion.appName, + insertStatus, historyError, raw.durationMs, dictionaryHits); + phase = DictationPhase.IDLE; + translateNext = false; + String doneMessage; + if (insertStatus == InsertStatus.INSERTED) { + doneMessage = "已插入 " + finalText.length() + " 个字"; + } else if (insertStatus == InsertStatus.COPIED_FALLBACK) { + doneMessage = "已复制 " + finalText.length() + " 个字"; + } else { + doneMessage = "插入失败"; + } + emit(CapsuleState.DONE, doneMessage); + if (insertStatus == InsertStatus.INSERTED) { + emitToast("已插入当前输入框。"); + } else if (insertStatus == InsertStatus.COPIED_FALLBACK) { + emitToast("已复制到剪贴板。"); + } else if (settings.allowClipboardFallback) { + emitToast("OpenLess 无法复制结果。"); + } else { + emitToast("OpenLess 键盘未激活,且剪贴板兜底已关闭。"); + } + } + }); + } + + private synchronized void fail(long currentSession, String message) { + if (currentSession != sessionId) { + return; + } + phase = DictationPhase.IDLE; + translateNext = false; + try { + historyStore.addFailure("", settingsStore.get().mode, "android_error", 0, dictionaryStore.enabledPhrases().size()); + } catch (Exception ignored) { + } + emit(CapsuleState.ERROR, message); + emitToast(message); + } + + private void postState(CapsuleState state, String message) { + main.post(() -> emit(state, message)); + } + + private void emit(CapsuleState state, String message) { + listener.onCapsuleState(state, message); + } + + private void emitToast(String message) { + listener.onToast(message == null ? "OpenLess 出错。" : message); + } +} diff --git a/openless-android/src/com/openless/android/AndroidPermissionDiagnostics.java b/openless-android/src/com/openless/android/AndroidPermissionDiagnostics.java new file mode 100644 index 00000000..66f07339 --- /dev/null +++ b/openless-android/src/com/openless/android/AndroidPermissionDiagnostics.java @@ -0,0 +1,135 @@ +package com.openless.android; + +import android.Manifest; +import android.app.NotificationManager; +import android.content.Context; +import android.content.pm.PackageManager; +import android.provider.Settings; +import android.view.inputmethod.InputMethodInfo; +import android.view.inputmethod.InputMethodManager; + +import java.util.ArrayList; +import java.util.List; + +final class AndroidPermissionDiagnostics { + private AndroidPermissionDiagnostics() { + } + + static List collect(Context context) { + ArrayList out = new ArrayList<>(); + out.add(microphoneStatus(context)); + out.add(overlayStatus(context)); + out.add(notificationStatus(context)); + out.add(foregroundServiceStatus(context)); + out.add(imeEnabledStatus(context)); + out.add(imeActiveStatus()); + return out; + } + + private static PermissionStatus microphoneStatus(Context context) { + boolean granted = context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED; + return new PermissionStatus( + "麦克风", + granted ? "已授权。" : "所有听写都需要麦克风权限。", + granted, + PermissionStatus.ACTION_APP_PERMISSIONS, + granted ? "查看" : "去设置"); + } + + private static PermissionStatus overlayStatus(Context context) { + boolean granted = Settings.canDrawOverlays(context); + return new PermissionStatus( + "悬浮窗", + granted ? "可以显示在其他应用上方。" : "悬浮触发气泡需要此权限。", + granted, + PermissionStatus.ACTION_OVERLAY, + granted ? "管理" : "授权"); + } + + private static PermissionStatus notificationStatus(Context context) { + boolean enabled = notificationsEnabled(context); + String detail; + if (android.os.Build.VERSION.SDK_INT < 33) { + detail = enabled ? "前台服务通知已启用。" : "前台服务通知被阻止。"; + } else { + detail = enabled ? "Android 13+ 通知权限已授权。" : "Android 13+ 需要通知权限才能显示前台服务。"; + } + return new PermissionStatus( + "通知", + detail, + enabled, + PermissionStatus.ACTION_NOTIFICATIONS, + enabled ? "管理" : "启用"); + } + + private static PermissionStatus foregroundServiceStatus(Context context) { + boolean microphone = context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED; + boolean notifications = notificationsEnabled(context); + boolean overlay = Settings.canDrawOverlays(context); + boolean ready = microphone && notifications && overlay; + String detail = ready + ? "前台麦克风服务所需条件已就绪。" + : "需要麦克风、通知和悬浮窗权限,才能接近桌面版的使用方式。"; + return new PermissionStatus( + "前台服务", + detail, + ready, + PermissionStatus.ACTION_NONE, + "就绪"); + } + + private static PermissionStatus imeEnabledStatus(Context context) { + boolean enabled = isImeEnabled(context); + return new PermissionStatus( + "OpenLess 键盘已启用", + enabled ? "已出现在系统输入法列表中。" : "请先在系统输入法设置中启用 OpenLess 键盘。", + enabled, + PermissionStatus.ACTION_IME, + enabled ? "管理" : "启用"); + } + + private static PermissionStatus imeActiveStatus() { + boolean active = OpenLessInputMethodService.isActive(); + return new PermissionStatus( + "OpenLess 键盘当前激活", + active ? "当前输入框可直接插入文字。" : "请在输入框中切换到 OpenLess 键盘以启用直接插入。", + active, + active ? PermissionStatus.ACTION_NONE : PermissionStatus.ACTION_IME, + active ? "已激活" : "打开设置"); + } + + private static boolean notificationsEnabled(Context context) { + if (android.os.Build.VERSION.SDK_INT >= 33 + && context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + return false; + } + NotificationManager manager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (manager == null) { + return true; + } + if (android.os.Build.VERSION.SDK_INT >= 24) { + return manager.areNotificationsEnabled(); + } + return true; + } + + private static boolean isImeEnabled(Context context) { + InputMethodManager imm = + (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm == null) { + return false; + } + String expectedId = context.getPackageName() + "/" + OpenLessInputMethodService.class.getName(); + List enabled = imm.getEnabledInputMethodList(); + for (InputMethodInfo info : enabled) { + if (expectedId.equals(info.getId())) { + return true; + } + } + return false; + } +} diff --git a/openless-android/src/com/openless/android/AsrProvider.java b/openless-android/src/com/openless/android/AsrProvider.java new file mode 100644 index 00000000..e6b8f155 --- /dev/null +++ b/openless-android/src/com/openless/android/AsrProvider.java @@ -0,0 +1,5 @@ +package com.openless.android; + +interface AsrProvider { + RawTranscript transcribe(AudioRecorder.Recording recording) throws Exception; +} diff --git a/openless-android/src/com/openless/android/AudioRecorder.java b/openless-android/src/com/openless/android/AudioRecorder.java new file mode 100644 index 00000000..7b7046f8 --- /dev/null +++ b/openless-android/src/com/openless/android/AudioRecorder.java @@ -0,0 +1,138 @@ +package com.openless.android; + +import android.media.AudioFormat; +import android.media.AudioRecord; +import android.media.MediaRecorder; + +import java.io.ByteArrayOutputStream; +import java.util.concurrent.atomic.AtomicBoolean; + +final class AudioRecorder { + static final int SAMPLE_RATE = 16000; + + interface AudioConsumer { + void consume(byte[] pcm, int length); + } + + interface LevelListener { + void onLevel(float level); + } + + private final AtomicBoolean recording = new AtomicBoolean(false); + private final Object pcmLock = new Object(); + private AudioRecord audioRecord; + private Thread worker; + private ByteArrayOutputStream pcm = new ByteArrayOutputStream(); + private long startedAtMs; + + boolean isRecording() { + return recording.get(); + } + + void start() { + start(null); + } + + void start(AudioConsumer consumer) { + start(consumer, null); + } + + void start(AudioConsumer consumer, LevelListener levelListener) { + if (recording.get()) { + return; + } + int minBuffer = AudioRecord.getMinBufferSize( + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT); + int bufferSize = Math.max(minBuffer, SAMPLE_RATE); + audioRecord = new AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize); + synchronized (pcmLock) { + pcm = new ByteArrayOutputStream(); + } + startedAtMs = System.currentTimeMillis(); + recording.set(true); + audioRecord.startRecording(); + worker = new Thread(() -> readLoop(bufferSize, consumer, levelListener), "openless-audio"); + worker.start(); + } + + Recording stop() { + if (!recording.getAndSet(false)) { + return new Recording(new byte[0], 0); + } + try { + if (audioRecord != null) { + audioRecord.stop(); + } + } catch (IllegalStateException ignored) { + } + if (worker != null) { + try { + worker.join(900); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + if (audioRecord != null) { + audioRecord.release(); + audioRecord = null; + } + long duration = Math.max(0, System.currentTimeMillis() - startedAtMs); + synchronized (pcmLock) { + return new Recording(pcm.toByteArray(), duration); + } + } + + private void readLoop(int bufferSize, AudioConsumer consumer, LevelListener levelListener) { + byte[] buffer = new byte[bufferSize]; + while (recording.get()) { + int read = audioRecord.read(buffer, 0, buffer.length); + if (read > 0) { + synchronized (pcmLock) { + pcm.write(buffer, 0, read); + } + if (consumer != null) { + byte[] copy = new byte[read]; + System.arraycopy(buffer, 0, copy, 0, read); + consumer.consume(copy, read); + } + if (levelListener != null) { + levelListener.onLevel(rms(buffer, read)); + } + } + } + } + + private float rms(byte[] buffer, int length) { + if (length < 2) { + return 0f; + } + double sum = 0.0; + int samples = length / 2; + for (int i = 0; i + 1 < length; i += 2) { + int lo = buffer[i] & 0xff; + int hi = buffer[i + 1]; + short sample = (short) ((hi << 8) | lo); + double normalized = sample / 32768.0; + sum += normalized * normalized; + } + double value = Math.sqrt(sum / Math.max(1, samples)); + return (float) Math.max(0.0, Math.min(1.0, value * 8.0)); + } + + static final class Recording { + final byte[] pcm; + final long durationMs; + + Recording(byte[] pcm, long durationMs) { + this.pcm = pcm; + this.durationMs = durationMs; + } + } +} diff --git a/openless-android/src/com/openless/android/CapsuleState.java b/openless-android/src/com/openless/android/CapsuleState.java new file mode 100644 index 00000000..23d574e5 --- /dev/null +++ b/openless-android/src/com/openless/android/CapsuleState.java @@ -0,0 +1,21 @@ +package com.openless.android; + +final class CapsuleState { + static final CapsuleState IDLE = new CapsuleState("就绪", 0xff2563eb); + static final CapsuleState STARTING = new CapsuleState("启动中", 0xffdc2626); + static final CapsuleState RECORDING = new CapsuleState("听写中", 0xffdc2626); + static final CapsuleState TRANSCRIBING = new CapsuleState("转写中", 0xffd97706); + static final CapsuleState POLISHING = new CapsuleState("润色中", 0xffd97706); + static final CapsuleState TRANSLATING = new CapsuleState("翻译中", 0xffd97706); + static final CapsuleState DONE = new CapsuleState("完成", 0xff16a34a); + static final CapsuleState CANCELLED = new CapsuleState("已取消", 0xffa0a0a3); + static final CapsuleState ERROR = new CapsuleState("错误", 0xffdc2626); + + final String label; + final int color; + + private CapsuleState(String label, int color) { + this.label = label; + this.color = color; + } +} diff --git a/openless-android/src/com/openless/android/DictationPhase.java b/openless-android/src/com/openless/android/DictationPhase.java new file mode 100644 index 00000000..21362507 --- /dev/null +++ b/openless-android/src/com/openless/android/DictationPhase.java @@ -0,0 +1,8 @@ +package com.openless.android; + +final class DictationPhase { + static final int IDLE = 0; + static final int STARTING = 1; + static final int LISTENING = 2; + static final int PROCESSING = 3; +} diff --git a/openless-android/src/com/openless/android/DictionaryActivity.java b/openless-android/src/com/openless/android/DictionaryActivity.java new file mode 100644 index 00000000..6684bb1f --- /dev/null +++ b/openless-android/src/com/openless/android/DictionaryActivity.java @@ -0,0 +1,492 @@ +package com.openless.android; + +import android.app.Activity; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.RoundRectShape; +import android.os.Bundle; +import android.view.Gravity; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import java.util.List; + +public final class DictionaryActivity extends Activity { + private static final int OL_CANVAS = Color.rgb(247, 247, 248); + private static final int OL_SURFACE = Color.rgb(255, 255, 255); + private static final int OL_INK = Color.rgb(10, 10, 11); + private static final int OL_INK_2 = Color.rgb(42, 42, 45); + private static final int OL_INK_3 = Color.rgb(160, 160, 163); + private static final int OL_INK_4 = Color.rgb(108, 108, 112); + private static final int OL_BLUE = Color.rgb(37, 99, 235); + private static final int OL_BLUE_SOFT = Color.rgb(239, 244, 255); + private static final int OL_LINE = Color.argb(20, 0, 0, 0); + private static final int OL_LINE_STRONG = Color.argb(36, 0, 0, 0); + private static final int OL_OK = Color.rgb(22, 163, 74); + private static final int OL_WARN = Color.rgb(217, 119, 6); + private static final int OL_ERR = Color.rgb(220, 38, 38); + + private DictionaryStore dictionaryStore; + private TextView summaryCountView; + private TextView summaryEnabledView; + private TextView summaryHitsView; + private TextView statusView; + private LinearLayout listContainer; + private EditText phraseInput; + private EditText noteInput; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + dictionaryStore = new DictionaryStore(this); + setContentView(buildContent()); + refreshList(); + } + + @Override + protected void onResume() { + super.onResume(); + refreshList(); + } + + private View buildContent() { + ScrollView scroll = new ScrollView(this); + scroll.setFillViewport(true); + scroll.setBackgroundColor(OL_CANVAS); + + LinearLayout root = column(); + root.setPadding(dp(16), dp(16), dp(16), dp(24)); + scroll.addView(root); + + header(root); + overviewSection(root); + editorSection(root); + listSection(root); + return scroll; + } + + private void header(LinearLayout root) { + LinearLayout top = row(); + top.setGravity(Gravity.CENTER_VERTICAL); + top.setPadding(0, dp(8), 0, dp(8)); + + Button back = ghostButton("返回", OL_INK_2); + back.setOnClickListener(v -> finish()); + top.addView(back); + top.addView(spacer(dp(8))); + + LinearLayout titleCol = column(); + TextView title = text("词典", 24, Typeface.BOLD); + titleCol.addView(title); + TextView subtitle = text("维护热词、术语和产品名,直接影响识别与润色。", 12, Typeface.NORMAL); + subtitle.setTextColor(OL_INK_3); + titleCol.addView(subtitle); + top.addView(titleCol, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1)); + + Button export = ghostButton("复制导出", OL_BLUE); + export.setOnClickListener(v -> { + String text = dictionaryStore.exportPlainText(); + if (text.trim().isEmpty()) { + setStatus("当前没有可导出的词条", OL_WARN); + return; + } + android.content.ClipboardManager clipboard = + (android.content.ClipboardManager) getSystemService(CLIPBOARD_SERVICE); + if (clipboard != null) { + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("OpenLess 字典", text)); + setStatus("词典内容已复制到剪贴板", OL_OK); + } + }); + top.addView(export); + top.addView(spacer(dp(8))); + + Button importButton = ghostButton("粘贴导入", OL_INK_2); + importButton.setOnClickListener(v -> importFromClipboard()); + top.addView(importButton); + + root.addView(top); + root.addView(divider()); + + statusView = text("就绪", 11, Typeface.BOLD); + statusView.setTextColor(OL_BLUE); + statusView.setPadding(0, dp(8), 0, 0); + root.addView(statusView); + } + + private void overviewSection(LinearLayout root) { + card(root, card -> { + LinearLayout head = row(); + head.setGravity(Gravity.CENTER_VERTICAL); + TextView title = text("词典概览", 14, Typeface.BOLD); + title.setTextColor(OL_INK_2); + head.addView(title, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1)); + TextView badge = text("热词", 10, Typeface.BOLD); + badge.setTextColor(OL_BLUE); + badge.setPadding(dp(8), dp(4), dp(8), dp(4)); + badge.setBackgroundDrawable(roundedBg(OL_BLUE_SOFT, 999)); + head.addView(badge); + card.addView(head); + + TextView desc = text("先看当前词条规模,再增删和启停。", 11, Typeface.NORMAL); + desc.setTextColor(OL_INK_4); + desc.setPadding(0, dp(4), 0, dp(10)); + card.addView(desc); + + LinearLayout row = row(); + row.addView(summaryCard("词条数")); + row.addView(spacer(dp(8))); + row.addView(summaryCard("启用中")); + row.addView(spacer(dp(8))); + row.addView(summaryCard("总命中")); + card.addView(row); + }); + } + + private void editorSection(LinearLayout root) { + card(root, card -> { + TextView title = text("新增词条", 13, Typeface.BOLD); + title.setTextColor(OL_INK_2); + card.addView(title); + TextView desc = text("适合录入产品名、人名、术语和缩写。", 11, Typeface.NORMAL); + desc.setTextColor(OL_INK_4); + desc.setPadding(0, dp(4), 0, dp(8)); + card.addView(desc); + + phraseInput = input("", "词条"); + noteInput = input("", "备注(可选)"); + card.addView(phraseInput); + card.addView(spacer(dp(6))); + card.addView(noteInput); + card.addView(spacer(dp(10))); + + LinearLayout actions = row(); + Button add = pillButton("添加词条", OL_BLUE); + add.setOnClickListener(v -> { + String phrase = value(phraseInput); + if (phrase.isEmpty()) { + setStatus("请先输入词条", OL_WARN); + return; + } + dictionaryStore.add(phrase, value(noteInput)); + phraseInput.setText(""); + noteInput.setText(""); + refreshList(); + setStatus("词条已添加", OL_OK); + }); + actions.addView(add, new LinearLayout.LayoutParams(0, dp(44), 1)); + actions.addView(spacer(dp(8))); + + Button clear = ghostButton("清空输入", OL_INK_3); + clear.setOnClickListener(v -> { + phraseInput.setText(""); + noteInput.setText(""); + setStatus("输入已清空", OL_INK_3); + }); + actions.addView(clear, new LinearLayout.LayoutParams(0, dp(44), 1)); + actions.addView(spacer(dp(8))); + + Button wipe = ghostButton("清空词典", OL_ERR); + wipe.setOnClickListener(v -> { + dictionaryStore.replacePlainText(""); + refreshList(); + setStatus("词典已清空", OL_WARN); + }); + actions.addView(wipe, new LinearLayout.LayoutParams(0, dp(44), 1)); + card.addView(actions); + }); + } + + private void listSection(LinearLayout root) { + card(root, card -> { + LinearLayout head = row(); + head.setGravity(Gravity.CENTER_VERTICAL); + TextView title = text("词条列表", 13, Typeface.BOLD); + title.setTextColor(OL_INK_2); + head.addView(title, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1)); + Button refresh = ghostButton("刷新", OL_BLUE); + refresh.setOnClickListener(v -> { + refreshList(); + setStatus("列表已刷新", OL_BLUE); + }); + head.addView(refresh); + card.addView(head); + card.addView(spacer(dp(8))); + listContainer = column(); + card.addView(listContainer); + }); + } + + private LinearLayout summaryCard(String label) { + LinearLayout box = column(); + box.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1)); + box.setPadding(dp(12), dp(10), dp(12), dp(10)); + box.setBackgroundDrawable(roundedBg(OL_CANVAS, 10)); + TextView title = text(label, 11, Typeface.BOLD); + title.setTextColor(OL_INK_4); + box.addView(title); + TextView value = text("", 14, Typeface.BOLD); + value.setTextColor(OL_INK); + value.setPadding(0, dp(3), 0, 0); + box.addView(value); + if ("词条数".equals(label)) { + summaryCountView = value; + } else if ("启用中".equals(label)) { + summaryEnabledView = value; + } else if ("总命中".equals(label)) { + summaryHitsView = value; + } + return box; + } + + private void refreshList() { + if (listContainer == null) return; + listContainer.removeAllViews(); + List entries = dictionaryStore.list(); + + int enabled = 0; + long hits = 0; + for (DictionaryStore.Entry entry : entries) { + if (entry.enabled) enabled++; + hits += entry.hits; + } + if (summaryCountView != null) summaryCountView.setText(String.valueOf(entries.size())); + if (summaryEnabledView != null) summaryEnabledView.setText(String.valueOf(enabled)); + if (summaryHitsView != null) summaryHitsView.setText(String.valueOf(hits)); + + if (entries.isEmpty()) { + TextView empty = text("还没有词条。可添加产品名、专有名词或术语来提升识别准确率。", 13, Typeface.NORMAL); + empty.setTextColor(OL_INK_3); + listContainer.addView(empty); + return; + } + + for (DictionaryStore.Entry entry : entries) { + LinearLayout box = column(); + box.setPadding(dp(12), dp(10), dp(12), dp(10)); + box.setBackgroundDrawable(roundedBg(OL_CANVAS, 10)); + + LinearLayout top = row(); + top.setGravity(Gravity.CENTER_VERTICAL); + TextView phrase = text(entry.phrase, 13, Typeface.BOLD); + phrase.setTextColor(OL_INK); + top.addView(phrase, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1)); + TextView state = text(entry.enabled ? "启用中" : "已停用", 10, Typeface.BOLD); + state.setTextColor(entry.enabled ? OL_OK : OL_WARN); + state.setPadding(dp(8), dp(4), dp(8), dp(4)); + state.setBackgroundDrawable(roundedBg(entry.enabled + ? Color.argb(20, 22, 163, 74) + : Color.argb(20, 217, 119, 6), 999)); + top.addView(state); + box.addView(top); + + StringBuilder meta = new StringBuilder(); + meta.append("命中 ").append(entry.hits); + if (!entry.note.isEmpty()) { + meta.append(" · ").append(entry.note); + } + TextView note = text(meta.toString(), 11, Typeface.NORMAL); + note.setTextColor(OL_INK_4); + note.setPadding(0, dp(4), 0, 0); + box.addView(note); + + LinearLayout actions = row(); + actions.setPadding(0, dp(8), 0, 0); + Button toggle = ghostButton(entry.enabled ? "停用" : "启用", entry.enabled ? OL_WARN : OL_OK); + toggle.setOnClickListener(v -> { + dictionaryStore.setEnabled(entry.id, !entry.enabled); + refreshList(); + setStatus(entry.enabled ? "词条已停用" : "词条已启用", entry.enabled ? OL_WARN : OL_OK); + }); + actions.addView(toggle, new LinearLayout.LayoutParams(0, dp(40), 1)); + actions.addView(spacer(dp(8))); + + Button copy = ghostButton("复制", OL_BLUE); + copy.setOnClickListener(v -> { + android.content.ClipboardManager clipboard = + (android.content.ClipboardManager) getSystemService(CLIPBOARD_SERVICE); + if (clipboard != null) { + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("OpenLess 词条", entry.phrase)); + setStatus("词条已复制", OL_OK); + } + }); + actions.addView(copy, new LinearLayout.LayoutParams(0, dp(40), 1)); + actions.addView(spacer(dp(8))); + + Button delete = ghostButton("删除", OL_ERR); + delete.setOnClickListener(v -> { + dictionaryStore.remove(entry.id); + refreshList(); + setStatus("词条已删除", OL_ERR); + }); + actions.addView(delete, new LinearLayout.LayoutParams(0, dp(40), 1)); + box.addView(actions); + + listContainer.addView(box); + listContainer.addView(spacer(dp(8))); + } + } + + private void importFromClipboard() { + android.content.ClipboardManager clipboard = + (android.content.ClipboardManager) getSystemService(CLIPBOARD_SERVICE); + if (clipboard == null || !clipboard.hasPrimaryClip()) { + setStatus("剪贴板为空", OL_WARN); + return; + } + android.content.ClipData clip = clipboard.getPrimaryClip(); + if (clip == null || clip.getItemCount() == 0) { + setStatus("剪贴板为空", OL_WARN); + return; + } + String raw = clip.getItemAt(0).coerceToText(this).toString().trim(); + if (raw.isEmpty()) { + setStatus("剪贴板为空", OL_WARN); + return; + } + dictionaryStore.replacePlainText(raw); + refreshList(); + setStatus("已按剪贴板内容覆盖导入词典", OL_OK); + } + + private void setStatus(String message, int color) { + if (statusView == null) return; + statusView.setText(message); + statusView.setTextColor(color); + } + + private void card(LinearLayout root, CardBuilder builder) { + LinearLayout card = column(); + card.setPadding(dp(14), dp(14), dp(14), dp(14)); + card.setBackgroundDrawable(cardBg()); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.setMargins(0, 0, 0, dp(10)); + card.setLayoutParams(params); + builder.build(card); + root.addView(card); + } + + private Drawable cardBg() { + float r = dp(12); + float[] radii = new float[]{r, r, r, r, r, r, r, r}; + ShapeDrawable bg = new ShapeDrawable(new RoundRectShape(radii, null, null)); + bg.getPaint().setColor(OL_SURFACE); + return bg; + } + + private Drawable roundedBg(int color, float radiusDip) { + float r = dp(radiusDip); + float[] radii = new float[]{r, r, r, r, r, r, r, r}; + ShapeDrawable bg = new ShapeDrawable(new RoundRectShape(radii, null, null)); + bg.getPaint().setColor(color); + return bg; + } + + private Drawable outlineBg(int borderColor) { + float r = dp(999); + float[] radii = new float[]{r, r, r, r, r, r, r, r}; + ShapeDrawable bg = new ShapeDrawable(new RoundRectShape(radii, null, null)); + bg.getPaint().setColor(Color.TRANSPARENT); + bg.getPaint().setStyle(Paint.Style.STROKE); + bg.getPaint().setStrokeWidth(dp(0.5f)); + bg.getPaint().setColor(borderColor); + return bg; + } + + private LinearLayout column() { + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.VERTICAL); + return layout; + } + + private LinearLayout row() { + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.HORIZONTAL); + return layout; + } + + private View divider() { + View v = new View(this); + v.setBackgroundColor(OL_LINE); + v.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1)); + return v; + } + + private View spacer(int px) { + View v = new View(this); + v.setLayoutParams(new LinearLayout.LayoutParams(Math.max(1, px), px)); + return v; + } + + private TextView text(String value, int sp, int style) { + TextView view = new TextView(this); + view.setText(value); + view.setTextColor(OL_INK); + view.setTextSize(sp); + view.setTypeface(Typeface.DEFAULT, style); + view.setLineSpacing(0, 1.2f); + return view; + } + + private Button ghostButton(String label, int color) { + Button button = new Button(this); + button.setText(label); + button.setAllCaps(false); + button.setTextColor(color); + button.setTextSize(11); + button.setBackgroundDrawable(outlineBg(OL_LINE_STRONG)); + button.setPadding(dp(8), dp(4), dp(8), dp(4)); + button.setMinHeight(0); + button.setMinimumHeight(0); + return button; + } + + private Button pillButton(String label, int color) { + Button button = new Button(this); + button.setText(label); + button.setAllCaps(false); + button.setTextColor(Color.WHITE); + button.setTextSize(12); + button.setBackgroundDrawable(roundedBg(color, 999)); + button.setPadding(dp(12), dp(8), dp(12), dp(8)); + button.setMinHeight(0); + button.setMinimumHeight(0); + return button; + } + + private EditText input(String value, String hint) { + EditText edit = new EditText(this); + edit.setText(value); + edit.setHint(hint); + edit.setTextColor(OL_INK); + edit.setHintTextColor(OL_INK_3); + edit.setBackgroundDrawable(roundedBg(OL_CANVAS, 10)); + edit.setPadding(dp(12), dp(10), dp(12), dp(10)); + return edit; + } + + private String value(EditText edit) { + return edit == null || edit.getText() == null ? "" : edit.getText().toString().trim(); + } + + private int dp(int value) { + return (int) (value * getResources().getDisplayMetrics().density + 0.5f); + } + + private float dp(float value) { + return value * getResources().getDisplayMetrics().density + 0.5f; + } + + private interface CardBuilder { + void build(LinearLayout card); + } +} diff --git a/openless-android/src/com/openless/android/DictionaryStore.java b/openless-android/src/com/openless/android/DictionaryStore.java new file mode 100644 index 00000000..db1d3cba --- /dev/null +++ b/openless-android/src/com/openless/android/DictionaryStore.java @@ -0,0 +1,232 @@ +package com.openless.android; + +import android.content.Context; +import android.content.SharedPreferences; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import java.util.UUID; + +final class DictionaryStore { + private static final String PREFS = "openless_dictionary"; + private static final String KEY = "items"; + + private final SharedPreferences prefs; + + DictionaryStore(Context context) { + prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + List list() { + List out = new ArrayList<>(); + try { + JSONArray items = new JSONArray(prefs.getString(KEY, "[]")); + for (int i = 0; i < items.length(); i++) { + JSONObject json = items.getJSONObject(i); + String phrase = json.optString("phrase", "").trim(); + if (phrase.isEmpty()) { + continue; + } + out.add(new Entry( + firstNonEmpty(json.optString("id"), UUID.randomUUID().toString()), + phrase, + json.optString("note", json.optString("notes", "")).trim(), + json.optBoolean("enabled", true), + json.optLong("hits", json.optLong("hitCount", 0)), + firstNonEmpty(json.optString("createdAt"), json.optString("created_at")))); + } + } catch (Exception ignored) { + } + return out; + } + + List enabledEntries() { + List out = new ArrayList<>(); + for (Entry entry : list()) { + if (entry.enabled && !entry.phrase.isEmpty()) { + out.add(entry); + } + } + return out; + } + + List enabledPhrases() { + List out = new ArrayList<>(); + for (Entry entry : enabledEntries()) { + out.add(entry.phrase); + } + return out; + } + + String exportPlainText() { + StringBuilder builder = new StringBuilder(); + for (Entry entry : list()) { + if (builder.length() > 0) { + builder.append('\n'); + } + builder.append(entry.phrase); + if (!entry.note.isEmpty()) { + builder.append(" | ").append(entry.note); + } + } + return builder.toString(); + } + + void replacePlainText(String text) { + JSONArray items = new JSONArray(); + String[] parts = text.split("[,,\\n]"); + for (String part : parts) { + String value = part.trim(); + if (value.isEmpty()) { + continue; + } + String phrase = value; + String note = ""; + int divider = value.indexOf('|'); + if (divider >= 0) { + phrase = value.substring(0, divider).trim(); + note = value.substring(divider + 1).trim(); + } + if (!phrase.isEmpty()) { + items.put(toJson(new Entry(UUID.randomUUID().toString(), phrase, note, true, 0, isoNow()))); + } + } + prefs.edit().putString(KEY, items.toString()).apply(); + } + + Entry add(String phrase, String note) { + Entry entry = new Entry(UUID.randomUUID().toString(), phrase.trim(), note == null ? "" : note.trim(), true, 0, isoNow()); + if (entry.phrase.isEmpty()) { + return entry; + } + List entries = list(); + entries.add(0, entry); + write(entries); + return entry; + } + + void remove(String id) { + List entries = list(); + List next = new ArrayList<>(); + for (Entry entry : entries) { + if (!entry.id.equals(id)) { + next.add(entry); + } + } + write(next); + } + + void setEnabled(String id, boolean enabled) { + List entries = list(); + List next = new ArrayList<>(); + for (Entry entry : entries) { + if (entry.id.equals(id)) { + next.add(new Entry(entry.id, entry.phrase, entry.note, enabled, entry.hits, entry.createdAt)); + } else { + next.add(entry); + } + } + write(next); + } + + int recordHits(String text) { + if (text == null || text.isEmpty()) { + return 0; + } + String haystack = text.toLowerCase(Locale.ROOT); + int total = 0; + boolean changed = false; + List next = new ArrayList<>(); + for (Entry entry : list()) { + if (!entry.enabled || entry.phrase.isEmpty()) { + next.add(entry); + continue; + } + int count = countOccurrences(haystack, entry.phrase.toLowerCase(Locale.ROOT)); + if (count > 0) { + total += count; + changed = true; + next.add(new Entry(entry.id, entry.phrase, entry.note, entry.enabled, entry.hits + count, entry.createdAt)); + } else { + next.add(entry); + } + } + if (changed) { + write(next); + } + return total; + } + + private void write(List entries) { + JSONArray items = new JSONArray(); + for (Entry entry : entries) { + items.put(toJson(entry)); + } + prefs.edit().putString(KEY, items.toString()).apply(); + } + + private JSONObject toJson(Entry entry) { + JSONObject item = new JSONObject(); + try { + item.put("id", entry.id); + item.put("phrase", entry.phrase); + item.put("note", entry.note); + item.put("enabled", entry.enabled); + item.put("hits", entry.hits); + item.put("createdAt", entry.createdAt == null || entry.createdAt.isEmpty() ? isoNow() : entry.createdAt); + } catch (Exception ignored) { + } + return item; + } + + private static int countOccurrences(String haystack, String needle) { + if (needle.isEmpty() || haystack.length() < needle.length()) { + return 0; + } + int count = 0; + int start = 0; + while (true) { + int pos = haystack.indexOf(needle, start); + if (pos < 0) { + return count; + } + count++; + start = pos + needle.length(); + } + } + + private static String isoNow() { + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); + format.setTimeZone(TimeZone.getTimeZone("UTC")); + return format.format(new Date()); + } + + private static String firstNonEmpty(String first, String second) { + return first == null || first.isEmpty() ? (second == null ? "" : second) : first; + } + + static final class Entry { + final String id; + final String phrase; + final String note; + final boolean enabled; + final long hits; + final String createdAt; + + Entry(String id, String phrase, String note, boolean enabled, long hits, String createdAt) { + this.id = id; + this.phrase = phrase; + this.note = note; + this.enabled = enabled; + this.hits = hits; + this.createdAt = createdAt; + } + } +} diff --git a/openless-android/src/com/openless/android/ErrorDetailActivity.java b/openless-android/src/com/openless/android/ErrorDetailActivity.java new file mode 100644 index 00000000..55c0223d --- /dev/null +++ b/openless-android/src/com/openless/android/ErrorDetailActivity.java @@ -0,0 +1,259 @@ +package com.openless.android; + +import android.app.Activity; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.RoundRectShape; +import android.os.Bundle; +import android.view.Gravity; +import android.view.View; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +public final class ErrorDetailActivity extends Activity { + static final String EXTRA_TITLE = "openless.extra.ERROR_TITLE"; + static final String EXTRA_SOURCE = "openless.extra.ERROR_SOURCE"; + static final String EXTRA_MESSAGE = "openless.extra.ERROR_MESSAGE"; + + private static final int OL_CANVAS = Color.rgb(247, 247, 248); + private static final int OL_SURFACE = Color.rgb(255, 255, 255); + private static final int OL_INK = Color.rgb(10, 10, 11); + private static final int OL_INK_2 = Color.rgb(42, 42, 45); + private static final int OL_INK_3 = Color.rgb(160, 160, 163); + private static final int OL_INK_4 = Color.rgb(108, 108, 112); + private static final int OL_BLUE = Color.rgb(37, 99, 235); + private static final int OL_OK = Color.rgb(22, 163, 74); + private static final int OL_RED = Color.rgb(220, 38, 38); + private static final int OL_RED_SOFT = Color.rgb(254, 242, 242); + private static final int OL_LINE = Color.argb(20, 0, 0, 0); + private static final int OL_LINE_STRONG = Color.argb(36, 0, 0, 0); + + private TextView statusView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(buildContent()); + } + + private View buildContent() { + ScrollView scroll = new ScrollView(this); + scroll.setFillViewport(true); + scroll.setBackgroundColor(OL_CANVAS); + + LinearLayout root = column(); + root.setPadding(dp(16), dp(16), dp(16), dp(24)); + scroll.addView(root); + + header(root); + bodySection(root); + return scroll; + } + + private void header(LinearLayout root) { + LinearLayout top = row(); + top.setGravity(Gravity.CENTER_VERTICAL); + top.setPadding(0, dp(8), 0, dp(8)); + + Button back = ghostButton("返回", OL_INK_2); + back.setOnClickListener(v -> finish()); + top.addView(back); + top.addView(spacer(dp(8))); + + LinearLayout titleCol = column(); + TextView title = text(fallback(extra(EXTRA_TITLE), "错误"), 24, Typeface.BOLD); + titleCol.addView(title); + TextView subtitle = text(fallback(extra(EXTRA_SOURCE), "OpenLess"), 12, Typeface.NORMAL); + subtitle.setTextColor(OL_INK_3); + titleCol.addView(subtitle); + top.addView(titleCol, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1)); + + Button copy = ghostButton("复制", OL_BLUE); + copy.setOnClickListener(v -> copyMessage()); + top.addView(copy); + + root.addView(top); + root.addView(divider()); + + statusView = text("就绪", 11, Typeface.BOLD); + statusView.setTextColor(OL_BLUE); + statusView.setPadding(0, dp(8), 0, 0); + root.addView(statusView); + } + + private void bodySection(LinearLayout root) { + card(root, card -> { + LinearLayout badgeRow = row(); + badgeRow.setGravity(Gravity.CENTER_VERTICAL); + TextView badge = text("错误详情", 10, Typeface.BOLD); + badge.setTextColor(OL_RED); + badge.setPadding(dp(8), dp(4), dp(8), dp(4)); + badge.setBackgroundDrawable(roundedBg(OL_RED_SOFT, 999)); + badgeRow.addView(badge); + card.addView(badgeRow); + + TextView desc = text("这里保留原始错误信息,便于排查和转发。", 11, Typeface.NORMAL); + desc.setTextColor(OL_INK_4); + desc.setPadding(0, dp(8), 0, dp(10)); + card.addView(desc); + + card.addView(detailBlock("来源", fallback(extra(EXTRA_SOURCE), "OpenLess"), false)); + card.addView(spacer(dp(8))); + card.addView(detailBlock("消息", fallback(extra(EXTRA_MESSAGE), "未知错误"), true)); + }); + } + + private void copyMessage() { + String message = "标题:" + + fallback(extra(EXTRA_TITLE), "错误") + + "\n来源:" + + fallback(extra(EXTRA_SOURCE), "OpenLess") + + "\n消息:\n" + + fallback(extra(EXTRA_MESSAGE), "未知错误"); + android.content.ClipboardManager clipboard = + (android.content.ClipboardManager) getSystemService(CLIPBOARD_SERVICE); + if (clipboard != null) { + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("OpenLess Error", message)); + setStatus("错误信息已复制", OL_OK); + } + } + + private void setStatus(String message, int color) { + if (statusView == null) return; + statusView.setText(message); + statusView.setTextColor(color); + } + + private String extra(String key) { + String value = getIntent() == null ? null : getIntent().getStringExtra(key); + return value == null ? "" : value; + } + + private String fallback(String value, String fallback) { + return value == null || value.trim().isEmpty() ? fallback : value; + } + + private View detailBlock(String title, String body, boolean selectable) { + LinearLayout box = column(); + box.setPadding(dp(12), dp(10), dp(12), dp(10)); + box.setBackgroundDrawable(roundedBg(selectable ? OL_CANVAS : OL_SURFACE, 8)); + + TextView label = text(title, 11, Typeface.BOLD); + label.setTextColor(OL_INK_4); + box.addView(label); + + TextView content = text(body, 13, Typeface.NORMAL); + content.setTextColor(OL_INK_2); + content.setPadding(0, dp(6), 0, 0); + content.setLineSpacing(0, 1.3f); + content.setTextIsSelectable(selectable); + box.addView(content); + return box; + } + + private void card(LinearLayout root, CardBuilder builder) { + LinearLayout card = column(); + card.setPadding(dp(14), dp(14), dp(14), dp(14)); + card.setBackgroundDrawable(cardBg()); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.setMargins(0, 0, 0, dp(10)); + card.setLayoutParams(params); + builder.build(card); + root.addView(card); + } + + private Drawable cardBg() { + float r = dp(12); + float[] radii = new float[]{r, r, r, r, r, r, r, r}; + ShapeDrawable bg = new ShapeDrawable(new RoundRectShape(radii, null, null)); + bg.getPaint().setColor(OL_SURFACE); + return bg; + } + + private Drawable roundedBg(int color, float radiusDip) { + float r = dp(radiusDip); + float[] radii = new float[]{r, r, r, r, r, r, r, r}; + ShapeDrawable bg = new ShapeDrawable(new RoundRectShape(radii, null, null)); + bg.getPaint().setColor(color); + return bg; + } + + private Drawable outlineBg(int borderColor) { + float r = dp(999); + float[] radii = new float[]{r, r, r, r, r, r, r, r}; + ShapeDrawable bg = new ShapeDrawable(new RoundRectShape(radii, null, null)); + bg.getPaint().setColor(Color.TRANSPARENT); + bg.getPaint().setStyle(Paint.Style.STROKE); + bg.getPaint().setStrokeWidth(dp(0.5f)); + bg.getPaint().setColor(borderColor); + return bg; + } + + private LinearLayout column() { + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.VERTICAL); + return layout; + } + + private LinearLayout row() { + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.HORIZONTAL); + return layout; + } + + private View divider() { + View v = new View(this); + v.setBackgroundColor(OL_LINE); + v.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1)); + return v; + } + + private View spacer(int px) { + View v = new View(this); + v.setLayoutParams(new LinearLayout.LayoutParams(Math.max(1, px), px)); + return v; + } + + private TextView text(String value, int sp, int style) { + TextView view = new TextView(this); + view.setText(value); + view.setTextColor(OL_INK); + view.setTextSize(sp); + view.setTypeface(Typeface.DEFAULT, style); + view.setLineSpacing(0, 1.2f); + return view; + } + + private Button ghostButton(String label, int color) { + Button button = new Button(this); + button.setText(label); + button.setAllCaps(false); + button.setTextColor(color); + button.setTextSize(11); + button.setBackgroundDrawable(outlineBg(OL_LINE_STRONG)); + button.setPadding(dp(8), dp(4), dp(8), dp(4)); + button.setMinHeight(0); + button.setMinimumHeight(0); + return button; + } + + private int dp(int value) { + return (int) (value * getResources().getDisplayMetrics().density + 0.5f); + } + + private float dp(float value) { + return value * getResources().getDisplayMetrics().density + 0.5f; + } + + private interface CardBuilder { + void build(LinearLayout card); + } +} diff --git a/openless-android/src/com/openless/android/FloatingTriggerService.java b/openless-android/src/com/openless/android/FloatingTriggerService.java new file mode 100644 index 00000000..36c5c4a9 --- /dev/null +++ b/openless-android/src/com/openless/android/FloatingTriggerService.java @@ -0,0 +1,389 @@ +package com.openless.android; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.ServiceInfo; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.RectF; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.provider.Settings; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.WindowManager; +import android.widget.Toast; + +// UI-only: refined bubble drawing (shadow hint, smoother mic icon) +// All actions preserved: toggle, translate, QA panel, QA clipboard, cancel, stop, refresh settings +// All Chinese text preserved +public final class FloatingTriggerService extends Service implements AndroidDictationCoordinator.Listener { + private static final int NOTIFICATION_ID = 1001; + private static final String CHANNEL_ID = "openless_floating_trigger"; + private static final String ACTION_STOP = "com.openless.android.STOP_FLOATING"; + private static final String ACTION_CANCEL = "com.openless.android.CANCEL_DICTATION"; + private static final String ACTION_TOGGLE = "com.openless.android.TOGGLE_DICTATION"; + private static final String ACTION_TRANSLATE = "com.openless.android.START_TRANSLATION"; + private static final String ACTION_OPEN_QA = "com.openless.android.OPEN_QA"; + private static final String ACTION_QA_CLIPBOARD = "com.openless.android.OPEN_QA_CLIPBOARD"; + static final String ACTION_REFRESH_SETTINGS = "com.openless.android.REFRESH_FLOATING_SETTINGS"; + private static final long LONG_PRESS_CANCEL_MS = 800; + private static final long IDLE_DELAY_MS = 1500; + + private WindowManager windowManager; + private WindowManager.LayoutParams params; + private SharedPreferences prefs; + private SettingsStore settingsStore; + private MicBubbleView bubble; + private AndroidDictationCoordinator coordinator; + private final Handler main = new Handler(Looper.getMainLooper()); + private int stateGeneration; + private boolean dragging; + private float downX; + private float downY; + private int startX; + private int startY; + private CapsuleState currentState = CapsuleState.IDLE; + private String currentMessage; + + @Override + public void onCreate() { + super.onCreate(); + settingsStore = new SettingsStore(this); + HistoryStore historyStore = new HistoryStore(this); + prefs = getSharedPreferences("openless_capsule", MODE_PRIVATE); + coordinator = new AndroidDictationCoordinator(this, settingsStore, historyStore, this); + windowManager = (WindowManager) getSystemService(WINDOW_SERVICE); + if (!Settings.canDrawOverlays(this)) { + toast("请先授予悬浮窗权限。"); + stopSelf(); + return; + } + startAsForegroundService(); + applyBubbleVisibility(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + String action = intent == null ? null : intent.getAction(); + if (ACTION_STOP.equals(action)) { stopSelf(); return START_NOT_STICKY; } + if (ACTION_CANCEL.equals(action)) { coordinator.cancel(); return START_STICKY; } + if (ACTION_TOGGLE.equals(action)) { coordinator.toggle(); return START_STICKY; } + if (ACTION_TRANSLATE.equals(action)) { coordinator.startTranslation(); return START_STICKY; } + if (ACTION_OPEN_QA.equals(action)) { + Intent qaIntent = new Intent(this, QaPanelActivity.class); + qaIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(qaIntent); + return START_STICKY; + } + if (ACTION_QA_CLIPBOARD.equals(action)) { openQaWithClipboardContext(); return START_STICKY; } + if (ACTION_REFRESH_SETTINGS.equals(action)) { applyBubbleVisibility(); return START_STICKY; } + if (bubble == null && Settings.canDrawOverlays(this)) { applyBubbleVisibility(); } + return START_STICKY; + } + + @Override + public void onDestroy() { + if (coordinator != null) coordinator.shutdown(); + if (bubble != null) { windowManager.removeView(bubble); bubble = null; } + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { return null; } + + private void showBubble() { + int type = android.os.Build.VERSION.SDK_INT >= 26 + ? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + : WindowManager.LayoutParams.TYPE_PHONE; + params = new WindowManager.LayoutParams(dp(64), dp(64), type, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + PixelFormat.TRANSLUCENT); + params.gravity = Gravity.TOP | Gravity.START; + params.x = prefs.getInt("x", dp(18)); + params.y = prefs.getInt("y", dp(180)); + bubble = new MicBubbleView(this); + bubble.setOnTouchListener(this::onBubbleTouch); + windowManager.addView(bubble, params); + } + + private void hideBubble() { + if (bubble != null) { windowManager.removeView(bubble); bubble = null; } + } + + private void applyBubbleVisibility() { + boolean shouldShow = settingsStore.get().showCapsule; + if (!Settings.canDrawOverlays(this)) { hideBubble(); return; } + if (shouldShow) { if (bubble == null) showBubble(); } + else { hideBubble(); } + } + + private void startAsForegroundService() { + NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (android.os.Build.VERSION.SDK_INT >= 26 && manager != null) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + getString(R.string.floating_channel_name), + NotificationManager.IMPORTANCE_LOW); + channel.setDescription(getString(R.string.floating_channel_description)); + manager.createNotificationChannel(channel); + } + Notification notification = buildNotification(currentState, currentMessage); + if (android.os.Build.VERSION.SDK_INT >= 29) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE); + } else { startForeground(NOTIFICATION_ID, notification); } + } + + private Notification buildNotification(CapsuleState state, String message) { + Intent openIntent = new Intent(this, MainActivity.class); + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, openIntent, + android.os.Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0); + PendingIntent cancelIntent = PendingIntent.getService(this, 1, + new Intent(this, FloatingTriggerService.class).setAction(ACTION_CANCEL), + android.os.Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0); + PendingIntent stopIntent = PendingIntent.getService(this, 2, + new Intent(this, FloatingTriggerService.class).setAction(ACTION_STOP), + android.os.Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0); + PendingIntent translateIntent = PendingIntent.getService(this, 3, + new Intent(this, FloatingTriggerService.class).setAction(ACTION_TRANSLATE), + android.os.Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0); + PendingIntent toggleIntent = PendingIntent.getService(this, 6, + new Intent(this, FloatingTriggerService.class).setAction(ACTION_TOGGLE), + android.os.Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0); + PendingIntent qaIntent = PendingIntent.getService(this, 4, + new Intent(this, FloatingTriggerService.class).setAction(ACTION_OPEN_QA), + android.os.Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0); + PendingIntent qaClipboardIntent = PendingIntent.getService(this, 5, + new Intent(this, FloatingTriggerService.class).setAction(ACTION_QA_CLIPBOARD), + android.os.Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0); + Notification.Builder builder = android.os.Build.VERSION.SDK_INT >= 26 + ? new Notification.Builder(this, CHANNEL_ID) + : new Notification.Builder(this); + builder.setSmallIcon(android.R.drawable.ic_btn_speak_now) + .setContentTitle(getString(R.string.floating_notification_title)) + .setContentText(notificationContent(state, message)) + .setContentIntent(pendingIntent) + .setOngoing(true); + if (state == CapsuleState.STARTING + || state == CapsuleState.RECORDING + || state == CapsuleState.TRANSCRIBING + || state == CapsuleState.POLISHING + || state == CapsuleState.TRANSLATING) { + builder.addAction(android.R.drawable.ic_menu_close_clear_cancel, getString(R.string.floating_action_cancel), cancelIntent) + .addAction(android.R.drawable.ic_menu_compass, getString(R.string.floating_action_qa), qaIntent) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, getString(R.string.floating_action_stop), stopIntent); + } else { + builder.addAction(android.R.drawable.ic_btn_speak_now, getString(R.string.floating_action_toggle), toggleIntent) + .addAction(android.R.drawable.ic_menu_edit, getString(R.string.floating_action_translate), translateIntent) + .addAction(android.R.drawable.ic_menu_search, getString(R.string.floating_action_qa_clipboard), qaClipboardIntent); + } + return builder.build(); + } + + private String notificationContent(CapsuleState state, String message) { + if (state == null || state == CapsuleState.IDLE) return getString(R.string.floating_notification_text); + if (message != null && !message.trim().isEmpty() + && (state == CapsuleState.DONE || state == CapsuleState.ERROR)) { + return message; + } + return state.label; + } + + private void updateForegroundNotification() { + NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (manager == null) return; + manager.notify(NOTIFICATION_ID, buildNotification(currentState, currentMessage)); + } + + private boolean onBubbleTouch(View view, MotionEvent event) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + downX = event.getRawX(); downY = event.getRawY(); + startX = params.x; startY = params.y; dragging = false; + return true; + case MotionEvent.ACTION_MOVE: + float dx = event.getRawX() - downX; + float dy = event.getRawY() - downY; + if (Math.abs(dx) > dp(8) || Math.abs(dy) > dp(8)) { + dragging = true; + params.x = startX + (int) dx; params.y = startY + (int) dy; + windowManager.updateViewLayout(bubble, params); + prefs.edit().putInt("x", params.x).putInt("y", params.y).apply(); + } + return true; + case MotionEvent.ACTION_UP: + if (!dragging) { + if (System.currentTimeMillis() - bubble.downAt >= LONG_PRESS_CANCEL_MS) { + coordinator.cancel(); + } else { coordinator.toggle(); } + } + return true; + default: return true; + } + } + + private void setState(CapsuleState state, String message) { + int generation = ++stateGeneration; + currentState = state; + currentMessage = message; + if (bubble != null) bubble.setState(state, message); + updateForegroundNotification(); + if (state == CapsuleState.DONE || state == CapsuleState.ERROR || state == CapsuleState.CANCELLED) { + main.postDelayed(() -> { if (generation == stateGeneration) setState(CapsuleState.IDLE, null); }, IDLE_DELAY_MS); + } + } + + @Override public void onCapsuleState(CapsuleState state, String message) { setState(state, message); } + @Override public void onRecordingLevel(float level) { if (bubble != null) bubble.setLevel(level); } + @Override public void onToast(String message) { toast(message); } + + private void toast(String message) { + Toast.makeText(this, message == null ? "OpenLess 操作失败。" : message, Toast.LENGTH_SHORT).show(); + } + + private void openQaWithClipboardContext() { + ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); + if (clipboard == null || !clipboard.hasPrimaryClip()) { toast("剪贴板为空。"); return; } + ClipData clip = clipboard.getPrimaryClip(); + if (clip == null || clip.getItemCount() == 0) { toast("剪贴板为空。"); return; } + CharSequence text = clip.getItemAt(0).coerceToText(this); + String context = text == null ? "" : text.toString().trim(); + Intent qaIntent = new Intent(this, QaPanelActivity.class); + qaIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + if (!context.isEmpty()) qaIntent.putExtra(QaPanelActivity.EXTRA_CONTEXT, context); + startActivity(qaIntent); + } + + private int dp(int value) { return (int) (value * getResources().getDisplayMetrics().density + 0.5f); } + + // ─── Mic bubble view (visual refined) ──────────────────────────── + + private static class MicBubbleView extends View { + private static final int BG_READY = Color.rgb(37, 99, 235); + private static final int BG_RECORDING = Color.rgb(220, 38, 38); + private static final int BG_PROCESSING = Color.rgb(217, 119, 6); + private static final int BG_DONE = Color.rgb(22, 163, 74); + + private final Paint circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint micPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint statusPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint dotPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final RectF micRect = new RectF(); + private float level; + private int bgColor = BG_READY; + private String statusText; + private boolean showDot = true; + private boolean dotOn = true; + long downAt; + + MicBubbleView(android.content.Context context) { + super(context); + circlePaint.setStyle(Paint.Style.FILL); + micPaint.setStyle(Paint.Style.STROKE); + micPaint.setStrokeCap(Paint.Cap.ROUND); + micPaint.setStrokeJoin(Paint.Join.ROUND); + statusPaint.setTextAlign(Paint.Align.CENTER); + statusPaint.setAntiAlias(true); + dotPaint.setStyle(Paint.Style.FILL); + } + + void setState(CapsuleState state, String message) { + switch (state.label) { + case "就绪": bgColor = BG_READY; statusText = null; showDot = true; break; + case "启动中": case "听写中": bgColor = BG_RECORDING; statusText = null; showDot = true; break; + case "转写中": case "润色中": case "翻译中": bgColor = BG_PROCESSING; statusText = ".."; showDot = false; break; + case "完成": case "已复制": bgColor = BG_DONE; statusText = message; showDot = false; break; + case "错误": bgColor = BG_RECORDING; statusText = "!"; showDot = false; break; + case "已取消": bgColor = Color.rgb(160, 160, 163); statusText = null; showDot = true; break; + default: bgColor = BG_READY; statusText = null; showDot = true; break; + } + dotOn = true; + invalidate(); + } + + void setLevel(float nextLevel) { level = Math.max(0f, Math.min(1f, nextLevel)); invalidate(); } + + @Override + protected void onDraw(Canvas canvas) { + float cx = getWidth() / 2f; + float cy = getHeight() / 2f; + float r = Math.min(cx, cy) - dp(2); + + // Shadow hint: subtle darker ring + Paint shadowPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + shadowPaint.setStyle(Paint.Style.FILL); + shadowPaint.setColor(Color.argb(18, 0, 0, 0)); + canvas.drawCircle(cx + dp(1), cy + dp(1), r, shadowPaint); + + circlePaint.setColor(bgColor); + canvas.drawCircle(cx, cy, r, circlePaint); + + if (showDot && dotOn) { + float dotR = dp(4); + dotPaint.setColor(Color.WHITE); + canvas.drawCircle(cx, cy - dp(6), dotR, dotPaint); + } else if (statusText != null) { + statusPaint.setColor(Color.WHITE); + statusPaint.setTextSize(dp(9)); + canvas.drawText(statusText, cx, cy + dp(3), statusPaint); + } else { + drawMicIcon(canvas, cx, cy, r); + } + } + + private void drawMicIcon(Canvas canvas, float cx, float cy, float r) { + float micSize = r * 0.42f; + float strokeW = 2.2f * getResources().getDisplayMetrics().density; + micPaint.setColor(Color.WHITE); + micPaint.setStrokeWidth(strokeW); + + float left = cx - micSize * 0.35f; + float top = cy - micSize * 0.7f; + float right = cx + micSize * 0.35f; + float bottom = cy + micSize * 0.25f; + micRect.set(left, top, right, bottom); + canvas.drawRoundRect(micRect, dp(3), dp(3), micPaint); + canvas.drawLine(cx, top + micSize * 0.15f, cx, cy + micSize * 0.15f, micPaint); + + float armY = cy + micSize * 0.25f; + canvas.drawLine(cx - micSize * 0.3f, armY, cx + micSize * 0.3f, armY, micPaint); + + float arcY = cy + micSize * 0.3f; + canvas.drawArc(new RectF(cx - micSize * 0.25f, arcY - micSize * 0.1f, + cx + micSize * 0.25f, arcY + micSize * 0.3f), 0, 180, false, micPaint); + + if (level > 0.05f) { + float waveHeight = level * micSize * 0.45f; + float waveAlpha = 0.3f + level * 0.4f; + micPaint.setAlpha((int) (255 * waveAlpha)); + canvas.drawArc(new RectF(cx - micSize * 0.55f - waveHeight * 0.3f, + cy - waveHeight * 0.4f, cx - micSize * 0.15f, cy + waveHeight * 0.4f), + -90, 180, false, micPaint); + canvas.drawArc(new RectF(cx + micSize * 0.15f, cy - waveHeight * 0.4f, + cx + micSize * 0.55f + waveHeight * 0.3f, cy + waveHeight * 0.4f), + 90, 180, false, micPaint); + micPaint.setAlpha(255); + } + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (event.getAction() == MotionEvent.ACTION_DOWN) downAt = System.currentTimeMillis(); + return super.onTouchEvent(event); + } + + private int dp(int value) { return (int) (value * getResources().getDisplayMetrics().density + 0.5f); } + } +} diff --git a/openless-android/src/com/openless/android/HistoryDetailActivity.java b/openless-android/src/com/openless/android/HistoryDetailActivity.java new file mode 100644 index 00000000..e74c93ef --- /dev/null +++ b/openless-android/src/com/openless/android/HistoryDetailActivity.java @@ -0,0 +1,323 @@ +package com.openless.android; + +import android.app.Activity; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.RoundRectShape; +import android.os.Bundle; +import android.view.Gravity; +import android.view.View; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +public final class HistoryDetailActivity extends Activity { + static final String EXTRA_ITEM_ID = "openless.extra.HISTORY_ID"; + static final String EXTRA_CREATED_AT = "openless.extra.HISTORY_CREATED_AT"; + static final String EXTRA_DURATION = "openless.extra.HISTORY_DURATION"; + static final String EXTRA_MODE = "openless.extra.HISTORY_MODE"; + static final String EXTRA_INSERT_STATUS = "openless.extra.HISTORY_INSERT_STATUS"; + static final String EXTRA_APP_NAME = "openless.extra.HISTORY_APP_NAME"; + static final String EXTRA_DICT_HITS = "openless.extra.HISTORY_DICT_HITS"; + static final String EXTRA_ERROR = "openless.extra.HISTORY_ERROR"; + static final String EXTRA_RAW = "openless.extra.HISTORY_RAW"; + static final String EXTRA_TEXT = "openless.extra.HISTORY_TEXT"; + + private static final int OL_CANVAS = Color.rgb(247, 247, 248); + private static final int OL_SURFACE = Color.rgb(255, 255, 255); + private static final int OL_INK = Color.rgb(10, 10, 11); + private static final int OL_INK_2 = Color.rgb(42, 42, 45); + private static final int OL_INK_3 = Color.rgb(160, 160, 163); + private static final int OL_INK_4 = Color.rgb(108, 108, 112); + private static final int OL_BLUE = Color.rgb(37, 99, 235); + private static final int OL_LINE = Color.argb(20, 0, 0, 0); + private static final int OL_LINE_STRONG = Color.argb(36, 0, 0, 0); + private static final int OL_OK = Color.rgb(22, 163, 74); + private static final int OL_WARN = Color.rgb(217, 119, 6); + private static final int OL_ERR = Color.rgb(220, 38, 38); + + private HistoryStore historyStore; + private TextView statusView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + historyStore = new HistoryStore(this); + setContentView(buildContent()); + } + + private View buildContent() { + ScrollView scroll = new ScrollView(this); + scroll.setFillViewport(true); + scroll.setBackgroundColor(OL_CANVAS); + + LinearLayout root = column(); + root.setPadding(dp(16), dp(16), dp(16), dp(24)); + scroll.addView(root); + + header(root); + detailSection(root); + return scroll; + } + + private void header(LinearLayout root) { + LinearLayout top = row(); + top.setGravity(Gravity.CENTER_VERTICAL); + top.setPadding(0, dp(8), 0, dp(8)); + + Button back = ghostButton("返回", OL_INK_2); + back.setOnClickListener(v -> finish()); + top.addView(back); + top.addView(spacer(dp(8))); + + LinearLayout titleCol = column(); + TextView title = text("历史详情", 24, Typeface.BOLD); + titleCol.addView(title); + TextView subtitle = text( + getStringExtra(EXTRA_CREATED_AT) + " · " + + formatDuration(getIntent().getLongExtra(EXTRA_DURATION, 0)), + 12, + Typeface.NORMAL); + subtitle.setTextColor(OL_INK_3); + titleCol.addView(subtitle); + top.addView(titleCol, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1)); + + Button copy = ghostButton("复制结果", OL_BLUE); + copy.setOnClickListener(v -> copyText(primaryBody())); + top.addView(copy); + + root.addView(top); + root.addView(divider()); + + statusView = text("就绪", 11, Typeface.BOLD); + statusView.setTextColor(OL_BLUE); + statusView.setPadding(0, dp(8), 0, 0); + root.addView(statusView); + } + + private void detailSection(LinearLayout root) { + card(root, card -> { + card.addView(detailBlock("模式", fallback(getStringExtra(EXTRA_MODE), "轻润色"), false)); + card.addView(spacer(dp(8))); + card.addView(detailBlock("插入状态", fallback(getStringExtra(EXTRA_INSERT_STATUS), "未记录"), false)); + + String appName = getStringExtra(EXTRA_APP_NAME); + if (!appName.isEmpty()) { + card.addView(spacer(dp(8))); + card.addView(detailBlock("目标应用", appName, false)); + } + + String dictHits = getStringExtra(EXTRA_DICT_HITS); + if (!dictHits.isEmpty()) { + card.addView(spacer(dp(8))); + card.addView(detailBlock("热词命中", dictHits, false)); + } + + String error = getStringExtra(EXTRA_ERROR); + if (!error.isEmpty()) { + card.addView(spacer(dp(8))); + card.addView(detailBlock("错误", error, false)); + } + + card.addView(spacer(dp(12))); + card.addView(detailBlock("原文", fallback(getStringExtra(EXTRA_RAW), "(空)"), true)); + card.addView(spacer(dp(10))); + card.addView(detailBlock("处理结果", fallback(getStringExtra(EXTRA_TEXT), "(空)"), true)); + + card.addView(spacer(dp(10))); + LinearLayout actions = row(); + Button qa = ghostButton("打开问答", OL_BLUE); + qa.setOnClickListener(v -> { + android.content.Intent intent = new android.content.Intent(this, QaPanelActivity.class); + intent.putExtra(QaPanelActivity.EXTRA_CONTEXT, primaryBody()); + startActivity(intent); + setStatus("已把历史内容送入问答", OL_BLUE); + }); + actions.addView(qa, new LinearLayout.LayoutParams(0, dp(40), 1)); + actions.addView(spacer(dp(8))); + Button copy = ghostButton("复制", OL_BLUE); + copy.setOnClickListener(v -> copyText(primaryBody())); + actions.addView(copy, new LinearLayout.LayoutParams(0, dp(40), 1)); + card.addView(actions); + + card.addView(spacer(dp(8))); + Button delete = ghostButton("删除记录", OL_ERR); + delete.setOnClickListener(v -> deleteItem()); + card.addView(delete, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + dp(40))); + }); + } + + private String primaryBody() { + String text = getStringExtra(EXTRA_TEXT); + return text.isEmpty() ? getStringExtra(EXTRA_RAW) : text; + } + + private void copyText(String text) { + android.content.ClipboardManager clipboard = + (android.content.ClipboardManager) getSystemService(CLIPBOARD_SERVICE); + if (clipboard != null) { + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("OpenLess History", text == null ? "" : text)); + setStatus("已复制", OL_OK); + } + } + + private void deleteItem() { + String id = getStringExtra(EXTRA_ITEM_ID); + if (id.isEmpty()) { + setStatus("当前记录缺少标识,无法删除", OL_ERR); + return; + } + historyStore.delete(id); + setStatus("记录已删除", OL_ERR); + finish(); + } + + private void setStatus(String message, int color) { + if (statusView == null) return; + statusView.setText(message); + statusView.setTextColor(color); + } + + private String getStringExtra(String key) { + String value = getIntent() == null ? null : getIntent().getStringExtra(key); + return value == null ? "" : value; + } + + private String fallback(String value, String fallback) { + return value == null || value.trim().isEmpty() ? fallback : value; + } + + private String formatDuration(long durationMs) { + if (durationMs <= 0) return "未记录"; + float seconds = durationMs / 1000f; + if (seconds < 60f) { + return String.format(java.util.Locale.US, "%.1f 秒", seconds); + } + return String.format(java.util.Locale.US, "%.1f 分钟", seconds / 60f); + } + + private View detailBlock(String title, String body, boolean selectable) { + LinearLayout box = column(); + box.setPadding(dp(12), dp(10), dp(12), dp(10)); + box.setBackgroundDrawable(roundedBg(selectable ? OL_CANVAS : OL_SURFACE, 8)); + + TextView label = text(title, 11, Typeface.BOLD); + label.setTextColor(OL_INK_4); + box.addView(label); + + TextView content = text(body, 13, Typeface.NORMAL); + content.setTextColor(OL_INK_2); + content.setPadding(0, dp(6), 0, 0); + content.setLineSpacing(0, 1.3f); + content.setTextIsSelectable(selectable); + box.addView(content); + return box; + } + + private void card(LinearLayout root, CardBuilder builder) { + LinearLayout card = column(); + card.setPadding(dp(14), dp(14), dp(14), dp(14)); + card.setBackgroundDrawable(cardBg()); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ); + params.setMargins(0, 0, 0, dp(10)); + card.setLayoutParams(params); + builder.build(card); + root.addView(card); + } + + private Drawable cardBg() { + float r = dp(12); + float[] radii = new float[]{r, r, r, r, r, r, r, r}; + ShapeDrawable bg = new ShapeDrawable(new RoundRectShape(radii, null, null)); + bg.getPaint().setColor(OL_SURFACE); + return bg; + } + + private Drawable roundedBg(int color, float radiusDip) { + float r = dp(radiusDip); + float[] radii = new float[]{r, r, r, r, r, r, r, r}; + ShapeDrawable bg = new ShapeDrawable(new RoundRectShape(radii, null, null)); + bg.getPaint().setColor(color); + return bg; + } + + private Drawable outlineBg(int borderColor) { + float r = dp(999); + float[] radii = new float[]{r, r, r, r, r, r, r, r}; + ShapeDrawable bg = new ShapeDrawable(new RoundRectShape(radii, null, null)); + bg.getPaint().setColor(Color.TRANSPARENT); + bg.getPaint().setStyle(Paint.Style.STROKE); + bg.getPaint().setStrokeWidth(dp(0.5f)); + bg.getPaint().setColor(borderColor); + return bg; + } + + private LinearLayout column() { + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.VERTICAL); + return layout; + } + + private LinearLayout row() { + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.HORIZONTAL); + return layout; + } + + private View divider() { + View v = new View(this); + v.setBackgroundColor(OL_LINE); + v.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1)); + return v; + } + + private View spacer(int px) { + View v = new View(this); + v.setLayoutParams(new LinearLayout.LayoutParams(Math.max(1, px), px)); + return v; + } + + private TextView text(String value, int sp, int style) { + TextView view = new TextView(this); + view.setText(value); + view.setTextColor(OL_INK); + view.setTextSize(sp); + view.setTypeface(Typeface.DEFAULT, style); + view.setLineSpacing(0, 1.2f); + return view; + } + + private Button ghostButton(String label, int color) { + Button button = new Button(this); + button.setText(label); + button.setAllCaps(false); + button.setTextColor(color); + button.setTextSize(11); + button.setBackgroundDrawable(outlineBg(OL_LINE_STRONG)); + button.setPadding(dp(8), dp(4), dp(8), dp(4)); + button.setMinHeight(0); + button.setMinimumHeight(0); + return button; + } + + private int dp(int value) { + return (int) (value * getResources().getDisplayMetrics().density + 0.5f); + } + + private float dp(float value) { + return value * getResources().getDisplayMetrics().density + 0.5f; + } + + private interface CardBuilder { + void build(LinearLayout card); + } +} diff --git a/openless-android/src/com/openless/android/HistoryStore.java b/openless-android/src/com/openless/android/HistoryStore.java new file mode 100644 index 00000000..caab6d79 --- /dev/null +++ b/openless-android/src/com/openless/android/HistoryStore.java @@ -0,0 +1,155 @@ +package com.openless.android; + +import android.content.Context; +import android.content.SharedPreferences; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import java.util.UUID; + +final class HistoryStore { + private static final String PREFS = "openless_history"; + private static final String KEY = "items"; + + private final SharedPreferences prefs; + + HistoryStore(Context context) { + prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + void add(String raw, String text, PolishMode mode, long durationMs) { + add(raw, text, mode, null, null, InsertStatus.COPIED_FALLBACK, null, durationMs, null); + } + + void add(String raw, String text, PolishMode mode, InsertStatus insertStatus, String errorCode, long durationMs, Integer dictionaryEntryCount) { + add(raw, text, mode, null, null, insertStatus, errorCode, durationMs, dictionaryEntryCount); + } + + void add(String raw, String text, PolishMode mode, String appBundleId, String appName, + InsertStatus insertStatus, String errorCode, long durationMs, Integer dictionaryEntryCount) { + try { + JSONArray items = new JSONArray(prefs.getString(KEY, "[]")); + JSONObject item = new JSONObject(); + item.put("id", UUID.randomUUID().toString()); + item.put("createdAt", isoNow()); + item.put("rawTranscript", raw == null ? "" : raw); + item.put("finalText", text == null ? "" : text); + item.put("mode", mode.id); + item.put("appBundleId", appBundleId == null ? JSONObject.NULL : appBundleId); + item.put("appName", appName == null ? JSONObject.NULL : appName); + item.put("insertStatus", insertStatus.id); + item.put("errorCode", errorCode == null ? JSONObject.NULL : errorCode); + item.put("durationMs", durationMs > 0 ? durationMs : JSONObject.NULL); + item.put("dictionaryEntryCount", dictionaryEntryCount == null ? JSONObject.NULL : dictionaryEntryCount); + JSONArray next = new JSONArray(); + next.put(item); + for (int i = 0; i < Math.min(items.length(), 199); i++) { + next.put(items.getJSONObject(i)); + } + prefs.edit().putString(KEY, next.toString()).apply(); + } catch (Exception ignored) { + } + } + + void addFailure(String raw, PolishMode mode, String errorCode, long durationMs, Integer dictionaryEntryCount) { + add(raw, "", mode, null, null, InsertStatus.FAILED, errorCode, durationMs, dictionaryEntryCount); + } + + List list() { + List out = new ArrayList<>(); + try { + JSONArray items = new JSONArray(prefs.getString(KEY, "[]")); + for (int i = 0; i < items.length(); i++) { + JSONObject json = items.getJSONObject(i); + out.add(new Item( + json.optString("id"), + json.optString("createdAt"), + firstNonEmpty(json.optString("rawTranscript"), json.optString("raw")), + firstNonEmpty(json.optString("finalText"), json.optString("text")), + PolishMode.fromId(json.optString("mode")), + nullIfEmpty(json.optString("appBundleId")), + nullIfEmpty(json.optString("appName")), + InsertStatus.fromId(json.optString("insertStatus")), + nullIfEmpty(json.optString("errorCode")), + json.optLong("durationMs"), + json.has("dictionaryEntryCount") && !json.isNull("dictionaryEntryCount") + ? json.optInt("dictionaryEntryCount") + : null)); + } + } catch (Exception ignored) { + } + return out; + } + + void delete(String id) { + if (id == null || id.isEmpty()) { + return; + } + try { + JSONArray items = new JSONArray(prefs.getString(KEY, "[]")); + JSONArray next = new JSONArray(); + for (int i = 0; i < items.length(); i++) { + JSONObject json = items.getJSONObject(i); + if (!id.equals(json.optString("id"))) { + next.put(json); + } + } + prefs.edit().putString(KEY, next.toString()).apply(); + } catch (Exception ignored) { + } + } + + void clear() { + prefs.edit().putString(KEY, "[]").apply(); + } + + private static String isoNow() { + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); + format.setTimeZone(TimeZone.getTimeZone("UTC")); + return format.format(new Date()); + } + + private static String firstNonEmpty(String a, String b) { + return a == null || a.isEmpty() ? b : a; + } + + private static String nullIfEmpty(String value) { + return value == null || value.isEmpty() || "null".equals(value) ? null : value; + } + + static final class Item { + final String id; + final String createdAt; + final String raw; + final String text; + final PolishMode mode; + final String appBundleId; + final String appName; + final InsertStatus insertStatus; + final String errorCode; + final long durationMs; + final Integer dictionaryEntryCount; + + Item(String id, String createdAt, String raw, String text, PolishMode mode, String appBundleId, + String appName, InsertStatus insertStatus, String errorCode, long durationMs, Integer dictionaryEntryCount) { + this.id = id; + this.createdAt = createdAt; + this.raw = raw; + this.text = text; + this.mode = mode; + this.appBundleId = appBundleId; + this.appName = appName; + this.insertStatus = insertStatus; + this.errorCode = errorCode; + this.durationMs = durationMs; + this.dictionaryEntryCount = dictionaryEntryCount; + } + } +} diff --git a/openless-android/src/com/openless/android/InsertStatus.java b/openless-android/src/com/openless/android/InsertStatus.java new file mode 100644 index 00000000..82e44f83 --- /dev/null +++ b/openless-android/src/com/openless/android/InsertStatus.java @@ -0,0 +1,25 @@ +package com.openless.android; + +final class InsertStatus { + static final InsertStatus INSERTED = new InsertStatus("inserted", "已插入"); + static final InsertStatus COPIED_FALLBACK = new InsertStatus("copiedFallback", "已复制"); + static final InsertStatus FAILED = new InsertStatus("failed", "失败"); + + final String id; + final String label; + + private InsertStatus(String id, String label) { + this.id = id; + this.label = label; + } + + static InsertStatus fromId(String id) { + if (INSERTED.id.equals(id)) { + return INSERTED; + } + if (COPIED_FALLBACK.id.equals(id)) { + return COPIED_FALLBACK; + } + return FAILED; + } +} diff --git a/openless-android/src/com/openless/android/MainActivity.java b/openless-android/src/com/openless/android/MainActivity.java new file mode 100644 index 00000000..7da1a477 --- /dev/null +++ b/openless-android/src/com/openless/android/MainActivity.java @@ -0,0 +1,1544 @@ +package com.openless.android; + +import android.Manifest; +import android.app.Activity; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.RoundRectShape; +import android.net.Uri; +import android.os.Bundle; +import android.text.InputType; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public final class MainActivity extends Activity { + private static final int REQ_AUDIO = 42; + private static final int REQ_NOTIFICATIONS = 43; + private static final int SECTION_DICTATION = 1; + private static final int SECTION_HISTORY = 2; + private static final int SECTION_TOOLS = 3; + + private static final int OL_CANVAS = Color.rgb(247, 247, 248); + private static final int OL_SURFACE = Color.rgb(255, 255, 255); + private static final int OL_INK = Color.rgb(10, 10, 11); + private static final int OL_INK_2 = Color.rgb(42, 42, 45); + private static final int OL_INK_3 = Color.rgb(160, 160, 163); + private static final int OL_INK_4 = Color.rgb(108, 108, 112); + private static final int OL_BLUE = Color.rgb(37, 99, 235); + private static final int OL_BLUE_SOFT = Color.rgb(239, 244, 255); + private static final int OL_LINE = Color.argb(20, 0, 0, 0); + private static final int OL_LINE_STRONG = Color.argb(36, 0, 0, 0); + private static final int OL_OK = Color.rgb(22, 163, 74); + private static final int OL_WARN = Color.rgb(217, 119, 6); + private static final int OL_ERR = Color.rgb(220, 38, 38); + + private final AudioRecorder recorder = new AudioRecorder(); + private final OpenLessClient client = new OpenLessClient(); + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private SettingsStore settingsStore; + private HistoryStore historyStore; + private DictionaryStore dictionaryStore; + private SettingsStore.Settings settings; + private TextInserter inserter; + private TextView status; + private TextView imeStatus; + private TextView rawText; + private TextView finalText; + private TextView historyCountView; + private TextView overviewAsrValue; + private TextView overviewLlmValue; + private TextView overviewModeValue; + private TextView overviewHistoryValue; + private TextView overviewTranslationValue; + private LinearLayout historyList; + private LinearLayout permissionStatusList; + private LinearLayout modeRow; + private Button micButton; + private Button floatingButton; + private Button translateButton; + private Button llmCheckButton; + private Button asrCheckButton; + private Button listModelsButton; + private VolcengineStreamingSession inlineVolcengineSession; + private boolean translateNext; + private static final String[] LLM_PROVIDER_IDS = new String[]{"ark", "deepseek", "siliconflow", "openai", "custom"}; + private LinearLayout dictationSectionView; + private LinearLayout historySectionView; + private LinearLayout toolsSectionView; + private final ArrayList