diff --git a/.gitignore b/.gitignore
index ae0d099..d1c5771 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/app/build.gradle b/app/build.gradle
index bbe7cca..76be583 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 912de8f..e5491b0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,7 +6,8 @@
-
+
+
@@ -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">
diff --git a/app/src/main/cpp/pcsx2/Config.h b/app/src/main/cpp/pcsx2/Config.h
index 338b533..730c81c 100644
--- a/app/src/main/cpp/pcsx2/Config.h
+++ b/app/src/main/cpp/pcsx2/Config.h
@@ -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 ParseSyncMode(const char* str);
diff --git a/app/src/main/cpp/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.cpp b/app/src/main/cpp/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.cpp
index 5255772..a06f2f2 100644
--- a/app/src/main/cpp/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.cpp
+++ b/app/src/main/cpp/pcsx2/GS/Renderers/Vulkan/GSDeviceVK.cpp
@@ -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;
diff --git a/app/src/main/cpp/pcsx2/GameList.cpp b/app/src/main/cpp/pcsx2/GameList.cpp
index 0938b39..a4c0894 100644
--- a/app/src/main/cpp/pcsx2/GameList.cpp
+++ b/app/src/main/cpp/pcsx2/GameList.cpp
@@ -24,6 +24,7 @@
#include
#include
#include
+#include
#include
#include
@@ -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 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;
}
diff --git a/app/src/main/cpp/pcsx2/Host/AudioStream.cpp b/app/src/main/cpp/pcsx2/Host/AudioStream.cpp
index 82292df..ab13a15 100644
--- a/app/src/main/cpp/pcsx2/Host/AudioStream.cpp
+++ b/app/src/main/cpp/pcsx2/Host/AudioStream.cpp
@@ -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 AudioStream::ParseBackendName(const char* str)
diff --git a/app/src/main/cpp/pcsx2/Host/SDLAudioStream.cpp b/app/src/main/cpp/pcsx2/Host/SDLAudioStream.cpp
index b057371..8aa5be5 100644
--- a/app/src/main/cpp/pcsx2/Host/SDLAudioStream.cpp
+++ b/app/src/main/cpp/pcsx2/Host/SDLAudioStream.cpp
@@ -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(m_sample_rate)};
- m_stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, AudioCallback, static_cast(this));
+ m_stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, AudioCallback, static_cast(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(m_parameters.expansion_mode)], stretch_enabled);
- SDL_ResumeAudioDevice(SDL_GetAudioStreamDevice(m_stream));
+ BaseInitialize(sample_readers[static_cast(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(userdata);
+ if (additional_amount > 0)
+ {
+ SDLAudioStream* const this_ptr = static_cast(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++;
+ }
+ }
}
diff --git a/app/src/main/cpp/pcsx2/Input/SDLInputSource.cpp b/app/src/main/cpp/pcsx2/Input/SDLInputSource.cpp
index 8a300a3..e5c4f15 100644
--- a/app/src/main/cpp/pcsx2/Input/SDLInputSource.cpp
+++ b/app/src/main/cpp/pcsx2/Input/SDLInputSource.cpp
@@ -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;
diff --git a/app/src/main/cpp/pcsx2/SPU2/spu2.cpp b/app/src/main/cpp/pcsx2/SPU2/spu2.cpp
index 4da7e1c..7a59c50 100644
--- a/app/src/main/cpp/pcsx2/SPU2/spu2.cpp
+++ b/app/src/main/cpp/pcsx2/SPU2/spu2.cpp
@@ -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",
diff --git a/app/src/main/java/com/izzy2lost/psx2/CoversAdapter.java b/app/src/main/java/com/izzy2lost/psx2/CoversAdapter.java
index ecdd2fa..b0b9378 100644
--- a/app/src/main/java/com/izzy2lost/psx2/CoversAdapter.java
+++ b/app/src/main/java/com/izzy2lost/psx2/CoversAdapter.java
@@ -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 {
public interface OnItemClick {
@@ -80,36 +81,47 @@ public class CoversAdapter extends RecyclerView.Adapter {
}
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 {
});
}
+ 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;
diff --git a/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java
index 0a3d3a5..dbb492b 100644
--- a/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java
+++ b/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java
@@ -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 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) {
}
}
diff --git a/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java
index 976963e..8ec6280 100644
--- a/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java
+++ b/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java
@@ -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);
}
diff --git a/app/src/main/java/com/izzy2lost/psx2/MainActivity.java b/app/src/main/java/com/izzy2lost/psx2/MainActivity.java
index 1f4fa1f..3e8b58a 100644
--- a/app/src/main/java/com/izzy2lost/psx2/MainActivity.java
+++ b/app/src/main/java/com/izzy2lost/psx2/MainActivity.java
@@ -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 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) -> {
diff --git a/app/src/main/java/com/izzy2lost/psx2/MyAppGlideModule.java b/app/src/main/java/com/izzy2lost/psx2/MyAppGlideModule.java
new file mode 100644
index 0000000..732f629
--- /dev/null
+++ b/app/src/main/java/com/izzy2lost/psx2/MyAppGlideModule.java
@@ -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;
+ }
+}
+
diff --git a/app/src/main/java/com/izzy2lost/psx2/NativeApp.java b/app/src/main/java/com/izzy2lost/psx2/NativeApp.java
index bd97d04..e84b57e 100644
--- a/app/src/main/java/com/izzy2lost/psx2/NativeApp.java
+++ b/app/src/main/java/com/izzy2lost/psx2/NativeApp.java
@@ -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);
diff --git a/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java
index 0645db4..5831c62 100644
--- a/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java
+++ b/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java
@@ -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) -> {
diff --git a/app/src/main/java/com/izzy2lost/psx2/SafManager.java b/app/src/main/java/com/izzy2lost/psx2/SafManager.java
new file mode 100644
index 0000000..7cd6299
--- /dev/null
+++ b/app/src/main/java/com/izzy2lost/psx2/SafManager.java
@@ -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;
+ }
+}
+
diff --git a/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java
index 9f44230..9073635 100644
--- a/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java
+++ b/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java
@@ -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 {
diff --git a/app/src/main/java/com/izzy2lost/psx2/SetupWizardDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/SetupWizardDialogFragment.java
new file mode 100644
index 0000000..6ef752d
--- /dev/null
+++ b/app/src/main/java/com/izzy2lost/psx2/SetupWizardDialogFragment.java
@@ -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);
+
+ }
+}
+
diff --git a/app/src/main/java/com/izzy2lost/psx2/TitleResolver.java b/app/src/main/java/com/izzy2lost/psx2/TitleResolver.java
index 442dc5d..c3a098a 100644
--- a/app/src/main/java/com/izzy2lost/psx2/TitleResolver.java
+++ b/app/src/main/java/com/izzy2lost/psx2/TitleResolver.java
@@ -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 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 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 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 {
diff --git a/app/src/main/java/com/izzy2lost/psx2/UiUtils.java b/app/src/main/java/com/izzy2lost/psx2/UiUtils.java
new file mode 100644
index 0000000..690c451
--- /dev/null
+++ b/app/src/main/java/com/izzy2lost/psx2/UiUtils.java
@@ -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;
+ }
+}
diff --git a/app/src/main/res/drawable/check_circle_24px.xml b/app/src/main/res/drawable/check_circle_24px.xml
new file mode 100644
index 0000000..728be3b
--- /dev/null
+++ b/app/src/main/res/drawable/check_circle_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/layout/dialog_game_settings.xml b/app/src/main/res/layout/dialog_game_settings.xml
index 0997e64..c6d3cd8 100644
--- a/app/src/main/res/layout/dialog_game_settings.xml
+++ b/app/src/main/res/layout/dialog_game_settings.xml
@@ -130,7 +130,8 @@
android:layout_height="wrap_content"
android:text="Enable Patch Codes (PNACH)"
style="@style/Widget.Material3.CompoundButton.MaterialSwitch"
- android:layout_marginBottom="8dp"/>
+ android:layout_marginBottom="8dp"
+ android:visibility="gone"/>
+
+
+
+
+
+
+
+ android:text="AUTO"/>
+ android:text="VK"/>
@@ -168,6 +168,14 @@
android:text="No Interlacing Patches"
android:layout_marginTop="8dp"
style="@style/Widget.Material3.CompoundButton.MaterialSwitch"/>
+
+