From 6d9fdeeba0ba56b4b326be8f48391f3344469f1f Mon Sep 17 00:00:00 2001 From: GlobalTechInfo Date: Thu, 8 Oct 2026 18:46:05 +0000 Subject: [PATCH 01/78] Add SFTP, fix backup and restore, and theme the terminal for v2.1.0 The headline is SFTP: a file browser for saved SSH servers, speaking the protocol directly over the bundled OpenSSH client so nothing needs to be installed and no separate sftp binary ships. Upload, download, search, permissions, symlinks and free space, with progress in bytes because the chunking happens on this side. Backup and restore now work on every distro. Three separate faults: - toybox tar has no case for a unix socket, so one socket in /run aborted the whole archive. The tree is now walked first and an explicit list is handed to tar with --no-recursion. - a rootfs ships files only root may read, and the app does not run as root, so /etc/shadow and friends were silently omitted from the backup. Owner bits are repaired before planning, and anything still unreadable is reported rather than dropped. - hard links are restored as independent copies. Arch and Manjaro carry several hundred in ca-certificates, link() failed with EPERM, and the restore stopped there. Void only appeared safe by coincidence. Restore is done in-process rather than by the device's tar, so the mode and mtime in the archive are applied. Without the mode every restored file came back 0644 and the distro could not execute anything. The terminal is themed like the rest of the app: a 259-entry palette derived from the theme, re-applied on attach and on every theme change, across all terminal views including split panes. The generated shell prompt no longer hard-codes a colour, so it follows the theme instead of going invisible in all but one of them. Distro logos on the install, home, settings and backup screens, with a lettered badge as the always-available fallback. Verified with 329 unit tests and lint clean apart from the three pre-existing Aligned16KB warnings. SFTP, backup and restore were exercised on device. --- .gitignore | 7 + AGENTS.md | 408 ++++++ CHANGELOG.md | 68 + README.md | 66 +- app/build.gradle.kts | 9 +- app/src/main/AndroidManifest.xml | 14 + .../main/assets/distro-icons/almalinux.png | Bin 0 -> 4760 bytes app/src/main/assets/distro-icons/alpine.png | Bin 0 -> 2425 bytes app/src/main/assets/distro-icons/arch.png | Bin 0 -> 2552 bytes app/src/main/assets/distro-icons/debian.png | Bin 0 -> 2468 bytes app/src/main/assets/distro-icons/fedora.png | Bin 0 -> 2052 bytes app/src/main/assets/distro-icons/kali.png | Bin 0 -> 6803 bytes app/src/main/assets/distro-icons/manjaro.png | Bin 0 -> 796 bytes app/src/main/assets/distro-icons/opensuse.png | Bin 0 -> 1644 bytes app/src/main/assets/distro-icons/rocky.png | Bin 0 -> 2129 bytes app/src/main/assets/distro-icons/ubuntu.png | Bin 0 -> 2094 bytes app/src/main/assets/distro-icons/void.png | Bin 0 -> 2792 bytes .../main/java/com/redtermapp/RedTermApp.kt | 22 + .../com/redtermapp/distro/BoundedIconCache.kt | 83 ++ .../java/com/redtermapp/distro/DistroBrand.kt | 165 +++ .../com/redtermapp/distro/DistroIconStore.kt | 245 ++++ .../com/redtermapp/distro/DistroInstaller.kt | 186 ++- .../com/redtermapp/distro/DistroRunner.kt | 34 +- .../java/com/redtermapp/distro/DistroSetup.kt | 7 +- .../java/com/redtermapp/distro/ProotLaunch.kt | 24 +- .../com/redtermapp/distro/RootfsArchive.kt | 278 +++++ .../com/redtermapp/distro/SessionRecorder.kt | 105 +- .../java/com/redtermapp/distro/ShellConfig.kt | 100 ++ .../com/redtermapp/distro/TarExtractor.kt | 424 +++++++ .../java/com/redtermapp/distro/TarMembers.kt | 63 + .../redtermapp/service/SessionKeepAwake.kt | 87 ++ .../com/redtermapp/service/TerminalService.kt | 121 +- .../redtermapp/ui/BackupManagerActivity.kt | 80 +- .../redtermapp/ui/BaseImageUpdateActivity.kt | 21 +- .../com/redtermapp/ui/DiskUsageActivity.kt | 2 +- .../java/com/redtermapp/ui/DistroBadge.kt | 107 ++ .../java/com/redtermapp/ui/DistroRestore.kt | 130 +- .../com/redtermapp/ui/FileBrowserActivity.kt | 670 +--------- .../main/java/com/redtermapp/ui/FlowLayout.kt | 218 ++++ .../java/com/redtermapp/ui/MainActivity.kt | 17 +- .../redtermapp/ui/PackageUpdateActivity.kt | 60 +- .../com/redtermapp/ui/RecordingActivity.kt | 94 +- .../java/com/redtermapp/ui/ScreenColors.kt | 63 +- .../java/com/redtermapp/ui/ScreenInsets.kt | 42 + .../java/com/redtermapp/ui/ScreenWidgets.kt | 103 ++ .../java/com/redtermapp/ui/SecretField.kt | 104 ++ .../com/redtermapp/ui/ServerExtrasView.kt | 259 ++++ .../com/redtermapp/ui/SessionDescriptor.kt | 137 ++ .../java/com/redtermapp/ui/SessionStore.kt | 49 + .../com/redtermapp/ui/SettingsActivity.kt | 93 +- .../com/redtermapp/ui/SftpServerPicker.kt | 76 ++ .../com/redtermapp/ui/SshConfigImporter.kt | 225 ++++ .../com/redtermapp/ui/SshCredentialStore.kt | 137 ++ .../com/redtermapp/ui/SshKeyAuthenticator.kt | 113 ++ .../java/com/redtermapp/ui/SshKeyImporter.kt | 211 ++++ .../java/com/redtermapp/ui/SshKeyStore.kt | 426 +++++++ .../java/com/redtermapp/ui/SshKeyUnlock.kt | 147 +++ .../java/com/redtermapp/ui/SshKeysActivity.kt | 672 ++++++++++ .../com/redtermapp/ui/SshLaunchOptions.kt | 182 +++ .../com/redtermapp/ui/SshManagerActivity.kt | 746 +++++++---- .../com/redtermapp/ui/SshPasswordPrompt.kt | 95 ++ .../main/java/com/redtermapp/ui/SshStore.kt | 220 +++- .../com/redtermapp/ui/TerminalActivity.kt | 1104 +++++++++++++---- .../java/com/redtermapp/ui/TerminalPalette.kt | 137 ++ .../com/redtermapp/ui/TerminalViewModel.kt | 121 +- .../com/redtermapp/ui/ToolOutputDialog.kt | 66 + .../java/com/redtermapp/ui/TunnelManager.kt | 157 +++ .../java/com/redtermapp/ui/ValidatedDialog.kt | 65 + .../java/com/redtermapp/ui/WelcomeActivity.kt | 28 + .../com/redtermapp/ui/WidgetConfigActivity.kt | 2 +- .../ui/filelist/BaseFileBrowserActivity.kt | 766 ++++++++++++ .../com/redtermapp/ui/filelist/FileEntry.kt | 52 + .../com/redtermapp/ui/filelist/FileSource.kt | 81 ++ .../ui/filelist/LocalDistroSource.kt | 132 ++ .../ui/filelist/SftpBrowserActivity.kt | 371 ++++++ .../com/redtermapp/ui/filelist/SftpSource.kt | 397 ++++++ .../com/redtermapp/util/CancelReceiver.kt | 29 + .../java/com/redtermapp/util/KnownHosts.kt | 105 ++ .../main/java/com/redtermapp/util/Notifier.kt | 145 ++- .../java/com/redtermapp/util/OngoingJobs.kt | 31 + .../java/com/redtermapp/util/SshClient.kt | 211 +++- .../com/redtermapp/util/StoragePermission.kt | 4 +- .../com/redtermapp/util/sftp/SftpClient.kt | 439 +++++++ .../com/redtermapp/util/sftp/SftpSession.kt | 481 +++++++ .../java/com/redtermapp/util/sftp/SftpWire.kt | 323 +++++ .../com/redtermapp/util/sftp/SshAskpass.kt | 231 ++++ app/src/main/res/drawable/bg_distro_badge.xml | 6 + app/src/main/res/drawable/ic_eye.xml | 10 + app/src/main/res/drawable/ic_eye_off.xml | 10 + app/src/main/res/drawable/ic_tool_sftp.xml | 10 + app/src/main/res/layout/activity_settings.xml | 23 + app/src/main/res/layout/activity_terminal.xml | 12 + app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/strings.xml | 192 ++- .../redtermapp/distro/BoundedIconCacheTest.kt | 138 +++ .../com/redtermapp/distro/DistroBrandTest.kt | 170 +++ .../redtermapp/distro/RootfsArchiveTest.kt | 531 ++++++++ .../distro/RootfsPermissionsTest.kt | 141 +++ .../distro/ShellConfigSourcingTest.kt | 144 +++ .../com/redtermapp/distro/ShellConfigTest.kt | 112 ++ .../com/redtermapp/distro/TarExtractorTest.kt | 383 ++++++ .../java/com/redtermapp/ui/AnswerLatchTest.kt | 85 ++ .../java/com/redtermapp/ui/FlowLayoutTest.kt | 142 +++ .../redtermapp/ui/SessionDescriptorTest.kt | 115 ++ .../redtermapp/ui/SshConfigImporterTest.kt | 218 ++++ .../java/com/redtermapp/ui/SshKeyStoreTest.kt | 232 ++++ .../com/redtermapp/ui/SshLaunchOptionsTest.kt | 275 ++++ .../java/com/redtermapp/ui/SshOptionsTest.kt | 106 +- .../redtermapp/ui/SshStoreRoundTripTest.kt | 169 +++ .../com/redtermapp/ui/TerminalPaletteTest.kt | 143 +++ .../redtermapp/ui/filelist/FileEntryTest.kt | 56 + .../com/redtermapp/util/SshProgramsTest.kt | 46 + .../redtermapp/util/sftp/FakeSftpServer.kt | 376 ++++++ .../redtermapp/util/sftp/SftpCommandTest.kt | 108 ++ .../util/sftp/SftpProtocolExtrasTest.kt | 148 +++ .../redtermapp/util/sftp/SftpSessionTest.kt | 508 ++++++++ .../util/sftp/SshAskpassDeclineTest.kt | 71 ++ .../redtermapp/util/sftp/SshAskpassTest.kt | 141 +++ native/build-openssh.sh | 44 +- screenshots/home.jpg | Bin 412630 -> 495846 bytes screenshots/icon.png | Bin 0 -> 3254 bytes screenshots/main.jpg | Bin 182481 -> 387568 bytes screenshots/sessions.jpg | Bin 238968 -> 238229 bytes screenshots/settings.jpg | Bin 283349 -> 306700 bytes screenshots/terminal.jpg | Bin 323719 -> 469172 bytes 125 files changed, 16772 insertions(+), 1587 deletions(-) create mode 100644 AGENTS.md create mode 100644 app/src/main/assets/distro-icons/almalinux.png create mode 100644 app/src/main/assets/distro-icons/alpine.png create mode 100644 app/src/main/assets/distro-icons/arch.png create mode 100644 app/src/main/assets/distro-icons/debian.png create mode 100644 app/src/main/assets/distro-icons/fedora.png create mode 100644 app/src/main/assets/distro-icons/kali.png create mode 100644 app/src/main/assets/distro-icons/manjaro.png create mode 100644 app/src/main/assets/distro-icons/opensuse.png create mode 100644 app/src/main/assets/distro-icons/rocky.png create mode 100644 app/src/main/assets/distro-icons/ubuntu.png create mode 100644 app/src/main/assets/distro-icons/void.png create mode 100644 app/src/main/java/com/redtermapp/distro/BoundedIconCache.kt create mode 100644 app/src/main/java/com/redtermapp/distro/DistroBrand.kt create mode 100644 app/src/main/java/com/redtermapp/distro/DistroIconStore.kt create mode 100644 app/src/main/java/com/redtermapp/distro/RootfsArchive.kt create mode 100644 app/src/main/java/com/redtermapp/distro/ShellConfig.kt create mode 100644 app/src/main/java/com/redtermapp/distro/TarExtractor.kt create mode 100644 app/src/main/java/com/redtermapp/distro/TarMembers.kt create mode 100644 app/src/main/java/com/redtermapp/service/SessionKeepAwake.kt create mode 100644 app/src/main/java/com/redtermapp/ui/DistroBadge.kt create mode 100644 app/src/main/java/com/redtermapp/ui/FlowLayout.kt create mode 100644 app/src/main/java/com/redtermapp/ui/ScreenInsets.kt create mode 100644 app/src/main/java/com/redtermapp/ui/ScreenWidgets.kt create mode 100644 app/src/main/java/com/redtermapp/ui/SecretField.kt create mode 100644 app/src/main/java/com/redtermapp/ui/ServerExtrasView.kt create mode 100644 app/src/main/java/com/redtermapp/ui/SessionDescriptor.kt create mode 100644 app/src/main/java/com/redtermapp/ui/SessionStore.kt create mode 100644 app/src/main/java/com/redtermapp/ui/SftpServerPicker.kt create mode 100644 app/src/main/java/com/redtermapp/ui/SshConfigImporter.kt create mode 100644 app/src/main/java/com/redtermapp/ui/SshCredentialStore.kt create mode 100644 app/src/main/java/com/redtermapp/ui/SshKeyAuthenticator.kt create mode 100644 app/src/main/java/com/redtermapp/ui/SshKeyImporter.kt create mode 100644 app/src/main/java/com/redtermapp/ui/SshKeyStore.kt create mode 100644 app/src/main/java/com/redtermapp/ui/SshKeyUnlock.kt create mode 100644 app/src/main/java/com/redtermapp/ui/SshKeysActivity.kt create mode 100644 app/src/main/java/com/redtermapp/ui/SshLaunchOptions.kt create mode 100644 app/src/main/java/com/redtermapp/ui/SshPasswordPrompt.kt create mode 100644 app/src/main/java/com/redtermapp/ui/TerminalPalette.kt create mode 100644 app/src/main/java/com/redtermapp/ui/ToolOutputDialog.kt create mode 100644 app/src/main/java/com/redtermapp/ui/TunnelManager.kt create mode 100644 app/src/main/java/com/redtermapp/ui/ValidatedDialog.kt create mode 100644 app/src/main/java/com/redtermapp/ui/filelist/BaseFileBrowserActivity.kt create mode 100644 app/src/main/java/com/redtermapp/ui/filelist/FileEntry.kt create mode 100644 app/src/main/java/com/redtermapp/ui/filelist/FileSource.kt create mode 100644 app/src/main/java/com/redtermapp/ui/filelist/LocalDistroSource.kt create mode 100644 app/src/main/java/com/redtermapp/ui/filelist/SftpBrowserActivity.kt create mode 100644 app/src/main/java/com/redtermapp/ui/filelist/SftpSource.kt create mode 100644 app/src/main/java/com/redtermapp/util/CancelReceiver.kt create mode 100644 app/src/main/java/com/redtermapp/util/KnownHosts.kt create mode 100644 app/src/main/java/com/redtermapp/util/OngoingJobs.kt create mode 100644 app/src/main/java/com/redtermapp/util/sftp/SftpClient.kt create mode 100644 app/src/main/java/com/redtermapp/util/sftp/SftpSession.kt create mode 100644 app/src/main/java/com/redtermapp/util/sftp/SftpWire.kt create mode 100644 app/src/main/java/com/redtermapp/util/sftp/SshAskpass.kt create mode 100644 app/src/main/res/drawable/bg_distro_badge.xml create mode 100644 app/src/main/res/drawable/ic_eye.xml create mode 100644 app/src/main/res/drawable/ic_eye_off.xml create mode 100644 app/src/main/res/drawable/ic_tool_sftp.xml create mode 100644 app/src/test/java/com/redtermapp/distro/BoundedIconCacheTest.kt create mode 100644 app/src/test/java/com/redtermapp/distro/DistroBrandTest.kt create mode 100644 app/src/test/java/com/redtermapp/distro/RootfsArchiveTest.kt create mode 100644 app/src/test/java/com/redtermapp/distro/RootfsPermissionsTest.kt create mode 100644 app/src/test/java/com/redtermapp/distro/ShellConfigSourcingTest.kt create mode 100644 app/src/test/java/com/redtermapp/distro/ShellConfigTest.kt create mode 100644 app/src/test/java/com/redtermapp/distro/TarExtractorTest.kt create mode 100644 app/src/test/java/com/redtermapp/ui/AnswerLatchTest.kt create mode 100644 app/src/test/java/com/redtermapp/ui/FlowLayoutTest.kt create mode 100644 app/src/test/java/com/redtermapp/ui/SessionDescriptorTest.kt create mode 100644 app/src/test/java/com/redtermapp/ui/SshConfigImporterTest.kt create mode 100644 app/src/test/java/com/redtermapp/ui/SshKeyStoreTest.kt create mode 100644 app/src/test/java/com/redtermapp/ui/SshLaunchOptionsTest.kt create mode 100644 app/src/test/java/com/redtermapp/ui/SshStoreRoundTripTest.kt create mode 100644 app/src/test/java/com/redtermapp/ui/TerminalPaletteTest.kt create mode 100644 app/src/test/java/com/redtermapp/ui/filelist/FileEntryTest.kt create mode 100644 app/src/test/java/com/redtermapp/util/SshProgramsTest.kt create mode 100644 app/src/test/java/com/redtermapp/util/sftp/FakeSftpServer.kt create mode 100644 app/src/test/java/com/redtermapp/util/sftp/SftpCommandTest.kt create mode 100644 app/src/test/java/com/redtermapp/util/sftp/SftpProtocolExtrasTest.kt create mode 100644 app/src/test/java/com/redtermapp/util/sftp/SftpSessionTest.kt create mode 100644 app/src/test/java/com/redtermapp/util/sftp/SshAskpassDeclineTest.kt create mode 100644 app/src/test/java/com/redtermapp/util/sftp/SshAskpassTest.kt create mode 100644 screenshots/icon.png mode change 100755 => 100644 screenshots/sessions.jpg diff --git a/.gitignore b/.gitignore index 44c863b..fe860ad 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,10 @@ native/ssh_probe_ssl.c .DS_Store *.swp *~ +Thumbs.db +desktop.ini + +# Merge and patch leftovers. These get created by a failed merge or a rejected hunk, +# and committing one silently re-applies a conflict the author had already resolved. +*.orig +*.rej diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b4960f1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,408 @@ +# RedTerm — agent notes + +## What this is + +Android terminal emulator + SSH client. Kotlin, single `:app` module, package `com.redtermapp`. +Terminal engine is the upstream Termux libraries pulled from jitpack +(`terminal-emulator`, `terminal-view` v0.118.3). Full SSH lives in the app itself: +real OpenSSH binaries are shipped as per-ABI assets under `app/src/main/assets/ssh//` +and executed through proot's `-L` loader (app storage is `noexec` on Android 12+). +No JSch/sshj — there is no second SSH stack. + +## Build / verify + +``` +./gradlew assembleDebug # build +./gradlew test # JVM unit tests (app/src/test) +./gradlew lint # gates the build: abortOnError = true +``` + +**This environment has no JDK, no Android SDK and no NDK.** Builds and `lint` cannot be run +here; the developer must verify locally. `native/build-openssh.sh` cross-compiles the OpenSSH +binaries and must also be run by the developer (it needs the NDK). + +## House style + +- Terminal/Session machinery must not assume a distro. A session is a `TerminalSession` from + the engine plus a `SessionDescriptor`; branch on descriptor kind, never on the display string. +- Persist anything that outlives an activity. In-memory singletons do not survive process death. +- Comments explain *why*, and must stay true. Several comments in this repo are already stale + (`TerminalActivity.launchSsh`, `SshManagerActivity` class doc) — don't add more. +- Secrets never reach `AppLog`. `SshClient.execScript` logs the full command line. +- New user-facing text goes in `app/src/main/res/values/strings.xml`. +- **A failure the user cannot read is a failure nobody can fix.** `AlertDialog.setMessage` + builds a non-selectable `TextView`, which is fine for "Archive already exists" and + useless for the several hundred characters of `tar` that explain a failed restore: + not selectable, not copyable, not scrollable on a phone, and gone on OK. Tool output + goes through `ToolOutputDialog` — selectable, scrollable, monospaced. A restore failure + also goes to `AppLog`, because `android.util.Log` is invisible in Diagnostics and the + dialog is the only copy that gets dismissed. +- **A multi-minute operation needs a progress bar, not just a line of text.** Backing up or + restoring a real distro takes minutes, and a dialog saying only "backing up" is + indistinguishable from one that has hung. Both now report a named phase and a fraction. +- **Logging is for failures.** A command that succeeds logs one line; the command, the + generated launcher and the full output are logged only when it does not. Both used to + be logged always, which buried the one line that mattered under an identical copy of the + same launcher script for every listing and every transfer. Every bug in this feature was + found by reading a verbose trace, so the trace stays — it just is not produced when + there is nothing to explain. +- Platform noise from a child binary (the `WARNING: linker:` lines every cross-compiled + executable prints) is filtered out and never enters a failure report, which must contain + only what ssh actually said. +- **Never lay buttons out with `weight = 1f` side by side.** Weights divide the width + before anything is measured, so on a phone each label gets a fraction of the screen + and wraps one word per line — which is exactly how four action buttons became ovals + with text stacked down them. Buttons keep their natural width and the row wraps: + `FlowLayout`, with `ScreenWidgets.actionButton` for the buttons themselves, and + `maxLines = 1` so a label that genuinely cannot fit truncates rather than stacking. +- Tool screens are built in code (`ScreenToolbar.install`, `ScreenColors.cardColor/cardTextColor`, + `ScreenWidgets.chip`), not layout XML. Only `activity_terminal.xml` and the file-list layouts + are XML. +- **A status is not another line of body text.** Cards put the label left and the type chip + right, facts muted under them, and anything about *behaviour* — protected by a passphrase — + as a chip of its own. A status rendered at the same weight as the creation date reads as + trivia about when the key was made rather than how it behaves. +- `ValidatedDialog` for any dialog that can be dismissed with unusable input. A positive + button's listener runs and *then* the dialog dismisses, so validate-show-toast-return + closes it anyway and the toast is the only trace; the dialog has to stay open with the + field carrying the error. +- `danger` is a themed colour with a light/dark pair, like `extraKeysBg`. A destructive + button that looks like the neutral ones beside it is how the wrong thing gets destroyed. + +## Active work — the SSH feature overhaul + +Agreed plan, all phases to be delivered. Status is tracked below and updated as work lands. + +### Phase 0 — testable, correct foundations [DONE] +- `SshClient`: split `PROGRAMS` into `REQUIRED_PROGRAMS` (`ssh`, `ssh-keygen`) and + `OPTIONAL_PROGRAMS` (`sftp`, `scp`); optional binaries extract best-effort and can never + fail `ensureInstalled()`. Install stamp bumped to `:` so upgrades + re-extract. `execScript`/`run` take a `redacted` argument. +- New `SshLaunchOptions`: pure arg builder (was `SshManagerActivity.buildOptions`). + `SshOptionsTest` rewritten to call production code instead of its private replica. +- New `SshKeyStore`: labeled multi-key registry, JSON in the `ssh_servers` prefs, migrating + legacy `~/.ssh/id_*` files into labelled entries on first load. + Fixes `SshClient.privateKeys()` offering `known_hosts` as a deletable key. + +### Phase 1 — key management UI [DONE] +- New `SshKeysActivity`: one card per key (label, type, created, used-by-N) with per-card + Copy public key / Copy path / Rename / Delete. Replaces the flat `setItems` dialog. +- Generate dialog asks for label + key type (ed25519/ecdsa/rsa-4096) + optional passphrase. +- `SshStore.Server` gained `keyId`; per-server key picker; server card shows the key. +- `SshKeyStore.delete` removes both halves of the pair, drops the entry and clears `keyId` + on every server that referenced it, so no server points at a purged file. +- `backup_rules.xml`: `ssh-rootfs/root/.ssh/` added to device-transfer only, not cloud-backup. + +### Phase 2 — SSH sessions as first-class sessions [DONE] +- New `SessionDescriptor` (sealed `Local`/`Ssh`) gives each session a durable identity. +- New `SessionStore` persists the open-session list; cleared whenever the last session dies. +- `TerminalViewModel` keeps a descriptor per session and generalizes `removeSessions(predicate)`. +- `TerminalActivity`: single `createSession(SessionRequest)` replaces the SSH/distro fork. + Launcher scripts are now per-session (`launch-.sh`) — the old fixed name let a second + concurrent session overwrite the script the first was still reading. The new-session picker + lists distros *and* servers. Picker key chooser. Guarded distro-only work that leaked into + SSH launches; fixed export filename, remote path-tap, paste/snippet split-focus bugs, + split-pane theme repaint, split-pane IME. +- `TerminalService` notification title reflects the current session instead of the first distro. +- Deleting a server kills its running sessions. Sessions restore on cold start. + +### Phase 3 — SFTP client [DONE] +- **SFTP is spoken in-app**, in `util/sftp/`, over `ssh -s user@host sftp` using the bundled + `ssh`. No `sftp`/`scp` binaries: they are separate executables and cannot be extracted from + `ssh`, and linking one needs a full OpenSSL for each ABI (`libssh.a` pulls in kex, so a + hash-only crypto shim is not a shortcut). `build-openssh.sh` therefore still ships only + `ssh` and `ssh-keygen`, and the APK does not grow. +- `ProotLaunch.writeLauncher(mergeStderr = false)` for this path: proot merges stderr into + stdout by default, and one host-key warning inside a packet desynchronises the stream for + the rest of the session. +- `SftpSession` reads through a polling deadline rather than a blocking read, because a + process pipe has no read timeout and a wedged server would otherwise hang the transfer + thread forever. +- Progress is reported in **bytes**, not percentages, because the client does the chunking. +- **The `-s` is what makes it a subsystem.** Without it, the trailing `sftp` is a program to + *run* on the remote host — and the program called `sftp` there is the client, which prints + its own usage and exits. The trace then shows a clean authentication and a connection + closed for no visible reason, because the missing flag names nothing that appears in it. +- **A failure report waits briefly for stderr.** stderr is drained on its own thread, so a + handshake can give up and describe the failure before a single word of ssh's explanation + has been appended. Read off an empty buffer, every such failure reads as a stopped + connection whatever the server actually said — which is where the search for the `-s` + went. `Diagnostics.tailAwaitingFirstLine` waits at most 400ms, and only on the failure + path. +- New `ui/filelist` package: `FileEntry`, `FileSource`, `LocalDistroSource`, `SftpSource`, + `BaseFileBrowserActivity`. `FileBrowserActivity` became a thin local implementation and + `SftpBrowserActivity` a thin remote one. +- Device-side file picking via SAF, so transfers need no storage permission. +- **Nothing about a source's `home` may be read on the main thread.** For the remote + source it costs an SSH connection, so the browser resolves it on its worker; doing it + in `onCreate` was a `NetworkOnMainThreadException` and an instant crash. +- **Both key kinds must work.** An unprotected key keeps the plain `BatchMode` path with + no askpass and no environment at all. A passphrase-protected key is unlocked through + `SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=force` (`util/sftp/SshAskpass.kt`): the app asks + once, keeps the phrase in memory for the process, and `BatchMode` is dropped so ssh is + allowed to use the helper. Asking ssh's own askpass question was the industry-standard + answer; the alternative is a "Permission denied (publickey)" for a key the server has + already accepted, which reads as the server refusing it. +- `SSH_ASKPASS` hands ssh one phrase with no way to say which key it is asking about, so + a passphrase key is offered **alone** on the command line. Offering several would answer + one key's question with another's phrase. +- **Ask once per key, then stop.** Every listing and every transfer is its own + connection, so a rule that re-asks about a key on each one is nagging for ever: the + user dismisses the dialog and the next tap brings it straight back. `SshAskpass` + holds phrases *and* declines, and a key is only asked about when it has neither. + Prompts are serialised, because a listing still running while a transfer starts + would otherwise stack two dialogs and the answer to the second goes into the first. +- **The browser keeps one connection open.** `SftpSource` holds the `SftpClient` for + its whole life rather than dialling per operation, which is both the latency the + user asked for and what stops the passphrase question being asked five times. A + dropped connection is retried once on a fresh one, because an idle timeout is + indistinguishable from a bad path otherwise. +- **A dialog answers later; wait for the dismissal, not for the showing.** `show()` + returns immediately, so reading the result straight after it reads `null` and the + connection retries with no passphrase — while the user watches the same refusal + again after having answered the dialog. SFTP showed this as an error appearing + *underneath* a still-open prompt. `SshKeyUnlock.AnswerLatch` exists so that rule is + one tested object: the wait blocks, is bounded, and a dismissal by tap-outside or + back press releases it rather than hanging a transfer thread. +- **One key per connection, tried in turn.** `SSH_ASKPASS` supplies a phrase with no + way to say which key it is for, so offering several keys in one command and asking + when one is refused cannot work. `SshKeyAuthenticator` therefore tries candidates one + at a time and asks about the key actually in use — which is what an agent does. Every + candidate is kept whatever its passphrase: narrowing the list to one key when another + is encrypted makes the encrypted key decide which servers the whole device can reach. +- **Unbound servers try the newest key first.** Store order is creation order, so taking + the list as it comes means the *oldest* key on the device always wins and a key made + for a server being added right now is never offered. Bound means the user's explicit + choice and is used alone; ties break on file name so the order is reproducible. +- **Never ask up front; ask when a connection is refused.** A passphrase-protected key and + a key the server has no record of both produce `Permission denied (publickey)` after + `Server accepts key`, so the recorded `encrypted` flag is a hint and nothing is gated on + it. The flag was also unwritable — added to `Entry` and to the reader, not the writer — + so *every* protected key loaded as unprotected. `SshKeyStore.encode`/`decode` are now + pure functions round-tripped by a test, because a missing writer field produces no error + anywhere, only a key the server accepted reported as refused. +- **A secret travels in `ProcessBuilder.environment()`, never in the command.** The command + is written into a launcher script in app storage and logged on the way past, so an inline + `VAR=value ssh ...` prefix puts the passphrase on disk in the one place it was promised not + to be. As an inline prefix it also has to be quoted, and the prefix was once applied to + *every* argument, which made `ssh` see the assignments as its own arguments. +- `SshAskpass.environmentPrefix` is tested by running it through a real `/bin/sh` and + reading the variable back in a child process. String comparison proved nothing: the + prefix was once missing a space, which glued `SSH_ASKPASS` to the next assignment and + then to the command name, so ssh looked for a helper at a nonsense path. +- `SftpBrowserActivity` must never reject a server for a reason that is not in the + saved list. An earlier version tested an Intent extra the app never sent, so every + real server reported "no longer saved". +- An SFTP connection's failure mode is reported from ssh's own stderr + (`SftpClient.OpenResult`), read *before* the handshake. End of stream is a flag on a + reader thread, not a null queue element — `LinkedBlockingQueue` rejects nulls, and + polling `available()` cannot tell a closed pipe from a quiet one. +- Key choice is never a per-server question. `SshKeyStore.identitiesFor` returns the + server's bound key, or *every* key on the device when none is bound — the labelled + filenames (`id__