Skip to main content
How a Linux program started in Termux (or a proot distro, or a Flatpak) gets a window on Android, how that window and the program find and trust each other, and how they live and die together. This is the browser Custom Tab path, the fallback (and config browser default). By default on Android a small app of ours shows the same pages in Android’s WebView, without a browser: BROWSER_AGNOSTIC_APK.md. Claims marked (tested) were checked on the lab VM b16 on 2026-10-27:
  • BlissOS 16 (Android 13), with Termux 0.118.3 from F-Droid.
  • Browsers: Cromite, Firefox 158 and Jellyfish.
  • The probes and raw logs are in lab/browser-window/.
  • lab/test-lindroids.sh b16 passes with SELinux permissive and enforcing (lab/README.md).

What is wanted

  1. Everything lives inside the container: data, programs, the relay and the window’s page. The container is Termux’s prefix, a proot distro or a Flatpak. One package, installed there.
  2. Nothing else on Android:
    • no root;
    • no APK required (the browser path; the window app is now the default on Android);
    • no Termux setting (allow-external-apps);
    • no special permission;
    • nothing that depends on an Android version or vendor.
  3. Every program gets a window of its own: not a tab, no browser interface, not something installed through the browser. Lindroids opens and manages it.
  4. The program’s icon is used.
  5. Single run: lindroids run X opens X’s window. The window and the program end together.
  6. Export: lindroids export X puts X on the home screen. Tapping it works even when Termux is not running.

What Android allows

On Android, every window on screen is an Activity, and every Activity belongs to an installed app. A shortcut is only an intent and draws nothing. So some installed app draws the window. Without an APK of ours, that leaves Termux, which is installed, and the browser, which every phone has. Everything else was tried:

The window: a Custom Tab, opened by Termux

A Custom Tab is the browser’s own mechanism for showing a web page as part of another app. Any app opens one with an ordinary intent that carries a Custom Tabs session extra; no permission and no setting are involved. am cannot put that extra (a Binder) into an intent, so Lindroids sends it from its app_process helper, as Termux, the way export pins shortcuts (crates/lindroids-cli/shortcut/Shortcut.java, open; first tried with lab/browser-window/Open.java). (tested with Firefox)
  • Its own window. With NEW_TASK | NEW_DOCUMENT, every Custom Tab for another URL is a task of its own (ExternalAppBrowserActivity). Each run has its own URL, so each program gets its own window, side by side in recents.
  • No browser interface after the first tap. The window shows only a thin bar: close, title, menu. No tabs, no address bar. On the first tap, the page asks for full screen and gets it: nothing of the browser is left, not even the status bar. Firefox briefly shows “Drag from top & use back gesture to exit”.
  • Raising a window. Sending the same intent again (the URL it opened with, ticket and all; no MULTIPLE_TASK) brings that window’s task to the front. Chromium does not: its Custom Tabs always take a new task, so a second window opens, takes over the run, and the old one closes itself (tested with Cromite). Firefox also loads a second copy of the page nobody sees, which the relay tells to stand down (see The handshake).
  • Closing by the user. The close button, or swiping the window from recents, gives the relay a clean pagehide and WebSocket close 1001, and the task goes away.
  • Back does not close it. The browser takes the window out of full screen on Back and closed it on the next one, and its program ended: on a phone, Back is a swipe in from the screen’s edge, which users make to leave, and by accident while using the program (reproduced on b16: Back twice from full screen, the program ended 5 s later). While its program runs, the page takes Back (CloseWatcher, Chrome 120 and later) and shows once in a while that Home or the app switcher leaves the program running and ✕ closes it. When the program has ended, Back closes the window again (tested, Cromite: Back three, four and six times, fast or slow, the window and the program stay; lab/test-windows.sh). Chrome may let a Back through when the page was not touched since the last one; that closes the window as before.
  • Closing by the program: Chromium yes, Firefox no. In Chromium’s Custom Tabs the page’s window.close() ends the window when the program exits (tested with Cromite). It is ignored in a Firefox Custom Tab, even a fresh one, and nothing outside Firefox may finish its activity: there the window shows the program’s end screen, and Back closes it.
