No description
  • Rust 71.7%
  • Kotlin 22.1%
  • Shell 4.3%
  • HTML 1.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
MeIsGaming d6f967d845
All checks were successful
mirror-github / push (push) Successful in 11s
Merge pull request #1 from MeIsGaming/claude/sweet-dijkstra-i4u8h1
Handshake/frame timeouts and cable-only pairing on the phone
2026-09-28 00:27:50 +02:00
.forgejo/workflows docs: mirror setup comment reflects the workflow-scoped gh token 2026-09-27 16:49:20 +02:00
.github/workflows docs: sync-forgejo setup comment reflects the token in use 2026-09-27 16:50:14 +02:00
android Pair over the cable only, from the phone's side too 2026-09-27 15:05:52 +00:00
deploy wifi streaming is the default, and the unit file says so out loud 2026-09-08 04:26:54 +02:00
docs status: #49 is a deploy bug, fixed and verified on the PC side 2026-09-08 04:39:16 +02:00
pc Bound the handshake as a whole and reads inside a frame 2026-09-27 15:05:51 +00:00
proto Bound the handshake as a whole and reads inside a frame 2026-09-27 15:05:51 +00:00
site seo: speaking title, og/twitter block, og image, robots and sitemap 2026-09-07 13:00:55 +02:00
tools wifi streaming is the default, and the unit file says so out loud 2026-09-08 04:26:54 +02:00
.gitignore fifteen a second, and a screen that is actually black 2026-08-24 02:01:44 +02:00
CLAUDE.md what the review's own tests were not saying 2026-08-24 00:06:02 +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 status: #49 is a deploy bug, fixed and verified on the PC side 2026-09-08 04:39:16 +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.

Running it

cargo run --release --bin uwucam-pc                    # the daemon (WiFi + beacon on)
cargo run --release --bin uwucam-pc -- --list-devices  # which device, and who has it
cargo run --release --bin uwucam-pc -- --no-wifi       # cable only: loopback, no beacon
cargo run --release --bin uwucam-pc -- --fingerprint   # which PC this is, as the phone sees it
cargo run --release --features gui --bin uwucam-gui    # the same thing in a window

Updating both halves

tools/roll-out.sh

Since v0.3 the two halves speak one protocol, and a daemon refuses an older app — politely, with a message, and with no picture. So the order matters: test, build, put the app on the phone, then replace the binary the running daemon uses, restart it, and check that a frame actually arrives. That order is what the script is; doing the daemon first leaves a working setup broken for as long as it takes somebody to notice.

Pairing, and WiFi

Plug the phone in and press Start once. That is the pairing: the PC hands a key down the cable, both sides store it, and neither asks anything. From then on the phone can connect over WiFi — pick WiFi in the app, type the address the PC shows, press Start.

Pairing is granted only over the cable. adb reverse delivers the phone's traffic from loopback, so "came from loopback" means "came down a cable somebody plugged in", and that is a channel that is trusted for a physical reason rather than a cryptographic one. A pairing request from the network is refused and logged — there is no code to type, and nothing to intercept, because the key never crosses the network.

One boundary is worth spelling out rather than leaving implicit: the gate asks adb whether a usable device is attached, and any usable device counts — not only the phone running the app. The gate verifies the cable, not the model, because anyone able to plug in a headset can just as well plug in a phone; a model heuristic would buy nothing and could only be wrong. So the security boundary is physical access to this machine's USB ports while the daemon runs. Whoever has that can pair; nobody without it can, and nothing that arrives over the network can.

Every connection after that proves both sides still hold that key and derives a fresh session key from it, and every frame travels sealed with ChaCha20-Poly1305. Two things follow, and both are the point: something sitting on the port cannot get a picture, and something sitting on the network cannot read one.

WiFi is on by default (--no-wifi turns it off, or the checkbox in the window). This is the #49 fix: the shipped service unit once started the daemon without these flags, so it bound loopback only and no phone could ever reach it — "WLAN-Streaming schlägt fehl" against a daemon that looked perfectly healthy. The beacon is on by default too (--no-announce turns it off); without it the phone's "Find my PC" has nothing to hear.

Installing

tools/install.sh        # ~/.local/bin, the service, the menu entry
tools/uninstall.sh      # all of it back out again

A checkout is a place to build things, not a place to run them from. The service used to point straight at pc/target/release/uwucam-pc, which meant cargo build silently replaced the binary of a running daemon, cargo clean deleted it, and checking out an older branch downgraded the camera without anybody asking. The installer copies to ~/.local/bin and points the service there.

