From 769d3a671e482a640318785024c6f6e8e6e083e3 Mon Sep 17 00:00:00 2001 From: MaxJubayerYT Date: Fri, 10 Jul 2026 12:12:29 +0600 Subject: [PATCH 1/3] fix(mods): drop already-installed deps from the install dialog regardless of source The earlier fix (b7ade356c) only recognized a dependency as already installed if its jar hash matched a Modrinth-hosted file. A dependency installed via CurseForge, or placed manually, has a different hash and slipped straight through, still showing up as Required/Optional even though it was already in the mods folder. Added a second, network-free check: read the mod id directly out of each installed jar's own metadata (fabric.mod.json/quilt.mod.json/ mods.toml/mcmod.info) and cross-reference it against each dependency's Modrinth slug. Matches are dropped from the dialog entirely rather than left unchecked, and if nothing's left to prompt for, the dialog is skipped and the mod downloads directly. Also added a User-Agent header and timeouts to ApiHandler's raw GET/POST calls, since a silently failing or hanging Modrinth request would make the original hash-based filter fall back to nothing installed too. --- README.md | 13 +- .../fragments/ModsSearchFragment.java | 161 +++++++++++++++++- .../modloaders/modpacks/api/ApiHandler.java | 14 +- 3 files changed, 173 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a430b05014..e7f8b0afc3 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,8 @@ Android CI Crowdin Discord - GitHub CurseForge Modrinth User - Modrinth Org -

