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