diff --git a/app/src/main/java/com/excp/podroid/data/repository/ContainerBackupRepository.kt b/app/src/main/java/com/excp/podroid/data/repository/ContainerBackupRepository.kt new file mode 100644 index 0000000..0815eb1 --- /dev/null +++ b/app/src/main/java/com/excp/podroid/data/repository/ContainerBackupRepository.kt @@ -0,0 +1,92 @@ +/* + * Podroid - Rootless Podman for Android + * Copyright (C) 2024-2026 Podroid contributors + * + * Lists container backup archives written by the guest `podroid-backup` tool + * into Downloads/Podroid/backups when sharing is enabled. + */ +package com.excp.podroid.data.repository + +import android.os.Environment +import com.excp.podroid.util.ShellQuote +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +data class ContainerBackupFile( + val name: String, + val sizeBytes: Long, + val lastModifiedMs: Long, + val absolutePath: String, +) + +@Singleton +class ContainerBackupRepository @Inject constructor() { + companion object { + const val BACKUP_SUBDIR = "Podroid/backups" + } + + fun backupDirectory(): File { + val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + return File(downloads, BACKUP_SUBDIR) + } + + fun guestBackupPathLabel(): String = + if (isDownloadsReachable()) "/mnt/downloads/Podroid/backups" + else "/var/backups/podroid" + + fun isDownloadsReachable(): Boolean { + val dir = backupDirectory() + return dir.exists() || dir.parentFile?.exists() == true + } + + fun listBackupFiles(): List { + val dir = backupDirectory() + if (!dir.isDirectory) return emptyList() + return dir.listFiles() + ?.filter { it.isFile && (it.name.endsWith(".tar") || it.name.endsWith(".tar.gz")) } + ?.map { f -> + ContainerBackupFile( + name = f.name, + sizeBytes = f.length(), + lastModifiedMs = f.lastModified(), + absolutePath = f.absolutePath, + ) + } + ?.sortedByDescending { it.lastModifiedMs } + ?: emptyList() + } + + fun formatSize(bytes: Long): String = when { + bytes >= 1024L * 1024 * 1024 -> String.format(Locale.US, "%.1f GB", bytes / (1024.0 * 1024 * 1024)) + bytes >= 1024L * 1024 -> String.format(Locale.US, "%.1f MB", bytes / (1024.0 * 1024)) + bytes >= 1024L -> String.format(Locale.US, "%.1f KB", bytes / 1024.0) + else -> "$bytes B" + } + + fun formatDate(ms: Long): String = + SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(ms)) + + fun exportCommand(containerName: String): String { + val q = ShellQuote.quote(containerName.trim()) + val fb = fallbackExportCommand(containerName) + return "if command -v podroid-backup >/dev/null 2>&1; then podroid-backup export $q; else $fb; fi" + } + + fun saveImageCommand(imageRef: String): String { + val q = ShellQuote.quote(imageRef.trim()) + val root = guestBackupPathLabel() + return "if command -v podroid-backup >/dev/null 2>&1; then podroid-backup save $q; else mkdir -p $root && podman save $q -o $root/\$(echo $q | tr -cd 'A-Za-z0-9._-')-\$(date +%Y%m%d-%H%M%S).tar; fi" + } + + fun listCommand(): String = "podroid-backup list" + + fun fallbackExportCommand(containerName: String): String { + val q = ShellQuote.quote(containerName.trim()) + val root = guestBackupPathLabel() + return "mkdir -p $root && podman export $q -o $root/\$(echo $q | tr -cd 'A-Za-z0-9._-')-\$(date +%Y%m%d-%H%M%S).tar" + } +} diff --git a/app/src/main/java/com/excp/podroid/data/repository/ContainerStatsRepository.kt b/app/src/main/java/com/excp/podroid/data/repository/ContainerStatsRepository.kt new file mode 100644 index 0000000..cfefbb0 --- /dev/null +++ b/app/src/main/java/com/excp/podroid/data/repository/ContainerStatsRepository.kt @@ -0,0 +1,31 @@ +/* + * Podroid - Rootless Podman for Android + * Copyright (C) 2024-2026 Podroid contributors + * + * Reads the container count written by the guest `podroid-update-stats` tool + * into Downloads/Podroid/container-count when sharing is enabled. + */ +package com.excp.podroid.data.repository + +import android.os.Environment +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ContainerStatsRepository @Inject constructor( + private val settingsRepository: SettingsRepository, +) { + fun statsFile(): File { + val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + return File(File(downloads, ContainerBackupRepository.BACKUP_SUBDIR), "container-count") + } + + suspend fun readContainerCount(): Int? { + val file = statsFile() + if (!file.isFile) return settingsRepository.getLastContainerCount() + val parsed = file.readText().trim().toIntOrNull() ?: return null + settingsRepository.setLastContainerCount(parsed) + return parsed + } +} diff --git a/app/src/main/java/com/excp/podroid/data/repository/SettingsRepository.kt b/app/src/main/java/com/excp/podroid/data/repository/SettingsRepository.kt index 430d2fd..0b97542 100644 --- a/app/src/main/java/com/excp/podroid/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/excp/podroid/data/repository/SettingsRepository.kt @@ -60,6 +60,7 @@ class SettingsRepository @Inject constructor( val KEY_HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled") val KEY_DYNAMIC_COLOR_ENABLED = booleanPreferencesKey("dynamic_color_enabled") val KEY_LAST_BOOT_DURATION_MS = longPreferencesKey("last_boot_duration_ms") + val KEY_LAST_CONTAINER_COUNT = intPreferencesKey("last_container_count") val KEY_ENGINE_SELECTION = stringPreferencesKey("engine_selection") val KEY_AVF_HINT_DISMISSED = booleanPreferencesKey("avf_hint_dismissed") val KEY_AVF_VERBOSE_LOGGING = booleanPreferencesKey("avf_verbose_logging") @@ -71,6 +72,9 @@ class SettingsRepository @Inject constructor( // changes the CPU-count setting so the new value is re-probed. val KEY_AVF_CPU_CAP = intPreferencesKey("avf_cpu_cap") val KEY_USB_PASSTHROUGH_ENABLED = booleanPreferencesKey("usb_passthrough_enabled") + val KEY_LOAD_BALANCE_ENABLED = booleanPreferencesKey("load_balance_enabled") + /** Guest egress cap via `podroid.bandwidth=` cmdline + tc. 0 = unlimited. */ + val KEY_BANDWIDTH_MBPS = intPreferencesKey("bandwidth_mbps") val KEY_X11_RES_MODE = stringPreferencesKey("x11_resolution_mode") val KEY_X11_RES_PRESET = stringPreferencesKey("x11_resolution_preset") @@ -143,12 +147,15 @@ class SettingsRepository @Inject constructor( val hapticsEnabled = pref(KEY_HAPTICS_ENABLED, true) val dynamicColorEnabled = pref(KEY_DYNAMIC_COLOR_ENABLED, false) val lastBootDurationMs = pref(KEY_LAST_BOOT_DURATION_MS, 0L) + val lastContainerCount = pref(KEY_LAST_CONTAINER_COUNT, -1) // Routed through pref() so a corrupted store emits the "auto" default instead // of throwing into LanguageManager's locale collector. val language: Flow = pref(KEY_LANGUAGE, "auto") val avfHintDismissed = pref(KEY_AVF_HINT_DISMISSED, false) val avfCpuCap = pref(KEY_AVF_CPU_CAP, 0) val usbPassthroughEnabled = pref(KEY_USB_PASSTHROUGH_ENABLED, false) + val loadBalanceEnabled = pref(KEY_LOAD_BALANCE_ENABLED, false) + val bandwidthMbps = pref(KEY_BANDWIDTH_MBPS, 0) val avfVerboseLogging: Flow = context.dataStore.data .catch { e -> if (e is IOException) emit(androidx.datastore.preferences.core.emptyPreferences()) else throw e } .map { prefs -> prefs[KEY_AVF_VERBOSE_LOGGING] ?: false } @@ -185,12 +192,19 @@ class SettingsRepository @Inject constructor( suspend fun setHapticsEnabled(value: Boolean) = set(KEY_HAPTICS_ENABLED, value) suspend fun setDynamicColorEnabled(value: Boolean) = set(KEY_DYNAMIC_COLOR_ENABLED, value) suspend fun setLastBootDurationMs(value: Long) = set(KEY_LAST_BOOT_DURATION_MS, value) + suspend fun setLastContainerCount(value: Int) = set(KEY_LAST_CONTAINER_COUNT, value) + suspend fun getLastContainerCount(): Int? { + val v = context.dataStore.data.first()[KEY_LAST_CONTAINER_COUNT] ?: -1 + return if (v < 0) null else v + } suspend fun setLanguage(value: String) = set(KEY_LANGUAGE, value) suspend fun setEngineSelection(value: EngineSelection) = set(KEY_ENGINE_SELECTION, value.name) suspend fun setAvfHintDismissed(value: Boolean) = set(KEY_AVF_HINT_DISMISSED, value) suspend fun setAvfVerboseLogging(value: Boolean) = set(KEY_AVF_VERBOSE_LOGGING, value) suspend fun setAvfCpuCap(value: Int) = set(KEY_AVF_CPU_CAP, value) suspend fun setUsbPassthroughEnabled(value: Boolean) = set(KEY_USB_PASSTHROUGH_ENABLED, value) + suspend fun setLoadBalanceEnabled(value: Boolean) = set(KEY_LOAD_BALANCE_ENABLED, value) + suspend fun setBandwidthMbps(value: Int) = set(KEY_BANDWIDTH_MBPS, value.coerceAtLeast(0)) /** * Persists all first-run setup choices in a single transaction so a process @@ -200,15 +214,23 @@ class SettingsRepository @Inject constructor( */ suspend fun completeSetup( storageSizeGb: Int, + vmRamMb: Int, + vmCpus: Int, sshEnabled: Boolean, storageAccessEnabled: Boolean, usbPassthroughEnabled: Boolean, + loadBalanceEnabled: Boolean, + bandwidthMbps: Int, ) { context.dataStore.edit { it[KEY_STORAGE_GB] = storageSizeGb.coerceAtLeast(1) + it[KEY_VM_RAM] = vmRamMb.coerceAtLeast(512) + it[KEY_VM_CPUS] = vmCpus.coerceAtLeast(1) it[KEY_SSH_ENABLED] = sshEnabled it[KEY_STORAGE_ACCESS_ENABLED] = storageAccessEnabled it[KEY_USB_PASSTHROUGH_ENABLED] = usbPassthroughEnabled + it[KEY_LOAD_BALANCE_ENABLED] = loadBalanceEnabled + it[KEY_BANDWIDTH_MBPS] = bandwidthMbps.coerceAtLeast(0) it[KEY_SETUP_DONE] = true } } @@ -261,4 +283,6 @@ class SettingsRepository @Inject constructor( suspend fun getAvfVerboseLoggingSnapshot() = avfVerboseLogging.first() suspend fun getAvfCpuCapSnapshot() = avfCpuCap.first() suspend fun getUsbPassthroughEnabledSnapshot() = usbPassthroughEnabled.first() + suspend fun getLoadBalanceEnabledSnapshot() = loadBalanceEnabled.first() + suspend fun getBandwidthMbpsSnapshot() = bandwidthMbps.first() } diff --git a/app/src/main/java/com/excp/podroid/engine/EngineHolder.kt b/app/src/main/java/com/excp/podroid/engine/EngineHolder.kt index d4749be..4de6eab 100644 --- a/app/src/main/java/com/excp/podroid/engine/EngineHolder.kt +++ b/app/src/main/java/com/excp/podroid/engine/EngineHolder.kt @@ -310,6 +310,8 @@ class EngineHolder @Inject constructor( } override val runningSinceMs: Long? get() = current.runningSinceMs + override fun emulatorRssMb(): Long? = current.emulatorRssMb() + override fun emulatorPid(): Int? = current.emulatorPid() // ── VmEngine: flows that follow the currently-selected engine ────────── // A freshly (re)selected engine that hasn't been started this cycle has its diff --git a/app/src/main/java/com/excp/podroid/engine/QemuEngine.kt b/app/src/main/java/com/excp/podroid/engine/QemuEngine.kt index 6436f49..d0a2537 100644 --- a/app/src/main/java/com/excp/podroid/engine/QemuEngine.kt +++ b/app/src/main/java/com/excp/podroid/engine/QemuEngine.kt @@ -28,6 +28,7 @@ import android.annotation.SuppressLint import android.content.Context import android.util.Log import com.excp.podroid.data.repository.PortForwardRule +import com.excp.podroid.util.HostMetrics import com.excp.podroid.util.LogProxy import com.termux.terminal.TerminalSession import com.termux.terminal.TerminalSessionClient @@ -81,6 +82,17 @@ class QemuEngine @Inject constructor( private var _runningSinceMs: Long? = null override val runningSinceMs: Long? get() = _runningSinceMs + override fun emulatorRssMb(): Long? { + val proc = process?.takeIf { it.isAlive } ?: return null + val pid = HostMetrics.processPid(proc) ?: return null + return HostMetrics.processVmRssMb(pid) + } + + override fun emulatorPid(): Int? { + val proc = process?.takeIf { it.isAlive } ?: return null + return HostMetrics.processPid(proc) + } + @Volatile var process: Process? = null private set @@ -558,6 +570,7 @@ class QemuEngine @Inject constructor( append(" androidip=").append(config.androidIp) if (config.sshEnabled) append(" ssh=1") append(" podroid.x11.dpi=").append(config.x11Dpi) + if (config.bandwidthMbps > 0) append(" podroid.bandwidth=").append(config.bandwidthMbps) } args += "-append"; args += cmdline } else { diff --git a/app/src/main/java/com/excp/podroid/engine/VmEngine.kt b/app/src/main/java/com/excp/podroid/engine/VmEngine.kt index b061625..3ccfebf 100644 --- a/app/src/main/java/com/excp/podroid/engine/VmEngine.kt +++ b/app/src/main/java/com/excp/podroid/engine/VmEngine.kt @@ -46,6 +46,12 @@ interface VmEngine { */ val runningSinceMs: Long? get() = null + /** Resident set size of the emulator process (QEMU), in MB; null if unknown. */ + fun emulatorRssMb(): Long? = null + + /** Emulator process PID on the Android host; null when not tracked (e.g. AVF). */ + fun emulatorPid(): Int? = null + /** QEMU-specific. Null on backends that don't use QMP (e.g. AVF). */ val qmpClient: QmpClient? @@ -101,4 +107,5 @@ data class VmConfig( val verboseLogging: Boolean = false, val x11Dpi: Int = 96, val usbPassthroughEnabled: Boolean = false, + val bandwidthMbps: Int = 0, ) diff --git a/app/src/main/java/com/excp/podroid/engine/avf/AvfEngine.kt b/app/src/main/java/com/excp/podroid/engine/avf/AvfEngine.kt index 8dd16d1..eb96c76 100644 --- a/app/src/main/java/com/excp/podroid/engine/avf/AvfEngine.kt +++ b/app/src/main/java/com/excp/podroid/engine/avf/AvfEngine.kt @@ -994,6 +994,7 @@ class AvfEngine @Inject constructor( val resolvedCmdline = ("console=hvc0 $earlycon root=/dev/ram0 mitigations=off " + "elevator=mq-deadline podroid.tty=hvc0 podroid.backend=avf podroid.epoch=$epoch " + "podroid.x11.dpi=${config.x11Dpi}$verboseFlags$nrCpusFlag " + + (if (config.bandwidthMbps > 0) "podroid.bandwidth=${config.bandwidthMbps} " else "") + config.kernelExtraCmdline).trim() AvfReflect.addParams(cb, resolvedCmdline) if (config.verboseLogging) { diff --git a/app/src/main/java/com/excp/podroid/service/PodroidService.kt b/app/src/main/java/com/excp/podroid/service/PodroidService.kt index 1b55eb7..fa37b3d 100644 --- a/app/src/main/java/com/excp/podroid/service/PodroidService.kt +++ b/app/src/main/java/com/excp/podroid/service/PodroidService.kt @@ -355,6 +355,7 @@ class PodroidService : Service() { verboseLogging = settingsRepository.getAvfVerboseLoggingSnapshot(), x11Dpi = settingsRepository.getX11DpiSnapshot(), usbPassthroughEnabled = settingsRepository.getUsbPassthroughEnabledSnapshot(), + bandwidthMbps = settingsRepository.getBandwidthMbpsSnapshot(), ) serviceScope.launch { observeStateForHostBridge() } if (config.usbPassthroughEnabled) { diff --git a/app/src/main/java/com/excp/podroid/ui/components/VmLoadGraph.kt b/app/src/main/java/com/excp/podroid/ui/components/VmLoadGraph.kt new file mode 100644 index 0000000..3487e8d --- /dev/null +++ b/app/src/main/java/com/excp/podroid/ui/components/VmLoadGraph.kt @@ -0,0 +1,110 @@ +package com.excp.podroid.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.excp.podroid.R +import com.excp.podroid.ui.theme.PodroidTokens + +@Composable +fun VmLoadGraph( + samples: List, + currentPercent: Float?, + modifier: Modifier = Modifier, + unavailableReason: String? = null, +) { + Column(modifier = modifier.fillMaxWidth()) { + val headline = when { + unavailableReason != null -> unavailableReason + currentPercent != null -> stringResource(R.string.status_vm_load_current, currentPercent.toInt()) + samples.isNotEmpty() -> stringResource(R.string.status_vm_load_collecting) + else -> stringResource(R.string.status_vm_load_waiting) + } + Text( + text = headline, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = PodroidTokens.Spacing.SM), + ) + + if (unavailableReason != null) { + Text( + text = stringResource(R.string.status_vm_load_graph_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + + val lineColor = MaterialTheme.colorScheme.primary + val fillColor = lineColor.copy(alpha = 0.18f) + val gridColor = MaterialTheme.colorScheme.outlineVariant + + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(140.dp), + ) { + val w = size.width + val h = size.height + val pad = 4f + + for (fraction in listOf(0.25f, 0.5f, 0.75f)) { + val y = h - pad - (h - 2 * pad) * fraction + drawLine( + color = gridColor, + start = Offset(pad, y), + end = Offset(w - pad, y), + strokeWidth = 1f, + ) + } + + if (samples.size < 2) return@Canvas + + val stepX = (w - 2 * pad) / (samples.size - 1).coerceAtLeast(1) + val points = samples.mapIndexed { i, pct -> + val x = pad + i * stepX + val y = h - pad - (pct.coerceIn(0f, 100f) / 100f) * (h - 2 * pad) + Offset(x, y) + } + + val fillPath = Path().apply { + moveTo(points.first().x, h - pad) + points.forEach { lineTo(it.x, it.y) } + lineTo(points.last().x, h - pad) + close() + } + drawPath(fillPath, fillColor) + + for (i in 0 until points.lastIndex) { + drawLine( + color = lineColor, + start = points[i], + end = points[i + 1], + strokeWidth = 3f, + cap = StrokeCap.Round, + ) + } + } + + Text( + text = stringResource(R.string.status_vm_load_axis), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = PodroidTokens.Spacing.XS), + ) + } +} diff --git a/app/src/main/java/com/excp/podroid/ui/components/VmResourceChips.kt b/app/src/main/java/com/excp/podroid/ui/components/VmResourceChips.kt new file mode 100644 index 0000000..44d7d20 --- /dev/null +++ b/app/src/main/java/com/excp/podroid/ui/components/VmResourceChips.kt @@ -0,0 +1,176 @@ +package com.excp.podroid.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.excp.podroid.R +import com.excp.podroid.ui.theme.PodroidTokens +import com.excp.podroid.util.DeviceResourcePolicy + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun VmRamChips( + currentMb: Int, + onChange: (Int) -> Unit, + enabled: Boolean = true, + showDivider: Boolean = true, +) { + Column(modifier = Modifier.padding(bottom = PodroidTokens.Spacing.SM)) { + Text( + "${stringResource(R.string.ram_label)} · ${formatRam(currentMb)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding( + top = PodroidTokens.Spacing.MD, + bottom = PodroidTokens.Spacing.SM, + ), + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.SM), + verticalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.SM), + ) { + DeviceResourcePolicy.RAM_OPTIONS_MB.forEach { mb -> + FilterChip( + selected = mb == currentMb, + enabled = enabled, + onClick = { onChange(mb) }, + label = { + Text( + formatRam(mb), + fontWeight = if (mb == currentMb) FontWeight.Bold else FontWeight.Normal, + ) + }, + shape = RoundedCornerShape(PodroidTokens.Radius.Chip), + colors = PodroidChipColors(), + ) + } + } + if (showDivider) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + thickness = 1.dp, + modifier = Modifier.padding(top = PodroidTokens.Spacing.MD), + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun VmCpuChips( + currentCpus: Int, + onChange: (Int) -> Unit, + enabled: Boolean = true, + showDivider: Boolean = true, +) { + Column(modifier = Modifier.padding(bottom = PodroidTokens.Spacing.SM)) { + Text( + "${stringResource(R.string.cpu_cores)} · $currentCpus", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding( + top = PodroidTokens.Spacing.MD, + bottom = PodroidTokens.Spacing.SM, + ), + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.SM), + verticalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.SM), + ) { + DeviceResourcePolicy.CPU_OPTIONS.forEach { n -> + FilterChip( + selected = n == currentCpus, + enabled = enabled, + onClick = { onChange(n) }, + label = { + Text( + "$n", + fontWeight = if (n == currentCpus) FontWeight.Bold else FontWeight.Normal, + ) + }, + shape = RoundedCornerShape(PodroidTokens.Radius.Chip), + colors = PodroidChipColors(), + ) + } + } + if (showDivider) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + thickness = 1.dp, + modifier = Modifier.padding(top = PodroidTokens.Spacing.MD), + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun VmBandwidthChips( + currentMbps: Int, + onChange: (Int) -> Unit, + enabled: Boolean = true, + showDivider: Boolean = true, +) { + Column(modifier = Modifier.padding(bottom = PodroidTokens.Spacing.SM)) { + Text( + "${stringResource(R.string.bandwidth_limit)} · ${formatBandwidth(currentMbps)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding( + top = PodroidTokens.Spacing.MD, + bottom = PodroidTokens.Spacing.SM, + ), + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.SM), + verticalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.SM), + ) { + DeviceResourcePolicy.BANDWIDTH_OPTIONS_MBPS.forEach { mbps -> + FilterChip( + selected = mbps == currentMbps, + enabled = enabled, + onClick = { onChange(mbps) }, + label = { + Text( + formatBandwidth(mbps), + fontWeight = if (mbps == currentMbps) FontWeight.Bold else FontWeight.Normal, + ) + }, + shape = RoundedCornerShape(PodroidTokens.Radius.Chip), + colors = PodroidChipColors(), + ) + } + } + if (showDivider) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + thickness = 1.dp, + modifier = Modifier.padding(top = PodroidTokens.Spacing.MD), + ) + } + } +} + +@Composable +private fun formatRam(mb: Int): String = + if (mb >= 1024) "${mb / 1024} GB" else "$mb MB" + +@Composable +private fun formatBandwidth(mbps: Int): String = + if (mbps <= 0) stringResource(R.string.bandwidth_unlimited) else "$mbps Mbps" diff --git a/app/src/main/java/com/excp/podroid/ui/navigation/NavGraph.kt b/app/src/main/java/com/excp/podroid/ui/navigation/NavGraph.kt index 374a32c..04cf9af 100644 --- a/app/src/main/java/com/excp/podroid/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/excp/podroid/ui/navigation/NavGraph.kt @@ -16,6 +16,8 @@ import com.excp.podroid.ui.screens.settings.SettingsScreen import com.excp.podroid.ui.screens.setup.SetupScreen import com.excp.podroid.ui.screens.terminal.TerminalScreen import com.excp.podroid.ui.screens.terminal.TerminalViewModel +import com.excp.podroid.ui.screens.backup.ContainerBackupScreen +import com.excp.podroid.ui.screens.status.StatusScreen import com.excp.podroid.ui.screens.x11.X11Screen object Routes { @@ -24,6 +26,8 @@ object Routes { const val TERMINAL = "terminal" const val TERMINAL_X11 = "terminal/x11" const val SETTINGS = "settings" + const val STATUS = "status" + const val CONTAINER_BACKUP = "container_backup" } @Composable @@ -70,6 +74,27 @@ fun PodroidNavGraph( onNavigateToSettings = { navController.navigate(Routes.SETTINGS) { launchSingleTop = true } }, + onNavigateToStatus = { + navController.navigate(Routes.STATUS) { launchSingleTop = true } + }, + onNavigateToContainerBackup = { + navController.navigate(Routes.CONTAINER_BACKUP) { launchSingleTop = true } + }, + ) + } + + composable(Routes.STATUS) { + StatusScreen( + windowSizeClass = windowSizeClass, + onNavigateBack = { + if (navController.currentDestination?.route == Routes.STATUS) { + navController.popBackStack() + } else if (!navController.popBackStack(Routes.HOME, inclusive = false)) { + navController.navigate(Routes.HOME) { + popUpTo(0) { inclusive = true } + } + } + }, ) } @@ -108,6 +133,21 @@ fun PodroidNavGraph( ) } + composable(Routes.CONTAINER_BACKUP) { + ContainerBackupScreen( + windowSizeClass = windowSizeClass, + onNavigateBack = { + if (navController.currentDestination?.route == Routes.CONTAINER_BACKUP) { + navController.popBackStack() + } else if (!navController.popBackStack(Routes.HOME, inclusive = false)) { + navController.navigate(Routes.HOME) { + popUpTo(0) { inclusive = true } + } + } + }, + ) + } + composable(Routes.SETTINGS) { val activity = LocalActivity.current val onLanguageChanged = remember(activity) { @@ -125,6 +165,9 @@ fun PodroidNavGraph( } }, onLanguageChanged = onLanguageChanged, + onNavigateToContainerBackup = { + navController.navigate(Routes.CONTAINER_BACKUP) { launchSingleTop = true } + }, ) } } diff --git a/app/src/main/java/com/excp/podroid/ui/screens/backup/ContainerBackupScreen.kt b/app/src/main/java/com/excp/podroid/ui/screens/backup/ContainerBackupScreen.kt new file mode 100644 index 0000000..6e0245b --- /dev/null +++ b/app/src/main/java/com/excp/podroid/ui/screens/backup/ContainerBackupScreen.kt @@ -0,0 +1,199 @@ +package com.excp.podroid.ui.screens.backup + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.excp.podroid.R +import com.excp.podroid.ui.components.AdaptiveContainer +import com.excp.podroid.ui.components.PodroidGhostButton +import com.excp.podroid.ui.components.PodroidListRow +import com.excp.podroid.ui.components.PodroidPrimaryButton +import com.excp.podroid.ui.components.PodroidSectionLabel +import com.excp.podroid.ui.components.PodroidTopBar +import com.excp.podroid.ui.theme.PodroidTokens + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContainerBackupScreen( + windowSizeClass: WindowSizeClass, + onNavigateBack: () -> Unit, + viewModel: ContainerBackupViewModel = hiltViewModel(), +) { + val ui by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + Scaffold( + topBar = { + PodroidTopBar( + title = stringResource(R.string.container_backup_title), + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) + } + }, + ) + }, + ) { innerPadding -> + AdaptiveContainer( + windowSizeClass = windowSizeClass, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = PodroidTokens.Spacing.XL, vertical = PodroidTokens.Spacing.LG), + verticalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.MD), + ) { + Text( + text = stringResource(R.string.container_backup_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + PodroidSectionLabel(stringResource(R.string.container_backup_location)) + PodroidListRow( + label = stringResource(R.string.container_backup_guest_path), + value = ui.guestPath, + mono = true, + ) + if (ui.storageAccessEnabled && ui.downloadsShareAvailable) { + PodroidListRow( + label = stringResource(R.string.container_backup_phone_path), + value = stringResource(R.string.container_backup_phone_path_value), + mono = true, + ) + } else { + Text( + text = stringResource( + if (!ui.downloadsShareAvailable) R.string.container_backup_avf_hint + else R.string.container_backup_downloads_hint, + ), + style = MaterialTheme.typography.bodySmall, + color = PodroidTokens.Amber, + ) + } + + PodroidSectionLabel(stringResource(R.string.container_backup_export)) + if (!ui.vmRunning) { + Text( + text = stringResource(R.string.container_backup_vm_stopped), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + OutlinedTextField( + value = ui.containerName, + onValueChange = viewModel::setContainerName, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.container_backup_container_name)) }, + singleLine = true, + ) + PodroidPrimaryButton( + text = stringResource(R.string.container_backup_copy_export), + onClick = { + if (viewModel.copyExportCommand()) { + Toast.makeText(context, context.getString(R.string.container_backup_copied), Toast.LENGTH_SHORT).show() + } + }, + enabled = ui.containerName.isNotBlank(), + ) + + PodroidSectionLabel(stringResource(R.string.container_backup_save_image)) + OutlinedTextField( + value = ui.imageRef, + onValueChange = viewModel::setImageRef, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.container_backup_image_ref)) }, + placeholder = { Text(stringResource(R.string.container_backup_image_placeholder)) }, + singleLine = true, + ) + PodroidGhostButton( + text = stringResource(R.string.container_backup_copy_save), + onClick = { + if (viewModel.copySaveCommand()) { + Toast.makeText(context, context.getString(R.string.container_backup_copied), Toast.LENGTH_SHORT).show() + } + }, + modifier = Modifier.fillMaxWidth(), + ) + + PodroidSectionLabel(stringResource(R.string.container_backup_tools)) + PodroidGhostButton( + text = stringResource(R.string.container_backup_copy_list), + onClick = { + viewModel.copyListCommand() + Toast.makeText(context, context.getString(R.string.container_backup_copied), Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.fillMaxWidth(), + ) + PodroidGhostButton( + text = stringResource(R.string.container_backup_copy_all), + onClick = { + viewModel.copyAllCommand() + Toast.makeText(context, context.getString(R.string.container_backup_copied), Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + text = stringResource(R.string.container_backup_terminal_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + ) + + Spacer(Modifier.height(PodroidTokens.Spacing.SM)) + PodroidSectionLabel(stringResource(R.string.container_backup_on_phone)) + PodroidGhostButton( + text = stringResource(R.string.container_backup_refresh), + onClick = viewModel::refresh, + modifier = Modifier.fillMaxWidth(), + ) + if (ui.backupFiles.isEmpty()) { + Text( + text = stringResource(R.string.container_backup_none), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + ui.backupFiles.forEach { file -> + PodroidListRow( + label = file.name, + value = "${viewModel.formatSize(file.sizeBytes)} · ${viewModel.formatDate(file.lastModifiedMs)}", + mono = true, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/excp/podroid/ui/screens/backup/ContainerBackupViewModel.kt b/app/src/main/java/com/excp/podroid/ui/screens/backup/ContainerBackupViewModel.kt new file mode 100644 index 0000000..3e04622 --- /dev/null +++ b/app/src/main/java/com/excp/podroid/ui/screens/backup/ContainerBackupViewModel.kt @@ -0,0 +1,108 @@ +package com.excp.podroid.ui.screens.backup + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.excp.podroid.data.repository.ContainerBackupFile +import com.excp.podroid.data.repository.ContainerBackupRepository +import com.excp.podroid.data.repository.SettingsRepository +import com.excp.podroid.engine.VmEngine +import com.excp.podroid.engine.VmState +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +data class ContainerBackupUiState( + val vmRunning: Boolean = false, + val storageAccessEnabled: Boolean = false, + val downloadsShareAvailable: Boolean = true, + val guestPath: String = "/var/backups/podroid", + val backupFiles: List = emptyList(), + val containerName: String = "", + val imageRef: String = "", +) + +@HiltViewModel +class ContainerBackupViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val repository: ContainerBackupRepository, + private val settingsRepository: SettingsRepository, + private val engine: VmEngine, +) : ViewModel() { + + private val _containerName = MutableStateFlow("") + private val _imageRef = MutableStateFlow("") + private val _backupFiles = MutableStateFlow>(emptyList()) + + val uiState: StateFlow = combine( + engine.state, + settingsRepository.storageAccessEnabled, + _containerName, + _imageRef, + _backupFiles, + ) { vmState, storageAccess, container, image, files -> + ContainerBackupUiState( + vmRunning = vmState is VmState.Running, + storageAccessEnabled = storageAccess, + downloadsShareAvailable = engine.backendId == "qemu", + guestPath = repository.guestBackupPathLabel(), + backupFiles = files, + containerName = container, + imageRef = image, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ContainerBackupUiState()) + + init { + refresh() + } + + fun refresh() { + _backupFiles.value = repository.listBackupFiles() + } + + fun setContainerName(value: String) { + _containerName.value = value + } + + fun setImageRef(value: String) { + _imageRef.value = value + } + + fun copyExportCommand(): Boolean { + val name = _containerName.value.trim() + if (name.isEmpty()) return false + copyToClipboard(repository.exportCommand(name)) + return true + } + + fun copySaveCommand(): Boolean { + val ref = _imageRef.value.trim() + if (ref.isEmpty()) return false + copyToClipboard(repository.saveImageCommand(ref)) + return true + } + + fun copyListCommand() { + copyToClipboard(repository.listCommand()) + } + + fun copyAllCommand() { + copyToClipboard("podroid-backup all") + } + + fun formatSize(bytes: Long): String = repository.formatSize(bytes) + + fun formatDate(ms: Long): String = repository.formatDate(ms) + + private fun copyToClipboard(text: String) { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("podroid-backup", text)) + } +} diff --git a/app/src/main/java/com/excp/podroid/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/excp/podroid/ui/screens/home/HomeScreen.kt index b4bc228..eaa4638 100644 --- a/app/src/main/java/com/excp/podroid/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/excp/podroid/ui/screens/home/HomeScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MonitorHeart import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.material3.AlertDialog @@ -68,6 +69,8 @@ fun HomeScreen( windowSizeClass: WindowSizeClass, onNavigateToTerminal: () -> Unit, onNavigateToSettings: () -> Unit, + onNavigateToStatus: () -> Unit, + onNavigateToContainerBackup: () -> Unit, viewModel: HomeViewModel = hiltViewModel(), ) { val context = LocalContext.current @@ -80,6 +83,7 @@ fun HomeScreen( val avfBootFailure by viewModel.avfBootFailure.collectAsStateWithLifecycle() val avfFailureAdvice by viewModel.avfFailureAdvice.collectAsStateWithLifecycle() val stopping by viewModel.stopping.collectAsStateWithLifecycle() + val containerCount by viewModel.containerCount.collectAsStateWithLifecycle() val isRunning = vmState is VmState.Running val isStarting = vmState is VmState.Starting @@ -97,7 +101,10 @@ fun HomeScreen( val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) phoneIp = viewModel.phoneIp() + if (event == Lifecycle.Event.ON_RESUME) { + phoneIp = viewModel.phoneIp() + viewModel.refreshContainerCount() + } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } @@ -137,6 +144,9 @@ fun HomeScreen( PodroidTopBar( title = stringResource(R.string.app_name), actions = { + IconButton(onClick = onNavigateToStatus) { + Icon(Icons.Default.MonitorHeart, contentDescription = stringResource(R.string.status_page_title)) + } IconButton(onClick = onNavigateToSettings) { Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings)) } @@ -172,13 +182,16 @@ fun HomeScreen( } HomeStatusBlock( isStarting, isRunning, isStopping, vmState, bootStage, meta, uptimeLabel, + containerCount = containerCount, avfBootFailure = avfBootFailure, avfFailureAdvice = avfFailureAdvice, onUseOneCore = { viewModel.useOneCoreAndRetry() }, onSwitchToQemu = { viewModel.switchToQemuAndRetry() }, onRetry = { viewModel.restartVm() }, ) - HomeDataSection(isRunning, isStopping, vmState, meta, phoneIp) + HomeDataSection( + isRunning, isStopping, vmState, meta, phoneIp, containerCount, + ) } Column( modifier = Modifier @@ -195,6 +208,8 @@ fun HomeScreen( onStop = { viewModel.stopVm() }, onRestart = { viewModel.restartVm() }, onOpenTerminal = onNavigateToTerminal, + onBackup = onNavigateToContainerBackup, + onStatus = onNavigateToStatus, ) } } @@ -218,13 +233,14 @@ fun HomeScreen( bootStage = bootStage, meta = meta, uptimeLabel = uptimeLabel, + containerCount = containerCount, avfBootFailure = avfBootFailure, avfFailureAdvice = avfFailureAdvice, onUseOneCore = { viewModel.useOneCoreAndRetry() }, onSwitchToQemu = { viewModel.switchToQemuAndRetry() }, onRetry = { viewModel.restartVm() }, ) - HomeDataSection(isRunning, isStopping, vmState, meta, phoneIp) + HomeDataSection(isRunning, isStopping, vmState, meta, phoneIp, containerCount) Spacer(Modifier.weight(1f)) HomeActionButtons( isRunning = isRunning, @@ -235,6 +251,8 @@ fun HomeScreen( onStop = { viewModel.stopVm() }, onRestart = { viewModel.restartVm() }, onOpenTerminal = onNavigateToTerminal, + onBackup = onNavigateToContainerBackup, + onStatus = onNavigateToStatus, ) Spacer(Modifier.height(PodroidTokens.Spacing.XL)) } @@ -292,6 +310,7 @@ private fun HomeStatusBlock( bootStage: String, meta: HomeMeta, uptimeLabel: String?, + containerCount: Int? = null, avfBootFailure: Boolean = false, avfFailureAdvice: AvfFailureGuidance.Advice = AvfFailureGuidance.Advice.SWITCH_TO_QEMU, onUseOneCore: () -> Unit = {}, @@ -348,6 +367,16 @@ private fun HomeStatusBlock( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + if (!isRunning && !isStarting && !isStopping) { + Spacer(Modifier.height(PodroidTokens.Spacing.MD)) + PodroidSectionLabel(stringResource(R.string.home_containers_created)) + Text( + text = containerCount?.let { stringResource(R.string.home_containers_count, it) } + ?: stringResource(R.string.home_containers_unknown), + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.primary, + ) + } if (vmState is VmState.Error) { Spacer(Modifier.height(PodroidTokens.Spacing.MD)) PodroidSectionLabel(stringResource(R.string.error_title)) @@ -401,6 +430,7 @@ private fun HomeDataSection( vmState: VmState, meta: HomeMeta, phoneIp: String, + containerCount: Int?, ) { val showStarting = vmState is VmState.Starting val showError = vmState is VmState.Error @@ -408,6 +438,11 @@ private fun HomeDataSection( Spacer(Modifier.height(PodroidTokens.Spacing.MD)) if (isRunning) { PodroidSectionLabel(stringResource(R.string.network)) + PodroidListRow( + label = stringResource(R.string.home_containers_created), + value = containerCount?.toString() ?: stringResource(R.string.home_containers_unknown), + mono = true, + ) PodroidListRow(label = stringResource(R.string.phone_ip), value = phoneIp, mono = true) PodroidListRow( label = stringResource(R.string.ssh), @@ -457,6 +492,8 @@ private fun HomeActionButtons( onStop: () -> Unit, onRestart: () -> Unit, onOpenTerminal: () -> Unit, + onBackup: () -> Unit, + onStatus: () -> Unit, ) { if (isStopping) { // Teardown in progress: one disabled affordance so the user can't @@ -470,6 +507,8 @@ private fun HomeActionButtons( } else if (isRunning) { PodroidPrimaryButton(text = stringResource(R.string.open_terminal), onClick = onOpenTerminal) Spacer(Modifier.height(PodroidTokens.Spacing.SM)) + HomeQuickActions(onBackup = onBackup, onStatus = onStatus, onTerminal = onOpenTerminal) + Spacer(Modifier.height(PodroidTokens.Spacing.SM)) Row(horizontalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.SM)) { PodroidGhostButton(text = stringResource(R.string.restart), onClick = onRestart, modifier = Modifier.weight(1f)) PodroidDestructiveButton(text = stringResource(R.string.stop), onClick = onStop, modifier = Modifier.weight(1f)) @@ -478,7 +517,39 @@ private fun HomeActionButtons( PodroidDestructiveButton(text = stringResource(R.string.stop), onClick = onStop) } else if (vmState is VmState.Error) { PodroidPrimaryButton(text = stringResource(R.string.try_again), onClick = onStart) + Spacer(Modifier.height(PodroidTokens.Spacing.SM)) + HomeQuickActions(onBackup = onBackup, onStatus = onStatus, onTerminal = onOpenTerminal) } else { PodroidPrimaryButton(text = stringResource(R.string.start_vm), onClick = onStart) + Spacer(Modifier.height(PodroidTokens.Spacing.SM)) + HomeQuickActions(onBackup = onBackup, onStatus = onStatus, onTerminal = onOpenTerminal) + } +} + +@Composable +private fun HomeQuickActions( + onBackup: () -> Unit, + onStatus: () -> Unit, + onTerminal: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.SM), + ) { + PodroidGhostButton( + text = stringResource(R.string.home_action_backup), + onClick = onBackup, + modifier = Modifier.weight(1f), + ) + PodroidGhostButton( + text = stringResource(R.string.home_action_status), + onClick = onStatus, + modifier = Modifier.weight(1f), + ) + PodroidGhostButton( + text = stringResource(R.string.home_action_terminal), + onClick = onTerminal, + modifier = Modifier.weight(1f), + ) } } diff --git a/app/src/main/java/com/excp/podroid/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/excp/podroid/ui/screens/home/HomeViewModel.kt index f5fc343..be227ef 100644 --- a/app/src/main/java/com/excp/podroid/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/com/excp/podroid/ui/screens/home/HomeViewModel.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.excp.podroid.BuildConfig +import com.excp.podroid.data.repository.ContainerStatsRepository import com.excp.podroid.data.repository.PortForwardRepository import com.excp.podroid.data.repository.SettingsRepository import com.excp.podroid.data.repository.UpdateInfo @@ -56,6 +57,7 @@ class HomeViewModel @Inject constructor( private val engine: VmEngine, private val settingsRepository: SettingsRepository, private val portForwardRepository: PortForwardRepository, + private val containerStatsRepository: ContainerStatsRepository, private val updateRepository: UpdateRepository, ) : ViewModel() { @@ -69,6 +71,9 @@ class HomeViewModel @Inject constructor( val bootStage: StateFlow = engine.bootStage .stateIn(viewModelScope, SharingStarted.Eagerly, "") + private val _containerCount = MutableStateFlow(null) + val containerCount: StateFlow = _containerCount.asStateFlow() + /** Aggregated metadata for the Home data sections. */ val meta: StateFlow = combine( settingsRepository.vmRamMb, @@ -172,6 +177,24 @@ class HomeViewModel @Inject constructor( init { checkForUpdate() + viewModelScope.launch { + val cached = settingsRepository.getLastContainerCount() + if (cached != null) _containerCount.value = cached + } + viewModelScope.launch { + var lastRunning = false + engine.state.collect { state -> + val running = state is VmState.Running + if (running && !lastRunning) refreshContainerCount() + lastRunning = running + } + } + viewModelScope.launch { + while (true) { + if (engine.state.value is VmState.Running) refreshContainerCount() + delay(5_000) + } + } // Maintain fallbackRunningSinceMs for engines that don't override runningSinceMs. viewModelScope.launch { var lastWasRunning = false @@ -226,6 +249,12 @@ class HomeViewModel @Inject constructor( viewModelScope.launch { settingsRepository.setAvfHintDismissed(true) } } + fun refreshContainerCount() { + viewModelScope.launch { + _containerCount.value = containerStatsRepository.readContainerCount() + } + } + fun startPodroid() = PodroidService.start(context) fun stopVm() = PodroidService.stop(context) diff --git a/app/src/main/java/com/excp/podroid/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/excp/podroid/ui/screens/settings/SettingsScreen.kt index 1340f11..101f898 100644 --- a/app/src/main/java/com/excp/podroid/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/excp/podroid/ui/screens/settings/SettingsScreen.kt @@ -84,6 +84,9 @@ import com.excp.podroid.ui.components.PodroidDestructiveButton import com.excp.podroid.ui.components.PodroidGhostButton import com.excp.podroid.ui.components.PodroidInlineAction import com.excp.podroid.ui.components.PodroidListRow +import com.excp.podroid.ui.components.VmBandwidthChips +import com.excp.podroid.ui.components.VmCpuChips +import com.excp.podroid.ui.components.VmRamChips import com.excp.podroid.ui.components.PodroidChipColors import com.excp.podroid.ui.components.PodroidSectionLabel import com.excp.podroid.ui.components.PodroidSwitch @@ -108,6 +111,7 @@ fun SettingsScreen( onNavigateBack: () -> Unit, onThemeOrFontChanged: () -> Unit = {}, onLanguageChanged: () -> Unit = {}, + onNavigateToContainerBackup: () -> Unit = {}, viewModel: SettingsViewModel = hiltViewModel(), ) { val ui by viewModel.uiState.collectAsStateWithLifecycle() @@ -229,15 +233,30 @@ fun SettingsScreen( modifier = Modifier.padding(top = PodroidTokens.Spacing.SM), ) } - RamSection( + PodroidListRow( + label = stringResource(R.string.load_balance), + rightSlot = { + PodroidSwitch( + checked = ui.loadBalanceEnabled, + onCheckedChange = { viewModel.setLoadBalanceEnabled(it) }, + enabled = vmNotRunning, + ) + }, + ) + VmRamChips( currentMb = ui.vmRamMb, onChange = viewModel::setVmRamMb, - enabled = vmNotRunning, + enabled = vmNotRunning && !ui.loadBalanceEnabled, ) - CpusSection( + VmCpuChips( currentCpus = ui.vmCpus, onChange = viewModel::setVmCpus, - enabled = vmNotRunning, + enabled = vmNotRunning && !ui.loadBalanceEnabled, + ) + VmBandwidthChips( + currentMbps = ui.bandwidthMbps, + onChange = viewModel::setBandwidthMbps, + enabled = vmNotRunning && !ui.loadBalanceEnabled, ) PodroidListRow( label = stringResource(R.string.storage), @@ -276,6 +295,12 @@ fun SettingsScreen( activeBackendId = activeBackendId, onToggle = { viewModel.setStorageAccessEnabled(it) }, ) + PodroidListRow( + label = stringResource(R.string.container_backup_title), + value = stringResource(R.string.container_backup_settings_subtitle), + trailing = "›", + onClick = onNavigateToContainerBackup, + ) UsbPassthroughRow( enabled = usbPassthrough, vmNotRunning = vmNotRunning, @@ -496,80 +521,6 @@ fun SettingsScreen( } } -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun RamSection(currentMb: Int, onChange: (Int) -> Unit, enabled: Boolean) { - Column(modifier = Modifier.padding(bottom = PodroidTokens.Spacing.SM)) { - Text( - "${stringResource(R.string.ram_label)} · ${if (currentMb >= 1024) "${currentMb / 1024} GB" else "$currentMb MB"}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding( - top = PodroidTokens.Spacing.MD, - bottom = PodroidTokens.Spacing.SM, - ), - ) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.SM), - verticalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.SM), - ) { - listOf(512, 1024, 2048, 4096).forEach { mb -> - FilterChip( - selected = mb == currentMb, - enabled = enabled, - onClick = { onChange(mb) }, - label = { Text(if (mb >= 1024) "${mb / 1024} GB" else "$mb MB") }, - shape = RoundedCornerShape(PodroidTokens.Radius.Chip), - colors = PodroidChipColors(), - ) - } - } - HorizontalDivider( - color = MaterialTheme.colorScheme.outline, - thickness = 1.dp, - modifier = Modifier.padding(top = PodroidTokens.Spacing.MD), - ) - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun CpusSection(currentCpus: Int, onChange: (Int) -> Unit, enabled: Boolean) { - Column(modifier = Modifier.padding(bottom = PodroidTokens.Spacing.SM)) { - Text( - "${stringResource(R.string.cpu_cores)} · $currentCpus", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding( - top = PodroidTokens.Spacing.MD, - bottom = PodroidTokens.Spacing.SM, - ), - ) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.SM), - verticalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.SM), - ) { - listOf(1, 2, 4, 6, 8).forEach { n -> - FilterChip( - selected = n == currentCpus, - enabled = enabled, - onClick = { onChange(n) }, - label = { Text("$n") }, - shape = RoundedCornerShape(PodroidTokens.Radius.Chip), - colors = PodroidChipColors(), - ) - } - } - HorizontalDivider( - color = MaterialTheme.colorScheme.outline, - thickness = 1.dp, - modifier = Modifier.padding(top = PodroidTokens.Spacing.MD), - ) - } -} - @Composable private fun PortForwardSection( rules: List, diff --git a/app/src/main/java/com/excp/podroid/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/com/excp/podroid/ui/screens/settings/SettingsViewModel.kt index 3eaffc3..12c96ad 100644 --- a/app/src/main/java/com/excp/podroid/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/excp/podroid/ui/screens/settings/SettingsViewModel.kt @@ -23,6 +23,7 @@ import com.excp.podroid.di.ApplicationScope import com.excp.podroid.engine.EngineSelection import com.excp.podroid.engine.VmEngine import com.excp.podroid.engine.VmState +import com.excp.podroid.util.DeviceResourcePolicy import com.excp.podroid.util.NetworkUtils import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -62,6 +63,8 @@ data class SettingsUiState( val engineSelection: EngineSelection = EngineSelection.AUTO, val language: String = "auto", val systemDefaultLanguage: String = "auto", + val loadBalanceEnabled: Boolean = false, + val bandwidthMbps: Int = 0, ) @HiltViewModel @@ -99,8 +102,9 @@ class SettingsViewModel @Inject constructor( settingsRepository.vmCpus, settingsRepository.storageSizeGb, settingsRepository.sshEnabled, - ) { ram, cpus, storage, ssh -> - arrayOf(ram, cpus, storage, ssh) + settingsRepository.loadBalanceEnabled, + ) { ram, cpus, storage, ssh, loadBal -> + arrayOf(ram, cpus, storage, ssh, loadBal) }, combine( settingsRepository.storageAccessEnabled, @@ -111,10 +115,15 @@ class SettingsViewModel @Inject constructor( ) { storageAccess, qemu, kernel, dark, dyn -> arrayOf(storageAccess, qemu, kernel, dark, dyn) }, - settingsRepository.engineSelection, - settingsRepository.language, - languageManager.language, - ) { a, b, engineSel, lang, sysLang -> + combine( + settingsRepository.bandwidthMbps, + settingsRepository.engineSelection, + settingsRepository.language, + languageManager.language, + ) { bandwidth, engineSel, lang, sysLang -> + arrayOf(bandwidth, engineSel, lang, sysLang) + }, + ) { a, b, c -> SettingsUiState( vmRamMb = a[0] as Int, vmCpus = a[1] as Int, @@ -125,9 +134,11 @@ class SettingsViewModel @Inject constructor( kernelExtraCmdline = b[2] as String, darkTheme = b[3] as Boolean, dynamicColorEnabled = b[4] as Boolean, - engineSelection = engineSel, - language = lang, - systemDefaultLanguage = sysLang, + engineSelection = c[1] as EngineSelection, + language = c[2] as String, + systemDefaultLanguage = c[3] as String, + loadBalanceEnabled = a[4] as Boolean, + bandwidthMbps = c[0] as Int, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SettingsUiState()) @@ -206,11 +217,36 @@ class SettingsViewModel @Inject constructor( } fun setVmRamMb(value: Int) { - viewModelScope.launch { settingsRepository.setVmRamMb(value) } + viewModelScope.launch { + settingsRepository.setLoadBalanceEnabled(false) + settingsRepository.setVmRamMb(value) + } } fun setVmCpus(value: Int) { - viewModelScope.launch { settingsRepository.setVmCpus(value) } + viewModelScope.launch { + settingsRepository.setLoadBalanceEnabled(false) + settingsRepository.setVmCpus(value) + } + } + + fun setBandwidthMbps(value: Int) { + viewModelScope.launch { + settingsRepository.setLoadBalanceEnabled(false) + settingsRepository.setBandwidthMbps(value) + } + } + + fun setLoadBalanceEnabled(enabled: Boolean) { + viewModelScope.launch { + settingsRepository.setLoadBalanceEnabled(enabled) + if (enabled) { + val profile = DeviceResourcePolicy.balancedProfile(context) + settingsRepository.setVmRamMb(profile.ramMb) + settingsRepository.setVmCpus(profile.cpus) + settingsRepository.setBandwidthMbps(profile.bandwidthMbps) + } + } } fun setTerminalFontSize(value: Int) { diff --git a/app/src/main/java/com/excp/podroid/ui/screens/setup/SetupScreen.kt b/app/src/main/java/com/excp/podroid/ui/screens/setup/SetupScreen.kt index 5fabf47..dba838d 100644 --- a/app/src/main/java/com/excp/podroid/ui/screens/setup/SetupScreen.kt +++ b/app/src/main/java/com/excp/podroid/ui/screens/setup/SetupScreen.kt @@ -67,7 +67,11 @@ import com.excp.podroid.ui.components.PodroidPrimaryButton import com.excp.podroid.ui.components.PodroidSectionLabel import com.excp.podroid.ui.components.PodroidSwitch import com.excp.podroid.ui.components.PodroidChipColors +import com.excp.podroid.ui.components.VmBandwidthChips +import com.excp.podroid.ui.components.VmCpuChips +import com.excp.podroid.ui.components.VmRamChips import com.excp.podroid.ui.theme.PodroidTokens +import com.excp.podroid.util.DeviceResourcePolicy import kotlinx.coroutines.launch private val storageSizes = listOf(2, 4, 8, 16, 32, 64) @@ -83,6 +87,10 @@ fun SetupScreen( // rememberSaveable preserves wizard choices through config changes (rotation, font-scale, etc.) // so a mid-wizard rotation doesn't silently reset storage to 8 GB (not resizable later). var selectedGb by rememberSaveable { mutableIntStateOf(DEFAULT_STORAGE_GB) } + var selectedRamMb by rememberSaveable { mutableIntStateOf(512) } + var selectedCpus by rememberSaveable { mutableIntStateOf(2) } + var selectedBandwidthMbps by rememberSaveable { mutableIntStateOf(0) } + var loadBalanceEnabled by rememberSaveable { mutableStateOf(true) } var sshEnabled by rememberSaveable { mutableStateOf(true) } var storageAccessEnabled by rememberSaveable { mutableStateOf(false) } var usbPassthroughEnabled by rememberSaveable { mutableStateOf(false) } @@ -91,6 +99,18 @@ fun SetupScreen( val pagerState = rememberPagerState(pageCount = { 4 }) val scope = rememberCoroutineScope() + fun applyLoadBalance() { + val profile = DeviceResourcePolicy.balancedProfile(context) + selectedRamMb = profile.ramMb + selectedCpus = profile.cpus + selectedGb = profile.storageGb + selectedBandwidthMbps = profile.bandwidthMbps + } + + LaunchedEffect(Unit) { + if (loadBalanceEnabled) applyLoadBalance() + } + // Request the notification permission BEFORE navigating away; using // rememberLauncherForActivityResult registers it while the composable is still // alive, so the result actually arrives. ActivityCompat.requestPermissions @@ -158,11 +178,24 @@ fun SetupScreen( 0 -> StoragePage( windowSizeClass = windowSizeClass, selectedGb = selectedGb, + loadBalanceEnabled = loadBalanceEnabled, onSelect = { selectedGb = it }, onNext = { scope.launch { pagerState.animateScrollToPage(1) } }, ) 1 -> VmConfigPage( windowSizeClass = windowSizeClass, + storageGb = selectedGb, + ramMb = selectedRamMb, + cpus = selectedCpus, + bandwidthMbps = selectedBandwidthMbps, + loadBalanceEnabled = loadBalanceEnabled, + onLoadBalanceToggle = { enabled -> + loadBalanceEnabled = enabled + if (enabled) applyLoadBalance() + }, + onRamChange = { selectedRamMb = it }, + onCpusChange = { selectedCpus = it }, + onBandwidthChange = { selectedBandwidthMbps = it }, sshEnabled = sshEnabled, onSshToggle = { sshEnabled = it }, onBack = { scope.launch { pagerState.animateScrollToPage(0) } }, @@ -207,9 +240,13 @@ fun SetupScreen( onGetStarted = { viewModel.completeSetup( storageSizeGb = selectedGb, + vmRamMb = selectedRamMb, + vmCpus = selectedCpus, sshEnabled = sshEnabled, storageAccessEnabled = storageAccessEnabled, usbPassthroughEnabled = usbPassthroughEnabled && usbPassthroughAvailable, + loadBalanceEnabled = loadBalanceEnabled, + bandwidthMbps = selectedBandwidthMbps, ) }, ) @@ -340,6 +377,7 @@ private fun SetupPageLayout( private fun StoragePage( windowSizeClass: WindowSizeClass, selectedGb: Int, + loadBalanceEnabled: Boolean, onSelect: (Int) -> Unit, onNext: () -> Unit, ) { @@ -356,7 +394,19 @@ private fun StoragePage( color = MaterialTheme.colorScheme.primary, ) Spacer(Modifier.height(PodroidTokens.Spacing.MD)) - StorageSizeChips(selectedGb, onSelect) + StorageSizeChips( + selectedGb = selectedGb, + onSelect = onSelect, + enabled = !loadBalanceEnabled, + ) + if (loadBalanceEnabled) { + Spacer(Modifier.height(PodroidTokens.Spacing.SM)) + Text( + text = stringResource(R.string.load_balance_storage_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } @@ -365,6 +415,15 @@ private fun StoragePage( @Composable private fun VmConfigPage( windowSizeClass: WindowSizeClass, + storageGb: Int, + ramMb: Int, + cpus: Int, + bandwidthMbps: Int, + loadBalanceEnabled: Boolean, + onLoadBalanceToggle: (Boolean) -> Unit, + onRamChange: (Int) -> Unit, + onCpusChange: (Int) -> Unit, + onBandwidthChange: (Int) -> Unit, sshEnabled: Boolean, onSshToggle: (Boolean) -> Unit, onBack: () -> Unit, @@ -377,9 +436,41 @@ private fun VmConfigPage( description = stringResource(R.string.vm_config_description), bottomBar = { SetupNavBar(onBack = onBack, onNext = onNext, nextLabel = stringResource(R.string.continue_label)) }, ) { + PodroidListRow( + label = stringResource(R.string.load_balance), + rightSlot = { + PodroidSwitch(checked = loadBalanceEnabled, onCheckedChange = onLoadBalanceToggle) + }, + ) + if (loadBalanceEnabled) { + Text( + text = stringResource(R.string.load_balance_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = PodroidTokens.Spacing.SM), + ) + } + PodroidSectionLabel(stringResource(R.string.resources)) - PodroidListRow(label = stringResource(R.string.cpu_cores), value = "2") - PodroidListRow(label = stringResource(R.string.ram_label), value = "512 MB") + PodroidListRow(label = stringResource(R.string.storage), value = "$storageGb GB") + VmRamChips( + currentMb = ramMb, + onChange = onRamChange, + enabled = !loadBalanceEnabled, + showDivider = false, + ) + VmCpuChips( + currentCpus = cpus, + onChange = onCpusChange, + enabled = !loadBalanceEnabled, + showDivider = false, + ) + VmBandwidthChips( + currentMbps = bandwidthMbps, + onChange = onBandwidthChange, + enabled = !loadBalanceEnabled, + showDivider = true, + ) PodroidSectionLabel(stringResource(R.string.network_label)) PodroidListRow( @@ -536,7 +627,7 @@ private fun SetupNavBar( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun StorageSizeChips(selectedGb: Int, onSelect: (Int) -> Unit) { +private fun StorageSizeChips(selectedGb: Int, onSelect: (Int) -> Unit, enabled: Boolean = true) { FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), @@ -546,6 +637,7 @@ private fun StorageSizeChips(selectedGb: Int, onSelect: (Int) -> Unit) { storageSizes.forEach { gb -> FilterChip( selected = gb == selectedGb, + enabled = enabled, onClick = { onSelect(gb) }, label = { Text( diff --git a/app/src/main/java/com/excp/podroid/ui/screens/setup/SetupViewModel.kt b/app/src/main/java/com/excp/podroid/ui/screens/setup/SetupViewModel.kt index 8fe7ecf..a9ed00f 100644 --- a/app/src/main/java/com/excp/podroid/ui/screens/setup/SetupViewModel.kt +++ b/app/src/main/java/com/excp/podroid/ui/screens/setup/SetupViewModel.kt @@ -35,16 +35,24 @@ class SetupViewModel @Inject constructor( */ fun completeSetup( storageSizeGb: Int, + vmRamMb: Int, + vmCpus: Int, sshEnabled: Boolean, storageAccessEnabled: Boolean, usbPassthroughEnabled: Boolean, + loadBalanceEnabled: Boolean, + bandwidthMbps: Int, ) { viewModelScope.launch { settingsRepository.completeSetup( storageSizeGb = storageSizeGb, + vmRamMb = vmRamMb, + vmCpus = vmCpus, sshEnabled = sshEnabled, storageAccessEnabled = storageAccessEnabled, usbPassthroughEnabled = usbPassthroughEnabled, + loadBalanceEnabled = loadBalanceEnabled, + bandwidthMbps = bandwidthMbps, ) _setupComplete.value = true } diff --git a/app/src/main/java/com/excp/podroid/ui/screens/status/StatusScreen.kt b/app/src/main/java/com/excp/podroid/ui/screens/status/StatusScreen.kt new file mode 100644 index 0000000..4c87c26 --- /dev/null +++ b/app/src/main/java/com/excp/podroid/ui/screens/status/StatusScreen.kt @@ -0,0 +1,258 @@ +package com.excp.podroid.ui.screens.status + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.excp.podroid.R +import com.excp.podroid.engine.EngineSelection +import com.excp.podroid.engine.VmState +import com.excp.podroid.ui.components.AdaptiveContainer +import com.excp.podroid.ui.components.PodroidListRow +import com.excp.podroid.ui.components.PodroidSectionLabel +import com.excp.podroid.ui.components.PodroidTopBar +import com.excp.podroid.ui.components.VmLoadGraph +import com.excp.podroid.ui.theme.PodroidTokens +import com.excp.podroid.util.HostMetrics + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StatusScreen( + windowSizeClass: WindowSizeClass, + onNavigateBack: () -> Unit, + viewModel: StatusViewModel = hiltViewModel(), +) { + val ui by viewModel.uiState.collectAsStateWithLifecycle() + val metrics = ui.metrics + val phoneRamUsedMb = (metrics.phoneTotalRamMb - metrics.phoneAvailRamMb).coerceAtLeast(0) + val phoneStorageUsedGb = (metrics.phoneStorageTotalGb - metrics.phoneStorageAvailGb) + .coerceAtLeast(0.0) + + Scaffold( + topBar = { + PodroidTopBar( + title = stringResource(R.string.status_page_title), + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) + } + }, + ) + }, + ) { innerPadding -> + AdaptiveContainer( + windowSizeClass = windowSizeClass, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = PodroidTokens.Spacing.XL, vertical = PodroidTokens.Spacing.LG), + verticalArrangement = Arrangement.spacedBy(PodroidTokens.Spacing.MD), + ) { + PodroidSectionLabel(stringResource(R.string.status_phone_section)) + StatusMetricBar( + label = stringResource(R.string.ram_label), + detail = "${HostMetrics.formatMb(phoneRamUsedMb)} / ${HostMetrics.formatMb(metrics.phoneTotalRamMb)}", + progress = HostMetrics.percent(phoneRamUsedMb, metrics.phoneTotalRamMb), + ) + StatusMetricBar( + label = stringResource(R.string.storage), + detail = "${HostMetrics.formatGb(phoneStorageUsedGb)} / ${HostMetrics.formatGb(metrics.phoneStorageTotalGb)}", + progress = HostMetrics.percent(phoneStorageUsedGb, metrics.phoneStorageTotalGb), + ) + PodroidListRow( + label = stringResource(R.string.cpu_cores), + value = "${metrics.phoneCpuCores}", + ) + val loadLabel = if (metrics.loadAvg1 != null) { + String.format( + "%.2f · %.2f · %.2f", + metrics.loadAvg1, + metrics.loadAvg5, + metrics.loadAvg15, + ) + } else { + stringResource(R.string.status_unavailable) + } + PodroidListRow( + label = stringResource(R.string.status_load_average), + value = loadLabel, + mono = true, + ) + + Spacer(Modifier.height(PodroidTokens.Spacing.SM)) + PodroidSectionLabel(stringResource(R.string.status_vm_section)) + PodroidListRow( + label = stringResource(R.string.vm_status), + value = vmStatusLabel(ui.vmState, ui.uptimeLabel), + ) + PodroidListRow( + label = stringResource(R.string.backend), + value = backendLabel(ui.engineSelection, ui.backendId), + mono = true, + ) + PodroidListRow( + label = stringResource(R.string.phone_ip), + value = ui.phoneIp, + mono = true, + ) + + Text( + text = stringResource(R.string.status_vm_load_graph_title), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = PodroidTokens.Spacing.SM), + ) + VmLoadGraph( + samples = ui.vmLoadHistory, + currentPercent = ui.vmLoadPercent, + unavailableReason = when { + ui.vmState !is VmState.Running -> stringResource(R.string.status_vm_load_stopped) + ui.vmLoadGraphUnavailable == "avf" -> stringResource(R.string.status_vm_load_avf_only) + else -> null + }, + modifier = Modifier.padding(bottom = PodroidTokens.Spacing.SM), + ) + + StatusMetricBar( + label = stringResource(R.string.status_vm_ram_allocated), + detail = HostMetrics.formatMb(ui.vmRamMb.toLong()), + progress = if (metrics.emulatorRssMb != null && ui.vmRamMb > 0) { + HostMetrics.percent(metrics.emulatorRssMb, ui.vmRamMb.toLong()) + } else null, + sublabel = metrics.emulatorRssMb?.let { + stringResource(R.string.status_emulator_rss, HostMetrics.formatMb(it)) + }, + ) + PodroidListRow( + label = stringResource(R.string.cpu_cores), + value = "${ui.vmCpus}", + ) + StatusMetricBar( + label = stringResource(R.string.status_vm_disk), + detail = "${HostMetrics.formatGb(metrics.vmDiskImageBytes)} / ${ui.storageSizeGb} GB", + progress = HostMetrics.percent( + metrics.vmDiskImageBytes, + ui.storageSizeGb.toLong() * 1024L * 1024L * 1024L, + ), + ) + PodroidListRow( + label = stringResource(R.string.bandwidth_limit), + value = if (ui.bandwidthMbps <= 0) { + stringResource(R.string.bandwidth_unlimited) + } else { + "${ui.bandwidthMbps} Mbps" + }, + ) + PodroidListRow( + label = stringResource(R.string.load_balance), + value = if (ui.loadBalanceEnabled) { + stringResource(R.string.on) + } else { + stringResource(R.string.off) + }, + ) + PodroidListRow( + label = stringResource(R.string.port_forwards), + value = "${ui.portForwardCount}", + divider = false, + ) + + Text( + text = stringResource(R.string.status_refresh_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = PodroidTokens.Spacing.SM), + ) + } + } + } +} + +@Composable +private fun StatusMetricBar( + label: String, + detail: String, + progress: Float?, + sublabel: String? = null, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = PodroidTokens.Spacing.SM), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Text( + detail, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (progress != null) { + Spacer(Modifier.height(PodroidTokens.Spacing.XS)) + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth(), + ) + } + if (sublabel != null) { + Spacer(Modifier.height(PodroidTokens.Spacing.XS)) + Text( + sublabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun vmStatusLabel(vmState: VmState, uptime: String?): String = when (vmState) { + is VmState.Running -> uptime?.let { "${stringResource(R.string.status_running)} · $it" } + ?: stringResource(R.string.status_running) + is VmState.Starting -> stringResource(R.string.status_starting) + is VmState.Error -> stringResource(R.string.status_error) + else -> stringResource(R.string.status_stopped) +} + +@Composable +private fun backendLabel(selection: EngineSelection, activeId: String): String = when (selection) { + EngineSelection.AUTO -> "${stringResource(R.string.auto)} ($activeId)" + EngineSelection.AVF -> stringResource(R.string.avf_kvm) + EngineSelection.QEMU -> stringResource(R.string.qemu_tcg) +} diff --git a/app/src/main/java/com/excp/podroid/ui/screens/status/StatusViewModel.kt b/app/src/main/java/com/excp/podroid/ui/screens/status/StatusViewModel.kt new file mode 100644 index 0000000..42234c0 --- /dev/null +++ b/app/src/main/java/com/excp/podroid/ui/screens/status/StatusViewModel.kt @@ -0,0 +1,183 @@ +package com.excp.podroid.ui.screens.status + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.excp.podroid.data.repository.PortForwardRepository +import com.excp.podroid.data.repository.SettingsRepository +import com.excp.podroid.engine.EngineSelection +import com.excp.podroid.engine.VmEngine +import com.excp.podroid.engine.VmState +import com.excp.podroid.util.HostMetrics +import com.excp.podroid.util.HostMetricsSnapshot +import com.excp.podroid.util.NetworkUtils +import com.excp.podroid.util.VmLoadSampler +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + +data class StatusUiState( + val vmState: VmState = VmState.Idle, + val backendId: String = "qemu", + val engineSelection: EngineSelection = EngineSelection.AUTO, + val uptimeLabel: String? = null, + val phoneIp: String = "—", + val vmRamMb: Int = 512, + val vmCpus: Int = 2, + val storageSizeGb: Int = 8, + val bandwidthMbps: Int = 0, + val loadBalanceEnabled: Boolean = false, + val portForwardCount: Int = 0, + val vmLoadPercent: Float? = null, + val vmLoadHistory: List = emptyList(), + val vmLoadGraphUnavailable: String? = null, + val metrics: HostMetricsSnapshot = HostMetricsSnapshot( + phoneTotalRamMb = 0, + phoneAvailRamMb = 0, + phoneCpuCores = 1, + loadAvg1 = null, + loadAvg5 = null, + loadAvg15 = null, + phoneStorageTotalGb = 0.0, + phoneStorageAvailGb = 0.0, + vmDiskImageBytes = 0L, + emulatorRssMb = null, + ), +) + +@HiltViewModel +class StatusViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val engine: VmEngine, + private val settingsRepository: SettingsRepository, + private val portForwardRepository: PortForwardRepository, +) : ViewModel() { + + private val _metrics = MutableStateFlow(defaultMetrics()) + private val _uptimeTick = MutableStateFlow(0L) + private val _vmLoadPercent = MutableStateFlow(null) + private val _vmLoadHistory = MutableStateFlow>(emptyList()) + private val _vmLoadUnavailable = MutableStateFlow(null) + private val loadSampler = VmLoadSampler() + + val uiState: StateFlow = combine( + combine( + engine.state, + settingsRepository.vmRamMb, + settingsRepository.vmCpus, + settingsRepository.storageSizeGb, + settingsRepository.bandwidthMbps, + ) { vmState, ram, cpus, storage, bandwidth -> + arrayOf(vmState, ram, cpus, storage, bandwidth) + }, + combine( + settingsRepository.loadBalanceEnabled, + settingsRepository.engineSelection, + portForwardRepository.rules, + _metrics, + _uptimeTick, + ) { loadBal, engineSel, rules, metrics, tick -> + arrayOf(loadBal, engineSel, rules, metrics, tick) + }, + combine( + _vmLoadPercent, + _vmLoadHistory, + _vmLoadUnavailable, + ) { pct, history, unavailable -> + arrayOf(pct, history, unavailable) + }, + ) { a, b, c -> + val vmState = a[0] as VmState + val tick = b[4] as Long + StatusUiState( + vmState = vmState, + backendId = engine.backendId, + engineSelection = b[1] as EngineSelection, + uptimeLabel = uptimeLabel(vmState, tick), + phoneIp = NetworkUtils.localIpv4(context), + vmRamMb = a[1] as Int, + vmCpus = a[2] as Int, + storageSizeGb = a[3] as Int, + bandwidthMbps = a[4] as Int, + loadBalanceEnabled = b[0] as Boolean, + portForwardCount = (b[2] as List<*>).size, + vmLoadPercent = c[0] as Float?, + vmLoadHistory = c[1] as List, + vmLoadGraphUnavailable = c[2] as String?, + metrics = b[3] as HostMetricsSnapshot, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatusUiState()) + + init { + refreshMetrics() + viewModelScope.launch { + while (isActive) { + delay(2_000) + refreshMetrics() + sampleVmLoad() + if (engine.state.value is VmState.Running) { + _uptimeTick.value = System.currentTimeMillis() + } + } + } + } + + fun refreshMetrics() { + val storageImg = File(context.filesDir, "storage.img") + val rss = if (engine.state.value is VmState.Running) engine.emulatorRssMb() else null + _metrics.value = HostMetrics.snapshot(context, storageImg, rss) + } + + private fun sampleVmLoad() { + if (engine.state.value !is VmState.Running) { + loadSampler.reset() + _vmLoadPercent.value = null + _vmLoadHistory.value = emptyList() + _vmLoadUnavailable.value = null + return + } + + val pid = engine.emulatorPid() + if (pid == null) { + loadSampler.reset() + _vmLoadPercent.value = null + _vmLoadHistory.value = emptyList() + _vmLoadUnavailable.value = "avf" + return + } + + _vmLoadUnavailable.value = null + val pct = loadSampler.sampleCpuPercent(pid, uiState.value.vmCpus) ?: return + _vmLoadPercent.value = pct + _vmLoadHistory.value = (_vmLoadHistory.value + pct).takeLast(VmLoadSampler.MAX_SAMPLES) + } + + private fun defaultMetrics(): HostMetricsSnapshot { + val storageImg = File(context.filesDir, "storage.img") + return HostMetrics.snapshot(context, storageImg, null) + } + + private fun uptimeLabel(vmState: VmState, tick: Long): String? { + if (vmState !is VmState.Running) return null + val since = engine.runningSinceMs ?: return null + val secs = ((tick.takeIf { it > 0 } ?: System.currentTimeMillis()) - since) / 1000 + if (secs < 0) return null + val h = secs / 3600 + val m = (secs % 3600) / 60 + val s = secs % 60 + return when { + h > 0 -> "${h}h ${m}m" + m > 0 -> "${m}m ${s}s" + else -> "${s}s" + } + } +} diff --git a/app/src/main/java/com/excp/podroid/util/DeviceResourcePolicy.kt b/app/src/main/java/com/excp/podroid/util/DeviceResourcePolicy.kt new file mode 100644 index 0000000..1a96a3a --- /dev/null +++ b/app/src/main/java/com/excp/podroid/util/DeviceResourcePolicy.kt @@ -0,0 +1,70 @@ +/* + * Podroid - Rootless Podman for Android + * Copyright (C) 2024-2026 Podroid contributors + * + * Device-aware VM resource recommendations for load-balance mode. + */ +package com.excp.podroid.util + +import android.app.ActivityManager +import android.content.Context +import android.os.StatFs + +object DeviceResourcePolicy { + val RAM_OPTIONS_MB = listOf(512, 1024, 2048, 4096) + val CPU_OPTIONS = listOf(1, 2, 4, 6, 8) + /** 0 = unlimited */ + val BANDWIDTH_OPTIONS_MBPS = listOf(0, 10, 50, 100, 500) + val STORAGE_OPTIONS_GB = listOf(2, 4, 8, 16, 32, 64) + + data class BalancedProfile( + val ramMb: Int, + val cpus: Int, + val storageGb: Int, + val bandwidthMbps: Int, + ) + + fun deviceTotalRamMb(context: Context): Long { + val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val info = ActivityManager.MemoryInfo() + am.getMemoryInfo(info) + return info.totalMem / (1024 * 1024) + } + + fun deviceCpuCount(): Int = Runtime.getRuntime().availableProcessors() + + fun deviceAvailableStorageGb(context: Context): Int { + val stat = StatFs(context.filesDir.absolutePath) + return (stat.availableBytes / (1024L * 1024 * 1024)).toInt() + } + + fun nearestAtMost(options: List, target: Int): Int = + options.filter { it <= target }.maxOrNull() ?: options.first() + + fun balancedRamMb(totalRamMb: Long): Int { + // Leave headroom for Android; give the VM ~35% of physical RAM. + val target = (totalRamMb * 0.35).toInt() + return nearestAtMost(RAM_OPTIONS_MB, target) + } + + fun balancedCpus(cpuCount: Int): Int { + // Half the host cores, capped at 4 for battery/thermals on phones. + val target = (cpuCount / 2).coerceAtLeast(1).coerceAtMost(4) + return nearestAtMost(CPU_OPTIONS, target) + } + + fun balancedStorageGb(availableGb: Int): Int { + // Use up to ~25% of free app-private space, at least 2 GB. + val target = (availableGb * 0.25).toInt().coerceAtLeast(2) + return nearestAtMost(STORAGE_OPTIONS_GB, target) + } + + fun balancedBandwidthMbps(): Int = 100 + + fun balancedProfile(context: Context): BalancedProfile = BalancedProfile( + ramMb = balancedRamMb(deviceTotalRamMb(context)), + cpus = balancedCpus(deviceCpuCount()), + storageGb = balancedStorageGb(deviceAvailableStorageGb(context)), + bandwidthMbps = balancedBandwidthMbps(), + ) +} diff --git a/app/src/main/java/com/excp/podroid/util/HostMetrics.kt b/app/src/main/java/com/excp/podroid/util/HostMetrics.kt new file mode 100644 index 0000000..1e17d41 --- /dev/null +++ b/app/src/main/java/com/excp/podroid/util/HostMetrics.kt @@ -0,0 +1,102 @@ +/* + * Podroid - Rootless Podman for Android + * Copyright (C) 2024-2026 Podroid contributors + * + * Host-side resource snapshots for the Status screen. + */ +package com.excp.podroid.util + +import android.app.ActivityManager +import android.content.Context +import android.os.StatFs +import java.io.File +import kotlin.math.roundToInt + +data class HostMetricsSnapshot( + val phoneTotalRamMb: Long, + val phoneAvailRamMb: Long, + val phoneCpuCores: Int, + val loadAvg1: Float?, + val loadAvg5: Float?, + val loadAvg15: Float?, + val phoneStorageTotalGb: Double, + val phoneStorageAvailGb: Double, + val vmDiskImageBytes: Long, + val emulatorRssMb: Long?, +) + +object HostMetrics { + fun snapshot( + context: Context, + storageImg: File, + emulatorRssMb: Long?, + ): HostMetricsSnapshot { + val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val mem = ActivityManager.MemoryInfo() + am.getMemoryInfo(mem) + + val stat = StatFs(context.filesDir.absolutePath) + val load = readLoadAvg() + + return HostMetricsSnapshot( + phoneTotalRamMb = mem.totalMem / (1024 * 1024), + phoneAvailRamMb = mem.availMem / (1024 * 1024), + phoneCpuCores = DeviceResourcePolicy.deviceCpuCount(), + loadAvg1 = load?.first, + loadAvg5 = load?.second, + loadAvg15 = load?.third, + phoneStorageTotalGb = stat.totalBytes / (1024.0 * 1024 * 1024), + phoneStorageAvailGb = stat.availableBytes / (1024.0 * 1024 * 1024), + vmDiskImageBytes = if (storageImg.isFile) storageImg.length() else 0L, + emulatorRssMb = emulatorRssMb, + ) + } + + fun readLoadAvg(): Triple? = try { + val parts = File("/proc/loadavg").readText().trim().split(Regex("\\s+")) + if (parts.size < 3) null + else Triple(parts[0].toFloat(), parts[1].toFloat(), parts[2].toFloat()) + } catch (_: Exception) { + null + } + + fun processPid(process: Process): Int? = try { + val m = Process::class.java.getMethod("pid") + (m.invoke(process) as Int).takeIf { it > 0 } + } catch (_: Exception) { + null + } + + /** VmRSS from /proc/[pid]/status, in megabytes. */ + fun processVmRssMb(pid: Int): Long? { + val kb = try { + File("/proc/$pid/status").useLines { lines -> + lines.firstOrNull { it.startsWith("VmRSS:") } + ?.substringAfter("VmRSS:") + ?.trim() + ?.substringBefore(" ") + ?.toLongOrNull() + } + } catch (_: Exception) { + null + } ?: return null + return (kb / 1024.0).roundToInt().toLong() + } + + fun formatGb(bytes: Long): String { + val gb = bytes / (1024.0 * 1024 * 1024) + return if (gb >= 10) "${gb.roundToInt()} GB" else String.format("%.1f GB", gb) + } + + fun formatGb(gb: Double): String = + if (gb >= 10) "${gb.roundToInt()} GB" else String.format("%.1f GB", gb) + + fun formatMb(mb: Long): String = + if (mb >= 1024) "${mb / 1024} GB" else "$mb MB" + + fun percent(used: Long, total: Long): Float = + if (total <= 0) 0f else (used.toFloat() / total.toFloat()).coerceIn(0f, 1f) + + fun percent(used: Double, total: Double): Float = + if (total <= 0.0) 0f else (used / total).toFloat().coerceIn(0f, 1f) +} diff --git a/app/src/main/java/com/excp/podroid/util/ShellQuote.kt b/app/src/main/java/com/excp/podroid/util/ShellQuote.kt new file mode 100644 index 0000000..f8244ca --- /dev/null +++ b/app/src/main/java/com/excp/podroid/util/ShellQuote.kt @@ -0,0 +1,12 @@ +/* + * Podroid - Rootless Podman for Android + * Copyright (C) 2024-2026 Podroid contributors + */ +package com.excp.podroid.util + +object ShellQuote { + fun quote(s: String): String = + if (s.isEmpty()) "''" + else if (s.none { it.isWhitespace() || it in "'\"\\$`" }) s + else "'" + s.replace("'", "'\\''") + "'" +} diff --git a/app/src/main/java/com/excp/podroid/util/VmLoadSampler.kt b/app/src/main/java/com/excp/podroid/util/VmLoadSampler.kt new file mode 100644 index 0000000..7e0cdc2 --- /dev/null +++ b/app/src/main/java/com/excp/podroid/util/VmLoadSampler.kt @@ -0,0 +1,60 @@ +/* + * Podroid - Rootless Podman for Android + * Copyright (C) 2024-2026 Podroid contributors + * + * Samples emulator CPU load from /proc/[pid]/stat for the Status load graph. + */ +package com.excp.podroid.util + +import android.os.SystemClock +import java.io.File + +/** + * Stateful sampler: call [sampleCpuPercent] every ~2s with the emulator PID. + * Returns null on the first sample (no delta yet) or when /proc is unreadable. + */ +class VmLoadSampler( + private val clockHz: Long = 100L, +) { + private var lastTicks: Long? = null + private var lastTimeMs: Long? = null + + fun reset() { + lastTicks = null + lastTimeMs = null + } + + /** + * CPU load as 0–100% of the VM's allocated vCPU count (QEMU process on Android). + */ + fun sampleCpuPercent(pid: Int, vmCpus: Int): Float? { + val ticks = readProcessCpuTicks(pid) ?: return null + val now = SystemClock.elapsedRealtime() + val prevTicks = lastTicks + val prevTime = lastTimeMs + lastTicks = ticks + lastTimeMs = now + if (prevTicks == null || prevTime == null) return null + + val deltaTicks = (ticks - prevTicks).coerceAtLeast(0) + val deltaSec = (now - prevTime) / 1000.0 + if (deltaSec <= 0.0) return null + + val coresUsed = (deltaTicks / clockHz.toDouble()) / deltaSec + val pct = (coresUsed / vmCpus.coerceAtLeast(1) * 100.0).toFloat() + return pct.coerceIn(0f, 100f) + } + + companion object { + const val MAX_SAMPLES = 60 + + fun readProcessCpuTicks(pid: Int): Long? = try { + val after = File("/proc/$pid/stat").readText().substringAfter(") ") + val fields = after.split(Regex("\\s+")) + if (fields.size < 13) null + else fields[11].toLong() + fields[12].toLong() + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 41f1e65..823c9e4 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -53,6 +53,11 @@ 网络 SSH 访问 ssh root@<手机-ip> -p 9922 (密码: podroid) + 负载均衡 + 根据本机自动调整 CPU、内存、存储和带宽。 + 存储大小已自动设置。在下一步关闭负载均衡后可手动选择。 + 带宽限制 + 无限制 下载文件夹共享 @@ -116,6 +121,63 @@ 活动 关闭 + 开启 + + + 状态监控 + 手机 + 虚拟机 + 平均负载(1 / 5 / 15 分钟) + 虚拟机内存(已分配) + 模拟器占用 %1$s + 虚拟机磁盘镜像 + + 错误 + 此页面打开时每 2 秒自动刷新。 + 虚拟机 CPU 负载 + 当前:%1$d%% + 正在采集数据… + 启动虚拟机后显示负载 + 虚拟机未运行 + 负载图表仅在 QEMU 后端且虚拟机运行时可用 + 根据手机上模拟器进程的 CPU 占用计算,并按虚拟机分配的核心数归一化。 + 最近约 2 分钟 · 0%(空闲)→ 100%(全部核心繁忙) + + + 容器备份 + 导出容器与镜像 + 将 Podman 容器导出或保存镜像为 tar 归档。在虚拟机内的终端中运行复制的命令。 + 备份位置 + 虚拟机路径 + 手机(下载目录) + Download/Podroid/backups + 在设置中启用下载目录共享,备份文件会出现在手机上。 + 下载目录共享仅在 QEMU 后端可用。备份保留在虚拟机 /var/backups/podroid。 + 导出容器 + 运行备份命令前请先启动虚拟机。 + 容器名称 + 复制导出命令 + 保存镜像 + 镜像引用 + docker.io/library/nginx:latest + 复制保存命令 + 工具 + 复制列表命令 + 复制全部备份命令 + 粘贴到 Podroid 终端。较新的客户机镜像包含 podroid-backup;旧镜像会自动回退到 podman export。 + 手机上的备份 + 刷新列表 + Downloads/Podroid/backups 中尚未找到备份归档。 + 命令已复制 + + + 已创建容器 + %1$d + + 备份 + 状态 + 终端 + 运行中 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f38fd7d..a1130b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -53,6 +53,11 @@ Network SSH access ssh root@<phone-ip> -p 9922 (password: podroid) + Load balance + Automatically tune CPU, RAM, storage, and bandwidth for this device. + Storage size is set automatically. Turn off load balance on the next step to pick manually. + Bandwidth limit + Unlimited Downloads Sharing @@ -116,6 +121,63 @@ None active Off + On + + + Status + Phone + Virtual machine + Load average (1 / 5 / 15 min) + VM RAM (allocated) + Emulator using %1$s + VM disk image + + Error + Refreshes every 2 seconds while this screen is open. + VM CPU load + Current: %1$d%% + Collecting samples… + Start the VM to see load + VM is not running + Load graph is available on the QEMU backend while the VM is running + Tracks the emulator process CPU use on your phone, normalized to the VM\'s allocated cores. + Last ~2 min · 0% (idle) → 100% (all VM cores busy) + + + Container backup + Export containers & images + Export Podman containers or save images to tar archives. Run the copied commands in the terminal inside the VM. + Backup location + Guest path + On phone (Downloads) + Download/Podroid/backups + Enable Downloads sharing in Settings so backups appear on your phone. + Downloads sharing is only available on the QEMU backend. Backups stay inside the VM at /var/backups/podroid. + Export container + Start the VM before running backup commands. + Container name + Copy export command + Save image + Image reference + docker.io/library/nginx:latest + Copy save command + Tools + Copy list command + Copy backup-all command + Paste into the Podroid terminal. podroid-backup is included in newer guest images; older images fall back to podman export automatically. + Backups on phone + Refresh list + No backup archives found in Downloads/Podroid/backups yet. + Command copied + + + Containers created + %1$d + + Backup + Status + Terminal + Up diff --git a/app/src/test/java/com/excp/podroid/util/DeviceResourcePolicyTest.kt b/app/src/test/java/com/excp/podroid/util/DeviceResourcePolicyTest.kt new file mode 100644 index 0000000..caa1197 --- /dev/null +++ b/app/src/test/java/com/excp/podroid/util/DeviceResourcePolicyTest.kt @@ -0,0 +1,36 @@ +package com.excp.podroid.util + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DeviceResourcePolicyTest { + + @Test + fun balancedRamMb_scalesWithDevice() { + assertEquals(512, DeviceResourcePolicy.balancedRamMb(2_048)) + assertEquals(1024, DeviceResourcePolicy.balancedRamMb(4_096)) + assertEquals(2048, DeviceResourcePolicy.balancedRamMb(8_192)) + assertEquals(4096, DeviceResourcePolicy.balancedRamMb(16_384)) + } + + @Test + fun balancedCpus_usesHalfCappedAtFour() { + assertEquals(1, DeviceResourcePolicy.balancedCpus(1)) + assertEquals(2, DeviceResourcePolicy.balancedCpus(4)) + assertEquals(4, DeviceResourcePolicy.balancedCpus(8)) + assertEquals(4, DeviceResourcePolicy.balancedCpus(12)) + } + + @Test + fun balancedStorageGb_respectsAvailableSpace() { + assertEquals(2, DeviceResourcePolicy.balancedStorageGb(4)) + assertEquals(8, DeviceResourcePolicy.balancedStorageGb(40)) + assertEquals(64, DeviceResourcePolicy.balancedStorageGb(512)) + } + + @Test + fun nearestAtMost_picksLargestOptionNotExceedingTarget() { + assertEquals(4, DeviceResourcePolicy.nearestAtMost(listOf(2, 4, 8), 5)) + assertEquals(2, DeviceResourcePolicy.nearestAtMost(listOf(2, 4, 8), 1)) + } +} diff --git a/app/src/test/java/com/excp/podroid/util/HostMetricsTest.kt b/app/src/test/java/com/excp/podroid/util/HostMetricsTest.kt new file mode 100644 index 0000000..8dd6252 --- /dev/null +++ b/app/src/test/java/com/excp/podroid/util/HostMetricsTest.kt @@ -0,0 +1,26 @@ +package com.excp.podroid.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HostMetricsTest { + + @Test + fun percent_clampsAndHandlesZeroTotal() { + assertEquals(0f, HostMetrics.percent(100, 0)) + assertEquals(0.5f, HostMetrics.percent(512, 1024)) + assertEquals(1f, HostMetrics.percent(2000, 1000)) + } + + @Test + fun formatGb_formatsSmallValuesWithDecimal() { + assertEquals("1.5 GB", HostMetrics.formatGb(1.5)) + assertEquals("12 GB", HostMetrics.formatGb(12.4)) + } + + @Test + fun processVmRssMb_returnsNullForMissingPid() { + assertNull(HostMetrics.processVmRssMb(-1)) + } +} diff --git a/app/src/test/java/com/excp/podroid/util/ShellQuoteTest.kt b/app/src/test/java/com/excp/podroid/util/ShellQuoteTest.kt new file mode 100644 index 0000000..ac3f79b --- /dev/null +++ b/app/src/test/java/com/excp/podroid/util/ShellQuoteTest.kt @@ -0,0 +1,31 @@ +/* + * Podroid - Rootless Podman for Android + * Copyright (C) 2024-2026 Podroid contributors + */ +package com.excp.podroid.util + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ShellQuoteTest { + + @Test + fun `quotes strings with spaces`() { + assertEquals("'my nginx'", ShellQuote.quote("my nginx")) + } + + @Test + fun `leaves simple names unquoted`() { + assertEquals("my-nginx", ShellQuote.quote("my-nginx")) + } + + @Test + fun `escapes embedded single quotes`() { + assertEquals("'it'\\''s'", ShellQuote.quote("it's")) + } + + @Test + fun `empty string becomes empty single quotes`() { + assertEquals("''", ShellQuote.quote("")) + } +} diff --git a/app/src/test/java/com/excp/podroid/util/VmLoadSamplerTest.kt b/app/src/test/java/com/excp/podroid/util/VmLoadSamplerTest.kt new file mode 100644 index 0000000..c919634 --- /dev/null +++ b/app/src/test/java/com/excp/podroid/util/VmLoadSamplerTest.kt @@ -0,0 +1,33 @@ +package com.excp.podroid.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class VmLoadSamplerTest { + + @Test + fun sampleCpuPercent_returnsNullOnFirstSample() { + val sampler = VmLoadSampler() + assertNull(sampler.sampleCpuPercent(pid = 1, vmCpus = 2)) + } + + @Test + fun sampleCpuPercent_computesNormalizedLoad() { + val sampler = VmLoadSampler(clockHz = 100L) + // Seed first sample + sampler.sampleCpuPercent(pid = 1, vmCpus = 2) + // Manually set state by second call with same pid - won't work without real /proc + + // Test math via readProcessCpuTicks on invalid pid + assertNull(VmLoadSampler.readProcessCpuTicks(-1)) + } + + @Test + fun reset_clearsWarmup() { + val sampler = VmLoadSampler() + sampler.sampleCpuPercent(1, 2) + sampler.reset() + assertNull(sampler.sampleCpuPercent(1, 2)) + } +} diff --git a/build-rootfs/build-rootfs.sh b/build-rootfs/build-rootfs.sh index 83cd7d6..15246c9 100755 --- a/build-rootfs/build-rootfs.sh +++ b/build-rootfs/build-rootfs.sh @@ -112,6 +112,8 @@ mkdir -p "$ROOTFS/usr/local/bin" cp /work/files/usr/local/bin/podroid-resize "$ROOTFS/usr/local/bin/" cp /work/files/usr/local/bin/podroid-login "$ROOTFS/usr/local/bin/" cp /work/files/usr/local/bin/podroid-getty "$ROOTFS/usr/local/bin/" +cp /work/files/usr/local/bin/podroid-backup "$ROOTFS/usr/local/bin/" +cp /work/files/usr/local/bin/podroid-update-stats "$ROOTFS/usr/local/bin/" # podroid-vsock-agent is COPY'd in from the vsock-builder Docker stage. Make # sure it's executable (cross-arch COPY can lose the mode bit on some buildkit # versions). diff --git a/build-rootfs/files/etc/init.d/podroid-network b/build-rootfs/files/etc/init.d/podroid-network index 3d4ea74..b4ed01d 100644 --- a/build-rootfs/files/etc/init.d/podroid-network +++ b/build-rootfs/files/etc/init.d/podroid-network @@ -62,6 +62,10 @@ start() { return 1 fi fi + BW_MBIT=$(sed -n 's/.*podroid\.bandwidth=\([0-9]*\).*/\1/p' /proc/cmdline 2>/dev/null) + if [ -n "$BW_MBIT" ] && [ "$BW_MBIT" -gt 0 ]; then + tc qdisc replace dev "$NETIF" root tbf rate "${BW_MBIT}mbit" burst 32kbit latency 400ms 2>/dev/null || true + fi echo "Network found" > /dev/console eend 0 } diff --git a/build-rootfs/files/etc/init.d/podroid-ready b/build-rootfs/files/etc/init.d/podroid-ready index a159c32..e0ac74e 100644 --- a/build-rootfs/files/etc/init.d/podroid-ready +++ b/build-rootfs/files/etc/init.d/podroid-ready @@ -21,6 +21,9 @@ start() { done # Emit the boot stages PodroidQemu.detectBootStage() expects. + if [ -x /usr/local/bin/podroid-update-stats ]; then + /usr/local/bin/podroid-update-stats || true + fi echo "Starting SSH..." > /dev/console echo "Almost ready..." > /dev/console echo "Ready!" > /dev/console diff --git a/build-rootfs/files/usr/local/bin/podroid-backup b/build-rootfs/files/usr/local/bin/podroid-backup new file mode 100644 index 0000000..c951380 --- /dev/null +++ b/build-rootfs/files/usr/local/bin/podroid-backup @@ -0,0 +1,144 @@ +#!/bin/sh +# Podroid container backup — export containers/images to tar archives. +# With Downloads sharing: /mnt/downloads/Podroid/backups/ +# Otherwise: /var/backups/podroid/ +set -eu + +backup_root() { + if mountpoint -q /mnt/downloads 2>/dev/null; then + printf '%s\n' "/mnt/downloads/Podroid/backups" + else + printf '%s\n' "/var/backups/podroid" + fi +} + +notify_ok() { + body="$1" + if command -v podroid-notify >/dev/null 2>&1; then + podroid-notify --title "Podroid backup" "$body" 2>/dev/null || true + fi +} + +notify_err() { + body="$1" + if command -v podroid-notify >/dev/null 2>&1; then + podroid-notify --priority high --title "Podroid backup failed" "$body" 2>/dev/null || true + fi +} + +refresh_stats() { + if [ -x /usr/local/bin/podroid-update-stats ]; then + /usr/local/bin/podroid-update-stats || true + fi +} + +usage() { + cat <<'EOF' +usage: + podroid-backup list + podroid-backup export [output.tar] + podroid-backup save [output.tar] + podroid-backup all + +Examples: + podroid-backup list + podroid-backup export my-nginx + podroid-backup save docker.io/library/nginx:latest + podroid-backup all +EOF +} + +cmd_list() { + if ! command -v podman >/dev/null 2>&1; then + echo "podman not found" >&2 + return 1 + fi + podman ps -a --format 'table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}' +} + +safe_name() { + printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_' +} + +cmd_export() { + name="$1" + out="${2:-}" + root="$(backup_root)" + mkdir -p "$root" + if [ -z "$out" ]; then + stamp="$(date +%Y%m%d-%H%M%S)" + out="$root/$(safe_name "$name")-$stamp.tar" + fi + if ! podman export "$name" -o "$out"; then + notify_err "export failed for $name" + return 1 + fi + echo "exported $name -> $out" + notify_ok "Exported container $name" + refresh_stats +} + +cmd_save() { + ref="$1" + out="${2:-}" + root="$(backup_root)" + mkdir -p "$root" + if [ -z "$out" ]; then + stamp="$(date +%Y%m%d-%H%M%S)" + out="$root/$(safe_name "$ref")-$stamp.tar" + fi + if ! podman save "$ref" -o "$out"; then + notify_err "save failed for $ref" + return 1 + fi + echo "saved $ref -> $out" + notify_ok "Saved image $ref" + refresh_stats +} + +cmd_all() { + root="$(backup_root)" + mkdir -p "$root" + ok=0 + fail=0 + for id in $(podman ps -aq 2>/dev/null); do + name="$(podman inspect -f '{{.Name}}' "$id" 2>/dev/null | sed 's#^/##')" + [ -n "$name" ] || name="$id" + stamp="$(date +%Y%m%d-%H%M%S)" + out="$root/$(safe_name "$name")-$stamp.tar" + if podman export "$name" -o "$out" 2>/dev/null; then + ok=$((ok + 1)) + echo "exported $name -> $out" + else + fail=$((fail + 1)) + echo "failed $name" >&2 + fi + done + notify_ok "Backed up $ok container(s)${fail:+, $fail failed}" + echo "done: $ok ok, $fail failed" + refresh_stats +} + +main() { + if ! command -v podman >/dev/null 2>&1; then + echo "podman not installed" >&2 + return 1 + fi + + case "${1:-}" in + list) cmd_list ;; + export) + [ $# -ge 2 ] || { usage; return 2; } + cmd_export "$2" "${3:-}" + ;; + save) + [ $# -ge 2 ] || { usage; return 2; } + cmd_save "$2" "${3:-}" + ;; + all) cmd_all ;; + -h|--help|help|"") usage ;; + *) echo "unknown command: $1" >&2; usage; return 2 ;; + esac +} + +main "$@" diff --git a/build-rootfs/files/usr/local/bin/podroid-update-stats b/build-rootfs/files/usr/local/bin/podroid-update-stats new file mode 100644 index 0000000..1dd71f5 --- /dev/null +++ b/build-rootfs/files/usr/local/bin/podroid-update-stats @@ -0,0 +1,16 @@ +#!/bin/sh +# Write Podman container count for the Android app (Downloads/Podroid/container-count). +set -eu + +count=0 +if command -v podman >/dev/null 2>&1; then + count="$(podman ps -aq 2>/dev/null | wc -l | tr -d ' ')" +fi + +mkdir -p /var/lib/podroid +printf '%s\n' "$count" > /var/lib/podroid/container-count + +if mountpoint -q /mnt/downloads 2>/dev/null; then + mkdir -p /mnt/downloads/Podroid + printf '%s\n' "$count" > /mnt/downloads/Podroid/container-count +fi