Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions Android/app/src/main/assets/export_container.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ error() { echo "[ERROR] $1"; }

# --- Cleanup state ---
TEMP_MOUNT=""
TEMP_CONFIG_DIR=""
CLEANUP_DONE=0

cleanup() {
Expand All @@ -41,6 +42,9 @@ cleanup() {
if [ -n "$TEMP_MOUNT" ] && [ -d "$TEMP_MOUNT" ]; then
rmdir "$TEMP_MOUNT" 2>/dev/null || true
fi
if [ -n "$TEMP_CONFIG_DIR" ]; then
"$BUSYBOX" rm -rf "$TEMP_CONFIG_DIR"
fi
}

# Always cleanup on exit (covers both success and failure)
Expand Down Expand Up @@ -93,8 +97,10 @@ if [ ! -f "$CONFIG_FILE" ]; then
exit 1
fi

# Parse rootfs_path from config
ROOTFS_PATH=$(grep "rootfs_path=" "$CONFIG_FILE" | cut -d'=' -f2)
# Snapshot the current host config, including edits made since this rootfs was imported.
TEMP_CONFIG_DIR=$("$BUSYBOX" mktemp -d "${CONTAINER_DIR}/.export-config.XXXXXX")
"$BUSYBOX" cp "$CONFIG_FILE" "$TEMP_CONFIG_DIR/container.config"
ROOTFS_PATH=$("$BUSYBOX" sed -n 's/^rootfs_path=//p' "$TEMP_CONFIG_DIR/container.config")

if [ -z "$ROOTFS_PATH" ]; then
error "Could not extract rootfs_path from $CONFIG_FILE"
Expand Down Expand Up @@ -179,7 +185,18 @@ fi

# --- Create archive ---
log "Creating archive... (this may take a while)"
if ! "$BUSYBOX" tar -czf "$OUTPUT_PATH" -C "$TAR_ROOT" . 2>&1; then
# BusyBox applies only the last -C, so prepend a small config tar without its end
# blocks. Both rootfs modes stay read-only, and no uncompressed rootfs copy is needed.
"$BUSYBOX" tar -cf "$TEMP_CONFIG_DIR/config.tar" -C "$TEMP_CONFIG_DIR" container.config
CONFIG_SIZE=$("$BUSYBOX" stat -c %s "$TEMP_CONFIG_DIR/container.config")
CONFIG_BLOCKS=$((1 + (CONFIG_SIZE + 511) / 512))
if ! (
set -o pipefail
{
"$BUSYBOX" dd if="$TEMP_CONFIG_DIR/config.tar" bs=512 count="$CONFIG_BLOCKS" 2>/dev/null &&
"$BUSYBOX" tar -cf - --exclude='./container.config' -C "$TAR_ROOT" .
} | "$BUSYBOX" gzip > "$OUTPUT_PATH"
); then
error "tar failed. Removing incomplete archive."
rm -f "$OUTPUT_PATH" 2>/dev/null || true
exit 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,7 @@ fun ContainerConfigForm(
val isSeccompDisabled = state.privileged.contains("noseccomp") || state.privileged.contains("full")
val usernsSupported = ok("user_ns")

// One pass: drop what the kernel cannot do, then the seccomp rule, then a
// One pass: apply capability adjustments, then the seccomp rule, then a
// single state write so the Edit screen sees one change, not several.
LaunchedEffect(caps, isSeccompDisabled, state.netMode) {
var s = caps?.coerce(state) ?: state
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import com.droidspaces.app.ui.util.LoadingIndicator
import com.droidspaces.app.ui.util.LoadingSize
import com.droidspaces.app.ui.util.FullScreenLoading
import com.droidspaces.app.ui.util.ErrorLogsDialog
import com.droidspaces.app.ui.component.HardwareAccessDialog
import com.droidspaces.app.ui.component.PrivilegedModeDialog
import com.droidspaces.app.R
import androidx.lifecycle.viewmodel.compose.viewModel
import com.droidspaces.app.ui.viewmodel.AppStateViewModel
import androidx.activity.ComponentActivity
Expand Down Expand Up @@ -352,21 +357,40 @@ fun DroidspacesNavigation(
val tarballUri = Uri.parse(tarballUriString)

LaunchedEffect(tarballUri) {
viewModel.setTarball(tarballUri)
viewModel.setTarball(context, tarballUri)
}

ContainerNameScreen(
initialName = viewModel.containerName,
initialHostname = viewModel.hostname,
existingContainerNames = sharedContainerViewModel.containerList.map { it.name },
onNext = { name, hostname ->
viewModel.setName(name, hostname)
navController.navigate(Screen.ContainerConfig.route)
},
onClose = {
navController.popBackStack()
if (viewModel.preparingTarball) {
FullScreenLoading(context.getString(R.string.rootfs_config_loading))
} else if (viewModel.preparationError != null) {
ErrorLogsDialog(listOf(viewModel.preparationError.orEmpty())) { navController.popBackStack() }
} else {
if (viewModel.recommendedHwAccess) {
HardwareAccessDialog(
onConfirm = { viewModel.confirmRecommendedHwAccess(true) },
onDismiss = { viewModel.confirmRecommendedHwAccess(false) }
)
} else if (viewModel.recommendedPrivileged.isNotEmpty()) {
PrivilegedModeDialog(
initialPrivileged = viewModel.recommendedPrivileged,
onConfirm = viewModel::confirmRecommendedPrivileged,
onDismiss = { viewModel.confirmRecommendedPrivileged("") }
)
}
)
ContainerNameScreen(
initialName = viewModel.containerName,
initialHostname = viewModel.hostname,
recommendationNotice = viewModel.recommendationNotice,
existingContainerNames = sharedContainerViewModel.containerList.map { it.name },
onNext = { name, hostname ->
viewModel.setName(name, hostname)
navController.navigate(Screen.ContainerConfig.route)
},
onClose = {
navController.popBackStack()
}
)
}
}

composable(
Expand Down Expand Up @@ -470,6 +494,7 @@ fun DroidspacesNavigation(
InstallationProgressScreen(
tarballUri = tarballUri,
config = config,
preparedTarball = viewModel.preparedTarball,
onSuccess = {
viewModel.reset()
// Trigger container list refresh before navigating back
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ fun ContainerNameScreen(
initialName: String = "",
initialHostname: String = "",
existingContainerNames: List<String> = emptyList(),
recommendationNotice: String? = null,
onNext: (String, String) -> Unit,
onClose: () -> Unit
) {
Expand Down Expand Up @@ -148,6 +149,14 @@ fun ContainerNameScreen(
fontWeight = FontWeight.Bold
)

recommendationNotice?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}

// Container Name
OutlinedTextField(
value = containerName,
Expand Down Expand Up @@ -202,4 +211,3 @@ fun ContainerNameScreen(
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import com.droidspaces.app.util.ContainerLogger
import com.droidspaces.app.util.FilePickerUtils
import com.droidspaces.app.util.ViewModelLogger
import kotlinx.coroutines.launch
import java.io.File

enum class InstallationState {
INSTALLING,
Expand All @@ -39,6 +40,7 @@ enum class InstallationState {
fun InstallationProgressScreen(
tarballUri: Uri,
config: ContainerInfo,
preparedTarball: File? = null,
onSuccess: () -> Unit,
onError: () -> Unit
) {
Expand Down Expand Up @@ -80,7 +82,8 @@ fun InstallationProgressScreen(
context = context,
tarballUri = tarballUri,
config = config,
logger = logger
logger = logger,
preparedTarball = preparedTarball
)

installationState = if (result.isSuccess) {
Expand Down Expand Up @@ -206,4 +209,3 @@ fun InstallationProgressScreen(
}
}
}

Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package com.droidspaces.app.ui.viewmodel

import android.content.Context
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.droidspaces.app.R
import com.droidspaces.app.util.ContainerInfo
import com.droidspaces.app.util.ContainerInstaller
import com.droidspaces.app.util.ContainerManager
import com.droidspaces.app.util.ContainerStatus
import com.droidspaces.app.util.Constants
Expand All @@ -14,11 +18,31 @@ import com.droidspaces.app.util.ContainerConfigState
import com.droidspaces.app.util.ValidationUtils
import com.droidspaces.app.util.withConfig
import com.droidspaces.app.util.HostCapabilities
import com.droidspaces.app.util.RootfsConfig
import com.droidspaces.app.util.toConfigState
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File

class ContainerInstallationViewModel : ViewModel() {
var tarballUri: Uri? by mutableStateOf(null)
private set

var preparedTarball: File? = null
private set
var preparingTarball by mutableStateOf(true)
private set
var preparationError: String? by mutableStateOf(null)
private set
var recommendationNotice: String? by mutableStateOf(null)
private set
var recommendedHwAccess by mutableStateOf(false)
private set
var recommendedPrivileged by mutableStateOf("")
private set

var containerName: String by mutableStateOf("")
private set

Expand All @@ -42,8 +66,56 @@ class ContainerInstallationViewModel : ViewModel() {
var configState: ContainerConfigState by mutableStateOf(ContainerConfigState())
private set

fun setTarball(uri: Uri) {
fun setTarball(context: Context, uri: Uri) {
// Returning to the first wizard page must not reapply defaults over user edits.
if (tarballUri == uri) return
tarballUri = uri
val appContext = context.applicationContext
viewModelScope.launch {
try {
val extension = ContainerInstaller.getTarballExtension(appContext, uri)
val archive = File.createTempFile("rootfs_", ".tar$extension", appContext.cacheDir)
preparedTarball = archive
ContainerInstaller.copyTarball(appContext, uri, archive)
try {
val recommended = withContext(Dispatchers.IO) { RootfsConfig.read(archive) }
if (recommended != null) {
containerName = ValidationUtils.normalizeContainerName(recommended.name)
hostname = recommended.hostname
useSparseImage = recommended.useSparseImage
sparseImageSizeGB = recommended.sparseImageSizeGB ?: 8
val state = recommended.toConfigState().let { HostCapabilities.state.value?.coerce(it) ?: it }
recommendedHwAccess = state.enableHwAccess
recommendedPrivileged = state.privileged
// These two settings keep the same confirmation gates as manual setup.
configState = state.copy(enableHwAccess = false, privileged = "")
recommendationNotice = appContext.getString(R.string.rootfs_config_loaded)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
recommendationNotice = appContext.getString(R.string.rootfs_config_invalid, e.message.orEmpty())
}
} catch (e: CancellationException) {
preparedTarball?.delete()
throw e
} catch (e: Exception) {
preparationError = e.message ?: appContext.getString(R.string.operation_failed_title)
preparedTarball?.delete()
} finally {
preparingTarball = false
}
}
}

fun confirmRecommendedHwAccess(enabled: Boolean) {
configState = configState.copy(enableHwAccess = enabled)
recommendedHwAccess = false
}

fun confirmRecommendedPrivileged(tags: String) {
configState = configState.copy(privileged = tags)
recommendedPrivileged = ""
}

fun setName(name: String, hostname: String) {
Expand Down Expand Up @@ -80,13 +152,24 @@ class ContainerInstallationViewModel : ViewModel() {
}

fun reset() {
preparedTarball?.delete()
preparedTarball = null
tarballUri = null
preparingTarball = true
preparationError = null
recommendationNotice = null
recommendedHwAccess = false
recommendedPrivileged = ""
containerName = ""
hostname = ""
useSparseImage = true
sparseImageSizeGB = 8
storageDir = null
configState = ContainerConfigState()
}
}

override fun onCleared() {
preparedTarball?.delete()
super.onCleared()
}
}
Loading
Loading