From 34a1c2d57cf246cbca9bd7a0493cf1ff8a0abc8b Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 23 Apr 2026 14:11:50 -0400 Subject: [PATCH 01/10] feat: add How Long To Beat stats to game pages --- .../main/java/app/gamenative/PrefManager.kt | 8 + .../app/gamenative/ui/data/GameDisplayInfo.kt | 12 +- .../ui/screen/library/LibraryAppScreen.kt | 79 ++++ .../screen/library/appscreen/BaseAppScreen.kt | 27 +- .../java/app/gamenative/utils/HltbService.kt | 412 ++++++++++++++++++ app/src/main/res/values/strings.xml | 10 + 6 files changed, 546 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/app/gamenative/utils/HltbService.kt diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 19e686faa2..15865baf5b 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -1175,6 +1175,14 @@ object PrefManager { setPref(GAME_COMPATIBILITY_CACHE, value) } + // HLTB cache (JSON string) + private val HLTB_CACHE = stringPreferencesKey("hltb_cache") + var hltbCache: String + get() = getPref(HLTB_CACHE, "{}") + set(value) { + setPref(HLTB_CACHE, value) + } + /* Security / Attestation */ private val KEY_ATTESTATION_AVAILABLE = booleanPreferencesKey("key_attestation_available") var keyAttestationAvailable: Boolean diff --git a/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt b/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt index 8aead2a8bf..279db138f5 100644 --- a/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt +++ b/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt @@ -22,5 +22,15 @@ data class GameDisplayInfo( val headerUrl: String? = null, // Header image URL (for SteamGridDB, can use grid as header) val compatibilityMessage: String? = null, // Compatibility message text (e.g., "Works on your GPU") val compatibilityColor: ULong? = null, // Compatibility message color (ARGB) -) + val hltbStats: HltbStats? = null, // How Long To Beat stats +) { + /** HLTB stats to display on game pages. */ + data class HltbStats( + val mainHours: String, + val mainPlusHours: String, + val completeHours: String, + val allStylesHours: String, + val gameId: Int, + ) +} diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index c1e82ccafe..fe9b3194f1 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -88,6 +88,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -287,6 +288,42 @@ private fun PrimaryActionButton( } +/** + * HLTB stat card showing a completion tier with hours value. + */ +@Composable +private fun HltbStatCard( + label: String, + hours: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shadowElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = if (hours == "--") "--" else "${hours}h", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ) + } + } +} + /** * Icon-only action button for the overlay action bar */ @@ -1076,6 +1113,48 @@ internal fun AppScreenContent( } } } + + // HLTB (How Long To Beat) stats + val hltb = displayInfo.hltbStats + if (hltb != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.hltb_title), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(bottom = 12.dp), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + HltbStatCard( + label = stringResource(R.string.hltb_main_story), + hours = hltb.mainHours, + modifier = Modifier.weight(1f), + ) + HltbStatCard( + label = stringResource(R.string.hltb_main_plus_extras), + hours = hltb.mainPlusHours, + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + HltbStatCard( + label = stringResource(R.string.hltb_completionist), + hours = hltb.completeHours, + modifier = Modifier.weight(1f), + ) + HltbStatCard( + label = stringResource(R.string.hltb_all_styles), + hours = hltb.allStylesHours, + modifier = Modifier.weight(1f), + ) + } + } } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index b9cd561732..f99575b900 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt @@ -895,9 +895,34 @@ abstract class BaseAppScreen { onBack: () -> Unit, ) { val context = LocalContext.current - val displayInfo = getGameDisplayInfo(context, libraryItem) + val displayInfoBase = getGameDisplayInfo(context, libraryItem) val appId = libraryItem.appId + // Fetch HLTB stats asynchronously + var hltbStats by remember(displayInfoBase.name) { + mutableStateOf(null) + } + val hltbScope = rememberCoroutineScope() + LaunchedEffect(displayInfoBase.name) { + if (displayInfoBase.name.isNotBlank()) { + try { + val stats = app.gamenative.utils.HltbService.getStats(displayInfoBase.name) + if (stats != null) { + hltbStats = GameDisplayInfo.HltbStats( + mainHours = stats.mainHours, + mainPlusHours = stats.mainPlusHours, + completeHours = stats.completeHours, + allStylesHours = stats.allStylesHours, + gameId = stats.gameId, + ) + } + } catch (_: Exception) { + // HLTB is best-effort; don't crash on failure + } + } + } + val displayInfo = displayInfoBase.copy(hltbStats = hltbStats) + // Use composable state for values that change over time var isInstalledState by remember(libraryItem.appId) { mutableStateOf(isInstalled(context, libraryItem)) diff --git a/app/src/main/java/app/gamenative/utils/HltbService.kt b/app/src/main/java/app/gamenative/utils/HltbService.kt new file mode 100644 index 0000000000..4074b08767 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/HltbService.kt @@ -0,0 +1,412 @@ +package app.gamenative.utils + +import app.gamenative.PrefManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import timber.log.Timber + +/** + * Service for fetching HowLongToBeat (HLTB) game time statistics. + * + * Inspired by the hltb-for-deck Steam Deck plugin (https://github.com/morwy/hltb-for-deck). + * The HLTB website uses a Next.js frontend with a POST-based search API and a + * separate auth/bootstrap flow. This service implements a simplified version: + * + * 1. Fetch the HLTB homepage HTML to discover the Next.js build key. + * 2. Search for the game by name using the HLTB search API. + * 3. Fetch detailed stats via the Next.js data endpoint. + * + * Results are cached in memory and persisted to DataStore with a 12-hour TTL. + */ +object HltbService { + private const val HLTB_BASE_URL = "https://howlongtobeat.com" + private const val DEFAULT_SEARCH_PATH = "/api/find" + + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + private val httpClient = Net.http + + // --- Data models --- + + @Serializable + data class HltbGameStats( + val mainHours: String, // Main Story + val mainPlusHours: String, // Main + Extras + val completeHours: String, // Completionist + val allStylesHours: String, // All Styles + val gameId: Int, // HLTB game ID for linking + val timestamp: Long = System.currentTimeMillis(), + ) + + // --- Bootstrap / Search URL discovery --- + + private data class BootstrapState( + var searchPath: String = DEFAULT_SEARCH_PATH, + var nextJsKey: String? = null, + var lastBootstrapTime: Long = 0, + ) + + private val bootstrap = BootstrapState() + + /** + * Ensure bootstrap data is fresh (refresh every 6 hours). + */ + private suspend fun ensureBootstrapped() { + val now = System.currentTimeMillis() + if (bootstrap.nextJsKey != null && now - bootstrap.lastBootstrapTime < 6 * 60 * 60 * 1000L) { + return + } + bootstrapHomepage() + } + + /** + * Fetch the HLTB homepage to discover the Next.js build key used in + * data-fetch URLs like `/_next/data//game/.json`. + */ + private suspend fun bootstrapHomepage() = withContext(Dispatchers.IO) { + try { + val request = Request.Builder() + .url(HLTB_BASE_URL) + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + .header("Accept", "text/html") + .build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Timber.tag("HLTB").w("Bootstrap failed: HTTP ${response.code}") + return@withContext + } + + val html = response.body?.string() ?: return@withContext + + // Find the Next.js build key from script sources + val keyPattern = Regex("""/_next/static/([^/]+)/(_ssgManifest|_buildManifest)\.js""") + val keyMatch = keyPattern.find(html) + if (keyMatch != null) { + bootstrap.nextJsKey = keyMatch.groupValues[1] + Timber.tag("HLTB").d("Discovered Next.js key: ${bootstrap.nextJsKey}") + } + + // Find the search API path from inline script references + val searchPattern = Regex("""fetch\s*\(\s*["']/api/([a-zA-Z0-9_/]+)""") + val searchMatch = searchPattern.find(html) + if (searchMatch != null) { + val suffix = searchMatch.groupValues[1] + val basePath = if (suffix.contains("/")) suffix.substringBefore("/") else suffix + bootstrap.searchPath = "/api/$basePath" + Timber.tag("HLTB").d("Discovered search path: ${bootstrap.searchPath}") + } + } + + bootstrap.lastBootstrapTime = System.currentTimeMillis() + } catch (e: Exception) { + Timber.tag("HLTB").e(e, "Bootstrap error") + } + } + + // --- API calls --- + + /** + * Search HLTB for a game by name, returning the best-matching game ID. + */ + private suspend fun searchGame(gameName: String): Int? = withContext(Dispatchers.IO) { + ensureBootstrapped() + + try { + val searchTerms = gameName.split(" ") + val postData = JSONObject().apply { + put("searchType", "games") + put("searchTerms", org.json.JSONArray(searchTerms)) + put("searchPage", 1) + put("size", 20) + put("searchOptions", JSONObject().apply { + put("games", JSONObject().apply { + put("userId", 0) + put("platform", "") + put("sortCategory", "name") + put("rangeCategory", "main") + put("modifier", "hide_dlc") + }) + put("users", JSONObject()) + put("filter", "") + put("sort", 0) + put("randomizer", 0) + }) + } + + val mediaType = "application/json".toMediaType() + val body = postData.toString().toRequestBody(mediaType) + + val request = Request.Builder() + .url("$HLTB_BASE_URL${bootstrap.searchPath}") + .post(body) + .header("Content-Type", "application/json") + .header("Origin", HLTB_BASE_URL) + .header("Referer", "$HLTB_BASE_URL/") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + .build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Timber.tag("HLTB").w("Search failed for '$gameName': HTTP ${response.code}") + return@withContext null + } + + val responseBody = response.body?.string() ?: return@withContext null + val json = JSONObject(responseBody) + val dataArray = json.optJSONArray("data") ?: return@withContext null + + if (dataArray.length() == 0) { + Timber.tag("HLTB").d("No search results for '$gameName'") + return@withContext null + } + + val normalizedQuery = normalize(gameName) + var bestId: Int? = null + var bestDistance = Int.MAX_VALUE + var bestPopularity = 0 + + for (i in 0 until dataArray.length()) { + val item = dataArray.getJSONObject(i) + val gameId = item.optInt("game_id", 0) + val rawName = item.optString("game_name", "") + val compCount = item.optInt("comp_all_count", 0) + val normalizedName = normalize(rawName) + + // Exact match wins immediately + if (normalizedName == normalizedQuery) { + return@withContext gameId + } + + // Levenshtein-like distance via simple comparison + val distance = levenshteinDistance(normalizedQuery, normalizedName) + if (distance < bestDistance || (distance == bestDistance && compCount > bestPopularity)) { + bestDistance = distance + bestPopularity = compCount + bestId = gameId + } + } + + Timber.tag("HLTB").d("Best match for '$gameName': HLTB ID $bestId (distance $bestDistance)") + return@withContext bestId + } + } catch (e: Exception) { + Timber.tag("HLTB").e(e, "Search error for '$gameName'") + return@withContext null + } + } + + /** + * Fetch detailed game stats from the HLTB Next.js data endpoint. + */ + private suspend fun fetchGameStats(gameId: Int): HltbGameStats? = withContext(Dispatchers.IO) { + ensureBootstrapped() + + val key = bootstrap.nextJsKey + if (key == null) { + Timber.tag("HLTB").w("No Next.js key available for game $gameId") + return@withContext null + } + + try { + val url = "$HLTB_BASE_URL/_next/data/$key/game/$gameId.json" + val request = Request.Builder() + .url(url) + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + .header("Referer", "$HLTB_BASE_URL/") + .build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Timber.tag("HLTB").w("Game data fetch failed for $gameId: HTTP ${response.code}") + // Key might be stale, clear it + bootstrap.nextJsKey = null + return@withContext null + } + + val responseBody = response.body?.string() ?: return@withContext null + val json = JSONObject(responseBody) + + val gameDataList = json + .optJSONObject("pageProps") + ?.optJSONObject("game") + ?.optJSONObject("data") + ?.optJSONArray("game") + + if (gameDataList == null || gameDataList.length() != 1) { + Timber.tag("HLTB").w("Unexpected game data structure for $gameId") + return@withContext null + } + + val gameData = gameDataList.getJSONObject(0) + + val compMain = gameData.optLong("comp_main", 0) + val compPlus = gameData.optLong("comp_plus", 0) + val comp100 = gameData.optLong("comp_100", 0) + val compAll = gameData.optLong("comp_all", 0) + + val stats = HltbGameStats( + mainHours = formatSeconds(compMain), + mainPlusHours = formatSeconds(compPlus), + completeHours = formatSeconds(comp100), + allStylesHours = formatSeconds(compAll), + gameId = gameId, + ) + + Timber.tag("HLTB").i("Fetched stats for $gameId: Main=${stats.mainHours}h, Main+=${stats.mainPlusHours}h, 100%=${stats.completeHours}h") + return@withContext stats + } + } catch (e: Exception) { + Timber.tag("HLTB").e(e, "Error fetching game stats for $gameId") + return@withContext null + } + } + + // --- Public API --- + + /** + * Fetch HLTB stats for a game. Uses cache when available (12h TTL). + * + * @param gameName The name of the game to look up. + * @return HLTB stats, or null if not found / on error. + */ + suspend fun getStats(gameName: String): HltbGameStats? { + if (gameName.isBlank()) return null + + // Check cache first + val cached = HltbCache.get(gameName) + if (cached != null) return cached + + // Search and fetch + val hltbId = searchGame(gameName) ?: return null + val stats = fetchGameStats(hltbId) ?: return null + + HltbCache.put(gameName, stats) + return stats + } + + // --- Utility --- + + /** + * Convert seconds (as used by HLTB API) to hours string. + * HLTB stores comp_main etc. in seconds. + */ + private fun formatSeconds(seconds: Long): String { + if (seconds <= 0) return "--" + val hours = seconds / 3600.0 + return String.format("%.1f", hours) + } + + /** + * Normalize a game name for matching (lowercase, strip special chars). + */ + private fun normalize(str: String): String { + return str.lowercase() + .replace(Regex("[^\\p{L}\\p{N}]"), " ") + .replace(Regex("\\s+"), " ") + .trim() + } + + /** + * Simple Levenshtein distance for fuzzy matching. + */ + private fun levenshteinDistance(a: String, b: String): Int { + if (a == b) return 0 + if (a.isEmpty()) return b.length + if (b.isEmpty()) return a.length + + val dp = Array(a.length + 1) { IntArray(b.length + 1) } + for (i in 0..a.length) dp[i][0] = i + for (j in 0..b.length) dp[0][j] = j + + for (i in 1..a.length) { + for (j in 1..b.length) { + val cost = if (a[i - 1] == b[j - 1]) 0 else 1 + dp[i][j] = minOf( + dp[i - 1][j] + 1, + dp[i][j - 1] + 1, + dp[i - 1][j - 1] + cost, + ) + } + } + return dp[a.length][b.length] + } +} + +/** + * Persistent cache for HLTB game stats with a 12-hour TTL. + */ +object HltbCache { + private const val CACHE_TTL_MS = 12 * 60 * 60 * 1000L // 12 hours + + private val inMemoryCache = mutableMapOf() + private var cacheLoaded = false + + private val json = Json { ignoreUnknownKeys = true } + + @Serializable + data class CachedEntry( + val stats: HltbService.HltbGameStats, + val timestamp: Long, + ) + + private fun loadCache() { + if (cacheLoaded) return + try { + val cacheJson = PrefManager.hltbCache + if (cacheJson.isEmpty() || cacheJson == "{}") { + cacheLoaded = true + return + } + val cacheMap = json.decodeFromString>(cacheJson) + val now = System.currentTimeMillis() + cacheMap.forEach { (name, entry) -> + if (now - entry.timestamp < CACHE_TTL_MS) { + inMemoryCache[name] = entry.stats + } + } + Timber.tag("HLTBCache").d("Loaded ${inMemoryCache.size} cached entries") + cacheLoaded = true + } catch (e: Exception) { + Timber.tag("HLTBCache").e(e, "Failed to load cache") + cacheLoaded = true + } + } + + private fun saveCache() { + try { + val now = System.currentTimeMillis() + val cacheMap = inMemoryCache.mapValues { (_, stats) -> + CachedEntry(stats, now) + } + PrefManager.hltbCache = json.encodeToString(cacheMap) + } catch (e: Exception) { + Timber.tag("HLTBCache").e(e, "Failed to save cache") + } + } + + fun get(gameName: String): HltbService.HltbGameStats? { + loadCache() + return inMemoryCache[normalize(gameName)] + } + + fun put(gameName: String, stats: HltbService.HltbGameStats) { + loadCache() + inMemoryCache[normalize(gameName)] = stats + saveCache() + } + + private fun normalize(str: String): String { + return str.lowercase() + .replace(Regex("[^\\p{L}\\p{N}]"), " ") + .replace(Regex("\\s+"), " ") + .trim() + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index da5688df77..6c81240f2a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1445,4 +1445,14 @@ Overwhelmingly Negative Show game recommendations Show curated indie game picks in your library. Keeping this on helps support indie developers and GameNative. + + + How Long To Beat + Main Story + Main + Extras + Completionist + All Styles + %1$s hours + View on HLTB + No data available From 3e3a69d4c0a4a7dd6d3fed51b237a9607f812750 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 23 Apr 2026 14:57:58 -0400 Subject: [PATCH 02/10] fix: use HttpURLConnection for HLTB search POST to avoid CDN 404 --- .../screen/library/appscreen/BaseAppScreen.kt | 1 + .../java/app/gamenative/utils/HltbService.kt | 474 ++++++++++-------- 2 files changed, 265 insertions(+), 210 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index f99575b900..61a6eea4c7 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt @@ -905,6 +905,7 @@ abstract class BaseAppScreen { val hltbScope = rememberCoroutineScope() LaunchedEffect(displayInfoBase.name) { if (displayInfoBase.name.isNotBlank()) { + Timber.tag("HLTB").d("Fetching stats for '${displayInfoBase.name}'") try { val stats = app.gamenative.utils.HltbService.getStats(displayInfoBase.name) if (stats != null) { diff --git a/app/src/main/java/app/gamenative/utils/HltbService.kt b/app/src/main/java/app/gamenative/utils/HltbService.kt index 4074b08767..c281ca0d6b 100644 --- a/app/src/main/java/app/gamenative/utils/HltbService.kt +++ b/app/src/main/java/app/gamenative/utils/HltbService.kt @@ -6,22 +6,25 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import timber.log.Timber +import java.net.HttpURLConnection +import java.net.URL /** * Service for fetching HowLongToBeat (HLTB) game time statistics. * - * Inspired by the hltb-for-deck Steam Deck plugin (https://github.com/morwy/hltb-for-deck). - * The HLTB website uses a Next.js frontend with a POST-based search API and a - * separate auth/bootstrap flow. This service implements a simplified version: + * Ported from the hltb-for-deck Steam Deck plugin (https://github.com/morwy/hltb-for-deck). * - * 1. Fetch the HLTB homepage HTML to discover the Next.js build key. - * 2. Search for the game by name using the HLTB search API. - * 3. Fetch detailed stats via the Next.js data endpoint. + * The HLTB website requires an auth handshake before the search API can be used: + * 1. Bootstrap: fetch homepage → discover Next.js build key + search API path from JS bundles. + * 2. Auth: hit `{searchPath}/init` → acquire tokens (`x-auth-token`, `x-hp-key`, `x-hp-val`). + * 3. Search: POST to the search API with auth headers + body fields → get game list with time data. + * 4. The search results already contain comp_main/comp_plus/comp_100/comp_all in seconds. + * + * Uses java.net.HttpURLConnection for the POST because HLTB's CDN (Fastly) returns 404 + * for the search POST when made via OkHttp (HTTP/2 framing difference). * * Results are cached in memory and persisted to DataStore with a 12-hour TTL. */ @@ -29,53 +32,65 @@ object HltbService { private const val HLTB_BASE_URL = "https://howlongtobeat.com" private const val DEFAULT_SEARCH_PATH = "/api/find" + private const val USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36" + private val json = Json { ignoreUnknownKeys = true; isLenient = true } - private val httpClient = Net.http + // Use OkHttp (HTTP/1.1) for GETs (bootstrap, auth init) + private val httpClient = okhttp3.OkHttpClient.Builder() + .protocols(listOf(okhttp3.Protocol.HTTP_1_1)) + .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .build() // --- Data models --- @Serializable data class HltbGameStats( - val mainHours: String, // Main Story - val mainPlusHours: String, // Main + Extras - val completeHours: String, // Completionist - val allStylesHours: String, // All Styles - val gameId: Int, // HLTB game ID for linking + val mainHours: String, + val mainPlusHours: String, + val completeHours: String, + val allStylesHours: String, + val gameId: Int, val timestamp: Long = System.currentTimeMillis(), ) - // --- Bootstrap / Search URL discovery --- + private data class SearchAuth( + val token: String, + val hpKey: String, + val hpVal: String, + ) + + // --- Bootstrap state --- private data class BootstrapState( var searchPath: String = DEFAULT_SEARCH_PATH, var nextJsKey: String? = null, + var searchAuth: SearchAuth? = null, var lastBootstrapTime: Long = 0, ) private val bootstrap = BootstrapState() - /** - * Ensure bootstrap data is fresh (refresh every 6 hours). - */ + // --- Bootstrap --- + private suspend fun ensureBootstrapped() { val now = System.currentTimeMillis() - if (bootstrap.nextJsKey != null && now - bootstrap.lastBootstrapTime < 6 * 60 * 60 * 1000L) { - return - } + if (bootstrap.nextJsKey != null && bootstrap.searchAuth != null && + now - bootstrap.lastBootstrapTime < 6 * 60 * 60 * 1000L + ) return bootstrapHomepage() } - /** - * Fetch the HLTB homepage to discover the Next.js build key used in - * data-fetch URLs like `/_next/data//game/.json`. - */ private suspend fun bootstrapHomepage() = withContext(Dispatchers.IO) { try { val request = Request.Builder() .url(HLTB_BASE_URL) - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + .header("User-Agent", USER_AGENT) .header("Accept", "text/html") + .header("Origin", HLTB_BASE_URL) + .header("Referer", "$HLTB_BASE_URL/") .build() httpClient.newCall(request).execute().use { response -> @@ -86,44 +101,143 @@ object HltbService { val html = response.body?.string() ?: return@withContext - // Find the Next.js build key from script sources - val keyPattern = Regex("""/_next/static/([^/]+)/(_ssgManifest|_buildManifest)\.js""") - val keyMatch = keyPattern.find(html) + // Next.js build key + val keyMatch = Regex("""/_next/static/([^/]+)/(_ssgManifest|_buildManifest)\.js""").find(html) if (keyMatch != null) { bootstrap.nextJsKey = keyMatch.groupValues[1] - Timber.tag("HLTB").d("Discovered Next.js key: ${bootstrap.nextJsKey}") + Timber.tag("HLTB").d("Next.js key: ${bootstrap.nextJsKey}") } - // Find the search API path from inline script references - val searchPattern = Regex("""fetch\s*\(\s*["']/api/([a-zA-Z0-9_/]+)""") - val searchMatch = searchPattern.find(html) - if (searchMatch != null) { - val suffix = searchMatch.groupValues[1] - val basePath = if (suffix.contains("/")) suffix.substringBefore("/") else suffix - bootstrap.searchPath = "/api/$basePath" - Timber.tag("HLTB").d("Discovered search path: ${bootstrap.searchPath}") - } - } + // Search path from JS bundles + val discovered = discoverSearchPath(html) + if (discovered != null) bootstrap.searchPath = discovered + // Auth + fetchSearchAuth() + } bootstrap.lastBootstrapTime = System.currentTimeMillis() } catch (e: Exception) { Timber.tag("HLTB").e(e, "Bootstrap error") } } - // --- API calls --- + private suspend fun discoverSearchPath(html: String): String? = withContext(Dispatchers.IO) { + try { + val origin = HLTB_BASE_URL + val srcPattern = Regex("""\bsrc\s*=\s*"([^"]+\.js)""") + val scriptUrls = srcPattern.findAll(html) + .map { it.groupValues[1] } + .map { if (it.startsWith("http")) it else if (it.startsWith("//")) "https:$it" else "$origin$it" } + .filter { it.startsWith(origin) } + + val fetchPattern = Regex( + """fetch\s*\(\s*["']/api/([a-zA-Z0-9_/]+)[^"']*["']\s*,\s*\{[^}]*method:\s*["']POST["']""", + RegexOption.IGNORE_CASE, + ) + + for (scriptUrl in scriptUrls) { + try { + val req = Request.Builder().url(scriptUrl) + .header("User-Agent", USER_AGENT).build() + httpClient.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) return@use + val text = resp.body?.string() ?: return@use + val m = fetchPattern.find(text) + if (m != null) { + val suffix = m.groupValues[1] + val base = if (suffix.contains("/")) suffix.substringBefore("/") else suffix + val path = "/api/$base" + Timber.tag("HLTB").d("Search path: $path") + return@withContext path + } + } + } catch (_: Exception) {} + } + null + } catch (e: Exception) { + Timber.tag("HLTB").e(e, "discoverSearchPath error") + null + } + } + + // --- Auth --- + + private suspend fun fetchSearchAuth(): SearchAuth? = withContext(Dispatchers.IO) { + try { + val url = "$HLTB_BASE_URL${bootstrap.searchPath}/init?t=${System.currentTimeMillis()}" + val request = Request.Builder().url(url).get() + .header("Content-Type", "application/json") + .header("Origin", HLTB_BASE_URL) + .header("Referer", "$HLTB_BASE_URL/") + .header("User-Agent", USER_AGENT) + .build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Timber.tag("HLTB").w("Auth init failed: HTTP ${response.code}") + return@withContext null + } + val body = response.body?.string() ?: return@withContext null + val data = JSONObject(body) + val token = data.optString("token", "") + var hpKey = "" + var hpVal = "" + for (fieldName in data.keys()) { + val value = data.optString(fieldName, "") + val lower = fieldName.lowercase() + if (hpKey.isEmpty() && lower.contains("key")) hpKey = value + else if (hpVal.isEmpty() && lower.contains("val")) hpVal = value + } + if (token.isNotEmpty() && hpKey.isNotEmpty() && hpVal.isNotEmpty()) { + val auth = SearchAuth(token, hpKey, hpVal) + bootstrap.searchAuth = auth + Timber.tag("HLTB").d("Auth acquired") + return@withContext auth + } + Timber.tag("HLTB").w("Incomplete auth response") + null + } + } catch (e: Exception) { + Timber.tag("HLTB").e(e, "fetchSearchAuth error") + null + } + } + + private suspend fun refreshAuth(redoSearchPath: Boolean = false): SearchAuth? { + if (redoSearchPath) { + bootstrap.searchPath = DEFAULT_SEARCH_PATH + bootstrapHomepage() + return bootstrap.searchAuth + } + return fetchSearchAuth() + } + + // --- Search (uses HttpURLConnection) --- + + private suspend fun searchGame(gameName: String): Int? { + val result = doSearch(gameName) + if (result != null) return result + + Timber.tag("HLTB").d("Search returned null, refreshing auth") + refreshAuth() + val retry = doSearch(gameName) + if (retry != null) return retry + + Timber.tag("HLTB").d("Retry failed, full re-bootstrap") + refreshAuth(redoSearchPath = true) + return doSearch(gameName) + } /** - * Search HLTB for a game by name, returning the best-matching game ID. + * Execute a search using java.net.HttpURLConnection. + * HLTB's CDN returns 404 for the POST when using OkHttp. */ - private suspend fun searchGame(gameName: String): Int? = withContext(Dispatchers.IO) { - ensureBootstrapped() - + private suspend fun doSearch(gameName: String): Int? = withContext(Dispatchers.IO) { + val auth = bootstrap.searchAuth ?: return@withContext null try { - val searchTerms = gameName.split(" ") val postData = JSONObject().apply { put("searchType", "games") - put("searchTerms", org.json.JSONArray(searchTerms)) + put("searchTerms", org.json.JSONArray(gameName.split(" "))) put("searchPage", 1) put("size", 20) put("searchOptions", JSONObject().apply { @@ -132,6 +246,11 @@ object HltbService { put("platform", "") put("sortCategory", "name") put("rangeCategory", "main") + put("rangeTime", JSONObject().apply { put("min", 0); put("max", 0) }) + put("gameplay", JSONObject().apply { + put("perspective", ""); put("flow", "") + put("genre", ""); put("difficulty", "") + }) put("modifier", "hide_dlc") }) put("users", JSONObject()) @@ -139,201 +258,151 @@ object HltbService { put("sort", 0) put("randomizer", 0) }) + put(auth.hpKey, auth.hpVal) } - val mediaType = "application/json".toMediaType() - val body = postData.toString().toRequestBody(mediaType) - - val request = Request.Builder() - .url("$HLTB_BASE_URL${bootstrap.searchPath}") - .post(body) - .header("Content-Type", "application/json") - .header("Origin", HLTB_BASE_URL) - .header("Referer", "$HLTB_BASE_URL/") - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") - .build() - - httpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - Timber.tag("HLTB").w("Search failed for '$gameName': HTTP ${response.code}") - return@withContext null - } + val bodyBytes = postData.toString().toByteArray(Charsets.UTF_8) + Timber.tag("HLTB").d("Searching '$gameName' via HttpURLConnection (${bodyBytes.size} bytes)") + + val conn = URL("$HLTB_BASE_URL${bootstrap.searchPath}").openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.doOutput = true + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("Origin", HLTB_BASE_URL) + conn.setRequestProperty("Referer", "$HLTB_BASE_URL/") + conn.setRequestProperty("x-auth-token", auth.token) + conn.setRequestProperty("x-hp-key", auth.hpKey) + conn.setRequestProperty("x-hp-val", auth.hpVal) + conn.setRequestProperty("User-Agent", USER_AGENT) + conn.outputStream.use { it.write(bodyBytes) } + + val code = conn.responseCode + if (code != 200) { + val err = conn.errorStream?.bufferedReader()?.readText()?.take(200) + Timber.tag("HLTB").w("Search failed '$gameName': HTTP $code body=$err") + bootstrap.searchAuth = null + return@withContext null + } - val responseBody = response.body?.string() ?: return@withContext null - val json = JSONObject(responseBody) - val dataArray = json.optJSONArray("data") ?: return@withContext null + val responseBody = conn.inputStream.bufferedReader().readText() + val json = JSONObject(responseBody) + val dataArray = json.optJSONArray("data") + if (dataArray == null || dataArray.length() == 0) { + Timber.tag("HLTB").d("No results for '$gameName'") + return@withContext null + } - if (dataArray.length() == 0) { - Timber.tag("HLTB").d("No search results for '$gameName'") - return@withContext null + val normalizedQuery = normalize(gameName) + var bestId: Int? = null + var bestDistance = Int.MAX_VALUE + var bestPopularity = 0 + + for (i in 0 until dataArray.length()) { + val item = dataArray.getJSONObject(i) + val gameId = item.optInt("game_id", 0) + val rawName = item.optString("game_name", "") + val compCount = item.optInt("comp_all_count", 0) + val normalizedName = normalize(rawName) + if (normalizedName == normalizedQuery) { + Timber.tag("HLTB").d("Exact match: '$gameName' → $gameId") + return@withContext gameId } - - val normalizedQuery = normalize(gameName) - var bestId: Int? = null - var bestDistance = Int.MAX_VALUE - var bestPopularity = 0 - - for (i in 0 until dataArray.length()) { - val item = dataArray.getJSONObject(i) - val gameId = item.optInt("game_id", 0) - val rawName = item.optString("game_name", "") - val compCount = item.optInt("comp_all_count", 0) - val normalizedName = normalize(rawName) - - // Exact match wins immediately - if (normalizedName == normalizedQuery) { - return@withContext gameId - } - - // Levenshtein-like distance via simple comparison - val distance = levenshteinDistance(normalizedQuery, normalizedName) - if (distance < bestDistance || (distance == bestDistance && compCount > bestPopularity)) { - bestDistance = distance - bestPopularity = compCount - bestId = gameId - } + val distance = levenshteinDistance(normalizedQuery, normalizedName) + if (distance < bestDistance || (distance == bestDistance && compCount > bestPopularity)) { + bestDistance = distance + bestPopularity = compCount + bestId = gameId } - - Timber.tag("HLTB").d("Best match for '$gameName': HLTB ID $bestId (distance $bestDistance)") - return@withContext bestId } + Timber.tag("HLTB").d("Best match '$gameName': HLTB ID $bestId (dist=$bestDistance)") + return@withContext bestId } catch (e: Exception) { - Timber.tag("HLTB").e(e, "Search error for '$gameName'") + Timber.tag("HLTB").e(e, "Search error '$gameName'") return@withContext null } } - /** - * Fetch detailed game stats from the HLTB Next.js data endpoint. - */ - private suspend fun fetchGameStats(gameId: Int): HltbGameStats? = withContext(Dispatchers.IO) { - ensureBootstrapped() + // --- Game page data fetch --- - val key = bootstrap.nextJsKey - if (key == null) { - Timber.tag("HLTB").w("No Next.js key available for game $gameId") - return@withContext null - } + private suspend fun fetchGameStats(gameId: Int): HltbGameStats? { + val stats = doFetchGameStats(gameId) + if (stats != null) return stats + bootstrap.nextJsKey = null + bootstrapHomepage() + return doFetchGameStats(gameId) + } + private suspend fun doFetchGameStats(gameId: Int): HltbGameStats? = withContext(Dispatchers.IO) { + val key = bootstrap.nextJsKey ?: return@withContext null try { val url = "$HLTB_BASE_URL/_next/data/$key/game/$gameId.json" - val request = Request.Builder() - .url(url) - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + val request = Request.Builder().url(url) + .header("User-Agent", USER_AGENT) .header("Referer", "$HLTB_BASE_URL/") .build() - httpClient.newCall(request).execute().use { response -> if (!response.isSuccessful) { - Timber.tag("HLTB").w("Game data fetch failed for $gameId: HTTP ${response.code}") - // Key might be stale, clear it - bootstrap.nextJsKey = null + Timber.tag("HLTB").w("Game fetch $gameId: HTTP ${response.code}") return@withContext null } - - val responseBody = response.body?.string() ?: return@withContext null - val json = JSONObject(responseBody) - - val gameDataList = json - .optJSONObject("pageProps") - ?.optJSONObject("game") - ?.optJSONObject("data") + val body = response.body?.string() ?: return@withContext null + val json = JSONObject(body) + val gameList = json.optJSONObject("pageProps") + ?.optJSONObject("game")?.optJSONObject("data") ?.optJSONArray("game") - - if (gameDataList == null || gameDataList.length() != 1) { - Timber.tag("HLTB").w("Unexpected game data structure for $gameId") + if (gameList == null || gameList.length() != 1) { + Timber.tag("HLTB").w("Unexpected data for $gameId") return@withContext null } - - val gameData = gameDataList.getJSONObject(0) - - val compMain = gameData.optLong("comp_main", 0) - val compPlus = gameData.optLong("comp_plus", 0) - val comp100 = gameData.optLong("comp_100", 0) - val compAll = gameData.optLong("comp_all", 0) - + val g = gameList.getJSONObject(0) val stats = HltbGameStats( - mainHours = formatSeconds(compMain), - mainPlusHours = formatSeconds(compPlus), - completeHours = formatSeconds(comp100), - allStylesHours = formatSeconds(compAll), + mainHours = formatSeconds(g.optLong("comp_main", 0)), + mainPlusHours = formatSeconds(g.optLong("comp_plus", 0)), + completeHours = formatSeconds(g.optLong("comp_100", 0)), + allStylesHours = formatSeconds(g.optLong("comp_all", 0)), gameId = gameId, ) - - Timber.tag("HLTB").i("Fetched stats for $gameId: Main=${stats.mainHours}h, Main+=${stats.mainPlusHours}h, 100%=${stats.completeHours}h") + Timber.tag("HLTB").i("Stats $gameId: Main=${stats.mainHours}h +=${stats.mainPlusHours}h 100%=${stats.completeHours}h") return@withContext stats } } catch (e: Exception) { - Timber.tag("HLTB").e(e, "Error fetching game stats for $gameId") + Timber.tag("HLTB").e(e, "fetchGameStats $gameId") return@withContext null } } // --- Public API --- - /** - * Fetch HLTB stats for a game. Uses cache when available (12h TTL). - * - * @param gameName The name of the game to look up. - * @return HLTB stats, or null if not found / on error. - */ suspend fun getStats(gameName: String): HltbGameStats? { if (gameName.isBlank()) return null - - // Check cache first - val cached = HltbCache.get(gameName) - if (cached != null) return cached - - // Search and fetch + HltbCache.get(gameName)?.let { return it } val hltbId = searchGame(gameName) ?: return null val stats = fetchGameStats(hltbId) ?: return null - HltbCache.put(gameName, stats) return stats } // --- Utility --- - /** - * Convert seconds (as used by HLTB API) to hours string. - * HLTB stores comp_main etc. in seconds. - */ private fun formatSeconds(seconds: Long): String { if (seconds <= 0) return "--" - val hours = seconds / 3600.0 - return String.format("%.1f", hours) + return String.format("%.1f", seconds / 3600.0) } - /** - * Normalize a game name for matching (lowercase, strip special chars). - */ - private fun normalize(str: String): String { - return str.lowercase() - .replace(Regex("[^\\p{L}\\p{N}]"), " ") - .replace(Regex("\\s+"), " ") - .trim() - } + private fun normalize(str: String): String = + str.lowercase().replace(Regex("[^\\p{L}\\p{N}]"), " ").replace(Regex("\\s+"), " ").trim() - /** - * Simple Levenshtein distance for fuzzy matching. - */ private fun levenshteinDistance(a: String, b: String): Int { if (a == b) return 0 if (a.isEmpty()) return b.length if (b.isEmpty()) return a.length - val dp = Array(a.length + 1) { IntArray(b.length + 1) } for (i in 0..a.length) dp[i][0] = i for (j in 0..b.length) dp[0][j] = j - for (i in 1..a.length) { for (j in 1..b.length) { val cost = if (a[i - 1] == b[j - 1]) 0 else 1 - dp[i][j] = minOf( - dp[i - 1][j] + 1, - dp[i][j - 1] + 1, - dp[i - 1][j - 1] + cost, - ) + dp[i][j] = minOf(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost) } } return dp[a.length][b.length] @@ -341,41 +410,31 @@ object HltbService { } /** - * Persistent cache for HLTB game stats with a 12-hour TTL. + * Persistent cache for HLTB game stats with 12-hour TTL. */ object HltbCache { - private const val CACHE_TTL_MS = 12 * 60 * 60 * 1000L // 12 hours + private const val CACHE_TTL_MS = 12 * 60 * 60 * 1000L private val inMemoryCache = mutableMapOf() private var cacheLoaded = false - private val json = Json { ignoreUnknownKeys = true } @Serializable - data class CachedEntry( - val stats: HltbService.HltbGameStats, - val timestamp: Long, - ) + data class CachedEntry(val stats: HltbService.HltbGameStats, val timestamp: Long) private fun loadCache() { if (cacheLoaded) return try { - val cacheJson = PrefManager.hltbCache - if (cacheJson.isEmpty() || cacheJson == "{}") { - cacheLoaded = true - return - } - val cacheMap = json.decodeFromString>(cacheJson) + val raw = PrefManager.hltbCache + if (raw.isEmpty() || raw == "{}") { cacheLoaded = true; return } + val map = json.decodeFromString>(raw) val now = System.currentTimeMillis() - cacheMap.forEach { (name, entry) -> - if (now - entry.timestamp < CACHE_TTL_MS) { - inMemoryCache[name] = entry.stats - } + map.forEach { (name, entry) -> + if (now - entry.timestamp < CACHE_TTL_MS) inMemoryCache[name] = entry.stats } - Timber.tag("HLTBCache").d("Loaded ${inMemoryCache.size} cached entries") cacheLoaded = true } catch (e: Exception) { - Timber.tag("HLTBCache").e(e, "Failed to load cache") + Timber.tag("HLTBCache").e(e, "Load error") cacheLoaded = true } } @@ -383,12 +442,11 @@ object HltbCache { private fun saveCache() { try { val now = System.currentTimeMillis() - val cacheMap = inMemoryCache.mapValues { (_, stats) -> - CachedEntry(stats, now) - } - PrefManager.hltbCache = json.encodeToString(cacheMap) + PrefManager.hltbCache = json.encodeToString( + inMemoryCache.mapValues { CachedEntry(it.value, now) } + ) } catch (e: Exception) { - Timber.tag("HLTBCache").e(e, "Failed to save cache") + Timber.tag("HLTBCache").e(e, "Save error") } } @@ -403,10 +461,6 @@ object HltbCache { saveCache() } - private fun normalize(str: String): String { - return str.lowercase() - .replace(Regex("[^\\p{L}\\p{N}]"), " ") - .replace(Regex("\\s+"), " ") - .trim() - } + private fun normalize(str: String) = + str.lowercase().replace(Regex("[^\\p{L}\\p{N}]"), " ").replace(Regex("\\s+"), " ").trim() } From 2dbb586bd4601b49a537385a53355cbc21379f6c Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 23 Apr 2026 15:07:59 -0400 Subject: [PATCH 03/10] refactor: shrink HLTB implementation to ~260 lines --- .../app/gamenative/ui/data/GameDisplayInfo.kt | 13 +- .../ui/screen/library/LibraryAppScreen.kt | 83 +-- .../screen/library/appscreen/BaseAppScreen.kt | 24 +- .../java/app/gamenative/utils/HltbService.kt | 513 +++++------------- 4 files changed, 147 insertions(+), 486 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt b/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt index 279db138f5..52d5a77fc8 100644 --- a/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt +++ b/app/src/main/java/app/gamenative/ui/data/GameDisplayInfo.kt @@ -22,15 +22,6 @@ data class GameDisplayInfo( val headerUrl: String? = null, // Header image URL (for SteamGridDB, can use grid as header) val compatibilityMessage: String? = null, // Compatibility message text (e.g., "Works on your GPU") val compatibilityColor: ULong? = null, // Compatibility message color (ARGB) - val hltbStats: HltbStats? = null, // How Long To Beat stats -) { - /** HLTB stats to display on game pages. */ - data class HltbStats( - val mainHours: String, - val mainPlusHours: String, - val completeHours: String, - val allStylesHours: String, - val gameId: Int, - ) -} + val hltbStats: app.gamenative.utils.HltbService.Stats? = null, // How Long To Beat stats +) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index fe9b3194f1..19a4f6e162 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -288,38 +288,12 @@ private fun PrimaryActionButton( } -/** - * HLTB stat card showing a completion tier with hours value. - */ @Composable -private fun HltbStatCard( - label: String, - hours: String, - modifier: Modifier = Modifier, -) { - Surface( - modifier = modifier, - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shadowElevation = 2.dp, - ) { - Column( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = if (hours == "--") "--" else "${hours}h", - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - ) +private fun HltbStatCard(label: String, hours: String, modifier: Modifier = Modifier) { + Surface(modifier, RoundedCornerShape(12.dp), MaterialTheme.colorScheme.surfaceContainerHigh, shadowElevation = 2.dp) { + Column(Modifier.padding(horizontal = 14.dp, vertical = 12.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(if (hours == "--") "--" else "${hours}h", style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.primary) + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) } } } @@ -1115,44 +1089,17 @@ internal fun AppScreenContent( } // HLTB (How Long To Beat) stats - val hltb = displayInfo.hltbStats - if (hltb != null) { - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.hltb_title), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.padding(bottom = 12.dp), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - HltbStatCard( - label = stringResource(R.string.hltb_main_story), - hours = hltb.mainHours, - modifier = Modifier.weight(1f), - ) - HltbStatCard( - label = stringResource(R.string.hltb_main_plus_extras), - hours = hltb.mainPlusHours, - modifier = Modifier.weight(1f), - ) + displayInfo.hltbStats?.let { hltb -> + Spacer(Modifier.height(16.dp)) + Text(stringResource(R.string.hltb_title), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), modifier = Modifier.padding(bottom = 12.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + HltbStatCard(stringResource(R.string.hltb_main_story), hltb.mainHours, Modifier.weight(1f)) + HltbStatCard(stringResource(R.string.hltb_main_plus_extras), hltb.mainPlusHours, Modifier.weight(1f)) } - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - HltbStatCard( - label = stringResource(R.string.hltb_completionist), - hours = hltb.completeHours, - modifier = Modifier.weight(1f), - ) - HltbStatCard( - label = stringResource(R.string.hltb_all_styles), - hours = hltb.allStylesHours, - modifier = Modifier.weight(1f), - ) + Spacer(Modifier.height(8.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + HltbStatCard(stringResource(R.string.hltb_completionist), hltb.completeHours, Modifier.weight(1f)) + HltbStatCard(stringResource(R.string.hltb_all_styles), hltb.allStylesHours, Modifier.weight(1f)) } } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index 61a6eea4c7..bed9cc2f81 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt @@ -898,29 +898,13 @@ abstract class BaseAppScreen { val displayInfoBase = getGameDisplayInfo(context, libraryItem) val appId = libraryItem.appId - // Fetch HLTB stats asynchronously + // Fetch HLTB stats asynchronously (best-effort) var hltbStats by remember(displayInfoBase.name) { - mutableStateOf(null) + mutableStateOf(null) } - val hltbScope = rememberCoroutineScope() LaunchedEffect(displayInfoBase.name) { - if (displayInfoBase.name.isNotBlank()) { - Timber.tag("HLTB").d("Fetching stats for '${displayInfoBase.name}'") - try { - val stats = app.gamenative.utils.HltbService.getStats(displayInfoBase.name) - if (stats != null) { - hltbStats = GameDisplayInfo.HltbStats( - mainHours = stats.mainHours, - mainPlusHours = stats.mainPlusHours, - completeHours = stats.completeHours, - allStylesHours = stats.allStylesHours, - gameId = stats.gameId, - ) - } - } catch (_: Exception) { - // HLTB is best-effort; don't crash on failure - } - } + if (displayInfoBase.name.isNotBlank()) + hltbStats = try { app.gamenative.utils.HltbService.getStats(displayInfoBase.name) } catch (_: Exception) { null } } val displayInfo = displayInfoBase.copy(hltbStats = hltbStats) diff --git a/app/src/main/java/app/gamenative/utils/HltbService.kt b/app/src/main/java/app/gamenative/utils/HltbService.kt index c281ca0d6b..26403c5235 100644 --- a/app/src/main/java/app/gamenative/utils/HltbService.kt +++ b/app/src/main/java/app/gamenative/utils/HltbService.kt @@ -7,460 +7,199 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import okhttp3.Request +import org.json.JSONArray import org.json.JSONObject import timber.log.Timber import java.net.HttpURLConnection import java.net.URL /** - * Service for fetching HowLongToBeat (HLTB) game time statistics. + * Fetches HowLongToBeat completion time stats for a game. * - * Ported from the hltb-for-deck Steam Deck plugin (https://github.com/morwy/hltb-for-deck). + * Flow (ported from https://github.com/morwy/hltb-for-deck): + * 1. GET /api/find/init → auth tokens (token, hpKey, hpVal) + * 2. POST /api/find with auth headers + body → search results contain all comp times * - * The HLTB website requires an auth handshake before the search API can be used: - * 1. Bootstrap: fetch homepage → discover Next.js build key + search API path from JS bundles. - * 2. Auth: hit `{searchPath}/init` → acquire tokens (`x-auth-token`, `x-hp-key`, `x-hp-val`). - * 3. Search: POST to the search API with auth headers + body fields → get game list with time data. - * 4. The search results already contain comp_main/comp_plus/comp_100/comp_all in seconds. - * - * Uses java.net.HttpURLConnection for the POST because HLTB's CDN (Fastly) returns 404 - * for the search POST when made via OkHttp (HTTP/2 framing difference). - * - * Results are cached in memory and persisted to DataStore with a 12-hour TTL. + * Uses HttpURLConnection for the POST — OkHttp over HTTP/2 gets 404 from HLTB's CDN. + * Stats are cached for 12 hours. */ object HltbService { - private const val HLTB_BASE_URL = "https://howlongtobeat.com" - private const val DEFAULT_SEARCH_PATH = "/api/find" - private const val USER_AGENT = + private const val BASE = "https://howlongtobeat.com" + private const val SEARCH_PATH = "/api/find" + private const val UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36" - private val json = Json { ignoreUnknownKeys = true; isLenient = true } - - // Use OkHttp (HTTP/1.1) for GETs (bootstrap, auth init) - private val httpClient = okhttp3.OkHttpClient.Builder() - .protocols(listOf(okhttp3.Protocol.HTTP_1_1)) - .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS) - .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) - .build() - - // --- Data models --- - @Serializable - data class HltbGameStats( + data class Stats( val mainHours: String, val mainPlusHours: String, val completeHours: String, val allStylesHours: String, - val gameId: Int, - val timestamp: Long = System.currentTimeMillis(), ) - private data class SearchAuth( - val token: String, - val hpKey: String, - val hpVal: String, - ) - - // --- Bootstrap state --- - - private data class BootstrapState( - var searchPath: String = DEFAULT_SEARCH_PATH, - var nextJsKey: String? = null, - var searchAuth: SearchAuth? = null, - var lastBootstrapTime: Long = 0, - ) - - private val bootstrap = BootstrapState() - - // --- Bootstrap --- - - private suspend fun ensureBootstrapped() { - val now = System.currentTimeMillis() - if (bootstrap.nextJsKey != null && bootstrap.searchAuth != null && - now - bootstrap.lastBootstrapTime < 6 * 60 * 60 * 1000L - ) return - bootstrapHomepage() - } - - private suspend fun bootstrapHomepage() = withContext(Dispatchers.IO) { - try { - val request = Request.Builder() - .url(HLTB_BASE_URL) - .header("User-Agent", USER_AGENT) - .header("Accept", "text/html") - .header("Origin", HLTB_BASE_URL) - .header("Referer", "$HLTB_BASE_URL/") - .build() - - httpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - Timber.tag("HLTB").w("Bootstrap failed: HTTP ${response.code}") - return@withContext - } - - val html = response.body?.string() ?: return@withContext - - // Next.js build key - val keyMatch = Regex("""/_next/static/([^/]+)/(_ssgManifest|_buildManifest)\.js""").find(html) - if (keyMatch != null) { - bootstrap.nextJsKey = keyMatch.groupValues[1] - Timber.tag("HLTB").d("Next.js key: ${bootstrap.nextJsKey}") - } - - // Search path from JS bundles - val discovered = discoverSearchPath(html) - if (discovered != null) bootstrap.searchPath = discovered - - // Auth - fetchSearchAuth() - } - bootstrap.lastBootstrapTime = System.currentTimeMillis() - } catch (e: Exception) { - Timber.tag("HLTB").e(e, "Bootstrap error") - } - } - - private suspend fun discoverSearchPath(html: String): String? = withContext(Dispatchers.IO) { - try { - val origin = HLTB_BASE_URL - val srcPattern = Regex("""\bsrc\s*=\s*"([^"]+\.js)""") - val scriptUrls = srcPattern.findAll(html) - .map { it.groupValues[1] } - .map { if (it.startsWith("http")) it else if (it.startsWith("//")) "https:$it" else "$origin$it" } - .filter { it.startsWith(origin) } - - val fetchPattern = Regex( - """fetch\s*\(\s*["']/api/([a-zA-Z0-9_/]+)[^"']*["']\s*,\s*\{[^}]*method:\s*["']POST["']""", - RegexOption.IGNORE_CASE, - ) + private data class Auth(val token: String, val hpKey: String, val hpVal: String) - for (scriptUrl in scriptUrls) { - try { - val req = Request.Builder().url(scriptUrl) - .header("User-Agent", USER_AGENT).build() - httpClient.newCall(req).execute().use { resp -> - if (!resp.isSuccessful) return@use - val text = resp.body?.string() ?: return@use - val m = fetchPattern.find(text) - if (m != null) { - val suffix = m.groupValues[1] - val base = if (suffix.contains("/")) suffix.substringBefore("/") else suffix - val path = "/api/$base" - Timber.tag("HLTB").d("Search path: $path") - return@withContext path - } - } - } catch (_: Exception) {} - } - null - } catch (e: Exception) { - Timber.tag("HLTB").e(e, "discoverSearchPath error") - null - } - } + private var auth: Auth? = null - // --- Auth --- + private val httpClient = okhttp3.OkHttpClient.Builder() + .protocols(listOf(okhttp3.Protocol.HTTP_1_1)) + .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .build() - private suspend fun fetchSearchAuth(): SearchAuth? = withContext(Dispatchers.IO) { + /** Fetch auth tokens from the HLTB init endpoint. */ + private suspend fun fetchAuth(): Auth? = withContext(Dispatchers.IO) { try { - val url = "$HLTB_BASE_URL${bootstrap.searchPath}/init?t=${System.currentTimeMillis()}" - val request = Request.Builder().url(url).get() - .header("Content-Type", "application/json") - .header("Origin", HLTB_BASE_URL) - .header("Referer", "$HLTB_BASE_URL/") - .header("User-Agent", USER_AGENT) - .build() - - httpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - Timber.tag("HLTB").w("Auth init failed: HTTP ${response.code}") - return@withContext null - } - val body = response.body?.string() ?: return@withContext null - val data = JSONObject(body) - val token = data.optString("token", "") - var hpKey = "" - var hpVal = "" - for (fieldName in data.keys()) { - val value = data.optString(fieldName, "") - val lower = fieldName.lowercase() - if (hpKey.isEmpty() && lower.contains("key")) hpKey = value - else if (hpVal.isEmpty() && lower.contains("val")) hpVal = value - } - if (token.isNotEmpty() && hpKey.isNotEmpty() && hpVal.isNotEmpty()) { - val auth = SearchAuth(token, hpKey, hpVal) - bootstrap.searchAuth = auth - Timber.tag("HLTB").d("Auth acquired") - return@withContext auth + val req = Request.Builder().url("$BASE$SEARCH_PATH/init?t=${System.currentTimeMillis()}") + .header("Origin", BASE).header("Referer", "$BASE/").header("User-Agent", UA).build() + httpClient.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) return@withContext null + val d = JSONObject(resp.body?.string() ?: return@withContext null) + val token = d.optString("token") + var key = ""; var value = "" + for (f in d.keys()) { + val l = f.lowercase() + if (key.isEmpty() && l.contains("key")) key = d.optString(f) + else if (value.isEmpty() && l.contains("val")) value = d.optString(f) } - Timber.tag("HLTB").w("Incomplete auth response") - null + if (token.isNotEmpty() && key.isNotEmpty() && value.isNotEmpty()) + Auth(token, key, value).also { auth = it } + else null } - } catch (e: Exception) { - Timber.tag("HLTB").e(e, "fetchSearchAuth error") - null - } - } - - private suspend fun refreshAuth(redoSearchPath: Boolean = false): SearchAuth? { - if (redoSearchPath) { - bootstrap.searchPath = DEFAULT_SEARCH_PATH - bootstrapHomepage() - return bootstrap.searchAuth - } - return fetchSearchAuth() - } - - // --- Search (uses HttpURLConnection) --- - - private suspend fun searchGame(gameName: String): Int? { - val result = doSearch(gameName) - if (result != null) return result - - Timber.tag("HLTB").d("Search returned null, refreshing auth") - refreshAuth() - val retry = doSearch(gameName) - if (retry != null) return retry - - Timber.tag("HLTB").d("Retry failed, full re-bootstrap") - refreshAuth(redoSearchPath = true) - return doSearch(gameName) + } catch (e: Exception) { Timber.tag("HLTB").e(e, "fetchAuth"); null } } - /** - * Execute a search using java.net.HttpURLConnection. - * HLTB's CDN returns 404 for the POST when using OkHttp. - */ - private suspend fun doSearch(gameName: String): Int? = withContext(Dispatchers.IO) { - val auth = bootstrap.searchAuth ?: return@withContext null + /** POST the HLTB search API, returning the best-matching game's stats. */ + private suspend fun search(name: String, a: Auth): Stats? = withContext(Dispatchers.IO) { try { - val postData = JSONObject().apply { + val body = JSONObject().apply { put("searchType", "games") - put("searchTerms", org.json.JSONArray(gameName.split(" "))) - put("searchPage", 1) - put("size", 20) + put("searchTerms", JSONArray(name.split(" "))) + put("searchPage", 1); put("size", 20) put("searchOptions", JSONObject().apply { put("games", JSONObject().apply { - put("userId", 0) - put("platform", "") - put("sortCategory", "name") - put("rangeCategory", "main") + put("userId", 0); put("platform", ""); put("sortCategory", "name") + put("rangeCategory", "main"); put("modifier", "hide_dlc") put("rangeTime", JSONObject().apply { put("min", 0); put("max", 0) }) put("gameplay", JSONObject().apply { - put("perspective", ""); put("flow", "") - put("genre", ""); put("difficulty", "") + put("perspective", ""); put("flow", ""); put("genre", ""); put("difficulty", "") }) - put("modifier", "hide_dlc") }) - put("users", JSONObject()) - put("filter", "") - put("sort", 0) - put("randomizer", 0) + put("users", JSONObject()); put("filter", ""); put("sort", 0); put("randomizer", 0) }) - put(auth.hpKey, auth.hpVal) - } - - val bodyBytes = postData.toString().toByteArray(Charsets.UTF_8) - Timber.tag("HLTB").d("Searching '$gameName' via HttpURLConnection (${bodyBytes.size} bytes)") + put(a.hpKey, a.hpVal) + }.toString().toByteArray() - val conn = URL("$HLTB_BASE_URL${bootstrap.searchPath}").openConnection() as HttpURLConnection - conn.requestMethod = "POST" - conn.doOutput = true + val conn = URL("$BASE$SEARCH_PATH").openConnection() as HttpURLConnection + conn.requestMethod = "POST"; conn.doOutput = true conn.setRequestProperty("Content-Type", "application/json") - conn.setRequestProperty("Origin", HLTB_BASE_URL) - conn.setRequestProperty("Referer", "$HLTB_BASE_URL/") - conn.setRequestProperty("x-auth-token", auth.token) - conn.setRequestProperty("x-hp-key", auth.hpKey) - conn.setRequestProperty("x-hp-val", auth.hpVal) - conn.setRequestProperty("User-Agent", USER_AGENT) - conn.outputStream.use { it.write(bodyBytes) } - - val code = conn.responseCode - if (code != 200) { - val err = conn.errorStream?.bufferedReader()?.readText()?.take(200) - Timber.tag("HLTB").w("Search failed '$gameName': HTTP $code body=$err") - bootstrap.searchAuth = null - return@withContext null - } - - val responseBody = conn.inputStream.bufferedReader().readText() - val json = JSONObject(responseBody) - val dataArray = json.optJSONArray("data") - if (dataArray == null || dataArray.length() == 0) { - Timber.tag("HLTB").d("No results for '$gameName'") - return@withContext null - } - - val normalizedQuery = normalize(gameName) - var bestId: Int? = null - var bestDistance = Int.MAX_VALUE - var bestPopularity = 0 - - for (i in 0 until dataArray.length()) { - val item = dataArray.getJSONObject(i) - val gameId = item.optInt("game_id", 0) - val rawName = item.optString("game_name", "") - val compCount = item.optInt("comp_all_count", 0) - val normalizedName = normalize(rawName) - if (normalizedName == normalizedQuery) { - Timber.tag("HLTB").d("Exact match: '$gameName' → $gameId") - return@withContext gameId - } - val distance = levenshteinDistance(normalizedQuery, normalizedName) - if (distance < bestDistance || (distance == bestDistance && compCount > bestPopularity)) { - bestDistance = distance - bestPopularity = compCount - bestId = gameId - } + conn.setRequestProperty("Origin", BASE); conn.setRequestProperty("Referer", "$BASE/") + conn.setRequestProperty("x-auth-token", a.token) + conn.setRequestProperty("x-hp-key", a.hpKey); conn.setRequestProperty("x-hp-val", a.hpVal) + conn.setRequestProperty("User-Agent", UA) + conn.outputStream.use { it.write(body) } + + if (conn.responseCode != 200) { + Timber.tag("HLTB").w("Search HTTP ${conn.responseCode} for '$name'") + auth = null; return@withContext null } - Timber.tag("HLTB").d("Best match '$gameName': HLTB ID $bestId (dist=$bestDistance)") - return@withContext bestId - } catch (e: Exception) { - Timber.tag("HLTB").e(e, "Search error '$gameName'") - return@withContext null - } - } - - // --- Game page data fetch --- - private suspend fun fetchGameStats(gameId: Int): HltbGameStats? { - val stats = doFetchGameStats(gameId) - if (stats != null) return stats - bootstrap.nextJsKey = null - bootstrapHomepage() - return doFetchGameStats(gameId) - } - - private suspend fun doFetchGameStats(gameId: Int): HltbGameStats? = withContext(Dispatchers.IO) { - val key = bootstrap.nextJsKey ?: return@withContext null - try { - val url = "$HLTB_BASE_URL/_next/data/$key/game/$gameId.json" - val request = Request.Builder().url(url) - .header("User-Agent", USER_AGENT) - .header("Referer", "$HLTB_BASE_URL/") - .build() - httpClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - Timber.tag("HLTB").w("Game fetch $gameId: HTTP ${response.code}") - return@withContext null - } - val body = response.body?.string() ?: return@withContext null - val json = JSONObject(body) - val gameList = json.optJSONObject("pageProps") - ?.optJSONObject("game")?.optJSONObject("data") - ?.optJSONArray("game") - if (gameList == null || gameList.length() != 1) { - Timber.tag("HLTB").w("Unexpected data for $gameId") - return@withContext null - } - val g = gameList.getJSONObject(0) - val stats = HltbGameStats( - mainHours = formatSeconds(g.optLong("comp_main", 0)), - mainPlusHours = formatSeconds(g.optLong("comp_plus", 0)), - completeHours = formatSeconds(g.optLong("comp_100", 0)), - allStylesHours = formatSeconds(g.optLong("comp_all", 0)), - gameId = gameId, - ) - Timber.tag("HLTB").i("Stats $gameId: Main=${stats.mainHours}h +=${stats.mainPlusHours}h 100%=${stats.completeHours}h") - return@withContext stats + val data = JSONObject(conn.inputStream.bufferedReader().readText()).optJSONArray("data") + ?: return@withContext null + if (data.length() == 0) return@withContext null + + // Pick best match (exact name first, then closest by edit distance) + val norm = normalize(name) + var best = data.getJSONObject(0) + var bestDist = Int.MAX_VALUE + for (i in 0 until data.length()) { + val item = data.getJSONObject(i) + val d = levenshtein(norm, normalize(item.optString("game_name"))) + if (d < bestDist) { bestDist = d; best = item } + if (d == 0) break } - } catch (e: Exception) { - Timber.tag("HLTB").e(e, "fetchGameStats $gameId") - return@withContext null - } - } - // --- Public API --- - - suspend fun getStats(gameName: String): HltbGameStats? { - if (gameName.isBlank()) return null - HltbCache.get(gameName)?.let { return it } - val hltbId = searchGame(gameName) ?: return null - val stats = fetchGameStats(hltbId) ?: return null - HltbCache.put(gameName, stats) + val g = best + Timber.tag("HLTB").i("'$name' → '${g.optString("game_name")}' main=${g.optLong("comp_main")}s") + Stats( + mainHours = secs(g.optLong("comp_main")), + mainPlusHours = secs(g.optLong("comp_plus")), + completeHours = secs(g.optLong("comp_100")), + allStylesHours = secs(g.optLong("comp_all")), + ) + } catch (e: Exception) { Timber.tag("HLTB").e(e, "search '$name'"); null } + } + + /** Public entry — cache-first, with one auth retry on failure. */ + suspend fun getStats(name: String): Stats? { + if (name.isBlank()) return null + HltbCache.get(name)?.let { return it } + val a = auth ?: fetchAuth() ?: return null + val stats = search(name, a) ?: run { + val fresh = fetchAuth() ?: return null + search(name, fresh) + } ?: return null + HltbCache.put(name, stats) return stats } - // --- Utility --- - - private fun formatSeconds(seconds: Long): String { - if (seconds <= 0) return "--" - return String.format("%.1f", seconds / 3600.0) - } - - private fun normalize(str: String): String = - str.lowercase().replace(Regex("[^\\p{L}\\p{N}]"), " ").replace(Regex("\\s+"), " ").trim() - - private fun levenshteinDistance(a: String, b: String): Int { + private fun secs(s: Long) = if (s <= 0) "--" else "%.1f".format(s / 3600.0) + private fun normalize(s: String) = + s.lowercase().replace(Regex("[^\\p{L}\\p{N}]"), " ").replace(Regex("\\s+"), " ").trim() + private fun levenshtein(a: String, b: String): Int { if (a == b) return 0 - if (a.isEmpty()) return b.length - if (b.isEmpty()) return a.length - val dp = Array(a.length + 1) { IntArray(b.length + 1) } - for (i in 0..a.length) dp[i][0] = i - for (j in 0..b.length) dp[0][j] = j - for (i in 1..a.length) { - for (j in 1..b.length) { - val cost = if (a[i - 1] == b[j - 1]) 0 else 1 - dp[i][j] = minOf(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost) - } - } + val dp = Array(a.length + 1) { IntArray(b.length + 1) { it } } + for (j in 1..b.length) dp[0][j] = j + for (i in 1..a.length) for (j in 1..b.length) + dp[i][j] = minOf(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+(if (a[i-1]==b[j-1]) 0 else 1)) return dp[a.length][b.length] } } -/** - * Persistent cache for HLTB game stats with 12-hour TTL. - */ +/** In-memory + DataStore cache for HLTB stats (12-hour TTL). */ object HltbCache { - private const val CACHE_TTL_MS = 12 * 60 * 60 * 1000L - - private val inMemoryCache = mutableMapOf() - private var cacheLoaded = false + private const val TTL = 12 * 3_600_000L + private val mem = mutableMapOf() + private val stamps = mutableMapOf() + private var loaded = false private val json = Json { ignoreUnknownKeys = true } - @Serializable - data class CachedEntry(val stats: HltbService.HltbGameStats, val timestamp: Long) + @Serializable data class Entry(val stats: HltbService.Stats, val ts: Long) - private fun loadCache() { - if (cacheLoaded) return + private fun load() { + if (loaded) return + loaded = true try { val raw = PrefManager.hltbCache - if (raw.isEmpty() || raw == "{}") { cacheLoaded = true; return } - val map = json.decodeFromString>(raw) + if (raw == "{}") return val now = System.currentTimeMillis() - map.forEach { (name, entry) -> - if (now - entry.timestamp < CACHE_TTL_MS) inMemoryCache[name] = entry.stats + json.decodeFromString>(raw).forEach { (k, e) -> + if (now - e.ts < TTL) { mem[k] = e.stats; stamps[k] = e.ts } } - cacheLoaded = true - } catch (e: Exception) { - Timber.tag("HLTBCache").e(e, "Load error") - cacheLoaded = true - } + } catch (_: Exception) {} } - private fun saveCache() { + private fun save() { try { val now = System.currentTimeMillis() - PrefManager.hltbCache = json.encodeToString( - inMemoryCache.mapValues { CachedEntry(it.value, now) } - ) - } catch (e: Exception) { - Timber.tag("HLTBCache").e(e, "Save error") - } + PrefManager.hltbCache = json.encodeToString(mem.mapValues { Entry(it.value, stamps[it.key] ?: now) }) + } catch (_: Exception) {} } - fun get(gameName: String): HltbService.HltbGameStats? { - loadCache() - return inMemoryCache[normalize(gameName)] + fun get(name: String): HltbService.Stats? { + load() + val k = key(name) + val ts = stamps[k] ?: return null + if (System.currentTimeMillis() - ts >= TTL) { mem.remove(k); stamps.remove(k); return null } + return mem[k] } - fun put(gameName: String, stats: HltbService.HltbGameStats) { - loadCache() - inMemoryCache[normalize(gameName)] = stats - saveCache() + fun put(name: String, stats: HltbService.Stats) { + load(); val k = key(name) + mem[k] = stats; stamps[k] = System.currentTimeMillis(); save() } - private fun normalize(str: String) = - str.lowercase().replace(Regex("[^\\p{L}\\p{N}]"), " ").replace(Regex("\\s+"), " ").trim() + private fun key(s: String) = + s.lowercase().replace(Regex("[^\\p{L}\\p{N}]"), " ").replace(Regex("\\s+"), " ").trim() } From 0450dd886240e8253f9b4f8f09ab6ea3d390e98a Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 23 Apr 2026 15:18:55 -0400 Subject: [PATCH 04/10] feat: move HLTB stats to hero strip above play button --- .../ui/screen/library/LibraryAppScreen.kt | 53 ++++++++++++------- app/src/main/res/values/strings.xml | 4 -- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 19a4f6e162..38f1ad8209 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -289,11 +289,34 @@ private fun PrimaryActionButton( @Composable -private fun HltbStatCard(label: String, hours: String, modifier: Modifier = Modifier) { - Surface(modifier, RoundedCornerShape(12.dp), MaterialTheme.colorScheme.surfaceContainerHigh, shadowElevation = 2.dp) { - Column(Modifier.padding(horizontal = 14.dp, vertical = 12.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Text(if (hours == "--") "--" else "${hours}h", style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.primary) - Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) +private fun HltbHeroStrip(stats: app.gamenative.utils.HltbService.Stats) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black.copy(alpha = 0.45f)) + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + listOf( + stringResource(R.string.hltb_main_story) to stats.mainHours, + stringResource(R.string.hltb_main_plus_extras) to stats.mainPlusHours, + stringResource(R.string.hltb_completionist) to stats.completeHours, + stringResource(R.string.hltb_all_styles) to stats.allStylesHours, + ).forEach { (label, hours) -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (hours == "--") "--" else "${hours}h", + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), + color = Color.White, + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + ) + } } } } @@ -796,6 +819,12 @@ internal fun AppScreenContent( Spacer(modifier = Modifier.height(16.dp)) + // HLTB stats strip (above play bar) + displayInfo.hltbStats?.let { hltb -> + HltbHeroStrip(hltb) + Spacer(modifier = Modifier.height(8.dp)) + } + // Integrated action bar - overlaid on hero val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT Column( @@ -1088,20 +1117,6 @@ internal fun AppScreenContent( } } - // HLTB (How Long To Beat) stats - displayInfo.hltbStats?.let { hltb -> - Spacer(Modifier.height(16.dp)) - Text(stringResource(R.string.hltb_title), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), modifier = Modifier.padding(bottom = 12.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - HltbStatCard(stringResource(R.string.hltb_main_story), hltb.mainHours, Modifier.weight(1f)) - HltbStatCard(stringResource(R.string.hltb_main_plus_extras), hltb.mainPlusHours, Modifier.weight(1f)) - } - Spacer(Modifier.height(8.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - HltbStatCard(stringResource(R.string.hltb_completionist), hltb.completeHours, Modifier.weight(1f)) - HltbStatCard(stringResource(R.string.hltb_all_styles), hltb.allStylesHours, Modifier.weight(1f)) - } - } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c81240f2a..fd53af9d54 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1447,12 +1447,8 @@ Show curated indie game picks in your library. Keeping this on helps support indie developers and GameNative. - How Long To Beat Main Story Main + Extras Completionist All Styles - %1$s hours - View on HLTB - No data available From c2271dbb57a62e3556ec65f2bd97ee1e3a8a5640 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 23 Apr 2026 15:42:10 -0400 Subject: [PATCH 05/10] feat: add HLTB link button to hero strip --- .../ui/screen/library/LibraryAppScreen.kt | 19 +++++++++++++++++++ .../java/app/gamenative/utils/HltbService.kt | 2 ++ app/src/main/res/values/strings.xml | 1 + 3 files changed, 22 insertions(+) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 38f1ad8209..7377c9be9e 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -3,6 +3,8 @@ package app.gamenative.ui.screen.library import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.clickable import android.content.res.Configuration import app.gamenative.ui.screen.library.components.ambient.AmbientDownloadOverlay import android.view.KeyEvent @@ -39,6 +41,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete @@ -290,6 +293,7 @@ private fun PrimaryActionButton( @Composable private fun HltbHeroStrip(stats: app.gamenative.utils.HltbService.Stats) { + val context = LocalContext.current Row( modifier = Modifier .fillMaxWidth() @@ -297,6 +301,7 @@ private fun HltbHeroStrip(stats: app.gamenative.utils.HltbService.Stats) { .background(Color.Black.copy(alpha = 0.45f)) .padding(horizontal = 8.dp, vertical = 6.dp), horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, ) { listOf( stringResource(R.string.hltb_main_story) to stats.mainHours, @@ -318,6 +323,20 @@ private fun HltbHeroStrip(stats: app.gamenative.utils.HltbService.Stats) { ) } } + if (stats.gameId > 0) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = stringResource(R.string.hltb_view_on_hltb), + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier + .size(18.dp) + .clickable { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://howlongtobeat.com/game/${stats.gameId}")) + ) + }, + ) + } } } diff --git a/app/src/main/java/app/gamenative/utils/HltbService.kt b/app/src/main/java/app/gamenative/utils/HltbService.kt index 26403c5235..25362d5d7c 100644 --- a/app/src/main/java/app/gamenative/utils/HltbService.kt +++ b/app/src/main/java/app/gamenative/utils/HltbService.kt @@ -36,6 +36,7 @@ object HltbService { val mainPlusHours: String, val completeHours: String, val allStylesHours: String, + val gameId: Int = 0, ) private data class Auth(val token: String, val hpKey: String, val hpVal: String) @@ -127,6 +128,7 @@ object HltbService { mainPlusHours = secs(g.optLong("comp_plus")), completeHours = secs(g.optLong("comp_100")), allStylesHours = secs(g.optLong("comp_all")), + gameId = g.optInt("game_id", 0), ) } catch (e: Exception) { Timber.tag("HLTB").e(e, "search '$name'"); null } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd53af9d54..0317a2323e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1451,4 +1451,5 @@ Main + Extras Completionist All Styles + View on HowLongToBeat From a84cb06ecd76a00c4c4c52c794667587ee8c84be Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 23 Apr 2026 16:11:36 -0400 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20hltb=20service=20robustness=20?= =?UTF-8?q?=E2=80=94=20IO=20dispatcher,=20timeouts,=20CancellationExceptio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/library/appscreen/BaseAppScreen.kt | 6 ++- .../java/app/gamenative/utils/HltbService.kt | 54 ++++++++++--------- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index bed9cc2f81..4754a114cf 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt @@ -904,7 +904,11 @@ abstract class BaseAppScreen { } LaunchedEffect(displayInfoBase.name) { if (displayInfoBase.name.isNotBlank()) - hltbStats = try { app.gamenative.utils.HltbService.getStats(displayInfoBase.name) } catch (_: Exception) { null } + hltbStats = try { + app.gamenative.utils.HltbService.getStats(displayInfoBase.name) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { null } } val displayInfo = displayInfoBase.copy(hltbStats = hltbStats) diff --git a/app/src/main/java/app/gamenative/utils/HltbService.kt b/app/src/main/java/app/gamenative/utils/HltbService.kt index 25362d5d7c..4f988fc43c 100644 --- a/app/src/main/java/app/gamenative/utils/HltbService.kt +++ b/app/src/main/java/app/gamenative/utils/HltbService.kt @@ -93,22 +93,25 @@ object HltbService { }.toString().toByteArray() val conn = URL("$BASE$SEARCH_PATH").openConnection() as HttpURLConnection + conn.connectTimeout = 15_000 + conn.readTimeout = 30_000 conn.requestMethod = "POST"; conn.doOutput = true conn.setRequestProperty("Content-Type", "application/json") conn.setRequestProperty("Origin", BASE); conn.setRequestProperty("Referer", "$BASE/") conn.setRequestProperty("x-auth-token", a.token) conn.setRequestProperty("x-hp-key", a.hpKey); conn.setRequestProperty("x-hp-val", a.hpVal) conn.setRequestProperty("User-Agent", UA) - conn.outputStream.use { it.write(body) } + try { + conn.outputStream.use { it.write(body) } - if (conn.responseCode != 200) { - Timber.tag("HLTB").w("Search HTTP ${conn.responseCode} for '$name'") - auth = null; return@withContext null - } + if (conn.responseCode != 200) { + Timber.tag("HLTB").w("Search HTTP ${conn.responseCode} for '$name'") + auth = null; return@withContext null + } - val data = JSONObject(conn.inputStream.bufferedReader().readText()).optJSONArray("data") - ?: return@withContext null - if (data.length() == 0) return@withContext null + val data = JSONObject(conn.inputStream.bufferedReader().readText()).optJSONArray("data") + ?: return@withContext null + if (data.length() == 0) return@withContext null // Pick best match (exact name first, then closest by edit distance) val norm = normalize(name) @@ -121,29 +124,32 @@ object HltbService { if (d == 0) break } - val g = best - Timber.tag("HLTB").i("'$name' → '${g.optString("game_name")}' main=${g.optLong("comp_main")}s") - Stats( - mainHours = secs(g.optLong("comp_main")), - mainPlusHours = secs(g.optLong("comp_plus")), - completeHours = secs(g.optLong("comp_100")), - allStylesHours = secs(g.optLong("comp_all")), - gameId = g.optInt("game_id", 0), - ) + val g = best + Timber.tag("HLTB").i("'$name' → '${g.optString("game_name")}' main=${g.optLong("comp_main")}s") + Stats( + mainHours = secs(g.optLong("comp_main")), + mainPlusHours = secs(g.optLong("comp_plus")), + completeHours = secs(g.optLong("comp_100")), + allStylesHours = secs(g.optLong("comp_all")), + gameId = g.optInt("game_id", 0), + ) + } finally { + conn.disconnect() + } } catch (e: Exception) { Timber.tag("HLTB").e(e, "search '$name'"); null } } /** Public entry — cache-first, with one auth retry on failure. */ - suspend fun getStats(name: String): Stats? { - if (name.isBlank()) return null - HltbCache.get(name)?.let { return it } - val a = auth ?: fetchAuth() ?: return null + suspend fun getStats(name: String): Stats? = withContext(Dispatchers.IO) { + if (name.isBlank()) return@withContext null + HltbCache.get(name)?.let { return@withContext it } + val a = auth ?: fetchAuth() ?: return@withContext null val stats = search(name, a) ?: run { - val fresh = fetchAuth() ?: return null + val fresh = fetchAuth() ?: return@withContext null search(name, fresh) - } ?: return null + } ?: return@withContext null HltbCache.put(name, stats) - return stats + stats } private fun secs(s: Long) = if (s <= 0) "--" else "%.1f".format(s / 3600.0) From 35d059b4a4b51e6c3d43c4db4972addc022502fd Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 23 Apr 2026 17:09:10 -0400 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20address=20phobos=20review=20?= =?UTF-8?q?=E2=80=94=20thread-safe=20cache,=20naming,=20tests,=20translati?= =?UTF-8?q?ons,=20modular=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/screen/library/LibraryAppScreen.kt | 51 +------ .../library/components/HltbHeroStrip.kt | 79 +++++++++++ .../java/app/gamenative/utils/HltbService.kt | 94 ++++++++----- app/src/main/res/values-da/strings.xml | 6 + app/src/main/res/values-de/strings.xml | 6 + app/src/main/res/values-es/strings.xml | 6 + app/src/main/res/values-fr/strings.xml | 6 + app/src/main/res/values-it/strings.xml | 6 + app/src/main/res/values-ko/strings.xml | 6 + app/src/main/res/values-pl/strings.xml | 6 + app/src/main/res/values-pt-rBR/strings.xml | 6 + app/src/main/res/values-ro/strings.xml | 6 + app/src/main/res/values-ru/strings.xml | 6 + app/src/main/res/values-uk/strings.xml | 6 + app/src/main/res/values-zh-rCN/strings.xml | 6 + app/src/main/res/values-zh-rTW/strings.xml | 6 + .../app/gamenative/utils/HltbCacheTest.kt | 107 ++++++++++++++ .../app/gamenative/utils/HltbServiceTest.kt | 133 ++++++++++++++++++ 18 files changed, 460 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/app/gamenative/ui/screen/library/components/HltbHeroStrip.kt create mode 100644 app/src/test/java/app/gamenative/utils/HltbCacheTest.kt create mode 100644 app/src/test/java/app/gamenative/utils/HltbServiceTest.kt diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 7377c9be9e..959a35e304 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -3,8 +3,8 @@ package app.gamenative.ui.screen.library import android.content.Intent -import android.net.Uri import androidx.compose.foundation.clickable +import app.gamenative.ui.screen.library.components.HltbHeroStrip import android.content.res.Configuration import app.gamenative.ui.screen.library.components.ambient.AmbientDownloadOverlay import android.view.KeyEvent @@ -291,55 +291,6 @@ private fun PrimaryActionButton( } -@Composable -private fun HltbHeroStrip(stats: app.gamenative.utils.HltbService.Stats) { - val context = LocalContext.current - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(Color.Black.copy(alpha = 0.45f)) - .padding(horizontal = 8.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - listOf( - stringResource(R.string.hltb_main_story) to stats.mainHours, - stringResource(R.string.hltb_main_plus_extras) to stats.mainPlusHours, - stringResource(R.string.hltb_completionist) to stats.completeHours, - stringResource(R.string.hltb_all_styles) to stats.allStylesHours, - ).forEach { (label, hours) -> - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = if (hours == "--") "--" else "${hours}h", - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), - color = Color.White, - ) - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = Color.White.copy(alpha = 0.7f), - textAlign = TextAlign.Center, - ) - } - } - if (stats.gameId > 0) { - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = stringResource(R.string.hltb_view_on_hltb), - tint = Color.White.copy(alpha = 0.7f), - modifier = Modifier - .size(18.dp) - .clickable { - context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse("https://howlongtobeat.com/game/${stats.gameId}")) - ) - }, - ) - } - } -} - /** * Icon-only action button for the overlay action bar */ diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/HltbHeroStrip.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/HltbHeroStrip.kt new file mode 100644 index 0000000000..f83ecb9296 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/HltbHeroStrip.kt @@ -0,0 +1,79 @@ +package app.gamenative.ui.screen.library.components + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import app.gamenative.R +import app.gamenative.utils.HltbService + +@Composable +fun HltbHeroStrip(stats: HltbService.Stats) { + val context = LocalContext.current + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black.copy(alpha = 0.45f)) + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + listOf( + stringResource(R.string.hltb_main_story) to stats.mainHours, + stringResource(R.string.hltb_main_plus_extras) to stats.mainPlusHours, + stringResource(R.string.hltb_completionist) to stats.completeHours, + stringResource(R.string.hltb_all_styles) to stats.allStylesHours, + ).forEach { (label, hours) -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (hours == "--") "--" else "${hours}h", + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), + color = Color.White, + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + ) + } + } + if (stats.gameId > 0) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = stringResource(R.string.hltb_view_on_hltb), + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier + .size(18.dp) + .clickable { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("${HltbService.GAME_URL}${stats.gameId}")) + ) + }, + ) + } + } +} diff --git a/app/src/main/java/app/gamenative/utils/HltbService.kt b/app/src/main/java/app/gamenative/utils/HltbService.kt index 4f988fc43c..d475e41126 100644 --- a/app/src/main/java/app/gamenative/utils/HltbService.kt +++ b/app/src/main/java/app/gamenative/utils/HltbService.kt @@ -30,6 +30,9 @@ object HltbService { private const val UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36" + /** Base URL for a game's HLTB page; append the numeric game ID to form the full URL. */ + const val GAME_URL = "https://howlongtobeat.com/game/" + @Serializable data class Stats( val mainHours: String, @@ -113,25 +116,32 @@ object HltbService { ?: return@withContext null if (data.length() == 0) return@withContext null - // Pick best match (exact name first, then closest by edit distance) - val norm = normalize(name) - var best = data.getJSONObject(0) - var bestDist = Int.MAX_VALUE - for (i in 0 until data.length()) { - val item = data.getJSONObject(i) - val d = levenshtein(norm, normalize(item.optString("game_name"))) - if (d < bestDist) { bestDist = d; best = item } - if (d == 0) break - } + // Pick best match (exact name first, then closest by edit distance) + val norm = normalize(name) + var bestMatch = data.getJSONObject(0) + var bestDist = Int.MAX_VALUE + for (i in 0 until data.length()) { + val candidate = data.getJSONObject(i) + val dist = levenshtein(norm, normalize(candidate.optString("game_name"))) + if (dist < bestDist) { bestDist = dist; bestMatch = candidate } + if (dist == 0) break + } - val g = best - Timber.tag("HLTB").i("'$name' → '${g.optString("game_name")}' main=${g.optLong("comp_main")}s") + // Reject poor fuzzy matches — avoids surfacing unrelated stub entries + val distanceThreshold = maxOf(3, norm.length / 2) + if (bestDist > distanceThreshold) return@withContext null + + // Skip entries with no completion data — don't poison the cache with placeholders + if (listOf("comp_main", "comp_plus", "comp_100", "comp_all") + .all { bestMatch.optLong(it) == 0L }) return@withContext null + + Timber.tag("HLTB").i("'$name' → '${bestMatch.optString("game_name")}' main=${bestMatch.optLong("comp_main")}s") Stats( - mainHours = secs(g.optLong("comp_main")), - mainPlusHours = secs(g.optLong("comp_plus")), - completeHours = secs(g.optLong("comp_100")), - allStylesHours = secs(g.optLong("comp_all")), - gameId = g.optInt("game_id", 0), + mainHours = secs(bestMatch.optLong("comp_main")), + mainPlusHours = secs(bestMatch.optLong("comp_plus")), + completeHours = secs(bestMatch.optLong("comp_100")), + allStylesHours = secs(bestMatch.optLong("comp_all")), + gameId = bestMatch.optInt("game_id", 0), ) } finally { conn.disconnect() @@ -152,22 +162,24 @@ object HltbService { stats } - private fun secs(s: Long) = if (s <= 0) "--" else "%.1f".format(s / 3600.0) - private fun normalize(s: String) = + internal fun secs(s: Long) = if (s <= 0) "--" else "%.1f".format(s / 3600.0) + internal fun normalize(s: String) = s.lowercase().replace(Regex("[^\\p{L}\\p{N}]"), " ").replace(Regex("\\s+"), " ").trim() - private fun levenshtein(a: String, b: String): Int { + internal fun levenshtein(a: String, b: String): Int { if (a == b) return 0 - val dp = Array(a.length + 1) { IntArray(b.length + 1) { it } } - for (j in 1..b.length) dp[0][j] = j + val dp = Array(a.length + 1) { IntArray(b.length + 1) } + for (i in 0..a.length) dp[i][0] = i + for (j in 0..b.length) dp[0][j] = j for (i in 1..a.length) for (j in 1..b.length) dp[i][j] = minOf(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+(if (a[i-1]==b[j-1]) 0 else 1)) return dp[a.length][b.length] } } -/** In-memory + DataStore cache for HLTB stats (12-hour TTL). */ +/** In-memory + DataStore cache for HLTB stats (12-hour TTL, max 200 entries). */ object HltbCache { private const val TTL = 12 * 3_600_000L + internal const val MAX_ENTRIES = 200 private val mem = mutableMapOf() private val stamps = mutableMapOf() private var loaded = false @@ -175,19 +187,24 @@ object HltbCache { @Serializable data class Entry(val stats: HltbService.Stats, val ts: Long) + @Synchronized private fun load() { if (loaded) return - loaded = true try { val raw = PrefManager.hltbCache - if (raw == "{}") return - val now = System.currentTimeMillis() - json.decodeFromString>(raw).forEach { (k, e) -> - if (now - e.ts < TTL) { mem[k] = e.stats; stamps[k] = e.ts } + if (raw != "{}") { + val now = System.currentTimeMillis() + json.decodeFromString>(raw).forEach { (k, e) -> + if (now - e.ts < TTL) { mem[k] = e.stats; stamps[k] = e.ts } + } } - } catch (_: Exception) {} + } catch (_: Exception) { + } finally { + loaded = true + } } + @Synchronized private fun save() { try { val now = System.currentTimeMillis() @@ -195,6 +212,7 @@ object HltbCache { } catch (_: Exception) {} } + @Synchronized fun get(name: String): HltbService.Stats? { load() val k = key(name) @@ -203,11 +221,23 @@ object HltbCache { return mem[k] } + @Synchronized fun put(name: String, stats: HltbService.Stats) { - load(); val k = key(name) - mem[k] = stats; stamps[k] = System.currentTimeMillis(); save() + load() + val k = key(name) + if (mem.size >= MAX_ENTRIES && !mem.containsKey(k)) { + // Evict the oldest entry to stay within memory budget + stamps.minByOrNull { it.value }?.key?.let { oldest -> mem.remove(oldest); stamps.remove(oldest) } + } + mem[k] = stats + stamps[k] = System.currentTimeMillis() + save() } - private fun key(s: String) = + internal fun key(s: String) = s.lowercase().replace(Regex("[^\\p{L}\\p{N}]"), " ").replace(Regex("\\s+"), " ").trim() + + /** Reset state — for testing only. */ + @Synchronized + internal fun reset() { mem.clear(); stamps.clear(); loaded = false } } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 2509c2e2e8..497f87529a 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1264,4 +1264,10 @@ Kunne ikke importere gemte filer: %s Importér gemte spil Eksportér gemte spil + + Hovedhistorie + Hoved + Ekstra + Perfektionist + Alle stilarter + Se på HowLongToBeat diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0d53f1360b..7cce10439d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1334,4 +1334,10 @@ Spielstände konnten nicht importiert werden: %s Spielstände importieren Spielstände exportieren + + Hauptstory + Haupt + Extras + Perfektionist + Alle Spielstile + Auf HowLongToBeat ansehen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e3e6c30c80..b03063ff3e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1400,4 +1400,10 @@ Error al importar partidas: %s Importar partidas guardadas Exportar partidas guardadas + + Historia principal + Principal + Extras + Completista + Todos los estilos + Ver en HowLongToBeat diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 3d25a04d73..cfa3976cb7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1394,4 +1394,10 @@ Échec de l\'importation des sauvegardes : %s Importer les sauvegardes Exporter les sauvegardes + + Histoire principale + Principal + Extras + Complétiste + Tous les styles + Voir sur HowLongToBeat diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e9ae73f906..9c57a0c363 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1390,4 +1390,10 @@ Importazione salvataggi fallita: %s Importa salvataggi Esporta salvataggi + + Storia principale + Principale + Extra + Completista + Tutti gli stili + Vedi su HowLongToBeat diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b8ff35762a..92a1c9c9eb 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1398,4 +1398,10 @@ 세이브 데이터 가져오기 실패: %s 세이브 가져오기 세이브 내보내기 + + 메인 스토리 + 메인 + 추가 + 완벽주의자 + 모든 스타일 + HowLongToBeat에서 보기 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d24c151442..5e0d647e9a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1397,4 +1397,10 @@ Nie udało się zaimportować zapisów: %s Importuj zapisy Eksportuj zapisy + + Główna fabuła + Główna + Dodatki + Perfekcjonista + Wszystkie style + Zobacz na HowLongToBeat diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 54f1b95b7a..36c939f157 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1264,4 +1264,10 @@ Falha ao importar saves: %s Importar saves Exportar saves + + História principal + Principal + Extras + Completista + Todos os estilos + Ver no HowLongToBeat diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index ecc49db376..5efdf4cb05 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1399,4 +1399,10 @@ Importul salvărilor a eșuat: %s Importă salvări Exportă salvări + + Poveste principală + Principal + Suplimente + Perfecționist + Toate stilurile + Vezi pe HowLongToBeat diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index e5621a1277..4d1f658290 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1327,4 +1327,10 @@ https://gamenative.app Не удалось импортировать сохранения: %s Импортировать сохранения Экспортировать сохранения + + Основной сюжет + Основное + Дополнения + Перфекционист + Все стили + Смотреть на HowLongToBeat diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 3e05c5ca83..ddc804a95e 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1393,4 +1393,10 @@ Не вдалося імпортувати збереження: %s Імпортувати збереження Експортувати збереження + + Основний сюжет + Основне + Доповнення + Перфекціоніст + Усі стилі + Переглянути на HowLongToBeat diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 69c167fa0e..6923741fc1 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1387,4 +1387,10 @@ 导入存档失败:%s 导入存档 导出存档 + + 主线剧情 + 主线 + 支线 + 完美主义者 + 所有风格 + 在 HowLongToBeat 上查看 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0275f5f9a3..5ef592297a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1390,4 +1390,10 @@ 匯入存檔失敗:%s 匯入存檔 匯出存檔 + + 主線劇情 + 主線 + 支線 + 完美主義者 + 所有風格 + 在 HowLongToBeat 上查看 diff --git a/app/src/test/java/app/gamenative/utils/HltbCacheTest.kt b/app/src/test/java/app/gamenative/utils/HltbCacheTest.kt new file mode 100644 index 0000000000..4374af9b7e --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/HltbCacheTest.kt @@ -0,0 +1,107 @@ +package app.gamenative.utils + +import app.gamenative.PrefManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockkObject +import io.mockk.runs +import io.mockk.unmockkObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class HltbCacheTest { + + private val sampleStats = HltbService.Stats( + mainHours = "10.0", + mainPlusHours = "15.0", + completeHours = "25.0", + allStylesHours = "12.0", + gameId = 42, + ) + + @Before + fun setUp() { + mockkObject(PrefManager) + every { PrefManager.hltbCache } returns "{}" + every { PrefManager.hltbCache = any() } just runs + HltbCache.reset() + } + + @After + fun tearDown() { + unmockkObject(PrefManager) + } + + // ── basic get / put ────────────────────────────────────────────────────── + + @Test + fun get_returnsPreviouslyPutStats() { + HltbCache.put("Halo", sampleStats) + assertNotNull(HltbCache.get("Halo")) + assertEquals(sampleStats, HltbCache.get("Halo")) + } + + @Test + fun get_returnsCaseInsensitiveMatch() { + HltbCache.put("Hollow Knight", sampleStats) + assertNotNull(HltbCache.get("hollow knight")) + assertNotNull(HltbCache.get("HOLLOW KNIGHT")) + } + + @Test + fun get_returnsNullForMissingEntry() { + assertNull(HltbCache.get("Unknown Game")) + } + + // ── key normalisation ──────────────────────────────────────────────────── + + @Test + fun key_normalisesPunctuation() { + // "Elden Ring!" and "Elden Ring" should resolve to the same key + HltbCache.put("Elden Ring!", sampleStats) + assertNotNull(HltbCache.get("Elden Ring")) + } + + // ── TTL eviction ───────────────────────────────────────────────────────── + + @Test + fun get_returnsNullAfterTtlExpires() { + // Put with an artificially old timestamp by manipulating via put then expiring via reset trick: + // We can't set the stamp directly, so we verify via a fresh cache that + // a non-expired entry survives, and rely on the TTL constant being 12 h. + HltbCache.put("Celeste", sampleStats) + // Immediately after put the entry should be valid (well within 12-hour TTL) + assertNotNull(HltbCache.get("Celeste")) + } + + // ── MAX_ENTRIES cap ────────────────────────────────────────────────────── + + @Test + fun put_evictsOldestWhenCapReached() { + // Fill cache to MAX_ENTRIES + repeat(HltbCache.MAX_ENTRIES) { i -> + HltbCache.put("Game $i", sampleStats) + } + // Verify an entry inside the cap exists + assertNotNull(HltbCache.get("Game 0")) + + // Adding one more entry should evict something — total should remain ≤ MAX_ENTRIES + HltbCache.put("Overflow Game", sampleStats) + // The new entry must be present + assertNotNull(HltbCache.get("Overflow Game")) + } + + // ── reset ──────────────────────────────────────────────────────────────── + + @Test + fun reset_clearsAllEntries() { + HltbCache.put("Halo", sampleStats) + HltbCache.reset() + assertNull(HltbCache.get("Halo")) + } +} + diff --git a/app/src/test/java/app/gamenative/utils/HltbServiceTest.kt b/app/src/test/java/app/gamenative/utils/HltbServiceTest.kt new file mode 100644 index 0000000000..f6bbc0b1f5 --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/HltbServiceTest.kt @@ -0,0 +1,133 @@ +package app.gamenative.utils + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test + +class HltbServiceTest { + + // ── secs() ────────────────────────────────────────────────────────────── + + @Test + fun secs_zeroReturnsPlaceholder() { + assertEquals("--", HltbService.secs(0L)) + } + + @Test + fun secs_negativeReturnsPlaceholder() { + assertEquals("--", HltbService.secs(-3600L)) + } + + @Test + fun secs_oneHourFormatsCorrectly() { + assertEquals("1.0", HltbService.secs(3600L)) + } + + @Test + fun secs_ninetyMinutesFormatsCorrectly() { + assertEquals("1.5", HltbService.secs(5400L)) + } + + @Test + fun secs_fractionalHourRoundsToOneDecimal() { + // 3700s ≈ 1.027… → "1.0" + assertEquals("1.0", HltbService.secs(3700L)) + } + + @Test + fun secs_largeValueFormatsCorrectly() { + // 100 hours + assertEquals("100.0", HltbService.secs(360_000L)) + } + + // ── normalize() ───────────────────────────────────────────────────────── + + @Test + fun normalize_lowercasesInput() { + assertEquals("halo", HltbService.normalize("HALO")) + } + + @Test + fun normalize_replacesSpecialCharsWithSpace() { + assertEquals("the witcher 3", HltbService.normalize("The Witcher 3")) + } + + @Test + fun normalize_collapsesMultipleSpaces() { + assertEquals("a b c", HltbService.normalize("A B C")) + } + + @Test + fun normalize_stripsPunctuation() { + assertEquals("hollow knight", HltbService.normalize("Hollow Knight!")) + } + + @Test + fun normalize_trimsLeadingAndTrailingSpaces() { + assertEquals("celeste", HltbService.normalize(" Celeste ")) + } + + // ── levenshtein() ──────────────────────────────────────────────────────── + + @Test + fun levenshtein_identicalStringsReturnZero() { + assertEquals(0, HltbService.levenshtein("halo", "halo")) + } + + @Test + fun levenshtein_emptyAndNonEmptyReturnsLength() { + assertEquals(4, HltbService.levenshtein("", "halo")) + assertEquals(4, HltbService.levenshtein("halo", "")) + } + + @Test + fun levenshtein_singleSubstitution() { + assertEquals(1, HltbService.levenshtein("halo", "hale")) + } + + @Test + fun levenshtein_singleInsertion() { + assertEquals(1, HltbService.levenshtein("halo", "halos")) + } + + @Test + fun levenshtein_singleDeletion() { + assertEquals(1, HltbService.levenshtein("halos", "halo")) + } + + @Test + fun levenshtein_completelyDifferentStrings() { + assertEquals(4, HltbService.levenshtein("halo", "doom")) + } + + @Test + fun levenshtein_isSymmetric() { + val a = "witcher" + val b = "alchemy" + assertEquals(HltbService.levenshtein(a, b), HltbService.levenshtein(b, a)) + } + + // ── normalize + levenshtein integration ────────────────────────────────── + + @Test + fun normalizeAndLevenshtein_exactMatchAfterNormalizeIsZero() { + val dist = HltbService.levenshtein( + HltbService.normalize("The Witcher 3: Wild Hunt"), + HltbService.normalize("The Witcher 3: Wild Hunt"), + ) + assertEquals(0, dist) + } + + @Test + fun normalizeAndLevenshtein_closeMatchHasSmallDistance() { + // "Hollow Knight" vs "Hollow Knight" with trailing punctuation stripped + val dist = HltbService.levenshtein( + HltbService.normalize("Hollow Knight"), + HltbService.normalize("Hollow Knight!"), + ) + assertEquals(0, dist) + } +} From def120d55590f0ab63a26b35a82b25b90a65adc3 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 23 Apr 2026 17:46:59 -0400 Subject: [PATCH 08/10] fix: address follow-up hltb review comments --- .../library/components/HltbHeroStrip.kt | 29 ++++++++++++------- .../java/app/gamenative/utils/HltbService.kt | 12 ++++++-- .../app/gamenative/utils/HltbCacheTest.kt | 12 ++------ 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/HltbHeroStrip.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/HltbHeroStrip.kt index f83ecb9296..f88a4d7cf3 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/HltbHeroStrip.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/HltbHeroStrip.kt @@ -1,9 +1,9 @@ package app.gamenative.ui.screen.library.components +import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,6 +14,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -28,6 +29,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import app.gamenative.R import app.gamenative.utils.HltbService +import timber.log.Timber @Composable fun HltbHeroStrip(stats: HltbService.Stats) { @@ -62,18 +64,25 @@ fun HltbHeroStrip(stats: HltbService.Stats) { } } if (stats.gameId > 0) { - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = stringResource(R.string.hltb_view_on_hltb), - tint = Color.White.copy(alpha = 0.7f), - modifier = Modifier - .size(18.dp) - .clickable { + IconButton( + onClick = { + try { context.startActivity( Intent(Intent.ACTION_VIEW, Uri.parse("${HltbService.GAME_URL}${stats.gameId}")) ) - }, - ) + } catch (e: ActivityNotFoundException) { + Timber.tag("HLTB").w(e, "No handler for HLTB game URL") + } + }, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = stringResource(R.string.hltb_view_on_hltb), + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(18.dp), + ) + } } } } diff --git a/app/src/main/java/app/gamenative/utils/HltbService.kt b/app/src/main/java/app/gamenative/utils/HltbService.kt index d475e41126..534dfb6033 100644 --- a/app/src/main/java/app/gamenative/utils/HltbService.kt +++ b/app/src/main/java/app/gamenative/utils/HltbService.kt @@ -194,9 +194,15 @@ object HltbCache { val raw = PrefManager.hltbCache if (raw != "{}") { val now = System.currentTimeMillis() - json.decodeFromString>(raw).forEach { (k, e) -> - if (now - e.ts < TTL) { mem[k] = e.stats; stamps[k] = e.ts } - } + json.decodeFromString>(raw) + .asSequence() + .filter { (_, entry) -> now - entry.ts < TTL } + .sortedByDescending { (_, entry) -> entry.ts } + .take(MAX_ENTRIES) + .forEach { (key, entry) -> + mem[key] = entry.stats + stamps[key] = entry.ts + } } } catch (_: Exception) { } finally { diff --git a/app/src/test/java/app/gamenative/utils/HltbCacheTest.kt b/app/src/test/java/app/gamenative/utils/HltbCacheTest.kt index 4374af9b7e..f6c505e5ce 100644 --- a/app/src/test/java/app/gamenative/utils/HltbCacheTest.kt +++ b/app/src/test/java/app/gamenative/utils/HltbCacheTest.kt @@ -69,12 +69,8 @@ class HltbCacheTest { // ── TTL eviction ───────────────────────────────────────────────────────── @Test - fun get_returnsNullAfterTtlExpires() { - // Put with an artificially old timestamp by manipulating via put then expiring via reset trick: - // We can't set the stamp directly, so we verify via a fresh cache that - // a non-expired entry survives, and rely on the TTL constant being 12 h. + fun get_returnsEntryWithinTtl() { HltbCache.put("Celeste", sampleStats) - // Immediately after put the entry should be valid (well within 12-hour TTL) assertNotNull(HltbCache.get("Celeste")) } @@ -82,16 +78,14 @@ class HltbCacheTest { @Test fun put_evictsOldestWhenCapReached() { - // Fill cache to MAX_ENTRIES repeat(HltbCache.MAX_ENTRIES) { i -> HltbCache.put("Game $i", sampleStats) } - // Verify an entry inside the cap exists assertNotNull(HltbCache.get("Game 0")) - // Adding one more entry should evict something — total should remain ≤ MAX_ENTRIES HltbCache.put("Overflow Game", sampleStats) - // The new entry must be present + + assertNull(HltbCache.get("Game 0")) assertNotNull(HltbCache.get("Overflow Game")) } From 0c99c2fc696d20e107c6f740d9293a274f2efd9d Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Fri, 24 Apr 2026 00:14:46 -0400 Subject: [PATCH 09/10] fix: address remaining hltb review feedback --- .../java/app/gamenative/utils/HltbService.kt | 93 ++++++---- .../utils/HltbServiceIntegrationTest.kt | 171 ++++++++++++++++++ .../app/gamenative/utils/HltbServiceTest.kt | 30 ++- 3 files changed, 241 insertions(+), 53 deletions(-) create mode 100644 app/src/test/java/app/gamenative/utils/HltbServiceIntegrationTest.kt diff --git a/app/src/main/java/app/gamenative/utils/HltbService.kt b/app/src/main/java/app/gamenative/utils/HltbService.kt index 534dfb6033..8a05919b0d 100644 --- a/app/src/main/java/app/gamenative/utils/HltbService.kt +++ b/app/src/main/java/app/gamenative/utils/HltbService.kt @@ -13,6 +13,9 @@ import timber.log.Timber import java.net.HttpURLConnection import java.net.URL +private fun normalizedKey(input: String) = + input.lowercase().replace(Regex("[^\\p{L}\\p{N}]"), " ").replace(Regex("\\s+"), " ").trim() + /** * Fetches HowLongToBeat completion time stats for a game. * @@ -25,8 +28,9 @@ import java.net.URL */ object HltbService { - private const val BASE = "https://howlongtobeat.com" + private const val DEFAULT_API_BASE_URL = "https://howlongtobeat.com" private const val SEARCH_PATH = "/api/find" + private const val INIT_PATH = "$SEARCH_PATH/init" private const val UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36" @@ -44,7 +48,8 @@ object HltbService { private data class Auth(val token: String, val hpKey: String, val hpVal: String) - private var auth: Auth? = null + @Volatile private var auth: Auth? = null + @Volatile private var apiBaseUrl = DEFAULT_API_BASE_URL private val httpClient = okhttp3.OkHttpClient.Builder() .protocols(listOf(okhttp3.Protocol.HTTP_1_1)) @@ -55,8 +60,8 @@ object HltbService { /** Fetch auth tokens from the HLTB init endpoint. */ private suspend fun fetchAuth(): Auth? = withContext(Dispatchers.IO) { try { - val req = Request.Builder().url("$BASE$SEARCH_PATH/init?t=${System.currentTimeMillis()}") - .header("Origin", BASE).header("Referer", "$BASE/").header("User-Agent", UA).build() + val req = Request.Builder().url("$apiBaseUrl$INIT_PATH?t=${System.currentTimeMillis()}") + .header("Origin", apiBaseUrl).header("Referer", "$apiBaseUrl/").header("User-Agent", UA).build() httpClient.newCall(req).execute().use { resp -> if (!resp.isSuccessful) return@withContext null val d = JSONObject(resp.body?.string() ?: return@withContext null) @@ -95,12 +100,12 @@ object HltbService { put(a.hpKey, a.hpVal) }.toString().toByteArray() - val conn = URL("$BASE$SEARCH_PATH").openConnection() as HttpURLConnection + val conn = URL("$apiBaseUrl$SEARCH_PATH").openConnection() as HttpURLConnection conn.connectTimeout = 15_000 conn.readTimeout = 30_000 conn.requestMethod = "POST"; conn.doOutput = true conn.setRequestProperty("Content-Type", "application/json") - conn.setRequestProperty("Origin", BASE); conn.setRequestProperty("Referer", "$BASE/") + conn.setRequestProperty("Origin", apiBaseUrl); conn.setRequestProperty("Referer", "$apiBaseUrl/") conn.setRequestProperty("x-auth-token", a.token) conn.setRequestProperty("x-hp-key", a.hpKey); conn.setRequestProperty("x-hp-val", a.hpVal) conn.setRequestProperty("User-Agent", UA) @@ -117,19 +122,20 @@ object HltbService { if (data.length() == 0) return@withContext null // Pick best match (exact name first, then closest by edit distance) - val norm = normalize(name) + val normalizedName = normalize(name) var bestMatch = data.getJSONObject(0) - var bestDist = Int.MAX_VALUE - for (i in 0 until data.length()) { - val candidate = data.getJSONObject(i) - val dist = levenshtein(norm, normalize(candidate.optString("game_name"))) - if (dist < bestDist) { bestDist = dist; bestMatch = candidate } - if (dist == 0) break + var bestDistance = Int.MAX_VALUE + for (index in 0 until data.length()) { + val candidate = data.getJSONObject(index) + val candidateName = candidate.optString("game_name") + val distance = levenshtein(normalizedName, normalize(candidateName)) + if (distance < bestDistance) { bestDistance = distance; bestMatch = candidate } + if (distance == 0) break } // Reject poor fuzzy matches — avoids surfacing unrelated stub entries - val distanceThreshold = maxOf(3, norm.length / 2) - if (bestDist > distanceThreshold) return@withContext null + val distanceThreshold = maxOf(3, normalizedName.length / 2) + if (bestDistance > distanceThreshold) return@withContext null // Skip entries with no completion data — don't poison the cache with placeholders if (listOf("comp_main", "comp_plus", "comp_100", "comp_all") @@ -137,10 +143,10 @@ object HltbService { Timber.tag("HLTB").i("'$name' → '${bestMatch.optString("game_name")}' main=${bestMatch.optLong("comp_main")}s") Stats( - mainHours = secs(bestMatch.optLong("comp_main")), - mainPlusHours = secs(bestMatch.optLong("comp_plus")), - completeHours = secs(bestMatch.optLong("comp_100")), - allStylesHours = secs(bestMatch.optLong("comp_all")), + mainHours = formatHours(bestMatch.optLong("comp_main")), + mainPlusHours = formatHours(bestMatch.optLong("comp_plus")), + completeHours = formatHours(bestMatch.optLong("comp_100")), + allStylesHours = formatHours(bestMatch.optLong("comp_all")), gameId = bestMatch.optInt("game_id", 0), ) } finally { @@ -153,26 +159,42 @@ object HltbService { suspend fun getStats(name: String): Stats? = withContext(Dispatchers.IO) { if (name.isBlank()) return@withContext null HltbCache.get(name)?.let { return@withContext it } - val a = auth ?: fetchAuth() ?: return@withContext null - val stats = search(name, a) ?: run { - val fresh = fetchAuth() ?: return@withContext null - search(name, fresh) + val cachedAuth = auth ?: fetchAuth() ?: return@withContext null + val firstAttempt = search(name, cachedAuth) + val stats = firstAttempt ?: run { + if (auth != null) return@withContext null + val refreshedAuth = fetchAuth() ?: return@withContext null + search(name, refreshedAuth) } ?: return@withContext null HltbCache.put(name, stats) stats } - internal fun secs(s: Long) = if (s <= 0) "--" else "%.1f".format(s / 3600.0) - internal fun normalize(s: String) = - s.lowercase().replace(Regex("[^\\p{L}\\p{N}]"), " ").replace(Regex("\\s+"), " ").trim() - internal fun levenshtein(a: String, b: String): Int { - if (a == b) return 0 - val dp = Array(a.length + 1) { IntArray(b.length + 1) } - for (i in 0..a.length) dp[i][0] = i - for (j in 0..b.length) dp[0][j] = j - for (i in 1..a.length) for (j in 1..b.length) - dp[i][j] = minOf(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+(if (a[i-1]==b[j-1]) 0 else 1)) - return dp[a.length][b.length] + internal fun formatHours(seconds: Long) = if (seconds <= 0) "--" else "%.1f".format(seconds / 3600.0) + internal fun normalize(input: String) = normalizedKey(input) + internal fun levenshtein(left: String, right: String): Int { + if (left == right) return 0 + val dp = Array(left.length + 1) { IntArray(right.length + 1) } + for (leftIndex in 0..left.length) dp[leftIndex][0] = leftIndex + for (rightIndex in 0..right.length) dp[0][rightIndex] = rightIndex + for (leftIndex in 1..left.length) for (rightIndex in 1..right.length) + dp[leftIndex][rightIndex] = minOf( + dp[leftIndex - 1][rightIndex] + 1, + dp[leftIndex][rightIndex - 1] + 1, + dp[leftIndex - 1][rightIndex - 1] + + (if (left[leftIndex - 1] == right[rightIndex - 1]) 0 else 1), + ) + return dp[left.length][right.length] + } + + internal fun setApiBaseUrlForTesting(baseUrl: String) { + apiBaseUrl = baseUrl.trimEnd('/') + auth = null + } + + internal fun resetForTesting() { + apiBaseUrl = DEFAULT_API_BASE_URL + auth = null } } @@ -240,8 +262,7 @@ object HltbCache { save() } - internal fun key(s: String) = - s.lowercase().replace(Regex("[^\\p{L}\\p{N}]"), " ").replace(Regex("\\s+"), " ").trim() + internal fun key(name: String) = normalizedKey(name) /** Reset state — for testing only. */ @Synchronized diff --git a/app/src/test/java/app/gamenative/utils/HltbServiceIntegrationTest.kt b/app/src/test/java/app/gamenative/utils/HltbServiceIntegrationTest.kt new file mode 100644 index 0000000000..142e27ebd0 --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/HltbServiceIntegrationTest.kt @@ -0,0 +1,171 @@ +package app.gamenative.utils + +import app.gamenative.PrefManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockkObject +import io.mockk.runs +import io.mockk.unmockkObject +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class HltbServiceIntegrationTest { + + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + + mockkObject(PrefManager) + every { PrefManager.hltbCache } returns "{}" + every { PrefManager.hltbCache = any() } just runs + + HltbCache.reset() + HltbService.setApiBaseUrlForTesting(server.url("/").toString().removeSuffix("/")) + } + + @After + fun tearDown() { + HltbService.resetForTesting() + HltbCache.reset() + unmockkObject(PrefManager) + server.shutdown() + } + + @Test + fun getStats_fetchesAndFormatsBestStubbedMatch() = runBlocking { + enqueueAuthResponse() + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "data": [ + { + "game_name": "Halo Wars", + "comp_main": 7200, + "comp_plus": 10800, + "comp_100": 14400, + "comp_all": 18000, + "game_id": 2 + }, + { + "game_name": "Halo", + "comp_main": 3600, + "comp_plus": 5400, + "comp_100": 7200, + "comp_all": 9000, + "game_id": 1 + } + ] + } + """.trimIndent(), + ), + ) + + val stats = HltbService.getStats("Halo") + + assertNotNull(stats) + assertEquals("1.0", stats?.mainHours) + assertEquals("1.5", stats?.mainPlusHours) + assertEquals("2.0", stats?.completeHours) + assertEquals("2.5", stats?.allStylesHours) + assertEquals(1, stats?.gameId) + + val initRequest = server.takeRequest() + assertEquals("GET", initRequest.method) + assertEquals("http://localhost:${server.port}", initRequest.getHeader("Origin")) + assertEquals("http://localhost:${server.port}/", initRequest.getHeader("Referer")) + + val searchRequest = server.takeRequest() + assertEquals("POST", searchRequest.method) + assertEquals("token-123", searchRequest.getHeader("x-auth-token")) + assertEquals("hp-key", searchRequest.getHeader("x-hp-key")) + assertEquals("hp-val", searchRequest.getHeader("x-hp-val")) + + val payload = JSONObject(searchRequest.body.readUtf8()) + assertEquals("games", payload.getString("searchType")) + assertEquals("Halo", payload.getJSONArray("searchTerms").getString(0)) + assertEquals("hp-val", payload.getString("hp-key")) + } + + @Test + fun getStats_usesCacheOnRepeatedCalls() = runBlocking { + enqueueAuthResponse() + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "data": [ + { + "game_name": "Celeste", + "comp_main": 14400, + "comp_plus": 21600, + "comp_100": 28800, + "comp_all": 32400, + "game_id": 99 + } + ] + } + """.trimIndent(), + ), + ) + + val first = HltbService.getStats("Celeste") + val requestCountAfterFirstCall = server.requestCount + val second = HltbService.getStats("Celeste") + + assertEquals(first, second) + assertEquals(2, requestCountAfterFirstCall) + assertEquals(2, server.requestCount) + } + + @Test + fun getStats_returnsNullForZeroValueStubbedMatch() = runBlocking { + enqueueAuthResponse() + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "data": [ + { + "game_name": "Empty Game", + "comp_main": 0, + "comp_plus": 0, + "comp_100": 0, + "comp_all": 0, + "game_id": 404 + } + ] + } + """.trimIndent(), + ), + ) + + assertNull(HltbService.getStats("Empty Game")) + assertEquals(2, server.requestCount) + } + + private fun enqueueAuthResponse() { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "token": "token-123", + "session_key": "hp-key", + "session_val": "hp-val" + } + """.trimIndent(), + ), + ) + } +} diff --git a/app/src/test/java/app/gamenative/utils/HltbServiceTest.kt b/app/src/test/java/app/gamenative/utils/HltbServiceTest.kt index f6bbc0b1f5..d652ca9ee8 100644 --- a/app/src/test/java/app/gamenative/utils/HltbServiceTest.kt +++ b/app/src/test/java/app/gamenative/utils/HltbServiceTest.kt @@ -1,46 +1,42 @@ package app.gamenative.utils -import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertNotNull -import org.junit.Before import org.junit.Test class HltbServiceTest { - // ── secs() ────────────────────────────────────────────────────────────── + // ── formatHours() ─────────────────────────────────────────────────────── @Test - fun secs_zeroReturnsPlaceholder() { - assertEquals("--", HltbService.secs(0L)) + fun formatHours_zeroReturnsPlaceholder() { + assertEquals("--", HltbService.formatHours(0L)) } @Test - fun secs_negativeReturnsPlaceholder() { - assertEquals("--", HltbService.secs(-3600L)) + fun formatHours_negativeReturnsPlaceholder() { + assertEquals("--", HltbService.formatHours(-3600L)) } @Test - fun secs_oneHourFormatsCorrectly() { - assertEquals("1.0", HltbService.secs(3600L)) + fun formatHours_oneHourFormatsCorrectly() { + assertEquals("1.0", HltbService.formatHours(3600L)) } @Test - fun secs_ninetyMinutesFormatsCorrectly() { - assertEquals("1.5", HltbService.secs(5400L)) + fun formatHours_ninetyMinutesFormatsCorrectly() { + assertEquals("1.5", HltbService.formatHours(5400L)) } @Test - fun secs_fractionalHourRoundsToOneDecimal() { + fun formatHours_fractionalHourRoundsToOneDecimal() { // 3700s ≈ 1.027… → "1.0" - assertEquals("1.0", HltbService.secs(3700L)) + assertEquals("1.0", HltbService.formatHours(3700L)) } @Test - fun secs_largeValueFormatsCorrectly() { + fun formatHours_largeValueFormatsCorrectly() { // 100 hours - assertEquals("100.0", HltbService.secs(360_000L)) + assertEquals("100.0", HltbService.formatHours(360_000L)) } // ── normalize() ───────────────────────────────────────────────────────── From c5f1bfa8c4d54cef6707724eb805102cbb7660cf Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Fri, 24 Apr 2026 00:35:18 -0400 Subject: [PATCH 10/10] test: reduce HLTB test boilerplate --- .../app/gamenative/utils/HltbCacheTest.kt | 47 ++---- .../utils/HltbServiceIntegrationTest.kt | 102 ++++-------- .../app/gamenative/utils/HltbServiceTest.kt | 146 +++++------------- 3 files changed, 84 insertions(+), 211 deletions(-) diff --git a/app/src/test/java/app/gamenative/utils/HltbCacheTest.kt b/app/src/test/java/app/gamenative/utils/HltbCacheTest.kt index f6c505e5ce..37d34e5451 100644 --- a/app/src/test/java/app/gamenative/utils/HltbCacheTest.kt +++ b/app/src/test/java/app/gamenative/utils/HltbCacheTest.kt @@ -36,20 +36,15 @@ class HltbCacheTest { unmockkObject(PrefManager) } - // ── basic get / put ────────────────────────────────────────────────────── - - @Test - fun get_returnsPreviouslyPutStats() { - HltbCache.put("Halo", sampleStats) - assertNotNull(HltbCache.get("Halo")) - assertEquals(sampleStats, HltbCache.get("Halo")) - } - @Test - fun get_returnsCaseInsensitiveMatch() { - HltbCache.put("Hollow Knight", sampleStats) - assertNotNull(HltbCache.get("hollow knight")) - assertNotNull(HltbCache.get("HOLLOW KNIGHT")) + fun get_returnsStoredStatsForNormalizedKeys() { + HltbCache.put("Hollow Knight!", sampleStats) + + listOf("Hollow Knight!", "hollow knight", "HOLLOW KNIGHT", "Hollow Knight") + .forEach { key -> + assertNotNull(HltbCache.get(key)) + assertEquals(sampleStats, HltbCache.get(key)) + } } @Test @@ -57,29 +52,10 @@ class HltbCacheTest { assertNull(HltbCache.get("Unknown Game")) } - // ── key normalisation ──────────────────────────────────────────────────── - - @Test - fun key_normalisesPunctuation() { - // "Elden Ring!" and "Elden Ring" should resolve to the same key - HltbCache.put("Elden Ring!", sampleStats) - assertNotNull(HltbCache.get("Elden Ring")) - } - - // ── TTL eviction ───────────────────────────────────────────────────────── - - @Test - fun get_returnsEntryWithinTtl() { - HltbCache.put("Celeste", sampleStats) - assertNotNull(HltbCache.get("Celeste")) - } - - // ── MAX_ENTRIES cap ────────────────────────────────────────────────────── - @Test fun put_evictsOldestWhenCapReached() { - repeat(HltbCache.MAX_ENTRIES) { i -> - HltbCache.put("Game $i", sampleStats) + repeat(HltbCache.MAX_ENTRIES) { index -> + HltbCache.put("Game $index", sampleStats) } assertNotNull(HltbCache.get("Game 0")) @@ -89,8 +65,6 @@ class HltbCacheTest { assertNotNull(HltbCache.get("Overflow Game")) } - // ── reset ──────────────────────────────────────────────────────────────── - @Test fun reset_clearsAllEntries() { HltbCache.put("Halo", sampleStats) @@ -98,4 +72,3 @@ class HltbCacheTest { assertNull(HltbCache.get("Halo")) } } - diff --git a/app/src/test/java/app/gamenative/utils/HltbServiceIntegrationTest.kt b/app/src/test/java/app/gamenative/utils/HltbServiceIntegrationTest.kt index 142e27ebd0..136b6b6ac7 100644 --- a/app/src/test/java/app/gamenative/utils/HltbServiceIntegrationTest.kt +++ b/app/src/test/java/app/gamenative/utils/HltbServiceIntegrationTest.kt @@ -9,6 +9,7 @@ import io.mockk.unmockkObject import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import org.json.JSONArray import org.json.JSONObject import org.junit.After import org.junit.Assert.assertEquals @@ -45,31 +46,9 @@ class HltbServiceIntegrationTest { @Test fun getStats_fetchesAndFormatsBestStubbedMatch() = runBlocking { enqueueAuthResponse() - server.enqueue( - MockResponse().setResponseCode(200).setBody( - """ - { - "data": [ - { - "game_name": "Halo Wars", - "comp_main": 7200, - "comp_plus": 10800, - "comp_100": 14400, - "comp_all": 18000, - "game_id": 2 - }, - { - "game_name": "Halo", - "comp_main": 3600, - "comp_plus": 5400, - "comp_100": 7200, - "comp_all": 9000, - "game_id": 1 - } - ] - } - """.trimIndent(), - ), + enqueueSearchResponse( + game("Halo Wars", 7200, 10800, 14400, 18000, 2), + game("Halo", 3600, 5400, 7200, 9000, 1), ) val stats = HltbService.getStats("Halo") @@ -101,24 +80,7 @@ class HltbServiceIntegrationTest { @Test fun getStats_usesCacheOnRepeatedCalls() = runBlocking { enqueueAuthResponse() - server.enqueue( - MockResponse().setResponseCode(200).setBody( - """ - { - "data": [ - { - "game_name": "Celeste", - "comp_main": 14400, - "comp_plus": 21600, - "comp_100": 28800, - "comp_all": 32400, - "game_id": 99 - } - ] - } - """.trimIndent(), - ), - ) + enqueueSearchResponse(game("Celeste", 14400, 21600, 28800, 32400, 99)) val first = HltbService.getStats("Celeste") val requestCountAfterFirstCall = server.requestCount @@ -132,24 +94,7 @@ class HltbServiceIntegrationTest { @Test fun getStats_returnsNullForZeroValueStubbedMatch() = runBlocking { enqueueAuthResponse() - server.enqueue( - MockResponse().setResponseCode(200).setBody( - """ - { - "data": [ - { - "game_name": "Empty Game", - "comp_main": 0, - "comp_plus": 0, - "comp_100": 0, - "comp_all": 0, - "game_id": 404 - } - ] - } - """.trimIndent(), - ), - ) + enqueueSearchResponse(game("Empty Game", 0, 0, 0, 0, 404)) assertNull(HltbService.getStats("Empty Game")) assertEquals(2, server.requestCount) @@ -158,14 +103,35 @@ class HltbServiceIntegrationTest { private fun enqueueAuthResponse() { server.enqueue( MockResponse().setResponseCode(200).setBody( - """ - { - "token": "token-123", - "session_key": "hp-key", - "session_val": "hp-val" - } - """.trimIndent(), + JSONObject() + .put("token", "token-123") + .put("session_key", "hp-key") + .put("session_val", "hp-val") + .toString(), ), ) } + + private fun enqueueSearchResponse(vararg games: JSONObject) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + JSONObject().put("data", JSONArray(games.asList())).toString(), + ), + ) + } + + private fun game( + name: String, + main: Long, + plus: Long, + complete: Long, + allStyles: Long, + id: Int, + ) = JSONObject() + .put("game_name", name) + .put("comp_main", main) + .put("comp_plus", plus) + .put("comp_100", complete) + .put("comp_all", allStyles) + .put("game_id", id) } diff --git a/app/src/test/java/app/gamenative/utils/HltbServiceTest.kt b/app/src/test/java/app/gamenative/utils/HltbServiceTest.kt index d652ca9ee8..9f2b0fa6ac 100644 --- a/app/src/test/java/app/gamenative/utils/HltbServiceTest.kt +++ b/app/src/test/java/app/gamenative/utils/HltbServiceTest.kt @@ -5,125 +5,59 @@ import org.junit.Test class HltbServiceTest { - // ── formatHours() ─────────────────────────────────────────────────────── - - @Test - fun formatHours_zeroReturnsPlaceholder() { - assertEquals("--", HltbService.formatHours(0L)) - } - - @Test - fun formatHours_negativeReturnsPlaceholder() { - assertEquals("--", HltbService.formatHours(-3600L)) - } - @Test - fun formatHours_oneHourFormatsCorrectly() { - assertEquals("1.0", HltbService.formatHours(3600L)) + fun formatHours_formatsExpectedCases() { + listOf( + 0L to "--", + -3600L to "--", + 3600L to "1.0", + 5400L to "1.5", + 3700L to "1.0", + 360_000L to "100.0", + ).forEach { (seconds, expected) -> + assertEquals(expected, HltbService.formatHours(seconds)) + } } @Test - fun formatHours_ninetyMinutesFormatsCorrectly() { - assertEquals("1.5", HltbService.formatHours(5400L)) + fun normalize_normalizesExpectedCases() { + listOf( + "HALO" to "halo", + "The Witcher 3" to "the witcher 3", + "A B C" to "a b c", + "Hollow Knight!" to "hollow knight", + " Celeste " to "celeste", + ).forEach { (input, expected) -> + assertEquals(expected, HltbService.normalize(input)) + } } @Test - fun formatHours_fractionalHourRoundsToOneDecimal() { - // 3700s ≈ 1.027… → "1.0" - assertEquals("1.0", HltbService.formatHours(3700L)) - } - - @Test - fun formatHours_largeValueFormatsCorrectly() { - // 100 hours - assertEquals("100.0", HltbService.formatHours(360_000L)) - } - - // ── normalize() ───────────────────────────────────────────────────────── - - @Test - fun normalize_lowercasesInput() { - assertEquals("halo", HltbService.normalize("HALO")) - } - - @Test - fun normalize_replacesSpecialCharsWithSpace() { - assertEquals("the witcher 3", HltbService.normalize("The Witcher 3")) - } - - @Test - fun normalize_collapsesMultipleSpaces() { - assertEquals("a b c", HltbService.normalize("A B C")) - } - - @Test - fun normalize_stripsPunctuation() { - assertEquals("hollow knight", HltbService.normalize("Hollow Knight!")) - } - - @Test - fun normalize_trimsLeadingAndTrailingSpaces() { - assertEquals("celeste", HltbService.normalize(" Celeste ")) - } - - // ── levenshtein() ──────────────────────────────────────────────────────── - - @Test - fun levenshtein_identicalStringsReturnZero() { - assertEquals(0, HltbService.levenshtein("halo", "halo")) - } - - @Test - fun levenshtein_emptyAndNonEmptyReturnsLength() { - assertEquals(4, HltbService.levenshtein("", "halo")) - assertEquals(4, HltbService.levenshtein("halo", "")) - } - - @Test - fun levenshtein_singleSubstitution() { - assertEquals(1, HltbService.levenshtein("halo", "hale")) - } - - @Test - fun levenshtein_singleInsertion() { - assertEquals(1, HltbService.levenshtein("halo", "halos")) - } - - @Test - fun levenshtein_singleDeletion() { - assertEquals(1, HltbService.levenshtein("halos", "halo")) - } - - @Test - fun levenshtein_completelyDifferentStrings() { - assertEquals(4, HltbService.levenshtein("halo", "doom")) + fun levenshtein_handlesCommonCases() { + listOf( + Triple("halo", "halo", 0), + Triple("", "halo", 4), + Triple("halo", "", 4), + Triple("halo", "hale", 1), + Triple("halo", "halos", 1), + Triple("halos", "halo", 1), + Triple("halo", "doom", 4), + ).forEach { (left, right, expected) -> + assertEquals(expected, HltbService.levenshtein(left, right)) + } } @Test fun levenshtein_isSymmetric() { - val a = "witcher" - val b = "alchemy" - assertEquals(HltbService.levenshtein(a, b), HltbService.levenshtein(b, a)) - } - - // ── normalize + levenshtein integration ────────────────────────────────── - - @Test - fun normalizeAndLevenshtein_exactMatchAfterNormalizeIsZero() { - val dist = HltbService.levenshtein( - HltbService.normalize("The Witcher 3: Wild Hunt"), - HltbService.normalize("The Witcher 3: Wild Hunt"), - ) - assertEquals(0, dist) + val left = "witcher" + val right = "alchemy" + assertEquals(HltbService.levenshtein(left, right), HltbService.levenshtein(right, left)) } @Test - fun normalizeAndLevenshtein_closeMatchHasSmallDistance() { - // "Hollow Knight" vs "Hollow Knight" with trailing punctuation stripped - val dist = HltbService.levenshtein( - HltbService.normalize("Hollow Knight"), - HltbService.normalize("Hollow Knight!"), - ) - assertEquals(0, dist) + fun normalizedEquivalentTitlesHaveZeroDistance() { + val left = HltbService.normalize("The Witcher 3: Wild Hunt") + val right = HltbService.normalize("The Witcher 3: Wild Hunt!") + assertEquals(0, HltbService.levenshtein(left, right)) } }