Konubinix' opinionated web of thoughts

Android 8+ Home-Screen-as-Code Launcher and Browser

Literature

tl;dr
on a de-Googled LineageOS a home screen can be provisioned from a versioned file — the launcher layout as a first-boot system XML (root or ROM, plus a data wipe), and PWAs as a TWA APK, by driving the browser’s own install over adb, or, on http://localhost, as our own WebView APK.

objective

why
I configure my NixOS box from a versioned file; I want my phone’s home screen the same way — declared once, applied reproducibly, with no tapping, dragging or dropping.
what
on Android 8+ / LineageOS, can a home screen carrying several screens, app widgets, and PWAs — including self-hosted ones served from http://localhost — be provisioned declaratively from a version-controlled config, and at what privilege cost?
why
my goal is an Android that installs easily.
  • why: the browser is a subgoal of it, and mine must be FOSS, lightweight, allow PWA installation, and on a new Android be maintained.
    • what: lightweight here means running on an old device such as oxenhead.
    • what: oxenhead is a Nexus 5X on Android 8.1.
why
all web-related work must go toward that subgoal.
scope
the target, held fixed for the whole note, is a de-Googled LineageOS — no Google Play Services (GMS) — running a Launcher3-based launcher, on a device where root is at hand.
  • therefore: a system-privileged step is payable, so the question stays what the privilege costs, not whether it can be paid.
  • what: on stock LineageOS that launcher is Trebuchet, a fork of AOSP’s Launcher31.
what
HSaC stands for home-screen-as-code.
[objective]: on a de-Googled LineageOS (Android 8+, no GMS, root at hand), can a home screen — several screens, app widgets and PWAs — be provisioned declaratively from a versioned file, with no manual tap/drag, and at what privilege cost?

can the home screen be described in a file?

what
the home screen is expressible as data.
  • what: « workspace » names the layout’s root element, beneath which everything the file declares sits, the dock included.
how
AOSP Launcher3 ships an XML parser whose vocabulary covers screens, app icons, folders, app widgets at chosen grid cells, and dock items at a rank.
how
a screen named on a desktop item is a screen made.
scope
the vocabulary reaches a web link, not a web app — of the five top-level tags getLayoutElementsMap registers, the only web one is <shortcut url"…"/>=, which makes an ACTION_VIEW item2.

at what privilege does that file apply?

what
expressible is not appliable.
how
the XML’s source and read-time decide it: Launcher3 reads a resource baked into a system package, only once, not a user file.

« add to home screen »: no WebAPK, but a chrome-less install

what
PWAs are the awkward third.
what
what « add to home screen » cannot give on a de-Googled phone, save Samsung Internet on Samsung hardware, is a WebAPK.
what
what it does give is a pinned shortcut.
  • what: on Brave 1.80.115 that shortcut opened chrome-less.
  • what: (unsourced — read on mojito [2026-09-06 Sun]) pm list packages there matches no webapk package, on a phone whose browser has a PWA installed.
what
Chrome 138 on Android 8.1 mints nothing either — the attempt leaves no webapk package, and what it pins is a deep shortcut into the browser, whose extras carry the site’s scope.
  • what: that icon opens the site with no URL bar.
how
a WebAPK is minted on a server, and of the two paths to one, the first is shut here and the second goes untested.

how to install a PWA declaratively

what
the browser will not mint the app, so the app is built ahead of time, from a file.
how
a Trusted Web Activity (TWA) wraps the PWA in a thin APK, and Bubblewrap generates that APK from the site’s web manifest.

and a PWA served from http://localhost?

what
one PWA does not fit the TWA path — the self-hosted one, served from the phone’s own loopback http://localhost, with no public domain and no certificate.
what
a Secure Context treats it as a securely-delivered, trustworthy origin3.
  • scope: that is a verdict about the trust of the origin, distinct from Android’s Network Security Config, which still blocks cleartext to it by default.
what
the trouble is proving to a Trusted Web Activity that app and site share one owner, the site being every device’s own localhost.
what
mojito is the phone whose home screen this note reproduces, and it runs Cromite over amirz.rootless.nexuslauncher, a later Launcher3 fork than the one read here.
  • scope: its browser-side reads carry over; its launcher-side ones are that fork’s, not Trebuchet’s.
  • what: (unsourced — read on mojito [2026-09-06 Sun]) the one browser-pinned item on its icons page is « podcast », whose webapp_scope is http://localhost:9682/Documents/localhost/.
    • therefore: the self-hosted case is not a corner of the question; on that page it is the whole of it.
    • scope: the other five screens’ items were not read, only their widgets.
  • what: (unsourced — read on mojito [2026-09-06 Sun]) that item is a Cromite deep shortcut, its favorites row carrying S.shortcut_id and category=com.android.launcher3.DEEP_SHORTCUT.
    • therefore: what Cromite pinned there is a shortcut Cromite owns, of a kind no tag read here declares.
how
stop asking a Trusted Web Activity to trust a site it structurally cannot verify, and ship an app that never runs the check.
how
the cleanest is our own thin WebView; forking the browser’s verification or decompiling a prebuilt TWA APK are heavier variants of the same bypass.

conclusion

