diff --git a/.github/workflows/release-mobile-lab-control.yml b/.github/workflows/release-mobile-lab-control.yml
new file mode 100644
index 0000000..8e2761b
--- /dev/null
+++ b/.github/workflows/release-mobile-lab-control.yml
@@ -0,0 +1,55 @@
+name: release Mobile Lab Control
+
+on:
+ push:
+ tags:
+ - "mobile-lab-control-v*"
+
+permissions:
+ contents: write
+
+jobs:
+ release-apk:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "17"
+ - name: Verify Android build inputs
+ run: |
+ test -f "$ANDROID_SDK_ROOT/platforms/android-35/android.jar"
+ test -x "$ANDROID_SDK_ROOT/build-tools/35.0.0/aapt2"
+ test -x "$ANDROID_SDK_ROOT/build-tools/35.0.0/apksigner"
+ - name: Restore release signing key
+ env:
+ KEYSTORE_B64: ${{ secrets.MOBILE_LAB_CONTROL_KEYSTORE_B64 }}
+ run: |
+ test -n "$KEYSTORE_B64"
+ umask 077
+ printf '%s' "$KEYSTORE_B64" | base64 --decode > "$RUNNER_TEMP/mobile-lab-control-release.jks"
+ - name: Build release APK
+ env:
+ ANDROID_RELEASE_KEYSTORE: ${{ runner.temp }}/mobile-lab-control-release.jks
+ ANDROID_RELEASE_KEY_ALIAS: ${{ secrets.MOBILE_LAB_CONTROL_KEY_ALIAS }}
+ ANDROID_KEYSTORE_PASSWORD: ${{ secrets.MOBILE_LAB_CONTROL_KEYSTORE_PASSWORD }}
+ ANDROID_KEY_PASSWORD: ${{ secrets.MOBILE_LAB_CONTROL_KEY_PASSWORD }}
+ run: |
+ version=${GITHUB_REF_NAME#mobile-lab-control-v}
+ IFS=. read -r major minor patch <<< "$version"
+ version_code=$((major * 10000 + minor * 100 + patch))
+ VERSION_NAME="$version" VERSION_CODE="$version_code" BUILD_MODE=release apps/mobile-lab-control/build.sh
+ cd apps/mobile-lab-control/build
+ sha256sum "mobile-lab-control-$version.apk" > "mobile-lab-control-$version.apk.sha256"
+ - name: Publish GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ version=${GITHUB_REF_NAME#mobile-lab-control-v}
+ apk="apps/mobile-lab-control/build/mobile-lab-control-$version.apk"
+ checksum="$apk.sha256"
+ gh release create "$GITHUB_REF_NAME" "$apk" "$checksum" \
+ --verify-tag \
+ --title "Mobile Lab Control $version" \
+ --notes "Installable release-signed APK and SHA-256 checksum for the three independent Happy, llama.cpp, and model-profile widgets. The app has no Internet permission. / 三个独立 Happy、llama.cpp、模型配置微件的正式签名 APK 与 SHA-256;App 不申请联网权限。"
diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml
index 17d0b31..61750aa 100644
--- a/.github/workflows/verify.yml
+++ b/.github/workflows/verify.yml
@@ -12,5 +12,35 @@ jobs:
repository:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- run: ./scripts/verify.sh
+
+ android-app:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "17"
+ - name: Verify Android build inputs
+ run: |
+ test -f "$ANDROID_SDK_ROOT/platforms/android-35/android.jar"
+ test -x "$ANDROID_SDK_ROOT/build-tools/35.0.0/aapt2"
+ test -x "$ANDROID_SDK_ROOT/build-tools/35.0.0/apksigner"
+ - name: Build debug-signed verification APK
+ run: BUILD_MODE=debug apps/mobile-lab-control/build.sh
+ - name: Verify package permissions and signature
+ run: |
+ apk=apps/mobile-lab-control/build/mobile-lab-control-0.4.0-debug.apk
+ permissions=$("$ANDROID_SDK_ROOT/build-tools/35.0.0/aapt2" dump permissions "$apk")
+ printf '%s\n' "$permissions"
+ ! grep -q 'android.permission.INTERNET' <<< "$permissions"
+ grep -q 'com.termux.permission.RUN_COMMAND' <<< "$permissions"
+ "$ANDROID_SDK_ROOT/build-tools/35.0.0/apksigner" verify --verbose "$apk"
+ - uses: actions/upload-artifact@v4
+ with:
+ name: mobile-lab-control-debug-verification
+ path: apps/mobile-lab-control/build/mobile-lab-control-0.4.0-debug.apk
+ if-no-files-found: error
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index 9f0fced..554329a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,6 @@
.env
secrets/
evidence/private/
+apps/mobile-lab-control/build/
+*.jks
+*.keystore
diff --git a/README.md b/README.md
index fef7a39..98f2a65 100644
--- a/README.md
+++ b/README.md
@@ -31,14 +31,17 @@ OpenCode TUI/Web ---------------------+-> provider profile -> model API
loopback OpenAI-compatible API for lightweight offline tasks.
- Switching model profiles is explicit, keeps secrets outside committed
configuration, and never changes service lifecycle implicitly.
-- Termux:Widget exposes three separate status-bearing controls for Happy,
- llama.cpp, and model configuration.
+- The offline `Mobile Lab Control` app exposes three independent native widgets
+ for Happy, llama.cpp, and model profiles. Termux:Widget remains an opt-in
+ terminal fallback.
## Repository layout
- `skills/deploy-android-ai-stack/` — install and operate the runtime.
- `skills/validate-android-ai-stack/` — bounded, evidence-based acceptance
checks.
+- `apps/mobile-lab-control/` — dependency-free Android AppWidget source and
+ reproducible build script.
- `config/profiles/` — safe provider-profile templates.
- `docs/` — architecture and the verified OnePlus 8T case.
- `manifests/` — facts captured from tested devices, with secret-free values.
@@ -52,7 +55,9 @@ OpenCode TUI/Web ---------------------+-> provider profile -> model API
[SKILL.md](skills/deploy-android-ai-stack/SKILL.md).
3. Create a provider profile from one of `config/profiles/*.example` and put
the token in the referenced private file.
-4. Use `$validate-android-ai-stack` to run zero-call checks first, then opt in
+4. Install the native control bridge and a checksum-verified APK from
+ [GitHub Releases](https://github.com/toolazytoname/android-ai-stack/releases).
+5. Use `$validate-android-ai-stack` to run zero-call checks first, then opt in
to at most one model call per client.
For an on-phone checklist, use the bilingual
@@ -77,6 +82,7 @@ fallback have both been exercised. Exact versions and evidence are recorded in
| Phone-local Happy | exercised end to end | lifecycle wrapper included |
| Provider switching | exercised on the private device config | redacted generic profiles included |
| llama.cpp local model | Qwen3 0.6B Q8, API and `local-lite` passed | authenticated loopback lifecycle and bounded checker included |
+| Native control widgets | three independent widgets exercised with zero model calls | source, allow-list bridge, bilingual UI, and release APK included |
The final phone acceptance also exercised the control split: profile changes
left running services untouched, Happy reported `STALE` when its startup
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 8ade261..897d293 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -9,8 +9,9 @@
Claude Code、Happy agent 与 Happy server 都运行在手机上;模型推理可以像
电脑一样,通过 URL/token 使用云 API、私有中转或可选的本地小模型。
-本仓只发布可复用的部署、模型切换和验收经验,不包含真实 token、私人
-endpoint、CC Switch 数据库、Happy 上游源码、签名 APK 或模型权重。
+本仓只发布可复用的部署、模型切换和验收经验,不包含真实 Token、私人
+Endpoint、CC Switch 数据库、Happy 上游源码或模型权重。正式签名 APK 只作为
+GitHub Release 资产发布,不进入 Git 历史。
## 架构
@@ -28,13 +29,14 @@ OpenCode TUI/Web ------------------------+-> provider profile -> 模型 API
- 本地推理也是可选扩展:小型 llama.cpp server 只监听 loopback,适合离线
摘要、分类、脱敏和断网兜底。
- 模型 profile 只保存路由信息;凭据始终位于独立的 mode `0600` 文件。
-- 配置切换不会隐式启停服务;Termux:Widget 分别提供 Happy、llama.cpp、Model
- 三个带状态入口。
+- 离线 `Mobile Lab Control` App 分别提供 Happy、llama.cpp、模型配置三个
+ 原生微件;Termux:Widget 只保留为可选终端回退。
## 仓库内容
- `skills/deploy-android-ai-stack/`:安装、模型切换和本地服务生命周期。
- `skills/validate-android-ai-stack/`:有调用预算的真实验收。
+- `apps/mobile-lab-control/`:无第三方依赖的 Android AppWidget 源码与可复建脚本。
- `config/profiles/`:直连、中转、本地 OpenAI-compatible 模板。
- `docs/`:架构、OnePlus 8T 案例和手机端速查教程。
- `manifests/`:不含身份信息的设备观察记录。
@@ -46,8 +48,11 @@ OpenCode TUI/Web ------------------------+-> provider profile -> 模型 API
2. 按 `$deploy-android-ai-stack` 安装固定版本的上游组件和本仓 wrapper。
3. 复制 `config/opencode.example.json`,从模板创建 mode `0600` 的 profile
和 token 文件。
-4. 先执行 `quick`、`tui`、`web-start` 等零模型调用检查。
-5. 前置全通过后,再分别允许 OpenCode 与 Claude 各做一次短请求。
+4. 安装原生控制桥接,并从
+ [GitHub Releases](https://github.com/toolazytoname/android-ai-stack/releases)
+ 下载和核对 APK。
+5. 先执行 `quick`、`tui`、`web-start` 等零模型调用检查。
+6. 前置全通过后,再分别允许 OpenCode 与 Claude 各做一次短请求。
适合直接放在手机上边看边操作的版本见
[手机速查教程](docs/phone-quickstart.zh-CN.md)。
@@ -61,6 +66,7 @@ OpenCode TUI/Web ------------------------+-> provider profile -> 模型 API
| 手机本地 Happy | 已做端到端验证 | 提供显式启停 wrapper |
| Provider 切换 | 私有设备配置已验证 | 提供脱敏通用 profile |
| llama.cpp 本地模型 | Qwen3 0.6B Q8 的 API 与 `local-lite` 已通过 | 提供带认证的 loopback wrapper 与限次 checker |
+| 原生控制微件 | 三个独立微件已完成零模型调用真机验收 | 提供源码、白名单桥接、双语 UI 与 Release APK |
最终手机验收也覆盖了控制面解耦:切换 profile 不改变正在运行的服务,Happy
启动时 profile 与当前配置不一致时明确显示 `STALE`,两个服务都能在不改变
diff --git a/SECURITY.md b/SECURITY.md
index 03aa3a2..10c1a24 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -25,5 +25,15 @@ Prefer text assertions and redacted command output. Capture screenshots only
when a GUI state cannot be proved otherwise. Run `scripts/verify.sh` before a
commit; it detects common secret-shaped strings and accidental model files.
+## APK release identity
+
+Installable Mobile Lab Control APKs belong in GitHub Releases, never in source
+history. Release APKs must match the public certificate in
+`apps/mobile-lab-control/release-certificate.pem`; its SHA-256 certificate
+fingerprint is
+`D0:2C:A4:E7:A4:FB:74:7A:E4:46:92:1A:DD:19:B1:1D:83:65:EE:3E:29:30:A4:CA:2B:25:94:AB:CE:74:70:45`.
+Do not publish a debug-signed APK as a product release. Keep the private key
+outside Git and back it up separately from GitHub Actions secrets.
+
Report a suspected vulnerability privately through GitHub's security advisory
feature rather than a public issue.
diff --git a/apps/mobile-lab-control/AndroidManifest.xml b/apps/mobile-lab-control/AndroidManifest.xml
new file mode 100644
index 0000000..5ee8f7d
--- /dev/null
+++ b/apps/mobile-lab-control/AndroidManifest.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/mobile-lab-control/README.md b/apps/mobile-lab-control/README.md
new file mode 100644
index 0000000..07a30c2
--- /dev/null
+++ b/apps/mobile-lab-control/README.md
@@ -0,0 +1,56 @@
+# Mobile Lab Control
+
+[English](README.md) · [简体中文](README.zh-CN.md)
+
+An offline Android control surface for the Android AI Stack. It exposes three
+independent home-screen widgets:
+
+- **Happy** — status, start/stop, restart, and refresh;
+- **llama.cpp** — status, start/stop, memory release, and refresh;
+- **model profiles** — current readable profile plus one-tap configuration
+ switching without changing either service lifecycle.
+
+The app has no Internet permission, reads no token, holds no root privilege,
+and accepts no free-form command. It can invoke only
+`$PREFIX/bin/mobile-lab-widget-action` through Termux's documented
+[`RUN_COMMAND` interface](https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent).
+The Termux bridge independently validates the same allow-list.
+
+## Build
+
+Install Android platform 35, build-tools 35.0.0, and a JDK, then run:
+
+```bash
+BUILD_MODE=debug ./build.sh
+```
+
+Supported modes are `debug`, `unsigned`, and `release`. Release signing reads
+the keystore path, alias, and passwords from environment variables; no key or
+password belongs in Git. Published APKs are attached to GitHub Releases rather
+than committed to repository history.
+
+The public release certificate is
+[`release-certificate.pem`](release-certificate.pem). Its SHA-256 certificate
+fingerprint is
+`D0:2C:A4:E7:A4:FB:74:7A:E4:46:92:1A:DD:19:B1:1D:83:65:EE:3E:29:30:A4:CA:2B:25:94:AB:CE:74:70:45`.
+Verify future APKs against this identity as well as the attached file checksum.
+
+## Runtime setup
+
+1. Install the Android AI Stack operator scripts in Termux.
+2. Review the external-command boundary, then run
+ `install-control-bridge --enable-external-apps`.
+3. Install the APK and grant `com.termux.permission.RUN_COMMAND` when Android
+ prompts.
+4. Open the app and add Happy, llama.cpp, and model-profile widgets separately.
+
+Copy [`config/widget-profiles.example`](../../config/widget-profiles.example) to
+`~/.config/android-ai-stack/widget-profiles`, keep it mode `0600`, and replace
+only the redacted profile names and labels. The bridge never reads provider
+tokens or CC Switch databases.
+
+## Upgrade identity
+
+An APK signed with a debug key cannot be upgraded in place to the public
+release-signed build. Remove the debug build once, install the release build,
+and re-add the widgets. Future release-signed versions then upgrade normally.
diff --git a/apps/mobile-lab-control/README.zh-CN.md b/apps/mobile-lab-control/README.zh-CN.md
new file mode 100644
index 0000000..99cfaab
--- /dev/null
+++ b/apps/mobile-lab-control/README.zh-CN.md
@@ -0,0 +1,47 @@
+# Mobile Lab Control
+
+[English](README.md) · [简体中文](README.zh-CN.md)
+
+这是 Android AI Stack 的离线原生控制面,提供三个彼此独立的桌面微件:
+
+- **Happy**:状态、启动/关闭、重启与刷新;
+- **llama.cpp**:状态、启动/关闭、释放模型内存与刷新;
+- **模型配置**:显示当前可读名称,一键切换配置,但不改变两个服务的启停状态。
+
+App 不申请联网权限、不读取 Token、不持有 root,也没有任意命令输入。它只通过
+Termux 官方 [`RUN_COMMAND` 接口](https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent)
+调用固定脚本
+`$PREFIX/bin/mobile-lab-widget-action`,Termux 桥接层会再次校验同一套白名单。
+
+## 构建
+
+准备 Android platform 35、build-tools 35.0.0 与 JDK,然后执行:
+
+```bash
+BUILD_MODE=debug ./build.sh
+```
+
+构建支持 `debug`、`unsigned` 和 `release`。正式签名只从环境变量读取 keystore
+路径、alias 与密码,密钥和密码都不能进入 Git。可安装 APK 通过 GitHub
+Release 发布,不提交进源码历史。
+
+公开 Release 证书保存在
+[`release-certificate.pem`](release-certificate.pem),证书 SHA-256 指纹为
+`D0:2C:A4:E7:A4:FB:74:7A:E4:46:92:1A:DD:19:B1:1D:83:65:EE:3E:29:30:A4:CA:2B:25:94:AB:CE:74:70:45`。
+后续 APK 除核对附件 SHA-256 外,也应核对这一签名身份。
+
+## 手机端配置
+
+1. 在 Termux 安装 Android AI Stack 操作脚本;
+2. 理解外部命令边界后执行 `install-control-bridge --enable-external-apps`;
+3. 安装 APK,并在 Android 提示时授予 `com.termux.permission.RUN_COMMAND`;
+4. 打开 App,分别添加 Happy、llama.cpp、模型配置三个微件。
+
+把 [`config/widget-profiles.example`](../../config/widget-profiles.example) 复制到
+`~/.config/android-ai-stack/widget-profiles`,保持 mode `0600`,只填写脱敏的
+Profile 名称和展示名。桥接层不会读取 Provider Token 或 CC Switch 数据库。
+
+## 升级签名说明
+
+使用 debug key 签名的旧版本不能直接覆盖升级到公开 release 签名版本。首次迁移
+需要卸载 debug 版、安装 release 版并重新添加微件;之后的正式版本可以正常覆盖升级。
diff --git a/apps/mobile-lab-control/build.sh b/apps/mobile-lab-control/build.sh
new file mode 100755
index 0000000..9a7a69a
--- /dev/null
+++ b/apps/mobile-lab-control/build.sh
@@ -0,0 +1,108 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+project_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+sdk_root=${ANDROID_SDK_ROOT:-${ANDROID_HOME:-/opt/homebrew/share/android-commandlinetools}}
+platform_api=${ANDROID_PLATFORM_API:-35}
+build_tools_version=${ANDROID_BUILD_TOOLS_VERSION:-35.0.0}
+version_name=${VERSION_NAME:-0.4.0}
+version_code=${VERSION_CODE:-400}
+build_mode=${BUILD_MODE:-debug}
+
+platform="$sdk_root/platforms/android-$platform_api/android.jar"
+build_tools="$sdk_root/build-tools/$build_tools_version"
+build_dir="$project_dir/build"
+compiled_dir="$build_dir/compiled"
+generated_dir="$build_dir/generated"
+classes_dir="$build_dir/classes"
+dex_dir="$build_dir/dex"
+unsigned_apk="$build_dir/mobile-lab-control-unsigned.apk"
+aligned_apk="$build_dir/mobile-lab-control-aligned.apk"
+
+case "$build_mode" in
+ debug|release|unsigned) ;;
+ *) printf 'BUILD_MODE must be debug, release, or unsigned\n' >&2; exit 2 ;;
+esac
+
+for required in "$platform" "$build_tools/aapt2" "$build_tools/zipalign" \
+ "$build_tools/apksigner" "$build_tools/d8"; do
+ [ -e "$required" ] || {
+ printf 'missing Android build dependency: %s\n' "$required" >&2
+ exit 1
+ }
+done
+command -v javac >/dev/null 2>&1 || { printf 'missing javac\n' >&2; exit 1; }
+
+if [ -d "$build_dir" ]; then
+ find "$build_dir" -mindepth 1 -delete
+fi
+mkdir -p "$compiled_dir" "$generated_dir" "$classes_dir" "$dex_dir"
+
+"$build_tools/aapt2" compile --dir "$project_dir/res" -o "$compiled_dir/resources.zip"
+"$build_tools/aapt2" link \
+ -I "$platform" \
+ --manifest "$project_dir/AndroidManifest.xml" \
+ --java "$generated_dir" \
+ --min-sdk-version 26 \
+ --target-sdk-version 35 \
+ --version-code "$version_code" \
+ --version-name "$version_name" \
+ -o "$unsigned_apk" \
+ "$compiled_dir/resources.zip"
+
+find "$project_dir/src" "$generated_dir" -type f -name '*.java' -print0 | \
+ xargs -0 javac -encoding UTF-8 -source 8 -target 8 -classpath "$platform" -d "$classes_dir"
+
+"$build_tools/d8" --min-api 26 --output "$dex_dir" \
+ $(find "$classes_dir" -type f -name '*.class' -print)
+(cd "$dex_dir" && zip -q -u "$unsigned_apk" classes.dex)
+"$build_tools/zipalign" -f 4 "$unsigned_apk" "$aligned_apk"
+
+case "$build_mode" in
+ unsigned)
+ final_apk="$build_dir/mobile-lab-control-$version_name-unsigned.apk"
+ cp "$aligned_apk" "$final_apk"
+ ;;
+ debug)
+ keystore=${ANDROID_DEBUG_KEYSTORE:-$HOME/.android/debug.keystore}
+ if [ ! -s "$keystore" ]; then
+ mkdir -p "$(dirname "$keystore")"
+ keytool -genkeypair -noprompt \
+ -keystore "$keystore" \
+ -storepass android \
+ -keypass android \
+ -alias androiddebugkey \
+ -keyalg RSA \
+ -keysize 2048 \
+ -validity 10000 \
+ -dname 'CN=Android Debug,O=Android,C=US'
+ fi
+ final_apk="$build_dir/mobile-lab-control-$version_name-debug.apk"
+ "$build_tools/apksigner" sign \
+ --ks "$keystore" \
+ --ks-key-alias androiddebugkey \
+ --ks-pass pass:android \
+ --key-pass pass:android \
+ --out "$final_apk" \
+ "$aligned_apk"
+ ;;
+ release)
+ : "${ANDROID_RELEASE_KEYSTORE:?set ANDROID_RELEASE_KEYSTORE}"
+ : "${ANDROID_RELEASE_KEY_ALIAS:?set ANDROID_RELEASE_KEY_ALIAS}"
+ : "${ANDROID_KEYSTORE_PASSWORD:?set ANDROID_KEYSTORE_PASSWORD}"
+ : "${ANDROID_KEY_PASSWORD:?set ANDROID_KEY_PASSWORD}"
+ final_apk="$build_dir/mobile-lab-control-$version_name.apk"
+ "$build_tools/apksigner" sign \
+ --ks "$ANDROID_RELEASE_KEYSTORE" \
+ --ks-key-alias "$ANDROID_RELEASE_KEY_ALIAS" \
+ --ks-pass env:ANDROID_KEYSTORE_PASSWORD \
+ --key-pass env:ANDROID_KEY_PASSWORD \
+ --out "$final_apk" \
+ "$aligned_apk"
+ ;;
+esac
+
+if [ "$build_mode" != unsigned ]; then
+ "$build_tools/apksigner" verify --verbose "$final_apk"
+fi
+printf 'built: %s\n' "$final_apk"
diff --git a/apps/mobile-lab-control/release-certificate.pem b/apps/mobile-lab-control/release-certificate.pem
new file mode 100644
index 0000000..a8f700a
--- /dev/null
+++ b/apps/mobile-lab-control/release-certificate.pem
@@ -0,0 +1,30 @@
+-----BEGIN CERTIFICATE-----
+MIIFKTCCAxGgAwIBAgIIWA6hUZEPrYAwDQYJKoZIhvcNAQEMBQAwQjELMAkGA1UE
+BhMCQ04xFjAUBgNVBAoTDXRvb2xhenl0b25hbWUxGzAZBgNVBAMTEk1vYmlsZSBM
+YWIgQ29udHJvbDAgFw0yNjA4MDcxMzM0MzlaGA8yMDUxMDgwMTEzMzQzOVowQjEL
+MAkGA1UEBhMCQ04xFjAUBgNVBAoTDXRvb2xhenl0b25hbWUxGzAZBgNVBAMTEk1v
+YmlsZSBMYWIgQ29udHJvbDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
+ANPnfEtX4tG5xh47Pkd/jrQtk4+7v/k/D6v2cWMR42iKc12ABRgCSIsBQmtXYcWi
+bZvpQ63qBWtfZzlsMBckuVIas3sKNfYV7zxtsxI9cewwfeBRavBqBd8TuZOpCiHU
+sevFex7KTLDRoJZPBqClabtC6GDwg69JD6HoLQgGTy4Z9UZay6wSFnlol3g5fTgW
+Jz31hGY2CjK16JSS6HangpAGAaaacybrX9wkqwVpyiwe6tIlA94OBIhh81WdVhVp
+leJIU74vYMu9wFNiOD6queBWOi2R0Ma57d71w4XkX6sUDQYkL0AYqbacw0w4OIGT
+tpfOqZWlNy8BdEeSoofzpm1p/ZQKq7D7xIUR8iwj5CAutOlMbcIgguKiNLNnkMzm
+I2jcOKpCmauQV16NIvmamUNaxY8p6eVolcL84lso03u3mT0fIPkKnEnhbn3MpY7I
+6geiLwhFqn5u8YGc59RmsWahoXfIFr2u1nqu12ffMJatRo0COujzxuDPzu99eyWs
+yCpz2zXLfJ/bCTRNI1P43vYb/qbjeIuto0d/yNbfsIInVaOh5Vr7pxje3Mpu7A3A
+QW6OuBNWR0Rnrf34u3y7iAgbBQFN94Eoe+qv8zHzsP+WOldN77aHmtrM42BecoOj
+FZmEgW+9+7EODemBiuED6catwJJw2JZY86aZ2siTd97XAgMBAAGjITAfMB0GA1Ud
+DgQWBBSTJ/yQQSqc0Cfo5Oeuc/hfWoLPZDANBgkqhkiG9w0BAQwFAAOCAgEAZMrL
+5WXoXIWvTUwiAnV/IQzYUN5sVBNDB4a+EE37k0Vd3jn0KQ2ewKKrstHvFjjQcyAb
+5iN7sclvGrqcduSFFZZ+UqkjbKwI4RvcaJ98zuBzxeQADt0JlO2BNYout0UGDgyd
+KCJOyXZ+gczdjXvRL9GCwgeNzYWMXJXhjodrFBePoW7fJyCKi2kjnRMiBQrcqeX4
+5O83UlZs8SB2vkO+INP/MAcLKzys3xCX9qEBW2vxezhbEApRq1FQ9fgcHCLNmvYK
+jsvKMHlEgt+mt42+PXHxTyhkOrK2AKcENFJKG9230l48VLsiZZFlYBXSJx5GDYvA
+yPoPdp+vScQ6fUTHWcef0fk65qgFpwPyyTv25gFLw18ofkPX15kJW9QlN3hstQcs
+0vfJ6qqNz9u8y0YE3/o2n6M95w+nAxmIQFDC8P4YeFd05dQHfeSYjxDl3tGdXq7q
+4axpSIxrLR4erRFW0c1/Qa+xugGnTRIf/eRuU7aHx5/gAYV57kt7+pdZU1O6ouhf
+ULPn7vFpmH+hvesYGrQneYYYxNaMmaXmRubdx1YISzDsm3GoO/GxtTMNsU6FsHr6
+VC/5P4ymvmbJZ1sOwzpE6NPNf2c7JswsTlEc/WF6SIco65rsVmrVMHs7tx/1iQPb
+D/biDZeukiO5Fo6KKCtmwjV47/+6d/MNF0axViQ=
+-----END CERTIFICATE-----
diff --git a/apps/mobile-lab-control/res/drawable/button_background.xml b/apps/mobile-lab-control/res/drawable/button_background.xml
new file mode 100644
index 0000000..048c942
--- /dev/null
+++ b/apps/mobile-lab-control/res/drawable/button_background.xml
@@ -0,0 +1,15 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/apps/mobile-lab-control/res/drawable/ic_mobile_lab.xml b/apps/mobile-lab-control/res/drawable/ic_mobile_lab.xml
new file mode 100644
index 0000000..b65d2a7
--- /dev/null
+++ b/apps/mobile-lab-control/res/drawable/ic_mobile_lab.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/apps/mobile-lab-control/res/drawable/widget_background.xml b/apps/mobile-lab-control/res/drawable/widget_background.xml
new file mode 100644
index 0000000..1b5c5ca
--- /dev/null
+++ b/apps/mobile-lab-control/res/drawable/widget_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/mobile-lab-control/res/layout/activity_main.xml b/apps/mobile-lab-control/res/layout/activity_main.xml
new file mode 100644
index 0000000..d41023b
--- /dev/null
+++ b/apps/mobile-lab-control/res/layout/activity_main.xml
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/mobile-lab-control/res/layout/widget_happy.xml b/apps/mobile-lab-control/res/layout/widget_happy.xml
new file mode 100644
index 0000000..934d480
--- /dev/null
+++ b/apps/mobile-lab-control/res/layout/widget_happy.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/mobile-lab-control/res/layout/widget_llama.xml b/apps/mobile-lab-control/res/layout/widget_llama.xml
new file mode 100644
index 0000000..31d086f
--- /dev/null
+++ b/apps/mobile-lab-control/res/layout/widget_llama.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/mobile-lab-control/res/layout/widget_model.xml b/apps/mobile-lab-control/res/layout/widget_model.xml
new file mode 100644
index 0000000..669228b
--- /dev/null
+++ b/apps/mobile-lab-control/res/layout/widget_model.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/mobile-lab-control/res/values-zh-rCN/strings.xml b/apps/mobile-lab-control/res/values-zh-rCN/strings.xml
new file mode 100644
index 0000000..5508b11
--- /dev/null
+++ b/apps/mobile-lab-control/res/values-zh-rCN/strings.xml
@@ -0,0 +1,81 @@
+
+
+ Mobile Lab Control
+ Happy 服务控制
+ llama.cpp 本地模型
+ AI 模型切换
+
+ 三个独立桌面微件分别控制 Happy、llama.cpp 与模型配置。按钮显示已配置的可读名称,不暴露内部别名。
+ 正在检查 Termux 权限…
+ 授权 Termux 控制权限
+ 刷新全部状态
+ 添加到桌面
+ 添加 Happy 微件
+ 添加 llama.cpp 微件
+ 添加模型切换微件
+ 安全边界:本 App 不联网、不读取 Token、不持有 root,只调用已安装的固定白名单桥接脚本。
+
+ Happy 本机服务
+ llama.cpp 本地模型
+ AI 模型配置
+ 状态:等待刷新
+ 启动 / 停止
+ 重启
+ ↻
+ 点击名称只切配置,不启停 Happy 或本地模型。
+ 当前:等待刷新
+ 等待刷新
+
+ 本地模型
+ 模型配置 A
+ 模型配置 B
+ 模型配置 C
+ 模型配置 D
+ 模型配置 E
+ 模型配置 F
+ 模型配置 G
+ 模型配置 H
+ 模型配置 I
+
+ ✓ Termux 控制权限已授予\n✓ App 无联网权限\n✓ 只允许固定控制动作
+ 权限已就绪 · 点击刷新状态
+ 需要授予“在 Termux 环境中运行命令”权限,微件才能工作。
+ Termux 控制权限已经授予
+ 请长按桌面 → 微件 → Mobile Lab Control
+ 启动器未接受请求,请从桌面微件列表添加。
+
+ 正在刷新状态…
+ 正在启动 Happy,通常需要 10–30 秒…
+ 正在停止 Happy,通常需要 10–30 秒…
+ 正在重启 Happy,通常需要 20–40 秒…
+ 正在启动本地模型,通常需要 5–15 秒…
+ 正在停止本地模型并释放内存…
+ 正在切换模型配置…
+
+ Termux 没有返回结果
+ 命令失败,退出码 %1$d
+ 操作失败,请查看微件状态
+ 操作完成
+ 拒绝未知模型配置
+ 请先授予 Termux 控制权限
+ 拒绝白名单之外的控制动作
+ 无法调用 Termux:%1$s
+ 错误:%1$s
+ 当前:%1$s
+
+ 状态:已启动
+ 状态:模型已切换,需要重启
+ 状态:server 已启动,daemon 未就绪
+ 状态:已关闭
+ 停止
+ 修复 / 重启
+ 启动
+
+ 状态:已启动,正在占用内存
+ 状态:异常,点击修复
+ 状态:已关闭,模型内存已释放
+ 停止并释放内存
+ 启动本地模型
+ %1$s(需登录)
+ %1$s(未配置)
+
diff --git a/apps/mobile-lab-control/res/values/colors.xml b/apps/mobile-lab-control/res/values/colors.xml
new file mode 100644
index 0000000..65d1a42
--- /dev/null
+++ b/apps/mobile-lab-control/res/values/colors.xml
@@ -0,0 +1,11 @@
+
+
+ #F2141B2D
+ #594FD1C5
+ #FFF7FAFC
+ #FFAAC0D1
+ #FF4FD1C5
+ #FF4C9DFF
+ #FFA78BFA
+ #FF22314A
+
diff --git a/apps/mobile-lab-control/res/values/strings.xml b/apps/mobile-lab-control/res/values/strings.xml
new file mode 100644
index 0000000..96714fc
--- /dev/null
+++ b/apps/mobile-lab-control/res/values/strings.xml
@@ -0,0 +1,81 @@
+
+
+ Mobile Lab Control
+ Happy service control
+ llama.cpp local model
+ AI profile switcher
+
+ Three independent home-screen widgets for Happy, llama.cpp, and model profiles. Buttons show configured names instead of internal aliases.
+ Checking Termux permission…
+ Grant Termux command permission
+ Refresh all status
+ Add to home screen
+ Add Happy widget
+ Add llama.cpp widget
+ Add profile widget
+ Security boundary: this app has no network permission, reads no token, holds no root privilege, and invokes only an installed allow-list bridge.
+
+ Happy local service
+ llama.cpp local model
+ AI model profile
+ Status: waiting for refresh
+ Start / stop
+ Restart
+ ↻
+ Selecting a name changes configuration only; it does not start or stop a service.
+ Current: waiting for refresh
+ waiting for refresh
+
+ Local model
+ Profile A
+ Profile B
+ Profile C
+ Profile D
+ Profile E
+ Profile F
+ Profile G
+ Profile H
+ Profile I
+
+ ✓ Termux command permission granted\n✓ App has no network permission\n✓ Only fixed control actions are allowed
+ Permission ready · refresh status
+ Grant “run commands in Termux” permission before using the widgets.
+ Termux command permission is already granted
+ Long-press the home screen → Widgets → Mobile Lab Control
+ The launcher did not accept the request; add it from the widget picker.
+
+ Refreshing status…
+ Starting Happy; this usually takes 10–30 seconds…
+ Stopping Happy; this usually takes 10–30 seconds…
+ Restarting Happy; this usually takes 20–40 seconds…
+ Starting the local model; this usually takes 5–15 seconds…
+ Stopping the local model and releasing memory…
+ Switching model profile…
+
+ Termux returned no result
+ Command failed with exit code %1$d
+ Operation failed; inspect the widget status
+ Operation complete
+ Rejected an unknown model profile
+ Grant Termux command permission first
+ Rejected an action outside the allow-list
+ Could not invoke Termux: %1$s
+ Error: %1$s
+ Current: %1$s
+
+ Status: running
+ Status: profile changed; restart required
+ Status: server running; daemon not ready
+ Status: stopped
+ Stop
+ Repair / restart
+ Start
+
+ Status: running and using memory
+ Status: unhealthy; tap to repair
+ Status: stopped; model memory released
+ Stop and release memory
+ Start local model
+ %1$s (sign-in required)
+ %1$s (not configured)
+
diff --git a/apps/mobile-lab-control/res/values/styles.xml b/apps/mobile-lab-control/res/values/styles.xml
new file mode 100644
index 0000000..85fe2bd
--- /dev/null
+++ b/apps/mobile-lab-control/res/values/styles.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/apps/mobile-lab-control/res/xml/widget_happy_info.xml b/apps/mobile-lab-control/res/xml/widget_happy_info.xml
new file mode 100644
index 0000000..d9314c1
--- /dev/null
+++ b/apps/mobile-lab-control/res/xml/widget_happy_info.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/apps/mobile-lab-control/res/xml/widget_llama_info.xml b/apps/mobile-lab-control/res/xml/widget_llama_info.xml
new file mode 100644
index 0000000..2e2b592
--- /dev/null
+++ b/apps/mobile-lab-control/res/xml/widget_llama_info.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/apps/mobile-lab-control/res/xml/widget_model_info.xml b/apps/mobile-lab-control/res/xml/widget_model_info.xml
new file mode 100644
index 0000000..a18d4b6
--- /dev/null
+++ b/apps/mobile-lab-control/res/xml/widget_model_info.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/CommandResultReceiver.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/CommandResultReceiver.java
new file mode 100644
index 0000000..4184caa
--- /dev/null
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/CommandResultReceiver.java
@@ -0,0 +1,42 @@
+package io.github.toolazytoname.mobilelabcontrol;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.widget.Toast;
+
+public final class CommandResultReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null) return;
+ String operation = intent.getStringExtra(ControlContract.EXTRA_OPERATION);
+ String scope = ControlContract.scopeForOperation(operation);
+ Bundle result = intent.getBundleExtra(ControlContract.RESULT_BUNDLE);
+ if (result == null) {
+ WidgetState.setError(context, scope, context.getString(R.string.error_termux_no_result));
+ WidgetUpdater.updateAll(context);
+ return;
+ }
+
+ int exitCode = result.getInt(ControlContract.RESULT_EXIT_CODE, -1);
+ String stdout = result.getString(ControlContract.RESULT_STDOUT, "");
+ String stderr = result.getString(ControlContract.RESULT_STDERR, "");
+ String errorMessage = result.getString(ControlContract.RESULT_ERROR_MESSAGE, "");
+
+ if (exitCode == 0) {
+ WidgetState.applyStatus(context, stdout, operation);
+ if (operation != null && !operation.startsWith("status")) {
+ Toast.makeText(context, R.string.operation_complete, Toast.LENGTH_SHORT).show();
+ }
+ } else {
+ String message = !errorMessage.isEmpty() ? errorMessage : stderr;
+ if (message == null || message.trim().isEmpty()) {
+ message = context.getString(R.string.error_command_exit, exitCode);
+ }
+ WidgetState.setError(context, scope, message);
+ Toast.makeText(context, R.string.error_operation_failed, Toast.LENGTH_SHORT).show();
+ }
+ WidgetUpdater.updateAll(context);
+ }
+}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ControlContract.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ControlContract.java
new file mode 100644
index 0000000..f1963e4
--- /dev/null
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ControlContract.java
@@ -0,0 +1,61 @@
+package io.github.toolazytoname.mobilelabcontrol;
+
+final class ControlContract {
+ static final String TERMUX_PERMISSION = "com.termux.permission.RUN_COMMAND";
+ static final String TERMUX_PACKAGE = "com.termux";
+ static final String TERMUX_SERVICE = "com.termux.app.RunCommandService";
+ static final String TERMUX_ACTION = "com.termux.RUN_COMMAND";
+ static final String TERMUX_PATH = "com.termux.RUN_COMMAND_PATH";
+ static final String TERMUX_ARGUMENTS = "com.termux.RUN_COMMAND_ARGUMENTS";
+ static final String TERMUX_WORKDIR = "com.termux.RUN_COMMAND_WORKDIR";
+ static final String TERMUX_BACKGROUND = "com.termux.RUN_COMMAND_BACKGROUND";
+ static final String TERMUX_PENDING_INTENT = "com.termux.RUN_COMMAND_PENDING_INTENT";
+
+ static final String RESULT_BUNDLE = "result";
+ static final String RESULT_STDOUT = "stdout";
+ static final String RESULT_STDERR = "stderr";
+ static final String RESULT_EXIT_CODE = "exitCode";
+ static final String RESULT_ERROR_MESSAGE = "errmsg";
+
+ static final String SCRIPT = "/data/data/com.termux/files/usr/bin/mobile-lab-widget-action";
+ static final String HOME = "/data/data/com.termux/files/home";
+
+ static final String ACTION_HAPPY_TOGGLE = "io.github.toolazytoname.mobilelabcontrol.HAPPY_TOGGLE";
+ static final String ACTION_HAPPY_RESTART = "io.github.toolazytoname.mobilelabcontrol.HAPPY_RESTART";
+ static final String ACTION_LLAMA_TOGGLE = "io.github.toolazytoname.mobilelabcontrol.LLAMA_TOGGLE";
+ static final String ACTION_REFRESH = "io.github.toolazytoname.mobilelabcontrol.REFRESH";
+ static final String ACTION_MODEL = "io.github.toolazytoname.mobilelabcontrol.MODEL";
+ static final String EXTRA_MODEL = "model";
+ static final String EXTRA_OPERATION = "operation";
+
+ static final String SCOPE_ALL = "all";
+ static final String SCOPE_HAPPY = "happy";
+ static final String SCOPE_LLAMA = "llama";
+ static final String SCOPE_MODEL = "model";
+
+ static boolean isAllowedModelTarget(String target) {
+ return "local".equals(target) || (target != null && target.matches("slot-0[1-9]"));
+ }
+
+ static boolean isAllowedCommand(String operation, String... arguments) {
+ int count = arguments == null ? 0 : arguments.length;
+ if ("status".equals(operation) || "happy-toggle".equals(operation)
+ || "happy-restart".equals(operation) || "llama-toggle".equals(operation)) {
+ return count == 0;
+ }
+ return "model-use".equals(operation) && count == 1 && isAllowedModelTarget(arguments[0]);
+ }
+
+ static String scopeForOperation(String operation) {
+ if (operation == null || "status".equals(operation)) return SCOPE_ALL;
+ if ("status-happy".equals(operation)) return SCOPE_HAPPY;
+ if ("status-llama".equals(operation)) return SCOPE_LLAMA;
+ if ("status-model".equals(operation)) return SCOPE_MODEL;
+ if (operation.startsWith("happy-")) return SCOPE_HAPPY;
+ if (operation.startsWith("llama-")) return SCOPE_LLAMA;
+ if (operation.startsWith("model-")) return SCOPE_MODEL;
+ return SCOPE_ALL;
+ }
+
+ private ControlContract() {}
+}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/HappyWidgetProvider.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/HappyWidgetProvider.java
new file mode 100644
index 0000000..3b09ad0
--- /dev/null
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/HappyWidgetProvider.java
@@ -0,0 +1,41 @@
+package io.github.toolazytoname.mobilelabcontrol;
+
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+import android.content.Intent;
+
+public final class HappyWidgetProvider extends AppWidgetProvider {
+ @Override
+ public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
+ WidgetUpdater.updateHappy(context);
+ TermuxRunner.refresh(context, ControlContract.SCOPE_HAPPY);
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ super.onReceive(context, intent);
+ if (intent == null || intent.getAction() == null) return;
+ switch (intent.getAction()) {
+ case ControlContract.ACTION_HAPPY_TOGGLE:
+ String current = WidgetState.get(context, "happy_state", "OFF");
+ WidgetState.setBusy(context, ControlContract.SCOPE_HAPPY, context.getString("ON".equals(current)
+ ? R.string.busy_happy_stop : R.string.busy_happy_start));
+ WidgetUpdater.updateAll(context);
+ TermuxRunner.run(context, "happy-toggle");
+ break;
+ case ControlContract.ACTION_HAPPY_RESTART:
+ WidgetState.setBusy(context, ControlContract.SCOPE_HAPPY, context.getString(R.string.busy_happy_restart));
+ WidgetUpdater.updateAll(context);
+ TermuxRunner.run(context, "happy-restart");
+ break;
+ case ControlContract.ACTION_REFRESH:
+ WidgetState.setBusy(context, ControlContract.SCOPE_HAPPY, context.getString(R.string.busy_refresh));
+ WidgetUpdater.updateAll(context);
+ TermuxRunner.refresh(context, ControlContract.SCOPE_HAPPY);
+ break;
+ default:
+ break;
+ }
+ }
+}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/LlamaWidgetProvider.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/LlamaWidgetProvider.java
new file mode 100644
index 0000000..94ff03f
--- /dev/null
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/LlamaWidgetProvider.java
@@ -0,0 +1,36 @@
+package io.github.toolazytoname.mobilelabcontrol;
+
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+import android.content.Intent;
+
+public final class LlamaWidgetProvider extends AppWidgetProvider {
+ @Override
+ public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
+ WidgetUpdater.updateLlama(context);
+ TermuxRunner.refresh(context, ControlContract.SCOPE_LLAMA);
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ super.onReceive(context, intent);
+ if (intent == null || intent.getAction() == null) return;
+ switch (intent.getAction()) {
+ case ControlContract.ACTION_LLAMA_TOGGLE:
+ String current = WidgetState.get(context, "llama_state", "OFF");
+ WidgetState.setBusy(context, ControlContract.SCOPE_LLAMA, context.getString("ON".equals(current)
+ ? R.string.busy_llama_stop : R.string.busy_llama_start));
+ WidgetUpdater.updateAll(context);
+ TermuxRunner.run(context, "llama-toggle");
+ break;
+ case ControlContract.ACTION_REFRESH:
+ WidgetState.setBusy(context, ControlContract.SCOPE_LLAMA, context.getString(R.string.busy_refresh));
+ WidgetUpdater.updateAll(context);
+ TermuxRunner.refresh(context, ControlContract.SCOPE_LLAMA);
+ break;
+ default:
+ break;
+ }
+ }
+}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/MainActivity.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/MainActivity.java
new file mode 100644
index 0000000..c32641c
--- /dev/null
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/MainActivity.java
@@ -0,0 +1,81 @@
+package io.github.toolazytoname.mobilelabcontrol;
+
+import android.app.Activity;
+import android.appwidget.AppWidgetManager;
+import android.content.ComponentName;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+
+public final class MainActivity extends Activity {
+ private static final int PERMISSION_REQUEST = 42;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_main);
+
+ findViewById(R.id.request_permission).setOnClickListener(view -> requestTermuxPermission());
+ findViewById(R.id.refresh_all).setOnClickListener(view -> {
+ WidgetState.setBusy(this, ControlContract.SCOPE_ALL, getString(R.string.busy_refresh));
+ WidgetUpdater.updateAll(this);
+ TermuxRunner.refresh(this, ControlContract.SCOPE_ALL);
+ });
+ findViewById(R.id.pin_happy).setOnClickListener(view -> pin(HappyWidgetProvider.class));
+ findViewById(R.id.pin_llama).setOnClickListener(view -> pin(LlamaWidgetProvider.class));
+ findViewById(R.id.pin_model).setOnClickListener(view -> pin(ModelWidgetProvider.class));
+ updatePermissionStatus();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ updatePermissionStatus();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode == PERMISSION_REQUEST) {
+ updatePermissionStatus();
+ if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ TermuxRunner.refresh(this, ControlContract.SCOPE_ALL);
+ }
+ }
+ }
+
+ private void requestTermuxPermission() {
+ if (TermuxRunner.hasPermission(this)) {
+ Toast.makeText(this, R.string.permission_already_granted, Toast.LENGTH_SHORT).show();
+ TermuxRunner.refresh(this, ControlContract.SCOPE_ALL);
+ return;
+ }
+ requestPermissions(new String[]{ControlContract.TERMUX_PERMISSION}, PERMISSION_REQUEST);
+ }
+
+ private void updatePermissionStatus() {
+ TextView status = findViewById(R.id.permission_status);
+ Button request = findViewById(R.id.request_permission);
+ if (TermuxRunner.hasPermission(this)) {
+ status.setText(R.string.permission_ready);
+ request.setText(R.string.permission_ready_button);
+ } else {
+ status.setText(R.string.permission_required);
+ request.setText(R.string.grant_termux_permission);
+ }
+ }
+
+ private void pin(Class> providerClass) {
+ AppWidgetManager manager = AppWidgetManager.getInstance(this);
+ if (!manager.isRequestPinAppWidgetSupported()) {
+ Toast.makeText(this, R.string.pin_manually, Toast.LENGTH_LONG).show();
+ return;
+ }
+ boolean requested = manager.requestPinAppWidget(new ComponentName(this, providerClass), null, null);
+ if (!requested) {
+ Toast.makeText(this, R.string.pin_rejected, Toast.LENGTH_LONG).show();
+ }
+ }
+}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ModelWidgetProvider.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ModelWidgetProvider.java
new file mode 100644
index 0000000..91cc6ac
--- /dev/null
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ModelWidgetProvider.java
@@ -0,0 +1,40 @@
+package io.github.toolazytoname.mobilelabcontrol;
+
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+import android.content.Intent;
+
+public final class ModelWidgetProvider extends AppWidgetProvider {
+ @Override
+ public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
+ WidgetUpdater.updateModel(context);
+ TermuxRunner.refresh(context, ControlContract.SCOPE_MODEL);
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ super.onReceive(context, intent);
+ if (intent == null || intent.getAction() == null) return;
+ switch (intent.getAction()) {
+ case ControlContract.ACTION_MODEL:
+ String model = intent.getStringExtra(ControlContract.EXTRA_MODEL);
+ if (!ControlContract.isAllowedModelTarget(model)) {
+ WidgetState.setError(context, ControlContract.SCOPE_MODEL, context.getString(R.string.error_unknown_profile));
+ WidgetUpdater.updateAll(context);
+ return;
+ }
+ WidgetState.setBusy(context, ControlContract.SCOPE_MODEL, context.getString(R.string.busy_model_switch));
+ WidgetUpdater.updateAll(context);
+ TermuxRunner.run(context, "model-use", model);
+ break;
+ case ControlContract.ACTION_REFRESH:
+ WidgetState.setBusy(context, ControlContract.SCOPE_MODEL, context.getString(R.string.busy_refresh));
+ WidgetUpdater.updateAll(context);
+ TermuxRunner.refresh(context, ControlContract.SCOPE_MODEL);
+ break;
+ default:
+ break;
+ }
+ }
+}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/TermuxRunner.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/TermuxRunner.java
new file mode 100644
index 0000000..62041f7
--- /dev/null
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/TermuxRunner.java
@@ -0,0 +1,74 @@
+package io.github.toolazytoname.mobilelabcontrol;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Build;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+final class TermuxRunner {
+ private static final AtomicInteger REQUESTS = new AtomicInteger(1000);
+
+ static boolean hasPermission(Context context) {
+ return context.checkSelfPermission(ControlContract.TERMUX_PERMISSION) == PackageManager.PERMISSION_GRANTED;
+ }
+
+ static void run(Context context, String operation, String... arguments) {
+ runInternal(context, operation, operation, arguments);
+ }
+
+ static void refresh(Context context, String scope) {
+ String callbackOperation = ControlContract.SCOPE_ALL.equals(scope) ? "status" : "status-" + scope;
+ runInternal(context, "status", callbackOperation);
+ }
+
+ private static void runInternal(Context context, String operation, String callbackOperation, String... arguments) {
+ String scope = ControlContract.scopeForOperation(callbackOperation);
+ if (!ControlContract.isAllowedCommand(operation, arguments)) {
+ WidgetState.setError(context, scope, context.getString(R.string.error_invalid_action));
+ WidgetUpdater.updateAll(context);
+ return;
+ }
+ if (!hasPermission(context)) {
+ WidgetState.setError(context, scope, context.getString(R.string.error_permission_first));
+ WidgetUpdater.updateAll(context);
+ return;
+ }
+
+ int requestCode = REQUESTS.incrementAndGet();
+ Intent resultIntent = new Intent(context, CommandResultReceiver.class);
+ resultIntent.putExtra(ControlContract.EXTRA_OPERATION, callbackOperation);
+ int flags = PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) flags |= PendingIntent.FLAG_MUTABLE;
+ PendingIntent callback = PendingIntent.getBroadcast(context, requestCode, resultIntent, flags);
+
+ List commandArguments = new ArrayList<>();
+ commandArguments.add(operation);
+ if (arguments != null) {
+ for (String argument : arguments) commandArguments.add(argument);
+ }
+
+ Intent command = new Intent();
+ command.setClassName(ControlContract.TERMUX_PACKAGE, ControlContract.TERMUX_SERVICE);
+ command.setAction(ControlContract.TERMUX_ACTION);
+ command.putExtra(ControlContract.TERMUX_PATH, ControlContract.SCRIPT);
+ command.putExtra(ControlContract.TERMUX_ARGUMENTS, commandArguments.toArray(new String[0]));
+ command.putExtra(ControlContract.TERMUX_WORKDIR, ControlContract.HOME);
+ command.putExtra(ControlContract.TERMUX_BACKGROUND, true);
+ command.putExtra(ControlContract.TERMUX_PENDING_INTENT, callback);
+
+ try {
+ context.startService(command);
+ } catch (Exception exception) {
+ WidgetState.setError(context, scope, context.getString(
+ R.string.error_termux_call, exception.getClass().getSimpleName()));
+ WidgetUpdater.updateAll(context);
+ }
+ }
+
+ private TermuxRunner() {}
+}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/WidgetState.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/WidgetState.java
new file mode 100644
index 0000000..34583ec
--- /dev/null
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/WidgetState.java
@@ -0,0 +1,93 @@
+package io.github.toolazytoname.mobilelabcontrol;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import java.util.HashMap;
+import java.util.Map;
+
+final class WidgetState {
+ private static final String PREFS = "mobile_lab_state";
+ private static final String[] SCOPES = {
+ ControlContract.SCOPE_ALL,
+ ControlContract.SCOPE_HAPPY,
+ ControlContract.SCOPE_LLAMA,
+ ControlContract.SCOPE_MODEL
+ };
+
+ static SharedPreferences prefs(Context context) {
+ return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ }
+
+ static void setBusy(Context context, String scope, String message) {
+ prefs(context).edit()
+ .putString("busy_" + scope, clean(message))
+ .remove("error_" + scope)
+ .apply();
+ }
+
+ static void setError(Context context, String scope, String message) {
+ prefs(context).edit()
+ .remove("busy_" + scope)
+ .putString("error_" + scope, clean(message))
+ .apply();
+ }
+
+ static String busy(Context context, String scope) {
+ String scoped = get(context, "busy_" + scope, "");
+ return scoped.isEmpty() ? get(context, "busy_all", "") : scoped;
+ }
+
+ static String error(Context context, String scope) {
+ String scoped = get(context, "error_" + scope, "");
+ return scoped.isEmpty() ? get(context, "error_all", "") : scoped;
+ }
+
+ static void applyStatus(Context context, String stdout, String operation) {
+ Map parsed = parse(stdout);
+ SharedPreferences.Editor editor = prefs(context).edit();
+ String scope = ControlContract.scopeForOperation(operation);
+ if (ControlContract.SCOPE_ALL.equals(scope)) {
+ for (String item : SCOPES) {
+ editor.remove("busy_" + item).remove("error_" + item);
+ }
+ } else {
+ editor.remove("busy_" + scope).remove("error_" + scope);
+ }
+ for (Map.Entry entry : parsed.entrySet()) {
+ editor.putString(entry.getKey(), entry.getValue());
+ }
+ editor.apply();
+ }
+
+ private static Map parse(String stdout) {
+ Map result = new HashMap<>();
+ if (stdout == null) return result;
+ for (String line : stdout.split("\\r?\\n")) {
+ int separator = line.indexOf('=');
+ if (separator <= 0) continue;
+ String key = line.substring(0, separator).trim();
+ String value = clean(line.substring(separator + 1));
+ if (isAllowedStatusKey(key) && !value.isEmpty()) result.put(key, value);
+ }
+ return result;
+ }
+
+ private static boolean isAllowedStatusKey(String key) {
+ if ("happy_state".equals(key) || "llama_state".equals(key)
+ || "model_alias".equals(key) || "model_name".equals(key)) return true;
+ return key.matches("(provider|readiness)_(local|slot0[1-9])");
+ }
+
+ static String get(Context context, String key, String fallback) {
+ return prefs(context).getString(key, fallback);
+ }
+
+ private static String clean(String value) {
+ if (value == null) return "";
+ String cleaned = value.replace('\n', ' ').replace('\r', ' ').trim();
+ return cleaned.length() > 120 ? cleaned.substring(0, 120) : cleaned;
+ }
+
+ private WidgetState() {}
+}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/WidgetUpdater.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/WidgetUpdater.java
new file mode 100644
index 0000000..5d0825f
--- /dev/null
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/WidgetUpdater.java
@@ -0,0 +1,151 @@
+package io.github.toolazytoname.mobilelabcontrol;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.widget.RemoteViews;
+
+final class WidgetUpdater {
+ private static final String[] MODEL_TARGETS = {
+ "local", "slot-01", "slot-02", "slot-03", "slot-04",
+ "slot-05", "slot-06", "slot-07", "slot-08", "slot-09"
+ };
+ private static final int[] MODEL_IDS = {
+ R.id.model_local, R.id.model_slot01, R.id.model_slot02, R.id.model_slot03, R.id.model_slot04,
+ R.id.model_slot05, R.id.model_slot06, R.id.model_slot07, R.id.model_slot08, R.id.model_slot09
+ };
+ private static final int[] MODEL_DEFAULT_NAMES = {
+ R.string.local_profile_default, R.string.profile_slot_a, R.string.profile_slot_b,
+ R.string.profile_slot_c, R.string.profile_slot_d, R.string.profile_slot_e,
+ R.string.profile_slot_f, R.string.profile_slot_g, R.string.profile_slot_h,
+ R.string.profile_slot_i
+ };
+
+ static void updateAll(Context context) {
+ updateHappy(context);
+ updateLlama(context);
+ updateModel(context);
+ }
+
+ static void updateHappy(Context context) {
+ AppWidgetManager manager = AppWidgetManager.getInstance(context);
+ int[] ids = manager.getAppWidgetIds(new ComponentName(context, HappyWidgetProvider.class));
+ RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_happy);
+ String busy = WidgetState.busy(context, ControlContract.SCOPE_HAPPY);
+ String error = WidgetState.error(context, ControlContract.SCOPE_HAPPY);
+ String state = WidgetState.get(context, "happy_state", "UNKNOWN");
+ String status = !busy.isEmpty() ? busy : (!error.isEmpty()
+ ? context.getString(R.string.error_format, error) : happyText(context, state));
+ views.setTextViewText(R.id.happy_status, status);
+ views.setTextViewText(R.id.happy_toggle, happyAction(context, state));
+ views.setOnClickPendingIntent(R.id.happy_toggle, broadcast(context, HappyWidgetProvider.class, ControlContract.ACTION_HAPPY_TOGGLE, 101));
+ views.setOnClickPendingIntent(R.id.happy_restart, broadcast(context, HappyWidgetProvider.class, ControlContract.ACTION_HAPPY_RESTART, 102));
+ views.setOnClickPendingIntent(R.id.happy_refresh, broadcast(context, HappyWidgetProvider.class, ControlContract.ACTION_REFRESH, 103));
+ manager.updateAppWidget(ids, views);
+ }
+
+ static void updateLlama(Context context) {
+ AppWidgetManager manager = AppWidgetManager.getInstance(context);
+ int[] ids = manager.getAppWidgetIds(new ComponentName(context, LlamaWidgetProvider.class));
+ RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_llama);
+ String busy = WidgetState.busy(context, ControlContract.SCOPE_LLAMA);
+ String error = WidgetState.error(context, ControlContract.SCOPE_LLAMA);
+ String state = WidgetState.get(context, "llama_state", "UNKNOWN");
+ String status = !busy.isEmpty() ? busy : (!error.isEmpty()
+ ? context.getString(R.string.error_format, error) : llamaText(context, state));
+ views.setTextViewText(R.id.llama_status, status);
+ views.setTextViewText(R.id.llama_toggle, context.getString("ON".equals(state)
+ ? R.string.llama_action_stop : R.string.llama_action_start));
+ views.setOnClickPendingIntent(R.id.llama_toggle, broadcast(context, LlamaWidgetProvider.class, ControlContract.ACTION_LLAMA_TOGGLE, 201));
+ views.setOnClickPendingIntent(R.id.llama_refresh, broadcast(context, LlamaWidgetProvider.class, ControlContract.ACTION_REFRESH, 202));
+ manager.updateAppWidget(ids, views);
+ }
+
+ static void updateModel(Context context) {
+ AppWidgetManager manager = AppWidgetManager.getInstance(context);
+ int[] ids = manager.getAppWidgetIds(new ComponentName(context, ModelWidgetProvider.class));
+ RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_model);
+ String busy = WidgetState.busy(context, ControlContract.SCOPE_MODEL);
+ String error = WidgetState.error(context, ControlContract.SCOPE_MODEL);
+ String target = WidgetState.get(context, "model_alias", "none");
+ String name = WidgetState.get(context, "model_name", context.getString(R.string.waiting_for_refresh));
+ String current = !busy.isEmpty() ? busy : (!error.isEmpty()
+ ? context.getString(R.string.error_format, error) : context.getString(R.string.current_format, name));
+ views.setTextViewText(R.id.model_current, current);
+ views.setOnClickPendingIntent(R.id.model_refresh, broadcast(context, ModelWidgetProvider.class, ControlContract.ACTION_REFRESH, 301));
+
+ for (int i = 0; i < MODEL_TARGETS.length; i++) {
+ String readiness = WidgetState.get(context, readinessKey(MODEL_TARGETS[i]), "unavailable");
+ String providerName = providerName(context, MODEL_TARGETS[i], MODEL_DEFAULT_NAMES[i], readiness);
+ String label = MODEL_TARGETS[i].equals(target) ? "✓ " + providerName : providerName;
+ views.setTextViewText(MODEL_IDS[i], label);
+ views.setBoolean(MODEL_IDS[i], "setEnabled", !"unavailable".equals(readiness));
+ Intent action = new Intent(context, ModelWidgetProvider.class);
+ action.setAction(ControlContract.ACTION_MODEL);
+ action.putExtra(ControlContract.EXTRA_MODEL, MODEL_TARGETS[i]);
+ views.setOnClickPendingIntent(MODEL_IDS[i], PendingIntent.getBroadcast(
+ context, 400 + i, action, immutableUpdateFlags()));
+ }
+ manager.updateAppWidget(ids, views);
+ }
+
+ private static String stateSuffix(String target) {
+ return target.replace("-", "");
+ }
+
+ private static String readinessKey(String target) {
+ return "readiness_" + stateSuffix(target);
+ }
+
+ private static String providerName(Context context, String target, int fallbackId, String readiness) {
+ String fallback = context.getString(fallbackId);
+ String name = WidgetState.get(context, "provider_" + stateSuffix(target), fallback);
+ if ("login-required".equals(readiness)) return context.getString(R.string.provider_login_required, name);
+ if ("unavailable".equals(readiness)) return context.getString(R.string.provider_unavailable, name);
+ return name;
+ }
+
+ private static PendingIntent broadcast(Context context, Class> receiver, String action, int requestCode) {
+ Intent intent = new Intent(context, receiver);
+ intent.setAction(action);
+ return PendingIntent.getBroadcast(context, requestCode, intent, immutableUpdateFlags());
+ }
+
+ private static int immutableUpdateFlags() {
+ int flags = PendingIntent.FLAG_UPDATE_CURRENT;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) flags |= PendingIntent.FLAG_IMMUTABLE;
+ return flags;
+ }
+
+ private static String happyText(Context context, String state) {
+ switch (state) {
+ case "ON": return context.getString(R.string.happy_status_on);
+ case "STALE": return context.getString(R.string.happy_status_stale);
+ case "SERVER_ONLY": return context.getString(R.string.happy_status_server_only);
+ case "OFF": return context.getString(R.string.happy_status_off);
+ default: return context.getString(R.string.status_waiting);
+ }
+ }
+
+ private static String happyAction(Context context, String state) {
+ if ("ON".equals(state)) return context.getString(R.string.happy_action_stop);
+ if ("STALE".equals(state) || "SERVER_ONLY".equals(state)) {
+ return context.getString(R.string.happy_action_repair);
+ }
+ return context.getString(R.string.happy_action_start);
+ }
+
+ private static String llamaText(Context context, String state) {
+ switch (state) {
+ case "ON": return context.getString(R.string.llama_status_on);
+ case "ERROR": return context.getString(R.string.llama_status_error);
+ case "OFF": return context.getString(R.string.llama_status_off);
+ default: return context.getString(R.string.status_waiting);
+ }
+ }
+
+ private WidgetUpdater() {}
+}
diff --git a/config/widget-profiles.example b/config/widget-profiles.example
new file mode 100644
index 0000000..532c7f2
--- /dev/null
+++ b/config/widget-profiles.example
@@ -0,0 +1,7 @@
+# slot|profile|label|readiness
+# Copy to ~/.config/android-ai-stack/widget-profiles and chmod 600.
+# The profile column must match a safe name returned by `ai-model list`.
+local|local-openai|Local Qwen|token-ready
+slot-01|cloud-direct|Cloud Direct|token-ready
+slot-02|cloud-relay|Private Relay|token-ready
+# readiness: token-ready, login-required, or unavailable
diff --git a/docs/architecture.md b/docs/architecture.md
index f965c30..caaa41f 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -8,6 +8,7 @@
| OpenCode | phone/Kali | TUI, web UI, OpenAI-compatible clients |
| Claude Code | phone/Kali | CLI agent and Anthropic-compatible clients |
| Happy server + daemon | phone | mobile session control and Claude bridge |
+| Mobile Lab Control | phone/Android | independent Happy, llama.cpp, and profile controls |
| Provider profile | phone | route/model selection without embedding secrets |
| Model inference | external by default | cloud API, private relay, or optional loopback model |
@@ -41,9 +42,10 @@ Local services are explicit:
- `ai-model use PROFILE` changes configuration only. It never starts, stops, or
restarts either service.
-Termux:Widget exposes the same split as three status-bearing controls: Happy,
-llama.cpp, and Model. This prevents a route selection from becoming a hidden
-service-lifecycle action.
+The native Mobile Lab Control app exposes the same split as three independent
+AppWidgets. It has no Internet permission and reaches Termux only through a
+fixed allow-list bridge. Termux:Widget is an optional terminal fallback. This
+prevents route selection from becoming a hidden service-lifecycle action.
Nothing is configured to start at Android boot by default. This limits battery,
thermal, and exposure costs.
diff --git a/docs/oneplus8t-case-study.md b/docs/oneplus8t-case-study.md
index d813570..0a5f72e 100644
--- a/docs/oneplus8t-case-study.md
+++ b/docs/oneplus8t-case-study.md
@@ -57,6 +57,9 @@ benchmarks.
| Switch profile while llama.cpp was running | PASS; the same process and port 8080 remained | 0 |
| Stop llama.cpp, then select local/cloud profiles | PASS; no service was started implicitly | 0 |
| Switch Happy profile while running | PASS; status changed to `STALE`, with the original daemon unchanged | 0 |
+| Native Happy widget lifecycle | PASS; `OFF → ON → OFF`, port 3005 opened and closed | 0 |
+| Native llama.cpp widget lifecycle | PASS; `OFF → ON → OFF`, port 8080 opened and closed | 0 |
+| Native profile widget | PASS; switched profile and restored it without starting either service | 0 |
| Final service state | PASS; Happy and llama.cpp both stopped, active profile restored | 0 |
The default OpenCode build prompt was roughly 7.4k tokens in this installation.
@@ -79,8 +82,11 @@ use matters for battery, heat, and foreground-app headroom.
Because Android may deny netlink inspection to regular apps, the on-phone
wrapper proves the bind behavior with positive `127.0.0.1` and negative
`127.0.0.2` TCP probes; ADB inspection separately confirmed the exact socket.
-- Model selection and service lifecycle are independent. Three status-bearing
- Termux:Widget entries expose Happy, llama.cpp, and Model separately.
+- Model selection and service lifecycle are independent. Mobile Lab Control
+ 0.3.0 exposed three native widgets and passed the lifecycle checks above.
+ The public 0.4.0 source generalizes private Provider aliases into a mode-600
+ slot map, localizes the UI, and isolates each widget's pending/error state;
+ its APK build and bridge behavior are covered by repository tests.
- Killing only the tmux session or only the PRoot parent can orphan
`llama-server`. The wrapper validates and terminates the managed process
group, then proves the loopback port is closed.
diff --git a/docs/oneplus8t-case-study.zh-CN.md b/docs/oneplus8t-case-study.zh-CN.md
index 3df2c50..ac31c9c 100644
--- a/docs/oneplus8t-case-study.zh-CN.md
+++ b/docs/oneplus8t-case-study.zh-CN.md
@@ -53,6 +53,9 @@ SHA-256。本仓不重新分发这些文件。
| llama.cpp 运行时切换 profile | PASS;原进程与 8080 listener 保持不变 | 0 |
| 停止 llama.cpp 后切换 local/cloud profile | PASS;没有服务被隐式启动 | 0 |
| Happy 运行时切换 profile | PASS;状态变为 `STALE`,原 daemon 不变 | 0 |
+| 原生 Happy 微件生命周期 | PASS;`OFF → ON → OFF`,3005 端口随之开启和关闭 | 0 |
+| 原生 llama.cpp 微件生命周期 | PASS;`OFF → ON → OFF`,8080 端口随之开启和关闭 | 0 |
+| 原生模型配置微件 | PASS;切换并恢复 Profile,两个服务均未被启动 | 0 |
| 最终服务状态 | PASS;Happy 与 llama.cpp 均停止,恢复日常 profile | 0 |
这套 OpenCode 的默认 build prompt 约 7.4k tokens,所以 context 2048 不可能
@@ -72,8 +75,10 @@ SHA-256。本仓不重新分发这些文件。
`0.0.0.0`;实测手机源码已应用本仓最小 bind patch。Android 可能禁止普通
App 使用 netlink,因此手机 wrapper 用 `127.0.0.1` 正向探测与
`127.0.0.2` 反向探测验证绑定行为,并由 ADB 另行确认精确 socket。
-- 模型选择与服务生命周期完全独立;Termux:Widget 用 Happy、llama.cpp、Model
- 三个带状态入口分别控制。
+- 模型选择与服务生命周期完全独立。Mobile Lab Control 0.3.0 的三个原生微件
+ 已完成上述真机生命周期验收。公开 0.4.0 源码进一步将私人 Provider alias
+ 泛化为 mode-600 槽位映射,增加中英文 UI,并隔离每个微件的处理中/错误状态;
+ APK 构建和桥接行为由仓库测试覆盖。
- 只杀 tmux session 或只杀 PRoot 父进程都可能遗留 `llama-server`。wrapper
会校验并终止受管进程组,再确认 loopback 端口关闭。
- `/v1/models` 可能允许匿名发现;验收必须请求真正的生成端点,并确认无 key
diff --git a/docs/phone-quickstart.en.md b/docs/phone-quickstart.en.md
index 2949a01..0b91a89 100644
--- a/docs/phone-quickstart.en.md
+++ b/docs/phone-quickstart.en.md
@@ -36,17 +36,19 @@ Profile selection changes configuration only. It does not start or stop
llama.cpp and does not restart Happy. A running Happy daemon shows `STALE` after
a profile change until you explicitly restart it.
-## Three independent widgets
+## Three independent native widgets
-With Termux:Widget installed, the deployment creates exactly three entries:
+Install the release APK and fixed Termux bridge as described in
+[`control-widgets.md`](../skills/deploy-android-ai-stack/references/control-widgets.md).
+Mobile Lab Control then provides three separate home-screen widgets:
-- `10-Happy-ON/OFF/STALE` — Happy lifecycle and logs;
-- `20-llama-ON/OFF/ERROR` — local model lifecycle and logs;
-- `30-Model-PROFILE` — active profile and the profile selector.
+- Happy status, start/stop, restart, and refresh;
+- llama.cpp status, start/stop, memory release, and refresh;
+- readable model-profile status plus direct configuration buttons.
-Legacy one-action GLM, Claude, GPT, local-off, and CC Switch shortcuts are
-removed during migration. Run `ai-widget-refresh` if the labels need a manual
-refresh.
+The profile widget never starts or stops a service. The app has no Internet
+permission and the bridge returns no token or endpoint. Termux:Widget is an
+opt-in terminal fallback, not the default UI.
Use a random token for the local profile as well. The same mode-600 file is
consumed by OpenCode and llama-server, while the server limits CORS to
diff --git a/docs/phone-quickstart.zh-CN.md b/docs/phone-quickstart.zh-CN.md
index 75e1ca1..f00dc20 100644
--- a/docs/phone-quickstart.zh-CN.md
+++ b/docs/phone-quickstart.zh-CN.md
@@ -51,16 +51,17 @@ llama.cpp,也不会重启 Happy。Happy 已运行时切换 profile 会显示 `
本地 token 也必须使用随机值:它同时交给 OpenCode 与 llama-server;server
只允许 localhost CORS,不要使用公开示例字符串。
-## 4. 三个独立 Widget
+## 4. 三个独立原生微件
-安装 Termux:Widget 后,部署只创建三个入口:
+按照[原生微件说明](../skills/deploy-android-ai-stack/references/control-widgets.zh-CN.md)
+安装 Release APK 与固定 Termux 桥接。Mobile Lab Control 随后提供:
-- `10-Happy-ON/OFF/STALE`:Happy 生命周期和日志;
-- `20-llama-ON/OFF/ERROR`:本地模型服务生命周期和日志;
-- `30-Model-PROFILE`:当前 profile 与统一模型选择器。
+- Happy 状态、启动/关闭、重启与刷新;
+- llama.cpp 状态、启动/关闭、释放模型内存与刷新;
+- 当前模型可读名称和直接配置按钮。
-迁移时会删除旧的 GLM、Claude、GPT、local-off 与 CC Switch 单动作开关。
-名称未及时刷新时运行 `ai-widget-refresh`。
+模型微件只切配置,不启停任何服务。App 没有联网权限,桥接也不会回传 Token
+或 Endpoint。Termux:Widget 只作为可选终端回退,不再是默认界面。
## 5. 有调用预算的验收
diff --git a/manifests/oneplus8t-lineageos21.yaml b/manifests/oneplus8t-lineageos21.yaml
index a6076fc..f767de3 100644
--- a/manifests/oneplus8t-lineageos21.yaml
+++ b/manifests/oneplus8t-lineageos21.yaml
@@ -17,6 +17,13 @@ topology:
claude_code: local
happy_server: loopback-local
happy_daemon: local
+ native_controls:
+ package: io.github.toolazytoname.mobilelabcontrol
+ case_version: 0.3.0
+ widgets: [happy, llama-cpp, model-profile]
+ internet_permission: false
+ run_command_bridge: fixed-allow-list
+ model_calls: 0
happy_server_bind_patch: applied-and-socket-verified
model_inference: external-provider-by-default
optional:
diff --git a/scripts/verify.sh b/scripts/verify.sh
index daa43a4..86f9d78 100755
--- a/scripts/verify.sh
+++ b/scripts/verify.sh
@@ -28,6 +28,19 @@ done
python3 -m json.tool config/opencode.example.json >/dev/null
tests/profile-switch.sh
tests/widget-refresh.sh
+tests/control-bridge.sh
+
+if grep -q 'android.permission.INTERNET' apps/mobile-lab-control/AndroidManifest.xml; then
+ echo "error: Mobile Lab Control must not request Internet permission" >&2
+ fail=1
+fi
+
+if grep -RInE '(cc-[0-9][0-9]|Agnes AI|Kimi 官方|中转站)' \
+ apps/mobile-lab-control/src apps/mobile-lab-control/res/layout \
+ apps/mobile-lab-control/res/values/strings.xml; then
+ echo "error: device-specific provider labels leaked into the public app" >&2
+ fail=1
+fi
if grep -RHE '^OPENCODE_MODEL=' config/profiles/*.example | \
grep -vE 'OPENCODE_MODEL=profile/[A-Za-z0-9._-]+$'; then
diff --git a/skills/deploy-android-ai-stack/SKILL.md b/skills/deploy-android-ai-stack/SKILL.md
index f2b7094..4e80cca 100644
--- a/skills/deploy-android-ai-stack/SKILL.md
+++ b/skills/deploy-android-ai-stack/SKILL.md
@@ -1,6 +1,6 @@
---
name: deploy-android-ai-stack
-description: Deploy or update a local-first Android AI runtime made of Termux, Kali PRoot, OpenCode, Claude Code, and a phone-local Happy server/daemon. Use when installing the stack, configuring secret-safe provider profiles, switching models, exposing OpenCode Web only on loopback, or adding an optional small llama.cpp fallback. Do not use this skill to publish a remote Happy relay; use happy-relay-deploy for that.
+description: Deploy or update a local-first Android AI runtime made of Termux, Kali PRoot, OpenCode, Claude Code, a phone-local Happy server/daemon, and the Mobile Lab Control Android widgets. Use when installing the stack, configuring secret-safe provider profiles, switching models, installing the native Happy/llama.cpp/profile controls, exposing OpenCode Web only on loopback, or adding an optional small llama.cpp fallback. Do not use this skill to publish a remote Happy relay; use happy-relay-deploy for that.
---
# Deploy Android AI Stack
@@ -15,6 +15,8 @@ failed command into an unbounded retry loop.
- Never read or publish CC Switch databases. Ask the user to create a redacted
profile from a template instead.
- Never print tokens. Require token files with mode `0600`.
+- Treat Termux `allow-external-apps` as an explicit trust boundary. Enable it
+ only with the fixed native-widget bridge installed mode `0700`.
- Never download a GGUF automatically.
- Pin a release and verify a checksum before installing a third-party binary.
- Stop after the first repeated failure. Capture at most the final 60 log lines
@@ -42,8 +44,8 @@ skill directory to the phone and run:
scripts/install-runtime.sh
```
-The script installs only the operator wrappers, profile loader, and three
-status-bearing Termux:Widget controls. It does not replace an existing
+The script installs only the operator wrappers, profile loader, and native
+widget allow-list bridge. It does not replace an existing
OpenCode, Claude, Happy, or Happy server installation.
Install those upstream components separately with pinned versions, then rerun
preflight.
@@ -74,14 +76,28 @@ configuration-only: it never starts, stops, or restarts Happy or llama.cpp.
If Happy is already running, `stack status` reports `STALE` until the operator
explicitly restarts it with the selected profile.
-The widget UI mirrors this separation:
+The native widget UI mirrors this separation:
-- `10-Happy-*` manages the Happy service and logs;
-- `20-llama-*` manages local inference and logs;
-- `30-Model-*` selects a profile without touching either service.
+- Happy manages only the Happy service;
+- llama.cpp manages only local inference and releases model memory when stopped;
+- model profiles select configuration without touching either service.
-Legacy one-action model shortcuts are removed during migration. Run
-`ai-widget-refresh` to refresh the labels manually.
+Read [references/control-widgets.md](references/control-widgets.md), copy the
+redacted slot template to mode `0600`, then install the public release APK.
+After reviewing the boundary, run:
+
+```bash
+install-control-bridge --enable-external-apps
+```
+
+Grant the app only `com.termux.permission.RUN_COMMAND`. The app has no network
+permission and can invoke only the fixed bridge; the bridge independently
+rejects free-form operations and unmapped profile slots. Do not silently
+uninstall a debug-signed build: Android signature migration removes the old
+app widgets and requires explicit operator confirmation.
+
+Termux:Widget remains an opt-in terminal fallback. Set
+`INSTALL_TERMUX_WIDGET_FALLBACK=1` only when the native app is unsuitable.
### 4. Start the local control plane
@@ -159,4 +175,6 @@ Deployment is complete only when:
- Happy server and daemon are both local and healthy;
- metrics and unintended public listeners are absent;
- the active profile is explicit;
+- the three native controls report independent, readable state and the bridge
+ emits no endpoint or credential;
- validation records distinguish zero-call checks from paid model calls.
diff --git a/skills/deploy-android-ai-stack/agents/openai.yaml b/skills/deploy-android-ai-stack/agents/openai.yaml
index 3811cb7..c2530fe 100644
--- a/skills/deploy-android-ai-stack/agents/openai.yaml
+++ b/skills/deploy-android-ai-stack/agents/openai.yaml
@@ -1,4 +1,4 @@
interface:
display_name: "Deploy Android AI Stack"
- short_description: "Install a self-contained Android AI runtime"
- default_prompt: "Use $deploy-android-ai-stack to deploy OpenCode, Claude Code, and Happy on an Android Termux device."
+ short_description: "Deploy Android AI runtime and control widgets"
+ default_prompt: "Use $deploy-android-ai-stack to install the local-first runtime and its three native Android control widgets."
diff --git a/skills/deploy-android-ai-stack/references/control-widgets.md b/skills/deploy-android-ai-stack/references/control-widgets.md
new file mode 100644
index 0000000..bc9e77f
--- /dev/null
+++ b/skills/deploy-android-ai-stack/references/control-widgets.md
@@ -0,0 +1,49 @@
+# Native control widgets
+
+[English](control-widgets.md) · [简体中文](control-widgets.zh-CN.md)
+
+## Boundary
+
+Mobile Lab Control is an Android app with three independent AppWidget
+providers: Happy, llama.cpp, and model profiles. It requests no Internet
+permission and holds no root privilege. Its only privileged edge is Termux's
+`RUN_COMMAND` permission targeting the exact executable
+`$PREFIX/bin/mobile-lab-widget-action`.
+
+Termux must set `allow-external-apps=true` for this edge to work. The installer
+therefore requires an explicit `--enable-external-apps` flag, creates a backup
+of the prior properties file, and installs the bridge mode `0700`. The bridge
+accepts only status, Happy toggle/restart, llama toggle, and mapped profile
+selection. It returns names and readiness, never tokens or endpoints.
+
+## Install
+
+1. Run `scripts/install-runtime.sh` in the real Termux app context.
+2. Copy `config/widget-profiles.example` to
+ `~/.config/android-ai-stack/widget-profiles`, edit only safe profile names
+ and labels, and set mode `0600`.
+3. Review the boundary, then run
+ `install-control-bridge --enable-external-apps`.
+4. Download the APK and its SHA-256 file from the repository's GitHub Release,
+ verify the checksum, and install with `adb install -r`.
+5. Open the app, grant the Termux command permission, refresh once, and add the
+ three widgets separately.
+
+The mapping format is `slot|profile|label|readiness`. Supported slots are
+`local` and `slot-01` through `slot-09`; readiness is `token-ready`,
+`login-required`, or `unavailable`. Profile names must already pass
+`ai-model list`. Labels are display-only and capped by the bridge.
+
+If no map exists, the bridge exposes safe profiles in sorted order. An explicit
+mode-600 map is preferred because it makes ordering and friendly labels stable.
+
+## Validate without a model call
+
+Run `mobile-lab-widget-action status`, then inspect the package permissions and
+exercise the three widgets. Starting and stopping services is a zero-model-call
+operation. Profile switching is also configuration-only. Record the listener,
+process, and active-profile delta instead of repeatedly taking screenshots.
+
+An existing debug-signed installation cannot be upgraded to the public release
+signature. Ask before uninstalling it because that removes widget placement and
+app-local state. Once migrated, future release-signed APKs upgrade in place.
diff --git a/skills/deploy-android-ai-stack/references/control-widgets.zh-CN.md b/skills/deploy-android-ai-stack/references/control-widgets.zh-CN.md
new file mode 100644
index 0000000..24170cd
--- /dev/null
+++ b/skills/deploy-android-ai-stack/references/control-widgets.zh-CN.md
@@ -0,0 +1,41 @@
+# 原生控制微件
+
+[English](control-widgets.md) · [简体中文](control-widgets.zh-CN.md)
+
+## 安全边界
+
+Mobile Lab Control 包含 Happy、llama.cpp、模型配置三个独立 AppWidget。App
+不申请联网权限、不持有 root;唯一的特权边是 Termux 的 `RUN_COMMAND` 权限,
+且目标固定为 `$PREFIX/bin/mobile-lab-widget-action`。
+
+Termux 需要设置 `allow-external-apps=true`。安装器因此要求显式传入
+`--enable-external-apps`,先备份原 properties,再以 mode `0700` 安装桥接。
+桥接只接受状态、Happy 启停/重启、llama 启停和已映射的 Profile 切换;回传
+内容只有名称、状态和就绪类型,不包含 Token 或 Endpoint。
+
+## 安装
+
+1. 在真实 Termux App 环境运行 `scripts/install-runtime.sh`;
+2. 将 `config/widget-profiles.example` 复制为
+ `~/.config/android-ai-stack/widget-profiles`,只填写安全 Profile 名和展示名,
+ 并设置 mode `0600`;
+3. 理解边界后执行 `install-control-bridge --enable-external-apps`;
+4. 从 GitHub Release 下载 APK 与 SHA-256 文件,核对后用 `adb install -r` 安装;
+5. 打开 App,授予 Termux 命令权限,刷新一次,再分别添加三个微件。
+
+映射格式是 `slot|profile|label|readiness`。槽位支持 `local` 与
+`slot-01` 至 `slot-09`;就绪类型支持 `token-ready`、`login-required`、
+`unavailable`。Profile 必须已通过 `ai-model list`,展示名只用于 UI,桥接会
+限制长度并清理控制字符。
+
+没有映射文件时,桥接会按安全 Profile 名排序显示;仍建议提供 mode-600 的
+显式映射,以固定顺序和可读名称。
+
+## 零模型调用验收
+
+执行 `mobile-lab-widget-action status`,核对 APK 权限,再分别操作三个微件。
+服务启停和 Profile 切换都不产生模型调用。记录进程、监听端口与活动 Profile
+的变化即可;除非专门验证渲染,不需要反复截图。
+
+debug 签名版本不能直接覆盖升级到公开 release 签名。卸载会移除微件位置与
+App 本地状态,必须先征得确认;完成一次迁移后,后续正式版本可以原位升级。
diff --git a/skills/deploy-android-ai-stack/scripts/install-control-bridge.sh b/skills/deploy-android-ai-stack/scripts/install-control-bridge.sh
new file mode 100755
index 0000000..5e161e4
--- /dev/null
+++ b/skills/deploy-android-ai-stack/scripts/install-control-bridge.sh
@@ -0,0 +1,39 @@
+#!/data/data/com.termux/files/usr/bin/bash
+set -euo pipefail
+
+: "${PREFIX:=/data/data/com.termux/files/usr}"
+: "${HOME:=/data/data/com.termux/files/home}"
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+properties_dir="$HOME/.termux"
+properties="$properties_dir/termux.properties"
+enable_external=0
+
+case "${1:-}" in
+ '') ;;
+ --enable-external-apps) enable_external=1 ;;
+ *) printf 'usage: install-control-bridge.sh [--enable-external-apps]\n' >&2; exit 2 ;;
+esac
+
+install -d -m 755 "$PREFIX/bin"
+install -d -m 700 "$properties_dir" "$HOME/.config/android-ai-stack"
+install -m 700 "$script_dir/mobile-lab-widget-action" "$PREFIX/bin/mobile-lab-widget-action"
+
+if [ "$enable_external" = 1 ]; then
+ if [ -f "$properties" ] && [ ! -e "$properties.pre-mobile-lab-control" ]; then
+ install -m 600 "$properties" "$properties.pre-mobile-lab-control"
+ fi
+ pending=$(mktemp "$properties.pending.XXXXXX")
+ if [ -f "$properties" ]; then
+ awk '!/^[[:space:]]*allow-external-apps[[:space:]]*=/' "$properties" > "$pending"
+ fi
+ printf 'allow-external-apps=true\n' >> "$pending"
+ chmod 600 "$pending"
+ mv "$pending" "$properties"
+ command -v termux-reload-settings >/dev/null 2>&1 && termux-reload-settings || true
+ printf 'installed bridge and enabled explicit external-app commands\n'
+else
+ printf 'installed bridge only\n'
+ if ! grep -Eq '^[[:space:]]*allow-external-apps[[:space:]]*=[[:space:]]*true[[:space:]]*$' "$properties" 2>/dev/null; then
+ printf 'next: review the boundary, then rerun with --enable-external-apps\n'
+ fi
+fi
diff --git a/skills/deploy-android-ai-stack/scripts/install-runtime.sh b/skills/deploy-android-ai-stack/scripts/install-runtime.sh
index 8740d88..8c79792 100755
--- a/skills/deploy-android-ai-stack/scripts/install-runtime.sh
+++ b/skills/deploy-android-ai-stack/scripts/install-runtime.sh
@@ -26,6 +26,8 @@ install -m 700 "$script_dir/stack" "$PREFIX/bin/stack"
install -m 700 "$script_dir/llama-local" "$PREFIX/bin/llama-local"
install -m 700 "$script_dir/oc" "$PREFIX/bin/oc"
install -m 700 "$script_dir/ai-widget-refresh" "$PREFIX/bin/ai-widget-refresh"
+install -m 700 "$script_dir/mobile-lab-widget-action" "$PREFIX/bin/mobile-lab-widget-action"
+install -m 700 "$script_dir/install-control-bridge.sh" "$PREFIX/bin/install-control-bridge"
install -d -m 700 "$PREFIX/libexec/android-ai-stack-widget"
install -m 700 "$script_dir/widget/control-happy" \
"$PREFIX/libexec/android-ai-stack-widget/control-happy"
@@ -52,8 +54,18 @@ for legacy in \
[ ! -e "$legacy" ] || unlink "$legacy"
done
-"$PREFIX/bin/ai-widget-refresh"
+for managed in \
+ "$HOME/.shortcuts"/10-Happy-* \
+ "$HOME/.shortcuts"/20-llama-* \
+ "$HOME/.shortcuts"/30-Model-*; do
+ [ ! -e "$managed" ] || unlink "$managed"
+done
+
+if [ "${INSTALL_TERMUX_WIDGET_FALLBACK:-0}" = 1 ]; then
+ "$PREFIX/bin/ai-widget-refresh"
+fi
printf 'installed operator scripts; no provider, model, or upstream binary was downloaded\n'
printf 'next: create a mode-600 profile and token files, then run ai-model use PROFILE\n'
-printf 'widget: Happy, llama.cpp, and Model are separate status-bearing controls\n'
+printf 'native control: install Mobile Lab Control, then run install-control-bridge --enable-external-apps\n'
+printf 'fallback: set INSTALL_TERMUX_WIDGET_FALLBACK=1 only when using Termux:Widget\n'
diff --git a/skills/deploy-android-ai-stack/scripts/mobile-lab-widget-action b/skills/deploy-android-ai-stack/scripts/mobile-lab-widget-action
new file mode 100755
index 0000000..f64e835
--- /dev/null
+++ b/skills/deploy-android-ai-stack/scripts/mobile-lab-widget-action
@@ -0,0 +1,208 @@
+#!/data/data/com.termux/files/usr/bin/bash
+# Fixed allow-list bridge used by the native Mobile Lab Control widgets.
+set -euo pipefail
+
+: "${PREFIX:=/data/data/com.termux/files/usr}"
+: "${HOME:=/data/data/com.termux/files/home}"
+PATH="$PREFIX/bin:/system/bin:/usr/bin:/bin"
+export PREFIX HOME PATH
+
+config_dir="$HOME/.config/android-ai-stack"
+map_file="$config_dir/widget-profiles"
+active_file="$config_dir/active"
+targets=(local slot-01 slot-02 slot-03 slot-04 slot-05 slot-06 slot-07 slot-08 slot-09)
+slot_profiles=()
+slot_labels=()
+slot_readiness=()
+available_profiles=()
+
+mode_of() { stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1"; }
+valid_profile() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; }
+valid_target() { [[ "$1" = local || "$1" =~ ^slot-0[1-9]$ ]]; }
+target_index() {
+ case "$1" in
+ local) printf '0' ;;
+ slot-01) printf '1' ;; slot-02) printf '2' ;; slot-03) printf '3' ;;
+ slot-04) printf '4' ;; slot-05) printf '5' ;; slot-06) printf '6' ;;
+ slot-07) printf '7' ;; slot-08) printf '8' ;; slot-09) printf '9' ;;
+ *) return 1 ;;
+ esac
+}
+clean_label() {
+ local value=${1//$'\n'/_}
+ value=${value//$'\r'/_}
+ value=${value//|/_}
+ value=${value//=/_}
+ printf '%.64s' "$value"
+}
+profile_available() {
+ local wanted=$1 profile
+ for profile in "${available_profiles[@]}"; do
+ [ "$profile" = "$wanted" ] && return 0
+ done
+ return 1
+}
+
+load_available_profiles() {
+ local profile suffix
+ available_profiles=()
+ while IFS= read -r profile; do
+ [ -n "$profile" ] || continue
+ suffix=' (unsafe)'
+ [[ "$profile" == *"$suffix" ]] && continue
+ valid_profile "$profile" || continue
+ available_profiles+=("$profile")
+ done < <(ai-model list 2>/dev/null || true)
+}
+
+initialize_slots() {
+ local index target
+ slot_profiles=()
+ slot_labels=()
+ slot_readiness=()
+ for index in {0..9}; do
+ target=${targets[$index]}
+ slot_profiles[$index]=
+ slot_labels[$index]=$([ "$target" = local ] && printf 'Local model' || printf 'Profile %s' "${target#slot-}")
+ slot_readiness[$index]=unavailable
+ done
+}
+
+load_explicit_map() {
+ local target profile label readiness extra line_number=0 index
+ [ -f "$map_file" ] && [ ! -L "$map_file" ] && [ "$(mode_of "$map_file")" = 600 ] || {
+ printf 'error=widget profile map must be a regular mode-600 file\n' >&2
+ return 1
+ }
+ while IFS='|' read -r target profile label readiness extra; do
+ line_number=$((line_number + 1))
+ case "$target" in ''|'#'*) continue ;; esac
+ valid_target "$target" && valid_profile "$profile" && [ -n "$label" ] && [ -z "$extra" ] || {
+ printf 'error=invalid widget profile map line %s\n' "$line_number" >&2
+ return 1
+ }
+ case "$readiness" in token-ready|login-required|unavailable) ;; *)
+ printf 'error=invalid readiness on widget profile map line %s\n' "$line_number" >&2
+ return 1 ;;
+ esac
+ profile_available "$profile" || {
+ printf 'error=unknown profile on widget profile map line %s\n' "$line_number" >&2
+ return 1
+ }
+ index=$(target_index "$target")
+ slot_profiles[$index]=$profile
+ slot_labels[$index]=$(clean_label "$label")
+ slot_readiness[$index]=$readiness
+ done < "$map_file"
+}
+
+load_automatic_map() {
+ local profile next=1 target index
+ for profile in "${available_profiles[@]}"; do
+ if [ "$profile" = local ] || [ "$profile" = local-openai ]; then
+ slot_profiles[0]=$profile
+ slot_labels[0]=$(clean_label "$profile")
+ slot_readiness[0]=token-ready
+ fi
+ done
+ while IFS= read -r profile; do
+ [ -n "$profile" ] || continue
+ [ "$profile" != "${slot_profiles[0]}" ] || continue
+ [ "$next" -le 9 ] || break
+ printf -v target 'slot-%02d' "$next"
+ index=$(target_index "$target")
+ slot_profiles[$index]=$profile
+ slot_labels[$index]=$(clean_label "$profile")
+ slot_readiness[$index]=token-ready
+ next=$((next + 1))
+ done < <(printf '%s\n' "${available_profiles[@]}" | sort)
+}
+
+load_slots() {
+ initialize_slots
+ load_available_profiles
+ if [ -e "$map_file" ]; then load_explicit_map; else load_automatic_map; fi
+}
+
+happy_state() {
+ local output
+ output=$(stack status 2>/dev/null || true)
+ if printf '%s\n' "$output" | grep -q '^RUNNING | agent_profile='; then printf 'ON'
+ elif printf '%s\n' "$output" | grep -q '^STALE |'; then printf 'STALE'
+ elif printf '%s\n' "$output" | grep -q '^RUNNING | server='; then printf 'SERVER_ONLY'
+ else printf 'OFF'; fi
+}
+
+llama_state() {
+ local output
+ output=$(llama-local status 2>/dev/null || true)
+ if printf '%s\n' "$output" | grep -q '^RUNNING'; then printf 'ON'
+ elif printf '%s\n' "$output" | grep -Eq '^(UNMANAGED|BROKEN)'; then printf 'ERROR'
+ else printf 'OFF'; fi
+}
+
+target_for_profile() {
+ local wanted=$1 index
+ for index in {0..9}; do
+ [ "${slot_profiles[$index]}" = "$wanted" ] && { printf '%s' "${targets[$index]}"; return; }
+ done
+ printf 'none'
+}
+
+print_status() {
+ local active target selected label key index
+ active=$(head -n 1 "$active_file" 2>/dev/null || printf 'none')
+ valid_profile "$active" || active=none
+ selected=$(target_for_profile "$active")
+ label=$active
+ if [ "$selected" != none ]; then
+ index=$(target_index "$selected")
+ label=${slot_labels[$index]}
+ fi
+ printf 'happy_state=%s\n' "$(happy_state)"
+ printf 'llama_state=%s\n' "$(llama_state)"
+ printf 'model_alias=%s\n' "$selected"
+ printf 'model_name=%s\n' "$(clean_label "$label")"
+ for index in {0..9}; do
+ target=${targets[$index]}
+ key=${target//-/}
+ printf 'provider_%s=%s\n' "$key" "$(clean_label "${slot_labels[$index]}")"
+ printf 'readiness_%s=%s\n' "$key" "${slot_readiness[$index]}"
+ done
+}
+
+load_slots
+operation=${1:-status}
+case "$operation" in
+ status)
+ [ "$#" -le 1 ] || { printf 'error=unexpected argument\n' >&2; exit 2; }
+ ;;
+ happy-toggle)
+ [ "$#" -eq 1 ] || { printf 'error=unexpected argument\n' >&2; exit 2; }
+ case "$(happy_state)" in ON) stack down ;; STALE|SERVER_ONLY) stack restart ;; *) stack up ;; esac
+ ;;
+ happy-restart)
+ [ "$#" -eq 1 ] || { printf 'error=unexpected argument\n' >&2; exit 2; }
+ stack restart
+ ;;
+ llama-toggle)
+ [ "$#" -eq 1 ] || { printf 'error=unexpected argument\n' >&2; exit 2; }
+ case "$(llama_state)" in ON) llama-local stop ;; ERROR) llama-local stop || true; llama-local start ;; *) llama-local start ;; esac
+ ;;
+ model-use)
+ [ "$#" -eq 2 ] && valid_target "$2" || { printf 'error=invalid model target\n' >&2; exit 2; }
+ index=$(target_index "$2")
+ [ "${slot_readiness[$index]}" != unavailable ] && [ -n "${slot_profiles[$index]}" ] || {
+ printf 'error=model target is not configured\n' >&2
+ exit 2
+ }
+ ai-model use "${slot_profiles[$index]}"
+ ;;
+ *)
+ printf 'error=operation is outside the widget allow-list\n' >&2
+ exit 2
+ ;;
+esac
+
+load_slots
+print_status
diff --git a/skills/validate-android-ai-stack/SKILL.md b/skills/validate-android-ai-stack/SKILL.md
index ecac83f..98350e7 100644
--- a/skills/validate-android-ai-stack/SKILL.md
+++ b/skills/validate-android-ai-stack/SKILL.md
@@ -1,6 +1,6 @@
---
name: validate-android-ai-stack
-description: Validate an Android Termux/Kali AI stack with a bounded evidence and model-call budget. Use when checking OpenCode TUI/Web, Claude CLI, a phone-local Happy server/daemon, provider switching, listener safety, or a freshly deployed llama.cpp fallback. Use this skill after installation and when a previous delivery claimed success without end-to-end proof.
+description: Validate an Android Termux/Kali AI stack with a bounded evidence and model-call budget. Use when checking OpenCode TUI/Web, Claude CLI, a phone-local Happy server/daemon, the native Mobile Lab Control widgets, provider switching, listener safety, or a freshly deployed llama.cpp fallback. Use this skill after installation and when a previous delivery claimed success without end-to-end proof.
---
# Validate Android AI Stack
@@ -50,6 +50,21 @@ The Android PRoot file picker may return an empty list. The checker creates a
minimal Git project/session and prints its deep link, avoiding screenshot-driven
retries.
+### Stage 2b: native controls, zero model calls
+
+Run the bridge directly once and confirm it returns only state, slot labels,
+and readiness—not endpoints or credentials:
+
+```bash
+mobile-lab-widget-action status
+```
+
+Then use each home-screen widget once. Happy and llama.cpp must complete an
+`OFF → ON → OFF` lifecycle with their loopback ports opening and closing. A
+profile-button check must change only the active profile and leave both service
+processes unchanged. Prefer UI XML plus process/socket assertions; screenshots
+are unnecessary unless rendering itself is under test.
+
### Stage 3: bounded inference
These modes make exactly one short request each:
diff --git a/skills/validate-android-ai-stack/agents/openai.yaml b/skills/validate-android-ai-stack/agents/openai.yaml
index 4cc6c0d..cfef384 100644
--- a/skills/validate-android-ai-stack/agents/openai.yaml
+++ b/skills/validate-android-ai-stack/agents/openai.yaml
@@ -1,4 +1,4 @@
interface:
display_name: "Validate Android AI Stack"
- short_description: "Run bounded evidence-based Android AI checks"
- default_prompt: "Use $validate-android-ai-stack to verify the Android AI runtime with a bounded model-call budget."
+ short_description: "Verify Android AI runtime and native controls"
+ default_prompt: "Use $validate-android-ai-stack to verify the Android AI runtime and native controls with a bounded model-call budget."
diff --git a/tests/control-bridge.sh b/tests/control-bridge.sh
new file mode 100755
index 0000000..9cf27ff
--- /dev/null
+++ b/tests/control-bridge.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+test_root=$(mktemp -d)
+trap 'rm -rf "$test_root"' EXIT
+test_prefix="$test_root/prefix"
+test_home="$test_root/home"
+mkdir -p "$test_prefix/bin" "$test_home/.config/android-ai-stack" "$test_home/.termux"
+export TEST_ROOT="$test_root"
+
+cat > "$test_prefix/bin/stack" <<'EOF'
+#!/usr/bin/env bash
+case "${1:-status}" in
+ status) printf 'stopped | server=http://127.0.0.1:3005\nstopped | agent\n' ;;
+ up|down|restart) printf '%s\n' "$1" > "$TEST_ROOT/stack-action" ;;
+ *) exit 2 ;;
+esac
+EOF
+cat > "$test_prefix/bin/llama-local" <<'EOF'
+#!/usr/bin/env bash
+case "${1:-status}" in
+ status) printf 'stopped\n' ;;
+ start|stop) printf '%s\n' "$1" > "$TEST_ROOT/llama-action" ;;
+ *) exit 2 ;;
+esac
+EOF
+cat > "$test_prefix/bin/ai-model" <<'EOF'
+#!/usr/bin/env bash
+case "${1:-status}" in
+ list) printf 'claude-login\ncloud-direct\nlocal-openai\n' ;;
+ use)
+ printf '%s\n' "$2" > "$TEST_ROOT/model-action"
+ printf '%s\n' "$2" > "$HOME/.config/android-ai-stack/active"
+ ;;
+ *) exit 2 ;;
+esac
+EOF
+chmod 700 "$test_prefix/bin/stack" "$test_prefix/bin/llama-local" "$test_prefix/bin/ai-model"
+
+cat > "$test_home/.config/android-ai-stack/widget-profiles" <<'EOF'
+local|local-openai|Local Qwen|token-ready
+slot-01|cloud-direct|Cloud Direct|token-ready
+slot-02|claude-login|Claude Official|login-required
+EOF
+chmod 600 "$test_home/.config/android-ai-stack/widget-profiles"
+printf 'cloud-direct\n' > "$test_home/.config/android-ai-stack/active"
+chmod 600 "$test_home/.config/android-ai-stack/active"
+
+bridge="$repo_root/skills/deploy-android-ai-stack/scripts/mobile-lab-widget-action"
+status=$(PREFIX="$test_prefix" HOME="$test_home" bash "$bridge" status)
+grep -qx 'happy_state=OFF' <<< "$status"
+grep -qx 'llama_state=OFF' <<< "$status"
+grep -qx 'model_alias=slot-01' <<< "$status"
+grep -qx 'model_name=Cloud Direct' <<< "$status"
+grep -qx 'provider_slot02=Claude Official' <<< "$status"
+grep -qx 'readiness_slot02=login-required' <<< "$status"
+grep -qx 'readiness_slot09=unavailable' <<< "$status"
+
+PREFIX="$test_prefix" HOME="$test_home" bash "$bridge" model-use slot-02 >/dev/null
+grep -qx 'claude-login' "$test_root/model-action"
+PREFIX="$test_prefix" HOME="$test_home" bash "$bridge" happy-toggle >/dev/null
+grep -qx 'up' "$test_root/stack-action"
+
+if PREFIX="$test_prefix" HOME="$test_home" bash "$bridge" model-use slot-99 >/dev/null 2>&1; then
+ echo 'invalid slot was accepted' >&2
+ exit 1
+fi
+if PREFIX="$test_prefix" HOME="$test_home" bash "$bridge" 'status; id' >/dev/null 2>&1; then
+ echo 'free-form operation was accepted' >&2
+ exit 1
+fi
+
+chmod 644 "$test_home/.config/android-ai-stack/widget-profiles"
+if PREFIX="$test_prefix" HOME="$test_home" bash "$bridge" status >/dev/null 2>&1; then
+ echo 'unsafe widget profile map was accepted' >&2
+ exit 1
+fi
+chmod 600 "$test_home/.config/android-ai-stack/widget-profiles"
+
+installer="$repo_root/skills/deploy-android-ai-stack/scripts/install-control-bridge.sh"
+printf 'extra-keys = [[ESC]]\n' > "$test_home/.termux/termux.properties"
+PREFIX="$test_prefix" HOME="$test_home" bash "$installer" --enable-external-apps >/dev/null
+test -x "$test_prefix/bin/mobile-lab-widget-action"
+test -f "$test_home/.termux/termux.properties.pre-mobile-lab-control"
+test "$(grep -c '^allow-external-apps=true$' "$test_home/.termux/termux.properties")" -eq 1
+
+printf 'native control bridge tests passed\n'