---
name: build-coreapp
version: 1.0.0
description: |
  Build, test, and run CoreApp (Kotlin Multiplatform / Compose Multiplatform).
  Use when asked to "build", "set up", "run the app", "test", "install",
  "launch emulator", "gradle sync", or any Android/iOS build task for CoreApp.
  Covers fresh machine setup, dependency installation, Gradle builds,
  emulator management, and troubleshooting common build failures.
allowed-tools:
  - Bash
  - Read
  - Write
  - Edit
  - Glob
  - Grep
  - WebSearch
  - AskUserQuestion
---

# CoreApp Build & Test Skill

## Project Overview

CoreApp is a **Kotlin Multiplatform (KMP)** project using **Compose Multiplatform** targeting Android, iOS, and Desktop. The main app module is `:composeApp` located at `public/composeApp/`.

Key facts:
- **Package**: `coredevices.coreapp`
- **Activity**: `coredevices.coreapp.MainActivity`
- **Gradle**: 8.14.4 (wrapper)
- **Kotlin**: 2.3.10
- **AGP**: 8.13.2
- **JVM toolchain**: 17
- **compileSdk**: 36, **minSdk**: 26, **targetSdk**: 35
- **Compose Multiplatform**: 1.10.1

## Prerequisites

### Java 17 (Amazon Corretto recommended)

```bash
# Linux x64
wget -q https://corretto.aws/downloads/latest/amazon-corretto-17-x64-linux-jdk.tar.gz -O /tmp/corretto-17.tar.gz
sudo mkdir -p /usr/lib/jvm
sudo tar -xzf /tmp/corretto-17.tar.gz -C /usr/lib/jvm/
# Find the extracted directory name
CORRETTO_DIR=$(ls -d /usr/lib/jvm/amazon-corretto-17* | head -1)
export JAVA_HOME=$CORRETTO_DIR
export PATH=$JAVA_HOME/bin:$PATH
# Persist
echo "export JAVA_HOME=$CORRETTO_DIR" >> ~/.bashrc
echo 'export PATH=$JAVA_HOME/bin:$PATH' >> ~/.bashrc
```

```bash
# macOS ARM64
wget https://corretto.aws/downloads/latest/amazon-corretto-17-aarch64-macos-jdk.tar.gz
mkdir -p ~/Library/Java/JavaVirtualMachines
tar -xzf amazon-corretto-17-aarch64-macos-jdk.tar.gz -C ~/Library/Java/JavaVirtualMachines/
export JAVA_HOME=~/Library/Java/JavaVirtualMachines/amazon-corretto-17.*/Contents/Home
```

### Android SDK

```bash
# Linux: Install command-line tools
export ANDROID_HOME=$HOME/Android/Sdk
mkdir -p $ANDROID_HOME/cmdline-tools
wget -q "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip" -O /tmp/cmdline-tools.zip
unzip -q /tmp/cmdline-tools.zip -d /tmp/cmdline-tools-tmp
mv /tmp/cmdline-tools-tmp/cmdline-tools $ANDROID_HOME/cmdline-tools/latest
export PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH

# Accept licenses
yes | sdkmanager --licenses

# Install required components
sdkmanager --install \
  "platform-tools" \
  "platforms;android-36" \
  "platforms;android-35" \
  "build-tools;36.0.0" \
  "build-tools;35.0.0" \
  "emulator" \
  "system-images;android-35;google_apis;x86_64"

# Persist
echo "export ANDROID_HOME=$HOME/Android/Sdk" >> ~/.bashrc
echo 'export PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH' >> ~/.bashrc
```

### Android Studio (optional, for IDE usage)

Download latest from https://developer.android.com/studio and extract to `/opt/android-studio`.

**Important**: Use Java 17 JDK in Android Studio: Settings > Build, Execution and Deployment > Build Tools > Gradle > Gradle JDK.

## Initial Setup

### 1. Clone and pull

```bash
cd /path/to/CoreApp
git pull
git submodule update --init --recursive
```