therefore
yes — as two mechanisms, not one: the launcher layout is expressible as declarative XML (a file) yet appliable only as a first-boot system artifact — root or a ROM build, plus a data wipe.
therefore
on this target no WebAPK is minted, save Samsung Internet on Samsung hardware, which stays untested (no WebAPK), and two paths stay open — the TWA-from-manifest APK, and the browser’s own install driven over adb.
therefore
a http://localhost PWA is the exception to even that path — a Trusted Web Activity cannot verify a loopback origin, so it degrades to a URL-bar Custom Tab (the localhost case); the reproducible answer is our own thin WebView APK, not a TWA.
therefore
the no-tap/drag goal holds on both halves; the price is asymmetric — the PWA half is plain user-space tooling, the launcher half costs system privilege, payable on this target since root is at hand, though still at the price of a data wipe. See the overview map in the whole argument.
[HSaC feasible, split path]: home-screen-as-code is feasible on a de-Googled LineageOS, but along two distinct paths — the launcher layout as a first-boot system XML (root/ROM + data wipe), and PWAs by either of two routes, a TWA APK built from a versioned manifest or the browser's own install driven over adb. The no-tap/drag goal holds; only the launcher half costs system privilege. A =http://localhost= PWA is the one exception to the TWA route — it cannot pass Digital Asset Links verification, so it ships as our own WebView APK instead.
scope
a user-space alternative for the launcher half — a third-party launcher whose backup/restore file can be generated and pushed without root (Nova, Lawnchair, others). With root assumed it is an optional simplification, not a necessity.
scope
which LineageOS Chromium-based browsers implement the TWA protocol well enough to render a Bubblewrap APK full-screen.

the whole argument

a worked example: the artifacts and the apply commands

what
this chapter turns the conclusion into runnable artifacts, reproducible and not only proven.
scope
the localhost PWA is not worked here; its source and its build are in (the bundle).

the launcher layout

how
declare the launcher layout as a system customization XML in AutoInstallsLayout’s vocabulary, then make it the first-boot source.
what
the workspace below is oxenhead’s own home screen — an agenda page, four pages of task widgets, a page of four icons, and the camera in the dock; its coordinates are the expressible layout (a file).
  • why: the task pages must sit left of the icons.
    • how: they take the lower screen numbers, which is the order the ranks are handed out in (a file).
    • what: (unsourced — observed on oxenhead [2026-09-06 Sun]) screens 0 to 5 took ranks 0 to 5 in workspaceScreens, the agenda at 0 and the icons at 5.
    • what: (unsourced — observed on oxenhead [2026-09-06 Sun]) five swipes rightward from the page the launcher opened on reached the icons.
    • scope: which page the launcher opens on is a property of the home screen for which no vocabulary was read in AutoInstallsLayout.
  • how: a widget is declared by its provider’s component, a cell, and a span in cells.
    • how: dumpsys appwidget lists the providers a device carries, by component.
    • how: the provider’s own minimum sits in its APK — pm path finds it, aapt2 dump xmltree reads minWidth, minHeight and the minResize pair off its appwidget-provider XML, and aapt2 dump resources resolves a dimension given as a reference.
      • example: OpenTasks declares minWidth 110dp and minHeight 40dp, no minResize pair, and resizeMode 3.
      • example: Todo Agenda declares minWidth 250dp, minHeight 110dp, minResize 40x40 dp and resizeMode 3.
    • scope: the minimum is dp and the file wants cells; the table that converts them is its source’s estimate for a 5x4 grid4, and what grid the device declares was not read.
      • what: (unsourced — observed on oxenhead [2026-09-06 Sun]) dumpsys reported minSpan(2,1) for each OpenTasks widget and minSpan(1,1) for the agenda’s.
        • what: the table returns that first pair for OpenTasks’ 110x40 dp, which declares no minResize, and the second for the agenda’s minResize of 40x40 dp.
        • scope: on the agenda’s minWidth and minHeight the table returns 4x2, which is not what was reported, so the pair it matches is the minResize one.
      • what: (unsourced — observed on oxenhead [2026-09-06 Sun]) the icons at cells (0,4) and (4,3) were accepted, so that grid is at least 5 by 5.
  • what: the task spans below are 5x2 above 5x3 and the agenda’s is 5x5, read off mojito’s favorites table.
    • scope: mojito also supplied which widgets there are, how many task pages, and the agenda first.
    • scope: every component named in the file was resolved on oxenhead.
    • scope: the screen numbers are not mojito’s; they put the task pages left of the icons.
    • scope: three of mojito’s four task pages carry that shape and the fourth carries 5x3 above 5x2; the four pages below are all the first shape.
  • what: a widget the layout places is bound without its configuration activity, and the layout may carry <extra key=… value=…/> pairs sent at that bind instead5.
    • what: OpenTasks declares android:configure on its provider, and whether that provider reads bind extras was not read.
      • what: (unsourced — observed on oxenhead [2026-09-06 Sun], on the apply before the agenda joined) the two widgets on the page the launcher then opened on drew their title bar over an empty body.
    • what: Todo Agenda names no configuration activity, so nothing is held open for it.
      • what: (unsourced — observed on oxenhead [2026-09-06 Sun]) its widget drew its date header, its toolbar and « Aucun événement à afficher ».

