Patches: cache the repository trees on disk

The other half of the online-browser complaint. Stopping the scan when you leave
addressed the heat; this addresses the minutes.

Every search downloads four GitHub '?recursive=1' listings — multi-megabyte JSON
for repositories holding tens of thousands of pnach files — and regex-scans each
for paths. The existing caches are in-memory only, so the first search after
every launch paid the full price again, which is why it reads as broken rather
than slow.

The EXTRACTED PATH LIST is what gets cached, not the JSON: a fraction of the
size, and coming back in it skips the expensive regex entirely, which is the CPU
half of the cost rather than the network half.

Seven-day TTL. These repositories gain files occasionally and the cost of being
stale is one newly-added cheat not appearing, against re-downloading megabytes
on every cold start. Keyed by a hash of the tree URL rather than a sanitised
name, since two repositories can differ only in characters a filesystem folds.

The cache directory is handed in rather than discovered: PatchRepo is a
context-less object, so until setCacheDir runs it stays memory-only and behaves
exactly as before.
This commit is contained in:
jpolo1224
2026-08-21 12:04:09 -04:00
parent 48094495a0
commit 66aeaaeb96
2 changed files with 65 additions and 3 deletions
@@ -66,6 +66,50 @@ object PatchRepo {
"https://api.github.com/repos/XiGuanChi/PCSX2-CheatsDB/git/trees/main?recursive=1",
),
)
// ---- on-disk tree cache -------------------------------------------------------------
//
// ★ The repository trees are the whole cost of a search, and they were re-fetched from
// scratch on every cold start.
//
// Each is a GitHub `?recursive=1` listing — multi-megabyte JSON for repositories holding tens
// of thousands of pnach files — that has to be downloaded AND regex-scanned for paths. The
// in-memory caches below only survive while the process does, so the first search after every
// launch paid the full price. That is the "it takes minutes" half of the complaint, as
// distinct from the "it never stops" half.
//
// What is cached is the EXTRACTED PATH LIST, not the JSON: it is a fraction of the size and
// it skips the expensive regex on the way back in.
@Volatile private var diskCacheDir: File? = null
/// A week. These repositories gain files occasionally, and the cost of being stale is one
/// newly-added cheat not showing up — against re-downloading megabytes on every launch.
private const val TREE_CACHE_TTL_MS = 7L * 24 * 60 * 60 * 1000
/** Point the tree cache at a directory. Until this is called the caches are memory-only. */
fun setCacheDir(dir: File) {
diskCacheDir = runCatching { dir.apply { mkdirs() } }.getOrNull()
}
private fun treeCacheFile(url: String): File? {
val dir = diskCacheDir ?: return null
// Hash rather than sanitising: a URL makes an unwieldy filename and two repos can differ
// only in characters a filesystem would fold.
return File(dir, "tree-%08x.txt".format(url.hashCode()))
}
private fun readTreeCache(url: String): List<String>? {
val f = treeCacheFile(url)?.takeIf { it.isFile } ?: return null
if ((System.currentTimeMillis() - f.lastModified()) > TREE_CACHE_TTL_MS)
return null
return runCatching { f.readLines().filter { it.isNotBlank() } }.getOrNull()?.takeIf { it.isNotEmpty() }
}
private fun writeTreeCache(url: String, paths: List<String>) {
val f = treeCacheFile(url) ?: return
runCatching { f.writeText(paths.joinToString("\n")) }
.onFailure { Log.w(TAG, "tree cache write failed: ${it.message}") }
}
private val cheatTreeCache = java.util.concurrent.ConcurrentHashMap<String, List<String>>()
private val CRC_RE = Regex("^[0-9A-Fa-f]{8}$")
private val SERIAL_RE = Regex("^[A-Z]{4}-\\d{5}$")
@@ -221,11 +265,15 @@ object PatchRepo {
/** Cached file listing for a cheat source. */
private suspend fun cheatTree(src: CheatSource): List<String> {
cheatTreeCache[src.raw]?.let { return it }
readTreeCache(src.tree)?.let { cheatTreeCache[src.raw] = it; 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
if (paths.isNotEmpty()) {
cheatTreeCache[src.raw] = paths
writeTreeCache(src.tree, paths)
}
return paths
}
@@ -342,18 +390,26 @@ object PatchRepo {
/** File listing of the Gabominated patch fork, cached for the session. */
private fun gaboTree(): List<String> {
gaboTreeCache?.let { return it }
readTreeCache(GABO_TREE)?.let { gaboTreeCache = it; return it }
val json = get(GABO_TREE) ?: return emptyList()
val paths = TREE_PATH_RE.findAll(json).map { it.groupValues[1] }.toList()
if (paths.isNotEmpty()) gaboTreeCache = paths
if (paths.isNotEmpty()) {
gaboTreeCache = paths
writeTreeCache(GABO_TREE, paths)
}
return paths
}
/** File listing of the whole patch repo, cached for the session. */
private fun repoTree(): List<String> {
treeCache?.let { return it }
readTreeCache(TREE_URL)?.let { treeCache = it; return it }
val json = get(TREE_URL) ?: return emptyList()
val paths = TREE_PATH_RE.findAll(json).map { it.groupValues[1] }.toList()
if (paths.isNotEmpty()) treeCache = paths
if (paths.isNotEmpty()) {
treeCache = paths
writeTreeCache(TREE_URL, paths)
}
return paths
}
@@ -55,6 +55,12 @@ data class PatchManagerUiState(
)
class PatchManagerViewModel(application: Application) : AndroidViewModel(application) {
init {
// Point the repository-tree cache at app storage. Until this runs the caches are
// memory-only, which is what made every cold start re-download and re-parse megabytes.
com.armsx2.PatchRepo.setCacheDir(java.io.File(application.cacheDir, "patch-trees"))
}
/** The in-flight online scan, if any. See searchOnline for why this is tracked. */
private var onlineSearchJob: kotlinx.coroutines.Job? = null