Android agent that connects to the backend via SignalR, reports device status, dispatches input events from remote viewers via an AccessibilityService, and streams the screen to viewers as an H.264 WebRTC video track.
When a viewer hits Connect in the web UI, the backend pushes StartCapture over SignalR. The agent surfaces the system MediaProjection consent dialog, elevates the foreground service to mediaProjection, and creates a PeerConnection (stream-webrtc-android) wired to a ScreenCapturerAndroid. SDP offer/answer and ICE candidates flow through the same SignalR hubs (/hubs/agent β /hubs/control). When the viewer disconnects, the hub pushes StopCapture and the session is torn down.
- Android Studio Hedgehog (2023.1) or newer, OR
- Command-line build: Android SDK with platform 35, Build Tools 35, JDK 17, and a system Gradle 8.10 (used once to bootstrap
gradlew). - A physical Android device running Android 10 (API 29) or later. Emulators work for input/control, but
MediaProjectionperformance is poor in emulators.
# (one-time) generate the Gradle wrapper jar β only needed if you don't
# open the project in Android Studio (which generates it for you).
cd c:/dev/rdp/android
gradle wrapper --gradle-version 8.10.2
# create local.properties pointing at your Android SDK
echo "sdk.dir=C:\\Users\\$env:UserName\\AppData\\Local\\Android\\Sdk" > local.propertiesFrom Android Studio: open c:/dev/rdp/android and Run.
From CLI:
./gradlew :app:assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk- In the web UI: log in as admin β click "Pair new device". The page renders a QR code that encodes
rdpair://<host>:<port>/pair?token=<uuid>. The token expires after 5 minutes.- The host/port the QR points at comes from the backend's
Pairing__BaseUrlenv var (e.g.http://192.168.1.10:5000). Without it the backend falls back to the requestHostheader, which only works when the admin's browser is on the same LAN segment as the server.
- The host/port the QR points at comes from the backend's
- On the phone: open the RemoteDesktop Agent app β tap Scan QR to pair β grant camera permission β point the camera at the QR. The agent parses the URI, calls
POST /api/agent/pairwith the token plus auto-detected metadata (Build.MODEL, OS version), and on success stores the trust key inEncryptedSharedPreferencesand starts the foreground service. The device name in the dashboard initially equalsBuild.MODEL; an admin can rename it inline from the device detail view. - Disable battery optimization: the app surfaces an indefinite snackbar with an Allow action. Tap it β confirm the system dialog. Without this, Android's App Standby + Doze suspend the SignalR socket within ~5 minutes of screen-off and the device flips offline in the dashboard.
- Enable input injection: once battery is sorted, the next snackbar prompts to enable the accessibility service. Tap Enable input injection β toggle on the "RemoteDesktop Input Bridge" service in Android Settings β Accessibility.
- The web UI's QR card switches to a green "Paired" state and auto-advances to the new device's detail page (driven by the
PairingCompletedSignalR event broadcast from the agent's pair call).
After pairing, the phone reconnects automatically on every boot until you tap Unpair (forget keys) or an admin removes / revokes the device from the web UI.
| Component | File | Role |
|---|---|---|
AgentService |
control/AgentService.kt |
Foreground service. Reconnects forever; runs the 30 s status reporter. Holds a PARTIAL_WAKE_LOCK for the service's whole lifetime so Android's App Standby can't suspend the SignalR socket; a separate SCREEN_BRIGHT_WAKE_LOCK is grabbed only during viewer sessions to keep the captured display awake. |
SignalRClient |
control/SignalRClient.kt |
Coroutine wrapper around com.microsoft.signalr. |
AgentApi |
control/AgentApi.kt |
OkHttp + kotlinx.serialization REST client for /api/agent/pair and /api/agent/connect. |
PairScannerActivity |
pair/PairScannerActivity.kt |
CameraX preview + ML Kit barcode scanner. Returns { baseUrl, token } to MainActivity via setResult. |
PairUri |
pair/PairUri.kt |
Parser for the rdpair://host:port/pair?token=... QR payload. |
DeviceIdentity |
identity/DeviceIdentity.kt |
EncryptedSharedPreferences-backed identity storage. |
InputAccessibilityService |
input/InputAccessibilityService.kt |
The only component that can inject taps/swipes; wired via InputDispatcher to the SignalR receive callback. |
WebRtcCaptureSession |
webrtc/WebRtcCaptureSession.kt |
Long-lived ScreenCapturerAndroid + MediaProjection pipeline. Each viewer attaches/detaches a fresh PeerConnection against the live video track β capture stays warm so the consent prompt only appears once per agent process. |
MediaProjectionPermissionActivity |
webrtc/MediaProjectionPermission.kt |
Transparent shim that pops the system consent dialog and feeds the result back to the service via a CompletableDeferred. |
ScreenUnlockActivity |
control/ScreenUnlockActivity.kt |
Transparent shim launched on viewer attach: surfaces over the lock screen, wakes the display, and asks the system to dismiss the keyguard (effective only for swipe-to-unlock devices). |
StatusReporter |
status/StatusReporter.kt |
Battery + wifi-bars + display-resolution snapshot, pushed via ReportStatus every 30 s. |
POST /api/agent/pairβ pairing-token-authed (token from the QR); returns{ deviceId, trustKey, token }.POST /api/agent/connectβ trustKey-authed; returns a fresh device JWT (60 min)./hubs/agentβ device-side SignalR hub (role=device).- device β server:
RegisterDevice,ReportStatus,SendSdpOffer,SendIceCandidate - server β device:
ReceiveInput,StartCapture(iceServers)(carries fresh ephemeral TURN/STUN creds, empty array for LAN-only),StopCapture,ReceiveSdpAnswer,ReceiveIceCandidate,Revoked
- device β server:
- TURN/STUN β the backend mints fresh ephemeral creds (HMAC-SHA1 over
<expiry>:<subject>) and ships them as the payload of everyStartCaptureSignalR push.AgentService.handleStartCapture(servers)latches them intoiceServersand passes the list toWebRtcCaptureSession.attachPeer(...). When the backend'sTurn:*config is unset, the array is empty and the agent uses host candidates only (LAN-only). - Adaptive bitrate β the WebRTC stack adapts down on congestion automatically and
WebRtcCaptureSessioncaps the outbound at 1.5 Mbps viaRtpSender.parameters. Phase 6 could expose the cap per-device via config. - Audio β capture is video-only. To add audio, you'll need
MediaProjection.createAudioRecord(API 29+) and anAudioTrackon the peer connection.
- WiFi RSSI may report
nullon Android 12+ unless the user grants location permission. The backend treats this as "unknown" rather than zero. - Key injection beyond the system navigation set (Home/Back/Recents/Power/Volume) requires
INJECT_INPUT_EVENTS, a system-level permission. Text typing is not implemented. - Force-stop of the app cancels the foreground service. Android's auto-restart will re-bring it up only after the user opens the app again.
- The MediaProjection consent dialog appears once per agent process (on the first viewer connect). The capture pipeline stays warm between viewers, so subsequent connects skip the prompt. The popup will reappear after a device reboot, app force-stop, or if the user revokes via the system "Stop sharing" notification.
- On viewer connect the agent acquires a screen wake lock (so the captured surface keeps receiving fresh frames) and pops a transparent activity that asks the system to dismiss the keyguard. This only works for swipe-to-unlock devices β secured locks (PIN, pattern, password, biometric) can't be bypassed from a non-system app.
- Battery optimization MUST be disabled for the agent (the app prompts on first launch). Without it, Android's App Standby suspends the OkHttp socket within ~5 minutes of screen-off and the dashboard flips the device offline. The service-lifetime
PARTIAL_WAKE_LOCKadds roughly 3β5 % battery per hour while the agent is paired but idle β acceptable for a phone whose primary purpose is being remotely controlled.