diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index ddf8d6e82..c5cd88f0b 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -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(null) private var showNonAdreno by mutableStateOf(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 diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 44c7c8073..5d55e0de2 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -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> { + internal fun steamLibraries(context: Context): List> { val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam") return listOfNotNull( root to "internal", diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt index 8dfafd42b..56340b111 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt @@ -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? { if (!ready(context)) return emptySet() diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt index 50d02c49b..2918a5b18 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt @@ -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, val originals: List) + + private fun packagesInUse(state: JSONObject): Set { + val names = HashSet() + 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().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, 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 diff --git a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt new file mode 100644 index 000000000..c0f74ab18 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt @@ -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, val otherIds: Set, val complete: Boolean) { + val ids: Set 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() + val read = ArrayList() + 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 { + 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()) } +} diff --git a/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt b/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt new file mode 100644 index 000000000..f4c6ca999 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt @@ -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, val dirs: List, +) + +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, + val prefixes: Long, + val shaders: Long, + val workshop: Long, + val flatpak: Long, + val games: List, + val tools: List, + val leftovers: List, + val logs: List, + val logBytes: Long, + val components: List, + val flatpakApps: List, + 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 get() = tools.filter { it.state == ToolState.UNUSED } + val gamesOnSd: Int get() = games.count { !it.internal } + val unusedComponents: List 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() + val known = HashMap() + + 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? 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() + val games = ArrayList() + 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() + 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, + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt b/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt new file mode 100644 index 000000000..8be0a3e85 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt @@ -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(null) + private set + var scanning by mutableStateOf(false) + private set + var cleaning by mutableStateOf(false) + private set + var lastFreed by mutableStateOf(null) + var progress by mutableStateOf(null) + private set + var lastError by mutableStateOf(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, 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" + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 6e24c7d37..278d4c58c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -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) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index c79bb2790..b3380cd0f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -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() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt new file mode 100644 index 000000000..f212fbbb5 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt @@ -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(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(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, 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(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(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(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 }) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d6ff4de9..06968444b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2293,4 +2293,174 @@ Top Bottom Center (default) + Storage + Measuring storage… + This takes a few seconds the first time. + Internal storage + %1$s of %2$s used + %1$s free + DroidDeck uses %1$s. Measured at %2$s. + measuring again… + Other Android apps + Safe to free now + Unused runtimes, leftovers, old logs + Clean up… + Stop the session to clean up or remove anything. Sizes are still shown. + Where it goes + Steam games + Game data + Prefixes %1$s · workshop %2$s · shaders %3$s + Proton & runtimes + %1$s not used + GOG, Epic & other stores + Uninstall these from Stores + Desktop apps + Flatpak apps and runtimes + Linux system & Steam client + Needed to run anything + DroidDeck data + Old session logs %1$s · caches · drivers + Games + Game data + Proton & runtimes + Other stores + Desktop apps + Linux system + DroidDeck + Clean up + Nothing here touches your installed games or downloads in progress. + Nothing to clean up right now. + Selected: %1$s · %2$s → %3$s free + Cleaning up… + Free %1$s + Freed %1$s. + Unused Steam runtimes and Protons + Leftovers from uninstalled games + Leftovers that may hold saves + Old session logs + Keeps the current session’s logs + No game is set to it. Reinstall it from Components if you need it. + Shader caches of installed games + Rebuilt as you play. The first minutes of each game stutter again. + Safe + Not used + Stutter + Check saves + Default + Required + Not used + Storage + %1$s with their data + Largest + Least played + On SD card + No games here. + Played %1$s + Never played + Game %1$s · data %2$s + Uninstall + Uninstall opens Steam’s own dialog so Steam keeps track. To move a game to the SD card, use Steam › Settings › Storage. + Uninstall %1$s? + 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. + Open Steam + %1$s · %2$s not used + Remove unused · %1$s + In use + Not used + Remove + Remove %1$s? + Frees %1$s. Steam downloads it again if a game ever asks for it. + Needed for Linux x86 games (FEX) + Needed by the ARM64 Protons + VC++ and DirectX installers games ask for + Runs Linux x86 games + Steam tool + DroidDeck runs games without this runtime + x86 Proton, which DroidDeck cannot run + Default for every game + No game is set to it + Steam’s settings could not be read, so it is kept + Wait for the storage clean-up to finish + Only %1$s free + Steam needs room for updates and shader caches. Below 10 GB, game updates can fail partway. + %1$s can be freed safely. + Free up space + Play anyway + + %d installed + %d installed + + + %d on the SD card + %d on the SD card + + + %d tool + %d tools + + + %d game’s empty prefix folders and shader cache + %d games’ empty prefix folders and shader caches + + + %d game’s prefix still holds files, which can include local saves. Back them up first if you want them. + %d games’ prefixes still hold files, which can include local saves. Back them up first if you want them. + + + %d game + %d games + + DroidDeck’s default Proton + DroidDeck’s Proton 11 option + + Used by %d game + Used by %d games + + Components + Components + Unused components + Original %1$s · %2$s + Frees %1$s. It only restores a Proton version that is no longer installed. + Not checked: a Steam app manifest could not be read, so prefixes of uninstalled games are kept until it can. + Could not finish: %1$s + shared %1$s + Installed games + Prefix %1$s · shaders %2$s · workshop %3$s + Clear shaders + Uninstalled games + Steam app %1$d + Its prefix still holds files, which can include local saves + Deletes its Wine prefix and shader cache (%1$s). Back up any saves you want first; Steam Cloud saves stay in your account. + Kept + Non-Steam games and Steam’s shared prefix + Prefixes of games added from stores or by hand + Downloaded packages + Saved originals + No component packages downloaded. + Selected for a Proton or a game + Not selected anywhere + Restores the installed Proton + Proton version no longer installed + In use + Components in use stay locked. Choose them in Components; anything removed here can be downloaded there again. + Frees %1$s. Packages you imported yourself cannot be downloaded again. + %1$s for Flatpak apps and what they share + Apps + No Flatpak apps installed. + Shared by apps + Runtimes + Libraries the apps run on; unused ones go with Remove unused + Download store + Flatpak’s object store and cached downloads + Remove unused + Removes runtimes no installed app needs and prunes Flatpak’s object store. Installed apps keep working. + Removes the app (%1$s) and any runtime nothing else uses. Its data in your home folder stays. + + %d package + %d packages + + + %d app + %d apps + diff --git a/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt b/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt new file mode 100644 index 000000000..91c862ae9 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt @@ -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(), 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)) + } +} diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak index ca9f8ef70..d91cae6ad 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak @@ -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 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 8efdbe481..bf8207c02 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -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"