Skip to content

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/Gpioa/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.toml is the active map. “Keyed” = highest committed v<semver>.toml whose semver is ≤ the package version.
  • If no map is committed at or below the current version, the active version is the sentinel 0.0.0 and 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:

MapCovers
sdk/shrink-maps/v0.1.0.tomlOriginal 42 framework classes from the first release cut.
sdk/shrink-maps/v0.2.0.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlStable — 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.tomlAdds 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.tomlStable — 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.tomlStable — 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.tomlAdds 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.tomlAdds 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.tomlStable — 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.tomlStable — 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.tomlAdds 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.tomlSchema 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.tomlOpens 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/Activitya/A, picodroid/app/Applicationa/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 — setTextuQ, nativeCreatemU — 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 a java/** or kotlin/** class or of an annotation interface;
  • every member name in sdk/api-contract.tsvtoString, equals, hashCode, run, compare, compareTo, hasNext, next, close, getMessage, name, ordinal, get, put, size, append, … These belong to java/** classes that have no class file (pico-jvm serves them from Rust arms), so the name itself is the contract: an app’s Point.toString() renamed to q would never be found by the interpreter’s "" + point, which invokes toString by literal and falls through to the builtin Object.toString;
  • [[member]] entries in sdk/keep.toml (main, injectMembers — invoked by literal from Rust on app classes).

Targets are az, AZ, 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:

Terminal window
./scripts/build.sh --app helloworld --shrink
./scripts/flash.sh --app blinky --shrink
./scripts/sim.sh --app helloworld --shrink
./scripts/build-apk.sh --app helloworld --shrink

The 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:

  1. 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’t 0.0.0, applies the map to them and embeds the shrunk output via FRAMEWORK_CLASSES. Also writes framework_mapping_version.rs (the version string the firmware advertises) and framework_unshrink.rs (the reverse-translation table).

  2. Apps (scripts/build-apk.sh): if shrinking is on, runs class-shrink shrink-dir on the app’s .class output. The map covers framework classes only, so the app’s own classes pass through unchanged — only cross-references like Lpicodroid/app/Application; in the app’s super_class get rewritten.

  3. PAPK manifest: papk-pack writes the active version (or 0.0.0 when shrinking is off) into the framework-map-version manifest key.

  4. Load time: platforms/rp/src/app.rs calls papk.verify_compat(FRAMEWORK_MAP_VERSION) right after parsing. A PAPK built with mismatched shrink settings (one side 0.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) — when CARGO_CFG_DEBUG_ASSERTIONS is absent (every build.sh / flash.sh build, and --release sim builds), the build script runs ./gradlew :sdk:stripClasses and embeds sdk/build/classes-stripped/java/main instead of compileJava’s tree; the shrink step then runs on top, unchanged. The generated framework_classes.rs records the choice in FRAMEWORK_CLASSES_DEBUG_STRIPPED, and a picodroid-core test pins it to !cfg!(debug_assertions).
  • PAPKsscripts/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.sh and the other host paths leave it off, so a dev-profile sim run still shows at 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>:install builds 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:

FirmwarePAPKAccepted?Why
0.0.00.0.0YesBoth unshrunk, names match.
v ≥ 0.16.0v' < 0.16.0No (FrameworkVersionMismatch)PAPK predates the member floor: its member names are unshrunk, the firmware’s are not.
v (≥1)v' (≥1) and v' ≤ vYesAppend-only maps: every shrunk name the PAPK uses is still present in firmware.
v (≥1)v' (≥1) and v' > vNo (FrameworkVersionMismatch)PAPK may reference shrunk names added after firmware’s release.
0.0.0non-zeroNoPAPK’s shrunk refs don’t exist in unshrunk firmware.
non-zero0.0.0NoPAPK’s original refs don’t exist in shrunk firmware.
anythingunversioned (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 read compileJava’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 — every java/** member pico-jvm serves — is not listed here; cut-release reads it from sdk/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:

Terminal window
# 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 that include!s the generated unshrink_class function from OUT_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.rspub const FRAMEWORK_MAP_VERSION: &str = "…"; ("0.0.0" when shrinking is off).
  • framework_unshrink.rsunshrink_class(name) -> &str. Identity passthrough when shrinking is off; a reverse-lookup match when on.
  • framework_classes.rspub static FRAMEWORK_CLASSES: &[&[u8]] = &[…]; pointing at (shrunk or raw) class files, plus FRAMEWORK_CLASSES_DEBUG_STRIPPED. For a debug-assertions-off build the raw tree is sdk/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):

RowWhat it tests
install-reject-hostBuild a PAPK in the OPPOSITE shrink mode of the firmware; assert pdb refuses pre-flight and the device still PINGs after.
install-reject-deviceSame as above but with --skip-host-check; assert the device returns STATUS_INCOMPAT in Phase A and stays alive.
install-reject-futureSynthesize 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:

  1. 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 if compat::check rejects.
  2. 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, run compat::check, and reply STATUS_INCOMPAT on 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:

Terminal window
./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.