Everything is per-user and nothing needs root. The one part that does — the v4l2loopback module options, which is also where the camera gets the name every dropdown shows — is checked and reported, never edited.

It exists because "the camera is broken" has twice turned out to mean "no daemon is running" — the module was loaded, the devices were there, the phone was plugged in, and nothing was feeding it.

The window and the terminal are the same program with different audiences: the daemon reports events (pc/src/report.rs) and each binary decides what that looks like. The window's Stop button really stops — the accept loop polls a StopSignal rather than blocking forever, and a_running_daemon_stops_when_asked is what keeps that true. The GUI also lists the loopback devices with whoever currently holds them open, which is the answer to the one question this program reliably produces — see the note about pinned formats below.

Testing

  • proto/: the protocol both halves link. Frame headers (rejecting a width/height/len that doesn't match reality instead of indexing out of bounds), and the transport: a full pairing-and-session handshake over real sockets, replay and tampering refused, ciphertext that differs between two sessions on the same pairing, and an impostor that completes every step by shape and still cannot produce the daemon's half. Run with cargo test from proto/.

  • pc/: the daemon end to end in-process — pair over loopback, reconnect, stream a frame — plus a refusal test that dials this machine's own network address to prove pairing is never granted off the cable. And 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/rust/: unit tests for the Mutex poison-recovery pattern the JNI entry points rely on (see lock_connection in android/rust/src/lib.rs). Run with cargo test from android/rust/.

  • 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 — and the YUV_420_888→NV21 conversion (YuvConversion.kt, including the semi-planar/shared-buffer case real devices commonly use) — both extracted specifically so they're testable without Robolectric or a device.

  • android/app/src/androidTest/: instrumented tests, run with ./gradlew connectedDebugAndroidTest against a plugged-in phone. They cover the one link nothing else can reach — the JNI boundary. Everything about the protocol is checked on both sides by Rust tests that link the same crate, and everything pure in the app is checked on a JVM; what sits between them is whether the .so inside the APK loads and answers to the exact names and signatures Kotlin declares. A mismatch there is an UnsatisfiedLinkError at the moment of the call, on the phone, mid-stream.

    One of them is a cross-check rather than a self-check: the phone computes a pairing fingerprint for a fixed key and compares it against the value the desktop build prints for the same key (uwucam-pc --fingerprint). Two builds of the same Rust for two architectures, asserted equal — because if they ever weren't, the two screens would show different fingerprints for one pairing, which reads as "you are paired with the wrong machine".

Architecture

[ Android: Camera2/CameraX capture ]                                [ PC: Rust daemon ]
              |                                                             |
              |  uwucam-proto: pairing, session key, sealed frames          |
              +--- USB (adb reverse) ---or--- WiFi (paired only) -----------+
                                                                            |
                                            /dev/videoN (v4l2loopback) --> OBS/anything

proto/ is the middle of that diagram, and it is one crate rather than two implementations that agree by comment. Both halves are Rust, so both link it: the phone seals a frame with the same code the daemon opens it with. The one place a mirror is still unavoidable is Kotlin's Pipeline enum, which cannot link Rust — so a test in pc/ reads the Kotlin source and fails if the wire tags or the port ever disagree again. They did once, and it cost an evening.

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.

Always a camera — the daemon writes a standby card into the loopback device from the moment it starts, not from the moment the phone connects. This is not cosmetic. A v4l2loopback node only becomes a capture device once a producer has negotiated a format and is writing frames; before that it advertises V4L2_CAP_VIDEO_OUTPUT and an empty format list. Chromium and everything built on it — Discord, any Electron app, a browser tab — enumerate cameras once at process start and skip a device with no formats. So a daemon that waited for the phone was invisible to every application that had started first, which for a chat app left open for days is all of them. The symptom was not an error anywhere: the camera was simply absent from the dropdown.

The card is drawn (pc/src/standby/) rather than shipped as an image, because it has to match the negotiated format exactly — including MJPG, which is what the phone defaults to. That is why there is a small baseline JPEG encoder in pc/src/standby/jpeg.rs: about three hundred lines of plain ITU T.81, no crate, encoding four static cards once per resolution and never again. The format the phone last used is remembered in $XDG_STATE_HOME/uwucam/last-format so the next run comes up in it — while a video call is attached the format is pinned, so coming back up in something the phone does not send would be unrecoverable without closing the call.

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: Android instrumented tests (the JVM-testable logic that can be pulled out of ImageProxy/CameraX, i.e. YuvConversion.kt and ResolutionMath.kt, now is).

  • v0.2 — MJPEG: the phone JPEG-encodes each frame and the daemon relays it untouched, no transcoding. ✅ Done, verified end-to-end against real hardware: a frame captured out of /dev/video11 while the phone streamed, and the pipeline switched between raw and JPEG within one daemon run (which is new — see below).

    Measured, on a Huawei P30 lite over USB, ten seconds per run:

    Resolution Raw (NV21) JPEG bytes per frame
    1280×720 17 fps 17 fps 1.38 MB → ~60 kB
    1920×1080 12 fps 15 fps 3.11 MB → ~110 kB
    3968×2976 2.2 fps 4.2 fps 17.7 MB → ~600 kB

    At 720p the camera is the limit and the two are identical. Above it the cable is the limit and JPEG pulls ahead, roughly doubling throughput at the sensor's full resolution. It is never slower, so JPEG is the default — and a real USB webcam almost always presents MJPEG anyway, so it is also the more ordinary thing for a camera to offer. Raw stays available as a fallback.

    H264 is not planned. The roadmap listed it beside MJPEG; the numbers above are the reason it is not being built. The remaining gap at full resolution is the cable, not the codec, and H264 would buy that back at the cost of a keyframe handshake, an encoder session that has to survive reconnects, and consumers that handle it less reliably than MJPEG. If a WiFi transport ever makes bandwidth the binding constraint again, this is the first thing to revisit.

    Two bugs this work uncovered, both invisible from either side alone:

    • The two halves disagreed about a port. The phone dialled 4748 and the daemon listened on 4747. The app retried politely every two seconds forever and the daemon sat there reporting "listening" — both healthy from their own point of view. the_two_sides_agree_on_a_port_and_on_ the_wire_tags in pc/src/lib.rs reads the Kotlin source and fails if they ever differ again.
    • A format could only be negotiated once per daemon run. v4l2loopback pins whatever was negotiated first for as long as anything holds the device open, and the daemon is one of those things — so changing resolution on the phone, or switching pipeline, failed with EBUSY naming the daemon itself. It now closes and reopens the device around a format change.
  • v0.3 — pairing, and WiFi: the transport moved into a crate both halves link (proto/), so the phone seals a frame with the same code the daemon opens it with. Pairing happens over the USB cable, sessions are authenticated both ways, and frames travel under ChaCha20-Poly1305. ✅ Built and covered by tests that run the real handshake over real sockets, including a refusal test that dials this machine's own network address to prove pairing is never granted off the cable. Not yet verified against the phone — the two halves have to be updated together, since a v0.2 app meeting a v0.3 daemon is refused by design (with a message saying so).

    Three decisions worth writing down:

    • Pairing over the cable, not a code. adb reverse means a USB connection arrives from loopback, which is a channel that is trusted because somebody plugged it in. That is stronger than six digits read off one screen and typed into another, and there is nothing to type.
    • Fresh key every session. The pairing secret never encrypts anything and never crosses WiFi; both ends contribute randomness and derive a session key from it. So a recorded session is worth nothing against the next one, and the frame counters can safely start at zero every time — which is what ChaCha20-Poly1305 needs to be true.
    • Nothing hand-rolled. The primitives are RustCrypto's. Writing ChaCha20-Poly1305 by hand would be the single worst decision in this repository: it is the one kind of code that fails silently, passes every test you think to write, and is wrong anyway.
  • v0.4 — finding the PC: the daemon can announce itself on the local network so a phone does not have to be told an address. A UDP datagram to a link-local multicast group every three seconds, carrying the pairing fingerprint, a port and a name — never the key. Hearing it therefore helps nobody connect: pairing is still granted only over the cable, and the fingerprint is the thing both screens already show so a person can compare them.

    On by default since the #49 fix (--no-announce turns it off, and it does nothing while --no-wifi is set). It used to be opt-in behind --announce; the shipped service unit never passed the flag, which is part of how "WLAN-Streaming schlägt fehl" sat undetected. Announcing and accepting are still different promises, and each keeps its own off switch.

    Multicast rather than broadcast, and it is worth writing down why: broadcast was tried first and looked like it worked. A datagram sent to 255.255.255.255 leaves the machine and is not delivered back to sockets on the same host, so the daemon announced happily and --find on the same PC heard nothing at all. Multicast makes local delivery a decision rather than an accident, and a TTL of 1 keeps the packet on the link.

    The phone listens too: Find my PC on this network in WiFi mode lists what it heard, marks the one whose fingerprint matches this phone's own pairing as "this is your PC", and fills in the address when you tap it. The fingerprint is shown either way, with the line that matters: check it against the one the PC is displaying before pressing Start. A machine can say which fingerprint it heard; only a person can say whether it is the one on the screen in front of them.

    Verified against the phone: the beacon is heard, the paired PC is recognised, and the address lands in the field.

    From the PC side, uwucam-pc --find answers "is my beacon actually going out": it listens a few beacon intervals and lists what it heard — an empty list plus the daemon still claiming to announce means read its note about self-multicast, and the phone remains the honest test. It is also the first diagnostic for the old #49 shape ("daemon runs, phone finds nothing"): a daemon that does not announce is one started with the beacon off, and a beacon that goes out while 4747 stays loopback-bound is the other half of the same mistake.

    ⚠ Streaming over WiFi is verified on the PC side, not yet end to end. As of 08.09 the daemon binds 0.0.0.0:4747 under the shipped service unit, accepts TCP on its LAN address at the application layer, and the beacon goes out — that was the whole of #49 (the daemon used to sit on loopback and no phone could reach it at all). What still needs a real phone on real WLAN: pressing Start in WiFi mode and watching frames arrive. If that works, this line goes; USB streaming is unaffected and works.

  • v1.0.0: WiFi streaming actually confirmed end to end, which needs the phone's connect path to say something when it fails — today it fails silently, and that is the first thing to fix.

  • 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).

  • A v4l2loopback device keeps whatever format was negotiated first for as long as anything has it open — and on a normal desktop that is usually something nobody thinks of as a camera app (Discord opens every video device it can find, just to fill a dropdown). While pinned, the device accepts an S_FMT for a different size or fourcc and reports success without applying it, so the resolution picked on the phone is silently ignored. Worse, the pinned state can outlive every opener: a device left in that state stays stuck until the module is reloaded.

    The daemon now detects both — it compares the driver's reply against what it asked for, and names the process holding the device at startup rather than at the first frame. To clear a device that is already stuck:

    sudo modprobe -r v4l2loopback && sudo modprobe v4l2loopback
    

    Check with cat /sys/devices/virtual/video4linux/videoN/format — empty means free, a value means pinned. Note exclusive_caps is an array parameter: a bare exclusive_caps=1 in /etc/modprobe.d/ applies to the first device only, so devices=3 needs exclusive_caps=1,1,1 or the other two behave differently from the first for no visible reason.

  • Two options v4l2loopback lines in /etc/modprobe.d/ do not merge — the last one parsed wins entirely. A machine that used to run DroidCam usually still has its line sitting there, so the camera can end up named Droidcam (or carry noforget) with nothing to explain why. Check with grep -l 'options v4l2loopback' /etc/modprobe.d/*.conf; there should be exactly one file. Only *.conf counts — modprobe reads nothing else in that directory, so the .bak and .pacsave files an upgrade leaves behind look alarming in a plain grep -r and change nothing.

  • An application that was already running when the daemon started still has to be restarted once. The standby card keeps the device visible from then on, but Chromium-based apps build their device list at process start and never refresh it, so anything launched before the very first daemon with standby support cannot see a camera that did not exist when it looked.

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 only if 4747 is taken

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.

On Arch

deploy/aur/ is the AUR package: PKGBUILD, .SRCINFO, the user unit, a desktop entry, and an .install that says the one thing a package cannot do for you — create a v4l2loopback device. It packages the PC half only. The Android side is an APK, it needs a signing key, and Arch has no business installing something onto a phone; the APK lives on the releases page instead.

Cutting a release, in order — the order matters, because each step needs the one before it:

# 1. tag, so the tarball the PKGBUILD points at exists
git tag -a v0.3.0 -m "..." && git push forgejo v0.3.0

# 2. build both halves and attach them to the Forgejo release
cd pc && cargo build --release --features gui
cd ../android && ./gradlew assembleRelease   # needs keystore.properties, see below

# 3. point the package at the real tarball
cd ../deploy/aur && updpkgsums && makepkg --printsrcinfo > .SRCINFO

# 4. and only then push to the AUR

sha256sums=('SKIP') in the committed PKGBUILD is a placeholder, not a decision: step 3 replaces it. Publishing with SKIP would ship a package that installs whatever the URL happens to be serving that day.

makepkg runs check(), which runs the test suite. Those tests are hermetic — the protocol ones talk over loopback sockets and the daemon ones open /dev/null as their device — so they pass in a clean chroot with no phone, no v4l2loopback and no network.

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.