AI;DR – This blog post and the script it describes were developed with significant help from Claude. If you don’t want to read “AI slop”, stop reading now.
A few weeks ago I wrote about building an Android APK on a Linux box without root access. The result was a tiny settings-shortcut app: tap the icon, land directly on the Tethering & Hotspot settings screen, done. The whole thing (JDK, Android SDK, Gradle, Kotlin sources) was assembled by a single self-contained shell script with no system-wide installation required.
That post sparked a follow-up idea: what if the same approach could package any URL as a standalone launcher icon? Not a shortcut to a settings screen, but a full-screen WebView that opens a specific page the moment you tap it: no browser chrome, no address bar, no navigation UI. Just the page.
The Script
The result is build-webview-app.sh, a companion to the earlier build-settings-shortcut.sh. It works the same way: drop it in an empty directory on an x86_64 glibc Linux machine, run it, and it downloads the toolchain, generates a complete Android project, and produces a debug-signed APK ready to sideload.
Basic usage:
./build-webview-app.sh --slug my-dashboard --name "My Dashboard" \ "https://dashboard.example.com"
This produces my-dashboard.apk in a subdirectory called my-dashboard/. Install it with adb or copy it to the phone and tap it (with “Install unknown apps” enabled).
The full set of options:
--slug <slug> Required. Lowercase letters, digits, hyphens.
Determines the APK filename, project directory,
and (with --publisher) the Android package ID.
--name <text> Display name shown under the icon (default: slug)
--publisher <domain> Reversed domain for the package ID (default: com.example)
--icon <file.png> Custom PNG icon instead of the default globe
--js-off Disable JavaScript in the WebView
Environment variables BASE_DIR, PROJECT_DIR, and ASSUME_YES=1 work the same as in the earlier script.
Package IDs and Domain Spoofing
One thing I thought carefully about: the package ID. An obvious approach would be to derive it from the target URL: docs.google.com becomes com.google.docs.webview or similar. That is a bad idea. Anyone could package a page hosted on a shared server under a domain they do not own and end up with a package ID that looks like an official app from that domain’s owner. On a phone with multiple user accounts or in an enterprise MDM context, that matters.
So the target URL has no influence on the package ID whatsoever. The ID is built entirely from --slug and --publisher:
<publisher>.webview.<slug_with_underscores>
For private use, the default com.example publisher is fine. For anything you hand to someone else, use your own reversed domain:
./build-webview-app.sh \ --slug company-wiki \ --name "Company Wiki" \ --publisher de.example \ "https://wiki.example.de" # package ID: de.example.webview.company_wiki
Icons
The default icon is the Material Symbols “language” glyph, a globe, which seems appropriate for a web page. It is downloaded on demand from the google/material-design-icons repository on GitHub (Apache 2.0 licence) and cached locally, so subsequent builds do not need a network round trip for it.
The SVG files from that repository use a slightly unusual coordinate system: viewBox="0 -960 960 960", meaning the Y axis runs from -960 to 0 instead of the more common 0 to 960. To place the glyph correctly inside Android’s 108 dp adaptive icon canvas (specifically within the central 72 dp safe zone), the group transform works out to:
translateX=18 translateY=90 scaleX=0.075 scaleY=0.075
Which maps x from 0..960 to 18..90 and y from -960..0 to 18..90. Everything inside the safe zone, nothing clipped.
If you prefer your own icon, --icon yourlogo.png copies the PNG straight into the drawable resource directory. Android scales it at runtime. No ImageMagick required.
The WebView Itself
The app is a single AppCompatActivity with a full-screen WebView as its only layout element. The target URL is baked in at build time as a string resource, so there is nothing the user can change at runtime, which is exactly the point.
A few deliberate choices in the WebView configuration:
Navigation stays in-app. By default Android’s WebView hands off link clicks to the external browser. Overriding shouldOverrideUrlLoading to return false keeps everything inside the app, which is usually what you want for a dedicated page.
Rotation is handled. onSaveInstanceState saves the WebView’s navigation history; restoreState brings it back. Combined with configChanges="orientation|screenSize|keyboardHidden" in the manifest, rotating the phone does not reload the page from scratch.
The back button navigates within the page. If there is history in the WebView, the hardware back button goes back one page instead of closing the app.
JavaScript is on by default, off with --js-off. Most modern pages need it. If you are opening something static, or something where you specifically do not want scripts to run, the flag is there.
usesCleartextTraffic="true" is set in the manifest so that plain http:// URLs work. On a local network with an internal server that does not bother with TLS, this matters.
Shared Toolchain
Both scripts (this one and the earlier settings-shortcut script) use the same toolchain/ directory by default. JDK 17, the Android SDK, and Gradle 8.7 are downloaded once and reused across all builds. Building a second or third app after the first is much faster: the toolchain is already there, and only the Kotlin/Gradle dependency cache needs to warm up on first use.
Caveats
A debug-signed APK is fine for personal sideloading. It cannot be submitted to the Play Store, and if you install a release-signed version of the same package ID over it (or vice versa), Android will refuse the installation unless you uninstall first.
The WebView renders using whatever version of the Android System WebView is installed on the device, not a bundled engine. On current Android versions that is kept up to date automatically via the Play Store, so in practice it is a recent Chromium. On old or heavily modified ROMs it might be older.
And of course: everything the WebView loads, it loads from the network at runtime. The APK is just the shell; it has no knowledge of the page’s content.
One WSL note: if you run the script under the Windows Subsystem for Linux, you are best off keeping everything on the Linux filesystem (somewhere under your home directory) rather than on a Windows-mounted drive such as /mnt/c. Windows mounts (v9fs on WSL2, drvfs on WSL1) cannot perform chmod, and that turns out to break things in two separate ways. First, tar and unzip cannot set Unix permissions or timestamps during extraction, so they print a flurry of “Cannot change mode” / “Cannot utime” errors. Second, and more fatally, Gradle itself refuses to run from such a mount: its daemon chmods its own cache directory and dies with “Unable to start the daemon process”, and even once that is worked around, resource packaging fails writing the build output (“Operation not permitted”). There is no Gradle flag to turn the chmod off.
Two more things caught me out, both at install time. The first is signing. A debug build is signed for you, but the Android Gradle Plugin quietly drops the old v1 (JAR) signature once the minimum SDK is 24 or higher, leaving an APK signed with the v2 scheme only. That verifies fine and installs on plenty of devices, yet some package installers reject a v2-only APK when you sideload it, with the distinctly unhelpful “There was a problem parsing the package”. Turning v1 signing back on alongside v2 fixes it. If a sideload still refuses, reach for adb install instead of copying the file across by hand: a half-finished transfer produces the very same parse error, and adb will tell you what actually went wrong.
The second is the theme. The activity extends AppCompatActivity, and AppCompat insists on a Theme.AppCompat theme or it throws an exception in onCreate and the app crashes the instant you tap the icon. So the manifest points the application at one. A NoActionBar variant is the natural pick here anyway, because it also removes the title bar and keeps the WebView properly full-screen.
The script handles all of this automatically. Extraction errors are tolerated and the result is verified explicitly. And when it detects that the project sits on a Windows mount, it relocates Gradle’s cache and runs the actual build in a directory on the Linux filesystem, then copies the finished APK back next to your project. It works either way, but builds that have to reach across the mount are noticeably slower, so a native path is still the better choice if you have one.
Getting It
The script is self-contained. Save it as build-webview-app.sh, make it executable, and run it:
chmod +x build-webview-app.sh ./build-webview-app.sh \ --slug dummzeuch-blog \ --name "twm's blog" \ --publisher de.dummzeuch \ "https://blog.dummzeuch.de"
This will create dummzeuch-blog.apk, which opens https://blog.dummzeuch.de. Feel free to use it to read my blog.
Requirements are the same as for the earlier script: Linux x86_64, glibc, curl or wget, tar, unzip, sed, about 3 GiB of free disk space. No root. Nothing installed system-wide. Delete the toolchain/ directory to remove all traces.