By browser: Lindroids tries, in order:
  1. the browsers named in LINDROIDS_BROWSER (comma-separated packages);
  2. the default browser, if it is one of these and not Firefox;
  3. Chrome, Edge, Brave, Vivaldi, Samsung Internet, Chrome Beta, Chromium, Cromite, then Firefox and its beta and nightly builds. Chromium’s windows show the program in Recents and close themselves; Firefox’s cannot (below), so it comes last even as the default.
It uses the first one that opens a window of its own (WINDOW_BROWSERS in crates/lindroids-cli/src/shortcut.rs). Custom Tabs are not taken for granted: the helper starts the window with startActivityAndWait and reads which activity the browser opened. A browser that opens an ordinary tab instead (Cromite without Allow custom tab intents, WebView-based browsers) is passed over; the page in that tab closes itself when the window in the next browser links, because each attempt’s URL names its browser (&via=<package>) and the server keeps the newer browser’s page (tested: Cromite with the setting off → a Firefox window, the Cromite tab gone). If no browser opens a window, the first ordinary tab is kept. That answer used to be lost: Android 11+ makes every process sample the app ops its calls note, puts them before the reply, and reading them throws outside an app (SyncNotedAppOp). The helper turns the sampling off (AppOpsManager.sConfig, a config that never expires) and gets whole replies.

Full screen: from the first input, not before (tested 2026-10-28, b16)

A window opens with the browser’s thin bar and Android’s status and navigation bars, and takes the whole screen on the user’s first input. Nothing tried makes it full screen before that, in Cromite 153 or Firefox 158 Custom Tabs, opened from Termux’s uid like Lindroids’ windows (lab/browser-window/fsprobe.py for the page, FsOpen.java for the intent, lab/test-fullscreen.sh for Lindroids’ own windows): So the page asks on the first input browsers allow, anywhere in the window except its own dialogs and toasts: a touch’s end (pointerup; touchend and click as fallbacks), a mouse button’s press, a plain key on a hardware keyboard. A refused request is asked again on the next input, and after the window comes back on screen. The page traces “opened out of full screen” and which input took it (“full screen asked on pointerup”) into the server’s log (lab/test-fullscreen.sh on b16 with the page from before this change: Cromite and Firefox open out of full screen, one tap in the middle takes it). A tap on the browser’s own bar does not reach the page. Not verified here: Chrome and Samsung Internet (no Play Store on the lab images), and phones in portrait, where Chromium’s bar may hide on a finger scroll.

The program’s icon

The Recents icon on One UI: every route, and why none works without an APK

Re-examined 2026-10-28 (lab/test-recents.sh, b16 with Launcher3, Cromite 153, Firefox 158; neither Chrome nor One UI is in the lab), and again from the terminal app’s side (ROUTES_ONLY=1 lab/test-recents.sh, lab/browser-window/Route.java: run with the terminal app’s uid, it takes the package from the uid, so it applies as it is to Termux’s forks or any other terminal app). The terminal app is an installed package, but outside its own process it cannot act as one in the activity manager: what decides a card (the task’s root, the TaskDescription) is open only to a process Android started for the app (an IApplicationThread) or to an activity’s token, and its app_process helper has neither. A launcher draws a card from what Android keeps for the task: the TaskDescription (label, icon) its activities set, and the components it runs (root and top activity). It can draw either the TaskDescription icon or an installed component’s icon; nothing else is linked to a task (a pinned shortcut’s icon is not). One UI draws the program’s name on Chrome’s windows (reported), and that name comes from the same TaskDescription, set in the same call as the icon by CustomTabTaskDescriptionHelper. So One UI most likely gets the program’s icon and draws the root activity’s app icon instead (an inference: One UI’s launcher is closed source and not in the lab). Chromium hit the same thing with WebAPKs: “the icon shown in the recents is the application icon of the root activity in the task stack”, so it made the WebAPK’s own activity the task’s root (Chromium change 2762873003, 2017). Under One UI only the package at the root of the task decides the icon, which means an installed package with the program’s icon.

