feat: add How Long To Beat stats strip to game pages#1282
feat: add How Long To Beat stats strip to game pages#1282xXJSONDeruloXx wants to merge 13 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds HowLongToBeat integration: new HltbService with in-memory + persisted JSON cache, PrefManager.hltbCache persistence, UI plumbing to fetch/display HLTB stats in library screens, a new HltbHeroStrip composable, localized string resources, and unit & integration tests. Changes
Sequence Diagram(s)sequenceDiagram
participant UI as AppScreen UI
participant BaseApp as BaseAppScreen
participant Hltb as HltbService
participant Mem as In-memory Cache
participant Pref as PrefManager (persistent cache)
participant API as HLTB API
UI->>BaseApp: Render with game name
BaseApp->>BaseApp: LaunchedEffect on name change
BaseApp->>Hltb: getStats(name)
Hltb->>Mem: check in-memory cache
alt mem hit
Mem-->>Hltb: return Stats
else mem miss
Hltb->>Pref: check persisted JSON cache
alt persistent hit & TTL valid
Pref-->>Hltb: return Stats
else persistent miss
Hltb->>API: GET /api/find/init (fetch tokens)
API-->>Hltb: auth tokens
Hltb->>API: POST /api/find (search with headers, name)
API-->>Hltb: results array
Hltb->>Hltb: normalize & select best match (Levenshtein)
Hltb->>Mem: store result
Hltb->>Pref: persist JSON with timestamp
alt auth failure
Hltb->>API: refresh token & retry
API-->>Hltb: results or null
end
end
end
Hltb-->>BaseApp: Stats or null
BaseApp->>UI: pass augmented displayInfo
UI->>UI: render HltbHeroStrip if stats present
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
4 issues found across 6 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt:334">
P2: Implicit ACTION_VIEW launch is unguarded and can crash with ActivityNotFoundException when no handler exists.</violation>
</file>
<file name="app/src/main/java/app/gamenative/utils/HltbService.kt">
<violation number="1" location="app/src/main/java/app/gamenative/utils/HltbService.kt:95">
P2: HttpURLConnection POST has no explicit connect/read timeout, so a hung network request can block indefinitely.</violation>
<violation number="2" location="app/src/main/java/app/gamenative/utils/HltbService.kt:139">
P2: `getStats()` performs cache load/JSON decode synchronously on the caller context, which is Main at the UI call site, risking UI jank.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt:907">
P2: Broad `Exception` catch in `LaunchedEffect` swallows coroutine cancellation instead of rethrowing `CancellationException`.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/src/main/java/app/gamenative/utils/HltbService.kt`:
- Around line 137-147: getStats blocks the main thread because
HltbCache.get(name) synchronously reads PrefManager.hltbCache via runBlocking
and is invoked from a LaunchedEffect (BaseAppScreen); fix it by moving the whole
function body off the main thread: wrap the contents of suspend fun
getStats(name: String): Stats? in withContext(Dispatchers.IO) so HltbCache.get,
any PrefManager.hltbCache access, auth/fetchAuth and search calls run on the IO
dispatcher and then return the result back to the caller.
- Around line 95-110: The HttpURLConnection in search() (variable conn) lacks
connect/read timeouts and isn't guaranteed to be disconnected, which can hang or
leak resources; fix by setting conn.connectTimeout and conn.readTimeout to
sensible values before writing/connecting, and wrap the request/response
handling in a try/finally so conn.disconnect() is always called in the finally
block (keep the existing outputStream.use for writing and preserve the response
handling logic that reads conn.inputStream). Ensure you reference the conn
variable in search() and perform timeouts/disconnect on that same
HttpURLConnection instance.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 3afc37dc-cce1-437f-99fa-af4f99d2f3dc
📒 Files selected for processing (6)
app/src/main/java/app/gamenative/PrefManager.ktapp/src/main/java/app/gamenative/ui/data/GameDisplayInfo.ktapp/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.ktapp/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.ktapp/src/main/java/app/gamenative/utils/HltbService.ktapp/src/main/res/values/strings.xml
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/utils/HltbService.kt (1)
115-135: Consider not caching (or not displaying) zero-value matches.If HLTB's search returns results but the best-match entry has all
comp_*fields at0(game exists but no completion data submitted, or the fuzzy match picked a stub entry),secs()returns"--"for every category and that "empty"Statsis still written to cache for 12 hours. For games that genuinely exist in HLTB but haven't been queried long enough to get real data, or when the Levenshtein picker selects a wrong stub, users will see a strip of--placeholders until the TTL expires.Optional: skip caching (or skip returning) when all four values are zero, and/or require a Levenshtein distance threshold relative to
norm.lengthbefore accepting the best match — otherwise any short/unrelated result will still be considered "best" regardless of how far off it is.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/app/gamenative/utils/HltbService.kt` around lines 115 - 135, The code is caching and returning a Stats object even when the chosen HLTB match (the local variable best / g) has all comp_* fields equal to zero or when the fuzzy match is a poor match; after you pick the best item in the loop that uses normalize(...) and levenshtein(...), add a guard that reads g.optLong("comp_main"), "comp_plus", "comp_100", and "comp_all" and if they are all zero then skip creating/writing the Stats (do not cache or return it) or return null/empty; additionally enforce a Levenshtein threshold (e.g. bestDist <= max(1, norm.length * 0.3)) before accepting the match — if bestDist is too large, treat as no reliable match and skip caching/returning; ensure this logic is applied where Stats(...) is constructed and wherever caching is performed so you don't store 12-hour placeholders for empty entries.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/src/main/java/app/gamenative/utils/HltbService.kt`:
- Around line 169-209: HltbCache is not thread-safe: protect mem, stamps, loaded
and the load/save sequence with a lock (e.g., a Kotlin Mutex or synchronized
block) so concurrent calls to get()/put() (and load()/save()) cannot mutate
LinkedHashMaps concurrently; move setting loaded = true to after successful
decode/population (inside load() after parsing loop) so a second thread won't
see loaded==true before data is populated; wrap accesses in get(name), put(name,
stats), load(), and save() with the same lock and ensure save() reads from the
guarded mem/stamps, using key(name) and PrefManager.hltbCache as before.
---
Nitpick comments:
In `@app/src/main/java/app/gamenative/utils/HltbService.kt`:
- Around line 115-135: The code is caching and returning a Stats object even
when the chosen HLTB match (the local variable best / g) has all comp_* fields
equal to zero or when the fuzzy match is a poor match; after you pick the best
item in the loop that uses normalize(...) and levenshtein(...), add a guard that
reads g.optLong("comp_main"), "comp_plus", "comp_100", and "comp_all" and if
they are all zero then skip creating/writing the Stats (do not cache or return
it) or return null/empty; additionally enforce a Levenshtein threshold (e.g.
bestDist <= max(1, norm.length * 0.3)) before accepting the match — if bestDist
is too large, treat as no reliable match and skip caching/returning; ensure this
logic is applied where Stats(...) is constructed and wherever caching is
performed so you don't store 12-hour placeholders for empty entries.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a201f55e-0420-4e76-b5c6-5ab76d9e1341
📒 Files selected for processing (2)
app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.ktapp/src/main/java/app/gamenative/utils/HltbService.kt
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="app/src/main/java/app/gamenative/utils/HltbService.kt">
<violation number="1" location="app/src/main/java/app/gamenative/utils/HltbService.kt:143">
P1: `HltbCache` is not thread-safe but is accessed from `Dispatchers.IO` (a multi-threaded dispatcher) here. The backing `mutableMapOf` collections (`mem`, `stamps`) are plain `LinkedHashMap` instances with no synchronization. Concurrent calls to `getStats` from multiple game screens will race on `get()`/`put()`, risking `ConcurrentModificationException` or silent data corruption.
Additionally, `load()` sets `loaded = true` *before* the decode/populate block runs, so a second thread entering `get()` while the first is still decoding will see `loaded == true`, skip loading, and read empty maps — causing unnecessary network fetches.
Wrap `HltbCache.get`, `put`, `load`, and `save` with `synchronized(lock)` (or use a `Mutex` for a coroutine-friendly approach), and move `loaded = true` to *after* the populate step completes.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
There was a problem hiding this comment.
Looks sensible, however there's a lot of parsing and transformations.
I'd recommend that you add some unit tests & an integration test with stubbed API responses so that this is proven to be handled correctly.
| 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 | ||
| } | ||
|
|
||
| val g = best |
There was a problem hiding this comment.
Hate to pick nits, but could you please be more semantic with your naming? Thanks.
| object HltbCache { | ||
| private const val TTL = 12 * 3_600_000L |
There was a problem hiding this comment.
What's the general amount of KBs used in-memory for HLTB entries per game?
Would be good for us to get a general amount and put in a limit in the cache to avoid potential OOM issues, especially on lower-end devices that have 4GB where we gotta be a bit brutal with memory usage.
There was a problem hiding this comment.
Like the other PRs, will be good to (for now) generate the other translations
| @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, |
There was a problem hiding this comment.
Could we pull this out to a composable component? Helps keep the size of LibraryAppScreen down.
We need to be a bit more modular in our UI work to help get things to flow easier. Lemme know if this is an arse to do.
| .size(18.dp) | ||
| .clickable { | ||
| context.startActivity( | ||
| Intent(Intent.ACTION_VIEW, Uri.parse("https://howlongtobeat.com/game/${stats.gameId}")) |
There was a problem hiding this comment.
I think it'd be easier to pull this URL out to a constant for the HLTB work
…ranslations, modular UI
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/utils/HltbService.kt (1)
47-47: Consider makingauthvolatile for cross-thread visibility.
authis read and written from multiple coroutines onDispatchers.IO(read at line 156, assigned at lines 71 and 112) without synchronization. Reference writes are atomic on the JVM, but without@Volatilea write may not be promptly visible to other threads, leading to redundantfetchAuth()calls on the first concurrent game opens. Benign but trivial to harden.♻️ Proposed refactor
- private var auth: Auth? = null + `@Volatile` private var auth: Auth? = null🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/app/gamenative/utils/HltbService.kt` at line 47, The shared mutable property auth in HltbService is accessed from multiple coroutines without synchronization; mark the property as volatile by adding the Kotlin `@Volatile` annotation to its declaration (i.e., change the declaration of auth to `@Volatile` private var auth: Auth? = null) so writes in fetchAuth() and other assignment sites are promptly visible to readers and avoid redundant concurrent fetches.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@app/src/main/java/app/gamenative/ui/screen/library/components/HltbHeroStrip.kt`:
- Around line 64-77: The external-link Icon in HltbHeroStrip.kt (the branch
checking stats.gameId > 0 and the Icon with clickable modifier) needs two fixes:
wrap the context.startActivity(Intent(Intent.ACTION_VIEW,
Uri.parse("${HltbService.GAME_URL}${stats.gameId}"))) call in a try/catch that
catches ActivityNotFoundException and logs/ignores it to avoid crashes, and
increase the touch target to at least 48dp by using an IconButton or wrapping
the Icon in a Box/Modifier with minimumInteractiveComponentSize(48.dp) (or a
48.dp clickable Box) and add semantics role = Role.Button for accessibility so
the element is both safe and reachable.
In `@app/src/test/java/app/gamenative/utils/HltbCacheTest.kt`:
- Around line 83-96: The test put_evictsOldestWhenCapReached doesn't assert that
the oldest entry was removed; update the test in HltbCacheTest (function
put_evictsOldestWhenCapReached) to, after inserting "Overflow Game", assert that
HltbCache.get("Game 0") is null (or absent) and optionally assert the cache size
remains ≤ HltbCache.MAX_ENTRIES to confirm eviction occurred; keep the existing
assertNotNull(HltbCache.get("Overflow Game")) and use sampleStats as before.
- Around line 71-79: The test get_returnsNullAfterTtlExpires doesn't actually
exercise TTL expiry; update it so it either (A) is renamed to
get_returnsEntryWithinTtl and only asserts that HltbCache.put("Celeste",
sampleStats) + HltbCache.get("Celeste") returns non-null, or (B) modify
HltbCache to accept an injectable time provider (or clock) and in the test
simulate advancing time > TTL (the 12-hour TTL constant) then assert
HltbCache.get("Celeste") returns null; refer to HltbCache.put and HltbCache.get
and the TTL constant when making the changes.
---
Nitpick comments:
In `@app/src/main/java/app/gamenative/utils/HltbService.kt`:
- Line 47: The shared mutable property auth in HltbService is accessed from
multiple coroutines without synchronization; mark the property as volatile by
adding the Kotlin `@Volatile` annotation to its declaration (i.e., change the
declaration of auth to `@Volatile` private var auth: Auth? = null) so writes in
fetchAuth() and other assignment sites are promptly visible to readers and avoid
redundant concurrent fetches.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: c1e3ceab-f6db-44ff-a36d-a8a408193309
📒 Files selected for processing (18)
app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.ktapp/src/main/java/app/gamenative/ui/screen/library/components/HltbHeroStrip.ktapp/src/main/java/app/gamenative/utils/HltbService.ktapp/src/main/res/values-da/strings.xmlapp/src/main/res/values-de/strings.xmlapp/src/main/res/values-es/strings.xmlapp/src/main/res/values-fr/strings.xmlapp/src/main/res/values-it/strings.xmlapp/src/main/res/values-ko/strings.xmlapp/src/main/res/values-pl/strings.xmlapp/src/main/res/values-pt-rBR/strings.xmlapp/src/main/res/values-ro/strings.xmlapp/src/main/res/values-ru/strings.xmlapp/src/main/res/values-uk/strings.xmlapp/src/main/res/values-zh-rCN/strings.xmlapp/src/main/res/values-zh-rTW/strings.xmlapp/src/test/java/app/gamenative/utils/HltbCacheTest.ktapp/src/test/java/app/gamenative/utils/HltbServiceTest.kt
✅ Files skipped from review due to trivial changes (13)
- app/src/main/res/values-es/strings.xml
- app/src/main/res/values-da/strings.xml
- app/src/main/res/values-fr/strings.xml
- app/src/main/res/values-uk/strings.xml
- app/src/main/res/values-ru/strings.xml
- app/src/main/res/values-pl/strings.xml
- app/src/main/res/values-ko/strings.xml
- app/src/main/res/values-it/strings.xml
- app/src/main/res/values-zh-rTW/strings.xml
- app/src/main/res/values-ro/strings.xml
- app/src/main/res/values-pt-rBR/strings.xml
- app/src/main/res/values-de/strings.xml
- app/src/main/res/values-zh-rCN/strings.xml
🚧 Files skipped from review as they are similar to previous changes (1)
- app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt
| 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}")) | ||
| ) | ||
| }, | ||
| ) | ||
| } |
There was a problem hiding this comment.
Icon click risks crash and has an undersized touch target.
Two concerns on the external-link icon:
context.startActivity(Intent.ACTION_VIEW, …)can throwActivityNotFoundExceptionon devices without a browser/handler (e.g., stripped-down Android TV images). Wrap the call in a try/catch so a missing handler doesn't crash the app.- The clickable area is 18dp, well under Material's 48dp minimum touch target. Consider sizing the clickable region to ≥ 48dp (via
minimumInteractiveComponentSize()/IconButton, or wrapping in a 48dp clickableBox) and adding aRole.Buttonsemantic for accessibility.
♻️ Proposed refactor
+import androidx.compose.material3.IconButton
+import androidx.compose.ui.semantics.Role
+import timber.log.Timber
...
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}"))
- )
- },
- )
+ IconButton(
+ onClick = {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse("${HltbService.GAME_URL}${stats.gameId}"))
+ try {
+ context.startActivity(intent)
+ } catch (e: android.content.ActivityNotFoundException) {
+ Timber.tag("HLTB").w(e, "No activity to handle HLTB link")
+ }
+ },
+ ) {
+ 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),
+ )
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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}")) | |
| ) | |
| }, | |
| ) | |
| } | |
| if (stats.gameId > 0) { | |
| IconButton( | |
| onClick = { | |
| val intent = Intent(Intent.ACTION_VIEW, Uri.parse("${HltbService.GAME_URL}${stats.gameId}")) | |
| try { | |
| context.startActivity(intent) | |
| } catch (e: android.content.ActivityNotFoundException) { | |
| Timber.tag("HLTB").w(e, "No activity to handle HLTB link") | |
| } | |
| }, | |
| ) { | |
| 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), | |
| ) | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@app/src/main/java/app/gamenative/ui/screen/library/components/HltbHeroStrip.kt`
around lines 64 - 77, The external-link Icon in HltbHeroStrip.kt (the branch
checking stats.gameId > 0 and the Icon with clickable modifier) needs two fixes:
wrap the context.startActivity(Intent(Intent.ACTION_VIEW,
Uri.parse("${HltbService.GAME_URL}${stats.gameId}"))) call in a try/catch that
catches ActivityNotFoundException and logs/ignores it to avoid crashes, and
increase the touch target to at least 48dp by using an IconButton or wrapping
the Icon in a Box/Modifier with minimumInteractiveComponentSize(48.dp) (or a
48.dp clickable Box) and add semantics role = Role.Button for accessibility so
the element is both safe and reachable.
There was a problem hiding this comment.
3 issues found across 18 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="app/src/main/java/app/gamenative/utils/HltbService.kt">
<violation number="1" location="app/src/main/java/app/gamenative/utils/HltbService.kt:197">
P2: Cache size cap (`MAX_ENTRIES`) is not enforced during persisted cache load, allowing startup state to exceed the documented 200-entry limit.</violation>
</file>
<file name="app/src/test/java/app/gamenative/utils/HltbCacheTest.kt">
<violation number="1" location="app/src/test/java/app/gamenative/utils/HltbCacheTest.kt:72">
P3: Test name `get_returnsNullAfterTtlExpires` is misleading — it only asserts the entry exists immediately after `put` and never exercises expiry. Either rename to something like `get_returnsEntryWithinTtl` or inject a time provider so TTL expiration can actually be simulated; as-is this gives false confidence that TTL logic is covered.</violation>
<violation number="2" location="app/src/test/java/app/gamenative/utils/HltbCacheTest.kt:95">
P2: Eviction test doesn't actually verify eviction. After inserting the overflow entry, add `assertNull(HltbCache.get("Game 0"))` to confirm the oldest entry was evicted. Without this, a regression in eviction logic (wrong victim, or no eviction at all) would still pass.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| // ── TTL eviction ───────────────────────────────────────────────────────── | ||
|
|
||
| @Test | ||
| fun get_returnsNullAfterTtlExpires() { |
There was a problem hiding this comment.
P3: Test name get_returnsNullAfterTtlExpires is misleading — it only asserts the entry exists immediately after put and never exercises expiry. Either rename to something like get_returnsEntryWithinTtl or inject a time provider so TTL expiration can actually be simulated; as-is this gives false confidence that TTL logic is covered.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/test/java/app/gamenative/utils/HltbCacheTest.kt, line 72:
<comment>Test name `get_returnsNullAfterTtlExpires` is misleading — it only asserts the entry exists immediately after `put` and never exercises expiry. Either rename to something like `get_returnsEntryWithinTtl` or inject a time provider so TTL expiration can actually be simulated; as-is this gives false confidence that TTL logic is covered.</comment>
<file context>
@@ -0,0 +1,107 @@
+ // ── 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
</file context>
There was a problem hiding this comment.
🧹 Nitpick comments (2)
app/src/main/java/app/gamenative/utils/HltbService.kt (2)
165-165: Renamesecsfor clarity.The function takes seconds and formats hours, but the name (and parameter
s) implies it works with seconds only. Given prior feedback on semantic naming, considerformatHours(seconds: Long)orsecondsToHoursLabel(seconds: Long).♻️ Proposed rename
- internal fun secs(s: Long) = if (s <= 0) "--" else "%.1f".format(s / 3600.0) + internal fun formatHours(seconds: Long) = + if (seconds <= 0) "--" else "%.1f".format(seconds / 3600.0)And update call sites at Lines 140–143 accordingly.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/app/gamenative/utils/HltbService.kt` at line 165, The helper function secs(s: Long) is misnamed and unclear because it accepts seconds and returns a formatted hours string; rename the function to a clearer name such as formatHours(seconds: Long) or secondsToHoursLabel(seconds: Long), update its parameter name from s to seconds, and change its signature and all call sites that reference secs (e.g., the places currently calling secs(...) around the HltbService usage) to use the new name and parameter. Keep the implementation logic identical (return "--" when seconds <= 0 else format hours with "%.1f".format(seconds / 3600.0)) and update any imports or references in HltbService to avoid unresolved symbol errors.
166-167: Deduplicatenormalize/key.
HltbService.normalize(Lines 166–167) andHltbCache.key(Lines 243–244) are byte-identical. If one ever changes without the other, cache lookups will silently diverge from search matching. Either haveHltbCache.keydelegate toHltbService.normalize, or extract a shared private helper.♻️ Proposed fix
- internal fun key(s: String) = - s.lowercase().replace(Regex("[^\\p{L}\\p{N}]"), " ").replace(Regex("\\s+"), " ").trim() + internal fun key(s: String) = HltbService.normalize(s)Also applies to: 243-244
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/app/gamenative/utils/HltbService.kt` around lines 166 - 167, HltbService.normalize and HltbCache.key are identical; to avoid future divergence, remove the duplicate implementation by making HltbCache.key delegate to the single canonical normalizer or extract a shared private helper used by both. Concretely, replace the body of HltbCache.key with a call to HltbService.normalize(...) (or move the regex logic into a private top-level function like normalizedKey(...) and use that from both HltbService.normalize and HltbCache.key), ensuring visibility allows the call and updating references accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@app/src/main/java/app/gamenative/utils/HltbService.kt`:
- Line 165: The helper function secs(s: Long) is misnamed and unclear because it
accepts seconds and returns a formatted hours string; rename the function to a
clearer name such as formatHours(seconds: Long) or secondsToHoursLabel(seconds:
Long), update its parameter name from s to seconds, and change its signature and
all call sites that reference secs (e.g., the places currently calling secs(...)
around the HltbService usage) to use the new name and parameter. Keep the
implementation logic identical (return "--" when seconds <= 0 else format hours
with "%.1f".format(seconds / 3600.0)) and update any imports or references in
HltbService to avoid unresolved symbol errors.
- Around line 166-167: HltbService.normalize and HltbCache.key are identical; to
avoid future divergence, remove the duplicate implementation by making
HltbCache.key delegate to the single canonical normalizer or extract a shared
private helper used by both. Concretely, replace the body of HltbCache.key with
a call to HltbService.normalize(...) (or move the regex logic into a private
top-level function like normalizedKey(...) and use that from both
HltbService.normalize and HltbCache.key), ensuring visibility allows the call
and updating references accordingly.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 3e94f197-12dd-41ec-b9f7-71d6b0f7580c
📒 Files selected for processing (3)
app/src/main/java/app/gamenative/ui/screen/library/components/HltbHeroStrip.ktapp/src/main/java/app/gamenative/utils/HltbService.ktapp/src/test/java/app/gamenative/utils/HltbCacheTest.kt
🚧 Files skipped from review as they are similar to previous changes (2)
- app/src/test/java/app/gamenative/utils/HltbCacheTest.kt
- app/src/main/java/app/gamenative/ui/screen/library/components/HltbHeroStrip.kt
There was a problem hiding this comment.
🧹 Nitpick comments (3)
app/src/main/java/app/gamenative/utils/HltbService.kt (2)
16-17: Pre-compile the normalization regexes.
normalizedKeyallocates and compiles twoRegexinstances on every call. Insearch()this runs once per candidate (up to 20) plus once for the query, so 40+ compilations per HLTB lookup on the IO dispatcher. Hoisting them to file-levelvals is a trivial win and common Kotlin practice.♻️ Proposed refactor
-private fun normalizedKey(input: String) = - input.lowercase().replace(Regex("[^\\p{L}\\p{N}]"), " ").replace(Regex("\\s+"), " ").trim() +private val NON_ALPHANUMERIC = Regex("[^\\p{L}\\p{N}]") +private val WHITESPACE = Regex("\\s+") + +private fun normalizedKey(input: String) = + input.lowercase().replace(NON_ALPHANUMERIC, " ").replace(WHITESPACE, " ").trim()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/app/gamenative/utils/HltbService.kt` around lines 16 - 17, The normalizedKey function compiles two Regex instances on every call causing unnecessary allocations; hoist the two Regex patterns (Regex("[^\\p{L}\\p{N}]") and Regex("\\s+")) to file-level val constants and reuse them inside normalizedKey so the function simply calls .replace with the precompiled regexes and then .trim(); update references inside normalizedKey accordingly to eliminate per-call compilation.
159-171: Clarify theauth == nullretry sentinel.The retry branch depends on
search()having nulled the module-levelauthto signal an HTTP-level auth rejection — a nullfirstAttemptdue to exception, poor fuzzy match, or zero-value stub intentionally leavesauthset and short-circuits here. That control flow is subtle and easy to break in a future edit (e.g., addingauth = nullto another error path would silently change retry semantics). A brief comment, or returning a sealed result fromsearch()that distinguishes "auth invalidated" from "no match", would make the contract explicit.💡 Minimal clarifying comment
val firstAttempt = search(name, cachedAuth) val stats = firstAttempt ?: run { - if (auth != null) return@withContext null + // search() nulls `auth` only on HTTP-level auth rejection; any other null + // (no match / zero-value stub / exception) leaves `auth` set and must not retry. + if (auth != null) return@withContext null val refreshedAuth = fetchAuth() ?: return@withContext null search(name, refreshedAuth) } ?: return@withContext null🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/app/gamenative/utils/HltbService.kt` around lines 159 - 171, The code relies on a hidden sentinel where search(name) null means multiple things (no match, error, or auth invalidation), making retry logic in getStats brittle; change search(...) to return an explicit sealed result (e.g., sealed class SearchResult { data class Found(val stats: Stats), object NoMatch, object AuthInvalidated }) and update getStats to pattern-match that result: on Found use the stats and cache it, on NoMatch return null immediately, and on AuthInvalidated call fetchAuth(), update auth, re-run search and then proceed only if Found; also update any callers of search and add a short comment on auth field to document that AuthInvalidated is the only code path that triggers a refresh.app/src/test/java/app/gamenative/utils/HltbServiceIntegrationTest.kt (1)
84-99: Prefer derivingOrigin/Refererfrom the server URL.Hardcoding
"http://localhost:${server.port}"assumesMockWebServer.url("/")always returnslocalhost. It usually does, but on hosts where IPv4 loopback resolves differently it can come back as127.0.0.1, making this test environment-sensitive. Reusing the base URL the service was configured with keeps the assertion aligned with the actual headerHltbServiceemits.♻️ Proposed tweak
+ val baseUrl = server.url("/").toString().removeSuffix("/") val initRequest = server.takeRequest() assertEquals("GET", initRequest.method) - assertEquals("http://localhost:${server.port}", initRequest.getHeader("Origin")) - assertEquals("http://localhost:${server.port}/", initRequest.getHeader("Referer")) + assertEquals(baseUrl, initRequest.getHeader("Origin")) + assertEquals("$baseUrl/", initRequest.getHeader("Referer"))🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/test/java/app/gamenative/utils/HltbServiceIntegrationTest.kt` around lines 84 - 99, The test currently hardcodes expected Origin/Referer values using "http://localhost:${server.port}" which can mismatch MockWebServer's actual URL; update the assertions that inspect initRequest.getHeader("Origin") and initRequest.getHeader("Referer") to derive expectations from server.url("/").toString() (use the URL without a trailing slash for the Origin expectation and with a trailing slash for the Referer expectation) so the assertions reflect the real base URL the HltbService was configured with.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@app/src/main/java/app/gamenative/utils/HltbService.kt`:
- Around line 16-17: The normalizedKey function compiles two Regex instances on
every call causing unnecessary allocations; hoist the two Regex patterns
(Regex("[^\\p{L}\\p{N}]") and Regex("\\s+")) to file-level val constants and
reuse them inside normalizedKey so the function simply calls .replace with the
precompiled regexes and then .trim(); update references inside normalizedKey
accordingly to eliminate per-call compilation.
- Around line 159-171: The code relies on a hidden sentinel where search(name)
null means multiple things (no match, error, or auth invalidation), making retry
logic in getStats brittle; change search(...) to return an explicit sealed
result (e.g., sealed class SearchResult { data class Found(val stats: Stats),
object NoMatch, object AuthInvalidated }) and update getStats to pattern-match
that result: on Found use the stats and cache it, on NoMatch return null
immediately, and on AuthInvalidated call fetchAuth(), update auth, re-run search
and then proceed only if Found; also update any callers of search and add a
short comment on auth field to document that AuthInvalidated is the only code
path that triggers a refresh.
In `@app/src/test/java/app/gamenative/utils/HltbServiceIntegrationTest.kt`:
- Around line 84-99: The test currently hardcodes expected Origin/Referer values
using "http://localhost:${server.port}" which can mismatch MockWebServer's
actual URL; update the assertions that inspect initRequest.getHeader("Origin")
and initRequest.getHeader("Referer") to derive expectations from
server.url("/").toString() (use the URL without a trailing slash for the Origin
expectation and with a trailing slash for the Referer expectation) so the
assertions reflect the real base URL the HltbService was configured with.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 755f5a8c-fb3e-45e8-a17b-b96cee74f207
📒 Files selected for processing (3)
app/src/main/java/app/gamenative/utils/HltbService.ktapp/src/test/java/app/gamenative/utils/HltbServiceIntegrationTest.ktapp/src/test/java/app/gamenative/utils/HltbServiceTest.kt
| 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() |
There was a problem hiding this comment.
I'd recommend using the HTTPClient from utils.
| conn.connectTimeout = 15_000 | ||
| conn.readTimeout = 30_000 |
There was a problem hiding this comment.
Duplicate timeouts which is already defined at the top
| 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] | ||
| } |
There was a problem hiding this comment.
Having a really hard time reading this here. What's the purpose of this function?
|
Some comments, also, would it not be a good idea to persist these into the DB over time? I think each store's game having a persisted HLTB will help reduce the amount of queries we make over time to HLTB and we could then do filtering/sorting in a follow-up PR. Thoughts? |
Description
Adds HowLongToBeat (HLTB) completion time stats to every game page as a compact strip overlaid on the hero image, sitting directly above the Play button
Recording
Type of Change
Checklist
#code-changes, I have discussed this change there and it has been green-lighted. If I do not have access, I have still provided clear context in this PR. If I skip both, I accept that this change may face delays in review, may not be reviewed at all, or may be closed.CONTRIBUTING.md.Summary by cubic
Add HowLongToBeat playtime stats to each game page as a compact strip above the Play button. Shows four time estimates and an external link; fetches on first view, caches for 12 hours, and opens the HLTB page safely.
HltbHeroStripover the hero with Main, Main+Extras, Completionist, All Styles, plus a link button; labels are localized; handles missing link handlers.BaseAppScreenviaHltbService; fuzzy name matching with edit distance; rejects poor matches and zero-data entries; exposed asGameDisplayInfo.hltbStats.HltbCache/PrefManager.hltbCache.okhttp3forced to HTTP/1.1; search POST viaHttpURLConnection; IO dispatcher, timeouts, cancellation propagation, single auth retry; unit and integration tests for normalization, formatting, matching, cache, and end-to-end behavior.Written for commit a57235e. Summary will update on new commits.
Summary by CodeRabbit
New Features
Performance
Tests