diff --git a/app/build.gradle b/app/build.gradle index 3d372a0..33ed52b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -115,6 +115,7 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'androidx.documentfile:documentfile:1.1.0' implementation 'androidx.recyclerview:recyclerview:1.4.0' + implementation 'androidx.viewpager2:viewpager2:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.activity:activity:1.12.1' implementation 'androidx.core:core-ktx:1.15.0' diff --git a/app/src/main/cpp/AndroidDeviceDetection.cpp b/app/src/main/cpp/AndroidDeviceDetection.cpp index 6fa45b3..7d1e763 100644 --- a/app/src/main/cpp/AndroidDeviceDetection.cpp +++ b/app/src/main/cpp/AndroidDeviceDetection.cpp @@ -41,6 +41,9 @@ namespace AndroidDeviceDetection std::string board = GetSystemProperty("ro.product.board"); std::string platform = GetSystemProperty("ro.board.platform"); + Console.WriteLn("Device Detection: hardware='%s', board='%s', platform='%s'", + hardware.c_str(), board.c_str(), platform.c_str()); + // Convert to lowercase for comparison auto toLower = [](std::string str) { for (char& c : str) c = std::tolower(c); @@ -65,6 +68,9 @@ namespace AndroidDeviceDetection std::string board = GetSystemProperty("ro.product.board"); std::string platform = GetSystemProperty("ro.board.platform"); + Console.WriteLn("Device Detection: hardware='%s', board='%s', platform='%s'", + hardware.c_str(), board.c_str(), platform.c_str()); + auto toLower = [](std::string str) { for (char& c : str) c = std::tolower(c); return str; @@ -99,13 +105,40 @@ namespace AndroidDeviceDetection // Check for other vendors via hardware string std::string hardware = GetSystemProperty("ro.hardware"); - if (hardware.find("exynos") != std::string::npos) + std::string manufacturer = GetSystemProperty("ro.product.manufacturer"); + + auto toLower = [](std::string str) { + for (char& c : str) c = std::tolower(c); + return str; + }; + + hardware = toLower(hardware); + manufacturer = toLower(manufacturer); + + // Samsung Exynos devices (Mali GPU) + if (hardware.find("exynos") != std::string::npos || + hardware.find("universal") != std::string::npos || + (manufacturer.find("samsung") != std::string::npos && hardware.find("samsungexynos") != std::string::npos)) { Console.WriteLn("Detected Samsung Exynos (Mali GPU)"); return GPUVendor::ARM; } - Console.WriteLn("Unknown GPU vendor, hardware: %s", hardware.c_str()); + // Kirin devices (Mali GPU) + if (hardware.find("kirin") != std::string::npos || hardware.find("hi") == 0) + { + Console.WriteLn("Detected HiSilicon Kirin (Mali GPU)"); + return GPUVendor::ARM; + } + + // Rockchip devices (Mali GPU) + if (hardware.find("rk") == 0 || hardware.find("rockchip") != std::string::npos) + { + Console.WriteLn("Detected Rockchip (Mali GPU)"); + return GPUVendor::ARM; + } + + Console.WriteLn("Unknown GPU vendor, hardware: %s, manufacturer: %s", hardware.c_str(), manufacturer.c_str()); return GPUVendor::Unknown; } } diff --git a/app/src/main/cpp/pcsx2/GS/Renderers/OpenGL/GLContextEGL.cpp b/app/src/main/cpp/pcsx2/GS/Renderers/OpenGL/GLContextEGL.cpp index cfa95fc..4ac928d 100644 --- a/app/src/main/cpp/pcsx2/GS/Renderers/OpenGL/GLContextEGL.cpp +++ b/app/src/main/cpp/pcsx2/GS/Renderers/OpenGL/GLContextEGL.cpp @@ -4,6 +4,27 @@ #include "GS/Renderers/OpenGL/GLContextEGL.h" #include "common/Console.h" +#ifdef __ANDROID__ +#include "AndroidDeviceDetection.h" +#endif +#include + +// NDK headers may lack ANGLE platform defines; provide fallbacks so we can request ANGLE. +#ifndef EGL_PLATFORM_ANGLE_ANGLE +#define EGL_PLATFORM_ANGLE_ANGLE 0x3202 +#endif +#ifndef EGL_PLATFORM_ANGLE_TYPE_ANGLE +#define EGL_PLATFORM_ANGLE_TYPE_ANGLE 0x3203 +#endif +#ifndef EGL_PLATFORM_ANGLE_TYPE_VULKAN_ANGLE +#define EGL_PLATFORM_ANGLE_TYPE_VULKAN_ANGLE 0x320F +#endif +#ifndef EGL_PLATFORM_ANGLE_DEVICE_TYPE_ANGLE +#define EGL_PLATFORM_ANGLE_DEVICE_TYPE_ANGLE 0x3209 +#endif +#ifndef EGL_PLATFORM_ANGLE_DEVICE_TYPE_HARDWARE_ANGLE +#define EGL_PLATFORM_ANGLE_DEVICE_TYPE_HARDWARE_ANGLE 0x320A +#endif #include #include @@ -43,12 +64,36 @@ bool GLContextEGL::Initialize(const Version* versions_to_try, size_t num_version return false; int egl_major, egl_minor; +retry_initialize: if (!eglInitialize(m_display, &egl_major, &egl_minor)) { +#ifdef __ANDROID__ + if (m_tried_angle_display && m_used_angle_display) + { + const EGLint error = eglGetError(); + Console.Warning("ANGLE: eglInitialize() failed with error 0x%x, retrying with native Mali driver", error); + m_used_angle_display = false; + m_display = eglGetDisplay(static_cast(m_wi.display_connection)); + if (m_display != EGL_NO_DISPLAY) + goto retry_initialize; + } +#endif Console.Error("eglInitialize() failed: %d", eglGetError()); return false; } Console.WriteLn("EGL Version: %d.%d", egl_major, egl_minor); + +#ifdef __ANDROID__ + if (m_used_angle_display) + { + Console.WriteLn("ANGLE: Successfully initialized ANGLE EGL display"); + Console.WriteLn("ANGLE: OpenGL renderer will use ANGLE (GL ES -> Vulkan translation)"); + } + else if (m_tried_angle_display) + { + Console.WriteLn("EGL: Using native Mali OpenGL ES driver (ANGLE not available)"); + } +#endif const char* extensions = eglQueryString(m_display, EGL_EXTENSIONS); if (extensions) @@ -67,7 +112,62 @@ bool GLContextEGL::Initialize(const Version* versions_to_try, size_t num_version bool GLContextEGL::SetDisplay() { - m_display = eglGetDisplay(static_cast(m_wi.display_connection)); +#ifdef __ANDROID__ + const AndroidDeviceDetection::GPUVendor vendor = AndroidDeviceDetection::DetectGPUVendor(); + const bool prefer_angle = (vendor == AndroidDeviceDetection::GPUVendor::ARM); + m_tried_angle_display = prefer_angle; + + Console.WriteLn("EGL: GPU Vendor detected: %d (ARM=%d), prefer_angle=%d", + static_cast(vendor), static_cast(AndroidDeviceDetection::GPUVendor::ARM), prefer_angle); + + if (prefer_angle) + { + Console.WriteLn("ANGLE: Mali GPU detected, attempting to use ANGLE with Vulkan backend"); + + auto get_platform_display = reinterpret_cast( + eglGetProcAddress("eglGetPlatformDisplayEXT")); + + if (get_platform_display) + { + Console.WriteLn("ANGLE: eglGetPlatformDisplayEXT found, requesting ANGLE display"); + + const EGLint attribs[] = { + EGL_PLATFORM_ANGLE_TYPE_ANGLE, EGL_PLATFORM_ANGLE_TYPE_VULKAN_ANGLE, + EGL_PLATFORM_ANGLE_DEVICE_TYPE_ANGLE, EGL_PLATFORM_ANGLE_DEVICE_TYPE_HARDWARE_ANGLE, + EGL_NONE, + }; + + m_display = get_platform_display(EGL_PLATFORM_ANGLE_ANGLE, EGL_DEFAULT_DISPLAY, attribs); + if (m_display != EGL_NO_DISPLAY) + { + Console.WriteLn("ANGLE: Successfully created ANGLE display with Vulkan backend for Mali GPU"); + Console.WriteLn("ANGLE: OpenGL ES calls will be translated to Vulkan"); + m_used_angle_display = true; + } + else + { + const EGLint error = eglGetError(); + Console.Warning("ANGLE: eglGetPlatformDisplayEXT failed with error 0x%x, falling back to default EGL", error); + } + } + else + { + Console.Warning("ANGLE: eglGetPlatformDisplayEXT not available (ANGLE libraries may not be loaded)"); + Console.Warning("ANGLE: Falling back to native Mali OpenGL ES driver"); + } + } + else + { + Console.WriteLn("EGL: Non-Mali GPU detected, using native OpenGL ES driver"); + } +#endif + + if (!m_display) + { + Console.WriteLn("EGL: Using default eglGetDisplay()"); + m_display = eglGetDisplay(static_cast(m_wi.display_connection)); + } + if (!m_display) { Console.Error("eglGetDisplay() failed: %d", eglGetError()); diff --git a/app/src/main/cpp/pcsx2/GS/Renderers/OpenGL/GLContextEGL.h b/app/src/main/cpp/pcsx2/GS/Renderers/OpenGL/GLContextEGL.h index bf7633e..7c16b59 100644 --- a/app/src/main/cpp/pcsx2/GS/Renderers/OpenGL/GLContextEGL.h +++ b/app/src/main/cpp/pcsx2/GS/Renderers/OpenGL/GLContextEGL.h @@ -48,4 +48,8 @@ protected: EGLConfig m_config = {}; bool m_supports_surfaceless = false; +#ifdef __ANDROID__ + bool m_tried_angle_display = false; + bool m_used_angle_display = false; +#endif }; diff --git a/app/src/main/cpp/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.cpp b/app/src/main/cpp/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.cpp index 576501b..fbe3ec0 100644 --- a/app/src/main/cpp/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.cpp +++ b/app/src/main/cpp/pcsx2/GS/Renderers/OpenGL/GSDeviceOGL.cpp @@ -648,6 +648,10 @@ bool GSDeviceOGL::CheckFeatures(bool& buggy_pbo) const std::string vendor_lower = to_lower(vendor_str); const std::string renderer_lower = to_lower(renderer_str); + // Detect Mali GPU + bool vendor_id_arm_mali = (vendor_lower.find("arm") != std::string::npos || + renderer_lower.find("mali") != std::string::npos); + if (vendor_lower.find("advanced micro devices") != std::string::npos || vendor_lower.find("ati technologies inc.") != std::string::npos || vendor_lower.find("ati") != std::string::npos) @@ -665,6 +669,10 @@ bool GSDeviceOGL::CheckFeatures(bool& buggy_pbo) Console.WriteLn(Color_StrongBlue, "GL: Intel GPU detected."); //vendor_id_intel = true; } + else if (vendor_id_arm_mali) + { + Console.WriteLn(Color_StrongYellow, "GL: ARM Mali GPU detected - applying workarounds"); + } GLint major_gl = 0; GLint minor_gl = 0; glGetIntegerv(GL_MAJOR_VERSION, &major_gl); @@ -746,12 +754,31 @@ bool GSDeviceOGL::CheckFeatures(bool& buggy_pbo) } else { buggy_pbo = !GLAD_GL_EXT_buffer_storage; } + + // Mali GPU workarounds - force PBO off and disable problematic features + if (vendor_id_arm_mali) + { + Console.WriteLn(Color_StrongYellow, "GL: Applying Mali GPU workarounds:"); + Console.WriteLn(" - Disabling framebuffer fetch"); + Console.WriteLn(" - Enabling texture barriers"); + Console.WriteLn(" - Disabling vertex shader expansion"); + Console.WriteLn(" - Disabling PBO for texture uploads"); + Console.WriteLn(" - Disabling PBO for texture downloads"); + Console.WriteLn(" - Disabling point expand"); + + buggy_pbo = true; + m_disable_download_pbo = true; + + Console.WriteLn("GL: Mali workarounds applied. Textures will use direct upload path."); + } + if (buggy_pbo) Console.Warning("GL: Not using PBOs for texture uploads because buffer_storage is unavailable."); // Give the user the option to disable PBO usage for downloads. // Most drivers seem to be faster with PBO. - m_disable_download_pbo = Host::GetBoolSettingValue("EmuCore/GS", "DisableGLDownloadPBO", false); + if (!vendor_id_arm_mali) + m_disable_download_pbo = Host::GetBoolSettingValue("EmuCore/GS", "DisableGLDownloadPBO", false); if (m_disable_download_pbo) Console.Warning("GL: Not using PBOs for texture downloads, this may reduce performance."); @@ -760,6 +787,14 @@ bool GSDeviceOGL::CheckFeatures(bool& buggy_pbo) m_features.primitive_id = true; m_features.framebuffer_fetch = GLAD_GL_EXT_shader_framebuffer_fetch; + + // Disable framebuffer fetch on Mali - causes 2D graphics issues + if (vendor_id_arm_mali && m_features.framebuffer_fetch) + { + Console.WriteLn("GL: Disabling framebuffer fetch on Mali GPU (causes rendering issues)"); + m_features.framebuffer_fetch = false; + } + if (m_features.framebuffer_fetch && GSConfig.DisableFramebufferFetch) { Host::AddOSDMessage( diff --git a/app/src/main/java/com/izzy2lost/psx2/NativeApp.java b/app/src/main/java/com/izzy2lost/psx2/NativeApp.java index 611da6d..9e48045 100644 --- a/app/src/main/java/com/izzy2lost/psx2/NativeApp.java +++ b/app/src/main/java/com/izzy2lost/psx2/NativeApp.java @@ -9,7 +9,20 @@ import java.io.File; import java.lang.ref.WeakReference; public class NativeApp { + private static boolean angleLoaded = false; + static { + try { + // Try shipping ANGLE (EGL/GLES-over-Vulkan). If missing, we'll fall back to system drivers. + System.loadLibrary("EGL_angle"); + System.loadLibrary("GLESv2_angle"); + angleLoaded = true; + android.util.Log.i("NativeApp", "ANGLE libraries loaded successfully (EGL_angle, GLESv2_angle)"); + } catch (UnsatisfiedLinkError e) { + // Optional: not all builds will bundle ANGLE. + android.util.Log.w("NativeApp", "ANGLE libraries not found, will use native OpenGL ES driver: " + e.getMessage()); + } + try { System.loadLibrary("emucore"); hasNoNativeBinary = false; @@ -17,6 +30,10 @@ public class NativeApp { hasNoNativeBinary = true; } } + + public static boolean isAngleLoaded() { + return angleLoaded; + } public static boolean hasNoNativeBinary; diff --git a/app/src/main/java/com/izzy2lost/psx2/SetupWizardDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/SetupWizardDialogFragment.java index 49bc0be..64ad765 100644 --- a/app/src/main/java/com/izzy2lost/psx2/SetupWizardDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/SetupWizardDialogFragment.java @@ -1,94 +1,93 @@ package com.izzy2lost.psx2; import android.app.Dialog; -import android.os.Build; +import android.content.Context; import android.os.Bundle; +import android.view.LayoutInflater; import android.view.View; -import android.view.Gravity; import android.view.ViewGroup; +import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import androidx.fragment.app.DialogFragment; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager2.widget.ViewPager2; import com.google.android.material.button.MaterialButton; -import androidx.core.content.ContextCompat; import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; 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; - private Runnable mPeriodicCheck; - private boolean mHasAutoAdvanced = false; - - // Method to detect if this is a lower-end device - private boolean isLowerEndDevice() { - try { - // Check available memory - Runtime runtime = Runtime.getRuntime(); - long maxMemory = runtime.maxMemory(); - long totalMemory = runtime.totalMemory(); - long freeMemory = runtime.freeMemory(); - long availableMemory = maxMemory - totalMemory + freeMemory; - - // Consider device lower-end if it has less than 512MB available memory - boolean lowMemory = availableMemory < 512 * 1024 * 1024; // 512MB - - // Check Android version - older versions might be on older hardware - boolean oldAndroid = Build.VERSION.SDK_INT < Build.VERSION_CODES.P; // Android 9+ - - // Check number of CPU cores - int cpuCores = runtime.availableProcessors(); - boolean fewCores = cpuCores <= 2; // 2 or fewer cores - - android.util.Log.d("SetupWizard", "Device specs - Available Memory: " + - (availableMemory / 1024 / 1024) + "MB, " + - "CPU Cores: " + cpuCores + - ", Android API: " + Build.VERSION.SDK_INT); - - return lowMemory || (oldAndroid && fewCores); - } catch (Exception e) { - android.util.Log.w("SetupWizard", "Error detecting device capabilities: " + e.getMessage()); - return false; // Assume not lower-end if we can't detect - } + private ViewPager2 pager; + private MaterialButton btnNext; + private LinearLayout indicatorContainer; + private TextView tvStep; + private TextView tvSubtitle; + private Runnable periodicCheck; + + private final List steps = Arrays.asList( + new SetupStep(StepType.DATA, R.drawable.data_table_24px, "Data folder", "Pick a writable PSX2 data folder for saves, states, and config.", "Choose data"), + new SetupStep(StepType.GAMES, R.drawable.stadia_controller_24px, "Games library", "Point PSX2 to your games folder so covers and sorting work.", "Choose games"), + new SetupStep(StepType.BIOS, R.drawable.memory_24px, "BIOS files", "Import your console BIOS so games can boot.", "Import BIOS") + ); + + private SetupPagerAdapter adapter; + + private enum StepType { + DATA, GAMES, BIOS } - - // Method to get appropriate timeout based on device capabilities - private long getTimeoutForDevice(long baseTimeout, long lowerEndTimeout) { - return isLowerEndDevice() ? lowerEndTimeout : baseTimeout; + + private static class SetupStep { + final StepType type; + final int iconRes; + final String title; + final String description; + final String ctaText; + + SetupStep(StepType t, int iconRes, String title, String description, String cta) { + this.type = t; + this.iconRes = iconRes; + this.title = title; + this.description = description; + this.ctaText = cta; + } } @NonNull @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - // Notify MainActivity that this dialog is opening try { if (getActivity() instanceof MainActivity) { ((MainActivity) getActivity()).onDialogOpened(); } } catch (Throwable ignored) {} - + Dialog d = new Dialog(requireContext(), R.style.PSX2_FullScreenDialog); - d.setContentView(buildContent()); - - // Resume game when dialog is dismissed + View content = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_setup_intro, null); + d.setContentView(content); + + bindViews(content); + setupPager(); + renderIndicators(0); + updateHeader(0); + updateNextButtonState(0); + d.setOnDismissListener(dialog -> { - android.util.Log.d("SetupWizardDialog", "Setup wizard dialog dismissed"); - // Use the global dialog tracking system if (getActivity() instanceof MainActivity) { ((MainActivity) getActivity()).onDialogClosed(); } }); - + return d; } @@ -96,21 +95,16 @@ public class SetupWizardDialogFragment extends DialogFragment { 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(); - // Auto-advance if all steps are complete - checkAndAutoAdvance(); - } catch (Throwable ignored) {} - - // Start periodic checking for BIOS files (in case onResume doesn't catch the import) + hideSystemUI(); + refreshAll(); startPeriodicCheck(); } - + @Override public void onPause() { super.onPause(); stopPeriodicCheck(); + showSystemUI(); } @Override @@ -119,123 +113,202 @@ public class SetupWizardDialogFragment extends DialogFragment { 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)); - // Use theme background - root.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.md_theme_surface)); + private void bindViews(View root) { + pager = root.findViewById(R.id.setup_pager); + btnNext = root.findViewById(R.id.btn_next); + indicatorContainer = root.findViewById(R.id.indicator_container); + tvStep = root.findViewById(R.id.tv_step); + tvSubtitle = root.findViewById(R.id.tv_subtitle); - titleView = new TextView(requireContext()); - titleView.setText("Welcome! Let's set up PSX2"); - titleView.setTextSize(26f); - titleView.setGravity(Gravity.CENTER_HORIZONTAL); - titleView.setTextAlignment(View.TEXT_ALIGNMENT_CENTER); - titleView.setTextColor(ContextCompat.getColor(requireContext(), R.color.md_theme_tertiaryFixedDim)); - titleView.setTypeface(titleView.getTypeface(), android.graphics.Typeface.BOLD); - root.addView(titleView); + btnNext.setOnClickListener(v -> handleNextClick()); + } - // Subtitle removed; using inline hint near the Done button instead. + private void setupPager() { + adapter = new SetupPagerAdapter(requireContext(), steps, new SetupPagerAdapter.StepListener() { + @Override + public boolean isComplete(StepType type) { + return isStepComplete(type); + } - 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(); + @Override + public void onAction(StepType type) { + triggerAction(type); + } }); - 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(); - MainActivity a = null; - try { a = (MainActivity) requireActivity(); a.setSetupWizardActive(false); } catch (Throwable ignored) {} - dismissAllowingStateLoss(); - if (a != null) { - // Use adaptive delay based on device capabilities - // Don't auto-open games dialog after setup wizard - // This prevents crashes when BIOS is still booting - // User can open it manually via home button when ready - android.util.Log.d("SetupWizard", "Setup complete - user can open games dialog via home button"); - } + pager.setAdapter(adapter); + pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + @Override + public void onPageSelected(int position) { + super.onPageSelected(position); + renderIndicators(position); + updateHeader(position); + updateNextButtonState(position); } }); - 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.setTextColor(ContextCompat.getColor(requireContext(), R.color.brand_primary)); - hintView.setAlpha(0.9f); - 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 void handleNextClick() { + int current = pager.getCurrentItem(); + boolean allDone = areAllStepsComplete(); + if (current < steps.size() - 1) { + pager.setCurrentItem(current + 1, true); + return; + } + if (allDone) { + completeAndDismiss(); + } else { + int target = firstIncompleteIndex(); + if (target >= 0) { + pager.setCurrentItem(target, true); + } + } } - 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 void triggerAction(StepType type) { + MainActivity a = (MainActivity) requireActivity(); + switch (type) { + case DATA -> a.pickDataRootFolder(); + case GAMES -> a.pickGamesFolder(); + case BIOS -> a.showBiosPrompt(); + } + } + + private void renderIndicators(int activeIndex) { + indicatorContainer.removeAllViews(); + int size = (int) (16 * getResources().getDisplayMetrics().density); + int margin = (int) (8 * getResources().getDisplayMetrics().density); + for (int i = 0; i < steps.size(); i++) { + View indicator = new View(requireContext()); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(size, size); + lp.setMargins(margin, 0, margin, 0); + indicator.setLayoutParams(lp); + indicator.setBackground(ContextCompat.getDrawable(requireContext(), + i == activeIndex ? R.drawable.setup_indicator_active : R.drawable.setup_indicator_inactive)); + indicatorContainer.addView(indicator); + } + } + + private void updateHeader(int position) { + String stepLabel = String.format(Locale.getDefault(), "Step %d of %d", position + 1, steps.size()); + tvStep.setText(stepLabel); + tvSubtitle.setText(steps.get(position).title); + } + + private void updateNextButtonState(int position) { + boolean allDone = areAllStepsComplete(); + boolean last = position == steps.size() - 1; + btnNext.setText(allDone ? "Start playing" : (last ? "Done" : "Next")); + btnNext.setEnabled(allDone || !last); + } + + private void refreshAll() { + if (adapter != null) adapter.notifyDataSetChanged(); + updateNextButtonState(pager != null ? pager.getCurrentItem() : 0); + if (areAllStepsComplete()) { + tryCompleteSoon(); + } + } + + private void startPeriodicCheck() { + stopPeriodicCheck(); + View root = getView(); + if (root != null) { + periodicCheck = new Runnable() { + @Override + public void run() { + try { + if (isAdded()) { + refreshAll(); + if (getView() != null && !areAllStepsComplete()) { + getView().postDelayed(this, 800); + } + } + } catch (Throwable ignored) {} + } + }; + root.postDelayed(periodicCheck, 800); + } + } + + private void stopPeriodicCheck() { + View root = getView(); + if (root != null && periodicCheck != null) { + root.removeCallbacks(periodicCheck); + } + periodicCheck = null; + } + + private void completeAndDismiss() { + try { + requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + .edit().putBoolean("first_run_done", true).apply(); + MainActivity a = (MainActivity) requireActivity(); + a.setSetupWizardActive(false); + } catch (Throwable ignored) {} + dismissAllowingStateLoss(); + } + + private void tryCompleteSoon() { + View decor = getDialog() != null && getDialog().getWindow() != null ? getDialog().getWindow().getDecorView() : null; + if (decor != null) { + decor.postDelayed(this::completeAndDismiss, 1200); + } + } + + public void refreshUi() { + try { + refreshAll(); + } catch (Throwable ignored) {} + } + + private void hideSystemUI() { + try { + if (getDialog() != null && getDialog().getWindow() != null) { + View decorView = getDialog().getWindow().getDecorView(); + decorView.setSystemUiVisibility( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_FULLSCREEN); + } + } catch (Throwable ignored) {} + } + + private void showSystemUI() { + try { + if (getDialog() != null && getDialog().getWindow() != null) { + View decorView = getDialog().getWindow().getDecorView(); + decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + } + } catch (Throwable ignored) {} + } + + private boolean isStepComplete(StepType type) { + return switch (type) { + case DATA -> SafManager.getDataRootUri(requireContext()) != null; + case GAMES -> { + String s = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + .getString("games_folder_uri", null); + yield s != null && !s.isEmpty(); + } + case BIOS -> isBiosPresent(); + }; + } + + private boolean areAllStepsComplete() { + for (SetupStep step : steps) { + if (!isStepComplete(step.type)) return false; + } + return true; + } + + private int firstIncompleteIndex() { + for (int i = 0; i < steps.size(); i++) { + if (!isStepComplete(steps.get(i).type)) return i; + } + return -1; } private boolean isBiosPresent() { @@ -245,20 +318,17 @@ public class SetupWizardDialogFragment extends DialogFragment { if (fs != null) { for (File f : fs) { if (f != null && f.isFile()) { - String lower = f.getName().toLowerCase(java.util.Locale.ROOT); - - // Check for component ROM files (these have specific names) - 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"); - + String lower = f.getName().toLowerCase(Locale.ROOT); + + 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 (isComponentSuffix || isBareComponent) { return true; } - - // Accept any .bin or .rom file that's at least 256KB (likely a BIOS) - // This covers renamed files and all regional variants + if ((lower.endsWith(".bin") || lower.endsWith(".rom")) && f.length() >= 256 * 1024) { return true; } @@ -269,123 +339,75 @@ public class SetupWizardDialogFragment extends DialogFragment { 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); - - // Add theme accent to completed buttons - int themeAccent = ContextCompat.getColor(requireContext(), R.color.md_theme_tertiary); - int defaultColor = ContextCompat.getColor(requireContext(), R.color.md_theme_onSurface); - - btnData.setIconTint(step1 ? android.content.res.ColorStateList.valueOf(themeAccent) : null); - btnGames.setIconTint(step2 ? android.content.res.ColorStateList.valueOf(themeAccent) : null); - btnBios.setIconTint(step3 ? android.content.res.ColorStateList.valueOf(themeAccent) : null); - - btnGames.setEnabled(step1); - btnBios.setEnabled(step1 && step2); - boolean doneEnabled = (step1 && step2 && step3); - btnDone.setEnabled(doneEnabled); - - // Style the Done button when ready - if (doneEnabled) { - btnDone.setBackgroundTintList(android.content.res.ColorStateList.valueOf(themeAccent)); - btnDone.setTextColor(ContextCompat.getColor(requireContext(), R.color.md_theme_onTertiary)); - if (hintView != null) { - hintView.setText("🎉 Ready to go! Tap Done to start."); - hintView.setTextColor(ContextCompat.getColor(requireContext(), R.color.md_theme_tertiaryFixedDim)); - } - } else { - btnDone.setBackgroundTintList(null); - btnDone.setTextColor(defaultColor); - if (hintView != null) { - hintView.setText("Complete all steps to finish."); - hintView.setTextColor(ContextCompat.getColor(requireContext(), R.color.brand_primary)); - } + private static class SetupPagerAdapter extends RecyclerView.Adapter { + interface StepListener { + boolean isComplete(StepType type); + void onAction(StepType type); } - - if (hintView != null) hintView.setVisibility(View.VISIBLE); - } + private final Context ctx; + private final List steps; + private final StepListener listener; - private void startPeriodicCheck() { - stopPeriodicCheck(); - View root = getView(); - if (root != null) { - mPeriodicCheck = new Runnable() { - @Override - public void run() { - try { - if (isAdded() && !mHasAutoAdvanced) { - updateUi(); - checkAndAutoAdvance(); - // Keep checking every 500ms until auto-advance happens - if (getView() != null && !mHasAutoAdvanced) { - getView().postDelayed(this, 500); - } - } - } catch (Throwable ignored) {} - } - }; - root.postDelayed(mPeriodicCheck, 500); + SetupPagerAdapter(Context ctx, List steps, StepListener listener) { + this.ctx = ctx; + this.steps = new ArrayList<>(steps); + this.listener = listener; } - } - - private void stopPeriodicCheck() { - View root = getView(); - if (root != null && mPeriodicCheck != null) { - root.removeCallbacks(mPeriodicCheck); - } - mPeriodicCheck = null; - } - - // Public method to manually refresh UI (can be called from MainActivity after BIOS import) - public void refreshUi() { - try { - updateUi(); - checkAndAutoAdvance(); - } catch (Throwable ignored) {} - } - private void checkAndAutoAdvance() { - if (mHasAutoAdvanced) return; // Prevent multiple auto-advances - - if (isDataFolderPicked() && isGamesFolderPicked() && isBiosPresent()) { - mHasAutoAdvanced = true; - stopPeriodicCheck(); - - // Use adaptive timeouts based on device capabilities - long autoAdvanceDelay = getTimeoutForDevice(2000, 4000); // 2s normal, 4s lower-end - long gamesDialogDelay = getTimeoutForDevice(3000, 6000); // 3s normal, 6s lower-end - - android.util.Log.d("SetupWizard", "Using timeouts - Auto-advance: " + autoAdvanceDelay + - "ms, Games dialog: " + gamesDialogDelay + "ms"); - - // All steps complete, auto-advance with adaptive delay - View decor = getDialog() != null && getDialog().getWindow() != null ? getDialog().getWindow().getDecorView() : null; - if (decor != null) { - decor.postDelayed(() -> { - try { - requireContext().getSharedPreferences("app_prefs", android.content.Context.MODE_PRIVATE) - .edit().putBoolean("first_run_done", true).apply(); - MainActivity a = (MainActivity) requireActivity(); - a.setSetupWizardActive(false); - dismissAllowingStateLoss(); - // Don't auto-open games dialog after setup wizard - // This prevents crashes when BIOS is still booting - // User can open it manually via home button when ready - } catch (Throwable ignored) {} - }, autoAdvanceDelay); + @NonNull + @Override + public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View v = LayoutInflater.from(ctx).inflate(R.layout.item_setup_intro_page, parent, false); + return new VH(v); + } + + @Override + public void onBindViewHolder(@NonNull VH holder, int position) { + SetupStep step = steps.get(position); + holder.title.setText(step.title); + holder.description.setText(step.description); + holder.stepChip.setText(step.type.name()); + holder.action.setText(step.ctaText); + holder.icon.setImageResource(step.iconRes); + + boolean complete = listener.isComplete(step.type); + int completeBg = ContextCompat.getColor(ctx, R.color.md_theme_primary); + int pendingBg = ContextCompat.getColor(ctx, R.color.md_theme_outlineVariant); + int completeFg = ContextCompat.getColor(ctx, R.color.md_theme_onPrimary); + int pendingFg = ContextCompat.getColor(ctx, R.color.md_theme_onSurface); + + holder.status.setText(complete ? "Complete" : "Pending"); + holder.status.setBackgroundTintList(android.content.res.ColorStateList.valueOf(complete ? completeBg : pendingBg)); + holder.status.setTextColor(complete ? completeFg : pendingFg); + holder.action.setIcon(ContextCompat.getDrawable(ctx, complete ? R.drawable.check_circle_24px : step.iconRes)); + holder.action.setIconTint(android.content.res.ColorStateList.valueOf(0xFF000000)); + holder.action.setEnabled(true); + holder.action.setOnClickListener(v -> listener.onAction(step.type)); + } + + @Override + public int getItemCount() { + return steps.size(); + } + + static class VH extends RecyclerView.ViewHolder { + final TextView title; + final TextView description; + final TextView stepChip; + final TextView status; + final MaterialButton action; + final ImageView icon; + + VH(@NonNull View itemView) { + super(itemView); + title = itemView.findViewById(R.id.tv_title); + description = itemView.findViewById(R.id.tv_description); + stepChip = itemView.findViewById(R.id.tv_step_chip); + status = itemView.findViewById(R.id.tv_status); + action = itemView.findViewById(R.id.btn_action); + icon = itemView.findViewById(R.id.iv_icon); } } } } - diff --git a/app/src/main/jniLibs/arm64-v8a/libEGL_angle.so b/app/src/main/jniLibs/arm64-v8a/libEGL_angle.so new file mode 100644 index 0000000..ab1d8b7 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libEGL_angle.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libGLESv2_angle.so b/app/src/main/jniLibs/arm64-v8a/libGLESv2_angle.so new file mode 100644 index 0000000..cd9ad6b Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libGLESv2_angle.so differ diff --git a/app/src/main/res/drawable/bg_setup_intro_header.xml b/app/src/main/res/drawable/bg_setup_intro_header.xml new file mode 100644 index 0000000..1e6ae70 --- /dev/null +++ b/app/src/main/res/drawable/bg_setup_intro_header.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_status_badge.xml b/app/src/main/res/drawable/bg_status_badge.xml new file mode 100644 index 0000000..068dde4 --- /dev/null +++ b/app/src/main/res/drawable/bg_status_badge.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/data_table_24px.xml b/app/src/main/res/drawable/data_table_24px.xml new file mode 100644 index 0000000..19e6230 --- /dev/null +++ b/app/src/main/res/drawable/data_table_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/memory_24px.xml b/app/src/main/res/drawable/memory_24px.xml new file mode 100644 index 0000000..366d8a5 --- /dev/null +++ b/app/src/main/res/drawable/memory_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/setup_indicator_active.xml b/app/src/main/res/drawable/setup_indicator_active.xml new file mode 100644 index 0000000..950b070 --- /dev/null +++ b/app/src/main/res/drawable/setup_indicator_active.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/setup_indicator_inactive.xml b/app/src/main/res/drawable/setup_indicator_inactive.xml new file mode 100644 index 0000000..fe67359 --- /dev/null +++ b/app/src/main/res/drawable/setup_indicator_inactive.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/dialog_setup_intro.xml b/app/src/main/res/layout/dialog_setup_intro.xml new file mode 100644 index 0000000..a0fc8f4 --- /dev/null +++ b/app/src/main/res/layout/dialog_setup_intro.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_setup_intro_page.xml b/app/src/main/res/layout/item_setup_intro_page.xml new file mode 100644 index 0000000..bd111b9 --- /dev/null +++ b/app/src/main/res/layout/item_setup_intro_page.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + +