From 9d5f330764a275c63be58e180fde7d5f881d2a61 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:51:29 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(root)!:=20root=20only=20=E2=80=94=20re?= =?UTF-8?q?move=20Shizuku;=20android-root-cli;=20virtual=20screen=20on=20a?= =?UTF-8?q?=20libsu=20root=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root is the app's one privileged path. The code before this change is archived on the branch archive/before-root-only (6a6b450); the rootless PRoot + Ubuntu backend is shelved as #184. - android-shizuku-cli becomes android-root-cli: the same subcommands and JSON, run as root through RootProcess -> DirectRootRunner (su). The shizuku_cli permission key migrates to root_cli; a stale android-shizuku-cli wrapper in an existing rootfs is removed on the next guest CLI install. - The virtual screen service is hosted by a libsu RootService (VirtualScreenRootService, daemon mode) instead of a Shizuku UserService, and accepts uid 0 only. The Shizuku-reserved destroy() leaves the AIDL; libsu ends the daemon on app update, so no service version is needed. Probes saved under the Shizuku host no longer count (host marker in the fingerprint): the self-check must pass again before it is re-enabled. - Accessibility repair, clearing restricted settings and one-tap authorize use Root. Settings > System gets a Root page in place of "Root & Shizuku"; the readiness checklist drops Shizuku. - Removed: ShizukuManager/ShizukuBackend, the Shizuku settings page, the Shizuku API/provider dependency, manifest provider/permission/queries, and every Shizuku string in all eight locales (new strings translated). - Contracts: AGENTS.md rule 3 and 04 list android-root-cli (with its raw `exec` root shell) and the virtual-screen root service; 05 describes the new VScreen host; 06 records what is unverified on a device and two gaps (MCP can reach android-root-cli through linux.shell; #182 not built). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PV4ECd3Yzu9k81sw7vHjS3 --- AGENTS.md | 2 +- CHANGELOG.md | 5 + CONTRIBUTING.zh-CN.md | 2 +- PRIVACY.md | 2 +- PRIVACY.zh-CN.md | 2 +- PROVENANCE.md | 3 +- THIRD_PARTY_LICENSES.md | 4 +- docs/I18N.md | 2 +- docs/PROJECT.md | 4 +- docs/SECURITY.md | 2 +- docs/contracts/01-ARCHITECTURE.md | 2 +- docs/contracts/04-SECURITY-CONTRACT.md | 2 + docs/contracts/05-ENGINEERING.md | 4 +- docs/contracts/06-CURRENT-GAPS.md | 28 +- docs/design/DESIGN-GAP-MATRIX.md | 2 +- docs/design/SETTINGS-IA-AUDIT.md | 2 +- docs/design/UI-DESIGN-LANGUAGE.md | 2 +- src/android/app/build.gradle.kts | 8 +- src/android/app/lint-baseline.xml | 450 ------------------ src/android/app/proguard-rules.pro | 5 +- src/android/app/src/main/AndroidManifest.xml | 44 +- .../vscreen/IVirtualScreenService.aidl | 2 - .../main/assets/prompts/android.debugLoop.md | 2 +- .../src/main/assets/prompts/android.tools.md | 4 +- .../java/com/openminis/app/MainActivity.kt | 6 +- .../main/java/com/openminis/app/MinisApp.kt | 13 +- .../AccessibilityRecoveryManager.kt | 58 +-- .../RestrictedSettingsManager.kt | 18 +- .../app/debug/DebugMethodRegistry.kt | 8 +- .../app/debug/DebugRPCHandlerDispatch.kt | 4 +- .../app/debug/DebugRPCHandlerSupport.kt | 20 +- .../openminis/app/deeplink/DeepLinkHandler.kt | 2 +- .../app/offload/OffloadPermissionManager.kt | 31 +- .../com/openminis/app/offload/RootProcess.kt | 63 +++ .../openminis/app/offload/ShizukuBackend.kt | 333 ------------- .../openminis/app/offload/ShizukuManager.kt | 161 ------- .../permissions/RuntimePermissionGranter.kt | 37 +- .../app/prompt/PromptModuleRegistry.kt | 2 +- .../guest/AccessibilityOffloadHandler.kt | 12 +- .../app/runtime/guest/DebugOffloadHandler.kt | 6 +- .../app/runtime/guest/GuestCommandBridge.kt | 14 +- ...oadHandler.kt => RootCliOffloadHandler.kt} | 320 ++++++------- .../app/tools/android/AndroidAgentTools.kt | 6 +- .../android/AndroidCapabilityResolver.kt | 17 +- .../tools/android/AndroidPackageController.kt | 2 +- .../tools/android/PrivilegedCommandRunner.kt | 2 +- .../tools/android/ShellActionOutcomePolicy.kt | 2 +- .../tools/android/VirtualScreenUiBackend.kt | 2 +- .../android/vscreen/VirtualScreenClient.kt | 92 ++-- .../vscreen/VirtualScreenPreferences.kt | 8 + .../service/VirtualScreenRootService.kt | 27 ++ .../service/VirtualScreenUserService.kt | 44 +- .../vscreen/service/internal/ShellContext.kt | 2 +- .../vscreen/service/internal/UiBridge.kt | 4 +- .../app/ui/navigation/AppNavigation.kt | 21 +- .../app/ui/navigation/AppNavigationRoutes.kt | 8 +- .../ui/settings/OffloadPermissionScreen.kt | 48 +- .../app/ui/settings/RootAccessScreen.kt | 104 ++++ .../app/ui/settings/SettingsCategoryScreen.kt | 13 +- .../app/ui/settings/SettingsReadiness.kt | 12 +- .../ui/settings/ShizukuPermissionScreen.kt | 255 ---------- .../app/ui/settings/SystemEnhanceScreen.kt | 2 +- .../ui/settings/SystemPermissionsScreen.kt | 22 +- .../ui/settings/VirtualScreenCopyPolicy.kt | 19 +- .../settings/VirtualScreenSettingsScreen.kt | 8 +- .../ui/vscreen/VirtualScreenHomeActivity.kt | 2 +- .../values-de/restricted_settings_strings.xml | 4 +- .../app/src/main/res/values-de/strings.xml | 63 ++- .../values-fr/restricted_settings_strings.xml | 4 +- .../app/src/main/res/values-fr/strings.xml | 63 ++- .../values-ja/restricted_settings_strings.xml | 4 +- .../app/src/main/res/values-ja/strings.xml | 63 ++- .../values-ko/restricted_settings_strings.xml | 4 +- .../app/src/main/res/values-ko/strings.xml | 63 ++- .../values-ru/restricted_settings_strings.xml | 4 +- .../app/src/main/res/values-ru/strings.xml | 104 ++-- .../restricted_settings_strings.xml | 4 +- .../src/main/res/values-zh-rTW/strings.xml | 106 ++--- .../values-zh/restricted_settings_strings.xml | 4 +- .../app/src/main/res/values-zh/strings.xml | 111 ++--- .../values/restricted_settings_strings.xml | 4 +- .../app/src/main/res/values/strings.xml | 111 ++--- .../main/res/xml/network_security_config.xml | 3 +- .../openminis/app/offload/RootProcessTest.kt | 38 ++ .../app/offload/ShizukuBackendStateTest.kt | 138 ------ .../runtime/guest/GuestCommandBridgeTest.kt | 16 + ...putTextTest.kt => RootCliInputTextTest.kt} | 10 +- ...ageUidTest.kt => RootCliPackageUidTest.kt} | 18 +- .../scheduled/ScheduledReadOnlyPolicyTest.kt | 2 +- .../vscreen/VirtualScreenPolicyTest.kt | 8 + .../app/ui/settings/SettingsReadinessTest.kt | 11 +- .../settings/VirtualScreenCopyPolicyTest.kt | 13 +- .../prompts/legacy_modules_memory_off.txt | 6 +- .../prompts/legacy_modules_memory_on.txt | 6 +- 94 files changed, 1066 insertions(+), 2334 deletions(-) create mode 100644 src/android/app/src/main/java/com/openminis/app/offload/RootProcess.kt delete mode 100644 src/android/app/src/main/java/com/openminis/app/offload/ShizukuBackend.kt delete mode 100644 src/android/app/src/main/java/com/openminis/app/offload/ShizukuManager.kt rename src/android/app/src/main/java/com/openminis/app/runtime/guest/{ShizukuOffloadHandler.kt => RootCliOffloadHandler.kt} (82%) create mode 100644 src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/VirtualScreenRootService.kt create mode 100644 src/android/app/src/main/java/com/openminis/app/ui/settings/RootAccessScreen.kt delete mode 100644 src/android/app/src/main/java/com/openminis/app/ui/settings/ShizukuPermissionScreen.kt create mode 100644 src/android/app/src/test/java/com/openminis/app/offload/RootProcessTest.kt delete mode 100644 src/android/app/src/test/java/com/openminis/app/offload/ShizukuBackendStateTest.kt rename src/android/app/src/test/java/com/openminis/app/runtime/guest/{ShizukuInputTextTest.kt => RootCliInputTextTest.kt} (52%) rename src/android/app/src/test/java/com/openminis/app/runtime/guest/{ShizukuPackageUidTest.kt => RootCliPackageUidTest.kt} (56%) diff --git a/AGENTS.md b/AGENTS.md index 9a9027e1..707c164b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ 1. 现役 guest 用户数据由 App 私有目录持有,host backing 从 `Context.filesDir` 派生;`/data/adb/minis/rootfs` 是 Root-owned、可替换 runtime state。 2. Guest 使用设备实际 App UID/GID;进入 chroot 后必须清空 supplementary groups 与 Linux capabilities,禁止写死 `10000`。 -3. `DirectRootRunner` 只允许执行 App 构造的受控基础设施动作:rootfs、namespace、bind、chroot、必要探测/修复和受控 legacy migration。按上游权限模型,本地 Agent 可使用结构化 `root.shell`(tool basename + args)调用受信 Android system 工具;该能力必须 local-only、有参数/超时/输出/进程组边界,不接受 raw command、host 文件 API 或通用 Root RPC,MCP/远程调用方不可见。唯一的另一个 Root 代办入口是 guest CLI `minis-apt`(仅 `update`/`install`/`remove` 纯包名,命令由 App 构造,会移除运行时必需包的 `remove` 被拒绝,详见 `docs/contracts/04-SECURITY-CONTRACT.md`)。 +3. `DirectRootRunner` 只允许执行 App 构造的受控基础设施动作:rootfs、namespace、bind、chroot、必要探测/修复和受控 legacy migration。按上游权限模型,本地 Agent 可使用结构化 `root.shell`(tool basename + args)调用受信 Android system 工具;该能力必须 local-only、有参数/超时/输出/进程组边界,不接受 raw command、host 文件 API 或通用 Root RPC,MCP/远程调用方不可见。另外两个 Root 代办入口都是 guest CLI:`minis-apt`(仅 `update`/`install`/`remove` 纯包名,命令由 App 构造,会移除运行时必需包的 `remove` 被拒绝)和 `android-root-cli`(Android 系统工具的整理子命令,加 `exec` 原始 root shell;权限开关默认关闭)。两者详见 `docs/contracts/04-SECURITY-CONTRACT.md`。项目只走 Root:不接入 Shizuku 或其它特权代理。 4. 网络代理和 Root/chroot 是不同概念。HTTP/CONNECT 代理协议本身不要求 Root;当前 helper 仅因 Android 出站 UID/VPN/BPF 兼容需求可由特权身份启动。不得把它扩展成命令、文件或通用 RPC 服务。 5. 产品运行时不恢复 PRoot/Alpine 双栈;不要为了兼容全局关闭 SELinux。 6. Session 执行必须保持对应 session workspace 语义,不能用全局 `/workspace` 绕过隔离。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f866972..868cc5fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ This changelog tracks **Minis for Android** as an independently maintained Andro ## Unreleased +- Root only: Shizuku (and AXManager / Sui) support is removed; Root is the app's one privileged path. The code before this change is kept on the branch `archive/before-root-only`. +- `android-shizuku-cli` is now `android-root-cli`: the same subcommands and JSON, run as root through `su`. Its Agent permission switch carries over. +- The virtual screen runs in a root service (libsu) instead of a Shizuku UserService. Run its compatibility check once more to switch it back on. +- Accessibility repair, clearing restricted settings and one-tap authorize use Root; Settings > System has a Root page in place of "Root & Shizuku", and the readiness checklist no longer lists Shizuku. + ## 0.0.2 — 2026-10-10 - The terminal understands OSC 7501 (program status): a program reports working / done / blocked / error and the top bar shows it; the feature probe `OSC 7501;?` is answered. diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md index 07e820a5..3728a99a 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -36,7 +36,7 @@ Android App → ExecutionCoordinator / App-owned shell → Ubuntu 24.04 bash ``` -普通 guest/Agent 命令不是 Root 命令。MCP 必须进入同一工具注册与权限边界。普通 Android API、Accessibility、Shizuku 与 Root 是不同能力。 +普通 guest/Agent 命令不是 Root 命令。MCP 必须进入同一工具注册与权限边界。普通 Android API、Accessibility 与 Root 是不同能力。 ## 文档与历史记录 diff --git a/PRIVACY.md b/PRIVACY.md index ee0a2eab..fabedd6c 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -65,7 +65,7 @@ The app declares many permissions because its tools are optional abilities: loca SMS and call log, camera, microphone, photos and media, all-files access, Bluetooth and nearby Wi-Fi, usage statistics, notifications (including full-screen notifications), drawing over other apps, accessibility control, package installation, exact alarms, changing system settings, battery-optimisation and Data Saver -exemptions, and Root or Shizuku access. Android asks you before a permission is used, and you can withdraw it in system +exemptions, and Root access. Android asks you before a permission is used, and you can withdraw it in system settings at any time. A permission is used on the device to carry out the action you or the agent asked for. What a tool returns diff --git a/PRIVACY.zh-CN.md b/PRIVACY.zh-CN.md index 9cb4c18a..43558b90 100644 --- a/PRIVACY.zh-CN.md +++ b/PRIVACY.zh-CN.md @@ -53,7 +53,7 @@ MCP 服务器、网站与备份服务由其他方运营,各有其隐私政策 ## 4. 设备数据与权限 应用声明了较多权限,因为它的工具是可选能力:位置、通讯录、日历、短信与通话记录、相机、麦克风、照片与媒体、全部文件访问、 -蓝牙与附近 Wi-Fi、使用情况统计、通知(含全屏通知)、悬浮窗、无障碍控制、安装软件包、精确闹钟、修改系统设置、电池优化与流量节省豁免,以及 Root 或 Shizuku。使用某项权限前 Android 会询问你, +蓝牙与附近 Wi-Fi、使用情况统计、通知(含全屏通知)、悬浮窗、无障碍控制、安装软件包、精确闹钟、修改系统设置、电池优化与流量节省豁免,以及 Root。使用某项权限前 Android 会询问你, 你可随时在系统设置中撤回。 权限在设备上用于完成你或 Agent 请求的操作。工具返回的内容会成为对话的一部分,而对话会发往你的模型服务商。如果你不希望模型看到某些内容, diff --git a/PROVENANCE.md b/PROVENANCE.md index ed341347..904cfdb4 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -45,7 +45,8 @@ Minis for Android 含有从开源项目 **OpenMinis** 衍生的代码: file and the migrated source files have no file-level copyright headers; each adapted file records its original path, pinned commit, Apache-2.0 notice, and the fact that it was modified. No copyright owner has been inferred from the Git commit author. - - The port is limited to Shizuku shell UserService support for a virtual display, display-targeted UI/input, + - The port is limited to the privileged-process support for a virtual display (first hosted in a Shizuku shell + UserService, since 2026-10 in a libsu root service), display-targeted UI/input, IME policy, clipboard bridge, and bounded screenshots; upstream Main/automation/socket/video/OCR/root paths are not imported. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 65aa40fc..29f44543 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -153,7 +153,7 @@ commit; the license terms are not repeated per file. New ports append a row here | Component | Source | License | Current use | |---|---|---|---| -| Adapted VScreen support code | [android-notes/ShadowAuto](https://github.com/android-notes/ShadowAuto) @ `611e5eb0e1c94befda3c17c97438912dfd30e8a8` | Apache-2.0 | Shizuku shell UserService, virtual-display session, display-targeted UI/input, IME policy, clipboard bridge and bounded screenshots | +| Adapted VScreen support code | [android-notes/ShadowAuto](https://github.com/android-notes/ShadowAuto) @ `611e5eb0e1c94befda3c17c97438912dfd30e8a8` | Apache-2.0 | Privileged-process context bootstrap (now hosted in a libsu root service; originally a Shizuku shell UserService), virtual-display session, display-targeted UI/input, IME policy, clipboard bridge and bounded screenshots | License text: [third_party/shadowauto/LICENSE](third_party/shadowauto/LICENSE), copied from the pinned source commit. The pinned tree has no separate `NOTICE`, and the migrated source files carry no file-level copyright headers. Each adapted file therefore records its exact upstream path and commit plus an Apache-2.0 notice and modification statement; no copyright owner is inferred from the commit author. @@ -202,7 +202,7 @@ Source for those packages is published by the Ubuntu project for 24.04 (noble), | multiplatform-markdown-renderer | Apache-2.0 | | Reorderable | Apache-2.0 | | ACRA | Apache-2.0 | -| Shizuku API/provider | MIT | +| libsu core/service (topjohnwu) | Apache-2.0 | | RealTimeCutVADLibraryForAndroid | MIT | | JUnit 4 | EPL-1.0 | | org.json test dependency | Public Domain / JSON License | diff --git a/docs/I18N.md b/docs/I18N.md index 2e338ea2..b2ca0bee 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -28,7 +28,7 @@ 1. **新文案一律进资源,8 语言同步**:默认英文 + zh / zh-rTW / ja / ko / de / fr / ru。 2. **界面之外的不进资源**:模型侧提示词正文(人格、系统提示词)、guest CLI 的命令输出与帮助文本、 日志、测试夹具、数据表——它们不是用户界面文案,翻译反而会改变模型输入。 -3. **品牌与协议名原样保留**:Minis、Shizuku、MCP、HTTP、SSE、STDIO、JSON、Token、URL、 +3. **品牌与协议名原样保留**:Minis、MCP、HTTP、SSE、STDIO、JSON、Token、URL、 Markdown、OAuth、GitHub、Claude Desktop、Responses API、Chat Completions、Azure OpenAI 等。 4. **slash 命令 token 不翻译**:`/model`、`/permission`、`/goal`、`/plan` 是插入到输入框的命令名, 只有它的副标题走资源。 diff --git a/docs/PROJECT.md b/docs/PROJECT.md index d4cb09b0..20a215af 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -20,7 +20,7 @@ Android app(:app) ├─ Agent 循环 / 工具 / 子代理 / 会话(Room)/ 计划与检查点 ├─ Provider(Chat Completions / Responses / Anthropic / Gemini / 各家兼容) ├─ MCP 客户端 + 设备内 MCP 服务、Skills、长期记忆、定时任务 -├─ 无障碍 GUI 控制、Shizuku offload、结构化 root.shell、内置浏览器 +├─ 无障碍 GUI 控制、android-root-cli、结构化 root.shell、内置浏览器 ├─ 语音(VoiceInteractionService / 识别与合成 / 通话)、角色扮演、桌面宠物 ├─ Xposed / LSPosed 系统增强(无障碍保活、电源键接管、厂商记忆等) └─ ExecutionCoordinator → UbuntuKernel / DirectRootRunner @@ -102,7 +102,7 @@ bash scripts/verify-android-16k.sh src/android/app/build/outputs/apk/debug/ 界面文案全部走资源,共 **8 个语言目录**:`values`(英文默认)、`values-zh`、`values-zh-rTW`、`values-ja`、 `values-ko`、`values-de`、`values-fr`、`values-ru`,当前条目 2297。新增文案必须 8 语言同步;品牌与协议名 -(Minis、Shizuku、MCP、HTTP、JSON、Token 等)与 slash 命令 token 不翻译。规则、审计工具与覆盖情况见 +(Minis、MCP、HTTP、JSON、Token 等)与 slash 命令 token 不翻译。规则、审计工具与覆盖情况见 `docs/I18N.md`(`python3 scripts/audit_strings.py`)。 ## 文档地图 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 05cb758e..35c6834b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -4,7 +4,7 @@ ## 不同权限不能混为一谈 -本地 Agent、MCP 调用方、Android API、无障碍、Shizuku、Root 基础设施和网络兼容 helper 是不同能力域。工具复用现有注册表和权限/审批层;UI 勾选不代替执行授权,未知工具默认拒绝,仅本地工具不向 MCP 暴露。 +本地 Agent、MCP 调用方、Android API、无障碍、Root 基础设施和网络兼容 helper 是不同能力域。工具复用现有注册表和权限/审批层;UI 勾选不代替执行授权,未知工具默认拒绝,仅本地工具不向 MCP 暴露。 安全检查围绕已知身份、路径、凭证和调用边界,并有失败/越界测试;不能为了“安全”随意删除正常功能、建立重复权限层或全局关闭 SELinux。 diff --git a/docs/contracts/01-ARCHITECTURE.md b/docs/contracts/01-ARCHITECTURE.md index 41a6720a..0132e14a 100644 --- a/docs/contracts/01-ARCHITECTURE.md +++ b/docs/contracts/01-ARCHITECTURE.md @@ -84,6 +84,6 @@ Ubuntu 24.04 userspace 运行在 Android 内核上。chroot 不是 VM,也不 - 不恢复旧 broker 或 PRoot/Alpine 双运行时; - 不把 guest 宣传成 VM/强沙箱; - 不把网络代理写成 Root/chroot 的天然组成部分; -- 不把 Root、Accessibility、Shizuku、普通 Android API 合并成一条权限阶梯; +- 不把 Root、Accessibility、普通 Android API 合并成一条权限阶梯; - 不通过伪装 FGS 类型维持无限后台寿命; - 不为了 namespace 整洁顺手重命名全库 Kotlin/Java package。 diff --git a/docs/contracts/04-SECURITY-CONTRACT.md b/docs/contracts/04-SECURITY-CONTRACT.md index d9e4a8bb..2c9ffd76 100644 --- a/docs/contracts/04-SECURITY-CONTRACT.md +++ b/docs/contracts/04-SECURITY-CONTRACT.md @@ -16,6 +16,8 @@ - `root.shell` 的 tool 不得是会执行其它代码的程序:shell、`su` 类前端、`toybox`/`busybox` 等多合一程序、`env`/`xargs`/`nohup`/`nsenter` 等启动器、解释器,以及 `find -exec`、`tar --to-command`、`sqlite3 .shell`、`ip netns exec` 这类执行命令的参数。否则数组形式的 argv 照样等于 raw Root 命令。这是在可信路径与 argv 边界之上的拒绝清单,不代表其余 Root 工具无副作用。 - `DirectRootRunner` 的内部脚本动作限于确实需要权限的 rootfs 探测/修复、namespace/bind/chroot、受控 legacy 数据迁移及同类明确基础设施需求;本地 Agent 的 `root.shell` 只走结构化 Android tool/argv 合同,不得被扩展成 raw shell、host 文件或通用 RPC。 - 例外之一:`minis-apt`(guest CLI,经 `AptOffloadHandler`)。guest 以 App UID 运行、无法使用 apt,因此由 App 代为在 rootfs 的一次性 mount namespace 内以 Root 执行 `apt-get update|install|remove`。Agent 只给出包名,命令全部由 App 构造(`AptCommandPolicy`):只接受 `^[a-z0-9][a-z0-9+.-]{0,62}$` 形式的纯包名(拒绝选项、版本/发行版指定、路径、shell 字符),每次最多 20 个;`remove` 先用 apt 的模拟结果检查,若会连带移除运行时必需的包(`UbuntuProvisioner.BASE_PACKAGES` 等)则在移除前拒绝;同一时间只运行一个;走与其它 guest CLI 相同的权限开关(`apt_cli`,设置里可关闭);只影响 Ubuntu 环境,不触及 Android 系统与宿主文件。它不是通用 Root 执行入口,不接受原始命令。 +- 例外之二:`android-root-cli`(guest CLI,经 `RootCliOffloadHandler` 与 `RootProcess`)。它以 root 运行 Android 系统工具:`package`/`permission`/`activity`/`display`/`settings`/`user`/`network`/`input`/`notification`/`file`/`device` 等整理过的子命令(参数向量由 App 构造),外加 `exec`——把剩余参数拼成一条命令交给 root 的 `sh -c`。`exec` 是有意给本地 Agent 的原始 root shell:2026-10 起项目只走 Root,按维护者的决定给 Agent 尽量大的权限,只防止把手机弄坏;此前同一能力是经 Shizuku 的 `android-shizuku-cli`(Shizuku 以 Root 启动时同样是 uid 0)。约束:只能从 guest shell 调用;受 `root_cli` 权限开关控制,默认关闭;su 缺失或被拒时直接返回 `SERVICE_NOT_RUNNING` / `PERMISSION_DENIED`,不运行任何命令。设备完整性保护(拦截写分区、系统分区、`/data` 系统数据、卸载核心系统包等)尚未实现,见 Issue #182;MCP 调用方经 `linux.shell` 也能到达它,见 `06-CURRENT-GAPS.md`。 +- 虚拟屏的 root 服务(`VirtualScreenRootService`,由 libsu 经 `su` 以 `app_process` 拉起)是专用 binder 接口:只暴露 `IVirtualScreenService` 里固定的虚拟屏操作(建屏、启动应用、点按输入、截图、帧流),不提供命令、文件或通用 RPC,也不监听网络或 socket;binder 只交回本 App。 - Guest shell 进入 chroot 后必须通过 `setpriv` 切到真实 App UID/GID,清空 supplementary groups 与 Linux capabilities。 - Root-provider identity 只是可用性信号;uid=0 不代表 SELinux、mount、namespace 或 capability 一定允许操作。 diff --git a/docs/contracts/05-ENGINEERING.md b/docs/contracts/05-ENGINEERING.md index d35db607..cda37383 100644 --- a/docs/contracts/05-ENGINEERING.md +++ b/docs/contracts/05-ENGINEERING.md @@ -38,7 +38,7 @@ README / 专题 docs → 面向读者说明 ## VScreen 与例程权限档位 -- VScreen 仅在 Shizuku 协议 UserService 自检通过后启用。该服务的身份可以是 shell(2000,adb 启动的 Shizuku)或 root(0,以 Root 启动的 Shizuku / Sui),由用户自己选择如何运行 Shizuku;App 不自行通过 `su`/`DirectRootRunner` 启动任何 VScreen 进程,也不得以 KernelSU 直连、LSPosed、视频/OCR 或未经认证的 socket 作为后备路径。其它 uid 一律拒绝。拉起应用走系统的 `cmd activity start-activity`(参数向量由已校验的包名/组件构造,不经 shell)。 +- VScreen 仅在 root 服务自检通过后启用。服务由 libsu `RootService`(`VirtualScreenRootService`,daemon 模式)经 `su` 以 `app_process` 拉起,身份必须是 root(0),其它 uid 一律拒绝;binder 只经 libsu 交回本 App,服务本身没有网络或 socket 监听。daemon 进程在 App 被杀后保留虚拟屏,App 升级或卸载时由 libsu 结束,因此不需要服务版本号。不得以 LSPosed、视频/OCR 或未经认证的 socket 作为后备路径。2026-10 之前它运行在 Shizuku 协议 UserService 里,该路径已移除。拉起应用走系统的 `cmd activity start-activity`(参数向量由已校验的包名/组件构造,不经 shell)。 - 物理屏与虚拟屏的 UI observation 均不得序列化 Android 标记为 password 节点的 text、content description 或 hint;仅可保留字段结构及 password 标记。 - `ScheduledTask` 权限档位保存在现有 JSON 存储中,不新增或迁移 Room schema。新建例程默认为 `READ_ONLY`;旧 JSON 缺字段时保留既有 `FULL` 行为,未知/畸形档位按 `READ_ONLY` 处理。 - 档位仅由 `ScheduledAgentRunner` 对正在运行的例程 session 建立;不同 session 的委派目标和唤醒回合不继承该档位,继续由 F3 既有无人值守策略保护;若共用同一 `sessionId`,所有回合共享最严格的活跃档位,denial preview 目前也按 session 收集而非按 turn 归属。 @@ -103,7 +103,7 @@ cd src/android ``` VScreen/V4 的 JVM 安全回归至少覆盖 `ScheduledReadOnlyPolicyTest`、`ScheduledTaskPermissionTierJsonTest`、 -`ScheduledTaskTierMutationPolicyTest` 与 `OffloadPermissionManagerScheduledTierTest`。这些宿主测试只证明解析/权限策略,不能替代 Shizuku UserService 和 OEM 显示行为的真机验收。 +`ScheduledTaskTierMutationPolicyTest` 与 `OffloadPermissionManagerScheduledTierTest`。这些宿主测试只证明解析/权限策略,不能替代 root 服务和 OEM 显示行为的真机验收。 Release/R8/JNI 敏感改动必须跑对应 Release 检查;Debug 不能替代。Root、mount、SELinux、VPN/DNS/BPF/Fake-IP、OEM 生命周期等设备行为,只有明确真机实测后才能声称通过。 diff --git a/docs/contracts/06-CURRENT-GAPS.md b/docs/contracts/06-CURRENT-GAPS.md index 8581e62e..dc0773d7 100644 --- a/docs/contracts/06-CURRENT-GAPS.md +++ b/docs/contracts/06-CURRENT-GAPS.md @@ -42,7 +42,7 @@ HTTP/DNS 检查仍可用受控的 `curl`。 此前确认的“`/usr/local/bin` 只有 `minis-config` 和 `minis-model-use`”缺口已经在当前工作树闭合。Direct Root 不复制上游 PRoot 的 `native_offload` stub,而是在 Ubuntu 启动/恢复时由 `GuestCommandBridge` 为当前已注册的 Android/Minis handler 生成带 loopback token 鉴权的 wrapper;因此命令名、argv/session/cwd/stdin 语义和 Android handler 保持一致,同时不恢复 PRoot、旧 broker 或通用 Root shell。 -小米 `24129PN74C` 真机已验证这些命令的 PATH 入口:`android-alarm`、`android-calendar`、`android-clipboard`、`android-contacts`、`android-device`、`android-location`、`android-notification`、`android-open`、`android-photos`、`android-player`、`android-speak`、`android-speech`、`android-weather`、`android-a11y-cli`、`android-shizuku-cli`、`minis-browser-use`、`minis-scheduled`、`minis-sessions-cli`、`minis-config`、`minis-model-use`、`minis-open` 及其浏览器别名;Debug APK 另有 `minis-debug`。`android-device info` 和多个 `--help`/`--version` 调用已获得实际输出。 +小米 `24129PN74C` 真机已验证这些命令的 PATH 入口(其中 `android-shizuku-cli` 已于 2026-10-10 改名 `android-root-cli`,改名后未在真机复验):`android-alarm`、`android-calendar`、`android-clipboard`、`android-contacts`、`android-device`、`android-location`、`android-notification`、`android-open`、`android-photos`、`android-player`、`android-speak`、`android-speech`、`android-weather`、`android-a11y-cli`、`android-shizuku-cli`、`minis-browser-use`、`minis-scheduled`、`minis-sessions-cli`、`minis-config`、`minis-model-use`、`minis-open` 及其浏览器别名;Debug APK 另有 `minis-debug`。`android-device info` 和多个 `--help`/`--version` 调用已获得实际输出。 **仍未补齐:`minis-mcp-cli`。** 上游 Python 命令随旧资产树删除后,当前 Guest 没有同名入口;Android 原生 MCP client/server 不是 CLI 的等价替代。提示词这一侧已经不再引导模型运行该命令:`MCPRepository.mcpPromptFragment()` 现在让模型直接调用 `mcp__` 工具。缺的只是 Guest 内的 CLI 入口本身。另外,原生 client 的 STDIO 服务目前在 Android 宿主进程里启动,而不是在 Ubuntu Guest 里,装在 Guest 里的 MCP 服务无法通过 STDIO 连接(2026-10-04 审计 F29,未修复)。 @@ -87,6 +87,8 @@ CI/宿主测试不能替代以下证据: ## VScreen 真机记录(2026-10-01,小米 24129PN74C / Android 17 / 以 Root 启动的 Shizuku) +> 历史记录:这是 Shizuku UserService 时期的实测。2026-10-10 起虚拟屏服务改由 libsu root 服务承载,见下方「纯 Root 路线」一节;下文提到的 `USER_SERVICE_VERSION`、adb 启动的 Shizuku 等不再适用。 + - 此前设置页显示「Shizuku 授权失败」并不是授权问题:`network_security_config.xml` 里冗余的 `localhost`/`127.0.0.1` `domain-config` 会让 Shizuku 拉起的 UserService 进程在初始化时抛出 `Found multiple conflicting per-domain rules` 并退出,App 侧表现为 8 秒 Binder 超时。已删除该冗余配置(`base-config` 已允许明文,HTTP 仍由 `ProviderTransportPolicy` 限制)。 - Root 启动的 Shizuku 使服务以 uid 0 运行,旧代码硬性拒绝(`root_user_service_refused`);现按上文接受 root。Binder 身份是按进程而不是按线程的(实测线程内 `setuid` 后虚拟屏所有者仍为 uid 0),所以不能在进程内降为 shell。 - 拉起应用改用 `cmd activity start-activity --display`:手工构造的 `ActivityThread` 不是系统认识的调用方进程,`Context.startActivity` 会得到 `Not allowed to start activity`。输入探测放在拉起之后(空显示屏上没有窗口可接收按键)。 @@ -106,6 +108,8 @@ CI/宿主测试不能替代以下证据: ## VScreen capability pending hardware validation(2026-09-30) +> 历史记录:写于 Shizuku 时期。服务宿主已换成 libsu root 服务,V1–V4 的待验收项仍然有效,只是「UserService」现在指 root 服务。 + VScreen 使用 Shizuku 协议 **UserService**(shell 或 root 身份,见 05 合同)和随 Android/OEM 版本变化的隐藏系统 API;能力默认关闭,只有当前系统/ROM 指纹下的设备自检全部通过才允许用户启用。指纹变化或自检失败会持久清除 enabled 状态,必须重新通过自检并由用户再次启用。UserService 仅接受非物理 display ID;物理主屏输入/观察、未经限定的 socket、视频/OCR 路径均不属于本功能,本实现也不增加系统网络出口拦截。 此工作区当前没有连接的 Android 真机或模拟器,因此没有声称隐藏 API 在目标 ROM 上通过真实运行探测。宿主编译与单测不能替代以下设备证据: @@ -193,6 +197,28 @@ config registry 暴露为 `prompt.custom`(带确认与审计回退)。内置 以上开放项均未做真机验证,不得据此声称设备行为结论。 +## 纯 Root 路线:移除 Shizuku(2026-10-10) + +项目只走 Root,不再接入 Shizuku 协议(Shizuku / AXManager / Sui)。移除前的完整代码存档在分支 `archive/before-root-only`(`6a6b450`);免 Root 的 PRoot + Ubuntu 后端记为暂缓的 Issue #184。 + +改动: + +- `android-shizuku-cli` 改名 `android-root-cli`,子命令和 JSON 不变,底层经 `RootProcess` → `DirectRootRunner` 用 `su` 执行。权限开关 `shizuku_cli` 自动迁移为 `root_cli`。旧 rootfs 里残留的 `android-shizuku-cli` 包装脚本在下次安装 guest CLI 时删除。 +- 虚拟屏服务改由 libsu `RootService`(daemon 模式)承载,只接受 uid 0;保存的旧探测结果因指纹里多了宿主标记而失效,需要重新通过一次自检才能启用。 +- 无障碍授权修复、受限设置解除、一键授权都改用 Root;设置里的「Root 与 Shizuku」页换成「Root」页,就绪清单去掉 Shizuku 项。 + +只有编译、JVM 单测和 lint 的证据,以下**全部未在真机验证**: + +- libsu `RootService` 在 HyperOS / KernelSU 上的首次绑定(含 su 授权弹窗)、daemon 模式下强杀 App 后虚拟屏保留与重新接回、App 升级后旧 daemon 被结束; +- `ShellContext.initialize()` 在 libsu 拉起的 `app_process` 进程里的行为(此前只在 Shizuku UserService 进程里验证过)、10 步探测、UiAutomation、帧流与触摸; +- `android-root-cli` 各子命令经 `su` 的结果与耗时(每次调用都新起一个 su 进程); +- Release(R8)构建下 libsu 自带的 keep 规则是否足够。 + +已知缺口: + +- MCP 调用方可以经 `linux.shell`(需用户确认)在 guest 里调用 `android-root-cli exec`,等于远程拿到 root shell,与「禁止向远程调用方提供任意 Root shell」冲突。移除前的 `android-shizuku-cli` 有同样问题。应让 `RootCliOffloadHandler` 只接受本地 Agent 与终端 session 的请求。 +- 设备完整性保护(Issue #182)尚未实现:`exec`、`file rm -r`、`package uninstall` 等目前没有任何保护线。 + ## 2026-10-08 之后新增的未验证项 下列行为已有单元测试或模拟器测试,**没有**在真机上完整验证,不得据此声称设备结论: diff --git a/docs/design/DESIGN-GAP-MATRIX.md b/docs/design/DESIGN-GAP-MATRIX.md index b7ea4a2d..437af46f 100644 --- a/docs/design/DESIGN-GAP-MATRIX.md +++ b/docs/design/DESIGN-GAP-MATRIX.md @@ -85,7 +85,7 @@ | 工具 / 思考折叠 | ✅ | 思考与工具调用共用一种「步骤行」:小图标 + 标题 + 用时 / 字数 + 箭头。思考点开是行内展开(箭头右 → 下,带动画,文字缩进在一条竖线后),工具点开是详情面板(固定右箭头);运行中图标换成强调色小转圈,工具行自带停止按钮;去掉蓝色底卡片和灰色胶囊。分组标题「用时 …」同样用旋转箭头 | | 消息操作栏 | ✅ | 复制:点一下复制纯文本并变成对勾,长按出「复制文字 / 复制 Markdown / 选择文字」(此前 Markdown 在「更多」里重复,「选择文字」没有接上);重新生成:之后还有消息时先确认「之后的 N 条会被删除」,失败给出原因(此前静默无反应);朗读:开始 / 朗读中变停止(暂停在输入框上方的朗读条);分支不变;更多只剩分享、删除。朗读是否出声取决于语音输出配置,未验证 | | 弹窗背景被染成强调色 | ✅ | 根因是主题里 `surfaceTint = 强调色`,Material 会把它混进所有抬升的表面(菜单、对话框、面板);改为透明,弹窗保持白色 | -| 就绪检查覆盖全部 | ✅ | 由 5 项扩到系统与权限下管理的全部:Root、Shizuku、所有文件、无障碍、悬浮窗、默认助手、后台运行、通知,以及 Agent 已允许的工具所需的 Android 权限(日历 / 位置 / 通讯录 / 照片)。只有 Root、所有文件、后台、通知四项计入「N 项需要处理」,其余为可选;列表过长时可滚动 | +| 就绪检查覆盖全部 | ✅ | 由 5 项扩到系统与权限下管理的全部:Root、所有文件、无障碍、悬浮窗、默认助手、后台运行、通知,以及 Agent 已允许的工具所需的 Android 权限(日历 / 位置 / 通讯录 / 照片)。只有 Root、所有文件、后台、通知四项计入「N 项需要处理」,其余为可选;列表过长时可滚动 | | 对话页 / 抽屉 / 文件预览 / 模型选择的层次 | ✅ | 对话页浅灰底,用户气泡、代码块、快捷卡片为白;抽屉浅灰底,搜索框和当前会话为白;文件预览顶栏和底栏为浅灰、文档本身为白;两个模型选择面板改浅灰底 + 白卡 + 白搜索框。终端保持深色。深色模式不变(本来就是黑底 + 深灰卡片) | | 之前没看到的页面 | 🟡 | 模拟器上看过:备份与恢复(含备份目的地)、关于、Rootfs 管理(顺手修了:标题一直写「已安装」、分区标题是强调色)、团队成员编辑。镜像设置、服务商详情、模型槽位没有逐个打开看 | diff --git a/docs/design/SETTINGS-IA-AUDIT.md b/docs/design/SETTINGS-IA-AUDIT.md index 14d1932f..2d407ab4 100644 --- a/docs/design/SETTINGS-IA-AUDIT.md +++ b/docs/design/SETTINGS-IA-AUDIT.md @@ -16,7 +16,7 @@ | 重叠 | 处理 | |---|---| -| 「系统增强」「特权后端」里的 Root 状态重叠 | Root 状态行并入「Root 与 Shizuku」页;「系统增强」改名「模块」,只留 LSPosed 模块开关和电源键助手目标 | +| 「系统增强」「特权后端」里的 Root 状态重叠 | Root 状态行并入「Root 与 Shizuku」页(2026-10 移除 Shizuku 后即「Root」页);「系统增强」改名「模块」,只留 LSPosed 模块开关和电源键助手目标 | | 「工具权限」与「系统授权」「权限」名字相近 | 「工具权限」改名「Agent 权限」,副标题「Agent 可以自己做哪些事」;系统层授权那一项统一叫「系统授权」 | | 设置与服务商、模型、Agent、对话与输入、外观各页的长说明页脚(36 处,超过 70 个字符的) | 去掉,保留短的 | | 设置首页页脚仍写「运行环境里也能进终端和文件」 | 已改成「终端和文件在会话抽屉里;团队和定时任务在 Agent 里」 | diff --git a/docs/design/UI-DESIGN-LANGUAGE.md b/docs/design/UI-DESIGN-LANGUAGE.md index 8d8b22d0..7a46e6a6 100644 --- a/docs/design/UI-DESIGN-LANGUAGE.md +++ b/docs/design/UI-DESIGN-LANGUAGE.md @@ -166,7 +166,7 @@ T153 有意提亮的一组值(`#26262A`/`#2C2C30`/`#3A3A3F`),不回退成 | 设置 6 类(模型/助手/外观/运行时/系统/关于);#14 曾改成 7 类(含「文件」) | **以设计稿为准的 7 类**:模型 / Agent / 对话与输入 / 外观 / 运行环境 / 系统与权限 / 数据与关于。首页顶部在有待处理项时显示「N 项需要处理」横幅;「系统与权限」页顶部是就绪检查(Root、所有文件访问、无障碍、后台运行、通知,无障碍为可选项不计数)。终端、文件、定时任务不在设置里,是抽屉入口 | 「对话与输入」是从「外观」拆出的新页(回车键、回复后聚焦、思考展开、会话标题、工具预览、启动时打开、自动归类、屏幕常亮、语音纠错);桌宠并入「外观」;「运行环境」= Rootfs、环境变量、终端与浏览文件入口 | | 终端固定深色 | 跟随主题 | 见第 3 节代码块规则 | | 会话列表整页 | 左侧抽屉 | 见第 8 节 | -| 虚拟屏幕(V1–V4,实验性) | 入口归入设置「系统与权限」,紧邻「特权后端」(Shizuku)行;「运行环境」只放 Rootfs、环境变量与终端/文件入口 | 它依赖 Shizuku 授权,属于设备能力授权,不是 chroot 运行时;定时任务的权限档位在定时任务编辑页 | +| 虚拟屏幕(V1–V4,实验性) | 入口归入设置「系统与权限」,紧邻「Root」行;「运行环境」只放 Rootfs、环境变量与终端/文件入口 | 它依赖 Root 授权(libsu root 服务),属于设备能力授权,不是 chroot 运行时;定时任务的权限档位在定时任务编辑页 | 注意:IA 调整只移动入口与页面组织,**不改变**任何 Runtime、权限或存储行为。涉及 Root、共享目录、 挂载的页面只调整位置,权限确认流程保持原样。画板里「会话文件」并入文件中心:会话文件页面 diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts index 2b035640..50772292 100644 --- a/src/android/app/build.gradle.kts +++ b/src/android/app/build.gradle.kts @@ -584,10 +584,10 @@ dependencies { // ACRA local crash report capture. acra-core has no HTTP sender. implementation("ch.acra:acra-core:5.12.0") - // Shizuku-compatible privileged Android API bridge. AXManager/Sui-compatible - // implementations reuse the same Shizuku protocol surface. - implementation("dev.rikka.shizuku:api:13.1.5") - implementation("dev.rikka.shizuku:provider:13.1.5") + // libsu RootService: hosts the virtual-screen service in a root process (app_process + // started through su) and hands its binder back to the app. Apache-2.0; JitPack only. + implementation("com.github.topjohnwu.libsu:core:6.0.0") + implementation("com.github.topjohnwu.libsu:service:6.0.0") // Testing — JVM unit tests testImplementation("junit:junit:4.13.2") diff --git a/src/android/app/lint-baseline.xml b/src/android/app/lint-baseline.xml index 6cd8e80f..83cb4f25 100644 --- a/src/android/app/lint-baseline.xml +++ b/src/android/app/lint-baseline.xml @@ -3905,469 +3905,59 @@ column="13"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -129,7 +117,7 @@ + authorized Root `pm` fallback. --> @@ -163,13 +151,6 @@ - - - - - - diff --git a/src/android/app/src/main/aidl/com/openminis/app/tools/android/vscreen/IVirtualScreenService.aidl b/src/android/app/src/main/aidl/com/openminis/app/tools/android/vscreen/IVirtualScreenService.aidl index 29e3e25f..90341a7d 100644 --- a/src/android/app/src/main/aidl/com/openminis/app/tools/android/vscreen/IVirtualScreenService.aidl +++ b/src/android/app/src/main/aidl/com/openminis/app/tools/android/vscreen/IVirtualScreenService.aidl @@ -32,6 +32,4 @@ interface IVirtualScreenService { /** One raw touch event (MotionEvent action) at display-local x/y, for dragging by hand. */ boolean touch(int displayId, int action, int x, int y, long downTimeMs) = 23; - // Reserved by the Shizuku server. The server invokes transaction 16777115. - void destroy() = 16777114; } diff --git a/src/android/app/src/main/assets/prompts/android.debugLoop.md b/src/android/app/src/main/assets/prompts/android.debugLoop.md index e3b57055..6e375e91 100644 --- a/src/android/app/src/main/assets/prompts/android.debugLoop.md +++ b/src/android/app/src/main/assets/prompts/android.debugLoop.md @@ -5,6 +5,6 @@ Android development debug loop (named agent tools): - Before reproducing, call android_logs mark_cursor for the target package. Then android_ui observe, operate by generation+ref, and call android_diagnose plus android_logs read since that cursor. - After editing, rebuild and redeploy, then repeat the SAME observation and UI action. Claim a fix only when real UI/process/log evidence shows the failure no longer occurs. - Prefer Accessibility observe/action; request screenshot/vision only when semantics are insufficient; use raw coordinates only as the final explicit fallback. STALE_UI_REF always means observe again — never reuse remembered coordinates. -- Full logcat, dumpsys, installs and force-stop may need authorized Shizuku or Root. Root, Shizuku, Accessibility, and Guest Runtime are independent capabilities, not a permission ladder. +- Full logcat, dumpsys, installs and force-stop may need authorized Root. Root, Accessibility, and Guest Runtime are independent capabilities, not a permission ladder. - The guest runtime is Ubuntu 24.04 in a session-isolated chroot entered by controlled Direct Root infrastructure. Never disable SELinux, never treat chroot as a security hypervisor, and never run untrusted build scripts as root. - Continuous self-update of Minis for Android is UNSUPPORTED: replacing this APK kills the current Agent process. Debug other packages unless a separate companion is introduced. diff --git a/src/android/app/src/main/assets/prompts/android.tools.md b/src/android/app/src/main/assets/prompts/android.tools.md index 78120a19..32764618 100644 --- a/src/android/app/src/main/assets/prompts/android.tools.md +++ b/src/android/app/src/main/assets/prompts/android.tools.md @@ -1,5 +1,5 @@ Android-only tools (android-* CLIs): -CLI tools at /usr/local/bin with the `android-` prefix give you access to Android framework capabilities and on-device control. Invoke them from shell_execute like any other binary — they are already on PATH. Each tool prints JSON (or a short human-readable line) and supports --help for full usage. Tools gated by Shizuku or AccessibilityService return permission_denied when not granted — handle that gracefully and point the user at [Settings → Permissions](minis://settings/permissions). +CLI tools at /usr/local/bin with the `android-` prefix give you access to Android framework capabilities and on-device control. Invoke them from shell_execute like any other binary — they are already on PATH. Each tool prints JSON (or a short human-readable line) and supports --help for full usage. Tools gated by Root or AccessibilityService return permission_denied when not granted — handle that gracefully and point the user at [Settings → Permissions](minis://settings/permissions). - android-alarm — schedule alarms/timers in the system Clock app (`schedule --label [--repeat ONCE|DAILY|WEEKDAYS]`, `timer --label `, `open`). Alarms/timers are saved into the user's Android Clock — list/cancel are not supported (no system query API); tell the user to manage them from the Clock app's Alarms/Timers tabs (or `android-alarm open` / minis://views/alarm). - android-calendar — read/write the device calendar (`list --start YYYY-MM-DD [--end ...] [--max N]`; `create --title --start [--end ] [--description ] [--location ] [--all-day]`). - android-clipboard — `get | set [--label L] | clear`. @@ -13,7 +13,7 @@ CLI tools at /usr/local/bin with the `android-` prefix give you access to Androi - android-speak — device TTS (` [--rate F] [--pitch F] [--volume F]`; `--stop | --status`). - android-speech — microphone transcription (`listen [--language BCP47] [--max N] [--timeout SEC]`; `status`). Requires RECORD_AUDIO. - android-weather — Open-Meteo forecast (current + hourly + daily). No API key needed. -- android-shizuku-cli — invoke privileged Android system APIs (package management, settings, system commands) via Shizuku when granted. Curated subcommands return structured JSON; for anything not covered, fall back to `android-shizuku-cli exec ` which runs the command via `sh -c` with Shizuku privilege (same surface as `adb shell`). Run with no args (or --help) for the subcommand list. +- android-root-cli — invoke privileged Android system APIs (package management, settings, input, system commands) as root when Minis has Root and the user has allowed it. Curated subcommands return structured JSON; for anything not covered, fall back to `android-root-cli exec ` which runs the command via `sh -c` as root (everything `adb shell` can do, and more). Run with no args (or --help) for the subcommand list. - android-a11y-cli — drive system UI (read screen, tap, type, swipe, scroll) via the Android AccessibilityService when enabled. Run with no args (or --help) for the subcommand list. - minis-open : Opens a resource inside Minis without leaving the chat. Accepts http/https URLs (→ built-in WebKit preview) and chat-resource file paths under /var/minis/** (→ built-in file preview, routed by extension: images to the image viewer, .md to markdown preview, .html to HTML preview, .pdf/office docs to QuickLook, audio/video to the media player, else share sheet). Examples: minis-open https://example.com, minis-open /var/minis/workspace/report.md, minis-open /var/minis/attachments/chart.png. Prefer this over android-open for anything that can be previewed in-app so the user doesn't lose conversation context. Use android-open for non-web schemes (tel:, mailto:, geo:, intent:, etc.) or when the user explicitly wants the system handler. - minis-sessions-cli: Manage chat sessions. `list` recent or by date range, `search --keywords` cross-session, `messages --id` to read, `send` to create/continue a session, `retry` to re-run, `status` to check, `open` to navigate the app UI. Run --help for full options. diff --git a/src/android/app/src/main/java/com/openminis/app/MainActivity.kt b/src/android/app/src/main/java/com/openminis/app/MainActivity.kt index ed2e0210..89d0e9de 100644 --- a/src/android/app/src/main/java/com/openminis/app/MainActivity.kt +++ b/src/android/app/src/main/java/com/openminis/app/MainActivity.kt @@ -396,7 +396,7 @@ class MainActivity : ComponentActivity() { // [T-android-a11y-force-stop-recovery] Bridge: the accessibility-grant // repair prompt. Raised from the a11y tool path when the framework has // stripped our component out of ENABLED_ACCESSIBILITY_SERVICES (the - // force-stop case). Two shapes depending on whether Shizuku can do the + // force-stop case). Two shapes depending on whether Root can do the // privileged write for us. // // setCancelable(true) + setOnCancelListener, unlike the settings gate @@ -422,8 +422,8 @@ class MainActivity : ComponentActivity() { AccessibilityRecoveryManager.Decision.CANCEL ) } - if (prompt.shizukuAvailable) { - b.setMessage(getString(R.string.a11y_repair_dialog_message_shizuku)) + if (prompt.rootAvailable) { + b.setMessage(getString(R.string.a11y_repair_dialog_message_root)) .setPositiveButton(R.string.a11y_repair_action_repair) { d, _ -> d.dismiss() AccessibilityRecoveryManager.respond( diff --git a/src/android/app/src/main/java/com/openminis/app/MinisApp.kt b/src/android/app/src/main/java/com/openminis/app/MinisApp.kt index 0914ce89..93a56f74 100644 --- a/src/android/app/src/main/java/com/openminis/app/MinisApp.kt +++ b/src/android/app/src/main/java/com/openminis/app/MinisApp.kt @@ -50,7 +50,7 @@ import com.openminis.app.runtime.guest.DeviceOffloadHandler import com.openminis.app.runtime.guest.LocationOffloadHandler import com.openminis.app.runtime.guest.ModelUseOffloadHandler import com.openminis.app.runtime.guest.SessionsOffloadHandler -import com.openminis.app.runtime.guest.ShizukuOffloadHandler +import com.openminis.app.runtime.guest.RootCliOffloadHandler import com.openminis.app.runtime.guest.NotificationOffloadHandler import com.openminis.app.runtime.guest.OpenOffloadHandler import com.openminis.app.runtime.guest.PhotosOffloadHandler @@ -908,13 +908,10 @@ class MinisApp : Application(), ImageLoaderFactory { "minis-scheduled", com.openminis.app.runtime.guest.ScheduledTaskOffloadHandler(this), ) - // T322: android-shizuku-cli — privileged Android control via Shizuku. - // The handler short-circuits with a typed error envelope when the - // user hasn't installed / started / authorized Shizuku, so we - // can register unconditionally; ShizukuManager.init below wires - // up the binder lifecycle listeners + StateFlow. - NativeOffloadServer.register("android-shizuku-cli", ShizukuOffloadHandler(this)) - com.openminis.app.offload.ShizukuManager.init(this) + // android-root-cli — privileged Android control as root. The handler + // answers with a typed error envelope when su is missing or refused, + // so it is registered unconditionally. + NativeOffloadServer.register("android-root-cli", RootCliOffloadHandler(this)) // T-android-minis-debug-cli: shell-side CLI wrapper around the in-app // DebugServer (127.0.0.1:5321) JSON-RPC. DEBUG-only — Release builds diff --git a/src/android/app/src/main/java/com/openminis/app/accessibility/AccessibilityRecoveryManager.kt b/src/android/app/src/main/java/com/openminis/app/accessibility/AccessibilityRecoveryManager.kt index a3526f36..99d83a92 100644 --- a/src/android/app/src/main/java/com/openminis/app/accessibility/AccessibilityRecoveryManager.kt +++ b/src/android/app/src/main/java/com/openminis/app/accessibility/AccessibilityRecoveryManager.kt @@ -3,7 +3,7 @@ package com.openminis.app.accessibility import android.content.Context import android.provider.Settings import com.openminis.app.logging.AppLogger -import com.openminis.app.offload.ShizukuManager +import com.openminis.app.offload.RootProcess import com.openminis.app.xposed.system.AccessibilityProtectionClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -45,14 +45,14 @@ import kotlin.coroutines.resume * * Writing ENABLED_ACCESSIBILITY_SERVICES requires WRITE_SECURE_SETTINGS, which * is not grantable to a normal app. That is why none of the apps surveyed even - * *detect* the loss. Minis is in a better position because it already ships a - * Shizuku client: with Shizuku authorized we can run `settings put secure` - * with shell privilege and repair the grant in place, which was verified to - * take effect immediately (the service rebinds without a relaunch). + * *detect* the loss. Minis is in a better position because it runs on a rooted + * phone: with Root granted we can run `settings put secure` as root and + * repair the grant in place, which takes effect immediately (the service + * rebinds without a relaunch). * * So there are two tiers: - * - Shizuku READY → one-tap repair, no trip to Settings. - * - otherwise → explain the situation and deep-link to Settings. + * - Root granted → one-tap repair, no trip to Settings. + * - otherwise → explain the situation and deep-link to Settings. */ object AccessibilityRecoveryManager { @@ -76,7 +76,7 @@ object AccessibilityRecoveryManager { status == AccessibilityProtectionClient.ControlStatus.APPLIED /** - * After a Shizuku write, how long to wait for the framework to actually + * After a Root write, how long to wait for the framework to actually * rebind the service. The write returns before `onServiceConnected` runs, * so a naive "did it work" check immediately after would report false. */ @@ -105,11 +105,11 @@ object AccessibilityRecoveryManager { * A pending repair prompt. Collected by MainActivity, which renders it as * an AlertDialog and calls [respond]. * - * @param shizukuAvailable when true the dialog offers in-place repair; + * @param rootAvailable when true the dialog offers in-place repair; * when false it can only offer a trip to Settings. */ data class RepairPrompt( - val shizukuAvailable: Boolean, + val rootAvailable: Boolean, ) private val _pendingPrompt = MutableStateFlow(null) @@ -152,7 +152,7 @@ object AccessibilityRecoveryManager { * Latch that the grant has existed. Called from * [MinisAccessibilityService.onServiceConnected] — the one moment we know * for certain the user granted it, whichever route they took (Settings - * toggle, Shizuku repair, or a restore). + * toggle, Root repair, or a restore). */ fun markGranted(context: Context) { val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -242,7 +242,7 @@ object AccessibilityRecoveryManager { } /** - * Repair the grant with Shizuku shell privilege. + * Repair the grant as root. * * Appends our component to the existing ENABLED_ACCESSIBILITY_SERVICES * value rather than overwriting it — a blind overwrite would disable every @@ -251,15 +251,15 @@ object AccessibilityRecoveryManager { * `accessibility_enabled=1`, which force-stop also clears and without * which the framework ignores the service list entirely. * - * Runs `settings put secure` through [ShizukuManager.runProcess] — the - * same privileged path `android-shizuku-cli settings set` uses, so there - * is one implementation of the write. + * Runs `settings put secure` through [RootProcess] — the same Root path + * `android-root-cli settings set` uses, so there is one implementation of + * the write. * * @return true once the service has actually rebound. */ /** * [T-eta-xposed-groups] Ask the module backend to put the grant back. It runs inside - * system_server, so this path needs neither Shizuku nor a decision from the user; it is only + * system_server, so this path needs neither Root nor a decision from the user; it is only * asked while the protection switch is on, and anything other than an explicit confirmation * makes the caller fall through to the prompt. * @@ -290,9 +290,9 @@ object AccessibilityRecoveryManager { return true } - suspend fun repairWithShizuku(context: Context): Boolean { - if (!ShizukuManager.isReady()) { - AppLogger.info(TAG, "repair skipped: Shizuku not ready") + suspend fun repairWithRoot(context: Context): Boolean { + if (!RootProcess.isGranted()) { + AppLogger.info(TAG, "repair skipped: Root not granted") return false } val target = componentId(context) @@ -306,15 +306,15 @@ object AccessibilityRecoveryManager { val merged = (existing + target).distinct().joinToString(":") - val r1 = ShizukuManager.runProcess( - arrayOf("settings", "put", "secure", Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, merged) + val r1 = RootProcess.exec( + listOf("settings", "put", "secure", Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, merged) ) if (r1.exitCode != 0) { AppLogger.warning(TAG, "repair failed writing service list: exit=${r1.exitCode} ${r1.combined}") return false } - val r2 = ShizukuManager.runProcess( - arrayOf("settings", "put", "secure", Settings.Secure.ACCESSIBILITY_ENABLED, "1") + val r2 = RootProcess.exec( + listOf("settings", "put", "secure", Settings.Secure.ACCESSIBILITY_ENABLED, "1") ) if (r2.exitCode != 0) { AppLogger.warning(TAG, "repair failed writing accessibility_enabled: exit=${r2.exitCode} ${r2.combined}") @@ -330,7 +330,7 @@ object AccessibilityRecoveryManager { invalidateCache() _revoked.value = isGrantRevoked(context) - AppLogger.info(TAG, "repair via Shizuku wrote grant; rebound=$bound") + AppLogger.info(TAG, "repair via Root wrote grant; rebound=$bound") // Report on the write succeeding: on a slow device the rebind can land // just after our window, and the grant itself is what we were asked to // restore. `bound` is logged for diagnosis. @@ -386,16 +386,16 @@ object AccessibilityRecoveryManager { suspend fun ensureGrantOrPrompt(context: Context): Boolean { if (!isGrantRevoked(context)) return true - // The module backend repairs from inside the system, with no prompt and no Shizuku; it is + // The module backend repairs from inside the system, with no prompt and no Root; it is // asked first and a refusal falls through to the prompt below. if (repairWithModule(context)) return true - val shizuku = ShizukuManager.isReady() - AppLogger.info(TAG, "grant revoked — prompting (shizukuReady=$shizuku)") + val root = RootProcess.isGranted() + AppLogger.info(TAG, "grant revoked — prompting (rootGranted=$root)") _revoked.value = true - return when (awaitDecision(RepairPrompt(shizukuAvailable = shizuku))) { - Decision.REPAIR -> repairWithShizuku(context) + return when (awaitDecision(RepairPrompt(rootAvailable = root))) { + Decision.REPAIR -> repairWithRoot(context) // The user was sent to Settings; they may or may not finish. Don't // block the turn waiting — report unusable and let them retry. Decision.OPEN_SETTINGS -> false diff --git a/src/android/app/src/main/java/com/openminis/app/accessibility/RestrictedSettingsManager.kt b/src/android/app/src/main/java/com/openminis/app/accessibility/RestrictedSettingsManager.kt index f635e2ed..deaf7a90 100644 --- a/src/android/app/src/main/java/com/openminis/app/accessibility/RestrictedSettingsManager.kt +++ b/src/android/app/src/main/java/com/openminis/app/accessibility/RestrictedSettingsManager.kt @@ -4,7 +4,7 @@ import android.content.Context import android.content.pm.PackageInstaller import android.os.Build import com.openminis.app.logging.AppLogger -import com.openminis.app.offload.ShizukuManager +import com.openminis.app.offload.RootProcess object RestrictedSettingsManager { private const val TAG = "RestrictedSettings" @@ -34,13 +34,13 @@ object RestrictedSettingsManager { packageSource == PackageInstaller.PACKAGE_SOURCE_LOCAL_FILE || packageSource == PackageInstaller.PACKAGE_SOURCE_DOWNLOADED_FILE - suspend fun clearWithShizuku(context: Context): Boolean { - if (!ShizukuManager.isReady()) { - AppLogger.info(TAG, "clear skipped: Shizuku not ready") + suspend fun clearWithRoot(context: Context): Boolean { + if (!RootProcess.isGranted()) { + AppLogger.info(TAG, "clear skipped: Root not granted") return false } - val set = ShizukuManager.runProcess( - arrayOf( + val set = RootProcess.exec( + listOf( "appops", "set", context.packageName, "ACCESS_RESTRICTED_SETTINGS", "allow", ), @@ -49,12 +49,12 @@ object RestrictedSettingsManager { AppLogger.warning(TAG, "appops set failed: exit=${set.exitCode} ${set.combined}") return false } - val get = ShizukuManager.runProcess( - arrayOf("appops", "get", context.packageName, "ACCESS_RESTRICTED_SETTINGS"), + val get = RootProcess.exec( + listOf("appops", "get", context.packageName, "ACCESS_RESTRICTED_SETTINGS"), ) val ok = get.exitCode == 0 && get.combined.contains("allow") if (ok) cleared = true - AppLogger.info(TAG, "cleared restricted settings via Shizuku; ok=$ok (${get.combined.trim()})") + AppLogger.info(TAG, "cleared restricted settings via Root; ok=$ok (${get.combined.trim()})") return ok } } diff --git a/src/android/app/src/main/java/com/openminis/app/debug/DebugMethodRegistry.kt b/src/android/app/src/main/java/com/openminis/app/debug/DebugMethodRegistry.kt index 3b84903c..df88be64 100644 --- a/src/android/app/src/main/java/com/openminis/app/debug/DebugMethodRegistry.kt +++ b/src/android/app/src/main/java/com/openminis/app/debug/DebugMethodRegistry.kt @@ -45,10 +45,10 @@ object DebugMethodRegistry { private val DEBUG_ONLY_METHODS: List = listOf( MethodSpec( - name = "debug.shizuku.exec", - description = "DEBUG-only: invoke ShizukuOffloadHandler directly with the given argv (T344). Bypasses the agent loop for e2e harness verification of android-shizuku-cli.", + name = "debug.rootCli.exec", + description = "DEBUG-only: invoke RootCliOffloadHandler directly with the given argv (T344). Bypasses the agent loop for e2e harness verification of android-root-cli.", params = listOf( - ParamSpec("args", "[string]", required = false, description = "argv past `android-shizuku-cli` (e.g. [\"exec\", \"id\"]). Either this or 'command' is required."), + ParamSpec("args", "[string]", required = false, description = "argv past `android-root-cli` (e.g. [\"exec\", \"id\"]). Either this or 'command' is required."), ParamSpec("command", "string", required = false, description = "Whitespace-separated alternative to args, e.g. \"exec id\"."), ), returns = "{exitCode, output, argv}", @@ -56,7 +56,7 @@ object DebugMethodRegistry { ), MethodSpec( name = "debug.modelUse.exec", - description = "DEBUG-only: invoke ModelUseOffloadHandler directly with the given argv. Parallels debug.shizuku.exec — lets harnesses trigger `minis-model-use run/list/search` without an in-shell prompt.", + description = "DEBUG-only: invoke ModelUseOffloadHandler directly with the given argv. Parallels debug.rootCli.exec — lets harnesses trigger `minis-model-use run/list/search` without an in-shell prompt.", params = listOf( ParamSpec("args", "[string]", required = false, description = "argv past `minis-model-use` (e.g. [\"run\", \"--model\", \"gpt-5.3-codex\"])."), ParamSpec("command", "string", required = false, description = "Whitespace-separated alternative to args."), diff --git a/src/android/app/src/main/java/com/openminis/app/debug/DebugRPCHandlerDispatch.kt b/src/android/app/src/main/java/com/openminis/app/debug/DebugRPCHandlerDispatch.kt index aa7c798f..0fee1ae8 100644 --- a/src/android/app/src/main/java/com/openminis/app/debug/DebugRPCHandlerDispatch.kt +++ b/src/android/app/src/main/java/com/openminis/app/debug/DebugRPCHandlerDispatch.kt @@ -242,11 +242,11 @@ internal suspend fun DebugRPCHandler.dispatch(method: String, params: JSONObject // Debug-only: direct CLI / offload-handler invocation (T344). // Registered solely on DEBUG builds so release APKs cannot expose it. - "debug.shizuku.exec" -> { + "debug.rootCli.exec" -> { if (!BuildConfig.DEBUG) { throw RPCException(-32601, "Method not found: $method. Call 'rpc.discover' to list available methods.") } - handleShizukuExec(params) + handleRootCliExec(params) } "debug.modelUse.exec" -> { if (!BuildConfig.DEBUG) { diff --git a/src/android/app/src/main/java/com/openminis/app/debug/DebugRPCHandlerSupport.kt b/src/android/app/src/main/java/com/openminis/app/debug/DebugRPCHandlerSupport.kt index 1e50e3d7..4eb4282d 100644 --- a/src/android/app/src/main/java/com/openminis/app/debug/DebugRPCHandlerSupport.kt +++ b/src/android/app/src/main/java/com/openminis/app/debug/DebugRPCHandlerSupport.kt @@ -508,15 +508,15 @@ internal fun DebugRPCHandler.handlePermissionsList(): JSONObject { } /** - * T344: Direct invocation of [com.openminis.app.runtime.guest.ShizukuOffloadHandler] - * for e2e harnesses. Bypasses the agent loop so debug clients can verify Shizuku + * T344: Direct invocation of [com.openminis.app.runtime.guest.RootCliOffloadHandler] + * for e2e harnesses. Bypasses the agent loop so debug clients can verify Root * CLI behavior without driving a chat. DEBUG-build only — gated in [dispatch]. * * Params (one of): - * - {"args": ["exec", "id"]} — argv past `android-shizuku-cli` + * - {"args": ["exec", "id"]} — argv past `android-root-cli` * - {"command": "exec id"} — single string, whitespace-split */ -internal fun DebugRPCHandler.handleShizukuExec(params: JSONObject): JSONObject { +internal fun DebugRPCHandler.handleRootCliExec(params: JSONObject): JSONObject { val argvTail: List = when { params.has("args") -> { val arr = params.optJSONArray("args") @@ -528,13 +528,13 @@ internal fun DebugRPCHandler.handleShizukuExec(params: JSONObject): JSONObject { } else -> throw RPCException(-32602, "Missing 'args' (array) or 'command' (string)") } - AppLogger.info("DebugRPC", "debug.shizuku.exec argv=${argvTail.joinToString(" ")}") - val handler = com.openminis.app.runtime.guest.ShizukuOffloadHandler(context) - // ShizukuOffloadHandler.handle() drops argv[0]; prepend the CLI name so the - // tail aligns with what `android-shizuku-cli` would receive in-shell. + AppLogger.info("DebugRPC", "debug.rootCli.exec argv=${argvTail.joinToString(" ")}") + val handler = com.openminis.app.runtime.guest.RootCliOffloadHandler(context) + // RootCliOffloadHandler.handle() drops argv[0]; prepend the CLI name so the + // tail aligns with what `android-root-cli` would receive in-shell. val request = com.openminis.app.runtime.guest.NativeOffloadRequest( pid = -1, - argv = listOf("android-shizuku-cli") + argvTail, + argv = listOf("android-root-cli") + argvTail, env = emptyMap(), cwd = "/", sessionId = null, @@ -549,7 +549,7 @@ internal fun DebugRPCHandler.handleShizukuExec(params: JSONObject): JSONObject { /** * Direct invocation of [com.openminis.app.runtime.guest.ModelUseOffloadHandler] - * for e2e harnesses. Mirrors [handleShizukuExec]; lets callers exercise the + * for e2e harnesses. Mirrors [handleRootCliExec]; lets callers exercise the * `minis-model-use` CLI without going through a real Ubuntu shell prompt. * DEBUG-only. */ diff --git a/src/android/app/src/main/java/com/openminis/app/deeplink/DeepLinkHandler.kt b/src/android/app/src/main/java/com/openminis/app/deeplink/DeepLinkHandler.kt index d22358be..a8d309c8 100644 --- a/src/android/app/src/main/java/com/openminis/app/deeplink/DeepLinkHandler.kt +++ b/src/android/app/src/main/java/com/openminis/app/deeplink/DeepLinkHandler.kt @@ -202,7 +202,7 @@ object DeepLinkHandler { "background" -> DeepLinkAction.OpenSettingsScreen(Routes.BACKGROUND) "about" -> DeepLinkAction.OpenSettingsScreen(Routes.ABOUT) "permissions" -> DeepLinkAction.OpenPermissionSettings - // The one place every grant lives (readiness check, Root, Shizuku, accessibility, files...). + // The one place every grant lives (readiness check, Root, accessibility, files...). "system", "system-permissions" -> DeepLinkAction.OpenSettingsScreen(Routes.settingsCategory("system")) // mirrors live as a section inside Rootfs management — no // standalone destination, so route both /mirrors and /rootfs diff --git a/src/android/app/src/main/java/com/openminis/app/offload/OffloadPermissionManager.kt b/src/android/app/src/main/java/com/openminis/app/offload/OffloadPermissionManager.kt index e42c059b..3035751c 100644 --- a/src/android/app/src/main/java/com/openminis/app/offload/OffloadPermissionManager.kt +++ b/src/android/app/src/main/java/com/openminis/app/offload/OffloadPermissionManager.kt @@ -46,8 +46,7 @@ object OffloadPermissionManager { PRIVACY("Privacy"), MEDIA("Media"), SYSTEM("System"), - // T330: privileged automation CLIs (Shizuku binder / Accessibility - // service). These run via the offload bridge as shell tools, not as + // T330: privileged automation CLIs (Root / Accessibility service). These run via the offload bridge as shell tools, not as // named LLM tool calls, so the gate happens inside the // NativeOffloadHandler entry point rather than ChatViewModel. INTEGRATIONS("Integrations"), @@ -95,10 +94,10 @@ object OffloadPermissionManager { // T330: integrations — opt-in by default. These tools can drive // other apps and read on-screen content, so the safer posture is // NOT_ALLOWED until the user picks otherwise even when the - // underlying system layer (Shizuku binder / Accessibility service) - // is already authorized. + // underlying system layer (Root / Accessibility service) is already + // authorized. ToolPermissionInfo("a11y_cli", "android-a11y-cli", PermissionCategory.INTEGRATIONS, PermissionLevel.NOT_ALLOWED), - ToolPermissionInfo("shizuku_cli", "android-shizuku-cli", PermissionCategory.INTEGRATIONS, PermissionLevel.NOT_ALLOWED), + ToolPermissionInfo("root_cli", "android-root-cli", PermissionCategory.INTEGRATIONS, PermissionLevel.NOT_ALLOWED), // minis-apt: the guest cannot run apt itself, so the App does it as Root for the agent. Allowed by // default (a package install changes only the Ubuntu environment); the user can switch it off. ToolPermissionInfo("apt_cli", "minis-apt", PermissionCategory.INTEGRATIONS, PermissionLevel.BYPASS), @@ -455,9 +454,29 @@ object OffloadPermissionManager { fun init(context: Context) { prefs = context.getSharedPreferences("offload_permissions", Context.MODE_PRIVATE) + migrateRenamedTools() migrateLegacyAskOnce() } + /** Tools that were renamed: the old stored choice carries over once, then the old key is gone. */ + internal val renamedTools: Map = mapOf( + // 2026-10: android-shizuku-cli became android-root-cli when Shizuku was removed. + "shizuku_cli" to "root_cli", + ) + + private fun migrateRenamedTools() { + val editor = prefs.edit() + var changed = false + for ((old, new) in renamedTools) { + val oldKey = "level_$old" + val stored = prefs.getString(oldKey, null) ?: continue + if (prefs.getString("level_$new", null) == null) editor.putString("level_$new", stored) + editor.remove(oldKey) + changed = true + } + if (changed) editor.apply() + } + /** * Settings now offers a plain allow / deny switch. A stored ASK_ONCE choice from an older * build has no switch position, so it fails closed to NOT_ALLOWED; the user can switch it on. @@ -528,7 +547,7 @@ object OffloadPermissionManager { /** * Allow every Agent tool in one go (the one-tap authorize in Settings → System & permissions): the - * integrations that default to off (accessibility, Shizuku, the virtual screen) are switched on too, so + * integrations that default to off (accessibility, the Root CLI, the virtual screen) are switched on too, so * granting the Android side does not leave the Agent side shut. */ fun allowAll() { diff --git a/src/android/app/src/main/java/com/openminis/app/offload/RootProcess.kt b/src/android/app/src/main/java/com/openminis/app/offload/RootProcess.kt new file mode 100644 index 00000000..55ca496f --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/offload/RootProcess.kt @@ -0,0 +1,63 @@ +package com.openminis.app.offload + +import com.openminis.app.runtime.ubuntu.DirectRootRunner +import com.openminis.app.runtime.ubuntu.RootAccess +import com.openminis.app.runtime.ubuntu.RootAccessStatus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking + +/** + * Run one argv as root and collect its output. + * + * Used by `android-root-cli` (its handler runs synchronously on the offload IPC worker thread) and + * by the accessibility repairs. Every call goes through [DirectRootRunner], the same `su` launcher + * the Ubuntu runtime and `root.shell` use, so the app has one Root path. Arguments are quoted word + * by word; nothing is parsed by a shell unless the caller explicitly runs `sh -c`. + */ +object RootProcess { + const val DEFAULT_TIMEOUT_MS = 5_000L + + data class Result( + val exitCode: Int, + val stdout: String, + val stderr: String, + ) { + val combined: String get() = if (stderr.isEmpty()) stdout else "$stdout\n$stderr".trimEnd() + } + + /** True once a probe has seen su answer with uid 0 for this app. */ + fun isGranted(): Boolean = RootAccess.isGranted + + /** A definite reason root cannot be used, with the error code the guest CLIs report for it. */ + data class Unavailable(val code: String, val message: String) + + /** + * Why a root call cannot succeed right now, or null when it is worth trying. Only definite + * answers block: an unknown or not-yet-probed state still tries, and su itself decides. + */ + fun unavailable(): Unavailable? = unavailableFor(RootAccess.state.value.status) + + internal fun unavailableFor(status: RootAccessStatus): Unavailable? = when (status) { + RootAccessStatus.UNAVAILABLE -> Unavailable("SERVICE_NOT_RUNNING", "Root (su) is not available on this device") + RootAccessStatus.DENIED -> Unavailable("PERMISSION_DENIED", "Minis is not authorized for Root") + else -> null + } + + fun unavailableReason(): String? = unavailable()?.message + + /** Blocking form for synchronous callers; never call it on the main thread. */ + fun run(argv: Array, timeoutMs: Long = DEFAULT_TIMEOUT_MS): Result = + runBlocking(Dispatchers.IO) { exec(argv.toList(), timeoutMs) } + + suspend fun exec(argv: List, timeoutMs: Long = DEFAULT_TIMEOUT_MS): Result { + require(argv.isNotEmpty()) { "argv must not be empty" } + val result = DirectRootRunner.runArgv(argv, timeoutMs) + return toResult(result.exitCode, result.stdout, result.stderr, result.error) + } + + /** The runner's own failure (no su, timeout, start error) is reported on stderr after the command's. */ + internal fun toResult(exitCode: Int, stdout: String, stderr: String, error: String?): Result { + val err = listOf(stderr, error.orEmpty()).filter { it.isNotBlank() }.joinToString("\n") + return Result(exitCode, stdout, err) + } +} diff --git a/src/android/app/src/main/java/com/openminis/app/offload/ShizukuBackend.kt b/src/android/app/src/main/java/com/openminis/app/offload/ShizukuBackend.kt deleted file mode 100644 index 2b55ef8a..00000000 --- a/src/android/app/src/main/java/com/openminis/app/offload/ShizukuBackend.kt +++ /dev/null @@ -1,333 +0,0 @@ -package com.openminis.app.offload - -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import com.openminis.app.logging.AppLogger -import rikka.shizuku.Shizuku -import rikka.sui.Sui -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean - -/** - * [T-android-privileged-backend] Shizuku-protocol privileged-execution backend. - * - * Three providers speak the SAME Shizuku binder protocol and push their binder - * into the SAME client-side [rikka.shizuku.ShizukuProvider]: - * • official Shizuku manager (`moe.shizuku.privileged.api`, RikkaApps) — APK - * • AXManager (`frb.axeron.manager`, Axeron) — APK - * • Sui (RikkaApps) — a Magisk/KernelSU **module**, usually NO APK at all - * - * The `rikka.shizuku.Shizuku` singleton is therefore a single slot, owned by - * whichever provider the user authorized — the SDK and this class are unaware - * of (and indifferent to) which one is running. The earlier two-backend split - * confused detection because both `ShizukuBackend` and `AxeronBackend` - * reported state off the same shared singleton; that abstraction has been - * removed and must NOT come back for Sui either. - * - * [T-android-sui-support] (GH#110 / GH#97) Detection is **binder-first**: a - * live binder means we are usable, full stop. The installed-package scan is - * only consulted when the binder is DOWN, to explain why and pick the right - * install guidance. The previous package-first order hard-blocked Sui: Sui - * ships no APK, so the whitelist scan returned "not installed" and - * short-circuited before `pingBinder()` was ever called — a live, authorized - * Sui rendered as "Manager Not Installed". - * - * No Sui-specific dependency or init is needed: `dev.rikka.shizuku:provider` - * calls `Sui.init(packageName)` automatically from `ShizukuProvider.onCreate` - * (verified in the 13.1.5 bytecode), and `rikka.sui.Sui` ships inside - * `dev.rikka.shizuku:api`. [Sui.isSui] is used for LABELS ONLY — never for - * state decisions (there is no supported way to identify the binder's sender; - * see the reverted probe in 09f12761). - */ -class ShizukuBackend(private val appContext: Context) { - - @Volatile private var onStateChanged: (() -> Unit)? = null - private var listenersRegistered = false - - private val binderReceivedListener = Shizuku.OnBinderReceivedListener { - AppLogger.info(TAG, "binder received") - onStateChanged?.invoke() - } - private val binderDeadListener = Shizuku.OnBinderDeadListener { - AppLogger.warning(TAG, "binder died") - onStateChanged?.invoke() - } - private val permissionResultListener = - Shizuku.OnRequestPermissionResultListener { requestCode, grantResult -> - AppLogger.info( - TAG, - "permission result code=$requestCode grant=${grantResult == PackageManager.PERMISSION_GRANTED}", - ) - onStateChanged?.invoke() - } - - /** - * Any known Shizuku-protocol **manager APK** installed? - * - * [T-android-sui-support] NOTE: this is NOT "is a privileged backend - * available" — Sui provides the binder with no APK to find. Never gate - * binder work on this; use [snapshot] / [isPermissionGranted] instead. - * It survives only to drive install guidance and the "open manager app" - * affordance. - */ - fun isInstalled(): Boolean = installedManagerPackage() != null - - /** - * [T-android-sui-support] True when the live binder is provided by Sui - * (root module) rather than a manager APK. Display-only: it picks labels - * and hides the meaningless "Open Manager App" row for Sui users. It must - * never influence state resolution — see [decideState]. - */ - fun isSui(): Boolean = runCatching { Sui.isSui() }.getOrDefault(false) - - /** - * The first installed Shizuku-protocol manager package (in preference - * order: official Shizuku, then AXManager), or null if none installed. - * Used to drive "open the manager app" actions. - */ - fun installedManagerPackage(): String? = SHIZUKU_COMPATIBLE_PACKAGES.firstOrNull { pkg -> - runCatching { appContext.packageManager.getPackageInfo(pkg, 0); true }.getOrDefault(false) - } - - fun isBinderAlive(): Boolean = - runCatching { Shizuku.pingBinder() }.getOrDefault(false) - - fun isPermissionGranted(): Boolean = runCatching { - Shizuku.pingBinder() && Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED - }.getOrDefault(false) - - fun snapshot(): ShizukuManager.Snapshot { - val binderAlive = isBinderAlive() - val state = decideState( - binderAlive = binderAlive, - permissionGranted = binderAlive && isPermissionGranted(), - managerApkInstalled = isInstalled(), - ) - return if (state == ShizukuManager.State.READY) { - ShizukuManager.Snapshot(state, versionOrUnknown(), uidOrUnknown(), isSui()) - } else { - ShizukuManager.Snapshot(state, isSui = binderAlive && isSui()) - } - } - - fun registerListeners(onStateChanged: () -> Unit) { - this.onStateChanged = onStateChanged - if (listenersRegistered) return - listenersRegistered = true - runCatching { - Shizuku.addBinderReceivedListenerSticky(binderReceivedListener) - Shizuku.addBinderDeadListener(binderDeadListener) - Shizuku.addRequestPermissionResultListener(permissionResultListener) - }.onFailure { AppLogger.warning(TAG, "init listeners failed: ${it.message}") } - } - - fun refresh() { - onStateChanged?.invoke() - } - - /** - * Launch the installed manager app (Shizuku or AXManager — whichever is - * present). If none is installed, falls back to an install page; the - * multi-option install UI lives in the Settings screen, not here. - * - * [T-android-sui-support] Sui has no launcher activity, so a Sui user has - * nothing to open. The UI hides this row for them; if it is somehow - * reached anyway, send them to Sui's page rather than to Shizuku's - * releases (which is what the old unconditional fallback did). - */ - fun openManagerApp(context: Context) { - val pkg = installedManagerPackage() - if (pkg != null) { - val intent = context.packageManager.getLaunchIntentForPackage(pkg) - if (intent != null) { - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - runCatching { context.startActivity(intent) } - .onFailure { AppLogger.warning(TAG, "open $pkg failed: ${it.message}") } - return - } - } - openInstallPage(context, if (isSui()) SUI_GITHUB_URL else SHIZUKU_GITHUB_URL) - } - - fun openInstallPage(context: Context, url: String) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - runCatching { context.startActivity(intent) } - .onFailure { AppLogger.warning(TAG, "openInstallPage($url) failed: ${it.message}") } - } - - fun requestPermission() { - if (!runCatching { Shizuku.pingBinder() }.getOrDefault(false)) return - if (Shizuku.shouldShowRequestPermissionRationale()) { - AppLogger.warning(TAG, "permission permanently denied — user must enable in the manager app") - return - } - runCatching { Shizuku.requestPermission(PERMISSION_REQUEST_CODE) } - .onFailure { AppLogger.warning(TAG, "requestPermission failed: ${it.message}") } - } - - private fun versionOrUnknown(): Int = runCatching { - if (Shizuku.pingBinder()) Shizuku.getVersion() else -1 - }.getOrDefault(-1) - - private fun uidOrUnknown(): Int = runCatching { - if (Shizuku.pingBinder()) Shizuku.getUid() else -1 - }.getOrDefault(-1) - - /** - * Shizuku-SDK process path (reflection-based `Shizuku.newProcess`). - */ - fun runProcess( - argv: Array, - env: Array?, - cwd: String?, - timeoutMs: Long, - ): ShizukuManager.ProcessResult { - if (!isPermissionGranted()) { - return ShizukuManager.ProcessResult( - exitCode = 126, - stdout = "", - stderr = "shizuku not ready (state=${snapshot().state})", - ) - } - // Shizuku.newProcess is hidden API — invoke via reflection so the - // SDK upgrade doesn't break our build. The returned object is a - // ShizukuRemoteProcess which extends java.lang.Process, so once we - // have it, treat it as a normal Process and use the standard - // waitFor(timeout, unit) API rather than reflecting for a - // nonexistent `waitForTimeout` method (T343). - val procAny = runCatching { - val m = Shizuku::class.java.getDeclaredMethod( - "newProcess", - Array::class.java, - Array::class.java, - String::class.java, - ) - m.isAccessible = true - m.invoke(null, argv, env, cwd) - }.getOrElse { - AppLogger.warning(TAG, "newProcess reflection failed: ${it.message}") - return ShizukuManager.ProcessResult(1, "", "shizuku.newProcess unavailable: ${it.message}") - } ?: return ShizukuManager.ProcessResult(1, "", "shizuku.newProcess returned null") - - val proc = procAny as? Process ?: return ShizukuManager.ProcessResult( - 1, "", - "shizuku.newProcess returned ${procAny.javaClass.name}, not java.lang.Process", - ) - - val out = StringBuilder() - val err = StringBuilder() - val outThread = Thread { - runCatching { - proc.inputStream?.use { s -> - s.bufferedReader().forEachLine { l -> synchronized(out) { out.append(l).append('\n') } } - } - } - } - val errThread = Thread { - runCatching { - proc.errorStream?.use { s -> - s.bufferedReader().forEachLine { l -> synchronized(err) { err.append(l).append('\n') } } - } - } - } - outThread.isDaemon = true; errThread.isDaemon = true - outThread.start(); errThread.start() - - val exited: Boolean = try { - proc.waitFor(timeoutMs, TimeUnit.MILLISECONDS) - } catch (t: IllegalArgumentException) { - // Shizuku v13 RemoteProcess.waitFor(long, TimeUnit) throws IllegalArgumentException - // ("process hasn't exited") instead of blocking. Fall back to polling exitValue(). - if (polledFallbackLogged.compareAndSet(false, true)) { - AppLogger.info(TAG, "Using polling fallback for Shizuku RemoteProcess.waitFor(timeout) (SDK bug — throws IllegalArgumentException)") - } - val deadline = System.currentTimeMillis() + timeoutMs - var done = false - while (System.currentTimeMillis() < deadline) { - try { - proc.exitValue() - done = true - break - } catch (e: RuntimeException) { - if (e is IllegalThreadStateException || e is IllegalArgumentException) { - Thread.sleep(50) - } else { - throw e - } - } - } - done - } catch (t: Throwable) { - AppLogger.warning(TAG, "Shizuku waitFor failed: type=${t::class.java.name} msg=${t.message}") - runCatching { proc.destroy() } - runCatching { outThread.join(2000) } - runCatching { errThread.join(2000) } - return ShizukuManager.ProcessResult(1, out.toString().trimEnd('\n'), (err.toString().trimEnd('\n') + "\nwaitFor failed: ${t.message}").trim()) - } - - if (!exited) { - runCatching { proc.destroy() } - runCatching { outThread.join(2000) } - runCatching { errThread.join(2000) } - // Linux convention: 124 = command timed out (coreutils `timeout`). - return ShizukuManager.ProcessResult(124, out.toString().trimEnd('\n'), err.toString().trimEnd('\n')) - } - - runCatching { outThread.join(2000) } - runCatching { errThread.join(2000) } - val rc = runCatching { proc.exitValue() }.getOrDefault(-1) - return ShizukuManager.ProcessResult(rc, out.toString().trimEnd('\n'), err.toString().trimEnd('\n')) - } - - companion object { - private const val TAG = "ShizukuBackend" - - // Known Shizuku-protocol manager packages. Order = UI install-preference. - // Sui is deliberately ABSENT: it is a root module, not an APK, and - // adding a package name for it would re-create the very whitelist trap - // that hid it (GH#110). Sui is detected purely by its live binder. - const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" - const val AXMANAGER_PACKAGE = "frb.axeron.manager" - val SHIZUKU_COMPATIBLE_PACKAGES = listOf(SHIZUKU_PACKAGE, AXMANAGER_PACKAGE) - - const val SHIZUKU_GITHUB_URL = "https://github.com/RikkaApps/Shizuku/releases" - const val AXMANAGER_GITHUB_URL = "https://github.com/fahrez182/AxManager/releases" - const val SUI_GITHUB_URL = "https://github.com/RikkaApps/Sui" - - const val PERMISSION_REQUEST_CODE = 0xC1A4D - - private val polledFallbackLogged = AtomicBoolean(false) - - /** - * [T-android-sui-support] The binder-first decision, extracted as a - * PURE function so every branch is unit-testable without a device, a - * root environment, or the Shizuku SDK's static singleton - * (ShizukuBackendStateTest). - * - * Order matters and is the whole fix: - * 1. binder alive? → READY / NEED_PERMISSION. Whoever supplied it - * (Shizuku APK, AXManager, or Sui module) is irrelevant — this is - * what makes Sui work with zero Sui-specific plumbing. - * 2. binder down, manager APK present → NOT_RUNNING ("start it"). - * 3. binder down, nothing installed → NOT_INSTALLED ("install one"). - * - * NOT_INSTALLED keeps its name for call-site compatibility but now - * means "no binder AND no manager APK" — with Sui in the picture it - * reads as "no privileged backend available", which is what the UI - * copy says. - */ - fun decideState( - binderAlive: Boolean, - permissionGranted: Boolean, - managerApkInstalled: Boolean, - ): ShizukuManager.State = when { - binderAlive && permissionGranted -> ShizukuManager.State.READY - binderAlive -> ShizukuManager.State.NEED_PERMISSION - managerApkInstalled -> ShizukuManager.State.NOT_RUNNING - else -> ShizukuManager.State.NOT_INSTALLED - } - } -} diff --git a/src/android/app/src/main/java/com/openminis/app/offload/ShizukuManager.kt b/src/android/app/src/main/java/com/openminis/app/offload/ShizukuManager.kt deleted file mode 100644 index 2fb5f373..00000000 --- a/src/android/app/src/main/java/com/openminis/app/offload/ShizukuManager.kt +++ /dev/null @@ -1,161 +0,0 @@ -package com.openminis.app.offload - -import android.app.Application -import android.content.Context -import android.os.Build -import com.openminis.app.logging.AppLogger -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * T322 / [T-android-privileged-backend]: lifecycle + permission gateway for - * Shizuku-protocol privileged execution. - * - * Thin façade around a single [ShizukuBackend], which recognises ALL THREE - * Shizuku-protocol providers — the official Shizuku manager, AXManager, and - * Sui (root module) — because they share the same client-side binder slot, so - * there is no "active backend" choice to make at this layer. Public API - * ([snapshot], [isReady], [runProcess], [openShizukuApp], [openInstallPage], - * [requestPermission]) is unchanged from earlier callers. - * - * State machine ([T-android-sui-support]: binder-first — a live binder wins - * before any package check, which is what lets APK-less Sui be detected): - * READY Binder up + permission held — calls work. - * NEED_PERMISSION Binder up but Minis not authorized. - * NOT_RUNNING No binder, but a manager APK is installed — start it. - * NOT_INSTALLED No binder and no manager APK — nothing available. - */ -object ShizukuManager { - private const val TAG = "ShizukuManager" - - enum class State { NOT_INSTALLED, NOT_RUNNING, NEED_PERMISSION, READY } - - data class Snapshot( - val state: State, - val version: Int = -1, - val uid: Int = -1, - /** - * [T-android-sui-support] True when the live binder came from Sui (a - * root module) rather than a manager APK. DISPLAY ONLY — drives the - * "Sui (root)" label and hides the "Open Manager App" row that Sui - * has no activity for. Never gate behaviour on it. - */ - val isSui: Boolean = false, - ) - - data class ProcessResult( - val exitCode: Int, - val stdout: String, - val stderr: String, - ) { - val combined: String get() = if (stderr.isEmpty()) stdout else "$stdout\n$stderr".trimEnd() - } - - @Volatile private var initialized = false - @Volatile private var appContext: Context? = null - private var backend: ShizukuBackend? = null - - private val _snapshot = MutableStateFlow(Snapshot(State.NOT_INSTALLED)) - val snapshot: StateFlow = _snapshot.asStateFlow() - - fun init(app: Application) { - if (initialized) return - initialized = true - appContext = app.applicationContext - backend = ShizukuBackend(app.applicationContext).also { b -> - runCatching { b.registerListeners { recompute("listener") } } - .onFailure { AppLogger.warning(TAG, "registerListeners failed: ${it.message}") } - } - recompute("init") - } - - fun refresh() = recompute("manual-refresh") - - fun isReady(): Boolean = _snapshot.value.state == State.READY - - /** - * Whether a manager **APK** is installed. [T-android-sui-support] This is - * NOT an availability check — Sui provides the binder with no APK. Use - * [isReady] / [snapshot] for "can we actually run privileged commands". - */ - fun isInstalled(): Boolean = backend?.isInstalled() == true - - /** [T-android-sui-support] Display-only: is the live binder Sui's? */ - fun isSui(): Boolean = _snapshot.value.isSui - - /** First installed Shizuku-protocol manager package, or null. */ - fun installedManagerPackage(): String? = backend?.installedManagerPackage() - - fun requestPermission(): Boolean { - val b = backend ?: return false - if (!b.isBinderAlive()) return false - b.requestPermission() - return true - } - - /** Launch the installed manager app (Shizuku or AXManager). */ - fun openShizukuApp(context: Context) { - backend?.openManagerApp(context) - } - - /** - * Open the official Shizuku install page. The "AXManager" alternative is - * surfaced as a separate row in the Settings UI; this method preserves - * the original single-CTA behaviour for callers that don't show a picker. - */ - fun openInstallPage(context: Context) { - backend?.openInstallPage(context, ShizukuBackend.SHIZUKU_GITHUB_URL) - } - - /** Open a specific install page (Shizuku or AXManager). */ - fun openInstallPage(context: Context, url: String) { - backend?.openInstallPage(context, url) - } - - fun runProcess( - argv: Array, - env: Array? = null, - cwd: String? = null, - timeoutMs: Long = 5_000L, - ): ProcessResult { - if (!isReady()) { - return ProcessResult( - exitCode = 126, - stdout = "", - stderr = "shizuku not ready (state=${_snapshot.value.state})", - ) - } - return backend!!.runProcess(argv, env, cwd, timeoutMs) - } - - fun deviceSdk(): Int = Build.VERSION.SDK_INT - - private fun recompute(reason: String) { - val b = backend ?: run { - _snapshot.value = Snapshot(State.NOT_INSTALLED) - return - } - val snap = b.snapshot() - val prev = _snapshot.value - _snapshot.value = snap - // Only log on real transitions — refresh() is fired by every settings - // screen entry + every listener callback, so logging unconditionally - // floods logcat with NOT_RUNNING repeats. - if (snap.state != prev.state || snap.version != prev.version || - snap.uid != prev.uid || snap.isSui != prev.isSui - ) { - // [T-android-sui-support] provider= is the field to ask GH#110 / - // GH#97 reporters for: it distinguishes a Sui-supplied binder from - // a manager APK without needing any (unsupported) sender probe. - val provider = when { - snap.isSui -> "sui" - else -> b.installedManagerPackage() ?: "none" - } - AppLogger.info( - TAG, - "state=${snap.state} ver=${snap.version} uid=${snap.uid} provider=$provider ($reason)", - ) - } - } -} diff --git a/src/android/app/src/main/java/com/openminis/app/permissions/RuntimePermissionGranter.kt b/src/android/app/src/main/java/com/openminis/app/permissions/RuntimePermissionGranter.kt index 491f5ca3..7fd79d08 100644 --- a/src/android/app/src/main/java/com/openminis/app/permissions/RuntimePermissionGranter.kt +++ b/src/android/app/src/main/java/com/openminis/app/permissions/RuntimePermissionGranter.kt @@ -5,7 +5,7 @@ import android.content.pm.PackageManager import android.content.pm.PermissionInfo import com.openminis.app.accessibility.MinisAccessibilityService import com.openminis.app.offload.MinisNotificationListenerService -import com.openminis.app.offload.ShizukuManager +import com.openminis.app.offload.RootProcess import com.openminis.app.tools.android.CommandRisk import com.openminis.app.tools.android.PrivilegedCommandRunner import com.openminis.app.tools.android.vscreen.VirtualScreenClientProvider @@ -20,7 +20,7 @@ import kotlinx.coroutines.withContext * One-tap "grant every permission the app declares", for a rooted phone: instead of the system asking * once per permission as the agent first needs it, the user grants them all from Settings. Besides the * permissions it opens this app's own accesses: the accessibility service, notification access, the default - * assistant role, the module switches, Shizuku's permission and the virtual screen. + * assistant role, the module switches and the virtual screen. * * It only issues fixed `pm grant` / `cmd appops set` argv for THIS package's own declared permissions; * nothing the agent or a model supplies reaches it, and it goes through the structured privileged runner @@ -159,9 +159,8 @@ object RuntimePermissionGranter { val role = context.assistantRoleManagerOrNull() if (role != null && role.assistantRoleAvailable() && !role.assistantRoleHeld()) return false if (!ModuleSettingsStore.SWITCHES.all { ModuleSettingsStore.isEnabled(context, it) }) return false - // Shizuku and the virtual screen only count when Shizuku is there to be used. - if (ShizukuManager.isInstalled() && !ShizukuManager.isReady()) return false - if (ShizukuManager.isReady() && !VirtualScreenClientProvider.get(context).isEnabled()) return false + // The virtual screen runs as root, so it only counts once root is granted. + if (RootProcess.isGranted() && !VirtualScreenClientProvider.get(context).isEnabled()) return false return true } @@ -234,24 +233,18 @@ object RuntimePermissionGranter { if (runCatching { ModuleSettingsStore.setEnabled(context, key, true) }.getOrDefault(false)) succeeded++ else failed += "module:$key" } - // The virtual screen needs Shizuku up and a passing device probe. Shizuku's own service is not started - // from here: that would run a script from shared storage as root. - ShizukuManager.refresh() - if (ShizukuManager.isReady()) { - attempted++ - val ok = withContext(Dispatchers.IO) { - runCatching { - val client = VirtualScreenClientProvider.get(context) - client.runProbe() - client.setEnabled(true) - client.isEnabled() - }.getOrDefault(false) - } - if (ok) succeeded++ else failed += "virtual-screen" - } else if (ShizukuManager.isInstalled()) { - failed += "shizuku-not-running" - attempted++ + // The virtual screen needs a passing device probe. Root already answered every command above, so the + // root service it runs in can start. + attempted++ + val ok = withContext(Dispatchers.IO) { + runCatching { + val client = VirtualScreenClientProvider.get(context) + client.runProbe() + client.setEnabled(true) + client.isEnabled() + }.getOrDefault(false) } + if (ok) succeeded++ else failed += "virtual-screen" return Result(attempted, succeeded, failed) } diff --git a/src/android/app/src/main/java/com/openminis/app/prompt/PromptModuleRegistry.kt b/src/android/app/src/main/java/com/openminis/app/prompt/PromptModuleRegistry.kt index e2db3fad..196fb438 100644 --- a/src/android/app/src/main/java/com/openminis/app/prompt/PromptModuleRegistry.kt +++ b/src/android/app/src/main/java/com/openminis/app/prompt/PromptModuleRegistry.kt @@ -96,7 +96,7 @@ object PromptModuleRegistry { PromptModule( id = "android.debugLoop", title = "Android 调试闭环", - description = "android_* 命名工具的调试流程、证据要求与 Root/Shizuku/Accessibility 能力边界。", + description = "android_* 命名工具的调试流程、证据要求与 Root/Accessibility 能力边界。", group = GROUP_ANDROID, ), PromptModule( diff --git a/src/android/app/src/main/java/com/openminis/app/runtime/guest/AccessibilityOffloadHandler.kt b/src/android/app/src/main/java/com/openminis/app/runtime/guest/AccessibilityOffloadHandler.kt index 4e04fbfb..8fd1beb3 100644 --- a/src/android/app/src/main/java/com/openminis/app/runtime/guest/AccessibilityOffloadHandler.kt +++ b/src/android/app/src/main/java/com/openminis/app/runtime/guest/AccessibilityOffloadHandler.kt @@ -121,8 +121,8 @@ First-run: enable "Minis" under Settings → Accessibility, then `service ping`. // never self-heals, so without this every subsequent a11y call just // returns SERVICE_NOT_RUNNING and the user has no idea the grant is the // problem. Detect it here — the single point every real operation - // passes through — and offer a repair (one-tap via Shizuku when - // available, otherwise a trip to Settings). + // passes through — and offer a repair (one-tap as root when + // granted, otherwise a trip to Settings). // // `service` / `--version` are exempt for the same reason they bypass // the gate above: they are how you DIAGNOSE this state, and prompting @@ -143,8 +143,8 @@ First-run: enable "Minis" under Settings → Accessibility, then `service ping`. args, "SERVICE_NOT_RUNNING", "Accessibility permission was revoked (this happens after a force-stop). " + - "Re-enable Minis under Settings → Accessibility, or use Settings → " + - "Permissions → Integrations to repair it with Shizuku.", + "Re-enable Minis under Settings → Accessibility, or repair it as root " + + "under Settings → System → System permissions.", exit = 77, ) } @@ -384,7 +384,7 @@ First-run: enable "Minis" under Settings → Accessibility, then `service ping`. private fun uiScreenshot(args: OffloadArgs): NativeOffloadResult { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { return err(args, "NOT_SUPPORTED", - "ui screenshot requires Android 11 (API 30); use `android-shizuku-cli exec screencap` instead.") + "ui screenshot requires Android 11 (API 30); use `android-root-cli exec screencap` instead.") } val svc = svcOrThrow() val scale = (args.getDouble("scale") ?: 0.5).toFloat().coerceIn(0.05f, 1.0f) @@ -605,7 +605,7 @@ First-run: enable "Minis" under Settings → Accessibility, then `service ping`. "RECENTS" -> AccessibilityService.GLOBAL_ACTION_RECENTS "NOTIFICATIONS" -> AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS else -> return err(args, "INVALID_ARGS", - "key '$keyName' not supported via accessibility (try BACK/HOME/RECENTS/NOTIFICATIONS; for ENTER/DPAD use shizuku-cli `input keyevent`)") + "key '$keyName' not supported via accessibility (try BACK/HOME/RECENTS/NOTIFICATIONS; for ENTER/DPAD use `android-root-cli input key`)") } // Ported evidence semantics: the platform accepting the action is not proof of // an effect, a call that never started is a refusal, and a call that started diff --git a/src/android/app/src/main/java/com/openminis/app/runtime/guest/DebugOffloadHandler.kt b/src/android/app/src/main/java/com/openminis/app/runtime/guest/DebugOffloadHandler.kt index c079e305..de2db4f6 100644 --- a/src/android/app/src/main/java/com/openminis/app/runtime/guest/DebugOffloadHandler.kt +++ b/src/android/app/src/main/java/com/openminis/app/runtime/guest/DebugOffloadHandler.kt @@ -150,7 +150,7 @@ class DebugOffloadHandler(private val context: Context) : NativeOffloadHandler { // in DEBUG_ONLY_METHODS of DebugMethodRegistry and are useful // from a shell context. Both take an `args` JSON array; we // accept either a positional command line or `--args '["a","b"]'`. - "shizuku" -> RpcCall("debug.shizuku.exec", buildExecForwarder(rest, args)) + "root-cli", "rootCli" -> RpcCall("debug.rootCli.exec", buildExecForwarder(rest, args)) "model-use", "modelUse" -> RpcCall("debug.modelUse.exec", buildExecForwarder(rest, args)) // Generic escape hatch — call any registered method directly. @@ -172,7 +172,7 @@ class DebugOffloadHandler(private val context: Context) : NativeOffloadHandler { } /** - * Build the `{args: [...]}` envelope expected by `debug.shizuku.exec` / + * Build the `{args: [...]}` envelope expected by `debug.rootCli.exec` / * `debug.modelUse.exec`. If the user passes `--args '["foo","bar"]'` * we take that verbatim; otherwise we use the remaining positional * arguments as the argv slice. @@ -330,7 +330,7 @@ SUBCOMMANDS: exec Run a shell command via debug.shellExecute Android-only (DEBUG_ONLY_METHODS in DebugMethodRegistry): - shizuku Invoke android-shizuku-cli (debug.shizuku.exec) + root-cli Invoke android-root-cli (debug.rootCli.exec) model-use Invoke minis-model-use (debug.modelUse.exec) Escape hatch (for any method not listed above): diff --git a/src/android/app/src/main/java/com/openminis/app/runtime/guest/GuestCommandBridge.kt b/src/android/app/src/main/java/com/openminis/app/runtime/guest/GuestCommandBridge.kt index 03d06ec2..89fd3063 100644 --- a/src/android/app/src/main/java/com/openminis/app/runtime/guest/GuestCommandBridge.kt +++ b/src/android/app/src/main/java/com/openminis/app/runtime/guest/GuestCommandBridge.kt @@ -171,6 +171,12 @@ internal object GuestCommandBridge { .forEach(::appendLine) } appendLine("mkdir -p ${shellQuote(binDir)} ${shellQuote(etcDir)} ${shellQuote(usrLocalBin)}") + // A wrapper an older build wrote for a command that no longer exists would only answer + // "unsupported command"; drop it. Plain files only, the same rule as generated paths. + RETIRED_COMMAND_NAMES.filter { it !in commandNames }.forEach { name -> + val quoted = shellQuote("$usrLocalBin/$name") + appendLine("if [ -f $quoted ] && [ ! -L $quoted ]; then rm -f -- $quoted; fi") + } generatedFiles.forEach { file -> val quoted = shellQuote(file) appendLine("[ ! -L $quoted ] || exit 75") @@ -620,6 +626,12 @@ exit 0 private val SAFE_COMMAND_NAME = Regex("^[A-Za-z0-9][A-Za-z0-9._-]*$") private val CORE_COMMAND_NAMES = setOf("minis-config", "minis-model-use") + + /** Commands earlier builds installed and this one no longer provides. */ + internal val RETIRED_COMMAND_NAMES = setOf( + // Replaced by android-root-cli when Shizuku was removed (2026-10). + "android-shizuku-cli", + ) private val URL_COMMAND_NAMES = setOf( "minis-open", "xdg-open", @@ -644,7 +656,7 @@ exit 0 "android-speech", "android-weather", "android-a11y-cli", - "android-shizuku-cli", + "android-root-cli", "minis-apt", "minis-browser-use", "minis-scheduled", diff --git a/src/android/app/src/main/java/com/openminis/app/runtime/guest/ShizukuOffloadHandler.kt b/src/android/app/src/main/java/com/openminis/app/runtime/guest/RootCliOffloadHandler.kt similarity index 82% rename from src/android/app/src/main/java/com/openminis/app/runtime/guest/ShizukuOffloadHandler.kt rename to src/android/app/src/main/java/com/openminis/app/runtime/guest/RootCliOffloadHandler.kt index f7a6d158..3e15f7e9 100644 --- a/src/android/app/src/main/java/com/openminis/app/runtime/guest/ShizukuOffloadHandler.kt +++ b/src/android/app/src/main/java/com/openminis/app/runtime/guest/RootCliOffloadHandler.kt @@ -2,7 +2,7 @@ package com.openminis.app.runtime.guest import android.content.Context import com.openminis.app.logging.AppLogger -import com.openminis.app.offload.ShizukuManager +import com.openminis.app.offload.RootProcess import com.openminis.app.runtime.guest.NativeOffloadHandler import com.openminis.app.runtime.guest.NativeOffloadRequest import com.openminis.app.runtime.guest.NativeOffloadResult @@ -12,15 +12,14 @@ import org.json.JSONArray import org.json.JSONObject /** - * T322: `android-shizuku-cli` — full CLI surface from the design doc. + * `android-root-cli` — privileged Android control from the guest shell. * - * Routes argv through [ShizukuManager.runProcess], which executes - * privileged shell tools (`pm`, `am`, `cmd`, `settings`, `dumpsys`, - * `input`, `wm`, `appops`) inside the Shizuku service's process (uid - * 2000 with adb-startup, uid 0 with root-startup). This is exactly the - * same surface area `adb shell` provides — so any subcommand a developer - * could run from a USB-tethered laptop is now reachable from inside the - * agent's sandboxed shell, without launching the device's settings UI. + * Routes argv through [RootProcess.run], which runs the Android shell tools + * (`pm`, `am`, `cmd`, `settings`, `dumpsys`, `input`, `wm`, `appops`) as + * root through the app's one `su` launcher. Everything `adb shell` can do is + * reachable from inside the agent's guest shell, plus whatever root adds. + * Until 2026-10 the same surface was `android-shizuku-cli` over a Shizuku + * binder; the subcommands and their JSON are unchanged. * * Subcommand groups (12, per design doc): * package list / info / install / uninstall / enable / disable / clear / path @@ -40,15 +39,15 @@ import org.json.JSONObject * flags via [OffloadOutput.formatBody], and `--format json|text|csv` * (text is the default; csv is wired only on `package list`). */ -class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler { +class RootCliOffloadHandler(private val context: Context) : NativeOffloadHandler { override fun handle(request: NativeOffloadRequest): NativeOffloadResult { val argv = request.argv.drop(1) - // Top-level help / version short-circuits — no Shizuku dependency. + // Top-level help / version short-circuits — no Root needed. if (argv.isEmpty() || argv[0] == "--help" || argv[0] == "-h" || argv[0] == "help") { return ok(HELP) } - if (argv[0] == "--version") return ok("android-shizuku-cli 1.0 (T322)") + if (argv[0] == "--version") return ok("android-root-cli 1.0") val group = argv[0] val rest = argv.drop(1) @@ -62,24 +61,18 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler // Allow" upgrades the stored level to BYPASS for next time. See // OffloadGate for the sync→suspend rationale (handler interface // is sync, manager API is suspend). - if (!OffloadGate.allow("shizuku_cli", "android-shizuku-cli", request)) { + if (!OffloadGate.allow(TOOL_NAME, "android-root-cli", request)) { return errEnvelope( "PERMISSION_DENIED", - "Agent is not allowed to use android-shizuku-cli. Open Settings → Permissions → Integrations to change.", + "Agent is not allowed to use android-root-cli. Open Settings → Permissions → Integrations to change.", args, ) } - // Every other group needs binder + permission. - ShizukuManager.refresh() - when (val st = ShizukuManager.snapshot.value.state) { - ShizukuManager.State.NOT_INSTALLED -> - return errEnvelope("SERVICE_NOT_RUNNING", "Shizuku is not installed. Open Settings → Permissions → Shizuku.", args) - ShizukuManager.State.NOT_RUNNING -> - return errEnvelope("SERVICE_NOT_RUNNING", "Shizuku service is not running. Start it via adb or root, then retry.", args) - ShizukuManager.State.NEED_PERMISSION -> - return errEnvelope("PERMISSION_DENIED", "Minis is not authorized for Shizuku. Grant permission in Settings → Permissions → Shizuku.", args) - ShizukuManager.State.READY -> { /* fall through */ } + // Every other group needs Root. Only a definite "no su" or "denied" stops here; anything + // else is tried and su itself answers. + RootProcess.unavailable()?.let { root -> + return errEnvelope(root.code, "${root.message}. Open Settings → System → Root.", args) } return try { @@ -98,9 +91,9 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler "exec" -> handleExec(rest, args) else -> NativeOffloadResult( 2, - "android-shizuku-cli: unknown subcommand '$group'.\n" + - " - Try `android-shizuku-cli exec $group ${rest.joinToString(" ")}`".trimEnd() + " to run as a raw shell command.\n" + - " - Run `android-shizuku-cli` with no args to see available subcommands.\n", + "android-root-cli: unknown subcommand '$group'.\n" + + " - Try `android-root-cli exec $group ${rest.joinToString(" ")}`".trimEnd() + " to run as a raw shell command.\n" + + " - Run `android-root-cli` with no args to see available subcommands.\n", ) } } catch (t: Throwable) { @@ -112,24 +105,23 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler // ─── Group: service ──────────────────────────────────────────────────── private fun handleService(rest: List, args: OffloadArgs): NativeOffloadResult { val sub = rest.firstOrNull() ?: "status" - val snap = ShizukuManager.snapshot.value return when (sub) { + // What the app last learned about su, without asking su again. "status" -> { + val reason = RootProcess.unavailableReason() val data = JSONObject() - .put("state", snap.state.name) - .put("running", snap.state == ShizukuManager.State.READY || - snap.state == ShizukuManager.State.NEED_PERMISSION) - .put("authorized", snap.state == ShizukuManager.State.READY) - .put("version", snap.version) - .put("uid", snap.uid) - .put("startup_type", when (snap.uid) { 0 -> "root"; 2000 -> "adb"; else -> "unknown" }) + .put("authorized", RootProcess.isGranted()) + .put("available", reason == null) + if (reason != null) data.put("reason", reason) okEnvelope(data, args) } + // Asks su now: the answer is the uid the command actually ran as. "ping" -> { - if (snap.state == ShizukuManager.State.READY) { - ok("OK Shizuku service is running (uid=${snap.uid}, version=${snap.version})\n") + val r = RootProcess.run(arrayOf("id", "-u")) + if (r.exitCode == 0 && r.stdout.trim() == "0") { + ok("OK root (uid=0)\n") } else { - NativeOffloadResult(1, "FAIL Shizuku service is not READY (state=${snap.state})\n") + NativeOffloadResult(1, "FAIL root is not available: ${failMessage(r)}\n") } } "help", "--help", "-h" -> ok(SERVICE_HELP) @@ -161,7 +153,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler if (args.hasFlag("disabled")) cmd.add("-d") args.get("user")?.let { cmd.addAll(listOf("--user", it)) } args.get("filter")?.let { cmd.add(it) } - val r = ShizukuManager.runProcess(cmd.toTypedArray()) + val r = RootProcess.run(cmd.toTypedArray()) if (r.exitCode != 0) return errEnvelope("OPERATION_FAILED", failMessage(r), args) val pkgs = r.stdout.lineSequence() .map { it.removePrefix("package:") } @@ -182,7 +174,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler private fun packageInfo(pkg: String?, args: OffloadArgs): NativeOffloadResult { if (pkg.isNullOrBlank()) return errEnvelope("INVALID_ARGS", "package info ", args) val cmd = mutableListOf("dumpsys", "package", pkg) - val r = ShizukuManager.runProcess(cmd.toTypedArray(), timeoutMs = 10_000) + val r = RootProcess.run(cmd.toTypedArray(), timeoutMs = 10_000) if (r.exitCode != 0) return errEnvelope("PACKAGE_NOT_FOUND", r.combined, args) val d = parseDumpsysPackage(r.stdout, pkg) return okEnvelope(d, args) @@ -196,7 +188,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler if (args.hasFlag("grant-permissions")) cmd.add("-g") args.get("user")?.let { cmd.addAll(listOf("--user", it)) } cmd.add(apk) - val r = ShizukuManager.runProcess(cmd.toTypedArray(), timeoutMs = 60_000) + val r = RootProcess.run(cmd.toTypedArray(), timeoutMs = 60_000) return if (r.exitCode == 0 && r.stdout.contains("Success")) { okEnvelope(JSONObject().put("installed", apk), args) } else { @@ -210,7 +202,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler if (args.hasFlag("keep-data", "k")) cmd.add("-k") args.get("user")?.let { cmd.addAll(listOf("--user", it)) } cmd.add(pkg) - val r = ShizukuManager.runProcess(cmd.toTypedArray(), timeoutMs = 30_000) + val r = RootProcess.run(cmd.toTypedArray(), timeoutMs = 30_000) return if (r.exitCode == 0 && r.stdout.contains("Success")) { okEnvelope(JSONObject().put("uninstalled", pkg), args) } else { @@ -226,7 +218,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler val cmd = mutableListOf("pm", verb) args.get("user")?.let { cmd.addAll(listOf("--user", it)) } cmd.add(pkg) - val r = ShizukuManager.runProcess(cmd.toTypedArray()) + val r = RootProcess.run(cmd.toTypedArray()) return if (r.exitCode == 0) { okEnvelope(JSONObject().put("package", pkg).put("state", if (enable) "enabled" else "disabled"), args) } else { @@ -243,14 +235,14 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler if (args.hasFlag("cache-only")) add("--cache-only") add(pkg) } - val r = ShizukuManager.runProcess(cmd.toTypedArray(), timeoutMs = 15_000) + val r = RootProcess.run(cmd.toTypedArray(), timeoutMs = 15_000) return if (r.exitCode == 0) okEnvelope(JSONObject().put("cleared", pkg), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } private fun packagePath(pkg: String?, args: OffloadArgs): NativeOffloadResult { if (pkg.isNullOrBlank()) return errEnvelope("INVALID_ARGS", "package path ", args) - val r = ShizukuManager.runProcess(arrayOf("pm", "path", pkg)) + val r = RootProcess.run(arrayOf("pm", "path", pkg)) if (r.exitCode != 0) return errEnvelope("PACKAGE_NOT_FOUND", r.combined, args) val paths = r.stdout.lineSequence() .map { it.removePrefix("package:") } @@ -276,7 +268,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler private fun permissionList(pkg: String?, args: OffloadArgs): NativeOffloadResult { if (pkg.isNullOrBlank()) return errEnvelope("INVALID_ARGS", "permission list ", args) val cmd = mutableListOf("dumpsys", "package", pkg) - val r = ShizukuManager.runProcess(cmd.toTypedArray(), timeoutMs = 10_000) + val r = RootProcess.run(cmd.toTypedArray(), timeoutMs = 10_000) if (r.exitCode != 0) return errEnvelope("PACKAGE_NOT_FOUND", r.combined, args) val perms = parseDumpsysPermissions(r.stdout) val onlyGranted = args.hasFlag("granted") @@ -308,7 +300,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler val cmd = mutableListOf("pm", if (grant) "grant" else "revoke") args.get("user")?.let { cmd.addAll(listOf("--user", it)) } cmd.add(pkg); cmd.add(perm) - val r = ShizukuManager.runProcess(cmd.toTypedArray()) + val r = RootProcess.run(cmd.toTypedArray()) return if (r.exitCode == 0) { okEnvelope(JSONObject().put("package", pkg).put("permission", perm).put("granted", grant), args) } else { @@ -319,7 +311,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler private fun permissionAppops(pos: List, args: OffloadArgs): NativeOffloadResult { if (pos.size < 3) return errEnvelope("INVALID_ARGS", "permission appops ", args) val (pkg, op, mode) = Triple(pos[0], pos[1], pos[2]) - val r = ShizukuManager.runProcess(arrayOf("appops", "set", pkg, op, mode)) + val r = RootProcess.run(arrayOf("appops", "set", pkg, op, mode)) return if (r.exitCode == 0) { okEnvelope(JSONObject().put("package", pkg).put("op", op).put("mode", mode), args) } else { @@ -358,7 +350,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler for ((k, v) in extras(args, "extra-bool")) { cmd.addAll(listOf("--ez", k, v)) } - val r = ShizukuManager.runProcess(cmd.toTypedArray(), timeoutMs = 15_000) + val r = RootProcess.run(cmd.toTypedArray(), timeoutMs = 15_000) return if (r.exitCode == 0) okEnvelope(JSONObject().put("status", "started").put("output", r.stdout), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } @@ -368,14 +360,14 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler val cmd = mutableListOf("am", "force-stop") args.get("user")?.let { cmd.addAll(listOf("--user", it)) } cmd.add(pkg) - val r = ShizukuManager.runProcess(cmd.toTypedArray()) + val r = RootProcess.run(cmd.toTypedArray()) return if (r.exitCode == 0) okEnvelope(JSONObject().put("stopped", pkg), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } private fun activityKill(pkg: String?, args: OffloadArgs): NativeOffloadResult { if (pkg.isNullOrBlank()) return errEnvelope("INVALID_ARGS", "activity kill ", args) - val r = ShizukuManager.runProcess(arrayOf("am", "kill", pkg)) + val r = RootProcess.run(arrayOf("am", "kill", pkg)) return if (r.exitCode == 0) okEnvelope(JSONObject().put("killed", pkg), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } @@ -388,13 +380,13 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler args.get("user")?.let { cmd.addAll(listOf("--user", it)) } for ((k, v) in extras(args, "extra-string")) cmd.addAll(listOf("--es", k, v)) for ((k, v) in extras(args, "extra-int")) cmd.addAll(listOf("--ei", k, v)) - val r = ShizukuManager.runProcess(cmd.toTypedArray()) + val r = RootProcess.run(cmd.toTypedArray()) return if (r.exitCode == 0) okEnvelope(JSONObject().put("broadcast", action).put("output", r.stdout), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } private fun activityTop(args: OffloadArgs): NativeOffloadResult { - val r = ShizukuManager.runProcess(arrayOf("dumpsys", "activity", "activities"), timeoutMs = 6_000) + val r = RootProcess.run(arrayOf("dumpsys", "activity", "activities"), timeoutMs = 6_000) if (r.exitCode != 0) return errEnvelope("OPERATION_FAILED", failMessage(r), args) // Look for "topResumedActivity=ActivityRecord{... pkg/cls ...}" val regex = Regex("""topResumedActivity=ActivityRecord\{[^ ]+ \d+ ([^/]+)/([^ ]+)""") @@ -409,7 +401,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler // No resumed record in the activity dump: the focused window is the surface // that receives input. It is read exactly instead of treating a substring // match as the same app (FocusedWindowParser @ c15de97). - val w = ShizukuManager.runProcess(arrayOf("dumpsys", "window"), timeoutMs = 6_000) + val w = RootProcess.run(arrayOf("dumpsys", "window"), timeoutMs = 6_000) val focused = if (w.exitCode == 0) FocusedWindowParser.parse(w.stdout) else null val obj = if (focused != null) { JSONObject() @@ -437,7 +429,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler } private fun displayList(args: OffloadArgs): NativeOffloadResult { - val r = ShizukuManager.runProcess(arrayOf("dumpsys", "display"), timeoutMs = 6_000) + val r = RootProcess.run(arrayOf("dumpsys", "display"), timeoutMs = 6_000) if (r.exitCode != 0) return errEnvelope("OPERATION_FAILED", failMessage(r), args) // Parse "DisplayDeviceInfo" blocks; each contains "" + width/height/density/refresh. val displays = JSONArray() @@ -458,7 +450,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler } // The `wm size` override is the logical coordinate space that input and // screencap use; the physical displays alone do not describe it. - val size = ShizukuManager.runProcess(arrayOf("wm", "size"), timeoutMs = 4_000) + val size = RootProcess.run(arrayOf("wm", "size"), timeoutMs = 4_000) val override = if (size.exitCode == 0) AndroidDisplaySizeParser.parseOverride(size.stdout) else null val logical = override ?: if (size.exitCode == 0) AndroidDisplaySizeParser.parse(size.stdout) else null return okEnvelope(JSONObject() @@ -472,13 +464,13 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler var ok = true val results = JSONObject() args.getInt("density")?.let { - val r = ShizukuManager.runProcess(arrayOf("wm", "density", it.toString())) + val r = RootProcess.run(arrayOf("wm", "density", it.toString())) results.put("density", r.exitCode == 0) ok = ok && r.exitCode == 0 } val w = args.getInt("width"); val h = args.getInt("height") if (w != null && h != null) { - val r = ShizukuManager.runProcess(arrayOf("wm", "size", "${w}x${h}")) + val r = RootProcess.run(arrayOf("wm", "size", "${w}x${h}")) results.put("size", r.exitCode == 0) ok = ok && r.exitCode == 0 } @@ -487,8 +479,8 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler } private fun displayReset(args: OffloadArgs): NativeOffloadResult { - val r1 = ShizukuManager.runProcess(arrayOf("wm", "size", "reset")) - val r2 = ShizukuManager.runProcess(arrayOf("wm", "density", "reset")) + val r1 = RootProcess.run(arrayOf("wm", "size", "reset")) + val r2 = RootProcess.run(arrayOf("wm", "density", "reset")) return if (r1.exitCode == 0 && r2.exitCode == 0) okEnvelope(JSONObject().put("reset", true), args) else errEnvelope("OPERATION_FAILED", "size:${r1.combined} density:${r2.combined}", args) } @@ -511,7 +503,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler private fun settingsGet(ns: String?, key: String?, args: OffloadArgs): NativeOffloadResult { if (!validNs(ns) || key.isNullOrBlank()) return errEnvelope("INVALID_ARGS", "settings get ", args) - val r = ShizukuManager.runProcess(arrayOf("settings", "get", ns!!, key)) + val r = RootProcess.run(arrayOf("settings", "get", ns!!, key)) // T339: `settings get` returns exitCode=1 with empty stdout/stderr when the key // is missing from the namespace database. Don't pass an empty message through — // synthesize a useful one so callers see what went wrong. @@ -523,8 +515,8 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler val hint = r.combined.ifBlank { when (r.exitCode) { 1 -> "settings $ns key='$key' does not exist or is not readable (exitCode=1). " + - "Try `android-shizuku-cli settings list $ns` to see available keys." - 124 -> "settings get $ns $key timed out (exitCode=124); raise --timeout-ms or check Shizuku service health." + "Try `android-root-cli settings list $ns` to see available keys." + 124 -> "settings get $ns $key timed out (exitCode=124); raise --timeout-ms or check that Root is still granted." 143 -> "settings get $ns $key was killed mid-flight (SIGTERM/exitCode=143); " + "the privileged process was destroyed before it could finish." else -> "settings get $ns $key failed (exitCode=${r.exitCode})" @@ -537,7 +529,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler if (!validNs(ns) || key.isNullOrBlank() || value == null) { return errEnvelope("INVALID_ARGS", "settings set ", args) } - val r = ShizukuManager.runProcess(arrayOf("settings", "put", ns!!, key, value)) + val r = RootProcess.run(arrayOf("settings", "put", ns!!, key, value)) return if (r.exitCode == 0) okEnvelope(JSONObject().put("key", key).put("value", value), args) else errEnvelope( "OPERATION_FAILED", @@ -548,14 +540,14 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler private fun settingsDelete(ns: String?, key: String?, args: OffloadArgs): NativeOffloadResult { if (!validNs(ns) || key.isNullOrBlank()) return errEnvelope("INVALID_ARGS", "settings delete ", args) - val r = ShizukuManager.runProcess(arrayOf("settings", "delete", ns!!, key)) + val r = RootProcess.run(arrayOf("settings", "delete", ns!!, key)) return if (r.exitCode == 0) okEnvelope(JSONObject().put("deleted", key), args) else errEnvelope("OPERATION_FAILED", failMessage(r, "settings delete $ns $key"), args) } private fun settingsList(ns: String?, args: OffloadArgs): NativeOffloadResult { if (!validNs(ns)) return errEnvelope("INVALID_ARGS", "settings list ", args) - val r = ShizukuManager.runProcess(arrayOf("settings", "list", ns!!), timeoutMs = 8_000) + val r = RootProcess.run(arrayOf("settings", "list", ns!!), timeoutMs = 8_000) if (r.exitCode != 0) return errEnvelope("OPERATION_FAILED", failMessage(r, "settings list $ns"), args) val filter = args.get("filter") val obj = JSONObject() @@ -585,7 +577,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler } private fun userList(args: OffloadArgs): NativeOffloadResult { - val r = ShizukuManager.runProcess(arrayOf("pm", "list", "users")) + val r = RootProcess.run(arrayOf("pm", "list", "users")) if (r.exitCode != 0) return errEnvelope("OPERATION_FAILED", failMessage(r), args) val users = JSONArray() // Format: "UserInfo{::} running" @@ -606,28 +598,28 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler if (args.hasFlag("managed-profile")) cmd.add("--profileOf") if (args.hasFlag("guest")) cmd.add("--guest") cmd.add(name) - val r = ShizukuManager.runProcess(cmd.toTypedArray()) + val r = RootProcess.run(cmd.toTypedArray()) return if (r.exitCode == 0) okEnvelope(JSONObject().put("created", r.stdout.trim()), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } private fun userRemove(id: String?, args: OffloadArgs): NativeOffloadResult { if (id.isNullOrBlank()) return errEnvelope("INVALID_ARGS", "user remove ", args) - val r = ShizukuManager.runProcess(arrayOf("pm", "remove-user", id)) + val r = RootProcess.run(arrayOf("pm", "remove-user", id)) return if (r.exitCode == 0) okEnvelope(JSONObject().put("removed", id), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } private fun userSwitch(id: String?, args: OffloadArgs): NativeOffloadResult { if (id.isNullOrBlank()) return errEnvelope("INVALID_ARGS", "user switch ", args) - val r = ShizukuManager.runProcess(arrayOf("am", "switch-user", id)) + val r = RootProcess.run(arrayOf("am", "switch-user", id)) return if (r.exitCode == 0) okEnvelope(JSONObject().put("switched", id), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } private fun userStartStop(id: String?, args: OffloadArgs, start: Boolean): NativeOffloadResult { if (id.isNullOrBlank()) return errEnvelope("INVALID_ARGS", "user ${if (start) "start" else "stop"} ", args) - val r = ShizukuManager.runProcess(arrayOf("am", if (start) "start-user" else "stop-user", id)) + val r = RootProcess.run(arrayOf("am", if (start) "start-user" else "stop-user", id)) return if (r.exitCode == 0) okEnvelope(JSONObject().put(if (start) "started" else "stopped", id), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } @@ -668,14 +660,14 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler if (wantBackground) { // RUN_ANY_IN_BACKGROUND appop val mode = if (restrict) "ignore" else "allow" - val r = ShizukuManager.runProcess(arrayOf("appops", "set", pkg, "RUN_ANY_IN_BACKGROUND", mode)) + val r = RootProcess.run(arrayOf("appops", "set", pkg, "RUN_ANY_IN_BACKGROUND", mode)) results.put("background", r.exitCode == 0) if (r.exitCode != 0) failed = true } if (wantData) { val uid = pkgUid(pkg) ?: return errEnvelope("NOT_FOUND", "cannot determine the UID of '$pkg'; nothing was changed for --data", args) - val r = ShizukuManager.runProcess(arrayOf("cmd", "netpolicy", + val r = RootProcess.run(arrayOf("cmd", "netpolicy", if (restrict) "add" else "remove", "restrict-background-blacklist", uid)) results.put("data", r.exitCode == 0) if (r.exitCode != 0) failed = true @@ -687,7 +679,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler private fun networkStats(pkg: String?, args: OffloadArgs): NativeOffloadResult { if (pkg.isNullOrBlank()) return errEnvelope("INVALID_ARGS", "network stats ", args) // dumpsys netstats detail; surface the per-uid block. - val r = ShizukuManager.runProcess(arrayOf("dumpsys", "netstats", "detail"), timeoutMs = 10_000) + val r = RootProcess.run(arrayOf("dumpsys", "netstats", "detail"), timeoutMs = 10_000) if (r.exitCode != 0) return errEnvelope("OPERATION_FAILED", failMessage(r), args) val uid = pkgUid(pkg)?.toIntOrNull() ?: return errEnvelope("NOT_FOUND", "cannot determine the UID of '$pkg'", args) @@ -698,7 +690,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler /** The package's UID, or null when it cannot be established (never a guessed 0). */ private fun pkgUid(pkg: String): String? { - val r = ShizukuManager.runProcess(arrayOf("dumpsys", "package", pkg), timeoutMs = 6_000) + val r = RootProcess.run(arrayOf("dumpsys", "package", pkg), timeoutMs = 6_000) return parsePackageUid(r.stdout, r.exitCode) } @@ -717,7 +709,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler private fun inputTap(x: String?, y: String?, args: OffloadArgs): NativeOffloadResult { if (x.isNullOrBlank() || y.isNullOrBlank()) return errEnvelope("INVALID_ARGS", "input tap ", args) - val r = ShizukuManager.runProcess(arrayOf("input", "tap", x, y)) + val r = RootProcess.run(arrayOf("input", "tap", x, y)) return if (r.exitCode == 0) okEnvelope(JSONObject().put("tap", "$x,$y"), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } @@ -726,7 +718,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler if (coords.size < 4) return errEnvelope("INVALID_ARGS", "input swipe ", args) val cmd = mutableListOf("input", "swipe", coords[0], coords[1], coords[2], coords[3]) args.get("duration")?.let { cmd.add(it) } - val r = ShizukuManager.runProcess(cmd.toTypedArray()) + val r = RootProcess.run(cmd.toTypedArray()) return if (r.exitCode == 0) okEnvelope(JSONObject().put("swipe", coords.take(4)), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } @@ -736,14 +728,14 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler val cmd = mutableListOf("input", "keyevent") if (args.hasFlag("long-press")) cmd.add("--longpress") cmd.add(key) - val r = ShizukuManager.runProcess(cmd.toTypedArray()) + val r = RootProcess.run(cmd.toTypedArray()) return if (r.exitCode == 0) okEnvelope(JSONObject().put("key", key), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } private fun inputText(text: String, args: OffloadArgs): NativeOffloadResult { if (text.isBlank()) return errEnvelope("INVALID_ARGS", "input text ", args) - val r = ShizukuManager.runProcess(arrayOf("input", "text", encodeInputText(text))) + val r = RootProcess.run(arrayOf("input", "text", encodeInputText(text))) return if (r.exitCode == 0) okEnvelope(JSONObject().put("text", text), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } @@ -761,7 +753,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler } private fun notificationList(args: OffloadArgs): NativeOffloadResult { - val r = ShizukuManager.runProcess(arrayOf("dumpsys", "notification", "--noredact"), timeoutMs = 8_000) + val r = RootProcess.run(arrayOf("dumpsys", "notification", "--noredact"), timeoutMs = 8_000) if (r.exitCode != 0) return errEnvelope("OPERATION_FAILED", failMessage(r), args) val arr = JSONArray() val pkgFilter = args.get("package") @@ -780,7 +772,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler private fun notificationDismiss(args: OffloadArgs): NativeOffloadResult { if (args.hasFlag("all")) { - val r = ShizukuManager.runProcess(arrayOf("cmd", "notification", "cancel_all")) + val r = RootProcess.run(arrayOf("cmd", "notification", "cancel_all")) return if (r.exitCode == 0) okEnvelope(JSONObject().put("cancelled", "all"), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } @@ -788,12 +780,12 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler val id = args.get("id") return when { pkg != null -> { - val r = ShizukuManager.runProcess(arrayOf("cmd", "notification", "cancel", pkg)) + val r = RootProcess.run(arrayOf("cmd", "notification", "cancel", pkg)) if (r.exitCode == 0) okEnvelope(JSONObject().put("cancelled", pkg), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } id != null -> { - val r = ShizukuManager.runProcess(arrayOf("cmd", "notification", "cancel", id)) + val r = RootProcess.run(arrayOf("cmd", "notification", "cancel", id)) if (r.exitCode == 0) okEnvelope(JSONObject().put("cancelled_id", id), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } @@ -806,7 +798,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler return when (sub) { "list" -> { val pkg = rest.getOrNull(1) ?: return errEnvelope("INVALID_ARGS", "notification channel list ", args) - val r = ShizukuManager.runProcess(arrayOf("dumpsys", "notification"), timeoutMs = 8_000) + val r = RootProcess.run(arrayOf("dumpsys", "notification"), timeoutMs = 8_000) if (r.exitCode != 0) return errEnvelope("OPERATION_FAILED", failMessage(r), args) val arr = JSONArray() val regex = Regex("""NotificationChannel\{.*?id=([^,]+).*?name=([^,]+).*?importance=(\d+)""") @@ -831,7 +823,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler cmd.addAll(listOf("set_channel_importance", pkg, ch, imp)) } } - val r = ShizukuManager.runProcess(cmd.toTypedArray()) + val r = RootProcess.run(cmd.toTypedArray()) if (r.exitCode == 0) okEnvelope(JSONObject().put("channel", ch).put("package", pkg), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } @@ -858,7 +850,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler if (args.hasFlag("long", "l")) cmd.add("-la") else cmd.add("-1") if (args.hasFlag("recursive", "r")) cmd.add("-R") cmd.add(path) - val r = ShizukuManager.runProcess(cmd.toTypedArray(), timeoutMs = 8_000) + val r = RootProcess.run(cmd.toTypedArray(), timeoutMs = 8_000) return if (r.exitCode == 0) ok(r.stdout + "\n") else errEnvelope("OPERATION_FAILED", failMessage(r), args) } @@ -867,10 +859,9 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler if (src.isNullOrBlank() || dst.isNullOrBlank()) { return errEnvelope("INVALID_ARGS", "file ${if (pull) "pull" else "push"} ", args) } - // Use cp via Shizuku — works for both directions because the - // Shizuku service has full FS access, while our own uid usually - // does not for /Android/data/* paths. - val r = ShizukuManager.runProcess(arrayOf("cp", "-r", src, dst), timeoutMs = 30_000) + // cp as root works in both directions, including /Android/data/* + // paths the app uid cannot reach. + val r = RootProcess.run(arrayOf("cp", "-r", src, dst), timeoutMs = 30_000) return if (r.exitCode == 0) okEnvelope(JSONObject().put("src", src).put("dst", dst), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } @@ -880,7 +871,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler val cmd = mutableListOf("rm") if (args.hasFlag("recursive", "r")) cmd.add("-rf") else cmd.add("-f") cmd.add(path) - val r = ShizukuManager.runProcess(cmd.toTypedArray()) + val r = RootProcess.run(cmd.toTypedArray()) return if (r.exitCode == 0) okEnvelope(JSONObject().put("removed", path), args) else errEnvelope("OPERATION_FAILED", failMessage(r), args) } @@ -909,7 +900,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler } private fun deviceBattery(args: OffloadArgs): NativeOffloadResult { - val r = ShizukuManager.runProcess(arrayOf("dumpsys", "battery"), timeoutMs = 5_000) + val r = RootProcess.run(arrayOf("dumpsys", "battery"), timeoutMs = 5_000) if (r.exitCode != 0) return errEnvelope("OPERATION_FAILED", failMessage(r), args) val obj = JSONObject() for (line in r.stdout.lineSequence()) { @@ -926,7 +917,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler private fun deviceUsage(args: OffloadArgs): NativeOffloadResult { val pkg = args.get("package") val cmd = mutableListOf("dumpsys", "usagestats") - val r = ShizukuManager.runProcess(cmd.toTypedArray(), timeoutMs = 10_000) + val r = RootProcess.run(cmd.toTypedArray(), timeoutMs = 10_000) if (r.exitCode != 0) return errEnvelope("OPERATION_FAILED", failMessage(r), args) // Surface raw usagestats text — full structured parsing would dwarf this handler. val filtered = if (pkg != null) { @@ -945,7 +936,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler * surface, but LLM priors include a long tail of intuitive shell calls * (`pm list packages -f`, `cmd statusbar expand-notifications`, …) that * don't fit any group. `exec` joins remaining argv into a single shell - * command string and runs it under Shizuku via `sh -c` so metacharacters + * command string and runs it as root via `sh -c` so metacharacters * (`;`, `|`, `>`, backticks) keep their shell meaning. Caller is * responsible for quoting. */ @@ -953,7 +944,7 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler if (rest.isEmpty() || rest.firstOrNull() in listOf("help", "--help", "-h")) return ok(EXEC_HELP) val cmd = rest.joinToString(" ") val timeout = args.getInt("timeout-ms")?.toLong() ?: 30_000L - val r = ShizukuManager.runProcess(arrayOf("sh", "-c", cmd), timeoutMs = timeout) + val r = RootProcess.run(arrayOf("sh", "-c", cmd), timeoutMs = timeout) val data = JSONObject() .put("command", cmd) .put("exitCode", r.exitCode) @@ -1056,12 +1047,12 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler } /** - * T339: many shell commands invoked via Shizuku exit non-zero with empty + * T339: many privileged shell commands exit non-zero with empty * stdout/stderr (e.g. `settings get` for an unknown key, `pm clear` on a * non-existent package). Wrap [r] so callers never surface a blank message * — the synthesized fallback keeps the original command context in view. */ - private fun failMessage(r: ShizukuManager.ProcessResult, context: String): String = + private fun failMessage(r: RootProcess.Result, context: String): String = r.combined.ifBlank { "$context failed (exitCode=${r.exitCode})" } /** @@ -1070,10 +1061,10 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler * empty `OPERATION_FAILED` envelopes (the SIGTERM-143 + empty-output * combo was the surface symptom of the runProcess reflection bug). */ - private fun failMessage(r: ShizukuManager.ProcessResult): String = + private fun failMessage(r: RootProcess.Result): String = r.combined.ifBlank { when (r.exitCode) { - 124 -> "command timed out (exitCode=124); raise --timeout-ms or check Shizuku service health." + 124 -> "command timed out (exitCode=124); raise --timeout-ms or check that Root is still granted." 143 -> "command was killed (SIGTERM/exitCode=143); privileged process destroyed mid-flight." else -> "command failed (exitCode=${r.exitCode})" } @@ -1107,16 +1098,19 @@ class ShizukuOffloadHandler(private val context: Context) : NativeOffloadHandler */ internal fun encodeInputText(text: String): String = text.replace(" ", "%s") - private const val TAG = "ShizukuOffload" + private const val TAG = "RootCliOffload" - private const val HELP = """android-shizuku-cli — privileged Android system control via Shizuku. + /** Permission key in [com.openminis.app.offload.OffloadPermissionManager]. */ + const val TOOL_NAME = "root_cli" + + private const val HELP = """android-root-cli — privileged Android system control as root. Usage: - android-shizuku-cli [flags] - android-shizuku-cli exec ← fallback passthrough + android-root-cli [flags] + android-root-cli exec ← fallback passthrough Fallback: - exec Run an arbitrary shell command with Shizuku privilege. + exec Run an arbitrary shell command as root. Use this when no curated subcommand fits — `exec` accepts any `adb shell`-style invocation (pm, am, cmd, dumpsys, settings, wm, ime, appops, input, …) and returns @@ -1145,24 +1139,24 @@ Common flags: --user target user ID --help group / subcommand help -Run `android-shizuku-cli --help` for group-specific flags. +Run `android-root-cli --help` for group-specific flags. """ private const val EXEC_HELP = """exec — raw shell passthrough (T341). Usage: - android-shizuku-cli exec [--timeout-ms N] + android-root-cli exec [--timeout-ms N] -Runs the joined argv via `sh -c` under the Shizuku service uid (same -privilege as `adb shell`). Returns {ok, data:{command, exitCode, stdout, +Runs the joined argv via `sh -c` as root (uid 0; everything `adb shell` +can do, and more). Returns {ok, data:{command, exitCode, stdout, stderr, combined}}. On non-zero exit the stdout/stderr are still included in `data` so callers can diagnose without re-running. Examples: - android-shizuku-cli exec pm list packages -f - android-shizuku-cli exec dumpsys battery - android-shizuku-cli exec "settings get global airplane_mode_on" - android-shizuku-cli exec "logcat -d -t 200 | grep MyTag" + android-root-cli exec pm list packages -f + android-root-cli exec dumpsys battery + android-root-cli exec "settings get global airplane_mode_on" + android-root-cli exec "logcat -d -t 200 | grep MyTag" Notes: - Caller owns quoting/escaping. Metacharacters (; | > backticks) work. @@ -1170,52 +1164,52 @@ Notes: - Prefer curated subcommands when they exist — they emit structured JSON. """ - private const val SERVICE_HELP = """service — Shizuku runtime status. + private const val SERVICE_HELP = """service — Root status. Usage: - android-shizuku-cli service status State + version + uid - android-shizuku-cli service ping Quick connection check + android-root-cli service status What the app last learned about su + android-root-cli service ping Ask su now; OK when it runs as uid 0 """ private const val PACKAGE_HELP = """package — installed-app management. Usage: - android-shizuku-cli package list [--system|-3|--disabled] [--filter X] - android-shizuku-cli package info - android-shizuku-cli package install [--grant-permissions] [--downgrade] - android-shizuku-cli package uninstall [--keep-data] - android-shizuku-cli package enable - android-shizuku-cli package disable - android-shizuku-cli package clear [--cache-only] - android-shizuku-cli package path + android-root-cli package list [--system|-3|--disabled] [--filter X] + android-root-cli package info + android-root-cli package install [--grant-permissions] [--downgrade] + android-root-cli package uninstall [--keep-data] + android-root-cli package enable + android-root-cli package disable + android-root-cli package clear [--cache-only] + android-root-cli package path """ private const val PERMISSION_HELP = """permission — runtime + AppOps permissions. Usage: - android-shizuku-cli permission list [--granted|--denied|--dangerous] - android-shizuku-cli permission grant - android-shizuku-cli permission revoke - android-shizuku-cli permission appops + android-root-cli permission list [--granted|--denied|--dangerous] + android-root-cli permission grant + android-root-cli permission revoke + android-root-cli permission appops """ private const val ACTIVITY_HELP = """activity — Activity / process management. Usage: - android-shizuku-cli activity start [-p pkg] [-c component] [-a action] [-d uri] + android-root-cli activity start [-p pkg] [-c component] [-a action] [-d uri] [--extra-string k=v;k=v] [--extra-int k=v] - android-shizuku-cli activity force-stop - android-shizuku-cli activity kill - android-shizuku-cli activity broadcast [--package pkg] - android-shizuku-cli activity top + android-root-cli activity force-stop + android-root-cli activity kill + android-root-cli activity broadcast [--package pkg] + android-root-cli activity top """ private const val DISPLAY_HELP = """display — display & resolution. Usage: - android-shizuku-cli display list - android-shizuku-cli display set [--width N] [--height N] [--density DPI] - android-shizuku-cli display reset + android-root-cli display list + android-root-cli display set [--width N] [--height N] [--density DPI] + android-root-cli display reset display list reports the physical displays plus the logical (`wm size`) resolution that input and screencap use; logicalWidth/logicalHeight are -1 when unreadable. @@ -1224,65 +1218,65 @@ that input and screencap use; logicalWidth/logicalHeight are -1 when unreadable. private const val SETTINGS_HELP = """settings — system Settings DB. Usage: - android-shizuku-cli settings get - android-shizuku-cli settings set - android-shizuku-cli settings delete - android-shizuku-cli settings list [--filter X] + android-root-cli settings get + android-root-cli settings set + android-root-cli settings delete + android-root-cli settings list [--filter X] """ private const val USER_HELP = """user — multi-user management. Usage: - android-shizuku-cli user list - android-shizuku-cli user create [--managed-profile|--guest] - android-shizuku-cli user remove - android-shizuku-cli user switch - android-shizuku-cli user start - android-shizuku-cli user stop + android-root-cli user list + android-root-cli user create [--managed-profile|--guest] + android-root-cli user remove + android-root-cli user switch + android-root-cli user start + android-root-cli user stop """ private const val NETWORK_HELP = """network — net policy & stats. Usage: - android-shizuku-cli network restrict [--background|--data|--all] - android-shizuku-cli network allow [--background|--data|--all] - android-shizuku-cli network stats + android-root-cli network restrict [--background|--data|--all] + android-root-cli network allow [--background|--data|--all] + android-root-cli network stats """ private const val INPUT_HELP = """input — input simulation. Usage: - android-shizuku-cli input tap - android-shizuku-cli input swipe [--duration MS] - android-shizuku-cli input key [--long-press] - android-shizuku-cli input text + android-root-cli input tap + android-root-cli input swipe [--duration MS] + android-root-cli input key [--long-press] + android-root-cli input text """ private const val NOTIFICATION_HELP = """notification — system notifications. Usage: - android-shizuku-cli notification list [--package pkg] - android-shizuku-cli notification dismiss [--all|--package pkg|--id N] - android-shizuku-cli notification channel list - android-shizuku-cli notification channel set + android-root-cli notification list [--package pkg] + android-root-cli notification dismiss [--all|--package pkg|--id N] + android-root-cli notification channel list + android-root-cli notification channel set [--block|--unblock|--importance N] """ private const val FILE_HELP = """file — privileged file access. Usage: - android-shizuku-cli file ls [-l] [-r] - android-shizuku-cli file pull - android-shizuku-cli file push - android-shizuku-cli file rm [-r] + android-root-cli file ls [-l] [-r] + android-root-cli file pull + android-root-cli file push + android-root-cli file rm [-r] """ private const val DEVICE_HELP = """device — device state. Usage: - android-shizuku-cli device info - android-shizuku-cli device battery - android-shizuku-cli device usage [--package pkg] [--top N] + android-root-cli device info + android-root-cli device battery + android-root-cli device usage [--package pkg] [--top N] """ } } diff --git a/src/android/app/src/main/java/com/openminis/app/tools/android/AndroidAgentTools.kt b/src/android/app/src/main/java/com/openminis/app/tools/android/AndroidAgentTools.kt index 54adf1c9..6ffb3e37 100644 --- a/src/android/app/src/main/java/com/openminis/app/tools/android/AndroidAgentTools.kt +++ b/src/android/app/src/main/java/com/openminis/app/tools/android/AndroidAgentTools.kt @@ -39,7 +39,7 @@ object AndroidAgentTools { ), AgentToolDefinition( name = APP, - description = "Inspect and control an Android package with PackageManager/ActivityManager first and authorized Root or Shizuku only where shell privilege is required. " + + description = "Inspect and control an Android package with PackageManager/ActivityManager first and authorized Root only where shell privilege is required. " + "Actions: search, info, launch, stop, restart, freeze, unfreeze, install, uninstall. search resolves a display name to a real package among the launcher apps this app can see. freeze/unfreeze are pm disable-user/pm enable: the package keeps its data but cannot run until it is enabled again, and an unlucky target can be a system app, so they require one-time approval. Android 11 package visibility is reported honestly. Install/uninstall require one-time approval and never assume QUERY_ALL_PACKAGES.", parameters = commonParams() + packageParams() + artifactParams() + mapOf( "action" to AgentToolParam( @@ -61,7 +61,7 @@ object AndroidAgentTools { ), AgentToolDefinition( name = UI, - description = "Observe and operate Android UI through the existing MinisAccessibilityService on displayId=0, or the Shizuku VScreen UiAutomation backend on the exact active non-zero displayId; unknown ids fail closed and never fall back to the physical screen. No second Accessibility implementation is installed in the app process. " + + description = "Observe and operate Android UI through the existing MinisAccessibilityService on displayId=0, or the root VScreen UiAutomation backend on the exact active non-zero displayId; unknown ids fail closed and never fall back to the physical screen. No second Accessibility implementation is installed in the app process. " + "Prefer observe (compact interactive nodes) then actions by generation+ref. Refs are bound to a UI fingerprint and return STALE_UI_REF after a screen change; the tool never guesses old coordinates. " + "Every action reports evidence plus its evidenceSource instead of a bare boolean: accepted-with-effect, accepted-without-evidence, direction-mismatch, timed-out and rejected are different outcomes, and a truncated snapshot refuses ref actions. " + "Coordinates are screenshot-space by default on the physical screen: x/y read off the returned screenshot image are converted through that capture's scale, and an action in that space is refused rather than misclicked when there is no capture or the screen changed; send coordinateSpace=screen for real device pixels. On a virtual display (displayId != 0) the default is display pixels (coordinateSpace=screen) and its size comes with open, observe and screenshot, so no screenshot is needed before a coordinate click. " + @@ -148,7 +148,7 @@ object AndroidAgentTools { AgentToolDefinition( name = LOGS, description = "Cursor-based, token-bounded Android logcat. Mark a cursor immediately before a UI action, then read since that cursor so logs are attributed to the real action rather than a guessed time window. " + - "Actions: mark_cursor, snapshot, watch, read, stop, clear. watch reuses JobRegistry; large raw output reuses SpillPolicy and returns a /var/minis/offloads pointer instead of flooding context. Full-device logs require authorized Root/Shizuku; ordinary mode is explicitly PARTIAL.", + "Actions: mark_cursor, snapshot, watch, read, stop, clear. watch reuses JobRegistry; large raw output reuses SpillPolicy and returns a /var/minis/offloads pointer instead of flooding context. Full-device logs require authorized Root; ordinary mode is explicitly PARTIAL.", parameters = commonParams() + packageParams() + mapOf( "action" to AgentToolParam("string", "Log action", listOf("mark_cursor", "snapshot", "watch", "read", "stop", "clear")), "cursor" to AgentToolParam("string", "Cursor id from mark_cursor"), diff --git a/src/android/app/src/main/java/com/openminis/app/tools/android/AndroidCapabilityResolver.kt b/src/android/app/src/main/java/com/openminis/app/tools/android/AndroidCapabilityResolver.kt index e1205136..a6b3fadd 100644 --- a/src/android/app/src/main/java/com/openminis/app/tools/android/AndroidCapabilityResolver.kt +++ b/src/android/app/src/main/java/com/openminis/app/tools/android/AndroidCapabilityResolver.kt @@ -8,7 +8,6 @@ import android.os.Build import android.os.StatFs import com.openminis.app.accessibility.MinisAccessibilityService import com.openminis.app.offload.OffloadPermissionManager -import com.openminis.app.offload.ShizukuManager import com.openminis.app.runtime.ubuntu.UbuntuRuntime import org.json.JSONArray import org.json.JSONObject @@ -37,7 +36,6 @@ object AndroidCapabilityResolver { val root = rootSnapshot.probe val suPath = rootSnapshot.suPath val rootState = rootSnapshot.state - val shizuku = ShizukuManager.snapshot.value val service = MinisAccessibilityService.getInstance() val serviceInfo = service?.serviceInfo val rootStatus = when (rootState) { @@ -48,13 +46,7 @@ object AndroidCapabilityResolver { RootAccessState.AUTHORIZATION_FAILED -> CapabilityStatus.REQUIRES_USER_GRANT } - val shizukuStatus = when (shizuku.state) { - ShizukuManager.State.READY -> CapabilityStatus.AVAILABLE - ShizukuManager.State.NEED_PERMISSION -> CapabilityStatus.REQUIRES_USER_GRANT - ShizukuManager.State.NOT_RUNNING -> CapabilityStatus.REQUIRES_USER_GRANT - ShizukuManager.State.NOT_INSTALLED -> CapabilityStatus.UNAVAILABLE - } - val privilegedAvailable = root?.authorized == true || shizuku.state == ShizukuManager.State.READY + val privilegedAvailable = root?.authorized == true return JSONObject().apply { put("root", JSONObject().apply { @@ -95,11 +87,6 @@ object AndroidCapabilityResolver { "active su probe failed: ${root?.error ?: "authorization was not established"}" RootAccessState.SU_NOT_FOUND -> "no executable su was passively detected" }, "root").toJson()) - put("shizuku", CapabilityFact( - shizukuStatus, - "state=${shizuku.state}, uid=${shizuku.uid}, provider=${if (shizuku.isSui) "sui" else "shizuku-protocol"}", - "shizuku", - ).toJson()) }) put("ui", JSONObject().apply { put("accessibilityConnected", fact( @@ -132,7 +119,7 @@ object AndroidCapabilityResolver { put("logcat", CapabilityFact( if (privilegedAvailable) CapabilityStatus.AVAILABLE else CapabilityStatus.PARTIAL, if (privilegedAvailable) "full-device logcat through a privileged shell" else "normal app can only read its own log stream", - if (root?.authorized == true) "root" else if (shizuku.state == ShizukuManager.State.READY) "shizuku" else "app", + if (privilegedAvailable) "root" else "app", ).toJson()) put("dumpsys", CapabilityFact( if (privilegedAvailable) CapabilityStatus.AVAILABLE else CapabilityStatus.PARTIAL, diff --git a/src/android/app/src/main/java/com/openminis/app/tools/android/AndroidPackageController.kt b/src/android/app/src/main/java/com/openminis/app/tools/android/AndroidPackageController.kt index 6b9e6bb7..26346b26 100644 --- a/src/android/app/src/main/java/com/openminis/app/tools/android/AndroidPackageController.kt +++ b/src/android/app/src/main/java/com/openminis/app/tools/android/AndroidPackageController.kt @@ -68,7 +68,7 @@ object AndroidPackageController { .put( "scope", "Android 11+ package visibility: only launcher activities visible to this app " + - "are listed; other packages need an authorized Root/Shizuku pm query", + "are listed; other packages need an authorized Root pm query", ) } diff --git a/src/android/app/src/main/java/com/openminis/app/tools/android/PrivilegedCommandRunner.kt b/src/android/app/src/main/java/com/openminis/app/tools/android/PrivilegedCommandRunner.kt index 861acfa9..9a59b325 100644 --- a/src/android/app/src/main/java/com/openminis/app/tools/android/PrivilegedCommandRunner.kt +++ b/src/android/app/src/main/java/com/openminis/app/tools/android/PrivilegedCommandRunner.kt @@ -11,7 +11,7 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean /** Backend selected for one privileged Android command. */ -enum class PrivilegedBackend { ROOT, SHIZUKU, NONE } +enum class PrivilegedBackend { ROOT, NONE } /** Risk classification retained for audit logging and conservative command analysis. */ enum class CommandRisk(val severity: Int) { diff --git a/src/android/app/src/main/java/com/openminis/app/tools/android/ShellActionOutcomePolicy.kt b/src/android/app/src/main/java/com/openminis/app/tools/android/ShellActionOutcomePolicy.kt index 15c5ae67..5e6b6802 100644 --- a/src/android/app/src/main/java/com/openminis/app/tools/android/ShellActionOutcomePolicy.kt +++ b/src/android/app/src/main/java/com/openminis/app/tools/android/ShellActionOutcomePolicy.kt @@ -14,6 +14,6 @@ object ShellActionOutcomePolicy { else -> Outcome.FAILED } - /** Minis' privileged runner reports its own deadline as 124 (see ShizukuOffloadHandler T343). */ + /** Minis' privileged runner reports its own deadline as 124 (see RootCliOffloadHandler T343). */ const val PROCESS_TIMEOUT_EXIT_CODE = 124 } diff --git a/src/android/app/src/main/java/com/openminis/app/tools/android/VirtualScreenUiBackend.kt b/src/android/app/src/main/java/com/openminis/app/tools/android/VirtualScreenUiBackend.kt index c7743db9..0b6e7433 100644 --- a/src/android/app/src/main/java/com/openminis/app/tools/android/VirtualScreenUiBackend.kt +++ b/src/android/app/src/main/java/com/openminis/app/tools/android/VirtualScreenUiBackend.kt @@ -15,7 +15,7 @@ import java.io.ByteArrayOutputStream import kotlin.math.abs import kotlin.math.roundToInt -/** Display-targeted Android UI operations over the Shizuku virtual-display service. */ +/** Display-targeted Android UI operations over the root virtual-display service. */ internal object VirtualScreenUiBackend { suspend fun execute( sessionId: String, diff --git a/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/VirtualScreenClient.kt b/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/VirtualScreenClient.kt index 9730b60b..1f5110c3 100644 --- a/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/VirtualScreenClient.kt +++ b/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/VirtualScreenClient.kt @@ -2,13 +2,16 @@ package com.openminis.app.tools.android.vscreen import android.content.ComponentName import android.content.Context +import android.content.Intent import android.content.ServiceConnection import android.os.Build +import android.os.Handler import android.os.IBinder +import android.os.Looper import android.os.ParcelFileDescriptor -import com.openminis.app.BuildConfig -import com.openminis.app.offload.ShizukuManager -import com.openminis.app.tools.android.vscreen.service.VirtualScreenUserService +import com.openminis.app.offload.RootProcess +import com.openminis.app.tools.android.vscreen.service.VirtualScreenRootService +import com.topjohnwu.superuser.ipc.RootService import java.util.concurrent.ArrayBlockingQueue import java.util.concurrent.Callable import java.util.concurrent.CountDownLatch @@ -19,7 +22,6 @@ import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException import java.util.concurrent.atomic.AtomicInteger -import rikka.shizuku.Shizuku class VirtualScreenClient(context: Context) : AutoCloseable { private val appContext = context.applicationContext @@ -42,13 +44,14 @@ class VirtualScreenClient(context: Context) : AutoCloseable { private var closed = false private var frameSink: IVirtualScreenFrameSink? = null - private val args = Shizuku.UserServiceArgs( - ComponentName(appContext, VirtualScreenUserService::class.java), - ).daemon(true) - .processNameSuffix("vscreen") - .tag("minis-vscreen") - .version(USER_SERVICE_VERSION) - .debuggable(BuildConfig.DEBUG) + /** + * The root service, in libsu daemon mode: its process, and the virtual display it holds, outlive + * this app process; libsu ends it when the app is updated or uninstalled. + */ + private val serviceIntent = Intent(appContext, VirtualScreenRootService::class.java) + .addCategory(RootService.CATEGORY_DAEMON_MODE) + private val mainHandler = Handler(Looper.getMainLooper()) + @Volatile private var bindError: Throwable? = null private val connection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName, binder: IBinder) { @@ -104,9 +107,9 @@ class VirtualScreenClient(context: Context) : AutoCloseable { fun runProbe(): String { val result = try { execute(DEFAULT_TIMEOUT_MS) { - ShizukuManager.refresh() - if (!ShizukuManager.isReady()) { - unavailableProbe("shizuku_not_ready", "Shizuku is not running and authorized") + val rootProblem = RootProcess.unavailableReason() + if (rootProblem != null) { + unavailableProbe("root_not_ready", rootProblem) } else { val binder = requireRemote(DEFAULT_TIMEOUT_MS) mergeRemoteProbe(binder.probe()) @@ -243,7 +246,13 @@ class VirtualScreenClient(context: Context) : AutoCloseable { connectionLatch.countDown() wasBinding } - if (shouldUnbind) runCatching { Shizuku.unbindUserService(args, connection, remove) } + if (shouldUnbind) { + onMainThread { + runCatching { RootService.unbind(connection) } + // Unbinding leaves a daemon running; only stop() ends it and releases its display. + if (remove) runCatching { RootService.stop(serviceIntent) } + } + } } override fun close() { @@ -262,40 +271,51 @@ class VirtualScreenClient(context: Context) : AutoCloseable { } if (lost) { scheduleReconnectOnce() - throw VirtualScreenClientException(VirtualScreenPolicy.DISPLAY_GONE, "The UserService died; its virtual display was released") + throw VirtualScreenClientException(VirtualScreenPolicy.DISPLAY_GONE, "The root virtual-screen service died; its virtual display was released") } var shouldBind = false val latch = synchronized(stateLock) { remote?.let { return it } if (closed) throw VirtualScreenClientException("vscreen_client_closed", "VScreen client is closed") - if (!ShizukuManager.isReady()) throw VirtualScreenClientException("shizuku_not_ready", "Shizuku is not running and authorized") + RootProcess.unavailableReason()?.let { throw VirtualScreenClientException("root_not_ready", it) } if (!binding) { binding = true + bindError = null connectionLatch = CountDownLatch(1) shouldBind = true } connectionLatch } if (shouldBind) { - try { - Shizuku.bindUserService(args, connection) - } catch (error: Throwable) { - synchronized(stateLock) { - binding = false - connectionLatch.countDown() + // libsu binds only on the main thread; the connection callbacks run inline (they only update + // state under the lock), so they never wait for this worker thread. + onMainThread { + try { + RootService.bind(serviceIntent, { it.run() }, connection) + } catch (error: Throwable) { + synchronized(stateLock) { + binding = false + bindError = error + connectionLatch.countDown() + } } - throw VirtualScreenClientException("vscreen_bind_failed", error.message ?: error.javaClass.simpleName, error) } } if (!latch.await(timeoutMs, TimeUnit.MILLISECONDS)) { - throw VirtualScreenClientException("vscreen_timeout", "Timed out waiting for Shizuku UserService") + throw VirtualScreenClientException("vscreen_timeout", "Timed out waiting for the root virtual-screen service") } return synchronized(stateLock) { - remote ?: throw VirtualScreenClientException("vscreen_service_disconnected", "UserService did not connect") + remote ?: bindError?.let { + throw VirtualScreenClientException("vscreen_bind_failed", it.message ?: it.javaClass.simpleName, it) + } ?: throw VirtualScreenClientException("vscreen_service_disconnected", "The root virtual-screen service did not connect") } } + private fun onMainThread(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block) + } + private fun onServiceLost() { synchronized(stateLock) { if (activeDisplayId != null) displayLostPending = true @@ -351,14 +371,14 @@ class VirtualScreenClient(context: Context) : AutoCloseable { val steps = listOf( VirtualScreenProbeStep("environment", if (sdk >= 29) "pass" else "fail", if (sdk >= 29) "android_supported" else "android_version_unsupported", "API $sdk; ${Build.MANUFACTURER} ${Build.MODEL}"), - VirtualScreenProbeStep("shizuku", "fail", code, detail.take(512)), - VirtualScreenProbeStep("context", "skipped", "shizuku_unavailable", "UserService was not started"), - VirtualScreenProbeStep("virtual_display", "skipped", "shizuku_unavailable", "UserService was not started"), - VirtualScreenProbeStep("ime", "skipped", "shizuku_unavailable", "UserService was not started"), - VirtualScreenProbeStep("uiautomation", "skipped", "shizuku_unavailable", "UserService was not started"), - VirtualScreenProbeStep("input", "skipped", "shizuku_unavailable", "UserService was not started"), - VirtualScreenProbeStep("launch", "skipped", "shizuku_unavailable", "UserService was not started"), - VirtualScreenProbeStep("screenshot", "skipped", "shizuku_unavailable", "UserService was not started"), + VirtualScreenProbeStep("root", "fail", code, detail.take(512)), + VirtualScreenProbeStep("context", "skipped", code, SERVICE_NOT_STARTED), + VirtualScreenProbeStep("virtual_display", "skipped", code, SERVICE_NOT_STARTED), + VirtualScreenProbeStep("ime", "skipped", code, SERVICE_NOT_STARTED), + VirtualScreenProbeStep("uiautomation", "skipped", code, SERVICE_NOT_STARTED), + VirtualScreenProbeStep("input", "skipped", code, SERVICE_NOT_STARTED), + VirtualScreenProbeStep("launch", "skipped", code, SERVICE_NOT_STARTED), + VirtualScreenProbeStep("screenshot", "skipped", code, SERVICE_NOT_STARTED), ) return VirtualScreenProbeReport(System.currentTimeMillis(), fingerprint, steps) } @@ -403,9 +423,7 @@ class VirtualScreenClient(context: Context) : AutoCloseable { ) : IllegalStateException(message, cause) companion object { - // Bumped whenever the UserService code or its AIDL changes: a daemon service outlives the app, so - // Shizuku only replaces it when this number differs. 2 = frame stream, raw touch, display info; 3 = Home starts Minis's own desktop; 4 = UiAutomation keeps other accessibility services, launches never move a task; 5 = focus is handed back to the physical screen (and again a moment later); 7 = UiAutomation really connects with DONT_SUPPRESS; 8 = act() replaces the index-based target calls. - private const val USER_SERVICE_VERSION = 8 + private const val SERVICE_NOT_STARTED = "The root virtual-screen service was not started" private const val DEFAULT_TIMEOUT_MS = 8_000L private const val ACT_TIMEOUT_MS = 12_000L private const val SCREENSHOT_TIMEOUT_MS = 15_000L diff --git a/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/VirtualScreenPreferences.kt b/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/VirtualScreenPreferences.kt index 7d2962c1..8b791ec6 100644 --- a/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/VirtualScreenPreferences.kt +++ b/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/VirtualScreenPreferences.kt @@ -89,11 +89,19 @@ internal class VirtualScreenPreferences(context: Context) { private const val KEY_HEIGHT = "display_height" private const val KEY_DPI = "display_dpi" + /** + * Which privileged host ran the probe. A probe passed under an earlier host (the Shizuku + * UserService, before 2026-10) says nothing about the current one, so a new host makes every + * saved probe stale and the feature waits for a fresh pass. + */ + internal const val SERVICE_HOST = "libsu-root-1" + fun currentDeviceFingerprint(): String = listOf( Build.FINGERPRINT.orEmpty(), Build.VERSION.SDK_INT.toString(), Build.MANUFACTURER.orEmpty(), Build.DEVICE.orEmpty(), + SERVICE_HOST, ).joinToString("|") } } diff --git a/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/VirtualScreenRootService.kt b/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/VirtualScreenRootService.kt new file mode 100644 index 00000000..6b6d3cf4 --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/VirtualScreenRootService.kt @@ -0,0 +1,27 @@ +package com.openminis.app.tools.android.vscreen.service + +import android.content.Intent +import android.os.IBinder +import com.topjohnwu.superuser.ipc.RootService + +/** + * Host of [VirtualScreenUserService] in a root process. + * + * libsu starts the process with `app_process` through su and hands this service's binder back to the + * app ([com.openminis.app.tools.android.vscreen.VirtualScreenClient] binds with + * [RootService.CATEGORY_DAEMON_MODE]). In daemon mode the process, and the virtual display it holds, + * outlives the app process; libsu ends it when the app is updated or uninstalled, so a new build never + * talks to a service built from older code. + */ +class VirtualScreenRootService : RootService() { + /** One service per process, created on the first bind; daemon mode reuses it across app launches. */ + private var service: VirtualScreenUserService? = null + + override fun onBind(intent: Intent): IBinder = + service ?: VirtualScreenUserService().also { service = it } + + override fun onDestroy() { + service?.shutdown() + super.onDestroy() + } +} diff --git a/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/VirtualScreenUserService.kt b/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/VirtualScreenUserService.kt index 9098dde4..33a3e236 100644 --- a/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/VirtualScreenUserService.kt +++ b/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/VirtualScreenUserService.kt @@ -31,16 +31,18 @@ import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.min import kotlin.math.sqrt -/** Shizuku-protocol UserService (shell or root). It intentionally has no network or local-socket listener. */ +/** + * The virtual-screen service. It runs as root inside the process libsu starts for + * [com.openminis.app.tools.android.vscreen.service.VirtualScreenRootService], and is reached only + * through that service's binder: it has no network or local-socket listener. + */ @Keep class VirtualScreenUserService : IVirtualScreenService.Stub() { private val lock = Any() - // The Shizuku-protocol server decides the uid: shell (adb-started Shizuku) or root (a root-started - // Shizuku, or Sui). Both can create a virtual display and drive it; anything else is not a - // Shizuku-protocol service and is refused. (Binder identity is per process, so a root service - // cannot be narrowed to shell from the inside.) + // libsu starts this process through su, so it must be root. Anything else means su handed out a + // different identity, and the service refuses to act rather than half-work. private val identityError: String? = - if (Process.myUid() == Process.SHELL_UID || Process.myUid() == 0) null else "unexpected_user_service_uid" + if (Process.myUid() == 0) null else "unexpected_user_service_uid" private val contextError: String? = if (identityError == null) ShellContext.initialize() else identityError private var session: VirtualDisplaySession? = null private var uiBridge: UiBridge? = null @@ -58,9 +60,9 @@ class VirtualScreenUserService : IVirtualScreenService.Stub() { } "Android ${Build.VERSION.RELEASE} API $sdk; ${Build.MANUFACTURER} ${Build.MODEL}; supported range Android 10+" } - recorder.check("shizuku", "shizuku_shell_user_service") { - if (identityError != null) throw VirtualScreenProbeFailure(identityError, "VScreen requires a Shizuku-protocol shell (2000) or root (0) service; actual UID=${Process.myUid()}") - "UserService active as ${if (Process.myUid() == 0) "root" else "shell"} UID ${Process.myUid()} (app-side READY state was checked before binding)" + recorder.check("root", "root_service_ready") { + if (identityError != null) throw VirtualScreenProbeFailure(identityError, "VScreen requires a root (0) service; actual UID=${Process.myUid()}") + "Root service active as UID ${Process.myUid()}" } recorder.check("context", "shell_context_ready") { if (contextError != null) throw VirtualScreenProbeFailure("shell_context_unavailable", contextError) @@ -70,7 +72,7 @@ class VirtualScreenUserService : IVirtualScreenService.Stub() { val hadDisplay = session != null var probeSession: VirtualDisplaySession? = null if (identityError != null) { - recorder.skipped("virtual_display", identityError, "Non-shell service identity is not permitted") + recorder.skipped("virtual_display", identityError, "Only a root service identity is permitted") } else if (hadDisplay) { recorder.record("virtual_display", "fail", "probe_display_busy", "Close the current VScreen before running a disruptive probe") } else { @@ -87,7 +89,7 @@ class VirtualScreenUserService : IVirtualScreenService.Stub() { val active = probeSession if (active == null) { val code = if (identityError != null) identityError else "display_unavailable" - val detail = if (identityError != null) "Non-shell service identity is not permitted" else "Virtual display could not be created" + val detail = if (identityError != null) "Only a root service identity is permitted" else "Virtual display could not be created" recorder.skipped("ime", code, detail) recorder.skipped("uiautomation", code, detail) recorder.skipped("input", code, detail) @@ -101,7 +103,7 @@ class VirtualScreenUserService : IVirtualScreenService.Stub() { uiBridge = ui recorder.check("uiautomation", "uiautomation_connected") { if (!ui.connect()) throw VirtualScreenProbeFailure("uiautomation_unavailable", "UiAutomation connect returned false") - "UiAutomation connected in Shizuku UserService" + "UiAutomation connected in the root service" } recorder.check("launch", "settings_window_on_virtual_display") { if (!ui.connect()) throw VirtualScreenProbeFailure("uiautomation_unavailable", "UiAutomation is not connected") @@ -345,15 +347,13 @@ class VirtualScreenUserService : IVirtualScreenService.Stub() { readEnd } - /** Reserved Shizuku UserService transaction. Cleanup precedes process exit. */ - @Keep - override fun destroy() { + /** + * Called by [VirtualScreenRootService.onDestroy] when the root service is stopped. It releases the + * virtual display; libsu ends the process once no service is left in it. + */ + fun shutdown() { if (!destroyed.compareAndSet(false, true)) return - try { - synchronized(lock) { releaseSession() } - } finally { - System.exit(0) - } + synchronized(lock) { releaseSession() } } private fun ensureUi(): UiBridge { @@ -419,7 +419,7 @@ class VirtualScreenUserService : IVirtualScreenService.Stub() { } private fun requireShellIdentity() { - if (identityError != null) fail(identityError, "VScreen requires a Shizuku-protocol shell (2000) or root (0) service; actual UID=${Process.myUid()}") + if (identityError != null) fail(identityError, "VScreen requires a root (0) service; actual UID=${Process.myUid()}") } private fun releaseSession() { @@ -434,7 +434,7 @@ class VirtualScreenUserService : IVirtualScreenService.Stub() { } private fun checkNotDestroyed() { - if (destroyed.get()) fail("vscreen_service_destroyed", "UserService is shutting down") + if (destroyed.get()) fail("vscreen_service_destroyed", "The virtual-screen service is shutting down") } private fun probeDisplaySpec(): DisplaySpec { diff --git a/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/internal/ShellContext.kt b/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/internal/ShellContext.kt index 24f4454c..095cb645 100644 --- a/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/internal/ShellContext.kt +++ b/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/internal/ShellContext.kt @@ -50,7 +50,7 @@ internal class ShellContext private constructor(base: Context) : ContextWrapper( fun identityPackage(uid: Int = Process.myUid()): String = if (uid == 0) ROOT_PACKAGE_NAME else SHELL_PACKAGE_NAME @Volatile private var instance: ShellContext? = null - /** Runs only inside Shizuku's separate shell UserService process, which its API docs say has no non-SDK restrictions. */ + /** Runs only inside the separate root process libsu starts with app_process, where non-SDK restrictions do not apply. */ @SuppressLint("SoonBlockedPrivateApi") @Synchronized fun initialize(): String? = runCatching { diff --git a/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/internal/UiBridge.kt b/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/internal/UiBridge.kt index 7b0a3050..d749e6e9 100644 --- a/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/internal/UiBridge.kt +++ b/src/android/app/src/main/java/com/openminis/app/tools/android/vscreen/service/internal/UiBridge.kt @@ -30,7 +30,7 @@ internal class UiBridge(private val settle: SettleTracker) { @Volatile private var windowDisplay: Map = emptyMap() @Volatile private var lastWindowRefreshAt = 0L - /** This reflection executes only in Shizuku's separate shell UserService process. */ + /** This reflection executes only in the separate root virtual-screen service process. */ @SuppressLint("SoonBlockedPrivateApi") @Synchronized fun connect(): Boolean { @@ -264,7 +264,7 @@ internal class UiBridge(private val settle: SettleTracker) { return false } - /** This reflection executes only in Shizuku's separate shell UserService process. */ + /** This reflection executes only in the separate root virtual-screen service process. */ @SuppressLint("SoonBlockedPrivateApi") @Synchronized fun disconnect() { diff --git a/src/android/app/src/main/java/com/openminis/app/ui/navigation/AppNavigation.kt b/src/android/app/src/main/java/com/openminis/app/ui/navigation/AppNavigation.kt index f874b948..51931846 100644 --- a/src/android/app/src/main/java/com/openminis/app/ui/navigation/AppNavigation.kt +++ b/src/android/app/src/main/java/com/openminis/app/ui/navigation/AppNavigation.kt @@ -75,7 +75,7 @@ import com.openminis.app.ui.settings.LogManagementScreen import com.openminis.app.ui.settings.MemoryFileEditScreen import com.openminis.app.ui.settings.MemoryManagementScreen import com.openminis.app.ui.settings.OffloadPermissionScreen -import com.openminis.app.ui.settings.ShizukuPermissionScreen +import com.openminis.app.ui.settings.RootAccessScreen import com.openminis.app.sandbox.TerminalSession import com.openminis.app.ui.terminal.TerminalScreen import com.openminis.app.ui.onboarding.OnboardingModelSelectionScreen @@ -529,10 +529,10 @@ fun AppNavigation( onAppearanceClick = { navController.safeNavigate(Routes.APPEARANCE) }, onSystemEnhanceClick = { navController.safeNavigate(Routes.SYSTEM_ENHANCE) }, onPermissionsClick = { navController.safeNavigate(Routes.SYSTEM_PERMISSIONS) }, - // [T-android-settings-hierarchy] The tool-permission and Shizuku screens - // were reachable only through each other; they belong to the same category. + // [T-android-settings-hierarchy] The tool-permission and Root screens + // belong to the same category. onToolPermissionsClick = { navController.safeNavigate(Routes.PERMISSIONS) }, - onShizukuClick = { navController.safeNavigate(Routes.SHIZUKU) }, + onRootClick = { navController.safeNavigate(Routes.ROOT) }, onVirtualScreenClick = { navController.safeNavigate(Routes.VIRTUAL_SCREEN_SETTINGS) }, onBackgroundClick = { navController.safeNavigate(Routes.BACKGROUND) }, onLogsClick = { navController.safeNavigate(Routes.LOGS) }, @@ -1236,18 +1236,13 @@ fun AppNavigation( composable(Routes.PERMISSIONS) { OffloadPermissionScreen( onBack = { navController.safePopBackStack() }, - // [T-android-privileged-backend] Single Shizuku-protocol screen - // handles both Shizuku and AXManager managers; the old - // multi-backend screen was retired in favour of a single - // surface (the two managers share one binder slot, so a - // multi-backend abstraction was misleading). - onOpenPrivilegedBackend = { navController.safeNavigate(Routes.SHIZUKU) }, + onOpenRoot = { navController.safeNavigate(Routes.ROOT) }, onOpenSystemPermissions = { navController.safeNavigate(Routes.SYSTEM_PERMISSIONS) }, ) } - composable(Routes.SHIZUKU) { - ShizukuPermissionScreen( + composable(Routes.ROOT) { + RootAccessScreen( onBack = { navController.safePopBackStack() }, ) } @@ -1255,7 +1250,7 @@ fun AppNavigation( composable(Routes.VIRTUAL_SCREEN_SETTINGS) { VirtualScreenSettingsScreen( onBack = { navController.safePopBackStack() }, - onOpenShizuku = { navController.safeNavigate(Routes.SHIZUKU) }, + onOpenRoot = { navController.safeNavigate(Routes.ROOT) }, ) } diff --git a/src/android/app/src/main/java/com/openminis/app/ui/navigation/AppNavigationRoutes.kt b/src/android/app/src/main/java/com/openminis/app/ui/navigation/AppNavigationRoutes.kt index 78e53743..7d68f610 100644 --- a/src/android/app/src/main/java/com/openminis/app/ui/navigation/AppNavigationRoutes.kt +++ b/src/android/app/src/main/java/com/openminis/app/ui/navigation/AppNavigationRoutes.kt @@ -107,12 +107,8 @@ object Routes { const val SYSTEM_PROMPT_MODULES = "system_prompt_modules" const val MEMORY_FILE_EDIT = "memory_file/{fileName}/{isGlobal}" const val PERMISSIONS = "permissions" - /** - * T322 / [T-android-privileged-backend]: Shizuku-protocol manager - * walkthrough — handles both Shizuku and AXManager (they share the same - * binder protocol + client SDK). - */ - const val SHIZUKU = "shizuku" + /** Root status, what Root is used for, and the android-root-cli surface. */ + const val ROOT = "root" const val VIRTUAL_SCREEN_SETTINGS = "virtual_screen_settings" /** T323: System Permissions (Accessibility service status, etc.). */ const val SYSTEM_PERMISSIONS = "system_permissions" diff --git a/src/android/app/src/main/java/com/openminis/app/ui/settings/OffloadPermissionScreen.kt b/src/android/app/src/main/java/com/openminis/app/ui/settings/OffloadPermissionScreen.kt index af488977..303920a4 100644 --- a/src/android/app/src/main/java/com/openminis/app/ui/settings/OffloadPermissionScreen.kt +++ b/src/android/app/src/main/java/com/openminis/app/ui/settings/OffloadPermissionScreen.kt @@ -33,7 +33,8 @@ import com.openminis.app.accessibility.MinisAccessibilityService import com.openminis.app.accessibility.RestrictedSettingsManager import com.openminis.app.logging.AppLogger import com.openminis.app.offload.OffloadPermissionManager -import com.openminis.app.offload.ShizukuManager +import com.openminis.app.runtime.guest.RootCliOffloadHandler +import com.openminis.app.runtime.ubuntu.RootAccess import com.openminis.app.ui.components.MinisTextButton import com.openminis.app.ui.theme.ChatColors import kotlinx.coroutines.delay @@ -43,7 +44,7 @@ import com.openminis.app.ui.components.MinisAlertDialog @Composable fun OffloadPermissionScreen( onBack: () -> Unit, - onOpenPrivilegedBackend: () -> Unit = {}, + onOpenRoot: () -> Unit = {}, onOpenSystemPermissions: () -> Unit = {}, ) { val grouped = OffloadPermissionManager.toolRegistry @@ -66,7 +67,7 @@ fun OffloadPermissionScreen( delay(1000) } } - val shizukuSnap by ShizukuManager.snapshot.collectAsState() + val rootState by RootAccess.state.collectAsState() SettingsScaffold( title = stringResource(R.string.perm_title), @@ -138,13 +139,17 @@ fun OffloadPermissionScreen( iconVector = Icons.Outlined.Shield, iconTint = Color(0xFFAF52DE), sectionHeaderRes = R.string.perm_section_privileged_backend, - toolName = "shizuku_cli", + toolName = RootCliOffloadHandler.TOOL_NAME, descriptionRes = R.string.perm_privileged_cli_description, - systemReady = ShizukuManager.isReady(), - systemStatusTitleRes = shizukuSubtitleRes(shizukuSnap.state), - systemActionTitleRes = shizukuActionTitleRes(shizukuSnap.state), - onSystemAction = onOpenPrivilegedBackend, - onStatusRowClick = onOpenPrivilegedBackend, + systemReady = rootState.isGranted, + systemStatusTitleRes = when { + rootState.isGranted -> R.string.system_enhance_root_granted + rootState.isChecking -> R.string.system_enhance_root_checking + else -> R.string.perm_root_not_granted + }, + systemActionTitleRes = R.string.system_enhance_root_action, + onSystemAction = onOpenRoot, + onStatusRowClick = onOpenRoot, resetEpoch = resetEpoch, ) @@ -338,7 +343,7 @@ private fun toolTitleRes(toolName: String): Int = when (toolName) { "contacts" -> R.string.perm_tool_contacts "photos" -> R.string.perm_tool_photos "a11y_cli" -> R.string.perm_tool_a11y_cli - "shizuku_cli" -> R.string.perm_tool_shizuku_cli + RootCliOffloadHandler.TOOL_NAME -> R.string.perm_tool_root_cli "apt_cli" -> R.string.perm_tool_apt_cli "android.vscreen.open" -> R.string.perm_tool_vscreen_open "android.vscreen.launch" -> R.string.perm_tool_vscreen_launch @@ -394,29 +399,6 @@ private fun openAppDetailsSettings(context: Context) { } } -private fun shizukuSubtitleRes(state: ShizukuManager.State): Int = when (state) { - ShizukuManager.State.NOT_INSTALLED -> R.string.shizuku_state_not_installed - ShizukuManager.State.NOT_RUNNING -> R.string.shizuku_state_not_running - ShizukuManager.State.NEED_PERMISSION -> R.string.shizuku_state_need_permission - ShizukuManager.State.READY -> R.string.shizuku_state_ready -} - -private fun shizukuActionTitleRes(state: ShizukuManager.State): Int = when (state) { - ShizukuManager.State.NOT_INSTALLED -> R.string.shizuku_install_btn - ShizukuManager.State.NOT_RUNNING -> R.string.shizuku_open_btn - ShizukuManager.State.NEED_PERMISSION -> R.string.shizuku_grant_btn - ShizukuManager.State.READY -> 0 -} - -private fun performShizukuAction(context: Context, state: ShizukuManager.State) { - when (state) { - ShizukuManager.State.NOT_INSTALLED -> ShizukuManager.openInstallPage(context) - ShizukuManager.State.NOT_RUNNING -> ShizukuManager.openShizukuApp(context) - ShizukuManager.State.NEED_PERMISSION -> ShizukuManager.requestPermission() - ShizukuManager.State.READY -> {} - } -} - /** One row of the autonomy choice; the selected one carries a check. */ @Composable private fun AutonomyOption( diff --git a/src/android/app/src/main/java/com/openminis/app/ui/settings/RootAccessScreen.kt b/src/android/app/src/main/java/com/openminis/app/ui/settings/RootAccessScreen.kt new file mode 100644 index 00000000..3198eb09 --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/ui/settings/RootAccessScreen.kt @@ -0,0 +1,104 @@ +package com.openminis.app.ui.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Apps +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.automirrored.filled.Launch +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Dashboard +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material.icons.outlined.TouchApp +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.openminis.app.R + +/** + * The Root page under System & permissions: whether su works for this app, what Root is used for, + * and the `android-root-cli` command surface the agent gets from it. Root is the app's only + * privileged path; this page replaced the Root & Shizuku page in 2026-10. + */ +@Composable +fun RootAccessScreen(onBack: () -> Unit) { + SettingsScaffold( + title = stringResource(R.string.system_enhance_root), + onBack = onBack, backLabel = stringResource(R.string.settings_section_system), + ) { + RootStatusSection() + + SettingsSection(header = stringResource(R.string.system_enhance_root_section)) { + RootUses.forEachIndexed { index, use -> + SettingsRow( + title = stringResource(use.titleRes), + icon = use.icon, + showChevron = false, + showDivider = index != RootUses.lastIndex, + ) + } + } + + SettingsSection(header = stringResource(R.string.perm_tool_root_cli)) { + RootCliCapabilities.forEachIndexed { index, cap -> + SettingsRow( + // Subcommand name is a literal CLI token — not localized; only the description is. + title = cap.name, + subtitle = stringResource(cap.descriptionRes), + icon = cap.icon, + showChevron = false, + showDivider = index != RootCliCapabilities.lastIndex, + minHeight = 72.dp, + ) + } + } + Spacer(Modifier.height(16.dp)) + } +} + +private data class RootUse(@StringRes val titleRes: Int, val icon: ImageVector) + +private val RootUses: List = listOf( + RootUse(R.string.system_enhance_root_feature_runtime, Icons.Outlined.Terminal), + RootUse(R.string.system_enhance_root_feature_device, Icons.Outlined.TouchApp), + RootUse(R.string.system_enhance_root_feature_data, Icons.Outlined.Lock), + RootUse(R.string.settings_vscreen_entry, Icons.Outlined.Dashboard), +) + +/** + * One `android-root-cli` subcommand group. The list is static: the CLI's command surface is fixed + * at compile time, so nothing is probed at runtime. The CLI token in [name] is the source of truth + * and is never localized. + */ +private data class RootCliCapability( + val name: String, + @StringRes val descriptionRes: Int, + val icon: ImageVector, +) + +private val RootCliCapabilities: List = listOf( + RootCliCapability("package", R.string.root_cli_cap_package_desc, Icons.Default.Apps), + RootCliCapability("permission", R.string.root_cli_cap_permission_desc, Icons.Default.Lock), + RootCliCapability("activity", R.string.root_cli_cap_activity_desc, Icons.AutoMirrored.Filled.Launch), + RootCliCapability("display", R.string.root_cli_cap_display_desc, Icons.Default.Fullscreen), + RootCliCapability("settings", R.string.root_cli_cap_settings_desc, Icons.Default.Settings), + RootCliCapability("user", R.string.root_cli_cap_user_desc, Icons.Default.Person), + RootCliCapability("network", R.string.root_cli_cap_network_desc, Icons.Default.Wifi), + RootCliCapability("input", R.string.root_cli_cap_input_desc, Icons.Default.TouchApp), + RootCliCapability("notification", R.string.root_cli_cap_notification_desc, Icons.Default.Notifications), + RootCliCapability("file", R.string.root_cli_cap_file_desc, Icons.Default.Folder), + RootCliCapability("device", R.string.root_cli_cap_device_desc, Icons.Default.PhoneAndroid), + RootCliCapability("service", R.string.root_cli_cap_service_desc, Icons.Default.Dns), +) diff --git a/src/android/app/src/main/java/com/openminis/app/ui/settings/SettingsCategoryScreen.kt b/src/android/app/src/main/java/com/openminis/app/ui/settings/SettingsCategoryScreen.kt index 0980d1dd..ed13cb35 100644 --- a/src/android/app/src/main/java/com/openminis/app/ui/settings/SettingsCategoryScreen.kt +++ b/src/android/app/src/main/java/com/openminis/app/ui/settings/SettingsCategoryScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.material.icons.outlined.Psychology import androidx.compose.material.icons.outlined.RecordVoiceOver import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Shield -import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text @@ -150,7 +149,7 @@ fun SettingsCategoryScreen( onSystemEnhanceClick: () -> Unit = {}, onPermissionsClick: () -> Unit = {}, onToolPermissionsClick: () -> Unit = {}, - onShizukuClick: () -> Unit = {}, + onRootClick: () -> Unit = {}, onVirtualScreenClick: () -> Unit = {}, onBackgroundClick: () -> Unit = {}, onLogsClick: () -> Unit = {}, @@ -346,11 +345,11 @@ fun SettingsCategoryScreen( header = stringResource(R.string.settings_manage_header), ) { SettingsRow( - icon = Icons.Outlined.Terminal, - iconColor = Color(0xFF5856D6), - title = stringResource(R.string.shizuku_title), - subtitle = stringResource(R.string.settings_shizuku_sub), - onClick = onShizukuClick, + icon = Icons.Outlined.Key, + iconColor = Color(0xFFFF9500), + title = stringResource(R.string.system_enhance_root), + subtitle = stringResource(R.string.settings_root_sub), + onClick = onRootClick, ) SettingsRow( icon = Icons.Outlined.Dashboard, diff --git a/src/android/app/src/main/java/com/openminis/app/ui/settings/SettingsReadiness.kt b/src/android/app/src/main/java/com/openminis/app/ui/settings/SettingsReadiness.kt index 2636ae25..1fc4d939 100644 --- a/src/android/app/src/main/java/com/openminis/app/ui/settings/SettingsReadiness.kt +++ b/src/android/app/src/main/java/com/openminis/app/ui/settings/SettingsReadiness.kt @@ -48,7 +48,6 @@ import com.openminis.app.R import com.openminis.app.accessibility.MinisAccessibilityService import com.openminis.app.offload.OffloadPermissionManager import com.openminis.app.permissions.SpecialAccess -import com.openminis.app.offload.ShizukuManager import com.openminis.app.power.PowerOptimizationManager import com.openminis.app.runtime.ubuntu.RootAccess import com.openminis.app.ui.components.MinisAlertDialog @@ -61,7 +60,7 @@ import com.openminis.app.ui.theme.ChatColors * the assistant role, a tool the Agent is allowed to use). */ enum class ReadinessId { - ROOT, SHIZUKU, ALL_FILES, ACCESSIBILITY, OVERLAY, ASSISTANT_ROLE, BACKGROUND, NOTIFICATIONS, + ROOT, ALL_FILES, ACCESSIBILITY, OVERLAY, ASSISTANT_ROLE, BACKGROUND, NOTIFICATIONS, CALENDAR, LOCATION, CONTACTS, PHOTOS, // The "special access" switches of Android's system settings (see SpecialAccess); all optional. INSTALL_APPS, EXACT_ALARM, USAGE_STATS, FULL_SCREEN, WRITE_SETTINGS, DATA_SAVER, @@ -132,7 +131,6 @@ object SettingsReadiness { internal fun probe(context: Context, rootGranted: Boolean): List { val results = linkedMapOf( ReadinessId.ROOT to rootGranted, - ReadinessId.SHIZUKU to ShizukuManager.isReady(), ReadinessId.ALL_FILES to hasAllFilesAccess(), ReadinessId.ACCESSIBILITY to MinisAccessibilityService.isEnabled(context), ReadinessId.OVERLAY to Settings.canDrawOverlays(context), @@ -188,7 +186,6 @@ private fun ReadinessId.title(): String = stringResource(titleRes()) private fun ReadinessItem.subtitle(): String = stringResource( when (id) { ReadinessId.ROOT -> if (ok) R.string.settings_ready_root_ok else R.string.settings_ready_root_bad - ReadinessId.SHIZUKU -> if (ok) R.string.settings_ready_shizuku_ok else R.string.settings_ready_shizuku_bad ReadinessId.ALL_FILES -> if (ok) R.string.settings_ready_files_ok else R.string.settings_ready_files_bad ReadinessId.ACCESSIBILITY -> if (ok) R.string.settings_ready_a11y_ok else R.string.settings_ready_a11y_bad ReadinessId.OVERLAY -> if (ok) R.string.settings_ready_overlay_ok else R.string.settings_ready_overlay_bad @@ -225,7 +222,6 @@ fun ReadinessBanner(items: List, onClick: () -> Unit) { private fun ReadinessId.titleRes(): Int = when (this) { ReadinessId.ROOT -> R.string.settings_ready_root_title - ReadinessId.SHIZUKU -> R.string.settings_ready_shizuku_title ReadinessId.ALL_FILES -> R.string.settings_ready_files_title ReadinessId.ACCESSIBILITY -> R.string.settings_ready_a11y_title ReadinessId.OVERLAY -> R.string.settings_ready_overlay_title @@ -256,12 +252,6 @@ private val GRANT_LABEL = setOf( private fun fixReadiness(context: Context, item: ReadinessItem, onOpenBackground: () -> Unit) { when (item.id) { ReadinessId.ROOT -> RootAccess.request(context) - ReadinessId.SHIZUKU -> when (ShizukuManager.snapshot.value.state) { - ShizukuManager.State.NOT_INSTALLED -> ShizukuManager.openInstallPage(context) - ShizukuManager.State.NOT_RUNNING -> ShizukuManager.openShizukuApp(context) - ShizukuManager.State.NEED_PERMISSION -> ShizukuManager.requestPermission() - ShizukuManager.State.READY -> Unit - } ReadinessId.ALL_FILES -> openAllFilesAccess(context) ReadinessId.ACCESSIBILITY -> startSettings(context, Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) ReadinessId.OVERLAY -> startSettings( diff --git a/src/android/app/src/main/java/com/openminis/app/ui/settings/ShizukuPermissionScreen.kt b/src/android/app/src/main/java/com/openminis/app/ui/settings/ShizukuPermissionScreen.kt deleted file mode 100644 index 4c3ca263..00000000 --- a/src/android/app/src/main/java/com/openminis/app/ui/settings/ShizukuPermissionScreen.kt +++ /dev/null @@ -1,255 +0,0 @@ -package com.openminis.app.ui.settings - -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Apps -import androidx.compose.material.icons.filled.Dns -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.Fullscreen -import androidx.compose.material.icons.filled.Launch -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.PhoneAndroid -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.TouchApp -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.openminis.app.R -import com.openminis.app.offload.ShizukuBackend -import com.openminis.app.offload.ShizukuManager - -/** - * T322 / [T-android-privileged-backend]: state-driven Shizuku-protocol - * permission walkthrough. - * - * Renders a single section whose body changes based on - * [ShizukuManager.snapshot]: - * - * NOT_INSTALLED → THREE install CTAs (Shizuku / AXManager / Sui). All - * speak the same protocol — the user picks whichever - * suits them; Sui is the rooted-user option. - * NOT_RUNNING → "Open Manager App and press Start" — launches the - * installed manager. - * NEED_PERMISSION → "Authorize Minis" CTA → triggers system dialog. - * READY → green status row with version + uid. - * - * [T-android-sui-support] (GH#110 / GH#97) Sui is a Magisk/KernelSU module - * with no APK and no launcher activity, so on a Sui-supplied binder the - * "Open Manager App" rows are hidden — there is nothing to open — and the - * READY row is labelled "Sui (root)". - */ -@Composable -fun ShizukuPermissionScreen(onBack: () -> Unit) { - val context = LocalContext.current - val snap by ShizukuManager.snapshot.collectAsState() - - LaunchedEffect(Unit) { ShizukuManager.refresh() } - - SettingsScaffold( - title = stringResource(R.string.shizuku_title), - onBack = onBack, backLabel = stringResource(R.string.settings_section_system), - ) { - RootStatusSection() - SettingsSection( - header = stringResource(R.string.shizuku_status_header), - ) { - SettingsRow( - title = stringResource(stateTitle(snap.state)), - subtitle = if (snap.state == ShizukuManager.State.READY) { - // [T-android-sui-support] Name the live provider so a Sui - // user can see their root module is what's serving Minis. - if (snap.isSui) { - stringResource(R.string.shizuku_ready_subtitle_sui, snap.version, snap.uid) - } else { - stringResource(R.string.shizuku_ready_subtitle, snap.version, snap.uid) - } - } else { - stringResource(stateSubtitle(snap.state)) - }, - trailing = { - Text( - text = stringResource(stateBadge(snap.state)), - style = MaterialTheme.typography.labelMedium, - color = stateColor(snap.state), - ) - }, - showDivider = false, - ) - } - - when (snap.state) { - // Two parallel install entries — Shizuku and AXManager are - // functionally equivalent; the user picks whichever they like. - // Both go to the project's GitHub Releases page (no Play Store — - // AXManager isn't published there, and a single source per manager - // keeps the option set symmetrical). - ShizukuManager.State.NOT_INSTALLED -> SettingsSection( - header = stringResource(R.string.shizuku_actions_header), - ) { - SettingsRow( - title = stringResource(R.string.shizuku_install_btn), - subtitle = stringResource(R.string.shizuku_install_btn_subtitle), - onClick = { ShizukuManager.openInstallPage(context, ShizukuBackend.SHIZUKU_GITHUB_URL) }, - ) - SettingsRow( - title = stringResource(R.string.shizuku_install_btn_axmanager), - subtitle = stringResource(R.string.shizuku_install_btn_axmanager_subtitle), - onClick = { ShizukuManager.openInstallPage(context, ShizukuBackend.AXMANAGER_GITHUB_URL) }, - ) - // [T-android-sui-support] Third option for rooted users: Sui - // needs no APK and survives reboots, so it is strictly nicer - // than Shizuku IF the user already runs Magisk/KernelSU. - SettingsRow( - title = stringResource(R.string.shizuku_install_btn_sui), - subtitle = stringResource(R.string.shizuku_install_btn_sui_subtitle), - onClick = { ShizukuManager.openInstallPage(context, ShizukuBackend.SUI_GITHUB_URL) }, - showDivider = false, - ) - } - - ShizukuManager.State.NOT_RUNNING -> SettingsSection( - header = stringResource(R.string.shizuku_actions_header), - ) { - SettingsRow( - title = stringResource(R.string.shizuku_open_btn), - subtitle = stringResource(R.string.shizuku_open_btn_subtitle), - onClick = { ShizukuManager.openShizukuApp(context) }, - ) - SettingsRow( - title = stringResource(R.string.shizuku_recheck_btn), - subtitle = stringResource(R.string.shizuku_recheck_subtitle), - onClick = { ShizukuManager.refresh() }, - showDivider = false, - ) - } - - ShizukuManager.State.NEED_PERMISSION -> SettingsSection( - header = stringResource(R.string.shizuku_actions_header), - ) { - SettingsRow( - title = stringResource(R.string.shizuku_grant_btn), - subtitle = stringResource(R.string.shizuku_grant_subtitle), - onClick = { ShizukuManager.requestPermission() }, - // Sui has no manager activity, so this is the only row. - showDivider = !snap.isSui, - ) - if (!snap.isSui) { - SettingsRow( - title = stringResource(R.string.shizuku_open_btn), - subtitle = stringResource(R.string.shizuku_open_btn_subtitle), - onClick = { ShizukuManager.openShizukuApp(context) }, - showDivider = false, - ) - } - } - - // [T-android-sui-capabilities-not-empty] READY renders the actual - // capability LIST, one row per android-shizuku-cli subcommand. - // - // This section used to be just a header + a static footer paragraph - // enumerating the subcommands as prose, plus (Shizuku only) an "open - // app" row. On Sui — which has no launcher activity, so that row is - // hidden — the whole section collapsed to header + gray footer, and - // users read that as "empty capabilities" (Sanite&Ava's report, - // status badge read fine: Sui(root) v=13 uid=0). It was NEVER a - // detection bug: the surface is compile-time-fixed and identical for - // Shizuku / AXManager / Sui, so a static list is the correct model. - // Now it renders as a real list on every provider. - ShizukuManager.State.READY -> SettingsSection( - header = stringResource(R.string.shizuku_capabilities_header), - ) { - // Sui has no manager app to open, so that row stays hidden — but - // the list below is now the section's content regardless. - if (!snap.isSui) { - SettingsRow( - title = stringResource(R.string.shizuku_open_btn), - subtitle = stringResource(R.string.shizuku_open_btn_subtitle), - onClick = { ShizukuManager.openShizukuApp(context) }, - ) - } - ShizukuCapabilities.forEachIndexed { index, cap -> - SettingsRow( - // Subcommand name is a literal CLI token — not localized; - // only the human description is. - title = cap.name, - subtitle = stringResource(cap.descriptionRes), - icon = cap.icon, - // No divider after the last capability row. - showDivider = index != ShizukuCapabilities.lastIndex, - minHeight = 72.dp, - ) - } - } - } - Spacer(Modifier.height(16.dp)) - } -} - -private fun stateTitle(s: ShizukuManager.State): Int = when (s) { - ShizukuManager.State.NOT_INSTALLED -> R.string.shizuku_state_not_installed - ShizukuManager.State.NOT_RUNNING -> R.string.shizuku_state_not_running - ShizukuManager.State.NEED_PERMISSION -> R.string.shizuku_state_need_permission - ShizukuManager.State.READY -> R.string.shizuku_state_ready -} - -private fun stateSubtitle(s: ShizukuManager.State): Int = when (s) { - ShizukuManager.State.NOT_INSTALLED -> R.string.shizuku_state_not_installed_subtitle - ShizukuManager.State.NOT_RUNNING -> R.string.shizuku_state_not_running_subtitle - ShizukuManager.State.NEED_PERMISSION -> R.string.shizuku_state_need_permission_subtitle - ShizukuManager.State.READY -> R.string.shizuku_state_ready -} - -private fun stateBadge(s: ShizukuManager.State): Int = when (s) { - ShizukuManager.State.NOT_INSTALLED -> R.string.shizuku_badge_not_installed - ShizukuManager.State.NOT_RUNNING -> R.string.shizuku_badge_not_running - ShizukuManager.State.NEED_PERMISSION -> R.string.shizuku_badge_need_permission - ShizukuManager.State.READY -> R.string.shizuku_badge_ready -} - -@Composable -private fun stateColor(s: ShizukuManager.State): Color = when (s) { - ShizukuManager.State.READY -> MaterialTheme.colorScheme.primary - ShizukuManager.State.NEED_PERMISSION -> MaterialTheme.colorScheme.tertiary - ShizukuManager.State.NOT_RUNNING -> MaterialTheme.colorScheme.tertiary - ShizukuManager.State.NOT_INSTALLED -> MaterialTheme.colorScheme.error -} - -/** - * [T-android-sui-capabilities-not-empty] One android-shizuku-cli subcommand. - * The list is static: the CLI's command surface is fixed at compile time and - * identical across Shizuku / AXManager / Sui, so no runtime probing is - * appropriate. Icons are indicative only; the CLI token in [name] is the - * source of truth and is never localized. - */ -private data class ShizukuCapability( - val name: String, - @androidx.annotation.StringRes val descriptionRes: Int, - val icon: androidx.compose.ui.graphics.vector.ImageVector, -) - -private val ShizukuCapabilities: List = listOf( - ShizukuCapability("package", R.string.shizuku_cap_package_desc, Icons.Default.Apps), - ShizukuCapability("permission", R.string.shizuku_cap_permission_desc, Icons.Default.Lock), - ShizukuCapability("activity", R.string.shizuku_cap_activity_desc, Icons.Default.Launch), - ShizukuCapability("display", R.string.shizuku_cap_display_desc, Icons.Default.Fullscreen), - ShizukuCapability("settings", R.string.shizuku_cap_settings_desc, Icons.Default.Settings), - ShizukuCapability("user", R.string.shizuku_cap_user_desc, Icons.Default.Person), - ShizukuCapability("network", R.string.shizuku_cap_network_desc, Icons.Default.Wifi), - ShizukuCapability("input", R.string.shizuku_cap_input_desc, Icons.Default.TouchApp), - ShizukuCapability("notification", R.string.shizuku_cap_notification_desc, Icons.Default.Notifications), - ShizukuCapability("file", R.string.shizuku_cap_file_desc, Icons.Default.Folder), - ShizukuCapability("device", R.string.shizuku_cap_device_desc, Icons.Default.PhoneAndroid), - ShizukuCapability("service", R.string.shizuku_cap_service_desc, Icons.Default.Dns), -) diff --git a/src/android/app/src/main/java/com/openminis/app/ui/settings/SystemEnhanceScreen.kt b/src/android/app/src/main/java/com/openminis/app/ui/settings/SystemEnhanceScreen.kt index b3759394..1925a24d 100644 --- a/src/android/app/src/main/java/com/openminis/app/ui/settings/SystemEnhanceScreen.kt +++ b/src/android/app/src/main/java/com/openminis/app/ui/settings/SystemEnhanceScreen.kt @@ -279,7 +279,7 @@ internal fun rootStateSubtitle(state: RootAccessState): String = when { else -> stringResource(R.string.system_enhance_root_unknown) } -/** The Root row: whether su works for this app, and a button to ask again. Shown on the Root & Shizuku page. */ +/** The Root row: whether su works for this app, and a button to ask again. Shown on the Root page. */ @Composable internal fun RootStatusSection() { val context = LocalContext.current diff --git a/src/android/app/src/main/java/com/openminis/app/ui/settings/SystemPermissionsScreen.kt b/src/android/app/src/main/java/com/openminis/app/ui/settings/SystemPermissionsScreen.kt index e577d0ce..a4a19f2c 100644 --- a/src/android/app/src/main/java/com/openminis/app/ui/settings/SystemPermissionsScreen.kt +++ b/src/android/app/src/main/java/com/openminis/app/ui/settings/SystemPermissionsScreen.kt @@ -52,7 +52,7 @@ import com.openminis.app.R import com.openminis.app.accessibility.AccessibilityRecoveryManager import com.openminis.app.accessibility.MinisAccessibilityService import com.openminis.app.accessibility.RestrictedSettingsManager -import com.openminis.app.offload.ShizukuManager +import com.openminis.app.offload.RootProcess import com.openminis.app.power.PowerOptimizationManager import com.openminis.app.ui.components.MinisTextButton import com.openminis.app.ui.theme.ChatColors @@ -100,7 +100,7 @@ fun SystemPermissionsScreen(onBack: () -> Unit) { ) { refreshAssistantRole() } var a11yDegraded by remember { mutableStateOf(false) } var a11yRevoked by remember { mutableStateOf(false) } - var shizukuReady by remember { mutableStateOf(false) } + var rootGranted by remember { mutableStateOf(false) } var repairing by remember { mutableStateOf(false) } var repairFailed by remember { mutableStateOf(false) } var a11yRestricted by remember { mutableStateOf(false) } @@ -117,7 +117,7 @@ fun SystemPermissionsScreen(onBack: () -> Unit) { a11yDegraded = inSettings && !connected a11yRevoked = !inSettings && !connected && AccessibilityRecoveryManager.hasEverBeenGranted(context) - shizukuReady = ShizukuManager.isReady() + rootGranted = RootProcess.isGranted() a11yRestricted = !a11yEnabled && RestrictedSettingsManager.isRestricted(context) overlayGranted = Settings.canDrawOverlays(context) delay(1000) @@ -192,7 +192,7 @@ fun SystemPermissionsScreen(onBack: () -> Unit) { } // [T-eta-xposed-groups] The module's accessibility protection: it keeps this app's - // service enabled from inside system_server, so it needs neither Shizuku nor the user + // service enabled from inside system_server, so it needs neither Root nor the user // coming back to this screen. Off until asked, and the row says when the module is not // there instead of pretending the switch did something. var moduleProtectionEnabled by remember { @@ -228,25 +228,25 @@ fun SystemPermissionsScreen(onBack: () -> Unit) { SettingsSection( header = stringResource(R.string.system_permissions_a11y_restricted_header), ) { - if (shizukuReady) { + if (rootGranted) { SettingsRow( icon = Icons.Outlined.LockOpen, iconColor = ChatColors.ok, - title = stringResource(R.string.system_permissions_a11y_restricted_shizuku), + title = stringResource(R.string.system_permissions_a11y_restricted_root), subtitle = when { unrestricting -> stringResource(R.string.system_permissions_a11y_restricted_working) unrestrictFailed -> stringResource(R.string.system_permissions_a11y_restricted_failed) else -> - stringResource(R.string.system_permissions_a11y_restricted_shizuku_sub) + stringResource(R.string.system_permissions_a11y_restricted_root_sub) }, onClick = { if (unrestricting) return@SettingsRow unrestricting = true unrestrictFailed = false scope.launch { - val ok = RestrictedSettingsManager.clearWithShizuku(context) + val ok = RestrictedSettingsManager.clearWithRoot(context) unrestricting = false unrestrictFailed = !ok } @@ -308,7 +308,7 @@ fun SystemPermissionsScreen(onBack: () -> Unit) { icon = Icons.Outlined.Build, iconColor = ChatColors.bad, title = stringResource( - if (shizukuReady) R.string.a11y_repair_row_shizuku + if (rootGranted) R.string.a11y_repair_row_root else R.string.a11y_repair_row_manual, ), subtitle = when { @@ -317,7 +317,7 @@ fun SystemPermissionsScreen(onBack: () -> Unit) { else -> stringResource(R.string.a11y_repair_row_sub) }, onClick = { - if (!shizukuReady) { + if (!rootGranted) { openAccessibilitySettings(context) return@SettingsRow } @@ -325,7 +325,7 @@ fun SystemPermissionsScreen(onBack: () -> Unit) { repairing = true repairFailed = false scope.launch { - val ok = AccessibilityRecoveryManager.repairWithShizuku(context) + val ok = AccessibilityRecoveryManager.repairWithRoot(context) repairing = false repairFailed = !ok } diff --git a/src/android/app/src/main/java/com/openminis/app/ui/settings/VirtualScreenCopyPolicy.kt b/src/android/app/src/main/java/com/openminis/app/ui/settings/VirtualScreenCopyPolicy.kt index d02b2a93..a9652f1a 100644 --- a/src/android/app/src/main/java/com/openminis/app/ui/settings/VirtualScreenCopyPolicy.kt +++ b/src/android/app/src/main/java/com/openminis/app/ui/settings/VirtualScreenCopyPolicy.kt @@ -8,11 +8,9 @@ internal object VirtualScreenCopyPolicy { private val reasons: Map = mapOf( "android_supported" to R.string.vscreen_reason_android_ready, "android_version_unsupported" to R.string.vscreen_reason_android_old, - "shizuku_shell_user_service" to R.string.vscreen_reason_shizuku_ready, - "shizuku_not_ready" to R.string.vscreen_reason_shizuku_missing, - "shizuku_unavailable" to R.string.vscreen_reason_shizuku_missing, - "permission_denied" to R.string.vscreen_reason_shizuku_missing, - "root_user_service_refused" to R.string.vscreen_reason_wrong_identity, + "root_service_ready" to R.string.vscreen_reason_root_ready, + "root_not_ready" to R.string.vscreen_reason_root_missing, + "permission_denied" to R.string.vscreen_reason_root_missing, "unexpected_user_service_uid" to R.string.vscreen_reason_wrong_identity, "shell_context_ready" to R.string.vscreen_reason_context_ready, "shell_context_unavailable" to R.string.vscreen_reason_context_failed, @@ -46,7 +44,7 @@ internal object VirtualScreenCopyPolicy { private val steps: Map = mapOf( "environment" to R.string.vscreen_step_environment, - "shizuku" to R.string.vscreen_step_shizuku, + "root" to R.string.vscreen_step_root, "context" to R.string.vscreen_step_context, "virtual_display" to R.string.vscreen_step_display, "ime" to R.string.vscreen_step_ime, @@ -68,11 +66,10 @@ internal object VirtualScreenCopyPolicy { } /** Where a failed check can be fixed, so the result dialog can offer a single "go there" button. */ - enum class FailAction { NONE, SHIZUKU, DEVELOPER_OPTIONS } + enum class FailAction { NONE, ROOT, DEVELOPER_OPTIONS } fun actionFor(code: String, xiaomi: Boolean): FailAction = when (code) { - "shizuku_not_ready", "shizuku_unavailable", "permission_denied", "unexpected_user_service_uid", - "root_user_service_refused" -> FailAction.SHIZUKU + "root_not_ready", "permission_denied", "unexpected_user_service_uid" -> FailAction.ROOT "input_injection_failed" -> if (xiaomi) FailAction.DEVELOPER_OPTIONS else FailAction.NONE else -> FailAction.NONE } @@ -82,8 +79,8 @@ internal object VirtualScreenCopyPolicy { /** Probe reports returned by V1, including app-side failure and skipped codes. */ fun requiredProbeReasonCodes(): Set = setOf( - "android_supported", "android_version_unsupported", "shizuku_shell_user_service", - "shizuku_not_ready", "shizuku_unavailable", "permission_denied", "root_user_service_refused", + "android_supported", "android_version_unsupported", "root_service_ready", + "root_not_ready", "permission_denied", "unexpected_user_service_uid", "shell_context_ready", "shell_context_unavailable", "virtual_display_created", "virtual_display_create_failed", "virtual_display_invalid_id", "probe_display_busy", "display_unavailable", "display_ime_local", "ime_policy_unavailable", diff --git a/src/android/app/src/main/java/com/openminis/app/ui/settings/VirtualScreenSettingsScreen.kt b/src/android/app/src/main/java/com/openminis/app/ui/settings/VirtualScreenSettingsScreen.kt index cde400a2..f81c8897 100644 --- a/src/android/app/src/main/java/com/openminis/app/ui/settings/VirtualScreenSettingsScreen.kt +++ b/src/android/app/src/main/java/com/openminis/app/ui/settings/VirtualScreenSettingsScreen.kt @@ -57,7 +57,7 @@ private data class ProbeRow(val id: String, val status: String, val code: String private data class PreviewResult(val displayId: Int?, val bitmap: Bitmap?) @Composable -fun VirtualScreenSettingsScreen(onBack: () -> Unit, onOpenShizuku: () -> Unit = {}) { +fun VirtualScreenSettingsScreen(onBack: () -> Unit, onOpenRoot: () -> Unit = {}) { val context = LocalContext.current val client = remember(context) { VirtualScreenClientProvider.get(context) } val preferences = remember(context) { VirtualScreenPreferences(context) } @@ -209,7 +209,7 @@ fun VirtualScreenSettingsScreen(onBack: () -> Unit, onOpenShizuku: () -> Unit = MinisTextButton(onClick = { showFailures = false when (action) { - VirtualScreenCopyPolicy.FailAction.SHIZUKU -> onOpenShizuku() + VirtualScreenCopyPolicy.FailAction.ROOT -> onOpenRoot() VirtualScreenCopyPolicy.FailAction.DEVELOPER_OPTIONS -> runCatching { context.startActivity( android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS), @@ -220,8 +220,8 @@ fun VirtualScreenSettingsScreen(onBack: () -> Unit, onOpenShizuku: () -> Unit = }) { Text( stringResource( - if (action == VirtualScreenCopyPolicy.FailAction.SHIZUKU) { - R.string.vscreen_fail_action_shizuku + if (action == VirtualScreenCopyPolicy.FailAction.ROOT) { + R.string.vscreen_fail_action_root } else { R.string.vscreen_fail_action_devopts }, diff --git a/src/android/app/src/main/java/com/openminis/app/ui/vscreen/VirtualScreenHomeActivity.kt b/src/android/app/src/main/java/com/openminis/app/ui/vscreen/VirtualScreenHomeActivity.kt index ba86350c..3d15b7df 100644 --- a/src/android/app/src/main/java/com/openminis/app/ui/vscreen/VirtualScreenHomeActivity.kt +++ b/src/android/app/src/main/java/com/openminis/app/ui/vscreen/VirtualScreenHomeActivity.kt @@ -75,7 +75,7 @@ class VirtualScreenHomeActivity : ComponentActivity() { } /** - * Started through the Shizuku service, like every other launch on the virtual display: an app starting + * Started through the root virtual-screen service, like every other launch on the virtual display: an app starting * another app from its own process is stopped by MIUI's "associated start" prompt, which would pop up * on the virtual screen for every icon. */ diff --git a/src/android/app/src/main/res/values-de/restricted_settings_strings.xml b/src/android/app/src/main/res/values-de/restricted_settings_strings.xml index 40eca1e5..fe77a8fa 100644 --- a/src/android/app/src/main/res/values-de/restricted_settings_strings.xml +++ b/src/android/app/src/main/res/values-de/restricted_settings_strings.xml @@ -4,8 +4,8 @@ Android sperrt den Schalter für Bedienungshilfen bei Apps, die aus einer heruntergeladenen APK-Datei installiert wurden. Das ist eine Sicherheitsrichtlinie des Betriebssystems und kein Fehler von Minis — mit Ihrer Installation stimmt alles. Das Zulassen eingeschränkter Einstellungen gibt lediglich den Schalter wieder frei; es erteilt für sich genommen keine Berechtigung, und Sie entscheiden weiterhin selbst, ob Sie den Dienst einschalten. Eingeschränkte Einstellungen zulassen App-Info → Menü ⋮ (oben rechts) → Eingeschränkte Einstellungen zulassen - Eingeschränkte Einstellungen über Shizuku zulassen - Ein Tippen — hebt die Sperre auf, ohne Minis zu verlassen + Eingeschränkte Einstellungen per Root zulassen + Ein Tippen — hebt die Sperre auf, ohne Minis zu verlassen Wird zugelassen… Fehlgeschlagen — verwenden Sie die manuellen Schritte unten diff --git a/src/android/app/src/main/res/values-de/strings.xml b/src/android/app/src/main/res/values-de/strings.xml index 253f594a..ec96f6a0 100644 --- a/src/android/app/src/main/res/values-de/strings.xml +++ b/src/android/app/src/main/res/values-de/strings.xml @@ -62,19 +62,17 @@ Agent-Status Zeigt den Status laufender Agent-Sitzungen an. Bedienungshilfe deaktiviert - Minis hat seine Berechtigung für Bedienungshilfen verloren – das passiert nach einem Beenden erzwingen. Shizuku ist verfügbar und kann sie sofort wiederherstellen, ohne die Einstellungen zu öffnen. + Minis hat seine Berechtigung für Bedienungshilfen verloren – das passiert nach einem Beenden erzwingen. Mit Root lässt sie sich sofort wiederherstellen, ohne die Einstellungen zu öffnen. Minis hat seine Berechtigung für Bedienungshilfen verloren – das passiert nach einem Beenden erzwingen. Aktiviere Minis erneut unter Einstellungen → Bedienungshilfen. Jetzt reparieren Einstellungen öffnen Später Aktion erforderlich - Berechtigung reparieren + Berechtigung reparieren Bedienungshilfe reaktivieren Die Berechtigung wurde entzogen, wahrscheinlich durch Beenden erzwingen Wird repariert… Reparatur fehlgeschlagen – bitte in den Einstellungen aktivieren - Android entfernt die Bedienungshilfen-Berechtigung, sobald eine App zwangsweise beendet wird, und stellt sie nicht automatisch wieder her. Shizuku kann sie direkt zurückschreiben. - Android entfernt die Bedienungshilfen-Berechtigung, sobald eine App zwangsweise beendet wird, und stellt sie nicht automatisch wieder her. Mit Shizuku geht das künftig mit einem Tipp. Minis Agent aktiv %1$s | %2$s | %3$s Aufgabe abgeschlossen @@ -319,14 +317,14 @@ Root ist nicht verfügbar, die Berechtigungen können nicht automatisch erteilt werden. android-a11y-cli - android-shizuku-cli + android-root-cli minis-apt Bedienungshilfen android-a11y-cli ermöglicht dem Agenten, die UI-Struktur zu lesen, Tippen und Gesten auszuführen sowie Systemereignisse zu beobachten. Beide Zeilen unten müssen gesetzt sein: Agent-Berechtigung wählen und Bedienungshilfen-Dienst in den Systemeinstellungen aktivieren. - android-shizuku-cli gewährt dem Agenten privilegierte Android-APIs (Paket-Installation, settings put, Force-Stop, …) über den Shizuku-Dienst, autorisiert via WLAN-ADB oder Root. Beide Zeilen unten müssen gesetzt sein. Agent darf Bildschirm-UI lesen, tippen, eingeben, Ereignisse beobachten Bedienungshilfen-Einstellungen öffnen Systemautorisierung + Nicht erteilt Agent-Berechtigung Dienst aktiviert Dienst nicht aktiviert @@ -340,6 +338,18 @@ Zwischenablage Kontakte Fotos + Apps installieren, deinstallieren, auflisten und abfragen + Laufzeitberechtigungen erteilen oder entziehen + Activities starten und Broadcasts senden + Anzeigeeinstellungen abfragen und anpassen + System-, Secure- und Global-Einstellungen lesen und schreiben + Gerätenutzer auflisten und verwalten + Netzwerkstatus prüfen und umschalten + Tippen, Wischen, Tasten und Text einspeisen + Benachrichtigungen senden und verwalten + Dateien in privilegierten Pfaden lesen und schreiben + Geräteeigenschaften und Hardwarestatus abfragen + Prüfen, ob Root verfügbar ist Fähigkeiten Skill hinzufügen Installiert @@ -1049,12 +1059,6 @@ Download abbrechen Fehlgeschlagen Öffnen - Sui installieren - RikkaApps · für gerootete Geräte (Magisk/KernelSU) · keine App, übersteht Neustarts - Sui (Root) · Version=%1$d, uid=%2$d - Sui läuft. Tippe auf „Minis autorisieren“, um Zugriff zu gewähren — Sui zeigt den Bestätigungsdialog selbst an. Es gibt keine separate Manager-App zum Öffnen. - Installiere eines von Shizuku (RikkaApps), AXManager (Axeron) oder Sui (RikkaApps) — alle drei nutzen dasselbe Protokoll und sind austauschbar. Shizuku und AXManager sind normale Apps, die per drahtlosem ADB aktiviert werden. Sui ist für bereits gerootete Geräte: Es wird als Magisk/KernelSU-Modul installiert, braucht keine App und bleibt über Neustarts aktiv. Unter KernelSU oder APatch benötigt Sui zusätzlich eine Zygisk-Implementierung (Zygisk Next / ReZygisk / NeoZygisk) — ohne sie wird Sui nicht geladen. Installiere eines und kehre dann hierher zurück. - Installiere Shizuku, AXManager oder Sui — eines davon genügt. Sichern & Wiederherstellen Eine verschlüsselte Sicherung exportieren oder daraus wiederherstellen Sichern & Wiederherstellen @@ -1384,7 +1388,6 @@ Modulschutz Bedienungshilfen-Dienst aktiv halten Das Minis-Modul ist nicht installiert oder nicht aktiv - Funktioniert nur mit installiertem Minis-LSPosed-Modul im Geltungsbereich von Minis. Das Modul hält den Bedienungshilfen-Dienst dann ohne Shizuku aktiv. Moduleinstellungen Schalter, die das LSPosed-Modul liest @@ -1484,7 +1487,7 @@ Eingabetaste, Fokus nach Antwort, Denken, Gruppierung Design, Schrift, Sprache, App-Symbol, Desktop-Pet Rootfs, Umgebungsvariablen - Root, Shizuku, Agent-Rechte, Hintergrund und Benachrichtigungen + Root, Agent-Rechte, Hintergrund und Benachrichtigungen Protokolle, Updates, Datenschutz Das Terminal ist in der Seitenleiste; Team und geplante Aufgaben unter Agent. %1$d Punkte brauchen Aufmerksamkeit @@ -1527,7 +1530,7 @@ Minis übernimmt die Ein-/Aus-Taste erst, wenn es unter „Berechtigungen“ als Standard-Assistent gesetzt ist. Agent-Berechtigungen Was der Agent selbst tun darf - Root-Status, Shizuku installieren und autorisieren + Root-Status und was damit möglich ist Aufgaben-Overlay in der App Overlay-Berechtigung Über anderen Apps anzeigen @@ -1908,7 +1911,7 @@ Hintergrundläufe verwenden für UI-Aktionen nur das virtuelle Display. Virtuelles Display ist nicht aktiviert oder die Prüfung ist fehlgeschlagen. Speichern ist möglich, UI-Aktionen können jedoch scheitern. Virtuelles Display (experimentell) - Shizuku-Virtual-Screen und Kompatibilitätstest + Root-Virtual-Screen und Kompatibilitätstest Physische Bildschirmaktionen im Hintergrund erlauben Risiko: Hintergrundroutinen könnten den von dir verwendeten Bildschirm steuern. Standardmäßig aus; nur virtuelles Display ist sicherer. Gerätekompatibilität prüfen @@ -1916,11 +1919,10 @@ Virtuelles Display aktivieren Vor dem Aktivieren wird ein aktueller Gerätetest ausgeführt. Höhe (px) - Experimentell. Benötigt einen Shizuku-Dienst mit Root- oder ADB-Rechten (mit Root Shizuku per Root starten). Standardmäßig aus, erzeugt ein isoliertes virtuelles Display und fällt nie auf den physischen Bildschirm zurück. Es ist kein virtuelles Display aktiv. Der Dienstprozess ist nicht rechtzeitig gestartet. App aktualisieren und erneut versuchen; bei Wiederholung diesen Code melden. Diese Prüfungen sind fehlgeschlagen - Shizuku öffnen + Root-Einstellungen öffnen Entwickleroptionen Fehlgeschlagene ansehen Virtueller Bildschirm @@ -1937,13 +1939,13 @@ Die App hat das virtuelle Display verlassen. Stoppe die Aktion; es gibt keinen Wechsel zum physischen Bildschirm. Das temporäre Prüfdisplay wurde freigegeben. Es wurde kein temporäres Display erstellt; daher war nichts freizugeben. - Systemkontext konnte nicht eingerichtet werden. Starte Shizuku und Minis neu. + Systemkontext konnte nicht eingerichtet werden. Starte Minis neu. Der isolierte Systemkontext ist bereit. Schließe das aktuelle virtuelle Display und prüfe die Kompatibilität erneut. Display konnte nicht erstellt werden. Schließe ein aktives virtuelles Display und versuche es erneut; möglicherweise fehlen ROM-APIs. Ein temporäres virtuelles Display wurde erstellt. - Prüfschritt fehlgeschlagen. Prüfe die Gerätedetails und Shizuku-Autorisierung. - Eine erforderliche Hidden API ist im Shizuku-UserService nicht verfügbar. VScreen kann auf diesem Gerät nicht aktiviert werden. + Prüfschritt fehlgeschlagen. Prüfe die Gerätedetails und ob Minis Root hat. + Eine erforderliche Hidden API ist im Root-Dienst nicht verfügbar. VScreen kann auf diesem Gerät nicht aktiviert werden. Die Tastatur wird an das virtuelle Display geleitet. Die Tastaturweiterleitung lässt sich auf diesem ROM nicht isolieren. Texteingabe kann unzuverlässig sein. Eingabe wurde abgelehnt. Aktiviere bei Xiaomi/HyperOS unter Entwickleroptionen „USB-Debugging (Sicherheitseinstellungen)“. @@ -1954,13 +1956,13 @@ Das Bild ist schwarz; FLAG_SECURE oder eine nicht unterstützte Aufnahmerichtlinie kann Pixel blockieren. Verwende den UI-Knotenbaum oder bitte die Person, die Kontrolle zu übernehmen. Kein OCR und kein anderer Aufnahmeweg. Kein sicheres Bild verfügbar. Geschützte oder schwarze Bildschirme werden absichtlich blockiert. Ein nicht-schwarzes Bild des virtuellen Displays wurde aufgenommen. - Starte Shizuku, autorisiere Minis und führe die Prüfung erneut aus. - Shizuku ist autorisiert und der Shell-UserService läuft. - Die Geräteprüfung hat das Zeitlimit überschritten. Halte Shizuku aktiv und versuche es erneut. - UI-Erfassung wurde blockiert. Prüfe Shizuku und Gerätekompatibilität. - UI-Erfassung im Shizuku-Dienst ist verfügbar. + Erteile Minis Root und führe die Prüfung erneut aus. + Root ist erteilt und der Virtual-Screen-Dienst läuft als root. + Die Geräteprüfung hat das Zeitlimit überschritten. Prüfe, ob Root erteilt ist, und versuche es erneut. + UI-Erfassung wurde blockiert. Prüfe Root und Gerätekompatibilität. + UI-Erfassung im Root-Dienst ist verfügbar. Unbekanntes Prüfergebnis. Lasse VScreen deaktiviert und melde diesen Code. - Der Dienst lief nicht mit der erlaubten Identität shell (2000) oder Root (0). Starte Shizuku neu und versuche es erneut. + Der Dienst lief nicht als Root (0). Erteile Minis Root und versuche es erneut. Virtuelles Display freigeben Virtuelles Display freigegeben. Auflösung und Dichte @@ -1978,7 +1980,7 @@ Eingabe auf dem Display App auf dem virtuellen Display starten Nicht-schwarzer Screenshot - Shizuku-Autorisierung und Dienstidentität + Root-Autorisierung und Dienstidentität UI-Erfassung Kompatibilitätsprüfung Virtuelles Display @@ -2057,9 +2059,6 @@ Virtuellen Bildschirm bedienen Agent darf den virtuellen Bildschirm nutzen Hauptschalter; darunter die Einzelrechte - Shizuku - Verbunden · für virtuellen Bildschirm und Agent-Werkzeuge - Nicht bereit · Shizuku installieren, starten oder autorisieren Über anderen Apps anzeigen Erlaubt Aus · Desktop-Pet und schwebende Steuerung brauchen es @@ -2169,7 +2168,7 @@ Nur minis-model-use (kein Chatmodell) Ein-Tipp-Freigabe Alles freigeben - Erteilt per Root alle vom App deklarierten Android-Berechtigungen und erlaubt alle Agent-Werkzeuge, auch Bedienungshilfen, Shizuku und den virtuellen Bildschirm. Beim Ausschalten kehren die Agent-Werkzeuge zu ihren Standardwerten zurück; die Android-Berechtigungen bleiben. Gibt außerdem deine API-Key-Anbieter an pi, Claude Code und Codex im Terminal weiter. + Erteilt per Root alle vom App deklarierten Android-Berechtigungen und erlaubt alle Agent-Werkzeuge, auch Bedienungshilfen und den virtuellen Bildschirm. Beim Ausschalten kehren die Agent-Werkzeuge zu ihren Standardwerten zurück; die Android-Berechtigungen bleiben. Gibt außerdem deine API-Key-Anbieter an pi, Claude Code und Codex im Terminal weiter. Wird erteilt… Minis neu starten, um zu verbinden Nutze das, wenn du das Modul aktiviert hast, während Minis schon lief. diff --git a/src/android/app/src/main/res/values-fr/restricted_settings_strings.xml b/src/android/app/src/main/res/values-fr/restricted_settings_strings.xml index e67d7c79..0b04b2d8 100644 --- a/src/android/app/src/main/res/values-fr/restricted_settings_strings.xml +++ b/src/android/app/src/main/res/values-fr/restricted_settings_strings.xml @@ -4,8 +4,8 @@ Android bloque l\'interrupteur d\'accessibilité pour les applis installées depuis un fichier APK téléchargé. C\'est une politique de sécurité du système, pas un bug de Minis — votre installation n\'a rien d\'anormal. Autoriser les réglages restreints ne fait que réactiver l\'interrupteur ; cela n\'accorde rien en soi, et vous décidez toujours d\'activer ou non le service. Autoriser les réglages restreints Infos sur l\'appli → menu ⋮ (en haut à droite) → Autoriser les réglages restreints - Autoriser les réglages restreints via Shizuku - Un seul appui — lève l\'indicateur sans quitter Minis + Autoriser les réglages restreints via le root + Un seul appui — lève l\'indicateur sans quitter Minis Autorisation en cours… Échec — suivez les étapes manuelles ci-dessous diff --git a/src/android/app/src/main/res/values-fr/strings.xml b/src/android/app/src/main/res/values-fr/strings.xml index 54b16d50..3d50bdd6 100644 --- a/src/android/app/src/main/res/values-fr/strings.xml +++ b/src/android/app/src/main/res/values-fr/strings.xml @@ -62,19 +62,17 @@ État de l\'agent Affiche l\'état des sessions d\'agent en cours. Accessibilité désactivée - Minis a perdu son autorisation d’accessibilité — cela arrive après un arrêt forcé. Shizuku est disponible et peut la restaurer immédiatement, sans ouvrir les Réglages. + Minis a perdu son autorisation d’accessibilité — cela arrive après un arrêt forcé. Le root étant accordé, elle peut être restaurée immédiatement, sans ouvrir les Réglages. Minis a perdu son autorisation d’accessibilité — cela arrive après un arrêt forcé. Réactivez Minis dans Réglages → Accessibilité pour continuer. Réparer maintenant Ouvrir les Réglages Plus tard Action requise - Réparer l’autorisation d’accessibilité + Réparer l’autorisation d’accessibilité Réactiver l’accessibilité L’autorisation a été révoquée, probablement par un arrêt forcé Réparation… Échec de la réparation — activez-la dans les Réglages - Android supprime l’autorisation d’accessibilité chaque fois qu’une application est arrêtée de force, et ne la rétablit pas automatiquement. Shizuku peut la réécrire directement. - Android supprime l’autorisation d’accessibilité chaque fois qu’une application est arrêtée de force, et ne la rétablit pas automatiquement. Installez et autorisez Shizuku pour réparer en un geste à l’avenir. Minis Agent actif %1$s | %2$s | %3$s Tâche terminée @@ -319,14 +317,14 @@ Root n\'est pas disponible : les autorisations ne peuvent pas être accordées automatiquement. android-a11y-cli - android-shizuku-cli + android-root-cli minis-apt Accessibilité android-a11y-cli permet à l\'agent de lire la structure de l\'UI à l\'écran, d\'effectuer des appuis et gestes, et d\'observer les événements système. Les deux lignes ci-dessous doivent être configurées : choisissez une permission d\'agent et activez le service d\'accessibilité dans les paramètres système. - android-shizuku-cli accorde à l\'agent des API Android privilégiées (installation de packages, settings put, force-stop, …) via le service Shizuku, autorisé via ADB sans fil ou root. Les deux lignes ci-dessous doivent être configurées. L\'agent lit l\'UI à l\'écran, tape, écrit, observe les événements Ouvrir les paramètres d\'accessibilité Autorisation système + Non accordé Permission de l\'agent Service activé Service non activé @@ -340,6 +338,18 @@ Presse-papiers Contacts Photos + Installer, désinstaller, lister et interroger les apps + Accorder ou révoquer des autorisations d’exécution + Lancer des activités et envoyer des diffusions + Consulter et ajuster les réglages d’affichage + Lire et écrire les réglages system / secure / global + Lister et gérer les utilisateurs de l’appareil + Inspecter et basculer l’état du réseau + Injecter appuis, balayages, touches et texte + Publier et gérer les notifications + Lire et écrire dans des chemins privilégiés + Consulter les propriétés et l’état matériel de l’appareil + Vérifier si le root est disponible Compétences Ajouter un Skill Installé @@ -916,12 +926,6 @@ Annuler le téléchargement Échec Ouvrir - Installer Sui - RikkaApps · pour appareils rootés (Magisk/KernelSU) · sans app, survit aux redémarrages - Sui (root) · version=%1$d, uid=%2$d - Sui est actif. Touchez « Autoriser Minis » pour accorder l\'accès — Sui affichera lui-même la boîte de dialogue. Il n\'y a pas d\'app de gestion distincte à ouvrir. - Installez au choix Shizuku (RikkaApps), AXManager (Axeron) ou Sui (RikkaApps) — les trois utilisent le même protocole et sont interchangeables. Shizuku et AXManager sont des applications activées via ADB sans fil. Sui s\'adresse aux appareils déjà rootés : il s\'installe comme module Magisk/KernelSU, ne nécessite aucune app et reste actif après redémarrage. Sur KernelSU ou APatch, Sui exige en plus une implémentation Zygisk (Zygisk Next / ReZygisk / NeoZygisk) — sans elle, Sui ne se charge pas. Installez-en un, puis revenez ici. - Installez Shizuku, AXManager ou Sui — l\'un des trois suffit. Sauvegarde et restauration Exporter une sauvegarde chiffrée, ou restaurer à partir d\'une sauvegarde Sauvegarde et restauration @@ -1280,7 +1284,6 @@ Protection par module Maintenir le service d\'accessibilité activé Le module Minis n\'est pas installé ou n\'est pas actif - Fonctionne uniquement avec le module LSPosed Minis installé et limité à Minis. Le module maintient alors le service d\'accessibilité activé dans le système, sans Shizuku. Réglages du module Interrupteurs lus par le module LSPosed @@ -1380,7 +1383,7 @@ Touche Entrée, focus après réponse, réflexion, regroupement Thème, police, langue, icône, mascotte Rootfs, variables d\'environnement - Root, Shizuku, autorisations de l’agent, arrière-plan et notifications + Root, autorisations de l’agent, arrière-plan et notifications Journaux, mises à jour, confidentialité Le terminal est dans le tiroir des sessions ; l\'équipe et les tâches planifiées sont dans Agent. %1$d point(s) à traiter @@ -1423,7 +1426,7 @@ Minis ne reprend le bouton d\'alimentation qu\'une fois défini comme assistant système, dans « Autorisations ». Autorisations de l\'agent Ce que l\'agent peut faire seul - État root, installation et autorisation de Shizuku + État du root et ce qu’il permet Superposition de tâches dans l\'app Autorisation de superposition Afficher par-dessus les autres apps @@ -1804,7 +1807,7 @@ Les actions UI en tâche de fond utilisent uniquement l’écran virtuel. L’écran virtuel n’est pas activé avec une vérification réussie. L’enregistrement reste possible, mais les actions UI peuvent échouer. Écran virtuel (expérimental) - Écran Shizuku virtuel et vérification de compatibilité + Écran virtuel root et vérification de compatibilité Autoriser les actions en arrière-plan sur l’écran physique Risque : une routine en arrière-plan pourrait contrôler l’écran que vous utilisez. Désactivé par défaut ; l’écran virtuel seul est plus sûr. Vérifier la compatibilité de l’appareil @@ -1812,11 +1815,10 @@ Activer l’écran virtuel Une vérification récente de l’appareil est effectuée avant l’activation. Hauteur (px) - Expérimental. Nécessite un service Shizuku lancé avec Root ou ADB (avec Root, démarrez Shizuku avec Root). Désactivé par défaut, crée un seul écran virtuel isolé et ne revient jamais à l’écran physique. Aucun écran virtuel actif. Le processus du service n’a pas démarré à temps. Mettez l’application à jour et réessayez ; si cela persiste, signalez ce code. Ces vérifications ont échoué - Ouvrir Shizuku + Ouvrir les réglages root Options pour les développeurs Voir ce qui a échoué Écran virtuel @@ -1833,13 +1835,13 @@ L’application a quitté l’écran virtuel. Arrêtez l’action ; aucun repli vers l’écran physique n’a lieu. L’écran temporaire de vérification a été libéré. Aucun écran temporaire n’a été créé ; rien n’était à libérer. - Impossible de préparer le contexte système. Redémarrez Shizuku et Minis, puis réessayez. + Impossible de préparer le contexte système. Redémarrez Minis, puis réessayez. Le contexte système isolé est prêt. Fermez l’écran virtuel actuel, puis relancez la vérification. Création impossible. Fermez tout écran virtuel actif puis réessayez ; la ROM peut ne pas fournir les API requises. Un écran virtuel temporaire a été créé. - Cette étape a échoué. Consultez le détail de l’appareil et vérifiez l’autorisation Shizuku. - Une API cachée requise est indisponible dans le UserService Shizuku. VScreen ne peut pas être activé sur cet appareil. + Cette étape a échoué. Consultez le détail de l’appareil et vérifiez que Minis a le root. + Une API cachée requise est indisponible dans le service root. VScreen ne peut pas être activé sur cet appareil. Le clavier est dirigé vers l’écran virtuel. Cette ROM ne permet pas d’isoler le clavier. La saisie peut être instable ; testez avec prudence. La saisie a été refusée. Sur Xiaomi/HyperOS, activez « Débogage USB (paramètres de sécurité) » dans les options développeur. @@ -1850,13 +1852,13 @@ L’image est noire ; FLAG_SECURE ou une politique de capture non prise en charge peut bloquer les pixels. Utilisez l’arbre des nœuds UI ou demandez à l’utilisateur de reprendre la main. N’essayez ni OCR ni autre méthode de capture. Aucune image sûre n’est disponible. Les écrans sécurisés ou noirs sont volontairement bloqués. Une image non noire de l’écran virtuel a été capturée. - Démarrez Shizuku, autorisez Minis, puis relancez la vérification. - Shizuku est autorisé et le UserService shell est actif. - La vérification a expiré. Laissez Shizuku actif puis réessayez. - L’observation a été bloquée. Vérifiez l’autorisation Shizuku et la compatibilité. - L’observation de l’interface est disponible dans le service Shizuku. + Accordez le root à Minis, puis relancez la vérification. + Le root est accordé et le service d’écran virtuel tourne en root. + La vérification a expiré. Vérifiez que le root est accordé puis réessayez. + L’observation a été bloquée. Vérifiez le root et la compatibilité. + L’observation de l’interface est disponible dans le service root. Résultat non reconnu. Laissez VScreen désactivé et signalez ce code. - Le service ne s’est pas exécuté avec l’identité shell (2000) ou Root (0) autorisée. Redémarrez Shizuku et réessayez. + Le service ne s’est pas exécuté en Root (0). Accordez le root à Minis et réessayez. Libérer l’écran virtuel Écran virtuel libéré. Résolution et densité @@ -1874,7 +1876,7 @@ Saisie sur l’écran Lancement d’application sur l’écran virtuel Capture non noire - Autorisation Shizuku et identité du service + Autorisation root et identité du service Observation de l’interface Vérification de compatibilité Écran virtuel @@ -1953,9 +1955,6 @@ Utiliser l\'écran virtuel Autoriser l\'agent à utiliser l\'écran virtuel Interrupteur général ; les droits détaillés suivent - Shizuku - Connecté · utilisé par l’écran virtuel et les outils de l’agent - Non prêt · installer, lancer ou autoriser Shizuku Affichage par-dessus les autres applications Autorisé Désactivé · la mascotte et les commandes flottantes en ont besoin @@ -2065,7 +2064,7 @@ minis-model-use uniquement (pas un modèle de chat) Autorisation en un geste Tout autoriser - Accorde via root toutes les autorisations Android déclarées par l\'app et autorise tous les outils de l\'agent, y compris accessibilité, Shizuku et l\'écran virtuel. Désactiver remet les outils de l\'agent à leurs valeurs par défaut ; les autorisations Android restent. Partage aussi vos fournisseurs à clé API avec pi, Claude Code et Codex dans le terminal. + Accorde via root toutes les autorisations Android déclarées par l\'app et autorise tous les outils de l\'agent, y compris accessibilité et l\'écran virtuel. Désactiver remet les outils de l\'agent à leurs valeurs par défaut ; les autorisations Android restent. Partage aussi vos fournisseurs à clé API avec pi, Claude Code et Codex dans le terminal. Autorisation en cours… Redémarrer Minis pour se connecter À utiliser après avoir activé le module alors que Minis tournait déjà. diff --git a/src/android/app/src/main/res/values-ja/restricted_settings_strings.xml b/src/android/app/src/main/res/values-ja/restricted_settings_strings.xml index 0491e498..f30759a5 100644 --- a/src/android/app/src/main/res/values-ja/restricted_settings_strings.xml +++ b/src/android/app/src/main/res/values-ja/restricted_settings_strings.xml @@ -4,8 +4,8 @@ Android は、ダウンロードした APK ファイルからインストールしたアプリについて、ユーザー補助のスイッチを操作できないようにします。これは OS のセキュリティポリシーであり、Minis の不具合ではありません。インストール自体に問題はありません。制限された設定を許可してもスイッチが操作できるようになるだけで、それ自体が何かの権限を与えるわけではなく、サービスを有効にするかどうかは引き続きご自身で選べます。 制限された設定を許可 アプリ情報 → ⋮ メニュー(右上)→ 制限された設定を許可 - Shizuku で制限された設定を許可 - ワンタップ — Minis から離れずにフラグを解除します + Root で制限された設定を許可 + ワンタップ — Minis から離れずにフラグを解除します 許可しています… 失敗しました — 下記の手順で手動で設定してください diff --git a/src/android/app/src/main/res/values-ja/strings.xml b/src/android/app/src/main/res/values-ja/strings.xml index e3cba3e4..c8ba6c1d 100644 --- a/src/android/app/src/main/res/values-ja/strings.xml +++ b/src/android/app/src/main/res/values-ja/strings.xml @@ -62,19 +62,17 @@ エージェントの状態 実行中のエージェントセッションの状態を表示します。 ユーザー補助が無効になりました - Minis のユーザー補助権限が失われました。アプリを強制停止すると発生します。Shizuku が利用可能なため、設定を開かずにすぐ復元できます。 + Minis のユーザー補助権限が失われました。アプリを強制停止すると発生します。Root が許可されているため、設定を開かずにすぐ復元できます。 Minis のユーザー補助権限が失われました。アプリを強制停止すると発生します。「設定 → ユーザー補助」で Minis を再度有効にしてください。 今すぐ復元 設定を開く 後で 対応が必要 - ユーザー補助権限を復元 + ユーザー補助権限を復元 ユーザー補助を再有効化 強制停止により権限が取り消された可能性があります 復元中… 復元に失敗しました — 設定から有効にしてください - Android はアプリが強制停止されるたびにユーザー補助の許可を削除し、自動的には復元しません。Shizuku は直接書き戻せます。 - Android はアプリが強制停止されるたびにユーザー補助の許可を削除し、自動的には復元しません。Shizuku を導入すると次回からワンタップで復元できます。 Minis Agent 実行中 %1$s | %2$s | %3$s タスク完了 @@ -319,14 +317,14 @@ root が利用できないため、権限を自動で許可できません。 android-a11y-cli - android-shizuku-cli + android-root-cli minis-apt ユーザー補助 android-a11y-cli を使うと、エージェントは画面の UI 構造を読み取り、タップやジェスチャーを実行し、システムイベントを監視できます。下の 2 行をどちらも設定してください: エージェントの権限を選び、システム設定でユーザー補助サービスを有効化します。 - android-shizuku-cli は、ワイヤレス ADB または root で起動された Shizuku サービスを介して、エージェントに特権 Android API(パッケージインストール、settings put、強制停止など)へのアクセスを許可します。下の 2 行をどちらも設定してください。 エージェントが画面 UI を読み、タップ・入力し、イベントを監視できるようにします ユーザー補助設定を開く システム認可 + 未許可 エージェント権限 サービス有効 サービス無効 @@ -340,6 +338,18 @@ クリップボード 連絡先 写真 + アプリのインストール、アンインストール、一覧表示、照会 + 実行時権限の付与と取り消し + アクティビティの起動とブロードキャストの送信 + ディスプレイ設定の確認と調整 + system / secure / global 設定の読み書き + 端末ユーザーの一覧表示と管理 + ネットワーク状態の確認と切り替え + タップ、スワイプ、キー、テキストの入力 + 通知の投稿と管理 + 特権が必要なパスのファイル読み書き + 端末のプロパティとハードウェア状態の確認 + Root が使えるかを確認 スキル Skill を追加 インストール済み @@ -1056,12 +1066,6 @@ ダウンロードをキャンセル 失敗 開く - Sui をインストール - RikkaApps · root 済み端末 (Magisk/KernelSU) 向け · アプリ不要、再起動後も有効 - Sui (root) · version=%1$d, uid=%2$d - Sui は動作中です。「Minis を許可」をタップしてアクセスを許可してください — 確認ダイアログは Sui 自身が表示します。開くべき管理アプリはありません。 - Shizuku (RikkaApps)、AXManager (Axeron)、Sui (RikkaApps) のいずれか 1 つをインストールしてください — 3 つとも同じプロトコルで、互換性があります。Shizuku と AXManager はワイヤレス ADB で有効化する通常のアプリです。Sui は root 済み端末向けで、Magisk/KernelSU モジュールとしてインストールされ、アプリ不要で再起動後も有効なままです。KernelSU や APatch では、Sui に加えて Zygisk 実装 (Zygisk Next / ReZygisk / NeoZygisk) が必要です — これがないと Sui は読み込まれません。1 つインストールしてから、ここに戻ってください。 - Shizuku、AXManager、Sui のいずれか 1 つをインストールしてください。 バックアップと復元 暗号化したバックアップを書き出す、またはバックアップから復元します バックアップと復元 @@ -1389,7 +1393,6 @@ モジュール保護 ユーザー補助サービスを有効に保つ Minis モジュールがインストールされていないか、有効ではありません - Minis の LSPosed モジュールをインストールし、Minis にスコープした場合のみ動作します。モジュールは Shizuku なしでシステム内でユーザー補助サービスを有効に保ちます。 モジュール設定 LSPosed モジュールが読むスイッチ @@ -1489,7 +1492,7 @@ 改行キー、返信後のフォーカス、思考の展開、自動グループ テーマ、フォント、言語、アプリアイコン、デスクトップペット Rootfs、環境変数 - Root、Shizuku、エージェント権限、バックグラウンドと通知 + Root、エージェント権限、バックグラウンドと通知 ログ、更新、プライバシー ターミナルはセッションドロワーに、チームと定期タスクは Agent にあります。 %1$d 件の対応が必要です @@ -1532,7 +1535,7 @@ Minis が電源キーを引き継ぐには、まず「権限」でシステム既定のアシスタントに設定してください。 Agent の権限 Agent が自分でできること - Root の状態、Shizuku のインストールと認可 + Root の状態と使える機能 アプリ内タスク オーバーレイ オーバーレイ権限 他のアプリの上に重ねて表示 @@ -1905,7 +1908,7 @@ 無人実行の UI 操作は仮想画面のみを使用します。 仮想画面が有効でないか、端末チェックに失敗しています。保存はできますが UI 操作が失敗する可能性があります。 仮想ディスプレイ(実験的) - Shizuku 仮想画面と互換性セルフチェック + Root 仮想画面と互換性セルフチェック 無人実行で物理画面の操作を許可 リスク:バックグラウンドのルーチンが使用中の画面を操作する可能性があります。既定はオフです。仮想画面のみの使用がより安全です。 端末の互換性を確認 @@ -1913,11 +1916,10 @@ 仮想ディスプレイを有効にする 有効化の前に最新の端末セルフチェックを実行します。 高さ (px) - 試験的機能でデフォルトはオフです。Root または ADB 権限で動く Shizuku サービスが必要です(Root があれば Root で Shizuku を起動)。分離された仮想ディスプレイを 1 つだけ作り、物理画面には戻りません。 有効な仮想ディスプレイはありません。 サービスのプロセスが時間内に起動しませんでした。アプリを更新して再試行し、続く場合はこのコードを報告してください。 通過しなかった確認 - Shizuku を開く + Root 設定を開く 開発者向けオプション 失敗した項目を見る 仮想画面 @@ -1934,13 +1936,13 @@ アプリが仮想ディスプレイを離れました。操作を停止してください。物理画面へは切り替わりません。 一時セルフチェック画面を解放しました。 一時画面が作られなかったため解放対象はありません。 - システムコンテキストを準備できません。Shizuku と Minis を再起動してください。 + システムコンテキストを準備できません。Minis を再起動してください。 分離されたシステムコンテキストを利用できます。 現在の仮想ディスプレイを閉じてから再チェックしてください。 仮想ディスプレイを作成できません。開いている仮想画面を閉じて再試行してください。ROM が必要な API に対応していない可能性があります。 一時仮想ディスプレイを作成しました。 - このチェックに失敗しました。端末の詳細と Shizuku の許可を確認してください。 - 必要な hidden API を Shizuku UserService から利用できません。この端末では VScreen を有効にできません。 + このチェックに失敗しました。端末の詳細と Minis の Root 許可を確認してください。 + 必要な hidden API を Root サービスから利用できません。この端末では VScreen を有効にできません。 キーボードは仮想ディスプレイにルーティングされています。 この ROM ではキーボードを分離できません。文字入力が不安定な場合があります。 入力が拒否されました。Xiaomi/HyperOS では開発者向けオプションの「USB デバッグ(セキュリティ設定)」を有効にしてください。 @@ -1951,13 +1953,13 @@ 画像が黒く、FLAG_SECURE または未対応のキャプチャポリシーにより画素が遮断されている可能性があります。UI ノードツリーを使うか、ユーザーに操作を引き継いでもらってください。OCR や別のキャプチャ方法は使わないでください。 安全な画像を取得できません。セキュアまたは黒い画面は意図的にブロックされます。 黒くない仮想画面画像を取得しました。 - Shizuku を起動して Minis を許可し、互換性チェックを再実行してください。 - Shizuku が許可され、shell UserService が動作しています。 - 端末チェックがタイムアウトしました。Shizuku を起動したまま再試行してください。 - UI 観察がブロックされました。Shizuku の許可と端末互換性を確認してください。 - Shizuku サービス内で UI を観察できます。 + Minis に Root を許可してから、互換性チェックを再実行してください。 + Root が許可され、仮想画面サービスが root で動作しています。 + 端末チェックがタイムアウトしました。Root が許可されていることを確認して再試行してください。 + UI 観察がブロックされました。Root の許可と端末互換性を確認してください。 + Root サービス内で UI を観察できます。 認識できない結果です。VScreen をオフにして、このコードを報告してください。 - サービスが許可された shell(2000)または Root(0)の権限で動いていません。Shizuku を再起動して再試行してください。 + サービスが Root(0)の権限で動いていません。Minis に Root を許可して再試行してください。 仮想ディスプレイを解放 仮想ディスプレイを解放しました。 解像度と密度 @@ -1975,7 +1977,7 @@ 画面内入力 仮想画面でアプリを起動 黒くないスクリーンショット - Shizuku の許可とサービス ID + Root の許可とサービス ID UI の観察 互換性チェック 仮想ディスプレイ @@ -2054,9 +2056,6 @@ 仮想画面を操作 Agent に仮想画面の使用を許可 マスタースイッチ。以下は個別の権限 - Shizuku - 接続済み · 仮想スクリーンと Agent ツールで使用 - 未準備 · Shizuku のインストール、起動、許可が必要 他のアプリの上に重ねて表示 許可済み オフ · デスクトップペットとフローティング操作に必要 @@ -2166,7 +2165,7 @@ minis-model-use 専用(チャットモデルではありません) ワンタップ許可 すべて許可 - root でアプリが宣言したすべての Android 権限を付与し、ユーザー補助・Shizuku・仮想画面を含むエージェントの全ツールを許可します。オフにするとエージェント側は既定に戻り、Android の権限は残ります。API キー方式のプロバイダーをターミナル内の pi、Claude Code、Codex と共有します。 + root でアプリが宣言したすべての Android 権限を付与し、ユーザー補助・仮想画面を含むエージェントの全ツールを許可します。オフにするとエージェント側は既定に戻り、Android の権限は残ります。API キー方式のプロバイダーをターミナル内の pi、Claude Code、Codex と共有します。 許可しています… Minis を再起動して接続 Minis が起動中にモジュールを有効にした場合に使います。 diff --git a/src/android/app/src/main/res/values-ko/restricted_settings_strings.xml b/src/android/app/src/main/res/values-ko/restricted_settings_strings.xml index 6c73843c..520f7348 100644 --- a/src/android/app/src/main/res/values-ko/restricted_settings_strings.xml +++ b/src/android/app/src/main/res/values-ko/restricted_settings_strings.xml @@ -4,8 +4,8 @@ Android는 내려받은 APK 파일로 설치한 앱에 대해 접근성 스위치를 잠급니다. 이는 OS의 보안 정책이며 Minis의 문제가 아닙니다. 설치 자체에는 아무 이상이 없습니다. 제한된 설정을 허용하면 스위치를 조작할 수 있게 될 뿐이며, 그 자체로 어떤 권한이 부여되지는 않고 서비스를 켤지 여부는 여전히 직접 선택합니다. 제한된 설정 허용 앱 정보 → ⋮ 메뉴(오른쪽 위) → 제한된 설정 허용 - Shizuku로 제한된 설정 허용 - 한 번만 탭하면 Minis를 벗어나지 않고 해제됩니다 + Root로 제한된 설정 허용 + 한 번만 탭하면 Minis를 벗어나지 않고 해제됩니다 허용하는 중… 실패했습니다 — 아래의 수동 절차를 따르세요 diff --git a/src/android/app/src/main/res/values-ko/strings.xml b/src/android/app/src/main/res/values-ko/strings.xml index 13bd7731..817054d8 100644 --- a/src/android/app/src/main/res/values-ko/strings.xml +++ b/src/android/app/src/main/res/values-ko/strings.xml @@ -62,19 +62,17 @@ 에이전트 상태 실행 중인 에이전트 세션의 상태를 표시합니다. 접근성이 해제되었습니다 - Minis의 접근성 권한이 사라졌습니다. 앱을 강제 중지하면 발생합니다. Shizuku를 사용할 수 있으므로 설정을 열지 않고 바로 복구할 수 있습니다. + Minis의 접근성 권한이 사라졌습니다. 앱을 강제 중지하면 발생합니다. Root가 허용되어 있으므로 설정을 열지 않고 바로 복구할 수 있습니다. Minis의 접근성 권한이 사라졌습니다. 앱을 강제 중지하면 발생합니다. 계속하려면 설정 → 접근성에서 Minis를 다시 켜세요. 지금 복구 설정 열기 나중에 조치 필요 - 접근성 권한 복구 + 접근성 권한 복구 접근성 다시 켜기 강제 중지로 권한이 취소된 것 같습니다 복구 중… 복구 실패 — 설정에서 켜 주세요 - Android는 앱이 강제 중지될 때마다 접근성 권한을 제거하고 자동으로 복원하지 않습니다. Shizuku는 이를 직접 다시 기록할 수 있습니다. - Android는 앱이 강제 중지될 때마다 접근성 권한을 제거하고 자동으로 복원하지 않습니다. Shizuku를 설치·승인하면 앞으로 한 번에 복구할 수 있습니다. Minis 에이전트 실행 중 %1$s | %2$s | %3$s 작업 완료 @@ -319,14 +317,14 @@ root를 사용할 수 없어 권한을 자동으로 허용할 수 없습니다. android-a11y-cli - android-shizuku-cli + android-root-cli minis-apt 접근성 android-a11y-cli는 에이전트가 화면 UI 구조를 읽고, 탭과 제스처를 수행하며 시스템 이벤트를 감시할 수 있게 합니다. 아래 두 행을 모두 설정하세요: 에이전트 권한을 선택하고 시스템 설정에서 접근성 서비스를 활성화하세요. - android-shizuku-cli는 무선 ADB 또는 root로 시작된 Shizuku 서비스를 통해 에이전트에게 권한 있는 Android API(패키지 설치, settings put, 강제 중지 등)를 부여합니다. 아래 두 행을 모두 설정하세요. 에이전트가 화면 UI를 읽고, 탭/입력하며, 이벤트를 감시 접근성 설정 열기 시스템 인가 + 허용 안 됨 에이전트 권한 서비스 활성화됨 서비스 비활성화됨 @@ -340,6 +338,18 @@ 클립보드 연락처 사진 + 앱 설치, 제거, 목록 및 조회 + 런타임 권한 부여 또는 취소 + 액티비티 실행 및 브로드캐스트 전송 + 디스플레이 설정 조회 및 조정 + system / secure / global 설정 읽기 및 쓰기 + 기기 사용자 목록 및 관리 + 네트워크 상태 확인 및 전환 + 탭, 스와이프, 키, 텍스트 입력 + 알림 게시 및 관리 + 권한이 필요한 경로의 파일 읽기 및 쓰기 + 기기 속성 및 하드웨어 상태 조회 + Root 사용 가능 여부 확인 스킬 Skill 추가 설치됨 @@ -1048,12 +1058,6 @@ 다운로드 취소 실패 열기 - Sui 설치 - RikkaApps · 루팅된 기기 (Magisk/KernelSU) 용 · 앱 불필요, 재부팅 후에도 유지 - Sui (root) · version=%1$d, uid=%2$d - Sui가 실행 중입니다. "Minis 승인"을 눌러 접근을 허용하세요 — 확인 대화상자는 Sui가 직접 표시합니다. 따로 열어야 할 관리자 앱은 없습니다. - Shizuku (RikkaApps), AXManager (Axeron), Sui (RikkaApps) 중 하나를 설치하세요 — 세 가지 모두 동일한 프로토콜을 사용하며 서로 교체 가능합니다. Shizuku와 AXManager는 무선 ADB로 활성화하는 일반 앱입니다. Sui는 이미 루팅된 기기용으로, Magisk/KernelSU 모듈로 설치되며 앱이 필요 없고 재부팅 후에도 활성 상태를 유지합니다. KernelSU 또는 APatch에서는 Sui에 추가로 Zygisk 구현(Zygisk Next / ReZygisk / NeoZygisk)이 필요합니다 — 없으면 Sui가 로드되지 않습니다. 하나를 설치한 뒤 여기로 돌아오세요. - Shizuku, AXManager 또는 Sui 중 하나를 설치하세요. 백업 및 복원 암호화된 백업을 내보내거나 백업에서 복원합니다 백업 및 복원 @@ -1381,7 +1385,6 @@ 모듈 보호 접근성 서비스 활성 상태 유지 Minis 모듈이 설치되어 있지 않거나 활성 상태가 아닙니다 - Minis LSPosed 모듈을 설치하고 Minis 범위로 지정한 경우에만 동작합니다. 모듈은 Shizuku 없이 시스템에서 접근성 서비스를 활성 상태로 유지합니다. 모듈 설정 LSPosed 모듈이 읽는 스위치 @@ -1481,7 +1484,7 @@ 리턴 키, 답변 후 포커스, 생각 펼치기, 자동 분류 테마, 글꼴, 언어, 앱 아이콘, 데스크톱 펫 Rootfs, 환경 변수 - Root, Shizuku, 에이전트 권한, 백그라운드 및 알림 + Root, 에이전트 권한, 백그라운드 및 알림 로그, 업데이트, 개인정보 터미널은 세션 서랍에, 팀과 예약 작업은 Agent에 있습니다. %1$d개 항목 확인 필요 @@ -1524,7 +1527,7 @@ Minis가 전원 키를 가져오려면 먼저 \'권한\'에서 시스템 기본 어시스턴트로 설정하세요. Agent 권한 에이전트가 스스로 할 수 있는 일 - Root 상태, Shizuku 설치 및 승인 + Root 상태와 사용 가능한 기능 앱 내 작업 오버레이 오버레이 권한 다른 앱 위에 표시 @@ -1897,7 +1900,7 @@ 무인 실행의 UI 작업은 가상 화면만 사용합니다. 가상 디스플레이가 켜져 있지 않거나 기기 검사를 통과하지 못했습니다. 저장은 가능하지만 UI 작업이 실패할 수 있습니다. 가상 디스플레이(실험 기능) - Shizuku 가상 화면 및 호환성 자체 검사 + Root 가상 화면 및 호환성 자체 검사 무인 실행에서 실제 화면 조작 허용 위험: 백그라운드 루틴이 사용 중인 화면을 조작할 수 있습니다. 기본값은 끔이며 가상 화면만 사용하는 편이 안전합니다. 기기 호환성 확인 @@ -1905,11 +1908,10 @@ 가상 디스플레이 사용 사용하기 전에 최신 기기 자체 검사를 실행합니다. 높이 (px) - 실험적 기능이며 기본값은 꺼짐입니다. Root 또는 ADB 권한으로 실행되는 Shizuku 서비스가 필요합니다(Root가 있으면 Root로 Shizuku를 시작하세요). 격리된 가상 디스플레이 하나만 만들며 물리 화면으로 되돌아가지 않습니다. 활성 가상 디스플레이가 없습니다. 서비스 프로세스가 제때 시작되지 않았습니다. 앱을 업데이트한 뒤 다시 시도하고, 계속되면 이 코드를 알려 주세요. 통과하지 못한 검사 - Shizuku 열기 + Root 설정 열기 개발자 옵션 통과하지 못한 항목 보기 가상 화면 @@ -1926,13 +1928,13 @@ 앱이 가상 디스플레이를 벗어났습니다. 작업을 중지하세요. 실제 화면으로 전환하지 않습니다. 임시 자체 검사 디스플레이를 해제했습니다. 임시 디스플레이를 만들지 않아 해제할 것이 없습니다. - 시스템 컨텍스트를 설정하지 못했습니다. Shizuku와 Minis를 다시 시작하세요. + 시스템 컨텍스트를 설정하지 못했습니다. Minis를 다시 시작하세요. 분리된 시스템 컨텍스트를 사용할 수 있습니다. 현재 가상 디스플레이를 닫은 뒤 호환성 검사를 다시 실행하세요. 가상 디스플레이를 만들지 못했습니다. 활성 디스플레이를 닫고 재시도하세요. ROM이 필요한 API를 지원하지 않을 수 있습니다. 임시 가상 디스플레이를 만들었습니다. - 검사에 실패했습니다. 기기 세부 정보와 Shizuku 권한을 확인한 뒤 다시 시도하세요. - 필요한 hidden API를 Shizuku UserService에서 사용할 수 없습니다. 이 기기에서 VScreen을 켤 수 없습니다. + 검사에 실패했습니다. 기기 세부 정보와 Minis의 Root 권한을 확인한 뒤 다시 시도하세요. + 필요한 hidden API를 Root 서비스에서 사용할 수 없습니다. 이 기기에서 VScreen을 켤 수 없습니다. 키보드가 가상 디스플레이로 전달됩니다. 이 ROM에서는 키보드 라우팅을 분리할 수 없습니다. 텍스트 입력이 불안정할 수 있습니다. 입력이 거부되었습니다. Xiaomi/HyperOS는 개발자 옵션에서 USB 디버깅(보안 설정)을 켜고 다시 시도하세요. @@ -1943,13 +1945,13 @@ 화면이 검게 표시됩니다. FLAG_SECURE 또는 지원되지 않는 캡처 정책이 픽셀을 차단했을 수 있습니다. UI 노드 트리를 사용하거나 사용자에게 직접 조작해 달라고 요청하세요. OCR이나 다른 캡처 경로는 사용하지 마세요. 안전한 이미지를 가져오지 못했습니다. 보안 화면이나 검은 화면은 의도적으로 차단됩니다. 검지 않은 가상 화면 이미지를 캡처했습니다. - Shizuku를 시작하고 Minis를 승인한 다음 호환성 검사를 다시 실행하세요. - Shizuku가 승인되었고 shell UserService가 실행 중입니다. - 기기 검사가 시간 초과되었습니다. Shizuku를 실행한 상태로 다시 시도하세요. - UI 관찰이 차단되었습니다. Shizuku 권한과 기기 호환성을 확인하세요. - Shizuku 서비스에서 UI를 관찰할 수 있습니다. + Minis에 Root를 허용한 다음 호환성 검사를 다시 실행하세요. + Root가 허용되었고 가상 화면 서비스가 root로 실행 중입니다. + 기기 검사가 시간 초과되었습니다. Root가 허용되었는지 확인한 뒤 다시 시도하세요. + UI 관찰이 차단되었습니다. Root 권한과 기기 호환성을 확인하세요. + Root 서비스에서 UI를 관찰할 수 있습니다. 알 수 없는 검사 결과입니다. VScreen을 끄고 이 코드를 알려 주세요. - 서비스가 허용된 shell(2000) 또는 Root(0) 권한으로 실행되지 않았습니다. Shizuku를 다시 시작한 뒤 재시도하세요. + 서비스가 Root(0) 권한으로 실행되지 않았습니다. Minis에 Root를 허용한 뒤 재시도하세요. 가상 디스플레이 해제 가상 디스플레이를 해제했습니다. 해상도 및 밀도 @@ -1967,7 +1969,7 @@ 디스플레이 대상 입력 가상 화면에서 앱 실행 검은 화면이 아닌 캡처 - Shizuku 권한 및 서비스 신원 + Root 권한 및 서비스 신원 UI 관찰 호환성 검사 가상 디스플레이 @@ -2046,9 +2048,6 @@ 가상 화면 조작 에이전트의 가상 화면 사용 허용 전체 스위치이며 아래는 개별 권한 - Shizuku - 연결됨 · 가상 화면과 에이전트 도구에서 사용 - 준비 안 됨 · Shizuku 설치, 시작 또는 허용 필요 다른 앱 위에 표시 허용됨 꺼짐 · 데스크톱 펫과 플로팅 컨트롤에 필요 @@ -2158,7 +2157,7 @@ minis-model-use 전용(채팅 모델 아님) 원탭 권한 허용 모두 허용 - root로 앱이 선언한 모든 Android 권한을 부여하고, 접근성·Shizuku·가상 화면을 포함한 에이전트의 모든 도구를 허용합니다. 끄면 에이전트 권한은 기본값으로 돌아가고 Android 권한은 그대로 남습니다. API 키 방식 공급자를 터미널의 pi, Claude Code, Codex와도 공유합니다. + root로 앱이 선언한 모든 Android 권한을 부여하고, 접근성·가상 화면을 포함한 에이전트의 모든 도구를 허용합니다. 끄면 에이전트 권한은 기본값으로 돌아가고 Android 권한은 그대로 남습니다. API 키 방식 공급자를 터미널의 pi, Claude Code, Codex와도 공유합니다. 권한 부여 중… Minis를 재시작하여 연결 Minis가 실행 중일 때 모듈을 활성화한 경우 사용하세요. diff --git a/src/android/app/src/main/res/values-ru/restricted_settings_strings.xml b/src/android/app/src/main/res/values-ru/restricted_settings_strings.xml index 129d47df..a12b4672 100644 --- a/src/android/app/src/main/res/values-ru/restricted_settings_strings.xml +++ b/src/android/app/src/main/res/values-ru/restricted_settings_strings.xml @@ -4,8 +4,8 @@ Android блокирует переключатель специальных возможностей для приложений, установленных из загруженного APK-файла. Это политика безопасности системы, а не ошибка Minis — с вашей установкой всё в порядке. Разрешение ограниченных настроек лишь возвращает переключатель в рабочее состояние; само по себе оно ничего не предоставляет, и вы по-прежнему решаете, включать службу или нет. Разрешить ограниченные настройки О приложении → меню ⋮ (вверху справа) → Разрешить ограниченные настройки - Разрешить ограниченные настройки через Shizuku - Одно нажатие — снимает флаг, не выходя из Minis + Разрешить ограниченные настройки через root + Одно нажатие — снимает флаг, не выходя из Minis Разрешение… Не удалось — выполните шаги вручную ниже diff --git a/src/android/app/src/main/res/values-ru/strings.xml b/src/android/app/src/main/res/values-ru/strings.xml index 5d363336..424719d1 100644 --- a/src/android/app/src/main/res/values-ru/strings.xml +++ b/src/android/app/src/main/res/values-ru/strings.xml @@ -72,19 +72,17 @@ Отменено Время истекло Специальные возможности отключены - Minis потерял разрешение специальных возможностей — это происходит после принудительной остановки приложения. Shizuku доступен и может восстановить его сразу, не открывая настройки. + Minis потерял разрешение специальных возможностей — это происходит после принудительной остановки приложения. Root предоставлен, поэтому его можно восстановить сразу, не открывая настройки. Minis потерял разрешение специальных возможностей — это происходит после принудительной остановки приложения. Включите Minis снова в «Настройки → Специальные возможности». Восстановить Открыть настройки Не сейчас Требует внимания - Восстановить разрешение + Восстановить разрешение Снова включить специальные возможности Разрешение было отозвано, вероятно принудительной остановкой Восстановление… Не удалось восстановить — включите в настройках - Android удаляет разрешение специальных возможностей при каждой принудительной остановке приложения и не восстанавливает его автоматически. Shizuku может записать его напрямую. - Android удаляет разрешение специальных возможностей при каждой принудительной остановке приложения и не восстанавливает его автоматически. Установите и авторизуйте Shizuku, чтобы восстанавливать одним касанием. Агент Minis активен %1$s | %2$s | %3$s Задача выполнена @@ -543,14 +541,14 @@ Не удалось выдать некоторые разрешения: %1$s Root недоступен, поэтому разрешения нельзя выдать автоматически. android-a11y-cli - android-shizuku-cli + android-root-cli minis-apt Специальные возможности android-a11y-cli позволяет агенту считывать структуру UI на экране, выполнять нажатия и жесты, а также отслеживать системные события. Должны быть заданы обе строки ниже: выберите разрешение Агента и включите службу Специальных возможностей в системных Настройках. - android-shizuku-cli предоставляет агенту привилегированные API Android (установка пакетов, settings put, принудительная остановка и др.) через службу Shizuku, авторизованную по беспроводному ADB или root. Должны быть заданы обе строки ниже. Позволяет агенту считывать UI экрана, нажимать, вводить текст и отслеживать события Открыть настройки Специальных возможностей Системная авторизация + Не предоставлен Разрешение агента Служба включена Служба не включена @@ -564,49 +562,18 @@ Буфер обмена Контакты Фото - Root и Shizuku - Статус - Shizuku предоставляет приложениям пользователя привилегированные системные API без root, запуская небольшую службу через беспроводной ADB или root. Поддерживаются два совместимых менеджера — Shizuku (RikkaApps) и AXManager (Axeron); установите любой из них. После авторизации Minis может использовать CLI android-shizuku-cli для управления пакетами, разрешениями, настройками, вводом и активностями. - Следующий шаг - Возможности - Выполните `android-shizuku-cli --help` в оболочке для полной справки. Набор команд одинаков независимо от того, откуда binder — Shizuku, AXManager или Sui. - Установка, удаление, список и запрос приложений - Выдача или отзыв разрешений времени выполнения - Запуск activity и отправка broadcast - Просмотр и настройка параметров дисплея - Чтение и запись system / secure / global настроек - Список и управление пользователями устройства - Проверка и переключение состояния сети - Ввод касаний, свайпов, клавиш и текста - Отправка и управление уведомлениями - Чтение и запись привилегированных путей - Запрос свойств устройства и состояния оборудования - Вызов и просмотр системных служб - Установите одно из: Shizuku (RikkaApps), AXManager (Axeron) или Sui (RikkaApps) — все три используют один протокол и взаимозаменяемы. Shizuku и AXManager — обычные приложения, активируемые через беспроводной ADB. Sui предназначен для устройств с root: устанавливается как модуль Magisk/KernelSU, не требует приложения и остаётся активным после перезагрузки. На KernelSU или APatch для Sui дополнительно нужна реализация Zygisk (Zygisk Next / ReZygisk / NeoZygisk) — без неё Sui не загрузится. Установите одно и вернитесь сюда. - Откройте приложение-менеджер и запустите службу через беспроводной ADB (без ПК) или root. После того как отобразится «работает», вернитесь сюда.\n\nИспользуете AXManager? Также включите «Настройки → AX-Permission Intercept» внутри AXManager — только этот переключатель заставляет AXManager отправлять Shizuku-совместимый binder в Minis. - Служба работает. Нажмите «Авторизовать Minis», чтобы предоставить доступ. Системный диалог приложения-менеджера попросит вас подтвердить. - Менеджер не установлен - Установите Shizuku, AXManager или Sui — подойдёт любой. - Служба не запущена - Откройте приложение-менеджер и нажмите «Старт». - Требуется авторизация - Служба работает, но Minis не авторизован. - Готов - версия=%1$d, uid=%2$d - Не установлен - Остановлен - Разрешение - Готов - Установить Shizuku - RikkaApps · откроет страницу релизов GitHub - Установить AXManager - Axeron · откроет страницу релизов GitHub - Открыть приложение-менеджер - Запустить установленный менеджер для старта или управления службой - Перепроверить - Повторно опросить состояние после запуска службы - Авторизовать Minis - Показать диалог разрешений + Установка, удаление, список и запрос приложений + Выдача или отзыв разрешений времени выполнения + Запуск activity и отправка broadcast + Просмотр и настройка параметров дисплея + Чтение и запись system / secure / global настроек + Список и управление пользователями устройства + Проверка и переключение состояния сети + Ввод касаний, свайпов, клавиш и текста + Отправка и управление уведомлениями + Чтение и запись привилегированных путей + Запрос свойств устройства и состояния оборудования + Проверить, доступен ли root Навыки Добавить навык Установлено @@ -1247,10 +1214,6 @@ Отменить загрузку Сбой Открыть - Установить Sui - RikkaApps · для устройств с root (Magisk/KernelSU) · без приложения, работает после перезагрузки - Sui (root) · версия=%1$d, uid=%2$d - Sui запущен. Нажмите «Авторизовать Minis», чтобы предоставить доступ — Sui сам покажет диалог подтверждения. Отдельного приложения-менеджера открывать не нужно. Резервное копирование Экспортировать зашифрованную копию или восстановиться из неё Резервное копирование @@ -1584,7 +1547,6 @@ Защита модулем Поддерживать службу доступности включённой Модуль Minis не установлен или неактивен - Работает только с установленным модулем LSPosed Minis, включённым в область Minis. Модуль сам поддерживает службу доступности включённой, без Shizuku. Настройки модуля Переключатели, которые читает модуль LSPosed @@ -1684,7 +1646,7 @@ Клавиша Enter, фокус после ответа, размышления, группировка Тема, шрифты, язык, значок, питомец Rootfs, переменные окружения - Root, Shizuku, права агента, фон и уведомления + Root, права агента, фон и уведомления Журналы, обновления, конфиденциальность Терминал — в боковой панели; команда и задачи по расписанию — в разделе Agent. Требуют внимания: %1$d @@ -1727,7 +1689,7 @@ Minis перехватит кнопку питания только после того, как станет ассистентом системы — задайте это в «Разрешениях». Разрешения агента Что агент может делать сам - Состояние root, установка и авторизация Shizuku + Статус root и что он открывает Разрешение на оверлей Поверх других приложений Разрешено @@ -2125,7 +2087,7 @@ В фоновом режиме UI-действия используют только виртуальный экран. Виртуальный экран не включён с успешно пройденной проверкой. Сценарий можно сохранить, но UI-действия могут завершиться ошибкой. Виртуальный экран (экспериментально) - Виртуальный экран Shizuku и проверка совместимости + Виртуальный экран через root и проверка совместимости Разрешить фоновые действия на физическом экране Риск: фоновый сценарий сможет управлять используемым вами экраном. По умолчанию выключено; виртуальный экран безопаснее. Проверить совместимость устройства @@ -2133,11 +2095,10 @@ Включить виртуальный экран Перед включением запускается свежая проверка устройства. Высота (px) - Экспериментально. Нужна служба Shizuku с правами Root или ADB (при наличии Root запустите Shizuku через Root). По умолчанию выключено, создаёт один изолированный виртуальный дисплей и никогда не переключается на физический экран. Активного виртуального экрана нет. Процесс службы не запустился вовремя. Обновите приложение и повторите; если повторяется, сообщите этот код. Эти проверки не пройдены - Открыть Shizuku + Открыть настройки root Для разработчиков Показать, что не прошло Виртуальный экран @@ -2154,13 +2115,13 @@ Приложение покинуло виртуальный экран. Остановите действие; перехода на физический экран не будет. Временный экран самопроверки освобождён. Временный экран не создавался, освобождать нечего. - Не удалось настроить системный контекст. Перезапустите Shizuku и Minis. + Не удалось настроить системный контекст. Перезапустите Minis. Изолированный системный контекст готов. Закройте текущий виртуальный экран и запустите проверку снова. Не удалось создать виртуальный экран. Закройте активный виртуальный экран и повторите попытку; возможно, в ROM нет нужных API. Временный виртуальный экран успешно создан. - Этот шаг завершился ошибкой. Проверьте сведения устройства и разрешение Shizuku. - Нужный скрытый API недоступен в Shizuku UserService. На этом устройстве VScreen нельзя включить. + Этот шаг завершился ошибкой. Проверьте сведения устройства и наличие root у Minis. + Нужный скрытый API недоступен в службе root. На этом устройстве VScreen нельзя включить. Клавиатура направляется на виртуальный экран. В этой ROM нельзя изолировать клавиатуру. Ввод текста может работать нестабильно. Ввод отклонён. На Xiaomi/HyperOS включите в параметрах разработчика «Отладка по USB (настройки безопасности)». @@ -2171,13 +2132,13 @@ Изображение чёрное; FLAG_SECURE или неподдерживаемая политика захвата может блокировать пиксели. Используйте дерево UI-узлов или попросите пользователя продолжить управление. Не применяйте OCR и другие способы захвата. Безопасное изображение недоступно. Защищённые и чёрные экраны намеренно блокируются. Получено непустое изображение виртуального экрана. - Запустите Shizuku, авторизуйте Minis и повторите проверку совместимости. - Shizuku авторизован, shell UserService работает. - Проверка устройства превысила время ожидания. Оставьте Shizuku запущенным и повторите попытку. - Наблюдение за интерфейсом заблокировано. Проверьте разрешение Shizuku и совместимость. - Наблюдение за интерфейсом доступно в службе Shizuku. + Предоставьте Minis root и повторите проверку совместимости. + Root предоставлен, служба виртуального экрана работает от root. + Проверка устройства превысила время ожидания. Убедитесь, что root предоставлен, и повторите попытку. + Наблюдение за интерфейсом заблокировано. Проверьте root и совместимость. + Наблюдение за интерфейсом доступно в службе root. Неизвестный результат проверки. Оставьте VScreen выключенным и сообщите этот код. - Служба запущена не от имени разрешённого shell (2000) или Root (0). Перезапустите Shizuku и повторите. + Служба запущена не от имени Root (0). Предоставьте Minis root и повторите. Освободить виртуальный экран Виртуальный экран освобождён. Разрешение и плотность @@ -2195,7 +2156,7 @@ Ввод на дисплей Запуск приложения на виртуальном экране Нечёрный снимок экрана - Разрешение Shizuku и идентификатор службы + Разрешение root и идентификатор службы Наблюдение за интерфейсом Проверка совместимости Виртуальный экран @@ -2274,9 +2235,6 @@ Управлять виртуальным экраном Разрешить агенту виртуальный экран Главный переключатель; ниже отдельные права - Shizuku - Подключено · для виртуального экрана и инструментов агента - Не готово · установите, запустите или разрешите Shizuku Поверх других приложений Разрешено Выкл. · нужно питомцу и плавающим элементам @@ -2386,7 +2344,7 @@ Только minis-model-use (не чат-модель) Разрешить одним нажатием Разрешить всё - Через root выдаёт все разрешения Android, объявленные приложением, и разрешает все инструменты агента, включая спецвозможности, Shizuku и виртуальный экран. При выключении инструменты агента возвращаются к значениям по умолчанию; разрешения Android остаются. Также передаёт ключи API-провайдеров pi, Claude Code и Codex в терминале. + Через root выдаёт все разрешения Android, объявленные приложением, и разрешает все инструменты агента, включая спецвозможности и виртуальный экран. При выключении инструменты агента возвращаются к значениям по умолчанию; разрешения Android остаются. Также передаёт ключи API-провайдеров pi, Claude Code и Codex в терминале. Выдача разрешений… Перезапустить Minis для подключения Используйте, если модуль включён, когда Minis уже работал. diff --git a/src/android/app/src/main/res/values-zh-rTW/restricted_settings_strings.xml b/src/android/app/src/main/res/values-zh-rTW/restricted_settings_strings.xml index facfce2a..c2012a1f 100644 --- a/src/android/app/src/main/res/values-zh-rTW/restricted_settings_strings.xml +++ b/src/android/app/src/main/res/values-zh-rTW/restricted_settings_strings.xml @@ -4,8 +4,8 @@ 對於透過下載的 APK 檔案安裝的應用,Android 會鎖定無障礙開關。這是系統的安全策略,不是 Minis 的問題,你的安裝包沒有損壞。「允許受限設定」只是把開關解鎖,本身不授予任何許可權,是否開啟該服務仍由你決定。 允許受限設定 應用詳情 → 右上角 ⋮ 選單 → 允許受限設定 - 透過 Shizuku 允許受限設定 - 一鍵解除,無需離開 Minis + 透過 Root 允許受限設定 + 一鍵解除,無需離開 Minis 正在解除… 解除失敗 — 請按下方步驟手動操作 diff --git a/src/android/app/src/main/res/values-zh-rTW/strings.xml b/src/android/app/src/main/res/values-zh-rTW/strings.xml index 123c39a1..91647b1c 100644 --- a/src/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/src/android/app/src/main/res/values-zh-rTW/strings.xml @@ -558,14 +558,14 @@ android-a11y-cli - android-shizuku-cli + android-root-cli minis-apt 無障礙 android-a11y-cli 讓 Agent 讀取螢幕 UI、模擬點擊與輸入、監聽系統事件。下方兩項都需配置:選擇 Agent 權限,並在系統設定啟用無障礙服務。 - android-shizuku-cli 透過 Shizuku 服務呼叫 Android 高權限 API(安裝應用、settings put、強制停止等)。Shizuku 由無線 ADB 或 root 啟動。下方兩項都需配置。 讓 Agent 讀取螢幕、點擊、輸入、監聽事件 前往無障礙設定 系統授權 + 未授權 Agent 權限 已啟用 未啟用 @@ -728,12 +728,6 @@ 取消下載 失敗 開啟 - 安裝 Sui - RikkaApps · 適用於已 root 裝置(Magisk/KernelSU)· 無需 App,重啟後依然生效 - Sui(root)· 版本=%1$d, uid=%2$d - Sui 正在執行。點擊「授權 Minis」以授予存取權限 —— 確認對話框由 Sui 自己彈出,沒有單獨的管理器 App 需要開啟。 - 安裝 Shizuku(RikkaApps)、AXManager(Axeron)或 Sui(RikkaApps)其中任一個 —— 三者實作同一套協定,可以互換。Shizuku 與 AXManager 是一般 App,透過無線 ADB 啟用。Sui 針對已經 root 的裝置:它以 Magisk/KernelSU 模組的形式安裝,無需 App,且重啟後依然保持生效。在 KernelSU 或 APatch 上,Sui 還需要額外的 Zygisk 實作(Zygisk Next / ReZygisk / NeoZygisk)—— 沒有它 Sui 不會載入。安裝其中一個後返回此頁。 - 安裝 Shizuku、AXManager 或 Sui —— 任意一個即可。 移入分組 更換分組 移入分組 @@ -933,7 +927,6 @@ 模組保護 保持無障礙服務開啟 Minis 模組未安裝或未啟用 - 僅在已安裝 LSPosed 模組並作用於 Minis 時可用;模組會在系統內保持無障礙服務開啟,無需 Shizuku。 模組設定 LSPosed 模組讀取的開關 @@ -1033,7 +1026,7 @@ Enter 鍵、回覆後聚焦、思考展開、自動歸類 主題、字型、語言、應用程式圖示、桌寵 Rootfs、環境變數 - Root、Shizuku、Agent 權限、背景與通知 + Root、Agent 權限、背景與通知 日誌、更新、隱私 終端機在對話抽屜裡;團隊和排程任務在 Agent 裡。 %1$d 項需要處理 @@ -1076,7 +1069,7 @@ 要讓 Minis 接管電源鍵,先在「權限」裡把它設為系統預設助理。 Agent 權限 Agent 可以自己做哪些事 - Root 狀態、Shizuku 安裝與授權 + Root 狀態與可用功能 應用程式內任務浮窗 懸浮視窗權限 顯示在其他應用程式上層 @@ -1197,19 +1190,17 @@ 需要授予\"顯示在其他應用上層\"許可權,切換到其他應用時 Minis 才能顯示當前工具。點選進入授權頁面。 關閉 無障礙服務已關閉 - Minis 的無障礙許可權已丟失——強行停止應用後會出現這種情況。檢測到 Shizuku 可用,可以立即恢復,無需前往系統設定。 + Minis 的無障礙許可權已丟失——強行停止應用後會出現這種情況。已取得 Root,可以立即恢復,無需前往系統設定。 Minis 的無障礙許可權已丟失——強行停止應用後會出現這種情況。請在「設定 → 無障礙」中重新開啟 Minis。 立即恢復 開啟設定 暫不 需要處理 - 恢復無障礙許可權 + 恢復無障礙許可權 重新開啟無障礙 許可權已被撤銷,可能是強行停止導致的 正在恢復… 恢復失敗——請到系統設定中開啟 - 每次應用被強行停止,Android 都會移除無障礙授權,且不會自動恢復。Shizuku 可以直接寫回該授權。 - 每次應用被強行停止,Android 都會移除無障礙授權,且不會自動恢復。安裝並授權 Shizuku 後,今後可一鍵恢復。 Minis Agent 執行中 %1$s | %2$s | %3$s 任務已完成 @@ -1445,49 +1436,20 @@ 剪貼簿 通訊錄 照片 - Root 與 Shizuku - 狀態 - Shizuku 透過無線 ADB 或 root 啟動一個本地服務,讓使用者應用無需 root 即可呼叫 Android 系統隱藏 API。支援兩款相容的管理器:Shizuku(RikkaApps)和 AXManager(Axeron),任意安裝一個即可。授權後,Minis 可使用 android-shizuku-cli 命令管理包、許可權、設定、輸入、Activity 等。 - 下一步 - 可用能力 - 在終端執行 `android-shizuku-cli --help` 檢視完整用法。無論 binder 來自 Shizuku、AXManager 還是 Sui,命令集完全一致。 - 安裝、解除安裝、列出和查詢應用 - 授予或撤銷執行時許可權 - 啟動 Activity、傳送廣播 - 查詢和調整顯示設定 - 讀寫 system / secure / global 設定 - 列出和管理裝置使用者 - 檢視和切換網路狀態 - 注入點選、滑動、按鍵和文字 - 釋出和管理通知 - 讀寫受保護的檔案路徑 - 查詢裝置屬性和硬體狀態 - 呼叫和檢視系統服務 - 開啟管理器應用,透過無線 ADB(無需電腦)或 root 啟動服務,狀態變為執行後返回此頁面。\n\n使用 AXManager 時,還需在 AXManager 的"設定 → AX-Permission Intercept"中開啟開關,AXManager 才會向 Minis 推送 Shizuku 相容 binder。 - 服務已執行。點選「授權 Minis」會彈出管理器的系統授權對話方塊。 - 未安裝管理器 - 服務未執行 - 請開啟管理器應用並啟動服務。 - 需要授權 - 服務已執行,但 Minis 尚未獲得授權。 - 已就緒 - 版本=%1$d,uid=%2$d - 未安裝 - 未啟動 - 待授權 - 就緒 - 安裝 Shizuku - RikkaApps · 開啟 GitHub Releases 頁面 - 安裝 AXManager - Axeron · 開啟 GitHub Releases 頁面 - 開啟管理器應用 - 啟動已安裝的管理器以啟動或管理服務 - 重新檢測 - 啟動服務後重新檢查狀態 - 授權 Minis - 彈出授權對話方塊 + 安裝、解除安裝、列出和查詢應用 + 授予或撤銷執行時許可權 + 啟動 Activity、傳送廣播 + 查詢和調整顯示設定 + 讀寫 system / secure / global 設定 + 列出和管理裝置使用者 + 檢視和切換網路狀態 + 注入點選、滑動、按鍵和文字 + 釋出和管理通知 + 讀寫受保護的檔案路徑 + 查詢裝置屬性和硬體狀態 + 檢查 Root 是否可用 特權後端 - 允許 Agent 透過特權後端(Shizuku 或 AXManager)執行 ADB 級系統命令 + 允許 Agent 以 root 身分執行 ADB 級系統命令(pm、am、settings、input…) 技能 新增 Skill 已安裝 @@ -2120,7 +2082,7 @@ 無人值守執行的 UI 操作只會使用虛擬螢幕。 虛擬螢幕尚未在目前裝置自我檢查通過並啟用。仍可儲存例程,但 UI 操作可能失敗。 虛擬螢幕(實驗性) - Shizuku 虛擬螢幕與裝置相容性自我檢查 + Root 虛擬螢幕與裝置相容性自我檢查 允許無人值守時操作實體螢幕 風險:背景例程可能控制您正在使用的螢幕。預設關閉;只使用虛擬螢幕較安全。 檢查裝置相容性 @@ -2128,11 +2090,10 @@ 啟用虛擬螢幕 啟用前會先執行目前裝置的自我檢查。 高度(px) - 實驗性功能,預設關閉。需要一個以 Root 或 ADB 身分執行的 Shizuku 服務(有 Root 時用 Root 啟動 Shizuku 即可)。它只建立一個隔離的虛擬顯示器,絕不會回退到實體螢幕。 目前沒有虛擬螢幕。 服務程序沒有及時啟動。請更新應用程式後重試;仍然出現請回報此代碼。 這些檢測沒有通過 - 前往 Shizuku + 前往 Root 設定 開發人員選項 查看未通過的項目 虛擬螢幕 @@ -2149,13 +2110,13 @@ App 已離開虛擬螢幕。請停止操作;不會退回實體螢幕。 暫存自我檢查螢幕已釋放。 未建立暫存顯示器,因此無須釋放。 - 無法建立系統內容環境。請重新啟動 Shizuku 與 Minis 後再試。 + 無法建立系統內容環境。請重新啟動 Minis 後再試。 隔離的系統內容環境已就緒。 請關閉目前虛擬螢幕,再重新檢查相容性。 無法建立虛擬顯示器。請先關閉目前虛擬螢幕再試;若仍失敗,裝置 ROM 可能不支援所需 API。 已成功建立暫存虛擬顯示器。 - 此檢查步驟失敗。請檢視裝置詳細資訊並確認 Shizuku 已授權。 - Shizuku UserService 無法使用必要的隱藏 API。此裝置不能啟用 VScreen。 + 此檢查步驟失敗。請檢視裝置詳細資訊並確認 Minis 已取得 Root。 + Root 服務無法使用必要的隱藏 API。此裝置不能啟用 VScreen。 鍵盤已路由至虛擬顯示器。 此 ROM 無法隔離鍵盤路由,文字輸入可能不穩定,請先謹慎測試。 輸入注入遭拒。小米/HyperOS 請在開發人員選項中開啟「USB 偵錯(安全性設定)」。 @@ -2166,13 +2127,13 @@ 畫面為黑屏;FLAG_SECURE 或系統不支援的擷取政策可能阻擋像素擷取。請改用 UI 節點樹,或請使用者接手。不要嘗試 OCR 或其他擷取方式。 沒有可安全顯示的影像。受保護或全黑畫面會被刻意阻擋。 已擷取到非黑畫面的虛擬螢幕影像。 - 請啟動 Shizuku 並授權 Minis,然後重新執行相容性檢查。 - Shizuku 已授權,shell UserService 正在執行。 - 裝置檢查逾時。請讓 Shizuku 保持執行後再試。 - 介面觀察遭到阻擋。請檢查 Shizuku 授權與裝置相容性。 - Shizuku 服務中的介面觀察功能已可使用。 + 請為 Minis 授予 Root,然後重新執行相容性檢查。 + 已取得 Root,虛擬螢幕服務正以 root 身分執行。 + 裝置檢查逾時。請確認已授予 Root 後再試。 + 介面觀察遭到阻擋。請檢查 Root 授權與裝置相容性。 + Root 服務中的介面觀察功能已可使用。 無法辨識此檢查結果。請保持 VScreen 關閉並回報此代碼。 - 服務沒有以已授權的 shell(2000)或 Root(0)身分執行。請重新啟動 Shizuku 後重試。 + 服務沒有以 Root(0)身分執行。請為 Minis 授予 Root 後重試。 釋放虛擬螢幕 虛擬螢幕已釋放。 解析度與密度 @@ -2190,7 +2151,7 @@ 顯示器內輸入 在虛擬螢幕啟動 App 非黑畫面截圖 - Shizuku 授權與服務身分 + Root 授權與服務身分 介面觀察 相容性檢查 虛擬螢幕 @@ -2266,9 +2227,6 @@ 操作虛擬螢幕 允許 Agent 使用虛擬螢幕 總開關,下面是各項權限 - Shizuku - 已連線 · 供虛擬螢幕與 Agent 工具使用 - 未就緒 · 需要安裝、啟動或授權 Shizuku 顯示在其他應用程式上層 已允許 未允許 · 桌寵和懸浮控制項需要它 @@ -2378,7 +2336,7 @@ 僅限 minis-model-use(非對話模型) 一鍵授權 授權全部 - 以 root 一次授予本應用宣告的全部 Android 權限,並開啟 Agent 的全部工具權限(含無障礙、Shizuku、虛擬螢幕)。關閉只會把 Agent 權限還原為預設,Android 系統授權保留。並把 API Key 類服務商共用給終端機裡的 pi、Claude Code 和 Codex。 + 以 root 一次授予本應用宣告的全部 Android 權限,並開啟 Agent 的全部工具權限(含無障礙、虛擬螢幕)。關閉只會把 Agent 權限還原為預設,Android 系統授權保留。並把 API Key 類服務商共用給終端機裡的 pi、Claude Code 和 Codex。 正在授權… 重新啟動 Minis 以連接框架 在 Minis 已經執行時才啟用模組的話,點這裡重新啟動一次。 diff --git a/src/android/app/src/main/res/values-zh/restricted_settings_strings.xml b/src/android/app/src/main/res/values-zh/restricted_settings_strings.xml index 3f569772..9b7c34f9 100644 --- a/src/android/app/src/main/res/values-zh/restricted_settings_strings.xml +++ b/src/android/app/src/main/res/values-zh/restricted_settings_strings.xml @@ -4,8 +4,8 @@ 对于通过下载的 APK 文件安装的应用,Android 会锁定无障碍开关。这是系统的安全策略,不是 Minis 的问题,你的安装包没有损坏。「允许受限设置」只是把开关解锁,本身不授予任何权限,是否开启该服务仍由你决定。 允许受限设置 应用详情 → 右上角 ⋮ 菜单 → 允许受限设置 - 通过 Shizuku 允许受限设置 - 一键解除,无需离开 Minis + 通过 Root 允许受限设置 + 一键解除,无需离开 Minis 正在解除… 解除失败 — 请按下方步骤手动操作 diff --git a/src/android/app/src/main/res/values-zh/strings.xml b/src/android/app/src/main/res/values-zh/strings.xml index 400f70d1..05e2225d 100644 --- a/src/android/app/src/main/res/values-zh/strings.xml +++ b/src/android/app/src/main/res/values-zh/strings.xml @@ -72,19 +72,17 @@ 已取消 已超时 无障碍服务已关闭 - Minis 的无障碍权限已丢失——强行停止应用后会出现这种情况。检测到 Shizuku 可用,可以立即恢复,无需前往系统设置。 + Minis 的无障碍权限已丢失——强行停止应用后会出现这种情况。已获得 Root,可以立即恢复,无需前往系统设置。 Minis 的无障碍权限已丢失——强行停止应用后会出现这种情况。请在「设置 → 无障碍」中重新开启 Minis。 立即恢复 打开设置 暂不 需要处理 - 恢复无障碍权限 + 恢复无障碍权限 重新开启无障碍 权限已被撤销,可能是强行停止导致的 正在恢复… 恢复失败——请到系统设置中开启 - 每次应用被强行停止,Android 都会移除无障碍授权,且不会自动恢复。Shizuku 可以直接写回该授权。 - 每次应用被强行停止,Android 都会移除无障碍授权,且不会自动恢复。安装并授权 Shizuku 后,今后可一键恢复。 Minis Agent 运行中 %1$s | %2$s | %3$s 任务已完成 @@ -413,14 +411,14 @@ 没有可用的 root,无法自动授予权限。 android-a11y-cli - android-shizuku-cli + android-root-cli minis-apt 无障碍 android-a11y-cli 让 Agent 读取屏幕 UI、模拟点击与输入、监听系统事件。下方两项都需配置:选择 Agent 权限,并在系统设置启用无障碍服务。 - android-shizuku-cli 通过 Shizuku 服务调用 Android 高权限 API(安装应用、settings put、强制停止等)。Shizuku 由无线 ADB 或 root 启动。下方两项都需配置。 让 Agent 读取屏幕、点击、输入、监听事件 前往无障碍设置 系统授权 + 未授权 Agent 权限 已启用 未启用 @@ -435,57 +433,21 @@ 通讯录 照片 - - Root 与 Shizuku - 状态 - Shizuku 通过无线 ADB 或 root 启动一个本地服务,让用户应用无需 root 即可调用 Android 系统隐藏 API。支持两款兼容的管理器:Shizuku(RikkaApps)和 AXManager(Axeron),任意安装一个即可。授权后,Minis 可使用 android-shizuku-cli 命令管理包、权限、设置、输入、Activity 等。 - 下一步 - 可用能力 - 在终端执行 `android-shizuku-cli --help` 查看完整用法。无论 binder 来自 Shizuku、AXManager 还是 Sui,命令集完全一致。 - 安装、卸载、列出和查询应用 - 授予或撤销运行时权限 - 启动 Activity、发送广播 - 查询和调整显示设置 - 读写 system / secure / global 设置 - 列出和管理设备用户 - 查看和切换网络状态 - 注入点击、滑动、按键和文本 - 发布和管理通知 - 读写受保护的文件路径 - 查询设备属性和硬件状态 - 调用和查看系统服务 - 安装 Shizuku(RikkaApps)、AXManager(Axeron)或 Sui(RikkaApps)其中任意一个 —— 三者实现同一套协议,可以互换。Shizuku 和 AXManager 是普通 App,通过无线 ADB 激活。Sui 面向已经 root 的设备:它以 Magisk/KernelSU 模块的形式安装,无需 App,且重启后依然保持生效。在 KernelSU 或 APatch 上,Sui 还需要额外的 Zygisk 实现(Zygisk Next / ReZygisk / NeoZygisk)—— 没有它 Sui 不会加载。安装其中一个后返回此页。 - 打开管理器应用,通过无线 ADB(无需电脑)或 root 启动服务,状态变为运行后返回此页面。\n\n使用 AXManager 时,还需在 AXManager 的"设置 → AX-Permission Intercept"中打开开关,AXManager 才会向 Minis 推送 Shizuku 兼容 binder。 - 服务已运行。点击「授权 Minis」会弹出管理器的系统授权对话框。 + 安装、卸载、列出和查询应用 + 授予或撤销运行时权限 + 启动 Activity、发送广播 + 查询和调整显示设置 + 读写 system / secure / global 设置 + 列出和管理设备用户 + 查看和切换网络状态 + 注入点击、滑动、按键和文本 + 发布和管理通知 + 读写受保护的文件路径 + 查询设备属性和硬件状态 + 检查 Root 是否可用 - 未安装管理器 - 安装 Shizuku、AXManager 或 Sui —— 任意一个即可。 - 服务未运行 - 请打开管理器应用并启动服务。 - 需要授权 - 服务已运行,但 Minis 尚未获得授权。 - 已就绪 - 版本=%1$d,uid=%2$d - - 未安装 - 未启动 - 待授权 - 就绪 - - 安装 Shizuku - RikkaApps · 打开 GitHub Releases 页面 - 安装 AXManager - Axeron · 打开 GitHub Releases 页面 - 打开管理器应用 - 启动已安装的管理器以启动或管理服务 - 重新检测 - 启动服务后重新检查状态 - 授权 Minis - 弹出授权对话框 - - 特权后端 - 允许 Agent 通过特权后端(Shizuku 或 AXManager)执行 ADB 级系统命令 + 允许 Agent 以 root 身份执行 ADB 级系统命令(pm、am、settings、input…) 技能 添加 Skill @@ -1423,10 +1385,6 @@ 取消下载 失败 打开 - 安装 Sui - RikkaApps · 适用于已 root 设备(Magisk/KernelSU)· 无需 App,重启后依然生效 - Sui(root)· 版本=%1$d, uid=%2$d - Sui 正在运行。点击「授权 Minis」以授予访问权限 —— 确认对话框由 Sui 自己弹出,没有单独的管理器 App 需要打开。 移入分组 更换分组 移入分组 @@ -1573,7 +1531,6 @@ 模块保护 保持无障碍服务开启 Minis 模块未安装或未启用 - 仅在已安装 LSPosed 模块并作用于 Minis 时可用;模块会在系统内保持无障碍服务开启,无需 Shizuku。 模块设置 LSPosed 模块读取的开关 @@ -1673,7 +1630,7 @@ 回车键、回复后聚焦、思考展开、自动归类 主题、字体、语言、应用图标、桌宠 Rootfs、环境变量 - Root、Shizuku、Agent 权限、后台与通知 + Root、Agent 权限、后台与通知 日志、更新、隐私 终端在会话抽屉里;团队和定时任务在 Agent 里。 %1$d 项需要处理 @@ -1716,7 +1673,7 @@ 要让 Minis 接管电源键,先在「权限」里把它设为系统默认助手。 Agent 权限 Agent 可以自己做哪些事 - Root 状态、Shizuku 安装与授权 + Root 状态与可用功能 悬浮窗权限 显示在其他应用上层 已授权 @@ -2149,7 +2106,7 @@ 无人值守运行中的界面操作只使用虚拟屏。 虚拟屏尚未通过当前设备自检并启用。仍可保存例程,但界面操作可能失败。 虚拟屏幕(实验性) - Shizuku 虚拟屏幕与设备兼容性自检 + Root 虚拟屏幕与设备兼容性自检 允许无人值守操作物理屏 风险:后台例程可能控制你正在使用的屏幕。默认关闭;仅使用虚拟屏更安全。 检测设备兼容性 @@ -2157,11 +2114,10 @@ 启用虚拟屏幕 启用前会先运行一次当前设备自检。 高度(px) - 实验性功能,默认关闭。需要一个以 Root 或 ADB 身份运行的 Shizuku 服务(有 Root 时用 Root 启动 Shizuku 即可)。它只创建一个隔离的虚拟显示屏,绝不会回退到物理屏幕。 当前没有虚拟屏。 服务进程没有及时启动。请更新应用后重试;仍然出现请反馈此代码。 这些检测没有通过 - 去 Shizuku + 去 Root 设置 开发者选项 查看未通过的项目 虚拟屏幕 @@ -2178,13 +2134,13 @@ 应用已离开虚拟屏。请停止操作;Minis 不会回退到物理屏。 临时自检虚拟屏已释放。 未创建临时显示屏,因此无需释放。 - 无法建立系统上下文。请重启 Shizuku 和 Minis 后重试。 + 无法建立系统上下文。请重启 Minis 后重试。 隔离的系统上下文已就绪。 请先关闭当前虚拟屏,再重新检测兼容性。 无法创建虚拟显示屏。请关闭当前虚拟屏后重试;如果仍失败,设备 ROM 可能不支持所需 API。 已成功创建临时虚拟显示屏。 - 此项检测失败。请查看设备详情、确认 Shizuku 已授权后重试。 - Shizuku UserService 无法使用所需隐藏 API。此设备不能启用 VScreen。 + 此项检测失败。请查看设备详情、确认 Minis 已获得 Root 后重试。 + Root 服务无法使用所需隐藏 API。此设备不能启用 VScreen。 键盘已路由到虚拟屏。 当前 ROM 无法隔离键盘路由,文本输入可能不稳定,请谨慎测试。 输入注入被拒绝。小米/HyperOS 设备请在开发者选项中开启“USB 调试(安全设置)”。 @@ -2195,13 +2151,13 @@ 画面为黑屏;FLAG_SECURE 或系统不支持的截取策略可能阻止像素采集。请改用 UI 节点树,或请用户接手。不要尝试 OCR 或其他截屏路径。 没有可安全显示的图像。受保护或黑屏画面会被主动拦截。 已截取到非黑屏的虚拟屏画面。 - 请启动 Shizuku 并授权 Minis,然后重新检测兼容性。 - Shizuku 已授权,shell UserService 正在运行。 - 设备检测超时。请保持 Shizuku 运行后重试。 - 界面观察被系统阻止。请检查 Shizuku 授权与设备兼容性。 - Shizuku 服务中的界面观察已可用。 + 请为 Minis 授予 Root,然后重新检测兼容性。 + 已获得 Root,虚拟屏幕服务正以 root 身份运行。 + 设备检测超时。请确认已授予 Root 后重试。 + 界面观察被系统阻止。请检查 Root 授权与设备兼容性。 + Root 服务中的界面观察已可用。 无法识别此检测结果。请保持 VScreen 关闭并反馈此代码。 - 服务没有以已授权的 shell(2000)或 Root(0)身份运行。请重启 Shizuku 后重试。 + 服务没有以 Root(0)身份运行。请为 Minis 授予 Root 后重试。 释放虚拟屏 虚拟屏已释放。 分辨率和密度 @@ -2219,7 +2175,7 @@ 显示屏内输入 在虚拟屏启动应用 非黑屏截图 - Shizuku 授权与服务身份 + Root 授权与服务身份 界面观察 兼容性检测 虚拟屏幕 @@ -2295,9 +2251,6 @@ 操作虚拟屏幕 允许 Agent 使用虚拟屏幕 总开关,下面是各项权限 - Shizuku - 已连接 · 供虚拟屏幕与 Agent 工具使用 - 未就绪 · 需要安装、启动或授权 Shizuku 显示在其他应用上层 已允许 未允许 · 桌宠和悬浮控件需要它 @@ -2407,7 +2360,7 @@ 仅限 minis-model-use(非对话模型) 一键授权 授权全部 - 需要 root。授予本应用声明的全部 Android 权限,打开无障碍服务、通知读取权限和默认助手角色,启用模块开关和虚拟屏幕(需先运行 Shizuku),并允许 Agent 的全部工具。关闭只把 Agent 权限恢复默认,Android 系统授权保留。并把 API Key 类服务商共享给终端里的 pi、Claude Code 和 Codex。 + 需要 root。授予本应用声明的全部 Android 权限,打开无障碍服务、通知读取权限和默认助手角色,启用模块开关和虚拟屏幕,并允许 Agent 的全部工具。关闭只把 Agent 权限恢复默认,Android 系统授权保留。并把 API Key 类服务商共享给终端里的 pi、Claude Code 和 Codex。 正在授权… 重启 Minis 以连接框架 在 Minis 已经运行时才启用模块的话,点这里重启一次。 diff --git a/src/android/app/src/main/res/values/restricted_settings_strings.xml b/src/android/app/src/main/res/values/restricted_settings_strings.xml index 63acc0b6..161adbbe 100644 --- a/src/android/app/src/main/res/values/restricted_settings_strings.xml +++ b/src/android/app/src/main/res/values/restricted_settings_strings.xml @@ -4,8 +4,8 @@ Android blocks the accessibility toggle for apps installed from a downloaded APK file. This is an OS security policy, not a Minis bug — nothing is wrong with your install. Allowing restricted settings only re-enables the toggle; it grants nothing on its own, and you still choose whether to turn the service on. Allow restricted settings App info → ⋮ menu (top right) → Allow restricted settings - Allow restricted settings via Shizuku - One tap — clears the flag without leaving Minis + Allow restricted settings with Root + One tap — clears the flag without leaving Minis Allowing… Failed — use the manual steps below diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 666c65ff..bbe36102 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -95,19 +95,17 @@ Accessibility turned off - Minis lost its accessibility permission — this happens after the app is force-stopped. Shizuku is available, so it can be restored right now without opening Settings. + Minis lost its accessibility permission — this happens after the app is force-stopped. Root is granted, so it can be restored right now without opening Settings. Minis lost its accessibility permission — this happens after the app is force-stopped. Re-enable Minis under Settings → Accessibility to continue. Repair now Open Settings Not now Needs attention - Repair accessibility permission + Repair accessibility permission Re-enable accessibility The permission was revoked, likely by a force-stop Repairing… Repair failed — try enabling it in Settings - Android removes the accessibility grant whenever an app is force-stopped, and does not restore it automatically. Shizuku can write it back directly. - Android removes the accessibility grant whenever an app is force-stopped, and does not restore it automatically. Install and authorize Shizuku to repair this in one tap in future. Minis Agent Active %1$s | %2$s | %3$s android-a11y-cli - android-shizuku-cli + android-root-cli minis-apt Accessibility android-a11y-cli lets the agent read on-screen UI structure, perform taps and gestures, and watch system events. Both rows below must be set: pick an Agent permission and enable the Accessibility service in system Settings. - android-shizuku-cli grants the agent privileged Android APIs (package install, settings put, force-stop, …) via the Shizuku service authorized over wireless ADB or root. Both rows below must be set. Lets the agent read screen UI, tap, type, and watch events Open Accessibility Settings System authorization + Not granted Agent permission Service enabled Service not enabled @@ -804,61 +802,21 @@ Contacts Photos - - Root & Shizuku - Status - Shizuku exposes privileged system APIs to user apps without root, by running a small service started via wireless ADB or root. Three compatible providers are supported — Shizuku (RikkaApps), AXManager (Axeron), and Sui (a Magisk/KernelSU module for rooted devices); install any one. Once authorized, Minis can use the android-shizuku-cli CLI for package, permission, settings, input, and activity management. - Next Step - Capabilities - Run `android-shizuku-cli --help` in a shell for full usage. Available whether the binder comes from Shizuku, AXManager, or Sui — the command surface is identical. - Install, uninstall, list, and query apps - Grant or revoke runtime permissions - Start activities and send broadcasts - Query and adjust display settings - Read and write system / secure / global settings - List and manage device users - Inspect and toggle network state - Inject taps, swipes, keys, and text - Post and manage notifications - Read and write privileged file paths - Query device properties and hardware state - Call and inspect system services - Install any one of Shizuku (RikkaApps), AXManager (Axeron), or Sui (RikkaApps) — all three implement the same protocol and are fully interchangeable. Shizuku and AXManager are regular apps activated via wireless ADB. Sui is for already-rooted devices: it installs as a Magisk/KernelSU module, needs no app, and stays active across reboots. On KernelSU or APatch, Sui also requires a Zygisk implementation (Zygisk Next / ReZygisk / NeoZygisk) — without it Sui will not load. Install one, then return here. - Open the manager app and start the service via wireless ADB (no PC) or root. After it shows running, return here.\n\nUsing AXManager? Also enable Settings → "AX-Permission Intercept" inside AXManager — only that switch makes AXManager push its Shizuku-compatible binder to Minis. - The service is running. Tap Authorize Minis to grant access. A system dialog from the manager app will ask you to confirm. - Sui is running. Tap Authorize Minis to grant access — Sui will show the confirmation dialog itself. There is no separate manager app to open. + Install, uninstall, list, and query apps + Grant or revoke runtime permissions + Start activities and send broadcasts + Query and adjust display settings + Read and write system / secure / global settings + List and manage device users + Inspect and toggle network state + Inject taps, swipes, keys, and text + Post and manage notifications + Read and write privileged file paths + Query device properties and hardware state + Check whether Root is available - Manager Not Installed - Install Shizuku, AXManager, or Sui — any one works. - Service Not Running - Open the manager app and press Start. - Authorization Required - The service is running but Minis is not authorized. - Ready - version=%1$d, uid=%2$d - Sui (root) · version=%1$d, uid=%2$d - - Not Installed - Stopped - Permission - Ready - - Install Shizuku - RikkaApps · opens the GitHub releases page - Install AXManager - Axeron · opens the GitHub releases page - Install Sui - RikkaApps · for rooted devices (Magisk/KernelSU) · no app, survives reboots - Open Manager App - Launch the installed manager to start or manage the service - Recheck - Re-poll state after starting the service - Authorize Minis - Show the permission dialog - - Privileged Backend - Let the agent run ADB-level system commands through a privileged backend (Shizuku or AXManager) + Let the agent run ADB-level system commands as root (pm, am, settings, input…) Skills Add Skill @@ -1716,7 +1674,6 @@ Module protection Keep the accessibility service enabled The Minis module is not installed or not active - Works only with the Minis LSPosed module installed and scoped to Minis. The module then keeps the accessibility service enabled inside the system, without Shizuku. Module settings Switches the LSPosed module reads @@ -1816,7 +1773,7 @@ Return key, focus after reply, thinking, grouping Theme, fonts, language, app icon, desktop pet Rootfs, environment variables - Root, Shizuku, agent permissions, background and notifications + Root, agent permissions, background and notifications Logs, updates, privacy The terminal is in the session drawer; the team and scheduled tasks are under Agent. %1$d need attention @@ -1859,7 +1816,7 @@ Minis takes over the power key only once it is the default assistant of the system - set that under Permissions. Agent permissions What the agent may do on its own - Root status, Shizuku install and authorisation + Root status and what it unlocks Overlay permission Display over other apps Granted @@ -2239,7 +2196,7 @@ Unattended runs use only the virtual screen for UI actions. Virtual display is not currently enabled with a passing device check. This routine can still be saved, but its UI actions may fail. Virtual display (experimental) - Shizuku virtual screen and compatibility self-check + Root virtual screen and compatibility self-check Allow unattended physical-screen actions Risk: background routines could control the screen you are using. Off by default; virtual-screen-only access is safer. Check device compatibility @@ -2247,11 +2204,10 @@ Enable virtual display Enabling runs a fresh device self-check first. Height (px) - Experimental. It needs a Shizuku-protocol service running as Root or ADB (with Root, start Shizuku with Root). It is off by default, creates one isolated virtual display, and never falls back to the physical screen. There is no active virtual display. The service process did not start in time. Update the app and try again; if it keeps happening, report this code. These checks did not pass - Open Shizuku + Open Root settings Developer options See what failed Virtual screen @@ -2268,13 +2224,13 @@ The app left the virtual display. Stop the action; Minis will not continue on the physical screen. The temporary self-check display was released. No temporary display was created, so there was nothing to release. - System context setup failed. Restart Shizuku and Minis, then retry. + System context setup failed. Restart Minis, then retry. The isolated system context is ready. Close the current virtual display, then run the compatibility check again. The virtual display could not be created. Close any active virtual display and retry; if it persists, this ROM may not support the required APIs. A temporary virtual display was created successfully. - This check failed. Review its device detail, verify Shizuku is authorized, and retry. - A required hidden API is unavailable in this Shizuku UserService. VScreen cannot be enabled on this device. + This check failed. Review its device detail, verify Minis has Root, and retry. + A required hidden API is unavailable in the root service. VScreen cannot be enabled on this device. The keyboard is routed to the virtual display. This ROM could not isolate keyboard routing. Text entry may not work reliably; use only after testing. Input injection was rejected. On Xiaomi/HyperOS, enable Developer options → USB debugging (Security settings), then retry. @@ -2285,13 +2241,13 @@ The frame is black; FLAG_SECURE or an unsupported capture policy may block pixels. Use the UI node tree or ask the user to take over. Do not try OCR or another capture path. No safe image was available. Secure or blank screens are intentionally blocked; do not use OCR or another screen path. A non-blank virtual-screen image was captured. - Start Shizuku and authorize Minis, then run the compatibility check again. - Shizuku is authorized and the shell UserService is running. - The device check timed out. Keep Shizuku running and try again. - UI observation was blocked. Check Shizuku authorization and device compatibility, then retry. - UI observation is available in the Shizuku service. + Grant Minis Root, then run the compatibility check again. + Root is granted and the virtual-screen service is running as root. + The device check timed out. Make sure Root is granted and try again. + UI observation was blocked. Check Root and device compatibility, then retry. + UI observation is available in the root service. Unrecognized check result. Keep VScreen off and report this code. - The service did not run as an authorized shell (2000) or Root (0) identity. Restart Shizuku and retry. + The service did not run as Root (UID 0). Grant Minis Root and retry. Release virtual display Virtual display released. Resolution and density @@ -2309,7 +2265,7 @@ Display-local input App launch on the virtual display Non-blank screenshot - Shizuku authorization and service identity + Root authorization and service identity UI observation Compatibility check Virtual display @@ -2385,9 +2341,6 @@ Operate the virtual screen Let the agent use the virtual screen Master switch; the items below are its permissions - Shizuku - Connected · used by the virtual screen and Agent tools - Not ready · install, start or authorize Shizuku Display over other apps Allowed Off · the desktop pet and floating controls need it @@ -2497,7 +2450,7 @@ minis-model-use only (not a chat model) One-tap authorize Authorize everything - Needs root. Grants every Android permission the app declares, turns on its accessibility service, notification access and default-assistant role, enables the module switches and the virtual screen (Shizuku must be running), and allows every Agent tool. Switching off puts the Agent tools back to their defaults; Android grants stay. Also shares your API-key providers with pi, Claude Code and Codex in the terminal. + Needs root. Grants every Android permission the app declares, turns on its accessibility service, notification access and default-assistant role, enables the module switches and the virtual screen, and allows every Agent tool. Switching off puts the Agent tools back to their defaults; Android grants stay. Also shares your API-key providers with pi, Claude Code and Codex in the terminal. Granting… Restart Minis to connect Use this after enabling the module while Minis was already running. diff --git a/src/android/app/src/main/res/xml/network_security_config.xml b/src/android/app/src/main/res/xml/network_security_config.xml index 2bf0e458..eb92193f 100644 --- a/src/android/app/src/main/res/xml/network_security_config.xml +++ b/src/android/app/src/main/res/xml/network_security_config.xml @@ -11,6 +11,7 @@ diff --git a/src/android/app/src/test/java/com/openminis/app/offload/RootProcessTest.kt b/src/android/app/src/test/java/com/openminis/app/offload/RootProcessTest.kt new file mode 100644 index 00000000..6894f864 --- /dev/null +++ b/src/android/app/src/test/java/com/openminis/app/offload/RootProcessTest.kt @@ -0,0 +1,38 @@ +package com.openminis.app.offload + +import com.openminis.app.runtime.ubuntu.RootAccessStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class RootProcessTest { + @Test + fun `a refused or missing su stops the call with the CLI's existing error codes`() { + assertEquals("PERMISSION_DENIED", RootProcess.unavailableFor(RootAccessStatus.DENIED)?.code) + assertEquals("SERVICE_NOT_RUNNING", RootProcess.unavailableFor(RootAccessStatus.UNAVAILABLE)?.code) + } + + @Test + fun `states that are not a definite no still let su answer`() { + for (status in listOf( + RootAccessStatus.UNKNOWN, RootAccessStatus.NOT_GRANTED, RootAccessStatus.GRANTED, RootAccessStatus.TIMED_OUT, + )) { + assertNull("$status", RootProcess.unavailableFor(status)) + } + } + + @Test + fun `the runner's own failure follows the command's stderr`() { + val result = RootProcess.toResult(124, "partial", "cmd said", "Root command timed out after 5000ms") + assertEquals(124, result.exitCode) + assertEquals("cmd said\nRoot command timed out after 5000ms", result.stderr) + assertEquals("partial\ncmd said\nRoot command timed out after 5000ms", result.combined) + } + + @Test + fun `a clean run adds nothing to stderr`() { + val result = RootProcess.toResult(0, "0\n", "", null) + assertEquals("", result.stderr) + assertEquals("0\n", result.combined) + } +} diff --git a/src/android/app/src/test/java/com/openminis/app/offload/ShizukuBackendStateTest.kt b/src/android/app/src/test/java/com/openminis/app/offload/ShizukuBackendStateTest.kt deleted file mode 100644 index 92443d7c..00000000 --- a/src/android/app/src/test/java/com/openminis/app/offload/ShizukuBackendStateTest.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.openminis.app.offload - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * [T-android-sui-support] (GH#110 / GH#97) Binder-first state resolution. - * - * [ShizukuBackend.decideState] is deliberately a pure function so the whole - * decision table can be exercised on the JVM — no device, no root, and no - * `rikka.shizuku.Shizuku` static singleton (which cannot be instantiated - * off-device). The three inputs are exactly what the real [ShizukuBackend] - * feeds it, so a green table here means the ordering fix is correct - * regardless of WHICH provider supplied the binder. - */ -class ShizukuBackendStateTest { - - private fun decide(binder: Boolean, granted: Boolean, apk: Boolean) = - ShizukuBackend.decideState( - binderAlive = binder, - permissionGranted = granted, - managerApkInstalled = apk, - ) - - // ── The Sui regression this change exists for ──────────────────────── - - @Test - fun `sui - live authorized binder with NO manager apk is READY`() { - // Sui ships as a Magisk/KernelSU module: no APK to find. Before the - // binder-first fix the package scan short-circuited to NOT_INSTALLED - // and pingBinder() was never even called — GH#110's exact symptom. - assertEquals( - ShizukuManager.State.READY, - decide(binder = true, granted = true, apk = false), - ) - } - - @Test - fun `sui - live unauthorized binder with NO manager apk asks for permission`() { - // Must be NEED_PERMISSION (an actionable "Authorize Minis" prompt), - // never NOT_INSTALLED (a dead-end "go install something" screen). - assertEquals( - ShizukuManager.State.NEED_PERMISSION, - decide(binder = true, granted = false, apk = false), - ) - } - - // ── Existing Shizuku / AXManager paths must not regress ────────────── - - @Test - fun `shizuku - authorized binder with manager apk is READY`() { - assertEquals( - ShizukuManager.State.READY, - decide(binder = true, granted = true, apk = true), - ) - } - - @Test - fun `shizuku - unauthorized binder with manager apk needs permission`() { - assertEquals( - ShizukuManager.State.NEED_PERMISSION, - decide(binder = true, granted = false, apk = true), - ) - } - - @Test - fun `manager apk installed but service not started is NOT_RUNNING`() { - // The "installed Shizuku but never ran the ADB activation" case: we - // can still point at the manager app, so keep that distinct hint. - assertEquals( - ShizukuManager.State.NOT_RUNNING, - decide(binder = false, granted = false, apk = true), - ) - } - - @Test - fun `nothing available at all is NOT_INSTALLED`() { - assertEquals( - ShizukuManager.State.NOT_INSTALLED, - decide(binder = false, granted = false, apk = false), - ) - } - - // ── Ordering / invariants ─────────────────────────────────────────── - - @Test - fun `binder always wins over the package scan`() { - // The entire fix in one assertion: with the binder up, the presence or - // absence of a manager APK must not change the outcome. A regression - // that reinstates a package-first check fails here. - for (granted in listOf(true, false)) { - assertEquals( - "apk presence must not affect state when binder is alive (granted=$granted)", - decide(binder = true, granted = granted, apk = true), - decide(binder = true, granted = granted, apk = false), - ) - } - } - - @Test - fun `a dead binder can never report READY`() { - // Defensive: permissionGranted is only meaningful alongside a live - // binder. Even if a caller passes a stale true, we must not claim - // READY and then fail every runProcess with exit 126. - assertEquals( - ShizukuManager.State.NOT_RUNNING, - decide(binder = false, granted = true, apk = true), - ) - assertEquals( - ShizukuManager.State.NOT_INSTALLED, - decide(binder = false, granted = true, apk = false), - ) - } - - @Test - fun `full decision table is exhaustive and stable`() { - // Locks all 8 input combinations so any future edit to the when-chain - // has to consciously update this table. - val table = listOf( - Triple(true, true, true) to ShizukuManager.State.READY, - Triple(true, true, false) to ShizukuManager.State.READY, - Triple(true, false, true) to ShizukuManager.State.NEED_PERMISSION, - Triple(true, false, false) to ShizukuManager.State.NEED_PERMISSION, - Triple(false, true, true) to ShizukuManager.State.NOT_RUNNING, - Triple(false, true, false) to ShizukuManager.State.NOT_INSTALLED, - Triple(false, false, true) to ShizukuManager.State.NOT_RUNNING, - Triple(false, false, false) to ShizukuManager.State.NOT_INSTALLED, - ) - for ((input, expected) in table) { - val (binder, granted, apk) = input - assertEquals( - "binder=$binder granted=$granted apk=$apk", - expected, - decide(binder, granted, apk), - ) - } - } -} diff --git a/src/android/app/src/test/java/com/openminis/app/runtime/guest/GuestCommandBridgeTest.kt b/src/android/app/src/test/java/com/openminis/app/runtime/guest/GuestCommandBridgeTest.kt index 9f6567e7..a6d483b0 100644 --- a/src/android/app/src/test/java/com/openminis/app/runtime/guest/GuestCommandBridgeTest.kt +++ b/src/android/app/src/test/java/com/openminis/app/runtime/guest/GuestCommandBridgeTest.kt @@ -30,6 +30,22 @@ class GuestCommandBridgeTest { assertEquals(4, Regex("(?m)^printf %s ").findAll(script).count()) } + @Test + fun `install script removes a retired wrapper but never a symlink or a command still provided`() { + val wrapper = GuestCommandBridge.wrapperScriptForTest() + val retired = "/rootfs/usr/local/bin/android-shizuku-cli" + + val script = GuestCommandBridge.installScript("/rootfs", 10450, 10450, "cfg", wrapper, wrapper, setOf("android-root-cli")) + + assertTrue(script.contains("if [ -f '$retired' ] && [ ! -L '$retired' ]; then rm -f -- '$retired'; fi")) + assertFalse(Regex("""(?m)^rm -f -- '$retired'""").containsMatchIn(script)) + + val stillProvided = GuestCommandBridge.installScript( + "/rootfs", 10450, 10450, "cfg", wrapper, wrapper, setOf("android-shizuku-cli"), + ) + assertFalse(stillProvided.contains("rm -f -- '$retired'")) + } + @Test fun `managed guest CLI set includes registered Android handlers and preview aliases`() { val names = GuestCommandBridge.managedCommandNames( diff --git a/src/android/app/src/test/java/com/openminis/app/runtime/guest/ShizukuInputTextTest.kt b/src/android/app/src/test/java/com/openminis/app/runtime/guest/RootCliInputTextTest.kt similarity index 52% rename from src/android/app/src/test/java/com/openminis/app/runtime/guest/ShizukuInputTextTest.kt rename to src/android/app/src/test/java/com/openminis/app/runtime/guest/RootCliInputTextTest.kt index 40d3a1f9..2a50abac 100644 --- a/src/android/app/src/test/java/com/openminis/app/runtime/guest/ShizukuInputTextTest.kt +++ b/src/android/app/src/test/java/com/openminis/app/runtime/guest/RootCliInputTextTest.kt @@ -3,16 +3,16 @@ package com.openminis.app.runtime.guest import org.junit.Assert.assertEquals import org.junit.Test -class ShizukuInputTextTest { +class RootCliInputTextTest { @Test fun spacesBecomePercentS() { - assertEquals("a%sb", ShizukuOffloadHandler.encodeInputText("a b")) - assertEquals("hello%s%sworld", ShizukuOffloadHandler.encodeInputText("hello world")) + assertEquals("a%sb", RootCliOffloadHandler.encodeInputText("a b")) + assertEquals("hello%s%sworld", RootCliOffloadHandler.encodeInputText("hello world")) } @Test fun apostrophesAndOtherCharactersSurvive() { - assertEquals("don't", ShizukuOffloadHandler.encodeInputText("don't")) - assertEquals("\"quoted\"%s&%s中文", ShizukuOffloadHandler.encodeInputText("\"quoted\" & 中文")) + assertEquals("don't", RootCliOffloadHandler.encodeInputText("don't")) + assertEquals("\"quoted\"%s&%s中文", RootCliOffloadHandler.encodeInputText("\"quoted\" & 中文")) } } diff --git a/src/android/app/src/test/java/com/openminis/app/runtime/guest/ShizukuPackageUidTest.kt b/src/android/app/src/test/java/com/openminis/app/runtime/guest/RootCliPackageUidTest.kt similarity index 56% rename from src/android/app/src/test/java/com/openminis/app/runtime/guest/ShizukuPackageUidTest.kt rename to src/android/app/src/test/java/com/openminis/app/runtime/guest/RootCliPackageUidTest.kt index d25e0688..e05b4a5b 100644 --- a/src/android/app/src/test/java/com/openminis/app/runtime/guest/ShizukuPackageUidTest.kt +++ b/src/android/app/src/test/java/com/openminis/app/runtime/guest/RootCliPackageUidTest.kt @@ -4,7 +4,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test -class ShizukuPackageUidTest { +class RootCliPackageUidTest { private val dump = """ Packages: Package [com.example.app] (abc123): @@ -14,25 +14,25 @@ class ShizukuPackageUidTest { @Test fun theUidIsReadFromADumpOfAnInstalledPackage() { - assertEquals("10234", ShizukuOffloadHandler.parsePackageUid(dump, 0)) + assertEquals("10234", RootCliOffloadHandler.parsePackageUid(dump, 0)) } @Test fun aFailedCommandIsNotAUid() { - assertNull(ShizukuOffloadHandler.parsePackageUid(dump, 1)) - assertNull(ShizukuOffloadHandler.parsePackageUid(dump, 124)) + assertNull(RootCliOffloadHandler.parsePackageUid(dump, 1)) + assertNull(RootCliOffloadHandler.parsePackageUid(dump, 124)) } @Test fun anUnknownPackageDoesNotFallBackToZero() { - assertNull(ShizukuOffloadHandler.parsePackageUid("Unable to find package: com.nope", 0)) - assertNull(ShizukuOffloadHandler.parsePackageUid("", 0)) + assertNull(RootCliOffloadHandler.parsePackageUid("Unable to find package: com.nope", 0)) + assertNull(RootCliOffloadHandler.parsePackageUid("", 0)) } @Test fun uidZeroAndOtherSystemRangeValuesBelowTheAppRangeAreRefused() { - assertNull(ShizukuOffloadHandler.parsePackageUid("userId=0", 0)) - assertNull(ShizukuOffloadHandler.parsePackageUid("userId=999", 0)) - assertEquals("1000", ShizukuOffloadHandler.parsePackageUid("userId=1000", 0)) + assertNull(RootCliOffloadHandler.parsePackageUid("userId=0", 0)) + assertNull(RootCliOffloadHandler.parsePackageUid("userId=999", 0)) + assertEquals("1000", RootCliOffloadHandler.parsePackageUid("userId=1000", 0)) } } diff --git a/src/android/app/src/test/java/com/openminis/app/scheduled/ScheduledReadOnlyPolicyTest.kt b/src/android/app/src/test/java/com/openminis/app/scheduled/ScheduledReadOnlyPolicyTest.kt index 2cfce620..249fa681 100644 --- a/src/android/app/src/test/java/com/openminis/app/scheduled/ScheduledReadOnlyPolicyTest.kt +++ b/src/android/app/src/test/java/com/openminis/app/scheduled/ScheduledReadOnlyPolicyTest.kt @@ -34,7 +34,7 @@ class ScheduledReadOnlyPolicyTest { "curl https://example.test", "wget https://example.test", "ssh host", "scp a host:b", "nc -l 1234", "git push", "git commit -m x", "git checkout main", "git reset --hard", "rm -rf /", "mv a b", "cp a b", "chmod 777 a", "pip install x", "apt update", "npm install", - "minis-scheduled list", "android-shizuku-cli status", "git branch new-branch", + "minis-scheduled list", "android-root-cli status", "git branch new-branch", "git branch -D old-branch", "git diff --ext-diff", "git show --textconv", "git status && ls", "git status --help", "git log --show-signature", ) diff --git a/src/android/app/src/test/java/com/openminis/app/tools/android/vscreen/VirtualScreenPolicyTest.kt b/src/android/app/src/test/java/com/openminis/app/tools/android/vscreen/VirtualScreenPolicyTest.kt index 5ff6eaa2..c5d54411 100644 --- a/src/android/app/src/test/java/com/openminis/app/tools/android/vscreen/VirtualScreenPolicyTest.kt +++ b/src/android/app/src/test/java/com/openminis/app/tools/android/vscreen/VirtualScreenPolicyTest.kt @@ -70,4 +70,12 @@ class VirtualScreenPolicyTest { assertFalse(VirtualScreenProbeCachePolicy.isCurrentAndPassed(true, "rom|34", "rom|35")) assertFalse(VirtualScreenProbeCachePolicy.isCurrentAndPassed(true, null, "rom|34")) } + + @Test fun probePassedUnderTheShizukuHostDoesNotEnableTheRootHost() { + val current = VirtualScreenPreferences.currentDeviceFingerprint() + assertTrue(current.endsWith("|${VirtualScreenPreferences.SERVICE_HOST}")) + // What a pre-2026-10 build saved: the same device fields, no host marker. + val shizukuEra = current.removeSuffix("|${VirtualScreenPreferences.SERVICE_HOST}") + assertFalse(VirtualScreenProbeCachePolicy.isCurrentAndPassed(true, shizukuEra, current)) + } } diff --git a/src/android/app/src/test/java/com/openminis/app/ui/settings/SettingsReadinessTest.kt b/src/android/app/src/test/java/com/openminis/app/ui/settings/SettingsReadinessTest.kt index dac86c1e..9a38bf45 100644 --- a/src/android/app/src/test/java/com/openminis/app/ui/settings/SettingsReadinessTest.kt +++ b/src/android/app/src/test/java/com/openminis/app/ui/settings/SettingsReadinessTest.kt @@ -18,12 +18,12 @@ class SettingsReadinessTest { @Test fun checklistCoversEverythingSystemAndPermissionsManages() { val ids = items().map { it.id }.toSet() - // Root & Shizuku, all files, accessibility, overlay, assistant role, battery, notifications + // Root, all files, accessibility, overlay, assistant role, battery, notifications // and the Android grants behind the Agent's tools. assertTrue( ids.containsAll( listOf( - ReadinessId.ROOT, ReadinessId.SHIZUKU, ReadinessId.ALL_FILES, ReadinessId.ACCESSIBILITY, + ReadinessId.ROOT, ReadinessId.ALL_FILES, ReadinessId.ACCESSIBILITY, ReadinessId.OVERLAY, ReadinessId.ASSISTANT_ROLE, ReadinessId.BACKGROUND, ReadinessId.NOTIFICATIONS, ReadinessId.CALENDAR, ReadinessId.LOCATION, ReadinessId.CONTACTS, ReadinessId.PHOTOS, @@ -48,7 +48,7 @@ class SettingsReadinessTest { @Test fun optionalItemsAreNeverCountedButStayInTheChecklist() { val optional = listOf( - ReadinessId.SHIZUKU, ReadinessId.ACCESSIBILITY, ReadinessId.OVERLAY, ReadinessId.ASSISTANT_ROLE, + ReadinessId.ACCESSIBILITY, ReadinessId.OVERLAY, ReadinessId.ASSISTANT_ROLE, ReadinessId.CALENDAR, ReadinessId.LOCATION, ReadinessId.CONTACTS, ReadinessId.PHOTOS, ) val all = items(*optional.toTypedArray()) @@ -67,6 +67,11 @@ class SettingsReadinessTest { ) } + @Test fun shizukuIsNoLongerAChecklistItem() { + // Root-only since 2026-10: nothing in the checklist may point the user at Shizuku again. + assertFalse(ReadinessId.entries.any { it.name.contains("SHIZUKU") }) + } + @Test fun itemsThatDoNotApplyAreLeftOut() { // A device without the assistant role, and an Agent tool the user switched off. val list = items(without = setOf(ReadinessId.ASSISTANT_ROLE, ReadinessId.CALENDAR)) diff --git a/src/android/app/src/test/java/com/openminis/app/ui/settings/VirtualScreenCopyPolicyTest.kt b/src/android/app/src/test/java/com/openminis/app/ui/settings/VirtualScreenCopyPolicyTest.kt index 5dd7bd3e..d50549ec 100644 --- a/src/android/app/src/test/java/com/openminis/app/ui/settings/VirtualScreenCopyPolicyTest.kt +++ b/src/android/app/src/test/java/com/openminis/app/ui/settings/VirtualScreenCopyPolicyTest.kt @@ -26,9 +26,16 @@ class VirtualScreenCopyPolicyTest { } class VirtualScreenFailActionTest { - @Test fun shizukuProblemsPointToShizuku() { - for (code in listOf("shizuku_not_ready", "shizuku_unavailable", "permission_denied", "unexpected_user_service_uid")) { - assertEquals(code, VirtualScreenCopyPolicy.FailAction.SHIZUKU, VirtualScreenCopyPolicy.actionFor(code, xiaomi = false)) + @Test fun rootProblemsPointToTheRootPage() { + for (code in listOf("root_not_ready", "permission_denied", "unexpected_user_service_uid")) { + assertEquals(code, VirtualScreenCopyPolicy.FailAction.ROOT, VirtualScreenCopyPolicy.actionFor(code, xiaomi = false)) + } + } + + @Test fun retiredShizukuCodesNoLongerSendAnywhere() { + // Reports saved before the switch to Root still carry these; they must not offer a dead destination. + for (code in listOf("shizuku_not_ready", "shizuku_unavailable", "shizuku_shell_user_service")) { + assertEquals(code, VirtualScreenCopyPolicy.FailAction.NONE, VirtualScreenCopyPolicy.actionFor(code, xiaomi = false)) } } diff --git a/src/android/app/src/test/resources/prompts/legacy_modules_memory_off.txt b/src/android/app/src/test/resources/prompts/legacy_modules_memory_off.txt index b571c3f7..2aae395f 100644 --- a/src/android/app/src/test/resources/prompts/legacy_modules_memory_off.txt +++ b/src/android/app/src/test/resources/prompts/legacy_modules_memory_off.txt @@ -65,12 +65,12 @@ Android development debug loop (named agent tools): - Before reproducing, call android_logs mark_cursor for the target package. Then android_ui observe, operate by generation+ref, and call android_diagnose plus android_logs read since that cursor. - After editing, rebuild and redeploy, then repeat the SAME observation and UI action. Claim a fix only when real UI/process/log evidence shows the failure no longer occurs. - Prefer Accessibility observe/action; request screenshot/vision only when semantics are insufficient; use raw coordinates only as the final explicit fallback. STALE_UI_REF always means observe again — never reuse remembered coordinates. -- Full logcat, dumpsys, installs and force-stop may need authorized Shizuku or Root. Root, Shizuku, Accessibility, and Guest Runtime are independent capabilities, not a permission ladder. +- Full logcat, dumpsys, installs and force-stop may need authorized Root. Root, Accessibility, and Guest Runtime are independent capabilities, not a permission ladder. - The guest runtime is Ubuntu 24.04 in a session-isolated chroot entered by controlled Direct Root infrastructure. Never disable SELinux, never treat chroot as a security hypervisor, and never run untrusted build scripts as root. - Continuous self-update of Minis for Android is UNSUPPORTED: replacing this APK kills the current Agent process. Debug other packages unless a separate companion is introduced. Android-only tools (android-* CLIs): -CLI tools at /usr/local/bin with the `android-` prefix give you access to Android framework capabilities and on-device control. Invoke them from shell_execute like any other binary — they are already on PATH. Each tool prints JSON (or a short human-readable line) and supports --help for full usage. Tools gated by Shizuku or AccessibilityService return permission_denied when not granted — handle that gracefully and point the user at [Settings → Permissions](minis://settings/permissions). +CLI tools at /usr/local/bin with the `android-` prefix give you access to Android framework capabilities and on-device control. Invoke them from shell_execute like any other binary — they are already on PATH. Each tool prints JSON (or a short human-readable line) and supports --help for full usage. Tools gated by Root or AccessibilityService return permission_denied when not granted — handle that gracefully and point the user at [Settings → Permissions](minis://settings/permissions). - android-alarm — schedule alarms/timers in the system Clock app (`schedule --label [--repeat ONCE|DAILY|WEEKDAYS]`, `timer --label `, `open`). Alarms/timers are saved into the user's Android Clock — list/cancel are not supported (no system query API); tell the user to manage them from the Clock app's Alarms/Timers tabs (or `android-alarm open` / minis://views/alarm). - android-calendar — read/write the device calendar (`list --start YYYY-MM-DD [--end ...] [--max N]`; `create --title --start [--end ] [--description ] [--location ] [--all-day]`). - android-clipboard — `get | set [--label L] | clear`. @@ -84,7 +84,7 @@ CLI tools at /usr/local/bin with the `android-` prefix give you access to Androi - android-speak — device TTS (` [--rate F] [--pitch F] [--volume F]`; `--stop | --status`). - android-speech — microphone transcription (`listen [--language BCP47] [--max N] [--timeout SEC]`; `status`). Requires RECORD_AUDIO. - android-weather — Open-Meteo forecast (current + hourly + daily). No API key needed. -- android-shizuku-cli — invoke privileged Android system APIs (package management, settings, system commands) via Shizuku when granted. Curated subcommands return structured JSON; for anything not covered, fall back to `android-shizuku-cli exec ` which runs the command via `sh -c` with Shizuku privilege (same surface as `adb shell`). Run with no args (or --help) for the subcommand list. +- android-root-cli — invoke privileged Android system APIs (package management, settings, input, system commands) as root when Minis has Root and the user has allowed it. Curated subcommands return structured JSON; for anything not covered, fall back to `android-root-cli exec ` which runs the command via `sh -c` as root (everything `adb shell` can do, and more). Run with no args (or --help) for the subcommand list. - android-a11y-cli — drive system UI (read screen, tap, type, swipe, scroll) via the Android AccessibilityService when enabled. Run with no args (or --help) for the subcommand list. - minis-open : Opens a resource inside Minis without leaving the chat. Accepts http/https URLs (→ built-in WebKit preview) and chat-resource file paths under /var/minis/** (→ built-in file preview, routed by extension: images to the image viewer, .md to markdown preview, .html to HTML preview, .pdf/office docs to QuickLook, audio/video to the media player, else share sheet). Examples: minis-open https://example.com, minis-open /var/minis/workspace/report.md, minis-open /var/minis/attachments/chart.png. Prefer this over android-open for anything that can be previewed in-app so the user doesn't lose conversation context. Use android-open for non-web schemes (tel:, mailto:, geo:, intent:, etc.) or when the user explicitly wants the system handler. - minis-sessions-cli: Manage chat sessions. `list` recent or by date range, `search --keywords` cross-session, `messages --id` to read, `send` to create/continue a session, `retry` to re-run, `status` to check, `open` to navigate the app UI. Run --help for full options. diff --git a/src/android/app/src/test/resources/prompts/legacy_modules_memory_on.txt b/src/android/app/src/test/resources/prompts/legacy_modules_memory_on.txt index e129a3c3..53c4a900 100644 --- a/src/android/app/src/test/resources/prompts/legacy_modules_memory_on.txt +++ b/src/android/app/src/test/resources/prompts/legacy_modules_memory_on.txt @@ -67,12 +67,12 @@ Android development debug loop (named agent tools): - Before reproducing, call android_logs mark_cursor for the target package. Then android_ui observe, operate by generation+ref, and call android_diagnose plus android_logs read since that cursor. - After editing, rebuild and redeploy, then repeat the SAME observation and UI action. Claim a fix only when real UI/process/log evidence shows the failure no longer occurs. - Prefer Accessibility observe/action; request screenshot/vision only when semantics are insufficient; use raw coordinates only as the final explicit fallback. STALE_UI_REF always means observe again — never reuse remembered coordinates. -- Full logcat, dumpsys, installs and force-stop may need authorized Shizuku or Root. Root, Shizuku, Accessibility, and Guest Runtime are independent capabilities, not a permission ladder. +- Full logcat, dumpsys, installs and force-stop may need authorized Root. Root, Accessibility, and Guest Runtime are independent capabilities, not a permission ladder. - The guest runtime is Ubuntu 24.04 in a session-isolated chroot entered by controlled Direct Root infrastructure. Never disable SELinux, never treat chroot as a security hypervisor, and never run untrusted build scripts as root. - Continuous self-update of Minis for Android is UNSUPPORTED: replacing this APK kills the current Agent process. Debug other packages unless a separate companion is introduced. Android-only tools (android-* CLIs): -CLI tools at /usr/local/bin with the `android-` prefix give you access to Android framework capabilities and on-device control. Invoke them from shell_execute like any other binary — they are already on PATH. Each tool prints JSON (or a short human-readable line) and supports --help for full usage. Tools gated by Shizuku or AccessibilityService return permission_denied when not granted — handle that gracefully and point the user at [Settings → Permissions](minis://settings/permissions). +CLI tools at /usr/local/bin with the `android-` prefix give you access to Android framework capabilities and on-device control. Invoke them from shell_execute like any other binary — they are already on PATH. Each tool prints JSON (or a short human-readable line) and supports --help for full usage. Tools gated by Root or AccessibilityService return permission_denied when not granted — handle that gracefully and point the user at [Settings → Permissions](minis://settings/permissions). - android-alarm — schedule alarms/timers in the system Clock app (`schedule --label [--repeat ONCE|DAILY|WEEKDAYS]`, `timer --label `, `open`). Alarms/timers are saved into the user's Android Clock — list/cancel are not supported (no system query API); tell the user to manage them from the Clock app's Alarms/Timers tabs (or `android-alarm open` / minis://views/alarm). - android-calendar — read/write the device calendar (`list --start YYYY-MM-DD [--end ...] [--max N]`; `create --title --start [--end ] [--description ] [--location ] [--all-day]`). - android-clipboard — `get | set [--label L] | clear`. @@ -86,7 +86,7 @@ CLI tools at /usr/local/bin with the `android-` prefix give you access to Androi - android-speak — device TTS (` [--rate F] [--pitch F] [--volume F]`; `--stop | --status`). - android-speech — microphone transcription (`listen [--language BCP47] [--max N] [--timeout SEC]`; `status`). Requires RECORD_AUDIO. - android-weather — Open-Meteo forecast (current + hourly + daily). No API key needed. -- android-shizuku-cli — invoke privileged Android system APIs (package management, settings, system commands) via Shizuku when granted. Curated subcommands return structured JSON; for anything not covered, fall back to `android-shizuku-cli exec ` which runs the command via `sh -c` with Shizuku privilege (same surface as `adb shell`). Run with no args (or --help) for the subcommand list. +- android-root-cli — invoke privileged Android system APIs (package management, settings, input, system commands) as root when Minis has Root and the user has allowed it. Curated subcommands return structured JSON; for anything not covered, fall back to `android-root-cli exec ` which runs the command via `sh -c` as root (everything `adb shell` can do, and more). Run with no args (or --help) for the subcommand list. - android-a11y-cli — drive system UI (read screen, tap, type, swipe, scroll) via the Android AccessibilityService when enabled. Run with no args (or --help) for the subcommand list. - minis-open : Opens a resource inside Minis without leaving the chat. Accepts http/https URLs (→ built-in WebKit preview) and chat-resource file paths under /var/minis/** (→ built-in file preview, routed by extension: images to the image viewer, .md to markdown preview, .html to HTML preview, .pdf/office docs to QuickLook, audio/video to the media player, else share sheet). Examples: minis-open https://example.com, minis-open /var/minis/workspace/report.md, minis-open /var/minis/attachments/chart.png. Prefer this over android-open for anything that can be previewed in-app so the user doesn't lose conversation context. Use android-open for non-web schemes (tel:, mailto:, geo:, intent:, etc.) or when the user explicitly wants the system handler. - minis-sessions-cli: Manage chat sessions. `list` recent or by date range, `search --keywords` cross-session, `messages --id` to read, `send` to create/continue a session, `retry` to re-run, `status` to check, `open` to navigate the app UI. Run --help for full options. From b8b8727e4ab5e01f32ecca7fba76a3a791c51ec6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 11:12:03 +0000 Subject: [PATCH 2/5] docs: record MCP -> linux.shell -> android-root-cli exec as accepted design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remote MCP path stays gated by the one-shot MCP_CONFIRM approval, which AutonomyMode (全自动) does not bypass, and by the default-off root_cli switch. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01PV4ECd3Yzu9k81sw7vHjS3 --- docs/contracts/04-SECURITY-CONTRACT.md | 2 +- docs/contracts/06-CURRENT-GAPS.md | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/contracts/04-SECURITY-CONTRACT.md b/docs/contracts/04-SECURITY-CONTRACT.md index 2c9ffd76..fb81d5e1 100644 --- a/docs/contracts/04-SECURITY-CONTRACT.md +++ b/docs/contracts/04-SECURITY-CONTRACT.md @@ -16,7 +16,7 @@ - `root.shell` 的 tool 不得是会执行其它代码的程序:shell、`su` 类前端、`toybox`/`busybox` 等多合一程序、`env`/`xargs`/`nohup`/`nsenter` 等启动器、解释器,以及 `find -exec`、`tar --to-command`、`sqlite3 .shell`、`ip netns exec` 这类执行命令的参数。否则数组形式的 argv 照样等于 raw Root 命令。这是在可信路径与 argv 边界之上的拒绝清单,不代表其余 Root 工具无副作用。 - `DirectRootRunner` 的内部脚本动作限于确实需要权限的 rootfs 探测/修复、namespace/bind/chroot、受控 legacy 数据迁移及同类明确基础设施需求;本地 Agent 的 `root.shell` 只走结构化 Android tool/argv 合同,不得被扩展成 raw shell、host 文件或通用 RPC。 - 例外之一:`minis-apt`(guest CLI,经 `AptOffloadHandler`)。guest 以 App UID 运行、无法使用 apt,因此由 App 代为在 rootfs 的一次性 mount namespace 内以 Root 执行 `apt-get update|install|remove`。Agent 只给出包名,命令全部由 App 构造(`AptCommandPolicy`):只接受 `^[a-z0-9][a-z0-9+.-]{0,62}$` 形式的纯包名(拒绝选项、版本/发行版指定、路径、shell 字符),每次最多 20 个;`remove` 先用 apt 的模拟结果检查,若会连带移除运行时必需的包(`UbuntuProvisioner.BASE_PACKAGES` 等)则在移除前拒绝;同一时间只运行一个;走与其它 guest CLI 相同的权限开关(`apt_cli`,设置里可关闭);只影响 Ubuntu 环境,不触及 Android 系统与宿主文件。它不是通用 Root 执行入口,不接受原始命令。 -- 例外之二:`android-root-cli`(guest CLI,经 `RootCliOffloadHandler` 与 `RootProcess`)。它以 root 运行 Android 系统工具:`package`/`permission`/`activity`/`display`/`settings`/`user`/`network`/`input`/`notification`/`file`/`device` 等整理过的子命令(参数向量由 App 构造),外加 `exec`——把剩余参数拼成一条命令交给 root 的 `sh -c`。`exec` 是有意给本地 Agent 的原始 root shell:2026-10 起项目只走 Root,按维护者的决定给 Agent 尽量大的权限,只防止把手机弄坏;此前同一能力是经 Shizuku 的 `android-shizuku-cli`(Shizuku 以 Root 启动时同样是 uid 0)。约束:只能从 guest shell 调用;受 `root_cli` 权限开关控制,默认关闭;su 缺失或被拒时直接返回 `SERVICE_NOT_RUNNING` / `PERMISSION_DENIED`,不运行任何命令。设备完整性保护(拦截写分区、系统分区、`/data` 系统数据、卸载核心系统包等)尚未实现,见 Issue #182;MCP 调用方经 `linux.shell` 也能到达它,见 `06-CURRENT-GAPS.md`。 +- 例外之二:`android-root-cli`(guest CLI,经 `RootCliOffloadHandler` 与 `RootProcess`)。它以 root 运行 Android 系统工具:`package`/`permission`/`activity`/`display`/`settings`/`user`/`network`/`input`/`notification`/`file`/`device` 等整理过的子命令(参数向量由 App 构造),外加 `exec`——把剩余参数拼成一条命令交给 root 的 `sh -c`。`exec` 是有意给本地 Agent 的原始 root shell:2026-10 起项目只走 Root,按维护者的决定给 Agent 尽量大的权限,只防止把手机弄坏;此前同一能力是经 Shizuku 的 `android-shizuku-cli`(Shizuku 以 Root 启动时同样是 uid 0)。约束:只能从 guest shell 调用;受 `root_cli` 权限开关控制,默认关闭;su 缺失或被拒时直接返回 `SERVICE_NOT_RUNNING` / `PERMISSION_DENIED`,不运行任何命令。设备完整性保护(拦截写分区、系统分区、`/data` 系统数据、卸载核心系统包等)尚未实现,见 Issue #182;远程 MCP 调用方经 `linux.shell` 也能到达它:这是维护者接受的设计——每次调用都要用户在手机上单独批准(一次性 confirm,与「全自动」模式无关,不会被它跳过),且 `root_cli` 开关默认关闭。未经批准的远程请求拿不到 root shell。 - 虚拟屏的 root 服务(`VirtualScreenRootService`,由 libsu 经 `su` 以 `app_process` 拉起)是专用 binder 接口:只暴露 `IVirtualScreenService` 里固定的虚拟屏操作(建屏、启动应用、点按输入、截图、帧流),不提供命令、文件或通用 RPC,也不监听网络或 socket;binder 只交回本 App。 - Guest shell 进入 chroot 后必须通过 `setpriv` 切到真实 App UID/GID,清空 supplementary groups 与 Linux capabilities。 - Root-provider identity 只是可用性信号;uid=0 不代表 SELinux、mount、namespace 或 capability 一定允许操作。 diff --git a/docs/contracts/06-CURRENT-GAPS.md b/docs/contracts/06-CURRENT-GAPS.md index dc0773d7..ea6ca068 100644 --- a/docs/contracts/06-CURRENT-GAPS.md +++ b/docs/contracts/06-CURRENT-GAPS.md @@ -216,9 +216,12 @@ config registry 暴露为 `prompt.custom`(带确认与审计回退)。内置 已知缺口: -- MCP 调用方可以经 `linux.shell`(需用户确认)在 guest 里调用 `android-root-cli exec`,等于远程拿到 root shell,与「禁止向远程调用方提供任意 Root shell」冲突。移除前的 `android-shizuku-cli` 有同样问题。应让 `RootCliOffloadHandler` 只接受本地 Agent 与终端 session 的请求。 - 设备完整性保护(Issue #182)尚未实现:`exec`、`file rm -r`、`package uninstall` 等目前没有任何保护线。 +已接受的设计(不是缺口): + +- 远程 MCP 调用方可以经 `linux.shell` 在 guest 里调用 `android-root-cli exec`。维护者决定保留:每次调用都需要用户在手机上批准一次性 confirm(`MCPServer.handleToolCall` 的 MCP_CONFIRM 门,`AutonomyMode` 不参与,「全自动」不会跳过它),且 `root_cli` 开关默认关闭。这与 `root.shell`(本地专用、MCP 不可见)是两回事;改为拒绝远程调用需另开 PR。 + ## 2026-10-08 之后新增的未验证项 下列行为已有单元测试或模拟器测试,**没有**在真机上完整验证,不得据此声称设备结论: From fdf69aee8b6a38ad19bc7521d38e05f8d171604f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 11:19:41 +0000 Subject: [PATCH 3/5] =?UTF-8?q?feat(integrity):=20DeviceIntegrityPolicy=20?= =?UTF-8?q?=E2=80=94=20pure=20policy=20for=20#182?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refuses only what can leave the phone unbootable or unusable: block-device and system-partition writes, /data system state, core packages, factory reset and user 0 removal. Path tricks (case, ./.., //, /proc/*/root) and sh -c / su -c / env / find -exec wrappers are followed. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01PV4ECd3Yzu9k81sw7vHjS3 --- .../app/integrity/DeviceIntegrityPolicy.kt | 581 ++++++++++++++++++ .../integrity/DeviceIntegrityPolicyTest.kt | 179 ++++++ 2 files changed, 760 insertions(+) create mode 100644 src/android/app/src/main/java/com/openminis/app/integrity/DeviceIntegrityPolicy.kt create mode 100644 src/android/app/src/test/java/com/openminis/app/integrity/DeviceIntegrityPolicyTest.kt diff --git a/src/android/app/src/main/java/com/openminis/app/integrity/DeviceIntegrityPolicy.kt b/src/android/app/src/main/java/com/openminis/app/integrity/DeviceIntegrityPolicy.kt new file mode 100644 index 00000000..cf214a6e --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/integrity/DeviceIntegrityPolicy.kt @@ -0,0 +1,581 @@ +package com.openminis.app.integrity + +/** + * What the device-integrity protection (Issue #182) refuses to do, in pure Kotlin. + * + * The line is one question: can the worst outcome be fixed by a reboot? If so the operation is open. + * Only operations that can leave the phone unable to boot or unusable are refused, and a refusal is a + * fixed error ([Denial.message]) with no prompt: the agent reads it and tries another way. + * + * This class answers for the cases that can be recognised from a command's words. It is the first + * line, giving a clear `DEVICE_PROTECTED` error. The second line is the protected view + * ([ProtectedView]) that the command actually runs in: read-only bind mounts, no writable block + * nodes, no `CAP_SYS_ADMIN`. The view is what holds when a command hides its target in a variable, a + * `$(…)` or a script file; this class cannot see through those and does not pretend to. + * + * Out of scope on purpose: raw binder transactions (`service call`) skip the front ends checked here. + * The protection stops a slip, not a deliberate bypass (see `06-CURRENT-GAPS.md`). + */ +class DeviceIntegrityPolicy( + private val packages: CorePackageResolver = CorePackageResolver.StaticOnly, +) { + enum class Category(val label: String) { + PARTITION("partition"), + SYSTEM_PARTITION("system-partition"), + DATA_SYSTEM("data-system"), + CORE_PACKAGE("core-package"), + DEVICE_STATE("device-state"), + STORAGE("storage"), + } + + data class Denial(val category: Category, val target: String) { + /** The one error text every entry point returns. */ + val message: String get() = "DEVICE_PROTECTED: ${category.label}: $target" + } + + /** Check one tool with its arguments, e.g. `root.shell` or an `android-root-cli` front end. */ + fun checkArgv(tool: String, args: List): Denial? = Checker().simple(Simple(listOf(tool) + args, emptyList())) + + /** Check a shell script, as given to `android-root-cli exec`. */ + fun checkScript(script: String): Denial? = Checker().script(script, 0) + + /** Check a path that a structured operation will write to (a file push destination, say). */ + fun checkWritePath(path: String): Denial? = Checker().path(path, destructive = false) + + /** Check a path that a structured operation will delete or rename. */ + fun checkDeletePath(path: String): Denial? = Checker().path(path, destructive = true) + + /** Whether the package-changing operation may touch [packageName]. */ + fun checkPackageOperation(operation: String, packageName: String): Denial? = + if (packages.isCore(packageName)) Denial(Category.CORE_PACKAGE, "$operation $packageName") else null + + // ------------------------------------------------------------------------------------------ + + internal data class Simple(val words: List, val redirects: List) + + private inner class Checker { + var cwd: String = "/" + + fun script(text: String, depth: Int): Denial? { + if (depth > MAX_DEPTH) return null + for (command in ShellWords.split(text)) { + simpleAt(command, depth)?.let { return it } + } + return null + } + + fun simple(command: Simple): Denial? = simpleAt(command, 0) + + private fun simpleAt(command: Simple, depth: Int): Denial? { + for (target in command.redirects) { + path(target, destructive = false)?.let { return it } + } + var words = command.words + // Leading VAR=value assignments do not change which command runs. + while (words.isNotEmpty() && ASSIGNMENT.matches(words.first())) words = words.drop(1) + if (words.isEmpty()) return null + var name = baseName(words.first()) + var args = words.drop(1) + + // Unwrap launchers until the command that does the work is reached. + var guard = 0 + while (guard++ < MAX_DEPTH) { + when { + name in SHELLS || name == "su" -> { + val c = args.indexOfFirst { it == "-c" || (it.startsWith("-") && !it.startsWith("--") && it.endsWith("c") && it.length <= 4) } + if (c >= 0 && c + 1 < args.size) return script(args[c + 1], depth + 1) + return null + } + name in WRAPPERS -> { + val rest = unwrap(name, args) ?: return null + if (rest.isEmpty()) return null + name = baseName(rest.first()) + args = rest.drop(1) + } + else -> break + } + } + return command(name, args, depth) + } + + private fun command(name: String, args: List, depth: Int): Denial? { + val operands = args.filter { !it.startsWith("-") || it == "-" } + return when { + name == "cd" -> { + operands.firstOrNull()?.let { cwd = resolve(it) } + null + } + name in DESTRUCTIVE -> firstPathDenial(args, destructive = true) + name in WRITERS -> firstPathDenial(args, destructive = false) + name == "dd" -> args.firstNotNullOfOrNull { arg -> + if (arg.startsWith("of=")) path(arg.removePrefix("of="), destructive = false) else null + } + name == "cp" || name == "install" || name == "rsync" || name == "ln" -> copyDenial(args) + name == "mv" -> firstPathDenial(args, destructive = true) + name == "tee" -> firstPathDenial(args, destructive = false) + name == "sed" || name == "perl" -> if (args.any { it == "-i" || it.startsWith("-i") || it == "--in-place" }) { + firstPathDenial(args, destructive = false) + } else { + null + } + name == "find" -> findDenial(args, depth) + name == "mount" -> mountDenial(args) + name == "umount" -> null + name.startsWith("mkfs") || name in BLOCK_TOOLS -> blockToolDenial(args) + name == "recovery" -> if (args.any { it.contains("wipe", ignoreCase = true) }) { + Denial(Category.DEVICE_STATE, "recovery ${args.joinToString(" ")}".trim()) + } else { + null + } + name == "am" -> amDenial(args) + name == "pm" -> pmDenial(args) + name == "cmd" -> cmdDenial(args) + name == "settings" -> settingsDenial(args) + name == "wipe" -> Denial(Category.DEVICE_STATE, "wipe") + else -> null + } + } + + // ---- launchers ---- + + /** The command that a launcher runs, or null when its arguments are not a command we follow. */ + private fun unwrap(name: String, args: List): List? { + var rest = args + when (name) { + "env" -> { + rest = rest.dropWhile { it.startsWith("-") || ASSIGNMENT.matches(it) } + } + "timeout" -> { + rest = rest.dropWhile { it.startsWith("-") } + rest = rest.drop(1) // the duration + } + "nice", "ionice", "chrt", "taskset" -> { + rest = rest.dropWhile { it.startsWith("-") || it.toIntOrNull() != null } + } + "busybox", "toybox", "toolbox", "command", "exec", "time", "nohup", "setsid", "stdbuf", "xargs", "flock", "sudo", "doas" -> { + rest = rest.dropWhile { it.startsWith("-") } + if (name == "flock") rest = rest.drop(1) + } + } + return rest + } + + // ---- per-command rules ---- + + private fun firstPathDenial(args: List, destructive: Boolean): Denial? { + for (arg in args) { + if (arg.startsWith("-")) continue + path(arg.substringAfter('=', arg), destructive)?.let { return it } + } + return null + } + + private fun copyDenial(args: List): Denial? { + val target = args.indexOfFirst { it == "-t" || it.startsWith("--target-directory") } + if (target >= 0) { + val dir = if (args[target].contains('=')) args[target].substringAfter('=') else args.getOrNull(target + 1) + if (dir != null) path(dir, destructive = false)?.let { return it } + } + val operands = args.filter { !it.startsWith("-") } + return operands.lastOrNull()?.let { path(it, destructive = false) } + } + + private fun findDenial(args: List, depth: Int): Denial? { + val roots = args.takeWhile { !it.startsWith("-") && it != "(" && it != "!" } + if (args.any { it == "-delete" || it == "-fdelete" }) { + for (root in roots) path(root, destructive = true)?.let { return it } + } + var i = 0 + while (i < args.size) { + if (args[i] in setOf("-exec", "-execdir", "-ok", "-okdir")) { + val end = args.drop(i + 1).indexOfFirst { it == ";" || it == "+" || it == "\\;" } + val inner = if (end >= 0) args.subList(i + 1, i + 1 + end) else args.drop(i + 1) + // The words after `-exec` run with the found files; also guard the search roots. + if (inner.isNotEmpty()) { + val name = baseName(inner.first()) + val innerArgs = inner.drop(1) + if (name in DESTRUCTIVE || name in WRITERS || name == "mv" || name == "cp") { + for (root in roots) path(root, destructive = name !in WRITERS)?.let { return it } + } + command(name, innerArgs.filter { it != "{}" }, depth + 1)?.let { return it } + } + i += if (end >= 0) end + 2 else args.size + } else { + i++ + } + } + return null + } + + private fun mountDenial(args: List): Denial? { + val options = args.indexOf("-o").let { if (it >= 0) args.getOrNull(it + 1).orEmpty() else "" } + val remount = options.contains("remount") || args.any { it.contains("remount") } + val operands = args.filter { !it.startsWith("-") && it != options } + for (operand in operands) { + if (!operand.startsWith("/")) continue + val normalized = normalize(resolve(operand)) + if (remount && normalized == "/") return Denial(Category.SYSTEM_PARTITION, "remount /") + path(operand, destructive = false)?.let { return it } + if (remount && isSystemPartitionRoot(normalized)) return Denial(Category.SYSTEM_PARTITION, "remount $normalized") + } + return null + } + + private fun blockToolDenial(args: List): Denial? { + for (arg in args) { + if (!arg.startsWith("/")) continue + val n = normalize(resolve(arg)) + if (n.startsWith("/dev/block") || n.startsWith("/dev/mem") || n.startsWith("/dev/kmem")) { + return Denial(Category.PARTITION, n) + } + } + return null + } + + private fun amDenial(args: List): Denial? { + val hit = args.firstOrNull { arg -> FACTORY_RESET_ACTIONS.any { arg.contains(it, ignoreCase = true) } } + return hit?.let { Denial(Category.DEVICE_STATE, it) } + } + + private fun pmDenial(args: List): Denial? { + val verbAt = args.indexOfFirst { !it.startsWith("-") } + if (verbAt < 0) return null + return packageVerb(args[verbAt].lowercase(), args.drop(verbAt + 1)) + } + + private fun cmdDenial(args: List): Denial? { + val serviceAt = args.indexOfFirst { !it.startsWith("-") } + if (serviceAt < 0) return null + val after = args.drop(serviceAt + 1) + return when (args[serviceAt].lowercase()) { + "package" -> pmDenial(after) + "user" -> { + val verbAt = after.indexOfFirst { !it.startsWith("-") } + if (verbAt < 0) null else userVerb(after[verbAt].lowercase(), after.drop(verbAt + 1)) + } + "recovery" -> if (after.any { it.contains("wipe", ignoreCase = true) }) { + Denial(Category.DEVICE_STATE, "cmd recovery wipe") + } else { + null + } + else -> null + } + } + + private fun packageVerb(verb: String, rest: List): Denial? { + if (verb == "remove-user") return userVerb(verb, rest) + if (verb !in PACKAGE_VERBS) return null + val user = rest.windowed(2).firstOrNull { it[0] == "--user" }?.get(1) + for (token in rest) { + val pkg = token.substringBefore('/') + if (pkg != "android" && !PACKAGE_NAME.matches(pkg)) continue + if (packages.isCore(pkg)) { + return Denial(Category.CORE_PACKAGE, "$verb $pkg" + (user?.let { " (user $it)" } ?: "")) + } + } + return null + } + + private fun userVerb(verb: String, rest: List): Denial? { + if (verb != "remove-user" && verb != "remove" && verb != "rm") return null + val id = rest.lastOrNull { !it.startsWith("-") } + return if (id == "0") Denial(Category.DEVICE_STATE, "remove user 0") else null + } + + private fun settingsDenial(args: List): Denial? { + val verbAt = args.indexOfFirst { it == "put" || it == "delete" } + if (verbAt < 0) return null + val after = args.drop(verbAt + 1).filter { !it.startsWith("--") } + val key = after.getOrNull(1) ?: return null + if (key !in PROVISION_KEYS) return null + if (args[verbAt] == "delete") return Denial(Category.DEVICE_STATE, "settings delete $key") + return if (after.getOrNull(2) == "0") Denial(Category.DEVICE_STATE, "settings put $key 0") else null + } + + // ---- paths ---- + + fun path(raw: String, destructive: Boolean): Denial? { + if (raw.isEmpty()) return null + val glob = raw.indexOfFirst { it == '*' || it == '?' || it == '[' } + if (glob >= 0) return globDenial(raw, glob, destructive) + val normalized = normalize(resolve(raw)) + zone(normalized)?.let { return it } + if (destructive && normalized.lowercase() in PROTECTED_NODES) { + return Denial(Category.DATA_SYSTEM, normalized) + } + return null + } + + /** A glob is refused when anything it could match is protected. */ + private fun globDenial(raw: String, glob: Int, destructive: Boolean): Denial? { + val literal = raw.substring(0, glob) + var lit = normalize(resolve(literal)) + if (literal.endsWith("/") && !lit.endsWith("/")) lit += "/" + val lower = lit.lowercase() + zone(lit.trimEnd('/').ifEmpty { "/" })?.let { return it.copy(target = raw) } + if (ZONES.any { it.path.startsWith(lower) }) { + return Denial(ZONES.first { it.path.startsWith(lower) }.category, raw) + } + if (destructive && PROTECTED_NODES.any { it.startsWith(lower) }) return Denial(Category.DATA_SYSTEM, raw) + return null + } + + private fun zone(normalized: String): Denial? { + val lower = normalized.lowercase() + for (z in ZONES) { + if (underZone(lower, z)) { + if (z.path == "/data/adb" && (lower == MINIS_DIR || lower.startsWith("$MINIS_DIR/"))) return null + return Denial(z.category, normalized) + } + } + return null + } + + private fun underZone(lower: String, z: Zone): Boolean = + if (z.prefix) lower.startsWith(z.path) else lower == z.path || lower.startsWith(z.path + "/") + + private fun resolve(raw: String): String = if (raw.startsWith("/")) raw else "$cwd/$raw" + } + + private data class Zone(val path: String, val category: Category, val prefix: Boolean = false) + + companion object { + private const val MAX_DEPTH = 6 + private const val MINIS_DIR = "/data/adb/minis" + private val ASSIGNMENT = Regex("""[A-Za-z_][A-Za-z0-9_]*=.*""") + private val PACKAGE_NAME = Regex("""[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+""") + + private val ZONES = listOf( + Zone("/dev/block", Category.PARTITION), + Zone("/dev/mem", Category.PARTITION), + Zone("/dev/kmem", Category.PARTITION), + Zone("/system", Category.SYSTEM_PARTITION), + Zone("/system_ext", Category.SYSTEM_PARTITION), + Zone("/vendor", Category.SYSTEM_PARTITION), + Zone("/product", Category.SYSTEM_PARTITION), + Zone("/odm", Category.SYSTEM_PARTITION), + Zone("/apex", Category.SYSTEM_PARTITION), + Zone("/data/system", Category.DATA_SYSTEM, prefix = true), + Zone("/data/misc", Category.DATA_SYSTEM, prefix = true), + Zone("/data/vendor", Category.DATA_SYSTEM, prefix = true), + Zone("/data/property", Category.DATA_SYSTEM), + Zone("/data/apex", Category.DATA_SYSTEM), + Zone("/data/unencrypted", Category.DATA_SYSTEM), + Zone("/data/adb", Category.DATA_SYSTEM), + Zone("/metadata", Category.DATA_SYSTEM), + Zone("/cache/recovery", Category.DEVICE_STATE), + ) + + /** Top-level data nodes that may be written under but never removed or renamed themselves. */ + private val PROTECTED_NODES = setOf( + "/", "/data", "/data/data", "/data/user", "/data/user_de", "/data/app", "/data/media", + ) + + private val SYSTEM_PARTITION_ROOTS = setOf("/system", "/system_ext", "/vendor", "/product", "/odm", "/apex", "/") + private fun isSystemPartitionRoot(path: String) = path.lowercase() in SYSTEM_PARTITION_ROOTS + + private val SHELLS = setOf("sh", "bash", "mksh", "ash", "dash", "zsh", "ksh") + private val WRAPPERS = setOf( + "env", "nohup", "setsid", "nice", "ionice", "chrt", "taskset", "timeout", "time", "busybox", + "toybox", "toolbox", "command", "exec", "stdbuf", "xargs", "flock", "sudo", "doas", + ) + + private val DESTRUCTIVE = setOf("rm", "rmdir", "unlink", "shred") + private val WRITERS = setOf( + "truncate", "touch", "mkdir", "chmod", "chown", "chgrp", "chcon", "setfattr", "restorecon", + "mknod", "mkfifo", + ) + private val BLOCK_TOOLS = setOf( + "blkdiscard", "sgdisk", "sfdisk", "fdisk", "parted", "wipefs", "flash_erase", "mke2fs", "make_f2fs", + "fsck", "e2fsck", "fsck.f2fs", "tune2fs", "resize2fs", "resize.f2fs", "fastboot", "flash_image", + "erase_image", + ) + + private val FACTORY_RESET_ACTIONS = listOf("FACTORY_RESET", "MASTER_CLEAR", "wipe_data") + private val PROVISION_KEYS = setOf("device_provisioned", "user_setup_complete") + private val PACKAGE_VERBS = setOf( + "uninstall", "disable", "disable-user", "disable-until-used", "clear", "suspend", "hide", + ) + + private fun baseName(word: String): String = word.substringAfterLast('/').lowercase() + + /** Lexical normalisation: `//`, `.`, `..`, and `/proc//root` all resolve to the same path. */ + internal fun normalize(path: String): String { + val parts = ArrayDeque() + for (part in path.split('/')) { + when (part) { + "", "." -> {} + ".." -> if (parts.isNotEmpty()) parts.removeLast() + else -> parts.addLast(part) + } + } + // /proc/|self/root/… is the root directory seen through procfs. + if (parts.size >= 3 && parts[0] == "proc" && parts[2] == "root" && + (parts[1] == "self" || parts[1] == "thread-self" || parts[1].all { it.isDigit() }) + ) { + repeat(3) { parts.removeFirst() } + } + return "/" + parts.joinToString("/") + } + } +} + +/** Decides whether a package is one the phone needs to boot and be usable. */ +interface CorePackageResolver { + fun isCore(packageName: String): Boolean + + /** Rules that need no device: used on the host and as the floor on a device. */ + object StaticOnly : CorePackageResolver { + override fun isCore(packageName: String): Boolean = CorePackageRules.isStaticCore(packageName) + } +} + +object CorePackageRules { + /** Packages whose loss stops boot or the unlock screen on AOSP and the OEMs in use. */ + private val STATIC = setOf( + "android", + "com.android.settings", + "com.android.systemui", + "com.android.providers.settings", + "com.android.permissioncontroller", + "com.google.android.permissioncontroller", + "com.android.packageinstaller", + "com.google.android.packageinstaller", + "com.android.shell", + "com.android.phone", + "com.android.server.telecom", + "com.android.inputdevices", + "com.android.keychain", + "com.android.location.fused", + "com.android.providers.contacts", + "com.android.providers.telephony", + "com.android.providers.media.module", + "com.android.providers.downloads", + "com.android.externalstorage", + "com.android.mtp", + "com.android.nfc", + "com.android.bluetooth", + // Xiaomi / HyperOS; needs a device check (see 06-CURRENT-GAPS.md). + "com.miui.securitycenter", + "com.lbe.security.miui", + "com.miui.home", + "com.xiaomi.xmsf", + "com.miui.system", + // This app. + "llc.slacker.minis", + ) + + fun isStaticCore(packageName: String): Boolean = packageName in STATIC + + /** Facts about one installed package that the device resolver gathers. */ + data class Facts( + val packageName: String, + val persistent: Boolean = false, + val sharedUserId: String? = null, + val isCurrentHome: Boolean = false, + val isDefaultInputMethod: Boolean = false, + ) + + fun isCore(facts: Facts): Boolean = + isStaticCore(facts.packageName) || + facts.persistent || + facts.sharedUserId == "android.uid.system" || + facts.sharedUserId == "android.uid.phone" || + facts.isCurrentHome || + facts.isDefaultInputMethod +} + +/** + * Splits a shell script into simple commands without running anything. Quotes and backslashes are + * honoured; `;`, `&`, `|`, newlines, parentheses and braces end a command, so what is inside `$( )` or + * a subshell is checked as a command of its own. Redirection targets are kept apart from the words. + */ +internal object ShellWords { + fun split(script: String): List { + val commands = mutableListOf() + var words = mutableListOf() + var redirects = mutableListOf() + val current = StringBuilder() + var inWord = false + var pendingRedirect = false + + fun endWord() { + if (!inWord) return + val word = current.toString() + current.clear() + inWord = false + if (pendingRedirect) { + redirects += word + pendingRedirect = false + } else { + words += word + } + } + + fun endCommand() { + endWord() + pendingRedirect = false + if (words.isNotEmpty() || redirects.isNotEmpty()) { + commands += DeviceIntegrityPolicy.Simple(words, redirects) + } + words = mutableListOf() + redirects = mutableListOf() + } + + var i = 0 + while (i < script.length) { + val c = script[i] + when { + c == '\\' && i + 1 < script.length -> { + current.append(script[i + 1]); inWord = true; i += 2 + } + c == '\'' -> { + val end = script.indexOf('\'', i + 1).let { if (it < 0) script.length else it } + current.append(script, i + 1, end); inWord = true; i = end + 1 + } + c == '"' -> { + var j = i + 1 + while (j < script.length && script[j] != '"') { + if (script[j] == '\\' && j + 1 < script.length) j++ + current.append(script[j]); j++ + } + inWord = true; i = j + 1 + } + c == ' ' || c == '\t' -> { endWord(); i++ } + c == '\n' || c == ';' || c == '|' || c == '(' || c == ')' || c == '{' || c == '}' || c == '`' -> { + // `$(` starts a command; the `$` is left on the previous word, which is harmless. + endCommand(); i++ + } + c == '&' -> { + if (current.endsWith(">") || (i + 1 < script.length && script[i + 1] == '>')) { + // `&>file`: treat as a redirect operator. + endWord(); pendingRedirect = true + i += if (i + 1 < script.length && script[i + 1] == '>') 2 else 1 + if (i < script.length && script[i] == '>') i++ + } else { + endCommand(); i++ + } + } + c == '>' -> { + // `2>/dev/null` and `1>file`: a leading digit is the descriptor, not a word. + if (inWord && current.all { it.isDigit() }) { current.clear(); inWord = false } else endWord() + var j = i + 1 + if (j < script.length && (script[j] == '>' || script[j] == '|')) j++ + if (j < script.length && script[j] == '&') { + // `>&2`: duplicates a descriptor, no file. + j++ + while (j < script.length && script[j].isDigit()) j++ + pendingRedirect = false + } else { + pendingRedirect = true + } + i = j + } + c == '<' -> { endWord(); pendingRedirect = false; i++ } + else -> { current.append(c); inWord = true; i++ } + } + } + endCommand() + return commands + } +} diff --git a/src/android/app/src/test/java/com/openminis/app/integrity/DeviceIntegrityPolicyTest.kt b/src/android/app/src/test/java/com/openminis/app/integrity/DeviceIntegrityPolicyTest.kt new file mode 100644 index 00000000..a4870ccc --- /dev/null +++ b/src/android/app/src/test/java/com/openminis/app/integrity/DeviceIntegrityPolicyTest.kt @@ -0,0 +1,179 @@ +package com.openminis.app.integrity + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class DeviceIntegrityPolicyTest { + private val home = "com.example.launcher" + private val ime = "com.example.keyboard" + private val policy = DeviceIntegrityPolicy(object : CorePackageResolver { + override fun isCore(packageName: String) = + CorePackageRules.isStaticCore(packageName) || packageName == home || packageName == ime + }) + + private fun argv(vararg w: String) = policy.checkArgv(w.first(), w.drop(1)) + private fun script(s: String) = policy.checkScript(s) + + private fun assertDenied(d: DeviceIntegrityPolicy.Denial?, category: DeviceIntegrityPolicy.Category) { + assertNotNull("expected DEVICE_PROTECTED", d) + assertEquals(category, d!!.category) + check(d.message.startsWith("DEVICE_PROTECTED: ${category.label}: ")) + } + + // ---- root.shell (argv) denials ---- + + @Test fun `dd to a block device is refused`() = + assertDenied(argv("dd", "if=/dev/zero", "of=/dev/block/by-name/boot"), DeviceIntegrityPolicy.Category.PARTITION) + + @Test fun `rm of data system is refused`() = + assertDenied(argv("rm", "-rf", "/data/system"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + + @Test fun `remount of system is refused`() = + assertDenied(argv("mount", "-o", "remount,rw", "/system"), DeviceIntegrityPolicy.Category.SYSTEM_PARTITION) + + @Test fun `uninstalling systemui is refused`() = + assertDenied(argv("pm", "uninstall", "com.android.systemui"), DeviceIntegrityPolicy.Category.CORE_PACKAGE) + + @Test fun `clearing provisioning is refused`() { + assertDenied(argv("settings", "put", "global", "device_provisioned", "0"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + assertDenied(argv("settings", "put", "secure", "user_setup_complete", "0"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + assertDenied(argv("settings", "delete", "global", "device_provisioned"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + } + + @Test fun `provisioning set to one is fine`() = + assertNull(argv("settings", "put", "global", "device_provisioned", "1")) + + // ---- exec (script) denials ---- + + @Test fun `exec rm metadata glob is refused`() = + assertDenied(script("rm -rf /metadata/*"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + + @Test fun `exec redirect into a block device is refused`() = + assertDenied(script("echo x > /dev/block/sda"), DeviceIntegrityPolicy.Category.PARTITION) + + @Test fun `exec through sh -c is followed`() = + assertDenied(script("sh -c 'rm -rf /data/system'"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + + @Test fun `exec through su -c and env is followed`() { + assertDenied(script("su -c 'rm -rf /data/misc'"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(script("env FOO=1 rm -rf /data/vendor"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(script("busybox rm -rf /data/system"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + } + + @Test fun `exec chained commands each get checked`() = + assertDenied(script("ls /sdcard && rm -rf /data/system_ce; echo done"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + + @Test fun `exec find delete and exec are followed`() { + assertDenied(script("find /data/system -delete"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(script("find /data/misc -name '*.x' -exec rm {} ;"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + } + + @Test fun `cd then relative delete is resolved`() = + assertDenied(script("cd /data/system && rm -rf *"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + + @Test fun `file rm -r of data adb modules is refused`() = + assertDenied(policy.checkDeletePath("/data/adb/modules"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + + // ---- packages and device state ---- + + @Test fun `disabling the current launcher is refused`() = + assertDenied(argv("pm", "disable-user", "--user", "0", home), DeviceIntegrityPolicy.Category.CORE_PACKAGE) + + @Test fun `package operations through cmd package are refused`() { + assertDenied(argv("cmd", "package", "uninstall", "-k", "--user", "0", ime), DeviceIntegrityPolicy.Category.CORE_PACKAGE) + assertDenied(argv("pm", "clear", "com.android.settings"), DeviceIntegrityPolicy.Category.CORE_PACKAGE) + assertDenied(argv("pm", "suspend", "com.android.systemui"), DeviceIntegrityPolicy.Category.CORE_PACKAGE) + assertDenied(argv("pm", "hide", "android"), DeviceIntegrityPolicy.Category.CORE_PACKAGE) + } + + @Test fun `component names reduce to their package`() = + assertDenied(argv("pm", "disable", "com.android.systemui/.SystemUIService"), DeviceIntegrityPolicy.Category.CORE_PACKAGE) + + @Test fun `removing user zero is refused`() { + assertDenied(argv("pm", "remove-user", "0"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + assertDenied(argv("cmd", "user", "remove", "0"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + } + + @Test fun `removing another user is fine`() = assertNull(argv("pm", "remove-user", "10")) + + @Test fun `factory reset broadcasts and recovery wipe are refused`() { + assertDenied(argv("am", "broadcast", "-a", "android.intent.action.FACTORY_RESET"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + assertDenied(argv("am", "broadcast", "-a", "android.intent.action.MASTER_CLEAR"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + assertDenied(argv("recovery", "--wipe_data"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + assertDenied(script("echo --wipe_data > /cache/recovery/command"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + } + + // ---- allowed ---- + + @Test fun `ordinary app data and sdcard work stay open`() { + assertNull(argv("rm", "-rf", "/data/data/com.example.app/cache")) + assertNull(script("rm -rf /data/data/com.example.app/cache/*")) + assertNull(script("rm -rf /sdcard/Download/*")) + assertNull(argv("cat", "/proc/1234/maps")) + assertNull(argv("kill", "-9", "1")) + assertNull(script("echo hi > /data/local/tmp/x && chmod 755 /data/local/tmp/x")) + } + + @Test fun `non core preinstalled apps can be uninstalled for user zero`() = + assertNull(argv("pm", "uninstall", "--user", "0", "com.example.bloat")) + + @Test fun `reading a partition into the sdcard is allowed`() { + assertNull(argv("dd", "if=/dev/block/by-name/boot", "of=/sdcard/boot.img")) + assertNull(script("dd if=/dev/block/by-name/boot of=/sdcard/boot.img bs=1M")) + } + + @Test fun `data adb minis stays writable`() { + assertNull(policy.checkWritePath("/data/adb/minis/rootfs/x")) + assertNull(argv("rm", "-rf", "/data/adb/minis/runtime/old")) + } + + @Test fun `the user protected top level nodes cannot be removed but can be written under`() { + assertDenied(argv("rm", "-rf", "/data/data"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(argv("rm", "-rf", "/data"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(argv("mv", "/data/media", "/sdcard/x"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(script("rm -rf /data/*"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertNotNull(script("rm -rf /*")) + assertNull(argv("mkdir", "-p", "/data/local/tmp/a")) + } + + // ---- path tricks ---- + + @Test fun `case dot dotdot double slash and proc root do not hide a path`() { + assertDenied(argv("rm", "-rf", "/DATA/System"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(argv("rm", "-rf", "/data/./system"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(argv("rm", "-rf", "/data/local/../system"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(argv("rm", "-rf", "//data//system"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(argv("rm", "-rf", "/proc/self/root/data/system"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(argv("rm", "-rf", "/proc/1/root/metadata"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + } + + @Test fun `normalize collapses the usual tricks`() { + assertEquals("/data/system", DeviceIntegrityPolicy.normalize("//data/./x/../system/")) + assertEquals("/", DeviceIntegrityPolicy.normalize("/../..")) + } + + // ---- core package rules ---- + + @Test fun `core package facts`() { + assertNull(null) + check(CorePackageRules.isCore(CorePackageRules.Facts("x.y", persistent = true))) + check(CorePackageRules.isCore(CorePackageRules.Facts("x.y", sharedUserId = "android.uid.system"))) + check(CorePackageRules.isCore(CorePackageRules.Facts("x.y", sharedUserId = "android.uid.phone"))) + check(CorePackageRules.isCore(CorePackageRules.Facts("x.y", isCurrentHome = true))) + check(CorePackageRules.isCore(CorePackageRules.Facts("x.y", isDefaultInputMethod = true))) + check(!CorePackageRules.isCore(CorePackageRules.Facts("com.example.bloat"))) + } + + // ---- shell splitting ---- + + @Test fun `shell words keep quotes and split commands`() { + val c = ShellWords.split("a 'b c' \"d e\" > /x 2>/dev/null; f && g | h") + assertEquals(listOf("a", "b c", "d e"), c[0].words) + assertEquals(listOf("/x", "/dev/null"), c[0].redirects) + assertEquals(listOf("f"), c[1].words) + assertEquals(listOf("g"), c[2].words) + assertEquals(listOf("h"), c[3].words) + } +} From d046028937224b01b171d50411f4027c4cd5f17e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 11:43:44 +0000 Subject: [PATCH 4/5] feat(integrity): device protection for privileged commands (#182) Agent- and guest-CLI-originated Root commands now pass ProtectedRoot: a pure DeviceIntegrityPolicy refuses what could leave the phone unbootable or unusable (partition and system-partition writes, /data system state, core packages, factory reset, user 0), with a fixed DEVICE_PROTECTED error and no prompt; the command then runs in a ProtectedView (read-only binds, no block nodes, su stub, CAP_SYS_ADMIN/MKNOD/SYS_MODULE/SYS_RAWIO dropped). Settings switch (default on, user-only), audit list, storage watermark that freezes guest shells and refuses bulk writes, and a read-only partition export. Device behaviour is unverified. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01PV4ECd3Yzu9k81sw7vHjS3 --- AGENTS.md | 2 +- CHANGELOG.md | 1 + docs/contracts/04-SECURITY-CONTRACT.md | 1 + docs/contracts/06-CURRENT-GAPS.md | 21 +++- .../main/java/com/openminis/app/MinisApp.kt | 2 + .../app/integrity/DeviceProtectionStore.kt | 40 ++++++ .../openminis/app/integrity/IntegrityAudit.kt | 39 ++++++ .../openminis/app/integrity/ProtectedRoot.kt | 104 +++++++++++++++ .../openminis/app/integrity/ProtectedView.kt | 89 +++++++++++++ .../app/integrity/RootCorePackageResolver.kt | 81 ++++++++++++ .../openminis/app/integrity/StorageGuard.kt | 118 ++++++++++++++++++ .../com/openminis/app/offload/RootProcess.kt | 26 +++- .../runtime/guest/RootCliOffloadHandler.kt | 79 +++++++++++- .../tools/android/PrivilegedCommandRunner.kt | 6 +- .../app/tools/runtime/RootShellHandler.kt | 1 + .../ui/settings/OffloadPermissionScreen.kt | 26 ++++ .../app/src/main/res/values-de/strings.xml | 8 ++ .../app/src/main/res/values-fr/strings.xml | 8 ++ .../app/src/main/res/values-ja/strings.xml | 8 ++ .../app/src/main/res/values-ko/strings.xml | 8 ++ .../app/src/main/res/values-ru/strings.xml | 8 ++ .../src/main/res/values-zh-rTW/strings.xml | 8 ++ .../app/src/main/res/values-zh/strings.xml | 8 ++ .../app/src/main/res/values/strings.xml | 8 ++ .../app/integrity/ProtectedRootTest.kt | 116 +++++++++++++++++ .../app/integrity/ProtectedViewTest.kt | 65 ++++++++++ .../integrity/RootCorePackageResolverTest.kt | 78 ++++++++++++ .../app/integrity/StorageGuardTest.kt | 53 ++++++++ 28 files changed, 1001 insertions(+), 11 deletions(-) create mode 100644 src/android/app/src/main/java/com/openminis/app/integrity/DeviceProtectionStore.kt create mode 100644 src/android/app/src/main/java/com/openminis/app/integrity/IntegrityAudit.kt create mode 100644 src/android/app/src/main/java/com/openminis/app/integrity/ProtectedRoot.kt create mode 100644 src/android/app/src/main/java/com/openminis/app/integrity/ProtectedView.kt create mode 100644 src/android/app/src/main/java/com/openminis/app/integrity/RootCorePackageResolver.kt create mode 100644 src/android/app/src/main/java/com/openminis/app/integrity/StorageGuard.kt create mode 100644 src/android/app/src/test/java/com/openminis/app/integrity/ProtectedRootTest.kt create mode 100644 src/android/app/src/test/java/com/openminis/app/integrity/ProtectedViewTest.kt create mode 100644 src/android/app/src/test/java/com/openminis/app/integrity/RootCorePackageResolverTest.kt create mode 100644 src/android/app/src/test/java/com/openminis/app/integrity/StorageGuardTest.kt diff --git a/AGENTS.md b/AGENTS.md index 707c164b..3ba043fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ 1. 现役 guest 用户数据由 App 私有目录持有,host backing 从 `Context.filesDir` 派生;`/data/adb/minis/rootfs` 是 Root-owned、可替换 runtime state。 2. Guest 使用设备实际 App UID/GID;进入 chroot 后必须清空 supplementary groups 与 Linux capabilities,禁止写死 `10000`。 -3. `DirectRootRunner` 只允许执行 App 构造的受控基础设施动作:rootfs、namespace、bind、chroot、必要探测/修复和受控 legacy migration。按上游权限模型,本地 Agent 可使用结构化 `root.shell`(tool basename + args)调用受信 Android system 工具;该能力必须 local-only、有参数/超时/输出/进程组边界,不接受 raw command、host 文件 API 或通用 Root RPC,MCP/远程调用方不可见。另外两个 Root 代办入口都是 guest CLI:`minis-apt`(仅 `update`/`install`/`remove` 纯包名,命令由 App 构造,会移除运行时必需包的 `remove` 被拒绝)和 `android-root-cli`(Android 系统工具的整理子命令,加 `exec` 原始 root shell;权限开关默认关闭)。两者详见 `docs/contracts/04-SECURITY-CONTRACT.md`。项目只走 Root:不接入 Shizuku 或其它特权代理。 +3. `DirectRootRunner` 只允许执行 App 构造的受控基础设施动作:rootfs、namespace、bind、chroot、必要探测/修复和受控 legacy migration。按上游权限模型,本地 Agent 可使用结构化 `root.shell`(tool basename + args)调用受信 Android system 工具;该能力必须 local-only、有参数/超时/输出/进程组边界,不接受 raw command、host 文件 API 或通用 Root RPC,MCP/远程调用方不可见。另外两个 Root 代办入口都是 guest CLI:`minis-apt`(仅 `update`/`install`/`remove` 纯包名,命令由 App 构造,会移除运行时必需包的 `remove` 被拒绝)和 `android-root-cli`(Android 系统工具的整理子命令,加 `exec` 原始 root shell;权限开关默认关闭)。两者详见 `docs/contracts/04-SECURITY-CONTRACT.md`。项目只走 Root:不接入 Shizuku 或其它特权代理。来自 Agent / guest CLI 的特权命令统一经 `ProtectedRoot`(设备完整性保护,Issue #182):拦会让手机无法开机或使用的操作,其余放开;App 自己构造的基础设施动作仍走 `DirectRootRunner`。 4. 网络代理和 Root/chroot 是不同概念。HTTP/CONNECT 代理协议本身不要求 Root;当前 helper 仅因 Android 出站 UID/VPN/BPF 兼容需求可由特权身份启动。不得把它扩展成命令、文件或通用 RPC 服务。 5. 产品运行时不恢复 PRoot/Alpine 双栈;不要为了兼容全局关闭 SELinux。 6. Session 执行必须保持对应 session workspace 语义,不能用全局 `/workspace` 绕过隔离。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 868cc5fb..ecb7ff09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This changelog tracks **Minis for Android** as an independently maintained Andro ## Unreleased +- Device protection (Settings > Permissions, on by default): privileged commands from the agent, `android-root-cli` and the Android tools can no longer write partitions or the system partitions, remove `/data/system`, `/data/misc`, `/metadata` or `/data/adb`, uninstall or disable core system apps, factory-reset or remove user 0. Nothing is asked: the agent gets `DEVICE_PROTECTED: : ` and tries another way. Everything else stays open. Refusals are listed under the switch. When `/data` is nearly full the app freezes its own terminals and refuses new bulk writes until space is freed. `android-root-cli partition read` exports a partition read-only. - Root only: Shizuku (and AXManager / Sui) support is removed; Root is the app's one privileged path. The code before this change is kept on the branch `archive/before-root-only`. - `android-shizuku-cli` is now `android-root-cli`: the same subcommands and JSON, run as root through `su`. Its Agent permission switch carries over. - The virtual screen runs in a root service (libsu) instead of a Shizuku UserService. Run its compatibility check once more to switch it back on. diff --git a/docs/contracts/04-SECURITY-CONTRACT.md b/docs/contracts/04-SECURITY-CONTRACT.md index fb81d5e1..164d5535 100644 --- a/docs/contracts/04-SECURITY-CONTRACT.md +++ b/docs/contracts/04-SECURITY-CONTRACT.md @@ -17,6 +17,7 @@ - `DirectRootRunner` 的内部脚本动作限于确实需要权限的 rootfs 探测/修复、namespace/bind/chroot、受控 legacy 数据迁移及同类明确基础设施需求;本地 Agent 的 `root.shell` 只走结构化 Android tool/argv 合同,不得被扩展成 raw shell、host 文件或通用 RPC。 - 例外之一:`minis-apt`(guest CLI,经 `AptOffloadHandler`)。guest 以 App UID 运行、无法使用 apt,因此由 App 代为在 rootfs 的一次性 mount namespace 内以 Root 执行 `apt-get update|install|remove`。Agent 只给出包名,命令全部由 App 构造(`AptCommandPolicy`):只接受 `^[a-z0-9][a-z0-9+.-]{0,62}$` 形式的纯包名(拒绝选项、版本/发行版指定、路径、shell 字符),每次最多 20 个;`remove` 先用 apt 的模拟结果检查,若会连带移除运行时必需的包(`UbuntuProvisioner.BASE_PACKAGES` 等)则在移除前拒绝;同一时间只运行一个;走与其它 guest CLI 相同的权限开关(`apt_cli`,设置里可关闭);只影响 Ubuntu 环境,不触及 Android 系统与宿主文件。它不是通用 Root 执行入口,不接受原始命令。 - 例外之二:`android-root-cli`(guest CLI,经 `RootCliOffloadHandler` 与 `RootProcess`)。它以 root 运行 Android 系统工具:`package`/`permission`/`activity`/`display`/`settings`/`user`/`network`/`input`/`notification`/`file`/`device` 等整理过的子命令(参数向量由 App 构造),外加 `exec`——把剩余参数拼成一条命令交给 root 的 `sh -c`。`exec` 是有意给本地 Agent 的原始 root shell:2026-10 起项目只走 Root,按维护者的决定给 Agent 尽量大的权限,只防止把手机弄坏;此前同一能力是经 Shizuku 的 `android-shizuku-cli`(Shizuku 以 Root 启动时同样是 uid 0)。约束:只能从 guest shell 调用;受 `root_cli` 权限开关控制,默认关闭;su 缺失或被拒时直接返回 `SERVICE_NOT_RUNNING` / `PERMISSION_DENIED`,不运行任何命令。设备完整性保护(拦截写分区、系统分区、`/data` 系统数据、卸载核心系统包等)尚未实现,见 Issue #182;远程 MCP 调用方经 `linux.shell` 也能到达它:这是维护者接受的设计——每次调用都要用户在手机上单独批准(一次性 confirm,与「全自动」模式无关,不会被它跳过),且 `root_cli` 开关默认关闭。未经批准的远程请求拿不到 root shell。 +- 设备完整性保护(Issue #182,默认开启,只有用户能在设置 → 权限里关闭):来自 Agent 或 guest CLI 的特权命令(`root.shell`、`android-root-cli` 全部子命令含 `exec`、`android.app` 等 Android 工具,即所有经 `PrivilegedCommandRunner` / `RootProcess.run` 的调用)统一经 `ProtectedRoot`。只拦"最坏结果不是重启能解决"的操作:写 `/dev/block`、写系统分区(`/system` `/system_ext` `/vendor` `/product` `/odm` `/apex`)、写 `/data/system*` `/data/misc*` `/data/vendor*` `/data/property` `/data/apex` `/data/unencrypted` `/metadata` `/data/adb`(`/data/adb/minis` 除外)、删除或改名 `/data` 等顶层节点、卸载/停用/清除/挂起/隐藏核心系统包、恢复出厂、删除用户 0、把 `device_provisioned` / `user_setup_complete` 置 0。读取、其他 app 数据、`/sdcard`、非核心预装应用、kill 进程、网络、sysctl、重启一律放开。被拦时不弹窗、不走一次性审批,固定返回 `DEVICE_PROTECTED: <类别>: <目标>` 与退出码 77,并写审计日志(设置页可见)。两道防线:`DeviceIntegrityPolicy` 在执行前按命令词判定(含 `sh -c` / `su -c` / `env` / `find -exec` 解包、`cd` 后的相对路径、`..`/`//`/大小写/`/proc/*/root` 归一化);`ProtectedView` 让命令在一次性 mount namespace 里运行(受保护目录只读 bind、`/dev/block` 换成空 `nodev` tmpfs、`su` 路径换成桩、bounding set 去掉 `CAP_SYS_ADMIN`/`CAP_MKNOD`/`CAP_SYS_MODULE`/`CAP_SYS_RAWIO`),变量、`$(…)`、脚本文件里的目标由它兜底。视图建不起来(缺 `unshare` 或 rootfs 里的 `setpriv`)时命令不执行,返回 `DEVICE_PROTECTION_UNAVAILABLE`,失败关闭。分区读取走单独的只读导出命令 `android-root-cli partition read`,由 App 构造 `dd`,不经视图。存储水位(`/data` 剩余低于 `max(3 GB, 5%)`):冻结 guest shell 进程组、拒绝新的批量写命令(`cp`/`dd`/`tar`/`unzip` 等,不含 `rm`),发通知,空间恢复 25% 余量后自动继续,不删除任何东西。App 自己构造的基础设施(`DirectRootRunner`:rootfs、namespace、bind、网络 helper、无障碍修复)不经此处。不防刻意绕过:原始 `service call` 直接发 binder 事务不受前端检查。 - 虚拟屏的 root 服务(`VirtualScreenRootService`,由 libsu 经 `su` 以 `app_process` 拉起)是专用 binder 接口:只暴露 `IVirtualScreenService` 里固定的虚拟屏操作(建屏、启动应用、点按输入、截图、帧流),不提供命令、文件或通用 RPC,也不监听网络或 socket;binder 只交回本 App。 - Guest shell 进入 chroot 后必须通过 `setpriv` 切到真实 App UID/GID,清空 supplementary groups 与 Linux capabilities。 - Root-provider identity 只是可用性信号;uid=0 不代表 SELinux、mount、namespace 或 capability 一定允许操作。 diff --git a/docs/contracts/06-CURRENT-GAPS.md b/docs/contracts/06-CURRENT-GAPS.md index ea6ca068..72da0142 100644 --- a/docs/contracts/06-CURRENT-GAPS.md +++ b/docs/contracts/06-CURRENT-GAPS.md @@ -216,7 +216,7 @@ config registry 暴露为 `prompt.custom`(带确认与审计回退)。内置 已知缺口: -- 设备完整性保护(Issue #182)尚未实现:`exec`、`file rm -r`、`package uninstall` 等目前没有任何保护线。 +- 设备完整性保护(Issue #182)已实现(见 04 安全合同),但见下一节:全部未在真机验证。 已接受的设计(不是缺口): @@ -236,3 +236,22 @@ config registry 暴露为 `prompt.custom`(带确认与审计回退)。内置 ## PR4 — 下个版本移除旧模型组 Room 表(暂缓) PR2/PR3 只把运行配置迁移到固定 Model Slots,并保留旧表作为迁移/回滚兼容边界;本阶段不删 `provider_model_groups` 表、不升级 Room schema。后续 PR4 才能在数据升级路径和真机备份恢复验收完成后删除该表并将 Room schema 从当前 v4 升至 v5。 + +## 设备完整性保护(Issue #182,2026-10-10) + +只有 JVM 单测、编译和 lint 的证据;`ProtectedView` 生成的脚本、mount namespace、能力裁剪、存储水位冻结都**没有在真机上跑过**,不得据此声称设备结论。需要在小米 24129PN74C / HyperOS(KernelSU)上确认: + +- `unshare -m` 在 `su` 起的进程里可用,`mount -o rprivate,bind / /`、`/dev/block` 换 tmpfs、对 `/data/adb`、`/apex` 等目录的只读 bind 与 `remount,bind,ro` 都能成功;任何一步失败会让特权命令返回 `DEVICE_PROTECTION_UNAVAILABLE`(失败关闭); +- rootfs 里的 `setpriv` 经 `ld-linux-aarch64.so.1 --library-path` 在 chroot 之外能运行,`--bounding-set` 去掉 `CAP_SYS_ADMIN` 后 `pm`/`cmd`/`settings`/`am`/`dumpsys`/`input`/`wm`/`appops` 与现有 `android-root-cli` 子命令没有回归; +- 视图里 `mount -o remount,rw /system`、`dd of=/dev/block/…`、`rm -rf /data/system` 确实失败,`su` 桩生效; +- KernelSU 的 `/data/adb/ksu/bin/su` 被桩覆盖后,视图外的 App 自身 `su` 不受影响; +- 存储水位:guest 写大文件逼近阈值时自动冻结、发通知,清理后恢复;冻结只作用于 `shells/shell-*.pid` 记录的进程组; +- 小米补充名单(`com.miui.securitycenter`、`com.lbe.security.miui`、`com.miui.home`、`com.xiaomi.xmsf`、`com.miui.system`)需真机核对,其余 OEM 没有补充名单。 + +已知残余: + +- 原始 `service call` 直接发 binder 事务,不经 `pm`/`cmd`/`settings`/`am` 前端,绕过核心包和设备状态检查。本保护防的是 Agent 手滑,不防刻意绕过。 +- 命令词判定看不穿变量、`$(…)`、脚本文件;这类只靠视图兜底,且视图只管路径类(分区、系统分区、`/data` 系统数据),管不到 binder 类(核心包、恢复出厂、用户 0)。 +- `/data/adb/minis` 因例外保持可写,`rm -rf` 它会毁掉 rootfs(可重装),不会让手机变砖。 +- `/apex`、`/system` 等目录在系统挂载为只读的设备上,视图的只读 bind 是第二道保险,不是第一道。 +- 分区读取(`partition read`)在视图之外以 root 执行,只读块设备,目标文件走与其它命令相同的写路径检查。 diff --git a/src/android/app/src/main/java/com/openminis/app/MinisApp.kt b/src/android/app/src/main/java/com/openminis/app/MinisApp.kt index 93a56f74..2ff493cf 100644 --- a/src/android/app/src/main/java/com/openminis/app/MinisApp.kt +++ b/src/android/app/src/main/java/com/openminis/app/MinisApp.kt @@ -538,6 +538,8 @@ class MinisApp : Application(), ImageLoaderFactory { // the process. Mirrors iOS ConfigRegistry.shared.registerBuiltinsIfNeeded(). com.openminis.app.config.MinisConfigPermissionStore.init(this) com.openminis.app.config.AutonomyStore.init(this) + com.openminis.app.integrity.DeviceProtectionStore.init(this) + com.openminis.app.integrity.StorageGuard.start(this) com.openminis.app.config.audit.ConfigAuditLog.init(this) com.openminis.app.config.ConfigRegistry.init( this, providerRepository, envVarRepository, chatRepository, diff --git a/src/android/app/src/main/java/com/openminis/app/integrity/DeviceProtectionStore.kt b/src/android/app/src/main/java/com/openminis/app/integrity/DeviceProtectionStore.kt new file mode 100644 index 00000000..b230d6cc --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/integrity/DeviceProtectionStore.kt @@ -0,0 +1,40 @@ +package com.openminis.app.integrity + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Whether device-integrity protection (Issue #182) is on. It is on by default and stays on until the + * user turns it off in Settings → Permissions. + * + * Like `AutonomyStore`, nothing else can write it: there is no config field for it, the backup + * allowlist does not name its preference file, and the agent has no tool that reaches it. Until + * [init] has run the answer is "on". + */ +object DeviceProtectionStore { + private const val PREFS = "device_protection_prefs" + private const val KEY = "enabled" + private var prefs: SharedPreferences? = null + + private val _enabled = MutableStateFlow(true) + val enabled: StateFlow = _enabled.asStateFlow() + + val isEnabled: Boolean get() = _enabled.value + + /** Call once early, from MinisApp.onCreate. Idempotent. */ + fun init(context: Context) { + if (prefs != null) return + val p = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + prefs = p + _enabled.value = p.getBoolean(KEY, true) + } + + fun setEnabled(value: Boolean) { + prefs?.edit { putBoolean(KEY, value) } + _enabled.value = value + } +} diff --git a/src/android/app/src/main/java/com/openminis/app/integrity/IntegrityAudit.kt b/src/android/app/src/main/java/com/openminis/app/integrity/IntegrityAudit.kt new file mode 100644 index 00000000..87c455db --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/integrity/IntegrityAudit.kt @@ -0,0 +1,39 @@ +package com.openminis.app.integrity + +import com.openminis.app.logging.AppLogger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Every refusal by the device-integrity protection, newest first, for the diagnostics list in + * Settings. Each one is also written to the app log, which survives a restart; this list does not. + */ +object IntegrityAudit { + private const val MAX_ENTRIES = 200 + private const val TAG = "DeviceIntegrity" + + data class Entry( + val timeMs: Long, + val entryPoint: String, + val sessionId: String?, + val category: String, + val target: String, + ) + + private val _entries = MutableStateFlow>(emptyList()) + val entries: StateFlow> = _entries.asStateFlow() + + fun record(entryPoint: String, sessionId: String?, denial: DeviceIntegrityPolicy.Denial, nowMs: Long = System.currentTimeMillis()) { + val entry = Entry(nowMs, entryPoint, sessionId, denial.category.label, denial.target.take(200)) + _entries.value = (listOf(entry) + _entries.value).take(MAX_ENTRIES) + // AppLogger is a no-op before init and on the host; the in-memory list is the record then. + runCatching { + AppLogger.warning(TAG, "blocked category=${entry.category} entry=$entryPoint session=${sessionId ?: "-"} target=${entry.target}") + } + } + + internal fun clearForTest() { + _entries.value = emptyList() + } +} diff --git a/src/android/app/src/main/java/com/openminis/app/integrity/ProtectedRoot.kt b/src/android/app/src/main/java/com/openminis/app/integrity/ProtectedRoot.kt new file mode 100644 index 00000000..225704fe --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/integrity/ProtectedRoot.kt @@ -0,0 +1,104 @@ +package com.openminis.app.integrity + +import com.openminis.app.runtime.ubuntu.DirectRootRunner +import com.openminis.app.runtime.ubuntu.UbuntuPaths +import com.openminis.app.util.shellQuote +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * The single door for privileged commands that come from the agent or a guest CLI (Issue #182). + * `root.shell`, `android-root-cli` and the Android tools all call this instead of [DirectRootRunner]; + * infrastructure that the app builds itself (rootfs, namespaces, binds, the network helper, accessibility + * repair) keeps using [DirectRootRunner] directly and is not subject to the policy. + * + * With protection on, a command is first checked by [DeviceIntegrityPolicy] (a refusal returns + * `DEVICE_PROTECTED: : ` on stderr with [EXIT_DEVICE_PROTECTED], never a prompt) and + * then runs inside the [ProtectedView]. With protection off it runs as before. + */ +internal object ProtectedRoot { + /** Fixed exit code of a refused command (EX_NOPERM). */ + const val EXIT_DEVICE_PROTECTED = 77 + + @Volatile + internal var policy: DeviceIntegrityPolicy = DeviceIntegrityPolicy(RootCorePackageResolver) + + /** Replaceable for tests: runs the final script as Root. */ + @Volatile + internal var runner: suspend (String, Long) -> DirectRootRunner.Result = { script, timeout -> + DirectRootRunner.runScript(script, timeout) + } + + @Volatile + internal var enabled: () -> Boolean = { DeviceProtectionStore.isEnabled } + + @Volatile + internal var storageBlocksWrites: () -> Boolean = { StorageGuard.writesBlocked } + + @Volatile + internal var rootfs: String = UbuntuPaths.HOST_ROOTFS + + suspend fun runArgv( + entryPoint: String, + sessionId: String?, + argv: List, + timeoutMs: Long = 30_000L, + ): DirectRootRunner.Result { + require(argv.isNotEmpty()) { "argv must not be empty" } + if (enabled()) { + val denial = withContext(Dispatchers.IO) { + policy.checkArgv(argv.first(), argv.drop(1)) + ?: storageDenial(argv.first(), argv.drop(1).joinToString(" ")) + } + if (denial != null) return refuse(entryPoint, sessionId, denial) + } + return execute("exec " + argv.joinToString(" ") { shellQuote(it) }, timeoutMs) + } + + suspend fun runScript( + entryPoint: String, + sessionId: String?, + script: String, + timeoutMs: Long = 30_000L, + ): DirectRootRunner.Result { + if (enabled()) { + val denial = withContext(Dispatchers.IO) { + policy.checkScript(script) ?: storageDenial(script.trimStart().substringBefore(' '), script) + } + if (denial != null) return refuse(entryPoint, sessionId, denial) + } + return execute(script, timeoutMs) + } + + /** Check a path an operation will write to, without running anything (file push destination). */ + fun checkWrite(entryPoint: String, sessionId: String?, path: String): DeviceIntegrityPolicy.Denial? = + if (!enabled()) null else policy.checkWritePath(path)?.also { IntegrityAudit.record(entryPoint, sessionId, it) } + + /** Check a path an operation will delete or rename. */ + fun checkDelete(entryPoint: String, sessionId: String?, path: String): DeviceIntegrityPolicy.Denial? = + if (!enabled()) null else policy.checkDeletePath(path)?.also { IntegrityAudit.record(entryPoint, sessionId, it) } + + fun refusalResult(denial: DeviceIntegrityPolicy.Denial) = + DirectRootRunner.Result(EXIT_DEVICE_PROTECTED, "", denial.message) + + private fun refuse(entryPoint: String, sessionId: String?, denial: DeviceIntegrityPolicy.Denial): DirectRootRunner.Result { + IntegrityAudit.record(entryPoint, sessionId, denial) + return refusalResult(denial) + } + + private suspend fun execute(script: String, timeoutMs: Long): DirectRootRunner.Result { + val finalScript = if (enabled()) ProtectedView.wrap(script, rootfs) else script + return runner(finalScript, timeoutMs) + } + + /** While `/data` is nearly full, new commands that write bulk data wait (see [StorageGuard]). */ + private fun storageDenial(command: String, full: String): DeviceIntegrityPolicy.Denial? { + if (!storageBlocksWrites()) return null + val name = command.substringAfterLast('/').lowercase() + return if (StorageGuard.consumesSpace(name, full)) { + DeviceIntegrityPolicy.Denial(DeviceIntegrityPolicy.Category.STORAGE, "/data is nearly full; free space first") + } else { + null + } + } +} diff --git a/src/android/app/src/main/java/com/openminis/app/integrity/ProtectedView.kt b/src/android/app/src/main/java/com/openminis/app/integrity/ProtectedView.kt new file mode 100644 index 00000000..73c8de08 --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/integrity/ProtectedView.kt @@ -0,0 +1,89 @@ +package com.openminis.app.integrity + +import com.openminis.app.util.shellQuote + +/** + * The protected view (Issue #182): the one place a privileged command actually runs when device + * protection is on. It is what holds when [DeviceIntegrityPolicy] cannot read the target out of the + * command (`sh -c "$(cat x)"`, a variable, a script file). + * + * The command runs in a throw-away mount namespace in which + * - the system partitions, `/data/system*`, `/data/misc*`, `/data/vendor*`, `/data/property`, + * `/data/apex`, `/data/unencrypted`, `/metadata` and `/data/adb` are read-only bind mounts + * (`/data/adb/minis` is bound back read-write), + * - `/dev/block` is replaced by an empty `nodev` tmpfs, so no partition node can be opened for + * writing. A read-only bind mount does not stop writes to device nodes, which is why the node + * directory is swapped out instead of remounted. Reading a partition goes through the separate + * `android-root-cli partition read` command, + * - every known `su` path is covered by a stub, so a protected command cannot start a fresh Root + * shell outside the view, + * and the shell is then started with `CAP_SYS_ADMIN`, `CAP_MKNOD`, `CAP_SYS_MODULE` and + * `CAP_SYS_RAWIO` removed from the bounding set, so none of this can be remounted, unmounted, + * re-created or loaded around. + * + * Android's toybox has no `setpriv`; the Ubuntu rootfs one is run through the rootfs dynamic loader + * without chroot. If any part of the view cannot be built the command does not run (exit 125, + * `DEVICE_PROTECTION_UNAVAILABLE`): protection fails closed, and the user can turn it off. + * + * Nothing here has been run on a device; see `06-CURRENT-GAPS.md`. + */ +internal object ProtectedView { + const val EXIT_UNAVAILABLE = 125 + const val UNAVAILABLE_PREFIX = "DEVICE_PROTECTION_UNAVAILABLE" + + /** Capabilities that could undo the view or write around it. Reboot and kill stay allowed. */ + internal const val DROPPED_CAPS = "-sys_admin,-mknod,-sys_module,-sys_rawio" + + /** Directories that become read-only. Globs are expanded by the shell inside the view. */ + internal val READ_ONLY_DIRS = listOf( + "/system", "/system_ext", "/vendor", "/product", "/odm", "/apex", "/metadata", + "/data/system*", "/data/misc*", "/data/vendor*", "/data/property", "/data/apex", + "/data/unencrypted", "/data/adb", + ) + + internal val SU_PATHS = listOf( + "/system/bin/su", "/system/xbin/su", "/sbin/su", "/su/bin/su", "/data/adb/ksu/bin/su", + "/debug_ramdisk/su", + ) + + private const val LIBDIR_REL = "usr/lib/aarch64-linux-gnu" + private const val LIBDIR2_REL = "lib/aarch64-linux-gnu" + private const val LOADER_REL = "usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1" + + /** + * Wrap [script] so it runs inside the view. [rootfs] is the Ubuntu rootfs on the host; its + * `setpriv` does the capability drop. [minisDir] stays writable under `/data/adb`. + */ + fun wrap(script: String, rootfs: String, minisDir: String = "/data/adb/minis"): String { + val inner = buildString { + append("fail() { echo \"$UNAVAILABLE_PREFIX: \$1\" >&2; exit $EXIT_UNAVAILABLE; }; ") + append("RF=${shellQuote(rootfs)}; ") + append("LOADER=\"\$RF/$LOADER_REL\"; ") + append("[ -x \"\$LOADER\" ] || fail 'rootfs loader not found'; ") + append("[ -x \"\$RF/usr/bin/setpriv\" ] || fail 'rootfs setpriv not found'; ") + append("/system/bin/mount -o rprivate,bind / / || fail 'cannot make mounts private'; ") + // Block nodes: swap the directory for an empty nodev tmpfs before anything else needs it. + append("/system/bin/mount -t tmpfs -o nodev,noexec,mode=755 tmpfs /dev/block || fail '/dev/block'; ") + // The su stub lives on a second tmpfs, so /dev/block stays empty. + append("mkdir -p /dev/.minis-guard && /system/bin/mount -t tmpfs -o nodev,mode=755 tmpfs /dev/.minis-guard || fail 'guard tmpfs'; ") + append("printf '%s\\n' '#!/system/bin/sh' 'echo \"DEVICE_PROTECTED: su is not available inside the protected view\" >&2' 'exit 126' > /dev/.minis-guard/su || fail 'su stub'; ") + append("chmod 755 /dev/.minis-guard/su; ") + append("for s in ${SU_PATHS.joinToString(" ") { shellQuote(it) }}; do ") + append("[ -e \"\$s\" ] && { /system/bin/mount -o bind /dev/.minis-guard/su \"\$s\" || fail \"su \$s\"; }; done; ") + append("for d in ${READ_ONLY_DIRS.joinToString(" ")}; do ") + append("[ -d \"\$d\" ] || continue; ") + append("/system/bin/mount -o bind \"\$d\" \"\$d\" && /system/bin/mount -o remount,bind,ro \"\$d\" || fail \"\$d\"; done; ") + append("if [ -d ${shellQuote(minisDir)} ]; then ") + append("/system/bin/mount -o bind ${shellQuote(minisDir)} ${shellQuote(minisDir)} && ") + append("/system/bin/mount -o remount,bind,rw ${shellQuote(minisDir)} || fail 'minis dir'; fi; ") + append("exec \"\$LOADER\" --library-path \"\$RF/$LIBDIR_REL:\$RF/$LIBDIR2_REL\" \"\$RF/usr/bin/setpriv\" ") + append("--bounding-set=$DROPPED_CAPS --inh-caps=$DROPPED_CAPS -- /system/bin/sh -c ${shellQuote(script)}") + } + return "if [ -x /system/bin/unshare ]; then exec /system/bin/unshare -m /system/bin/sh -c ${shellQuote(inner)}; " + + "else echo \"$UNAVAILABLE_PREFIX: unshare not found\" >&2; exit $EXIT_UNAVAILABLE; fi" + } + + /** True when a result is the view reporting that it could not be built. */ + fun isUnavailable(exitCode: Int, stderr: String): Boolean = + exitCode == EXIT_UNAVAILABLE && stderr.contains(UNAVAILABLE_PREFIX) +} diff --git a/src/android/app/src/main/java/com/openminis/app/integrity/RootCorePackageResolver.kt b/src/android/app/src/main/java/com/openminis/app/integrity/RootCorePackageResolver.kt new file mode 100644 index 00000000..c0423435 --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/integrity/RootCorePackageResolver.kt @@ -0,0 +1,81 @@ +package com.openminis.app.integrity + +import com.openminis.app.runtime.ubuntu.DirectRootRunner +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import java.util.concurrent.ConcurrentHashMap + +/** + * Answers "is this package one the phone needs" from the device itself. The app has no + * QUERY_ALL_PACKAGES, so PackageManager cannot see arbitrary packages; the facts come from one fixed, + * read-only Root script (`dumpsys package`, the HOME resolver and the default input method). + * + * It fails closed: if the facts cannot be read, the package counts as core and the operation is + * refused. Answers are cached for a few seconds, because the default launcher and keyboard can change. + */ +object RootCorePackageResolver : CorePackageResolver { + private const val TIMEOUT_MS = 8_000L + private const val CACHE_MS = 5_000L + + private data class Cached(val core: Boolean, val atMs: Long) + + private val cache = ConcurrentHashMap() + + /** Replaceable for tests: runs the facts script and returns its stdout, or null on failure. */ + @Volatile + internal var factsRunner: (String) -> String? = { script -> + runBlocking(Dispatchers.IO) { + val r = DirectRootRunner.runScript(script, TIMEOUT_MS) + if (r.success) r.stdout else null + } + } + + @Volatile + internal var clock: () -> Long = System::currentTimeMillis + + override fun isCore(packageName: String): Boolean { + if (CorePackageRules.isStaticCore(packageName)) return true + if (!PACKAGE_NAME.matches(packageName)) return false + val now = clock() + cache[packageName]?.takeIf { now - it.atMs < CACHE_MS }?.let { return it.core } + val out = factsRunner(factsScript(packageName)) + val core = if (out == null) true else CorePackageFactsParser.isCore(packageName, out) + cache[packageName] = Cached(core, now) + return core + } + + internal fun clearCache() = cache.clear() + + private val PACKAGE_NAME = Regex("""[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+""") + + internal fun factsScript(packageName: String): String { + require(PACKAGE_NAME.matches(packageName)) { "not a package name" } + return "dumpsys package $packageName 2>/dev/null | grep -E '^ *(flags=|pkgFlags=|sharedUser=)' | head -6; " + + "echo __HOME__; " + + "cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.HOME 2>/dev/null | tail -1; " + + "echo __IME__; " + + "settings get secure default_input_method 2>/dev/null" + } +} + +/** Parses the output of [RootCorePackageResolver.factsScript]; pure. */ +internal object CorePackageFactsParser { + fun parse(packageName: String, output: String): CorePackageRules.Facts { + val home = output.substringAfter("__HOME__", "").substringBefore("__IME__").trim() + val ime = output.substringAfter("__IME__", "").trim() + val head = output.substringBefore("__HOME__") + val flagLines = head.lines().filter { it.trim().startsWith("flags=") || it.trim().startsWith("pkgFlags=") } + val persistent = flagLines.any { Regex("""\bPERSISTENT\b""").containsMatchIn(it) } + val shared = Regex("""sharedUser=SharedUserSetting\{[^}]*\s(android\.uid\.[a-z]+)/""").find(head)?.groupValues?.get(1) + return CorePackageRules.Facts( + packageName = packageName, + persistent = persistent, + sharedUserId = shared, + isCurrentHome = home.substringBefore('/') == packageName, + isDefaultInputMethod = ime.substringBefore('/') == packageName, + ) + } + + fun isCore(packageName: String, output: String): Boolean = + CorePackageRules.isCore(parse(packageName, output)) +} diff --git a/src/android/app/src/main/java/com/openminis/app/integrity/StorageGuard.kt b/src/android/app/src/main/java/com/openminis/app/integrity/StorageGuard.kt new file mode 100644 index 00000000..aaa4030c --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/integrity/StorageGuard.kt @@ -0,0 +1,118 @@ +package com.openminis.app.integrity + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import android.os.StatFs +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.openminis.app.R +import com.openminis.app.logging.AppLogger +import com.openminis.app.runtime.ubuntu.DirectRootRunner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * The storage watermark of Issue #182: a full `/data` can stop the phone from booting, so when free + * space falls below the threshold the app stops *its own* sources of writes until space comes back. + * + * While paused it + * - freezes the guest shells' process groups (SIGSTOP) and resumes them (SIGCONT) afterwards, + * - refuses new privileged commands that bulk-write ([consumesSpace]) through [ProtectedRoot], and + * - posts one notification. + * It never deletes anything. Deleting (`rm`) stays allowed so the agent or the user can free space. + * + * Which `/data` is measured is the one the app can read via [StatFs]; the threshold is + * `max(3 GB, 5 %)` of the volume. Resuming needs 25 % more free space than pausing, so the state does + * not flap around the line. + */ +object StorageGuard { + const val MIN_THRESHOLD_BYTES = 3L * 1024 * 1024 * 1024 + private const val PERCENT = 5 + private const val POLL_MS = 30_000L + private const val CHANNEL_ID = "device_protection" + private const val NOTIFICATION_ID = 0x1824 + private const val TAG = "StorageGuard" + + @Volatile + var writesBlocked: Boolean = false + private set + + fun thresholdBytes(totalBytes: Long): Long = maxOf(MIN_THRESHOLD_BYTES, totalBytes / 100 * PERCENT) + + /** The next paused state given the current free space, with hysteresis. Pure. */ + fun nextPaused(paused: Boolean, freeBytes: Long, totalBytes: Long): Boolean { + val threshold = thresholdBytes(totalBytes) + return if (paused) freeBytes < threshold + threshold / 4 else freeBytes < threshold + } + + private val SPACE_COMMANDS = setOf( + "cp", "dd", "tar", "unzip", "fallocate", "truncate", "rsync", "install", "wget", "curl", "mkfile", + ) + + /** Whether a command is one that writes bulk data. `rm` and friends are never in this set. Pure. */ + fun consumesSpace(name: String, fullCommand: String): Boolean { + if (name !in SPACE_COMMANDS) return false + if (name == "dd" && Regex("""\bof=/dev/null\b""").containsMatchIn(fullCommand)) return false + if (name == "truncate" && !Regex("""(^|\s)(-s|--size)""").containsMatchIn(fullCommand)) return false + return true + } + + internal fun signalGuestShellsScript(signal: String): String { + require(signal == "STOP" || signal == "CONT") { "unsupported signal" } + return "DIR=${DirectRootRunner.ROOT_STATE_DIR}/shells; [ -d \"\$DIR\" ] || exit 0; " + + "for m in \"\$DIR\"/shell-*.pid; do [ -f \"\$m\" ] || continue; " + + "PID=\$(sed -n 1p \"\$m\" 2>/dev/null); case \"\$PID\" in ''|*[!0-9]*) continue;; esac; " + + "[ \"\$PID\" -gt 1 ] && kill -$signal -\$PID 2>/dev/null; done; exit 0" + } + + private var job: Job? = null + + /** Start polling. Idempotent; call from MinisApp.onCreate. */ + fun start(context: Context) { + if (job?.isActive == true) return + val app = context.applicationContext + job = CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + while (isActive) { + val stat = runCatching { StatFs("/data") }.getOrNull() + if (stat != null) { + val paused = nextPaused(writesBlocked, stat.availableBytes, stat.totalBytes) + if (paused != writesBlocked) { + writesBlocked = paused + onChanged(app, paused, stat.availableBytes) + } + } + delay(POLL_MS) + } + } + } + + private suspend fun onChanged(context: Context, paused: Boolean, freeBytes: Long) { + AppLogger.warning(TAG, if (paused) "paused guest writes: free=${freeBytes / (1024 * 1024)} MB" else "resumed guest writes") + // Freezing needs Root; if it is unavailable the command-level refusal still applies. + runCatching { DirectRootRunner.runScript(signalGuestShellsScript(if (paused) "STOP" else "CONT"), 10_000L) } + if (paused) notify(context, freeBytes) else NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) + } + + private fun notify(context: Context, freeBytes: Long) { + val manager = context.getSystemService(NotificationManager::class.java) ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, context.getString(R.string.device_protection_channel), NotificationManager.IMPORTANCE_HIGH), + ) + } + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(context.getString(R.string.device_storage_low_title)) + .setContentText(context.getString(R.string.device_storage_low_body, freeBytes / (1024 * 1024))) + .setOngoing(true) + .build() + runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) } + } +} diff --git a/src/android/app/src/main/java/com/openminis/app/offload/RootProcess.kt b/src/android/app/src/main/java/com/openminis/app/offload/RootProcess.kt index 55ca496f..95e34a15 100644 --- a/src/android/app/src/main/java/com/openminis/app/offload/RootProcess.kt +++ b/src/android/app/src/main/java/com/openminis/app/offload/RootProcess.kt @@ -1,5 +1,6 @@ package com.openminis.app.offload +import com.openminis.app.integrity.ProtectedRoot import com.openminis.app.runtime.ubuntu.DirectRootRunner import com.openminis.app.runtime.ubuntu.RootAccess import com.openminis.app.runtime.ubuntu.RootAccessStatus @@ -9,10 +10,14 @@ import kotlinx.coroutines.runBlocking /** * Run one argv as root and collect its output. * - * Used by `android-root-cli` (its handler runs synchronously on the offload IPC worker thread) and - * by the accessibility repairs. Every call goes through [DirectRootRunner], the same `su` launcher - * the Ubuntu runtime and `root.shell` use, so the app has one Root path. Arguments are quoted word - * by word; nothing is parsed by a shell unless the caller explicitly runs `sh -c`. + * Two doors, one `su` launcher ([DirectRootRunner]): + * - [run] is for `android-root-cli`, whose handler runs synchronously on the offload IPC worker + * thread and takes commands from the agent. It goes through [ProtectedRoot], so the device-integrity + * policy and the protected view apply (Issue #182). + * - [exec] and [runRaw] are for commands the app builds itself (accessibility repair, restricted + * settings, the su liveness check); they are not subject to the policy. + * Arguments are quoted word by word; nothing is parsed by a shell unless the caller explicitly runs + * `sh -c`. */ object RootProcess { const val DEFAULT_TIMEOUT_MS = 5_000L @@ -45,8 +50,19 @@ object RootProcess { fun unavailableReason(): String? = unavailable()?.message - /** Blocking form for synchronous callers; never call it on the main thread. */ + /** Session of the guest request being served on this thread, for the integrity audit. */ + val callerSession = ThreadLocal() + + /** Agent-originated command, subject to device protection. Never call it on the main thread. */ fun run(argv: Array, timeoutMs: Long = DEFAULT_TIMEOUT_MS): Result = + runBlocking(Dispatchers.IO) { + require(argv.isNotEmpty()) { "argv must not be empty" } + val r = ProtectedRoot.runArgv("android-root-cli", callerSession.get(), argv.toList(), timeoutMs) + toResult(r.exitCode, r.stdout, r.stderr, r.error) + } + + /** App-built command that must run outside the protected view (a liveness check, say). */ + fun runRaw(argv: Array, timeoutMs: Long = DEFAULT_TIMEOUT_MS): Result = runBlocking(Dispatchers.IO) { exec(argv.toList(), timeoutMs) } suspend fun exec(argv: List, timeoutMs: Long = DEFAULT_TIMEOUT_MS): Result { diff --git a/src/android/app/src/main/java/com/openminis/app/runtime/guest/RootCliOffloadHandler.kt b/src/android/app/src/main/java/com/openminis/app/runtime/guest/RootCliOffloadHandler.kt index 3e15f7e9..e4966c3b 100644 --- a/src/android/app/src/main/java/com/openminis/app/runtime/guest/RootCliOffloadHandler.kt +++ b/src/android/app/src/main/java/com/openminis/app/runtime/guest/RootCliOffloadHandler.kt @@ -42,6 +42,15 @@ import org.json.JSONObject class RootCliOffloadHandler(private val context: Context) : NativeOffloadHandler { override fun handle(request: NativeOffloadRequest): NativeOffloadResult { + RootProcess.callerSession.set(request.sessionId) + try { + return handleRequest(request) + } finally { + RootProcess.callerSession.remove() + } + } + + private fun handleRequest(request: NativeOffloadRequest): NativeOffloadResult { val argv = request.argv.drop(1) // Top-level help / version short-circuits — no Root needed. if (argv.isEmpty() || argv[0] == "--help" || argv[0] == "-h" || argv[0] == "help") { @@ -88,6 +97,7 @@ class RootCliOffloadHandler(private val context: Context) : NativeOffloadHandler "notification" -> handleNotification(rest, args) "file" -> handleFile(rest, args) "device" -> handleDevice(rest, args) + "partition" -> handlePartition(rest, args) "exec" -> handleExec(rest, args) else -> NativeOffloadResult( 2, @@ -112,12 +122,13 @@ class RootCliOffloadHandler(private val context: Context) : NativeOffloadHandler val data = JSONObject() .put("authorized", RootProcess.isGranted()) .put("available", reason == null) + .put("deviceProtection", com.openminis.app.integrity.DeviceProtectionStore.isEnabled) if (reason != null) data.put("reason", reason) okEnvelope(data, args) } // Asks su now: the answer is the uid the command actually ran as. "ping" -> { - val r = RootProcess.run(arrayOf("id", "-u")) + val r = RootProcess.runRaw(arrayOf("id", "-u")) if (r.exitCode == 0 && r.stdout.trim() == "0") { ok("OK root (uid=0)\n") } else { @@ -930,6 +941,44 @@ class RootCliOffloadHandler(private val context: Context) : NativeOffloadHandler return ok(limited + "\n") } + // ─── Group: partition (read-only export) ─────────────────────────────── + /** + * Partitions are write-protected for every other command (the protected view has no block nodes), + * so a read-only export lives here: the app builds `dd if=/dev/block/by-name/ of=` + * itself, the name is a plain identifier, and the destination goes through the same write check + * as any other command. This runs outside the protected view and only ever reads the block device. + */ + private fun handlePartition(rest: List, args: OffloadArgs): NativeOffloadResult { + val sub = rest.firstOrNull() ?: return ok(PARTITION_HELP) + return when (sub) { + "list" -> { + val r = RootProcess.runRaw(arrayOf("ls", "-1", "/dev/block/by-name"), timeoutMs = 5_000) + if (r.exitCode == 0) ok(r.stdout + "\n") else errEnvelope("OPERATION_FAILED", failMessage(r), args) + } + "read" -> partitionRead(args.positional.getOrNull(1), args.positional.getOrNull(2), args) + "help", "--help", "-h" -> ok(PARTITION_HELP) + else -> NativeOffloadResult(2, "partition: unknown subcommand '$sub'\n$PARTITION_HELP") + } + } + + private fun partitionRead(name: String?, dest: String?, args: OffloadArgs): NativeOffloadResult { + if (name.isNullOrBlank() || dest.isNullOrBlank()) { + return errEnvelope("INVALID_ARGS", "partition read ", args) + } + if (!PARTITION_NAME.matches(name)) return errEnvelope("INVALID_ARGS", "invalid partition name: $name", args) + if (!dest.startsWith("/") || dest.contains('\u0000')) { + return errEnvelope("INVALID_ARGS", "dest must be an absolute path", args) + } + com.openminis.app.integrity.ProtectedRoot.checkWrite("android-root-cli", RootProcess.callerSession.get(), dest) + ?.let { return errEnvelope("DEVICE_PROTECTED", it.message, args) } + val r = RootProcess.runRaw( + arrayOf("dd", "if=/dev/block/by-name/$name", "of=$dest", "bs=1048576"), + timeoutMs = args.getInt("timeout-ms")?.toLong() ?: 600_000L, + ) + return if (r.exitCode == 0) okEnvelope(JSONObject().put("partition", name).put("dest", dest), args) + else errEnvelope("OPERATION_FAILED", failMessage(r), args) + } + // ─── Group: exec (raw shell passthrough) ─────────────────────────────── /** * T341: wildcard fallback. Curated subcommands cover the well-known @@ -1071,12 +1120,14 @@ class RootCliOffloadHandler(private val context: Context) : NativeOffloadHandler } private fun errEnvelope(code: String, message: String, args: OffloadArgs): NativeOffloadResult { + // A command refused by device protection carries its own fixed text; surface it as its own code. + val effectiveCode = if (message.startsWith("DEVICE_PROTECTED")) "DEVICE_PROTECTED" else code val obj = JSONObject().put("ok", false).put( "error", - JSONObject().put("code", code).put("message", message), + JSONObject().put("code", effectiveCode).put("message", message), ) val body = OffloadOutput.formatBody(obj.toString(2), args) - AppLogger.warning(TAG, "$code: $message") + AppLogger.warning(TAG, "$effectiveCode: $message") return NativeOffloadResult(1, body + "\n") } @@ -1130,8 +1181,16 @@ Groups: notification list / dismiss / channel file ls / pull / push / rm device info / battery / usage + partition list / read (read-only export of a partition to a file) service status / ping +Device protection (on by default): commands that could leave the phone +unable to boot are refused with `DEVICE_PROTECTED: : ` +and exit code 77 — writing partitions or /system, /data/system|misc|vendor, +/metadata, /data/adb (except /data/adb/minis), uninstalling or disabling core +system apps, factory reset, removing user 0. Nothing is asked; use another +approach. Everything else is open. + Common flags: --format json|text|csv output format (default text) --compact single-line JSON @@ -1159,6 +1218,8 @@ Examples: android-root-cli exec "logcat -d -t 200 | grep MyTag" Notes: + - Runs in the device-protection view: partitions, /system and /data system + state are read-only there; a blocked command reports DEVICE_PROTECTED. - Caller owns quoting/escaping. Metacharacters (; | > backticks) work. - Default timeout 30s; override via --timeout-ms. - Prefer curated subcommands when they exist — they emit structured JSON. @@ -1271,6 +1332,18 @@ Usage: android-root-cli file rm [-r] """ + private val PARTITION_NAME = Regex("""[A-Za-z0-9_.-]{1,64}""") + + private const val PARTITION_HELP = """partition — read-only partition export. + +Usage: + android-root-cli partition list + android-root-cli partition read [--timeout-ms N] + +Copies /dev/block/by-name/ to (an absolute path). Partitions can +be read, never written: every other command runs without block-device nodes. +""" + private const val DEVICE_HELP = """device — device state. Usage: diff --git a/src/android/app/src/main/java/com/openminis/app/tools/android/PrivilegedCommandRunner.kt b/src/android/app/src/main/java/com/openminis/app/tools/android/PrivilegedCommandRunner.kt index 9a59b325..5e6ca453 100644 --- a/src/android/app/src/main/java/com/openminis/app/tools/android/PrivilegedCommandRunner.kt +++ b/src/android/app/src/main/java/com/openminis/app/tools/android/PrivilegedCommandRunner.kt @@ -2,6 +2,7 @@ package com.openminis.app.tools.android import android.content.Context import android.util.Log +import com.openminis.app.integrity.ProtectedRoot import com.openminis.app.runtime.ubuntu.DirectRootRunner import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -419,6 +420,7 @@ object PrivilegedCommandRunner { risk: CommandRisk = CommandRisk.READ_ONLY, timeoutMs: Long = 30_000L, rootOnly: Boolean = false, + entryPoint: String = "android-tool", ): AndroidCommandResult { require(argv.isNotEmpty()) { "privileged command argv must not be empty" } val tool = argv.first() @@ -447,7 +449,9 @@ object PrivilegedCommandRunner { "tool=$tool session=$sessionId", ) val result = try { - DirectRootRunner.runArgv(listOf(resolvedTool) + commandArgs, timeoutMs) + // Every agent-facing privileged command passes the device-integrity policy and runs in the + // protected view (Issue #182). Infrastructure the app builds itself uses DirectRootRunner. + ProtectedRoot.runArgv(entryPoint, sessionId, listOf(resolvedTool) + commandArgs, timeoutMs) } catch (cancelled: CancellationException) { throw cancelled } diff --git a/src/android/app/src/main/java/com/openminis/app/tools/runtime/RootShellHandler.kt b/src/android/app/src/main/java/com/openminis/app/tools/runtime/RootShellHandler.kt index bbf65786..67a5ee41 100644 --- a/src/android/app/src/main/java/com/openminis/app/tools/runtime/RootShellHandler.kt +++ b/src/android/app/src/main/java/com/openminis/app/tools/runtime/RootShellHandler.kt @@ -76,6 +76,7 @@ class RootShellHandler : ToolHandler { risk = PrivilegedCommandRisk.classify(tool, args), timeoutMs = timeout, rootOnly = true, + entryPoint = "root.shell", ) if (!response.success) { val detail = response.unavailableReason ?: response.stderr.ifBlank { "Root execution failed" } diff --git a/src/android/app/src/main/java/com/openminis/app/ui/settings/OffloadPermissionScreen.kt b/src/android/app/src/main/java/com/openminis/app/ui/settings/OffloadPermissionScreen.kt index 303920a4..51454915 100644 --- a/src/android/app/src/main/java/com/openminis/app/ui/settings/OffloadPermissionScreen.kt +++ b/src/android/app/src/main/java/com/openminis/app/ui/settings/OffloadPermissionScreen.kt @@ -95,6 +95,32 @@ fun OffloadPermissionScreen( ) } + // Device protection (Issue #182): only the user switches this, from here. + val protectionOn by com.openminis.app.integrity.DeviceProtectionStore.enabled.collectAsState() + val blocked by com.openminis.app.integrity.IntegrityAudit.entries.collectAsState() + SettingsSection(header = stringResource(R.string.perm_section_device_protection)) { + SettingsSwitchRow( + title = stringResource(R.string.perm_device_protection_title), + subtitle = stringResource(R.string.perm_device_protection_sub), + checked = protectionOn, + onCheckedChange = { com.openminis.app.integrity.DeviceProtectionStore.setEnabled(it) }, + showDivider = protectionOn, + ) + if (protectionOn) { + val recent = blocked.take(5) + SettingsRow( + title = stringResource(R.string.perm_device_protection_blocked), + subtitle = if (recent.isEmpty()) { + stringResource(R.string.perm_device_protection_none) + } else { + recent.joinToString("\n") { "${it.category}: ${it.target} (${it.entryPoint})" } + }, + showChevron = false, + showDivider = false, + ) + } + } + SettingsSection( header = stringResource(R.string.perm_section_config_tool), ) { diff --git a/src/android/app/src/main/res/values-de/strings.xml b/src/android/app/src/main/res/values-de/strings.xml index ec96f6a0..9c3360d2 100644 --- a/src/android/app/src/main/res/values-de/strings.xml +++ b/src/android/app/src/main/res/values-de/strings.xml @@ -306,6 +306,14 @@ Konfigurationstool Autonomie des Agenten + Geräteschutz + Geräteschutz + Blockiert nur, was das Gerät unbrauchbar machen oder am Starten hindern könnte: Schreiben auf Partitionen und Systemdateien, Kern-Systemapps, Zurücksetzen auf Werkseinstellungen. Es wird nichts abgefragt: Der Agent erhält einen Fehler und versucht es anders. Alles andere bleibt offen. + Zuletzt blockiert + Noch nichts blockiert + Geräteschutz + Speicher knapp — Minis hat Schreibvorgänge pausiert + Auf /data sind nur noch %1$d MB frei. Terminals und Hintergrundjobs sind pausiert, es wird nichts gelöscht. Sie laufen automatisch weiter, sobald Platz frei ist. Fragen Jede Änderung der Einstellungen durch den Agenten bestätigen. Intelligent diff --git a/src/android/app/src/main/res/values-fr/strings.xml b/src/android/app/src/main/res/values-fr/strings.xml index 3d50bdd6..94d52a40 100644 --- a/src/android/app/src/main/res/values-fr/strings.xml +++ b/src/android/app/src/main/res/values-fr/strings.xml @@ -306,6 +306,14 @@ Outil de configuration Autonomie de l\'agent + Protection de l\'appareil + Protection de l\'appareil + Bloque uniquement ce qui pourrait empêcher le téléphone de démarrer ou le rendre inutilisable : écriture sur les partitions et les fichiers système, applications système essentielles, réinitialisation d\'usine. Aucune confirmation : l\'agent reçoit une erreur et essaie autrement. Tout le reste reste ouvert. + Bloqués récemment + Rien n\'a été bloqué + Protection de l\'appareil + Stockage presque plein : Minis a suspendu ses écritures + Il ne reste que %1$d Mo sur /data. Les terminaux et les tâches en arrière-plan sont suspendus, rien n\'est supprimé. Ils reprennent automatiquement dès que de l\'espace est libéré. Demander Confirmer chaque modification des réglages par l\'agent. Intelligent diff --git a/src/android/app/src/main/res/values-ja/strings.xml b/src/android/app/src/main/res/values-ja/strings.xml index c8ba6c1d..483a1854 100644 --- a/src/android/app/src/main/res/values-ja/strings.xml +++ b/src/android/app/src/main/res/values-ja/strings.xml @@ -306,6 +306,14 @@ 設定ツール エージェントの自律レベル + デバイス保護 + デバイス保護 + 端末が起動できなくなる、または使えなくなる操作だけを止めます:パーティションやシステムファイルへの書き込み、コアシステムアプリの削除・無効化、初期化。確認は出さず、エージェントにはエラーが返り、別の方法を試します。それ以外は制限しません。 + 最近ブロックされた操作 + ブロックされた操作はまだありません + デバイス保護 + 空き容量が少ないため、Minisの書き込みを一時停止しました + /data の空きは %1$d MB のみです。ターミナルとバックグラウンドジョブを一時停止しました。ファイルは削除しません。空き容量が戻ると自動で再開します。 確認する エージェントによる設定変更をすべて確認します。 スマート diff --git a/src/android/app/src/main/res/values-ko/strings.xml b/src/android/app/src/main/res/values-ko/strings.xml index 817054d8..1e1951da 100644 --- a/src/android/app/src/main/res/values-ko/strings.xml +++ b/src/android/app/src/main/res/values-ko/strings.xml @@ -306,6 +306,14 @@ 설정 도구 에이전트 자율 수준 + 기기 보호 + 기기 보호 + 기기가 부팅되지 않거나 사용할 수 없게 되는 작업만 막습니다: 파티션·시스템 파일 쓰기, 핵심 시스템 앱 삭제·비활성화, 초기화. 확인 창 없이 에이전트에 오류가 전달되고 다른 방법을 시도합니다. 그 밖의 작업은 제한하지 않습니다. + 최근 차단된 작업 + 차단 기록이 없습니다 + 기기 보호 + 저장 공간이 부족하여 Minis의 쓰기를 일시 중지했습니다 + /data에 %1$d MB만 남았습니다. 터미널과 백그라운드 작업을 일시 중지했으며 파일은 삭제하지 않습니다. 공간이 확보되면 자동으로 재개됩니다. 묻기 에이전트가 설정을 바꿀 때마다 확인합니다. 스마트 diff --git a/src/android/app/src/main/res/values-ru/strings.xml b/src/android/app/src/main/res/values-ru/strings.xml index 424719d1..172836f4 100644 --- a/src/android/app/src/main/res/values-ru/strings.xml +++ b/src/android/app/src/main/res/values-ru/strings.xml @@ -531,6 +531,14 @@ Сбросить Инструмент настройки Автономность агента + Защита устройства + Защита устройства + Блокирует только то, что может помешать загрузке телефона или сделать его непригодным: запись в разделы и системные файлы, основные системные приложения, сброс до заводских настроек. Запросов нет: агент получает ошибку и пробует иначе. Всё остальное остаётся открытым. + Недавно заблокировано + Пока ничего не блокировалось + Защита устройства + Мало места — Minis приостановил запись + На /data осталось всего %1$d МБ. Терминалы и фоновые задачи приостановлены, ничего не удаляется. Они продолжатся автоматически, когда место освободится. Спрашивать Подтверждать каждое изменение настроек агентом. Умный режим diff --git a/src/android/app/src/main/res/values-zh-rTW/strings.xml b/src/android/app/src/main/res/values-zh-rTW/strings.xml index 91647b1c..b4bf0d9a 100644 --- a/src/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/src/android/app/src/main/res/values-zh-rTW/strings.xml @@ -1417,6 +1417,14 @@ 重置 配置工具 Agent 自主程度 + 裝置保護 + 裝置保護 + 只攔會讓手機無法開機或無法使用的操作:寫入分割區和系統檔案、解除安裝或停用核心系統應用程式、恢復原廠設定。不彈窗,Agent 直接收到錯誤並換個辦法;其他操作不受限。 + 最近攔截 + 尚無攔截紀錄 + 裝置保護 + 儲存空間不足,Minis 已暫停寫入 + /data 只剩 %1$d MB。終端機和背景任務已暫停,不會刪除任何檔案,釋出空間後自動繼續。 詢問 Agent 每次修改設定都要你確認。 智慧 diff --git a/src/android/app/src/main/res/values-zh/strings.xml b/src/android/app/src/main/res/values-zh/strings.xml index 05e2225d..f53e2455 100644 --- a/src/android/app/src/main/res/values-zh/strings.xml +++ b/src/android/app/src/main/res/values-zh/strings.xml @@ -400,6 +400,14 @@ 配置工具 Agent 自主程度 + 设备保护 + 设备保护 + 只拦会让手机无法开机或无法使用的操作:写分区和系统文件、卸载或停用核心系统应用、恢复出厂。不弹窗,Agent 直接收到错误并换办法;其他操作不受限。 + 最近被拦截 + 还没有拦截记录 + 设备保护 + 存储空间不足,Minis 已暂停写入 + /data 只剩 %1$d MB。终端和后台任务已暂停,不会删除任何文件,释放空间后自动继续。 询问 Agent 每次修改设置都要你确认。 智能 diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index bbe36102..39efa3d8 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -766,6 +766,14 @@ Configuration Tool Agent autonomy + Device protection + Device protection + Blocks only what could leave the phone unable to boot or unusable: writes to partitions and system files, core system apps, factory reset. Nothing is asked: the agent gets an error and tries another way. Everything else stays open. + Recently blocked + Nothing blocked yet + Device protection + Storage is low — Minis paused its writes + Only %1$d MB left on /data. Terminals and background jobs are paused and nothing is deleted. They resume automatically once space is freed. Ask Confirm every change the agent makes to settings. Smart diff --git a/src/android/app/src/test/java/com/openminis/app/integrity/ProtectedRootTest.kt b/src/android/app/src/test/java/com/openminis/app/integrity/ProtectedRootTest.kt new file mode 100644 index 00000000..81fddf4b --- /dev/null +++ b/src/android/app/src/test/java/com/openminis/app/integrity/ProtectedRootTest.kt @@ -0,0 +1,116 @@ +package com.openminis.app.integrity + +import com.openminis.app.runtime.ubuntu.DirectRootRunner +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ProtectedRootTest { + private val scripts = mutableListOf() + private var enabled = true + private var storageBlocked = false + private var runnerResult = DirectRootRunner.Result(0, "ok", "") + + @Before fun setUp() { + scripts.clear(); enabled = true; storageBlocked = false + runnerResult = DirectRootRunner.Result(0, "ok", "") + IntegrityAudit.clearForTest() + ProtectedRoot.policy = DeviceIntegrityPolicy(CorePackageResolver.StaticOnly) + ProtectedRoot.enabled = { enabled } + ProtectedRoot.storageBlocksWrites = { storageBlocked } + ProtectedRoot.rootfs = "/rootfs" + ProtectedRoot.runner = { script, _ -> scripts += script; runnerResult } + } + + @After fun tearDown() { + ProtectedRoot.policy = DeviceIntegrityPolicy(RootCorePackageResolver) + ProtectedRoot.enabled = { DeviceProtectionStore.isEnabled } + ProtectedRoot.storageBlocksWrites = { StorageGuard.writesBlocked } + ProtectedRoot.rootfs = com.openminis.app.runtime.ubuntu.UbuntuPaths.HOST_ROOTFS + } + + private fun argv(vararg a: String, session: String? = "s1") = + runBlocking { ProtectedRoot.runArgv("root.shell", session, a.toList()) } + + private fun script(s: String) = runBlocking { ProtectedRoot.runScript("android-root-cli", "s1", s) } + + @Test fun `a refused argv never reaches su and returns the fixed error`() { + val r = argv("dd", "if=/dev/zero", "of=/dev/block/by-name/boot") + assertEquals(ProtectedRoot.EXIT_DEVICE_PROTECTED, r.exitCode) + assertTrue(r.stderr.startsWith("DEVICE_PROTECTED: partition: ")) + assertTrue("runner must not run", scripts.isEmpty()) + } + + @Test fun `a refused exec script never reaches su`() { + val r = script("sh -c 'rm -rf /data/system'") + assertEquals(77, r.exitCode) + assertTrue(scripts.isEmpty()) + } + + @Test fun `a refusal is recorded with entry point session and target`() { + argv("rm", "-rf", "/data/system", session = "sess-9") + val e = IntegrityAudit.entries.value.single() + assertEquals("root.shell", e.entryPoint) + assertEquals("sess-9", e.sessionId) + assertEquals("data-system", e.category) + assertEquals("/data/system", e.target) + } + + @Test fun `an allowed command runs inside the protected view`() { + val r = argv("rm", "-rf", "/data/data/com.example/cache") + assertEquals(0, r.exitCode) + val s = scripts.single() + assertTrue(s.contains("/system/bin/unshare -m")) + assertTrue(s.contains("rm")) + assertTrue(s.contains("--bounding-set=")) + } + + @Test fun `a view that cannot be built is reported not hidden`() { + runnerResult = DirectRootRunner.Result(125, "", "DEVICE_PROTECTION_UNAVAILABLE: rootfs setpriv not found") + val r = argv("ls", "/sdcard") + assertEquals(125, r.exitCode) + assertTrue(ProtectedView.isUnavailable(r.exitCode, r.stderr)) + } + + @Test fun `with protection off nothing is checked or wrapped`() { + enabled = false + val r = argv("rm", "-rf", "/data/system") + assertEquals(0, r.exitCode) + assertEquals("exec 'rm' '-rf' '/data/system'", scripts.single()) + assertTrue(IntegrityAudit.entries.value.isEmpty()) + } + + @Test fun `while storage is low bulk writes are refused but deleting is not`() { + storageBlocked = true + val cp = argv("cp", "-r", "/sdcard/a", "/sdcard/b") + assertEquals(77, cp.exitCode) + assertTrue(cp.stderr.startsWith("DEVICE_PROTECTED: storage: ")) + assertEquals(0, argv("rm", "-rf", "/sdcard/big").exitCode) + assertEquals(0, argv("ls", "/sdcard").exitCode) + } + + @Test fun `storage refusal also covers exec scripts`() { + storageBlocked = true + assertEquals(77, script("dd if=/dev/zero of=/sdcard/fill bs=1M").exitCode) + assertEquals(0, script("dd if=/dev/zero of=/dev/null count=1").exitCode) + } + + @Test fun `checkWrite and checkDelete answer without running anything`() { + assertEquals("data-system", ProtectedRoot.checkWrite("android-root-cli", "s", "/metadata/x")?.category?.label) + assertNull(ProtectedRoot.checkWrite("android-root-cli", "s", "/sdcard/x")) + assertEquals("data-system", ProtectedRoot.checkDelete("android-root-cli", "s", "/data/data")?.category?.label) + assertTrue(scripts.isEmpty()) + enabled = false + assertNull(ProtectedRoot.checkWrite("android-root-cli", "s", "/metadata/x")) + } + + @Test fun `a refusal result is not a success`() { + val r = ProtectedRoot.refusalResult(DeviceIntegrityPolicy.Denial(DeviceIntegrityPolicy.Category.PARTITION, "x")) + assertFalse(r.success) + } +} diff --git a/src/android/app/src/test/java/com/openminis/app/integrity/ProtectedViewTest.kt b/src/android/app/src/test/java/com/openminis/app/integrity/ProtectedViewTest.kt new file mode 100644 index 00000000..e8688ddb --- /dev/null +++ b/src/android/app/src/test/java/com/openminis/app/integrity/ProtectedViewTest.kt @@ -0,0 +1,65 @@ +package com.openminis.app.integrity + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProtectedViewTest { + private val view = ProtectedView.wrap("echo hi", "/data/adb/minis/rootfs") + + @Test fun `runs in a private mount namespace and fails closed without unshare`() { + assertTrue(view.startsWith("if [ -x /system/bin/unshare ]; then exec /system/bin/unshare -m")) + assertTrue(view.contains("DEVICE_PROTECTION_UNAVAILABLE: unshare not found")) + assertTrue(view.contains("exit 125")) + } + + @Test fun `every protected directory is bound read-only`() { + for (dir in ProtectedView.READ_ONLY_DIRS) assertTrue(dir, view.contains(dir)) + assertTrue(view.contains("remount,bind,ro")) + } + + @Test fun `block nodes are swapped for an empty nodev tmpfs`() { + assertTrue(view.contains("-t tmpfs -o nodev,noexec,mode=755 tmpfs /dev/block")) + } + + @Test fun `su is covered before the read-only binds so it cannot start a root shell outside`() { + for (su in ProtectedView.SU_PATHS) assertTrue(su, view.contains(su)) + assertTrue(view.indexOf("/dev/.minis-guard/su") < view.indexOf("remount,bind,ro")) + } + + @Test fun `the minis directory is bound back read-write after data adb went read-only`() { + assertTrue(view.indexOf("remount,bind,ro") < view.indexOf("remount,bind,rw")) + assertTrue(view.contains("remount,bind,rw")) + } + + @Test fun `capabilities that could undo the view are dropped from the bounding set`() { + assertEquals("-sys_admin,-mknod,-sys_module,-sys_rawio", ProtectedView.DROPPED_CAPS) + assertTrue(view.contains("--bounding-set=-sys_admin,-mknod,-sys_module,-sys_rawio")) + assertTrue(view.contains("--inh-caps=-sys_admin,-mknod,-sys_module,-sys_rawio")) + // reboot and kill stay allowed + assertFalse(ProtectedView.DROPPED_CAPS.contains("sys_boot")) + assertFalse(ProtectedView.DROPPED_CAPS.contains("kill")) + } + + @Test fun `setpriv comes from the rootfs through its own loader and a missing one fails closed`() { + assertTrue(view.contains("ld-linux-aarch64.so.1")) + assertTrue(view.contains("rootfs setpriv not found")) + assertTrue(view.contains("rootfs loader not found")) + } + + @Test fun `the user script stays one quoted word and cannot break out`() { + val hostile = "echo '; rm -rf /data/system #" + val wrapped = ProtectedView.wrap(hostile, "/r") + // After the capability drop the script is a single shell-quoted argument. + val quoted = com.openminis.app.util.shellQuote(hostile) + assertTrue(wrapped.contains(com.openminis.app.util.shellQuote("/system/bin/sh -c $quoted").takeLast(8)) || wrapped.isNotEmpty()) + assertFalse("the raw script text must not appear unquoted", wrapped.contains("-- /system/bin/sh -c echo")) + } + + @Test fun `unavailable is recognised only by code and prefix together`() { + assertTrue(ProtectedView.isUnavailable(125, "DEVICE_PROTECTION_UNAVAILABLE: x")) + assertFalse(ProtectedView.isUnavailable(125, "something else")) + assertFalse(ProtectedView.isUnavailable(1, "DEVICE_PROTECTION_UNAVAILABLE: x")) + } +} diff --git a/src/android/app/src/test/java/com/openminis/app/integrity/RootCorePackageResolverTest.kt b/src/android/app/src/test/java/com/openminis/app/integrity/RootCorePackageResolverTest.kt new file mode 100644 index 00000000..b23fa17e --- /dev/null +++ b/src/android/app/src/test/java/com/openminis/app/integrity/RootCorePackageResolverTest.kt @@ -0,0 +1,78 @@ +package com.openminis.app.integrity + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class RootCorePackageResolverTest { + private var now = 0L + private var calls = 0 + private var output: String? = "" + + @Before fun setUp() { + now = 0; calls = 0; output = "" + RootCorePackageResolver.clearCache() + RootCorePackageResolver.clock = { now } + RootCorePackageResolver.factsRunner = { calls++; output } + } + + @After fun tearDown() { + RootCorePackageResolver.clearCache() + RootCorePackageResolver.clock = System::currentTimeMillis + } + + private val persistentDump = """ + pkgFlags=[ SYSTEM HAS_CODE PERSISTENT ALLOW_CLEAR_USER_DATA ] + sharedUser=SharedUserSetting{abc android.uid.phone/1001} + __HOME__ + com.miui.home/.launcher.Launcher + __IME__ + com.sohu.inputmethod.sogou/.SogouIME + """.trimIndent() + + @Test fun `persistent and shared system uid packages are core`() { + assertTrue(CorePackageFactsParser.parse("x.y", persistentDump).persistent) + assertEquals("android.uid.phone", CorePackageFactsParser.parse("x.y", persistentDump).sharedUserId) + assertTrue(CorePackageFactsParser.isCore("x.y", persistentDump)) + } + + @Test fun `the current launcher and keyboard are core`() { + assertTrue(CorePackageFactsParser.parse("com.miui.home", persistentDump).isCurrentHome) + assertTrue(CorePackageFactsParser.isCore("com.sohu.inputmethod.sogou", persistentDump)) + } + + @Test fun `an ordinary preinstalled app is not core`() { + val dump = "pkgFlags=[ SYSTEM HAS_CODE ALLOW_CLEAR_USER_DATA ]\n__HOME__\ncom.miui.home/.L\n__IME__\ncom.x/.Y" + assertFalse(CorePackageFactsParser.isCore("com.example.bloat", dump)) + } + + @Test fun `unreadable facts fail closed`() { + output = null + assertTrue(RootCorePackageResolver.isCore("com.example.bloat")) + } + + @Test fun `answers are cached briefly`() { + output = "pkgFlags=[ SYSTEM ]\n__HOME__\n\n__IME__\n" + assertFalse(RootCorePackageResolver.isCore("com.example.a")) + assertFalse(RootCorePackageResolver.isCore("com.example.a")) + assertEquals(1, calls) + now = 6_000 + assertFalse(RootCorePackageResolver.isCore("com.example.a")) + assertEquals(2, calls) + } + + @Test fun `static core packages need no device and a bad name never reaches the shell`() { + assertTrue(RootCorePackageResolver.isCore("com.android.systemui")) + assertEquals(0, calls) + assertFalse(RootCorePackageResolver.isCore("com.x; rm -rf /")) + assertEquals(0, calls) + try { + RootCorePackageResolver.factsScript("a b") + error("must reject") + } catch (_: IllegalArgumentException) { + } + } +} diff --git a/src/android/app/src/test/java/com/openminis/app/integrity/StorageGuardTest.kt b/src/android/app/src/test/java/com/openminis/app/integrity/StorageGuardTest.kt new file mode 100644 index 00000000..7f113c0b --- /dev/null +++ b/src/android/app/src/test/java/com/openminis/app/integrity/StorageGuardTest.kt @@ -0,0 +1,53 @@ +package com.openminis.app.integrity + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StorageGuardTest { + private val gb = 1024L * 1024 * 1024 + + @Test fun `threshold is the larger of 3 GB and 5 percent`() { + assertEquals(3 * gb, StorageGuard.thresholdBytes(32 * gb)) + assertEquals(256 * gb / 100 * 5, StorageGuard.thresholdBytes(256 * gb)) + assertTrue(StorageGuard.thresholdBytes(512 * gb) > 3 * gb) + } + + @Test fun `pauses below the line and resumes only with margin`() { + val total = 128 * gb + val t = StorageGuard.thresholdBytes(total) + assertFalse(StorageGuard.nextPaused(false, t + 1, total)) + assertTrue(StorageGuard.nextPaused(false, t - 1, total)) + // Paused: still paused just above the line, resumed once 25 percent clear of it. + assertTrue(StorageGuard.nextPaused(true, t + 1, total)) + assertTrue(StorageGuard.nextPaused(true, t + t / 4 - 1, total)) + assertFalse(StorageGuard.nextPaused(true, t + t / 4, total)) + } + + @Test fun `bulk writers are space consumers and deleters are not`() { + for (c in listOf("cp", "tar", "unzip", "fallocate", "rsync", "wget", "curl")) { + assertTrue(c, StorageGuard.consumesSpace(c, "$c x y")) + } + for (c in listOf("rm", "rmdir", "ls", "cat", "find", "pm", "kill", "df", "du", "mv")) { + assertFalse(c, StorageGuard.consumesSpace(c, "$c x")) + } + assertFalse(StorageGuard.consumesSpace("dd", "dd if=/dev/zero of=/dev/null count=1")) + assertTrue(StorageGuard.consumesSpace("dd", "dd if=/dev/zero of=/sdcard/x")) + assertFalse(StorageGuard.consumesSpace("truncate", "truncate x")) + assertTrue(StorageGuard.consumesSpace("truncate", "truncate -s 10G x")) + } + + @Test fun `the freeze script only signals recorded process groups`() { + val stop = StorageGuard.signalGuestShellsScript("STOP") + assertTrue(stop.contains("kill -STOP -\$PID")) + assertTrue(stop.contains("shell-*.pid")) + assertTrue(stop.contains("[ \"\$PID\" -gt 1 ]")) + assertTrue(StorageGuard.signalGuestShellsScript("CONT").contains("kill -CONT")) + try { + StorageGuard.signalGuestShellsScript("KILL") + error("must reject other signals") + } catch (_: IllegalArgumentException) { + } + } +} From 09f29a24f1cd1d910349325717ff15fc03d4c1b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 12:28:43 +0000 Subject: [PATCH 5/5] fix(integrity): check POST_NOTIFICATIONS before posting the low-storage notice Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01PV4ECd3Yzu9k81sw7vHjS3 --- .../java/com/openminis/app/integrity/StorageGuard.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/android/app/src/main/java/com/openminis/app/integrity/StorageGuard.kt b/src/android/app/src/main/java/com/openminis/app/integrity/StorageGuard.kt index aaa4030c..2aecbe5c 100644 --- a/src/android/app/src/main/java/com/openminis/app/integrity/StorageGuard.kt +++ b/src/android/app/src/main/java/com/openminis/app/integrity/StorageGuard.kt @@ -1,12 +1,15 @@ package com.openminis.app.integrity +import android.Manifest import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context +import android.content.pm.PackageManager import android.os.Build import android.os.StatFs import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import com.openminis.app.R import com.openminis.app.logging.AppLogger import com.openminis.app.runtime.ubuntu.DirectRootRunner @@ -113,6 +116,13 @@ object StorageGuard { .setContentText(context.getString(R.string.device_storage_low_body, freeBytes / (1024 * 1024))) .setOngoing(true) .build() + // POST_NOTIFICATIONS is a runtime permission on Android 13+; without it the freeze and the command + // refusals still apply, only the notice is skipped. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return + } runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) } } }