diff --git a/platforms/android/app/src/main/java/com/armsx2/PatchRepo.kt b/platforms/android/app/src/main/java/com/armsx2/PatchRepo.kt index fb10edb59d..1818859040 100644 --- a/platforms/android/app/src/main/java/com/armsx2/PatchRepo.kt +++ b/platforms/android/app/src/main/java/com/armsx2/PatchRepo.kt @@ -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>? { + private suspend fun fetchCheats(serial: String?, crc: String): Pair>? { val c = crc.uppercase() val s = serial?.uppercase() val haveCrc = CRC_RE.matches(c) @@ -180,6 +197,9 @@ object PatchRepo { val entries = mutableListOf() val seenNames = HashSet() 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 { + private suspend fun cheatTree(src: CheatSource): List { 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 `_.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.") diff --git a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt index cb2fffd6c5..2ce755929a 100644 --- a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt +++ b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt @@ -1003,6 +1003,7 @@ private val BASE_EN: Map = 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", diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/patches/PatchManagerScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/patches/PatchManagerScreen.kt index 418f4dbd2d..5fd8a64f1b 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/patches/PatchManagerScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/patches/PatchManagerScreen.kt @@ -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) }, diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/patches/PatchManagerViewModel.kt b/platforms/android/app/src/main/java/com/armsx2/ui/patches/PatchManagerViewModel.kt index c3e7fc4766..c0b97d60a3 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/patches/PatchManagerViewModel.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/patches/PatchManagerViewModel.kt @@ -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