Skip to content

Create standalone MCP so ADB/Termux/Shizuku isnt't needed #5

Description

@sanjarcode

Here's the spec, written for a coding assistant:


PhoneBridge — Android MCP Bridge App

Overview

PhoneBridge is a standalone Android app that acts as a local MCP (Model Context Protocol) server on the device. Once installed, it allows an AI assistant (e.g. Claude) to control the phone autonomously — launching apps, reading the screen, installing/uninstalling apps, sending messages, and more — entirely over the internet, with no ADB, no Termux, and no Shizuku required.

The target user is non-technical. Setup should be: install APK → grant permissions → done.


Scope & Goals

In scope:

  • Full MCP server running on-device, accessible over the internet
  • App launch, deep linking, UI automation, screenshot, package management
  • App install (via Play Store UI automation) and uninstall (via code where possible, UI fallback where not)
  • All operations prefer direct Android API calls over UI automation, to minimize round-trips, token usage, and fragility

Out of scope:

  • Root-only operations
  • Shizuku dependency
  • Termux dependency
  • Laptop/ADB dependency of any kind — once installed, the app is fully self-contained

Architecture

AI Assistant (Claude / any MCP client)
        │
        │  HTTPS / SSE (MCP protocol)
        ▼
  Cloudflare Tunnel  ←── runs inside the app as a background service
        │
        ▼
  PhoneBridge APK  (foreground service, persistent)
  ├── MCP HTTP Server (Ktor, port 8080)
  ├── AccessibilityService        ← UI automation, screen reading
  ├── MediaProjection             ← screenshots
  ├── PackageInstaller API        ← silent sideload installs (APK files)
  ├── PackageManager API          ← list, query, check packages
  └── startActivity() / Intents  ← app launch, deep links, Play Store nav

Permissions Required (One-Time Setup)

The app will walk the user through granting these on first launch:

Permission Why How granted
Accessibility Service UI automation, screen reading Settings → Accessibility → PhoneBridge
Media Projection Screenshots One-time system dialog on first use
Display over other apps UI overlay if needed Settings toggle
Notification access Read/dismiss notifications Settings → Notification access
Install unknown apps Sideload APK installs Settings → Install unknown apps
Internet MCP server + tunnel Auto-granted (manifest)
Battery optimization exempt Stay alive in background Prompt on first launch

No root. No ADB. No developer options required.


MCP Tools — Full List

Device & Screen

Tool Implementation Notes
Screenshot() MediaProjection API Returns image directly, no shell
GetScreenLayout() AccessibilityService.getRootInActiveWindow() Returns accessibility tree as structured JSON
WaitForElement(text, resourceId, timeout) AccessibilityService polling Avoids snapshot loops

App Control

Tool Implementation Notes
LaunchApp(package) PackageManager.getLaunchIntentForPackage() + startActivity() Direct API, no UI, no shell
OpenDeepLink(uri) Intent(ACTION_VIEW, uri) Direct API
ListInstalledApps() PackageManager.getInstalledPackages() Direct API, returns package list
GetAppInfo(package) PackageManager.getApplicationInfo() Version, label, install state

UI Automation

Tool Implementation Notes
Tap(x, y) AccessibilityService.dispatchGesture() Direct API
TapBySelector(text, resourceId, description) AccessibilityService.findAccessibilityNodeInfosByText() Preferred over coordinates
Swipe(x1, y1, x2, y2, duration) AccessibilityService.dispatchGesture() Direct API
TypeText(text) AccessibilityService + ACTION_SET_TEXT Direct API, no input text shell
PressKey(keycode) AccessibilityService.performGlobalAction() Home, back, recents, etc.
Scroll(direction, target) AccessibilityService scroll action Direct API

Notifications

Tool Implementation Notes
GetNotifications() NotificationListenerService Direct API, returns structured list
DismissNotification(key) NotificationListenerService.cancelNotification() Direct API

App Install / Uninstall

This is the most nuanced area. The principle is: use direct API as far as Android allows, fall back to UI automation only where the OS forces a dialog.

Install

Scenario Method UI shown?
Install from APK file (URL or local) PackageInstaller API (SessionParams) ❌ None — fully silent
Install from Play Store startActivity() to Play Store listing → AccessibilityService taps "Install" ✅ Play Store UI (unavoidable)

For Play Store installs, the flow is:

