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:
- Ktor server binds to
localhost:8080
- App spawns
cloudflared tunnel (bundled as a native binary in the APK)
- Tunnel URL (e.g.
https://random-name.trycloudflare.com) is shown in the app's UI and optionally sent to a relay/config endpoint
- 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
-
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.
-
Structured responses. All MCP tool responses return structured JSON, not raw strings. Minimizes token usage on the model side.
-
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.
-
Single permission setup. All permissions are requested upfront in a guided onboarding flow. The app should not ask for permissions mid-operation.
-
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.
-
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 |
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:
Out of scope:
Architecture
Permissions Required (One-Time Setup)
The app will walk the user through granting these on first launch:
No root. No ADB. No developer options required.
MCP Tools — Full List
Device & Screen
Screenshot()MediaProjectionAPIGetScreenLayout()AccessibilityService.getRootInActiveWindow()WaitForElement(text, resourceId, timeout)AccessibilityServicepollingApp Control
LaunchApp(package)PackageManager.getLaunchIntentForPackage()+startActivity()OpenDeepLink(uri)Intent(ACTION_VIEW, uri)ListInstalledApps()PackageManager.getInstalledPackages()GetAppInfo(package)PackageManager.getApplicationInfo()UI Automation
Tap(x, y)AccessibilityService.dispatchGesture()TapBySelector(text, resourceId, description)AccessibilityService.findAccessibilityNodeInfosByText()Swipe(x1, y1, x2, y2, duration)AccessibilityService.dispatchGesture()TypeText(text)AccessibilityService+ACTION_SET_TEXTinput textshellPressKey(keycode)AccessibilityService.performGlobalAction()Scroll(direction, target)AccessibilityServicescroll actionNotifications
GetNotifications()NotificationListenerServiceDismissNotification(key)NotificationListenerService.cancelNotification()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
PackageInstallerAPI (SessionParams)startActivity()to Play Store listing →AccessibilityServicetaps "Install"For Play Store installs, the flow is:
For APK installs (sideload), use
PackageInstallersession API:Note:
PackageInstallersilent install works if the app holdsREQUEST_INSTALL_PACKAGESand the APK is being installed by the same app. Full silent install without any prompt requiresINSTALL_PACKAGES(system/ADB permission) — which is not available without root or Shizuku. Therefore:REQUEST_INSTALL_PACKAGESrestriction. This is unavoidable without root.Uninstall
PackageInstallerAPIPackageInstaller.uninstall()ACTION_DELETEintentIntent(ACTION_DELETE, package:uri)For uninstall,
AccessibilityServiceautomatically taps "OK" / "Uninstall" on the system dialog, so from the user's perspective it's seamless — the dialog flashes briefly and dismisses itself.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:
localhost:8080cloudflaredtunnel (bundled as a native binary in the APK)https://random-name.trycloudflare.com) is shown in the app's UI and optionally sent to a relay/config endpointFor persistent setups, the app can support a user-configured Cloudflare account with a fixed subdomain (e.g.
myphone.mydomain.com).Persistence & Reliability
startForeground()with a persistent notificationBOOT_COMPLETEDbroadcast receiver restarts all services automaticallyDesign Principles for the Coding Assistant
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.
Structured responses. All MCP tool responses return structured JSON, not raw strings. Minimizes token usage on the model side.
No polling loops in the MCP layer. Use
WaitForElementwith a timeout rather than repeatedScreenshot+ parse cycles. AccessibilityService callbacks are event-driven — use them.Single permission setup. All permissions are requested upfront in a guided onboarding flow. The app should not ask for permissions mid-operation.
Fail loudly. Every tool returns a clear
errorfield with a human-readable reason if it fails. The model should never have to guess why something didn't work.Stateless tools. Each MCP tool call is self-contained. No session state is assumed between calls.
Out of Scope (Explicitly)
Tech Stack