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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ContainerBackupFile> {
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"
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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<String> = 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<Boolean> = 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 }
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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()
}
2 changes: 2 additions & 0 deletions app/src/main/java/com/excp/podroid/engine/EngineHolder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/com/excp/podroid/engine/QemuEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/com/excp/podroid/engine/VmEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -101,4 +107,5 @@ data class VmConfig(
val verboseLogging: Boolean = false,
val x11Dpi: Int = 96,
val usbPassthroughEnabled: Boolean = false,
val bandwidthMbps: Int = 0,
)
1 change: 1 addition & 0 deletions app/src/main/java/com/excp/podroid/engine/avf/AvfEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
110 changes: 110 additions & 0 deletions app/src/main/java/com/excp/podroid/ui/components/VmLoadGraph.kt
Original file line number Diff line number Diff line change
@@ -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<Float>,
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),
)
}
}
Loading