Note: The `public/` directory is a git subrepo (not a submodule). The submodule command is a no-op but safe to run.

### 2. Create local.properties

Create `local.properties` in the project root with:

```properties
sdk.dir=/path/to/Android/Sdk
github.username=YOUR_GITHUB_USERNAME
github.token=YOUR_GITHUB_TOKEN
```

The GitHub token must be a **classic token** with `repo` and `read:packages` scopes. This is required for Gradle to pull dependencies from GitHub Packages.

On macOS, also add CocoaPods path for iOS builds:
```properties
kotlin.apple.cocoapods.bin=/opt/homebrew/lib/ruby/gems/4.0.0/bin/pod
```

## Building

### Android Debug Build

```bash
export JAVA_HOME=/path/to/corretto-17
export ANDROID_HOME=$HOME/Android/Sdk
export PATH=$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$PATH

./gradlew :composeApp:assembleDebug
```

Output APK: `public/composeApp/build/outputs/apk/debug/composeApp-*-debug.apk`

### Install on Device/Emulator

```bash
./gradlew :composeApp:installDebug
```

### Desktop Build

```bash
./gradlew :desktopAITool:run
```

## Emulator Management

### Create AVD

```bash
# Pixel 7 with API 35 + Google Play Store (recommended for app testing)
echo "no" | avdmanager create avd \
  -n "Pixel_7_PlayStore" \
  -k "system-images;android-35;google_apis_playstore;x86_64" \
  -d "pixel_7" --sdcard 512M

# Pixel 6 with API 35 (lightweight, no Play Store)
echo "no" | avdmanager create avd \
  -n "Pixel_API_35" \
  -k "system-images;android-35;google_apis;x86_64" \
  -d "pixel_6" --sdcard 512M
```

Install the Play Store system image if needed:
```bash
sdkmanager --install "system-images;android-35;google_apis_playstore;x86_64"
```

**Important**: Pixel 9 device definition is NOT available in current cmdline-tools. Use `pixel_7` or `pixel_6` instead.

**NEVER use `-wipe-data` on an emulator.** This destroys all user data including Google account sign-ins, installed apps, and app logins. There is no way to recover wiped data. If you need a fresh emulator, create a new AVD instead.

### AVD Configuration Tips

Edit `~/.android/avd/<name>.avd/config.ini` for these quality-of-life settings:
```ini
hw.keyboard = yes          # Accept host keyboard input
hw.lcd.depth = 32          # Full 32-bit color depth
hw.camera.front = emulated # Enable front camera
showDeviceFrame = no       # Remove phone bezel for more screen space
```

**Note**: `hardware-qemu.ini` is auto-regenerated on each launch and overrides `config.ini` for some values (like RAM). Use command-line flags to override instead.

### Start Emulator

```bash
# Linux (need KVM access and DISPLAY)
sudo chmod 666 /dev/kvm  # or: sudo usermod -aG kvm $USER (requires re-login)

# Find active X display (e.g., for VNC sessions)
ls /tmp/.X11-unix/  # Shows X0, X1, etc.

# Launch emulator
DISPLAY=:1 emulator -avd Pixel_7_PlayStore \
  -gpu swangle \
  -memory 4096 \
  -no-boot-anim &

# Wait for boot
adb wait-for-device
while [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do sleep 2; done
echo "Emulator ready"

# Disable animations for snappier interaction
adb shell settings put global window_animation_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global animator_duration_scale 0
```

### Emulator on Proxmox / Virtualized Hosts

When running inside a VM (Proxmox, QEMU, etc.):

