A 22 KB home screen for the HTC Desire Z, built without Gradle

I assumed the cheap way to get a small launcher was to take an existing one and cut. The phone is an HTC Desire Z, the 2010 Android slider with a full keyboard, which I'm turning into a field device for a mesh network, on CyanogenMod 7, the community build of Android 2.3. Its home screen app is ADWLauncher, and I had its source open on my desk with every intention of carving it down to three screens.

Cutting lost on the first read of the source, and a new one written from nothing reached the phone less than ten minutes after I chose to write it. Most of the trouble in those minutes came from the build tools, which had opinions about what year it is. What follows is the launcher that came out, about 22 KB of APK, and what it took to get Android 2.3 to accept it.

Strip or rewrite: ADW's numbers#

ADWLauncher, as CyanogenMod 7 ships it, is a 730 KB APK. Its source is 88 Java files and 1,231,099 bytes of Java. Launcher.java alone runs 5,120 lines. Workspace, the home-screen pager, extends WidgetSpace from a bundled mobi.intuitit widget library, thirteen of those 88 files, and this phone needs no widgets. There is no dock to customize either. There's an 800 MHz Snapdragon, 512 MB of RAM, and a keyboard that deserves a launcher aware of it.

I picked the clean rewrite, keeping ADW's source next to it as a reference to steal from. The new launcher has three screens: stats on the left, the app grid in the middle, toggle tiles on the right, and a bar of five pinned apps along the bottom. The morning I wrote it up, wc -l over the sources counted eight files (the descriptions are mine):

FileLinesWhat it does
MeshLauncher.java224Activity, Fn shortcuts, app loading
MeshWorkspace.java1553-screen horizontal pager
StatsView.java285/proc reader, battery, RSSI sparkline
EssentialsView.java2262x4 toggle tiles
AppGridView.java131Grid + keyboard filter
QuickBar.java97Bottom bar, five pinned apps
ScreenshotUtil.java83Framebuffer capture
AppInfo.java14Data class

Total: 1,215 lines. One build that night came out at 22,149 bytes, signed.

Two JDKs and a 64-line build script#

There is no Gradle here, and no Ant either, although Ant was the first plan. The build is a 64-line shell script around aapt, javac, dx and jarsigner from the Android SDK's platform 10 and build-tools 28.0.3. Its core, as it stands in build.sh:

JAVA6=$HOME/.sdkman/candidates/java/6.0.119-zulu
JAVA8=$HOME/.sdkman/candidates/java/8.0.482-tem

echo "=== Compiling Java (Java 6) ==="
find $SRC $OUT/gen -name "*.java" > $OUT/sources.txt
$JAVA6/bin/javac -source 1.6 -target 1.6 \
    -bootclasspath $PLATFORM/android.jar \
    -classpath $PLATFORM/android.jar \
    -d $OUT/classes \
    @$OUT/sources.txt \
    -Xlint:none 2>&1

echo "=== Creating DEX (Java 8 runtime) ==="
JAVA_HOME=$JAVA8 $DX --dex --output=$OUT/classes.dex $OUT/classes

Two JDKs, because the first two I tried each refused a different job. The first build used the JDK 21 on my machine, whose javac answered -source 1.6 with error: Source option 6 is no longer supported. Use 8 or later. I took that advice for exactly one build. That build compiled, and the phone refused to install it, for a reason that had nothing to do with bytecode (next section).

So javac went to a Zulu JDK 6 I already had installed, and the next build died one step later. dx from build-tools 28.0.3 will not run on Java 6: java.lang.UnsupportedClassVersionError: com/android/dx/command/Main : Unsupported major.minor version 51.0. Class-file version 51 is Java 7. Hence the split you see above: Java 6 compiles, Java 8 runs the dexer.

The certificate the package manager couldn't find#

Both of the first two builds that got as far as the phone ended the same way:

Failure [INSTALL_PARSE_FAILED_NO_CERTIFICATES]

It had a certificate, package manager. It had been signed seconds earlier. What Android 2.3 wants is a v1 JAR signature with SHA-1, and the fix is two flags on jarsigner:

$JAVA6/bin/jarsigner -keystore $KEYSTORE \
    -sigalg SHA1withRSA -digestalg SHA1 \
    -signedjar $BIN/mesh-launcher-debug.apk \
    $OUT/mesh-launcher-unsigned.apk \
    platform 2>/dev/null

The keystore password line is left out of that excerpt. I had added those two flags once already, during the JDK 21 attempt, and lost them when I rewrote the same lines for the JDK 6 switch, so the package manager got to tell me the same thing twice. With the flags back, the install went through. From the first build to a launcher on the phone took about two minutes, four failures included. Each of the four printed an error that said what was wrong, which on this phone I have learned not to take for granted.

The platform alias at the end is a later change. That night I also found that CyanogenMod 7 is signed with AOSP's public test keys, so an APK signed with the public platform key can declare android:sharedUserId="android.uid.system" and run as UID 1000, the system user, with no su at all. That one has its own post.

Reading /proc for the stats screen#

The stats screen refreshes every 3 seconds. Network throughput comes from /proc/net/dev:

