Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 16 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ or model weights.

```text
Happy app -> 127.0.0.1 Happy server -> local Happy daemon -> local Claude Code
|
OpenCode TUI/Web ---------------------+-> provider profile -> model API
(cloud/relay/local)
|
Claude/Happy profile -> model API

OpenCode TUI/Web -> independent OpenCode profile -> model API (cloud/relay/local)
```

- The control plane is self-contained on the phone.
Expand All @@ -29,11 +30,12 @@ OpenCode TUI/Web ---------------------+-> provider profile -> model API
if you intentionally need one.
- Local inference is optional. A small `llama.cpp` server can expose a
loopback OpenAI-compatible API for lightweight offline tasks.
- Switching model profiles is explicit, keeps secrets outside committed
configuration, and never changes service lifecycle implicitly.
- 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.
- OpenCode and Claude/Happy have separate selectors and active files. Switching
either route is explicit and never changes the other client or a service
lifecycle.
- The offline `Mobile Lab Control` app exposes five independent native widgets:
Happy, llama.cpp, Claude/Happy models, OpenCode Web, and OpenCode models.
Termux:Widget remains an opt-in terminal fallback.

## Repository layout

Expand Down Expand Up @@ -82,12 +84,13 @@ 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 |
| Native control widgets | five controls exercised on Android 14; lifecycle and model routes stayed independent | 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
profile differed, and both services stopped cleanly without changing the
selected profile.
The 2026-08-09 phone acceptance also exercised real inference: OpenCode and
Claude CLI each returned an exact nonce through the OpenAI-labelled relay, and
Happy returned an exact nonce from the mobile UI. The Android 14 widget path was
fixed to use a user-initiated activity, and both local services stopped cleanly
afterward.

“Included” does not mean automatically installed. Upstream binaries and model
weights remain explicit, pinned operator choices.
Expand Down
23 changes: 13 additions & 10 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ GitHub Release 资产发布,不进入 Git 历史。

```text
Happy App -> 127.0.0.1 Happy server -> 本地 Happy daemon -> 本地 Claude Code
|
OpenCode TUI/Web ------------------------+-> provider profile -> 模型 API
云 / 中转 / 本地
|
Claude/Happy profile -> 模型 API

OpenCode TUI/Web -> 独立 OpenCode profile -> 模型 API(云 / 中转 / 本地)
```

- 手机默认是自洽的控制面,不依赖远端 Happy relay。
Expand All @@ -28,9 +29,10 @@ OpenCode TUI/Web ------------------------+-> provider profile -> 模型 API
负责。
- 本地推理也是可选扩展:小型 llama.cpp server 只监听 loopback,适合离线
摘要、分类、脱敏和断网兜底。
- 模型 profile 只保存路由信息;凭据始终位于独立的 mode `0600` 文件。
- 离线 `Mobile Lab Control` App 分别提供 Happy、llama.cpp、模型配置三个
原生微件;Termux:Widget 只保留为可选终端回退。
- OpenCode 与 Claude/Happy 分别使用独立选择器和 active 文件,切换一边绝不
改写另一边,也不会隐式启停服务;凭据仍位于独立的 mode `0600` 文件。
- 离线 `Mobile Lab Control` App 提供五个原生微件:Happy、llama.cpp、
Claude/Happy 模型、OpenCode Web、OpenCode 模型。

## 仓库内容

Expand Down Expand Up @@ -66,11 +68,12 @@ 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 |
| 原生控制微件 | 五项控制已在 Android 14 真机验收,服务与两条模型路由互不联动 | 提供源码、白名单桥接、双语 UI 与 Release APK |

最终手机验收也覆盖了控制面解耦:切换 profile 不改变正在运行的服务,Happy
启动时 profile 与当前配置不一致时明确显示 `STALE`,两个服务都能在不改变
模型选择的情况下独立关闭。
2026-08-09 的手机验收不仅检查配置:OpenCode、Claude CLI 均通过标为 OpenAI
的中转返回精确 nonce,Happy 也从手机 App 得到精确回复。Android 14 微件改用
用户触发的短生命周期 Activity 后不再报后台启动限制;验收结束后所有本地服务
端口均已关闭。

“仓库包含 wrapper”不等于“上游组件已经自动安装”。二进制、服务端源码和
GGUF 都必须由操作者明确选择、固定来源并验证 SHA-256。
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile-lab-control/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@
</intent-filter>
</activity>

<!-- AppWidget broadcasts cannot start Termux's command service on
Android 14. A user tap opens this short-lived activity, preserving
Android's user-initiated background-start boundary. -->
<activity
android:name=".ControlActivity"
android:excludeFromRecents="true"
android:exported="false"
android:noHistory="true"
android:theme="@style/ControlActivityTheme" />

<receiver
android:name=".CommandResultReceiver"
android:exported="false" />
Expand Down Expand Up @@ -60,5 +70,15 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_model_info" />
</receiver>

<receiver android:name=".OpenCodeWebWidgetProvider" android:exported="false">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_oc_web_info" />
</receiver>

<receiver android:name=".OpenCodeModelWidgetProvider" android:exported="false">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_oc_model_info" />
</receiver>
</application>
</manifest>
23 changes: 16 additions & 7 deletions apps/mobile-lab-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

[English](README.md) · [简体中文](README.zh-CN.md)

