feat: storage page to see and free DroidDeck's storage (#913)

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-15 16:45:37 -04:00
1 parent d301148881
commit ef16de6280
14 files changed
+1915 -7

No files matched your search

@@ -109,6 +109,7 @@ class MainActivity : ComponentActivity() {
private var lossless by mutableStateOf(Lossless.State.NONE)
private var showRemove by mutableStateOf(false)
private var showResetSettings by mutableStateOf(false)
private var lowSpace by mutableStateOf<Long?>(null)
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
@@ -583,7 +584,12 @@ class MainActivity : ComponentActivity() {
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
),
FrontEndActions(
onPlay = { startSteamSession() },
onPlay = { playSteam() },
onSteamUrl = { url ->
if (!SessionState.running) startSession(
Intent(this, SessionActivity::class.java).putExtra(SessionService.EXTRA_STEAM_URL, url), steamSession = true,
)
},
onPlayDesktopUi = {
startSession(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_STEAM)
@@ -850,6 +856,14 @@ class MainActivity : ComponentActivity() {
onConfirm = { showRemove = false; removeRuntime() },
onDismiss = { showRemove = false },
)
lowSpace?.let { free ->
com.droiddeck.launcher.ui.LowSpaceDialog(
free,
onFreeUp = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.requestOpen() },
onPlay = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.lowSpaceDismissed = true; startSteamSession() },
onDismiss = { lowSpace = null },
)
}
if (showResetSettings) com.droiddeck.launcher.ui.ResetSettingsDialog(
onConfirm = { gameProfiles -> resetSettings(gameProfiles) },
onDismiss = { showResetSettings = false },
@@ -902,6 +916,14 @@ class MainActivity : ComponentActivity() {
if (SessionPrefs.runSteamAtStartup(this) && !isFinishing && !isDestroyed) startSteamSession()
}
private fun playSteam() {
val storage = com.droiddeck.launcher.storage.StorageState
if (!SessionState.running && !storage.lowSpaceDismissed && storage.lowOnSpace(this)) {
lowSpace = storage.freeBytes(this)
storage.refresh(this)
} else startSteamSession()
}
private fun startSteamSession() {
startSession(Intent(this, SessionActivity::class.java), steamSession = true)
}
@@ -1613,6 +1635,10 @@ class MainActivity : ComponentActivity() {
private fun startSession(intent: Intent, steamSession: Boolean = false): Boolean {
if (busy || LinuxRuntimeInstaller.isBusy()) return false
if (com.droiddeck.launcher.storage.StorageState.cleaning) {
android.widget.Toast.makeText(this, R.string.storage_cleanup_busy, android.widget.Toast.LENGTH_SHORT).show()
return false
}
refreshPhantomStatus()
if (steamSession && PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
showPhantomGate = true
@@ -69,7 +69,7 @@ object Library {
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
private fun steamLibraries(context: Context): List<Pair<File, String>> {
internal fun steamLibraries(context: Context): List<Pair<File, String>> {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
return listOfNotNull(
root to "internal",
@@ -176,6 +176,7 @@ object FlatpakManager {
return id to problem
}
fun update(context: Context, id: String?, onProgress: (String, Int) -> Unit) = transaction(context, "update", id, onProgress)
fun prune(context: Context, onProgress: (String, Int) -> Unit) = transaction(context, "prune", null, onProgress)
fun updates(context: Context): Set<String>? {
if (!ready(context)) return emptySet()
@@ -711,9 +711,71 @@ object ComponentsManager {
saveState(context, state)
}
class StoredPackage(val pkg: Package, val inUse: Boolean)
class StoredOriginal(val protonId: String, val protonName: String?, val comp: String, val protonVersion: String, val size: Long, val kept: Boolean)
class Stored(val packages: List<StoredPackage>, val originals: List<StoredOriginal>)
private fun packagesInUse(state: JSONObject): Set<String> {
val names = HashSet<String>()
for (key in listOf("active", "queued")) {
val byProton = state.optJSONObject(key) ?: continue
for (pid in byProton.keys()) {
val comps = byProton.optJSONObject(pid) ?: continue
for (comp in comps.keys()) comps.optJSONObject(comp)?.optString("file")?.takeIf { it.isNotEmpty() }?.let(names::add)
}
}
val profiles = state.optJSONObject("profiles")
for (scope in profiles?.keys() ?: emptyList<String>().iterator()) {
val profile = profiles?.optJSONObject(scope) ?: continue
COMPONENTS.forEach { comp -> profile.optString(comp).takeIf { it.isNotEmpty() }?.let(names::add) }
}
return names
}
private fun originalKept(state: JSONObject, installed: Map<String, Proton>, protonId: String, comp: String, version: String): Boolean {
val current = installed[protonId]?.let { safeName(it.version) == version } == true
val queued = state.optJSONObject("queued")?.optJSONObject(protonId)?.optJSONObject(comp)
?.takeIf { it.optString("kind") == "original" }?.let { safeName(it.optString("protonVersion")) == version } == true
return current || queued
}
fun stored(context: Context): Stored = synchronized(lock) {
val state = loadState(context)
val used = packagesInUse(state)
val installed = protons(context).associateBy { it.id }
val originals = originalsDir(context).listFiles()?.filter { it.isDirectory }?.flatMap { proton ->
proton.listFiles()?.filter { it.isDirectory }?.flatMap { version ->
COMPONENTS.mapNotNull { comp ->
val f = File(version, "$comp.wcp")
if (!f.isFile) null else StoredOriginal(
proton.name, installed[proton.name]?.name, comp, version.name, f.length(),
originalKept(state, installed, proton.name, comp, version.name),
)
}
}.orEmpty()
}.orEmpty()
Stored(packages(context).map { StoredPackage(it, it.file in used) }, originals)
}
fun deleteUnusedOriginal(context: Context, protonId: String, comp: String, protonVersion: String): Boolean = synchronized(lock) {
val state = loadState(context)
if (originalKept(state, protons(context).associateBy { it.id }, protonId, comp, protonVersion)) return false
if (comp !in COMPONENTS || listOf(protonId, protonVersion).any { it.isEmpty() || '/' in it || it == "." || it == ".." }) return false
val f = File(originalsDir(context), "$protonId/$protonVersion/$comp.wcp")
if (!f.isFile || !f.delete()) return false
f.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
f.parentFile?.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
true
}
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
val name = safeName(file)
val state = loadState(context)
val queued = state.sub("queued")
for (pid in queued.keys()) {
val comps = queued.optJSONObject(pid) ?: continue
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
}
val active = state.sub("active")
for (pid in active.keys()) {
val comps = active.optJSONObject(pid) ?: continue
@@ -0,0 +1,123 @@
package com.droiddeck.launcher.storage
import java.io.File
object SteamStorage {
const val SCOUT = 1070560
const val SOLDIER = 1391110
const val SNIPER = 1628350
const val ARM_RUNTIME = 4185400
const val PROTON_ARM = 4427310
const val PROTON_11_ARM = 4628740
const val REDISTRIBUTABLES = 228980
const val FEX = 3127680
const val ARM_TOOL = "droiddeck-proton-arm64"
const val ARM_11_TOOL = "droiddeck-proton-11-arm64"
private val VALVE_TOOLS = setOf(
858280, 961940, 1054830, 1113280, 1245040, 1420170, 1493710, 1580130, 1887720, 2180100, 2348590,
2805730, 3029110, 3658110, 4183110, 4628710, 4690330,
SCOUT, SOLDIER, SNIPER, ARM_RUNTIME, PROTON_ARM, PROTON_11_ARM, REDISTRIBUTABLES, FEX,
)
private val APP_ID = Regex("^\\s*\"appid\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val MAPPING = Regex("\"(\\d+)\"\\s*\\{\\s*\"name\"\\s*\"([^\"]*)\"")
private val COMPAT_NAME = Regex("\"compat_tools\"\\s*\\{\\s*\"([^\"]+)\"")
data class Manifest(
val file: File, val appId: Int, val name: String, val installDir: String?,
val installed: Boolean, val lastPlayed: Long,
)
fun parseManifest(file: File, text: String): Manifest? {
val id = APP_ID.find(text)?.groupValues?.get(1)?.toIntOrNull()
?: file.name.removePrefix("appmanifest_").removeSuffix(".acf").toIntOrNull() ?: return null
val dir = INSTALL_DIR.find(text)?.groupValues?.get(1)?.trim()
?.takeIf { it.isNotEmpty() && it != "." && it != ".." && '/' !in it && '\\' !in it }
return Manifest(
file, id, NAME.find(text)?.groupValues?.get(1)?.trim().orEmpty(), dir,
(STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0) and 4 != 0,
LAST_PLAYED.find(text)?.groupValues?.get(1)?.toLongOrNull() ?: 0L,
)
}
class Listing(val manifests: List<Manifest>, val otherIds: Set<Int>, val complete: Boolean) {
val ids: Set<Int> get() = manifests.map { it.appId }.toSet() + otherIds
}
private val PENDING_MANIFEST = Regex("appmanifest_(\\d+)\\.acf\\..+")
// A partial download has no manifest yet, only a temporary one and its staging folder.
fun manifests(library: File): Listing? {
val apps = File(library, "steamapps")
val names = apps.list() ?: return null
var complete = true
val other = HashSet<Int>()
val read = ArrayList<Manifest>()
for (name in names.sorted()) {
val pending = PENDING_MANIFEST.matchEntire(name)
if (pending != null) {
pending.groupValues[1].toIntOrNull()?.let(other::add)
continue
}
if (!name.startsWith("appmanifest_") || !name.endsWith(".acf")) continue
val f = File(apps, name)
if (!f.isFile) continue
val named = name.removePrefix("appmanifest_").removeSuffix(".acf").toIntOrNull()
val text = runCatching { f.readText() }.getOrNull()
if (text == null) {
if (named != null) other.add(named) else complete = false
continue
}
parseManifest(f, text)?.let(read::add)
}
File(apps, "downloading").list()?.forEach { it.toIntOrNull()?.let(other::add) }
return Listing(read, other, complete)
}
fun isTool(m: Manifest): Boolean = m.appId in VALVE_TOOLS ||
m.installDir?.let { it.startsWith("Proton ") || it.startsWith("SteamLinuxRuntime") } == true
enum class Kind { ARM_PROTON, REQUIRED, OLD_RUNTIME, X86_PROTON }
// DroidDeck's ARM64 Steam client cannot run Valve's x86 Protons, and its own Proton and FEX tools
// replace the scout and soldier container runtimes.
fun kind(m: Manifest): Kind {
val dir = m.installDir.orEmpty()
return when {
m.appId == SCOUT || m.appId == SOLDIER || dir == "SteamLinuxRuntime" || dir == "SteamLinuxRuntime_soldier" -> Kind.OLD_RUNTIME
dir.startsWith("Proton") && "ARM64" in dir -> Kind.ARM_PROTON
m.appId == PROTON_ARM || m.appId == PROTON_11_ARM -> Kind.ARM_PROTON
dir.startsWith("Proton ") -> Kind.X86_PROTON
else -> Kind.REQUIRED
}
}
fun compatMappings(configVdf: String): Map<String, String> {
val start = configVdf.indexOf("\"CompatToolMapping\"")
if (start < 0) return emptyMap()
val open = configVdf.indexOf('{', start)
if (open < 0) return emptyMap()
var depth = 0
var end = configVdf.length
for (i in open until configVdf.length) {
when (configVdf[i]) {
'{' -> depth++
'}' -> if (--depth == 0) { end = i; break }
}
}
return MAPPING.findAll(configVdf.substring(open, end)).associate { it.groupValues[1] to it.groupValues[2] }
}
fun compatToolName(compatibilityToolVdf: String): String? = COMPAT_NAME.find(compatibilityToolVdf)?.groupValues?.get(1)
fun leftoverId(name: String): Int? = name.toLongOrNull()?.takeIf { it in 1 until (1L shl 31) }?.toInt()
fun isStubPrefix(prefix: File): Boolean = prefix.walkTopDown().onEnter { !java.nio.file.Files.isSymbolicLink(it.toPath()) }
.none { it.isFile && it.name != "steam_autocloud.vdf" && !java.nio.file.Files.isSymbolicLink(it.toPath()) }
}
@@ -0,0 +1,264 @@
package com.droiddeck.launcher.storage
import android.content.Context
import android.os.StatFs
import android.system.Os
import android.system.OsConstants
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.runtime.LinuxRuntime
import android.util.Log
import com.droiddeck.launcher.runtime.FlatpakManager
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.SecondaryLibrary
import com.droiddeck.launcher.session.SessionPaths
import java.io.File
enum class Bucket { GAMES, GAME_DATA, TOOLS, COMPONENTS, STORES, DESKTOP, SYSTEM, DROIDDECK }
class GameUsage(
val appId: Int, val name: String, val art: File?, val library: String, val internal: Boolean, val lastPlayed: Long,
val game: Long, val prefix: Long, val shaders: Long, val workshop: Long, val shaderDir: File?,
) {
val data: Long get() = prefix + shaders + workshop
val total: Long get() = game + data
}
enum class ToolState { IN_USE, REQUIRED, UNUSED }
enum class ToolReason { DEFAULT_PROTON, PROTON, FEX_RUNTIME, ARM_RUNTIME, REDISTRIBUTABLES, FEX, STEAM_TOOL, OLD_RUNTIME, X86_PROTON, CUSTOM, CUSTOM_UNKNOWN }
class ToolUsage(
val key: String, val name: String, val bytes: Long, val state: ToolState, val reason: ToolReason,
val games: Int, val isDefault: Boolean, val manifests: List<File>, val dirs: List<File>,
)
class Leftover(val appId: Int, val prefix: File?, val shaders: File?, val bytes: Long, val safe: Boolean)
class ComponentUsage(
val comp: String, val version: String, val bytes: Long, val inUse: Boolean,
val packageFile: String? = null, val protonId: String? = null, val protonName: String? = null,
)
class FlatpakUsage(val id: String, val name: String, val bytes: Long)
class StorageReport(
val at: Long,
val total: Long,
val free: Long,
val buckets: Map<Bucket, Long>,
val prefixes: Long,
val shaders: Long,
val workshop: Long,
val flatpak: Long,
val games: List<GameUsage>,
val tools: List<ToolUsage>,
val leftovers: List<Leftover>,
val logs: List<File>,
val logBytes: Long,
val components: List<ComponentUsage>,
val flatpakApps: List<FlatpakUsage>,
val flatpakRuntimes: Long,
val otherPrefixes: Long,
val leftoversChecked: Boolean,
) {
val droiddeck: Long get() = buckets.values.sum()
val installedShaders: Long get() = games.sumOf { it.shaders }
val unusedTools: List<ToolUsage> get() = tools.filter { it.state == ToolState.UNUSED }
val gamesOnSd: Int get() = games.count { !it.internal }
val unusedComponents: List<ComponentUsage> get() = components.filter { !it.inUse }
val flatpakCache: Long get() = (flatpak - flatpakApps.sumOf { it.bytes } - flatpakRuntimes).coerceAtLeast(0)
}
object StorageScan {
private class Walker {
private val seen = HashSet<Long>()
val known = HashMap<String, Long>()
fun size(file: File): Long {
known[file.path]?.let { return it }
val st = try { Os.lstat(file.path) } catch (e: Exception) { return 0 }
if (st.st_nlink > 1 && !OsConstants.S_ISDIR(st.st_mode)) {
if (!seen.add(st.st_ino xor (st.st_dev shl 40))) return 0
}
var total = st.st_blocks * 512
if (OsConstants.S_ISDIR(st.st_mode)) file.list()?.forEach { total += size(File(file, it)) }
return total
}
fun measure(file: File): Long = size(file).also { known[file.path] = it }
}
fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
fun scan(context: Context): StorageReport {
val walker = Walker()
val files = context.filesDir
val rootfs = LinuxRuntime.rootDir(context)
val steamRoot = steamRoot(context)
val libraries = Library.steamLibraries(context)
val primary = runCatching { steamRoot.canonicalPath }.getOrDefault(steamRoot.path)
val art = runCatching { Library.steamGames(context) }.getOrDefault(emptyList()).associateBy { it.appId }
class Lib(val root: File, val label: String, val internal: Boolean, val data: File, val listing: SteamStorage.Listing?) {
val manifests: List<SteamStorage.Manifest>? get() = listing?.manifests
}
val libs = libraries.map { (root, label) ->
val internal = runCatching { root.canonicalPath }.getOrDefault(root.path) == primary
val data = if (internal) root else SecondaryLibrary.privateRoot(files, root)
Lib(root, label, internal, data, SteamStorage.manifests(root))
}
val everyLibraryRead = libs.all { it.listing?.complete == true }
val everyId = libs.flatMap { it.listing?.ids.orEmpty() }.toSet()
val mappings = runCatching { SteamStorage.compatMappings(File(steamRoot, "config/config.vdf").readText()) }.getOrNull()
val installedGames = libs.flatMap { it.manifests.orEmpty() }.filter { !SteamStorage.isTool(it) && it.installed }.map { it.appId.toString() }.toSet()
fun gamesOn(tool: String) = mappings.orEmpty().count { (app, name) -> name == tool && app in installedGames }
val defaultTool = mappings?.get("0")
val tools = LinkedHashMap<String, ToolUsage>()
val games = ArrayList<GameUsage>()
for (lib in libs) {
for (m in lib.manifests.orEmpty()) {
val dir = m.installDir
if (SteamStorage.isTool(m)) {
val dirs = listOfNotNull(
dir?.let { File(lib.root, "steamapps/common/$it") },
dir?.let { File(lib.data, "steamapps/common/$it") }.takeIf { !lib.internal },
).filter { it.exists() }
val bytes = dirs.sumOf { walker.measure(it) }
val kind = SteamStorage.kind(m)
val (state, reason) = when (kind) {
SteamStorage.Kind.OLD_RUNTIME -> ToolState.UNUSED to ToolReason.OLD_RUNTIME
SteamStorage.Kind.X86_PROTON -> ToolState.UNUSED to ToolReason.X86_PROTON
SteamStorage.Kind.ARM_PROTON -> ToolState.IN_USE to (if (m.appId == SteamStorage.PROTON_ARM) ToolReason.DEFAULT_PROTON else ToolReason.PROTON)
SteamStorage.Kind.REQUIRED -> ToolState.REQUIRED to when (m.appId) {
SteamStorage.SNIPER -> ToolReason.FEX_RUNTIME
SteamStorage.ARM_RUNTIME -> ToolReason.ARM_RUNTIME
SteamStorage.REDISTRIBUTABLES -> ToolReason.REDISTRIBUTABLES
SteamStorage.FEX -> ToolReason.FEX
else -> ToolReason.STEAM_TOOL
}
}
val count = when (m.appId) {
SteamStorage.PROTON_ARM -> gamesOn(SteamStorage.ARM_TOOL)
SteamStorage.PROTON_11_ARM -> gamesOn(SteamStorage.ARM_11_TOOL)
else -> 0
}
val key = "app:${m.appId}"
val prior = tools[key]
tools[key] = ToolUsage(
key, m.name.ifEmpty { dir ?: m.appId.toString() }, bytes + (prior?.bytes ?: 0), state, reason, count,
false, (prior?.manifests.orEmpty()) + m.file, (prior?.dirs.orEmpty()) + dirs,
)
continue
}
if (!m.installed) continue
val gameBytes = dir?.let { File(lib.root, "steamapps/common/$it") }?.takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
val prefix = File(lib.data, "steamapps/compatdata/${m.appId}").takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
val shaderDir = File(lib.data, "steamapps/shadercache/${m.appId}").takeIf { it.isDirectory }
val shaders = shaderDir?.let { walker.measure(it) } ?: 0
val workshop = File(lib.root, "steamapps/workshop/content/${m.appId}").takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
val known = art[m.appId]
games.add(GameUsage(
m.appId, known?.name ?: m.name, known?.art, lib.label, lib.internal, m.lastPlayed,
gameBytes, prefix, shaders, workshop, shaderDir,
))
}
}
File(steamRoot, "compatibilitytools.d").listFiles()?.sortedBy { it.name }?.forEach { dir ->
if (!dir.isDirectory) return@forEach
val bytes = walker.measure(dir)
if (dir.name.startsWith(".") || dir.name.startsWith("droiddeck-") || dir.name.startsWith("bannerlator-") ||
!File(dir, "toolmanifest.vdf").isFile) return@forEach
val name = runCatching { SteamStorage.compatToolName(File(dir, "compatibilitytool.vdf").readText()) }.getOrNull() ?: dir.name
val count = gamesOn(name)
val isDefault = defaultTool == name
tools["dir:${dir.name}"] = ToolUsage(
"dir:${dir.name}", dir.name, bytes,
when {
mappings == null -> ToolState.REQUIRED
count > 0 || isDefault -> ToolState.IN_USE
else -> ToolState.UNUSED
},
if (mappings == null) ToolReason.CUSTOM_UNKNOWN else ToolReason.CUSTOM, count, isDefault, emptyList(), listOf(dir),
)
}
val leftovers = ArrayList<Leftover>()
for (lib in libs) {
if (!everyLibraryRead) break
val prefixes = File(lib.data, "steamapps/compatdata").listFiles().orEmpty()
.mapNotNull { f -> SteamStorage.leftoverId(f.name)?.takeIf { it !in everyId && f.isDirectory }?.let { it to f } }.toMap()
val caches = File(lib.data, "steamapps/shadercache").listFiles().orEmpty()
.mapNotNull { f -> SteamStorage.leftoverId(f.name)?.takeIf { it !in everyId && f.isDirectory }?.let { it to f } }.toMap()
for (id in (prefixes.keys + caches.keys).sorted()) {
val prefix = prefixes[id]
val cache = caches[id]
val bytes = (prefix?.let { walker.measure(it) } ?: 0) + (cache?.let { walker.measure(it) } ?: 0)
val safe = prefix == null || SteamStorage.isStubPrefix(prefix)
leftovers.add(Leftover(id, prefix, cache, bytes, safe))
}
}
val logs = SessionPaths.sessionFolders(context).filter { it != SessionPaths.current() } +
listOf(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR)).filter { it.exists() }
val logBytes = logs.sumOf { walker.measure(it) }
val flatpakDir = File(rootfs, "root/.local/share/flatpak")
val flatpakNames = runCatching { FlatpakManager.installedApps(context) }.getOrDefault(emptyList()).associate { it.id to it.name }
val flatpakApps = File(flatpakDir, "app").listFiles().orEmpty().filter { it.isDirectory }.map { dir ->
FlatpakUsage(dir.name, flatpakNames[dir.name] ?: dir.name.substringAfterLast('.'), walker.measure(dir))
}.sortedByDescending { it.bytes }
val flatpakRuntimes = File(flatpakDir, "runtime").takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
val componentsDir = File(files, "components")
val stored = runCatching { ComponentsManager.stored(context) }.onFailure { Log.w("StorageScan", "components", it) }.getOrNull()
val components = stored?.let { st ->
st.packages.map { ComponentUsage(it.pkg.comp, it.pkg.version, it.pkg.size, it.inUse, packageFile = it.pkg.file) } +
st.originals.map { ComponentUsage(it.comp, it.protonVersion, it.size, it.kept, protonId = it.protonId, protonName = it.protonName) }
}.orEmpty()
val otherPrefixes = libs.flatMap { lib ->
File(lib.data, "steamapps/compatdata").listFiles().orEmpty().filter { it.isDirectory && SteamStorage.leftoverId(it.name) == null }
}.sumOf { walker.measure(it) }
val apps = File(steamRoot, "steamapps")
val privateRoots = File(files, "steam-libraries").listFiles().orEmpty().filter { it.isDirectory }
fun m(file: File): Long = if (file.exists()) walker.measure(file) else 0
val toolDirs = tools.values.flatMap { it.dirs }.distinctBy { it.path }
val toolsInApps = toolDirs.filter { it.path.startsWith(apps.path + "/") }.sumOf { walker.known[it.path] ?: 0 }
val toolsPrivate = toolDirs.filter { d -> privateRoots.any { d.path.startsWith(it.path + "/") } }.sumOf { walker.known[it.path] ?: 0 }
val compatTools = m(File(steamRoot, "compatibilitytools.d"))
val installs = m(File(apps, "common")) + m(File(apps, "downloading")) + m(File(apps, "temp"))
val privatePrefixes = privateRoots.sumOf { m(File(it, "steamapps/compatdata")) }
val privateShaders = privateRoots.sumOf { m(File(it, "steamapps/shadercache")) }
val prefixes = m(File(apps, "compatdata")) + privatePrefixes
val shaders = m(File(apps, "shadercache")) + privateShaders
val workshop = m(File(apps, "workshop"))
val stores = m(File(rootfs, "root/Games"))
val flatpak = m(File(rootfs, "root/.local/share/flatpak"))
val rootfsBytes = m(rootfs)
val privateBytes = privateRoots.sumOf { m(it) }
val system = rootfsBytes - installs - m(File(apps, "compatdata")) - m(File(apps, "shadercache")) - workshop -
compatTools - stores - flatpak
val componentBytes = m(componentsDir)
val droiddeck = m(files) + m(context.cacheDir) - rootfsBytes - privateBytes - componentBytes +
(privateBytes - toolsPrivate - privatePrefixes - privateShaders)
val stat = StatFs(files.path)
return StorageReport(
System.currentTimeMillis(), stat.totalBytes, stat.availableBytes,
mapOf(
Bucket.GAMES to (installs - toolsInApps).coerceAtLeast(0),
Bucket.GAME_DATA to (prefixes + shaders + workshop),
Bucket.TOOLS to toolsInApps + toolsPrivate + compatTools,
Bucket.COMPONENTS to componentBytes,
Bucket.STORES to stores,
Bucket.DESKTOP to flatpak,
Bucket.SYSTEM to system.coerceAtLeast(0),
Bucket.DROIDDECK to droiddeck.coerceAtLeast(0),
),
prefixes, shaders, workshop, flatpak,
games.sortedByDescending { it.total }, tools.values.toList(), leftovers, logs, logBytes,
components, flatpakApps, flatpakRuntimes, otherPrefixes, everyLibraryRead,
)
}
}
@@ -0,0 +1,219 @@
package com.droiddeck.launcher.storage
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.os.StatFs
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.files.FileOps
import com.droiddeck.launcher.runtime.FlatpakManager
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.SessionState
enum class CleanItem { OLD_TOOLS, LEFTOVERS, LEFTOVERS_UNSURE, LOGS, UNUSED_CUSTOM, UNUSED_COMPONENTS, SHADERS }
object StorageState {
private const val TAG = "StorageState"
const val LOW_SPACE_BYTES = 10L * 1024 * 1024 * 1024
private const val FRESH_MS = 5L * 60 * 1000
var report by mutableStateOf<StorageReport?>(null)
private set
var scanning by mutableStateOf(false)
private set
var cleaning by mutableStateOf(false)
private set
var lastFreed by mutableStateOf<Long?>(null)
var progress by mutableStateOf<String?>(null)
private set
var lastError by mutableStateOf<String?>(null)
var openRequests by mutableIntStateOf(0)
private set
var openPending = false
@Volatile var lowSpaceDismissed = false
private val main = Handler(Looper.getMainLooper())
@Volatile private var appContext: Context? = null
fun requestOpen() {
openPending = true
openRequests++
}
fun refresh(context: Context, force: Boolean = false) {
val current = report
if (scanning || cleaning) return
if (!force && current != null && System.currentTimeMillis() - current.at < FRESH_MS) return
val app = context.applicationContext
scanning = true
Thread({
val result = runCatching { StorageScan.scan(app) }
.onFailure { Log.w(TAG, "storage scan failed", it) }.getOrNull()
main.post {
if (result != null) report = result
scanning = false
}
}, "storage-scan").start()
}
fun freeBytes(context: Context): Long = runCatching { StatFs(context.filesDir.path).availableBytes }.getOrDefault(Long.MAX_VALUE)
fun lowOnSpace(context: Context): Boolean = freeBytes(context) < LOW_SPACE_BYTES
fun bytes(report: StorageReport, item: CleanItem): Long = when (item) {
CleanItem.OLD_TOOLS -> report.unusedTools.filter { it.reason != ToolReason.CUSTOM }.sumOf { it.bytes }
CleanItem.LEFTOVERS -> report.leftovers.filter { it.safe }.sumOf { it.bytes }
CleanItem.LEFTOVERS_UNSURE -> report.leftovers.filter { !it.safe }.sumOf { it.bytes }
CleanItem.LOGS -> report.logBytes
CleanItem.UNUSED_CUSTOM -> report.unusedTools.filter { it.reason == ToolReason.CUSTOM }.sumOf { it.bytes }
CleanItem.UNUSED_COMPONENTS -> report.unusedComponents.sumOf { it.bytes }
CleanItem.SHADERS -> report.installedShaders
}
val SAFE = setOf(CleanItem.OLD_TOOLS, CleanItem.LEFTOVERS, CleanItem.LOGS)
fun safeBytes(report: StorageReport): Long = SAFE.sumOf { bytes(report, it) }
fun clean(context: Context, items: Set<CleanItem>, onDone: (Long?) -> Unit) {
if (cleaning || SessionState.running) { onDone(null); return }
val app = context.applicationContext
appContext = app
cleaning = true
Thread({
val before = freeBytes(app)
val fresh = runCatching { StorageScan.scan(app) }.onFailure { Log.w(TAG, "storage scan before cleanup failed", it) }.getOrNull()
if (fresh != null && !SessionState.running) {
for (item in items) runCatching { cleanItem(fresh, item) }.onFailure { Log.w(TAG, "could not clean $item", it) }
}
val freed = if (fresh == null) null else (freeBytes(app) - before).coerceAtLeast(0)
Log.i(TAG, "cleaned ${items.joinToString()} and freed $freed bytes")
finish(app, freed, onDone)
}, "storage-clean").start()
}
fun removeTool(context: Context, tool: ToolUsage, onDone: (Long?) -> Unit) {
if (tool.state != ToolState.UNUSED || cleaning || SessionState.running) { onDone(null); return }
val app = context.applicationContext
cleaning = true
Thread({
val before = freeBytes(app)
val current = runCatching { StorageScan.scan(app) }.getOrNull()?.tools?.firstOrNull { it.key == tool.key }
val freed = if (current?.state == ToolState.UNUSED && !SessionState.running) {
runCatching { deleteTool(current) }.onFailure { Log.w(TAG, "could not remove ${tool.key}", it) }
(freeBytes(app) - before).coerceAtLeast(0)
} else {
Log.w(TAG, "kept ${tool.key}: it is in use now or could not be checked")
null
}
finish(app, freed, onDone)
}, "storage-remove-tool").start()
}
// Every action works from a fresh scan so nothing is deleted on the strength of an older one.
private fun act(context: Context, name: String, action: (Context, StorageReport) -> Boolean, onDone: (Long?) -> Unit = {}) {
if (cleaning || SessionState.running) { onDone(null); return }
val app = context.applicationContext
appContext = app
cleaning = true
lastError = null
Thread({
val before = freeBytes(app)
val fresh = runCatching { StorageScan.scan(app) }.getOrNull()
val done = fresh != null && !SessionState.running &&
runCatching { action(app, fresh) }.onFailure { Log.w(TAG, "$name failed", it); main.post { lastError = it.message } }.getOrDefault(false)
val freed = if (done) (freeBytes(app) - before).coerceAtLeast(0) else null
Log.i(TAG, "$name: freed $freed bytes")
main.post { progress = null }
finish(app, freed, onDone)
}, "storage-$name").start()
}
fun clearShaders(context: Context, appId: Int) = act(context, "shaders-$appId", { _, fresh ->
val dir = fresh.games.firstOrNull { it.appId == appId }?.shaderDir ?: return@act false
FileOps.delete(dir)
})
fun removeLeftover(context: Context, appId: Int) = act(context, "leftover-$appId", { _, fresh ->
val leftover = fresh.leftovers.firstOrNull { it.appId == appId } ?: return@act false
deleteLeftover(leftover)
true
})
fun removeComponent(context: Context, usage: ComponentUsage) = act(context, "component", { app, fresh ->
val current = fresh.components.firstOrNull { it.packageFile == usage.packageFile && it.protonId == usage.protonId &&
it.comp == usage.comp && it.version == usage.version } ?: return@act false
!current.inUse && deleteComponent(app, current)
})
fun uninstallFlatpak(context: Context, id: String) = act(context, "flatpak-$id", { app, fresh ->
if (fresh.flatpakApps.none { it.id == id }) return@act false
val error = FlatpakManager.uninstall(app, id) { step, _ -> main.post { progress = step } }
if (error != null) main.post { lastError = error }
error == null
})
fun pruneFlatpak(context: Context) = act(context, "flatpak-prune", { app, _ ->
val error = FlatpakManager.prune(app) { step, _ -> main.post { progress = step } }
if (error != null) main.post { lastError = error }
error == null
})
private fun deleteComponent(app: Context, usage: ComponentUsage): Boolean {
if (usage.inUse) return false
usage.packageFile?.let { file ->
return runCatching { ComponentsManager.deletePackage(app, file) }.onFailure { Log.w(TAG, "kept $file", it) }.isSuccess
}
val proton = usage.protonId ?: return false
return ComponentsManager.deleteUnusedOriginal(app, proton, usage.comp, usage.version)
}
private fun finish(app: Context, freed: Long?, onDone: (Long?) -> Unit) {
val rescanned = runCatching { StorageScan.scan(app) }.getOrNull()
main.post {
if (rescanned != null) report = rescanned
cleaning = false
lastFreed = freed
onDone(freed)
}
}
private fun cleanItem(report: StorageReport, item: CleanItem) {
when (item) {
CleanItem.OLD_TOOLS -> report.unusedTools.filter { it.reason != ToolReason.CUSTOM }.forEach(::deleteTool)
CleanItem.UNUSED_CUSTOM -> report.unusedTools.filter { it.reason == ToolReason.CUSTOM }.forEach(::deleteTool)
CleanItem.UNUSED_COMPONENTS -> report.unusedComponents.forEach { deleteComponent(appContext!!, it) }
CleanItem.LEFTOVERS -> report.leftovers.filter { it.safe }.forEach(::deleteLeftover)
CleanItem.LEFTOVERS_UNSURE -> report.leftovers.filter { !it.safe }.forEach(::deleteLeftover)
CleanItem.LOGS -> report.logs.forEach { FileOps.delete(it) }
CleanItem.SHADERS -> report.games.mapNotNull { it.shaderDir }.forEach { FileOps.delete(it) }
}
}
// Steam treats an app as installed while its manifest exists, so the manifest goes first.
private fun deleteTool(tool: ToolUsage) {
tool.manifests.forEach { check(!it.exists() || it.delete()) { "cannot delete $it" } }
tool.dirs.forEach { FileOps.delete(it) }
}
private fun deleteLeftover(leftover: Leftover) {
leftover.prefix?.let { FileOps.delete(it) }
leftover.shaders?.let { FileOps.delete(it) }
}
fun format(bytes: Long): String {
val gb = bytes / (1024.0 * 1024 * 1024)
return when {
gb >= 100 -> String.format(java.util.Locale.US, "%.0f GB", gb)
gb >= 10 -> String.format(java.util.Locale.US, "%.1f GB", gb)
gb >= 0.995 -> String.format(java.util.Locale.US, "%.2f GB", gb)
bytes >= 1024L * 1024 -> String.format(java.util.Locale.US, "%.0f MB", bytes / (1024.0 * 1024))
else -> "0 MB"
}
}
}
@@ -148,6 +148,7 @@ class FrontEndState(
class FrontEndActions(
val onPlay: () -> Unit,
val onPlayDesktopUi: () -> Unit,
val onSteamUrl: (String) -> Unit = {},
val onSteamGame: (Library.SteamGame) -> Unit,
val onGameShortcut: (Library.SteamGame) -> Unit = {},
val onExportGameFile: (Library.SteamGame) -> Unit = {},
@@ -436,6 +437,9 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
selected = key
}
}
LaunchedEffect(com.droiddeck.launcher.storage.StorageState.openRequests) {
if (com.droiddeck.launcher.storage.StorageState.openPending) showRailPage("setup", true)
}
val onRailFocus: (String) -> Unit = { key ->
if (key != railSelection) showRailPage(key, false)
}
@@ -84,6 +84,8 @@ import com.droiddeck.launcher.R
private enum class CheckState { OK, WARN, BUSY }
private const val STORAGE_TAB = 3
@Composable
private fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
val colors = MaterialTheme.colorScheme
@@ -165,12 +167,18 @@ internal fun SetupPanel(
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
var editSgdbKey by remember { mutableStateOf(false) }
if (editSgdbKey) SgdbKeyDialog(s.sgdbUserKey, onSave = { a.onSgdbKey(it); editSgdbKey = false }, onDismiss = { editSgdbKey = false })
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_diagnostics))
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_storage), stringResource(R.string.setup_tab_diagnostics))
var tab by rememberSaveable { mutableStateOf(0) }
val chipFocus = remember { List(4) { FocusRequester() } }
val chipFocus = remember { List(tabs.size) { FocusRequester() } }
var tabTurned by remember { mutableStateOf(false) }
val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true }
val inputModeManager = LocalInputModeManager.current
LaunchedEffect(com.droiddeck.launcher.storage.StorageState.openRequests) {
if (com.droiddeck.launcher.storage.StorageState.openPending) {
com.droiddeck.launcher.storage.StorageState.openPending = false
tab = STORAGE_TAB
}
}
LaunchedEffect(tab) {
if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) {
androidx.compose.runtime.withFrameNanos { }
@@ -387,7 +395,8 @@ internal fun SetupPanel(
Column(Modifier.weight(1f)) { device(); art() }
} else { look(); device(); art() }
}
3 -> {
STORAGE_TAB -> StoragePanel(s, a)
4 -> {
SettingsGroup(stringResource(R.string.setup_tab_diagnostics)) {
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() }
@@ -0,0 +1,843 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.outlined.Extension
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Monitor
import androidx.compose.material.icons.outlined.SaveAlt
import androidx.compose.material.icons.outlined.ShoppingBag
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material.icons.outlined.VideogameAsset
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.storage.Bucket
import com.droiddeck.launcher.storage.CleanItem
import com.droiddeck.launcher.storage.ComponentUsage
import com.droiddeck.launcher.storage.GameUsage
import com.droiddeck.launcher.storage.StorageReport
import com.droiddeck.launcher.storage.StorageState
import com.droiddeck.launcher.storage.ToolReason
import com.droiddeck.launcher.storage.ToolState
import com.droiddeck.launcher.storage.ToolUsage
import java.text.DateFormat
import java.util.Date
private val BucketColors = mapOf(
Bucket.GAMES to Color(0xFF4B9CF5),
Bucket.GAME_DATA to Color(0xFF8F7CF6),
Bucket.TOOLS to Color(0xFF3FC1C9),
Bucket.COMPONENTS to Color(0xFF9BD65B),
Bucket.STORES to Color(0xFFF0B44C),
Bucket.DESKTOP to Color(0xFFE57FB1),
Bucket.SYSTEM to Color(0xFF7D8796),
Bucket.DROIDDECK to Color(0xFF6FD08C),
)
private val OtherApps = Color(0xFF3A3F48)
private val Shape22 = RoundedCornerShape(18.dp)
private fun gb(bytes: Long) = StorageState.format(bytes)
private const val ONE_MB = 1024L * 1024
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
internal fun StoragePanel(s: FrontEndState, a: FrontEndActions) {
val ctx = LocalContext.current
var view by remember { mutableStateOf("overview") }
var cleanup by remember { mutableStateOf(false) }
val report = StorageState.report
LaunchedEffect(Unit) { StorageState.refresh(ctx) }
LaunchedEffect(s.sessionRunning) { if (!s.sessionRunning) StorageState.refresh(ctx, force = report != null) }
if (report == null) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).clip(Shape14).background(MaterialTheme.colorScheme.surface)
.border(1.dp, LocalPalette.current.line, Shape14).padding(18.dp),
) {
CircularProgressIndicator(strokeWidth = 3.dp, modifier = Modifier.size(28.dp), color = LocalPalette.current.signal)
Column {
Text(stringResource(R.string.storage_measuring), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
Text(stringResource(R.string.storage_measuring_hint), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
return
}
if (cleanup) CleanUpDialog(report, s.sessionRunning) { cleanup = false }
val top = remember { androidx.compose.foundation.relocation.BringIntoViewRequester() }
LaunchedEffect(view) {
StorageState.lastFreed = null
StorageState.lastError = null
runCatching { top.bringIntoView() }
}
Box(Modifier.fillMaxWidth().height(1.dp).bringIntoViewRequester(top))
when (view) {
"games" -> GamesView(report, s.sessionRunning, a) { view = "overview" }
"data" -> GameDataView(report, s.sessionRunning) { view = "overview" }
"components" -> ComponentsView(report, s.sessionRunning) { view = "overview" }
"desktop" -> DesktopView(report, s.sessionRunning) { view = "overview" }
"tools" -> ToolsView(report, s.sessionRunning) { view = "overview" }
else -> Overview(report, s.sessionRunning, onCleanUp = { cleanup = true }, onOpen = { view = it })
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun Overview(report: StorageReport, sessionRunning: Boolean, onCleanUp: () -> Unit, onOpen: (String) -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val wide = !LocalNarrowPane.current
val used = report.total - report.free
val safe = StorageState.safeBytes(report)
val low = report.free < StorageState.LOW_SPACE_BYTES
ActionStatus()
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(18.dp),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)
.padding(horizontal = 18.dp, vertical = 14.dp),
) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.storage_internal), fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text("· " + stringResource(R.string.storage_used_of, gb(used), gb(report.total)), fontSize = 15.sp, color = colors.onSurfaceVariant)
Pill(stringResource(R.string.storage_free, gb(report.free)), if (low) AttentionAmber else pal.good)
}
val time = DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(report.at))
Text(
stringResource(R.string.storage_droiddeck_uses, gb(report.droiddeck), time) +
if (StorageState.scanning) " · " + stringResource(R.string.storage_rescanning) else "",
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp),
)
UsageBar(report, Modifier.padding(top = 12.dp, bottom = 10.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Bucket.entries.forEach { Legend(BucketColors.getValue(it), bucketLabel(it)) }
Legend(OtherApps, stringResource(R.string.storage_other_apps))
}
}
Box(Modifier.width(1.dp).height(120.dp).background(pal.line))
Column(Modifier.widthIn(min = 170.dp, max = 210.dp)) {
Text(stringResource(R.string.storage_safe_now), fontSize = 13.sp, color = colors.onSurfaceVariant)
Text(gb(safe), fontSize = 26.sp, fontWeight = FontWeight.Bold, color = if (safe > 0) pal.good else colors.onBackground)
Text(stringResource(R.string.storage_safe_hint), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 10.dp))
PrimaryButton(stringResource(R.string.storage_clean_up), enabled = !sessionRunning && !StorageState.cleaning, compact = true, onClick = onCleanUp)
}
}
if (sessionRunning) {
Spacer(Modifier.height(8.dp))
Note(stringResource(R.string.storage_session_running))
}
SectionTitle(stringResource(R.string.storage_where), null)
val unused = report.unusedTools.sumOf { it.bytes }
val tiles = listOf<@Composable (Modifier) -> Unit>(
{ m ->
BucketCard(Bucket.GAMES, Icons.Outlined.VideogameAsset, stringResource(R.string.storage_games),
pluralStringResource(R.plurals.storage_games_installed, report.games.size, report.games.size) +
if (report.gamesOnSd > 0) " · " + pluralStringResource(R.plurals.storage_games_on_sd, report.gamesOnSd, report.gamesOnSd) else "",
report.buckets.getValue(Bucket.GAMES), m) { onOpen("games") }
},
{ m ->
BucketCard(Bucket.GAME_DATA, Icons.Outlined.SaveAlt, stringResource(R.string.storage_game_data),
stringResource(R.string.storage_game_data_hint, gb(report.prefixes), gb(report.workshop), gb(report.shaders)),
report.buckets.getValue(Bucket.GAME_DATA), m) { onOpen("data") }
},
{ m ->
BucketCard(Bucket.TOOLS, Icons.Outlined.Layers, stringResource(R.string.storage_tools),
if (unused > 0) stringResource(R.string.storage_tools_unused, gb(unused)) else pluralStringResource(R.plurals.storage_tools_count, report.tools.size, report.tools.size),
report.buckets.getValue(Bucket.TOOLS), m, attention = unused > 0) { onOpen("tools") }
},
{ m ->
val unusedComponents = report.unusedComponents.sumOf { it.bytes }
BucketCard(Bucket.COMPONENTS, Icons.Outlined.Extension, stringResource(R.string.storage_components),
if (unusedComponents > 0) stringResource(R.string.storage_tools_unused, gb(unusedComponents))
else pluralStringResource(R.plurals.storage_components_count, report.components.size, report.components.size),
report.buckets.getValue(Bucket.COMPONENTS), m, attention = unusedComponents > 0) { onOpen("components") }
},
{ m ->
BucketCard(Bucket.STORES, Icons.Outlined.ShoppingBag, stringResource(R.string.storage_stores),
stringResource(R.string.storage_stores_hint), report.buckets.getValue(Bucket.STORES), m)
},
{ m ->
BucketCard(Bucket.DESKTOP, Icons.Outlined.Monitor, stringResource(R.string.storage_desktop),
pluralStringResource(R.plurals.storage_desktop_apps, report.flatpakApps.size, report.flatpakApps.size) +
" · " + stringResource(R.string.storage_desktop_shared, gb(report.flatpakRuntimes + report.flatpakCache)),
report.buckets.getValue(Bucket.DESKTOP), m) { onOpen("desktop") }
},
{ m ->
BucketCard(Bucket.SYSTEM, Icons.Outlined.Storage, stringResource(R.string.storage_system),
stringResource(R.string.storage_system_hint), report.buckets.getValue(Bucket.SYSTEM), m, locked = true)
},
{ m ->
BucketCard(Bucket.DROIDDECK, Icons.Outlined.Folder, stringResource(R.string.storage_app_data),
stringResource(R.string.storage_app_data_hint, gb(report.logBytes)), report.buckets.getValue(Bucket.DROIDDECK), m)
},
)
val columns = if (wide) 2 else 1
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
tiles.chunked(columns).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
row.forEach { it(Modifier.weight(1f).fillMaxHeight()) }
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
@Composable
private fun bucketLabel(bucket: Bucket) = stringResource(when (bucket) {
Bucket.GAMES -> R.string.storage_legend_games
Bucket.GAME_DATA -> R.string.storage_legend_data
Bucket.TOOLS -> R.string.storage_legend_tools
Bucket.COMPONENTS -> R.string.storage_legend_components
Bucket.STORES -> R.string.storage_legend_stores
Bucket.DESKTOP -> R.string.storage_legend_desktop
Bucket.SYSTEM -> R.string.storage_legend_system
Bucket.DROIDDECK -> R.string.storage_legend_app
})
@Composable
private fun UsageBar(report: StorageReport, modifier: Modifier) {
val total = report.total.coerceAtLeast(1).toFloat()
val other = (report.total - report.free - report.droiddeck).coerceAtLeast(0)
Row(modifier.fillMaxWidth().height(14.dp).clip(RoundedCornerShape(7.dp)).background(Color(0xFF0A0C0F))) {
Bucket.entries.forEach { b ->
val v = report.buckets.getValue(b)
if (v > 0) Box(Modifier.weight(v / total).fillMaxHeight().background(BucketColors.getValue(b)))
}
if (other > 0) Box(Modifier.weight(other / total).fillMaxHeight().background(OtherApps))
if (report.free > 0) Spacer(Modifier.weight(report.free / total))
}
}
@Composable
private fun Legend(color: Color, label: String) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(Modifier.size(9.dp).clip(RoundedCornerShape(3.dp)).background(color))
Text(label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun Pill(text: String, color: Color) {
Text(
text, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = color, maxLines = 1,
modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(color.copy(alpha = 0.16f)).padding(horizontal = 7.dp, vertical = 2.dp),
)
}
@Composable
private fun BucketCard(
bucket: Bucket, icon: ImageVector, title: String, detail: String, bytes: Long, modifier: Modifier,
attention: Boolean = false, locked: Boolean = false, onClick: (() -> Unit)? = null,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src) && onClick != null
val pressed by src.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "bucketScale")
val tint = BucketColors.getValue(bucket)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = modifier.paneItem("storage:${bucket.name}")
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(Shape14)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
.glideBorder(hot, Shape14, pal.signal, pal.line)
.then(if (onClick != null) Modifier.hoverable(src)
.clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
.controllerConfirm(onClick = onClick) else Modifier.hoverable(src))
.heightIn(min = 64.dp)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(tint)) {
Icon(icon, contentDescription = null, tint = Color(0xFF0B0D10), modifier = Modifier.size(20.dp))
}
Column(Modifier.weight(1f)) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(detail, fontSize = 12.5.sp, color = if (attention) AttentionAmber else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text(gb(bytes), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
when {
locked -> Icon(Icons.Outlined.Lock, contentDescription = null, tint = colors.onSurfaceVariant, modifier = Modifier.size(16.dp))
onClick != null -> Icon(Icons.Filled.ChevronRight, contentDescription = null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(18.dp))
else -> Spacer(Modifier.width(16.dp))
}
}
}
@Composable
private fun CleanUpDialog(report: StorageReport, sessionRunning: Boolean, onDismiss: () -> Unit) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val available = CleanItem.entries.filter { StorageState.bytes(report, it) > 0 }
var picked by remember { mutableStateOf(available.filter { it in StorageState.SAFE }.toSet()) }
val total = picked.sumOf { StorageState.bytes(report, it) }
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { first.requestFocus() } }
AlertDialog(
properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false),
modifier = Modifier.fillMaxWidth(0.62f).widthIn(min = 420.dp, max = 760.dp),
onDismissRequest = { if (!StorageState.cleaning) onDismiss() },
title = { Text(stringResource(R.string.storage_clean_up_title)) },
text = {
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.storage_clean_up_lede), fontSize = 13.sp, color = colors.onSurfaceVariant)
if (available.isEmpty()) Text(stringResource(R.string.storage_nothing_to_clean), fontSize = 14.sp, color = colors.onBackground)
available.forEachIndexed { i, item ->
val on = item in picked
CleanRow(
item, report, on, if (i == 0) Modifier.focusRequester(first) else Modifier,
) { picked = if (on) picked - item else picked + item }
}
Text(
stringResource(R.string.storage_selected, gb(total), gb(report.free), gb(report.free + total)),
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp),
)
if (StorageState.cleaning) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(16.dp), color = pal.signal)
Text(stringResource(R.string.storage_cleaning), fontSize = 13.sp, color = colors.onBackground)
}
}
},
confirmButton = {
if (picked.isNotEmpty() && !sessionRunning && !StorageState.cleaning) {
FocusText(stringResource(R.string.storage_free_button, gb(total)), pal.signal) {
StorageState.clean(ctx, picked) { onDismiss() }
}
}
},
dismissButton = {
FocusText(stringResource(R.string.common_cancel), colors.onBackground) { if (!StorageState.cleaning) onDismiss() }
},
)
}
@Composable
private fun CleanRow(item: CleanItem, report: StorageReport, on: Boolean, modifier: Modifier, onToggle: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val unsureCount = report.leftovers.count { !it.safe }
val safeCount = report.leftovers.count { it.safe }
val oldTools = report.unusedTools.filter { it.reason != ToolReason.CUSTOM }
val custom = report.unusedTools.filter { it.reason == ToolReason.CUSTOM }
val unusedLabels = report.unusedComponents.map { componentLabel(it) }
val (title, detail, tag) = when (item) {
CleanItem.OLD_TOOLS -> Triple(stringResource(R.string.storage_item_runtimes), oldTools.joinToString(", ") { it.name }, 0)
CleanItem.LEFTOVERS -> Triple(stringResource(R.string.storage_item_leftovers), pluralStringResource(R.plurals.storage_item_leftovers_detail, safeCount, safeCount), 0)
CleanItem.LEFTOVERS_UNSURE -> Triple(stringResource(R.string.storage_item_leftovers_unsure), pluralStringResource(R.plurals.storage_item_leftovers_unsure_detail, unsureCount, unsureCount), 2)
CleanItem.LOGS -> Triple(stringResource(R.string.storage_item_logs), stringResource(R.string.storage_item_logs_detail), 0)
CleanItem.UNUSED_CUSTOM -> Triple(custom.joinToString(", ") { it.name }, stringResource(R.string.storage_item_custom_detail), 1)
CleanItem.UNUSED_COMPONENTS -> Triple(stringResource(R.string.storage_item_components), unusedLabels.joinToString(", "), 1)
CleanItem.SHADERS -> Triple(stringResource(R.string.storage_item_shaders), stringResource(R.string.storage_item_shaders_detail), 2)
}
val shape = RoundedCornerShape(12.dp)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.fillMaxWidth().clip(shape)
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surfaceVariant.copy(alpha = 0.5f))
.glideBorder(hot, shape, pal.signal, pal.line)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Checkbox, onClick = onToggle)
.controllerConfirm(onClick = onToggle)
.padding(horizontal = 12.dp, vertical = 9.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(22.dp).clip(RoundedCornerShape(6.dp))
.background(if (on) pal.signal else Color.Transparent)
.border(2.dp, if (on) pal.signal else colors.onSurfaceVariant, RoundedCornerShape(6.dp)),
) { if (on) Icon(Icons.Filled.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp)) }
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
when (tag) {
0 -> Pill(stringResource(R.string.storage_tag_safe), pal.good)
1 -> Pill(stringResource(R.string.storage_tag_unused), colors.onSurfaceVariant)
else -> Pill(stringResource(if (item == CleanItem.SHADERS) R.string.storage_tag_stutter else R.string.storage_tag_saves), AttentionAmber)
}
}
Text(detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
Text(gb(StorageState.bytes(report, item)), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
}
}
@Composable
private fun SubHeader(title: String, detail: String, onBack: () -> Unit, trailing: @Composable () -> Unit = {}) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 8.dp).controllerBack(onBack),
) {
BackLink(stringResource(R.string.storage_back), compact = true, onClick = onBack)
Column(Modifier.weight(1f)) {
Text(title, fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(detail, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
trailing()
}
}
@Composable
private fun GamesView(report: StorageReport, sessionRunning: Boolean, a: FrontEndActions, onBack: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var sort by rememberSaveable { mutableStateOf(0) }
var confirm by remember { mutableStateOf<GameUsage?>(null) }
val games = when (sort) {
1 -> report.games.sortedBy { if (it.lastPlayed == 0L) Long.MIN_VALUE else it.lastPlayed }
2 -> report.games.filter { !it.internal }
else -> report.games
}
val max = report.games.maxOfOrNull { it.total }?.coerceAtLeast(1) ?: 1
SubHeader(stringResource(R.string.storage_games), stringResource(R.string.storage_games_with_data, gb(report.games.sumOf { it.total })), onBack)
Box(Modifier.fillMaxWidth().padding(bottom = 10.dp)) {
SheetChips(listOf(stringResource(R.string.storage_sort_largest), stringResource(R.string.storage_sort_least_played), stringResource(R.string.storage_sort_sd)), sort, null) { sort = it }
}
if (sessionRunning) { Note(stringResource(R.string.storage_session_running)); Spacer(Modifier.height(8.dp)) }
Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
if (games.isEmpty()) Text(stringResource(R.string.storage_no_games), fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(16.dp))
games.forEachIndexed { i, g ->
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
) {
Box(Modifier.size(42.dp).clip(RoundedCornerShape(8.dp)).background(artBrush(hueOf(g.name)))) {
if (g.art != null) AsyncImage(model = g.art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(42.dp))
}
Column(Modifier.weight(1.3f)) {
Text(g.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
listOfNotNull(
g.library.takeIf { !g.internal },
if (g.lastPlayed > 0) stringResource(R.string.storage_played, DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(g.lastPlayed * 1000))) else stringResource(R.string.storage_never_played),
).joinToString(" · "),
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
Column(Modifier.weight(1f)) {
Row(Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)).background(Color(0xFF0A0C0F))) {
listOf(g.game to BucketColors.getValue(Bucket.GAMES), g.prefix to BucketColors.getValue(Bucket.GAME_DATA), (g.shaders + g.workshop) to Color(0xFFC5B8FF))
.forEach { (v, c) -> if (v > 0) Box(Modifier.weight(v.toFloat() / max).fillMaxHeight().background(c)) }
val rest = max - g.total
if (rest > 0) Spacer(Modifier.weight(rest.toFloat() / max))
}
Text(
stringResource(R.string.storage_game_parts, gb(g.game), gb(g.data)),
fontSize = 11.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 3.dp), maxLines = 1,
)
}
Text(gb(g.total), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, modifier = Modifier.widthIn(min = 72.dp))
SecondaryButton(stringResource(R.string.storage_uninstall), compact = true, enabled = !sessionRunning) { confirm = g }
}
if (i < games.lastIndex) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
Spacer(Modifier.height(8.dp))
Note(stringResource(R.string.storage_games_note))
confirm?.let { g ->
AlertDialog(
onDismissRequest = { confirm = null },
title = { Text(stringResource(R.string.storage_uninstall_title, g.name)) },
text = { Text(stringResource(R.string.storage_uninstall_body, gb(g.game)), fontSize = 14.sp) },
confirmButton = { FocusText(stringResource(R.string.storage_open_steam), pal.signal) { confirm = null; a.onSteamUrl("steam://uninstall/${g.appId}") } },
dismissButton = {
val cancel = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { cancel.requestFocus() } }
FocusText(stringResource(R.string.common_cancel), colors.onBackground, modifier = Modifier.focusRequester(cancel)) { confirm = null }
},
)
}
}
@Composable
private fun ToolsView(report: StorageReport, sessionRunning: Boolean, onBack: () -> Unit) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var confirm by remember { mutableStateOf<ToolUsage?>(null) }
var confirmAll by remember { mutableStateOf(false) }
val unused = report.unusedTools
val inUse = report.tools.filter { it.state != ToolState.UNUSED }
SubHeader(
stringResource(R.string.storage_tools),
stringResource(R.string.storage_tools_detail, gb(report.tools.sumOf { it.bytes }), gb(unused.sumOf { it.bytes })), onBack,
) {
if (unused.isNotEmpty()) PrimaryButton(stringResource(R.string.storage_remove_unused, gb(unused.sumOf { it.bytes })), compact = true,
enabled = !sessionRunning && !StorageState.cleaning) { confirmAll = true }
}
if (sessionRunning) { Note(stringResource(R.string.storage_session_running)); Spacer(Modifier.height(4.dp)) }
SectionTitle(stringResource(R.string.storage_in_use), null)
ToolList(inUse, sessionRunning) { }
if (unused.isNotEmpty()) {
SectionTitle(stringResource(R.string.storage_not_used), null)
ToolList(unused, sessionRunning) { confirm = it }
}
confirm?.let { t ->
AlertDialog(
onDismissRequest = { confirm = null },
title = { Text(stringResource(R.string.storage_remove_title, t.name)) },
text = { Text(toolDetail(t) + "\n\n" + stringResource(R.string.storage_remove_body, gb(t.bytes)), fontSize = 14.sp) },
confirmButton = { FocusText(stringResource(R.string.storage_remove), colors.error) { confirm = null; StorageState.removeTool(ctx, t) { } } },
dismissButton = {
val cancel = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { cancel.requestFocus() } }
FocusText(stringResource(R.string.common_cancel), colors.onBackground, modifier = Modifier.focusRequester(cancel)) { confirm = null }
},
)
}
if (confirmAll) AlertDialog(
onDismissRequest = { confirmAll = false },
title = { Text(stringResource(R.string.storage_remove_unused, gb(unused.sumOf { it.bytes }))) },
text = { Text(unused.joinToString("\n") { "• " + it.name + " " + gb(it.bytes) }, fontSize = 14.sp) },
confirmButton = {
FocusText(stringResource(R.string.storage_remove), colors.error) {
confirmAll = false
StorageState.clean(ctx, setOf(CleanItem.OLD_TOOLS, CleanItem.UNUSED_CUSTOM)) { }
}
},
dismissButton = {
val cancel = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { cancel.requestFocus() } }
FocusText(stringResource(R.string.common_cancel), colors.onBackground, modifier = Modifier.focusRequester(cancel)) { confirmAll = false }
},
)
if (StorageState.cleaning) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 10.dp)) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(16.dp), color = pal.signal)
Text(stringResource(R.string.storage_cleaning), fontSize = 13.sp, color = colors.onBackground)
}
}
@Composable
private fun toolDetail(t: ToolUsage): String = when (t.reason) {
ToolReason.DEFAULT_PROTON -> stringResource(R.string.storage_tool_default_proton)
ToolReason.PROTON -> stringResource(R.string.storage_tool_proton)
ToolReason.FEX_RUNTIME -> stringResource(R.string.storage_tool_fex_runtime)
ToolReason.ARM_RUNTIME -> stringResource(R.string.storage_tool_arm_runtime)
ToolReason.REDISTRIBUTABLES -> stringResource(R.string.storage_tool_redist)
ToolReason.FEX -> stringResource(R.string.storage_tool_fex)
ToolReason.STEAM_TOOL -> stringResource(R.string.storage_tool_steam)
ToolReason.OLD_RUNTIME -> stringResource(R.string.storage_tool_old_runtime)
ToolReason.X86_PROTON -> stringResource(R.string.storage_tool_x86_proton)
ToolReason.CUSTOM_UNKNOWN -> stringResource(R.string.storage_tool_custom_unknown)
ToolReason.CUSTOM -> when {
t.isDefault -> stringResource(R.string.storage_tool_custom_default)
t.games > 0 -> pluralStringResource(R.plurals.storage_tool_custom_games, t.games, t.games)
else -> stringResource(R.string.storage_tool_custom_unused)
}
}
@Composable
private fun ToolList(tools: List<ToolUsage>, sessionRunning: Boolean, onRemove: (ToolUsage) -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
tools.forEachIndexed { i, t ->
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 14.dp, vertical = 8.dp),
) {
Column(Modifier.weight(1f)) {
Text(t.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(toolDetail(t), fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
when (t.state) {
ToolState.IN_USE -> Pill(if (t.isDefault) stringResource(R.string.storage_tag_default) else pluralStringResource(R.plurals.storage_tag_games, t.games, t.games), pal.signal)
ToolState.REQUIRED -> Pill(stringResource(R.string.storage_tag_required), colors.onSurfaceVariant)
ToolState.UNUSED -> Pill(stringResource(R.string.storage_tag_not_used), AttentionAmber)
}
Text(gb(t.bytes), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, modifier = Modifier.widthIn(min = 70.dp))
if (t.state == ToolState.UNUSED) SecondaryButton(stringResource(R.string.storage_remove), compact = true, enabled = !sessionRunning && !StorageState.cleaning) { onRemove(t) }
else Icon(Icons.Outlined.Lock, contentDescription = null, tint = colors.onSurfaceVariant, modifier = Modifier.size(16.dp))
}
if (i < tools.lastIndex) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
}
@Composable
internal fun LowSpaceDialog(free: Long, onFreeUp: () -> Unit, onPlay: () -> Unit, onDismiss: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val report = StorageState.report
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.storage_low_title, gb(free))) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.storage_low_body), fontSize = 14.sp)
if (report != null && StorageState.safeBytes(report) > 0) Text(
stringResource(R.string.storage_low_safe, gb(StorageState.safeBytes(report))), fontSize = 13.sp, color = pal.good,
)
}
},
confirmButton = { FocusText(stringResource(R.string.storage_free_up), pal.signal, modifier = Modifier.focusRequester(focus), onClick = onFreeUp) },
dismissButton = { FocusText(stringResource(R.string.storage_play_anyway), colors.onBackground, onClick = onPlay) },
)
}
@Composable
private fun ActionStatus() {
val pal = LocalPalette.current
val colors = MaterialTheme.colorScheme
when {
StorageState.cleaning -> Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp).clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(12.dp),
) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(16.dp), color = pal.signal)
Text(StorageState.progress ?: stringResource(R.string.storage_cleaning), fontSize = 13.sp, color = colors.onBackground)
}
StorageState.lastError != null -> { Note(stringResource(R.string.storage_failed, StorageState.lastError ?: "")); Spacer(Modifier.height(8.dp)) }
StorageState.lastFreed != null -> { Note(stringResource(R.string.storage_freed, gb(StorageState.lastFreed ?: 0))); Spacer(Modifier.height(8.dp)) }
}
}
@Composable
private fun componentLabel(c: ComponentUsage): String {
val name = com.droiddeck.launcher.session.ComponentsManager.LABEL[c.comp] ?: c.comp
return if (c.packageFile != null) "$name ${c.version}"
else stringResource(R.string.storage_component_original, name, c.protonName ?: c.protonId.orEmpty())
}
@Composable
private fun ListCard(content: @Composable () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) { content() }
}
@Composable
private fun ItemRow(title: String, detail: String?, bytes: Long, last: Boolean, pill: (@Composable () -> Unit)? = null, action: (@Composable () -> Unit)? = null) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 14.dp, vertical = 8.dp),
) {
Column(Modifier.weight(1f)) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (detail != null) Text(detail, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
pill?.invoke()
Text(gb(bytes), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, modifier = Modifier.widthIn(min = 70.dp))
action?.invoke()
}
if (!last) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
@Composable
private fun ConfirmDialog(title: String, body: String, confirm: String, danger: Boolean = true, onConfirm: () -> Unit, onDismiss: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(body, fontSize = 14.sp) },
confirmButton = { FocusText(confirm, if (danger) colors.error else pal.signal) { onDismiss(); onConfirm() } },
dismissButton = {
val cancel = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { cancel.requestFocus() } }
FocusText(stringResource(R.string.common_cancel), colors.onBackground, modifier = Modifier.focusRequester(cancel), onClick = onDismiss)
},
)
}
@Composable
private fun GameDataView(report: StorageReport, sessionRunning: Boolean, onBack: () -> Unit) {
val ctx = LocalContext.current
val busy = sessionRunning || StorageState.cleaning
var confirm by remember { mutableStateOf<com.droiddeck.launcher.storage.Leftover?>(null) }
SubHeader(stringResource(R.string.storage_game_data), stringResource(R.string.storage_game_data_hint, gb(report.prefixes), gb(report.workshop), gb(report.shaders)), onBack)
ActionStatus()
if (sessionRunning) { Note(stringResource(R.string.storage_session_running)); Spacer(Modifier.height(4.dp)) }
val games = report.games.filter { it.data >= ONE_MB }.sortedByDescending { it.data }
SectionTitle(stringResource(R.string.storage_data_installed), gb(games.sumOf { it.data }))
ListCard {
if (games.isEmpty()) Text(stringResource(R.string.storage_no_games), fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(16.dp))
games.forEachIndexed { i, g ->
ItemRow(
g.name, stringResource(R.string.storage_data_parts, gb(g.prefix), gb(g.shaders), gb(g.workshop)), g.data, i == games.lastIndex,
action = { SecondaryButton(stringResource(R.string.storage_clear_shaders), compact = true, enabled = !busy && g.shaders >= ONE_MB) { StorageState.clearShaders(ctx, g.appId) } },
)
}
}
val leftovers = report.leftovers.sortedByDescending { it.bytes }
if (!report.leftoversChecked) {
SectionTitle(stringResource(R.string.storage_data_leftovers), null)
Note(stringResource(R.string.storage_leftovers_unchecked))
}
if (leftovers.isNotEmpty()) {
SectionTitle(stringResource(R.string.storage_data_leftovers), gb(leftovers.sumOf { it.bytes }))
val safe = leftovers.filter { it.safe }
ListCard {
if (safe.isNotEmpty()) ItemRow(
pluralStringResource(R.plurals.storage_item_leftovers_detail, safe.size, safe.size), null, safe.sumOf { it.bytes }, leftovers.none { !it.safe },
pill = { Pill(stringResource(R.string.storage_tag_safe), LocalPalette.current.good) },
action = { SecondaryButton(stringResource(R.string.storage_remove), compact = true, enabled = !busy) { StorageState.clean(ctx, setOf(CleanItem.LEFTOVERS)) { } } },
)
val unsure = leftovers.filter { !it.safe }
unsure.forEachIndexed { i, l ->
ItemRow(
stringResource(R.string.storage_steam_app, l.appId), stringResource(R.string.storage_leftover_unsure_detail), l.bytes, i == unsure.lastIndex,
pill = { Pill(stringResource(R.string.storage_tag_saves), AttentionAmber) },
action = { SecondaryButton(stringResource(R.string.storage_remove), compact = true, enabled = !busy) { confirm = l } },
)
}
}
}
if (report.otherPrefixes > 0) {
SectionTitle(stringResource(R.string.storage_data_other), null)
ListCard { ItemRow(stringResource(R.string.storage_data_other_title), stringResource(R.string.storage_data_other_detail), report.otherPrefixes, true) }
}
confirm?.let { l ->
ConfirmDialog(
stringResource(R.string.storage_remove_title, stringResource(R.string.storage_steam_app, l.appId)),
stringResource(R.string.storage_leftover_confirm, gb(l.bytes)), stringResource(R.string.storage_remove),
onConfirm = { StorageState.removeLeftover(ctx, l.appId) }, onDismiss = { confirm = null },
)
}
}
@Composable
private fun ComponentsView(report: StorageReport, sessionRunning: Boolean, onBack: () -> Unit) {
val ctx = LocalContext.current
val pal = LocalPalette.current
val colors = MaterialTheme.colorScheme
val busy = sessionRunning || StorageState.cleaning
var confirm by remember { mutableStateOf<ComponentUsage?>(null) }
val unused = report.unusedComponents
SubHeader(stringResource(R.string.storage_components),
stringResource(R.string.storage_tools_detail, gb(report.components.sumOf { it.bytes }), gb(unused.sumOf { it.bytes })), onBack) {
if (unused.isNotEmpty()) PrimaryButton(stringResource(R.string.storage_remove_unused, gb(unused.sumOf { it.bytes })), compact = true, enabled = !busy) {
StorageState.clean(ctx, setOf(CleanItem.UNUSED_COMPONENTS)) { }
}
}
ActionStatus()
if (sessionRunning) { Note(stringResource(R.string.storage_session_running)); Spacer(Modifier.height(4.dp)) }
val packages = report.components.filter { it.packageFile != null }
val originals = report.components.filter { it.packageFile == null }
SectionTitle(stringResource(R.string.storage_components_packages), null)
ListCard {
if (packages.isEmpty()) Text(stringResource(R.string.storage_components_none), fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(16.dp))
packages.forEachIndexed { i, c ->
ItemRow(componentLabel(c), stringResource(if (c.inUse) R.string.storage_component_in_use else R.string.storage_component_unused), c.bytes, i == packages.lastIndex,
pill = { Pill(stringResource(if (c.inUse) R.string.storage_tag_in_use else R.string.storage_tag_not_used), if (c.inUse) pal.signal else AttentionAmber) },
action = { if (!c.inUse) SecondaryButton(stringResource(R.string.storage_remove), compact = true, enabled = !busy) { confirm = c } })
}
}
if (originals.isNotEmpty()) {
SectionTitle(stringResource(R.string.storage_components_originals), null)
ListCard {
originals.forEachIndexed { i, c ->
ItemRow(componentLabel(c), c.version + " · " + stringResource(if (c.inUse) R.string.storage_original_kept else R.string.storage_original_old), c.bytes, i == originals.lastIndex,
pill = { Pill(stringResource(if (c.inUse) R.string.storage_tag_required else R.string.storage_tag_not_used), if (c.inUse) colors.onSurfaceVariant else AttentionAmber) },
action = { if (!c.inUse) SecondaryButton(stringResource(R.string.storage_remove), compact = true, enabled = !busy) { confirm = c } })
}
}
}
Spacer(Modifier.height(8.dp))
Note(stringResource(R.string.storage_components_note))
confirm?.let { c ->
ConfirmDialog(stringResource(R.string.storage_remove_title, componentLabel(c)),
stringResource(if (c.packageFile != null) R.string.storage_component_confirm else R.string.storage_original_confirm, gb(c.bytes)),
stringResource(R.string.storage_remove), onConfirm = { StorageState.removeComponent(ctx, c) }, onDismiss = { confirm = null })
}
}
@Composable
private fun DesktopView(report: StorageReport, sessionRunning: Boolean, onBack: () -> Unit) {
val ctx = LocalContext.current
val busy = sessionRunning || StorageState.cleaning
var confirm by remember { mutableStateOf<com.droiddeck.launcher.storage.FlatpakUsage?>(null) }
var confirmPrune by remember { mutableStateOf(false) }
SubHeader(stringResource(R.string.storage_desktop), stringResource(R.string.storage_desktop_detail, gb(report.flatpak)), onBack) {
PrimaryButton(stringResource(R.string.storage_desktop_prune), compact = true, enabled = !busy && report.flatpakRuntimes + report.flatpakCache >= ONE_MB) { confirmPrune = true }
}
ActionStatus()
if (sessionRunning) { Note(stringResource(R.string.storage_session_running)); Spacer(Modifier.height(4.dp)) }
SectionTitle(stringResource(R.string.storage_desktop_apps_title), null)
ListCard {
if (report.flatpakApps.isEmpty()) Text(stringResource(R.string.storage_desktop_none), fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(16.dp))
report.flatpakApps.forEachIndexed { i, app ->
ItemRow(app.name, app.id, app.bytes, i == report.flatpakApps.lastIndex,
action = { SecondaryButton(stringResource(R.string.storage_uninstall), compact = true, enabled = !busy) { confirm = app } })
}
}
SectionTitle(stringResource(R.string.storage_desktop_shared_title), null)
ListCard {
ItemRow(stringResource(R.string.storage_desktop_runtimes), stringResource(R.string.storage_desktop_runtimes_detail), report.flatpakRuntimes, false)
ItemRow(stringResource(R.string.storage_desktop_cache), stringResource(R.string.storage_desktop_cache_detail), report.flatpakCache, true)
}
confirm?.let { app ->
ConfirmDialog(stringResource(R.string.storage_uninstall_title, app.name), stringResource(R.string.storage_flatpak_uninstall_body, gb(app.bytes)),
stringResource(R.string.storage_uninstall), onConfirm = { StorageState.uninstallFlatpak(ctx, app.id) }, onDismiss = { confirm = null })
}
if (confirmPrune) ConfirmDialog(stringResource(R.string.storage_desktop_prune), stringResource(R.string.storage_desktop_prune_body),
stringResource(R.string.storage_desktop_prune), danger = false, onConfirm = { StorageState.pruneFlatpak(ctx) }, onDismiss = { confirmPrune = false })
}
+170
View File
@@ -2293,4 +2293,174 @@
<string name="display_position_top">Top</string>
<string name="display_position_bottom">Bottom</string>
<string name="display_position_center">Center (default)</string>
<string name="setup_tab_storage">Storage</string>
<string name="storage_measuring">Measuring storage…</string>
<string name="storage_measuring_hint">This takes a few seconds the first time.</string>
<string name="storage_internal">Internal storage</string>
<string name="storage_used_of">%1$s of %2$s used</string>
<string name="storage_free">%1$s free</string>
<string name="storage_droiddeck_uses">DroidDeck uses %1$s. Measured at %2$s.</string>
<string name="storage_rescanning">measuring again…</string>
<string name="storage_other_apps">Other Android apps</string>
<string name="storage_safe_now">Safe to free now</string>
<string name="storage_safe_hint">Unused runtimes, leftovers, old logs</string>
<string name="storage_clean_up">Clean up…</string>
<string name="storage_session_running">Stop the session to clean up or remove anything. Sizes are still shown.</string>
<string name="storage_where">Where it goes</string>
<string name="storage_games">Steam games</string>
<string name="storage_game_data">Game data</string>
<string name="storage_game_data_hint">Prefixes %1$s · workshop %2$s · shaders %3$s</string>
<string name="storage_tools">Proton &amp; runtimes</string>
<string name="storage_tools_unused">%1$s not used</string>
<string name="storage_stores">GOG, Epic &amp; other stores</string>
<string name="storage_stores_hint">Uninstall these from Stores</string>
<string name="storage_desktop">Desktop apps</string>
<string name="storage_desktop_hint">Flatpak apps and runtimes</string>
<string name="storage_system">Linux system &amp; Steam client</string>
<string name="storage_system_hint">Needed to run anything</string>
<string name="storage_app_data">DroidDeck data</string>
<string name="storage_app_data_hint">Old session logs %1$s · caches · drivers</string>
<string name="storage_legend_games">Games</string>
<string name="storage_legend_data">Game data</string>
<string name="storage_legend_tools">Proton &amp; runtimes</string>
<string name="storage_legend_stores">Other stores</string>
<string name="storage_legend_desktop">Desktop apps</string>
<string name="storage_legend_system">Linux system</string>
<string name="storage_legend_app">DroidDeck</string>
<string name="storage_clean_up_title">Clean up</string>
<string name="storage_clean_up_lede">Nothing here touches your installed games or downloads in progress.</string>
<string name="storage_nothing_to_clean">Nothing to clean up right now.</string>
<string name="storage_selected">Selected: %1$s · %2$s → %3$s free</string>
<string name="storage_cleaning">Cleaning up…</string>
<string name="storage_free_button">Free %1$s</string>
<string name="storage_freed">Freed %1$s.</string>
<string name="storage_item_runtimes">Unused Steam runtimes and Protons</string>
<string name="storage_item_leftovers">Leftovers from uninstalled games</string>
<string name="storage_item_leftovers_unsure">Leftovers that may hold saves</string>
<string name="storage_item_logs">Old session logs</string>
<string name="storage_item_logs_detail">Keeps the current session’s logs</string>
<string name="storage_item_custom_detail">No game is set to it. Reinstall it from Components if you need it.</string>
<string name="storage_item_shaders">Shader caches of installed games</string>
<string name="storage_item_shaders_detail">Rebuilt as you play. The first minutes of each game stutter again.</string>
<string name="storage_tag_safe">Safe</string>
<string name="storage_tag_unused">Not used</string>
<string name="storage_tag_stutter">Stutter</string>
<string name="storage_tag_saves">Check saves</string>
<string name="storage_tag_default">Default</string>
<string name="storage_tag_required">Required</string>
<string name="storage_tag_not_used">Not used</string>
<string name="storage_back">Storage</string>
<string name="storage_games_with_data">%1$s with their data</string>
<string name="storage_sort_largest">Largest</string>
<string name="storage_sort_least_played">Least played</string>
<string name="storage_sort_sd">On SD card</string>
<string name="storage_no_games">No games here.</string>
<string name="storage_played">Played %1$s</string>
<string name="storage_never_played">Never played</string>
<string name="storage_game_parts">Game %1$s · data %2$s</string>
<string name="storage_uninstall">Uninstall</string>
<string name="storage_games_note">Uninstall opens Steam’s own dialog so Steam keeps track. To move a game to the SD card, use Steam › Settings › Storage.</string>
<string name="storage_uninstall_title">Uninstall %1$s?</string>
<string name="storage_uninstall_body">Steam opens and asks you to confirm. This frees the game’s %1$s. Its prefix stays until you clean up leftovers, and Steam Cloud saves stay in your account.</string>
<string name="storage_open_steam">Open Steam</string>
<string name="storage_tools_detail">%1$s · %2$s not used</string>
<string name="storage_remove_unused">Remove unused · %1$s</string>
<string name="storage_in_use">In use</string>
<string name="storage_not_used">Not used</string>
<string name="storage_remove">Remove</string>
<string name="storage_remove_title">Remove %1$s?</string>
<string name="storage_remove_body">Frees %1$s. Steam downloads it again if a game ever asks for it.</string>
<string name="storage_tool_fex_runtime">Needed for Linux x86 games (FEX)</string>
<string name="storage_tool_arm_runtime">Needed by the ARM64 Protons</string>
<string name="storage_tool_redist">VC++ and DirectX installers games ask for</string>
<string name="storage_tool_fex">Runs Linux x86 games</string>
<string name="storage_tool_steam">Steam tool</string>
<string name="storage_tool_old_runtime">DroidDeck runs games without this runtime</string>
<string name="storage_tool_x86_proton">x86 Proton, which DroidDeck cannot run</string>
<string name="storage_tool_custom_default">Default for every game</string>
<string name="storage_tool_custom_unused">No game is set to it</string>
<string name="storage_tool_custom_unknown">Steam’s settings could not be read, so it is kept</string>
<string name="storage_cleanup_busy">Wait for the storage clean-up to finish</string>
<string name="storage_low_title">Only %1$s free</string>
<string name="storage_low_body">Steam needs room for updates and shader caches. Below 10 GB, game updates can fail partway.</string>
<string name="storage_low_safe">%1$s can be freed safely.</string>
<string name="storage_free_up">Free up space</string>
<string name="storage_play_anyway">Play anyway</string>
<plurals name="storage_games_installed">
<item quantity="one">%d installed</item>
<item quantity="other">%d installed</item>
</plurals>
<plurals name="storage_games_on_sd">
<item quantity="one">%d on the SD card</item>
<item quantity="other">%d on the SD card</item>
</plurals>
<plurals name="storage_tools_count">
<item quantity="one">%d tool</item>
<item quantity="other">%d tools</item>
</plurals>
<plurals name="storage_item_leftovers_detail">
<item quantity="one">%d game’s empty prefix folders and shader cache</item>
<item quantity="other">%d games’ empty prefix folders and shader caches</item>
</plurals>
<plurals name="storage_item_leftovers_unsure_detail">
<item quantity="one">%d game’s prefix still holds files, which can include local saves. Back them up first if you want them.</item>
<item quantity="other">%d games’ prefixes still hold files, which can include local saves. Back them up first if you want them.</item>
</plurals>
<plurals name="storage_tag_games">
<item quantity="one">%d game</item>
<item quantity="other">%d games</item>
</plurals>
<string name="storage_tool_default_proton">DroidDeck’s default Proton</string>
<string name="storage_tool_proton">DroidDeck’s Proton 11 option</string>
<plurals name="storage_tool_custom_games">
<item quantity="one">Used by %d game</item>
<item quantity="other">Used by %d games</item>
</plurals>
<string name="storage_legend_components">Components</string>
<string name="storage_components">Components</string>
<string name="storage_item_components">Unused components</string>
<string name="storage_component_original">Original %1$s · %2$s</string>
<string name="storage_original_confirm">Frees %1$s. It only restores a Proton version that is no longer installed.</string>
<string name="storage_leftovers_unchecked">Not checked: a Steam app manifest could not be read, so prefixes of uninstalled games are kept until it can.</string>
<string name="storage_failed">Could not finish: %1$s</string>
<string name="storage_desktop_shared">shared %1$s</string>
<string name="storage_data_installed">Installed games</string>
<string name="storage_data_parts">Prefix %1$s · shaders %2$s · workshop %3$s</string>
<string name="storage_clear_shaders">Clear shaders</string>
<string name="storage_data_leftovers">Uninstalled games</string>
<string name="storage_steam_app">Steam app %1$d</string>
<string name="storage_leftover_unsure_detail">Its prefix still holds files, which can include local saves</string>
<string name="storage_leftover_confirm">Deletes its Wine prefix and shader cache (%1$s). Back up any saves you want first; Steam Cloud saves stay in your account.</string>
<string name="storage_data_other">Kept</string>
<string name="storage_data_other_title">Non-Steam games and Steam’s shared prefix</string>
<string name="storage_data_other_detail">Prefixes of games added from stores or by hand</string>
<string name="storage_components_packages">Downloaded packages</string>
<string name="storage_components_originals">Saved originals</string>
<string name="storage_components_none">No component packages downloaded.</string>
<string name="storage_component_in_use">Selected for a Proton or a game</string>
<string name="storage_component_unused">Not selected anywhere</string>
<string name="storage_original_kept">Restores the installed Proton</string>
<string name="storage_original_old">Proton version no longer installed</string>
<string name="storage_tag_in_use">In use</string>
<string name="storage_components_note">Components in use stay locked. Choose them in Components; anything removed here can be downloaded there again.</string>
<string name="storage_component_confirm">Frees %1$s. Packages you imported yourself cannot be downloaded again.</string>
<string name="storage_desktop_detail">%1$s for Flatpak apps and what they share</string>
<string name="storage_desktop_apps_title">Apps</string>
<string name="storage_desktop_none">No Flatpak apps installed.</string>
<string name="storage_desktop_shared_title">Shared by apps</string>
<string name="storage_desktop_runtimes">Runtimes</string>
<string name="storage_desktop_runtimes_detail">Libraries the apps run on; unused ones go with Remove unused</string>
<string name="storage_desktop_cache">Download store</string>
<string name="storage_desktop_cache_detail">Flatpak’s object store and cached downloads</string>
<string name="storage_desktop_prune">Remove unused</string>
<string name="storage_desktop_prune_body">Removes runtimes no installed app needs and prunes Flatpak’s object store. Installed apps keep working.</string>
<string name="storage_flatpak_uninstall_body">Removes the app (%1$s) and any runtime nothing else uses. Its data in your home folder stays.</string>
<plurals name="storage_components_count">
<item quantity="one">%d package</item>
<item quantity="other">%d packages</item>
</plurals>
<plurals name="storage_desktop_apps">
<item quantity="one">%d app</item>
<item quantity="other">%d apps</item>
</plurals>
</resources>
@@ -0,0 +1,139 @@
package com.droiddeck.launcher.storage
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class SteamStorageTest {
@get:Rule val tmp = TemporaryFolder()
private fun manifest(id: Int, dir: String, flags: Int = 4) = SteamStorage.parseManifest(
File("appmanifest_$id.acf"),
"""
"AppState"
{
"appid" "$id"
"name" "App $id"
"StateFlags" "$flags"
"installdir" "$dir"
"LastPlayed" "1792072812"
}
""".trimIndent(),
)!!
@Test fun readsManifestFields() {
val m = manifest(1794680, "Vampire Survivors")
assertEquals(1794680, m.appId)
assertEquals("Vampire Survivors", m.installDir)
assertTrue(m.installed)
assertEquals(1792072812L, m.lastPlayed)
assertFalse(manifest(1, "x", flags = 1026).installed)
}
@Test fun rejectsInstallDirsThatEscapeCommon() {
assertNull(manifest(5, "../steamapps").installDir)
assertNull(manifest(5, "a/b").installDir)
}
@Test fun classifiesTools() {
assertEquals(SteamStorage.Kind.OLD_RUNTIME, SteamStorage.kind(manifest(1070560, "SteamLinuxRuntime")))
assertEquals(SteamStorage.Kind.OLD_RUNTIME, SteamStorage.kind(manifest(1391110, "SteamLinuxRuntime_soldier")))
assertEquals(SteamStorage.Kind.REQUIRED, SteamStorage.kind(manifest(1628350, "SteamLinuxRuntime_sniper")))
assertEquals(SteamStorage.Kind.REQUIRED, SteamStorage.kind(manifest(4185400, "SteamLinuxRuntime_4-arm64")))
assertEquals(SteamStorage.Kind.ARM_PROTON, SteamStorage.kind(manifest(4427310, "Proton Experimental (ARM64)")))
assertEquals(SteamStorage.Kind.ARM_PROTON, SteamStorage.kind(manifest(4628740, "Proton 11.0 (ARM64)")))
assertEquals(SteamStorage.Kind.X86_PROTON, SteamStorage.kind(manifest(1493710, "Proton - Experimental")))
assertEquals(SteamStorage.Kind.X86_PROTON, SteamStorage.kind(manifest(2805730, "Proton 9.0")))
assertEquals(SteamStorage.Kind.REQUIRED, SteamStorage.kind(manifest(228980, "Steamworks Shared")))
assertTrue(SteamStorage.isTool(manifest(2805730, "Proton 9.0")))
assertFalse(SteamStorage.isTool(manifest(204360, "CastleCrashers")))
}
@Test fun readsOnlyTheCompatToolMappingBlock() {
val vdf = """
"InstallConfigStore"
{
"Software"
{
"Valve"
{
"Steam"
{
"CompatToolMapping"
{
"0"
{
"name" "droiddeck-proton-arm64"
"config" ""
}
"207140"
{
"name" "droiddeck-proton-11-arm64"
}
}
"Other"
{
"999"
{
"name" "not-a-mapping"
}
}
}
}
}
}
""".trimIndent()
assertEquals(mapOf("0" to "droiddeck-proton-arm64", "207140" to "droiddeck-proton-11-arm64"), SteamStorage.compatMappings(vdf))
assertEquals(emptyMap<String, String>(), SteamStorage.compatMappings("\"x\" {}"))
}
@Test fun readsCustomToolName() {
val vdf = "\"compatibilitytools\"\n{\n \"compat_tools\"\n {\n \"GE-Proton11-9-aarch64\"\n {\n \"install_path\" \".\"\n }\n }\n}"
assertEquals("GE-Proton11-9-aarch64", SteamStorage.compatToolName(vdf))
}
@Test fun listingCountsPendingDownloadsAndUnreadableManifests() {
val library = tmp.newFolder("lib")
val apps = File(library, "steamapps").apply { mkdirs() }
File(apps, "appmanifest_10.acf").writeText("\"appid\" \"10\"\n\"StateFlags\" \"4\"")
File(apps, "appmanifest_379720.acf.1131809557.tmp").writeText("")
File(apps, "downloading/3061810").mkdirs()
File(apps, "appmanifest_20.acf").mkdirs()
val listing = SteamStorage.manifests(library)!!
assertTrue(listing.complete)
assertEquals(setOf(10, 379720, 3061810), listing.ids)
val named = File(apps, "appmanifest_30.acf").apply { writeText("x"); setReadable(false) }
val unnamed = File(apps, "appmanifest_.acf").apply { writeText("x"); setReadable(false) }
if (!named.canRead() && !unnamed.canRead()) {
val after = SteamStorage.manifests(library)!!
assertTrue(30 in after.ids)
assertFalse(after.complete)
}
assertNull(SteamStorage.manifests(File(library, "missing")))
}
@Test fun leftoverIdsSkipTheSharedPrefixAndShortcuts() {
assertNull(SteamStorage.leftoverId("0"))
assertNull(SteamStorage.leftoverId("3207321049"))
assertNull(SteamStorage.leftoverId("-1768546955"))
assertNull(SteamStorage.leftoverId("pfx"))
assertEquals(452280, SteamStorage.leftoverId("452280"))
}
@Test fun stubPrefixes() {
val stub = tmp.newFolder("compatdata", "10")
assertTrue(SteamStorage.isStubPrefix(stub))
File(stub, "pfx/drive_c/users/steamuser/AppData/LocalLow/Studio/Game").mkdirs()
File(stub, "pfx/drive_c/users/steamuser/AppData/LocalLow/Studio/Game/steam_autocloud.vdf").writeText("x")
assertTrue(SteamStorage.isStubPrefix(stub))
val used = tmp.newFolder("compatdata", "20")
File(used, "pfx/drive_c/users/steamuser/Saved Games").mkdirs()
File(used, "pfx/drive_c/users/steamuser/Saved Games/slot1.sav").writeText("x")
assertFalse(SteamStorage.isStubPrefix(used))
}
}
@@ -181,6 +181,27 @@ def uninstall(app_id):
return rc
def prune():
inst = installation()
try:
unused = inst.list_unused_refs(None, None)
except GLib.Error as err:
emit(e="error", message=err.message)
return 1
if unused:
t, state = transaction(inst)
for r in unused:
t.add_uninstall(r.format_ref())
if run(t, state, report_done=False):
return 1
try:
inst.prune_local_repo(None)
except GLib.Error as err:
emit(e="warning", message=err.message)
emit(e="done", removed=len(unused))
return 0
def updatable(inst):
try:
return inst.list_installed_refs_for_update(None)
@@ -216,7 +237,7 @@ def updates():
def main():
if len(sys.argv) < 2:
sys.stderr.write(__doc__ or "usage: droiddeck-flatpak install|install-bundle|uninstall|update|updates [app-id|file]\n")
sys.stderr.write(__doc__ or "usage: droiddeck-flatpak install|install-bundle|uninstall|update|updates|prune [app-id|file]\n")
return 2
cmd, args = sys.argv[1], sys.argv[2:]
if cmd in ("install", "install-bundle", "update"):
@@ -231,7 +252,9 @@ def main():
return update(args[0] if args else None)
if cmd == "updates":
return updates()
sys.stderr.write("usage: droiddeck-flatpak install|install-bundle|uninstall|update|updates [app-id|file]\n")
if cmd == "prune":
return prune()
sys.stderr.write("usage: droiddeck-flatpak install|install-bundle|uninstall|update|updates|prune [app-id|file]\n")
return 2
@@ -516,13 +516,27 @@ case $mode in
) &
if [ -n "${BL_LAUNCH_DIR:-}" ] && [ -z "${BL_DESKTOP:-}" ]; then
_bl_held=
_bl_uninstall=
_bl_rest=()
for _bl_arg in "$@"; do
case $_bl_arg in
steam://rungameid/*) _bl_held=${_bl_arg#steam://rungameid/} ;;
steam://uninstall/*) _bl_uninstall=${_bl_arg#steam://uninstall/} ;;
*) _bl_rest+=("$_bl_arg") ;;
esac
done
if [ -n "$_bl_uninstall" ]; then
set -- "${_bl_rest[@]}"
(
_bl_end=$((SECONDS + 120))
until [ "$(xprop -root GAMESCOPE_FOCUSED_APP 2>/dev/null | sed -n 's/.*= *//p')" = 769 ] || [ "$SECONDS" -ge "$_bl_end" ]; do
sleep 1
done
sleep 4
echo "== steam: interface up; asking to uninstall $_bl_uninstall"
printf '%s\n' "$_bl_uninstall" > "$BL_LAUNCH_DIR/steam-uninstall.tmp" && mv -f "$BL_LAUNCH_DIR/steam-uninstall.tmp" "$BL_LAUNCH_DIR/steam-uninstall"
) &
fi
if [ -n "$_bl_held" ]; then
set -- "${_bl_rest[@]}"
(
@@ -773,6 +787,17 @@ BUSCONF
"$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true
exit 0
fi
if [ -f "$BL_LAUNCH_DIR/steam-uninstall" ]; then
uninstall_id=$(cat "$BL_LAUNCH_DIR/steam-uninstall" 2>/dev/null)
rm -f "$BL_LAUNCH_DIR/steam-uninstall"
case $uninstall_id in
''|0*|*[!0-9]*|???????????*) echo "== steam: ignored invalid uninstall request" ;;
*)
echo "== steam: opening the uninstall dialog for $uninstall_id"
"$steam_root/steamrtarm64/steam" "steam://uninstall/$uninstall_id" >/dev/null 2>&1 &
;;
esac
fi
if [ -f "$BL_LAUNCH_DIR/steam-game" ]; then
game_id=$(cat "$BL_LAUNCH_DIR/steam-game" 2>/dev/null)
rm -f "$BL_LAUNCH_DIR/steam-game"