Native Android (Kotlin + Jetpack Compose) app for LAN monitoring and control of FlashForge
printers: Adventurer 5M / 5M Pro / AD5X, Creator 5 / 5 Pro, and partial (status-only) Adventurer
3/4. Port of the desktop FlashForgeUI-Electron
app. Single module :app, package me.ghost.ffui (namespace + applicationId). Public repo in
beta (first release v1.1.0-beta1, 2026-10-07) — anything committed is visible to everyone.
User docs: README.md. Build setup for humans: DEVELOPING.md.
- Commit and push straight to
main— sole maintainer; no feature branches or PRs unless asked. Still only commit/push when asked. - Remote:
Parallel-7/FlashForgeUI-Android. History was rewritten on 2026-10-07 to purge an old APK and keystore blob — any clone from before then must be re-cloned, not pulled.
All FlashForge wire protocol — HTTP REST (8898), TCP G-code (8899), serialNumber + checkCode
auth, model detection, per-model backends, @Serializable wire shapes, palettes — is owned by the
library ff-5mp-api-kt (me.ghost.ffapi, GitHub GhostTypes/ff-5mp-api-kt, sibling checkout
at ../../1flashforge_printers/ff-5mp-api-kt; read its CLAUDE.md + docs/parity.md).
- Consumed as
me.ghost:ff-5mp-api-kt:<ver>frommavenLocal()(pin inapp/build.gradle.kts). - Need a new endpoint,
/detailfield, G-code command, or backend behavior? Change the library,./gradlew :ffapi:publishToMavenLocalthere, bump the pin here. If you're writing socket/HTTP/serialization code inapp/, stop. - The app keeps only: UI/state, Room, session orchestration, Spoolman client, NFC, and the
hardware-proven
UdpDiscovery(the library has an equivalentPrinterDiscovery; migrating is optional — never change the empty broadcast payload). - Upstream ground truth for protocol intent, in
../../1flashforge_printers/:ff-5mp-api-ts(reference client the library ports),flashforge-api-docs(protocol docs),FlashForgeUI-Electron(desktop app: notifications, Spoolman, layout),flashforge-emulator-v2(headless printer emulator for legacy/modern testing).
Gradle wrapper 9.3.1 (required by AGP 9.1.1) on JDK 25. Shell is PowerShell
(.\gradlew.bat); Bash can use ./gradlew.
./gradlew assembleDebug # four per-ABI APKs -> app/build/outputs/apk/debug/ (no universal APK)
./gradlew test # unit + Robolectric + Roborazzi screenshot tests
./gradlew installDebug # installs the matching ABI
./gradlew recordRoborazziDebug # re-record screenshot baselines after intentional UI changes
- ABI splits are on because of libmpv's native libs. Emulator =
app-x86_64-debug.apk, phones usuallyapp-arm64-v8a-debug.apk. minSdk 26,targetSdk/compileSdk 36(android-36.1); app code is Java 11.- Per-machine, gitignored:
local.properties(sdk.dir=…) anddebug.keystoreat repo root (regen command inDEVELOPING.md). Release signing readsKEYSTORE_PATH/STORE_PASSWORD/KEY_PASSWORD; without them the release APK is unsigned. R8 is off. - Fresh machine can't resolve the library → publish it to mavenLocal first (above).
- CI (
.github/workflows/ci.yml, shared setup in.github/actions/setup-build): checks out the library at tagv<pinned version>, publishes it to mavenLocal, then runstest+assembleDebug. A library pin bump therefore needs a matching pushed library tag. - Releases (
.github/workflows/release.yml): bumpversionName(andversionCode), commit, then push tagv<versionName>(e.g.v1.1.0-beta1). CI checks the tag matches, builds signed APKs from theRELEASE_KEYSTORE_BASE64/RELEASE_STORE_PASSWORD/RELEASE_KEY_PASSWORDsecrets, fails unless the cert SHA-256 matches the one pinned in the workflow, and opens a draft release (prerelease when the version has a-suffix). The maintainer publishes it. The release key lives outside the repo; never commit it. scripts/seed_printers.py/scripts/emulator-printers.ps1re-pair printers into the emulator after a DB wipe via the debug-onlyDebugPrinterSeederintent extra. Check-codes live in the gitignoredscripts/printers.local.json.
android layout --device=<serial>first — Compose-aware JSON withtext,interactions,stateand a tappablecenter;--diffshows only changes. It also sees system permission dialogs.- Screenshots (
adb exec-out screencap -p > .build-outputs/x.png) only for genuinely visual checks. Save under.build-outputs/(gitignored), not/tmp. uiautomator dumponly as a last resort (slow/flaky on this Windows setup).
After launch, check adb logcat -d -t 200 | grep -iE "FATAL|AndroidRuntime|ffui".
me.ghost.ffui
├── FfuiApplication builds the process singletons (no DI framework — keep it manual)
├── MainActivity edge-to-edge, NFC foreground dispatch, debug seed intent
├── api/ UdpDiscovery (hardware-proven), SpoolmanApi + SpoolmanModels
├── data/
│ ├── AppDatabase (v5) / PrinterDao / PrinterEntity / PrinterRepository
│ ├── ActivePrinterSession lives in PrinterRepository.kt — owns the library backend +
│ │ FlashForgeHttpApi/FlashForgeClient; identify, poll loop, ConnectionState
│ ├── PrinterSessionManager process-lifetime owner of sessions map + activeSerial, notifier wiring,
│ │ startup reconnect, foreground-service + background throttle
│ ├── PollCadence / PrintEventDetector / ConnectionResolver pure logic, unit-tested
│ ├── SettingsDataStore global prefs (incl. NFC tagged spools/boxes, device-local)
│ └── SpoolmanRepository / SpoolBox / ThumbnailCache / DebugSeed
├── service/ PrinterMonitorService (specialUse FGS keep-alive), BatteryOptimization
├── notifications/ PrinterNotifier (complete / cooled / error)
├── nfc/ NfcManager (tag read/write, scan modes)
└── ui/ MainViewModel (thin facade over the manager), FlasherApp (bottom nav),
JobState, PrinterModelNames, components/ (MpvPlayer, IfsPalette, …),
dashboard/ controls/ discovery/ files/ info/ settings/ spools/ theme/
- Sessions.
PrinterSessionManager.sessions: StateFlow<Map<serial, ActivePrinterSession>>+activeSerial; dashboard is aHorizontalPager+PrinterTabBarsynced toactiveSerial. Connecting an open serial just switches tabs. Connected serials persist for startup reconnect. - Connect resolves via discovery first. A short UDP scan matches by serial and updates the
saved IP if it moved. A user-tapped connect that misses discovery and fails at the transport
level (
Offline(transportFailure=true)) prompts for a new IP; background reconnects never prompt. - Model detection by
pid(35 5M, 36 5M Pro, 38 AD5X, 40 Creator 5, 41 Creator 5 Pro) from first/detail; legacy printers via TCP~M115./productflags + per-printer overrides →PrinterCapabilities; UI is capability-gated (hide unsupported). - HTTP
/detailis the single source of truth for modern printers (status, temps, IFS inline). TCP is control-only (LEDs, homing, M104/M140 temps on 5M/AD5X). Only the legacy backend polls TCP. - Creator 5 backends are
httpOnly— never touch 8899; temps go over HTTP (-100= off, except nozzles where0= off). Base C5 reports chamber-108→ no chamber controls. - Job state maps through the library's
MachineState(JobState.kt): bothpause/pausedspellings, unknown ⇒ busy,friendlyStateLabeleverywhere — never show raw firmware tokens. Poll cadence follows the same enum (1.5 s printing … 15 s auth-failed;pollFloorMsthrottles in background). - Errors: library
AuthException→AuthFailed;ApiErrorException→Offlinewith the firmware message (C5 in cloud mode → "not in LAN mode"). - Background monitoring: opt-in toggle keeps
PrinterMonitorServicealive while ≥1 printer is connected;START_STICKYnull-intent restart callsresumeMonitoringAfterRestart(). When off,MainViewModel.onClearedtears sessions down. Settings nudges for the battery-optimization exemption. - NFC tag format is a public contract — tags in the wild depend on it: text record
SPOOL:<id>orBOX:<location>; optional trailing URI record is never read back. Tagged state is device-local (DataStore), never written to Spoolman. Spoolman usage tracking is manual only.
- Cleartext HTTP must stay permitted (
res/xml/network_security_config.xml) — printers have no TLS; removing it silently breaks polling. - Firmware numbers are inconsistent (
5vs5.0): numeric wire fields areFloat?(onlypidisInt?), same for Spoolman models. No conversion at the Compose boundary. - Camera (libmpv): one
MpvControllerper printer, one attachedMpvVideoSurfaceat a time (cameras are single-client; fullscreen swaps the surface on the same controller).activate()order isattachSurface → force-window=yes → vo=gpu(vo first = black screen). FPS countsonSurfaceTextureUpdated, notestimated-vf-fps(0 underuntimed). Only the settled pager page streams. - Room: do NOT write migrations during prototyping. Bump
@Database(version)and letfallbackToDestructiveMigration(dropAllTables = true)wipe. This flips to mandatory real migrations before production — this file will say so. allowBackupstays ON deliberately (restores saved printers on a new phone). Don't change it or add exclude rules without asking.- Temp dialogs clamp to firmware ceilings (nozzle 265 °C, bed 100 °C, chamber 80 °C), accept one
decimal and send the rounded integer — never strip the separator (that turned
21.5into215). - Networking on
Dispatchers.IO; release the TCP lock (~M602) and close sockets on teardown.
StateFlowcollected withcollectAsStateWithLifecycle()— never plaincollectAsState().kotlinx.serialization+ OkHttp only (no Retrofit/Moshi).Json { ignoreUnknownKeys = true; explicitNulls = false }.- All user-visible strings in
res/values/strings.xml, prefixed by screen (dashboard_,spools_,common_, …), positional args (%1$s),pluralsfor counts. No literals in composables. - Theme tokens, no hardcoded colors:
MaterialTheme.colorSchemeorui/theme/Color.kt(GeometricOrangePrimarynozzle,GeometricBluePrimarybed). Edge-to-edge — honor insets. - All DB access goes through
PrinterRepository, async. - KDoc new public API; no TODO stubs; idiomatic Kotlin (
val, strict nullability). - UI copy: short and scannable (~50-char subtitles, one sentence); no protocol jargon (no "TCP"/"HTTP", "LED" not "Factory LED"); no instructional helper text under controls — make it a button instead; labels should be self-explanatory.
- Nothing is "done" until exercised on real hardware — keep the table below honest.
The maintainer uses the app day-to-day on a real 5M Pro and AD5X; the rows below record what has been specifically exercised. The README's printer table must stay consistent with this.
| Area | Status |
|---|---|
UDP discovery, /detail polling, pid detection, capability gating, IFS read |
Hardware: AD5X (fw 3.1.0) + 5M Pro |
| Camera, fullscreen, FPS counter | Hardware: AD5X |
Scan roll → IFS slot (NFC read + Spoolman + msConfig_cmd) |
Hardware: AD5X + NTAG215 |
| Spoolman tab, NFC spool/box UI | Emulator + local Spoolman; NFC write untested on hardware |
| Notifications (foreground) | Event detection verified on AD5X read path |
| Background service, throttle, sticky restart | Build/test only — no long-print or OOM soak |
| Discovery-first connect + edit-address prompt | Build/test only |
| TCP temps/motion/LED, files + start print, filtration, slot editor | Built, unverified on hardware |
| Legacy Adventurer 3/4 (TCP backend) | Emulator only |
| Creator 5 / 5 Pro (all paths) | Build only — no hardware, not run on emulator |
- Spoolman instant-load: render last-known
spoolsimmediately, refresh silently. Keep it simple — no Room table; a DataStore JSON blob at most if cross-process-death persistence is wanted. SpoolsScreencan stick onNotConfiguredif Spoolman is enabled with a blank URL and the URL is set later without an app restart.- AD5X single-color starts send
gcodeToolCnt: 1app-side; drop the workaround when the library carries the fix. - File upload exists in the library but is intentionally not exposed in the app.
- Not started (desktop parity): G-code terminal, Discord/webhook notifications.
android-cli (device/emulator/build), testing-setup, edge-to-edge, compose-styles,
navigation-3, adaptive, best-practices.