diff --git a/docs/android-apk-architecture.md b/docs/android-apk-architecture.md index bfe7822..4954fb1 100644 --- a/docs/android-apk-architecture.md +++ b/docs/android-apk-architecture.md @@ -308,7 +308,8 @@ that work also needs the session manager. - **T5.2-T5.6**: Integration testing on device (Alpine, Debian, Ubuntu, backup/restore, isolated mode) - **Build test**: APK has not been built with termlib integration yet — Gradle sync + `assembleDebug` needed to verify module resolution - **Background sessions**: keep a session running after leaving its screen, with a foreground - service and a user choice between ending and keeping it on Back (see "Session lifetime and Back") + service and a user choice between ending and keeping it on Back (see "Session lifetime and Back"). + Design: `docs/design/background-sessions.md` - **Compose theme**: Currently uses default Material3 — should adopt app branding (#1a1a2e navy, #e94560 red) - ~~**Terminal resize**: TerminalActivity does not yet observe Compose layout size changes to call `session.resize()` + `emulator.resize()` — terminal starts at 24x80 regardless of screen size~~ **Resolved**: `onResize` callback added to `TerminalEmulatorFactory.create()`; forwarded to `session.resize()`. diff --git a/docs/design/background-sessions.md b/docs/design/background-sessions.md new file mode 100644 index 0000000..acc86df --- /dev/null +++ b/docs/design/background-sessions.md @@ -0,0 +1,217 @@ +# Design Spec: Background Sessions + +**Date:** 2026-10-17 +**Topic:** Keep terminal sessions running after leaving their screen +**Baseline:** tag `pre-background-sessions` (main `86d9e93`, before this work) + +## 1. Goal & Context + +A terminal session (the shell `pr-cli login` starts in a container, and everything started from +it) is owned by `TerminalActivity` today: the PTY, the `TerminalEmulator` holding the screen, and +the reader thread. Closing the screen ends the session; Back runs `cleanup()` (see +`android-apk-architecture.md`, "Session lifetime and Back"). Because pr-cli starts proot with +`--kill-on-exit`, nothing started in the session outlives it. + +The main use is a long-running server, the Hermes gateway, that must keep working with the screen +off for hours. Ordinary terminal multitasking (leave a build running, come back later) comes with +it. + +Success means: a session keeps running after leaving its screen and after swiping the app away +from recents, survives deep sleep when the user asks for it, can be reattached with its screen +contents, and ends only when the user ends it or its program exits. + +## 2. Decisions + +| Question | Decision | Alternatives considered | +|----------|----------|-------------------------| +| Main goal | Hermes gateway 24/7 first; covers multitasking | Multitasking only | +| Sessions per container | One; open reattaches. More shells via tmux inside the container | Several, with a session list or tabs | +| Who owns sessions | An app-wide `SessionManager`; a thin foreground service keeps the process alive (approach B) | A: a bound service owns the sessions. C: sessions in a separate process with IPC | +| Back | Prompt every time: End session / Keep running / Cancel | Keep running plus a setting; End plus a separate Detach action | +| CPU in deep sleep | "Keep CPU awake" toggle in the notification, off by default, not remembered | Always hold a wake lock; no wake lock | +| Service type | `specialUse` | `dataSync` (Android 15 limits it to 6 hours a day) | +| Session ended while away | Post a "session ended (exit N)" notification | Silent | + +Approach B follows Android's "Guide to app architecture": activities and services are entry points +the system can destroy, and state lives in a layer they use. Reliability is the same as A, since +both keep sessions in one process kept alive by the same foreground service; B avoids a service that +is both started and bound, and keeps the session logic testable on the JVM. + +## 3. Components + +### `SessionManager` (new, app module) + +Owned by `App`, lives as long as the process. Holds at most one `ManagedSession` per container +alias. + +- `open(alias): ManagedSession` returns the running session for the container, or starts one + through `ProotLauncher.startSession`. A failure to start is returned to the caller, not thrown + past it. +- `end(alias)` and `endAll()` close sessions. +- `sessions: StateFlow>` (the aliases of running sessions) for the list screen + and the notification. +- When the number of running sessions goes from 0 to 1 it starts `SessionService`; from 1 to 0 it + stops it. This is the only place the two are kept in step. +- All changes to the session map are serialised: concurrent `open` and `end` for one container + yield either the old session or a new one, never two. +- Holds only the application context. + +Seams, so the logic runs in JVM unit tests (the real emulator uses native code): + +- a session starter (real: `ProotLauncher`; tests: an in-memory fake); +- an emulator factory (real: `TerminalEmulatorFactory`); +- a foreground controller (real: starts and stops `SessionService`). + +### `ManagedSession` (new, app module) + +What `TerminalActivity` holds today, moved out of it: + +- the `ProotLauncher.Session` (PTY master fd, child pid, reaper); +- the `TerminalEmulator`, which keeps the screen contents while no screen is attached; +- the reader thread, which keeps draining PTY output with no screen attached, so a program that + writes a lot never blocks on a full PTY buffer. + +It exposes `write`, `resize`, `attached` (whether a screen shows it) and the exit state. When the +child exits it records the exit code and notifies the manager. The manager stops counting it as +running at once, and drops it when no screen shows it: immediately if none is attached, otherwise +when that screen closes, so the screen can still show the exit code. + +### `SessionService` (new foreground service, type `specialUse`) + +Holds no session state. + +- Keeps the process alive while at least one session runs; returns `START_NOT_STICKY`, and stops + itself if started with no sessions. +- Owns the ongoing notification and its actions. +- Holds the partial wake lock while "Keep CPU awake" is on. + +### `TerminalActivity` (changed) + +A view onto a session. It calls `SessionManager.open(alias)`, draws the session's emulator, and +forwards keyboard input and resizes. Closing the screen detaches; it never ends the session. Back +keeps its current order: the first press hides the keyboard if it is up, the next clears a text +selection if one is active, and after that it shows the End session / Keep running / Cancel +prompt. If the session has ended, Back returns to the list without a prompt. + +### `MainActivity` (changed) + +Containers with a running session show a "running" mark; tapping one reattaches. It shows the +banners described in sections 5 and 6. + +### Unchanged + +`ProotLauncher`, `PtyNative`, `ptyjni.c`, pr-cli and proot. + +## 4. Session Lifecycle + +| Event | Behaviour | +|-------|-----------| +| Open a container with no session | `SessionManager.open` starts the shell; 0 to 1 starts the service and its notification; the screen attaches | +| Open a container whose session runs | The screen reattaches, shows the current contents, and resizes the session | +| Back | Keyboard, then selection, then the prompt. End closes the session; Keep running returns to the list; Cancel stays | +| Home, or switching apps | No prompt; the session keeps running | +| Swipe the app away from recents | Screens are destroyed; the foreground service keeps the process, so sessions keep running | +| The program exits (`exit`, crash) | The session is marked ended with its exit code and no longer counts as running. An attached screen shows "Session ended (exit N)", Back returns without a prompt, and the session is dropped when the screen closes; with no screen attached it is dropped at once and a "session ended" notification is posted | +| "End all" in the notification | Every session ends as with End | +| Last running session ends | The wake lock is released, the service stops, the ongoing notification goes away (even if a screen still shows an ended session) | +| The process is killed (memory, crash, app update) | All sessions end with it; nothing is restored and the service is not restarted | +| Phone reboot | Nothing restarts (start-at-boot is out of scope) | + +Only End (prompt or notification) and the program exiting end a session; a screen closing never +does. + +## 5. Notifications, Service Type and Permissions + +### Manifest + +- `FOREGROUND_SERVICE` (present), `FOREGROUND_SERVICE_SPECIAL_USE`, `WAKE_LOCK`, + `POST_NOTIFICATIONS`, `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`. +- `SessionService` with `android:foregroundServiceType="specialUse"` and a + `PROPERTY_SPECIAL_USE_FGS_SUBTYPE` property describing the use (user-started Linux terminal + sessions). On Google Play this needs a justification; `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` is + restricted there too. Sideloaded builds are not affected. + +### Starting the service + +`SessionManager.open` starts it while the user is opening a terminal, so the app is in the +foreground (Android 12+ forbids starting foreground services from the background). +`SessionService` calls `startForeground` at once. + +### Notification permission + +Requested at runtime (Android 13+) the first time a session starts. If denied, the service still +runs and sessions still survive; only the notification is hidden. The app does not ask again. + +### Channels + +1. **Running sessions**: low importance, silent, ongoing. Title "N sessions running", text the + container names, plus "CPU kept awake" when the toggle is on. Tapping opens the list. Actions: + **End all**, and **Keep CPU awake** / **Let CPU sleep**. +2. **Session ended**: default importance. "alias: session ended (exit N)"; tapping opens the list. + +## 6. Wake Lock and Battery Optimization + +- "Keep CPU awake" holds a `PARTIAL_WAKE_LOCK` in `SessionService`; the screen can turn off. +- Off by default and kept in memory only: each time sessions start from zero it is off, so a + forgotten toggle cannot drain the battery for days. Released when toggled off or when the last + session ends. +- A wake lock is not enough for an app that is battery-optimized (Doze, and vendor "sleeping apps" + lists). When the toggle is turned on and the app is not exempt, the notification text adds + "Battery optimization may still pause sessions", and the list shows a banner with **Allow**, + which opens the system's ignore-battery-optimizations dialog. The dialog opens from the app + screen, because Android 12+ blocks starting activities from notification actions routed through a + service. + +## 7. Error Handling + +| Failure | Behaviour | +|---------|-----------| +| Session cannot start (pr-cli missing, PTY fork fails) | The terminal screen shows "Couldn't start alias: reason"; nothing is recorded; the service is not started | +| The foreground service cannot start | The session runs while the app is open but is not protected; the list shows "Background running unavailable: reason"; no crash | +| Notification permission denied | Everything works; the notification is hidden; no repeated requests | +| Reader thread error or PTY hangup | Treated as the session ending; the exit code comes from the reaper | +| Input or resize for an ended session | Ignored | +| The system restarts the service | `START_NOT_STICKY`; with no sessions it stops itself | +| End and open race on one container | Serialised by `SessionManager` | +| The process is killed | Sessions end; documented, not handled | + +## 8. Testing + +### JVM unit tests (app module gains a JUnit setup) + +`SessionManager` with fake seams: + +- one session per container; `open` on a running container returns the same session; +- the service starts at 0 to 1 and stops at 1 to 0, through End, End all and program exit; +- the "session ended" notification fires only when no screen is attached; +- concurrent open and end for one container never produce two sessions; +- output keeps being drained with no screen attached. + +### Existing suites + +proot-engine instrumented tests (8), `pr-cli test alpine` and `pr-cli test ubuntu`, the full +integration suite from an in-app terminal, and the 169 host tests stay green. + +### Device lifecycle checks (adb; results recorded in the PR) + +1. A counter writing a timestamp every second; Keep running; it keeps counting. +2. Reattach shows the screen contents. +3. Swipe the app away from recents; still counting. +4. `dumpsys battery unplug` and `dumpsys deviceidle force-idle`: with Keep CPU awake on, the counter + continues; with it off, record what Doze does. +5. `exit` with no screen attached posts the "session ended" notification. +6. End all ends every session and removes the ongoing notification. +7. With the notification permission denied, sessions still survive. + +### Target check + +The Hermes gateway in the `hermes` container, Keep running, Keep CPU awake on, forced deep sleep: +it keeps answering from the host through `adb forward` for at least 30 minutes. + +## 9. Out of Scope + +- Several sessions per container (use tmux inside the container). +- Starting sessions or the gateway at boot. +- Restoring sessions after the process is killed. +- Remembering the "Keep CPU awake" state across session starts. +- Sessions in a separate process.