Added setup wizard, Started SAF stuff

initial work on moving everything from android/data to the user selected folder.
This commit is contained in:
izzy2lost
2025-09-06 02:07:48 -04:00
parent 7f04373064
commit e1161027f1
25 changed files with 975 additions and 246 deletions
@@ -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;
}
}