Skip to content
Draft
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,13 @@ jobs:
- name: Build Android toolchain image
run: docker build --file app/Dockerfile.build --tag rish-mcp-android-ci app

- name: Unit test and assemble debug APK
- name: Lint, unit test, and assemble debug APK
run: >-
docker run --rm
--volume "${GITHUB_WORKSPACE}/app:/work"
--workdir /work
rish-mcp-android-ci
gradle --no-daemon testDebugUnitTest assembleDebug
gradle --no-daemon lintDebug testDebugUnitTest assembleDebug

- name: Verify APK output
run: test -f app/app/build/outputs/apk/debug/app-debug.apk
58 changes: 31 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,10 @@ single **outbound** WebSocket to a relay on a public hostname you control; AIs
call the relay's MCP endpoint.

```
상시 WS (일반 기기)
┌─────────┐ MCP ┌──────────────────┐ ◀───────────────── ┌──────────────┐
│ AI │──HTTPS─▶│ Go relay + MCP │ │ Android 앱 │
│(Claude) │ ◀───────│ 서버 │──FCM 웨이크업──────▶│ (저사양 기기) │
└─────────┘ └──────────────────┘ (Google FCM 경유) └──────────────┘
┌─────────┐ MCP ┌──────────────────┐ ◀── outbound WS ── ┌──────────────────┐
│ AI │──HTTPS─▶│ Go relay + MCP │ │ Android app │
│(Claude) │ ◀───────│ server │── exec / result ──▶│ Shizuku → ADB fb │
└─────────┘ └──────────────────┘ └──────────────────┘
│ 버전/체크섬 조회, APK 배포
Expand All @@ -35,38 +34,39 @@ call the relay's MCP endpoint.
> A signed, real-device-verified rewrite APK has not been published yet. Until
> one is available, build the Android app from this checkout; see
> [Release channels](docs/RELEASES.md) for the versioning boundary and gates.
> The current source version is **1.0.0** (Android `versionCode` 10000), but that is
> a source milestone—not an assertion that a signed `agent-v1.0.0` release is
> already available.

## Why rewrite

The old agent depended on [Shizuku](https://shizuku.rikka.app/) — a separate
app the user had to install, understand, and grant permission to, which also
meant devices that didn't support or know about Shizuku couldn't use rish-mcp
at all. See [`plan.md`](plan.md) for the full rationale (Shizuku dependency,
Wear OS performance, server code quality, no official version endpoint).
The old agent required [Shizuku](https://shizuku.rikka.app/) and had no
fallback. The rewrite first moved to an on-device ADB client; 1.0 combines the
two: Shizuku is the preferred owner-authorized backend, while paired ADB keeps
the app usable when Shizuku is absent or stopped. The relay was independently
rewritten in Go for a smaller, testable trust boundary.

## What's different this time

- **No Shizuku.** The Android app pairs with its own `adbd` directly —
wireless-debugging pairing on Android 11+, a PC+`adb tcpip` bridge below
that. See [`docs/DESIGN.md` §3.1](docs/DESIGN.md#31-셸-접근-페어링-shizuku-대체).
- **Two shell backends.** Shizuku is preferred after an explicit permission
grant. Paired on-device ADB is the automatic fallback — wireless-debugging
pairing on Android 11+, or a PC+`adb tcpip` bridge below that.
- **Go relay**, not Node/TS — same MCP tool contracts (`run_shell`,
`list_devices`), same WS relay protocol, same OAuth model, rewritten for
concurrency/memory efficiency and a single static binary.
- **Hybrid connection model** (planned): normal phones/tablets keep an
always-on WebSocket; low-spec devices (Wear OS) are meant to move to an
FCM-wake + short session model instead. **Not implemented yet** — it needs
a Firebase project this repo doesn't have configured. Every device
currently uses the always-on path.
- **One honest connection model.** Every device currently keeps an outbound
WebSocket. The unused Firebase stub and SDK were removed; FCM wake will only
return if both the relay sender and a real Firebase project are implemented.

## Status

| Piece | State |
|---|---|
| Go relay (`server/cmd/relay`) — MCP tools, WS relay, static bearer + OAuth | ✅ built, tested |
| Official version server (`server/cmd/publicserver`) | ✅ built, tested |
| Android `AdbShellClient` (ADB pairing, shell exec) | ✅ built, tested (unit-testable parts only — no device to pair against in this environment) |
| `ConnectionManager` / `AgentService` / `MainActivity` (pairing UI) | ✅ built, compiles — **not verified against a real device** |
| Low-spec hybrid connection + FCM wake | ⛔ blockedneeds a Firebase project (see `docs/DESIGN.md` §7) |
| Android Shizuku + ADB fallback backends | ✅ built, router/policy tested — **not verified against a real device** |
| `ConnectionManager` / `AgentService` / `MainActivity` | ✅ duplicate-reconnect and command-overload guards; Docker build tested |
| Low-spec push wake | not shippedno dead Firebase dependency or misleading stub in the APK |
| Docker packaging for the Go binaries | ✅ `server/Dockerfile` (`--target relay` / `--target publicserver`) |
| docker-compose / reverse-proxy deploy config | ✅ `docker-compose.yml` (Traefik/Dokploy) |
| Signed rewrite APK release | ⛔ not published — legacy releases are incompatible |
Expand All @@ -78,9 +78,9 @@ Wear OS performance, server code quality, no official version endpoint).
bearer or OAuth for AIs, shared token for the device.
- `server/cmd/publicserver` — Go. Separate, secret-free binary: reports the
current agent version and serves the APK. No route to the relay.
- `app/` — Android (Kotlin). One installable APK: pairs with the device's own
`adbd` to run commands as shell uid, a foreground service holds the
outbound WS, auto-starts on boot.
- `app/` — Android (Kotlin). One installable APK: runs commands as shell uid
through Shizuku when authorized, otherwise through a paired local `adbd`;
a foreground service holds the outbound WS and auto-starts on boot.

## Quick start: local Android build and setup

Expand Down Expand Up @@ -111,7 +111,7 @@ docker build --target publicserver -t rishmcp-public server
# Android unit tests + debug APK (run from the repository root)
docker build -t rishmcp-android-build -f app/Dockerfile.build app
docker run --rm -v "$PWD/app:/work" -w /work rishmcp-android-build \
gradle --no-daemon testDebugUnitTest assembleDebug
gradle --no-daemon lintDebug testDebugUnitTest assembleDebug
# output: app/app/build/outputs/apk/debug/app-debug.apk
```

