diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 18ec8ef451..99b17cfb16 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -1204,6 +1204,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..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,5 +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: 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 937d077b40..8177be9c3c 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 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 @@ -46,6 +48,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 @@ -95,6 +98,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 @@ -803,6 +807,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( @@ -1094,6 +1104,7 @@ internal fun AppScreenContent( } } } + } } 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 5510869c31..a038d0ed5f 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,23 @@ 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 (best-effort) + var hltbStats by remember(displayInfoBase.name) { + mutableStateOf(null) + } + LaunchedEffect(displayInfoBase.name) { + if (displayInfoBase.name.isNotBlank()) + 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) + // 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/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..f88a4d7cf3 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/HltbHeroStrip.kt @@ -0,0 +1,88 @@ +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.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.IconButton +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 +import timber.log.Timber + +@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) { + 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 new file mode 100644 index 0000000000..8a05919b0d --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/HltbService.kt @@ -0,0 +1,270 @@ +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.Request +import org.json.JSONArray +import org.json.JSONObject +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. + * + * 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 + * + * 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 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" + + /** 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, + 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) + + @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)) + .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .build() + + /** Fetch auth tokens from the HLTB init endpoint. */ + private suspend fun fetchAuth(): Auth? = withContext(Dispatchers.IO) { + try { + 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) + 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) + } + if (token.isNotEmpty() && key.isNotEmpty() && value.isNotEmpty()) + Auth(token, key, value).also { auth = it } + else null + } + } catch (e: Exception) { Timber.tag("HLTB").e(e, "fetchAuth"); 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 body = JSONObject().apply { + put("searchType", "games") + 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("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("users", JSONObject()); put("filter", ""); put("sort", 0); put("randomizer", 0) + }) + put(a.hpKey, a.hpVal) + }.toString().toByteArray() + + 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", 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) + 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 + } + + 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 normalizedName = normalize(name) + var bestMatch = data.getJSONObject(0) + 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, 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") + .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 = 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 { + 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? = withContext(Dispatchers.IO) { + if (name.isBlank()) return@withContext null + HltbCache.get(name)?.let { return@withContext it } + 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 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 + } +} + +/** 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 + private val json = Json { ignoreUnknownKeys = true } + + @Serializable data class Entry(val stats: HltbService.Stats, val ts: Long) + + @Synchronized + private fun load() { + if (loaded) return + try { + val raw = PrefManager.hltbCache + if (raw != "{}") { + val now = System.currentTimeMillis() + 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 { + loaded = true + } + } + + @Synchronized + private fun save() { + try { + val now = System.currentTimeMillis() + PrefManager.hltbCache = json.encodeToString(mem.mapValues { Entry(it.value, stamps[it.key] ?: now) }) + } catch (_: Exception) {} + } + + @Synchronized + 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] + } + + @Synchronized + fun put(name: String, stats: HltbService.Stats) { + 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() + } + + internal fun key(name: String) = normalizedKey(name) + + /** 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 103f497551..19a2b7a0ef 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1300,6 +1300,12 @@ Kunne ikke importere gemte filer: %s Importér gemte spil Eksportér gemte spil + + Hovedhistorie + Hoved + Ekstra + Perfektionist + Alle stilarter + Se på HowLongToBeat LSFG-VK diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index ff21cb3c00..e7f11ae751 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1370,6 +1370,12 @@ Spielstände konnten nicht importiert werden: %s Spielstände importieren Spielstände exportieren + + Hauptstory + Haupt + Extras + Perfektionist + Alle Spielstile + Auf HowLongToBeat ansehen LSFG-VK diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 72986022de..f551ed72e1 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1436,6 +1436,12 @@ Error al importar partidas: %s Importar partidas guardadas Exportar partidas guardadas + + Historia principal + Principal + Extras + Completista + Todos los estilos + Ver en HowLongToBeat LSFG-VK diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 49e59f4c73..8f17264f40 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1430,6 +1430,12 @@ É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 LSFG-VK diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 092aa37d53..2d0d75d056 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1426,6 +1426,12 @@ Importazione salvataggi fallita: %s Importa salvataggi Esporta salvataggi + + Storia principale + Principale + Extra + Completista + Tutti gli stili + Vedi su HowLongToBeat LSFG-VK diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d6c03e3840..4507679a41 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1434,6 +1434,12 @@ 세이브 데이터 가져오기 실패: %s 세이브 가져오기 세이브 내보내기 + + 메인 스토리 + 메인 + 추가 + 완벽주의자 + 모든 스타일 + HowLongToBeat에서 보기 LSFG-VK diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 24a46e91a0..b03473c875 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1433,6 +1433,12 @@ 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 LSFG-VK diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index d139eeed5f..295d908013 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1300,6 +1300,12 @@ Falha ao importar saves: %s Importar saves Exportar saves + + História principal + Principal + Extras + Completista + Todos os estilos + Ver no HowLongToBeat LSFG-VK diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 6e3af31a74..d4844481f1 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1435,6 +1435,12 @@ Importul salvărilor a eșuat: %s Importă salvări Exportă salvări + + Poveste principală + Principal + Suplimente + Perfecționist + Toate stilurile + Vezi pe HowLongToBeat LSFG-VK diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index e89b10cfe3..f87ba543ee 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1363,6 +1363,12 @@ https://gamenative.app Не удалось импортировать сохранения: %s Импортировать сохранения Экспортировать сохранения + + Основной сюжет + Основное + Дополнения + Перфекционист + Все стили + Смотреть на HowLongToBeat LSFG-VK diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index e6c6192b8d..427cf42c06 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1429,6 +1429,12 @@ Не вдалося імпортувати збереження: %s Імпортувати збереження Експортувати збереження + + Основний сюжет + Основне + Доповнення + Перфекціоніст + Усі стилі + Переглянути на HowLongToBeat LSFG-VK diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index dd3acc357d..5cc7a5b616 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1503,6 +1503,12 @@ 未检测到正在运行的EXE。 + + 主线剧情 + 主线 + 支线 + 完美主义者 + 所有风格 + 在 HowLongToBeat 上查看 LSFG-VK @@ -1545,4 +1551,4 @@ 提示:如果你使用的是 Mali GPU,请使用系统驱动。 提示:出现黑屏?尝试使用菜单中的\"%s\"选项检查驱动是否正常工作。 启用在线游玩并提升兼容性\n不一定总能正常工作\n尝试前请先备份存档 - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7ac8128c9b..b983a53d7b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1495,6 +1495,12 @@ 圖示樣式 未偵測到正在執行的 EXE。 + + 主線劇情 + 主線 + 支線 + 完美主義者 + 所有風格 + 在 HowLongToBeat 上查看 LSFG-VK @@ -1537,4 +1543,4 @@ 提示:如果你使用的是 Mali GPU,請使用系統驅動。 提示:出現黑畫面?嘗試使用選單中的「%s」選項檢查驅動是否正常運作。 啟用線上遊玩並提升相容性\n不一定總能正常運作\n嘗試前請先備份存檔 - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7985c8fdd..93714eecc3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1498,6 +1498,14 @@ Reduces quality for higher throughput Show game recommendations Show curated indie game picks in your library. Keeping this on helps support indie developers and GameNative. + + + Main Story + Main + Extras + Completionist + All Styles + View on HowLongToBeat + Booting may take a few minutes on first launch Tip: You can view the game files by pressing \"%s\" in the game settings. 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..37d34e5451 --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/HltbCacheTest.kt @@ -0,0 +1,74 @@ +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) + } + + @Test + 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 + fun get_returnsNullForMissingEntry() { + assertNull(HltbCache.get("Unknown Game")) + } + + @Test + fun put_evictsOldestWhenCapReached() { + repeat(HltbCache.MAX_ENTRIES) { index -> + HltbCache.put("Game $index", sampleStats) + } + assertNotNull(HltbCache.get("Game 0")) + + HltbCache.put("Overflow Game", sampleStats) + + assertNull(HltbCache.get("Game 0")) + assertNotNull(HltbCache.get("Overflow Game")) + } + + @Test + fun reset_clearsAllEntries() { + HltbCache.put("Halo", sampleStats) + HltbCache.reset() + 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 new file mode 100644 index 0000000000..136b6b6ac7 --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/HltbServiceIntegrationTest.kt @@ -0,0 +1,137 @@ +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.JSONArray +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() + enqueueSearchResponse( + game("Halo Wars", 7200, 10800, 14400, 18000, 2), + game("Halo", 3600, 5400, 7200, 9000, 1), + ) + + 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() + enqueueSearchResponse(game("Celeste", 14400, 21600, 28800, 32400, 99)) + + 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() + enqueueSearchResponse(game("Empty Game", 0, 0, 0, 0, 404)) + + assertNull(HltbService.getStats("Empty Game")) + assertEquals(2, server.requestCount) + } + + private fun enqueueAuthResponse() { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + 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 new file mode 100644 index 0000000000..9f2b0fa6ac --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/HltbServiceTest.kt @@ -0,0 +1,63 @@ +package app.gamenative.utils + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HltbServiceTest { + + @Test + 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 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 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 left = "witcher" + val right = "alchemy" + assertEquals(HltbService.levenshtein(left, right), HltbService.levenshtein(right, left)) + } + + @Test + 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)) + } +}