Skip to content

Storage: support external volumes, existing image import, and relocation - #279

Open
zennnez wants to merge 8 commits into
ravindu644:devfrom
zennnez:dev
Open

Storage: support external volumes, existing image import, and relocation#279
zennnez wants to merge 8 commits into
ravindu644:devfrom
zennnez:dev

Conversation

@zennnez

@zennnez zennnez commented Aug 12, 2026

Copy link
Copy Markdown

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 / StorageDestinationPicker to let users select an SD card or USB-OTG path via root-shell browsing or SAF.

  • ContainerManager.getRootfsPath() / getSparseImagePath() now accept an optional customStorageDir.

  • Container metadata (config, .env, PID file, etc.) remains under the internal CONTAINERS_BASE_PATH; only the rootfs / rootfs.img is relocated.

  • Directory-mode rootfs now requires a POSIX-compatible filesystem such as ext4/f2fs.

    • StorageChecker.inspectFilesystemCapabilities() probes:

      • chmod / chown
      • symlink support
      • noexec
      • read-only mount state
    • Incompatible filesystems such as FAT32/exFAT are rejected.

    • Advanced users can explicitly bypass the check through a "dangerous territory" override.

  • ContainerInstaller:

    • Creates the external parent directory before extraction.
    • Checks free space at the actual destination.
    • Rejects sparse images >= 4 GiB on FAT32 destinations up front, avoiding failures later due to FAT32's single-file size limit.

2. Import an existing sparse rootfs image

SparseImageConfigScreen now supports three modes:

  1. New sparse image
  2. Existing image
  3. Directory

Existing .img files can be selected through the root browser, SAF, or by choosing an .img tarball during the naming step.

The selected image is processed through ExistingImageManager.inspect(), which:

  • Loop-mounts the image read-only.
  • Runs e2fsck.
  • Detects filesystem/image size.
  • Reads /etc/os-release.
  • Detects an embedded container.config, when present.

If the image contains an embedded configuration, its settings can be used to prefill the setup wizard. ContainerConfigScreen displays an override banner when this occurs.

ContainerInstaller.installFromExistingImage():

  • Copies the image to the selected destination or uses it in-place.
  • Runs e2fsck.
  • Re-embeds the current container configuration at /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

ContainerCard now provides a Move storage location action.

ContainersScreen displays a MoveStorageDialog that 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:

  1. Verifies that the container is not running.
  2. Checks destination free space.
  3. Validates FAT32's 4 GiB file-size limitation.
  4. Attempts a same-filesystem rename first.
  5. Falls back to copy-then-delete when a rename is not possible.
  6. Rewrites the container configuration to the new path.
  7. Removes the old copy only after the configuration successfully points to the new location.

This ordering ensures that a crash during the move does not leave the container referencing missing data.

ContainerOperationsViewModel.executeMoveStorage() handles the UI flow, including:

  • Stopping the container.
  • Showing move progress.
  • Surfacing errors and operation logs.

Supporting Android External Storage Infrastructure

RootNamespace

Root-shell commands targeting /storage or /mnt/media_rw are 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.

StorageMountManager

  • Resolves 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).

SafPathResolver

Converts 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.sh

The boot script now:

  • Pre-creates /dev/loop0/dev/loop7.
  • Bind-mounts /mnt/media_rw/* volumes into /storage/*.
  • Performs this setup before containers are started.

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

  • SparseImageInstaller now performs the complete mount/extract/postfix sequence as one atomic root-shell script with a trap-based cleanup handler.

    • Previously, these operations were split across multiple Shell.cmd calls.
    • An exception between commands could leave a loop device or mount attached.
  • Export, migrate, and resize commands are now invoked explicitly through sh.

    • This avoids SELinux / noexec mount denials encountered on some devices.

Result

This PR expands container storage from a fixed internal-only model into a more flexible system supporting:

  • Internal storage
  • SD cards
  • USB-OTG storage
  • Existing sparse image imports
  • In-place image usage
  • Container storage relocation
  • Automatic recovery after external-volume remounts

while keeping container metadata internal and adding filesystem validation and failure-safe storage operations.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant