Skip to content

Add Nexus Mods support#1545

Draft
Nightwalker743 wants to merge 1 commit into
utkarshdalal:masterfrom
Nightwalker743:feature/nexus-mod-support
Draft

Add Nexus Mods support#1545
Nightwalker743 wants to merge 1 commit into
utkarshdalal:masterfrom
Nightwalker743:feature/nexus-mod-support

Conversation

@Nightwalker743

@Nightwalker743 Nightwalker743 commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Description

Adds Nexus Mods support to GameNative. This includes importing Nexus mod files and collections, managing installed mods per game, setting mod priority/order, handling file placement, FOMOD installers, Bethesda plugin/load-order checks, storage cleanup, and install health checks.

Main things added:
Import Nexus mod files and collections, with support for resuming after interruptions.
Manage installed mods per game, including profiles that save enable/disable state, and priority order.
Apply mod order into the game folder or container with conflict checks and overwrite backups.
Detect common mod placement presets for Bethesda, BepInEx, MelonLoader, Unreal, REDmod, and custom installs.
Add Bethesda plugin checks for missing masters, missing deployed plugins, and missing BSA/BA2 files.
Add FOMOD support for ModuleConfig-based installers.
Add Nexus storage cleanup and install health checks.

Recording

My recordings are too large, three videos here and some pictures showing a few other games working: https://drive.google.com/drive/folders/1iH226A55hEkOxPDn22OKg90vH02LvgSv?usp=sharing

Type of Change

  • Bug fix
  • Performance / stability improvement
  • Compatibility improvements
  • Other (requires prior approval)

Checklist

  • If I have access to #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.
  • This change aligns with the current project scope (core functionality, stability, or performance). If not, it has been explicitly approved beforehand.
  • I have attached a recording of the change.
  • I have read and agree to the contribution guidelines in CONTRIBUTING.md.

Summary by cubic

Adds first-class Nexus Mods support: import mods and collections, manage per‑game profiles and load order, and apply files safely with conflict checks and Bethesda plugin validation. Includes a new Mods UI, background import service, FOMOD installer, and robust archive extraction.

  • New Features

    • Import Nexus mod files and collections with resume support.
    • Mods UI (Manage Nexus Mods) and background NexusModImportService with live progress.
    • FOMOD (ModuleConfig) installer with deterministic auto-selection when possible.
    • Safe archive extraction (zip/7z/rar/rar5) with zip-slip protection.
    • Placement presets and targets (Bethesda, BepInEx, MelonLoader, Unreal, REDmod); modes: symlink/copy/overwrite with backups.
    • Per‑game profiles with enable/disable, priority, conflict reports; apply order to game folder or container.
    • Bethesda plugin checks (load order, missing masters, BSA/BA2); storage cleanup and install health checks.
    • Database: Room schema v23 with new mod tables and migrations (v20–v22); encrypted Nexus API key in PrefManager.
  • Dependencies

    • Adds me.zhanghai.android.libarchive via libs.libarchive.android.

Written for commit 05b0f65. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added Nexus Mods integration with support for browsing, importing, and managing mods
    • Added automatic mod archive extraction and placement detection
    • Added mod profile management for organizing installed mods
    • Added FOMOD installer wizard for automated configuration
    • Added Bethesda plugin management and load-order tools
    • Added mod conflict detection and resolution
    • Added "Manage Nexus Mods" menu option to library
  • Bug Fixes

    • Cleanup of mod installations when games are uninstalled

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds Nexus mod archive handling, collection and manifest parsing, persistent mod/profile storage, import and placement orchestration, and a Manage Mods UI flow with supporting tests.

Changes

Nexus mod management

Layer / File(s) Summary
Database and state foundation
gradle/libs.versions.toml, app/build.gradle.kts, app/schemas/.../23.json, app/src/main/java/app/gamenative/data/ModInstall.kt, app/src/main/java/app/gamenative/db/..., app/src/main/java/app/gamenative/PrefManager.kt, app/src/main/java/app/gamenative/mods/ModContainerResolver.kt, app/src/main/java/app/gamenative/mods/ModDownloadRegistry.kt, app/src/main/java/app/gamenative/mods/ModProfileManager.kt, app/src/main/java/app/gamenative/mods/NexusImportState.kt, app/src/main/java/app/gamenative/mods/ModPlacementSources.kt, app/src/main/java/app/gamenative/mods/ModTargetResolver.kt
Adds the libarchive dependency, Room schema, mod tables, DAO methods, migrations, DI wiring, Nexus preferences, download tracking, profile helpers, import state helpers, placement-source normalization, and target-root resolution.
Nexus metadata and collection parsing
app/src/main/java/app/gamenative/mods/NexusUrlParser.kt, app/src/main/java/app/gamenative/mods/NexusApiClient.kt, app/src/main/java/app/gamenative/mods/NexusCollectionManifest.kt, app/src/main/java/app/gamenative/mods/NexusCollectionPrioritySuggester.kt, app/src/main/java/app/gamenative/mods/NexusCollectionReusePolicy.kt, app/src/main/java/app/gamenative/mods/NexusModAutomationPolicy.kt
Adds Nexus mod/collection URL parsing, API access, collection manifest classification, file ordering, reuse matching, and automation policy matching.
Archive extraction and placement generation
app/src/main/java/app/gamenative/mods/ModArchiveExtractor.kt, app/src/main/java/app/gamenative/mods/ModArchiveInstallAssessor.kt, app/src/main/java/app/gamenative/mods/ModPlacementPreset.kt, app/src/main/java/app/gamenative/mods/BethesdaPlacementRecipeExpander.kt, app/src/main/java/app/gamenative/mods/BethesdaPluginManager.kt, app/src/main/java/app/gamenative/mods/FomodInstaller.kt, app/src/main/java/app/gamenative/mods/FomodAutoSelector.kt
Adds archive extraction, install assessment, preset detection, Bethesda recipe expansion, Bethesda plugin handling, FOMOD parsing, and deterministic FOMOD auto-selection.
Install execution and UI wiring
app/src/main/java/app/gamenative/mods/ModMaterializer.kt, app/src/main/java/app/gamenative/mods/ModConflictAnalyzer.kt, app/src/main/java/app/gamenative/mods/NexusModManager.kt, app/src/main/java/app/gamenative/mods/ModPathDetector.kt, app/src/main/java/app/gamenative/service/NexusModImportService.kt, app/src/main/java/app/gamenative/ui/..., app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt, app/src/main/res/values/strings.xml
Adds file application, conflict reporting, import orchestration, storage/health cleanup, path detection, foreground import service behavior, and the new Manage Mods UI/menu surfaces.
Test coverage
app/src/androidTest/java/app/gamenative/mods/ModArchiveExtractorAndroidTest.kt, app/src/test/java/app/gamenative/mods/*Test.kt
Adds tests covering extraction, archive assessment, placement presets, Bethesda plugin management, FOMOD parsing/selection, Nexus API parsing, collection helpers, import state, conflict analysis, and URL parsing.

Sequence Diagram(s)

sequenceDiagram
  participant NexusModImportService
  participant NexusModManager
  participant ModMaterializer
  participant ModConflictAnalyzer
  participant UI
  UI->>NexusModImportService: enqueue import or resume
  NexusModImportService->>NexusModManager: importNexusFile / applyInstall
  NexusModManager->>ModMaterializer: materialize placement recipes
  NexusModManager->>ModConflictAnalyzer: compute file conflicts
  NexusModManager->>UI: progress, status, and results
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~90+ minutes

Possibly related PRs

Suggested reviewers

  • utkarshdalal
  • phobos665

Poem

🐰 I hopped through mods and archives neat,
Mapped every path on careful feet.
From nexus threads to wizard light,
The burrow hums with plans just right.
A carrot toast to managed lore—
Ears up, the game now knows much more!

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

@Nightwalker743 Nightwalker743 marked this pull request as ready for review June 7, 2026 23:07

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

28 issues found across 69 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/mods/ModProfileManager.kt">

<violation number="1" location="app/src/main/java/app/gamenative/mods/ModProfileManager.kt:36">
P2: Non-transactional max-priority assignment can race and produce duplicate priorities.</violation>
</file>

<file name="app/src/main/java/app/gamenative/mods/ModContainerResolver.kt">

<violation number="1" location="app/src/main/java/app/gamenative/mods/ModContainerResolver.kt:11">
P2: `appId` is interpolated unsanitized into a filesystem path, creating a potential path traversal risk.</violation>
</file>

<file name="app/src/main/java/app/gamenative/mods/ModArchiveExtractor.kt">

<violation number="1" location="app/src/main/java/app/gamenative/mods/ModArchiveExtractor.kt:51">
P2: Unchecked `deleteRecursively()` results can leave stale/partial files and violate clean extraction assumptions.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/mods/ModArchiveExtractor.kt:145">
P1: ZIP extraction eagerly materializes all entries with `toList()` before the `MAX_ENTRIES` safety limit is checked, risking OOM on archives with many entries.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt:406">
P2: Nexus mod cleanup failure incorrectly marks a successful GOG game uninstall as failed because `NexusModManager.deleteInstallsForApp(...)` is not wrapped in its own error handler inside the success branch. If cleanup throws, the outer catch block reports 'Failed to uninstall game' even though `GOGService.deleteGame(...)` already succeeded.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/component/dialog/NexusModsSearchField.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/component/dialog/NexusModsSearchField.kt:41">
P2: Search helper repeatedly allocates Regex and intermediate collections per item during list filtering, causing unnecessary GC pressure for large mod lists.</violation>
</file>

<file name="app/src/main/java/app/gamenative/mods/ModPlacementSources.kt">

<violation number="1" location="app/src/main/java/app/gamenative/mods/ModPlacementSources.kt:9">
P1: Single-path values that start with `[` are misinterpreted as JSON arrays by `decode` and silently dropped, causing encode/decode data loss for bracket-prefixed directory names.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/screen/library/appscreen/CustomGameAppScreen.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/library/appscreen/CustomGameAppScreen.kt:496">
P1: Mod cleanup can abort the deletion flow and leave inconsistent state</violation>
</file>

<file name="app/src/main/java/app/gamenative/mods/NexusApiClient.kt">

<violation number="1" location="app/src/main/java/app/gamenative/mods/NexusApiClient.kt:314">
P1: API key attached to arbitrary download URLs risks credential leakage</violation>

<violation number="2" location="app/src/main/java/app/gamenative/mods/NexusApiClient.kt:391">
P2: Unbounded full-buffer HTTP response and zip entry reads may cause OOM on mobile devices</violation>
</file>

<file name="app/src/main/java/app/gamenative/mods/ModPlacementPreset.kt">

<violation number="1" location="app/src/main/java/app/gamenative/mods/ModPlacementPreset.kt:55">
P2: Hardcoded user-facing strings should be declared in Android string resources instead of inline literals.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/mods/ModPlacementPreset.kt:157">
P2: Redmod detection contains a redundant guard that makes the game-name check dead code, allowing false-positive preset matches for unrelated games.</violation>
</file>

<file name="app/src/main/java/app/gamenative/mods/FomodInstaller.kt">

<violation number="1" location="app/src/main/java/app/gamenative/mods/FomodInstaller.kt:303">
P2: XML parser security features are silently ignored on failure, which may leave XXE protections incomplete on Android runtimes where DocumentBuilderFactory does not support the requested features.</violation>
</file>

<file name="app/src/main/java/app/gamenative/mods/ModArchiveInstallAssessor.kt">

<violation number="1" location="app/src/main/java/app/gamenative/mods/ModArchiveInstallAssessor.kt:23">
P2: Hardcoded user-visible strings (queueMessage and assessment reasons) should be extracted to strings.xml and referenced via resources instead of inline literals.</violation>
</file>

<file name="app/src/main/java/app/gamenative/mods/FomodAutoSelector.kt">

<violation number="1" location="app/src/main/java/app/gamenative/mods/FomodAutoSelector.kt:43">
P1: SELECT_AT_LEAST_ONE rejects deterministic single-candidate groups when no preferred plugin exists</violation>

<violation number="2" location="app/src/main/java/app/gamenative/mods/FomodAutoSelector.kt:73">
P2: Hardcoded English reason strings bypass Android string resources and localization conventions.</violation>
</file>

<file name="app/src/main/java/app/gamenative/mods/ModDownloadRegistry.kt">

<violation number="1" location="app/src/main/java/app/gamenative/mods/ModDownloadRegistry.kt:26">
P2: `start()` unconditionally clears pending cancel flags, which can lose valid cancel requests in startup races.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/mods/ModDownloadRegistry.kt:34">
P2: Hardcoded UI status string bypasses localization</violation>

<violation number="3" location="app/src/main/java/app/gamenative/mods/ModDownloadRegistry.kt:65">
P2: `canceledImports` may leak stale install IDs because `requestCancel()` blindly adds any ID without checking whether it corresponds to an active download. Cleanup only occurs in `start()` and `finish()`, which are not guaranteed to be called for every ID passed to `requestCancel()` (e.g., invalid IDs, stale UI states, or interrupted flows). This can cause the set to grow unbounded in memory over the app's lifetime.</violation>
</file>

<file name="app/src/main/java/app/gamenative/mods/ModConflictAnalyzer.kt">

<violation number="1" location="app/src/main/java/app/gamenative/mods/ModConflictAnalyzer.kt:33">
P2: Analyzer performs full in-memory materialization of all planned files, which may not scale for large mods/libraries. Multiple `.toList()` calls and `flatMap` create large intermediate collections eagerly. For large mod packs this can cause high memory pressure and long pauses.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/mods/ModConflictAnalyzer.kt:35">
P1: Conflict analysis silently drops installs when ModMaterializer.plannedEntries throws, causing false-negative conflict reports with no error propagation or logging.</violation>

<violation number="3" location="app/src/main/java/app/gamenative/mods/ModConflictAnalyzer.kt:42">
P2: Unprotected `canonicalFile` call in `groupBy` can crash the entire conflict analysis.</violation>
</file>

<file name="app/src/main/java/app/gamenative/mods/BethesdaPluginManager.kt">

<violation number="1" location="app/src/main/java/app/gamenative/mods/BethesdaPluginManager.kt:105">
P1: Plugin detection silently drops installs on exceptions. `runCatching { ... }.getOrDefault(emptyList())` swallows any per-install planning error with no logging or user feedback, leading to incomplete plugin lists and misleading downstream diagnostics (missing masters/assets).</violation>

<violation number="2" location="app/src/main/java/app/gamenative/mods/BethesdaPluginManager.kt:241">
P2: Plugin entries are deduplicated with first-occurrence-wins (`distinctBy`), which can preserve stale enabled/disabled states when duplicate lines exist. A last-occurrence-wins approach is safer for line-based config files.</violation>
</file>

<file name="app/src/main/java/app/gamenative/data/ModInstall.kt">

<violation number="1" location="app/src/main/java/app/gamenative/data/ModInstall.kt:44">
P1: No database-level constraint ensures only one active mod profile per app, risking nondeterministic profile selection.</violation>
</file>

<file name="app/src/main/java/app/gamenative/mods/ModTargetResolver.kt">

<violation number="1" location="app/src/main/java/app/gamenative/mods/ModTargetResolver.kt:16">
P1: Resolution logic depends on directories already existing, which can block valid installs that should create missing target paths. `roots()` checks `isDirectory` for every Wine prefix subdirectory, so `resolve()` returns `null` for targets like `AppData/LocalLow` or `Documents/My Games` when they don't yet exist, causing "Target root is unavailable" errors and hiding the options from the UI.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/mods/ModTargetResolver.kt:49">
P2: `canonicalFile` can throw `IOException` (broken symlinks, permission denied, I/O errors), but `resolve()` is designed to return `null` for failures. Wrap `canonicalFile` accesses with `runCatching` to keep the nullable failure contract consistent with the existing `valueOf` guard.</violation>
</file>

<file name="app/src/main/java/app/gamenative/mods/ModMaterializer.kt">

<violation number="1" location="app/src/main/java/app/gamenative/mods/ModMaterializer.kt:586">
P1: Space check in overwrite-copy mode uses backupRoot instead of target filesystem</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

val entries = mutableListOf<ModArchiveEntry>()
var expandedBytes = 0L
ZipFile(archiveFile).use { zip ->
val zipEntries = zip.entries().asSequence().toList()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: ZIP extraction eagerly materializes all entries with toList() before the MAX_ENTRIES safety limit is checked, risking OOM on archives with many entries.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/mods/ModArchiveExtractor.kt, line 145:

<comment>ZIP extraction eagerly materializes all entries with `toList()` before the `MAX_ENTRIES` safety limit is checked, risking OOM on archives with many entries.</comment>

<file context>
@@ -0,0 +1,401 @@
+        val entries = mutableListOf<ModArchiveEntry>()
+        var expandedBytes = 0L
+        ZipFile(archiveFile).use { zip ->
+            val zipEntries = zip.entries().asSequence().toList()
+            val totalBytes = zipEntries.filter { !it.isDirectory && it.size > 0L }.sumOf { it.size }
+            zipEntries.forEach { entry ->
</file context>

fun decode(raw: String): List<String> {
val trimmed = raw.trim()
if (trimmed.isBlank()) return emptyList()
if (!trimmed.startsWith("[")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Single-path values that start with [ are misinterpreted as JSON arrays by decode and silently dropped, causing encode/decode data loss for bracket-prefixed directory names.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/mods/ModPlacementSources.kt, line 9:

<comment>Single-path values that start with `[` are misinterpreted as JSON arrays by `decode` and silently dropped, causing encode/decode data loss for bracket-prefixed directory names.</comment>

<file context>
@@ -0,0 +1,48 @@
+    fun decode(raw: String): List<String> {
+        val trimmed = raw.trim()
+        if (trimmed.isBlank()) return emptyList()
+        if (!trimmed.startsWith("[")) {
+            val normalized = normalize(trimmed)
+            return if (normalized.isBlank()) emptyList() else listOf(normalized)
</file context>

// Remove from manual folders list and invalidate cache
withContext(Dispatchers.IO) {
val folderPath = CustomGameScanner.getFolderPathFromAppId(libraryItem.appId)
NexusModManager.deleteInstallsForApp(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Mod cleanup can abort the deletion flow and leave inconsistent state

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/ui/screen/library/appscreen/CustomGameAppScreen.kt, line 496:

<comment>Mod cleanup can abort the deletion flow and leave inconsistent state</comment>

<file context>
@@ -492,6 +493,11 @@ class CustomGameAppScreen : BaseAppScreen() {
                                     // Remove from manual folders list and invalidate cache
                                     withContext(Dispatchers.IO) {
                                         val folderPath = CustomGameScanner.getFolderPathFromAppId(libraryItem.appId)
+                                        NexusModManager.deleteInstallsForApp(
+                                            context = context,
+                                            appId = libraryItem.appId,
</file context>

return parseCollectionManifest(JSONObject(jsonText), graphInfo)
}

private fun fetchCollectionPayload(url: String, apiKey: String): ByteArray =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: API key attached to arbitrary download URLs risks credential leakage

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/mods/NexusApiClient.kt, line 314:

<comment>API key attached to arbitrary download URLs risks credential leakage</comment>

<file context>
@@ -0,0 +1,689 @@
+        return parseCollectionManifest(JSONObject(jsonText), graphInfo)
+    }
+
+    private fun fetchCollectionPayload(url: String, apiKey: String): ByteArray =
+        executeBytes(baseRequest(url, apiKey).build())
+
</file context>

candidates.size == 1 -> candidates
else -> return null
}
FomodGroupType.SELECT_AT_LEAST_ONE -> preferred.ifEmpty { return null }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: SELECT_AT_LEAST_ONE rejects deterministic single-candidate groups when no preferred plugin exists

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/mods/FomodAutoSelector.kt, line 43:

<comment>SELECT_AT_LEAST_ONE rejects deterministic single-candidate groups when no preferred plugin exists</comment>

<file context>
@@ -0,0 +1,77 @@
+                        candidates.size == 1 -> candidates
+                        else -> return null
+                    }
+                    FomodGroupType.SELECT_AT_LEAST_ONE -> preferred.ifEmpty { return null }
+                    FomodGroupType.SELECT_AT_MOST_ONE -> when {
+                        preferred.size <= 1 -> preferred
</file context>

winePrefix: String,
): List<ModFileConflictReport> = withContext(Dispatchers.IO) {
val installById = installs.associateBy { it.installId }
val plannedFiles = installs.flatMap { install ->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Analyzer performs full in-memory materialization of all planned files, which may not scale for large mods/libraries. Multiple .toList() calls and flatMap create large intermediate collections eagerly. For large mod packs this can cause high memory pressure and long pauses.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/mods/ModConflictAnalyzer.kt, line 33:

<comment>Analyzer performs full in-memory materialization of all planned files, which may not scale for large mods/libraries. Multiple `.toList()` calls and `flatMap` create large intermediate collections eagerly. For large mod packs this can cause high memory pressure and long pauses.</comment>

<file context>
@@ -0,0 +1,108 @@
+        winePrefix: String,
+    ): List<ModFileConflictReport> = withContext(Dispatchers.IO) {
+        val installById = installs.associateBy { it.installId }
+        val plannedFiles = installs.flatMap { install ->
+            val recipes = recipesByInstallId[install.installId].orEmpty()
+            runCatching {
</file context>

Comment on lines +241 to +245
.distinctBy { it.fileName.lowercase() }
.mapNotNull { plugin ->
when {
plugin.enabled -> enabledPluginLine(plugin.fileName, usesMarkers)
usesMarkers -> plugin.fileName

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Plugin entries are deduplicated with first-occurrence-wins (distinctBy), which can preserve stale enabled/disabled states when duplicate lines exist. A last-occurrence-wins approach is safer for line-based config files.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/mods/BethesdaPluginManager.kt, line 241:

<comment>Plugin entries are deduplicated with first-occurrence-wins (`distinctBy`), which can preserve stale enabled/disabled states when duplicate lines exist. A last-occurrence-wins approach is safer for line-based config files.</comment>

<file context>
@@ -0,0 +1,522 @@
+            .distinctPluginLines(game)
+        val baseLines = basePlugins.map { enabledPluginLine(it, usesMarkers) }
+        val managedLines = managedPlugins
+            .distinctBy { it.fileName.lowercase() }
+            .mapNotNull { plugin ->
+                when {
</file context>
Suggested change
.distinctBy { it.fileName.lowercase() }
.mapNotNull { plugin ->
when {
plugin.enabled -> enabledPluginLine(plugin.fileName, usesMarkers)
usesMarkers -> plugin.fileName
private fun readPluginEntries(file: File, game: BethesdaGame?): List<PluginEntry> =
pluginFileVariants(file)
.flatMap { existingFile -> if (existingFile.isFile) existingFile.readLines() else emptyList() }
.mapNotNull { parsePluginLine(it, game) }
.asReversed()
.distinctBy { it.fileName.lowercase() }
.reversed()

if (rootType == ModTargetRoot.CUSTOM_ABSOLUTE) {
val rawTarget = File(targetRelativePath.trim().replace('\\', '/'))
if (!rawTarget.isAbsolute) return null
val target = rawTarget.canonicalFile

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: canonicalFile can throw IOException (broken symlinks, permission denied, I/O errors), but resolve() is designed to return null for failures. Wrap canonicalFile accesses with runCatching to keep the nullable failure contract consistent with the existing valueOf guard.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/mods/ModTargetResolver.kt, line 49:

<comment>`canonicalFile` can throw `IOException` (broken symlinks, permission denied, I/O errors), but `resolve()` is designed to return `null` for failures. Wrap `canonicalFile` accesses with `runCatching` to keep the nullable failure contract consistent with the existing `valueOf` guard.</comment>

<file context>
@@ -0,0 +1,68 @@
+        if (rootType == ModTargetRoot.CUSTOM_ABSOLUTE) {
+            val rawTarget = File(targetRelativePath.trim().replace('\\', '/'))
+            if (!rawTarget.isAbsolute) return null
+            val target = rawTarget.canonicalFile
+            val allowedRoots = roots(gameRootDir, winePrefix).map { it.dir.canonicalFile }
+            return target.takeIf { candidate ->
</file context>

}
}

fun requestCancel(installId: String) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: canceledImports may leak stale install IDs because requestCancel() blindly adds any ID without checking whether it corresponds to an active download. Cleanup only occurs in start() and finish(), which are not guaranteed to be called for every ID passed to requestCancel() (e.g., invalid IDs, stale UI states, or interrupted flows). This can cause the set to grow unbounded in memory over the app's lifetime.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/mods/ModDownloadRegistry.kt, line 65:

<comment>`canceledImports` may leak stale install IDs because `requestCancel()` blindly adds any ID without checking whether it corresponds to an active download. Cleanup only occurs in `start()` and `finish()`, which are not guaranteed to be called for every ID passed to `requestCancel()` (e.g., invalid IDs, stale UI states, or interrupted flows). This can cause the set to grow unbounded in memory over the app's lifetime.</comment>

<file context>
@@ -0,0 +1,73 @@
+        }
+    }
+
+    fun requestCancel(installId: String) {
+        synchronized(canceledImports) {
+            canceledImports += installId
</file context>

installId = installId,
appId = appId,
displayName = displayName,
status = "Starting",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Hardcoded UI status string bypasses localization

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/mods/ModDownloadRegistry.kt, line 34:

<comment>Hardcoded UI status string bypasses localization</comment>

<file context>
@@ -0,0 +1,73 @@
+            installId = installId,
+            appId = appId,
+            displayName = displayName,
+            status = "Starting",
+        )
+        downloads.update { current -> current + (installId to info) }
</file context>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

🧹 Nitpick comments (5)
app/src/test/java/app/gamenative/mods/NexusApiClientTest.kt (1)

29-32: ⚡ Quick win

Consider cleaning up the OkHttpClient in tearDown.

The OkHttpClient created in setUp() maintains internal thread pools (dispatcher and connection pool) that are not shut down when each test completes. While the JVM will eventually garbage-collect these resources, explicitly cleaning them up prevents thread accumulation across test executions.

♻️ Recommended cleanup
 `@After`
 fun tearDown() {
+    client.dispatcher.executorService.shutdown()
+    client.connectionPool.evictAll()
     server.shutdown()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/test/java/app/gamenative/mods/NexusApiClientTest.kt` around lines 29
- 32, The tearDown() currently only calls server.shutdown(), but the
OkHttpClient created in setUp() must also be cleaned up to avoid lingering
threads; in tearDown() (the teardown method) call the OkHttpClient instance's
cleanup routines: shut down its dispatcher executor service
(client.dispatcher().executorService().shutdown()), evict connections from its
connection pool (client.connectionPool().evictAll()), and close its cache if
present (client.cache()?.close()), ensuring you reference the OkHttpClient
variable created in setUp() so tests fully release resources.
app/src/main/java/app/gamenative/data/ModInstall.kt (1)

40-66: ⚡ Quick win

Consider adding a unique partial index to enforce single active profile per app.

The schema allows multiple profiles for the same app_id to have active = true simultaneously, which could lead to ambiguity about which profile is current. While ModProfileManager.activateProfile likely handles deactivation in application code, a database-level constraint would provide stronger guarantees.

🔒 Suggested partial index to enforce single active profile

Add this to the indices array:

 `@Entity`(
     tableName = "mod_profile",
     indices = [
         Index("app_id"),
         Index(value = ["app_id", "name"], unique = true),
+        Index(value = ["app_id"], unique = true, where = "active = 1"),
     ],
 )

Note: Room's @Index annotation does not support a where clause as of Room 2.x. You would need to add this constraint via a migration SQL statement:

CREATE UNIQUE INDEX IF NOT EXISTS `index_mod_profile_app_id_active` 
ON `mod_profile` (`app_id`) WHERE `active` = 1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/app/gamenative/data/ModInstall.kt` around lines 40 - 66,
The ModProfile entity currently allows multiple active profiles per app; add a
DB-level unique partial index to enforce a single active profile by creating a
migration that runs the SQL: create a unique index on app_id where active = 1
for table mod_profile (index name like index_mod_profile_app_id_active) since
Room's `@Index` can't express WHERE; add this migration alongside your existing
migrations and ensure ModProfileManager.activateProfile logic remains in sync
with the migration so only one profile per app can be active.
app/src/main/java/app/gamenative/ui/component/dialog/NexusModsImportSections.kt (2)

319-324: ⚡ Quick win

Avoid O(n²) indexing in collection row rendering.

pending.mods.indexOf(mod) runs a linear search for each displayed item. Use indexed iteration to keep rendering work linear.

Suggested fix
-            filteredMods.forEach { mod ->
+            filteredMods.forEach { mod ->
                 val key = mod.collectionKey()
                 val queueItem = queueItems[key]
                 val selected = key in selectedKeys
-                val index = pending.mods.indexOf(mod)
+                val index = pending.mods.indexOf(mod) // replace by precomputed index map or indexed list
// Example approach outside this block:
val indexByKey = remember(pending.mods) {
    pending.mods.mapIndexed { idx, m -> m.collectionKey() to idx }.toMap()
}
-                val index = pending.mods.indexOf(mod)
+                val index = indexByKey[key] ?: -1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/app/gamenative/ui/component/dialog/NexusModsImportSections.kt`
around lines 319 - 324, The render loop in NexusModsImportSections.kt uses
pending.mods.indexOf(mod) inside filteredMods.forEach, causing O(n²) work;
replace this by computing indexes once (e.g., with pending.mods.mapIndexed to
build an indexByKey map inside a remember block) or iterate using forEachIndexed
so you can look up the index in O(1) when rendering each item; update the code
that references pending.mods.indexOf(mod) (the line inside the
filteredMods.forEach block) to use the precomputed index lookup
(indexByKey[mod.collectionKey()] or the forEachIndexed index) and keep existing
uses of queueItems, selectedKeys and mod.collectionKey() unchanged.

63-127: 🏗️ Heavy lift

Move new user-facing strings into strings.xml.

This section introduces many hardcoded labels/messages, which bypasses localization and makes translation/consistency maintenance difficult for the new Nexus flow.

Also applies to: 144-175, 233-257, 268-281, 337-414, 438-441, 455-463, 520-523, 573-582, 627-628

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/app/gamenative/ui/component/dialog/NexusModsImportSections.kt`
around lines 63 - 127, Replace hardcoded user-facing strings in the Nexus Mods
composables with string resources: move literals like "Nexus account", the help
texts, "Personal API key", "Save and check key", "Add from Nexus Mods", "Mod or
collection URL", "Find files" (and the other occurrences noted) into strings.xml
and use stringResource(...) in the composable functions (e.g., the top-level
Nexus Mods auth UI and ImportSection). Update calls in ImportSection, the
NoExtractOutlinedTextField labels, Buttons, Texts and any validation messages to
reference those resource IDs instead of hardcoded literals so the UI is
localizable and consistent.
app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt (1)

346-348: 💤 Low value

Consider distinct icons for ManageGameContent and ManageMods.

Both ManageGameContent (line 346) and ManageMods (line 348) map to Icons.Default.Apps. Since both options appear in the same Help & Info section (lines 401-404), users will see two adjacent menu items with identical icons, which may reduce clarity.

Consider assigning a distinct icon to ManageMods to improve visual differentiation—for example:

-        AppOptionMenuType.ManageMods -> Icons.Default.Apps
+        AppOptionMenuType.ManageMods -> Icons.Default.Extension  // or another suitable icon
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt`
around lines 346 - 348, Two menu entries use the same icon:
AppOptionMenuType.ManageGameContent and AppOptionMenuType.ManageMods both map to
Icons.Default.Apps in GameOptionsPanel.kt; change the mapping for
AppOptionMenuType.ManageMods to a distinct, semantically appropriate icon (for
example Icons.Default.Extension or Icons.Default.Build) so the Help & Info
section shows visually distinct icons; update the branch/switch case that
returns the icon for AppOptionMenuType.ManageMods to the chosen alternative and
ensure imports remain correct.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/main/java/app/gamenative/db/dao/ModDao.kt`:
- Around line 142-146: The method replaceOverwriteManifestsForTargets currently
returns early when manifests.isEmpty(), preventing deletion of previously stored
manifests; change it so that when manifests.isEmpty() you still remove existing
entries for that installId (e.g. call a DAO method to delete all overwrite
manifests for the installId such as
deleteOverwriteManifestsForInstall(installId) or add that method if it doesn't
exist), and otherwise proceed to call
deleteOverwriteManifestsForTargets(installId, manifests.map { it.targetPath })
followed by insertOverwriteManifests(manifests); keep the `@Transaction` and
suspend signature on replaceOverwriteManifestsForTargets and reference
ModOverwriteManifest when building the targetPath list.
- Around line 73-79: activateProfile is deactivating all profiles for appId via
clearActiveProfile(appId) but then re-activates by profileId only
(setProfileActive(profileId)), which can toggle a profile belonging to a
different app; change setProfileActive to accept appId (e.g.,
setProfileActive(appId: String, profileId: String, updatedAt: Long =
System.currentTimeMillis())) and update its `@Query` to include "AND app_id =
:appId" (or otherwise constrain the UPDATE by app id), then call
setProfileActive(appId, profileId) from activateProfile so activation is scoped
to the same app (ensure references to setProfileActive and activateProfile are
updated accordingly).

In `@app/src/main/java/app/gamenative/di/DatabaseModule.kt`:
- Around line 34-41: The database builder currently calls
fallbackToDestructiveMigration(true) which will drop non-reconstructable tables
like mod_profile/mod_placement_recipe/mod_overwrite_manifest; remove the
fallbackToDestructiveMigration(...) call from the Room.databaseBuilder chain in
DatabaseModule.kt and ensure you provide explicit Room migrations (e.g., augment
the set passed to .addMigrations(...) — ROOM_MIGRATION_V7_to_V8,
ROOM_MIGRATION_V20_to_V23, ROOM_MIGRATION_V21_to_V23, ROOM_MIGRATION_V22_to_V23)
for any schema version jumps you expect, or throw/handle a migration-missing
error so destructive resets cannot silently occur.

In `@app/src/main/java/app/gamenative/mods/FomodInstaller.kt`:
- Around line 315-330: The generate(...) overload that maps selectedPluginNames
to keys can silently select multiple plugins when names are duplicated; update
generate to detect ambiguous name matches and fail fast: in generate, for each
name in selectedPluginNames use the same lookup logic (inspecting
installer.steps -> step.groups -> group.plugins and pluginKey(stepIndex,
groupIndex, pluginIndex)) but group results by name and if any name maps to more
than one plugin throw an IllegalArgumentException (or return an error result)
instructing callers to use the key-based API (generateForPluginKeys), otherwise
convert unambiguous names to keys and call generateForPluginKeys with that set;
ensure you reference generate, selectedPluginNames, installer, pluginKey and
generateForPluginKeys in the change.
- Around line 300-308: The XML hardening feature calls in secureFactory() are
currently swallowed by runCatching, letting parsing continue with incomplete
protections; change those runCatching wrappers so failures abort: call
setFeature(...) for each of the four feature URIs
(http://apache.org/xml/features/disallow-doctype-decl,
http://xml.org/sax/features/external-general-entities,
http://xml.org/sax/features/external-parameter-entities,
http://apache.org/xml/features/nonvalidating/load-external-dtd) without
suppressing exceptions (or catch and rethrow a ParserConfigurationException with
a clear message) so secureFactory() throws when a feature cannot be enabled and
callers (e.g. the code that invokes parse(moduleConfigXml)) will skip the
installer if XML hardening cannot be applied; keep the isNamespaceAware and
isExpandEntityReferences settings as-is.

In `@app/src/main/java/app/gamenative/mods/ModConflictAnalyzer.kt`:
- Around line 41-43: The grouping step on plannedFiles uses
it.target.canonicalFile.absolutePath which can throw IOException for
non-existent planned targets; wrap the canonicalization in a safe handler (e.g.,
use runCatching or try/catch) when deriving the key so failed canonicalization
yields a fallback path (such as target.path or absolutePath) or filters out that
PlannedFile, and update the groupBy call to use that safe key provider;
specifically adjust the groupBy lambda referencing
PlannedFile.target/.canonicalFile.absolutePath so it never lets IOException
escape the coroutine.

In `@app/src/main/java/app/gamenative/mods/ModTargetResolver.kt`:
- Around line 66-68: The helper File.isInsideOrEqual has a bug when root.path
equals File.separator (filesystem root) because concatenating another separator
produces "//" and breaks startsWith; update isInsideOrEqual to normalize the
root path before checking containment (e.g., treat root.path == File.separator
specially or trim trailing separators) so that the check becomes this == root ||
path.startsWith(normalizedRoot + File.separator) (and handle the case where
normalizedRoot is just File.separator by checking
path.startsWith(File.separator)); ensure you update the implementation in the
isInsideOrEqual extension to use normalizedRoot and keep the existing equality
fallback.

In `@app/src/main/java/app/gamenative/mods/NexusApiClient.kt`:
- Around line 166-171: The retry/fallback loops in NexusApiClient (e.g., the
getCollectionRevisionGraph call inside the withContext block and the subsequent
GraphQL/REST fallback blocks) only catch NexusApiException and therefore abort
on transient I/O, socket, JSON parsing, or other runtime errors; update those
try/catch blocks to also catch transient exceptions (e.g., IOException,
JSONException/JsonParseException, and general Exception as a last resort) while
preserving the current behavior of rethrowing NexusApiException for status codes
401/403/429, and when catching these transient errors set lastError (or the same
accumulator used currently) and continue the loop so the code will try the next
Graph URL or fallback to REST revision paths (apply the same change to the other
similar try/catch blocks that call getCollectionRevisionGraph and REST
resolution).
- Around line 297-315: The code currently calls
fetchCollectionPayload(resolveNexusUrl(...), apiKey) which always uses
baseRequest(url, apiKey) and thus forwards the Nexus API key to any absolute
URL; modify the request logic so collection/download requests do not leak the
API key: update fetchCollectionPayload(url, apiKey) (and any callers like the
block that handles nextLink) to detect whether the target URL is a Nexus-owned
HTTPS host (use the same resolution logic as resolveNexusUrl or parse the URL
host) and only call baseRequest(url, apiKey) for those Nexus HTTPS hosts; for
all other absolute URLs build a request that does NOT include the API key and
enforces HTTPS (or fails if not HTTPS) before calling executeBytes, ensuring
tokens are never sent to external or plain-HTTP hosts.

In `@app/src/main/java/app/gamenative/mods/NexusModManager.kt`:
- Around line 438-441: The saveLastPlacementForApp function currently returns
early when enabledRecipes is empty, leaving any previously stored placement for
appId in PrefManager.nexusLastPlacementJson; change the logic so when
enabledRecipes.isEmpty() you load the existing
JSONObject(PrefManager.nexusLastPlacementJson) (or create one), remove the entry
for the provided appId (or set it to null), and write the modified JSONObject
back to PrefManager.nexusLastPlacementJson so saved placements are cleared for
that app; keep modifications localized to saveLastPlacementForApp and use the
same JSONObject handling already present in that function.

In `@app/src/main/java/app/gamenative/service/NexusModImportService.kt`:
- Around line 294-301: The resumeInterruptedImports function currently calls
ContextCompat.startForegroundService(...) directly which can throw and crash
callers; wrap the call in a try/catch around
ContextCompat.startForegroundService(appContext, Intent(appContext,
NexusModImportService::class.java).apply { action = ACTION_RESUME_IMPORTS }) and
on exception catch it, log the failure with contextual info (e.g., include
ACTION_RESUME_IMPORTS and NexusModImportService) and return/skip without
rethrowing so the caller is not crashed.

In
`@app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialogHelpers.kt`:
- Around line 345-355: In archiveChildren, the case-insensitive startsWith check
can mismatch the case-sensitive removePrefix call causing incorrect remaining
paths; update the logic in archiveChildren (within the loop handling
path/prefix) to strip the prefix in a case-insensitive-safe way — either compute
remaining via path.substring(prefix.length) after confirming
path.startsWith(prefix, ignoreCase = true), or normalize both path and prefix
(e.g., lowercased) and call removePrefix on the normalized value and use the
corresponding substring from the original path; ensure this change is applied
where remaining is computed so children are correctly resolved.

In
`@app/src/main/java/app/gamenative/ui/component/dialog/NexusModsFomodSections.kt`:
- Around line 279-286: The call to FomodRecipeGenerator.generateForPluginKeys in
NexusModsFomodSections.kt hardcodes targetRelativePath = "Data", causing
incorrect placement for non-Bethesda presets; change that argument to use
baseDraft.targetRelativePath (or a sensible fallback like "Data" when
baseDraft.targetRelativePath is null/blank) so generateForPluginKeys receives
the user-selected placement; update the invocation in the block that builds
result (the call with installId, installer, selectedPluginKeys, targetRoot =
baseDraft.targetRoot, targetRelativePath = "Data", mode =
ModPlacementMode.OVERWRITE_COPY.name) to pass baseDraft.targetRelativePath ?:
"Data" (or equivalent) instead of the literal "Data".

In
`@app/src/main/java/app/gamenative/ui/component/dialog/NexusModsInstalledSections.kt`:
- Around line 129-131: The TextButton currently validates with
name.trim().isNotBlank() but calls onConfirm(name) with the untrimmed value;
update the onClick handler in NexusModsInstalledSections (the TextButton that
uses onConfirm and name) to pass the trimmed string (e.g., call onConfirm with
name.trim()) so stored profile names don't keep accidental leading/trailing
whitespace.

In
`@app/src/main/java/app/gamenative/ui/screen/library/appscreen/AmazonAppScreen.kt`:
- Around line 351-358: The code calls AmazonService.deleteGame(...) before
computing gameRootDir, so uninstall can clear install metadata and make
getInstallPath(...) return null; capture the install path first by calling
getInstallPath(context, libraryItem) and constructing the File (or null) into a
local variable (e.g. gameRootDirFile) before calling AmazonService.deleteGame,
then call DownloadService.invalidateCache() and pass that captured
gameRootDirFile into NexusModManager.deleteInstallsForApp(context, appId =
libraryItem.appId, gameRootDir = gameRootDirFile) so filesystem mod cleanup runs
against the correct path.

In
`@app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt`:
- Around line 489-494: After EpicService.deleteGame() returns success (the
branch where result.isSuccess is true in EpicAppScreen.kt), move the call to
NexusModManager.deleteInstallsForApp(...) out of the main success path so any
exception it throws cannot propagate to the outer uninstall catch; wrap the
NexusModManager.deleteInstallsForApp(...) call in its own try/catch and on
failure log the error (including context like libraryItem.appId and the path
from getInstallPath(...)) or schedule a retry, but do NOT rethrow so the
uninstall remains reported as successful.

---

Nitpick comments:
In `@app/src/main/java/app/gamenative/data/ModInstall.kt`:
- Around line 40-66: The ModProfile entity currently allows multiple active
profiles per app; add a DB-level unique partial index to enforce a single active
profile by creating a migration that runs the SQL: create a unique index on
app_id where active = 1 for table mod_profile (index name like
index_mod_profile_app_id_active) since Room's `@Index` can't express WHERE; add
this migration alongside your existing migrations and ensure
ModProfileManager.activateProfile logic remains in sync with the migration so
only one profile per app can be active.

In
`@app/src/main/java/app/gamenative/ui/component/dialog/NexusModsImportSections.kt`:
- Around line 319-324: The render loop in NexusModsImportSections.kt uses
pending.mods.indexOf(mod) inside filteredMods.forEach, causing O(n²) work;
replace this by computing indexes once (e.g., with pending.mods.mapIndexed to
build an indexByKey map inside a remember block) or iterate using forEachIndexed
so you can look up the index in O(1) when rendering each item; update the code
that references pending.mods.indexOf(mod) (the line inside the
filteredMods.forEach block) to use the precomputed index lookup
(indexByKey[mod.collectionKey()] or the forEachIndexed index) and keep existing
uses of queueItems, selectedKeys and mod.collectionKey() unchanged.
- Around line 63-127: Replace hardcoded user-facing strings in the Nexus Mods
composables with string resources: move literals like "Nexus account", the help
texts, "Personal API key", "Save and check key", "Add from Nexus Mods", "Mod or
collection URL", "Find files" (and the other occurrences noted) into strings.xml
and use stringResource(...) in the composable functions (e.g., the top-level
Nexus Mods auth UI and ImportSection). Update calls in ImportSection, the
NoExtractOutlinedTextField labels, Buttons, Texts and any validation messages to
reference those resource IDs instead of hardcoded literals so the UI is
localizable and consistent.

In
`@app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt`:
- Around line 346-348: Two menu entries use the same icon:
AppOptionMenuType.ManageGameContent and AppOptionMenuType.ManageMods both map to
Icons.Default.Apps in GameOptionsPanel.kt; change the mapping for
AppOptionMenuType.ManageMods to a distinct, semantically appropriate icon (for
example Icons.Default.Extension or Icons.Default.Build) so the Help & Info
section shows visually distinct icons; update the branch/switch case that
returns the icon for AppOptionMenuType.ManageMods to the chosen alternative and
ensure imports remain correct.

In `@app/src/test/java/app/gamenative/mods/NexusApiClientTest.kt`:
- Around line 29-32: The tearDown() currently only calls server.shutdown(), but
the OkHttpClient created in setUp() must also be cleaned up to avoid lingering
threads; in tearDown() (the teardown method) call the OkHttpClient instance's
cleanup routines: shut down its dispatcher executor service
(client.dispatcher().executorService().shutdown()), evict connections from its
connection pool (client.connectionPool().evictAll()), and close its cache if
present (client.cache()?.close()), ensuring you reference the OkHttpClient
variable created in setUp() so tests fully release resources.
🪄 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: c08df9e0-66c0-42f6-883c-fdfcb81e246b

📥 Commits

Reviewing files that changed from the base of the PR and between fbd1d59 and 05b0f65.

📒 Files selected for processing (69)
  • app/build.gradle.kts
  • app/schemas/app.gamenative.db.PluviaDatabase/23.json
  • app/src/androidTest/java/app/gamenative/mods/ModArchiveExtractorAndroidTest.kt
  • app/src/main/AndroidManifest.xml
  • app/src/main/java/app/gamenative/PrefManager.kt
  • app/src/main/java/app/gamenative/data/ModInstall.kt
  • app/src/main/java/app/gamenative/db/PluviaDatabase.kt
  • app/src/main/java/app/gamenative/db/dao/ModDao.kt
  • app/src/main/java/app/gamenative/db/migration/RoomMigration.kt
  • app/src/main/java/app/gamenative/di/DatabaseModule.kt
  • app/src/main/java/app/gamenative/mods/BethesdaPlacementRecipeExpander.kt
  • app/src/main/java/app/gamenative/mods/BethesdaPluginManager.kt
  • app/src/main/java/app/gamenative/mods/FomodAutoSelector.kt
  • app/src/main/java/app/gamenative/mods/FomodInstaller.kt
  • app/src/main/java/app/gamenative/mods/ModArchiveExtractor.kt
  • app/src/main/java/app/gamenative/mods/ModArchiveInstallAssessor.kt
  • app/src/main/java/app/gamenative/mods/ModConflictAnalyzer.kt
  • app/src/main/java/app/gamenative/mods/ModContainerResolver.kt
  • app/src/main/java/app/gamenative/mods/ModDownloadRegistry.kt
  • app/src/main/java/app/gamenative/mods/ModMaterializer.kt
  • app/src/main/java/app/gamenative/mods/ModPathDetector.kt
  • app/src/main/java/app/gamenative/mods/ModPlacementPreset.kt
  • app/src/main/java/app/gamenative/mods/ModPlacementSources.kt
  • app/src/main/java/app/gamenative/mods/ModProfileManager.kt
  • app/src/main/java/app/gamenative/mods/ModTargetResolver.kt
  • app/src/main/java/app/gamenative/mods/NexusApiClient.kt
  • app/src/main/java/app/gamenative/mods/NexusCollectionManifest.kt
  • app/src/main/java/app/gamenative/mods/NexusCollectionPrioritySuggester.kt
  • app/src/main/java/app/gamenative/mods/NexusCollectionReusePolicy.kt
  • app/src/main/java/app/gamenative/mods/NexusImportState.kt
  • app/src/main/java/app/gamenative/mods/NexusModAutomationPolicy.kt
  • app/src/main/java/app/gamenative/mods/NexusModManager.kt
  • app/src/main/java/app/gamenative/mods/NexusUrlParser.kt
  • app/src/main/java/app/gamenative/service/NexusModImportService.kt
  • app/src/main/java/app/gamenative/ui/component/dialog/NexusModsBethesdaSections.kt
  • app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialog.kt
  • app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialogHelpers.kt
  • app/src/main/java/app/gamenative/ui/component/dialog/NexusModsFomodSections.kt
  • app/src/main/java/app/gamenative/ui/component/dialog/NexusModsImportSections.kt
  • app/src/main/java/app/gamenative/ui/component/dialog/NexusModsInstalledSections.kt
  • app/src/main/java/app/gamenative/ui/component/dialog/NexusModsPlacementSections.kt
  • app/src/main/java/app/gamenative/ui/component/dialog/NexusModsSearchField.kt
  • app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt
  • app/src/main/java/app/gamenative/ui/screen/library/appscreen/AmazonAppScreen.kt
  • app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt
  • app/src/main/java/app/gamenative/ui/screen/library/appscreen/CustomGameAppScreen.kt
  • app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt
  • app/src/main/java/app/gamenative/ui/screen/library/appscreen/GOGAppScreen.kt
  • app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt
  • app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt
  • app/src/main/res/values/strings.xml
  • app/src/test/java/app/gamenative/mods/BethesdaPlacementRecipeExpanderTest.kt
  • app/src/test/java/app/gamenative/mods/BethesdaPluginManagerTest.kt
  • app/src/test/java/app/gamenative/mods/FomodAutoSelectorTest.kt
  • app/src/test/java/app/gamenative/mods/FomodInstallerTest.kt
  • app/src/test/java/app/gamenative/mods/ModArchiveExtractorTest.kt
  • app/src/test/java/app/gamenative/mods/ModArchiveInstallAssessorTest.kt
  • app/src/test/java/app/gamenative/mods/ModConflictAnalyzerTest.kt
  • app/src/test/java/app/gamenative/mods/ModMaterializerTest.kt
  • app/src/test/java/app/gamenative/mods/ModPlacementPresetDetectorTest.kt
  • app/src/test/java/app/gamenative/mods/ModPlacementSourcesTest.kt
  • app/src/test/java/app/gamenative/mods/ModTargetResolverTest.kt
  • app/src/test/java/app/gamenative/mods/NexusApiClientTest.kt
  • app/src/test/java/app/gamenative/mods/NexusCollectionManifestParserTest.kt
  • app/src/test/java/app/gamenative/mods/NexusCollectionPrioritySuggesterTest.kt
  • app/src/test/java/app/gamenative/mods/NexusCollectionReusePolicyTest.kt
  • app/src/test/java/app/gamenative/mods/NexusImportStateTest.kt
  • app/src/test/java/app/gamenative/mods/NexusUrlParserTest.kt
  • gradle/libs.versions.toml

Comment on lines +73 to +79
@Query("UPDATE mod_profile SET active = 1, updated_at = :updatedAt WHERE profile_id = :profileId")
suspend fun setProfileActive(profileId: String, updatedAt: Long = System.currentTimeMillis())

@Transaction
suspend fun activateProfile(appId: String, profileId: String) {
clearActiveProfile(appId)
setProfileActive(profileId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep profile activation scoped to the same app.

Line 78 clears actives for appId, but Line 79 re-activates by profileId only. A stale or mismatched ID will deactivate one game's active profile and activate another game's profile in the same transaction.

Suggested fix
-    `@Query`("UPDATE mod_profile SET active = 1, updated_at = :updatedAt WHERE profile_id = :profileId")
-    suspend fun setProfileActive(profileId: String, updatedAt: Long = System.currentTimeMillis())
+    `@Query`("UPDATE mod_profile SET active = 1, updated_at = :updatedAt WHERE app_id = :appId AND profile_id = :profileId")
+    suspend fun setProfileActive(appId: String, profileId: String, updatedAt: Long = System.currentTimeMillis())

     `@Transaction`
     suspend fun activateProfile(appId: String, profileId: String) {
         clearActiveProfile(appId)
-        setProfileActive(profileId)
+        setProfileActive(appId, profileId)
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/app/gamenative/db/dao/ModDao.kt` around lines 73 - 79,
activateProfile is deactivating all profiles for appId via
clearActiveProfile(appId) but then re-activates by profileId only
(setProfileActive(profileId)), which can toggle a profile belonging to a
different app; change setProfileActive to accept appId (e.g.,
setProfileActive(appId: String, profileId: String, updatedAt: Long =
System.currentTimeMillis())) and update its `@Query` to include "AND app_id =
:appId" (or otherwise constrain the UPDATE by app id), then call
setProfileActive(appId, profileId) from activateProfile so activation is scoped
to the same app (ensure references to setProfileActive and activateProfile are
updated accordingly).

Comment on lines +142 to +146
@Transaction
suspend fun replaceOverwriteManifestsForTargets(installId: String, manifests: List<ModOverwriteManifest>) {
if (manifests.isEmpty()) return
deleteOverwriteManifestsForTargets(installId, manifests.map { it.targetPath })
insertOverwriteManifests(manifests)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

replaceOverwriteManifestsForTargets() cannot clear a target set to empty.

When manifests is empty, Line 144 returns before any delete runs. That means callers cannot use this helper to replace a previously non-empty overwrite set with an empty one, so stale mod_overwrite_manifest rows can survive after the filesystem state changed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/app/gamenative/db/dao/ModDao.kt` around lines 142 - 146,
The method replaceOverwriteManifestsForTargets currently returns early when
manifests.isEmpty(), preventing deletion of previously stored manifests; change
it so that when manifests.isEmpty() you still remove existing entries for that
installId (e.g. call a DAO method to delete all overwrite manifests for the
installId such as deleteOverwriteManifestsForInstall(installId) or add that
method if it doesn't exist), and otherwise proceed to call
deleteOverwriteManifestsForTargets(installId, manifests.map { it.targetPath })
followed by insertOverwriteManifests(manifests); keep the `@Transaction` and
suspend signature on replaceOverwriteManifestsForTargets and reference
ModOverwriteManifest when building the targetPath list.

Comment on lines 34 to 41
return Room.databaseBuilder(context, PluviaDatabase::class.java, DATABASE_NAME)
.addMigrations(ROOM_MIGRATION_V7_to_V8)
.addMigrations(
ROOM_MIGRATION_V7_to_V8,
ROOM_MIGRATION_V20_to_V23,
ROOM_MIGRATION_V21_to_V23,
ROOM_MIGRATION_V22_to_V23,
)
.fallbackToDestructiveMigration(true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Avoid global destructive fallback now that the DB tracks deployed mod state.

After this PR, the database owns non-reconstructable state like mod_profile, mod_placement_recipe, and mod_overwrite_manifest. If a future version gap hits Line 41, Room will drop that metadata while the deployed files and backups remain on disk, leaving installs unsafe to reconcile or restore.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/app/gamenative/di/DatabaseModule.kt` around lines 34 - 41,
The database builder currently calls fallbackToDestructiveMigration(true) which
will drop non-reconstructable tables like
mod_profile/mod_placement_recipe/mod_overwrite_manifest; remove the
fallbackToDestructiveMigration(...) call from the Room.databaseBuilder chain in
DatabaseModule.kt and ensure you provide explicit Room migrations (e.g., augment
the set passed to .addMigrations(...) — ROOM_MIGRATION_V7_to_V8,
ROOM_MIGRATION_V20_to_V23, ROOM_MIGRATION_V21_to_V23, ROOM_MIGRATION_V22_to_V23)
for any schema version jumps you expect, or throw/handle a migration-missing
error so destructive resets cannot silently occur.

Comment on lines +300 to +308
private fun secureFactory(): DocumentBuilderFactory =
DocumentBuilderFactory.newInstance().apply {
isNamespaceAware = false
runCatching { setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) }
runCatching { setFeature("http://xml.org/sax/features/external-general-entities", false) }
runCatching { setFeature("http://xml.org/sax/features/external-parameter-entities", false) }
runCatching { setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) }
isExpandEntityReferences = false
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the file (in case path differs) and print relevant sections around secureFactory and its usage.
FILE="app/src/main/java/app/gamenative/mods/FomodInstaller.kt"
test -f "$FILE" || { echo "Missing $FILE"; exit 1; }

echo "=== secureFactory() around lines 260-360 ==="
sed -n '250,360p' "$FILE" | cat -n

echo "=== Search for ModuleConfig.xml parsing ==="
rg -n "ModuleConfig\.xml|moduleconfig\.xml|ModuleConfig|config\.xml" "$FILE" || true

echo "=== Search for XML parsing usage of DocumentBuilderFactory / secureFactory ==="
rg -n "secureFactory\(|DocumentBuilderFactory|DocumentBuilder|parse\(|newDocumentBuilder\(|DocumentBuilder" "$FILE" || true

echo "=== Search for runCatching { setFeature(...) } usage ==="
rg -n "runCatching\s*\{\s*setFeature" "$FILE" || true

echo "=== Check plugin selection logic for name ambiguity ==="
rg -n "selectedPluginNames|plugin\.name|display name|name-based|map.*plugin|associate.*plugin|group" "$FILE" || true

echo "=== Print generate()/selection-related sections (approx. 360-520) ==="
sed -n '360,540p' "$FILE" | cat -n

Repository: utkarshdalal/GameNative

Length of output: 15890


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="app/src/main/java/app/gamenative/mods/FomodInstaller.kt"
test -f "$FILE"

echo "=== parsePlugin() / plugin.name definition ==="
rg -n "fun parsePlugin|parsePlugin\\(|data class FomodPlugin|class FomodPlugin|val name:|plugin\\.name" "$FILE" || true

# Show around parsePlugin if found
LINE=$(rg -n "fun parsePlugin" "$FILE" | head -n1 | cut -d: -f1 || true)
if [ -n "${LINE:-}" ]; then
  START=$((LINE-60)); END=$((LINE+120))
  sed -n "${START},${END}p" "$FILE" | cat -n
fi

echo "=== selectedPluginNames construction / callers of generate() ==="
rg -n "FomodRecipeGenerator\\.generate\\(" -S . || true
rg -n "selectedPluginNames" -S . || true

Repository: utkarshdalal/GameNative

Length of output: 11261


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "FomodParser\\.parse\\(" -S app/src/main/java app/src/test/java || true

Repository: utkarshdalal/GameNative

Length of output: 903


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the NexusModsDialog parse call sites and surrounding flow.
FILE="app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialog.kt"
test -f "$FILE"

echo "=== NexusModsDialog.kt around first FomodParser.parse call (line ~1284) ==="
sed -n '1250,1315p' "$FILE" | cat -n

echo "=== NexusModsDialog.kt around second FomodParser.parse call (line ~1523) ==="
sed -n '1485,1555p' "$FILE" | cat -n

echo "=== Search where selectedPluginNames is built/passed ==="
rg -n "selectedPluginNames" "$FILE" || true
echo "=== Search for FomodRecipeGenerator.generate call in NexusModsDialog ==="
rg -n "FomodRecipeGenerator\\.generate\\(" "$FILE" || true

Repository: utkarshdalal/GameNative

Length of output: 8126


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "FomodRecipeGenerator\\.generate\\(" app/src/main/java app/src/test/java || true
rg -n "selectedPluginNames\\s*=" app/src/main/java app/src/test/java || true
rg -n "selectedPluginNames" app/src/main/java app/src/test/java || true

rg -n "FomodAutoSelector" app/src/main/java app/src/test/java || true

Repository: utkarshdalal/GameNative

Length of output: 1764


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="app/src/main/java/app/gamenative/mods/FomodAutoSelector.kt"
test -f "$FILE"
sed -n '1,220p' "$FILE" | cat -n

Repository: utkarshdalal/GameNative

Length of output: 3848


Fail closed if XML hardening cannot be enabled.
In FomodInstaller.secureFactory(), the setFeature(...) hardening calls are wrapped in runCatching { ... } and failures are ignored, then the parser is created and parse(moduleConfigXml) runs. If the active XML provider doesn’t support one (or more) of these features, XXE protections may be incomplete. Make feature-setting failures abort parsing (throw) so the caller can skip the installer.

Suggested fix
 private fun secureFactory(): DocumentBuilderFactory =
     DocumentBuilderFactory.newInstance().apply {
         isNamespaceAware = false
-        runCatching { setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) }
-        runCatching { setFeature("http://xml.org/sax/features/external-general-entities", false) }
-        runCatching { setFeature("http://xml.org/sax/features/external-parameter-entities", false) }
-        runCatching { setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) }
+        setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
+        setFeature("http://xml.org/sax/features/external-general-entities", false)
+        setFeature("http://xml.org/sax/features/external-parameter-entities", false)
+        setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
+        isXIncludeAware = false
         isExpandEntityReferences = false
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/app/gamenative/mods/FomodInstaller.kt` around lines 300 -
308, The XML hardening feature calls in secureFactory() are currently swallowed
by runCatching, letting parsing continue with incomplete protections; change
those runCatching wrappers so failures abort: call setFeature(...) for each of
the four feature URIs (http://apache.org/xml/features/disallow-doctype-decl,
http://xml.org/sax/features/external-general-entities,
http://xml.org/sax/features/external-parameter-entities,
http://apache.org/xml/features/nonvalidating/load-external-dtd) without
suppressing exceptions (or catch and rethrow a ParserConfigurationException with
a clear message) so secureFactory() throws when a feature cannot be enabled and
callers (e.g. the code that invokes parse(moduleConfigXml)) will skip the
installer if XML hardening cannot be applied; keep the isNamespaceAware and
isExpandEntityReferences settings as-is.

Comment on lines +315 to +330
fun generate(
installId: String,
installer: FomodInstaller,
selectedPluginNames: Set<String>,
targetRoot: String = ModTargetRoot.GAME_DIR.name,
targetRelativePath: String = "Data",
mode: String = ModPlacementMode.OVERWRITE_COPY.name,
): FomodRecipeGenerationResult {
val selectedPluginKeys = installer.steps.flatMapIndexed { stepIndex, step ->
step.groups.flatMapIndexed { groupIndex, group ->
group.plugins.mapIndexedNotNull { pluginIndex, plugin ->
if (plugin.name in selectedPluginNames) pluginKey(stepIndex, groupIndex, pluginIndex) else null
}
}
}.toSet()
return generateForPluginKeys(installId, installer, selectedPluginKeys, targetRoot, targetRelativePath, mode)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Name-based selection is ambiguous across FOMOD groups and steps.

Lines 323-327 turn selected display names into plugin keys by matching every plugin with that name. If two different options share a label, selecting one will select both and generate the wrong recipe set. The key-based API already exists, so this overload should not be used for disambiguation-sensitive selection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/app/gamenative/mods/FomodInstaller.kt` around lines 315 -
330, The generate(...) overload that maps selectedPluginNames to keys can
silently select multiple plugins when names are duplicated; update generate to
detect ambiguous name matches and fail fast: in generate, for each name in
selectedPluginNames use the same lookup logic (inspecting installer.steps ->
step.groups -> group.plugins and pluginKey(stepIndex, groupIndex, pluginIndex))
but group results by name and if any name maps to more than one plugin throw an
IllegalArgumentException (or return an error result) instructing callers to use
the key-based API (generateForPluginKeys), otherwise convert unambiguous names
to keys and call generateForPluginKeys with that set; ensure you reference
generate, selectedPluginNames, installer, pluginKey and generateForPluginKeys in
the change.

Comment on lines +345 to +355
internal fun archiveChildren(entries: List<ModArchiveEntry>, currentPath: String): List<ArchiveBrowserItem> {
val prefix = normalizeArchivePath(currentPath).let { if (it.isBlank()) "" else "$it/" }
val directories = linkedMapOf<String, ArchiveBrowserItem>()
val files = mutableListOf<ArchiveBrowserItem>()
entries.forEach { entry ->
val path = normalizeArchivePath(entry.path)
if (path.isBlank() || (prefix.isNotBlank() && !path.startsWith(prefix, ignoreCase = true))) return@forEach
val remaining = path.removePrefix(prefix)
if (remaining.isBlank()) return@forEach
val name = remaining.substringBefore('/')
val childPath = if (prefix.isBlank()) name else prefix + name

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Prefix stripping is inconsistent with the case-insensitive match.

Line 351 accepts entries with startsWith(prefix, ignoreCase = true), but Line 352 removes the prefix with case-sensitive removePrefix(prefix). If currentPath is data and the archive entry is Data/foo.txt, remaining stays Data/foo.txt, so the browser re-lists the current folder instead of its children. Strip by length after the ignore-case check, or normalize both sides before removing.

🧭 Suggested fix
-        val remaining = path.removePrefix(prefix)
+        val remaining = if (prefix.isBlank()) {
+            path
+        } else {
+            path.substring(prefix.length)
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialogHelpers.kt`
around lines 345 - 355, In archiveChildren, the case-insensitive startsWith
check can mismatch the case-sensitive removePrefix call causing incorrect
remaining paths; update the logic in archiveChildren (within the loop handling
path/prefix) to strip the prefix in a case-insensitive-safe way — either compute
remaining via path.substring(prefix.length) after confirming
path.startsWith(prefix, ignoreCase = true), or normalize both path and prefix
(e.g., lowercased) and call removePrefix on the normalized value and use the
corresponding substring from the original path; ensure this change is applied
where remaining is computed so children are correctly resolved.

Comment on lines +279 to +286
val result = FomodRecipeGenerator.generateForPluginKeys(
installId = installId,
installer = installer,
selectedPluginKeys = selectedKeys,
targetRoot = baseDraft.targetRoot,
targetRelativePath = "Data",
mode = ModPlacementMode.OVERWRITE_COPY.name,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Search for dynamic target-path resolution that should be used here

# Check if baseDraft contains targetRoot or other path context
rg -nP -A3 'RecipeDraft|baseDraft' --type=kt --iglob '*fomod*' --iglob '*placement*'

# Check if FomodRecipeGenerator uses the targetRelativePath parameter
ast-grep --pattern 'fun generateForPluginKeys($$$, targetRelativePath: $_, $$$)'

# Look for game-type or preset-based path resolution
rg -nP 'ModPlacementPreset|targetRoot|Data\b' --type=kt --iglob '*placement*' --iglob '*preset*'

Repository: utkarshdalal/GameNative

Length of output: 121


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Locate the relevant call site and all uses of targetRelativePath / "Data" near FOMOD placement
rg -n --hidden --no-ignore -S "targetRelativePath\s*=\s*\"Data\"|targetRelativePath" app/src/main/java/app/gamenative -g'*.kt'
rg -n --hidden --no-ignore -S "\"Data\"\b" app/src/main/java/app/gamenative -g'*.kt' | head -n 200

# 2) Find FomodRecipeGenerator implementation
rg -n --hidden --no-ignore -S "FomodRecipeGenerator" app/src/main/java/app/gamenative -g'*.kt'

# 3) Inspect the call + nearby context in NexusModsFomodSections.kt
sed -n '240,330p' app/src/main/java/app/gamenative/ui/component/dialog/NexusModsFomodSections.kt

# 4) Print the generator function that accepts targetRelativePath (or closest match)
rg -n --hidden --no-ignore -S "generateForPluginKeys\(" app/src/main/java/app/gamenative -g'*.kt'
gen_file="$(rg -l --hidden --no-ignore -S "object\s+FomodRecipeGenerator|class\s+FomodRecipeGenerator|FomodRecipeGenerator" app/src/main/java/app/gamenative -g'*.kt' | head -n 1 || true)"
echo "Generator file candidate: ${gen_file:-<none>}"
if [[ -n "${gen_file}" ]]; then
  sed -n '1,240p' "$gen_file"
  rg -n --hidden --no-ignore -S "generateForPluginKeys" "$gen_file" -g'*.kt'
  # Print around the implementation if present
  rg -n --hidden --no-ignore -S "generateForPluginKeys" "$gen_file" -g'*.kt' | head -n 5 | awk -F: '{print $1}' | head -n 1 >/tmp/gen_line.txt || true
  gen_line="$(cat /tmp/gen_line.txt 2>/dev/null || true)"
  if [[ -n "${gen_line}" ]]; then
    start=$((gen_line-80)); end=$((gen_line+120))
    sed -n "${start},${end}p" "$gen_file"
  fi
fi

# 5) Search for any dynamic target relative path/preset mapping (e.g., placement presets) that could override "Data"
rg -n --hidden --no-ignore -S "ModPlacementPreset|targetRelative|RelativePath|Data\b" app/src/main/java/app/gamenative -g'*.kt'

Repository: utkarshdalal/GameNative

Length of output: 8934


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="app/src/main/java/app/gamenative/ui/component/dialog/NexusModsFomodSections.kt"
echo "---- $FILE: context around call site ----"
sed -n '240,320p' "$FILE"

echo
echo "---- $FILE: find where baseDraft is created/used ----"
rg -n "baseDraft\s*=|baseDraft\.|targetRelativePath\s*=" "$FILE" -g'*.kt' | head -n 50

echo
echo "---- app/src/main/java/app/gamenative/mods/FomodInstaller.kt: generateForPluginKeys ----"
F="app/src/main/java/app/gamenative/mods/FomodInstaller.kt"
sed -n '280,380p' "$F"

echo
echo "---- app/src/main/java/app/gamenative/mods/FomodInstaller.kt: joinPath/recipe generation bits ----"
sed -n '380,520p' "$F"

echo
echo "---- app/src/main/java/app/gamenative/mods/BethesdaPlacementRecipeExpander.kt ----"
B="app/src/main/java/app/gamenative/mods/BethesdaPlacementRecipeExpander.kt"
sed -n '1,120p' "$B"

echo
echo "---- app/src/main/java/app/gamenative/mods/ModPlacementPreset.kt ----"
P="app/src/main/java/app/gamenative/mods/ModPlacementPreset.kt"
sed -n '1,220p' "$P"

Repository: utkarshdalal/GameNative

Length of output: 27318


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="app/src/main/java/app/gamenative/ui/component/dialog/NexusModsFomodSections.kt"

echo "---- rg baseDraft / targetRelativePath in $FILE ----"
rg -n --hidden --no-ignore -S "baseDraft|targetRelativePath" "$FILE" -g'*.kt'

echo
echo "---- context around baseDraft definition ----"
rg -n --hidden --no-ignore -S "val\s+baseDraft|var\s+baseDraft|baseDraft\s*=" "$FILE" -g'*.kt' || true

# Print a couple likely ranges around where baseDraft is used (around the call site and earlier wizard setup)
echo
echo "---- sed around wizard/setup where baseDraft likely set (200-340) ----"
sed -n '180,340p' "$FILE"

echo
echo "---- sed around any ModPlacement draft init (340-520) ----"
sed -n '340,520p' "$FILE"

Repository: utkarshdalal/GameNative

Length of output: 16371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Find where RecipeDraft is defined and what fields it has
rg -n --hidden --no-ignore -S "data class RecipeDraft|class RecipeDraft|RecipeDraft\(" app/src/main/java/app/gamenative -g'*.kt'

echo
echo "---- RecipeDraft usages in FOMOD + placement dialogs ----"
rg -n --hidden --no-ignore -S "RecipeDraft" app/src/main/java/app/gamenative -g'*.kt'

echo
echo "---- inspect NexusModsFomodSections for any use of baseDraft.targetRelativePath ----"
FILE="app/src/main/java/app/gamenative/ui/component/dialog/NexusModsFomodSections.kt"
rg -n --hidden --no-ignore -S "baseDraft\.targetRelativePath|targetRelativePath\s*=" "$FILE" -g'*.kt'

echo
echo "---- show where baseDraft is passed into NexusModsFomodSections (callers) ----"
rg -n --hidden --no-ignore -S "NexusModsFomodSections|FomodSections\(" app/src/main/java/app/gamenative/ui -g'*.kt'

echo
echo "---- inspect NexusModsPlacementSections around where draft/targetRelativePath is selected ----"
PS="app/src/main/java/app/gamenative/ui/component/dialog/NexusModsPlacementSections.kt"
sed -n '480,640p' "$PS"
sed -n '780,880p' "$PS"

echo
echo "---- inspect NexusModsDialogHelpers normalized target relative path logic ----"
DH="app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialogHelpers.kt"
sed -n '120,220p' "$DH"
sed -n '220,280p' "$DH"

Repository: utkarshdalal/GameNative

Length of output: 5932


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect RecipeDraft definition
sed -n '90,160p' app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialog.kt

echo
echo "---- NexusModsPlacementSections: around baseDraft selection ----"
sed -n '240,330p' app/src/main/java/app/gamenative/ui/component/dialog/NexusModsPlacementSections.kt

echo
echo "---- NexusModsDialogHelpers: RecipeDraft helpers (normalizedTargetPath, automaticDraftFor) ----"
sed -n '100,220p' app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialogHelpers.kt
sed -n '120,200p' app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialogHelpers.kt

echo
echo "---- NexusModsDialog: where initial RecipeDrafts are created (around the block that sets targetRelativePath = game.dataDirName) ----"
sed -n '660,780p' app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialog.kt
sed -n '520,620p' app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialog.kt

echo
echo "---- any FOMOD-specific flow in NexusModsDialog.kt (search for Fomod) ----"
rg -n "Fomod|FOMOD|FomodRecipeGenerator|NexusModsFomodSections" app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialog.kt -n

Repository: utkarshdalal/GameNative

Length of output: 27506


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect ModTargetResolver.normalizeRelativePath
FILE="app/src/main/java/app/gamenative/mods/ModTargetResolver.kt"
sed -n '1,140p' "$FILE"

# Inspect ModTargetResolver.normalizeRelativePath and any mapping for "Data"
rg -n "normalizeRelativePath|Data" "$FILE"

# Find where BethesdaPlacementRecipeExpander is applied to FOMOD-generated recipes
rg -n "BethesdaPlacementRecipeExpander" app/src/main/java/app/gamenative -g'*.kt'

# Inspect ModMaterializer / recipe expansion pipeline around expansion
rg -n "expand\(" app/src/main/java/app/gamenative/mods -g'*.kt' | head -n 50

# Search for handling of FOMOD recipes into final placements
rg -n "onFomodRecipes|Apply FOMOD|FOMOD choices added|generatedDrafts|pendingFomod" app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialog.kt -g'*.kt'

Repository: utkarshdalal/GameNative

Length of output: 4966


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="app/src/main/java/app/gamenative/ui/component/dialog/NexusModsDialog.kt"

echo "---- BethesdaPlacementRecipeExpander.expand call around 1000-1060 ----"
sed -n '980,1060p' "$FILE"

echo
echo "---- BethesdaPlacementRecipeExpander.expand call around 1530-1570 ----"
sed -n '1500,1585p' "$FILE"

echo
echo "---- BethesdaPlacementRecipeExpander.expand call around 1810-1860 ----"
sed -n '1790,1860p' "$FILE"

echo
echo "---- onFomodRecipes handling around 2160-2225 ----"
sed -n '2140,2235p' "$FILE"

Repository: utkarshdalal/GameNative

Length of output: 19548


Fix non-Bethesda FOMOD placement: stop hardcoding targetRelativePath = "Data"
NexusModsFomodSections.kt always calls FomodRecipeGenerator.generateForPluginKeys(..., targetRoot = baseDraft.targetRoot, targetRelativePath = "Data", ...), ignoring baseDraft.targetRelativePath selected via placement presets (e.g., BepInEx, Content/Paks, REDmod). This will generate incorrect target paths for non-Bethesda games; pass baseDraft.targetRelativePath into generateForPluginKeys (with a sensible fallback).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/app/gamenative/ui/component/dialog/NexusModsFomodSections.kt`
around lines 279 - 286, The call to FomodRecipeGenerator.generateForPluginKeys
in NexusModsFomodSections.kt hardcodes targetRelativePath = "Data", causing
incorrect placement for non-Bethesda presets; change that argument to use
baseDraft.targetRelativePath (or a sensible fallback like "Data" when
baseDraft.targetRelativePath is null/blank) so generateForPluginKeys receives
the user-selected placement; update the invocation in the block that builds
result (the call with installId, installer, selectedPluginKeys, targetRoot =
baseDraft.targetRoot, targetRelativePath = "Data", mode =
ModPlacementMode.OVERWRITE_COPY.name) to pass baseDraft.targetRelativePath ?:
"Data" (or equivalent) instead of the literal "Data".

Comment on lines +129 to +131
TextButton(
onClick = { onConfirm(name) },
enabled = name.trim().isNotBlank(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Trim profile names before saving.

At Line 130, onConfirm receives the raw value even though validation at Line 131 uses trim(). This can persist names with accidental leading/trailing spaces.

💡 Proposed fix
-                onClick = { onConfirm(name) },
+                onClick = { onConfirm(name.trim()) },
📝 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.

Suggested change
TextButton(
onClick = { onConfirm(name) },
enabled = name.trim().isNotBlank(),
TextButton(
onClick = { onConfirm(name.trim()) },
enabled = name.trim().isNotBlank(),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/app/gamenative/ui/component/dialog/NexusModsInstalledSections.kt`
around lines 129 - 131, The TextButton currently validates with
name.trim().isNotBlank() but calls onConfirm(name) with the untrimmed value;
update the onClick handler in NexusModsInstalledSections (the TextButton that
uses onConfirm and name) to pass the trimmed string (e.g., call onConfirm with
name.trim()) so stored profile names don't keep accidental leading/trailing
whitespace.

Comment on lines 351 to +358
val result = AmazonService.deleteGame(context, productId)
DownloadService.invalidateCache()
if (result.isSuccess) {
NexusModManager.deleteInstallsForApp(
context = context,
appId = libraryItem.appId,
gameRootDir = getInstallPath(context, libraryItem)?.let(::File),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Capture gameRootDir before deleting the game install.

gameRootDir is resolved at Line 357 after deleteGame runs. If uninstall clears install metadata, this becomes null and cleanup may skip filesystem artifacts for installed mods.

💡 Proposed fix
 private fun performUninstall(context: Context, libraryItem: LibraryItem) {
     val productId = productIdOf(libraryItem)
+    val gameRootDir = getInstallPath(context, libraryItem)?.let(::File)
     Timber.tag(TAG).i("performUninstall: deleting game $productId")
     CoroutineScope(Dispatchers.IO).launch {
         val result = AmazonService.deleteGame(context, productId)
         DownloadService.invalidateCache()
         if (result.isSuccess) {
             NexusModManager.deleteInstallsForApp(
                 context = context,
                 appId = libraryItem.appId,
-                gameRootDir = getInstallPath(context, libraryItem)?.let(::File),
+                gameRootDir = gameRootDir,
             )
             Timber.tag(TAG).i("Uninstall succeeded for $productId")
         } else {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/app/gamenative/ui/screen/library/appscreen/AmazonAppScreen.kt`
around lines 351 - 358, The code calls AmazonService.deleteGame(...) before
computing gameRootDir, so uninstall can clear install metadata and make
getInstallPath(...) return null; capture the install path first by calling
getInstallPath(context, libraryItem) and constructing the File (or null) into a
local variable (e.g. gameRootDirFile) before calling AmazonService.deleteGame,
then call DownloadService.invalidateCache() and pass that captured
gameRootDirFile into NexusModManager.deleteInstallsForApp(context, appId =
libraryItem.appId, gameRootDir = gameRootDirFile) so filesystem mod cleanup runs
against the correct path.

Comment on lines 489 to +494
if (result.isSuccess) {
NexusModManager.deleteInstallsForApp(
context = context,
appId = libraryItem.appId,
gameRootDir = getInstallPath(context, libraryItem)?.let(::File),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't let post-uninstall Nexus cleanup flip a successful uninstall into a failure.

Once EpicService.deleteGame() returns success, any exception from NexusModManager.deleteInstallsForApp() falls into the outer catch and reports the whole uninstall as failed even though the game was already removed. Handle this cleanup separately so uninstall success is preserved and only the secondary cleanup is logged/retried.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/app/gamenative/ui/screen/library/appscreen/EpicAppScreen.kt`
around lines 489 - 494, After EpicService.deleteGame() returns success (the
branch where result.isSuccess is true in EpicAppScreen.kt), move the call to
NexusModManager.deleteInstallsForApp(...) out of the main success path so any
exception it throws cannot propagate to the outer uninstall catch; wrap the
NexusModManager.deleteInstallsForApp(...) call in its own try/catch and on
failure log the error (including context like libraryItem.appId and the path
from getInstallPath(...)) or schedule a retry, but do NOT rethrow so the
uninstall remains reported as successful.

@Nightwalker743 Nightwalker743 marked this pull request as draft June 8, 2026 01:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant