Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app_pojavlauncher/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ android {
// multiDexEnabled = true
// debuggable = true
resValue "string", "app_name", "Copper"
resValue "string", "app_short_name", "Copper (Debug)"
resValue "string", "app_short_name", "Copper"
resValue 'string', 'storageProviderAuthorities', 'com.maxjubayeryt.copper.gamefolder'
resValue 'string', 'application_package', 'com.maxjubayeryt.copper'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import android.content.res.ColorStateList;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.widget.Toast;
import android.view.View;
Expand All @@ -29,9 +31,10 @@
import net.kdt.pojavlaunch.modloaders.modpacks.ModItemAdapter;
import net.kdt.pojavlaunch.modloaders.modpacks.api.CommonApi;
import net.kdt.pojavlaunch.modloaders.modpacks.api.ModpackApi;
import net.kdt.pojavlaunch.modloaders.modpacks.api.ModrinthApi;
import net.kdt.pojavlaunch.modloaders.modpacks.models.ModDetail;
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.prefs.LauncherPreferences;
import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper;
import net.kdt.pojavlaunch.profiles.VersionSelectorDialog;
Expand All @@ -41,10 +44,14 @@

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

/**
* Searches and installs individual mods (not modpacks) into the current instance's mods folder.
* Auto-detects MC version from the selected profile's lastVersionId.
* Searches and installs individual mods into the current instance's mods folder.
* - Version filter: when an MC version is selected, only versions matching it are shown.
* - Dependency dialog: shown before download, matching the ModBundle UI.
*/
public class ModsSearchFragment extends Fragment implements ModItemAdapter.SearchResultCallback {

Expand Down Expand Up @@ -74,15 +81,14 @@ public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
public ModsSearchFragment() {
super(R.layout.fragment_mod_search);
mSearchFilters = new SearchFilters();
mSearchFilters.isModpack = false; // individual mods, not modpacks
mSearchFilters.isModpack = false;
}

@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
// Wrap CommonApi so installation goes to mods folder instead of creating a new instance
mModpackApi = new ModsInstallApi(context.getString(R.string.curseforge_api_key));
// Auto-detect MC version from current profile
mModpackApi = new ModsInstallApi(context.getString(R.string.curseforge_api_key), mSearchFilters);
((ModsInstallApi) mModpackApi).mActivityContext = context;
}

@Override
Expand Down Expand Up @@ -120,10 +126,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
});

mFilterButton.setOnClickListener(v -> displayFilterDialog());

// Override hint — the layout uses fragment_mod_search which says "Search for modpacks"
mSearchEditText.setHint(R.string.hint_search_mod);

searchMods(null);
}

Expand Down Expand Up @@ -192,26 +195,37 @@ private void displayFilterDialog() {
dialog.show();
}

// ── Helpers ──────────────────────────────────────────────────────────────

/** Parse MC version from lastVersionId like "fabric-loader-0.16.14-1.21.4" → "1.21.4" or "1.21.4" → "1.21.4" */

// ── ModsInstallApi ───────────────────────────────────────────────────────
// ── ModsInstallApi ────────────────────────────────────────────────────────

