Files
PSX2/app/src/main/java/com/izzy2lost/psx2/MainActivity.java
T

2084 lines
92 KiB
Java
Raw Normal View History

2025-08-22 04:11:11 -04:00
package com.izzy2lost.psx2;
import android.app.Activity;
2025-08-28 08:11:50 -04:00
import androidx.appcompat.app.AlertDialog;
2025-08-25 07:58:09 -04:00
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
2025-08-22 04:11:11 -04:00
import android.content.Context;
import android.content.Intent;
import android.content.res.AssetManager;
import android.content.res.Configuration;
import android.database.Cursor;
import android.os.Bundle;
import android.text.TextUtils;
import android.net.Uri;
import android.view.InputDevice;
import android.hardware.input.InputManager;
2025-08-25 00:17:24 -04:00
import android.view.InputEvent;
2025-08-22 04:11:11 -04:00
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;
2025-09-11 23:14:41 -04:00
import android.widget.ArrayAdapter;
import android.widget.Spinner;
2025-08-22 04:11:11 -04:00
import android.view.ViewGroup;
import android.content.res.ColorStateList;
import android.graphics.Color;
import android.content.ClipData;
import android.content.SharedPreferences;
import android.os.Build;
import android.view.Window;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import android.view.WindowManager;
import android.util.TypedValue;
2025-09-06 02:07:48 -04:00
import android.widget.Toast;
2025-08-22 04:11:11 -04:00
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.documentfile.provider.DocumentFile;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
2025-09-10 09:24:01 -04:00
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.core.view.GravityCompat;
2025-08-22 04:11:11 -04:00
import androidx.activity.OnBackPressedCallback;
import com.google.android.material.button.MaterialButton;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import android.provider.OpenableColumns;
import org.json.JSONArray;
import org.json.JSONException;
import androidx.fragment.app.FragmentManager;
2025-09-10 09:24:01 -04:00
import com.google.android.material.navigation.NavigationView;
import com.google.android.material.button.MaterialButtonToggleGroup;
2025-08-22 04:11:11 -04:00
import java.util.List;
import java.util.Locale;
2025-08-22 04:11:11 -04:00
2025-08-25 13:56:13 -04:00
public class MainActivity extends AppCompatActivity implements GamesCoverDialogFragment.OnGameSelectedListener, ControllerInputHandler.ControllerInputListener {
2025-08-22 04:11:11 -04:00
private String m_szGamefile = "";
private HIDDeviceManager mHIDDeviceManager;
2025-08-25 13:56:13 -04:00
private ControllerInputHandler mControllerInputHandler;
2025-08-22 04:11:11 -04:00
private Thread mEmulationThread = null;
2025-09-06 02:07:48 -04:00
private boolean mSetupWizardActive = false;
2025-08-22 04:11:11 -04:00
private boolean mHudVisible = false;
private InputManager mInputManager;
2025-08-22 04:11:11 -04:00
// Track joystick directional pressed state to avoid duplicate down events
private boolean joyUpPressed = false;
private boolean joyDownPressed = false;
private boolean joyLeftPressed = false;
private boolean joyRightPressed = false;
private boolean controllerUiApplied = false;
2025-08-28 08:11:50 -04:00
private AlertDialog mBiosPromptDialog = null;
2025-09-11 23:14:41 -04:00
private boolean mControllerHintShowing = false;
2025-08-22 04:11:11 -04:00
private boolean isThread() {
if (mEmulationThread != null) {
Thread.State _thread_state = mEmulationThread.getState();
return _thread_state == Thread.State.BLOCKED
|| _thread_state == Thread.State.RUNNABLE
|| _thread_state == Thread.State.TIMED_WAITING
|| _thread_state == Thread.State.WAITING;
}
return false;
}
// Expose whether a game has been chosen (non-empty path)
public boolean hasSelectedGame() {
return !TextUtils.isEmpty(m_szGamefile);
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
// Keep fullscreen on rotate and reflow constraints without recreating
hideStatusBar();
applyConstraintsForOrientation(newConfig.orientation);
}
private void applyConstraintsForOrientation(int orientation) {
// Views present in both layouts
View quick = findViewById(R.id.ll_quick_actions);
View btnSettings = findViewById(R.id.btn_settings);
2025-09-10 09:24:01 -04:00
// Visibility toggle removed; no dependency on it for constraints
2025-08-22 04:11:11 -04:00
View llJoy = findViewById(R.id.ll_pad_joy);
View llDpad = findViewById(R.id.ll_pad_dpad);
View llRight = findViewById(R.id.ll_pad_right_buttons);
View llSelectStart = findViewById(R.id.ll_pad_select_start);
// Use helper dp()
2025-09-10 09:24:01 -04:00
if (quick != null && btnSettings != null) {
2025-08-22 04:11:11 -04:00
ConstraintLayout.LayoutParams lp = safeCLP(quick);
if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
2025-09-10 09:24:01 -04:00
// Top-aligned row stretching from settings to end
2025-08-22 04:11:11 -04:00
lp.width = 0; // chain between start/end
lp.topToTop = btnSettings.getId();
lp.topToBottom = ConstraintLayout.LayoutParams.UNSET;
lp.startToEnd = btnSettings.getId();
2025-09-10 09:24:01 -04:00
lp.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
2025-08-22 04:11:11 -04:00
lp.startToStart = ConstraintLayout.LayoutParams.UNSET;
lp.topMargin = dp(0);
lp.horizontalChainStyle = ConstraintLayout.LayoutParams.CHAIN_PACKED;
} else {
// Centered under settings
lp.width = ConstraintLayout.LayoutParams.WRAP_CONTENT;
lp.topToTop = ConstraintLayout.LayoutParams.UNSET;
lp.topToBottom = btnSettings.getId();
lp.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
lp.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
lp.startToEnd = ConstraintLayout.LayoutParams.UNSET;
lp.endToStart = ConstraintLayout.LayoutParams.UNSET;
lp.topMargin = dp(16);
lp.horizontalChainStyle = ConstraintLayout.LayoutParams.CHAIN_SPREAD;
}
quick.setLayoutParams(lp);
}
if (llJoy != null) {
ConstraintLayout.LayoutParams lp = safeCLP(llJoy);
lp.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID;
lp.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
lp.endToEnd = ConstraintLayout.LayoutParams.UNSET;
lp.topToTop = ConstraintLayout.LayoutParams.UNSET;
lp.topToBottom = ConstraintLayout.LayoutParams.UNSET;
int m = (orientation == Configuration.ORIENTATION_LANDSCAPE) ? 6 : 6;
lp.setMargins(dp(m), dp(m), dp(m), dp(m));
llJoy.setLayoutParams(lp);
// Nudge joystick further left in both orientations to avoid Select overlap
llJoy.setTranslationX(-dp(28));
}
if (llDpad != null && llJoy != null) {
ConstraintLayout.LayoutParams lp = safeCLP(llDpad);
// Above-left of joystick for both, with slight spacing
lp.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
lp.endToEnd = ConstraintLayout.LayoutParams.UNSET;
lp.bottomToTop = llJoy.getId();
lp.bottomToBottom = ConstraintLayout.LayoutParams.UNSET;
lp.topToTop = ConstraintLayout.LayoutParams.UNSET;
lp.topToBottom = ConstraintLayout.LayoutParams.UNSET;
// Shift the whole D-pad slightly to the right with a small start margin
lp.setMargins(dp(48), dp(0), dp(0), dp(orientation == Configuration.ORIENTATION_LANDSCAPE ? 2 : 0));
2025-08-22 04:11:11 -04:00
llDpad.setLayoutParams(lp);
// Nudge D-pad downward a little
llDpad.setTranslationY(dp(14));
2025-08-22 04:11:11 -04:00
}
if (llRight != null) {
ConstraintLayout.LayoutParams lp = safeCLP(llRight);
if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
lp.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID;
lp.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
lp.bottomToTop = ConstraintLayout.LayoutParams.UNSET;
lp.setMargins(dp(12), dp(12), dp(12), dp(12));
} else {
// Above Select/Start, aligned to end
if (llSelectStart != null) {
lp.bottomToTop = llSelectStart.getId();
} else {
lp.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID;
}
lp.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
lp.bottomToBottom = (llSelectStart == null) ? ConstraintLayout.LayoutParams.PARENT_ID : ConstraintLayout.LayoutParams.UNSET;
lp.setMargins(0,0,0,dp(8));
}
llRight.setLayoutParams(lp);
}
if (llSelectStart != null) {
ConstraintLayout.LayoutParams lp = safeCLP(llSelectStart);
lp.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
lp.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
lp.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID;
lp.setMargins(0,0,0,dp(orientation == Configuration.ORIENTATION_LANDSCAPE ? 8 : 0));
llSelectStart.setLayoutParams(lp);
}
}
private ConstraintLayout.LayoutParams safeCLP(View v) {
ViewGroup.LayoutParams p = v.getLayoutParams();
if (p instanceof ConstraintLayout.LayoutParams) return (ConstraintLayout.LayoutParams) p;
ConstraintLayout.LayoutParams lp = new ConstraintLayout.LayoutParams(p);
v.setLayoutParams(lp);
return lp;
}
private int dp(int d) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, d, getResources().getDisplayMetrics());
}
private void hideStatusBar() {
2025-08-28 05:11:34 -04:00
// Make immersive (hide status + navigation) and allow swipe to reveal temporarily
2025-08-22 04:11:11 -04:00
Window w = getWindow();
if (w == null) return;
2025-08-29 15:47:14 -04:00
// Request full screen window flags for additional assurance
w.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
// Allow drawing into display cutouts (notches) on API 28+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
WindowManager.LayoutParams lp = w.getAttributes();
lp.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
w.setAttributes(lp);
}
2025-09-11 23:14:41 -04:00
// Common flags for legacy immersive (works as belt-and-suspenders on newer API too)
final int legacyFlags = View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION;
2025-08-22 04:11:11 -04:00
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
2025-08-28 05:11:34 -04:00
// Tell Window to lay out edge-to-edge and hide all system bars
w.setDecorFitsSystemWindows(false);
2025-08-22 04:11:11 -04:00
WindowInsetsController controller = w.getInsetsController();
if (controller != null) {
2025-08-28 05:11:34 -04:00
controller.hide(WindowInsets.Type.systemBars());
2025-08-22 04:11:11 -04:00
controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
2025-09-11 23:14:41 -04:00
// Also apply legacy flags to decor; helps on 3-button nav devices
View decor = w.getDecorView();
decor.setSystemUiVisibility(legacyFlags);
decor.setOnSystemUiVisibilityChangeListener(vis -> {
if ((vis & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) {
decor.setSystemUiVisibility(legacyFlags);
}
});
2025-08-22 04:11:11 -04:00
} else {
2025-08-28 05:11:34 -04:00
View decor = w.getDecorView();
2025-09-11 23:14:41 -04:00
decor.setSystemUiVisibility(legacyFlags);
decor.setOnSystemUiVisibilityChangeListener(vis -> {
if ((vis & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) {
decor.setSystemUiVisibility(legacyFlags);
}
});
2025-08-22 04:11:11 -04:00
}
}
2025-09-01 15:36:45 -04:00
// Enable immersive mode to hide navigation and status bars
private void enableImmersiveMode() {
hideStatusBar();
}
2025-08-28 05:11:34 -04:00
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) hideStatusBar();
}
2025-09-06 02:07:48 -04:00
public void pickGamesFolder() {
2025-08-22 04:11:11 -04:00
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
startActivityResultGamesFolderPick.launch(intent);
}
2025-09-06 02:07:48 -04:00
// Let the user select a data root (SAF) where app folders/files live (covers, resources, saves, etc.)
public void pickDataRootFolder() {
startActivityResultDataRootPick.launch(SafManager.buildOpenTreeIntent());
}
2025-08-22 04:11:11 -04:00
private void showGamesListOrReselect(Uri treeUri) {
// Re-scan quickly each time to keep list fresh
String[] names;
String[] uris;
try {
GameList list = scanGamesFromTreeUri(treeUri);
names = list.names;
uris = list.uris;
} catch (Exception e) {
names = new String[0];
uris = new String[0];
}
// Make effectively-final copies for use in lambda
final String[] namesFinal = names;
final String[] urisFinal = uris;
if (namesFinal.length == 0) {
2025-08-25 07:58:09 -04:00
new MaterialAlertDialogBuilder(this,
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
2025-09-06 02:07:48 -04:00
.setCustomTitle(UiUtils.centeredDialogTitle(this, "GAMES"))
2025-08-22 04:11:11 -04:00
.setMessage("No games found. Pick a folder?")
.setNegativeButton("Cancel", null)
.setPositiveButton("Pick Folder", (d,w) -> pickGamesFolder())
.show();
return;
}
// Show covers grid dialog fragment
GamesCoverDialogFragment frag = GamesCoverDialogFragment.newInstance(namesFinal, urisFinal);
FragmentManager fm = getSupportFragmentManager();
frag.show(fm, "covers_dialog");
}
@Override
public void onGameSelected(String gameUri) {
if (!TextUtils.isEmpty(gameUri)) {
// Avoid any pre-VM native calls here; just set the game and launch.
m_szGamefile = gameUri;
2025-09-01 15:36:45 -04:00
// Record last played timestamp for sorting
try {
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
prefs.edit().putLong("last_played:" + gameUri, System.currentTimeMillis()).apply();
} catch (Throwable ignored) {}
2025-08-22 04:11:11 -04:00
restartEmuThread();
}
}
private static final String[] GAME_EXTS = new String[]{
2025-09-11 23:14:41 -04:00
".iso", ".bin", ".img", ".mdf", ".nrg", ".chd", ".cso", ".zso", ".gz"
2025-08-22 04:11:11 -04:00
};
private static boolean hasGameExt(String name) {
if (TextUtils.isEmpty(name)) return false;
String lower = name.toLowerCase(Locale.ROOT);
2025-08-22 04:11:11 -04:00
for (String ext : GAME_EXTS) {
if (lower.endsWith(ext)) return true;
}
return false;
}
private static class GameList {
final String[] names;
final String[] uris;
GameList(String[] n, String[] u) { names = n; uris = u; }
}
private GameList scanGamesFromTreeUri(Uri treeUri) {
DocumentFile dir = DocumentFile.fromTreeUri(this, treeUri);
if (dir == null || !dir.isDirectory()) return new GameList(new String[0], new String[0]);
java.util.ArrayList<String> nameList = new java.util.ArrayList<>();
java.util.ArrayList<String> uriList = new java.util.ArrayList<>();
scanGamesRecursive(dir, nameList, uriList);
// Persist folder and latest list
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
JSONArray arr = new JSONArray();
for (int i = 0; i < nameList.size(); i++) {
JSONArray pair = new JSONArray();
try {
pair.put(nameList.get(i));
pair.put(uriList.get(i));
} catch (Exception ignored) {}
arr.put(pair);
}
prefs.edit()
.putString("games_folder_uri", treeUri.toString())
.putString("games_list_json", arr.toString())
.apply();
return new GameList(nameList.toArray(new String[0]), uriList.toArray(new String[0]));
}
private void scanGamesRecursive(DocumentFile dir, java.util.List<String> names, java.util.List<String> uris) {
DocumentFile[] children = dir.listFiles();
if (children == null) return;
for (DocumentFile child : children) {
if (child == null) continue;
if (child.isDirectory()) {
scanGamesRecursive(child, names, uris);
} else if (child.isFile()) {
String name = child.getName();
if (hasGameExt(name)) {
names.add(name);
uris.add(child.getUri().toString());
}
}
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
2025-08-29 15:47:14 -04:00
// Hide action bar to improve immersive appearance
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
2025-08-22 04:11:11 -04:00
setContentView(R.layout.activity_main);
2025-09-01 15:36:45 -04:00
enableImmersiveMode();
2025-08-22 04:11:11 -04:00
// Setup back button handler
setupBackPressedHandler();
// Default resources
copyAssetAll(getApplicationContext(), "resources");
2025-09-06 02:07:48 -04:00
// If a SAF data root is set, mirror resources to it (first time only)
copyAssetsToSafDataRoot();
2025-08-22 04:11:11 -04:00
Initialize();
2025-08-25 13:56:13 -04:00
// Initialize controller input handler
mControllerInputHandler = new ControllerInputHandler(this);
// Log connected controllers for debugging
ControllerConfig.logControllerInfo(this);
2025-08-22 04:11:11 -04:00
makeButtonTouch();
2025-08-28 08:11:50 -04:00
updateRendererButtonLabel();
2025-08-22 04:11:11 -04:00
setSurfaceView(new SDLSurface(this));
2025-08-24 09:53:35 -04:00
// Ensure consistent ripple across all MaterialButtons
2025-08-22 04:11:11 -04:00
tintAllMaterialButtonOutlines();
// Apply orientation-specific constraints once at startup
int currentOrientation = getResources().getConfiguration().orientation;
applyConstraintsForOrientation(currentOrientation);
2025-09-01 15:36:45 -04:00
2025-09-06 02:07:48 -04:00
// Prompt for BIOS if missing, but only after first-run setup
if (getSharedPreferences("app_prefs", MODE_PRIVATE).getBoolean("first_run_done", false)) {
maybePromptForBios();
}
// Listen for controller attach/detach and update UI accordingly
mInputManager = (InputManager) getSystemService(Context.INPUT_SERVICE);
if (mInputManager != null) {
mInputManager.registerInputDeviceListener(mInputDeviceListener, null);
}
updateUiForControllerPresence();
2025-09-06 02:07:48 -04:00
2025-09-10 09:24:01 -04:00
// After initial BIOS auto-boot, gently open the Games dialog
// Only when setup is complete and this is app launch (not rotation)
if (getSharedPreferences("app_prefs", MODE_PRIVATE).getBoolean("first_run_done", false)) {
try {
final View decor = (getWindow() != null) ? getWindow().getDecorView() : null;
if (decor != null) {
decor.postDelayed(() -> {
if (!isFinishing() && !mSetupWizardActive) {
openGamesDialog();
}
}, 1600); // small delay to let BIOS boot briefly
}
} catch (Throwable ignored) {}
}
2025-09-06 02:07:48 -04:00
// Show first-run setup wizard if needed
if (!getSharedPreferences("app_prefs", MODE_PRIVATE).getBoolean("first_run_done", false)) {
SetupWizardDialogFragment f = SetupWizardDialogFragment.newInstance();
f.setCancelable(false);
f.show(getSupportFragmentManager(), "setup_wizard");
}
}
public void setSetupWizardActive(boolean active) {
mSetupWizardActive = active;
}
// Public method to open the games covers dialog via controller quick actions
public void openGamesDialog() {
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
String folderUri = prefs.getString("games_folder_uri", null);
if (TextUtils.isEmpty(folderUri)) {
pickGamesFolder();
} else {
showGamesListOrReselect(Uri.parse(folderUri));
}
2025-08-22 04:11:11 -04:00
}
private void setupBackPressedHandler() {
OnBackPressedCallback callback = new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
showExitDialog();
}
};
getOnBackPressedDispatcher().addCallback(this, callback);
}
private void makeButtonTouch() {
2025-09-10 09:24:01 -04:00
// SAVES button removed from main UI; access Save States via drawer or Quick Actions
2025-08-22 04:11:11 -04:00
2025-09-10 09:24:01 -04:00
// Menu button opens left drawer
2025-08-22 04:11:11 -04:00
MaterialButton btn_settings = findViewById(R.id.btn_settings);
if (btn_settings != null) {
btn_settings.setOnClickListener(v -> {
2025-09-10 09:24:01 -04:00
try {
2025-09-11 23:14:41 -04:00
// Refresh drawer settings before opening
refreshDrawerSettings();
2025-09-10 09:24:01 -04:00
DrawerLayout drawer = findViewById(R.id.drawer_layout);
if (drawer != null) drawer.openDrawer(androidx.core.view.GravityCompat.START);
} catch (Throwable ignored) {}
2025-08-22 04:11:11 -04:00
});
}
2025-09-11 23:14:41 -04:00
// Pause/Play button toggles emulation pause state
MaterialButton btn_pause_play = findViewById(R.id.btn_pause_play);
if (btn_pause_play != null) {
btn_pause_play.setOnClickListener(v -> {
try {
togglePauseState();
} catch (Throwable ignored) {}
});
}
2025-09-10 09:24:01 -04:00
// Wire left drawer header controls (title/power/reboot/renderer)
try {
NavigationView nav = findViewById(R.id.nav_view);
if (nav != null) {
View header = (nav.getHeaderCount() > 0) ? nav.getHeaderView(0) : nav.inflateHeaderView(R.layout.drawer_header_settings);
if (header != null) {
View btnPower = header.findViewById(R.id.drawer_btn_power);
if (btnPower != null) {
btnPower.setOnClickListener(v -> {
new MaterialAlertDialogBuilder(this)
.setTitle("Power Off")
.setMessage("Quit the app?")
.setNegativeButton("Cancel", null)
.setPositiveButton("Quit", (d,w) -> { try { NativeApp.shutdown(); } catch (Throwable ignored) {} finishAffinity(); finishAndRemoveTask(); System.exit(0); })
.show();
});
}
View btnReboot = header.findViewById(R.id.drawer_btn_reboot);
if (btnReboot != null) {
btnReboot.setOnClickListener(v -> {
new MaterialAlertDialogBuilder(this)
.setTitle("Reboot")
.setMessage("Restart the current game?")
.setNegativeButton("Cancel", null)
.setPositiveButton("Reboot", (d,w) -> rebootEmu())
.show();
});
}
MaterialButtonToggleGroup tg = header.findViewById(R.id.drawer_tg_renderer);
View btnGameState = header.findViewById(R.id.drawer_btn_game_state);
View tbAt = header.findViewById(R.id.drawer_tb_at);
View tbVk = header.findViewById(R.id.drawer_tb_vk);
View tbGl = header.findViewById(R.id.drawer_tb_gl);
View tbSw = header.findViewById(R.id.drawer_tb_sw);
if (tg != null) {
int current = -1;
try { current = NativeApp.getCurrentRenderer(); } catch (Throwable ignored) {}
if (current == 14 && tbVk != null) tg.check(tbVk.getId());
else if (current == 12 && tbGl != null) tg.check(tbGl.getId());
else if (current == 13 && tbSw != null) tg.check(tbSw.getId());
else if (tbAt != null) tg.check(tbAt.getId());
tg.addOnButtonCheckedListener((group, checkedId, isChecked) -> {
if (!isChecked) return;
int r = -1;
if (checkedId == (tbVk != null ? tbVk.getId() : -2)) r = 14;
else if (checkedId == (tbGl != null ? tbGl.getId() : -2)) r = 12;
else if (checkedId == (tbSw != null ? tbSw.getId() : -2)) r = 13;
else r = -1;
try {
getSharedPreferences("app_prefs", MODE_PRIVATE).edit().putInt("renderer", r).apply();
NativeApp.renderGpu(r);
} catch (Throwable ignored) {}
});
}
2025-09-11 23:14:41 -04:00
View btnGames = header.findViewById(R.id.drawer_btn_games);
if (btnGames != null) {
btnGames.setOnClickListener(v -> {
try {
// Close drawer first
DrawerLayout drawer = findViewById(R.id.drawer_layout);
if (drawer != null) drawer.closeDrawer(androidx.core.view.GravityCompat.START);
// Open games dialog
openGamesDialog();
} catch (Throwable ignored) {}
});
}
2025-09-10 09:24:01 -04:00
if (btnGameState != null) {
btnGameState.setOnClickListener(v -> {
try {
SavesDialogFragment dialog = new SavesDialogFragment();
dialog.show(getSupportFragmentManager(), "saves_dialog");
} catch (Throwable ignored) {}
});
}
2025-09-11 23:14:41 -04:00
// Setup drawer settings controls to mirror quick actions
setupDrawerSettings(header);
2025-09-10 09:24:01 -04:00
}
}
} catch (Throwable ignored) {}
// Visibility toggle removed
2025-08-22 04:11:11 -04:00
// PAD
MaterialButton btn_pad_select = findViewById(R.id.btn_pad_select);
if(btn_pad_select != null) {
btn_pad_select.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_BUTTON_SELECT);
return true;
});
}
MaterialButton btn_pad_start = findViewById(R.id.btn_pad_start);
if(btn_pad_start != null) {
btn_pad_start.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_BUTTON_START);
return true;
});
}
MaterialButton btn_pad_a = findViewById(R.id.btn_pad_a);
if(btn_pad_a != null) {
btn_pad_a.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_BUTTON_A);
return true;
});
}
MaterialButton btn_pad_b = findViewById(R.id.btn_pad_b);
if(btn_pad_b != null) {
btn_pad_b.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_BUTTON_B);
return true;
});
}
MaterialButton btn_pad_x = findViewById(R.id.btn_pad_x);
if(btn_pad_x != null) {
btn_pad_x.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_BUTTON_X);
return true;
});
}
MaterialButton btn_pad_y = findViewById(R.id.btn_pad_y);
if(btn_pad_y != null) {
btn_pad_y.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_BUTTON_Y);
return true;
});
}
2025-09-01 15:36:45 -04:00
//// Shoulder buttons
2025-08-22 04:11:11 -04:00
MaterialButton btn_pad_l1 = findViewById(R.id.btn_pad_l1);
if(btn_pad_l1 != null) {
btn_pad_l1.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_BUTTON_L1);
return true;
});
}
MaterialButton btn_pad_r1 = findViewById(R.id.btn_pad_r1);
if(btn_pad_r1 != null) {
btn_pad_r1.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_BUTTON_R1);
return true;
});
}
MaterialButton btn_pad_l2 = findViewById(R.id.btn_pad_l2);
if(btn_pad_l2 != null) {
btn_pad_l2.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_BUTTON_L2);
return true;
});
}
MaterialButton btn_pad_r2 = findViewById(R.id.btn_pad_r2);
if(btn_pad_r2 != null) {
btn_pad_r2.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_BUTTON_R2);
return true;
});
}
MaterialButton btn_pad_l3 = findViewById(R.id.btn_pad_l3);
if(btn_pad_l3 != null) {
btn_pad_l3.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_BUTTON_THUMBL);
return true;
});
}
MaterialButton btn_pad_r3 = findViewById(R.id.btn_pad_r3);
if(btn_pad_r3 != null) {
btn_pad_r3.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_BUTTON_THUMBR);
return true;
});
}
2025-09-01 15:36:45 -04:00
//// D-Pad
2025-08-22 04:11:11 -04:00
final int PAD_L_UP = 110;
final int PAD_L_RIGHT = 111;
final int PAD_L_DOWN = 112;
final int PAD_L_LEFT = 113;
final int PAD_R_UP = 120;
final int PAD_R_RIGHT = 121;
final int PAD_R_DOWN = 122;
final int PAD_R_LEFT = 123;
// Draggable JoystickView (portrait/landscape layouts)
View joystick = findViewById(R.id.joystick_view);
if (joystick instanceof JoystickView) {
JoystickView jv = (JoystickView) joystick;
jv.setOnMoveListener((nx, ny, action) -> {
// Thresholds
final float T = 0.3f;
boolean up = ny < -T;
boolean down = ny > T;
boolean left = nx < -T;
boolean right = nx > T;
// Issue key down/up transitions only when state changes
if (up != joyUpPressed) {
sendKeyAction(jv, up ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP, PAD_L_UP);
joyUpPressed = up;
}
if (down != joyDownPressed) {
sendKeyAction(jv, down ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP, PAD_L_DOWN);
joyDownPressed = down;
}
if (left != joyLeftPressed) {
sendKeyAction(jv, left ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP, PAD_L_LEFT);
joyLeftPressed = left;
}
if (right != joyRightPressed) {
sendKeyAction(jv, right ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP, PAD_L_RIGHT);
joyRightPressed = right;
}
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
// Ensure all released
if (joyUpPressed) sendKeyAction(jv, MotionEvent.ACTION_UP, PAD_L_UP);
if (joyDownPressed) sendKeyAction(jv, MotionEvent.ACTION_UP, PAD_L_DOWN);
if (joyLeftPressed) sendKeyAction(jv, MotionEvent.ACTION_UP, PAD_L_LEFT);
if (joyRightPressed) sendKeyAction(jv, MotionEvent.ACTION_UP, PAD_L_RIGHT);
joyUpPressed = joyDownPressed = joyLeftPressed = joyRightPressed = false;
}
});
}
2025-09-01 15:36:45 -04:00
//// D-Pad buttons
2025-08-22 04:11:11 -04:00
MaterialButton btn_pad_dir_top = findViewById(R.id.btn_pad_dir_top);
if(btn_pad_dir_top != null) {
btn_pad_dir_top.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_DPAD_UP);
return true;
});
}
MaterialButton btn_pad_dir_bottom = findViewById(R.id.btn_pad_dir_bottom);
if(btn_pad_dir_bottom != null) {
btn_pad_dir_bottom.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_DPAD_DOWN);
return true;
});
}
MaterialButton btn_pad_dir_left = findViewById(R.id.btn_pad_dir_left);
if(btn_pad_dir_left != null) {
btn_pad_dir_left.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_DPAD_LEFT);
return true;
});
}
MaterialButton btn_pad_dir_right = findViewById(R.id.btn_pad_dir_right);
if(btn_pad_dir_right != null) {
btn_pad_dir_right.setOnTouchListener((v, event) -> {
sendKeyAction(v, event.getAction(), KeyEvent.KEYCODE_DPAD_RIGHT);
return true;
});
}
}
2025-09-10 09:24:01 -04:00
// Visibility toggle removed; no global hidden state flag
2025-08-22 04:11:11 -04:00
private void setControlsVisible(boolean visible) {
int vis = visible ? View.VISIBLE : View.GONE;
View llDpad = findViewById(R.id.ll_pad_dpad);
View llRight = findViewById(R.id.ll_pad_right_buttons);
View llSelectStart = findViewById(R.id.ll_pad_select_start);
View llJoy = findViewById(R.id.ll_pad_joy);
if (llDpad != null) llDpad.setVisibility(vis);
if (llRight != null) llRight.setVisibility(vis);
if (llSelectStart != null) llSelectStart.setVisibility(vis);
if (llJoy != null) llJoy.setVisibility(vis);
}
2025-09-10 09:24:01 -04:00
// Removed visibility toggle function
2025-08-28 08:11:50 -04:00
// --- Controller presence handling ---
private final InputManager.InputDeviceListener mInputDeviceListener = new InputManager.InputDeviceListener() {
@Override public void onInputDeviceAdded(int deviceId) { updateUiForControllerPresence(); }
@Override public void onInputDeviceRemoved(int deviceId) { updateUiForControllerPresence(); }
@Override public void onInputDeviceChanged(int deviceId) { updateUiForControllerPresence(); }
};
private boolean isControllerDevice(InputDevice device) {
if (device == null) return false;
// Filter out virtual/built-in devices which can falsely report DPAD sources
try {
if (device.isVirtual()) return false;
} catch (Throwable ignored) {}
// Heuristic: devices with both vendor and product = 0 are often virtual
try {
if (device.getVendorId() == 0 && device.getProductId() == 0) return false;
} catch (Throwable ignored) {}
// Filter by name for common virtual/built-in inputs
String name = device.getName();
if (name != null) {
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;
}
}
int sources = device.getSources();
boolean gamepad = (sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
boolean joystick = (sources & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK;
boolean dpad = (sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD;
// Consider DPAD-only devices as controllers only if they are external (heuristic above)
return gamepad || joystick || dpad;
}
private boolean isAnyControllerConnected() {
try {
int[] ids = InputDevice.getDeviceIds();
for (int id : ids) {
InputDevice dev = InputDevice.getDevice(id);
if (isControllerDevice(dev)) return true;
}
} catch (Throwable ignored) {}
return false;
}
private void updateUiForControllerPresence() {
boolean hasController = isAnyControllerConnected();
// Show all UI when no controller; hide UI when a controller is connected.
int vis = hasController ? View.GONE : View.VISIBLE;
View btnSettings = findViewById(R.id.btn_settings);
View llQuickActions = findViewById(R.id.ll_quick_actions);
2025-09-10 09:24:01 -04:00
// Keep menu (settings) button visible even when a controller is connected
if (btnSettings != null) btnSettings.setVisibility(View.VISIBLE);
if (llQuickActions != null) llQuickActions.setVisibility(vis);
// Hide on-screen touch controls when a physical controller is connected
setControlsVisible(!hasController);
2025-09-11 23:14:41 -04:00
// Show controller hint when no controller is connected (only once)
if (!hasController) {
showControllerHintIfNeeded();
}
}
2025-08-28 08:11:50 -04:00
private int getCurrentRendererPref() {
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
2025-08-30 20:39:56 -04:00
// Default to -1 (Automatic) if not set
return prefs.getInt("renderer", -1);
2025-08-28 08:11:50 -04:00
}
private String rendererShortLabel(int r) {
2025-08-30 20:39:56 -04:00
if (r == -1) return "AUTO";
2025-08-28 08:11:50 -04:00
if (r == 12) return "OGL";
if (r == 13) return "SW";
return "VK"; // 14
}
2025-09-06 02:07:48 -04:00
2025-08-28 08:11:50 -04:00
private void updateRendererButtonLabel() {
2025-09-10 09:24:01 -04:00
// No-op: renderer label now handled in drawer
}
2025-08-28 08:11:50 -04:00
2025-09-10 09:24:01 -04:00
// Public minimal UI refresh hook for dialogs
2025-09-02 10:36:29 -04:00
public void refreshQuickUi() {
updateRendererButtonLabel();
}
2025-08-28 08:11:50 -04:00
private void setRendererAndSave(int renderer) {
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
prefs.edit().putInt("renderer", renderer).apply();
2025-08-28 08:11:50 -04:00
// Apply live if possible; this path has proven stable via top bar buttons
try {
NativeApp.renderGpu(renderer);
} catch (Throwable t) {
android.util.Log.e("MainActivity", "Renderer switch failed: " + t.getMessage());
}
updateRendererButtonLabel();
2025-08-22 04:11:11 -04:00
}
private void tintAllMaterialButtonOutlines() {
2025-08-24 09:53:35 -04:00
// Ripple + specific color tweaks, no strokes (transparent container style)
2025-08-22 04:11:11 -04:00
View root = findViewById(android.R.id.content);
if (root instanceof ViewGroup) {
2025-08-24 09:53:35 -04:00
traverseAndTintButtons((ViewGroup) root);
2025-08-22 04:11:11 -04:00
}
}
2025-08-24 09:53:35 -04:00
private void traverseAndTintButtons(ViewGroup group) {
2025-08-22 04:11:11 -04:00
for (int i = 0; i < group.getChildCount(); i++) {
View child = group.getChildAt(i);
if (child instanceof ViewGroup) {
2025-08-24 09:53:35 -04:00
traverseAndTintButtons((ViewGroup) child);
2025-08-22 04:11:11 -04:00
}
if (child instanceof MaterialButton) {
MaterialButton mb = (MaterialButton) child;
// Ensure ripple matches brand globally
mb.setRippleColor(ColorStateList.valueOf(ContextCompat.getColor(this, R.color.brand_ripple)));
int id = mb.getId();
2025-08-24 09:53:35 -04:00
if (id == R.id.btn_pad_y) { // Triangle = green
2025-08-22 04:11:11 -04:00
final int base = ContextCompat.getColor(this, R.color.ps2_triangle_green);
ColorStateList stateful = pressedColorStateList(base);
mb.setTextColor(stateful);
} else if (id == R.id.btn_pad_b) { // Circle = red
final int base = ContextCompat.getColor(this, R.color.ps2_circle_red);
ColorStateList stateful = pressedColorStateList(base);
mb.setTextColor(stateful);
} else if (id == R.id.btn_pad_a) { // Cross = blue
final int base = ContextCompat.getColor(this, R.color.ps2_cross_blue);
ColorStateList stateful = pressedColorStateList(base);
mb.setTextColor(stateful);
} else if (id == R.id.btn_pad_x) { // Square = pink
final int base = ContextCompat.getColor(this, R.color.ps2_square_pink);
ColorStateList stateful = pressedColorStateList(base);
mb.setTextColor(stateful);
} else if (id == R.id.btn_pad_dir_top || id == R.id.btn_pad_dir_left || id == R.id.btn_pad_dir_right || id == R.id.btn_pad_dir_bottom) {
// D-pad arrow icon tint to brand accent
ColorStateList iconTint = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.brand_accent));
mb.setIconTint(iconTint);
}
2025-08-24 09:53:35 -04:00
// No stroke; background is transparent per style
2025-08-22 04:11:11 -04:00
}
}
}
2025-08-28 08:11:50 -04:00
// --- BIOS presence check and prompt ---
private boolean hasAnyBiosFiles(File dir) {
if (dir == null || !dir.exists()) return false;
File[] files = dir.listFiles();
if (files == null) return false;
for (File f : files) {
if (f == null) continue;
if (f.isDirectory()) {
if (hasAnyBiosFiles(f)) return true;
} else {
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(Locale.ROOT);
2025-08-28 08:11:50 -04:00
boolean isMainBios = lower.startsWith("scph") && (lower.endsWith(".bin") || lower.endsWith(".rom"));
boolean isComponentSuffix = lower.endsWith(".rom0") || lower.endsWith(".rom1") || lower.endsWith(".rom2") || lower.endsWith(".erom");
boolean isBareComponent = lower.equals("rom0") || lower.equals("rom1") || lower.equals("rom2") || lower.equals("erom");
if (isMainBios) {
if (f.length() >= 256 * 1024) return true; // main BIOS typically >= 2MB, but allow 256KB+
} else if (isComponentSuffix || isBareComponent) {
if (f.length() >= 64 * 1024) return true; // component ROMs can be smaller
} else if (lower.endsWith(".bin") || lower.endsWith(".rom")) {
// Fallback: any .bin/.rom over 256KB
if (f.length() >= 256 * 1024) return true;
}
}
}
}
return false;
}
private void maybePromptForBios() {
2025-09-06 02:07:48 -04:00
// Temporarily disable automatic BIOS prompt
if (!getSharedPreferences("app_prefs", MODE_PRIVATE).getBoolean("bios_auto_prompt_enabled", false))
return;
2025-08-28 08:11:50 -04:00
File biosDir = new File(getApplicationContext().getExternalFilesDir(null), "bios");
if (hasAnyBiosFiles(biosDir)) return;
showBiosPrompt();
}
private boolean ensureBiosOrPrompt() {
2025-09-06 02:07:48 -04:00
// Temporarily disable automatic BIOS prompting; wizard handles manual import
return true;
2025-08-28 08:11:50 -04:00
}
2025-09-06 02:07:48 -04:00
public void showBiosPrompt() {
2025-08-28 08:11:50 -04:00
if (mBiosPromptDialog != null && mBiosPromptDialog.isShowing()) return;
mBiosPromptDialog = new com.google.android.material.dialog.MaterialAlertDialogBuilder(this,
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
2025-09-06 02:07:48 -04:00
.setCustomTitle(UiUtils.centeredDialogTitle(this, "BIOS Required"))
2025-08-28 08:11:50 -04:00
.setMessage("No PS2 BIOS detected. Import your BIOS files to run games.\n\nHint: Press Select+Start for Quick Actions.")
.setNegativeButton("Later", (d, w) -> { /* leave dialog dismiss */ })
.setPositiveButton("Pick Files", (d, w) -> {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
intent.setType("*/*");
startActivityResultBiosPick.launch(intent);
})
.setNeutralButton("Pick Folder", (d, w) -> {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
startActivityResultBiosFolderPick.launch(intent);
})
.create();
mBiosPromptDialog.setOnDismissListener(dialog -> mBiosPromptDialog = null);
mBiosPromptDialog.show();
}
private void dismissBiosPromptIfResolved() {
File biosDir = new File(getApplicationContext().getExternalFilesDir(null), "bios");
if (hasAnyBiosFiles(biosDir)) {
if (mBiosPromptDialog != null && mBiosPromptDialog.isShowing()) {
try { mBiosPromptDialog.dismiss(); } catch (Exception ignored) {}
}
}
}
2025-08-22 04:11:11 -04:00
private ColorStateList pressedColorStateList(int base) {
int pressed = darkenColor(base, 0.85f); // 15% darker when pressed
int[][] states = new int[][]{
new int[]{android.R.attr.state_pressed},
new int[]{}
};
int[] colors = new int[]{
pressed,
base
};
return new ColorStateList(states, colors);
}
private int darkenColor(int color, float factor) {
int a = (color >> 24) & 0xFF;
int r = (color >> 16) & 0xFF;
int g = (color >> 8) & 0xFF;
int b = color & 0xFF;
r = Math.max(0, Math.min(255, (int)(r * factor)));
g = Math.max(0, Math.min(255, (int)(g * factor)));
b = Math.max(0, Math.min(255, (int)(b * factor)));
return (a << 24) | (r << 16) | (g << 8) | b;
}
private void applySavedSettings() {
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
2025-08-22 08:04:47 -04:00
// Renderer constants (match SettingsDialogFragment)
final int RENDERER_OPENGL = 12;
final int RENDERER_SOFTWARE = 13;
final int RENDERER_VULKAN = 14;
2025-09-06 02:07:48 -04:00
// Default to Automatic (-1) so the core can select a compatible renderer on older devices
int renderer = prefs.getInt("renderer", -1);
2025-08-22 04:11:11 -04:00
NativeApp.renderGpu(renderer);
// Resolution scale multiplier (float), default 1.0
float scale = prefs.getFloat("upscale_multiplier", 1.0f);
NativeApp.renderUpscalemultiplier(scale);
// Aspect ratio: 0=Stretch, 1=Auto 4:3/3:2, 2=4:3, 3=16:9, 4=10:7
int aspectRatio = prefs.getInt("aspect_ratio", 1); // Default to Auto 4:3/3:2
NativeApp.setAspectRatio(aspectRatio);
// Widescreen patches
2025-09-02 09:21:48 -04:00
boolean widescreenPatches = prefs.getBoolean("widescreen_patches", true);
2025-08-22 04:11:11 -04:00
NativeApp.setWidescreenPatches(widescreenPatches);
// No interlacing patches
2025-09-02 09:21:48 -04:00
boolean noInterlacingPatches = prefs.getBoolean("no_interlacing_patches", true);
2025-08-22 04:11:11 -04:00
NativeApp.setNoInterlacingPatches(noInterlacingPatches);
2025-08-22 08:04:47 -04:00
// Texture loading options
boolean loadTextures = prefs.getBoolean("load_textures", false);
NativeApp.setLoadTextures(loadTextures);
boolean asyncTextureLoading = prefs.getBoolean("async_texture_loading", true);
NativeApp.setAsyncTextureLoading(asyncTextureLoading);
2025-08-22 04:11:11 -04:00
// HUD visibility
boolean hudVisible = prefs.getBoolean("hud_visible", false);
NativeApp.setHudVisible(hudVisible);
2025-08-22 08:04:47 -04:00
// Set brighter default brightness (60 instead of 50)
NativeApp.setShadeBoost(true);
NativeApp.setShadeBoostBrightness(60);
NativeApp.setShadeBoostContrast(50);
NativeApp.setShadeBoostSaturation(50);
2025-08-22 04:11:11 -04:00
}
public final ActivityResultLauncher<Intent> startActivityResultLocalFilePlay = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if(result.getResultCode() == Activity.RESULT_OK) {
try {
Intent _intent = result.getData();
if(_intent != null) {
m_szGamefile = _intent.getDataString();
if(!TextUtils.isEmpty(m_szGamefile)) {
2025-09-01 15:36:45 -04:00
try {
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
prefs.edit().putLong("last_played:" + m_szGamefile, System.currentTimeMillis()).apply();
} catch (Throwable ignored) {}
2025-08-22 04:11:11 -04:00
restartEmuThread();
}
}
}
catch (Exception ignored) {}
}
}
);
public final ActivityResultLauncher<Intent> startActivityResultBiosPick = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK) {
try {
Intent data = result.getData();
if (data != null) {
File biosDir = new File(getApplicationContext().getExternalFilesDir(null), "bios");
if (!biosDir.exists()) biosDir.mkdirs();
ClipData clipData = data.getClipData();
if (clipData != null && clipData.getItemCount() > 0) {
for (int i = 0; i < clipData.getItemCount(); i++) {
Uri uri = clipData.getItemAt(i).getUri();
importSingleBiosUri(uri, biosDir);
}
} else {
Uri uri = data.getData();
if (uri != null) {
importSingleBiosUri(uri, biosDir);
}
}
2025-08-28 08:11:50 -04:00
// If BIOS now present, dismiss the prompt
dismissBiosPromptIfResolved();
2025-08-22 04:11:11 -04:00
}
} catch (Exception ignored) {}
}
});
public final ActivityResultLauncher<Intent> startActivityResultBiosFolderPick = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK) {
try {
Intent data = result.getData();
if (data != null) {
Uri treeUri = data.getData();
if (treeUri != null) {
// Persist read permission for future imports, optional
final int takeFlags = (data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION));
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);
}
2025-08-22 04:11:11 -04:00
DocumentFile pickedDir = DocumentFile.fromTreeUri(this, treeUri);
if (pickedDir != null && pickedDir.isDirectory()) {
File biosDir = new File(getApplicationContext().getExternalFilesDir(null), "bios");
if (!biosDir.exists()) biosDir.mkdirs();
copyDocumentTreeToDirectory(pickedDir, biosDir);
2025-08-28 08:11:50 -04:00
// If BIOS now present, dismiss the prompt
dismissBiosPromptIfResolved();
2025-08-22 04:11:11 -04:00
}
}
}
} catch (Exception ignored) {}
}
});
public final ActivityResultLauncher<Intent> startActivityResultGamesFolderPick = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK) {
try {
Intent data = result.getData();
if (data != null) {
Uri treeUri = data.getData();
if (treeUri != null) {
final int takeFlags = (data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION));
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) {}
}
2025-09-06 02:07:48 -04:00
// Save folder and optionally show games
2025-08-22 04:11:11 -04:00
getSharedPreferences("app_prefs", MODE_PRIVATE)
.edit()
.putString("games_folder_uri", treeUri.toString())
.apply();
2025-09-06 02:07:48 -04:00
if (!mSetupWizardActive) {
showGamesListOrReselect(treeUri);
} else {
Toast.makeText(this, "Games folder set", Toast.LENGTH_SHORT).show();
}
2025-08-22 04:11:11 -04:00
}
}
} catch (Exception ignored) {}
}
});
2025-09-06 02:07:48 -04:00
// SAF data-root picker result
public final ActivityResultLauncher<Intent> startActivityResultDataRootPick = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK) {
try {
Intent data = result.getData();
if (data != null) {
Uri treeUri = data.getData();
if (treeUri != null) {
final int takeFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
try { getContentResolver().takePersistableUriPermission(treeUri, takeFlags); } catch (SecurityException ignored) {}
SafManager.setDataRootUri(this, treeUri);
// Seed default resources to SAF data root
copyAssetsToSafDataRoot();
Toast.makeText(this, "Data folder set", Toast.LENGTH_SHORT).show();
}
}
} catch (Exception ignored) {}
}
});
// Copies assets/resources under the selected SAF data root (resources/..)
private void copyAssetsToSafDataRoot() {
Uri root = SafManager.getDataRootUri(this);
if (root == null) return;
// Only seed once
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
if (prefs.getBoolean("saf_resources_seeded", false)) return;
// Flatten copy of assets/resources directory to SAF
try {
copyAssetAllToSaf(getApplicationContext(), "resources");
prefs.edit().putBoolean("saf_resources_seeded", true).apply();
} catch (Throwable ignored) {}
}
2025-08-22 04:11:11 -04:00
private void importSingleBiosUri(Uri uri, File biosDir) {
if (uri == null) return;
String displayName = getDisplayNameFromUri(this, uri);
if (TextUtils.isEmpty(displayName)) displayName = "bios.bin";
File outFile = new File(biosDir, displayName);
copyUriToFile(this, uri, outFile);
2025-09-06 02:07:48 -04:00
// Also mirror to SAF data root if set
android.net.Uri dataRoot = SafManager.getDataRootUri(this);
if (dataRoot != null) {
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(this, new String[]{"bios"}, displayName, "application/octet-stream");
if (target != null) {
try (java.io.InputStream in = getContentResolver().openInputStream(uri)) {
SafManager.copyFromStream(this, in, target.getUri());
} catch (Exception ignored) {}
}
}
2025-08-22 04:11:11 -04:00
}
private void copyDocumentTreeToDirectory(DocumentFile dir, File outDir) {
if (dir == null || !dir.isDirectory()) return;
DocumentFile[] children = dir.listFiles();
if (children == null) return;
for (DocumentFile child : children) {
if (child == null) continue;
if (child.isDirectory()) {
File sub = new File(outDir, child.getName() != null ? child.getName() : "folder");
if (!sub.exists()) sub.mkdirs();
copyDocumentTreeToDirectory(child, sub);
} else if (child.isFile()) {
String name = child.getName();
if (TextUtils.isEmpty(name)) name = "bios.bin";
File dest = new File(outDir, name);
copyUriToFile(this, child.getUri(), dest);
}
}
}
private static String getDisplayNameFromUri(Context context, Uri uri) {
String name = null;
Cursor cursor = context.getContentResolver().query(uri, null, null, null, null);
if (cursor != null) {
try {
int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (nameIndex >= 0 && cursor.moveToFirst()) {
name = cursor.getString(nameIndex);
}
} finally {
cursor.close();
}
}
if (TextUtils.isEmpty(name)) {
name = uri.getLastPathSegment();
}
return name;
}
private static boolean copyUriToFile(Context context, Uri uri, File destFile) {
InputStream is = null;
FileOutputStream os = null;
try {
is = context.getContentResolver().openInputStream(uri);
if (is == null) return false;
os = new FileOutputStream(destFile);
byte[] buffer = new byte[8192];
int read;
while ((read = is.read(buffer)) != -1) {
os.write(buffer, 0, read);
}
os.flush();
return true;
} catch (Exception e) {
return false;
} finally {
try { if (is != null) is.close(); } catch (Exception ignored) {}
try { if (os != null) os.close(); } catch (Exception ignored) {}
}
}
@Override
protected void onPause() {
NativeApp.pause();
super.onPause();
////
if (mHIDDeviceManager != null) {
mHIDDeviceManager.setFrozen(true);
}
}
@Override
protected void onResume() {
NativeApp.resume();
super.onResume();
////
if (mHIDDeviceManager != null) {
mHIDDeviceManager.setFrozen(false);
}
// Re-assert full screen when returning to the activity
2025-09-01 15:36:45 -04:00
enableImmersiveMode();
updateUiForControllerPresence();
2025-08-22 04:11:11 -04:00
}
@Override
protected void onDestroy() {
NativeApp.shutdown();
super.onDestroy();
////
if (mHIDDeviceManager != null) {
HIDDeviceManager.release(mHIDDeviceManager);
mHIDDeviceManager = null;
}
if (mInputManager != null) {
try { mInputManager.unregisterInputDeviceListener(mInputDeviceListener); } catch (Exception ignored) {}
mInputManager = null;
}
2025-08-22 04:11:11 -04:00
////
if (mEmulationThread != null) {
try {
mEmulationThread.join();
mEmulationThread = null;
}
catch (InterruptedException ignored) {}
}
int appPid = android.os.Process.myPid();
android.os.Process.killProcess(appPid);
}
public void Initialize() {
NativeApp.initializeOnce(getApplicationContext());
// Set up JNI
SDLControllerManager.nativeSetupJNI();
// Initialize state
SDLControllerManager.initialize();
// Load and apply saved settings
2025-09-11 23:14:41 -04:00
loadAndApplyStoredSettings();
2025-08-22 04:11:11 -04:00
mHIDDeviceManager = HIDDeviceManager.acquire(this);
2025-08-25 13:56:13 -04:00
// Initialize HID device manager for USB and Bluetooth controllers
mHIDDeviceManager.initialize(true, true);
2025-08-22 08:04:47 -04:00
2025-08-22 04:11:11 -04:00
}
private void setSurfaceView(Object p_value) {
FrameLayout fl_board = findViewById(R.id.fl_board);
if(fl_board != null) {
if(fl_board.getChildCount() > 0) {
fl_board.removeAllViews();
}
////
if(p_value instanceof SDLSurface) {
fl_board.addView((SDLSurface)p_value);
}
}
}
public void startEmuThread() {
2025-08-28 08:11:50 -04:00
// Ensure BIOS present before starting emulation
if (!ensureBiosOrPrompt()) return;
2025-08-22 04:11:11 -04:00
if(!isThread()) {
mEmulationThread = new Thread(() -> NativeApp.runVMThread(m_szGamefile));
mEmulationThread.start();
2025-09-11 23:14:41 -04:00
// Show pause button when game starts (game is always running initially)
runOnUiThread(() -> {
MaterialButton btn_pause_play = findViewById(R.id.btn_pause_play);
if (btn_pause_play != null) {
btn_pause_play.setVisibility(View.VISIBLE);
btn_pause_play.setIcon(ContextCompat.getDrawable(this, R.drawable.pause_circle_24px));
}
});
2025-08-22 04:11:11 -04:00
}
}
private void restartEmuThread() {
2025-08-28 08:11:50 -04:00
// Ensure BIOS present before starting/restarting emulation
if (!ensureBiosOrPrompt()) return;
2025-08-22 04:11:11 -04:00
NativeApp.shutdown();
if (mEmulationThread != null) {
try {
mEmulationThread.join();
mEmulationThread = null;
}
catch (InterruptedException ignored) {}
}
2025-08-22 08:04:47 -04:00
// Apply global renderer setting before starting new game
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
2025-09-06 02:07:48 -04:00
// Default to Automatic (-1) so the core can pick the best available backend (Vulkan/OpenGL/Software)
int renderer = prefs.getInt("renderer", -1);
2025-08-22 08:04:47 -04:00
android.util.Log.d("MainActivity", "Applying global renderer before game restart: " + renderer);
NativeApp.renderGpu(renderer);
2025-08-22 04:11:11 -04:00
////
startEmuThread();
}
2025-09-06 02:07:48 -04:00
// (Renderer toast temporarily removed per user request — AUTO behavior retained.)
2025-08-22 04:11:11 -04:00
// Public API for UI components to reboot the emulator
public void rebootEmu() {
if (!TextUtils.isEmpty(m_szGamefile)) {
restartEmuThread();
} else {
// No game loaded; just shutdown for safety
NativeApp.shutdown();
2025-09-11 23:14:41 -04:00
updatePausePlayButton();
2025-08-22 04:11:11 -04:00
}
}
2025-09-06 02:07:48 -04:00
2025-08-22 04:11:11 -04:00
@Override
public boolean onGenericMotionEvent(MotionEvent event) {
2025-08-25 13:56:13 -04:00
// Use only our controller handler - disable SDL fallback to avoid conflicts
if (mControllerInputHandler != null && mControllerInputHandler.handleMotionEvent(event)) {
2025-08-22 04:11:11 -04:00
return true;
}
2025-08-25 13:56:13 -04:00
// Skip SDL controller manager to avoid mapping conflicts
2025-08-22 04:11:11 -04:00
return super.onGenericMotionEvent(event);
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
// Allow dialogs to receive DPAD navigation when visible
if (!isAnyAppDialogShowing()) {
// Intercept controller DPAD/gamepad keys before focused views consume them
if (mControllerInputHandler != null && mControllerInputHandler.handleKeyEvent(event)) {
return true;
}
}
return super.dispatchKeyEvent(event);
}
private boolean isAnyAppDialogShowing() {
try {
java.util.List<androidx.fragment.app.Fragment> frags = getSupportFragmentManager().getFragments();
for (androidx.fragment.app.Fragment f : frags) {
if (f instanceof androidx.fragment.app.DialogFragment) {
android.app.Dialog d = ((androidx.fragment.app.DialogFragment) f).getDialog();
if (d != null && d.isShowing()) return true;
}
}
} catch (Throwable ignored) {}
return false;
}
2025-08-22 04:11:11 -04:00
@Override
public boolean onKeyDown(int p_keyCode, KeyEvent p_event) {
2025-08-25 13:56:13 -04:00
// Use only our controller handler - disable SDL fallback to avoid conflicts
if (mControllerInputHandler != null && mControllerInputHandler.handleKeyEvent(p_event)) {
return true;
2025-08-22 04:11:11 -04:00
}
2025-08-25 13:56:13 -04:00
// Handle back button for non-gamepad sources
if (p_keyCode == KeyEvent.KEYCODE_BACK) {
showExitDialog();
return true;
2025-08-22 04:11:11 -04:00
}
2025-08-25 13:56:13 -04:00
// Skip SDL controller manager to avoid mapping conflicts
2025-08-22 04:11:11 -04:00
return super.onKeyDown(p_keyCode, p_event);
}
@Override
public boolean onKeyUp(int p_keyCode, KeyEvent p_event) {
2025-08-25 13:56:13 -04:00
// Use only our controller handler - disable SDL fallback to avoid conflicts
if (mControllerInputHandler != null && mControllerInputHandler.handleKeyEvent(p_event)) {
return true;
2025-08-22 04:11:11 -04:00
}
2025-08-25 13:56:13 -04:00
// Skip SDL controller manager to avoid mapping conflicts
2025-08-22 04:11:11 -04:00
return super.onKeyUp(p_keyCode, p_event);
}
public static void sendKeyAction(View p_view, int p_action, int p_keycode) {
if(p_action == MotionEvent.ACTION_DOWN) {
p_view.setPressed(true);
int pad_force = 0;
if(p_keycode >= 110) {
float _abs = 90; // Joystic test value
_abs = Math.min(_abs, 100);
pad_force = (int) (_abs * 32766.0f / 100);
}
NativeApp.setPadButton(p_keycode, pad_force, true);
} else if(p_action == MotionEvent.ACTION_UP || p_action == MotionEvent.ACTION_CANCEL) {
p_view.setPressed(false);
NativeApp.setPadButton(p_keycode, 0, false);
}
}
// Asset copy helpers used on first launch to seed default resources
private void copyAssetAll(Context context, String srcPath) {
AssetManager assetMgr = context.getAssets();
try {
String[] assets = assetMgr.list(srcPath);
String destPath = context.getExternalFilesDir(null) + File.separator + srcPath;
if (assets != null) {
if (assets.length == 0) {
copyFile(context, srcPath, destPath);
} else {
File dir = new File(destPath);
if (!dir.exists()) dir.mkdirs();
for (String element : assets) {
copyAssetAll(context, srcPath + File.separator + element);
}
}
}
} catch (IOException ignored) {}
}
2025-09-06 02:07:48 -04:00
// Mirror asset folder into SAF data root (if set)
private void copyAssetAllToSaf(Context context, String srcPath) {
Uri dataRoot = SafManager.getDataRootUri(context);
if (dataRoot == null) return;
AssetManager assetMgr = context.getAssets();
try {
String[] assets = assetMgr.list(srcPath);
if (assets != null) {
if (assets.length == 0) {
// It's a file under srcPath; create it in SAF
String[] parts = srcPath.split("/");
String filename = parts.length > 0 ? parts[parts.length - 1] : srcPath;
String[] dirSegs = parts.length > 1 ? java.util.Arrays.copyOf(parts, parts.length - 1) : new String[0];
DocumentFile existing = SafManager.getChild(context, dirSegs, filename);
if (existing != null && existing.length() > 0) return;
DocumentFile target = SafManager.createChild(context, dirSegs, filename, guessMime(filename));
if (target != null) {
try (InputStream is = assetMgr.open(srcPath)) {
SafManager.copyFromStream(context, is, target.getUri());
} catch (Exception ignored) {}
}
} else {
for (String element : assets) {
copyAssetAllToSaf(context, srcPath + File.separator + element);
}
}
}
} catch (IOException ignored) {}
}
private static String guessMime(String filename) {
String lower = filename.toLowerCase(java.util.Locale.ROOT);
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml";
if (lower.endsWith(".zip")) return "application/zip";
if (lower.endsWith(".txt")) return "text/plain";
return "application/octet-stream";
}
2025-08-22 04:11:11 -04:00
private static void copyFile(Context context, String srcFile, String destFile) {
AssetManager assetMgr = context.getAssets();
InputStream is = null;
FileOutputStream os = null;
try {
is = assetMgr.open(srcFile);
boolean exists = new File(destFile).exists();
if (srcFile.contains("shaders")) {
exists = false; // always refresh shaders
}
if (!exists) {
File parent = new File(destFile).getParentFile();
if (parent != null && !parent.exists()) parent.mkdirs();
os = new FileOutputStream(destFile);
byte[] buffer = new byte[4096];
int read;
while ((read = is.read(buffer)) != -1) {
os.write(buffer, 0, read);
}
os.flush();
}
} catch (IOException ignored) {
} finally {
try { if (is != null) is.close(); } catch (IOException ignored) {}
try { if (os != null) os.close(); } catch (IOException ignored) {}
}
}
@Override
public void onBackPressed() {
// Fallback for older Android versions
super.onBackPressed();
2025-08-22 04:11:11 -04:00
showExitDialog();
}
private void showExitDialog() {
2025-08-25 07:58:09 -04:00
new MaterialAlertDialogBuilder(this,
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
2025-09-06 02:07:48 -04:00
.setCustomTitle(UiUtils.centeredDialogTitle(this, "Exit App"))
2025-08-22 04:11:11 -04:00
.setMessage("Do you want to exit PSX2?")
.setIcon(android.R.drawable.ic_dialog_alert)
.setPositiveButton("Exit", (dialog, which) -> {
// Stop emulator first
NativeApp.shutdown();
// Quit the app
finishAffinity();
finishAndRemoveTask();
// As a fallback ensure process exit
System.exit(0);
})
.setNegativeButton("Cancel", null)
.show();
}
2025-08-25 13:56:13 -04:00
// ControllerInputHandler.ControllerInputListener implementation
@Override
public void onControllerButtonPressed(int controllerId, int button, boolean pressed) {
android.util.Log.d("Controller", "Controller " + controllerId + " button " + button + " (" + getButtonName(button) + ") " + (pressed ? "pressed" : "released"));
2025-08-25 13:56:13 -04:00
// Send controller input to native code using existing setPadButton method
// Range 255 for pressed, 0 for released (matching PS2 button range)
int range = pressed ? 255 : 0;
NativeApp.setPadButton(button, range, pressed);
// Hide touch controls as soon as controller activity is detected
maybeActivateControllerUi();
2025-08-25 13:56:13 -04:00
}
private String getButtonName(int button) {
switch (button) {
case KeyEvent.KEYCODE_BUTTON_A: return "Cross";
case KeyEvent.KEYCODE_BUTTON_B: return "Circle";
case KeyEvent.KEYCODE_BUTTON_X: return "Square";
case KeyEvent.KEYCODE_BUTTON_Y: return "Triangle";
case KeyEvent.KEYCODE_BUTTON_L1: return "L1";
case KeyEvent.KEYCODE_BUTTON_R1: return "R1";
case KeyEvent.KEYCODE_BUTTON_L2: return "L2";
case KeyEvent.KEYCODE_BUTTON_R2: return "R2";
case KeyEvent.KEYCODE_BUTTON_SELECT: return "Select";
case KeyEvent.KEYCODE_BUTTON_START: return "Start";
case KeyEvent.KEYCODE_BUTTON_THUMBL: return "L3";
case KeyEvent.KEYCODE_BUTTON_THUMBR: return "R3";
case KeyEvent.KEYCODE_DPAD_UP: return "D-Up";
case KeyEvent.KEYCODE_DPAD_DOWN: return "D-Down";
case KeyEvent.KEYCODE_DPAD_LEFT: return "D-Left";
case KeyEvent.KEYCODE_DPAD_RIGHT: return "D-Right";
default: return "Unknown(" + button + ")";
}
}
@Override
public void onControllerAnalogInput(int controllerId, int axis, float value) {
android.util.Log.d("Controller", "Controller " + controllerId + " axis " + axis + " (" + getAxisName(axis) + ") value " + value);
2025-08-25 13:56:13 -04:00
// Send analog input to native code using setPadButton
handleAnalogInput(axis, value);
// Hide touch controls on analog movement as well
if (Math.abs(value) > 0.1f) {
maybeActivateControllerUi();
}
}
private void maybeActivateControllerUi() {
if (!controllerUiApplied) {
controllerUiApplied = true;
runOnUiThread(() -> setControlsVisible(false));
}
2025-08-25 13:56:13 -04:00
}
@Override
public void onControllerCombo(int controllerId, String comboName) {
android.util.Log.d("Controller", "Controller " + controllerId + " combo: " + comboName);
if ("select_start".equals(comboName)) {
// Show quick actions dialog
runOnUiThread(() -> {
QuickActionsDialogFragment dialog = new QuickActionsDialogFragment();
dialog.show(getSupportFragmentManager(), "quick_actions");
});
}
}
private String getAxisName(int axis) {
switch (axis) {
case 110: return "L-Up";
case 111: return "L-Right";
case 112: return "L-Down";
case 113: return "L-Left";
case 120: return "R-Up";
case 121: return "R-Right";
case 122: return "R-Down";
case 123: return "R-Left";
case KeyEvent.KEYCODE_BUTTON_L2: return "L2-Trigger";
case KeyEvent.KEYCODE_BUTTON_R2: return "R2-Trigger";
default: return "Unknown(" + axis + ")";
}
}
private void handleAnalogInput(int axis, float value) {
2025-09-01 15:36:45 -04:00
// Convert analog input to button presses for the native interface
2025-08-25 13:56:13 -04:00
// For analog sticks, only send positive values (negative values are handled by opposite direction)
int intensity = Math.max(0, Math.round(Math.abs(value) * 255));
boolean pressed = Math.abs(value) > 0.1f;
switch (axis) {
case ControllerInputHandler.PAD_L_UP:
case ControllerInputHandler.PAD_L_DOWN:
case ControllerInputHandler.PAD_L_LEFT:
case ControllerInputHandler.PAD_L_RIGHT:
case ControllerInputHandler.PAD_R_UP:
case ControllerInputHandler.PAD_R_DOWN:
case ControllerInputHandler.PAD_R_LEFT:
case ControllerInputHandler.PAD_R_RIGHT:
// Analog stick directions
NativeApp.setPadButton(axis, intensity, pressed);
break;
case ControllerInputHandler.PAD_L2:
case ControllerInputHandler.PAD_R2:
// Triggers: 0-255 range
NativeApp.setPadButton(axis, Math.round(value * 255), value > 0.1f);
break;
}
}
2025-09-11 23:14:41 -04:00
private void setupDrawerSettings(View header) {
if (header == null) return;
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
// Aspect Ratio spinner - setup like QuickActions
Spinner spAspect = header.findViewById(R.id.drawer_sp_aspect_ratio);
if (spAspect != null) {
if (spAspect.getAdapter() == null) {
ArrayAdapter<CharSequence> aspectAdapter = ArrayAdapter.createFromResource(this,
R.array.aspect_ratio_entries, android.R.layout.simple_spinner_item);
aspectAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spAspect.setAdapter(aspectAdapter);
spAspect.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
@Override public void onItemSelected(android.widget.AdapterView<?> parent, View view, int position, long id) {
prefs.edit().putInt("aspect_ratio", position).apply();
try { NativeApp.setAspectRatio(position); } catch (Throwable ignored) {}
}
@Override public void onNothingSelected(android.widget.AdapterView<?> parent) {}
});
}
ArrayAdapter<?> adapter = (ArrayAdapter<?>) spAspect.getAdapter();
if (adapter != null) {
int savedAspect = prefs.getInt("aspect_ratio", 1);
if (savedAspect < 0 || savedAspect >= adapter.getCount()) savedAspect = 1;
spAspect.setSelection(savedAspect);
}
}
// Resolution Scale spinner
Spinner spScale = header.findViewById(R.id.drawer_sp_scale);
if (spScale != null) {
if (spScale.getAdapter() == null) {
ArrayAdapter<CharSequence> scaleAdapter = ArrayAdapter.createFromResource(this,
R.array.scale_entries, android.R.layout.simple_spinner_item);
scaleAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spScale.setAdapter(scaleAdapter);
spScale.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
@Override public void onItemSelected(android.widget.AdapterView<?> parent, View view, int position, long id) {
float scale = Math.max(1, Math.min(8, position + 1));
prefs.edit().putFloat("upscale_multiplier", scale).apply();
try { NativeApp.renderUpscalemultiplier(scale); } catch (Throwable ignored) {}
}
@Override public void onNothingSelected(android.widget.AdapterView<?> parent) {}
});
}
ArrayAdapter<?> adapter = (ArrayAdapter<?>) spScale.getAdapter();
if (adapter != null) {
float savedScale = prefs.getFloat("upscale_multiplier", 1.0f);
int scaleIndex = Math.max(0, Math.min(adapter.getCount() - 1, Math.round(savedScale) - 1));
spScale.setSelection(scaleIndex);
}
}
// Blending Accuracy spinner - setup like QuickActions
Spinner spBlending = header.findViewById(R.id.drawer_sp_blending_accuracy);
if (spBlending != null) {
if (spBlending.getAdapter() == null) {
ArrayAdapter<CharSequence> blendAdapter = ArrayAdapter.createFromResource(this,
R.array.blending_accuracy_entries, android.R.layout.simple_spinner_item);
blendAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
spBlending.setAdapter(blendAdapter);
spBlending.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
@Override public void onItemSelected(android.widget.AdapterView<?> parent, View view, int position, long id) {
prefs.edit().putInt("blending_accuracy", position).apply();
try { NativeApp.setBlendingAccuracy(position); } catch (Throwable ignored) {}
}
@Override public void onNothingSelected(android.widget.AdapterView<?> parent) {}
});
}
ArrayAdapter<?> adapter = (ArrayAdapter<?>) spBlending.getAdapter();
if (adapter != null) {
int savedBlend = prefs.getInt("blending_accuracy", 1);
if (savedBlend < 0 || savedBlend >= adapter.getCount()) savedBlend = 1;
spBlending.setSelection(savedBlend);
}
}
// Setup switch listeners only once
setupDrawerSwitchListeners(header, prefs);
}
private void setupDrawerSwitchListeners(View header, SharedPreferences prefs) {
// Widescreen Patches switch
com.google.android.material.materialswitch.MaterialSwitch swWide = header.findViewById(R.id.drawer_sw_widescreen);
if (swWide != null && swWide.getTag() == null) {
swWide.setTag("setup"); // Mark as setup to avoid duplicate listeners
swWide.setOnCheckedChangeListener((buttonView, isChecked) -> {
prefs.edit().putBoolean("widescreen_patches", isChecked).apply();
try { NativeApp.setWidescreenPatches(isChecked); } catch (Throwable ignored) {}
});
}
// No Interlacing switch
com.google.android.material.materialswitch.MaterialSwitch swNoInt = header.findViewById(R.id.drawer_sw_no_interlacing);
if (swNoInt != null && swNoInt.getTag() == null) {
swNoInt.setTag("setup");
swNoInt.setOnCheckedChangeListener((buttonView, isChecked) -> {
prefs.edit().putBoolean("no_interlacing_patches", isChecked).apply();
try { NativeApp.setNoInterlacingPatches(isChecked); } catch (Throwable ignored) {}
});
}
// Load Textures switch
com.google.android.material.materialswitch.MaterialSwitch swLoadTex = header.findViewById(R.id.drawer_sw_load_textures);
if (swLoadTex != null && swLoadTex.getTag() == null) {
swLoadTex.setTag("setup");
swLoadTex.setOnCheckedChangeListener((buttonView, isChecked) -> {
prefs.edit().putBoolean("load_textures", isChecked).apply();
try { NativeApp.setLoadTextures(isChecked); } catch (Throwable ignored) {}
});
}
// Async Textures switch
com.google.android.material.materialswitch.MaterialSwitch swAsyncTex = header.findViewById(R.id.drawer_sw_async_textures);
if (swAsyncTex != null && swAsyncTex.getTag() == null) {
swAsyncTex.setTag("setup");
swAsyncTex.setOnCheckedChangeListener((buttonView, isChecked) -> {
prefs.edit().putBoolean("async_texture_loading", isChecked).apply();
try { NativeApp.setAsyncTextureLoading(isChecked); } catch (Throwable ignored) {}
});
}
// Precache Textures switch
com.google.android.material.materialswitch.MaterialSwitch swPrecache = header.findViewById(R.id.drawer_sw_precache_textures);
if (swPrecache != null && swPrecache.getTag() == null) {
swPrecache.setTag("setup");
swPrecache.setOnCheckedChangeListener((buttonView, isChecked) -> {
prefs.edit().putBoolean("precache_textures", isChecked).apply();
try { NativeApp.setPrecacheTextureReplacements(isChecked); } catch (Throwable ignored) {}
});
}
// HUD Developer switch
com.google.android.material.materialswitch.MaterialSwitch swDevHud = header.findViewById(R.id.drawer_sw_dev_hud);
if (swDevHud != null && swDevHud.getTag() == null) {
swDevHud.setTag("setup");
swDevHud.setOnCheckedChangeListener((buttonView, isChecked) -> {
prefs.edit().putBoolean("hud_visible", isChecked).apply();
try { NativeApp.setHudVisible(isChecked); } catch (Throwable ignored) {}
});
}
}
private void refreshDrawerSettings() {
try {
NavigationView nav = findViewById(R.id.nav_view);
if (nav != null && nav.getHeaderCount() > 0) {
View header = nav.getHeaderView(0);
if (header != null) {
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
// Refresh switch states only (spinners are set up once in setupDrawerSettings)
com.google.android.material.materialswitch.MaterialSwitch swWide = header.findViewById(R.id.drawer_sw_widescreen);
if (swWide != null) {
swWide.setChecked(prefs.getBoolean("widescreen_patches", true));
}
com.google.android.material.materialswitch.MaterialSwitch swNoInt = header.findViewById(R.id.drawer_sw_no_interlacing);
if (swNoInt != null) {
swNoInt.setChecked(prefs.getBoolean("no_interlacing_patches", true));
}
com.google.android.material.materialswitch.MaterialSwitch swLoadTex = header.findViewById(R.id.drawer_sw_load_textures);
if (swLoadTex != null) {
swLoadTex.setChecked(prefs.getBoolean("load_textures", false));
}
com.google.android.material.materialswitch.MaterialSwitch swAsyncTex = header.findViewById(R.id.drawer_sw_async_textures);
if (swAsyncTex != null) {
swAsyncTex.setChecked(prefs.getBoolean("async_texture_loading", true));
}
com.google.android.material.materialswitch.MaterialSwitch swPrecache = header.findViewById(R.id.drawer_sw_precache_textures);
if (swPrecache != null) {
swPrecache.setChecked(prefs.getBoolean("precache_textures", false));
}
com.google.android.material.materialswitch.MaterialSwitch swDevHud = header.findViewById(R.id.drawer_sw_dev_hud);
if (swDevHud != null) {
swDevHud.setChecked(prefs.getBoolean("hud_visible", false));
}
}
}
} catch (Throwable ignored) {}
}
private void loadAndApplyStoredSettings() {
try {
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
// Apply aspect ratio setting
int aspectRatio = prefs.getInt("aspect_ratio", 1);
NativeApp.setAspectRatio(aspectRatio);
// Apply blending accuracy setting
int blendingAccuracy = prefs.getInt("blending_accuracy", 1);
NativeApp.setBlendingAccuracy(blendingAccuracy);
// Apply other settings
boolean widescreenPatches = prefs.getBoolean("widescreen_patches", true);
NativeApp.setWidescreenPatches(widescreenPatches);
boolean noInterlacing = prefs.getBoolean("no_interlacing_patches", true);
NativeApp.setNoInterlacingPatches(noInterlacing);
boolean loadTextures = prefs.getBoolean("load_textures", false);
NativeApp.setLoadTextures(loadTextures);
boolean asyncTextures = prefs.getBoolean("async_texture_loading", true);
NativeApp.setAsyncTextureLoading(asyncTextures);
boolean precacheTextures = prefs.getBoolean("precache_textures", false);
NativeApp.setPrecacheTextureReplacements(precacheTextures);
// Apply renderer setting
int renderer = prefs.getInt("renderer", -1);
NativeApp.renderGpu(renderer);
} catch (Throwable ignored) {}
}
private void showControllerHintIfNeeded() {
// Prevent multiple dialogs from showing
if (mControllerHintShowing) return;
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
boolean hasShownHint = prefs.getBoolean("controller_hint_shown", false);
// Only show the hint once, and only if setup is complete
if (!hasShownHint && prefs.getBoolean("first_run_done", false)) {
mControllerHintShowing = true;
// Small delay to avoid showing immediately on startup
findViewById(android.R.id.content).postDelayed(() -> {
if (!isFinishing() && !mSetupWizardActive && mControllerHintShowing) {
showControllerHintDialog();
}
}, 2000); // 2 second delay
}
}
private void showControllerHintDialog() {
// Double-check to prevent multiple dialogs
if (!mControllerHintShowing) return;
View dialogView = getLayoutInflater().inflate(R.layout.dialog_controller_hint, null);
com.google.android.material.materialswitch.MaterialSwitch swDontShow = dialogView.findViewById(R.id.sw_dont_show_again);
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this,
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
.setCustomTitle(UiUtils.centeredDialogTitle(this, "Controller Tip"))
.setView(dialogView)
.setPositiveButton("Got it!", (dialog, which) -> {
SharedPreferences prefs = getSharedPreferences("app_prefs", MODE_PRIVATE);
if (swDontShow != null && swDontShow.isChecked()) {
prefs.edit().putBoolean("controller_hint_shown", true).apply();
}
mControllerHintShowing = false;
dialog.dismiss();
})
.setCancelable(true)
.setOnDismissListener(dialog -> {
mControllerHintShowing = false;
});
AlertDialog dialog = builder.create();
dialog.show();
}
// Pause/Play toggle functionality
public void togglePauseState() {
try {
boolean isPaused = NativeApp.isPaused();
if (isPaused) {
NativeApp.resume();
} else {
NativeApp.pause();
}
updatePausePlayButton();
} catch (Throwable ignored) {}
}
private void updatePausePlayButton() {
MaterialButton btn_pause_play = findViewById(R.id.btn_pause_play);
if (btn_pause_play != null) {
try {
boolean isPaused = NativeApp.isPaused();
boolean hasGame = hasSelectedGame() && isThread();
// Show button only when a game is running
btn_pause_play.setVisibility(hasGame ? View.VISIBLE : View.GONE);
if (hasGame) {
// Update icon to show the action it will perform
if (isPaused) {
// Game is paused, show pause icon (will pause more/stay paused)
btn_pause_play.setIcon(ContextCompat.getDrawable(this, R.drawable.pause_circle_24px));
} else {
// Game is running, show play icon (will keep playing)
btn_pause_play.setIcon(ContextCompat.getDrawable(this, R.drawable.play_circle_24px));
}
}
} catch (Throwable ignored) {}
}
}
// Call this method when game starts/stops to update button visibility
public void updateGameState() {
updatePausePlayButton();
}
2025-08-22 04:11:11 -04:00
}