<?xml version="1.0" encoding="utf-8"?>
<workspace>
  <appwidget packageName="org.andstatus.todoagenda"
             className="org.andstatus.todoagenda.AppWidgetProvider"
             screen="0" x="0" y="0" spanX="5" spanY="5" />
  <appwidget packageName="org.dmfs.tasks"
             className="org.dmfs.tasks.homescreen.TaskListWidgetProvider"
             screen="1" x="0" y="0" spanX="5" spanY="2" />
  <appwidget packageName="org.dmfs.tasks"
             className="org.dmfs.tasks.homescreen.TaskListWidgetProvider"
             screen="1" x="0" y="2" spanX="5" spanY="3" />
  <appwidget packageName="org.dmfs.tasks"
             className="org.dmfs.tasks.homescreen.TaskListWidgetProvider"
             screen="2" x="0" y="0" spanX="5" spanY="2" />
  <appwidget packageName="org.dmfs.tasks"
             className="org.dmfs.tasks.homescreen.TaskListWidgetProvider"
             screen="2" x="0" y="2" spanX="5" spanY="3" />
  <appwidget packageName="org.dmfs.tasks"
             className="org.dmfs.tasks.homescreen.TaskListWidgetProvider"
             screen="3" x="0" y="0" spanX="5" spanY="2" />
  <appwidget packageName="org.dmfs.tasks"
             className="org.dmfs.tasks.homescreen.TaskListWidgetProvider"
             screen="3" x="0" y="2" spanX="5" spanY="3" />
  <appwidget packageName="org.dmfs.tasks"
             className="org.dmfs.tasks.homescreen.TaskListWidgetProvider"
             screen="4" x="0" y="0" spanX="5" spanY="2" />
  <appwidget packageName="org.dmfs.tasks"
             className="org.dmfs.tasks.homescreen.TaskListWidgetProvider"
             screen="4" x="0" y="2" spanX="5" spanY="3" />
  <appicon packageName="com.proscenic.smart.robot"
           className="com.app.robot.activity.LaunchActivity"
           screen="5" x="0" y="4" />
  <appicon packageName="eu.konubinix.konixpoc"
           className="org.kivy.android.PythonActivity"
           screen="5" x="2" y="3" />
  <appicon packageName="com.limelight"
           className="com.limelight.PcView"
           screen="5" x="3" y="3" />
  <appicon packageName="com.molink.john.wifiviewer"
           className="com.wifiview.activity.MainActivity"
           screen="5" x="4" y="3" />
  <appicon container="hotseat" rank="4"
           packageName="org.lineageos.snap"
           className="com.android.camera.CameraLauncher" />
</workspace>

the customization APK that carries it

how
ship that XML as a customization APK, a system package whose <receiver> carries the android.autoinstalls.config.action.PLAY_AUTO_INSTALL filter.
  • how: the loader resolves the marker among broadcast receivers (root or a ROM build, plus a data wipe).
  • how: the resource is named, not merely placed — res/xml/default_layout.xml, or the same name suffixed with the grid’s columns and rows, or with those and the hotseat count6.
how
three blocks land under /var/run/user/1000/launcher-layout/ — the manifest, the layout and the build script — with package com.example.launcherlayout.
  • scope: the build ran against one SDK platform and one build-tools.
  • how: the icons’ components are what cmd package resolve-activity --brief answered on oxenhead, and the widgets’ provider is one dumpsys appwidget lists there.
  • what: (unsourced — observed on oxenhead [2026-09-06 Sun], applying a layout that declared it) an item at container“hotseat” rank=“4”= came back with 4 in the SCREEN column of its favorites row.
    • scope: that settles the branch for rank 4 on that device, and for nothing else.
how
once tangled, run it inside the flake’s devShell — nix develop /home/sam/prog/devel/flakes/android# --command bash build.sh — which compiles the resources, links, aligns and signs, and emits build/launcher-layout.apk.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.launcherlayout"
    android:versionCode="1"
    android:versionName="1.0">
  <uses-sdk android:minSdkVersion="24" android:targetSdkVersion="27" />
  <application android:hasCode="false" android:label="Launcher layout">
    <receiver android:name=".Marker" android:exported="true">
      <intent-filter>
        <action android:name="android.autoinstalls.config.action.PLAY_AUTO_INSTALL" />
      </intent-filter>
    </receiver>
  </application>
</manifest>

#!/usr/bin/env bash
set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"

SDK="${ANDROID_SDK_ROOT:?set ANDROID_SDK_ROOT (the flake devShell provides it)}"
BT_DIR=$(ls -1 "$SDK/build-tools" | sort -V | tail -1)
BT="$SDK/build-tools/$BT_DIR"
PLATFORM="$SDK/platforms/android-34/android.jar"

OUT=build
rm -rf "$OUT"
mkdir -p "$OUT/compiled"

"$BT/aapt2" compile --dir res -o "$OUT/compiled/res.zip"
"$BT/aapt2" link -I "$PLATFORM" \
  --manifest AndroidManifest.xml \
  -o "$OUT/app-unsigned.apk" \
  "$OUT/compiled/res.zip"

