Patches: stop the online scan when you leave the browser

Reported by SNAKEATEROP (Helio G99): after using the online cheats/patches
browser, going back to the game left the device heating severely and a game that
had held full speed no longer did. Nothing in the emulator explained it.

The scan was UNSTOPPABLE, not merely slow. PatchRepo's fetch functions were
plain blocking calls with no isActive check, no ensureActive and not even
suspend. Kotlin cancellation is cooperative, so cancelling the scope did
nothing: the work ran to completion no matter what the user did. It walks four
community repositories, each a multi-megabyte GitHub tree that is downloaded and
then regex-scanned for paths — that is the CPU the game was competing with, and
it kept going long after anyone was looking at it.

Three parts:

  · PatchRepo's entry points are suspend and check for cancellation between
    every repository and every file. Between SOURCES is the one that matters —
    that is where the time goes.
  · The scan's Job is tracked, so a second search cannot stack on the first, and
    the browser cancels it in onDispose. viewModelScope alone was not enough:
    the ViewModel is Activity-scoped and shared with the settings tab, so it
    does not clear merely because the user went back to the game — which is
    exactly the case that was reported.
  · The progress text now says it takes a minute or two AND that leaving is
    safe. Users assumed it had hung, and several were told to just wait; nobody
    should have to sit through it to protect their device.

