Skip to content
Closed
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: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ package-lock.json
*.AppImage
*.deb
*.dmg

# Android build outputs
android/.gradle/
android/**/build/
6 changes: 6 additions & 0 deletions android/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.gradle/
.kotlin/
.idea/
build/
local.properties
*.iml
83 changes: 83 additions & 0 deletions android/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Parallel Code for Android

Native companion app for the desktop's **Connect Phone** (Remote Access) feature. It talks to the same HTTP/WebSocket API as the phone web UI in `src/remote/`.

## What it does

- **Connect:** scan the QR code in Connect Phone, or paste the link under it. This gives a view-only token.
- **Pair:** enter the six-digit code from Connect Phone to get a paired token, which may type into terminals. "Keep this phone authorized" asks the desktop to remember the phone across restarts.
- **Several computers:** link more than one desktop (for example the installed app and a dev build, or two machines) and switch between them in Settings → Computers; each keeps its own pairing.
- **Agents:** live list with each agent's status and last line, under the desktop's Claude, Codex, and Antigravity 5-hour and weekly usage meters (hidden on desktops without `/api/mobile/usage`).
- **Minimized tasks:** tasks minimized on the desktop are pinned below the live list; a setting hides them.
- **Looks:** the same 15 themes as the desktop, in Settings → Appearance. Follow system / always dark / always light picks the tone, and a separate dark and light look is remembered, so switching your phone's theme switches the look with it. Each look is drawn with a live swatch, and every color and corner radius comes from the desktop's own stylesheet. See [Looks](#looks).
- **Settings:** theme and looks, keep the screen on, widget background transparency and card color, connection status, and forget this computer.
- **Swipe between tasks:** with a task open, swipe sideways to the previous or next one in the list; the header shows its position ("2 of 5").
- **Terminal:** an agent's terminal in the colors of the look you picked, matching the desktop. Once paired: a reply box and keys a phone keyboard lacks (Enter, Esc, Tab, arrows, Ctrl+C). With "Fit the terminal to this phone" on (Settings, off by default), the terminal takes the phone's size while open so full-screen agents such as Claude Code fill it; the computer's own terminal shifts meanwhile and gets its size back when you leave.
- **Changes:** the task's diff against its base branch, file by file with added and removed lines.
- **Quick replies and voice:** saved replies above the reply box (edit them in Settings) and a mic button that dictates with Android's speech recognizer.
- **Widget:** a home-screen widget with the agents that need you and the usage meters, updated while the app is connected. Settings → Widget sets its background transparency (opaque, 75%, 50% or 25%; the border fades with the card, so your wallpaper shows through) and its card color (Obsidian, Slate or Light, each with text colors that stay readable).
- **Notes:** read a task's notes panel; edit and save it once paired.
- **New task:** pick a project and describe the work; needs pairing.
- **Notifications:** optional, in Settings. A foreground service keeps the connection open in the background and notifies when an agent needs input, hits an error, or finishes (each can be turned off); tapping one opens that agent.
- **Close task:** from an agent's screen; needs pairing. Like the desktop, it warns before losing uncommitted or unmerged work.

- **Built-in chat:** read the conversation, send messages, stop the agent, and answer its approvals and questions once paired. Choosing the model and attaching images stay on the computer.

## Looks

The phone uses the desktop's look presets, not its own. `LookPalettes.kt` is generated from the files the desktop already keeps its looks in:

| Desktop source | What it contributes |
| ------------------ | ------------------------------------------------------------ |
| `src/lib/look.ts` | Preset ids, labels, descriptions, order, and light/dark tone |
| `src/styles.css` | The colors and the corner radius scale |
| `src/lib/theme.ts` | The terminal ANSI palettes and which look pairs with which |

```sh
npm run generate:android-looks # rewrite LookPalettes.kt after a desktop theme change
npm run check:android-looks # fail if it is out of date (also run by the Kotlin tests)
```

Three things are worth knowing about the mapping:

- **The cascade is resolved, not copied.** Each desktop theme sets only the variables it changes and inherits the rest from `:root`, so the generator resolves the full palette per preset. The phone has no fallback values of its own.
- **Gradients are flattened.** Several desktop backgrounds are `radial-gradient`s. The phone draws flat surfaces, so a gradient becomes its middle stop, which keeps the look recognizable. Everything else is the exact value.
- **Terminals follow the look.** A terminal is drawn over the look's `--task-panel-bg` with the ANSI set the desktop pairs with that look, so Midnight gets a pure-black panel and Noir gets Noir's ANSI colors. Dark looks with no set of their own on the desktop fall back to the muted Noir set, because the desktop's fallback there is xterm's own defaults.

Obsidian in both tones is the default, and its values are pinned by `LookPalettesTest`, so adding a theme cannot quietly change what the app looks like out of the box.

## Build

Needs JDK 17+ and the Android SDK (compile SDK 37). Set `ANDROID_HOME` or add `sdk.dir` to `android/local.properties`.

```sh
cd android
./gradlew testDebugUnitTest # unit tests
./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk
./gradlew installDebug # install on a connected device
```

QR scanning uses the Google Play services code scanner, so the app needs no camera permission. On phones without Play services, paste the link instead.

## How it maps to the server

See `electron/remote/server.ts` and `electron/remote/protocol.ts`.

| Step | Request |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| Pair | `POST /api/pair/verify` with `Authorization: Bearer <token>` and `{ pin, remember }`; returns `{ token }` |
| Connect | WebSocket `/ws`; first message `{ type: "auth", token }`. The paired token is used when present |
| Watch | `subscribe` / `unsubscribe`; the server sends `scrollback`, then `output` (base64 PTY bytes) |
| View size | `view-size` with `{ cols, rows }` (paired) while a terminal is open; without them, or on disconnect, the desktop size returns |
| Projects | `GET /api/mobile/projects` (paired) |
| New task | `POST /api/mobile/tasks` with `{ projectId, name, prompt }` (paired); returns `{ taskId }` |
| Usage | `GET /api/mobile/usage`; the desktop status bar's snapshot, readable view-only |
| Notes | `GET` / `PUT /api/mobile/notes/<taskId>` with `{ notes }`; reading works view-only, saving needs pairing |
| Close task | `POST /api/mobile/tasks/<taskId>/close` with `{ force }` (paired); `409` with `{ warnings }` when work would be lost |
| Changes | `GET /api/mobile/tasks/<taskId>/diff` → `{ diff, truncated, unsupported }`; readable view-only |
| Reply | `input` with `submit: true` and a `requestId`; confirmed by `input-result` |
| Close `4001` | Paired token rejected: drop it and reconnect view-only. QR token rejected: scan again |
| Close `4003` | Typing rights lost: drop the paired token |
| HTTP 401 | On a paired-token request: drop the paired token and reconnect view-only |

Remote Access serves plain HTTP on the LAN or Tailscale address, so the app allows cleartext traffic. Credentials live in app-private storage and are excluded from backups and device transfer.
55 changes: 55 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
}

