Android 8+ Home-Screen-as-Code Launcher and Browser
Literature- objective
- can the home screen be described in a file?
- at what privilege does that file apply?
- « add to home screen »: no WebAPK, but a chrome-less install
- how to install a PWA declaratively
- and a PWA served from http://localhost?
- conclusion
- the whole argument
- a worked example: the artifacts and the apply commands
- the bundle: source for the localhost WebView app
- driving the browser’s own PWA installation
- Permalink
- 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: the browser is a subgoal of it, and mine must be FOSS, lightweight, allow PWA installation,
and on a new Android be maintained.
- 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.
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
getLayoutElementsMapregisters, the only web one is<shortcut url"…"/>=, which makes anACTION_VIEWitem2.
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 )
pm list packagesthere matches nowebapkpackage, on a phone whose browser has a PWA installed.
- what
- Chrome 138 on Android 8.1 mints nothing either — the attempt leaves no
webapkpackage, 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 ) the one browser-pinned item on its icons page is
« podcast », whose
webapp_scopeishttp://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 ) that item is a Cromite deep shortcut, its
favoritesrow carryingS.shortcut_idandcategory=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://localhostPWA 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.
- 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 ) 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 ) 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 appwidgetlists the providers a device carries, by component. - how: the provider’s own minimum sits in its APK —
pm pathfinds it,aapt2 dump xmltreereadsminWidth,minHeightand theminResizepair off itsappwidget-providerXML, andaapt2 dump resourcesresolves a dimension given as a reference.- example: OpenTasks declares
minWidth 110dpandminHeight 40dp, nominResizepair, andresizeMode 3. - example: Todo Agenda declares
minWidth 250dp,minHeight 110dp,minResize40x40 dp andresizeMode 3.
- example: OpenTasks declares
- 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 )
dumpsysreportedminSpan(2,1)for each OpenTasks widget andminSpan(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’sminResizeof 40x40 dp. - scope: on the agenda’s
minWidthandminHeightthe table returns 4x2, which is not what was reported, so the pair it matches is theminResizeone.
- what: the table returns that first pair for OpenTasks’ 110x40 dp, which declares no
- what: (unsourced — observed on oxenhead ) the icons at cells (0,4) and (4,3) were accepted, so that grid is at least 5 by 5.
- what: (unsourced — observed on oxenhead )
- how:
- what: the task spans below are 5x2 above 5x3 and the agenda’s is 5x5, read off mojito’s
favoritestable.- 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:configureon its provider, and whether that provider reads bind extras was not read.- what: (unsourced — observed on oxenhead , 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 ) its widget drew its date header, its toolbar and « Aucun événement à afficher ».
- what: OpenTasks declares
- why: the task pages must sit left of the icons.
<?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 theandroid.autoinstalls.config.action.PLAY_AUTO_INSTALLfilter.- 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 packagecom.example.launcherlayout.- scope: the build ran against one SDK platform and one
build-tools. - how: the icons’ components are what
cmd package resolve-activity --briefanswered on oxenhead, and the widgets’ provider is onedumpsys appwidgetlists there. - what: (unsourced — observed on oxenhead , applying a layout that declared it) an
item at
container“hotseat” rank=“4”= came back with 4 in theSCREENcolumn of itsfavoritesrow.- scope: that settles the branch for rank 4 on that device, and for nothing else.
- scope: the build ran against one SDK platform and one
- 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 emitsbuild/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 ) the APK this chapter builds, applied that
way, wrote the
favoritesandworkspaceScreensrows the layout above declares.
- what: (unsourced — observed on oxenhead ) the APK this chapter builds, applied that
way, wrote the
- 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.jsonso 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 packagecom.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 emitsbuild/memories-webview.apk.- how:
adb install build/memories-webview.apkthen places it. - scope: no
adb reversehere, the server sitting on the device’s own loopback per the target’s setup.
- how:
<?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://localhostcase (the localhost case) stays untested here. - what: forging the state by hand is the other way — a
webapp_<uuid>.xmlunder the browser plus afavoritesrow in the launcher’slauncher.dbgives a chrome-less PWA installation too.- scope: it takes root, the
sqlite3the 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.
- scope: it takes root, the
- scope: the evidence is oxenhead alone — Trebuchet, and the two Chromium forks it carried, both of
which the script verified as reaching
- 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 webapp —
webapps.WebappActivity,webapp_scope.
- scope: the browser’s own identifiers call that a webapp —
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-keyguarddoes against a PIN is unknown.
- scope: the sandbox carries no secure lock, so what
finding a control by id
- how
uiautomator dumpwrites 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.pyand 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_buttonopens the app menu, andapp_menu_listscrolls when the entry sits below the fold. - how: the install entry is
universal_installon Brave 1.80.115 andadd_to_homescreen_idon Bromite 108; which version cuts over between them is unknown. - how: the script tries
option_install,button_installandpositive_buttonin 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_shortcutstays out of the list — it is the row besideoption_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_celland 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.
- how:
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-activityanswers 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_acceptbeing the button. - rule: it reads
dumpsys shortcutand stops when a pinned shortcut’swebapp_scopealready 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 namewebapps.WebappActivity.
- rule: the browser is named in the file, never guessed — with two browsers installed and no default
set,
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:
dumpsysreadsminSdk=26for 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:
- 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'toaapt2; 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
Permalink
-
#+BEGIN_QUOTE
https://android.googlesource.com/platform/packages/apps/Launcher3 ↩︎
-
// 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 ↩︎
-
↩︎Locally-delivered resources such as those with
http://127.0.0.1,http://localhost, andhttp://*.localhostURLs (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
-
↩︎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 :
https://developer.android.com/develop/ui/views/appwidgets/layouts
-
// 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 ↩︎
-
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 ↩︎
-
↩︎“pushed_at”: “2024-01-24T16:48:54Z”
— the GitHub API’s record for
bromite/bromite(primary), read : -
↩︎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 ()
-
↩︎GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
— Cromite’s own
LICENSE(primary), read :https://raw.githubusercontent.com/uazo/cromite/master/LICENSE
-
↩︎Mozilla Public License Version 2.0
— the brave-browser repository’s own
LICENSE(primary), read :https://raw.githubusercontent.com/brave/brave-browser/master/LICENSE