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.
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# 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)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 usageTypeface.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 dashboardam 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)| Metric | WebView Launcher | Native Launcher |
|---|---|---|
| APK size | 48 KB | 12.6 KB |
| RSS | 216 MB | 55 MB |
| PSS | ~180 MB | ~45 MB |
| RAM saved | N/A | 170 MB |
| WebView process | Yes (Chromium renderer) | None |
| Build system | Gradle | Manual (javac + dx + aapt) |
| Dependencies | WebView, Chromium, V8 | Android framework only |