mirror of
https://github.com/izzy2lost/PSX2.git
synced 2026-07-05 15:18:36 -07:00
Added setup wizard, Started SAF stuff
initial work on moving everything from android/data to the user selected folder.
This commit is contained in:
+3
-7
@@ -9,18 +9,14 @@ local.properties
|
||||
*.keystore
|
||||
|
||||
# Build folder
|
||||
app/build/
|
||||
build/
|
||||
|
||||
# NDK
|
||||
app/.cxx/
|
||||
.cxx/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.iml
|
||||
*.exe
|
||||
/PCSX2_ARM64
|
||||
build/reports/problems/problems-report.html
|
||||
crash.txt
|
||||
/AetherSX2-main
|
||||
crash-NEW.txt
|
||||
AetherSX2-main/
|
||||
|
||||
+26
-1
@@ -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
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<uses-feature android:glEsVersion="0x00030000" android:required="true" />
|
||||
<!-- Prefer OpenGL ES 3.2 when available, but allow install/run on older devices -->
|
||||
<uses-feature android:glEsVersion="0x00030002" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.screen.landscape" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
@@ -40,8 +41,7 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="false"
|
||||
android:theme="@style/AppTheme"
|
||||
tools:targetApi="31">
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<meta-data android:name="android.max_aspect" android:value="2.4" />
|
||||
|
||||
|
||||
@@ -887,7 +887,8 @@ struct Pcsx2Config
|
||||
};
|
||||
|
||||
static constexpr s32 MAX_VOLUME = 200;
|
||||
static constexpr AudioBackend DEFAULT_BACKEND = AudioBackend::Oboe;
|
||||
// Default to Oboe on Android for stable low-latency output.
|
||||
static constexpr AudioBackend DEFAULT_BACKEND = AudioBackend::Oboe;
|
||||
static constexpr SPU2SyncMode DEFAULT_SYNC_MODE = SPU2SyncMode::TimeStretch;
|
||||
|
||||
static std::optional<SPU2SyncMode> ParseSyncMode(const char* str);
|
||||
|
||||
@@ -114,7 +114,17 @@ VkInstance GSDeviceVK::CreateVulkanInstance(const WindowInfo& wi, OptionalExtens
|
||||
app_info.pEngineName = "PCSX2";
|
||||
app_info.engineVersion = VK_MAKE_VERSION(
|
||||
BuildVersion::GitTagHi, BuildVersion::GitTagMid, BuildVersion::GitTagLo);
|
||||
app_info.apiVersion = VK_API_VERSION_1_1;
|
||||
// Prefer a newer Vulkan API when available, but clamp to loader support.
|
||||
// This unlocks newer features on capable drivers without breaking older ones.
|
||||
uint32_t loader_api_version = VK_API_VERSION_1_1;
|
||||
if (vkEnumerateInstanceVersion)
|
||||
{
|
||||
uint32_t ver = 0;
|
||||
if (vkEnumerateInstanceVersion(&ver) == VK_SUCCESS && ver != 0)
|
||||
loader_api_version = ver;
|
||||
}
|
||||
const uint32_t desired_api_version = VK_API_VERSION_1_3; // headers provide up to 1.4, 1.3 is widely supported
|
||||
app_info.apiVersion = (loader_api_version < desired_api_version) ? loader_api_version : desired_api_version;
|
||||
|
||||
VkInstanceCreateInfo instance_create_info = {};
|
||||
instance_create_info.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
@@ -214,20 +215,34 @@ void GameList::FillBootParametersForEntry(VMBootParameters* params, const Entry*
|
||||
|
||||
bool GameList::GetIsoSerialAndCRC(const std::string& path, s32* disc_type, std::string* serial, u32* crc)
|
||||
{
|
||||
// Add a static mutex to protect CDVD operations during scanning
|
||||
static std::mutex cdvd_scan_mutex;
|
||||
std::lock_guard<std::mutex> lock(cdvd_scan_mutex);
|
||||
|
||||
Error error;
|
||||
|
||||
// Save the current CDVD state to restore it later (thread safety)
|
||||
const CDVD_API* prev_cdvd = CDVD;
|
||||
|
||||
// This isn't great, we really want to make it all thread-local...
|
||||
CDVD = &CDVDapi_Iso;
|
||||
if (!CDVD->open(path, &error))
|
||||
{
|
||||
Console.Error(fmt::format("(GameList::GetIsoSerialAndCRC) CDVD open of '{}' failed: {}", path, error.GetDescription()));
|
||||
// Restore previous CDVD state
|
||||
CDVD = prev_cdvd;
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: we could include the version in the game list?
|
||||
*disc_type = DoCDVDdetectDiskType();
|
||||
|
||||
cdvdGetDiscInfo(serial, nullptr, nullptr, crc, nullptr);
|
||||
|
||||
DoCDVDclose();
|
||||
|
||||
// Restore previous CDVD state
|
||||
CDVD = prev_cdvd;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -149,14 +149,16 @@ u32 AudioStream::GetMSForBufferSize(u32 sample_rate, u32 buffer_size)
|
||||
}
|
||||
|
||||
static constexpr const std::array s_backend_names = {
|
||||
"Null",
|
||||
"Cubeb",
|
||||
"SDL",
|
||||
"Null",
|
||||
"Cubeb",
|
||||
"SDL",
|
||||
"Oboe",
|
||||
};
|
||||
static constexpr const std::array s_backend_display_names = {
|
||||
TRANSLATE_NOOP("AudioStream", "Null (No Output)"),
|
||||
TRANSLATE_NOOP("AudioStream", "Cubeb"),
|
||||
TRANSLATE_NOOP("AudioStream", "SDL"),
|
||||
TRANSLATE_NOOP("AudioStream", "Null (No Output)"),
|
||||
TRANSLATE_NOOP("AudioStream", "Cubeb"),
|
||||
TRANSLATE_NOOP("AudioStream", "SDL"),
|
||||
TRANSLATE_NOOP("AudioStream", "Oboe"),
|
||||
};
|
||||
|
||||
std::optional<AudioBackend> AudioStream::ParseBackendName(const char* str)
|
||||
|
||||
@@ -41,16 +41,17 @@ static bool InitializeSDLAudio(Error* error)
|
||||
SDL_SetHint("SDL_AUDIO_DEVICE_APP_NAME", "PCSX2");
|
||||
|
||||
// May as well keep it alive until the process exits.
|
||||
if (!SDL_InitSubSystem(SDL_INIT_AUDIO))
|
||||
{
|
||||
Error::SetStringFmt(error, "SDL_InitSubSystem(SDL_INIT_AUDIO) failed: {}", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
if (SDL_InitSubSystem(SDL_INIT_AUDIO) != 0)
|
||||
{
|
||||
Error::SetStringFmt(error, "SDL_InitSubSystem(SDL_INIT_AUDIO) failed: {}", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
|
||||
std::atexit([]() { SDL_QuitSubSystem(SDL_INIT_AUDIO); });
|
||||
std::atexit([]() { SDL_QuitSubSystem(SDL_INIT_AUDIO); });
|
||||
|
||||
initialized = true;
|
||||
return true;
|
||||
initialized = true;
|
||||
Console.WriteLn("[SDL-Audio] Initialized SDL audio subsystem");
|
||||
return true;
|
||||
}
|
||||
|
||||
SDLAudioStream::SDLAudioStream(u32 sample_rate, const AudioStreamParameters& parameters)
|
||||
@@ -108,7 +109,12 @@ bool SDLAudioStream::OpenDevice(bool stretch_enabled, Error* error)
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DEVICE_SAMPLE_FRAMES, fmt::format("{}", samples).c_str());
|
||||
|
||||
const SDL_AudioSpec spec = {SDL_AUDIO_S16LE, m_output_channels, static_cast<int>(m_sample_rate)};
|
||||
m_stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, AudioCallback, static_cast<void*>(this));
|
||||
m_stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &spec, AudioCallback, static_cast<void*>(this));
|
||||
if (!m_stream)
|
||||
{
|
||||
Error::SetStringFmt(error, "SDL_OpenAudioDeviceStream() failed: {}", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
|
||||
SDL_AudioSpec obtained_spec = {};
|
||||
int obtained_samples = 0;
|
||||
@@ -118,23 +124,31 @@ bool SDLAudioStream::OpenDevice(bool stretch_enabled, Error* error)
|
||||
else
|
||||
DEV_LOG("SDL_GetAudioDeviceFormat() failed {}", SDL_GetError());
|
||||
|
||||
BaseInitialize(sample_readers[static_cast<size_t>(m_parameters.expansion_mode)], stretch_enabled);
|
||||
SDL_ResumeAudioDevice(SDL_GetAudioStreamDevice(m_stream));
|
||||
BaseInitialize(sample_readers[static_cast<size_t>(m_parameters.expansion_mode)], stretch_enabled);
|
||||
SDL_AudioDeviceID dev = SDL_GetAudioStreamDevice(m_stream);
|
||||
Console.WriteLnFmt("[SDL-Audio] Opened stream: rate={} Hz, channels={}, device_id={}", m_sample_rate, m_output_channels, dev);
|
||||
SDL_ResumeAudioDevice(dev);
|
||||
|
||||
return true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void SDLAudioStream::SetPaused(bool paused)
|
||||
{
|
||||
if (m_paused == paused)
|
||||
return;
|
||||
if (m_paused == paused)
|
||||
return;
|
||||
|
||||
if (paused)
|
||||
SDL_PauseAudioDevice(SDL_GetAudioStreamDevice(m_stream));
|
||||
else
|
||||
SDL_ResumeAudioDevice(SDL_GetAudioStreamDevice(m_stream));
|
||||
if (paused)
|
||||
{
|
||||
SDL_PauseAudioDevice(SDL_GetAudioStreamDevice(m_stream));
|
||||
Console.WriteLn("[SDL-Audio] Paused");
|
||||
}
|
||||
else
|
||||
{
|
||||
SDL_ResumeAudioDevice(SDL_GetAudioStreamDevice(m_stream));
|
||||
Console.WriteLn("[SDL-Audio] Resumed");
|
||||
}
|
||||
|
||||
m_paused = paused;
|
||||
m_paused = paused;
|
||||
}
|
||||
|
||||
void SDLAudioStream::CloseDevice()
|
||||
@@ -145,17 +159,23 @@ void SDLAudioStream::CloseDevice()
|
||||
|
||||
void SDLAudioStream::AudioCallback(void* userdata, SDL_AudioStream* stream, int additional_amount, int total_amount)
|
||||
{
|
||||
if (additional_amount > 0)
|
||||
{
|
||||
SDLAudioStream* const this_ptr = static_cast<SDLAudioStream*>(userdata);
|
||||
if (additional_amount > 0)
|
||||
{
|
||||
SDLAudioStream* const this_ptr = static_cast<SDLAudioStream*>(userdata);
|
||||
|
||||
const u32 num_frames = additional_amount / sizeof(SampleType) / this_ptr->m_output_channels;
|
||||
SampleType* buffer = SDL_stack_alloc(SampleType, additional_amount / sizeof(SampleType));
|
||||
if (buffer)
|
||||
{
|
||||
this_ptr->ReadFrames(buffer, num_frames);
|
||||
SDL_PutAudioStreamData(stream, buffer, additional_amount);
|
||||
SDL_stack_free(buffer);
|
||||
}
|
||||
}
|
||||
const u32 num_frames = additional_amount / sizeof(SampleType) / this_ptr->m_output_channels;
|
||||
SampleType* buffer = SDL_stack_alloc(SampleType, additional_amount / sizeof(SampleType));
|
||||
if (buffer)
|
||||
{
|
||||
this_ptr->ReadFrames(buffer, num_frames);
|
||||
SDL_PutAudioStreamData(stream, buffer, additional_amount);
|
||||
SDL_stack_free(buffer);
|
||||
}
|
||||
static int s_dbg_count = 0;
|
||||
if (s_dbg_count < 3)
|
||||
{
|
||||
Console.WriteLnFmt("[SDL-Audio] Callback: additional={} bytes, total={}, frames={}", additional_amount, total_amount, num_frames);
|
||||
s_dbg_count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -103,8 +103,8 @@ void SPU2::CreateOutputStream()
|
||||
s_output_stream.reset();
|
||||
|
||||
Error error;
|
||||
s_output_stream = AudioStream::CreateStream(EmuConfig.SPU2.Backend, sample_rate, EmuConfig.SPU2.StreamParameters,
|
||||
EmuConfig.SPU2.DriverName.c_str(), EmuConfig.SPU2.DeviceName.c_str(), EmuConfig.SPU2.IsTimeStretchEnabled(), &error);
|
||||
s_output_stream = AudioStream::CreateStream(EmuConfig.SPU2.Backend, sample_rate, EmuConfig.SPU2.StreamParameters,
|
||||
EmuConfig.SPU2.DriverName.c_str(), EmuConfig.SPU2.DeviceName.c_str(), EmuConfig.SPU2.IsTimeStretchEnabled(), &error);
|
||||
if (!s_output_stream)
|
||||
{
|
||||
Host::ReportErrorAsync("Error",
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import java.io.File;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
public class CoversAdapter extends RecyclerView.Adapter<CoversAdapter.VH> {
|
||||
public interface OnItemClick {
|
||||
@@ -80,36 +81,47 @@ public class CoversAdapter extends RecyclerView.Adapter<CoversAdapter.VH> {
|
||||
}
|
||||
holder.title.setText(titles[real]);
|
||||
String local = (localPaths != null && real < localPaths.length) ? localPaths[real] : null;
|
||||
File localFile = null;
|
||||
if (local != null) {
|
||||
File f = new File(local);
|
||||
if (f.exists() && f.length() > 0) localFile = f;
|
||||
}
|
||||
|
||||
if (localFile != null) {
|
||||
Glide.with(context)
|
||||
.load(localFile)
|
||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||
.fitCenter()
|
||||
.placeholder(android.R.color.transparent)
|
||||
.error(android.R.color.transparent)
|
||||
.into(holder.cover);
|
||||
} else {
|
||||
// Show a default placeholder from resources/no-cover.png if present
|
||||
File resDir = context.getExternalFilesDir("resources");
|
||||
File placeholder = (resDir != null) ? new File(resDir, "no-cover.png") : null;
|
||||
if (placeholder != null && placeholder.exists() && placeholder.length() > 0) {
|
||||
boolean loadedImage = false;
|
||||
if (local != null && local.startsWith("content://")) {
|
||||
android.net.Uri uri = android.net.Uri.parse(local);
|
||||
// Only load if the SAF file has content (length > 0)
|
||||
boolean hasContent = false;
|
||||
try {
|
||||
DocumentFile df = DocumentFile.fromSingleUri(context, uri);
|
||||
hasContent = (df != null && df.length() > 0);
|
||||
} catch (Throwable ignored) {}
|
||||
if (hasContent) {
|
||||
Glide.with(context)
|
||||
.load(placeholder)
|
||||
.load(uri)
|
||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||
.fitCenter()
|
||||
.placeholder(android.R.color.transparent)
|
||||
.error(android.R.color.transparent)
|
||||
.into(holder.cover);
|
||||
} else {
|
||||
// Fallback to logo if placeholder not found
|
||||
holder.cover.setImageResource(R.drawable.psx2_logo2_fixed);
|
||||
loadedImage = true;
|
||||
}
|
||||
} else if (local != null) {
|
||||
File f = new File(local);
|
||||
if (f.exists() && f.length() > 0) {
|
||||
Glide.with(context)
|
||||
.load(f)
|
||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||
.fitCenter()
|
||||
.placeholder(android.R.color.transparent)
|
||||
.error(android.R.color.transparent)
|
||||
.into(holder.cover);
|
||||
loadedImage = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!loadedImage) {
|
||||
Glide.with(context)
|
||||
.load(getPlaceholder())
|
||||
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
|
||||
.fitCenter()
|
||||
.placeholder(android.R.color.transparent)
|
||||
.error(android.R.color.transparent)
|
||||
.into(holder.cover);
|
||||
}
|
||||
holder.itemView.setOnClickListener(v -> {
|
||||
if (onItemClick != null) {
|
||||
@@ -133,6 +145,20 @@ public class CoversAdapter extends RecyclerView.Adapter<CoversAdapter.VH> {
|
||||
});
|
||||
}
|
||||
|
||||
private Object getPlaceholder() {
|
||||
// Try SAF resources/no-cover.png first
|
||||
android.net.Uri dataRoot = SafManager.getDataRootUri(context);
|
||||
if (dataRoot != null) {
|
||||
androidx.documentfile.provider.DocumentFile f = SafManager.getChild(context, new String[]{"resources"}, "no-cover.png");
|
||||
if (f != null && f.exists()) return f.getUri();
|
||||
}
|
||||
// Then try app external files path
|
||||
File resDir = context.getExternalFilesDir("resources");
|
||||
File placeholder = (resDir != null) ? new File(resDir, "no-cover.png") : null;
|
||||
if (placeholder != null && placeholder.exists() && placeholder.length() > 0) return placeholder;
|
||||
return R.drawable.psx2_logo2_fixed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return titles.length == 0 ? 0 : Integer.MAX_VALUE;
|
||||
|
||||
@@ -12,6 +12,8 @@ import android.widget.Spinner;
|
||||
import com.google.android.material.materialswitch.MaterialSwitch;
|
||||
import android.widget.TextView;
|
||||
import android.net.Uri;
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -25,6 +27,10 @@ public class GameSettingsDialogFragment extends DialogFragment {
|
||||
private static final String ARG_GAME_SERIAL = "game_serial";
|
||||
private static final String ARG_GAME_CRC = "game_crc";
|
||||
|
||||
// File picker state
|
||||
private ActivityResultLauncher<Intent> mPnachPicker;
|
||||
private boolean mImportAsCheats = true;
|
||||
|
||||
public static GameSettingsDialogFragment newInstance(String gameTitle, String gameUri, String gameSerial, String gameCrc) {
|
||||
GameSettingsDialogFragment fragment = new GameSettingsDialogFragment();
|
||||
Bundle args = new Bundle();
|
||||
@@ -42,6 +48,52 @@ public class GameSettingsDialogFragment extends DialogFragment {
|
||||
Context ctx = requireContext();
|
||||
View view = getLayoutInflater().inflate(R.layout.dialog_game_settings, null, false);
|
||||
|
||||
// Register picker ahead of time to avoid lifecycle crashes
|
||||
if (mPnachPicker == null) {
|
||||
mPnachPicker = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
|
||||
try {
|
||||
if (result.getResultCode() != Activity.RESULT_OK) return;
|
||||
Intent data = result.getData(); if (data == null) return;
|
||||
Uri uri = data.getData(); if (uri == null) return;
|
||||
Bundle args = getArguments();
|
||||
String gameSerial = args != null ? args.getString(ARG_GAME_SERIAL, "") : "";
|
||||
if (gameSerial == null || gameSerial.isEmpty()) {
|
||||
try { gameSerial = NativeApp.getCurrentGameSerial(); } catch (Throwable ignored) {}
|
||||
}
|
||||
if (gameSerial == null || gameSerial.isEmpty()) {
|
||||
android.widget.Toast.makeText(ctx, "Serial unknown; cannot import", android.widget.Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
java.io.File baseDir = ctx.getExternalFilesDir(null);
|
||||
if (baseDir == null) baseDir = ctx.getFilesDir();
|
||||
java.io.File targetDir = new java.io.File(baseDir, mImportAsCheats ? "cheats" : "patches");
|
||||
if (!targetDir.exists()) targetDir.mkdirs();
|
||||
java.io.File outFile = new java.io.File(targetDir, gameSerial + ".pnach");
|
||||
android.content.ContentResolver cr = ctx.getContentResolver();
|
||||
java.io.InputStream in = cr.openInputStream(uri);
|
||||
if (in == null) { android.widget.Toast.makeText(ctx, "Failed to open file", android.widget.Toast.LENGTH_SHORT).show(); return; }
|
||||
java.io.FileOutputStream fos = new java.io.FileOutputStream(outFile);
|
||||
byte[] buf = new byte[8192]; int n; while ((n = in.read(buf)) != -1) fos.write(buf, 0, n);
|
||||
fos.flush(); fos.close(); in.close();
|
||||
|
||||
// Mirror to SAF data root if set
|
||||
android.net.Uri dataRoot = SafManager.getDataRootUri(ctx);
|
||||
if (dataRoot != null) {
|
||||
String subdir = mImportAsCheats ? "cheats" : "patches";
|
||||
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{subdir}, gameSerial + ".pnach", "text/plain");
|
||||
if (target != null) {
|
||||
try (java.io.InputStream in2 = cr.openInputStream(android.net.Uri.fromFile(outFile))) {
|
||||
SafManager.copyFromStream(ctx, in2, target.getUri());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
android.widget.Toast.makeText(ctx, (mImportAsCheats ? "Cheats" : "Patch Codes") + " imported for " + gameSerial, android.widget.Toast.LENGTH_SHORT).show();
|
||||
} catch (Exception e) {
|
||||
android.widget.Toast.makeText(ctx, "Import failed: " + e.getMessage(), android.widget.Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Bundle args = getArguments();
|
||||
String gameTitle = args != null ? args.getString(ARG_GAME_TITLE, "Unknown Game") : "Unknown Game";
|
||||
String gameUri = args != null ? args.getString(ARG_GAME_URI, "") : "";
|
||||
@@ -88,8 +140,33 @@ public class GameSettingsDialogFragment extends DialogFragment {
|
||||
MaterialSwitch swNoInterlacingPatches = view.findViewById(R.id.sw_no_interlacing_patches);
|
||||
MaterialSwitch swEnablePatchCodes = view.findViewById(R.id.sw_enable_patch_codes);
|
||||
MaterialSwitch swEnableCheats = view.findViewById(R.id.sw_enable_cheats);
|
||||
MaterialSwitch swLoadTextures = view.findViewById(R.id.sw_load_textures_per_game);
|
||||
MaterialSwitch swAsyncTextures = view.findViewById(R.id.sw_async_texture_loading_per_game);
|
||||
|
||||
// Load existing per-game settings from INI and prefill widgets; if missing, use global
|
||||
// Prefill with global defaults
|
||||
android.content.SharedPreferences gp = ctx.getSharedPreferences("app_prefs", Context.MODE_PRIVATE);
|
||||
int gRenderer = gp.getInt("renderer", -1);
|
||||
float gScale = gp.getFloat("upscale_multiplier", 1.0f);
|
||||
int gBlend = gp.getInt("blending_accuracy", 1);
|
||||
boolean gWide = gp.getBoolean("widescreen_patches", true);
|
||||
boolean gNoInt = gp.getBoolean("no_interlacing_patches", true);
|
||||
boolean gLoadTex = gp.getBoolean("load_textures", false);
|
||||
boolean gAsyncTex = gp.getBoolean("async_texture_loading", true);
|
||||
boolean gCheats = gp.getBoolean("enable_cheats", false);
|
||||
|
||||
// Map to indices
|
||||
int defaultRendererIdx = (gRenderer == -1 ? 0 : (gRenderer == 14 ? 1 : (gRenderer == 12 ? 2 : 3)));
|
||||
int defaultScaleIdx = Math.max(0, Math.min(7, Math.round(gScale) - 1));
|
||||
spRenderer.setSelection(defaultRendererIdx);
|
||||
spResolution.setSelection(defaultScaleIdx);
|
||||
spBlendingAccuracy.setSelection(Math.max(0, Math.min(5, gBlend)));
|
||||
swWidescreenPatches.setChecked(gWide);
|
||||
swNoInterlacingPatches.setChecked(gNoInt);
|
||||
if (swLoadTextures != null) swLoadTextures.setChecked(gLoadTex);
|
||||
if (swAsyncTextures != null) swAsyncTextures.setChecked(gAsyncTex);
|
||||
swEnableCheats.setChecked(gCheats);
|
||||
|
||||
// Load existing per-game settings from INI and prefill widgets; if present overrides globals
|
||||
try {
|
||||
String serial = gameSerial;
|
||||
if (serial == null || serial.isEmpty()) {
|
||||
@@ -195,17 +272,19 @@ public class GameSettingsDialogFragment extends DialogFragment {
|
||||
// Use MaterialAlertDialogBuilder with Material 3 overlay for the main dialog
|
||||
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(ctx,
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog);
|
||||
builder.setTitle("Per-Game Settings")
|
||||
.setView(view)
|
||||
.setNegativeButton("Cancel", (d, w) -> d.dismiss())
|
||||
.setPositiveButton("Save", (d, w) -> {
|
||||
builder.setCustomTitle(UiUtils.centeredDialogTitle(ctx, "Per-Game Settings"))
|
||||
.setView(view)
|
||||
.setNegativeButton("Cancel", (d, w) -> d.dismiss())
|
||||
.setPositiveButton("Save", (d, w) -> {
|
||||
final int blendLevel = spBlendingAccuracy.getSelectedItemPosition();
|
||||
final int rendererIdx = spRenderer.getSelectedItemPosition();
|
||||
final int resIdx = spResolution.getSelectedItemPosition();
|
||||
final boolean wide = swWidescreenPatches.isChecked();
|
||||
final boolean noInt = swNoInterlacingPatches.isChecked();
|
||||
final boolean enablePatches = swEnablePatchCodes.isChecked();
|
||||
final boolean enablePatches = true; // always on
|
||||
final boolean enableCheats = swEnableCheats.isChecked();
|
||||
final boolean loadTex = (swLoadTextures != null && swLoadTextures.isChecked());
|
||||
final boolean asyncTex = (swAsyncTextures != null && swAsyncTextures.isChecked());
|
||||
|
||||
// Persist per-game INI explicitly (supports Auto as well)
|
||||
writeGameSettingsIni(ctx, gameSerial, gameCrc,
|
||||
@@ -222,6 +301,8 @@ public class GameSettingsDialogFragment extends DialogFragment {
|
||||
else renderer = 13;
|
||||
|
||||
float scale = Math.max(1, Math.min(8, resIdx + 1));
|
||||
NativeApp.setLoadTextures(loadTex);
|
||||
NativeApp.setAsyncTextureLoading(asyncTex);
|
||||
NativeApp.applyPerGameSettingsBatch(renderer, scale, blendLevel, wide, noInt, enablePatches, enableCheats);
|
||||
} catch (Throwable t) {
|
||||
android.util.Log.e("GameSettings", "Per-game batch apply failed: " + t.getMessage());
|
||||
@@ -250,45 +331,13 @@ public class GameSettingsDialogFragment extends DialogFragment {
|
||||
final String[] choices = new String[]{"Import as Cheats", "Import as Patch Codes"};
|
||||
new MaterialAlertDialogBuilder(ctx,
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("Import PNACH")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(ctx, "Import PNACH"))
|
||||
.setItems(choices, (dlg, which) -> {
|
||||
boolean asCheats = (which == 0);
|
||||
// Prepare picker
|
||||
mImportAsCheats = (which == 0);
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
// Store choice in tag
|
||||
view.setTag(R.id.btn_import_pnach, asCheats);
|
||||
registerForActivityResult(new androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(), result -> {
|
||||
try {
|
||||
if (result.getResultCode() != android.app.Activity.RESULT_OK) return;
|
||||
Intent data = result.getData(); if (data == null) return;
|
||||
android.net.Uri uri = data.getData(); if (uri == null) return;
|
||||
boolean importAsCheats = Boolean.TRUE.equals(view.getTag(R.id.btn_import_pnach));
|
||||
String serialLoad = gameSerial;
|
||||
if (serialLoad == null || serialLoad.isEmpty()) {
|
||||
try { serialLoad = NativeApp.getCurrentGameSerial(); } catch (Throwable ignored) {}
|
||||
}
|
||||
if (serialLoad == null || serialLoad.isEmpty()) {
|
||||
android.widget.Toast.makeText(ctx, "Serial unknown; cannot import", android.widget.Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
java.io.File baseDir = ctx.getExternalFilesDir(null);
|
||||
if (baseDir == null) baseDir = ctx.getFilesDir();
|
||||
java.io.File targetDir = new java.io.File(baseDir, importAsCheats ? "cheats" : "patches");
|
||||
if (!targetDir.exists()) targetDir.mkdirs();
|
||||
java.io.File outFile = new java.io.File(targetDir, serialLoad + ".pnach");
|
||||
android.content.ContentResolver cr = ctx.getContentResolver();
|
||||
java.io.InputStream in = cr.openInputStream(uri);
|
||||
if (in == null) { android.widget.Toast.makeText(ctx, "Failed to open file", android.widget.Toast.LENGTH_SHORT).show(); return; }
|
||||
java.io.FileOutputStream fos = new java.io.FileOutputStream(outFile);
|
||||
byte[] buf = new byte[8192]; int n; while ((n = in.read(buf)) != -1) fos.write(buf, 0, n);
|
||||
fos.flush(); fos.close(); in.close();
|
||||
android.widget.Toast.makeText(ctx, (importAsCheats ? "Cheats" : "Patch Codes") + " imported for " + serialLoad, android.widget.Toast.LENGTH_SHORT).show();
|
||||
} catch (Exception e) {
|
||||
android.widget.Toast.makeText(ctx, "Import failed: " + e.getMessage(), android.widget.Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}).launch(intent);
|
||||
mPnachPicker.launch(intent);
|
||||
})
|
||||
.show();
|
||||
});
|
||||
@@ -374,6 +423,18 @@ public class GameSettingsDialogFragment extends DialogFragment {
|
||||
fos.flush();
|
||||
fos.close();
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
// Mirror to SAF data root if set
|
||||
android.net.Uri dataRoot = SafManager.getDataRootUri(ctx);
|
||||
if (dataRoot != null) {
|
||||
try {
|
||||
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{"gamesettings"}, fileName, "text/plain");
|
||||
if (target != null) {
|
||||
byte[] data = sb.toString().getBytes("UTF-8");
|
||||
SafManager.writeBytes(ctx, target.getUri(), data);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
String serial = saved;
|
||||
if (serial == null || serial.isEmpty()) {
|
||||
try {
|
||||
String nativeSerial = NativeApp.getGameSerial(uris[i]);
|
||||
String nativeSerial = NativeApp.getGameSerialSafe(uris[i]);
|
||||
if (nativeSerial != null && !nativeSerial.isEmpty()) {
|
||||
serial = normalizeSerial(nativeSerial);
|
||||
prefs.edit().putString("serial:" + uris[i], serial).apply();
|
||||
@@ -218,7 +218,20 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
serial = buildSerialFromUri(uris[i]);
|
||||
}
|
||||
coverUrls[i] = buildCoverUrlFromSerial(serial);
|
||||
localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
|
||||
// Prefer SAF content URI if data root is set, else absolute file path
|
||||
android.net.Uri dataRoot = SafManager.getDataRootUri(requireContext());
|
||||
if (dataRoot != null) {
|
||||
androidx.documentfile.provider.DocumentFile f = SafManager.getChild(requireContext(), new String[]{"covers"}, serial + ".png");
|
||||
if (f != null && f.exists()) {
|
||||
localPaths[i] = f.getUri().toString();
|
||||
} else {
|
||||
// Pre-create to get a stable Uri
|
||||
androidx.documentfile.provider.DocumentFile nf = SafManager.createChild(requireContext(), new String[]{"covers"}, serial + ".png", "image/png");
|
||||
localPaths[i] = (nf != null) ? nf.getUri().toString() : new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
|
||||
}
|
||||
} else {
|
||||
localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
|
||||
}
|
||||
}
|
||||
|
||||
// cache originals for sorting/filtering
|
||||
@@ -737,12 +750,18 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
try {
|
||||
// Prefer native serial extraction so CHDs work
|
||||
String better = null;
|
||||
try { better = NativeApp.getGameSerial(uris[i]); } catch (Throwable ignored) {}
|
||||
try { better = NativeApp.getGameSerialSafe(uris[i]); } catch (Throwable ignored) {}
|
||||
if (better == null) better = extractSerialFromUri(uris[i]);
|
||||
if (better != null && !better.equalsIgnoreCase(serialFromUrl(coverUrls[i]))) {
|
||||
String serial = normalizeSerial(better);
|
||||
coverUrls[i] = buildCoverUrlFromSerial(serial);
|
||||
localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
|
||||
android.net.Uri dataRoot = SafManager.getDataRootUri(requireContext());
|
||||
if (dataRoot != null) {
|
||||
androidx.documentfile.provider.DocumentFile nf = SafManager.createChild(requireContext(), new String[]{"covers"}, serial + ".png", "image/png");
|
||||
localPaths[i] = (nf != null) ? nf.getUri().toString() : new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
|
||||
} else {
|
||||
localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
|
||||
}
|
||||
editor.putString("serial:" + uris[i], serial);
|
||||
}
|
||||
} catch (Exception ignored) { }
|
||||
@@ -758,7 +777,7 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
String outPath = localPaths[i];
|
||||
if (isFileValid(outPath)) { ok++; continue; }
|
||||
try {
|
||||
if (downloadToFile(url, outPath)) ok++;
|
||||
if (downloadToTarget(url, outPath)) ok++;
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
final int downloaded = ok;
|
||||
@@ -784,13 +803,19 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
return base;
|
||||
}
|
||||
|
||||
private static boolean isFileValid(String path) {
|
||||
private boolean isFileValid(String path) {
|
||||
if (path == null) return false;
|
||||
if (path.startsWith("content://")) {
|
||||
try {
|
||||
androidx.documentfile.provider.DocumentFile f = androidx.documentfile.provider.DocumentFile.fromSingleUri(requireContext(), android.net.Uri.parse(path));
|
||||
return f != null && f.length() > 0;
|
||||
} catch (Throwable ignored) { return false; }
|
||||
}
|
||||
java.io.File f = new java.io.File(path);
|
||||
return f.exists() && f.length() > 0;
|
||||
}
|
||||
|
||||
private static boolean downloadToFile(String urlStr, String outPath) throws Exception {
|
||||
private boolean downloadToTarget(String urlStr, String outPath) throws Exception {
|
||||
java.net.URL url = new java.net.URL(urlStr);
|
||||
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(10000);
|
||||
@@ -799,17 +824,29 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
conn.connect();
|
||||
int code = conn.getResponseCode();
|
||||
if (code != 200) { conn.disconnect(); return false; }
|
||||
java.io.File outFile = new java.io.File(outPath);
|
||||
java.io.File parent = outFile.getParentFile();
|
||||
if (parent != null && !parent.exists()) parent.mkdirs();
|
||||
java.io.InputStream in = conn.getInputStream();
|
||||
java.io.FileOutputStream fos = new java.io.FileOutputStream(outFile);
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) fos.write(buf, 0, n);
|
||||
fos.flush();
|
||||
fos.close();
|
||||
in.close();
|
||||
if (outPath.startsWith("content://")) {
|
||||
android.net.Uri uri = android.net.Uri.parse(outPath);
|
||||
try (java.io.OutputStream os = requireContext().getContentResolver().openOutputStream(uri, "w")) {
|
||||
if (os == null) { conn.disconnect(); return false; }
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) os.write(buf, 0, n);
|
||||
os.flush();
|
||||
}
|
||||
in.close();
|
||||
} else {
|
||||
java.io.File outFile = new java.io.File(outPath);
|
||||
java.io.File parent = outFile.getParentFile();
|
||||
if (parent != null && !parent.exists()) parent.mkdirs();
|
||||
java.io.FileOutputStream fos = new java.io.FileOutputStream(outFile);
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) fos.write(buf, 0, n);
|
||||
fos.flush();
|
||||
fos.close();
|
||||
in.close();
|
||||
}
|
||||
conn.disconnect();
|
||||
return true;
|
||||
}
|
||||
@@ -888,7 +925,7 @@ public class GamesCoverDialogFragment extends DialogFragment {
|
||||
private void showGameSettings(String gameTitle, String gameUri) {
|
||||
// Prefer native extraction so CHDs work
|
||||
String gameSerial = null;
|
||||
try { gameSerial = NativeApp.getGameSerial(gameUri); } catch (Throwable ignored) {}
|
||||
try { gameSerial = NativeApp.getGameSerialSafe(gameUri); } catch (Throwable ignored) {}
|
||||
if (gameSerial == null || gameSerial.isEmpty()) {
|
||||
gameSerial = extractSerialFromUri(gameUri);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import android.view.WindowInsets;
|
||||
import android.view.WindowInsetsController;
|
||||
import android.view.WindowManager;
|
||||
import android.util.TypedValue;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
@@ -58,6 +59,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
private HIDDeviceManager mHIDDeviceManager;
|
||||
private ControllerInputHandler mControllerInputHandler;
|
||||
private Thread mEmulationThread = null;
|
||||
private boolean mSetupWizardActive = false;
|
||||
private boolean mHudVisible = false;
|
||||
private InputManager mInputManager;
|
||||
|
||||
@@ -249,7 +251,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
if (hasFocus) hideStatusBar();
|
||||
}
|
||||
|
||||
private void pickGamesFolder() {
|
||||
public void pickGamesFolder() {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||
@@ -257,6 +259,11 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
startActivityResultGamesFolderPick.launch(intent);
|
||||
}
|
||||
|
||||
// Let the user select a data root (SAF) where app folders/files live (covers, resources, saves, etc.)
|
||||
public void pickDataRootFolder() {
|
||||
startActivityResultDataRootPick.launch(SafManager.buildOpenTreeIntent());
|
||||
}
|
||||
|
||||
private void showGamesListOrReselect(Uri treeUri) {
|
||||
// Re-scan quickly each time to keep list fresh
|
||||
String[] names;
|
||||
@@ -276,7 +283,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
if (namesFinal.length == 0) {
|
||||
new MaterialAlertDialogBuilder(this,
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("GAMES")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(this, "GAMES"))
|
||||
.setMessage("No games found. Pick a folder?")
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setPositiveButton("Pick Folder", (d,w) -> pickGamesFolder())
|
||||
@@ -381,6 +388,8 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
|
||||
// Default resources
|
||||
copyAssetAll(getApplicationContext(), "resources");
|
||||
// If a SAF data root is set, mirror resources to it (first time only)
|
||||
copyAssetsToSafDataRoot();
|
||||
|
||||
Initialize();
|
||||
|
||||
@@ -402,8 +411,10 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
int currentOrientation = getResources().getConfiguration().orientation;
|
||||
applyConstraintsForOrientation(currentOrientation);
|
||||
|
||||
// Prompt for BIOS if missing
|
||||
maybePromptForBios();
|
||||
// Prompt for BIOS if missing, but only after first-run setup
|
||||
if (getSharedPreferences("app_prefs", MODE_PRIVATE).getBoolean("first_run_done", false)) {
|
||||
maybePromptForBios();
|
||||
}
|
||||
|
||||
// Listen for controller attach/detach and update UI accordingly
|
||||
mInputManager = (InputManager) getSystemService(Context.INPUT_SERVICE);
|
||||
@@ -411,6 +422,17 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
mInputManager.registerInputDeviceListener(mInputDeviceListener, null);
|
||||
}
|
||||
updateUiForControllerPresence();
|
||||
|
||||
// Show first-run setup wizard if needed
|
||||
if (!getSharedPreferences("app_prefs", MODE_PRIVATE).getBoolean("first_run_done", false)) {
|
||||
SetupWizardDialogFragment f = SetupWizardDialogFragment.newInstance();
|
||||
f.setCancelable(false);
|
||||
f.show(getSupportFragmentManager(), "setup_wizard");
|
||||
}
|
||||
}
|
||||
|
||||
public void setSetupWizardActive(boolean active) {
|
||||
mSetupWizardActive = active;
|
||||
}
|
||||
|
||||
// Public method to open the games covers dialog via controller quick actions
|
||||
@@ -461,6 +483,11 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
SavesDialogFragment dialog = new SavesDialogFragment();
|
||||
dialog.show(getSupportFragmentManager(), "saves_dialog");
|
||||
});
|
||||
// Long-press: choose SAF data folder for app files (covers/resources/etc)
|
||||
btn_saves.setOnLongClickListener(v -> {
|
||||
pickDataRootFolder();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// BIOS button repurposed: short tap toggles renderer, long-press picks BIOS folder
|
||||
@@ -822,6 +849,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
return "VK"; // 14
|
||||
}
|
||||
|
||||
|
||||
private void updateRendererButtonLabel() {
|
||||
MaterialButton btn_bios = findViewById(R.id.btn_bios);
|
||||
if (btn_bios != null) {
|
||||
@@ -933,23 +961,24 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
}
|
||||
|
||||
private void maybePromptForBios() {
|
||||
// Temporarily disable automatic BIOS prompt
|
||||
if (!getSharedPreferences("app_prefs", MODE_PRIVATE).getBoolean("bios_auto_prompt_enabled", false))
|
||||
return;
|
||||
File biosDir = new File(getApplicationContext().getExternalFilesDir(null), "bios");
|
||||
if (hasAnyBiosFiles(biosDir)) return;
|
||||
showBiosPrompt();
|
||||
}
|
||||
|
||||
private boolean ensureBiosOrPrompt() {
|
||||
File biosDir = new File(getApplicationContext().getExternalFilesDir(null), "bios");
|
||||
if (hasAnyBiosFiles(biosDir)) return true;
|
||||
showBiosPrompt();
|
||||
return false;
|
||||
// Temporarily disable automatic BIOS prompting; wizard handles manual import
|
||||
return true;
|
||||
}
|
||||
|
||||
private void showBiosPrompt() {
|
||||
public void showBiosPrompt() {
|
||||
if (mBiosPromptDialog != null && mBiosPromptDialog.isShowing()) return;
|
||||
mBiosPromptDialog = new com.google.android.material.dialog.MaterialAlertDialogBuilder(this,
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("BIOS Required")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(this, "BIOS Required"))
|
||||
.setMessage("No PS2 BIOS detected. Import your BIOS files to run games.\n\nHint: Press Select+Start for Quick Actions.")
|
||||
.setNegativeButton("Later", (d, w) -> { /* leave dialog dismiss */ })
|
||||
.setPositiveButton("Pick Files", (d, w) -> {
|
||||
@@ -1011,7 +1040,8 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
final int RENDERER_SOFTWARE = 13;
|
||||
final int RENDERER_VULKAN = 14;
|
||||
|
||||
int renderer = prefs.getInt("renderer", RENDERER_VULKAN);
|
||||
// Default to Automatic (-1) so the core can select a compatible renderer on older devices
|
||||
int renderer = prefs.getInt("renderer", -1);
|
||||
NativeApp.renderGpu(renderer);
|
||||
|
||||
// Resolution scale multiplier (float), default 1.0
|
||||
@@ -1151,24 +1181,74 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
getContentResolver().takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
} catch (SecurityException ignored) {}
|
||||
}
|
||||
// Save folder and show games
|
||||
// Save folder and optionally show games
|
||||
getSharedPreferences("app_prefs", MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("games_folder_uri", treeUri.toString())
|
||||
.apply();
|
||||
showGamesListOrReselect(treeUri);
|
||||
if (!mSetupWizardActive) {
|
||||
showGamesListOrReselect(treeUri);
|
||||
} else {
|
||||
Toast.makeText(this, "Games folder set", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
});
|
||||
|
||||
// SAF data-root picker result
|
||||
public final ActivityResultLauncher<Intent> startActivityResultDataRootPick = registerForActivityResult(
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {
|
||||
if (result.getResultCode() == Activity.RESULT_OK) {
|
||||
try {
|
||||
Intent data = result.getData();
|
||||
if (data != null) {
|
||||
Uri treeUri = data.getData();
|
||||
if (treeUri != null) {
|
||||
final int takeFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
try { getContentResolver().takePersistableUriPermission(treeUri, takeFlags); } catch (SecurityException ignored) {}
|
||||
SafManager.setDataRootUri(this, treeUri);
|
||||
// Seed default resources to SAF data root
|
||||
copyAssetsToSafDataRoot();
|
||||
Toast.makeText(this, "Data folder set", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
});
|
||||
|
||||
// Copies assets/resources under the selected SAF data root (resources/..)
|
||||
private void copyAssetsToSafDataRoot() {
|
||||
Uri root = SafManager.getDataRootUri(this);
|
||||
if (root == null) return;
|
||||
// Only seed once
|
||||
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
|
||||
if (prefs.getBoolean("saf_resources_seeded", false)) return;
|
||||
// Flatten copy of assets/resources directory to SAF
|
||||
try {
|
||||
copyAssetAllToSaf(getApplicationContext(), "resources");
|
||||
prefs.edit().putBoolean("saf_resources_seeded", true).apply();
|
||||
} catch (Throwable ignored) {}
|
||||
}
|
||||
|
||||
private void importSingleBiosUri(Uri uri, File biosDir) {
|
||||
if (uri == null) return;
|
||||
String displayName = getDisplayNameFromUri(this, uri);
|
||||
if (TextUtils.isEmpty(displayName)) displayName = "bios.bin";
|
||||
File outFile = new File(biosDir, displayName);
|
||||
copyUriToFile(this, uri, outFile);
|
||||
// Also mirror to SAF data root if set
|
||||
android.net.Uri dataRoot = SafManager.getDataRootUri(this);
|
||||
if (dataRoot != null) {
|
||||
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(this, new String[]{"bios"}, displayName, "application/octet-stream");
|
||||
if (target != null) {
|
||||
try (java.io.InputStream in = getContentResolver().openInputStream(uri)) {
|
||||
SafManager.copyFromStream(this, in, target.getUri());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void copyDocumentTreeToDirectory(DocumentFile dir, File outDir) {
|
||||
@@ -1334,7 +1414,8 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
|
||||
// Apply global renderer setting before starting new game
|
||||
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
|
||||
int renderer = prefs.getInt("renderer", 14); // Default to Vulkan if no setting
|
||||
// Default to Automatic (-1) so the core can pick the best available backend (Vulkan/OpenGL/Software)
|
||||
int renderer = prefs.getInt("renderer", -1);
|
||||
android.util.Log.d("MainActivity", "Applying global renderer before game restart: " + renderer);
|
||||
NativeApp.renderGpu(renderer);
|
||||
|
||||
@@ -1342,6 +1423,8 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
startEmuThread();
|
||||
}
|
||||
|
||||
// (Renderer toast temporarily removed per user request — AUTO behavior retained.)
|
||||
|
||||
// Public API for UI components to reboot the emulator
|
||||
public void rebootEmu() {
|
||||
if (!TextUtils.isEmpty(m_szGamefile)) {
|
||||
@@ -1352,6 +1435,8 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public boolean onGenericMotionEvent(MotionEvent event) {
|
||||
// Use only our controller handler - disable SDL fallback to avoid conflicts
|
||||
@@ -1452,6 +1537,46 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
// Mirror asset folder into SAF data root (if set)
|
||||
private void copyAssetAllToSaf(Context context, String srcPath) {
|
||||
Uri dataRoot = SafManager.getDataRootUri(context);
|
||||
if (dataRoot == null) return;
|
||||
AssetManager assetMgr = context.getAssets();
|
||||
try {
|
||||
String[] assets = assetMgr.list(srcPath);
|
||||
if (assets != null) {
|
||||
if (assets.length == 0) {
|
||||
// It's a file under srcPath; create it in SAF
|
||||
String[] parts = srcPath.split("/");
|
||||
String filename = parts.length > 0 ? parts[parts.length - 1] : srcPath;
|
||||
String[] dirSegs = parts.length > 1 ? java.util.Arrays.copyOf(parts, parts.length - 1) : new String[0];
|
||||
DocumentFile existing = SafManager.getChild(context, dirSegs, filename);
|
||||
if (existing != null && existing.length() > 0) return;
|
||||
DocumentFile target = SafManager.createChild(context, dirSegs, filename, guessMime(filename));
|
||||
if (target != null) {
|
||||
try (InputStream is = assetMgr.open(srcPath)) {
|
||||
SafManager.copyFromStream(context, is, target.getUri());
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
} else {
|
||||
for (String element : assets) {
|
||||
copyAssetAllToSaf(context, srcPath + File.separator + element);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
private static String guessMime(String filename) {
|
||||
String lower = filename.toLowerCase(java.util.Locale.ROOT);
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml";
|
||||
if (lower.endsWith(".zip")) return "application/zip";
|
||||
if (lower.endsWith(".txt")) return "text/plain";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
private static void copyFile(Context context, String srcFile, String destFile) {
|
||||
AssetManager assetMgr = context.getAssets();
|
||||
InputStream is = null;
|
||||
@@ -1490,7 +1615,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
|
||||
private void showExitDialog() {
|
||||
new MaterialAlertDialogBuilder(this,
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("Exit App")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(this, "Exit App"))
|
||||
.setMessage("Do you want to exit PSX2?")
|
||||
.setIcon(android.R.drawable.ic_dialog_alert)
|
||||
.setPositiveButton("Exit", (dialog, which) -> {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.izzy2lost.psx2;
|
||||
|
||||
import com.bumptech.glide.annotation.GlideModule;
|
||||
import com.bumptech.glide.module.AppGlideModule;
|
||||
|
||||
@GlideModule
|
||||
public final class MyAppGlideModule extends AppGlideModule {
|
||||
@Override
|
||||
public boolean isManifestParsingEnabled() {
|
||||
// Avoid manifest parsing to speed up initialization and prevent double modules
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,42 @@ public class NativeApp {
|
||||
public static native String getGameSerial(String gameUri);
|
||||
public static native String getGameCrc(String gameUri);
|
||||
public static native String getCurrentGameSerial();
|
||||
|
||||
// Synchronization object for CDVD operations to prevent crashes
|
||||
private static final Object CDVD_LOCK = new Object();
|
||||
|
||||
// Synchronized wrapper for getGameSerial to prevent CDVD race conditions
|
||||
public static String getGameSerialSafe(String gameUri) {
|
||||
synchronized (CDVD_LOCK) {
|
||||
try {
|
||||
return getGameSerial(gameUri);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronized wrapper for getGameTitleFromUri to prevent CDVD race conditions
|
||||
public static String getGameTitleFromUriSafe(String gameUri) {
|
||||
synchronized (CDVD_LOCK) {
|
||||
try {
|
||||
return getGameTitleFromUri(gameUri);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronized wrapper for getGameCrc to prevent CDVD race conditions
|
||||
public static String getGameCrcSafe(String gameUri) {
|
||||
synchronized (CDVD_LOCK) {
|
||||
try {
|
||||
return getGameCrc(gameUri);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static native void onNativeSurfaceCreated();
|
||||
public static native void onNativeSurfaceChanged(Surface surface, int w, int h);
|
||||
|
||||
@@ -40,7 +40,7 @@ public class QuickActionsDialogFragment extends DialogFragment {
|
||||
btnExitToMenu.setOnClickListener(v -> {
|
||||
new MaterialAlertDialogBuilder(requireContext(),
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("Exit to Menu")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(requireContext(), "Exit to Menu"))
|
||||
.setMessage("This feature is not implemented yet. Would you like to quit the app instead?")
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setPositiveButton("Quit App", (d, w) -> {
|
||||
@@ -56,7 +56,7 @@ public class QuickActionsDialogFragment extends DialogFragment {
|
||||
btnRestartGame.setOnClickListener(v -> {
|
||||
new MaterialAlertDialogBuilder(requireContext(),
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("Restart Game")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(requireContext(), "Restart Game"))
|
||||
.setMessage("Restart the current game?")
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setPositiveButton("Restart", (d, w) -> {
|
||||
@@ -76,7 +76,7 @@ public class QuickActionsDialogFragment extends DialogFragment {
|
||||
btnQuitApp.setOnClickListener(v -> {
|
||||
new MaterialAlertDialogBuilder(requireContext(),
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("Quit App")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(requireContext(), "Quit App"))
|
||||
.setMessage("Quit PSX2?")
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setPositiveButton("Quit", (d, w) -> {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.izzy2lost.psx2;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.provider.DocumentsContract;
|
||||
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* Minimal helper around Android SAF for a user-selected data root directory.
|
||||
* Stores a persisted tree URI in SharedPreferences and provides helpers to
|
||||
* create/list/read/write files under subdirectories (e.g., covers, resources).
|
||||
*/
|
||||
public final class SafManager {
|
||||
private static final String PREFS = "app_prefs";
|
||||
private static final String KEY_DATA_ROOT = "data_root_tree_uri";
|
||||
|
||||
private SafManager() {}
|
||||
|
||||
public static Uri getDataRootUri(Context ctx) {
|
||||
SharedPreferences prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
String s = prefs.getString(KEY_DATA_ROOT, null);
|
||||
return (s != null && !s.isEmpty()) ? Uri.parse(s) : null;
|
||||
}
|
||||
|
||||
public static void setDataRootUri(Context ctx, Uri treeUri) {
|
||||
SharedPreferences prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
prefs.edit().putString(KEY_DATA_ROOT, treeUri != null ? treeUri.toString() : null).apply();
|
||||
}
|
||||
|
||||
public static DocumentFile getDataRoot(Context ctx) {
|
||||
Uri u = getDataRootUri(ctx);
|
||||
if (u == null) return null;
|
||||
return DocumentFile.fromTreeUri(ctx, u);
|
||||
}
|
||||
|
||||
public static DocumentFile getOrCreateDir(Context ctx, String... segments) {
|
||||
DocumentFile root = getDataRoot(ctx);
|
||||
if (root == null) return null;
|
||||
DocumentFile cur = root;
|
||||
for (String seg : segments) {
|
||||
if (seg == null || seg.isEmpty()) continue;
|
||||
DocumentFile next = cur.findFile(seg);
|
||||
if (next == null) next = cur.createDirectory(seg);
|
||||
if (next == null) return null;
|
||||
cur = next;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
public static DocumentFile getChild(Context ctx, String[] dirSegments, String filename) {
|
||||
DocumentFile dir = getOrCreateDir(ctx, dirSegments);
|
||||
if (dir == null) return null;
|
||||
DocumentFile f = dir.findFile(filename);
|
||||
return f;
|
||||
}
|
||||
|
||||
public static DocumentFile createChild(Context ctx, String[] dirSegments, String filename, String mime) {
|
||||
DocumentFile dir = getOrCreateDir(ctx, dirSegments);
|
||||
if (dir == null) return null;
|
||||
DocumentFile f = dir.findFile(filename);
|
||||
if (f != null && f.isFile()) return f;
|
||||
return dir.createFile(mime != null ? mime : "application/octet-stream", filename);
|
||||
}
|
||||
|
||||
public static boolean writeBytes(Context ctx, Uri target, byte[] data) {
|
||||
if (target == null || data == null) return false;
|
||||
try (OutputStream os = ctx.getContentResolver().openOutputStream(target, "w")) {
|
||||
if (os == null) return false;
|
||||
os.write(data);
|
||||
os.flush();
|
||||
return true;
|
||||
} catch (Exception ignored) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean copyFromStream(Context ctx, InputStream in, Uri target) {
|
||||
if (in == null || target == null) return false;
|
||||
try (OutputStream os = ctx.getContentResolver().openOutputStream(target, "w")) {
|
||||
if (os == null) return false;
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) os.write(buf, 0, n);
|
||||
os.flush();
|
||||
return true;
|
||||
} catch (Exception ignored) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean exists(Context ctx, Uri uri) {
|
||||
try (InputStream is = ctx.getContentResolver().openInputStream(uri)) {
|
||||
return is != null;
|
||||
} catch (Exception ignored) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static Intent buildOpenTreeIntent() {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION |
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION |
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION |
|
||||
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
|
||||
return intent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ public class SettingsDialogFragment extends DialogFragment {
|
||||
btnReboot.setOnClickListener(v -> {
|
||||
new MaterialAlertDialogBuilder(requireContext(),
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
|
||||
.setTitle("Reboot")
|
||||
.setCustomTitle(UiUtils.centeredDialogTitle(requireContext(), "Reboot"))
|
||||
.setMessage("Restart the current game?")
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setPositiveButton("Reboot", (d1, w1) -> {
|
||||
@@ -188,6 +188,7 @@ public class SettingsDialogFragment extends DialogFragment {
|
||||
boolean savedLoadTextures = prefs.getBoolean("load_textures", false);
|
||||
boolean savedAsyncTextureLoading = prefs.getBoolean("async_texture_loading", true);
|
||||
boolean savedHud = prefs.getBoolean("hud_visible", false);
|
||||
boolean savedCheatsGlobal = prefs.getBoolean("enable_cheats", false);
|
||||
int savedBlending = prefs.getInt("blending_accuracy", 1);
|
||||
|
||||
if (savedRenderer == RENDERER_VULKAN && rbVk != null) rbVk.setChecked(true);
|
||||
@@ -213,10 +214,12 @@ public class SettingsDialogFragment extends DialogFragment {
|
||||
swLoadTextures.setChecked(savedLoadTextures);
|
||||
swAsyncTextureLoading.setChecked(savedAsyncTextureLoading);
|
||||
if (swDevHud != null) swDevHud.setChecked(savedHud);
|
||||
MaterialSwitch swCheatsGlobal = view.findViewById(R.id.sw_enable_cheats_global);
|
||||
if (swCheatsGlobal != null) swCheatsGlobal.setChecked(savedCheatsGlobal);
|
||||
|
||||
MaterialAlertDialogBuilder b = new MaterialAlertDialogBuilder(requireContext(),
|
||||
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog);
|
||||
b.setTitle("Global Settings")
|
||||
b.setCustomTitle(UiUtils.centeredDialogTitle(requireContext(), "Global Settings"))
|
||||
.setView(view)
|
||||
.setNegativeButton("Cancel", (d, w) -> d.dismiss())
|
||||
.setPositiveButton("Save", (d, w) -> {
|
||||
@@ -233,21 +236,23 @@ public class SettingsDialogFragment extends DialogFragment {
|
||||
boolean noInterlacingPatches = swNoInterlacing.isChecked();
|
||||
boolean loadTextures = swLoadTextures.isChecked();
|
||||
boolean asyncTextureLoading = swAsyncTextureLoading.isChecked();
|
||||
boolean hudVisible = (swDevHud != null && swDevHud.isChecked());
|
||||
boolean hudVisible = (swDevHud != null && swDevHud.isChecked());
|
||||
boolean enableCheatsGlobal = swCheatsGlobal != null && swCheatsGlobal.isChecked();
|
||||
|
||||
// Persist settings to SharedPreferences
|
||||
int blendingLevel = spBlending.getSelectedItemPosition();
|
||||
prefs.edit()
|
||||
.putInt("renderer", renderer)
|
||||
.putFloat("upscale_multiplier", scale)
|
||||
.putInt("aspect_ratio", aspectRatio)
|
||||
.putInt("blending_accuracy", blendingLevel)
|
||||
.putBoolean("widescreen_patches", widescreenPatches)
|
||||
.putBoolean("no_interlacing_patches", noInterlacingPatches)
|
||||
.putBoolean("load_textures", loadTextures)
|
||||
.putBoolean("async_texture_loading", asyncTextureLoading)
|
||||
.putBoolean("hud_visible", hudVisible)
|
||||
.apply();
|
||||
prefs.edit()
|
||||
.putInt("renderer", renderer)
|
||||
.putFloat("upscale_multiplier", scale)
|
||||
.putInt("aspect_ratio", aspectRatio)
|
||||
.putInt("blending_accuracy", blendingLevel)
|
||||
.putBoolean("widescreen_patches", widescreenPatches)
|
||||
.putBoolean("no_interlacing_patches", noInterlacingPatches)
|
||||
.putBoolean("load_textures", loadTextures)
|
||||
.putBoolean("async_texture_loading", asyncTextureLoading)
|
||||
.putBoolean("hud_visible", hudVisible)
|
||||
.putBoolean("enable_cheats", enableCheatsGlobal)
|
||||
.apply();
|
||||
|
||||
// Apply in one batch to avoid repeated ApplySettings calls
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.izzy2lost.psx2;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.Gravity;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public class SetupWizardDialogFragment extends DialogFragment {
|
||||
public static SetupWizardDialogFragment newInstance() { return new SetupWizardDialogFragment(); }
|
||||
|
||||
private MaterialButton btnData;
|
||||
private MaterialButton btnGames;
|
||||
private MaterialButton btnBios;
|
||||
private MaterialButton btnDone;
|
||||
private TextView titleView;
|
||||
private TextView hintView;
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
|
||||
Dialog d = new Dialog(requireContext(), R.style.PSX2_FullScreenDialog);
|
||||
d.setContentView(buildContent());
|
||||
return d;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
try { ((MainActivity) requireActivity()).setSetupWizardActive(true); } catch (Throwable ignored) {}
|
||||
// Refresh state (in case a step completed while this dialog was covered by a picker)
|
||||
try { updateUi(); } catch (Throwable ignored) {}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDismiss(@NonNull android.content.DialogInterface dialog) {
|
||||
super.onDismiss(dialog);
|
||||
try { ((MainActivity) requireActivity()).setSetupWizardActive(false); } catch (Throwable ignored) {}
|
||||
}
|
||||
|
||||
private View buildContent() {
|
||||
final LinearLayout root = new LinearLayout(requireContext());
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
root.setGravity(Gravity.CENTER);
|
||||
int pad = (int)(24 * getResources().getDisplayMetrics().density);
|
||||
root.setPadding(pad, pad, pad, pad);
|
||||
root.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
titleView = new TextView(requireContext());
|
||||
titleView.setText("Welcome! Let's set up PSX2");
|
||||
titleView.setTextSize(22f);
|
||||
titleView.setGravity(Gravity.CENTER_HORIZONTAL);
|
||||
titleView.setTextAlignment(View.TEXT_ALIGNMENT_CENTER);
|
||||
root.addView(titleView);
|
||||
|
||||
// Subtitle removed; using inline hint near the Done button instead.
|
||||
|
||||
int btnHeight = (int)(48 * getResources().getDisplayMetrics().density);
|
||||
|
||||
btnData = new MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle);
|
||||
btnData.setText("1) Choose Data Folder");
|
||||
btnData.setMinimumHeight(btnHeight);
|
||||
btnData.setIconGravity(MaterialButton.ICON_GRAVITY_TEXT_START);
|
||||
btnData.setIconPadding((int)(8 * getResources().getDisplayMetrics().density));
|
||||
btnData.setOnClickListener(v -> {
|
||||
MainActivity a = (MainActivity) requireActivity();
|
||||
a.pickDataRootFolder();
|
||||
});
|
||||
LinearLayout.LayoutParams lp1 = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
lp1.topMargin = (int)(24 * getResources().getDisplayMetrics().density);
|
||||
btnData.setLayoutParams(lp1);
|
||||
root.addView(btnData);
|
||||
|
||||
btnGames = new MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle);
|
||||
btnGames.setText("2) Choose Games Folder");
|
||||
btnGames.setMinimumHeight(btnHeight);
|
||||
btnGames.setIconGravity(MaterialButton.ICON_GRAVITY_TEXT_START);
|
||||
btnGames.setIconPadding((int)(8 * getResources().getDisplayMetrics().density));
|
||||
btnGames.setOnClickListener(v -> {
|
||||
MainActivity a = (MainActivity) requireActivity();
|
||||
a.pickGamesFolder();
|
||||
});
|
||||
LinearLayout.LayoutParams lp2 = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
lp2.topMargin = (int)(12 * getResources().getDisplayMetrics().density);
|
||||
btnGames.setLayoutParams(lp2);
|
||||
root.addView(btnGames);
|
||||
|
||||
btnBios = new MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle);
|
||||
btnBios.setText("3) Import BIOS Files");
|
||||
btnBios.setMinimumHeight(btnHeight);
|
||||
btnBios.setIconGravity(MaterialButton.ICON_GRAVITY_TEXT_START);
|
||||
btnBios.setIconPadding((int)(8 * getResources().getDisplayMetrics().density));
|
||||
btnBios.setOnClickListener(v -> {
|
||||
// Reuse existing BIOS prompt flow
|
||||
MainActivity a = (MainActivity) requireActivity();
|
||||
a.showBiosPrompt();
|
||||
});
|
||||
LinearLayout.LayoutParams lp3 = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
lp3.topMargin = (int)(12 * getResources().getDisplayMetrics().density);
|
||||
btnBios.setLayoutParams(lp3);
|
||||
root.addView(btnBios);
|
||||
|
||||
btnDone = new MaterialButton(requireContext());
|
||||
btnDone.setText("Done");
|
||||
btnDone.setMinimumHeight(btnHeight);
|
||||
btnDone.setOnClickListener(v -> {
|
||||
if (isDataFolderPicked() && isGamesFolderPicked() && isBiosPresent()) {
|
||||
requireContext().getSharedPreferences("app_prefs", android.content.Context.MODE_PRIVATE)
|
||||
.edit().putBoolean("first_run_done", true).apply();
|
||||
try { ((MainActivity) requireActivity()).setSetupWizardActive(false); } catch (Throwable ignored) {}
|
||||
dismissAllowingStateLoss();
|
||||
}
|
||||
});
|
||||
LinearLayout.LayoutParams lp4 = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
lp4.topMargin = (int)(28 * getResources().getDisplayMetrics().density);
|
||||
btnDone.setLayoutParams(lp4);
|
||||
root.addView(btnDone);
|
||||
|
||||
// Inline hint below Done button
|
||||
hintView = new TextView(requireContext());
|
||||
hintView.setText("Complete all steps to finish.");
|
||||
hintView.setTextSize(14f);
|
||||
hintView.setGravity(Gravity.CENTER_HORIZONTAL);
|
||||
hintView.setTextAlignment(View.TEXT_ALIGNMENT_CENTER);
|
||||
hintView.setAlpha(0.8f);
|
||||
LinearLayout.LayoutParams hintLp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
hintLp.topMargin = (int)(8 * getResources().getDisplayMetrics().density);
|
||||
hintView.setLayoutParams(hintLp);
|
||||
root.addView(hintView);
|
||||
|
||||
// Initialize state
|
||||
updateUi();
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private boolean isDataFolderPicked() {
|
||||
return SafManager.getDataRootUri(requireContext()) != null;
|
||||
}
|
||||
|
||||
private boolean isGamesFolderPicked() {
|
||||
String s = requireContext().getSharedPreferences("app_prefs", android.content.Context.MODE_PRIVATE)
|
||||
.getString("games_folder_uri", null);
|
||||
return s != null && !s.isEmpty();
|
||||
}
|
||||
|
||||
private boolean isBiosPresent() {
|
||||
File biosDir = new File(requireContext().getExternalFilesDir(null), "bios");
|
||||
if (biosDir != null && biosDir.isDirectory()) {
|
||||
File[] fs = biosDir.listFiles();
|
||||
if (fs != null) {
|
||||
for (File f : fs) {
|
||||
if (f != null && f.isFile()) {
|
||||
String lower = f.getName().toLowerCase(java.util.Locale.ROOT);
|
||||
boolean isMainBios = lower.startsWith("scph") && (lower.endsWith(".bin") || lower.endsWith(".rom"));
|
||||
boolean isComponentSuffix = lower.endsWith(".rom0") || lower.endsWith(".rom1") || lower.endsWith(".rom2") || lower.endsWith(".erom");
|
||||
boolean isBareComponent = lower.equals("rom0") || lower.equals("rom1") || lower.equals("rom2") || lower.equals("erom");
|
||||
if ((isMainBios && f.length() >= 256 * 1024) || (isComponentSuffix || isBareComponent))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void updateUi() {
|
||||
boolean step1 = isDataFolderPicked();
|
||||
boolean step2 = isGamesFolderPicked();
|
||||
boolean step3 = isBiosPresent();
|
||||
|
||||
btnData.setText("1) Choose Data Folder");
|
||||
btnGames.setText("2) Choose Games Folder");
|
||||
btnBios.setText("3) Import BIOS Files");
|
||||
|
||||
btnData.setIcon(step1 ? ContextCompat.getDrawable(requireContext(), R.drawable.check_circle_24px) : null);
|
||||
btnGames.setIcon(step2 ? ContextCompat.getDrawable(requireContext(), R.drawable.check_circle_24px) : null);
|
||||
btnBios.setIcon(step3 ? ContextCompat.getDrawable(requireContext(), R.drawable.check_circle_24px) : null);
|
||||
|
||||
btnGames.setEnabled(step1);
|
||||
btnBios.setEnabled(step1 && step2);
|
||||
boolean doneEnabled = (step1 && step2 && step3);
|
||||
btnDone.setEnabled(doneEnabled);
|
||||
if (hintView != null) hintView.setVisibility(doneEnabled ? View.GONE : View.VISIBLE);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user