Class-name Shrinker
Picodroid ships a build-time class-name shrinker (tools/class-shrink/)
that rewrites framework .class files to use short synthetic names
(picodroid/pio/Gpio → a/S, etc.). It trims a few kilobytes from
firmware Flash and from every .papk without any change to Java source
or to the native dispatch layer — the translation is completely
transparent at runtime.
Shrinking is off by default, matching Android’s “R8 off by default”
behavior. Opt in on any build by passing --shrink to the top-level
script (build.sh, flash.sh, sim.sh, or build-apk.sh), which
sets PICODROID_SHRINK=1. Both firmware and PAPK builds honor the
same env var so the two sides always agree.
This doc is reference material. Day-to-day app development doesn’t need any of it.
Design overview
Shrink maps are release-versioned and append-only:
- Each picodroid release can commit an immutable map file at
sdk/shrink-maps/v<semver>.toml. - The map keyed to the picodroid package version in the root
Cargo.tomlis the active map. “Keyed” = highest committedv<semver>.tomlwhose semver is ≤ the package version. - If no map is committed at or below the current version, the active
version is the sentinel
0.0.0and no shrinking happens. - Classes added to the framework between releases stay un-shrunk (retain their original full names) until the next release cut folds them in. Symbols in the active map are never renamed.
The append-only rule is what lets old PAPKs keep working on newer firmware: every name a PAPK-at-version-P refers to is still present in firmware-at-version-F ≥ P.
PAPK compatibility is enforced at load time
(papk-format/src/lib.rs verify_compat): a PAPK with a map
version greater than the firmware’s is rejected with
PapkError::FrameworkVersionMismatch.
Active maps
Fourteen release maps are committed today:
| Map | Covers |
|---|---|
sdk/shrink-maps/v0.1.0.toml | Original 42 framework classes from the first release cut. |
sdk/shrink-maps/v0.2.0.toml | Adds classes introduced after v0.1.0 — Executors / Executor / MainExecutor / BackgroundExecutor, the SensorManager family (Sensor, SensorEvent, SensorEventListener, SensorManager), the HTTP client (Url, HttpUrlConnection, HttpInputStream, HttpOutputStream), and KeyEvent / OnKeyListener. Every v0.1.0 mapping is copied verbatim. |
sdk/shrink-maps/v0.3.0.toml | Adds classes introduced after v0.2.0 — picodroid.graphics.Theme, the drawable family (Drawable, GradientDrawable, GradientDrawable$Orientation), gesture / animation surface (GestureDetector, GestureDetector$OnGestureListener, OnTouchListener, ViewPropertyAnimator), and the new dialog / keyboard widgets (Toast, AlertDialog, AlertDialog$Builder, AlertDialog$1, Keyboard). Every v0.2.0 mapping is copied verbatim. |
sdk/shrink-maps/v0.4.0.toml | Adds the DI + Service surface — picodroid.app.{Service, IBinder, Notification, Notification$Builder}, picodroid.content.{ServiceConnection, Intent, Context}, and picodroid.di.{ApplicationComponent, ActivitySingletonComponent}. Every v0.3.0 mapping copied verbatim. |
sdk/shrink-maps/v0.5.0.toml | Adds the soft-keyboard polish surface — picodroid.widget.OnEditorActionListener, picodroid.view.inputmethod.EditorInfo, plus internal anchor classes for the slide-up animation. Every v0.4.0 mapping copied verbatim. |
sdk/shrink-maps/v0.6.0.toml | Stable — byte-identical to v0.5.0. The picoenvmon showcase app and the LTR559 driver landed without adding framework classes. |
sdk/shrink-maps/v0.7.0.toml | Adds the Tier C widget surface — picodroid.widget.{Snackbar, DatePicker, TimePicker, SwipeRefreshLayout} and picodroid.view.OnSwipeListener (entries a/CE..a/CI). Every v0.6.0 mapping copied verbatim. |
sdk/shrink-maps/v0.8.0.toml | Stable — byte-identical to v0.7.0. The PAPK ASSETS section (v1.1) and imagedemo landed outside the framework class set. |
sdk/shrink-maps/v0.9.0.toml | Stable — byte-identical to v0.8.0. The Apache → GPL relicense, multi-family refactor (platforms/, picodroid-core), ESP32-S3 M1 scaffolding, and Activity Display singleton bootstrap shipped without adding framework classes. |
sdk/shrink-maps/v0.10.0.toml | Adds the Android-parity Tier 1/2 surface (+23 classes, 87 → 110) — picodroid.view.ViewGroup (+ LayoutParams), the adapter family (Adapter, AdapterView, ArrayAdapter, BaseAdapter), picodroid.widget.CompoundButton, picodroid.content.DialogInterface, and the typed listener interfaces (View$OnClickListener/OnFocusChangeListener, CompoundButton$OnCheckedChangeListener, AdapterView$OnItemClickListener, etc.). Every v0.9.0 mapping copied verbatim. |
sdk/shrink-maps/v0.11.0.toml | Adds the package-move + widget-completion surface (+25 classes, 110 → 135) — picodroid.app.AlertDialog (+ Builder/$1, moved from picodroid.widget), picodroid.content.SharedPreferences (+ Editor), picodroid.os.IBinder (moved from picodroid.app), picodroid.net.URL / HttpURLConnection (Java-cased rename), picodroid.text.{TextWatcher, InputType}, picodroid.view.Gravity, GestureDetector$SimpleOnGestureListener, the picodroid.view.animation interpolator family, and picodroid.widget.{NumberPicker, RadioButton, RadioGroup}. Every v0.10.0 mapping copied verbatim. |
sdk/shrink-maps/v0.12.0.toml | Stable — byte-identical to v0.11.0. The Pico 2 W networking bring-up, the FreeRTOS host simulator, the runtime-flash fixes, and the picodroid-core / papk-format / pdb-protocol extractions all landed outside the framework class set. |
sdk/shrink-maps/v0.13.0.toml | Stable — byte-identical to v0.12.0. The typed java.net exceptions, HttpURLConnection header/timeout surface, InetAddress.getByName, ServerSocket.setSoTimeout, and SystemClock.setCurrentTimeMillis all landed as methods on classes the v0.11.0 cut already named; the JVM, GC, and memory-diagnostics work added no sdk/java classes. |
sdk/shrink-maps/v0.14.0.toml | Adds the concurrency + injection-point surface (+14 classes, 135 → 149) — the pure-Java java.util.concurrent core set (picodroid.concurrent.{Callable, Future, FutureTask, ExecutorService, ThreadPoolExecutor, TimeUnit, CountDownLatch, AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference}), Thread$UncaughtExceptionHandler, and the two injection points javax.inject.Provider / picodroid.di.Lazy. Every v0.13.0 mapping copied verbatim. |
sdk/shrink-maps/v0.16.0.toml | Schema 2: adds the [[member]] section — 868 method and field names of the framework mapped to 1–2-character targets (every v0.15.0 class mapping copied verbatim, class allocation untouched) and member-floor = "0.16.0". Everything in sdk/api-contract.tsv’s member column, <init>, javac synthetics and names ≤ 2 chars stay verbatim. |
sdk/shrink-maps/v0.15.0.toml | Opens the b/ namespace for java/** (+88 classes, 149 → 237) — Object, String, StringBuilder, the boxed types, the collection classes and interfaces, every builtin exception and the java.lang.invoke bootstrap names: every java/** class the framework references or pico-jvm serves. a/ allocation is untouched; every v0.14.0 mapping copied verbatim. |
Scope
Maps rename class names (every release since v0.1.0) and, since
v0.16.0, method and field names (see Member names).
Class names collapse into two synthetic packages, each with its own counter: a/
for framework classes (picodroid/**, javax/**; 42 of them in the first
cut) and, since v0.15.0, b/ for the java/** classes pico-jvm serves
natively. The JVM reverse-translates b/ names at its class-file
boundary (jvm/src/class_file/names.rs) — its own tables, catch
matching, instanceof, Class.getName() and native dispatch all keep
seeing java/lang/String — and compares descriptors, which stay shrunk on
flash, through a translating desc_eq. Both spellings of a java/** name
remain valid, so a PAPK shrunk with an older map still loads.
- Order: sort original internal names lexicographically.
- Suffix: bijective base-26 (
A,B, …,Z,AA,AB, …), skipping Java reserved keywords. - So
picodroid/app/Activity→a/A,picodroid/app/Application→a/B, …a/AP.
The class-name step preserves the .class bytes outside the constant pool
byte-for-byte. Only CONSTANT_Utf8_info entries get rewritten — bare
class-name references and Lfoo/Bar; substrings inside descriptors. CP
indices stay stable so the trailing sections (attributes, Code) don’t
need touching. (For a device build its input has already been through the
independent debug-attribute strip and, under a
member map, the member rename.)
Member names
Since map v0.16.0 the [[member]] section renames the framework’s method
and field names — setText → uQ, nativeCreate → mU — in the SDK
corpus and in every app PAPK. The map is keyed by bare name, not by
owner: onCreate renames the same way in Activity, in Application,
and in your MainActivity override, and at every call site, so overrides
and callers stay in lockstep without any per-class analysis. JVM method
and field namespaces are disjoint, so one target serves both kinds.
What is never mapped (cut-release enforces all of it):
<init>/<clinit>, javac synthetics ($VALUES,lambda$…), names of two characters or fewer, and every member of ajava/**orkotlin/**class or of an annotation interface;- every member name in
sdk/api-contract.tsv—toString,equals,hashCode,run,compare,compareTo,hasNext,next,close,getMessage,name,ordinal,get,put,size,append, … These belong tojava/**classes that have no class file (pico-jvm serves them from Rust arms), so the name itself is the contract: an app’sPoint.toString()renamed toqwould never be found by the interpreter’s"" + point, which invokestoStringby literal and falls through to the builtinObject.toString; [[member]]entries insdk/keep.toml(main,injectMembers— invoked by literal from Rust on app classes).
Targets are a–z, A–Z, then two characters (letter + letter/digit)
with all-lowercase pairs excluded — apps declare members called id,
io, eq, of, and the kotlin-shim has to — and never equal any name
spelled anywhere in the SDK corpus, the shim, the contract or the keep
list. Two characters cover the ~870 names in the first cut with room to
spare.
The rewrite is an ASM pass (buildSrc’s ShrinkMembersTask, a
ClassRemapper over the [[member]] rows), not the Rust class-name tool:
ClassWriter(0) rebuilds the constant pool, so a Utf8 slot javac
shared between a member name and an ldc string literal — every enum
constant’s name — comes out as two, and TimeUnit.SECONDS.name() still
says SECONDS. It runs before the Rust class pass: for app PAPKs as the
shrinkMembers task between the optional strip and shrinkClasses, for
the SDK corpus as :sdk:shrinkMembersStripped / :sdk:shrinkMembersRaw,
which build.rs invokes (map path as -Ppicodroid.shrinkMap) whenever the
active map has member rows. papk-pack --shrink-map translates the
onCreate it looks for in the entry point the same way.
Compatibility. Class renames are additive, so an older PAPK keeps
resolving on newer firmware. The first member map renamed existing
members, which that rule cannot survive: firmware at or past the member
floor (compat::MEMBER_SHRINK_FLOOR = 0.16.0, recorded as
member-floor in every map from there on) rejects a shrunk PAPK cut
before it with FrameworkVersionMismatch (pdb install says PAPK was
shrunk before method/field names were). Every workflow rebuilds PAPK and
firmware together; the check turns the stale case into a clear error
instead of a runtime NoSuchMethod. Un-keeping a member later would need
a new floor — don’t.
Enabling shrinking
Off by default. Opt in with --shrink on any top-level script:
./scripts/build.sh --app helloworld --shrink./scripts/flash.sh --app blinky --shrink./scripts/sim.sh --app helloworld --shrink./scripts/build-apk.sh --app helloworld --shrinkThe flag exports PICODROID_SHRINK=1, which both build.rs and
build-apk.sh pick up. Without it, both sides emit the 0.0.0
sentinel and no framework .class bytes are touched.
How builds consume the active map
When PICODROID_SHRINK=1, class-shrink print-version resolves the
active version from the root Cargo.toml + sdk/shrink-maps/. Both
sides of the build call it:
-
Firmware (
build.rs): after Gradle has compiled — and, for a debug-assertions-off build, stripped — the framework classes, if shrinking is on and the active version isn’t0.0.0, applies the map to them and embeds the shrunk output viaFRAMEWORK_CLASSES. Also writesframework_mapping_version.rs(the version string the firmware advertises) andframework_unshrink.rs(the reverse-translation table). -
Apps (
scripts/build-apk.sh): if shrinking is on, runsclass-shrink shrink-diron the app’s.classoutput. The map covers framework classes only, so the app’s own classes pass through unchanged — only cross-references likeLpicodroid/app/Application;in the app’s super_class get rewritten. -
PAPK manifest:
papk-packwrites the active version (or0.0.0when shrinking is off) into theframework-map-versionmanifest key. -
Load time:
platforms/rp/src/app.rscallspapk.verify_compat(FRAMEWORK_MAP_VERSION)right after parsing. A PAPK built with mismatched shrink settings (one side0.0.0, the other non-zero) is rejected with a hard error asking to rebuild.
Debug-attribute strip
Independent of the shrink map, and applied to everything bound for a device:
the .class files a device firmware carries lose the attributes pico-jvm
never reads there. LineNumberTable is parsed only in debug_assertions
builds (it feeds the (:line) in stack traces), and SourceFile and
StackMapTable have no reader at all — picodroid does not run a bytecode
verifier. Device firmware is built with debug-assertions off in both
profiles, so the three were dead flash: about 14 KB of SDK corpus on every
board plus 10–15 % of each PAPK. The measurements and the design are in
docs/designs/flash-string-budget-2026-08.md
§4.
How it is applied:
- SDK corpus (
build.rs) — whenCARGO_CFG_DEBUG_ASSERTIONSis absent (everybuild.sh/flash.shbuild, and--releasesim builds), the build script runs./gradlew :sdk:stripClassesand embedssdk/build/classes-stripped/java/maininstead ofcompileJava’s tree; the shrink step then runs on top, unchanged. The generatedframework_classes.rsrecords the choice inFRAMEWORK_CLASSES_DEBUG_STRIPPED, and apicodroid-coretest pins it to!cfg!(debug_assertions). - PAPKs —
scripts/build-apk.sh --strip-debug(Gradle property-Ppicodroid.stripDebug=true), which every device path passes:build.sh/flash.sh,hil-run.sh, the size ratchet and pre-commit’s firmware snapshot.sim.shand the other host paths leave it off, so a dev-profile sim run still showsat Foo.bar(:42)for app frames, and a Java PAPK built without the flag is byte-identical to before.
The rewrite is the ASM strip Kotlin apps already go through
(ClassStrip.kt):
it also drops annotations, InnerClasses, Signature and the other
attributes the JVM skips, and because each class is re-serialised without a
source reader the constant pool is rebuilt — the orphaned
"LineNumberTable", "Foo.java", … entries go with it. The result has no
StackMapTable, so a HotSpot JVM refuses to load it; only pico-jvm may
consume sdk/build/classes-stripped/ or a --strip-debug PAPK.
To inspect: javap -v on a class under sdk/build/classes-stripped/java/main
(or its shrunk copy under
target/<triple>/<profile>/build/picodroid-core-*/out/framework_classes_shrunk)
lists no LineNumberTable / SourceFile / StackMapTable; papk-info
shows the per-class sizes of a PAPK built with and without the flag.
Two things to keep in mind:
./gradlew :examples:<app>:installbuilds its PAPK in the same Gradle invocation, so it ships unstripped unless you pass-Ppicodroid.stripDebug=true— larger, not incorrect.- Anything that wants to read annotations from SDK or app classes (a
@KeepName-style keep, say) must read the compiler’s output, not what ships: the strip removes them.
Compatibility rules
verify_compat accepts these combinations and rejects all others:
| Firmware | PAPK | Accepted? | Why |
|---|---|---|---|
0.0.0 | 0.0.0 | Yes | Both unshrunk, names match. |
v ≥ 0.16.0 | v' < 0.16.0 | No (FrameworkVersionMismatch) | PAPK predates the member floor: its member names are unshrunk, the firmware’s are not. |
v (≥1) | v' (≥1) and v' ≤ v | Yes | Append-only maps: every shrunk name the PAPK uses is still present in firmware. |
v (≥1) | v' (≥1) and v' > v | No (FrameworkVersionMismatch) | PAPK may reference shrunk names added after firmware’s release. |
0.0.0 | non-zero | No | PAPK’s shrunk refs don’t exist in unshrunk firmware. |
| non-zero | 0.0.0 | No | PAPK’s original refs don’t exist in shrunk firmware. |
| anything | unversioned (legacy, pre-M1) | Only if firmware is 0.0.0 (FrameworkVersionMissing otherwise) | Backward compat. |
Native dispatch — class names reverse-translated, method names generated
Every (class, method) match arm in picodroid-core/src/native_handler/**
uses the original internal class name (e.g. "picodroid/pio/Gpio"). At
each dispatcher’s entry we call crate::shrink_names::unshrink_class
once, so the incoming shrunk name is translated back to the original
literal before the match runs. Method names take the other route: there is
no runtime translation. crate::shrink_names::m holds one const per SDK
method name whose value is whatever the loaded framework spells it, and
every arm and upcall matches through it:
use crate::shrink_names::m;
pub fn dispatch(class_name: &str, method_name: &str, ctx: &mut NativeContext<'_>) -> Option<Result<Option<Value>, JvmError>>{ let class_name = crate::shrink_names::unshrink_class(class_name); match (class_name, method_name) { ("picodroid/pio/Gpio", m::setValue) => ..., // m::setValue == "setValue", or "uQ" under --shrink // ... }}unshrink_class is code-generated at build time by picodroid-core/build.rs
(emitting framework_unshrink.rs into OUT_DIR) as a match on &'static str;
when no map is active it’s an identity passthrough — zero cost beyond one
function call. The m module comes from the same build script
(framework_member_names.rs), out of the committed sdk/member-names.tsv
— every method the SDK declares, kept current by the
member_names_are_current test and regenerated by
scripts/gen-api-contract.sh — crossed with the active map. A const in a
match pattern compiles to exactly the code the literal did, so dispatch
costs nothing extra in either mode (and the shrunk 1–2-byte names make
every failed-arm compare cheaper). Three tests keep this honest:
handled_rows_use_member_consts, no_sdk_method_literals_in_dispatch
(no "setText"-style literal anywhere in dispatch or upcall code) and
no_builtin_name_is_ever_mapped. Test-only unshrink_member /
shrink_member let the corpus-reading tests compare against
original-name tables in both lanes.
Keep list
sdk/keep.toml declares names the shrinker must never touch:
-
picodroid/annotation/KeepName(exact): the annotation class used by future method/field keeps in Java source. Such a keep must readcompileJava’s output — the debug-attribute strip removes annotations from what ships. -
kotlin/**(glob): the hand-written stdlib shim that rides inside Kotlin apps’ PAPKs (kotlin-shim/). kotlinc-compiled app classes name these classes literally, and maps are generated from the SDK set anyway, so this documents the invariant more than it enforces it. -
[[member]]names (main,injectMembers): methods Rust invokes by literal on app classes. The much larger member keep set — everyjava/**member pico-jvm serves — is not listed here;cut-releasereads it fromsdk/api-contract.tsv(see Member names).
Add an entry here before adding new framework surface that Rust
references by name in a way the reverse-translation layer can’t cover.
java/** is deliberately absent since v0.15.0: those names are shrunk
under b/ and undone inside pico-jvm, so nothing in Rust ever matches a
shrunk java/** spelling.
Cutting a release
Update sdk/shrink-maps/ whenever you bump the picodroid package
version:
# Fresh-compile the framework to a scratch dir.TMP=$(mktemp -d)find sdk/java -name '*.java' -print0 \ | xargs -0 javac --release 8 -Xlint:-options -d "$TMP"
# The kotlin-shim's member names must never become member targets../gradlew :kotlin-shim:compileJava -q
# Generate the map. --base copies the previous release verbatim so the# append-only invariant is enforced automatically; --extra-names feeds the# java/** names the framework never references itself from the committed# list of everything pico-jvm serves; --members allocates method/field# targets, keeping every --keep-contract member name and reserving every# name the --reserve tree spells.cargo run -p class-shrink -- cut-release --members \ --classes-dir "$TMP" \ --keep sdk/keep.toml \ --extra-names sdk/api-contract.tsv \ --keep-contract sdk/api-contract.tsv \ --reserve kotlin-shim/build/classes/java/main \ --base sdk/shrink-maps/v<previous>.toml \ --version <new> \ --out sdk/shrink-maps/v<new>.toml
# Commit both the map and the Cargo.toml version bump in the same commit.From that commit onwards, build.rs and scripts/build-apk.sh pick
up the new map automatically.
What’s committed
tools/class-shrink/— the shrinker binary and library.sdk/keep.toml— keep list.sdk/shrink-maps/v*.toml— one file per release, immutable.picodroid-core/src/shrink_names.rs— one-line module thatinclude!s the generatedunshrink_classfunction fromOUT_DIR(each family, e.g.platforms/rp/src/shrink_names.rs, re-exports it).
What’s generated at build time (OUT_DIR)
Always emitted:
framework_mapping_version.rs—pub const FRAMEWORK_MAP_VERSION: &str = "…";("0.0.0"when shrinking is off).framework_unshrink.rs—unshrink_class(name) -> &str. Identity passthrough when shrinking is off; a reverse-lookup match when on.framework_classes.rs—pub static FRAMEWORK_CLASSES: &[&[u8]] = &[…];pointing at (shrunk or raw) class files, plusFRAMEWORK_CLASSES_DEBUG_STRIPPED. For a debug-assertions-off build the raw tree issdk/build/classes-stripped/java/main, written by:sdk:stripClasses(see Debug-attribute strip).
Emitted only when shrinking is on and a map is active:
framework_classes_shrunk/…— shrunk class files.
CI coverage
Both scripts/sim-run.sh and
scripts/hil-run.sh run the full test matrix
twice — once with shrinking off, once with it on. Each result is
tagged with [no-shrink] or [shrink] so regressions on either side
are obvious. Pass --mode no-shrink, --mode shrink, or --mode both
(default) to narrow the run.
The HIL suite also exercises rejection paths — three test rows per mode (see scripts/hil-tests.conf):
| Row | What it tests |
|---|---|
install-reject-host | Build a PAPK in the OPPOSITE shrink mode of the firmware; assert pdb refuses pre-flight and the device still PINGs after. |
install-reject-device | Same as above but with --skip-host-check; assert the device returns STATUS_INCOMPAT in Phase A and stays alive. |
install-reject-future | Synthesize a future map (v0.<MIN+1>.0.toml) via scripts/test-future-version-rejection.sh, build a PAPK against it, assert rejection. Only meaningful in shrink mode. |
After every rejection, hil-run.sh runs a pdb ping to confirm the
device is responsive — a successful rejection must not have erased flash
or rebooted.
pdb install pre-flight
pdb install has two compatibility gates so a bad install never reboots
the device:
- Host pre-flight in tools/pdb/src/install.rs:
after PING, before sending the install header, parse the PAPK manifest
for
framework-map-version, compare to the firmware’s version learned from the new PING greeting, and exit with a clear error ifcompat::checkrejects. - Device-side check in picodroid-core/src/install/orchestrator.rs:
after stopping the JVM but before erasing flash, peek the first
INSTALL_PEEK_BYTES(512) of the PAPK off the wire, runcompat::check, and replySTATUS_INCOMPATon mismatch. The host inlines those bytes right after the install header so the peek doesn’t stall.
The PING greeting was bumped from picodroid/2.0 to picodroid/2.1 and
gained a trailing [u8 len][N bytes] field for the firmware’s
framework-map-version. pdb install hard-refuses old picodroid/2.0
firmware (you must reflash via SWD) since it can’t verify compatibility.
For testing, pdb install accepts two flags (used by the HIL reject rows):
--skip-host-check (bypass the host pre-flight) and --expect-rejected
(invert exit codes — refusal = success).
Diagnosing version mismatch
PapkError::FrameworkVersionMismatch means the PAPK was packaged
against a shrink map newer than what the firmware knows. Rebuild the
PAPK against matching firmware:
./scripts/build-apk.sh --app <name>PapkError::FrameworkVersionMissing means the PAPK predates the
manifest key but the firmware has a shrink map active. Again, rebuild.