- **GPU**: Use `-gpu swangle` (ANGLE + SwiftShader). Avoid `swiftshader_indirect` (causes `bad color buffer handle` crashes) and `guest` (unstable under load).
- **RAM**: Play Store images cap guest RAM at 2048MB by default. The `hardware-qemu.ini` override gets regenerated on launch. Use `-memory 4096` flag to force higher RAM. This is critical for heavy apps like Beeper which OOM-crash the emulator at 2GB.
- **KVM**: Required. Ensure `/dev/kvm` is accessible: `sudo chmod 666 /dev/kvm` or add user to kvm group.
- **Display**: The emulator needs an X display. For headless/VNC servers, find the display with `ls /tmp/.X11-unix/` and set `DISPLAY=:1` (or whichever is active).
- **Stability**: The emulator may crash under heavy graphics load. If it does, reduce screen resolution in config.ini (`hw.lcd.width`/`hw.lcd.height`) or switch GPU modes.

### Launch App

```bash
adb shell am start -n coredevices.coreapp/coredevices.coreapp.MainActivity
```

### Take Screenshot

```bash
adb exec-out screencap -p > screenshot.png
```

### UI Interaction (adb)

```bash
# Tap at coordinates
adb shell input tap X Y

# Type text
adb shell input text "search query"

# Press keys
adb shell input keyevent KEYCODE_ENTER
adb shell input keyevent KEYCODE_BACK

# Dump UI hierarchy (find element bounds)
adb shell uiautomator dump /sdcard/ui.xml
adb shell cat /sdcard/ui.xml | sed 's/></>\n</g' | grep "text="

# Screen resolution
adb shell wm size
```

## Troubleshooting

### "Could not resolve" dependency errors
Verify `github.username` and `github.token` in `local.properties`. Token needs `repo` + `read:packages` scopes.

### CocoaPods not found (macOS/iOS only)
Add to `local.properties`:
```properties
kotlin.apple.cocoapods.bin=/opt/homebrew/lib/ruby/gems/4.0.0/bin/pod
```

### Emulator: "grant KVM access"
```bash
sudo chmod 666 /dev/kvm  # immediate fix, no re-login needed
# Or persistent: sudo usermod -aG kvm $USER (requires re-login)
```

### Emulator: no DISPLAY / Qt platform plugin error
```bash
# Find active X display (VNC/GUI session)
ls /tmp/.X11-unix/  # Shows X0, X1, etc.
DISPLAY=:1 xdpyinfo  # Test which works
# Then launch with: DISPLAY=:1 emulator ...
```

### Emulator: crashes under load / "bad color buffer handle"
Switch GPU mode: use `-gpu swangle` instead of `-gpu auto` or `-gpu swiftshader_indirect`. Also ensure enough guest RAM with `-memory 4096`.

### Emulator: Play Store image caps RAM at 2GB
The `hardware-qemu.ini` is regenerated on each launch, resetting RAM to 2048MB. Use `-memory 4096` command-line flag to override.

### Build OOM
The project uses `-Xmx8g` for the Gradle daemon. Ensure at least 16GB RAM. If still failing:
```bash
./gradlew :composeApp:assembleDebug --no-daemon
```

### "Digital Wellbeing isn't responding" on emulator
Dismiss with: `adb shell input tap 540 1315` (tap "Wait" or "Close app")

## Module Reference

| Module | Path | Description |
|--------|------|-------------|
| :composeApp | public/composeApp | Main multiplatform app |
| :experimental | public/experimental | Experimental features (Ring) |
| :libpebble3 | public/libpebble3 | Pebble hardware library |
| :pebble | public/pebble | Pebble protocol |
| :util | public/util | Shared utilities |
| :index-ai | public/index-ai | AI indexing |
| :cactus | public/cactus | Cactus module |
| :mcp | public/mcp | Model Context Protocol |
| :desktopAITool | desktopAITool | Desktop Compose app |
| :resampler | public/resampler | Audio resampling |
| :krisp-stubs | public/krisp-stubs | Krisp audio stubs |
| :blobannotations | public/blobannotations | Blob annotations |
| :blobdbgen | public/blobdbgen | DB generation |

## Development discipline

