LambentEx Android client
Native Android client for LambentEx. Everything it does goes over the
Phoenix channels at /socket — there is no REST API and nothing to log into.
See ../docs/socket.md for the protocol.
Sister app to ../../waxx/android in shape, not in dress: same Kotlin/Compose
shell, but the palette and font come from this app’s own web UI rather than the
shared Casabeza set.
Stack
| Layer | Choice |
|---|---|
| Language / UI | Kotlin 2.0 · Jetpack Compose · Material 3 |
| Transport | com.github.dsrees:JavaPhoenixClient (0.3.4, via jitpack) |
| Storage | SharedPreferences — a LAN address, no credential |
| Scanner | com.journeyapps:zxing-android-embedded (ScanContract) |
| Build | Gradle 9.0 · AGP 8.5.2 · JDK 17 |
| minSdk | 26 (Android 8) · targetSdk 34 |
Single activity, single module, no DI, no ViewModels — LambentSocket is an
object holding one socket and a StateFlow per channel, and the screens
collect it. The server publishes whole state on a timer, so every screen is a
reducer over pushes exactly like its LiveView is.
Layout
android/
app/src/main/
java/org/neiam/lambent/app/
MainActivity.kt # connect gate, tabs, snackbar for notices
data/
LambentSocket.kt # the socket, four channels, all commands
Models.kt # Device / Machine / Link / Service + parsing
BaseUrlStore.kt # where the box lives
UiPrefs.kt # layout the person picked, e.g. upright faders
ui/
ConnectScreen.kt # QR scan or type an address
DevicesScreen.kt # rename / place / order / poke / find-by-IP
MachinesScreen.kt # speed, brightness, persist, quit, previews, library
LinksScreen.kt # toggle / persist / quit, create with channel
BrightnessScreen.kt # global + per-place clamps, rows or upright faders
ServicesScreen.kt # list + delete, and forget-this-box
theme/ # the afterdark palette and TF2 Build
res/font/tf2build.ttf # converted from assets/css/fonts/TF2Build.woff
Theme
ui/theme/Theme.kt is the web UI’s daisyUI afterdark theme from
assets/tailwind.config.js, mapped onto a Material 3 darkColorScheme —
primary #7B79B5, accent #fef3c7, base #201D65 on a #38357F background.
There is only the one scheme, because the web app is dark-only; honouring the
system light mode would mean inventing a palette that doesn’t exist.
ui/theme/Typography.kt uses TF2 Build, the same face the web UI loads for
font-build. It’s a display font there — titles and nav only — so it stays on
the display/headline/title slots and the platform sans carries body copy. The
TTF was converted from the shipped .woff with fontTools; to regenerate:
python3 -c "from fontTools.ttLib import TTFont; f=TTFont('assets/css/fonts/TF2Build.woff'); f.flavor=None; f.save('android/app/src/main/res/font/tf2build.ttf')"
Building
Needs an Android SDK and JDK 17 — AGP 8.5.2 does not run on JDK 22+, so
point JAVA_HOME at 17 (or use Android Studio’s bundled JDK) even if a newer
one is your default.
cd android
./gradlew :app:assembleDebug
./gradlew :app:installDebug # or: adb install -r app/build/outputs/apk/debug/app-debug.apk
Connecting
Open the ⬛ QR icon in the web UI (top right of any page) and scan it, or type
the address by hand — a bare 172.16.13.5 is fine, port 8033 is assumed and
http:// is not coerced to https, because this is a LAN box with no
certificate. The address is remembered; Services → Forget this box clears
it.
Cleartext HTTP is enabled in the manifest for the same reason.
Not shipped
- Creating custom machines. The library sheet starts a machine from a
library entry; the full machine form is a polymorphic embed per machine type
and would be a screen of its own. The
createcommand on the channel already accepts the same params the web form posts. - Bulk links.
LambentSocket.createLinksspeaks thecreate_bulkcommand, but no screen calls it yet. - Creating services. Not offered by the web UI either — those three form components are still stubs.
Release builds
CI builds and signs the release APK in a container
(android/Dockerfile) and publishes it to the forge’s own F-Droid
repository — nothing is installed on the runner, and the same command
reproduces it locally:
docker buildx build --target android-export \
--secret id=keystore,src=./release.jks \
--secret id=keystore_password,src=./keystore.pass \
--progress=plain \
--output type=local,dest=./apk --file android/Dockerfile android
The keystore is a BuildKit secret, so it never reaches a Gradle script, a Gradle cache, or an image layer. Signing is a separate step from Gradle for the same reason.
.forgejo/workflows/android.yml runs that on every push to master
and POSTs the APK to /pkg/fdroid/<owner>/<repo>/upload with the
job’s own token — there is no publishing credential to create.
versionCode comes from the CI run number and versionName from the
commit, so every published build sorts after the last one. Local builds
stay at versionCode = 1 / …+dev; they are never published.
Install from the repository by adding it in F-Droid:
<forge>/pkg/fdroid/<owner>/_all?fingerprint=<fingerprint>
curl -fsSL <forge>/pkg/_keys/fdroid/<owner> # prints the fingerprint
One entry covers every Android app the owner publishes, this one included.
Two different keys, deliberately. The APK key below is what Android checks on every update. The index key is per-owner, lives in the forge’s OpenBao transit engine, and never comes near CI.
The release keystore
Generate it once. Android verifies this certificate on every update, so losing it means every install has to be uninstalled and redone by hand — there is no recovery and no rotation.
keytool -genkeypair -v \
-keystore release.jks \
-storetype PKCS12 \
-alias lambent \
-keyalg RSA -keysize 4096 \
-validity 10000 \
-dname "CN=LambentEx, O=neiam, C=US"
It prompts for the password rather than taking -storepass, which
keeps it out of shell history. PKCS12 uses one password for the store
and the key, which is what android/Dockerfile assumes — it passes the
one password file to --ks-pass and lets the key password default to
it.
Back it up before it exists anywhere else
So there is never a window where your laptop holds the only copy:
export BAO_TOKEN=<root token>
E() { kubectl -n openbao exec -i statefulset/openbao -- env BAO_TOKEN="$BAO_TOKEN" "$@"; }
E bao kv put kv/lambent_ex/android \
keystore="$(base64 -w0 release.jks)" \
password='<the password>'
kv-v2 versions writes, so a later overwrite doesn’t destroy this one.
Recover with:
E bao kv get -field=keystore kv/lambent_ex/android | base64 -d > release.jks
Hand it to CI
On the repository: Settings → Actions → Secrets.
| Secret | Value |
|---|---|
ANDROID_KEYSTORE |
base64 -w0 release.jks |
ANDROID_KEYSTORE_PASS |
the password |
The android job skips cleanly while these are unset, so nothing
breaks before you get to it. It refuses to fall back to a debug-signed
APK: that key is shared by everyone with an SDK, so publishing one
would let anyone ship an “update” to it.
CI does not read the keystore out of OpenBao. apksigner has no
external-signer hook, so the key has to reach the build container
whichever store it comes from, and the runner is outside the cluster
while OpenBao has no ingress on purpose. Bao holds the durable copy;
the Forgejo secret is the operational one.