Architecture
September 9, 2026 ยท View on GitHub
Voyager is a single-activity Jetpack Compose application. VoyagerApp retains the browser ViewModel and transfer controller for the process lifetime. MainActivity owns the storage-access decision and renders either the permission choice or the navigation graph. FileBrowserViewModel owns browser state, provider sessions, file operations, persisted preferences, saved connections, bookmarks, and Trash state.
UI and navigation
AppNavigation defines Home, Browser, Categories, Remote Connections, Trash, and Settings destinations. Home presents storage volumes, common folders, local bookmarks, document-tree access, active sessions, Trash, and remote connections. Browser renders a session-aware toolbar, breadcrumb navigation, search and type filters, list, compact-list, or grid content, contextual selection actions including archive creation and extraction, progress, empty states, retryable errors, and a read-only details sheet. Navigation uses directional transitions with an opaque theme background; Back reverses the direction. Pull-to-refresh uses the existing load guard and resets its UI state on path, source, and session changes. Bookmark actions derive their label from persisted state, and Home provides an accessible removal action.
The UI uses Material 3 components and theme tokens from ui/theme. Direct-local deletion offers recoverable Trash and explicitly irreversible permanent deletion per operation. Permanent-only provider deletion, permanent Trash deletion, emptying Trash, deleting saved connections, and saving a cleartext remote transport require explicit confirmation where appropriate.
Browser state and sessions
FileBrowserViewModel exposes immutable StateFlow values to Compose. Each open location is represented by a BrowserSession with a stable ID, source, root boundary, current path, and optional remote connection metadata. A provider instance is retained per session so local, SAF, and remote state cannot be accidentally mixed.
Directory requests use DirectoryLoadGuard tokens. Only the newest request for the active session may update the visible file list, which prevents a slow response from overwriting a later navigation result. The asynchronous default-folder load is canceled when explicit local, document-tree, session, or remote navigation begins, so startup work cannot replace the user's chosen location. Search and type filtering are derived from the loaded directory in BrowseState; sorting preserves directories before files.
File providers
All storage backends implement FileProvider, which defines listing, metadata, create, delete, rename, copy, move, existence checks, parent navigation, and input/output streams.
| Provider | Path form | Notes |
|---|---|---|
| Local | Absolute filesystem path | Direct access requires Android storage permission; operations validate conflicts and self-descendant copies. |
| Root | Absolute filesystem path | An explicitly confirmed session uses su -c through RootShell. It is a distinct source and never changes ordinary local access. Listing has byte and entry limits; file data uses process pipes. |
| SAF | Document URI string | Uses persisted document-tree grants and DocumentsContract; parent relationships are learned while browsing. |
| SFTP | POSIX remote path | Uses JSch with Bouncy Castle hybrid post-quantum key exchange, one SSH session with per-operation channels, app-owned known hosts, and streamed transfers. |
| FTP | POSIX remote path | Uses Commons Net in passive binary mode with 64 KiB transfer buffers; data connections stream and provider-local copy uses bounded cache files. Metadata for one path comes from MLST where the server offers it, otherwise from a directory change or a one-entry listing, and its name is always taken from the path because servers such as ProFTPD and Pure-FTPd answer a single-file LIST with the path as requested. |
| SMB | Slash-delimited UI path | A configured share keeps the direct-share model. A blank share exposes a read-only server root whose disk-share entries map /<share>/... to SMBJ paths; file handles stay open for the lifetime of returned streams. |
| WebDAV | Slash-delimited remote path | Uses Sardine for metadata operations. Coordinator transfers stream through an OkHttp request body so progress measures the HTTP upload; generic output streams use a temporary-file request body when a caller requires the OutputStream interface. File taps can create an independent authenticated OkHttp byte-range source for direct external playback. |
FileOperationCoordinator handles copy and move across different providers. Destination names are checked by listing the target directory, then each file or directory is created through FileProvider; all writes, recursion, and cleanup use the canonical identifier returned by that create operation rather than deriving a child path. This is required for opaque SAF document URIs and also keeps provider-specific path rules behind the abstraction. The coordinator removes a newly created partial destination after failure and deletes a move source only after the copy succeeds. Providers handle same-provider operations so they can use native rename or server-side copy behavior when available.
Android sharing is intentionally limited to local and SAF files in the current implementation. ShareIntentPlan rejects directories and remote items, computes the narrowest common MIME type, and chooses ACTION_SEND or ACTION_SEND_MULTIPLE. FileUtils exposes local files through the app FileProvider, preserves SAF content URIs, and grants read access through both the intent flag and ClipData. Opening a local or SAF file sends ACTION_VIEW directly so Android can apply its resolver and saved default-app behavior.
Direct opening is available for WebDAV files. WebDavRangeSource authenticates every request, requires a valid byte-range probe, validates each 206 Partial Content response and exact Content-Range, and owns an OkHttp client that is independent of the browser session. PlaybackTokenStore maps a cryptographically random 256-bit token to the source and safe display metadata. Each open descriptor holds a lease; closing one descriptor preserves other readers and allows an application to inspect a file, close it, and reopen it. The ten-minute idle lifetime starts after the last descriptor closes, and a periodic sweep closes expired sources. Explicit revocation invalidates active leases. WebDavPlaybackProvider is non-exported and read-only; an external application receives one URI-specific read grant and accesses a proxy descriptor that translates seekable reads into validated WebDAV ranges. The URI contains only the opaque token, never a remote URL, path, connection identifier, username, or password. Tap and Open with use the file's MIME type. A failed preparation presents Download and Cancel instead of silently staging the file. SFTP, FTP, SMB, and directories retain their existing navigation or download behavior.
Pinned folder shortcuts target the main Activity through an explicit intent. Canonical destination validation restricts launches to readable directories within currently accessible shared-storage roots. Pending requests survive Activity recreation and wait for full storage access; filesystem checks run on IO and navigation runs on the main thread. Missing or disconnected destinations produce a recovery message.
Archives
ArchiveService operates only through FileProvider streams and create, list, metadata, and delete methods, so the same workflow applies to local storage, SAF document trees, SFTP, FTP, SMB, and WebDAV. It creates ZIP files and extracts ZIP, TAR, TAR.GZ/TGZ, TAR.BZ2/TBZ2, GZ, and BZ2 inputs. RAR is recognized but intentionally unsupported.
Archive extraction creates a new sibling directory and never overwrites an existing item. Entry paths reject absolute paths, drive and UNC paths, blank or dot segments, traversal, and NUL characters. ZIP and TAR links, special files, unreadable or encrypted ZIP entries, duplicate normalized paths, conflicting entry types, and corrupt archives fail closed. Failed creation or extraction removes the partial archive or extraction root. ZIP input is staged through a bounded-memory copy to an app-private temporary file so central-directory metadata, including Unix link modes, can be checked before each entry is written.
Previews
Local and SAF video previews decode a scaled frame away from the UI thread, with a pixel cap before legacy decoding on API 26. OOXML previews read only embedded PNG/JPEG thumbnails from bounded ZIP archives; external references, traversal, excessive entries, oversized images, and malformed metadata fall back to the file icon. SAF staging has a byte limit and is removed after use. Trash rows use the original filename for classification and the payload path for decoding.
Storage access
On Android 11 and later, full local browsing uses MANAGE_EXTERNAL_STORAGE. A user who declines can explicitly enter limited mode, where SAF document trees and remote connections remain available. On older supported versions, Voyager requests legacy read and write storage permissions.
FileUtils.getStorageVolumes adapts Android StorageManager volumes into StorageVolumeInfo. Mounted internal, SD card, and USB/OTG roots appear directly on Home when a filesystem path is available. Unmounted or otherwise unavailable media remains visible as disabled state rather than failing silently.
Home Quick Access opens APK, video, audio, image and document categories across mounted shared-storage roots. StorageCategoryIndex streams directory entries on IO with cooperative cancellation and permission checks. It does not elevate privileges or scan app-private storage or SAF grants. The category screen follows the hidden-file setting, refreshes on resume, stops scanning on departure/backgrounding, and displays per-volume status with visited-file, inaccessible-entry and skipped-entry counts. Folder descendants skipped because of access, hidden-file policy, links, duplicate roots, special files or a 128-level depth bound remain unknown, not counted as empty. The in-memory result list stops at 50,000 matches with an explicit incomplete status. Opening a result revalidates current mounted scope, readability, type, size and modification time; stale results are removed and permission revocation clears the index. Ordinary Folders and Bookmarks keep direct folder navigation.
Trash
DuplicateScanner runs only on an explicitly chosen local folder. It streams regular files into size groups, hashes candidate groups with SHA-256, excludes links and Trash directories, and reports missing, changed and unreadable files separately. The scan stops at 100,000 files or 200,000 visited entries and labels limited coverage. Selection starts empty and must retain an unselected copy. removeVerifiedDuplicates rehashes the selected file and an unchanged keeper immediately before each removal; the retained operation controller serializes the confirmed Trash/permanent action. System Back and toolbar Back both refresh the browser. These pre-operation checks cannot prevent unrelated processes from changing files after validation.
Local deletion uses LocalTrashManager by default. Each configured volume has a hidden .VoyagerTrash directory containing one payload and metadata file per entry. A pending directory makes the move recoverable if finalization is interrupted. Restore recreates a missing parent directory but refuses to overwrite an existing destination. When Trash is enabled, direct-local deletion presents both Trash and permanent choices without mutating the saved preference. SAF and remote deletions remain permanent and use permanent-delete confirmation wording.
Persistence and secrets
Room stores remote connection records and local bookmarks. DataStore stores theme, display, sort, Trash, limited-mode, and default-path preferences. ConnectionRepository is the only ViewModel-facing saved-connection layer: it encrypts passwords before Room writes, decrypts rows for the editor and providers, and atomically migrates legacy plaintext passwords.
AndroidCredentialCipher uses AES-GCM with a random IV and a non-exportable Android Keystore key. If an encrypted value cannot be decrypted, the repository exposes an empty password so the user can edit and save the connection again. Android backup rules exclude Room databases, DataStore, generated SSH material, and SFTP known hosts because Keystore keys are device-bound and connection state is sensitive.
SFTP retains JSch's secure default algorithm policy and uses Bouncy Castle to make the ML-KEM and SNTRUP hybrid post-quantum key exchanges available on supported Android versions. It stores first-seen host keys in files/ssh/known_hosts and rejects changed keys. SftpKnownHosts serializes updates against the latest on-disk repository; targeted removal preserves unrelated host identities, aliases, and comments. The editor displays SHA-256 fingerprints and requires confirmation before forgetting the selected host and port. Generated private keys stay in app-private storage while the corresponding OpenSSH public key is exposed through explicit Copy and Save actions. WebDAV transport is explicit and supports HTTPS on any port. FTP and HTTP WebDAV are cleartext and require a warning confirmation in the connection editor. Voyager advertises SMB encryption and uses it when negotiated, including servers that require it; SMB2 servers that do not support encryption remain accessible. SMB share discovery uses Rapid7 dcerpc 0.12.13 over the authenticated SMBJ 0.13.0 session and filters the level-1 server response to disk trees. Its older transitive SMBJ and Bouncy Castle artifacts are excluded so Voyager retains SMBJ 0.13.0 and Bouncy Castle 1.85. The library's malformed-response path names java.rmi.UnmarshalException, which Android does not supply; release shrinking suppresses that missing-class warning, provider boundaries catch discovery failures, and successful enumeration is verified on Android rather than assumed from JVM compatibility alone.
Concurrency and failure handling
FileOperationCoordinator suspends destination conflicts for Replace, Skip or Cancel, optionally applying a decision to later conflicts. Replacement writes to an owned sibling, temporarily renames an existing destination, promotes the staged copy, and rolls back failed promotion. A move removes the source only after copying succeeds. Provider identity checks reject self-copy, self-move and ancestor targets, including SMB discovery/direct-share aliases. Skipped items have separate terminal counts. SMB transfer streams own their transport independently of browser sessions, allowing cancellation to close sockets during connection, read, write, flush and close without invalidating the browsing connection used for rollback.
AudioToneInstaller validates bounded local/SAF audio, creates an owned MediaStore copy, and preserves the source. Android system-settings permission is checked before installation. Cancellable copying precedes a short final commit that publishes the item and sets/reads back the system tone; failed copies remove their owned records. API 26 uses the legacy MediaStore file path, while newer versions use pending publication.
TransferOperationController owns an application-scoped coroutine and serializes operations independently of Activity recreation. A data-sync foreground service publishes progress and supported Cancel actions while holding a bounded wake lock. Notification intents carry distinct operation identities so an old action cannot cancel a new transfer. Cooperative cancellation closes transport resources, checks stream and recursive boundaries, and retains Running/Cancelling until rollback finishes; operations without cooperative cancellation do not expose Cancel. SAF destination pipes and sockets use bounded waits so provider backpressure cannot indefinitely trap a cancelled write. Completed, failed, and cancelled results remain visible until dismissed or another operation begins. Counts describe completed requested items, including at the first and final update.
Filesystem and network work runs on Dispatchers.IO. OperationState serializes user-initiated mutations and drives an accessible progress indicator. Cross-provider copy and move, document upload, and recursive remote download all use the same bounded StreamTransfer primitive, which reports the active path, transferred bytes, an optional trustworthy total, and monotonic elapsed time. The UI derives average transfer speed from those values, shows determinate progress only when a byte or item total is known, and does not convert an unknown total to zero. Stream updates publish on the first event, item or path changes, completion, and whenever either 256 KiB or 250 ms has elapsed since the previous update, so both fast and slow transfers remain responsive. Archive operations publish their own per-entry byte progress. Provider output-stream wrappers that add close-time behavior extend ForwardingOutputStream, which passes each buffered chunk to the wrapped stream in one call; a plain FilterOutputStream would split every chunk into single-byte socket, SFTP packet, or file writes. Provider references and operation inputs are captured before asynchronous work begins so switching locations cannot redirect an in-flight mutation. OperationMessages maps conflicts, permission denial, missing items, unreachable hosts, timeouts, and archive failures to recovery-oriented feedback.
Root sessions
RootFileProvider supports listing, create, rename, copy, move, deletion, and streamed regular-file access. Root and local path aliases are compared through the privileged provider before cross-provider replacement. Every command quotes path arguments; NUL-delimited listing records preserve embedded newlines. RootShell starts an isolated privileged supervisor and a separate command process group, caps buffered command output at 4 MiB and retained stderr at 16 KiB, and limits concurrent commands. Bounded, acknowledged input frames distinguish successful completion from cancellation. The supervisor terminates the command group on cancellation, inactivity timeout, or session closure, including descendants holding a blocked stream open. Missing command-isolation tools fail before the requested operation starts. Root denial and read-only filesystem failures remain visible.
Settings opens root access only after confirmation. A root file tap opens the in-app UTF-8 editor, limited to 256 KiB; text stays in the ViewModel across Activity recreation. Saving uses the shared operation controller so it cannot race a move or another mutation. A busy controller or foreground-service startup failure preserves the draft and original. Save creates a private sibling directory and binds its parent, staging directory, and exclusively created payload to verified file descriptors. Byte and metadata writes use the held payload descriptor; promotion verifies its inode and link count. It preserves owner, mode, and the SELinux label where present, and rechecks the original file identity, metadata, label, and SHA-256 immediately before replacement. Cleanup verifies the staging directory identity and never recursively follows a replaced pathname. Changed originals, symlinks, hard-linked files, invalid UTF-8, binary data, and special files are rejected for editing. The editor does not export privileged files to external apps. Root deletion retains permanent-delete confirmation and bypasses Trash. An interrupted process or device shutdown can leave a hidden .voyager-root-* staging sibling; the original remains in place until promotion. Extended attributes other than the SELinux label are not guaranteed to be preserved.
Tests
JVM tests cover pure models, sharing plans, validation, opaque provider identifiers and playback tokens, strict WebDAV range responses, stream progress and throttling, unknown-size completion, operation safety, archive formats and hostile entry handling, Trash recovery, credentials, storage adapters, navigation races, and embedded FTP, SFTP, and WebDAV servers. One opt-in Docker test restricts an OpenSSH-backed SFTP server to ML-KEM and SNTRUP hybrid post-quantum key exchanges, authenticates with a key generated by Voyager, transfers a probe file, and performs a remote ZIP create/extract round trip. An opt-in Samba matrix uses a digest-pinned container to verify required SMB3 encryption, optional encryption, SMB2 compatibility, wrong-password rejection, authenticated RPC share enumeration, direct-share access, exact read/write bytes, and repeated session cleanup. Credential-driven SMB integration remains available for an external disposable server. Android instrumentation tests cover Compose rendering, Android file-open and WebDAV playback intents, seekable read-only playback proxy descriptors, explicit playback-download fallback, upload metadata, image, PDF, and APK thumbnails, Android TV focus and select keys, optional on-device SMB RPC integration, generated public-key export, operation progress, archive menus and round trips, unavailable storage, single and multiple share intents, contextual selection actions, per-operation deletion choices, compact-view persistence, file details, search-to-folder Back behavior, selection-control accessibility, and Android Keystore behavior. See TESTING.md for commands and the manual regression matrix.