/**
* Wraps CommonApi and overrides handleInstallation to download the mod JAR
* directly into the current instance's mods/ folder instead of installing a modpack.
*/
private static class ModsInstallApi extends CommonApi {

ModsInstallApi(String curseforgeApiKey) {
private final SearchFilters mFilters;
private final ModrinthApi mModrinthApi = new ModrinthApi();
private final Handler mMainHandler = new Handler(Looper.getMainLooper());
private Context mActivityContext;

ModsInstallApi(String curseforgeApiKey, SearchFilters filters) {
super(curseforgeApiKey);
mFilters = filters;
}

/**
* Override getModDetails to filter versions by the selected MC version.
* Only versions matching the filter are shown in the version dropdown.
*/
@Override
public ModDetail getModDetails(ModItem item) {
if (item.apiSource == net.kdt.pojavlaunch.modloaders.modpacks.models.Constants.SOURCE_MODRINTH) {
String filterVer = (mFilters.mcVersion != null && !mFilters.mcVersion.isEmpty())
? mFilters.mcVersion : null;
return mModrinthApi.getModDetails(item, filterVer);
}
return super.getModDetails(item);
}

@Override
public void handleInstallation(Context context, ModDetail modDetail, int selectedVersion) {
if (modDetail.isModpack) {
// Fall back to normal modpack install
super.handleInstallation(context, modDetail, selectedVersion);
return;
}
Expand All @@ -223,47 +237,144 @@ public void handleInstallation(Context context, ModDetail modDetail, int selecte
return;
}

// Determine file name from URL
String fileName = url.substring(url.lastIndexOf('/') + 1);
if (!fileName.endsWith(".jar")) fileName += ".jar";
// Strip query params if present
if (fileName.contains("?")) fileName = fileName.substring(0, fileName.indexOf('?'));
// 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";

// Check if this version has dependencies
String[] depIds = (modDetail.versionDependencyIds != null) ? modDetail.versionDependencyIds[selectedVersion] : null;
String[] depTypes = (modDetail.versionDependencyTypes != null) ? modDetail.versionDependencyTypes[selectedVersion] : null;

if (depIds == null || depIds.length == 0) {
// No deps — download directly
downloadMod(context, url, fileName, new String[0], new String[0]);
return;
}

// Fetch project names for all deps, then show dialog
String[] labels = new String[depIds.length];
final boolean[] checkedDefaults = new boolean[depIds.length];
AtomicInteger remaining = new AtomicInteger(depIds.length);

for (int i = 0; i < depIds.length; i++) {
final int idx = i;
final String type = (depTypes != null && idx < depTypes.length) ? depTypes[idx] : "required";
final String prefix = "required".equals(type) ? "Required: " : "Optional: ";
checkedDefaults[idx] = "required".equals(type);

final String projectId = depIds[idx];
PojavApplication.sExecutorService.execute(() -> {
// Fetch project name from Modrinth
String name = fetchProjectName(projectId);
labels[idx] = prefix + (name != null ? name : projectId);
if (remaining.decrementAndGet() == 0) {
mMainHandler.post(() -> showDepsDialog(context, url, fileName,
depIds, depTypes, labels, checkedDefaults));
}
});
}
}

private void showDepsDialog(Context context, String url, String fileName,
String[] depIds, String[] depTypes,
String[] labels, boolean[] checkedDefaults) {
// context here is getApplicationContext() from ModItemAdapter — no window token.
// Use the stored Activity reference instead.
Context dialogCtx = mActivityContext != null ? mActivityContext : context;
boolean[] selected = checkedDefaults.clone();

new AlertDialog.Builder(dialogCtx)
.setTitle(R.string.mod_deps_title)
.setMultiChoiceItems(labels, selected,
(dialog, which, isChecked) -> selected[which] = isChecked)
.setPositiveButton(R.string.mod_deps_install_selected, (d, w) -> {
List<String> selectedIds = new ArrayList<>();
for (int i = 0; i < depIds.length; i++) {
if (selected[i]) selectedIds.add(depIds[i]);
}
downloadMod(context, url, fileName,
selectedIds.toArray(new String[0]), depTypes);
})
.setNeutralButton(R.string.mod_deps_install_without,
(d, w) -> downloadMod(context, url, fileName, new String[0], new String[0]))
.setNegativeButton(android.R.string.cancel, null)
.show();
}

private void downloadMod(Context context, String url, String fileName,
String[] depIds, String[] depTypes) {
File modsDir = getModsDir();
if (!modsDir.exists()) modsDir.mkdirs();
File destFile = new File(modsDir, fileName);

String finalFileName = fileName;
ProgressLayout.setProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.global_waiting);
PojavApplication.sExecutorService.execute(() -> {
try {
DownloadUtils.downloadFile(url, destFile);
// Download main mod
DownloadUtils.downloadFile(url, new File(modsDir, fileName));

// Download selected dependencies
for (String depId : depIds) {
if (depId == null || depId.isEmpty()) continue;
downloadDependency(depId, modsDir);
}

ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK);
Tools.runOnUiThread(() ->
Toast.makeText(context,
context.getString(R.string.mod_install_success, finalFileName),
Toast.LENGTH_LONG).show()
);
Toast.makeText(context,
context.getString(R.string.mod_install_success, fileName),
Toast.LENGTH_LONG).show());
} catch (Exception e) {
ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK);
Tools.showErrorRemote(context, R.string.modpack_install_download_failed, e);
}
});
}