Expand Down Expand Up @@ -155,8 +155,8 @@ Same tool surface as before — this part of the contract didn't change:
}
```

- `list_devices()` — connected devices, agent version, connection age, and
pending-command count.
- `list_devices()` — connected devices, active shell backend, agent version,
connection age, and pending-command count.
- `run_shell({cmd, deviceId?, timeoutMs?})` — run a command as shell uid;
returns stdout, stderr, exit code.

Expand All @@ -171,6 +171,10 @@ Full tool reference, OAuth flow, and the WS relay protocol are documented in
inbound connections.
- No root is required or used — shell access is uid 2000, same ceiling as
`adb shell`.
- Shizuku access is optional and only becomes active after the device owner
grants this app permission; otherwise the paired ADB backend is used.
- Root-mode Shizuku is deliberately rejected. The agent binds only when the
Shizuku server reports uid 2000, preserving the documented shell ceiling.
- Scope is the **owner's own device** for personal automation, same as
before — see `plan.md`'s explicit "multi-tenant 아님" non-goal.

Expand Down
35 changes: 10 additions & 25 deletions app/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,20 @@ android {
applicationId = "kr.scin.rishmcp"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
versionCode = 10000
versionName = "1.0.0"
}

buildFeatures {
aidl = true
buildConfig = true
}

// Sideloaded personal app — skip the release lint gate (see before/app for
// the original rationale; it also tries to auto-install SDK bits into a
// read-only image SDK dir under the Docker build).
// Official signing remains a separate release gate, but debug lint errors
// must fail local/CI builds.
lint {
checkReleaseBuilds = false
abortOnError = false
abortOnError = true
}

compileOptions {
Expand All @@ -47,29 +47,14 @@ dependencies {
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")

// On-device ADB client (pairing + connect + shell), replacing Shizuku.
// See docs/DESIGN.md §2.1 and §3.1.
// Shell backends. Shizuku is preferred when the owner granted access;
// on-device ADB remains available as a no-Shizuku fallback.
implementation("dev.rikka.shizuku:api:13.1.5")
implementation("dev.rikka.shizuku:provider:13.1.5")
implementation("com.github.MuntashirAkon:libadb-android:3.1.1")
// Self-signed X.509 cert generation for the ADB auth key pair (no AOSP
// sun.security.x509 classes on Android otherwise); used by AdbShellClient.
implementation("com.github.MuntashirAkon:sun-security-android:1.1")

// Low-spec device wake path (docs/DESIGN.md §3.2, roadmap step 4).
// Harmless to depend on ahead of time: FcmWakeReceiver only does
// anything once a real google-services.json makes the plugin below
// active and Firebase actually initializes.
// Note: firebase-messaging-ktx is deprecated (its Kotlin extensions were
// merged into the base artifact) and has no version mapping in recent
// BoM releases — use firebase-messaging directly.
implementation(platform("com.google.firebase:firebase-bom:34.17.0"))
implementation("com.google.firebase:firebase-messaging")

testImplementation("junit:junit:4.13.2")
}

// Only apply Google Services once a real config file exists, so the build
// doesn't break before a Firebase project is wired up (docs/DESIGN.md §7).
// Swap for `google-services.json.example` locally to see what's expected.
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
29 changes: 0 additions & 29 deletions app/app/google-services.json.example

This file was deleted.

54 changes: 40 additions & 14 deletions app/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<!-- One APK for normal Android devices and Wear OS watches. -->
<uses-feature
android:name="android.hardware.type.watch"
android:required="false" />
<!-- No watch-only uses-feature is declared: this sideload APK stays
installable on both handhelds and Wear OS, with runtime detection. -->

<!-- ADB wireless-debugging pairing/connect (loopback) and the relay
WebSocket both need network access. -->
Expand All @@ -16,8 +15,24 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />

<!-- libadb-android has legacy target metadata that makes manifest-merger
imply these unrelated permissions. rish-mcp never uses them. -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_PHONE_STATE"
tools:node="remove" />

<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
Expand All @@ -32,6 +47,17 @@
</intent-filter>
</activity>

<!-- Shell-only headless provisioning endpoint. MainActivity remains
launcher-accessible but ignores configuration extras delivered
through its unprotected component. The shell uid holds DUMP. -->
<activity
android:name=".ProvisioningActivity"
android:excludeFromRecents="true"
android:exported="true"
android:noHistory="true"
android:permission="android.permission.DUMP"
android:theme="@style/Theme.RishMcp" />

<service
android:name=".AgentService"
android:exported="false"
Expand All @@ -50,14 +76,14 @@
</intent-filter>
</receiver>

<!-- Low-spec device wake path (docs/DESIGN.md §3.2) — inert until a
real google-services.json/Firebase project exists (§7). -->
<service
android:name=".FcmWakeReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- Optional Shizuku backend. Devices without Shizuku continue to use
the on-device ADB backend; the provider alone grants no access. -->
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:enabled="true"
android:exported="true"
android:multiprocess="false"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
</application>
</manifest>
9 changes: 9 additions & 0 deletions app/app/src/main/aidl/kr/scin/rishmcp/IUserService.aidl
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.scin.rishmcp;

interface IUserService {
// Reserved by Shizuku for stopping a UserService process.
void destroy() = 16777114;

// Runs `sh -c <cmd>` as uid 2000 and returns a JSON ShellResult.
String exec(String cmd, long timeoutMs) = 1;
}
Loading