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
+3 -7
View File
@@ -9,18 +9,14 @@ local.properties
*.keystore
# Build folder
app/build/
build/
# NDK
app/.cxx/
.cxx/
# IDE
.vscode/
.idea/
*.iml
*.exe
/PCSX2_ARM64
build/reports/problems/problems-report.html
crash.txt
/AetherSX2-main
crash-NEW.txt
AetherSX2-main/
+26 -1
View File
@@ -18,7 +18,12 @@ android {
externalNativeBuild {
cmake {
arguments '-DANDROID=true','-DCMAKE_BUILD_TYPE=Release','-DANDROID_STL=c++_static'
// Avoid LTO core on Android to prevent x86 objects from leaking into arm64 link
arguments(
'-DANDROID=true',
'-DCMAKE_BUILD_TYPE=Release',
'-DANDROID_STL=c++_static'
)
// arguments '-DANDROID=true','-DCMAKE_BUILD_TYPE=Debug','-DANDROID_STL=c++_static'
}
}
@@ -26,16 +31,36 @@ android {
//noinspection ChromeOsAbiSupport
abiFilters 'arm64-v8a'
}
// Placeholder to toggle profileable per buildType (for AGI)
manifestPlaceholders = [
profileable: "false"
]
}
buildTypes {
release {
ndk {
// Keep symbol table for crash triage while testing
debugSymbolLevel 'SYMBOL_TABLE'
}
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
// Build for profiling with Android GPU Inspector
benchmark {
// Release-like build, not debuggable, but profileable
initWith release
debuggable false
// Use debug keystore to simplify installs
signingConfig signingConfigs.debug
// Turn on profileable in manifest via placeholder
manifestPlaceholders = [ profileable: "true" ]
}
}
// Relax lint for test release builds
lint {
checkReleaseBuilds false
abortOnError false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
+3 -3
View File
@@ -6,7 +6,8 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:glEsVersion="0x00030000" android:required="true" />
<!-- Prefer OpenGL ES 3.2 when available, but allow install/run on older devices -->
<uses-feature android:glEsVersion="0x00030002" android:required="false" />
<uses-feature android:name="android.hardware.screen.landscape" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="false" />
@@ -40,8 +41,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="false"
android:theme="@style/AppTheme"
tools:targetApi="31">
android:theme="@style/AppTheme">
<meta-data android:name="android.max_aspect" android:value="2.4" />
+2 -1
View File
@@ -887,7 +887,8 @@ struct Pcsx2Config
};
static constexpr s32 MAX_VOLUME = 200;
static constexpr AudioBackend DEFAULT_BACKEND = AudioBackend::Oboe;
// Default to Oboe on Android for stable low-latency output.
static constexpr AudioBackend DEFAULT_BACKEND = AudioBackend::Oboe;
static constexpr SPU2SyncMode DEFAULT_SYNC_MODE = SPU2SyncMode::TimeStretch;
static std::optional<SPU2SyncMode> ParseSyncMode(const char* str);
@@ -114,7 +114,17 @@ VkInstance GSDeviceVK::CreateVulkanInstance(const WindowInfo& wi, OptionalExtens
app_info.pEngineName = "PCSX2";
app_info.engineVersion = VK_MAKE_VERSION(
BuildVersion::GitTagHi, BuildVersion::GitTagMid, BuildVersion::GitTagLo);
app_info.apiVersion = VK_API_VERSION_1_1;
// Prefer a newer Vulkan API when available, but clamp to loader support.
// This unlocks newer features on capable drivers without breaking older ones.
uint32_t loader_api_version = VK_API_VERSION_1_1;
if (vkEnumerateInstanceVersion)
{
uint32_t ver = 0;
if (vkEnumerateInstanceVersion(&ver) == VK_SUCCESS && ver != 0)
loader_api_version = ver;
}
const uint32_t desired_api_version = VK_API_VERSION_1_3; // headers provide up to 1.4, 1.3 is widely supported
app_info.apiVersion = (loader_api_version < desired_api_version) ? loader_api_version : desired_api_version;
VkInstanceCreateInfo instance_create_info = {};
instance_create_info.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
+15
View File
@@ -24,6 +24,7 @@
#include <cstdio>
#include <ctime>
#include <fstream>
#include <mutex>
#include <string_view>
#include <utility>
@@ -214,20 +215,34 @@ void GameList::FillBootParametersForEntry(VMBootParameters* params, const Entry*
bool GameList::GetIsoSerialAndCRC(const std::string& path, s32* disc_type, std::string* serial, u32* crc)
{
// Add a static mutex to protect CDVD operations during scanning
static std::mutex cdvd_scan_mutex;
std::lock_guard<std::mutex> lock(cdvd_scan_mutex);
Error error;
// Save the current CDVD state to restore it later (thread safety)
const CDVD_API* prev_cdvd = CDVD;
// This isn't great, we really want to make it all thread-local...
CDVD = &CDVDapi_Iso;
if (!CDVD->open(path, &error))
{
Console.Error(fmt::format("(GameList::GetIsoSerialAndCRC) CDVD open of '{}' failed: {}", path, error.GetDescription()));
// Restore previous CDVD state
CDVD = prev_cdvd;
return false;
}
// TODO: we could include the version in the game list?
*disc_type = DoCDVDdetectDiskType();
cdvdGetDiscInfo(serial, nullptr, nullptr, crc, nullptr);
DoCDVDclose();
// Restore previous CDVD state
CDVD = prev_cdvd;
return true;
}
+8 -6
View File
@@ -149,14 +149,16 @@ u32 AudioStream::GetMSForBufferSize(u32 sample_rate, u32 buffer_size)
}
static constexpr const std::array s_backend_names = {
"Null",
"Cubeb",
"SDL",
"Null",
"Cubeb",
"SDL",
"Oboe",
};
static constexpr const std::array s_backend_display_names = {
TRANSLATE_NOOP("AudioStream", "Null (No Output)"),
TRANSLATE_NOOP("AudioStream", "Cubeb"),
TRANSLATE_NOOP("AudioStream", "SDL"),
TRANSLATE_NOOP("AudioStream", "Null (No Output)"),
TRANSLATE_NOOP("AudioStream", "Cubeb"),
TRANSLATE_NOOP("AudioStream", "SDL"),
TRANSLATE_NOOP("AudioStream", "Oboe"),
};
std::optional<AudioBackend> AudioStream::ParseBackendName(const char* str)
+51 -31
View File
@@ -41,16 +41,17 @@ static bool InitializeSDLAudio(Error* error)
SDL_SetHint("SDL_AUDIO_DEVICE_APP_NAME", "PCSX2");
// May as well keep it alive until the process exits.
if (!SDL_InitSubSystem(SDL_INIT_AUDIO))
{
Error::SetStringFmt(error, "SDL_InitSubSystem(SDL_INIT_AUDIO) failed: {}", SDL_GetError());
return false;
}
if (SDL_InitSubSystem(SDL_INIT_AUDIO) != 0)
{
Error::SetStringFmt(error, "SDL_InitSubSystem(SDL_INIT_AUDIO) failed: {}", SDL_GetError());
return false;
}
std::atexit([]() { SDL_QuitSubSystem(SDL_INIT_AUDIO); });
std::atexit([]() { SDL_QuitSubSystem(SDL_INIT_AUDIO); });
initialized = true;
return true;
initialized = true;
Console.WriteLn("[SDL-Audio] Initialized SDL audio subsystem");
return true;
}
SDLAudioStream::SDLAudioStream(u32 sample_rate, const AudioStreamParameters& parameters)
@@ -108,7 +109,12 @@ bool SDLAudioStream::OpenDevice(bool stretch_enabled, Error* error)
SDL_SetHint(SDL_HINT_AUDIO_DEVICE_SAMPLE_FRAMES, fmt::format("{}", samples).c_str());
const SDL_AudioSpec spec = {SDL_AUDIO_S16LE, m_output_channels, static_cast<int>(m_sample_rate)};
m_stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, AudioCallback, static_cast<void*>(this));
m_stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, AudioCallback, static_cast<void*>(this));
if (!m_stream)
{
Error::SetStringFmt(error, "SDL_OpenAudioDeviceStream() failed: {}", SDL_GetError());
return false;
}
SDL_AudioSpec obtained_spec = {};
int obtained_samples = 0;
@@ -118,23 +124,31 @@ bool SDLAudioStream::OpenDevice(bool stretch_enabled, Error* error)
else
DEV_LOG("SDL_GetAudioDeviceFormat() failed {}", SDL_GetError());
BaseInitialize(sample_readers[static_cast<size_t>(m_parameters.expansion_mode)], stretch_enabled);
SDL_ResumeAudioDevice(SDL_GetAudioStreamDevice(m_stream));
BaseInitialize(sample_readers[static_cast<size_t>(m_parameters.expansion_mode)], stretch_enabled);
SDL_AudioDeviceID dev = SDL_GetAudioStreamDevice(m_stream);
Console.WriteLnFmt("[SDL-Audio] Opened stream: rate={} Hz, channels={}, device_id={}", m_sample_rate, m_output_channels, dev);
SDL_ResumeAudioDevice(dev);
return true;
return true;
}
void SDLAudioStream::SetPaused(bool paused)
{
if (m_paused == paused)
return;
if (m_paused == paused)
return;
if (paused)
SDL_PauseAudioDevice(SDL_GetAudioStreamDevice(m_stream));
else
SDL_ResumeAudioDevice(SDL_GetAudioStreamDevice(m_stream));
if (paused)
{
SDL_PauseAudioDevice(SDL_GetAudioStreamDevice(m_stream));
Console.WriteLn("[SDL-Audio] Paused");
}
else
{
SDL_ResumeAudioDevice(SDL_GetAudioStreamDevice(m_stream));
Console.WriteLn("[SDL-Audio] Resumed");
}
m_paused = paused;
m_paused = paused;
}
void SDLAudioStream::CloseDevice()
@@ -145,17 +159,23 @@ void SDLAudioStream::CloseDevice()
void SDLAudioStream::AudioCallback(void* userdata, SDL_AudioStream* stream, int additional_amount, int total_amount)
{
if (additional_amount > 0)
{
SDLAudioStream* const this_ptr = static_cast<SDLAudioStream*>(userdata);
if (additional_amount > 0)
{
SDLAudioStream* const this_ptr = static_cast<SDLAudioStream*>(userdata);
const u32 num_frames = additional_amount / sizeof(SampleType) / this_ptr->m_output_channels;
SampleType* buffer = SDL_stack_alloc(SampleType, additional_amount / sizeof(SampleType));
if (buffer)
{
this_ptr->ReadFrames(buffer, num_frames);
SDL_PutAudioStreamData(stream, buffer, additional_amount);
SDL_stack_free(buffer);
}
}
const u32 num_frames = additional_amount / sizeof(SampleType) / this_ptr->m_output_channels;
SampleType* buffer = SDL_stack_alloc(SampleType, additional_amount / sizeof(SampleType));
if (buffer)
{
this_ptr->ReadFrames(buffer, num_frames);
SDL_PutAudioStreamData(stream, buffer, additional_amount);
SDL_stack_free(buffer);
}
static int s_dbg_count = 0;
if (s_dbg_count < 3)
{
Console.WriteLnFmt("[SDL-Audio] Callback: additional={} bytes, total={}, frames={}", additional_amount, total_amount, num_frames);
s_dbg_count++;
}
}
}
+45 -29
View File
@@ -635,49 +635,65 @@ void SDLInputSource::SetHints()
bool SDLInputSource::InitializeSubsystem()
{
if (!SDL_InitSubSystem(SDL_INIT_JOYSTICK | SDL_INIT_GAMEPAD | SDL_INIT_HAPTIC))
{
Console.Error("SDL_InitSubSystem(SDL_INIT_JOYSTICK | SDL_INIT_GAMEPAD | SDL_INIT_HAPTIC) failed");
return false;
}
SDL_SetLogOutputFunction(SDLLogCallback, nullptr);
#ifdef PCSX2_DEVBUILD
SDL_SetLogPriorities(SDL_LOG_PRIORITY_VERBOSE);
#ifdef __ANDROID__
// On Android we rely on Java-side input; skip SDL joystick/gamepad/haptic init.
// Keep the source marked initialized to prevent re-init loops.
m_sdl_subsystem_initialized = true;
return true;
#else
SDL_SetLogPriorities(SDL_LOG_PRIORITY_INFO);
if (!SDL_InitSubSystem(SDL_INIT_JOYSTICK | SDL_INIT_GAMEPAD | SDL_INIT_HAPTIC))
{
Console.Error("SDL_InitSubSystem(SDL_INIT_JOYSTICK | SDL_INIT_GAMEPAD | SDL_INIT_HAPTIC) failed");
return false;
}
SDL_SetLogOutputFunction(SDLLogCallback, nullptr);
#ifdef PCSX2_DEVBUILD
SDL_SetLogPriorities(SDL_LOG_PRIORITY_VERBOSE);
#else
SDL_SetLogPriorities(SDL_LOG_PRIORITY_INFO);
#endif
// we should open the controllers as the connected events come in, so no need to do any more here
m_sdl_subsystem_initialized = true;
// we should open the controllers as the connected events come in, so no need to do any more here
m_sdl_subsystem_initialized = true;
int count;
char** mappings = SDL_GetGamepadMappings(&count);
if (mappings != nullptr)
{
SDL_free(mappings);
Console.WriteLnFmt(Color_StrongGreen, "SDLInputSource: {} gamepad mappings are loaded.", count);
}
else
Console.Error("SDL_GetGamepadMappings() failed {}", SDL_GetError());
int count;
char** mappings = SDL_GetGamepadMappings(&count);
if (mappings != nullptr)
{
SDL_free(mappings);
Console.WriteLnFmt(Color_StrongGreen, "SDLInputSource: {} gamepad mappings are loaded.", count);
}
else
Console.Error("SDL_GetGamepadMappings() failed {}", SDL_GetError());
return true;
return true;
#endif
}
void SDLInputSource::ShutdownSubsystem()
{
while (!m_controllers.empty())
CloseDevice(m_controllers.begin()->joystick_id);
while (!m_controllers.empty())
CloseDevice(m_controllers.begin()->joystick_id);
if (m_sdl_subsystem_initialized)
{
SDL_QuitSubSystem(SDL_INIT_JOYSTICK | SDL_INIT_GAMEPAD | SDL_INIT_HAPTIC);
m_sdl_subsystem_initialized = false;
}
#ifdef __ANDROID__
// Nothing was initialized; just mark uninitialized.
m_sdl_subsystem_initialized = false;
#else
if (m_sdl_subsystem_initialized)
{
SDL_QuitSubSystem(SDL_INIT_JOYSTICK | SDL_INIT_GAMEPAD | SDL_INIT_HAPTIC);
m_sdl_subsystem_initialized = false;
}
#endif
}
void SDLInputSource::PollEvents()
{
#ifdef __ANDROID__
// No SDL input events on Android; Java side handles input.
return;
#endif
for (;;)
{
SDL_Event ev;
+2 -2
View File
@@ -103,8 +103,8 @@ void SPU2::CreateOutputStream()
s_output_stream.reset();
Error error;
s_output_stream = AudioStream::CreateStream(EmuConfig.SPU2.Backend, sample_rate, EmuConfig.SPU2.StreamParameters,
EmuConfig.SPU2.DriverName.c_str(), EmuConfig.SPU2.DeviceName.c_str(), EmuConfig.SPU2.IsTimeStretchEnabled(), &error);
s_output_stream = AudioStream::CreateStream(EmuConfig.SPU2.Backend, sample_rate, EmuConfig.SPU2.StreamParameters,
EmuConfig.SPU2.DriverName.c_str(), EmuConfig.SPU2.DeviceName.c_str(), EmuConfig.SPU2.IsTimeStretchEnabled(), &error);
if (!s_output_stream)
{
Host::ReportErrorAsync("Error",
@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More