The parts

  • The relay (lindroids server):
    • One per uid, started on demand, detached (setsid).
    • Serves the page and the WebSocket on loopback.
    • Composites windows, plays sound and answers the desktop portal.
    • Keeps the launches (what run started, by pid), the tickets, and one link per page.
    • Stops by itself if its control socket disappears: Termux empties its tmp dir when it starts again, and an unreachable relay would hold the port for good.
  • A run is one start of one program. It consists of:
    • the supervisor;
    • the program and everything under it;
    • its Wayland and X11 windows;
    • the one Custom Tab that shows them.
    A run is named by the supervisor’s child’s pid.
  • The supervisor is lindroids run (single run) or lindroids launch (exported program).
    • It is the program’s parent and waits for it.
    • It holds the program’s stdio: the terminal for run, <runtime>/launch-<id>.log for launch.
    • It never carries pixels.
    • It exits with the program’s status, or 128 + the signal number if the program was killed.
  • The window is the page at /?window&run=<pid>, in a Custom Tab of its own. The program’s toplevels and dialogs are drawn inside it, one tab each in the page’s own tab bar. Programs started on a desktop, or where no browser has Custom Tabs, share the plain page (/) as before.

Stdio or a process?

The program is an ordinary process, not a stream. run forks it with its environment pointing at the relay’s sockets in the runtime dir: WAYLAND_DISPLAY, DISPLAY, PULSE_SERVER and DBUS_SESSION_BUS_ADDRESS. It is not moved to a process group of its own, so one that reads the terminal does not stop on SIGTTIN. Its stdin, stdout and stderr stay where a desktop would put them:
  • For run, the terminal, so lindroids run gimp behaves like gimp.
  • For launch, a log file.
The relay ties windows to runs (icons.rs, x11.rs):
  • A Wayland client belongs to the launch it descends from. The relay starts from the client’s pid (SO_PEERCRED) and walks up /proc/<pid>/stat. This works under proot, whose tracees are real processes (tested: Xournal++ in a proot Debian).
  • An X11 window comes through xwayland-satellite, a single client of the relay. For each of satellite’s toplevels, the relay asks Xwayland which X client owns the window with that title and class (X-Resource, XResQueryClientIds), and walks up from that pid (tested: xclock got its own window). This runs on a thread with a 500 ms limit, because Xwayland reaches the compositor through satellite. It is only asked about satellite’s windows, so asking never starts X11.
  • A Flatpak that asks for X11 and not Wayland (sockets=x11 in its metadata) runs the way flatpak runs it: on the X display only, with SDL, GTK and Qt set to X11 and no WAYLAND_DISPLAY. Some can only draw with X11: SDL 1.2 has no Wayland driver, so under SDL_VIDEODRIVER=wayland it stopped with “No available video device”. Without the integrated X display (Xwayland not installed: Termux’s package only recommends it, from the x11 repository) the run offers to install it in its terminal (lindroids setup x11), restarts the server and goes on; without a terminal it says so (tested: Tux Typing on b15, Android 12L). lindroids export asks the same when it exports such a program, so its icon does not fail later. A launch with no terminal (its page asked the server) hands itself to Termux the way the icon does (the launch URL shared with Termux runs lindroids launch in a session), where it asks.
  • No video mode switches. The X display has only the modes of the window it draws in. SDL 1.2 switched to its own full-screen mode with XF86VidMode, and a mode the display lacks is an X error that ends the program: Tux Typing stopped with BadValue on XF86VidModeSwitchToMode on a 1200x1920 portrait tablet (reproduced on b15 at that size). Programs on the integrated display get SDL_VIDEO_X11_VIDMODE=0 (SDL 1.2) and SDL_VIDEO_X11_XVIDMODE=0 (SDL 2): full screen is the window’s own size, which Lindroids scales anyway (tested: Tux Typing’s menu and a game at 1200x1920 and 1280x800).
  • Termux from Google Play runs every program in its storage through the system linker (/system/bin/linker64 <file>; termux-exec’s TERMUX_EXEC__SYSTEM_LINKER_EXEC__MODE=force does the same on another Termux). /proc/self/exe is then the linker, so a program that runs itself again through it hands the linker its first argument: Lindroids’ own server did not start (“error: expected absolute path: “server""), and proot’s helper for SysV shared memory printed error: expected absolute path: "--shm-helper", after which every shmget in proot fails. Lindroids finds its own file in TERMUX_EXEC__PROC_SELF_EXE there, and Xwayland runs without MIT-SHM, so X programs do not try shared memory (tested on b15 with the mode forced: Tux Typing, xclock, foot and GNOME Calculator, and export’s launcher).
  • A window that appears before its launch is registered moves to its run’s window once it is.

