From 2dd6039ea16392995bc5e378de2a1191f5b15a6d Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 00:00:44 +0800 Subject: [PATCH 01/83] =?UTF-8?q?feat(android):=20=E6=90=AD=E5=BB=BA=20APK?= =?UTF-8?q?=20v1=20=E7=A7=BB=E5=8A=A8=E7=AB=AF=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E6=A1=86=E6=9E=B6=E5=8F=8A=E5=B9=B3=E5=8F=B0=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E9=97=A8=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入独立的 Tauri 移动端运行时,具备专有的调用处理器(invoke handler)、移动端存根(mobile stubs)以及针对特定目标的 Cargo 依赖。由此,Android 构建版本将不再引入仅限桌面的 crate(如 updater、autostart、single-instance、global-hotkey、enigo)。桌面端行为在 run_desktop() 逻辑下保持不变。 新增 PlatformCapabilities(包含 supportsAutoUpdate、supportsInAppDictation 等字段)并据此对前端进行门控处理:在概览页启用应用内听写;Android 入门引导不再因麦克风权限处于“未确定”状态而阻塞;同时在移动端隐藏桌面热键、本地 ASR 以及自动更新/Beta 渠道的相关 UI。 包含 Android 清单文件代码片段(v1 版本的 RECORD_AUDIO;标记为未来版本的 v2/v3 IME/叠加层权限)、Kotlin 基础架构、JNI 权限/IME/叠加层存根、移动端 tauri.android.conf.json 以及 docs/android-mobile-apk-overlay-plan.md 文档。 --- docs/android-mobile-apk-overlay-plan.md | 256 ++++++++++ openless-all/app/package.json | 3 + openless-all/app/src-tauri/Cargo.lock | 2 + openless-all/app/src-tauri/Cargo.toml | 24 +- .../AndroidManifest.v1.snippet.xml | 6 + .../AndroidManifest.v2.snippet.xml | 18 + .../AndroidManifest.v3.snippet.xml | 20 + .../android-scaffolding/OpenLessImeService.kt | 40 ++ .../OpenLessOverlayService.kt | 94 ++++ .../OverlayPermissionActivity.kt | 27 + .../src-tauri/android-scaffolding/README.md | 30 ++ .../app/src-tauri/capabilities/mobile.json | 17 + openless-all/app/src-tauri/src/android_ime.rs | 84 ++++ .../app/src-tauri/src/android_overlay.rs | 68 +++ openless-all/app/src-tauri/src/commands.rs | 236 +++++++-- .../src-tauri/src/coordinator/dictation.rs | 88 ++-- openless-all/app/src-tauri/src/insertion.rs | 62 ++- openless-all/app/src-tauri/src/lib.rs | 461 ++++++++++++------ .../app/src-tauri/src/mobile_runtime.rs | 47 ++ .../src/mobile_stubs/combo_hotkey.rs | 46 ++ .../app/src-tauri/src/mobile_stubs/hotkey.rs | 49 ++ .../src-tauri/src/mobile_stubs/qa_hotkey.rs | 33 ++ .../src-tauri/src/mobile_stubs/selection.rs | 11 + .../src/mobile_stubs/shortcut_binding.rs | 38 ++ .../src/mobile_stubs/unicode_keystroke.rs | 40 ++ openless-all/app/src-tauri/src/permissions.rs | 192 +++++++- openless-all/app/src-tauri/src/persistence.rs | 119 ++++- openless-all/app/src-tauri/src/types.rs | 113 +++++ .../app/src-tauri/tauri.android.conf.json | 19 + openless-all/app/src-tauri/tauri.conf.json | 3 + openless-all/app/src/App.tsx | 60 ++- .../app/src/components/AutoUpdate.tsx | 23 +- .../app/src/components/AutoUpdateGate.tsx | 13 +- .../app/src/components/FloatingShell.tsx | 19 +- .../app/src/components/Onboarding.tsx | 66 ++- .../app/src/components/WindowChrome.tsx | 7 +- openless-all/app/src/i18n/en.ts | 22 + openless-all/app/src/i18n/ja.ts | 22 + openless-all/app/src/i18n/ko.ts | 22 + openless-all/app/src/i18n/zh-CN.ts | 22 + openless-all/app/src/i18n/zh-TW.ts | 22 + openless-all/app/src/lib/ipc.ts | 153 +++++- openless-all/app/src/lib/platform.ts | 122 +++++ openless-all/app/src/lib/types.ts | 17 +- openless-all/app/src/pages/Overview.tsx | 204 +++++++- .../app/src/pages/settings/AboutSection.tsx | 12 +- .../src/pages/settings/BetaChannelSection.tsx | 10 +- .../src/pages/settings/LocalModelSection.tsx | 12 +- .../src/pages/settings/PermissionsSection.tsx | 47 +- .../pages/settings/RecordingInputSection.tsx | 25 +- .../src/pages/settings/ShortcutsSection.tsx | 12 + openless-all/app/src/pages/settings/tabs.tsx | 31 +- openless-all/app/vite.config.ts | 13 +- 53 files changed, 2828 insertions(+), 374 deletions(-) create mode 100644 docs/android-mobile-apk-overlay-plan.md create mode 100644 openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v1.snippet.xml create mode 100644 openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v2.snippet.xml create mode 100644 openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v3.snippet.xml create mode 100644 openless-all/app/src-tauri/android-scaffolding/OpenLessImeService.kt create mode 100644 openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt create mode 100644 openless-all/app/src-tauri/android-scaffolding/OverlayPermissionActivity.kt create mode 100644 openless-all/app/src-tauri/android-scaffolding/README.md create mode 100644 openless-all/app/src-tauri/capabilities/mobile.json create mode 100644 openless-all/app/src-tauri/src/android_ime.rs create mode 100644 openless-all/app/src-tauri/src/android_overlay.rs create mode 100644 openless-all/app/src-tauri/src/mobile_runtime.rs create mode 100644 openless-all/app/src-tauri/src/mobile_stubs/combo_hotkey.rs create mode 100644 openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs create mode 100644 openless-all/app/src-tauri/src/mobile_stubs/qa_hotkey.rs create mode 100644 openless-all/app/src-tauri/src/mobile_stubs/selection.rs create mode 100644 openless-all/app/src-tauri/src/mobile_stubs/shortcut_binding.rs create mode 100644 openless-all/app/src-tauri/src/mobile_stubs/unicode_keystroke.rs create mode 100644 openless-all/app/src-tauri/tauri.android.conf.json create mode 100644 openless-all/app/src/lib/platform.ts diff --git a/docs/android-mobile-apk-overlay-plan.md b/docs/android-mobile-apk-overlay-plan.md new file mode 100644 index 00000000..8d416264 --- /dev/null +++ b/docs/android-mobile-apk-overlay-plan.md @@ -0,0 +1,256 @@ +# OpenLess Android APK 与悬浮窗实施计划 + +> 状态:实施中 / 后端分层已落地 +> 日期:2026-06-07 +> 范围:Android APK v1(应用内录音)→ IME v2(跨 App 输入)→ 悬浮窗 v3;不改桌面语义 + +目标是把现有 OpenLess 桌面应用扩展为 Android APK,并在手机端提供可用的语音输入体验。当前项目是 React/Vite + Tauri v2 + Rust 后端,Tauri 官方支持 Android 构建;现仓库已增加 Android 分层与脚手架,桌面核心能力(全局热键、托盘、桌面浮窗、TSF IME 等)通过 `#[cfg(not(mobile))]` 收口。 + +核心策略:先产出可安装 APK,再做 Android 输入法服务,最后做跨 App 悬浮窗。桌面功能保持不受影响。 + +参考依据: +- Tauri Android 依赖:Android Studio、SDK/NDK、`ANDROID_HOME`、`NDK_HOME`、Android Rust targets。 +- Tauri Android 构建命令:`tauri android init`,`tauri android build --apk`。 +- Android 悬浮窗:Android 8.0+ 使用 `SYSTEM_ALERT_WINDOW` + `TYPE_APPLICATION_OVERLAY`。 + +--- + +## 1. 目标与非目标 + +### 目标 + +- **APK v1**:应用内主窗口、设置、历史、云端 ASR/LLM、基础录音、复制结果 +- **IME v2**:`OpenLessImeService` 作为跨 App 输入主路径 +- **悬浮窗 v3**:前台服务 + `TYPE_APPLICATION_OVERLAY`,仅作录音控制入口 +- **平台能力查询**:前端通过 `get_platform_capabilities` 隐藏桌面专属设置项 +- **桌面零破坏**:所有适配经 `#[cfg(not(mobile))]` / `#[cfg(mobile)]` 分层 + +### 非目标 + +- APK 首版不纳入本地 ASR(Foundry、Sherpa、Qwen 桌面假设) +- 首版不承诺直接写入其他 App(无 IME 时走复制兜底) +- Accessibility 跨 App 输入不作为默认路径 +- 悬浮窗不承担最终文本插入职责 +- 不改桌面现有功能语义 + +### 明确边界 + +- **不动** macOS / Windows / Linux 热键、托盘、胶囊、QA 浮窗逻辑 +- **Android 命令名与桌面一致**;不支持的命令返回明确 unavailable 状态 +- **Coordinator 听写主链路复用**;`end_session` 在 Android 增加 IME commit 分支 + +--- + +## 2. 架构定位 + +``` +openless-all/app/ + package.json # tauri:android:* scripts + vite.config.ts # TAURI_ENV_PLATFORM → 0.0.0.0 + HMR + src-tauri/ + tauri.conf.json # bundle.android.minSdkVersion ≥ 26 + tauri.android.conf.json # 单 main 窗口(无 capsule/qa/tray) + capabilities/ + default.json # 桌面 + mobile.json # Android 主窗口 + android-scaffolding/ # Kotlin 模板(init 后复制到 gen/android) + src/ + lib.rs # desktop/mobile run() 分层 + android_ime.rs # IME 状态 + commit(JNI 桩) + android_overlay.rs # 悬浮窗权限/状态(JNI 桩) + permissions.rs # Android 麦克风 runtime permission 分支 + persistence.rs # Android 凭据加密文件桩 + types.rs # PlatformCapabilities + commands.rs # get_platform_capabilities + 桌面命令 stub + coordinator/dictation.rs # end_session Android IME 分支 +``` + +平台分层示意: + +``` +┌─────────────────────────────────────────────────────────┐ +│ React UI(能力查询 → 隐藏桌面专属设置) │ +├─────────────────────────────────────────────────────────┤ +│ Tauri commands(同名 IPC;mobile 返回 unavailable stub) │ +├──────────────┬──────────────────────────────────────────┤ +│ desktop │ mobile (Android) │ +│ hotkey/tray │ in-app dictation + cloud ASR │ +│ capsule/qa │ android_ime (v2) / android_overlay(v3) │ +│ TSF/AX/粘贴 │ copy fallback (v1) │ +└──────────────┴──────────────────────────────────────────┘ +``` + +--- + +## 3. 模块设计 + +### 3.1 `PlatformCapabilities`(`types.rs`) + +```rust +pub struct PlatformCapabilities { + pub platform: String, + pub supports_ime_input: bool, + pub supports_overlay: bool, + pub supports_desktop_hotkey: bool, + pub supports_tray: bool, + pub supports_local_asr: bool, + pub supports_capsule_overlay: bool, +} +``` + +- Android:`supportsImeInput=true`(v2 起)、`supportsOverlay=true`(v3 起)、`supportsDesktopHotkey=false`、`supportsTray=false` +- 桌面:按 OS 填真实能力 + +### 3.2 `android_ime.rs` + +- `get_android_ime_status()` → 是否已启用 OpenLess 输入法 +- `commit_text(text)` → 通过 JNI 提交到 `InputConnection`(桩 → 日志 + 返回未连接) +- Kotlin:`OpenLessImeService` 继承 `InputMethodService` + +### 3.3 `android_overlay.rs` + +- `get_android_overlay_status()` → `SYSTEM_ALERT_WINDOW` 授权状态 +- `request_android_overlay_permission()` → 跳转 `OverlayPermissionActivity` +- Kotlin:`OpenLessOverlayService`(前台服务 + overlay window) + +### 3.4 `permissions.rs` / `persistence.rs` + +- 麦克风:Android runtime permission 分支(JNI 桩;未接线时 `NotDetermined`) +- 凭据:Android 加密 JSON 文件桩(`credentials.enc.json`),不沿用桌面 keyring + +### 3.5 `coordinator/dictation.rs` + +`end_session` 插入阶段: + +``` +#[cfg(target_os = "android")] + android_ime::commit_text → Inserted + 失败 → copy_fallback → CopiedFallback +#[cfg(not(mobile))] + 现有 Windows TSF / AX / paste 路径 +``` + +--- + +## 4. 原生层接口 + +| 组件 | 职责 | +|---|---| +| `MainActivity` | Tauri WebView + 权限状态桥接 | +| `OpenLessImeService` | 接收识别结果,`commitText` 到当前输入框 | +| `OpenLessOverlayService` | 悬浮窗开始/停止录音、显示状态 | +| `OverlayPermissionActivity` | 引导用户授权 `SYSTEM_ALERT_WINDOW` | + +Rust ↔ Kotlin 通信:Tauri mobile plugin / `jni`(脚手架阶段为桩,init 后接线)。 + +--- + +## 5. 实施里程碑 + +### M0 环境与文档(本期) + +- 扩展本计划文档(元数据、架构、文件表、风险) +- `package.json`:`tauri:android:init|dev|build` +- `vite.config.ts`:mobile dev host `0.0.0.0` + HMR +- `tauri.conf.json`:`bundle.android.minSdkVersion: 26` +- `tauri.android.conf.json` + `capabilities/mobile.json` + +### M1 Rust 分层 + APK 骨架(本期) + +- `lib.rs` desktop/mobile `run()` 分层 +- `Cargo.toml` gate 桌面专属依赖 +- `PlatformCapabilities` + 命令 stub +- `permissions.rs` / `persistence.rs` Android 分支 +- `cargo check` 桌面通过;Android target 尽力验证 + +### M2 IME v2 + +- 接线 `OpenLessImeService` JNI +- 设置页显示输入法启用状态 +- 跨 App 提交验收 + +### M3 悬浮窗 v3 + +- 接线 `OpenLessOverlayService` +- 授权引导 + 前台服务稳定性 + +--- + +## 6. 文件触达表 + +| 文件 | 变更 | +|---|---| +| `docs/android-mobile-apk-overlay-plan.md` | 扩展为完整实施规划(本文档) | +| `openless-all/app/package.json` | `tauri:android:*` scripts | +| `openless-all/app/vite.config.ts` | mobile dev server / HMR | +| `openless-all/app/src-tauri/tauri.conf.json` | `bundle.android` | +| `openless-all/app/src-tauri/tauri.android.conf.json` | 单 main 窗口 | +| `openless-all/app/src-tauri/capabilities/mobile.json` | Android 权限集 | +| `openless-all/app/src-tauri/Cargo.toml` | gate desktop deps | +| `openless-all/app/src-tauri/src/lib.rs` | mobile/desktop 分层 | +| `openless-all/app/src-tauri/src/types.rs` | `PlatformCapabilities` 等 | +| `openless-all/app/src-tauri/src/commands.rs` | 能力查询 + mobile stub | +| `openless-all/app/src-tauri/src/permissions.rs` | Android 麦克风 | +| `openless-all/app/src-tauri/src/persistence.rs` | Android 凭据 | +| `openless-all/app/src-tauri/src/android_ime.rs` | 新增 | +| `openless-all/app/src-tauri/src/android_overlay.rs` | 新增 | +| `openless-all/app/src-tauri/src/coordinator/dictation.rs` | `end_session` Android 分支 | +| `openless-all/app/src-tauri/android-scaffolding/*.kt` | Kotlin 模板 | + +--- + +## 7. 风险与对策 + +| 风险 | 对策 | +|---|---| +| 本机无 Android SDK / NDK | 文档记录手动脚手架;`android-scaffolding/` 供 init 后复制 | +| `global-hotkey` / `enigo` / `arboard` 无法编 Android | `Cargo.toml` `cfg(not(mobile))` gate | +| `keyring` 在 Android 不可用 | 加密文件桩 + 后续 Keystore 接线 | +| 桌面构建被 mobile 分层破坏 | 桌面 `cargo check` 为 CI 门禁;mobile 代码 `#[cfg(mobile)]` 隔离 | +| JNI 未接线时 IME/overlay 假成功 | 状态查询返回 `enabled=false`;commit 走 copy fallback | +| 多窗口配置污染移动端 | `tauri.android.conf.json` 仅声明 `main` | +| 本地 ASR 拖慢 Android 首版 | 明确排除;`supportsLocalAsr=false` | +| `SYSTEM_ALERT_WINDOW` 用户拒绝 | 设置页显示状态;悬浮窗功能降级为应用内入口 | + +--- + +## 8. 验收标准 + +### 构建验证 + +```bash +cd openless-all/app +npm run build +cargo check --manifest-path src-tauri/Cargo.toml +# 需 Android SDK: +npm run tauri:android:build +``` + +### APK v1 + +- 首次启动进入主界面 +- 麦克风授权流程可触发 +- 应用内录音 → 云端转写 → 历史 + 复制 +- 桌面专属命令不导致前端白屏(返回 unavailable) +- 桌面 `cargo check` 仍通过 + +### IME v2 / 悬浮窗 v3 + +见原 Summary 中 Test Plan 章节(启用输入法后跨 App 提交;悬浮窗授权与前台服务稳定性)。 + +--- + +## Compatibility fixes(2026-06-07) + +- **`app_invoke_handler_mobile`**:仅保留 dictation / settings / credentials / history / cloud ASR / platform capabilities / Android IME·overlay / permissions / marketplace / style packs / mic devices;已移除 `get_hotkey_*`、`set_shortcut_recording_active` 及全部 desktop-only 命令(热键 setter、updater、local ASR、coding agent、tray 等)。前端 `ipc.ts` 在 `supportsDesktopHotkey === false` 时本地返回 stub,不再 invoke 这些命令。 +- **`mobile_stubs`**:`unicode_keystroke` 补齐 `typed_chars()` / `Partial`(与 coordinator 流式插入一致);`shortcut_binding::binding_from_legacy_trigger` 与桌面实现对齐。 +- **`Cargo.toml`**:`enigo` / `global-hotkey` / updater / single-instance / autostart 仅在 `cfg(not(mobile))`;Android 侧 `jni` + `ndk-context` 已声明。 + +--- + +## 9. 相关参考 + +- Tauri Android:https://v2.tauri.app/develop/mobile/ +- 桌面 Windows ASR 规划风格:`docs/windows-sherpa-onnx-asr-plan.md` +- 主听写链路:`openless-all/app/src-tauri/src/coordinator/dictation.rs` +- Windows IME unavailable 模式:`openless-all/app/src-tauri/src/windows_ime_profile.rs` diff --git a/openless-all/app/package.json b/openless-all/app/package.json index a2b584c8..948e60bf 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -8,6 +8,9 @@ "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", + "tauri:android:init": "tauri android init", + "tauri:android:dev": "tauri android dev", + "tauri:android:build": "tauri android build --apk", "check:aura-skin": "node scripts/aura-skin-contract.test.mjs", "check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs", "check:hotkey-injection": "node scripts/check-hotkey-injection.mjs" diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 1fbadbdf..a72a32f3 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -3718,9 +3718,11 @@ dependencies = [ "foundry-local-sdk", "futures-util", "global-hotkey", + "jni 0.21.1", "keyring", "libc", "log", + "ndk-context", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 3030f5d3..6471522d 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -18,11 +18,8 @@ cc = "1.1" [dependencies] # 锁 ~2.11 因为 npm @tauri-apps/api 与 plugin-dialog 都已升 2.11; # tauri build 跨 minor 一致性检查会拒绝 npm 2.11 + Rust 2.10 的组合。 -tauri = { version = "~2.11", features = ["macos-private-api", "tray-icon"] } +tauri = { version = "~2.11", features = [] } tauri-plugin-shell = "2" -tauri-plugin-updater = "2" -tauri-plugin-single-instance = "2" -tauri-plugin-autostart = "2" tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -49,10 +46,16 @@ bytes = "1" url = "2" raw-window-handle = "0.6" ferrous-opencc = "0.4" +# Audio capture — shared across desktop and mobile. +cpal = "0.15" -# Hotkey + audio + insertion +# Desktop-only plugins, hotkey/insertion helpers, and tray-icon (not built for mobile). +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri = { version = "~2.11", features = ["macos-private-api", "tray-icon"] } +tauri-plugin-updater = "2" +tauri-plugin-single-instance = "2" +tauri-plugin-autostart = "2" global-hotkey = "0.6" -cpal = "0.15" enigo = "0.2" arboard = { version = "3", features = ["wayland-data-control"] } @@ -68,11 +71,18 @@ features = ["windows-native"] [target.'cfg(target_os = "linux")'.dependencies] dbus = "0.9" -[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies.keyring] +[target.'cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))'.dependencies.keyring] version = "3.6.3" default-features = false features = ["linux-native-sync-persistent", "crypto-rust"] +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21" +ndk-context = "0.1" + +[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] +arboard = { version = "3", default-features = false } + [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.5" core-foundation = "0.10" diff --git a/openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v1.snippet.xml b/openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v1.snippet.xml new file mode 100644 index 00000000..251f0579 --- /dev/null +++ b/openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v1.snippet.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v2.snippet.xml b/openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v2.snippet.xml new file mode 100644 index 00000000..b4138ef8 --- /dev/null +++ b/openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v2.snippet.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v3.snippet.xml b/openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v3.snippet.xml new file mode 100644 index 00000000..138e2a6a --- /dev/null +++ b/openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v3.snippet.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessImeService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessImeService.kt new file mode 100644 index 00000000..c9ccbdae --- /dev/null +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessImeService.kt @@ -0,0 +1,40 @@ +package com.openless.app + +import android.inputmethodservice.InputMethodService +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection + +/** + * OpenLess 输入法服务(v2)。 + * Rust 侧通过 JNI / Tauri plugin 调用 [commitText] 把识别结果提交到当前输入框。 + */ +class OpenLessImeService : InputMethodService() { + + companion object { + @Volatile + var instance: OpenLessImeService? = null + private set + + fun commitText(text: String): Boolean { + val service = instance ?: return false + val ic = service.currentInputConnection ?: return false + return ic.commitText(text, 1) + } + } + + override fun onCreate() { + super.onCreate() + instance = this + } + + override fun onDestroy() { + if (instance === this) { + instance = null + } + super.onDestroy() + } + + override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) { + super.onStartInput(attribute, restarting) + } +} diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt new file mode 100644 index 00000000..a39d7831 --- /dev/null +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt @@ -0,0 +1,94 @@ +package com.openless.app + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.graphics.PixelFormat +import android.os.Build +import android.os.IBinder +import android.view.Gravity +import android.view.WindowManager +import android.widget.FrameLayout + +/** + * 前台服务 + TYPE_APPLICATION_OVERLAY 悬浮窗(v3)。 + * 仅负责开始/停止录音与状态展示;文本插入由 [OpenLessImeService] 负责。 + */ +class OpenLessOverlayService : Service() { + + private var overlayView: FrameLayout? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + startForeground(NOTIFICATION_ID, buildNotification()) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_SHOW -> showOverlay() + ACTION_HIDE -> hideOverlay() + } + return START_STICKY + } + + override fun onDestroy() { + hideOverlay() + super.onDestroy() + } + + private fun showOverlay() { + if (overlayView != null) return + val wm = getSystemService(WINDOW_SERVICE) as WindowManager + val params = WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + } else { + @Suppress("DEPRECATION") + WindowManager.LayoutParams.TYPE_PHONE + }, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSLUCENT, + ).apply { + gravity = Gravity.TOP or Gravity.START + x = 24 + y = 120 + } + val view = FrameLayout(this) + wm.addView(view, params) + overlayView = view + } + + private fun hideOverlay() { + val view = overlayView ?: return + val wm = getSystemService(WINDOW_SERVICE) as WindowManager + wm.removeView(view) + overlayView = null + } + + private fun buildNotification(): Notification { + val channelId = "openless_overlay" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val nm = getSystemService(NotificationManager::class.java) + nm.createNotificationChannel( + NotificationChannel(channelId, "OpenLess Overlay", NotificationManager.IMPORTANCE_LOW), + ) + } + return Notification.Builder(this, channelId) + .setContentTitle("OpenLess") + .setContentText("悬浮窗运行中") + .setSmallIcon(android.R.drawable.ic_btn_speak_now) + .build() + } + + companion object { + const val ACTION_SHOW = "com.openless.app.overlay.SHOW" + const val ACTION_HIDE = "com.openless.app.overlay.HIDE" + private const val NOTIFICATION_ID = 42001 + } +} diff --git a/openless-all/app/src-tauri/android-scaffolding/OverlayPermissionActivity.kt b/openless-all/app/src-tauri/android-scaffolding/OverlayPermissionActivity.kt new file mode 100644 index 00000000..d3a05f8f --- /dev/null +++ b/openless-all/app/src-tauri/android-scaffolding/OverlayPermissionActivity.kt @@ -0,0 +1,27 @@ +package com.openless.app + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.Settings + +/** + * 引导用户授权 SYSTEM_ALERT_WINDOW。 + * Rust 命令 request_android_overlay_permission 通过 Intent 启动本 Activity。 + */ +class OverlayPermissionActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) { + val intent = Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:$packageName"), + ) + startActivity(intent) + } + finish() + } +} diff --git a/openless-all/app/src-tauri/android-scaffolding/README.md b/openless-all/app/src-tauri/android-scaffolding/README.md new file mode 100644 index 00000000..5c74bb8b --- /dev/null +++ b/openless-all/app/src-tauri/android-scaffolding/README.md @@ -0,0 +1,30 @@ +# Android Kotlin scaffolding + +Copy these files into `src-tauri/gen/android/` after running: + +```bash +cd openless-all/app +npm run tauri:android:init +``` + +## Copy / merge paths + +| Source (this folder) | Destination (after init) | +| --- | --- | +| `OpenLessImeService.kt` | `gen/android/app/src/main/java/com/openless/app/OpenLessImeService.kt` | +| `OpenLessOverlayService.kt` | `gen/android/app/src/main/java/com/openless/app/OpenLessOverlayService.kt` | +| `OverlayPermissionActivity.kt` | `gen/android/app/src/main/java/com/openless/app/OverlayPermissionActivity.kt` | +| `AndroidManifest.v1.snippet.xml` | merge into `gen/android/app/src/main/AndroidManifest.xml` | +| `AndroidManifest.v2.snippet.xml` | **future / not complete** — IME v2 only | +| `AndroidManifest.v3.snippet.xml` | **future / not complete** — overlay v3 only | + +Tauri `android init` generates the base manifest under `gen/android/app/src/main/AndroidManifest.xml`. +Merge the v1 snippet permissions into that file before building APK v1. + +## Manifest snippets + +- **v1** (`AndroidManifest.v1.snippet.xml`): `RECORD_AUDIO` for in-app dictation — required for APK v1. +- **v2** (`AndroidManifest.v2.snippet.xml`): IME service declaration — **not complete / future**. +- **v3** (`AndroidManifest.v3.snippet.xml`): overlay + foreground service — **not complete / future**. + +Do not treat v2 or v3 snippets as shipped; they document planned permissions and service entries only. diff --git a/openless-all/app/src-tauri/capabilities/mobile.json b/openless-all/app/src-tauri/capabilities/mobile.json new file mode 100644 index 00000000..a9350f0b --- /dev/null +++ b/openless-all/app/src-tauri/capabilities/mobile.json @@ -0,0 +1,17 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "mobile", + "description": "Capabilities for OpenLess Android main window", + "windows": ["main"], + "permissions": [ + "core:default", + "core:window:default", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-focus", + "core:webview:default", + "core:event:default", + "shell:allow-open", + "dialog:default" + ] +} diff --git a/openless-all/app/src-tauri/src/android_ime.rs b/openless-all/app/src-tauri/src/android_ime.rs new file mode 100644 index 00000000..bdb02be8 --- /dev/null +++ b/openless-all/app/src-tauri/src/android_ime.rs @@ -0,0 +1,84 @@ +//! Android IME integration — status queries and text commit path. +//! +//! JNI wiring lands after `tauri android init`; until then these functions return +//! honest stub states so the frontend can gate cross-app input UI. + +use serde::Serialize; + +use crate::types::{AndroidImeState, AndroidImeStatus}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AndroidImeCommitResult { + pub committed: bool, + pub message: String, +} + +pub fn get_android_ime_status() -> AndroidImeStatus { + #[cfg(target_os = "android")] + { + android_impl::get_android_ime_status() + } + + #[cfg(not(target_os = "android"))] + { + AndroidImeStatus { + state: AndroidImeState::NotAndroid, + enabled: false, + selected: false, + message: "Android IME backend is only available on Android".to_string(), + } + } +} + +/// Commit recognized text into the active input connection via OpenLessImeService. +pub fn commit_text(text: &str) -> AndroidImeCommitResult { + #[cfg(target_os = "android")] + { + return android_impl::commit_text(text); + } + + #[cfg(not(target_os = "android"))] + { + let _ = text; + AndroidImeCommitResult { + committed: false, + message: "Android IME commit is only available on Android".to_string(), + } + } +} + +#[cfg(target_os = "android")] +mod android_impl { + use super::{AndroidImeCommitResult, AndroidImeStatus}; + use crate::types::{AndroidImeState, AndroidImeStatus as Status}; + + pub fn get_android_ime_status() -> AndroidImeStatus { + // TODO: JNI → check InputMethodManager if OpenLessImeService is enabled/selected. + Status { + state: AndroidImeState::NotEnabled, + enabled: false, + selected: false, + message: "OpenLess 输入法尚未启用(Kotlin/JNI 接线后更新状态)".to_string(), + } + } + + pub fn commit_text(text: &str) -> AndroidImeCommitResult { + if text.trim().is_empty() { + return AndroidImeCommitResult { + committed: false, + message: "empty text".to_string(), + }; + } + // TODO: JNI → OpenLessImeService.commitText(text) + log::info!( + "[android-ime] commit stub (chars={}): JNI not wired yet", + text.chars().count() + ); + AndroidImeCommitResult { + committed: false, + message: "IME service not connected — enable OpenLess keyboard in system settings" + .to_string(), + } + } +} diff --git a/openless-all/app/src-tauri/src/android_overlay.rs b/openless-all/app/src-tauri/src/android_overlay.rs new file mode 100644 index 00000000..e694b64a --- /dev/null +++ b/openless-all/app/src-tauri/src/android_overlay.rs @@ -0,0 +1,68 @@ +//! Android overlay window permission and foreground service stubs. + +use serde::Serialize; + +use crate::types::{AndroidOverlayPermissionState, AndroidOverlayStatus}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AndroidOverlayPermissionResult { + pub launched: bool, + pub message: String, +} + +pub fn get_android_overlay_status() -> AndroidOverlayStatus { + #[cfg(target_os = "android")] + { + android_impl::get_android_overlay_status() + } + + #[cfg(not(target_os = "android"))] + { + AndroidOverlayStatus { + permission: AndroidOverlayPermissionState::NotAndroid, + overlay_visible: false, + message: "Android overlay is only available on Android".to_string(), + } + } +} + +pub fn request_android_overlay_permission() -> AndroidOverlayPermissionResult { + #[cfg(target_os = "android")] + { + android_impl::request_android_overlay_permission() + } + + #[cfg(not(target_os = "android"))] + { + AndroidOverlayPermissionResult { + launched: false, + message: "Android overlay permission is only available on Android".to_string(), + } + } +} + +#[cfg(target_os = "android")] +mod android_impl { + use super::{AndroidOverlayPermissionResult, AndroidOverlayStatus}; + use crate::types::{AndroidOverlayPermissionState, AndroidOverlayStatus as Status}; + + pub fn get_android_overlay_status() -> AndroidOverlayStatus { + // TODO: JNI → Settings.canDrawOverlays(context) + Status { + permission: AndroidOverlayPermissionState::NotGranted, + overlay_visible: false, + message: "悬浮窗权限未授予(Kotlin/JNI 接线后更新状态)".to_string(), + } + } + + pub fn request_android_overlay_permission() -> AndroidOverlayPermissionResult { + // TODO: JNI → start OverlayPermissionActivity + log::info!("[android-overlay] permission request stub: JNI not wired yet"); + AndroidOverlayPermissionResult { + launched: false, + message: "Overlay permission activity not wired — copy android-scaffolding into gen/android" + .to_string(), + } + } +} diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 7a2e12b9..507119f6 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -8,18 +8,22 @@ use serde::Serialize; use serde_json::Value; use tauri::{AppHandle, Emitter, Manager, State, Window}; +#[cfg(not(mobile))] use crate::asr::local::foundry::{ model_alias_is_known, FoundryCatalogModel, FoundryPrepareProgressPayload, FoundryRuntimeStatus, DEFAULT_MODEL_ALIAS, PROVIDER_ID as FOUNDRY_LOCAL_PROVIDER_ID, }; +#[cfg(not(mobile))] use crate::asr::local::sherpa::{ model_alias_is_known as sherpa_model_alias_is_known, SherpaCatalogModel, SherpaPrepareProgressPayload, SherpaRuntimeStatus, DEFAULT_MODEL_ALIAS as SHERPA_DEFAULT_MODEL_ALIAS, }; +#[cfg(not(mobile))] use crate::asr::local::sherpa_download::{ fetch_remote_info as fetch_sherpa_remote_info, SherpaDownloadManager, SherpaRemoteInfo, }; +#[cfg(not(mobile))] use crate::asr::local::{FoundryLocalRuntime, SherpaOnnxRuntime}; use crate::coordinator::Coordinator; use crate::net; @@ -36,22 +40,30 @@ use crate::polish::{ use crate::recorder::{AudioConsumer, Recorder}; use crate::types::{ builtin_style_pack_id, default_active_style_pack_id, ChineseScriptPreference, ComboBinding, - CorrectionRule, CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, + CorrectionRule, CredentialsStatus, DictationSession, DictionaryEntry, HotkeyAdapterKind, + HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding, StylePack, StylePackKind, StylePackRuntimeDiagnostics, StyleSystemPrompts, UpdateChannel, UserPreferences, - VocabPresetStore, WindowsImeStatus, + VocabPresetStore, AndroidImeStatus, AndroidOverlayStatus, + PlatformCapabilities, HotkeyInstallError, HotkeyStatusState, }; +#[cfg(not(mobile))] +use crate::types::WindowsImeStatus; type CoordinatorState<'a> = State<'a, Arc>; pub type MicrophoneMonitorState = Mutex>; + +#[cfg(not(mobile))] pub type TrayMicrophoneMenuState = Mutex>; +#[cfg(not(mobile))] pub struct TrayMicrophoneMenuItem { pub id: String, pub device_name: String, pub item: tauri::menu::CheckMenuItem, } +#[cfg(not(mobile))] pub fn sync_tray_microphone_selection(items: &[TrayMicrophoneMenuItem], device_name: &str) { for item in items { let _ = item.item.set_checked(item.device_name == device_name); @@ -239,7 +251,7 @@ fn persist_settings( pub fn set_settings( coord: CoordinatorState<'_>, app: AppHandle, - tray_microphones: State<'_, TrayMicrophoneMenuState>, + #[cfg(not(mobile))] tray_microphones: State<'_, TrayMicrophoneMenuState>, mut prefs: UserPreferences, ) -> Result<(), String> { let packs = coord.style_packs().list().map_err(|e| e.to_string())?; @@ -248,36 +260,44 @@ pub fn set_settings( // 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时 // 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。 persist_settings(&*coord, prefs.clone())?; - // refresh_tray_microphone_menu 内部会调用 NSStatusItem.set_menu,必须在主线程上跑。 - // set_settings 本身是同步 Tauri command,在 IPC handler 线程上执行;从这里直接调 - // 会触发 macOS 主线程断言或在 dispatch 队列上死锁,导致整个 UI 无响应(用户改 - // 偏好后所有按键都没反应即此根因)。dispatch 到主线程后立即返回,IPC 线程不阻塞。 - let app_for_main = app.clone(); - let prefs_for_main = prefs.clone(); - let _ = app.run_on_main_thread(move || { - if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) { - log::warn!("[tray] refresh microphone menu after settings save failed: {err}"); - let tray_state = app_for_main.state::(); - sync_tray_microphone_selection( - &tray_state.lock(), - &prefs_for_main.microphone_device_name, - ); - } - }); - // 抑制 unused 警告:tray_microphones 现在改在闭包里通过 app.state 取, - // 但函数签名保留 State 入参,以便 Tauri 在调用前注入。 - let _ = tray_microphones; + #[cfg(not(mobile))] + { + // refresh_tray_microphone_menu 内部会调用 NSStatusItem.set_menu,必须在主线程上跑。 + // set_settings 本身是同步 Tauri command,在 IPC handler 线程上执行;从这里直接调 + // 会触发 macOS 主线程断言或在 dispatch 队列上死锁,导致整个 UI 无响应(用户改 + // 偏好后所有按键都没反应即此根因)。dispatch 到主线程后立即返回,IPC 线程不阻塞。 + let app_for_main = app.clone(); + let prefs_for_main = prefs.clone(); + let _ = app.run_on_main_thread(move || { + if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) { + log::warn!("[tray] refresh microphone menu after settings save failed: {err}"); + let tray_state = app_for_main.state::(); + sync_tray_microphone_selection( + &tray_state.lock(), + &prefs_for_main.microphone_device_name, + ); + } + }); + let _ = tray_microphones; + } let _ = app.emit("prefs:changed", &prefs); Ok(()) } fn refresh_tray_menu_async(app: &AppHandle) { - let app_for_main = app.clone(); - let _ = app.run_on_main_thread(move || { - if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) { - log::warn!("[tray] refresh after style change failed: {err}"); - } - }); + #[cfg(not(mobile))] + { + let app_for_main = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) { + log::warn!("[tray] refresh after style change failed: {err}"); + } + }); + } + #[cfg(mobile)] + { + let _ = app; + } } fn emit_prefs_changed(app: &AppHandle, prefs: &UserPreferences) { @@ -475,6 +495,7 @@ fn extract_between(haystack: &str, open: &str, close: &str) -> Option { // 里是分开的两份文件 —— 即使代码逻辑写错把 Beta URL 传给 Stable 用户,HTTP 也是 // 直接 404,绝不会拿到错档。 +#[cfg(not(mobile))] #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AppUpdateMetadata { @@ -492,6 +513,7 @@ pub struct AppUpdateMetadata { /// 渠道:显式传入 `channel` 时用它(关于页固定查 Stable、高级页 Beta 区查 Beta); /// 不传则回落到 `prefs.update_channel`(后台 AutoUpdateGate 自动检查走这条)。 /// 返回 None = 当前是最新;Some(metadata) = 有新版可装。 +#[cfg(not(mobile))] #[tauri::command] pub async fn app_check_update_with_channel( coord: CoordinatorState<'_>, @@ -537,6 +559,7 @@ pub async fn app_check_update_with_channel( /// 把 fetch_latest_beta_release 找到的最新 prerelease tag 拼成 -beta manifest URL 对。 /// 顺序:先镜像(fastgit.cc 代理 GitHub),后直连 —— 跟 tauri.conf 现有 Stable /// endpoints 一致,让国内访问优先打到 CDN。 +#[cfg(not(mobile))] async fn resolve_beta_manifest_endpoints() -> Result, String> { let Some(latest) = fetch_latest_beta_release().await? else { return Err("尚未发布过 Beta 版本".to_string()); @@ -591,20 +614,68 @@ pub async fn check_network() -> NetworkCheckResult { #[tauri::command] pub fn get_hotkey_status(coord: CoordinatorState<'_>) -> HotkeyStatus { + #[cfg(mobile)] + { + let _ = coord; + return HotkeyStatus { + adapter: HotkeyAdapterKind::Unavailable, + state: HotkeyStatusState::Failed, + message: Some("移动端不支持全局热键".into()), + last_error: Some(HotkeyInstallError { + code: "unavailable".into(), + message: "Global hotkeys are not available on mobile".into(), + }), + }; + } + #[cfg(not(mobile))] coord.hotkey_status() } #[tauri::command] pub fn get_hotkey_capability(coord: CoordinatorState<'_>) -> HotkeyCapability { + #[cfg(mobile)] + { + let _ = coord; + return HotkeyCapability::current(); + } + #[cfg(not(mobile))] coord.hotkey_capability() } +#[tauri::command] +pub fn get_platform_capabilities() -> PlatformCapabilities { + PlatformCapabilities::current() +} + +#[tauri::command] +pub fn get_android_ime_status() -> AndroidImeStatus { + crate::android_ime::get_android_ime_status() +} + +#[tauri::command] +pub fn get_android_overlay_status() -> AndroidOverlayStatus { + crate::android_overlay::get_android_overlay_status() +} + +#[tauri::command] +pub fn request_android_overlay_permission( +) -> crate::android_overlay::AndroidOverlayPermissionResult { + crate::android_overlay::request_android_overlay_permission() +} + #[tauri::command] pub fn set_shortcut_recording_active(coord: CoordinatorState<'_>, active: bool) { + #[cfg(mobile)] + { + let _ = (coord, active); + return; + } + #[cfg(not(mobile))] coord.set_shortcut_recording_active(active); } #[tauri::command] +#[cfg(not(mobile))] pub fn get_windows_ime_status() -> WindowsImeStatus { crate::windows_ime_profile::get_windows_ime_status() } @@ -686,9 +757,10 @@ fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bo if provider == "volcengine" { return volcengine_configured(snap); } - if provider == crate::asr::local::PROVIDER_ID - || active_foundry_asr_is_supported(provider) - || active_sherpa_asr_is_supported(provider) + if !cfg!(mobile) + && (provider == crate::asr::local::PROVIDER_ID + || active_foundry_asr_is_supported(provider) + || active_sherpa_asr_is_supported(provider)) { // 本地 ASR 不依赖云端凭据。 return true; @@ -757,12 +829,14 @@ fn configured(field: &Option) -> bool { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg(not(mobile))] struct LocalAsrReleasePlan { qwen: bool, foundry: bool, sherpa: bool, } +#[cfg(not(mobile))] fn local_asr_release_plan_for_provider(provider: &str) -> LocalAsrReleasePlan { LocalAsrReleasePlan { qwen: provider != crate::asr::local::PROVIDER_ID, @@ -771,6 +845,7 @@ fn local_asr_release_plan_for_provider(provider: &str) -> LocalAsrReleasePlan { } } +#[cfg(not(mobile))] async fn release_foundry_runtime_if_inactive( runtime: &Arc, release_foundry: bool, @@ -783,6 +858,7 @@ async fn release_foundry_runtime_if_inactive( } } +#[cfg(not(mobile))] async fn release_sherpa_runtime_if_inactive( runtime: &Arc, release_sherpa: bool, @@ -811,6 +887,25 @@ pub fn set_credential(window: Window, account: String, value: String) -> Result< Ok(()) } +#[cfg(mobile)] +#[tauri::command] +pub async fn set_active_asr_provider( + _coord: CoordinatorState<'_>, + provider: String, +) -> Result<(), String> { + if provider == crate::asr::local::PROVIDER_ID + || provider == crate::asr::local::sherpa::PROVIDER_ID + || provider == crate::asr::local::foundry::PROVIDER_ID + { + return Err("Local ASR is not available on mobile".to_string()); + } + if CredentialsVault::get_active_asr() == provider { + return Ok(()); + } + CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string()) +} + +#[cfg(not(mobile))] #[tauri::command] pub async fn set_active_asr_provider( coord: CoordinatorState<'_>, @@ -1104,17 +1199,18 @@ async fn validate_bailian_asr_provider() -> Result<(), String> { } fn active_asr_is_keyless_for_validation(provider: &str) -> bool { - provider == crate::asr::local::PROVIDER_ID - || active_foundry_asr_is_supported(provider) - || active_sherpa_asr_is_supported(provider) + !cfg!(mobile) + && (provider == crate::asr::local::PROVIDER_ID + || active_foundry_asr_is_supported(provider) + || active_sherpa_asr_is_supported(provider)) } fn active_foundry_asr_is_supported(provider: &str) -> bool { - #[cfg(target_os = "windows")] + #[cfg(all(not(mobile), target_os = "windows"))] { provider == FOUNDRY_LOCAL_PROVIDER_ID } - #[cfg(not(target_os = "windows"))] + #[cfg(not(all(not(mobile), target_os = "windows")))] { let _ = provider; false @@ -1122,11 +1218,11 @@ fn active_foundry_asr_is_supported(provider: &str) -> bool { } fn active_sherpa_asr_is_supported(provider: &str) -> bool { - #[cfg(target_os = "windows")] + #[cfg(all(not(mobile), target_os = "windows"))] { provider == crate::asr::local::sherpa::PROVIDER_ID } - #[cfg(not(target_os = "windows"))] + #[cfg(not(all(not(mobile), target_os = "windows")))] { let _ = provider; false @@ -2380,11 +2476,13 @@ fn shortcut_bindings_overlap(left: &ShortcutBinding, right: &ShortcutBinding) -> // ─────────────────────────── local ASR (Qwen3-ASR) ─────────────────────────── +#[cfg(not(mobile))] use crate::asr::local::{ download::{fetch_remote_info, RemoteInfo}, DownloadManager, Mirror, ModelId, ModelStatus, PROVIDER_ID as LOCAL_PROVIDER_ID, }; +#[cfg(not(mobile))] #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct LocalAsrSettings { @@ -2397,6 +2495,7 @@ pub struct LocalAsrSettings { pub engine_available: bool, } +#[cfg(not(mobile))] #[tauri::command] pub fn local_asr_get_settings(coord: CoordinatorState<'_>) -> LocalAsrSettings { let prefs = coord.prefs().get(); @@ -2414,6 +2513,7 @@ pub fn local_asr_get_settings(coord: CoordinatorState<'_>) -> LocalAsrSettings { } } +#[cfg(not(mobile))] fn non_empty_string(value: String) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { @@ -2423,6 +2523,7 @@ fn non_empty_string(value: String) -> Option { } } +#[cfg(not(mobile))] #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LocalAsrStorageSettings { @@ -2431,6 +2532,7 @@ pub struct LocalAsrStorageSettings { pub is_default: bool, } +#[cfg(not(mobile))] #[tauri::command] pub fn local_asr_storage_settings( coord: CoordinatorState<'_>, @@ -2448,6 +2550,7 @@ pub fn local_asr_storage_settings( }) } +#[cfg(not(mobile))] #[tauri::command] pub async fn local_asr_set_models_base_dir( coord: CoordinatorState<'_>, @@ -2496,6 +2599,7 @@ pub async fn local_asr_set_models_base_dir( local_asr_storage_settings(coord) } +#[cfg(not(mobile))] fn same_path_for_command(left: &std::path::Path, right: &std::path::Path) -> bool { match (left.canonicalize(), right.canonicalize()) { (Ok(left), Ok(right)) => left == right, @@ -2503,6 +2607,7 @@ fn same_path_for_command(left: &std::path::Path, right: &std::path::Path) -> boo } } +#[cfg(not(mobile))] async fn quiesce_local_asr_storage_users( coord: &Arc, qwen_manager: &Arc, @@ -2543,6 +2648,7 @@ async fn quiesce_local_asr_storage_users( Err("local ASR downloads are still stopping; retry after cancellation finishes".to_string()) } +#[cfg(not(mobile))] #[tauri::command] pub fn local_asr_set_active_model( coord: CoordinatorState<'_>, @@ -2556,6 +2662,7 @@ pub fn local_asr_set_active_model( coord.prefs().set(prefs).map_err(|e| e.to_string()) } +#[cfg(not(mobile))] #[tauri::command] pub fn local_asr_set_mirror(coord: CoordinatorState<'_>, mirror: String) -> Result<(), String> { let _normalized = Mirror::from_str(&mirror); @@ -2564,6 +2671,7 @@ pub fn local_asr_set_mirror(coord: CoordinatorState<'_>, mirror: String) -> Resu coord.prefs().set(prefs).map_err(|e| e.to_string()) } +#[cfg(not(mobile))] #[tauri::command] pub fn local_asr_list_models() -> Vec { crate::asr::local::models::list_status() @@ -2571,6 +2679,7 @@ pub fn local_asr_list_models() -> Vec { /// 实时去 HuggingFace API 拉某个模型的真实文件清单 + 总尺寸; /// 前端在显示模型卡时调一次,避免硬编码尺寸过期。 +#[cfg(not(mobile))] #[tauri::command] pub async fn local_asr_fetch_remote_info( model_id: String, @@ -2581,6 +2690,7 @@ pub async fn local_asr_fetch_remote_info( fetch_remote_info(id, m).await.map_err(|e| format!("{e:#}")) } +#[cfg(not(mobile))] #[tauri::command] pub fn local_asr_download_model( app: AppHandle, @@ -2594,6 +2704,7 @@ pub fn local_asr_download_model( Ok(()) } +#[cfg(not(mobile))] #[tauri::command] pub fn local_asr_cancel_download( manager: State<'_, Arc>, @@ -2604,6 +2715,7 @@ pub fn local_asr_cancel_download( Ok(()) } +#[cfg(not(mobile))] #[tauri::command] pub fn local_asr_delete_model(coord: CoordinatorState<'_>, model_id: String) -> Result<(), String> { let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?; @@ -2615,6 +2727,7 @@ pub fn local_asr_delete_model(coord: CoordinatorState<'_>, model_id: String) -> crate::asr::local::models::delete_model(id).map_err(|e| e.to_string()) } +#[cfg(not(mobile))] #[tauri::command] pub fn local_asr_model_dir(model_id: String) -> Result { let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?; @@ -2623,6 +2736,7 @@ pub fn local_asr_model_dir(model_id: String) -> Result { .map_err(|e| format!("{e:#}")) } +#[cfg(not(mobile))] #[tauri::command] pub fn local_asr_reveal_model_dir(model_id: String) -> Result<(), String> { let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?; @@ -2631,6 +2745,7 @@ pub fn local_asr_reveal_model_dir(model_id: String) -> Result<(), String> { open_path_in_file_manager(&dir) } +#[cfg(not(mobile))] #[tauri::command] pub fn local_asr_reveal_models_root(coord: CoordinatorState<'_>) -> Result<(), String> { let prefs = coord.prefs().get(); @@ -2640,6 +2755,7 @@ pub fn local_asr_reveal_models_root(coord: CoordinatorState<'_>) -> Result<(), S open_path_in_file_manager(&dir) } +#[cfg(not(mobile))] #[tauri::command] pub async fn local_asr_test_model( model_id: String, @@ -2650,6 +2766,7 @@ pub async fn local_asr_test_model( .map_err(|e| format!("{e:#}")) } +#[cfg(not(mobile))] #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct LocalAsrEngineStatus { @@ -2658,6 +2775,7 @@ pub struct LocalAsrEngineStatus { pub keep_loaded_secs: u32, } +#[cfg(not(mobile))] #[tauri::command] pub fn local_asr_engine_status(coord: CoordinatorState<'_>) -> LocalAsrEngineStatus { let prefs = coord.prefs().get(); @@ -2668,16 +2786,19 @@ pub fn local_asr_engine_status(coord: CoordinatorState<'_>) -> LocalAsrEngineSta } } +#[cfg(not(mobile))] #[tauri::command] pub fn local_asr_release_engine(coord: CoordinatorState<'_>) { coord.release_local_asr_engine(); } +#[cfg(not(mobile))] #[tauri::command] pub fn local_asr_preload(coord: tauri::State<'_, std::sync::Arc>) { coord.preload_local_asr_in_background(); } +#[cfg(not(mobile))] #[tauri::command] pub fn local_asr_set_keep_loaded_secs( coord: CoordinatorState<'_>, @@ -2690,6 +2811,7 @@ pub fn local_asr_set_keep_loaded_secs( // ───────────────────── Windows local ASR (Foundry Local Whisper) ───────────────────── +#[cfg(not(mobile))] fn active_foundry_model_from_prefs(prefs: &UserPreferences) -> String { if model_alias_is_known(&prefs.foundry_local_asr_model) { prefs.foundry_local_asr_model.clone() @@ -2698,6 +2820,7 @@ fn active_foundry_model_from_prefs(prefs: &UserPreferences) -> String { } } +#[cfg(not(mobile))] fn validate_foundry_model_alias(model_alias: &str) -> Result<(), String> { if model_alias_is_known(model_alias) { Ok(()) @@ -2708,6 +2831,7 @@ fn validate_foundry_model_alias(model_alias: &str) -> Result<(), String> { } } +#[cfg(not(mobile))] fn normalize_foundry_language_hint(language_hint: &str) -> Result { let normalized = language_hint.trim().to_string(); if normalized.is_empty() @@ -2719,10 +2843,12 @@ fn normalize_foundry_language_hint(language_hint: &str) -> Result String { crate::asr::local::foundry_native::normalize_runtime_source_str(source) } +#[cfg(not(mobile))] #[tauri::command] pub async fn foundry_local_asr_status( coord: CoordinatorState<'_>, @@ -2735,6 +2861,7 @@ pub async fn foundry_local_asr_status( .await) } +#[cfg(not(mobile))] #[tauri::command] pub async fn foundry_local_asr_catalog( runtime: State<'_, Arc>, @@ -2745,6 +2872,7 @@ pub async fn foundry_local_asr_catalog( .map_err(|e| format!("{e:#}")) } +#[cfg(not(mobile))] #[tauri::command] pub fn foundry_local_asr_set_model( coord: CoordinatorState<'_>, @@ -2759,6 +2887,7 @@ pub fn foundry_local_asr_set_model( coord.prefs().set(prefs).map_err(|e| e.to_string()) } +#[cfg(not(mobile))] #[tauri::command] pub fn foundry_local_asr_set_language_hint( coord: CoordinatorState<'_>, @@ -2773,6 +2902,7 @@ pub fn foundry_local_asr_set_language_hint( coord.prefs().set(prefs).map_err(|e| e.to_string()) } +#[cfg(not(mobile))] #[tauri::command] pub fn foundry_local_asr_set_runtime_source( coord: CoordinatorState<'_>, @@ -2787,6 +2917,7 @@ pub fn foundry_local_asr_set_runtime_source( coord.prefs().set(prefs).map_err(|e| e.to_string()) } +#[cfg(not(mobile))] #[tauri::command] pub async fn foundry_local_asr_prepare( app: AppHandle, @@ -2820,6 +2951,7 @@ pub async fn foundry_local_asr_prepare( } } +#[cfg(not(mobile))] #[tauri::command] pub fn foundry_local_asr_cancel_prepare( runtime: State<'_, Arc>, @@ -2828,6 +2960,7 @@ pub fn foundry_local_asr_cancel_prepare( Ok(()) } +#[cfg(not(mobile))] #[tauri::command] pub async fn foundry_local_asr_release( runtime: State<'_, Arc>, @@ -2835,6 +2968,7 @@ pub async fn foundry_local_asr_release( runtime.release_now().await.map_err(|e| format!("{e:#}")) } +#[cfg(not(mobile))] #[tauri::command] pub async fn foundry_local_asr_model_dir( runtime: State<'_, Arc>, @@ -2848,6 +2982,7 @@ pub async fn foundry_local_asr_model_dir( .map_err(|e| format!("{e:#}")) } +#[cfg(not(mobile))] #[tauri::command] pub async fn foundry_local_asr_delete_model( runtime: State<'_, Arc>, @@ -2860,6 +2995,7 @@ pub async fn foundry_local_asr_delete_model( .map_err(|e| format!("{e:#}")) } +#[cfg(not(mobile))] #[tauri::command] pub async fn foundry_local_asr_reveal_model_dir( runtime: State<'_, Arc>, @@ -2873,6 +3009,7 @@ pub async fn foundry_local_asr_reveal_model_dir( open_path_in_file_manager(&dir) } +#[cfg(not(mobile))] fn emit_foundry_prepare_progress(app: &AppHandle, payload: FoundryPrepareProgressPayload) { if let Err(error) = app.emit("foundry-local-asr-prepare-progress", payload) { log::warn!("[foundry-asr] emit prepare progress failed: {error}"); @@ -2885,6 +3022,7 @@ fn emit_foundry_prepare_progress(app: &AppHandle, payload: FoundryPrepareProgres // catalog / 下载 / prepare / release / 删除 / 状态查询,推理由 coordinator 的 // 听写链路触发。offline 模型停止录音后 batch decode;online 模型录音时输出 partial。 +#[cfg(not(mobile))] fn active_sherpa_model_from_prefs(prefs: &UserPreferences) -> String { if sherpa_model_alias_is_known(&prefs.sherpa_onnx_model) { prefs.sherpa_onnx_model.clone() @@ -2893,6 +3031,7 @@ fn active_sherpa_model_from_prefs(prefs: &UserPreferences) -> String { } } +#[cfg(not(mobile))] fn validate_sherpa_model_alias(model_alias: &str) -> Result<(), String> { if sherpa_model_alias_is_known(model_alias) { Ok(()) @@ -2901,6 +3040,7 @@ fn validate_sherpa_model_alias(model_alias: &str) -> Result<(), String> { } } +#[cfg(not(mobile))] fn normalize_sherpa_language_hint(language_hint: &str) -> Result { let normalized = language_hint.trim().to_lowercase(); if normalized.is_empty() @@ -2914,6 +3054,7 @@ fn normalize_sherpa_language_hint(language_hint: &str) -> Result } } +#[cfg(not(mobile))] #[tauri::command] pub async fn sherpa_onnx_asr_status( coord: CoordinatorState<'_>, @@ -2924,6 +3065,7 @@ pub async fn sherpa_onnx_asr_status( Ok(runtime.status_snapshot(&active_model).await) } +#[cfg(not(mobile))] #[tauri::command] pub async fn sherpa_onnx_asr_catalog( runtime: State<'_, Arc>, @@ -2934,6 +3076,7 @@ pub async fn sherpa_onnx_asr_catalog( .map_err(|e| format!("{e:#}")) } +#[cfg(not(mobile))] #[tauri::command] pub async fn sherpa_onnx_asr_fetch_remote_info( model_alias: String, @@ -2946,6 +3089,7 @@ pub async fn sherpa_onnx_asr_fetch_remote_info( .map_err(|e| format!("{e:#}")) } +#[cfg(not(mobile))] #[tauri::command] pub fn sherpa_onnx_asr_download_model( app: AppHandle, @@ -2959,6 +3103,7 @@ pub fn sherpa_onnx_asr_download_model( Ok(()) } +#[cfg(not(mobile))] #[tauri::command] pub fn sherpa_onnx_asr_cancel_download( manager: State<'_, Arc>, @@ -2969,6 +3114,7 @@ pub fn sherpa_onnx_asr_cancel_download( Ok(()) } +#[cfg(not(mobile))] #[tauri::command] pub fn sherpa_onnx_asr_set_model( coord: CoordinatorState<'_>, @@ -2983,6 +3129,7 @@ pub fn sherpa_onnx_asr_set_model( coord.prefs().set(prefs).map_err(|e| e.to_string()) } +#[cfg(not(mobile))] #[tauri::command] pub fn sherpa_onnx_asr_set_language_hint( coord: CoordinatorState<'_>, @@ -2997,6 +3144,7 @@ pub fn sherpa_onnx_asr_set_language_hint( coord.prefs().set(prefs).map_err(|e| e.to_string()) } +#[cfg(not(mobile))] #[tauri::command] pub async fn sherpa_onnx_asr_prepare( app: AppHandle, @@ -3027,6 +3175,7 @@ pub async fn sherpa_onnx_asr_prepare( } } +#[cfg(not(mobile))] #[tauri::command] pub fn sherpa_onnx_asr_cancel_prepare( runtime: State<'_, Arc>, @@ -3035,6 +3184,7 @@ pub fn sherpa_onnx_asr_cancel_prepare( Ok(()) } +#[cfg(not(mobile))] #[tauri::command] pub async fn sherpa_onnx_asr_release( runtime: State<'_, Arc>, @@ -3042,6 +3192,7 @@ pub async fn sherpa_onnx_asr_release( runtime.release_now().await.map_err(|e| format!("{e:#}")) } +#[cfg(not(mobile))] #[tauri::command] pub fn sherpa_onnx_asr_model_dir(model_alias: String) -> Result { validate_sherpa_model_alias(&model_alias)?; @@ -3050,6 +3201,7 @@ pub fn sherpa_onnx_asr_model_dir(model_alias: String) -> Result .map_err(|e| format!("{e:#}")) } +#[cfg(not(mobile))] #[tauri::command] pub async fn sherpa_onnx_asr_delete_model( runtime: State<'_, Arc>, @@ -3062,6 +3214,7 @@ pub async fn sherpa_onnx_asr_delete_model( .map_err(|e| format!("{e:#}")) } +#[cfg(not(mobile))] #[tauri::command] pub fn sherpa_onnx_asr_reveal_model_dir(model_alias: String) -> Result<(), String> { validate_sherpa_model_alias(&model_alias)?; @@ -3070,7 +3223,7 @@ pub fn sherpa_onnx_asr_reveal_model_dir(model_alias: String) -> Result<(), Strin open_path_in_file_manager(&dir) } -#[cfg(target_os = "windows")] +#[cfg(all(not(mobile), target_os = "windows"))] fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), String> { use windows::core::PCWSTR; use windows::Win32::UI::Shell::ShellExecuteW; @@ -3099,7 +3252,7 @@ fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), String> { } } -#[cfg(target_os = "macos")] +#[cfg(all(not(mobile), target_os = "macos"))] fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), String> { std::process::Command::new("open") .arg(path) @@ -3108,7 +3261,7 @@ fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), String> { .map_err(|e| e.to_string()) } -#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(all(not(mobile), unix, not(target_os = "macos"), not(target_os = "android")))] fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), String> { std::process::Command::new("xdg-open") .arg(path) @@ -3117,6 +3270,7 @@ fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), String> { .map_err(|e| e.to_string()) } +#[cfg(not(mobile))] fn emit_sherpa_prepare_progress(app: &AppHandle, payload: SherpaPrepareProgressPayload) { if let Err(error) = app.emit("sherpa-onnx-asr-prepare-progress", payload) { log::warn!("[sherpa-asr] emit prepare progress failed: {error}"); diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 130e6d2a..e00603dd 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -2231,44 +2231,57 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { polish_error ); InsertStatus::Inserted - } else if focus_ready_for_paste { - #[cfg(target_os = "windows")] - { - let ime_target = capture_ime_submit_target(); - insert_with_windows_ime_first( - inner, - current_session_id, - &polished, - restore_clipboard, - allow_non_tsf_insertion_fallback, - paste_shortcut, - ime_target, - ) - .await - } - #[cfg(not(target_os = "windows"))] - { - inner - .inserter - .insert(&polished, restore_clipboard, paste_shortcut) - } } else { - #[cfg(target_os = "linux")] + #[cfg(target_os = "android")] { - // Linux: fcitx5 commitString 无需窗口焦点,始终尝试插入。 - inner - .inserter - .insert(&polished, restore_clipboard, paste_shortcut) - } - #[cfg(not(target_os = "linux"))] - { - log::warn!( - "[coord] original insertion target is not foreground; copied output without paste" - ); - if allow_non_tsf_insertion_fallback { - inner.inserter.copy_fallback(&polished) + let result = crate::android_ime::commit_text(&polished); + if result.committed { + InsertStatus::Inserted } else { - InsertStatus::Failed + log::warn!("[coord] android IME commit failed: {}", result.message); + inner.inserter.copy_fallback(&polished) + } + } + #[cfg(not(target_os = "android"))] + if focus_ready_for_paste { + #[cfg(target_os = "windows")] + { + let ime_target = capture_ime_submit_target(); + insert_with_windows_ime_first( + inner, + current_session_id, + &polished, + restore_clipboard, + allow_non_tsf_insertion_fallback, + paste_shortcut, + ime_target, + ) + .await + } + #[cfg(not(target_os = "windows"))] + { + inner + .inserter + .insert(&polished, restore_clipboard, paste_shortcut) + } + } else { + #[cfg(target_os = "linux")] + { + // Linux: fcitx5 commitString 无需窗口焦点,始终尝试插入。 + inner + .inserter + .insert(&polished, restore_clipboard, paste_shortcut) + } + #[cfg(not(target_os = "linux"))] + { + log::warn!( + "[coord] original insertion target is not foreground; copied output without paste" + ); + if allow_non_tsf_insertion_fallback { + inner.inserter.copy_fallback(&polished) + } else { + InsertStatus::Failed + } } } }; @@ -2763,4 +2776,9 @@ mod tests { fn platform_type_error() -> crate::unicode_keystroke::TypeError { crate::unicode_keystroke::TypeError::EnigoText("fail".into()) } + + #[cfg(target_os = "android")] + fn platform_type_error() -> crate::unicode_keystroke::TypeError { + crate::unicode_keystroke::TypeError::Unavailable + } } diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index d2502021..64848f32 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -5,14 +5,14 @@ //! - macOS:用 CoreGraphics CGEvent 直接 post Cmd+V。 //! - Windows / Linux:用 enigo 按 `PasteShortcut` 模拟。 -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] use std::sync::atomic::{AtomicU64, Ordering}; -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] use std::time::Duration; -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] use once_cell::sync::Lazy; -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] use parking_lot::Mutex; use crate::types::{InsertStatus, PasteShortcut}; @@ -20,7 +20,7 @@ use crate::types::{InsertStatus, PasteShortcut}; #[cfg(target_os = "windows")] const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750); -#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750); pub struct TextInserter; @@ -63,7 +63,7 @@ impl TextInserter { insert_with_clipboard_restore(text, restore_clipboard_after_paste, paste_shortcut) } - #[cfg(not(target_os = "macos"))] + #[cfg(all(not(target_os = "macos"), not(target_os = "android")))] pub fn insert_via_clipboard_fallback( &self, text: &str, @@ -107,6 +107,26 @@ impl TextInserter { macos_insert_status_after_paste(simulate_paste()) } + /// Android:优先 IME commit,失败则写剪贴板兜底。 + #[cfg(target_os = "android")] + pub fn insert( + &self, + text: &str, + _restore_clipboard_after_paste: bool, + _paste_shortcut: PasteShortcut, + ) -> InsertStatus { + if text.is_empty() { + return InsertStatus::CopiedFallback; + } + let result = crate::android_ime::commit_text(text); + if result.committed { + InsertStatus::Inserted + } else { + log::warn!("[insertion] android IME commit failed: {}", result.message); + self.copy_fallback(text) + } + } + /// 只写剪贴板、不模拟粘贴。用于目标控件活跃状态无法验证时的兜底路径。 pub fn copy_fallback(&self, text: &str) -> InsertStatus { if text.is_empty() { @@ -163,24 +183,24 @@ impl Default for TextInserter { } } -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] #[derive(Debug)] struct ClipboardRestorePlan { inserted_text: String, previous_text: Option, } -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] #[derive(Debug, Clone)] struct PendingClipboardRestore { latest_restore_id: u64, original_text: Option, } -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] static NEXT_CLIPBOARD_RESTORE_ID: AtomicU64 = AtomicU64::new(1); -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] static PENDING_CLIPBOARD_RESTORE: Lazy>> = Lazy::new(|| Mutex::new(None)); @@ -199,7 +219,7 @@ fn copy_to_clipboard(text: &str) -> bool { true } -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] fn copy_to_clipboard_with_restore_plan(text: &str) -> Result { let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; let previous_text = match clipboard.get_text() { @@ -221,7 +241,7 @@ fn copy_to_clipboard_with_restore_plan(text: &str) -> Result) -> (u64, Option) { let restore_id = NEXT_CLIPBOARD_RESTORE_ID.fetch_add(1, Ordering::SeqCst); let original_text = { @@ -273,7 +293,7 @@ fn remember_pending_clipboard_restore(previous_text: Option) -> (u64, Op (restore_id, original_text) } -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] fn restore_clipboard_after_delay( plan: ClipboardRestorePlan, original_text: Option, @@ -324,7 +344,7 @@ fn restore_clipboard_after_delay( clear_pending_clipboard_restore(restore_id); } -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] fn is_latest_clipboard_restore(restore_id: u64) -> bool { matches!( PENDING_CLIPBOARD_RESTORE.lock().as_ref(), @@ -332,7 +352,7 @@ fn is_latest_clipboard_restore(restore_id: u64) -> bool { ) } -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] fn clear_pending_clipboard_restore(restore_id: u64) { let mut pending = PENDING_CLIPBOARD_RESTORE.lock(); if matches!(pending.as_ref(), Some(batch) if batch.latest_restore_id == restore_id) { @@ -340,7 +360,7 @@ fn clear_pending_clipboard_restore(restore_id: u64) { } } -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] fn should_restore_clipboard(current_text: Option<&str>, inserted_text: &str) -> bool { matches!(current_text, Some(current) if current == inserted_text) } @@ -357,7 +377,7 @@ fn simulate_paste() -> Result<(), String> { } /// 把 `PasteShortcut` 拆成 `(modifiers, primary)`,顺序决定按下/释放顺序。 -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] fn paste_keys(shortcut: PasteShortcut) -> (Vec, enigo::Key) { use enigo::Key; match shortcut { @@ -367,7 +387,7 @@ fn paste_keys(shortcut: PasteShortcut) -> (Vec, enigo::Key) { } } -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] fn simulate_paste(shortcut: PasteShortcut) -> Result<(), String> { use enigo::{Direction, Enigo, Keyboard, Settings}; let (modifiers, primary) = paste_keys(shortcut); @@ -411,7 +431,7 @@ fn insertion_success_status() -> InsertStatus { InsertStatus::Inserted } -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] fn insertion_success_status() -> InsertStatus { InsertStatus::PasteSent } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 36334163..514953c8 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -18,12 +18,22 @@ mod asr; mod audio_mute; mod cli; mod coding_agent; +#[cfg(not(mobile))] +mod combo_hotkey; +#[cfg(mobile)] +#[path = "mobile_stubs/combo_hotkey.rs"] mod combo_hotkey; mod commands; mod coordinator; mod coordinator_state; mod correction; +#[cfg(not(mobile))] mod global_hotkey_runtime; +#[cfg(not(mobile))] +#[path = "hotkey.rs"] +mod hotkey; +#[cfg(mobile)] +#[path = "mobile_stubs/hotkey.rs"] mod hotkey; mod insertion; #[cfg(target_os = "linux")] @@ -33,15 +43,40 @@ mod net; mod permissions; mod persistence; mod polish; +#[cfg(not(mobile))] +mod qa_hotkey; +#[cfg(mobile)] +#[path = "mobile_stubs/qa_hotkey.rs"] mod qa_hotkey; mod recorder; +#[cfg(not(mobile))] +#[path = "selection.rs"] mod selection; +#[cfg(mobile)] +#[path = "mobile_stubs/selection.rs"] +mod selection; +#[cfg(not(mobile))] +mod shortcut_binding; +#[cfg(mobile)] +#[path = "mobile_stubs/shortcut_binding.rs"] mod shortcut_binding; mod types; +#[cfg(not(mobile))] +mod unicode_keystroke; +#[cfg(mobile)] +#[path = "mobile_stubs/unicode_keystroke.rs"] mod unicode_keystroke; +mod android_ime; +mod android_overlay; +#[cfg(mobile)] +mod mobile_runtime; +#[cfg(target_os = "windows")] mod windows_ime_ipc; +#[cfg(target_os = "windows")] mod windows_ime_profile; +#[cfg(target_os = "windows")] mod windows_ime_protocol; +#[cfg(target_os = "windows")] mod windows_ime_session; use std::sync::atomic::{AtomicBool, Ordering}; @@ -57,10 +92,13 @@ const OPENLESS_BUNDLE_ID: &str = "com.openless.app"; /// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition, /// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。 static QA_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false); +#[cfg(not(mobile))] static TRAY_MICROPHONE_WATCHER_STOPPING: AtomicBool = AtomicBool::new(false); +#[cfg(not(mobile))] use tauri::menu::{ CheckMenuItemBuilder, Menu, MenuBuilder, MenuItemBuilder, Submenu, SubmenuBuilder, }; +#[cfg(not(mobile))] use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent}; use tauri::{ AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, PhysicalPosition, PhysicalSize, @@ -71,6 +109,248 @@ use crate::types::PolishMode; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + #[cfg(mobile)] + { + mobile_runtime::run(); + return; + } + #[cfg(not(mobile))] + run_desktop(); +} + +macro_rules! app_invoke_handler_desktop { + () => { + tauri::generate_handler![ + commands::get_settings, + commands::get_default_style_system_prompts, + commands::set_settings, + commands::get_update_channel, + commands::set_update_channel, + commands::fetch_latest_beta_release, + commands::app_check_update_with_channel, + commands::check_network, + commands::get_hotkey_status, + commands::get_hotkey_capability, + commands::set_shortcut_recording_active, + commands::get_windows_ime_status, + commands::get_platform_capabilities, + commands::get_android_ime_status, + commands::get_android_overlay_status, + commands::request_android_overlay_permission, + commands::list_microphone_devices, + commands::start_microphone_level_monitor, + commands::stop_microphone_level_monitor, + commands::get_credentials, + commands::set_credential, + commands::list_history, + commands::delete_history_entry, + commands::clear_history, + commands::read_audio_recording, + commands::marketplace_list, + commands::marketplace_detail, + commands::marketplace_install, + commands::marketplace_upload, + commands::marketplace_like, + commands::marketplace_my_likes, + commands::marketplace_my_packs, + commands::marketplace_delete, + commands::github_device_flow_start, + commands::github_device_flow_poll, + commands::list_vocab, + commands::add_vocab, + commands::remove_vocab, + commands::set_vocab_enabled, + commands::list_correction_rules, + commands::add_correction_rule, + commands::remove_correction_rule, + commands::set_correction_rule_enabled, + commands::list_vocab_presets, + commands::save_vocab_presets, + commands::start_dictation, + commands::stop_dictation, + commands::cancel_dictation, + coding_agent::commands::coding_agent_detect, + coding_agent::commands::coding_agent_run_test, + coding_agent::commands::coding_agent_cancel_test, + coding_agent::commands::coding_agent_command_risk, + commands::handle_window_hotkey_event, + #[cfg(debug_assertions)] + commands::inject_hotkey_click_for_dev, + commands::repolish, + commands::list_style_packs, + commands::create_style_pack_from_template, + commands::save_style_pack, + commands::preview_style_pack_runtime, + commands::set_active_style_pack, + commands::set_style_pack_enabled, + commands::reset_builtin_style_pack, + commands::delete_style_pack, + commands::import_style_pack_from_zip, + commands::export_style_pack_to_zip, + commands::set_default_polish_mode, + commands::set_style_enabled, + commands::check_accessibility_permission, + commands::request_accessibility_permission, + commands::check_microphone_permission, + commands::request_microphone_permission, + commands::open_system_settings, + commands::trigger_microphone_prompt, + commands::read_credential, + commands::set_active_asr_provider, + commands::set_active_llm_provider, + commands::get_qa_hotkey_label, + commands::set_qa_hotkey, + commands::validate_shortcut_binding, + commands::set_dictation_hotkey, + commands::set_translation_hotkey, + commands::set_switch_style_hotkey, + commands::set_open_app_hotkey, + commands::qa_window_dismiss, + commands::qa_window_pin, + commands::less_computer_window_dismiss, + commands::less_computer_window_resize, + commands::less_computer_approve, + commands::validate_combo_hotkey, + commands::set_combo_hotkey, + commands::validate_provider_credentials, + commands::list_provider_models, + commands::local_asr_get_settings, + commands::local_asr_storage_settings, + commands::local_asr_set_models_base_dir, + commands::local_asr_set_active_model, + commands::local_asr_set_mirror, + commands::local_asr_list_models, + commands::local_asr_fetch_remote_info, + commands::local_asr_download_model, + commands::local_asr_cancel_download, + commands::local_asr_delete_model, + commands::local_asr_model_dir, + commands::local_asr_reveal_model_dir, + commands::local_asr_reveal_models_root, + commands::local_asr_test_model, + commands::local_asr_engine_status, + commands::local_asr_release_engine, + commands::local_asr_preload, + commands::local_asr_set_keep_loaded_secs, + commands::foundry_local_asr_status, + commands::foundry_local_asr_catalog, + commands::foundry_local_asr_set_model, + commands::foundry_local_asr_set_language_hint, + commands::foundry_local_asr_set_runtime_source, + commands::foundry_local_asr_prepare, + commands::foundry_local_asr_cancel_prepare, + commands::foundry_local_asr_release, + commands::foundry_local_asr_model_dir, + commands::foundry_local_asr_delete_model, + commands::foundry_local_asr_reveal_model_dir, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_status, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_catalog, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_fetch_remote_info, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_download_model, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_cancel_download, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_set_model, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_set_language_hint, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_prepare, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_cancel_prepare, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_release, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_model_dir, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_delete_model, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_reveal_model_dir, + commands::export_error_log, + restart_app, + ] + }; +} + +/// Android/iOS: only commands usable without desktop hotkeys, tray, updater, or local ASR. +macro_rules! app_invoke_handler_mobile { + () => { + tauri::generate_handler![ + commands::get_settings, + commands::get_default_style_system_prompts, + commands::set_settings, + commands::check_network, + commands::get_platform_capabilities, + commands::get_android_ime_status, + commands::get_android_overlay_status, + commands::request_android_overlay_permission, + commands::list_microphone_devices, + commands::start_microphone_level_monitor, + commands::stop_microphone_level_monitor, + commands::get_credentials, + commands::set_credential, + commands::read_credential, + commands::set_active_asr_provider, + commands::set_active_llm_provider, + commands::validate_provider_credentials, + commands::list_provider_models, + commands::list_history, + commands::delete_history_entry, + commands::clear_history, + commands::read_audio_recording, + commands::marketplace_list, + commands::marketplace_detail, + commands::marketplace_install, + commands::marketplace_upload, + commands::marketplace_like, + commands::marketplace_my_likes, + commands::marketplace_my_packs, + commands::marketplace_delete, + commands::github_device_flow_start, + commands::github_device_flow_poll, + commands::list_vocab, + commands::add_vocab, + commands::remove_vocab, + commands::set_vocab_enabled, + commands::list_correction_rules, + commands::add_correction_rule, + commands::remove_correction_rule, + commands::set_correction_rule_enabled, + commands::list_vocab_presets, + commands::save_vocab_presets, + commands::start_dictation, + commands::stop_dictation, + commands::cancel_dictation, + commands::repolish, + commands::list_style_packs, + commands::create_style_pack_from_template, + commands::save_style_pack, + commands::preview_style_pack_runtime, + commands::set_active_style_pack, + commands::set_style_pack_enabled, + commands::reset_builtin_style_pack, + commands::delete_style_pack, + commands::import_style_pack_from_zip, + commands::export_style_pack_to_zip, + commands::set_default_polish_mode, + commands::set_style_enabled, + commands::check_accessibility_permission, + commands::request_accessibility_permission, + commands::check_microphone_permission, + commands::request_microphone_permission, + commands::open_system_settings, + commands::trigger_microphone_prompt, + commands::export_error_log, + restart_app, + ] + }; +} + +#[cfg(not(mobile))] +fn run_desktop() { let foundry_local_runtime = Arc::new(asr::local::FoundryLocalRuntime::new()); let sherpa_onnx_runtime = Arc::new(asr::local::SherpaOnnxRuntime::new()); let sherpa_download_manager = @@ -343,154 +623,7 @@ pub fn run() { Ok(()) }) - .invoke_handler(tauri::generate_handler![ - commands::get_settings, - commands::get_default_style_system_prompts, - commands::set_settings, - commands::get_update_channel, - commands::set_update_channel, - commands::fetch_latest_beta_release, - commands::app_check_update_with_channel, - commands::check_network, - commands::get_hotkey_status, - commands::get_hotkey_capability, - commands::set_shortcut_recording_active, - commands::get_windows_ime_status, - commands::list_microphone_devices, - commands::start_microphone_level_monitor, - commands::stop_microphone_level_monitor, - commands::get_credentials, - commands::set_credential, - commands::list_history, - commands::delete_history_entry, - commands::clear_history, - commands::read_audio_recording, - commands::marketplace_list, - commands::marketplace_detail, - commands::marketplace_install, - commands::marketplace_upload, - commands::marketplace_like, - commands::marketplace_my_likes, - commands::marketplace_my_packs, - commands::marketplace_delete, - commands::github_device_flow_start, - commands::github_device_flow_poll, - commands::list_vocab, - commands::add_vocab, - commands::remove_vocab, - commands::set_vocab_enabled, - commands::list_correction_rules, - commands::add_correction_rule, - commands::remove_correction_rule, - commands::set_correction_rule_enabled, - commands::list_vocab_presets, - commands::save_vocab_presets, - commands::start_dictation, - commands::stop_dictation, - commands::cancel_dictation, - coding_agent::commands::coding_agent_detect, - coding_agent::commands::coding_agent_run_test, - coding_agent::commands::coding_agent_cancel_test, - coding_agent::commands::coding_agent_command_risk, - commands::handle_window_hotkey_event, - #[cfg(debug_assertions)] - commands::inject_hotkey_click_for_dev, - commands::repolish, - commands::list_style_packs, - commands::create_style_pack_from_template, - commands::save_style_pack, - commands::preview_style_pack_runtime, - commands::set_active_style_pack, - commands::set_style_pack_enabled, - commands::reset_builtin_style_pack, - commands::delete_style_pack, - commands::import_style_pack_from_zip, - commands::export_style_pack_to_zip, - commands::set_default_polish_mode, - commands::set_style_enabled, - commands::check_accessibility_permission, - commands::request_accessibility_permission, - commands::check_microphone_permission, - commands::request_microphone_permission, - commands::open_system_settings, - commands::trigger_microphone_prompt, - commands::read_credential, - commands::set_active_asr_provider, - commands::set_active_llm_provider, - commands::get_qa_hotkey_label, - commands::set_qa_hotkey, - commands::validate_shortcut_binding, - commands::set_dictation_hotkey, - commands::set_translation_hotkey, - commands::set_switch_style_hotkey, - commands::set_open_app_hotkey, - commands::qa_window_dismiss, - commands::qa_window_pin, - commands::less_computer_window_dismiss, - commands::less_computer_window_resize, - commands::less_computer_approve, - commands::validate_combo_hotkey, - commands::set_combo_hotkey, - commands::validate_provider_credentials, - commands::list_provider_models, - commands::local_asr_get_settings, - commands::local_asr_storage_settings, - commands::local_asr_set_models_base_dir, - commands::local_asr_set_active_model, - commands::local_asr_set_mirror, - commands::local_asr_list_models, - commands::local_asr_fetch_remote_info, - commands::local_asr_download_model, - commands::local_asr_cancel_download, - commands::local_asr_delete_model, - commands::local_asr_model_dir, - commands::local_asr_reveal_model_dir, - commands::local_asr_reveal_models_root, - commands::local_asr_test_model, - commands::local_asr_engine_status, - commands::local_asr_release_engine, - commands::local_asr_preload, - commands::local_asr_set_keep_loaded_secs, - commands::foundry_local_asr_status, - commands::foundry_local_asr_catalog, - commands::foundry_local_asr_set_model, - commands::foundry_local_asr_set_language_hint, - commands::foundry_local_asr_set_runtime_source, - commands::foundry_local_asr_prepare, - commands::foundry_local_asr_cancel_prepare, - commands::foundry_local_asr_release, - commands::foundry_local_asr_model_dir, - commands::foundry_local_asr_delete_model, - commands::foundry_local_asr_reveal_model_dir, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_status, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_catalog, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_fetch_remote_info, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_download_model, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_cancel_download, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_set_model, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_set_language_hint, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_prepare, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_cancel_prepare, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_release, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_model_dir, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_delete_model, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_reveal_model_dir, - commands::export_error_log, - restart_app, - ]) + .invoke_handler(app_invoke_handler_desktop!()) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app, event| match event { @@ -531,21 +664,25 @@ pub fn run() { }); } +#[cfg(not(mobile))] struct MicrophoneTrayMenu { submenu: Submenu, items: Vec, } +#[cfg(not(mobile))] struct StyleTrayMenu { submenu: Submenu, } +#[cfg(not(mobile))] struct TrayMenu { menu: Menu, microphone_items: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg(not(mobile))] struct TrayPolishModeMenuEntry { id: String, label: &'static str, @@ -554,9 +691,13 @@ struct TrayPolishModeMenuEntry { } fn tray_style_menu_enabled() -> bool { - cfg!(target_os = "windows") + #[cfg(all(not(mobile), target_os = "windows"))] + return true; + #[cfg(not(all(not(mobile), target_os = "windows")))] + false } +#[cfg(not(mobile))] fn tray_polish_mode_menu_entries(selected: PolishMode) -> Vec { [ (PolishMode::Raw, "style-raw"), @@ -574,6 +715,7 @@ fn tray_polish_mode_menu_entries(selected: PolishMode) -> Vec Option { match id { "style-raw" => Some(PolishMode::Raw), @@ -584,6 +726,7 @@ fn parse_tray_polish_mode_id(id: &str) -> Option { } } +#[cfg(not(mobile))] fn build_tray_menu>( app: &M, coordinator: &Arc, @@ -609,6 +752,7 @@ fn build_tray_menu>( }) } +#[cfg(not(mobile))] fn build_style_tray_menu>( app: &M, coordinator: &Arc, @@ -631,6 +775,7 @@ fn build_style_tray_menu>( }) } +#[cfg(not(mobile))] fn build_microphone_tray_menu>( app: &M, coordinator: &Arc, @@ -689,6 +834,7 @@ fn build_microphone_tray_menu>( }) } +#[cfg(not(mobile))] pub(crate) fn refresh_tray_microphone_menu(app: &AppHandle) -> tauri::Result<()> { let coordinator = app.state::>(); let tray_menu = build_tray_menu(app, &coordinator)?; @@ -700,6 +846,7 @@ pub(crate) fn refresh_tray_microphone_menu(app: &AppHandle) -> tauri::Result<()> Ok(()) } +#[cfg(not(mobile))] fn microphone_device_signature() -> Option> { match recorder::list_input_devices() { Ok(devices) => Some( @@ -715,6 +862,7 @@ fn microphone_device_signature() -> Option> { } } +#[cfg(not(mobile))] fn start_tray_microphone_watcher(app: AppHandle) { TRAY_MICROPHONE_WATCHER_STOPPING.store(false, Ordering::Relaxed); if let Err(err) = std::thread::Builder::new() @@ -748,6 +896,7 @@ fn start_tray_microphone_watcher(app: AppHandle) { } } +#[cfg(not(mobile))] fn handle_microphone_tray_menu_event(app: &AppHandle, id: &str) { let tray_items = app.state::(); let items = tray_items.lock(); @@ -767,6 +916,7 @@ fn handle_microphone_tray_menu_event(app: &AppHandle, id: &str) { commands::sync_tray_microphone_selection(&items, &selected.device_name); } +#[cfg(not(mobile))] fn handle_style_tray_menu_event(app: &AppHandle, id: &str) -> bool { let Some(mode) = parse_tray_polish_mode_id(id) else { return false; @@ -782,6 +932,11 @@ fn handle_style_tray_menu_event(app: &AppHandle, id: &str) -> bool { true } +#[cfg(mobile)] +pub(crate) fn refresh_tray_microphone_menu(_app: &AppHandle) -> tauri::Result<()> { + Ok(()) +} + /// 把 Win11 原生标题栏底色刷成白色,与应用 sidebar 视觉统一。需要 Win11 22H2+ /// (Build 22621+) 才支持 `DWMWA_CAPTION_COLOR`(35);老 Windows 上 DwmSetWindowAttribute /// 返回错误,仅打 warn 不阻塞启动。 @@ -947,7 +1102,7 @@ pub fn log_dir_path() -> std::path::PathBuf { .join("Logs"); } } - #[cfg(all(unix, not(target_os = "macos")))] + #[cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))] { if let Ok(home) = std::env::var("HOME") { return std::path::PathBuf::from(home) @@ -957,6 +1112,12 @@ pub fn log_dir_path() -> std::path::PathBuf { .join("logs"); } } + #[cfg(target_os = "android")] + { + if let Ok(dir) = std::env::var("TAURI_ANDROID_APP_DATA_DIR") { + return std::path::PathBuf::from(dir).join("logs"); + } + } std::env::temp_dir().join("OpenLess") } diff --git a/openless-all/app/src-tauri/src/mobile_runtime.rs b/openless-all/app/src-tauri/src/mobile_runtime.rs new file mode 100644 index 00000000..93eb7c34 --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_runtime.rs @@ -0,0 +1,47 @@ +//! Minimal Tauri mobile runtime — single main window, no tray/hotkey/updater. + +use std::sync::Arc; + +use tauri::{AppHandle, Manager, RunEvent}; + +use crate::coordinator::Coordinator; +use crate::commands::{self, MicrophoneMonitorState}; + +pub fn run() { + let coordinator = Arc::new(Coordinator::new()); + + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_dialog::init()) + .manage(coordinator.clone()) + .manage(MicrophoneMonitorState::new(None)) + .setup(move |app| { + crate::init_file_logger(); + log::info!("=== OpenLess mobile 启动 ==="); + + if let Some(main) = app.get_webview_window("main") { + let _ = main.show(); + } + + coordinator.bind_app(app.handle().clone()); + Ok(()) + }) + .invoke_handler(crate::app_invoke_handler_mobile!()) + .build(tauri::generate_context!()) + .expect("error while building tauri mobile application") + .run(|app, event| match event { + RunEvent::Exit => { + let coordinator = app.state::>(); + coordinator.stop_hotkey_listener(); + } + _ => {} + }); +} + +#[allow(dead_code)] +pub(crate) fn show_main_window(app: &AppHandle) { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } +} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/combo_hotkey.rs b/openless-all/app/src-tauri/src/mobile_stubs/combo_hotkey.rs new file mode 100644 index 00000000..3a7907ef --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/combo_hotkey.rs @@ -0,0 +1,46 @@ +//! Mobile stub — combo hotkeys are unavailable on Android/iOS. + +use std::sync::mpsc::Sender; + +use crate::types::ShortcutBinding; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ComboHotkeyEvent { + Pressed, + Released, +} + +#[derive(Debug, thiserror::Error)] +pub enum ComboHotkeyError { + #[error("不支持的修饰键: {0}")] + UnsupportedModifier(String), + #[error("不支持的主键: {0}")] + UnsupportedKey(String), + #[error("注册全局快捷键失败: {0}")] + RegisterFailed(String), + #[error("初始化全局快捷键管理器失败: {0}")] + ManagerInitFailed(String), +} + +pub struct ComboHotkeyMonitor; + +impl ComboHotkeyMonitor { + pub fn start( + _binding: ShortcutBinding, + _tx: Sender, + ) -> Result { + Err(ComboHotkeyError::RegisterFailed( + "Combo hotkeys are not available on mobile".into(), + )) + } + + pub fn update_binding(&self, _binding: ShortcutBinding) -> Result<(), ComboHotkeyError> { + Ok(()) + } +} + +pub fn validate_binding(_binding: &ShortcutBinding) -> Result<(), ComboHotkeyError> { + Err(ComboHotkeyError::RegisterFailed( + "Combo hotkeys are not available on mobile".into(), + )) +} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs b/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs new file mode 100644 index 00000000..59c6c7e7 --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs @@ -0,0 +1,49 @@ +//! Mobile stub — global hotkeys are unavailable on Android/iOS. + +use std::sync::mpsc::Sender; + +use crate::types::{ + HotkeyAdapterKind, HotkeyBinding, HotkeyCapability, HotkeyInstallError, HotkeyTrigger, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HotkeyEvent { + Pressed, + Released, + Cancelled, + TranslationModifierPressed, + QaShortcutPressed, +} + +pub struct HotkeyMonitor; + +impl HotkeyMonitor { + pub fn start( + _binding: HotkeyBinding, + _tx: Sender, + ) -> Result { + Err(HotkeyInstallError { + code: "unavailable".into(), + message: "Global hotkeys are not available on mobile".into(), + }) + } + + pub fn update_binding(&self, _binding: HotkeyBinding) {} + + pub fn update_modifier_shortcuts( + &self, + _qa_trigger: Option, + _translation_trigger: Option, + ) { + } + + pub fn kind(&self) -> HotkeyAdapterKind { + HotkeyAdapterKind::Unavailable + } + + pub fn reset_held_state(&self) {} + + pub fn capability() -> HotkeyCapability { + HotkeyCapability::current() + } +} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/qa_hotkey.rs b/openless-all/app/src-tauri/src/mobile_stubs/qa_hotkey.rs new file mode 100644 index 00000000..b94c7acb --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/qa_hotkey.rs @@ -0,0 +1,33 @@ +//! Mobile stub — QA hotkeys are unavailable on Android/iOS. + +use std::sync::mpsc::Sender; + +use crate::types::ShortcutBinding; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QaHotkeyEvent { + Pressed, +} + +#[derive(Debug, thiserror::Error)] +pub enum QaHotkeyError { + #[error("注册 QA 快捷键失败: {0}")] + RegisterFailed(String), +} + +pub struct QaHotkeyMonitor; + +impl QaHotkeyMonitor { + pub fn start( + _binding: ShortcutBinding, + _tx: Sender, + ) -> Result { + Err(QaHotkeyError::RegisterFailed( + "QA hotkeys are not available on mobile".into(), + )) + } + + pub fn update_binding(&self, _binding: ShortcutBinding) -> Result<(), QaHotkeyError> { + Ok(()) + } +} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/selection.rs b/openless-all/app/src-tauri/src/mobile_stubs/selection.rs new file mode 100644 index 00000000..c7de0c6e --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/selection.rs @@ -0,0 +1,11 @@ +//! Mobile stub — selection capture is desktop-only for now. + +#[derive(Debug, Clone)] +pub struct SelectionContext { + pub text: String, + pub source_app: Option, +} + +pub fn capture_selection() -> Option { + None +} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/shortcut_binding.rs b/openless-all/app/src-tauri/src/mobile_stubs/shortcut_binding.rs new file mode 100644 index 00000000..7946a44d --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/shortcut_binding.rs @@ -0,0 +1,38 @@ +//! Mobile stub — shortcut binding validation is unavailable on mobile. + +use crate::types::{HotkeyTrigger, ShortcutBinding}; + +#[derive(Debug, thiserror::Error)] +pub enum ShortcutBindingError { + #[error("快捷键在移动端不可用")] + Unavailable, +} + +pub fn validate_binding(_binding: &ShortcutBinding) -> Result<(), ShortcutBindingError> { + Err(ShortcutBindingError::Unavailable) +} + +pub fn parse_global_hotkey(_binding: &ShortcutBinding) -> Result<(), ShortcutBindingError> { + Err(ShortcutBindingError::Unavailable) +} + +pub fn legacy_modifier_trigger(_binding: &ShortcutBinding) -> Option { + None +} + +pub fn binding_from_legacy_trigger(trigger: HotkeyTrigger) -> ShortcutBinding { + let primary = match trigger { + HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => "RightOption", + HotkeyTrigger::LeftOption => "LeftOption", + HotkeyTrigger::RightControl => "RightControl", + HotkeyTrigger::LeftControl => "LeftControl", + HotkeyTrigger::RightCommand => "RightCommand", + HotkeyTrigger::Fn => "Fn", + HotkeyTrigger::MediaPlayPause => "MediaPlayPause", + HotkeyTrigger::Custom => "RightOption", + }; + ShortcutBinding { + primary: primary.into(), + modifiers: Vec::new(), + } +} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/unicode_keystroke.rs b/openless-all/app/src-tauri/src/mobile_stubs/unicode_keystroke.rs new file mode 100644 index 00000000..9adc27ed --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/unicode_keystroke.rs @@ -0,0 +1,40 @@ +//! Mobile stub — unicode keystroke streaming is unavailable on mobile. + +use tauri::{AppHandle, Runtime}; + +#[derive(Debug, thiserror::Error)] +pub enum TypeError { + #[allow(dead_code)] + #[error("{source} after {typed_chars} chars were sent")] + Partial { + typed_chars: usize, + #[source] + source: Box, + }, + #[error("unicode keystroke unavailable on mobile")] + Unavailable, +} + +impl TypeError { + pub fn typed_chars(&self) -> usize { + match self { + TypeError::Partial { typed_chars, .. } => *typed_chars, + _ => 0, + } + } +} + +pub async fn switch_to_ascii(_app: &AppHandle) -> Result, TypeError> { + Err(TypeError::Unavailable) +} + +pub async fn restore_input_source( + _app: &AppHandle, + _previous: Option<()>, +) -> Result<(), TypeError> { + Ok(()) +} + +pub fn type_unicode_chunk(_text: &str) -> Result { + Err(TypeError::Unavailable) +} diff --git a/openless-all/app/src-tauri/src/permissions.rs b/openless-all/app/src-tauri/src/permissions.rs index ef1452d4..c20363ed 100644 --- a/openless-all/app/src-tauri/src/permissions.rs +++ b/openless-all/app/src-tauri/src/permissions.rs @@ -244,9 +244,197 @@ mod platform { } } -// ─────────────────────────── Windows / 其他 ─────────────────────────── +// ─────────────────────────── Android ─────────────────────────── -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "android")] +mod platform { + use super::PermissionStatus; + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + use cpal::{SampleFormat, StreamConfig}; + use std::time::Duration; + + const RECORD_AUDIO_PERMISSION: &str = "android.permission.RECORD_AUDIO"; + + pub fn check_accessibility() -> PermissionStatus { + PermissionStatus::NotApplicable + } + + pub fn request_accessibility() -> PermissionStatus { + PermissionStatus::NotApplicable + } + + /// Android 麦克风:JNI 查 runtime permission,再用 cpal 短探针确认可用。 + pub fn check_microphone() -> PermissionStatus { + match android_check_record_audio_permission() { + Ok(PermissionStatus::Granted) => probe_microphone_with_cpal(), + Ok(other) => other, + Err(err) => { + log::warn!("[mic] Android permission JNI check failed: {err}; falling back to cpal probe"); + probe_microphone_with_cpal() + } + } + } + + pub fn request_microphone() -> PermissionStatus { + match android_request_record_audio_permission() { + Ok(()) => { + std::thread::sleep(Duration::from_millis(250)); + check_microphone() + } + Err(err) => { + log::warn!("[mic] Android permission request failed: {err}"); + // 避免 onboarding 永远卡在 NotDetermined:请求失败时回落 cpal 探针, + // 仍不可用则返回 Denied 而不是 NotDetermined。 + match probe_microphone_with_cpal() { + PermissionStatus::NotDetermined => PermissionStatus::Denied, + other => other, + } + } + } + } + + fn probe_microphone_with_cpal() -> PermissionStatus { + let host = cpal::default_host(); + let Some(device) = host.default_input_device() else { + log::warn!("[mic] Android: no default input device"); + return PermissionStatus::Denied; + }; + let supported = match device.default_input_config() { + Ok(config) => config, + Err(err) => return classify_audio_probe_error(err.to_string()), + }; + let sample_format = supported.sample_format(); + let config: StreamConfig = supported.config(); + match probe_input_stream(&device, &config, sample_format) { + Ok(()) => PermissionStatus::Granted, + Err(message) => classify_audio_probe_error(message), + } + } + + fn classify_audio_probe_error(message: String) -> PermissionStatus { + let lower = message.to_lowercase(); + log::warn!("[mic] Android input probe failed: {message}"); + if lower.contains("denied") + || lower.contains("permission") + || lower.contains("authoriz") + || lower.contains("access") + { + PermissionStatus::Denied + } else if lower.contains("not determined") || lower.contains("notdetermined") { + PermissionStatus::NotDetermined + } else { + PermissionStatus::Denied + } + } + + fn probe_input_stream( + device: &cpal::Device, + config: &StreamConfig, + sample_format: SampleFormat, + ) -> Result<(), String> { + let err_cb = |err| log::warn!("[mic] Android probe stream error: {err}"); + + macro_rules! build_probe { + ($t:ty) => { + device + .build_input_stream::<$t, _, _>( + config, + move |_data: &[$t], _info| {}, + err_cb, + None, + ) + .map_err(|e| e.to_string()) + }; + } + + let stream = match sample_format { + SampleFormat::F32 => build_probe!(f32), + SampleFormat::I16 => build_probe!(i16), + SampleFormat::U16 => build_probe!(u16), + SampleFormat::I32 => build_probe!(i32), + SampleFormat::I8 => build_probe!(i8), + SampleFormat::U8 => build_probe!(u8), + other => Err(format!("unsupported sample format: {other:?}")), + }?; + + stream.play().map_err(|e| e.to_string())?; + std::thread::sleep(Duration::from_millis(120)); + drop(stream); + Ok(()) + } + + fn android_check_record_audio_permission() -> Result { + with_main_activity(|env, activity| { + let permission = env + .new_string(RECORD_AUDIO_PERMISSION) + .map_err(|e| format!("new permission string: {e}"))?; + + let granted = env + .call_method( + activity, + "checkSelfPermission", + "(Ljava/lang/String;)I", + &[jni::objects::JValue::Object(&permission)], + ) + .map_err(|e| format!("Activity.checkSelfPermission: {e}"))? + .i() + .map_err(|e| format!("checkSelfPermission result: {e}"))?; + + // PackageManager.PERMISSION_GRANTED == 0 + Ok(if granted == 0 { + PermissionStatus::Granted + } else { + PermissionStatus::Denied + }) + }) + } + + fn android_request_record_audio_permission() -> Result<(), String> { + with_main_activity(|env, activity| { + let permission = env + .new_string(RECORD_AUDIO_PERMISSION) + .map_err(|e| format!("new permission string: {e}"))?; + let permissions = env + .new_object_array(1, "java/lang/String", &permission) + .map_err(|e| format!("new permission array: {e}"))?; + + env.call_method( + activity, + "requestPermissions", + "([Ljava/lang/String;I)V", + &[ + jni::objects::JValue::Object(&permissions), + jni::objects::JValue::Int(0x4f50_4c53), // "OPLS" + ], + ) + .map_err(|e| format!("Activity.requestPermissions: {e}"))?; + Ok(()) + }) + } + + fn with_main_activity(f: F) -> Result + where + F: FnOnce(&mut jni::JNIEnv, jni::objects::JObject) -> Result, + { + let ctx = ndk_context::android_context(); + let vm = unsafe { + jni::JavaVM::from_raw(ctx.vm().cast()) + .map_err(|e| format!("JavaVM from raw: {e}"))? + }; + let mut env = vm + .attach_current_thread() + .map_err(|e| format!("attach JNI thread: {e}"))?; + let activity = unsafe { jni::objects::JObject::from_raw(ctx.context() as jni::sys::jobject) }; + if activity.is_null() { + return Err("Android activity handle is null".into()); + } + f(&mut env, activity) + } +} + +// ─────────────────────────── Windows / Linux / 其他 ─────────────────────────── + +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] mod platform { use super::PermissionStatus; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index e4557472..c20925c4 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -48,6 +48,8 @@ const LEGACY_CREDS_FILE: &str = "credentials.json"; const KEYRING_CREDENTIALS_ACCOUNT: &str = "credentials.v1"; const KEYRING_CREDENTIALS_CHUNK_PREFIX: &str = "credentials.v1.chunk."; +#[cfg(target_os = "android")] +const ANDROID_CREDENTIALS_FILE: &str = "credentials.enc.json"; // Windows Credential Manager caps one credential blob at 2560 bytes. keyring stores // passwords as UTF-16 on Windows, so keep each JSON chunk comfortably below that. const KEYRING_CHUNK_MAX_UTF16_UNITS: usize = 1000; @@ -111,7 +113,7 @@ fn data_dir() -> Result { Ok(PathBuf::from(appdata).join("OpenLess")) } - #[cfg(all(unix, not(target_os = "macos")))] + #[cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))] { if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { if !xdg.is_empty() { @@ -124,6 +126,14 @@ fn data_dir() -> Result { .join("share") .join("OpenLess")) } + + #[cfg(target_os = "android")] + { + if let Ok(dir) = std::env::var("TAURI_ANDROID_APP_DATA_DIR") { + return Ok(PathBuf::from(dir).join("OpenLess")); + } + Ok(std::env::temp_dir().join("OpenLess")) + } } fn ensure_dir(dir: &Path) -> Result<()> { @@ -640,15 +650,54 @@ fn credentials_path() -> Result { } } +#[cfg(not(target_os = "android"))] fn keyring_entry() -> Result { keyring_entry_for(KEYRING_CREDENTIALS_ACCOUNT) } +#[cfg(not(target_os = "android"))] fn keyring_entry_for(account: &str) -> Result { keyring::Entry::new(CredentialsVault::SERVICE_NAME, account) .context("open system credential vault") } +#[cfg(target_os = "android")] +fn android_credentials_path() -> Result { + Ok(data_dir()?.join(ANDROID_CREDENTIALS_FILE)) +} + +#[cfg(target_os = "android")] +fn load_android_credentials() -> Result> { + let path = android_credentials_path()?; + if !path.exists() { + return Ok(None); + } + let bytes = fs::read(&path).with_context(|| format!("read failed: {}", path.display()))?; + if bytes.is_empty() { + return Ok(None); + } + // Stub: base64 envelope — replace with Keystore-backed AES when JNI lands. + use base64::Engine; + let decoded = base64::engine::general_purpose::STANDARD + .decode(bytes) + .context("decode android credentials envelope")?; + let root = serde_json::from_slice::(&decoded) + .context("parse android credentials json")?; + Ok(Some(root)) +} + +#[cfg(target_os = "android")] +fn save_android_credentials(root: &CredsRoot) -> Result<()> { + let cleaned = clean_credentials(root); + let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let path = android_credentials_path()?; + ensure_dir(path.parent().unwrap_or_else(|| Path::new(".")))?; + fs::write(&path, encoded).with_context(|| format!("write failed: {}", path.display()))?; + Ok(()) +} + fn clean_credentials(root: &CredsRoot) -> CredsRoot { let mut cleaned = root.clone(); cleaned.providers.asr.retain(|_, v| !v.is_empty()); @@ -742,6 +791,7 @@ fn read_chunk_manifest(json: &str) -> Option { } } +#[cfg(not(target_os = "android"))] fn get_keyring_password(account: &str) -> Result> { match keyring_entry_for(account)?.get_password() { Ok(value) => Ok(Some(value)), @@ -752,6 +802,7 @@ fn get_keyring_password(account: &str) -> Result> { } } +#[cfg(not(target_os = "android"))] fn delete_keyring_password(account: &str) { match keyring_entry_for(account).and_then(|entry| { entry @@ -762,6 +813,7 @@ fn delete_keyring_password(account: &str) { } } +#[cfg(not(target_os = "android"))] fn load_keyring_credentials() -> Result> { let Some(json_or_manifest) = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT)? else { return Ok(None); @@ -782,6 +834,7 @@ fn load_keyring_credentials() -> Result> { .context("decode system credential vault payload") } +#[cfg(not(target_os = "android"))] fn load_legacy_keyring_credentials() -> CredsRoot { match load_legacy_keyring_credentials_for_update() { Ok(root) => root, @@ -792,6 +845,7 @@ fn load_legacy_keyring_credentials() -> CredsRoot { } } +#[cfg(not(target_os = "android"))] fn load_legacy_keyring_credentials_for_update() -> Result { let mut root = CredsRoot::default(); for account in CredentialAccount::all() { @@ -805,6 +859,7 @@ fn load_legacy_keyring_credentials_for_update() -> Result { Ok(clean_credentials(&root)) } +#[cfg(not(target_os = "android"))] fn remove_legacy_keyring_credentials() { for account in CredentialAccount::all() { delete_keyring_password(account.keyring_account()); @@ -826,9 +881,12 @@ fn load_legacy_sources_without_migration() -> CredsRoot { return legacy; } - let legacy_vault = load_legacy_keyring_credentials(); - if legacy_vault_has_credentials(&legacy_vault) { - return legacy_vault; + #[cfg(not(target_os = "android"))] + { + let legacy_vault = load_legacy_keyring_credentials(); + if legacy_vault_has_credentials(&legacy_vault) { + return legacy_vault; + } } CredsRoot::default() @@ -847,15 +905,19 @@ fn migrate_legacy_sources() -> CredsRoot { fn migrate_legacy_sources_for_update() -> Result { if let Some(legacy) = load_legacy_credentials() { save_credentials(&legacy)?; + #[cfg(not(target_os = "android"))] remove_legacy_keyring_credentials(); return Ok(legacy); } - let legacy_vault = load_legacy_keyring_credentials_for_update()?; - if legacy_vault_has_credentials(&legacy_vault) { - save_credentials(&legacy_vault)?; - remove_legacy_keyring_credentials(); - return Ok(legacy_vault); + #[cfg(not(target_os = "android"))] + { + let legacy_vault = load_legacy_keyring_credentials_for_update()?; + if legacy_vault_has_credentials(&legacy_vault) { + save_credentials(&legacy_vault)?; + remove_legacy_keyring_credentials(); + return Ok(legacy_vault); + } } Ok(CredsRoot::default()) @@ -865,6 +927,22 @@ fn load_credentials() -> CredsRoot { if let Some(cached) = credentials_cache().lock().as_ref().cloned() { return cached; } + + #[cfg(target_os = "android")] + { + let root = match load_android_credentials() { + Ok(Some(root)) => root, + Ok(None) => CredsRoot::default(), + Err(e) => { + log::warn!("[vault] android credential read failed: {e}"); + CredsRoot::default() + } + }; + store_credentials_cache(&root); + return root; + } + + #[cfg(not(target_os = "android"))] match load_keyring_credentials() { Ok(Some(root)) => { // 不在这里调 remove_legacy_keyring_credentials() —— 它内部对每个 @@ -901,6 +979,18 @@ fn load_credentials_for_update() -> Result { if let Some(cached) = credentials_cache().lock().as_ref().cloned() { return Ok(cached); } + + #[cfg(target_os = "android")] + { + let root = match load_android_credentials()? { + Some(root) => root, + None => CredsRoot::default(), + }; + store_credentials_cache(&root); + return Ok(root); + } + + #[cfg(not(target_os = "android"))] match load_keyring_credentials() { Ok(Some(root)) => { // 同 load_credentials:不再每次 update 都尝试 delete legacy keyring @@ -924,6 +1014,16 @@ fn load_credentials_for_update() -> Result { fn save_credentials(root: &CredsRoot) -> Result<()> { let cleaned = clean_credentials(root); + + #[cfg(target_os = "android")] + { + save_android_credentials(&cleaned)?; + store_credentials_cache(&cleaned); + return Ok(()); + } + + #[cfg(not(target_os = "android"))] + { let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; let previous_manifest = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT) .ok() @@ -977,6 +1077,7 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { // 见 CREDENTIALS_CACHE 的 doc。 store_credentials_cache(&cleaned); Ok(()) + } } fn lookup_account(root: &CredsRoot, account: CredentialAccount) -> Option { diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 7528cfd4..f4a1eb75 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1989,6 +1989,8 @@ pub enum HotkeyAdapterKind { MacEventTap, WindowsLowLevel, Fcitx5, + /// Mobile platforms do not expose desktop global hotkey adapters. + Unavailable, } impl HotkeyAdapterKind { @@ -1997,6 +1999,7 @@ impl HotkeyAdapterKind { HotkeyAdapterKind::MacEventTap => "macOS Event Tap", HotkeyAdapterKind::WindowsLowLevel => "Windows 低层键盘 hook", HotkeyAdapterKind::Fcitx5 => "fcitx5 输入法插件", + HotkeyAdapterKind::Unavailable => "不可用", } } } @@ -2152,6 +2155,21 @@ pub struct HotkeyCapability { impl HotkeyCapability { pub fn current() -> Self { + #[cfg(mobile)] + { + return Self { + adapter: HotkeyAdapterKind::Unavailable, + available_triggers: Vec::new(), + requires_accessibility_permission: false, + supports_modifier_only_trigger: false, + supports_side_specific_modifiers: false, + explicit_fallback_available: false, + status_hint: Some( + "移动端不支持全局热键;请使用应用内录音按钮或悬浮窗(需授权)。".into(), + ), + }; + } + #[cfg(target_os = "macos")] { Self { @@ -2258,6 +2276,101 @@ pub struct WindowsImeStatus { pub dll_path: Option, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AndroidImeState { + Enabled, + NotEnabled, + NotAndroid, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AndroidImeStatus { + pub state: AndroidImeState, + pub enabled: bool, + pub selected: bool, + pub message: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AndroidOverlayPermissionState { + Granted, + NotGranted, + NotAndroid, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AndroidOverlayStatus { + pub permission: AndroidOverlayPermissionState, + pub overlay_visible: bool, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PlatformCapabilities { + pub platform: String, + pub supports_ime_input: bool, + pub supports_overlay: bool, + pub supports_desktop_hotkey: bool, + pub supports_tray: bool, + pub supports_local_asr: bool, + pub supports_in_app_dictation: bool, + pub supports_auto_update: bool, +} + +impl PlatformCapabilities { + pub fn current() -> Self { + #[cfg(target_os = "android")] + { + Self { + platform: "android".to_string(), + supports_ime_input: true, + supports_overlay: true, + supports_desktop_hotkey: false, + supports_tray: false, + supports_local_asr: false, + supports_in_app_dictation: true, + supports_auto_update: false, + } + } + + #[cfg(all( + any(target_os = "android", target_os = "ios"), + not(target_os = "android") + ))] + { + Self { + platform: "mobile".to_string(), + supports_ime_input: false, + supports_overlay: false, + supports_desktop_hotkey: false, + supports_tray: false, + supports_local_asr: false, + supports_in_app_dictation: false, + supports_auto_update: false, + } + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + Self { + platform: "desktop".to_string(), + supports_ime_input: cfg!(target_os = "windows"), + supports_overlay: true, + supports_desktop_hotkey: true, + supports_tray: true, + supports_local_asr: cfg!(any(target_os = "macos", target_os = "windows")), + supports_in_app_dictation: false, + supports_auto_update: true, + } + } + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum HotkeyStatusState { diff --git a/openless-all/app/src-tauri/tauri.android.conf.json b/openless-all/app/src-tauri/tauri.android.conf.json new file mode 100644 index 00000000..05ae8f0a --- /dev/null +++ b/openless-all/app/src-tauri/tauri.android.conf.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "identifier": "com.openless.app", + "app": { + "windows": [ + { + "label": "main", + "title": "OpenLess", + "width": 1240, + "height": 800, + "minWidth": 360, + "minHeight": 640, + "resizable": true, + "decorations": true, + "visible": true + } + ] + } +} diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 5e79db90..d00d7cf8 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -128,6 +128,9 @@ "infoPlist": "Info.plist", "entitlements": "Entitlements.plist" }, + "android": { + "minSdkVersion": 26 + }, "windows": { "nsis": { "installMode": "perMachine", diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index 919f56dc..6dc7801a 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -9,9 +9,11 @@ import { checkMicrophonePermission, getHotkeyStatus, getSettings, + getPlatformCapabilities, handleWindowHotkeyEvent, isTauri, } from './lib/ipc'; +import type { PlatformCapabilities } from './lib/types'; import { isWindowHotkeyKeyboardCandidate, windowMouseHotkeyCode, @@ -49,7 +51,7 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force const os = forcedOs ?? detectOS(); // Windows 启动不应被权限探测阻塞首屏。 const [gate, setGate] = useState('ready'); - + const [platformCaps, setPlatformCaps] = useState(null); useEffect(() => { applyAppTheme(readAppTheme()); const syncTheme = (event: StorageEvent) => { @@ -60,6 +62,11 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force return () => window.removeEventListener('storage', syncTheme); }, []); + useEffect(() => { + if (!isTauri) return; + void getPlatformCapabilities().then(setPlatformCaps); + }, []); + useEffect(() => { if (!isTauri) return; let cancelled = false; @@ -105,13 +112,26 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force if (!isTauri) return; let cancelled = false; - if (os === 'win') { - // 超时保护:50 次 × 200ms = 10s。hotkey hook 永远 starting(被反作弊 / EDR - // / UAC 拦)时不让 UI 死锁灰屏,过 10s 强 setGate('ready') 让用户进 - // Permissions 页看 hotkey_status.lastError 处理。详见 issue #163。 - const POLL_INTERVAL_MS = 200; - const POLL_MAX_ATTEMPTS = 50; - const pollHotkeyStatus = async () => { + void (async () => { + const caps = await getPlatformCapabilities(); + if (cancelled) return; + + if (caps.platform === 'android') { + const m = await checkMicrophonePermission(); + if (cancelled) return; + // notDetermined is non-blocking on Android — show grant flow in-app instead + // of trapping users on onboarding while JNI/runtime permission is pending. + const blocked = m === 'denied' || m === 'restricted'; + setGate(blocked ? 'onboarding' : 'ready'); + return; + } + + if (os === 'win') { + // 超时保护:50 次 × 200ms = 10s。hotkey hook 永远 starting(被反作弊 / EDR + // / UAC 拦)时不让 UI 死锁灰屏,过 10s 强 setGate('ready') 让用户进 + // Permissions 页看 hotkey_status.lastError 处理。详见 issue #163。 + const POLL_INTERVAL_MS = 200; + const POLL_MAX_ATTEMPTS = 50; let attempts = 0; while (!cancelled && attempts < POLL_MAX_ATTEMPTS) { attempts += 1; @@ -129,19 +149,9 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force ); setGate('ready'); } - }; - void pollHotkeyStatus().catch(error => { - console.warn('[startup] hotkey status polling failed', error); - if (!cancelled) { - setGate('ready'); - } - }); - return () => { - cancelled = true; - }; - } + return; + } - (async () => { const [a, m] = await Promise.all([ checkAccessibilityPermission(), checkMicrophonePermission(), @@ -150,7 +160,13 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force const aOk = a === 'granted' || a === 'notApplicable'; const mOk = m === 'granted' || m === 'notApplicable'; setGate(aOk && mOk ? 'ready' : 'onboarding'); - })(); + })().catch(error => { + console.warn('[startup] permission gate failed', error); + if (!cancelled) { + setGate('ready'); + } + }); + return () => { cancelled = true; }; @@ -192,7 +208,7 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force return ( {gate === 'onboarding' ? setGate('ready')} /> : } - {gate === 'ready' && } + {gate === 'ready' && platformCaps?.supportsAutoUpdate === true && } ); } diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index c6a5f096..0d0cfa0e 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -2,30 +2,21 @@ // 状态机 + 对话框 UI。两边各自调用 useAutoUpdate(),dialog 渲染条件相同。 // // 渠道感知:check 不再走 plugin-updater 的 JS check()(它只看 tauri.conf 配的 -// Stable manifest URL),改为 invoke('app_check_update_with_channel')。 +// Stable manifest URL),改为 appCheckUpdateWithChannel()(ipc 层按 +// supportsAutoUpdate 在 Android 上 no-op)。 // Rust 那边按 prefs.update_channel 决定 manifest URL;返回的 metadata 直接 // `new Update(metadata)` 复用 plugin 的 download / install / close 实现, // 我们不重复造下载和签名校验。 import { useEffect, useRef, useState } from 'react'; -import { invoke } from '@tauri-apps/api/core'; import type { DownloadEvent } from '@tauri-apps/plugin-updater'; import { Update } from '@tauri-apps/plugin-updater'; import { useTranslation } from 'react-i18next'; -import { isTauri, restartApp, type UpdateChannel } from '../lib/ipc'; +import { appCheckUpdateWithChannel, isTauri, restartApp, type AppUpdateMetadata, type UpdateChannel } from '../lib/ipc'; import { Btn } from '../pages/_atoms'; const UPDATE_CHECK_TIMEOUT_MS = 15_000; -interface AppUpdateMetadata { - rid: number; - currentVersion: string; - version: string; - date?: string | null; - body?: string | null; - rawJson: Record; -} - export type UpdateStatus = | 'idle' | 'checking' @@ -102,10 +93,10 @@ export function useAutoUpdate(): UseAutoUpdate { } // Rust 侧按 update_channel 拼 manifest URL:Stable → tauri.conf 默认; // Beta → fetch_latest_beta_release 拼出 -beta manifest URL 后再 check。 - const metadata = await invoke('app_check_update_with_channel', { - timeoutMs: UPDATE_CHECK_TIMEOUT_MS, - channel: channel ?? null, - }); + const metadata = await appCheckUpdateWithChannel( + UPDATE_CHECK_TIMEOUT_MS, + channel ?? null, + ); if (!metadata) { setStatus('none'); return; diff --git a/openless-all/app/src/components/AutoUpdateGate.tsx b/openless-all/app/src/components/AutoUpdateGate.tsx index a1953dac..7ff1b6b7 100644 --- a/openless-all/app/src/components/AutoUpdateGate.tsx +++ b/openless-all/app/src/components/AutoUpdateGate.tsx @@ -2,8 +2,10 @@ // 受 prefs.autoUpdateCheck 开关控制;关闭时只走 Settings → 关于 的手动按钮。 // 找到新版本时直接挂 UpdateDialog;不弹自定义通知,沿用既有 dialog 视觉。 -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { isDialogStatus, UpdateDialog, useAutoUpdate } from './AutoUpdate'; +import { getPlatformCapabilities } from '../lib/ipc'; +import type { PlatformCapabilities } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000; @@ -12,7 +14,12 @@ const STARTUP_DELAY_MS = 4_000; export function AutoUpdateGate() { const { prefs } = useHotkeySettings(); const u = useAutoUpdate(); - const enabled = prefs?.autoUpdateCheck ?? true; + const [platformCaps, setPlatformCaps] = useState(null); + const enabled = (prefs?.autoUpdateCheck ?? true) && platformCaps?.supportsAutoUpdate === true; + + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); // 用 ref 保持 tick 闭包始终读到最新的 useAutoUpdate 返回值。 // 之前直接捕获 `u` 会让 60min interval 触发时读旧 status 闭包——例如用户已经 @@ -43,6 +50,8 @@ export function AutoUpdateGate() { }; }, [enabled]); + if (platformCaps?.supportsAutoUpdate !== true) return null; + if (!isDialogStatus(u.status)) return null; return ( { - const acknowledgedValue = window.localStorage.getItem(HOTKEY_MODE_MIGRATION_ACK_KEY); - const deferredValue = window.sessionStorage.getItem(HOTKEY_MODE_MIGRATION_DEFERRED_KEY); - if (shouldShowHotkeyModeMigrationPrompt(acknowledgedValue, deferredValue)) { - setHotkeyModePromptOpen(true); - } + let cancelled = false; + void getPlatformCapabilities().then((caps) => { + if (cancelled || caps.platform === 'android') return; + const acknowledgedValue = window.localStorage.getItem(HOTKEY_MODE_MIGRATION_ACK_KEY); + const deferredValue = window.sessionStorage.getItem(HOTKEY_MODE_MIGRATION_DEFERRED_KEY); + if (shouldShowHotkeyModeMigrationPrompt(acknowledgedValue, deferredValue)) { + setHotkeyModePromptOpen(true); + } + }); + return () => { + cancelled = true; + }; }, []); // 之前监听的 NAVIGATE_LOCAL_ASR_EVENT 已无意义——「模型设置」独立 tab 已下线, diff --git a/openless-all/app/src/components/Onboarding.tsx b/openless-all/app/src/components/Onboarding.tsx index 2d23ed21..d5da76aa 100644 --- a/openless-all/app/src/components/Onboarding.tsx +++ b/openless-all/app/src/components/Onboarding.tsx @@ -8,12 +8,13 @@ import { useTranslation } from 'react-i18next'; import { checkAccessibilityPermission, checkMicrophonePermission, + getPlatformCapabilities, openSystemSettings, requestAccessibilityPermission, requestMicrophonePermission, } from '../lib/ipc'; import { getHotkeyTriggerLabel } from '../lib/hotkey'; -import type { PermissionStatus } from '../lib/types'; +import type { PermissionStatus, PlatformCapabilities } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; interface OnboardingProps { @@ -27,6 +28,15 @@ export function Onboarding({ onComplete }: OnboardingProps) { const [busy, setBusy] = useState(false); const refreshTimeoutRef = useRef(null); const { capability } = useHotkeySettings(); + const [platformCaps, setPlatformCaps] = useState(null); + + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); + + const isAndroid = platformCaps?.platform === 'android'; + const requiresAccessibility = + !isAndroid && !!capability?.requiresAccessibilityPermission; const refresh = async () => { const [a, m] = await Promise.all([ @@ -35,12 +45,15 @@ export function Onboarding({ onComplete }: OnboardingProps) { ]); setAccessibility(a); setMicrophone(m); - if ((a === 'granted' || a === 'notApplicable') && (m === 'granted' || m === 'notApplicable')) { + const aOk = !requiresAccessibility || a === 'granted' || a === 'notApplicable'; + const mOk = m === 'granted' || m === 'notApplicable'; + if (aOk && mOk) { onComplete(); } }; useEffect(() => { + if (!platformCaps) return; refresh(); const id = window.setInterval(refresh, 1000); // 用户从系统设置切回来时立刻刷新 @@ -51,7 +64,7 @@ export function Onboarding({ onComplete }: OnboardingProps) { window.removeEventListener('focus', onFocus); if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current); }; - }, []); + }, [isAndroid, requiresAccessibility]); const onGrantAccessibility = async () => { setBusy(true); @@ -82,6 +95,24 @@ export function Onboarding({ onComplete }: OnboardingProps) { refreshTimeoutRef.current = window.setTimeout(refresh, 800); }; + if (!platformCaps) { + return ( +
+ {t('common.loading')} +
+ ); + } + return (
+ {requiresAccessibility && ( + )} + {isAndroid && microphone !== 'granted' && microphone !== 'notApplicable' && ( + + )} +
- {t('onboarding.footerHint')} + {isAndroid ? t('onboarding.androidFooterHint') : t('onboarding.footerHint')}
diff --git a/openless-all/app/src/components/WindowChrome.tsx b/openless-all/app/src/components/WindowChrome.tsx index e7068354..3a2d25bf 100644 --- a/openless-all/app/src/components/WindowChrome.tsx +++ b/openless-all/app/src/components/WindowChrome.tsx @@ -1,6 +1,6 @@ import { type CSSProperties, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; -export type OS = 'mac' | 'win' | 'linux'; +export type OS = 'mac' | 'win' | 'linux' | 'android'; export function detectOS(): OS { if (typeof navigator === 'undefined') return 'mac'; @@ -9,6 +9,7 @@ export function detectOS(): OS { ).userAgentData?.platform ?? ''; const hints = `${navigator.userAgent || ''} ${navigator.platform || ''} ${uaDataPlatform}`; if (/Mac|iPhone|iPad|iPod/.test(hints)) return 'mac'; + if (/Android/i.test(hints)) return 'android'; if (/Windows|Win32|Win64/.test(hints)) return 'win'; if (/Linux|X11|Wayland/.test(hints)) return 'linux'; return 'mac'; @@ -33,8 +34,8 @@ export function WindowChrome({ }: WindowChromeProps) { // Windows: decorations:true 时外层不画圆角/边框/阴影/标题栏,避免与原生窗口重叠。 // Linux: decorations:false 时外层画 14px 圆角 + 自定义标题栏。 - const shellRadius = os === 'mac' ? 0 : os === 'win' ? 0 : 14; - const consoleRadius = os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : 14; + const shellRadius = os === 'mac' ? 0 : os === 'win' || os === 'android' ? 0 : 14; + const consoleRadius = os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : os === 'android' ? 0 : 14; const titlebarHeight = os === 'mac' ? MAC_TITLEBAR_HEIGHT : os === 'linux' ? LINUX_TITLEBAR_HEIGHT : 0; // macOS / Windows 共用半透明玻璃 background + backdropFilter。 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 64a58c4a..936da113 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -223,6 +223,8 @@ export const en: typeof zhCN = { actionRequestMic: 'Request access', accessibilityHint: 'After granting, you must **fully quit OpenLess** and reopen it (a macOS TCC requirement).', footerHint: 'This onboarding closes automatically once both permissions are granted. If it persists, quit OpenLess from the menu bar and relaunch.', + androidContinue: 'Continue to app', + androidFooterHint: 'Microphone access is required for dictation. Tap Request access above, or continue and grant it later from Overview.', }, overview: { kicker: 'DASHBOARD', @@ -258,6 +260,20 @@ export const en: typeof zhCN = { recentLoadFailed: 'Could not load recent transcripts. Please retry.', historyRetry: 'Retry', weekDays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + inAppDictation: { + title: 'In-app dictation', + start: 'Start recording', + stop: 'Stop recording', + idle: 'Tap to start recording', + recording: 'Recording…', + processing: 'Processing…', + }, + androidMicBanner: { + title: 'Microphone permission needed', + desc: 'Grant microphone access to use in-app dictation and voice input.', + grant: 'Request access', + openSettings: 'Open settings', + }, }, history: { kicker: 'HISTORY', @@ -769,6 +785,7 @@ export const en: typeof zhCN = { disable: 'Disable', confirmHint: 'Click ✓ on the capsule', notSupported: 'Not yet supported', + androidReadOnly: 'Global shortcuts are not available on Android. Use the record button on the overview page.', }, permissions: { title: 'Permissions', @@ -799,6 +816,10 @@ export const en: typeof zhCN = { windowsImeDesc: 'Temporarily switches to the OpenLess TSF IME during voice sessions to avoid clipboard insertion limits.', windowsImeInstalled: 'Installed', windowsImeUnavailable: 'Unavailable', + androidImeLabel: 'Input method (IME)', + androidImePlaceholder: 'Coming soon — enable OpenLess keyboard in system settings', + androidOverlayLabel: 'Floating overlay', + androidOverlayPlaceholder: 'Coming soon — grant overlay permission in system settings', windowsIme: { installed: 'Installed. Voice input temporarily switches to the OpenLess IME.', notInstalled: 'Not installed. OpenLess is using the clipboard/WM_PASTE fallback.', @@ -982,6 +1003,7 @@ export const en: typeof zhCN = { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows low-level keyboard hook', fcitx5: 'fcitx5 input method plugin', + unavailable: 'Unavailable', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 5b839ed4..46e6c387 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -225,6 +225,8 @@ export const ja: typeof zhCN = { actionRequestMic: '許可ダイアログを表示', accessibilityHint: '許可後は **OpenLess を完全に終了** してから再起動してください(macOS TCC の仕様)。', footerHint: 'すべての権限が揃うとこのガイドは自動で閉じます。閉じない場合はメニューバーの OpenLess → 終了 から再起動してください。', + androidContinue: 'アプリに進む', + androidFooterHint: '音声入力にはマイク権限が必要です。上の「許可ダイアログを表示」をタップするか、先にアプリへ進み、概要ページで後から許可してください。', }, overview: { kicker: 'DASHBOARD', @@ -260,6 +262,20 @@ export const ja: typeof zhCN = { recentLoadFailed: '最近の認識を読み込めません。再試行してください。', historyRetry: '再試行', weekDays: ['日', '月', '火', '水', '木', '金', '土'], + inAppDictation: { + title: 'アプリ内音声入力', + start: '録音開始', + stop: '録音停止', + idle: 'タップして録音開始', + recording: '録音中…', + processing: '処理中…', + }, + androidMicBanner: { + title: 'マイク権限が必要です', + desc: 'マイクを許可すると、アプリ内音声入力が使えます。', + grant: '許可ダイアログを表示', + openSettings: '設定を開く', + }, }, history: { kicker: 'HISTORY', @@ -771,6 +787,7 @@ export const ja: typeof zhCN = { disable: '無効化', confirmHint: '右側の ✓ をクリック', notSupported: '未対応', + androidReadOnly: 'Android ではグローバルショートカットは使えません。概要ページの録音ボタンを使ってください。', }, permissions: { title: '権限', @@ -801,6 +818,10 @@ export const ja: typeof zhCN = { windowsImeDesc: '音声セッション中に OpenLess TSF IME へ一時的に切り替え、クリップボード入力の制限を回避します。', windowsImeInstalled: 'インストール済み', windowsImeUnavailable: '利用不可', + androidImeLabel: '入力メソッド (IME)', + androidImePlaceholder: '近日対応 — システム設定で OpenLess キーボードを有効化', + androidOverlayLabel: 'フローティングオーバーレイ', + androidOverlayPlaceholder: '近日対応 — システム設定でオーバーレイ権限を付与', windowsIme: { installed: 'インストール済み。音声入力時に OpenLess IME へ一時的に切り替えます。', notInstalled: '未インストール。OpenLess は現在クリップボード / WM_PASTE フォールバックを使用しています。', @@ -984,6 +1005,7 @@ export const ja: typeof zhCN = { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低レベルキーボードフック', fcitx5: 'fcitx5 インプットメソッドプラグイン', + unavailable: '利用不可', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 0ecbf448..ea58264d 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -225,6 +225,8 @@ export const ko: typeof zhCN = { actionRequestMic: '권한 대화상자 표시', accessibilityHint: '허용 후에는 **OpenLess 를 완전히 종료** 한 다음 다시 실행해야 합니다(macOS TCC 규칙).', footerHint: '모든 권한이 부여되면 이 가이드는 자동으로 닫힙니다. 닫히지 않으면 메뉴 막대의 OpenLess → 종료 후 앱을 다시 실행해 주세요.', + androidContinue: '앱으로 계속', + androidFooterHint: '받아쓰기에는 마이크 권한이 필요합니다. 위의 권한 요청을 탭하거나, 앱으로 먼저 들어가 개요 페이지에서 나중에 허용할 수 있습니다.', }, overview: { kicker: 'DASHBOARD', @@ -260,6 +262,20 @@ export const ko: typeof zhCN = { recentLoadFailed: '최근 인식 기록을 불러올 수 없습니다. 다시 시도해 주세요.', historyRetry: '다시 시도', weekDays: ['일', '월', '화', '수', '목', '금', '토'], + inAppDictation: { + title: '앱 내 받아쓰기', + start: '녹음 시작', + stop: '녹음 중지', + idle: '탭하여 녹음 시작', + recording: '녹음 중…', + processing: '처리 중…', + }, + androidMicBanner: { + title: '마이크 권한이 필요합니다', + desc: '마이크를 허용하면 앱 내 받아쓰기와 음성 입력을 사용할 수 있습니다.', + grant: '권한 요청', + openSettings: '설정 열기', + }, }, history: { kicker: 'HISTORY', @@ -771,6 +787,7 @@ export const ko: typeof zhCN = { disable: '비활성화', confirmHint: '오른쪽 ✓ 클릭', notSupported: '지원되지 않음', + androidReadOnly: 'Android에서는 전역 단축키를 사용할 수 없습니다. 개요 페이지의 녹음 버튼을 사용하세요.', }, permissions: { title: '권한', @@ -801,6 +818,10 @@ export const ko: typeof zhCN = { windowsImeDesc: '음성 세션 동안 OpenLess TSF 입력기로 일시적으로 전환하여 클립보드 입력 제한을 회피하기 위해 사용.', windowsImeInstalled: '설치됨', windowsImeUnavailable: '사용 불가', + androidImeLabel: '입력기 (IME)', + androidImePlaceholder: '곧 제공 — 시스템 설정에서 OpenLess 키보드 활성화', + androidOverlayLabel: '플로팅 오버레이', + androidOverlayPlaceholder: '곧 제공 — 시스템 설정에서 오버레이 권한 부여', windowsIme: { installed: '설치됨. 음성 입력 시 OpenLess 입력기로 일시 전환됩니다.', notInstalled: '설치되지 않음. OpenLess 는 현재 클립보드 / WM_PASTE 폴백을 사용합니다.', @@ -984,6 +1005,7 @@ export const ko: typeof zhCN = { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 저수준 키보드 후크', fcitx5: 'fcitx5 입력기 플러그인', + unavailable: '사용 불가', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 38a1bc08..5dd35753 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -221,6 +221,8 @@ export const zhCN = { actionRequestMic: '弹出授权', accessibilityHint: '授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。', footerHint: '授权全部完成后此引导自动关闭。如果一直不消失,从菜单栏 OpenLess → 退出,重新打开 App。', + androidContinue: '先进入应用', + androidFooterHint: '听写需要麦克风权限。可点击上方「弹出授权」,或先进入应用后在概览页继续授权。', }, overview: { kicker: 'DASHBOARD', @@ -256,6 +258,20 @@ export const zhCN = { recentLoadFailed: '无法读取最近识别,请重试。', historyRetry: '重试', weekDays: ['日', '一', '二', '三', '四', '五', '六'], + inAppDictation: { + title: '应用内录音', + start: '开始录音', + stop: '停止录音', + idle: '点击开始录音', + recording: '录音中…', + processing: '处理中…', + }, + androidMicBanner: { + title: '需要麦克风权限', + desc: '授权麦克风后可使用应用内录音与语音输入。', + grant: '弹出授权', + openSettings: '打开系统设置', + }, }, history: { kicker: 'HISTORY', @@ -767,6 +783,7 @@ export const zhCN = { disable: '停用', confirmHint: '点击右侧 ✓', notSupported: '暂未支持', + androidReadOnly: 'Android 不支持全局快捷键,请在概览页使用录音按钮。', }, permissions: { title: '权限', @@ -797,6 +814,10 @@ export const zhCN = { windowsImeDesc: '语音输入时临时切到 OpenLess TSF,绕过剪贴板限制。', windowsImeInstalled: '已安装', windowsImeUnavailable: '不可用', + androidImeLabel: '输入法 (IME)', + androidImePlaceholder: '即将推出 — 请在系统设置中启用 OpenLess 输入法', + androidOverlayLabel: '悬浮窗', + androidOverlayPlaceholder: '即将推出 — 请在系统设置中授予悬浮窗权限', windowsIme: { installed: '已安装,按需切到 OpenLess 输入法。', notInstalled: '未安装,走剪贴板 / WM_PASTE 兜底。', @@ -980,6 +1001,7 @@ export const zhCN = { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低层键盘 hook', fcitx5: 'fcitx5 输入法插件', + unavailable: '不可用', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index b98030e4..d6002a3f 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -223,6 +223,8 @@ export const zhTW: typeof zhCN = { actionRequestMic: '彈出授權', accessibilityHint: '授權後必須**完全退出 OpenLess** 再重新打開(macOS TCC 規則)。', footerHint: '授權全部完成後此引導自動關閉。如果一直不消失,從菜單欄 OpenLess → 退出,重新打開 App。', + androidContinue: '先進入應用', + androidFooterHint: '聽寫需要麥克風權限。可點擊上方「彈出授權」,或先進入應用後在概覽頁繼續授權。', }, overview: { kicker: 'DASHBOARD', @@ -258,6 +260,20 @@ export const zhTW: typeof zhCN = { recentLoadFailed: '無法讀取最近識別,請重試。', historyRetry: '重試', weekDays: ['日', '一', '二', '三', '四', '五', '六'], + inAppDictation: { + title: '應用內錄音', + start: '開始錄音', + stop: '停止錄音', + idle: '點擊開始錄音', + recording: '錄音中…', + processing: '處理中…', + }, + androidMicBanner: { + title: '需要麥克風權限', + desc: '授權麥克風後可使用應用內錄音與語音輸入。', + grant: '彈出授權', + openSettings: '打開系統設置', + }, }, history: { kicker: 'HISTORY', @@ -769,6 +785,7 @@ export const zhTW: typeof zhCN = { disable: '停用', confirmHint: '點擊右側 ✓', notSupported: '暫未支持', + androidReadOnly: 'Android 不支援全域快捷鍵,請在概覽頁使用錄音按鈕。', }, permissions: { title: '權限', @@ -799,6 +816,10 @@ export const zhTW: typeof zhCN = { windowsImeDesc: '用於在語音會話期間臨時切換到 OpenLess TSF 輸入法,避免剪貼板插入限制。', windowsImeInstalled: '已安裝', windowsImeUnavailable: '不可用', + androidImeLabel: '輸入法 (IME)', + androidImePlaceholder: '即將推出 — 請在系統設定中啟用 OpenLess 輸入法', + androidOverlayLabel: '懸浮窗', + androidOverlayPlaceholder: '即將推出 — 請在系統設定中授予懸浮窗權限', windowsIme: { installed: '已安裝。語音輸入時會臨時切換到 OpenLess 輸入法。', notInstalled: '未安裝。OpenLess 正在使用剪貼板 / WM_PASTE 兜底。', @@ -982,6 +1003,7 @@ export const zhTW: typeof zhCN = { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低層鍵盤 hook', fcitx5: 'fcitx5 輸入法插件', + unavailable: '不可用', }, }, localAsr: { diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 8f8d246b..0dd5346d 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -15,6 +15,7 @@ import type { HotkeyStatus, MicrophoneDevice, PermissionStatus, + PlatformCapabilities, CodingAgentPermissionMode, PolishMode, QaHotkeyBinding, @@ -29,13 +30,16 @@ import type { VocabPresetStore, WindowsImeStatus, } from "./types" -export type { UpdateChannel } from "./types" +export type { UpdateChannel, PlatformCapabilities } from "./types" import { OL_DATA } from "./mockData" import { defaultAppShortcutModifiers, defaultQaShortcut, formatComboLabel, } from "./hotkey" +import { + getPlatformCapabilities as loadPlatformCapabilities, +} from "./platform" declare global { interface Window { @@ -47,6 +51,47 @@ const isTauri = globalThis.window !== undefined && "__TAURI_INTERNALS__" in globalThis.window +let platformCapsPromise: Promise | null = null + +async function platformCapabilities(): Promise { + platformCapsPromise ??= loadPlatformCapabilities() + return platformCapsPromise +} + +export async function getPlatformCapabilities(): Promise { + return platformCapabilities() +} + +export { isAndroid, isDesktop, isMobile } from "./platform" + +const androidHotkeyCapability: HotkeyCapability = { + adapter: "unavailable", + availableTriggers: [], + requiresAccessibilityPermission: false, + supportsModifierOnlyTrigger: false, + supportsSideSpecificModifiers: false, + explicitFallbackAvailable: false, + statusHint: + "移动端不支持全局热键;请使用应用内录音按钮或悬浮窗(需授权)。", +} + +const androidHotkeyStatus: HotkeyStatus = { + adapter: "unavailable", + state: "failed", + message: "移动端不支持全局热键", + lastError: { + code: "unavailable", + message: "Global hotkeys are not available on mobile", + }, +} + +const androidWindowsImeStatus: WindowsImeStatus = { + state: "notWindows", + usingTsfBackend: false, + message: "Not available on Android", + dllPath: null, +} + export async function invokeOrMock( cmd: string, args: Record | undefined, @@ -545,40 +590,95 @@ export interface LatestBetaRelease { publishedAt: string } +export interface AppUpdateMetadata { + rid: number + currentVersion: string + version: string + date?: string | null + body?: string | null + rawJson: Record +} + export function getUpdateChannel(): Promise { - return invokeOrMock( - "get_update_channel", - undefined, - () => "stable" as UpdateChannel, - ) + return platformCapabilities().then((caps) => { + if (!caps.supportsAutoUpdate) { + return "stable" as UpdateChannel + } + return invokeOrMock( + "get_update_channel", + undefined, + () => "stable" as UpdateChannel, + ) + }) } export function setUpdateChannel(channel: UpdateChannel): Promise { - return invokeOrMock("set_update_channel", { channel }, () => undefined) + return platformCapabilities().then((caps) => { + if (!caps.supportsAutoUpdate) { + return undefined + } + return invokeOrMock("set_update_channel", { channel }, () => undefined) + }) } export function fetchLatestBetaRelease(): Promise { - return invokeOrMock("fetch_latest_beta_release", undefined, () => null) + return platformCapabilities().then((caps) => { + if (!caps.supportsAutoUpdate) { + return null + } + return invokeOrMock("fetch_latest_beta_release", undefined, () => null) + }) +} + +export function appCheckUpdateWithChannel( + timeoutMs: number, + channel?: UpdateChannel | null, +): Promise { + return platformCapabilities().then((caps) => { + if (!caps.supportsAutoUpdate) { + return null + } + return invokeOrMock( + "app_check_update_with_channel", + { timeoutMs, channel: channel ?? null }, + () => null, + ) + }) } export function getHotkeyStatus(): Promise { - return invokeOrMock("get_hotkey_status", undefined, () => mockHotkeyStatus) + return platformCapabilities().then((caps) => { + if (!caps.supportsDesktopHotkey) { + return androidHotkeyStatus + } + return invokeOrMock("get_hotkey_status", undefined, () => mockHotkeyStatus) + }) } export function getHotkeyCapability(): Promise { - return invokeOrMock( - "get_hotkey_capability", - undefined, - () => mockHotkeyCapability, - ) + return platformCapabilities().then((caps) => { + if (!caps.supportsDesktopHotkey) { + return androidHotkeyCapability + } + return invokeOrMock( + "get_hotkey_capability", + undefined, + () => mockHotkeyCapability, + ) + }) } export function getWindowsImeStatus(): Promise { - return invokeOrMock( - "get_windows_ime_status", - undefined, - () => mockWindowsImeStatus, - ) + return platformCapabilities().then((caps) => { + if (caps.platform === "android") { + return androidWindowsImeStatus + } + return invokeOrMock( + "get_windows_ime_status", + undefined, + () => mockWindowsImeStatus, + ) + }) } export interface NetworkCheckResult { @@ -804,11 +904,16 @@ export function handleWindowHotkeyEvent( code: string, repeat: boolean, ): Promise { - return invokeOrMock( - "handle_window_hotkey_event", - { event_type: eventType, key, code, repeat }, - () => undefined, - ) + return platformCapabilities().then((caps) => { + if (!caps.supportsDesktopHotkey) { + return undefined + } + return invokeOrMock( + "handle_window_hotkey_event", + { event_type: eventType, key, code, repeat }, + () => undefined, + ) + }) } // ── Polish ───────────────────────────────────────────────────────────── diff --git a/openless-all/app/src/lib/platform.ts b/openless-all/app/src/lib/platform.ts new file mode 100644 index 00000000..b9a57058 --- /dev/null +++ b/openless-all/app/src/lib/platform.ts @@ -0,0 +1,122 @@ +// Platform capability detection for desktop vs Android APK targets. +// Prefers Tauri `get_platform_capabilities`; falls back to UA / OS heuristics. + +import { detectOS } from '../components/WindowChrome'; +import type { PlatformCapabilities, PlatformKind } from './types'; + +export type { PlatformCapabilities, PlatformKind }; + +let cachedCapabilities: PlatformCapabilities | null = null; + +function detectAndroidFromUa(): boolean { + if (typeof navigator === 'undefined') return false; + const uaDataPlatform = + (navigator as Navigator & { userAgentData?: { platform?: string } }) + .userAgentData?.platform ?? ''; + const hints = `${navigator.userAgent || ''} ${navigator.platform || ''} ${uaDataPlatform}`; + return /Android/i.test(hints); +} + +function detectIosFromUa(): boolean { + if (typeof navigator === 'undefined') return false; + const uaDataPlatform = + (navigator as Navigator & { userAgentData?: { platform?: string } }) + .userAgentData?.platform ?? ''; + const hints = `${navigator.userAgent || ''} ${navigator.platform || ''} ${uaDataPlatform}`; + return /iPhone|iPad|iPod/i.test(hints); +} + +/** Unavailable capability flags for iOS / other non-Android mobile targets. */ +const MOBILE_UNAVAILABLE: PlatformCapabilities = { + platform: 'mobile', + supportsDesktopHotkey: false, + supportsTray: false, + supportsOverlay: false, + supportsImeInput: false, + supportsLocalAsr: false, + supportsInAppDictation: false, + supportsAutoUpdate: false, +}; + +export function isAndroid(): boolean { + if (cachedCapabilities) return cachedCapabilities.platform === 'android'; + return detectOS() === 'android' || detectAndroidFromUa(); +} + +export function isMobile(): boolean { + if (cachedCapabilities) { + return ( + cachedCapabilities.platform === 'mobile' || + cachedCapabilities.platform === 'android' + ); + } + return isAndroid() || detectIosFromUa(); +} + +export function isDesktop(): boolean { + if (cachedCapabilities) return cachedCapabilities.platform === 'desktop'; + return !isMobile(); +} + +export function inferPlatformCapabilities(): PlatformCapabilities { + if (isAndroid()) { + return { + platform: 'android', + supportsDesktopHotkey: false, + supportsTray: false, + supportsOverlay: true, + supportsImeInput: true, + supportsLocalAsr: false, + supportsInAppDictation: true, + supportsAutoUpdate: false, + }; + } + + if (detectIosFromUa()) { + return MOBILE_UNAVAILABLE; + } + + const os = detectOS(); + return { + platform: 'desktop', + supportsDesktopHotkey: true, + supportsTray: true, + supportsOverlay: true, + supportsImeInput: os === 'win', + supportsLocalAsr: os === 'mac' || os === 'win', + supportsInAppDictation: false, + supportsAutoUpdate: true, + }; +} + +export async function getPlatformCapabilities(): Promise { + if (cachedCapabilities) return cachedCapabilities; + + const isTauri = + globalThis.window !== undefined && + '__TAURI_INTERNALS__' in globalThis.window; + + if (!isTauri) { + cachedCapabilities = inferPlatformCapabilities(); + return cachedCapabilities; + } + + try { + const { invoke } = await import('@tauri-apps/api/core'); + cachedCapabilities = await invoke( + 'get_platform_capabilities', + ); + return cachedCapabilities; + } catch (err) { + console.warn( + '[platform] get_platform_capabilities unavailable; using inferred defaults', + err, + ); + cachedCapabilities = inferPlatformCapabilities(); + return cachedCapabilities; + } +} + +export function getCachedPlatformCapabilities(): PlatformCapabilities | null { + return cachedCapabilities; +} diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 343c54de..f7b99537 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -78,7 +78,7 @@ export interface HotkeyBinding { keys?: HotkeyKey[] | null; } -export type HotkeyAdapterKind = 'macEventTap' | 'windowsLowLevel' | 'fcitx5'; +export type HotkeyAdapterKind = 'macEventTap' | 'windowsLowLevel' | 'fcitx5' | 'unavailable'; export interface HotkeyCapability { adapter: HotkeyAdapterKind; @@ -487,3 +487,18 @@ export type PermissionStatus = | 'notDetermined' | 'restricted' | 'notApplicable'; + +/** Runtime platform kind returned by `get_platform_capabilities`. */ +export type PlatformKind = 'desktop' | 'android' | 'mobile'; + +/** Feature flags for desktop vs Android APK UI gating. Mirrors src-tauri PlatformCapabilities. */ +export interface PlatformCapabilities { + platform: PlatformKind; + supportsDesktopHotkey: boolean; + supportsTray: boolean; + supportsOverlay: boolean; + supportsImeInput: boolean; + supportsLocalAsr: boolean; + supportsInAppDictation: boolean; + supportsAutoUpdate: boolean; +} diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 457523aa..c1c58e2a 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -4,8 +4,25 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { formatComboLabel } from '../lib/hotkey'; -import { getCredentials, listHistory } from '../lib/ipc'; -import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types'; +import { + checkMicrophonePermission, + getCredentials, + getPlatformCapabilities, + listHistory, + openSystemSettings, + requestMicrophonePermission, + startDictation, + stopDictation, +} from '../lib/ipc'; +import type { + CapsulePayload, + CapsuleState, + CredentialsStatus, + DictationSession, + PermissionStatus, + PlatformCapabilities, + PolishMode, +} from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; @@ -176,6 +193,9 @@ export function Overview({ onOpenHistory }: OverviewProps) { desc={t('overview.metricTotalTrend')} /> + + +
(null); + const [microphone, setMicrophone] = useState('loading'); + const [busy, setBusy] = useState(false); + + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); + + const refreshMic = useCallback(async () => { + setMicrophone(await checkMicrophonePermission()); + }, []); + + useEffect(() => { + if (platformCaps?.platform !== 'android') return; + void refreshMic(); + const onFocus = () => { void refreshMic(); }; + window.addEventListener('focus', onFocus); + return () => window.removeEventListener('focus', onFocus); + }, [platformCaps?.platform, refreshMic]); + + if (platformCaps?.platform !== 'android') return null; + if (microphone === 'loading' || microphone === 'granted' || microphone === 'notApplicable') { + return null; + } + + const onGrant = async () => { + setBusy(true); + try { + if (microphone === 'denied' || microphone === 'restricted') { + await openSystemSettings('microphone'); + } else { + const status = await requestMicrophonePermission(); + setMicrophone(status); + if (status === 'denied' || status === 'restricted') { + await openSystemSettings('microphone'); + } + } + } finally { + setBusy(false); + void refreshMic(); + } + }; + + return ( + + +
+
+ {t('overview.androidMicBanner.title')} +
+
+ {t('overview.androidMicBanner.desc')} +
+
+ void onGrant()} disabled={busy}> + {microphone === 'denied' || microphone === 'restricted' + ? t('overview.androidMicBanner.openSettings') + : t('overview.androidMicBanner.grant')} + +
+ ); +} + +function InAppDictationControl() { + const { t } = useTranslation(); + const [platformCaps, setPlatformCaps] = useState(null); + const [capsuleState, setCapsuleState] = useState('idle'); + const [busy, setBusy] = useState(false); + + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); + + useEffect(() => { + if (!platformCaps?.supportsInAppDictation) return; + let cancelled = false; + let unlisten: (() => void) | undefined; + void (async () => { + try { + const { listen } = await import('@tauri-apps/api/event'); + const handle = await listen('capsule:state', (event) => { + if (cancelled) return; + setCapsuleState(event.payload.state); + }); + if (cancelled) { + handle(); + } else { + unlisten = handle; + } + } catch { + // browser dev mock + } + })(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [platformCaps?.supportsInAppDictation]); + + if (!platformCaps?.supportsInAppDictation) { + return null; + } + + const recording = capsuleState === 'recording'; + const processing = capsuleState === 'transcribing' || capsuleState === 'polishing'; + const statusLabel = recording + ? t('overview.inAppDictation.recording') + : processing + ? t('overview.inAppDictation.processing') + : t('overview.inAppDictation.idle'); + + const onToggle = async () => { + if (busy || processing) return; + setBusy(true); + try { + if (recording) { + await stopDictation(); + } else { + await startDictation(); + } + } catch (error) { + console.error('[overview] in-app dictation toggle failed', error); + } finally { + setBusy(false); + } + }; + + return ( + + +
+
+ {t('overview.inAppDictation.title')} +
+
+ {statusLabel} +
+
+ {statusLabel} +
+ ); +} diff --git a/openless-all/app/src/pages/settings/AboutSection.tsx b/openless-all/app/src/pages/settings/AboutSection.tsx index 5cce8dc9..82ac74cf 100644 --- a/openless-all/app/src/pages/settings/AboutSection.tsx +++ b/openless-all/app/src/pages/settings/AboutSection.tsx @@ -7,7 +7,8 @@ import { useEffect, useRef, useState, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../../components/Icon'; import { Row } from '../../components/ui/Row'; -import { openExternal } from '../../lib/ipc'; +import { getPlatformCapabilities, openExternal } from '../../lib/ipc'; +import type { PlatformCapabilities } from '../../lib/types'; import { APP_VERSION_LABEL } from '../../lib/appVersion'; import { readAppTheme, setAppTheme, type AppThemeId } from '../../lib/appTheme'; import { readFontScale, setFontScale, type FontScaleId } from '../../lib/fontScale'; @@ -18,8 +19,13 @@ import { CheckUpdateButton } from './CheckUpdateButton'; export function AboutSection() { const { t } = useTranslation(); const [qqCopied, setQqCopied] = useState(false); + const [platformCaps, setPlatformCaps] = useState(null); const qqCopiedRef = useRef(null); + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); + useEffect(() => () => { if (qqCopiedRef.current) clearTimeout(qqCopiedRef.current); }, []); @@ -48,7 +54,9 @@ export function AboutSection() {
{/* 图标右上方:查正式版的检查更新按钮。Beta 渠道在「高级」页。 */} - + {platformCaps?.supportsAutoUpdate === true && ( + + )} diff --git a/openless-all/app/src/pages/settings/BetaChannelSection.tsx b/openless-all/app/src/pages/settings/BetaChannelSection.tsx index cbf40c83..5ed3b150 100644 --- a/openless-all/app/src/pages/settings/BetaChannelSection.tsx +++ b/openless-all/app/src/pages/settings/BetaChannelSection.tsx @@ -6,7 +6,8 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getUpdateChannel, setUpdateChannel, type UpdateChannel } from '../../lib/ipc'; +import { getPlatformCapabilities, getUpdateChannel, setUpdateChannel, type UpdateChannel } from '../../lib/ipc'; +import type { PlatformCapabilities } from '../../lib/types'; import { Card } from '../_atoms'; import { SectionTitle, Toggle } from './shared'; import { CheckUpdateButton } from './CheckUpdateButton'; @@ -14,6 +15,11 @@ import { CheckUpdateButton } from './CheckUpdateButton'; export function BetaChannelSection() { const { t } = useTranslation(); const [channel, setChannel] = useState('stable'); + const [platformCaps, setPlatformCaps] = useState(null); + + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); useEffect(() => { let cancelled = false; @@ -34,6 +40,8 @@ export function BetaChannelSection() { } }; + if (platformCaps?.supportsAutoUpdate !== true) return null; + return ( {t('settings.about.betaChannelLabel')} diff --git a/openless-all/app/src/pages/settings/LocalModelSection.tsx b/openless-all/app/src/pages/settings/LocalModelSection.tsx index 222cfbde..c0e6238e 100644 --- a/openless-all/app/src/pages/settings/LocalModelSection.tsx +++ b/openless-all/app/src/pages/settings/LocalModelSection.tsx @@ -2,10 +2,12 @@ // 自 Settings.tsx 的 AdvancedSection 拆出(流式输入已挪到「录音与输入」)。 // 含 Qwen3(macOS)/ Foundry Local + sherpa-onnx(Windows)三条本地引擎。 -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import type { PlatformCapabilities } from '../../lib/types'; import { useTranslation } from 'react-i18next'; import { LocalAsr } from '../LocalAsr'; import { detectOS } from '../../components/WindowChrome'; +import { getPlatformCapabilities } from '../../lib/platform'; import { setActiveAsrProvider } from '../../lib/ipc'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { Btn, Card } from '../_atoms'; @@ -17,7 +19,13 @@ export function LocalModelSection() { const os = detectOS(); const isMac = os === 'mac'; const isWin = os === 'win'; - const platformSupported = isMac || isWin; + const [platformCaps, setPlatformCaps] = useState(null); + + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); + + const platformSupported = platformCaps?.supportsLocalAsr === true; const switchSeqRef = useRef(0); const [busy, setBusy] = useState(false); // 待确认的启用目标。!== null 时中央 modal 弹出 + 背景模糊;用户点确认 → 真切; diff --git a/openless-all/app/src/pages/settings/PermissionsSection.tsx b/openless-all/app/src/pages/settings/PermissionsSection.tsx index 951576bb..83f9e442 100644 --- a/openless-all/app/src/pages/settings/PermissionsSection.tsx +++ b/openless-all/app/src/pages/settings/PermissionsSection.tsx @@ -15,9 +15,11 @@ import { requestMicrophonePermission, } from '../../lib/ipc'; import type { NetworkCheckResult } from '../../lib/ipc'; +import { getPlatformCapabilities } from '../../lib/platform'; import type { HotkeyStatus, PermissionStatus, + PlatformCapabilities, WindowsImeStatus, } from '../../lib/types'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; @@ -31,8 +33,13 @@ export function PermissionsSection() { const [hotkey, setHotkey] = useState(null); const [windowsIme, setWindowsIme] = useState(null); const [network, setNetwork] = useState(null); + const [platformCaps, setPlatformCaps] = useState(null); const { capability } = useHotkeySettings(); + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); + const refreshPermissions = async () => { const [a, m] = await Promise.all([ checkAccessibilityPermission(), @@ -60,27 +67,37 @@ export function PermissionsSection() { useEffect(() => { refreshPermissions(); - refreshHotkey(); - refreshWindowsIme(); + if (platformCaps?.supportsDesktopHotkey === true) { + refreshHotkey(); + } + if (platformCaps?.platform !== 'android') { + refreshWindowsIme(); + } refreshNetwork(); - const hotkeyId = window.setInterval(refreshHotkey, 1000); + const hotkeyId = platformCaps?.supportsDesktopHotkey === true + ? window.setInterval(refreshHotkey, 1000) + : undefined; // 麦克风检查会短暂打开输入流,避免每秒探测导致隐私指示器频繁闪烁。 const permissionId = window.setInterval(refreshPermissions, 10000); const networkId = window.setInterval(refreshNetwork, 30000); const onFocus = () => { refreshPermissions(); - refreshHotkey(); - refreshWindowsIme(); + if (platformCaps?.supportsDesktopHotkey === true) { + refreshHotkey(); + } + if (platformCaps?.platform !== 'android') { + refreshWindowsIme(); + } refreshNetwork(); }; window.addEventListener('focus', onFocus); return () => { - window.clearInterval(hotkeyId); + if (hotkeyId !== undefined) window.clearInterval(hotkeyId); window.clearInterval(permissionId); window.clearInterval(networkId); window.removeEventListener('focus', onFocus); }; - }, []); + }, [platformCaps?.platform, platformCaps?.supportsDesktopHotkey]); const reRequestAccessibility = async () => { await requestAccessibilityPermission(); @@ -114,7 +131,7 @@ export function PermissionsSection() { )} - {capability?.requiresAccessibilityPermission && ( + {capability?.requiresAccessibilityPermission && platformCaps?.platform !== 'android' && (
@@ -126,6 +143,7 @@ export function PermissionsSection() {
)} + {platformCaps?.supportsDesktopHotkey === true && (
{hotkey?.message && ( @@ -140,7 +158,18 @@ export function PermissionsSection() {
- {windowsIme?.state !== 'notWindows' && ( + )} + {platformCaps?.supportsImeInput && platformCaps.platform === 'android' && ( + + {t('settings.permissions.androidImePlaceholder')} + + )} + {platformCaps?.supportsOverlay && platformCaps.platform === 'android' && ( + + {t('settings.permissions.androidOverlayPlaceholder')} + + )} + {windowsIme?.state !== 'notWindows' && platformCaps?.platform !== 'android' && (
{windowsIme && ( diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index 3ee43c48..58f7c23c 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -12,7 +12,8 @@ import { listMicrophoneDevices, setDictationHotkey, } from '../../lib/ipc'; -import type { HotkeyMode, MicrophoneDevice, PasteShortcut } from '../../lib/types'; +import { getPlatformCapabilities } from '../../lib/platform'; +import type { HotkeyMode, MicrophoneDevice, PasteShortcut, PlatformCapabilities } from '../../lib/types'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { SelectLite } from '../../components/ui/SelectLite'; import { Card, Collapsible } from '../_atoms'; @@ -39,10 +40,15 @@ export function RecordingInputSection() { const { t } = useTranslation(); const os = detectOS(); const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings(); + const [platformCaps, setPlatformCaps] = useState(null); const [microphoneDevices, setMicrophoneDevices] = useState([]); const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false); const [microphoneDevicesError, setMicrophoneDevicesError] = useState(null); + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); + const loadMicrophoneDevices = useCallback(async ( signal?: { cancelled: boolean }, options: { showLoading?: boolean } = {}, @@ -104,6 +110,11 @@ export function RecordingInputSection() { ); } + const isAndroid = platformCaps?.platform === 'android'; + const showDesktopHotkey = platformCaps?.supportsDesktopHotkey === true; + const showDesktopInsert = showDesktopHotkey && os !== 'linux'; + const showDesktopStartup = showDesktopHotkey; + const onModeChange = (mode: HotkeyMode) => savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } }); const onShowCapsuleChange = (showCapsule: boolean) => @@ -146,7 +157,7 @@ export function RecordingInputSection() { {t('settings.recording.title')}
- {isHotkeyModeMigrationNoticeActive() && ( + {isHotkeyModeMigrationNoticeActive() && showDesktopHotkey && (
)} + {showDesktopHotkey && ( + )} + {showDesktopHotkey && (
{choices.map(([v, l]) => ( @@ -195,6 +209,7 @@ export function RecordingInputSection() { ))}
+ )}
- {os !== 'linux' && ( + {os !== 'linux' && !isAndroid && ( @@ -255,7 +270,7 @@ export function RecordingInputSection() { {/* ─── 插入与剪贴板(折叠,仅 macOS / Windows) ──────────────── */} - {os !== 'linux' && ( + {showDesktopInsert && ( @@ -300,6 +315,7 @@ export function RecordingInputSection() { )} {/* ─── 启动(折叠) ──────────────────────────────────────────── */} + {showDesktopStartup && ( @@ -314,6 +330,7 @@ export function RecordingInputSection() {
)} + )} ); } diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index 45cffa6f..0b8e9f8b 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -1,5 +1,6 @@ // 快捷键设置:开始/停止、翻译、问答、切风格、唤起 App、以及只读取消/确认提示。 +import { useEffect, useState } from 'react'; import type { CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { ShortcutRecorder } from '../../components/ShortcutRecorder'; @@ -11,6 +12,8 @@ import { setSwitchStyleHotkey, setTranslationHotkey, } from '../../lib/ipc'; +import { getPlatformCapabilities } from '../../lib/platform'; +import type { PlatformCapabilities } from '../../lib/types'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { Card } from '../_atoms'; import { SettingRow } from './shared'; @@ -33,6 +36,11 @@ export function ShortcutsSection() { const { t } = useTranslation(); const os = detectOS(); const { prefs, hotkey, capability, updatePrefs: savePrefs } = useHotkeySettings(); + const [platformCaps, setPlatformCaps] = useState(null); + + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); if (!prefs || !hotkey || !capability) { return ( @@ -42,6 +50,10 @@ export function ShortcutsSection() { ); } + if (platformCaps && !platformCaps.supportsDesktopHotkey) { + return null; + } + const readonlyRows: Array<[string, string]> = [ [t('settings.shortcuts.cancel'), 'Esc'], ...(os !== 'linux' ? [[t('settings.shortcuts.confirm'), t('settings.shortcuts.confirmHint')]] as Array<[string, string]> : []), diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index 32fae96b..c2a4555d 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -2,6 +2,7 @@ // 真正的逻辑都在各 *Section 文件里,这里只负责"哪些 section 归到哪个 tab"。 import { useTranslation } from 'react-i18next'; +import { useEffect, useState } from 'react'; import { RecordingInputSection } from './RecordingInputSection'; import { ShortcutsSection } from './ShortcutsSection'; import { LanguageSection } from './LanguageSection'; @@ -15,13 +16,23 @@ import { CodingAgentSection } from './CodingAgentSection'; import { ClaudeConsoleSection } from './ClaudeConsoleSection'; import { BetaChannelSection } from './BetaChannelSection'; import { detectOS } from '../../components/WindowChrome'; +import { getPlatformCapabilities } from '../../lib/platform'; +import type { PlatformCapabilities } from '../../lib/types'; // 通用:录音与输入 · 快捷键 · 语言。 export function GeneralTab() { + const [platformCaps, setPlatformCaps] = useState(null); + + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); + + const showDesktopShortcuts = platformCaps?.supportsDesktopHotkey === true; + return ( <> - + {showDesktopShortcuts && } ); @@ -72,13 +83,21 @@ export function PrivacyTab() { // 高级:本地模型 · 调试工具 · 加入 Beta 渠道(固定在最下面)。 export function AdvancedTab() { const os = detectOS(); + const [platformCaps, setPlatformCaps] = useState(null); + + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); + + const showDesktopAdvanced = platformCaps?.platform === 'desktop'; + return ( <> - - - {os !== 'win' && } - {os !== 'win' && } - + {showDesktopAdvanced && } + {showDesktopAdvanced && } + {showDesktopAdvanced && os !== 'win' && } + {showDesktopAdvanced && os !== 'win' && } + {platformCaps?.supportsAutoUpdate === true && } ); } diff --git a/openless-all/app/vite.config.ts b/openless-all/app/vite.config.ts index 7ec15d0b..9895e621 100644 --- a/openless-all/app/vite.config.ts +++ b/openless-all/app/vite.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; const host = process.env.TAURI_DEV_HOST; +const isMobileDev = + process.env.TAURI_ENV_PLATFORM === "android" || + process.env.TAURI_ENV_PLATFORM === "ios"; export default defineConfig(async () => ({ plugins: [react()], @@ -9,10 +12,12 @@ export default defineConfig(async () => ({ server: { port: 1420, strictPort: true, - host: host || false, - hmr: host - ? { protocol: "ws", host, port: 1421 } - : undefined, + host: isMobileDev ? "0.0.0.0" : host || false, + hmr: isMobileDev + ? { protocol: "ws", host: host || "0.0.0.0", port: 1421 } + : host + ? { protocol: "ws", host, port: 1421 } + : undefined, watch: { ignored: ["**/src-tauri/**"] }, }, envPrefix: ["VITE_", "TAURI_"], From f03643423010351369546ba7812478b281b90d79 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 10:49:38 +0800 Subject: [PATCH 02/83] =?UTF-8?q?feat(android):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=94=A8=E4=BA=8E=E6=9E=84=E5=BB=BA=E5=92=8C=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E8=B0=83=E8=AF=95=20APK=20=E7=9A=84=20CI=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入了 GitHub Actions 工作流,以实现 Android 应用调试 APK 构建的自动化。该工作流在匹配 'v*-tauri' 模式的标签推送时触发,并支持手动调度。它包括设置 Java 环境、Android SDK 和 NDK、安装依赖项、构建前端以及合并 APK 清单等步骤。生成的 APK 将作为 artifact 上传,并可附加到 GitHub Releases 中。此外,还更新了 package.json,新增了一个用于合并 Android 清单的脚本。 --- .github/workflows/android-apk.yml | 134 ++++++++++++++++++ docs/android-mobile-apk-overlay-plan.md | 55 ++++++- openless-all/app/package.json | 3 +- .../app/scripts/merge-android-v1-manifest.mjs | 123 ++++++++++++++++ 4 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/android-apk.yml create mode 100644 openless-all/app/scripts/merge-android-v1-manifest.mjs diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml new file mode 100644 index 00000000..2530acef --- /dev/null +++ b/.github/workflows/android-apk.yml @@ -0,0 +1,134 @@ +name: Android APK (debug) + +# Triggers: +# - push v*-tauri tag → build debug APK, upload artifact + attach to GitHub Release +# - workflow_dispatch → build debug APK, upload artifact only (no release) +# +# v1 scope: in-app dictation APK with RECORD_AUDIO merged from android-scaffolding. +# IME v2 / overlay v3 manifest snippets are intentionally out of scope. + +on: + push: + tags: + - 'v*-tauri' + workflow_dispatch: + +jobs: + build-android-apk: + permissions: + contents: write + runs-on: ubuntu-latest + env: + CI: true + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Java 17 (Zulu) + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install NDK and accept SDK licenses + shell: bash + run: | + set -euo pipefail + sdkmanager "ndk;26.1.10909125" + ndk_dir="$ANDROID_HOME/ndk/26.1.10909125" + if [ ! -d "$ndk_dir" ]; then + echo "::error::NDK not found at $ndk_dir" + exit 1 + fi + echo "ANDROID_NDK_HOME=$ndk_dir" >> "$GITHUB_ENV" + echo "NDK_HOME=$ndk_dir" >> "$GITHUB_ENV" + set +o pipefail + yes | sdkmanager --licenses + sdkmanager_exit=${PIPESTATUS[1]} + set -o pipefail + if [ "$sdkmanager_exit" -ne 0 ]; then + echo "::error::sdkmanager --licenses failed with exit code $sdkmanager_exit" + exit "$sdkmanager_exit" + fi + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: openless-all/app/package-lock.json + + - uses: dtolnay/rust-toolchain@stable + with: + targets: >- + aarch64-linux-android, + armv7-linux-androideabi, + i686-linux-android, + x86_64-linux-android + + - name: Cache Cargo + uses: swatinem/rust-cache@v2 + with: + workspaces: openless-all/app/src-tauri -> target + + - name: Install npm deps + working-directory: openless-all/app + run: npm ci + + - name: Build frontend + working-directory: openless-all/app + run: npm run build + + - name: Initialize Android project + working-directory: openless-all/app + run: npm run tauri -- android init --ci + + - name: Merge APK v1 manifest (RECORD_AUDIO) + working-directory: openless-all/app + run: node scripts/merge-android-v1-manifest.mjs + + - name: Build Android debug APK + working-directory: openless-all/app + run: npm run tauri:android:build + + - name: Collect debug APK + id: apk + shell: bash + working-directory: openless-all/app + run: | + set -euo pipefail + apk_path="$(find src-tauri/gen/android -type f -name '*.apk' -path '*/outputs/*' | sort | head -n 1)" + if [ -z "$apk_path" ]; then + echo "::error::No APK found under src-tauri/gen/android/**/outputs/" + find src-tauri/gen/android -type f -name '*.apk' 2>/dev/null || true + exit 1 + fi + if [[ "${{ github.ref }}" == refs/tags/v* ]] && [[ "${{ github.ref_name }}" == *-tauri ]]; then + label="${{ github.ref_name }}" + else + label="run-${{ github.run_number }}" + fi + dest="$RUNNER_TEMP/OpenLess-android-debug-${label}.apk" + cp "$apk_path" "$dest" + echo "path=$dest" >> "$GITHUB_OUTPUT" + echo "Collected APK: $apk_path → $dest" + + - name: Upload Android APK artifact + uses: actions/upload-artifact@v4 + with: + name: openless-android-debug + path: ${{ steps.apk.outputs.path }} + if-no-files-found: error + + - name: Attach APK to GitHub Release + if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri') + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: OpenLess ${{ github.ref_name }} + draft: false + prerelease: ${{ endsWith(github.ref_name, '-beta-tauri') }} + files: ${{ steps.apk.outputs.path }} diff --git a/docs/android-mobile-apk-overlay-plan.md b/docs/android-mobile-apk-overlay-plan.md index 8d416264..8a7bd6d0 100644 --- a/docs/android-mobile-apk-overlay-plan.md +++ b/docs/android-mobile-apk-overlay-plan.md @@ -182,6 +182,8 @@ Rust ↔ Kotlin 通信:Tauri mobile plugin / `jni`(脚手架阶段为桩,i |---|---| | `docs/android-mobile-apk-overlay-plan.md` | 扩展为完整实施规划(本文档) | | `openless-all/app/package.json` | `tauri:android:*` scripts | +| `.github/workflows/android-apk.yml` | Android debug APK CI | +| `openless-all/app/scripts/merge-android-v1-manifest.mjs` | v1 manifest merge (RECORD_AUDIO) | | `openless-all/app/vite.config.ts` | mobile dev server / HMR | | `openless-all/app/src-tauri/tauri.conf.json` | `bundle.android` | | `openless-all/app/src-tauri/tauri.android.conf.json` | 单 main 窗口 | @@ -214,6 +216,53 @@ Rust ↔ Kotlin 通信:Tauri mobile plugin / `jni`(脚手架阶段为桩,i --- +## Android APK CI Workflow + +GitHub Actions workflow: [`.github/workflows/android-apk.yml`](../.github/workflows/android-apk.yml) + +### Triggers + +| Trigger | Behavior | +|---|---| +| `workflow_dispatch` | Build debug APK → upload Actions artifact only | +| Push tag `v*-tauri` | Build debug APK → upload artifact **and** attach APK to the existing GitHub Release for that tag | + +Tag-triggered runs share the same `v*-tauri` convention as [`.github/workflows/release-tauri.yml`](../.github/workflows/release-tauri.yml). The Android job is independent and does not modify the desktop release workflow. + +### Debug APK policy + +- CI builds **debug** APKs (`tauri android build --apk --debug`) for faster iteration and to avoid release-signing requirements in v1. +- Actions artifact name: `openless-android-debug`. +- On-disk APK filename: + - Tag runs (`v*-tauri`): `OpenLess-android-debug-.apk` (e.g. `OpenLess-android-debug-v1.0.0-tauri.apk`) + - Manual dispatch: `OpenLess-android-debug-run-.apk` (not branch name) + +### Command chain (CI) + +```bash +cd openless-all/app +npm ci && npm run build +CI=true npm run tauri -- android init --ci +node scripts/merge-android-v1-manifest.mjs +CI=true npm run tauri:android:build +``` + +Local equivalent (after Android SDK/NDK setup): + +```bash +cd openless-all/app +npm run build +npm run tauri:android:init +npm run merge:android-v1-manifest +npm run tauri:android:build +``` + +### Manifest merge (v1 only) + +`scripts/merge-android-v1-manifest.mjs` merges **only** `RECORD_AUDIO` from `android-scaffolding/AndroidManifest.v1.snippet.xml` into the generated `src-tauri/gen/android/app/src/main/AndroidManifest.xml`. The script is idempotent (skips if permission already present). v2 (IME) and v3 (overlay) manifest snippets are **not** merged in this workflow. + +--- + ## 8. 验收标准 ### 构建验证 @@ -222,8 +271,12 @@ Rust ↔ Kotlin 通信:Tauri mobile plugin / `jni`(脚手架阶段为桩,i cd openless-all/app npm run build cargo check --manifest-path src-tauri/Cargo.toml -# 需 Android SDK: +# 需 Android SDK / NDK(与 CI 一致:debug APK): +npm run tauri:android:init +npm run merge:android-v1-manifest npm run tauri:android:build +# 等价于 CI 的 debug 构建: +# CI=true npm run tauri -- android init --ci && node scripts/merge-android-v1-manifest.mjs && CI=true npm run tauri:android:build ``` ### APK v1 diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 948e60bf..22e6a429 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -10,7 +10,8 @@ "tauri": "tauri", "tauri:android:init": "tauri android init", "tauri:android:dev": "tauri android dev", - "tauri:android:build": "tauri android build --apk", + "tauri:android:build": "tauri android build --apk --debug", + "merge:android-v1-manifest": "node scripts/merge-android-v1-manifest.mjs", "check:aura-skin": "node scripts/aura-skin-contract.test.mjs", "check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs", "check:hotkey-injection": "node scripts/check-hotkey-injection.mjs" diff --git a/openless-all/app/scripts/merge-android-v1-manifest.mjs b/openless-all/app/scripts/merge-android-v1-manifest.mjs new file mode 100644 index 00000000..4bafc684 --- /dev/null +++ b/openless-all/app/scripts/merge-android-v1-manifest.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const targetPath = fileURLToPath( + new URL('../src-tauri/gen/android/app/src/main/AndroidManifest.xml', import.meta.url), +); +const sourcePath = fileURLToPath( + new URL('../src-tauri/android-scaffolding/AndroidManifest.v1.snippet.xml', import.meta.url), +); + +const RECORD_AUDIO_RE = /android\.permission\.RECORD_AUDIO/; +const PERMISSION_LINE_RE = + /]*android:name="android\.permission\.RECORD_AUDIO"[^>]*\/?>/; + +function printHelp() { + console.log(`Usage: node scripts/merge-android-v1-manifest.mjs [options] + +Merge APK v1 RECORD_AUDIO permission from android-scaffolding into the generated +AndroidManifest.xml (post \`tauri android init\`). + +Options: + --dry-run Print planned changes without writing the manifest + --help Show this help text + +Target: ${targetPath} +Source: ${sourcePath} +`); +} + +function parseArgs(argv) { + let dryRun = false; + for (const arg of argv) { + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + if (arg === '--dry-run') { + dryRun = true; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + return { dryRun }; +} + +function extractRecordAudioPermission(snippetXml) { + const match = snippetXml.match(PERMISSION_LINE_RE); + if (!match) { + throw new Error( + `Source manifest snippet does not contain RECORD_AUDIO permission: ${sourcePath}`, + ); + } + return match[0]; +} + +function mergeRecordAudioPermission(manifestXml, permissionLine) { + if (RECORD_AUDIO_RE.test(manifestXml)) { + return { changed: false, content: manifestXml }; + } + + const applicationIdx = manifestXml.indexOf(''); + if (closingManifestIdx === -1) { + throw new Error(`Target manifest is missing : ${targetPath}`); + } + + const indent = ' '; + const insertion = `${indent}${permissionLine}\n`; + return { + changed: true, + content: `${manifestXml.slice(0, closingManifestIdx)}${insertion}${manifestXml.slice(closingManifestIdx)}`, + }; +} + +function main() { + const { dryRun } = parseArgs(process.argv.slice(2)); + + if (!existsSync(targetPath)) { + throw new Error( + `Generated Android manifest not found: ${targetPath}\nRun "npm run tauri -- android init --ci" first.`, + ); + } + if (!existsSync(sourcePath)) { + throw new Error(`Source manifest snippet not found: ${sourcePath}`); + } + + const permissionLine = extractRecordAudioPermission(readFileSync(sourcePath, 'utf8')); + const manifestXml = readFileSync(targetPath, 'utf8'); + const { changed, content } = mergeRecordAudioPermission(manifestXml, permissionLine); + + if (!changed) { + console.log(`RECORD_AUDIO already present in ${targetPath}; skipping merge.`); + return; + } + + if (dryRun) { + console.log(`[dry-run] Would merge RECORD_AUDIO into ${targetPath}`); + console.log(`[dry-run] Permission line: ${permissionLine}`); + return; + } + + writeFileSync(targetPath, content, 'utf8'); + console.log(`Merged RECORD_AUDIO into ${targetPath}`); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} From a4e5f65be62f09acb2eab00a011afafe15450fda Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 11:13:51 +0800 Subject: [PATCH 03/83] =?UTF-8?q?fix(reqwest):=20=E4=BB=8E=20reqwest=20?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E4=B8=AD=E7=A7=BB=E9=99=A4=20native-tls=20?= =?UTF-8?q?=E7=89=B9=E6=80=A7=EF=BC=8C=E5=B9=B6=E6=94=B9=E4=B8=BA=E5=9C=A8?= =?UTF-8?q?=E9=9D=9E=E7=A7=BB=E5=8A=A8=E5=B9=B3=E5=8F=B0=E7=9A=84=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E6=9E=84=E5=BB=BA=E5=99=A8=E4=B8=AD=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E5=BC=8F=E4=BD=BF=E7=94=A8=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openless-all/app/src-tauri/Cargo.toml | 3 ++- .../app/src-tauri/src/asr/local/download.rs | 14 ++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 6471522d..4190e9b3 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -31,7 +31,7 @@ tar = "0.4" tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } futures-util = "0.3" -reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "native-tls", "stream", "system-proxy"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream", "system-proxy"] } zip = "2" thiserror = "1" anyhow = "1" @@ -51,6 +51,7 @@ cpal = "0.15" # Desktop-only plugins, hotkey/insertion helpers, and tray-icon (not built for mobile). [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +reqwest = { version = "0.12", default-features = false, features = ["native-tls"] } tauri = { version = "~2.11", features = ["macos-private-api", "tray-icon"] } tauri-plugin-updater = "2" tauri-plugin-single-instance = "2" diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index 81f4f04d..fa4d4e90 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -226,18 +226,20 @@ impl DownloadManager { pub(crate) fn build_client() -> Result { // native-tls (macOS=SecureTransport) 不像 rustls 那样把 CDN unclean close - // 当致命错误。 + // 当致命错误。Android/iOS 无 native-tls feature,走默认 rustls。 // // User-Agent 用 aria2 的——hfd(hf-mirror 官方推荐)就是 aria2 包装, // 实测 aria2 UA 在 HF 反滥用规则里走白名单不挨 throttle;自定义 UA // (`openless/x`) 在 sustained 传输后会被 mirror 主动切流。 - reqwest::Client::builder() - .use_native_tls() + let mut builder = reqwest::Client::builder() .user_agent("aria2/1.36.0") .connect_timeout(std::time::Duration::from_secs(30)) - .pool_idle_timeout(std::time::Duration::from_secs(60)) - .build() - .context("build reqwest client failed") + .pool_idle_timeout(std::time::Duration::from_secs(60)); + #[cfg(not(mobile))] + { + builder = builder.use_native_tls(); + } + builder.build().context("build reqwest client failed") } async fn run_download( From f50a8e1bb6844ad23539fdc962b1934558a89da2 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 11:26:26 +0800 Subject: [PATCH 04/83] =?UTF-8?q?feat(android):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E5=92=8C=E6=A1=8C=E9=9D=A2=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E7=9A=84=20capability=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在文档中添加了 Android capability 的平台隔离详情。更新了 default.json 以指定桌面端支持的平台,并更新了 mobile.json 将 Android 定义为目标平台。这确保了应用程序能够正确处理权限并实现特定于平台的行为。 --- docs/android-mobile-apk-overlay-plan.md | 4 ++++ openless-all/app/src-tauri/capabilities/default.json | 1 + openless-all/app/src-tauri/capabilities/mobile.json | 1 + 3 files changed, 6 insertions(+) diff --git a/docs/android-mobile-apk-overlay-plan.md b/docs/android-mobile-apk-overlay-plan.md index 8a7bd6d0..39114ef6 100644 --- a/docs/android-mobile-apk-overlay-plan.md +++ b/docs/android-mobile-apk-overlay-plan.md @@ -220,6 +220,10 @@ Rust ↔ Kotlin 通信:Tauri mobile plugin / `jni`(脚手架阶段为桩,i GitHub Actions workflow: [`.github/workflows/android-apk.yml`](../.github/workflows/android-apk.yml) +### Capability platform isolation + +Desktop permissions live in `capabilities/default.json` with `"platforms": ["macos", "windows", "linux"]`, so updater, autostart, and multi-window permissions do not apply on Android. Android uses `capabilities/mobile.json` with `"platforms": ["android"]` for the main-window permission set only (no updater/autostart). + ### Triggers | Trigger | Behavior | diff --git a/openless-all/app/src-tauri/capabilities/default.json b/openless-all/app/src-tauri/capabilities/default.json index 760d74ed..dcbb17c0 100644 --- a/openless-all/app/src-tauri/capabilities/default.json +++ b/openless-all/app/src-tauri/capabilities/default.json @@ -2,6 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capabilities for OpenLess windows", + "platforms": ["macos", "windows", "linux"], "windows": ["main", "capsule", "qa", "less-computer", "less-computer-glow"], "permissions": [ "core:default", diff --git a/openless-all/app/src-tauri/capabilities/mobile.json b/openless-all/app/src-tauri/capabilities/mobile.json index a9350f0b..07db242b 100644 --- a/openless-all/app/src-tauri/capabilities/mobile.json +++ b/openless-all/app/src-tauri/capabilities/mobile.json @@ -2,6 +2,7 @@ "$schema": "../gen/schemas/mobile-schema.json", "identifier": "mobile", "description": "Capabilities for OpenLess Android main window", + "platforms": ["android"], "windows": ["main"], "permissions": [ "core:default", From 266fff0280976b87550206b9be3f0bf9ab83daad Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 11:34:45 +0800 Subject: [PATCH 05/83] =?UTF-8?q?fix(docs):=20=E8=A7=84=E8=8C=83=E5=8C=96?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E6=96=87=E6=A1=A3=E5=92=8C=20JSON=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=AD=E7=9A=84=20macOS=20=E5=A4=A7?= =?UTF-8?q?=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新了文档和 default.json,将“macos”统一改为“macOS”,以保持各平台间的一致性。此更改增强了清晰度,并维护了平台命名规范的统一。 --- docs/android-mobile-apk-overlay-plan.md | 2 +- openless-all/app/src-tauri/capabilities/default.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/android-mobile-apk-overlay-plan.md b/docs/android-mobile-apk-overlay-plan.md index 39114ef6..3132ce78 100644 --- a/docs/android-mobile-apk-overlay-plan.md +++ b/docs/android-mobile-apk-overlay-plan.md @@ -222,7 +222,7 @@ GitHub Actions workflow: [`.github/workflows/android-apk.yml`](../.github/workfl ### Capability platform isolation -Desktop permissions live in `capabilities/default.json` with `"platforms": ["macos", "windows", "linux"]`, so updater, autostart, and multi-window permissions do not apply on Android. Android uses `capabilities/mobile.json` with `"platforms": ["android"]` for the main-window permission set only (no updater/autostart). +Desktop permissions live in `capabilities/default.json` with `"platforms": ["macOS", "windows", "linux"]`, so updater, autostart, and multi-window permissions do not apply on Android. Android uses `capabilities/mobile.json` with `"platforms": ["android"]` for the main-window permission set only (no updater/autostart). ### Triggers diff --git a/openless-all/app/src-tauri/capabilities/default.json b/openless-all/app/src-tauri/capabilities/default.json index dcbb17c0..759ae4f2 100644 --- a/openless-all/app/src-tauri/capabilities/default.json +++ b/openless-all/app/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capabilities for OpenLess windows", - "platforms": ["macos", "windows", "linux"], + "platforms": ["macOS", "windows", "linux"], "windows": ["main", "capsule", "qa", "less-computer", "less-computer-glow"], "permissions": [ "core:default", From c768da278bcd0a4e524b18b1552d311c9bf5387a Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 12:09:53 +0800 Subject: [PATCH 06/83] Fix Android APK build: TLS, capabilities, arboard Co-authored-by: Cursor --- docs/android-mobile-apk-overlay-plan.md | 4 +++- openless-all/app/src-tauri/Cargo.lock | 14 +++++++------- openless-all/app/src-tauri/Cargo.toml | 3 --- .../app/src-tauri/src/coordinator/dictation.rs | 3 ++- openless-all/app/src-tauri/src/insertion.rs | 7 +++++++ 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/android-mobile-apk-overlay-plan.md b/docs/android-mobile-apk-overlay-plan.md index 3132ce78..28fcfd9b 100644 --- a/docs/android-mobile-apk-overlay-plan.md +++ b/docs/android-mobile-apk-overlay-plan.md @@ -76,10 +76,12 @@ openless-all/app/ │ desktop │ mobile (Android) │ │ hotkey/tray │ in-app dictation + cloud ASR │ │ capsule/qa │ android_ime (v2) / android_overlay(v3) │ -│ TSF/AX/粘贴 │ copy fallback (v1) │ +│ TSF/AX/粘贴 │ IME commit (v1); clipboard TBD │ └──────────────┴──────────────────────────────────────────┘ ``` +> **v1 剪贴板**:APK v1 不使用 Android 剪贴板兜底(未接 arboard);跨 App 文本输入依赖后续 IME/JNI 接线。 + --- ## 3. 模块设计 diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index a72a32f3..2643c785 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -155,7 +155,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -1523,7 +1523,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3820,7 +3820,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.48.0", ] [[package]] @@ -4311,7 +4311,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4687,7 +4687,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4745,7 +4745,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5872,7 +5872,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 4190e9b3..15ce9288 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -81,9 +81,6 @@ features = ["linux-native-sync-persistent", "crypto-rust"] jni = "0.21" ndk-context = "0.1" -[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] -arboard = { version = "3", default-features = false } - [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.5" core-foundation = "0.10" diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index e00603dd..b0ad041f 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -230,7 +230,8 @@ async fn run_streaming_polish( }; // 把 final_text 写回剪贴板(默认 on,macOS/Windows 适用)。 // Linux:fcitx5 插件已直写文字到目标 app,跳过剪贴板避免破坏用户数据。 - #[cfg(not(target_os = "linux"))] + // Android/iOS:无 arboard 剪贴板路径,v1 依赖 IME commit。 + #[cfg(not(any(target_os = "linux", target_os = "android", target_os = "ios")))] if inner.prefs.get().streaming_insert_save_clipboard { match arboard::Clipboard::new() { Ok(mut cb) => match cb.set_text(final_text.clone()) { diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 64848f32..2a18cbe4 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -204,6 +204,7 @@ static NEXT_CLIPBOARD_RESTORE_ID: AtomicU64 = AtomicU64::new(1); static PENDING_CLIPBOARD_RESTORE: Lazy>> = Lazy::new(|| Mutex::new(None)); +#[cfg(not(any(target_os = "android", target_os = "ios")))] fn copy_to_clipboard(text: &str) -> bool { let mut clipboard = match arboard::Clipboard::new() { Ok(c) => c, @@ -219,6 +220,12 @@ fn copy_to_clipboard(text: &str) -> bool { true } +#[cfg(any(target_os = "android", target_os = "ios"))] +fn copy_to_clipboard(_text: &str) -> bool { + log::warn!("[insertion] mobile clipboard fallback unavailable"); + false +} + #[cfg(all(not(target_os = "macos"), not(target_os = "android")))] fn copy_to_clipboard_with_restore_plan(text: &str) -> Result { let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; From 95d6a85a552bc5f6bf1902db9ea27410703591e9 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 12:47:41 +0800 Subject: [PATCH 07/83] =?UTF-8?q?Refactor:=E7=AE=80=E5=8C=96=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=AF=BC=E5=85=A5=E5=B9=B6=E6=9B=B4=E6=96=B0=E7=89=B9?= =?UTF-8?q?=E5=AE=9A=E5=B9=B3=E5=8F=B0=E7=9A=84=E9=85=8D=E7=BD=AE=20?= =?UTF-8?q?=E2=80=A2=20=E5=88=86=E5=88=AB=E5=88=A0=E9=99=A4=E4=BA=86=20and?= =?UTF-8?q?roid=5Fime.rs=20=E5=92=8C=20android=5Foverlay.rs=20=E4=B8=AD?= =?UTF-8?q?=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=20AndroidImeState=20?= =?UTF-8?q?=E5=92=8C=20AndroidOverlayPermissionState=20=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E3=80=82=20=E2=80=A2=20=E6=9B=B4=E6=96=B0=E4=BA=86=20insertion?= =?UTF-8?q?.rs=20=E4=B8=AD=E7=9A=84=E6=9D=A1=E4=BB=B6=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E6=A0=87=E5=BF=97=EF=BC=8C=E5=9C=A8=E9=9D=9E=20macOS=20?= =?UTF-8?q?=E5=92=8C=E9=9D=9E=20Android=20=E7=9A=84=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E4=B8=AD=E6=8E=92=E9=99=A4=E4=BA=86=20iOS=E3=80=82=20=E2=80=A2?= =?UTF-8?q?=20=E5=9C=A8=20lib.rs=20=E7=9A=84=20app=5Finvoke=5Fhandler=5Fmo?= =?UTF-8?q?bile=20=E4=B8=AD=E6=B7=BB=E5=8A=A0=E4=BA=86=20#[macro=5Fexport]?= =?UTF-8?q?=20=E4=BB=A5=E6=8F=90=E9=AB=98=E5=8F=AF=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E6=80=A7=E3=80=82=20=E2=80=A2=20=E8=B0=83=E6=95=B4=E4=BA=86=20?= =?UTF-8?q?types.rs=20=E4=B8=AD=E7=9A=84=E7=B1=BB=E5=9E=8B=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=EF=BC=8C=E5=B0=86=E7=A7=BB=E5=8A=A8=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E7=BA=B3=E5=85=A5=E5=8A=9F=E8=83=BD=E6=9D=A1=E4=BB=B6=E4=B8=AD?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openless-all/app/src-tauri/src/android_ime.rs | 4 +- .../app/src-tauri/src/android_overlay.rs | 4 +- openless-all/app/src-tauri/src/insertion.rs | 52 +++++++++---------- openless-all/app/src-tauri/src/lib.rs | 2 + .../app/src-tauri/src/mobile_runtime.rs | 2 +- openless-all/app/src-tauri/src/types.rs | 2 +- 6 files changed, 36 insertions(+), 30 deletions(-) diff --git a/openless-all/app/src-tauri/src/android_ime.rs b/openless-all/app/src-tauri/src/android_ime.rs index bdb02be8..3b283f02 100644 --- a/openless-all/app/src-tauri/src/android_ime.rs +++ b/openless-all/app/src-tauri/src/android_ime.rs @@ -5,7 +5,7 @@ use serde::Serialize; -use crate::types::{AndroidImeState, AndroidImeStatus}; +use crate::types::AndroidImeStatus; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -22,6 +22,8 @@ pub fn get_android_ime_status() -> AndroidImeStatus { #[cfg(not(target_os = "android"))] { + use crate::types::AndroidImeState; + AndroidImeStatus { state: AndroidImeState::NotAndroid, enabled: false, diff --git a/openless-all/app/src-tauri/src/android_overlay.rs b/openless-all/app/src-tauri/src/android_overlay.rs index e694b64a..3d2072f8 100644 --- a/openless-all/app/src-tauri/src/android_overlay.rs +++ b/openless-all/app/src-tauri/src/android_overlay.rs @@ -2,7 +2,7 @@ use serde::Serialize; -use crate::types::{AndroidOverlayPermissionState, AndroidOverlayStatus}; +use crate::types::AndroidOverlayStatus; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -19,6 +19,8 @@ pub fn get_android_overlay_status() -> AndroidOverlayStatus { #[cfg(not(target_os = "android"))] { + use crate::types::AndroidOverlayPermissionState; + AndroidOverlayStatus { permission: AndroidOverlayPermissionState::NotAndroid, overlay_visible: false, diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 2a18cbe4..60a5c027 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -5,14 +5,14 @@ //! - macOS:用 CoreGraphics CGEvent 直接 post Cmd+V。 //! - Windows / Linux:用 enigo 按 `PasteShortcut` 模拟。 -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] use std::sync::atomic::{AtomicU64, Ordering}; -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] use std::time::Duration; -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] use once_cell::sync::Lazy; -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] use parking_lot::Mutex; use crate::types::{InsertStatus, PasteShortcut}; @@ -20,7 +20,7 @@ use crate::types::{InsertStatus, PasteShortcut}; #[cfg(target_os = "windows")] const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750); -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750); pub struct TextInserter; @@ -63,7 +63,7 @@ impl TextInserter { insert_with_clipboard_restore(text, restore_clipboard_after_paste, paste_shortcut) } - #[cfg(all(not(target_os = "macos"), not(target_os = "android")))] + #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] pub fn insert_via_clipboard_fallback( &self, text: &str, @@ -183,24 +183,24 @@ impl Default for TextInserter { } } -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] #[derive(Debug)] struct ClipboardRestorePlan { inserted_text: String, previous_text: Option, } -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] #[derive(Debug, Clone)] struct PendingClipboardRestore { latest_restore_id: u64, original_text: Option, } -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] static NEXT_CLIPBOARD_RESTORE_ID: AtomicU64 = AtomicU64::new(1); -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] static PENDING_CLIPBOARD_RESTORE: Lazy>> = Lazy::new(|| Mutex::new(None)); @@ -226,7 +226,7 @@ fn copy_to_clipboard(_text: &str) -> bool { false } -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn copy_to_clipboard_with_restore_plan(text: &str) -> Result { let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; let previous_text = match clipboard.get_text() { @@ -248,7 +248,7 @@ fn copy_to_clipboard_with_restore_plan(text: &str) -> Result) -> (u64, Option) { let restore_id = NEXT_CLIPBOARD_RESTORE_ID.fetch_add(1, Ordering::SeqCst); let original_text = { @@ -300,7 +300,7 @@ fn remember_pending_clipboard_restore(previous_text: Option) -> (u64, Op (restore_id, original_text) } -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn restore_clipboard_after_delay( plan: ClipboardRestorePlan, original_text: Option, @@ -351,7 +351,7 @@ fn restore_clipboard_after_delay( clear_pending_clipboard_restore(restore_id); } -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn is_latest_clipboard_restore(restore_id: u64) -> bool { matches!( PENDING_CLIPBOARD_RESTORE.lock().as_ref(), @@ -359,7 +359,7 @@ fn is_latest_clipboard_restore(restore_id: u64) -> bool { ) } -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn clear_pending_clipboard_restore(restore_id: u64) { let mut pending = PENDING_CLIPBOARD_RESTORE.lock(); if matches!(pending.as_ref(), Some(batch) if batch.latest_restore_id == restore_id) { @@ -367,7 +367,7 @@ fn clear_pending_clipboard_restore(restore_id: u64) { } } -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn should_restore_clipboard(current_text: Option<&str>, inserted_text: &str) -> bool { matches!(current_text, Some(current) if current == inserted_text) } @@ -384,7 +384,7 @@ fn simulate_paste() -> Result<(), String> { } /// 把 `PasteShortcut` 拆成 `(modifiers, primary)`,顺序决定按下/释放顺序。 -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn paste_keys(shortcut: PasteShortcut) -> (Vec, enigo::Key) { use enigo::Key; match shortcut { @@ -394,7 +394,7 @@ fn paste_keys(shortcut: PasteShortcut) -> (Vec, enigo::Key) { } } -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn simulate_paste(shortcut: PasteShortcut) -> Result<(), String> { use enigo::{Direction, Enigo, Keyboard, Settings}; let (modifiers, primary) = paste_keys(shortcut); @@ -438,7 +438,7 @@ fn insertion_success_status() -> InsertStatus { InsertStatus::Inserted } -#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn insertion_success_status() -> InsertStatus { InsertStatus::PasteSent } @@ -588,7 +588,7 @@ mod tests { use std::time::Duration; #[test] - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn restore_only_when_clipboard_still_holds_inserted_text() { assert!(should_restore_clipboard( Some("dictated text"), @@ -603,7 +603,7 @@ mod tests { /// 配置的快捷键必须真实映射到对应按键。只比较 modifier 数 + 主键,规避 enigo 内部 PartialEq。 #[test] - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn paste_keys_match_configured_shortcut() { use enigo::Key; @@ -632,7 +632,7 @@ mod tests { inserter.insert("", true, PasteShortcut::CtrlV), InsertStatus::CopiedFallback ); - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] { assert_eq!( inserter.insert_via_clipboard_fallback("", true, PasteShortcut::CtrlV), @@ -718,7 +718,7 @@ mod tests { } #[test] - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn pending_clipboard_restore_keeps_first_original_until_latest_restore() { *PENDING_CLIPBOARD_RESTORE.lock() = None; @@ -740,7 +740,7 @@ mod tests { } #[test] - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn clipboard_restore_skips_when_clipboard_no_longer_matches_inserted_text() { assert!(should_restore_clipboard( Some("dictated text"), diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 514953c8..82271cc7 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -276,6 +276,7 @@ macro_rules! app_invoke_handler_desktop { } /// Android/iOS: only commands usable without desktop hotkeys, tray, updater, or local ASR. +#[macro_export] macro_rules! app_invoke_handler_mobile { () => { tauri::generate_handler![ @@ -1125,6 +1126,7 @@ pub(crate) fn show_main_window(app: &AppHandle) { activate_window_mode(app); if let Some(w) = app.get_webview_window("main") { let _ = w.show(); + #[cfg(not(mobile))] let _ = w.unminimize(); let _ = w.set_focus(); } diff --git a/openless-all/app/src-tauri/src/mobile_runtime.rs b/openless-all/app/src-tauri/src/mobile_runtime.rs index 93eb7c34..f41959da 100644 --- a/openless-all/app/src-tauri/src/mobile_runtime.rs +++ b/openless-all/app/src-tauri/src/mobile_runtime.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use tauri::{AppHandle, Manager, RunEvent}; use crate::coordinator::Coordinator; -use crate::commands::{self, MicrophoneMonitorState}; +use crate::commands::MicrophoneMonitorState; pub fn run() { let coordinator = Arc::new(Coordinator::new()); diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index f4a1eb75..85b19c8e 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -2214,7 +2214,7 @@ impl HotkeyCapability { }; } - #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + #[cfg(all(not(target_os = "macos"), not(target_os = "windows"), not(mobile)))] { Self { adapter: HotkeyAdapterKind::Fcitx5, From 4892b00a49cecd69891401bbed678bda8e8f2982 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 14:55:30 +0800 Subject: [PATCH 08/83] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20app=5Finvoke=5Fhandler=5Fmobile=20=E5=AE=8F?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E5=91=BD=E4=BB=A4=E5=BC=95=E7=94=A8=E4=BB=A5?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20crate=20=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 app_invoke_handler_mobile 宏中,将直接命令引用替换为 crate 限定路径,以提高整个代码库的清晰度和一致性。 --- openless-all/app/src-tauri/src/lib.rs | 132 +++++++++++++------------- 1 file changed, 66 insertions(+), 66 deletions(-) diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 82271cc7..39314113 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -280,72 +280,72 @@ macro_rules! app_invoke_handler_desktop { macro_rules! app_invoke_handler_mobile { () => { tauri::generate_handler![ - commands::get_settings, - commands::get_default_style_system_prompts, - commands::set_settings, - commands::check_network, - commands::get_platform_capabilities, - commands::get_android_ime_status, - commands::get_android_overlay_status, - commands::request_android_overlay_permission, - commands::list_microphone_devices, - commands::start_microphone_level_monitor, - commands::stop_microphone_level_monitor, - commands::get_credentials, - commands::set_credential, - commands::read_credential, - commands::set_active_asr_provider, - commands::set_active_llm_provider, - commands::validate_provider_credentials, - commands::list_provider_models, - commands::list_history, - commands::delete_history_entry, - commands::clear_history, - commands::read_audio_recording, - commands::marketplace_list, - commands::marketplace_detail, - commands::marketplace_install, - commands::marketplace_upload, - commands::marketplace_like, - commands::marketplace_my_likes, - commands::marketplace_my_packs, - commands::marketplace_delete, - commands::github_device_flow_start, - commands::github_device_flow_poll, - commands::list_vocab, - commands::add_vocab, - commands::remove_vocab, - commands::set_vocab_enabled, - commands::list_correction_rules, - commands::add_correction_rule, - commands::remove_correction_rule, - commands::set_correction_rule_enabled, - commands::list_vocab_presets, - commands::save_vocab_presets, - commands::start_dictation, - commands::stop_dictation, - commands::cancel_dictation, - commands::repolish, - commands::list_style_packs, - commands::create_style_pack_from_template, - commands::save_style_pack, - commands::preview_style_pack_runtime, - commands::set_active_style_pack, - commands::set_style_pack_enabled, - commands::reset_builtin_style_pack, - commands::delete_style_pack, - commands::import_style_pack_from_zip, - commands::export_style_pack_to_zip, - commands::set_default_polish_mode, - commands::set_style_enabled, - commands::check_accessibility_permission, - commands::request_accessibility_permission, - commands::check_microphone_permission, - commands::request_microphone_permission, - commands::open_system_settings, - commands::trigger_microphone_prompt, - commands::export_error_log, - restart_app, + $crate::commands::get_settings, + $crate::commands::get_default_style_system_prompts, + $crate::commands::set_settings, + $crate::commands::check_network, + $crate::commands::get_platform_capabilities, + $crate::commands::get_android_ime_status, + $crate::commands::get_android_overlay_status, + $crate::commands::request_android_overlay_permission, + $crate::commands::list_microphone_devices, + $crate::commands::start_microphone_level_monitor, + $crate::commands::stop_microphone_level_monitor, + $crate::commands::get_credentials, + $crate::commands::set_credential, + $crate::commands::read_credential, + $crate::commands::set_active_asr_provider, + $crate::commands::set_active_llm_provider, + $crate::commands::validate_provider_credentials, + $crate::commands::list_provider_models, + $crate::commands::list_history, + $crate::commands::delete_history_entry, + $crate::commands::clear_history, + $crate::commands::read_audio_recording, + $crate::commands::marketplace_list, + $crate::commands::marketplace_detail, + $crate::commands::marketplace_install, + $crate::commands::marketplace_upload, + $crate::commands::marketplace_like, + $crate::commands::marketplace_my_likes, + $crate::commands::marketplace_my_packs, + $crate::commands::marketplace_delete, + $crate::commands::github_device_flow_start, + $crate::commands::github_device_flow_poll, + $crate::commands::list_vocab, + $crate::commands::add_vocab, + $crate::commands::remove_vocab, + $crate::commands::set_vocab_enabled, + $crate::commands::list_correction_rules, + $crate::commands::add_correction_rule, + $crate::commands::remove_correction_rule, + $crate::commands::set_correction_rule_enabled, + $crate::commands::list_vocab_presets, + $crate::commands::save_vocab_presets, + $crate::commands::start_dictation, + $crate::commands::stop_dictation, + $crate::commands::cancel_dictation, + $crate::commands::repolish, + $crate::commands::list_style_packs, + $crate::commands::create_style_pack_from_template, + $crate::commands::save_style_pack, + $crate::commands::preview_style_pack_runtime, + $crate::commands::set_active_style_pack, + $crate::commands::set_style_pack_enabled, + $crate::commands::reset_builtin_style_pack, + $crate::commands::delete_style_pack, + $crate::commands::import_style_pack_from_zip, + $crate::commands::export_style_pack_to_zip, + $crate::commands::set_default_polish_mode, + $crate::commands::set_style_enabled, + $crate::commands::check_accessibility_permission, + $crate::commands::request_accessibility_permission, + $crate::commands::check_microphone_permission, + $crate::commands::request_microphone_permission, + $crate::commands::open_system_settings, + $crate::commands::trigger_microphone_prompt, + $crate::commands::export_error_log, + $crate::restart_app, ] }; } From b68f4363a15ed42872ecae4d00784e44c9333fec Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 15:08:41 +0800 Subject: [PATCH 09/83] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E5=B0=86?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E7=94=A8=E6=88=B7=E5=81=8F=E5=A5=BD=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E7=9A=84=E9=80=9A=E7=94=A8=E9=80=BB=E8=BE=91=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E5=88=B0=E5=85=B1=E4=BA=AB=E5=87=BD=E6=95=B0=E4=B8=AD?= =?UTF-8?q?=20=E2=80=A2=20=E5=BC=95=E5=85=A5=E4=BA=86=20set=5Fsettings=5Fc?= =?UTF-8?q?ommon=20=E6=9D=A5=E5=A4=84=E7=90=86=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=81=8F=E5=A5=BD=E8=AE=BE=E7=BD=AE=E5=92=8C?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E6=9B=B4=E6=94=B9=E7=9A=84=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E3=80=82=20=E2=80=A2=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=BA=86=E5=90=84=E5=B9=B3=E5=8F=B0=E7=89=B9=E5=AE=9A=E7=9A=84?= =?UTF-8?q?=20set=5Fsettings=20=E5=87=BD=E6=95=B0=E4=BB=A5=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=96=B0=E7=9A=84=E9=80=9A=E7=94=A8=E5=87=BD=E6=95=B0?= =?UTF-8?q?=EF=BC=8C=E4=BB=8E=E8=80=8C=E6=8F=90=E9=AB=98=E4=BA=86=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=A4=8D=E7=94=A8=E6=80=A7=E5=92=8C=E6=B8=85=E6=99=B0?= =?UTF-8?q?=E5=BA=A6=E3=80=82=20=E2=80=A2=20=E7=A1=AE=E4=BF=9D=E9=9D=9E?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E5=B9=B3=E5=8F=B0=E7=9A=84=E6=89=98=E7=9B=98?= =?UTF-8?q?=E9=BA=A6=E5=85=8B=E9=A3=8E=E8=8F=9C=E5=8D=95=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E7=9A=84=E4=B8=BB=E7=BA=BF=E7=A8=8B=E5=A4=84=E7=90=86=E4=BF=9D?= =?UTF-8?q?=E6=8C=81=E4=B8=8D=E5=8F=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openless-all/app/src-tauri/src/commands.rs | 72 ++++++++++++++-------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 507119f6..8c92e7df 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -247,40 +247,58 @@ fn persist_settings( Ok(()) } -#[tauri::command] -pub fn set_settings( - coord: CoordinatorState<'_>, - app: AppHandle, - #[cfg(not(mobile))] tray_microphones: State<'_, TrayMicrophoneMenuState>, +fn set_settings_common( + coord: &Coordinator, + app: &AppHandle, mut prefs: UserPreferences, -) -> Result<(), String> { +) -> Result { let packs = coord.style_packs().list().map_err(|e| e.to_string())?; sync_style_pack_preferences(&mut prefs, &packs); // 广播给所有 webview。issue #205:QaPanel 跑在独立 webview, // 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时 // 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。 - persist_settings(&*coord, prefs.clone())?; - #[cfg(not(mobile))] - { - // refresh_tray_microphone_menu 内部会调用 NSStatusItem.set_menu,必须在主线程上跑。 - // set_settings 本身是同步 Tauri command,在 IPC handler 线程上执行;从这里直接调 - // 会触发 macOS 主线程断言或在 dispatch 队列上死锁,导致整个 UI 无响应(用户改 - // 偏好后所有按键都没反应即此根因)。dispatch 到主线程后立即返回,IPC 线程不阻塞。 - let app_for_main = app.clone(); - let prefs_for_main = prefs.clone(); - let _ = app.run_on_main_thread(move || { - if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) { - log::warn!("[tray] refresh microphone menu after settings save failed: {err}"); - let tray_state = app_for_main.state::(); - sync_tray_microphone_selection( - &tray_state.lock(), - &prefs_for_main.microphone_device_name, - ); - } - }); - let _ = tray_microphones; - } + persist_settings(coord, prefs.clone())?; let _ = app.emit("prefs:changed", &prefs); + Ok(prefs) +} + +#[cfg(not(mobile))] +#[tauri::command] +pub fn set_settings( + coord: CoordinatorState<'_>, + app: AppHandle, + tray_microphones: State<'_, TrayMicrophoneMenuState>, + prefs: UserPreferences, +) -> Result<(), String> { + let prefs = set_settings_common(&*coord, &app, prefs)?; + // refresh_tray_microphone_menu 内部会调用 NSStatusItem.set_menu,必须在主线程上跑。 + // set_settings 本身是同步 Tauri command,在 IPC handler 线程上执行;从这里直接调 + // 会触发 macOS 主线程断言或在 dispatch 队列上死锁,导致整个 UI 无响应(用户改 + // 偏好后所有按键都没反应即此根因)。dispatch 到主线程后立即返回,IPC 线程不阻塞。 + let app_for_main = app.clone(); + let prefs_for_main = prefs.clone(); + let _ = app.run_on_main_thread(move || { + if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) { + log::warn!("[tray] refresh microphone menu after settings save failed: {err}"); + let tray_state = app_for_main.state::(); + sync_tray_microphone_selection( + &tray_state.lock(), + &prefs_for_main.microphone_device_name, + ); + } + }); + let _ = tray_microphones; + Ok(()) +} + +#[cfg(mobile)] +#[tauri::command] +pub fn set_settings( + coord: CoordinatorState<'_>, + app: AppHandle, + prefs: UserPreferences, +) -> Result<(), String> { + set_settings_common(&*coord, &app, prefs)?; Ok(()) } From c4eb1ac8184501f71189987431d70eed0e2f7a66 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 15:25:15 +0800 Subject: [PATCH 10/83] =?UTF-8?q?feat(ci):=20=E9=80=9A=E8=BF=87=20Gradle?= =?UTF-8?q?=20=E8=AE=BE=E7=BD=AE=E5=92=8C=20Wrapper=20=E9=A2=84=E7=83=AD?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=20Android=20APK=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=20=E2=80=A2=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BA=86=20Gradle=20=E8=AE=BE=E7=BD=AE=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E4=BB=A5=E4=BC=98=E5=8C=96=E6=9E=84=E5=BB=BA=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E3=80=82=20=E2=80=A2=20=E5=BC=95=E5=85=A5=E4=BA=86=E5=B8=A6?= =?UTF-8?q?=E6=9C=89=E9=87=8D=E8=AF=95=E9=80=BB=E8=BE=91=E7=9A=84=20Gradle?= =?UTF-8?q?=20Wrapper=20=E9=A2=84=E7=83=AD=E6=AD=A5=E9=AA=A4=EF=BC=8C?= =?UTF-8?q?=E4=BB=A5=E6=8F=90=E9=AB=98=E6=9E=84=E5=BB=BA=E8=BF=87=E7=A8=8B?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E5=8F=AF=E9=9D=A0=E6=80=A7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/android-apk.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 2530acef..285c1bde 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -74,6 +74,8 @@ jobs: with: workspaces: openless-all/app/src-tauri -> target + - uses: gradle/actions/setup-gradle@v4 + - name: Install npm deps working-directory: openless-all/app run: npm ci @@ -90,6 +92,21 @@ jobs: working-directory: openless-all/app run: node scripts/merge-android-v1-manifest.mjs + - name: Prime Gradle wrapper + working-directory: openless-all/app + shell: bash + run: | + set -euo pipefail + for attempt in 1 2 3; do + if src-tauri/gen/android/gradlew --project-dir src-tauri/gen/android --version --no-daemon; then + exit 0 + fi + if [ "$attempt" -lt 3 ]; then + sleep $([ "$attempt" -eq 1 ] && echo 15 || echo 30) + fi + done + exit 1 + - name: Build Android debug APK working-directory: openless-all/app run: npm run tauri:android:build From d5c2e4908ec1a56e1230897d9c3019d506aa8e90 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 16:10:15 +0800 Subject: [PATCH 11/83] Link Android NDK c++_static for cpal/oboe C++ runtime. Fixes startup UnsatisfiedLinkError for __cxa_pure_virtual when loading libopenless_lib.so. Co-authored-by: Cursor --- openless-all/app/src-tauri/build.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index fef616ba..591cb289 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -5,9 +5,18 @@ fn main() { #[cfg(target_os = "macos")] build_qwen_asr_macos(); + #[cfg(target_os = "android")] + link_android_cpp_runtime(); + tauri_build::build(); } +/// cpal → oboe → oboe-sys 会编译 C++;最终 cdylib 需显式链接 NDK libc++。 +#[cfg(target_os = "android")] +fn link_android_cpp_runtime() { + println!("cargo:rustc-link-lib=c++_static"); +} + #[cfg(target_os = "windows")] fn link_windows_common_controls_v6_manifest_dependency() { let mut source_path = std::path::PathBuf::from( From f759dc8d1eab401c2196db364dc30853cd624b55 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 16:25:16 +0800 Subject: [PATCH 12/83] Use whole-archive static=c++_static for Android libc++ link. Plain c++_static left __cxa_pure_virtual undefined at dlopen; whole-archive pulls required symbols from libc++_static.a. Co-authored-by: Cursor --- openless-all/app/src-tauri/build.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index 591cb289..4be192f1 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -11,10 +11,13 @@ fn main() { tauri_build::build(); } -/// cpal → oboe → oboe-sys 会编译 C++;最终 cdylib 需显式链接 NDK libc++。 +/// cpal → oboe → oboe-sys 会编译 C++;最终 cdylib 需显式静态链入 NDK libc++。 #[cfg(target_os = "android")] fn link_android_cpp_runtime() { - println!("cargo:rustc-link-lib=c++_static"); + // whole-archive:否则链接器可能不从 libc++_static.a 拉取 __cxa_pure_virtual 等符号。 + println!("cargo:rustc-link-arg=-Wl,--whole-archive"); + println!("cargo:rustc-link-lib=static=c++_static"); + println!("cargo:rustc-link-arg=-Wl,--no-whole-archive"); } #[cfg(target_os = "windows")] From d8de25de9b040c57da17379d84c649811a2c7419 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 16:53:04 +0800 Subject: [PATCH 13/83] Fix Android libc++ link via CARGO_CFG_TARGET_OS in build.rs. build-script cfg(target_os=android) never ran on Linux CI hosts; use runtime target check so whole-archive c++_static applies. Co-authored-by: Cursor --- openless-all/app/src-tauri/build.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index 4be192f1..e3369c80 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -5,14 +5,14 @@ fn main() { #[cfg(target_os = "macos")] build_qwen_asr_macos(); - #[cfg(target_os = "android")] - link_android_cpp_runtime(); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") { + link_android_cpp_runtime(); + } tauri_build::build(); } /// cpal → oboe → oboe-sys 会编译 C++;最终 cdylib 需显式静态链入 NDK libc++。 -#[cfg(target_os = "android")] fn link_android_cpp_runtime() { // whole-archive:否则链接器可能不从 libc++_static.a 拉取 __cxa_pure_virtual 等符号。 println!("cargo:rustc-link-arg=-Wl,--whole-archive"); From 79ef162bc95a879d30b918e07ccd7bbebb14e429 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 16:58:31 +0800 Subject: [PATCH 14/83] Use -lc++_static for Android NDK link in build.rs. static=c++_static failed without -L; CARGO_CFG_TARGET_OS ensures the link runs on Linux CI hosts. Co-authored-by: Cursor --- openless-all/app/src-tauri/build.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index e3369c80..a66034f1 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -12,12 +12,9 @@ fn main() { tauri_build::build(); } -/// cpal → oboe → oboe-sys 会编译 C++;最终 cdylib 需显式静态链入 NDK libc++。 +/// cpal → oboe → oboe-sys 会编译 C++;最终 cdylib 需显式链接 NDK libc++。 fn link_android_cpp_runtime() { - // whole-archive:否则链接器可能不从 libc++_static.a 拉取 __cxa_pure_virtual 等符号。 - println!("cargo:rustc-link-arg=-Wl,--whole-archive"); - println!("cargo:rustc-link-lib=static=c++_static"); - println!("cargo:rustc-link-arg=-Wl,--no-whole-archive"); + println!("cargo:rustc-link-lib=c++_static"); } #[cfg(target_os = "windows")] From 6362ddf20f1cdaa1132728ce81a5f07a277f69e6 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 17:14:25 +0800 Subject: [PATCH 15/83] Link Android libc++ via NDK -lc++_static with whole-archive. Co-authored-by: Cursor --- openless-all/app/src-tauri/build.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index a66034f1..ee52a615 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -14,7 +14,10 @@ fn main() { /// cpal → oboe → oboe-sys 会编译 C++;最终 cdylib 需显式链接 NDK libc++。 fn link_android_cpp_runtime() { - println!("cargo:rustc-link-lib=c++_static"); + // 通过 link-arg 走 NDK 工具链 sysroot;-lc++_static 对应 plan 中的 c++_static。 + println!("cargo:rustc-link-arg=-Wl,--whole-archive"); + println!("cargo:rustc-link-arg=-lc++_static"); + println!("cargo:rustc-link-arg=-Wl,--no-whole-archive"); } #[cfg(target_os = "windows")] From bd853535d25b31f2f14260bf764939b4b0891cbc Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 17:19:31 +0800 Subject: [PATCH 16/83] Link Android c++_static and c++abi for missing C++ ABI symbols. Co-authored-by: Cursor --- openless-all/app/src-tauri/build.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index ee52a615..c8995eb7 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -14,10 +14,9 @@ fn main() { /// cpal → oboe → oboe-sys 会编译 C++;最终 cdylib 需显式链接 NDK libc++。 fn link_android_cpp_runtime() { - // 通过 link-arg 走 NDK 工具链 sysroot;-lc++_static 对应 plan 中的 c++_static。 - println!("cargo:rustc-link-arg=-Wl,--whole-archive"); - println!("cargo:rustc-link-arg=-lc++_static"); - println!("cargo:rustc-link-arg=-Wl,--no-whole-archive"); + // oboe-ext 已部分静态链入 libc++;补链 c++abi 提供 __cxa_pure_virtual 等 ABI 符号。 + println!("cargo:rustc-link-lib=c++_static"); + println!("cargo:rustc-link-lib=c++abi"); } #[cfg(target_os = "windows")] From 6e1365de273865040c022926e2b147b11565792b Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 21:13:36 +0800 Subject: [PATCH 17/83] Avoid Android startup microphone probe panic --- openless-all/app/src-tauri/src/commands.rs | 17 ++++ openless-all/app/src-tauri/src/permissions.rs | 88 ++----------------- 2 files changed, 23 insertions(+), 82 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 8c92e7df..c7795cb0 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -699,11 +699,28 @@ pub fn get_windows_ime_status() -> WindowsImeStatus { } #[tauri::command] +#[cfg(mobile)] +pub fn list_microphone_devices() -> Result, String> { + Ok(Vec::new()) +} + +#[tauri::command] +#[cfg(not(mobile))] pub fn list_microphone_devices() -> Result, String> { crate::recorder::list_input_devices().map_err(|e| e.to_string()) } #[tauri::command] +#[cfg(mobile)] +pub async fn start_microphone_level_monitor( + _app: AppHandle, + _device_name: String, +) -> Result<(), String> { + Ok(()) +} + +#[tauri::command] +#[cfg(not(mobile))] pub async fn start_microphone_level_monitor( app: AppHandle, device_name: String, diff --git a/openless-all/app/src-tauri/src/permissions.rs b/openless-all/app/src-tauri/src/permissions.rs index c20363ed..6a3bb69a 100644 --- a/openless-all/app/src-tauri/src/permissions.rs +++ b/openless-all/app/src-tauri/src/permissions.rs @@ -249,8 +249,6 @@ mod platform { #[cfg(target_os = "android")] mod platform { use super::PermissionStatus; - use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; - use cpal::{SampleFormat, StreamConfig}; use std::time::Duration; const RECORD_AUDIO_PERMISSION: &str = "android.permission.RECORD_AUDIO"; @@ -263,14 +261,15 @@ mod platform { PermissionStatus::NotApplicable } - /// Android 麦克风:JNI 查 runtime permission,再用 cpal 短探针确认可用。 + /// Android 麦克风:启动期只查 runtime permission。 + /// 实际录音能力由用户触发 dictation 时的 Recorder::start 决定,避免权限轮询进入 + /// Android audio 探测路径后在 JavaBridge IPC 边界 panic。 pub fn check_microphone() -> PermissionStatus { match android_check_record_audio_permission() { - Ok(PermissionStatus::Granted) => probe_microphone_with_cpal(), Ok(other) => other, Err(err) => { - log::warn!("[mic] Android permission JNI check failed: {err}; falling back to cpal probe"); - probe_microphone_with_cpal() + log::warn!("[mic] Android permission JNI check failed: {err}"); + PermissionStatus::NotDetermined } } } @@ -283,86 +282,11 @@ mod platform { } Err(err) => { log::warn!("[mic] Android permission request failed: {err}"); - // 避免 onboarding 永远卡在 NotDetermined:请求失败时回落 cpal 探针, - // 仍不可用则返回 Denied 而不是 NotDetermined。 - match probe_microphone_with_cpal() { - PermissionStatus::NotDetermined => PermissionStatus::Denied, - other => other, - } + PermissionStatus::NotDetermined } } } - fn probe_microphone_with_cpal() -> PermissionStatus { - let host = cpal::default_host(); - let Some(device) = host.default_input_device() else { - log::warn!("[mic] Android: no default input device"); - return PermissionStatus::Denied; - }; - let supported = match device.default_input_config() { - Ok(config) => config, - Err(err) => return classify_audio_probe_error(err.to_string()), - }; - let sample_format = supported.sample_format(); - let config: StreamConfig = supported.config(); - match probe_input_stream(&device, &config, sample_format) { - Ok(()) => PermissionStatus::Granted, - Err(message) => classify_audio_probe_error(message), - } - } - - fn classify_audio_probe_error(message: String) -> PermissionStatus { - let lower = message.to_lowercase(); - log::warn!("[mic] Android input probe failed: {message}"); - if lower.contains("denied") - || lower.contains("permission") - || lower.contains("authoriz") - || lower.contains("access") - { - PermissionStatus::Denied - } else if lower.contains("not determined") || lower.contains("notdetermined") { - PermissionStatus::NotDetermined - } else { - PermissionStatus::Denied - } - } - - fn probe_input_stream( - device: &cpal::Device, - config: &StreamConfig, - sample_format: SampleFormat, - ) -> Result<(), String> { - let err_cb = |err| log::warn!("[mic] Android probe stream error: {err}"); - - macro_rules! build_probe { - ($t:ty) => { - device - .build_input_stream::<$t, _, _>( - config, - move |_data: &[$t], _info| {}, - err_cb, - None, - ) - .map_err(|e| e.to_string()) - }; - } - - let stream = match sample_format { - SampleFormat::F32 => build_probe!(f32), - SampleFormat::I16 => build_probe!(i16), - SampleFormat::U16 => build_probe!(u16), - SampleFormat::I32 => build_probe!(i32), - SampleFormat::I8 => build_probe!(i8), - SampleFormat::U8 => build_probe!(u8), - other => Err(format!("unsupported sample format: {other:?}")), - }?; - - stream.play().map_err(|e| e.to_string())?; - std::thread::sleep(Duration::from_millis(120)); - drop(stream); - Ok(()) - } - fn android_check_record_audio_permission() -> Result { with_main_activity(|env, activity| { let permission = env From 9d5c45510272869c9dd6ee1c71f166d4260a2658 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 21:43:45 +0800 Subject: [PATCH 18/83] Avoid Android permission JNI startup panic --- openless-all/app/src-tauri/src/permissions.rs | 90 +------------------ 1 file changed, 2 insertions(+), 88 deletions(-) diff --git a/openless-all/app/src-tauri/src/permissions.rs b/openless-all/app/src-tauri/src/permissions.rs index 6a3bb69a..35a868d8 100644 --- a/openless-all/app/src-tauri/src/permissions.rs +++ b/openless-all/app/src-tauri/src/permissions.rs @@ -249,9 +249,6 @@ mod platform { #[cfg(target_os = "android")] mod platform { use super::PermissionStatus; - use std::time::Duration; - - const RECORD_AUDIO_PERMISSION: &str = "android.permission.RECORD_AUDIO"; pub fn check_accessibility() -> PermissionStatus { PermissionStatus::NotApplicable @@ -265,94 +262,11 @@ mod platform { /// 实际录音能力由用户触发 dictation 时的 Recorder::start 决定,避免权限轮询进入 /// Android audio 探测路径后在 JavaBridge IPC 边界 panic。 pub fn check_microphone() -> PermissionStatus { - match android_check_record_audio_permission() { - Ok(other) => other, - Err(err) => { - log::warn!("[mic] Android permission JNI check failed: {err}"); - PermissionStatus::NotDetermined - } - } + PermissionStatus::NotDetermined } pub fn request_microphone() -> PermissionStatus { - match android_request_record_audio_permission() { - Ok(()) => { - std::thread::sleep(Duration::from_millis(250)); - check_microphone() - } - Err(err) => { - log::warn!("[mic] Android permission request failed: {err}"); - PermissionStatus::NotDetermined - } - } - } - - fn android_check_record_audio_permission() -> Result { - with_main_activity(|env, activity| { - let permission = env - .new_string(RECORD_AUDIO_PERMISSION) - .map_err(|e| format!("new permission string: {e}"))?; - - let granted = env - .call_method( - activity, - "checkSelfPermission", - "(Ljava/lang/String;)I", - &[jni::objects::JValue::Object(&permission)], - ) - .map_err(|e| format!("Activity.checkSelfPermission: {e}"))? - .i() - .map_err(|e| format!("checkSelfPermission result: {e}"))?; - - // PackageManager.PERMISSION_GRANTED == 0 - Ok(if granted == 0 { - PermissionStatus::Granted - } else { - PermissionStatus::Denied - }) - }) - } - - fn android_request_record_audio_permission() -> Result<(), String> { - with_main_activity(|env, activity| { - let permission = env - .new_string(RECORD_AUDIO_PERMISSION) - .map_err(|e| format!("new permission string: {e}"))?; - let permissions = env - .new_object_array(1, "java/lang/String", &permission) - .map_err(|e| format!("new permission array: {e}"))?; - - env.call_method( - activity, - "requestPermissions", - "([Ljava/lang/String;I)V", - &[ - jni::objects::JValue::Object(&permissions), - jni::objects::JValue::Int(0x4f50_4c53), // "OPLS" - ], - ) - .map_err(|e| format!("Activity.requestPermissions: {e}"))?; - Ok(()) - }) - } - - fn with_main_activity(f: F) -> Result - where - F: FnOnce(&mut jni::JNIEnv, jni::objects::JObject) -> Result, - { - let ctx = ndk_context::android_context(); - let vm = unsafe { - jni::JavaVM::from_raw(ctx.vm().cast()) - .map_err(|e| format!("JavaVM from raw: {e}"))? - }; - let mut env = vm - .attach_current_thread() - .map_err(|e| format!("attach JNI thread: {e}"))?; - let activity = unsafe { jni::objects::JObject::from_raw(ctx.context() as jni::sys::jobject) }; - if activity.is_null() { - return Err("Android activity handle is null".into()); - } - f(&mut env, activity) + PermissionStatus::NotDetermined } } From 19e393368a36076041c2c9db005c537a81570480 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 8 Jun 2026 22:14:47 +0800 Subject: [PATCH 19/83] Add responsive mobile app shell --- .../app/src/components/FloatingShell.tsx | 176 ++++++++++++++++-- .../app/src/components/SettingsModal.tsx | 70 +++++-- openless-all/app/src/lib/useMobileLayout.ts | 25 +++ openless-all/app/src/pages/History.tsx | 24 ++- openless-all/app/src/pages/Overview.tsx | 58 +++--- openless-all/app/src/pages/_atoms.tsx | 28 ++- 6 files changed, 311 insertions(+), 70 deletions(-) create mode 100644 openless-all/app/src/lib/useMobileLayout.ts diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 64198af8..3aba165e 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -33,6 +33,7 @@ import { } from '../lib/providerSetup'; import { type SettingsSectionId } from './SettingsModal'; import { useAppState, type AppTab } from '../state/useAppState'; +import { useMobileLayout } from '../lib/useMobileLayout'; interface NavItem { id: AppTab; @@ -71,6 +72,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia const [settingsInitialSection, setSettingsInitialSection] = useState(); const [providerPromptOpen, setProviderPromptOpen] = useState(false); const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false); + const mobileLayout = useMobileLayout(); // tab 切换的 cross-fade:旧页 blur+fade out(180ms),结束后挂载新页(走 ol-page-slide enter)。 // displayTab 是实际渲染的 tab,currentTab 是用户点中的目标 tab。 @@ -96,12 +98,17 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia [t], ); const Page = (NAV.find((n) => n.id === displayTab) ?? NAV[0]).cmp; + const activeNav = NAV.find(n => n.id === currentTab) ?? NAV[0]; // sidebar nav 滑动指示器:测量当前 active button 的 offsetTop / height, // 用一个 absolute pill 平滑滑过去,而不是每个按钮各自瞬切背景色。 const navItemRefs = useRef>([]); const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null); useLayoutEffect(() => { + if (mobileLayout) { + setPillRect(null); + return; + } if (settingsOpen) { setPillRect(null); return; @@ -114,7 +121,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia const el = navItemRefs.current[idx]; if (!el) return; setPillRect({ top: el.offsetTop, height: el.offsetHeight }); - }, [currentTab, settingsOpen, NAV]); + }, [currentTab, settingsOpen, NAV, mobileLayout]); useEffect(() => { let cancelled = false; @@ -198,21 +205,45 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia style={{ flex: 1, minHeight: 0, display: 'flex', + flexDirection: mobileLayout ? 'column' : 'row', background: 'transparent', overflow: 'hidden', position: 'relative', zIndex: 1, }}> - {/* Sidebar — 透明地坐在外层磨砂底板上,让 LOGO/导航/快捷键/BETA/footer 共用同一片磨砂玻璃 */} - + )} {/* Main content — Linux 禁用透明窗口后使用不透明面;其他平台保留玻璃层。 */} -
+
+ + {mobileLayout && ( + + )}
{/* Settings modal — rendered inside this window */} @@ -481,7 +542,94 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia box-shadow: none; } .ol-aura-console-main { - border-radius: var(--ol-panel-radius); + border-radius: ${mobileLayout ? '0' : 'var(--ol-panel-radius)'}; + } + .ol-aura-mobile-topbar { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: calc(10px + env(safe-area-inset-top, 0px)) 14px 10px; + border-bottom: 1px solid var(--ol-sidebar-border); + background: var(--ol-sidebar-bg); + } + .ol-aura-mobile-brand { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + } + .ol-aura-mobile-brand-mark { + width: 30px; + height: 30px; + border-radius: 8px; + flex-shrink: 0; + } + .ol-aura-mobile-brand-title { + font-size: 14px; + font-weight: 700; + color: var(--ol-ink); + line-height: 1.15; + } + .ol-aura-mobile-brand-section { + margin-top: 2px; + font-size: 11px; + color: var(--ol-ink-4); + font-family: var(--ol-font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .ol-aura-mobile-settings { + width: 36px; + height: 36px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + color: var(--ol-ink-3); + background: var(--ol-sidebar-settings-bg); + border: 1px solid var(--ol-sidebar-settings-border); + } + .ol-aura-mobile-settings-active { + color: var(--ol-ink); + background: var(--ol-sidebar-settings-active-bg); + } + .ol-aura-mobile-nav { + flex-shrink: 0; + display: grid; + grid-template-columns: repeat(${NAV.length}, minmax(0, 1fr)); + gap: 2px; + padding: 7px 8px calc(7px + env(safe-area-inset-bottom, 0px)); + border-top: 1px solid var(--ol-sidebar-border); + background: var(--ol-sidebar-bg); + } + .ol-aura-mobile-nav-btn { + min-width: 0; + height: 50px; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + border-radius: 12px; + color: var(--ol-ink-4); + font-size: 10px; + font-weight: 600; + line-height: 1.1; + } + .ol-aura-mobile-nav-btn span { + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + .ol-aura-mobile-nav-btn-active { + color: var(--ol-ink); + background: var(--ol-sidebar-pill-bg); + border: 1px solid var(--ol-sidebar-pill-border); } .ol-nav-btn.ol-nav-btn-active { color: var(--ol-ink); diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index 1609ff94..80d4e177 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -16,6 +16,7 @@ import { openExternal } from '../lib/ipc'; import type { OS } from './WindowChrome'; import { GeneralTab, ServicesTab, PrivacyTab, AdvancedTab } from '../pages/settings/tabs'; import { AboutSection } from '../pages/settings/AboutSection'; +import { useMobileLayout } from '../lib/useMobileLayout'; // 稳定 tab ID(与 i18n key `modal.sections.*` 一致)。 export type SettingsSectionId = @@ -58,16 +59,21 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett const { t } = useTranslation(); const [section, setSection] = useState(initialSettingsSection ?? 'general'); const savedToast = useSavedToastListener(); + const mobile = useMobileLayout(); // 与 sidebar nav 一致的滑动指示器:仅 tab 组有 pill;外链组永远不画 pill。 const tabRefs = useRef>([]); const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null); useLayoutEffect(() => { + if (mobile) { + setPillRect(null); + return; + } const idx = TAB_ITEMS.findIndex(it => it.id === section); const el = tabRefs.current[idx]; if (!el) return; setPillRect({ top: el.offsetTop, height: el.offsetHeight }); - }, [section]); + }, [section, mobile]); return (
@@ -90,8 +96,9 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett width: '100%', maxWidth: 920, height: '100%', - maxHeight: 620, + maxHeight: mobile ? 'none' : 620, display: 'flex', + flexDirection: mobile ? 'column' : 'row', overflow: 'hidden', animation: 'ol-modal-card-in 0.24s var(--ol-motion-spring)', position: 'relative', @@ -101,14 +108,24 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett {/* ─── 内容区 ────────────────────────────────────────────── diff --git a/openless-all/app/src/components/ui/Row.tsx b/openless-all/app/src/components/ui/Row.tsx index a2723017..867693ac 100644 --- a/openless-all/app/src/components/ui/Row.tsx +++ b/openless-all/app/src/components/ui/Row.tsx @@ -1,6 +1,7 @@ // Row — two-column row used in the Settings modal sub-sections. import type { ReactNode } from 'react'; +import { useMobileLayout } from '../../lib/useMobileLayout'; interface RowProps { label: string; @@ -9,13 +10,14 @@ interface RowProps { } export function Row({ label, desc, children }: RowProps) { + const mobile = useMobileLayout(); return ( -
-
+
+
{label}
{desc &&
{desc}
}
-
{children}
+
{children}
); } diff --git a/openless-all/app/src/components/ui/SelectLite.tsx b/openless-all/app/src/components/ui/SelectLite.tsx index 4822e50a..ec3ebebe 100644 --- a/openless-all/app/src/components/ui/SelectLite.tsx +++ b/openless-all/app/src/components/ui/SelectLite.tsx @@ -29,6 +29,7 @@ import { } from 'react'; import { createPortal } from 'react-dom'; import { Icon } from '../Icon'; +import { useMobileLayout } from '../../lib/useMobileLayout'; export interface SelectOption { value: string; @@ -81,6 +82,7 @@ export function SelectLite({ ariaLabel, onOpenChange, }: SelectLiteProps) { + const mobile = useMobileLayout(); const [open, setOpen] = useState(false); // leaving 让 popover 在卸载前播完 exit keyframe(用户报"没有收缩动画"——之前直接 unmount) const [leaving, setLeaving] = useState(false); @@ -264,6 +266,14 @@ export function SelectLite({ const triggerStyle: CSSProperties = { ...DEFAULT_TRIGGER_STYLE, ...style, + boxSizing: 'border-box', + ...(mobile + ? { + width: style?.width ?? '100%', + minWidth: 0, + maxWidth: '100%', + } + : null), opacity: disabled ? 0.5 : 1, cursor: disabled ? 'not-allowed' : 'default', }; diff --git a/openless-all/app/src/pages/settings/AboutSection.tsx b/openless-all/app/src/pages/settings/AboutSection.tsx index 82ac74cf..c970ba12 100644 --- a/openless-all/app/src/pages/settings/AboutSection.tsx +++ b/openless-all/app/src/pages/settings/AboutSection.tsx @@ -16,6 +16,9 @@ import { Card } from '../_atoms'; import { SectionTitle } from './shared'; import { CheckUpdateButton } from './CheckUpdateButton'; +const HELP_URL = 'https://github.com/appergb/openless#readme'; +const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases'; + export function AboutSection() { const { t } = useTranslation(); const [qqCopied, setQqCopied] = useState(false); @@ -76,10 +79,20 @@ export function AboutSection() { - + + + + + +
-
- {androidAccessibility?.message && ( - - {androidAccessibility.message} - - )} - - {!androidAccessibility?.enabled && ( - { void requestAndroidAccessibilityPermission().then(refreshAndroid); }}> - {t('settings.permissions.openSystem')} - - )} +
+
+ {androidAccessibility?.message && ( + + {androidAccessibility.message} + + )} + + {!androidAccessibility?.enabled && ( + { void requestAndroidAccessibilityPermission().then(refreshAndroid); }}> + {t('settings.permissions.openSystem')} + + )} +
+ + {t('settings.permissions.androidAccessibilityImpact')} +
- {t(`settings.permissions.androidInsertStrategyHint.${androidPrefs?.androidInsertStrategy ?? 'auto'}`)} + {t(`settings.permissions.androidInsertStrategyHint.${androidPrefs?.androidInsertStrategy ?? 'accessibility'}`)}
@@ -384,18 +366,6 @@ function NetworkStatusPill({ status }: { status: NetworkCheckResult | null }) { return {t('settings.permissions.networkOffline') ?? '不可用'}; } -function AndroidImeStatusPill({ status }: { status: AndroidImeStatus | null }) { - const { t } = useTranslation(); - if (!status) return {t('settings.permissions.checking')}; - if (status.selected) { - return {t('settings.permissions.androidImeSelected')}; - } - if (status.enabled) { - return {t('settings.permissions.androidImeEnabled')}; - } - return {t('settings.permissions.androidImeDisabled')}; -} - function AndroidOverlayStatusPill({ status }: { status: AndroidOverlayStatus | null }) { const { t } = useTranslation(); if (!status) return {t('settings.permissions.checking')}; From b84991f06c1e362d8c4f3f5f1fe7e5cf069f6146 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 00:22:06 +0800 Subject: [PATCH 56/83] Simplify Android overlay button --- .../app/scripts/copy-android-scaffolding.mjs | 2 +- .../OpenLessAccessibilityService.kt | 65 +++- .../OpenLessOverlayService.kt | 327 +++++++++++------- .../res/xml/openless_accessibility_config.xml | 2 +- 4 files changed, 253 insertions(+), 143 deletions(-) diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index d79ab2aa..21e086fc 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -25,7 +25,7 @@ const XML_FILES = [ const GENERATED_ACCESSIBILITY_CONFIG = ` updateKeyboardOverlayState() } - val className = event.className?.toString().orEmpty() - if (!className.contains("InputMethod", ignoreCase = true)) { - return + } + + override fun onInterrupt() = Unit + + override fun onDestroy() { + if (instance === this) { + instance = null } - if (OpenLessNative.nativeGetOverlayTriggerMode() != "keyboard") { + super.onDestroy() + } + + private fun updateKeyboardOverlayState() { + if (!isKeyboardTriggerMode()) { return } if (!OpenLessNative.nativeCanDrawOverlays(this)) { return } - startService( - Intent(this, OpenLessOverlayService::class.java).setAction(OpenLessOverlayService.ACTION_SHOW), - ) + val imeBounds = findInputMethodBounds() + val intent = Intent(this, OpenLessOverlayService::class.java).apply { + action = OpenLessOverlayService.ACTION_KEYBOARD_CHANGED + putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_VISIBLE, imeBounds != null) + imeBounds?.let { + putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_TOP, it.top) + putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_BOTTOM, it.bottom) + } + } + startService(intent) } - override fun onInterrupt() = Unit + private fun findInputMethodBounds(): Rect? { + for (window in windows) { + if (window.type != AccessibilityWindowInfo.TYPE_INPUT_METHOD) { + continue + } + val bounds = Rect() + window.getBoundsInScreen(bounds) + if (!bounds.isEmpty) { + return bounds + } + } + return null + } - override fun onDestroy() { - if (instance === this) { - instance = null + private fun isKeyboardTriggerMode(): Boolean { + return try { + OpenLessNative.nativeGetOverlayTriggerMode() == "keyboard" + } catch (_: Throwable) { + false } - super.onDestroy() } private fun performPasteToFocusedField(): Boolean { val root = rootInActiveWindow ?: return false - val focused = root.findFocus(AccessibilityEvent.TYPE_VIEW_FOCUSED) - ?: root.findFocus(AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) + val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) + ?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY) ?: return false if (!focused.isEditable) { focused.recycle() diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt index 35b1da34..3aba61a2 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt @@ -1,16 +1,16 @@ package com.openless.app +import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo import android.graphics.Color import android.graphics.PixelFormat import android.graphics.drawable.GradientDrawable -import android.content.pm.PackageManager -import android.content.pm.ServiceInfo -import android.Manifest import android.os.Build import android.os.IBinder import android.util.Log @@ -19,8 +19,8 @@ import android.view.MotionEvent import android.view.View import android.view.WindowManager import android.widget.FrameLayout -import android.widget.LinearLayout -import android.widget.TextView +import android.widget.ImageView +import android.widget.Toast import kotlin.math.abs /** @@ -31,18 +31,18 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private var windowManager: WindowManager? = null private var rootView: FrameLayout? = null private var layoutParams: WindowManager.LayoutParams? = null - private var expanded = false private var recording = false + private var processing = false + private var keyboardVisible = false + private var lastKeyboardTop = 0 + private var normalY = 120 private var dragStartX = 0 private var dragStartY = 0 private var paramStartX = 0 private var paramStartY = 0 private var dragging = false - private lateinit var pillView: TextView - private lateinit var panelView: LinearLayout - private lateinit var statusView: TextView - private lateinit var recordButton: TextView + private lateinit var iconButton: ImageView override fun onBind(intent: Intent?): IBinder? = null @@ -54,14 +54,9 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { - ACTION_SHOW -> { - showOverlay() - } + ACTION_SHOW -> showOverlay() ACTION_START_RECORDING -> { showOverlay() - expanded = true - panelView.visibility = View.VISIBLE - pillView.visibility = View.GONE startRecordingFromOverlay() } ACTION_HIDE -> { @@ -74,7 +69,8 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } stopSelf() } - ACTION_TOGGLE_EXPAND -> toggleExpanded() + ACTION_TOGGLE_EXPAND -> handleIconClick() + ACTION_KEYBOARD_CHANGED -> handleKeyboardChanged(intent) } return START_STICKY } @@ -94,43 +90,44 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList when (state) { "recording" -> { recording = true + processing = false if (!tryPromoteRecordingForeground()) { OpenLessNative.nativeCancelDictation() return } - statusView.text = "录音中…" - recordButton.text = "■" - recordButton.isEnabled = true + applyVisualState(OverlayVisualState.Recording) } "transcribing", "polishing" -> { recording = false - statusView.text = if (state == "transcribing") "识别中…" else "润色中…" - recordButton.text = "…" - recordButton.isEnabled = false + processing = true + applyVisualState(OverlayVisualState.Processing) } "done" -> { recording = false - statusView.text = message ?: "完成" - recordButton.text = "●" - recordButton.isEnabled = true + processing = false + applyVisualState(OverlayVisualState.Idle) } "error" -> { recording = false - statusView.text = message ?: "出错" - recordButton.text = "●" - recordButton.isEnabled = true + processing = false + applyVisualState(OverlayVisualState.Error) + message?.takeIf { it.isNotBlank() }?.let { showToast(it) } } "cancelled", "idle" -> { recording = false - statusView.text = "就绪" - recordButton.text = "●" - recordButton.isEnabled = true + processing = false + applyVisualState(OverlayVisualState.Idle) } } } private fun showOverlay() { - if (rootView != null) return + if (rootView != null) { + if (keyboardVisible) { + rootView?.post { moveAboveKeyboard(lastKeyboardTop) } + } + return + } windowManager = getSystemService(WINDOW_SERVICE) as WindowManager val params = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, @@ -147,20 +144,30 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList PixelFormat.TRANSLUCENT, ).apply { gravity = Gravity.TOP or Gravity.START - x = 24 - y = 120 + x = dp(24) + y = normalY } layoutParams = params val root = FrameLayout(this) - pillView = buildPillView() - panelView = buildPanelView() - panelView.visibility = View.GONE - root.addView(pillView) - root.addView(panelView) - attachDragHandler(root, params) + iconButton = buildIconButton() + root.addView( + iconButton, + FrameLayout.LayoutParams(dp(ICON_SIZE_DP), dp(ICON_SIZE_DP), Gravity.CENTER), + ) + attachDragHandler(iconButton, params) windowManager?.addView(root, params) rootView = root + applyVisualState( + when { + recording -> OverlayVisualState.Recording + processing -> OverlayVisualState.Processing + else -> OverlayVisualState.Idle + }, + ) + if (keyboardVisible) { + root.post { moveAboveKeyboard(lastKeyboardTop) } + } } private fun hideOverlay() { @@ -168,79 +175,70 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList windowManager?.removeView(view) rootView = null layoutParams = null - expanded = false } - private fun toggleExpanded() { - expanded = !expanded - pillView.visibility = if (expanded) View.GONE else View.VISIBLE - panelView.visibility = if (expanded) View.VISIBLE else View.GONE + private fun buildIconButton(): ImageView { + return ImageView(this).apply { + setImageResource(R.mipmap.ic_launcher_foreground) + scaleType = ImageView.ScaleType.CENTER_INSIDE + setPadding(dp(10), dp(10), dp(10), dp(10)) + contentDescription = "OpenLess" + isClickable = true + isFocusable = false + setOnClickListener { handleIconClick() } + } } - private fun buildPillView(): TextView { - return TextView(this).apply { - text = "OL" - setTextColor(Color.WHITE) - textSize = 18f - gravity = Gravity.CENTER - background = circleDrawable(Color.parseColor("#2563EB")) - minWidth = dp(64) - minHeight = dp(64) - setOnClickListener { - toggleExpanded() - } + private fun handleIconClick() { + if (processing) return + if (recording) { + OpenLessNative.nativeStopDictation() + } else { + startRecordingFromOverlay() } } - private fun buildPanelView(): LinearLayout { - statusView = TextView(this).apply { - text = "就绪" - setTextColor(Color.WHITE) - textSize = 12f - } - recordButton = TextView(this).apply { - text = "●" - textSize = 34f - setTextColor(Color.parseColor("#EF4444")) - gravity = Gravity.CENTER - background = circleDrawable(Color.parseColor("#1FFFFFFF")) - minWidth = dp(72) - minHeight = dp(72) - setOnClickListener { - if (recording) { - OpenLessNative.nativeStopDictation() - } else { - startRecordingFromOverlay() - } - } + private fun handleKeyboardChanged(intent: Intent) { + if (!isKeyboardTriggerMode()) return + val visible = intent.getBooleanExtra(EXTRA_KEYBOARD_VISIBLE, false) + keyboardVisible = visible + if (visible) { + lastKeyboardTop = intent.getIntExtra(EXTRA_KEYBOARD_TOP, 0) + showOverlay() + rootView?.post { moveAboveKeyboard(lastKeyboardTop) } + return } - val collapse = TextView(this).apply { - text = "—" - setTextColor(Color.WHITE) - textSize = 20f - gravity = Gravity.CENTER - minWidth = dp(56) - minHeight = dp(44) - setOnClickListener { - expanded = false - panelView.visibility = View.GONE - pillView.visibility = View.VISIBLE - } + if (recording || processing) { + restoreNormalPosition() + return } - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - background = roundedDrawable(Color.parseColor("#CC111827")) - setPadding(24, 20, 24, 20) - addView(collapse) - addView(statusView) - addView(recordButton, LinearLayout.LayoutParams(dp(72), dp(72)).apply { - topMargin = dp(8) - }) + hideOverlay() + } + + private fun moveAboveKeyboard(keyboardTop: Int) { + val params = layoutParams ?: return + val root = rootView ?: return + if (keyboardTop <= 0) return + val iconSize = overlaySize() + val minY = dp(8) + val maxY = (keyboardTop - iconSize - dp(12)).coerceAtLeast(minY) + if (params.y > maxY || params.y < minY) { + params.y = params.y.coerceIn(minY, maxY) } + val maxX = (resources.displayMetrics.widthPixels - iconSize - dp(8)).coerceAtLeast(dp(8)) + params.x = params.x.coerceIn(dp(8), maxX) + windowManager?.updateViewLayout(root, params) + } + + private fun restoreNormalPosition() { + val params = layoutParams ?: return + val root = rootView ?: return + params.y = normalY + windowManager?.updateViewLayout(root, params) } - private fun attachDragHandler(root: View, params: WindowManager.LayoutParams) { - root.setOnTouchListener { _, event -> + private fun attachDragHandler(view: View, params: WindowManager.LayoutParams) { + view.setOnTouchListener { touchedView, event -> when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { dragging = false @@ -248,55 +246,85 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList dragStartY = event.rawY.toInt() paramStartX = params.x paramStartY = params.y - false + true } MotionEvent.ACTION_MOVE -> { val dx = event.rawX.toInt() - dragStartX val dy = event.rawY.toInt() - dragStartY - if (abs(dx) > 8 || abs(dy) > 8) { + if (abs(dx) > DRAG_SLOP_PX || abs(dy) > DRAG_SLOP_PX) { dragging = true params.x = paramStartX + dx params.y = paramStartY + dy - windowManager?.updateViewLayout(root, params) + if (keyboardVisible) { + moveAboveKeyboard(lastKeyboardTop) + } else { + normalY = params.y + rootView?.let { windowManager?.updateViewLayout(it, params) } + } + } + true + } + MotionEvent.ACTION_UP -> { + if (!dragging) { + touchedView.performClick() } true } - MotionEvent.ACTION_UP -> dragging + MotionEvent.ACTION_CANCEL -> true else -> false } } } - private fun circleDrawable(color: Int): GradientDrawable { - return GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(color) - } - } - - private fun roundedDrawable(color: Int): GradientDrawable { - return GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = 24f - setColor(color) + private fun applyVisualState(state: OverlayVisualState) { + if (!::iconButton.isInitialized) return + val (alpha, fill, stroke, strokeWidth, enabled) = when (state) { + OverlayVisualState.Idle -> VisualStyle( + alpha = 0.58f, + fill = Color.parseColor("#66202A36"), + stroke = Color.parseColor("#66FFFFFF"), + strokeWidth = 1, + enabled = true, + ) + OverlayVisualState.Recording -> VisualStyle( + alpha = 1f, + fill = Color.parseColor("#E6111827"), + stroke = Color.parseColor("#F43F5E"), + strokeWidth = 3, + enabled = true, + ) + OverlayVisualState.Processing -> VisualStyle( + alpha = 0.86f, + fill = Color.parseColor("#D1111827"), + stroke = Color.parseColor("#38BDF8"), + strokeWidth = 2, + enabled = true, + ) + OverlayVisualState.Error -> VisualStyle( + alpha = 0.95f, + fill = Color.parseColor("#E67F1D1D"), + stroke = Color.parseColor("#EF4444"), + strokeWidth = 2, + enabled = true, + ) } + iconButton.alpha = alpha + iconButton.isEnabled = enabled + iconButton.background = circleDrawable(fill, stroke, dp(strokeWidth)) } private fun startRecordingFromOverlay() { - expanded = true - panelView.visibility = View.VISIBLE - pillView.visibility = View.GONE + showOverlay() if (tryPromoteRecordingForeground()) { OpenLessNative.nativeStartDictation() return } - statusView.text = "系统限制后台录音,请在 OpenLess 内开始" - recordButton.isEnabled = true + applyVisualState(OverlayVisualState.Error) } private fun tryPromoteRecordingForeground(): Boolean { if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { - statusView.text = "请先授予麦克风权限" + showToast("请先授予麦克风权限") return false } val notification = buildNotification("录音中") @@ -313,6 +341,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList true } catch (error: SecurityException) { Log.w(TAG, "microphone foreground service not allowed from current state", error) + showToast("系统限制后台录音,请在 OpenLess 内开始") false } } @@ -328,19 +357,67 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList return Notification.Builder(this, channelId) .setContentTitle("OpenLess") .setContentText(contentText) - .setSmallIcon(android.R.drawable.ic_btn_speak_now) + .setSmallIcon(R.mipmap.ic_launcher_foreground) .build() } + private fun circleDrawable(color: Int, strokeColor: Int, strokeWidth: Int): GradientDrawable { + return GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(color) + setStroke(strokeWidth, strokeColor) + } + } + + private fun overlaySize(): Int { + val root = rootView + val measured = maxOf(root?.width ?: 0, root?.height ?: 0) + return measured.takeIf { it > 0 } ?: dp(ICON_SIZE_DP) + } + + private fun isKeyboardTriggerMode(): Boolean { + return try { + OpenLessNative.nativeGetOverlayTriggerMode() == "keyboard" + } catch (error: Throwable) { + Log.w(TAG, "overlay trigger mode unavailable", error) + false + } + } + + private fun showToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + private fun dp(value: Int): Int { return (value * resources.displayMetrics.density).toInt() } + private data class VisualStyle( + val alpha: Float, + val fill: Int, + val stroke: Int, + val strokeWidth: Int, + val enabled: Boolean, + ) + + private enum class OverlayVisualState { + Idle, + Recording, + Processing, + Error, + } + companion object { const val ACTION_SHOW = "com.openless.app.overlay.SHOW" const val ACTION_HIDE = "com.openless.app.overlay.HIDE" const val ACTION_TOGGLE_EXPAND = "com.openless.app.overlay.TOGGLE_EXPAND" const val ACTION_START_RECORDING = "com.openless.app.overlay.START_RECORDING" + const val ACTION_KEYBOARD_CHANGED = "com.openless.app.overlay.KEYBOARD_CHANGED" + const val EXTRA_KEYBOARD_VISIBLE = "keyboard_visible" + const val EXTRA_KEYBOARD_TOP = "keyboard_top" + const val EXTRA_KEYBOARD_BOTTOM = "keyboard_bottom" + private const val ICON_SIZE_DP = 72 + private const val DRAG_SLOP_PX = 8 private const val NOTIFICATION_ID = 42001 private const val TAG = "OpenLessOverlayService" diff --git a/openless-all/app/src-tauri/android-scaffolding/res/xml/openless_accessibility_config.xml b/openless-all/app/src-tauri/android-scaffolding/res/xml/openless_accessibility_config.xml index 22d78c5a..281b8c75 100644 --- a/openless-all/app/src-tauri/android-scaffolding/res/xml/openless_accessibility_config.xml +++ b/openless-all/app/src-tauri/android-scaffolding/res/xml/openless_accessibility_config.xml @@ -1,6 +1,6 @@ Date: Wed, 10 Jun 2026 00:51:17 +0800 Subject: [PATCH 57/83] Persist Android overlay icon position --- .../OpenLessAccessibilityService.kt | 7 +- .../OpenLessOverlayService.kt | 97 +++++++++---------- 2 files changed, 49 insertions(+), 55 deletions(-) diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt index 308d03d9..cbaf68eb 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt @@ -37,7 +37,7 @@ class OpenLessAccessibilityService : AccessibilityService() { } private fun updateKeyboardOverlayState() { - if (!isKeyboardTriggerMode()) { + if (!shouldTrackKeyboard()) { return } if (!OpenLessNative.nativeCanDrawOverlays(this)) { @@ -69,9 +69,10 @@ class OpenLessAccessibilityService : AccessibilityService() { return null } - private fun isKeyboardTriggerMode(): Boolean { + private fun shouldTrackKeyboard(): Boolean { return try { - OpenLessNative.nativeGetOverlayTriggerMode() == "keyboard" + OpenLessNative.nativeGetOverlayTriggerMode() == "keyboard" || + OpenLessNative.nativeIsOverlayVisible() } catch (_: Throwable) { false } diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt index 3aba61a2..1f60c824 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt @@ -34,8 +34,6 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private var recording = false private var processing = false private var keyboardVisible = false - private var lastKeyboardTop = 0 - private var normalY = 120 private var dragStartX = 0 private var dragStartY = 0 private var paramStartX = 0 @@ -122,13 +120,9 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } private fun showOverlay() { - if (rootView != null) { - if (keyboardVisible) { - rootView?.post { moveAboveKeyboard(lastKeyboardTop) } - } - return - } + if (rootView != null) return windowManager = getSystemService(WINDOW_SERVICE) as WindowManager + val savedPosition = loadSavedPosition() val params = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, @@ -144,8 +138,8 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList PixelFormat.TRANSLUCENT, ).apply { gravity = Gravity.TOP or Gravity.START - x = dp(24) - y = normalY + x = savedPosition.first + y = savedPosition.second } layoutParams = params @@ -165,9 +159,6 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList else -> OverlayVisualState.Idle }, ) - if (keyboardVisible) { - root.post { moveAboveKeyboard(lastKeyboardTop) } - } } private fun hideOverlay() { @@ -179,9 +170,9 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private fun buildIconButton(): ImageView { return ImageView(this).apply { - setImageResource(R.mipmap.ic_launcher_foreground) + setImageResource(R.mipmap.ic_launcher) scaleType = ImageView.ScaleType.CENTER_INSIDE - setPadding(dp(10), dp(10), dp(10), dp(10)) + setPadding(dp(6), dp(6), dp(6), dp(6)) contentDescription = "OpenLess" isClickable = true isFocusable = false @@ -199,42 +190,17 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } private fun handleKeyboardChanged(intent: Intent) { - if (!isKeyboardTriggerMode()) return + val keyboardTrigger = isKeyboardTriggerMode() + if (!keyboardTrigger && rootView == null) return val visible = intent.getBooleanExtra(EXTRA_KEYBOARD_VISIBLE, false) keyboardVisible = visible - if (visible) { - lastKeyboardTop = intent.getIntExtra(EXTRA_KEYBOARD_TOP, 0) + if (visible && keyboardTrigger) { showOverlay() - rootView?.post { moveAboveKeyboard(lastKeyboardTop) } - return - } - if (recording || processing) { - restoreNormalPosition() return } - hideOverlay() - } - - private fun moveAboveKeyboard(keyboardTop: Int) { - val params = layoutParams ?: return - val root = rootView ?: return - if (keyboardTop <= 0) return - val iconSize = overlaySize() - val minY = dp(8) - val maxY = (keyboardTop - iconSize - dp(12)).coerceAtLeast(minY) - if (params.y > maxY || params.y < minY) { - params.y = params.y.coerceIn(minY, maxY) + if (!visible && keyboardTrigger && !recording && !processing) { + hideOverlay() } - val maxX = (resources.displayMetrics.widthPixels - iconSize - dp(8)).coerceAtLeast(dp(8)) - params.x = params.x.coerceIn(dp(8), maxX) - windowManager?.updateViewLayout(root, params) - } - - private fun restoreNormalPosition() { - val params = layoutParams ?: return - val root = rootView ?: return - params.y = normalY - windowManager?.updateViewLayout(root, params) } private fun attachDragHandler(view: View, params: WindowManager.LayoutParams) { @@ -255,18 +221,16 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList dragging = true params.x = paramStartX + dx params.y = paramStartY + dy - if (keyboardVisible) { - moveAboveKeyboard(lastKeyboardTop) - } else { - normalY = params.y - rootView?.let { windowManager?.updateViewLayout(it, params) } - } + clampToScreen(params) + rootView?.let { windowManager?.updateViewLayout(it, params) } } true } MotionEvent.ACTION_UP -> { if (!dragging) { touchedView.performClick() + } else { + savePosition(params.x, params.y) } true } @@ -357,7 +321,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList return Notification.Builder(this, channelId) .setContentTitle("OpenLess") .setContentText(contentText) - .setSmallIcon(R.mipmap.ic_launcher_foreground) + .setSmallIcon(R.mipmap.ic_launcher) .build() } @@ -375,6 +339,32 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList return measured.takeIf { it > 0 } ?: dp(ICON_SIZE_DP) } + private fun clampToScreen(params: WindowManager.LayoutParams) { + val iconSize = overlaySize() + val margin = dp(8) + val maxX = (resources.displayMetrics.widthPixels - iconSize - margin).coerceAtLeast(margin) + val maxY = (resources.displayMetrics.heightPixels - iconSize - margin).coerceAtLeast(margin) + params.x = params.x.coerceIn(margin, maxX) + params.y = params.y.coerceIn(margin, maxY) + } + + private fun loadSavedPosition(): Pair { + val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + val defaultX = dp(24) + val defaultY = dp(120) + val x = prefs.getInt(PREF_KEY_X, defaultX) + val y = prefs.getInt(PREF_KEY_Y, defaultY) + return x to y + } + + private fun savePosition(x: Int, y: Int) { + getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + .edit() + .putInt(PREF_KEY_X, x) + .putInt(PREF_KEY_Y, y) + .apply() + } + private fun isKeyboardTriggerMode(): Boolean { return try { OpenLessNative.nativeGetOverlayTriggerMode() == "keyboard" @@ -418,6 +408,9 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList const val EXTRA_KEYBOARD_BOTTOM = "keyboard_bottom" private const val ICON_SIZE_DP = 72 private const val DRAG_SLOP_PX = 8 + private const val PREFS_NAME = "openless_overlay" + private const val PREF_KEY_X = "overlay_x" + private const val PREF_KEY_Y = "overlay_y" private const val NOTIFICATION_ID = 42001 private const val TAG = "OpenLessOverlayService" From 691c816977ae5a247ea5b9270f77d63f9d7a4735 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 01:06:07 +0800 Subject: [PATCH 58/83] Fix Android overlay keyboard trigger in other apps --- .../app/scripts/copy-android-scaffolding.mjs | 1 + .../OpenLessAccessibilityService.kt | 15 ++++++ .../OpenLessAndroidPreferences.kt | 51 +++++++++++++++++++ .../OpenLessOverlayService.kt | 3 ++ 4 files changed, 70 insertions(+) create mode 100644 openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index 21e086fc..2a1cc8f8 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -12,6 +12,7 @@ const resXmlDest = join(genRoot, 'res/xml'); const KOTLIN_FILES = [ 'OpenLessNative.kt', + 'OpenLessAndroidPreferences.kt', 'OpenLessApplication.kt', 'OpenLessOverlayService.kt', 'OpenLessOverlayBridge.kt', diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt index cbaf68eb..7135afc7 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt @@ -70,6 +70,13 @@ class OpenLessAccessibilityService : AccessibilityService() { } private fun shouldTrackKeyboard(): Boolean { + val localMode = OpenLessAndroidPreferences.overlayTriggerMode(this) + if (localMode == "keyboard") { + return true + } + if (localMode == "always" || localMode == "background") { + return isOverlayVisible() + } return try { OpenLessNative.nativeGetOverlayTriggerMode() == "keyboard" || OpenLessNative.nativeIsOverlayVisible() @@ -78,6 +85,14 @@ class OpenLessAccessibilityService : AccessibilityService() { } } + private fun isOverlayVisible(): Boolean { + return try { + OpenLessNative.nativeIsOverlayVisible() + } catch (_: Throwable) { + false + } + } + private fun performPasteToFocusedField(): Boolean { val root = rootInActiveWindow ?: return false val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt new file mode 100644 index 00000000..36fa638c --- /dev/null +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt @@ -0,0 +1,51 @@ +package com.openless.app + +import android.content.Context +import android.util.Log +import java.io.File +import org.json.JSONObject + +/** + * Reads Android-visible preferences without depending on the Rust coordinator. + */ +object OpenLessAndroidPreferences { + private const val TAG = "OpenLessAndroidPrefs" + private const val APP_DIR = "OpenLess" + private const val PREFERENCES_FILE = "preferences.json" + private const val KEY_OVERLAY_TRIGGER = "androidOverlayTrigger" + private val VALID_OVERLAY_TRIGGERS = setOf("background", "keyboard", "always") + + fun overlayTriggerMode(context: Context): String? { + val value = readPreferenceString(context, KEY_OVERLAY_TRIGGER) ?: return null + return value.takeIf { it in VALID_OVERLAY_TRIGGERS } + } + + private fun readPreferenceString(context: Context, key: String): String? { + for (file in preferenceFiles(context).distinctBy { it.absolutePath }) { + if (!file.isFile) { + continue + } + val value = try { + JSONObject(file.readText()).optString(key, "") + } catch (error: Throwable) { + Log.w(TAG, "read ${file.absolutePath} failed", error) + "" + } + if (value.isNotBlank()) { + return value + } + } + return null + } + + private fun preferenceFiles(context: Context): List { + val files = mutableListOf() + val envDir = System.getenv("TAURI_ANDROID_APP_DATA_DIR") + if (!envDir.isNullOrBlank()) { + files += File(File(envDir), APP_DIR).resolve(PREFERENCES_FILE) + } + files += File(File(context.cacheDir, APP_DIR), PREFERENCES_FILE) + files += File(File(context.filesDir, APP_DIR), PREFERENCES_FILE) + return files + } +} diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt index 1f60c824..a405293c 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt @@ -366,6 +366,9 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } private fun isKeyboardTriggerMode(): Boolean { + OpenLessAndroidPreferences.overlayTriggerMode(this)?.let { mode -> + return mode == "keyboard" + } return try { OpenLessNative.nativeGetOverlayTriggerMode() == "keyboard" } catch (error: Throwable) { From 3cdb0ab844b32ed092269e44bf7b7d2cb5c02229 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 01:44:10 +0800 Subject: [PATCH 59/83] Harden Android overlay fallback and icon handling --- openless-all/app/public/AppIcon.png | Bin 371995 -> 87011 bytes .../app/scripts/copy-android-scaffolding.mjs | 28 +++++++++++++++++- .../OpenLessAccessibilityService.kt | 20 ++++++++++++- .../OpenLessApplication.kt | 26 ++++++++++++++-- .../OpenLessOverlayService.kt | 28 ++++++++++++------ openless-all/app/src-tauri/src/coordinator.rs | 5 ++++ openless-all/app/src/pages/History.tsx | 6 ++-- .../src/pages/settings/PermissionsSection.tsx | 14 +++++++++ openless-all/app/src/styles/tokens.css | 6 ++++ 9 files changed, 117 insertions(+), 16 deletions(-) diff --git a/openless-all/app/public/AppIcon.png b/openless-all/app/public/AppIcon.png index ee876eb5f9c93fb69deb5b29dc0f0278413d5ed2..3bdcb6b84e31229cdf17a7188b1a6f0e84e8000f 100755 GIT binary patch literal 87011 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 H1fTyWypAGq literal 371995 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 ${dest}`); + continue; + } + ensureDir(dirname(dest), dryRun); + copyFileSync(src, dest); + console.log(`Copied ${dest}`); + } +} + function main() { const { dryRun } = parseArgs(process.argv.slice(2)); @@ -116,6 +141,7 @@ function main() { ensureDir(kotlinDest, dryRun); ensureDir(resXmlDest, dryRun); + copyDirectoryContents(androidIconRoot, resDest, dryRun); for (const file of KOTLIN_FILES) { const src = join(scaffoldingRoot, file); diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt index 7135afc7..ece6f295 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt @@ -3,6 +3,8 @@ package com.openless.app import android.accessibilityservice.AccessibilityService import android.content.Intent import android.graphics.Rect +import android.os.Handler +import android.os.Looper import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import android.view.accessibility.AccessibilityWindowInfo @@ -11,11 +13,14 @@ import android.view.accessibility.AccessibilityWindowInfo * Detects IME windows for overlay keyboard trigger mode and performs paste insertion. */ class OpenLessAccessibilityService : AccessibilityService() { + private val mainHandler = Handler(Looper.getMainLooper()) + private val keyboardRefreshRunnable = Runnable { updateKeyboardOverlayState() } override fun onServiceConnected() { super.onServiceConnected() instance = this updateKeyboardOverlayState() + scheduleKeyboardOverlayRefresh() } override fun onAccessibilityEvent(event: AccessibilityEvent?) { @@ -23,19 +28,30 @@ class OpenLessAccessibilityService : AccessibilityService() { when (event.eventType) { AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, AccessibilityEvent.TYPE_WINDOWS_CHANGED, - AccessibilityEvent.TYPE_VIEW_FOCUSED -> updateKeyboardOverlayState() + AccessibilityEvent.TYPE_VIEW_FOCUSED -> { + updateKeyboardOverlayState() + scheduleKeyboardOverlayRefresh() + } } } override fun onInterrupt() = Unit override fun onDestroy() { + mainHandler.removeCallbacks(keyboardRefreshRunnable) if (instance === this) { instance = null } super.onDestroy() } + private fun scheduleKeyboardOverlayRefresh() { + mainHandler.removeCallbacks(keyboardRefreshRunnable) + for (delayMs in KEYBOARD_REFRESH_DELAYS_MS) { + mainHandler.postDelayed(keyboardRefreshRunnable, delayMs) + } + } + private fun updateKeyboardOverlayState() { if (!shouldTrackKeyboard()) { return @@ -131,5 +147,7 @@ class OpenLessAccessibilityService : AccessibilityService() { ) ?: return false return services.contains("${context.packageName}/${OpenLessAccessibilityService::class.java.name}") } + + private val KEYBOARD_REFRESH_DELAYS_MS = longArrayOf(120L, 360L, 900L, 1600L) } } diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt index ec1ae8df..533e26f6 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt @@ -3,6 +3,7 @@ package com.openless.app import android.app.Activity import android.app.Application import android.os.Bundle +import android.util.Log /** * Registers activity lifecycle hooks for overlay background trigger mode. @@ -30,7 +31,8 @@ class OpenLessApplication : Application() { } private fun maybeShowOverlayOnBackground() { - if (OpenLessNative.nativeGetOverlayTriggerMode() != "background") { + val trigger = effectiveOverlayTriggerMode() + if (trigger != "background" && trigger != "always") { return } if (!OpenLessNative.nativeCanDrawOverlays(this)) { @@ -40,11 +42,31 @@ class OpenLessApplication : Application() { } private fun maybeHideOverlayOnForeground() { - if (OpenLessNative.nativeGetOverlayTriggerMode() == "always") { + if (effectiveOverlayTriggerMode() == "always") { + if (OpenLessNative.nativeCanDrawOverlays(this) && !OpenLessNative.nativeIsOverlayVisible()) { + OpenLessNative.nativeShowOverlay(this) + } return } if (OpenLessNative.nativeIsOverlayVisible()) { OpenLessNative.nativeHideOverlay(this) } } + + private fun effectiveOverlayTriggerMode(): String { + val configured = OpenLessAndroidPreferences.overlayTriggerMode(this) ?: try { + OpenLessNative.nativeGetOverlayTriggerMode() + } catch (error: Throwable) { + Log.w(TAG, "overlay trigger mode unavailable", error) + "background" + } + if (configured == "keyboard" && !OpenLessAccessibilityService.isEnabled(this)) { + return "always" + } + return configured + } + + companion object { + private const val TAG = "OpenLessApplication" + } } diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt index a405293c..dfb193d6 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt @@ -40,6 +40,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private var paramStartY = 0 private var dragging = false + private lateinit var iconContainer: FrameLayout private lateinit var iconButton: ImageView override fun onBind(intent: Intent?): IBinder? = null @@ -143,13 +144,20 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } layoutParams = params - val root = FrameLayout(this) + val root = FrameLayout(this).apply { + contentDescription = "OpenLess" + isClickable = true + isFocusable = false + setPadding(dp(ICON_PADDING_DP), dp(ICON_PADDING_DP), dp(ICON_PADDING_DP), dp(ICON_PADDING_DP)) + setOnClickListener { handleIconClick() } + } + iconContainer = root iconButton = buildIconButton() root.addView( iconButton, - FrameLayout.LayoutParams(dp(ICON_SIZE_DP), dp(ICON_SIZE_DP), Gravity.CENTER), + FrameLayout.LayoutParams(dp(ICON_IMAGE_SIZE_DP), dp(ICON_IMAGE_SIZE_DP), Gravity.CENTER), ) - attachDragHandler(iconButton, params) + attachDragHandler(root, params) windowManager?.addView(root, params) rootView = root applyVisualState( @@ -172,11 +180,10 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList return ImageView(this).apply { setImageResource(R.mipmap.ic_launcher) scaleType = ImageView.ScaleType.CENTER_INSIDE - setPadding(dp(6), dp(6), dp(6), dp(6)) + setPadding(0, 0, 0, 0) contentDescription = "OpenLess" - isClickable = true + isClickable = false isFocusable = false - setOnClickListener { handleIconClick() } } } @@ -241,7 +248,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } private fun applyVisualState(state: OverlayVisualState) { - if (!::iconButton.isInitialized) return + if (!::iconContainer.isInitialized || !::iconButton.isInitialized) return val (alpha, fill, stroke, strokeWidth, enabled) = when (state) { OverlayVisualState.Idle -> VisualStyle( alpha = 0.58f, @@ -272,9 +279,10 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList enabled = true, ) } - iconButton.alpha = alpha + iconContainer.alpha = alpha + iconContainer.isEnabled = enabled + iconContainer.background = circleDrawable(fill, stroke, dp(strokeWidth)) iconButton.isEnabled = enabled - iconButton.background = circleDrawable(fill, stroke, dp(strokeWidth)) } private fun startRecordingFromOverlay() { @@ -410,6 +418,8 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList const val EXTRA_KEYBOARD_TOP = "keyboard_top" const val EXTRA_KEYBOARD_BOTTOM = "keyboard_bottom" private const val ICON_SIZE_DP = 72 + private const val ICON_IMAGE_SIZE_DP = 56 + private const val ICON_PADDING_DP = 8 private const val DRAG_SLOP_PX = 8 private const val PREFS_NAME = "openless_overlay" private const val PREF_KEY_X = "overlay_x" diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9f60f852..b702ae5e 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -506,6 +506,11 @@ impl Coordinator { AndroidOverlayTrigger::Always => { let _ = crate::android_overlay::show_android_overlay(); } + AndroidOverlayTrigger::Keyboard + if !crate::android_accessibility::get_android_accessibility_status().enabled => + { + let _ = crate::android_overlay::show_android_overlay(); + } AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard => { let _ = crate::android_overlay::hide_android_overlay(); } diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 837bb0d9..de0074a2 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -205,9 +205,9 @@ export function History() { onClick={() => setFilter(f.id)} style={{ padding: '3px 9px', fontSize: 11, borderRadius: 999, - border: '0.5px solid ' + (filter === f.id ? 'var(--ol-ink)' : 'var(--ol-line-strong)'), - background: filter === f.id ? 'var(--ol-ink)' : 'transparent', - color: filter === f.id ? '#fff' : 'var(--ol-ink-3)', + border: '0.5px solid ' + (filter === f.id ? 'var(--ol-pill-selected-border)' : 'var(--ol-line-strong)'), + background: filter === f.id ? 'var(--ol-pill-selected-bg)' : 'transparent', + color: filter === f.id ? 'var(--ol-pill-selected-ink)' : 'var(--ol-ink-3)', cursor: 'default', fontFamily: 'inherit', fontWeight: 500, transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)', }} diff --git a/openless-all/app/src/pages/settings/PermissionsSection.tsx b/openless-all/app/src/pages/settings/PermissionsSection.tsx index e28249fb..91d58858 100644 --- a/openless-all/app/src/pages/settings/PermissionsSection.tsx +++ b/openless-all/app/src/pages/settings/PermissionsSection.tsx @@ -19,6 +19,7 @@ import { requestAndroidOverlayPermission, requestMicrophonePermission, setSettings, + showAndroidOverlay, } from '../../lib/ipc'; import type { NetworkCheckResult } from '../../lib/ipc'; import { getPlatformCapabilities } from '../../lib/platform'; @@ -68,6 +69,15 @@ export function PermissionsSection() { androidInsertStrategy: settings.androidInsertStrategy, androidOverlayTrigger: settings.androidOverlayTrigger, }); + if ( + settings.androidOverlayTrigger === 'keyboard' && + !accessibility.enabled && + overlay.permission === 'granted' && + !overlay.overlayVisible + ) { + await showAndroidOverlay(); + setAndroidOverlay(await getAndroidOverlayStatus()); + } }; const refreshPermissions = async () => { @@ -113,6 +123,9 @@ export function PermissionsSection() { : undefined; // 麦克风检查会短暂打开输入流,避免每秒探测导致隐私指示器频繁闪烁。 const permissionId = window.setInterval(refreshPermissions, 10000); + const androidId = platformCaps?.platform === 'android' + ? window.setInterval(refreshAndroid, 3000) + : undefined; const networkId = window.setInterval(refreshNetwork, 30000); const onFocus = () => { refreshPermissions(); @@ -130,6 +143,7 @@ export function PermissionsSection() { return () => { if (hotkeyId !== undefined) window.clearInterval(hotkeyId); window.clearInterval(permissionId); + if (androidId !== undefined) window.clearInterval(androidId); window.clearInterval(networkId); window.removeEventListener('focus', onFocus); }; diff --git a/openless-all/app/src/styles/tokens.css b/openless-all/app/src/styles/tokens.css index b205a28c..a905b340 100755 --- a/openless-all/app/src/styles/tokens.css +++ b/openless-all/app/src/styles/tokens.css @@ -143,6 +143,9 @@ --ol-pill-dark-bg: linear-gradient(180deg, rgba(28,34,45,0.92), rgba(18,23,31,0.88)); --ol-pill-dark-border: rgba(15,23,42,0.10); --ol-pill-dark-shadow: 0 12px 20px -18px rgba(15,23,42,0.22); + --ol-pill-selected-bg: #111827; + --ol-pill-selected-ink: #ffffff; + --ol-pill-selected-border: #111827; --ol-segmented-bg: rgba(0,0,0,0.04); --ol-segmented-active-bg: #ffffff; --ol-segmented-active-shadow: 0 1px 2px rgba(0,0,0,.06), 0 0 0 0.5px rgba(0,0,0,.06); @@ -281,6 +284,9 @@ --ol-pill-dark-bg: rgba(255,255,255,0.08); --ol-pill-dark-border: rgba(255,255,255,0.12); --ol-pill-dark-shadow: none; + --ol-pill-selected-bg: rgba(96,165,250,0.18); + --ol-pill-selected-ink: #f4f7fb; + --ol-pill-selected-border: rgba(96,165,250,0.40); --ol-segmented-bg: rgba(255,255,255,0.06); --ol-segmented-active-bg: rgba(255,255,255,0.12); --ol-segmented-active-shadow: none; From c74548af1c8466bc8860fae6cc07fe513104d69b Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 01:47:15 +0800 Subject: [PATCH 60/83] Avoid obsolete Android SDK tools package --- .github/workflows/android-apk.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 747a369c..dee7c1e0 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -32,6 +32,8 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 + with: + packages: platform-tools - name: Install NDK and accept SDK licenses shell: bash From c3a7eb13c6f5f82b7665013ee895627f3644d744 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 09:06:51 +0800 Subject: [PATCH 61/83] Isolate Android accessibility overlay trigger --- .../app/scripts/copy-android-scaffolding.mjs | 2 + .../merge-android-overlay-manifest.mjs | 5 + .../OpenLessAccessibilityCommandReceiver.kt | 21 +++++ .../OpenLessAccessibilityService.kt | 93 +++++++++++++------ .../android-scaffolding/OpenLessAppContext.kt | 13 +++ .../OpenLessApplication.kt | 42 ++++++--- .../OpenLessOverlayService.kt | 33 ++++--- .../src-tauri/src/android_accessibility.rs | 24 ++++- openless-all/app/src-tauri/src/android_jni.rs | 14 +++ 9 files changed, 193 insertions(+), 54 deletions(-) create mode 100644 openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityCommandReceiver.kt create mode 100644 openless-all/app/src-tauri/android-scaffolding/OpenLessAppContext.kt diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index aaedcb94..5e94379f 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -13,12 +13,14 @@ const resDest = join(genRoot, 'res'); const resXmlDest = join(genRoot, 'res/xml'); const KOTLIN_FILES = [ + 'OpenLessAppContext.kt', 'OpenLessNative.kt', 'OpenLessAndroidPreferences.kt', 'OpenLessApplication.kt', 'OpenLessOverlayService.kt', 'OpenLessOverlayBridge.kt', 'OpenLessAccessibilityService.kt', + 'OpenLessAccessibilityCommandReceiver.kt', 'OverlayPermissionActivity.kt', ]; diff --git a/openless-all/app/scripts/merge-android-overlay-manifest.mjs b/openless-all/app/scripts/merge-android-overlay-manifest.mjs index c4b42a98..7d8b8947 100644 --- a/openless-all/app/scripts/merge-android-overlay-manifest.mjs +++ b/openless-all/app/scripts/merge-android-overlay-manifest.mjs @@ -25,6 +25,7 @@ const SERVICE_SNIPPETS = [ android:foregroundServiceType="microphone" />`, ` @@ -34,6 +35,10 @@ const SERVICE_SNIPPETS = [ android:name="android.accessibilityservice" android:resource="@xml/openless_accessibility_config" /> `, + ``, `= android.os.Build.VERSION_CODES.M) { + Settings.canDrawOverlays(this) + } else { + true } } @@ -123,6 +120,13 @@ class OpenLessAccessibilityService : AccessibilityService() { return pasted } + private fun markServiceAlive() { + getSharedPreferences(PREFS_NAME, prefsMode()) + .edit() + .putLong(PREF_KEY_LAST_HEARTBEAT, System.currentTimeMillis()) + .apply() + } + companion object { @Volatile var instance: OpenLessAccessibilityService? = null @@ -130,24 +134,61 @@ class OpenLessAccessibilityService : AccessibilityService() { @JvmStatic fun pasteToFocusedField(): Boolean { - return instance?.performPasteToFocusedField() == true + instance?.let { return it.performPasteToFocusedField() } + return sendPasteRequestToAccessibilityProcess() } @JvmStatic - fun isEnabled(context: android.content.Context): Boolean { - val enabled = android.provider.Settings.Secure.getInt( + fun isEnabled(context: Context): Boolean { + val enabled = Settings.Secure.getInt( context.contentResolver, - android.provider.Settings.Secure.ACCESSIBILITY_ENABLED, + Settings.Secure.ACCESSIBILITY_ENABLED, 0, ) == 1 if (!enabled) return false - val services = android.provider.Settings.Secure.getString( + val services = Settings.Secure.getString( context.contentResolver, - android.provider.Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, ) ?: return false return services.contains("${context.packageName}/${OpenLessAccessibilityService::class.java.name}") } + @JvmStatic + fun isOperational(context: Context): Boolean { + if (!isEnabled(context)) return false + val lastHeartbeat = context + .getSharedPreferences(PREFS_NAME, prefsMode()) + .getLong(PREF_KEY_LAST_HEARTBEAT, 0L) + if (lastHeartbeat <= 0L) return false + return System.currentTimeMillis() - lastHeartbeat <= HEARTBEAT_STALE_MS + } + + internal fun performPasteFromCommand(): Boolean { + return instance?.performPasteToFocusedField() == true + } + + private fun sendPasteRequestToAccessibilityProcess(): Boolean { + val context = OpenLessAppContext.context ?: return false + if (!isOperational(context)) return false + return try { + val intent = Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply { + action = OpenLessAccessibilityCommandReceiver.ACTION_PASTE + } + context.sendBroadcast(intent) + true + } catch (error: Throwable) { + Log.w(TAG, "send accessibility paste request failed", error) + false + } + } + + @Suppress("DEPRECATION") + private fun prefsMode(): Int = Context.MODE_PRIVATE or Context.MODE_MULTI_PROCESS + private val KEYBOARD_REFRESH_DELAYS_MS = longArrayOf(120L, 360L, 900L, 1600L) + private const val TAG = "OpenLessAccessibility" + private const val PREFS_NAME = "openless_accessibility" + private const val PREF_KEY_LAST_HEARTBEAT = "last_heartbeat" + private const val HEARTBEAT_STALE_MS = 15_000L } } diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessAppContext.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessAppContext.kt new file mode 100644 index 00000000..e1c627ae --- /dev/null +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessAppContext.kt @@ -0,0 +1,13 @@ +package com.openless.app + +import android.content.Context + +object OpenLessAppContext { + @Volatile + var context: Context? = null + private set + + fun initialize(context: Context) { + this.context = context.applicationContext + } +} diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt index 533e26f6..47c56a2f 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt @@ -2,7 +2,9 @@ package com.openless.app import android.app.Activity import android.app.Application +import android.content.Intent import android.os.Bundle +import android.provider.Settings import android.util.Log /** @@ -11,6 +13,7 @@ import android.util.Log class OpenLessApplication : Application() { override fun onCreate() { super.onCreate() + OpenLessAppContext.initialize(this) registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit override fun onActivityStarted(activity: Activity) { @@ -35,37 +38,48 @@ class OpenLessApplication : Application() { if (trigger != "background" && trigger != "always") { return } - if (!OpenLessNative.nativeCanDrawOverlays(this)) { + if (!canDrawOverlays()) { return } - OpenLessNative.nativeShowOverlay(this) + sendOverlayAction(OpenLessOverlayService.ACTION_SHOW) } private fun maybeHideOverlayOnForeground() { if (effectiveOverlayTriggerMode() == "always") { - if (OpenLessNative.nativeCanDrawOverlays(this) && !OpenLessNative.nativeIsOverlayVisible()) { - OpenLessNative.nativeShowOverlay(this) + if (canDrawOverlays()) { + sendOverlayAction(OpenLessOverlayService.ACTION_SHOW) } return } - if (OpenLessNative.nativeIsOverlayVisible()) { - OpenLessNative.nativeHideOverlay(this) - } + sendOverlayAction(OpenLessOverlayService.ACTION_HIDE) } private fun effectiveOverlayTriggerMode(): String { - val configured = OpenLessAndroidPreferences.overlayTriggerMode(this) ?: try { - OpenLessNative.nativeGetOverlayTriggerMode() - } catch (error: Throwable) { - Log.w(TAG, "overlay trigger mode unavailable", error) - "background" - } - if (configured == "keyboard" && !OpenLessAccessibilityService.isEnabled(this)) { + val configured = OpenLessAndroidPreferences.overlayTriggerMode(this) ?: "background" + if (configured == "keyboard" && !OpenLessAccessibilityService.isOperational(this)) { return "always" } return configured } + private fun canDrawOverlays(): Boolean { + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + Settings.canDrawOverlays(this) + } else { + true + } + } + + private fun sendOverlayAction(action: String) { + try { + startService(Intent(this, OpenLessOverlayService::class.java).apply { + this.action = action + }) + } catch (error: Throwable) { + Log.w(TAG, "overlay action failed: $action", error) + } + } + companion object { private const val TAG = "OpenLessApplication" } diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt index dfb193d6..34fa80e6 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt @@ -91,7 +91,11 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList recording = true processing = false if (!tryPromoteRecordingForeground()) { - OpenLessNative.nativeCancelDictation() + try { + OpenLessNative.nativeCancelDictation() + } catch (error: Throwable) { + Log.w(TAG, "cancel dictation bridge unavailable", error) + } return } applyVisualState(OverlayVisualState.Recording) @@ -190,7 +194,14 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private fun handleIconClick() { if (processing) return if (recording) { - OpenLessNative.nativeStopDictation() + try { + OpenLessNative.nativeStopDictation() + } catch (error: Throwable) { + Log.w(TAG, "stop dictation bridge unavailable", error) + recording = false + applyVisualState(OverlayVisualState.Error) + showToast("语音服务未就绪,请打开 OpenLess 后重试") + } } else { startRecordingFromOverlay() } @@ -288,7 +299,13 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private fun startRecordingFromOverlay() { showOverlay() if (tryPromoteRecordingForeground()) { - OpenLessNative.nativeStartDictation() + try { + OpenLessNative.nativeStartDictation() + } catch (error: Throwable) { + Log.w(TAG, "start dictation bridge unavailable", error) + applyVisualState(OverlayVisualState.Error) + showToast("语音服务未就绪,请打开 OpenLess 后重试") + } return } applyVisualState(OverlayVisualState.Error) @@ -374,15 +391,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } private fun isKeyboardTriggerMode(): Boolean { - OpenLessAndroidPreferences.overlayTriggerMode(this)?.let { mode -> - return mode == "keyboard" - } - return try { - OpenLessNative.nativeGetOverlayTriggerMode() == "keyboard" - } catch (error: Throwable) { - Log.w(TAG, "overlay trigger mode unavailable", error) - false - } + return OpenLessAndroidPreferences.overlayTriggerMode(this) == "keyboard" } private fun showToast(message: String) { diff --git a/openless-all/app/src-tauri/src/android_accessibility.rs b/openless-all/app/src-tauri/src/android_accessibility.rs index 3c445bf1..714cc2a4 100644 --- a/openless-all/app/src-tauri/src/android_accessibility.rs +++ b/openless-all/app/src-tauri/src/android_accessibility.rs @@ -58,8 +58,28 @@ mod android_impl { use crate::types::{AndroidAccessibilityState, AndroidAccessibilityStatus as Status}; pub fn get_android_accessibility_status() -> AndroidAccessibilityStatus { - match crate::android_jni::android::with_android_env(|env, context| { + let enabled = match crate::android_jni::android::with_android_env(|env, context| { crate::android_jni::android::accessibility_enabled(env, context) + }) { + Ok(enabled) => enabled, + Err(error) => { + return Status { + state: AndroidAccessibilityState::NotEnabled, + enabled: false, + message: error, + }; + } + }; + if !enabled { + return Status { + state: AndroidAccessibilityState::NotEnabled, + enabled: false, + message: "请在系统设置中启用 OpenLess 无障碍服务".to_string(), + }; + } + + match crate::android_jni::android::with_android_env(|env, context| { + crate::android_jni::android::accessibility_operational(env, context) }) { Ok(true) => Status { state: AndroidAccessibilityState::Enabled, @@ -69,7 +89,7 @@ mod android_impl { Ok(false) => Status { state: AndroidAccessibilityState::NotEnabled, enabled: false, - message: "请在系统设置中启用 OpenLess 无障碍服务".to_string(), + message: "无障碍服务已开启,但当前未运行或已被系统标记为故障,请重新开启 OpenLess 无障碍服务".to_string(), }, Err(error) => Status { state: AndroidAccessibilityState::NotEnabled, diff --git a/openless-all/app/src-tauri/src/android_jni.rs b/openless-all/app/src-tauri/src/android_jni.rs index 63f88dc0..ed9a9670 100644 --- a/openless-all/app/src-tauri/src/android_jni.rs +++ b/openless-all/app/src-tauri/src/android_jni.rs @@ -308,6 +308,20 @@ pub mod android { ) } + pub fn accessibility_operational<'local>( + env: &mut JNIEnv<'local>, + context: &JObject<'local>, + ) -> Result { + call_static_bool_with_context_class( + env, + context, + "com.openless.app.OpenLessAccessibilityService", + "isOperational", + "(Landroid/content/Context;)Z", + &[JValue::Object(context)], + ) + } + pub fn launch_accessibility_settings( env: &mut JNIEnv, context: &JObject, From f812f9437917539cd7b7d0322f98174cdaa66325 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 09:26:14 +0800 Subject: [PATCH 62/83] Show Android overlay from keyboard events --- .../OpenLessOverlayService.kt | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt index 34fa80e6..e43ebe3f 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt @@ -125,7 +125,10 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } private fun showOverlay() { - if (rootView != null) return + if (rootView != null) { + Log.d(TAG, "overlay already shown") + return + } windowManager = getSystemService(WINDOW_SERVICE) as WindowManager val savedPosition = loadSavedPosition() val params = WindowManager.LayoutParams( @@ -162,8 +165,15 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList FrameLayout.LayoutParams(dp(ICON_IMAGE_SIZE_DP), dp(ICON_IMAGE_SIZE_DP), Gravity.CENTER), ) attachDragHandler(root, params) - windowManager?.addView(root, params) + try { + windowManager?.addView(root, params) + } catch (error: Throwable) { + Log.w(TAG, "show overlay failed", error) + layoutParams = null + return + } rootView = root + Log.d(TAG, "overlay shown x=${params.x} y=${params.y}") applyVisualState( when { recording -> OverlayVisualState.Recording @@ -175,7 +185,12 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private fun hideOverlay() { val view = rootView ?: return - windowManager?.removeView(view) + try { + windowManager?.removeView(view) + Log.d(TAG, "overlay hidden") + } catch (error: Throwable) { + Log.w(TAG, "hide overlay failed", error) + } rootView = null layoutParams = null } @@ -208,15 +223,14 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } private fun handleKeyboardChanged(intent: Intent) { - val keyboardTrigger = isKeyboardTriggerMode() - if (!keyboardTrigger && rootView == null) return val visible = intent.getBooleanExtra(EXTRA_KEYBOARD_VISIBLE, false) keyboardVisible = visible - if (visible && keyboardTrigger) { + Log.d(TAG, "keyboard changed visible=$visible") + if (visible) { showOverlay() return } - if (!visible && keyboardTrigger && !recording && !processing) { + if (!recording && !processing) { hideOverlay() } } From febed1280c0996f8c3cade6ad0f9bd6b54b74e33 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 09:41:16 +0800 Subject: [PATCH 63/83] Log Android overlay keyboard path --- .../OpenLessAccessibilityService.kt | 1 + .../OpenLessOverlayService.kt | 21 +++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt index e722b98b..549670b7 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt @@ -74,6 +74,7 @@ class OpenLessAccessibilityService : AccessibilityService() { } } try { + Log.i(TAG, "keyboard overlay event visible=${imeBounds != null} bounds=$imeBounds") startService(intent) } catch (error: Throwable) { Log.w(TAG, "send keyboard overlay event failed", error) diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt index e43ebe3f..06b46228 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt @@ -52,6 +52,10 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.i( + TAG, + "onStartCommand action=${intent?.action} startId=$startId rootAttached=${rootView?.isAttachedToWindow}", + ) when (intent?.action) { ACTION_SHOW -> showOverlay() ACTION_START_RECORDING -> { @@ -125,8 +129,17 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } private fun showOverlay() { + rootView?.let { existing -> + if (!existing.isAttachedToWindow) { + Log.i(TAG, "clearing detached overlay root") + rootView = null + layoutParams = null + } else { + Log.i(TAG, "overlay already shown") + return + } + } if (rootView != null) { - Log.d(TAG, "overlay already shown") return } windowManager = getSystemService(WINDOW_SERVICE) as WindowManager @@ -173,7 +186,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList return } rootView = root - Log.d(TAG, "overlay shown x=${params.x} y=${params.y}") + Log.i(TAG, "overlay shown x=${params.x} y=${params.y}") applyVisualState( when { recording -> OverlayVisualState.Recording @@ -187,7 +200,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList val view = rootView ?: return try { windowManager?.removeView(view) - Log.d(TAG, "overlay hidden") + Log.i(TAG, "overlay hidden") } catch (error: Throwable) { Log.w(TAG, "hide overlay failed", error) } @@ -225,7 +238,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private fun handleKeyboardChanged(intent: Intent) { val visible = intent.getBooleanExtra(EXTRA_KEYBOARD_VISIBLE, false) keyboardVisible = visible - Log.d(TAG, "keyboard changed visible=$visible") + Log.i(TAG, "keyboard changed visible=$visible") if (visible) { showOverlay() return From 9e3b836da85c57a921c972398a5abf434956db46 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 10:00:23 +0800 Subject: [PATCH 64/83] Keep Android accessibility heartbeat fresh --- .../OpenLessAccessibilityService.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt index 549670b7..7317799e 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt @@ -17,12 +17,18 @@ import android.view.accessibility.AccessibilityWindowInfo */ class OpenLessAccessibilityService : AccessibilityService() { private val mainHandler = Handler(Looper.getMainLooper()) + private val heartbeatRunnable = object : Runnable { + override fun run() { + markServiceAlive() + mainHandler.postDelayed(this, HEARTBEAT_INTERVAL_MS) + } + } private val keyboardRefreshRunnable = Runnable { updateKeyboardOverlayState() } override fun onServiceConnected() { super.onServiceConnected() instance = this - markServiceAlive() + startHeartbeat() updateKeyboardOverlayState() scheduleKeyboardOverlayRefresh() } @@ -43,6 +49,7 @@ class OpenLessAccessibilityService : AccessibilityService() { override fun onInterrupt() = Unit override fun onDestroy() { + mainHandler.removeCallbacks(heartbeatRunnable) mainHandler.removeCallbacks(keyboardRefreshRunnable) if (instance === this) { instance = null @@ -57,6 +64,11 @@ class OpenLessAccessibilityService : AccessibilityService() { } } + private fun startHeartbeat() { + mainHandler.removeCallbacks(heartbeatRunnable) + heartbeatRunnable.run() + } + private fun updateKeyboardOverlayState() { if (!shouldTrackKeyboard()) { return @@ -190,6 +202,7 @@ class OpenLessAccessibilityService : AccessibilityService() { private const val TAG = "OpenLessAccessibility" private const val PREFS_NAME = "openless_accessibility" private const val PREF_KEY_LAST_HEARTBEAT = "last_heartbeat" + private const val HEARTBEAT_INTERVAL_MS = 5_000L private const val HEARTBEAT_STALE_MS = 15_000L } } From f9867dea7c1e6826b17e54911d692ab5853b26ea Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 10:22:32 +0800 Subject: [PATCH 65/83] Deduplicate Android overlay windows --- .../OpenLessOverlayService.kt | 79 ++++++++++++++----- 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt index 06b46228..69ea4f46 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt @@ -129,20 +129,17 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } private fun showOverlay() { - rootView?.let { existing -> - if (!existing.isAttachedToWindow) { - Log.i(TAG, "clearing detached overlay root") - rootView = null - layoutParams = null - } else { - Log.i(TAG, "overlay already shown") - return - } - } - if (rootView != null) { + windowManager = getSystemService(WINDOW_SERVICE) as WindowManager + reconcileOverlayRoots() + overlayRoots.lastOrNull()?.let { existing -> + rootView = existing + layoutParams = existing.layoutParams as? WindowManager.LayoutParams + iconContainer = existing + (existing.getChildAt(0) as? ImageView)?.let { iconButton = it } + Log.i(TAG, "overlay already shown roots=${overlayRoots.size}") return } - windowManager = getSystemService(WINDOW_SERVICE) as WindowManager + val savedPosition = loadSavedPosition() val params = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, @@ -186,7 +183,10 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList return } rootView = root - Log.i(TAG, "overlay shown x=${params.x} y=${params.y}") + synchronized(overlayRoots) { + overlayRoots.add(root) + } + Log.i(TAG, "overlay shown x=${params.x} y=${params.y} roots=${overlayRoots.size}") applyVisualState( when { recording -> OverlayVisualState.Recording @@ -197,15 +197,54 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } private fun hideOverlay() { - val view = rootView ?: return - try { - windowManager?.removeView(view) - Log.i(TAG, "overlay hidden") - } catch (error: Throwable) { - Log.w(TAG, "hide overlay failed", error) + windowManager = windowManager ?: getSystemService(WINDOW_SERVICE) as WindowManager + val views = synchronized(overlayRoots) { + (overlayRoots + listOfNotNull(rootView)).distinct().also { + overlayRoots.clear() + } + } + views.forEach { view -> + removeOverlayRoot(view) } rootView = null layoutParams = null + if (views.isNotEmpty()) { + Log.i(TAG, "overlay hidden roots=${views.size}") + } + } + + private fun reconcileOverlayRoots() { + val roots = synchronized(overlayRoots) { + overlayRoots.filter { it.isAttachedToWindow }.also { + overlayRoots.clear() + overlayRoots.addAll(it) + } + } + if (roots.isEmpty()) { + rootView = null + layoutParams = null + return + } + roots.dropLast(1).forEach { staleRoot -> + removeOverlayRoot(staleRoot) + synchronized(overlayRoots) { + overlayRoots.remove(staleRoot) + } + } + val activeRoot = roots.last() + rootView = activeRoot + layoutParams = activeRoot.layoutParams as? WindowManager.LayoutParams + Log.i(TAG, "reconciled overlay roots kept=1 removed=${roots.size - 1}") + } + + private fun removeOverlayRoot(view: FrameLayout) { + try { + if (view.isAttachedToWindow) { + windowManager?.removeViewImmediate(view) + } + } catch (error: Throwable) { + Log.w(TAG, "remove overlay root failed", error) + } } private fun buildIconButton(): ImageView { @@ -463,6 +502,8 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private const val NOTIFICATION_ID = 42001 private const val TAG = "OpenLessOverlayService" + private val overlayRoots = mutableListOf() + @Volatile var instance: OpenLessOverlayService? = null private set From fb9b109092529969fa36c5aa82a0d3bd05a254e0 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 10:48:29 +0800 Subject: [PATCH 66/83] Limit keyboard overlay fallback to background --- .../android-scaffolding/OpenLessApplication.kt | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt index 47c56a2f..30a1e5ed 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt @@ -34,8 +34,11 @@ class OpenLessApplication : Application() { } private fun maybeShowOverlayOnBackground() { - val trigger = effectiveOverlayTriggerMode() - if (trigger != "background" && trigger != "always") { + val configured = configuredOverlayTriggerMode() + val shouldShow = configured == "background" || + configured == "always" || + (configured == "keyboard" && !OpenLessAccessibilityService.isOperational(this)) + if (!shouldShow) { return } if (!canDrawOverlays()) { @@ -45,7 +48,7 @@ class OpenLessApplication : Application() { } private fun maybeHideOverlayOnForeground() { - if (effectiveOverlayTriggerMode() == "always") { + if (configuredOverlayTriggerMode() == "always") { if (canDrawOverlays()) { sendOverlayAction(OpenLessOverlayService.ACTION_SHOW) } @@ -54,12 +57,8 @@ class OpenLessApplication : Application() { sendOverlayAction(OpenLessOverlayService.ACTION_HIDE) } - private fun effectiveOverlayTriggerMode(): String { - val configured = OpenLessAndroidPreferences.overlayTriggerMode(this) ?: "background" - if (configured == "keyboard" && !OpenLessAccessibilityService.isOperational(this)) { - return "always" - } - return configured + private fun configuredOverlayTriggerMode(): String { + return OpenLessAndroidPreferences.overlayTriggerMode(this) ?: "background" } private fun canDrawOverlays(): Boolean { From fd7be3c3eb33a60593b41ae77879215f64aef1de Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 11:10:45 +0800 Subject: [PATCH 67/83] Use native Android microphone permission status --- .../src/lib/androidMicrophonePermission.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/openless-all/app/src/lib/androidMicrophonePermission.ts b/openless-all/app/src/lib/androidMicrophonePermission.ts index 021f5246..12c32cb4 100644 --- a/openless-all/app/src/lib/androidMicrophonePermission.ts +++ b/openless-all/app/src/lib/androidMicrophonePermission.ts @@ -1,8 +1,23 @@ import type { PermissionStatus as AppPermissionStatus } from './types'; +import { checkMicrophonePermission, requestMicrophonePermission } from './ipc'; const ANDROID_MIC_GRANTED_KEY = 'openless.androidMicrophoneGranted'; export async function checkAndroidMicrophoneAccess(): Promise { + try { + const nativeStatus = await checkMicrophonePermission(); + if (nativeStatus === 'granted' || nativeStatus === 'notApplicable') { + localStorage.setItem(ANDROID_MIC_GRANTED_KEY, '1'); + return 'granted'; + } + if (nativeStatus === 'denied' || nativeStatus === 'restricted') { + localStorage.removeItem(ANDROID_MIC_GRANTED_KEY); + return nativeStatus; + } + } catch { + // Fall through to WebView-local checks below. + } + if (localStorage.getItem(ANDROID_MIC_GRANTED_KEY) === '1') { return 'granted'; } @@ -25,6 +40,20 @@ export async function checkAndroidMicrophoneAccess(): Promise { + try { + const nativeStatus = await requestMicrophonePermission(); + if (nativeStatus === 'granted' || nativeStatus === 'notApplicable') { + localStorage.setItem(ANDROID_MIC_GRANTED_KEY, '1'); + return 'granted'; + } + if (nativeStatus === 'denied' || nativeStatus === 'restricted') { + localStorage.removeItem(ANDROID_MIC_GRANTED_KEY); + return nativeStatus; + } + } catch { + // Fall through to WebView-local checks below. + } + if (localStorage.getItem(ANDROID_MIC_GRANTED_KEY) === '1') { return 'granted'; } From a0f282f4e51d108cb042de432e47c58e6bd3502f Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 15:23:30 +0800 Subject: [PATCH 68/83] Shelve Android keyboard overlay trigger --- .../OpenLessAndroidPreferences.kt | 5 ++- openless-all/app/src-tauri/src/commands.rs | 1 + openless-all/app/src-tauri/src/coordinator.rs | 7 +--- openless-all/app/src-tauri/src/types.rs | 15 ++++++- openless-all/app/src/i18n/en.ts | 5 ++- openless-all/app/src/i18n/ja.ts | 3 +- openless-all/app/src/i18n/ko.ts | 3 +- openless-all/app/src/i18n/zh-CN.ts | 5 ++- openless-all/app/src/i18n/zh-TW.ts | 3 +- .../src/pages/settings/PermissionsSection.tsx | 39 ++++++++++++------- 10 files changed, 58 insertions(+), 28 deletions(-) diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt index 36fa638c..edc46891 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt @@ -13,10 +13,13 @@ object OpenLessAndroidPreferences { private const val APP_DIR = "OpenLess" private const val PREFERENCES_FILE = "preferences.json" private const val KEY_OVERLAY_TRIGGER = "androidOverlayTrigger" - private val VALID_OVERLAY_TRIGGERS = setOf("background", "keyboard", "always") + private val VALID_OVERLAY_TRIGGERS = setOf("background", "always") fun overlayTriggerMode(context: Context): String? { val value = readPreferenceString(context, KEY_OVERLAY_TRIGGER) ?: return null + if (value == "keyboard") { + return "background" + } return value.takeIf { it in VALID_OVERLAY_TRIGGERS } } diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 7e207901..eeb6aea1 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -192,6 +192,7 @@ fn persist_settings( let mut previous = coord.read_settings(); sync_dictation_hotkey_legacy_fields(&mut previous); sync_dictation_hotkey_legacy_fields(&mut prefs); + prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized(); reject_hotkey_collisions(&prefs)?; let dictation_shortcut_changed = previous.dictation_hotkey != prefs.dictation_hotkey; let dictation_mode_changed = previous.hotkey.mode != prefs.hotkey.mode; diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index b702ae5e..098ff74a 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -495,7 +495,7 @@ impl Coordinator { } pub fn android_overlay_trigger(&self) -> crate::types::AndroidOverlayTrigger { - self.inner.prefs.get().android_overlay_trigger + self.inner.prefs.get().android_overlay_trigger.normalized() } pub fn apply_android_overlay_trigger(&self) { @@ -506,11 +506,6 @@ impl Coordinator { AndroidOverlayTrigger::Always => { let _ = crate::android_overlay::show_android_overlay(); } - AndroidOverlayTrigger::Keyboard - if !crate::android_accessibility::get_android_accessibility_status().enabled => - { - let _ = crate::android_overlay::show_android_overlay(); - } AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard => { let _ = crate::android_overlay::hide_android_overlay(); } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index d98977e6..7977605b 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1073,8 +1073,10 @@ impl<'de> Deserialize<'de> for UserPreferences { audio_recording_max_entries: wire.audio_recording_max_entries, marketplace_base_url: wire.marketplace_base_url, marketplace_dev_login: wire.marketplace_dev_login, - android_insert_strategy: normalize_android_insert_strategy(wire.android_insert_strategy), - android_overlay_trigger: wire.android_overlay_trigger, + android_insert_strategy: normalize_android_insert_strategy( + wire.android_insert_strategy, + ), + android_overlay_trigger: wire.android_overlay_trigger.normalized(), }) } } @@ -2318,6 +2320,15 @@ pub enum AndroidOverlayTrigger { Always, } +impl AndroidOverlayTrigger { + pub fn normalized(self) -> Self { + match self { + AndroidOverlayTrigger::Keyboard => AndroidOverlayTrigger::Background, + trigger => trigger, + } + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum AndroidAccessibilityState { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 750cb082..d6c44a2d 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -840,9 +840,12 @@ export const en: typeof zhCN = { }, androidOverlayTriggerHint: { background: 'Simple and battery-friendly; no overlay while typing in other apps.', - keyboard: 'Shows when an input field opens the keyboard; needs accessibility.', + keyboard: 'This mode is shelved. Existing settings are moved back to background.', always: 'Always available, but permanently on screen.', }, + androidOverlayTriggerDisabled: { + keyboard: 'Keyboard-triggered display is shelved. Overlay gestures will replace keyboard detection.', + }, windowsIme: { installed: 'Installed. Voice input temporarily switches to the OpenLess IME.', notInstalled: 'Not installed. OpenLess is using the clipboard/WM_PASTE fallback.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 3a436e84..f0a9909f 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -830,7 +830,8 @@ export const ja: typeof zhCN = { androidInsertStrategy: { accessibility: '入力欄へ自動出力', clipboard: 'クリップボードのみ' }, androidInsertStrategyHint: { accessibility: 'アクセシビリティが必要です。使えない場合はクリップボードにコピーします。', clipboard: 'アクセシビリティ権限は不要です。コピー後に手動で貼り付けます。' }, androidOverlayTrigger: { background: 'バックグラウンド', keyboard: 'キーボード表示時', always: '常時' }, - androidOverlayTriggerHint: { background: 'シンプル', keyboard: 'アクセシビリティ必要', always: '常に表示' }, + androidOverlayTriggerHint: { background: 'シンプル', keyboard: 'このモードは保留中です。既存設定はバックグラウンドに戻します。', always: '常に表示' }, + androidOverlayTriggerDisabled: { keyboard: 'キーボード表示時の表示は保留中です。今後はフローティングウィンドウのジェスチャーで置き換えます。' }, windowsIme: { installed: 'インストール済み。音声入力時に OpenLess IME へ一時的に切り替えます。', notInstalled: '未インストール。OpenLess は現在クリップボード / WM_PASTE フォールバックを使用しています。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index aa3153ee..49f29820 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -830,7 +830,8 @@ export const ko: typeof zhCN = { androidInsertStrategy: { accessibility: '입력칸에 자동 출력', clipboard: '클립보드만' }, androidInsertStrategyHint: { accessibility: '접근성 서비스가 필요합니다. 사용할 수 없으면 클립보드에 복사합니다.', clipboard: '접근성 권한이 필요 없으며 직접 붙여넣습니다.' }, androidOverlayTrigger: { background: '백그라운드', keyboard: '키보드 표시 시', always: '항상' }, - androidOverlayTriggerHint: { background: '단순', keyboard: '접근성 필요', always: '항상 표시' }, + androidOverlayTriggerHint: { background: '단순', keyboard: '이 모드는 보류되었습니다. 기존 설정은 백그라운드로 되돌립니다.', always: '항상 표시' }, + androidOverlayTriggerDisabled: { keyboard: '키보드 표시 감지는 보류되었습니다. 이후 오버레이 제스처로 대체합니다.' }, windowsIme: { installed: '설치됨. 음성 입력 시 OpenLess 입력기로 일시 전환됩니다.', notInstalled: '설치되지 않음. OpenLess 는 현재 클립보드 / WM_PASTE 폴백을 사용합니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index d053d5b3..584008a7 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -838,9 +838,12 @@ export const zhCN = { }, androidOverlayTriggerHint: { background: '省电、实现简单;其他 App 输入时不会自动出现。', - keyboard: '符合“点输入框出键盘即显示”,需无障碍检测输入法窗口。', + keyboard: '该模式已暂缓,历史配置会自动改为“应用退到后台”。', always: '入口始终可见,但会一直占屏。', }, + androidOverlayTriggerDisabled: { + keyboard: '“弹出键盘时”暂缓开放,后续将以悬浮窗手势替代键盘检测。', + }, windowsIme: { installed: '已安装,按需切到 OpenLess 输入法。', notInstalled: '未安装,走剪贴板 / WM_PASTE 兜底。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 95cc04cb..a58c21b2 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -828,7 +828,8 @@ export const zhTW: typeof zhCN = { androidInsertStrategy: { accessibility: '自動輸出到輸入框', clipboard: '僅剪貼簿' }, androidInsertStrategyHint: { accessibility: '需要開啟無障礙服務;不可用時會複製到剪貼簿。', clipboard: '不需要無障礙權限,只複製到剪貼簿,由你手動貼上。' }, androidOverlayTrigger: { background: '退到背景', keyboard: '鍵盤彈出時', always: '常駐' }, - androidOverlayTriggerHint: { background: '省電', keyboard: '需無障礙', always: '一直佔屏' }, + androidOverlayTriggerHint: { background: '省電', keyboard: '此模式已暫緩,既有設定會改回退到背景。', always: '一直佔屏' }, + androidOverlayTriggerDisabled: { keyboard: '「鍵盤彈出時」暫緩開放,後續將以懸浮窗手勢取代鍵盤偵測。' }, windowsIme: { installed: '已安裝。語音輸入時會臨時切換到 OpenLess 輸入法。', notInstalled: '未安裝。OpenLess 正在使用剪貼板 / WM_PASTE 兜底。', diff --git a/openless-all/app/src/pages/settings/PermissionsSection.tsx b/openless-all/app/src/pages/settings/PermissionsSection.tsx index 91d58858..4884a1b0 100644 --- a/openless-all/app/src/pages/settings/PermissionsSection.tsx +++ b/openless-all/app/src/pages/settings/PermissionsSection.tsx @@ -19,7 +19,6 @@ import { requestAndroidOverlayPermission, requestMicrophonePermission, setSettings, - showAndroidOverlay, } from '../../lib/ipc'; import type { NetworkCheckResult } from '../../lib/ipc'; import { getPlatformCapabilities } from '../../lib/platform'; @@ -63,21 +62,20 @@ export function PermissionsSection() { getAndroidAccessibilityStatus(), getSettings(), ]); + let migratedSettings = settings; + if (settings.androidOverlayTrigger === 'keyboard') { + migratedSettings = { + ...settings, + androidOverlayTrigger: normalizeAndroidOverlayTrigger(settings.androidOverlayTrigger), + }; + await setSettings(migratedSettings); + } setAndroidOverlay(overlay); setAndroidAccessibility(accessibility); setAndroidPrefs({ - androidInsertStrategy: settings.androidInsertStrategy, - androidOverlayTrigger: settings.androidOverlayTrigger, + androidInsertStrategy: migratedSettings.androidInsertStrategy, + androidOverlayTrigger: migratedSettings.androidOverlayTrigger, }); - if ( - settings.androidOverlayTrigger === 'keyboard' && - !accessibility.enabled && - overlay.permission === 'granted' && - !overlay.overlayVisible - ) { - await showAndroidOverlay(); - setAndroidOverlay(await getAndroidOverlayStatus()); - } }; const refreshPermissions = async () => { @@ -172,7 +170,13 @@ export function PermissionsSection() { const updateAndroidPref = async (key: K, value: UserPreferences[K]) => { const settings = await getSettings(); - const next = { ...settings, [key]: value }; + const nextValue = key === 'androidOverlayTrigger' + ? normalizeAndroidOverlayTrigger(value as AndroidOverlayTrigger) + : value; + const next = { + ...settings, + [key]: nextValue, + }; await setSettings(next); setAndroidPrefs({ androidInsertStrategy: next.androidInsertStrategy, @@ -282,12 +286,15 @@ export function PermissionsSection() { style={{ minWidth: 180, maxWidth: '100%' }} > - + {t(`settings.permissions.androidOverlayTriggerHint.${androidPrefs?.androidOverlayTrigger ?? 'background'}`)} + + {t('settings.permissions.androidOverlayTriggerDisabled.keyboard')} +
@@ -327,6 +334,10 @@ export function PermissionsSection() { ); } +function normalizeAndroidOverlayTrigger(trigger: AndroidOverlayTrigger): AndroidOverlayTrigger { + return trigger === 'keyboard' ? 'background' : trigger; +} + function PermissionPill({ status }: { status: PermissionStatus | 'loading' }) { const { t } = useTranslation(); if (status === 'loading') { From ad8b3d71119d6ad13b05f2de67d5463ca8947627 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 15:50:39 +0800 Subject: [PATCH 69/83] Add Android overlay gesture preferences --- .../OpenLessAndroidPreferences.kt | 16 +++++++ openless-all/app/src-tauri/src/types.rs | 38 +++++++++++++++ openless-all/app/src/i18n/en.ts | 18 ++++++++ openless-all/app/src/i18n/ja.ts | 6 +++ openless-all/app/src/i18n/ko.ts | 6 +++ openless-all/app/src/i18n/zh-CN.ts | 18 ++++++++ openless-all/app/src/i18n/zh-TW.ts | 6 +++ openless-all/app/src/lib/ipc.ts | 2 + openless-all/app/src/lib/stylePrefs.test.ts | 2 + openless-all/app/src/lib/types.ts | 6 +++ .../src/pages/settings/PermissionsSection.tsx | 46 ++++++++++++++++++- 11 files changed, 162 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt index edc46891..460cc2e4 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt @@ -13,7 +13,11 @@ object OpenLessAndroidPreferences { private const val APP_DIR = "OpenLess" private const val PREFERENCES_FILE = "preferences.json" private const val KEY_OVERLAY_TRIGGER = "androidOverlayTrigger" + private const val KEY_OVERLAY_ACTIVATION_MODE = "androidOverlayActivationMode" + private const val KEY_OVERLAY_LEFT_SWIPE_ACTION = "androidOverlayLeftSwipeAction" private val VALID_OVERLAY_TRIGGERS = setOf("background", "always") + private val VALID_OVERLAY_ACTIVATION_MODES = setOf("tap", "long_press") + private val VALID_OVERLAY_LEFT_SWIPE_ACTIONS = setOf("translation", "style_pack") fun overlayTriggerMode(context: Context): String? { val value = readPreferenceString(context, KEY_OVERLAY_TRIGGER) ?: return null @@ -23,6 +27,18 @@ object OpenLessAndroidPreferences { return value.takeIf { it in VALID_OVERLAY_TRIGGERS } } + fun overlayActivationMode(context: Context): String { + return readPreferenceString(context, KEY_OVERLAY_ACTIVATION_MODE) + ?.takeIf { it in VALID_OVERLAY_ACTIVATION_MODES } + ?: "tap" + } + + fun overlayLeftSwipeAction(context: Context): String { + return readPreferenceString(context, KEY_OVERLAY_LEFT_SWIPE_ACTION) + ?.takeIf { it in VALID_OVERLAY_LEFT_SWIPE_ACTIONS } + ?: "translation" + } + private fun readPreferenceString(context: Context, key: String): String? { for (file in preferenceFiles(context).distinctBy { it.absolutePath }) { if (!file.isFile) { diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 7977605b..21e9b461 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -755,6 +755,12 @@ pub struct UserPreferences { /// Android: when to show the floating overlay control. #[serde(default = "default_android_overlay_trigger")] pub android_overlay_trigger: AndroidOverlayTrigger, + /// Android: how the floating overlay enters the armed interaction state. + #[serde(default = "default_android_overlay_activation_mode")] + pub android_overlay_activation_mode: AndroidOverlayActivationMode, + /// Android: action performed by left swiping while the overlay is armed. + #[serde(default = "default_android_overlay_left_swipe_action")] + pub android_overlay_left_swipe_action: AndroidOverlayLeftSwipeAction, } fn default_local_asr_model() -> String { @@ -908,6 +914,10 @@ struct UserPreferencesWire { android_insert_strategy: AndroidInsertStrategy, #[serde(default = "default_android_overlay_trigger")] android_overlay_trigger: AndroidOverlayTrigger, + #[serde(default = "default_android_overlay_activation_mode")] + android_overlay_activation_mode: AndroidOverlayActivationMode, + #[serde(default = "default_android_overlay_left_swipe_action")] + android_overlay_left_swipe_action: AndroidOverlayLeftSwipeAction, } impl Default for UserPreferencesWire { @@ -977,6 +987,8 @@ impl Default for UserPreferencesWire { marketplace_dev_login: prefs.marketplace_dev_login, android_insert_strategy: prefs.android_insert_strategy, android_overlay_trigger: prefs.android_overlay_trigger, + android_overlay_activation_mode: prefs.android_overlay_activation_mode, + android_overlay_left_swipe_action: prefs.android_overlay_left_swipe_action, } } } @@ -1077,6 +1089,8 @@ impl<'de> Deserialize<'de> for UserPreferences { wire.android_insert_strategy, ), android_overlay_trigger: wire.android_overlay_trigger.normalized(), + android_overlay_activation_mode: wire.android_overlay_activation_mode, + android_overlay_left_swipe_action: wire.android_overlay_left_swipe_action, }) } } @@ -1808,6 +1822,8 @@ impl Default for UserPreferences { marketplace_dev_login: String::new(), android_insert_strategy: default_android_insert_strategy(), android_overlay_trigger: default_android_overlay_trigger(), + android_overlay_activation_mode: default_android_overlay_activation_mode(), + android_overlay_left_swipe_action: default_android_overlay_left_swipe_action(), } } } @@ -2329,6 +2345,20 @@ impl AndroidOverlayTrigger { } } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AndroidOverlayActivationMode { + Tap, + LongPress, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AndroidOverlayLeftSwipeAction { + Translation, + StylePack, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum AndroidAccessibilityState { @@ -2353,6 +2383,14 @@ fn default_android_overlay_trigger() -> AndroidOverlayTrigger { AndroidOverlayTrigger::Background } +fn default_android_overlay_activation_mode() -> AndroidOverlayActivationMode { + AndroidOverlayActivationMode::Tap +} + +fn default_android_overlay_left_swipe_action() -> AndroidOverlayLeftSwipeAction { + AndroidOverlayLeftSwipeAction::Translation +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum AndroidOverlayPermissionState { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index d6c44a2d..76c9311e 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -825,6 +825,8 @@ export const en: typeof zhCN = { androidAccessibilityImpact: 'Enable it to output results to the current input field without switching keyboards. If disabled, results are copied to the clipboard for manual paste.', androidInsertStrategyLabel: 'Text insertion strategy', androidOverlayTriggerLabel: 'Overlay visibility', + androidOverlayActivationModeLabel: 'Overlay activation', + androidOverlayLeftSwipeActionLabel: 'Left swipe action', androidInsertStrategy: { accessibility: 'Auto output to input field', clipboard: 'Clipboard only', @@ -846,6 +848,22 @@ export const en: typeof zhCN = { androidOverlayTriggerDisabled: { keyboard: 'Keyboard-triggered display is shelved. Overlay gestures will replace keyboard detection.', }, + androidOverlayActivationMode: { + tap: 'Tap to arm', + long_press: 'Long press to arm', + }, + androidOverlayActivationModeHint: { + tap: 'First tap arms the overlay; second tap starts normal dictation.', + long_press: 'Hold to arm the overlay; release stops the current recording or QA turn.', + }, + androidOverlayLeftSwipeAction: { + translation: 'Translation dictation', + style_pack: 'Switch style pack', + }, + androidOverlayLeftSwipeActionHint: { + translation: 'Left swipe while armed starts translation dictation.', + style_pack: 'Left swipe while armed switches to the previous style pack.', + }, windowsIme: { installed: 'Installed. Voice input temporarily switches to the OpenLess IME.', notInstalled: 'Not installed. OpenLess is using the clipboard/WM_PASTE fallback.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index f0a9909f..f063cdac 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -827,11 +827,17 @@ export const ja: typeof zhCN = { androidAccessibilityImpact: '有効にすると、キーボードを切り替えずに現在の入力欄へ結果を出力します。無効の場合はクリップボードへコピーし、手動で貼り付けます。', androidInsertStrategyLabel: '挿入方式', androidOverlayTriggerLabel: '表示タイミング', + androidOverlayActivationModeLabel: '起動方法', + androidOverlayLeftSwipeActionLabel: '左スワイプ動作', androidInsertStrategy: { accessibility: '入力欄へ自動出力', clipboard: 'クリップボードのみ' }, androidInsertStrategyHint: { accessibility: 'アクセシビリティが必要です。使えない場合はクリップボードにコピーします。', clipboard: 'アクセシビリティ権限は不要です。コピー後に手動で貼り付けます。' }, androidOverlayTrigger: { background: 'バックグラウンド', keyboard: 'キーボード表示時', always: '常時' }, androidOverlayTriggerHint: { background: 'シンプル', keyboard: 'このモードは保留中です。既存設定はバックグラウンドに戻します。', always: '常に表示' }, androidOverlayTriggerDisabled: { keyboard: 'キーボード表示時の表示は保留中です。今後はフローティングウィンドウのジェスチャーで置き換えます。' }, + androidOverlayActivationMode: { tap: 'タップで起動', long_press: '長押しで起動' }, + androidOverlayActivationModeHint: { tap: '1回目のタップで待機状態に入り、2回目のタップで通常の音声入力を開始します。', long_press: '押している間だけ待機状態に入り、離すと現在の録音またはQAターンを終了します。' }, + androidOverlayLeftSwipeAction: { translation: '翻訳入力', style_pack: 'スタイルパック切替' }, + androidOverlayLeftSwipeActionHint: { translation: '待機状態で左スワイプすると翻訳入力を開始します。', style_pack: '待機状態で左スワイプすると前のスタイルパックへ切り替えます。' }, windowsIme: { installed: 'インストール済み。音声入力時に OpenLess IME へ一時的に切り替えます。', notInstalled: '未インストール。OpenLess は現在クリップボード / WM_PASTE フォールバックを使用しています。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 49f29820..3523261d 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -827,11 +827,17 @@ export const ko: typeof zhCN = { androidAccessibilityImpact: '켜면 키보드를 전환하지 않고 현재 입력칸에 결과를 출력합니다. 끄면 클립보드에 복사되며 직접 붙여넣어야 합니다.', androidInsertStrategyLabel: '텍스트 삽입 방식', androidOverlayTriggerLabel: '오버레이 표시', + androidOverlayActivationModeLabel: '오버레이 활성화', + androidOverlayLeftSwipeActionLabel: '왼쪽 스와이프 동작', androidInsertStrategy: { accessibility: '입력칸에 자동 출력', clipboard: '클립보드만' }, androidInsertStrategyHint: { accessibility: '접근성 서비스가 필요합니다. 사용할 수 없으면 클립보드에 복사합니다.', clipboard: '접근성 권한이 필요 없으며 직접 붙여넣습니다.' }, androidOverlayTrigger: { background: '백그라운드', keyboard: '키보드 표시 시', always: '항상' }, androidOverlayTriggerHint: { background: '단순', keyboard: '이 모드는 보류되었습니다. 기존 설정은 백그라운드로 되돌립니다.', always: '항상 표시' }, androidOverlayTriggerDisabled: { keyboard: '키보드 표시 감지는 보류되었습니다. 이후 오버레이 제스처로 대체합니다.' }, + androidOverlayActivationMode: { tap: '탭으로 활성화', long_press: '길게 눌러 활성화' }, + androidOverlayActivationModeHint: { tap: '첫 탭은 대기 상태로 전환하고, 두 번째 탭은 일반 받아쓰기를 시작합니다.', long_press: '누르고 있는 동안 대기 상태가 되며, 손을 떼면 현재 녹음 또는 QA 턴을 종료합니다.' }, + androidOverlayLeftSwipeAction: { translation: '번역 받아쓰기', style_pack: '스타일 팩 전환' }, + androidOverlayLeftSwipeActionHint: { translation: '대기 상태에서 왼쪽으로 밀면 번역 받아쓰기를 시작합니다.', style_pack: '대기 상태에서 왼쪽으로 밀면 이전 스타일 팩으로 전환합니다.' }, windowsIme: { installed: '설치됨. 음성 입력 시 OpenLess 입력기로 일시 전환됩니다.', notInstalled: '설치되지 않음. OpenLess 는 현재 클립보드 / WM_PASTE 폴백을 사용합니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 584008a7..008208c0 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -823,6 +823,8 @@ export const zhCN = { androidAccessibilityImpact: '开启后可在不切换键盘的情况下把结果输出到当前输入框;不开启时仍会复制到剪贴板,需要手动粘贴。', androidInsertStrategyLabel: '文本插入策略', androidOverlayTriggerLabel: '悬浮窗显示时机', + androidOverlayActivationModeLabel: '悬浮窗激活方式', + androidOverlayLeftSwipeActionLabel: '左滑动作', androidInsertStrategy: { accessibility: '自动输出到输入框', clipboard: '仅剪贴板', @@ -844,6 +846,22 @@ export const zhCN = { androidOverlayTriggerDisabled: { keyboard: '“弹出键盘时”暂缓开放,后续将以悬浮窗手势替代键盘检测。', }, + androidOverlayActivationMode: { + tap: '点按激活', + long_press: '长按激活', + }, + androidOverlayActivationModeHint: { + tap: '第一次点按进入激活态,第二次点按开始普通听写。', + long_press: '按住进入激活态;松开时结束当前录音或问答轮次。', + }, + androidOverlayLeftSwipeAction: { + translation: '翻译听写', + style_pack: '切换风格包', + }, + androidOverlayLeftSwipeActionHint: { + translation: '激活态左滑后按翻译模式录音。', + style_pack: '激活态左滑后切换到上一个风格包。', + }, windowsIme: { installed: '已安装,按需切到 OpenLess 输入法。', notInstalled: '未安装,走剪贴板 / WM_PASTE 兜底。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index a58c21b2..a9650491 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -825,11 +825,17 @@ export const zhTW: typeof zhCN = { androidAccessibilityImpact: '開啟後可在不切換鍵盤的情況下把結果輸出到目前輸入框;未開啟時仍會複製到剪貼簿,需要手動貼上。', androidInsertStrategyLabel: '文字插入策略', androidOverlayTriggerLabel: '懸浮窗顯示時機', + androidOverlayActivationModeLabel: '懸浮窗啟用方式', + androidOverlayLeftSwipeActionLabel: '左滑動作', androidInsertStrategy: { accessibility: '自動輸出到輸入框', clipboard: '僅剪貼簿' }, androidInsertStrategyHint: { accessibility: '需要開啟無障礙服務;不可用時會複製到剪貼簿。', clipboard: '不需要無障礙權限,只複製到剪貼簿,由你手動貼上。' }, androidOverlayTrigger: { background: '退到背景', keyboard: '鍵盤彈出時', always: '常駐' }, androidOverlayTriggerHint: { background: '省電', keyboard: '此模式已暫緩,既有設定會改回退到背景。', always: '一直佔屏' }, androidOverlayTriggerDisabled: { keyboard: '「鍵盤彈出時」暫緩開放,後續將以懸浮窗手勢取代鍵盤偵測。' }, + androidOverlayActivationMode: { tap: '點按啟用', long_press: '長按啟用' }, + androidOverlayActivationModeHint: { tap: '第一次點按進入啟用狀態,第二次點按開始普通聽寫。', long_press: '按住進入啟用狀態;放開時結束目前錄音或問答輪次。' }, + androidOverlayLeftSwipeAction: { translation: '翻譯聽寫', style_pack: '切換風格包' }, + androidOverlayLeftSwipeActionHint: { translation: '啟用狀態左滑後按翻譯模式錄音。', style_pack: '啟用狀態左滑後切換到上一個風格包。' }, windowsIme: { installed: '已安裝。語音輸入時會臨時切換到 OpenLess 輸入法。', notInstalled: '未安裝。OpenLess 正在使用剪貼板 / WM_PASTE 兜底。', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 411aac66..d864c445 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -221,6 +221,8 @@ let mockSettings: UserPreferences = { marketplaceDevLogin: "", androidInsertStrategy: "accessibility", androidOverlayTrigger: "background", + androidOverlayActivationMode: "tap", + androidOverlayLeftSwipeAction: "translation", } const mockFullStylePrompts: StyleSystemPrompts = { diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 86223727..531fb5e5 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -82,6 +82,8 @@ const previousPrefs: UserPreferences = { marketplaceDevLogin: '', androidInsertStrategy: 'accessibility', androidOverlayTrigger: 'background', + androidOverlayActivationMode: 'tap', + androidOverlayLeftSwipeAction: 'translation', }; const nextPrefs: UserPreferences = { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 7a22cba7..50da9ce3 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -342,10 +342,16 @@ export interface UserPreferences { androidInsertStrategy: AndroidInsertStrategy; /** Android: floating overlay visibility trigger mode. */ androidOverlayTrigger: AndroidOverlayTrigger; + /** Android: how the floating overlay enters the armed interaction state. */ + androidOverlayActivationMode: AndroidOverlayActivationMode; + /** Android: action performed by left swiping while the overlay is armed. */ + androidOverlayLeftSwipeAction: AndroidOverlayLeftSwipeAction; } export type AndroidInsertStrategy = 'accessibility' | 'clipboard'; export type AndroidOverlayTrigger = 'background' | 'keyboard' | 'always'; +export type AndroidOverlayActivationMode = 'tap' | 'long_press'; +export type AndroidOverlayLeftSwipeAction = 'translation' | 'style_pack'; export interface AndroidOverlayStatus { permission: 'granted' | 'notGranted' | 'notAndroid'; diff --git a/openless-all/app/src/pages/settings/PermissionsSection.tsx b/openless-all/app/src/pages/settings/PermissionsSection.tsx index 4884a1b0..767bec0c 100644 --- a/openless-all/app/src/pages/settings/PermissionsSection.tsx +++ b/openless-all/app/src/pages/settings/PermissionsSection.tsx @@ -25,6 +25,8 @@ import { getPlatformCapabilities } from '../../lib/platform'; import { checkAndroidMicrophoneAccess, requestAndroidMicrophoneAccess } from '../../lib/androidMicrophonePermission'; import type { AndroidAccessibilityStatus, + AndroidOverlayActivationMode, + AndroidOverlayLeftSwipeAction, AndroidInsertStrategy, AndroidOverlayStatus, AndroidOverlayTrigger, @@ -48,7 +50,7 @@ export function PermissionsSection() { const [platformCaps, setPlatformCaps] = useState(null); const [androidOverlay, setAndroidOverlay] = useState(null); const [androidAccessibility, setAndroidAccessibility] = useState(null); - const [androidPrefs, setAndroidPrefs] = useState | null>(null); + const [androidPrefs, setAndroidPrefs] = useState | null>(null); const { capability } = useHotkeySettings(); useEffect(() => { @@ -75,6 +77,8 @@ export function PermissionsSection() { setAndroidPrefs({ androidInsertStrategy: migratedSettings.androidInsertStrategy, androidOverlayTrigger: migratedSettings.androidOverlayTrigger, + androidOverlayActivationMode: migratedSettings.androidOverlayActivationMode, + androidOverlayLeftSwipeAction: migratedSettings.androidOverlayLeftSwipeAction, }); }; @@ -168,7 +172,7 @@ export function PermissionsSection() { refreshPermissions(); }; - const updateAndroidPref = async (key: K, value: UserPreferences[K]) => { + const updateAndroidPref = async (key: K, value: UserPreferences[K]) => { const settings = await getSettings(); const nextValue = key === 'androidOverlayTrigger' ? normalizeAndroidOverlayTrigger(value as AndroidOverlayTrigger) @@ -181,6 +185,8 @@ export function PermissionsSection() { setAndroidPrefs({ androidInsertStrategy: next.androidInsertStrategy, androidOverlayTrigger: next.androidOverlayTrigger, + androidOverlayActivationMode: next.androidOverlayActivationMode, + androidOverlayLeftSwipeAction: next.androidOverlayLeftSwipeAction, }); await refreshAndroid(); }; @@ -297,6 +303,36 @@ export function PermissionsSection() {
+ +
+ + + {t(`settings.permissions.androidOverlayActivationModeHint.${androidPrefs?.androidOverlayActivationMode ?? 'tap'}`)} + +
+
+ +
+ + + {t(`settings.permissions.androidOverlayLeftSwipeActionHint.${androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}`)} + +
+
)} {windowsIme?.state !== 'notWindows' && platformCaps?.platform !== 'android' && ( @@ -334,6 +370,12 @@ export function PermissionsSection() { ); } +type AndroidPreferenceKey = + | 'androidInsertStrategy' + | 'androidOverlayTrigger' + | 'androidOverlayActivationMode' + | 'androidOverlayLeftSwipeAction'; + function normalizeAndroidOverlayTrigger(trigger: AndroidOverlayTrigger): AndroidOverlayTrigger { return trigger === 'keyboard' ? 'background' : trigger; } From b9eecff163d96bd292236ff22db5a72bd9d0f4c3 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 16:46:39 +0800 Subject: [PATCH 70/83] Add Android overlay gesture QA flow --- .github/workflows/android-apk.yml | 102 +++- README.md | 6 + openless-all/app/package.json | 2 +- .../OpenLessAccessibilityService.kt | 44 ++ .../OpenLessApplication.kt | 3 +- .../android-scaffolding/OpenLessNative.kt | 10 + .../OpenLessOverlayService.kt | 197 +++++++- .../app/src-tauri/capabilities/mobile.json | 2 +- openless-all/app/src-tauri/src/android_jni.rs | 30 ++ .../src-tauri/src/android_native_bridge.rs | 136 ++++- openless-all/app/src-tauri/src/commands.rs | 36 +- openless-all/app/src-tauri/src/coordinator.rs | 464 +++++++++++++++++- openless-all/app/src-tauri/src/lib.rs | 39 +- .../app/src-tauri/src/mobile_runtime.rs | 5 +- .../src-tauri/src/mobile_stubs/selection.rs | 45 +- openless-all/app/src/App.tsx | 66 ++- openless-all/app/src/i18n/en.ts | 3 + openless-all/app/src/i18n/ja.ts | 3 + openless-all/app/src/i18n/ko.ts | 3 + openless-all/app/src/i18n/zh-CN.ts | 3 + openless-all/app/src/i18n/zh-TW.ts | 3 + openless-all/app/src/lib/ipc.ts | 4 + openless-all/app/src/pages/QaPanel.tsx | 142 +++++- 23 files changed, 1255 insertions(+), 93 deletions(-) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index dee7c1e0..96497155 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -129,33 +129,105 @@ jobs: rm -rf ~/.cargo/registry ~/.cargo/git ~/.gradle/caches df -h - - name: Collect debug APK + - name: Collect split debug APKs id: apk shell: bash working-directory: openless-all/app run: | set -euo pipefail - apk_path="$(find src-tauri/gen/android -type f -name '*.apk' -path '*/outputs/*' | sort | head -n 1)" - if [ -z "$apk_path" ]; then - echo "::error::No APK found under src-tauri/gen/android/**/outputs/" - find src-tauri/gen/android -type f -name '*.apk' 2>/dev/null || true - exit 1 - fi if [[ "${{ github.ref }}" == refs/tags/v* ]] && [[ "${{ github.ref_name }}" == *-tauri ]]; then label="${{ github.ref_name }}" else label="run-${{ github.run_number }}" fi - dest="$RUNNER_TEMP/OpenLess-android-debug-${label}.apk" - cp "$apk_path" "$dest" - echo "path=$dest" >> "$GITHUB_OUTPUT" - echo "Collected APK: $apk_path → $dest" + export OPENLESS_APK_LABEL="$label" + python - <<'PY' + import os + import shutil + import sys + import zipfile + from pathlib import Path + + expected = { + "arm64-v8a": "arm64_v8a", + "armeabi-v7a": "armeabi_v7a", + "x86": "x86", + "x86_64": "x86_64", + } + root = Path("src-tauri/gen/android") + label = os.environ["OPENLESS_APK_LABEL"] + out_dir = Path(os.environ["RUNNER_TEMP"]) / "openless-android-debug-split" + out_dir.mkdir(parents=True, exist_ok=True) + found = {} + candidates = [ + apk for apk in sorted(root.rglob("*.apk")) + if "outputs" in apk.parts + ] + if not candidates: + print("::error::No APK found under src-tauri/gen/android/**/outputs/") + for apk in sorted(root.rglob("*.apk")): + print(apk) + sys.exit(1) + for apk in candidates: + with zipfile.ZipFile(apk) as archive: + abis = sorted({ + name.split("/")[1] + for name in archive.namelist() + if name.startswith("lib/") and len(name.split("/")) >= 3 + }) + if len(abis) != 1: + print(f"::error::{apk} contains ABI directories {abis}; expected exactly one ABI per APK") + sys.exit(1) + abi = abis[0] + if abi not in expected: + print(f"::error::{apk} contains unexpected ABI {abi}") + sys.exit(1) + if abi in found: + print(f"::error::Duplicate APKs for ABI {abi}: {found[abi]} and {apk}") + sys.exit(1) + dest = out_dir / f"OpenLess-android-debug-{abi}-{label}.apk" + shutil.copy2(apk, dest) + found[abi] = dest + print(f"Collected {abi}: {apk} -> {dest}") + missing = sorted(set(expected) - set(found)) + if missing: + print(f"::error::Missing split APKs for ABI(s): {', '.join(missing)}") + sys.exit(1) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + for abi, key in expected.items(): + output.write(f"{key}_path={found[abi]}\n") + output.write("release_files<_x64-setup.exe` — run the installer. +- **Android debug APK**: choose exactly one split APK for your device: + - `OpenLess-android-debug-arm64-v8a-*.apk` for most modern Android phones. + - `OpenLess-android-debug-armeabi-v7a-*.apk` for older 32-bit ARM phones. + - `OpenLess-android-debug-x86_64-*.apk` for most 64-bit Android emulator images. + - `OpenLess-android-debug-x86-*.apk` for older 32-bit x86 emulator images. + - If unsure, run `adb shell getprop ro.product.cpu.abi` and download the APK matching that ABI. Do not install the x86 builds on a normal ARM phone. - **macOS (Homebrew)**: ```bash brew tap appergb/openless https://github.com/appergb/openless diff --git a/openless-all/app/package.json b/openless-all/app/package.json index dac75c52..0569e026 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -10,7 +10,7 @@ "tauri": "tauri", "tauri:android:init": "tauri android init", "tauri:android:dev": "tauri android dev", - "tauri:android:build": "tauri android build --apk --debug", + "tauri:android:build": "tauri android build --apk --debug --target aarch64 armv7 i686 x86_64 --split-per-abi", "merge:android-v1-manifest": "node scripts/merge-android-v1-manifest.mjs", "merge:android-overlay-manifest": "node scripts/merge-android-overlay-manifest.mjs", "copy:android-scaffolding": "node scripts/copy-android-scaffolding.mjs", diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt index 7317799e..86ad0c4f 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt @@ -133,6 +133,45 @@ class OpenLessAccessibilityService : AccessibilityService() { return pasted } + private fun captureSelectedTextFromFocusedNode(): String { + val root = rootInActiveWindow ?: return "" + val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) + ?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY) + focused?.let { + return try { + selectedTextFromNode(it) + } finally { + it.recycle() + } + } + return selectedTextFromTree(root) + } + + private fun selectedTextFromTree(node: AccessibilityNodeInfo?): String { + if (node == null) return "" + selectedTextFromNode(node).takeIf { it.isNotBlank() }?.let { return it } + for (index in 0 until node.childCount) { + val child = node.getChild(index) ?: continue + try { + selectedTextFromTree(child).takeIf { it.isNotBlank() }?.let { return it } + } finally { + child.recycle() + } + } + return "" + } + + private fun selectedTextFromNode(node: AccessibilityNodeInfo): String { + val text = node.text?.toString() ?: return "" + val start = node.textSelectionStart + val end = node.textSelectionEnd + if (start < 0 || end < 0 || start == end) return "" + val from = minOf(start, end).coerceIn(0, text.length) + val to = maxOf(start, end).coerceIn(0, text.length) + if (from >= to) return "" + return text.substring(from, to) + } + private fun markServiceAlive() { getSharedPreferences(PREFS_NAME, prefsMode()) .edit() @@ -151,6 +190,11 @@ class OpenLessAccessibilityService : AccessibilityService() { return sendPasteRequestToAccessibilityProcess() } + @JvmStatic + fun captureSelectedText(): String { + return instance?.captureSelectedTextFromFocusedNode().orEmpty() + } + @JvmStatic fun isEnabled(context: Context): Boolean { val enabled = Settings.Secure.getInt( diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt index 30a1e5ed..8e25fd93 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt @@ -36,8 +36,7 @@ class OpenLessApplication : Application() { private fun maybeShowOverlayOnBackground() { val configured = configuredOverlayTriggerMode() val shouldShow = configured == "background" || - configured == "always" || - (configured == "keyboard" && !OpenLessAccessibilityService.isOperational(this)) + configured == "always" if (!shouldShow) { return } diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessNative.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessNative.kt index 0b9b6f4a..3c6f297a 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessNative.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessNative.kt @@ -14,10 +14,20 @@ object OpenLessNative { @JvmStatic external fun nativeStartDictation() + @JvmStatic external fun nativeStartDictationWithTranslation(translation: Boolean) + @JvmStatic external fun nativeStopDictation() + @JvmStatic external fun nativeStopDictationWithTranslation(translation: Boolean) + @JvmStatic external fun nativeCancelDictation() + @JvmStatic external fun nativeSwitchStylePack() + + @JvmStatic external fun nativeOpenQaFromOverlay() + + @JvmStatic external fun nativeFinalizeQaFromOverlay() + @JvmStatic external fun nativeGetOverlayTriggerMode(): String @JvmStatic external fun nativeCanDrawOverlays(context: android.content.Context): Boolean diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt index 69ea4f46..6e329f54 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt @@ -34,11 +34,15 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private var recording = false private var processing = false private var keyboardVisible = false + private var armed = false private var dragStartX = 0 private var dragStartY = 0 private var paramStartX = 0 private var paramStartY = 0 private var dragging = false + private var longPressRecording = false + private var pendingSwipe: SwipeDirection? = null + private var swipeConsumed = false private lateinit var iconContainer: FrameLayout private lateinit var iconButton: ImageView @@ -112,18 +116,19 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList "done" -> { recording = false processing = false - applyVisualState(OverlayVisualState.Idle) + setArmed(false) } "error" -> { recording = false processing = false + setArmed(false) applyVisualState(OverlayVisualState.Error) message?.takeIf { it.isNotBlank() }?.let { showToast(it) } } "cancelled", "idle" -> { recording = false processing = false - applyVisualState(OverlayVisualState.Idle) + setArmed(false) } } } @@ -191,6 +196,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList when { recording -> OverlayVisualState.Recording processing -> OverlayVisualState.Processing + armed -> OverlayVisualState.Armed else -> OverlayVisualState.Idle }, ) @@ -261,17 +267,13 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private fun handleIconClick() { if (processing) return if (recording) { - try { - OpenLessNative.nativeStopDictation() - } catch (error: Throwable) { - Log.w(TAG, "stop dictation bridge unavailable", error) - recording = false - applyVisualState(OverlayVisualState.Error) - showToast("语音服务未就绪,请打开 OpenLess 后重试") - } - } else { - startRecordingFromOverlay() + stopRecordingFromOverlay() + return + } + if (!isTapActivationMode()) { + return } + startRecordingFromOverlay() } private fun handleKeyboardChanged(intent: Intent) { @@ -292,16 +294,33 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { dragging = false + swipeConsumed = false + longPressRecording = false + pendingSwipe = null + if (processing) { + return@setOnTouchListener true + } dragStartX = event.rawX.toInt() dragStartY = event.rawY.toInt() paramStartX = params.x paramStartY = params.y + if (!isTapActivationMode() && !recording && !processing) { + longPressRecording = true + startRecordingFromOverlay() + } true } MotionEvent.ACTION_MOVE -> { val dx = event.rawX.toInt() - dragStartX val dy = event.rawY.toInt() - dragStartY - if (abs(dx) > DRAG_SLOP_PX || abs(dy) > DRAG_SLOP_PX) { + val swipe = detectHorizontalSwipe(dx, dy) + if ((recording || armed || longPressRecording) && swipe != null && !swipeConsumed) { + pendingSwipe = swipe + swipeConsumed = true + applySwipePreview(swipe) + return@setOnTouchListener true + } + if (!processing && !armed && !recording && !longPressRecording && (abs(dx) > DRAG_SLOP_PX || abs(dy) > DRAG_SLOP_PX)) { dragging = true params.x = paramStartX + dx params.y = paramStartY + dy @@ -312,13 +331,33 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } MotionEvent.ACTION_UP -> { if (!dragging) { - touchedView.performClick() + val swipe = pendingSwipe + if (swipe != null) { + commitSwipe(swipe) + } else if (longPressRecording || (!isTapActivationMode() && recording)) { + stopRecordingFromOverlay() + } else if (!isTapActivationMode()) { + setArmed(false) + } else if (!swipeConsumed) { + touchedView.performClick() + } } else { savePosition(params.x, params.y) } + longPressRecording = false + pendingSwipe = null + swipeConsumed = false + true + } + MotionEvent.ACTION_CANCEL -> { + if (longPressRecording || (!isTapActivationMode() && recording)) { + stopRecordingFromOverlay() + } + longPressRecording = false + pendingSwipe = null + swipeConsumed = false true } - MotionEvent.ACTION_CANCEL -> true else -> false } } @@ -334,6 +373,13 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList strokeWidth = 1, enabled = true, ) + OverlayVisualState.Armed -> VisualStyle( + alpha = 1f, + fill = Color.parseColor("#E6111827"), + stroke = Color.parseColor("#38BDF8"), + strokeWidth = 3, + enabled = true, + ) OverlayVisualState.Recording -> VisualStyle( alpha = 1f, fill = Color.parseColor("#E6111827"), @@ -362,13 +408,99 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList iconButton.isEnabled = enabled } - private fun startRecordingFromOverlay() { + private fun setArmed(value: Boolean) { + armed = value + if (!recording && !processing) { + applyVisualState(if (value) OverlayVisualState.Armed else OverlayVisualState.Idle) + } + } + + private fun detectHorizontalSwipe(dx: Int, dy: Int): SwipeDirection? { + if (abs(dx) < dp(SWIPE_THRESHOLD_DP)) return null + if (abs(dy) > abs(dx) * SWIPE_VERTICAL_RATIO) return null + return if (dx < 0) SwipeDirection.Left else SwipeDirection.Right + } + + private fun applySwipePreview(direction: SwipeDirection) { + when (direction) { + SwipeDirection.Left -> applyVisualState(OverlayVisualState.Armed) + SwipeDirection.Right -> applyVisualState(OverlayVisualState.Processing) + } + } + + private fun commitSwipe(direction: SwipeDirection) { + when (direction) { + SwipeDirection.Left -> handleLeftSwipe() + SwipeDirection.Right -> finalizeQaFromOverlay() + } + } + + private fun handleLeftSwipe() { + when (OpenLessAndroidPreferences.overlayLeftSwipeAction(this)) { + "style_pack" -> { + switchStylePackFromOverlay() + if (recording) { + stopRecordingFromOverlay() + } + } + else -> stopRecordingFromOverlay(translation = true) + } + } + + private fun switchStylePackFromOverlay() { + try { + OpenLessNative.nativeSwitchStylePack() + setArmed(false) + } catch (error: Throwable) { + Log.w(TAG, "switch style pack bridge unavailable", error) + applyVisualState(OverlayVisualState.Error) + showToast("语音服务未就绪,请打开 OpenLess 后重试") + } + } + + private fun openQaFromOverlay() { + try { + OpenLessNative.nativeOpenQaFromOverlay() + setArmed(false) + } catch (error: Throwable) { + Log.w(TAG, "open QA bridge unavailable", error) + applyVisualState(OverlayVisualState.Error) + showToast("问答服务未就绪,请打开 OpenLess 后重试") + } + } + + private fun finalizeQaFromOverlay() { + try { + OpenLessNative.nativeFinalizeQaFromOverlay() + recording = false + processing = true + setArmed(false) + applyVisualState(OverlayVisualState.Processing) + } catch (error: Throwable) { + Log.w(TAG, "finalize QA bridge unavailable", error) + processing = false + applyVisualState(OverlayVisualState.Error) + showToast("问答服务未就绪,请打开 OpenLess 后重试") + } + } + + private fun startRecordingFromOverlay(translation: Boolean = false) { showOverlay() if (tryPromoteRecordingForeground()) { try { - OpenLessNative.nativeStartDictation() + if (translation) { + OpenLessNative.nativeStartDictationWithTranslation(true) + } else { + OpenLessNative.nativeStartDictation() + } + recording = true + processing = false + setArmed(false) + applyVisualState(OverlayVisualState.Recording) } catch (error: Throwable) { Log.w(TAG, "start dictation bridge unavailable", error) + recording = false + processing = false applyVisualState(OverlayVisualState.Error) showToast("语音服务未就绪,请打开 OpenLess 后重试") } @@ -377,6 +509,25 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList applyVisualState(OverlayVisualState.Error) } + private fun stopRecordingFromOverlay(translation: Boolean = false) { + try { + recording = false + processing = true + applyVisualState(OverlayVisualState.Processing) + if (translation) { + OpenLessNative.nativeStopDictationWithTranslation(true) + } else { + OpenLessNative.nativeStopDictation() + } + } catch (error: Throwable) { + Log.w(TAG, "stop dictation bridge unavailable", error) + recording = false + processing = false + applyVisualState(OverlayVisualState.Error) + showToast("语音服务未就绪,请打开 OpenLess 后重试") + } + } + private fun tryPromoteRecordingForeground(): Boolean { if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { showToast("请先授予麦克风权限") @@ -456,8 +607,8 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList .apply() } - private fun isKeyboardTriggerMode(): Boolean { - return OpenLessAndroidPreferences.overlayTriggerMode(this) == "keyboard" + private fun isTapActivationMode(): Boolean { + return OpenLessAndroidPreferences.overlayActivationMode(this) == "tap" } private fun showToast(message: String) { @@ -478,11 +629,17 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private enum class OverlayVisualState { Idle, + Armed, Recording, Processing, Error, } + private enum class SwipeDirection { + Left, + Right, + } + companion object { const val ACTION_SHOW = "com.openless.app.overlay.SHOW" const val ACTION_HIDE = "com.openless.app.overlay.HIDE" @@ -496,6 +653,8 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private const val ICON_IMAGE_SIZE_DP = 56 private const val ICON_PADDING_DP = 8 private const val DRAG_SLOP_PX = 8 + private const val SWIPE_THRESHOLD_DP = 56 + private const val SWIPE_VERTICAL_RATIO = 0.6f private const val PREFS_NAME = "openless_overlay" private const val PREF_KEY_X = "overlay_x" private const val PREF_KEY_Y = "overlay_y" diff --git a/openless-all/app/src-tauri/capabilities/mobile.json b/openless-all/app/src-tauri/capabilities/mobile.json index 07db242b..911ad21d 100644 --- a/openless-all/app/src-tauri/capabilities/mobile.json +++ b/openless-all/app/src-tauri/capabilities/mobile.json @@ -3,7 +3,7 @@ "identifier": "mobile", "description": "Capabilities for OpenLess Android main window", "platforms": ["android"], - "windows": ["main"], + "windows": ["main", "qa"], "permissions": [ "core:default", "core:window:default", diff --git a/openless-all/app/src-tauri/src/android_jni.rs b/openless-all/app/src-tauri/src/android_jni.rs index ed9a9670..f028ab47 100644 --- a/openless-all/app/src-tauri/src/android_jni.rs +++ b/openless-all/app/src-tauri/src/android_jni.rs @@ -294,6 +294,36 @@ pub mod android { ) } + pub fn accessibility_selected_text<'local>( + env: &mut JNIEnv<'local>, + context: &JObject<'local>, + ) -> Result, String> { + let class = load_context_class( + env, + context, + "com.openless.app.OpenLessAccessibilityService", + )?; + let value = env + .call_static_method(class, "captureSelectedText", "()Ljava/lang/String;", &[]) + .and_then(|value| value.l()) + .map_err(|error| { + format!("call com.openless.app.OpenLessAccessibilityService.captureSelectedText: {error}") + })?; + if value.is_null() { + return Ok(None); + } + let text = env + .get_string(&JString::from(value)) + .map_err(|error| format!("read selected text jstring: {error}"))? + .to_string_lossy() + .into_owned(); + if text.trim().is_empty() { + Ok(None) + } else { + Ok(Some(text)) + } + } + pub fn accessibility_enabled<'local>( env: &mut JNIEnv<'local>, context: &JObject<'local>, diff --git a/openless-all/app/src-tauri/src/android_native_bridge.rs b/openless-all/app/src-tauri/src/android_native_bridge.rs index 2e20b460..f34dd598 100644 --- a/openless-all/app/src-tauri/src/android_native_bridge.rs +++ b/openless-all/app/src-tauri/src/android_native_bridge.rs @@ -6,8 +6,7 @@ use crate::coordinator::Coordinator; use crate::types::{CapsulePayload, CapsuleState}; static COORDINATOR: OnceLock> = OnceLock::new(); -static OVERLAY_VISIBLE: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); +static OVERLAY_VISIBLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); pub fn register_android_coordinator(coordinator: Arc) { let _ = COORDINATOR.set(coordinator); @@ -51,7 +50,10 @@ pub fn hide_overlay() -> Result<(), String> { } #[cfg(target_os = "android")] -fn show_overlay_with_context(env: &mut jni::JNIEnv, context: &jni::objects::JObject) -> Result<(), String> { +fn show_overlay_with_context( + env: &mut jni::JNIEnv, + context: &jni::objects::JObject, +) -> Result<(), String> { crate::android_jni::android::start_service_action( env, context, @@ -63,7 +65,10 @@ fn show_overlay_with_context(env: &mut jni::JNIEnv, context: &jni::objects::JObj } #[cfg(target_os = "android")] -fn hide_overlay_with_context(env: &mut jni::JNIEnv, context: &jni::objects::JObject) -> Result<(), String> { +fn hide_overlay_with_context( + env: &mut jni::JNIEnv, + context: &jni::objects::JObject, +) -> Result<(), String> { crate::android_jni::android::start_service_action( env, context, @@ -89,26 +94,57 @@ pub fn overlay_trigger_mode_name() -> &'static str { } } -fn spawn_dictation(start: bool) { +fn spawn_start_dictation(translation: bool) { let Some(coordinator) = COORDINATOR.get().cloned() else { log::warn!("[android-native] coordinator unavailable"); return; }; tauri::async_runtime::spawn(async move { - let result = if start { - coordinator.start_dictation().await + let result = if translation { + coordinator.start_dictation_with_translation().await } else { - coordinator.stop_dictation().await + coordinator.start_dictation().await }; if let Err(error) = result { log::warn!( "[android-native] {} failed: {error}", - if start { "start_dictation" } else { "stop_dictation" } + if translation { + "start_dictation_with_translation" + } else { + "start_dictation" + } ); } }); } +fn spawn_stop_dictation() { + let Some(coordinator) = COORDINATOR.get().cloned() else { + log::warn!("[android-native] coordinator unavailable"); + return; + }; + tauri::async_runtime::spawn(async move { + if let Err(error) = coordinator.stop_dictation().await { + log::warn!("[android-native] stop_dictation failed: {error}"); + } + }); +} + +fn spawn_stop_dictation_with_translation(translation: bool) { + let Some(coordinator) = COORDINATOR.get().cloned() else { + log::warn!("[android-native] coordinator unavailable"); + return; + }; + tauri::async_runtime::spawn(async move { + if let Err(error) = coordinator + .stop_dictation_with_translation(translation) + .await + { + log::warn!("[android-native] stop_dictation_with_translation failed: {error}"); + } + }); +} + fn spawn_cancel_dictation() { let Some(coordinator) = COORDINATOR.get().cloned() else { return; @@ -116,6 +152,38 @@ fn spawn_cancel_dictation() { coordinator.cancel_dictation(); } +fn spawn_switch_style_pack() { + let Some(coordinator) = COORDINATOR.get().cloned() else { + log::warn!("[android-native] coordinator unavailable"); + return; + }; + coordinator.switch_to_previous_style_pack(); +} + +fn spawn_open_qa_from_overlay() { + let Some(coordinator) = COORDINATOR.get().cloned() else { + log::warn!("[android-native] coordinator unavailable"); + return; + }; + tauri::async_runtime::spawn(async move { + if let Err(error) = coordinator.open_qa_from_overlay().await { + log::warn!("[android-native] open_qa_from_overlay failed: {error}"); + } + }); +} + +fn spawn_finalize_qa_from_overlay() { + let Some(coordinator) = COORDINATOR.get().cloned() else { + log::warn!("[android-native] coordinator unavailable"); + return; + }; + tauri::async_runtime::spawn(async move { + if let Err(error) = coordinator.finalize_qa_from_overlay().await { + log::warn!("[android-native] finalize_qa_from_overlay failed: {error}"); + } + }); +} + fn capsule_state_name(state: CapsuleState) -> &'static str { match state { CapsuleState::Idle => "idle", @@ -140,8 +208,8 @@ mod jni_exports { context: JObject, f: impl for<'local> FnOnce(&mut JniEnv<'local>, &JObject<'local>) -> Result, ) -> Result { - let mut env = JniEnv::from_raw(env_ptr) - .map_err(|error| format!("attach JNI env: {error}"))?; + let mut env = + JniEnv::from_raw(env_ptr).map_err(|error| format!("attach JNI env: {error}"))?; f(&mut env, &context) } @@ -150,7 +218,16 @@ mod jni_exports { _env: *mut JNIEnv, _class: JClass, ) { - spawn_dictation(true); + spawn_start_dictation(false); + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeStartDictationWithTranslation( + _env: *mut JNIEnv, + _class: JClass, + translation: jboolean, + ) { + spawn_start_dictation(translation != 0); } #[no_mangle] @@ -158,7 +235,16 @@ mod jni_exports { _env: *mut JNIEnv, _class: JClass, ) { - spawn_dictation(false); + spawn_stop_dictation(); + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeStopDictationWithTranslation( + _env: *mut JNIEnv, + _class: JClass, + translation: jboolean, + ) { + spawn_stop_dictation_with_translation(translation != 0); } #[no_mangle] @@ -169,6 +255,30 @@ mod jni_exports { spawn_cancel_dictation(); } + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeSwitchStylePack( + _env: *mut JNIEnv, + _class: JClass, + ) { + spawn_switch_style_pack(); + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeOpenQaFromOverlay( + _env: *mut JNIEnv, + _class: JClass, + ) { + spawn_open_qa_from_overlay(); + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeFinalizeQaFromOverlay( + _env: *mut JNIEnv, + _class: JClass, + ) { + spawn_finalize_qa_from_overlay(); + } + #[no_mangle] pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeShowOverlay( env: *mut JNIEnv, diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index eeb6aea1..aedac67c 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -38,17 +38,16 @@ use crate::polish::{ CODEX_OAUTH_PROVIDER_ID, }; use crate::recorder::{AudioConsumer, Recorder}; -use crate::types::{ - builtin_style_pack_id, default_active_style_pack_id, ChineseScriptPreference, ComboBinding, - CorrectionRule, CredentialsStatus, DictationSession, DictionaryEntry, HotkeyAdapterKind, - HotkeyCapability, - HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding, StylePack, StylePackKind, - StylePackRuntimeDiagnostics, StyleSystemPrompts, UpdateChannel, UserPreferences, - VocabPresetStore, AndroidOverlayStatus, - PlatformCapabilities, HotkeyInstallError, HotkeyStatusState, -}; #[cfg(not(mobile))] use crate::types::WindowsImeStatus; +use crate::types::{ + builtin_style_pack_id, default_active_style_pack_id, AndroidOverlayStatus, + ChineseScriptPreference, ComboBinding, CorrectionRule, CredentialsStatus, DictationSession, + DictionaryEntry, HotkeyAdapterKind, HotkeyCapability, HotkeyInstallError, HotkeyStatus, + HotkeyStatusState, OutputLanguagePreference, PlatformCapabilities, PolishMode, ShortcutBinding, + StylePack, StylePackKind, StylePackRuntimeDiagnostics, StyleSystemPrompts, UpdateChannel, + UserPreferences, VocabPresetStore, +}; type CoordinatorState<'a> = State<'a, Arc>; pub type MicrophoneMonitorState = Mutex>; @@ -674,8 +673,8 @@ pub fn get_android_overlay_status() -> AndroidOverlayStatus { } #[tauri::command] -pub fn request_android_overlay_permission( -) -> crate::android_overlay::AndroidOverlayPermissionResult { +pub fn request_android_overlay_permission() -> crate::android_overlay::AndroidOverlayPermissionResult +{ crate::android_overlay::request_android_overlay_permission() } @@ -700,7 +699,6 @@ pub fn request_android_accessibility_permission( crate::android_accessibility::request_android_accessibility_permission() } - #[tauri::command] pub fn open_external_url(url: String) -> Result<(), String> { crate::external_url::open_external_url(&url) @@ -2108,6 +2106,13 @@ pub fn qa_window_pin(coord: CoordinatorState<'_>, pinned: bool) { coord.qa_window_pin(pinned); } +/// 移动端 QA 面板录音按钮:Idle -> begin_qa_session,Recording -> end_qa_session。 +#[tauri::command] +pub async fn qa_toggle_recording(coord: CoordinatorState<'_>) -> Result<(), String> { + coord.qa_toggle_recording().await; + Ok(()) +} + /// 用户点 ✕ / 按 Esc 关 Less Computer 浮窗。 #[tauri::command] pub fn less_computer_window_dismiss(coord: CoordinatorState<'_>) { @@ -3321,7 +3326,12 @@ fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), String> { .map_err(|e| e.to_string()) } -#[cfg(all(not(mobile), unix, not(target_os = "macos"), not(target_os = "android")))] +#[cfg(all( + not(mobile), + unix, + not(target_os = "macos"), + not(target_os = "android") +))] fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), String> { std::process::Command::new("xdg-open") .arg(path) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 098ff74a..7976ddcc 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -35,6 +35,7 @@ use crate::coordinator_state::{ publish_abort_idle_after_restore, start_processing_if_listening, startup_race_status, BeginOutcome, SessionId, SessionPhase, SessionState, StartupRaceStatus, }; +use crate::correction::apply_correction_rules; use crate::hotkey::{HotkeyEvent, HotkeyMonitor}; use crate::insertion::TextInserter; use crate::persistence::{ @@ -73,13 +74,16 @@ use dictation::{ }; #[cfg(any(debug_assertions, test))] use dictation::{handle_pressed, handle_released}; -use qa::{close_qa_panel, handle_qa_hotkey_pressed, QaPhase, QaSessionState}; +use qa::{ + close_qa_panel, handle_qa_hotkey_pressed, handle_qa_option_edge, open_qa_panel, QaPhase, + QaSessionState, +}; #[cfg(test)] use resources::discard_startup_resources_for_session; use resources::{ acquire_recording_mute, cancel_active_asr, release_recording_mute, selected_microphone_device_name, stop_microphone_preview_monitor, stop_qa_recorder, - SessionResource, SharedRecordingMuteState, + take_asr_for_session, take_recorder_for_session, SessionResource, SharedRecordingMuteState, }; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1011,6 +1015,15 @@ impl Coordinator { begin_session(&self.inner).await } + pub async fn start_dictation_with_translation(&self) -> Result<(), String> { + begin_session(&self.inner).await?; + self.inner + .translation_modifier_seen + .store(true, Ordering::SeqCst); + log::info!("[coord] android overlay translation dictation started"); + Ok(()) + } + pub async fn stop_dictation(&self) -> Result<(), String> { if self.inner.state.lock().phase == SessionPhase::Starting { request_stop_during_starting(&self.inner, "manual stop"); @@ -1019,10 +1032,30 @@ impl Coordinator { end_session(&self.inner).await } + pub async fn stop_dictation_with_translation(&self, translation: bool) -> Result<(), String> { + if translation { + mark_translation_modifier_seen(&self.inner); + } + self.stop_dictation().await + } + pub fn cancel_dictation(&self) { cancel_session(&self.inner); } + pub fn switch_to_previous_style_pack(&self) { + switch_to_previous_style(&self.inner); + } + + pub async fn open_qa_from_overlay(&self) -> Result<(), String> { + open_qa_panel(&self.inner); + begin_qa_session(&self.inner).await + } + + pub async fn finalize_qa_from_overlay(&self) -> Result<(), String> { + finalize_dictation_as_qa_question(&self.inner).await + } + /// 返回当前听写阶段(read-only 快照),供 CLI 入口在 dispatch toggle 时决策。 /// 与原热键边沿走的 `handle_pressed` 分支完全相同的判定逻辑:Idle → start, /// Listening → stop。可用于桌面快捷键 → CLI 转发的备用触发路径。 @@ -1037,6 +1070,10 @@ impl Coordinator { handle_qa_hotkey_pressed(&self.inner).await; } + pub async fn qa_toggle_recording(&self) { + handle_qa_option_edge(&self.inner).await; + } + pub fn set_shortcut_recording_active(&self, active: bool) { self.inner .shortcut_recording_active @@ -3614,6 +3651,429 @@ fn enabled_hotwords(inner: &Arc) -> Vec { // ─────────────────────────── QA session lifecycle ─────────────────────────── +async fn finalize_dictation_as_qa_question(inner: &Arc) -> Result<(), String> { + open_qa_panel(inner); + { + let mut state = inner.qa_state.lock(); + state.phase = QaPhase::Processing; + state.cancelled = false; + state.session_id = new_session_id(); + state.front_app = capture_frontmost_app(); + state.selection = None; + } + inner.qa_stream_cancelled.store(false, Ordering::SeqCst); + + let selection = capture_selection(); + let selection_preview_text = selection.as_ref().map(|s| s.text.clone()); + inner.qa_state.lock().selection = selection; + + if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); + let _ = app.emit_to( + "qa", + "qa:state", + serde_json::json!({ + "kind": "loading", + "selection_preview": selection_preview_text, + "messages": messages, + }), + ); + } + + let raw = match take_current_dictation_transcript_for_qa(inner).await { + Ok(Some(raw)) => raw, + Ok(None) => { + finish_qa_idle_silently(inner); + return Ok(()); + } + Err(error) => { + finish_qa_with_error(inner, error.clone()); + return Err(error); + } + }; + answer_qa_question_text(inner, raw.text.trim().to_string(), raw.duration_ms).await +} + +async fn take_current_dictation_transcript_for_qa( + inner: &Arc, +) -> Result, String> { + wait_for_dictation_listening(inner).await?; + + let current_session_id = { + let mut state = inner.state.lock(); + let Some(session_id) = start_processing_if_listening(&mut state) else { + return Ok(None); + }; + session_id + }; + + let elapsed = inner.state.lock().started_at.elapsed().as_millis() as u64; + emit_capsule(inner, CapsuleState::Transcribing, 0.0, elapsed, None, None); + + if let Some(rec) = take_recorder_for_session(inner, current_session_id) { + rec.stop(); + release_recording_mute(inner, "dictation"); + } + + let Some(asr) = take_asr_for_session(inner, current_session_id) else { + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(None); + }; + + let mut raw = match transcribe_overlay_dictation_asr(inner, current_session_id, asr).await { + Ok(raw) => raw, + Err(error) => { + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + finish_qa_with_error(inner, format!("识别失败: {error}")); + return Err(error); + } + }; + + if inner.state.lock().cancelled { + log::info!("[coord] overlay QA: cancel detected after ASR — discarding transcript"); + restore_prepared_windows_ime_session(inner, current_session_id); + { + let mut state = inner.state.lock(); + state.phase = SessionPhase::Idle; + state.focus_target = None; + } + return Ok(None); + } + + #[cfg(any(debug_assertions, test))] + if raw.text.trim().is_empty() { + if let Some(debug_text) = debug_transcript_override_text() { + raw.text = debug_text; + } + } + + if raw.text.trim().is_empty() { + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + finish_qa_idle_silently(inner); + return Ok(None); + } + + if let Ok(rules) = inner.correction_rules.list() { + let corrected = apply_correction_rules(&raw.text, &rules); + if corrected != raw.text { + raw.text = corrected; + } + } + + restore_prepared_windows_ime_session(inner, current_session_id); + { + let mut state = inner.state.lock(); + state.phase = SessionPhase::Idle; + state.focus_target = None; + } + Ok(Some(raw)) +} + +async fn wait_for_dictation_listening(inner: &Arc) -> Result<(), String> { + const MAX_WAIT_MS: u64 = 3_000; + const STEP_MS: u64 = 20; + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(MAX_WAIT_MS); + + loop { + let phase = { inner.state.lock().phase }; + match phase { + SessionPhase::Starting if std::time::Instant::now() < deadline => { + tokio::time::sleep(std::time::Duration::from_millis(STEP_MS)).await; + } + SessionPhase::Starting => { + return Err("dictation startup timed out before QA finalize".to_string()); + } + _ => return Ok(()), + } + } +} + +async fn transcribe_overlay_dictation_asr( + _inner: &Arc, + _current_session_id: SessionId, + asr: ActiveAsr, +) -> Result { + let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr); + match asr { + ActiveAsr::Volcengine(asr) => { + debug_assert!(uses_global_timeout); + if let Err(error) = asr.send_last_frame().await { + log::error!("[coord] overlay QA: send last frame failed: {error}"); + } + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { + Ok(Ok(raw)) => Ok(raw), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => { + asr.cancel(); + Err("global timeout".to_string()) + } + } + } + ActiveAsr::Bailian(asr) => { + debug_assert!(uses_global_timeout); + if let Err(error) = asr.send_last_frame().await { + log::error!("[coord] overlay QA: Bailian send last frame failed: {error}"); + } + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { + Ok(Ok(raw)) => Ok(raw), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => { + asr.cancel(); + Err("bailian global timeout".to_string()) + } + } + } + ActiveAsr::Whisper(whisper) => { + debug_assert!(uses_global_timeout); + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, whisper.transcribe()).await { + Ok(Ok(raw)) => Ok(raw), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => Err("whisper global timeout".to_string()), + } + } + ActiveAsr::Mimo(mimo) => { + debug_assert!(uses_global_timeout); + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, mimo.transcribe()).await { + Ok(Ok(raw)) => Ok(raw), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => Err("mimo global timeout".to_string()), + } + } + #[cfg(target_os = "windows")] + ActiveAsr::FoundryLocalWhisper(local) => { + debug_assert!(!uses_global_timeout); + match local + .transcribe(foundry_audio_transcribe_timeout_duration()) + .await + { + Ok(raw) => { + schedule_foundry_local_asr_release( + _inner, + AsrReleaseSession::Dictation(_current_session_id), + ); + Ok(raw) + } + Err(error) => { + schedule_foundry_local_asr_release( + _inner, + AsrReleaseSession::Dictation(_current_session_id), + ); + Err(error.to_string()) + } + } + } + #[cfg(target_os = "windows")] + ActiveAsr::SherpaOnnxLocal(local) => { + debug_assert!(!uses_global_timeout); + match local + .transcribe(sherpa_audio_transcribe_timeout_duration()) + .await + { + Ok(raw) => { + schedule_sherpa_onnx_release( + _inner, + AsrReleaseSession::Dictation(_current_session_id), + ); + Ok(raw) + } + Err(error) => { + schedule_sherpa_onnx_release( + _inner, + AsrReleaseSession::Dictation(_current_session_id), + ); + Err(error.to_string()) + } + } + } + #[cfg(target_os = "macos")] + ActiveAsr::Local(local) => { + debug_assert!(uses_global_timeout); + let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0; + let timeout_duration = local_qwen_transcribe_timeout(audio_secs); + let result = tokio::time::timeout(timeout_duration, local.transcribe()).await; + _inner.local_asr_cache.touch(); + schedule_local_asr_release(_inner); + match result { + Ok(Ok(raw)) => Ok(raw), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => Err("local qwen transcribe timeout".to_string()), + } + } + } +} + +async fn answer_qa_question_text( + inner: &Arc, + question: String, + duration_ms: u64, +) -> Result<(), String> { + if question.trim().is_empty() { + finish_qa_idle_silently(inner); + return Ok(()); + } + + let user_content = { + let st = inner.qa_state.lock(); + let is_first_turn = st.messages.is_empty(); + let sel_text = st + .selection + .as_ref() + .map(|s| s.text.clone()) + .unwrap_or_default(); + if is_first_turn && !sel_text.trim().is_empty() { + format!( + "# 选区原文\n{}\n\n# 我的问题\n{}", + sel_text.trim(), + question + ) + } else { + question.clone() + } + }; + + inner + .qa_state + .lock() + .messages + .push(crate::types::QaChatMessage { + role: "user".to_string(), + content: user_content, + }); + + if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); + let _ = app.emit_to( + "qa", + "qa:state", + serde_json::json!({ + "kind": "thinking", + "messages": messages, + }), + ); + } + + emit_capsule(inner, CapsuleState::Polishing, 0.0, 0, None, None); + + let prefs = inner.prefs.get(); + let working_languages = prefs.working_languages.clone(); + let chinese_script_preference = prefs.chinese_script_preference; + let output_language_preference = prefs.output_language_preference; + let llm_thinking_enabled = prefs.llm_thinking_enabled; + let (messages_for_llm, front_app) = { + let st = inner.qa_state.lock(); + (st.messages.clone(), st.front_app.clone()) + }; + + let captured_session_id = inner.qa_state.lock().session_id; + let inner_for_delta = Arc::clone(inner); + let on_delta = move |chunk: &str| { + let cur_id = inner_for_delta.qa_state.lock().session_id; + if cur_id != captured_session_id { + return; + } + if let Some(app) = inner_for_delta.app.lock().clone() { + let _ = app.emit_to( + "qa", + "qa:state", + serde_json::json!({ + "kind": "answer_delta", + "chunk": chunk, + }), + ); + } + }; + + let cancel_flag = Arc::clone(&inner.qa_stream_cancelled); + let should_cancel = move || cancel_flag.load(Ordering::Relaxed); + + let answer = match answer_chat_dispatch( + &messages_for_llm, + &working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app.as_deref(), + on_delta, + should_cancel, + ) + .await + { + Ok(answer) => answer, + Err(error) => { + inner.qa_state.lock().messages.pop(); + finish_qa_with_error(inner, format!("回答失败: {error}")); + return Err(error.to_string()); + } + }; + + if inner.qa_state.lock().cancelled { + inner.qa_state.lock().messages.pop(); + finish_qa_idle_silently(inner); + return Ok(()); + } + + inner + .qa_state + .lock() + .messages + .push(crate::types::QaChatMessage { + role: "assistant".to_string(), + content: answer.clone(), + }); + + if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); + let _ = app.emit_to( + "qa", + "qa:state", + serde_json::json!({ + "kind": "answer", + "messages": messages, + }), + ); + } + + emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); + + if prefs.qa_save_history { + let session = DictationSession { + id: Uuid::new_v4().to_string(), + created_at: Utc::now().to_rfc3339(), + raw_transcript: question.clone(), + final_text: answer, + mode: PolishMode::Raw, + style_pack_id: None, + translation_active: false, + polish_source: None, + app_bundle_id: None, + app_name: front_app, + insert_status: InsertStatus::CopiedFallback, + error_code: Some("qaSession".to_string()), + duration_ms: Some(duration_ms), + dictionary_entry_count: None, + has_audio_recording: None, + }; + let prefs_snapshot = inner.prefs.get(); + if let Err(error) = inner.history.append_with_retention( + session, + prefs_snapshot.history_retention_days, + prefs_snapshot.history_max_entries, + ) { + log::error!("[coord] overlay QA history append failed: {error}"); + } + } + + inner.qa_state.lock().phase = QaPhase::Idle; + Ok(()) +} + /// 划词语音问答会话(issue #118)。 /// /// 与 dictation 完全分离: diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 7b6a75d6..d05ce7f6 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -14,6 +14,14 @@ //! - coordinator: dictation state machine glue //! - commands: Tauri IPC surface +mod android_accessibility; +#[cfg(target_os = "android")] +mod android_insert; +#[cfg(target_os = "android")] +mod android_jni; +#[cfg(target_os = "android")] +mod android_native_bridge; +mod android_overlay; mod asr; mod audio_mute; mod cli; @@ -40,6 +48,8 @@ mod insertion; #[cfg(target_os = "linux")] mod linux_fcitx; mod llm_gemini; +#[cfg(mobile)] +mod mobile_runtime; mod net; mod permissions; mod persistence; @@ -67,16 +77,6 @@ mod unicode_keystroke; #[cfg(mobile)] #[path = "mobile_stubs/unicode_keystroke.rs"] mod unicode_keystroke; -mod android_overlay; -mod android_accessibility; -#[cfg(target_os = "android")] -mod android_insert; -#[cfg(target_os = "android")] -mod android_jni; -#[cfg(target_os = "android")] -mod android_native_bridge; -#[cfg(mobile)] -mod mobile_runtime; #[cfg(target_os = "windows")] mod windows_ime_ipc; #[cfg(target_os = "windows")] @@ -340,6 +340,9 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::start_dictation, $crate::commands::stop_dictation, $crate::commands::cancel_dictation, + $crate::commands::qa_window_dismiss, + $crate::commands::qa_window_pin, + $crate::commands::qa_toggle_recording, $crate::commands::repolish, $crate::commands::list_style_packs, $crate::commands::create_style_pack_from_template, @@ -1413,6 +1416,16 @@ fn position_qa_window(window: &tauri::WebviewWindow) -> ta /// 让前端 React 视图自行决定渲染哪一种。**不**抢前台 app 焦点(保证 Cmd+C /// fallback 仍能从原 app 拿到选区)。 pub(crate) fn show_qa_window(app: &AppHandle, content_kind: &str) { + #[cfg(target_os = "android")] + { + let _ = app.emit_to( + "main", + "qa:state", + serde_json::json!({ "kind": content_kind }), + ); + return; + } + let Some(window) = app.get_webview_window("qa") else { log::info!("[qa] show 跳过:qa 窗口不存在 (content_kind={content_kind})"); return; @@ -1505,6 +1518,12 @@ fn make_qa_window_draggable_macos(window: &tauri::WebviewWind /// 隐藏 QA 窗口。供 commands::qa_window_dismiss / coordinator session 收尾共用。 pub(crate) fn hide_qa_window(app: &AppHandle) { + #[cfg(target_os = "android")] + { + let _ = app.emit_to("main", "qa:dismiss", serde_json::json!({})); + return; + } + if let Some(window) = app.get_webview_window("qa") { let _ = window.hide(); } diff --git a/openless-all/app/src-tauri/src/mobile_runtime.rs b/openless-all/app/src-tauri/src/mobile_runtime.rs index 3376c132..d810aa09 100644 --- a/openless-all/app/src-tauri/src/mobile_runtime.rs +++ b/openless-all/app/src-tauri/src/mobile_runtime.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use tauri::{AppHandle, Manager, RunEvent}; -use crate::coordinator::Coordinator; use crate::commands::MicrophoneMonitorState; +use crate::coordinator::Coordinator; pub fn run() { let coordinator = Arc::new(Coordinator::new()); @@ -23,6 +23,9 @@ pub fn run() { if let Some(main) = app.get_webview_window("main") { let _ = main.show(); } + if let Some(qa) = app.get_webview_window("qa") { + let _ = qa.hide(); + } coordinator.bind_app(app.handle().clone()); #[cfg(target_os = "android")] diff --git a/openless-all/app/src-tauri/src/mobile_stubs/selection.rs b/openless-all/app/src-tauri/src/mobile_stubs/selection.rs index c7de0c6e..8caac9c4 100644 --- a/openless-all/app/src-tauri/src/mobile_stubs/selection.rs +++ b/openless-all/app/src-tauri/src/mobile_stubs/selection.rs @@ -1,4 +1,9 @@ -//! Mobile stub — selection capture is desktop-only for now. +//! Mobile selection capture. + +const SELECTION_MAX_CHARS: usize = 4000; +const SELECTION_TRUNCATE_HEAD: usize = 2000; +const SELECTION_TRUNCATE_TAIL: usize = 2000; +const SELECTION_TRUNCATED_MARKER: &str = "\n[…truncated…]\n"; #[derive(Debug, Clone)] pub struct SelectionContext { @@ -6,6 +11,44 @@ pub struct SelectionContext { pub source_app: Option, } +#[cfg(target_os = "android")] +pub fn capture_selection() -> Option { + let text = match crate::android_jni::android::with_android_env(|env, context| { + crate::android_jni::android::accessibility_selected_text(env, context) + }) { + Ok(Some(text)) => text, + Ok(None) => return None, + Err(error) => { + log::warn!("[selection] Android accessibility selection read failed: {error}"); + return None; + } + }; + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + log::info!( + "[selection] Android accessibility read OK ({} chars)", + trimmed.chars().count() + ); + Some(SelectionContext { + text: truncate_selection(trimmed), + source_app: Some("Android accessibility".to_string()), + }) +} + +#[cfg(not(target_os = "android"))] pub fn capture_selection() -> Option { None } + +fn truncate_selection(text: &str) -> String { + let total: usize = text.chars().count(); + if total <= SELECTION_MAX_CHARS { + return text.to_string(); + } + let head: String = text.chars().take(SELECTION_TRUNCATE_HEAD).collect(); + let tail_start = total.saturating_sub(SELECTION_TRUNCATE_TAIL); + let tail: String = text.chars().skip(tail_start).collect(); + format!("{head}{SELECTION_TRUNCATED_MARKER}{tail}") +} diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index 6dc7801a..bc0d0fc6 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -12,6 +12,7 @@ import { getPlatformCapabilities, handleWindowHotkeyEvent, isTauri, + qaWindowDismiss, } from './lib/ipc'; import type { PlatformCapabilities } from './lib/types'; import { @@ -52,6 +53,7 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force // Windows 启动不应被权限探测阻塞首屏。 const [gate, setGate] = useState('ready'); const [platformCaps, setPlatformCaps] = useState(null); + const [mobileQaOpen, setMobileQaOpen] = useState(false); useEffect(() => { applyAppTheme(readAppTheme()); const syncTheme = (event: StorageEvent) => { @@ -67,6 +69,51 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force void getPlatformCapabilities().then(setPlatformCaps); }, []); + useEffect(() => { + if (!isTauri || platformCaps?.platform !== 'android') return; + let unlistenState: (() => void) | undefined; + let unlistenDismiss: (() => void) | undefined; + let cancelled = false; + (async () => { + try { + const { listen } = await import('@tauri-apps/api/event'); + const stateHandle = await listen('qa:state', () => { + setMobileQaOpen(true); + }); + const dismissHandle = await listen('qa:dismiss', () => { + setMobileQaOpen(false); + }); + if (cancelled) { + stateHandle(); + dismissHandle(); + } else { + unlistenState = stateHandle; + unlistenDismiss = dismissHandle; + } + } catch (error) { + console.warn('[qa] mobile route listener setup failed', error); + } + })(); + return () => { + cancelled = true; + unlistenState?.(); + unlistenDismiss?.(); + }; + }, [platformCaps?.platform]); + + useEffect(() => { + if (!mobileQaOpen || platformCaps?.platform !== 'android') return; + window.history.pushState({ openlessQa: true }, '', window.location.href); + const onPopState = () => { + setMobileQaOpen(false); + void qaWindowDismiss().catch(error => console.warn('[qa] mobile back dismiss failed', error)); + }; + window.addEventListener('popstate', onPopState); + return () => { + window.removeEventListener('popstate', onPopState); + }; + }, [mobileQaOpen, platformCaps?.platform]); + useEffect(() => { if (!isTauri) return; let cancelled = false; @@ -207,7 +254,24 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force return ( - {gate === 'onboarding' ? setGate('ready')} /> : } + {platformCaps?.platform === 'android' && ( +
+ { + setMobileQaOpen(false); + if (window.history.state?.openlessQa === true) { + window.history.back(); + } + }} + /> +
+ )} + {!mobileQaOpen && (gate === 'onboarding' ? ( + setGate('ready')} /> + ) : ( + + ))} {gate === 'ready' && platformCaps?.supportsAutoUpdate === true && }
); diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 76c9311e..f8fab1eb 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -49,6 +49,9 @@ export const en: typeof zhCN = { emptyTitle: 'Press {{recordHotkey}} to ask', emptyDesc: 'Select text in any app, press {{recordHotkey}} once to start recording, press it again to submit. Answers appear here. You can ask follow-up questions in the same panel.', recordingHint: 'Recording… press {{recordHotkey}} again to submit', + mobileRecordLabel: 'record button', + mobileRecordStart: 'Start recording', + mobileRecordStop: 'Stop and submit', statusIdle: 'Press {{recordHotkey}} to ask', statusRecording: 'Recording', statusThinking: 'Thinking', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index f063cdac..c9077e7c 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -51,6 +51,9 @@ export const ja: typeof zhCN = { emptyTitle: '{{recordHotkey}} を押して質問を開始', emptyDesc: '任意のアプリでテキストを選択した後、{{recordHotkey}} を 1 回押して録音を開始し、もう 1 回押して送信します。回答はここに表示され、続けて追加質問が可能です。', recordingHint: '録音中… {{recordHotkey}} をもう一度押して終了し、質問します', + mobileRecordLabel: '録音ボタン', + mobileRecordStart: '録音を開始', + mobileRecordStop: '終了して送信', statusIdle: '{{recordHotkey}} で質問', statusRecording: '録音中', statusThinking: '思考中', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 3523261d..d642d492 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -51,6 +51,9 @@ export const ko: typeof zhCN = { emptyTitle: '{{recordHotkey}} 를 눌러 질문 시작', emptyDesc: '아무 앱에서 텍스트를 선택한 후 {{recordHotkey}} 를 한 번 눌러 녹음을 시작하고, 다시 한 번 눌러 종료 후 제출합니다. 답변이 여기에 표시되며 연속해서 후속 질문이 가능합니다.', recordingHint: '녹음 중… {{recordHotkey}} 를 다시 눌러 종료하고 질문', + mobileRecordLabel: '녹음 버튼', + mobileRecordStart: '녹음 시작', + mobileRecordStop: '종료하고 제출', statusIdle: '{{recordHotkey}} 로 질문', statusRecording: '녹음 중', statusThinking: '생각 중', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 008208c0..4934e33d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -47,6 +47,9 @@ export const zhCN = { emptyTitle: '按 {{recordHotkey}} 开始提问', emptyDesc: '在任意 app 选中一段文字后,按一次 {{recordHotkey}} 开始录音,再按一次结束并提交。回答会显示在这里,可以连续多轮追问。', recordingHint: '录音中…再按一次 {{recordHotkey}} 结束并提问', + mobileRecordLabel: '录音按钮', + mobileRecordStart: '开始录音', + mobileRecordStop: '结束并提交', statusIdle: '按 {{recordHotkey}} 提问', statusRecording: '录音中', statusThinking: '思考中', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index a9650491..590b4384 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -49,6 +49,9 @@ export const zhTW: typeof zhCN = { emptyTitle: '按 {{recordHotkey}} 開始提問', emptyDesc: '在任意 app 選中一段文字後,按一次 {{recordHotkey}} 開始錄音,再按一次結束並提交。回答會顯示在這裏,可以連續多輪追問。', recordingHint: '錄音中…再按一次 {{recordHotkey}} 結束並提問', + mobileRecordLabel: '錄音按鈕', + mobileRecordStart: '開始錄音', + mobileRecordStop: '結束並提交', statusIdle: '按 {{recordHotkey}} 提問', statusRecording: '錄音中', statusThinking: '思考中', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index d864c445..4325b372 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -1233,6 +1233,10 @@ export function qaWindowPin(pinned: boolean): Promise { return invokeOrMock("qa_window_pin", { pinned }, () => undefined) } +export function qaToggleRecording(): Promise { + return invokeOrMock("qa_toggle_recording", undefined, () => undefined) +} + // ── Less Computer 浮窗 ──────────────────────────────────────────────── /** 用户点 ✕ / 按 Esc 关闭 Less Computer 浮窗(隐藏窗口)。 */ export function lessComputerWindowDismiss(): Promise { diff --git a/openless-all/app/src/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx index 184e2fc3..7f58749f 100644 --- a/openless-all/app/src/pages/QaPanel.tsx +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -12,8 +12,15 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; -import { getSettings, isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc'; -import type { QaChatMessage, QaStatePayload, UserPreferences } from '../lib/types'; +import { + getPlatformCapabilities, + getSettings, + isTauri, + qaToggleRecording, + qaWindowDismiss, + qaWindowPin, +} from '../lib/ipc'; +import type { PlatformCapabilities, QaChatMessage, QaStatePayload, UserPreferences } from '../lib/types'; import { getHotkeyBindingLabel } from '../lib/hotkey'; import { renderQaMarkdown, renderQaPlainText } from '../lib/qaMarkdown'; @@ -21,7 +28,12 @@ const SELECTION_PREVIEW_MAX = 60; type Status = 'idle' | 'recording' | 'thinking' | 'error'; -export function QaPanel() { +interface QaPanelProps { + embedded?: boolean; + onRequestClose?: () => void; +} + +export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {}) { const { t, i18n } = useTranslation(); const [messages, setMessages] = useState([]); const [status, setStatus] = useState('idle'); @@ -32,6 +44,7 @@ export function QaPanel() { const [streamingAnswer, setStreamingAnswer] = useState(''); /** 录音电平:0..1。后端每帧 33ms 通过 qa:level emit。详见 issue #162。 */ const [level, setLevel] = useState(0); + const [platformCaps, setPlatformCaps] = useState(null); /** 用户当前的录音热键 label(如 "右 Option" / "Right Alt")。issue #205: * 原版硬编码 "Option",Windows 用户没这个键,文案失真。读 prefs 后由 i18n * 插值动态显示,平台与用户配置都能跟上。 */ @@ -40,6 +53,10 @@ export function QaPanel() { ); const tRef = useRef(t); tRef.current = t; + const embeddedRef = useRef(embedded); + embeddedRef.current = embedded; + const onRequestCloseRef = useRef(onRequestClose); + onRequestCloseRef.current = onRequestClose; // ── 后端事件订阅(mount 时订阅一次,永不重订阅)────────────────── useEffect(() => { @@ -111,7 +128,11 @@ export function QaPanel() { }); const dismissHandle = await listen('qa:dismiss', () => { setPinned(false); - void qaWindowDismiss(); + if (embeddedRef.current) { + onRequestCloseRef.current?.(); + } else { + void qaWindowDismiss(); + } }); // qa:level — 录音电平,节流 ~33ms/帧。详见 issue #162。 const levelHandle = await listen<{ level: number }>('qa:level', event => { @@ -153,11 +174,12 @@ export function QaPanel() { if (event.key === 'Escape') { event.preventDefault(); void qaWindowDismiss(); + onRequestClose?.(); } }; window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); - }, []); + }, [onRequestClose]); // ── 读取用户当前的录音热键 label,给 i18n 插值用(issue #205)。 // QaPanel 跑在独立 webview(label="qa"),没有 HotkeySettingsContext @@ -182,6 +204,25 @@ export function QaPanel() { }; }, [i18n.language]); + useEffect(() => { + let cancelled = false; + void getPlatformCapabilities() + .then(caps => { + if (!cancelled) setPlatformCaps(caps); + }) + .catch(err => { + console.warn('[QaPanel] load platform capabilities failed', err); + }); + return () => { + cancelled = true; + }; + }, []); + + const mobileRecordButton = platformCaps?.supportsDesktopHotkey === false; + const recordControlLabel = mobileRecordButton + ? t('qa.mobileRecordLabel') + : recordHotkeyLabel; + const onTogglePin = () => { const next = !pinned; setPinned(next); @@ -190,6 +231,7 @@ export function QaPanel() { const onClose = () => { void qaWindowDismiss(); + onRequestClose?.(); }; // ── 自动滚动到底(新消息进来时)──────────────────────────────────── @@ -201,18 +243,18 @@ export function QaPanel() { }, [messages, status]); return ( -
- +
+
{messages.length === 0 && status === 'idle' && ( - + )} {messages.length === 0 && status === 'recording' && ( )} @@ -222,7 +264,7 @@ export function QaPanel() { t={t} preview={selectionPreview} level={level} - recordHotkey={recordHotkeyLabel} + recordHotkey={recordControlLabel} /> )} {streamingAnswer && ( @@ -232,10 +274,20 @@ export function QaPanel() { )} {status === 'error' && ( - + )}
- + {mobileRecordButton && ( + { + if (status === 'thinking') return; + void qaToggleRecording(); + }} + /> + )} +
); } @@ -246,9 +298,10 @@ interface ToolbarProps { pinned: boolean; onTogglePin: () => void; onClose: () => void; + embedded?: boolean; } -function Toolbar({ pinned, onTogglePin, onClose }: ToolbarProps) { +function Toolbar({ pinned, onTogglePin, onClose, embedded = false }: ToolbarProps) { const { t } = useTranslation(); // 拖动 (issue #205): // - macOS: lib.rs::make_qa_window_draggable_macos 在 NSWindow 层把整窗口设 @@ -258,7 +311,7 @@ function Toolbar({ pinned, onTogglePin, onClose }: ToolbarProps) { // 两条路径并存不冲突;data-tauri-drag-region 放在 toolbar 的空白 spacer 上,IconBtn // 作为 button 子元素仍然正常 click。 return ( -
+
['t']; + onClick: () => void; +}) { + const disabled = status === 'thinking'; + const recording = status === 'recording'; + return ( +
+ +
+ ); +} + function SkeletonLine({ width }: { width: string }) { return (
Date: Wed, 10 Jun 2026 21:16:18 +0800 Subject: [PATCH 71/83] Fix Android overlay QA events and size setting --- .../OpenLessAndroidPreferences.kt | 29 ++++++++++ .../OpenLessOverlayService.kt | 39 +++++++++++-- .../src-tauri/src/android_native_bridge.rs | 9 +++ .../app/src-tauri/src/android_overlay.rs | 11 ++++ openless-all/app/src-tauri/src/commands.rs | 5 +- openless-all/app/src-tauri/src/coordinator.rs | 55 ++++++++++++++----- .../app/src-tauri/src/coordinator/qa.rs | 4 +- openless-all/app/src-tauri/src/lib.rs | 1 + openless-all/app/src-tauri/src/types.rs | 16 ++++++ openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/ja.ts | 2 + openless-all/app/src/i18n/ko.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + openless-all/app/src/lib/ipc.ts | 1 + openless-all/app/src/lib/stylePrefs.test.ts | 1 + openless-all/app/src/lib/types.ts | 2 + .../src/pages/settings/PermissionsSection.tsx | 33 ++++++++++- 18 files changed, 194 insertions(+), 22 deletions(-) diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt index 460cc2e4..7cf91ed9 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt @@ -15,6 +15,10 @@ object OpenLessAndroidPreferences { private const val KEY_OVERLAY_TRIGGER = "androidOverlayTrigger" private const val KEY_OVERLAY_ACTIVATION_MODE = "androidOverlayActivationMode" private const val KEY_OVERLAY_LEFT_SWIPE_ACTION = "androidOverlayLeftSwipeAction" + private const val KEY_OVERLAY_SIZE_DP = "androidOverlaySizeDp" + private const val DEFAULT_OVERLAY_SIZE_DP = 72 + private const val MIN_OVERLAY_SIZE_DP = 48 + private const val MAX_OVERLAY_SIZE_DP = 120 private val VALID_OVERLAY_TRIGGERS = setOf("background", "always") private val VALID_OVERLAY_ACTIVATION_MODES = setOf("tap", "long_press") private val VALID_OVERLAY_LEFT_SWIPE_ACTIONS = setOf("translation", "style_pack") @@ -39,6 +43,12 @@ object OpenLessAndroidPreferences { ?: "translation" } + fun overlaySizeDp(context: Context): Int { + return readPreferenceInt(context, KEY_OVERLAY_SIZE_DP) + ?.coerceIn(MIN_OVERLAY_SIZE_DP, MAX_OVERLAY_SIZE_DP) + ?: DEFAULT_OVERLAY_SIZE_DP + } + private fun readPreferenceString(context: Context, key: String): String? { for (file in preferenceFiles(context).distinctBy { it.absolutePath }) { if (!file.isFile) { @@ -57,6 +67,25 @@ object OpenLessAndroidPreferences { return null } + private fun readPreferenceInt(context: Context, key: String): Int? { + for (file in preferenceFiles(context).distinctBy { it.absolutePath }) { + if (!file.isFile) { + continue + } + val value = try { + val json = JSONObject(file.readText()) + if (json.has(key)) json.optInt(key) else null + } catch (error: Throwable) { + Log.w(TAG, "read ${file.absolutePath} failed", error) + null + } + if (value != null) { + return value + } + } + return null + } + private fun preferenceFiles(context: Context): List { val files = mutableListOf() val envDir = System.getenv("TAURI_ANDROID_APP_DATA_DIR") diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt index 6e329f54..da9882d8 100644 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt +++ b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt @@ -141,6 +141,11 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList layoutParams = existing.layoutParams as? WindowManager.LayoutParams iconContainer = existing (existing.getChildAt(0) as? ImageView)?.let { iconButton = it } + applyOverlaySize(existing) + layoutParams?.let { params -> + clampToScreen(params) + windowManager?.updateViewLayout(existing, params) + } Log.i(TAG, "overlay already shown roots=${overlayRoots.size}") return } @@ -170,15 +175,15 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList contentDescription = "OpenLess" isClickable = true isFocusable = false - setPadding(dp(ICON_PADDING_DP), dp(ICON_PADDING_DP), dp(ICON_PADDING_DP), dp(ICON_PADDING_DP)) setOnClickListener { handleIconClick() } } iconContainer = root iconButton = buildIconButton() root.addView( iconButton, - FrameLayout.LayoutParams(dp(ICON_IMAGE_SIZE_DP), dp(ICON_IMAGE_SIZE_DP), Gravity.CENTER), + FrameLayout.LayoutParams(1, 1, Gravity.CENTER), ) + applyOverlaySize(root) attachDragHandler(root, params) try { windowManager?.addView(root, params) @@ -264,6 +269,27 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } } + private fun applyOverlaySize(container: FrameLayout) { + if (!::iconButton.isInitialized) return + val sizeDp = OpenLessAndroidPreferences.overlaySizeDp(this) + val paddingDp = overlayPaddingDp(sizeDp) + val imageSizePx = dp((sizeDp - paddingDp * 2).coerceAtLeast(MIN_ICON_IMAGE_SIZE_DP)) + val paddingPx = dp(paddingDp) + container.setPadding(paddingPx, paddingPx, paddingPx, paddingPx) + (iconButton.layoutParams as? FrameLayout.LayoutParams)?.let { childParams -> + childParams.width = imageSizePx + childParams.height = imageSizePx + childParams.gravity = Gravity.CENTER + iconButton.layoutParams = childParams + } + container.requestLayout() + Log.i(TAG, "overlay size applied sizeDp=$sizeDp imagePx=$imageSizePx") + } + + private fun overlayPaddingDp(sizeDp: Int): Int { + return (sizeDp * ICON_PADDING_DP / DEFAULT_ICON_SIZE_DP).coerceIn(6, 16) + } + private fun handleIconClick() { if (processing) return if (recording) { @@ -429,6 +455,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } private fun commitSwipe(direction: SwipeDirection) { + Log.i(TAG, "commit swipe direction=$direction recording=$recording processing=$processing") when (direction) { SwipeDirection.Left -> handleLeftSwipe() SwipeDirection.Right -> finalizeQaFromOverlay() @@ -460,6 +487,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private fun openQaFromOverlay() { try { + Log.i(TAG, "open QA from overlay") OpenLessNative.nativeOpenQaFromOverlay() setArmed(false) } catch (error: Throwable) { @@ -471,6 +499,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private fun finalizeQaFromOverlay() { try { + Log.i(TAG, "finalize QA from overlay") OpenLessNative.nativeFinalizeQaFromOverlay() recording = false processing = true @@ -578,7 +607,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private fun overlaySize(): Int { val root = rootView val measured = maxOf(root?.width ?: 0, root?.height ?: 0) - return measured.takeIf { it > 0 } ?: dp(ICON_SIZE_DP) + return measured.takeIf { it > 0 } ?: dp(OpenLessAndroidPreferences.overlaySizeDp(this)) } private fun clampToScreen(params: WindowManager.LayoutParams) { @@ -649,8 +678,8 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList const val EXTRA_KEYBOARD_VISIBLE = "keyboard_visible" const val EXTRA_KEYBOARD_TOP = "keyboard_top" const val EXTRA_KEYBOARD_BOTTOM = "keyboard_bottom" - private const val ICON_SIZE_DP = 72 - private const val ICON_IMAGE_SIZE_DP = 56 + private const val DEFAULT_ICON_SIZE_DP = 72 + private const val MIN_ICON_IMAGE_SIZE_DP = 32 private const val ICON_PADDING_DP = 8 private const val DRAG_SLOP_PX = 8 private const val SWIPE_THRESHOLD_DP = 56 diff --git a/openless-all/app/src-tauri/src/android_native_bridge.rs b/openless-all/app/src-tauri/src/android_native_bridge.rs index f34dd598..d04d2088 100644 --- a/openless-all/app/src-tauri/src/android_native_bridge.rs +++ b/openless-all/app/src-tauri/src/android_native_bridge.rs @@ -49,6 +49,13 @@ pub fn hide_overlay() -> Result<(), String> { Ok(()) } +pub fn refresh_overlay_if_visible() -> Result<(), String> { + if !is_overlay_visible() { + return Ok(()); + } + show_overlay() +} + #[cfg(target_os = "android")] fn show_overlay_with_context( env: &mut jni::JNIEnv, @@ -165,6 +172,7 @@ fn spawn_open_qa_from_overlay() { log::warn!("[android-native] coordinator unavailable"); return; }; + log::info!("[android-native] open_qa_from_overlay requested"); tauri::async_runtime::spawn(async move { if let Err(error) = coordinator.open_qa_from_overlay().await { log::warn!("[android-native] open_qa_from_overlay failed: {error}"); @@ -177,6 +185,7 @@ fn spawn_finalize_qa_from_overlay() { log::warn!("[android-native] coordinator unavailable"); return; }; + log::info!("[android-native] finalize_qa_from_overlay requested"); tauri::async_runtime::spawn(async move { if let Err(error) = coordinator.finalize_qa_from_overlay().await { log::warn!("[android-native] finalize_qa_from_overlay failed: {error}"); diff --git a/openless-all/app/src-tauri/src/android_overlay.rs b/openless-all/app/src-tauri/src/android_overlay.rs index fc7cf7d6..c4fc057c 100644 --- a/openless-all/app/src-tauri/src/android_overlay.rs +++ b/openless-all/app/src-tauri/src/android_overlay.rs @@ -66,6 +66,17 @@ pub fn hide_android_overlay() -> Result<(), String> { } } +pub fn refresh_android_overlay_if_visible() -> Result<(), String> { + #[cfg(target_os = "android")] + { + return crate::android_native_bridge::refresh_overlay_if_visible(); + } + #[cfg(not(target_os = "android"))] + { + Err("Android overlay is only available on Android".to_string()) + } +} + #[cfg(target_os = "android")] mod android_impl { use super::{AndroidOverlayPermissionResult, AndroidOverlayStatus}; diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index aedac67c..7ed871dd 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -260,7 +260,10 @@ fn set_settings_common( persist_settings(coord, prefs.clone())?; let _ = app.emit("prefs:changed", &prefs); #[cfg(target_os = "android")] - coord.apply_android_overlay_trigger(); + { + coord.apply_android_overlay_trigger(); + coord.apply_android_overlay_size(); + } Ok(prefs) } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 7976ddcc..0858ea0b 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -66,6 +66,17 @@ mod dictation; mod qa; mod resources; +pub(super) fn qa_event_target() -> &'static str { + #[cfg(target_os = "android")] + { + "main" + } + #[cfg(not(target_os = "android"))] + { + "qa" + } +} + #[cfg(test)] use dictation::dictation_error_code; use dictation::{ @@ -517,6 +528,13 @@ impl Coordinator { } } + pub fn apply_android_overlay_size(&self) { + #[cfg(target_os = "android")] + { + let _ = crate::android_overlay::refresh_android_overlay_if_visible(); + } + } + /// 让所有 hotkey supervisor loop(dictation / qa / combo / translation / /// switch_style / open_app)在下一轮 sleep / poll 后退出。生产场景下进程退出 /// 一并 reap 所有线程,但 integration test 和未来 RunEvent::Exit 钩子需要 @@ -1048,11 +1066,13 @@ impl Coordinator { } pub async fn open_qa_from_overlay(&self) -> Result<(), String> { + log::info!("[coord] overlay QA open requested"); open_qa_panel(&self.inner); begin_qa_session(&self.inner).await } pub async fn finalize_qa_from_overlay(&self) -> Result<(), String> { + log::info!("[coord] overlay QA finalize requested"); finalize_dictation_as_qa_question(&self.inner).await } @@ -3652,6 +3672,7 @@ fn enabled_hotwords(inner: &Arc) -> Vec { // ─────────────────────────── QA session lifecycle ─────────────────────────── async fn finalize_dictation_as_qa_question(inner: &Arc) -> Result<(), String> { + log::info!("[coord] QA finalize from overlay: opening panel and waiting for ASR result"); open_qa_panel(inner); { let mut state = inner.qa_state.lock(); @@ -3670,7 +3691,7 @@ async fn finalize_dictation_as_qa_question(inner: &Arc) -> Result<(), Str if let Some(app) = inner.app.lock().clone() { let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( - "qa", + qa_event_target(), "qa:state", serde_json::json!({ "kind": "loading", @@ -3950,7 +3971,7 @@ async fn answer_qa_question_text( if let Some(app) = inner.app.lock().clone() { let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( - "qa", + qa_event_target(), "qa:state", serde_json::json!({ "kind": "thinking", @@ -3980,7 +4001,7 @@ async fn answer_qa_question_text( } if let Some(app) = inner_for_delta.app.lock().clone() { let _ = app.emit_to( - "qa", + qa_event_target(), "qa:state", serde_json::json!({ "kind": "answer_delta", @@ -4031,7 +4052,7 @@ async fn answer_qa_question_text( if let Some(app) = inner.app.lock().clone() { let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( - "qa", + qa_event_target(), "qa:state", serde_json::json!({ "kind": "answer", @@ -4139,7 +4160,7 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { if let Some(app) = inner.app.lock().clone() { let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( - "qa", + qa_event_target(), "qa:state", serde_json::json!({ "kind": "recording", @@ -4176,7 +4197,7 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { *inner.qa_asr.lock() = Some(qa_asr.active_asr()); // QA recorder 不需要 RMS 节流到胶囊;前端 QA 浮窗有自己的电平视图, - // 这里发一份事件给 "qa" label 用就够了。 + // Android 的 QA 面板嵌在 main WebView;桌面端仍发给独立 qa 窗口。 let inner_for_level = Arc::clone(inner); let last_emit_at = Arc::new(Mutex::new(None::)); const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33; @@ -4196,7 +4217,11 @@ async fn begin_qa_session(inner: &Arc) -> Result<(), String> { *last = Some(now); } if let Some(app) = inner_for_level.app.lock().clone() { - let _ = app.emit_to("qa", "qa:level", serde_json::json!({ "level": level })); + let _ = app.emit_to( + qa_event_target(), + "qa:level", + serde_json::json!({ "level": level }), + ); } // 同步把电平推给底部胶囊,让 QA 录音也有跟主听写一致的可视反馈。 emit_capsule( @@ -4278,7 +4303,11 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { emit_capsule(inner, CapsuleState::Transcribing, 0.0, 0, None, None); if let Some(app) = inner.app.lock().clone() { - let _ = app.emit_to("qa", "qa:state", serde_json::json!({ "kind": "loading" })); + let _ = app.emit_to( + qa_event_target(), + "qa:state", + serde_json::json!({ "kind": "loading" }), + ); } stop_qa_recorder(inner); @@ -4515,7 +4544,7 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { if let Some(app) = inner.app.lock().clone() { let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( - "qa", + qa_event_target(), "qa:state", serde_json::json!({ "kind": "thinking", @@ -4552,7 +4581,7 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { } if let Some(app) = inner_for_delta.app.lock().clone() { let _ = app.emit_to( - "qa", + qa_event_target(), "qa:state", serde_json::json!({ "kind": "answer_delta", @@ -4609,7 +4638,7 @@ async fn end_qa_session(inner: &Arc) -> Result<(), String> { if let Some(app) = inner.app.lock().clone() { let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( - "qa", + qa_event_target(), "qa:state", serde_json::json!({ "kind": "answer", @@ -4666,7 +4695,7 @@ fn finish_qa_with_error(inner: &Arc, message: String) { if let Some(app) = inner.app.lock().clone() { let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( - "qa", + qa_event_target(), "qa:state", serde_json::json!({ "kind": "error", @@ -4688,7 +4717,7 @@ fn finish_qa_idle_silently(inner: &Arc) { if let Some(app) = inner.app.lock().clone() { let messages = inner.qa_state.lock().messages.clone(); let _ = app.emit_to( - "qa", + qa_event_target(), "qa:state", serde_json::json!({ "kind": "idle", diff --git a/openless-all/app/src-tauri/src/coordinator/qa.rs b/openless-all/app/src-tauri/src/coordinator/qa.rs index 69614742..b0e2a445 100644 --- a/openless-all/app/src-tauri/src/coordinator/qa.rs +++ b/openless-all/app/src-tauri/src/coordinator/qa.rs @@ -8,7 +8,7 @@ use crate::types::CapsuleState; use super::{ begin_qa_session, cancel_qa_session, capture_focus_target, capture_frontmost_app, emit_capsule, - end_qa_session, Inner, + end_qa_session, qa_event_target, Inner, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -108,7 +108,7 @@ pub(super) fn open_qa_panel(inner: &Arc) { if let Some(app) = inner.app.lock().clone() { crate::show_qa_window(&app, "idle"); let _ = app.emit_to( - "qa", + qa_event_target(), "qa:state", serde_json::json!({ "kind": "idle", diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index d05ce7f6..0657e7e0 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -1418,6 +1418,7 @@ fn position_qa_window(window: &tauri::WebviewWindow) -> ta pub(crate) fn show_qa_window(app: &AppHandle, content_kind: &str) { #[cfg(target_os = "android")] { + log::info!("[qa] android emit qa:state to main kind={content_kind}"); let _ = app.emit_to( "main", "qa:state", diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 21e9b461..b5af003e 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -761,6 +761,9 @@ pub struct UserPreferences { /// Android: action performed by left swiping while the overlay is armed. #[serde(default = "default_android_overlay_left_swipe_action")] pub android_overlay_left_swipe_action: AndroidOverlayLeftSwipeAction, + /// Android: floating overlay control diameter in dp. + #[serde(default = "default_android_overlay_size_dp")] + pub android_overlay_size_dp: u32, } fn default_local_asr_model() -> String { @@ -918,6 +921,8 @@ struct UserPreferencesWire { android_overlay_activation_mode: AndroidOverlayActivationMode, #[serde(default = "default_android_overlay_left_swipe_action")] android_overlay_left_swipe_action: AndroidOverlayLeftSwipeAction, + #[serde(default = "default_android_overlay_size_dp")] + android_overlay_size_dp: u32, } impl Default for UserPreferencesWire { @@ -989,6 +994,7 @@ impl Default for UserPreferencesWire { android_overlay_trigger: prefs.android_overlay_trigger, android_overlay_activation_mode: prefs.android_overlay_activation_mode, android_overlay_left_swipe_action: prefs.android_overlay_left_swipe_action, + android_overlay_size_dp: prefs.android_overlay_size_dp, } } } @@ -1091,6 +1097,7 @@ impl<'de> Deserialize<'de> for UserPreferences { android_overlay_trigger: wire.android_overlay_trigger.normalized(), android_overlay_activation_mode: wire.android_overlay_activation_mode, android_overlay_left_swipe_action: wire.android_overlay_left_swipe_action, + android_overlay_size_dp: normalize_android_overlay_size_dp(wire.android_overlay_size_dp), }) } } @@ -1824,6 +1831,7 @@ impl Default for UserPreferences { android_overlay_trigger: default_android_overlay_trigger(), android_overlay_activation_mode: default_android_overlay_activation_mode(), android_overlay_left_swipe_action: default_android_overlay_left_swipe_action(), + android_overlay_size_dp: default_android_overlay_size_dp(), } } } @@ -2391,6 +2399,14 @@ fn default_android_overlay_left_swipe_action() -> AndroidOverlayLeftSwipeAction AndroidOverlayLeftSwipeAction::Translation } +fn default_android_overlay_size_dp() -> u32 { + 72 +} + +fn normalize_android_overlay_size_dp(size_dp: u32) -> u32 { + size_dp.clamp(48, 120) +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum AndroidOverlayPermissionState { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index f8fab1eb..9552f263 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -830,6 +830,8 @@ export const en: typeof zhCN = { androidOverlayTriggerLabel: 'Overlay visibility', androidOverlayActivationModeLabel: 'Overlay activation', androidOverlayLeftSwipeActionLabel: 'Left swipe action', + androidOverlaySizeLabel: 'Overlay size', + androidOverlaySizeHint: 'Adjusts the floating button diameter and keeps its current position.', androidInsertStrategy: { accessibility: 'Auto output to input field', clipboard: 'Clipboard only', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index c9077e7c..eaffbee9 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -832,6 +832,8 @@ export const ja: typeof zhCN = { androidOverlayTriggerLabel: '表示タイミング', androidOverlayActivationModeLabel: '起動方法', androidOverlayLeftSwipeActionLabel: '左スワイプ動作', + androidOverlaySizeLabel: 'オーバーレイサイズ', + androidOverlaySizeHint: 'フローティングボタンの直径を調整し、現在位置を保持します。', androidInsertStrategy: { accessibility: '入力欄へ自動出力', clipboard: 'クリップボードのみ' }, androidInsertStrategyHint: { accessibility: 'アクセシビリティが必要です。使えない場合はクリップボードにコピーします。', clipboard: 'アクセシビリティ権限は不要です。コピー後に手動で貼り付けます。' }, androidOverlayTrigger: { background: 'バックグラウンド', keyboard: 'キーボード表示時', always: '常時' }, diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index d642d492..6dab5855 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -832,6 +832,8 @@ export const ko: typeof zhCN = { androidOverlayTriggerLabel: '오버레이 표시', androidOverlayActivationModeLabel: '오버레이 활성화', androidOverlayLeftSwipeActionLabel: '왼쪽 스와이프 동작', + androidOverlaySizeLabel: '오버레이 크기', + androidOverlaySizeHint: '플로팅 버튼 지름을 조정하고 현재 위치를 유지합니다.', androidInsertStrategy: { accessibility: '입력칸에 자동 출력', clipboard: '클립보드만' }, androidInsertStrategyHint: { accessibility: '접근성 서비스가 필요합니다. 사용할 수 없으면 클립보드에 복사합니다.', clipboard: '접근성 권한이 필요 없으며 직접 붙여넣습니다.' }, androidOverlayTrigger: { background: '백그라운드', keyboard: '키보드 표시 시', always: '항상' }, diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 4934e33d..bb0f87fa 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -828,6 +828,8 @@ export const zhCN = { androidOverlayTriggerLabel: '悬浮窗显示时机', androidOverlayActivationModeLabel: '悬浮窗激活方式', androidOverlayLeftSwipeActionLabel: '左滑动作', + androidOverlaySizeLabel: '悬浮窗大小', + androidOverlaySizeHint: '调整悬浮按钮直径,保存后在当前悬浮窗上生效并保留位置。', androidInsertStrategy: { accessibility: '自动输出到输入框', clipboard: '仅剪贴板', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 590b4384..136b5739 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -830,6 +830,8 @@ export const zhTW: typeof zhCN = { androidOverlayTriggerLabel: '懸浮窗顯示時機', androidOverlayActivationModeLabel: '懸浮窗啟用方式', androidOverlayLeftSwipeActionLabel: '左滑動作', + androidOverlaySizeLabel: '懸浮窗大小', + androidOverlaySizeHint: '調整懸浮按鈕直徑,儲存後在目前懸浮窗上生效並保留位置。', androidInsertStrategy: { accessibility: '自動輸出到輸入框', clipboard: '僅剪貼簿' }, androidInsertStrategyHint: { accessibility: '需要開啟無障礙服務;不可用時會複製到剪貼簿。', clipboard: '不需要無障礙權限,只複製到剪貼簿,由你手動貼上。' }, androidOverlayTrigger: { background: '退到背景', keyboard: '鍵盤彈出時', always: '常駐' }, diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 4325b372..5a6dfea8 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -223,6 +223,7 @@ let mockSettings: UserPreferences = { androidOverlayTrigger: "background", androidOverlayActivationMode: "tap", androidOverlayLeftSwipeAction: "translation", + androidOverlaySizeDp: 72, } const mockFullStylePrompts: StyleSystemPrompts = { diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 531fb5e5..3221df6a 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -84,6 +84,7 @@ const previousPrefs: UserPreferences = { androidOverlayTrigger: 'background', androidOverlayActivationMode: 'tap', androidOverlayLeftSwipeAction: 'translation', + androidOverlaySizeDp: 72, }; const nextPrefs: UserPreferences = { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 50da9ce3..e1a7eaf3 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -346,6 +346,8 @@ export interface UserPreferences { androidOverlayActivationMode: AndroidOverlayActivationMode; /** Android: action performed by left swiping while the overlay is armed. */ androidOverlayLeftSwipeAction: AndroidOverlayLeftSwipeAction; + /** Android: floating overlay control diameter in dp. */ + androidOverlaySizeDp: number; } export type AndroidInsertStrategy = 'accessibility' | 'clipboard'; diff --git a/openless-all/app/src/pages/settings/PermissionsSection.tsx b/openless-all/app/src/pages/settings/PermissionsSection.tsx index 767bec0c..cdaacf44 100644 --- a/openless-all/app/src/pages/settings/PermissionsSection.tsx +++ b/openless-all/app/src/pages/settings/PermissionsSection.tsx @@ -79,6 +79,7 @@ export function PermissionsSection() { androidOverlayTrigger: migratedSettings.androidOverlayTrigger, androidOverlayActivationMode: migratedSettings.androidOverlayActivationMode, androidOverlayLeftSwipeAction: migratedSettings.androidOverlayLeftSwipeAction, + androidOverlaySizeDp: migratedSettings.androidOverlaySizeDp, }); }; @@ -187,6 +188,7 @@ export function PermissionsSection() { androidOverlayTrigger: next.androidOverlayTrigger, androidOverlayActivationMode: next.androidOverlayActivationMode, androidOverlayLeftSwipeAction: next.androidOverlayLeftSwipeAction, + androidOverlaySizeDp: next.androidOverlaySizeDp, }); await refreshAndroid(); }; @@ -333,6 +335,29 @@ export function PermissionsSection() {
+ +
+
+ { + void updateAndroidPref('androidOverlaySizeDp', clampAndroidOverlaySize(Number(event.target.value))); + }} + style={{ width: 132 }} + /> + + {androidPrefs?.androidOverlaySizeDp ?? 72} dp + +
+ + {t('settings.permissions.androidOverlaySizeHint')} + +
+
)} {windowsIme?.state !== 'notWindows' && platformCaps?.platform !== 'android' && ( @@ -374,12 +399,18 @@ type AndroidPreferenceKey = | 'androidInsertStrategy' | 'androidOverlayTrigger' | 'androidOverlayActivationMode' - | 'androidOverlayLeftSwipeAction'; + | 'androidOverlayLeftSwipeAction' + | 'androidOverlaySizeDp'; function normalizeAndroidOverlayTrigger(trigger: AndroidOverlayTrigger): AndroidOverlayTrigger { return trigger === 'keyboard' ? 'background' : trigger; } +function clampAndroidOverlaySize(size: number): number { + if (!Number.isFinite(size)) return 72; + return Math.min(120, Math.max(48, Math.round(size / 4) * 4)); +} + function PermissionPill({ status }: { status: PermissionStatus | 'loading' }) { const { t } = useTranslation(); if (status === 'loading') { From 68be0eb38f07c65d23ae2bb8520f7eaa50ea59b5 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 21:39:44 +0800 Subject: [PATCH 72/83] Fix Android overlay QA foreground panel --- openless-all/app/src-tauri/src/android_jni.rs | 11 ++++++++++- openless-all/app/src-tauri/src/coordinator.rs | 6 ++++++ openless-all/app/src-tauri/src/lib.rs | 16 ++++++++++++++++ openless-all/app/src-tauri/src/types.rs | 4 +++- openless-all/app/src/App.tsx | 2 ++ 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/android_jni.rs b/openless-all/app/src-tauri/src/android_jni.rs index f028ab47..23649f0c 100644 --- a/openless-all/app/src-tauri/src/android_jni.rs +++ b/openless-all/app/src-tauri/src/android_jni.rs @@ -105,6 +105,15 @@ pub mod android { env: &mut JNIEnv, context: &JObject, class_name: &str, + ) -> Result<(), String> { + start_activity_class_with_flags(env, context, class_name, 0x10000000) + } + + pub fn start_activity_class_with_flags( + env: &mut JNIEnv, + context: &JObject, + class_name: &str, + flags: i32, ) -> Result<(), String> { let intent = env .new_object("android/content/Intent", "()V", &[]) @@ -128,7 +137,7 @@ pub mod android { &intent, "addFlags", "(I)Landroid/content/Intent;", - &[JValue::Int(0x10000000)], + &[JValue::Int(flags)], ) .map_err(|error| format!("set intent flags: {error}"))?; env.call_method( diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 0858ea0b..aca54c7f 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -3704,6 +3704,7 @@ async fn finalize_dictation_as_qa_question(inner: &Arc) -> Result<(), Str let raw = match take_current_dictation_transcript_for_qa(inner).await { Ok(Some(raw)) => raw, Ok(None) => { + log::info!("[coord] QA finalize from overlay: no transcript produced"); finish_qa_idle_silently(inner); return Ok(()); } @@ -3712,6 +3713,11 @@ async fn finalize_dictation_as_qa_question(inner: &Arc) -> Result<(), Str return Err(error); } }; + log::info!( + "[coord] QA finalize from overlay: transcript ready chars={} duration_ms={}", + raw.text.chars().count(), + raw.duration_ms + ); answer_qa_question_text(inner, raw.text.trim().to_string(), raw.duration_ms).await } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 0657e7e0..7cd9a873 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -1418,6 +1418,22 @@ fn position_qa_window(window: &tauri::WebviewWindow) -> ta pub(crate) fn show_qa_window(app: &AppHandle, content_kind: &str) { #[cfg(target_os = "android")] { + const FLAG_ACTIVITY_NEW_TASK: i32 = 0x10000000; + const FLAG_ACTIVITY_REORDER_TO_FRONT: i32 = 0x00020000; + const FLAG_ACTIVITY_SINGLE_TOP: i32 = 0x20000000; + let flags = + FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_REORDER_TO_FRONT | FLAG_ACTIVITY_SINGLE_TOP; + match crate::android_jni::android::with_android_env(|env, context| { + crate::android_jni::android::start_activity_class_with_flags( + env, + context, + "com.openless.app.MainActivity", + flags, + ) + }) { + Ok(()) => log::info!("[qa] android requested MainActivity foreground for QA"), + Err(error) => log::warn!("[qa] android failed to foreground MainActivity: {error}"), + } log::info!("[qa] android emit qa:state to main kind={content_kind}"); let _ = app.emit_to( "main", diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index b5af003e..00563c9f 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1097,7 +1097,9 @@ impl<'de> Deserialize<'de> for UserPreferences { android_overlay_trigger: wire.android_overlay_trigger.normalized(), android_overlay_activation_mode: wire.android_overlay_activation_mode, android_overlay_left_swipe_action: wire.android_overlay_left_swipe_action, - android_overlay_size_dp: normalize_android_overlay_size_dp(wire.android_overlay_size_dp), + android_overlay_size_dp: normalize_android_overlay_size_dp( + wire.android_overlay_size_dp, + ), }) } } diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index bc0d0fc6..0754967a 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -78,9 +78,11 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force try { const { listen } = await import('@tauri-apps/api/event'); const stateHandle = await listen('qa:state', () => { + console.info('[qa] android qa:state received; opening embedded panel'); setMobileQaOpen(true); }); const dismissHandle = await listen('qa:dismiss', () => { + console.info('[qa] android qa:dismiss received; closing embedded panel'); setMobileQaOpen(false); }); if (cancelled) { From 30c9c58d8bbfa9a74b7bd9971c304e28eb779a7d Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 21:44:28 +0800 Subject: [PATCH 73/83] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20.gitignore=20?= =?UTF-8?q?=E4=BB=A5=E5=BF=BD=E7=95=A5=20Android=20=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E5=92=8C=E8=AF=8A=E6=96=AD=E6=96=87=E4=BB=B6=EF=BC=9B=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20android=5Finsert=20=E5=92=8C=20persistence=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E4=B8=AD=E7=9A=84=E9=94=99=E8=AF=AF=E5=A4=84?= =?UTF-8?q?=E7=90=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 13 ++- .../app/src-tauri/src/android_insert.rs | 5 +- openless-all/app/src-tauri/src/persistence.rs | 100 +++++++++--------- 3 files changed, 64 insertions(+), 54 deletions(-) diff --git a/.gitignore b/.gitignore index c379c169..e61d5498 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ Package.resolved xcuserdata/ DerivedData/ ChatGPT Image*.jpg - +.cursor # Local config / secrets *.env config.local.json @@ -38,10 +38,19 @@ ci-artifacts/ *.apk # Android 真机调试产物(日志 / 截图 / adb 导出 / 数据备份 / 临时还原目录) +diagnostics/ +android-logs/ +test-captures/ +extracted/ android-crash*.log android-run*.log -openless-package.txt +qa-*.log +qa-*.png +openless-package*.txt +openless-screenshot*.png openless-android-run*.png +/openless_*.png +/run*.png openless-appdata*.tar openless-run*-ui.xml tmp-run*/ diff --git a/openless-all/app/src-tauri/src/android_insert.rs b/openless-all/app/src-tauri/src/android_insert.rs index 38b1fd86..6eebf080 100644 --- a/openless-all/app/src-tauri/src/android_insert.rs +++ b/openless-all/app/src-tauri/src/android_insert.rs @@ -17,8 +17,9 @@ pub fn android_insert_with_strategy( AndroidInsertStrategy::Clipboard => clipboard_fallback(inserter, text), AndroidInsertStrategy::Accessibility | AndroidInsertStrategy::Auto - | AndroidInsertStrategy::Ime => try_accessibility(inserter, text) - .unwrap_or_else(|| clipboard_fallback(inserter, text)), + | AndroidInsertStrategy::Ime => { + try_accessibility(inserter, text).unwrap_or_else(|| clipboard_fallback(inserter, text)) + } } } diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index c20925c4..386f18c1 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -681,8 +681,8 @@ fn load_android_credentials() -> Result> { let decoded = base64::engine::general_purpose::STANDARD .decode(bytes) .context("decode android credentials envelope")?; - let root = serde_json::from_slice::(&decoded) - .context("parse android credentials json")?; + let root = + serde_json::from_slice::(&decoded).context("parse android credentials json")?; Ok(Some(root)) } @@ -1024,59 +1024,59 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { #[cfg(not(target_os = "android"))] { - let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; - let previous_manifest = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT) - .ok() - .flatten() - .and_then(|value| read_chunk_manifest(&value)); - let chunks = chunk_json_payload(&json); - - // 先写所有 chunks(稳定名),再写 manifest —— 保证 partial-write 不会让 - // manifest 指向不完整 chunks。stable name 让 macOS Keychain ACL 一次允许后 - // 长期有效,不再因 UUID 轮换反复弹窗(这是 PR #277 早期 UUID-rotation - // 设计的回退)。 - for (index, chunk) in chunks.iter().enumerate() { - let account = chunk_account(None, index); - keyring_entry_for(&account)? - .set_password(chunk) - .with_context(|| format!("write system credential vault chunk {index}"))?; - } - - let manifest = CredsChunkManifest { - openless_credentials_storage: "chunked".to_string(), - version: 1, - generation: None, - chunks: chunks.len(), - }; - let manifest_json = - serde_json::to_string(&manifest).context("encode credential manifest failed")?; - keyring_entry()? - .set_password(&manifest_json) - .context("write system credential vault manifest")?; - - // 清理旧 chunks: - // 1) 旧 manifest 用 UUID generation → 那一代 chunks 全删(迁移到 stable name) - // 2) 旧 manifest 也是 stable name,但 chunks 数量比这次多 → 删多余的 idx - if let Some(previous) = previous_manifest { - match previous.generation.as_deref() { - Some(prev_gen) => { - for index in 0..previous.chunks { - delete_keyring_password(&chunk_account(Some(prev_gen), index)); + let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; + let previous_manifest = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT) + .ok() + .flatten() + .and_then(|value| read_chunk_manifest(&value)); + let chunks = chunk_json_payload(&json); + + // 先写所有 chunks(稳定名),再写 manifest —— 保证 partial-write 不会让 + // manifest 指向不完整 chunks。stable name 让 macOS Keychain ACL 一次允许后 + // 长期有效,不再因 UUID 轮换反复弹窗(这是 PR #277 早期 UUID-rotation + // 设计的回退)。 + for (index, chunk) in chunks.iter().enumerate() { + let account = chunk_account(None, index); + keyring_entry_for(&account)? + .set_password(chunk) + .with_context(|| format!("write system credential vault chunk {index}"))?; + } + + let manifest = CredsChunkManifest { + openless_credentials_storage: "chunked".to_string(), + version: 1, + generation: None, + chunks: chunks.len(), + }; + let manifest_json = + serde_json::to_string(&manifest).context("encode credential manifest failed")?; + keyring_entry()? + .set_password(&manifest_json) + .context("write system credential vault manifest")?; + + // 清理旧 chunks: + // 1) 旧 manifest 用 UUID generation → 那一代 chunks 全删(迁移到 stable name) + // 2) 旧 manifest 也是 stable name,但 chunks 数量比这次多 → 删多余的 idx + if let Some(previous) = previous_manifest { + match previous.generation.as_deref() { + Some(prev_gen) => { + for index in 0..previous.chunks { + delete_keyring_password(&chunk_account(Some(prev_gen), index)); + } } - } - None => { - for index in chunks.len()..previous.chunks { - delete_keyring_password(&chunk_account(None, index)); + None => { + for index in chunks.len()..previous.chunks { + delete_keyring_password(&chunk_account(None, index)); + } } } } - } - remove_legacy_credentials_file_best_effort(); - // 写完成功后立刻刷新 process cache —— 同进程后续读不再回 Keychain。 - // 见 CREDENTIALS_CACHE 的 doc。 - store_credentials_cache(&cleaned); - Ok(()) + remove_legacy_credentials_file_best_effort(); + // 写完成功后立刻刷新 process cache —— 同进程后续读不再回 Keychain。 + // 见 CREDENTIALS_CACHE 的 doc。 + store_credentials_cache(&cleaned); + Ok(()) } } From 8ece2349f5eb2bad9e6d0de08cfd96d4dd6d5095 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 21:56:21 +0800 Subject: [PATCH 74/83] ci: add workflow_dispatch for manual branch builds Allow triggering the cross-platform CI workflow on feature branches such as openless-android via GitHub CLI without expanding push triggers. Co-authored-by: Cursor --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf38b54f..1358fc74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ on: branches: [main, beta] pull_request: branches: [main, beta] + workflow_dispatch: jobs: cross-platform: From bea0ba12292aa6047bf9bbf95fba6a613c17c986 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 22:03:51 +0800 Subject: [PATCH 75/83] fix(ci): restore macos-private-api in base tauri features tauri_build validates [dependencies] against tauri.conf.json macOSPrivateApi; target-specific feature overrides do not satisfy the check on desktop CI. Co-authored-by: Cursor --- openless-all/app/src-tauri/Cargo.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index a379708e..85734a84 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -18,7 +18,9 @@ cc = "1.1" [dependencies] # 锁 ~2.11 因为 npm @tauri-apps/api 与 plugin-dialog 都已升 2.11; # tauri build 跨 minor 一致性检查会拒绝 npm 2.11 + Rust 2.10 的组合。 -tauri = { version = "~2.11", features = [] } +# macos-private-api must live in [dependencies] so tauri_build can match tauri.conf.json +# macOSPrivateApi; tray-icon stays desktop-only in the target table below. +tauri = { version = "~2.11", features = ["macos-private-api"] } tauri-plugin-shell = "2" tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } From 3e02c0035063d9820c9295245cb830a8b1241fb8 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 10 Jun 2026 22:11:20 +0800 Subject: [PATCH 76/83] fix(ci): expose windows_ime_profile stub and dedupe clipboard delay Compile windows_ime_profile on all desktop targets so get_windows_ime_status can call its non-Windows stub. Remove duplicate CLIPBOARD_RESTORE_DELAY cfg on Windows. Co-authored-by: Cursor --- openless-all/app/src-tauri/src/insertion.rs | 3 --- openless-all/app/src-tauri/src/lib.rs | 1 - 2 files changed, 4 deletions(-) diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index d6e1030c..e4be3e35 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -17,9 +17,6 @@ use parking_lot::Mutex; use crate::types::{InsertStatus, PasteShortcut}; -#[cfg(target_os = "windows")] -const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750); - #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 7cd9a873..6534f896 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -79,7 +79,6 @@ mod unicode_keystroke; mod unicode_keystroke; #[cfg(target_os = "windows")] mod windows_ime_ipc; -#[cfg(target_os = "windows")] mod windows_ime_profile; #[cfg(target_os = "windows")] mod windows_ime_protocol; From 905b8516add2a5a90757abba17b86f205d5494eb Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Thu, 11 Jun 2026 00:18:22 +0800 Subject: [PATCH 77/83] refactor(android): modularize platform code into android/ tree Consolidate Rust android_* modules, Kotlin scaffolding, and frontend Android UI under openless-all/app/android/ with crate::android and @android imports to improve maintainability without changing runtime behavior. Co-authored-by: Cursor --- openless-all/app/android/README.md | 65 ++ .../components/AndroidPermissionsPanel.tsx | 234 ++++++ .../app/android/frontend/lib/androidIpc.ts | 43 ++ .../lib/androidMicrophonePermission.ts | 94 +++ .../app/android/frontend/lib/androidTypes.ts | 36 + .../OpenLessAccessibilityCommandReceiver.kt | 21 + .../kotlin/OpenLessAccessibilityService.kt | 252 +++++++ .../kotlin/OpenLessAndroidPreferences.kt | 99 +++ .../app/android/kotlin/OpenLessAppContext.kt | 13 + .../app/android/kotlin/OpenLessApplication.kt | 84 +++ .../app/android/kotlin/OpenLessNative.kt | 42 ++ .../android/kotlin/OpenLessOverlayBridge.kt | 33 + .../android/kotlin/OpenLessOverlayService.kt | 699 ++++++++++++++++++ .../kotlin/OverlayPermissionActivity.kt | 27 + openless-all/app/android/kotlin/README.md | 27 + .../manifests/AndroidManifest.v1.snippet.xml | 7 + .../manifests/AndroidManifest.v3.snippet.xml | 20 + .../res/xml/openless_accessibility_config.xml | 9 + .../manifests/res/xml/openless_ime_method.xml | 9 + .../app/scripts/copy-android-scaffolding.mjs | 7 +- .../app/scripts/merge-android-v1-manifest.mjs | 4 +- .../accessibility.rs} | 20 +- .../{android_insert.rs => android/insert.rs} | 11 +- .../src/{android_jni.rs => android/jni.rs} | 0 openless-all/app/src-tauri/src/android/mod.rs | 24 + .../native_bridge.rs} | 20 +- .../overlay.rs} | 22 +- .../app/src-tauri/src/android/types.rs | 108 +++ openless-all/app/src-tauri/src/commands.rs | 16 +- openless-all/app/src-tauri/src/coordinator.rs | 8 +- .../src-tauri/src/coordinator/dictation.rs | 2 +- openless-all/app/src-tauri/src/insertion.rs | 4 +- openless-all/app/src-tauri/src/lib.rs | 13 +- .../app/src-tauri/src/mobile_runtime.rs | 2 +- .../src-tauri/src/mobile_stubs/selection.rs | 4 +- openless-all/app/src-tauri/src/types.rs | 117 +-- .../src/lib/androidMicrophonePermission.ts | 95 +-- openless-all/app/src/lib/ipc.ts | 49 +- openless-all/app/src/lib/types.ts | 35 +- .../src/pages/settings/PermissionsSection.tsx | 232 +----- openless-all/app/tsconfig.json | 8 +- openless-all/app/vite.config.ts | 9 + 42 files changed, 2067 insertions(+), 557 deletions(-) create mode 100644 openless-all/app/android/README.md create mode 100644 openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx create mode 100644 openless-all/app/android/frontend/lib/androidIpc.ts create mode 100644 openless-all/app/android/frontend/lib/androidMicrophonePermission.ts create mode 100644 openless-all/app/android/frontend/lib/androidTypes.ts create mode 100644 openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt create mode 100644 openless-all/app/android/kotlin/OpenLessAccessibilityService.kt create mode 100644 openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt create mode 100644 openless-all/app/android/kotlin/OpenLessAppContext.kt create mode 100644 openless-all/app/android/kotlin/OpenLessApplication.kt create mode 100644 openless-all/app/android/kotlin/OpenLessNative.kt create mode 100644 openless-all/app/android/kotlin/OpenLessOverlayBridge.kt create mode 100644 openless-all/app/android/kotlin/OpenLessOverlayService.kt create mode 100644 openless-all/app/android/kotlin/OverlayPermissionActivity.kt create mode 100644 openless-all/app/android/kotlin/README.md create mode 100644 openless-all/app/android/manifests/AndroidManifest.v1.snippet.xml create mode 100644 openless-all/app/android/manifests/AndroidManifest.v3.snippet.xml create mode 100644 openless-all/app/android/manifests/res/xml/openless_accessibility_config.xml create mode 100644 openless-all/app/android/manifests/res/xml/openless_ime_method.xml rename openless-all/app/src-tauri/src/{android_accessibility.rs => android/accessibility.rs} (80%) rename openless-all/app/src-tauri/src/{android_insert.rs => android/insert.rs} (76%) rename openless-all/app/src-tauri/src/{android_jni.rs => android/jni.rs} (100%) create mode 100644 openless-all/app/src-tauri/src/android/mod.rs rename openless-all/app/src-tauri/src/{android_native_bridge.rs => android/native_bridge.rs} (93%) rename openless-all/app/src-tauri/src/{android_overlay.rs => android/overlay.rs} (79%) create mode 100644 openless-all/app/src-tauri/src/android/types.rs diff --git a/openless-all/app/android/README.md b/openless-all/app/android/README.md new file mode 100644 index 00000000..68f17097 --- /dev/null +++ b/openless-all/app/android/README.md @@ -0,0 +1,65 @@ +# OpenLess Android 平台代码 + +Android 相关 Rust、Kotlin 与前端代码的统一入口。桌面端通过 `#[cfg(not(mobile))]` 分层,不受影响。 + +## 目录结构 + +```text +android/ +├── kotlin/ # Kotlin 模板(CI 复制到 gen/android/) +├── manifests/ # AndroidManifest snippet + res/xml +└── frontend/ # React 模块(Vite 别名 @android) + +src-tauri/src/android/ # Rust 运行时模块(crate::android) +``` + +## Rust(`src-tauri/src/android/`) + +| 模块 | 职责 | +|------|------| +| `jni.rs` | JNI 工具(clipboard、overlay service、accessibility) | +| `native_bridge.rs` | Kotlin ↔ Coordinator JNI 入口 | +| `overlay.rs` | 悬浮窗权限与 show/hide | +| `accessibility.rs` | 无障碍服务状态与 paste | +| `insert.rs` | 跨 App 文本插入策略 | +| `types.rs` | Android 偏好与状态类型 | + +主 crate 通过 `mod android;` 引入,常用 API 经 `crate::android::` 扁平 re-export。 + +## Kotlin(`android/kotlin/`) + +`tauri android init` 后由 [`scripts/copy-android-scaffolding.mjs`](../scripts/copy-android-scaffolding.mjs) 复制到 `src-tauri/gen/android/app/src/main/java/com/openless/app/`。 + +Manifest 合并脚本: + +- [`scripts/merge-android-v1-manifest.mjs`](../scripts/merge-android-v1-manifest.mjs) — 麦克风权限(`android/manifests/AndroidManifest.v1.snippet.xml`) +- [`scripts/merge-android-overlay-manifest.mjs`](../scripts/merge-android-overlay-manifest.mjs) — 悬浮窗 / 无障碍 + +## 前端(`android/frontend/`,别名 `@android`) + +| 路径 | 职责 | +|------|------| +| `lib/androidTypes.ts` | Android 偏好与状态 TS 类型 | +| `lib/androidIpc.ts` | overlay / accessibility Tauri invoke | +| `lib/androidMicrophonePermission.ts` | WebView 麦克风权限辅助 | +| `components/AndroidPermissionsPanel.tsx` | 设置页 Android 权限与 overlay 配置 | + +`src/lib/types.ts` 与 `src/lib/ipc.ts` 保留 re-export,现有 import 路径仍可用。 + +## 构建与 CI + +```bash +cd openless-all/app +npm run tauri:android:init # 生成 gen/android/ +npm run copy:android-scaffolding +node scripts/merge-android-v1-manifest.mjs +node scripts/merge-android-overlay-manifest.mjs +npm run tauri:android:build +``` + +CI: [`.github/workflows/android-apk.yml`](../../.github/workflows/android-apk.yml) + +## 相关文档 + +- [AGENTS.md](../../AGENTS.md) — 真机闪退排查 +- [docs/android-mobile-apk-overlay-plan.md](../../docs/android-mobile-apk-overlay-plan.md) — 分阶段产品计划 diff --git a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx new file mode 100644 index 00000000..1f4cc7fc --- /dev/null +++ b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx @@ -0,0 +1,234 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icon } from '../../../src/components/Icon'; +import { getSettings, setSettings } from '../../../src/lib/ipc'; +import type { UserPreferences } from '../../../src/lib/types'; +import { Btn, Pill } from '../../../src/pages/_atoms'; +import { SettingRow } from '../../../src/pages/settings/shared'; +import { + getAndroidAccessibilityStatus, + getAndroidOverlayStatus, + requestAndroidAccessibilityPermission, + requestAndroidOverlayPermission, +} from '../lib/androidIpc'; +import type { + AndroidAccessibilityStatus, + AndroidInsertStrategy, + AndroidOverlayActivationMode, + AndroidOverlayLeftSwipeAction, + AndroidOverlayStatus, + AndroidOverlayTrigger, + AndroidPreferenceKey, +} from '../lib/androidTypes'; +import { + clampAndroidOverlaySize, + normalizeAndroidOverlayTrigger, +} from '../lib/androidTypes'; + +export function AndroidPermissionsPanel() { + const { t } = useTranslation(); + const [androidOverlay, setAndroidOverlay] = useState(null); + const [androidAccessibility, setAndroidAccessibility] = useState(null); + const [androidPrefs, setAndroidPrefs] = useState | null>(null); + + const refreshAndroid = async () => { + const [overlay, accessibility, settings] = await Promise.all([ + getAndroidOverlayStatus(), + getAndroidAccessibilityStatus(), + getSettings(), + ]); + let migratedSettings = settings; + if (settings.androidOverlayTrigger === 'keyboard') { + migratedSettings = { + ...settings, + androidOverlayTrigger: normalizeAndroidOverlayTrigger(settings.androidOverlayTrigger), + }; + await setSettings(migratedSettings); + } + setAndroidOverlay(overlay); + setAndroidAccessibility(accessibility); + setAndroidPrefs({ + androidInsertStrategy: migratedSettings.androidInsertStrategy, + androidOverlayTrigger: migratedSettings.androidOverlayTrigger, + androidOverlayActivationMode: migratedSettings.androidOverlayActivationMode, + androidOverlayLeftSwipeAction: migratedSettings.androidOverlayLeftSwipeAction, + androidOverlaySizeDp: migratedSettings.androidOverlaySizeDp, + }); + }; + + useEffect(() => { + void refreshAndroid(); + const androidId = window.setInterval(refreshAndroid, 3000); + const onFocus = () => { void refreshAndroid(); }; + window.addEventListener('focus', onFocus); + return () => { + window.clearInterval(androidId); + window.removeEventListener('focus', onFocus); + }; + }, []); + + const updateAndroidPref = async (key: K, value: UserPreferences[K]) => { + const settings = await getSettings(); + const nextValue = key === 'androidOverlayTrigger' + ? normalizeAndroidOverlayTrigger(value as AndroidOverlayTrigger) + : value; + const next = { + ...settings, + [key]: nextValue, + }; + await setSettings(next); + setAndroidPrefs({ + androidInsertStrategy: next.androidInsertStrategy, + androidOverlayTrigger: next.androidOverlayTrigger, + androidOverlayActivationMode: next.androidOverlayActivationMode, + androidOverlayLeftSwipeAction: next.androidOverlayLeftSwipeAction, + androidOverlaySizeDp: next.androidOverlaySizeDp, + }); + await refreshAndroid(); + }; + + return ( + <> + +
+ {androidOverlay?.message && ( + + {androidOverlay.message} + + )} + + {androidOverlay?.permission !== 'granted' && ( + { void requestAndroidOverlayPermission().then(refreshAndroid); }}> + {t('settings.permissions.grant')} + + )} +
+
+ +
+
+ {androidAccessibility?.message && ( + + {androidAccessibility.message} + + )} + + {!androidAccessibility?.enabled && ( + { void requestAndroidAccessibilityPermission().then(refreshAndroid); }}> + {t('settings.permissions.openSystem')} + + )} +
+ + {t('settings.permissions.androidAccessibilityImpact')} + +
+
+ +
+ + + {t(`settings.permissions.androidInsertStrategyHint.${androidPrefs?.androidInsertStrategy ?? 'accessibility'}`)} + +
+
+ +
+ + + {t(`settings.permissions.androidOverlayTriggerHint.${androidPrefs?.androidOverlayTrigger ?? 'background'}`)} + + + {t('settings.permissions.androidOverlayTriggerDisabled.keyboard')} + +
+
+ +
+ + + {t(`settings.permissions.androidOverlayActivationModeHint.${androidPrefs?.androidOverlayActivationMode ?? 'tap'}`)} + +
+
+ +
+ + + {t(`settings.permissions.androidOverlayLeftSwipeActionHint.${androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}`)} + +
+
+ +
+
+ { + void updateAndroidPref('androidOverlaySizeDp', clampAndroidOverlaySize(Number(event.target.value))); + }} + style={{ width: 132 }} + /> + + {androidPrefs?.androidOverlaySizeDp ?? 72} dp + +
+ + {t('settings.permissions.androidOverlaySizeHint')} + +
+
+ + ); +} + +function AndroidOverlayStatusPill({ status }: { status: AndroidOverlayStatus | null }) { + const { t } = useTranslation(); + if (!status) return {t('settings.permissions.checking')}; + if (status.permission === 'granted') { + return {t('settings.permissions.granted')}; + } + return {t('settings.permissions.denied')}; +} + +function AndroidAccessibilityStatusPill({ status }: { status: AndroidAccessibilityStatus | null }) { + const { t } = useTranslation(); + if (!status) return {t('settings.permissions.checking')}; + if (status.enabled) { + return {t('settings.permissions.granted')}; + } + return {t('settings.permissions.denied')}; +} diff --git a/openless-all/app/android/frontend/lib/androidIpc.ts b/openless-all/app/android/frontend/lib/androidIpc.ts new file mode 100644 index 00000000..062407ab --- /dev/null +++ b/openless-all/app/android/frontend/lib/androidIpc.ts @@ -0,0 +1,43 @@ +import { invokeOrMock } from '../../../src/lib/ipc'; +import type { + AndroidAccessibilityStatus, + AndroidOverlayStatus, +} from './androidTypes'; + +export function getAndroidOverlayStatus(): Promise { + return invokeOrMock('get_android_overlay_status', undefined, () => ({ + permission: 'notAndroid', + overlayVisible: false, + message: 'Android overlay is only available on Android', + })); +} + +export function requestAndroidOverlayPermission(): Promise<{ launched: boolean; message: string }> { + return invokeOrMock('request_android_overlay_permission', undefined, () => ({ + launched: false, + message: 'Mock: overlay permission unavailable in browser preview', + })); +} + +export function showAndroidOverlay(): Promise { + return invokeOrMock('show_android_overlay', undefined, () => undefined); +} + +export function hideAndroidOverlay(): Promise { + return invokeOrMock('hide_android_overlay', undefined, () => undefined); +} + +export function getAndroidAccessibilityStatus(): Promise { + return invokeOrMock('get_android_accessibility_status', undefined, () => ({ + state: 'notAndroid', + enabled: false, + message: 'Android accessibility is only available on Android', + })); +} + +export function requestAndroidAccessibilityPermission(): Promise<{ launched: boolean; message: string }> { + return invokeOrMock('request_android_accessibility_permission', undefined, () => ({ + launched: false, + message: 'Mock: accessibility settings unavailable in browser preview', + })); +} diff --git a/openless-all/app/android/frontend/lib/androidMicrophonePermission.ts b/openless-all/app/android/frontend/lib/androidMicrophonePermission.ts new file mode 100644 index 00000000..b0f79d89 --- /dev/null +++ b/openless-all/app/android/frontend/lib/androidMicrophonePermission.ts @@ -0,0 +1,94 @@ +import type { PermissionStatus as AppPermissionStatus } from '../../../src/lib/types'; +import { checkMicrophonePermission, requestMicrophonePermission } from '../../../src/lib/ipc'; + +const ANDROID_MIC_GRANTED_KEY = 'openless.androidMicrophoneGranted'; + +export async function checkAndroidMicrophoneAccess(): Promise { + try { + const nativeStatus = await checkMicrophonePermission(); + if (nativeStatus === 'granted' || nativeStatus === 'notApplicable') { + localStorage.setItem(ANDROID_MIC_GRANTED_KEY, '1'); + return 'granted'; + } + if (nativeStatus === 'denied' || nativeStatus === 'restricted') { + localStorage.removeItem(ANDROID_MIC_GRANTED_KEY); + return nativeStatus; + } + } catch { + // Fall through to WebView-local checks below. + } + + if (localStorage.getItem(ANDROID_MIC_GRANTED_KEY) === '1') { + return 'granted'; + } + + try { + const permissions = navigator.permissions; + if (permissions?.query) { + const status = await permissions.query({ name: 'microphone' as PermissionName }); + if (status.state === 'granted') return 'granted'; + if (status.state === 'denied') { + localStorage.removeItem(ANDROID_MIC_GRANTED_KEY); + return 'denied'; + } + } + } catch { + // Android WebView versions differ on navigator.permissions support. + } + + return 'notDetermined'; +} + +export async function requestAndroidMicrophoneAccess(): Promise { + try { + const nativeStatus = await requestMicrophonePermission(); + if (nativeStatus === 'granted' || nativeStatus === 'notApplicable') { + localStorage.setItem(ANDROID_MIC_GRANTED_KEY, '1'); + return 'granted'; + } + if (nativeStatus === 'denied' || nativeStatus === 'restricted') { + localStorage.removeItem(ANDROID_MIC_GRANTED_KEY); + return nativeStatus; + } + } catch { + // Fall through to WebView-local checks below. + } + + if (localStorage.getItem(ANDROID_MIC_GRANTED_KEY) === '1') { + return 'granted'; + } + + try { + const permissions = navigator.permissions; + if (permissions?.query) { + const status = await permissions.query({ name: 'microphone' as PermissionName }); + if (status.state === 'granted') { + localStorage.setItem(ANDROID_MIC_GRANTED_KEY, '1'); + return 'granted'; + } + if (status.state === 'denied') { + localStorage.removeItem(ANDROID_MIC_GRANTED_KEY); + return 'denied'; + } + } + } catch { + // Android WebView versions differ on navigator.permissions support. + } + + const mediaDevices = navigator.mediaDevices; + if (!mediaDevices?.getUserMedia) { + return 'notDetermined'; + } + + let stream: MediaStream | null = null; + try { + localStorage.setItem(ANDROID_MIC_GRANTED_KEY, '1'); + stream = await mediaDevices.getUserMedia({ audio: true }); + return 'granted'; + } catch (error) { + console.warn('[android-mic] WebView microphone permission request failed', error); + return 'granted'; + } finally { + stream?.getTracks().forEach(track => track.stop()); + } +} diff --git a/openless-all/app/android/frontend/lib/androidTypes.ts b/openless-all/app/android/frontend/lib/androidTypes.ts new file mode 100644 index 00000000..b8a63039 --- /dev/null +++ b/openless-all/app/android/frontend/lib/androidTypes.ts @@ -0,0 +1,36 @@ +/** Android-specific preference and status types (mirrors Rust IPC payloads). */ + +export type AndroidInsertStrategy = 'accessibility' | 'clipboard'; +export type AndroidOverlayTrigger = 'background' | 'keyboard' | 'always'; +export type AndroidOverlayActivationMode = 'tap' | 'long_press'; +export type AndroidOverlayLeftSwipeAction = 'translation' | 'style_pack'; + +export interface AndroidOverlayStatus { + permission: 'granted' | 'notGranted' | 'notAndroid'; + overlayVisible: boolean; + message: string; +} + +export interface AndroidAccessibilityStatus { + state: 'enabled' | 'notEnabled' | 'notAndroid'; + enabled: boolean; + message: string; +} + +export type AndroidPreferenceKey = + | 'androidInsertStrategy' + | 'androidOverlayTrigger' + | 'androidOverlayActivationMode' + | 'androidOverlayLeftSwipeAction' + | 'androidOverlaySizeDp'; + +export function normalizeAndroidOverlayTrigger( + trigger: AndroidOverlayTrigger, +): AndroidOverlayTrigger { + return trigger === 'keyboard' ? 'background' : trigger; +} + +export function clampAndroidOverlaySize(size: number): number { + if (!Number.isFinite(size)) return 72; + return Math.min(120, Math.max(48, Math.round(size / 4) * 4)); +} diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt new file mode 100644 index 00000000..eccf203e --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt @@ -0,0 +1,21 @@ +package com.openless.app + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +class OpenLessAccessibilityCommandReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + if (intent?.action != ACTION_PASTE) return + val pasted = OpenLessAccessibilityService.performPasteFromCommand() + if (!pasted) { + Log.w(TAG, "paste command did not find an editable focused field") + } + } + + companion object { + const val ACTION_PASTE = "com.openless.app.accessibility.PASTE" + private const val TAG = "OpenLessA11yCommand" + } +} diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt new file mode 100644 index 00000000..86ad0c4f --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt @@ -0,0 +1,252 @@ +package com.openless.app + +import android.accessibilityservice.AccessibilityService +import android.content.Context +import android.content.Intent +import android.graphics.Rect +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.util.Log +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo + +/** + * Detects IME windows for overlay keyboard trigger mode and performs paste insertion. + */ +class OpenLessAccessibilityService : AccessibilityService() { + private val mainHandler = Handler(Looper.getMainLooper()) + private val heartbeatRunnable = object : Runnable { + override fun run() { + markServiceAlive() + mainHandler.postDelayed(this, HEARTBEAT_INTERVAL_MS) + } + } + private val keyboardRefreshRunnable = Runnable { updateKeyboardOverlayState() } + + override fun onServiceConnected() { + super.onServiceConnected() + instance = this + startHeartbeat() + updateKeyboardOverlayState() + scheduleKeyboardOverlayRefresh() + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + if (event == null) return + markServiceAlive() + when (event.eventType) { + AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, + AccessibilityEvent.TYPE_WINDOWS_CHANGED, + AccessibilityEvent.TYPE_VIEW_FOCUSED -> { + updateKeyboardOverlayState() + scheduleKeyboardOverlayRefresh() + } + } + } + + override fun onInterrupt() = Unit + + override fun onDestroy() { + mainHandler.removeCallbacks(heartbeatRunnable) + mainHandler.removeCallbacks(keyboardRefreshRunnable) + if (instance === this) { + instance = null + } + super.onDestroy() + } + + private fun scheduleKeyboardOverlayRefresh() { + mainHandler.removeCallbacks(keyboardRefreshRunnable) + for (delayMs in KEYBOARD_REFRESH_DELAYS_MS) { + mainHandler.postDelayed(keyboardRefreshRunnable, delayMs) + } + } + + private fun startHeartbeat() { + mainHandler.removeCallbacks(heartbeatRunnable) + heartbeatRunnable.run() + } + + private fun updateKeyboardOverlayState() { + if (!shouldTrackKeyboard()) { + return + } + if (!canDrawOverlays()) { + return + } + val imeBounds = findInputMethodBounds() + val intent = Intent(this, OpenLessOverlayService::class.java).apply { + action = OpenLessOverlayService.ACTION_KEYBOARD_CHANGED + putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_VISIBLE, imeBounds != null) + imeBounds?.let { + putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_TOP, it.top) + putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_BOTTOM, it.bottom) + } + } + try { + Log.i(TAG, "keyboard overlay event visible=${imeBounds != null} bounds=$imeBounds") + startService(intent) + } catch (error: Throwable) { + Log.w(TAG, "send keyboard overlay event failed", error) + } + } + + private fun findInputMethodBounds(): Rect? { + for (window in windows) { + if (window.type != AccessibilityWindowInfo.TYPE_INPUT_METHOD) { + continue + } + val bounds = Rect() + window.getBoundsInScreen(bounds) + if (!bounds.isEmpty) { + return bounds + } + } + return null + } + + private fun shouldTrackKeyboard(): Boolean { + return OpenLessAndroidPreferences.overlayTriggerMode(this) == "keyboard" + } + + private fun canDrawOverlays(): Boolean { + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + Settings.canDrawOverlays(this) + } else { + true + } + } + + private fun performPasteToFocusedField(): Boolean { + val root = rootInActiveWindow ?: return false + val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) + ?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY) + ?: return false + if (!focused.isEditable) { + focused.recycle() + return false + } + val pasted = focused.performAction(AccessibilityNodeInfo.ACTION_PASTE) + focused.recycle() + return pasted + } + + private fun captureSelectedTextFromFocusedNode(): String { + val root = rootInActiveWindow ?: return "" + val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) + ?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY) + focused?.let { + return try { + selectedTextFromNode(it) + } finally { + it.recycle() + } + } + return selectedTextFromTree(root) + } + + private fun selectedTextFromTree(node: AccessibilityNodeInfo?): String { + if (node == null) return "" + selectedTextFromNode(node).takeIf { it.isNotBlank() }?.let { return it } + for (index in 0 until node.childCount) { + val child = node.getChild(index) ?: continue + try { + selectedTextFromTree(child).takeIf { it.isNotBlank() }?.let { return it } + } finally { + child.recycle() + } + } + return "" + } + + private fun selectedTextFromNode(node: AccessibilityNodeInfo): String { + val text = node.text?.toString() ?: return "" + val start = node.textSelectionStart + val end = node.textSelectionEnd + if (start < 0 || end < 0 || start == end) return "" + val from = minOf(start, end).coerceIn(0, text.length) + val to = maxOf(start, end).coerceIn(0, text.length) + if (from >= to) return "" + return text.substring(from, to) + } + + private fun markServiceAlive() { + getSharedPreferences(PREFS_NAME, prefsMode()) + .edit() + .putLong(PREF_KEY_LAST_HEARTBEAT, System.currentTimeMillis()) + .apply() + } + + companion object { + @Volatile + var instance: OpenLessAccessibilityService? = null + private set + + @JvmStatic + fun pasteToFocusedField(): Boolean { + instance?.let { return it.performPasteToFocusedField() } + return sendPasteRequestToAccessibilityProcess() + } + + @JvmStatic + fun captureSelectedText(): String { + return instance?.captureSelectedTextFromFocusedNode().orEmpty() + } + + @JvmStatic + fun isEnabled(context: Context): Boolean { + val enabled = Settings.Secure.getInt( + context.contentResolver, + Settings.Secure.ACCESSIBILITY_ENABLED, + 0, + ) == 1 + if (!enabled) return false + val services = Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + ) ?: return false + return services.contains("${context.packageName}/${OpenLessAccessibilityService::class.java.name}") + } + + @JvmStatic + fun isOperational(context: Context): Boolean { + if (!isEnabled(context)) return false + val lastHeartbeat = context + .getSharedPreferences(PREFS_NAME, prefsMode()) + .getLong(PREF_KEY_LAST_HEARTBEAT, 0L) + if (lastHeartbeat <= 0L) return false + return System.currentTimeMillis() - lastHeartbeat <= HEARTBEAT_STALE_MS + } + + internal fun performPasteFromCommand(): Boolean { + return instance?.performPasteToFocusedField() == true + } + + private fun sendPasteRequestToAccessibilityProcess(): Boolean { + val context = OpenLessAppContext.context ?: return false + if (!isOperational(context)) return false + return try { + val intent = Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply { + action = OpenLessAccessibilityCommandReceiver.ACTION_PASTE + } + context.sendBroadcast(intent) + true + } catch (error: Throwable) { + Log.w(TAG, "send accessibility paste request failed", error) + false + } + } + + @Suppress("DEPRECATION") + private fun prefsMode(): Int = Context.MODE_PRIVATE or Context.MODE_MULTI_PROCESS + + private val KEYBOARD_REFRESH_DELAYS_MS = longArrayOf(120L, 360L, 900L, 1600L) + private const val TAG = "OpenLessAccessibility" + private const val PREFS_NAME = "openless_accessibility" + private const val PREF_KEY_LAST_HEARTBEAT = "last_heartbeat" + private const val HEARTBEAT_INTERVAL_MS = 5_000L + private const val HEARTBEAT_STALE_MS = 15_000L + } +} diff --git a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt new file mode 100644 index 00000000..7cf91ed9 --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt @@ -0,0 +1,99 @@ +package com.openless.app + +import android.content.Context +import android.util.Log +import java.io.File +import org.json.JSONObject + +/** + * Reads Android-visible preferences without depending on the Rust coordinator. + */ +object OpenLessAndroidPreferences { + private const val TAG = "OpenLessAndroidPrefs" + private const val APP_DIR = "OpenLess" + private const val PREFERENCES_FILE = "preferences.json" + private const val KEY_OVERLAY_TRIGGER = "androidOverlayTrigger" + private const val KEY_OVERLAY_ACTIVATION_MODE = "androidOverlayActivationMode" + private const val KEY_OVERLAY_LEFT_SWIPE_ACTION = "androidOverlayLeftSwipeAction" + private const val KEY_OVERLAY_SIZE_DP = "androidOverlaySizeDp" + private const val DEFAULT_OVERLAY_SIZE_DP = 72 + private const val MIN_OVERLAY_SIZE_DP = 48 + private const val MAX_OVERLAY_SIZE_DP = 120 + private val VALID_OVERLAY_TRIGGERS = setOf("background", "always") + private val VALID_OVERLAY_ACTIVATION_MODES = setOf("tap", "long_press") + private val VALID_OVERLAY_LEFT_SWIPE_ACTIONS = setOf("translation", "style_pack") + + fun overlayTriggerMode(context: Context): String? { + val value = readPreferenceString(context, KEY_OVERLAY_TRIGGER) ?: return null + if (value == "keyboard") { + return "background" + } + return value.takeIf { it in VALID_OVERLAY_TRIGGERS } + } + + fun overlayActivationMode(context: Context): String { + return readPreferenceString(context, KEY_OVERLAY_ACTIVATION_MODE) + ?.takeIf { it in VALID_OVERLAY_ACTIVATION_MODES } + ?: "tap" + } + + fun overlayLeftSwipeAction(context: Context): String { + return readPreferenceString(context, KEY_OVERLAY_LEFT_SWIPE_ACTION) + ?.takeIf { it in VALID_OVERLAY_LEFT_SWIPE_ACTIONS } + ?: "translation" + } + + fun overlaySizeDp(context: Context): Int { + return readPreferenceInt(context, KEY_OVERLAY_SIZE_DP) + ?.coerceIn(MIN_OVERLAY_SIZE_DP, MAX_OVERLAY_SIZE_DP) + ?: DEFAULT_OVERLAY_SIZE_DP + } + + private fun readPreferenceString(context: Context, key: String): String? { + for (file in preferenceFiles(context).distinctBy { it.absolutePath }) { + if (!file.isFile) { + continue + } + val value = try { + JSONObject(file.readText()).optString(key, "") + } catch (error: Throwable) { + Log.w(TAG, "read ${file.absolutePath} failed", error) + "" + } + if (value.isNotBlank()) { + return value + } + } + return null + } + + private fun readPreferenceInt(context: Context, key: String): Int? { + for (file in preferenceFiles(context).distinctBy { it.absolutePath }) { + if (!file.isFile) { + continue + } + val value = try { + val json = JSONObject(file.readText()) + if (json.has(key)) json.optInt(key) else null + } catch (error: Throwable) { + Log.w(TAG, "read ${file.absolutePath} failed", error) + null + } + if (value != null) { + return value + } + } + return null + } + + private fun preferenceFiles(context: Context): List { + val files = mutableListOf() + val envDir = System.getenv("TAURI_ANDROID_APP_DATA_DIR") + if (!envDir.isNullOrBlank()) { + files += File(File(envDir), APP_DIR).resolve(PREFERENCES_FILE) + } + files += File(File(context.cacheDir, APP_DIR), PREFERENCES_FILE) + files += File(File(context.filesDir, APP_DIR), PREFERENCES_FILE) + return files + } +} diff --git a/openless-all/app/android/kotlin/OpenLessAppContext.kt b/openless-all/app/android/kotlin/OpenLessAppContext.kt new file mode 100644 index 00000000..e1c627ae --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessAppContext.kt @@ -0,0 +1,13 @@ +package com.openless.app + +import android.content.Context + +object OpenLessAppContext { + @Volatile + var context: Context? = null + private set + + fun initialize(context: Context) { + this.context = context.applicationContext + } +} diff --git a/openless-all/app/android/kotlin/OpenLessApplication.kt b/openless-all/app/android/kotlin/OpenLessApplication.kt new file mode 100644 index 00000000..8e25fd93 --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessApplication.kt @@ -0,0 +1,84 @@ +package com.openless.app + +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.os.Bundle +import android.provider.Settings +import android.util.Log + +/** + * Registers activity lifecycle hooks for overlay background trigger mode. + */ +class OpenLessApplication : Application() { + override fun onCreate() { + super.onCreate() + OpenLessAppContext.initialize(this) + registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + override fun onActivityStarted(activity: Activity) { + if (activity.javaClass.name.endsWith("MainActivity")) { + maybeHideOverlayOnForeground() + } + } + override fun onActivityResumed(activity: Activity) = Unit + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) { + if (activity.javaClass.name.endsWith("MainActivity")) { + maybeShowOverlayOnBackground() + } + } + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit + }) + } + + private fun maybeShowOverlayOnBackground() { + val configured = configuredOverlayTriggerMode() + val shouldShow = configured == "background" || + configured == "always" + if (!shouldShow) { + return + } + if (!canDrawOverlays()) { + return + } + sendOverlayAction(OpenLessOverlayService.ACTION_SHOW) + } + + private fun maybeHideOverlayOnForeground() { + if (configuredOverlayTriggerMode() == "always") { + if (canDrawOverlays()) { + sendOverlayAction(OpenLessOverlayService.ACTION_SHOW) + } + return + } + sendOverlayAction(OpenLessOverlayService.ACTION_HIDE) + } + + private fun configuredOverlayTriggerMode(): String { + return OpenLessAndroidPreferences.overlayTriggerMode(this) ?: "background" + } + + private fun canDrawOverlays(): Boolean { + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + Settings.canDrawOverlays(this) + } else { + true + } + } + + private fun sendOverlayAction(action: String) { + try { + startService(Intent(this, OpenLessOverlayService::class.java).apply { + this.action = action + }) + } catch (error: Throwable) { + Log.w(TAG, "overlay action failed: $action", error) + } + } + + companion object { + private const val TAG = "OpenLessApplication" + } +} diff --git a/openless-all/app/android/kotlin/OpenLessNative.kt b/openless-all/app/android/kotlin/OpenLessNative.kt new file mode 100644 index 00000000..3c6f297a --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessNative.kt @@ -0,0 +1,42 @@ +package com.openless.app + +/** + * JNI bridge from Kotlin overlay / lifecycle code into Rust Coordinator. + */ +object OpenLessNative { + init { + try { + System.loadLibrary("openless_lib") + } catch (error: UnsatisfiedLinkError) { + android.util.Log.e("OpenLessNative", "failed to load openless_lib", error) + } + } + + @JvmStatic external fun nativeStartDictation() + + @JvmStatic external fun nativeStartDictationWithTranslation(translation: Boolean) + + @JvmStatic external fun nativeStopDictation() + + @JvmStatic external fun nativeStopDictationWithTranslation(translation: Boolean) + + @JvmStatic external fun nativeCancelDictation() + + @JvmStatic external fun nativeSwitchStylePack() + + @JvmStatic external fun nativeOpenQaFromOverlay() + + @JvmStatic external fun nativeFinalizeQaFromOverlay() + + @JvmStatic external fun nativeGetOverlayTriggerMode(): String + + @JvmStatic external fun nativeCanDrawOverlays(context: android.content.Context): Boolean + + @JvmStatic external fun nativeShowOverlay(context: android.content.Context) + + @JvmStatic external fun nativeHideOverlay(context: android.content.Context) + + @JvmStatic external fun nativeIsOverlayVisible(): Boolean + + @JvmStatic external fun nativeNotifyOverlayPermissionChanged(context: android.content.Context) +} diff --git a/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt b/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt new file mode 100644 index 00000000..92406730 --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt @@ -0,0 +1,33 @@ +package com.openless.app + +import android.os.Handler +import android.os.Looper + +/** + * Rust calls back into this object to refresh overlay UI state. + */ +object OpenLessOverlayBridge { + private val mainHandler = Handler(Looper.getMainLooper()) + + @Volatile + var listener: OverlayStateListener? = null + + interface OverlayStateListener { + fun onCapsuleStateChanged(state: String, message: String?) + } + + @JvmStatic + fun onCapsuleStateChanged(state: String, message: String?) { + mainHandler.post { + listener?.onCapsuleStateChanged(state, message) + } + } + + @JvmStatic + fun showToast(message: String) { + mainHandler.post { + val service = OpenLessOverlayService.instance ?: return@post + android.widget.Toast.makeText(service.applicationContext, message, android.widget.Toast.LENGTH_SHORT).show() + } + } +} diff --git a/openless-all/app/android/kotlin/OpenLessOverlayService.kt b/openless-all/app/android/kotlin/OpenLessOverlayService.kt new file mode 100644 index 00000000..da9882d8 --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessOverlayService.kt @@ -0,0 +1,699 @@ +package com.openless.app + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.graphics.Color +import android.graphics.PixelFormat +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.os.IBinder +import android.util.Log +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.Toast +import kotlin.math.abs + +/** + * Foreground service + TYPE_APPLICATION_OVERLAY floating dictation control. + */ +class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateListener { + + private var windowManager: WindowManager? = null + private var rootView: FrameLayout? = null + private var layoutParams: WindowManager.LayoutParams? = null + private var recording = false + private var processing = false + private var keyboardVisible = false + private var armed = false + private var dragStartX = 0 + private var dragStartY = 0 + private var paramStartX = 0 + private var paramStartY = 0 + private var dragging = false + private var longPressRecording = false + private var pendingSwipe: SwipeDirection? = null + private var swipeConsumed = false + + private lateinit var iconContainer: FrameLayout + private lateinit var iconButton: ImageView + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + instance = this + OpenLessOverlayBridge.listener = this + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.i( + TAG, + "onStartCommand action=${intent?.action} startId=$startId rootAttached=${rootView?.isAttachedToWindow}", + ) + when (intent?.action) { + ACTION_SHOW -> showOverlay() + ACTION_START_RECORDING -> { + showOverlay() + startRecordingFromOverlay() + } + ACTION_HIDE -> { + hideOverlay() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + stopSelf() + } + ACTION_TOGGLE_EXPAND -> handleIconClick() + ACTION_KEYBOARD_CHANGED -> handleKeyboardChanged(intent) + } + return START_STICKY + } + + override fun onDestroy() { + if (OpenLessOverlayBridge.listener === this) { + OpenLessOverlayBridge.listener = null + } + hideOverlay() + if (instance === this) { + instance = null + } + super.onDestroy() + } + + override fun onCapsuleStateChanged(state: String, message: String?) { + when (state) { + "recording" -> { + recording = true + processing = false + if (!tryPromoteRecordingForeground()) { + try { + OpenLessNative.nativeCancelDictation() + } catch (error: Throwable) { + Log.w(TAG, "cancel dictation bridge unavailable", error) + } + return + } + applyVisualState(OverlayVisualState.Recording) + } + "transcribing", "polishing" -> { + recording = false + processing = true + applyVisualState(OverlayVisualState.Processing) + } + "done" -> { + recording = false + processing = false + setArmed(false) + } + "error" -> { + recording = false + processing = false + setArmed(false) + applyVisualState(OverlayVisualState.Error) + message?.takeIf { it.isNotBlank() }?.let { showToast(it) } + } + "cancelled", "idle" -> { + recording = false + processing = false + setArmed(false) + } + } + } + + private fun showOverlay() { + windowManager = getSystemService(WINDOW_SERVICE) as WindowManager + reconcileOverlayRoots() + overlayRoots.lastOrNull()?.let { existing -> + rootView = existing + layoutParams = existing.layoutParams as? WindowManager.LayoutParams + iconContainer = existing + (existing.getChildAt(0) as? ImageView)?.let { iconButton = it } + applyOverlaySize(existing) + layoutParams?.let { params -> + clampToScreen(params) + windowManager?.updateViewLayout(existing, params) + } + Log.i(TAG, "overlay already shown roots=${overlayRoots.size}") + return + } + + val savedPosition = loadSavedPosition() + val params = WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + } else { + @Suppress("DEPRECATION") + WindowManager.LayoutParams.TYPE_PHONE + }, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, + PixelFormat.TRANSLUCENT, + ).apply { + gravity = Gravity.TOP or Gravity.START + x = savedPosition.first + y = savedPosition.second + } + layoutParams = params + + val root = FrameLayout(this).apply { + contentDescription = "OpenLess" + isClickable = true + isFocusable = false + setOnClickListener { handleIconClick() } + } + iconContainer = root + iconButton = buildIconButton() + root.addView( + iconButton, + FrameLayout.LayoutParams(1, 1, Gravity.CENTER), + ) + applyOverlaySize(root) + attachDragHandler(root, params) + try { + windowManager?.addView(root, params) + } catch (error: Throwable) { + Log.w(TAG, "show overlay failed", error) + layoutParams = null + return + } + rootView = root + synchronized(overlayRoots) { + overlayRoots.add(root) + } + Log.i(TAG, "overlay shown x=${params.x} y=${params.y} roots=${overlayRoots.size}") + applyVisualState( + when { + recording -> OverlayVisualState.Recording + processing -> OverlayVisualState.Processing + armed -> OverlayVisualState.Armed + else -> OverlayVisualState.Idle + }, + ) + } + + private fun hideOverlay() { + windowManager = windowManager ?: getSystemService(WINDOW_SERVICE) as WindowManager + val views = synchronized(overlayRoots) { + (overlayRoots + listOfNotNull(rootView)).distinct().also { + overlayRoots.clear() + } + } + views.forEach { view -> + removeOverlayRoot(view) + } + rootView = null + layoutParams = null + if (views.isNotEmpty()) { + Log.i(TAG, "overlay hidden roots=${views.size}") + } + } + + private fun reconcileOverlayRoots() { + val roots = synchronized(overlayRoots) { + overlayRoots.filter { it.isAttachedToWindow }.also { + overlayRoots.clear() + overlayRoots.addAll(it) + } + } + if (roots.isEmpty()) { + rootView = null + layoutParams = null + return + } + roots.dropLast(1).forEach { staleRoot -> + removeOverlayRoot(staleRoot) + synchronized(overlayRoots) { + overlayRoots.remove(staleRoot) + } + } + val activeRoot = roots.last() + rootView = activeRoot + layoutParams = activeRoot.layoutParams as? WindowManager.LayoutParams + Log.i(TAG, "reconciled overlay roots kept=1 removed=${roots.size - 1}") + } + + private fun removeOverlayRoot(view: FrameLayout) { + try { + if (view.isAttachedToWindow) { + windowManager?.removeViewImmediate(view) + } + } catch (error: Throwable) { + Log.w(TAG, "remove overlay root failed", error) + } + } + + private fun buildIconButton(): ImageView { + return ImageView(this).apply { + setImageResource(R.mipmap.ic_launcher) + scaleType = ImageView.ScaleType.CENTER_INSIDE + setPadding(0, 0, 0, 0) + contentDescription = "OpenLess" + isClickable = false + isFocusable = false + } + } + + private fun applyOverlaySize(container: FrameLayout) { + if (!::iconButton.isInitialized) return + val sizeDp = OpenLessAndroidPreferences.overlaySizeDp(this) + val paddingDp = overlayPaddingDp(sizeDp) + val imageSizePx = dp((sizeDp - paddingDp * 2).coerceAtLeast(MIN_ICON_IMAGE_SIZE_DP)) + val paddingPx = dp(paddingDp) + container.setPadding(paddingPx, paddingPx, paddingPx, paddingPx) + (iconButton.layoutParams as? FrameLayout.LayoutParams)?.let { childParams -> + childParams.width = imageSizePx + childParams.height = imageSizePx + childParams.gravity = Gravity.CENTER + iconButton.layoutParams = childParams + } + container.requestLayout() + Log.i(TAG, "overlay size applied sizeDp=$sizeDp imagePx=$imageSizePx") + } + + private fun overlayPaddingDp(sizeDp: Int): Int { + return (sizeDp * ICON_PADDING_DP / DEFAULT_ICON_SIZE_DP).coerceIn(6, 16) + } + + private fun handleIconClick() { + if (processing) return + if (recording) { + stopRecordingFromOverlay() + return + } + if (!isTapActivationMode()) { + return + } + startRecordingFromOverlay() + } + + private fun handleKeyboardChanged(intent: Intent) { + val visible = intent.getBooleanExtra(EXTRA_KEYBOARD_VISIBLE, false) + keyboardVisible = visible + Log.i(TAG, "keyboard changed visible=$visible") + if (visible) { + showOverlay() + return + } + if (!recording && !processing) { + hideOverlay() + } + } + + private fun attachDragHandler(view: View, params: WindowManager.LayoutParams) { + view.setOnTouchListener { touchedView, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + dragging = false + swipeConsumed = false + longPressRecording = false + pendingSwipe = null + if (processing) { + return@setOnTouchListener true + } + dragStartX = event.rawX.toInt() + dragStartY = event.rawY.toInt() + paramStartX = params.x + paramStartY = params.y + if (!isTapActivationMode() && !recording && !processing) { + longPressRecording = true + startRecordingFromOverlay() + } + true + } + MotionEvent.ACTION_MOVE -> { + val dx = event.rawX.toInt() - dragStartX + val dy = event.rawY.toInt() - dragStartY + val swipe = detectHorizontalSwipe(dx, dy) + if ((recording || armed || longPressRecording) && swipe != null && !swipeConsumed) { + pendingSwipe = swipe + swipeConsumed = true + applySwipePreview(swipe) + return@setOnTouchListener true + } + if (!processing && !armed && !recording && !longPressRecording && (abs(dx) > DRAG_SLOP_PX || abs(dy) > DRAG_SLOP_PX)) { + dragging = true + params.x = paramStartX + dx + params.y = paramStartY + dy + clampToScreen(params) + rootView?.let { windowManager?.updateViewLayout(it, params) } + } + true + } + MotionEvent.ACTION_UP -> { + if (!dragging) { + val swipe = pendingSwipe + if (swipe != null) { + commitSwipe(swipe) + } else if (longPressRecording || (!isTapActivationMode() && recording)) { + stopRecordingFromOverlay() + } else if (!isTapActivationMode()) { + setArmed(false) + } else if (!swipeConsumed) { + touchedView.performClick() + } + } else { + savePosition(params.x, params.y) + } + longPressRecording = false + pendingSwipe = null + swipeConsumed = false + true + } + MotionEvent.ACTION_CANCEL -> { + if (longPressRecording || (!isTapActivationMode() && recording)) { + stopRecordingFromOverlay() + } + longPressRecording = false + pendingSwipe = null + swipeConsumed = false + true + } + else -> false + } + } + } + + private fun applyVisualState(state: OverlayVisualState) { + if (!::iconContainer.isInitialized || !::iconButton.isInitialized) return + val (alpha, fill, stroke, strokeWidth, enabled) = when (state) { + OverlayVisualState.Idle -> VisualStyle( + alpha = 0.58f, + fill = Color.parseColor("#66202A36"), + stroke = Color.parseColor("#66FFFFFF"), + strokeWidth = 1, + enabled = true, + ) + OverlayVisualState.Armed -> VisualStyle( + alpha = 1f, + fill = Color.parseColor("#E6111827"), + stroke = Color.parseColor("#38BDF8"), + strokeWidth = 3, + enabled = true, + ) + OverlayVisualState.Recording -> VisualStyle( + alpha = 1f, + fill = Color.parseColor("#E6111827"), + stroke = Color.parseColor("#F43F5E"), + strokeWidth = 3, + enabled = true, + ) + OverlayVisualState.Processing -> VisualStyle( + alpha = 0.86f, + fill = Color.parseColor("#D1111827"), + stroke = Color.parseColor("#38BDF8"), + strokeWidth = 2, + enabled = true, + ) + OverlayVisualState.Error -> VisualStyle( + alpha = 0.95f, + fill = Color.parseColor("#E67F1D1D"), + stroke = Color.parseColor("#EF4444"), + strokeWidth = 2, + enabled = true, + ) + } + iconContainer.alpha = alpha + iconContainer.isEnabled = enabled + iconContainer.background = circleDrawable(fill, stroke, dp(strokeWidth)) + iconButton.isEnabled = enabled + } + + private fun setArmed(value: Boolean) { + armed = value + if (!recording && !processing) { + applyVisualState(if (value) OverlayVisualState.Armed else OverlayVisualState.Idle) + } + } + + private fun detectHorizontalSwipe(dx: Int, dy: Int): SwipeDirection? { + if (abs(dx) < dp(SWIPE_THRESHOLD_DP)) return null + if (abs(dy) > abs(dx) * SWIPE_VERTICAL_RATIO) return null + return if (dx < 0) SwipeDirection.Left else SwipeDirection.Right + } + + private fun applySwipePreview(direction: SwipeDirection) { + when (direction) { + SwipeDirection.Left -> applyVisualState(OverlayVisualState.Armed) + SwipeDirection.Right -> applyVisualState(OverlayVisualState.Processing) + } + } + + private fun commitSwipe(direction: SwipeDirection) { + Log.i(TAG, "commit swipe direction=$direction recording=$recording processing=$processing") + when (direction) { + SwipeDirection.Left -> handleLeftSwipe() + SwipeDirection.Right -> finalizeQaFromOverlay() + } + } + + private fun handleLeftSwipe() { + when (OpenLessAndroidPreferences.overlayLeftSwipeAction(this)) { + "style_pack" -> { + switchStylePackFromOverlay() + if (recording) { + stopRecordingFromOverlay() + } + } + else -> stopRecordingFromOverlay(translation = true) + } + } + + private fun switchStylePackFromOverlay() { + try { + OpenLessNative.nativeSwitchStylePack() + setArmed(false) + } catch (error: Throwable) { + Log.w(TAG, "switch style pack bridge unavailable", error) + applyVisualState(OverlayVisualState.Error) + showToast("语音服务未就绪,请打开 OpenLess 后重试") + } + } + + private fun openQaFromOverlay() { + try { + Log.i(TAG, "open QA from overlay") + OpenLessNative.nativeOpenQaFromOverlay() + setArmed(false) + } catch (error: Throwable) { + Log.w(TAG, "open QA bridge unavailable", error) + applyVisualState(OverlayVisualState.Error) + showToast("问答服务未就绪,请打开 OpenLess 后重试") + } + } + + private fun finalizeQaFromOverlay() { + try { + Log.i(TAG, "finalize QA from overlay") + OpenLessNative.nativeFinalizeQaFromOverlay() + recording = false + processing = true + setArmed(false) + applyVisualState(OverlayVisualState.Processing) + } catch (error: Throwable) { + Log.w(TAG, "finalize QA bridge unavailable", error) + processing = false + applyVisualState(OverlayVisualState.Error) + showToast("问答服务未就绪,请打开 OpenLess 后重试") + } + } + + private fun startRecordingFromOverlay(translation: Boolean = false) { + showOverlay() + if (tryPromoteRecordingForeground()) { + try { + if (translation) { + OpenLessNative.nativeStartDictationWithTranslation(true) + } else { + OpenLessNative.nativeStartDictation() + } + recording = true + processing = false + setArmed(false) + applyVisualState(OverlayVisualState.Recording) + } catch (error: Throwable) { + Log.w(TAG, "start dictation bridge unavailable", error) + recording = false + processing = false + applyVisualState(OverlayVisualState.Error) + showToast("语音服务未就绪,请打开 OpenLess 后重试") + } + return + } + applyVisualState(OverlayVisualState.Error) + } + + private fun stopRecordingFromOverlay(translation: Boolean = false) { + try { + recording = false + processing = true + applyVisualState(OverlayVisualState.Processing) + if (translation) { + OpenLessNative.nativeStopDictationWithTranslation(true) + } else { + OpenLessNative.nativeStopDictation() + } + } catch (error: Throwable) { + Log.w(TAG, "stop dictation bridge unavailable", error) + recording = false + processing = false + applyVisualState(OverlayVisualState.Error) + showToast("语音服务未就绪,请打开 OpenLess 后重试") + } + } + + private fun tryPromoteRecordingForeground(): Boolean { + if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + showToast("请先授予麦克风权限") + return false + } + val notification = buildNotification("录音中") + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + true + } catch (error: SecurityException) { + Log.w(TAG, "microphone foreground service not allowed from current state", error) + showToast("系统限制后台录音,请在 OpenLess 内开始") + false + } + } + + private fun buildNotification(contentText: String): Notification { + val channelId = "openless_overlay" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val nm = getSystemService(NotificationManager::class.java) + nm.createNotificationChannel( + NotificationChannel(channelId, "OpenLess Overlay", NotificationManager.IMPORTANCE_LOW), + ) + } + return Notification.Builder(this, channelId) + .setContentTitle("OpenLess") + .setContentText(contentText) + .setSmallIcon(R.mipmap.ic_launcher) + .build() + } + + private fun circleDrawable(color: Int, strokeColor: Int, strokeWidth: Int): GradientDrawable { + return GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(color) + setStroke(strokeWidth, strokeColor) + } + } + + private fun overlaySize(): Int { + val root = rootView + val measured = maxOf(root?.width ?: 0, root?.height ?: 0) + return measured.takeIf { it > 0 } ?: dp(OpenLessAndroidPreferences.overlaySizeDp(this)) + } + + private fun clampToScreen(params: WindowManager.LayoutParams) { + val iconSize = overlaySize() + val margin = dp(8) + val maxX = (resources.displayMetrics.widthPixels - iconSize - margin).coerceAtLeast(margin) + val maxY = (resources.displayMetrics.heightPixels - iconSize - margin).coerceAtLeast(margin) + params.x = params.x.coerceIn(margin, maxX) + params.y = params.y.coerceIn(margin, maxY) + } + + private fun loadSavedPosition(): Pair { + val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + val defaultX = dp(24) + val defaultY = dp(120) + val x = prefs.getInt(PREF_KEY_X, defaultX) + val y = prefs.getInt(PREF_KEY_Y, defaultY) + return x to y + } + + private fun savePosition(x: Int, y: Int) { + getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + .edit() + .putInt(PREF_KEY_X, x) + .putInt(PREF_KEY_Y, y) + .apply() + } + + private fun isTapActivationMode(): Boolean { + return OpenLessAndroidPreferences.overlayActivationMode(this) == "tap" + } + + private fun showToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + private fun dp(value: Int): Int { + return (value * resources.displayMetrics.density).toInt() + } + + private data class VisualStyle( + val alpha: Float, + val fill: Int, + val stroke: Int, + val strokeWidth: Int, + val enabled: Boolean, + ) + + private enum class OverlayVisualState { + Idle, + Armed, + Recording, + Processing, + Error, + } + + private enum class SwipeDirection { + Left, + Right, + } + + companion object { + const val ACTION_SHOW = "com.openless.app.overlay.SHOW" + const val ACTION_HIDE = "com.openless.app.overlay.HIDE" + const val ACTION_TOGGLE_EXPAND = "com.openless.app.overlay.TOGGLE_EXPAND" + const val ACTION_START_RECORDING = "com.openless.app.overlay.START_RECORDING" + const val ACTION_KEYBOARD_CHANGED = "com.openless.app.overlay.KEYBOARD_CHANGED" + const val EXTRA_KEYBOARD_VISIBLE = "keyboard_visible" + const val EXTRA_KEYBOARD_TOP = "keyboard_top" + const val EXTRA_KEYBOARD_BOTTOM = "keyboard_bottom" + private const val DEFAULT_ICON_SIZE_DP = 72 + private const val MIN_ICON_IMAGE_SIZE_DP = 32 + private const val ICON_PADDING_DP = 8 + private const val DRAG_SLOP_PX = 8 + private const val SWIPE_THRESHOLD_DP = 56 + private const val SWIPE_VERTICAL_RATIO = 0.6f + private const val PREFS_NAME = "openless_overlay" + private const val PREF_KEY_X = "overlay_x" + private const val PREF_KEY_Y = "overlay_y" + private const val NOTIFICATION_ID = 42001 + private const val TAG = "OpenLessOverlayService" + + private val overlayRoots = mutableListOf() + + @Volatile + var instance: OpenLessOverlayService? = null + private set + } +} diff --git a/openless-all/app/android/kotlin/OverlayPermissionActivity.kt b/openless-all/app/android/kotlin/OverlayPermissionActivity.kt new file mode 100644 index 00000000..d3a05f8f --- /dev/null +++ b/openless-all/app/android/kotlin/OverlayPermissionActivity.kt @@ -0,0 +1,27 @@ +package com.openless.app + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.Settings + +/** + * 引导用户授权 SYSTEM_ALERT_WINDOW。 + * Rust 命令 request_android_overlay_permission 通过 Intent 启动本 Activity。 + */ +class OverlayPermissionActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) { + val intent = Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:$packageName"), + ) + startActivity(intent) + } + finish() + } +} diff --git a/openless-all/app/android/kotlin/README.md b/openless-all/app/android/kotlin/README.md new file mode 100644 index 00000000..465107de --- /dev/null +++ b/openless-all/app/android/kotlin/README.md @@ -0,0 +1,27 @@ +# Android Kotlin scaffolding + +Copy these files into `src-tauri/gen/android/` after running: + +```bash +cd openless-all/app +npm run tauri:android:init +``` + +## Copy / merge paths + +| Source (this folder) | Destination (after init) | +| --- | --- | +| `OpenLessOverlayService.kt` | `gen/android/app/src/main/java/com/openless/app/OpenLessOverlayService.kt` | +| `OverlayPermissionActivity.kt` | `gen/android/app/src/main/java/com/openless/app/OverlayPermissionActivity.kt` | +| `AndroidManifest.v1.snippet.xml` | 在 `../manifests/`,merge 进 `gen/android/.../AndroidManifest.xml` | +| `AndroidManifest.v3.snippet.xml` | 在 `../manifests/`,**future / not complete** — overlay v3 only | + +Tauri `android init` generates the base manifest under `gen/android/app/src/main/AndroidManifest.xml`. +Merge the v1 snippet permissions into that file before building APK v1. + +## Manifest snippets + +- **v1** (`AndroidManifest.v1.snippet.xml`): `RECORD_AUDIO` and `MODIFY_AUDIO_SETTINGS` for in-app dictation — required for APK v1. +- **v3** (`AndroidManifest.v3.snippet.xml`): overlay + foreground service — **not complete / future**. + +Do not treat v3 snippets as shipped; they document planned permissions and service entries only. diff --git a/openless-all/app/android/manifests/AndroidManifest.v1.snippet.xml b/openless-all/app/android/manifests/AndroidManifest.v1.snippet.xml new file mode 100644 index 00000000..7f012e3c --- /dev/null +++ b/openless-all/app/android/manifests/AndroidManifest.v1.snippet.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/openless-all/app/android/manifests/AndroidManifest.v3.snippet.xml b/openless-all/app/android/manifests/AndroidManifest.v3.snippet.xml new file mode 100644 index 00000000..859f99b6 --- /dev/null +++ b/openless-all/app/android/manifests/AndroidManifest.v3.snippet.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/openless-all/app/android/manifests/res/xml/openless_accessibility_config.xml b/openless-all/app/android/manifests/res/xml/openless_accessibility_config.xml new file mode 100644 index 00000000..281b8c75 --- /dev/null +++ b/openless-all/app/android/manifests/res/xml/openless_accessibility_config.xml @@ -0,0 +1,9 @@ + + diff --git a/openless-all/app/android/manifests/res/xml/openless_ime_method.xml b/openless-all/app/android/manifests/res/xml/openless_ime_method.xml new file mode 100644 index 00000000..5a2afd44 --- /dev/null +++ b/openless-all/app/android/manifests/res/xml/openless_ime_method.xml @@ -0,0 +1,9 @@ + + + + diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index 5e94379f..69098fa0 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -5,7 +5,8 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); -const scaffoldingRoot = join(appRoot, 'src-tauri/android-scaffolding'); +const kotlinRoot = join(appRoot, 'android/kotlin'); +const manifestsRoot = join(appRoot, 'android/manifests'); const androidIconRoot = join(appRoot, 'src-tauri/icons/android'); const genRoot = join(appRoot, 'src-tauri/gen/android/app/src/main'); const kotlinDest = join(genRoot, 'java/com/openless/app'); @@ -146,7 +147,7 @@ function main() { copyDirectoryContents(androidIconRoot, resDest, dryRun); for (const file of KOTLIN_FILES) { - const src = join(scaffoldingRoot, file); + const src = join(kotlinRoot, file); const dest = join(kotlinDest, file); if (!existsSync(src)) { throw new Error(`Missing scaffolding file: ${src}`); @@ -160,7 +161,7 @@ function main() { } for (const [relSrc, destName] of XML_FILES) { - const src = join(scaffoldingRoot, relSrc); + const src = join(manifestsRoot, relSrc); const dest = join(resXmlDest, destName); const content = existsSync(src) ? readFileSync(src, 'utf8') diff --git a/openless-all/app/scripts/merge-android-v1-manifest.mjs b/openless-all/app/scripts/merge-android-v1-manifest.mjs index 929fd4e0..e5982d8d 100644 --- a/openless-all/app/scripts/merge-android-v1-manifest.mjs +++ b/openless-all/app/scripts/merge-android-v1-manifest.mjs @@ -7,7 +7,7 @@ const targetPath = fileURLToPath( new URL('../src-tauri/gen/android/app/src/main/AndroidManifest.xml', import.meta.url), ); const sourcePath = fileURLToPath( - new URL('../src-tauri/android-scaffolding/AndroidManifest.v1.snippet.xml', import.meta.url), + new URL('../android/manifests/AndroidManifest.v1.snippet.xml', import.meta.url), ); const PERMISSION_LINE_RE = @@ -16,7 +16,7 @@ const PERMISSION_LINE_RE = function printHelp() { console.log(`Usage: node scripts/merge-android-v1-manifest.mjs [options] -Merge APK v1 permissions from android-scaffolding into the generated +Merge APK v1 permissions from android/manifests into the generated AndroidManifest.xml (post \`tauri android init\`). Options: diff --git a/openless-all/app/src-tauri/src/android_accessibility.rs b/openless-all/app/src-tauri/src/android/accessibility.rs similarity index 80% rename from openless-all/app/src-tauri/src/android_accessibility.rs rename to openless-all/app/src-tauri/src/android/accessibility.rs index 714cc2a4..ec5ec976 100644 --- a/openless-all/app/src-tauri/src/android_accessibility.rs +++ b/openless-all/app/src-tauri/src/android/accessibility.rs @@ -2,7 +2,7 @@ use serde::Serialize; -use crate::types::{AndroidAccessibilityState, AndroidAccessibilityStatus}; +use crate::android::types::{AndroidAccessibilityState, AndroidAccessibilityStatus}; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -55,11 +55,11 @@ pub fn paste_via_accessibility() -> bool { #[cfg(target_os = "android")] mod android_impl { use super::{AndroidAccessibilityPermissionResult, AndroidAccessibilityStatus}; - use crate::types::{AndroidAccessibilityState, AndroidAccessibilityStatus as Status}; + use crate::android::types::{AndroidAccessibilityState, AndroidAccessibilityStatus as Status}; pub fn get_android_accessibility_status() -> AndroidAccessibilityStatus { - let enabled = match crate::android_jni::android::with_android_env(|env, context| { - crate::android_jni::android::accessibility_enabled(env, context) + let enabled = match crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::accessibility_enabled(env, context) }) { Ok(enabled) => enabled, Err(error) => { @@ -78,8 +78,8 @@ mod android_impl { }; } - match crate::android_jni::android::with_android_env(|env, context| { - crate::android_jni::android::accessibility_operational(env, context) + match crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::accessibility_operational(env, context) }) { Ok(true) => Status { state: AndroidAccessibilityState::Enabled, @@ -100,8 +100,8 @@ mod android_impl { } pub fn request_android_accessibility_permission() -> AndroidAccessibilityPermissionResult { - match crate::android_jni::android::with_android_env(|env, context| { - crate::android_jni::android::launch_accessibility_settings(env, context) + match crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::launch_accessibility_settings(env, context) }) { Ok(()) => AndroidAccessibilityPermissionResult { launched: true, @@ -115,8 +115,8 @@ mod android_impl { } pub fn paste_via_accessibility() -> bool { - crate::android_jni::android::with_android_env(|env, context| { - crate::android_jni::android::accessibility_paste(env, context) + crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::accessibility_paste(env, context) }) .unwrap_or(false) } diff --git a/openless-all/app/src-tauri/src/android_insert.rs b/openless-all/app/src-tauri/src/android/insert.rs similarity index 76% rename from openless-all/app/src-tauri/src/android_insert.rs rename to openless-all/app/src-tauri/src/android/insert.rs index 6eebf080..8a6b3aac 100644 --- a/openless-all/app/src-tauri/src/android_insert.rs +++ b/openless-all/app/src-tauri/src/android/insert.rs @@ -2,7 +2,8 @@ #![cfg(target_os = "android")] use crate::insertion::TextInserter; -use crate::types::{AndroidInsertStrategy, InsertStatus}; +use crate::android::types::AndroidInsertStrategy; +use crate::types::InsertStatus; pub fn android_insert_with_strategy( inserter: &TextInserter, @@ -24,14 +25,14 @@ pub fn android_insert_with_strategy( } fn try_accessibility(inserter: &TextInserter, text: &str) -> Option { - if !crate::android_accessibility::get_android_accessibility_status().enabled { + if !crate::android::accessibility::get_android_accessibility_status().enabled { log::info!("[android-insert] accessibility service not enabled"); return None; } if !matches!(inserter.copy_fallback(text), InsertStatus::CopiedFallback) { return None; } - if crate::android_accessibility::paste_via_accessibility() { + if crate::android::accessibility::paste_via_accessibility() { Some(InsertStatus::Inserted) } else { log::warn!("[android-insert] accessibility paste failed; text remains on clipboard"); @@ -42,8 +43,8 @@ fn try_accessibility(inserter: &TextInserter, text: &str) -> Option InsertStatus { let status = inserter.copy_fallback(text); if matches!(status, InsertStatus::CopiedFallback) { - let _ = crate::android_jni::android::with_android_env(|env, context| { - crate::android_jni::android::show_overlay_toast(env, context, "已复制到剪贴板") + let _ = crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::show_overlay_toast(env, context, "已复制到剪贴板") }); } status diff --git a/openless-all/app/src-tauri/src/android_jni.rs b/openless-all/app/src-tauri/src/android/jni.rs similarity index 100% rename from openless-all/app/src-tauri/src/android_jni.rs rename to openless-all/app/src-tauri/src/android/jni.rs diff --git a/openless-all/app/src-tauri/src/android/mod.rs b/openless-all/app/src-tauri/src/android/mod.rs new file mode 100644 index 00000000..7fe68f46 --- /dev/null +++ b/openless-all/app/src-tauri/src/android/mod.rs @@ -0,0 +1,24 @@ +//! Android platform integration (JNI, overlay, accessibility, insert). + +pub mod accessibility; +#[cfg(target_os = "android")] +pub mod insert; +pub mod jni; +pub mod native_bridge; +pub mod overlay; +pub mod types; + +pub use accessibility::{ + get_android_accessibility_status, paste_via_accessibility, + request_android_accessibility_permission, AndroidAccessibilityPermissionResult, +}; +pub use native_bridge::{ + hide_overlay, is_overlay_visible, notify_capsule_state, refresh_overlay_if_visible, + register_android_coordinator, show_overlay, +}; +pub use overlay::{ + get_android_overlay_status, hide_android_overlay, refresh_android_overlay_if_visible, + request_android_overlay_permission, show_android_overlay, AndroidOverlayPermissionResult, +}; +#[cfg(target_os = "android")] +pub use insert::android_insert_with_strategy; diff --git a/openless-all/app/src-tauri/src/android_native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs similarity index 93% rename from openless-all/app/src-tauri/src/android_native_bridge.rs rename to openless-all/app/src-tauri/src/android/native_bridge.rs index d04d2088..c8fdb657 100644 --- a/openless-all/app/src-tauri/src/android_native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -17,8 +17,8 @@ pub fn notify_capsule_state(payload: &CapsulePayload) { { let state = capsule_state_name(payload.state); let message = payload.message.as_deref(); - if let Err(error) = crate::android_jni::android::with_android_env(|env, context| { - crate::android_jni::android::notify_overlay_bridge(env, context, state, message) + if let Err(error) = crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::notify_overlay_bridge(env, context, state, message) }) { log::warn!("[android-native] notify overlay bridge failed: {error}"); } @@ -29,7 +29,7 @@ pub fn notify_capsule_state(payload: &CapsulePayload) { pub fn show_overlay() -> Result<(), String> { #[cfg(target_os = "android")] { - crate::android_jni::android::with_android_env(|env, context| { + crate::android::jni::android::with_android_env(|env, context| { show_overlay_with_context(env, context) })?; } @@ -42,7 +42,7 @@ pub fn hide_overlay() -> Result<(), String> { } #[cfg(target_os = "android")] { - crate::android_jni::android::with_android_env(|env, context| { + crate::android::jni::android::with_android_env(|env, context| { hide_overlay_with_context(env, context) })?; } @@ -61,7 +61,7 @@ fn show_overlay_with_context( env: &mut jni::JNIEnv, context: &jni::objects::JObject, ) -> Result<(), String> { - crate::android_jni::android::start_service_action( + crate::android::jni::android::start_service_action( env, context, "com.openless.app.OpenLessOverlayService", @@ -76,7 +76,7 @@ fn hide_overlay_with_context( env: &mut jni::JNIEnv, context: &jni::objects::JObject, ) -> Result<(), String> { - crate::android_jni::android::start_service_action( + crate::android::jni::android::start_service_action( env, context, "com.openless.app.OpenLessOverlayService", @@ -317,10 +317,10 @@ mod jni_exports { context: JObject, ) -> jboolean { let visible = with_jni_context(env, context, |env, context| { - crate::android_jni::android::can_draw_overlays(env, context) + crate::android::jni::android::can_draw_overlays(env, context) }) .unwrap_or(false); - crate::android_jni::android::export_jboolean(visible) + crate::android::jni::android::export_jboolean(visible) } #[no_mangle] @@ -328,7 +328,7 @@ mod jni_exports { _env: *mut JNIEnv, _class: JClass, ) -> jboolean { - crate::android_jni::android::export_jboolean(is_overlay_visible()) + crate::android::jni::android::export_jboolean(is_overlay_visible()) } #[no_mangle] @@ -338,7 +338,7 @@ mod jni_exports { ) -> jstring { let mode = overlay_trigger_mode_name(); match JniEnv::from_raw(env) { - Ok(mut env) => crate::android_jni::android::export_jstring(&mut env, mode), + Ok(mut env) => crate::android::jni::android::export_jstring(&mut env, mode), Err(_) => std::ptr::null_mut(), } } diff --git a/openless-all/app/src-tauri/src/android_overlay.rs b/openless-all/app/src-tauri/src/android/overlay.rs similarity index 79% rename from openless-all/app/src-tauri/src/android_overlay.rs rename to openless-all/app/src-tauri/src/android/overlay.rs index c4fc057c..35ed7761 100644 --- a/openless-all/app/src-tauri/src/android_overlay.rs +++ b/openless-all/app/src-tauri/src/android/overlay.rs @@ -2,7 +2,7 @@ use serde::Serialize; -use crate::types::AndroidOverlayStatus; +use crate::android::types::AndroidOverlayStatus; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -19,7 +19,7 @@ pub fn get_android_overlay_status() -> AndroidOverlayStatus { #[cfg(not(target_os = "android"))] { - use crate::types::AndroidOverlayPermissionState; + use crate::android::types::AndroidOverlayPermissionState; AndroidOverlayStatus { permission: AndroidOverlayPermissionState::NotAndroid, @@ -47,7 +47,7 @@ pub fn request_android_overlay_permission() -> AndroidOverlayPermissionResult { pub fn show_android_overlay() -> Result<(), String> { #[cfg(target_os = "android")] { - return crate::android_native_bridge::show_overlay(); + return crate::android::native_bridge::show_overlay(); } #[cfg(not(target_os = "android"))] { @@ -58,7 +58,7 @@ pub fn show_android_overlay() -> Result<(), String> { pub fn hide_android_overlay() -> Result<(), String> { #[cfg(target_os = "android")] { - return crate::android_native_bridge::hide_overlay(); + return crate::android::native_bridge::hide_overlay(); } #[cfg(not(target_os = "android"))] { @@ -69,7 +69,7 @@ pub fn hide_android_overlay() -> Result<(), String> { pub fn refresh_android_overlay_if_visible() -> Result<(), String> { #[cfg(target_os = "android")] { - return crate::android_native_bridge::refresh_overlay_if_visible(); + return crate::android::native_bridge::refresh_overlay_if_visible(); } #[cfg(not(target_os = "android"))] { @@ -80,11 +80,11 @@ pub fn refresh_android_overlay_if_visible() -> Result<(), String> { #[cfg(target_os = "android")] mod android_impl { use super::{AndroidOverlayPermissionResult, AndroidOverlayStatus}; - use crate::types::{AndroidOverlayPermissionState, AndroidOverlayStatus as Status}; + use crate::android::types::{AndroidOverlayPermissionState, AndroidOverlayStatus as Status}; pub fn get_android_overlay_status() -> AndroidOverlayStatus { - let granted = crate::android_jni::android::with_android_env(|env, context| { - crate::android_jni::android::can_draw_overlays(env, context) + let granted = crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::can_draw_overlays(env, context) }) .unwrap_or(false); Status { @@ -93,7 +93,7 @@ mod android_impl { } else { AndroidOverlayPermissionState::NotGranted }, - overlay_visible: crate::android_native_bridge::is_overlay_visible(), + overlay_visible: crate::android::native_bridge::is_overlay_visible(), message: if granted { "悬浮窗权限已授予".to_string() } else { @@ -103,8 +103,8 @@ mod android_impl { } pub fn request_android_overlay_permission() -> AndroidOverlayPermissionResult { - match crate::android_jni::android::with_android_env(|env, context| { - crate::android_jni::android::start_activity_class( + match crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::start_activity_class( env, context, "com.openless.app.OverlayPermissionActivity", diff --git a/openless-all/app/src-tauri/src/android/types.rs b/openless-all/app/src-tauri/src/android/types.rs new file mode 100644 index 00000000..a734352e --- /dev/null +++ b/openless-all/app/src-tauri/src/android/types.rs @@ -0,0 +1,108 @@ +//! Android-specific preference types and status payloads. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AndroidInsertStrategy { + Auto, + Ime, + Accessibility, + Clipboard, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AndroidOverlayTrigger { + Background, + Keyboard, + Always, +} + +impl AndroidOverlayTrigger { + pub fn normalized(self) -> Self { + match self { + AndroidOverlayTrigger::Keyboard => AndroidOverlayTrigger::Background, + trigger => trigger, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AndroidOverlayActivationMode { + Tap, + LongPress, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AndroidOverlayLeftSwipeAction { + Translation, + StylePack, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AndroidAccessibilityState { + Enabled, + NotEnabled, + NotAndroid, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AndroidAccessibilityStatus { + pub state: AndroidAccessibilityState, + pub enabled: bool, + pub message: String, +} + +pub fn default_android_insert_strategy() -> AndroidInsertStrategy { + AndroidInsertStrategy::Accessibility +} + +pub fn default_android_overlay_trigger() -> AndroidOverlayTrigger { + AndroidOverlayTrigger::Background +} + +pub fn default_android_overlay_activation_mode() -> AndroidOverlayActivationMode { + AndroidOverlayActivationMode::Tap +} + +pub fn default_android_overlay_left_swipe_action() -> AndroidOverlayLeftSwipeAction { + AndroidOverlayLeftSwipeAction::Translation +} + +pub fn default_android_overlay_size_dp() -> u32 { + 72 +} + +pub fn normalize_android_insert_strategy(strategy: AndroidInsertStrategy) -> AndroidInsertStrategy { + match strategy { + AndroidInsertStrategy::Auto | AndroidInsertStrategy::Ime => { + AndroidInsertStrategy::Accessibility + } + strategy => strategy, + } +} + +pub fn normalize_android_overlay_size_dp(size_dp: u32) -> u32 { + size_dp.clamp(48, 120) +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AndroidOverlayPermissionState { + Granted, + NotGranted, + NotAndroid, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AndroidOverlayStatus { + pub permission: AndroidOverlayPermissionState, + pub overlay_visible: bool, + pub message: String, +} diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 7ed871dd..5cb87160 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -672,34 +672,34 @@ pub fn get_platform_capabilities() -> PlatformCapabilities { #[tauri::command] pub fn get_android_overlay_status() -> AndroidOverlayStatus { - crate::android_overlay::get_android_overlay_status() + crate::android::get_android_overlay_status() } #[tauri::command] -pub fn request_android_overlay_permission() -> crate::android_overlay::AndroidOverlayPermissionResult +pub fn request_android_overlay_permission() -> crate::android::AndroidOverlayPermissionResult { - crate::android_overlay::request_android_overlay_permission() + crate::android::request_android_overlay_permission() } #[tauri::command] pub fn show_android_overlay() -> Result<(), String> { - crate::android_overlay::show_android_overlay() + crate::android::show_android_overlay() } #[tauri::command] pub fn hide_android_overlay() -> Result<(), String> { - crate::android_overlay::hide_android_overlay() + crate::android::hide_android_overlay() } #[tauri::command] pub fn get_android_accessibility_status() -> crate::types::AndroidAccessibilityStatus { - crate::android_accessibility::get_android_accessibility_status() + crate::android::get_android_accessibility_status() } #[tauri::command] pub fn request_android_accessibility_permission( -) -> crate::android_accessibility::AndroidAccessibilityPermissionResult { - crate::android_accessibility::request_android_accessibility_permission() +) -> crate::android::AndroidAccessibilityPermissionResult { + crate::android::request_android_accessibility_permission() } #[tauri::command] diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index aca54c7f..4f10394b 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -519,10 +519,10 @@ impl Coordinator { use crate::types::AndroidOverlayTrigger; match self.android_overlay_trigger() { AndroidOverlayTrigger::Always => { - let _ = crate::android_overlay::show_android_overlay(); + let _ = crate::android::show_android_overlay(); } AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard => { - let _ = crate::android_overlay::hide_android_overlay(); + let _ = crate::android::hide_android_overlay(); } } } @@ -531,7 +531,7 @@ impl Coordinator { pub fn apply_android_overlay_size(&self) { #[cfg(target_os = "android")] { - let _ = crate::android_overlay::refresh_android_overlay_if_visible(); + let _ = crate::android::refresh_android_overlay_if_visible(); } } @@ -6194,7 +6194,7 @@ fn emit_capsule( }; #[cfg(target_os = "android")] - crate::android_native_bridge::notify_capsule_state(&payload); + crate::android::notify_capsule_state(&payload); // visible / translation 是「这一帧 capsule:state event 的 payload」内容 —— // 必须在 call-site(即音频线程触发 emit_capsule 时)就算定,否则 main thread diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 472cfcec..df0050fc 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -2235,7 +2235,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } else { #[cfg(target_os = "android")] { - crate::android_insert::android_insert_with_strategy( + crate::android::android_insert_with_strategy( &inner.inserter, &polished, inner.prefs.get().android_insert_strategy, diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index e4be3e35..d4c5ac18 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -215,8 +215,8 @@ fn copy_to_clipboard(text: &str) -> bool { fn copy_to_clipboard(text: &str) -> bool { #[cfg(target_os = "android")] { - return crate::android_jni::android::with_android_env(|env, context| { - crate::android_jni::android::copy_to_clipboard(env, context, text) + return crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::copy_to_clipboard(env, context, text) }) .unwrap_or_else(|error| { log::error!("[insertion] android clipboard failed: {error}"); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 6534f896..d9c8ebc0 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -14,14 +14,7 @@ //! - coordinator: dictation state machine glue //! - commands: Tauri IPC surface -mod android_accessibility; -#[cfg(target_os = "android")] -mod android_insert; -#[cfg(target_os = "android")] -mod android_jni; -#[cfg(target_os = "android")] -mod android_native_bridge; -mod android_overlay; +mod android; mod asr; mod audio_mute; mod cli; @@ -1422,8 +1415,8 @@ pub(crate) fn show_qa_window(app: &AppHandle, content_kind const FLAG_ACTIVITY_SINGLE_TOP: i32 = 0x20000000; let flags = FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_REORDER_TO_FRONT | FLAG_ACTIVITY_SINGLE_TOP; - match crate::android_jni::android::with_android_env(|env, context| { - crate::android_jni::android::start_activity_class_with_flags( + match crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::start_activity_class_with_flags( env, context, "com.openless.app.MainActivity", diff --git a/openless-all/app/src-tauri/src/mobile_runtime.rs b/openless-all/app/src-tauri/src/mobile_runtime.rs index d810aa09..19085239 100644 --- a/openless-all/app/src-tauri/src/mobile_runtime.rs +++ b/openless-all/app/src-tauri/src/mobile_runtime.rs @@ -30,7 +30,7 @@ pub fn run() { coordinator.bind_app(app.handle().clone()); #[cfg(target_os = "android")] { - crate::android_native_bridge::register_android_coordinator(coordinator.clone()); + crate::android::register_android_coordinator(coordinator.clone()); coordinator.apply_android_overlay_trigger(); } Ok(()) diff --git a/openless-all/app/src-tauri/src/mobile_stubs/selection.rs b/openless-all/app/src-tauri/src/mobile_stubs/selection.rs index 8caac9c4..ef48faa6 100644 --- a/openless-all/app/src-tauri/src/mobile_stubs/selection.rs +++ b/openless-all/app/src-tauri/src/mobile_stubs/selection.rs @@ -13,8 +13,8 @@ pub struct SelectionContext { #[cfg(target_os = "android")] pub fn capture_selection() -> Option { - let text = match crate::android_jni::android::with_android_env(|env, context| { - crate::android_jni::android::accessibility_selected_text(env, context) + let text = match crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::accessibility_selected_text(env, context) }) { Ok(Some(text)) => text, Ok(None) => return None, diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 00563c9f..0dfd8b07 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -3,6 +3,18 @@ use serde::{Deserialize, Serialize}; +pub use crate::android::types::{ + AndroidAccessibilityState, AndroidAccessibilityStatus, AndroidInsertStrategy, + AndroidOverlayActivationMode, AndroidOverlayLeftSwipeAction, AndroidOverlayPermissionState, + AndroidOverlayStatus, AndroidOverlayTrigger, +}; +use crate::android::types::{ + default_android_insert_strategy, default_android_overlay_activation_mode, + default_android_overlay_left_swipe_action, default_android_overlay_size_dp, + default_android_overlay_trigger, normalize_android_insert_strategy, + normalize_android_overlay_size_dp, +}; + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] #[derive(Default)] @@ -1104,15 +1116,6 @@ impl<'de> Deserialize<'de> for UserPreferences { } } -fn normalize_android_insert_strategy(strategy: AndroidInsertStrategy) -> AndroidInsertStrategy { - match strategy { - AndroidInsertStrategy::Auto | AndroidInsertStrategy::Ime => { - AndroidInsertStrategy::Accessibility - } - strategy => strategy, - } -} - fn default_qa_hotkey() -> Option { Some(ShortcutBinding::default_qa()) } @@ -2329,102 +2332,6 @@ pub struct WindowsImeStatus { pub dll_path: Option, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum AndroidInsertStrategy { - Auto, - Ime, - Accessibility, - Clipboard, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum AndroidOverlayTrigger { - Background, - Keyboard, - Always, -} - -impl AndroidOverlayTrigger { - pub fn normalized(self) -> Self { - match self { - AndroidOverlayTrigger::Keyboard => AndroidOverlayTrigger::Background, - trigger => trigger, - } - } -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum AndroidOverlayActivationMode { - Tap, - LongPress, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum AndroidOverlayLeftSwipeAction { - Translation, - StylePack, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum AndroidAccessibilityState { - Enabled, - NotEnabled, - NotAndroid, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct AndroidAccessibilityStatus { - pub state: AndroidAccessibilityState, - pub enabled: bool, - pub message: String, -} - -fn default_android_insert_strategy() -> AndroidInsertStrategy { - AndroidInsertStrategy::Accessibility -} - -fn default_android_overlay_trigger() -> AndroidOverlayTrigger { - AndroidOverlayTrigger::Background -} - -fn default_android_overlay_activation_mode() -> AndroidOverlayActivationMode { - AndroidOverlayActivationMode::Tap -} - -fn default_android_overlay_left_swipe_action() -> AndroidOverlayLeftSwipeAction { - AndroidOverlayLeftSwipeAction::Translation -} - -fn default_android_overlay_size_dp() -> u32 { - 72 -} - -fn normalize_android_overlay_size_dp(size_dp: u32) -> u32 { - size_dp.clamp(48, 120) -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum AndroidOverlayPermissionState { - Granted, - NotGranted, - NotAndroid, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct AndroidOverlayStatus { - pub permission: AndroidOverlayPermissionState, - pub overlay_visible: bool, - pub message: String, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PlatformCapabilities { diff --git a/openless-all/app/src/lib/androidMicrophonePermission.ts b/openless-all/app/src/lib/androidMicrophonePermission.ts index 12c32cb4..74490bd6 100644 --- a/openless-all/app/src/lib/androidMicrophonePermission.ts +++ b/openless-all/app/src/lib/androidMicrophonePermission.ts @@ -1,94 +1 @@ -import type { PermissionStatus as AppPermissionStatus } from './types'; -import { checkMicrophonePermission, requestMicrophonePermission } from './ipc'; - -const ANDROID_MIC_GRANTED_KEY = 'openless.androidMicrophoneGranted'; - -export async function checkAndroidMicrophoneAccess(): Promise { - try { - const nativeStatus = await checkMicrophonePermission(); - if (nativeStatus === 'granted' || nativeStatus === 'notApplicable') { - localStorage.setItem(ANDROID_MIC_GRANTED_KEY, '1'); - return 'granted'; - } - if (nativeStatus === 'denied' || nativeStatus === 'restricted') { - localStorage.removeItem(ANDROID_MIC_GRANTED_KEY); - return nativeStatus; - } - } catch { - // Fall through to WebView-local checks below. - } - - if (localStorage.getItem(ANDROID_MIC_GRANTED_KEY) === '1') { - return 'granted'; - } - - try { - const permissions = navigator.permissions; - if (permissions?.query) { - const status = await permissions.query({ name: 'microphone' as PermissionName }); - if (status.state === 'granted') return 'granted'; - if (status.state === 'denied') { - localStorage.removeItem(ANDROID_MIC_GRANTED_KEY); - return 'denied'; - } - } - } catch { - // Android WebView versions differ on navigator.permissions support. - } - - return 'notDetermined'; -} - -export async function requestAndroidMicrophoneAccess(): Promise { - try { - const nativeStatus = await requestMicrophonePermission(); - if (nativeStatus === 'granted' || nativeStatus === 'notApplicable') { - localStorage.setItem(ANDROID_MIC_GRANTED_KEY, '1'); - return 'granted'; - } - if (nativeStatus === 'denied' || nativeStatus === 'restricted') { - localStorage.removeItem(ANDROID_MIC_GRANTED_KEY); - return nativeStatus; - } - } catch { - // Fall through to WebView-local checks below. - } - - if (localStorage.getItem(ANDROID_MIC_GRANTED_KEY) === '1') { - return 'granted'; - } - - try { - const permissions = navigator.permissions; - if (permissions?.query) { - const status = await permissions.query({ name: 'microphone' as PermissionName }); - if (status.state === 'granted') { - localStorage.setItem(ANDROID_MIC_GRANTED_KEY, '1'); - return 'granted'; - } - if (status.state === 'denied') { - localStorage.removeItem(ANDROID_MIC_GRANTED_KEY); - return 'denied'; - } - } - } catch { - // Android WebView versions differ on navigator.permissions support. - } - - const mediaDevices = navigator.mediaDevices; - if (!mediaDevices?.getUserMedia) { - return 'notDetermined'; - } - - let stream: MediaStream | null = null; - try { - localStorage.setItem(ANDROID_MIC_GRANTED_KEY, '1'); - stream = await mediaDevices.getUserMedia({ audio: true }); - return 'granted'; - } catch (error) { - console.warn('[android-mic] WebView microphone permission request failed', error); - return 'granted'; - } finally { - stream?.getTracks().forEach(track => track.stop()); - } -} +export * from '@android/lib/androidMicrophonePermission'; diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 5a6dfea8..b7075db7 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -29,10 +29,6 @@ import type { UserPreferences, VocabPresetStore, WindowsImeStatus, - AndroidOverlayStatus, - AndroidAccessibilityStatus, - AndroidInsertStrategy, - AndroidOverlayTrigger, } from "./types" export type { UpdateChannel, PlatformCapabilities } from "./types" import { OL_DATA } from "./mockData" @@ -66,43 +62,14 @@ export async function getPlatformCapabilities(): Promise { return platformCapabilities() } -export function getAndroidOverlayStatus(): Promise { - return invokeOrMock("get_android_overlay_status", undefined, () => ({ - permission: "notAndroid", - overlayVisible: false, - message: "Android overlay is only available on Android", - })) -} - -export function requestAndroidOverlayPermission(): Promise<{ launched: boolean; message: string }> { - return invokeOrMock("request_android_overlay_permission", undefined, () => ({ - launched: false, - message: "Mock: overlay permission unavailable in browser preview", - })) -} - -export function showAndroidOverlay(): Promise { - return invokeOrMock("show_android_overlay", undefined, () => undefined) -} - -export function hideAndroidOverlay(): Promise { - return invokeOrMock("hide_android_overlay", undefined, () => undefined) -} - -export function getAndroidAccessibilityStatus(): Promise { - return invokeOrMock("get_android_accessibility_status", undefined, () => ({ - state: "notAndroid", - enabled: false, - message: "Android accessibility is only available on Android", - })) -} - -export function requestAndroidAccessibilityPermission(): Promise<{ launched: boolean; message: string }> { - return invokeOrMock("request_android_accessibility_permission", undefined, () => ({ - launched: false, - message: "Mock: accessibility settings unavailable in browser preview", - })) -} +export { + getAndroidOverlayStatus, + requestAndroidOverlayPermission, + showAndroidOverlay, + hideAndroidOverlay, + getAndroidAccessibilityStatus, + requestAndroidAccessibilityPermission, +} from '../../android/frontend/lib/androidIpc'; export { isAndroid, isDesktop, isMobile } from "./platform" diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index e1a7eaf3..f707c885 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -2,6 +2,24 @@ // All keys are camelCase (Rust serializes with #[serde(rename_all = "camelCase")]). // PolishMode is an exception — Rust uses lowercase serialization. +import type { + AndroidAccessibilityStatus, + AndroidInsertStrategy, + AndroidOverlayActivationMode, + AndroidOverlayLeftSwipeAction, + AndroidOverlayStatus, + AndroidOverlayTrigger, +} from '../../android/frontend/lib/androidTypes'; + +export type { + AndroidAccessibilityStatus, + AndroidInsertStrategy, + AndroidOverlayActivationMode, + AndroidOverlayLeftSwipeAction, + AndroidOverlayStatus, + AndroidOverlayTrigger, +}; + export type PolishMode = 'raw' | 'light' | 'structured' | 'formal'; export type InsertStatus = 'inserted' | 'pasteSent' | 'copiedFallback' | 'failed'; @@ -350,23 +368,6 @@ export interface UserPreferences { androidOverlaySizeDp: number; } -export type AndroidInsertStrategy = 'accessibility' | 'clipboard'; -export type AndroidOverlayTrigger = 'background' | 'keyboard' | 'always'; -export type AndroidOverlayActivationMode = 'tap' | 'long_press'; -export type AndroidOverlayLeftSwipeAction = 'translation' | 'style_pack'; - -export interface AndroidOverlayStatus { - permission: 'granted' | 'notGranted' | 'notAndroid'; - overlayVisible: boolean; - message: string; -} - -export interface AndroidAccessibilityStatus { - state: 'enabled' | 'notEnabled' | 'notAndroid'; - enabled: boolean; - message: string; -} - export interface MarketplaceListItem { id: string; slug: string; diff --git a/openless-all/app/src/pages/settings/PermissionsSection.tsx b/openless-all/app/src/pages/settings/PermissionsSection.tsx index cdaacf44..78ace45c 100644 --- a/openless-all/app/src/pages/settings/PermissionsSection.tsx +++ b/openless-all/app/src/pages/settings/PermissionsSection.tsx @@ -3,37 +3,25 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { AndroidPermissionsPanel } from '@android/components/AndroidPermissionsPanel'; import { Icon } from '../../components/Icon'; import { checkAccessibilityPermission, checkMicrophonePermission, checkNetwork, - getAndroidAccessibilityStatus, - getAndroidOverlayStatus, getHotkeyStatus, - getSettings, getWindowsImeStatus, openSystemSettings, requestAccessibilityPermission, - requestAndroidAccessibilityPermission, - requestAndroidOverlayPermission, requestMicrophonePermission, - setSettings, } from '../../lib/ipc'; import type { NetworkCheckResult } from '../../lib/ipc'; import { getPlatformCapabilities } from '../../lib/platform'; -import { checkAndroidMicrophoneAccess, requestAndroidMicrophoneAccess } from '../../lib/androidMicrophonePermission'; +import { checkAndroidMicrophoneAccess, requestAndroidMicrophoneAccess } from '@android/lib/androidMicrophonePermission'; import type { - AndroidAccessibilityStatus, - AndroidOverlayActivationMode, - AndroidOverlayLeftSwipeAction, - AndroidInsertStrategy, - AndroidOverlayStatus, - AndroidOverlayTrigger, HotkeyStatus, PermissionStatus, PlatformCapabilities, - UserPreferences, WindowsImeStatus, } from '../../lib/types'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; @@ -48,41 +36,12 @@ export function PermissionsSection() { const [windowsIme, setWindowsIme] = useState(null); const [network, setNetwork] = useState(null); const [platformCaps, setPlatformCaps] = useState(null); - const [androidOverlay, setAndroidOverlay] = useState(null); - const [androidAccessibility, setAndroidAccessibility] = useState(null); - const [androidPrefs, setAndroidPrefs] = useState | null>(null); const { capability } = useHotkeySettings(); useEffect(() => { void getPlatformCapabilities().then(setPlatformCaps); }, []); - const refreshAndroid = async () => { - if (platformCaps?.platform !== 'android') return; - const [overlay, accessibility, settings] = await Promise.all([ - getAndroidOverlayStatus(), - getAndroidAccessibilityStatus(), - getSettings(), - ]); - let migratedSettings = settings; - if (settings.androidOverlayTrigger === 'keyboard') { - migratedSettings = { - ...settings, - androidOverlayTrigger: normalizeAndroidOverlayTrigger(settings.androidOverlayTrigger), - }; - await setSettings(migratedSettings); - } - setAndroidOverlay(overlay); - setAndroidAccessibility(accessibility); - setAndroidPrefs({ - androidInsertStrategy: migratedSettings.androidInsertStrategy, - androidOverlayTrigger: migratedSettings.androidOverlayTrigger, - androidOverlayActivationMode: migratedSettings.androidOverlayActivationMode, - androidOverlayLeftSwipeAction: migratedSettings.androidOverlayLeftSwipeAction, - androidOverlaySizeDp: migratedSettings.androidOverlaySizeDp, - }); - }; - const refreshPermissions = async () => { const [a, m] = await Promise.all([ checkAccessibilityPermission(), @@ -117,8 +76,6 @@ export function PermissionsSection() { } if (platformCaps?.platform !== 'android') { refreshWindowsIme(); - } else { - refreshAndroid(); } refreshNetwork(); const hotkeyId = platformCaps?.supportsDesktopHotkey === true @@ -126,9 +83,6 @@ export function PermissionsSection() { : undefined; // 麦克风检查会短暂打开输入流,避免每秒探测导致隐私指示器频繁闪烁。 const permissionId = window.setInterval(refreshPermissions, 10000); - const androidId = platformCaps?.platform === 'android' - ? window.setInterval(refreshAndroid, 3000) - : undefined; const networkId = window.setInterval(refreshNetwork, 30000); const onFocus = () => { refreshPermissions(); @@ -137,8 +91,6 @@ export function PermissionsSection() { } if (platformCaps?.platform !== 'android') { refreshWindowsIme(); - } else { - refreshAndroid(); } refreshNetwork(); }; @@ -146,7 +98,6 @@ export function PermissionsSection() { return () => { if (hotkeyId !== undefined) window.clearInterval(hotkeyId); window.clearInterval(permissionId); - if (androidId !== undefined) window.clearInterval(androidId); window.clearInterval(networkId); window.removeEventListener('focus', onFocus); }; @@ -173,26 +124,6 @@ export function PermissionsSection() { refreshPermissions(); }; - const updateAndroidPref = async (key: K, value: UserPreferences[K]) => { - const settings = await getSettings(); - const nextValue = key === 'androidOverlayTrigger' - ? normalizeAndroidOverlayTrigger(value as AndroidOverlayTrigger) - : value; - const next = { - ...settings, - [key]: nextValue, - }; - await setSettings(next); - setAndroidPrefs({ - androidInsertStrategy: next.androidInsertStrategy, - androidOverlayTrigger: next.androidOverlayTrigger, - androidOverlayActivationMode: next.androidOverlayActivationMode, - androidOverlayLeftSwipeAction: next.androidOverlayLeftSwipeAction, - androidOverlaySizeDp: next.androidOverlaySizeDp, - }); - await refreshAndroid(); - }; - return (
{t('settings.permissions.title')}
@@ -235,130 +166,7 @@ export function PermissionsSection() { )} {platformCaps?.supportsOverlay && platformCaps.platform === 'android' && ( - <> - -
- {androidOverlay?.message && ( - - {androidOverlay.message} - - )} - - {androidOverlay?.permission !== 'granted' && ( - { void requestAndroidOverlayPermission().then(refreshAndroid); }}> - {t('settings.permissions.grant')} - - )} -
-
- -
-
- {androidAccessibility?.message && ( - - {androidAccessibility.message} - - )} - - {!androidAccessibility?.enabled && ( - { void requestAndroidAccessibilityPermission().then(refreshAndroid); }}> - {t('settings.permissions.openSystem')} - - )} -
- - {t('settings.permissions.androidAccessibilityImpact')} - -
-
- -
- - - {t(`settings.permissions.androidInsertStrategyHint.${androidPrefs?.androidInsertStrategy ?? 'accessibility'}`)} - -
-
- -
- - - {t(`settings.permissions.androidOverlayTriggerHint.${androidPrefs?.androidOverlayTrigger ?? 'background'}`)} - - - {t('settings.permissions.androidOverlayTriggerDisabled.keyboard')} - -
-
- -
- - - {t(`settings.permissions.androidOverlayActivationModeHint.${androidPrefs?.androidOverlayActivationMode ?? 'tap'}`)} - -
-
- -
- - - {t(`settings.permissions.androidOverlayLeftSwipeActionHint.${androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}`)} - -
-
- -
-
- { - void updateAndroidPref('androidOverlaySizeDp', clampAndroidOverlaySize(Number(event.target.value))); - }} - style={{ width: 132 }} - /> - - {androidPrefs?.androidOverlaySizeDp ?? 72} dp - -
- - {t('settings.permissions.androidOverlaySizeHint')} - -
-
- + )} {windowsIme?.state !== 'notWindows' && platformCaps?.platform !== 'android' && ( @@ -395,22 +203,6 @@ export function PermissionsSection() { ); } -type AndroidPreferenceKey = - | 'androidInsertStrategy' - | 'androidOverlayTrigger' - | 'androidOverlayActivationMode' - | 'androidOverlayLeftSwipeAction' - | 'androidOverlaySizeDp'; - -function normalizeAndroidOverlayTrigger(trigger: AndroidOverlayTrigger): AndroidOverlayTrigger { - return trigger === 'keyboard' ? 'background' : trigger; -} - -function clampAndroidOverlaySize(size: number): number { - if (!Number.isFinite(size)) return 72; - return Math.min(120, Math.max(48, Math.round(size / 4) * 4)); -} - function PermissionPill({ status }: { status: PermissionStatus | 'loading' }) { const { t } = useTranslation(); if (status === 'loading') { @@ -463,21 +255,3 @@ function NetworkStatusPill({ status }: { status: NetworkCheckResult | null }) { } return {t('settings.permissions.networkOffline') ?? '不可用'}; } - -function AndroidOverlayStatusPill({ status }: { status: AndroidOverlayStatus | null }) { - const { t } = useTranslation(); - if (!status) return {t('settings.permissions.checking')}; - if (status.permission === 'granted') { - return {t('settings.permissions.granted')}; - } - return {t('settings.permissions.denied')}; -} - -function AndroidAccessibilityStatusPill({ status }: { status: AndroidAccessibilityStatus | null }) { - const { t } = useTranslation(); - if (!status) return {t('settings.permissions.checking')}; - if (status.enabled) { - return {t('settings.permissions.granted')}; - } - return {t('settings.permissions.denied')}; -} diff --git a/openless-all/app/tsconfig.json b/openless-all/app/tsconfig.json index 17f43b17..8437b088 100644 --- a/openless-all/app/tsconfig.json +++ b/openless-all/app/tsconfig.json @@ -14,8 +14,12 @@ "strict": true, "noUnusedLocals": false, "noUnusedParameters": false, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@android/*": ["android/frontend/*"] + } }, - "include": ["src"], + "include": ["src", "android/frontend"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/openless-all/app/vite.config.ts b/openless-all/app/vite.config.ts index 9895e621..e7aca335 100644 --- a/openless-all/app/vite.config.ts +++ b/openless-all/app/vite.config.ts @@ -1,6 +1,10 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +const appRoot = fileURLToPath(new URL(".", import.meta.url)); + const host = process.env.TAURI_DEV_HOST; const isMobileDev = process.env.TAURI_ENV_PLATFORM === "android" || @@ -8,6 +12,11 @@ const isMobileDev = export default defineConfig(async () => ({ plugins: [react()], + resolve: { + alias: { + "@android": path.resolve(appRoot, "android/frontend"), + }, + }, clearScreen: false, server: { port: 1420, From 237a60ace9e33f52939def0329088f2ac81ce51d Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Thu, 11 Jun 2026 00:33:22 +0800 Subject: [PATCH 78/83] fix(ci): include android types via path for backend-tests harness Load android/types.rs from types.rs directly so backend-tests can compile types without the full android module tree. Co-authored-by: Cursor --- openless-all/app/src-tauri/src/android/mod.rs | 2 +- openless-all/app/src-tauri/src/lib.rs | 2 +- openless-all/app/src-tauri/src/types.rs | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/src/android/mod.rs b/openless-all/app/src-tauri/src/android/mod.rs index 7fe68f46..3b3fd2cb 100644 --- a/openless-all/app/src-tauri/src/android/mod.rs +++ b/openless-all/app/src-tauri/src/android/mod.rs @@ -6,7 +6,7 @@ pub mod insert; pub mod jni; pub mod native_bridge; pub mod overlay; -pub mod types; +pub use crate::types::android_types as types; pub use accessibility::{ get_android_accessibility_status, paste_via_accessibility, diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index d9c8ebc0..5ac690d3 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ //! - coordinator: dictation state machine glue //! - commands: Tauri IPC surface +mod types; mod android; mod asr; mod audio_mute; @@ -64,7 +65,6 @@ mod shortcut_binding; #[cfg(mobile)] #[path = "mobile_stubs/shortcut_binding.rs"] mod shortcut_binding; -mod types; #[cfg(not(mobile))] mod unicode_keystroke; #[cfg(mobile)] diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 0dfd8b07..26c64f67 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -3,12 +3,15 @@ use serde::{Deserialize, Serialize}; -pub use crate::android::types::{ +#[path = "android/types.rs"] +pub mod android_types; + +pub use android_types::{ AndroidAccessibilityState, AndroidAccessibilityStatus, AndroidInsertStrategy, AndroidOverlayActivationMode, AndroidOverlayLeftSwipeAction, AndroidOverlayPermissionState, AndroidOverlayStatus, AndroidOverlayTrigger, }; -use crate::android::types::{ +use android_types::{ default_android_insert_strategy, default_android_overlay_activation_mode, default_android_overlay_left_swipe_action, default_android_overlay_size_dp, default_android_overlay_trigger, normalize_android_insert_strategy, From 58c7c91ce36b60f91c2f78e44648bf50776e4669 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Thu, 11 Jun 2026 12:20:21 +0800 Subject: [PATCH 79/83] chore(android): remove migrated android-scaffolding from git index Stage deletions left over from the android/ directory migration so the working tree matches the relocated kotlin and manifest sources. Co-authored-by: Cursor --- .../AndroidManifest.v1.snippet.xml | 7 - .../AndroidManifest.v3.snippet.xml | 20 - .../OpenLessAccessibilityCommandReceiver.kt | 21 - .../OpenLessAccessibilityService.kt | 252 ------- .../OpenLessAndroidPreferences.kt | 99 --- .../android-scaffolding/OpenLessAppContext.kt | 13 - .../OpenLessApplication.kt | 84 --- .../android-scaffolding/OpenLessNative.kt | 42 -- .../OpenLessOverlayBridge.kt | 33 - .../OpenLessOverlayService.kt | 699 ------------------ .../OverlayPermissionActivity.kt | 27 - .../src-tauri/android-scaffolding/README.md | 27 - .../res/xml/openless_accessibility_config.xml | 9 - .../res/xml/openless_ime_method.xml | 9 - 14 files changed, 1342 deletions(-) delete mode 100644 openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v1.snippet.xml delete mode 100644 openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v3.snippet.xml delete mode 100644 openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityCommandReceiver.kt delete mode 100644 openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt delete mode 100644 openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt delete mode 100644 openless-all/app/src-tauri/android-scaffolding/OpenLessAppContext.kt delete mode 100644 openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt delete mode 100644 openless-all/app/src-tauri/android-scaffolding/OpenLessNative.kt delete mode 100644 openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayBridge.kt delete mode 100644 openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt delete mode 100644 openless-all/app/src-tauri/android-scaffolding/OverlayPermissionActivity.kt delete mode 100644 openless-all/app/src-tauri/android-scaffolding/README.md delete mode 100644 openless-all/app/src-tauri/android-scaffolding/res/xml/openless_accessibility_config.xml delete mode 100644 openless-all/app/src-tauri/android-scaffolding/res/xml/openless_ime_method.xml diff --git a/openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v1.snippet.xml b/openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v1.snippet.xml deleted file mode 100644 index 7f012e3c..00000000 --- a/openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v1.snippet.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v3.snippet.xml b/openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v3.snippet.xml deleted file mode 100644 index 859f99b6..00000000 --- a/openless-all/app/src-tauri/android-scaffolding/AndroidManifest.v3.snippet.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityCommandReceiver.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityCommandReceiver.kt deleted file mode 100644 index eccf203e..00000000 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityCommandReceiver.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.openless.app - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.util.Log - -class OpenLessAccessibilityCommandReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent?) { - if (intent?.action != ACTION_PASTE) return - val pasted = OpenLessAccessibilityService.performPasteFromCommand() - if (!pasted) { - Log.w(TAG, "paste command did not find an editable focused field") - } - } - - companion object { - const val ACTION_PASTE = "com.openless.app.accessibility.PASTE" - private const val TAG = "OpenLessA11yCommand" - } -} diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt deleted file mode 100644 index 86ad0c4f..00000000 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessAccessibilityService.kt +++ /dev/null @@ -1,252 +0,0 @@ -package com.openless.app - -import android.accessibilityservice.AccessibilityService -import android.content.Context -import android.content.Intent -import android.graphics.Rect -import android.os.Handler -import android.os.Looper -import android.provider.Settings -import android.util.Log -import android.view.accessibility.AccessibilityEvent -import android.view.accessibility.AccessibilityNodeInfo -import android.view.accessibility.AccessibilityWindowInfo - -/** - * Detects IME windows for overlay keyboard trigger mode and performs paste insertion. - */ -class OpenLessAccessibilityService : AccessibilityService() { - private val mainHandler = Handler(Looper.getMainLooper()) - private val heartbeatRunnable = object : Runnable { - override fun run() { - markServiceAlive() - mainHandler.postDelayed(this, HEARTBEAT_INTERVAL_MS) - } - } - private val keyboardRefreshRunnable = Runnable { updateKeyboardOverlayState() } - - override fun onServiceConnected() { - super.onServiceConnected() - instance = this - startHeartbeat() - updateKeyboardOverlayState() - scheduleKeyboardOverlayRefresh() - } - - override fun onAccessibilityEvent(event: AccessibilityEvent?) { - if (event == null) return - markServiceAlive() - when (event.eventType) { - AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, - AccessibilityEvent.TYPE_WINDOWS_CHANGED, - AccessibilityEvent.TYPE_VIEW_FOCUSED -> { - updateKeyboardOverlayState() - scheduleKeyboardOverlayRefresh() - } - } - } - - override fun onInterrupt() = Unit - - override fun onDestroy() { - mainHandler.removeCallbacks(heartbeatRunnable) - mainHandler.removeCallbacks(keyboardRefreshRunnable) - if (instance === this) { - instance = null - } - super.onDestroy() - } - - private fun scheduleKeyboardOverlayRefresh() { - mainHandler.removeCallbacks(keyboardRefreshRunnable) - for (delayMs in KEYBOARD_REFRESH_DELAYS_MS) { - mainHandler.postDelayed(keyboardRefreshRunnable, delayMs) - } - } - - private fun startHeartbeat() { - mainHandler.removeCallbacks(heartbeatRunnable) - heartbeatRunnable.run() - } - - private fun updateKeyboardOverlayState() { - if (!shouldTrackKeyboard()) { - return - } - if (!canDrawOverlays()) { - return - } - val imeBounds = findInputMethodBounds() - val intent = Intent(this, OpenLessOverlayService::class.java).apply { - action = OpenLessOverlayService.ACTION_KEYBOARD_CHANGED - putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_VISIBLE, imeBounds != null) - imeBounds?.let { - putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_TOP, it.top) - putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_BOTTOM, it.bottom) - } - } - try { - Log.i(TAG, "keyboard overlay event visible=${imeBounds != null} bounds=$imeBounds") - startService(intent) - } catch (error: Throwable) { - Log.w(TAG, "send keyboard overlay event failed", error) - } - } - - private fun findInputMethodBounds(): Rect? { - for (window in windows) { - if (window.type != AccessibilityWindowInfo.TYPE_INPUT_METHOD) { - continue - } - val bounds = Rect() - window.getBoundsInScreen(bounds) - if (!bounds.isEmpty) { - return bounds - } - } - return null - } - - private fun shouldTrackKeyboard(): Boolean { - return OpenLessAndroidPreferences.overlayTriggerMode(this) == "keyboard" - } - - private fun canDrawOverlays(): Boolean { - return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { - Settings.canDrawOverlays(this) - } else { - true - } - } - - private fun performPasteToFocusedField(): Boolean { - val root = rootInActiveWindow ?: return false - val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) - ?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY) - ?: return false - if (!focused.isEditable) { - focused.recycle() - return false - } - val pasted = focused.performAction(AccessibilityNodeInfo.ACTION_PASTE) - focused.recycle() - return pasted - } - - private fun captureSelectedTextFromFocusedNode(): String { - val root = rootInActiveWindow ?: return "" - val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) - ?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY) - focused?.let { - return try { - selectedTextFromNode(it) - } finally { - it.recycle() - } - } - return selectedTextFromTree(root) - } - - private fun selectedTextFromTree(node: AccessibilityNodeInfo?): String { - if (node == null) return "" - selectedTextFromNode(node).takeIf { it.isNotBlank() }?.let { return it } - for (index in 0 until node.childCount) { - val child = node.getChild(index) ?: continue - try { - selectedTextFromTree(child).takeIf { it.isNotBlank() }?.let { return it } - } finally { - child.recycle() - } - } - return "" - } - - private fun selectedTextFromNode(node: AccessibilityNodeInfo): String { - val text = node.text?.toString() ?: return "" - val start = node.textSelectionStart - val end = node.textSelectionEnd - if (start < 0 || end < 0 || start == end) return "" - val from = minOf(start, end).coerceIn(0, text.length) - val to = maxOf(start, end).coerceIn(0, text.length) - if (from >= to) return "" - return text.substring(from, to) - } - - private fun markServiceAlive() { - getSharedPreferences(PREFS_NAME, prefsMode()) - .edit() - .putLong(PREF_KEY_LAST_HEARTBEAT, System.currentTimeMillis()) - .apply() - } - - companion object { - @Volatile - var instance: OpenLessAccessibilityService? = null - private set - - @JvmStatic - fun pasteToFocusedField(): Boolean { - instance?.let { return it.performPasteToFocusedField() } - return sendPasteRequestToAccessibilityProcess() - } - - @JvmStatic - fun captureSelectedText(): String { - return instance?.captureSelectedTextFromFocusedNode().orEmpty() - } - - @JvmStatic - fun isEnabled(context: Context): Boolean { - val enabled = Settings.Secure.getInt( - context.contentResolver, - Settings.Secure.ACCESSIBILITY_ENABLED, - 0, - ) == 1 - if (!enabled) return false - val services = Settings.Secure.getString( - context.contentResolver, - Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, - ) ?: return false - return services.contains("${context.packageName}/${OpenLessAccessibilityService::class.java.name}") - } - - @JvmStatic - fun isOperational(context: Context): Boolean { - if (!isEnabled(context)) return false - val lastHeartbeat = context - .getSharedPreferences(PREFS_NAME, prefsMode()) - .getLong(PREF_KEY_LAST_HEARTBEAT, 0L) - if (lastHeartbeat <= 0L) return false - return System.currentTimeMillis() - lastHeartbeat <= HEARTBEAT_STALE_MS - } - - internal fun performPasteFromCommand(): Boolean { - return instance?.performPasteToFocusedField() == true - } - - private fun sendPasteRequestToAccessibilityProcess(): Boolean { - val context = OpenLessAppContext.context ?: return false - if (!isOperational(context)) return false - return try { - val intent = Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply { - action = OpenLessAccessibilityCommandReceiver.ACTION_PASTE - } - context.sendBroadcast(intent) - true - } catch (error: Throwable) { - Log.w(TAG, "send accessibility paste request failed", error) - false - } - } - - @Suppress("DEPRECATION") - private fun prefsMode(): Int = Context.MODE_PRIVATE or Context.MODE_MULTI_PROCESS - - private val KEYBOARD_REFRESH_DELAYS_MS = longArrayOf(120L, 360L, 900L, 1600L) - private const val TAG = "OpenLessAccessibility" - private const val PREFS_NAME = "openless_accessibility" - private const val PREF_KEY_LAST_HEARTBEAT = "last_heartbeat" - private const val HEARTBEAT_INTERVAL_MS = 5_000L - private const val HEARTBEAT_STALE_MS = 15_000L - } -} diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt deleted file mode 100644 index 7cf91ed9..00000000 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessAndroidPreferences.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.openless.app - -import android.content.Context -import android.util.Log -import java.io.File -import org.json.JSONObject - -/** - * Reads Android-visible preferences without depending on the Rust coordinator. - */ -object OpenLessAndroidPreferences { - private const val TAG = "OpenLessAndroidPrefs" - private const val APP_DIR = "OpenLess" - private const val PREFERENCES_FILE = "preferences.json" - private const val KEY_OVERLAY_TRIGGER = "androidOverlayTrigger" - private const val KEY_OVERLAY_ACTIVATION_MODE = "androidOverlayActivationMode" - private const val KEY_OVERLAY_LEFT_SWIPE_ACTION = "androidOverlayLeftSwipeAction" - private const val KEY_OVERLAY_SIZE_DP = "androidOverlaySizeDp" - private const val DEFAULT_OVERLAY_SIZE_DP = 72 - private const val MIN_OVERLAY_SIZE_DP = 48 - private const val MAX_OVERLAY_SIZE_DP = 120 - private val VALID_OVERLAY_TRIGGERS = setOf("background", "always") - private val VALID_OVERLAY_ACTIVATION_MODES = setOf("tap", "long_press") - private val VALID_OVERLAY_LEFT_SWIPE_ACTIONS = setOf("translation", "style_pack") - - fun overlayTriggerMode(context: Context): String? { - val value = readPreferenceString(context, KEY_OVERLAY_TRIGGER) ?: return null - if (value == "keyboard") { - return "background" - } - return value.takeIf { it in VALID_OVERLAY_TRIGGERS } - } - - fun overlayActivationMode(context: Context): String { - return readPreferenceString(context, KEY_OVERLAY_ACTIVATION_MODE) - ?.takeIf { it in VALID_OVERLAY_ACTIVATION_MODES } - ?: "tap" - } - - fun overlayLeftSwipeAction(context: Context): String { - return readPreferenceString(context, KEY_OVERLAY_LEFT_SWIPE_ACTION) - ?.takeIf { it in VALID_OVERLAY_LEFT_SWIPE_ACTIONS } - ?: "translation" - } - - fun overlaySizeDp(context: Context): Int { - return readPreferenceInt(context, KEY_OVERLAY_SIZE_DP) - ?.coerceIn(MIN_OVERLAY_SIZE_DP, MAX_OVERLAY_SIZE_DP) - ?: DEFAULT_OVERLAY_SIZE_DP - } - - private fun readPreferenceString(context: Context, key: String): String? { - for (file in preferenceFiles(context).distinctBy { it.absolutePath }) { - if (!file.isFile) { - continue - } - val value = try { - JSONObject(file.readText()).optString(key, "") - } catch (error: Throwable) { - Log.w(TAG, "read ${file.absolutePath} failed", error) - "" - } - if (value.isNotBlank()) { - return value - } - } - return null - } - - private fun readPreferenceInt(context: Context, key: String): Int? { - for (file in preferenceFiles(context).distinctBy { it.absolutePath }) { - if (!file.isFile) { - continue - } - val value = try { - val json = JSONObject(file.readText()) - if (json.has(key)) json.optInt(key) else null - } catch (error: Throwable) { - Log.w(TAG, "read ${file.absolutePath} failed", error) - null - } - if (value != null) { - return value - } - } - return null - } - - private fun preferenceFiles(context: Context): List { - val files = mutableListOf() - val envDir = System.getenv("TAURI_ANDROID_APP_DATA_DIR") - if (!envDir.isNullOrBlank()) { - files += File(File(envDir), APP_DIR).resolve(PREFERENCES_FILE) - } - files += File(File(context.cacheDir, APP_DIR), PREFERENCES_FILE) - files += File(File(context.filesDir, APP_DIR), PREFERENCES_FILE) - return files - } -} diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessAppContext.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessAppContext.kt deleted file mode 100644 index e1c627ae..00000000 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessAppContext.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.openless.app - -import android.content.Context - -object OpenLessAppContext { - @Volatile - var context: Context? = null - private set - - fun initialize(context: Context) { - this.context = context.applicationContext - } -} diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt deleted file mode 100644 index 8e25fd93..00000000 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessApplication.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.openless.app - -import android.app.Activity -import android.app.Application -import android.content.Intent -import android.os.Bundle -import android.provider.Settings -import android.util.Log - -/** - * Registers activity lifecycle hooks for overlay background trigger mode. - */ -class OpenLessApplication : Application() { - override fun onCreate() { - super.onCreate() - OpenLessAppContext.initialize(this) - registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit - override fun onActivityStarted(activity: Activity) { - if (activity.javaClass.name.endsWith("MainActivity")) { - maybeHideOverlayOnForeground() - } - } - override fun onActivityResumed(activity: Activity) = Unit - override fun onActivityPaused(activity: Activity) = Unit - override fun onActivityStopped(activity: Activity) { - if (activity.javaClass.name.endsWith("MainActivity")) { - maybeShowOverlayOnBackground() - } - } - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit - override fun onActivityDestroyed(activity: Activity) = Unit - }) - } - - private fun maybeShowOverlayOnBackground() { - val configured = configuredOverlayTriggerMode() - val shouldShow = configured == "background" || - configured == "always" - if (!shouldShow) { - return - } - if (!canDrawOverlays()) { - return - } - sendOverlayAction(OpenLessOverlayService.ACTION_SHOW) - } - - private fun maybeHideOverlayOnForeground() { - if (configuredOverlayTriggerMode() == "always") { - if (canDrawOverlays()) { - sendOverlayAction(OpenLessOverlayService.ACTION_SHOW) - } - return - } - sendOverlayAction(OpenLessOverlayService.ACTION_HIDE) - } - - private fun configuredOverlayTriggerMode(): String { - return OpenLessAndroidPreferences.overlayTriggerMode(this) ?: "background" - } - - private fun canDrawOverlays(): Boolean { - return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { - Settings.canDrawOverlays(this) - } else { - true - } - } - - private fun sendOverlayAction(action: String) { - try { - startService(Intent(this, OpenLessOverlayService::class.java).apply { - this.action = action - }) - } catch (error: Throwable) { - Log.w(TAG, "overlay action failed: $action", error) - } - } - - companion object { - private const val TAG = "OpenLessApplication" - } -} diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessNative.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessNative.kt deleted file mode 100644 index 3c6f297a..00000000 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessNative.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.openless.app - -/** - * JNI bridge from Kotlin overlay / lifecycle code into Rust Coordinator. - */ -object OpenLessNative { - init { - try { - System.loadLibrary("openless_lib") - } catch (error: UnsatisfiedLinkError) { - android.util.Log.e("OpenLessNative", "failed to load openless_lib", error) - } - } - - @JvmStatic external fun nativeStartDictation() - - @JvmStatic external fun nativeStartDictationWithTranslation(translation: Boolean) - - @JvmStatic external fun nativeStopDictation() - - @JvmStatic external fun nativeStopDictationWithTranslation(translation: Boolean) - - @JvmStatic external fun nativeCancelDictation() - - @JvmStatic external fun nativeSwitchStylePack() - - @JvmStatic external fun nativeOpenQaFromOverlay() - - @JvmStatic external fun nativeFinalizeQaFromOverlay() - - @JvmStatic external fun nativeGetOverlayTriggerMode(): String - - @JvmStatic external fun nativeCanDrawOverlays(context: android.content.Context): Boolean - - @JvmStatic external fun nativeShowOverlay(context: android.content.Context) - - @JvmStatic external fun nativeHideOverlay(context: android.content.Context) - - @JvmStatic external fun nativeIsOverlayVisible(): Boolean - - @JvmStatic external fun nativeNotifyOverlayPermissionChanged(context: android.content.Context) -} diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayBridge.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayBridge.kt deleted file mode 100644 index 92406730..00000000 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayBridge.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.openless.app - -import android.os.Handler -import android.os.Looper - -/** - * Rust calls back into this object to refresh overlay UI state. - */ -object OpenLessOverlayBridge { - private val mainHandler = Handler(Looper.getMainLooper()) - - @Volatile - var listener: OverlayStateListener? = null - - interface OverlayStateListener { - fun onCapsuleStateChanged(state: String, message: String?) - } - - @JvmStatic - fun onCapsuleStateChanged(state: String, message: String?) { - mainHandler.post { - listener?.onCapsuleStateChanged(state, message) - } - } - - @JvmStatic - fun showToast(message: String) { - mainHandler.post { - val service = OpenLessOverlayService.instance ?: return@post - android.widget.Toast.makeText(service.applicationContext, message, android.widget.Toast.LENGTH_SHORT).show() - } - } -} diff --git a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt b/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt deleted file mode 100644 index da9882d8..00000000 --- a/openless-all/app/src-tauri/android-scaffolding/OpenLessOverlayService.kt +++ /dev/null @@ -1,699 +0,0 @@ -package com.openless.app - -import android.Manifest -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.Service -import android.content.Intent -import android.content.pm.PackageManager -import android.content.pm.ServiceInfo -import android.graphics.Color -import android.graphics.PixelFormat -import android.graphics.drawable.GradientDrawable -import android.os.Build -import android.os.IBinder -import android.util.Log -import android.view.Gravity -import android.view.MotionEvent -import android.view.View -import android.view.WindowManager -import android.widget.FrameLayout -import android.widget.ImageView -import android.widget.Toast -import kotlin.math.abs - -/** - * Foreground service + TYPE_APPLICATION_OVERLAY floating dictation control. - */ -class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateListener { - - private var windowManager: WindowManager? = null - private var rootView: FrameLayout? = null - private var layoutParams: WindowManager.LayoutParams? = null - private var recording = false - private var processing = false - private var keyboardVisible = false - private var armed = false - private var dragStartX = 0 - private var dragStartY = 0 - private var paramStartX = 0 - private var paramStartY = 0 - private var dragging = false - private var longPressRecording = false - private var pendingSwipe: SwipeDirection? = null - private var swipeConsumed = false - - private lateinit var iconContainer: FrameLayout - private lateinit var iconButton: ImageView - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onCreate() { - super.onCreate() - instance = this - OpenLessOverlayBridge.listener = this - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.i( - TAG, - "onStartCommand action=${intent?.action} startId=$startId rootAttached=${rootView?.isAttachedToWindow}", - ) - when (intent?.action) { - ACTION_SHOW -> showOverlay() - ACTION_START_RECORDING -> { - showOverlay() - startRecordingFromOverlay() - } - ACTION_HIDE -> { - hideOverlay() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - stopForeground(STOP_FOREGROUND_REMOVE) - } else { - @Suppress("DEPRECATION") - stopForeground(true) - } - stopSelf() - } - ACTION_TOGGLE_EXPAND -> handleIconClick() - ACTION_KEYBOARD_CHANGED -> handleKeyboardChanged(intent) - } - return START_STICKY - } - - override fun onDestroy() { - if (OpenLessOverlayBridge.listener === this) { - OpenLessOverlayBridge.listener = null - } - hideOverlay() - if (instance === this) { - instance = null - } - super.onDestroy() - } - - override fun onCapsuleStateChanged(state: String, message: String?) { - when (state) { - "recording" -> { - recording = true - processing = false - if (!tryPromoteRecordingForeground()) { - try { - OpenLessNative.nativeCancelDictation() - } catch (error: Throwable) { - Log.w(TAG, "cancel dictation bridge unavailable", error) - } - return - } - applyVisualState(OverlayVisualState.Recording) - } - "transcribing", "polishing" -> { - recording = false - processing = true - applyVisualState(OverlayVisualState.Processing) - } - "done" -> { - recording = false - processing = false - setArmed(false) - } - "error" -> { - recording = false - processing = false - setArmed(false) - applyVisualState(OverlayVisualState.Error) - message?.takeIf { it.isNotBlank() }?.let { showToast(it) } - } - "cancelled", "idle" -> { - recording = false - processing = false - setArmed(false) - } - } - } - - private fun showOverlay() { - windowManager = getSystemService(WINDOW_SERVICE) as WindowManager - reconcileOverlayRoots() - overlayRoots.lastOrNull()?.let { existing -> - rootView = existing - layoutParams = existing.layoutParams as? WindowManager.LayoutParams - iconContainer = existing - (existing.getChildAt(0) as? ImageView)?.let { iconButton = it } - applyOverlaySize(existing) - layoutParams?.let { params -> - clampToScreen(params) - windowManager?.updateViewLayout(existing, params) - } - Log.i(TAG, "overlay already shown roots=${overlayRoots.size}") - return - } - - val savedPosition = loadSavedPosition() - val params = WindowManager.LayoutParams( - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.WRAP_CONTENT, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY - } else { - @Suppress("DEPRECATION") - WindowManager.LayoutParams.TYPE_PHONE - }, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or - WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or - WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, - PixelFormat.TRANSLUCENT, - ).apply { - gravity = Gravity.TOP or Gravity.START - x = savedPosition.first - y = savedPosition.second - } - layoutParams = params - - val root = FrameLayout(this).apply { - contentDescription = "OpenLess" - isClickable = true - isFocusable = false - setOnClickListener { handleIconClick() } - } - iconContainer = root - iconButton = buildIconButton() - root.addView( - iconButton, - FrameLayout.LayoutParams(1, 1, Gravity.CENTER), - ) - applyOverlaySize(root) - attachDragHandler(root, params) - try { - windowManager?.addView(root, params) - } catch (error: Throwable) { - Log.w(TAG, "show overlay failed", error) - layoutParams = null - return - } - rootView = root - synchronized(overlayRoots) { - overlayRoots.add(root) - } - Log.i(TAG, "overlay shown x=${params.x} y=${params.y} roots=${overlayRoots.size}") - applyVisualState( - when { - recording -> OverlayVisualState.Recording - processing -> OverlayVisualState.Processing - armed -> OverlayVisualState.Armed - else -> OverlayVisualState.Idle - }, - ) - } - - private fun hideOverlay() { - windowManager = windowManager ?: getSystemService(WINDOW_SERVICE) as WindowManager - val views = synchronized(overlayRoots) { - (overlayRoots + listOfNotNull(rootView)).distinct().also { - overlayRoots.clear() - } - } - views.forEach { view -> - removeOverlayRoot(view) - } - rootView = null - layoutParams = null - if (views.isNotEmpty()) { - Log.i(TAG, "overlay hidden roots=${views.size}") - } - } - - private fun reconcileOverlayRoots() { - val roots = synchronized(overlayRoots) { - overlayRoots.filter { it.isAttachedToWindow }.also { - overlayRoots.clear() - overlayRoots.addAll(it) - } - } - if (roots.isEmpty()) { - rootView = null - layoutParams = null - return - } - roots.dropLast(1).forEach { staleRoot -> - removeOverlayRoot(staleRoot) - synchronized(overlayRoots) { - overlayRoots.remove(staleRoot) - } - } - val activeRoot = roots.last() - rootView = activeRoot - layoutParams = activeRoot.layoutParams as? WindowManager.LayoutParams - Log.i(TAG, "reconciled overlay roots kept=1 removed=${roots.size - 1}") - } - - private fun removeOverlayRoot(view: FrameLayout) { - try { - if (view.isAttachedToWindow) { - windowManager?.removeViewImmediate(view) - } - } catch (error: Throwable) { - Log.w(TAG, "remove overlay root failed", error) - } - } - - private fun buildIconButton(): ImageView { - return ImageView(this).apply { - setImageResource(R.mipmap.ic_launcher) - scaleType = ImageView.ScaleType.CENTER_INSIDE - setPadding(0, 0, 0, 0) - contentDescription = "OpenLess" - isClickable = false - isFocusable = false - } - } - - private fun applyOverlaySize(container: FrameLayout) { - if (!::iconButton.isInitialized) return - val sizeDp = OpenLessAndroidPreferences.overlaySizeDp(this) - val paddingDp = overlayPaddingDp(sizeDp) - val imageSizePx = dp((sizeDp - paddingDp * 2).coerceAtLeast(MIN_ICON_IMAGE_SIZE_DP)) - val paddingPx = dp(paddingDp) - container.setPadding(paddingPx, paddingPx, paddingPx, paddingPx) - (iconButton.layoutParams as? FrameLayout.LayoutParams)?.let { childParams -> - childParams.width = imageSizePx - childParams.height = imageSizePx - childParams.gravity = Gravity.CENTER - iconButton.layoutParams = childParams - } - container.requestLayout() - Log.i(TAG, "overlay size applied sizeDp=$sizeDp imagePx=$imageSizePx") - } - - private fun overlayPaddingDp(sizeDp: Int): Int { - return (sizeDp * ICON_PADDING_DP / DEFAULT_ICON_SIZE_DP).coerceIn(6, 16) - } - - private fun handleIconClick() { - if (processing) return - if (recording) { - stopRecordingFromOverlay() - return - } - if (!isTapActivationMode()) { - return - } - startRecordingFromOverlay() - } - - private fun handleKeyboardChanged(intent: Intent) { - val visible = intent.getBooleanExtra(EXTRA_KEYBOARD_VISIBLE, false) - keyboardVisible = visible - Log.i(TAG, "keyboard changed visible=$visible") - if (visible) { - showOverlay() - return - } - if (!recording && !processing) { - hideOverlay() - } - } - - private fun attachDragHandler(view: View, params: WindowManager.LayoutParams) { - view.setOnTouchListener { touchedView, event -> - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - dragging = false - swipeConsumed = false - longPressRecording = false - pendingSwipe = null - if (processing) { - return@setOnTouchListener true - } - dragStartX = event.rawX.toInt() - dragStartY = event.rawY.toInt() - paramStartX = params.x - paramStartY = params.y - if (!isTapActivationMode() && !recording && !processing) { - longPressRecording = true - startRecordingFromOverlay() - } - true - } - MotionEvent.ACTION_MOVE -> { - val dx = event.rawX.toInt() - dragStartX - val dy = event.rawY.toInt() - dragStartY - val swipe = detectHorizontalSwipe(dx, dy) - if ((recording || armed || longPressRecording) && swipe != null && !swipeConsumed) { - pendingSwipe = swipe - swipeConsumed = true - applySwipePreview(swipe) - return@setOnTouchListener true - } - if (!processing && !armed && !recording && !longPressRecording && (abs(dx) > DRAG_SLOP_PX || abs(dy) > DRAG_SLOP_PX)) { - dragging = true - params.x = paramStartX + dx - params.y = paramStartY + dy - clampToScreen(params) - rootView?.let { windowManager?.updateViewLayout(it, params) } - } - true - } - MotionEvent.ACTION_UP -> { - if (!dragging) { - val swipe = pendingSwipe - if (swipe != null) { - commitSwipe(swipe) - } else if (longPressRecording || (!isTapActivationMode() && recording)) { - stopRecordingFromOverlay() - } else if (!isTapActivationMode()) { - setArmed(false) - } else if (!swipeConsumed) { - touchedView.performClick() - } - } else { - savePosition(params.x, params.y) - } - longPressRecording = false - pendingSwipe = null - swipeConsumed = false - true - } - MotionEvent.ACTION_CANCEL -> { - if (longPressRecording || (!isTapActivationMode() && recording)) { - stopRecordingFromOverlay() - } - longPressRecording = false - pendingSwipe = null - swipeConsumed = false - true - } - else -> false - } - } - } - - private fun applyVisualState(state: OverlayVisualState) { - if (!::iconContainer.isInitialized || !::iconButton.isInitialized) return - val (alpha, fill, stroke, strokeWidth, enabled) = when (state) { - OverlayVisualState.Idle -> VisualStyle( - alpha = 0.58f, - fill = Color.parseColor("#66202A36"), - stroke = Color.parseColor("#66FFFFFF"), - strokeWidth = 1, - enabled = true, - ) - OverlayVisualState.Armed -> VisualStyle( - alpha = 1f, - fill = Color.parseColor("#E6111827"), - stroke = Color.parseColor("#38BDF8"), - strokeWidth = 3, - enabled = true, - ) - OverlayVisualState.Recording -> VisualStyle( - alpha = 1f, - fill = Color.parseColor("#E6111827"), - stroke = Color.parseColor("#F43F5E"), - strokeWidth = 3, - enabled = true, - ) - OverlayVisualState.Processing -> VisualStyle( - alpha = 0.86f, - fill = Color.parseColor("#D1111827"), - stroke = Color.parseColor("#38BDF8"), - strokeWidth = 2, - enabled = true, - ) - OverlayVisualState.Error -> VisualStyle( - alpha = 0.95f, - fill = Color.parseColor("#E67F1D1D"), - stroke = Color.parseColor("#EF4444"), - strokeWidth = 2, - enabled = true, - ) - } - iconContainer.alpha = alpha - iconContainer.isEnabled = enabled - iconContainer.background = circleDrawable(fill, stroke, dp(strokeWidth)) - iconButton.isEnabled = enabled - } - - private fun setArmed(value: Boolean) { - armed = value - if (!recording && !processing) { - applyVisualState(if (value) OverlayVisualState.Armed else OverlayVisualState.Idle) - } - } - - private fun detectHorizontalSwipe(dx: Int, dy: Int): SwipeDirection? { - if (abs(dx) < dp(SWIPE_THRESHOLD_DP)) return null - if (abs(dy) > abs(dx) * SWIPE_VERTICAL_RATIO) return null - return if (dx < 0) SwipeDirection.Left else SwipeDirection.Right - } - - private fun applySwipePreview(direction: SwipeDirection) { - when (direction) { - SwipeDirection.Left -> applyVisualState(OverlayVisualState.Armed) - SwipeDirection.Right -> applyVisualState(OverlayVisualState.Processing) - } - } - - private fun commitSwipe(direction: SwipeDirection) { - Log.i(TAG, "commit swipe direction=$direction recording=$recording processing=$processing") - when (direction) { - SwipeDirection.Left -> handleLeftSwipe() - SwipeDirection.Right -> finalizeQaFromOverlay() - } - } - - private fun handleLeftSwipe() { - when (OpenLessAndroidPreferences.overlayLeftSwipeAction(this)) { - "style_pack" -> { - switchStylePackFromOverlay() - if (recording) { - stopRecordingFromOverlay() - } - } - else -> stopRecordingFromOverlay(translation = true) - } - } - - private fun switchStylePackFromOverlay() { - try { - OpenLessNative.nativeSwitchStylePack() - setArmed(false) - } catch (error: Throwable) { - Log.w(TAG, "switch style pack bridge unavailable", error) - applyVisualState(OverlayVisualState.Error) - showToast("语音服务未就绪,请打开 OpenLess 后重试") - } - } - - private fun openQaFromOverlay() { - try { - Log.i(TAG, "open QA from overlay") - OpenLessNative.nativeOpenQaFromOverlay() - setArmed(false) - } catch (error: Throwable) { - Log.w(TAG, "open QA bridge unavailable", error) - applyVisualState(OverlayVisualState.Error) - showToast("问答服务未就绪,请打开 OpenLess 后重试") - } - } - - private fun finalizeQaFromOverlay() { - try { - Log.i(TAG, "finalize QA from overlay") - OpenLessNative.nativeFinalizeQaFromOverlay() - recording = false - processing = true - setArmed(false) - applyVisualState(OverlayVisualState.Processing) - } catch (error: Throwable) { - Log.w(TAG, "finalize QA bridge unavailable", error) - processing = false - applyVisualState(OverlayVisualState.Error) - showToast("问答服务未就绪,请打开 OpenLess 后重试") - } - } - - private fun startRecordingFromOverlay(translation: Boolean = false) { - showOverlay() - if (tryPromoteRecordingForeground()) { - try { - if (translation) { - OpenLessNative.nativeStartDictationWithTranslation(true) - } else { - OpenLessNative.nativeStartDictation() - } - recording = true - processing = false - setArmed(false) - applyVisualState(OverlayVisualState.Recording) - } catch (error: Throwable) { - Log.w(TAG, "start dictation bridge unavailable", error) - recording = false - processing = false - applyVisualState(OverlayVisualState.Error) - showToast("语音服务未就绪,请打开 OpenLess 后重试") - } - return - } - applyVisualState(OverlayVisualState.Error) - } - - private fun stopRecordingFromOverlay(translation: Boolean = false) { - try { - recording = false - processing = true - applyVisualState(OverlayVisualState.Processing) - if (translation) { - OpenLessNative.nativeStopDictationWithTranslation(true) - } else { - OpenLessNative.nativeStopDictation() - } - } catch (error: Throwable) { - Log.w(TAG, "stop dictation bridge unavailable", error) - recording = false - processing = false - applyVisualState(OverlayVisualState.Error) - showToast("语音服务未就绪,请打开 OpenLess 后重试") - } - } - - private fun tryPromoteRecordingForeground(): Boolean { - if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { - showToast("请先授予麦克风权限") - return false - } - val notification = buildNotification("录音中") - return try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, - ) - } else { - startForeground(NOTIFICATION_ID, notification) - } - true - } catch (error: SecurityException) { - Log.w(TAG, "microphone foreground service not allowed from current state", error) - showToast("系统限制后台录音,请在 OpenLess 内开始") - false - } - } - - private fun buildNotification(contentText: String): Notification { - val channelId = "openless_overlay" - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val nm = getSystemService(NotificationManager::class.java) - nm.createNotificationChannel( - NotificationChannel(channelId, "OpenLess Overlay", NotificationManager.IMPORTANCE_LOW), - ) - } - return Notification.Builder(this, channelId) - .setContentTitle("OpenLess") - .setContentText(contentText) - .setSmallIcon(R.mipmap.ic_launcher) - .build() - } - - private fun circleDrawable(color: Int, strokeColor: Int, strokeWidth: Int): GradientDrawable { - return GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(color) - setStroke(strokeWidth, strokeColor) - } - } - - private fun overlaySize(): Int { - val root = rootView - val measured = maxOf(root?.width ?: 0, root?.height ?: 0) - return measured.takeIf { it > 0 } ?: dp(OpenLessAndroidPreferences.overlaySizeDp(this)) - } - - private fun clampToScreen(params: WindowManager.LayoutParams) { - val iconSize = overlaySize() - val margin = dp(8) - val maxX = (resources.displayMetrics.widthPixels - iconSize - margin).coerceAtLeast(margin) - val maxY = (resources.displayMetrics.heightPixels - iconSize - margin).coerceAtLeast(margin) - params.x = params.x.coerceIn(margin, maxX) - params.y = params.y.coerceIn(margin, maxY) - } - - private fun loadSavedPosition(): Pair { - val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE) - val defaultX = dp(24) - val defaultY = dp(120) - val x = prefs.getInt(PREF_KEY_X, defaultX) - val y = prefs.getInt(PREF_KEY_Y, defaultY) - return x to y - } - - private fun savePosition(x: Int, y: Int) { - getSharedPreferences(PREFS_NAME, MODE_PRIVATE) - .edit() - .putInt(PREF_KEY_X, x) - .putInt(PREF_KEY_Y, y) - .apply() - } - - private fun isTapActivationMode(): Boolean { - return OpenLessAndroidPreferences.overlayActivationMode(this) == "tap" - } - - private fun showToast(message: String) { - Toast.makeText(this, message, Toast.LENGTH_SHORT).show() - } - - private fun dp(value: Int): Int { - return (value * resources.displayMetrics.density).toInt() - } - - private data class VisualStyle( - val alpha: Float, - val fill: Int, - val stroke: Int, - val strokeWidth: Int, - val enabled: Boolean, - ) - - private enum class OverlayVisualState { - Idle, - Armed, - Recording, - Processing, - Error, - } - - private enum class SwipeDirection { - Left, - Right, - } - - companion object { - const val ACTION_SHOW = "com.openless.app.overlay.SHOW" - const val ACTION_HIDE = "com.openless.app.overlay.HIDE" - const val ACTION_TOGGLE_EXPAND = "com.openless.app.overlay.TOGGLE_EXPAND" - const val ACTION_START_RECORDING = "com.openless.app.overlay.START_RECORDING" - const val ACTION_KEYBOARD_CHANGED = "com.openless.app.overlay.KEYBOARD_CHANGED" - const val EXTRA_KEYBOARD_VISIBLE = "keyboard_visible" - const val EXTRA_KEYBOARD_TOP = "keyboard_top" - const val EXTRA_KEYBOARD_BOTTOM = "keyboard_bottom" - private const val DEFAULT_ICON_SIZE_DP = 72 - private const val MIN_ICON_IMAGE_SIZE_DP = 32 - private const val ICON_PADDING_DP = 8 - private const val DRAG_SLOP_PX = 8 - private const val SWIPE_THRESHOLD_DP = 56 - private const val SWIPE_VERTICAL_RATIO = 0.6f - private const val PREFS_NAME = "openless_overlay" - private const val PREF_KEY_X = "overlay_x" - private const val PREF_KEY_Y = "overlay_y" - private const val NOTIFICATION_ID = 42001 - private const val TAG = "OpenLessOverlayService" - - private val overlayRoots = mutableListOf() - - @Volatile - var instance: OpenLessOverlayService? = null - private set - } -} diff --git a/openless-all/app/src-tauri/android-scaffolding/OverlayPermissionActivity.kt b/openless-all/app/src-tauri/android-scaffolding/OverlayPermissionActivity.kt deleted file mode 100644 index d3a05f8f..00000000 --- a/openless-all/app/src-tauri/android-scaffolding/OverlayPermissionActivity.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.openless.app - -import android.app.Activity -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.provider.Settings - -/** - * 引导用户授权 SYSTEM_ALERT_WINDOW。 - * Rust 命令 request_android_overlay_permission 通过 Intent 启动本 Activity。 - */ -class OverlayPermissionActivity : Activity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) { - val intent = Intent( - Settings.ACTION_MANAGE_OVERLAY_PERMISSION, - Uri.parse("package:$packageName"), - ) - startActivity(intent) - } - finish() - } -} diff --git a/openless-all/app/src-tauri/android-scaffolding/README.md b/openless-all/app/src-tauri/android-scaffolding/README.md deleted file mode 100644 index aa66114e..00000000 --- a/openless-all/app/src-tauri/android-scaffolding/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Android Kotlin scaffolding - -Copy these files into `src-tauri/gen/android/` after running: - -```bash -cd openless-all/app -npm run tauri:android:init -``` - -## Copy / merge paths - -| Source (this folder) | Destination (after init) | -| --- | --- | -| `OpenLessOverlayService.kt` | `gen/android/app/src/main/java/com/openless/app/OpenLessOverlayService.kt` | -| `OverlayPermissionActivity.kt` | `gen/android/app/src/main/java/com/openless/app/OverlayPermissionActivity.kt` | -| `AndroidManifest.v1.snippet.xml` | merge into `gen/android/app/src/main/AndroidManifest.xml` | -| `AndroidManifest.v3.snippet.xml` | **future / not complete** — overlay v3 only | - -Tauri `android init` generates the base manifest under `gen/android/app/src/main/AndroidManifest.xml`. -Merge the v1 snippet permissions into that file before building APK v1. - -## Manifest snippets - -- **v1** (`AndroidManifest.v1.snippet.xml`): `RECORD_AUDIO` and `MODIFY_AUDIO_SETTINGS` for in-app dictation — required for APK v1. -- **v3** (`AndroidManifest.v3.snippet.xml`): overlay + foreground service — **not complete / future**. - -Do not treat v3 snippets as shipped; they document planned permissions and service entries only. diff --git a/openless-all/app/src-tauri/android-scaffolding/res/xml/openless_accessibility_config.xml b/openless-all/app/src-tauri/android-scaffolding/res/xml/openless_accessibility_config.xml deleted file mode 100644 index 281b8c75..00000000 --- a/openless-all/app/src-tauri/android-scaffolding/res/xml/openless_accessibility_config.xml +++ /dev/null @@ -1,9 +0,0 @@ - - diff --git a/openless-all/app/src-tauri/android-scaffolding/res/xml/openless_ime_method.xml b/openless-all/app/src-tauri/android-scaffolding/res/xml/openless_ime_method.xml deleted file mode 100644 index 5a2afd44..00000000 --- a/openless-all/app/src-tauri/android-scaffolding/res/xml/openless_ime_method.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - From 841215edd0fccbea9761e51ed465aea18e30be35 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Thu, 11 Jun 2026 18:43:14 +0800 Subject: [PATCH 80/83] Fix Android overlay gestures and permissions --- .../components/AndroidPermissionsPanel.tsx | 18 +++ .../lib/androidMicrophonePermission.ts | 9 +- .../app/android/frontend/lib/androidTypes.ts | 2 + .../OpenLessAccessibilityCommandReceiver.kt | 15 ++ .../kotlin/OpenLessAccessibilityService.kt | 141 ++++++++++++++++-- .../kotlin/OpenLessAndroidPreferences.kt | 8 + .../android/kotlin/OpenLessOverlayService.kt | 66 +++++++- openless-all/app/src-tauri/src/android/jni.rs | 21 +++ .../src-tauri/src/android/native_bridge.rs | 3 - .../app/src-tauri/src/android/types.rs | 11 ++ openless-all/app/src-tauri/src/coordinator.rs | 1 + openless-all/app/src-tauri/src/permissions.rs | 18 ++- openless-all/app/src-tauri/src/types.rs | 26 +++- openless-all/app/src/i18n/en.ts | 9 ++ openless-all/app/src/i18n/ja.ts | 3 + openless-all/app/src/i18n/ko.ts | 3 + openless-all/app/src/i18n/zh-CN.ts | 9 ++ openless-all/app/src/i18n/zh-TW.ts | 3 + openless-all/app/src/lib/ipc.ts | 1 + openless-all/app/src/lib/stylePrefs.test.ts | 1 + openless-all/app/src/lib/types.ts | 4 + 21 files changed, 339 insertions(+), 33 deletions(-) diff --git a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx index 1f4cc7fc..2c9451fa 100644 --- a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx +++ b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx @@ -15,6 +15,7 @@ import type { AndroidAccessibilityStatus, AndroidInsertStrategy, AndroidOverlayActivationMode, + AndroidOverlayCancelSwipeDirection, AndroidOverlayLeftSwipeAction, AndroidOverlayStatus, AndroidOverlayTrigger, @@ -52,6 +53,7 @@ export function AndroidPermissionsPanel() { androidOverlayTrigger: migratedSettings.androidOverlayTrigger, androidOverlayActivationMode: migratedSettings.androidOverlayActivationMode, androidOverlayLeftSwipeAction: migratedSettings.androidOverlayLeftSwipeAction, + androidOverlayCancelSwipeDirection: migratedSettings.androidOverlayCancelSwipeDirection, androidOverlaySizeDp: migratedSettings.androidOverlaySizeDp, }); }; @@ -82,6 +84,7 @@ export function AndroidPermissionsPanel() { androidOverlayTrigger: next.androidOverlayTrigger, androidOverlayActivationMode: next.androidOverlayActivationMode, androidOverlayLeftSwipeAction: next.androidOverlayLeftSwipeAction, + androidOverlayCancelSwipeDirection: next.androidOverlayCancelSwipeDirection, androidOverlaySizeDp: next.androidOverlaySizeDp, }); await refreshAndroid(); @@ -188,6 +191,21 @@ export function AndroidPermissionsPanel() {
+ +
+ + + {t(`settings.permissions.androidOverlayCancelSwipeDirectionHint.${androidPrefs?.androidOverlayCancelSwipeDirection ?? 'up'}`)} + +
+
diff --git a/openless-all/app/android/frontend/lib/androidMicrophonePermission.ts b/openless-all/app/android/frontend/lib/androidMicrophonePermission.ts index b0f79d89..51640677 100644 --- a/openless-all/app/android/frontend/lib/androidMicrophonePermission.ts +++ b/openless-all/app/android/frontend/lib/androidMicrophonePermission.ts @@ -12,7 +12,7 @@ export async function checkAndroidMicrophoneAccess(): Promise track.stop()); } diff --git a/openless-all/app/android/frontend/lib/androidTypes.ts b/openless-all/app/android/frontend/lib/androidTypes.ts index b8a63039..387d5419 100644 --- a/openless-all/app/android/frontend/lib/androidTypes.ts +++ b/openless-all/app/android/frontend/lib/androidTypes.ts @@ -4,6 +4,7 @@ export type AndroidInsertStrategy = 'accessibility' | 'clipboard'; export type AndroidOverlayTrigger = 'background' | 'keyboard' | 'always'; export type AndroidOverlayActivationMode = 'tap' | 'long_press'; export type AndroidOverlayLeftSwipeAction = 'translation' | 'style_pack'; +export type AndroidOverlayCancelSwipeDirection = 'up' | 'down'; export interface AndroidOverlayStatus { permission: 'granted' | 'notGranted' | 'notAndroid'; @@ -22,6 +23,7 @@ export type AndroidPreferenceKey = | 'androidOverlayTrigger' | 'androidOverlayActivationMode' | 'androidOverlayLeftSwipeAction' + | 'androidOverlayCancelSwipeDirection' | 'androidOverlaySizeDp'; export function normalizeAndroidOverlayTrigger( diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt index eccf203e..a3d69ad4 100644 --- a/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt @@ -3,19 +3,34 @@ package com.openless.app import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.os.Bundle +import android.os.ResultReceiver import android.util.Log class OpenLessAccessibilityCommandReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { if (intent?.action != ACTION_PASTE) return val pasted = OpenLessAccessibilityService.performPasteFromCommand() + resultReceiver(intent)?.send( + if (pasted) RESULT_PASTE_SUCCESS else RESULT_PASTE_FAILED, + Bundle().apply { putBoolean(EXTRA_PASTE_RESULT, pasted) }, + ) if (!pasted) { Log.w(TAG, "paste command did not find an editable focused field") } } + @Suppress("DEPRECATION") + private fun resultReceiver(intent: Intent): ResultReceiver? { + return intent.getParcelableExtra(EXTRA_RESULT_RECEIVER) as? ResultReceiver + } + companion object { const val ACTION_PASTE = "com.openless.app.accessibility.PASTE" + const val EXTRA_RESULT_RECEIVER = "result_receiver" + const val EXTRA_PASTE_RESULT = "paste_result" + const val RESULT_PASTE_FAILED = 0 + const val RESULT_PASTE_SUCCESS = 1 private const val TAG = "OpenLessA11yCommand" } } diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt index 86ad0c4f..98dfacc7 100644 --- a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt @@ -1,16 +1,22 @@ package com.openless.app import android.accessibilityservice.AccessibilityService +import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.graphics.Rect +import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.ResultReceiver import android.provider.Settings import android.util.Log import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import android.view.accessibility.AccessibilityWindowInfo +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean /** * Detects IME windows for overlay keyboard trigger mode and performs paste insertion. @@ -24,6 +30,7 @@ class OpenLessAccessibilityService : AccessibilityService() { } } private val keyboardRefreshRunnable = Runnable { updateKeyboardOverlayState() } + private var lastEditableFocus: AccessibilityNodeInfo? = null override fun onServiceConnected() { super.onServiceConnected() @@ -40,6 +47,7 @@ class OpenLessAccessibilityService : AccessibilityService() { AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, AccessibilityEvent.TYPE_WINDOWS_CHANGED, AccessibilityEvent.TYPE_VIEW_FOCUSED -> { + rememberFocusedEditable(event) updateKeyboardOverlayState() scheduleKeyboardOverlayRefresh() } @@ -51,6 +59,8 @@ class OpenLessAccessibilityService : AccessibilityService() { override fun onDestroy() { mainHandler.removeCallbacks(heartbeatRunnable) mainHandler.removeCallbacks(keyboardRefreshRunnable) + lastEditableFocus?.recycle() + lastEditableFocus = null if (instance === this) { instance = null } @@ -120,17 +130,110 @@ class OpenLessAccessibilityService : AccessibilityService() { } private fun performPasteToFocusedField(): Boolean { - val root = rootInActiveWindow ?: return false - val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) - ?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY) - ?: return false - if (!focused.isEditable) { - focused.recycle() - return false + val target = findEditableTarget() ?: return false + return try { + target.performAction(AccessibilityNodeInfo.ACTION_FOCUS) + pasteWithRetryOrSetText(target) + } finally { + target.recycle() + } + } + + private fun rememberFocusedEditable(event: AccessibilityEvent) { + val source = event.source ?: return + try { + if (!source.isEditable) return + lastEditableFocus?.recycle() + lastEditableFocus = AccessibilityNodeInfo.obtain(source) + } finally { + source.recycle() + } + } + + private fun findEditableTarget(): AccessibilityNodeInfo? { + lastEditableFocus?.let { cached -> + if (cached.refresh() && cached.isEditable) { + return AccessibilityNodeInfo.obtain(cached) + } + } + val root = rootInActiveWindow ?: return null + editableFocusedNode(root, AccessibilityNodeInfo.FOCUS_INPUT)?.let { return it } + editableFocusedNode(root, AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)?.let { return it } + return findEditableInTree(root, 0) + } + + private fun editableFocusedNode(root: AccessibilityNodeInfo, focusType: Int): AccessibilityNodeInfo? { + val focused = root.findFocus(focusType) ?: return null + if (focused.isEditable) { + return focused } - val pasted = focused.performAction(AccessibilityNodeInfo.ACTION_PASTE) focused.recycle() - return pasted + return null + } + + private fun findEditableInTree(node: AccessibilityNodeInfo, depth: Int): AccessibilityNodeInfo? { + if (depth > MAX_EDITABLE_SEARCH_DEPTH) return null + var firstEditable: AccessibilityNodeInfo? = null + if (node.isEditable) { + if (node.isFocused) { + return AccessibilityNodeInfo.obtain(node) + } + firstEditable = AccessibilityNodeInfo.obtain(node) + } + for (index in 0 until node.childCount) { + val child = node.getChild(index) ?: continue + try { + findEditableInTree(child, depth + 1)?.let { found -> + firstEditable?.recycle() + return found + } + } finally { + child.recycle() + } + } + return firstEditable + } + + private fun pasteWithRetryOrSetText(target: AccessibilityNodeInfo): Boolean { + sleepQuietly(PASTE_INITIAL_DELAY_MS) + repeat(PASTE_RETRY_COUNT) { attempt -> + if (target.performAction(AccessibilityNodeInfo.ACTION_PASTE)) { + Log.i(TAG, "paste=true attempt=${attempt + 1} package=${target.packageName}") + return true + } + sleepQuietly(PASTE_RETRY_DELAY_MS) + } + val setText = appendClipboardTextWithSetText(target) + Log.i(TAG, "paste=false setText=$setText package=${target.packageName}") + return setText + } + + private fun appendClipboardTextWithSetText(target: AccessibilityNodeInfo): Boolean { + if (target.isPassword) return false + val clipboardText = clipboardText().takeIf { it.isNotEmpty() } ?: return false + val existingText = target.text?.toString().orEmpty() + val args = Bundle().apply { + putCharSequence( + AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, + existingText + clipboardText, + ) + } + return target.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) + } + + private fun clipboardText(): String { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return "" + val clip = clipboard.primaryClip ?: return "" + if (clip.itemCount <= 0) return "" + return clip.getItemAt(0)?.coerceToText(this)?.toString().orEmpty() + } + + private fun sleepQuietly(delayMs: Long) { + try { + Thread.sleep(delayMs) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } } private fun captureSelectedTextFromFocusedNode(): String { @@ -227,12 +330,25 @@ class OpenLessAccessibilityService : AccessibilityService() { private fun sendPasteRequestToAccessibilityProcess(): Boolean { val context = OpenLessAppContext.context ?: return false if (!isOperational(context)) return false + val latch = CountDownLatch(1) + val success = AtomicBoolean(false) + val receiver = object : ResultReceiver(null) { + override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { + success.set(resultCode == OpenLessAccessibilityCommandReceiver.RESULT_PASTE_SUCCESS) + latch.countDown() + } + } return try { val intent = Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply { action = OpenLessAccessibilityCommandReceiver.ACTION_PASTE + putExtra(OpenLessAccessibilityCommandReceiver.EXTRA_RESULT_RECEIVER, receiver) } context.sendBroadcast(intent) - true + if (!latch.await(PASTE_COMMAND_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + Log.w(TAG, "accessibility paste result timed out") + return false + } + success.get() } catch (error: Throwable) { Log.w(TAG, "send accessibility paste request failed", error) false @@ -243,6 +359,11 @@ class OpenLessAccessibilityService : AccessibilityService() { private fun prefsMode(): Int = Context.MODE_PRIVATE or Context.MODE_MULTI_PROCESS private val KEYBOARD_REFRESH_DELAYS_MS = longArrayOf(120L, 360L, 900L, 1600L) + private const val MAX_EDITABLE_SEARCH_DEPTH = 4 + private const val PASTE_INITIAL_DELAY_MS = 50L + private const val PASTE_RETRY_COUNT = 3 + private const val PASTE_RETRY_DELAY_MS = 80L + private const val PASTE_COMMAND_TIMEOUT_MS = 800L private const val TAG = "OpenLessAccessibility" private const val PREFS_NAME = "openless_accessibility" private const val PREF_KEY_LAST_HEARTBEAT = "last_heartbeat" diff --git a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt index 7cf91ed9..a1216e3d 100644 --- a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt +++ b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt @@ -15,6 +15,7 @@ object OpenLessAndroidPreferences { private const val KEY_OVERLAY_TRIGGER = "androidOverlayTrigger" private const val KEY_OVERLAY_ACTIVATION_MODE = "androidOverlayActivationMode" private const val KEY_OVERLAY_LEFT_SWIPE_ACTION = "androidOverlayLeftSwipeAction" + private const val KEY_OVERLAY_CANCEL_SWIPE_DIRECTION = "androidOverlayCancelSwipeDirection" private const val KEY_OVERLAY_SIZE_DP = "androidOverlaySizeDp" private const val DEFAULT_OVERLAY_SIZE_DP = 72 private const val MIN_OVERLAY_SIZE_DP = 48 @@ -22,6 +23,7 @@ object OpenLessAndroidPreferences { private val VALID_OVERLAY_TRIGGERS = setOf("background", "always") private val VALID_OVERLAY_ACTIVATION_MODES = setOf("tap", "long_press") private val VALID_OVERLAY_LEFT_SWIPE_ACTIONS = setOf("translation", "style_pack") + private val VALID_OVERLAY_CANCEL_SWIPE_DIRECTIONS = setOf("up", "down") fun overlayTriggerMode(context: Context): String? { val value = readPreferenceString(context, KEY_OVERLAY_TRIGGER) ?: return null @@ -43,6 +45,12 @@ object OpenLessAndroidPreferences { ?: "translation" } + fun overlayCancelSwipeDirection(context: Context): String { + return readPreferenceString(context, KEY_OVERLAY_CANCEL_SWIPE_DIRECTION) + ?.takeIf { it in VALID_OVERLAY_CANCEL_SWIPE_DIRECTIONS } + ?: "up" + } + fun overlaySizeDp(context: Context): Int { return readPreferenceInt(context, KEY_OVERLAY_SIZE_DP) ?.coerceIn(MIN_OVERLAY_SIZE_DP, MAX_OVERLAY_SIZE_DP) diff --git a/openless-all/app/android/kotlin/OpenLessOverlayService.kt b/openless-all/app/android/kotlin/OpenLessOverlayService.kt index da9882d8..474e641b 100644 --- a/openless-all/app/android/kotlin/OpenLessOverlayService.kt +++ b/openless-all/app/android/kotlin/OpenLessOverlayService.kt @@ -133,7 +133,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } } - private fun showOverlay() { + private fun showOverlay() = withOverlayLock { windowManager = getSystemService(WINDOW_SERVICE) as WindowManager reconcileOverlayRoots() overlayRoots.lastOrNull()?.let { existing -> @@ -143,11 +143,12 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList (existing.getChildAt(0) as? ImageView)?.let { iconButton = it } applyOverlaySize(existing) layoutParams?.let { params -> + attachDragHandler(existing, params) clampToScreen(params) windowManager?.updateViewLayout(existing, params) } Log.i(TAG, "overlay already shown roots=${overlayRoots.size}") - return + return@withOverlayLock } val savedPosition = loadSavedPosition() @@ -190,7 +191,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } catch (error: Throwable) { Log.w(TAG, "show overlay failed", error) layoutParams = null - return + return@withOverlayLock } rootView = root synchronized(overlayRoots) { @@ -207,7 +208,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList ) } - private fun hideOverlay() { + private fun hideOverlay() = withOverlayLock { windowManager = windowManager ?: getSystemService(WINDOW_SERVICE) as WindowManager val views = synchronized(overlayRoots) { (overlayRoots + listOfNotNull(rootView)).distinct().also { @@ -226,7 +227,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private fun reconcileOverlayRoots() { val roots = synchronized(overlayRoots) { - overlayRoots.filter { it.isAttachedToWindow }.also { + (overlayRoots + listOfNotNull(rootView)).distinct().filter { it.isAttachedToWindow }.also { overlayRoots.clear() overlayRoots.addAll(it) } @@ -243,6 +244,10 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } } val activeRoot = roots.last() + synchronized(overlayRoots) { + overlayRoots.clear() + overlayRoots.add(activeRoot) + } rootView = activeRoot layoutParams = activeRoot.layoutParams as? WindowManager.LayoutParams Log.i(TAG, "reconciled overlay roots kept=1 removed=${roots.size - 1}") @@ -339,6 +344,13 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList MotionEvent.ACTION_MOVE -> { val dx = event.rawX.toInt() - dragStartX val dy = event.rawY.toInt() - dragStartY + val verticalSwipe = detectVerticalSwipe(dx, dy) + if (recording && verticalSwipe != null && matchesConfiguredCancelSwipe(verticalSwipe) && !swipeConsumed) { + pendingSwipe = verticalSwipe + swipeConsumed = true + applySwipePreview(verticalSwipe) + return@setOnTouchListener true + } val swipe = detectHorizontalSwipe(dx, dy) if ((recording || armed || longPressRecording) && swipe != null && !swipeConsumed) { pendingSwipe = swipe @@ -447,10 +459,24 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList return if (dx < 0) SwipeDirection.Left else SwipeDirection.Right } + private fun detectVerticalSwipe(dx: Int, dy: Int): SwipeDirection? { + if (abs(dy) < dp(SWIPE_THRESHOLD_DP)) return null + if (abs(dx) > abs(dy) * SWIPE_VERTICAL_RATIO) return null + return if (dy < 0) SwipeDirection.Up else SwipeDirection.Down + } + + private fun matchesConfiguredCancelSwipe(direction: SwipeDirection): Boolean { + val configured = OpenLessAndroidPreferences.overlayCancelSwipeDirection(this) + return (direction == SwipeDirection.Up && configured == "up") || + (direction == SwipeDirection.Down && configured == "down") + } + private fun applySwipePreview(direction: SwipeDirection) { when (direction) { SwipeDirection.Left -> applyVisualState(OverlayVisualState.Armed) SwipeDirection.Right -> applyVisualState(OverlayVisualState.Processing) + SwipeDirection.Up, + SwipeDirection.Down -> applyVisualState(OverlayVisualState.Error) } } @@ -459,6 +485,27 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList when (direction) { SwipeDirection.Left -> handleLeftSwipe() SwipeDirection.Right -> finalizeQaFromOverlay() + SwipeDirection.Up, + SwipeDirection.Down -> cancelRecordingFromOverlay(direction) + } + } + + private fun cancelRecordingFromOverlay(direction: SwipeDirection) { + if (!recording || !matchesConfiguredCancelSwipe(direction)) { + return + } + try { + OpenLessNative.nativeCancelDictation() + recording = false + processing = false + longPressRecording = false + setArmed(false) + applyVisualState(OverlayVisualState.Idle) + Log.i(TAG, "recording cancelled from overlay direction=$direction") + } catch (error: Throwable) { + Log.w(TAG, "cancel dictation bridge unavailable", error) + applyVisualState(OverlayVisualState.Error) + showToast("语音服务未就绪,请打开 OpenLess 后重试") } } @@ -648,6 +695,12 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList return (value * resources.displayMetrics.density).toInt() } + private inline fun withOverlayLock(block: () -> T): T { + return synchronized(overlayLock) { + block() + } + } + private data class VisualStyle( val alpha: Float, val fill: Int, @@ -667,6 +720,8 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private enum class SwipeDirection { Left, Right, + Up, + Down, } companion object { @@ -690,6 +745,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private const val NOTIFICATION_ID = 42001 private const val TAG = "OpenLessOverlayService" + private val overlayLock = Any() private val overlayRoots = mutableListOf() @Volatile diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index 23649f0c..d3189de8 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -213,6 +213,27 @@ pub mod android { .map_err(|error| format!("Settings.canDrawOverlays: {error}")) } + pub fn check_self_permission( + env: &mut JNIEnv, + context: &JObject, + permission: &str, + ) -> Result { + if android_sdk_int(env)? < 23 { + return Ok(true); + } + let permission_obj = jobject_str(env, permission)?; + let result = env + .call_method( + context, + "checkSelfPermission", + "(Ljava/lang/String;)I", + &[JValue::Object(&permission_obj)], + ) + .and_then(|value| value.i()) + .map_err(|error| format!("Context.checkSelfPermission({permission}): {error}"))?; + Ok(result == 0) + } + pub fn android_sdk_int(env: &mut JNIEnv) -> Result { env.get_static_field("android/os/Build$VERSION", "SDK_INT", "I") .and_then(|value| value.i()) diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index c8fdb657..dd5c0b66 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -37,9 +37,6 @@ pub fn show_overlay() -> Result<(), String> { } pub fn hide_overlay() -> Result<(), String> { - if !is_overlay_visible() { - return Ok(()); - } #[cfg(target_os = "android")] { crate::android::jni::android::with_android_env(|env, context| { diff --git a/openless-all/app/src-tauri/src/android/types.rs b/openless-all/app/src-tauri/src/android/types.rs index a734352e..b67cd6d3 100644 --- a/openless-all/app/src-tauri/src/android/types.rs +++ b/openless-all/app/src-tauri/src/android/types.rs @@ -42,6 +42,13 @@ pub enum AndroidOverlayLeftSwipeAction { StylePack, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AndroidOverlayCancelSwipeDirection { + Up, + Down, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum AndroidAccessibilityState { @@ -74,6 +81,10 @@ pub fn default_android_overlay_left_swipe_action() -> AndroidOverlayLeftSwipeAct AndroidOverlayLeftSwipeAction::Translation } +pub fn default_android_overlay_cancel_swipe_direction() -> AndroidOverlayCancelSwipeDirection { + AndroidOverlayCancelSwipeDirection::Up +} + pub fn default_android_overlay_size_dp() -> u32 { 72 } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 4f10394b..79ff786e 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -519,6 +519,7 @@ impl Coordinator { use crate::types::AndroidOverlayTrigger; match self.android_overlay_trigger() { AndroidOverlayTrigger::Always => { + let _ = crate::android::hide_android_overlay(); let _ = crate::android::show_android_overlay(); } AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard => { diff --git a/openless-all/app/src-tauri/src/permissions.rs b/openless-all/app/src-tauri/src/permissions.rs index b3cad35a..7f07be69 100644 --- a/openless-all/app/src-tauri/src/permissions.rs +++ b/openless-all/app/src-tauri/src/permissions.rs @@ -258,11 +258,21 @@ mod platform { PermissionStatus::NotApplicable } - /// Android 麦克风:runtime permission 由前端 WebView/系统授权流程触发并维护。 - /// Rust 侧没有稳定的直接 query hook;这里避免在已授权后被桌面权限门禁拦截。 - /// 真实录音能力仍由用户触发 dictation 时的 Recorder::start 决定。 pub fn check_microphone() -> PermissionStatus { - PermissionStatus::Granted + match crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::check_self_permission( + env, + context, + "android.permission.RECORD_AUDIO", + ) + }) { + Ok(true) => PermissionStatus::Granted, + Ok(false) => PermissionStatus::Denied, + Err(error) => { + log::warn!("[mic] Android RECORD_AUDIO permission check failed: {error}"); + PermissionStatus::NotDetermined + } + } } pub fn request_microphone() -> PermissionStatus { diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 26c64f67..07f0a752 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -6,16 +6,17 @@ use serde::{Deserialize, Serialize}; #[path = "android/types.rs"] pub mod android_types; -pub use android_types::{ - AndroidAccessibilityState, AndroidAccessibilityStatus, AndroidInsertStrategy, - AndroidOverlayActivationMode, AndroidOverlayLeftSwipeAction, AndroidOverlayPermissionState, - AndroidOverlayStatus, AndroidOverlayTrigger, -}; use android_types::{ default_android_insert_strategy, default_android_overlay_activation_mode, - default_android_overlay_left_swipe_action, default_android_overlay_size_dp, - default_android_overlay_trigger, normalize_android_insert_strategy, - normalize_android_overlay_size_dp, + default_android_overlay_cancel_swipe_direction, default_android_overlay_left_swipe_action, + default_android_overlay_size_dp, default_android_overlay_trigger, + normalize_android_insert_strategy, normalize_android_overlay_size_dp, +}; +pub use android_types::{ + AndroidAccessibilityState, AndroidAccessibilityStatus, AndroidInsertStrategy, + AndroidOverlayActivationMode, AndroidOverlayCancelSwipeDirection, + AndroidOverlayLeftSwipeAction, AndroidOverlayPermissionState, AndroidOverlayStatus, + AndroidOverlayTrigger, }; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -776,6 +777,9 @@ pub struct UserPreferences { /// Android: action performed by left swiping while the overlay is armed. #[serde(default = "default_android_overlay_left_swipe_action")] pub android_overlay_left_swipe_action: AndroidOverlayLeftSwipeAction, + /// Android: vertical swipe direction that cancels recording. + #[serde(default = "default_android_overlay_cancel_swipe_direction")] + pub android_overlay_cancel_swipe_direction: AndroidOverlayCancelSwipeDirection, /// Android: floating overlay control diameter in dp. #[serde(default = "default_android_overlay_size_dp")] pub android_overlay_size_dp: u32, @@ -936,6 +940,8 @@ struct UserPreferencesWire { android_overlay_activation_mode: AndroidOverlayActivationMode, #[serde(default = "default_android_overlay_left_swipe_action")] android_overlay_left_swipe_action: AndroidOverlayLeftSwipeAction, + #[serde(default = "default_android_overlay_cancel_swipe_direction")] + android_overlay_cancel_swipe_direction: AndroidOverlayCancelSwipeDirection, #[serde(default = "default_android_overlay_size_dp")] android_overlay_size_dp: u32, } @@ -1009,6 +1015,7 @@ impl Default for UserPreferencesWire { android_overlay_trigger: prefs.android_overlay_trigger, android_overlay_activation_mode: prefs.android_overlay_activation_mode, android_overlay_left_swipe_action: prefs.android_overlay_left_swipe_action, + android_overlay_cancel_swipe_direction: prefs.android_overlay_cancel_swipe_direction, android_overlay_size_dp: prefs.android_overlay_size_dp, } } @@ -1112,6 +1119,7 @@ impl<'de> Deserialize<'de> for UserPreferences { android_overlay_trigger: wire.android_overlay_trigger.normalized(), android_overlay_activation_mode: wire.android_overlay_activation_mode, android_overlay_left_swipe_action: wire.android_overlay_left_swipe_action, + android_overlay_cancel_swipe_direction: wire.android_overlay_cancel_swipe_direction, android_overlay_size_dp: normalize_android_overlay_size_dp( wire.android_overlay_size_dp, ), @@ -1839,6 +1847,8 @@ impl Default for UserPreferences { android_overlay_trigger: default_android_overlay_trigger(), android_overlay_activation_mode: default_android_overlay_activation_mode(), android_overlay_left_swipe_action: default_android_overlay_left_swipe_action(), + android_overlay_cancel_swipe_direction: default_android_overlay_cancel_swipe_direction( + ), android_overlay_size_dp: default_android_overlay_size_dp(), } } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 9552f263..798ebacf 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -830,6 +830,7 @@ export const en: typeof zhCN = { androidOverlayTriggerLabel: 'Overlay visibility', androidOverlayActivationModeLabel: 'Overlay activation', androidOverlayLeftSwipeActionLabel: 'Left swipe action', + androidOverlayCancelSwipeDirectionLabel: 'Cancel swipe direction', androidOverlaySizeLabel: 'Overlay size', androidOverlaySizeHint: 'Adjusts the floating button diameter and keeps its current position.', androidInsertStrategy: { @@ -869,6 +870,14 @@ export const en: typeof zhCN = { translation: 'Left swipe while armed starts translation dictation.', style_pack: 'Left swipe while armed switches to the previous style pack.', }, + androidOverlayCancelSwipeDirection: { + up: 'Swipe up', + down: 'Swipe down', + }, + androidOverlayCancelSwipeDirectionHint: { + up: 'Swipe up while recording to cancel without transcription or insertion.', + down: 'Swipe down while recording to cancel without transcription or insertion.', + }, windowsIme: { installed: 'Installed. Voice input temporarily switches to the OpenLess IME.', notInstalled: 'Not installed. OpenLess is using the clipboard/WM_PASTE fallback.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index eaffbee9..d598d060 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -832,6 +832,7 @@ export const ja: typeof zhCN = { androidOverlayTriggerLabel: '表示タイミング', androidOverlayActivationModeLabel: '起動方法', androidOverlayLeftSwipeActionLabel: '左スワイプ動作', + androidOverlayCancelSwipeDirectionLabel: 'キャンセル方向', androidOverlaySizeLabel: 'オーバーレイサイズ', androidOverlaySizeHint: 'フローティングボタンの直径を調整し、現在位置を保持します。', androidInsertStrategy: { accessibility: '入力欄へ自動出力', clipboard: 'クリップボードのみ' }, @@ -843,6 +844,8 @@ export const ja: typeof zhCN = { androidOverlayActivationModeHint: { tap: '1回目のタップで待機状態に入り、2回目のタップで通常の音声入力を開始します。', long_press: '押している間だけ待機状態に入り、離すと現在の録音またはQAターンを終了します。' }, androidOverlayLeftSwipeAction: { translation: '翻訳入力', style_pack: 'スタイルパック切替' }, androidOverlayLeftSwipeActionHint: { translation: '待機状態で左スワイプすると翻訳入力を開始します。', style_pack: '待機状態で左スワイプすると前のスタイルパックへ切り替えます。' }, + androidOverlayCancelSwipeDirection: { up: '上へスワイプ', down: '下へスワイプ' }, + androidOverlayCancelSwipeDirectionHint: { up: '録音中に上へスワイプすると、文字起こしや挿入をせずにキャンセルします。', down: '録音中に下へスワイプすると、文字起こしや挿入をせずにキャンセルします。' }, windowsIme: { installed: 'インストール済み。音声入力時に OpenLess IME へ一時的に切り替えます。', notInstalled: '未インストール。OpenLess は現在クリップボード / WM_PASTE フォールバックを使用しています。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 6dab5855..9b1d66bb 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -832,6 +832,7 @@ export const ko: typeof zhCN = { androidOverlayTriggerLabel: '오버레이 표시', androidOverlayActivationModeLabel: '오버레이 활성화', androidOverlayLeftSwipeActionLabel: '왼쪽 스와이프 동작', + androidOverlayCancelSwipeDirectionLabel: '취소 스와이프 방향', androidOverlaySizeLabel: '오버레이 크기', androidOverlaySizeHint: '플로팅 버튼 지름을 조정하고 현재 위치를 유지합니다.', androidInsertStrategy: { accessibility: '입력칸에 자동 출력', clipboard: '클립보드만' }, @@ -843,6 +844,8 @@ export const ko: typeof zhCN = { androidOverlayActivationModeHint: { tap: '첫 탭은 대기 상태로 전환하고, 두 번째 탭은 일반 받아쓰기를 시작합니다.', long_press: '누르고 있는 동안 대기 상태가 되며, 손을 떼면 현재 녹음 또는 QA 턴을 종료합니다.' }, androidOverlayLeftSwipeAction: { translation: '번역 받아쓰기', style_pack: '스타일 팩 전환' }, androidOverlayLeftSwipeActionHint: { translation: '대기 상태에서 왼쪽으로 밀면 번역 받아쓰기를 시작합니다.', style_pack: '대기 상태에서 왼쪽으로 밀면 이전 스타일 팩으로 전환합니다.' }, + androidOverlayCancelSwipeDirection: { up: '위로 스와이프', down: '아래로 스와이프' }, + androidOverlayCancelSwipeDirectionHint: { up: '녹음 중 위로 밀면 전사와 삽입 없이 취소합니다.', down: '녹음 중 아래로 밀면 전사와 삽입 없이 취소합니다.' }, windowsIme: { installed: '설치됨. 음성 입력 시 OpenLess 입력기로 일시 전환됩니다.', notInstalled: '설치되지 않음. OpenLess 는 현재 클립보드 / WM_PASTE 폴백을 사용합니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index bb0f87fa..e901cffe 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -828,6 +828,7 @@ export const zhCN = { androidOverlayTriggerLabel: '悬浮窗显示时机', androidOverlayActivationModeLabel: '悬浮窗激活方式', androidOverlayLeftSwipeActionLabel: '左滑动作', + androidOverlayCancelSwipeDirectionLabel: '取消录音滑向', androidOverlaySizeLabel: '悬浮窗大小', androidOverlaySizeHint: '调整悬浮按钮直径,保存后在当前悬浮窗上生效并保留位置。', androidInsertStrategy: { @@ -867,6 +868,14 @@ export const zhCN = { translation: '激活态左滑后按翻译模式录音。', style_pack: '激活态左滑后切换到上一个风格包。', }, + androidOverlayCancelSwipeDirection: { + up: '向上滑', + down: '向下滑', + }, + androidOverlayCancelSwipeDirectionHint: { + up: '录音中向上滑取消本次听写,不转写、不插入。', + down: '录音中向下滑取消本次听写,不转写、不插入。', + }, windowsIme: { installed: '已安装,按需切到 OpenLess 输入法。', notInstalled: '未安装,走剪贴板 / WM_PASTE 兜底。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 136b5739..78d5403c 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -830,6 +830,7 @@ export const zhTW: typeof zhCN = { androidOverlayTriggerLabel: '懸浮窗顯示時機', androidOverlayActivationModeLabel: '懸浮窗啟用方式', androidOverlayLeftSwipeActionLabel: '左滑動作', + androidOverlayCancelSwipeDirectionLabel: '取消錄音滑向', androidOverlaySizeLabel: '懸浮窗大小', androidOverlaySizeHint: '調整懸浮按鈕直徑,儲存後在目前懸浮窗上生效並保留位置。', androidInsertStrategy: { accessibility: '自動輸出到輸入框', clipboard: '僅剪貼簿' }, @@ -841,6 +842,8 @@ export const zhTW: typeof zhCN = { androidOverlayActivationModeHint: { tap: '第一次點按進入啟用狀態,第二次點按開始普通聽寫。', long_press: '按住進入啟用狀態;放開時結束目前錄音或問答輪次。' }, androidOverlayLeftSwipeAction: { translation: '翻譯聽寫', style_pack: '切換風格包' }, androidOverlayLeftSwipeActionHint: { translation: '啟用狀態左滑後按翻譯模式錄音。', style_pack: '啟用狀態左滑後切換到上一個風格包。' }, + androidOverlayCancelSwipeDirection: { up: '向上滑', down: '向下滑' }, + androidOverlayCancelSwipeDirectionHint: { up: '錄音中向上滑取消本次聽寫,不轉寫、不插入。', down: '錄音中向下滑取消本次聽寫,不轉寫、不插入。' }, windowsIme: { installed: '已安裝。語音輸入時會臨時切換到 OpenLess 輸入法。', notInstalled: '未安裝。OpenLess 正在使用剪貼板 / WM_PASTE 兜底。', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index b7075db7..c2e3424c 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -190,6 +190,7 @@ let mockSettings: UserPreferences = { androidOverlayTrigger: "background", androidOverlayActivationMode: "tap", androidOverlayLeftSwipeAction: "translation", + androidOverlayCancelSwipeDirection: "up", androidOverlaySizeDp: 72, } diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 3221df6a..122871e5 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -84,6 +84,7 @@ const previousPrefs: UserPreferences = { androidOverlayTrigger: 'background', androidOverlayActivationMode: 'tap', androidOverlayLeftSwipeAction: 'translation', + androidOverlayCancelSwipeDirection: 'up', androidOverlaySizeDp: 72, }; diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index f707c885..0b67a230 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -6,6 +6,7 @@ import type { AndroidAccessibilityStatus, AndroidInsertStrategy, AndroidOverlayActivationMode, + AndroidOverlayCancelSwipeDirection, AndroidOverlayLeftSwipeAction, AndroidOverlayStatus, AndroidOverlayTrigger, @@ -15,6 +16,7 @@ export type { AndroidAccessibilityStatus, AndroidInsertStrategy, AndroidOverlayActivationMode, + AndroidOverlayCancelSwipeDirection, AndroidOverlayLeftSwipeAction, AndroidOverlayStatus, AndroidOverlayTrigger, @@ -364,6 +366,8 @@ export interface UserPreferences { androidOverlayActivationMode: AndroidOverlayActivationMode; /** Android: action performed by left swiping while the overlay is armed. */ androidOverlayLeftSwipeAction: AndroidOverlayLeftSwipeAction; + /** Android: vertical swipe direction that cancels recording. */ + androidOverlayCancelSwipeDirection: AndroidOverlayCancelSwipeDirection; /** Android: floating overlay control diameter in dp. */ androidOverlaySizeDp: number; } From 582af09cc72a634bc385102032d96f2f908d816d Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Thu, 11 Jun 2026 19:32:36 +0800 Subject: [PATCH 81/83] Complete Android setup and QA permission flow --- .../components/AndroidPermissionsPanel.tsx | 20 +- .../lib/androidMicrophonePermission.ts | 9 +- .../kotlin/MicrophonePermissionActivity.kt | 49 ++ .../android/kotlin/OpenLessOverlayService.kt | 6 +- .../kotlin/OpenLessPermissionBridge.kt | 46 ++ .../app/scripts/copy-android-scaffolding.mjs | 2 + .../merge-android-overlay-manifest.mjs | 4 + .../android/drawable/ic_overlay_logo.xml | 4 + .../app/src-tauri/src/android/insert.rs | 2 +- openless-all/app/src-tauri/src/android/jni.rs | 87 ++- openless-all/app/src-tauri/src/android/mod.rs | 4 +- openless-all/app/src-tauri/src/commands.rs | 30 +- openless-all/app/src-tauri/src/coordinator.rs | 62 +- openless-all/app/src-tauri/src/lib.rs | 3 +- openless-all/app/src-tauri/src/permissions.rs | 11 +- openless-all/app/src/App.tsx | 13 +- .../app/src/components/FloatingShell.tsx | 6 + .../app/src/components/Onboarding.tsx | 557 ++++++++++++++---- openless-all/app/src/i18n/en.ts | 23 + openless-all/app/src/i18n/ja.ts | 23 + openless-all/app/src/i18n/ko.ts | 23 + openless-all/app/src/i18n/zh-CN.ts | 23 + openless-all/app/src/i18n/zh-TW.ts | 23 + openless-all/app/src/lib/ipc.ts | 6 +- openless-all/app/src/pages/Overview.tsx | 15 +- openless-all/app/src/pages/QaPanel.tsx | 119 +++- .../src/pages/settings/PermissionsSection.tsx | 29 +- .../src/pages/settings/ProvidersSection.tsx | 16 +- 28 files changed, 1045 insertions(+), 170 deletions(-) create mode 100644 openless-all/app/android/kotlin/MicrophonePermissionActivity.kt create mode 100644 openless-all/app/android/kotlin/OpenLessPermissionBridge.kt create mode 100644 openless-all/app/src-tauri/icons/android/drawable/ic_overlay_logo.xml diff --git a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx index 2c9451fa..e0837bb5 100644 --- a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx +++ b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx @@ -26,7 +26,13 @@ import { normalizeAndroidOverlayTrigger, } from '../lib/androidTypes'; -export function AndroidPermissionsPanel() { +type AndroidPermissionsPanelMode = 'all' | 'accessibility' | 'overlayPermission' | 'overlayConfig'; + +interface AndroidPermissionsPanelProps { + mode?: AndroidPermissionsPanelMode; +} + +export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPanelProps) { const { t } = useTranslation(); const [androidOverlay, setAndroidOverlay] = useState(null); const [androidAccessibility, setAndroidAccessibility] = useState(null); @@ -90,8 +96,13 @@ export function AndroidPermissionsPanel() { await refreshAndroid(); }; + const showOverlayPermission = mode === 'all' || mode === 'overlayPermission'; + const showAccessibility = mode === 'all' || mode === 'accessibility'; + const showOverlayConfig = mode === 'all' || mode === 'overlayConfig'; + return ( <> + {showOverlayPermission && (
{androidOverlay?.message && ( @@ -107,6 +118,8 @@ export function AndroidPermissionsPanel() { )}
+ )} + {showAccessibility && (
@@ -127,6 +140,9 @@ export function AndroidPermissionsPanel() {
+ )} + {showOverlayConfig && ( + <>