These rules come from real review feedback (sjp4 on PR #56, crc-32 on
PRs #17–#32). They override your defaults — when in doubt, follow them
even if it feels excessive.

### Keep PRs small enough to actually review

CoreApp is ~100,000 Kotlin lines total, accumulated over a year of
work. A PR that adds 10,000+ lines is asking the reviewer to absorb
10% of the codebase in one sitting — that is not sensible.

**Hard target: stay under ~1,500 lines of net Kotlin per PR.**
**Soft target: under 800.** If you're approaching either ceiling,
stop and split.

When a feature genuinely needs more than that:

- Split by **stacked PRs** — data layer first (entities + DAOs +
  repositories + services), UI second (screens + ViewModels +
  components). Each PR is mergeable on its own.
- Split by **layer of the MVC** — never bundle data-model changes,
  business logic, and UI in one PR.
- Split by **feature flag** — land the data layer behind a flag,
  iterate the UI in follow-ups.
- If the PR is exploratory and the right answer isn't clear yet,
  open it as a **draft** and STOP coding after the architectural
  shape is sketched. Wait for engineering input before adding the
  next 800 lines.

When you find yourself making a non-obvious architectural decision
(new sync service, new theme, new top-bar pattern, mirroring a remote
DB locally, parallel deserialization paths, etc.), STOP and surface
the decision to the human BEFORE building 1,000 lines on top of it.
Decisions made in isolation become very expensive to unwind.

### Do not bundle unrelated changes

Every file in your diff must be load-bearing for the PR's stated
purpose. Steve will (correctly) reject anything else.

**Categorically forbidden in feature PRs:**

- `gradle.properties` tweaks. If you need a property, justify it in a
  separate PR.
- `build.gradle.kts` dependency bumps that aren't required by the
  feature. Don't sneak in a Kotlin / Compose / library upgrade.
- Touching the **shared chrome** (top bar, nav host, navigation
  routes, splash, theme stack) for a feature scoped to one screen.
  If your screen needs different chrome, find a way to express that
  through the existing chrome's parameters, not by forking it.
- Refactoring code outside your feature's scope ("while I was here…").

If you find yourself editing `public/pebble/`, `public/composeApp/`,
`public/util/`, or anything in `public/experimental/` outside your
feature's package, that's a strong signal to back out the change and
open a separate PR or work it through with engineering first.

### Don't commit working files

The repo isn't your scratchpad. These belong in `~/Documents/` or a
local note tool, never in git:

- `PLAN.md`, `TODOs.md`, `NOTES.md`, design dump JSON, screenshots,
  prototype directories. **Add the directory to `.gitignore` and
  archive it locally.**
- One-off Python dump scripts go in `scripts/` with a docstring and
  no hardcoded paths (no `/Users/eric/Downloads/...`). Use CLI
  arguments or env vars.

If a config file (e.g. `firebase.json`, `firestore.rules`) genuinely
belongs in the repo, put it in a named subdirectory (`/firestore/`,
`/infra/`, etc.) with a README explaining what it is and how to use
it. Don't drop it at the repo root.

### Stop over-commenting

Comments that explain WHY a non-obvious decision was made are good.
Comments that narrate WHAT the code does, or that document the
author's thought process, are noise that goes stale fast. Steve's
review of PR #56 flagged dozens of these.

**Remove these patterns before commit:**

- ASCII art diagrams of UI layouts ("`┌──┐ ┌──┐`"). The screen will
  redesign three times before the comment updates.
- References to a "prototype" / "JSX prototype" / "v0 design" /
  whatever upstream you ported from. Future readers don't have that
  context and don't care.
- Multi-paragraph KDoc on Koin module bindings (`singleOf(::Foo)`).
  The class name is the documentation.
- "Mirrors the X pattern" — if it actually mirrors something, the
  similarity is self-evident; if it doesn't quite, the comment is
  wrong.
- Section-of-the-day comments ("`// ── Top bars + small bits ──`")
  inside files. If a file needs section dividers it should be split
  into multiple files.
- Long block comments inside `@Composable` functions explaining
  Compose recomposition behaviour. Trust Compose; comment only the
  surprises.
- Self-congratulatory "`// CRITICAL FIX: …`", "`// ROOT CAUSE FIX:
  …`", "`// IMPORTANT: …`" preambles. Just write the fix.

A useful test: would a reviewer skip your comment because it doesn't
tell them anything they couldn't infer from the code? Then delete it.

### Don't duplicate types or code

If you find yourself writing parallel data classes (e.g.
`LenientRecordingDocument` mirroring `RecordingDocument`), helpers
that already exist (three copies of `relativeTime()` across three
files), or two ways to do the same thing (a new `FeedHistorySyncService`
alongside the existing recording auto-upload observer) — STOP.

Duplication is almost always the wrong answer:

- **Tolerant deserialization**: don't fork the type. Make the strict
  type's fields nullable and validate post-load — same code path,
  one type, one place to maintain.
- **Format helpers**: hoist to a shared `internal` function in the
  package, or put it as an extension on the data class. Never
  copy-paste.
- **Sync paths**: there is one auto-upload observer in
  `RecordingProcessingQueue.init` that watches a Room flow, debounces,
  and syncs to Firestore. Use that pattern (or the same observer,
  generalized) for any new collection. Do not write a parallel
  `XxxSyncService`.
- **Themes**: the app has one Material 3 theme. Don't fork it for
  your feature. If a screen needs different colors, parameterize the
  existing theme.
- **Top-bar / nav scaffolding**: extend the parameters of the
  existing `WatchHomeScreen` / `TopBarParams` system. Don't bolt
  your own nav chrome onto a single screen.

### Question Room mirrors of Firestore data

Firestore already provides offline persistence, conflict resolution
via document-level timestamps, and snapshot listeners. Before
introducing a Room mirror of a Firestore collection, justify:

- What does Room buy you that Firestore offline doesn't?
- What's the migration cost when the Firestore schema changes?
- How do you reconcile two writers (your timestamp-merge code is
  100 lines per collection)?

If the answer is "we already mirror recordings in Room", check
whether that mirror was the right call to begin with — sometimes
the existing pattern is the legacy, not the standard.

### Use existing abstractions at the correct DI scope

CoreApp uses Koin DI extensively. Before writing new code, search
for existing services and utilities that already do what you need.
If something feels like it should exist, it probably does. Per-watch
state belongs in per-watch services; singleton state belongs in
singletons. Putting state at the wrong scope is a common reviewer
flag (see crc-32 PR #17–#32 feedback).

### Match the rest of the app's UX patterns

When implementing a screen behavior, look at how other screens do
the same thing. Examples from PR #56:

- **Tab switching**: when the user switches tabs and comes back, the
  scroll position is preserved. Re-tapping the same tab scrolls to
  top. Don't invent a different pattern for your tab.
- **Compose-bar / input position**: existing screens lift the input
  above the keyboard via the parent scaffold's `imePadding()`. Don't
  reinvent.
- **Status-bar inset handling**: the chrome handles status bars.
  Don't pad your screen separately.

### Verify iOS compiles before pushing

The `compileKotlinIosArm64` target catches expect/actual mismatches,
missing test-mock overrides, and cinterop drift. Run it locally
before every push:

```bash
./gradlew :experimental:compileKotlinIosArm64
```

Don't push and let CI tell you. CI cycles are 6+ minutes and will
test reviewer patience.

### Run pre-commit review with codex

Before committing finished work, run:

```bash
codex exec "Review this diff for bugs, logic errors, security issues, \
  and missed edge cases. Focus on correctness issues, not style. \
  Here is the diff: $(git diff)" --full-auto
```

Surface the codex feedback to the user. Don't silently fix or
ignore findings — let the human decide which (if any) to address.

## Self-Hosted CI Runner

See `CLAUDE.md` in project root for full GitHub Actions runner setup on the Mac Mini (100.126.148.125). Key differences from local dev:
- Runner uses `~/actions-runner/.env` and `.path` files, NOT shell rc files
- `local.properties` must exist at `~/actions-runner/_work/CoreApp/CoreApp/local.properties`
- Runner labels: `[self-hosted, macOS, ARM64]`