Copper is a fork of [Amethyst](https://github.com/AngelAuraMC/Amethyst-Android) that allows you to play Minecraft: Java Edition on your Android devices. @@ -89,8 +86,13 @@ If you need more control over the build process, follow these steps: * [x] Bug Fixes * [x] Fix GL4ES and KW in older versions * [x] Add Modpack export -* [x] Add mclo.gs -* [ ] Add More Renders +* [x] Add mclo.gs +* [ ] Add Res pack downloader +* [ ] Add Shader pack downloader +* [ ] Add in launcher file manager +* [ ] Add Res pack manager +* [ ] Add Shader pack manager +* [ ] Add More Renerers ## Known Issues @@ -113,6 +115,7 @@ Copper is licensed under [GNU LGPLv3](https://github.com/CopperLauncher/Copper-A * [MojoLauncher](https://github.com/MojoLauncher/MojoLauncher/): [LGPL-3.0 license](https://github.com/AngelAuraMC/Amethyst-Android/blob/v3_openjdk/LICENSE) * Android Support Libraries: [Apache License 2.0](https://android.googlesource.com/platform/prebuilts/maven_repo/android/+/master/NOTICE.txt). * [GL4ES](https://github.com/AngelAuraMC/gl4es): [MIT License](https://github.com/ptitSeb/gl4es/blob/master/LICENSE). +* [Krypton Wrapper](https://github.com/BZLZHH/NG-GL4ES): [MIT License](https://github.com/BZLZHH/NG-GL4ES/blob/main/LICENSE). * [MobileGlues](https://github.com/MobileGL-Dev/MobileGlues): [LGPL-2.1 License](https://github.com/MobileGL-Dev/MobileGlues/blob/dev-es/LICENSE). * [ANGLE](https://chromium.googlesource.com/angle/angle): [All Rights Reserved](app_pojavlauncher/src/main/assets/licenses/ANGLE_LICENSE). * [OpenJDK](https://github.com/AngelAuraMC/openjdk-multiarch-jdk8u): [GNU GPLv2 License](https://openjdk.java.net/legal/gplv2+ce.html). diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java index b2547689d0..ac606e2b44 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java @@ -45,9 +45,14 @@ import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; /** * Searches and installs individual mods into the current instance's mods folder. @@ -477,11 +482,22 @@ public void handleInstallation(Context context, ModDetail modDetail, int selecte } /** Fetches display names for the (already filtered to not-yet-installed) dependency - * list, then shows the install dialog once every name has resolved. */ + * list, then shows the install dialog once every name has resolved. + * + * The filter that ran before this only drops dependencies whose exact jar hash + * matches something already in the mods folder on Modrinth's own database — so a + * dependency that's installed but came from CurseForge, or was placed manually, + * has a completely different file hash and slips right through, appearing in the + * dialog as "required"/"optional" even though it's already there. To catch that, + * we additionally read the mod id directly out of every installed jar's own + * metadata (fabric.mod.json / quilt.mod.json / mods.toml) — this is source-agnostic + * and needs no network — and cross-check it against each dependency's Modrinth slug. */ private void promptForDependencies(Context context, String url, String fileName, String[] depIds, String[] depTypes, String oldFilePath) { - // Fetch project names for all deps, then show dialog + final java.util.Set installedModIds = getInstalledModIds(); + String[] labels = new String[depIds.length]; + String[] slugs = new String[depIds.length]; final boolean[] checkedDefaults = new boolean[depIds.length]; AtomicInteger remaining = new AtomicInteger(depIds.length); @@ -499,17 +515,52 @@ private void promptForDependencies(Context context, String url, String fileName, final String projectId = depIds[idx]; PojavApplication.sExecutorService.execute(() -> { - // Fetch project name from Modrinth - String name = fetchProjectName(projectId); + // Fetch project title + slug from Modrinth + String[] details = fetchProjectDetails(projectId); + String name = details != null ? details[0] : null; + slugs[idx] = details != null ? details[1] : null; labels[idx] = prefix + (name != null ? name : projectId); if (remaining.decrementAndGet() == 0) { - mMainHandler.post(() -> showDepsDialog(context, url, fileName, - depIds, depTypes, labels, checkedDefaults, oldFilePath)); + mMainHandler.post(() -> finishDependencyPrompt(context, url, fileName, + depIds, depTypes, labels, slugs, checkedDefaults, installedModIds, oldFilePath)); } }); } } + /** Drops any dependency whose slug matches a mod id already found in the mods + * folder (see promptForDependencies), then shows the dialog with what's left — + * or skips it entirely and downloads directly if nothing's left to ask about. */ + private void finishDependencyPrompt(Context context, String url, String fileName, + String[] depIds, String[] depTypes, String[] labels, + String[] slugs, boolean[] checkedDefaults, + java.util.Set installedModIds, String oldFilePath) { + List keptIds = new ArrayList<>(); + List keptTypes = new ArrayList<>(); + List keptLabels = new ArrayList<>(); + List keptChecked = new ArrayList<>(); + for (int i = 0; i < depIds.length; i++) { + String slug = slugs[i]; + if (slug != null && installedModIds.contains(slug.toLowerCase(java.util.Locale.ROOT))) continue; + keptIds.add(depIds[i]); + keptTypes.add((depTypes != null && i < depTypes.length) ? depTypes[i] : "required"); + keptLabels.add(labels[i]); + keptChecked.add(checkedDefaults[i]); + } + + if (keptIds.isEmpty()) { + downloadMod(context, url, fileName, new String[0], new String[0], oldFilePath); + return; + } + + boolean[] checkedArr = new boolean[keptChecked.size()]; + for (int i = 0; i < checkedArr.length; i++) checkedArr[i] = keptChecked.get(i); + + showDepsDialog(context, url, fileName, + keptIds.toArray(new String[0]), keptTypes.toArray(new String[0]), + keptLabels.toArray(new String[0]), checkedArr, oldFilePath); + } + private void showDepsDialog(Context context, String url, String fileName, String[] depIds, String[] depTypes, String[] labels, boolean[] checkedDefaults, String oldFilePath) { @@ -651,17 +702,111 @@ private java.util.Set getInstalledModrinthProjectIds() { return projectIds; } - private String fetchProjectName(String projectId) { + /** Fetches a Modrinth project's title and slug in one call. Returns {title, slug} + * (either element may be null), or null entirely if the lookup failed. */ + private String[] fetchProjectDetails(String projectId) { try { net.kdt.pojavlaunch.modloaders.modpacks.api.ApiHandler handler = new net.kdt.pojavlaunch.modloaders.modpacks.api.ApiHandler("https://api.modrinth.com/v2"); com.google.gson.JsonObject obj = handler.get("project/" + projectId, com.google.gson.JsonObject.class); - if (obj != null && obj.has("title")) return obj.get("title").getAsString(); + if (obj == null) return null; + String title = (obj.has("title") && !obj.get("title").isJsonNull()) + ? obj.get("title").getAsString() : null; + String slug = (obj.has("slug") && !obj.get("slug").isJsonNull()) + ? obj.get("slug").getAsString() : null; + return new String[]{title, slug}; } catch (Exception ignored) {} return null; } + /** + * Reads the mod id (not the display name) embedded in every jar currently in the + * mods folder — straight from fabric.mod.json / quilt.mod.json / mods.toml / + * mcmod.info. Unlike {@link #getInstalledModrinthProjectIds()} this needs no + * network call and doesn't care where the jar originally came from, so it also + * catches dependencies that were installed via CurseForge or dropped in manually + * — cases the Modrinth-hash lookup can never see since those jars simply don't + * have a Modrinth file hash to match against. + */ + private java.util.Set getInstalledModIds() { + java.util.Set ids = new java.util.HashSet<>(); + File modsDir = getModsDir(); + File[] files = modsDir.listFiles(f -> f.isFile() && + (f.getName().endsWith(".jar") || f.getName().endsWith(".jar.disabled"))); + if (files == null) return ids; + for (File f : files) { + String id = extractModId(f); + if (id != null && !id.isEmpty()) ids.add(id.toLowerCase(java.util.Locale.ROOT)); + } + return ids; + } + + private static String extractModId(File jarFile) { + try (ZipFile zip = new ZipFile(jarFile)) { + String content = readZipEntry(zip, "fabric.mod.json"); + if (content != null) { + try { + com.google.gson.JsonObject obj = com.google.gson.JsonParser.parseString(content).getAsJsonObject(); + if (obj.has("id") && !obj.get("id").isJsonNull()) { + String id = obj.get("id").getAsString().trim(); + if (!id.isEmpty()) return id; + } + } catch (Exception ignored) {} + } + content = readZipEntry(zip, "quilt.mod.json"); + if (content != null) { + try { + com.google.gson.JsonObject root = com.google.gson.JsonParser.parseString(content).getAsJsonObject(); + com.google.gson.JsonObject ql = root.has("quilt_loader") ? root.getAsJsonObject("quilt_loader") : null; + if (ql != null && ql.has("id") && !ql.get("id").isJsonNull()) { + String id = ql.get("id").getAsString().trim(); + if (!id.isEmpty()) return id; + } + } catch (Exception ignored) {} + } + for (String toml : new String[]{"META-INF/neoforge.mods.toml", "META-INF/mods.toml"}) { + content = readZipEntry(zip, toml); + if (content != null) { + String modId = tomlStringField(content, "modId"); + if (modId != null && !modId.isEmpty()) return modId.trim(); + } + } + content = readZipEntry(zip, "mcmod.info"); + if (content != null) { + try { + com.google.gson.JsonArray arr = com.google.gson.JsonParser.parseString(content).getAsJsonArray(); + if (arr.size() > 0 && arr.get(0).isJsonObject()) { + com.google.gson.JsonObject mod = arr.get(0).getAsJsonObject(); + if (mod.has("modid") && !mod.get("modid").isJsonNull()) { + String id = mod.get("modid").getAsString().trim(); + if (!id.isEmpty()) return id; + } + } + } catch (Exception ignored) {} + } + } catch (Exception e) { + Log.w(TAG, "Failed to read mod id from JAR: " + jarFile.getName() + " - " + e.getMessage()); + } + return null; + } + + private static String readZipEntry(ZipFile zip, String entryPath) { + ZipEntry entry = zip.getEntry(entryPath); + if (entry == null) return null; + try (InputStream is = zip.getInputStream(entry)) { + return Tools.read(is); + } catch (Exception e) { + return null; + } + } + + /** Minimal `field = "value"` line lookup — enough for the modId line in mods.toml. */ + private static String tomlStringField(String content, String field) { + Matcher m = Pattern.compile(field + "\\s*=\\s*\"([^\"]*)\"").matcher(content); + return m.find() ? m.group(1) : null; + } + private static File getModsDir() { try { String key = LauncherPreferences.DEFAULT_PREF diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ApiHandler.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ApiHandler.java index 4c03ecf2b8..dc04b93c81 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ApiHandler.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ApiHandler.java @@ -52,6 +52,10 @@ public T post(String endpoint, HashMap query, T body, Class< } //Make a get request and return the response as a raw string; + private static final String USER_AGENT = "CopperLauncher/Copper-Android (https://github.com/CopperLauncher/Copper-Android)"; + private static final int CONNECT_TIMEOUT_MS = 10_000; + private static final int READ_TIMEOUT_MS = 15_000; + public static String getRaw(String url) { return getRaw(null, url); } @@ -60,6 +64,9 @@ public static String getRaw(Map headers, String url) { Log.d("ApiHandler", url); try { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setRequestProperty("User-Agent", USER_AGENT); addHeaders(conn, headers); InputStream inputStream = conn.getInputStream(); String data = Tools.read(inputStream); @@ -80,9 +87,12 @@ public static String postRaw(String url, String body) { public static String postRaw(Map headers, String url, String body) { try { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Type", "application/json"); conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("User-Agent", USER_AGENT); addHeaders(conn, headers); conn.setDoOutput(true); @@ -98,7 +108,7 @@ public static String postRaw(Map headers, String url, String bod conn.disconnect(); return data; } catch (IOException e) { - e.printStackTrace(); + Log.w("ApiHandler", "POST " + url + " failed: " + e.getMessage()); } return null; } @@ -161,4 +171,4 @@ private static String urlEncodeUTF8(String input) { throw new RuntimeException("UTF-8 is required"); } } -} +} \ No newline at end of file From c7f976d0fc33789de9ed92f5fba346fa34e42a9b Mon Sep 17 00:00:00 2001 From: MaxJubayerYT Date: Sat, 11 Jul 2026 09:23:28 +0600 Subject: [PATCH 2/3] feat(mods): unify mods/resourcepacks/shaderpacks into Browse Content & Manage Content Replace Browse Mods / Manage Mods with a content-type picker that lets you browse or manage mods, resource packs, or shader packs through the same search/install/update pipeline. Introduces a ContentType enum carrying folder name, file extension, and Modrinth/CurseForge search parameters per kind. Relocates the per-instance version/loader filter from a button inside Manage Mods to the Manage Content picker, since it's shared across all three content types rather than mod-specific. The loader half of that filter is now ignored for resource packs and shader packs everywhere (search, dependency install, update checks), and auto-fills from the instance's own version/loader the first time it's opened. InstalledModAdapter generalized from hardcoded .jar scanning to any ContentType's extension; icon/name extraction already degraded gracefully for non-mod content so needed no changes there. --- .../fragments/ContentFilterDialog.java | 125 +++++++++++++ .../fragments/InstanceVersionResolver.java | 92 ++++++++++ .../fragments/MainMenuFragment.java | 95 +++++++++- .../fragments/ManageModsFragment.java | 171 ++++++------------ .../fragments/ModsSearchFragment.java | 89 +++++++-- .../modloaders/InstalledModAdapter.java | 26 ++- .../modloaders/modpacks/api/ApiHandler.java | 2 +- .../modpacks/api/CurseforgeApi.java | 3 +- .../modloaders/modpacks/api/ModrinthApi.java | 3 +- .../modpacks/models/ContentType.java | 46 +++++ .../modpacks/models/SearchFilters.java | 7 + .../main/res/layout/dialog_content_picker.xml | 59 ++++++ .../main/res/layout/fragment_manage_mods.xml | 9 - .../src/main/res/values/strings.xml | 17 +- 14 files changed, 587 insertions(+), 157 deletions(-) create mode 100644 app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ContentFilterDialog.java create mode 100644 app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstanceVersionResolver.java create mode 100644 app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/ContentType.java create mode 100644 app_pojavlauncher/src/main/res/layout/dialog_content_picker.xml diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ContentFilterDialog.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ContentFilterDialog.java new file mode 100644 index 0000000000..76e4c7184e --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ContentFilterDialog.java @@ -0,0 +1,125 @@ +package net.kdt.pojavlaunch.fragments; + +import android.content.Context; +import android.content.SharedPreferences; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.Spinner; +import android.widget.TextView; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import net.kdt.pojavlaunch.R; +import net.kdt.pojavlaunch.profiles.VersionSelectorDialog; + +/** + * The per-instance Minecraft-version/loader filter used to drive update + * checking in "Manage Content" (mods, resource packs, and shader packs all + * share this one filter). Originally a button inside Manage Mods; now lives + * on the "Manage Content" picker sheet since it applies across all three + * content types rather than just mods. + */ +public final class ContentFilterDialog { + + private static final String PREF_FILE = "mod_filters"; + private static final String KEY_MC_VERSION = "mc_version_"; + private static final String KEY_LOADER = "loader_"; + + private ContentFilterDialog() {} + + public interface OnApplied { + void onApplied(String version, String loader); + } + + /** Whether a version or loader filter is currently set for this profile — + * used to tint the filter icon so it's obvious a filter is active. */ + public static boolean isActive(Context context, String profileKey) { + SharedPreferences prefs = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE); + return !prefs.getString(KEY_MC_VERSION + profileKey, "").isEmpty() + || !prefs.getString(KEY_LOADER + profileKey, "").isEmpty(); + } + + public static void show(Context context, String profileKey, @Nullable OnApplied onApplied) { + SharedPreferences prefs = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE); + String storedVersion = prefs.getString(KEY_MC_VERSION + profileKey, ""); + String storedLoader = prefs.getString(KEY_LOADER + profileKey, ""); + + // Nothing set yet for this instance — default to whatever version/loader + // the instance itself is actually running, rather than showing blank. + final String savedVersion; + final String savedLoader; + if (storedVersion.isEmpty() && storedLoader.isEmpty()) { + InstanceVersionResolver.Info info = InstanceVersionResolver.resolve(profileKey); + savedVersion = info.mcVersion != null ? info.mcVersion : ""; + savedLoader = info.loader; + } else { + savedVersion = storedVersion; + savedLoader = storedLoader; + } + + AlertDialog dialog = new AlertDialog.Builder(context) + .setView(R.layout.dialog_mod_filters) + .create(); + + dialog.setOnShowListener(di -> { + TextView versionText = dialog.findViewById(R.id.search_mod_selected_mc_version_textview); + Button selectVersion = dialog.findViewById(R.id.search_mod_mc_version_button); + Button applyButton = dialog.findViewById(R.id.search_mod_apply_filters); + Spinner loaderSpinner = dialog.findViewById(R.id.search_mod_loader_spinner); + + // This dialog is shared with the mod/modpack search screens, which also + // show a Modrinth/CurseForge/Both engine picker — not applicable here, + // since this just filters already-installed content by version/loader + // for update checking, it doesn't pick a search engine. + View engineLabel = dialog.findViewById(R.id.search_mod_engine_textview); + View engineSpinner = dialog.findViewById(R.id.search_mod_engine_spinner); + if (engineLabel != null) engineLabel.setVisibility(View.GONE); + if (engineSpinner != null) engineSpinner.setVisibility(View.GONE); + + if (versionText == null || selectVersion == null || applyButton == null) return; + + versionText.setText(savedVersion); + + final String[] loaderValues = { "", "fabric", "forge", "quilt", "neoforge" }; + if (loaderSpinner != null) { + String[] loaderLabels = { + context.getString(R.string.search_mod_any_loader), + "Fabric", "Forge", "Quilt", "NeoForge" + }; + ArrayAdapter adapter = new ArrayAdapter<>( + context, android.R.layout.simple_spinner_item, loaderLabels); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + loaderSpinner.setAdapter(adapter); + for (int i = 0; i < loaderValues.length; i++) { + if (loaderValues[i].equals(savedLoader)) { + loaderSpinner.setSelection(i); + break; + } + } + } + + selectVersion.setOnClickListener(v -> + VersionSelectorDialog.open(v.getContext(), true, + (id, snapshot) -> versionText.setText(id))); + + applyButton.setOnClickListener(v -> { + String newVersion = versionText.getText().toString().trim(); + String newLoader = (loaderSpinner != null) + ? loaderValues[loaderSpinner.getSelectedItemPosition()] + : ""; + + prefs.edit() + .putString(KEY_MC_VERSION + profileKey, newVersion) + .putString(KEY_LOADER + profileKey, newLoader) + .apply(); + + if (onApplied != null) onApplied.onApplied(newVersion, newLoader); + di.dismiss(); + }); + }); + + dialog.show(); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstanceVersionResolver.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstanceVersionResolver.java new file mode 100644 index 0000000000..845f60fe88 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstanceVersionResolver.java @@ -0,0 +1,92 @@ +package net.kdt.pojavlaunch.fragments; + +import net.kdt.pojavlaunch.JMinecraftVersionList; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles; +import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile; + +/** + * Infers a profile's plain Minecraft version (e.g. "1.20.1") and mod loader + * (fabric/forge/quilt/neoforge/"") from its {@code lastVersionId}. Used to + * auto-fill the Manage Content filter with sensible defaults the first time + * it's opened for an instance, instead of leaving it blank. + */ +final class InstanceVersionResolver { + + private InstanceVersionResolver() {} + + static final class Info { + /** May be null if it couldn't be determined at all. */ + final String mcVersion; + /** "", "fabric", "forge", "quilt", or "neoforge". */ + final String loader; + + Info(String mcVersion, String loader) { + this.mcVersion = mcVersion; + this.loader = loader; + } + } + + static Info resolve(String profileKey) { + try { + LauncherProfiles.load(); + MinecraftProfile profile = LauncherProfiles.mainProfileJson.profiles.get(profileKey); + if (profile == null || profile.lastVersionId == null || profile.lastVersionId.isEmpty()) { + return new Info(null, ""); + } + String versionId = profile.lastVersionId; + String loader = loaderFromVersionId(versionId); + String mcVersion = cleanMcVersion(versionId, loader); + return new Info(mcVersion, loader); + } catch (Exception e) { + return new Info(null, ""); + } + } + + private static String loaderFromVersionId(String versionId) { + if (versionId.startsWith("fabric-loader-")) return "fabric"; + if (versionId.startsWith("quilt-loader-")) return "quilt"; + if (versionId.startsWith("neoforge-")) return "neoforge"; + if (versionId.contains("-forge-")) return "forge"; + return ""; + } + + /** Strips the loader naming convention off a version id (see ModLoader.getVersionId()) + * to get the plain Minecraft version underneath — e.g. "fabric-loader-0.15.11-1.20.1" + * → "1.20.1". Falls back to the version JSON's own inheritsFrom (which every mod + * loader install here sets to the vanilla version it's built on), and finally to the + * raw id itself if neither approach works (e.g. NeoForge ids don't embed the MC + * version at all, so this relies entirely on inheritsFrom for that loader). */ + private static String cleanMcVersion(String versionId, String loader) { + switch (loader) { + case "fabric": { + String rest = versionId.substring("fabric-loader-".length()); + int dash = rest.indexOf('-'); + if (dash > 0) return rest.substring(dash + 1); + break; + } + case "quilt": { + String rest = versionId.substring("quilt-loader-".length()); + int dash = rest.indexOf('-'); + if (dash > 0) return rest.substring(dash + 1); + break; + } + case "forge": { + int idx = versionId.indexOf("-forge-"); + if (idx > 0) return versionId.substring(0, idx); + break; + } + default: + break; + } + + try { + JMinecraftVersionList.Version info = Tools.getVersionInfo(versionId); + if (info != null && info.inheritsFrom != null && !info.inheritsFrom.isEmpty()) { + return info.inheritsFrom; + } + } catch (Exception ignored) {} + + return loader.isEmpty() ? versionId : null; + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java index 6339cbb1d4..03d3759660 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java @@ -136,10 +136,12 @@ public boolean tryOpenInRightPane(Class fragmentClass, Strin * Internal navigation: right pane in landscape, full-screen swap in portrait. */ /** - * Opens ModsSearchFragment with the saved per-instance filter pre-seeded. - * Always creates a fresh instance so the args bundle is applied on every open. + * Opens ModsSearchFragment with the saved per-instance filter pre-seeded, for + * the given content type. Always creates a fresh instance so the args bundle + * is applied on every open. The loader filter is only ever passed through for + * MOD — resource packs and shader packs aren't loader-specific. */ - private void openModStore() { + private void openModStore(net.kdt.pojavlaunch.modloaders.modpacks.models.ContentType contentType) { String profileKey = LauncherPreferences.DEFAULT_PREF .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, "default"); SharedPreferences prefs = requireContext() @@ -149,12 +151,86 @@ private void openModStore() { String loader = prefs.getString("loader_" + profileKey, ""); Bundle args = new Bundle(); + args.putString(ModsSearchFragment.ARG_CONTENT_TYPE, contentType.name()); if (!version.isEmpty()) args.putString(ModsSearchFragment.ARG_PRESET_MC_VERSION, version); - if (!loader.isEmpty()) args.putString(ModsSearchFragment.ARG_PRESET_LOADER, loader); + if (contentType == net.kdt.pojavlaunch.modloaders.modpacks.models.ContentType.MOD + && !loader.isEmpty()) { + args.putString(ModsSearchFragment.ARG_PRESET_LOADER, loader); + } ModsSearchFragment fragment = new ModsSearchFragment(); fragment.setArguments(args); - openPaneFragment(fragment, ModsSearchFragment.TAG); + openPaneFragment(fragment, ModsSearchFragment.TAG + ":" + contentType.name()); + } + + /** + * Shows the "Browse Content" / "Manage Content" picker: mods, resource packs, + * or shader packs, styled like the Microsoft/Local account picker. The manage + * variant also exposes the per-instance version/loader filter (relocated here + * from a button that used to live inside Manage Mods itself, since the filter + * is shared across all three content types rather than being mod-specific). + */ + private void showContentPicker(boolean manage) { + androidx.appcompat.app.AlertDialog dialog = + new androidx.appcompat.app.AlertDialog.Builder(requireContext()) + .setView(R.layout.dialog_content_picker) + .create(); + + dialog.setOnShowListener(di -> { + android.widget.TextView title = dialog.findViewById(R.id.content_picker_title); + ImageButton filterButton = dialog.findViewById(R.id.content_picker_filter); + View modsButton = dialog.findViewById(R.id.content_picker_mods); + View resourcepacksButton = dialog.findViewById(R.id.content_picker_resourcepacks); + View shaderpacksButton = dialog.findViewById(R.id.content_picker_shaderpacks); + + if (title != null) { + title.setText(manage ? R.string.content_picker_title_manage + : R.string.content_picker_title_browse); + } + + if (filterButton != null) { + if (manage) { + filterButton.setVisibility(View.VISIBLE); + filterButton.setOnClickListener(v -> { + String profileKey = LauncherPreferences.DEFAULT_PREF + .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, "default"); + ContentFilterDialog.show(requireContext(), profileKey, (version, loader) -> { + Toast.makeText(requireContext(), + getString(R.string.manage_mods_filter_active, version, loader), + Toast.LENGTH_SHORT).show(); + }); + }); + } else { + filterButton.setVisibility(View.GONE); + } + } + + if (modsButton != null) modsButton.setOnClickListener(v -> { + dialog.dismiss(); + onContentTypeChosen(manage, net.kdt.pojavlaunch.modloaders.modpacks.models.ContentType.MOD); + }); + if (resourcepacksButton != null) resourcepacksButton.setOnClickListener(v -> { + dialog.dismiss(); + onContentTypeChosen(manage, net.kdt.pojavlaunch.modloaders.modpacks.models.ContentType.RESOURCE_PACK); + }); + if (shaderpacksButton != null) shaderpacksButton.setOnClickListener(v -> { + dialog.dismiss(); + onContentTypeChosen(manage, net.kdt.pojavlaunch.modloaders.modpacks.models.ContentType.SHADER_PACK); + }); + }); + + dialog.show(); + } + + private void onContentTypeChosen(boolean manage, + net.kdt.pojavlaunch.modloaders.modpacks.models.ContentType contentType) { + if (manage) { + Bundle args = new Bundle(); + args.putString(ManageModsFragment.ARG_CONTENT_TYPE, contentType.name()); + openPane(ManageModsFragment.class, ManageModsFragment.TAG + ":" + contentType.name(), args); + } else { + openModStore(contentType); + } } /** @@ -300,9 +376,9 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat mCustomControlButton.setOnClickListener(v -> startActivity(new Intent(requireContext(), CustomControlsActivity.class))); - // Mod Store + // Browse Content (mods / resource packs / shader packs) if (mModStoreButton != null) - mModStoreButton.setOnClickListener(v -> openModStore()); + mModStoreButton.setOnClickListener(v -> showContentPicker(false)); // Execute .jar if (hasOnlineProfile()) { @@ -320,9 +396,8 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat if (mShareLogsButton != null) mShareLogsButton.setOnClickListener(v -> shareLog(requireContext())); - // Manage Mods - mManageModsButton.setOnClickListener(v -> - openPane(ManageModsFragment.class, ManageModsFragment.TAG, null)); + // Manage Content (mods / resource packs / shader packs) + mManageModsButton.setOnClickListener(v -> showContentPicker(true)); // Open game directory if (mOpenDirectoryButton != null) { diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java index 32d043ec80..7220356a0d 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java @@ -3,17 +3,13 @@ import android.content.SharedPreferences; import android.os.Bundle; import android.view.View; -import android.widget.ArrayAdapter; -import android.widget.Button; import android.widget.ImageButton; import android.widget.ProgressBar; -import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -21,25 +17,42 @@ import net.kdt.pojavlaunch.R; import net.kdt.pojavlaunch.Tools; import net.kdt.pojavlaunch.modloaders.InstalledModAdapter; +import net.kdt.pojavlaunch.modloaders.modpacks.models.ContentType; import net.kdt.pojavlaunch.prefs.LauncherPreferences; -import net.kdt.pojavlaunch.profiles.VersionSelectorDialog; import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles; import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile; import java.io.File; +/** + * Manages installed content (mods, resource packs, or shader packs — see + * {@link #ARG_CONTENT_TYPE}) for the current instance: list, enable/disable, + * delete, and check for updates. The version/loader filter used for update + * checking now lives in the "Manage Content" picker (see MainMenuFragment / + * ContentFilterDialog) rather than a button on this screen, since it's a + * single per-instance filter shared across all three content types. + * + * The loader half of that filter only ever applies to mods — resource packs + * and shader packs aren't loader-specific, so update checks/searches for + * those two content types always ignore the saved loader value even if one + * is set (see {@link #effectiveLoader(String)}). + */ public class ManageModsFragment extends Fragment { public static final String TAG = "ManageModsFragment"; + /** Bundle key: which kind of content this screen manages. Value is a + * ContentType enum name(); defaults to MOD when absent. */ + public static final String ARG_CONTENT_TYPE = "content_type"; + private static final String PREF_FILE = "mod_filters"; private static final String KEY_MC_VERSION = "mc_version_"; private static final String KEY_LOADER = "loader_"; - private ImageButton mFilterButton; private ImageButton mRefreshButton; private ProgressBar mUpdateProgress; private InstalledModAdapter mAdapter; + private ContentType mContentType = ContentType.MOD; public ManageModsFragment() { super(R.layout.fragment_manage_mods); @@ -47,8 +60,11 @@ public ManageModsFragment() { @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + Bundle args = getArguments(); + String typeName = args != null ? args.getString(ARG_CONTENT_TYPE, null) : null; + mContentType = typeName != null ? ContentType.valueOf(typeName) : ContentType.MOD; + ImageButton backButton = view.findViewById(R.id.manage_mods_back); - mFilterButton = view.findViewById(R.id.manage_mods_filter); mRefreshButton = view.findViewById(R.id.manage_mods_refresh); mUpdateProgress = view.findViewById(R.id.manage_mods_update_progress); ImageButton addButton = view.findViewById(R.id.manage_mods_add); @@ -57,25 +73,25 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat View emptyState = view.findViewById(R.id.manage_mods_empty); backButton.setOnClickListener(v -> requireActivity().onBackPressed()); - mFilterButton.setOnClickListener(v -> showFilterDialog()); mRefreshButton.setOnClickListener(v -> runUpdateCheck(false)); addButton.setOnClickListener(v -> openModSearch()); String profileName = getCurrentProfileName(); - title.setText(profileName.isEmpty() - ? getString(R.string.mcl_button_manage_mods) - : profileName + " - Mods"); + String typeLabel = getString(contentTypeLabelRes()); + title.setText(profileName.isEmpty() ? typeLabel : profileName + " - " + typeLabel); - refreshFilterButtonTint(); + if (emptyState instanceof TextView) { + ((TextView) emptyState).setText(contentTypeEmptyLabelRes()); + } // Build adapter, inject saved filter (no auto update-check — opt-in via refresh button) String profileKey = getCurrentProfileKey(); SharedPreferences prefs = requireContext() .getSharedPreferences(PREF_FILE, android.content.Context.MODE_PRIVATE); String savedVersion = prefs.getString(KEY_MC_VERSION + profileKey, ""); - String savedLoader = prefs.getString(KEY_LOADER + profileKey, ""); + String savedLoader = effectiveLoader(prefs.getString(KEY_LOADER + profileKey, "")); - mAdapter = new InstalledModAdapter(requireContext(), getModsDir(), isEmpty -> { + mAdapter = new InstalledModAdapter(requireContext(), getContentDir(), mContentType, isEmpty -> { recycler.setVisibility(isEmpty ? View.GONE : View.VISIBLE); emptyState.setVisibility(isEmpty ? View.VISIBLE : View.GONE); }); @@ -88,12 +104,12 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat // the manual refresh button. Silent (no "checking…" toast, and no // "set a filter first" toast) since this fires automatically — if // there's no filter set yet, it just quietly does nothing until the - // user configures one and hits refresh manually. + // user configures one via the Manage Content filter icon. runUpdateCheck(true); } /** - * Runs the Modrinth update check across all installed mods for this instance. + * Runs the Modrinth update check across all installed content for this instance. * Triggered automatically when the screen opens (silent = true) and manually * via the refresh button (silent = false). * @@ -108,7 +124,7 @@ private void runUpdateCheck(boolean silent) { SharedPreferences prefs = requireContext() .getSharedPreferences(PREF_FILE, android.content.Context.MODE_PRIVATE); String version = prefs.getString(KEY_MC_VERSION + profileKey, ""); - String loader = prefs.getString(KEY_LOADER + profileKey, ""); + String loader = effectiveLoader(prefs.getString(KEY_LOADER + profileKey, "")); if (version.isEmpty() && loader.isEmpty()) { if (!silent) { @@ -138,115 +154,48 @@ private void runUpdateCheck(boolean silent) { }); } - // ── Filter dialog ──────────────────────────────────────────────────────── - - private void showFilterDialog() { - String profileKey = getCurrentProfileKey(); - SharedPreferences prefs = requireContext() - .getSharedPreferences(PREF_FILE, android.content.Context.MODE_PRIVATE); - - String savedVersion = prefs.getString(KEY_MC_VERSION + profileKey, ""); - String savedLoader = prefs.getString(KEY_LOADER + profileKey, ""); - - AlertDialog dialog = new AlertDialog.Builder(requireContext()) - .setView(R.layout.dialog_mod_filters) - .create(); - - dialog.setOnShowListener(di -> { - TextView versionText = dialog.findViewById(R.id.search_mod_selected_mc_version_textview); - Button selectVersion = dialog.findViewById(R.id.search_mod_mc_version_button); - Button applyButton = dialog.findViewById(R.id.search_mod_apply_filters); - Spinner loaderSpinner = dialog.findViewById(R.id.search_mod_loader_spinner); - - // This dialog is shared with the mod/modpack search screens, which also - // show a Modrinth/CurseForge/Both engine picker — not applicable here, - // since Manage Mods just filters already-installed mods by version/loader - // for update checking, it doesn't pick a search engine. - View engineLabel = dialog.findViewById(R.id.search_mod_engine_textview); - View engineSpinner = dialog.findViewById(R.id.search_mod_engine_spinner); - if (engineLabel != null) engineLabel.setVisibility(View.GONE); - if (engineSpinner != null) engineSpinner.setVisibility(View.GONE); - - if (versionText == null || selectVersion == null || applyButton == null) return; - - versionText.setText(savedVersion); - - final String[] loaderValues = { "", "fabric", "forge", "quilt", "neoforge" }; - if (loaderSpinner != null) { - String[] loaderLabels = { - getString(R.string.search_mod_any_loader), - "Fabric", "Forge", "Quilt", "NeoForge" - }; - ArrayAdapter adapter = new ArrayAdapter<>( - requireContext(), android.R.layout.simple_spinner_item, loaderLabels); - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - loaderSpinner.setAdapter(adapter); - for (int i = 0; i < loaderValues.length; i++) { - if (loaderValues[i].equals(savedLoader)) { - loaderSpinner.setSelection(i); - break; - } - } - } - - selectVersion.setOnClickListener(v -> - VersionSelectorDialog.open(v.getContext(), true, - (id, snapshot) -> versionText.setText(id))); - - applyButton.setOnClickListener(v -> { - String newVersion = versionText.getText().toString().trim(); - String newLoader = (loaderSpinner != null) - ? loaderValues[loaderSpinner.getSelectedItemPosition()] - : ""; - - prefs.edit() - .putString(KEY_MC_VERSION + profileKey, newVersion) - .putString(KEY_LOADER + profileKey, newLoader) - .apply(); - - // Update the adapter's filter. Update checking itself stays - // opt-in — only the refresh button triggers a check. - if (mAdapter != null) { - mAdapter.setFilter(newVersion, newLoader); - } - - refreshFilterButtonTint(); - di.dismiss(); - }); - }); - - dialog.show(); - } - private void openModSearch() { String profileKey = getCurrentProfileKey(); SharedPreferences prefs = requireContext() .getSharedPreferences(PREF_FILE, android.content.Context.MODE_PRIVATE); String version = prefs.getString(KEY_MC_VERSION + profileKey, ""); - String loader = prefs.getString(KEY_LOADER + profileKey, ""); + String loader = effectiveLoader(prefs.getString(KEY_LOADER + profileKey, "")); Bundle args = new Bundle(); if (!version.isEmpty()) args.putString(ModsSearchFragment.ARG_PRESET_MC_VERSION, version); if (!loader.isEmpty()) args.putString(ModsSearchFragment.ARG_PRESET_LOADER, loader); + args.putString(ModsSearchFragment.ARG_CONTENT_TYPE, mContentType.name()); ModsSearchFragment fragment = new ModsSearchFragment(); fragment.setArguments(args); - navigateToFragment(fragment, ModsSearchFragment.TAG); + navigateToFragment(fragment, ModsSearchFragment.TAG + ":" + mContentType.name()); } - private void refreshFilterButtonTint() { - if (mFilterButton == null || !isAdded()) return; - String profileKey = getCurrentProfileKey(); - SharedPreferences prefs = requireContext() - .getSharedPreferences(PREF_FILE, android.content.Context.MODE_PRIVATE); - boolean active = !prefs.getString(KEY_MC_VERSION + profileKey, "").isEmpty() - || !prefs.getString(KEY_LOADER + profileKey, "").isEmpty(); - mFilterButton.setAlpha(active ? 1.0f : 0.4f); + /** Loader filters only make sense for mods — a saved loader preference is + * simply ignored when managing/searching resource packs or shader packs. */ + private String effectiveLoader(String savedLoader) { + return mContentType == ContentType.MOD ? savedLoader : ""; } // ── Helpers ────────────────────────────────────────────────────────────── + private int contentTypeLabelRes() { + switch (mContentType) { + case RESOURCE_PACK: return R.string.mcl_content_resourcepacks; + case SHADER_PACK: return R.string.mcl_content_shaderpacks; + default: return R.string.mcl_content_mods; + } + } + + private int contentTypeEmptyLabelRes() { + switch (mContentType) { + case RESOURCE_PACK: return R.string.manage_resourcepacks_empty; + case SHADER_PACK: return R.string.manage_shaderpacks_empty; + default: return R.string.manage_mods_empty; + } + } + @NonNull private String getCurrentProfileKey() { String key = LauncherPreferences.DEFAULT_PREF @@ -266,17 +215,17 @@ private String getCurrentProfileName() { } } - private File getModsDir() { + private File getContentDir() { try { String key = getCurrentProfileKey(); LauncherProfiles.load(); MinecraftProfile profile = LauncherProfiles.mainProfileJson.profiles.get(key); if (profile != null) { File gameDir = Tools.getGameDirPath(profile); - return new File(gameDir, "mods"); + return new File(gameDir, mContentType.folderName); } } catch (Exception ignored) {} - return new File(Tools.DIR_GAME_NEW, "mods"); + return new File(Tools.DIR_GAME_NEW, mContentType.folderName); } private void navigateToFragment(Fragment fragment, String tag) { @@ -297,4 +246,4 @@ private void navigateToFragment(Fragment fragment, String tag) { .commit(); } } -} \ No newline at end of file +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java index ac606e2b44..94b97a2e95 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java @@ -36,6 +36,7 @@ import net.kdt.pojavlaunch.modloaders.modpacks.models.ModItem; import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchFilters; import net.kdt.pojavlaunch.modloaders.modpacks.models.Constants; +import net.kdt.pojavlaunch.modloaders.modpacks.models.ContentType; import net.kdt.pojavlaunch.prefs.LauncherPreferences; import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper; import net.kdt.pojavlaunch.profiles.VersionSelectorDialog; @@ -67,6 +68,9 @@ public class ModsSearchFragment extends Fragment implements ModItemAdapter.Searc public static final String ARG_PRESET_MC_VERSION = "preset_mc_version"; /** Bundle key: pre-seed the mod loader filter ("fabric","forge","quilt","neoforge"). */ public static final String ARG_PRESET_LOADER = "preset_loader"; + /** Bundle key: which kind of content to browse. Value is a ContentType enum + * name(); defaults to MOD when absent. */ + public static final String ARG_CONTENT_TYPE = "content_type"; private View mOverlay; private float mOverlayTopCache; @@ -139,30 +143,50 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat }); mFilterButton.setOnClickListener(v -> displayFilterDialog()); - mSearchEditText.setHint(R.string.hint_search_mod); - // Apply any pre-seeded filters passed in from ManageModsFragment filter button + // Apply any pre-seeded filters passed in from the Manage Content picker applyPresetArgs(); + mSearchEditText.setHint(searchHintRes()); + searchMods(null); } + private int searchHintRes() { + switch (mSearchFilters.contentType) { + case RESOURCE_PACK: return R.string.hint_search_resourcepack; + case SHADER_PACK: return R.string.hint_search_shaderpack; + default: return R.string.hint_search_mod; + } + } + /** - * Reads ARG_PRESET_MC_VERSION / ARG_PRESET_LOADER from the fragment's arguments - * and seeds mSearchFilters before the first search. This lets ManageModsFragment - * pass in the current instance's version+loader so the search is already filtered. + * Reads ARG_CONTENT_TYPE / ARG_PRESET_MC_VERSION / ARG_PRESET_LOADER from the + * fragment's arguments and seeds mSearchFilters before the first search. This + * lets ManageModsFragment (via the Manage Content picker) pass in the current + * instance's content type + version/loader so the search opens already filtered. + * + * The loader preset is only applied for MOD — resource packs and shader packs + * aren't loader-specific, so a saved loader filter is ignored for those, both + * here and in the filter dialog itself. */ private void applyPresetArgs() { Bundle args = getArguments(); if (args == null) return; + String contentTypeName = args.getString(ARG_CONTENT_TYPE, null); + if (contentTypeName != null) { + mSearchFilters.contentType = ContentType.valueOf(contentTypeName); + } + String presetVersion = args.getString(ARG_PRESET_MC_VERSION, null); String presetLoader = args.getString(ARG_PRESET_LOADER, null); if (presetVersion != null && !presetVersion.isEmpty()) { mSearchFilters.mcVersion = presetVersion; } - if (presetLoader != null && !presetLoader.isEmpty()) { + if (mSearchFilters.contentType == ContentType.MOD + && presetLoader != null && !presetLoader.isEmpty()) { mSearchFilters.modLoader = presetLoader; } } @@ -253,9 +277,15 @@ private void displayFilterDialog() { } } - // Set up loader spinner + // Set up loader spinner — only meaningful for mods. Resource packs and + // shader packs aren't loader-specific, so this whole row is hidden and + // any previously-set loader filter is simply not applied to the search. + TextView mLoaderLabel = dialog.findViewById(R.id.search_mod_loader_textview); final String[] loaderValues = {"", "fabric", "forge", "quilt", "neoforge"}; + boolean showLoaderFilter = mSearchFilters.contentType == ContentType.MOD; + if (mLoaderLabel != null) mLoaderLabel.setVisibility(showLoaderFilter ? View.VISIBLE : View.GONE); if (mLoaderSpinner != null) { + mLoaderSpinner.setVisibility(showLoaderFilter ? View.VISIBLE : View.GONE); String[] loaderLabels = {getString(R.string.search_mod_any_loader), "Fabric", "Forge", "Quilt", "NeoForge"}; android.widget.ArrayAdapter loaderAdapter = new android.widget.ArrayAdapter<>( requireContext(), android.R.layout.simple_spinner_item, loaderLabels); @@ -275,14 +305,38 @@ private void displayFilterDialog() { mSelectVersionButton.setOnClickListener(v -> VersionSelectorDialog.open(v.getContext(), true, (id, snapshot) -> mSelectedVersion.setText(id))); + + // If nothing's been picked yet (no preset args, filter never touched + // before), default to the current instance's own version/loader + // instead of leaving the filter blank. + if ((mSearchFilters.mcVersion == null || mSearchFilters.mcVersion.isEmpty()) + && (mSearchFilters.modLoader == null || mSearchFilters.modLoader.isEmpty())) { + String profileKey = net.kdt.pojavlaunch.prefs.LauncherPreferences.DEFAULT_PREF + .getString(net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null); + if (profileKey != null) { + InstanceVersionResolver.Info info = InstanceVersionResolver.resolve(profileKey); + if (info.mcVersion != null) mSearchFilters.mcVersion = info.mcVersion; + if (showLoaderFilter && !info.loader.isEmpty()) mSearchFilters.modLoader = info.loader; + if (mLoaderSpinner != null) { + for (int i = 0; i < loaderValues.length; i++) { + if (loaderValues[i].equals(mSearchFilters.modLoader)) { + mLoaderSpinner.setSelection(i); + break; + } + } + } + } + } mSelectedVersion.setText(mSearchFilters.mcVersion); mApplyButton.setOnClickListener(v -> { if (mEngineSpinner != null) { mSearchFilters.engine = engineValues[mEngineSpinner.getSelectedItemPosition()]; } - if (mLoaderSpinner != null) { + if (showLoaderFilter && mLoaderSpinner != null) { mSearchFilters.modLoader = loaderValues[mLoaderSpinner.getSelectedItemPosition()]; + } else { + mSearchFilters.modLoader = ""; } mSearchFilters.mcVersion = mSelectedVersion.getText().toString(); searchMods(mSearchEditText.getText().toString()); @@ -359,7 +413,8 @@ private void resolveInstalledVersionIndex(ModDetail detail) { File modsDir = getModsDir(); File[] files = modsDir.listFiles(f -> f.isFile() && - (f.getName().endsWith(".jar") || f.getName().endsWith(".jar.disabled"))); + (f.getName().endsWith(mFilters.contentType.fileExtension) || + f.getName().endsWith(mFilters.contentType.fileExtension + ".disabled"))); if (files == null || files.length == 0) return; java.util.Map installedHashToFile = new java.util.HashMap<>(); @@ -437,7 +492,7 @@ public void handleInstallation(Context context, ModDetail modDetail, int selecte // Extract filename String rawName = url.substring(url.lastIndexOf('/') + 1); if (rawName.contains("?")) rawName = rawName.substring(0, rawName.indexOf('?')); - final String fileName = rawName.endsWith(".jar") ? rawName : rawName + ".jar"; + final String fileName = rawName.endsWith(mFilters.contentType.fileExtension) ? rawName : rawName + mFilters.contentType.fileExtension; // If a different version of this same mod is already installed under // a different file name (the Update/Downgrade case), remember its path @@ -645,7 +700,7 @@ private void downloadDependency(String projectId, File modsDir) { String depUrl = depDetail.versionUrls[0]; String depName = depUrl.substring(depUrl.lastIndexOf('/') + 1); if (depName.contains("?")) depName = depName.substring(0, depName.indexOf('?')); - if (!depName.endsWith(".jar")) depName += ".jar"; + if (!depName.endsWith(mFilters.contentType.fileExtension)) depName += mFilters.contentType.fileExtension; DownloadUtils.downloadFile(depUrl, new File(modsDir, depName)); } catch (Exception e) { @@ -663,7 +718,8 @@ private java.util.Set getInstalledModrinthProjectIds() { java.util.Set projectIds = new java.util.HashSet<>(); File modsDir = getModsDir(); File[] files = modsDir.listFiles(f -> f.isFile() && - (f.getName().endsWith(".jar") || f.getName().endsWith(".jar.disabled"))); + (f.getName().endsWith(mFilters.contentType.fileExtension) || + f.getName().endsWith(mFilters.contentType.fileExtension + ".disabled"))); if (files == null || files.length == 0) return projectIds; List hashes = new ArrayList<>(); @@ -733,7 +789,8 @@ private java.util.Set getInstalledModIds() { java.util.Set ids = new java.util.HashSet<>(); File modsDir = getModsDir(); File[] files = modsDir.listFiles(f -> f.isFile() && - (f.getName().endsWith(".jar") || f.getName().endsWith(".jar.disabled"))); + (f.getName().endsWith(mFilters.contentType.fileExtension) || + f.getName().endsWith(mFilters.contentType.fileExtension + ".disabled"))); if (files == null) return ids; for (File f : files) { String id = extractModId(f); @@ -807,17 +864,17 @@ private static String tomlStringField(String content, String field) { return m.find() ? m.group(1) : null; } - private static File getModsDir() { + private File getModsDir() { try { String key = LauncherPreferences.DEFAULT_PREF .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null); if (key != null && !key.isEmpty()) { LauncherProfiles.load(); MinecraftProfile profile = LauncherProfiles.mainProfileJson.profiles.get(key); - if (profile != null) return new File(Tools.getGameDirPath(profile), "mods"); + if (profile != null) return new File(Tools.getGameDirPath(profile), mFilters.contentType.folderName); } } catch (Exception ignored) {} - return new File(Tools.DIR_GAME_NEW, "mods"); + return new File(Tools.DIR_GAME_NEW, mFilters.contentType.folderName); } } } \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java index d5bc416463..fe6483c8b9 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java @@ -34,6 +34,7 @@ import net.kdt.pojavlaunch.modloaders.modpacks.api.ApiHandler; import net.kdt.pojavlaunch.modloaders.modpacks.imagecache.ImageReceiver; import net.kdt.pojavlaunch.modloaders.modpacks.imagecache.ModIconCache; +import net.kdt.pojavlaunch.modloaders.modpacks.models.ContentType; import net.kdt.pojavlaunch.utils.DownloadUtils; import net.kdt.pojavlaunch.utils.Murmur2; @@ -98,6 +99,10 @@ public interface EmptyStateListener { private final Context mContext; private final String mCurseforgeApiKey; + /** Which kind of content this instance manages — drives the file extension used + * for scanning/enabling/disabling entries. Icon and name extraction stay + * extension-agnostic (they already fall back gracefully for non-mod content). */ + private final ContentType mContentType; // Per-instance filter — set by ManageModsFragment before triggering update check private String mFilterMcVersion = ""; @@ -116,14 +121,21 @@ public interface EmptyStateListener { // loading (or anything else routed through sExecutorService) while they run. private static final ExecutorService sUpdateCheckExecutor = Executors.newFixedThreadPool(3); + /** Kept for any callers still constructing a mods-only adapter directly. */ public InstalledModAdapter(Context context, File modsDir, EmptyStateListener listener) { + this(context, modsDir, ContentType.MOD, listener); + } + + public InstalledModAdapter(Context context, File contentDir, ContentType contentType, EmptyStateListener listener) { mContext = context.getApplicationContext(); + mContentType = contentType; mCurseforgeApiKey = net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_DISABLE_CURSEFORGE_API ? null : net.kdt.pojavlaunch.prefs.LauncherPreferences.resolveCurseforgeApiKey(context); mEmptyListener = listener; - if (modsDir != null && modsDir.isDirectory()) { - File[] files = modsDir.listFiles(f -> f.isFile() && - (f.getName().endsWith(".jar") || f.getName().endsWith(".jar.disabled"))); + if (contentDir != null && contentDir.isDirectory()) { + String ext = mContentType.fileExtension; + File[] files = contentDir.listFiles(f -> f.isFile() && + (f.getName().endsWith(ext) || f.getName().endsWith(ext + ".disabled"))); if (files != null) { Arrays.sort(files, (a, b) -> a.getName().compareToIgnoreCase(b.getName())); for (File f : files) mMods.add(new ModEntry(f)); @@ -939,15 +951,17 @@ static class ModEntry { String displayName() { if (metaName != null && !metaName.isEmpty()) return metaName; String n = file.getName(); - if (n.endsWith(".jar.disabled")) n = n.substring(0, n.length() - 13); - else if (n.endsWith(".jar")) n = n.substring(0, n.length() - 4); + if (n.endsWith(".disabled")) n = n.substring(0, n.length() - ".disabled".length()); + int dot = n.lastIndexOf('.'); + if (dot > 0) n = n.substring(0, dot); // strip .jar / .zip return n; } void setEnabled(boolean enable) { if (enable == this.enabled) return; File target = enable - ? new File(file.getParent(), file.getName().replace(".jar.disabled", ".jar")) + ? new File(file.getParent(), file.getName().substring(0, + file.getName().length() - ".disabled".length())) : new File(file.getParent(), file.getName() + ".disabled"); if (file.renameTo(target)) { file = target; diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ApiHandler.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ApiHandler.java index dc04b93c81..2bd1531f99 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ApiHandler.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ApiHandler.java @@ -171,4 +171,4 @@ private static String urlEncodeUTF8(String input) { throw new RuntimeException("UTF-8 is required"); } } -} \ No newline at end of file +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java index 05f4e95e14..e2fb56b98c 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java @@ -58,7 +58,8 @@ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previous HashMap params = new HashMap<>(); params.put("gameId", CURSEFORGE_MINECRAFT_GAME_ID); - params.put("classId", searchFilters.isModpack ? CURSEFORGE_MODPACK_CLASS_ID : CURSEFORGE_MOD_CLASS_ID); + params.put("classId", searchFilters.isModpack + ? CURSEFORGE_MODPACK_CLASS_ID : searchFilters.contentType.curseforgeClassId); params.put("searchFilter", searchFilters.name); params.put("sortField", CURSEFORGE_SORT_RELEVANCY); params.put("sortOrder", "desc"); diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModrinthApi.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModrinthApi.java index 78e48fc10f..d89ef8e07d 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModrinthApi.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/ModrinthApi.java @@ -49,7 +49,8 @@ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previous HashMap params = new HashMap<>(); StringBuilder facetString = new StringBuilder(); facetString.append("["); - facetString.append(String.format("[\"project_type:%s\"]", searchFilters.isModpack ? "modpack" : "mod")); + facetString.append(String.format("[\"project_type:%s\"]", + searchFilters.isModpack ? "modpack" : searchFilters.contentType.modrinthType)); if(searchFilters.mcVersion != null && !searchFilters.mcVersion.isEmpty()) facetString.append(String.format(",[\"versions:%s\"]", searchFilters.mcVersion)); if(searchFilters.modLoader != null && !searchFilters.modLoader.isEmpty()) diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/ContentType.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/ContentType.java new file mode 100644 index 0000000000..22c3ed94dd --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/ContentType.java @@ -0,0 +1,46 @@ +package net.kdt.pojavlaunch.modloaders.modpacks.models; + +/** + * The three kinds of installable content that "Browse Content" / "Manage Content" + * support. Mods, resource packs, and shader packs all go through the same + * search/install/update-check pipeline (ModsSearchFragment, ManageModsFragment, + * InstalledModAdapter) — this enum centralizes the handful of things that + * actually differ between them, so that pipeline doesn't need three separate + * copies: + * - which folder the content lives in under the instance's game directory + * - what file extension its files use + * - the Modrinth `project_type` facet to search under + * - the CurseForge classId (category) to search under + */ +public enum ContentType { + MOD("mods", ".jar", "mod", 6), + RESOURCE_PACK("resourcepacks", ".zip", "resourcepack", 12), + // https://api.curseforge.com/v1/categories?gameId=432 — "Shaders" category id. + SHADER_PACK("shaderpacks", ".zip", "shader", 6552); + + /** Folder name under the instance's game directory (e.g. ".minecraft/mods"). */ + public final String folderName; + /** File extension used for this content's files (e.g. ".jar", ".zip"). */ + public final String fileExtension; + /** Modrinth `project_type` facet value. */ + public final String modrinthType; + /** CurseForge classId under the Minecraft game (id 432). */ + public final int curseforgeClassId; + + ContentType(String folderName, String fileExtension, String modrinthType, int curseforgeClassId) { + this.folderName = folderName; + this.fileExtension = fileExtension; + this.modrinthType = modrinthType; + this.curseforgeClassId = curseforgeClassId; + } + + /** Inverse of {@link #modrinthType} — used to round-trip the selection through a Bundle. */ + public static ContentType fromModrinthType(String type) { + if (type == null) return MOD; + switch (type) { + case "resourcepack": return RESOURCE_PACK; + case "shader": return SHADER_PACK; + default: return MOD; + } + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/SearchFilters.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/SearchFilters.java index 9b79392669..961c9289e8 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/SearchFilters.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/SearchFilters.java @@ -19,4 +19,11 @@ public class SearchFilters { */ public int engine = Constants.ENGINE_MODRINTH; + /** + * Which kind of content to search for. Defaults to MOD; isModpack (when + * true) still takes priority over this for the existing modpack-browsing + * flow, since modpacks aren't one of the three ContentType values. + */ + public ContentType contentType = ContentType.MOD; + } \ No newline at end of file diff --git a/app_pojavlauncher/src/main/res/layout/dialog_content_picker.xml b/app_pojavlauncher/src/main/res/layout/dialog_content_picker.xml new file mode 100644 index 0000000000..5cb2c771a4 --- /dev/null +++ b/app_pojavlauncher/src/main/res/layout/dialog_content_picker.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + diff --git a/app_pojavlauncher/src/main/res/layout/fragment_manage_mods.xml b/app_pojavlauncher/src/main/res/layout/fragment_manage_mods.xml index 96dcbe71eb..774736b3e7 100644 --- a/app_pojavlauncher/src/main/res/layout/fragment_manage_mods.xml +++ b/app_pojavlauncher/src/main/res/layout/fragment_manage_mods.xml @@ -55,15 +55,6 @@ android:src="@drawable/ic_refresh" android:contentDescription="@string/mod_check_updates" /> - - Back Add No mods installed for this profile. + No resource packs installed for this profile. + No shader packs installed for this profile. Delete %s? Filter mods Update mod @@ -111,6 +113,17 @@ Filter cleared %s installed to mods folder. Search for mods + Search for resource packs + Search for shader packs + + + Mods + Resource Packs + Shader Packs + Browse Content + Manage Content + Filter + Installed @@ -526,9 +539,9 @@ Quick Actions Instance Tools Select Instance - Browse Mods + Browse Content Settings - Manage Mods + Manage Content Install Modpack (.mrpack/zip) Delete Your GPU is not capable of rendering above 7 render distance without Sodium or other similar mods. The render distance will be automatically reduced when you click "OK". From af17076f7a7befd99705eec645702e293618287f Mon Sep 17 00:00:00 2001 From: MaxJubayerYT Date: Sat, 11 Jul 2026 12:07:47 +0600 Subject: [PATCH 3/3] fix(mods): auto-fill instance filter on open + fix picker dialog corners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browse Content and Manage Content's + search button previously opened unfiltered until the user manually opened the filter dialog and hit Apply. Both now fall back to the instance's own detected version/loader (same InstanceVersionResolver logic the filter dialog already uses) when no filter has been saved yet, so results are relevant immediately. Also fixed the Browse/Manage Content picker dialog's corners looking mismatched — the layout draws its own rounded card, but the dialog window's default background was still showing through behind it. Window background is now transparent so only the card's rounded corners show. --- .../fragments/MainMenuFragment.java | 18 ++++++++++++++++++ .../fragments/ManageModsFragment.java | 8 +++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java index 03d3759660..dc60e5b448 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java @@ -150,6 +150,15 @@ private void openModStore(net.kdt.pojavlaunch.modloaders.modpacks.models.Content String version = prefs.getString("mc_version_" + profileKey, ""); String loader = prefs.getString("loader_" + profileKey, ""); + // Nothing saved yet for this instance — default to the version/loader it's + // actually running, same as the Manage Content filter does, so the results + // are already relevant without an extra filter-then-Apply step. + if (version.isEmpty() && loader.isEmpty()) { + InstanceVersionResolver.Info info = InstanceVersionResolver.resolve(profileKey); + if (info.mcVersion != null) version = info.mcVersion; + loader = info.loader; + } + Bundle args = new Bundle(); args.putString(ModsSearchFragment.ARG_CONTENT_TYPE, contentType.name()); if (!version.isEmpty()) args.putString(ModsSearchFragment.ARG_PRESET_MC_VERSION, version); @@ -176,6 +185,15 @@ private void showContentPicker(boolean manage) { .setView(R.layout.dialog_content_picker) .create(); + // The layout itself already draws a rounded card (background_card); without + // this, the dialog window's own default (square-cornered) background shows + // through behind it, so the corners look like they've got a mismatched + // rectangle peeking out from under the rounded card. + if (dialog.getWindow() != null) { + dialog.getWindow().setBackgroundDrawable( + new android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT)); + } + dialog.setOnShowListener(di -> { android.widget.TextView title = dialog.findViewById(R.id.content_picker_title); ImageButton filterButton = dialog.findViewById(R.id.content_picker_filter); diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java index 7220356a0d..18f0acdc64 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java @@ -162,6 +162,12 @@ private void openModSearch() { String version = prefs.getString(KEY_MC_VERSION + profileKey, ""); String loader = effectiveLoader(prefs.getString(KEY_LOADER + profileKey, "")); + if (version.isEmpty() && loader.isEmpty()) { + InstanceVersionResolver.Info info = InstanceVersionResolver.resolve(profileKey); + if (info.mcVersion != null) version = info.mcVersion; + loader = effectiveLoader(info.loader); + } + Bundle args = new Bundle(); if (!version.isEmpty()) args.putString(ModsSearchFragment.ARG_PRESET_MC_VERSION, version); if (!loader.isEmpty()) args.putString(ModsSearchFragment.ARG_PRESET_LOADER, loader); @@ -246,4 +252,4 @@ private void navigateToFragment(Fragment fragment, String tag) { .commit(); } } -} +} \ No newline at end of file