fix: stored components can be deleted after a Proton update (#1434)

After a Proton update, stored DXVK / VKD3D / FEX packages can't be
deleted. Components says they're "in use" by a Proton that's gone
(`DroidDeck-Proton_11-9.1` after updating to 11-11.1) or by a bare
number (`4005446849`).

## Cause
`deletePackage` and the Storage page's in-use check count every recorded
selection:
- **Removed Protons:** swaps on Protons that are no longer installed.
- **Out-of-date swaps:** swaps from before the Proton's version changed.
The launcher already ignores these.
- **Game profiles:** a per-game pick blocked the delete with the "swap
that Proton" message, naming the game's id.

## Fix
- **Out-of-date selections:** a selection only counts while its Proton
is installed and at the version the swap was made on. That's the rule
launches already use. Deleting a package removes its stale entries.
- **Profile picks:** deleting a package also clears it from any game's
component profile, so those games go back to the Proton's own copy.
Storage cleanup still keeps profile picks.
- **Live swaps:** a package swapped into an installed Proton is still
blocked. The message now shows the Proton's name rather than its id.

No new strings.

## Testing
On a REDMAGIC NP06J, with the state from the reports recreated:

| Case | main | this branch |
|---|---|---|
| DXVK swapped into a removed `DroidDeck-Proton_11-9.1` | Deleting
failed: in use in DroidDeck-Proton_11-9.1 | Deleted, stale entry removed
|
| FEX picked in game profile `4005446849` | Deleting failed: in use in
4005446849 | Deleted, profile cleared |
| DXVK swapped into installed Proton Experimental | blocked | blocked:
in use in Proton Experimental (ARM64) |

<img
src="https://raw.githubusercontent.com/maxjivi05/DroidDeck/pr-media/1434/main-stale-proton.jpg"
width="420"> <img
src="https://raw.githubusercontent.com/maxjivi05/DroidDeck/pr-media/1434/main-game-profile.jpg"
width="420">
<img
src="https://raw.githubusercontent.com/maxjivi05/DroidDeck/pr-media/1434/fix-game-profile.jpg"
width="420"> <img
src="https://raw.githubusercontent.com/maxjivi05/DroidDeck/pr-media/1434/fix-live-blocked.jpg"
width="420">

`ComponentsSelectionTest` covers removed, out-of-date and live
selections.

## Credits
Reported on Discord by Siigonis, Bado and woodrow1712.
This commit is contained in:
MaxsTechReview authored and GitHub committed 2026-10-23 19:13:29 -04:00
commit 0d8b5a7cf2
2 files changed
+69 -19

No files matched your search

@@ -721,13 +721,19 @@ object ComponentsManager {
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> {
internal fun liveSelection(key: String, entry: JSONObject, proton: Proton?): Boolean =
proton != null && (key == "queued" || entry.optString("protonVersion") == proton.version)
private fun packagesInUse(state: JSONObject, installed: Map<String, Proton>): 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)
for (comp in comps.keys()) {
val entry = comps.optJSONObject(comp) ?: continue
if (liveSelection(key, entry, installed[pid])) entry.optString("file").takeIf { it.isNotEmpty() }?.let(names::add)
}
}
}
val profiles = state.optJSONObject("profiles")
@@ -747,8 +753,8 @@ object ComponentsManager {
fun stored(context: Context): Stored = synchronized(lock) {
val state = loadState(context)
val used = packagesInUse(state)
val installed = protons(context).associateBy { it.id }
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
val used = packagesInUse(state, installed)
val originals = originalsDir(context).listFiles()?.filter { it.isDirectory }?.flatMap { proton ->
proton.listFiles()?.filter { it.isDirectory }?.flatMap { version ->
COMPONENTS.mapNotNull { comp ->
@@ -777,24 +783,36 @@ object ComponentsManager {
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
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
}
val profiles = state.sub("profiles")
for (scope in profiles.keys()) {
val profile = profiles.optJSONObject(scope) ?: continue
check(COMPONENTS.none { profile.optString(it) == name }) { context.getString(R.string.cmgr_package_in_use, name, scope) }
val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
var changed = false
for (key in listOf("queued", "active")) {
val byProton = state.sub(key)
for (pid in byProton.keys().asSequence().toList()) {
val comps = byProton.optJSONObject(pid) ?: continue
for (comp in comps.keys().asSequence().toList()) {
val entry = comps.optJSONObject(comp) ?: continue
if (entry.optString("file") != name) continue
check(!liveSelection(key, entry, installed[pid])) { context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid) }
comps.remove(comp)
changed = true
}
if (comps.length() == 0) byProton.remove(pid)
}
}
val f = File(packagesDir(context), name)
check(f.isFile) { context.getString(R.string.cmgr_package_not_stored, name) }
f.delete()
val profiles = state.sub("profiles")
for (scope in profiles.keys().asSequence().toList()) {
val profile = profiles.optJSONObject(scope) ?: continue
for (comp in COMPONENTS.filter { profile.optString(it) == name }) {
rememberProfiledComponent(state, comp)
profile.remove(comp)
changed = true
}
if (profile.length() == 0) profiles.remove(scope)
}
check(f.delete()) { name }
if (changed) saveState(context, state)
context.getString(R.string.cmgr_deleted, name)
}
@@ -0,0 +1,32 @@
package com.droiddeck.launcher.session
import org.json.JSONObject
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
@RunWith(RobolectricTestRunner::class)
class ComponentsSelectionTest {
private val proton = ComponentsManager.Proton("DroidDeck-Proton_11-11.1", "DroidDeck-Proton 11-11.1", File("/p"), "/p", "DroidDeck-Proton-11-11.1", false)
private val entry = JSONObject().put("file", "dxvk-low-latency-arm64ec-3.1.1-2-linux.wcp").put("protonVersion", "DroidDeck-Proton-11-11.1")
@Test
fun `a package swapped into an installed Proton is in use`() {
assertTrue(ComponentsManager.liveSelection("active", entry, proton))
assertTrue(ComponentsManager.liveSelection("queued", JSONObject().put("file", "x.wcp"), proton))
}
@Test
fun `a package left on a removed Proton is not in use`() {
assertFalse(ComponentsManager.liveSelection("active", entry, null))
assertFalse(ComponentsManager.liveSelection("queued", entry, null))
}
@Test
fun `a package from before a Proton update is not in use`() {
assertFalse(ComponentsManager.liveSelection("active", JSONObject(entry.toString()).put("protonVersion", "DroidDeck-Proton-11-9.1"), proton))
}
}