(cd "$OUT" \
   && "$BT/zipalign" -p 4 app-unsigned.apk aligned.apk \
   && mv aligned.apk app-unsigned.apk)

KEY="$OUT/debug.keystore"
if [ ! -f "$KEY" ]; then
  keytool -genkey -v -keystore "$KEY" -alias androiddebugkey \
    -keyalg RSA -keysize 2048 -validity 10000 \
    -storepass android -keypass android \
    -dname "CN=Android Debug,O=Android,C=US"
fi

"$BT/apksigner" sign --ks "$KEY" --ks-key-alias androiddebugkey \
  --ks-pass pass:android --key-pass pass:android \
  --out "$OUT/launcher-layout.apk" "$OUT/app-unsigned.apk"

echo "built $HERE/$OUT/launcher-layout.apk"
scope
the path and the modes are the ones exercised on oxenhead; whether another system location yields the flag the loader wants (root or a ROM build, plus a data wipe) was not tested.
  • what: (unsourced — observed on oxenhead [2026-09-06 Sun]) the APK this chapter builds, applied that way, wrote the favorites and workspaceScreens rows the layout above declares.
scope
this is the first-boot-only, system-privilege cost the conclusion already named.

the TWA APK

# init writes twa-manifest.json from the site's manifest
npx @bubblewrap/cli init \
  --manifest https://example.com/manifest.webmanifest \
  --packageId com.example.twa
# build emits app-release-signed.apk
npx @bubblewrap/cli build
# install on the device; a TWA-capable browser renders it
adb install app-release-signed.apk
how
prove app and site share one owner via Digital Asset Links — host the file the browser fetches at first launch (the TWA path).

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.twa",
      "sha256_cert_fingerprints": ["AA:BB:CC:DD:EE:FF"]
    }
  }
]
how
publish it at https://example.com/.well-known/assetlinks.json so verification passes and the URL bar stays hidden (what verification fetches, and when the bar hides).

the bundle: source for the localhost WebView app

scope
this chapter is the runnable source behind the localhost path (the localhost case, the worked example) — a full-screen WebView on http://localhost:9682/memories/, tangled to a buildable tree.
how
three blocks land under /var/run/user/1000/memories-webview/ — the manifest, the Activity, and the build script — with package com.example.memorieswebview.
how
once tangled, run it inside the flake’s devShell — nix develop /home/sam/prog/devel/flakes/android# --command bash build.sh — which compiles, dexes, signs and emits build/memories-webview.apk.
  • how: adb install build/memories-webview.apk then places it.
  • scope: no adb reverse here, the server sitting on the device’s own loopback per the target’s setup.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.memorieswebview"
    android:versionCode="1"
    android:versionName="1.0">
  <uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34" />
  <uses-permission android:name="android.permission.INTERNET" />
  <application
      android:label="Memories"
      android:usesCleartextTraffic="true">
    <activity
        android:name=".MainActivity"
        android:exported="true"
        android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
      <!-- the shared SMS link is followed in the browser, which the server
           302-redirects to this app-unique scheme; BROWSABLE + DEFAULT let
           the browser hand the redirect off to this app instead of keeping
           it. The scheme is per-app, so it never claims the shared link. -->
      <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="memorieswebview" />
      </intent-filter>
    </activity>
  </application>
</manifest>

package com.example.memorieswebview;

import android.app.Activity;
import android.content.Intent;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Bundle;
import android.os.Message;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;

public class MainActivity extends Activity {
    private WebView webView;

    // the app's own scheme, redirected to by the server after the shared
    // SMS link is followed in the browser. The inner localhost target the
    // app actually loads is carried in the redirect URL's "go" query param.
    private static final String SCHEME = "memorieswebview";
    private static final String FALLBACK = "http://localhost:9682/memories/";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        webView = new WebView(this);
        WebSettings settings = webView.getSettings();
        settings.setJavaScriptEnabled(true);
        // the page is served over http://localhost but pulls its ES modules
        // (solid-js, urql, yjs) from https://esm.sh — that is mixed content,
        // blocked by default on API 21+, so allow it explicitly.
        settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
        settings.setDomStorageEnabled(true);
        settings.setCacheMode(WebSettings.LOAD_DEFAULT);
        settings.setSupportMultipleWindows(true);
        webView.setWebViewClient(new WebViewClient());
        // a same-origin authz flow may open its login step with
        // window.open(...) / target="_blank"; a plain WebView drops that
        // silently, so route the new "window" back into this WebView.
        webView.setWebChromeClient(new WebChromeClient() {
            @Override
            public boolean onCreateWindow(WebView view, boolean dialog,
                                         boolean userGesture, Message resultMsg) {
                WebView newView = new WebView(view.getContext());
                newView.setWebViewClient(new WebViewClient() {
                    @Override
                    public void onPageStarted(WebView w, String url, Bitmap fav) {
                        view.loadUrl(url);
                    }
                });
                WebView.WebViewTransport transport =
                    (WebView.WebViewTransport) resultMsg.obj;
                transport.setWebView(newView);
                resultMsg.sendToTarget();
                return true;
            }
        });
        webView.loadUrl(targetUrl(getIntent()));
        setContentView(webView);
    }

    // launched from the home icon -> FALLBACK; launched from the 302
    // redirect -> the "go" param holds the localhost URL to open.
    private static String targetUrl(Intent intent) {
        Uri data = (intent == null) ? null : intent.getData();
        if (data != null && SCHEME.equals(data.getScheme())) {
            String go = data.getQueryParameter("go");
            if (go != null && !go.isEmpty()) {
                return go;
            }
        }
        return FALLBACK;
    }

    @Override
    public void onBackPressed() {
        if (webView.canGoBack()) {
            webView.goBack();
        } else {
            super.onBackPressed();
        }
    }
}