An offline Android control surface for the Android AI Stack. It exposes three
An offline Android control surface for the Android AI Stack. It exposes five
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.
- **Claude Code + Happy models** — readable cloud profile selection for that
client pair only;
- **OpenCode Web** — status, loopback-only start/stop, and browser handoff;
- **OpenCode models** — an independent cloud/relay/local selector that never
rewrites Claude Code or Happy.

The app has no Internet permission, reads no token, holds no root privilege,
and accepts no free-form command. It can invoke only
Expand Down Expand Up @@ -42,12 +45,18 @@ Verify future APKs against this identity as well as the attached file checksum.
`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.
4. Open the app and add the five controls separately. OpenCode Web may also be
started and stopped from the app page.

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.
`~/.config/android-ai-stack/widget-profiles`, or split it into mode-`0600`
`claude-widget-profiles` and `opencode-widget-profiles`. The two selectors use
separate active files and commands (`ai-model` and `oc-model`). The bridge never
reads provider tokens or CC Switch databases.

Android 14 blocks an AppWidget broadcast receiver from starting Termux's
service directly. Widget taps therefore enter a short-lived, user-initiated
activity before invoking the fixed allow-list bridge.

## Upgrade identity

Expand Down
17 changes: 12 additions & 5 deletions apps/mobile-lab-control/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

[English](README.md) · [简体中文](README.zh-CN.md)

这是 Android AI Stack 的离线原生控制面,提供三个彼此独立的桌面微件
这是 Android AI Stack 的离线原生控制面,提供五个彼此独立的桌面微件

- **Happy**:状态、启动/关闭、重启与刷新;
- **llama.cpp**:状态、启动/关闭、释放模型内存与刷新;
- **模型配置**:显示当前可读名称,一键切换配置,但不改变两个服务的启停状态。
- **Claude Code + Happy 模型**:只切换这两个客户端使用的云端配置;
- **OpenCode Web**:显示状态、仅 loopback 启停,并在启动后打开浏览器;
- **OpenCode 模型**:独立切换云端、中转或本地模型,绝不改写 Claude/Happy。

App 不申请联网权限、不读取 Token、不持有 root,也没有任意命令输入。它只通过
Termux 官方 [`RUN_COMMAND` 接口](https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent)
Expand Down Expand Up @@ -35,11 +37,16 @@ Release 发布,不提交进源码历史。
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、模型配置三个微件
4. 打开 App,分别添加五个控制微件;OpenCode Web 也可以直接在 App 页面启停

把 [`config/widget-profiles.example`](../../config/widget-profiles.example) 复制到
`~/.config/android-ai-stack/widget-profiles`,保持 mode `0600`,只填写脱敏的
Profile 名称和展示名。桥接层不会读取 Provider Token 或 CC Switch 数据库。
`~/.config/android-ai-stack/widget-profiles`,或拆成 mode `0600` 的
`claude-widget-profiles` 与 `opencode-widget-profiles`。两个选择器分别使用
`ai-model`、`oc-model` 和独立 active 文件。桥接层不会读取 Provider Token 或
CC Switch 数据库。

Android 14 不允许 AppWidget 广播接收器直接启动 Termux 服务,因此微件点击会先
进入一个短生命周期、由用户明确触发的 Activity,再调用固定白名单桥接。

## 升级签名说明

Expand Down
36 changes: 36 additions & 0 deletions apps/mobile-lab-control/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,24 @@
android:text="@string/refresh_all"
android:textColor="@color/widget_text" />

<Button
android:id="@+id/oc_web_toggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/button_background"
android:text="@string/oc_web_page_action"
android:textColor="@color/accent_blue" />

<Button
android:id="@+id/oc_web_stop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/button_background"
android:text="@string/oc_web_stop"
android:textColor="@color/widget_text" />

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
Expand Down Expand Up @@ -92,6 +110,24 @@
android:text="@string/add_model_widget"
android:textColor="@color/accent_purple" />

<Button
android:id="@+id/pin_oc_web"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/button_background"
android:text="@string/add_oc_web_widget"
android:textColor="@color/accent_blue" />

<Button
android:id="@+id/pin_oc_model"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="@drawable/button_background"
android:text="@string/add_oc_model_widget"
android:textColor="@color/accent_purple" />

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile-lab-control/res/layout/widget_model.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
android:orientation="vertical">

<TextView
android:id="@+id/model_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/model_title"
Expand Down Expand Up @@ -47,6 +48,7 @@
</LinearLayout>

<TextView
android:id="@+id/model_note"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile-lab-control/res/layout/widget_oc_web.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent"
android:background="@drawable/widget_background" android:orientation="vertical" android:padding="14dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content"
android:text="@string/oc_web_title" android:textColor="@color/accent_blue" android:textSize="17sp" android:textStyle="bold" />
<TextView android:id="@+id/oc_web_status" android:layout_width="match_parent" android:layout_height="0dp"
android:layout_weight="1" android:gravity="center_vertical" android:text="@string/status_waiting" android:textColor="@color/widget_text" android:textSize="15sp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal">
<Button android:id="@+id/oc_web_toggle" android:layout_width="0dp" android:layout_height="42dp" android:layout_weight="1"
android:background="@drawable/button_background" android:text="@string/toggle_start_stop" android:textColor="@color/widget_text" android:textSize="12sp" />
<Button android:id="@+id/oc_web_refresh" android:layout_width="42dp" android:layout_height="42dp" android:layout_marginStart="6dp"
android:background="@drawable/button_background" android:text="@string/refresh_symbol" android:textColor="@color/widget_text" android:textSize="18sp" />
</LinearLayout>
</LinearLayout>
Loading
Loading