The origin

The page is served at http://<label>.localhost:<port>/:
  • <port> is fixed per uid (server_tcp_port: 47000 + the app id’s offset).
  • <label> is 26 base32 characters (130 random bits), made once and kept in ~/.local/share/lindroids/origin (0600).
Why a .localhost name:
  • Every browser treats it as loopback and as a secure context (tested). Cromite, Firefox and Jellyfish all resolve k7f3q9.localhost to 127.0.0.1, report isSecureContext, and offer WebCrypto. No TLS is needed.
  • The origin becomes a capability. Browser storage belongs to an origin, which includes the port, and any app can bind a free loopback port. With the plain 127.0.0.1:<port> origin, an app that binds the port while the relay is down and sends the browser there runs its own page in our origin. It can then read or use what the page stored. With an unguessable label, it can only serve an origin of its own.
  • Anything without the label gets nothing, not even the label: the relay answers 421 wrong host (tested: curl 127.0.0.1:<port>).
    • It answers only requests whose Host is exactly <label>.localhost:<port> (case aside).
    • It upgrades a WebSocket only if its Origin is exactly http://<label>.localhost:<port>.
    • Web sites and DNS rebinding are kept out by the same checks.
The label stays the same for good, so that windows find their origin’s storage again. It is not a secret from everything: it is in the browser’s history. Worse, a page that tries to reconnect while the relay is down sends it to whatever holds the port then. That is why the keys and run tickets below exist.

The bridge and its security

Transport: no TLS, no message encryption

The link is HTTP/1.1, plus one WebSocket per window, over loopback TCP.
  • TLS is not available. For a local name, the browser would accept only a certificate from a CA the user installs in Android’s settings, which is a special setting.
  • TLS would protect against nobody. Reading loopback traffic or injecting into an established loopback connection takes root, and root or adb can already read Termux’s files and keys.
  • Encrypting each message in the page is rejected for the same reason. AES-GCM over WebCrypto is possible, but it costs CPU on every frame and adds no protection.
What the link needs is for both ends to prove who they are, at the start of each connection. A loopback TCP connection cannot be taken over once established.

Threat model

What is left: such an app’s page could link as the shared page (/), which shows what was started without a window of its own. On Android every run has its own window, so that is nothing. On a desktop, it is the programs shown in the browser tab.

Keys and tokens

Why P-256 and not Ed25519: every browser with WebCrypto has ECDSA P-256. Ed25519 arrived only recently. On the wire, public keys are uncompressed SEC1 points and signatures are r‖s (what WebCrypto makes), both base64url. The Rust side is RustCrypto’s p256. Why key pairs instead of the shared HMAC secret Lindroids used before:
  • The display’s private key never exists in readable form, not even for script in our own origin.
  • The relay keeps only public keys, so its files can’t be used to pose as a display.
  • No long-lived secret travels in URLs.
  • Each browser is listed and forgotten on its own (lindroids displays [forget <id>]).
How tickets reach the page:
  1. The CLI asks the relay for a ticket over the control socket. The socket lives in the 0700 runtime dir, and the relay also checks the caller’s uid with SO_PEERCRED.
  2. The CLI puts the ticket in the URL fragment. The helper sends that URL in an intent addressed to the browser package, which other apps cannot see.
  3. Browsers never send the fragment over HTTP. The page removes it from the address bar at once with history.replaceState, and keeps it in that history entry’s state: a page the browser loads again (after dropping it in the background, or a reload) attaches to its run with it. No other page can read another window’s history state.
Run tickets are not single use, on purpose. The same run must get the same URL every time, because the window is found again by its URL (raising it, and Firefox restoring it). A run ticket is useless once its process is gone, and the relay forgets them all when it stops.

The handshake (tested; protocol 5)

These are the first messages on every WebSocket (lindroids-proto: ToServer, ToApp, auth.rs). origin is the page’s origin (empty for debug-app, which is not a page), and ‖ stands for a NUL separator.
One window per run. When a second page links for a run whose window is still linked, the new page stands down with Bye (tested). This is because bringing a window back sends its intent again, and Firefox then raises the old window and loads a second copy nobody sees. The shared page keeps “newest wins”.

