Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f8ed67b
feat(android): native phone app for Remote Access
BigWebstas Sep 29, 2026
9e3f57c
feat(android): widget card transparency and color
BigWebstas Sep 30, 2026
91bb1ba
feat(remote): add phone diff review and task lifecycle
BigWebstas Sep 30, 2026
5d73b0a
feat(android): merge tasks from the phone with readiness checks
BigWebstas Sep 30, 2026
6381b17
feat(android): ship the desktop's look presets on the phone
BigWebstas Sep 30, 2026
87adeac
Merge branch 'task/lets-look-at-the-android-app-and-8e5115' into feat…
BigWebstas Sep 30, 2026
c619091
fix(remote): explain a phone diff for a task with no branch
BigWebstas Sep 30, 2026
5eca4c8
feat(android): host quick-switch, composer upgrades, connection labels
BigWebstas Oct 1, 2026
5e7eb47
feat(android): add VPN check and wait-to-connect support
BigWebstas Oct 1, 2026
34ac767
test(mermaid): increase render error timeout to avoid flakiness under…
BigWebstas Oct 1, 2026
1fe608c
feat(android): skip waiting for VPN on home Wi-Fi
BigWebstas Oct 3, 2026
fbe7015
ci(android): build, test, and release the phone app
BigWebstas Oct 3, 2026
e6241fb
feat(remote): show agent subscription usage on the phone
BigWebstas Sep 28, 2026
e3882c1
test(ipc): expect renderer-bound usage request channel
BigWebstas Sep 28, 2026
99f520b
feat(git): add commit dialog with stage-all next to merge
BigWebstas Oct 3, 2026
4034675
feat(git): add unstage all to the commit dialog
BigWebstas Oct 4, 2026
24847bf
feat(remote): let paired phones stage, unstage and commit
BigWebstas Oct 4, 2026
63e4deb
feat(android): commit, stage all and unstage all from the phone
BigWebstas Oct 4, 2026
1b0a992
Merge branch 'feat/android-phone-app' into android-app-interfacing
BigWebstas Oct 4, 2026
b35391b
fix(remote): send phones the full terminal history
BigWebstas Oct 4, 2026
5c02518
feat(android): wrap long terminal lines instead of scrolling sideways
BigWebstas Oct 4, 2026
6e9a18c
Merge pull request #13 from BigWebstas/task/agents-terminal-history-d…
BigWebstas Oct 4, 2026
7a42fb8
fix(android): return to the task list after closing a task
BigWebstas Oct 4, 2026
6142119
feat(android): back up and restore phone data
BigWebstas Oct 4, 2026
5576d37
feat(android): widget totals across computers, with icon and status dot
BigWebstas Oct 4, 2026
040057f
fix(android): draw the widget divider with an ImageView
BigWebstas Oct 4, 2026
32d5386
Merge pull request #14 from BigWebstas/task/agents-terminal-history-d…
BigWebstas Oct 4, 2026
c067003
Merge remote-tracking branch 'upstream/main' into feat/android-phone-app
BigWebstas Oct 4, 2026
9077399
Merge remote-tracking branch 'upstream/main' into android-app-interfa…
BigWebstas Oct 4, 2026
296a5d6
Merge remote-tracking branch 'origin/feat/android-phone-app' into and…
BigWebstas Oct 4, 2026
554d5a8
fix(android): spell out "computers" in the widget headline
BigWebstas Oct 4, 2026
811c59c
Merge remote-tracking branch 'origin/feat/android-phone-app' into and…
BigWebstas Oct 4, 2026
cf66dc3
feat(remote): support agent selection for mobile task creation
BigWebstas Oct 8, 2026
b4c5c84
Merge branch 'main' into android-app-interfacing
BigWebstas Oct 8, 2026
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
79 changes: 79 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Android

# Tests and builds the phone app when it changes. Pushing an android-v* tag (for example
# android-v0.2.0) also publishes a signed APK as a GitHub release, separate from desktop v* releases.
on:
push:
branches: [main, feat/android-phone-app]
tags: ['android-v*']
paths: ['android/**', '.github/workflows/android.yml']
pull_request:
paths: ['android/**', '.github/workflows/android.yml']

permissions:
contents: read

defaults:
run:
working-directory: android

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: temurin
java-version: '21'

- uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0

- name: Test and build
run: ./gradlew testDebugUnitTest assembleDebug

release:
if: startsWith(github.ref, 'refs/tags/android-v')
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: temurin
java-version: '21'

- uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0

- name: Decode signing key
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: echo -n "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/release.jks"

- name: Build signed APK
env:
ANDROID_KEYSTORE_PATH: ${{ runner.temp }}/release.jks
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
VERSION_CODE: ${{ github.run_number }}
run: |
VERSION="${GITHUB_REF_NAME#android-v}"
./gradlew assembleRelease -PversionName="$VERSION" -PversionCode="$VERSION_CODE"
mv app/build/outputs/apk/release/app-release.apk "$RUNNER_TEMP/parallel-code-phone-$VERSION.apk"

# Not marked latest, so the desktop auto-updater keeps finding the desktop release.
- name: Publish release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
name: Android ${{ github.ref_name }}
files: ${{ runner.temp }}/parallel-code-phone-*.apk
make_latest: 'false'

- name: Remove signing key
if: always()
run: rm -f "$RUNNER_TEMP/release.jks"
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
87 changes: 87 additions & 0 deletions android/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 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, wait for VPN (skipped on your home Wi-Fi, which needs location access to read the network name), 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
```

### Releases

`.github/workflows/android.yml` tests and builds the app whenever `android/` changes. Pushing a tag such as `android-v0.2.0` also publishes a signed APK as a GitHub release, kept separate from the desktop's `v*` releases. Signing reads `ANDROID_KEYSTORE_PATH`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, and `ANDROID_KEY_PASSWORD` from the environment; CI fills them from repository secrets of the same names, with the keystore stored base64-encoded as `ANDROID_KEYSTORE_BASE64`. Every update must be signed with the same key, so keep a backup of it.

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.
71 changes: 71 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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
// CI passes these from the android-v* release tag (see .github/workflows/android.yml).
versionCode = providers.gradleProperty("versionCode").orNull?.toInt() ?: 1
versionName = providers.gradleProperty("versionName").orNull ?: "0.1.0"
}

// Release signing comes from the environment, so the keystore never enters the repo. Without
// it, release builds are unsigned.
val keystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
signingConfigs {
if (keystorePath != null) {
create("release") {
storeFile = file(keystorePath)
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
}
}
}

buildTypes {
release {
signingConfigs.findByName("release")?.let { signingConfig = it }
// 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.
69 changes: 69 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Home Wi-Fi: Android only reveals the Wi-Fi name to apps with location access. -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- 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" />
<!-- Scheduled backups survive a reboot (a persisted JobScheduler job). -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

<!-- 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>
<service
android:name=".BackupJobService"
android:exported="false"
android:permission="android.permission.BIND_JOB_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