better SAF - texture pack support etc.

This commit is contained in:
izzy2lost
2025-09-07 03:45:34 -04:00
parent e1161027f1
commit 56c53405f6
15 changed files with 797 additions and 111 deletions
@@ -0,0 +1,143 @@
package com.izzy2lost.psx2;
import android.app.Dialog;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.io.InputStream;
import java.util.ArrayList;
public class CheatsDialogFragment extends DialogFragment {
private ArrayAdapter<String> adapter;
private ArrayList<String> items = new ArrayList<>();
private static final int REQ_IMPORT_PNACH = 1001;
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
Context ctx = requireContext();
View view = LayoutInflater.from(ctx).inflate(R.layout.simple_list, null, false);
ListView lv = view.findViewById(android.R.id.list);
adapter = new ArrayAdapter<>(ctx, android.R.layout.simple_list_item_1, items);
lv.setAdapter(adapter);
lv.setOnItemLongClickListener((parent, v, position, id) -> {
String name = items.get(position);
deleteCheat(name);
return true;
});
refreshList();
return new MaterialAlertDialogBuilder(ctx, com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
.setCustomTitle(UiUtils.centeredDialogTitle(ctx, "Manage Cheats"))
.setView(view)
.setNegativeButton("Close", null)
.setPositiveButton("Import For Game", (d, w) -> startImport())
.create();
}
private void refreshList() {
items.clear();
String[] names = NativeApp.listSafFilenames("cheats");
if (names != null && names.length > 0) {
for (String n : names) if (n != null && n.endsWith(".pnach")) items.add(n);
} else {
java.io.File dir = new java.io.File(requireContext().getExternalFilesDir(null), "cheats");
if (!dir.exists()) dir.mkdirs();
java.io.File[] arr = dir.listFiles((f, n) -> n != null && n.endsWith(".pnach"));
if (arr != null) for (java.io.File f : arr) items.add(f.getName());
}
if (adapter != null) adapter.notifyDataSetChanged();
}
private void startImport() {
try {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
startActivityForResult(intent, REQ_IMPORT_PNACH);
} catch (Throwable t) {
Toast.makeText(requireContext(), "No file picker available", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQ_IMPORT_PNACH && resultCode == android.app.Activity.RESULT_OK && data != null) {
Uri uri = data.getData();
if (uri == null) return;
String serial = null;
try { serial = NativeApp.getCurrentGameSerial(); } catch (Throwable ignored) {}
if (serial == null || serial.isEmpty()) {
Toast.makeText(requireContext(), "Unknown game serial", Toast.LENGTH_SHORT).show();
return;
}
String outName = serial + ".pnach";
android.net.Uri dataRoot = SafManager.getDataRootUri(requireContext());
if (dataRoot != null) {
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(requireContext(), new String[]{"cheats"}, outName, "application/octet-stream");
if (target != null) {
try (InputStream in = requireContext().getContentResolver().openInputStream(uri)) {
if (in != null && SafManager.copyFromStream(requireContext(), in, target.getUri())) {
Toast.makeText(requireContext(), "Imported to Data Folder", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(requireContext(), "Import failed", Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
Toast.makeText(requireContext(), "Import failed", Toast.LENGTH_SHORT).show();
}
}
} else {
java.io.File dir = new java.io.File(requireContext().getExternalFilesDir(null), "cheats");
if (!dir.exists()) dir.mkdirs();
java.io.File out = new java.io.File(dir, outName);
try (InputStream in = requireContext().getContentResolver().openInputStream(uri);
java.io.FileOutputStream os = new java.io.FileOutputStream(out)) {
if (in != null) {
byte[] buf = new byte[8192]; int n; while ((n = in.read(buf)) != -1) os.write(buf, 0, n);
os.flush();
Toast.makeText(requireContext(), "Imported", Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
Toast.makeText(requireContext(), "Import failed", Toast.LENGTH_SHORT).show();
}
}
refreshList();
}
}
private void deleteCheat(String name) {
android.net.Uri dataRoot = SafManager.getDataRootUri(requireContext());
boolean ok = false;
if (dataRoot != null) {
androidx.documentfile.provider.DocumentFile f = SafManager.getChild(requireContext(), new String[]{"cheats"}, name);
if (f != null) ok = f.delete();
} else {
java.io.File dir = new java.io.File(requireContext().getExternalFilesDir(null), "cheats");
java.io.File f = new java.io.File(dir, name);
ok = f.delete();
}
if (ok) {
Toast.makeText(requireContext(), "Deleted", Toast.LENGTH_SHORT).show();
refreshList();
} else {
Toast.makeText(requireContext(), "Delete failed", Toast.LENGTH_SHORT).show();
}
}
}
@@ -80,7 +80,7 @@ public class GameSettingsDialogFragment extends DialogFragment {
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");
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{subdir}, gameSerial + ".pnach", "application/octet-stream");
if (target != null) {
try (java.io.InputStream in2 = cr.openInputStream(android.net.Uri.fromFile(outFile))) {
SafManager.copyFromStream(ctx, in2, target.getUri());
@@ -428,7 +428,7 @@ public class GameSettingsDialogFragment extends DialogFragment {
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");
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{"gamesettings"}, fileName, "application/octet-stream");
if (target != null) {
byte[] data = sb.toString().getBytes("UTF-8");
SafManager.writeBytes(ctx, target.getUri(), data);
@@ -521,6 +521,13 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
SettingsDialogFragment dialog = new SettingsDialogFragment();
dialog.show(fm, "settings_dialog");
});
// Long-press to open Cheats manager
btn_settings.setOnLongClickListener(v -> {
FragmentManager fm2 = getSupportFragmentManager();
CheatsDialogFragment cd = new CheatsDialogFragment();
cd.show(fm2, "cheats_dialog");
return true;
});
}
// Toggle all UI visibility (including controls)
@@ -1742,3 +1749,4 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
}
}
}
@@ -35,7 +35,7 @@ public class NativeApp {
initialize(externalFilesDir.getAbsolutePath(), android.os.Build.VERSION.SDK_INT);
}
public static native void initialize(String path, int apiVer);
public static native void initialize(String path, int apiVer);
public static native String getGameTitle(String path);
public static native String getGameTitleFromUri(String gameUri);
public static native String getGameSerial();
@@ -69,6 +69,7 @@ public class NativeApp {
// Texture loading options for texture packs
public static native void setLoadTextures(boolean enabled);
public static native void setAsyncTextureLoading(boolean enabled);
public static native void setPrecacheTextureReplacements(boolean enabled);
public static native void setBlendingAccuracy(int level);
// Shade Boost (brightness/contrast/saturation)
@@ -154,7 +155,7 @@ public class NativeApp {
public static native void onNativeSurfaceChanged(Surface surface, int w, int h);
public static native void onNativeSurfaceDestroyed();
public static native boolean runVMThread(String path);
public static native boolean runVMThread(String path);
public static native void pause();
public static native void resume();
@@ -166,17 +167,149 @@ public class NativeApp {
public static native byte[] getImageSlot(int slot);
// Call jni
public static int openContentUri(String uriString) {
Context _context = getContext();
if(_context != null) {
ContentResolver _contentResolver = _context.getContentResolver();
try {
ParcelFileDescriptor filePfd = _contentResolver.openFileDescriptor(Uri.parse(uriString), "r");
if (filePfd != null) {
return filePfd.detachFd(); // Take ownership of the fd.
}
} catch (Exception ignored) {}
}
return -1;
}
public static int openContentUri(String uriString) {
Context _context = getContext();
if(_context != null) {
ContentResolver _contentResolver = _context.getContentResolver();
try {
ParcelFileDescriptor filePfd = _contentResolver.openFileDescriptor(Uri.parse(uriString), "r");
if (filePfd != null) {
return filePfd.detachFd(); // Take ownership of the fd.
}
} catch (Exception ignored) {}
}
return -1;
}
// Indicates whether a SAF Data Root has been selected by the user.
public static boolean hasSafDataRoot() {
return SafManager.getDataRootUri(getContext()) != null;
}
// Open a SAF content Uri with the requested mode ("r", "w", or "rw"). Returns a detached FD or -1.
public static int openContentUriMode(String uriString, String mode) {
Context _context = getContext();
if(_context != null) {
ContentResolver _contentResolver = _context.getContentResolver();
try {
ParcelFileDescriptor filePfd = _contentResolver.openFileDescriptor(Uri.parse(uriString), mode);
if (filePfd != null) {
return filePfd.detachFd();
}
} catch (Exception ignored) {}
}
return -1;
}
// Resolve a child document Uri within the SAF Data Root.
// subdir: e.g., "gamesettings", filename: e.g., "SLUS-12345.ini". If create is true, creates file.
public static String resolveSafChildUri(String subdir, String filename, boolean create) {
Uri root = SafManager.getDataRootUri(getContext());
if (root == null) return null;
try {
androidx.documentfile.provider.DocumentFile df;
if (create) {
df = SafManager.createChild(getContext(), new String[]{subdir}, filename, "application/octet-stream");
} else {
df = SafManager.getChild(getContext(), new String[]{subdir}, filename);
}
return (df != null) ? df.getUri().toString() : null;
} catch (Throwable ignored) { }
return null;
}
// Resolve a file path relative to the SAF Data Root. Accepts nested paths like
// "textures/SLUS-12345/replacements/subdir/file.png". If create is true, creates the file.
public static String resolveSafPathUri(String relativePath, boolean create) {
if (relativePath == null) return null;
Uri root = SafManager.getDataRootUri(getContext());
if (root == null) return null;
try {
String[] parts = relativePath.split("/");
if (parts.length == 0) return null;
String[] dirSegs;
String filename;
if (parts.length == 1) {
dirSegs = new String[]{};
filename = parts[0];
} else {
dirSegs = new String[parts.length - 1];
System.arraycopy(parts, 0, dirSegs, 0, parts.length - 1);
filename = parts[parts.length - 1];
}
androidx.documentfile.provider.DocumentFile df;
if (create) {
df = SafManager.createChild(getContext(), dirSegs, filename, "application/octet-stream");
} else {
df = SafManager.getChild(getContext(), dirSegs, filename);
}
return (df != null) ? df.getUri().toString() : null;
} catch (Throwable ignored) { }
return null;
}
// List files under a relative SAF directory recursively. Returns full relative paths from the root.
public static String[] listSafRecursiveFiles(String relativeDir) {
java.util.ArrayList<String> out = new java.util.ArrayList<>();
try {
androidx.documentfile.provider.DocumentFile base = SafManager.getOrCreateDir(getContext(), relativeDir.split("/"));
if (base == null || !base.exists()) return new String[0];
walkDirRecursive(base, relativeDir, out);
} catch (Throwable ignored) { }
return out.toArray(new String[0]);
}
private static void walkDirRecursive(androidx.documentfile.provider.DocumentFile dir, String relPrefix, java.util.ArrayList<String> out) {
androidx.documentfile.provider.DocumentFile[] arr = dir.listFiles();
if (arr == null) return;
for (androidx.documentfile.provider.DocumentFile f : arr) {
if (f == null) continue;
String name = f.getName();
if (name == null || name.isEmpty()) continue;
if (f.isDirectory()) {
walkDirRecursive(f, relPrefix + "/" + name, out);
} else if (f.isFile()) {
out.add(relPrefix + "/" + name);
}
}
}
// List files directly under a relative SAF directory (non-recursive). Returns full relative paths.
public static String[] listSafFilesFlat(String relativeDir) {
java.util.ArrayList<String> out = new java.util.ArrayList<>();
try {
androidx.documentfile.provider.DocumentFile dir = SafManager.getOrCreateDir(getContext(), relativeDir.split("/"));
if (dir == null || !dir.isDirectory()) return new String[0];
androidx.documentfile.provider.DocumentFile[] arr = dir.listFiles();
if (arr != null) {
for (androidx.documentfile.provider.DocumentFile f : arr) {
if (f != null && f.isFile()) {
String name = f.getName();
if (name != null && !name.isEmpty()) out.add(relativeDir + "/" + name);
}
}
}
} catch (Throwable ignored) { }
return out.toArray(new String[0]);
}
// List filenames (files only) under a SAF subdirectory (e.g., "cheats", "patches").
public static String[] listSafFilenames(String subdir) {
try {
androidx.documentfile.provider.DocumentFile dir = SafManager.getOrCreateDir(getContext(), subdir);
if (dir == null || !dir.isDirectory()) return new String[0];
androidx.documentfile.provider.DocumentFile[] arr = dir.listFiles();
java.util.ArrayList<String> out = new java.util.ArrayList<>();
if (arr != null) {
for (androidx.documentfile.provider.DocumentFile f : arr) {
if (f != null && f.isFile()) {
String name = f.getName();
if (name != null && !name.isEmpty()) out.add(name);
}
}
}
return out.toArray(new String[0]);
} catch (Throwable ignored) { }
return new String[0];
}
}
@@ -42,6 +42,7 @@ public class SettingsDialogFragment extends DialogFragment {
boolean noInterlacingPatches = prefs.getBoolean("no_interlacing_patches", true);
boolean loadTextures = prefs.getBoolean("load_textures", false);
boolean asyncTextureLoading = prefs.getBoolean("async_texture_loading", true);
boolean precacheTextures = prefs.getBoolean("precache_textures", false);
boolean hudVisible = prefs.getBoolean("hud_visible", false);
// Debug logging
@@ -58,6 +59,7 @@ public class SettingsDialogFragment extends DialogFragment {
NativeApp.setNoInterlacingPatches(noInterlacingPatches);
NativeApp.setLoadTextures(loadTextures);
NativeApp.setAsyncTextureLoading(asyncTextureLoading);
NativeApp.setPrecacheTextureReplacements(precacheTextures);
NativeApp.setHudVisible(hudVisible);
// Set brighter default brightness (60 instead of 50)
@@ -85,6 +87,7 @@ public class SettingsDialogFragment extends DialogFragment {
MaterialSwitch swNoInterlacing = view.findViewById(R.id.sw_no_interlacing);
MaterialSwitch swLoadTextures = view.findViewById(R.id.sw_load_textures);
MaterialSwitch swAsyncTextureLoading = view.findViewById(R.id.sw_async_texture_loading);
MaterialSwitch swPrecacheTextures = view.findViewById(R.id.sw_precache_textures);
MaterialSwitch swDevHud = view.findViewById(R.id.sw_dev_hud);
View btnPower = view.findViewById(R.id.btn_power);
View btnReboot = view.findViewById(R.id.btn_reboot);
@@ -187,6 +190,7 @@ public class SettingsDialogFragment extends DialogFragment {
boolean savedNoInterlacing = prefs.getBoolean("no_interlacing_patches", true);
boolean savedLoadTextures = prefs.getBoolean("load_textures", false);
boolean savedAsyncTextureLoading = prefs.getBoolean("async_texture_loading", true);
boolean savedPrecacheTextures = prefs.getBoolean("precache_textures", false);
boolean savedHud = prefs.getBoolean("hud_visible", false);
boolean savedCheatsGlobal = prefs.getBoolean("enable_cheats", false);
int savedBlending = prefs.getInt("blending_accuracy", 1);
@@ -213,6 +217,7 @@ public class SettingsDialogFragment extends DialogFragment {
swNoInterlacing.setChecked(savedNoInterlacing);
swLoadTextures.setChecked(savedLoadTextures);
swAsyncTextureLoading.setChecked(savedAsyncTextureLoading);
if (swPrecacheTextures != null) swPrecacheTextures.setChecked(savedPrecacheTextures);
if (swDevHud != null) swDevHud.setChecked(savedHud);
MaterialSwitch swCheatsGlobal = view.findViewById(R.id.sw_enable_cheats_global);
if (swCheatsGlobal != null) swCheatsGlobal.setChecked(savedCheatsGlobal);
@@ -235,7 +240,8 @@ public class SettingsDialogFragment extends DialogFragment {
boolean widescreenPatches = swWidescreen.isChecked();
boolean noInterlacingPatches = swNoInterlacing.isChecked();
boolean loadTextures = swLoadTextures.isChecked();
boolean asyncTextureLoading = swAsyncTextureLoading.isChecked();
boolean asyncTextureLoading = swAsyncTextureLoading.isChecked();
boolean precacheTextureReplacements = swPrecacheTextures != null && swPrecacheTextures.isChecked();
boolean hudVisible = (swDevHud != null && swDevHud.isChecked());
boolean enableCheatsGlobal = swCheatsGlobal != null && swCheatsGlobal.isChecked();
@@ -250,17 +256,20 @@ public class SettingsDialogFragment extends DialogFragment {
.putBoolean("no_interlacing_patches", noInterlacingPatches)
.putBoolean("load_textures", loadTextures)
.putBoolean("async_texture_loading", asyncTextureLoading)
.putBoolean("precache_textures", precacheTextureReplacements)
.putBoolean("hud_visible", hudVisible)
.putBoolean("enable_cheats", enableCheatsGlobal)
.apply();
// Apply in one batch to avoid repeated ApplySettings calls
try {
NativeApp.applyGlobalSettingsBatch(renderer, scale, aspectRatio, blendingLevel,
widescreenPatches, noInterlacingPatches, loadTextures, asyncTextureLoading, hudVisible);
} catch (Throwable t) {
android.util.Log.e("SettingsDialog", "Batch apply failed: " + t.getMessage());
}
try {
NativeApp.applyGlobalSettingsBatch(renderer, scale, aspectRatio, blendingLevel,
widescreenPatches, noInterlacingPatches, loadTextures, asyncTextureLoading, hudVisible);
// Apply precache separately (not part of the batch JNI)
NativeApp.setPrecacheTextureReplacements(precacheTextureReplacements);
} catch (Throwable t) {
android.util.Log.e("SettingsDialog", "Apply failed: " + t.getMessage());
}
// Refresh quick UI (renderer label) if hosting activity is MainActivity
try {