Lifecycles

Run states

When the window goes (the grace is over):
  1. The relay asks the run’s windows to close (xdg_toplevel.close), as a desktop’s close button does: a program saves its state then (Electron’s SIGTERM handler does not wait for that).
  2. 3 seconds later, the relay sends SIGTERM to the roots of the run’s process tree, seen through proot (proot does not pass SIGTERM on: its tracee, the program, gets it), so that a program’s main process ends its children itself. Before, every process got SIGTERM, children first, and Electron’s renderers died before its main process could save.
  3. The relay sends SIGKILL to whatever is left 5 seconds later, every process it saw in the tree. A program under proot is proot’s tracee and outlives proot otherwise (tested: Xournal++ was left running before this).
  4. The supervisor’s wait returns, and it exits with 128 + the signal number (0 if the program closed when asked).
The same three steps end every run when the server stops (lindroids stop), 2 s and 3 s apart. When the program goes (and only then: a program with no window open for a while, a dialog in its place or a window it hides, still runs, and its window says so):
  1. The supervisor (lindroids run, a subreaper: it adopts what the program detached) ends what the program left behind: SIGTERM to the roots of what is left below it, SIGKILL 3 s later. Left running, a detached GUI held on to what the next launch needed: VS Code’s single-instance socket handed that launch to it, and its window went to another run’s page (seen on the tablet: VSCodium’s whole tree running, no window anywhere). Not while another run shows a window of it (a second VSCodium handed its work to it, and claimed the window it opened): those windows count as the run’s, and what it left ends with the last such run.
  2. The supervisor tells the server (RunEnded) and exits with its status. A launcher that detached its GUI and exited (VS Code’s CLI) is followed to the GUI’s end: its processes end, or it has had no window at all for 5 seconds (a window it closes to open another is not its end).
  3. The server ends any process of the run that is still there: it notes the run’s processes every 2 seconds while it runs, so a GUI is found even when its supervisor was killed (Android’s phantom process killer) and the run is lost. Before the next launch, whatever of an ended run is still there gets SIGKILL.
  4. The page shows the program’s icon and “<name> closed. The window closes with the program.”
  5. It also calls window.close() after 1.5 s, in case a splash window gave way to the real one. Browsers that allow it close the window; Firefox’s Custom Tabs ignore it. In a Lindroids window app the page closes its task (lindroids.close()), and the server has the app close it too (lindroids window-end <run>: an org.lindroids.END broadcast to that app alone, with the run’s ticket), for a page that cannot: frozen in the background, or its process gone while its card stays in Recents. A card left by a run that ended anyway goes when the next run’s window opens (the app asks the server which of its cards’ runs still run, by their tickets).

What the relay can see (tested)

The grace period. A lost connection is ambiguous: the close code does not tell a closed window from a killed page (Chromium sends 1001 for both). What does is whether the page was on screen: the page tells the relay when it leaves the screen and comes back (Shown).
  • Out of sight for 3 s or more, and lost without a word: the browser dropped it in the background. Phones do this all the time: the user switches to another app, and Android kills the browser’s page process, or the whole browser, for memory. When the user comes back to the window, the browser loads the page again, and the page links to its run again (the ticket is in its history entry). The program runs on until then, for 10 minutes at most (LINDROIDS_BACKGROUND, in seconds): its notification brings the window back. In a Lindroids window app, the app is asked 3 s after the drop and after each limit (below) whether the window’s card is still in Recents: Android kills the app’s process for memory in the background and keeps its card, and a tap on the card brings the page back. While it is there the run is kept, for the same time again. Before, it ended 5 s later, and the window, back, said it had closed (reported on a Samsung phone; reproduced on b16 by killing Cromite’s page process). Before this round it had no limit at all, and a window swiped away after its page was dropped left its program running for good, with no window anywhere (the tablet: VSCodium’s whole tree under Termux, no Lindroids task).
  • Closed by the user: Closing (pagehide: the close button, swiped from Recents with the page alive) or 1001 on screen: 5 s. In a Lindroids window app, also its task removed from Recents, said by the app itself (below): 5 s.
  • Lost on screen (the browser crashed): 60 seconds (LINDROIDS_LINGER, in seconds).
