Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import android.graphics.BitmapFactory;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
Expand All @@ -27,23 +28,22 @@
import net.kdt.pojavlaunch.R;

import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

/**
* Adapter for the installed mods list.
* Enable/disable: rename .jar <-> .jar.disabled
* Icons: reads the real icon path from mod metadata (fabric.mod.json, mods.toml, etc.)
* Delete: removes file with confirmation
*/
public class InstalledModAdapter extends RecyclerView.Adapter<InstalledModAdapter.ModViewHolder> {

private static final String TAG = "ModAdapter";

public interface EmptyStateListener {
void onEmptyStateChanged(boolean isEmpty);
}
Expand Down Expand Up @@ -78,6 +78,14 @@ public void onBindViewHolder(@NonNull ModViewHolder holder, int position) {
holder.bind(mMods.get(position));
}

@Override
public void onViewRecycled(@NonNull ModViewHolder holder) {
// Clear the tag so any in-flight load doesn't update this recycled view
holder.icon.setTag(null);
holder.icon.setImageResource(R.drawable.ic_add_modded);
super.onViewRecycled(holder);
}

@Override
public int getItemCount() { return mMods.size(); }

Expand Down Expand Up @@ -105,15 +113,24 @@ class ModViewHolder extends RecyclerView.ViewHolder {
void bind(ModEntry entry) {
name.setText(entry.displayName());
version.setText(entry.file.getName());
icon.setImageResource(R.drawable.ic_add_modded); // placeholder

// Async icon load
int pos = getBindingAdapterPosition();
// Tag the ImageView with the file path so we can verify it hasn't been recycled
icon.setTag(entry.file.getAbsolutePath());
icon.setImageResource(R.drawable.ic_add_modded);

final String expectedTag = entry.file.getAbsolutePath();
final WeakReference<ImageView> iconRef = new WeakReference<>(icon);
final File jarFile = entry.file;

PojavApplication.sExecutorService.execute(() -> {
Bitmap bmp = extractModIcon(entry.file);
Bitmap bmp = extractModIcon(jarFile);
if (bmp == null) return;
mMainHandler.post(() -> {
if (getBindingAdapterPosition() == pos && bmp != null)
icon.setImageBitmap(bmp);
ImageView iv = iconRef.get();
// Only update if the view still belongs to the same mod
if (iv != null && expectedTag.equals(iv.getTag())) {
iv.setImageBitmap(bmp);
}
});
});

Expand Down Expand Up @@ -172,134 +189,174 @@ void setEnabled(boolean enable) {

// ── Icon extraction ───────────────────────────────────────────────────

/**
* Reads the real icon path from mod metadata, then extracts it from the JAR.
* Supports: Fabric, Quilt, Forge legacy, Forge/NeoForge (TOML).
* Falls back to common filenames if metadata not found.
*/
@Nullable
private static Bitmap extractModIcon(File jarFile) {
try (ZipFile zip = new ZipFile(jarFile)) {
String iconPath = resolveIconPath(zip);
Log.d(TAG, jarFile.getName() + " → icon path: " + iconPath);

if (iconPath != null) {
Bitmap bmp = loadEntryAsBitmap(zip, iconPath);
if (bmp != null) return bmp;
Log.w(TAG, "Icon path resolved but bitmap failed: " + iconPath);
}
// Fallback — some old mods use these

// Fallback scan — some old mods don't declare icon in metadata
for (String fallback : new String[]{"pack.png", "icon.png", "logo.png"}) {
Bitmap bmp = loadEntryAsBitmap(zip, fallback);
if (bmp != null) return bmp;
}
} catch (Exception ignored) {}
} catch (Exception e) {
Log.w(TAG, "Failed to open JAR: " + jarFile.getName() + " — " + e.getMessage());
}
return null;
}

/** Reads mod metadata files in priority order and returns the icon path. */
@Nullable
private static String resolveIconPath(ZipFile zip) {
// 1. Fabric — fabric.mod.json → "icon"
String path = readEntry(zip, "fabric.mod.json");
if (path != null) {
String icon = jsonStringField(path, "icon");
if (icon != null) return icon;
// 1. Fabric — fabric.mod.json → "icon" (string OR {"64":"path"} object)
String content = readEntry(zip, "fabric.mod.json");
if (content != null) {
try {
JsonObject obj = JsonParser.parseString(content).getAsJsonObject();
if (obj.has("icon")) {
JsonElement iconEl = obj.get("icon");
if (iconEl.isJsonPrimitive()) {
return iconEl.getAsString();
} else if (iconEl.isJsonObject()) {
// Size map e.g. {"64": "path64.png", "128": "path128.png"}
// Pick the largest available
JsonObject sizeMap = iconEl.getAsJsonObject();
String best = null;
int bestSize = 0;
for (String key : sizeMap.keySet()) {
try {
int sz = Integer.parseInt(key);
if (sz > bestSize) {
bestSize = sz;
best = sizeMap.get(key).getAsString();
}
} catch (NumberFormatException ignored) {
best = sizeMap.get(key).getAsString();
}
}
if (best != null) return best;
}
}
} catch (Exception e) {
Log.w(TAG, "fabric.mod.json parse error: " + e.getMessage());
}
}

// 2. Quilt — quilt.mod.json → quilt_loader.metadata.icon
path = readEntry(zip, "quilt.mod.json");
if (path != null) {
content = readEntry(zip, "quilt.mod.json");
if (content != null) {
try {
JsonObject root = JsonParser.parseString(path).getAsJsonObject();
if (root.has("quilt_loader")) {
JsonObject ql = root.getAsJsonObject("quilt_loader");
if (ql.has("metadata")) {
JsonObject meta = ql.getAsJsonObject("metadata");
String icon = jsonStringField(meta.toString(), "icon");
if (icon != null) return icon;
}
JsonObject root = JsonParser.parseString(content).getAsJsonObject();
JsonObject ql = root.has("quilt_loader") ?
root.getAsJsonObject("quilt_loader") : null;
if (ql != null && ql.has("metadata")) {
JsonObject meta = ql.getAsJsonObject("metadata");
if (meta.has("icon") && meta.get("icon").isJsonPrimitive())
return meta.get("icon").getAsString();
}
} catch (Exception ignored) {}
} catch (Exception e) {
Log.w(TAG, "quilt.mod.json parse error: " + e.getMessage());
}
}

// 3. Forge legacy — mcmod.info → logoFile (JSON array)
path = readEntry(zip, "mcmod.info");
if (path != null) {
// 3. Forge legacy — mcmod.info → logoFile
content = readEntry(zip, "mcmod.info");
if (content != null) {
try {
JsonArray arr = JsonParser.parseString(path).getAsJsonArray();
if (arr.size() > 0) {
JsonElement el = arr.get(0);
if (el.isJsonObject()) {
String logo = jsonStringField(el.getAsJsonObject().toString(), "logoFile");
if (logo != null && !logo.isEmpty()) return logo;
// mcmod.info is a JSON array
JsonArray arr = JsonParser.parseString(content).getAsJsonArray();
if (arr.size() > 0 && arr.get(0).isJsonObject()) {
JsonObject mod = arr.get(0).getAsJsonObject();
if (mod.has("logoFile")) {
String logo = mod.get("logoFile").getAsString();
if (!logo.isEmpty()) return logo;
}
}
} catch (Exception ignored) {}
} catch (Exception e) {
Log.w(TAG, "mcmod.info parse error: " + e.getMessage());
}
}

// 4. Forge/NeoForge — META-INF/mods.toml or neoforge.mods.toml → logoFile
// 4. Forge/NeoForge — TOML — logoFile = "path"
for (String toml : new String[]{"META-INF/neoforge.mods.toml", "META-INF/mods.toml"}) {
path = readEntry(zip, toml);
if (path != null) {
String logo = tomlStringField(path, "logoFile");
content = readEntry(zip, toml);
if (content != null) {
String logo = tomlStringField(content, "logoFile");
if (logo != null && !logo.isEmpty()) return logo;
}
}

return null;
}

// ── Helpers ───────────────────────────────────────────────────────────
// ── Low-level helpers ─────────────────────────────────────────────────

/**
* Load a ZipEntry as a Bitmap.
* IMPORTANT: BitmapFactory.decodeStream needs mark/reset support.
* ZipInputStream doesn't support it, so we buffer all bytes first.
*/
@Nullable
private static Bitmap loadEntryAsBitmap(ZipFile zip, String entryPath) {
try {
ZipEntry entry = zip.getEntry(entryPath);
if (entry == null) return null;
try (InputStream is = zip.getInputStream(entry)) {
return BitmapFactory.decodeStream(is);
// ZipFile.getEntry is case-sensitive — try exact then case-insensitive scan
ZipEntry entry = zip.getEntry(entryPath);
if (entry == null) {
// Case-insensitive fallback
String lower = entryPath.toLowerCase();
Enumeration<? extends ZipEntry> entries = zip.entries();
while (entries.hasMoreElements()) {
ZipEntry e = entries.nextElement();
if (e.getName().toLowerCase().equals(lower)) {
entry = e;
break;
}
}
}
if (entry == null) return null;

try (InputStream is = zip.getInputStream(entry)) {
// Buffer into byte array — BitmapFactory needs mark/reset
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int read;
while ((read = is.read(buf)) != -1) baos.write(buf, 0, read);
byte[] bytes = baos.toByteArray();
return BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
} catch (Exception e) {
Log.w(TAG, "loadEntryAsBitmap failed for " + entryPath + ": " + e.getMessage());
return null;
}
}

@Nullable
private static String readEntry(ZipFile zip, String entryPath) {
try {
ZipEntry entry = zip.getEntry(entryPath);
if (entry == null) return null;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(zip.getInputStream(entry)))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) sb.append(line).append('\n');
return sb.toString();
}
ZipEntry entry = zip.getEntry(entryPath);
if (entry == null) return null;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(zip.getInputStream(entry), "UTF-8"))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) sb.append(line).append('\n');
return sb.toString();
} catch (Exception e) {
return null;
}
}

/** Extract a string field from a JSON string using Gson. */
@Nullable
private static String jsonStringField(String json, String field) {
try {
JsonObject obj = JsonParser.parseString(json).getAsJsonObject();
if (obj.has(field) && obj.get(field).isJsonPrimitive())
return obj.get(field).getAsString();
} catch (Exception ignored) {}
return null;
}

/** Extract a string field from a TOML string (simple key = "value" parsing). */
@Nullable
private static String tomlStringField(String toml, String field) {
for (String line : toml.split("\n")) {
line = line.trim();
if (line.startsWith(field)) {
if (line.startsWith(field + " ") || line.startsWith(field + "=")) {
int eq = line.indexOf('=');
if (eq < 0) continue;
String val = line.substring(eq + 1).trim();
// strip surrounding quotes
if (val.startsWith("\"") && val.endsWith("\""))
val = val.substring(1, val.length() - 1);
if (!val.isEmpty()) return val;
Expand Down
Loading