Skip to content

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

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.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.
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.17.0.tomlMaps 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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.tomlAdds 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 a kotlin/** class (the shim is reserved, not renamed) or of an annotation interface;
  • [[member]] entries in sdk/keep.toml (main, injectMembers — app entry points invoked by literal from Rust on classes the SDK never declares, kept the way ProGuard keeps main).

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:

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.

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

  1. class-shrink cut-app copies the active release map and appends
    • every class the app defines, under a third prefix c/ (c/A, c/B, …), except kotlin/** (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 onCreate rename 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.
  2. shrinkMembers, shrinkClasses and papk-pack read that merged map instead of the release map. papk-pack spells the manifest entry (main-class / activity / application) through it.
  3. build-apk.sh copies the merged map next to the PAPK (build/apks/<app>-shrunk.papk) as build/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 returns c.A; string literals are never rewritten, so an app that compares getName() against its own name literal sees a mismatch (examples/classlit accepts either).
  • Log lines, stack traces and pdb output spell c/A.qZ — retrace with the app map, not the release map.
  • Generic Signature attributes 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/ or c/, or in the default package, moves to a fresh c/ 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:

  1. Firmware (build.rs): after Gradle has compiled and stripped the framework classes, if shrinking is on and the active version isn’t 0.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 writes framework_mapping_version.rs (the version string the firmware advertises) and names.rs (the c:: / m:: / d:: constants, spelled through the same map).

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

  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: crates/picodroid-core/src/boot.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. 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 the line-numbers cargo feature picks which: without it the build script runs ./gradlew :sdk:stripClasses and embeds sdk/build/classes-stripped/java/main; with it (the simulator, or a firmware built with PICODROID_LINE_NUMBERS=1) it runs :sdk:stripClassesLines and embeds sdk/build/classes-stripped-lines/java/main, which keeps LineNumberTable and SourceFile. Under a member map the tasks are :sdk:shrinkMembersStripped / :sdk:shrinkMembersStrippedLines. The class-name shrink then runs on top, unchanged. The generated framework_classes.rs records the choice in FRAMEWORK_CLASSES_LINE_NUMBERS, and a picodroid-core test pins it to cfg!(feature = "line-numbers"). The RP2040 gets no line numbers in either profile (they cost its debug image about 27 KB); PICODROID_LINE_NUMBERS=0|1 overrides 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.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 ≥ floorv' < floorNo (FrameworkVersionMismatch)PAPK is below the member floor: names the firmware spells mapped are verbatim in the PAPK.
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.
anythingno keyOnly 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:

ModuleSourceExampleno-shrink--shrink
c::sdk/class-names.tsv + the contract’s classesc::picodroid_pio_Gpio"picodroid/pio/Gpio""a/S"
m::sdk/member-names.tsv + the contract’s membersm::setValue"setValue""uQ"
d::sdk/descriptors.tsvd::String__V"(Ljava/lang/String;)V""(Lb/AQ;)V"
ch:: / mh::the c:: / m:: listsch::picodroid_pio_Gpio, mh::setValuefnv1a("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.

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

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 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 generated c:: / m:: / d:: constants.
  • crates/picodroid-core/src/shrink_names.rs and crates/jvm/src/names.rs — one-line modules that include! the generated names.rs from each crate’s OUT_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 — the c:: / 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), plus FRAMEWORK_CLASS_COUNT, FRAMEWORK_EXCLUDED_CLASSES and FRAMEWORK_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):

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 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, 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 (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:

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