diff --git a/app_pojavlauncher/src/main/assets/amethyst.png b/app_pojavlauncher/src/main/assets/amethyst.png index 42343f8595..b07d0b9ba6 100644 Binary files a/app_pojavlauncher/src/main/assets/amethyst.png and b/app_pojavlauncher/src/main/assets/amethyst.png differ diff --git a/app_pojavlauncher/src/main/ic_launcher-playstore.png b/app_pojavlauncher/src/main/ic_launcher-playstore.png index f6f5921ad4..ee564bdfe9 100644 Binary files a/app_pojavlauncher/src/main/ic_launcher-playstore.png and b/app_pojavlauncher/src/main/ic_launcher-playstore.png differ diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java index 630f359f89..f21b2760f1 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java @@ -72,7 +72,9 @@ public class LauncherActivity extends BaseActivity { if(data != null) { PojavApplication.sExecutorService.execute(() -> { try { - ModLoader loaderInfo = new CommonApi(getString(R.string.curseforge_api_key)).importModpack(this, data); + ModLoader loaderInfo = new CommonApi( + net.kdt.pojavlaunch.prefs.LauncherPreferences.resolveCurseforgeApiKey(this)) + .importModpack(this, data); if (loaderInfo == null) return; loaderInfo.getDownloadTask(new NotificationDownloadListener(this, loaderInfo)).run(); } catch (IOException e) { 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 94576d568d..6339cbb1d4 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 @@ -157,9 +157,24 @@ private void openModStore() { openPaneFragment(fragment, ModsSearchFragment.TAG); } + /** + * True if {@code tag} is already the top entry on the right pane's back stack — + * i.e. that fragment is already showing. Used to swallow a double-tap on the + * same trigger instead of pushing a second, identical back-stack entry (which + * made Back have to be pressed twice to actually leave). + */ + private boolean isTagAlreadyOnTop(String tag) { + if (!isTwoPane()) return false; + androidx.fragment.app.FragmentManager fm = getChildFragmentManager(); + int count = fm.getBackStackEntryCount(); + if (count == 0) return false; + return tag.equals(fm.getBackStackEntryAt(count - 1).getName()); + } + /** Navigate to a pre-built fragment instance (preserves args on fresh instances). */ private void openPaneFragment(Fragment fragment, String tag) { if (isTwoPane()) { + if (isTagAlreadyOnTop(tag)) return; // already showing — ignore the double-tap getChildFragmentManager() .beginTransaction() .setReorderingAllowed(true) @@ -179,6 +194,7 @@ private void openPaneFragment(Fragment fragment, String tag) { private void openPane(Class fragmentClass, String tag, @Nullable Bundle args) { if (isTwoPane()) { + if (isTagAlreadyOnTop(tag)) return; // already showing — ignore the double-tap getChildFragmentManager() .beginTransaction() .setReorderingAllowed(true) 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 ac27a48af1..32d043ec80 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 @@ -6,6 +6,7 @@ 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; @@ -37,6 +38,7 @@ public class ManageModsFragment extends Fragment { private ImageButton mFilterButton; private ImageButton mRefreshButton; + private ProgressBar mUpdateProgress; private InstalledModAdapter mAdapter; public ManageModsFragment() { @@ -48,6 +50,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat 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); TextView title = view.findViewById(R.id.manage_mods_title); RecyclerView recycler = view.findViewById(R.id.manage_mods_recycler); @@ -55,7 +58,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat backButton.setOnClickListener(v -> requireActivity().onBackPressed()); mFilterButton.setOnClickListener(v -> showFilterDialog()); - mRefreshButton.setOnClickListener(v -> runUpdateCheck()); + mRefreshButton.setOnClickListener(v -> runUpdateCheck(false)); addButton.setOnClickListener(v -> openModSearch()); String profileName = getCurrentProfileName(); @@ -81,19 +84,24 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat recycler.setLayoutManager(new LinearLayoutManager(requireContext())); recycler.setAdapter(mAdapter); - // Update checking is opt-in: only runs when the user taps the refresh - // button (see mRefreshButton's listener above). Auto-checking here used - // to fire a network call per mod the instant this screen opened, which - // also fought with the initial icon-resolution pass and made icons - // flash to the placeholder glyph. + // Auto-check for updates as soon as the screen opens, in addition to + // 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. + runUpdateCheck(true); } /** * Runs the Modrinth update check across all installed mods for this instance. - * Only ever triggered by the user tapping the refresh button — update - * checking is fully opt-in, never automatic. + * Triggered automatically when the screen opens (silent = true) and manually + * via the refresh button (silent = false). + * + * @param silent when true, skips the "checking…"/"set a filter first" toasts — + * used for the automatic on-open check so it doesn't nag the + * user if they haven't configured a version/loader filter yet. */ - private void runUpdateCheck() { + private void runUpdateCheck(boolean silent) { if (mAdapter == null) return; String profileKey = getCurrentProfileKey(); @@ -103,17 +111,31 @@ private void runUpdateCheck() { String loader = prefs.getString(KEY_LOADER + profileKey, ""); if (version.isEmpty() && loader.isEmpty()) { - Toast.makeText(requireContext(), - R.string.mod_update_no_filter, Toast.LENGTH_SHORT).show(); + if (!silent) { + Toast.makeText(requireContext(), + R.string.mod_update_no_filter, Toast.LENGTH_SHORT).show(); + } return; } mAdapter.setFilter(version, loader); + // While checking: hide the refresh button and show just a spinner in + // its place. No per-mod update button appears until its own check + // resolves, so this spinner is the only thing indicating progress. mRefreshButton.setEnabled(false); - Toast.makeText(requireContext(), R.string.mod_update_checking, Toast.LENGTH_SHORT).show(); + mRefreshButton.setVisibility(View.GONE); + if (mUpdateProgress != null) mUpdateProgress.setVisibility(View.VISIBLE); + + if (!silent) { + Toast.makeText(requireContext(), R.string.mod_update_checking, Toast.LENGTH_SHORT).show(); + } - mAdapter.checkForUpdates(() -> mRefreshButton.setEnabled(true)); + mAdapter.checkForUpdates(() -> { + mRefreshButton.setEnabled(true); + mRefreshButton.setVisibility(View.VISIBLE); + if (mUpdateProgress != null) mUpdateProgress.setVisibility(View.GONE); + }); } // ── Filter dialog ──────────────────────────────────────────────────────── @@ -136,6 +158,15 @@ private void showFilterDialog() { 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); 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 a7dea1b2bf..b2547689d0 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 @@ -93,7 +93,9 @@ public ModsSearchFragment() { @Override public void onAttach(@NonNull Context context) { super.onAttach(context); - mModpackApi = new ModsInstallApi(context.getString(R.string.curseforge_api_key), mSearchFilters); + String curseforgeApiKey = LauncherPreferences.PREF_DISABLE_CURSEFORGE_API + ? null : LauncherPreferences.resolveCurseforgeApiKey(context); + mModpackApi = new ModsInstallApi(curseforgeApiKey, mSearchFilters); ((ModsInstallApi) mModpackApi).mActivityContext = context; } @@ -205,15 +207,51 @@ private void displayFilterDialog() { Button mSelectVersionButton = dialog.findViewById(R.id.search_mod_mc_version_button); Button mApplyButton = dialog.findViewById(R.id.search_mod_apply_filters); android.widget.Spinner mLoaderSpinner = dialog.findViewById(R.id.search_mod_loader_spinner); + android.widget.Spinner mEngineSpinner = dialog.findViewById(R.id.search_mod_engine_spinner); assert mSelectedVersion != null; assert mSelectVersionButton != null; assert mApplyButton != null; + // Set up the "Modrinth / CurseForge / Both" engine picker. If CurseForge is + // disabled in experimental settings, only Modrinth is offered and the filter + // is pinned to it, since a CurseForge-only or Both search would otherwise + // silently return nothing. + boolean curseforgeDisabled = net.kdt.pojavlaunch.prefs.LauncherPreferences.DEFAULT_PREF + .getBoolean("disableCurseforgeApi", false); + final int[] engineValues = curseforgeDisabled + ? new int[]{Constants.ENGINE_MODRINTH} + : new int[]{Constants.ENGINE_MODRINTH, Constants.ENGINE_CURSEFORGE, Constants.ENGINE_BOTH}; + if (mEngineSpinner != null) { + String[] engineLabels = curseforgeDisabled + ? new String[]{getString(R.string.search_mod_engine_modrinth)} + : new String[]{getString(R.string.search_mod_engine_modrinth), + getString(R.string.search_mod_engine_curseforge), + getString(R.string.search_mod_engine_both)}; + android.widget.ArrayAdapter engineAdapter = new android.widget.ArrayAdapter<>( + requireContext(), android.R.layout.simple_spinner_item, engineLabels); + engineAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + mEngineSpinner.setAdapter(engineAdapter); + + if (curseforgeDisabled) { + mSearchFilters.engine = Constants.ENGINE_MODRINTH; + mEngineSpinner.setSelection(0); + mEngineSpinner.setEnabled(false); + } else { + mEngineSpinner.setEnabled(true); + for (int i = 0; i < engineValues.length; i++) { + if (engineValues[i] == mSearchFilters.engine) { + mEngineSpinner.setSelection(i); + break; + } + } + } + } + // Set up loader spinner + final String[] loaderValues = {"", "fabric", "forge", "quilt", "neoforge"}; if (mLoaderSpinner != null) { String[] loaderLabels = {getString(R.string.search_mod_any_loader), "Fabric", "Forge", "Quilt", "NeoForge"}; - final String[] loaderValues = {"", "fabric", "forge", "quilt", "neoforge"}; android.widget.ArrayAdapter loaderAdapter = new android.widget.ArrayAdapter<>( requireContext(), android.R.layout.simple_spinner_item, loaderLabels); loaderAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); @@ -227,34 +265,24 @@ private void displayFilterDialog() { break; } } + } - mSelectVersionButton.setOnClickListener(v -> - VersionSelectorDialog.open(v.getContext(), true, - (id, snapshot) -> mSelectedVersion.setText(id))); - - mSelectedVersion.setText(mSearchFilters.mcVersion); - - mApplyButton.setOnClickListener(v -> { - mSearchFilters.mcVersion = mSelectedVersion.getText().toString(); - int pos = mLoaderSpinner.getSelectedItemPosition(); - mSearchFilters.modLoader = loaderValues[pos]; - searchMods(mSearchEditText.getText().toString()); - dialogInterface.dismiss(); - }); - } else { - // Fallback if spinner view not found - mSelectVersionButton.setOnClickListener(v -> - VersionSelectorDialog.open(v.getContext(), true, - (id, snapshot) -> mSelectedVersion.setText(id))); - - mSelectedVersion.setText(mSearchFilters.mcVersion); + mSelectVersionButton.setOnClickListener(v -> + VersionSelectorDialog.open(v.getContext(), true, + (id, snapshot) -> mSelectedVersion.setText(id))); + mSelectedVersion.setText(mSearchFilters.mcVersion); - mApplyButton.setOnClickListener(v -> { - mSearchFilters.mcVersion = mSelectedVersion.getText().toString(); - searchMods(mSearchEditText.getText().toString()); - dialogInterface.dismiss(); - }); - } + mApplyButton.setOnClickListener(v -> { + if (mEngineSpinner != null) { + mSearchFilters.engine = engineValues[mEngineSpinner.getSelectedItemPosition()]; + } + if (mLoaderSpinner != null) { + mSearchFilters.modLoader = loaderValues[mLoaderSpinner.getSelectedItemPosition()]; + } + mSearchFilters.mcVersion = mSelectedVersion.getText().toString(); + searchMods(mSearchEditText.getText().toString()); + dialogInterface.dismiss(); + }); }); dialog.show(); @@ -268,12 +296,16 @@ private static class ModsInstallApi extends CommonApi { private final ModrinthApi mModrinthApi = new ModrinthApi(); private final Handler mMainHandler = new Handler(Looper.getMainLooper()); private Context mActivityContext; + @Nullable private final net.kdt.pojavlaunch.modloaders.modpacks.api.CurseforgeApi mCurseforgeApi; - ModsInstallApi(String curseforgeApiKey, SearchFilters filters) { - super(curseforgeApiKey); + /** @param curseforgeApiKey null when CurseForge is disabled in experimental settings */ + ModsInstallApi(@Nullable String curseforgeApiKey, SearchFilters filters) { + super(curseforgeApiKey != null ? curseforgeApiKey : "", curseforgeApiKey == null); mFilters = filters; - mCurseforgeApi = new net.kdt.pojavlaunch.modloaders.modpacks.api.CurseforgeApi(curseforgeApiKey); + mCurseforgeApi = curseforgeApiKey != null + ? new net.kdt.pojavlaunch.modloaders.modpacks.api.CurseforgeApi(curseforgeApiKey) + : null; } /** @@ -293,9 +325,12 @@ public ModDetail getModDetails(ModItem item) { ? mFilters.modLoader : null; detail = mModrinthApi.getModDetails(item, filterVer, filterLoader); } else if (item.apiSource == net.kdt.pojavlaunch.modloaders.modpacks.models.Constants.SOURCE_CURSEFORGE) { + if (mCurseforgeApi == null) return null; // disabled in experimental settings String filterVer = (mFilters.mcVersion != null && !mFilters.mcVersion.isEmpty()) ? mFilters.mcVersion : null; - detail = mCurseforgeApi.getModDetails(item, filterVer); + String filterLoader = (mFilters.modLoader != null && !mFilters.modLoader.isEmpty()) + ? mFilters.modLoader : null; + detail = mCurseforgeApi.getModDetails(item, filterVer, filterLoader); } else { detail = super.getModDetails(item); } @@ -415,6 +450,36 @@ public void handleInstallation(Context context, ModDetail modDetail, int selecte return; } + // Drop any dependency that's already sitting in the mods folder (as any + // version, not just the one this mod happens to ask for) — otherwise every + // reinstall/update of a mod with common dependencies (Fabric API, Cloth + // Config, etc.) nags you to "install" something you already have. + PojavApplication.sExecutorService.execute(() -> { + java.util.Set installedProjectIds = getInstalledModrinthProjectIds(); + List remainingIds = new ArrayList<>(); + List remainingTypes = new ArrayList<>(); + for (int i = 0; i < depIds.length; i++) { + if (depIds[i] != null && installedProjectIds.contains(depIds[i])) continue; + remainingIds.add(depIds[i]); + remainingTypes.add((depTypes != null && i < depTypes.length) ? depTypes[i] : "required"); + } + + if (remainingIds.isEmpty()) { + // Every dependency is already installed — no need to prompt at all + mMainHandler.post(() -> + downloadMod(context, url, fileName, new String[0], new String[0], oldFilePath)); + return; + } + + mMainHandler.post(() -> promptForDependencies(context, url, fileName, + remainingIds.toArray(new String[0]), remainingTypes.toArray(new String[0]), oldFilePath)); + }); + } + + /** Fetches display names for the (already filtered to not-yet-installed) dependency + * list, then shows the install dialog once every name has resolved. */ + private void promptForDependencies(Context context, String url, String fileName, + String[] depIds, String[] depTypes, String oldFilePath) { // Fetch project names for all deps, then show dialog String[] labels = new String[depIds.length]; final boolean[] checkedDefaults = new boolean[depIds.length]; @@ -537,6 +602,55 @@ private void downloadDependency(String projectId, File modsDir) { } } + /** + * Hashes every jar currently in the mods folder and bulk-resolves them against + * Modrinth's version_files endpoint to find which Modrinth project each one + * belongs to — used to filter "install dependencies" prompts down to ones that + * aren't already installed under some other version/file name. + */ + 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"))); + if (files == null || files.length == 0) return projectIds; + + List hashes = new ArrayList<>(); + for (File f : files) { + String hash = sha1Hex(f); + if (hash != null) hashes.add(hash); + } + if (hashes.isEmpty()) return projectIds; + + try { + com.google.gson.JsonObject body = new com.google.gson.JsonObject(); + com.google.gson.JsonArray hashArray = new com.google.gson.JsonArray(); + for (String hash : hashes) hashArray.add(hash); + body.add("hashes", hashArray); + body.addProperty("algorithm", "sha1"); + + java.util.Map headers = new java.util.HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Accept", "application/json"); + + String responseRaw = net.kdt.pojavlaunch.modloaders.modpacks.api.ApiHandler.postRaw( + headers, "https://api.modrinth.com/v2/version_files", body.toString()); + if (responseRaw == null) return projectIds; + + com.google.gson.JsonObject response = com.google.gson.JsonParser.parseString(responseRaw).getAsJsonObject(); + for (java.util.Map.Entry entry : response.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + com.google.gson.JsonObject version = entry.getValue().getAsJsonObject(); + if (version.has("project_id") && !version.get("project_id").isJsonNull()) { + projectIds.add(version.get("project_id").getAsString()); + } + } + } catch (Exception e) { + Log.w(TAG, "Failed to bulk-resolve installed mod hashes: " + e.getMessage()); + } + return projectIds; + } + private String fetchProjectName(String projectId) { try { net.kdt.pojavlaunch.modloaders.modpacks.api.ApiHandler handler = diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java index 59fb74d8b9..c71ab2460f 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java @@ -67,7 +67,10 @@ public SearchModFragment(){ @Override public void onAttach(@NonNull Context context) { super.onAttach(context); - modpackApi = new ModpackSearchApi(context.getString(R.string.curseforge_api_key), mSearchFilters); + boolean disableCurseforge = net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_DISABLE_CURSEFORGE_API; + String curseforgeApiKey = disableCurseforge + ? "" : net.kdt.pojavlaunch.prefs.LauncherPreferences.resolveCurseforgeApiKey(context); + modpackApi = new ModpackSearchApi(curseforgeApiKey, disableCurseforge, mSearchFilters); } @Override @@ -155,15 +158,51 @@ private void displayFilterDialog() { Button mSelectVersionButton = dialog.findViewById(R.id.search_mod_mc_version_button); Button mApplyButton = dialog.findViewById(R.id.search_mod_apply_filters); Spinner mLoaderSpinner = dialog.findViewById(R.id.search_mod_loader_spinner); + Spinner mEngineSpinner = dialog.findViewById(R.id.search_mod_engine_spinner); assert mSelectVersionButton != null; assert mSelectedVersion != null; assert mApplyButton != null; + // Set up the "Modrinth / CurseForge / Both" engine picker. If CurseForge is + // disabled in experimental settings, only Modrinth is offered and the filter + // is pinned to it, since a CurseForge-only or Both search would otherwise + // silently return nothing. + boolean curseforgeDisabled = net.kdt.pojavlaunch.prefs.LauncherPreferences.DEFAULT_PREF + .getBoolean("disableCurseforgeApi", false); + final int[] engineValues = curseforgeDisabled + ? new int[]{Constants.ENGINE_MODRINTH} + : new int[]{Constants.ENGINE_MODRINTH, Constants.ENGINE_CURSEFORGE, Constants.ENGINE_BOTH}; + if (mEngineSpinner != null) { + String[] engineLabels = curseforgeDisabled + ? new String[]{getString(R.string.search_mod_engine_modrinth)} + : new String[]{getString(R.string.search_mod_engine_modrinth), + getString(R.string.search_mod_engine_curseforge), + getString(R.string.search_mod_engine_both)}; + ArrayAdapter engineAdapter = new ArrayAdapter<>( + requireContext(), android.R.layout.simple_spinner_item, engineLabels); + engineAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + mEngineSpinner.setAdapter(engineAdapter); + + if (curseforgeDisabled) { + mSearchFilters.engine = Constants.ENGINE_MODRINTH; + mEngineSpinner.setSelection(0); + mEngineSpinner.setEnabled(false); + } else { + mEngineSpinner.setEnabled(true); + for (int i = 0; i < engineValues.length; i++) { + if (engineValues[i] == mSearchFilters.engine) { + mEngineSpinner.setSelection(i); + break; + } + } + } + } + // Set up loader spinner + final String[] loaderValues = {"", "fabric", "forge", "quilt", "neoforge"}; if (mLoaderSpinner != null) { String[] loaderLabels = {"Any loader", "Fabric", "Forge", "Quilt", "NeoForge"}; - final String[] loaderValues = {"", "fabric", "forge", "quilt", "neoforge"}; ArrayAdapter loaderAdapter = new ArrayAdapter<>( requireContext(), android.R.layout.simple_spinner_item, loaderLabels); loaderAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); @@ -177,31 +216,24 @@ private void displayFilterDialog() { break; } } - - mSelectVersionButton.setOnClickListener(v -> - VersionSelectorDialog.open(v.getContext(), true, - (id, snapshot) -> mSelectedVersion.setText(id))); - - mSelectedVersion.setText(mSearchFilters.mcVersion); - - mApplyButton.setOnClickListener(v -> { - mSearchFilters.mcVersion = mSelectedVersion.getText().toString(); - int pos = mLoaderSpinner.getSelectedItemPosition(); - mSearchFilters.modLoader = loaderValues[pos]; - searchMods(mSearchEditText.getText().toString()); - dialogInterface.dismiss(); - }); - } else { - mSelectVersionButton.setOnClickListener(v -> - VersionSelectorDialog.open(v.getContext(), true, - (id, snapshot) -> mSelectedVersion.setText(id))); - mSelectedVersion.setText(mSearchFilters.mcVersion); - mApplyButton.setOnClickListener(v -> { - mSearchFilters.mcVersion = mSelectedVersion.getText().toString(); - searchMods(mSearchEditText.getText().toString()); - dialogInterface.dismiss(); - }); } + + mSelectVersionButton.setOnClickListener(v -> + VersionSelectorDialog.open(v.getContext(), true, + (id, snapshot) -> mSelectedVersion.setText(id))); + mSelectedVersion.setText(mSearchFilters.mcVersion); + + mApplyButton.setOnClickListener(v -> { + if (mEngineSpinner != null) { + mSearchFilters.engine = engineValues[mEngineSpinner.getSelectedItemPosition()]; + } + if (mLoaderSpinner != null) { + mSearchFilters.modLoader = loaderValues[mLoaderSpinner.getSelectedItemPosition()]; + } + mSearchFilters.mcVersion = mSelectedVersion.getText().toString(); + searchMods(mSearchEditText.getText().toString()); + dialogInterface.dismiss(); + }); }); dialog.show(); @@ -213,8 +245,8 @@ private static class ModpackSearchApi extends CommonApi { private final SearchFilters mFilters; private final ModrinthApi mModrinthApi = new ModrinthApi(); - ModpackSearchApi(String curseforgeApiKey, SearchFilters filters) { - super(curseforgeApiKey); + ModpackSearchApi(String curseforgeApiKey, boolean disableCurseforge, SearchFilters filters) { + super(curseforgeApiKey, disableCurseforge); mFilters = filters; } 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 a08a71092b..d5bc416463 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 @@ -118,7 +118,8 @@ public interface EmptyStateListener { public InstalledModAdapter(Context context, File modsDir, EmptyStateListener listener) { mContext = context.getApplicationContext(); - mCurseforgeApiKey = context.getString(R.string.curseforge_api_key); + 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() && @@ -279,7 +280,7 @@ private void checkUpdateForEntry(ModEntry entry) throws Exception { /** Downloads the update, replaces the existing jar, refreshes the entry. */ private void applyUpdate(Context context, ModEntry entry, int position) { - if (entry.updateUrl == null) return; + if (entry.updateUrl == null || entry.isUpdating) return; String updateUrl = entry.updateUrl; String updateName = entry.updateFileName; @@ -288,26 +289,31 @@ private void applyUpdate(Context context, ModEntry entry, int position) { boolean wasDisabled = entry.file.getName().endsWith(".disabled"); String targetName = wasDisabled ? updateName + ".disabled" : updateName; File targetFile = new File(entry.file.getParent(), targetName); + final File oldFile = entry.file; Toast.makeText(context, context.getString(R.string.mod_updating, entry.displayName()), Toast.LENGTH_SHORT).show(); + // Flip the spinner on immediately so the update button disappears + // before the download even starts. + entry.isUpdating = true; + if (position < mMods.size()) notifyItemChanged(position); + sUpdateCheckExecutor.execute(() -> { try { // Download to a temp file first so we never leave a half-written jar File tmpFile = new File(entry.file.getParent(), targetName + ".tmp"); DownloadUtils.downloadFile(updateUrl, tmpFile); - // Delete old file, rename temp to final - entry.file.delete(); - tmpFile.renameTo(targetFile); + replaceModFile(oldFile, tmpFile, targetFile); mMainHandler.post(() -> { entry.file = targetFile; entry.enabled = !wasDisabled; entry.updateUrl = null; entry.updateFileName = null; + entry.isUpdating = false; if (position < mMods.size()) notifyItemChanged(position); Toast.makeText(context, context.getString(R.string.mod_update_done, entry.displayName()), @@ -315,14 +321,74 @@ private void applyUpdate(Context context, ModEntry entry, int position) { }); } catch (Exception e) { Log.e(TAG, "Update download failed: " + e.getMessage()); - mMainHandler.post(() -> - Toast.makeText(context, - context.getString(R.string.mod_update_failed, entry.displayName()), - Toast.LENGTH_SHORT).show()); + mMainHandler.post(() -> { + entry.isUpdating = false; + if (position < mMods.size()) notifyItemChanged(position); + Toast.makeText(context, + context.getString(R.string.mod_update_failed, entry.displayName()), + Toast.LENGTH_SHORT).show(); + }); } }); } + /** + * Replaces {@code oldFile} with the freshly downloaded {@code tmpFile}, ending + * up at {@code targetFile}. Handles both cases: + *
    + *
  • Same file name (old == target): {@code renameTo} atomically replaces + * the destination on most Android filesystems, so this always works + * even if deleting the old file first happens to fail.
  • + *
  • Different file name (old != target): these are two independent + * filesystem entries, so the old one needs an explicit, verified + * delete — otherwise a transient failure (e.g. the jar being briefly + * held open by an icon/name-resolution read on another thread) leaves + * it behind and it shows up as a duplicate mod next time the list is + * rescanned.
  • + *
+ * Old-file deletion is retried a few times with a short backoff before + * giving up, since these locks are normally released within milliseconds. + */ + private static void replaceModFile(File oldFile, File tmpFile, File targetFile) { + boolean sameName = oldFile.getAbsolutePath().equals(targetFile.getAbsolutePath()); + + if (sameName) { + // renameTo() onto an existing path replaces it atomically, so the + // old file is gone the moment this succeeds — no separate delete needed. + if (!tmpFile.renameTo(targetFile)) { + Log.w(TAG, "Failed to rename downloaded update into place: " + targetFile); + } + return; + } + + // Different names: rename the new file into place first, then clean up + // the old one — never leave the mods folder without a working jar even + // if the delete step below has trouble. + if (!tmpFile.renameTo(targetFile)) { + Log.w(TAG, "Failed to rename downloaded update into place: " + targetFile); + return; + } + + if (!deleteWithRetry(oldFile)) { + Log.w(TAG, "Failed to delete superseded jar, it will linger as a duplicate: " + oldFile); + } + } + + /** Deletes a file, retrying briefly if it's transiently locked by another thread. */ + private static boolean deleteWithRetry(File file) { + if (!file.exists()) return true; + for (int attempt = 0; attempt < 5; attempt++) { + if (file.delete() || !file.exists()) return true; + try { + Thread.sleep(60L * (attempt + 1)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + return !file.exists(); + } + // ── Switch version ─────────────────────────────────────────────────────── /** @@ -524,8 +590,7 @@ private void applySwitchVersion(Context context, ModEntry entry, int position, File tmpFile = new File(entry.file.getParent(), targetName + ".tmp"); DownloadUtils.downloadFile(url, tmpFile); - oldFile.delete(); - tmpFile.renameTo(targetFile); + replaceModFile(oldFile, tmpFile, targetFile); mMainHandler.post(() -> { entry.file = targetFile; @@ -740,7 +805,8 @@ class ModViewHolder extends RecyclerView.ViewHolder { final ImageView icon; final TextView name, version; final SwitchCompat toggle; - final android.widget.Button update; + final ImageButton update; + final ProgressBar updateProgress; final ImageButton delete; final ImageButton switchVersion; @@ -751,6 +817,7 @@ class ModViewHolder extends RecyclerView.ViewHolder { version= itemView.findViewById(R.id.installed_mod_version); toggle = itemView.findViewById(R.id.installed_mod_toggle); update = itemView.findViewById(R.id.installed_mod_update); + updateProgress = itemView.findViewById(R.id.installed_mod_update_progress); delete = itemView.findViewById(R.id.installed_mod_delete); switchVersion = itemView.findViewById(R.id.installed_mod_switch_version); } @@ -803,14 +870,22 @@ void bind(ModEntry entry) { toggle.setChecked(entry.enabled); toggle.setOnCheckedChangeListener((btn, checked) -> entry.setEnabled(checked)); - // Update button — visible only when an update is available - if (entry.updateUrl != null) { + // Update button — visible only when an update is available and not + // currently being downloaded. While downloading, the spinner takes + // its place instead. + if (entry.isUpdating) { + update.setVisibility(View.GONE); + update.setOnClickListener(null); + updateProgress.setVisibility(View.VISIBLE); + } else if (entry.updateUrl != null) { + updateProgress.setVisibility(View.GONE); update.setVisibility(View.VISIBLE); update.setOnClickListener(v -> { int pos = getBindingAdapterPosition(); if (pos != RecyclerView.NO_POSITION) applyUpdate(v.getContext(), entry, pos); }); } else { + updateProgress.setVisibility(View.GONE); update.setVisibility(View.GONE); update.setOnClickListener(null); } @@ -846,6 +921,9 @@ static class ModEntry { boolean enabled; @Nullable String updateUrl; @Nullable String updateFileName; + // True while a downloaded update jar is being fetched/applied for this + // mod — drives the per-row spinner that replaces the update button. + boolean isUpdating; // Friendly name read out of the jar's own metadata (fabric.mod.json, // mods.toml, etc.) once resolved. Null/empty until resolved or if // the jar simply has no name field — displayName() falls back to diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CommonApi.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CommonApi.java index 7383e2c2b3..9e3ff406f5 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CommonApi.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CommonApi.java @@ -36,11 +36,43 @@ public class CommonApi implements ModpackApi { private final ModpackApi mCurseforgeApi; private final ModpackApi mModrinthApi; private final ModpackApi[] mModpackApis; + /** Parallel to mModpackApis — which Constants.SOURCE_* each entry is, for engine filtering. */ + private final int[] mModpackApiSources; public CommonApi(String curseforgeApiKey) { + this(curseforgeApiKey, false); + } + + /** + * @param disableCurseforge when true, CurseForge is excluded from + * {@link #searchMod}'s result fan-out entirely — + * used by the "Disable CurseForge" experimental + * setting, since its API can be noticeably slower + * than Modrinth's. The instance is still created + * (cheap, no network) so {@link #getModpackApi} + * keeps working for things like importing an + * existing CurseForge modpack zip. + */ + public CommonApi(String curseforgeApiKey, boolean disableCurseforge) { mCurseforgeApi = new CurseforgeApi(curseforgeApiKey); mModrinthApi = new ModrinthApi(); - mModpackApis = new ModpackApi[]{mModrinthApi, mCurseforgeApi}; + if (disableCurseforge) { + mModpackApis = new ModpackApi[]{mModrinthApi}; + mModpackApiSources = new int[]{Constants.SOURCE_MODRINTH}; + } else { + mModpackApis = new ModpackApi[]{mModrinthApi, mCurseforgeApi}; + mModpackApiSources = new int[]{Constants.SOURCE_MODRINTH, Constants.SOURCE_CURSEFORGE}; + } + } + + /** Whether the given per-api source should be queried under the requested engine filter. */ + private static boolean isEngineIncluded(int apiSource, int engineFilter) { + switch (engineFilter) { + case Constants.ENGINE_MODRINTH: return apiSource == Constants.SOURCE_MODRINTH; + case Constants.ENGINE_CURSEFORGE: return apiSource == Constants.SOURCE_CURSEFORGE; + case Constants.ENGINE_BOTH: + default: return true; + } } @Override @@ -54,6 +86,8 @@ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previous Future[] futures = new Future[mModpackApis.length]; for(int i = 0; i < mModpackApis.length; i++) { + // Skip engines the user didn't pick in the search filter dialog (Modrinth / CurseForge / Both) + if(!isEngineIncluded(mModpackApiSources[i], searchFilters.engine)) continue; // If there is an array and its length is zero, this means that we've exhausted the results for this // search query and we don't need to actually do the search if(results[i] != null && results[i].results.length == 0) continue; @@ -225,4 +259,4 @@ public SearchResult call() { class CommonApiSearchResult extends SearchResult { SearchResult[] searchResults = new SearchResult[mModpackApis.length]; } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/CurseforgeApi.java index 908d987eb9..05f4e95e14 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 @@ -128,10 +128,23 @@ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previous @Override public ModDetail getModDetails(ModItem item) { - return getModDetails(item, null); + return getModDetails(item, null, null); } public ModDetail getModDetails(ModItem item, String filterMcVersion) { + return getModDetails(item, filterMcVersion, null); + } + + /** + * @param filterMcVersion only return files tagged with this MC version (e.g. "1.20.1") + * @param filterLoader only return files tagged with this loader (e.g. "fabric", + * "forge", "quilt", "neoforge"). CurseForge doesn't split loader + * into its own field on the file object — it's just another + * string mixed into the same "gameVersions" array as the MC + * version tags (e.g. ["1.20.1", "Fabric", "Client"]) — so without + * this filter, files built for every loader are shown together. + */ + public ModDetail getModDetails(ModItem item, String filterMcVersion, String filterLoader) { // Short-circuit for restricted mods — no point fetching versions if (item.isRestricted) { return new ModDetail(item, @@ -163,6 +176,22 @@ public ModDetail getModDetails(ModItem item, String filterMcVersion) { allModDetails = filtered; } + // Filter by loader if specified. Same "gameVersions" array, just matched + // case-insensitively against the loader name instead of an MC version. + if (filterLoader != null && !filterLoader.isEmpty()) { + ArrayList filtered = new ArrayList<>(); + for (JsonObject v : allModDetails) { + JsonArray gameVersions = v.getAsJsonArray("gameVersions"); + for (JsonElement el : gameVersions) { + if (filterLoader.equalsIgnoreCase(el.getAsString())) { + filtered.add(v); + break; + } + } + } + allModDetails = filtered; + } + int length = allModDetails.size(); // Check if ALL versions have null downloadUrl (fully restricted mod) diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/Constants.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/Constants.java index b628a2ae67..08e361ce7f 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/Constants.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/Constants.java @@ -8,9 +8,19 @@ private Constants(){} public static final int SOURCE_CURSEFORGE = 0x1; public static final int SOURCE_TECHNIC = 0x2; + /** + * Which search engine(s) to actually query — set via the "Source" picker + * in the search filter dialog. Distinct from SOURCE_MODRINTH/SOURCE_CURSEFORGE + * above, which tag where a given *result* came from; these instead say + * which engines should be asked in the first place. + */ + public static final int ENGINE_MODRINTH = 0x0; + public static final int ENGINE_CURSEFORGE = 0x1; + public static final int ENGINE_BOTH = 0x2; + /** Modrinth api, file environments */ public static final String MODRINTH_FILE_ENV_REQUIRED = "required"; public static final String MODRINTH_FILE_ENV_OPTIONAL = "optional"; public static final String MODRINTH_FILE_ENV_UNSUPPORTED = "unsupported"; -} +} \ No newline at end of file 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 e61bdca934..9b79392669 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 @@ -11,5 +11,12 @@ public class SearchFilters { @Nullable public String mcVersion; /** Mod loader filter: "fabric", "forge", "quilt", "neoforge", or null/empty for any */ @Nullable public String modLoader; + /** + * Which search engine(s) to query: {@link Constants#ENGINE_MODRINTH}, + * {@link Constants#ENGINE_CURSEFORGE}, or {@link Constants#ENGINE_BOTH}. + * Defaults to Modrinth only — CurseForge's API is noticeably slower, so + * it's opt-in per search rather than queried by default. + */ + public int engine = Constants.ENGINE_MODRINTH; } \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/LauncherPreferences.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/LauncherPreferences.java index 1df08d83e1..8645a8b83a 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/LauncherPreferences.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/LauncherPreferences.java @@ -77,6 +77,14 @@ public class LauncherPreferences { public static boolean PREF_MOUSE_GRAB_FORCE = false; public static boolean PREF_KEYBOARD_PANNING = true; + /** Experimental: skip CurseForge entirely (search, version lookups, icon + * fallback) since its API can be noticeably slower than Modrinth's. */ + public static boolean PREF_DISABLE_CURSEFORGE_API = false; + /** Experimental: user-supplied CurseForge API key, used instead of the + * bundled one when non-empty — a personal key isn't shared with every + * other install of the app, so it isn't rate-limited by them either. */ + public static String PREF_CURSEFORGE_API_KEY_OVERRIDE = ""; + public static void loadPreferences(Context ctx) { //Required for CTRLDEF_FILE and MultiRT @@ -123,6 +131,8 @@ public static void loadPreferences(Context ctx) { PREF_TOUCHCONTROLLER_VIBRATE_LENGTH = DEFAULT_PREF.getInt("touchControllerVibrateLength", 100); PREF_MOUSE_GRAB_FORCE = DEFAULT_PREF.getBoolean("always_grab_mouse", false); PREF_KEYBOARD_PANNING = DEFAULT_PREF.getBoolean("keyboardPanning", true); + PREF_DISABLE_CURSEFORGE_API = DEFAULT_PREF.getBoolean("disableCurseforgeApi", false); + PREF_CURSEFORGE_API_KEY_OVERRIDE = DEFAULT_PREF.getString("curseforgeApiKeyOverride", ""); String argLwjglLibname = "-Dorg.lwjgl.opengl.libname="; for (String arg : JREUtils.parseJavaArguments(PREF_CUSTOM_JAVA_ARGS)) { @@ -265,4 +275,18 @@ public static void writeMGRendererSettings() throws IOException { throw new RuntimeException(e); } } + + /** + * Resolves the CurseForge API key to actually use: the user's own key from + * the experimental settings if they've set one, otherwise the bundled + * default. Doesn't take PREF_DISABLE_CURSEFORGE_API into account — callers + * that can skip CurseForge entirely should check that flag themselves + * before bothering to resolve a key at all. + */ + public static String resolveCurseforgeApiKey(Context ctx) { + if (PREF_CURSEFORGE_API_KEY_OVERRIDE != null && !PREF_CURSEFORGE_API_KEY_OVERRIDE.trim().isEmpty()) { + return PREF_CURSEFORGE_API_KEY_OVERRIDE.trim(); + } + return ctx.getString(R.string.curseforge_api_key); + } } \ No newline at end of file diff --git a/app_pojavlauncher/src/main/res/layout/dialog_mod_filters.xml b/app_pojavlauncher/src/main/res/layout/dialog_mod_filters.xml index 6b39f135eb..6b01f19b7e 100644 --- a/app_pojavlauncher/src/main/res/layout/dialog_mod_filters.xml +++ b/app_pojavlauncher/src/main/res/layout/dialog_mod_filters.xml @@ -9,13 +9,33 @@ android:paddingTop="@dimen/padding_heavy" > + + + + + + + + - -
Name Version + Search source + Modrinth + CurseForge + Both Delete Java Runtime Renderer @@ -352,6 +356,10 @@ Force the renderer to run on the big core Forces the Minecraft render thread to run on the core with the highest maximum frequency. + Disable CurseForge + Skip CurseForge entirely when searching or checking for mod updates — only Modrinth will be used. CurseForge\'s API can be noticeably slower. + Your own CurseForge API key + Optional. Uses the built-in key by default, which is shared across every install of the app and can get rate-limited. Get your own key at console.curseforge.com for faster, more reliable responses. Force landscape orientation Always rotate the launcher to landscape, regardless of device orientation. Launcher appearance diff --git a/app_pojavlauncher/src/main/res/xml/pref_experimental.xml b/app_pojavlauncher/src/main/res/xml/pref_experimental.xml index 4697c4b785..3d3a01f92e 100644 --- a/app_pojavlauncher/src/main/res/xml/pref_experimental.xml +++ b/app_pojavlauncher/src/main/res/xml/pref_experimental.xml @@ -15,5 +15,18 @@ android:title="@string/preference_force_big_core_title" android:summary="@string/preference_force_big_core_desc" /> + + + + \ No newline at end of file