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(用于测试构建,不发版)
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..31473d60
--- /dev/null
+++ b/openless-android/src/com/openless/android/FloatingTriggerService.java
@@ -0,0 +1,390 @@
+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;
+ /** 0 = no active bubble press; set on ACTION_DOWN, cleared on ACTION_UP/CANCEL. */
+ private long bubbleGestureDownAtMs;
+ 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;
+ bubbleGestureDownAtMs = System.currentTimeMillis();
+ 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() - bubbleGestureDownAtMs >= LONG_PRESS_CANCEL_MS) {
+ coordinator.cancel();
+ } else { coordinator.toggle(); }
+ }
+ bubbleGestureDownAtMs = 0;
+ return true;
+ case MotionEvent.ACTION_CANCEL:
+ bubbleGestureDownAtMs = 0;
+ dragging = false;
+ 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;
+
+ 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);
+ }
+ }
+
+ 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