For human readers: this file is Twig's engineering notebook. It is written for "the person about to change this code", not for "someone meeting the project for the first time" — for an introduction see README.md. The most valuable part is "Hard-won lessons": one file per area under docs/lessons/, indexed at the bottom of this file. Every entry is the closing record of a real incident, with the symptom, the wrong diagnosis, the root cause and the current solution. Before touching the terminal, thumbnails, TS demuxing, privileged access or WiFi sharing, open the matching file and read it first — several of them took two or three shipped regressions to pin down, and stepping on them again is expensive. Keeping them out of this file is deliberate: what gets loaded every session stays short, and the detail is one read away.
The file is named
AGENTS.mdbecause coding agents read it automatically; the content applies to humans just as well. There is deliberately one copy — it was duplicated per-agent for a while and the copies drifted a day out of sync. Machine-specific settings (test device address, release directory, …) live inAGENTS.local.md, which is not committed.
A size-first, dual-pane Android file manager, one tree over every source: plain Kotlin + XML Views, no Material library, minimal dependencies.
# Build release (R8 + resource shrinking; build-tools pinned to 36.1.0)
# ★ There are flavors: full includes RAR and is the one you install; libre is the
# F-Droid build.
./gradlew :app:assembleFullRelease
# Optional NFS build (full/libre both exclude NFS by default; APK suffix: -nfs.apk)
./gradlew :app:assembleFullRelease -PwithNfs=true
# Verify the version with aapt (★ never trust the pipeline's exit code —
# a failed build can still leave you installing the previous APK)
"$ANDROID_HOME"/build-tools/36.1.0/aapt dump badging \
app/build/outputs/apk/full/release/app-full-release.apk | grep -E "versionName|versionCode"- Verification discipline: before installing, always confirm with aapt that
versionNameis the build you just made. A failed build once got masked by a pipelineexit 0, and an old APK shipped as a new one (the 0.20.3 == 0.20.2 incident). - Version numbers:
versionCode(+1) andversionNameinapp/build.gradle.ktsmove only for a release that actually goes out, never for a test build. A test build is signed with the debug key (debugSign=true) and installs straight over the previous one, so it needs no number of its own — and spending one on a build nobody else receives is exactly how 1.9.1 and 1.9.2 became versions that never existed. Small intermediate iterations within one round of polishing (tweaking a corner radius or spacing back and forth) only need a compile check (compileFullReleaseKotlin/assembleFullRelease), not a version bump each time. ★ Which component moves is decided by the changelog, not by the commit types. Write the changelog first, then read it back: any sentence saying something is new or now possible makes it a minor; a changelog that only corrects behaviour, or only records an internal change, makes it a patch. The rule used to be "fixes bump the patch component, features bump the minor one", and it judged the wrong thing — Twig is an app with no callers, so "what kind of work was this" tells a user nothing. It also had no answer for abuild:orchore:release, and it filed 1.8.5 (whose changelog says "No user-visible change") in the same tier as 1.9.3 (a crash on Android 9 and 10). Reading the changelog instead costs nothing: it has to be written anyway, it is the one text the user actually sees, and build or chore work lands on patch by itself because it cannot produce a "this is new" sentence. What no component can express is urgency — F-Droid has no "update now" flag, so a crash, data-loss or security fix has to say so in the changelog's first sentence. ★ BumpingversionCodemeans writing a changelog in the same commit:fastlane/metadata/android/{en-US,zh-CN}/changelogs/<the new versionCode>.txt, 500 characters or less, and the file is named after the versionCode, not the version name. That is what F-Droid shows as the release note — miss it and that release ships with no description. The rest of the store copy and images live underfastlane/, rules infastlane/README.md.
What happens after that — which device to install on, where builds get published, how
you get told about it — is every developer's own preference, so it is deliberately not
in this file. The setup used on the machine this was written on lives in
AGENTS.local.md, which is not committed.
For GitHub previews, commit a prerelease version such as 1.16.0-beta.1 and its
English/Chinese changelogs, then run tools/preview.sh to build and verify full/libre
APKs. Add --publish to push the current branch/tag and publish a Pre-release without
changing Latest. --notes-file PATH supplies detailed release notes; --offline
uses cached Gradle dependencies. The script respects the configured signing key and
uses tools/verify-apk.py before publishing. Stable releases use tools/release.sh.
- Release is signed with our own keystore, whose credentials are read from
keystore.propertiesin the repository root (path and passwords are inAGENTS.local.md; neither the key nor that file is committed). ★ When the file is missing, release produces an unsigned APK rather than silently falling back to the debug config: the AOSP debug key is public, so anyone could sign an APK that installs over the real one, and a silent fallback would bury that trap again with no warning. An unsigned APK is also exactly what F-Droid wants (it signs its own, or does a reproducible-build comparison). ★ Theimport java.util.Propertiesat the top ofapp/build.gradle.ktscannot be omitted: in the Kotlin DSL,javaresolves to Gradle's java extension, so writingjava.util.Propertiesinline fails with "Unresolved reference: util". ★INSTALL_PARSE_FAILED_NO_CERTIFICATESwhen installing means exactly one thing: the build found nokeystore.propertiesand produced an unsigned APK. ★ Changing which key signs a build makes it refuse to install over the old one (INSTALL_FAILED_UPDATE_INCOMPATIBLE) — the previous install has to be uninstalled first, taking its data with it. This is why test builds are signed with the debug key instead: putdebugSign=trueinlocal.properties(gitignored, so it never reaches another branch) and release is signed with the AOSP debug key, letting a test build install straight over the last one.-PdebugSign=true|falseoverrides the file. - ABIs are limited to
arm64-v8a+x86_64; for a store listing usebundleReleaseto produce an AAB split per device.
Every backend (local / archives / FTP / SFTP / SMB / WebDAV / restic) presents the same
FileSystem + XFile interface to the UI, and cross-backend copy/move goes through
CopyEngine streaming over openInput()/openOutput(). Adding a backend = adding one
FileSystem implementation, with zero changes to the UI or to copying.
Every XFile carries a scheme; FsRegistry.of(scheme) finds the matching FileSystem.
Multiple servers of the same kind each get a unique scheme.
Optional capability interfaces (core-fs/MediaServer.kt): FileSystem itself is the
floor that "every backend must answer"; capabilities that only make sense for a few
backends get their own interface, implementations opt in with : PlaybackProgress, and
callers always do FsRegistry.of(file) as? XXX and fall back to the old path when they
get null. There are currently seven, most of them added for media servers:
PlaybackProgress (the backend owns playback position), CoverSource (the backend has
posters), MediaInfoSource, LyricsSource, SearchSource, EpisodeSeries, SizeProbe (the listing
has no byte count; a row can ask for its own — :app's SizeProbes drives it per bound row,
never per listing).
Before adding a capability, decide whether every backend should be able to answer it —
if yes it belongs in FileSystem, otherwise it goes here; don't make 20 implementations
each write = null.
UnixMetadataSource (core-fs/UnixMetadata.kt) is a read-only capability for Unix properties.
Local storage uses an injected Android lstat reader, with root/Shizuku fallback only for
failed reads. XFile.unixMode carries the entry's own mode into listings; render its permissions after
folder counts and before size/time, without bind-time filesystem calls.
Prefs.unixPermissionsOctal defaults on and switches the list between octal and symbolic
notation. Octal labels have an outline shield matching count-icon size, tint and opacity.
The format choice participates in uiSignature and configuration backups. The independent,
default-off Prefs.unixPermissions display setting controls this row only, participates
in uiSignature and is included in configuration backups. The Unix properties tab stays
available with the setting off. When counts are enabled, reserve both count slots for rows
with permissions, including files and pending directory counts. The counts container exposes its first child's baseline so all metadata
text aligns across the nested layout. Numeric permissions, UID/GID, access/status-change times and available SELinux context belong in a dedicated
Unix properties tab, after all other tabs including Hash. Its initial loading tab uses listing metadata only;
unsupported backends have no Unix tab. General retains modification time and link targets.
ctime is inode status change, never creation time. Symlink attributes describe the link;
its listing size must still describe the followed stream for copy/resume checks.
| Module | Purpose |
|---|---|
:core-fs |
Pure JVM: XFile / FileSystem / FsRegistry / CopyEngine |
:fs-local |
LocalFileSystem (java.io.File) + priv/ privileged fallback (PrivilegedShell, shared by root and Shizuku) |
:fs-archive |
ArchiveFileSystem base + Zip (read/write, auto-detects GBK) / 7z (read-only) / tar (read-only, entries read by offset slicing) / gz·xz·bz2·zst (SingleFileSystem, one entry, stacks into tar.gz; zstd's decoder is injected by :app because this module is plain JVM); ArchiveWriter packing (zip/7z) across backends; encrypted archives (ZipAes/ZipCrypto/ZipWriter) |
:fs-archive-rar |
RarFileSystem (RAR4 + RAR5, read-only). A separate module purely for licensing — junrar is non-free and only ships in the full flavor, see docs/lessons/rar-and-fdroid.md |
:fs-network |
FtpFileSystem / SftpFileSystem (SSHJ) / WebDavFileSystem (hand-written PROPFIND) / S3FileSystem (hand-written SigV4 + REST) / JellyfinFileSystem (Jellyfin + Emby, read-only virtual tree) |
:fs-smb |
SmbFileSystem — libsmb2 via NDK/JNI, read/write |
:fs-restic |
Read-only decrypting reader for restic repositories (pure Kotlin) |
:fs-zstd |
zstd JNI: NativeZstd (whole-block, restic) + ZstdInputStream (streaming, .zst archives) |
:git-lite |
Mini git in pure Kotlin (GitRepo/ObjectStore/IndexFile/patience Diff) for browsing worktree status and history; worktrees/submodules via GitLayout + WorktreeGitFs |
:app |
Dual-pane UI, permissions, mutations, viewers, terminal, SAF fallback, WiFi sharing service, website bookmarks and customization |
MainActivity+ two residentPaneFragments (ViewPager2 was dropped; visibility toggling + fling gestures).Prefs.paneLayoutchooses follow-orientation (default), single or dual panes;Prefs.dualPanedrives visibility, focus, swipe handling and directory-count subscriptions across Main and both pickers. In Main, toolbar visibility follows orientation unless the default-offPrefs.hideTitleBarPortraithides it in portrait too. Hidden title-bar actions stay pinned above the strip with the same separator, using one column in portrait and two in landscape. Action-strip density still follows orientation. Both settings participate inuiSignatureand configuration backups.Prefs.hidePathBarSinglePanedefaults off and hides the address bar whenever the effective layout is single-pane, including both pickers. Dual-pane layouts retain it. The setting participates inuiSignatureand configuration backups.PaneViewModelowns the navigation stack and tree expansion;FileAdapterrenders rows. Tapping the path bar reveals horizontally scrollable path buttons; its source icon toggles the compact display; clicking a tree node also restores it. Buttons have square corners, fill the bar height, omit slash separators and prefer display aliases. Only the selected segment changes background.pathSegmentsuses real tree keys and mount roots, retaining child segments when selecting a parent and replacing them on another branch.selectPathSegmentopens the selected directory with only its direct children visible and all child directories collapsed, keeping ancestors open; never split SAF/media-server paths. Long press and double tap still edit paths.TreeIndentationsubtracts the shallowest visible depth from display indentation, with a shared 250 ms transition; partially visible rows retain their depth, and properties cards use their owner's depth. Grid cells retain their full width.Prefs.autoTreeIndentdefaults on, has a settings-only toggle, participates inuiSignatureand is backed up withtwig_prefs. Actual node depths never change. Expanding a directory reuses its cached children immediately, then re-lists in the background (relistQuietly) on every backend, including server and favorite roots. Only changed listings update the rows. Expanded local directories stay live through inotify (PaneFragment.syncObservers); resume also re-lists the current directory on any backend. The tree always expands/collapses in place, it never "enters" a directory.- Directory counts (
DirectoryCounts) use separate background workers for direct local storage and other sources. Only attached rows in visible panes request probes; prefetch does not enumerate directories. Detaching/recycling a row, hiding its pane or stopping its screen releases that row's subscription, while another pane's subscription to the same directory remains valid. Pending work without subscribers is dropped. Queued counts follow live adapter positions, from top to bottom in the displayed sort order, including after pane activation or a resort. Local entry processing checks cancellation; an already-running blocking backend call may finish, without holding up the local count worker. Ordinary listings populate the shared cache, and failed reads stay unknown rather than becoming zero.DirectoryCountSourceoptionally provides streaming count-only enumeration. SFTP checks cancellation per entry, skips unused metadata and stops once both visible counts reach 1000 (999+); rarer categories still require reaching the end to be exact. Hidden and all-entry counts are cached separately. Never cap ordinarylistresults. Probes have a three-second soft deadline starting when IO begins, excluding queue wait. Streaming/local entry processing checks it; late blocking results are discarded. Timeouts keep existing cache entries, deliver no zero and share the failure cooldown. Ordinary expansion/refresh listings can still populate counts after any duration. Confirmed empty directories hide their row chevron across supported backends, retaining click/destination behavior and arrows for synthetic children. Reuse ordinary listings even with counts disabled; extra nonlocal probes remain opt-in. - Persistence: connections in
ConnectionStore, favorites inFavoritesStore, preferences inPrefs— all SharedPreferences. Recent locations also record directories used inside restic snapshots, storing the repository connection/path, snapshot selector and internal path rather than a session scheme. Fixed snapshot metadata is frozen for display;latestremains a dynamic alias. Restoring reconnects and unlocks as needed, expands the repository chain, then matches snapshot rows by path ID (their display names are dates). Favorites and Recent share the repository unlock resolver; a live unlocked repository needs no password prompt. Missing snapshots/listings report errors without changing the selected directory. Newer navigation or Collapse abandons pending restores. Favorites and websites keep their stored order. Long-pressing either heading offers inline reordering with drag handles; favorite children are temporarily hidden, websites temporarily use list rows even in grid mode, and drops save the order for both panes. A tap outside the drag handles or Back restores normal expansion; scrolling leaves sorting active.SortDialogprovides separate sorting and view dialogs, each with defaults or overrides for the selected directory. Resetting one leaves the other unchanged. Defaults remain intwig_sort/twig_prefs;DirectoryViewStorestores only explicit directory overrides intwig_directory_views(included in.twigbak). Sorting and view mode inherit independently. Each has a default-off recursive flag: a directory's own override wins, otherwise the nearest recursive ancestor wins, then the global default. Nonrecursive overrides do not propagate or block more distant recursive ancestors. Resolve once per directory listing/render, without scanning descendants or persisting copies; ordinary paths derive ancestry, while opaque paths use parent links learned from listings. Listing identities (mountRoots/keyFile) identify the containing directory, including favorites, servers and opaque SAF paths.FileNode.gridModecarries its effective view to the adapter; directory overrides are parsed per settings refresh, not per rendered row. Network display paths shareformatLocationPath/Format.pathLabeland usetype://server/path; aliases follow history, raw addresses are used for favorites and playlist exports. M3U network locations usetype://server/path; malformed single-slash source addresses are skipped. Internalscheme:pathkeys remain separate from display text. - Private space:
PrivateSpaceusesapplicationInfo.dataDir/private_spacethrough the ordinary local filesystem. The main menu's root-item visibility choices are stored per pane in one dialog with left/right columns. Dragging its handles saves a shared order for configurable roots, including internal storage and one removable-storage slot. The latter renders mounted SD/USB volumes directly, without an extra group row; its per-pane visibility also applies to newly attached volumes. Fixed entries retain their slots. Private space defaults visible; an explicit hidden choice is retained. Root-item and action-panel dialogs offer Reset for their draft order and visibility; only OK persists it, and Cancel retains the saved configuration. Its files are excluded from all system backup/transfer channels, and configuration-only.twigbaknever includes them. Known ancestors of Twig's data directory get UI-only navigation hints when Android denies enumeration; filesystem copy/compare listings must still fail. - Websites:
WebsiteStorepersists per-site options intwig_websites;WebsiteActivityopens each bookmark in its own reusable document task using the shared WebView profile.SearchEnginestores the global search homepage and{query}URL template intwig_prefs(included in configuration backups). The website heading menu opensWebSearch.configure; text viewer/editor, PDF and terminal selection actions use the systemACTION_WEB_SEARCHchooser throughWebSearch. Website and preview WebViews keep their native selection menus. The exportedWebSearchActivitymakes Twig an optional system search handler. Long-pressing the action strip's Search opens this entry explicitly, using Twig's configured homepage without a chooser, in Main and both pickers.SearchStripItemretains that gesture while file search is disabled; ordinary taps stay disabled and scrolling cancels the pending long press. After the app lock gate,WebSearchActivityuses the configured template and its own.searchtask affinity so systemNEW_TASKrequests cannot bring MainActivity forward instead of returning to the calling website, viewer or external app. Empty queries use the homepage. Only requests handled by Twig share thetwig://searchdocument task, carrying the requested URL as an extra; reused intents load the new request only after the app lock gate. Bookmarks and externally opened browser pages keep their separate document identities.TerminalSelectionMenureplaces Termux's unused More entry with sharing and installedACTION_PROCESS_TEXThandlers. It reads the live selection at click time, passes text as read-only and ignores results; external processing must never write returned text to a terminal session.WebsiteDialogsshares its editor with website defaults: new bookmarks copy display options only. UA/JS/CSS/hide/block settings apply globally at runtime; per-site UA overrides the global value, while scripts/styles/selectors/block rules combine with it. Keep raw per-site fields separate from effective settings, including debug edits and browser saves, so global values never become permanent site additions. Open pages observe global changes; locked pages defer applying them until unlock. Each bookmark can opt intoapplyToDomain: external links, system searches and navigation borrow its raw configuration on the exact same hostname, then merge global additions once. Enabling a second bookmark for that host disables the earlier choice; directly opened saved bookmarks retain their own configuration on their saved origin. Resolve again for top-level navigation and history, never for subresources. Keep the document's identity separate from its current page configuration. Domain sharing does not inherit capture/location grants or background execution; headers and Basic credentials retain their original scheme/host/port scope. Temporary and borrowed-page debug edits stay in memory, without rewriting the source bookmark. Prepare changed UAs before intercepted GET loads; history/POST loads that bypass interception finish before changing UA, since changing it mid-load restarts the request.WebsiteMediaPermissionshandles HTTPS microphone/camera requests with explicit origin-specific consent followed by Android runtime permissions. Grant only selected, recognized capture resources, after the WebView resumes. Saved bookmarks have independent ask/allow/deny policies and may remember prompt decisions for their saved origin only; temporary pages always ask. Foreign frames and redirected origins cannot inherit them. Navigation, WebView cancellation and leaving the foreground invalidate pending requests, except while Android's own permission prompt is active. Camera/microphone hardware stays optional; Chromium audio capture also requires MODIFY_AUDIO_SETTINGS. Set mediaPlaybackRequiresUserGesture=false before loading/restoring website pages: Chromium latches the gesture lock when each media element is created; changing it during permission callbacks does not unlock existing camera preview elements.WebsiteLocationPermissionsapplies the same per-bookmark origin checks to geolocation. Request coarse/fine Android permissions together and accept approximate-only access; never retain decisions in WebView's shared geolocation store (callback retain=false). Website defaults do not inherit these grants. Changing a bookmark's origin resets all three permissions; revoking one refreshes the matching live page.WebsiteLinkActivityaccepts external HTTP(S) VIEW intents and forwards only the validated URL to a temporaryWebsiteActivitydocument using those defaults. Temporary pages enter the bookmark list only through the explicit add action; debug edits stay in memory.WebsiteCustomizationscopes JS/CSS, hidden selectors, navigation headers and Basic auth to the saved origin. Headers apply to same-origin GET page navigation, not all resources.WebsiteRequestBlockerhandles manual resource rules, excluding main-frame navigation.WebsiteDebugPanel/WebsiteElementInspectorprovide page tools;WebsiteRemoteDebugowns the session-only computer debugging switch.WebsiteDownloads/WebsiteDownloadTaskuse Twig's picker and transfer service, defaulting toDownloads/twig. WebView file uploads also usePickerActivity, with single/multiple selection matching the page's request and stream URIs returned to WebView. Opt-in background sessions useWebsiteBackgroundService(see website background execution). Basic credentials and header values useSecrets;.twigbakincludes website configuration with those fields decrypted for export and re-encrypted on import, but excludes WebView cookies and sessions. Import restores the website list as a whole, rather than merging entries. Website tasks gate cold starts, reused shortcut intents and resumes withSecurityUi; defer reading credentials and loading/restoring pages until unlocked. An opaque cover blocks the live page while prompting without pausing opted-in background execution. While locked,FLAG_SECUREprevents screenshots/task snapshots. After unlocking, each website's "Prevent screenshots" option controls it and defaults off, regardless of whether a master password is configured. - Viewers:
TextViewerActivity/HexViewerActivity/ImageViewerActivity/MediaPlayerActivity(ExoPlayer + ffmpeg software audio decoding). The image viewer plays GIF and animated WebP on Android 9+ using the platform decoder; Android 7/8 and image comparison retain static frames. Only the displayed animation owns a decoder, outside the bitmap cache and prefetch. Pause it when leaving the foreground or switching images; never overlay static hi-res regions on live frames. Rotation wraps the live drawable and forwards invalidations. Detach the previous drawable before wrapping it, so ImageView does not clear the new callback chain. Music inside mounted restic repositories and archives uses an in-memory queue (Playlist.TEMP), with same-directory siblings and ordinary playback controls. These tracks cannot be favorited, saved into playlists or exported; player exit clears the queue. Resolve them through the live filesystem, without reopening a repository or archive from a persisted descriptor. Continue Watching (PlaybackStore) retains apendingNextdestination after confirmed local/file-backend episode completion, including automatic ending skips. This is a zero-position destination, never watched progress: quick exits, buffering and failures leave it intact; qualified progress replaces it. Reuse the loaded episode queue, independently of auto-play, and never create it for manual episode changes or server-owned playback. Existing next-episode progress wins. Only durable local/saved-connection locations can become destinations. Playback choice memory (MediaPrefStore+TrackMatch): the audio track, the subtitle and the picture scaling a user picks by hand are applied again on the next episode and the next time the file is opened (scaling rides along because it has the same scope — every episode of a show shares an aspect ratio — and needs no matching, the mode is the record). Two rules carry it: the record is the track's identity (language / label / codec), never its index — episodes order their tracks differently, so an index is a wrong answer waiting to happen — and a match belowTrackMatch.MIN_SCOREselects nothing, because playing an episode in a language nobody asked for is worse than one tap in the dialog. An external subtitle is stored as the tail of its name relative to the video's stem (.chs.srt), the only part that is the same for every episode. Scope is per file, falling back per series (Episodesprefix, or the queue's first episode for a media server, whose names are titles); only deliberate choices are written, never what the player auto-selected, or one wrong default would be frozen forever. One switch covers all three (Prefs.rememberMediaPrefs) — they are one wish, not three. The hex table itself isHexPane(one RecyclerView + oneHexSource+ the row rendering), shared by the viewer andHexCompareActivity; highlights come from a singlehitshook, so search hits and byte differences take the same rendering path. - File compare: ★ a listing that fails is unknown, never empty —
scanComparemarks such a directoryPairState.ERROR(no children, sync skips it), andstatSides/lookupChildreport failure separately from "not found". Reading a failed listing as empty turned the other side's contents into "extras" that mirror sync deleted (2026-09-17 review).CompareActivity(directory tree) dispatches a pair toImageCompareActivity(images) orDiffActivity(two-column text). ★DiffActivityhands binaries and oversized pairs on toHexCompareActivityrather than dead-ending ondiff_binary— whether a file is binary is only knowable after reading it, so that routing cannot happen back on the compare page. Hex compare aligns same offset against same offset with no resynchronisation (HexDiff): inserting a byte makes everything after it differ, which is the honest answer for the case this page is for (firmware, headers, patched binaries — structures that keep their offsets). Two constraints that are easy to get wrong, both with tests inHexDiffTest: both columns must share one bytes-per-row and one offset-digit count (otherwise the two offset columns disagree and "the same row on both sides" is a lie), and the shorter file's column is padded with blank rows (HexPane.padTo) — without that it bottoms out first and, since the columns push their scroll positions onto each other, drags the longer side back up, so the tail difference can never be reached. - Git:
GitActivity(status/history),DiffActivity(two-column patience diff); SFTP repositories run git remotely over exec viaSshGitData. - Terminal → pane (
TerminalActivity.locateCurrentDir): "Locate current directory" takes one of two routes, and which one depends on whether the directory is in the pane's tree at all. In it — a local path, or a remote one inside the subdirectory the SFTP connection mounts — it is an ordinaryMainActivity.revealIntent, so the user lands in the tree they know and the pane keeps the result. Outside it there is no row to reveal, soMainActivity.locateIntentpins the directory on top of the tree like an externally opened archive (PaneViewModel.mountLocated) and the back key takes it down, restores what was there and returns to the session — which is why that route does not finish the terminal. Reaching such a path needs a filesystem that can:Connections.sftpRootTwinregisters a second one for the same server rooted at/, kept for the life of the process (a copy in flight resolves its filesystem by scheme on every file). Where the directory itself comes from is its own problem, see docs/lessons/terminal-local.md. - Interop with other apps: two ways in (
SEND→ShareTargetActivity"copy to…";VIEW→ViewIntentActivity"open with Twig") and one way out (OpenFiles.openWithexposes a streamingcontent://viaStreamProvider). Plain shared text uses the same destination picker with an editable filename and UTF-8 bytes carried byShareSourceFileSystemas an internal data URI. Incoming data URIs remain forbidden byIncomingUri; attachments take precedence over text captions. Text sources are self-contained so retries need no Activity or cache file. File picking, the share destination picker and the main screen shareview_panes.xml,PaneLayoutand thePaneSwitchtoolbar control: single-pane mode uses one continuous track with the strip between panes; dual-pane mode shows both panes around the middle strip.PickerBrowsershares both picker screens' navigation, strip actions and pane-switch behavior. File picking also supports locked-source panes and retains its file/path/directory result modes. Each picker keeps independent root visibility and navigation; the toolbar switch or horizontal swipe changes the active pane. Only that pane controls the destination button, through a replaceableenableOnWritableDirobserver. The active side and edited filename survive Activity recreation.SortDialogis shared with the main screen; picker strip actions delegate search and history to the active pane, while compress and WiFi sharing stay hidden. Both pickers followPrefs.fullscreenthroughStatusBarFullscreen, including resume and window-focus changes. Copy/move and compression confirmation default to the other pane's directory but can choose another writable directory directly in the opposite resident pane. Main temporarily pins that pane active and shows a destination bar; source touch, keyboard/accessibility focus and destructive/context actions are blocked while choosing. Copy/move choose missing/read-only defaults first, followed by confirmation; Cancel/Back returns to the source, restoring an existing confirmation or keeping the checked files. Preserve the clipboard independently. A pane-owned in-memory draft retains sources, mode and choosing flags across rotation; process death discards it. Other hosts keep the directory-picker fallback throughPickerActivity. Confirm only writable targets outside the source directory/subtree, using the shared clipboard destination checks. Compression permits the sources' containing directory, while still rejecting selected directory subtrees. Its separateCompressConfirmDialogretains filename, format, move mode and password in the sharedDestinationStatedraft; passwords never enter saved view state and are cleared when the draft is discarded. Compression opens its form without a target too; "Save to source folder" uses the known writable common parent and leaves the other target intact until replaced. ZIP/7z offer Fast / Standard / Store, with the level remembered. Password-protected 7z defaults to encrypted file names, with an opt-out; ZIP file names stay visible. Long-press transfers remain immediate for valid targets; otherwise choose a target or show confirmation. Source-size planning is shared across recreation. Outgoing system sharing sends directly readable local files and known-size remote files up to 512 KiB directly. If a batch includes a larger or unknown-size nonlocal file, prepare its nonlocal sources in a separate cache asTransfers.Work.Share, usingTransferServiceandTransferBoxfor progress, cancellation, background execution and notification reopening. A headlessShareTransfersfragment only bridges resumed UI to the completed local URIs; there is no separate share dialog/ViewModel. Finished shares retain their cache leases until handoff, independently of ordinary transfer results. Preparation completion suppresses the success toast; notification and vibration follow ordinary operation preferences, except an entirely cached share does not vibrate. Preparation errors still show a toast. ExternalACTION_GET_CONTENTselections inPickerActivityuse the same preparation policy before returning granted URIs to the caller. Each picker owns its result by a saved ID: other resumed screens cannot consume it or launch a share chooser. Finishing the picker cancels its preparation and releases unclaimed results; internal imports and path/directory selections keep their existing streaming/path behavior. Completed files are reused by scheme/path/name/size/mtime when size and time are known; partial files are removed on failure/cancellation. Cache hits refresh the 24-hour idle retention, and the next preparation prunes expired files except those held by an active preparation. "Open with" continues to stream. Incomingcontent://URIs are all carried byShareSourceFileSystem(schemeshare), whoseopenRandomseeks throughopenFileDescriptor— so archives are parsed streaming without materializing and video can seek.ViewIntentActivityis a headless relay: it dispatches byOpenFiles' extension rules (falling back to the MIME major type) to the right viewer, while archives go throughMainActivity.mountIntent→PaneViewModel.mountExternaland are mounted at the top of the tree, expanded in place. ★ Do not addFLAG_ACTIVITY_NEW_TASKwhen dispatching: the temporary read grant on acontent://follows the receiving task stack, so in a new stack the viewer throws SecurityException on first read. That is also why base FileSystem registration moved from MainActivity toTwigApp.registerBaseFs— these entry points can cold-start the process with no main UI present. - Trash:
TrashStorekeeps local shared-storage deletions on their original volume;DeleteUidistinguishes recoverable and permanent deletion across pane/viewer/compare entry points. Confirmed deletions and trash purges useTransfers/TransferServicefor background progress; cancellation stops between backend calls.TrashFileSystemexpands in the pane;TrashDialogsrestores or purges entries. No privileged, SAF or network fallback for recycling. See trash safety. - Security:
secure/Secrets(two-layer Keystore/master-password encryption of sensitive fields) +secure/Backup(.twigbakconfig backup) +ui/SecurityUi(dialogs). See docs/lessons/security.md. - Themes: AppCompat screens and transparent relays inherit
TwigActivity, which selects the complete theme beforesuper.onCreate.AppThemelifecycle callbacks only track screens for theme changes; do not apply palettes or patch window colors there. OLED has separate opaque and translucent styles. Keep its window background as a resource reference: olderPhoneWindowversions cannot use an inline color there. - Action panel:
StripItemsdefines stable action identities;StripItemsDialogin the main menu uses drag handles and shared visibility choices. Order and visibility live intwig_prefsand are included in configuration backups.StripGrid.applyItemsreorders existing views while resetting GridLayout specs; retain listeners and orientation changes. Main and picker screens apply the same configuration, with pickers filtering unsupported actions. Pinned landscape navigation stays fixed. Duplicate defaults to hidden; tapping confirms editable copy names, while holding copies selected entries immediately into their own parents with_1,_2, … suffixes. Holding Rename applies the same numbering as an immediate rename of the originals. Existing numeric suffixes advance instead of stacking (file_1.txt → file_2.txt). File extensions are retained; dotted directory names stay whole. Duplicate copies use ordinary background transfers and retain their generated names across retries. Properties defaults to hidden and toggles the current/checked items; long-press requests the second page after metadata finishes loading, falling back to Basic for one-page cards. Quit is appended to the default action order and hidden by default; tapping uses the menu confirmation policy, while holding quits immediately. - Settings:
SettingsActivity(a hand-written standalone settings page: display / grid view / thumbnails, no preference library).Prefs.toolbarAtBottomdefaults off;ToolbarPlacementmoves the existing browser toolbar below content and matches navigation-bar tint to its background. Main and share-picker screens follow it; the main landscape layout keeps the toolbar hidden and the strip centered. The preference is included inuiSignatureso returning from settings recreates the main layout while retaining pane ViewModels.Prefs.backActionapplies only to the main screen after pane-local Back handling: close the screen (default), exit viaconfirmExit, or use the pane's Up action until the tree top, then close the screen or confirm exit (two separate choices). Legacyexit_on_back=truemaps to Exit; false/absent maps to Close until an explicit choice is saved. Back-triggered exits always require confirmation; explicit exit shortcuts ask only with active tasks. Music keeps its long-press Shuffle exit shortcut. Layout-affecting preferences (row height, grid, thumbnails) go throughPrefs.uiSignature—MainActivity.onResumecompares the signature and callsrecreate()on change; inPaneFragment.onViewCreated, when the VM already has rows, only callresortAll()and do not bootstrap again (after recreate the VM survives, and a second bootstrap is either skipped by an internal guard or breaks position restoration). - Transfer retries: failed copy/move/sync operations offer “Retry unfinished items” directly in
the failure dialog, along with the exact failing destination and “Skip this item”. Outside
touches must not dismiss this dialog or discard its partial files. Skipping
continues the remaining entries and keeps the skipped source (including its containing move
directory); repeated failures identify the next entry independently.
TransferCheckpointbelongs to that operation in memory only; there is no task history or persisted queue. Completed roots/files are skipped on retry; owned UUID.twigpartsiblings retain partial bytes until retry, success, cancellation or dismissal.ResumableWriteis an explicit optional capability (local direct paths, FTP, SFTP, SMB1/2/3, NFSv3/v4); other destinations useSafeWriteand restart incomplete files. Resume requires known source size and modification time. Original targets survive interrupted writes; only a complete temporary file can replace them. A moving retry must still finish a failed source deletion. - Operation completion:
OperationFeedbacktracks resumed Twig activities and checks screen/keyguard state; a detached progress dialog does not mean the app is in the background. Foreground completion notifications and completion vibration are independent, default-off preferences. Background/locked-screen results retain a notification; transfer failures also retain a notification in the foreground, independently of the successful-completion notification preference. Cancellation still suppresses result alerts. Completion also requests a toast regardless of app visibility. Running transfers always keep their progress notification.Transfers.finishhandles feedback and service shutdown on the main thread before notifying UI observers, which only refresh their views. Never decide whether to post a result fromTransfers.ui: completion itself clears that observer, making the old worker-thread check race with dismissal of an open progress dialog. Completion vibration uses alarm alert attributes, so silent ringer mode does not suppress it and background completion is allowed. Do not substitute touch usage: Android restricts that usage for background processes. See the Android haptics guide. - Thumbnails:
Thumbs(the engine). Two cache layers: an in-memory LruCache and a 100 MB disk LRU undercacheDir/thumbs; the key is md5(name:size:mtime) and is path-independent (moving a file or remounting still hits, changing it invalidates). There used to be an optional "share a.thumbnailsfolder next to the file" layer; it was removed entirely on 2026-08-06. Network images are gated by a preference — when off, only the embedded EXIF thumbnail in the JPEG header is read; PDFs need a seekable real file so they are generated for local files only. Files inside archives count as "network" (the host may live on SMB). Failures get a 60 s cooldown (not a permanent blocklist); the generation queue is FIFO with 2 threads; collapsing a directory callscancelPendingto drop its queued work. App icons do not go through thumbnails (2026-08-06): icons for APK files and for "Apps" tree entries (schemeapps) come straight fromFileIconsasking the PackageManager (keysapk <path>andpkg:<package>), asynchronously and with its own cache, unaffected by the thumbnail preference. Routing them through the thumbnail pipeline would only render the same image twice and burn a second copy of disk cache and a queue slot. Package directories in Android/data, Android/media, Android/obb and private app storage use the same installed-app icon cache (AppPackageFoldersidentifies direct children, including local aliases and external-storage SAF documents). Missing apps retain the folder icon. - Network video thumbnails (★ see docs/lessons/thumbnails.md for the full saga): instead of
downloading the whole file, Twig parses the container itself to locate the keyframe at
1/10 of the duration and downloads only ~2 MB. MP4 parses the moov sample tables
(
findKeyframeOffset); MKV parses EBML (SeekHead → Cues → target Cluster). All four network backends (SMB/WebDAV/SFTP/FTP) implement an efficientopenRandom(FileSystem.randomAccessEfficient() == true), which player seeking benefits from too. - Grid view (independent of the thumbnail preference): the same RecyclerView switches to
a
GridLayoutManagerand mixes spans — ordinary rows take a full span, file cells take one column, and the column count adapts to the pane width / 96dp. Directories and expandable items (archives) always take a full row, and tree expansion logic is unchanged. Group sorting lives inPaneViewModel.sortListand applies when the thumbnail master switch is on or grid view is on (tree list and grid are treated alike): directories → [in the grid's "All files" mode: expandable archives] → files that can produce a thumbnail → everything else, with the user's chosen sort inside each group. Archives get their own group only in the "All files" grid — there they are the only full-width row in a sea of cells, and leaving one in the middle would cut the grid in half. - SQLite: database files expand into dedicated table nodes; tables expand into a
pane-width virtual data grid, with row/column counts and Columns/DDL property tabs.
SqliteSessionowns read-only source leases and bounded typed previews;SqliteTreeowns cancellable jobs and an eight-page cache per table. Never model tables as writable filesystem directories or use displayed row offsets for future edits. Nonlocal sources use disposable snapshots including WAL; opaque parents come from the tree, and failed sibling listings cannot prove WAL absence. See SQLite browsing for limits, source consistency and the planned editing/query boundaries. - Info card: long-press → "Properties" inserts a tab card below the file's row
(
PaneViewModel.InfoNode+ a second viewType in FileAdapter); the content comes fromFileInfousing system APIs only (EXIF / MediaMetadataRetriever / PackageManager; media track details are enumerated with media3'sMetadataRetriever). No-full-read rule: anything requiring a full file read is not fetched automatically — remote APKs show no app info, and the hash tab computes automatically for local files but requires a tap for network ones. - Space map (SpaceSniffer-style treemap):
TreemapView(hand-written squarified layout; pinch zoom is a geometric transform and the font size stays fixed, so zooming in simply reveals more labels) +TreemapScanner, embedded inside PaneFragment (mapMode replaces the tree in place, it is not a new screen). The back key is routed throughMainActivity.onBackPressed → activePane().handleBack()(up one level / exit); the block menu and the tree's long-press menu sharecommonFileActions; "Select" on the map stores its multi-selection inmapSelected, and the sidebar's copy/move/delete prefer it viaselectionOrCurrent(); "Show on the other side" isPaneViewModel.revealPath, expanding level by level (local and connected server schemes only). - Navigation bar tinted to match the page (
ui/NavBarTint, 2026-08-21): the default solid black navigation bar is a jarring black strip at the bottom in the light theme, and even in the dark theme it is a shade off from the list background. Tinting it the same color makes it look transparent. We do not make it truly transparent — that requiressetDecorFitsSystemWindows(false)and handling insets in layout ourselves (the music player page already does exactly that, which is why it does not use this). Two things matter:- Take the actual background of the bottom-most layer, not
@color/bg: the file pane (fragment_pane) and both sides of directory compare use@color/surface, image compare is pure black, and the terminal uses the accessory key bar'sTermColors.keyBarBg()(terminal colors are their own scheme, unrelated to the app theme, so its tinting lives inapplyColors()and follows the scheme). One shade off and you can see the seam. - ★ A light tint is not allowed below API 26:
SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR, which darkens the navigation icons, only exists from API 26. On older systems the icons are always white — a light tint means white icons on a white background, so home/back are still there but invisible. In that case keep the system default (black).
- Take the actual background of the bottom-most layer, not
- WiFi sharing (
com.twig.app.share, 0.96.0): Twig's only "others connect to Twig" path.HttpServer(HTTP/1.1 on a bare ServerSocket: request parsing, Basic auth, Range, keep-alive, chunked request bodies, 100-continue) +ShareHandler(GET/POST for browsers, PROPFIND/PUT/MKCOL/MOVE/COPY/LOCK for WebDAV clients) +WebUi(self-contained HTML/CSS/JS with streamingMultipartupload) +ShareRoot(URL path ↔ XFile) +Discovery(UDP probe/response) +ShareService(foreground service). The UI is onlyui/ShareDialogs(config/status dialog + scan dialog), there is no separate Activity; the entry point is in the action bar (as_share), the icon becomes a filled fan while the service runs (syncShareIcon, refresh all three action bars), and the shared scope defaults to the green-highlightedPaneViewModel.currentDir— there is no directory picker in the dialog because the entry point sits next to the file tree and the location was already chosen before the dialog opened. A few settled decisions:- Read-only by default; every write method's check funnels through
ShareHandler.requireWritein one place, not scattered across handlers. - In "all backends" mode the top-level path segment is the scheme, not the display name: schemes are globally unique and derived deterministically from the connection label, so once a WebDAV client has saved one as a mount point, paths do not shift because another server got expanded. Display names are used only for the link text in HTML.
..is always rejected (ShareRoot.segmentsreturns null), and uploaded file names are reduced to their last segment — in this feature path traversal means handing out things outside the shared directory.DAV: 2must be advertised and LOCK must be implemented (even as a fake lock that does not actually lock): macOS Finder and Windows Explorer mount read-only as soon as they see no LOCK support, which would make writing pointless. LikewisePROPPATCHmust answer 207 rather than 501, or Finder decides the copy failed and deletes the file it just uploaded.- The WakeLock is held only while requests are in flight (
HttpServer'sonActivecallback); when idle the foreground service is enough. The WiFi lock, on the other hand, is held for the whole sharing session (once the screen is off and WiFi sleeps, the device disappears from the LAN). - No START_STICKY: if the process really was killed, the sockets and threads are
gone, and the system restarting the service would only produce an empty shell with
session == null. Dying with the process is more honest.
- Read-only by default; every write method's check funnels through
CodeHighlighter: hand-written lexical highlighting, shared by TextViewer and the two-column gitDiffActivity— diff tokenizes each side's whole text once and then slices per line withsubSequence(only that way are multi-line comment/string states correct). DiffActivity uses the user's chosen theme when its lightness does not clash with the current system light/dark mode; when it does (e.g. Monokai while the system is light) it temporarily substitutes the matching default (light → GitHub Light, dark → Monokai) so the diff area does not collide with the toolbar/status bar, which still follow the system. This affects only that display,Prefs.codeThemeis untouched.listLeft/listRightbackgrounds switch totheme.bgand the default row text color totheme.fg; added/removed row backgrounds are translucent overlays, so they read as reddish/greenish over any background and no per-theme palette is needed — before 2026-07-28 there was adiffTheme()that unconditionally fell back to GitHub Light based on background lightness, because the background was not following the theme and dark themes were unreadable. The root cause was the background, not whether the theme should be used.
resolve()is for navigation;stat()is for metadata;exists()answers existence (settled 2026-09-18). Six backends return an optimistic stub fromresolve(SFTP/SMB never stat, FTP and restic assume a directory, an archive invents one, SAF drops the display name), which is why building a file to write from it produced a directory and trusting its size stopped network audio on the first read.stat(path): XFile?is the honest one — real type, size, time, display name, or null. Its default lists the parent and matches by path (a media server's name is a title, a SAF path is a URI), and local / archives / restic / SFTP / WebDAV / SAF override it with a real stat. ★ For many entries of one directory, list that directory once instead (MusicEngineandCompareActivity.statSidesbatch on purpose).- Size first: no heavyweight dependencies; if it can be hand-written, hand-write it (WebDAV without an SDK, git reimplemented, restic decrypted ourselves). Before adding a dependency, measure the APK delta.
- R8: obfuscation is on. JNI (libsmb2/zstd) binds by name, so native methods and their
class names must be kept; Termux and BouncyCastle are also kept in
app/proguard-rules.pro. Read that file before changing how those libraries are called. - User-facing text lives in two places; before adding a string, work out which layer
you are in:
- Inside
:appeverything goes throughstrings.xml(default English plus all supported locale directories, with identical translatablenamesets). Native + English language labels (language_zh,language_en, etc.) aretranslatable="false"and live only in the defaultvalues/strings.xml; onlylanguage_systemis translated. Keep new languages inLanguagePrefandxml/locales_config.xmlin sync.FileInfo,GitVfs,SafFileSystemand friends have a Context even though they are not Activities — there are no exceptions. - Pure JVM modules (
core-fs/fs-*/git-lite, using thekotlin.jvmplugin) have no Context and no R, so their messages are always English. Their exception messages get toasted to the user throughit.message, and turning those modules into Android libraries just to localize them would cost the millisecond-fast pure-JVM unit tests — not worth it. If localization is ever really needed, the way is "FsExceptionwith an error code + a lookup table in the UI layer", not moving resources around.
- Inside
- Commit messages: English Conventional Commits,
type: task name — summary - The settings triad: a new display feature = its own preference (never bolted onto
another preference; keep them orthogonal) + a row on the settings page + only when it
is something you flip back and forth a quick entry in the top-bar menu (decoupling
grid from thumbnails in 0.45.0 is where the orthogonality rule came from).
- ★ The menu holds only high-frequency toggles (settled 2026-08-26): thumbnails, grid view, remember position, fullscreen and theme are things you may flip several times a day; anything you set once and never touch again belongs on the settings page only — row height, text size, language and "show hidden files" have all been removed from the menu. The menu is not a mirror of the settings page; every extra item dilutes the few that are genuinely used.
- Row height and text size are two independent preferences (split 2026-08-26,
Prefs.density/Prefs.textSize): previously one setting drove row height, icon size and font size together, so "shorter rows but readable text" was impossible. ★textSizereturnsdensity(ctx)when it has never been set — so the appearance is pixel-identical before and after the split and upgrading users see no jump. ★★ But "fall back to the default" must be cut off the moment the user changes row height:setDensityfirst writes the current text size out explicitly (using the density level from before the change, so the user sees nothing change), otherwise someone who never touched text size still gets their font size dragged along by row height and the split accomplishes nothing. The assertion is inRowSizeSplitTest, which measures the bound row height in px and font size in px, not "what the preference reads back as" — the latter is green even on the version where the adapter still derives the font size from density. - Unit tests live in each
:fs-*module'stestsource set (zip/ftp/sftp/webdav/restic/ git all have them); run them with e.g../gradlew :fs-network:test.
Each entry is the closing record of a real incident — the symptom, the wrong diagnosis, the root cause and the current solution. They live one file per area under docs/lessons/; this is the index, and it is deliberately only a pointer: before changing code in one of these areas, open its file and read it first. Several of these took two or three shipped regressions to pin down, and stepping on them again is expensive. When you close out a new incident, append it to the matching file and add its symptom here.
- SSH, SFTP and the terminal — every outbound SSHJ call (resize included) runs on a background thread and network exceptions are never swallowed: an NMTE poisons the cipher stream, so the connection dies on the next packet. No local process is started; the emulator is injected reflectively. Full BouncyCastle must stay. Explains: "it disconnects on the first keypress" on every server, htop's blank margins, keystrokes dropped after a reconnect.
- git viewer — never use
FileSystem.resolve()to test existence or type;.gitmay be a file, and gitdir ≠ commondir with per-worktree files routed between them. Caching is three layers deep and no refresh reaches the bottom one. Explains: empty history/branches/diff with no error, "Worktrees (3)" that expands into nothing, new commits missing after a fetch. - Thumbnails and frame grabbing — never let
MediaMetadataRetriever seek by itself over a slow network: parse the container (MP4
sample tables, MKV EBML/Cues) and feed it only the keyframe bytes. A fat moov is its own
bottleneck: download only the video trak and serve every other child box as a same-size
freestub, never a shorter moov. An algorithm change that invalidates a cache means renaming the cache directory. Explains: 111 MB and 14 s for one thumbnail, green or black frames, a multi-audio 4K release whose thumbnail only appears if you wait and retry, a flat-looking waveform, cover rounding that never shows, slow local FLAC waveforms caused by sending already decoded PCM through a raw codec, ALAC that plays but has no waveform because the platform lacks an ALAC decoder. - Media playback — internal media URIs must carry a
real extension (
twig:///media.$ext); M2TS packets are 192 bytes and HDMV stream types collide with the standard registry; never allocate in aDataSource.read()hot path; the player needs an explicitLoadControl(media3's default buffer is 137MB against a 256MB heap, and its small local-playback tier is picked by URI scheme, whichtwig://never matches); AVI needs its samples repaired before any decoder sees them — MP3 is a byte stream to be cut back into frames (with a double-sync check), and MPEG-4 arrives in coding order, sometimes two VOPs to a chunk, sometimes as 1-byte stuffing; episode queues come from the backend first, file names second. Imported songs missing from the live queue during a playlist switch come from deduplicating against the previous queue. Explains: playlist info/share doing nothing before metadata loads, music notifications lingering after app exit, history video taps appearing unresponsive before the player opens, fast lyric swipes seeking or toggling the cover, properties stuck loading on a damaged MKV, an AVI that plays as 300 ms of audio, no sound on a TrueHD Blu-ray, "m2ts seeks slowly", black AVI thumbnails, "half my AVIs open at 13 seconds and then freeze", "the picture keeps shaking", "this one plays at two thirds speed", "this one will not open at all and has no thumbnail", the next episode not starting, "4K over the network quits after a while and then the app asks for my master password again". - Archives — adding an entry to a zip appends
(rewriting means decompressing and recompressing everything); encryption detection must
come after
rootOf(); encrypted archives are read-only; expanding an archive selects its root as the paste target; never read a zip withZipInputStream— a STORED entry with a data descriptor has no readable length outside the central directory; "open with another app" on a compressed entry must materialize first, never proxy it (fastRandomfalse means "reopen and skip", not "somewhat slower"); an entry with a..segment stays visible and openable — the copy is what refuses it (zip slip). Explains: 10 GB of I/O to add ten small files, a remote encrypted archive that never asks for a password, "I cannot paste into this archive", an APKPure .xapk that fails to install with "only DEFLATED entries can have EXT descriptor", ZIPs with invalid filename bytes failing with "Error reading Zip content from FileChannelImpl" (#53), a PDF inside an archive that no app (built-in or third-party) can open, followed by the whole tree freezing up and a copy failing with ENOENT until retried. - WiFi sharing — read-only by default through one
requireWritefunnel,..always rejected,DAV: 2+ LOCK or Finder mounts read-only; addressing usespathand links are built fromrawPath. A new mode needs tests for that mode. Requests run without the root/Shizuku fallback, and the app's data directory is never served. Explains: a corrupt download (multipart boundary), "I copied it and the source vanished", a non-ASCII directory that will not open, buttons squeezed to 1 px. - Jellyfin / Emby — the biggest file, and the one to
read in full before touching media servers: the tree follows the server's own
organisation, paths are ids so
displayNamemust travel everywhere, no metadata request may sit in the streaming path, and the two servers differ on Resume, lyrics and images. Arguments here are settled bydocker running both servers, not by reasoning. Explains: "Continue watching is always empty" on Emby, square posters, slow covers, 4K stuttering on this backend only, search at the root doing nothing, songs listed as "38",Unknown Jellyfin directory: /lib. - S3 — the request path is RFC 3986 while object names in
responses are form encoded (
+= space) and the continuation token is opaque; the signed string must match the bytes actually sent; never bring in the AWS SDK; abort a failed multipart upload. Explains: SignatureDoesNotMatch with no hint, a listing that looks fine and 404s on click, empty directories vanishing after a refresh. - Privileged access (root / Shizuku) — elevation
is a fallback inside
LocalFileSystem, not a new scheme; the stderr drain must not share the exec lock; the extracted.somust never be writable even for an instant; the privileged terminal is its own menu item and never falls back silently. One-shotcatstreams must check the exit status — EOF is not success.suhas to be a child of a shell, never the process the pty was forked into. Explains: a command that hangs until timeout,/sdcardlisting as empty, "the dialog has no options at all", a root process left alive after an upgrade, a file that vanished when moved into a place even root could not write, a root terminal that exits with code 1 the moment it opens and prints nothing,/systembeing readable while/cannot be listed. - RAR and F-Droid —
fullandlibrediffer in RAR alone; check the scheme viaArchives.RAR_SCHEME, neverRarFileSystem.SCHEME; the verification criterion is the dependency graph, not that it compiles. F-Droid's scanner reads the whole tree, so the split alone does not get a build through — the module has to berm'd. Also records every rejected route (unrar-free, porting, clean-room) so they are not researched again, and what reproducible builds took: AGP's dependency-metadata block has to be turned off, and AGP 8.5.2's bundled R8 is not deterministic across machines. Explains: "libre has no junrar on its classpath, so why did fdroid build stop on it", "F-Droid's build matches ours in every byte but four". - Password encryption, config backup and the app lock —
toggling the master password or fingerprint rewrites no field ciphertext; on any crypto
failure return the input unchanged; every exported Activity must call
SecurityUi.gate; views andlateinitfields are created inonCreateand only data reads go in the gate callback; unlocking must happen before pane initialisation. Only a permanently lost Keystore key may regenerate the DEK; exported entries never read the app's own data, and stream URIs are HMAC-signed. Explains: a crash right after entering the master password, "after unlocking, WebDAV login fails until I re-save the connection", a password field rendered in plain text, saved archive/sharing passwords included in system backups, document previews fetching tracking pixels, computer WebView debugging still available after its switch is turned off on userdebug systems, authenticated website tasks opening from shortcuts or recents while Twig is locked. - SAF — a SAF path is a whole document URI, so nothing may slice it: ask the tree who contains a row instead of computing a parent. The grant is persistable, so SAF entries are local (real fd, pread), not outsiders. Explains: music opening in the single-track player, no thumbnails or PDF previews, the green highlight disappearing on "Up", "why is there no go to the containing folder here", a local-to-SAF copy creating an empty document but writing to a bogus local path. Removable-volume grants also route the original local entry through SAF when direct access is restricted (#47).
- Trash and deletion — sync the recovery record before moving, never replace a failed trash move with permanent deletion, preserve offline/corrupt entries, and purge symlinks without following them. Explains: how to recover interrupted moves, why Robolectric needs real lstat semantics, and Android 11 FUSE rejecting no-replace moves with EINVAL.
- Storage — shared-profile storage uses the public
API before directory enumeration; never assume a clone user id.
getStorageVolumes()does return invisible removable volumes, the event channels do not fire for them (the foreground UI polls), filter byisPrimaryrather thanisRemovable, and/mnt/media_rwneeds elevation. Explains: app-clone storage missing beside Internal Storage (#55; Samsung device verification pending), a USB drive that only appears after restarting the app, internal storage listed twice, capacity reads blocking the UI during device changes. - Terminal: local shell, fonts, zoom — only
returning to the foreground may arm the size thaw; a terminal font is acceptable only
at a 0.5em advance;
attachSessionforks the shell itself; the key bar shares one text size. A local shell reports nothing about itself: the session list's second line is an OSC title, which Android's mksh never writes — the prompt has to write it (the directory, and only the directory), with the escape wrapped in mksh's\001delimiters or line editing drifts; a privileged session sources no rc at all unless$ENVnames one the shell uid can read. Never type such a hook into a remote shell: it is echoed, and it lands in that server's history. Explains: a garbled screen after screen-off, fat Chinese and a thin●, tab completion finding no commands, pinch zoom doing nothing, clipped key labels, a local session whose row stays one line while SSH rows show what is running. - Tree and adapter — a duplicate row key makes
DiffUtil pick the wrong row; for async expansion the later tap wins, and "do not
accordionExpand" ≠ "do not expand"; writes are gated by
isMutable()/isWritableDir()at six entry points; selection changes use payloads, nevernotifyDataSetChanged. Source-directory imports require read access; reusing the writable-destination gate makes read-only music directories impossible to select. Explains: directory-tree labels staying in the system language after an app-language change on Android 11, a fullscreen empty band above the toolbar or beside the landscape cutout (including websites with the status bar hidden), a landscape Terminal button missing without active sessions, the same archive empty in one place, a directory that closes itself, a spinner that never stops, the whole list flickering when you tick a box, collapsing Apps / Installed crashing after an app APK was opened as an archive and restored, the Properties ripple disappearing on the first tap with folder counts enabled, fast pane swipes also clicking a node, website refresh firing midway through a fixed-header page, webpage video fullscreen buttons doing nothing, website fullscreen adding an extra Back step, batch-rename preview columns shifting until scrolling, content-read errors offered as sync differences, external multi-file folder taps selecting entries instead of expanding them. - Website background execution — skipping WebView.onPause does not protect a cached host process from Android freezing. Opted-in website sessions hold a general foreground service until closed; register from the Lifecycle RESUMED event, share ownership across pages, and never restart stale pages. Explains: website music stopping after backgrounding and resuming when the page returns.
- Text handling and editing — one decoding entry
point (
TextCodec), where order is priority; write back in the encoding the file was read with and never silently substitute?; cap highlighting by longest line as well as file size to avoid minified-code measurement ANRs. Explains: mojibake in subtitles but not in the viewer, another program showing garbage after you edited one line, an invisible caret, a caret sitting on the last character. - Other backends and odds and ends — launcher
shortcut icons must not rely on
android:tintand dynamic shortcuts follow the app's language; libsmb2's context is not thread-safe, so never name a member functionrun; do not SSH into servers to read logs. Saving a connection replaces it in place — the stored order is the sidebar's order, and host keys / tokens are written back the moment a server is expanded. ★ AContentProvidermust userequireNotNull(context)—requireContext()is API 30 and nothing warns you below it. Explains: a built APK rejected at install with “intent tag may have at most one data scheme”, a white, invisible shortcut icon, an empty long-press menu, an SMB crash while thumbnails load, a server that jumps to the bottom of its group the first time you expand it after importing a backup, "track info closes the music page" on Android 9, an SMB1 cold connection that waits on WORKGROUP but is fast on subsequent access.