< Back to all hacks

#40 Native APK: Kill the WebView (216 -> 45 MB)

Launcher
Problem
WebView launcher uses 216 MB RSS — more than the gateway itself (186 MB).
Solution
Complete rewrite: native Activity + ScrollView + TextView, HTTP fetch, manual JSON parsing. 12.6 KB APK, 45 MB RSS.
Lesson
WebView is Chromium. Loading one HTML page costs 170+ MB on ARM32. Native Views use only shared zygote pages.

Context

The first PocketClaw launcher (Hack #39) was a WebView-based APK. It loaded the dashboard HTML page from http://localhost:9003/dashboard inside an Android WebView. The dashboard rendered beautifully — green CRT theme, live stats, interactive controls. But WebView is Chromium. On the Moto E2's ARM32 Snapdragon 410, instantiating a single WebView allocates the Chromium renderer process, V8 (a second JavaScript engine alongside the gateway's Node.js), GPU compositing buffers, and DOM layout structures. The result: 216 MB RSS for displaying a single status page. That is more than the gateway itself (186 MB at the time).

With 1 GB total RAM and the OS floor at ~165 MB, a 216 MB launcher plus a 186 MB gateway (401 MB) leaves only ~434 MB for the OS — barely survivable. Android's low-memory killer would frequently kill either the launcher or the gateway. The solution was to rewrite the launcher using only native Android Views: no WebView, no Chromium, no embedded browser engine of any kind. Just the basic View classes that are already loaded in every Android process via the shared zygote pages.

Prerequisites

  • Android SDK with API 23 (Android 6.0) build tools
  • Java 8 (for dx/d8 dex compiler)
  • aapt, dx/d8, and zipalign from the Android SDK build tools

Implementation

The native launcher is a single Activity with a ScrollView containing a LinearLayout of TextViews. All views use Typeface.MONOSPACE for the terminal aesthetic. The green CRT theme is achieved entirely with color constants — no CSS, no images, no assets.

// MainActivity.java — complete launcher
package com.pocketclaw.launcher;

import android.app.Activity;
import android.graphics.Color;
import android.graphics.Typeface;
import android.os.Bundle;
import android.os.Handler;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;

public class MainActivity extends Activity {
    private static final int BG_COLOR = 0xFF000A00;      // near-black green
    private static final int TEXT_COLOR = 0xFF00FF41;     // phosphor green
    private static final int DIM_COLOR = 0xFF006B1A;      // dim green for labels
    private static final long REFRESH_MS = 3000;

    private TextView statusView;
    private TextView uptimeView;
    private TextView ramView;
    private TextView heapView;
    private Handler handler = new Handler();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        ScrollView scroll = new ScrollView(this);
        scroll.setBackgroundColor(BG_COLOR);

        LinearLayout layout = new LinearLayout(this);
        layout.setOrientation(LinearLayout.VERTICAL);
        layout.setPadding(32, 48, 32, 48);

        // Title
        TextView title = makeLabel("POCKETCLAW", 24);
        layout.addView(title);

        // Status line
        statusView = makeLabel("STATUS: ...", 16);
        layout.addView(statusView);

        // Uptime
        uptimeView = makeLabel("UPTIME: ...", 14);
        layout.addView(uptimeView);

        // RAM
        ramView = makeLabel("RAM: ...", 14);
        layout.addView(ramView);

        // Heap
        heapView = makeLabel("HEAP: ...", 14);
        layout.addView(heapView);

        scroll.addView(layout);
        setContentView(scroll);

        // Start polling
        handler.post(pollRunnable);
    }

    private TextView makeLabel(String text, int sizeSp) {
        TextView tv = new TextView(this);
        tv.setText(text);
        tv.setTextColor(TEXT_COLOR);
        tv.setTextSize(sizeSp);
        tv.setTypeface(Typeface.MONOSPACE);
        tv.setPadding(0, 8, 0, 8);
        return tv;
    }

    private final Runnable pollRunnable = new Runnable() {
        @Override
        public void run() {
            new Thread(() -> {
                try {
                    URL url = new URL("http://localhost:9000/api/status");
                    HttpURLConnection conn = (HttpURLConnection) url.openConnection();
                    conn.setConnectTimeout(2000);
                    conn.setReadTimeout(2000);

                    BufferedReader reader = new BufferedReader(
                        new InputStreamReader(conn.getInputStream()));
                    StringBuilder sb = new StringBuilder();
                    String line;
                    while ((line = reader.readLine()) != null) sb.append(line);
                    reader.close();

                    String json = sb.toString();
                    // Manual JSON parsing — no Gson, no org.json
                    String status = extractField(json, "status");
                    String uptime = extractField(json, "uptime");
                    String rss = extractField(json, "rss");
                    String heap = extractField(json, "heapUsed");

                    runOnUiThread(() -> {
                        statusView.setText("STATUS: " + status);
                        uptimeView.setText("UPTIME: " + uptime + "s");
                        ramView.setText("RSS: " + rss + " MB");
                        heapView.setText("HEAP: " + heap + " MB");
                    });
                } catch (Exception e) {
                    runOnUiThread(() ->
                        statusView.setText("STATUS: OFFLINE"));
                }
            }).start();
            handler.postDelayed(this, REFRESH_MS);
        }
    };

    // Minimal JSON field extractor — no library needed
    private static String extractField(String json, String key) {
        String search = "\"" + key + "\":";
        int idx = json.indexOf(search);
        if (idx < 0) return "?";
        int start = idx + search.length();
        // Skip whitespace and quotes
        while (start < json.length() &&
               (json.charAt(start) == ' ' || json.charAt(start) == '"'))
            start++;
        int end = start;
        while (end < json.length() &&
               json.charAt(end) != ',' &&
               json.charAt(end) != '}' &&
               json.charAt(end) != '"')
            end++;
        return json.substring(start, end);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        handler.removeCallbacks(pollRunnable);
    }
}

The AndroidManifest.xml declares the launcher intent and INTERNET permission:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.pocketclaw.launcher"
    android:versionCode="1"
    android:versionName="1.0">

    <uses-sdk android:minSdkVersion="23" android:targetSdkVersion="23" />
    <uses-permission android:name="android.permission.INTERNET" />

    <application android:label="PocketClaw" android:theme="@android:style/Theme.NoTitleBar">
        <activity android:name=".MainActivity"
            android:screenOrientation="portrait"
            android:launchMode="singleTask">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.HOME" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

Build the APK manually without Gradle (Gradle itself uses more RAM than this phone has):

# Compile Java to class files
javac -source 1.7 -target 1.7 -bootclasspath $ANDROID_SDK/platforms/android-23/android.jar \
  -d build/classes src/com/pocketclaw/launcher/MainActivity.java

# Convert to DEX
dx --dex --output=build/classes.dex build/classes/

# Package with aapt
aapt package -f -M AndroidManifest.xml -I $ANDROID_SDK/platforms/android-23/android.jar \
  -F build/pocketclaw-unsigned.apk

# Add DEX to APK
cd build && zip -j pocketclaw-unsigned.apk classes.dex && cd ..

# Align and sign
zipalign -f 4 build/pocketclaw-unsigned.apk build/pocketclaw.apk
apksigner sign --ks debug.keystore --ks-pass pass:android build/pocketclaw.apk
# Install on phone
adb install build/pocketclaw.apk
# Set as default launcher
adb shell cmd package set-home-activity com.pocketclaw.launcher/.MainActivity

Verification

# Check APK size:
ls -lh build/pocketclaw.apk
# 12.6 KB

# Check RSS after launch:
adb shell dumpsys meminfo com.pocketclaw.launcher | grep "TOTAL PSS"
# ~45 MB PSS (55 MB RSS including shared zygote pages)

# Verify it registered as a HOME launcher:
adb shell dumpsys package com.pocketclaw.launcher | grep "HOME"
# Category: "android.intent.category.HOME"

# Confirm no WebView process:
adb shell ps | grep -i webview
# (empty — no WebView process)

Gotchas

  • The 55 MB RSS includes ~40 MB of shared zygote pages (Dalvik VM, system framework classes) that are shared with every Android process. The launcher's unique private pages are only ~10-15 MB. You cannot eliminate the zygote overhead without replacing Android entirely
  • Android requires at least one app with the HOME category. If you uninstall this APK without another launcher installed, the phone hangs at the boot animation. Always keep a backup launcher enabled
  • HttpURLConnection on Android 6 does not support HTTP/2 or connection pooling. This is fine for polling one endpoint every 3 seconds but would not scale for complex API usage
  • The manual JSON parser is intentionally minimal — it handles flat key-value pairs but not nested objects or arrays. The /api/status endpoint returns a flat JSON object, so this is sufficient
  • Typeface.MONOSPACE maps to Droid Sans Mono on Android 6, which has limited Unicode coverage. Emoji and CJK characters may not render. This is acceptable for a status dashboard
  • The am force-stop com.pocketclaw.launcher command kills the launcher, but Android immediately recreates it because it is the HOME activity. This is why killing the launcher to save RAM does not work (it respawns within seconds)
  • Without Gradle, the build process is manual (javac, dx, aapt, zipalign, apksigner). This is intentional — Gradle's JVM would consume more RAM than the entire phone has

Result

MetricWebView LauncherNative Launcher
APK size48 KB12.6 KB
RSS216 MB55 MB
PSS~180 MB~45 MB
RAM savedN/A170 MB
WebView processYes (Chromium renderer)None
Build systemGradleManual (javac + dx + aapt)
DependenciesWebView, Chromium, V8Android framework only