7.6 KiB · text History 949a2ee

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 create command on the channel already accepts the same params the web form posts.
  • Bulk links. LambentSocket.createLinks speaks the create_bulk command, 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.