diff --git a/.gitignore b/.gitignore index 4f596ce..2ce0f6d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ app/.cxx/ *.exe /PCSX2_ARM64 build/reports/problems/problems-report.html +crash.txt diff --git a/_tmp_lines.txt b/_tmp_lines.txt new file mode 100644 index 0000000..9b900ce --- /dev/null +++ b/_tmp_lines.txt @@ -0,0 +1,422 @@ + 0: package com.izzy2lost.psx2; + 1: + 2: import android.app.Dialog; + 3: import android.content.Context; + 4: import android.net.Uri; + 5: import android.content.SharedPreferences; + 6: import android.os.Bundle; + 7: import android.view.LayoutInflater; + 8: import android.view.View; + 9: import android.view.ViewGroup; + 10: import android.view.Window; + 11: import android.view.WindowManager; + 12: import android.widget.Toast; + 13: import android.os.Build; + 14: import android.view.WindowInsets; + 15: import android.view.WindowInsetsController; + 16: + 17: import androidx.annotation.NonNull; + 18: import androidx.annotation.Nullable; + 19: import androidx.appcompat.app.AlertDialog; + 20: // Import MaterialAlertDialogBuilder for Material 3 styling + 21: import com.google.android.material.dialog.MaterialAlertDialogBuilder; + 22: import androidx.fragment.app.DialogFragment; + 23: import androidx.recyclerview.widget.GridLayoutManager; + 24: import androidx.recyclerview.widget.RecyclerView; + 25: import java.util.Locale; + 26: + 27: public class GamesCoverDialogFragment extends DialogFragment { + 28: private CoversAdapter adapter; + 29: private String[] titles; + 30: private String[] uris; + 31: private String[] coverUrls; + 32: private String[] localPaths; + 33: private RecyclerView rv; + 34: private GridLayoutManager glm; + 35: + 36: public interface OnGameSelectedListener { + 37: void onGameSelected(String gameUri); + 38: } + 39: + 40: private static final String ARG_TITLES = "titles"; + 41: private static final String ARG_URIS = "uris"; + 42: + 43: public static GamesCoverDialogFragment newInstance(String[] titles, String[] uris) { + 44: GamesCoverDialogFragment f = new GamesCoverDialogFragment(); + 45: Bundle b = new Bundle(); + 46: b.putStringArray(ARG_TITLES, titles); + 47: b.putStringArray(ARG_URIS, uris); + 48: f.setArguments(b); + 49: return f; + 50: } + 51: + 52: private OnGameSelectedListener listener; + 53: + 54: @Override + 55: public void onAttach(@NonNull Context context) { + 56: super.onAttach(context); + 57: if (context instanceof OnGameSelectedListener) { + 58: listener = (OnGameSelectedListener) context; + 59: } + 60: } + 61: + 62: @Override + 63: public void onResume() { + 64: super.onResume(); + 65: // Re-assert fixed span (2/4) after resume to avoid any flips + 66: if (rv != null && glm != null) { + 67: int currentOrientation = getResources().getConfiguration().orientation; + 68: int fixedSpan = (currentOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) ? 4 : 2; + 69: if (fixedSpan != glm.getSpanCount()) { + 70: glm.setSpanCount(fixedSpan); + 71: } + 72: } + 73: } + 74: + 75: @NonNull + 76: @Override + 77: public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + 78: LayoutInflater inflater = getLayoutInflater(); + 79: View root = inflater.inflate(R.layout.dialog_covers_grid, null, false); + 80: + 81: rv = root.findViewById(R.id.recycler_covers); + 82: rv.setHasFixedSize(true); + 83: glm = new GridLayoutManager(requireContext(), 3); + 84: rv.setLayoutManager(glm); + 85: // spacing decoration (8dp) using half on each side so the gap between items is exactly spacingPx + 86: final int spacingPx = (int) (8 * getResources().getDisplayMetrics().density); + 87: final int half = Math.max(1, spacingPx / 2); + 88: rv.addItemDecoration(new RecyclerView.ItemDecoration() { + 89: @Override + 90: public void getItemOffsets(android.graphics.Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + 91: outRect.set(half, half, half, half); + 92: } + 93: }); + 94: // Hard lock spans based on orientation only + 95: rv.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + 96: int currentOrientation = getResources().getConfiguration().orientation; + 97: int fixedSpan = (currentOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) ? 4 : 2; + 98: if (fixedSpan != glm.getSpanCount()) { glm.setSpanCount(fixedSpan); } + 99: }); + 100: // Set initial fixed span as soon as possible + 101: root.post(() -> { + 102: int currentOrientation = getResources().getConfiguration().orientation; + 103: int fixedSpan = (currentOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) ? 4 : 2; + 104: if (fixedSpan != glm.getSpanCount()) { glm.setSpanCount(fixedSpan); } + 105: }); + 106: + 107: titles = getArguments() != null ? getArguments().getStringArray(ARG_TITLES) : new String[0]; + 108: uris = getArguments() != null ? getArguments().getStringArray(ARG_URIS) : new String[0]; + 109: coverUrls = new String[uris.length]; + 110: localPaths = new String[uris.length]; + 111: SharedPreferences prefs = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE); + 112: for (int i = 0; i < uris.length; i++) { + 113: String saved = prefs.getString("serial:" + uris[i], null); + 114: String serial = saved; + 115: if (serial == null || serial.isEmpty()) { + 116: // Ask native core for the real serial (supports ISO/CHD and content://) + 117: try { + 118: String nativeSerial = NativeApp.getGameSerial(uris[i]); + 119: if (nativeSerial != null && !nativeSerial.isEmpty()) { + 120: serial = normalizeSerial(nativeSerial); + 121: prefs.edit().putString("serial:" + uris[i], serial).apply(); + 122: } + 123: } catch (Throwable ignored) {} + 124: } + 125: if (serial == null || serial.isEmpty()) { + 126: // Heuristic fallback from filename + 127: serial = buildSerialFromUri(uris[i]); + 128: } + 129: coverUrls[i] = buildCoverUrlFromSerial(serial); + 130: localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath(); + 131: } + 132: + 133: adapter = new CoversAdapter(requireContext(), titles, coverUrls, localPaths, + 134: position -> { + 135: // Regular click - start game + 136: if (listener != null && position >= 0 && position < uris.length) { + 137: listener.onGameSelected(uris[position]); + 138: dismissAllowingStateLoss(); + 139: } + 140: }, + 141: position -> { + 142: // Long click - show game settings + 143: if (position >= 0 && position < uris.length) { + 144: showGameSettings(titles[position], uris[position]); + 145: } + 146: }); + 147: rv.setAdapter(adapter); + 148: + 149: // Toolbar buttons + 150: View btnHome = root.findViewById(R.id.btn_home); + 151: if (btnHome != null) btnHome.setOnClickListener(v -> dismissAllowingStateLoss()); + 152: View btnDownload = root.findViewById(R.id.btn_download); + 153: if (btnDownload != null) btnDownload.setOnClickListener(v -> startDownloadCovers()); + 154: + 155: AlertDialog dialog = new MaterialAlertDialogBuilder(requireContext(), + 156: com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog) + 157: .setView(root) + 158: .create(); + 159: return dialog; + 160: } + 161: + 162: @Override + 163: public void onStart() { + 164: super.onStart(); + 165: Dialog d = getDialog(); + 166: if (d == null) return; + 167: Window w = d.getWindow(); + 168: if (w == null) return; + 169: // Make dialog fullscreen + 170: w.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + 171: // Enter immersive fullscreen to avoid showing system navigation buttons + 172: if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { + 173: w.setDecorFitsSystemWindows(false); + 174: WindowInsetsController c = w.getInsetsController(); + 175: if (c != null) { + 176: c.hide(WindowInsets.Type.systemBars()); + 177: c.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + 178: } + 179: } else { + 180: View decor = w.getDecorView(); + 181: int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE + 182: | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + 183: | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + 184: | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + 185: | View.SYSTEM_UI_FLAG_FULLSCREEN + 186: | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + 187: decor.setSystemUiVisibility(flags); + 188: w.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + 189: } + 190: } + 191: + 192: @Override + 193: public void onStart() { + 194: super.onStart(); + 195: Dialog d = getDialog(); + 196: if (d != null) { + 197: Window w = d.getWindow(); + 198: if (w != null) { + 199: w.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + 200: w.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + 201: // Hide status bar for true full-screen dialog + 202: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + 203: w.setDecorFitsSystemWindows(false); + 204: WindowInsetsController controller = w.getInsetsController(); + 205: if (controller != null) { + 206: controller.hide(WindowInsets.Type.statusBars()); + 207: controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + 208: } + 209: } else { + 210: w.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + 211: } + 212: } + 213: } + 214: } + 215: + 216: private int calculateSpanForWidth(int rvWidthPx, int itemDp, int spacingPx) { + 217: float density = getResources().getDisplayMetrics().density; + 218: int usable = Math.max(0, rvWidthPx); + 219: int itemPx = (int) (itemDp * density); + 220: // Include spacing in the packing calculation to avoid oscillation + 221: // span = floor((usable + spacing) / (itemPx + spacing)) + 222: int span = (itemPx > 0) ? (int) Math.floor((usable + (double) spacingPx) / (itemPx + (double) spacingPx)) : 1; + 223: return Math.max(2, Math.max(1, span)); + 224: } + 225: + 226: private void preloadCovers(String[] urls) { + 227: // Use Glide to warm cache + 228: for (String url : urls) { + 229: if (url == null) continue; + 230: com.bumptech.glide.Glide.with(requireContext()).load(url).preload(); + 231: } + 232: } + 233: + 234: private void startDownloadCovers() { + 235: Toast.makeText(requireContext(), "Downloading covers in background", Toast.LENGTH_SHORT).show(); + 236: new Thread(() -> { + 237: // Try to refine serials/URLs by scanning disc contents first + 238: SharedPreferences prefs = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE); + 239: SharedPreferences.Editor editor = prefs.edit(); + 240: for (int i = 0; i < uris.length; i++) { + 241: try { + 242: // Prefer native serial extraction so CHDs work + 243: String better = null; + 244: try { better = NativeApp.getGameSerial(uris[i]); } catch (Throwable ignored) {} + 245: if (better == null) better = extractSerialFromUri(uris[i]); + 246: if (better != null && !better.equalsIgnoreCase(serialFromUrl(coverUrls[i]))) { + 247: String serial = normalizeSerial(better); + 248: coverUrls[i] = buildCoverUrlFromSerial(serial); + 249: localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath(); + 250: editor.putString("serial:" + uris[i], serial); + 251: } + 252: } catch (Exception ignored) { } + 253: } + 254: editor.apply(); + 255: + 256: int total = coverUrls.length; + 257: int ok = 0; + 258: java.io.File dir = getCoversDir(); + 259: if (!dir.exists()) dir.mkdirs(); + 260: for (int i = 0; i < total; i++) { + 261: String url = coverUrls[i]; + 262: String outPath = localPaths[i]; + 263: if (isFileValid(outPath)) { ok++; continue; } + 264: try { + 265: if (downloadToFile(url, outPath)) ok++; + 266: } catch (Exception ignored) { } + 267: } + 268: final int downloaded = ok; + 269: if (isAdded()) requireActivity().runOnUiThread(() -> { + 270: Toast.makeText(requireContext(), "Covers ready: " + downloaded + "/" + total, Toast.LENGTH_SHORT).show(); + 271: // refresh adapter to prefer local files now + 272: if (adapter != null) adapter.notifyDataSetChanged(); + 273: }); + 274: }).start(); + 275: } + 276: + 277: private static String serialFromUrl(String url) { + 278: if (url == null) return null; + 279: int slash = url.lastIndexOf('/'); + 280: int dot = url.lastIndexOf('.'); + 281: if (slash >= 0 && dot > slash) return url.substring(slash + 1, dot); + 282: return null; + 283: } + 284: + 285: private java.io.File getCoversDir() { + 286: java.io.File base = requireContext().getExternalFilesDir("covers"); + 287: if (base == null) base = new java.io.File(requireContext().getFilesDir(), "covers"); + 288: return base; + 289: } + 290: + 291: private static boolean isFileValid(String path) { + 292: if (path == null) return false; + 293: java.io.File f = new java.io.File(path); + 294: return f.exists() && f.length() > 0; + 295: } + 296: + 297: private static boolean downloadToFile(String urlStr, String outPath) throws Exception { + 298: java.net.URL url = new java.net.URL(urlStr); + 299: java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection(); + 300: conn.setConnectTimeout(10000); + 301: conn.setReadTimeout(15000); + 302: conn.setInstanceFollowRedirects(true); + 303: conn.connect(); + 304: int code = conn.getResponseCode(); + 305: if (code != 200) { conn.disconnect(); return false; } + 306: java.io.File outFile = new java.io.File(outPath); + 307: java.io.File parent = outFile.getParentFile(); + 308: if (parent != null && !parent.exists()) parent.mkdirs(); + 309: java.io.InputStream in = conn.getInputStream(); + 310: java.io.FileOutputStream fos = new java.io.FileOutputStream(outFile); + 311: byte[] buf = new byte[8192]; + 312: int n; + 313: while ((n = in.read(buf)) != -1) fos.write(buf, 0, n); + 314: fos.flush(); + 315: fos.close(); + 316: in.close(); + 317: conn.disconnect(); + 318: return true; + 319: } + 320: + 321: private static String buildSerialFromUri(String gameUri) { + 322: // Try to infer PS2 serial from file name: e.g., SLUS-20312 or SLPS_123.45 style + 323: String last = Uri.parse(gameUri).getLastPathSegment(); + 324: if (last == null) last = ""; + 325: last = last.replace('_', '-'); + 326: // remove extension + 327: int dot = last.lastIndexOf('.'); + 328: if (dot > 0) last = last.substring(0, dot); + 329: String serial = null; + 330: // Very simple heuristic: find token like XXXX-XXXXX + 331: String upper = last.toUpperCase(Locale.ROOT); + 332: java.util.regex.Matcher m = java.util.regex.Pattern.compile("([A-Z]{4,5}-[0-9]{3,5})").matcher(upper); + 333: if (m.find()) { + 334: serial = m.group(1); + 335: } + 336: if (serial == null) { + 337: serial = upper; + 338: } + 339: return serial; + 340: } + 341: + 342: private static String buildCoverUrlFromSerial(String serial) { + 343: return "https://raw.githubusercontent.com/izzy2lost/ps2-covers/main/covers/3d/" + serial + ".png"; + 344: } + 345: + 346: private String extractSerialFromUri(String gameUri) { + 347: try { + 348: java.io.InputStream in = requireContext().getContentResolver().openInputStream(Uri.parse(gameUri)); + 349: if (in == null) return null; + 350: // Read first 8MB searching for SYSTEM.CNF contents, e.g., "BOOT2 = cdrom0:\\SLUS_203.12;1" + 351: final int MAX_BYTES = 8 * 1024 * 1024; + 352: final byte[] buf = new byte[64 * 1024]; + 353: int read; + 354: int total = 0; + 355: StringBuilder sb = new StringBuilder(); + 356: while ((read = in.read(buf)) != -1 && total < MAX_BYTES) { + 357: total += read; + 358: // append as ASCII + 359: sb.append(new String(buf, 0, read)); + 360: // try to match as we go to avoid huge strings + 361: String found = findSerialInString(sb); + 362: if (found != null) { in.close(); return found; } + 363: if (sb.length() > 512 * 1024) sb.delete(0, sb.length() - 128 * 1024); // keep window + 364: } + 365: in.close(); + 366: } catch (Exception ignored) { } + 367: return null; + 368: } + 369: + 370: private static String findSerialInString(CharSequence cs) { + 371: // Match common forms: SLUS_203.12, SLPM_650.51, SCES_123.45 etc. + 372: java.util.regex.Matcher m = java.util.regex.Pattern + 373: .compile("([A-Z]{4,5})[_-]([0-9]{3})\\.([0-9]{2})") + 374: .matcher(cs); + 375: if (m.find()) { + 376: String prefix = m.group(1); + 377: String part1 = m.group(2); + 378: String part2 = m.group(3); + 379: return prefix + "-" + part1 + part2; // SLUS-20312 + 380: } + 381: return null; + 382: } + 383: + 384: private static String normalizeSerial(String serial) { + 385: if (serial == null) return null; + 386: String s = serial.toUpperCase(Locale.ROOT).replace('_', '-'); + 387: // If form like XXXX-123.45 -> XXXX-12345 + 388: s = s.replaceAll("([A-Z]{4,5})-([0-9]{3})\\.([0-9]{2})", "$1-$2$3"); + 389: return s; + 390: } + 391: + 392: private void showGameSettings(String gameTitle, String gameUri) { + 393: // Prefer native extraction so CHDs work + 394: String gameSerial = null; + 395: try { gameSerial = NativeApp.getGameSerial(gameUri); } catch (Throwable ignored) {} + 396: if (gameSerial == null || gameSerial.isEmpty()) { + 397: gameSerial = extractSerialFromUri(gameUri); + 398: } + 399: if (gameSerial == null || gameSerial.isEmpty()) { + 400: gameSerial = buildSerialFromUri(gameUri); + 401: } + 402: gameSerial = normalizeSerial(gameSerial); + 403: + 404: // CRC (native if available) + 405: String gameCrc = null; + 406: try { gameCrc = NativeApp.getGameCrc(gameUri); } catch (Throwable ignored) {} + 407: if (gameCrc == null || gameCrc.isEmpty()) { + 408: gameCrc = String.format("%08X", Math.abs(gameUri.hashCode())); + 409: } + 410: + 411: // Debug logging + 412: android.util.Log.d("GameSettings", "Opening game settings for: " + gameTitle); + 413: android.util.Log.d("GameSettings", "URI: " + gameUri); + 414: android.util.Log.d("GameSettings", "Extracted Serial: " + gameSerial); + 415: android.util.Log.d("GameSettings", "Generated CRC: " + gameCrc); + 416: + 417: GameSettingsDialogFragment dialog = GameSettingsDialogFragment.newInstance( + 418: gameTitle, gameUri, gameSerial, gameCrc); + 419: dialog.show(getParentFragmentManager(), "game_settings"); + 420: } + 421: } diff --git a/app/src/main/java/com/izzy2lost/psx2/ControllerConfig.java b/app/src/main/java/com/izzy2lost/psx2/ControllerConfig.java index b7647cd..ec062d0 100644 --- a/app/src/main/java/com/izzy2lost/psx2/ControllerConfig.java +++ b/app/src/main/java/com/izzy2lost/psx2/ControllerConfig.java @@ -5,6 +5,7 @@ import android.view.InputDevice; import android.util.Log; import java.util.ArrayList; import java.util.List; +import java.util.Locale; /** * Controller configuration and detection utility @@ -43,7 +44,7 @@ public class ControllerConfig { * Get controller type description */ private static String getControllerType(InputDevice device) { - String name = device.getName().toLowerCase(); + String name = device.getName().toLowerCase(Locale.ROOT); if (name.contains("xbox")) { return "Xbox Controller"; diff --git a/app/src/main/java/com/izzy2lost/psx2/ControllerInputHandler.java b/app/src/main/java/com/izzy2lost/psx2/ControllerInputHandler.java index 96db586..0c0efbe 100644 --- a/app/src/main/java/com/izzy2lost/psx2/ControllerInputHandler.java +++ b/app/src/main/java/com/izzy2lost/psx2/ControllerInputHandler.java @@ -5,6 +5,7 @@ import android.view.KeyEvent; import android.view.MotionEvent; import android.util.Log; import android.util.SparseArray; +import android.util.SparseIntArray; /** * Controller input handler based on AetherSX2's PAD implementation @@ -50,10 +51,10 @@ public class ControllerInputHandler { private static final long COMBO_TIMEOUT_MS = 500; // 500ms window for combo // Key mapping from Android KeyEvent to PS2 buttons - private static final SparseArray sKeyMapping = new SparseArray<>(); + private static final SparseIntArray sKeyMapping = new SparseIntArray(); // Motion axis mapping from Android MotionEvent to PS2 analog inputs - private static final SparseArray sAxisMapping = new SparseArray<>(); + private static final SparseIntArray sAxisMapping = new SparseIntArray(); static { // Initialize key mappings (Android KeyEvent -> PS2 button) @@ -128,9 +129,9 @@ public class ControllerInputHandler { } int keyCode = event.getKeyCode(); - Integer ps2Button = sKeyMapping.get(keyCode); + int ps2Button = sKeyMapping.get(keyCode, Integer.MIN_VALUE); - if (ps2Button != null) { + if (ps2Button != Integer.MIN_VALUE) { int controllerId = event.getDeviceId(); boolean pressed = (event.getAction() == KeyEvent.ACTION_DOWN); diff --git a/app/src/main/java/com/izzy2lost/psx2/ControllerTestDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/ControllerTestDialogFragment.java index 045903e..6c8574e 100644 --- a/app/src/main/java/com/izzy2lost/psx2/ControllerTestDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/ControllerTestDialogFragment.java @@ -11,6 +11,7 @@ import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.List; +import java.util.Locale; /** * Dialog for testing controller input @@ -29,7 +30,7 @@ public class ControllerTestDialogFragment extends DialogFragment implements Cont @NonNull @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - View view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_controller_test, null); + View view = getLayoutInflater().inflate(R.layout.dialog_controller_test, null); mControllerListText = view.findViewById(R.id.tv_controller_list); mInputLogText = view.findViewById(R.id.tv_input_log); @@ -66,7 +67,7 @@ public class ControllerTestDialogFragment extends DialogFragment implements Cont public void onControllerButtonPressed(int controllerId, int button, boolean pressed) { String buttonName = getButtonName(button); String action = pressed ? "PRESSED" : "RELEASED"; - String logEntry = String.format("Controller %d: %s %s\n", controllerId, buttonName, action); + String logEntry = String.format(Locale.ROOT, "Controller %d: %s %s\n", controllerId, buttonName, action); mInputLog.append(logEntry); @@ -88,7 +89,7 @@ public class ControllerTestDialogFragment extends DialogFragment implements Cont public void onControllerAnalogInput(int controllerId, int axis, float value) { if (Math.abs(value) > 0.1f) { // Only log significant analog input String axisName = getAxisName(axis); - String logEntry = String.format("Controller %d: %s %.2f\n", controllerId, axisName, value); + String logEntry = String.format(Locale.ROOT, "Controller %d: %s %.2f\n", controllerId, axisName, value); mInputLog.append(logEntry); @@ -145,7 +146,7 @@ public class ControllerTestDialogFragment extends DialogFragment implements Cont @Override public void onControllerCombo(int controllerId, String comboName) { - String logEntry = String.format("Controller %d: COMBO %s\n", controllerId, comboName); + String logEntry = String.format(Locale.ROOT, "Controller %d: COMBO %s\n", controllerId, comboName); mInputLog.append(logEntry); diff --git a/app/src/main/java/com/izzy2lost/psx2/CoversAdapter.java b/app/src/main/java/com/izzy2lost/psx2/CoversAdapter.java index 2b41f6a..8eced0e 100644 --- a/app/src/main/java/com/izzy2lost/psx2/CoversAdapter.java +++ b/app/src/main/java/com/izzy2lost/psx2/CoversAdapter.java @@ -53,22 +53,25 @@ public class CoversAdapter extends RecyclerView.Adapter { @Override public void onBindViewHolder(@NonNull VH holder, int position) { holder.title.setText(titles[position]); - String url = coverUrls[position]; String local = (localPaths != null && position < localPaths.length) ? localPaths[position] : null; - Object source = null; + File localFile = null; if (local != null) { File f = new File(local); - if (f.exists() && f.length() > 0) source = f; + if (f.exists() && f.length() > 0) localFile = f; } - if (source == null) source = url; - Glide.with(context) - .load(source) - .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) - .fitCenter() - .placeholder(android.R.color.transparent) - .error(android.R.color.transparent) - .into(holder.cover); + 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 { + // Do not load from network automatically; wait for explicit download + holder.cover.setImageDrawable(null); + } holder.itemView.setOnClickListener(v -> { if (onItemClick != null) onItemClick.onClick(position); }); diff --git a/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java index 0e14b6c..d7f8d42 100644 --- a/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java @@ -9,7 +9,7 @@ import android.view.LayoutInflater; import android.view.View; import android.widget.ArrayAdapter; import android.widget.Spinner; -import android.widget.Switch; +import com.google.android.material.materialswitch.MaterialSwitch; import android.widget.TextView; import android.net.Uri; @@ -41,7 +41,7 @@ public class GameSettingsDialogFragment extends DialogFragment { @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { Context ctx = requireContext(); - View view = LayoutInflater.from(ctx).inflate(R.layout.dialog_game_settings, null, false); + View view = getLayoutInflater().inflate(R.layout.dialog_game_settings, null, false); Bundle args = getArguments(); String gameTitle = args != null ? args.getString(ARG_GAME_TITLE, "Unknown Game") : "Unknown Game"; @@ -85,10 +85,10 @@ public class GameSettingsDialogFragment extends DialogFragment { spResolution.setAdapter(resolutionAdapter); // Switches - Switch swWidescreenPatches = view.findViewById(R.id.sw_widescreen_patches); - Switch swNoInterlacingPatches = view.findViewById(R.id.sw_no_interlacing_patches); - Switch swEnablePatchCodes = view.findViewById(R.id.sw_enable_patch_codes); - Switch swEnableCheats = view.findViewById(R.id.sw_enable_cheats); + MaterialSwitch swWidescreenPatches = view.findViewById(R.id.sw_widescreen_patches); + 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); // Load existing per-game settings from INI and prefill widgets; if missing, use global try { diff --git a/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java index a8d6939..270c330 100644 --- a/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java @@ -17,14 +17,18 @@ import android.view.WindowInsetsController; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -// Import MaterialAlertDialogBuilder for Material 3 styling -import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import android.app.Dialog; import androidx.fragment.app.DialogFragment; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import java.util.Locale; public class GamesCoverDialogFragment extends DialogFragment { + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setStyle(DialogFragment.STYLE_NO_FRAME, R.style.AppTheme); + } private CoversAdapter adapter; private String[] titles; private String[] uris; @@ -75,14 +79,19 @@ public class GamesCoverDialogFragment extends DialogFragment { @NonNull @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - LayoutInflater inflater = LayoutInflater.from(requireContext()); - View root = inflater.inflate(R.layout.dialog_covers_grid, null, false); + // Return a styled dialog; content is provided by onCreateView + return new Dialog(requireContext(), R.style.PSX2_FullScreenDialog); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View root = inflater.inflate(R.layout.dialog_covers_grid, container, false); rv = root.findViewById(R.id.recycler_covers); rv.setHasFixedSize(true); glm = new GridLayoutManager(requireContext(), 3); rv.setLayoutManager(glm); - // spacing decoration (8dp) using half on each side so the gap between items is exactly spacingPx final int spacingPx = (int) (8 * getResources().getDisplayMetrics().density); final int half = Math.max(1, spacingPx / 2); rv.addItemDecoration(new RecyclerView.ItemDecoration() { @@ -91,13 +100,11 @@ public class GamesCoverDialogFragment extends DialogFragment { outRect.set(half, half, half, half); } }); - // Hard lock spans based on orientation only rv.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { int currentOrientation = getResources().getConfiguration().orientation; int fixedSpan = (currentOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) ? 4 : 2; if (fixedSpan != glm.getSpanCount()) { glm.setSpanCount(fixedSpan); } }); - // Set initial fixed span as soon as possible root.post(() -> { int currentOrientation = getResources().getConfiguration().orientation; int fixedSpan = (currentOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE) ? 4 : 2; @@ -113,7 +120,6 @@ public class GamesCoverDialogFragment extends DialogFragment { String saved = prefs.getString("serial:" + uris[i], null); String serial = saved; if (serial == null || serial.isEmpty()) { - // Ask native core for the real serial (supports ISO/CHD and content://) try { String nativeSerial = NativeApp.getGameSerial(uris[i]); if (nativeSerial != null && !nativeSerial.isEmpty()) { @@ -123,63 +129,58 @@ public class GamesCoverDialogFragment extends DialogFragment { } catch (Throwable ignored) {} } if (serial == null || serial.isEmpty()) { - // Heuristic fallback from filename serial = buildSerialFromUri(uris[i]); } coverUrls[i] = buildCoverUrlFromSerial(serial); localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath(); } - adapter = new CoversAdapter(requireContext(), titles, coverUrls, localPaths, - position -> { - // Regular click - start game - if (listener != null && position >= 0 && position < uris.length) { - listener.onGameSelected(uris[position]); - dismissAllowingStateLoss(); - } - }, - position -> { - // Long click - show game settings - if (position >= 0 && position < uris.length) { - showGameSettings(titles[position], uris[position]); - } - }); + adapter = new CoversAdapter(requireContext(), titles, coverUrls, localPaths, + position -> { + if (listener != null && position >= 0 && position < uris.length) { + listener.onGameSelected(uris[position]); + dismissAllowingStateLoss(); + } + }, + position -> { + if (position >= 0 && position < uris.length) { + showGameSettings(titles[position], uris[position]); + } + }); rv.setAdapter(adapter); - // Toolbar buttons View btnHome = root.findViewById(R.id.btn_home); if (btnHome != null) btnHome.setOnClickListener(v -> dismissAllowingStateLoss()); View btnDownload = root.findViewById(R.id.btn_download); if (btnDownload != null) btnDownload.setOnClickListener(v -> startDownloadCovers()); - AlertDialog dialog = new MaterialAlertDialogBuilder(requireContext(), - com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog) - .setView(root) - .create(); - return dialog; + return root; } - @Override public void onStart() { super.onStart(); Dialog d = getDialog(); - if (d != null) { - Window w = d.getWindow(); - if (w != null) { - w.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); - w.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); - // Hide status bar for true full-screen dialog - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - w.setDecorFitsSystemWindows(false); - WindowInsetsController controller = w.getInsetsController(); - if (controller != null) { - controller.hide(WindowInsets.Type.statusBars()); - controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); - } - } else { - w.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - } + if (d == null) return; + Window w = d.getWindow(); + if (w == null) return; + w.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + w.setDecorFitsSystemWindows(false); + WindowInsetsController controller = w.getInsetsController(); + if (controller != null) { + controller.hide(WindowInsets.Type.systemBars()); + controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); } + } else { + w.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + View decor = w.getDecorView(); + int flags = 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 + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + decor.setSystemUiVisibility(flags); } } @@ -298,7 +299,7 @@ public class GamesCoverDialogFragment extends DialogFragment { if (dot > 0) last = last.substring(0, dot); String serial = null; // Very simple heuristic: find token like XXXX-XXXXX - String upper = last.toUpperCase(); + String upper = last.toUpperCase(Locale.ROOT); java.util.regex.Matcher m = java.util.regex.Pattern.compile("([A-Z]{4,5}-[0-9]{3,5})").matcher(upper); if (m.find()) { serial = m.group(1); @@ -353,7 +354,7 @@ public class GamesCoverDialogFragment extends DialogFragment { private static String normalizeSerial(String serial) { if (serial == null) return null; - String s = serial.toUpperCase().replace('_', '-'); + String s = serial.toUpperCase(Locale.ROOT).replace('_', '-'); // If form like XXXX-123.45 -> XXXX-12345 s = s.replaceAll("([A-Z]{4,5})-([0-9]{3})\\.([0-9]{2})", "$1-$2$3"); return s; diff --git a/app/src/main/java/com/izzy2lost/psx2/HIDDeviceBLESteamController.java b/app/src/main/java/com/izzy2lost/psx2/HIDDeviceBLESteamController.java index b73af38..8ed46ba 100644 --- a/app/src/main/java/com/izzy2lost/psx2/HIDDeviceBLESteamController.java +++ b/app/src/main/java/com/izzy2lost/psx2/HIDDeviceBLESteamController.java @@ -9,11 +9,13 @@ import android.bluetooth.BluetoothGattService; import android.bluetooth.BluetoothManager; import android.bluetooth.BluetoothProfile; import android.content.Context; +import android.content.pm.PackageManager; import android.hardware.usb.UsbDevice; import android.os.Build; import android.os.Handler; import android.os.Looper; import android.util.Log; +import androidx.core.content.ContextCompat; import java.util.Arrays; import java.util.LinkedList; @@ -80,23 +82,33 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe case CHR_READ: chr = getCharacteristic(mUuid); //Log.v(TAG, "Reading characteristic " + chr.getUuid()); - if (!mGatt.readCharacteristic(chr)) { - Log.e(TAG, "Unable to read characteristic " + mUuid.toString()); + try { + if (!mGatt.readCharacteristic(chr)) { + Log.e(TAG, "Unable to read characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for reading characteristic: " + e.getMessage()); mResult = false; - break; } - mResult = true; break; case CHR_WRITE: chr = getCharacteristic(mUuid); //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value)); chr.setValue(mValue); - if (!mGatt.writeCharacteristic(chr)) { - Log.e(TAG, "Unable to write characteristic " + mUuid.toString()); + try { + if (!mGatt.writeCharacteristic(chr)) { + Log.e(TAG, "Unable to write characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for writing characteristic: " + e.getMessage()); mResult = false; - break; } - mResult = true; break; case ENABLE_NOTIFICATION: chr = getCharacteristic(mUuid); @@ -116,14 +128,19 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe return; } - mGatt.setCharacteristicNotification(chr, true); - cccd.setValue(value); - if (!mGatt.writeDescriptor(cccd)) { - Log.e(TAG, "Unable to write descriptor " + mUuid.toString()); + try { + mGatt.setCharacteristicNotification(chr, true); + cccd.setValue(value); + if (!mGatt.writeDescriptor(cccd)) { + Log.e(TAG, "Unable to write descriptor " + mUuid.toString()); + mResult = false; + return; + } + mResult = true; + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for notification setup: " + e.getMessage()); mResult = false; - return; } - mResult = true; } } } @@ -176,6 +193,14 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe return String.format("SteamController.%s", mDevice.getAddress()); } + private boolean hasBluetoothPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return ContextCompat.checkSelfPermission(mManager.getContext(), + android.Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED; + } + return true; // Pre-Android 12 doesn't need runtime permission + } + BluetoothGatt getGatt() { return mGatt; } @@ -183,14 +208,19 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe // Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead // of TRANSPORT_LE. Let's force ourselves to connect low energy. private BluetoothGatt connectGatt(boolean managed) { - if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) { - try { - return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE); - } catch (Exception e) { + try { + if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) { + try { + return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE); + } catch (Exception e) { + return mDevice.connectGatt(mManager.getContext(), managed, this); + } + } else { return mDevice.connectGatt(mManager.getContext(), managed, this); } - } else { - return mDevice.connectGatt(mManager.getContext(), managed, this); + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for GATT connection: " + e.getMessage()); + return null; } } @@ -213,13 +243,22 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe return BluetoothProfile.STATE_DISCONNECTED; } - return btManager.getConnectionState(mDevice, BluetoothProfile.GATT); + try { + return btManager.getConnectionState(mDevice, BluetoothProfile.GATT); + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for getting connection state: " + e.getMessage()); + return BluetoothProfile.STATE_DISCONNECTED; + } } void reconnect() { if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) { - mGatt.disconnect(); + try { + mGatt.disconnect(); + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for disconnect: " + e.getMessage()); + } mGatt = connectGatt(); } @@ -241,7 +280,11 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe // to try to recover. Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect."); mIsReconnecting = true; - mGatt.disconnect(); + try { + mGatt.disconnect(); + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for disconnect: " + e.getMessage()); + } mGatt = connectGatt(false); break; } @@ -253,7 +296,11 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe else { Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover."); mIsReconnecting = true; - mGatt.disconnect(); + try { + mGatt.disconnect(); + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for disconnect: " + e.getMessage()); + } mGatt = connectGatt(false); break; } @@ -268,7 +315,11 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover."); mIsReconnecting = true; - mGatt.disconnect(); + try { + mGatt.disconnect(); + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for disconnect: " + e.getMessage()); + } mGatt = connectGatt(false); break; @@ -328,7 +379,11 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us."); mIsConnected = false; mIsReconnecting = true; - mGatt.disconnect(); + try { + mGatt.disconnect(); + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for disconnect: " + e.getMessage()); + } mGatt = connectGatt(false); } @@ -423,7 +478,11 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe mHandler.post(new Runnable() { @Override public void run() { - mGatt.discoverServices(); + try { + mGatt.discoverServices(); + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for service discovery: " + e.getMessage()); + } } }); } @@ -443,7 +502,11 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack."); mIsReconnecting = true; mIsConnected = false; - gatt.disconnect(); + try { + gatt.disconnect(); + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for disconnect: " + e.getMessage()); + } mGatt = connectGatt(false); } else { @@ -505,7 +568,11 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe if (reportChr != null) { Log.v(TAG, "Writing report characteristic to enter valve mode"); reportChr.setValue(enterValveMode); - gatt.writeCharacteristic(reportChr); + try { + gatt.writeCharacteristic(reportChr); + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for writing report characteristic: " + e.getMessage()); + } } } @@ -638,8 +705,12 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe BluetoothGatt g = mGatt; if (g != null) { - g.disconnect(); - g.close(); + try { + g.disconnect(); + g.close(); + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for shutdown operations: " + e.getMessage()); + } mGatt = null; } mManager = null; diff --git a/app/src/main/java/com/izzy2lost/psx2/HIDDeviceManager.java b/app/src/main/java/com/izzy2lost/psx2/HIDDeviceManager.java index 285feaf..4823ed8 100644 --- a/app/src/main/java/com/izzy2lost/psx2/HIDDeviceManager.java +++ b/app/src/main/java/com/izzy2lost/psx2/HIDDeviceManager.java @@ -12,6 +12,7 @@ import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.hardware.usb.UsbConstants; +import androidx.core.content.ContextCompat; import android.hardware.usb.UsbDevice; import android.hardware.usb.UsbInterface; import android.hardware.usb.UsbManager; @@ -134,7 +135,7 @@ public class HIDDeviceManager { } spedit.putInt(identifier, result); - spedit.commit(); + spedit.apply(); return result; } @@ -192,11 +193,7 @@ public class HIDDeviceManager { filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - mContext.registerReceiver(mUsbBroadcast, filter, Context.RECEIVER_EXPORTED); - } else { - mContext.registerReceiver(mUsbBroadcast, filter); - } + ContextCompat.registerReceiver(mContext, mUsbBroadcast, filter, ContextCompat.RECEIVER_NOT_EXPORTED); for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) { handleUsbDeviceAttached(usbDevice); @@ -446,7 +443,13 @@ public class HIDDeviceManager { ArrayList disconnected = new ArrayList(); ArrayList connected = new ArrayList(); - List currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT); + List currentConnected; + try { + currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT); + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for getting connected devices: " + e.getMessage()); + return; + } for (BluetoothDevice bluetoothDevice : currentConnected) { if (!mLastBluetoothDevices.contains(bluetoothDevice)) { @@ -519,11 +522,21 @@ public class HIDDeviceManager { } // If the device has no local name, we really don't want to try an equality check against it. - if (bluetoothDevice.getName() == null) { + String deviceName; + int deviceType; + try { + deviceName = bluetoothDevice.getName(); + deviceType = bluetoothDevice.getType(); + } catch (SecurityException e) { + Log.e(TAG, "Permission denied for getting device info: " + e.getMessage()); + return false; + } + + if (deviceName == null) { return false; } - return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0); + return deviceName.equals("SteamController") && ((deviceType & BluetoothDevice.DEVICE_TYPE_LE) != 0); } private void close() { diff --git a/app/src/main/java/com/izzy2lost/psx2/HIDDeviceUSB.java b/app/src/main/java/com/izzy2lost/psx2/HIDDeviceUSB.java index bc3d3e4..aa685a9 100644 --- a/app/src/main/java/com/izzy2lost/psx2/HIDDeviceUSB.java +++ b/app/src/main/java/com/izzy2lost/psx2/HIDDeviceUSB.java @@ -9,6 +9,7 @@ import android.os.Build; import android.util.Log; import java.util.Arrays; +import java.util.Locale; class HIDDeviceUSB implements HIDDevice { @@ -36,7 +37,7 @@ class HIDDeviceUSB implements HIDDevice { } String getIdentifier() { - return String.format("%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex); + return String.format(Locale.ROOT, "%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex); } @Override diff --git a/app/src/main/java/com/izzy2lost/psx2/MainActivity.java b/app/src/main/java/com/izzy2lost/psx2/MainActivity.java index eb09093..0b417a5 100644 --- a/app/src/main/java/com/izzy2lost/psx2/MainActivity.java +++ b/app/src/main/java/com/izzy2lost/psx2/MainActivity.java @@ -51,6 +51,7 @@ import org.json.JSONArray; import org.json.JSONException; import androidx.fragment.app.FragmentManager; import java.util.List; +import java.util.Locale; public class MainActivity extends AppCompatActivity implements GamesCoverDialogFragment.OnGameSelectedListener, ControllerInputHandler.ControllerInputListener { private String m_szGamefile = ""; @@ -306,7 +307,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF private static boolean hasGameExt(String name) { if (TextUtils.isEmpty(name)) return false; - String lower = name.toLowerCase(); + String lower = name.toLowerCase(Locale.ROOT); for (String ext : GAME_EXTS) { if (lower.endsWith(ext)) return true; } @@ -528,7 +529,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF if(btn_ogl != null) { btn_ogl.setOnClickListener(v -> { // Save setting first with commit() to ensure immediate write - getSharedPreferences("app_prefs", MODE_PRIVATE).edit().putInt("renderer", 12).commit(); + getSharedPreferences("app_prefs", MODE_PRIVATE).edit().putInt("renderer", 12).apply(); // Small delay to ensure setting is persisted try { @@ -546,7 +547,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF if(btn_vulkan != null) { btn_vulkan.setOnClickListener(v -> { // Save setting first with commit() to ensure immediate write - getSharedPreferences("app_prefs", MODE_PRIVATE).edit().putInt("renderer", 14).commit(); + getSharedPreferences("app_prefs", MODE_PRIVATE).edit().putInt("renderer", 14).apply(); // Small delay to ensure setting is persisted try { @@ -566,7 +567,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF android.util.Log.d("MainActivity", "SW button clicked - saving renderer 13"); // Save setting first with commit() to ensure immediate write - getSharedPreferences("app_prefs", MODE_PRIVATE).edit().putInt("renderer", 13).commit(); + getSharedPreferences("app_prefs", MODE_PRIVATE).edit().putInt("renderer", 13).apply(); android.util.Log.d("MainActivity", "SW renderer setting saved"); // Small delay to ensure setting is persisted @@ -908,7 +909,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF // Filter by name for common virtual/built-in inputs String name = device.getName(); if (name != null) { - String lower = name.toLowerCase(); + String lower = name.toLowerCase(Locale.ROOT); if (lower.contains("virtual") || lower.contains("uinput") || lower.contains("touch") || lower.contains("keyboard") || lower.contains("keypad") || lower.contains("gpio")) { return false; @@ -986,7 +987,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF private void setRendererAndSave(int renderer) { SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE); - prefs.edit().putInt("renderer", renderer).commit(); + prefs.edit().putInt("renderer", renderer).apply(); // Apply live if possible; this path has proven stable via top bar buttons try { NativeApp.renderGpu(renderer); @@ -1054,7 +1055,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF String name = f.getName(); // Common PCSX2 BIOS components: SCPH*.bin (main), and component ROMs ROM0/ROM1/ROM2/EROM if (name != null) { - String lower = name.toLowerCase(); + String lower = name.toLowerCase(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"); @@ -1247,7 +1248,12 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF if (treeUri != null) { // Persist read permission for future imports, optional final int takeFlags = (data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION)); - getContentResolver().takePersistableUriPermission(treeUri, takeFlags); + if ((takeFlags & Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0) { + getContentResolver().takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + if ((takeFlags & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0) { + getContentResolver().takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } DocumentFile pickedDir = DocumentFile.fromTreeUri(this, treeUri); if (pickedDir != null && pickedDir.isDirectory()) { @@ -1273,9 +1279,16 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF 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) {} + if ((takeFlags & Intent.FLAG_GRANT_READ_URI_PERMISSION) != 0) { + try { + getContentResolver().takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + } catch (SecurityException ignored) {} + } + if ((takeFlags & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0) { + try { + getContentResolver().takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } catch (SecurityException ignored) {} + } // Save folder and show games getSharedPreferences("app_prefs", MODE_PRIVATE) .edit() @@ -1423,13 +1436,8 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF // Initialize HID device manager for USB and Bluetooth controllers mHIDDeviceManager.initialize(true, true); - // Apply renderer setting again after a delay to override any automatic detection - new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { - SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE); - int renderer = prefs.getInt("renderer", 14); // Default to Vulkan if no setting - android.util.Log.d("MainActivity", "Re-applying renderer setting after delay: " + renderer); - NativeApp.renderGpu(renderer); - }, 1000); // 1 second delay + // Avoid forcing renderer init when no game/surface is active. + // Renderer is applied on game start (restartEmuThread) and from settings when appropriate. } private void setSurfaceView(Object p_value) { @@ -1622,6 +1630,7 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF @Override public void onBackPressed() { // Fallback for older Android versions + super.onBackPressed(); showExitDialog(); } diff --git a/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java index dab6480..0645db4 100644 --- a/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/QuickActionsDialogFragment.java @@ -17,7 +17,7 @@ public class QuickActionsDialogFragment extends DialogFragment { @NonNull @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - View view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_quick_actions, null, false); + View view = getLayoutInflater().inflate(R.layout.dialog_quick_actions, null, false); MaterialButton btnOpenGames = view.findViewById(R.id.btn_open_games); MaterialButton btnExitToMenu = view.findViewById(R.id.btn_exit_to_menu); diff --git a/app/src/main/java/com/izzy2lost/psx2/SDLControllerManager.java b/app/src/main/java/com/izzy2lost/psx2/SDLControllerManager.java index 00e985f..5446328 100644 --- a/app/src/main/java/com/izzy2lost/psx2/SDLControllerManager.java +++ b/app/src/main/java/com/izzy2lost/psx2/SDLControllerManager.java @@ -504,14 +504,23 @@ class SDLHapticHandler_API31 extends SDLHapticHandler { return; } - VibratorManager manager = device.getVibratorManager(); - int[] vibrators = manager.getVibratorIds(); - if (vibrators.length >= 2) { - vibrate(manager.getVibrator(vibrators[0]), low_frequency_intensity, length); - vibrate(manager.getVibrator(vibrators[1]), high_frequency_intensity, length); - } else if (vibrators.length == 1) { - float intensity = (low_frequency_intensity * 0.6f) + (high_frequency_intensity * 0.4f); - vibrate(manager.getVibrator(vibrators[0]), intensity, length); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + VibratorManager manager = device.getVibratorManager(); + int[] vibrators = manager.getVibratorIds(); + if (vibrators.length >= 2) { + vibrate(manager.getVibrator(vibrators[0]), low_frequency_intensity, length); + vibrate(manager.getVibrator(vibrators[1]), high_frequency_intensity, length); + } else if (vibrators.length == 1) { + float intensity = (low_frequency_intensity * 0.6f) + (high_frequency_intensity * 0.4f); + vibrate(manager.getVibrator(vibrators[0]), intensity, length); + } + } else { + // Fallback for older Android versions + Vibrator vibrator = device.getVibrator(); + if (vibrator != null) { + float intensity = (low_frequency_intensity * 0.6f) + (high_frequency_intensity * 0.4f); + vibrate(vibrator, intensity, length); + } } } diff --git a/app/src/main/java/com/izzy2lost/psx2/SavesDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/SavesDialogFragment.java index 2fd2c82..974c839 100644 --- a/app/src/main/java/com/izzy2lost/psx2/SavesDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/SavesDialogFragment.java @@ -149,7 +149,7 @@ public class SavesDialogFragment extends DialogFragment { @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { Context ctx = requireContext(); - View view = LayoutInflater.from(ctx).inflate(R.layout.dialog_saves, null, false); + View view = getLayoutInflater().inflate(R.layout.dialog_saves, null, false); RecyclerView recyclerView = view.findViewById(R.id.rv_save_slots); recyclerView.setLayoutManager(new LinearLayoutManager(ctx)); diff --git a/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java index b91f99b..767d5fb 100644 --- a/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/SettingsDialogFragment.java @@ -10,7 +10,7 @@ import android.widget.ArrayAdapter; import android.widget.RadioButton; import android.widget.RadioGroup; import android.widget.Spinner; -import android.widget.Switch; +import com.google.android.material.materialswitch.MaterialSwitch; import android.content.res.ColorStateList; import androidx.annotation.NonNull; @@ -70,7 +70,7 @@ public class SettingsDialogFragment extends DialogFragment { @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { Context ctx = requireContext(); - View view = LayoutInflater.from(ctx).inflate(R.layout.dialog_settings, null, false); + View view = getLayoutInflater().inflate(R.layout.dialog_settings, null, false); RadioGroup rgRenderer = view.findViewById(R.id.rg_renderer); RadioButton rbGl = view.findViewById(R.id.rb_renderer_gl); @@ -79,11 +79,11 @@ public class SettingsDialogFragment extends DialogFragment { Spinner spScale = view.findViewById(R.id.sp_scale); Spinner spBlending = view.findViewById(R.id.sp_blending_accuracy); Spinner spAspectRatio = view.findViewById(R.id.sp_aspect_ratio); - Switch swWidescreen = view.findViewById(R.id.sw_widescreen); - Switch swNoInterlacing = view.findViewById(R.id.sw_no_interlacing); - Switch swLoadTextures = view.findViewById(R.id.sw_load_textures); - Switch swAsyncTextureLoading = view.findViewById(R.id.sw_async_texture_loading); - Switch swDevHud = view.findViewById(R.id.sw_dev_hud); + MaterialSwitch swWidescreen = view.findViewById(R.id.sw_widescreen); + MaterialSwitch swNoInterlacing = view.findViewById(R.id.sw_no_interlacing); + MaterialSwitch swLoadTextures = view.findViewById(R.id.sw_load_textures); + MaterialSwitch swAsyncTextureLoading = view.findViewById(R.id.sw_async_texture_loading); + MaterialSwitch swDevHud = view.findViewById(R.id.sw_dev_hud); View btnPower = view.findViewById(R.id.btn_power); View btnReboot = view.findViewById(R.id.btn_reboot); View btnTestController = view.findViewById(R.id.btn_test_controller); @@ -106,37 +106,7 @@ public class SettingsDialogFragment extends DialogFragment { if (rbVk != null) CompoundButtonCompat.setButtonTintList(rbVk, brandChecked); if (rbSw != null) CompoundButtonCompat.setButtonTintList(rbSw, brandChecked); - // Switches (thumb = brand when checked; track = subtle brand when checked) - ColorStateList thumbTint = new ColorStateList( - new int[][]{new int[]{android.R.attr.state_checked}, new int[]{}}, - new int[]{brand, outline} - ); - int brandTrack = ColorStateList.valueOf(brand).withAlpha(100).getDefaultColor(); - int outlineTrack = ColorStateList.valueOf(outline).withAlpha(80).getDefaultColor(); - ColorStateList trackTint = new ColorStateList( - new int[][]{new int[]{android.R.attr.state_checked}, new int[]{}}, - new int[]{brandTrack, outlineTrack} - ); - if (swWidescreen != null) { - swWidescreen.setThumbTintList(thumbTint); - swWidescreen.setTrackTintList(trackTint); - } - if (swNoInterlacing != null) { - swNoInterlacing.setThumbTintList(thumbTint); - swNoInterlacing.setTrackTintList(trackTint); - } - if (swLoadTextures != null) { - swLoadTextures.setThumbTintList(thumbTint); - swLoadTextures.setTrackTintList(trackTint); - } - if (swAsyncTextureLoading != null) { - swAsyncTextureLoading.setThumbTintList(thumbTint); - swAsyncTextureLoading.setTrackTintList(trackTint); - } - if (swDevHud != null) { - swDevHud.setThumbTintList(thumbTint); - swDevHud.setTrackTintList(trackTint); - } + // MaterialSwitch: rely on default Material3 theme styling from XML style/overlay if (btnPower != null) { btnPower.setOnClickListener(v -> { @@ -264,7 +234,7 @@ public class SettingsDialogFragment extends DialogFragment { " (12=OpenGL, 13=Software, 14=Vulkan)"); // Save renderer setting first with commit() to ensure immediate write - prefs.edit().putInt("renderer", renderer).commit(); + prefs.edit().putInt("renderer", renderer).apply(); android.util.Log.d("SettingsDialog", "Renderer setting saved successfully"); // Small delay to ensure setting is persisted diff --git a/app/src/main/res/drawable/bg_covers_gradient_blue.xml b/app/src/main/res/drawable/bg_covers_gradient_blue.xml new file mode 100644 index 0000000..d08a32a --- /dev/null +++ b/app/src/main/res/drawable/bg_covers_gradient_blue.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_covers_gradient_purple.xml b/app/src/main/res/drawable/bg_covers_gradient_purple.xml new file mode 100644 index 0000000..9fb6535 --- /dev/null +++ b/app/src/main/res/drawable/bg_covers_gradient_purple.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_unhide.xml b/app/src/main/res/drawable/ic_unhide.xml index b522953..d2700cb 100644 --- a/app/src/main/res/drawable/ic_unhide.xml +++ b/app/src/main/res/drawable/ic_unhide.xml @@ -1,7 +1,7 @@ @@ -310,4 +310,4 @@ android:strokeColor="#8f61ff" android:strokeLineCap="round"/> - \ No newline at end of file + diff --git a/app/src/main/res/layout-land/dialog_covers_grid.xml b/app/src/main/res/layout-land/dialog_covers_grid.xml index b7bf474..b195c11 100644 --- a/app/src/main/res/layout-land/dialog_covers_grid.xml +++ b/app/src/main/res/layout-land/dialog_covers_grid.xml @@ -1,7 +1,9 @@ + app:tint="@color/brand_primary" /> - - - - + android:paddingTop="4dp" + android:text="@string/covers_hint_long_press" + android:textColor="@color/brand_primary" + android:textStyle="bold" + android:drawableStart="@drawable/ic_info" + android:drawablePadding="8dp"/> @@ -60,7 +60,7 @@ android:layout_width="60dip" android:layout_height="wrap_content" android:text="VK" - android:textSize="12dp" + android:textSize="12sp" app:rippleColor="#80cdcdcd" /> @@ -70,7 +70,7 @@ android:layout_width="60dip" android:layout_height="wrap_content" android:text="SW" - android:textSize="12dp" + android:textSize="12sp" app:rippleColor="#80cdcdcd" /> diff --git a/app/src/main/res/layout-port/dialog_covers_grid.xml b/app/src/main/res/layout-port/dialog_covers_grid.xml index b7bf474..b195c11 100644 --- a/app/src/main/res/layout-port/dialog_covers_grid.xml +++ b/app/src/main/res/layout-port/dialog_covers_grid.xml @@ -1,7 +1,9 @@ + app:tint="@color/brand_primary" /> - - - - + android:paddingTop="4dp" + android:text="@string/covers_hint_long_press" + android:textColor="@color/brand_primary" + android:textStyle="bold" + android:drawableStart="@drawable/ic_info" + android:drawablePadding="8dp"/> @@ -53,7 +53,7 @@ android:layout_width="60dip" android:layout_height="wrap_content" android:text="VK" - android:textSize="12dp" + android:textSize="12sp" app:rippleColor="#80cdcdcd" /> @@ -63,7 +63,7 @@ android:layout_width="60dip" android:layout_height="wrap_content" android:text="SW" - android:textSize="12dp" + android:textSize="12sp" app:rippleColor="#80cdcdcd" /> diff --git a/app/src/main/res/layout/dialog_covers_grid.xml b/app/src/main/res/layout/dialog_covers_grid.xml index b087cff..8032cbb 100644 --- a/app/src/main/res/layout/dialog_covers_grid.xml +++ b/app/src/main/res/layout/dialog_covers_grid.xml @@ -3,6 +3,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="@drawable/bg_covers_gradient_blue" android:orientation="vertical"> + app:tint="#883DA4" /> - - - - + android:paddingTop="4dp" + android:text="@string/covers_hint_long_press" + android:textColor="@color/brand_primary" + android:textStyle="bold" + android:drawableStart="@drawable/ic_info" + android:drawablePadding="8dp"/> @@ -106,33 +108,37 @@ android:textSize="16sp" android:paddingBottom="8dp"/> - - - - @@ -30,7 +32,7 @@ android:contentDescription="Reboot" android:padding="8dp" android:src="@drawable/ic_reboot" - android:tint="@android:color/holo_red_light" + app:tint="@android:color/holo_red_light" android:layout_marginEnd="8dp" /> + app:tint="@android:color/holo_red_light" /> - + android:text="Widescreen Patches" + style="@style/Widget.Material3.CompoundButton.MaterialSwitch"/> - + android:layout_marginTop="8dp" + style="@style/Widget.Material3.CompoundButton.MaterialSwitch"/> - + android:text="Load Texture Replacements" + style="@style/Widget.Material3.CompoundButton.MaterialSwitch"/> - + android:layout_marginTop="8dp" + style="@style/Widget.Material3.CompoundButton.MaterialSwitch"/> - + android:text="Dev: HUD overlay" + style="@style/Widget.Material3.CompoundButton.MaterialSwitch"/> + android:foreground="?attr/selectableItemBackground"> @style/PSX2.OutlinedButton + + + diff --git a/graduated-gradient-rainbow-vinyl-vertical-black-to-blue-260-10.jpg b/graduated-gradient-rainbow-vinyl-vertical-black-to-blue-260-10.jpg new file mode 100644 index 0000000..5813c81 Binary files /dev/null and b/graduated-gradient-rainbow-vinyl-vertical-black-to-blue-260-10.jpg differ diff --git a/graduated-gradient-rainbow-vinyl-vertical-black-to-purple-263-10.jpg b/graduated-gradient-rainbow-vinyl-vertical-black-to-purple-263-10.jpg new file mode 100644 index 0000000..4c99892 Binary files /dev/null and b/graduated-gradient-rainbow-vinyl-vertical-black-to-purple-263-10.jpg differ