Native Android · Gerber RS-274X · Excellon
Open the fab package on your phone and see what the fab will see — before you pay for it.
You export a ZIP from KiCad, Altium or EasyEDA and send it to a fabricator. If a layer is misnamed, an inner layer is out of order, or a drill file is in the wrong units, you find out days later — in a box of wrong boards.
GerberScope opens that ZIP on the device you already have. It parses the Gerbers and the Excellon drill, classifies every file, renders the board, and runs pre-fab sanity checks. No WebView, no network, no upload. The app declares no INTERNET permission at all — files never leave the phone.
Figures from the demo package that ships with the app’s test tooling — a synthetic 4-layer board, read on a Motorola moto g(60). No customer artwork appears anywhere on this page.
:core is a pure-JVM library — parsers, classification, geometry, DFM checks, diagnostics — with zero Android imports. That single constraint is what makes the hard part testable: 73 JVM tests run in seconds on a laptop with no emulator, no device and no SDK.
:app is Jetpack Compose on top: SAF ingest, the Canvas renderer, and the screens. It carries a deliberate dependency budget — core-ktx, lifecycle, activity-compose, the Compose BOM and coroutines. The 3D work below added no new dependencies at all.
| Module | Type | Holds | Proof |
|---|---|---|---|
| :core | Pure JVM | RS-274X + Excellon parsers, classifier, geometry, DFM checks | 73 tests |
| :app | Android | SAF ingest, Compose UI, Canvas renderer, GL renderer, export | on device |
Internal geometry is millimetres as Double throughout. Arcs flatten to polylines at parse time at ~0.004 mm sagitta; circular flashes stay exact circles. Layer classification detects the toolchain first, then applies that vendor's rules — and the rule tables are JSON data, not code, so a new vendor is a data change.
The Compose module was written in a sandbox with no Android SDK — complete source, never built. Two errors stood between it and an APK, and only one of them was ordinary.
Kotlin's Android plugin died on NoClassDefFoundError: com/android/build/gradle/api/BaseVariant — a class that demonstrably was in the resolved AGP jar.
Not a version conflict: a classloader one. The root build put the Kotlin plugin on the build classpath, while :app requested AGP with its own version in its plugins {} block, which gets a separate scope. Kotlin's plugin simply could not see AGP. Resolving both on one buildscript classpath fixed it — with the Android half kept conditional on an SDK being present, so the pure-JVM tests still run in a sandbox with no Google Maven access.
Three items(...) calls in the checks screen with no import androidx.compose.foundation.lazy.items.
Everything else compiled as written — nativeCanvas, saveLayer, PorterDuff CLEAR, detectTransformGestures, the icon names, edge-to-edge. The predicted API drift never materialised.
Every defect below survived code review and the compiler. All six were found by driving the real app — four on an emulator, and the two that mattered most on the phone.
| Where | Defect | Root cause | |
|---|---|---|---|
| core/model | A .gbrjob was flagged unparsed, filed under Needs review, and raised “1 file(s) could not be parsed” | The loader had a job-aware check and threw it away; the model recomputed it job-blind | wrong |
| layer_rules.json | A KiCad .drl flagged at 50% under Needs review on a package where every other file scored 100% | KiCad's job file never lists drill files, and the KiCad rule table had no drill pattern — so it fell through to the generic table | wrong |
| BoardScreen | Visibility chips for layers the view never draws — 4 dead controls of 13 | Chip row listed every layer with geometry; the compositor draws one side's set | dead UI |
| GerberScopeApp | Rotating the phone threw you back to the first tab | remember where rememberSaveable was needed | state loss |
| MainViewModel | A recent entry that could never reopen | One-shot URI grant recorded as if it were persistable | dead entry |
| LayersScreen | “Open” wrapping mid-word on a long file name | Unbounded name column squeezing the button | cosmetic |
.gbrjob carries no geometry by design — and it is the very thing that classifies every other file at 100%. Reporting “no geometry” as “failed to parse” made a clean package look broken.A measurement worth keeping: a suspected renderer stall turned out to be nothing. A steady-state pan measured 5.8% janky frames — then the same build measured 43.7% and 33.7%. The emulator's software rasteriser was the variable, not the code, so the optimisation it seemed to call for was never written.
Layer order is the thing you must verify and most often get wrong, and a list of checkboxes hides it. Three views make it physical — and none of them added a dependency.
android.graphics.Camera; each plate drawn by the same compositor as the flat view.Two things the hardware corrected that reasoning had not.
The explode looked right in theory as a camera z-offset — on screen it merely nested the plates at slightly different scales, because Camera composes its translate before the rotation. It became a screen-space offset instead.
And the underside rendered physically correctly: in shadow, and unreadable. A bottom layer you cannot inspect is useless, so a fill light now bounces up at it — accuracy traded for the thing the view is for.
ACTION_VIEW content-URI path.Pinch-zoom and double-tap-to-reset are unverified. adb injects a single pointer, and each synthetic tap lands well outside the 300 ms double-tap window, so both register as ordinary taps. They need a human finger, and saying otherwise would be a guess dressed as a result.