Storage: support external volumes, existing image import, and relocation - #279
Open
zennnez wants to merge 8 commits into
Open
Storage: support external volumes, existing image import, and relocation#279zennnez wants to merge 8 commits into
zennnez wants to merge 8 commits into
Conversation
Stop leaving the terminal background transparent in light mode. Use MaterialTheme.colorScheme.surface as the terminal view background for light theme, always call setBackgroundColor, and set color index 257 (terminal default background) for both themes. This removes the dark-mode-only handling and makes terminal background consistent with the app Material surface color.
Introduces the low-level plumbing needed to reliably operate on external storage (SD cards, USB-OTG drives) as root on Android. - RootNamespace: routes root shell commands targeting /storage or /mnt/media_rw paths through the volume's live physical mount point where possible, or nsenter's into the app's own mount namespace (which already has a working FUSE view of external storage) as a fallback. Root's own namespace only sees external volumes if a same-process bind mount happened to succeed, which isn't reliable across devices/ROMs. - StorageMountManager: resolves an SD card/USB volume ID to its current physical kernel mount point (/mnt/media_rw/<volId>), bind-mounts it into /storage/<volId> for root's namespace, and detects stale bind mounts left over from a USB disconnect/reconnect by comparing device IDs rather than trusting mountpoint(1) alone. - SafPathResolver: converts Storage Access Framework document/tree URIs into plain filesystem paths, needed for USB drives the root browser can't see until Android registers them. - service.sh: pre-creates /dev/loop0-7 device nodes and bind-mounts /mnt/media_rw/* volumes into /storage/* before starting containers, so loop-mounted sparse images on external storage work from boot without depending on Android having already refreshed those views.
Adds StorageChecker.inspectFilesystemCapabilities(), which probes a candidate storage path for POSIX chmod/chown, setuid, symlinks, hardlinks, FIFOs, device nodes, and noexec/ro mount flags, and reports whether it's fully compatible with directory-mode rootfs (ext4/f2fs only -- FAT32/exFAT/ NTFS SD cards are not). Also rewrites getFreeSpaceGB() to accept an arbitrary path (not just /data), resolving it through StorageMountManager to the real physical mount point first so it doesn't report the free space of Android's in-memory /storage tmpfs instead of the actual drive. Adds validateWritablePath(), getSharedStoragePath(), and listStorageVolumes() helpers used by the storage-location pickers.
…emap Extends ContainerManager with the operations needed by external-storage containers: - getRootfsPath/getSparseImagePath take an optional customStorageDir, so a container's rootfs can live under an SD card/USB path while its metadata (config, .env, pid file) stays under the internal CONTAINERS_BASE_PATH. - moveContainerStorage() relocates an existing container's rootfs: checks the container is stopped, checks destination free space and FAT32's 4GB single-file limit, tries a same-filesystem rename and falls back to copy-then-delete, and only removes the old copy after the config has been rewritten to point at the new path -- so a crash mid-move never leaves the container referencing missing data. - autoDetectAndRemapContainerPath() runs on start/restart: if a container's external rootfs path no longer exists (e.g. the USB drive was remounted with a new mount instance), it searches currently connected volumes for a matching rootfs and rewrites the container's config automatically. - updateContainerConfig() now also re-embeds the config into the container's sparse image (when present) on every edit, keeping the embedded copy in sync. Adds Constants.EMBEDDED_CONFIG_RELATIVE_PATH, the canonical in-image location for the embedded config, used by this and later commits.
…import ContainerInstaller.installContainer() now creates the external parent directory before extraction (when a custom storage location was chosen), checks free space at the real destination instead of always /data, and rejects sparse images >= 4GB on a FAT32 destination up front (FAT32's 4GiB single-file limit would otherwise fail deep inside mkfs with a confusing error). Root-shell calls inside the install path are routed through RootNamespace so they target the volume's physical mount point. Adds installFromExistingImage(), which imports a container from an existing sparse ext4 rootfs image instead of a tarball: places the image (copy or in-place), verifies filesystem integrity with e2fsck, applies host permissions/SELinux context, and re-embeds the current container config into the image (/etc/droidspaces.config) so it stays portable across devices. Adds ExistingImageManager, which loop-mounts a candidate .img read-only to validate it (e2fsck), report its size and OS release, and read back any embedded container config -- and mounts it read-write to (re-)embed a config via embedConfig().
SparseImageInstaller's mount -> extract -> post-fix -> unmount sequence was previously a series of separate Shell.cmd() calls, which could leave a loop device or mount attached if an intermediate step threw. This replaces it with a single root-shell script (built in buildInstallerScript()) that uses a shell `trap cleanup EXIT` handler, so the image is always unmounted and its loop device always released regardless of where the script fails. The script also now embeds the container's config into the image (configContent param) as part of the same atomic sequence, and is routed through RootNamespace.runScript() so it works correctly when the image lives on external storage. Loop-mount fallback logic was hardened to create the specific /dev/loop<N> node losetup picks (Android only pre-creates 0-7 for APEX modules, so losetup -f can return a higher, node-less index).
…ting a container Reworks the container-creation wizard: - SparseImageConfigScreen now offers three explicit modes -- new sparse image, existing image, or directory -- instead of a single sparse-image on/off toggle. Picking "existing image" browses via the root file picker or SAF, inspects the file, and can prefill the rest of the wizard from its embedded config (ContainerConfigScreen shows an "override" banner when this happens). - New StorageLocationScreen (internal vs. external/custom, with the new filesystem-compatibility checks and a "dangerous territory" bypass for advanced users) is inserted into the flow for new/directory containers; it's skipped for existing images, which already specify their own location. StorageDestinationPicker is the shared path-picker widget (root explorer or SAF) used here and by the "move storage" dialog added in a later commit. - ContainerInstallationViewModel gains ImageSourceMode and the associated existing-image/inspection/storage-location state that drives the above. - Navigation (DroidspacesNavigation) is reordered: name -> image source -> storage location (if applicable) -> config -> summary -> install, and passes the existing-image path through to InstallationSummaryScreen and InstallationProgressScreen so they can install from an image instead of a tarball. FilePickerDialog/FilePickerUtils gain external-storage browsing support and .img recognition. - New strings for the above screens.
Adds a "Move storage location" action to ContainerCard, and a MoveStorageDialog (internal vs. external/custom, reusing StorageDestinationPicker and the same filesystem-compatibility checks used at creation time) to ContainersScreen. Confirming stops the container if running, then calls ContainerManager.moveContainerStorage() to relocate the data and rewrite the config. ContainerOperationsViewModel gains executeMoveStorage() (drives the dialog's progress/error/logs state) and MoveStorageState. Its executeOperation() (start/stop/restart) now also runs the container through autoDetectAndRemapContainerPath() first, so a container whose external rootfs moved (USB replug) is found automatically before the operation proceeds. Export/migrate/resize shell invocations are switched to explicit `sh "<script>"` to avoid SELinux/mount noexec denials on some devices, and sparse-image resize now uses the container's actual (possibly external) rootfs path instead of recomputing the default internal one.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR adds support for external container storage, importing existing sparse rootfs images, and relocating existing containers, along with the Android storage plumbing required to make these workflows reliable.
Previously, containers were restricted to internal storage and could only be created from a tarball.
1. Custom storage location for new containers
Added
StorageLocationScreen/StorageDestinationPickerto let users select an SD card or USB-OTG path via root-shell browsing or SAF.ContainerManager.getRootfsPath()/getSparseImagePath()now accept an optionalcustomStorageDir.Container metadata (
config,.env, PID file, etc.) remains under the internalCONTAINERS_BASE_PATH; only the rootfs /rootfs.imgis relocated.Directory-mode rootfs now requires a POSIX-compatible filesystem such as ext4/f2fs.
StorageChecker.inspectFilesystemCapabilities()probes:chmod/chownnoexecIncompatible filesystems such as FAT32/exFAT are rejected.
Advanced users can explicitly bypass the check through a "dangerous territory" override.
ContainerInstaller:>= 4 GiBon FAT32 destinations up front, avoiding failures later due to FAT32's single-file size limit.2. Import an existing sparse rootfs image
SparseImageConfigScreennow supports three modes:Existing
.imgfiles can be selected through the root browser, SAF, or by choosing an.imgtarball during the naming step.The selected image is processed through
ExistingImageManager.inspect(), which:e2fsck./etc/os-release.container.config, when present.If the image contains an embedded configuration, its settings can be used to prefill the setup wizard.
ContainerConfigScreendisplays an override banner when this occurs.ContainerInstaller.installFromExistingImage():e2fsck./etc/droidspaces.config.This keeps imported images portable across devices.
ContainerManager.updateContainerConfig()also re-embeds the configuration whenever the container configuration is edited, keeping the embedded copy synchronized.3. Move an existing container's storage
ContainerCardnow provides a Move storage location action.ContainersScreendisplays aMoveStorageDialogthat supports moving between internal and external/custom storage while reusing the same destination picker and filesystem validation used during creation.ContainerManager.moveContainerStorage()performs a safe stop-and-move operation:This ordering ensures that a crash during the move does not leave the container referencing missing data.
ContainerOperationsViewModel.executeMoveStorage()handles the UI flow, including:Supporting Android External Storage Infrastructure
RootNamespaceRoot-shell commands targeting
/storageor/mnt/media_rware now routed through the volume's live physical mount point whenever possible.When that is not possible, commands are executed inside the app's own mount namespace using
nsenter. The app namespace already has a working FUSE view of external storage.This avoids relying on root's namespace, which may not see external volumes unless a same-process bind mount happens to succeed. That behavior varies across devices and ROMs.
StorageMountManagerResolves SD/USB volume IDs to their current physical kernel mount points:
/mnt/media_rw/<volId>Bind-mounts them into:
/storage/<volId>Detects stale bind mounts after USB disconnect/reconnect.
Compares device IDs instead of relying solely on
mountpoint(1).SafPathResolverConverts Android Storage Access Framework document/tree URIs into plain filesystem paths.
This is particularly important for USB drives that are visible to SAF before the root browser can access them.
Automatic external-path recovery
ContainerManager.autoDetectAndRemapContainerPath()runs during container start/restart.If an external rootfs path no longer exists—for example, because a USB drive was disconnected and later remounted with a different mount instance—it searches currently connected volumes for the matching rootfs and automatically rewrites the container configuration to the new path.
service.shThe boot script now:
/dev/loop0–/dev/loop7./mnt/media_rw/*volumes into/storage/*.This allows sparse images stored on external volumes to work during boot without depending on Android having already refreshed its storage views.
Additional Reliability Fixes
SparseImageInstallernow performs the complete mount/extract/postfix sequence as one atomic root-shell script with a trap-based cleanup handler.Shell.cmdcalls.Export, migrate, and resize commands are now invoked explicitly through
sh.noexecmount denials encountered on some devices.Result
This PR expands container storage from a fixed internal-only model into a more flexible system supporting:
while keeping container metadata internal and adding filesystem validation and failure-safe storage operations.