From 1bbebcc05ddb15b2ace80945c35b2d91b8db06a4 Mon Sep 17 00:00:00 2001 From: MaxJubayerYT Date: Wed, 24 Jun 2026 14:53:43 +0600 Subject: [PATCH 1/2] feat: add mrpack export to edit instance tab lets you export an instance as a .mrpack the same way the modrinth app does - pick which folders/files get included (defaults match modrinth: config/mods/resourcepacks/shaderpacks checked, rest unchecked), hash mods against modrinth so matched ones get referenced by url instead of bundled, everything else goes under overrides/. exported file lands in games/.../exports/ and opens the share sheet right after. --- .../fragments/ProfileEditorFragment.java | 6 +- .../modpacks/ExportFileTreeAdapter.java | 207 ++++++++++ .../modpacks/ExportMrpackDialog.java | 167 ++++++++ .../modpacks/api/MrpackExporter.java | 366 ++++++++++++++++++ .../net/kdt/pojavlaunch/utils/HashUtils.java | 52 +++ .../net/kdt/pojavlaunch/utils/ZipUtils.java | 34 +- .../main/res/drawable/ic_export_mrpack.xml | 9 + .../main/res/layout/dialog_export_mrpack.xml | 149 +++++++ .../res/layout/fragment_profile_editor.xml | 12 + .../res/layout/item_export_tree_entry.xml | 43 ++ .../src/main/res/values/strings.xml | 18 + 11 files changed, 1061 insertions(+), 2 deletions(-) create mode 100644 app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ExportFileTreeAdapter.java create mode 100644 app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ExportMrpackDialog.java create mode 100644 app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/api/MrpackExporter.java create mode 100644 app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/HashUtils.java create mode 100644 app_pojavlauncher/src/main/res/drawable/ic_export_mrpack.xml create mode 100644 app_pojavlauncher/src/main/res/layout/dialog_export_mrpack.xml create mode 100644 app_pojavlauncher/src/main/res/layout/item_export_tree_entry.xml 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..109480cafe --- /dev/null +++ b/app_pojavlauncher/src/main/res/layout/dialog_export_mrpack.xml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +