- Kotlin 47.8%
- Rust 42.4%
- HTML 9.8%
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. |
||
|---|---|---|
| android | ||
| deploy | ||
| pc | ||
| site | ||
| .gitignore | ||
| LICENSE | ||
| README.md | ||
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 forFormatNegotiator, 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 redundantVIDIOC_S_FMTcall that always failed withEBUSYwhile a reader like OBS had the device open, silently freezing the stream after every reconnect). Run withcargo testfrompc/.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 realImageProxy, 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 reverseon its own 2-second timer, independent of whatever it's doing otherwise —adb reverseis tied to the USB/adb transport session and silently dies on a replug, and re-asserting it only before blocking onaccept()(the first approach) meant it never got refreshed once already blocked there. - The v4l2loopback format negotiation state (
FormatNegotiatorinpc/src/main.rs) is tracked across reconnects, not reset per-connection — otherwise every reconnect at an already-active resolution re-triggered a doomed-to-failVIDIOC_S_FMTcall (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
sendFrameblocked for however long the OS takes to notice — tens of seconds to minutes of TCP retransmission — while holding the lockdisconnect()also needs, freezing the whole app if the user hit Stop during that window. CaptureServiceholds aPARTIAL_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:
ImageProxytest 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 av4l2loopbackdevice set toMJPG, no transcoding needed) with unit tests. Not verified working yet — self-testing against a real loopback device foundVIDIOC_S_FMTnot honoring the requested width/height for theMJPGfourcc; see the doc comment onPixelFormatinpc/src/main.rs. Needs that resolved against a clean, exclusively-held loopback device before the Android-side JPEG encoder (straightforward viaYuvImage.compressToJpeg, not yet implemented) is worth wiring up. The wire protocol change itself is backward-compatible in behavior (Android still only ever sends theRawtag 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-gradle0.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.0instead of the classicandroid-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/targetSdkare 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 needsjavax.xml.bind, removed from the JDK years ago. Use the moderncmdline-toolspackage'ssdkmanagerinstead (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.