mirror of
https://github.com/izzy2lost/PSX2.git
synced 2026-07-05 15:18:36 -07:00
Added Coverflow for game covers
This commit is contained in:
@@ -29,31 +29,57 @@ public class CoversAdapter extends RecyclerView.Adapter<CoversAdapter.VH> {
|
||||
private final String[] localPaths; // absolute file paths for cached covers (may be null)
|
||||
private final OnItemClick onItemClick;
|
||||
private final OnItemLongClick onItemLongClick;
|
||||
private final int itemLayoutResId;
|
||||
private int overrideItemWidthPx = 0;
|
||||
|
||||
public CoversAdapter(Context context, String[] titles, String[] coverUrls, String[] localPaths, OnItemClick click) {
|
||||
this(context, titles, coverUrls, localPaths, click, null);
|
||||
this(context, titles, coverUrls, localPaths, R.layout.item_cover, click, null);
|
||||
}
|
||||
|
||||
public CoversAdapter(Context context, String[] titles, String[] coverUrls, String[] localPaths, OnItemClick click, OnItemLongClick longClick) {
|
||||
public CoversAdapter(Context context, String[] titles, String[] coverUrls, String[] localPaths, int itemLayoutResId, OnItemClick click, OnItemLongClick longClick) {
|
||||
this.context = context;
|
||||
this.titles = titles;
|
||||
this.coverUrls = coverUrls;
|
||||
this.localPaths = localPaths;
|
||||
this.itemLayoutResId = itemLayoutResId;
|
||||
this.onItemClick = click;
|
||||
this.onItemLongClick = longClick;
|
||||
setHasStableIds(true);
|
||||
}
|
||||
|
||||
public void setItemWidthPx(int widthPx) {
|
||||
if (widthPx != overrideItemWidthPx) {
|
||||
overrideItemWidthPx = widthPx;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_cover, parent, false);
|
||||
View v = LayoutInflater.from(parent.getContext()).inflate(itemLayoutResId, parent, false);
|
||||
if (overrideItemWidthPx > 0) {
|
||||
RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) v.getLayoutParams();
|
||||
lp.width = overrideItemWidthPx;
|
||||
v.setLayoutParams(lp);
|
||||
}
|
||||
return new VH(v);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull VH holder, int position) {
|
||||
holder.title.setText(titles[position]);
|
||||
String local = (localPaths != null && position < localPaths.length) ? localPaths[position] : null;
|
||||
int count = titles.length;
|
||||
int real = (count == 0) ? 0 : (position % count);
|
||||
// Ensure dynamic width is applied
|
||||
if (overrideItemWidthPx > 0) {
|
||||
RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) holder.itemView.getLayoutParams();
|
||||
if (lp.width != overrideItemWidthPx) {
|
||||
lp.width = overrideItemWidthPx;
|
||||
holder.itemView.setLayoutParams(lp);
|
||||
}
|
||||
}
|
||||
holder.title.setText(titles[real]);
|
||||
String local = (localPaths != null && real < localPaths.length) ? localPaths[real] : null;
|
||||
File localFile = null;
|
||||
if (local != null) {
|
||||
File f = new File(local);
|
||||
@@ -69,15 +95,28 @@ public class CoversAdapter extends RecyclerView.Adapter<CoversAdapter.VH> {
|
||||
.error(android.R.color.transparent)
|
||||
.into(holder.cover);
|
||||
} else {
|
||||
// Do not load from network automatically; wait for explicit download
|
||||
holder.cover.setImageDrawable(null);
|
||||
// Show a default placeholder from resources/no-cover.png if present
|
||||
File resDir = context.getExternalFilesDir("resources");
|
||||
File placeholder = (resDir != null) ? new File(resDir, "no-cover.png") : null;
|
||||
if (placeholder != null && placeholder.exists() && placeholder.length() > 0) {
|
||||
Glide.with(context)
|
||||
.load(placeholder)
|
||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||
.fitCenter()
|
||||
.placeholder(android.R.color.transparent)
|
||||
.error(android.R.color.transparent)
|
||||
.into(holder.cover);
|
||||
} else {
|
||||
// Fallback to logo if placeholder not found
|
||||
holder.cover.setImageResource(R.drawable.psx2_logo2_fixed);
|
||||
}
|
||||
}
|
||||
holder.itemView.setOnClickListener(v -> {
|
||||
if (onItemClick != null) onItemClick.onClick(position);
|
||||
if (onItemClick != null) onItemClick.onClick(real);
|
||||
});
|
||||
holder.itemView.setOnLongClickListener(v -> {
|
||||
if (onItemLongClick != null) {
|
||||
onItemLongClick.onLongClick(position);
|
||||
onItemLongClick.onLongClick(real);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -86,7 +125,15 @@ public class CoversAdapter extends RecyclerView.Adapter<CoversAdapter.VH> {
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return titles.length;
|
||||
return titles.length == 0 ? 0 : Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
if (titles.length == 0) return RecyclerView.NO_ID;
|
||||
int real = position % titles.length;
|
||||
String t = titles[real];
|
||||
return t != null ? t.hashCode() : real;
|
||||
}
|
||||
|
||||
static class VH extends RecyclerView.ViewHolder {
|
||||
|
||||
@@ -20,10 +20,13 @@ import androidx.annotation.Nullable;
|
||||
import android.app.Dialog;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.PagerSnapHelper;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import java.util.Locale;
|
||||
|
||||
public class GamesCoverDialogFragment extends DialogFragment {
|
||||
private boolean didInitialNudge = false;
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -35,7 +38,10 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
private String[] coverUrls;
|
||||
private String[] localPaths;
|
||||
private RecyclerView rv;
|
||||
private GridLayoutManager glm;
|
||||
private LinearLayoutManager llm;
|
||||
private PagerSnapHelper snapHelper;
|
||||
private int lastRvW = -1, lastRvH = -1;
|
||||
private boolean pendingResnap = false;
|
||||
|
||||
public interface OnGameSelectedListener {
|
||||
void onGameSelected(String gameUri);
|
||||
@@ -66,14 +72,7 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
// Re-assert fixed span (2/4) after resume to avoid any flips
|
||||
if (rv != null && glm != null) {
|
||||
int currentOrientation = getResources().getConfiguration().orientation;
|
||||
int fixedSpan = (currentOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) ? 4 : 2;
|
||||
if (fixedSpan != glm.getSpanCount()) {
|
||||
glm.setSpanCount(fixedSpan);
|
||||
}
|
||||
}
|
||||
if (rv != null) applyCoverflowTransforms(rv);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@@ -90,25 +89,33 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
|
||||
rv = root.findViewById(R.id.recycler_covers);
|
||||
rv.setHasFixedSize(true);
|
||||
glm = new GridLayoutManager(requireContext(), 3);
|
||||
rv.setLayoutManager(glm);
|
||||
final int spacingPx = (int) (8 * getResources().getDisplayMetrics().density);
|
||||
final int half = Math.max(1, spacingPx / 2);
|
||||
rv.addItemDecoration(new RecyclerView.ItemDecoration() {
|
||||
llm = new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false);
|
||||
rv.setLayoutManager(llm);
|
||||
rv.setClipToPadding(false);
|
||||
int sidePad = (int) (48 * getResources().getDisplayMetrics().density);
|
||||
int vertPad = (int) (24 * getResources().getDisplayMetrics().density);
|
||||
rv.setPadding(sidePad, vertPad, sidePad, vertPad);
|
||||
// Snap to center item
|
||||
snapHelper = new PagerSnapHelper();
|
||||
snapHelper.attachToRecyclerView(rv);
|
||||
// Scale/alpha transform based on distance from center
|
||||
rv.addOnScrollListener(new RecyclerView.OnScrollListener() {
|
||||
@Override
|
||||
public void getItemOffsets(android.graphics.Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
|
||||
outRect.set(half, half, half, half);
|
||||
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
|
||||
super.onScrolled(recyclerView, dx, dy);
|
||||
applyCoverflowTransforms(recyclerView);
|
||||
}
|
||||
@Override
|
||||
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
|
||||
super.onScrollStateChanged(recyclerView, newState);
|
||||
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||
if (pendingResnap) {
|
||||
resnapToCenter(recyclerView);
|
||||
pendingResnap = false;
|
||||
}
|
||||
applyCoverflowTransforms(recyclerView);
|
||||
}
|
||||
}
|
||||
});
|
||||
rv.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
|
||||
int currentOrientation = getResources().getConfiguration().orientation;
|
||||
int fixedSpan = (currentOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) ? 4 : 2;
|
||||
if (fixedSpan != glm.getSpanCount()) { glm.setSpanCount(fixedSpan); }
|
||||
});
|
||||
root.post(() -> {
|
||||
int currentOrientation = getResources().getConfiguration().orientation;
|
||||
int fixedSpan = (currentOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) ? 4 : 2;
|
||||
if (fixedSpan != glm.getSpanCount()) { glm.setSpanCount(fixedSpan); }
|
||||
});
|
||||
|
||||
titles = getArguments() != null ? getArguments().getStringArray(ARG_TITLES) : new String[0];
|
||||
@@ -135,7 +142,7 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
|
||||
}
|
||||
|
||||
adapter = new CoversAdapter(requireContext(), titles, coverUrls, localPaths,
|
||||
adapter = new CoversAdapter(requireContext(), titles, coverUrls, localPaths, R.layout.item_coverflow,
|
||||
position -> {
|
||||
if (listener != null && position >= 0 && position < uris.length) {
|
||||
listener.onGameSelected(uris[position]);
|
||||
@@ -148,6 +155,80 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
}
|
||||
});
|
||||
rv.setAdapter(adapter);
|
||||
// One-time tiny nudge to force snap/transform on some devices
|
||||
rv.post(() -> {
|
||||
if (!isAdded() || didInitialNudge) return;
|
||||
// Anchor to a large middle position for "infinite" scroll
|
||||
int n = titles != null ? titles.length : 0;
|
||||
if (n > 0) {
|
||||
int center = (1 << 29); // ~536 million
|
||||
int startPos = center - (center % n);
|
||||
llm.scrollToPosition(startPos);
|
||||
}
|
||||
rv.scrollBy(1, 0);
|
||||
rv.scrollBy(-1, 0);
|
||||
applyCoverflowTransforms(rv);
|
||||
didInitialNudge = true;
|
||||
});
|
||||
// Reduce resize flicker and keep a few views ready
|
||||
rv.setItemAnimator(null);
|
||||
rv.setItemViewCacheSize(12);
|
||||
|
||||
// Ensure initial measurement + transforms run after first layout
|
||||
rv.getViewTreeObserver().addOnGlobalLayoutListener(new android.view.ViewTreeObserver.OnGlobalLayoutListener() {
|
||||
@Override public void onGlobalLayout() {
|
||||
if (!isAdded()) return;
|
||||
applyCoverflowTransforms(rv);
|
||||
rv.getViewTreeObserver().removeOnGlobalLayoutListener(this);
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve proper game titles using local YAML index if available (GameIndex/Redump).
|
||||
// Falls back to native URI API, then filename if needed.
|
||||
new Thread(() -> {
|
||||
boolean changed = false;
|
||||
for (int i = 0; i < uris.length; i++) {
|
||||
try {
|
||||
String t = TitleResolver.resolveTitleForUri(requireContext(), uris[i], titles[i]);
|
||||
if (t != null && !t.isEmpty() && i < titles.length && !t.equals(titles[i])) {
|
||||
titles[i] = t;
|
||||
changed = true;
|
||||
}
|
||||
} catch (Throwable ignored) {}
|
||||
}
|
||||
if (changed && isAdded()) requireActivity().runOnUiThread(() -> adapter.notifyDataSetChanged());
|
||||
}).start();
|
||||
|
||||
// Dynamically size items based on RecyclerView size and orientation
|
||||
rv.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
|
||||
final int rvW = right - left;
|
||||
final int rvH = bottom - top;
|
||||
if (rvW <= 0 || rvH <= 0) return;
|
||||
if (rvW == lastRvW && rvH == lastRvH) return; // no real size change
|
||||
lastRvW = rvW;
|
||||
lastRvH = rvH;
|
||||
rv.post(() -> {
|
||||
if (!isAdded()) return;
|
||||
boolean landscape = getResources().getConfiguration().orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE;
|
||||
int titleDp = 36;
|
||||
int extraDp = 16;
|
||||
float density = getResources().getDisplayMetrics().density;
|
||||
int reservedH = (int) ((titleDp + extraDp) * density) + (vertPad * 2);
|
||||
int availableH = Math.max(0, rvH - reservedH);
|
||||
float ratio = 567f / 878f;
|
||||
int widthFromHeight = (int) (availableH * ratio);
|
||||
int widthFromWidth = (int) (rvW * (landscape ? 0.35f : 0.55f));
|
||||
int itemWidth = Math.max(160, Math.min(widthFromHeight, widthFromWidth));
|
||||
adapter.setItemWidthPx(itemWidth);
|
||||
// If user is scrolling, defer resnap until idle to avoid fighting gesture
|
||||
if (rv.getScrollState() == RecyclerView.SCROLL_STATE_IDLE) {
|
||||
resnapToCenter(rv);
|
||||
applyCoverflowTransforms(rv);
|
||||
} else {
|
||||
pendingResnap = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
View btnHome = root.findViewById(R.id.btn_home);
|
||||
if (btnHome != null) btnHome.setOnClickListener(v -> dismissAllowingStateLoss());
|
||||
@@ -157,6 +238,59 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
return root;
|
||||
}
|
||||
|
||||
private void applyCoverflowTransforms(@NonNull RecyclerView recyclerView) {
|
||||
int rvCenterX = (recyclerView.getLeft() + recyclerView.getRight()) / 2;
|
||||
boolean landscape = getResources().getConfiguration().orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE;
|
||||
final float maxScale = 1.0f;
|
||||
final float minScale = landscape ? 0.70f : 0.82f;
|
||||
final float maxAlpha = 1.0f;
|
||||
final float minAlpha = landscape ? 0.50f : 0.60f;
|
||||
final float maxTiltDeg = landscape ? 22f : 16f;
|
||||
final float density = getResources().getDisplayMetrics().density;
|
||||
final float maxParallax = (landscape ? 14f : 18f) * density;
|
||||
for (int i = 0; i < recyclerView.getChildCount(); i++) {
|
||||
View child = recyclerView.getChildAt(i);
|
||||
int childCenterX = (child.getLeft() + child.getRight()) / 2;
|
||||
float dx = childCenterX - rvCenterX;
|
||||
float dist = Math.abs(dx);
|
||||
float norm = Math.min(1f, dist / (recyclerView.getWidth() * 0.5f));
|
||||
float scale = maxScale - (maxScale - minScale) * norm;
|
||||
float alpha = maxAlpha - (maxAlpha - minAlpha) * norm;
|
||||
float tilt = Math.signum(dx) * maxTiltDeg * norm; // tilt away from center
|
||||
child.setCameraDistance(8000f * density);
|
||||
child.setScaleX(scale);
|
||||
child.setScaleY(scale);
|
||||
child.setAlpha(alpha);
|
||||
child.setTranslationZ((1f - norm) * 10f);
|
||||
child.setRotationY(tilt);
|
||||
|
||||
// Title parallax (gentle)
|
||||
View title = child.findViewById(R.id.text_title);
|
||||
if (title != null) {
|
||||
float parallax = Math.max(-maxParallax, Math.min(maxParallax, -dx / (recyclerView.getWidth() * 0.5f) * maxParallax));
|
||||
title.setTranslationX(parallax);
|
||||
title.setAlpha(0.85f + 0.15f * (1f - norm));
|
||||
}
|
||||
|
||||
// Shadow intensity scales with centeredness
|
||||
View shadow = child.findViewById(R.id.view_shadow);
|
||||
if (shadow != null) {
|
||||
shadow.setAlpha((1f - norm) * 0.7f);
|
||||
shadow.setScaleX(scale + 0.2f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void resnapToCenter(@NonNull RecyclerView rv) {
|
||||
if (snapHelper == null || llm == null) return;
|
||||
View snapView = snapHelper.findSnapView(llm);
|
||||
if (snapView == null) return;
|
||||
int[] dist = snapHelper.calculateDistanceToFinalSnap(llm, snapView);
|
||||
if (dist != null && (dist[0] != 0 || dist[1] != 0)) {
|
||||
rv.scrollBy(dist[0], dist[1]);
|
||||
}
|
||||
}
|
||||
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
Dialog d = getDialog();
|
||||
|
||||
@@ -36,7 +36,9 @@ public class NativeApp {
|
||||
}
|
||||
|
||||
public static native void initialize(String path, int apiVer);
|
||||
public static native String getGameTitle(String path);
|
||||
public static native String getGameTitle(String path);
|
||||
// New: Resolve game title from a URI (content:// or file://). Implement in native when available.
|
||||
public static native String getGameTitleFromUri(String gameUri);
|
||||
public static native String getGameSerial();
|
||||
public static native float getFPS();
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package com.izzy2lost.psx2;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import org.json.JSONObject;
|
||||
import org.json.JSONTokener;
|
||||
|
||||
/**
|
||||
* Loads game title index from resources/GameIndex.yaml or resources/RedumpDatabase.yaml
|
||||
* and resolves human titles from serials extracted via native APIs.
|
||||
*/
|
||||
public final class TitleResolver {
|
||||
private static Map<String, String> sSerialToTitle; // UPPERCASE SERIAL -> Title
|
||||
private static boolean sLoaded;
|
||||
|
||||
private TitleResolver() {}
|
||||
|
||||
public static synchronized void ensureLoaded(Context ctx) {
|
||||
if (sLoaded && sSerialToTitle != null) return;
|
||||
sSerialToTitle = new HashMap<>();
|
||||
File base = ctx.getExternalFilesDir(null);
|
||||
if (base == null) base = ctx.getFilesDir();
|
||||
File resDir = new File(base, "resources");
|
||||
// Prefer JSON cache if available
|
||||
File jsonCache = new File(resDir, "gameindex.json");
|
||||
if (loadJsonIfPresent(jsonCache, sSerialToTitle)) {
|
||||
sLoaded = true;
|
||||
return;
|
||||
}
|
||||
// Try both files if present
|
||||
loadYamlSafe(new File(resDir, "GameIndex.yaml"), sSerialToTitle);
|
||||
loadYamlSafe(new File(resDir, "RedumpDatabase.yaml"), sSerialToTitle);
|
||||
// Write JSON cache for faster subsequent loads
|
||||
writeJsonSafe(jsonCache, sSerialToTitle);
|
||||
sLoaded = true;
|
||||
}
|
||||
|
||||
public static String resolveTitleForUri(Context ctx, String uriString, String fallback) {
|
||||
try {
|
||||
// 1) Check per-URI cache
|
||||
String cached = getCachedTitle(ctx, uriString);
|
||||
if (cached != null && !cached.isEmpty()) return cached;
|
||||
|
||||
// 2) Ensure index loaded (JSON fast path or YAML)
|
||||
ensureLoaded(ctx);
|
||||
|
||||
// 3) Resolve serial via native; if missing, try filename hint
|
||||
String serial = null;
|
||||
try { serial = NativeApp.getGameSerial(uriString); } catch (Throwable ignored) {}
|
||||
if (serial == null || serial.isEmpty()) {
|
||||
Uri u = Uri.parse(uriString);
|
||||
String name = u.getLastPathSegment();
|
||||
if (name != null) serial = normalizeCandidate(name);
|
||||
}
|
||||
|
||||
// 4) Lookup in index
|
||||
if (serial != null) {
|
||||
serial = normalizeSerial(serial);
|
||||
String title = sSerialToTitle.get(serial);
|
||||
if (title != null && !title.isEmpty()) {
|
||||
putCachedTitle(ctx, uriString, title);
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Fallback to native URI title if available
|
||||
String nativeTitle = null;
|
||||
try { nativeTitle = NativeApp.getGameTitleFromUri(uriString); } catch (Throwable ignored) {}
|
||||
if (nativeTitle != null && !nativeTitle.isEmpty()) {
|
||||
putCachedTitle(ctx, uriString, nativeTitle);
|
||||
return nativeTitle;
|
||||
}
|
||||
} catch (Throwable ignored) {}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static void loadYamlSafe(File file, Map<String, String> out) {
|
||||
if (file == null || !file.exists()) return;
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
String pendingSerial = null;
|
||||
int currentIndent = 0;
|
||||
while ((line = br.readLine()) != null) {
|
||||
String l = line.trim();
|
||||
if (l.isEmpty() || l.startsWith("#")) continue;
|
||||
// Detect top-level or map-key serial of the form SERIAL:
|
||||
String serialKey = extractSerialMapKey(line);
|
||||
if (serialKey != null) {
|
||||
pendingSerial = normalizeSerial(serialKey);
|
||||
currentIndent = leadingSpaces(line);
|
||||
continue;
|
||||
}
|
||||
// If inside a serial block, parse a name/title field at greater indent
|
||||
if (pendingSerial != null) {
|
||||
int indent = leadingSpaces(line);
|
||||
if (indent <= currentIndent) {
|
||||
// Out of this block
|
||||
pendingSerial = null;
|
||||
continue;
|
||||
}
|
||||
String title = extractTitleFromLine(l);
|
||||
if (title != null) {
|
||||
out.put(pendingSerial, title);
|
||||
pendingSerial = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
private static boolean loadJsonIfPresent(File file, Map<String, String> out) {
|
||||
if (file == null || !file.exists()) return false;
|
||||
try (FileInputStream fis = new FileInputStream(file)) {
|
||||
InputStreamReader isr = new InputStreamReader(fis, StandardCharsets.UTF_8);
|
||||
StringBuilder sb = new StringBuilder(1 << 20);
|
||||
char[] buf = new char[4096];
|
||||
int n;
|
||||
while ((n = isr.read(buf)) != -1) sb.append(buf, 0, n);
|
||||
JSONObject obj = new JSONObject(new JSONTokener(sb.toString()));
|
||||
java.util.Iterator<String> keys = obj.keys();
|
||||
while (keys.hasNext()) {
|
||||
String k = keys.next();
|
||||
String v = obj.optString(k, null);
|
||||
if (v != null && !v.isEmpty()) out.put(k, v);
|
||||
}
|
||||
return true;
|
||||
} catch (Exception ignored) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void writeJsonSafe(File file, Map<String, String> map) {
|
||||
if (file == null) return;
|
||||
try {
|
||||
if (file.getParentFile() != null && !file.getParentFile().exists()) file.getParentFile().mkdirs();
|
||||
JSONObject obj = new JSONObject(map);
|
||||
byte[] bytes = obj.toString().getBytes(StandardCharsets.UTF_8);
|
||||
try (FileOutputStream fos = new FileOutputStream(file, false)) {
|
||||
fos.write(bytes);
|
||||
fos.flush();
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
private static String getCachedTitle(Context ctx, String uri) {
|
||||
try {
|
||||
SharedPreferences prefs = ctx.getSharedPreferences("title_cache", Context.MODE_PRIVATE);
|
||||
return prefs.getString(uri, null);
|
||||
} catch (Throwable ignored) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void putCachedTitle(Context ctx, String uri, String title) {
|
||||
try {
|
||||
SharedPreferences prefs = ctx.getSharedPreferences("title_cache", Context.MODE_PRIVATE);
|
||||
prefs.edit().putString(uri, title).apply();
|
||||
} catch (Throwable ignored) {}
|
||||
}
|
||||
|
||||
private static String extractTitleFromLine(String l) {
|
||||
// Common YAML keys
|
||||
int idx = indexOfKey(l, "name:");
|
||||
if (idx < 0) idx = indexOfKey(l, "title:");
|
||||
if (idx < 0) return null;
|
||||
String v = l.substring(idx).trim();
|
||||
// Strip key
|
||||
int colon = v.indexOf(':');
|
||||
if (colon >= 0) v = v.substring(colon + 1).trim();
|
||||
// Trim quotes if present
|
||||
if ((v.startsWith("\"") && v.endsWith("\"")) || (v.startsWith("'") && v.endsWith("'"))) {
|
||||
v = v.substring(1, v.length() - 1);
|
||||
}
|
||||
return v.isEmpty() ? null : v;
|
||||
}
|
||||
|
||||
private static int indexOfKey(String l, String key) {
|
||||
int i = l.toLowerCase(Locale.ROOT).indexOf(key);
|
||||
return i;
|
||||
}
|
||||
|
||||
private static String extractSerialFromLine(String l) {
|
||||
String upper = l.toUpperCase(Locale.ROOT);
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern
|
||||
.compile("([A-Z]{4,5})[- _]?([0-9]{3})[._]?([0-9]{2})")
|
||||
.matcher(upper);
|
||||
if (m.find()) return m.group(1) + "-" + m.group(2) + m.group(3);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String extractSerialMapKey(String rawLine) {
|
||||
// Matches: "SLUS-20312:" or " SLUS_203.12:" (with indentation) at start of key
|
||||
int colon = rawLine.indexOf(':');
|
||||
if (colon <= 0) return null;
|
||||
String key = rawLine.substring(0, colon).trim();
|
||||
String s = extractSerialFromLine(key);
|
||||
// Ensure the whole key is a serial, not just contains one
|
||||
if (s != null) {
|
||||
String normalizedKey = key.toUpperCase(Locale.ROOT).replace('_','-').replace(".", "");
|
||||
String normalizedSerial = s.toUpperCase(Locale.ROOT).replace('_','-');
|
||||
if (normalizedKey.replace("-", "").equals(normalizedSerial.replace("-", ""))) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int leadingSpaces(String s) {
|
||||
int i = 0;
|
||||
while (i < s.length() && Character.isWhitespace(s.charAt(i))) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
private static String normalizeCandidate(String s) {
|
||||
if (s == null) return null;
|
||||
String upper = s.toUpperCase(Locale.ROOT).replace('_', '-');
|
||||
// Try to extract serial
|
||||
return extractSerialFromLine(upper);
|
||||
}
|
||||
|
||||
private static String normalizeSerial(String serial) {
|
||||
String s = serial.toUpperCase(Locale.ROOT).replace('_', '-');
|
||||
s = s.replaceAll("([A-Z]{4,5})-([0-9]{3})\\.([0-9]{2})", "$1-$2$3");
|
||||
return s;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user