diff --git a/README.md b/README.md
index a430b05014..e7f8b0afc3 100644
--- a/README.md
+++ b/README.md
@@ -9,11 +9,8 @@
-
-
-
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/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..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
@@ -136,10 +136,12 @@ public boolean tryOpenInRightPane(Class extends Fragment> 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()
@@ -148,13 +150,105 @@ private void openModStore() {
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);
- 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();
+
+ // 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);
+ 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 +394,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 +414,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..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
@@ -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,54 @@ 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, ""));
+
+ 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);
+ 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 +221,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) {
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..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;
@@ -45,9 +46,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.
@@ -62,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;
@@ -134,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;
}
}
@@ -248,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);
@@ -270,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());
@@ -354,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<>();
@@ -432,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
@@ -477,11 +537,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 +570,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) {
@@ -594,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) {
@@ -612,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<>();
@@ -651,28 +758,123 @@ 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;
}
- private static File getModsDir() {
+ /**
+ * 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(mFilters.contentType.fileExtension) ||
+ f.getName().endsWith(mFilters.contentType.fileExtension + ".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 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 4c03ecf2b8..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
@@ -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;
}
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".