#!/usr/bin/env bash
set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"

SDK="${ANDROID_SDK_ROOT:?set ANDROID_SDK_ROOT (the flake devShell provides it)}"
BT_DIR=$(ls -1 "$SDK/build-tools" | sort -V | tail -1)
BT="$SDK/build-tools/$BT_DIR"
PLATFORM="$SDK/platforms/android-34/android.jar"

JAVAC="$(command -v javac || true)"
KEYTOOL="$(command -v keytool || true)"
if [ -z "$JAVAC" ] || [ -z "$KEYTOOL" ]; then
  echo "javac/keytool not on PATH — run inside: nix shell nixpkgs#openjdk" >&2
  exit 1
fi
PKG=com/example/memorieswebview
OUT=build
mkdir -p "$OUT"

"$BT/aapt2" link -I "$PLATFORM" \
  --manifest AndroidManifest.xml -o "$OUT/app-unsigned.apk"

"$JAVAC" -cp "$PLATFORM" -d "$OUT" "src/$PKG/MainActivity.java"
DEXDIR="$OUT/dex"
mkdir -p "$DEXDIR"
"$BT/d8" --output "$DEXDIR" --lib "$PLATFORM" "$OUT/$PKG"/*.class

(cd "$OUT" \
   && "$BT/zipalign" -p 4 app-unsigned.apk app-unsigned-aligned.apk \
   && mv app-unsigned-aligned.apk app-unsigned.apk)
(cd "$OUT/dex" && zip -q ../app-unsigned.apk "classes.dex")

KEY="$OUT/debug.keystore"
if [ ! -f "$KEY" ]; then
  "$KEYTOOL" -genkey -v -keystore "$KEY" -alias androiddebugkey \
    -keyalg RSA -keysize 2048 -validity 10000 \
    -storepass android -keypass android \
    -dname "CN=Android Debug,O=Android,C=US"
fi
"$BT/apksigner" sign --ks "$KEY" --ks-key-alias androiddebugkey \
  --ks-pass pass:android --key-pass pass:android \
  --out "$OUT/memories-webview.apk" "$OUT/app-unsigned.apk"

echo "built $OUT/memories-webview.apk"

driving the browser’s own PWA installation

scope
this chapter is the alternative to the WebView APK (the bundle) — the target’s browser installs a PWA chrome-less by itself, so the work is driving that install from a versioned file rather than building an APK.
  • scope: the evidence is oxenhead alone — Trebuchet, and the two Chromium forks it carried, both of which the script verified as reaching webapps.WebappActivity; the launcher half rests on Trebuchet alone.
    • scope: Brave’s own runs went through different screens from one another.
    • scope: what Bromite’s confirm step showed was not recorded before it was uninstalled.
  • scope: the PWAs exercised were public https sites, so the http://localhost case (the localhost case) stays untested here.
  • what: forging the state by hand is the other way — a webapp_<uuid>.xml under the browser plus a favorites row in the launcher’s launcher.db gives a chrome-less PWA installation too.
    • scope: it takes root, the sqlite3 the device does not carry and the launcher’s own schema; its row shows a stock icon, and its file-exists check reads an emptied leftover as installed.
what
the PWA is the site, and the PWA installation is what the browser makes from it.
  • scope: the browser’s own identifiers call that a webappwebapps.WebappActivity, webapp_scope.

the install the browser already does

what
« add to home screen » does two things.
  • what: it registers the installation inside the browser.
  • what: it asks the launcher to pin an icon for it.
    • what: that icon carries the browser’s badge, and the installation belongs to that browser.
  • what: both are reachable from adb, so the install asks for no root and no hand on the phone.
    • scope: the sandbox carries no secure lock, so what wm dismiss-keyguard does against a PIN is unknown.

finding a control by id

how
uiautomator dump writes the current window as XML, every control carrying its resource-id and its bounds; the script looks a control up by id and taps the centre of its bounds.
  • therefore: a step that has an id depends neither on the screen size nor on the wording of the button.

the steps

how
the steps run in this order, the script landing at /var/run/user/1000/pwa-install/install-pwa.py and taking the url, the browser and, at need, the launcher.
  • how: am start -a android.intent.action.VIEW -d <url> -p <browser> opens the page.
  • how: menu_button opens the app menu, and app_menu_list scrolls when the entry sits below the fold.
  • how: the install entry is universal_install on Brave 1.80.115 and add_to_homescreen_id on Bromite 108; which version cuts over between them is unknown.
  • how: the script tries option_install, button_install and positive_button in that order and taps the first a screen offers.
    • scope: each of the three appeared on oxenhead under Brave 1.80.115, in varying combinations, and which a build shows is unknown.
    • scope: option_shortcut stays out of the list — it is the row beside option_install, not the install row.
      • what: the browser labels that row « Shortcuts open in Brave ».
      • scope: what it produces was not tested here.
  • how: the launcher’s pin dialog carries widget_cell and buttons with no id at all, so the script takes the rightmost — place-automatically sitting right of cancel.
    • scope: that one step is positional, and one locale exercised it.

what holds for every run

rule
five things hold for every run.
  • rule: the browser is named in the file, never guessed — with two browsers installed and no default set, resolve-activity answers the chooser, android.
  • rule: the launcher is resolved the same way; the run stops when the answer comes back android, and a third argument names it instead.
  • rule: the script clears a fresh browser’s onboarding first, terms_accept being the button.
  • rule: it reads dumpsys shortcut and stops when a pinned shortcut’s webapp_scope already covers the url; without that read a second run pins a second icon under a new uuid.
  • rule: it checks its own work — it taps the icon it pinned and reads dumpsys activity activities, which has to name webapps.WebappActivity.

choosing the browser

rule
the browser is chosen against the subgoal (the objective).
  • scope: nothing read here separates the two that ran, and above their range nothing here ran at all.
  • what: Brave 1.80.115 and Bromite 108 both ran on oxenhead, at API 27, and the script drove the install on both (the steps).
    • what: dumpsys reads minSdk=26 for Brave 1.80.115, so 26 and 28 are what its manifest declares rather than what ran.
    • what: Bromite’s repository has had no push since 2024-01-247.
      • scope: that date is what was read; whether the project is maintained it does not settle.
  • what: Cromite asks « Android 10 and above »8 and carries a GNU GPL v3 LICENSE9; this note has not run it.
  • what: Brave’s current release, 1.94.121, declares minSdkVersion:'29' to aapt2; no device here has run it, and its install row is unread.
    • what: so under API 29 the only candidates here are old builds, the two current ones asking that floor or higher.
  • how: a build comes from a release asset pinned by tag — …/releases/download/v1.80.115/BraveMonoarm64.apk, which answers 200 — where the …/releases/latest/download/ path tracks the newest rather than pinning it.
    • scope: the assets read are arm64.
  • scope: the choice is not made once for every Android — which browser the file names may differ by version.
    • example: several Brave builds, each the last its Android version supports.
  • scope: of the subgoal’s four criteria, FOSS is settled for no candidate here — what is read is each repository’s LICENSE, brave-browser’s10 and Cromite’s, not the licence of the APK either serves.

the script

#!/usr/bin/env python3
"""Install a PWA on the home screen, chrome-less, by driving the browser.

Usage: install-pwa.py <url> <browser-package> [launcher-package]
Needs adb alone: no root, no launcher database, no browser preferences.
"""
import re
import subprocess
import sys
import time
import xml.etree.ElementTree as ET

# the install entry: universal_install on Brave 1.80.115,
# add_to_homescreen_id on Bromite 108. Add an id for a fork outside the pair.
INSTALL_ENTRIES = ("universal_install", "add_to_homescreen_id")
INSTALL_BUTTONS = ("option_install", "button_install", "positive_button")


def shell(*args):
    return subprocess.run(["adb", "shell", *args],
                          capture_output=True, text=True).stdout


def screen():
    for _ in range(10):
        if "dumped" in shell("uiautomator", "dump", "/sdcard/ui.xml"):
            raw = subprocess.run(["adb", "exec-out", "cat", "/sdcard/ui.xml"],
                                 capture_output=True).stdout
            if raw.strip():
                return ET.fromstring(raw)
        time.sleep(1)
    raise SystemExit("uiautomator dump keeps failing")


def nodes(root, rid=None, cls=None, pkg=None):
    for node in root.iter("node"):
        if rid and not node.get("resource-id", "").endswith(":id/" + rid):
            continue
        if cls and node.get("class") != cls:
            continue
        if pkg and node.get("package") != pkg:
            continue
        yield node


def first(root, ids, pkg):
    return next((n for rid in ids for n in nodes(root, rid=rid, pkg=pkg)), None)


def box(node):
    return list(map(int, re.findall(r"-?\d+", node.get("bounds"))))


def tap(node):
    x1, y1, x2, y2 = box(node)
    shell("input", "tap", str((x1 + x2) // 2), str((y1 + y2) // 2))


def home_launcher():
    out = shell("cmd", "package", "resolve-activity", "--brief",
                "-a", "android.intent.action.MAIN",
                "-c", "android.intent.category.HOME")
    pkg = out.strip().splitlines()[-1].split("/")[0]
    if pkg in ("android", ""):
        raise SystemExit("no default launcher: name its package as argument 3")
    return pkg


def pinned_webapp(browser, url):
    """dumpsys prints a pinned shortcut's extras, its scope included."""
    for line in shell("dumpsys", "shortcut").splitlines():
        if "ShortcutInfo {" not in line:
            continue
        if "packageName=%s," % browser not in line:
            continue
        flags = re.search(r"flags=0x[0-9a-f]+ \[([^]]*)\]", line)
        scope = re.search(r"webapp_scope=([^,}]+)", line)
        if not flags or "P" not in flags.group(1) or not scope:
            continue
        if url.startswith(scope.group(1)):
            return re.search(r"id=([^,]+),", line).group(1)
    return None


def clear_first_run(browser):
    """A browser installed a moment ago shows its onboarding first."""
    for _ in range(12):
        root = screen()
        if next(nodes(root, rid="url_bar", pkg=browser), None) is not None:
            return
        step = next(nodes(root, rid="terms_accept", pkg=browser), None)
        if step is not None:
            tap(step)
        time.sleep(1)


def open_menu(browser):
    for _ in range(15):
        menu = next(nodes(screen(), rid="menu_button", pkg=browser), None)
        if menu is not None:
            tap(menu)
            return True
        time.sleep(1)
    return False


def install_entry(browser):
    """The entry may sit below the fold, so scroll the menu to reach it."""
    for _ in range(6):
        root = screen()
        entry = first(root, INSTALL_ENTRIES, browser)
        if entry is not None:
            return entry
        menu = next(nodes(root, rid="app_menu_list", pkg=browser), None)
        if menu is None:
            return None
        x1, y1, x2, y2 = box(menu)
        mid = (x1 + x2) // 2
        shell("input", "swipe", str(mid), str(y2 - 50),
              str(mid), str(y1 + 50), "300")
    return None


def confirm_install(browser, launcher):
    for _ in range(12):
        root = screen()
        if next(nodes(root, rid="widget_cell", pkg=launcher), None) is not None:
            return
        step = first(root, INSTALL_BUTTONS, browser)
        if step is not None:
            tap(step)
        time.sleep(1)


def pin_icon(launcher):
    """The pin dialog's buttons carry no id; the rightmost one places it."""
    for _ in range(15):
        root = screen()
        if next(nodes(root, rid="widget_cell", pkg=launcher), None) is not None:
            buttons = list(nodes(root, cls="android.widget.Button",
                                 pkg=launcher))
            if buttons:
                label = next(nodes(root, rid="widget_name", pkg=launcher), None)
                tap(max(buttons, key=lambda n: box(n)[0]))
                return label.get("text") if label is not None else ""
        time.sleep(1)
    return None


def resumed():
    lines = shell("dumpsys", "activity", "activities").splitlines()
    return next((l for l in lines if "mResumedActivity" in l), "")


def check(label, launcher):
    """Open the icon just pinned; it has to come up as webapps.WebappActivity."""
    shell("input", "keyevent", "KEYCODE_HOME")
    for _ in range(10):
        icon = next((n for n in screen().iter("node")
                     if n.get("text") == label
                     and n.get("package") == launcher), None)
        if icon is not None:
            tap(icon)
            break
        time.sleep(1)
    else:
        return "no icon named %r on the home screen" % label
    for _ in range(10):
        if "webapps.WebappActivity" in resumed():
            return None
        time.sleep(1)
    return "the icon opened %s" % (resumed() or "nothing")


def main(url, browser, launcher=None):
    launcher = launcher or home_launcher()
    print("browser=%s launcher=%s" % (browser, launcher))

    already = pinned_webapp(browser, url)
    if already:
        print("already installed as %s" % already)
        return

    # uiautomator sees nothing through a dark screen or a keyguard
    shell("input", "keyevent", "KEYCODE_WAKEUP")
    shell("wm", "dismiss-keyguard")

    shell("am", "start", "-a", "android.intent.action.VIEW",
          "-d", url, "-p", browser)
    clear_first_run(browser)
    if not open_menu(browser):
        raise SystemExit("%s did not come up on %s" % (browser, url))

    entry = install_entry(browser)
    if entry is None:
        raise SystemExit("%s offers no install entry for %s" % (browser, url))
    tap(entry)
    confirm_install(browser, launcher)

    label = pin_icon(launcher)
    if label is None:
        raise SystemExit("%s never asked to pin the icon" % launcher)
    wrong = check(label, launcher)
    if wrong:
        raise SystemExit(wrong)
    print("installed and checked: %s" % label)


if __name__ == "__main__":
    if not 3 <= len(sys.argv) <= 4:
        raise SystemExit(__doc__)
    main(*sys.argv[1:])

— LineageOS Trebuchet’s .gitupstream, which records the AOSP repository it forks (branch lineage-22.2):

https://github.com/LineageOS/android_packages_apps_Trebuchet/blob/lineage-22.2/.gitupstream

#+END_QUOTE


  1. #+BEGIN_QUOTE

    https://android.googlesource.com/platform/packages/apps/Launcher3 ↩︎

  2. // in AutoInstallsLayout
    private static final String TAG_SHORTCUT = "shortcut";
    private static final String ATTR_URL = "url";
    
    // in AutoInstallsLayout.getLayoutElementsMap
    parsers.put(TAG_APP_ICON, new AppShortcutParser());
    parsers.put(TAG_AUTO_INSTALL, new AutoInstallParser());
    parsers.put(TAG_FOLDER, new FolderParser());
    parsers.put(TAG_APPWIDGET, new PendingWidgetParser());
    parsers.put(TAG_SHORTCUT, new ShortcutParser(mSourceRes));
    
    // in AutoInstallsLayout.ShortcutParser
    protected Intent parseIntent(XmlResourceParser parser) {
       final String url = getAttributeValue(parser, ATTR_URL);
       if (TextUtils.isEmpty(url) || !Patterns.WEB_URL.matcher(url).matches()) {
           if (LOGD) Log.d(TAG, "Ignoring shortcut, invalid url: " + url);
           return null;
       }
       return new Intent(Intent.ACTION_VIEW, null).setData(Uri.parse(url));
    }
    []
    return addShortcut(mSourceRes.getString(titleResId),
           intent, Favorites.ITEM_TYPE_SHORTCUT);
    

    AutoInstallsLayout.java at android-7.1.2_r36 (primary), read [2026-09-06 Sun] ↩︎

  3. Locally-delivered resources such as those with http://127.0.0.1, http://localhost, and http://*.localhost URLs (for example, http://dev.whatever.localhost/) are not delivered using HTTPS, but they can be considered to have been delivered securely because they are on the same device as the browser. They are therefore potentially trustworthy.

    https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts

     ↩︎
  4. Use the following table to estimate your widget’s minimum dimensions on a typical 5x4 grid handset, given the number of occupied grid cells you want

    (73n - 16) x (118m - 16)

    the rough estimation of general available sizes

    — Android’s own widget layout guide (primary), read [2026-09-05 Sat]:

    https://developer.android.com/develop/ui/views/appwidgets/layouts

     ↩︎
  5. // in AutoInstallsLayout
    private static final String TAG_EXTRA = "extra";
    private static final String ATTR_KEY = "key";
    private static final String ATTR_VALUE = "value";
    
    // in AutoInstallsLayout.PendingWidgetParser.parseAndAdd
    if (TAG_EXTRA.equals(parser.getName())) {
       String key = getAttributeValue(parser, ATTR_KEY);
       String value = getAttributeValue(parser, ATTR_VALUE);
    
    // in AutoInstallsLayout.PendingWidgetParser.verifyAndInsert
    mValues.put(Favorites.RESTORED,
           LauncherAppWidgetInfo.FLAG_ID_NOT_VALID |
                   LauncherAppWidgetInfo.FLAG_PROVIDER_NOT_READY |
                   LauncherAppWidgetInfo.FLAG_DIRECT_CONFIG);
    []
    if (!extras.isEmpty()) {
       mValues.put(Favorites.INTENT, new Intent().putExtras(extras).toUri(0));
    }
    
    // in LauncherAppWidgetInfo
    /**
    * Indicates that the widget does not need to show config activity, even if it has a
    * configuration screen. It can also optionally have some extras which are sent during bind.
    */
    public static final int FLAG_DIRECT_CONFIG = 32;
    

    AutoInstallsLayout.java and LauncherAppWidgetInfo.java at android-7.1.2_r36 (primary), read [2026-09-06 Sun] ↩︎

  6. private static final String FORMATTED_LAYOUT_RES_WITH_HOSTEAT = "default_layout_%dx%d_h%s";
    private static final String FORMATTED_LAYOUT_RES = "default_layout_%dx%d";
    private static final String LAYOUT_RES = "default_layout";
    []
    String layoutName = String.format(Locale.ENGLISH, FORMATTED_LAYOUT_RES_WITH_HOSTEAT,
           (int) grid.numColumns, (int) grid.numRows, (int) grid.numHotseatIcons);
    int layoutId = targetRes.getIdentifier(layoutName, "xml", pkg);
    
    // Try with only grid size
    if (layoutId == 0) {
       Log.d(TAG, "Formatted layout: " + layoutName
               + " not found. Trying layout without hosteat");
       layoutName = String.format(Locale.ENGLISH, FORMATTED_LAYOUT_RES,
               (int) grid.numColumns, (int) grid.numRows);
       layoutId = targetRes.getIdentifier(layoutName, "xml", pkg);
    }
    
    // Try the default layout
    if (layoutId == 0) {
       Log.d(TAG, "Formatted layout: " + layoutName + " not found. Trying the default layout");
       layoutId = targetRes.getIdentifier(LAYOUT_RES, "xml", pkg);
    }
    

    AutoInstallsLayout.java at android-7.1.2_r36 (primary), read [2026-09-04 Fri] ↩︎

  7. “pushed_at”: “2024-01-24T16:48:54Z”

    — the GitHub API’s record for bromite/bromite (primary), read [2026-09-05 Sat]:

    https://api.github.com/repos/bromite/bromite

     ↩︎
  8. Cromite is available for Android arm64-v8a, arm32-v7a and x86_64, Android 10 and above

    — Cromite’s own README (primary): https://raw.githubusercontent.com/uazo/cromite/master/README.md ([2026-09-02 Wed])

     ↩︎
  9. GNU GENERAL PUBLIC LICENSE

    Version 3, 29 June 2007

    — Cromite’s own LICENSE (primary), read [2026-09-05 Sat]:

    https://raw.githubusercontent.com/uazo/cromite/master/LICENSE

     ↩︎
  10. Mozilla Public License Version 2.0

    — the brave-browser repository’s own LICENSE (primary), read [2026-09-05 Sat]:

    https://raw.githubusercontent.com/brave/brave-browser/master/LICENSE

     ↩︎