The window app says when its window goes. A page in the background is often frozen or gone, and cannot say anything. The app can: the page tells it which run it shows and the run’s ticket (lindroids.attached), and the app keeps that per task. When the user removes the task from Recents, Android tells the app’s CloseWatch service (onTaskRemoved); when the activity is finished for good, its onDestroy. Either asks the server GET /run/<pid>/closed over loopback with the ticket (header X-Lindroids-Ticket), and the server treats it as Closing. CloseWatch is a plain started service (no permission, no notification); Android stops it about a minute after the app leaves the screen, and each window starts it again when it comes back. A window swiped away after that, with its page dropped, is the one case left to the 10-minute limit. Asked about a window dropped in the background. When the limit is over for a run whose window went to a Lindroids app, the server runs lindroids window-ask <run>: the helper (as Termux, which needs no permission for it) sends org.lindroids.ASK to that app alone, with the run, its ticket and the server’s host:port. The app’s End receiver (Android starts the app’s process for it) looks at its tasks (getAppTasks, as for END) and answers over loopback with the ticket: /run/<pid>/kept when a task shows that run with that ticket (the run is kept, and the app asked again after the limit), /run/<pid>/closed when none does (as a removed task: the run ends 5 s later). No answer within 15 s (the app uninstalled or disabled, or older than template 2), and a window in a browser: the run ends, as before. Tasks are known by their run and ticket (not by their URL without the ticket: a card left by an ended run whose pid a new run got would answer, or say closed, for the new run). A window whose page links again says it closed again later. The page links again by itself when its link is lost while its program runs, also unseen in the background (tested: a dropped link, a reload, both relinked to the running Calculator). It says “<name> closed” only when the program has ended: the server said so (RunEnded), it refused the window’s ticket on the way back (a run’s ticket goes with its process), or the server cannot be reached (the program’s display went with it). A refused ticket is always the end, also for a window Android rebuilt from Recents that never linked on this page: before, only a window that had linked before took it so, and a rebuilt one sat on “This window was not opened by lindroids run” for good. A program’s own app goes back to its start page instead, which starts the program again. Before, a window took any lost link for the end, and said so while its program still ran in Termux (reported: Chrome on a Samsung phone, after leaving the window and coming back). Each window traces what happens to it into the server’s log (shown and hidden, full screen, pagehide, its link closing and how, its program’s last window going, the end it shows, closing itself), with what it noted while unlinked sent once linked again. lindroids log prints that, and how each page left (below), with the time of day: what a phone’s browser does can only be seen there. lindroids log --copy puts it (the last 200 KB) on Android’s clipboard instead, to paste into a bug report (--all for the server’s whole log). The page logs how the link was lost (linked again after the browser dropped the link (code …, hidden|shown)) and the server how each page went (close 1001, no close frame…), in <runtime>/server.log. No heartbeat. A dead browser is reported over loopback at once (EOF). And browsers freeze pages in the background, so a heartbeat would take a window the user left in the background for a dead one and end its program.

Single run: lindroids run X (tested: Mousepad, xclock, foot, Flatpak Calculator and VSCodium, Xournal++ in proot Debian)

  1. Make sure the relay is up.
  2. Fork the program, keeping the terminal as its stdio.
  3. Register it with the relay: name, icon, and “a window of its own”. A program that ends within a second (a wrong option, a crash on start) has said why in the terminal: it gets no window, and run reports its exit code (tested: wrong options to Mousepad and Calculator). A launch from a home screen icon still opens its window, which is where its error shows.
  4. Open the window:
    • Ask the relay for this run’s ticket.
    • The helper (shortcut.dex open, run with app_process as Termux) sends the Custom Tab intent for http://<label>.localhost:<port>/?window&run=<pid>#t=<ticket>.
    • The intent goes to the first installed browser of the list in shortcut.rs (LINDROIDS_BROWSER goes first).
    • The intent goes out while Termux is in the foreground, because the user just typed the command. Android 10 and later block activity starts from apps in the background.
  5. The page pairs (first time) or signs in, and attaches. It shows the program’s icon and name until the first window comes, then the program’s windows, and goes full screen on the first tap.
  6. Either end ends the run, as above. Ctrl+C in the terminal, or closing the Termux session, ends the program; its window then shows the end screen, out of full screen so that the browser’s ✕ shows (Back only sends a Firefox Custom Tab behind; ✕ removes it (tested)). A run started from a home screen icon ends with Termux’s session, server and all: the page cannot reach the server again, and takes that as the end of the program.
  7. While it runs, a notification stands for it (its icon in the status bar), posted by the helper as Termux; requests from the program (sound, file pickers, links, notifications) go to its own window, by pid → run.

