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/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 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".