Shrinker
Picodroid ships a build-time name shrinker (tools/class-shrink/ plus a
Gradle ASM pass) that rewrites .class files to use short synthetic
names — picodroid/pio/Gpio → a/S, java/lang/String → b/AQ,
setText → uQ — the way ProGuard and R8 do. It trims kilobytes from
firmware flash and from every .papk without any change to Java source:
the Rust runtime is compiled against the same spellings, so nothing is
translated back at run time. --shrink-app extends the rename to an
app’s own classes and members, and scripts/retrace.sh turns a shrunk
log back into original names.
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 keeps every mapping stable once cut: a name a PAPK-at-version-P refers to is present in firmware-at-version-F ≥ P.
PAPK compatibility is enforced at load time
(crates/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
Thirty-eight release maps are committed today, v0.1.0 through v0.38.0:
| 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.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. |
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.17.0.toml | Maps the last kept names (+125 members, 868 → 993): the java/** contract members the runtime serves (toString, hashCode, equals, hasNext, …) and javac’s $ synthetics, previously kept because the Rust arms matched them by literal — the arms now match through the generated m:: constants. member-floor re-based to 0.17.0; only main and injectMembers stay verbatim. Classes unchanged (238). |
sdk/shrink-maps/v0.18.0.toml | Adds the Tier 1 small-methods surface — one class (java.util.Objects, 238 → 239) and 14 members (993 → 1007): getFloat / putFloat, DIRECTION_IN, createNewFile / mkdirs / getParent / getParentFile / getAbsolutePath, hash / isNull / nonNull / requireNonNull, intBitsToFloat, T_FLOAT. member-floor stays 0.17.0; every v0.17.0 mapping copied verbatim. |
sdk/shrink-maps/v0.19.0.toml | Adds picodroid.net.ConnectivityManager (239 → 240) and its four type constants (1007 → 1011 members). Every v0.18.0 mapping copied verbatim. |
sdk/shrink-maps/v0.20.0.toml | Adds the picodroid.json surface (+4 classes, 240 → 244: JSONObject, JSONArray, JSONException, JSONObject$1) and 83 members (1011 → 1094). Every v0.19.0 mapping copied verbatim. |
sdk/shrink-maps/v0.21.0.toml | Adds the multi-app package surface (+5 classes, 244 → 249: ActivityNotFoundException, PackageInfo, ApplicationInfo, PackageManager$NameNotFoundException, BitmapDrawable) and 31 members (1094 → 1125). Every v0.20.0 mapping copied verbatim. |
sdk/shrink-maps/v0.22.0.toml | Adds the storage surface (+6 classes, 249 → 255: Build, Build$VERSION, StatFs, StorageStatsManager, StorageStats, PackageInstaller) and 40 members (1125 → 1165). Every v0.21.0 mapping copied verbatim. |
sdk/shrink-maps/v0.23.0.toml | Adds the TextView line-mode surface (+2 classes, 255 → 257: picodroid.text.TextUtils, TextUtils$TruncateAt) and 15 members (1165 → 1180: setSingleLine, setEllipsize / getEllipsize, setMaxLines / getMaxLines, the packed mLineMode state and the native* bindings). member-floor stays 0.17.0; every v0.22.0 mapping copied verbatim. |
sdk/shrink-maps/v0.24.0.toml | Adds the alarm surface (+2 classes, 257 → 259: picodroid.app.AlarmManager, picodroid.app.PendingIntent) and 29 members (1180 → 1209: the four clock constants, the six PendingIntent flags, getActivity / requestCode and the flattened extras, fireAlarm and the native* bindings, plus ALARM_SERVICE, elapsedRealtime, Intent.setClassName and KEYCODE_HOME). member-floor stays 0.17.0; every v0.23.0 mapping copied verbatim. |
sdk/shrink-maps/v0.25.0.toml | Adds the tone surface (+2 classes, 259 → 261: picodroid.media.ToneGenerator, picodroid.media.AudioManager) and 45 members (1209 → 1254: the sixteen TONE_DTMF_*, ten TONE_SUP_* and five TONE_PROP_* constants, the eight STREAM_* types, MAX_SEQUENCE_LENGTH, and startTone / startToneSequence / stopTone / release / nativeInit). member-floor stays 0.17.0; every v0.24.0 mapping copied verbatim. |
sdk/shrink-maps/v0.26.0.toml | Adds the format-exception family (+4 classes, 261 → 265: java.util.IllegalFormatConversionException, IllegalFormatPrecisionException, MissingFormatArgumentException, UnknownFormatConversionException) and 12 members (1254 → 1266: the released-view API — isReleased / checkNotReleased / mChildren / mChildCount / nativeAddView / nativeRemoveView / nativeRemoveAllViews — the radio-group sync mGroup / setCheckedSilently / onButtonChecked / nativeSetChecked, and the preferences writeInPlace). member-floor stays 0.17.0; every v0.25.0 mapping copied verbatim. |
sdk/shrink-maps/v0.27.0.toml | Adds the saved-instance-state and resources surface (+5 classes, 265 → 270: picodroid.os.Bundle, picodroid.content.res.Resources, Resources$NotFoundException, picodroid.view.LayoutInflater, InflateException) and 44 members (1266 → 1310). The Bundle accessors and the lifecycle hooks that carry one — getBundle / getByteArray / getIntArray / getStringArray / getExtras / putAll / putBundle / putByteArray / putDouble / putExtras / putIntArray / putStringArray / extras / vals, onSaveInstanceState / onRestoreInstanceState, performCreate / performSaveInstanceState / performRestoreInstanceState, recreate, and Intent.getBundleExtra / getLongExtra. The resource lookups — getResources / getColor / getInteger / getDimension / getDimensionPixelOffset / getDimensionPixelSize, with sInstance / string / from. The inflater — inflate / getContext / getLayoutInflater / mContentView / mContext / mLayout / mPos / node / nativeWord / truncateAt, plus findViewById / findViewTraversal and ImageView.setImageResource. member-floor stays 0.17.0; every v0.26.0 mapping copied verbatim. |
sdk/shrink-maps/v0.29.0.toml | Adds the keys, text gravity and java.time surface (+27 classes, 274 → 301: java/time/DateTimeException, DayOfWeek, Duration, Instant, LocalDate, LocalDateTime, LocalTime, Month, Year, ZoneId (+ ZoneId$Fixed), ZoneOffset, chrono/ChronoLocalDate, chrono/ChronoLocalDateTime, format/DateTimeFormatter, temporal/ChronoUnit, temporal/Temporal, temporal/TemporalAccessor, temporal/TemporalUnit, zone/ZoneRules and java/util/TimeZone, the four $1 switch-map classes javac made for ChronoUnit switches, and the name-only temporal/TemporalAmount / temporal/TemporalQuery the contract lists) and 227 members (1402 → 1629): the Activity key path (onKeyDown / onKeyUp / performKeyDown / performKeyUp, KeyEvent.startTracking / isTracking / tracking), TextView.setGravity / getGravity / mGravity / nativeSetGravity and the Gravity masks, Math.floorDiv / floorMod / addExact / subtractExact / multiplyExact / toIntExact, and the whole java.time surface. member-floor stays 0.17.0; every v0.28.0 mapping copied verbatim. |
sdk/shrink-maps/v0.30.0.toml | Adds View.getParent() (+1 class, 301 → 302: picodroid/view/ViewParent) and 3 members (1629 → 1632): mParent, detachChild and nativeClose, the parent back-pointer and the detaching View.close(). member-floor stays 0.17.0; every v0.29.0 mapping copied verbatim. |
sdk/shrink-maps/v0.31.0.toml | Adds custom drawing and UDP broadcast (+6 classes, 302 → 308: picodroid/graphics/Canvas, Paint, Paint$Align, Paint$Cap, Paint$Style and picodroid/view/View$DrawTask) and 54 members (1632 → 1686): onDraw / invalidate / postInvalidate / performDraw / scheduleDraw with mCanvas / mDrawPending / mDrawTask / sMainExecutor, the Canvas draw calls (drawColor / drawRect / drawRoundRect / drawCircle / drawLine / drawArc / drawText) and their native* bindings, the Paint accessors (setARGB, setAntiAlias / isAntiAlias, setFlags / getFlags, style, stroke width and cap, text align, ascent / descent / measureText, reset, the STROKE / FILL_AND_STROKE / BUTT / ROUND / SQUARE constants) and DatagramSocket.setBroadcast / getBroadcast. member-floor stays 0.17.0; every v0.30.0 mapping copied verbatim. |
sdk/shrink-maps/v0.32.0.toml | Adds java.util.zip.CRC32, the main-thread ScheduledExecutorService and ConnectivityManager.NetworkCallback (+11 classes, 308 → 319: java/util/zip/CRC32 and Checksum; picodroid/concurrent/ScheduledExecutorService, ScheduledFuture, MainScheduledExecutor and ScheduledFutureTask; picodroid/net/ConnectivityManager$NetworkCallback, Network, NetworkCapabilities, NetworkRequest and NetworkRequest$Builder) and 76 members (1686 → 1762): updateBytes / crc, the scheduler (newSingleThreadScheduledExecutor, schedule / scheduleAtFixedRate / scheduleWithFixedDelay, getDelay / isPeriodic / runAndReset, the schedule0 / cancel0 / completed0 / delay0 bindings, arm / armed / cancelArmed, ONE_SHOT / FIXED_RATE / FIXED_DELAY) and the connectivity surface (registerDefaultNetworkCallback / registerNetworkCallback / requestNetwork / unregisterNetworkCallback, getActiveNetwork / getNetworkCapabilities, onAvailable / onCapabilitiesChanged / onLost / onLosing / onUnavailable, fireLinkChange / linkChanged / syncActive / deliverInitial, the NET_CAPABILITY_* and TRANSPORT_* constants, the NetworkRequest.Builder setters). member-floor stays 0.17.0; every v0.31.0 mapping copied verbatim. |
sdk/shrink-maps/v0.33.0.toml | Adds picodroid.protobuf and key long-press / auto-repeat (+9 classes, 319 → 328: picodroid/protobuf/CodedInputStream, CodedOutputStream, CodedOutputStream$OutOfSpaceException, InvalidProtocolBufferException, MessageLite and WireFormat; picodroid/view/KeyEvent$Callback, KeyEvent$DispatcherState and ViewConfiguration) and 176 members (1762 → 1938): the protobuf streams (readTag / readInt64 / readString / readBytes / … / skipField / skipMessage, pushLimit / popLimit / isAtEnd / getBytesUntilLimit, the write* / write*NoTag and compute*Size families, writeRawVarint32 / writeRawVarint64, encodeZigZag* / decodeZigZag*, getSerializedSize / writeTo / toByteArray, the WIRETYPE_* constants, makeTag / getTagWireType / getTagFieldNumber, the exception factories and the native* codec bindings) and the key path (dispatch, onKeyLongPress / onKeyMultiple / performKeyEvent / handleUpEvent, getRepeatCount / isLongPress / isCanceled / getDownTime, the FLAG_LONG_PRESS / FLAG_CANCELED / FLAG_CANCELED_LONG_PRESS / FLAG_TRACKING / FLAG_START_TRACKING flags, getLongPressTimeout / getKeyRepeatTimeout / getKeyRepeatDelay, mKeyDispatchState). member-floor stays 0.17.0; every v0.32.0 mapping copied verbatim. |
sdk/shrink-maps/v0.34.0.toml | Adds picodroid.net.wifi (+7 classes, 328 → 335: picodroid/net/wifi/ScanResult, SupplicantState, WifiConfiguration, WifiConfiguration$Status, WifiInfo, WifiManager and WifiManager$ScanResultsCallback) and 86 members (1938 → 2024): WIFI_SERVICE, isWifiEnabled / setWifiEnabled / getWifiState, startScan / getScanResults / registerScanResultsCallback / unregisterScanResultsCallback / onScanResultsAvailable, getConnectionInfo / getConfiguredNetworks / addNetwork / updateNetwork / enableNetwork / removeNetwork / reconnect / reassociate, calculateSignalLevel / compareSignalLevel, the WifiInfo getters, the ScanResult and WifiConfiguration fields, the SupplicantState and WifiConfiguration.Status constants, the native* link bindings, and the InputType password variations (TYPE_TEXT_VARIATION_WEB_PASSWORD / TYPE_TEXT_VARIATION_VISIBLE_PASSWORD / TYPE_NUMBER_VARIATION_PASSWORD). member-floor stays 0.17.0; every v0.33.0 mapping copied verbatim. |
sdk/shrink-maps/v0.35.0.toml | Adds Fragments, ViewPager2 and HTTPS (+13 classes, 335 → 348: picodroid/app/Fragment, FragmentFactory, FragmentManager, FragmentManager$OnBackStackChangedListener, FragmentTransaction, picodroid/widget/ViewPager2, ViewPager2$OnPageChangeCallback, FragmentStateAdapter, picodroid/net/ssl/HttpsURLConnection, picodroid/net/SntpClient, javax/net/ssl/SSLException, SSLHandshakeException and SSLPeerUnverifiedException) and the 233 members they declare (2024 → 2257). The v0.35.0 release. member-floor stays 0.17.0; every v0.34.0 mapping copied verbatim. |
sdk/shrink-maps/v0.36.0.toml | Adds picodroid.lifecycle, the options menu and the app-portability surface (+35 classes, 348 → 383: picodroid/lifecycle/Lifecycle, LifecycleOwner, LiveData (+$ObserverWrapper), MutableLiveData, Observer, ViewModel, ViewModelProvider (+$Factory), ViewModelStore, ViewModelStoreOwner and HasDefaultViewModelProviderFactory; picodroid/app/OptionsMenu (+$Item), picodroid/view/Menu, MenuItem (+$OnMenuItemClickListener); picodroid/content/res/Configuration, picodroid/view/KeyCharacterMap, picodroid/os/PowerManager, picodroid/provider/Settings (+$System), picodroid/content/ContentResolver; picodroid/view/AsyncLayoutInflater (+$OnInflateFinishedListener, $Request), LayoutInflater$Factory, picodroid/util/AttributeSet; View$MeasureSpec, ViewGroup$MarginLayoutParams, picodroid/widget/Space; picodroid/content/ComponentName, picodroid/os/Binder, picodroid/concurrent/ScheduledThreadPoolExecutor (+$Task)) and the 251 members they declare (2257 → 2508). The v0.36.0 release. member-floor stays 0.17.0; every v0.35.0 mapping copied verbatim. |
sdk/shrink-maps/v0.37.0.toml | Adds java.io streams, reflection-lite and the time setting (+20 classes, 383 → 403: java/io/InputStream, OutputStream, ByteArrayInputStream, ByteArrayOutputStream, Reader, Writer, InputStreamReader, BufferedReader, OutputStreamWriter, PrintWriter, Closeable and UnsupportedEncodingException; java/lang/ReflectiveOperationException, ClassNotFoundException, InstantiationException and IllegalAccessException; picodroid/lifecycle/ViewModelProvider$NewInstanceFactory, picodroid/provider/Settings$Global, picodroid/net/SocketInputStream and SocketOutputStream) and the 63 members they declare (2508 → 2571). The v0.37.0 release. member-floor stays 0.17.0; every v0.36.0 mapping copied verbatim. |
sdk/shrink-maps/v0.38.0.toml | Adds broadcasts (+5 classes, 403 → 408: picodroid/content/BroadcastReceiver, picodroid/content/IntentFilter, picodroid/widget/InflaterSupport, java/lang/NoClassDefFoundError and java/lang/Deprecated) and the 138 members added since v0.37.0 (2571 → 2709): the broadcast surface (registerReceiver / unregisterReceiver / sendBroadcast / onReceive / addAction / hasAction / countActions / matchAction, the ACTION_* / EXTRA_* constants and the network actions), getSerial, Snackbar.isShown, getParcelable / putParcelable / getParcelableExtra, the one-native inflater (nativeInflateNode / attachInflated / onViewAdded) and DatePicker’s Android API (updateDate / dayOfMonth / nativeGetYear / nativeGetMonth / nativeGetDay / nativeSetDate). The v0.38.0 release. member-floor stays 0.17.0; every v0.37.0 mapping copied verbatim. |
sdk/shrink-maps/v0.28.0.toml | Adds the text-size, ring-gauge and ProgressBar tint surface (+4 classes, 270 → 274: picodroid.util.TypedValue, picodroid.util.DisplayMetrics, picodroid.content.res.ColorStateList, picodroid.widget.CircularProgressIndicator) and 92 members (1310 → 1402). Text size — setTextSize / getTextSize / getLineHeight, COMPLEX_UNIT_* / applyDimension, getDisplayMetrics and the DisplayMetrics fields. ProgressBar — getMax / setMin / getMin / incrementProgressBy and the three tint-list setters and getters, with ColorStateList.getDefaultColor / getColorForState / isStateful / withAlpha. The ring — the indicator/track colour, thickness, size, direction, corner-radius and start/sweep-angle accessors and their native* bindings. The Activity callback trampolines — performStart / performResume / performRestart / performPause / performStop / performDestroy / performBackPressed / performActivityResult. member-floor stays 0.17.0; every v0.27.0 mapping copied verbatim. |
Scope
Maps rename class names and 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/**) and b/ for the java/** classes pico-jvm
serves natively. Nothing translates either prefix back at run time: the Rust
runtime is compiled against constants generated from the active map
(c::java_lang_String is "java/lang/String" in a no-shrink build and
"b/AQ" under --shrink — see Native dispatch),
so the JVM’s tables, catch matching, instanceof, native dispatch and
Class.getName() all use the one spelling the class files carry. A
--shrink image contains no original name at all, exactly as a ProGuard
build does; "x".getClass().getName() returns b.AQ. Build without
--shrink for readable names, or pipe a shrunk log through
scripts/retrace.sh (see Reading a shrunk log).
- 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 mapped: every method and field name the SDK declares, javac’s
synthetics ($VALUES, lambda$…, access$…) included, and every
member name in sdk/api-contract.tsv — toString, equals,
hashCode, run, compare, compareTo, hasNext, next, close,
getMessage, name, ordinal, get, put, size, append, … Those
belong to java/** classes that have no class file (pico-jvm serves them
from Rust arms), and until v0.17.0 they were kept because the arms
matched them by literal. The arms now match through the generated m::
constants, so an app’s Point.toString() and the interpreter’s "" + point rename in lockstep and the mapped name is found exactly as the
original was.
What is never mapped (cut-release enforces all of it):
<init>/<clinit>, names of two characters or fewer, and every member of akotlin/**class (the shim is reserved, not renamed) or of an annotation interface;[[member]]entries insdk/keep.toml(main,injectMembers— app entry points invoked by literal from Rust on classes the SDK never declares, kept the way ProGuard keepsmain).
Targets are a–z, A–Z, then two characters (letter + letter/digit),
then three (letter + two letters/digits) once the pairs run out, with
all-lowercase names excluded — apps declare members called id, io,
eq, of, get, run, 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. The release maps take about 2,300 of the 3,276 one- and
two-character names by v0.35.0; an app cut that continues past the last
pair gets three-character names.
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.
The member floor. Class renames are additive. A map that renames
existing members is not, so it moves the member floor
(compat::MEMBER_SHRINK_FLOOR, recorded as member-floor in every map),
and firmware at or past the floor 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 a stale pair into a clear error instead of a
runtime NoSuchMethod. Un-keeping another member later would need
another floor.
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.
App shrinking (--shrink-app)
--shrink-app (on build-apk.sh, build.sh, flash.sh, sim.sh;
env PICODROID_SHRINK_APP=1) renames the app’s own classes and
private members too, ProGuard-style. It requires --shrink: the app’s
member targets are allocated by continuing the release map’s counter,
so there is nothing to build on without that map.
What happens, per PAPK build (buildSrc task cutAppShrinkMap):
class-shrink cut-appcopies the active release map and appends- every class the app defines, under a third prefix
c/(c/A,c/B, …), exceptkotlin/**(the shim is kept, see below); - every method/field name an app class declares that is longer than
two characters and not already in the release map (SDK overrides
such as
onCreaterename in lockstep through the release rows), not kept (main,injectMembers), and not spelled by a kept class; - every name an app class declares that is itself a release-map
target, whatever its length (see Name collisions).
Targets skip every name the app tree,
sdk/member-names.tsv,sdk/api-contract.tsv, the keep list and the release map spell.
- every class the app defines, under a third prefix
shrinkMembers,shrinkClassesandpapk-packread that merged map instead of the release map.papk-packspells the manifest entry (main-class/activity/application) through it.build-apk.shcopies the merged map next to the PAPK (build/apks/<app>-shrunk.papk) asbuild/apks/<app>-shrunk.shrink-map.toml. That file is the PAPK’s retrace key:./scripts/retrace.sh build/apks/<app>-shrunk.shrink-map.toml < log.
The firmware is untouched: it resolves app classes only through the
PAPK’s own class table, and framework-map-version still names the
release map. The one structural dependency is the @Inject support —
lifecycle.rs derives <runtime class name, $→_>_MembersInjector from
the component’s name — so cut-app names an injector after its
component’s shrunk name (c/A → c/A_MembersInjector); an injector
whose component is kept is kept too.
Consequences and limits:
Class.getName()on an app class returnsc.A; string literals are never rewritten, so an app that comparesgetName()against its own name literal sees a mismatch (examples/classlitaccepts either).- Log lines, stack traces and
pdboutput spellc/A.qZ— retrace with the app map, not the release map. - Generic
Signatureattributes are not rewritten; device PAPKs strip them, an unstripped (sim) PAPK keeps original names inside them. - The kotlin-shim stays verbatim: its classes are keep-globbed and its member names are neither candidates nor targets.
Name collisions
An app never renames anything to be shrunk. The release map hands out the
shortest names first, so almost every one- and two-character name (p,
x, A, id-shaped pairs) is some framework member’s target, and a/,
b/ and c/ are the framework’s packages. An app that spells one of them
would alias the framework’s member or class at run time: a field p on a
View subclass shadowing the framework’s p, a method p()V overriding
it, an app class a/A hidden behind the framework’s a/A.
cut-app renames the app’s side instead, in both shrink modes:
- A member name an app class declares that is a release target gets a
fresh target (
p→Qz), together with every call site. Such a name is never an SDK original (release targets skip every name the SDK spells), so no override or framework call can depend on it. - An app class under
a/,b/orc/, or in the default package, moves to a freshc/name. A default-package class’s references move to a new constant-pool string, so a"Main"log tag or a member named like the class keeps its spelling.
Plain --shrink runs the same cut with --declash-only: those renames
and nothing else, so a clash-free app shrinks byte-for-byte as before. The
merged map lands next to the PAPK in both modes; retrace with it.
Two cases remain errors, because the app cannot mean what it wrote:
- An app class spelled exactly like a framework class (its own
java/lang/Math). The framework’s copy always wins, as the boot classpath does on Android. - A colliding member name spelled by a kept class (
kotlin/**,sdk/keep.toml) or an annotation element: those stay verbatim by contract.
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 stripped the framework classes, if shrinking is on and the active version isn’t0.0.0, applies the map to them. The surviving classes are then linked into one class section (FRAMEWORK_CLSS, see Linking comes last) and embedded. Also writesframework_mapping_version.rs(the version string the firmware advertises) andnames.rs(thec::/m::/d::constants, spelled through the same map). -
Apps (
scripts/build-apk.sh): if shrinking is on, runsclass-shrink shrink-diron the app’s.classoutput. The release map covers framework classes only, so by default the app’s own classes pass through unchanged — only cross-references likeLpicodroid/app/Application;in the app’s super_class get rewritten.--shrink-app(below) extends that to the app itself. -
PAPK manifest:
papk-packwrites the active version (or0.0.0when shrinking is off) into theframework-map-versionmanifest key. -
Load time:
crates/picodroid-core/src/boot.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. The firmware build runs the same check on the system apps it embeds (the launcher, the settings app), so a mismatch there fails the build instead of the boot.
Linking comes last
Since PAPK v2 (2026-09-28) papk-pack and the firmware build link every
class after the renames: each class gets a table holding a hash of its
name, of its superclass and of each interface, and a signature hash
(name plus descriptor) per method, and the set gets a class index sorted
by name hash (crates/class-link). The hashes are 32-bit FNV-1a over the
names as the class file spells them, so under --shrink they are hashes
of the shrunk names, the same ones the runtime computes over its c:: /
m:: constants. Nothing in the shrinker changed for it. Since PAPK v3
(2026-10-13) the section also keeps every Utf8 constant of the set once,
in a shared pool, and each class keeps only the entry’s tag byte, so the
bytes in the PAPK are what the shrinker wrote minus those constants;
class_link::build::unpooled_class puts them back.
Two names in one set that hash alike cannot be told apart by the index,
so the linker refuses the set (two class names hash alike), as it
refuses two classes that spell the same name. For a framework class the
way out is a new spelling from the next map cut, not a weaker lookup.
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. StackMapTable has no reader at all — picodroid does not
run a bytecode verifier — and LineNumberTable + SourceFile are read only
by a firmware built with the line-numbers cargo feature, which feeds the
(File.java:42) in stack traces. scripts/flash.sh enables that feature for
its default debug profile and drops it for --release, and builds the PAPK
to match (build-apk.sh --strip-debug --keep-lines keeps just those two
attributes). Release firmware therefore carries none of the three: about
15 KB of SDK corpus on every board plus 10–15 % of each PAPK, and its stack
traces print the bytecode offset, (pc=9), which scripts/retrace.sh
resolves on the host (below). The measurements and the design are in
docs/designs/flash-string-budget-2026-08.md
§4.
How it is applied:
- SDK corpus (
build.rs) — every build embeds a stripped tree, and theline-numberscargo feature picks which: without it the build script runs./gradlew :sdk:stripClassesand embedssdk/build/classes-stripped/java/main; with it (the simulator, or a firmware built withPICODROID_LINE_NUMBERS=1) it runs:sdk:stripClassesLinesand embedssdk/build/classes-stripped-lines/java/main, which keepsLineNumberTableandSourceFile. Under a member map the tasks are:sdk:shrinkMembersStripped/:sdk:shrinkMembersStrippedLines. The class-name shrink then runs on top, unchanged. The generatedframework_classes.rsrecords the choice inFRAMEWORK_CLASSES_LINE_NUMBERS, and apicodroid-coretest pins it tocfg!(feature = "line-numbers"). The RP2040 gets no line numbers in either profile (they cost its debug image about 27 KB);PICODROID_LINE_NUMBERS=0|1overrides the choice on any board. - 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 ≥ floor | v' < floor | No (FrameworkVersionMismatch) | PAPK is below the member floor: names the firmware spells mapped are verbatim in the PAPK. |
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 | no key | Only if firmware is 0.0.0 (FrameworkVersionMissing otherwise) | A hand-assembled file; the packer always writes the key. |
Native dispatch — every name generated
Rust never spells a Java class, member or descriptor as a string literal.
crates/build_support/names.rs — run by both crates/jvm/build.rs and
crates/picodroid-core/build.rs — turns three committed lists into three
const modules whose values are whatever the loaded framework spells:
| Module | Source | Example | no-shrink | --shrink |
|---|---|---|---|---|
c:: | sdk/class-names.tsv + the contract’s classes | c::picodroid_pio_Gpio | "picodroid/pio/Gpio" | "a/S" |
m:: | sdk/member-names.tsv + the contract’s members | m::setValue | "setValue" | "uQ" |
d:: | sdk/descriptors.tsv | d::String__V | "(Ljava/lang/String;)V" | "(Lb/AQ;)V" |
ch:: / mh:: | the c:: / m:: lists | ch::picodroid_pio_Gpio, mh::setValue | fnv1a("picodroid/pio/Gpio") | fnv1a("a/S") |
ch:: and mh:: are the 32-bit FNV-1a hash of each c:: / m:: constant’s
loaded spelling, the hash the link tables already carry, and the generator
refuses two spellings in one namespace that hash alike. Since 2026-10-12 a
native dispatch arm matches the hashes: each handler hashes the two names
once per call (pico_jvm::NativeKey) and the arms compare u32s, which
compile to compare trees instead of string compares. Every DISPATCH_SITES
row, every PICODROID_NATIVE_CLASSES / BUILTIN_CLASS_NAMES entry, every
catch and instanceof table, and every Rust-side allocation still goes
through c:: / m:: / d:::
use crate::shrink_names::{ch, mh};
pub fn dispatch(key: NativeKey, ctx: &mut NativeContext<'_>) -> Option<Result<Option<Value>, JvmError>>{ match (key.class, key.method) { (ch::picodroid_pio_Gpio, mh::setValue) => ..., // ... }}A const in a match pattern compiles to exactly the code the literal
did, so dispatch costs nothing in either mode, and a shrink build has no
second copy of any name — the unshrink_class match this replaced was
12 KB of code plus 5 KB of original names in every shrink image. The
lists are kept current by picodroid-core’s class_names_are_current /
member_names_are_current tests (scripts/gen-api-contract.sh
regenerates them); sdk/descriptors.tsv is hand-maintained. Three tests
keep the invariant: handled_rows_use_member_consts,
no_original_name_literals (no "java/…", "picodroid/…", "(L…;)…"
or served-member literal in any non-test source of either crate), and
names_agree_with_pico_jvm (both crates generated the same spellings).
pre-commit --full additionally links the rp2350 --release --shrink
image and greps it (scripts/check-shrunk-image.sh). Test-only
unshrink_class / unshrink_member / unshrink_descriptor and the
fixture respeller pico_jvm::names::spelled let the corpus-reading tests
and the JVM’s hand-assembled class-file fixtures run in both lanes.
Reading a shrunk log
A --shrink firmware prints mapped names in Class.getName(), uncaught
exception banners, stack traces and pdb output. scripts/retrace.sh
(class-shrink retrace --map <file>) is the host-side inverse, as
ProGuard’s retrace is: it substitutes a/DK / a.DK / b/AK / b.AK
tokens and member targets in .name( position back to their originals
and passes everything else through.
It also resolves stack-trace frames. A release firmware carries no
LineNumberTable and prints at pkg.Class.method(pc=9); retrace.sh
reads the unstripped class trees this checkout compiled — the SDK’s
sdk/build/classes/java/main always, the app’s with --app <name> — and
rewrites the frame to the at pkg.Class.method(Class.java:42) the sim and
a debug-profile device print themselves. Names are un-shrunk first, so the
two compose. A frame that resolves to nothing is left alone; overloads of
one name that disagree list every candidate (Class.java:12|40), since
the frame carries no descriptor. The trees must come from the same sources
the device is running.
./scripts/sim.sh --app foo --shrink 2>&1 | ./scripts/retrace.sh./scripts/retrace.sh sdk/shrink-maps/v0.18.0.toml < build/hil/logs/foo.shrink.log./scripts/retrace.sh --app foo < rtt-release.log # (pc=N) -> (Foo.java:LINE)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 (sdk/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): app entry points Rust invokes by literal on classes the SDK never declares.
Nothing else is kept. New framework surface referenced from Rust goes
through the generated constants (scripts/gen-api-contract.sh adds the
name to the lists); adding a keep is a permanent cost in every shrink
image and, for a member, a compatibility floor.
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 for everything the SDK declares plus the --contract member# column, reserving every name the --reserve tree spells. --floor (not# shown) re-bases member-floor for a cut that renames names the previous# map left verbatim.cargo run -p class-shrink -- cut-release --members \ --classes-dir "$TMP" \ --keep sdk/keep.toml \ --extra-names sdk/api-contract.tsv \ --contract sdk/api-contract.tsv \ --reserve sdk/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.sdk/class-names.tsv,sdk/member-names.tsv,sdk/descriptors.tsv— the inputs of the generatedc::/m::/d::constants.crates/picodroid-core/src/shrink_names.rsandcrates/jvm/src/names.rs— one-line modules thatinclude!the generatednames.rsfrom each crate’sOUT_DIR.
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).names.rs— thec::/m::/d::constant modules (crates/build_support/names.rs), spelled through the active map; original spellings when shrinking is off. Plus test-only reverse translators.framework_classes.rs—FRAMEWORK_CLSS, the (shrunk or raw) framework classes linked into one class section (framework_clss.bin, the layout of a PAPK’s CLASSES section), plusFRAMEWORK_CLASS_COUNT,FRAMEWORK_EXCLUDED_CLASSESandFRAMEWORK_CLASSES_LINE_NUMBERS. The tree the classes come from is one of the stripped ones (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 crates/pd-install/src/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 (picodroid/2.2, today’s, added the package
directory’s state after it). pdb install hard-refuses old picodroid/2.0
firmware (you must reflash via SWD) since it can’t verify compatibility.
Before either gate, and whatever the flags, the host checks the file’s
structure: a PAPK in format version 1, or one whose link tables do not
match its class bytes, is refused without the device being asked. See
pdb install.
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 carries no
framework-map-version key but the firmware has a shrink map active.
Again, rebuild.