This does not make the scan faster. It makes it stop, which is the part that was
damaging. Caching the repository trees on disk is the fix for the duration —
they are re-downloaded and re-parsed on every cold start today — and is worth
doing next.
This commit is contained in:
jpolo1224
2026-08-21 11:51:32 -04:00
parent 879d07209c
commit 48094495a0
4 changed files with 92 additions and 9 deletions
@@ -2,6 +2,8 @@
package com.armsx2
import android.util.Log
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kr.co.iefriends.pcsx2.HttpClient
import java.io.File
@@ -104,7 +106,20 @@ object PatchRepo {
* offline patch DB (resources/patches.zip) — read FIRST so patches resolve even when
* the booted CRC isn't a DB filename or the network is rate-limited; the network below
* only supplements. Without it, the in-game manager showed cheats but no patches. */
fun fetchForGame(serial: String?, crc: String, bundledZip: File? = null): Result {
/**
* ★ SUSPEND, and it checks for cancellation between every network call.
*
* These were plain blocking functions. Kotlin cancellation is cooperative, so code that never
* suspends and never checks isActive cannot be stopped — cancelling the viewModelScope did
* nothing and the scan ran to completion regardless. Leaving the browser and going back to the
* game therefore left four repository trees still downloading and being regex-scanned behind
* the emulator, which is what users reported as sudden severe heating and a game that would no
* longer hold full speed (SNAKEATEROP, Helio G99).
*
* Every loop below now yields that ability back. It does not make the scan faster; it makes it
* STOP.
*/
suspend fun fetchForGame(serial: String?, crc: String, bundledZip: File? = null): Result {
val c = crc.trim().uppercase()
if (!CRC_RE.matches(c))
return Result("", emptyList(), "No game CRC yet — boot the game first.")
@@ -130,6 +145,7 @@ object PatchRepo {
add(c)
}
for (name in patchCandidates) {
currentCoroutineContext().ensureActive()
val text = get("$RAW_BASE/patches/$name.pnach") ?: continue
val (gt, es) = parse(text, "patches")
if (gametitle.isEmpty()) gametitle = gt
@@ -141,6 +157,7 @@ object PatchRepo {
// Also merge improvement patches from the Gabominated compilation (No-Blur etc.),
// skipping any whose name already came from the official DB.
for (name in patchCandidates) {
currentCoroutineContext().ensureActive()
val text = get("$GABO_BASE/$GABO_DIR/$name.pnach") ?: continue
val (gt, es) = parse(text, "patches")
if (gametitle.isEmpty()) gametitle = gt
@@ -170,7 +187,7 @@ object PatchRepo {
/** Fetch + parse community cheats for a game across all sources. Matches
* each repo's tree by CRC (exact) first, then by serial as a fallback;
* dedupes entries by normalized name (earlier sources win). Null if nothing found. */
private fun fetchCheats(serial: String?, crc: String): Pair<String, List<Entry>>? {
private suspend fun fetchCheats(serial: String?, crc: String): Pair<String, List<Entry>>? {
val c = crc.uppercase()
val s = serial?.uppercase()
val haveCrc = CRC_RE.matches(c)
@@ -180,6 +197,9 @@ object PatchRepo {
val entries = mutableListOf<Entry>()
val seenNames = HashSet<String>()
for (src in CHEAT_SOURCES) {
// Between sources: four repositories, each a multi-megabyte tree. This is the check
// that matters most — it is where the bulk of the time goes.
currentCoroutineContext().ensureActive()
val tree = cheatTree(src)
if (tree.isEmpty()) continue
var matches = if (haveCrc)
@@ -188,6 +208,7 @@ object PatchRepo {
if (matches.isEmpty() && s != null)
matches = tree.filter { it.substringAfterLast('/').uppercase().startsWith("${s}_") }
for (m in matches) {
currentCoroutineContext().ensureActive()
val text = get("${src.raw}/${m.replace(" ", "%20")}") ?: continue
val (gt, es) = parse(text, "cheats")
if (gametitle.isEmpty()) gametitle = gt
@@ -198,9 +219,11 @@ object PatchRepo {
}
/** Cached file listing for a cheat source. */
private fun cheatTree(src: CheatSource): List<String> {
private suspend fun cheatTree(src: CheatSource): List<String> {
cheatTreeCache[src.raw]?.let { return it }
currentCoroutineContext().ensureActive()
val json = get(src.tree) ?: return emptyList()
currentCoroutineContext().ensureActive() // the regex below is the CPU-heavy part
val paths = TREE_PATH_RE.findAll(json).map { it.groupValues[1] }.toList()
if (paths.isNotEmpty()) cheatTreeCache[src.raw] = paths
return paths
@@ -210,7 +233,7 @@ object PatchRepo {
* booted, where we have the serial but not the disc CRC. Looks the game up
* in the repo file tree to find its `<serial>_<crc>.pnach`; the CRC comes
* back in [Result.crc] so the caller can name the saved file correctly. */
fun fetchForSerial(serial: String?, bundledZip: File? = null): Result {
suspend fun fetchForSerial(serial: String?, bundledZip: File? = null): Result {
val s = serial?.trim()?.uppercase()
if (s.isNullOrBlank() || !SERIAL_RE.matches(s))
return Result("", emptyList(), "This game has no serial to search the patch database with.")
@@ -1003,6 +1003,7 @@ private val BASE_EN: Map<String, String> = mapOf(
"patches.online.header" to "Browse online",
"patches.online.fetch" to "Find patches & cheats for this game",
"patches.online.loading" to "Searching the community repos…",
"patches.online.loading.hint" to "This searches several community repositories and can take a minute or two. You can leave this screen — the search stops when you do, so it won\u2019t slow the game down.",
"patches.online.install" to "Install selected",
"patches.section.patches" to "Patches",
"patches.section.cheats" to "Cheats",
@@ -61,6 +61,16 @@ import java.io.File
@Composable
fun PatchManagerScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: PatchManagerViewModel = viewModel()) {
val state = viewModel.state.value
// ★ Stop any online scan when this leaves the screen.
//
// The scan downloads and regex-scans four multi-megabyte repository trees. Left running it
// competes with the emulator for CPU, which on a low-end device costs full speed and heats
// the phone badly — reported on a Helio G99, where returning to the game did not stop it.
// The ViewModel is Activity-scoped and shared with the settings tab, so it does NOT clear
// just because the user navigated away; this is what actually ends the work.
androidx.compose.runtime.DisposableEffect(Unit) {
onDispose { viewModel.cancelOnlineSearch() }
}
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> uri?.let(viewModel::import) }
// Folder import: cheats arrive as a folder of files far more often than one at a time, and the
// single-file picker made adding a set a repetitive chore. Requested by Fun (SD712).
@@ -226,6 +236,16 @@ private fun PatchDisclaimer() {
@Composable
fun PatchesSettingsTab(game: GameInfo? = null, viewModel: PatchManagerViewModel = viewModel()) {
val state = viewModel.state.value
// ★ Stop any online scan when this leaves the screen.
//
// The scan downloads and regex-scans four multi-megabyte repository trees. Left running it
// competes with the emulator for CPU, which on a low-end device costs full speed and heats
// the phone badly — reported on a Helio G99, where returning to the game did not stop it.
// The ViewModel is Activity-scoped and shared with the settings tab, so it does NOT clear
// just because the user navigated away; this is what actually ends the work.
androidx.compose.runtime.DisposableEffect(Unit) {
onDispose { viewModel.cancelOnlineSearch() }
}
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> uri?.let(viewModel::import) }
// Keyed on the game, not Unit: the scope switch above hands this tab a different game
// (the game, or null for Global), and refresh() is what re-reads the tier. Keyed on
@@ -339,10 +359,23 @@ private fun OnlineBrowser(
val forThisGame = state.onlineForGameKey == (game?.uri?.toString() ?: "")
val entries = if (forThisGame) state.onlineEntries else emptyList()
when {
state.onlineLoading && forThisGame -> Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(10.dp))
Text(str("patches.online.loading"))
// Says how long, and that leaving is safe. The scan reads four community
// repositories — tens of thousands of files between them — so a minute or two is
// normal, and users reported assuming it had hung. The second line matters as
// much as the first: the search now stops when this screen closes, so nobody has
// to sit and wait to protect their device.
state.onlineLoading && forThisGame -> Column {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(10.dp))
Text(str("patches.online.loading"))
}
Spacer(Modifier.height(6.dp))
Text(
str("patches.online.loading.hint"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
entries.isEmpty() -> Button(
onClick = { viewModel.fetchOnline(game) },
@@ -55,6 +55,23 @@ data class PatchManagerUiState(
)
class PatchManagerViewModel(application: Application) : AndroidViewModel(application) {
/** The in-flight online scan, if any. See searchOnline for why this is tracked. */
private var onlineSearchJob: kotlinx.coroutines.Job? = null
/**
* Stop any online scan in progress.
*
* Called when the browser leaves the screen. Cancellation is cooperative and PatchRepo now
* checks between every repository and every file, so this takes effect within one request
* rather than at the end of the whole scan.
*/
fun cancelOnlineSearch() {
onlineSearchJob?.cancel()
onlineSearchJob = null
if (state.value.onlineLoading)
state.value = state.value.copy(onlineLoading = false)
}
private companion object {
/** A user can hand us a storage root by accident; an unbounded SAF walk of that is a hang. */
const val MAX_IMPORT_DEPTH = 4
@@ -335,7 +352,16 @@ class PatchManagerViewModel(application: Application) : AndroidViewModel(applica
state.value = state.value.copy(
onlineLoading = true, error = null, onlineEntries = emptyList(), onlineForGameKey = gameKey,
)
viewModelScope.launch {
// ★ Tracked so it can be STOPPED, and so a second search cannot stack on the first.
//
// The scan walks four repositories, each a multi-megabyte GitHub tree that is downloaded
// and regex-scanned. Left running behind the emulator that is enough CPU to cost a
// low-end device full speed and heat it badly — reported on a Helio G99, where going
// back to the game did not stop it. viewModelScope alone was not enough: it only
// cancels when the ViewModel clears, which does not happen merely because the user
// returned to the game.
onlineSearchJob?.cancel()
onlineSearchJob = viewModelScope.launch {
// Serial priority: the library's (filename-derived) serial, then the running
// game's serial, then — for a plainly-named file whose filename yielded no
// serial and that isn't running — read it straight off the disc image