From 3c3d53cf6e022791f7c2daa3d1d2817c24ef8e6c Mon Sep 17 00:00:00 2001 From: afrinlibrary Date: Wed, 1 Apr 2026 16:18:23 +0000 Subject: [PATCH] feat: fix mod icons --- .../modloaders/InstalledModAdapter.java | 217 +++++++++++------- 1 file changed, 137 insertions(+), 80 deletions(-) diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java index f720f07bb4..68a81977e7 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java @@ -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; @@ -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 { + private static final String TAG = "ModAdapter"; + public interface EmptyStateListener { void onEmptyStateChanged(boolean isEmpty); } @@ -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(); } @@ -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 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); + } }); }); @@ -172,74 +189,105 @@ 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; } } @@ -247,59 +295,68 @@ private static String resolveIconPath(ZipFile zip) { 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 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;