From e1161027f17fff6df901a40876fb13a1f06f1b7c Mon Sep 17 00:00:00 2001 From: izzy2lost Date: Sat, 6 Sep 2025 02:07:48 -0400 Subject: [PATCH] Added setup wizard, Started SAF stuff initial work on moving everything from android/data to the user selected folder. --- .gitignore | 10 +- app/build.gradle | 27 ++- app/src/main/AndroidManifest.xml | 6 +- app/src/main/cpp/pcsx2/Config.h | 3 +- .../pcsx2/GS/Renderers/Vulkan/GSDeviceVK.cpp | 12 +- app/src/main/cpp/pcsx2/GameList.cpp | 15 ++ app/src/main/cpp/pcsx2/Host/AudioStream.cpp | 14 +- .../main/cpp/pcsx2/Host/SDLAudioStream.cpp | 82 +++++--- .../main/cpp/pcsx2/Input/SDLInputSource.cpp | 74 ++++--- app/src/main/cpp/pcsx2/SPU2/spu2.cpp | 4 +- .../com/izzy2lost/psx2/CoversAdapter.java | 72 +++++-- .../psx2/GameSettingsDialogFragment.java | 143 +++++++++---- .../psx2/GamesCoverDialogFragment.java | 73 +++++-- .../java/com/izzy2lost/psx2/MainActivity.java | 155 ++++++++++++-- .../com/izzy2lost/psx2/MyAppGlideModule.java | 14 ++ .../java/com/izzy2lost/psx2/NativeApp.java | 36 ++++ .../psx2/QuickActionsDialogFragment.java | 6 +- .../java/com/izzy2lost/psx2/SafManager.java | 112 ++++++++++ .../psx2/SettingsDialogFragment.java | 33 +-- .../psx2/SetupWizardDialogFragment.java | 199 ++++++++++++++++++ .../com/izzy2lost/psx2/TitleResolver.java | 58 ++--- .../main/java/com/izzy2lost/psx2/UiUtils.java | 20 ++ .../main/res/drawable/check_circle_24px.xml | 10 + .../main/res/layout/dialog_game_settings.xml | 27 ++- app/src/main/res/layout/dialog_settings.xml | 16 +- 25 files changed, 975 insertions(+), 246 deletions(-) create mode 100644 app/src/main/java/com/izzy2lost/psx2/MyAppGlideModule.java create mode 100644 app/src/main/java/com/izzy2lost/psx2/SafManager.java create mode 100644 app/src/main/java/com/izzy2lost/psx2/SetupWizardDialogFragment.java create mode 100644 app/src/main/java/com/izzy2lost/psx2/UiUtils.java create mode 100644 app/src/main/res/drawable/check_circle_24px.xml 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"/> + +