mirror of
https://github.com/izzy2lost/PSX2.git
synced 2026-07-05 15:18:36 -07:00
Added setup wizard, Started SAF stuff
initial work on moving everything from android/data to the user selected folder.
This commit is contained in:
@@ -13,6 +13,7 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import java.io.File;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
public class CoversAdapter extends RecyclerView.Adapter<CoversAdapter.VH> {
|
||||
public interface OnItemClick {
|
||||
@@ -80,36 +81,47 @@ public class CoversAdapter extends RecyclerView.Adapter<CoversAdapter.VH> {
|
||||
}
|
||||
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);
|
||||
if (f.exists() && f.length() > 0) localFile = f;
|
||||
}
|
||||
|
||||
if (localFile != null) {
|
||||
Glide.with(context)
|
||||
.load(localFile)
|
||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||
.fitCenter()
|
||||
.placeholder(android.R.color.transparent)
|
||||
.error(android.R.color.transparent)
|
||||
.into(holder.cover);
|
||||
} else {
|
||||
// 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) {
|
||||
boolean loadedImage = false;
|
||||
if (local != null && local.startsWith("content://")) {
|
||||
android.net.Uri uri = android.net.Uri.parse(local);
|
||||
// Only load if the SAF file has content (length > 0)
|
||||
boolean hasContent = false;
|
||||
try {
|
||||
DocumentFile df = DocumentFile.fromSingleUri(context, uri);
|
||||
hasContent = (df != null && df.length() > 0);
|
||||
} catch (Throwable ignored) {}
|
||||
if (hasContent) {
|
||||
Glide.with(context)
|
||||
.load(placeholder)
|
||||
.load(uri)
|
||||
.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);
|
||||
loadedImage = true;
|
||||
}
|
||||
} else if (local != null) {
|
||||
File f = new File(local);
|
||||
if (f.exists() && f.length() > 0) {
|
||||
Glide.with(context)
|
||||
.load(f)
|
||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||
.fitCenter()
|
||||
.placeholder(android.R.color.transparent)
|
||||
.error(android.R.color.transparent)
|
||||
.into(holder.cover);
|
||||
loadedImage = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!loadedImage) {
|
||||
Glide.with(context)
|
||||
.load(getPlaceholder())
|
||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||
.fitCenter()
|
||||
.placeholder(android.R.color.transparent)
|
||||
.error(android.R.color.transparent)
|
||||
.into(holder.cover);
|
||||
}
|
||||
holder.itemView.setOnClickListener(v -> {
|
||||
if (onItemClick != null) {
|
||||
@@ -133,6 +145,20 @@ public class CoversAdapter extends RecyclerView.Adapter<CoversAdapter.VH> {
|
||||
});
|
||||
}
|
||||
|
||||
private Object getPlaceholder() {
|
||||
// Try SAF resources/no-cover.png first
|
||||
android.net.Uri dataRoot = SafManager.getDataRootUri(context);
|
||||
if (dataRoot != null) {
|
||||
androidx.documentfile.provider.DocumentFile f = SafManager.getChild(context, new String[]{"resources"}, "no-cover.png");
|
||||
if (f != null && f.exists()) return f.getUri();
|
||||
}
|
||||
// Then try app external files path
|
||||
File resDir = context.getExternalFilesDir("resources");
|
||||
File placeholder = (resDir != null) ? new File(resDir, "no-cover.png") : null;
|
||||
if (placeholder != null && placeholder.exists() && placeholder.length() > 0) return placeholder;
|
||||
return R.drawable.psx2_logo2_fixed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return titles.length == 0 ? 0 : Integer.MAX_VALUE;
|
||||
|
||||
@@ -12,6 +12,8 @@ import android.widget.Spinner;
|
||||
import com.google.android.material.materialswitch.MaterialSwitch;
|
||||
import android.widget.TextView;
|
||||
import android.net.Uri;
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -25,6 +27,10 @@ public class GameSettingsDialogFragment extends DialogFragment {
|
||||
private static final String ARG_GAME_SERIAL = "game_serial";
|
||||
private static final String ARG_GAME_CRC = "game_crc";
|
||||
|
||||
// File picker state
|
||||
private ActivityResultLauncher<Intent> mPnachPicker;
|
||||
private boolean mImportAsCheats = true;
|
||||
|
||||
public static GameSettingsDialogFragment newInstance(String gameTitle, String gameUri, String gameSerial, String gameCrc) {
|
||||
GameSettingsDialogFragment fragment = new GameSettingsDialogFragment();
|
||||
Bundle args = new Bundle();
|
||||
@@ -42,6 +48,52 @@ public class GameSettingsDialogFragment extends DialogFragment {
|
||||
Context ctx = requireContext();
|
||||
View view = getLayoutInflater().inflate(R.layout.dialog_game_settings, null, false);
|
||||
|
||||
// Register picker ahead of time to avoid lifecycle crashes
|
||||
if (mPnachPicker == null) {
|
||||
mPnachPicker = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
|
||||
try {
|
||||
if (result.getResultCode() != Activity.RESULT_OK) return;
|
||||
Intent data = result.getData(); if (data == null) return;
|
||||
Uri uri = data.getData(); if (uri == null) return;
|
||||
Bundle args = getArguments();
|
||||
String gameSerial = args != null ? args.getString(ARG_GAME_SERIAL, "") : "";
|
||||
if (gameSerial == null || gameSerial.isEmpty()) {
|
||||
try { gameSerial = NativeApp.getCurrentGameSerial(); } catch (Throwable ignored) {}
|
||||
}
|
||||
if (gameSerial == null || gameSerial.isEmpty()) {
|
||||
android.widget.Toast.makeText(ctx, "Serial unknown; cannot import", android.widget.Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
java.io.File baseDir = ctx.getExternalFilesDir(null);
|
||||
if (baseDir == null) baseDir = ctx.getFilesDir();
|
||||
java.io.File targetDir = new java.io.File(baseDir, mImportAsCheats ? "cheats" : "patches");
|
||||
if (!targetDir.exists()) targetDir.mkdirs();
|
||||
java.io.File outFile = new java.io.File(targetDir, gameSerial + ".pnach");
|
||||
android.content.ContentResolver cr = ctx.getContentResolver();
|
||||
java.io.InputStream in = cr.openInputStream(uri);
|
||||
if (in == null) { android.widget.Toast.makeText(ctx, "Failed to open file", android.widget.Toast.LENGTH_SHORT).show(); return; }
|
||||
java.io.FileOutputStream fos = new java.io.FileOutputStream(outFile);
|
||||
byte[] buf = new byte[8192]; int n; while ((n = in.read(buf)) != -1) fos.write(buf, 0, n);
|
||||
fos.flush(); fos.close(); in.close();
|
||||
|
||||
// Mirror to SAF data root if set
|
||||
android.net.Uri dataRoot = SafManager.getDataRootUri(ctx);
|
||||
if (dataRoot != null) {
|
||||
String subdir = mImportAsCheats ? "cheats" : "patches";
|
||||
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{subdir}, gameSerial + ".pnach", "text/plain");
|
||||
if (target != null) {
|
||||
try (java.io.InputStream in2 = cr.openInputStream(android.net.Uri.fromFile(outFile))) {
|
||||
SafManager.copyFromStream(ctx, in2, target.getUri());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
android.widget.Toast.makeText(ctx, (mImportAsCheats ? "Cheats" : "Patch Codes") + " imported for " + gameSerial, android.widget.Toast.LENGTH_SHORT).show();
|
||||
} catch (Exception e) {
|
||||
android.widget.Toast.makeText(ctx, "Import failed: " + e.getMessage(), android.widget.Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Bundle args = getArguments();
|
||||
String gameTitle = args != null ? args.getString(ARG_GAME_TITLE, "Unknown Game") : "Unknown Game";
|
||||
String gameUri = args != null ? args.getString(ARG_GAME_URI, "") : "";
|
||||
@@ -88,8 +140,33 @@ public class GameSettingsDialogFragment extends DialogFragment {
|
||||
MaterialSwitch swNoInterlacingPatches = view.findViewById(R.id.sw_no_interlacing_patches);
|
||||
MaterialSwitch swEnablePatchCodes = view.findViewById(R.id.sw_enable_patch_codes);
|
||||
MaterialSwitch swEnableCheats = view.findViewById(R.id.sw_enable_cheats);
|
||||
MaterialSwitch swLoadTextures = view.findViewById(R.id.sw_load_textures_per_game);
|
||||
MaterialSwitch swAsyncTextures = view.findViewById(R.id.sw_async_texture_loading_per_game);
|
||||
|
||||
// Load existing per-game settings from INI and prefill widgets; if missing, use global
|
||||
// Prefill with global defaults
|
||||
android.content.SharedPreferences gp = ctx.getSharedPreferences("app_prefs", Context.MODE_PRIVATE);
|
||||
int gRenderer = gp.getInt("renderer", -1);
|
||||
float gScale = gp.getFloat("upscale_multiplier", 1.0f);
|
||||
int gBlend = gp.getInt("blending_accuracy", 1);
|
||||
boolean gWide = gp.getBoolean("widescreen_patches", true);
|
||||
boolean gNoInt = gp.getBoolean("no_interlacing_patches", true);
|
||||
boolean gLoadTex = gp.getBoolean("load_textures", false);
|
||||
boolean gAsyncTex = gp.getBoolean("async_texture_loading", true);
|
||||
boolean gCheats = gp.getBoolean("enable_cheats", false);
|
||||
|
||||
// Map to indices
|
||||
int defaultRendererIdx = (gRenderer == -1 ? 0 : (gRenderer == 14 ? 1 : (gRenderer == 12 ? 2 : 3)));
|
||||
int defaultScaleIdx = Math.max(0, Math.min(7, Math.round(gScale) - 1));
|
||||
spRenderer.setSelection(defaultRendererIdx);
|
||||
spResolution.setSelection(defaultScaleIdx);
|
||||
spBlendingAccuracy.setSelection(Math.max(0, Math.min(5, gBlend)));
|
||||
swWidescreenPatches.setChecked(gWide);
|
||||
swNoInterlacingPatches.setChecked(gNoInt);
|
||||
if (swLoadTextures != null) swLoadTextures.setChecked(gLoadTex);
|
||||
if (swAsyncTextures != null) swAsyncTextures.setChecked(gAsyncTex);
|
||||
swEnableCheats.setChecked(gCheats);
|
||||
|
||||
// Load existing per-game settings from INI and prefill widgets; if present overrides globals
|
||||
try {
|
||||
String serial = gameSerial;
|
||||
if (serial == null || serial.isEmpty()) {
|
||||
@@ -195,17 +272,19 @@ public class GameSettingsDialogFragment extends DialogFragment {
|
||||
// Use MaterialAlertDialogBuilder with Material 3 overlay for the main dialog
|
||||
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(ctx,
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog);
|
||||
builder.setTitle("Per-Game Settings")
|
||||
.setView(view)
|
||||
.setNegativeButton("Cancel", (d, w) -> d.dismiss())
|
||||
.setPositiveButton("Save", (d, w) -> {
|
||||
builder.setCustomTitle(UiUtils.centeredDialogTitle(ctx, "Per-Game Settings"))
|
||||
.setView(view)
|
||||
.setNegativeButton("Cancel", (d, w) -> d.dismiss())
|
||||
.setPositiveButton("Save", (d, w) -> {
|
||||
final int blendLevel = spBlendingAccuracy.getSelectedItemPosition();
|
||||
final int rendererIdx = spRenderer.getSelectedItemPosition();
|
||||
final int resIdx = spResolution.getSelectedItemPosition();
|
||||
final boolean wide = swWidescreenPatches.isChecked();
|
||||
final boolean noInt = swNoInterlacingPatches.isChecked();
|
||||
final boolean enablePatches = swEnablePatchCodes.isChecked();
|
||||
final boolean enablePatches = true; // always on
|
||||
final boolean enableCheats = swEnableCheats.isChecked();
|
||||
final boolean loadTex = (swLoadTextures != null && swLoadTextures.isChecked());
|
||||
final boolean asyncTex = (swAsyncTextures != null && swAsyncTextures.isChecked());
|
||||
|
||||
// Persist per-game INI explicitly (supports Auto as well)
|
||||
writeGameSettingsIni(ctx, gameSerial, gameCrc,
|
||||
@@ -222,6 +301,8 @@ public class GameSettingsDialogFragment extends DialogFragment {
|
||||
else renderer = 13;
|
||||
|
||||
float scale = Math.max(1, Math.min(8, resIdx + 1));
|
||||
NativeApp.setLoadTextures(loadTex);
|
||||
NativeApp.setAsyncTextureLoading(asyncTex);
|
||||
NativeApp.applyPerGameSettingsBatch(renderer, scale, blendLevel, wide, noInt, enablePatches, enableCheats);
|
||||
} catch (Throwable t) {
|
||||
android.util.Log.e("GameSettings", "Per-game batch apply failed: " + t.getMessage());
|
||||
@@ -250,45 +331,13 @@ public class GameSettingsDialogFragment extends DialogFragment {
|
||||
final String[] choices = new String[]{"Import as Cheats", "Import as Patch Codes"};
|
||||
new MaterialAlertDialogBuilder(ctx,
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("Import PNACH")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(ctx, "Import PNACH"))
|
||||
.setItems(choices, (dlg, which) -> {
|
||||
boolean asCheats = (which == 0);
|
||||
// Prepare picker
|
||||
mImportAsCheats = (which == 0);
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
// Store choice in tag
|
||||
view.setTag(R.id.btn_import_pnach, asCheats);
|
||||
registerForActivityResult(new androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(), result -> {
|
||||
try {
|
||||
if (result.getResultCode() != android.app.Activity.RESULT_OK) return;
|
||||
Intent data = result.getData(); if (data == null) return;
|
||||
android.net.Uri uri = data.getData(); if (uri == null) return;
|
||||
boolean importAsCheats = Boolean.TRUE.equals(view.getTag(R.id.btn_import_pnach));
|
||||
String serialLoad = gameSerial;
|
||||
if (serialLoad == null || serialLoad.isEmpty()) {
|
||||
try { serialLoad = NativeApp.getCurrentGameSerial(); } catch (Throwable ignored) {}
|
||||
}
|
||||
if (serialLoad == null || serialLoad.isEmpty()) {
|
||||
android.widget.Toast.makeText(ctx, "Serial unknown; cannot import", android.widget.Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
java.io.File baseDir = ctx.getExternalFilesDir(null);
|
||||
if (baseDir == null) baseDir = ctx.getFilesDir();
|
||||
java.io.File targetDir = new java.io.File(baseDir, importAsCheats ? "cheats" : "patches");
|
||||
if (!targetDir.exists()) targetDir.mkdirs();
|
||||
java.io.File outFile = new java.io.File(targetDir, serialLoad + ".pnach");
|
||||
android.content.ContentResolver cr = ctx.getContentResolver();
|
||||
java.io.InputStream in = cr.openInputStream(uri);
|
||||
if (in == null) { android.widget.Toast.makeText(ctx, "Failed to open file", android.widget.Toast.LENGTH_SHORT).show(); return; }
|
||||
java.io.FileOutputStream fos = new java.io.FileOutputStream(outFile);
|
||||
byte[] buf = new byte[8192]; int n; while ((n = in.read(buf)) != -1) fos.write(buf, 0, n);
|
||||
fos.flush(); fos.close(); in.close();
|
||||
android.widget.Toast.makeText(ctx, (importAsCheats ? "Cheats" : "Patch Codes") + " imported for " + serialLoad, android.widget.Toast.LENGTH_SHORT).show();
|
||||
} catch (Exception e) {
|
||||
android.widget.Toast.makeText(ctx, "Import failed: " + e.getMessage(), android.widget.Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}).launch(intent);
|
||||
mPnachPicker.launch(intent);
|
||||
})
|
||||
.show();
|
||||
});
|
||||
@@ -374,6 +423,18 @@ public class GameSettingsDialogFragment extends DialogFragment {
|
||||
fos.flush();
|
||||
fos.close();
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
// Mirror to SAF data root if set
|
||||
android.net.Uri dataRoot = SafManager.getDataRootUri(ctx);
|
||||
if (dataRoot != null) {
|
||||
try {
|
||||
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{"gamesettings"}, fileName, "text/plain");
|
||||
if (target != null) {
|
||||
byte[] data = sb.toString().getBytes("UTF-8");
|
||||
SafManager.writeBytes(ctx, target.getUri(), data);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
String serial = saved;
|
||||
if (serial == null || serial.isEmpty()) {
|
||||
try {
|
||||
String nativeSerial = NativeApp.getGameSerial(uris[i]);
|
||||
String nativeSerial = NativeApp.getGameSerialSafe(uris[i]);
|
||||
if (nativeSerial != null && !nativeSerial.isEmpty()) {
|
||||
serial = normalizeSerial(nativeSerial);
|
||||
prefs.edit().putString("serial:" + uris[i], serial).apply();
|
||||
@@ -218,7 +218,20 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
serial = buildSerialFromUri(uris[i]);
|
||||
}
|
||||
coverUrls[i] = buildCoverUrlFromSerial(serial);
|
||||
localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
|
||||
// Prefer SAF content URI if data root is set, else absolute file path
|
||||
android.net.Uri dataRoot = SafManager.getDataRootUri(requireContext());
|
||||
if (dataRoot != null) {
|
||||
androidx.documentfile.provider.DocumentFile f = SafManager.getChild(requireContext(), new String[]{"covers"}, serial + ".png");
|
||||
if (f != null && f.exists()) {
|
||||
localPaths[i] = f.getUri().toString();
|
||||
} else {
|
||||
// Pre-create to get a stable Uri
|
||||
androidx.documentfile.provider.DocumentFile nf = SafManager.createChild(requireContext(), new String[]{"covers"}, serial + ".png", "image/png");
|
||||
localPaths[i] = (nf != null) ? nf.getUri().toString() : new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
|
||||
}
|
||||
} else {
|
||||
localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
|
||||
}
|
||||
}
|
||||
|
||||
// cache originals for sorting/filtering
|
||||
@@ -737,12 +750,18 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
try {
|
||||
// Prefer native serial extraction so CHDs work
|
||||
String better = null;
|
||||
try { better = NativeApp.getGameSerial(uris[i]); } catch (Throwable ignored) {}
|
||||
try { better = NativeApp.getGameSerialSafe(uris[i]); } catch (Throwable ignored) {}
|
||||
if (better == null) better = extractSerialFromUri(uris[i]);
|
||||
if (better != null && !better.equalsIgnoreCase(serialFromUrl(coverUrls[i]))) {
|
||||
String serial = normalizeSerial(better);
|
||||
coverUrls[i] = buildCoverUrlFromSerial(serial);
|
||||
localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
|
||||
android.net.Uri dataRoot = SafManager.getDataRootUri(requireContext());
|
||||
if (dataRoot != null) {
|
||||
androidx.documentfile.provider.DocumentFile nf = SafManager.createChild(requireContext(), new String[]{"covers"}, serial + ".png", "image/png");
|
||||
localPaths[i] = (nf != null) ? nf.getUri().toString() : new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
|
||||
} else {
|
||||
localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
|
||||
}
|
||||
editor.putString("serial:" + uris[i], serial);
|
||||
}
|
||||
} catch (Exception ignored) { }
|
||||
@@ -758,7 +777,7 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
String outPath = localPaths[i];
|
||||
if (isFileValid(outPath)) { ok++; continue; }
|
||||
try {
|
||||
if (downloadToFile(url, outPath)) ok++;
|
||||
if (downloadToTarget(url, outPath)) ok++;
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
final int downloaded = ok;
|
||||
@@ -784,13 +803,19 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
return base;
|
||||
}
|
||||
|
||||
private static boolean isFileValid(String path) {
|
||||
private boolean isFileValid(String path) {
|
||||
if (path == null) return false;
|
||||
if (path.startsWith("content://")) {
|
||||
try {
|
||||
androidx.documentfile.provider.DocumentFile f = androidx.documentfile.provider.DocumentFile.fromSingleUri(requireContext(), android.net.Uri.parse(path));
|
||||
return f != null && f.length() > 0;
|
||||
} catch (Throwable ignored) { return false; }
|
||||
}
|
||||
java.io.File f = new java.io.File(path);
|
||||
return f.exists() && f.length() > 0;
|
||||
}
|
||||
|
||||
private static boolean downloadToFile(String urlStr, String outPath) throws Exception {
|
||||
private boolean downloadToTarget(String urlStr, String outPath) throws Exception {
|
||||
java.net.URL url = new java.net.URL(urlStr);
|
||||
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(10000);
|
||||
@@ -799,17 +824,29 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
conn.connect();
|
||||
int code = conn.getResponseCode();
|
||||
if (code != 200) { conn.disconnect(); return false; }
|
||||
java.io.File outFile = new java.io.File(outPath);
|
||||
java.io.File parent = outFile.getParentFile();
|
||||
if (parent != null && !parent.exists()) parent.mkdirs();
|
||||
java.io.InputStream in = conn.getInputStream();
|
||||
java.io.FileOutputStream fos = new java.io.FileOutputStream(outFile);
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) fos.write(buf, 0, n);
|
||||
fos.flush();
|
||||
fos.close();
|
||||
in.close();
|
||||
if (outPath.startsWith("content://")) {
|
||||
android.net.Uri uri = android.net.Uri.parse(outPath);
|
||||
try (java.io.OutputStream os = requireContext().getContentResolver().openOutputStream(uri, "w")) {
|
||||
if (os == null) { conn.disconnect(); return false; }
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) os.write(buf, 0, n);
|
||||
os.flush();
|
||||
}
|
||||
in.close();
|
||||
} else {
|
||||
java.io.File outFile = new java.io.File(outPath);
|
||||
java.io.File parent = outFile.getParentFile();
|
||||
if (parent != null && !parent.exists()) parent.mkdirs();
|
||||
java.io.FileOutputStream fos = new java.io.FileOutputStream(outFile);
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) fos.write(buf, 0, n);
|
||||
fos.flush();
|
||||
fos.close();
|
||||
in.close();
|
||||
}
|
||||
conn.disconnect();
|
||||
return true;
|
||||
}
|
||||
@@ -888,7 +925,7 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
private void showGameSettings(String gameTitle, String gameUri) {
|
||||
// Prefer native extraction so CHDs work
|
||||
String gameSerial = null;
|
||||
try { gameSerial = NativeApp.getGameSerial(gameUri); } catch (Throwable ignored) {}
|
||||
try { gameSerial = NativeApp.getGameSerialSafe(gameUri); } catch (Throwable ignored) {}
|
||||
if (gameSerial == null || gameSerial.isEmpty()) {
|
||||
gameSerial = extractSerialFromUri(gameUri);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import android.view.WindowInsets;
|
||||
import android.view.WindowInsetsController;
|
||||
import android.view.WindowManager;
|
||||
import android.util.TypedValue;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
@@ -58,6 +59,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
private HIDDeviceManager mHIDDeviceManager;
|
||||
private ControllerInputHandler mControllerInputHandler;
|
||||
private Thread mEmulationThread = null;
|
||||
private boolean mSetupWizardActive = false;
|
||||
private boolean mHudVisible = false;
|
||||
private InputManager mInputManager;
|
||||
|
||||
@@ -249,7 +251,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
if (hasFocus) hideStatusBar();
|
||||
}
|
||||
|
||||
private void pickGamesFolder() {
|
||||
public void pickGamesFolder() {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||
@@ -257,6 +259,11 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
startActivityResultGamesFolderPick.launch(intent);
|
||||
}
|
||||
|
||||
// Let the user select a data root (SAF) where app folders/files live (covers, resources, saves, etc.)
|
||||
public void pickDataRootFolder() {
|
||||
startActivityResultDataRootPick.launch(SafManager.buildOpenTreeIntent());
|
||||
}
|
||||
|
||||
private void showGamesListOrReselect(Uri treeUri) {
|
||||
// Re-scan quickly each time to keep list fresh
|
||||
String[] names;
|
||||
@@ -276,7 +283,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
if (namesFinal.length == 0) {
|
||||
new MaterialAlertDialogBuilder(this,
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("GAMES")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(this, "GAMES"))
|
||||
.setMessage("No games found. Pick a folder?")
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setPositiveButton("Pick Folder", (d,w) -> pickGamesFolder())
|
||||
@@ -381,6 +388,8 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
|
||||
// Default resources
|
||||
copyAssetAll(getApplicationContext(), "resources");
|
||||
// If a SAF data root is set, mirror resources to it (first time only)
|
||||
copyAssetsToSafDataRoot();
|
||||
|
||||
Initialize();
|
||||
|
||||
@@ -402,8 +411,10 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
int currentOrientation = getResources().getConfiguration().orientation;
|
||||
applyConstraintsForOrientation(currentOrientation);
|
||||
|
||||
// Prompt for BIOS if missing
|
||||
maybePromptForBios();
|
||||
// Prompt for BIOS if missing, but only after first-run setup
|
||||
if (getSharedPreferences("app_prefs", MODE_PRIVATE).getBoolean("first_run_done", false)) {
|
||||
maybePromptForBios();
|
||||
}
|
||||
|
||||
// Listen for controller attach/detach and update UI accordingly
|
||||
mInputManager = (InputManager) getSystemService(Context.INPUT_SERVICE);
|
||||
@@ -411,6 +422,17 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
mInputManager.registerInputDeviceListener(mInputDeviceListener, null);
|
||||
}
|
||||
updateUiForControllerPresence();
|
||||
|
||||
// Show first-run setup wizard if needed
|
||||
if (!getSharedPreferences("app_prefs", MODE_PRIVATE).getBoolean("first_run_done", false)) {
|
||||
SetupWizardDialogFragment f = SetupWizardDialogFragment.newInstance();
|
||||
f.setCancelable(false);
|
||||
f.show(getSupportFragmentManager(), "setup_wizard");
|
||||
}
|
||||
}
|
||||
|
||||
public void setSetupWizardActive(boolean active) {
|
||||
mSetupWizardActive = active;
|
||||
}
|
||||
|
||||
// Public method to open the games covers dialog via controller quick actions
|
||||
@@ -461,6 +483,11 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
SavesDialogFragment dialog = new SavesDialogFragment();
|
||||
dialog.show(getSupportFragmentManager(), "saves_dialog");
|
||||
});
|
||||
// Long-press: choose SAF data folder for app files (covers/resources/etc)
|
||||
btn_saves.setOnLongClickListener(v -> {
|
||||
pickDataRootFolder();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// BIOS button repurposed: short tap toggles renderer, long-press picks BIOS folder
|
||||
@@ -822,6 +849,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
return "VK"; // 14
|
||||
}
|
||||
|
||||
|
||||
private void updateRendererButtonLabel() {
|
||||
MaterialButton btn_bios = findViewById(R.id.btn_bios);
|
||||
if (btn_bios != null) {
|
||||
@@ -933,23 +961,24 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
}
|
||||
|
||||
private void maybePromptForBios() {
|
||||
// Temporarily disable automatic BIOS prompt
|
||||
if (!getSharedPreferences("app_prefs", MODE_PRIVATE).getBoolean("bios_auto_prompt_enabled", false))
|
||||
return;
|
||||
File biosDir = new File(getApplicationContext().getExternalFilesDir(null), "bios");
|
||||
if (hasAnyBiosFiles(biosDir)) return;
|
||||
showBiosPrompt();
|
||||
}
|
||||
|
||||
private boolean ensureBiosOrPrompt() {
|
||||
File biosDir = new File(getApplicationContext().getExternalFilesDir(null), "bios");
|
||||
if (hasAnyBiosFiles(biosDir)) return true;
|
||||
showBiosPrompt();
|
||||
return false;
|
||||
// Temporarily disable automatic BIOS prompting; wizard handles manual import
|
||||
return true;
|
||||
}
|
||||
|
||||
private void showBiosPrompt() {
|
||||
public void showBiosPrompt() {
|
||||
if (mBiosPromptDialog != null && mBiosPromptDialog.isShowing()) return;
|
||||
mBiosPromptDialog = new com.google.android.material.dialog.MaterialAlertDialogBuilder(this,
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("BIOS Required")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(this, "BIOS Required"))
|
||||
.setMessage("No PS2 BIOS detected. Import your BIOS files to run games.\n\nHint: Press Select+Start for Quick Actions.")
|
||||
.setNegativeButton("Later", (d, w) -> { /* leave dialog dismiss */ })
|
||||
.setPositiveButton("Pick Files", (d, w) -> {
|
||||
@@ -1011,7 +1040,8 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
final int RENDERER_SOFTWARE = 13;
|
||||
final int RENDERER_VULKAN = 14;
|
||||
|
||||
int renderer = prefs.getInt("renderer", RENDERER_VULKAN);
|
||||
// Default to Automatic (-1) so the core can select a compatible renderer on older devices
|
||||
int renderer = prefs.getInt("renderer", -1);
|
||||
NativeApp.renderGpu(renderer);
|
||||
|
||||
// Resolution scale multiplier (float), default 1.0
|
||||
@@ -1151,24 +1181,74 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
getContentResolver().takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
} catch (SecurityException ignored) {}
|
||||
}
|
||||
// Save folder and show games
|
||||
// Save folder and optionally show games
|
||||
getSharedPreferences("app_prefs", MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("games_folder_uri", treeUri.toString())
|
||||
.apply();
|
||||
showGamesListOrReselect(treeUri);
|
||||
if (!mSetupWizardActive) {
|
||||
showGamesListOrReselect(treeUri);
|
||||
} else {
|
||||
Toast.makeText(this, "Games folder set", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
});
|
||||
|
||||
// SAF data-root picker result
|
||||
public final ActivityResultLauncher<Intent> startActivityResultDataRootPick = registerForActivityResult(
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {
|
||||
if (result.getResultCode() == Activity.RESULT_OK) {
|
||||
try {
|
||||
Intent data = result.getData();
|
||||
if (data != null) {
|
||||
Uri treeUri = data.getData();
|
||||
if (treeUri != null) {
|
||||
final int takeFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
try { getContentResolver().takePersistableUriPermission(treeUri, takeFlags); } catch (SecurityException ignored) {}
|
||||
SafManager.setDataRootUri(this, treeUri);
|
||||
// Seed default resources to SAF data root
|
||||
copyAssetsToSafDataRoot();
|
||||
Toast.makeText(this, "Data folder set", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
});
|
||||
|
||||
// Copies assets/resources under the selected SAF data root (resources/..)
|
||||
private void copyAssetsToSafDataRoot() {
|
||||
Uri root = SafManager.getDataRootUri(this);
|
||||
if (root == null) return;
|
||||
// Only seed once
|
||||
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
|
||||
if (prefs.getBoolean("saf_resources_seeded", false)) return;
|
||||
// Flatten copy of assets/resources directory to SAF
|
||||
try {
|
||||
copyAssetAllToSaf(getApplicationContext(), "resources");
|
||||
prefs.edit().putBoolean("saf_resources_seeded", true).apply();
|
||||
} catch (Throwable ignored) {}
|
||||
}
|
||||
|
||||
private void importSingleBiosUri(Uri uri, File biosDir) {
|
||||
if (uri == null) return;
|
||||
String displayName = getDisplayNameFromUri(this, uri);
|
||||
if (TextUtils.isEmpty(displayName)) displayName = "bios.bin";
|
||||
File outFile = new File(biosDir, displayName);
|
||||
copyUriToFile(this, uri, outFile);
|
||||
// Also mirror to SAF data root if set
|
||||
android.net.Uri dataRoot = SafManager.getDataRootUri(this);
|
||||
if (dataRoot != null) {
|
||||
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(this, new String[]{"bios"}, displayName, "application/octet-stream");
|
||||
if (target != null) {
|
||||
try (java.io.InputStream in = getContentResolver().openInputStream(uri)) {
|
||||
SafManager.copyFromStream(this, in, target.getUri());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void copyDocumentTreeToDirectory(DocumentFile dir, File outDir) {
|
||||
@@ -1334,7 +1414,8 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
|
||||
// Apply global renderer setting before starting new game
|
||||
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
|
||||
int renderer = prefs.getInt("renderer", 14); // Default to Vulkan if no setting
|
||||
// Default to Automatic (-1) so the core can pick the best available backend (Vulkan/OpenGL/Software)
|
||||
int renderer = prefs.getInt("renderer", -1);
|
||||
android.util.Log.d("MainActivity", "Applying global renderer before game restart: " + renderer);
|
||||
NativeApp.renderGpu(renderer);
|
||||
|
||||
@@ -1342,6 +1423,8 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
startEmuThread();
|
||||
}
|
||||
|
||||
// (Renderer toast temporarily removed per user request — AUTO behavior retained.)
|
||||
|
||||
// Public API for UI components to reboot the emulator
|
||||
public void rebootEmu() {
|
||||
if (!TextUtils.isEmpty(m_szGamefile)) {
|
||||
@@ -1352,6 +1435,8 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public boolean onGenericMotionEvent(MotionEvent event) {
|
||||
// Use only our controller handler - disable SDL fallback to avoid conflicts
|
||||
@@ -1452,6 +1537,46 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
// Mirror asset folder into SAF data root (if set)
|
||||
private void copyAssetAllToSaf(Context context, String srcPath) {
|
||||
Uri dataRoot = SafManager.getDataRootUri(context);
|
||||
if (dataRoot == null) return;
|
||||
AssetManager assetMgr = context.getAssets();
|
||||
try {
|
||||
String[] assets = assetMgr.list(srcPath);
|
||||
if (assets != null) {
|
||||
if (assets.length == 0) {
|
||||
// It's a file under srcPath; create it in SAF
|
||||
String[] parts = srcPath.split("/");
|
||||
String filename = parts.length > 0 ? parts[parts.length - 1] : srcPath;
|
||||
String[] dirSegs = parts.length > 1 ? java.util.Arrays.copyOf(parts, parts.length - 1) : new String[0];
|
||||
DocumentFile existing = SafManager.getChild(context, dirSegs, filename);
|
||||
if (existing != null && existing.length() > 0) return;
|
||||
DocumentFile target = SafManager.createChild(context, dirSegs, filename, guessMime(filename));
|
||||
if (target != null) {
|
||||
try (InputStream is = assetMgr.open(srcPath)) {
|
||||
SafManager.copyFromStream(context, is, target.getUri());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
} else {
|
||||
for (String element : assets) {
|
||||
copyAssetAllToSaf(context, srcPath + File.separator + element);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
private static String guessMime(String filename) {
|
||||
String lower = filename.toLowerCase(java.util.Locale.ROOT);
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml";
|
||||
if (lower.endsWith(".zip")) return "application/zip";
|
||||
if (lower.endsWith(".txt")) return "text/plain";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
private static void copyFile(Context context, String srcFile, String destFile) {
|
||||
AssetManager assetMgr = context.getAssets();
|
||||
InputStream is = null;
|
||||
@@ -1490,7 +1615,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
private void showExitDialog() {
|
||||
new MaterialAlertDialogBuilder(this,
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("Exit App")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(this, "Exit App"))
|
||||
.setMessage("Do you want to exit PSX2?")
|
||||
.setIcon(android.R.drawable.ic_dialog_alert)
|
||||
.setPositiveButton("Exit", (dialog, which) -> {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.izzy2lost.psx2;
|
||||
|
||||
import com.bumptech.glide.annotation.GlideModule;
|
||||
import com.bumptech.glide.module.AppGlideModule;
|
||||
|
||||
@GlideModule
|
||||
public final class MyAppGlideModule extends AppGlideModule {
|
||||
@Override
|
||||
public boolean isManifestParsingEnabled() {
|
||||
// Avoid manifest parsing to speed up initialization and prevent double modules
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,42 @@ public class NativeApp {
|
||||
public static native String getGameSerial(String gameUri);
|
||||
public static native String getGameCrc(String gameUri);
|
||||
public static native String getCurrentGameSerial();
|
||||
|
||||
// Synchronization object for CDVD operations to prevent crashes
|
||||
private static final Object CDVD_LOCK = new Object();
|
||||
|
||||
// Synchronized wrapper for getGameSerial to prevent CDVD race conditions
|
||||
public static String getGameSerialSafe(String gameUri) {
|
||||
synchronized (CDVD_LOCK) {
|
||||
try {
|
||||
return getGameSerial(gameUri);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronized wrapper for getGameTitleFromUri to prevent CDVD race conditions
|
||||
public static String getGameTitleFromUriSafe(String gameUri) {
|
||||
synchronized (CDVD_LOCK) {
|
||||
try {
|
||||
return getGameTitleFromUri(gameUri);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronized wrapper for getGameCrc to prevent CDVD race conditions
|
||||
public static String getGameCrcSafe(String gameUri) {
|
||||
synchronized (CDVD_LOCK) {
|
||||
try {
|
||||
return getGameCrc(gameUri);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static native void onNativeSurfaceCreated();
|
||||
public static native void onNativeSurfaceChanged(Surface surface, int w, int h);
|
||||
|
||||
@@ -40,7 +40,7 @@ public class QuickActionsDialogFragment extends DialogFragment {
|
||||
btnExitToMenu.setOnClickListener(v -> {
|
||||
new MaterialAlertDialogBuilder(requireContext(),
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("Exit to Menu")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(requireContext(), "Exit to Menu"))
|
||||
.setMessage("This feature is not implemented yet. Would you like to quit the app instead?")
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setPositiveButton("Quit App", (d, w) -> {
|
||||
@@ -56,7 +56,7 @@ public class QuickActionsDialogFragment extends DialogFragment {
|
||||
btnRestartGame.setOnClickListener(v -> {
|
||||
new MaterialAlertDialogBuilder(requireContext(),
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("Restart Game")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(requireContext(), "Restart Game"))
|
||||
.setMessage("Restart the current game?")
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setPositiveButton("Restart", (d, w) -> {
|
||||
@@ -76,7 +76,7 @@ public class QuickActionsDialogFragment extends DialogFragment {
|
||||
btnQuitApp.setOnClickListener(v -> {
|
||||
new MaterialAlertDialogBuilder(requireContext(),
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("Quit App")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(requireContext(), "Quit App"))
|
||||
.setMessage("Quit PSX2?")
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setPositiveButton("Quit", (d, w) -> {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.izzy2lost.psx2;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.provider.DocumentsContract;
|
||||
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* Minimal helper around Android SAF for a user-selected data root directory.
|
||||
* Stores a persisted tree URI in SharedPreferences and provides helpers to
|
||||
* create/list/read/write files under subdirectories (e.g., covers, resources).
|
||||
*/
|
||||
public final class SafManager {
|
||||
private static final String PREFS = "app_prefs";
|
||||
private static final String KEY_DATA_ROOT = "data_root_tree_uri";
|
||||
|
||||
private SafManager() {}
|
||||
|
||||
public static Uri getDataRootUri(Context ctx) {
|
||||
SharedPreferences prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
String s = prefs.getString(KEY_DATA_ROOT, null);
|
||||
return (s != null && !s.isEmpty()) ? Uri.parse(s) : null;
|
||||
}
|
||||
|
||||
public static void setDataRootUri(Context ctx, Uri treeUri) {
|
||||
SharedPreferences prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
prefs.edit().putString(KEY_DATA_ROOT, treeUri != null ? treeUri.toString() : null).apply();
|
||||
}
|
||||
|
||||
public static DocumentFile getDataRoot(Context ctx) {
|
||||
Uri u = getDataRootUri(ctx);
|
||||
if (u == null) return null;
|
||||
return DocumentFile.fromTreeUri(ctx, u);
|
||||
}
|
||||
|
||||
public static DocumentFile getOrCreateDir(Context ctx, String... segments) {
|
||||
DocumentFile root = getDataRoot(ctx);
|
||||
if (root == null) return null;
|
||||
DocumentFile cur = root;
|
||||
for (String seg : segments) {
|
||||
if (seg == null || seg.isEmpty()) continue;
|
||||
DocumentFile next = cur.findFile(seg);
|
||||
if (next == null) next = cur.createDirectory(seg);
|
||||
if (next == null) return null;
|
||||
cur = next;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
public static DocumentFile getChild(Context ctx, String[] dirSegments, String filename) {
|
||||
DocumentFile dir = getOrCreateDir(ctx, dirSegments);
|
||||
if (dir == null) return null;
|
||||
DocumentFile f = dir.findFile(filename);
|
||||
return f;
|
||||
}
|
||||
|
||||
public static DocumentFile createChild(Context ctx, String[] dirSegments, String filename, String mime) {
|
||||
DocumentFile dir = getOrCreateDir(ctx, dirSegments);
|
||||
if (dir == null) return null;
|
||||
DocumentFile f = dir.findFile(filename);
|
||||
if (f != null && f.isFile()) return f;
|
||||
return dir.createFile(mime != null ? mime : "application/octet-stream", filename);
|
||||
}
|
||||
|
||||
public static boolean writeBytes(Context ctx, Uri target, byte[] data) {
|
||||
if (target == null || data == null) return false;
|
||||
try (OutputStream os = ctx.getContentResolver().openOutputStream(target, "w")) {
|
||||
if (os == null) return false;
|
||||
os.write(data);
|
||||
os.flush();
|
||||
return true;
|
||||
} catch (Exception ignored) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean copyFromStream(Context ctx, InputStream in, Uri target) {
|
||||
if (in == null || target == null) return false;
|
||||
try (OutputStream os = ctx.getContentResolver().openOutputStream(target, "w")) {
|
||||
if (os == null) return false;
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) os.write(buf, 0, n);
|
||||
os.flush();
|
||||
return true;
|
||||
} catch (Exception ignored) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean exists(Context ctx, Uri uri) {
|
||||
try (InputStream is = ctx.getContentResolver().openInputStream(uri)) {
|
||||
return is != null;
|
||||
} catch (Exception ignored) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static Intent buildOpenTreeIntent() {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION |
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION |
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION |
|
||||
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
|
||||
return intent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ public class SettingsDialogFragment extends DialogFragment {
|
||||
btnReboot.setOnClickListener(v -> {
|
||||
new MaterialAlertDialogBuilder(requireContext(),
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("Reboot")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(requireContext(), "Reboot"))
|
||||
.setMessage("Restart the current game?")
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setPositiveButton("Reboot", (d1, w1) -> {
|
||||
@@ -188,6 +188,7 @@ public class SettingsDialogFragment extends DialogFragment {
|
||||
boolean savedLoadTextures = prefs.getBoolean("load_textures", false);
|
||||
boolean savedAsyncTextureLoading = prefs.getBoolean("async_texture_loading", true);
|
||||
boolean savedHud = prefs.getBoolean("hud_visible", false);
|
||||
boolean savedCheatsGlobal = prefs.getBoolean("enable_cheats", false);
|
||||
int savedBlending = prefs.getInt("blending_accuracy", 1);
|
||||
|
||||
if (savedRenderer == RENDERER_VULKAN && rbVk != null) rbVk.setChecked(true);
|
||||
@@ -213,10 +214,12 @@ public class SettingsDialogFragment extends DialogFragment {
|
||||
swLoadTextures.setChecked(savedLoadTextures);
|
||||
swAsyncTextureLoading.setChecked(savedAsyncTextureLoading);
|
||||
if (swDevHud != null) swDevHud.setChecked(savedHud);
|
||||
MaterialSwitch swCheatsGlobal = view.findViewById(R.id.sw_enable_cheats_global);
|
||||
if (swCheatsGlobal != null) swCheatsGlobal.setChecked(savedCheatsGlobal);
|
||||
|
||||
MaterialAlertDialogBuilder b = new MaterialAlertDialogBuilder(requireContext(),
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog);
|
||||
b.setTitle("Global Settings")
|
||||
b.setCustomTitle(UiUtils.centeredDialogTitle(requireContext(), "Global Settings"))
|
||||
.setView(view)
|
||||
.setNegativeButton("Cancel", (d, w) -> d.dismiss())
|
||||
.setPositiveButton("Save", (d, w) -> {
|
||||
@@ -233,21 +236,23 @@ public class SettingsDialogFragment extends DialogFragment {
|
||||
boolean noInterlacingPatches = swNoInterlacing.isChecked();
|
||||
boolean loadTextures = swLoadTextures.isChecked();
|
||||
boolean asyncTextureLoading = swAsyncTextureLoading.isChecked();
|
||||
boolean hudVisible = (swDevHud != null && swDevHud.isChecked());
|
||||
boolean hudVisible = (swDevHud != null && swDevHud.isChecked());
|
||||
boolean enableCheatsGlobal = swCheatsGlobal != null && swCheatsGlobal.isChecked();
|
||||
|
||||
// Persist settings to SharedPreferences
|
||||
int blendingLevel = spBlending.getSelectedItemPosition();
|
||||
prefs.edit()
|
||||
.putInt("renderer", renderer)
|
||||
.putFloat("upscale_multiplier", scale)
|
||||
.putInt("aspect_ratio", aspectRatio)
|
||||
.putInt("blending_accuracy", blendingLevel)
|
||||
.putBoolean("widescreen_patches", widescreenPatches)
|
||||
.putBoolean("no_interlacing_patches", noInterlacingPatches)
|
||||
.putBoolean("load_textures", loadTextures)
|
||||
.putBoolean("async_texture_loading", asyncTextureLoading)
|
||||
.putBoolean("hud_visible", hudVisible)
|
||||
.apply();
|
||||
prefs.edit()
|
||||
.putInt("renderer", renderer)
|
||||
.putFloat("upscale_multiplier", scale)
|
||||
.putInt("aspect_ratio", aspectRatio)
|
||||
.putInt("blending_accuracy", blendingLevel)
|
||||
.putBoolean("widescreen_patches", widescreenPatches)
|
||||
.putBoolean("no_interlacing_patches", noInterlacingPatches)
|
||||
.putBoolean("load_textures", loadTextures)
|
||||
.putBoolean("async_texture_loading", asyncTextureLoading)
|
||||
.putBoolean("hud_visible", hudVisible)
|
||||
.putBoolean("enable_cheats", enableCheatsGlobal)
|
||||
.apply();
|
||||
|
||||
// Apply in one batch to avoid repeated ApplySettings calls
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.izzy2lost.psx2;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.Gravity;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public class SetupWizardDialogFragment extends DialogFragment {
|
||||
public static SetupWizardDialogFragment newInstance() { return new SetupWizardDialogFragment(); }
|
||||
|
||||
private MaterialButton btnData;
|
||||
private MaterialButton btnGames;
|
||||
private MaterialButton btnBios;
|
||||
private MaterialButton btnDone;
|
||||
private TextView titleView;
|
||||
private TextView hintView;
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
|
||||
Dialog d = new Dialog(requireContext(), R.style.PSX2_FullScreenDialog);
|
||||
d.setContentView(buildContent());
|
||||
return d;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
try { ((MainActivity) requireActivity()).setSetupWizardActive(true); } catch (Throwable ignored) {}
|
||||
// Refresh state (in case a step completed while this dialog was covered by a picker)
|
||||
try { updateUi(); } catch (Throwable ignored) {}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDismiss(@NonNull android.content.DialogInterface dialog) {
|
||||
super.onDismiss(dialog);
|
||||
try { ((MainActivity) requireActivity()).setSetupWizardActive(false); } catch (Throwable ignored) {}
|
||||
}
|
||||
|
||||
private View buildContent() {
|
||||
final LinearLayout root = new LinearLayout(requireContext());
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
root.setGravity(Gravity.CENTER);
|
||||
int pad = (int)(24 * getResources().getDisplayMetrics().density);
|
||||
root.setPadding(pad, pad, pad, pad);
|
||||
root.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
titleView = new TextView(requireContext());
|
||||
titleView.setText("Welcome! Let's set up PSX2");
|
||||
titleView.setTextSize(22f);
|
||||
titleView.setGravity(Gravity.CENTER_HORIZONTAL);
|
||||
titleView.setTextAlignment(View.TEXT_ALIGNMENT_CENTER);
|
||||
root.addView(titleView);
|
||||
|
||||
// Subtitle removed; using inline hint near the Done button instead.
|
||||
|
||||
int btnHeight = (int)(48 * getResources().getDisplayMetrics().density);
|
||||
|
||||
btnData = new MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle);
|
||||
btnData.setText("1) Choose Data Folder");
|
||||
btnData.setMinimumHeight(btnHeight);
|
||||
btnData.setIconGravity(MaterialButton.ICON_GRAVITY_TEXT_START);
|
||||
btnData.setIconPadding((int)(8 * getResources().getDisplayMetrics().density));
|
||||
btnData.setOnClickListener(v -> {
|
||||
MainActivity a = (MainActivity) requireActivity();
|
||||
a.pickDataRootFolder();
|
||||
});
|
||||
LinearLayout.LayoutParams lp1 = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
lp1.topMargin = (int)(24 * getResources().getDisplayMetrics().density);
|
||||
btnData.setLayoutParams(lp1);
|
||||
root.addView(btnData);
|
||||
|
||||
btnGames = new MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle);
|
||||
btnGames.setText("2) Choose Games Folder");
|
||||
btnGames.setMinimumHeight(btnHeight);
|
||||
btnGames.setIconGravity(MaterialButton.ICON_GRAVITY_TEXT_START);
|
||||
btnGames.setIconPadding((int)(8 * getResources().getDisplayMetrics().density));
|
||||
btnGames.setOnClickListener(v -> {
|
||||
MainActivity a = (MainActivity) requireActivity();
|
||||
a.pickGamesFolder();
|
||||
});
|
||||
LinearLayout.LayoutParams lp2 = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
lp2.topMargin = (int)(12 * getResources().getDisplayMetrics().density);
|
||||
btnGames.setLayoutParams(lp2);
|
||||
root.addView(btnGames);
|
||||
|
||||
btnBios = new MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle);
|
||||
btnBios.setText("3) Import BIOS Files");
|
||||
btnBios.setMinimumHeight(btnHeight);
|
||||
btnBios.setIconGravity(MaterialButton.ICON_GRAVITY_TEXT_START);
|
||||
btnBios.setIconPadding((int)(8 * getResources().getDisplayMetrics().density));
|
||||
btnBios.setOnClickListener(v -> {
|
||||
// Reuse existing BIOS prompt flow
|
||||
MainActivity a = (MainActivity) requireActivity();
|
||||
a.showBiosPrompt();
|
||||
});
|
||||
LinearLayout.LayoutParams lp3 = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
lp3.topMargin = (int)(12 * getResources().getDisplayMetrics().density);
|
||||
btnBios.setLayoutParams(lp3);
|
||||
root.addView(btnBios);
|
||||
|
||||
btnDone = new MaterialButton(requireContext());
|
||||
btnDone.setText("Done");
|
||||
btnDone.setMinimumHeight(btnHeight);
|
||||
btnDone.setOnClickListener(v -> {
|
||||
if (isDataFolderPicked() && isGamesFolderPicked() && isBiosPresent()) {
|
||||
requireContext().getSharedPreferences("app_prefs", android.content.Context.MODE_PRIVATE)
|
||||
.edit().putBoolean("first_run_done", true).apply();
|
||||
try { ((MainActivity) requireActivity()).setSetupWizardActive(false); } catch (Throwable ignored) {}
|
||||
dismissAllowingStateLoss();
|
||||
}
|
||||
});
|
||||
LinearLayout.LayoutParams lp4 = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
lp4.topMargin = (int)(28 * getResources().getDisplayMetrics().density);
|
||||
btnDone.setLayoutParams(lp4);
|
||||
root.addView(btnDone);
|
||||
|
||||
// Inline hint below Done button
|
||||
hintView = new TextView(requireContext());
|
||||
hintView.setText("Complete all steps to finish.");
|
||||
hintView.setTextSize(14f);
|
||||
hintView.setGravity(Gravity.CENTER_HORIZONTAL);
|
||||
hintView.setTextAlignment(View.TEXT_ALIGNMENT_CENTER);
|
||||
hintView.setAlpha(0.8f);
|
||||
LinearLayout.LayoutParams hintLp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
hintLp.topMargin = (int)(8 * getResources().getDisplayMetrics().density);
|
||||
hintView.setLayoutParams(hintLp);
|
||||
root.addView(hintView);
|
||||
|
||||
// Initialize state
|
||||
updateUi();
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private boolean isDataFolderPicked() {
|
||||
return SafManager.getDataRootUri(requireContext()) != null;
|
||||
}
|
||||
|
||||
private boolean isGamesFolderPicked() {
|
||||
String s = requireContext().getSharedPreferences("app_prefs", android.content.Context.MODE_PRIVATE)
|
||||
.getString("games_folder_uri", null);
|
||||
return s != null && !s.isEmpty();
|
||||
}
|
||||
|
||||
private boolean isBiosPresent() {
|
||||
File biosDir = new File(requireContext().getExternalFilesDir(null), "bios");
|
||||
if (biosDir != null && biosDir.isDirectory()) {
|
||||
File[] fs = biosDir.listFiles();
|
||||
if (fs != null) {
|
||||
for (File f : fs) {
|
||||
if (f != null && f.isFile()) {
|
||||
String lower = f.getName().toLowerCase(java.util.Locale.ROOT);
|
||||
boolean isMainBios = lower.startsWith("scph") && (lower.endsWith(".bin") || lower.endsWith(".rom"));
|
||||
boolean isComponentSuffix = lower.endsWith(".rom0") || lower.endsWith(".rom1") || lower.endsWith(".rom2") || lower.endsWith(".erom");
|
||||
boolean isBareComponent = lower.equals("rom0") || lower.equals("rom1") || lower.equals("rom2") || lower.equals("erom");
|
||||
if ((isMainBios && f.length() >= 256 * 1024) || (isComponentSuffix || isBareComponent))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void updateUi() {
|
||||
boolean step1 = isDataFolderPicked();
|
||||
boolean step2 = isGamesFolderPicked();
|
||||
boolean step3 = isBiosPresent();
|
||||
|
||||
btnData.setText("1) Choose Data Folder");
|
||||
btnGames.setText("2) Choose Games Folder");
|
||||
btnBios.setText("3) Import BIOS Files");
|
||||
|
||||
btnData.setIcon(step1 ? ContextCompat.getDrawable(requireContext(), R.drawable.check_circle_24px) : null);
|
||||
btnGames.setIcon(step2 ? ContextCompat.getDrawable(requireContext(), R.drawable.check_circle_24px) : null);
|
||||
btnBios.setIcon(step3 ? ContextCompat.getDrawable(requireContext(), R.drawable.check_circle_24px) : null);
|
||||
|
||||
btnGames.setEnabled(step1);
|
||||
btnBios.setEnabled(step1 && step2);
|
||||
boolean doneEnabled = (step1 && step2 && step3);
|
||||
btnDone.setEnabled(doneEnabled);
|
||||
if (hintView != null) hintView.setVisibility(doneEnabled ? View.GONE : View.VISIBLE);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,11 @@ 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
|
||||
@@ -32,17 +29,9 @@ public final class TitleResolver {
|
||||
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
|
||||
// Only use YAML sources 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;
|
||||
}
|
||||
|
||||
@@ -57,7 +46,15 @@ public final class TitleResolver {
|
||||
|
||||
// 3) Resolve serial via native; if missing, try filename hint
|
||||
String serial = null;
|
||||
try { serial = NativeApp.getGameSerial(uriString); } catch (Throwable ignored) {}
|
||||
// Prefer previously-cached serial to avoid heavy native reads on first run
|
||||
try {
|
||||
android.content.SharedPreferences prefs = ctx.getSharedPreferences("app_prefs", Context.MODE_PRIVATE);
|
||||
String saved = prefs.getString("serial:" + uriString, null);
|
||||
if (saved != null && !saved.isEmpty()) serial = saved;
|
||||
} catch (Throwable ignored) {}
|
||||
if (serial == null || serial.isEmpty()) {
|
||||
try { serial = NativeApp.getGameSerialSafe(uriString); } catch (Throwable ignored) {}
|
||||
}
|
||||
if (serial == null || serial.isEmpty()) {
|
||||
Uri u = Uri.parse(uriString);
|
||||
String name = u.getLastPathSegment();
|
||||
@@ -76,7 +73,7 @@ public final class TitleResolver {
|
||||
|
||||
// 5) Fallback to native URI title if available
|
||||
String nativeTitle = null;
|
||||
try { nativeTitle = NativeApp.getGameTitleFromUri(uriString); } catch (Throwable ignored) {}
|
||||
try { nativeTitle = NativeApp.getGameTitleFromUriSafe(uriString); } catch (Throwable ignored) {}
|
||||
if (nativeTitle != null && !nativeTitle.isEmpty()) {
|
||||
putCachedTitle(ctx, uriString, nativeTitle);
|
||||
return nativeTitle;
|
||||
@@ -119,38 +116,7 @@ public final class TitleResolver {
|
||||
} 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) {}
|
||||
}
|
||||
// JSON handling removed. YAML index is used exclusively.
|
||||
|
||||
private static String getCachedTitle(Context ctx, String uri) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.izzy2lost.psx2;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.Gravity;
|
||||
import android.widget.TextView;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
class UiUtils {
|
||||
static TextView centeredDialogTitle(Context ctx, String title) {
|
||||
TextView tv = new TextView(ctx);
|
||||
tv.setText(title);
|
||||
tv.setGravity(Gravity.CENTER_HORIZONTAL);
|
||||
int pad = (int) (ctx.getResources().getDisplayMetrics().density * 16);
|
||||
tv.setPadding(pad, pad, pad, pad / 2);
|
||||
tv.setTextAppearance(ctx, com.google.android.material.R.style.TextAppearance_Material3_TitleLarge);
|
||||
// Use brand primary (now mapped to brighter pink/purple) for dialog titles
|
||||
try { tv.setTextColor(ContextCompat.getColor(ctx, R.color.brand_primary)); } catch (Throwable ignored) {}
|
||||
return tv;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user