private void downloadDependency(String projectId, File modsDir) {
// Fetch latest version for the current MC version/loader filter
try {
String filterVer = (mFilters.mcVersion != null && !mFilters.mcVersion.isEmpty())
? mFilters.mcVersion : "";
ModItem depItem = new ModItem(
net.kdt.pojavlaunch.modloaders.modpacks.models.Constants.SOURCE_MODRINTH,
false, projectId, projectId, "", "");
ModDetail depDetail = mModrinthApi.getModDetails(depItem, filterVer.isEmpty() ? null : filterVer);
if (depDetail == null || depDetail.versionUrls == null || depDetail.versionUrls.length == 0) return;

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";

DownloadUtils.downloadFile(depUrl, new File(modsDir, depName));
} catch (Exception e) {
Log.w(TAG, "Failed to download dependency " + projectId + ": " + e.getMessage());
}
}

private String fetchProjectName(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();
} catch (Exception ignored) {}
return null;
}

private static 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), "mods");
}
} catch (Exception ignored) {}
return new File(Tools.DIR_GAME_NEW, "mods");
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,32 +86,73 @@ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previous

@Override
public ModDetail getModDetails(ModItem item) {
return getModDetails(item, null);
}

public ModDetail getModDetails(ModItem item, String filterMcVersion) {
JsonArray response = mApiHandler.get(String.format("project/%s/version", item.id), JsonArray.class);
if(response == null) return null;
System.out.println(response);
String[] names = new String[response.size()];
String[] mcNames = new String[response.size()];
String[] urls = new String[response.size()];
String[] hashes = new String[response.size()];

for (int i=0; i<response.size(); ++i) {
JsonObject version = response.get(i).getAsJsonObject();
names[i] = version.get("name").getAsString();

// Collect versions, optionally filtering by MC version
java.util.List<JsonObject> versions = new java.util.ArrayList<>();
for (int i = 0; i < response.size(); i++) {
JsonObject v = response.get(i).getAsJsonObject();
if (filterMcVersion != null && !filterMcVersion.isEmpty()) {
JsonArray gameVersions = v.get("game_versions").getAsJsonArray();
boolean matches = false;
for (int j = 0; j < gameVersions.size(); j++) {
if (filterMcVersion.equals(gameVersions.get(j).getAsString())) {
matches = true;
break;
}
}
if (!matches) continue;
}
versions.add(v);
}

if (versions.isEmpty()) return null;

int size = versions.size();
String[] names = new String[size];
String[] mcNames = new String[size];
String[] urls = new String[size];
String[] hashes = new String[size];
String[][] depIds = new String[size][];
String[][] depTypes = new String[size][];

for (int i = 0; i < size; i++) {
JsonObject version = versions.get(i);
names[i] = version.get("name").getAsString();
mcNames[i] = version.get("game_versions").getAsJsonArray().get(0).getAsString();
urls[i] = version.get("files").getAsJsonArray().get(0).getAsJsonObject().get("url").getAsString();
// Assume there may not be hashes, in case the API changes
urls[i] = version.get("files").getAsJsonArray().get(0).getAsJsonObject().get("url").getAsString();

JsonObject hashesMap = version.getAsJsonArray("files").get(0).getAsJsonObject()
.get("hashes").getAsJsonObject();
if(hashesMap == null || hashesMap.get("sha1") == null){
hashes[i] = null;
continue;
hashes[i] = (hashesMap == null || hashesMap.get("sha1") == null) ? null
: hashesMap.get("sha1").getAsString();

// Capture dependencies
if (version.has("dependencies") && !version.get("dependencies").isJsonNull()) {
JsonArray deps = version.getAsJsonArray("dependencies");
java.util.List<String> ids = new java.util.ArrayList<>();
java.util.List<String> types = new java.util.ArrayList<>();
for (int j = 0; j < deps.size(); j++) {
JsonObject dep = deps.get(j).getAsJsonObject();
if (dep.has("project_id") && !dep.get("project_id").isJsonNull()) {
ids.add(dep.get("project_id").getAsString());
types.add(dep.has("dependency_type") ? dep.get("dependency_type").getAsString() : "required");
}
}
depIds[i] = ids.toArray(new String[0]);
depTypes[i] = types.toArray(new String[0]);
} else {
depIds[i] = new String[0];
depTypes[i] = new String[0];
}

hashes[i] = hashesMap.get("sha1").getAsString();
}

return new ModDetail(item, names, mcNames, urls, hashes);
return new ModDetail(item, names, mcNames, urls, hashes, depIds, depTypes);
}

@Override
Expand Down
Loading
Loading