android {
namespace = "com.parallelcode.phone"
compileSdk = 37

defaultConfig {
applicationId = "com.parallelcode.phone"
minSdk = 26
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
}

buildTypes {
release {
// R8 drops unused code and resources; the libraries ship their own keep rules.
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

buildFeatures {
compose = true
}
}

dependencies {
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.material3:material3")
// Look preset rows show a check mark on the selected theme.
implementation("androidx.compose.material:material-icons-core")
// Stop, history and mic buttons: these icons only ship in the extended set.
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
// Installs the baseline profiles Compose ships, so a sideloaded APK starts and scrolls
// compiled rather than interpreted.
implementation("androidx.profileinstaller:profileinstaller:1.4.1")
// Scanner UI comes from Google Play services, so the app needs no camera permission.
implementation("com.google.android.gms:play-services-code-scanner:16.1.0")

testImplementation("junit:junit:4.13.2")
// android.jar only has stubs for org.json; unit tests need the real implementation.
testImplementation("org.json:json:20260814")
}
2 changes: 2 additions & 0 deletions android/app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# App-specific R8 keep rules. OkHttp, Compose and ML Kit bring their own consumer rules, and the
# app reads JSON through org.json (part of Android), so nothing needs keeping here yet.
58 changes: 58 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<!-- Agent notifications: a foreground service keeps the desktop connection open in the background. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />

<!-- Remote Access serves plain HTTP on the LAN or Tailscale address, so cleartext is required.
Backups and device transfer are off so the phone's credentials never leave the device. -->
<!-- Voice input: find the speech recognizer (package visibility, Android 11+). -->
<queries>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>

<application
android:name=".PhoneApplication"
android:allowBackup="false"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="Parallel Code"
android:supportsRtl="true"
android:theme="@style/Theme.ParallelCode"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".AgentWidget"
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_agents_info" />
</receiver>
<service
android:name=".AgentWatchService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Keeps a live connection to the user's own desktop app to notify when a coding agent needs input." />
</service>
</application>
</manifest>
37 changes: 37 additions & 0 deletions android/app/src/main/java/com/parallelcode/phone/AgentNotifier.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.parallelcode.phone

/** An agent change worth a notification. */
enum class AgentEvent { NEEDS_INPUT, ERROR, FINISHED }

data class AgentNotice(val agent: RemoteAgent, val event: AgentEvent)

private val BUSY = setOf("active", "shell_busy")
private val SETTLED = setOf("ready", "review", "idle")

/**
* Turns successive agent lists into notices. The first list is the baseline: agents already
* waiting when watching starts are not announced.
*/
class AgentNotifier {
private var previous: Map<String, RemoteAgent>? = null

fun update(agents: List<RemoteAgent>): List<AgentNotice> {
val before = previous
previous = agents.associateBy { it.agentId }
if (before == null) return emptyList()
return agents.mapNotNull { agent ->
val old = before[agent.agentId] ?: return@mapNotNull null
eventFor(old, agent)?.let { AgentNotice(agent, it) }
}
}
}

internal fun eventFor(old: RemoteAgent, new: RemoteAgent): AgentEvent? = when {
new.collapsed -> null
new.attention == old.attention && new.running == old.running -> null
new.attention == "needs_input" -> AgentEvent.NEEDS_INPUT
new.attention == "error" -> AgentEvent.ERROR
old.running && !new.running -> AgentEvent.FINISHED
old.attention in BUSY && new.attention in SETTLED -> AgentEvent.FINISHED
else -> null
}
Loading