Release notes
This page covers everything that landed in releases v0.4.0 through v0.38.0, and what is on main since. Earlier history is in git log v0.1.0...v0.3.0.
Unreleased — on main since v0.38.0
Nothing yet.
v0.38.0 — 2026-10-18
The Android-runtime release. Apps get Android’s broadcast model — BroadcastReceiver,
IntentFilter, sendBroadcast, the system time, screen and network actions and manifest
<receiver>s that wake an app which is not running. A view keeps its listener for as long as it
is reachable, with no per-kind caps, and the main loop judges a screen build by Android’s
budget, so apps build their screens in onCreate. Every device has a serial number for pdb
and Build.getSerial(). Underneath, the class section keeps each string constant once (PAPK
v3), which takes a fifth off a packed app and up to 112 KB off a firmware image; natives are
dispatched by hash; line-number tables are out of every firmware image; and two bug-scan rounds
landed about ninety one-bug fixes across the JVM, the widgets, the java.* shim and the packer.
Broadcasts (2026-10-18)
Written up in broadcasts-2026-10.md.
Android’s broadcast model: picodroid.content.BroadcastReceiver and IntentFilter,
Context.registerReceiver / unregisterReceiver / sendBroadcast, Intent.setAction and
the ACTION_* strings with Android’s values. The system actions are ACTION_TIME_TICK at
every minute (what claudeusage polled the clock once a second for), ACTION_TIME_CHANGED
and ACTION_TIMEZONE_CHANGED (what picoclock now re-plans its alarms on),
ACTION_SCREEN_ON / OFF, and on a network board the Wi-Fi and connectivity actions (what
Settings’ Wi-Fi screen polled twice a second for). PendingIntent.getBroadcast arms an alarm
that delivers to a receiver class or an action; Context.startActivity exists on every
Context, as on Android. The registry is Java’s and the loop makes at most one call per frame,
only while some filter names a system action, so an app that registers nothing pays nothing.
A manifest <receiver> for BOOT_COMPLETED, TIME_SET or TIMEZONE_CHANGED is delivered to
an app that is not running by a headless run of it — at boot before the boot app, between apps,
or by interrupting the launcher — which is how picoclock re-arms its alarms after a power cut
once the network has set the clock. Not here: ordered and sticky broadcasts, goAsync.
examples/qa_bcast covers the rules and examples/bootdemo the boot circle.
A view keeps its listener, as on Android (2026-10-17)
Written up in view-listeners-2026-10.md.
A listener lives with its view: a view that Java reaches — through its parent chain from the
Activity’s content view, or from a field — keeps its listener for as long as it is reachable,
however many views have one. The fixed-size native listener tables are gone, and with them the
cap of 32 per kind, the IllegalStateException on the 33rd setOnClickListener, and the
silently dropped registrations of the other kinds. The handle table now binds each widget to
its Java object; a collection drops the bindings of views nothing reaches, and the simulator
reports a view collected while its widget is still on screen (under the handle sanitizer it
stops). A shown AlertDialog or Snackbar is held by the framework until it is dismissed, as
Android’s window manager would — which also fixes an unfielded Snackbar action being swept —
and Snackbar.isShown() is new. Settings, the launcher, the calculator, picoenvmon and the
tutorials no longer hold view arrays “so the rows stay reachable”; the tutorials now teach the
Android shape. removeView still frees the widget. The legacy-handle-cast escape hatch,
promised for one release after 2026-09-15, is deleted.
The UI tick budget is the framework’s concern, not the app’s (2026-10-17)
Written up in ui-build-budget-2026-10.md.
The main loop now judges a Java span the way Android does. A screen build — an Activity’s
onCreate → onResume, its resume after a child finished, Application.onCreate, a Service
callback, or any handler that made eight or more views — may hold the 16 ms tick for a slow
first frame, and is reported (screen build: … took N ms, V views) only past 250 ms. A
steady-state handler (a widget event, a posted Runnable, a network or alarm callback) is
counted at 50 ms and reported on its own only at 250 ms; what is reported instead is
sustained jank (jank: K frames lost to N slow handlers in the last second). The old
slow handler line at 50 ms, which made apps build their screens one row per tick, is gone;
PICODROID_SLOW_HANDLER_MS (set it to get the old per-handler line) and the new
PICODROID_BUILD_BUDGET_MS tune the simulator. The first Activity’s and the Application’s
onCreate, never timed before, are now. An app’s loop logs its totals when it ends
(ui budget: …).
With it, the inflater makes a framework view in one native call: the runtime reads the layout
node, creates the widget under its final parent, applies every attribute under one style
refresh and writes the Java wrapper’s cached fields, where it used to take a native per word of
the layout, one per setter and a re-parent. An inflated view went from ~800 bytecodes and 28
natives to ~250 and 4 (the design doc has the device figures). AsyncLayoutInflater stays, as
the escape hatch for a layout that overruns the budget. The settings app, the launcher,
picoclock and claudeusage now build their screens inside onCreate (their rows inflated from
res/layout/*.xml), as an Android app does; the row-per-tick Runnable chains, generation
counters and AsyncLayoutInflater hosts they carried are gone.
Every device has a serial number (2026-10-14)
Written up in device-serial-2026-10.md.
A board’s serial is its chip’s unique ID (the RP2040’s flash unique ID, the RP2350’s OTP chip
ID, 16 hex digits); a chip with neither gets a random one made once from its TRNG and kept at
/system/serial; a simulator’s is emulator-<pid>, as adb names an emulator. The same value
is the USB iSerialNumber (so lsusb -v and /dev/serial/by-id/ show it), the field the
picodroid/2.4 greeting carries, and what the new Build.getSerial() returns to every app
(there is no permission model yet). Build.UNKNOWN is new and the deprecated Build.SERIAL
reads it, as for an Android app targeting O or later. Settings → About has a Serial row.
pdb picks a device the way adb does: -s <serial>, PICODROID_SERIAL (ANDROID_SERIAL’s
counterpart), -d for the one USB device, -e (or -s sim) for the one simulator, else the
only one attached; a port or socket path still works in place of a serial. pdb devices prints
adb’s header and SERIAL STATE BOARD SPACE AVAILABLE columns (-l adds the port, release and
USB position); get-serialno and wait-for-device are new. An install follows the board
through its reboot by serial, so a board that comes back on another /dev/ttyACM* while a
neighbour takes its old one is found, and the neighbour never answers for it; that was the
pdb-install HIL flake. Auto-detect no longer pings ports without the picodroid VID/PID.
Firmware older than 2.4 has no serial and is named by its port.
Docs: pdb commands, Build,
the porting guide (device_serial is the one identity hook a
family writes).
One Utf8 pool per class section: PAPK v3 (2026-10-13)
Third round of class-link-2026-09.md.
A class section kept every class file whole, so its string constants repeated in nearly every
class (java/lang/Object 145 times in the RP2040’s framework section). The section now ends in
a pool holding each distinct Utf8 once, and a class keeps only the entry’s tag byte; the
runtime reads through the pool, a registered class is still 16 bytes of RAM. The framework
section shrank from 261,296 to 219,256 B on the RP2040 and from 561,804 to 460,796 B on the
RP2350 (flash −39.5 KB and −112 KB); claudeusage’s packed classes −18.8 %, a one-class app
+52 B. The package format is major version 3. papk-pack --repack puts the constants back
when it rewrites a file.
Natives dispatched by hash (2026-10-12)
Every native dispatch arm compared the class and method names as strings: 734 memcmp sites
on the RP2040 image. The arms now match the 32-bit FNV-1a hash of each name, the hash the link
tables already use, and the generator refuses two spellings in one namespace that hash alike.
memcmp sites 868 → 267 on the RP2040 and 1034 → 293 on the RP2350; flash −9.3 KB and
−9.8 KB; dispatch timings unchanged within noise. Two consequences for an app: an app class
whose name hashes like a framework or builtin class is refused at pack time (papk-pack --framework-classes, which Gradle passes), at install and at load, with the colliding name in
the message; and papk-pack refuses an app class that declares a native method, which never
had anything to bind to. Both are new spellings away.
Line-number tables out of every firmware (2026-10-14)
The line-numbers feature and the SDK’s LineNumberTable / SourceFile cost the
pico_display2_w debug image 71,544 B of its program region, the image that overflowed it on
2026-09-29 and 2026-10-11. build-lib.sh now defaults PICODROID_LINE_NUMBERS to 0 on every
board and profile (the RP2040 had dropped them on 2026-09-26): device frames print (pc=N)
and scripts/retrace.sh --app <app> resolves them on the host; PICODROID_LINE_NUMBERS=1
builds them in on a board with the room, and flash.sh prints a reminder whenever it flashes
without them. The simulator still prints line numbers. In the same pass LVGL’s line widget
went out of the build and its box shadow and blur were stubbed (nothing in the SDK reached
them; −6.8 KB, screenshots pixel-identical). See Debugging.
Shrunk PAPKs are named <app>-shrunk.papk (2026-10-13)
A --shrink PAPK only installs on a firmware built with the same map, yet it shared
<app>.papk with the unshrunk build. Gradle’s packPapk and build-apk.sh now write
build/apks/<app>-shrunk.papk under the shrink gate, with the map beside it as
<app>-shrunk.shrink-map.toml; an explicit -o is kept as given. See the
shrinker.
DatePicker takes Android’s API (2026-10-11)
updateDate(year, monthOfYear, dayOfMonth), init(year, month, day, listener), getYear(),
getMonth() and getDayOfMonth() with Android’s signatures, the month 0-based (January is 0)
in all of them and in OnDateChangedListener, so a ported new GregorianCalendar(y, picker.getMonth(), d) lands on the month shown. The picker shows 1 January 2026 until a date
is set (the getters used to read 0 until a tap). pickerdemo follows. Doc: DatePicker.
PeripheralManager refuses the pads the board owns (2026-10-12)
claudeusage opened the Display Pack’s RGB LED as PWM on GP8; on the testbench GP8 is the
display’s DC line, so the panel showed noise with a clean boot log. The board’s [display],
[touch], [[button]] and [audio] pads, and the display’s SPI bus, are now reserved: the
GPIO, PWM, ADC and SPI openers throw IllegalArgumentException naming the line (GP8 is the display's DC line on this board), so an app driving optional hardware catches it and carries
on. blinky reads back GP22 instead of GP16 for the same reason. Doc:
Peripherals.
The RP2350 display and touch path (2026-10-11 to 2026-10-12)
Four bench findings from the bug scan, recorded in bench-handoff-2026-10-11.md:
- Every RP2350 board asked its display SPI for 62.5 MHz and got 75 MHz, the lowest rate the
controller can program at that clock. The boards now say
75000000, the rate they have run at since bring-up, and aspi_freqthe chip cannot program exactly no longer builds. - The XPT2046 driver decoded 11 of the sample’s 12 bits. It takes all twelve now and the
testbench calibrations are doubled to match; a
[touch] cal_*table written before 2026-10-11 is in the old units.examples/touchprobelogs every touch-down in panel pixels so a board’s table can be checked by hand. - A touch read on the display’s shared bus used to clock its commands into the panel as
pixels (a purple dash per band on
testbench_rp2350w); it now waits for the band in flight and releases the panel’s select first. - The SPI DMA ISR refills by bytes outstanding and judges a write by its channels, which ended the touch short-read line in the nightlies (28 a night on the RP2040 → 0).
The debug images grew past their regions on the way: the three 4 MB WiFi boards
(testbench_rp2350w, pico_enviro_mon_w, pico_display2_w) now have a 2560 KB program
region and a 1024 KB app region (eight claudeusage-sized apps still fit), and
pico_touch_kit’s app region is 9728 KB. LittleFS and the app region moved, so the first
flash of a new firmware reformats them. See Build & flash.
WiFi stays joined: the cyw43 join supervisor, NET-12 (2026-10-10)
About 3 % of power cycles never joined WiFi and never recovered: the vendored driver retries
nothing after a verdict, files a 4-way-handshake timeout under AP load as BADAUTH, and never
collapses a self-join its firmware completes after a verdict. A pure, host-tested
JoinSupervisor now rejoins after 3 s doubling to 60 s (a bad password rides the ladder, then
one try every 5 minutes), the link driver waits up to 5 s for a self-join in progress and
performs the collapse, a lost link after DHCP reaches the IP stack, and a missed probe is
retried after 1 s. 437 power cycles on pico_enviro_mon_w: 0 stuck, 30 recovered by a rejoin.
Log line: wifi: rejoin "<ssid>" (<why>; attempt N, join state 0x…). Write-up in
completed/networking-followups-2026-08.md.
DatagramSocket sends and receives whole datagrams (2026-10-10)
Both natives went through a 256-byte buffer: send cut a longer packet without a word and
receive dropped the rest of a longer datagram. The buffers are now sized by the packet (an
IOException past 65,507 bytes) and allocated fallibly. DatagramPacket gained java.net’s
window API: the offset constructors, getOffset, setData, setAddress, setPort, the
argument checks, and a packet reused across receives keeps its window after a short datagram
set length. Doc: UDP.
UTF-8 through java.io (2026-10-10 to 2026-10-11)
A char from U+0080 up is written as its UTF-8 bytes; InputStreamReader decodes UTF-8 (the
default charset, and what every charset name decodes: one char per code point of the BMP,
U+FFFD for a malformed byte, across its buffer end), OutputStreamWriter encodes a char back,
and new String(byte[]) keeps a valid UTF-8 sequence instead of turning every high byte into
?. A zero-length read is not the end of the stream. qa_io pins all of it.
Doc: java.io streams.
The pack-time checks grew (2026-10-10 to 2026-10-11)
The link derivation, run once per class at pack time and again at install, now verifies every
method’s bytecode: operand lengths, branch and switch targets inside the code, tableswitch
and lookupswitch bounds, wide before an opcode it widens, the fixed bytes of
invokeinterface / invokedynamic, newarray type codes, no unassigned opcode
(LinkError::BadBytecode { pc } names the instruction; a malformed class used to reset the
device). A superclass chain that loops is refused. A class or member name with a
supplementary character (an emoji identifier) is re-encoded from modified UTF-8 instead of
refused, and so is a string literal with one. papk-pack --repack keeps the declared
components and theme; the packer warns when a base layout names a value a variant redefines,
applies shorthand padding / layout_margin the same whatever the XML attribute order, drops
an unescaped quote in a string resource (toggling whitespace keeping, as aapt does), refuses a
non-Activity component under a shrink map too, refuses an over-long override string and stops
the resource table at 65,535 entries per type. The shrink-map parser refuses duplicate rows and
unknown keys, and keep globs combine * and **.
Two bug-scan rounds: about ninety one-bug fixes (2026-10-10 and 2026-10-11)
Each is one commit on main (eb1fd1a9..14cedd99); the records are
bugscan-2026-10-follow-ups.md
and bugscan-2026-10-open-items.md.
The ones an app can notice:
- JVM.
invokestaticinitialises the declaring class, not the one the constant pool names; a superclass’s<clinit>runs before its subclass’s from the run loop; a class whose<clinit>threw is erroneous, not initialised; asynchronizedmethod entered through a framework upcall takes its monitor;monitorenter/monitorexiton null throwNullPointerException; collection iterators fail fast on equal-size structural changes too;new Random()seeds differ between boots; the wrappers’Stringconstructors parse (new Integer("12")was storing the reference); the collector reserves its mark bitmaps before marking and scansArrayList/HashMapbuffers as slots; a boxed return,Arrays.sort,Arrays.toStringandArrays.copyOfon an exhausted heap throw a catchableOutOfMemoryErrorinstead of resetting. - Strings and numbers.
String.substringthrowsStringIndexOutOfBoundsException;trimstrips every char at or below U+0020;split(String, int)honours its limit;indexOffinds an empty needle atlength();String.formattakes explicit argument indices (%1$s,%<s) and bounds width and precision to anint;Double.parseDoubleaccepts only Java’sInfinityandNaNspellings;Double.toString/Float.toStringprint JDK 19+‘s two-digit minimum (Double.MIN_VALUEis4.9E-324,Float.MIN_VALUE1.4E-45);Arrays.copyOfwith a negative length throwsNegativeArraySizeException. - Views and widgets.
SeekBar.setProgress/setMaxreportonProgressChangedwithfromUser == false(nothing when the value is unchanged);View.INVISIBLEandsetAlphano longer fight over the one opacity;ViewGroup.getChildCount()counts the childrenaddViewadded andonViewAdded(View)is called for every overload (aListViewcounts its rows);RadioGrouptracks buttons added through everyaddViewoverload,check(-1)clears the selection and each button keeps its own listener slot;ViewPager2drops a stale page and clamps a negative item;popBackStack(name, POP_BACK_STACK_INCLUSIVE)pops every same-named entry; a nullStringto a text setter clears it andCanvastext throwsNullPointerException; a text setter takes any length, not 127 bytes; every dialog’s three buttons dispatch (the button map held one dialog’s); aViewwhose widget went with its Activity throws instead of a null dereference;withEndActionruns when nothing is left to animate; a push past the Activity stack cap leaves the top resumed. - Services, time, storage.
onServiceDisconnectedis not delivered for the app’s ownunbindService(Android delivers it for a dead process only); a wake in flight waits for a queued launch;DateTimeFormatterkeeps an escaped quote inside a literal;Instant.toStringalways prints the seconds;ISO_LOCAL_TIMEandISO_LOCAL_DATE_TIMEprint the fraction of a second;ZoneId.of("")throwsDateTimeExceptionandTimeZone.getTimeZone("")is GMT; a zone offset rejects a second sign inside the digit groups;SharedPreferences.Editor.putString(key, null)removes the key;JSONObject.numberToString(-0.0)prints-0, as Android does. - Kotlin shim.
roundToInt/roundToLonground exactly;toDoubleOrNull/toFloatOrNulltake the JVM stdlib’s grammar;replace("", new, ignoreCase = true)inserts like the stdlib;IntArray.fill/FloatArray.fillreject an inverted range;split("")matches between every char;copyOfRangethrows the stdlib’s exceptions. - Networking and install. A chunked response whose head fills the buffer is refused as too
large;
http-head’s decimal parser saturates its final add; an install checks the streamed image is a PAPK before committing it, the feature gate re-reads the whole manifest, and the host’s installREADYtimeout scales with the device’s app region (a nearly fullpico_touch_kitcompacts for longer than the old 120 s). Thepicodroid/2.3greeting names the board, sopdb devicesandpingsay which board answered. - Hardware. BME688 pressure keeps Bosch’s
par_p3 << 5; PWM wrap is the period minus one; I2C SCL counts saturate and keep the controller’s minimums; UART baud divisors are clamped and host-tested.
Smaller things (2026-10-10 to 2026-10-14)
- The CI build and test logs are warning-free but for one upstream
proc-macro-error2note; CI’s Building job linkspico_display2_win debug, the largest image of the fleet, and the nightly tracks it in the size lane. check-shrunk-imagereports a class no map covers yet instead of failing it as a leak.- The HIL runner stops a loop row 5 s after its patterns match, skips the launcher rows on a
touchless board before building, and no longer recompiles
picodroid-corefor every row; bench boards are named by serial infleet.confand the fleet scripts read it from sysfs. qa_lifewatches its Activity chain from one thread instead of a timer per step.
Map v0.38.0 (2026-10-18)
- Map v0.38.0, cut for the release, gives the classes added since v0.37.0 their names (+5
classes, 403 → 408:
BroadcastReceiver,IntentFilter,InflaterSupport,NoClassDefFoundErrorandjava.lang.Deprecated). It also names the 138 members added since (2571 → 2709): the broadcast surface (registerReceiver/unregisterReceiver/sendBroadcast/onReceive, theACTION_*andEXTRA_*constants),getSerial,Snackbar.isShown, the one-native inflater andDatePicker’s Android API. The member floor stays at v0.17.0.Build.VERSION.RELEASEreads0.38.0.
v0.37.0 — 2026-10-10
The Android-idioms release. App code that follows Android’s usual patterns now works without
picodroid-specific workarounds. The manifest takes Android’s <activity>, <service> and
android:theme, and the build checks every component an Intent names. Class.forName and
Class.newInstance let the framework construct ViewModels, fragments and custom views by name,
so the factory overrides apps used to need are gone. The platform sets the wall clock from NTP
and keeps the time zone as a user setting. java.io streams and Adapter.getView with
convertView are here too.
The Android-shaped manifest: <activity>, <service>, android:theme (2026-10-09)
Decision F4 of the claudeusage decisions,
written up in manifest-components-2026-10.md.
PicodroidManifest.xml reads Android’s shape beside the short form: <activity android:name>
children (the MAIN intent filter, else the first, is the entry), <service android:name>
children, and android:theme="@style/Name" on <application>, which ?attr/ resolves against
and the framework applies before the first Activity’s onCreate, so the app’s setTheme call
goes. Declared components are checked at build time: papk-pack that each is packed and extends
the right base, and a new Gradle verifyManifest task that every new Intent(X.class) the code
starts names a declared component, with the element to add in the error (Android refuses these at
run time). <uses-permission> is read and ignored; nothing is enforced. The short form declares
nothing and is unchanged; claudeusage, layoutdemo, servicedemo and tutorial_service move.
Three manifest keys: activities, services, theme. See the manifest
reference.
Reflection-lite: Class.forName and Class.newInstance (2026-10-09)
Decision F2 of the claudeusage decisions,
written up in class-newinstance-2026-10.md.
java.lang.Class gains forName(String) and newInstance() with the JDK’s signatures and
checked exceptions (ClassNotFoundException, InstantiationException, IllegalAccessException,
new builtins), served by the interpreter: a constructor is found by descriptor and run as a frame,
the class initialised first. The framework uses them where Android uses reflection, so three
overrides every multi-screen app carried are gone: ViewModelProvider.NewInstanceFactory (new,
the default getDefaultViewModelProviderFactory()) makes a ViewModel through its public
no-argument constructor, the default FragmentFactory re-creates saved fragments by name, and
LayoutInflater constructs a custom view a layout names through its public (Context, AttributeSet) constructor when Activity.onCreateView returns null (its default).
claudeusage, fragmentdemo and layoutdemo lose those overrides; under --shrink-app the
packer now writes a layout’s custom class name through the shrink map. No Method, Field or
Constructor, no member discovery, no access checks.
The platform sets the clock (2026-10-09)
Decision F5 of the claudeusage decisions,
written up in time-service-2026-10.md.
On every board with a network a framework task anchors the wall clock from pool.ntp.org
seconds after the link comes up and every six hours after; an https handshake that gets there
first waits for it instead of being refused. The zone is a user setting: Settings → Date &
time has “Automatic date & time” (Settings.Global.AUTO_TIME, new), a time-zone list
(UTC-12:00 to UTC+14:00, stored at /system/time, written through the new
AlarmManager.setTimeZone; AlarmManager.setTime too) and the time now. TimeZone.getDefault()
and so ZoneId.systemDefault() and every now() read that zone, the same for every app;
setDefault still overrides it per process. SystemClock.setCurrentTimeMillis and SntpClient
are unchanged and no longer needed: https_get, weather, askclaude and both picoenvmon
twins lost their SNTP code, claudeusage takes the bridge’s time only while the clock is
unset, and picoclock shows the right time after a power cut (roadmap R2) with the platform
zone as its default offset. The simulator syncs against the real pool; PICODROID_SIM_NTP_SERVER=off
keeps it from doing so. Log lines: time: synced from pool.ntp.org (…), rtt N ms, step N ms,
time: zone UTC+05:30, time: automatic off.
java.io streams (2026-10-09)
The java.io stream hierarchy, as pure-Java classes on every board: InputStream /
OutputStream, ByteArrayInputStream / ByteArrayOutputStream, Reader / Writer,
InputStreamReader, BufferedReader with readLine(), OutputStreamWriter and PrintWriter.
picodroid.io.FileInputStream / FileOutputStream and HttpInputStream / HttpOutputStream
now extend them, and Socket gained getInputStream() / getOutputStream(), so
new BufferedReader(new InputStreamReader(conn.getInputStream())) and
new PrintWriter(socket.getOutputStream(), true) work as written. Chars are bytes (no charset
decoding; a UTF-8 line survives readLine()). An app’s own stream subclasses InputStream and
implements read(). Roadmap T3.3; examples/qa_io pins it.
Doc: java.io streams.
Adapter.getView and convertView (2026-10-09)
Adapter has Android’s getView(int position, View convertView, ViewGroup parent); a
BaseAdapter subclass builds any row it likes and re-binds the convertView it is handed back.
ListView keeps the rows as its children in position order (getChildAt(i)), re-binds them in
place on notifyDataSetChanged(), frees the surplus, starts fresh on setAdapter, and passes the
row to onItemClick as view. ArrayAdapter renders a TextView per item or inflates Android’s
layout-resource constructors ((Context, int resource[, int textViewResourceId], T[] | List<T>)),
and gained addAll, insert, remove, getPosition. ViewGroup.addView(View, int index).
A BaseAdapter subclass implements getView. Roadmap T3.4.
Doc: ListView.
Map v0.37.0 (2026-10-10)
- Map v0.37.0, cut for the release, gives the classes added since v0.36.0 their names (+20
classes, 383 → 403: the
java.iostreams, readers and writers,ClassNotFoundException,InstantiationException,IllegalAccessExceptionandReflectiveOperationException,ViewModelProvider.NewInstanceFactory,Settings.Globaland the socket streams). It also names the 63 members they declare (2508 → 2571). The member floor stays at v0.17.0.Build.VERSION.RELEASEreads0.37.0.
v0.36.0 — 2026-10-08
The write-once release. An app written for one board now runs on every board: the framework
scales a design size to the panel, gives every board BACK, HOME and WAKE, drives every stock
widget from four keys, picks resources by window size and input, and offers the options menu as
the one place an app’s actions live. Apps can ask what board they landed on
(Resources.getConfiguration, KeyCharacterMap.deviceHasKey), and the display dozes while the
app keeps running. picodroid.lifecycle brings ViewModel and LiveData. Classes are linked
when they are packed (PAPK v2), which makes calls about a fifth faster on the RP2350.
The options menu: actions without buttons (2026-10-06)
Stage 11 of app portability:
Activity.onCreateOptionsMenu(Menu) / onOptionsItemSelected(MenuItem) as the one place an
app’s actions live, with onPrepareOptionsMenu, onOptionsMenuClosed, openOptionsMenu,
closeOptionsMenu and invalidateOptionsMenu; picodroid.view.Menu and MenuItem (with
OnMenuItemClickListener). The framework shows the menu as a list and opens it from a MENU key,
from holding SELECT on a four-key board when the focused view has no long press of its own,
and from a round menu control it draws bottom-right on a touch board while the resumed
Activity has a menu. examples/menudemo; see
Button-only navigation.
Held keys reach LVGL (2026-10-06)
Stage 10 of app portability:
a key stays pressed for LVGL while it is held, so LVGL’s own clock runs on it — a held SELECT
long-clicks the focused view (OnLongClickListener, with the click suppressed when it is
consumed, as a touch long-press does) and a held UP or DOWN walks the focus ring at LVGL’s
repeat rate. The Java side is unchanged: onKeyDown repeats and onKeyLongPress arrive as
before. A key held across a screen change is ignored by the new screen until it is released.
keydemo shows both.
Resource directories that vary by window and input (2026-10-06)
Stage 9 of app portability: Android’s qualified resource directories, in the subset that can differ between picodroid boards.
values-<q>/andlayout-<q>/withsw<N>dp,w<N>dp,h<N>dp,land/portandnotouch/finger, combined in that order (layout-w320dp-land/). A variant overrides whatres/values/andres/layout/define — every name must exist in the base, soRand every id are one set — and the runtime picks the matching variants once, when the app starts, by Android’s precedence against the app’s window (its<supports-screens>design size, else the panel) and the board’s input. The PAPK stays board-independent; an older firmware reads the base values. See Configuration variants.- Density, locale and night directories remain a build error, as do drawable variants and styles
in a variant.
papk-infolists a package’s variants; the simulator logs the choice ([res] 320x240dp land notouch: 2 of 3 variants apply).examples/resdemocarries one of each kind and the nightly checks them on four boards.
What an app can learn about the board it landed on (2026-10-06)
Stage 8 of app portability.
Resources.getConfiguration()returns apicodroid.content.res.Configuration:screenWidthDp/screenHeightDp/smallestScreenWidthDp(the app’s window in dp, which is a pixel),densityDpi(160),orientation,touchscreen(TOUCHSCREEN_FINGERwhere the board has a panel, elseNOTOUCH),navigation(NAVIGATION_DPADwhere it has the four keys, elseNONAV) andkeyboard(KEYBOARD_NOKEYS), withisLayoutSizeAtLeast,setToandsetToDefaults. A snapshot: nothing in it changes while an app runs.KeyCharacterMap.deviceHasKey(int)anddeviceHasKeys(int[])say which keys this board can produce — its buttons asboard.tomlmaps them, plus BACK and HOME on every board (the on-screen control, or holding BACK). Use it for hints such as “A: up”; handle the keys regardless.PackageManager.FEATURE_TOUCHSCREENis listed in the compatibility matrix.layoutdemoprints both on each of the four geometries and the nightly asserts the values.
An app drawn for one panel runs on every panel (2026-10-06)
Stage 7 of app portability: two new manifest elements, documented in the manifest reference.
<supports-screens design-width=".." design-height=".."/>— the logical size the app was laid out against. The runtime shows the app in a window of exactly that size on every board:Display.getWidth()/getHeight(),match_parentand every coordinate are the design’s. On a larger panel the window is centred; on a smaller one the screen pans to what does not fit, as it already did for an oversized root. picoclock (320×480) and claudeusage (320×240) declare theirs and now run on the Enviro+, the Pico Display 2 and the touch kit alike. Leave it out for an app whose layout stretches — the default, and what every other app does.<uses-feature name=".." required="true"/>— the installer (pdb install, the launcher, the simulator’sapps install) refuses an app that requires a feature the board lacks, before anything is erased:requires a feature this board lacks (uses-feature). The names arePackageManager’s (picodroid.hardware.touchscreen,wifi,ethernet); the default isrequired="false", which is informational.dragdemorequires the touchscreen.- The PAPK carries them as
design-width,design-heightandrequires-features;papk-packtakes--design-size WxHand--requires-feature <name>.
One layout on three panels, checked every night (2026-10-06)
Stage 6 of app portability: the proof that a resizeable app fits every board.
- The
[layout]line. After eachsetContentView, the simulator and a debug build log[layout] fit ok 320x240 in 320x240when the root fits the window, or[layout] overflow 320x480 in 320x240: the screen pans 0 right, 240 downwhen it does not. An oversized root is not clipped — the screen pans to it under a drag, or as the focus moves — but the line is the sign to let the layout stretch instead. Release firmware does not log it. - The nightly screen matrix. The same PAPK of calculator, picoenvmon, weather and the launcher
runs on a 240×240, a 320×240 and a 320×480 board expecting
fit ok(scripts/hil-tests.confand the launcher lanes ofsim-run.sh, which now take a board and an input profile). - picoenvmon and PicoEnvMonKt size from the panel (
match_parentroot and rows, weighted lists) instead of the Enviro+‘s 240×240, so the hint bar sits on the bottom edge of any panel; five demos that sized their root to one panel usematch_parenttoo.
Every stock widget on four keys (2026-10-06)
Stage 5 of app portability: an app written for a touchscreen works on a four-button board without a key handler of its own.
SeekBar,TimePickerandDatePickerare edited with the keys, the wayNumberPickeralready was: SELECT enters edit mode (LVGL’s edited outline), UP/DOWN adjust — the day before and after on a calendar, where SELECT picks the day — and SELECT or BACK leave without reachingonBackPressed. ASpinner’s open list is walked with UP/DOWN and picked with SELECT; BACK closes it. AnAlertDialogwith items gives its list the focus: UP/DOWN move between rows, SELECT picks (the Settings → Display timeout list works by keys now).- A view with a click listener is focusable where keys move the focus (
View.isInTouchMode()false), as Android’sfocusable="auto"has it. A screen written with click listeners alone is reachable withoutsetFocusablecalls; an app that laid out its focus order by hand may find a clickableTextViewhas joined it. - A screen with nothing focusable still answers UP/DOWN: they page its first
ScrollView. DatePickeris one focus stop (the calendar’s day grid), not two.examples/keynavdrives all of this from the simulator’s control channel.
The layout vocabulary for one layout on every panel (2026-10-06)
Stage 4 of app portability: what a layout needs to say its shape in relationships rather than coordinates.
- New:
picodroid.widget.Space(<Space>),View.setMinimumWidth/setMinimumHeight/getMinimumWidth/getMinimumHeight(android:minWidth/minHeight),TextView.setMaxWidth/getMaxWidth(android:maxWidth). View.INVISIBLEkeeps its room and lets touches through, as on Android; it used to behave asGONE. A view’s alpha is kept across INVISIBLE and back.- Behaviour change: a
LinearLayout’s cross axis defaults to the start, Android’sSTART | TOP— a column’s children sit at the left edge, a row’s at the top. They used to be centred. A layout that relied on the centring setsandroid:gravity="center_horizontal"(orcenter_vertical) on theLinearLayout. android:layout_gravityon aLinearLayoutchild is recorded but not applied; the layout compiler now says so, andexamples/layoutdemoexercises the whole vocabulary.
The display dozes, the app keeps running (2026-10-06)
Stage 3 of app portability: display sleep works on every board, because it no longer stops anything but the panel.
- Doze, not freeze. After the screen timeout with no key or touch, the panel and backlight go off and LVGL stops ticking; the main loop runs on, so Runnables, alarms, scheduled tasks, network callbacks and the sensors continue. Any button wakes the panel, and so does a finger where the controller is still read (the touch kit’s GT911 sampler, the testbenches’ XPT2046 polled inline); the wake press is swallowed, and the finger that woke it lands as no click.
- It is on by default everywhere now, 60 s. The four boards that set
idle_timeout_ms = 0because the old sleep froze them no longer do; their apps (claudeusage,picoenvmon,weather,picoclock’s face) hold the panel with the newView.setKeepScreenOn(true)(android:keepScreenOn="true"in a layout).picoclock’s ringing alarm calls the newActivity.setTurnScreenOn(true)to light a dozing panel. - Settings → Display → Screen timeout stores a choice (15 s to 5 min, or Never) at
/system/display; apps read and write it asSettings.System.SCREEN_OFF_TIMEOUTthroughSettings.System.getInt/putIntwithContext.getContentResolver(). - New:
PowerManager.isInteractive()fromgetSystemService(POWER_SERVICE);KEYCODE_SLEEP,KEYCODE_WAKEUPandKEYCODE_POWERdoze, wake and toggle (as soft keys frominput keyeventon any board). The simulator shows a black window while dozing;examples/powerdemowalks every state. - Behaviour change: the old sleep also paused the sensor sampler and the tick source; neither pauses now. The 16 ms tick continues while dozing (it only skips the render), which the design doc’s amendment records.
BACK, HOME and WAKE on every board (2026-10-06)
Stage 2 of app portability: every board with a display meets an input profile, checked by the build, so an app written for four keys or a finger can always be left.
- Holding BACK for a second is HOME on a board with no HOME key (
home_hold_ms, default 1000). The app sees its 400 ms long-press first, then a release flaggedFLAG_CANCELED, and the launcher comes up; the log sayskey: BACK held -> HOME. An app that swallows BACK can no longer keep the user. - A touchscreen board with no button draws its own BACK/HOME control (
soft_nav = true; the three testbenches): a round control in the bottom-left corner of the window, a tap for BACK, a hold for HOME. It hides while the system keyboard is up and rides above a dialog. - The build checks the board’s input profile. No touch: the four logical keys (
PREV/19,NEXT/20,ENTER/23,ESC/4) are required. Touch: a BACK or HOME[[button]], orsoft_nav.has_nav_keysandsoft_navare cfgs;board_cfg::input::*carries the constants. input keyeventworks on every board. A key the board has no pin for is delivered as a soft key — it reaches the framework and the app without touching the focus ring — so a test script can send BACK or HOME to a testbench (pdb inputand the simulator’s control channel alike).--longpress 4holds BACK long enough for HOME where that is what holding BACK means.- New:
KeyEvent.KEYCODE_POWER/ENTER/MENU/SLEEP/WAKEUP,PackageManager.FEATURE_TOUCHSCREEN,View.isInTouchMode()(true where no button moves focus).
One layout, every board: the floor and the panel’s pitch (2026-10-05)
The first stage of app portability: write an app once against a 240×240 logical-pixel floor and let the framework place it.
- Every board is at least 240×240 logical pixels; the build refuses a smaller panel.
DisplayMetrics.xdpi/ydpiare the panel’s real pixel pitch (the board file’s new[display] dpi), soTypedValue.COMPLEX_UNIT_PT/IN/MMare ruler measurements;densitystays- New:
Display.getMetrics(DisplayMetrics).
- New:
- The same layout gives the same pixels on every board. LVGL’s theme DPI is pinned to 160
everywhere; it used to follow a per-board
lv_dpi, so awrap_contentbutton was 0.81–1.25× larger on some panels than others. Thelv_dpikey is refused. - System overlays follow the window. Toasts, snackbars and dialogs size and place themselves from the display rather than from a 240×240 panel (a snackbar on the 320×480 touch kit was a third of the way up the screen), and they live on LVGL’s top layer, so a screen that pans does not take them with it. Dialog cards grow to five eighths of a wide panel.
- Small clickable views reach a fingertip on touch boards. A view with a click listener shorter than 7 mm gets its press area grown (not its layout), capped so neighbours stay apart.
- HOME is consumed on both edges. Its press no longer reaches
onKeyDown. - Board files:
[[button]]entries are checked — a navigationlv_keymust carry its keycode (PREV=19,NEXT=20,ENTER=23,ESC=4), and no two buttons may share a pin or a keycode.
Android-shaped app code: the claudeusage round (2026-10-01)
The remaining places where examples/claudeusage read differently from the same app written
for Android, closed in the SDK.
ServiceConnectionandbindServicehave Android’s signatures.onServiceConnected(ComponentName name, IBinder service),onServiceDisconnected(ComponentName name)andboolean bindService(Intent, ServiceConnection, int flags)withContext.BIND_AUTO_CREATEreplace the one-argument, no-argument and two-argument forms. The callbacks are ordinary interface calls now, so a connection may inherit them from a base class (the flat lookup that found them only on the exact class is gone). New with them:picodroid.content.ComponentName,picodroid.os.Binder(extend it for a LocalBinder;implements IBinderworks too) andnew Intent(Context, Class).Executors.newSingleThreadScheduledExecutor()runs on a thread of its own, as in the JDK: its tasks may block and must not touch views. The main-thread scheduler isExecutors.mainScheduledExecutor(), for tasks that touch the UI.DatagramPacket.getAddress()returns anInetAddress, not the packedint(getAddress().getRawAddress()is the old value).InetAddress.getByAddress(byte[])andgetAddress()are new.SharedPreferencesbehaves as Android’s.Context.getSharedPreferencesreturns one instance per file, safe from any thread.apply()changes memory at once and writes the file on a background thread, so it is safe on the main thread; pending writes are finished when the Activity stops and before the file is read again.commit()still writes before returning. A write that fails leaves the new values in memory (it used to roll them back).SharedPreferences.open(name)is unchanged: a separate instance read from the file.- Board configuration:
[background_pool] stack_bytesnow defaults to 6144 and the build refuses less, because the framework itself puts file writes on a pool worker (SharedPreferences.apply()); a 4 KB worker overflowed doing one ontestbench_rp2040. That board now runs two workers with 8 KB stacks in the RAM four with 4 KB took. Every other board already set 6144 or more. - A view with nothing set shows nothing. A new
TextViewread “Text”, aCheckBox“Check box” and an emptySpinner“Option 1”; they are empty now. - Setters do nothing when nothing changes, as on Android:
View.setVisibility,setAlpha,setEnabled,setBackgroundwith the same drawable,TextView.setTextandsetTextColor, and theProgressBartint lists.TextView.setText(null)is the empty string, andgetText()no longer costs a native call. - New on
View:getBackground(),setBackgroundTintList(ColorStateList)/getBackgroundTintList()(recolour a background and keep its shape), and for a view that draws itselfonMeasure/setMeasuredDimension/measure/getMeasuredWidth/getMeasuredHeight/resolveSize/getDefaultSizewithView.MeasureSpec. - New on
Context/Activity/Fragment/Resources:getString(int, Object...),Context.getMainExecutor(),Activity.runOnUiThread(Runnable),Context.setTheme(int),Activity.onCreateView(String, Context, AttributeSet).KeyEvent.KEYCODE_BUTTON_A/_B/_X/_Yexist as constants (no board maps a button to them). - Layouts say much more. The resource compiler now takes
layout_marginand its per-side forms (ViewGroup.MarginLayoutParams), applieslayout_gravityand margins in aFrameLayout, and accepts<include>,<View>,android:includeFontPadding,style="@style/…",?attr/…against the app’sAppThemestyle,<shape>drawables as backgrounds, and elements that name a view class of the app’s own, which the Activity constructs inonCreateView.ViewGroup.addView(child)applies theLayoutParamsthe child carries. See Resources, R and XML layouts;examples/layoutdemochecks all of it. picodroid.view.AsyncLayoutInflater(androidx’s) inflates a layout over several ticks of the main loop, a few milliseconds each, and calls back when the tree is whole.examples/claudeusageis written with all of this: its pages are XML layouts inflated asynchronously, its data flow is a repository, aViewModeland one immutable UI state in aLiveData, and the hand-written diffing, per-tick view building andThemeassignments are gone. The four screens are pixel-identical to the code-built ones in the simulator.
Classes are linked when they are packed (PAPK v2) (2026-09-28)
papk-packnow does the class loader’s work at build time. Each class in a.papkcarries a link table (the parsed record, a signature hash per method, a hash per superclass and interface, a 4-byte descriptor per method reference), and the package carries a sorted class index. The runtime reads both in place from flash: nothing is parsed or allocated per class when an app starts. The framework’s own classes are embedded in the firmware in the same form, so apps and the framework share one loader.- Calls are faster. On
testbench_rp2350(release,--shrink --shrink-app) thebenchmarkexample’s total drops from 123.6 s to 97.0 s (−21.6 %): virtual calls −28 %, interface calls −25 %, object allocation −40 %, string operations −48 %.claudeusage’s History page uses 81 KB less of the JVM arena. - The PAPK header is 28 bytes and its major version is 2.
papk-infovalidates the link tables of a packed file. - The tables live in flash: +126 KB on
testbench_rp2350, +61 KB ontestbench_rp2040. The flash layout of four boards moved to make room, below.
Flash layout: larger program regions on the W boards and the RP2040 (2026-09-28)
pico_display2_w,pico_enviro_mon_wandtestbench_rp2350w: the program region grows from 2048 KB to 2304 KB and the app region shrinks from 1536 KB to 1280 KB (eight apps the size ofclaudeusagestill fit). LittleFS and the app region start at new addresses on these boards, so the first flash of a new build reformats the volume and the installed apps are gone: reinstall them, and re-enter the Wi-Fi network if it was saved from Settings.testbench_rp2040: the program region grows from 896 KB to 1152 KB and the app region shrinks from 1024 KB to 768 KB. It is a single-app board and the largest example is about 125 KB.testbench_rp2040also leaves out the scheduled executor (ScheduledExecutorService,ScheduledFuture,MainScheduledExecutor) andSntpClient, next to the executors and network classes it already dropped. An app that schedules needs an RP2350 board.- The other RP2350 boards are unchanged.
Swipes reach their listener (2026-09-28)
- An
OnSwipeListeneron a view below the screen never fired,ViewPager2’s included, in the simulator or on a board: LVGL passed every gesture up to the screen. The nearest view with a swipe listener takes the swipe now, as on Android, so a swipe turns aViewPager2page. SwipeRefreshLayout: a drag is no longer swallowed as a scroll of the container, and a pull-down that lands on a child with its own swipe listener goes to the layout first (Android’sonInterceptTouchEvent).
Fixes (2026-09-27/28)
FileOutputStream.writeof a large array streams through one open and one sync instead of one per 256 bytes. Writing sixty-three 16 KB files onpico_touch_kitwent from not finishing in 480 s to passing; the simulator with that board’s profile went from 110.7 s to 10.4 s.Thread.startretries a task spawn that fails because the previous thread’s stack has not been reclaimed yet (three attempts, 2 ms apart). Forty start/join cycles in a row no longer fail on a board with little free heap.pdb listno longer hangs on Linux when the reply is an exact multiple of 64 bytes.- Device builds work on both stable and nightly
cargo: the RP2350 linker flags no longer depend on how the toolchain spells the target triple.
Map v0.36.0 (2026-10-08)
- Map v0.36.0, cut for the release, gives the classes added since v0.35.0 their names (+35
classes, 348 → 383:
picodroid.lifecycle, the options menu’sMenuandMenuItem,Configuration,KeyCharacterMap,PowerManager,Settings.System,AsyncLayoutInflater,View.MeasureSpec,ViewGroup.MarginLayoutParams,Space,ComponentName,Binderand the rest), and it also names the 251 members they declare (2257 → 2508). The member floor stays at v0.17.0.Build.VERSION.RELEASEreads0.36.0.
v0.35.0 — 2026-09-27
The apps-and-networks release, and the first tag since the release version started following the shrink map: v0.15.0 through v0.34.0 were map cuts on main, so this one is v0.35.0. The work since v0.14.0 gives a board a launcher, several installed apps with their own storage, a settings app, and Wi-Fi it can join from that app instead of from the build. Apps reach the network in Android’s shapes, from HTTPS with certificate checking and ConnectivityManager.NetworkCallback to UDP broadcast, JSON and protobuf. The UI gains Fragments and ViewPager2, custom drawing through View.onDraw(Canvas), resources with R and XML layouts, saved instance state, long-press and key auto-repeat, tones, and text sizes. --shrink is unconditional now and renames java/** names and each app’s own names too. The runtime runs one Java task at a time under a kernel mutex, and puts the RP2350’s hot code in SRAM. It catches stale widget handles and enforces the main-stack floor at link time. A QA round fixed forty correctness bugs.
Fragments and ViewPager2 (2026-09-27; map v0.35.0, package 0.35.0)
picodroid.app.Fragment,FragmentManager,FragmentTransactionandFragmentFactory, the shape ofandroidx.fragment.app:getSupportFragmentManager().beginTransaction().replace( R.id.container, new DetailFragment()).addToBackStack(null).commit(), Android’s callbacks in Android’s order driven by the host Activity, hide/show, detach/attach, a back stack that BACK pops beforefinish(), and the fragments’ state saved with the Activity’s. Pure Java, on every RP2350 board (the RP2040 testbench leaves the classes out). Two things differ, both forced by the panel: a view given up inonDestroyViewis freed at once, and after a re-creation the fragments come back only through aFragmentFactorythe app sets beforesuper.onCreate(there is no reflection). See Fragments.picodroid.widget.ViewPager2andFragmentStateAdapter,<ViewPager2>in layouts: one page of fragments alive at a time, the outgoing page freed before the incoming one is built, its state kept as a Bundle;smoothScrollis a fade, a swipe turns the page, keys callsetCurrentItem.claudeusage’s four screens are now fragments in one.
HTTPS (2026-09-27; map v0.35.0, package 0.35.0)
httpsURLs work:URL.openConnection()returns apicodroid.net.ssl.HttpsURLConnection(the shape ofjavax.net.ssl.HttpsURLConnection) andconnect()runs a TLS 1.3 handshake — server name checked, chain verified against a compiled-in store of 14 Mozilla roots (ECDSA and RSA), validity checked against the wall clock. Failures throwjavax.net.ssl.SSLHandshakeException/SSLException(IOExceptions, as on Android). A board capability:has_tls = true, on for every RP2350 WiFi board, never on the RP2040 (329 KB of flash; a handshake is 1.1–2.0 s on the RP2350 and runs on a task of its own with a 40 KB stack that exists only for the handshake). See HTTPS.picodroid.net.SntpClient, the shape of Android’sandroid.net.SntpClient: one NTP round trip, thenSystemClock.setCurrentTimeMillis. The runtime refuses to handshake with the clock unset rather than skip the validity check.picodroidBuildConfig { field("NAME", …) }in an app’sbuild.gradle.ktsgenerates<package>.BuildConfigstring constants (Android’sbuildConfigField);fieldFromPropertyreads a Gradle property, the per-invocationpicodroid.env.<VAR>propertybuild-apk.shforwards for everyPICODROID_*variable, the variable itself, then a default.- Examples:
askclaude(a prompt to the Claude Messages API from four buttons, the reply on screen; the API key is aBuildConfigconstant) andhttps_get(the nightly’s TLS row). The test host gains a TLS listener on 8443–8446 (scripts/tls-listener.py, a valid leaf and three that must be refused) whose test CA a test build trusts throughPICODROID_TLS_EXTRA_CA;PICODROID_SIM_WALL_CLOCK=1anchors the simulator’s clock to the host’s. picoenvmon(both twins) fetches its open-meteo weather row over HTTPS now, the first cleartext call in an example to move: thehttpsURL, aSSLHandshakeExceptionlog line, and the fetch waits for the NTP sync the certificate check needs. Three things it found:pico_enviro_mon_wruns two pool workers instead of four (the arena was 1 KB short of the handshake’s transient on this board); the HTTP client’s connection object is a fallible allocation, so an arena that is down to fragments after a handshake gives anIOExceptioninstead of a HardFault; and in the simulator the handshake task runs at the JVM tier, since a Java thread in a host socket call (picoenvmon’s dashboardaccept()) is not idle to FreeRTOS’s POSIX port and starved the background tier forever (design doc A2).- Example
weather: a phone-style weather app for the four-buttonpico_enviro_mon_w(today with an hourly strip, the week with a range bar per day, details with the room’s own readings), one open-meteo request over HTTPS every 15 minutes, the glyphs and lists drawn withView.onDraw(Canvas), the sky aGradientDrawable; the place and units areBuildConfigconstants. The test listener answersGET /v1/forecastwith a canned forecast for its nightly row. - Under the hood:
crates/pd-tlsover a vendoredembedded-tls0.19 with three marked patches (third_party/embedded-tls/README-PICODROID.md); design and measurements indocs/designs/tls-2026-09.md. - Map v0.35.0, cut for the release, gives the Fragment,
ViewPager2, HTTPS and SNTP classes their names (+13 classes, 335 → 348:picodroid/app/Fragment,FragmentFactory,FragmentManager,FragmentManager$OnBackStackChangedListener,FragmentTransaction,picodroid/widget/ViewPager2,ViewPager2$OnPageChangeCallback,FragmentStateAdapter,picodroid/net/ssl/HttpsURLConnection,picodroid/net/SntpClientandjavax/net/ssl/SSLException,SSLHandshakeException,SSLPeerUnverifiedException), and it also names the 233 members they declare (2024 → 2257). The member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.34.0 still install.Build.VERSION.RELEASEreads0.35.0.
Wi-Fi provisioning from Settings (2026-09-26; map v0.34.0, package 0.34.0)
- A board no longer needs its network compiled in. Settings gains a Wi-Fi row on
FEATURE_WIFIboards: scan, pick a network, type the password on the system keyboard (by touch or, on keypad boards, by walking its keys), and the board joins it. The network is saved to/system/wifiand rejoined at every boot.PICODROID_WIFI_SSID/PICODROID_WIFI_PASSstill work and, when set at build time, take precedence over the saved network. See WiFi & networking setup. picodroid.net.wifi, Android’s shape:WifiManager(Context.WIFI_SERVICE) withstartScan/getScanResults/registerScanResultsCallback,getConnectionInfo,addNetwork/enableNetwork/removeNetwork/reconnect,calculateSignalLevel;ScanResult,WifiInfo,WifiConfigurationandSupplicantState. One saved network; the join outcome is read back, not broadcast.EditTextmasks the password input types (TYPE_TEXT_VARIATION_WEB_PASSWORD,TYPE_TEXT_VARIATION_VISIBLE_PASSWORD,TYPE_NUMBER_VARIATION_PASSWORDjoinInputType) and is focusable by default, as on Android. The system keyboard is sized to the display and no longer sits 100 px too low.- The simulator fakes a list of access points (
PICODROID_SIM_WIFI_NETWORKS/_PASS) and adds the control verbinput text; thesettings-wifi-touchandsettings-wifi-keyssim-run.shlanes run the flow nightly. - Map v0.34.0, cut on
main, folds in the sevenpicodroid/net/wificlasses (328 → 335) and the 86 member names they andInputTypedeclare (1938 → 2024). The member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.33.0 still install.Build.VERSION.RELEASEreads0.34.0.
picodroid.protobuf (2026-09-26; map v0.33.0, package 0.33.0)
- protobuf-javalite’s stream API —
CodedInputStream,CodedOutputStream,MessageLite,WireFormat,InvalidProtocolBufferException— over a native wire codec (micropb, reading the Javabyte[]in place), andprotoc-gen-picodroid(tools/protoc-gen-picodroid/), aprotocplugin that turns a proto3.protointo mutable message classes withparseFrom,writeTo,getSerializedSizeandtoByteArray;scripts/gen-proto.shregenerates and checks them. A board capability like JSON:has_protobuf = trueinboard.toml, on for every RP2350 board, off ontestbench_rp2040. See Protocol Buffers. examples/protodemois the conformance app;claudeusagereads its bridge’s reply as protobuf (Accept: application/x-protobuf; the bridge still answers JSON to everything else) and no longer referencespicodroid.json.- Map v0.33.0, cut on
main, folds in the sixpicodroid/protobufclasses (withCodedOutputStream$OutOfSpaceException),KeyEvent$Callback,KeyEvent$DispatcherStateandViewConfiguration(319 → 328) and the 176 member names they declare (1762 → 1938). The member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.32.0 still install.Build.VERSION.RELEASEreads0.33.0.
Long-press and key auto-repeat (2026-09-26; map v0.33.0, package 0.33.0)
- A held key repeats as Android’s input dispatcher repeats it: a synthetic
ACTION_DOWNwithgetRepeatCount()1 after 400 ms (ViewConfiguration.getKeyRepeatTimeout()), then one every 50 ms until the release; the first repeat carriesFLAG_LONG_PRESS. - Android’s Java surface, member for member:
KeyEvent.dispatch(Callback, DispatcherState, Object),KeyEvent.Callback(whichActivitynow implements, gainingonKeyLongPressandonKeyMultiple),KeyEvent.DispatcherState,isLongPress,isCanceled,getFlags,getDownTime, andViewConfigurationwith the three timings. The two-actions-per-button idiom transfers unchanged:startTracking()inonKeyDown, act on the hold inonKeyLongPress(which cancels the release), act on the press inonKeyUpwhen the release is tracked and not cancelled. A cancelled BACK release no longer runsonBackPressed. - Tooling:
input keyevent --longpress(adb’s flag) and--down/--upon the sim control channel andpdb input. A key verb now settles 40 ms after its release, which ends the occasional dropped control-FIFO tap (two queued verbs landed inside the 5 ms debounce).examples/keydemoshows every path and runs as a nightly sim row. claudeusage: four buttons, eight actions — a held A/B keeps turning pages, a held X rediscovers the bridge, a held Y toggles AUTO from any screen.
The RP2350’s hot code runs from SRAM (2026-09-26)
- Beyond the interpreter loop, 101 LVGL functions (style lookup, event dispatch, RGB565 blend),
30 FreeRTOS kernel and port functions and 20 pico-jvm helpers now execute from SRAM on every
RP2350 board, chosen from a PC-sample profile of
claudeusagepage turns. Onpico_display2_wa page turn’s CPU work drops by a third and the Java-side spans of a page visit fall from 205 to 118 ms. No LVGL or kernel source changes: the build retargets the named sections in the built archives (platforms/rp/mcus/rp/hot-ram-*.txt). - It costs 48 KB of heap arena, set by
hot_ram_kb = 48inboard.toml: 372 → 324 KB on the RP2350A boards, 300 → 252 KB on the touch kit. The RP2040 has no room and is unchanged. Design and measurements:docs/designs/sram-hotpath-2026-09.md.
java.util.zip.CRC32 (2026-09-26; map v0.32.0, package 0.32.0)
- The JDK’s
CRC32with itsChecksuminterface:update(int),update(byte[]),update(byte[], int, int),getValue(),reset();"123456789"checks to0xCBF43926. The per-byte loop is the runtime’s (jvm/src/native/zip.rs, bit-serial, no table), behind the same twoprivate static nativesteps the JDK hands to zlib; the class is real bytecode, so aChecksumreference dispatches like any other object. SharedPreferencessigns and verifies its file with it. Loading a 90-byte preferences file cost the RP2350 some 40 ms of interpreted bytecode insideService.onCreate— found by the claudeusage device QA as the one slow tick a boot had left — and everyapply()paid the same again on its worker.claudeusage: with AUTO on, Y from a data page now restarts the 10 s countdown as a page turn does; before, the next auto turn could follow the home press by a second or two.
ScheduledExecutorService on the main thread (2026-09-26; map v0.32.0, package 0.32.0)
Executors.newSingleThreadScheduledExecutor()with the JDK shape:schedule,scheduleAtFixedRate,scheduleWithFixedDelay,submit,shutdown(periodic tasks cancelled, one-shots still run),shutdownNow,isTerminated,awaitTermination, andScheduledFuturewithcancel,getDelay,isPeriodic. Its single thread is the main thread: the frame tick posts each task when it is due, so a task may touch widgets, costs no task stack, and stalls the UI if it blocks. This is the delayed-work API in place ofHandler.postDelayedandTimer.- Runtime: a 16-entry deadline table checked once per frame (
executors/scheduled.rs), 16 ms resolution; a fixed-rate task late by more than a period runs once a period later, not in a burst; nothing fires while the tick is paused.FutureTaskgainedrunAndReset()anddone(). examples/executordemois the pattern and its sim test;claudeusage’s one-second ticker is a scheduled task now, and itsusage-tickthread (17 KB) is gone.
ConnectivityManager.NetworkCallback (2026-09-26; map v0.32.0, package 0.32.0)
ConnectivityManageris a service now —getSystemService(Context.CONNECTIVITY_SERVICE)— with Android’sNetworkCallback:registerDefaultNetworkCallback,registerNetworkCallback(NetworkRequest, cb),requestNetwork,unregisterNetworkCallback,getActiveNetwork(),getNetworkCapabilities(Network).onAvailable(thenonCapabilitiesChanged) when the link comes up with an address,onLostwhen it drops; a callback registered while the link is already up hearsonAvailableright afterregisterreturns, never from inside it. Callbacks run on the main thread between frames, delivered by the Activity event loop. NewNetwork(a fresh one each time the link returns, as on Android),NetworkCapabilities(TRANSPORT_WIFI/TRANSPORT_ETHERNETasNetworkInfo.getType()says;INTERNET,VALIDATED,NOT_METEREDand theNOT_*set once up) andNetworkRequestwith itsBuilder. Not there:LinkProperties,onLosing/onUnavailable(declared, never called),getActiveNetworkInfo(). TheTYPE_*constants stay.- Runtime: the IP stack’s up/down hook (device) and
net up|down(simulator) bump one link-change generation; the event loop checks it once per frame and callsConnectivityManager.fireLinkChangeonly when it moved, so an app that never registers pays one atomic load per frame.testbench_rp2040excludes the new classes with the rest ofpicodroid.net. examples/connectivityis the pattern and its sim test; thesimrows ofhil-tests.confcan now carry anexamples/<app>/test.ctrlthat sim-run feeds to the control channel (wait <regex>,sleep <s>, a verb), which is how the row flaps the link.claudeusageno longer pollsNetworkInfo.isConnected(): its Service registers a callback inonCreate;onAvailablewakes the poll thread,onLostpaints the offline state.- Map v0.32.0, cut on
main, folds injava/util/zip/CRC32andChecksum, the four scheduler classes and the fiveNetworkCallbackclasses (308 → 319) and the 76 member names they declare (1686 → 1762). The member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.31.0 still install.Build.VERSION.RELEASEreads0.32.0.
Custom drawing: View.onDraw(Canvas) (2026-09-26; map v0.31.0, package 0.31.0)
- Android’s custom-view API: subclass
View(new View(Context)), overrideonDraw(Canvas), and callinvalidate()(orpostInvalidate()off the main thread) when the picture changes.CanvasdrawsdrawColor,drawRect,drawRoundRect,drawCircle,drawLine,drawArcanddrawText.Paintcarries colour and alpha (setARGB,ANTI_ALIAS_FLAG),Style(FILL/STROKE/FILL_AND_STROKE), stroke width,Cap, text size andAlign, andascent,descentandmeasureText. - There is no pixel buffer: a 320×240 canvas would be 150 KB. Each draw call records a 32-byte
op in a per-view list in the LVGL pool (2 KB cap), and LVGL replays the list whenever it paints
the view. So
onDrawruns once perinvalidate(), not once per frame. testbench_rp2040leaves it out (the newhas_canvas = falseboard key, about 16 KB of flash there), andbuild-apk.sh --board testbench_rp2040rejects an app that uses it.examples/claudeusagedraws its Burn and History charts with it, pixel-identical to the bar views they replace.examples/displaydemogains a shapes row, andqa_uia canvas section.
UDP broadcast on DatagramSocket (2026-09-26; map v0.31.0, package 0.31.0)
DatagramSocket(),setSoTimeout,setBroadcast/getBroadcastandDatagramPacket(byte[], int, InetAddress, int), with theirjava.netspellings. Broadcast is on by default and the setting is recorded but not enforced, since neither network stack refuses a broadcast. In the simulator, UDP sockets getSO_BROADCASTas Java’s do;PICODROID_SIM_NET_BROADCAST=0withholds it, to rehearse an access point with client isolation.examples/claudeusagefinds its bridge by broadcastingPICODROID-USAGE?to UDP 8788 rather than using a build-time address. The bridge’s reply names the port, and its source address names the PC.- Map v0.31.0, cut on
main, folds inCanvas,Paint,Paint$Align/$Cap/$StyleandView$DrawTask(302 → 308 classes) and the 54 member names the drawing and broadcast surfaces added (1632 → 1686). The member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.30.0 still install.Build.VERSION.RELEASEreads0.31.0.
--shrink-app shrinks every app; an app never renames anything for the shrinker (2026-09-26)
cut-appgives an app member whose name is already a release-map target a fresh target of its own, instead of refusing the app. App classes in the default package or undera/,b/orc/move to freshc/names. Five examples that could not be built with--shrink-appnow can (qa_oop,langsuite_kt,clinitdemo,langsuite,defaultmethods).- Plain
--shrinkruns the same check (cut-app --declash-only) and renames only the colliding names. Before, an app member that happened to spell a shrunk SDK name could silently alias it. A clash-free app shrinks exactly as before. Still refused: an app copy of a framework class, and a colliding name spelled by a kept class or an annotation element.
The simulator’s memory model matches the board’s (M8–M10), and GC compaction stops failing on a fragmented heap (2026-09-25/26)
- Parsed class metadata is one
u16record per class. Exception and bootstrap tables are read from the class bytes in flash rather than copied. On claudeusage’s History page, parsed metadata drops from 161 KB to 51 KB in the simulator and from 94 KB to 47 KB on the board. Class files over 64 KB are refused at registration; the largest that ships is 51 KB. - Resolution tables are keyed by constant-pool site (
SiteKey) instead of by pointers, so the simulator and the device hold the same entries and hit at the same rate. Static fields are keyed by class and field index, and class-table names are stored as offsets into the class bytes. The simulator’s LVGL pool is scaled by the measured 1.6× pointer overhead. A screen that fits the board no longer triggers memory-pressure Activity reclaim in the simulator. - GC compaction works in 512-key slices from a 4 KB buffer claimed at boot. Before, it asked for one block per collection that grew with the live set (38 KB on claudeusage). On a fragmented heap that request failed, and compaction was skipped.
onServiceConnectedis delivered on the next main-loop turn rather than inside the drain that ran the Service’sonCreate/onBind, which splits the last long boot span out of claudeusage’s start.
View.close() leaves its parent; View.getParent() (2026-09-25; map v0.30.0, package 0.30.0)
View.close()on a child now detaches it from its parent before freeing the widget, asViewGroup.removeViewdoes from the parent’s side. Before, the parent’s child list kept the closed subtree reachable until the parent died: a dashboard that turned pages withclose()went from 14 KB to 48 KB of live heap over a dozen turns and then hitOutOfMemoryError. A closed view is released like a removed one, and a secondclose()is a no-op.View.getParent()andpicodroid.view.ViewParent, as on Android;ViewGroupimplements it, so((ViewGroup) v.getParent()).removeView(v)works as written.addViewon a view that already has a parent moves it and takes it off the old parent’s list (Android throws; picodroid’sremoveViewfrees, so a move is the only way to reparent).- Sim: with the handle sanitizer on (the
sim.shdefault), freeing a widget that still sits under a Java-owned container stops the run with a backtrace instead of leaking. - Map v0.30.0, cut on
main, folds inViewParentand the three member names the detachingclose()added (mParent,detachChild,nativeClose), so the shrunk-image check is clean again. The member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.29.0 still install.Build.VERSION.RELEASEreads0.30.0.
Keys, text gravity, flat containers, java.time (2026-09-24; map v0.29.0, package 0.29.0)
Activity.onKeyDown(int, KeyEvent)/onKeyUp(int, KeyEvent): a hardware key no focused view consumed reaches the foreground Activity, as on Android, so a screen with nothing to focus needs no invisible focus-catcher. The defaults carry Android’s BACK contract —onKeyDownconsumes BACK andstartTracking()s it,onKeyUprunsonBackPressed()for a tracked release — so an override that consumes BACK inonKeyDownsuppresses the back action without touchingonBackPressed.KeyEvent.startTracking()/isTracking()are new.examples/keydemoandexamples/claudeusageuse them.TextView.setGravity(int)/getGravity()andandroid:gravityon aTextView: the horizontal field aligns the text inside a view wider than it (LEFT/START,CENTER_HORIZONTAL,RIGHT/END); the vertical field is stored, not drawn — a label is its text’s height — so centre a label in a taller row with the parent’s gravity.Gravitygains Android’sHORIZONTAL_GRAVITY_MASK,VERTICAL_GRAVITY_MASK,RELATIVE_LAYOUT_DIRECTIONandRELATIVE_HORIZONTAL_GRAVITY_MASK.LinearLayout,FrameLayoutandRadioGroupare flat by default, as on Android: no background, border, corner radius or padding until set. They carried the LVGL theme’s 2 px card border, rounded corners, fill and 13 px padding before, which every app stripped by hand (claudeusage,picoclock,calculator,picoenvmoneach had a helper for it). Onelv_obj_remove_styleat creation replaces six per-side padding writes, so aLinearLayoutcosts one style refresh instead of six (the 1.6–2.0 msnativeCreateof the D4 findings).View.setBackgroundColorhonours the colour’s alpha, soColor.TRANSPARENTclears a background and aTextViewbackground draws; layouts take#AARRGGBBand@android:color/transparent/black/white. Compatibility: a container that relied on the theme’s implicit border or fill now needs aGradientDrawable(setStroke,setColor) —examples/resdemo’s geometry check lost its2 * 2border term; a code-built container that calledsetPadding(0, 0, 0, 0)or applied a stroke-0 drawable can stop.java.timein the SDK:LocalDate,LocalTime,LocalDateTime,Instant,Duration,ZoneOffset,ZoneId,Month,DayOfWeek,Year,DateTimeFormatter,ChronoUnitandjava.util.TimeZone, with their JDK signatures (Instant.plus(TemporalAmount),LocalDate.compareTo(ChronoLocalDate),DateTimeFormatter.format(TemporalAccessor)), so Android date code compiles and runs unchanged. Fixed-offset zones only, noZonedDateTimeorPeriod,ofPatternwith the common letters and English names, ISO parsing; see java.time.Math.floorDiv/floorMod/addExact/subtractExact/multiplyExact/toIntExactcome with it. About 67 KB of class files (76 KB of RP2350 flash with the rest of this round), sotestbench_rp2040excludes the package (framework_class_excludes);examples/timedemochecks it, on the host JDK too.- The SDK’s
compileJavais no longer incremental: the tree shadows JDK types, and an incremental compile checked a changedjava/timefile against the JDK’s interfaces instead of the tree’s. - Map v0.29.0, cut on
main, folds in the 27 classes added since v0.28.0 (thejava.timepackage,ZoneRulesandjava.util.TimeZone) and 227 member names (the key callbacks and tracking, text gravity, theMathhelpers and everyjava.timemember), so the shrunk-image check is clean again. The member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.28.0 still install.Build.VERSION.RELEASEreads0.29.0.
Text size (map v0.28.0, package 0.28.0)
TextView.setTextSize(float),setTextSize(int unit, float),getTextSize()andgetLineHeight()mirror Android, backed by newpicodroid.util.TypedValue(COMPLEX_UNIT_*,applyDimension) andpicodroid.util.DisplayMetrics(Resources.getDisplayMetrics()), andandroid:textSizein a layout — a dimension, so@dimen/references work.Buttoninherits it and a content-sized button grows with its face. The one divergence: the faces are bitmaps, so a size snaps to the nearest compiled face (ties go up) andgetLineHeight()reports the face in use. One density:px,dpandspare one pixel,PT/IN/MMare nominal at 160 dpi.- Boards choose their ladder with a
text_sizeskey (MCU or board toml):14;20;28;64on the RP2350 boards,14on the RP2040 testbench, where every size snaps to 14. Sizes above 14 are ASCII-only Montserrat faces generated byscripts/gen-fonts.shintocrates/pd-lvgl-sys/lvgl/fonts/(half the flash of LVGL’s stock faces);pd_fonts.cassembles the table the runtime reads, so nothing is compiled a board did not list. setIncludeFontPadding(false)now trims the leading the face actually has above its digits (measured on the face, unchanged at 14) and, on aButton, trims the label rather than the button box; a single-line or max-lines cap follows a size change.- Compatibility: apps that never call
setTextSizerender exactly as before. - Map v0.28.0, cut on
main, folds in the 4 classes added since v0.27.0 —TypedValue,DisplayMetrics,ColorStateListandCircularProgressIndicator— and 92 member names (the text-size, ring-gauge,ProgressBarrange/tint andperform*lifecycle surfaces), so the shrunk-image check is clean again. The member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.27.0 still install.Build.VERSION.RELEASEreads0.28.0.
Ring gauges: CircularProgressIndicator (map v0.28.0, package 0.28.0)
picodroid.widget.CircularProgressIndicatorarrives: Material Components’ determinate ring, aProgressBarsubclass over LVGL’slv_arc, withsetIndicatorColor,setTrackColor,setTrackThickness,setIndicatorSize,setIndicatorDirectionandsetTrackCornerRadius, plus picodroid’ssetStartAngle/setSweepAngle(Canvas.drawArcdegrees) for dials that are not a full circle. Inflatable from XML as<CircularProgressIndicator>with Material’s attribute names.examples/claudeusagedraws its Limits page with two of them, andexamples/displaydemoshows one beside the bar.ProgressBar’s handle constructor isprotectedand its natives are package-private, so a subclass over another LVGL widget can adopt it; the ring’ssetProgressTintListandsetProgressBackgroundTintListcolour its indicator and track. No other app-visible change.
ProgressBar styled per instance, Android-shaped (map v0.28.0, package 0.28.0)
ProgressBargains Android’s range and tint API:setMax/getMax,setMin/getMin,setProgress(int, boolean animate)(80 ms, as Android’s),incrementProgressBy, andsetProgressTintList/setProgressBackgroundTintList/setIndeterminateTintListwith their getters, taking the new single-colourpicodroid.content.res.ColorStateList(ColorStateList.valueOf(color),withAlpha).nullreturns a part to the theme colour. Layouts takemin,max,progressTint,progressBackgroundTintandindeterminateTinton<ProgressBar>, applied withmin/maxbeforeprogresswhatever the XML order.setProgress(int)is now instant, as on Android. It was nominally animated, but LVGL’s theme gives the bar a 0 ms animation, so nothing visible changes; ask for motion withsetProgress(value, true).examples/claudeusagedraws its limit and model bars withProgressBarinstead of nestedFrameLayouts.
Activity callbacks reach a base-class override (map v0.28.0, package 0.28.0)
onStart,onResume,onRestart,onPause,onStop,onDestroy,onBackPressedandonActivityResultnow dispatch throughfinalperform*methods onActivity, as theBundlecallbacks already did. Before, the runtime looked each callback up by name on the app’s own class and fell back toActivity, so an override declared on an app’s shared baseActivitywas silently never called.examples/bundledemocovers it with aBaseStateActivityand aBaseHomeActivity.Servicecallbacks are unchanged.
Saved instance state, and Activities the framework may reclaim (map v0.27.0, package 0.27.0)
picodroid.os.Bundlearrives, and with it Android’s instance-state contract:onCreate(Bundle savedInstanceState),onSaveInstanceState(Bundle outState),onRestoreInstanceState(Bundle)andActivity.recreate(). ABundleis a typed string-keyed map —putInt/putLong/putFloat/putDouble/putBoolean/putString/putBundleand theint[]/byte[]/String[]arrays, eachgetwith an optional default, plussize/isEmpty/containsKey/remove/clear/keySet/putAlland a copy constructor. AnIntent’s extras are oneBundlenow, reachable asgetExtras/putExtras/getBundleExtra/putBundle. Seeexamples/bundledemo.onCreate()takes aBundle. EveryActivitydeclaresprotected void onCreate(Bundle savedInstanceState)and callssuper.onCreate(savedInstanceState), as on Android; the build rejects an appActivitythat declares a no-argonCreate()(ApiContract.RETIRED_CALLBACKS), because without@Overrideit would compile and never run.ApplicationandServicehave the no-argonCreate(), their Android shape.Intent’sextraCount/extraKey/extraInt/isIntExtraare gone; theBundleaccessors replace them.- A covered Activity can now be destroyed and rebuilt from its Bundle. Until this release
the framework never reclaimed one, so a saved
Bundleonly ever came back throughrecreate(). An Activity covered by another is now released — oldest first, at both ends of a push — while the LVGL pool has less than an eighth of its bytes free or the native heap less than a sixteenth, and is re-created on the way back out (onCreate(saved)→onStart→onRestoreInstanceState→ result →onResume, with noonRestart). Build or run withPICODROID_DONT_KEEP_ACTIVITIES=1to reclaim on every push regardless of pressure, which is how to find an Activity that keeps its state in fields instead of itsBundle. Each stack entry carries a token that outlives the instance, so astartActivityForResultresult still reaches a caller that was destroyed and rebuilt while the callee was up. Pressure is sampled only when an Activity is pushed; an allocation that fails mid-screen does not trigger a reclaim. Seeexamples/reclaimdemo. - Map v0.27.0, cut on
mainafter the merge, folds in the 5 classes this package added —Bundle,LayoutInflater,InflateException,ResourcesandResources$NotFoundException— and 44 member names, so the shrunk-image check is clean again. The member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.26.0 still install.Build.VERSION.RELEASEreads0.27.0. Everything else under Unreleased ships in the same package.
Resources, R and XML layouts
- An app may carry an Android-style
res/directory:res/values(string,color,dimen,integer,bool),res/layoutandres/drawable. Gradle generatesRand compiles the tree into a new RESOURCES section of the PAPK (format v1.2); no XML is parsed on the device, and theRclasses are left out of the package, so resources cost an app only their table. New API:Context.getResources()/getString(int)/getColor(int),Resources,Activity.setContentView(int),getLayoutInflater(),LayoutInflater,findViewByIdonActivityandView, andImageView.setImageResource(int). See the resources guide andexamples/resdemo. - An app without
res/builds a byte-identical PAPK, and firmware from before this change runs a v1.2 PAPK unchanged (it never looks at the new section). MATCH_PARENTnow means the parent’s size.setSizeandLayoutParamstranslated it to a constant left over from an older LVGL coordinate encoding — in effect a width of two million pixels, clipped by the parent. Left-aligned content looked right; a centred or weighted child of aMATCH_PARENTcontainer sat far off screen, andgetWidth()returned the raw number. It is now LVGL’s ownLV_PCT(100). Layouts that leaned on the old behaviour will shift.
The main-stack floor is enforced by the link, and it was measuring the wrong thing
- Every firmware build has printed its core-0 main-stack headroom and failed below 8 KB
since 2026-09-04, but the figure came from
arm-none-eabi-size, which files an executable.data(the RAM-resident flash routines) under text. It overstated the stack by 8 to 10 KB per board: the touch kit printed 11,816 B and linked 2,048 B. The boot-time sweep of a previous app’s LittleFS data needs 2,052 B (measured with a painted stack), so the HIL nightly of 2026-09-17 lost every touch-kit row after the first app that wrote a file, each one faulting straight after itsboot:line. - The generated linker script now asserts
_stack_start - _stack_end >= 8 KB(__main_stack_floor), so the link itself refuses such an image whichever script or CI job built it; the build’s headroom line reads the same linked figure. The RP2350B descriptor (pico_touch_kit) gives the JVM arena 336 KB instead of 344 KB, which links a 10,240 B stack. - The same assertion caught
testbench_rp2350w, whose release image had linked a 3,072 B stack under the radio’s statics, andtestbench_rp2350sat at 9,216 B in release and exactly 8,192 B in debug. Both boards’ LVGL pools drop to the 48 KB the Enviro boards andtestbench_rp2040already run on: 19,456 B and 25,600 B (24,576 B debug), and 16,384 B less static RAM on the ratchet. The others were already above the floor:testbench_rp204011,264 B,pico_enviro_mon_w19,456 B,pico_enviro_mon24,576 B.
LVGL v9.6.0, rendering straight into the panels’ byte order; cyw43-driver v2.0.0 under MIT
- LVGL moves from v9.5.0 to v9.6.0, the last v9 release. It renumbers
lv_event_code_tagain (LV_EVENT_DELETE42 to 48,VALUE_CHANGED35 to 39,INVALIDATE_AREA54 to 60, among others) andLV_IMAGE_ALIGN_COVER(14 to 15), and moves its public headers toinclude/lvgl/; the hand-pinned constants were re-read from a C probe compiled against the new headers, and the header-parsing guards follow the move.lv_conf.his off the names v9.6 deprecates and pinsLV_USE_CHECK_ARGoff, which v9.6 turns on when Kconfig is absent. - The panels take RGB565 big-endian. LVGL used to render little-endian and byte-swap every band
before the flush (
LV_COLOR_16_SWAP, deprecated in v9.6 and gone in v10); it now renders intoLV_COLOR_FORMAT_RGB565_SWAPPEDdirectly. The flush receives the same bytes: the per-band CRC sequence ofgraphicsbenchis identical before and after in the simulator on three boards, and ontestbench_rp2040,testbench_rp2350andpico_touch_kit. It is not a speed change — the old swap was ~3 ms of a ~109 ms touch-board frame, and RGB565 images now pay a swap per pixel while blending. - Flash: +5,044 B (RP2040) and +5,056 B (RP2350) for v9.6.0, +6,552 B and +6,432 B for the swapped blender; +28 B of RAM, all v9.6.0.
- The cyw43-driver fork now carries upstream v2.0.0 (was v1.1.1) under its gSPI bring-up
fixes. v2.0.0 relicenses the driver to MIT and drops
LICENSE.RP, so the Raspberry Pi silicon clause no longer applies;NOTICEand licensing follow. The Pico 2 W passesnetdemoandhttp_geton the bench.
One Java task interprets at a time, held by a kernel mutex
- The shared JVM heap relies on Java tasks changing places only where one blocks. That was a
property of the scheduler configuration — one priority, time slicing off — and FreeRTOS
broke it: when a higher-priority task wakes and blocks again, the kernel resumes the next
ready task of the interrupted tier, so two Java tasks swapped at whatever instruction one of
them was on. The simulator’s new
pdbtask did that a hundred times a second andthreadstressfailed (InvalidReference,OutOfMemoryErrorwith 230 KB free); on a board the tick timer, the sensor sampler and the USB bridge do the same, more rarely. - Every Java life — the UI task, each
Thread.startchild, each background-pool item — now holds a recursive kernel mutex, released around every blocking wait (sleeps, monitor waits, queues, sockets,Thread.yield). A task the kernel rotates in blocks on the mutex instead of running Java, so what a Java thread observes is unchanged. The flip side: a native that blocks for long without releasing it now stalls every Java thread, not just its own.docs/designs/jvm-run-lock-2026-09.mdhas the rules for new blocking code. - The “View touched off the UI thread” warning fires only for a
VieworDisplaynative; a worker callingThread.currentThread()orSystemClock.sleep()no longer trips it. - Flash:
testbench_rp2040+2,716 B (with the storage sweep below),testbench_rp2350+1,568 B.
The UI clocks advance by measured time, and alarms are polled only when one can be due
- The UI tick fed LVGL, toasts and property animations a literal 16 ms per tick, so a late
tick — a 200 ms frame coalesces the ticks posted meanwhile into one — advanced them by one
period and everything ran slow on a slow board. The tick now advances by a period while the
loop keeps up and by its measured lateness when that is more, staying within a period of the
wall clock;
animdemo’s end action lands at wall-clock time.Display.update()keeps its fixed one-period step, so an app pumping its own frames still counts them. AlarmManagerkeeps the earliest armed trigger per clock, and the tick asks that first — two comparisons — instead of scanning the alarm table every 16 ms. Idle GC waits 2 s of measured time rather than 125 ticks.- This change was reverted on 2026-09-13 and re-landed with its bug fixed: the gate judged an
RTC_WAKEUPtrigger against the clock’s offset rather than the wall clock, so on a board whose clock was never set an RTC alarm could be held back for good. After the fixalarmdemofires 124 / 126 ms late on the Pico 2 W slot (115 / 128 ms before the change). - Flash: RP2040 +144 B and +8 B of RAM, RP2350 +604 B.
A shared-bus touch read no longer freezes the UI for five seconds
- On a board whose XPT2046 shares the display’s SPI bus, a full-duplex transfer could end with
the controller finished but the received-byte count one short, and no interrupt left to
come. The driver waited out its whole 5,000 ms cap, silently, on the UI task — and, under
the run lock above, froze every Java thread with it. This was the
qa_lifestall on the Pico 2 W testbench, the last open P1 of the 2026-09-13 QA round;testbench_rp2040had been paying the same five seconds without failing a row. - The wait also accepts the controller’s own account of being done (everything shifted out,
shifter idle, RX FIFO empty). What is lost then is one touch sample, reported once per boot.
qa_lifepasses 6/6 on the slot that stalled.pico_touch_kit(GT911 on I2C) and the Enviro boards (no touch) never reach this path.
The RP2040 stops resetting where it should throw, and stops filling its volume
- Scaled images.
imagedemoHardFaulted ontestbench_rp2040at its first scaled image: LVGL reads asset pixels in place from flash, and nothing aligned a PAPK’s sections, so an image could land at an odd address — which a Cortex-M0+ answers with a HardFault, where the RP2350 and the simulator read it happily.papk-packnow aligns every section to 4 bytes (at most 3 bytes per section; older readers take the new files unchanged). A PAPK packed before this change still installs, but an image whose pixels sit at an odd address is skipped on every board with[assets] <name> skipped: pixel data is not 2-byte alignedinstead of being drawn; rebuild and reinstall the app to get it back. - Out-of-memory resets. The JVM’s method and field resolution caches grew with a plain
Vec::push— on a full heap, a single 10,240-byte request that reset the board whereqa_uiexpected anOutOfMemoryError. They now simply decline to memoise when the heap refuses, at the cost of one re-resolve. The object and array heaps place their slots fallibly too, so a slot-table chunk the heap cannot hold is an allocation failure the caller collects and retries on, not an abort; the infallible slotpushis test-only now. - Orphaned app data. A single-app board never swept
/data/<package>directories, so every app flashed onto it left one behind — an 8 KB LittleFS metadata pair each — until the RP2040’s 128 KB volume was full and everymkdirfailed. Every board now removes, at boot, the data of any package that is not installed. On a single-app board that means flashing a different app deletes the previous app’s files and preferences; reflashing the same app keeps them. - A HardFault on an RP board now logs the stacked frame (
pc,lr,r0-r3,r12,xpsr); resolve it witharm-none-eabi-addr2line. probe-rs 0.31 halts at the fault vector by default, so the line appears only underprobe-rs run --no-catch-hardfault.
Forty correctness fixes from a QA round, and the exceptions that name their case (map v0.26.0, package 0.26.0)
- Seven self-checking apps (
examples/qa_*) were written against the served API surface and run on the simulator with the handle sanitizer and the device heap model on, then on all three bench boards in both shrink modes. Every failure was traced to a defect, a documented divergence, or a mistake in the app itself, and each defect was fixed in its own commit.docs/qa-2026-09-13.mdcarries the table and the divergences kept on purpose. - Language and collection semantics. A method call on a
nullreceiver throwsNullPointerExceptioninstead of an uncatchable internal error — that covers every unboxing of a nullInteger. Method references to builtins, virtual targets and constructors resolve; a lambda proxy intercepts only its own single abstract method, so a default method called on a lambda no longer runs the lambda body.Enum.valueOf,getClass()on an array, and the boxedxxxValue()accessors work;Integer.valueOfshares one box per value in the JLS range;HashMap/HashSet/ArrayListhonour a user-definedequalsand compare boxes by class and value rather than by their first field, which used to collapseInteger(1),Short(1)andBoolean(true)onto one key. Also fixed:list.sort(null),clear()releasing the buffer,parseInt(null)and its siblings,sb.append(otherBuilder),Random.nextInt(0),String.compareTo’s return value, and"ab".replace("", "-"). String.formatmatches Java.%sof aBooleanorCharacter,%xof a negative byte at the box’s own width,%,fgrouping, and floats rounded HALF_UP on their shortest digits (%.2fof 1.005 is1.01), checked against a host JDK. A bad specifier throws the subclass Java throws —IllegalFormatPrecisionExceptionfor a precision on%d,MissingFormatArgumentException,IllegalFormatConversionException,UnknownFormatConversionException— which are the four classes this map folds in.- Running out of memory is a catchable
OutOfMemoryError, not a board reset. A collection or builder the arena cannot grow, an allocation inside a native arm, a JVM frame, interning a dynamic string,String.format’s buffers, the file streams’ windows and LittleFS’s 4 KB per-open cache all report an allocation failure the app can catch; a builtin collects and retries before giving up, and the streams moved to 256-byte stack chunks instead of copying a whole 20 KB window through the heap. A host-side ratchet counts the infallible allocations left in the native arms so the list cannot grow unnoticed. - UI and lifecycle. Text is no longer cut at 127 bytes;
SeekBar.setMaxclamps andsetProgressreads back at once;ViewGroup.getChildAtanswers, and a view thatremoveViewfreed gives up its handle, so a board still on the raw-pointer handle cast can no longer re-parent a freed widget.RadioButton.setCheckedkeeps its group in sync, a secondAlertDialog.dismiss()is safe, a full click-listener table or pending-op queue throwsIllegalStateExceptioninstead of silently dropping the click or the transition, and an exception escaping aRunnableposted to the main executor is logged rather than swallowed. - On the RP2040 every
(int)cast of a negative fraction floored: the HAL maps the conversion intrinsics to the bootrom’sfloat_to_int, which rounds toward −∞. The vendored HAL truncates, which corrects the framework’s Rust and LVGL’s C alike, and the firmware checks it at boot. Disabling the ROM intrinsics instead was measured and rejected — it also drops the divider intrinsics, for +11 % on the JVM benchmark. - A LittleFS volume whose superblock carries another board’s geometry is formatted like a blank chip, with a log line, instead of failing every open until a chip erase.
- Map v0.26.0, cut on
mainafter the merge, folds the four format-exception classes and 12 member names in, so the shrunk-image check is clean again; the member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.25.0 still install.Build.VERSION.RELEASEreads0.26.0. Everything else under Unreleased ships in the same package.
Devices detect a stale widget handle, instead of following it
- The generation-tagged widget handle table now runs on the boards, not only in the simulator.
A 32-bit
nativeHandleused to be the widget’s raw address, so a handle kept past the widget’s deletion pointed into freed LVGL memory and the device followed it — the one bug class the simulator could not reproduce by construction. Every handle now carries the generation of its table slot, so a stale or forged one resolves to null and the call no-ops. - It costs 2.4 KB of flash on the RP2040 and 3.0 KB on the RP2350, plus ~1 KB of RAM for the
table, and it is on for every board with nothing to enable; the RP2040’s first hardware run of
the table passed
bugbash_ui,callbacktest,dialogdemoandanimdemo. The old cast survives one release behind the opt-outlegacy-handle-castfeature, in case a board needs it back.
The simulator is a pdb device
- The simulator now runs the device’s own debug bridge on a real
pdbtask, over a Unix socket, soping,list,sysmon, input injection,installanduninstallwork against it exactly as against a board. An install or uninstall ends the scheduler and re-execs the process into a warm boot, so the real boot path — rescan, orphan sweep, boot policy — runs and the host sees the reboot within the second. pdb -stakes a tty, a socket path, orsimfor the one running simulator;pdb deviceslists simulators tagged[sim]and prunes the sockets of killed ones. Several simulators can run side by side, one socket each.scripts/pdb.shtakes no board lease for a simulator target, so this costs nothing on a shared bench.
A scheduling monitor for the task that will not yield
- The opt-in
sched-diagfeature reports, once a second, a real-time-band task seen running by three consecutive tick hooks, a task starved while ready, and a sleep-poll loop — fed by the kernel’s own trace and tick hooks, which are compiled in only when the feature is on. Nothing of it exists in a normal build.docs/scheduling-diagnostics.mdhas the output format and what each report means.
No task spins where it could sleep
- A full-tree audit of busy-waits, sleep-polls and blocking (
docs/scheduling-audit-2026-09.md, findings F1-F20) and the fixes it ranked. WiFi: every 1 ms wait in the cyw43 driver was a nop spin — up to 500 ms per ioctl and 3 s at bring-up, with the driver mutex held — and now sleeps on the kernel; each gSPI frame waits on the DMA completion interrupt instead of spinning up to ~450 µs. Installs erase a PAPK run one sector at a time: a 512 KB run used to mask interrupts in one ~6 s window, overflowing the CYW43’s RX FIFO and timing out open TCP connections. - Displays and touch. Panel and touch settling delays (ST7789 ~350 ms and ST7796 ~540 ms
at init, 120 ms on every screen wake, 76 ms in the GT911 reset) sleep on the kernel instead
of burning the UI task’s core. The GT911 is read when its interrupt line fires (at most every
10 ms while a finger is down, a 50 ms safety net when idle) rather than a hundred times a
second, and a stale report buffer now means “nothing new” rather than a release: a drag on
the touch board used to arrive as press/release pairs, so a
ScrollViewstuck at its limit and a picker stepped against the gesture. An XPT2046 sample is one 30-byte transfer instead of ten, I2C skips the controller disable when the target is unchanged, and small SPI transfers take the bus lock, so a JavaSpiDeviceon the display’s bus cannot write into a band in flight. - Threads and the debug bridge. Any number of
Thread.joincallers wake when the thread ends (a fifth joiner used to poll every 20 ms).SystemClock.setCurrentTimeMilliscan no longer livelock the UI task on the wall-clock seqlock.pdbblocks for USB completions with a 500 ms timeout, where a host that stopped reading left it spinning and the JVM never ran again; a thread stopped by an install inside asynchronizedblock unwinds quietly instead of throwingIllegalMonitorStateException. A UARTwritelonger than the 32-byte FIFO sleeps a tick per retry instead of spinning ~1 ms per byte at 9600 baud, and drops a byte (logged once per boot) only when the line has not drained for 100 ms. - Both idle tasks execute
wfiinstead of tight-looping. In the simulator, child threads are drained by notification andServerSocket.acceptwith a timeout blocks inpoll(2). - Guards:
platforms/rp/src/spin_guard.rsrejects any busy-wait that is not written withspin_until!or excused by aspin-ok:marker, with the counts pinned (14 excused, no debt left); a config test asserts the idle hooks and the cyw43 wait gates. Thertt-lossyfeature makes defmt’s RTT channel non-blocking for timing work with a probe attached.
The touch board scrolls with its panel, not with a repaint
- A full-width
ScrollViewon a portrait ST7796 (the touch board) now scrolls by rotating a band of the panel’s own frame memory (VSCRDEF/VSCRSADD) and rendering only the rows that scrolled in, instead of repainting the whole viewport. Nothing in LVGL is patched: the scroll event and the display’s invalidation hook are the seam, every flush is translated through the panel’s rotation, and anything the panel cannot take (a scroller with a border line or rounded corner inside the band, something drawn over it, sideways motion) repaints as before. On hardware, scrolling picoclock’s Set-time screen: 36 rows rendered per frame instead of 399, render 71.5 ms to 10.8 ms, SPI 36.1 ms to 2.0 ms, 109 ms to 24 ms per frame, 9.1 to 41.9 fps. The simulator emulates the registers, and a scripted gesture is pixel-identical with the feature on and off. ScrollViewdraws no border and has square corners, as Android’s does, and its scrollbar no longer changes style while scrolling: the theme’s 80 ms thumb-opacity transition invalidated the whole scroller on every animation tick, which was five full repaints per drag on the touch board and a wasted repaint at every scroll start and end elsewhere. The thumb is opaque rather than 40%, so after a hardware step only its two ends are repainted.- Two
HalDisplaymethods with no-op defaults,set_vertical_scroll_areaandset_vertical_scroll_start; a board may force the path off withhw_vscroll = falsein[display].docs/designs/scroll-performance-2026-09.md§5 S4 has the design and the measurements, and what is left in a scroll frame now.
The touch board renders the next band while the panel takes the previous one
- The flush is asynchronous on the RP family: a band’s SPI transfer is handed to DMA and LVGL
renders the next band meanwhile, collecting the transfer before it reuses the buffer. A board
takes a second draw buffer with
[display] draw_buffers = 2, which the build refuses where the touch controller shares the display’s SPI bus.pico_touch_kitshipsband_height = 60with two buffers — the same 76,800 B as the single 120-row band below. Measured on the board: a clock-face full repaint 64 to 51 ms, the Set-time entry paint 230 to 229 ms, a hardware-scrolled frame 12.4 to 12.1 ms. - Two
HalDisplaymethods with defaults,write_pixels_startandwrite_pixels_wait; a family without a completion signal keeps them and flushes synchronously, as the simulator does. Flash:testbench_rp2040+304 B,testbench_rp2350+320 B.
The touch board paints half again as many frames
- LVGL was asked to refresh every 33 ms while the tick arrives every 16 ms, and its timer
carries no credit for the overshoot, so every paint waited three ticks and spent two of
them idle. The period is now the tick period, and a guard test reads the value out of
lv_conf.hand asserts the two agree. Measured in the simulator with a forced full-screen invalidation: 3 ticks and 50 ms on every one of 1,105 paints before, 1 tick and 16 ms on every one of 4,800 after. No size change on either board. - The touch board renders in full-width bands and walks the widget tree once per band a
widget crosses, so a full repaint of 22 twenty-row bands paid that setup 22 times. Bands
are now 120 rows — four of them — bought with a 76,800 B buffer funded out of the
RP2350B’s FreeRTOS arena (
heap_kb408 to 344). On hardware, scrolling picoclock’s Set-time screen: steady render 108.9 ms to 61.6 ms, frame 149.6 ms to 98.3 ms, 6.7 to 10.2 fps, and the entry paint 447 ms to 245 ms. The arena’s low-water mark stays near 66-76 KB through the launcher, the settings screens and an install. - Five other explanations for the render cost are dead, measured rather than argued:
LVGL’s style cache and
-O3on the C each bought nothing, and the XIP cache misses on 0.53% of accesses, so the renderer is not starved for instructions. The work is real, executed and diffuse.docs/designs/scroll-performance-2026-09.mdanddocs/designs/band-height-120-2026-09.mdcarry the profiling and what is left.
The RP2350B’s PSRAM comes up at boot, and a board may put the LVGL pool in it
- 8 MB of APS6404 on the Pico Plus 2 W is brought up from RAM-resident code at boot: the
part is taken out of QPI first (a watchdog reset leaves it there), identified, put back
into QPI, and given QMI window-1 timing derived from the system clock — a 75 MHz bus at
150 MHz — then spot-checked past the cache. An MCU descriptor declares
psram_kb,psram_originandpsram_cs_pin; a full 8 MB write-and-verify sweep sits behind thepsram-sweepfeature and reports 8.7 MB/s writing and 19.4 MB/s reading. - A board that sets
lv_mem_in_psramhands LVGL the window as its pool while render targets stay in SRAM. It ships off on the touch kit: measured there, the pool in PSRAM costs 10 ms of a 98 ms frame and 45 ms of the entry paint, because LVGL’s per-band draw-task churn lives in the pool and would cross the QSPI bus. The taller bands above are the better use of the same 64 KB, and the key is one flip away if that changes. - Any code that disables XIP now cleans the RP2350’s write-back cache by set/way and saves the second window’s timing registers around the boot ROM’s reset of them, since the ROM’s flush only invalidates. The rule is in the porting guide and in the macro itself, tested with the pool in PSRAM across a preferences commit and an install.
Android’s tones, on a board with a buzzer (map v0.25.0, package 0.25.0)
- New
picodroid.media.ToneGeneratorandpicodroid.media.AudioManager: a board with a piezo buzzer plays Android’s CEPT cadences by their own constant names. In Android a tone type is a segment list with a repeat count, not a frequency —TONE_SUP_RINGTONEis 425 Hz for a second and silence for four, forever — so the tone table and its sequencer are the faithful implementation, not a simplification. Segments advance on the existing 16 ms UI tick, so there is no task, no stack and no boot budget for it, and the hardware is touched once per note.startToneSequenceplays an app’s own melody and has noandroid.mediacounterpart, because on a phone a melody is a file. - One piezo is one square wave, so a tone plays the lowest component of Android’s
multi-frequency original and DTMF will not decode. Sampled audio has no path at all,
which is why
MediaPlayer,AudioTrackandSoundPoolare absent rather than stubbed. A board with no buzzer runs the same app and getsfalsefromstartTone, exactly as Android answers for a tone its platform will not play. See media and the compatibility matrix. - Start quiet. The 52Pi EP-0172’s piezo is rated 85 dB and sits inches from whoever is
holding the board.
examples/tonedemoopens at volume 20, a 10% duty cycle, which is plenty to hear across a desk; a repeating tone at a high volume is the case that really punishes you. - The UI tick reads a plain flag before deciding whether a tone is playing, instead of suspending the scheduler sixty times a second to ask in silence.
- Flash:
testbench_rp2350+1,556 B for the two classfiles,testbench_rp2040+152 B — that board lists them inframework_class_excludesby hand, since 2.2 KB that can never make a sound is more than its flash can spare. - Map v0.25.0, cut on
mainafter the merge, folds the two classes and their 45 member names in, so the shrunk-image check is clean again; the member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.24.0 still install.Build.VERSION.RELEASEreads0.25.0. Everything else under Unreleased ships in the same package.
LinearLayout.setGravity reaches the far edge; two apps for the touch board
setGravity(Gravity.RIGHT)andGravity.BOTTOMdecoded as START, because Android’s two ends of an axis share a bit and the decoder tested the shared bit first, so noLinearLayoutcould align its children at its right or bottom edge. The main axis now follows the layout’s orientation, as on Android (callsetGravityaftersetOrientation), and the cross axis follows the other field instead of staying centred. +112 B on both boards.- New
examples/picoclock(a clock face, alarms, a Set-time screen and a ring screen with snooze and stop, over the EP-0172 carrier’s buzzer and LED) andexamples/calculator(a four-function keypad) forpico_touch_kit.
The touchscreen is read on its own clock, not the frame’s
- Scrolling the touch board did not scroll, it teleported. The panel was read from inside LVGL’s input callback, which runs once per rendered frame, and a frame there costs 120-200 ms: a 300 ms swipe produced two positions 234 px apart. A dedicated sampler now reads the panel every 10 ms and queues each changed position; the input callback hands LVGL all of them in one pass, so LVGL walks the path the finger actually took and renders once at the end. The same swipe now delivers all thirteen of its positions on hardware.
- A tap on the capacitive board registers on the sample that saw it. The first reading after touch-down used to be discarded as unsettled, which a resistive panel needs and a capacitive controller reporting finished pixels does not.
- Boards whose touch controller shares the display’s SPI bus keep the old inline read, and
carry none of the new machinery in their image: the sampler would have to drive that bus
from a second task, which nothing serialises against a display flush. Flash, release
images:
testbench_rp2040+24 B,testbench_rp2350+32 B. - Profiled and scoped in
docs/designs/scroll-performance-2026-09.md, which also carries what is left — the panel’s own interrupt line, an honest LVGL tick, and the render and tearing work that the 3-8 fps figure actually belongs to.
An alarm that outlives the app that set it (map v0.24.0, package 0.24.0)
- New
picodroid.app.AlarmManagerandpicodroid.app.PendingIntent, on multi-app boards: an app schedules one of its own Activities for a wall-clock or elapsed-time instant and is free to exit. The framework holds the alarm outside every app’s memory, starts the app again when it comes due, and delivers the Activity on top of it.set/setExact(both exact) andcancel, with Android’s four clock constants;PendingIntent.getActivitycarries up to twointextras. Alarms live in RAM and are lost at a reset, so an app re-registers at startup as an Android app does afterBOOT_COMPLETED. See services anddocs/designs/alarm-manager-2026-09.md. picoclockno longer watches the clock itself, which is what it was for: its alarms used to stop the moment you pressed HOME, and now they ring whatever you are doing. ItsAlarmServicekeeps the heartbeat, the buzzer and the snooze, and hands the framework one operation per alarm slot. The carrier’s buzzer also drops from a 50% to a 10% duty cycle — the loudest a square-wave sounder gets is louder than a bedside alarm needs.Intent.setClassName(String, String), four framework-internal extras accessors onIntent,Context.ALARM_SERVICE, andSystemClock.elapsedRealtime().examples/alarmdemois the cycle in three classes and a harness row;sim-run.shgains analarmlane that drives the leave-and-come-back path through the launcher.- Map v0.24.0, cut on
mainafter the merge, folds the two classes and their 29 member names in, so the shrunk-image check is clean again; the member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.23.0 still install.Build.VERSION.RELEASEreads0.24.0. Everything else under Unreleased ships in the same package.
The settings screens and the launcher stop holding the UI tick
- Opening Settings > Storage or Apps froze a device for 190–330 ms, and the launcher’s list for 240 ms: every row is ~20 ms of LVGL work and the screens built them all inside
onCreate. Every screen now shows its header at once and adds rows one per UI tick, each under the watchdog’s 50 ms; the Storage screen fetches its numbers onExecutors.backgroundExecutor()and fills the rows in as they arrive. Noslow handlerline on any screen. - Pool workers on the multi-app boards get 6 KB stacks (
[background_pool] stack_bytes = 6144, as the Enviro W already had): the Storage job measured 4,088 B deep and overflowed the 4 KB default. +8 KB of the FreeRTOS arena per board, none of the image. - The package directory keeps each entry’s name, label, version and icon as slices into its image, read once at scan time, so a
PackageManagerquery no longer parses a manifest from XIP flash (the Apps screen at seven packages did so about 150 times).StorageStats.getDataBytes()keeps a package’s figure until its directory changes, andStatFswalks the volume only after something on it changed. See storage and the launcher guide. - RAM: release images,
testbench_rp2350flash +340 B and RAM unchanged;testbench_rp2040flash −968 B, RAM +8 B — the package entries grow by 36 bytes each and the quota keeps a row per slot, inside padding the images already carried.
A TextView holds one line, cut with an ellipsis (map v0.23.0, package 0.23.0)
TextView.setSingleLine()/setSingleLine(boolean),setEllipsize(TextUtils.TruncateAt)/getEllipsize()andsetMaxLines(int)/getMaxLines()mirror Android over LVGL’s label long modes: a single-line or max-lines view is at most that many lines tall and, with an ellipsize, the last line that fits is cut with three ASCII dots;MARQUEEscrolls the text circularly.getText()returns the full text throughout. Newpicodroid.text.TextUtilswithTruncateAtandisEmpty. See UI and the compatibility matrix for the divergences.- The launcher and settings rows use it: a long app label is cut by the pixel, not by a character count, and the version and the storage numbers always show.
examples/ellipsizedemopins the behaviour as a sim lane. The simulator’s owncargo test -p picodroid-corecompiles again (the package-verb poll instop_jvmis gated on thesimfeature).- Flash, release images:
testbench_rp2040+2,880 B,testbench_rp2350+3,512 B — the twoTextUtilsclasses, theTextViewmethods and their native, the FFI, and the launcher and settings PAPKs. - Map v0.23.0, cut on
mainafter the merge, folds the twoTextUtilsclasses and their 15 member names in, so the shrunk-image check is clean again; the member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.22.0 still install.Build.VERSION.RELEASEreads0.23.0. The multi-app QA fixes below ship in the same package.
Multi-app QA fixes (after v0.22.0)
- Keypad boards. A view’s
requestFocus()no longer reorders the focus ring (LVGL’slv_group_add_objre-appends a member at the tail; “down” from the settings root’s About row went Home). A shownAlertDialogholds the keypad: NEXT/PREV cycle its own buttons instead of walking onto the rows behind the scrim, where “select” opened a second dialog. A focusable view shows a 2 px border while it has the focus — the theme’s outline is clipped away for full-width rows, so the launcher and settings screens gave no sign of the selection. - Dialogs cover the whole display and are centred on it (the scrim was a 240 px square; on the 320 px testbench a tap beside it reached the rows behind the dialog), without a frame around the buttons or a scrollbar on the card. The harness taps the settings dialog’s Uninstall button at a position derived from the board’s width (
lib.sh::settings_dialog_ok). Build.VERSION.RELEASEis the firmware release (0.22.0), generated intobuild_info.rs; it read0.0.0on every unshrunk image, the framework-map sentinel, and Settings > About showed it.- Launcher and settings columns scroll (a
LinearLayoutdoes not, as on Android; a swipe on the testbench launched the row under the finger, and the Enviro’s focus moved onto rows that never scrolled into view); rows hold one line; the Apps list is sorted like the launcher’s, both case-insensitively; the About and Storage rows are focusable, so a keypad walks and scrolls them. - Simulator:
apps installreads the PAPK outside the simulated heap cap (an 800 KB app was “out of memory”), the package verbs reach anApplicationthat has no Activity (blinky could not be uninstalled), every install stops the running app and brings it back — a launcher that comes back lists the new app — and a refused install says why in words. A device that has nothing left to run no longer reports the exited app asrunninginpdb list. - Flash: the RP2350 release image +1,468 B against the v0.22.0 baseline (the settings and launcher PAPKs, the focus border, the modal group); the RP2040 −664 B.
Every app has its own storage, a settings app, and apps that uninstall apps (multi-app M3; map v0.22.0, package 0.22.0)
- The storage sandbox. Every path an app names —
new File("/x"), aSharedPreferencesfile,getFilesDir()— now lives under the app’s own/data/<package>on the one LittleFS volume, on every board:..is refused, an app path is at most 185 bytes, and no path can reach another app’s files. Existing examples run unchanged; files a dev board held at the volume root are no longer visible to apps (they are not deleted).ContextgainsgetDataDir,getFilesDir(/files),openFileOutput(MODE_PRIVATE,MODE_APPEND),openFileInput,fileListanddeleteFile;Filegainslist()andlistFiles(). Write-side failures throwjava.io.IOExceptioninstead of a VM fault:FileOutputStream.write,File.createNewFileand theopenFile*methods declare it. Data goes with its package:pdb uninstallandPackageInstaller.uninstallremove/data/<package>, and a multi-app device sweeps orphaned directories at boot. See storage. - The reserve and the cap (multi-app boards).
fs_system_reserve_kb(64) keeps the tail of the volume for the system;app_data_cap_kb(a quarter offs_kb, 128 KB on the RP2350 boards;0lifts it) bounds one app’s directory, in LittleFS’s currency — 4 KB blocks per file, an 8 KB pair per directory.picodroid.os.StatFsreports the volume and what the app may still write;picodroid.app.usage.StorageStatsManager(getSystemService(STORAGE_STATS_SERVICE)) the bytes per package;picodroid.os.Buildthe board, MCU and release.examples/quotademoandexamples/filesdemoprove the rules; both are bench rows. - The settings app.
system-apps/settings(picodroid.settings) sits beside the launcher in every multi-app firmware: About (board, MCU, release, volume, heap), Apps (one row per installed app; a tap opens a dialog and Uninstall removes the app and its data throughPackageInstaller), Storage (app and data bytes per package). On a touch board the header row goes up one level and the root’s header is Home.PackageManager.getPackageInstaller().uninstall(name)is the API behind it, synchronous, refusing a system app or the caller withIllegalArgumentException. - Simulator. Its host image now has the board’s volume size (
fs_kb) and an image of another size is started afresh;sim.sh --system-appsloads the settings app too, and a newsettingslane insim-run.shwalks its screens. - Flash, release images against the
-Osbaselines below:testbench_rp2350+36,064 B (911,827 → 947,891: the settings PAPK, the new classes and natives, the quota);testbench_rp2040+10,116 B (783,547 → 793,663: the sandbox, theContextfile API,File.list,StatFs,Build). Map v0.22.0 folds the six classes (Build,Build.VERSION,StatFs,StorageStatsManager,StorageStats,PackageInstaller) and their 40 member names in; the member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.21.0 still install.Build.VERSION.RELEASEreads0.22.0.
The device C compiles at -Os, without frame pointers
- Both RP MCU tomls (
platforms/rp/mcus/rp/rp2040.toml,rp2350.toml) setc_opt_level = "s": every C object built for that MCU — LVGL, the FreeRTOS kernel and shim, and the network stack and WiFi driver on a board that has them — compiles at-Osinstead of following cargo’s-O3, through the newbuild_support/config.rs::apply_c_opt_level. The same helper drops the frame pointers cc-rs adds because the profiles keepdebug = 2; device backtraces come from DWARF and are unaffected. Rust and the JVM are untouched, and so is the simulator, whose C still follows cargo. GCC’s Thumb-1 switch tables call libgcc helpers that rust-lld does not link, so the RP2040 objects also get-fno-jump-tables. The key is documented in the porting guide. - Flash, release images:
testbench_rp2040893,243 → 783,547 B (−109,696; −90,564 from-Os, −19,132 from the frame pointers),testbench_rp23501,004,755 → 911,827 B (−92,928; −81,440 from-Os, −11,488 from the frame pointers). Debug images:testbench_rp2040913,083 → 803,484 B,testbench_rp23501,006,348 → 913,436 B. RAM unchanged. The RP2040’shandle-table-32debug leg had 1,893 B of program region left before this; the room is what the multi-app M3 storage work needs. The cost is LVGL render throughput, unmeasured.
flash.sh flashes the image it measured
flash.shbuilt the firmware with the profile overrides every firmware build uses (debug-assertions and overflow-checks off; no fat LTO on the RP2040), printed and gated its size, and then flashed through acargo runthat lacked those overrides. Profile keys are part of cargo’s fingerprint, so that second call rebuilt the whole tree under the stock profile and put an image some 40 KB larger than the gated one on the board — a debug RP2040 image that fitted the gate could fail to link there.build_firmwarenow exports the overrides asFIRMWARE_PROFILE_ARGS, and the flash step passes them, so the second call is a no-op build and the board gets the measured image.
A launcher built into the firmware, and apps that start one another (multi-app M2; map v0.21.0, package 0.21.0)
- The launcher. Every multi-app board (
max_installed_appsabove 1; the four RP2350 boards) now carriessystem-apps/launcherinside its firmware: one row per installed app, icon and label, started by a tap or by the up/down/select buttons. It shows inpdb listas aSYSTEMrow, takes no space in the app region, and cannot be uninstalled. Single-app boards embed nothing. - What boots.
flash.sh --boot app|launcher|<package>, then a board’sboot_package, then the appflash.sh --appbaked in, then the launcher, then the lowest run. A freshly flashed app always wins over an older copy apdb installleft behind. In the simulatorPICODROID_BOOTdoes the same at run time, andsim.sh --system-appsloads the launcher; without it the simulator runs one app and exits, as before. - Starting another app.
Intent.setPackage(name), orgetPackageManager().getLaunchIntentForPackage(name), withstartActivity. One app runs at a time: the current app is torn down through its normal lifecycle and the other one starts; when it finishes, the launcher comes back. A package that is not installed throwsActivityNotFoundException. There is no task stack, and extras do not cross over. PackageManagerqueries.getInstalledPackages,getPackageInfo,getLaunchIntentForPackage,getApplicationLabel,getApplicationIcon, withPackageInfo,ApplicationInfo(FLAG_SYSTEM) andPackageManager.NameNotFoundException;Context.getPackageManagerandContext.getPackageName.BitmapDrawablecarries another app’s icon intoImageView.setImageDrawableorView.setBackground. On single-app boards onlyhasSystemFeaturestays; the rest leaves that board’s framework. A focusable view now scrolls into view when it takes focus.pdb listadds the system rows and arunning: <package>line; the protocol stayspicodroid/2.2. New bench rowslaunchandlaunch-soak(twenty launch-and-return cycles with no heap drift) and a nightly simulator lane cover the switch. See the launcher guide.- Flash:
testbench_rp2350+27,600 B (the launcher PAPK, the new classes and natives),testbench_rp2040+7,988 B (the largerPackageManager,IntentandContextclasses and the boot selection). Map v0.21.0 folds the five classes and their 31 member names in; the member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.20.0 still install.
picodroid.json: JSONObject, JSONArray, JSONException (roadmap T2.6; map v0.20.0, package 0.20.0)
- Android’s
org.jsonAPI, underpicodroid.json. The full method surface ofJSONObjectandJSONArray— constructors from text,Map,Collectionand arrays,get/optwith Android’s coercions,put/putOpt/accumulate/append/remove,keys/names/keySet,toString/toString(indent),quote/numberToString/wrap, theNULLsentinel — and a checkedJSONException. Documents live in a native node pool: a wrapper holds only its node’s index, values materialize onget, and a child wrapper shares its parent’s node (mutations are visible both ways, as on Android). Nodes are reclaimed with the garbage collector through a newnative_state_prunehandler hook, which fires right after every collection the waymonitors_prunedoes. The parser is strict RFC 8259; the pool is capped at 2048 nodes and 16 KiB of strings. See JSON. - One board switch.
has_json = trueinboard.tomlturns the whole feature on — the four RP2350 boards set it.testbench_rp2040does not: the three classes leave its embedded SDK and the parser compiles out, so it pays nothing, andverifyApiContract --board testbench_rp2040rejects an app that uses JSON at build time instead of on the device. - picoenvmon’s weather row comes from open-meteo JSON (
current.temperature_2mplus the WMOweather_codemapped to text, e.g.Overcast +17C) instead of wttr.in’s plain-text one-liner, in both the Java and Kotlin twins. Same fail-soft contract and 4 s timeouts. - New
examples/jsondemoconformance app (also ajsondemoHIL row) covering parsing, coercion, mutation, identity, serialization, the error cases and a GC-stress loop over the pool. - Map v0.20.0 folds the three classes (and
JSONObject$1) and their 83 member names in; the member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 through v0.19.0 still install.
net: down is logged once per change, not once per retry
- While a WiFi join keeps failing, FreeRTOS+TCP re-runs its link initialisation every 3 s and fired the down hook each time, so the RTT log filled with
net: downlines that carried no news. The hook now remembers the last state and logs only a transition: onenet: down, thennet: up, ip …when the join succeeds, andnet: upagain only if the address changes.
Debug firmware boots on the Pico 2 W again; every board keeps a main-stack floor
- The core-0 main stack is whatever
.data+.bssleave of RAM. The network boards’ statics had squeezed it to 4.4 KB, and the debug-profile image (the oneflash.shbuilds by default, with line-number traces) faulted at boot before its first log line. The RP2350 heap arena is now 408 KB instead of 416 KB, which leaves at least 12 KB on every RP2350 board; picoenvmon still fits. - Every firmware build now prints its main-stack headroom and fails when it drops below 8 KB (
MAIN_STACK_FLOOR_BYTESinscripts/lib.sh), so static growth can no longer eat the stack silently.
Networking seams for FreeRTOS+TCP families; apps can tell WiFi from Ethernet (map v0.19.0, package 0.19.0)
NetworkInfo.getType()returnsConnectivityManager.TYPE_WIFI(1) orTYPE_ETHERNET(9), orTYPE_NONE(-1) on a board without networking. The newpicodroid.net.ConnectivityManagerholds Android’s constants; it has no instance, because a board has one link.PackageManager.FEATURE_ETHERNET.hasSystemFeaturenow answersFEATURE_WIFIandFEATURE_ETHERNETby the board’s link kind instead of “any network”; picoenvmon’s network thread accepts either.- Porting. A family that runs FreeRTOS and FreeRTOS+TCP writes no socket code any more: core’s
FreeRtosTcpNetis registered with one line, the stack glue and IP policy live inpicodroid-core/net-freertos-tcp/, and the family writes aNetworkInterface_<X>.cplus aNetLinkfor its chip (the RP family’s cyw43 driver is the reference). The porting guide’s “The network” section walks through it; SPI MACs such as the W5500 and on-chip MACs both fit. Design:docs/designs/network-seam-2026-09.md. board.toml:has_network = truenow requires anetwork_type; the build emitsnetwork_link_wifi/network_link_ethernetfrom the(type, kind)table inbuild_support/board_cfg.rs, and the core features arenetwork-wifi/network-ethernet.- Flash: the Pico 2 W images +736 B;
testbench_rp2040+172 B andtestbench_rp2350+404 B (the Java additions). Map v0.19.0 foldsConnectivityManagerand its fields in; the member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 or v0.18.0 still install.
All third-party code lives under third_party/
- The
vendor/tree is gone. The LVGL, FreeRTOS+TCP and cyw43-driver submodules now sit beside FreeRTOS-Kernel and the littlefs fork underthird_party/, andscripts/format_java.sh/format_kotlin.shdownload their JARs there (third_party/*.jaris gitignored). The submodule names changed with the paths, so existing checkouts must rungit submodule sync && git submodule update --initafter pulling, then delete the stalevendor/directory. The build-time fork guards now saythird_party/cyw43-driver is the unpatched upstream(and the FreeRTOS+TCP equivalent); the header drift guards,build_support,NOTICEand the docs follow the move.
Stack traces name the source line — on device too (roadmap T2.4)
- Uncaught-exception frames now read
at pkg.Class.method(Class.java:42), Android’sStackTraceElementspelling, in the simulator and in debug-profile device firmware (scripts/flash.sh’s default). Before, only the simulator resolved a line — as(:42)— and a device printed the bytecode offset,(pc=9), because the firmware’sLineNumberTableparser was gated ondebug_assertions, which every device build turns off. The gate is now aline-numberscargo feature; it is on forsim.shand debug-profileflash.sh/build.sh, and off for--release(HIL, CI, the size ratchet), which keeps the ~15 KB of SDK line tables out of release flash.PICODROID_LINE_NUMBERS=0|1overrides. A build with the feature also keeps the tables in its PAPK (build-apk.sh --strip-debug --keep-lines); mixing a with-lines PAPK and a release firmware, or the reverse, is harmless. - Release logs resolve on the host.
scripts/retrace.sh [--app <name>] < device.logturns a release firmware’s(pc=9)frames into the same(Class.java:42)frames from the unstripped class trees this checkout compiled, composed with the shrink-map un-shrinking it already did — one pass reads a--release --shrinklog. Overloads that disagree list every candidate (Class.java:12|40). A class without aSourceFileprints(Unknown Source:42). No RAM cost: the per-method table offset is au16inMethodInfo’s padding.
The small Android methods people reach for first (map v0.18.0, package 0.18.0)
TextView.getText()(andButton.getText()) returnsCharSequence, as on Android, sogetText().toString()works unchanged.Gpio.DIRECTION_INandgetValue()— a pin can now be read, not only driven.DIRECTION_INcarries no pull (Android Things semantics); the simulator reads such a pinLOW.java.util.Objects—equals,hashCode,hash,toString(o),toString(o, default),requireNonNull(both forms),isNull,nonNull. A real class, soequals/hashCode/toStringreach your overrides.String.joinforjoin(delim, a, b, …)/String[]andjoin(delim, ArrayList); elements must beStringornull.Float.intBitsToFloat.File.getName/getParent/getParentFile/getAbsolutePath/mkdirs/createNewFile.SharedPreferences.getFloat/Editor.putFloat(new blob type tag; existing preference files load unchanged).- Flash: +5.9 KB on both boards (about 2.4 KB of it Java class bytes,
Objects.classalone 1.1 KB). Map v0.18.0 folds the new class and members in; the member floor stays at v0.17.0, so PAPKs shrunk with v0.17.0 still install.
Smaller PAPKs: --shrink-app renames the app’s own classes and members
--shrink-app(onbuild-apk.sh,build.sh,flash.shandsim.sh; requires--shrink) extends the release map to the app itself, ProGuard-style: the app’s classes move under a third prefix,c/, its private method and field names shrink to one or two characters, and the descriptors naming them follow. Each PAPK build cuts its own map (class-shrink cut-app, Gradle taskcutAppShrinkMap) by continuing the release map’s allocator, andbuild-apk.shwrites it next to the PAPK asbuild/apks/<app>.shrink-map.toml— that file is the PAPK’s retrace key (./scripts/retrace.sh build/apks/<app>.shrink-map.toml < log).picoenvmon’s stripped PAPK: 49,929 → 40,632 B (−18.6 %); the rp2350 release image that embeds it, 917,393 → 908,096 B. The firmware is untouched andframework-map-versionis unchanged, so a--shrink-appPAPK installs on any--shrinkfirmware of the same release.- What it means for an app:
Class.getName()on your own class returnsc.Aand log lines spellc/A.qZ(examples/classlitaccepts either spelling); string literals are never rewritten; the manifest keeps namingmyapp/MyAppandpapk-packmaps the entry class for you;mainand the generatedinjectMembersstay verbatim, and a<X>_MembersInjectorfollows its component’s new name so@Injectkeeps resolving; thekotlin/**shim rides through untouched. Classes in the default package, or in a package literally nameda,borc, are rejected at build time. Details on the shrinker page.
--shrink is now unconditional — ProGuard semantics (map v0.17.0, package 0.17.0)
- A
--shrinkfirmware carries no original Java name anywhere. Previously the runtime kept both spellings of every framework class (a 300-armunshrink_classmatch translateda/ABback topicodroid/view/Viewon every native call), undid theb/prefix forjava/**names at the JVM’s class-file boundary, and kept the ~125java/**member names it dispatches by literal (toString,hashCode,equals,hasNext, …) unmapped. All three are gone: every class, member and descriptor the Rust runtime names is now a build-generated constant (c::picodroid_view_View,m::toString,d::String__V—build_support/names.rs) whose value is the loaded spelling, the contract members and javac’s$synthetics are mapped like everything else, and the image is checked after linking (scripts/check-shrunk-image.sh, inpre-commit --full).picoenvmononpico_enviro_mon(--release --shrink): see the flash budget §6.2 for the measured delta. No-shrink builds are byte-identical. Class.getName(), stack traces and log lines show the mapped name under--shrink("x".getClass().getName()isb.AQ), exactly as in a ProGuard build. Build without--shrinkfor readable names, or pipe a shrunk log through the newscripts/retrace.sh(class-shrink retrace), the host-side inverse of the map.examples/classlitnow checks name consistency rather than literal spellings.--shrinkis faster than no-shrink again, by design. Uniform four-byte class names had turned every class-table scan in the JVM (find_method, the superclass walk,new) into abcmpcall per candidate — 57 M compare calls per simbenchmarkrun against 3.2 M unshrunk, and a 16 % slowerTOTAL(string_operations+73 %). Every registered class now carries a name hash and every scan goes through onefind_class; the builtin dispatch table carries the same hash. Compare calls: 372 k shrunk / 310 k unshrunk. Ontestbench_rp2350the old shrink image was +19.5 % overall (+138 % string operations, +69 % allocation, 20 runs each, σ ≤ 5 ms); the fixed shrink image is −18 % against it and the fastest of the four measured, and in the sim--shrinkand no-shrink are at parity overall with the lookup-heavy sections 3–5 % faster shrunk. Costs ~0.9 KB of flash. Caveat for anyone reading device numbers: a 120-byte shift of the interpreter loop in XIP flash moves pure-arithmetic sections by up to 40 %, so compare only the sections a change touches (docs/designs/unconditional-shrink-2026-09.md§8.3).- The member floor moves to v0.17.0 — a PAPK shrunk with an older map is refused by 0.17.0 firmware (
pdb install: PAPK was shrunk before method/field names were).cut-releasetakes--contract(was--keep-contract; the names are now mapped, not kept) and--floor.
API shape corrections (T2.7). Three picodroid.* shapes that had diverged from their android.* counterparts are corrected in place.
Service extends Context, andonStartCommandtakes Android’s three arguments —onStartCommand(Intent intent, int flags, int startId).flagsis always0(START_FLAG_REDELIVERY/START_FLAG_RETRYdescribe redelivery after a process kill, which never happens on an MCU; the constants exist so Android code that tests them compiles). Inside a Service,getSystemService,getSharedPreferences,startServiceandbindServicenow work onthis.Button extends TextView(wasView), as on Android:setTextColoris available on a Button, and a Button can be passed wherever aTextViewis expected.setTextkeeps its button-specific native implementation.ViewPropertyAnimatoris to-only.alpha(float),x(float),y(float)andsetDuration(long)take only the target; thefrom, tooverloads are gone and the animation starts from the view’s current value, read back from the renderer. New on the animator:translationX/Y(float),rotation(float),scaleX/Y(float),setStartDelay(long),getDuration(),getStartDelay(). New onView:setTranslationX/Y,setRotation,setScaleX/Yand their getters;getX()is nowgetLeft() + getTranslationX(). Starting a property that is already animating replaces the running animation; a delayed start takes over when its delay expires. Rotation and scale render through an off-screen layer of the view’s own size (from LVGL’s 64 KB pool) — keep transformed views small.
Interface-typed collections, and a leaner framework
Map<String, String> m = new HashMap<>();is documented and pinned. Interface-typed collections (List,Set,Collection,Map,Iterable) work as locals, fields, parameters, return types, cast andinstanceoftargets — they have worked since v0.14.0’s interpreter work, but nothing said so and nothing tested it.collectionsdemonow covers the whole shape (includingIterator.remove(),Map.Entryas a declared type, and a user class implementingIterable), and the compatibility matrix states the caveat: your app compiles against the JDK’s full interfaces, so members picodroid does not implement (map.forEach,list.removeIf,new TreeMap<>()) compile and then fail at run time.- Six framework classes deleted, ≈1.5 KB of flash reclaimed on every board.
java.util.List,java.util.Comparator,java.lang.Comparable,Runnable,CloneableandAutoCloseableshipped as SDK source but were dead weight: apps compile against the JDK’s declarations (which shadow the SDK’s), and the JVM dispatches on the receiver’s runtime class, so neither side ever read them. They are now JVM builtins likeMapandSetalready were. No app change is needed — the imports and semantics are unchanged. A new test refuses any future body-lessjava.*framework class. - Firmware no longer carries Java debug attributes — 27–34 KB of flash reclaimed on every board. The
LineNumberTable,SourceFileandStackMapTableattributes in the SDK classes and in device-bound PAPKs were dead weight on a device: pico-jvm reads line numbers only in the simulator’sdebug_assertionsbuilds and never reads the other two. Device firmware now embeds a stripped SDK corpus (−25 % on the shrunk corpus), andbuild.sh/flash.sh/hil-run.shbuild their PAPKs with the same strip (scripts/build-apk.sh --strip-debug;picoenvmon.papk−18 %). The simulator is unchanged —sim.shstill printsat Foo.bar(:42)for app and SDK frames alike.testbench_rp2040flash 901,223 → 873,902 B,testbench_rp2350941,235 → 906,854 B (release,helloworld). Details in the shrinker page anddocs/designs/flash-string-budget-2026-08.md§4. SharedPreferences.getAll()returnsMap<String, ?>of every stored preference, matching Android. It replaces the picodroid-onlygetAllKeys(), which is gone (getAll().keySet()is the direct equivalent).String.classand other builtin class literals no longer throw.ldcon a class the JVM serves natively (String,Object,Runnable, …) required a loaded class file and failed with an uncatchable error; it now resolves, and"x".getClass() == String.classholds.
Smaller --shrink images: java/** names shrink too
- Shrink map v0.15.0 opens a second namespace,
b/, for thejava/**classes.java/lang/Objectalone appeared 109 times in the embedded framework corpus; with--shrink, it and the other 87 natively servedjava/**names (String,StringBuilder, the boxed types, the collections, every builtin exception) are now stored asb/…in both the framework corpus and app PAPKs, inside descriptors too. The JVM reverse-translates them at its class-file boundary, soClass.getName(), exception text,catch,instanceofand native dispatch are unchanged —"x".getClass().getName()is stilljava.lang.String— and a PAPK built with an older map keeps loading.a/allocation is untouched. See the shrinker reference. framework_class_excludesworks under--shrink. The board key compared original names against shrunk file paths, so a--shrinkbuild oftestbench_rp2040panicked at build time; it now un-shrinks the path first.ArrayStoreExceptionis catchable.System.arraycopythrew it, but it was registered nowhere, so nocatch— not evenThrowable— matched it.- Shrink map v0.16.0 renames method and field names too. The 868 method and field names the framework declares now shrink to one or two characters in the SDK corpus and in every app PAPK —
setText→uQ, and youronCreateoverride renames in lockstep with the framework’s, because the map is keyed by bare name. Names the JVM itself dispatches by literal (toString,equals,run,compare,hasNext,getMessage, … — everything insdk/api-contract.tsv),<init>, and names of two characters or fewer stay as they are. Native dispatch matches through build-generated constants rather than string literals, so it costs nothing at run time (the simbenchmarkis ~7 % faster under--shrink— shorter names compare faster).picoenvmononpico_enviro_mon(--release --shrink): 959,911 → 943,959 B (−15.9 KB);helloworldontestbench_rp2040872,853 → 859,307 B (−13.5 KB), ontestbench_rp2350900,149 → 886,015 B (−14.1 KB). Only--shrinkimages change; no-shrink builds are byte-identical. Compatibility: a PAPK shrunk with a map older than v0.16.0 is refused by firmware at v0.16.0 or later (pdb install: PAPK was shrunk before method/field names were) — rebuild it. Details on the shrinker page.
Contributor tooling
scripts/pre-commitbuilds nothing. The hook runs the source guards (shadow-twin, cfg hygiene,apply_jvm_env) and whichever ofcargo fmt, the Java and Kotlin formatters and markdown lint the change implicates — seconds. Ascripts/change adds thehil-tests.confdrift check and the device-lock test. Tests, per-board clippy, the example APKs and the sim smoke are CI’s job (which gains aguardsjob running the same text guards), and the binary-size ratchet moved to the 3 AMsim-run.shas asize-ratchetlane.--fullis the release-cut gate and keeps only the legs nothing else runs: thelegacy-handle-castclippy leg, themem-diag/sched-diagfirmware builds,pico_enviro_mon_wclippy, the shrunk-image check and the size ratchet on both boards.--listprints the stages and--serialstreams one lane at a time. See Contributing.- One board, several sessions: the device lock.
flash.sh,power-cycle.sh,pdb.sh,parity-bench.sh --hilandhil-run.shtake a lease on one board throughscripts/device-lock.shbefore touching its probe; a bench described by~/.config/picodroid/fleet.confholds several boards, one lease each, and--boardpicks one (without the file, a single board is assumed). A free board is acquired for your session automatically and released when that session ends; a busy one makes the script exit 75 with the holder’s name, anddevice-lock.sh acquire --waitqueues FIFO. The nightly HIL run waits up to an hour, then records a SKIP instead of colliding. See Troubleshooting. build-apk.sh --keep-lineskeepsLineNumberTableandSourceFilethrough--strip-debugfor aline-numbersfirmware (the device scripts pass it for debug-profile builds), andscripts/check-shrunk-image.sh <elf> [<app-package/>]proves a linked--shrink --shrink-appimage spells no original name;pre-commit --fulland CI run it. Until 2026-09-14 its scans could not fail locally (astrings | grep -qpipeline underpipefailread a match as a pass); they grep a dump now.- A HardFault’s stacked frame is logged on RP boards, and
rtt-lossymakes RTT non-blocking for timing work with a probe attached; both are described in the entries above.
Porting a new MCU family
picodroid_core::portingis the porting checklist. It re-exports every seam item a family implements — the 20 HAL, kernel and hook traits and the 21 registration and logging macros — and its module doc is the seven-part checklist; two tests keep it and the porting guide complete, with the counts pinned. The guide is rewritten around it: the real tree, one table for the HAL traits, sections for the kernel (scheduler_running, theFsWorkerpin), the hooks, the filesystem, the debug bridge and installer, the simulator, the supervisor loop, logging,build.rsand the guards a port inherits.- Less to port. The simulator now boots from one place in core (
register_sim_platform!takes the family’s GC roots, boot-budget model and app entry), and the boot-budget engine, the PAPK flash slot, the GPIO event ring, the scripted-touch override, the Java-array bus wrappers and the USB identity all moved out ofplatforms/rpintopicodroid-coreandpdb-protocol.platforms/rp/srcshrank 8,774 → 7,952 lines and the rp2040 release image by 1,316 B.ARCHITECTURE.mdgains the boundary rules a port must respect, each backed by a test. [background_pool] prioritymust be 15, the JVM’s own tier, and the build fails otherwise. The porting guide’s old row documented a default of 5 and a 1–10 range; neither was ever accepted.
v0.14.0 — 2026-08-31
The languages-and-threads release. Kotlin becomes a supported app language end to end — build tooling, a stdlib shim, a documented subset, and five example apps validated on hardware. Java threading grows the pieces that were missing underneath it: monitors that know their owner, synchronized methods that actually lock, Object.wait/notify, a java.lang.Thread-shaped API, and a pure-Java java.util.concurrent core set. Compile-time dependency injection arrives in the Dagger/Hilt shape. And a code-wide bug bash fixed roughly forty defects across the JVM, framework, SDK and Kotlin shim, most of them wrong answers rather than crashes.
Kotlin apps
- Kotlin is a first-class app language.
./gradlew newApp -Pname=myapp -Plang=kotlinscaffolds one; the newpicodroid-papk-kotlinplugin compiles it (kotlinc 2.1,jvmTarget1.8, warnings as errors) and packs it into an ordinary PAPK. There is no Kotlin runtime on the device:kotlin-shim/is a hand-written Java implementation of thekotlin/**classes the compiler actually emits references to (53 of them today), and every app’s PAPK carries only the members it reaches. The packer strips class metadata, applies the shim renames, prunes unreachable shim classes and shakes unreferenced statics out of the*Ktfacades to a fixpoint —helloktships 3 classes in 2,818 bytes with zero bytes of annotation, signature or debug attributes. - The shim is contract-checked, not hoped at.
:kotlin-shim:contractCheckruns in pre-commit and CI and fails three ways: akotlin/**reference an example emits that the shim does not serve (it prints the ready-to-paste Java signature), a shim member nothing references, and ajava/**reference not listed inkotlin-shim/jdk-allowlist.tsv(302 rows). A picodroid-core test then proves every allowlisted owner is really served by the JVM. - Measured cost.
picoenvmon_kt— the Kotlin twin of the flagship example, same screens and services — costs +5 % PAPK and +2.5 KB of parsed class metadata against its Java twin. That metadata is the binding constraint, not code size: one process parsing all 155 classes of the Kotlin language suite OOMs the 416 KB arena on metadata alone, which is why the suite ships as two apps (langsuite_kt, 15 language demos;langsuite_kt_stdlib, 8 stdlib demos — 738 checks between them). - New Kotlin guide: toolchain (
newApp -Plang=kotlin, ktfmt, kapt), the supported subset and the idioms that fail loudly, the JVM divergence table (unordered maps/sets, slot-index identityhashCode,@Synchronizedmethods, the 65,535-element array cap …), the class-metadata frugality rules, and what to do when:kotlin-shim:contractCheckreports a missing member. Runtime limits gains the measured Kotlin figures. examples/gcstress_kt: a GC-stress lane for the churn Kotlin adds (per-iteration lambda proxies,Refboxes, autoboxing through collections,Pair, string templates, map entry views) that also asserts identity-hashCode stability and lambda-capture rooting across collections. It runs in the sim smoke set and the HIL matrix.- Validated on hardware. Every Kotlin example —
hellokt,langsuite_kt,langsuite_kt_stdlib,injectdemo_kt,gcstress_kt— passes onpico_enviro_mon_win both shrink modes, andpicoenvmon_ktran a 7.5 h device soak (11,677 dashboard requests plus hourly navigation bursts) with a flat native footprint (~287 KB throughout), min-ever-free 155.7 → 124.3 KB against a 120 KB budget, and no JVM-heap growth. One unattributed device reboot fired at 9 h 29 m with no RTT attached and no backtrace; the measured window is unaffected and the event is tracked indocs/designs/kotlin-roadmap-2026-08.md.
JVM: the language coverage Kotlin needed (and Java gets too)
instanceofandcheckcastwork on the built-in types. NewBUILTIN_SUPER/BUILTIN_INTERFACEStables give the boxed types,ArrayList,HashMap,HashSet,Stringand the array classes a real supertype set, walked transitively through loaded interface class files.instanceofon aStringis no longer always false, and a failedcheckcastthrows a catchableClassCastExceptioninstead of being silently skipped.- Object identity semantics.
Object.equals/hashCode(the heap slot) /toString(pkg.Cls@hhhh), the boxedequals/hashCode/compareToplus thecompare/hashCode(x)/floatToIntBitsstatics that data classes need, andEnum.hashCode. - Interface default methods. Method resolution now continues into the superinterfaces of every class on the chain when the superclass walk misses, picking the maximally-specific non-abstract candidate — a sub-interface’s default beats the inherited one whatever the
implementsorder. Covers diamonds andsuper<I>.f(). "" + objfinally callstoString().StringBuilder.append(Object)andString.valueOf(Object)push the object’s JavatoString()frame and re-execute the invoke with the resulting String; before this, every"" + objand every Kotlin"$obj"template appended nothing.HashMap.entrySet()with a realMap$Entry(getKey/getValue),java/util/LinkedHashMapandLinkedHashSetas aliases ofHashMap/HashSet(no insertion order — documented),ArrayList.toArray()/toArray(T[]),java/lang/Appendable, andHashSet.iterator.- Iterators and map views pin their source collection.
for (w in text.split(" "))had its temporary list swept mid-loop. InvalidReferenceretreats further. User code doingnew IllegalArgumentException(...)on a class with no class file canonicalised to “unknown” and was uncatchable; every hierarchy-table name is now a real built-in name, enforced by a hygiene test.
Java concurrency parity
synchronizedmethods take their monitor. javac emitsmonitorenter/monitorexitonly for the block form; for a synchronized method it setsACC_SYNCHRONIZEDand leaves the locking to the JVM (JVMS §2.11.10). The interpreter never read the flag, sosynchronized void inc()— the commonest form in Android code — ran with no lock at all, on device and in the simulator. The monitor is now taken before the frame is pushed (the receiver, or theClassobject for a static method) and released by every frame pop, including every truncation during exception delivery.- Monitors track owner and depth, and are collected. The monitor store keyed kernel mutexes by heap slot and never removed an entry, so a new object landing in a recycled slot inherited the dead object’s monitor — false contention, or a permanent deadlock if the previous owner died holding it — and every distinct
synchronizedtarget ever seen leaked a FreeRTOS semaphore. Monitors now carry their owning task and recursion depth,monitorexitby a non-owner throwsIllegalMonitorStateException, and free monitors are pruned right after every collection. A task leaving the interpreter through a non-Java error no longer holds its monitors forever. synchronizeddid nothing in the simulator. The host monitor implementation was still compiled out from before sim threads became real, so for a monthsynchronizedacquired nothing on the host while the docs said otherwise. Sim threads now contend real kernel mutexes.picodroid.concurrent.Threadmirrorsjava.lang.Thread. ARunnabletarget or an overriddenrun(),Thread(String)/Thread(Runnable, String), a secondstart()→IllegalThreadStateException, interruptiblesleep,join/join(ms),interrupt/isInterrupted/interrupted,isAlive,currentThread()(the UI task and pool workers adopt aThreadobject lazily),get/setName,getId,yield,setDaemon,toString, and per-thread plus defaultUncaughtExceptionHandler. A started thread is a GC root from before its task exists, so neither it nor its target can be swept.Object.wait/wait(ms)/notify/notifyAll.waitreleases the monitor at its full recursion depth, parks, and reacquires at that depth;notifywakes the longest waiter first; all four throwIllegalMonitorStateExceptionfor a non-owner. Spurious wakeups are possible, as the JLS allows — always wait in a loop.- New: a
java.util.concurrentcore set, inpicodroid.concurrent, written entirely in Java over those primitives — zero natives, zero bytes of firmware.Callable<V>,Future<V>(get, timedget,cancel,isDone,isCancelled),FutureTask<V>,TimeUnit,ExecutorService(submit×2,shutdown,shutdownNow,isShutdown,isTerminated,awaitTermination), a fixedThreadPoolExecutorreached throughExecutors.newFixedThreadPool(n)/newSingleThreadExecutor(),AtomicInteger/AtomicLong/AtomicBoolean/AtomicReference, andCountDownLatch. The exceptions keep their JDK names sothrowsandcatchclauses compile:ExecutionException,CancellationException,TimeoutException,RejectedExecutionException— andThrowable’s cause-taking constructors now record the cause, sogetCause()answers.Executors.mainExecutor()/backgroundExecutor()are unchanged.testbench_rp2040excludes these classes to stay inside its flash budget; apdb installof an app that uses them fails to resolve there. - Every Java task now runs at one priority. The shared heap is lock-free because a running JVM task keeps its core until it blocks — which holds only with time slicing off and equal priorities.
Thread.startchildren ran at Android priority + 10 and the background pool at 5, so a Java thread one notch above the UI task could preempt it at any instruction, mid-heap-mutation or mid-LVGL call.Thread.setPriorityis now advisory — stored and reported, never applied. The corollary is that equal-priority threads do not round-robin: a compute-bound thread starves its siblings until it blocks. See Known issues. - Touching a
Viewoff the UI thread now warns, namingExecutors.mainExecutor(). Android throwsCalledFromWrongThreadException; here the same mistake is memory corruption inside C, so the diagnosis has to arrive before the fault. - New test-harness examples
threadparity(22 checks) andjucdemo(23 checks), both in the simulator run and the HIL matrix.
Compile-time dependency injection: @Inject / @Singleton
- Apps can now use JSR-330’s
javax.inject.Injecton constructors, fields and methods andjavax.inject.Singletonon classes, in the Dagger/Hilt shape. An annotation processor wired into every Java app build generatesFoo_Factory/Foo_MembersInjectorclasses; nothing is resolved at runtime and the annotations never reach the device (SOURCEretention).Application,ActivityandServiceinstances have their@Injectmembers populated automatically beforeonCreate(), like Hilt’s@AndroidEntryPoint; everything else is built throughFoo_Factory.get(). The compiler rejects the shapes pico-jvm cannot honour (an@Injectconstructor on a framework component, private fields, shadowed field names, cycles, interfaces without a provider) with a message that says why.javax.inject.Provider<T>(fresh perget(), constructs nothing until called) andpicodroid.di.Lazy<T>(memoized, thedagger.Lazycounterpart) inject anywhere aTcan and break dependency cycles; both are two-method SDK interfaces backed by generatedT_Provider/T_Lazyclasses.picodroid.di.Module/picodroid.di.Provides(thedagger.Module/dagger.Providescounterparts) bind SDK types, interfaces and abstract types — static or instance methods,@Singleton-scopable, every module installed automatically into the implicit component; a type bound twice is a compile error. Not yet:@Binds, qualifiers, other scopes. See Services & DI and the newinjectdemoexample. - Kotlin apps get the same
@Inject/@Modulesupport:picodroid-papk-kotlinruns the processor through kapt over kotlinc’s stubs (@Inject lateinit var,@Inject constructor,@Module object+@JvmStatic @Provides,@Module classfor instance@Provides).examples/injectdemo_ktis the twin ofinjectdemo. - The manual
ApplicationComponent/ActivitySingletonComponentshape is unchanged and coexists with the new one.picoenvmonmigrated to@Inject/@Singletonand dropped its hand-writtendi/package; itsSharedPreferencescomes from an@Providesmethod inEnvModule. - Behaviour change: the
Applicationsubclass (and a manifestactivity=boot Activity) is now constructed like every other component — field defaults, then<init>, then injection — so instance-field initializers and constructors on anApplicationfinally run, as on Android. PreviouslyApplicationwas allocated without running its constructor.
Bug bash — about forty fixes
A deliberate code-wide hunt across the JVM, framework, SDK and Kotlin shim, one commit per defect, closed out in docs/bugbash-2026-08-30.md. Most were wrong answers rather than crashes, which is why they had survived.
- Runtime faults are catchable Java exceptions. Division by zero, an out-of-range array index and a null receiver aborted the app with a JVM-internal error no
catchcould reach; they now throwArithmeticException,ArrayIndexOutOfBoundsExceptionandNullPointerException. An unsatisfiable array allocation throwsOutOfMemoryError. - Numbers.
Integer.toString(MIN_VALUE)/Long.toString(MIN_VALUE)returned"-".Math.abs(MIN_VALUE)panicked, andround/min/maxdiverged from Java.Double.toStringwas missing entirely (NoSuchMethod, orjava.lang.Double@NNNNthroughString.valueOf).String.formatprinted"inf", and every double stringification narrowed throughf32first. The float formatter lost its rounding carry and broke above 2³¹. - Strings.
String.valueOf('\n')returned a space.splitkept trailing empty strings.indexOf/lastIndexOfignoredfromIndex;charAtout of bounds returned 0.equals(non-String)was an uncatchable error rather thanfalse.String.format("%s", obj)printed the identity string instead of callingtoString(). - Collections.
ArrayList.remove(Object)was an uncatchable error;contains/removemissed strings built at runtime;add(i, …)clamped its index,set(i, …)silently no-opped, andget/removeout of range were uncatchable.Iterator.removenow works and iteration is fail-fast. All theArrays.fill/sortrange overloads and theboolean[]/char[]handling were wrong. ObjectRef(0)stored in anObject[]read back asnull— a valid reference to heap slot 0 was indistinguishable from the null encoding.- Shadowed fields aliased their superclass’s slot, so a subclass field with the same name as its parent’s read and wrote the wrong storage.
- GC roots. Runnables sitting in the executor queues and a
Thread.starttarget that had not yet run were both invisible to the collector, so a lambda whose only reference was the queued word could be swept before it ran. Two new stress apps (executorstress,threadstress) pin the behaviour. - Lifecycle and services.
finish()was not idempotent — a second call popped the parent Activity too. Binding past the connection table leaked the bind forever, and app exit left foreground banners behind. A dropped pending-op now warns instead of vanishing. The fifth concurrentAlertDialogwas shown but never tracked; toast and snackbar timers bypassed pre-delete cleanup; app teardown never popped the keypad focus groups; the background pool kept the previous app’s Runnables across a reload. SharedPreferences.Editoraliased the live preferences and lost puts issued beforeclear().- Networking:
Transfer-Encoding: chunkedresponse bodies reached the app with the chunk framing still in them. - I/O and sensors:
FileInputStream.read/FileOutputStream.writewith a negative length panicked; microsecond sensor sampling periods silently becameSENSOR_DELAY_NORMAL;SystemClock.sleep(negative)slept for 49.7 days; a failedpdb installstream or CRC left the flash-park request latched. - Kotlin shim:
emptyList/emptyMap/emptySetreturned shared mutable singletons, so mutating one leaked into every other caller.maxOf(Comparable)and the long/float/doublecoerceAtLeast/coerceAtMostforms were missing.
Native → Java upcalls
- Native code can now call a Java method synchronously and use its return value (
Executor::invoke_java). PreviouslyNativeContextheld no interpreter handle, so every native→Java path had to be deferred through a queue — which only works when the native side can return before the Java runs. The nested call runs over the same frame vector, so the frames are GC-rooted for free; lambda proxies resolve first; and exception handling gained a floor so an exception thrown inside an upcall cannot find a handler below the native arm and truncate frames the outer invoke still owns. The operand-stack window where a call’s popped arguments live in no frame, field or static is now covered by shadow roots. Frame and upcall depth are bounded, so runaway recursion raises a catchableStackOverflowErrorinstead of exhausting the heap.
Flash and performance
- The premultiplied-ARGB8888 draw path is gone — 22,892 bytes of RP2040 flash. LVGL defaults the option on when Kconfig is absent, so it had been linked since the port began without anyone asking for it, and it is unreachable both ways: the packer bakes RGB565 into every image, and no
lv_draw_layer_createcall site requests the format. Rendering is pixel-exact, not merely working — 1000 band CRC32s byte-identical before and after. - A hand-written introsort replaces the standard library’s — 9,888 bytes. Insertion sort under 16, median-of-three Hoare quicksort, heapsort fallback at 2·log₂(n) depth. Hoare rather than Lomuto specifically so
Arrays.sorton a constant array splits down the middle instead of going quadratic. The JVM’s two sort callers (app-sizedArrays.sort, GC arena compaction) are not bound by sort throughput. Sort machinery: 10,074 → 1,774 bytes. - Interpreter overhead removed on four hot paths — a code-slice cache in the dispatch loop, a
has_lambdas()gate so a virtual invoke skips the lambda probe, one atomic section per object allocation instead of one per field written, and a binary-searchlookupswitch. Every deterministic counter is identical before and after, which is the signature of pure overhead removal — and also why the deterministic proxy cannot score it. The simulator says −7.1 %; the device says +0.4 %; both batches are internally clean, and the honest conclusion is recorded indocs/perf-campaign-2026-08.mdrather than claimed as a win. - Net effect on the RP2040 image: 881,875 → 849,047 bytes over the campaign, then back to 897,287 as Kotlin, the interpreter work, the bug bash and the concurrency surface spent it — 19,961 bytes below the 917,248 ceiling. Every one of those spends is a recorded, ratcheted decision (below).
Benchmark harness, gates and CI
- Four months of nightly benchmark history recovered. The crons had written device timings into per-run log directories since April, where nothing read them;
bench-backfill.pyparses them intobench/parity/history.csv— 6 rows → 59,142, back to 2026-04-15, with ~38 metrics recovered per (app, mode) where the old parser recovered one. Two documented beliefs die on the evidence: thea828229“+4 % regression” is not one (every deterministic counter is byte-identical across the range), and the “6.8 % shrink tax” does not exist (across 29 paired runs it flips sign: mean −1.7 %, stdev 5.6 %). Both were the same artifact — device wall-clock has σ ≈ 4 % per binary across rebuilds, up to ±40 % per microbenchmark, while runs of the same image are reproducible to 32 ppm. Treat any single-digit wall-clock delta between two builds as noise. - The binary size is ratcheted.
bench-report.py --ratchetgates flash and RAM againstbench/parity/ratchet.tomlat 0 % — the only metrics with a literally zero noise floor — with a hard RP2040 ceiling of 908,000 bytes set below the linker’s so the gate trips in review rather than at link time.--acceptrecords new sizes, which is the explicit act of consenting to spend budget. Wired into pre-commit, and backstopped by a CIsize-ratchetjob that a--no-verifycannot skip. parity-bench.sh --checknow gates on the two things that are actually decidable — the size ratchet and sim↔device counter parity (insns/allocs/gcsmust be equal for the same commit and app; inequality is a runtime divergence, not a performance signal). The old 30 % wall-clock ratio alarm is retired: it was set roughly ten thousand times above the device floor, never fired once in four months, and was measuring a biased predictor rather than a noisy one.
Documentation
- The docs site is actually published. GitHub Pages was set to the legacy branch builder, so Jekyll rendered
README.mdon every push and clobbered whatever the docs workflow had deployed — every Starlight route 404’d. Alongside the settings fix: mermaid diagrams render (the architecture diagram now appears on the Architecture page), the header shows the site name again, and both light and dark themes use the logo accent at checked contrast. - The example catalogue is complete and correct at 72 apps,
storage.md’sSharedPreferencessample compiles, and the compatibility matrix records every divergence the Kotlin and interpreter work turned up.
Dependencies
- FreeRTOS-Kernel bumped to V11.3.1 — a clean tag checkout with no local patches. Nothing we compile changes behaviour; the only real deltas are assert and guard additions, none of which fired in any suite. Verified with the full HIL matrix producing a result file byte-identical to the V11.3.0 nightly, and the RP2040 image 48 bytes smaller.
- New build dependencies for the Kotlin lane:
kotlin-gradle-plugin2.1.21 (the Kotlin pin for every Kotlin app) and ASM 9.7 inbuildSrc, plus ktfmt 0.64 for formatting — SHA-256 pinned invendor/like google-java-format.
Shrink map: +14 classes (135 → 149) — the java.util.concurrent core set, Thread.UncaughtExceptionHandler, and the two injection points javax.inject.Provider / picodroid.di.Lazy. Every v0.13.0 mapping is copied verbatim.
v0.13.0 — 2026-08-19
The networking-maturity release. Sockets stop failing with an uncatchable JVM-internal error and start throwing the java.net exceptions Android apps already catch; HttpURLConnection grows the header and timeout surface real REST work needs; and picoenvmon becomes a WiFi showcase on a new Enviro+/Pico 2 W board — live web dashboard, NTP-anchored clock, internet weather. Underneath, a long device soak turned up an SMP heap-corruption family in the JVM (now fixed), and the memory work that followed cut picoenvmon’s live heap by a third.
Source-incompatible: net methods declare their checked exceptions
Socket.send/recv,ServerSocket(int)/accept,DatagramSocket(int)/send/receive, and theHttpURLConnectionsurface now declarethrows IOExceptionand friends, matchingjava.net’s contracts. Code that called these without handlingIOExceptionstops compiling — catch it or declare it.Socket()stays undeclared, likejava.net.Socket(). The in-repo examples were updated in the same change (http_getpreviously had no catch at all: a DNS failure was an uncatchable app-kill).
Networking — typed errors, headers, timeouts
- Every net native now surfaces failure as the typed exception Android expects, with Android’s wording:
ConnectException,SocketTimeoutException,NoRouteToHostException,BindException,UnknownHostException,SocketException,ProtocolException. Previously a failed connect aborted the app withInvalidReference, a JVM-internal error nocatchcan reach; that error is now reserved for malformed native arguments.Socket.recvreturns-1only at orderly EOF. The full mapping is the error-handling table in the networking API reference. - The JVM knows the
java.nethierarchy natively, so a native-thrown exception with no classfile behind it still matchescatch (IOException)/catch (Exception)exactly as on Android — including real Java’s quirk thatSocketTimeoutExceptionextendsInterruptedIOException, notSocketException. HttpURLConnectiongains request and response headers —setRequestProperty/addRequestProperty/getRequestProperty,getHeaderFieldby name and by index,getHeaderFieldKey,getResponseMessage,getErrorStream, and theHTTP_*status constants — which is what an auth token, a content type, or readingLocationback needs. Response headers land in a bounded table; header count and sizes are capped like theSharedPreferenceslimits, since the memory comes out of the same shared heap as everything else.HttpURLConnection.setConnectTimeout/setReadTimeout, with Android’s semantics (0= infinite, negative throwsIllegalArgumentException, expiry throwsSocketTimeoutException). Before this there was no timeout anywhere on the HTTP path, so a server that accepted the connection and then stalled hung the calling thread for good.- New:
InetAddress.getByName(String)— the first DNS entry point, throwingUnknownHostException; dotted-quad literals resolve without touching the network. AndServerSocket.setSoTimeout(int). vendor/freertos-plus-tcpnow points at the picodroid fork. Connecting to a reachable host with a closed port froze the app forever: upstream v4.4.1’s RST handler moves aSYN-SENTsocket toeCLOSED, butvTCPStateChange()sets no event bit for that transition, andFreeRTOS_connectsleeps with an infinite block time. Existing checkouts must rungit submodule sync && git submodule update --init vendor/freertos-plus-tcp; the device build fails early with instructions if the unpatched upstream is detected. (Upstream PR #1355 fixes the same defect from the RFC-793 direction — we drop the patch and take theirs once it reaches a release.)- Hardware WiFi: link-up is gated on full association instead of a join-in-progress state, so DHCP no longer starts on a link that cannot carry frames and
net: downstops flapping through every retry. The cyw43 host-wake IRQ on GP24 replaces the 100 ms poll as the RX path (26 IRQ wakes per boot, DHCP 4 s faster); the poll is now a 1 s safety net. The RP2350 TRNG feedsxApplicationGetRandomNumber.PICODROID_WIFI_AUTHpicksopen/wpa2/wpa3/wpa2wpa3at build time — WPA3 is plumbed through to the driver’s SAE path but untested against a real AP. - Socket and HTTP handle tables unified on one slot-reusing implementation, fixing two defects: the 64-bit tables never reused slots (a create/close loop exhausted them after ~31 sockets), and the 32-bit arms handed Java the raw pointer with a no-op remove, making close-then-use a dangling dereference into the network stack. A stale handle now throws
SocketException("Socket is closed").
New board: pico_enviro_mon_w — Enviro+ Pack on Pico 2 W
- The first board combining sensors and networking (same wiring as
pico_enviro_mon; the CYW43 pins are on-module). FreeRTOS+TCP’s buffer tunables became#ifndef-wrapped defaults that optionalnet_*keys inboard.tomloverride per board — this board halves descriptors, TCP buffers, and window segments, because the stack shares the 416 KB heap with the JVM and serves one connection at a time. Measured: +5.1 KB static RAM for the network stack, ~20 KB static headroom on a release build. picoenvmonon it is the showcase: a live web dashboard on port 8080 (five smoothed readings, IP, uptime, 2 s meta-refresh), an NTP-anchored wall clock (a single RFC 4330 exchange against pool.ntp.org, re-syncing every 6 h) that puts realHH:MMstamps on history rows, sample dialogs, and alert lines, and an internet weather row from wttr.in refreshed every 15 min. All three are strictly fail-soft, and CI never asserts on internet-dependent content. The Network screen gained a Refresh action; on WiFi-less boards the screen stays reachable and explains itself.- New
SystemClock.setCurrentTimeMillis(Android’s real API) anchorsSystem.currentTimeMillisto epoch time — reads stay monotonic-driven, and leaving it unset preserves the historical count-from-boot behaviour. - Behaviour change on both Enviro+ boards:
SensorLoggerServicenow starts at boot. The device is an environmental monitor, so logging (and the IAQ LED) default on — a freshly flashed board serves live data unattended instead of reading--until someone opens Live and flips the Logger switch. The switch remains the off-toggle.
JVM correctness
- Category-2 arguments occupy two local slots, per JVMS. Classfile local indices count a
long/doubleas two slots, but frames packed arguments one per slot — so any Java method with a parameter after alongordoubleread garbage. Never hit before because the tree had no such method untilTimeFormat.floorDiv(long, long), which killed the network thread withInvalidBytecode. - Every
StringBuildergets its own buffer. All instances shared one global buffer stack, so two builders alive at once interleaved their bytes — and across threads it was silent corruption rather than a visible error. String.getBytes()andnew String(byte[])— thebyte[]↔Stringbridge that network code was hand-rolling in both directions.- SMP heap corruption fixed. FreeRTOS SMP yields whenever an unblocked task’s priority is ≥ the running task’s, and
configUSE_TIME_SLICING = 0only disables tick round-robin, not that. Two equal-priority JVM tasks could therefore interleave inside an arena resize or inside GC scratch growth — the single root cause behind a whole family of soak failures (slice-OOB compaction panics, child-threadInvalidReferencedeath, rooted objects swept, permanent GC thrash).gc::collectand every compound heap mutation now run inside a scheduler-atomic section; measured cost on the device benchmark is 0.81%, and it is a no-op on the host. - GC could not see a parked task’s frames, so a thread blocked in
sleeporaccepthad its frame locals swept by any collection another task triggered. Collection now walks a registry of every executing stack. - Native allocations count toward GC pacing. The threshold counted only bytecode allocation opcodes, so a workload allocating mainly through natives and builtins (
getBytes,toString/format interning, sensor events) accumulated KB of garbage with the counter near zero — then OOM’d when a table-growth step needed tens of contiguous KB. Each heap now counts its own allocation events at the source. Thread.startchildren and background-pool workers share one loaded class set instead of each building a private JVM and re-parsing every class. picoenvmon’s single network thread was priced at ~13.9 KB of duplicate metadata; child spawn latency drops too.- Offensive memory-diagnostics mode now actually arms on device — it was silently sim-only.
Memory
- Packed
byte[]/boolean[]storage — 1 byte per element instead of a full 4-byte arena slot, with 32 bytes of inline reach instead of 8. Semantics are unchanged (bastorealready truncated, loads sign-extend). Measured on picoenvmon: byte payloads 12,428 → 3,921 B (−68%), 27 of 33 byte arrays fully inline, total live heap 21.4 → 13.8 KB.char[]/short[]are a noted follow-up. - New
heapcensusdiagnostic answers who holds the bytes right now, where the existing histogram only counted churn: live bytes and counts per class, arrays by element type (inline vs arena, dead/slack), dyn-string length vs capacity, theArrayList/HashMap/StringBuilder/exception side tables thatlive=never counted, and per-executor class-metadata cost. Background-thread GCs are finally visible ingc=, and a newgcb=column reports bytes reclaimed per window. Seedocs/memory-diagnostics.md. - picoenvmon’s dashboard serve path is allocation-free (constant response heads and page framing cached as
byte[], byte-level assembly into a persistent buffer), which letgc_alloc_thresholdgo back up to 128 on the W board: perfbench composite 1018 → 792 (lower is better), GCs 240 → 119, GC time 1424 → 768 µs. Newprereserve_arena8_bytestunable, and a prereserve retune for the packed-arena era.
Flash
framework_class_excludes— every compiled SDK class ships on every board and is loaded at boot, so a new class costs its full size in flash whether an app uses it or not, and the RP2040 program region had 1,585 bytes left. A board can now drop classes it can never use via an optional top-level key inboard.toml. An exclude matching no compiled class fails the build, so a typo cannot silently keep shipping it.testbench_rp2040uses it to leave outpicodroid.net.*exceptNetworkInfo(~9 KB). Note the split this introduces: on a board that merely lacks networking a socket call throwsUnsupportedOperationException; on one that also excludes the classes it fails to resolve. Probing withNetworkInfo.isConnected()and degrading is the portable pattern.- All seven
Arrays.sortprimitive overloads, the GC’s arena compaction, and the touchscreen median filter now share a singleu64sort instead of monomorphising Rust’s generic sort per element type — each element type maps onto an order-preserving key (sign-bit flip for integers, the IEEE-754 total-order transform for floats). Sort machinery: 35,092 → 7,386 bytes across 34 → 5 instantiations. - Together those take the RP2040 image from 915,663 to 881,875 bytes — from 1,585 bytes of headroom to ~35 K.
Simulator & tooling
SystemClock.sleepblocks through the kernel. As a barestd::thread::sleepit was invisible to the FreeRTOS POSIX port, so with time slicing off the sleeping task stayed Running and an equal-priority sibling never got a yield point —threaddemo’s second thread starved without executing a single instruction.Thread.startalso no longer swallows a declined spawn.- Sim socket read timeouts actually expire now. The scheduler’s 1 ms
SIGALRMEINTRs every blocking read and the naive retry restartedSO_RCVTIMEOfrom scratch, so a timeout could never elapse;tcp_recv/udp_recvfromtrack an explicit deadline andtcp_acceptemulates one with a nonblocking poll. The device side normalises FreeRTOS+TCP’s inverted encoding to the same contract (Ok(0)= orderly EOF, timeout is an error). build-apk.shpasses the shrink flag as a per-invocation Gradle property. It relied on an env fallback that a long-lived Gradle daemon freezes at start, so a daemon left behind by a--shrinkrun silently shrink-stamped every later PAPK, which then failedpdb installagainst no-shrink firmware. Latent until v0.12.0 shipped the first real shrink map.- Permanent info-level logging for key dispatch and activity transitions — pdb injection receipt (keycode → pin), per-edge dispatch outcome, BACK dismissal, activity push/pop by class — plus a warning on the previously silent 64-slot key-queue overflow.
- Nightly coverage: a
sim-only test category for rows the HIL board cannot run, with a per-row board override; anetexceptionrow asserting the typed-exception taxonomy in both shrink modes; and apico_enviro_mon_wdashboard smoke lane. Network test targets are baked in at build time (-PpicodroidNetTestHost), so pointingnetdemo/http_getat a real machine is an env var rather than a source edit. - google-java-format 1.35.0 → 1.36.1.
Shrink map: stable — byte-identical to v0.12.0 (135 classes). Everything new this release landed as methods on classes the v0.11.0 cut already named.
v0.12.0 — 2026-08-14
The networking release. WiFi on the Pico 2 W goes from “compiles” to validated end-to-end on hardware, the host simulator starts running the real FreeRTOS kernel, and runtime flash writes (the pdb install path) are fixed on both chips. The Java SDK surface is unchanged — everything here is framework, platform, and tooling work behind the existing API.
Networking — WiFi works on hardware (Pico 2 W)
- The full
picodroid.netstack (TCP/UDP sockets,HttpURLConnection) now runs end-to-end ontestbench_rp2350w: WPA2 join in ~6 s, DHCP lease shortly after, TCP echo and HTTP GET/POST validated against LAN hosts. The API itself is unchanged — it now works on the device instead of only under the simulator’s host stack. - The cyw43 gSPI transport was rewritten in Rust on PIO + DMA (PIO0, 37.5 MHz), replacing the vendored bit-bang C transport, and the WiFi task runs on core 1 — leaving core 0 to the JVM.
vendor/cyw43-drivernow points at the picodroid fork. Existing checkouts must rungit submodule sync && git submodule update --init vendor/cyw43-driver; the device build fails early with instructions if the unpatched upstream is detected.- WiFi credentials are baked in at build time via
PICODROID_WIFI_SSID/PICODROID_WIFI_PASS— see the new WiFi & networking setup guide. On hardware, pollNetworkInfo.isConnected()before the first socket call;netdemoandhttp_getshow the pattern (an app’sonCreateraces the WiFi join + DHCP window). - Current limits are collected on the new known issues page: open/WPA2-AES networks only, no TLS, 256-byte socket I/O chunking, coarse HTTP error reporting.
ESP32-S3 support removed
- The compile-only ESP32-S3 / Lilygo T-Deck Plus target (Milestone-1 scaffolding from v0.9.0) is gone:
platforms/esp/, thetdeck_plusboard, its cargo aliases, and its docs pages. Retrieveplatforms/esp/from git history if it returns. Supported boards are now the four RP-family ones (Pico, Pico 2, Pico 2 W, Pico + Enviro+ pack).
Simulator — the real FreeRTOS kernel
- The host simulator now compiles and runs the actual FreeRTOS kernel (POSIX port) in-process instead of approximating it with host threads.
Thread.start()spawns a real task with the device’s 16 KiB stack charged against the simulated heap,Executors.backgroundExecutor()runs on the device’s fourjvm-bgworker tasks,synchronizeduses kernel recursive mutexes, andthreaddemonow runs — and is asserted — under the sim. - Remaining gap, documented in the simulator guide: the POSIX port is single-core, so cross-core races remain hardware-only.
Runtime flash writes fixed on both chips
- RP2040: install-time flash writes no longer hang. Three stacked causes fixed — core-1 execution in the XIP-off window (a core-1 parker task now covers it), a FreeRTOS scheduler-configuration deadlock, and a per-core VTOR defect.
pdb installnow works on the original Pico. - RP2350: a recurrence (core 1 taking an interrupt inside the XIP-off window) fixed by extending the core-1 parker; regression-verified across the full HIL suite.
Tooling
- New
pdb input— Android-faithful synthetic input over USB:keyevent,dpad,back,tap,swipe, resolved against the board’s button table on-device. The sim control channel accepts the sameinput …verbs, so an input sequence rehearsed headlessly replays verbatim on hardware. See the new pdb command reference. pdb --helpnow prints the real command set (includinginputandlogcat);pdb sysmonprints the JVM block after the task table;papk-infolabels ≥ 1 MiB sizes correctly.- Flash-size reporting is honest: builds report usage against the linker’s program region (an RP2040
--releaseimage is 99% full, not the previously reported 43% of chip total), and RP2040 release builds automatically drop LTO to fit the 896 K ceiling — build viascripts/build.sh, not rawcargo build --release, on RP2040. hil-run.shderives the probe chip from the board and can now drive the RP2040 testbench.- PAPKs are structurally validated at embed time — a corrupt
PICODROID_APK_PATHfails the build with a clear message instead of failing mysteriously at install.
Framework & robustness
NotificationManager.notify/cancelare implemented (previously stubs).- Button GPIO edges get a 5 ms per-pin dead-time debounce; all sensor I2C moved to a dedicated FreeRTOS sampler task shared by sim and device.
- Keypad bursts are no longer misdelivered across Activity transitions; widget listener maps unregister on
LV_EVENT_DELETE; picoenvmon threshold alerts are edge-detected (one log line per alert edge instead of ~13/s at idle). - New
[jvm] prereserve_*board tunables pre-reserve steady-state heap storage at app start to curb navigation-churn fragmentation — see JVM tunables.
Internal architecture
- The platform-agnostic framework moved into the
picodroid-corecrate (JVM natives, lifecycle, graphics, networking, sim HAL); the PAPK container format and the PDB wire protocol became thepapk-formatandpdb-protocolcrates — each a single source of truth shared by firmware, simulator, and host tools. A pre-commit shadow-twin guard keeps the trees disjoint. - The 2026-07 code-health audit closed its P0/P1 backlog: clippy now gates all host tools and every board (ARM targets included), CI enforces the shrink-map append-only invariant and the widened LVGL constant drift guard, and a generation-tagged widget handle table is staged behind the default-off
handle-table-32feature for 32-bit targets.
Shrink map: stable — byte-identical to v0.11.0 (135 classes). The networking, simulator, and extraction work all landed outside the sdk/java framework surface.
v0.11.0 — 2026-07-20
The memory-diagnostics and Android-parity-completion release. Folds in a large SDK surface expansion (widget completion, Android-cased renames, package moves), a JVM correctness pass, and a full opt-in memory diagnostics suite built to make heap growth and steady-state churn visible in both the simulator and on real hardware.
Android parity — widget completion & renames
AlertDialogmoved topicodroid.app(matchingandroid.app.AlertDialog);IBindermoved topicodroid.os;Url/HttpUrlConnectionrenamed to Java’sURL/HttpURLConnectioncasing;PreferencesbecameSharedPreferenceswith Android’s full get/edit/commit idiom.- New widgets:
RadioButton+RadioGroupwith mutual exclusion,NumberPickerwith keypad edit mode (replacing the picoenvmon Settings keyboard entry),TextWatcherwithafterTextChangedonEditText,GestureDetector.SimpleOnGestureListener, the standard interpolator family (Linear/Accelerate/Decelerate/AccelerateDecelerate) plus animation end actions,View.OnLongClickListener+performLongClick,AdapterView.OnItemSelectedListener, view-relativeMotionEvent.getX/getYand screen-absolutegetRawX/getRawY. - Rounded out:
AlertDialogneutral button (Android’s 3-slot layout) and single-/multi-choice list variants,SeekBarpress-edge tracking callbacks,Service.onRebind/stopSelfResult,startActivityForResultwith Android’s result-delivery order,Activity.getIntent(), theonRestartlifecycle callback,View.setId/getId/setTag/getTag, fullViewproperty getters,picodroid.view.Gravity, fullIME_ACTION_*/InputTypeconstant sets, Android sensorTYPE_*/SENSOR_STATUS_*constants,DialogInterface.BUTTON_NEUTRAL,Logseverity ladder +Throwableoverloads. - An
android.*import-compatibility layer (stub jar + class-shrink alias rewriting) was landed, then reverted a few commits later — apps still importpicodroid.*only; see the compat matrix notes indocs/.
JVM correctness
getClass()no longer mints a freshClassobject per call after the first string concat (was breaking identity comparisons);Class.getName()returns Java’s dot-form; the builtinThrowablehierarchy now matches forcatch/instanceof; clinit throws are wrapped inExceptionInInitializerError;Throwable.addSuppressed/getSuppressednow store/return.Object.clone()shallow copy +Cloneablemarker,Object.getClass()withldc-literal identity,java.util.Comparator+Collections.sort(List, Comparator),Integer.parseIntfamily, boxedByte/Short, full-contractSystem.arraycopy.StringBuilder.append(char)no longer scrubs\nto a space (was breaking\n-joined strings passed to native code).- 32-bit-clean object layout: fields arena + 12-byte slots everywhere, closing the last 64-bit assumption in object layout.
- Fixed
MethodNotFound/sensor-dispatch spam caused byclass_tableandIntenttarget-class names aliasing a GC-freeddyn String; both now canonicalize at the native boundary.
Robustness
- The
Displaysingleton is now a GC root (was being swept and slot-reused, breaking all navigation with a post-first-GCNoSuchMethod). - A view’s animations are canceled when the view is deleted; a soft keyboard unbinds from its textarea on delete; consumed
onTouch/long-press now correctly suppress the synthetic click. - New handle use-after-delete sanitizer for the simulator (
--sanitize-handles) and a method-class cross-check test against the native dispatch registry.
Memory diagnostics (new)
- Opt-in
--mem-diagmonitor:[memmon]heap-growth sentinel, per-class allocation histogram (PICODROID_MEMDIAG_HISTO), offensive heap checks (PICODROID_MEMDIAG_OFFENSIVE), and apdbCMD_SYSMONextension that pulls the live JVM heap block over USB. - Plugged the input-driven heap leaks the new diagnostics surfaced: recycled
KeyEvent/MotionEventgive zero-alloc steady-state key and touch dispatch; sensor delivery is now allocation-free with an emergency GC at the native boundary; runtime flash writes now restore fast XIP mode afterward. - Killed JVM string-churn copies via an
intern_dyn_ownedhandoff and format-scratch reuse. - New steady-state flatness test, a soak-test harness (
scripts/test-memdiag.sh), dedicated CI lanes, and a full guide atdocs/memory-diagnostics.md.
Simulator ↔ MCU parity
- The simulator now models the device heap for real: a
heap_4arena, a default heap cap, a flash-modeled APK, and boot pre-charge — closing most of the sim/hardware memory-behavior gap. Parity-strictThread.start, parity-metrics execution counters, and a parity-bench ratio tracker round out the harness; seedocs/parity-audit.md. - Fixed the host-only minifb window buffer being wrongly charged against the simulated heap cap (was causing spurious OOM at low
-llimits).
picoenvmon polish
- History now shows recorded data with a clearer empty state; Settings moved from soft-keyboard entry to
NumberPickersteppers; several layout/clipping fixes (Live/Settings tile spacing, Logger/Units switch knob, ListView focus highlight, Settings hint truncation); Back is disabled on the home hub so Y is the only exit.
Tooling
- Fixed a
class-shrinkshort-name allocator bug found while cutting this release’s map: two unrelated classes (picodroid.os.IBinder,picodroid.text.InputType) could be assigned the identical shrunk name when the raw-index allocator crossed a skipped Java-reserved-keyword boundary ("do"/"DO") — the per-call skip-ahead wasn’t reflected in the caller’s counter. Fixed by threading a single shared raw-index counter through the allocator and deriving each release’s starting index by inverting existing entries’ shrunk names rather than trusting the map’s entry count. - Error Prone enabled as a default bug net (plus
@Overrideenforcement); CI now caches Rust/Gradle builds, compiles all example apps, and runs sim smoke on every push; nightly failure emails now diff against the previous run.
Shrink map: +25 classes (110 → 135) — see the shrinker reference for the full per-class breakdown; v0.10.0 entries copied verbatim.
v0.10.0 — 2026-06-02
The Android-parity release. Folds in the typed-listener, adapter, and focus-navigation surface that had been accumulating on main since v0.9.0, plus a wave of JVM heap and garbage-collector fixes that keep long-running, callback-driven apps alive.
Android parity
- Typed listener interfaces (Tier 1) and the
Adapterpattern (Tier 2) land as first-class developer surface:ViewGroup+ViewGroup.LayoutParams,Adapter/AdapterView/ArrayAdapter/BaseAdapter,CompoundButton, andDialogInterface. Listener interfaces now matchandroid.*shapes —View.OnClickListener/OnFocusChangeListener,AdapterView.OnItemClickListener,CompoundButton.OnCheckedChangeListener,Spinner.OnItemSelectedListener,SeekBar.OnSeekBarChangeListener,DatePicker.OnDateChangedListener,TimePicker.OnTimeChangedListener,SwipeRefreshLayout.OnRefreshListener,Keyboard.OnReadyListener. ArrayAdapternow renders correctly —Object.toString()resolves through the JVM, so adapter-backedListViews show real item text.- Context constructors +
Displaycleanup (Tier 4) round out the parity work.
Keypad & focus navigation
- New View focus API (
setFocusable/requestFocus) backed by per-Activity LVGL focus groups, plus real D-pad item selection inListView. This is what makes button-only devices (no touchscreen) fully navigable. AlertDialogis now keypad-dismissable (BACK cancels, ENTER confirms) and is torn down whenever its Activity leaves the foreground — no more leaked dialogs.
JVM & runtime
invokestaticnow walks the superclass chain per JVMS §5.4.3.3.- Garbage-collector fixes for callback-driven apps: Views and dialogs referenced only by native listener maps (key / touch / click / dialog) are now GC roots, fixing input that died ~15 s into a session. Also plugs a native-state root leak and a GC-starvation path.
- Heap shrink:
helloworldpeak heap drops 51 KB → 25 KB via aJvmObjectlayout rework (singleBox<[Value]>field store,class_idxside table, tightened layout guard). New chunked-slot heap storage plus an RP2350 heap bump 384 KB → 416 KB. - Past JVM optimisations are now tunable from a board’s
[jvm]board.tomlsection.
Robustness
- Bad-APK and poisoned-mutex paths log and early-return instead of panicking.
- A covered Activity no longer receives
onServiceConnected(fixes a stale bound-service use-after-free) and has its dialogs dismissed when pushed under another Activity; further stale-view UAF and duplicate-launch hardening.
picoenvmon showcase
- Pimoroni Pico Enviro+ Pack bring-up — display plus I2C BME688 / LTR559 sensors.
- Redesigned to a hub-menu 4-button navigation model (A=up / B=down / X=open / Y=back), smoothed
HomeActivityto 1 Hz via a bound service, and fixed the sensordemo “1 event then silent” phantom-IRQ bug.
Tooling, simulator & docs
- The simulator now emulates the physical buttons via the keyboard plus a headless control channel, runs the real XPT2046 touch driver, and synthesizes BME688 / LTR559 readings instead of zeros.
- New
perfbench(unified speed + memory) andgraphicsbench(LVGL render pipeline) benchmarks, each with a composite SCORE. - Documentation migrated to an Astro Starlight site, with a central reference page for the
[jvm]tunables. Example apps coalesced 59 → 51.
Shrink map: +23 classes (87 → 110) covering the Tier 1/2 listener and adapter surface; v0.9.0 entries copied verbatim.
v0.9.0 — 2026-05-06
The largest release yet. Bundles the licensing, multi-family, and lifecycle work that had been accumulating on main since v0.8.0.
Licensing
- Project relicensed Apache-2.0 → GPL-3.0-only (no Classpath Exception). Shipped a Contributor License Agreement (Harmony FLA-style) and a dual-licensing framework — see Licensing for details.
Multi-family architecture
platforms/<family>/directory replaces the flatsrc/hal/<family>/layout. RP code now lives underplatforms/rp/; ESP scaffolding lives underplatforms/esp/.- New
picodroid-core/workspace member holds cross-family shared code (no HAL imports). - HAL CONTRACT v1 — the required public-symbol set every family must expose — is documented in
platforms/rp/src/hal/mod.rsand compile-time enforced viaplatforms/rp/src/hal/contract.rs. - Build pipeline generalized via
build_support/{config,freertos,network,boards}.rsfor shared path resolution.
ESP32-S3 / Lilygo T-Deck Plus (M1)
- First Xtensa target lands as Milestone 1 — compile-only. The firmware produces a valid
xtensa-esp32s3-none-elfELF and flashes viaespflash, but FreeRTOS, networking, display, and the LVGL stack are no-ops at this milestone. (ESP32-S3 support, including its quickstart/toolchain pages and cargo aliases, was removed in 2026-07 — retrieveplatforms/esp/from git history if needed.)
Lifecycle and dispatch
Activitynow bootstraps theDisplaysingleton beforeonCreate(), eliminating a class of null-pointer dereferences in app code that touched the display inonCreate.pdb installno longer panics when the running app never starts an Activity (e.g. ablinky-style LED loop).main_queuesplits tick coalescing from cross-task wakes, reducing wakeup latency on busy frames.
LVGL
- Bumped 9.2.2 → 9.5.0 (already in v0.6.0; v0.9.0 enables
LV_DRAW_SW_SUPPORT_RGB565A8on top, fixing aliased rendering forImageView.setScaleType/setScale).
Build & CI
.actrcletsactrun the GitHub Actions workflows locally — see Advanced configuration → .actrc.- macOS toolchain hardening: switched off the broken
gcc-arm-embeddedcask onto the formula; fixedlibudev-devand absolute APK path issues for HIL testing.
Shrink map: byte-identical to v0.8.0 (no new framework classes).
v0.8.0 — 2026-05-02
PAPK 1.1 — bundled image assets. PAPKs gained an ASST section that carries pre-decoded PNG images as LVGL-native RGB565 structures mapped to XIP flash. ImageView.setImageSource("foo.png") becomes a name-keyed lookup with no on-device PNG decoder. See Bundled image assets and the new imagedemo example.
papk-pack and papk-info learned the asset table; the runtime resolver registers assets at boot via LVGL’s image cache.
Shrink map: byte-identical to v0.7.0 — bundled assets land outside the framework class set.
v0.7.0 — 2026-05-01
Tier C widget framework. Five new widgets (and one new listener) ship in this release:
Snackbar— toast with a clickable action lozenge.DatePicker—lv_calendarbinding.TimePicker—lv_rollerbinding, with 12-hour / AM-PM mode.SwipeRefreshLayout— pull-to-refresh container.OnSwipeListener— per-View swipe-direction primitive.ImageViewgainedsetScaleType/setTint/setScale.ProgressBargained an indeterminate variant viaProgressBar.indeterminate()(lv_spinner).
Shrink map: 5 new entries (a/CE..a/CI); v0.6.0 entries copied verbatim.
v0.6.0 — 2026-04-30
Showcase release. No new framework classes — the picoenvmon feature-showcase app and the LTR559 driver shipped this release. picoenvmon demonstrates the manual DI pattern (ApplicationComponent / ActivitySingletonComponent) in production-shape code.
Shrink map: stable, byte-identical to v0.5.0.
v0.5.0 — 2026-04-29
Soft-keyboard polish. The system soft keyboard:
- Slides up from the bottom edge over ~150 ms when an
EditTextgains focus, and slides back down on dismiss. - Forwards the OK key through a new
OnEditorActionListenerinterface before its default close behavior runs. - Dismisses on tap-outside.
Plus a new EditorInfo constants surface (TYPE_NUMBER / TYPE_EMAIL / TYPE_PHONE / TYPE_PASSWORD / TYPE_TEXT) for EditText.setInputType.
See EditText and the polish notes under Keyboard.
Shrink map: 2 new entries (OnEditorActionListener, EditorInfo); v0.4.0 entries copied verbatim.
v0.4.0 — 2026-04-27
DI + Service framework (Preview). Introduced the picodroid.app.Service lifecycle plus the manual DI components used by picoenvmon. New surface:
Service—onCreate/onStartCommand/onBind/onUnbind/onRebind/onDestroy.IBinder,Notification(withNotification.Builder), andstartForeground(int, Notification)for foreground services.ServiceConnectionfor binding lifecycle.- Manual DI components:
ApplicationComponent,ActivitySingletonComponent.
Also includes the servicedemo example which drives the full Service v1 lifecycle in one non-UI run.
Shrink map: ~10 new entries covering the DI + Service surface; v0.3.0 entries copied verbatim.
Older releases
For v0.1.0–v0.3.0, see git log and the original docs/ history. Highlights:
- v0.3.0 —
Theme, gestures (GestureDetector,OnTouchListener), animations (ViewPropertyAnimator), dialogs (AlertDialog),Toast,Keyboard. - v0.2.0 —
SensorManagerfamily (BME688), HTTP client,KeyEvent/OnKeyListener,Executors(main + background). - v0.1.0 — first release cut: 42 framework classes covering peripherals, storage, basic widgets, the JVM core.