Export: lindroids export X (tested)

  1. Write <state>/apps/<id>.json: name, icon, and the run arguments with the distro pinned.
  2. Pin a home screen shortcut with the program’s icon and name, for Termux, through the app_process helper. Its intent shares http://127.0.0.1:<port>/launch/termux/<id> with Termux’s share receiver, which needs no permission and no Termux setting. Exporting an id that was removed before works: the helper re-enables the shortcut first, because Android keeps a removed pinned shortcut disabled and refuses to pin it again.
No browser is involved in exporting. The browser install page and per-program web app manifests of commit fdaf800 are reverted. Tapping the icon, Termux running or not:
  1. Termux starts if needed, and its share receiver runs ~/bin/termux-url-opener. export installs that opener; it keeps the user’s own opener for every other URL.
  2. The opener runs lindroids launch <id> in a Termux session. That session keeps Termux’s foreground service, and with it the relay and the program, alive.
  3. launch is run with the export’s arguments. The window opens over Termux (allowed: Termux is in the foreground at that moment).
  4. When the program ends, launch exits. If that session was Termux’s only one, Termux closes itself.
Timings in the lab: the window was there 2 s after the tap, from a stopped Termux. Tapping the icon of a program that is already running brings its window back instead of starting another (tested: one Mousepad, the same window). launch asks the server for the program’s instances that run and raises the window of the one used last.

Keeping the relay alive on Android

Termux’s process survives in the background only while TermuxService is a foreground service, that is while a terminal session exists or Termux’s wake lock is held. Otherwise Android caches the process. From Android 11 on, it may freeze the cached process, with the relay and every program in it, or kill it. Lindroids needs no wake lock:
  • A single run is started from a terminal session, which exists while the program runs.
  • An exported program runs in the session the share receiver opened, until it ends.
(tested) The wake lock is the way out for runs that leave their session:
  • After a cold start through the share receiver, a session that exited took the service with it.
  • With termux-wake-lock taken first, the service stayed in the foreground (procState 4, OOM adj 50) with Termux’s window closed, and a detached child kept running.
One limit no app can fix: Android 12’s phantom process killer can kill an app’s children. It does so beyond 32 of them across the system, or for children busy in the background. Android 14 has a developer option to turn it off. Lindroids keeps its own process count low and reports signal 9 deaths as such.

The requirements, met

Ideas to make it better

These have not been tried yet.
  • Stop from the running program’s notification. It has the program’s icon and a tap that brings back the window (done). A Stop button needs a way back from Android to the relay without Termux coming to the front (a PendingIntent can only start Termux’s activities).
  • Chrome. Chromium’s Custom Tabs are tested through Cromite; Chrome itself only on the user’s Samsung phone so far.
  • An app of its own, opt-in. lindroids export --webapk has Chrome (with Google Play services) or Samsung Internet (on Samsung phones) install the program as a WebAPK, whose task Android shows with the program’s icon on every launcher, Samsung’s too. Only those two browsers can, so it is never the default and errors where neither is installed: WEBAPK.md. Not yet tried on a phone with Chrome.
  • Closing Firefox’s window from outside. Its source offers no way: Gecko does ask to close a one-entry window, but only normal tabs act on it; ExternalAppBrowserActivity ends itself only when its tab is gone, and only its ✕, “Open in Firefox” or its own end remove that tab (the activity is not exported). Every Custom Tab intent it receives gets a new tab, and it strips FLAG_ACTIVITY_CLEAR_TASK from them: re-sending the window’s intent with it left the window as it was (tested, Firefox 158). Its Recents icon is Firefox’s for the same reason: only Firefox’s installed web apps (https only) set a task’s icon.
  • app_process exits 137 after the helper starts an activity (the activity does start; the helper prints its result first). Find out why.