lindroids export <program> gives the program an Android app of its own: its name and icon in
the app list, on the home screen and in Recents, and a window that is Android’s own WebView, full
screen, with no browser. Lindroids builds the app on the phone, in Rust, in milliseconds, signs
it with a key made on the phone, and hands it to Android’s installer. Nothing is downloaded and
nothing leaves the phone.
What the app is
The window app’s code (packaging/webview-app, one activity, a WebView and a small JavaScript
bridge) under the program’s own identity:
Its icon opens the start page, which starts the program and becomes its window. When Termux is not
running, the app shows its own page, “VSCodium runs in Termux, which is not running now”, whose
Start hands the program’s launch URL to Termux (the same URL a Termux home screen shortcut
shares); Termux starts the program and its window opens in the app. Windows the server opens later
(
lindroids run, a notification’s tap) open in the same app, so every window of the program has
its name and icon.
How it is built: no SDK on the phone
Android installs only APKs: a ZIP with a compiled (binary XML) manifest, compiled resources, Dalvik code, and a signature. Building one normally takes the Android SDK (aapt2, d8, zipalign, apksigner) and Java: hundreds of megabytes that Termux users should not need. So the project builds one template and the phone only fills it in:- The template (
crates/lindroids-apk/template/window-app.apk, unsigned, about 160 KB) is built bypackaging/build-app-template.shwith the SDK, frompackaging/webview-appandAndroidManifest.program.xml, whose package, label and meta-data are placeholder strings. It is committed, like the helper’sshortcut.dex, and carried inside thelindroidsbinary. - On the phone (
crates/lindroids-apk, pure Rust:p256,sha2,flate2,crc32fast):- the binary manifest’s string pool is decoded, the placeholders replaced (package, name, start
page, launch URL, terminal app, id) and written again;
versionCode(minutes since 1970, so a new export is always an update) andversionNameare set in place. An unchanged manifest comes out byte for byte (a test checks it); - the three icon PNGs are swapped;
- the ZIP is written again, stored entries aligned to 4 bytes (
zipalign -p 4: Android 11+ refuses a compressed or unalignedresources.arsc); - it is signed with APK Signature Scheme v2, ECDSA P-256 with SHA-256, and a self-signed
certificate “Lindroids on this phone”. v2 is all Android 7 (the minimum) and later need.
Google’s
apksigner verifyaccepts the result.
- the binary manifest’s string pool is decoded, the placeholders replaced (package, name, start
page, launch URL, terminal app, id) and written again;
cargo run -p lindroids-apk --example build-app -- out.apk <id> <name> <start> <launch> <icon.png>
builds one on a computer.
How it reaches Android’s installer
Installing is always the user’s tap in Android’s installer. Getting the APK there depends on the terminal app:
With Termux paired once with Android’s Wireless debugging (
lindroids adb pair), none of this is
needed: Termux’s own adb installs the app with no taps (No-tap installs).
Then Google Play Protect may stop an app it has not seen: “App scan recommended”. Scan app
uploads the APK to Google; More details → Install without scanning installs it after the phone’s
PIN or fingerprint. Either is the user’s choice; Lindroids cannot answer it.
Tested on a real phone
Samsung Galaxy Tab Active5 (SM-X306B), Android 16, One UI, Termux from Google Play (googleplay.2026.10.09, targetSdk 37), 2026-10-29:
Then, driven by a second agent from a written plan (round 1,
0.1.0+dev):
The installer’s Open and the icon could each make a start-page task (two VSCodium cards before
any window opened): the icon was an alias of the window activity, whose tasks are per document.
The icon is now its own single-task activity (
org.lindroids.Launch), and every window Lindroids
opens for the program loads in that task: one program, one window.
Termux from F-Droid (lab VM b15, Bliss OS, Android 12, Termux 0.118.0): lindroids export foot opened Android’s installer directly. The first time it asks to allow Termux (“For your
security, your tablet currently isn’t allowed to install unknown apps from this source”); after
Settings → Allow from this source → Back Android returned to the home screen, not the installer,
so lindroids export foot again, then Do you want to install this app? Foot → Install. Foot’s
icon then opened foot in org.lindroids.app.termux.foot, full screen, first in Recents.
Found and fixed on the way:
- Termux cannot see the app it offered. Termux from Google Play targets Android 15+ without
QUERY_ALL_PACKAGES, so package lookups (getPackageInfo,pm path) do not see other apps; “is it installed?” always said no. Lindroids no longer asks. - Termux cannot open the app from behind the installer. Opening it while My Files was in front
was refused:
Abort background activity starts from 10160,BAL_BLOCK, result 102 (which is not an error code: the helper now treats every result but 0–3 as a failure). So the app is not opened byexport: its first start, from the user’s tap, hands its launch to Termux. - The app’s WebView has storage of its own, not paired with this Lindroids: its start page
would have asked for
lindroids pair’s code. An unpaired start page in an app now starts the program through Termux instead; the window then opened in the app carries its run’s ticket and pairs it, and later starts need nothing. - Two windows: the start page (“not running”) stayed in Recents as a second VSCodium card next to the program’s window. Now the icon’s activity is single-task and the program’s windows load in it (see above).
- MediaStore cannot be used from the helper:
app_processrunning as Termux is not an app Activity Manager knows (“Unable to find app for caller … when getting content provider media”), and a Context made there calls itselfandroid(“Given calling package android does not match caller’s uid”). Writing the file path in Download directly works instead. - The service worker the browser apps use for their offline page is not registered inside a Lindroids app (the app shows its own offline page).
Clean starts and single runs (third round)
Two errors the user saw on the tablet, and what caused them:- “VSCodium runs in Termux, which is not running now”, all the time. The app’s window still showed a run whose server had gone (the binary was replaced while it ran); when a new server answered, the offline page’s retry loaded that dead run’s page again. The retry now goes back to the program’s start page, which finds the program running or starts it, and only while the app is in front.
- “Another instance of VSCodium is running but not responding”. That VSCodium (under proot) had outlived its server: nothing ended the programs a stopping server had launched, and an orphan without a display hangs. It also made the next launch think VSCodium’s app was busy, so that window went to Chrome. The server now ends every program it launched when it stops (the process tree, SIGTERM then SIGKILL), and a program’s app takes a new run in place.
- A clean first start. Opening a program’s icon with Lindroids not running starts it through Termux by itself (no Start tap): tested, the icon handed the launch to Termux within 0.2 s and VSCodium’s window loaded in the same task.
- Single runs in a window app of their own.
lindroids app installbuilds the windows app (org.lindroids.windows, “Lindroids”) on the phone from the same template, with no page of its own.lindroids run <program>opens each run there as one task, with the program’s name and icon on it (setTaskDescription), closed when the program ends and when the server stops (tested: GNOME Calculator’s window opened in it and went withlindroids stop). One UI draws the windows app’s icon on such a card, not the program’s: it ignores task icons (tested earlier with Chrome’s Custom Tabs). Only an installed app of the program’s own (export) gets its icon there; installing one per run would mean an installer prompt per run.
adb install instead.
The demo (media/demos/export-and-run.mp4, 45 s) was recorded on the tablet with
adb shell screenrecord: lindroids run org.gnome.Calculator, then lindroids export org.gnome.Calculator (built in 28 ms), the installer, the icon with Lindroids not running, and
Recents. File names in My Files and other apps’ Recents cards are blurred.
Touch, keyboard and speed (fourth round, lab VM)
Reported from the tablet: the app flashed blue on taps and froze; there was no way to type. Tested on lab VM b15 (Android 12L, x86_64, foot exported,LINDROIDS_STATS=1), since the tablet
was away:
Typing on the soft keyboard (LatinIME) and on a hardware keyboard reaches foot, fast taps
included. Open: twice, the first character typed right after the app started was lost
(“base64” arrived as “ase64”); not reproduced after, with the page’s events traced over
DevTools (the page had the character; the loss is on the server or program side).
Input and the GPU (fifth round, lab VM)
Asked for: input as fast as it can be, drawing on the GPU. Changed:- The window app asks Android for unbuffered pointer events (
requestUnbufferedDispatch, Android 11+): touches reach the page as they come, not batched to the screen’s next frame. It also asks for the screen’s fastest refresh rate, and declares hardware acceleration. - The page draws in a worker that owns the canvas (
OffscreenCanvas,data/web/draw.js), on the GPU: each changed tile goes into a WebGL texture (texSubImage2D) that one triangle draws. Software GL (SwiftShader, llvmpipe) falls back toputImageData; browsers withoutOffscreenCanvasrun the same code in the page. A lost GPU context gets a whole frame again. LZ4 decoding copies repeated runs in doubling chunks (2.4x faster on the same data, and checked byte for byte againstlz4_flexon 60 generated payloads). - Input leaves at once: the lines one event makes go in one WebSocket message; mouse, pen (and
in WebView, touch) moves come from
pointerrawupdate; the canvas position is kept, not read on every event. - The server packs and sends frames with fewer copies (rows, not pixels; the payload straight into the link’s buffer), and converts client buffers in loops the compiler vectorizes.
- Only what changed (second pass): a client’s
damage/damage_bufferrectangles are all that is converted from its new buffer, into the memory the surface already has (a new allocation per commit was megabytes of page faults); when no surface moved, only their union is composed and diffed again. A whole buffer is converted on up to four cores;lz4_flexcompresses with its fast (non-safe) encoder.LINDROIDS_STATS=1now also logs the server’s own time per frame. - Keys from the first one: the window app gives its WebView Android’s focus when the window opens (a WebView without it took the first D-pad or arrow key to gain focus, and moved the page’s focus to a button or the hidden text area instead of passing the key on; reported on b15 with Tux Typing); the page’s buttons and that text area are out of key focus; a window’s canvas takes the focus when it is activated.
event.timeStamp is Android’s event time). Medians of three runs,
before and after interleaved; the host was shared with other builds, so single runs vary:
Second pass, same VM and method, interleaved with the first pass’s build (three runs each):
The GTK 3 widget factory run gives the same screens as before damage tracking (the only
differing pixels are its two spinners and the pulsing progress bar).
Touches now arrive at the digitizer’s rate rather than once a frame, and each goes out at once
(here ~860 a second from adb’s injected swipe; a real screen gives 120-480).
Checked by hand on b15: the soft keyboard (LatinIME) types into foot, with Backspace and Enter;
foot in a Firefox 158 Custom Tab draws with WebGL 2 in a worker, takes keys and goes full screen on
the first tap. In headless Chromium with GTK 3’s widget factory, in each of the four ways to draw
(worker or page, WebGL or 2D): a click, a touch tap, typing, soft-keyboard text (
é✓), a touch
drag on a slider, the wheel and a tab switch, with the same screens in all four; at
devicePixelRatio 2 too; and a lost and restored WebGL context redrew the whole window. Not
checked: a real phone or the tablet (not attached), a 90/120 Hz screen.
Switching between apps (sixth round, lab VM)
Reported from the tablet: closing one program’s app and opening another flashed the previous app’s screen. Tested on lab VM b15 (Android 12L) with foot’s and xclock’s apps, recording every screen update from the VM’s VNC display (25-50 ms apart) through each switch, with Termux’s “display over other apps” turned off (as on the tablet: it lets Termux start activities from the background):
How the first start works now: Termux, in the background, may not start the app’s activity
(
Abort background activity starts), but an explicit broadcast is not an activity start. The
server runs lindroids launch <id> with LINDROIDS_APP_BROADCAST=1; the helper still tries the
activity, then sends org.lindroids.WINDOW with the run’s URL to org.lindroids.app.<id> alone
(Android 12 answers 0 for the blocked start, Android 16 102, so it always sends it). The app’s
start activity loads only a URL under its own start page on its own server. Starting a program
this way needs no pairing, as sharing its launch URL to Termux never did; the window’s ticket
reaches only the program’s own package. When the server cannot (an older one) or no window comes
within 15 s, the page hands the launch to Termux as before. With Lindroids not running at all,
the start still goes through Termux, which must come forward to start it.
Not checked: the tablet (not attached), Android 13+ (the receiver is registered with
RECEIVER_EXPORTED there), a Chrome WebAPK.
A window and its program go together (seventh round)
Seen on the tablet: VSCodium’s whole process tree running under Termux, with no Lindroids task in Recents. A window swiped away after Android had dropped its page said nothing, and the server kept programs lost in the background with no limit; a detached GUI also outlived its run, and a window brought back after its run ended sat on a dead panel. Now (docs/DISPLAY.md, “Lifecycles”):
Out-of-date apps. The app’s code is in each APK, built from the template of the Lindroids that
built it: an app built before these fixes lacks them all, whatever Lindroids is installed now.
The template has a number (
WindowActivity.TEMPLATE, lindroids_proto::APP_TEMPLATE, bumped
together; a test checks), which the app’s bridge reports (template(); none before this round).
A page in an app older than its server says so once (“This app is out of date”, with lindroids export <id> or lindroids app install); lindroids run (when its window opens), lindroids export and lindroids doctor list the apps the server has seen that are older.
On the tablet (2026-11-10)
Galaxy Tab SM-X306B, Android 16, Termux from Google Play, a dev build of this round (0.1.178~dev), VSCodium (Flatpak) in its own app and GNOME Calculator as a single run in the
shared windows app; both apps rebuilt from the new template and installed over adb.
Not tested there: a hardware keyboard’s Caps Lock and held keys (adb’s key events carry no lock
state and no real hold), a mouse, and the folder picker.
Ctrl+C in Termux, and a card swiped away late (after 0.1.187)
- Ctrl+C. A run’s program gets the terminal (it may read it). Once nobody is left in its
group who could (the launcher ended; under proot only proot stays, which Termux from Google
Play shows as
linker64),lindroidstakes the terminal back, within a second. Ctrl+C then reacheslindroids, which passes it on to everything the run started and, 5 s later, ends what ignored it. A program that is still in the terminal’s group keeps Ctrl+C to itself, as before (a shell, a REPL). - The early question. Only a clear “its card is gone” ends the run that early. No answer within 15 s keeps it for the usual limit, after which no answer ends it, as before.
- The Recents icon of a single run stays the windows app’s on One UI. The task has the
program’s icon (
dumpsys activity recents:taskDescription { ... iconBitmap=true }), and One UI’s Recents draws the installed app’s icon all the same. Only an app of the program’s own (lindroids export) has its icon there.
Menus under a finger (after 0.1.189)
On the tablet VSCodium’s menu opened, but its entries with a submenu (File, View, Terminal…) only lit up under a finger: the submenu stayed shut.adb shell input tap opened them every time, which
hid it: that tap does not move. A press that rolls 4 px in 130 ms (input swipe x y x+4 y+3 130,
what a finger does) failed every time. The page sent each of those moves on as wl_touch.motion,
and the program took a touch that moved as a drag, not a tap.
The page now holds a finger’s moves back until it is 8 CSS px (Android’s own 8dp touch slop) from
where it went down; a finger that never got that far comes up where it went down. Past the slop
every move goes out as before.
tests/stuck-input.mjs has both cases (TOUCH-A, TOUCH-B); they fail on the page of 0.1.189.
A window that jumped as the keyboard came and went (after 0.1.189)
Samsung’s keyboard was floating on the tablet. A floating keyboard takes no room; Android only shows a bar under the window while it is up (112 px here, 56 CSS px at density 2), and the window app gives the window’s bottom to it. VSCodium asks for the keyboard in the editor and the terminal and lets go of it in a list, so moving around showed and hid it all the time, and each time the program’s window was resized by those 112 px and laid out again: a 60 fps screen recording shows the status bar at a third height for some frames, then the old layout, then the new one.- In the window app, once a bar like that was seen (the height changed by 100 CSS px or less) its strip stays the keyboard’s: the stage keeps the size it has above it, with or without the bar, until the window is left, turned, or a keyboard that takes real room comes up. The strip is black while the keyboard is away.
- A window’s canvas is shown at its frame’s own size from the top left, not stretched to the stage: while a program has not drawn at a new size yet, its last frame stays where it was.
tests/stuck-input.mjs: KB-A, KB-B (fail on the page before), KB-C (a keyboard that takes room
still resizes both ways). Still Android’s own: the bar’s slide in and out, and One UI moving the
whole screen for a few frames as it goes.
Review fixes (template 2)
To check on the tablet: an app built from template 2 (
lindroids app install, lindroids export <id>); a window left in the background while Android kills its app (am kill org.lindroids with
LINDROIDS_BACKGROUND=60 set for the server): its program still runs after the limit, and the
server log says the app answered that the card is in Recents; the card swiped away then: the
program ends; a game or wev with a held key on a hardware keyboard.
What Android allows, and what we assume
Checked on the tablet unless marked assumed:- An app may start an activity of an app it cannot see (explicit intent); it may not look it up. Starting needs the caller in front (or another background-start exemption).
- Android’s installer acts only for an app that declares
REQUEST_INSTALL_PACKAGES; the user’s “install unknown apps” switch is per source app. My Files, Chrome, Samsung Internet and Firefox declare it on the tablet. - An app targeting Android 11+ may create files in
Download/by path without any storage permission. Assumed to hold on other vendors’ Android 11+ builds. - Play Protect’s prompt for unknown apps is shown by
com.android.vendingon devices with Google Play; assumed absent on devices without it (LineageOS, Huawei, Fire). - Google’s developer verification (2026–2027, see BROWSER_AGNOSTIC_APK.md): apps signed by an unregistered key will need the user’s one-time “advanced flow” or ADB once it covers all installs. Untested: it was not enforced on the tablet on 2026-10-29.
- The per-phone key: if Termux’s data is cleared, the key is gone and the installed apps can only be uninstalled and installed again (Android refuses an update signed by another key).
Still to test
- The browser route (a terminal app that may neither install nor write to Download).
- Other launchers’ and vendors’ Recents (Pixel, Launcher3), Android 7–10 (minSdk 24).
- An app installed but never started, then started from Termux (
lindroids launch).