diff --git a/README.md b/README.md
index f747be2efa..a430b05014 100644
--- a/README.md
+++ b/README.md
@@ -36,8 +36,8 @@ Copper is a fork of [Amethyst](https://github.com/AngelAuraMC/Amethyst-Android)
## Introduction
-* Copper is a Minecraft: Java Edition launcher for Android based on [Boardwalk](https://github.com/zhuowei/Boardwalk) and [PojavLauncher](https://github.com/PojavLauncherTeam/PojavLauncher)
-* This launcher can launch almost all available Minecraft versions ranging from rd-132211 to 1.21 snapshots (including Combat Test versions)
+* Copper is a Minecraft: Java Edition launcher for Android based on [Boardwalk](https://github.com/zhuowei/Boardwalk), [PojavLauncher](https://github.com/PojavLauncherTeam/PojavLauncher) and [Amethyst Launcher](https://github.com/AngelAuraMC/Amethyst-Android).
+* This launcher can launch almost all available Minecraft versions ranging from rd-132211 to 26.x snapshots (including Combat Test versions)
* Modding via Forge and Fabric are also supported.
* This repository contains source code for Android.
@@ -87,7 +87,9 @@ If you need more control over the build process, follow these steps:
* [x] New UI
* [x] Bug Fixes
-* [ ] Fix GL4ES and KW in older versions
+* [x] Fix GL4ES and KW in older versions
+* [x] Add Modpack export
+* [x] Add mclo.gs
* [ ] Add More Renders
@@ -127,6 +129,7 @@ Copper is licensed under [GNU LGPLv3](https://github.com/CopperLauncher/Copper-A
* [SDL3](https://github.com/libsdl-org/SDL): [zlib License](https://github.com/libsdl-org/SDL/blob/main/LICENSE.txt)
* [sdl2-compat](https://github.com/libsdl-org/sdl2-compat): [zlib License](https://github.com/libsdl-org/sdl2-compat/blob/main/LICENSE.txt)
* Thanks to [MCHeads](https://mc-heads.net) for providing Minecraft avatars.
+* Thanks to [Modrinth](https://api.modrinth.com/), [CurseForge](https://docs.curseforge.com/rest-api/) and [McLo.gs](https://api.mclo.gs) for providing us the free API's.
## Roadmap
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileEditorFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileEditorFragment.java
index 1f86c62b54..feb159f673 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileEditorFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ProfileEditorFragment.java
@@ -27,6 +27,7 @@
import net.kdt.pojavlaunch.fragments.MainMenuFragment;
import net.kdt.pojavlaunch.extra.ExtraConstants;
import net.kdt.pojavlaunch.extra.ExtraCore;
+import net.kdt.pojavlaunch.modloaders.modpacks.ExportMrpackDialog;
import net.kdt.pojavlaunch.multirt.MultiRTUtils;
import net.kdt.pojavlaunch.multirt.RTSpinnerAdapter;
import net.kdt.pojavlaunch.multirt.Runtime;
@@ -53,7 +54,7 @@ public class ProfileEditorFragment extends Fragment implements CropperUtils.Crop
private String mProfileKey;
private MinecraftProfile mTempProfile = null;
private String mValueToConsume = "";
- private Button mSaveButton, mDeleteButton, mControlSelectButton, mGameDirButton, mVersionSelectButton;
+ private Button mSaveButton, mDeleteButton, mControlSelectButton, mGameDirButton, mVersionSelectButton, mExportButton;
private Spinner mDefaultRuntime, mDefaultRenderer;
private EditText mDefaultName, mDefaultJvmArgument;
private TextView mDefaultPath, mDefaultVersion, mDefaultControl;
@@ -141,6 +142,8 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
// Set up the icon change click listener
mProfileIcon.setOnClickListener(v -> CropperUtils.startCropper(mCropperLauncher));
+ mExportButton.setOnClickListener(v -> ExportMrpackDialog.show(requireActivity(), mTempProfile));
+
loadValues(LauncherPreferences.DEFAULT_PREF.getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, ""), view.getContext());
}
@@ -262,6 +265,7 @@ private void bindViews(@NonNull View view){
mVersionSelectButton = view.findViewById(R.id.vprof_editor_version_button);
mGameDirButton = view.findViewById(R.id.vprof_editor_path_button);
mProfileIcon = view.findViewById(R.id.vprof_editor_profile_icon);
+ mExportButton = view.findViewById(R.id.vprof_editor_export_button);
}
private void save(){
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ExportFileTreeAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ExportFileTreeAdapter.java
new file mode 100644
index 0000000000..e5710c0c48
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ExportFileTreeAdapter.java
@@ -0,0 +1,207 @@
+package net.kdt.pojavlaunch.modloaders.modpacks;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CheckBox;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.kdt.pojavlaunch.R;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Renders an expandable, checkable file/folder tree for the .mrpack export dialog, the same
+ * way the official Modrinth app lets you drill into a folder and deselect individual files
+ * instead of just toggling the whole folder.
+ *
+ * Selection state lives in a single {@code Map} (path relative to the
+ * instance root -> explicit checked state) that is shared with {@code MrpackExporter}: a path
+ * with no explicit entry simply inherits the state of its nearest ancestor that has one. This
+ * means the export step doesn't need this adapter at all — it can recompute the same effective
+ * state by walking the same map.
+ */
+public class ExportFileTreeAdapter extends RecyclerView.Adapter {
+
+ private static class Node {
+ final File file;
+ final String relPath;
+ final boolean isDirectory;
+ final int depth;
+ boolean expanded = false;
+ List children;
+
+ Node(File file, String relPath, int depth) {
+ this.file = file;
+ this.relPath = relPath;
+ this.isDirectory = file.isDirectory();
+ this.depth = depth;
+ }
+ }
+
+ private final List mVisibleNodes = new ArrayList<>();
+ private final Map mOverrides;
+ private final int mIndentPx;
+
+ public ExportFileTreeAdapter(File instanceDir, Map overrides, int indentPx) {
+ mOverrides = overrides;
+ mIndentPx = indentPx;
+ mVisibleNodes.addAll(buildNodeList(instanceDir, "", 0));
+ }
+
+ private static List buildNodeList(File parent, String parentRelPath, int depth) {
+ File[] files = parent.listFiles();
+ List nodes = new ArrayList<>();
+ if (files == null) return nodes;
+
+ List directories = new ArrayList<>();
+ List plainFiles = new ArrayList<>();
+ for (File f : files) {
+ if (f.isDirectory()) directories.add(f);
+ else plainFiles.add(f);
+ }
+ Comparator byNameIgnoreCase = (a, b) -> a.getName().compareToIgnoreCase(b.getName());
+ Collections.sort(directories, byNameIgnoreCase);
+ Collections.sort(plainFiles, byNameIgnoreCase);
+
+ List ordered = new ArrayList<>(directories.size() + plainFiles.size());
+ ordered.addAll(directories);
+ ordered.addAll(plainFiles);
+
+ for (File f : ordered) {
+ String relPath = parentRelPath.isEmpty() ? f.getName() : parentRelPath + "/" + f.getName();
+ nodes.add(new Node(f, relPath, depth));
+ }
+ return nodes;
+ }
+
+ private boolean effectiveChecked(String relPath) {
+ String path = relPath;
+ while (true) {
+ Boolean explicit = mOverrides.get(path);
+ if (explicit != null) return explicit;
+ int lastSlash = path.lastIndexOf('/');
+ if (lastSlash < 0) return false;
+ path = path.substring(0, lastSlash);
+ }
+ }
+
+ private void setCheckedCascading(Node node, boolean checked) {
+ mOverrides.put(node.relPath, checked);
+ // Drop any explicit overrides belonging to already-known descendants so they go back
+ // to inheriting from this node, instead of conflicting with the new state.
+ String prefix = node.relPath + "/";
+ Iterator keys = mOverrides.keySet().iterator();
+ while (keys.hasNext()) {
+ if (keys.next().startsWith(prefix)) keys.remove();
+ }
+ if (node.children != null) {
+ for (Node child : node.children) setCheckedCascading(child, checked);
+ }
+ }
+
+ @NonNull
+ @Override
+ public RowHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View view = LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.item_export_tree_entry, parent, false);
+ return new RowHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull RowHolder holder, int position) {
+ holder.bind(mVisibleNodes.get(position));
+ }
+
+ @Override
+ public int getItemCount() {
+ return mVisibleNodes.size();
+ }
+
+ private void toggleExpand(Node node) {
+ int index = mVisibleNodes.indexOf(node);
+ if (index < 0) return;
+
+ if (node.expanded) {
+ int removeCount = countVisibleDescendants(index, node.depth);
+ for (int i = 0; i < removeCount; i++) mVisibleNodes.remove(index + 1);
+ node.expanded = false;
+ notifyItemChanged(index);
+ if (removeCount > 0) notifyItemRangeRemoved(index + 1, removeCount);
+ } else {
+ if (node.children == null) node.children = buildNodeList(node.file, node.relPath, node.depth + 1);
+ mVisibleNodes.addAll(index + 1, node.children);
+ node.expanded = true;
+ notifyItemChanged(index);
+ if (!node.children.isEmpty()) notifyItemRangeInserted(index + 1, node.children.size());
+ }
+ }
+
+ private int countVisibleDescendants(int index, int depth) {
+ int count = 0;
+ for (int i = index + 1; i < mVisibleNodes.size(); i++) {
+ if (mVisibleNodes.get(i).depth <= depth) break;
+ count++;
+ }
+ return count;
+ }
+
+ private void refreshVisibleDescendants(Node node) {
+ int index = mVisibleNodes.indexOf(node);
+ if (index < 0) return;
+ int count = countVisibleDescendants(index, node.depth);
+ if (count > 0) notifyItemRangeChanged(index + 1, count);
+ }
+
+ class RowHolder extends RecyclerView.ViewHolder {
+ final View indentSpacer;
+ final CheckBox checkBox;
+ final TextView nameView;
+ final ImageView chevron;
+
+ RowHolder(@NonNull View itemView) {
+ super(itemView);
+ indentSpacer = itemView.findViewById(R.id.export_entry_indent);
+ checkBox = itemView.findViewById(R.id.export_entry_checkbox);
+ nameView = itemView.findViewById(R.id.export_entry_name);
+ chevron = itemView.findViewById(R.id.export_entry_chevron);
+ }
+
+ void bind(Node node) {
+ ViewGroup.LayoutParams lp = indentSpacer.getLayoutParams();
+ lp.width = node.depth * mIndentPx;
+ indentSpacer.setLayoutParams(lp);
+
+ nameView.setText(node.isDirectory ? node.file.getName() + "/" : node.file.getName());
+
+ checkBox.setOnCheckedChangeListener(null);
+ checkBox.setChecked(effectiveChecked(node.relPath));
+ checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ setCheckedCascading(node, isChecked);
+ refreshVisibleDescendants(node);
+ });
+
+ if (node.isDirectory) {
+ chevron.setVisibility(View.VISIBLE);
+ chevron.setRotation(node.expanded ? 0f : 180f);
+ View.OnClickListener expandListener = v -> toggleExpand(node);
+ chevron.setOnClickListener(expandListener);
+ itemView.setOnClickListener(expandListener);
+ } else {
+ chevron.setVisibility(View.INVISIBLE);
+ chevron.setOnClickListener(null);
+ itemView.setOnClickListener(null);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ExportMrpackDialog.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ExportMrpackDialog.java
new file mode 100644
index 0000000000..3adf086f00
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ExportMrpackDialog.java
@@ -0,0 +1,167 @@
+package net.kdt.pojavlaunch.modloaders.modpacks;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.ProgressDialog;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.Toast;
+
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.kdt.pojavlaunch.PojavApplication;
+import net.kdt.pojavlaunch.R;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.modloaders.modpacks.api.MrpackExporter;
+import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Drives the "Export as .mrpack" dialog shown from the Edit Instance tab. Lets the user name
+ * the pack, set a version/description, and pick exactly which files get bundled — defaulting
+ * to the same selection the official Modrinth app pre-checks (config/mods/resourcepacks/
+ * shaderpacks, everything else unchecked) — then hands the actual export work off to
+ * {@link MrpackExporter}.
+ */
+public class ExportMrpackDialog {
+
+ private static final Set DEFAULT_CHECKED_TOP_LEVEL = new HashSet<>(Arrays.asList(
+ "config", "mods", "resourcepacks", "shaderpacks"));
+
+ public static void show(Activity activity, MinecraftProfile profile) {
+ File instanceDir = Tools.getGameDirPath(profile);
+ if (!instanceDir.isDirectory()) {
+ Toast.makeText(activity, R.string.export_mrpack_error, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ Map overrides = new HashMap<>();
+ File[] topLevelEntries = instanceDir.listFiles();
+ if (topLevelEntries != null) {
+ for (File entry : topLevelEntries) {
+ boolean defaultChecked = DEFAULT_CHECKED_TOP_LEVEL.contains(entry.getName().toLowerCase(Locale.ROOT));
+ overrides.put(entry.getName(), defaultChecked);
+ }
+ }
+
+ View dialogView = LayoutInflater.from(activity).inflate(R.layout.dialog_export_mrpack, null);
+
+ EditText nameInput = dialogView.findViewById(R.id.export_mrpack_name_input);
+ EditText versionInput = dialogView.findViewById(R.id.export_mrpack_version_input);
+ EditText descriptionInput = dialogView.findViewById(R.id.export_mrpack_description_input);
+
+ String defaultName = (Tools.isValidString(profile.name) && !"New".equalsIgnoreCase(profile.name))
+ ? profile.name.trim() : instanceDir.getName();
+ nameInput.setText(defaultName);
+ versionInput.setText("1.0.0");
+
+ RecyclerView treeRecyclerView = dialogView.findViewById(R.id.export_mrpack_file_tree);
+ treeRecyclerView.setLayoutManager(new LinearLayoutManager(activity));
+ treeRecyclerView.setNestedScrollingEnabled(false);
+ int indentPx = activity.getResources().getDimensionPixelSize(R.dimen._14sdp);
+ treeRecyclerView.setAdapter(new ExportFileTreeAdapter(instanceDir, overrides, indentPx));
+
+ View filesHeader = dialogView.findViewById(R.id.export_mrpack_files_header);
+ ImageView filesChevron = dialogView.findViewById(R.id.export_mrpack_files_chevron);
+ filesHeader.setOnClickListener(v -> {
+ boolean willBeVisible = treeRecyclerView.getVisibility() != View.VISIBLE;
+ treeRecyclerView.setVisibility(willBeVisible ? View.VISIBLE : View.GONE);
+ filesChevron.setRotation(willBeVisible ? 180f : 0f);
+ });
+
+ AlertDialog dialog = new AlertDialog.Builder(activity)
+ .setTitle(R.string.export_mrpack_dialog_title)
+ .setView(dialogView)
+ .create();
+
+ dialogView.findViewById(R.id.export_mrpack_cancel_button).setOnClickListener(v -> dialog.dismiss());
+ dialogView.findViewById(R.id.export_mrpack_confirm_button).setOnClickListener(v -> {
+ String packName = nameInput.getText().toString().trim();
+ if (packName.isEmpty()) {
+ Toast.makeText(activity, R.string.export_mrpack_name_required, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ String packVersion = versionInput.getText().toString().trim();
+ String packDescription = descriptionInput.getText().toString();
+ dialog.dismiss();
+ runExport(activity, profile, instanceDir, packName, packVersion, packDescription, overrides);
+ });
+
+ dialog.show();
+ }
+
+ private static void runExport(Activity activity, MinecraftProfile profile, File instanceDir,
+ String packName, String packVersion, String packDescription,
+ Map overrides) {
+
+ if (MinecraftProfile.LATEST_RELEASE.equals(profile.lastVersionId)
+ || MinecraftProfile.LATEST_SNAPSHOT.equals(profile.lastVersionId)) {
+ Toast.makeText(activity, R.string.export_mrpack_mc_version_unknown, Toast.LENGTH_LONG).show();
+ }
+
+ ProgressDialog progressDialog = Tools.getWaitingDialog(activity, R.string.export_mrpack_progress_preparing);
+
+ File outputFile = new File(new File(Tools.DIR_GAME_HOME, "exports"), buildOutputFileName(packName, packVersion));
+
+ PojavApplication.sExecutorService.execute(() -> {
+ try {
+ MrpackExporter.ExportResult result = MrpackExporter.export(
+ instanceDir, packName, packVersion, packDescription, profile.lastVersionId,
+ overrides, outputFile,
+ rawMessage -> Tools.runOnUiThread(() -> applyProgressMessage(activity, progressDialog, rawMessage)));
+
+ Tools.runOnUiThread(() -> {
+ progressDialog.dismiss();
+ Toast.makeText(activity,
+ activity.getString(R.string.export_mrpack_success, result.outputFile.getName()),
+ Toast.LENGTH_LONG).show();
+ Tools.openPath(activity, result.outputFile, true);
+ });
+ } catch (Exception e) {
+ Tools.runOnUiThread(() -> {
+ progressDialog.dismiss();
+ Tools.showError(activity, R.string.export_mrpack_error, e);
+ });
+ }
+ });
+ }
+
+ private static String buildOutputFileName(String packName, String packVersion) {
+ String safeName = packName.replaceAll("[^a-zA-Z0-9._ -]", "_").trim();
+ if (safeName.isEmpty()) safeName = "modpack";
+ String safeVersion = packVersion == null ? "" : packVersion.replaceAll("[^a-zA-Z0-9._-]", "_").trim();
+ return safeVersion.isEmpty() ? (safeName + ".mrpack") : (safeName + "-" + safeVersion + ".mrpack");
+ }
+
+ private static void applyProgressMessage(Activity activity, ProgressDialog progressDialog, String rawMessage) {
+ if (rawMessage == null) return;
+ String[] parts = rawMessage.split(":");
+ String text;
+ switch (parts[0]) {
+ case "hashing":
+ text = activity.getString(R.string.export_mrpack_progress_hashing,
+ Integer.parseInt(parts[1]), Integer.parseInt(parts[2]));
+ break;
+ case "packaging":
+ text = activity.getString(R.string.export_mrpack_progress_packaging,
+ Integer.parseInt(parts[1]), Integer.parseInt(parts[2]));
+ break;
+ case "checking":
+ text = activity.getString(R.string.export_mrpack_progress_checking);
+ break;
+ default:
+ return;
+ }
+ progressDialog.setMessage(text);
+ }
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/MrpackExporter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/MrpackExporter.java
new file mode 100644
index 0000000000..c0edfc1831
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/MrpackExporter.java
@@ -0,0 +1,366 @@
+package net.kdt.pojavlaunch.modloaders.modpacks.api;
+
+import android.util.Log;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import androidx.annotation.Nullable;
+
+import net.kdt.pojavlaunch.JMinecraftVersionList;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.ModrinthIndex;
+import net.kdt.pojavlaunch.utils.HashUtils;
+import net.kdt.pojavlaunch.utils.ZipUtils;
+import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Builds a Modrinth-format .mrpack from an existing launcher instance, exactly the way the
+ * official Modrinth app does it: jars under mods/ are hash-matched against Modrinth's database
+ * so the pack can reference them by URL instead of bundling them, and everything else that was
+ * selected for export is bundled raw under overrides/.
+ */
+public class MrpackExporter {
+ private static final String TAG = "MrpackExporter";
+ private static final String MODRINTH_API = "https://api.modrinth.com/v2";
+
+ public interface ProgressListener {
+ /** message is one of the export_mrpack_progress_* string resource ids, pre-formatted by the caller */
+ void onProgress(String message);
+ }
+
+ public static class ExportResult {
+ public final int totalModJars;
+ public final int matchedOnModrinth;
+ public final File outputFile;
+ public ExportResult(int totalModJars, int matchedOnModrinth, File outputFile) {
+ this.totalModJars = totalModJars;
+ this.matchedOnModrinth = matchedOnModrinth;
+ this.outputFile = outputFile;
+ }
+ }
+
+ /**
+ * @param instanceDir the root directory of the instance being exported (as returned by
+ * {@code Tools.getGameDirPath(profile)})
+ * @param packName free-form modpack name, goes into modrinth.index.json's "name" field
+ * @param packVersion free-form version string, goes into modrinth.index.json's "versionId" field
+ * @param packDescription optional description, goes into the "summary" field if non-empty
+ * @param minecraftVersionId the profile's lastVersionId, used to detect the Minecraft version
+ * and mod loader for the "dependencies" field
+ * @param selectionOverrides explicit checked/unchecked state for every path the user
+ * interacted with in the export dialog, keyed by path relative to
+ * instanceDir (no leading slash, "/" separated). A path with no
+ * explicit entry inherits the state of its nearest ancestor that
+ * has one.
+ * @param outputFile the destination .mrpack file to write
+ * @param progressListener optional progress callback, invoked on the calling thread (so callers
+ * should call {@link #export} from a background thread already)
+ */
+ public static ExportResult export(File instanceDir, String packName, String packVersion,
+ @Nullable String packDescription, @Nullable String minecraftVersionId,
+ Map selectionOverrides, File outputFile,
+ @Nullable ProgressListener progressListener) throws IOException {
+
+ List modJarFiles = new ArrayList<>();
+ List modJarRelPaths = new ArrayList<>();
+ List overrideFiles = new ArrayList<>();
+ List overrideRelPaths = new ArrayList<>();
+
+ collectSelectedFiles(instanceDir, "", selectionOverrides, false,
+ modJarFiles, modJarRelPaths, overrideFiles, overrideRelPaths);
+
+ // Hash every candidate mod jar (both sha1, for the Modrinth lookup, and sha512, which
+ // every file entry in modrinth.index.json is required to have)
+ Map sha1ByRelPath = new HashMap<>();
+ Map sha512ByRelPath = new HashMap<>();
+ List sha1List = new ArrayList<>();
+ for (int i = 0; i < modJarFiles.size(); i++) {
+ if (progressListener != null) {
+ progressListener.onProgress("hashing:" + (i + 1) + ":" + modJarFiles.size());
+ }
+ String relPath = modJarRelPaths.get(i);
+ String sha1 = HashUtils.sha1Hex(modJarFiles.get(i));
+ String sha512 = HashUtils.sha512Hex(modJarFiles.get(i));
+ sha1ByRelPath.put(relPath, sha1);
+ sha512ByRelPath.put(relPath, sha512);
+ sha1List.add(sha1);
+ }
+
+ if (progressListener != null) progressListener.onProgress("checking");
+
+ Map versionsByHash = lookupModrinthVersionsByHash(sha1List);
+
+ Set projectIds = new HashSet<>();
+ for (JsonObject version : versionsByHash.values()) {
+ if (version.has("project_id") && !version.get("project_id").isJsonNull()) {
+ projectIds.add(version.get("project_id").getAsString());
+ }
+ }
+ Map projectsById = lookupModrinthProjects(projectIds);
+
+ List indexFiles = new ArrayList<>();
+ int matchedCount = 0;
+ for (int i = 0; i < modJarFiles.size(); i++) {
+ String relPath = modJarRelPaths.get(i);
+ String sha1 = sha1ByRelPath.get(relPath);
+ JsonObject version = versionsByHash.get(sha1);
+ JsonObject matchedFile = (version != null) ? findMatchingFile(version, sha1) : null;
+
+ if (matchedFile == null) {
+ // Not found on Modrinth (private/local build, removed listing, etc.) — bundle the
+ // raw jar like every other override file instead of referencing a URL.
+ overrideFiles.add(modJarFiles.get(i));
+ overrideRelPaths.add(relPath);
+ continue;
+ }
+
+ matchedCount++;
+ ModrinthIndex.ModrinthIndexFile indexFile = new ModrinthIndex.ModrinthIndexFile();
+ indexFile.path = relPath;
+ indexFile.fileSize = (int) modJarFiles.get(i).length();
+ indexFile.hashes = new ModrinthIndex.ModrinthIndexFile.ModrinthIndexFileHashes();
+ indexFile.hashes.sha1 = sha1;
+ indexFile.hashes.sha512 = sha512ByRelPath.get(relPath);
+ indexFile.downloads = new String[]{ matchedFile.get("url").getAsString() };
+
+ String envClient = "required";
+ String envServer = "required";
+ String projectId = (version.has("project_id") && !version.get("project_id").isJsonNull())
+ ? version.get("project_id").getAsString() : null;
+ JsonObject project = (projectId != null) ? projectsById.get(projectId) : null;
+ if (project != null) {
+ if (project.has("client_side") && !project.get("client_side").isJsonNull())
+ envClient = project.get("client_side").getAsString();
+ if (project.has("server_side") && !project.get("server_side").isJsonNull())
+ envServer = project.get("server_side").getAsString();
+ }
+ indexFile.env = new ModrinthIndex.ModrinthIndexFile.ModrinthIndexFileEnv();
+ indexFile.env.client = envClient;
+ indexFile.env.server = envServer;
+
+ indexFiles.add(indexFile);
+ }
+
+ ModrinthIndex index = new ModrinthIndex();
+ index.formatVersion = 1;
+ index.game = "minecraft";
+ index.versionId = (packVersion == null || packVersion.trim().isEmpty()) ? "1.0.0" : packVersion.trim();
+ index.name = packName;
+ if (packDescription != null && !packDescription.trim().isEmpty()) index.summary = packDescription.trim();
+ index.dependencies = detectDependencies(minecraftVersionId);
+ index.files = indexFiles.toArray(new ModrinthIndex.ModrinthIndexFile[0]);
+
+ File parentDir = outputFile.getParentFile();
+ if (parentDir != null && !parentDir.exists() && !parentDir.mkdirs()) {
+ throw new IOException("Could not create output directory: " + parentDir);
+ }
+
+ try (FileOutputStream fileOutputStream = new FileOutputStream(outputFile);
+ java.util.zip.ZipOutputStream zipOutputStream = new java.util.zip.ZipOutputStream(fileOutputStream)) {
+ ZipUtils.putBytes(zipOutputStream, "modrinth.index.json",
+ Tools.GLOBAL_GSON.toJson(index).getBytes(StandardCharsets.UTF_8));
+
+ for (int i = 0; i < overrideFiles.size(); i++) {
+ if (progressListener != null) {
+ progressListener.onProgress("packaging:" + (i + 1) + ":" + overrideFiles.size());
+ }
+ ZipUtils.putFile(zipOutputStream, "overrides/" + overrideRelPaths.get(i), overrideFiles.get(i));
+ }
+ }
+
+ return new ExportResult(modJarFiles.size(), matchedCount, outputFile);
+ }
+
+ /**
+ * Recursively walks instanceDir, splitting selected files into two buckets: jar files
+ * directly inside mods/ (candidates for Modrinth hash-matching) and everything else
+ * selected (always bundled raw into overrides/).
+ */
+ private static void collectSelectedFiles(File node, String relPath, Map overrides,
+ boolean inheritedChecked, List modJarFiles,
+ List modJarRelPaths, List overrideFiles,
+ List overrideRelPaths) {
+ Boolean explicit = overrides.get(relPath);
+ boolean checked = (explicit != null) ? explicit : inheritedChecked;
+
+ if (node.isFile()) {
+ if (!checked) return;
+ boolean isTopLevelModJar = relPath.startsWith("mods/")
+ && !relPath.substring("mods/".length()).contains("/")
+ && relPath.toLowerCase(Locale.ROOT).endsWith(".jar");
+ if (isTopLevelModJar) {
+ modJarFiles.add(node);
+ modJarRelPaths.add(relPath);
+ } else {
+ overrideFiles.add(node);
+ overrideRelPaths.add(relPath);
+ }
+ return;
+ }
+
+ File[] children = node.listFiles();
+ if (children == null) return;
+ for (File child : children) {
+ String childRelPath = relPath.isEmpty() ? child.getName() : relPath + "/" + child.getName();
+ collectSelectedFiles(child, childRelPath, overrides, checked, modJarFiles, modJarRelPaths,
+ overrideFiles, overrideRelPaths);
+ }
+ }
+
+ /** Finds the file entry inside a Modrinth version's files[] array whose sha1 matches. */
+ @Nullable
+ private static JsonObject findMatchingFile(JsonObject version, String sha1) {
+ if (!version.has("files")) return null;
+ JsonArray files = version.getAsJsonArray("files");
+ JsonObject firstFile = null;
+ for (JsonElement element : files) {
+ JsonObject fileObject = element.getAsJsonObject();
+ if (firstFile == null) firstFile = fileObject;
+ JsonObject hashes = fileObject.has("hashes") ? fileObject.getAsJsonObject("hashes") : null;
+ if (hashes != null && hashes.has("sha1") && !hashes.get("sha1").isJsonNull()
+ && sha1.equalsIgnoreCase(hashes.get("sha1").getAsString())) {
+ return fileObject;
+ }
+ }
+ // Fall back to the first file in the version if none matched by hash directly — this can
+ // happen if Modrinth's version_files response was keyed by a *different* file in a
+ // multi-file version (rare, e.g. sources jars).
+ return firstFile;
+ }
+
+ /**
+ * Bulk-resolves a list of SHA1 hashes against Modrinth's version_files endpoint, in a single
+ * request, exactly like the Modrinth app does when scanning an instance for export.
+ * @return a map of sha1 (lowercase hex) -> matching version JSON object, only for hashes that matched
+ */
+ private static Map lookupModrinthVersionsByHash(List sha1Hashes) {
+ Map result = new HashMap<>();
+ if (sha1Hashes.isEmpty()) return result;
+
+ JsonObject body = new JsonObject();
+ JsonArray hashArray = new JsonArray();
+ for (String hash : sha1Hashes) hashArray.add(hash);
+ body.add("hashes", hashArray);
+ body.addProperty("algorithm", "sha1");
+
+ Map headers = new HashMap<>();
+ headers.put("Content-Type", "application/json");
+ headers.put("Accept", "application/json");
+
+ String responseRaw = ApiHandler.postRaw(headers, MODRINTH_API + "/version_files", body.toString());
+ if (responseRaw == null) return result;
+ try {
+ JsonObject responseObject = JsonParser.parseString(responseRaw).getAsJsonObject();
+ for (Map.Entry entry : responseObject.entrySet()) {
+ if (entry.getValue().isJsonObject()) {
+ result.put(entry.getKey().toLowerCase(Locale.ROOT), entry.getValue().getAsJsonObject());
+ }
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to parse Modrinth version_files response", e);
+ }
+ return result;
+ }
+
+ /**
+ * Bulk-fetches project metadata (used for the per-mod client/server "env" requirement) for
+ * a set of Modrinth project ids, in a single request.
+ */
+ private static Map lookupModrinthProjects(Set projectIds) {
+ Map result = new HashMap<>();
+ if (projectIds.isEmpty()) return result;
+
+ JsonArray idsArray = new JsonArray();
+ for (String id : projectIds) idsArray.add(id);
+
+ String idsParam;
+ try {
+ idsParam = URLEncoder.encode(idsArray.toString(), "UTF-8");
+ } catch (UnsupportedEncodingException e) {
+ return result; // UTF-8 is always available; unreachable in practice
+ }
+
+ String responseRaw = ApiHandler.getRaw(MODRINTH_API + "/projects?ids=" + idsParam);
+ if (responseRaw == null) return result;
+ try {
+ JsonArray responseArray = JsonParser.parseString(responseRaw).getAsJsonArray();
+ for (JsonElement element : responseArray) {
+ JsonObject project = element.getAsJsonObject();
+ if (project.has("id")) result.put(project.get("id").getAsString(), project);
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to parse Modrinth projects response", e);
+ }
+ return result;
+ }
+
+ /**
+ * Reverse-engineers the "dependencies" block of modrinth.index.json (minecraft version +
+ * mod loader/version) from a profile's lastVersionId, by reversing the naming convention
+ * {@link ModLoader#getVersionId()} uses, and reading the installed version JSON's
+ * inheritsFrom for the underlying Minecraft version.
+ */
+ public static Map detectDependencies(@Nullable String versionId) {
+ Map dependencies = new LinkedHashMap<>();
+ if (versionId == null || versionId.isEmpty()) return dependencies;
+ if (MinecraftProfile.LATEST_RELEASE.equals(versionId) || MinecraftProfile.LATEST_SNAPSHOT.equals(versionId)) {
+ // These are launch-time placeholders, not on-disk version ids — there is no local
+ // version JSON to read inheritsFrom from. The caller surfaces a warning in this case.
+ return dependencies;
+ }
+
+ String minecraftVersion = resolveMinecraftVersion(versionId);
+ if (minecraftVersion != null) dependencies.put("minecraft", minecraftVersion);
+
+ if (versionId.contains("-forge-")) {
+ int index = versionId.indexOf("-forge-");
+ dependencies.put("forge", versionId.substring(index + "-forge-".length()));
+ } else if (versionId.startsWith("fabric-loader-")) {
+ String rest = versionId.substring("fabric-loader-".length());
+ int lastDash = rest.lastIndexOf('-');
+ if (lastDash > 0) dependencies.put("fabric-loader", rest.substring(0, lastDash));
+ } else if (versionId.startsWith("quilt-loader-")) {
+ String rest = versionId.substring("quilt-loader-".length());
+ int lastDash = rest.lastIndexOf('-');
+ if (lastDash > 0) dependencies.put("quilt-loader", rest.substring(0, lastDash));
+ } else if (versionId.startsWith("neoforge-")) {
+ dependencies.put("neoforge", versionId.substring("neoforge-".length()));
+ }
+
+ return dependencies;
+ }
+
+ @Nullable
+ private static String resolveMinecraftVersion(String versionId) {
+ File versionJsonFile = new File(Tools.DIR_HOME_VERSION, versionId + "/" + versionId + ".json");
+ if (!versionJsonFile.exists()) return versionId;
+ try {
+ JMinecraftVersionList.Version version = Tools.GLOBAL_GSON.fromJson(
+ Tools.read(versionJsonFile), JMinecraftVersionList.Version.class);
+ if (version == null) return versionId;
+ if (version.inheritsFrom != null && !version.inheritsFrom.isEmpty()) return version.inheritsFrom;
+ return versionId;
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to read version JSON for " + versionId, e);
+ return versionId;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/HashUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/HashUtils.java
new file mode 100644
index 0000000000..0a63007f84
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/HashUtils.java
@@ -0,0 +1,52 @@
+package net.kdt.pojavlaunch.utils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * Small helper for computing hex-encoded file hashes.
+ *
+ * Used by the .mrpack exporter, which needs both a SHA1 (to match installed mods against
+ * Modrinth's database) and a SHA512 (required by every file entry in modrinth.index.json)
+ * for every mod jar being considered for export.
+ */
+public class HashUtils {
+
+ /** Computes the lowercase hex-encoded SHA1 digest of a file. */
+ public static String sha1Hex(File file) throws IOException {
+ return hashHex(file, "SHA-1");
+ }
+
+ /** Computes the lowercase hex-encoded SHA512 digest of a file. */
+ public static String sha512Hex(File file) throws IOException {
+ return hashHex(file, "SHA-512");
+ }
+
+ private static String hashHex(File file, String algorithm) throws IOException {
+ MessageDigest digest;
+ try {
+ digest = MessageDigest.getInstance(algorithm);
+ } catch (NoSuchAlgorithmException e) {
+ // Every Android version this launcher supports ships both SHA-1 and SHA-512
+ // providers, so this is only a defensive fallback.
+ throw new IOException("Hash algorithm not available: " + algorithm, e);
+ }
+ byte[] buffer = new byte[8192];
+ try (InputStream inputStream = new FileInputStream(file)) {
+ int read;
+ while ((read = inputStream.read(buffer)) != -1) {
+ digest.update(buffer, 0, read);
+ }
+ }
+ byte[] hash = digest.digest();
+ StringBuilder hexBuilder = new StringBuilder(hash.length * 2);
+ for (byte b : hash) {
+ hexBuilder.append(String.format("%02x", b));
+ }
+ return hexBuilder.toString();
+ }
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/ZipUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/ZipUtils.java
index 91247aa1e5..82a8846732 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/ZipUtils.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/ZipUtils.java
@@ -3,6 +3,7 @@
import org.apache.commons.io.IOUtils;
import java.io.File;
+import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
@@ -10,6 +11,7 @@
import java.util.Enumeration;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
+import java.util.zip.ZipOutputStream;
public class ZipUtils {
/**
@@ -52,4 +54,34 @@ public static void zipExtract(ZipFile zipFile, String dirName, File destination)
}
}
}
-}
+
+ /**
+ * Writes a raw byte array as a new entry in an open ZipOutputStream.
+ * Used to write modrinth.index.json into a .mrpack being built.
+ * @param zipOutputStream the open ZipOutputStream to write into
+ * @param entryName the full path of the entry inside the zip
+ * @param data the raw bytes to write as the entry's content
+ * @throws IOException if writing the entry failed
+ */
+ public static void putBytes(ZipOutputStream zipOutputStream, String entryName, byte[] data) throws IOException {
+ zipOutputStream.putNextEntry(new ZipEntry(entryName));
+ zipOutputStream.write(data);
+ zipOutputStream.closeEntry();
+ }
+
+ /**
+ * Copies a file on disk into an open ZipOutputStream as a new entry.
+ * Used to write override files into a .mrpack being built.
+ * @param zipOutputStream the open ZipOutputStream to write into
+ * @param entryName the full path of the entry inside the zip
+ * @param file the source file to copy the content of
+ * @throws IOException if reading the source file or writing the entry failed
+ */
+ public static void putFile(ZipOutputStream zipOutputStream, String entryName, File file) throws IOException {
+ zipOutputStream.putNextEntry(new ZipEntry(entryName));
+ try (InputStream inputStream = new FileInputStream(file)) {
+ IOUtils.copy(inputStream, zipOutputStream);
+ }
+ zipOutputStream.closeEntry();
+ }
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/res/drawable/ic_export_mrpack.xml b/app_pojavlauncher/src/main/res/drawable/ic_export_mrpack.xml
new file mode 100644
index 0000000000..912e6abbee
--- /dev/null
+++ b/app_pojavlauncher/src/main/res/drawable/ic_export_mrpack.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/res/layout/dialog_export_mrpack.xml b/app_pojavlauncher/src/main/res/layout/dialog_export_mrpack.xml
new file mode 100644
index 0000000000..add67906fa
--- /dev/null
+++ b/app_pojavlauncher/src/main/res/layout/dialog_export_mrpack.xml
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/res/layout/fragment_profile_editor.xml b/app_pojavlauncher/src/main/res/layout/fragment_profile_editor.xml
index f25907e719..9ea222f81b 100644
--- a/app_pojavlauncher/src/main/res/layout/fragment_profile_editor.xml
+++ b/app_pojavlauncher/src/main/res/layout/fragment_profile_editor.xml
@@ -265,6 +265,18 @@
app:layout_constraintEnd_toEndOf="@id/vprof_editor_profile_renderer"
app:layout_constraintTop_toTopOf="@id/vprof_editor_profile_renderer" />
+
+
diff --git a/app_pojavlauncher/src/main/res/layout/item_export_tree_entry.xml b/app_pojavlauncher/src/main/res/layout/item_export_tree_entry.xml
new file mode 100644
index 0000000000..5fe1b6c4d4
--- /dev/null
+++ b/app_pojavlauncher/src/main/res/layout/item_export_tree_entry.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/res/values/strings.xml b/app_pojavlauncher/src/main/res/values/strings.xml
index 450873d0ea..cab8ba73f6 100644
--- a/app_pojavlauncher/src/main/res/values/strings.xml
+++ b/app_pojavlauncher/src/main/res/values/strings.xml
@@ -388,6 +388,24 @@
Click here to finish modpack installation
Failed to download mod loader information
Failed to download the mod loader files
+
+ Export as .mrpack
+ Export modpack
+ Modpack Name
+ Modpack name
+ Version number
+ Description
+ Enter modpack description…
+ Configure which files are included in this export
+ Export
+ Preparing export…
+ Hashing mods (%1$d/%2$d)
+ Checking mods on Modrinth…
+ Packaging files (%1$d/%2$d)
+ Modpack exported — tap to share %1$s
+ Failed to export modpack
+ Please enter a modpack name
+ Couldn\'t automatically detect this instance\'s Minecraft version. The exported pack may not specify one.
Failed to download modpack files
Create modpack profile