private long[] readNetBytes() {
    long rx = 0, tx = 0;
    BufferedReader br = null;
    try {
        br = new BufferedReader(new FileReader("/proc/net/dev"));
        String line;
        while ((line = br.readLine()) != null) {
            line = line.trim();
            if (line.startsWith("wlan0:") || line.startsWith("rmnet0:")) {
                String[] parts = line.split("\\s+");
                rx += Long.parseLong(parts[1]);
                tx += Long.parseLong(parts[9]);
            }
        }
    } catch (Exception e) {
    } finally {
        if (br != null) try { br.close(); } catch (Exception e2) {}
    }
    return new long[]{rx, tx};
}

That finally block is the reviewed version. The first one closed the reader only on the happy path, so any exception in the loop leaked a file descriptor, on a method that runs every three seconds. More on the review below.

The difference between two reads, over the elapsed time, is the throughput. The WiFi signal history is a ten-slot ring buffer of RSSI readings, drawn as Unicode block characters:

private String rssiBar() {
    String[] bars = {"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"};
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < 10; i++) {
        int idx = (rssiIdx - 10 + i + 100) % 10;
        int rssi = rssiHistory[idx];
        if (rssi == 0) { sb.append(" "); continue; }
        int level = Math.max(0, Math.min(7, (rssi + 90) / 5));
        sb.append(bars[level]);
    }
    return sb.toString();
}

Ten samples three seconds apart: thirty seconds of signal in one line of monospace text.

Toggle tiles as data#

Eight tiles: WiFi, GPS, Bluetooth, Airplane, Brightness, Timeout, Kill All, Reboot. Each one is a label plus an implementation of a two-method interface:

private interface TileAction {
    boolean getState();
    void toggle();
}

The WiFi tile, as it sits in the tile array:

new Tile("WIFI", new TileAction() {
    public boolean getState() {
        WifiManager wm = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
        return wm.isWifiEnabled();
    }
    public void toggle() {
        WifiManager wm = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
        wm.setWifiEnabled(!wm.isWifiEnabled());
    }
})

On state, green text and "ON"; off, red text and "OFF". A tap calls toggle() and re-reads the state 500 ms later.

Fn shortcuts, and why dispatchKeyEvent#

The Desire Z's Fn key arrives as ALT_LEFT, so a shortcut is a key event with META_ALT_ON in its meta state. The first version handled keys in the activity's onKeyDown, and screenshots of the running launcher showed what was wrong with that: the left and right arrow keys, meant to switch screens, never got there, because the GridView with the apps ate them first. dispatchKeyEvent runs before any child view sees the key, so the Fn shortcuts moved up a level:

public boolean dispatchKeyEvent(KeyEvent event) {
    if (event.getAction() == KeyEvent.ACTION_DOWN) {
        boolean alt = (event.getMetaState() & KeyEvent.META_ALT_ON) != 0;
        if (alt && handleFnKey(event.getKeyCode())) {
            return true;
        }
    }
    return super.dispatchKeyEvent(event);
}

Fn+Q toggles WiFi, Fn+T opens the terminal, Fn+Z reboots, Fn+S takes a screenshot. My scripted test pressed every one of them with sendevent and every one failed: an ALT and a key injected that way never produce the META_ALT_ON flag. So the one feature built for this phone's keyboard was the one feature I could only test with my thumbs on that keyboard. It worked, inside the launcher; with another app in front, the launcher never sees the keys.

Eight findings, seven fixed in two minutes#

Before the next install I had the code reviewed. The review came back with eight findings:

  1. Battery receiver never unregistered — StatsView kept no reference to it, so nothing could ever pass it to unregisterReceiver().
  2. Filter mode unreachable — Fn+Space switched the app grid into filter mode, and no code path ever sent it the typed keys.
  3. ArrayIndexOutOfBoundsException on pkgList — the kill-all code read pkgList[0] without checking the array was non-empty, in two places.
  4. File descriptor leak in readNetBytes() — the one above, with the same pattern noted in readFile().
  5. Scroll before layout — snapToScreen() multiplied by getWidth(), which is 0 before the first layout pass.
  6. Null app label — loadLabel() returning null would crash the alphabetical sort.
  7. Battery level of -1% — the stats screen could show the placeholder value before the first battery broadcast arrived; a display bug, not a crash.
  8. Handler leak in EssentialsView — the 500 ms tile refresh could fire after the view was gone.

The clean build came less than two minutes after the review landed. It fixed seven of the eight, plus the readFile() leak; the -1% placeholder never got its guard. Two minutes says more about the size of each bug than about me.

781 milliseconds, measured once#

ActivityManager logged one launch of the launcher that night as Displayed com.mesh.launcher/.MeshLauncher: +781ms. That is the number in my notes as its startup time. Other launches that night logged anywhere from 499 ms to 1.2 s, and one that hit an exception on the way took 4.1 s, so I keep the 781 as one reading on one 800 MHz single core from 2010, not as a benchmark. I didn't time ADW the same way, and I am not going to invent the comparison now.

Twenty-two kilobytes, eight files, no XML layouts: every view is built in code. No content providers, no widget subsystem. I came to cut 5,120 lines down to size and ended up writing 1,215 of my own. The launcher was the easy part. Getting two JDKs and a package manager from 2010 to agree on it took the four failures, and I would like it noted that every one of them told me exactly what it wanted, in a voice that suggested I should have known.

Filed under: android, java, hardware

Sources#