LaunchApp("com.android.vending") 
  → navigate to app listing via deep link (market://details?id=<package>)
  → AccessibilityService monitors for "Install" button 
  → taps it 
  → monitors for "Open" button (signals install complete)
  → returns success

For APK installs (sideload), use PackageInstaller session API:

val installer = packageManager.packageInstaller
val params = PackageInstaller.SessionParams(MODE_FULL_INSTALL)
val sessionId = installer.createSession(params)
// write APK bytes into session, then commit
// system installs silently if INSTALL_PACKAGES permission available
// otherwise falls back to ACTION_INSTALL_PACKAGE intent (shows UI)

Note: PackageInstaller silent install works if the app holds REQUEST_INSTALL_PACKAGES and the APK is being installed by the same app. Full silent install without any prompt requires INSTALL_PACKAGES (system/ADB permission) — which is not available without root or Shizuku. Therefore:

  • Sideload install will show a one-time "Install anyway?" prompt on first use per source, due to Android's REQUEST_INSTALL_PACKAGES restriction. This is unavoidable without root.
  • Play Store install shows Play Store UI but requires no extra permissions.

Uninstall

Scenario Method UI shown?
Uninstall via PackageInstaller API PackageInstaller.uninstall() ✅ System confirmation dialog (unavoidable without root)
Uninstall via ACTION_DELETE intent Intent(ACTION_DELETE, package:uri) ✅ System confirmation dialog

For uninstall, AccessibilityService automatically taps "OK" / "Uninstall" on the system dialog, so from the user's perspective it's seamless — the dialog flashes briefly and dismisses itself.

UninstallApp("com.example.app")
  → PackageInstaller.uninstall() triggers system dialog
  → AccessibilityService detects dialog, taps "Uninstall" 
  → monitors PackageManager for package removal
  → returns success

There is no fully silent uninstall without root. The system dialog is mandatory by Android design. The AccessibilityService auto-confirm approach is the correct non-root solution.


Internet Exposure (No Port Forwarding Required)

The app runs a Cloudflare Tunnel internally to expose the MCP server over the internet without requiring the user to configure their router or know their IP.

On startup:

  1. Ktor server binds to localhost:8080
  2. App spawns cloudflared tunnel (bundled as a native binary in the APK)
  3. Tunnel URL (e.g. https://random-name.trycloudflare.com) is shown in the app's UI and optionally sent to a relay/config endpoint
  4. MCP client points to this URL

For persistent setups, the app can support a user-configured Cloudflare account with a fixed subdomain (e.g. myphone.mydomain.com).


Persistence & Reliability

Challenge Solution
Android kills background services Run as startForeground() with a persistent notification
Battery optimization kills the app Prompt user to exempt PhoneBridge from battery optimization on setup
AccessibilityService gets disabled App detects this and shows a re-enable prompt
Tunnel drops Auto-reconnect with exponential backoff
Device reboot BOOT_COMPLETED broadcast receiver restarts all services automatically

Design Principles for the Coding Assistant

  1. API-first, UI-last. Every tool must attempt a direct Android API call before falling back to AccessibilityService UI automation. Document clearly which path each tool takes.

  2. Structured responses. All MCP tool responses return structured JSON, not raw strings. Minimizes token usage on the model side.

  3. No polling loops in the MCP layer. Use WaitForElement with a timeout rather than repeated Screenshot + parse cycles. AccessibilityService callbacks are event-driven — use them.

  4. Single permission setup. All permissions are requested upfront in a guided onboarding flow. The app should not ask for permissions mid-operation.

  5. Fail loudly. Every tool returns a clear error field with a human-readable reason if it fails. The model should never have to guess why something didn't work.

  6. Stateless tools. Each MCP tool call is self-contained. No session state is assumed between calls.


Out of Scope (Explicitly)

  • Shizuku integration — not required, not included
  • Termux integration — not required, not included
  • ADB dependency of any kind
  • Root-only operations
  • iOS support
  • Multi-device management (v1 is single device only)

Tech Stack

Component Choice Reason
Language Kotlin Native Android, best API access
HTTP server Ktor (embedded) Lightweight, Kotlin-native
Tunnel Cloudflare Tunnel (bundled binary) No port forwarding, free tier
MCP transport HTTP + SSE Standard MCP spec
Min SDK Android 11 (API 30) Required for stable AccessibilityService + PackageInstaller behaviour
Target SDK Android 15 (API 35) Latest

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions