No description
  • Kotlin 47.8%
  • Rust 42.4%
  • HTML 9.8%
Find a file
MeIsGaming 715692de79 feat: v0.2 wire protocol groundwork (MJPEG relay path, not yet verified)
Wire protocol gains a per-frame pixel-format tag so the PC daemon can tell
raw NV21 frames apart from a future compressed pipeline. FormatNegotiator's
key now includes the format, not just size, since switching pipeline at
the same resolution is still a real format change. Added an MJPEG relay
path: v4l2loopback treats each write() as one opaque compressed frame
regardless of size, so a JPEG-encoded frame from the phone could go
straight through with no transcoding -- in theory.

Self-tested against a real v4l2loopback device (synthetic JPEG, no phone
involved) and found VIDIOC_S_FMT not honoring the requested width/height
for MJPG -- documented as an open question on PixelFormat rather than
assumed working. Android-side JPEG encoding intentionally not wired up yet:
no point building on an unconfirmed foundation.

The protocol bump itself is safe today: Android only ever sends the Raw
tag (lib.rs updated to match), so behavior for the shipped v0.1 pipeline
is unchanged.

Also: refreshed the website (uwucam.purrr.chat) with current status and a
download link now that a v0.1.0 release exists.
2026-08-16 20:15:27 +02:00
android feat: v0.2 wire protocol groundwork (MJPEG relay path, not yet verified) 2026-08-16 20:15:27 +02:00
deploy add website (uwucam.purrr.chat), proper build/install docs, make repo public 2026-08-16 19:12:26 +02:00
pc feat: v0.2 wire protocol groundwork (MJPEG relay path, not yet verified) 2026-08-16 20:15:27 +02:00
site feat: v0.2 wire protocol groundwork (MJPEG relay path, not yet verified) 2026-08-16 20:15:27 +02:00
.gitignore build: add release signing config, document release process, ship v0.1.0 2026-08-16 20:05:53 +02:00
LICENSE v0.1 skeleton: PC daemon proves standard V4L2 output ioctls work against v4l2loopback 2026-08-16 18:12:58 +02:00
README.md feat: v0.2 wire protocol groundwork (MJPEG relay path, not yet verified) 2026-08-16 20:15:27 +02:00

uwucam 📸

A DroidCam replacement, built because the AUR version broke against a newer ffmpeg ABI and the Android app hides its resolution setting somewhere we couldn't find. So: doing it ourselves.

PC side is Rust. Android side uses Rust (via NDK/JNI) for the camera + streaming logic, with the unavoidable minimum of Kotlin for the parts Android simply requires in Java-land (Activity entry point, permission requests, AndroidManifest.xml). Anything performance-relevant that can be Rust, is Rust.

Linux-only for now (v4l2loopback as the virtual camera sink). Android-only phone side for now.

Status

v0.1 walking skeleton works end-to-end and has been verified streaming live video against real hardware over USB/adb, including surviving a cable unplug/replug mid-stream (camera keeps running, connection auto-recovers). No releases yet — see Roadmap. If you build it and hit something weird, that's expected at this stage; open an issue.

Testing

  • pc/: unit tests for the NV21→YU12 chroma-deinterleaving logic and for FormatNegotiator, the state machine deciding when the v4l2loopback format actually needs renegotiating (including a regression test for a real bug: reconnecting at the same resolution used to force a redundant VIDIOC_S_FMT call that always failed with EBUSY while a reader like OBS had the device open, silently freezing the stream after every reconnect). Run with cargo test from pc/.
  • android/: JVM unit tests (app/src/test/, run with ./gradlew test) cover the pure resolution math (ResolutionMath.kt) — aspect-ratio bucket selection and default-resolution picking — extracted specifically so they're testable without Robolectric or a device. The Kotlin-side YUV conversion (CaptureService.kt) still isn't covered; it takes a real ImageProxy, so it'd need either Robolectric or extracting the plane-copy logic further. Tracked as a roadmap item, not skipped on purpose.

Architecture

[ Android: Camera2/CameraX capture ] --USB (adb reverse)--> [ PC: Rust daemon ] --> /dev/videoN (v4l2loopback) --> OBS/anything

Use the plain v4l2loopback kernel module, not the v4l2loopback-dc ("DroidCam-compatible") fork. The DC fork exists because DroidCam's client needs custom, non-standard ioctls to renegotiate format on the fly — but that's only necessary if you're talking to DroidCam's protocol. uwucam controls both ends of the wire, so it just uses plain VIDIOC_S_FMT, which the regular module supports fine and which every standard V4L2 tool (and the v4l Rust crate) already knows how to speak.

Reconnect handling (both unplugging the cable and stopping/restarting the app are meant to just work):

  • The PC daemon refreshes adb reverse on its own 2-second timer, independent of whatever it's doing otherwise — adb reverse is tied to the USB/adb transport session and silently dies on a replug, and re-asserting it only before blocking on accept() (the first approach) meant it never got refreshed once already blocked there.
  • The v4l2loopback format negotiation state (FormatNegotiator in pc/src/main.rs) is tracked across reconnects, not reset per-connection — otherwise every reconnect at an already-active resolution re-triggered a doomed-to-fail VIDIOC_S_FMT call (see Testing above).
  • On the Android side, connection failures (initial or mid-stream) never tear down the foreground service; a background loop keeps retrying while the camera keeps running, dropping frames until reconnected.
  • The socket write from phone to PC has a 2-second timeout. Without one, a dead connection (cable pulled) could leave sendFrame blocked for however long the OS takes to notice — tens of seconds to minutes of TCP retransmission — while holding the lock disconnect() also needs, freezing the whole app if the user hit Stop during that window.
  • CaptureService holds a PARTIAL_WAKE_LOCK (CPU only, not the screen) so the reconnect loop and camera pipeline don't stall if the screen locks. Deliberately not a screen-on lock — that would cost more power than it saves.

Roadmap

  • v0.1 — walking skeleton: one video pipeline (raw NV21) end-to-end over USB/adb, proving camera → PC → v4l2loopback → OBS actually works, resilient to cable unplug/replug and stop/restart. Done, verified with live video against real hardware, released. Still open: ImageProxy test coverage, Android instrumented tests.
  • v0.2: add MJPEG and H264 pipelines, benchmark harness (fps/latency/CPU), pick the best as default, keep others as flags. 🚧 In progress: wire protocol extended with a per-frame pixel-format tag (pc/src/main.rs), PC daemon has an MJPEG relay path (write the phone's already-JPEG-encoded frames straight to a v4l2loopback device set to MJPG, no transcoding needed) with unit tests. Not verified working yet — self-testing against a real loopback device found VIDIOC_S_FMT not honoring the requested width/height for the MJPG fourcc; see the doc comment on PixelFormat in pc/src/main.rs. Needs that resolved against a clean, exclusively-held loopback device before the Android-side JPEG encoder (straightforward via YuvImage.compressToJpeg, not yet implemented) is worth wiring up. The wire protocol change itself is backward-compatible in behavior (Android still only ever sends the Raw tag today) and safe to build from source now.
  • v1.0.0: WiFi/TCP transport option.
  • Later: ios/, macos/, windows/ — added when work on them actually starts, not before.

Known issues

  • org.mozilla.rust-android-gradle 0.9.6 (latest published) internally uses a couple of Gradle APIs deprecated for removal in Gradle 9.0 (CopyProcessingSpec.setFileMode, Project.exec). Nothing to fix on our end until upstream updates; not blocking today.
  • If your distro ships an Android SDK with decimal platform naming (e.g. android-37.0 instead of the classic android-37) at a read-only system path, AGP chokes on it with "inconsistent location" / "Failed to find Platform SDK" errors that a symlink alone won't fix — the repository metadata inside the platform directory has to agree with its own path, and you can't edit that on a read-only mount. Simplest fix: install your own user-owned SDK (see Building below) rather than fighting the system one. compileSdk/targetSdk are pinned to 35 for broad AGP compatibility, not because anything here needs a newer API level.
  • The legacy tools/bin/sdkmanager (if your system SDK still has one) doesn't run on Java 17 at all — it needs javax.xml.bind, removed from the JDK years ago. Use the modern cmdline-tools package's sdkmanager instead (see Building).

Building

PC daemon

Normal Rust crate, needs v4l2loopback (the plain module, not v4l2loopback-dc — see Architecture above for why) loaded:

cd pc
cargo build --release
cargo test        # NV21->YU12 conversion unit tests

Android app

You need: a JDK (17+), the Android SDK (platform 35 + build-tools 35.0.0), the Android NDK, a Rust aarch64-linux-android target, and cargo-ndk. If your distro's system-wide Android SDK is awkward to build against (see Known issues), install your own:

# NDK + Rust side
rustup target add aarch64-linux-android
cargo install cargo-ndk
# get your distro's NDK however it prefers (AUR: android-ndk, elsewhere: sdkmanager or
# https://developer.android.com/ndk/downloads) and note where it lands, e.g. /opt/android-ndk

# a user-owned SDK, sidestepping any system SDK weirdness entirely
mkdir -p ~/Android/sdk/cmdline-tools
curl -sL -o /tmp/cmdline-tools.zip \
  "https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip"
unzip -q /tmp/cmdline-tools.zip -d ~/Android/sdk/cmdline-tools/
mv ~/Android/sdk/cmdline-tools/cmdline-tools ~/Android/sdk/cmdline-tools/latest
yes | ~/Android/sdk/cmdline-tools/latest/bin/sdkmanager \
  --sdk_root=$HOME/Android/sdk "platforms;android-35" "build-tools;35.0.0" "platform-tools"

Then point the project at your SDK/NDK and build:

cd android
echo "sdk.dir=$HOME/Android/sdk" > local.properties
# adjust android.ndkPath in app/build.gradle.kts if your NDK isn't at /opt/android-ndk
./gradlew assembleDebug

Running it

adb install -r android/app/build/outputs/apk/debug/app-debug.apk
./pc/target/release/uwucam-pc --device /dev/videoN --port 4748   # pick a free v4l2loopback device

The daemon sets up adb reverse itself (and keeps re-asserting it on a timer, so unplugging and replugging the cable doesn't require restarting anything on the PC side) — no need to run adb reverse by hand.

Open the app, pick a resolution (1280x720-ish is the sane default — raw NV21 at your phone's max resolution will overwhelm adb's transfer rate, see Known issues history in the commit log if curious why that matters), hit Start. Add the resulting device in OBS (or anywhere else) as a "Video Capture Device (V4L2)" source.

Changing resolution while a viewer (e.g. OBS) has the source open won't work — that's not a uwucam bug, it's how v4l2loopback works: once something has the device open reading one format, it won't let a writer switch to a different one (EBUSY) until that reader lets go. Close/remove and re-add the source (or restart the viewer) after changing resolution in the app.

Releases

Prebuilt binaries are on the Releases page for people who'd rather not build from source. Building it yourself (above) is still the recommended path at this stage — releases are provided for convenience, not polish.

The Android app needs a signing key to produce an installable release APK. If you're building your own release rather than using the prebuilt one, an unsigned release APK still builds fine (./gradlew assembleRelease) but Android won't let you install it until it's signed. To sign your own:

keytool -genkeypair -v -keystore ~/.android/uwucam-release.jks -alias uwucam \
  -keyalg RSA -keysize 2048 -validity 10000

cat > android/keystore.properties <<EOF
storeFile=$HOME/.android/uwucam-release.jks
storePassword=<the password you just chose>
keyAlias=uwucam
keyPassword=<same password>
EOF

keystore.properties is gitignored on purpose — it's a credential, never commit it. Once it exists, ./gradlew assembleRelease produces a signed app/build/outputs/apk/release/app-release.apk. Keep the keystore itself somewhere safe: losing it means future releases can't be installed as upgrades over an existing install (Android requires matching signatures), only side-by-side after uninstalling.

License

GPLv3 — see LICENSE.