From 59beeac4c714d3f34a479293faffd3197bea70ea Mon Sep 17 00:00:00 2001 From: Charles Lombardo Date: Thu, 30 Nov 2023 10:38:25 -0500 Subject: [PATCH] Android UI Overhaul Part 3 (#7216) * android: Rework Emulation Activity's UI - New in-game menu - Ability to open games from file manager - New shader loading UI - Fixes an issue where the system bars would stay visible during emulation * android: Port yuzu's foreground service logic Fixes an issue where the foreground service notification would be stuck with no way to dismiss it --- src/android/app/src/main/AndroidManifest.xml | 13 +- .../java/org/citra/citra_emu/NativeLibrary.kt | 2 +- .../activities/EmulationActivity.java | 788 ------------- .../citra_emu/activities/EmulationActivity.kt | 453 +++++++ .../citra/citra_emu/adapters/GameAdapter.kt | 5 +- .../camera/StillImageCameraHelper.java | 68 -- .../camera/StillImageCameraHelper.kt | 67 ++ .../fragments/EmulationFragment.java | 337 ------ .../citra_emu/fragments/EmulationFragment.kt | 1048 +++++++++++++++++ .../fragments/SystemFilesFragment.kt | 10 +- .../citra/citra_emu/overlay/InputOverlay.java | 4 +- .../overlay/InputOverlayDrawableJoystick.java | 2 +- .../citra/citra_emu/ui/main/MainActivity.kt | 4 +- ...Helper.java => ControllerMappingHelper.kt} | 47 +- .../utils/DiskShaderCacheProgress.java | 138 --- .../utils/DiskShaderCacheProgress.kt | 60 + .../utils/EmulationMenuSettings.java | 78 -- .../citra_emu/utils/EmulationMenuSettings.kt | 78 ++ .../citra_emu/utils/ForegroundService.java | 63 - .../citra_emu/utils/ForegroundService.kt | 70 ++ .../citra_emu/viewmodel/EmulationViewModel.kt | 45 + .../app/src/main/res/drawable/ic_code.xml | 5 + .../app/src/main/res/drawable/ic_exit.xml | 10 + .../src/main/res/drawable/ic_fit_screen.xml | 9 + .../app/src/main/res/drawable/ic_lock.xml | 9 + .../app/src/main/res/drawable/ic_nfc.xml | 9 + .../app/src/main/res/drawable/ic_pause.xml | 9 + .../app/src/main/res/drawable/ic_play.xml | 9 + .../app/src/main/res/drawable/ic_save.xml | 9 + .../src/main/res/drawable/ic_splitscreen.xml | 9 + .../app/src/main/res/drawable/ic_unlocked.xml | 9 + .../main/res/layout/activity_emulation.xml | 30 +- .../main/res/layout/fragment_emulation.xml | 167 ++- .../src/main/res/layout/header_in_game.xml | 14 + .../src/main/res/menu/menu_amiibo_options.xml | 13 + .../app/src/main/res/menu/menu_in_game.xml | 55 + .../res/menu/menu_landscape_screen_layout.xml | 24 + .../main/res/menu/menu_overlay_options.xml | 41 + .../app/src/main/res/menu/menu_savestates.xml | 12 + .../res/navigation/emulation_navigation.xml | 34 + .../main/res/navigation/home_navigation.xml | 5 + .../app/src/main/res/values/strings.xml | 8 +- 42 files changed, 2307 insertions(+), 1563 deletions(-) delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.kt rename src/android/app/src/main/java/org/citra/citra_emu/utils/{ControllerMappingHelper.java => ControllerMappingHelper.kt} (55%) delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/viewmodel/EmulationViewModel.kt create mode 100644 src/android/app/src/main/res/drawable/ic_code.xml create mode 100644 src/android/app/src/main/res/drawable/ic_exit.xml create mode 100644 src/android/app/src/main/res/drawable/ic_fit_screen.xml create mode 100644 src/android/app/src/main/res/drawable/ic_lock.xml create mode 100644 src/android/app/src/main/res/drawable/ic_nfc.xml create mode 100644 src/android/app/src/main/res/drawable/ic_pause.xml create mode 100644 src/android/app/src/main/res/drawable/ic_play.xml create mode 100644 src/android/app/src/main/res/drawable/ic_save.xml create mode 100644 src/android/app/src/main/res/drawable/ic_splitscreen.xml create mode 100644 src/android/app/src/main/res/drawable/ic_unlocked.xml create mode 100644 src/android/app/src/main/res/layout/header_in_game.xml create mode 100644 src/android/app/src/main/res/menu/menu_amiibo_options.xml create mode 100644 src/android/app/src/main/res/menu/menu_in_game.xml create mode 100644 src/android/app/src/main/res/menu/menu_landscape_screen_layout.xml create mode 100644 src/android/app/src/main/res/menu/menu_overlay_options.xml create mode 100644 src/android/app/src/main/res/menu/menu_savestates.xml create mode 100644 src/android/app/src/main/res/navigation/emulation_navigation.xml diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index 416321ce80..6263a42dbd 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -64,9 +64,18 @@ + android:launchMode="singleTop"> + + + + + + + + diff --git a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt index 3a357ed319..75150fd167 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt @@ -252,7 +252,7 @@ object NativeLibrary { @Keep @JvmStatic - fun landscapeScreenLayout(): Int = EmulationMenuSettings.getLandscapeScreenLayout() + fun landscapeScreenLayout(): Int = EmulationMenuSettings.landscapeScreenLayout @Keep @JvmStatic diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java deleted file mode 100644 index 2f631b61ee..0000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java +++ /dev/null @@ -1,788 +0,0 @@ -package org.citra.citra_emu.activities; - -import android.app.Activity; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.util.Pair; -import android.util.SparseIntArray; -import android.view.InputDevice; -import android.view.KeyEvent; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.MotionEvent; -import android.view.SubMenu; -import android.view.View; -import android.view.WindowManager; -import android.widget.CheckBox; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.activity.result.ActivityResultCallback; -import androidx.activity.result.ActivityResultLauncher; -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.PopupMenu; -import androidx.core.app.NotificationManagerCompat; -import androidx.documentfile.provider.DocumentFile; -import androidx.fragment.app.FragmentActivity; - -import org.citra.citra_emu.CitraApplication; -import org.citra.citra_emu.NativeLibrary; -import org.citra.citra_emu.R; -import org.citra.citra_emu.contracts.OpenFileResultContract; -import org.citra.citra_emu.features.cheats.ui.CheatsActivity; -import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; -import org.citra.citra_emu.features.settings.ui.SettingsActivity; -import org.citra.citra_emu.features.settings.utils.SettingsFile; -import org.citra.citra_emu.camera.StillImageCameraHelper; -import org.citra.citra_emu.fragments.EmulationFragment; -import org.citra.citra_emu.ui.main.MainActivity; -import org.citra.citra_emu.utils.ControllerMappingHelper; -import org.citra.citra_emu.utils.EmulationMenuSettings; -import org.citra.citra_emu.utils.FileBrowserHelper; -import org.citra.citra_emu.utils.FileUtil; -import org.citra.citra_emu.utils.ForegroundService; -import org.citra.citra_emu.utils.Log; -import org.citra.citra_emu.utils.ThemeUtil; - -import java.io.File; -import java.io.IOException; -import java.lang.annotation.Retention; -import java.util.Collections; -import java.util.List; - -import static android.Manifest.permission.CAMERA; -import static android.Manifest.permission.RECORD_AUDIO; -import static java.lang.annotation.RetentionPolicy.SOURCE; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.slider.Slider; - -public final class EmulationActivity extends AppCompatActivity { - public static final String EXTRA_SELECTED_GAME = "SelectedGame"; - public static final String EXTRA_SELECTED_TITLE = "SelectedTitle"; - public static final int MENU_ACTION_EDIT_CONTROLS_PLACEMENT = 0; - public static final int MENU_ACTION_TOGGLE_CONTROLS = 1; - public static final int MENU_ACTION_ADJUST_SCALE = 2; - public static final int MENU_ACTION_EXIT = 3; - public static final int MENU_ACTION_SHOW_FPS = 4; - public static final int MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE = 5; - public static final int MENU_ACTION_SCREEN_LAYOUT_PORTRAIT = 6; - public static final int MENU_ACTION_SCREEN_LAYOUT_SINGLE = 7; - public static final int MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE = 8; - public static final int MENU_ACTION_SWAP_SCREENS = 9; - public static final int MENU_ACTION_RESET_OVERLAY = 10; - public static final int MENU_ACTION_SHOW_OVERLAY = 11; - public static final int MENU_ACTION_OPEN_SETTINGS = 12; - public static final int MENU_ACTION_LOAD_AMIIBO = 13; - public static final int MENU_ACTION_REMOVE_AMIIBO = 14; - public static final int MENU_ACTION_JOYSTICK_REL_CENTER = 15; - public static final int MENU_ACTION_DPAD_SLIDE_ENABLE = 16; - public static final int MENU_ACTION_OPEN_CHEATS = 17; - public static final int MENU_ACTION_CLOSE_GAME = 18; - - public static final int REQUEST_SELECT_AMIIBO = 2; - private static final int EMULATION_RUNNING_NOTIFICATION = 0x1000; - private static SparseIntArray buttonsActionsMap = new SparseIntArray(); - - private final ActivityResultLauncher mOpenFileLauncher = - registerForActivityResult(new OpenFileResultContract(), result -> { - if (result == null) - return; - String[] selectedFiles = FileBrowserHelper.getSelectedFiles( - result, getApplicationContext(), Collections.singletonList("bin")); - if (selectedFiles == null) - return; - - onAmiiboSelected(selectedFiles[0]); - }); - - static { - buttonsActionsMap.append(R.id.menu_emulation_edit_layout, - EmulationActivity.MENU_ACTION_EDIT_CONTROLS_PLACEMENT); - buttonsActionsMap.append(R.id.menu_emulation_toggle_controls, - EmulationActivity.MENU_ACTION_TOGGLE_CONTROLS); - buttonsActionsMap - .append(R.id.menu_emulation_adjust_scale, EmulationActivity.MENU_ACTION_ADJUST_SCALE); - buttonsActionsMap.append(R.id.menu_emulation_show_fps, - EmulationActivity.MENU_ACTION_SHOW_FPS); - buttonsActionsMap.append(R.id.menu_screen_layout_landscape, - EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE); - buttonsActionsMap.append(R.id.menu_screen_layout_portrait, - EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_PORTRAIT); - buttonsActionsMap.append(R.id.menu_screen_layout_single, - EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_SINGLE); - buttonsActionsMap.append(R.id.menu_screen_layout_sidebyside, - EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE); - buttonsActionsMap.append(R.id.menu_emulation_swap_screens, - EmulationActivity.MENU_ACTION_SWAP_SCREENS); - buttonsActionsMap - .append(R.id.menu_emulation_reset_overlay, EmulationActivity.MENU_ACTION_RESET_OVERLAY); - buttonsActionsMap - .append(R.id.menu_emulation_show_overlay, EmulationActivity.MENU_ACTION_SHOW_OVERLAY); - buttonsActionsMap - .append(R.id.menu_emulation_open_settings, EmulationActivity.MENU_ACTION_OPEN_SETTINGS); - buttonsActionsMap - .append(R.id.menu_emulation_amiibo_load, EmulationActivity.MENU_ACTION_LOAD_AMIIBO); - buttonsActionsMap - .append(R.id.menu_emulation_amiibo_remove, EmulationActivity.MENU_ACTION_REMOVE_AMIIBO); - buttonsActionsMap.append(R.id.menu_emulation_joystick_rel_center, - EmulationActivity.MENU_ACTION_JOYSTICK_REL_CENTER); - buttonsActionsMap.append(R.id.menu_emulation_dpad_slide_enable, - EmulationActivity.MENU_ACTION_DPAD_SLIDE_ENABLE); - buttonsActionsMap - .append(R.id.menu_emulation_open_cheats, EmulationActivity.MENU_ACTION_OPEN_CHEATS); - buttonsActionsMap - .append(R.id.menu_emulation_close_game, EmulationActivity.MENU_ACTION_CLOSE_GAME); - } - - private EmulationFragment mEmulationFragment; - private SharedPreferences mPreferences; - private ControllerMappingHelper mControllerMappingHelper; - private Intent foregroundService; - private boolean activityRecreated; - private String mSelectedTitle; - private String mPath; - - public static void launch(FragmentActivity activity, String path, String title) { - Intent launcher = new Intent(activity, EmulationActivity.class); - - launcher.putExtra(EXTRA_SELECTED_GAME, path); - launcher.putExtra(EXTRA_SELECTED_TITLE, title); - activity.startActivity(launcher); - } - - public static void tryDismissRunningNotification(Activity activity) { - NotificationManagerCompat.from(activity).cancel(EMULATION_RUNNING_NOTIFICATION); - } - - @Override - protected void onDestroy() { - stopService(foregroundService); - super.onDestroy(); - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - Log.gameLaunched = true; - ThemeUtil.INSTANCE.setTheme(this); - super.onCreate(savedInstanceState); - - if (savedInstanceState == null) { - // Get params we were passed - Intent gameToEmulate = getIntent(); - mPath = gameToEmulate.getStringExtra(EXTRA_SELECTED_GAME); - mSelectedTitle = gameToEmulate.getStringExtra(EXTRA_SELECTED_TITLE); - activityRecreated = false; - } else { - activityRecreated = true; - restoreState(savedInstanceState); - } - - mControllerMappingHelper = new ControllerMappingHelper(); - - // Set these options now so that the SurfaceView the game renders into is the right size. - enableFullscreenImmersive(); - - setContentView(R.layout.activity_emulation); - - // Find or create the EmulationFragment - mEmulationFragment = (EmulationFragment) getSupportFragmentManager() - .findFragmentById(R.id.frame_emulation_fragment); - if (mEmulationFragment == null) { - mEmulationFragment = EmulationFragment.newInstance(mPath); - getSupportFragmentManager().beginTransaction() - .add(R.id.frame_emulation_fragment, mEmulationFragment) - .commit(); - } - - setTitle(mSelectedTitle); - - mPreferences = PreferenceManager.getDefaultSharedPreferences(this); - - // Start a foreground service to prevent the app from getting killed in the background - foregroundService = new Intent(EmulationActivity.this, ForegroundService.class); - startForegroundService(foregroundService); - - // Override Citra core INI with the one set by our in game menu - NativeLibrary.INSTANCE.swapScreens(EmulationMenuSettings.getSwapScreens(), - getWindowManager().getDefaultDisplay().getRotation()); - } - - @Override - protected void onSaveInstanceState(@NonNull Bundle outState) { - outState.putString(EXTRA_SELECTED_GAME, mPath); - outState.putString(EXTRA_SELECTED_TITLE, mSelectedTitle); - super.onSaveInstanceState(outState); - } - - protected void restoreState(Bundle savedInstanceState) { - mPath = savedInstanceState.getString(EXTRA_SELECTED_GAME); - mSelectedTitle = savedInstanceState.getString(EXTRA_SELECTED_TITLE); - } - - @Override - public void onRestart() { - super.onRestart(); - NativeLibrary.INSTANCE.reloadCameraDevices(); - } - - @Override - public void onBackPressed() { - View anchor = findViewById(R.id.menu_anchor); - PopupMenu popupMenu = new PopupMenu(this, anchor); - onCreateOptionsMenu(popupMenu.getMenu(), popupMenu.getMenuInflater()); - updateSavestateMenuOptions(popupMenu.getMenu()); - popupMenu.setOnMenuItemClickListener(this::onOptionsItemSelected); - popupMenu.show(); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - switch (requestCode) { - case NativeLibrary.REQUEST_CODE_NATIVE_CAMERA: - if (grantResults[0] != PackageManager.PERMISSION_GRANTED && - shouldShowRequestPermissionRationale(CAMERA)) { - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.camera) - .setMessage(R.string.camera_permission_needed) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - NativeLibrary.INSTANCE.cameraPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED); - break; - case NativeLibrary.REQUEST_CODE_NATIVE_MIC: - if (grantResults[0] != PackageManager.PERMISSION_GRANTED && - shouldShowRequestPermissionRationale(RECORD_AUDIO)) { - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.microphone) - .setMessage(R.string.microphone_permission_needed) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - NativeLibrary.INSTANCE.micPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED); - break; - default: - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - break; - } - } - - public void onEmulationStarted() { - Toast.makeText(this, getString(R.string.emulation_menu_help), Toast.LENGTH_LONG).show(); - } - - private void enableFullscreenImmersive() { - // TODO: Remove this once we properly account for display insets in the input overlay - getWindow().getAttributes().layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER; - - getWindow().getDecorView().setSystemUiVisibility( - 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 | - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - // Inflate the menu; this adds items to the action bar if it is present. - onCreateOptionsMenu(menu, getMenuInflater()); - return true; - } - - private void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.menu_emulation, menu); - - int layoutOptionMenuItem = R.id.menu_screen_layout_landscape; - switch (EmulationMenuSettings.getLandscapeScreenLayout()) { - case EmulationMenuSettings.LayoutOption_SingleScreen: - layoutOptionMenuItem = R.id.menu_screen_layout_single; - break; - case EmulationMenuSettings.LayoutOption_SideScreen: - layoutOptionMenuItem = R.id.menu_screen_layout_sidebyside; - break; - case EmulationMenuSettings.LayoutOption_MobilePortrait: - layoutOptionMenuItem = R.id.menu_screen_layout_portrait; - break; - } - - menu.findItem(layoutOptionMenuItem).setChecked(true); - menu.findItem(R.id.menu_emulation_joystick_rel_center).setChecked(EmulationMenuSettings.getJoystickRelCenter()); - menu.findItem(R.id.menu_emulation_dpad_slide_enable).setChecked(EmulationMenuSettings.getDpadSlideEnable()); - menu.findItem(R.id.menu_emulation_show_fps).setChecked(EmulationMenuSettings.getShowFps()); - menu.findItem(R.id.menu_emulation_swap_screens).setChecked(EmulationMenuSettings.getSwapScreens()); - menu.findItem(R.id.menu_emulation_show_overlay).setChecked(EmulationMenuSettings.getShowOverlay()); - } - - private void DisplaySavestateWarning() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext()); - if (preferences.getBoolean("savestateWarningShown", false)) { - return; - } - - LayoutInflater inflater = mEmulationFragment.requireActivity().getLayoutInflater(); - View view = inflater.inflate(R.layout.dialog_checkbox, null); - CheckBox checkBox = view.findViewById(R.id.checkBox); - - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.savestate_warning_title) - .setMessage(R.string.savestate_warning_message) - .setView(view) - .setPositiveButton(android.R.string.ok, (dialog, which) -> { - preferences.edit().putBoolean("savestateWarningShown", checkBox.isChecked()).apply(); - }) - .show(); - } - - @Override - public boolean onPrepareOptionsMenu(Menu menu) { - super.onPrepareOptionsMenu(menu); - updateSavestateMenuOptions(menu); - return true; - } - - private void updateSavestateMenuOptions(Menu menu) { - final NativeLibrary.SaveStateInfo[] savestates = NativeLibrary.INSTANCE.getSavestateInfo(); - if (savestates == null) { - menu.findItem(R.id.menu_emulation_save_state).setVisible(false); - menu.findItem(R.id.menu_emulation_load_state).setVisible(false); - return; - } - menu.findItem(R.id.menu_emulation_save_state).setVisible(true); - menu.findItem(R.id.menu_emulation_load_state).setVisible(true); - - final SubMenu saveStateMenu = menu.findItem(R.id.menu_emulation_save_state).getSubMenu(); - final SubMenu loadStateMenu = menu.findItem(R.id.menu_emulation_load_state).getSubMenu(); - saveStateMenu.clear(); - loadStateMenu.clear(); - - // Update savestates information - for (int i = 0; i < NativeLibrary.SAVESTATE_SLOT_COUNT; ++i) { - final int slot = i + 1; - final String text = getString(R.string.emulation_empty_state_slot, slot); - saveStateMenu.add(text).setEnabled(true).setOnMenuItemClickListener((item) -> { - DisplaySavestateWarning(); - NativeLibrary.INSTANCE.saveState(slot); - return true; - }); - loadStateMenu.add(text).setEnabled(false).setOnMenuItemClickListener((item) -> { - NativeLibrary.INSTANCE.loadState(slot); - return true; - }); - } - for (final NativeLibrary.SaveStateInfo info : savestates) { - final String text = getString(R.string.emulation_occupied_state_slot, info.getSlot(), info.getTime()); - saveStateMenu.getItem(info.getSlot() - 1).setTitle(text); - loadStateMenu.getItem(info.getSlot() - 1).setTitle(text).setEnabled(true); - } - } - - @SuppressWarnings("WrongConstant") - @Override - public boolean onOptionsItemSelected(MenuItem item) { - int action = buttonsActionsMap.get(item.getItemId(), -1); - - switch (action) { - // Edit the placement of the controls - case MENU_ACTION_EDIT_CONTROLS_PLACEMENT: - editControlsPlacement(); - break; - - // Enable/Disable specific buttons or the entire input overlay. - case MENU_ACTION_TOGGLE_CONTROLS: - toggleControls(); - break; - - // Adjust the scale of the overlay controls. - case MENU_ACTION_ADJUST_SCALE: - adjustScale(); - break; - - // Toggle the visibility of the Performance stats TextView - case MENU_ACTION_SHOW_FPS: { - final boolean isEnabled = !EmulationMenuSettings.getShowFps(); - EmulationMenuSettings.setShowFps(isEnabled); - item.setChecked(isEnabled); - - mEmulationFragment.updateShowFpsOverlay(); - break; - } - // Sets the screen layout to Landscape - case MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE: - changeScreenOrientation(EmulationMenuSettings.LayoutOption_MobileLandscape, item); - break; - - // Sets the screen layout to Portrait - case MENU_ACTION_SCREEN_LAYOUT_PORTRAIT: - changeScreenOrientation(EmulationMenuSettings.LayoutOption_MobilePortrait, item); - break; - - // Sets the screen layout to Single - case MENU_ACTION_SCREEN_LAYOUT_SINGLE: - changeScreenOrientation(EmulationMenuSettings.LayoutOption_SingleScreen, item); - break; - - // Sets the screen layout to Side by Side - case MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE: - changeScreenOrientation(EmulationMenuSettings.LayoutOption_SideScreen, item); - break; - - // Swap the top and bottom screen locations - case MENU_ACTION_SWAP_SCREENS: { - final boolean isEnabled = !EmulationMenuSettings.getSwapScreens(); - EmulationMenuSettings.setSwapScreens(isEnabled); - item.setChecked(isEnabled); - - NativeLibrary.INSTANCE.swapScreens(isEnabled, getWindowManager().getDefaultDisplay() - .getRotation()); - break; - } - - // Reset overlay placement - case MENU_ACTION_RESET_OVERLAY: - resetOverlay(); - break; - - // Show or hide overlay - case MENU_ACTION_SHOW_OVERLAY: { - final boolean isEnabled = !EmulationMenuSettings.getShowOverlay(); - EmulationMenuSettings.setShowOverlay(isEnabled); - item.setChecked(isEnabled); - - mEmulationFragment.refreshInputOverlay(); - break; - } - - case MENU_ACTION_EXIT: - mEmulationFragment.stopEmulation(); - finish(); - break; - - case MENU_ACTION_OPEN_SETTINGS: - SettingsActivity.launch(this, SettingsFile.FILE_NAME_CONFIG, ""); - break; - - case MENU_ACTION_LOAD_AMIIBO: - mOpenFileLauncher.launch(false); - break; - - case MENU_ACTION_REMOVE_AMIIBO: - RemoveAmiibo(); - break; - - case MENU_ACTION_JOYSTICK_REL_CENTER: - final boolean isJoystickRelCenterEnabled = !EmulationMenuSettings.getJoystickRelCenter(); - EmulationMenuSettings.setJoystickRelCenter(isJoystickRelCenterEnabled); - item.setChecked(isJoystickRelCenterEnabled); - break; - - case MENU_ACTION_DPAD_SLIDE_ENABLE: - final boolean isDpadSlideEnabled = !EmulationMenuSettings.getDpadSlideEnable(); - EmulationMenuSettings.setDpadSlideEnable(isDpadSlideEnabled); - item.setChecked(isDpadSlideEnabled); - break; - - case MENU_ACTION_OPEN_CHEATS: - CheatsActivity.launch(this, NativeLibrary.INSTANCE.getRunningTitleId()); - break; - - case MENU_ACTION_CLOSE_GAME: - NativeLibrary.INSTANCE.pauseEmulation(); - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.emulation_close_game) - .setMessage(R.string.emulation_close_game_message) - .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> - { - mEmulationFragment.stopEmulation(); - finish(); - }) - .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> NativeLibrary.INSTANCE.unPauseEmulation()) - .setOnCancelListener(dialogInterface -> NativeLibrary.INSTANCE.unPauseEmulation()) - .show(); - break; - } - - return true; - } - - private void changeScreenOrientation(int layoutOption, MenuItem item) { - item.setChecked(true); - NativeLibrary.INSTANCE.notifyOrientationChange(layoutOption, getWindowManager().getDefaultDisplay() - .getRotation()); - EmulationMenuSettings.setLandscapeScreenLayout(layoutOption); - } - - private void editControlsPlacement() { - if (mEmulationFragment.isConfiguringControls()) { - mEmulationFragment.stopConfiguringControls(); - } else { - mEmulationFragment.startConfiguringControls(); - } - } - - // Gets button presses - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - int action; - int button = mPreferences.getInt(InputBindingSetting.Companion.getInputButtonKey(event.getKeyCode()), event.getKeyCode()); - - switch (event.getAction()) { - case KeyEvent.ACTION_DOWN: - // Handling the case where the back button is pressed. - if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { - onBackPressed(); - return true; - } - - // Normal key events. - action = NativeLibrary.ButtonState.PRESSED; - break; - case KeyEvent.ACTION_UP: - action = NativeLibrary.ButtonState.RELEASED; - break; - default: - return false; - } - InputDevice input = event.getDevice(); - - if (input == null) { - // Controller was disconnected - return false; - } - - return NativeLibrary.INSTANCE.onGamePadEvent(input.getDescriptor(), button, action); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent result) { - super.onActivityResult(requestCode, resultCode, result); - if (requestCode == StillImageCameraHelper.REQUEST_CAMERA_FILE_PICKER) { - StillImageCameraHelper.OnFilePickerResult(resultCode == RESULT_OK ? result : null); - } - } - - private void onAmiiboSelected(String selectedFile) { - boolean success = NativeLibrary.INSTANCE.loadAmiibo(selectedFile); - - if (!success) { - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.amiibo_load_error) - .setMessage(R.string.amiibo_load_error_message) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - } - - private void RemoveAmiibo() { - NativeLibrary.INSTANCE.removeAmiibo(); - } - - private void toggleControls() { - final SharedPreferences.Editor editor = mPreferences.edit(); - boolean[] enabledButtons = new boolean[14]; - - for (int i = 0; i < enabledButtons.length; i++) { - // Buttons that are disabled by default - boolean defaultValue = true; - switch (i) { - case 6: // ZL - case 7: // ZR - case 12: // C-stick - defaultValue = false; - break; - } - - enabledButtons[i] = mPreferences.getBoolean("buttonToggle" + i, defaultValue); - } - - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.emulation_toggle_controls) - .setMultiChoiceItems(R.array.n3dsButtons, enabledButtons, - (dialog, indexSelected, isChecked) -> editor - .putBoolean("buttonToggle" + indexSelected, isChecked)) - .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> - { - editor.apply(); - mEmulationFragment.refreshInputOverlay(); - }) - .show(); - } - - private void adjustScale() { - LayoutInflater inflater = LayoutInflater.from(this); - View view = inflater.inflate(R.layout.dialog_slider, null); - - final Slider slider = view.findViewById(R.id.slider); - final TextView textValue = view.findViewById(R.id.text_value); - final TextView units = view.findViewById(R.id.text_units); - - slider.setValueTo(150); - slider.setValue(mPreferences.getInt("controlScale", 50)); - slider.addOnChangeListener((slider1, progress, fromUser) -> { - textValue.setText(String.valueOf((int) progress + 50)); - setControlScale((int) slider1.getValue()); - }); - - textValue.setText(String.valueOf((int) slider.getValue() + 50)); - units.setText("%"); - - final int previousProgress = (int) slider.getValue(); - - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.emulation_control_scale) - .setView(view) - .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> setControlScale(previousProgress)) - .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> setControlScale((int) slider.getValue())) - .setNeutralButton(R.string.slider_default, (dialogInterface, i) -> setControlScale(50)) - .show(); - } - - private void setControlScale(int scale) { - SharedPreferences.Editor editor = mPreferences.edit(); - editor.putInt("controlScale", scale); - editor.apply(); - mEmulationFragment.refreshInputOverlay(); - } - - private void resetOverlay() { - new MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.emulation_touch_overlay_reset)) - .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> mEmulationFragment.resetInputOverlay()) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - @Override - public boolean dispatchGenericMotionEvent(MotionEvent event) { - if (((event.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) == 0)) { - return super.dispatchGenericMotionEvent(event); - } - - // Don't attempt to do anything if we are disconnecting a device. - if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { - return true; - } - - InputDevice input = event.getDevice(); - List motions = input.getMotionRanges(); - - float[] axisValuesCirclePad = {0.0f, 0.0f}; - float[] axisValuesCStick = {0.0f, 0.0f}; - float[] axisValuesDPad = {0.0f, 0.0f}; - boolean isTriggerPressedLMapped = false; - boolean isTriggerPressedRMapped = false; - boolean isTriggerPressedZLMapped = false; - boolean isTriggerPressedZRMapped = false; - boolean isTriggerPressedL = false; - boolean isTriggerPressedR = false; - boolean isTriggerPressedZL = false; - boolean isTriggerPressedZR = false; - - for (InputDevice.MotionRange range : motions) { - int axis = range.getAxis(); - float origValue = event.getAxisValue(axis); - float value = mControllerMappingHelper.scaleAxis(input, axis, origValue); - int nextMapping = mPreferences.getInt(InputBindingSetting.Companion.getInputAxisButtonKey(axis), -1); - int guestOrientation = mPreferences.getInt(InputBindingSetting.Companion.getInputAxisOrientationKey(axis), -1); - - if (nextMapping == -1 || guestOrientation == -1) { - // Axis is unmapped - continue; - } - - if ((value > 0.f && value < 0.1f) || (value < 0.f && value > -0.1f)) { - // Skip joystick wobble - value = 0.f; - } - - if (nextMapping == NativeLibrary.ButtonType.STICK_LEFT) { - axisValuesCirclePad[guestOrientation] = value; - } else if (nextMapping == NativeLibrary.ButtonType.STICK_C) { - axisValuesCStick[guestOrientation] = value; - } else if (nextMapping == NativeLibrary.ButtonType.DPAD) { - axisValuesDPad[guestOrientation] = value; - } else if (nextMapping == NativeLibrary.ButtonType.TRIGGER_L) { - isTriggerPressedLMapped = true; - isTriggerPressedL = value != 0.f; - } else if (nextMapping == NativeLibrary.ButtonType.TRIGGER_R) { - isTriggerPressedRMapped = true; - isTriggerPressedR = value != 0.f; - } else if (nextMapping == NativeLibrary.ButtonType.BUTTON_ZL) { - isTriggerPressedZLMapped = true; - isTriggerPressedZL = value != 0.f; - } else if (nextMapping == NativeLibrary.ButtonType.BUTTON_ZR) { - isTriggerPressedZRMapped = true; - isTriggerPressedZR = value != 0.f; - } - } - - // Circle-Pad and C-Stick status - NativeLibrary.INSTANCE.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_LEFT, axisValuesCirclePad[0], axisValuesCirclePad[1]); - NativeLibrary.INSTANCE.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_C, axisValuesCStick[0], axisValuesCStick[1]); - - // Triggers L/R and ZL/ZR - if (isTriggerPressedLMapped) { - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_L, isTriggerPressedL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); - } - if (isTriggerPressedRMapped) { - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_R, isTriggerPressedR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); - } - if (isTriggerPressedZLMapped) { - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZL, isTriggerPressedZL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); - } - if (isTriggerPressedZRMapped) { - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZR, isTriggerPressedZR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); - } - - // Work-around to allow D-pad axis to be bound to emulated buttons - if (axisValuesDPad[0] == 0.f) { - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED); - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED); - } - if (axisValuesDPad[0] < 0.f) { - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.PRESSED); - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED); - } - if (axisValuesDPad[0] > 0.f) { - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED); - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.PRESSED); - } - if (axisValuesDPad[1] == 0.f) { - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED); - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED); - } - if (axisValuesDPad[1] < 0.f) { - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.PRESSED); - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED); - } - if (axisValuesDPad[1] > 0.f) { - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED); - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.PRESSED); - } - - return true; - } - - public boolean isActivityRecreated() { - return activityRecreated; - } - - @Retention(SOURCE) - @IntDef({MENU_ACTION_EDIT_CONTROLS_PLACEMENT, MENU_ACTION_TOGGLE_CONTROLS, MENU_ACTION_ADJUST_SCALE, - MENU_ACTION_EXIT, MENU_ACTION_SHOW_FPS, MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE, - MENU_ACTION_SCREEN_LAYOUT_PORTRAIT, MENU_ACTION_SCREEN_LAYOUT_SINGLE, MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE, - MENU_ACTION_SWAP_SCREENS, MENU_ACTION_RESET_OVERLAY, MENU_ACTION_SHOW_OVERLAY, MENU_ACTION_OPEN_SETTINGS}) - public @interface MenuAction { - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.kt new file mode 100644 index 0000000000..587233732b --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.kt @@ -0,0 +1,453 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.activities + +import android.Manifest.permission +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.WindowManager +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.NotificationManagerCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.navigation.fragment.NavHostFragment +import androidx.preference.PreferenceManager +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.NativeLibrary +import org.citra.citra_emu.R +import org.citra.citra_emu.camera.StillImageCameraHelper.OnFilePickerResult +import org.citra.citra_emu.contracts.OpenFileResultContract +import org.citra.citra_emu.databinding.ActivityEmulationBinding +import org.citra.citra_emu.features.settings.model.SettingsViewModel +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting +import org.citra.citra_emu.fragments.MessageDialogFragment +import org.citra.citra_emu.utils.ControllerMappingHelper +import org.citra.citra_emu.utils.EmulationMenuSettings +import org.citra.citra_emu.utils.FileBrowserHelper +import org.citra.citra_emu.utils.ForegroundService +import org.citra.citra_emu.utils.ThemeUtil +import org.citra.citra_emu.viewmodel.EmulationViewModel + +class EmulationActivity : AppCompatActivity() { + private val preferences: SharedPreferences + get() = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + private var foregroundService: Intent? = null + var isActivityRecreated = false + + private val settingsViewModel: SettingsViewModel by viewModels() + private val emulationViewModel: EmulationViewModel by viewModels() + + private lateinit var binding: ActivityEmulationBinding + + override fun onCreate(savedInstanceState: Bundle?) { + ThemeUtil.setTheme(this) + + settingsViewModel.settings.loadSettings() + + super.onCreate(savedInstanceState) + + binding = ActivityEmulationBinding.inflate(layoutInflater) + setContentView(binding.root) + + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + val navController = navHostFragment.navController + navController.setGraph(R.navigation.emulation_navigation, intent.extras) + + isActivityRecreated = savedInstanceState != null + + // Set these options now so that the SurfaceView the game renders into is the right size. + enableFullscreenImmersive() + + // Override Citra core INI with the one set by our in game menu + NativeLibrary.swapScreens( + EmulationMenuSettings.swapScreens, + windowManager.defaultDisplay.rotation + ) + + // Start a foreground service to prevent the app from getting killed in the background + foregroundService = Intent(this, ForegroundService::class.java) + startForegroundService(foregroundService) + } + + // On some devices, the system bars will not disappear on first boot or after some + // rotations. Here we set full screen immersive repeatedly in onResume and in + // onWindowFocusChanged to prevent the unwanted status bar state. + override fun onResume() { + super.onResume() + enableFullscreenImmersive() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + enableFullscreenImmersive() + } + + public override fun onRestart() { + super.onRestart() + NativeLibrary.reloadCameraDevices() + } + + override fun onDestroy() { + stopForegroundService(this) + super.onDestroy() + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + when (requestCode) { + NativeLibrary.REQUEST_CODE_NATIVE_CAMERA -> { + if (grantResults[0] != PackageManager.PERMISSION_GRANTED && + shouldShowRequestPermissionRationale(permission.CAMERA) + ) { + MessageDialogFragment.newInstance( + R.string.camera, + R.string.camera_permission_needed + ).show(supportFragmentManager, MessageDialogFragment.TAG) + } + NativeLibrary.cameraPermissionResult( + grantResults[0] == PackageManager.PERMISSION_GRANTED + ) + } + + NativeLibrary.REQUEST_CODE_NATIVE_MIC -> { + if (grantResults[0] != PackageManager.PERMISSION_GRANTED && + shouldShowRequestPermissionRationale(permission.RECORD_AUDIO) + ) { + MessageDialogFragment.newInstance( + R.string.microphone, + R.string.microphone_permission_needed + ).show(supportFragmentManager, MessageDialogFragment.TAG) + } + NativeLibrary.micPermissionResult( + grantResults[0] == PackageManager.PERMISSION_GRANTED + ) + } + + else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + } + + fun onEmulationStarted() { + emulationViewModel.setEmulationStarted(true) + Toast.makeText( + applicationContext, + getString(R.string.emulation_menu_help), + Toast.LENGTH_LONG + ).show() + } + + private fun enableFullscreenImmersive() { + // TODO: Remove this once we properly account for display insets in the input overlay + window.attributes.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER + + WindowCompat.setDecorFitsSystemWindows(window, false) + + WindowInsetsControllerCompat(window, window.decorView).let { controller -> + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + // Gets button presses + @Suppress("DEPRECATION") + @SuppressLint("GestureBackNavigation") + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + // TODO: Move this check into native code - prevents crash if input pressed before starting emulation + if (!NativeLibrary.isRunning()) { + return false + } + + val button = + preferences.getInt(InputBindingSetting.getInputButtonKey(event.keyCode), event.keyCode) + val action: Int = when (event.action) { + KeyEvent.ACTION_DOWN -> { + // On some devices, the back gesture / button press is not intercepted by androidx + // and fails to open the emulation menu. So we're stuck running deprecated code to + // cover for either a fault on androidx's side or in OEM skins (MIUI at least) + if (event.keyCode == KeyEvent.KEYCODE_BACK) { + onBackPressed() + } + + // Normal key events. + NativeLibrary.ButtonState.PRESSED + } + + KeyEvent.ACTION_UP -> NativeLibrary.ButtonState.RELEASED + else -> return false + } + val input = event.device + ?: // Controller was disconnected + return false + return NativeLibrary.onGamePadEvent(input.descriptor, button, action) + } + + private fun onAmiiboSelected(selectedFile: String) { + val success = NativeLibrary.loadAmiibo(selectedFile) + if (!success) { + MessageDialogFragment.newInstance( + R.string.amiibo_load_error, + R.string.amiibo_load_error_message + ).show(supportFragmentManager, MessageDialogFragment.TAG) + } + } + + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + // TODO: Move this check into native code - prevents crash if input pressed before starting emulation + if (!NativeLibrary.isRunning()) { + return super.dispatchGenericMotionEvent(event) + } + + if (event.source and InputDevice.SOURCE_CLASS_JOYSTICK == 0) { + return super.dispatchGenericMotionEvent(event) + } + + // Don't attempt to do anything if we are disconnecting a device. + if (event.actionMasked == MotionEvent.ACTION_CANCEL) { + return true + } + val input = event.device + val motions = input.motionRanges + val axisValuesCirclePad = floatArrayOf(0.0f, 0.0f) + val axisValuesCStick = floatArrayOf(0.0f, 0.0f) + val axisValuesDPad = floatArrayOf(0.0f, 0.0f) + var isTriggerPressedLMapped = false + var isTriggerPressedRMapped = false + var isTriggerPressedZLMapped = false + var isTriggerPressedZRMapped = false + var isTriggerPressedL = false + var isTriggerPressedR = false + var isTriggerPressedZL = false + var isTriggerPressedZR = false + for (range in motions) { + val axis = range.axis + val origValue = event.getAxisValue(axis) + var value = ControllerMappingHelper.scaleAxis(input, axis, origValue) + val nextMapping = + preferences.getInt(InputBindingSetting.getInputAxisButtonKey(axis), -1) + val guestOrientation = + preferences.getInt(InputBindingSetting.getInputAxisOrientationKey(axis), -1) + if (nextMapping == -1 || guestOrientation == -1) { + // Axis is unmapped + continue + } + if (value > 0f && value < 0.1f || value < 0f && value > -0.1f) { + // Skip joystick wobble + value = 0f + } + when (nextMapping) { + NativeLibrary.ButtonType.STICK_LEFT -> { + axisValuesCirclePad[guestOrientation] = value + } + + NativeLibrary.ButtonType.STICK_C -> { + axisValuesCStick[guestOrientation] = value + } + + NativeLibrary.ButtonType.DPAD -> { + axisValuesDPad[guestOrientation] = value + } + + NativeLibrary.ButtonType.TRIGGER_L -> { + isTriggerPressedLMapped = true + isTriggerPressedL = value != 0f + } + + NativeLibrary.ButtonType.TRIGGER_R -> { + isTriggerPressedRMapped = true + isTriggerPressedR = value != 0f + } + + NativeLibrary.ButtonType.BUTTON_ZL -> { + isTriggerPressedZLMapped = true + isTriggerPressedZL = value != 0f + } + + NativeLibrary.ButtonType.BUTTON_ZR -> { + isTriggerPressedZRMapped = true + isTriggerPressedZR = value != 0f + } + } + } + + // Circle-Pad and C-Stick status + NativeLibrary.onGamePadMoveEvent( + input.descriptor, + NativeLibrary.ButtonType.STICK_LEFT, + axisValuesCirclePad[0], + axisValuesCirclePad[1] + ) + NativeLibrary.onGamePadMoveEvent( + input.descriptor, + NativeLibrary.ButtonType.STICK_C, + axisValuesCStick[0], + axisValuesCStick[1] + ) + + // Triggers L/R and ZL/ZR + if (isTriggerPressedLMapped) { + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.TRIGGER_L, + if (isTriggerPressedL) { + NativeLibrary.ButtonState.PRESSED + } else { + NativeLibrary.ButtonState.RELEASED + } + ) + } + if (isTriggerPressedRMapped) { + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.TRIGGER_R, + if (isTriggerPressedR) { + NativeLibrary.ButtonState.PRESSED + } else { + NativeLibrary.ButtonState.RELEASED + } + ) + } + if (isTriggerPressedZLMapped) { + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.BUTTON_ZL, + if (isTriggerPressedZL) { + NativeLibrary.ButtonState.PRESSED + } else { + NativeLibrary.ButtonState.RELEASED + } + ) + } + if (isTriggerPressedZRMapped) { + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.BUTTON_ZR, + if (isTriggerPressedZR) { + NativeLibrary.ButtonState.PRESSED + } else { + NativeLibrary.ButtonState.RELEASED + } + ) + } + + // Work-around to allow D-pad axis to be bound to emulated buttons + if (axisValuesDPad[0] == 0f) { + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.DPAD_LEFT, + NativeLibrary.ButtonState.RELEASED + ) + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.DPAD_RIGHT, + NativeLibrary.ButtonState.RELEASED + ) + } + if (axisValuesDPad[0] < 0f) { + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.DPAD_LEFT, + NativeLibrary.ButtonState.PRESSED + ) + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.DPAD_RIGHT, + NativeLibrary.ButtonState.RELEASED + ) + } + if (axisValuesDPad[0] > 0f) { + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.DPAD_LEFT, + NativeLibrary.ButtonState.RELEASED + ) + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.DPAD_RIGHT, + NativeLibrary.ButtonState.PRESSED + ) + } + if (axisValuesDPad[1] == 0f) { + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.DPAD_UP, + NativeLibrary.ButtonState.RELEASED + ) + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.DPAD_DOWN, + NativeLibrary.ButtonState.RELEASED + ) + } + if (axisValuesDPad[1] < 0f) { + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.DPAD_UP, + NativeLibrary.ButtonState.PRESSED + ) + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.DPAD_DOWN, + NativeLibrary.ButtonState.RELEASED + ) + } + if (axisValuesDPad[1] > 0f) { + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.DPAD_UP, + NativeLibrary.ButtonState.RELEASED + ) + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + NativeLibrary.ButtonType.DPAD_DOWN, + NativeLibrary.ButtonState.PRESSED + ) + } + return true + } + + val openFileLauncher = + registerForActivityResult(OpenFileResultContract()) { result: Intent? -> + if (result == null) return@registerForActivityResult + val selectedFiles = FileBrowserHelper.getSelectedFiles( + result, applicationContext, listOf("bin") + ) ?: return@registerForActivityResult + onAmiiboSelected(selectedFiles[0]) + } + + val openImageLauncher = + registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { result: Uri? -> + if (result == null) { + return@registerForActivityResult + } + + OnFilePickerResult(result.toString()) + } + + companion object { + fun stopForegroundService(activity: Activity) { + val startIntent = Intent(activity, ForegroundService::class.java) + startIntent.action = ForegroundService.ACTION_STOP + activity.startForegroundService(startIntent) + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt index b507ea5bdd..e84aacb1e2 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt @@ -15,6 +15,7 @@ import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModelProvider +import androidx.navigation.findNavController import androidx.preference.PreferenceManager import androidx.recyclerview.widget.AsyncDifferConfig import androidx.recyclerview.widget.DiffUtil @@ -22,6 +23,7 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.citra.citra_emu.HomeNavigationDirections import org.citra.citra_emu.CitraApplication import org.citra.citra_emu.R import org.citra.citra_emu.activities.EmulationActivity @@ -77,7 +79,8 @@ class GameAdapter(private val activity: AppCompatActivity) : ) .apply() - EmulationActivity.launch(activity, holder.game.path, holder.game.title) + val action = HomeNavigationDirections.actionGlobalEmulationActivity(holder.game) + view.findNavController().navigate(action) } /** diff --git a/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java deleted file mode 100644 index 55be2660a9..0000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2020 Citra Emulator Project -// Licensed under GPLv2 or any later version -// Refer to the license.txt file included. - -package org.citra.citra_emu.camera; - -import android.content.Intent; -import android.graphics.Bitmap; -import android.provider.MediaStore; - -import org.citra.citra_emu.NativeLibrary; -import org.citra.citra_emu.R; -import org.citra.citra_emu.activities.EmulationActivity; -import org.citra.citra_emu.utils.PicassoUtils; - -import androidx.annotation.Keep; -import androidx.annotation.Nullable; - -// Used in native code. -public final class StillImageCameraHelper { - public static final int REQUEST_CAMERA_FILE_PICKER = 1; - private static final Object filePickerLock = new Object(); - private static @Nullable - String filePickerPath; - - // Opens file picker for camera. - @Keep - public static @Nullable - String OpenFilePicker() { - final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); - - // At this point, we are assuming that we already have permissions as they are - // needed to launch a game - emulationActivity.runOnUiThread(() -> { - Intent intent = new Intent(Intent.ACTION_PICK); - intent.setDataAndType(MediaStore.Images.Media.INTERNAL_CONTENT_URI, "image/*"); - emulationActivity.startActivityForResult( - Intent.createChooser(intent, - emulationActivity.getString(R.string.camera_select_image)), - REQUEST_CAMERA_FILE_PICKER); - }); - - synchronized (filePickerLock) { - try { - filePickerLock.wait(); - } catch (InterruptedException ignored) { - } - } - - return filePickerPath; - } - - // Called from EmulationActivity. - public static void OnFilePickerResult(Intent result) { - filePickerPath = result == null ? null : result.getDataString(); - - synchronized (filePickerLock) { - filePickerLock.notifyAll(); - } - } - - // Blocking call. Load image from file and crop/resize it to fit in width x height. - @Keep - @Nullable - public static Bitmap LoadImageFromFile(String uri, int width, int height) { - return PicassoUtils.LoadBitmapFromFile(uri, width, height); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.kt b/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.kt new file mode 100644 index 0000000000..c1d45e3af2 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.kt @@ -0,0 +1,67 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.camera + +import android.graphics.Bitmap +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.Keep +import androidx.core.graphics.drawable.toBitmap +import coil.executeBlocking +import coil.imageLoader +import coil.request.ImageRequest +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.NativeLibrary + +// Used in native code. +object StillImageCameraHelper { + private val filePickerLock = Object() + private var filePickerPath: String? = null + + // Opens file picker for camera. + @Keep + @JvmStatic + fun OpenFilePicker(): String? { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + + // At this point, we are assuming that we already have permissions as they are + // needed to launch a game + emulationActivity!!.runOnUiThread { + val request = PickVisualMediaRequest.Builder() + .setMediaType(ActivityResultContracts.PickVisualMedia.ImageOnly).build() + emulationActivity.openImageLauncher.launch(request) + } + synchronized(filePickerLock) { + try { + filePickerLock.wait() + } catch (ignored: InterruptedException) { + } + } + return filePickerPath + } + + // Called from EmulationActivity. + @JvmStatic + fun OnFilePickerResult(result: String) { + filePickerPath = result + synchronized(filePickerLock) { filePickerLock.notifyAll() } + } + + // Blocking call. Load image from file and crop/resize it to fit in width x height. + @Keep + @JvmStatic + fun LoadImageFromFile(uri: String?, width: Int, height: Int): Bitmap? { + val context = CitraApplication.appContext + val request = ImageRequest.Builder(context) + .data(uri) + .size(width, height) + .build() + return context.imageLoader.executeBlocking(request).drawable?.toBitmap( + width, + height, + Bitmap.Config.ARGB_8888 + ) + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java deleted file mode 100644 index 834bd3317a..0000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java +++ /dev/null @@ -1,337 +0,0 @@ -package org.citra.citra_emu.fragments; - -import android.content.Context; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.os.Handler; -import android.preference.PreferenceManager; -import android.view.Choreographer; -import android.view.LayoutInflater; -import android.view.Surface; -import android.view.SurfaceHolder; -import android.view.SurfaceView; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; - -import org.citra.citra_emu.NativeLibrary; -import org.citra.citra_emu.R; -import org.citra.citra_emu.activities.EmulationActivity; -import org.citra.citra_emu.overlay.InputOverlay; -import org.citra.citra_emu.utils.DirectoryInitialization; -import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState; -import org.citra.citra_emu.utils.EmulationMenuSettings; -import org.citra.citra_emu.utils.Log; - -public final class EmulationFragment extends Fragment implements SurfaceHolder.Callback, Choreographer.FrameCallback { - private static final String KEY_GAMEPATH = "gamepath"; - - private static final Handler perfStatsUpdateHandler = new Handler(); - - private SharedPreferences mPreferences; - - private InputOverlay mInputOverlay; - - private EmulationState mEmulationState; - - private EmulationActivity activity; - - private TextView mPerfStats; - - private Runnable perfStatsUpdater; - - public static EmulationFragment newInstance(String gamePath) { - Bundle args = new Bundle(); - args.putString(KEY_GAMEPATH, gamePath); - - EmulationFragment fragment = new EmulationFragment(); - fragment.setArguments(args); - return fragment; - } - - @Override - public void onAttach(@NonNull Context context) { - super.onAttach(context); - - if (context instanceof EmulationActivity) { - activity = (EmulationActivity) context; - NativeLibrary.INSTANCE.setEmulationActivity((EmulationActivity) context); - } else { - throw new IllegalStateException("EmulationFragment must have EmulationActivity parent"); - } - } - - /** - * Initialize anything that doesn't depend on the layout / views in here. - */ - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - // So this fragment doesn't restart on configuration changes; i.e. rotation. - setRetainInstance(true); - - mPreferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); - - String gamePath = getArguments().getString(KEY_GAMEPATH); - mEmulationState = new EmulationState(gamePath); - } - - /** - * Initialize the UI and start emulation in here. - */ - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View contents = inflater.inflate(R.layout.fragment_emulation, container, false); - - SurfaceView surfaceView = contents.findViewById(R.id.surface_emulation); - surfaceView.getHolder().addCallback(this); - - mInputOverlay = contents.findViewById(R.id.surface_input_overlay); - mPerfStats = contents.findViewById(R.id.show_fps_text); - - Button doneButton = contents.findViewById(R.id.done_control_config); - if (doneButton != null) { - doneButton.setOnClickListener(v -> stopConfiguringControls()); - } - - // Show/hide the "Show FPS" overlay - updateShowFpsOverlay(); - - // The new Surface created here will get passed to the native code via onSurfaceChanged. - return contents; - } - - @Override - public void onResume() { - super.onResume(); - Choreographer.getInstance().postFrameCallback(this); - mEmulationState.run(activity.isActivityRecreated()); - } - - @Override - public void onPause() { - if (mEmulationState.isRunning()) { - mEmulationState.pause(); - } - - Choreographer.getInstance().removeFrameCallback(this); - super.onPause(); - } - - @Override - public void onDetach() { - NativeLibrary.INSTANCE.clearEmulationActivity(); - super.onDetach(); - } - - public void refreshInputOverlay() { - mInputOverlay.refreshControls(); - } - - public void resetInputOverlay() { - // Reset button scale - SharedPreferences.Editor editor = mPreferences.edit(); - editor.putInt("controlScale", 50); - editor.apply(); - - mInputOverlay.resetButtonPlacement(); - } - - public void updateShowFpsOverlay() { - if (EmulationMenuSettings.getShowFps()) { - final int SYSTEM_FPS = 0; - final int FPS = 1; - final int FRAMETIME = 2; - final int SPEED = 3; - - perfStatsUpdater = () -> - { - final double[] perfStats = NativeLibrary.INSTANCE.getPerfStats(); - if (perfStats[FPS] > 0) { - mPerfStats.setText(String.format("FPS: %d Speed: %d%%", (int) (perfStats[FPS] + 0.5), - (int) (perfStats[SPEED] * 100.0 + 0.5))); - } - - perfStatsUpdateHandler.postDelayed(perfStatsUpdater, 3000); - }; - perfStatsUpdateHandler.post(perfStatsUpdater); - - mPerfStats.setVisibility(View.VISIBLE); - } else { - if (perfStatsUpdater != null) { - perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater); - } - - mPerfStats.setVisibility(View.GONE); - } - } - - @Override - public void surfaceCreated(SurfaceHolder holder) { - // We purposely don't do anything here. - // All work is done in surfaceChanged, which we are guaranteed to get even for surface creation. - } - - @Override - public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { - Log.debug("[EmulationFragment] Surface changed. Resolution: " + width + "x" + height); - mEmulationState.newSurface(holder.getSurface()); - } - - @Override - public void surfaceDestroyed(SurfaceHolder holder) { - mEmulationState.clearSurface(); - } - - @Override - public void doFrame(long frameTimeNanos) { - Choreographer.getInstance().postFrameCallback(this); - NativeLibrary.INSTANCE.doFrame(); - } - - public void stopEmulation() { - mEmulationState.stop(); - } - - public void startConfiguringControls() { - getView().findViewById(R.id.done_control_config).setVisibility(View.VISIBLE); - mInputOverlay.setIsInEditMode(true); - } - - public void stopConfiguringControls() { - getView().findViewById(R.id.done_control_config).setVisibility(View.GONE); - mInputOverlay.setIsInEditMode(false); - } - - public boolean isConfiguringControls() { - return mInputOverlay.isInEditMode(); - } - - private static class EmulationState { - private final String mGamePath; - private State state; - private Surface mSurface; - private boolean mRunWhenSurfaceIsValid; - - EmulationState(String gamePath) { - mGamePath = gamePath; - // Starting state is stopped. - state = State.STOPPED; - } - - public synchronized boolean isStopped() { - return state == State.STOPPED; - } - - // Getters for the current state - - public synchronized boolean isPaused() { - return state == State.PAUSED; - } - - public synchronized boolean isRunning() { - return state == State.RUNNING; - } - - public synchronized void stop() { - if (state != State.STOPPED) { - Log.debug("[EmulationFragment] Stopping emulation."); - state = State.STOPPED; - NativeLibrary.INSTANCE.stopEmulation(); - } else { - Log.warning("[EmulationFragment] Stop called while already stopped."); - } - } - - // State changing methods - - public synchronized void pause() { - if (state != State.PAUSED) { - state = State.PAUSED; - Log.debug("[EmulationFragment] Pausing emulation."); - - // Release the surface before pausing, since emulation has to be running for that. - NativeLibrary.INSTANCE.surfaceDestroyed(); - NativeLibrary.INSTANCE.pauseEmulation(); - } else { - Log.warning("[EmulationFragment] Pause called while already paused."); - } - } - - public synchronized void run(boolean isActivityRecreated) { - if (isActivityRecreated) { - if (NativeLibrary.INSTANCE.isRunning()) { - state = State.PAUSED; - } - } else { - Log.debug("[EmulationFragment] activity resumed or fresh start"); - } - - // If the surface is set, run now. Otherwise, wait for it to get set. - if (mSurface != null) { - runWithValidSurface(); - } else { - mRunWhenSurfaceIsValid = true; - } - } - - // Surface callbacks - public synchronized void newSurface(Surface surface) { - mSurface = surface; - if (mRunWhenSurfaceIsValid) { - runWithValidSurface(); - } - } - - public synchronized void clearSurface() { - if (mSurface == null) { - Log.warning("[EmulationFragment] clearSurface called, but surface already null."); - } else { - mSurface = null; - Log.debug("[EmulationFragment] Surface destroyed."); - - if (state == State.RUNNING) { - NativeLibrary.INSTANCE.surfaceDestroyed(); - state = State.PAUSED; - } else if (state == State.PAUSED) { - Log.warning("[EmulationFragment] Surface cleared while emulation paused."); - } else { - Log.warning("[EmulationFragment] Surface cleared while emulation stopped."); - } - } - } - - private void runWithValidSurface() { - mRunWhenSurfaceIsValid = false; - if (state == State.STOPPED) { - NativeLibrary.INSTANCE.surfaceChanged(mSurface); - Thread mEmulationThread = new Thread(() -> - { - Log.debug("[EmulationFragment] Starting emulation thread."); - NativeLibrary.INSTANCE.run(mGamePath); - }, "NativeEmulation"); - mEmulationThread.start(); - - } else if (state == State.PAUSED) { - Log.debug("[EmulationFragment] Resuming emulation."); - NativeLibrary.INSTANCE.surfaceChanged(mSurface); - NativeLibrary.INSTANCE.unPauseEmulation(); - } else { - Log.debug("[EmulationFragment] Bug, run called while already running."); - } - state = State.RUNNING; - } - - private enum State { - STOPPED, RUNNING, PAUSED - } - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.kt new file mode 100644 index 0000000000..62c787c1e7 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.kt @@ -0,0 +1,1048 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.annotation.SuppressLint +import android.content.Context +import android.content.DialogInterface +import android.content.SharedPreferences +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.view.Choreographer +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.MotionEvent +import android.view.Surface +import android.view.SurfaceHolder +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import android.widget.TextView +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.drawerlayout.widget.DrawerLayout +import androidx.drawerlayout.widget.DrawerLayout.DrawerListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.preference.PreferenceManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.EmulationNavigationDirections +import org.citra.citra_emu.NativeLibrary +import org.citra.citra_emu.R +import org.citra.citra_emu.activities.EmulationActivity +import org.citra.citra_emu.databinding.DialogCheckboxBinding +import org.citra.citra_emu.databinding.DialogSliderBinding +import org.citra.citra_emu.databinding.FragmentEmulationBinding +import org.citra.citra_emu.features.settings.ui.SettingsActivity +import org.citra.citra_emu.features.settings.utils.SettingsFile +import org.citra.citra_emu.model.Game +import org.citra.citra_emu.utils.DirectoryInitialization +import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState +import org.citra.citra_emu.utils.EmulationMenuSettings +import org.citra.citra_emu.utils.FileUtil +import org.citra.citra_emu.utils.GameHelper +import org.citra.citra_emu.utils.GameIconUtils +import org.citra.citra_emu.utils.Log +import org.citra.citra_emu.utils.ViewUtils +import org.citra.citra_emu.viewmodel.EmulationViewModel +import java.lang.NullPointerException + +class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.FrameCallback { + private val preferences: SharedPreferences + get() = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + + private lateinit var emulationState: EmulationState + private var perfStatsUpdater: Runnable? = null + + private lateinit var emulationActivity: EmulationActivity + + private var _binding: FragmentEmulationBinding? = null + private val binding get() = _binding!! + + private val args by navArgs() + + private lateinit var game: Game + + private val emulationViewModel: EmulationViewModel by activityViewModels() + + override fun onAttach(context: Context) { + super.onAttach(context) + if (context is EmulationActivity) { + emulationActivity = context + NativeLibrary.setEmulationActivity(context) + } else { + throw IllegalStateException("EmulationFragment must have EmulationActivity parent") + } + } + + /** + * Initialize anything that doesn't depend on the layout / views in here. + */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val intent = requireActivity().intent + val intentUri: Uri? = intent.data + val oldIntentInfo = Pair( + intent.getStringExtra("SelectedGame"), + intent.getStringExtra("SelectedTitle") + ) + var intentGame: Game? = null + if (intentUri != null) { + intentGame = if (Game.extensions.contains(FileUtil.getExtension(intentUri))) { + GameHelper.getGame(intentUri, isInstalled = false, addedToLibrary = false) + } else { + null + } + } else if (oldIntentInfo.first != null) { + val gameUri = Uri.parse(oldIntentInfo.first) + intentGame = if (Game.extensions.contains(FileUtil.getExtension(gameUri))) { + GameHelper.getGame(gameUri, isInstalled = false, addedToLibrary = false) + } else { + null + } + } + + try { + game = args.game ?: intentGame!! + } catch (e: NullPointerException) { + Toast.makeText( + requireContext(), + R.string.no_game_present, + Toast.LENGTH_SHORT + ).show() + requireActivity().finish() + return + } + + // So this fragment doesn't restart on configuration changes; i.e. rotation. + retainInstance = true + emulationState = EmulationState(game.path) + emulationActivity = requireActivity() as EmulationActivity + } + + /** + * Initialize the UI and start emulation in here. + */ + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentEmulationBinding.inflate(inflater) + return binding.root + } + + // This is using the correct scope, lint is just acting up + @SuppressLint("UnsafeRepeatOnLifecycleDetector") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (requireActivity().isFinishing) { + return + } + + binding.surfaceEmulation.holder.addCallback(this) + binding.doneControlConfig.setOnClickListener { + binding.doneControlConfig.visibility = View.GONE + binding.surfaceInputOverlay.setIsInEditMode(false) + } + + // Show/hide the "Show FPS" overlay + updateShowFpsOverlay() + + binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + binding.drawerLayout.addDrawerListener(object : DrawerListener { + override fun onDrawerSlide(drawerView: View, slideOffset: Float) { + binding.surfaceInputOverlay.dispatchTouchEvent( + MotionEvent.obtain( + SystemClock.uptimeMillis(), + SystemClock.uptimeMillis() + 100, + MotionEvent.ACTION_UP, + 0f, + 0f, + 0 + ) + ) + } + + override fun onDrawerOpened(drawerView: View) { + binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED) + } + + override fun onDrawerClosed(drawerView: View) { + binding.drawerLayout.setDrawerLockMode(EmulationMenuSettings.drawerLockMode) + } + + override fun onDrawerStateChanged(newState: Int) { + // No op + } + }) + binding.inGameMenu.menu.findItem(R.id.menu_lock_drawer).apply { + val titleId = if (EmulationMenuSettings.drawerLockMode == DrawerLayout.LOCK_MODE_LOCKED_CLOSED) { + R.string.unlock_drawer + } else { + R.string.lock_drawer + } + val iconId = if (EmulationMenuSettings.drawerLockMode == DrawerLayout.LOCK_MODE_UNLOCKED) { + R.drawable.ic_unlocked + } else { + R.drawable.ic_lock + } + + title = getString(titleId) + icon = ResourcesCompat.getDrawable( + resources, + iconId, + requireContext().theme + ) + } + + binding.inGameMenu.getHeaderView(0).findViewById(R.id.text_game_title).text = + game.title + binding.inGameMenu.setNavigationItemSelectedListener { + when (it.itemId) { + R.id.menu_emulation_pause -> { + if (emulationState.isPaused) { + emulationState.unpause() + it.title = resources.getString(R.string.pause_emulation) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_pause, + requireContext().theme + ) + } else { + emulationState.pause() + it.title = resources.getString(R.string.resume_emulation) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_play, + requireContext().theme + ) + } + true + } + + R.id.menu_emulation_savestates -> { + showSavestateMenu() + true + } + + R.id.menu_overlay_options -> { + showOverlayMenu() + true + } + + R.id.menu_amiibo -> { + showAmiiboMenu() + true + } + + R.id.menu_landscape_screen_layout -> { + showScreenLayoutMenu() + true + } + + R.id.menu_swap_screens -> { + val isEnabled = !EmulationMenuSettings.swapScreens + EmulationMenuSettings.swapScreens = isEnabled + NativeLibrary.swapScreens( + isEnabled, + requireActivity().windowManager.defaultDisplay.rotation + ) + true + } + + R.id.menu_lock_drawer -> { + when (EmulationMenuSettings.drawerLockMode) { + DrawerLayout.LOCK_MODE_UNLOCKED -> { + EmulationMenuSettings.drawerLockMode = + DrawerLayout.LOCK_MODE_LOCKED_CLOSED + it.title = resources.getString(R.string.unlock_drawer) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_lock, + requireContext().theme + ) + } + + DrawerLayout.LOCK_MODE_LOCKED_CLOSED -> { + EmulationMenuSettings.drawerLockMode = DrawerLayout.LOCK_MODE_UNLOCKED + it.title = resources.getString(R.string.lock_drawer) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_unlocked, + requireContext().theme + ) + } + } + true + } + + R.id.menu_cheats -> { + val action = EmulationNavigationDirections + .actionGlobalCheatsActivity(NativeLibrary.getRunningTitleId()) + binding.root.findNavController().navigate(action) + true + } + + R.id.menu_settings -> { + SettingsActivity.launch( + requireContext(), + SettingsFile.FILE_NAME_CONFIG, + "" + ) + true + } + + R.id.menu_exit -> { + NativeLibrary.pauseEmulation() + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.emulation_close_game) + .setMessage(R.string.emulation_close_game_message) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + emulationState.stop() + requireActivity().finish() + } + .setNegativeButton(android.R.string.cancel) { _: DialogInterface?, _: Int -> + NativeLibrary.unPauseEmulation() + } + .setOnCancelListener { NativeLibrary.unPauseEmulation() } + .show() + true + } + + else -> true + } + } + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (!emulationViewModel.emulationStarted.value) { + return + } + + if (binding.drawerLayout.isOpen) { + binding.drawerLayout.close() + } else { + binding.drawerLayout.open() + } + } + } + ) + + GameIconUtils.loadGameIcon(requireActivity(), game, binding.loadingImage) + binding.loadingTitle.text = game.title + + viewLifecycleOwner.lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + emulationViewModel.shaderProgress.collectLatest { + if (it > 0 && it != emulationViewModel.totalShaders.value) { + binding.loadingProgressIndicator.isIndeterminate = false + binding.loadingProgressText.visibility = View.VISIBLE + binding.loadingProgressText.text = String.format( + "%d/%d", + emulationViewModel.shaderProgress.value, + emulationViewModel.totalShaders.value + ) + + if (it < binding.loadingProgressIndicator.max) { + binding.loadingProgressIndicator.progress = it + } + } + + if (it == emulationViewModel.totalShaders.value) { + binding.loadingText.setText(R.string.loading) + binding.loadingProgressIndicator.isIndeterminate = true + binding.loadingProgressText.visibility = View.GONE + } + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + emulationViewModel.totalShaders.collectLatest { + binding.loadingProgressIndicator.max = it + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + emulationViewModel.shaderMessage.collectLatest { + if (it != "") { + binding.loadingText.text = it + } + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + emulationViewModel.emulationStarted.collectLatest { started -> + if (started) { + ViewUtils.hideView(binding.loadingIndicator) + ViewUtils.showView(binding.surfaceInputOverlay) + binding.inGameMenu.menu.findItem(R.id.menu_emulation_savestates) + .setVisible(NativeLibrary.getSavestateInfo() != null) + binding.drawerLayout.setDrawerLockMode(EmulationMenuSettings.drawerLockMode) + } + } + } + } + } + + setInsets() + } + + override fun onResume() { + super.onResume() + Choreographer.getInstance().postFrameCallback(this) + if (NativeLibrary.isRunning()) { + NativeLibrary.unPauseEmulation() + return + } + + if (DirectoryInitialization.areCitraDirectoriesReady()) { + emulationState.run(emulationActivity.isActivityRecreated) + } else { + setupCitraDirectoriesThenStartEmulation() + } + } + + override fun onPause() { + if (NativeLibrary.isRunning()) { + emulationState.pause() + } + Choreographer.getInstance().removeFrameCallback(this) + super.onPause() + } + + override fun onDetach() { + NativeLibrary.clearEmulationActivity() + super.onDetach() + } + + private fun setupCitraDirectoriesThenStartEmulation() { + val directoryInitializationState = DirectoryInitialization.start() + if (directoryInitializationState === + DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED + ) { + emulationState.run(emulationActivity.isActivityRecreated) + } else if (directoryInitializationState === + DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED + ) { + Toast.makeText(context, R.string.write_permission_needed, Toast.LENGTH_SHORT) + .show() + } else if (directoryInitializationState === + DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE + ) { + Toast.makeText( + context, + R.string.external_storage_not_mounted, + Toast.LENGTH_SHORT + ).show() + } + } + + private fun showSavestateMenu() { + val popupMenu = PopupMenu( + requireContext(), + binding.inGameMenu.findViewById(R.id.menu_emulation_savestates) + ) + + popupMenu.menuInflater.inflate(R.menu.menu_savestates, popupMenu.menu) + + popupMenu.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_emulation_save_state -> { + showSaveStateSubmenu() + true + } + + R.id.menu_emulation_load_state -> { + showLoadStateSubmenu() + true + } + + else -> true + } + } + + popupMenu.show() + } + + private fun showSaveStateSubmenu() { + val savestates = NativeLibrary.getSavestateInfo() + + val popupMenu = PopupMenu( + requireContext(), + binding.inGameMenu.findViewById(R.id.menu_emulation_savestates) + ) + + popupMenu.menu.apply { + for (i in 0 until NativeLibrary.SAVESTATE_SLOT_COUNT) { + val slot = i + 1 + val text = getString(R.string.emulation_empty_state_slot, slot) + add(text).setEnabled(true).setOnMenuItemClickListener { + displaySavestateWarning() + NativeLibrary.saveState(slot) + true + } + } + } + + savestates?.forEach { + val text = getString(R.string.emulation_occupied_state_slot, it.slot, it.time) + popupMenu.menu.getItem(it.slot - 1).setTitle(text) + } + + popupMenu.show() + } + + private fun showLoadStateSubmenu() { + val savestates = NativeLibrary.getSavestateInfo() + + val popupMenu = PopupMenu( + requireContext(), + binding.inGameMenu.findViewById(R.id.menu_emulation_savestates) + ) + + popupMenu.menu.apply { + for (i in 0 until NativeLibrary.SAVESTATE_SLOT_COUNT) { + val slot = i + 1 + val text = getString(R.string.emulation_empty_state_slot, slot) + add(text).setEnabled(false).setOnMenuItemClickListener { + NativeLibrary.loadState(slot) + true + } + } + } + + savestates?.forEach { + val text = getString(R.string.emulation_occupied_state_slot, it.slot, it.time) + popupMenu.menu.getItem(it.slot - 1).setTitle(text).setEnabled(true) + } + + popupMenu.show() + } + + private fun displaySavestateWarning() { + if (preferences.getBoolean("savestateWarningShown", false)) { + return + } + + val dialogCheckboxBinding = DialogCheckboxBinding.inflate(layoutInflater) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.savestates) + .setMessage(R.string.savestate_warning_message) + .setView(dialogCheckboxBinding.root) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + preferences.edit() + .putBoolean("savestateWarningShown", dialogCheckboxBinding.checkBox.isChecked) + .apply() + } + .show() + } + + private fun showOverlayMenu() { + val popupMenu = PopupMenu( + requireContext(), + binding.inGameMenu.findViewById(R.id.menu_overlay_options) + ) + + popupMenu.menuInflater.inflate(R.menu.menu_overlay_options, popupMenu.menu) + + popupMenu.menu.apply { + findItem(R.id.menu_show_overlay).isChecked = EmulationMenuSettings.showOverlay + findItem(R.id.menu_show_fps).isChecked = EmulationMenuSettings.showFps + findItem(R.id.menu_emulation_joystick_rel_center).isChecked = + EmulationMenuSettings.joystickRelCenter + findItem(R.id.menu_emulation_dpad_slide_enable).isChecked = + EmulationMenuSettings.dpadSlide + } + + popupMenu.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_show_overlay -> { + EmulationMenuSettings.showOverlay = !EmulationMenuSettings.showOverlay + binding.surfaceInputOverlay.refreshControls() + true + } + + R.id.menu_show_fps -> { + EmulationMenuSettings.showFps = !EmulationMenuSettings.showFps + updateShowFpsOverlay() + true + } + + R.id.menu_emulation_edit_layout -> { + editControlsPlacement() + binding.drawerLayout.close() + true + } + + R.id.menu_emulation_toggle_controls -> { + showToggleControlsDialog() + true + } + + R.id.menu_emulation_adjust_scale -> { + showAdjustScaleDialog() + true + } + + R.id.menu_emulation_joystick_rel_center -> { + EmulationMenuSettings.joystickRelCenter = + !EmulationMenuSettings.joystickRelCenter + true + } + + R.id.menu_emulation_dpad_slide_enable -> { + EmulationMenuSettings.dpadSlide = !EmulationMenuSettings.dpadSlide + true + } + + R.id.menu_emulation_reset_overlay -> { + showResetOverlayDialog() + true + } + + else -> true + } + } + + popupMenu.show() + } + + private fun showAmiiboMenu() { + val popupMenu = PopupMenu( + requireContext(), + binding.inGameMenu.findViewById(R.id.menu_amiibo) + ) + + popupMenu.menuInflater.inflate(R.menu.menu_amiibo_options, popupMenu.menu) + + popupMenu.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_emulation_amiibo_load -> { + emulationActivity.openFileLauncher.launch(false) + true + } + + R.id.menu_emulation_amiibo_remove -> { + NativeLibrary.removeAmiibo() + true + } + + else -> true + } + } + + popupMenu.show() + } + + private fun showScreenLayoutMenu() { + val popupMenu = PopupMenu( + requireContext(), + binding.inGameMenu.findViewById(R.id.menu_landscape_screen_layout) + ) + + popupMenu.menuInflater.inflate(R.menu.menu_landscape_screen_layout, popupMenu.menu) + + val layoutOptionMenuItem = when (EmulationMenuSettings.landscapeScreenLayout) { + EmulationMenuSettings.LayoutOption_SingleScreen -> + R.id.menu_screen_layout_single + + EmulationMenuSettings.LayoutOption_SideScreen -> + R.id.menu_screen_layout_sidebyside + + EmulationMenuSettings.LayoutOption_MobilePortrait -> + R.id.menu_screen_layout_portrait + + else -> R.id.menu_screen_layout_landscape + } + popupMenu.menu.findItem(layoutOptionMenuItem).setChecked(true) + + popupMenu.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_screen_layout_landscape -> { + changeScreenOrientation(EmulationMenuSettings.LayoutOption_MobileLandscape, it) + true + } + + R.id.menu_screen_layout_portrait -> { + changeScreenOrientation(EmulationMenuSettings.LayoutOption_MobilePortrait, it) + true + } + + R.id.menu_screen_layout_single -> { + changeScreenOrientation(EmulationMenuSettings.LayoutOption_SingleScreen, it) + true + } + + R.id.menu_screen_layout_sidebyside -> { + changeScreenOrientation(EmulationMenuSettings.LayoutOption_SideScreen, it) + true + } + + else -> true + } + } + + popupMenu.show() + } + + private fun changeScreenOrientation(layoutOption: Int, item: MenuItem) { + item.setChecked(true) + NativeLibrary.notifyOrientationChange( + layoutOption, + requireActivity().windowManager.defaultDisplay.rotation + ) + EmulationMenuSettings.landscapeScreenLayout = layoutOption + } + + private fun editControlsPlacement() { + if (binding.surfaceInputOverlay.isInEditMode) { + binding.doneControlConfig.visibility = View.GONE + binding.surfaceInputOverlay.setIsInEditMode(false) + } else { + binding.doneControlConfig.visibility = View.VISIBLE + binding.surfaceInputOverlay.setIsInEditMode(true) + } + } + + private fun showToggleControlsDialog() { + val editor = preferences.edit() + val enabledButtons = BooleanArray(14) + enabledButtons.forEachIndexed { i: Int, _: Boolean -> + // Buttons that are disabled by default + var defaultValue = true + when (i) { + 6, 7, 12, 13 -> defaultValue = false + } + enabledButtons[i] = preferences.getBoolean("buttonToggle$i", defaultValue) + } + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.emulation_toggle_controls) + .setMultiChoiceItems( + R.array.n3dsButtons, enabledButtons + ) { _: DialogInterface?, indexSelected: Int, isChecked: Boolean -> + editor.putBoolean("buttonToggle$indexSelected", isChecked) + } + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + editor.apply() + binding.surfaceInputOverlay.refreshControls() + } + .show() + } + + private fun showAdjustScaleDialog() { + val sliderBinding = DialogSliderBinding.inflate(layoutInflater) + + sliderBinding.apply { + slider.valueTo = 150f + slider.value = preferences.getInt("controlScale", 50).toFloat() + slider.addOnChangeListener( + Slider.OnChangeListener { slider: Slider, progress: Float, _: Boolean -> + textValue.text = (progress.toInt() + 50).toString() + setControlScale(slider.value.toInt()) + }) + textValue.text = (sliderBinding.slider.value.toInt() + 50).toString() + textUnits.text = "%" + } + val previousProgress = sliderBinding.slider.value.toInt() + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.emulation_control_scale) + .setView(sliderBinding.root) + .setNegativeButton(android.R.string.cancel) { _: DialogInterface?, _: Int -> + setControlScale(previousProgress) + } + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + setControlScale(sliderBinding.slider.value.toInt()) + } + .setNeutralButton(R.string.slider_default) { _: DialogInterface?, _: Int -> + setControlScale(50) + } + .show() + } + + private fun setControlScale(scale: Int) { + preferences.edit() + .putInt("controlScale", scale) + .apply() + binding.surfaceInputOverlay.refreshControls() + } + + private fun showResetOverlayDialog() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.emulation_touch_overlay_reset)) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + resetInputOverlay() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun resetInputOverlay() { + preferences.edit() + .putInt("controlScale", 50) + .apply() + + val editor = preferences.edit() + for (i in 0 until 14) { + var defaultValue = true + when (i) { + 6, 7, 12, 13 -> defaultValue = false + } + editor.putBoolean("buttonToggle$i", defaultValue) + } + editor.apply() + + binding.surfaceInputOverlay.resetButtonPlacement() + } + + fun updateShowFpsOverlay() { + if (EmulationMenuSettings.showFps) { + val SYSTEM_FPS = 0 + val FPS = 1 + val FRAMETIME = 2 + val SPEED = 3 + perfStatsUpdater = Runnable { + val perfStats = NativeLibrary.getPerfStats() + if (perfStats[FPS] > 0) { + binding.showFpsText.text = String.format( + "FPS: %d Speed: %d%%", + (perfStats[FPS] + 0.5).toInt(), + (perfStats[SPEED] * 100.0 + 0.5).toInt() + ) + } + perfStatsUpdateHandler.postDelayed(perfStatsUpdater!!, 3000) + } + perfStatsUpdateHandler.post(perfStatsUpdater!!) + binding.showFpsText.visibility = View.VISIBLE + } else { + if (perfStatsUpdater != null) { + perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) + } + binding.showFpsText.visibility = View.GONE + } + } + + override fun surfaceCreated(holder: SurfaceHolder) { + // We purposely don't do anything here. + // All work is done in surfaceChanged, which we are guaranteed to get even for surface creation. + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + Log.debug("[EmulationFragment] Surface changed. Resolution: " + width + "x" + height) + emulationState.newSurface(holder.surface) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + emulationState.clearSurface() + } + + override fun doFrame(frameTimeNanos: Long) { + Choreographer.getInstance().postFrameCallback(this) + NativeLibrary.doFrame() + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener( + binding.inGameMenu + ) { v: View, windowInsets: WindowInsetsCompat -> + val cutInsets: Insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + var left = 0 + var right = 0 + if (ViewCompat.getLayoutDirection(v) == ViewCompat.LAYOUT_DIRECTION_LTR) { + left = cutInsets.left + } else { + right = cutInsets.right + } + + v.setPadding(left, cutInsets.top, right, 0) + + // Ensure FPS text doesn't get cut off by rounded display corners + val sidePadding = resources.getDimensionPixelSize(R.dimen.spacing_large) + if (cutInsets.left == 0) { + binding.showFpsText.setPadding( + sidePadding, + cutInsets.top, + cutInsets.right, + cutInsets.bottom + ) + } else { + binding.showFpsText.setPadding( + cutInsets.left, + cutInsets.top, + cutInsets.right, + cutInsets.bottom + ) + } + windowInsets + } + } + + private class EmulationState(private val gamePath: String) { + private var state: State + private var surface: Surface? = null + + init { + // Starting state is stopped. + state = State.STOPPED + } + + @get:Synchronized + val isStopped: Boolean + get() = state == State.STOPPED + + @get:Synchronized + val isPaused: Boolean + // Getters for the current state + get() = state == State.PAUSED + + @get:Synchronized + val isRunning: Boolean + get() = state == State.RUNNING + + @Synchronized + fun stop() { + if (state != State.STOPPED) { + Log.debug("[EmulationFragment] Stopping emulation.") + state = State.STOPPED + NativeLibrary.stopEmulation() + } else { + Log.warning("[EmulationFragment] Stop called while already stopped.") + } + } + + // State changing methods + @Synchronized + fun pause() { + if (state != State.PAUSED) { + state = State.PAUSED + Log.debug("[EmulationFragment] Pausing emulation.") + + // Release the surface before pausing, since emulation has to be running for that. + NativeLibrary.surfaceDestroyed() + NativeLibrary.pauseEmulation() + } else { + Log.warning("[EmulationFragment] Pause called while already paused.") + } + } + + @Synchronized + fun unpause() { + if (state != State.RUNNING) { + state = State.RUNNING + Log.debug("[EmulationFragment] Unpausing emulation.") + + NativeLibrary.unPauseEmulation() + } else { + Log.warning("[EmulationFragment] Unpause called while already running.") + } + } + + @Synchronized + fun run(isActivityRecreated: Boolean) { + if (isActivityRecreated) { + if (NativeLibrary.isRunning()) { + state = State.PAUSED + } + } else { + Log.debug("[EmulationFragment] activity resumed or fresh start") + } + + // If the surface is set, run now. Otherwise, wait for it to get set. + if (surface != null) { + runWithValidSurface() + } + } + + // Surface callbacks + @Synchronized + fun newSurface(surface: Surface?) { + this.surface = surface + if (this.surface != null) { + runWithValidSurface() + } + } + + @Synchronized + fun clearSurface() { + if (surface == null) { + Log.warning("[EmulationFragment] clearSurface called, but surface already null.") + } else { + surface = null + Log.debug("[EmulationFragment] Surface destroyed.") + when (state) { + State.RUNNING -> { + NativeLibrary.surfaceDestroyed() + state = State.PAUSED + } + + State.PAUSED -> { + Log.warning("[EmulationFragment] Surface cleared while emulation paused.") + } + + else -> { + Log.warning("[EmulationFragment] Surface cleared while emulation stopped.") + } + } + } + } + + private fun runWithValidSurface() { + NativeLibrary.surfaceChanged(surface!!) + when (state) { + State.STOPPED -> { + Thread({ + Log.debug("[EmulationFragment] Starting emulation thread.") + NativeLibrary.run(gamePath) + }, "NativeEmulation").start() + } + + State.PAUSED -> { + Log.debug("[EmulationFragment] Resuming emulation.") + NativeLibrary.unPauseEmulation() + } + + else -> { + Log.debug("[EmulationFragment] Bug, run called while already running.") + } + } + state = State.RUNNING + } + + private enum class State { + STOPPED, + RUNNING, + PAUSED + } + } + + companion object { + private val perfStatsUpdateHandler = Handler(Looper.myLooper()!!) + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SystemFilesFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SystemFilesFragment.kt index 219f769fee..5868234241 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SystemFilesFragment.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SystemFilesFragment.kt @@ -27,11 +27,13 @@ import com.google.android.material.textfield.MaterialAutoCompleteTextView import com.google.android.material.transition.MaterialSharedAxis import kotlinx.coroutines.launch import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.HomeNavigationDirections import org.citra.citra_emu.NativeLibrary import org.citra.citra_emu.R import org.citra.citra_emu.activities.EmulationActivity import org.citra.citra_emu.databinding.FragmentSystemFilesBinding import org.citra.citra_emu.features.settings.model.Settings +import org.citra.citra_emu.model.Game import org.citra.citra_emu.utils.SystemSaveGame import org.citra.citra_emu.viewmodel.GamesViewModel import org.citra.citra_emu.viewmodel.HomeViewModel @@ -199,7 +201,13 @@ class SystemFilesFragment : Fragment() { populateHomeMenuOptions() binding.buttonStartHomeMenu.setOnClickListener { val menuPath = homeMenuMap[binding.dropdownSystemRegionStart.text.toString()]!! - EmulationActivity.launch(requireActivity(), menuPath, getString(R.string.home_menu)) + val menu = Game( + title = getString(R.string.home_menu), + path = menuPath, + filename = "" + ) + val action = HomeNavigationDirections.actionGlobalEmulationActivity(menu) + binding.root.findNavController().navigate(action) } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java index e4d8da7912..df41beb4e9 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java @@ -352,7 +352,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener { } for (InputOverlayDrawableDpad dpad : overlayDpads) { - if (!dpad.updateStatus(event, EmulationMenuSettings.getDpadSlideEnable())) { + if (!dpad.updateStatus(event, EmulationMenuSettings.INSTANCE.getDpadSlide())) { continue; } NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getUpId(), dpad.getUpStatus()); @@ -608,7 +608,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener { "-Portrait" : ""; // Add all the enabled overlay items back to the HashSet. - if (EmulationMenuSettings.getShowOverlay()) { + if (EmulationMenuSettings.INSTANCE.getShowOverlay()) { addOverlayControls(orientation); } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java index 0f44d5add1..f25771afc3 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java @@ -94,7 +94,7 @@ public final class InputOverlayDrawableJoystick { mPressedState = true; mOuterBitmap.setAlpha(0); mBoundsBoxBitmap.setAlpha(255); - if (EmulationMenuSettings.getJoystickRelCenter()) { + if (EmulationMenuSettings.INSTANCE.getJoystickRelCenter()) { getVirtBounds().offset(xPosition - getVirtBounds().centerX(), yPosition - getVirtBounds().centerY()); } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.kt index 6010851ea2..c2aa87de4e 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.kt @@ -157,7 +157,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { } // Dismiss previous notifications (should not happen unless a crash occurred) - EmulationActivity.tryDismissRunningNotification(this) + EmulationActivity.stopForegroundService(this) setInsets() } @@ -170,7 +170,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { } override fun onDestroy() { - EmulationActivity.tryDismissRunningNotification(this) + EmulationActivity.stopForegroundService(this) super.onDestroy() } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.kt similarity index 55% rename from src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java rename to src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.kt index f801a05f0f..7bba904b51 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.kt @@ -1,66 +1,69 @@ -package org.citra.citra_emu.utils; +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. -import android.view.InputDevice; -import android.view.KeyEvent; -import android.view.MotionEvent; +package org.citra.citra_emu.utils + +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent /** * Some controllers have incorrect mappings. This class has special-case fixes for them. */ -public class ControllerMappingHelper { +object ControllerMappingHelper { /** * Some controllers report extra button presses that can be ignored. */ - public boolean shouldKeyBeIgnored(InputDevice inputDevice, int keyCode) { - if (isDualShock4(inputDevice)) { + fun shouldKeyBeIgnored(inputDevice: InputDevice, keyCode: Int): Boolean { + return if (isDualShock4(inputDevice)) { // The two analog triggers generate analog motion events as well as a keycode. // We always prefer to use the analog values, so throw away the button press - return keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2; - } - return false; + keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2 + } else false } /** * Scale an axis to be zero-centered with a proper range. */ - public float scaleAxis(InputDevice inputDevice, int axis, float value) { + fun scaleAxis(inputDevice: InputDevice, axis: Int, value: Float): Float { if (isDualShock4(inputDevice)) { // Android doesn't have correct mappings for this controller's triggers. It reports them // as RX & RY, centered at -1.0, and with a range of [-1.0, 1.0] // Scale them to properly zero-centered with a range of [0.0, 1.0]. if (axis == MotionEvent.AXIS_RX || axis == MotionEvent.AXIS_RY) { - return (value + 1) / 2.0f; + return (value + 1) / 2.0f } } else if (isXboxOneWireless(inputDevice)) { // Same as the DualShock 4, the mappings are missing. if (axis == MotionEvent.AXIS_Z || axis == MotionEvent.AXIS_RZ) { - return (value + 1) / 2.0f; + return (value + 1) / 2.0f } if (axis == MotionEvent.AXIS_GENERIC_1) { // This axis is stuck at ~.5. Ignore it. - return 0.0f; + return 0.0f } } else if (isMogaPro2Hid(inputDevice)) { // This controller has a broken axis that reports a constant value. Ignore it. if (axis == MotionEvent.AXIS_GENERIC_1) { - return 0.0f; + return 0.0f } } - return value; + return value } - private boolean isDualShock4(InputDevice inputDevice) { + private fun isDualShock4(inputDevice: InputDevice): Boolean { // Sony DualShock 4 controller - return inputDevice.getVendorId() == 0x54c && inputDevice.getProductId() == 0x9cc; + return inputDevice.vendorId == 0x54c && inputDevice.productId == 0x9cc } - private boolean isXboxOneWireless(InputDevice inputDevice) { + private fun isXboxOneWireless(inputDevice: InputDevice): Boolean { // Microsoft Xbox One controller - return inputDevice.getVendorId() == 0x45e && inputDevice.getProductId() == 0x2e0; + return inputDevice.vendorId == 0x45e && inputDevice.productId == 0x2e0 } - private boolean isMogaPro2Hid(InputDevice inputDevice) { + private fun isMogaPro2Hid(inputDevice: InputDevice): Boolean { // Moga Pro 2 HID - return inputDevice.getVendorId() == 0x20d6 && inputDevice.getProductId() == 0x6271; + return inputDevice.vendorId == 0x20d6 && inputDevice.productId == 0x6271 } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.java deleted file mode 100644 index 5897328aea..0000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.java +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright 2021 Citra Emulator Project -// Licensed under GPLv2 or any later version -// Refer to the license.txt file included. - -package org.citra.citra_emu.utils; - -import android.app.Activity; -import android.app.Dialog; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.ProgressBar; -import android.widget.TextView; - -import androidx.annotation.Keep; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.DialogFragment; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import org.citra.citra_emu.NativeLibrary; -import org.citra.citra_emu.R; -import org.citra.citra_emu.activities.EmulationActivity; -import org.citra.citra_emu.utils.Log; - -import java.util.Objects; - -@Keep -public class DiskShaderCacheProgress { - - // Equivalent to VideoCore::LoadCallbackStage - public enum LoadCallbackStage { - Prepare, - Decompile, - Build, - Complete, - } - - private static final Object finishLock = new Object(); - private static ProgressDialogFragment fragment; - - public static class ProgressDialogFragment extends DialogFragment { - ProgressBar progressBar; - TextView progressText; - AlertDialog dialog; - - static ProgressDialogFragment newInstance(String title, String message) { - ProgressDialogFragment frag = new ProgressDialogFragment(); - Bundle args = new Bundle(); - args.putString("title", title); - args.putString("message", message); - frag.setArguments(args); - return frag; - } - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - final Activity emulationActivity = requireActivity(); - - final String title = Objects.requireNonNull(requireArguments().getString("title")); - final String message = Objects.requireNonNull(requireArguments().getString("message")); - - LayoutInflater inflater = LayoutInflater.from(emulationActivity); - View view = inflater.inflate(R.layout.dialog_progress_bar, null); - - progressBar = view.findViewById(R.id.progress_bar); - progressText = view.findViewById(R.id.progress_text); - progressText.setText(""); - - setCancelable(false); - setRetainInstance(true); - - synchronized (finishLock) { - finishLock.notifyAll(); - } - - dialog = new MaterialAlertDialogBuilder(emulationActivity) - .setView(view) - .setTitle(title) - .setMessage(message) - .setNegativeButton(android.R.string.cancel, (dialog, which) -> emulationActivity.onBackPressed()) - .create(); - return dialog; - } - - private void onUpdateProgress(String msg, int progress, int max) { - requireActivity().runOnUiThread(() -> { - progressBar.setProgress(progress); - progressBar.setMax(max); - progressText.setText(String.format("%d/%d", progress, max)); - dialog.setMessage(msg); - }); - } - } - - private static void prepareDialog() { - NativeLibrary.sEmulationActivity.get().runOnUiThread(() -> { - final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); - fragment = ProgressDialogFragment.newInstance(emulationActivity.getString(R.string.loading), emulationActivity.getString(R.string.preparing_shaders)); - fragment.show(emulationActivity.getSupportFragmentManager(), "diskShaders"); - }); - - synchronized (finishLock) { - try { - finishLock.wait(); - } catch (Exception ignored) { - } - } - } - - public static void loadProgress(LoadCallbackStage stage, int progress, int max) { - final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); - if (emulationActivity == null) { - Log.error("[DiskShaderCacheProgress] EmulationActivity not present"); - return; - } - - switch (stage) { - case Prepare: - prepareDialog(); - break; - case Decompile: - fragment.onUpdateProgress(emulationActivity.getString(R.string.preparing_shaders), progress, max); - break; - case Build: - fragment.onUpdateProgress(emulationActivity.getString(R.string.building_shaders), progress, max); - break; - case Complete: - // Workaround for when dialog is dismissed when the app is in the background - fragment.dismissAllowingStateLoss(); - - emulationActivity.runOnUiThread(emulationActivity::onEmulationStarted); - break; - } - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.kt new file mode 100644 index 0000000000..a34924a9a4 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.kt @@ -0,0 +1,60 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import androidx.annotation.Keep +import androidx.lifecycle.ViewModelProvider +import org.citra.citra_emu.NativeLibrary +import org.citra.citra_emu.R +import org.citra.citra_emu.activities.EmulationActivity +import org.citra.citra_emu.viewmodel.EmulationViewModel + +@Keep +object DiskShaderCacheProgress { + private lateinit var emulationViewModel: EmulationViewModel + + private fun prepareViewModel() { + emulationViewModel = + ViewModelProvider( + NativeLibrary.sEmulationActivity.get() as EmulationActivity + )[EmulationViewModel::class.java] + } + + @JvmStatic + fun loadProgress(stage: LoadCallbackStage, progress: Int, max: Int) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + if (emulationActivity == null) { + Log.error("[DiskShaderCacheProgress] EmulationActivity not present") + return + } + + emulationActivity.runOnUiThread { + when (stage) { + LoadCallbackStage.Prepare -> prepareViewModel() + LoadCallbackStage.Decompile -> emulationViewModel.updateProgress( + emulationActivity.getString(R.string.preparing_shaders), + progress, + max + ) + + LoadCallbackStage.Build -> emulationViewModel.updateProgress( + emulationActivity.getString(R.string.building_shaders), + progress, + max + ) + + LoadCallbackStage.Complete -> emulationActivity.onEmulationStarted() + } + } + } + + // Equivalent to VideoCore::LoadCallbackStage + enum class LoadCallbackStage { + Prepare, + Decompile, + Build, + Complete + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java deleted file mode 100644 index 2b31876b65..0000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.content.SharedPreferences; -import android.preference.PreferenceManager; - -import org.citra.citra_emu.CitraApplication; - -public class EmulationMenuSettings { - private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext()); - - // These must match what is defined in src/common/settings.h - public static final int LayoutOption_Default = 0; - public static final int LayoutOption_SingleScreen = 1; - public static final int LayoutOption_LargeScreen = 2; - public static final int LayoutOption_SideScreen = 3; - public static final int LayoutOption_MobilePortrait = 5; - public static final int LayoutOption_MobileLandscape = 6; - - public static boolean getJoystickRelCenter() { - return mPreferences.getBoolean("EmulationMenuSettings_JoystickRelCenter", true); - } - - public static void setJoystickRelCenter(boolean value) { - final SharedPreferences.Editor editor = mPreferences.edit(); - editor.putBoolean("EmulationMenuSettings_JoystickRelCenter", value); - editor.apply(); - } - - public static boolean getDpadSlideEnable() { - return mPreferences.getBoolean("EmulationMenuSettings_DpadSlideEnable", true); - } - - public static void setDpadSlideEnable(boolean value) { - final SharedPreferences.Editor editor = mPreferences.edit(); - editor.putBoolean("EmulationMenuSettings_DpadSlideEnable", value); - editor.apply(); - } - - public static int getLandscapeScreenLayout() { - return mPreferences.getInt("EmulationMenuSettings_LandscapeScreenLayout", LayoutOption_MobileLandscape); - } - - public static void setLandscapeScreenLayout(int value) { - final SharedPreferences.Editor editor = mPreferences.edit(); - editor.putInt("EmulationMenuSettings_LandscapeScreenLayout", value); - editor.apply(); - } - - public static boolean getShowFps() { - return mPreferences.getBoolean("EmulationMenuSettings_ShowFps", false); - } - - public static void setShowFps(boolean value) { - final SharedPreferences.Editor editor = mPreferences.edit(); - editor.putBoolean("EmulationMenuSettings_ShowFps", value); - editor.apply(); - } - - public static boolean getSwapScreens() { - return mPreferences.getBoolean("EmulationMenuSettings_SwapScreens", false); - } - - public static void setSwapScreens(boolean value) { - final SharedPreferences.Editor editor = mPreferences.edit(); - editor.putBoolean("EmulationMenuSettings_SwapScreens", value); - editor.apply(); - } - - public static boolean getShowOverlay() { - return mPreferences.getBoolean("EmulationMenuSettings_ShowOverylay", true); - } - - public static void setShowOverlay(boolean value) { - final SharedPreferences.Editor editor = mPreferences.edit(); - editor.putBoolean("EmulationMenuSettings_ShowOverylay", value); - editor.apply(); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.kt new file mode 100644 index 0000000000..c07636dbbf --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.kt @@ -0,0 +1,78 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import androidx.drawerlayout.widget.DrawerLayout +import androidx.preference.PreferenceManager +import org.citra.citra_emu.CitraApplication + +object EmulationMenuSettings { + private val preferences = + PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + + // These must match what is defined in src/common/settings.h + const val LayoutOption_Default = 0 + const val LayoutOption_SingleScreen = 1 + const val LayoutOption_LargeScreen = 2 + const val LayoutOption_SideScreen = 3 + const val LayoutOption_MobilePortrait = 5 + const val LayoutOption_MobileLandscape = 6 + + var joystickRelCenter: Boolean + get() = preferences.getBoolean("EmulationMenuSettings_JoystickRelCenter", true) + set(value) { + preferences.edit() + .putBoolean("EmulationMenuSettings_JoystickRelCenter", value) + .apply() + } + var dpadSlide: Boolean + get() = preferences.getBoolean("EmulationMenuSettings_DpadSlideEnable", true) + set(value) { + preferences.edit() + .putBoolean("EmulationMenuSettings_DpadSlideEnable", value) + .apply() + } + var landscapeScreenLayout: Int + get() = preferences.getInt( + "EmulationMenuSettings_LandscapeScreenLayout", + LayoutOption_MobileLandscape + ) + set(value) { + preferences.edit() + .putInt("EmulationMenuSettings_LandscapeScreenLayout", value) + .apply() + } + var showFps: Boolean + get() = preferences.getBoolean("EmulationMenuSettings_ShowFps", false) + set(value) { + preferences.edit() + .putBoolean("EmulationMenuSettings_ShowFps", value) + .apply() + } + var swapScreens: Boolean + get() = preferences.getBoolean("EmulationMenuSettings_SwapScreens", false) + set(value) { + preferences.edit() + .putBoolean("EmulationMenuSettings_SwapScreens", value) + .apply() + } + var showOverlay: Boolean + get() = preferences.getBoolean("EmulationMenuSettings_ShowOverlay", true) + set(value) { + preferences.edit() + .putBoolean("EmulationMenuSettings_ShowOverlay", value) + .apply() + } + var drawerLockMode: Int + get() = preferences.getInt( + "EmulationMenuSettings_DrawerLockMode", + DrawerLayout.LOCK_MODE_UNLOCKED + ) + set(value) { + preferences.edit() + .putInt("EmulationMenuSettings_DrawerLockMode", value) + .apply() + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java deleted file mode 100644 index 021179ab1d..0000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright 2014 Dolphin Emulator Project - * Licensed under GPLv2+ - * Refer to the license.txt file included. - */ - -package org.citra.citra_emu.utils; - -import android.app.PendingIntent; -import android.app.Service; -import android.content.Intent; -import android.os.IBinder; - -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; - -import org.citra.citra_emu.R; -import org.citra.citra_emu.activities.EmulationActivity; - -/** - * A service that shows a permanent notification in the background to avoid the app getting - * cleared from memory by the system. - */ -public class ForegroundService extends Service { - private static final int EMULATION_RUNNING_NOTIFICATION = 0x1000; - - private void showRunningNotification() { - // Intent is used to resume emulation if the notification is clicked - PendingIntent contentIntent = PendingIntent.getActivity(this, 0, - new Intent(this, EmulationActivity.class), PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); - - NotificationCompat.Builder builder = new NotificationCompat.Builder(this, getString(R.string.app_notification_channel_id)) - .setSmallIcon(R.drawable.ic_stat_notification_logo) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.app_notification_running)) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setOngoing(true) - .setVibrate(null) - .setSound(null) - .setContentIntent(contentIntent); - startForeground(EMULATION_RUNNING_NOTIFICATION, builder.build()); - } - - @Override - public IBinder onBind(Intent intent) { - return null; - } - - @Override - public void onCreate() { - showRunningNotification(); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - return START_STICKY; - } - - @Override - public void onDestroy() { - NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.kt new file mode 100644 index 0000000000..2d7f2368c7 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.kt @@ -0,0 +1,70 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import org.citra.citra_emu.R +import org.citra.citra_emu.activities.EmulationActivity + +/** + * A service that shows a permanent notification in the background to avoid the app getting + * cleared from memory by the system. + */ +class ForegroundService : Service() { + companion object { + const val EMULATION_RUNNING_NOTIFICATION = 0x1000 + + const val ACTION_STOP = "stop" + } + + private fun showRunningNotification() { + // Intent is used to resume emulation if the notification is clicked + val contentIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, EmulationActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + val builder = + NotificationCompat.Builder(this, getString(R.string.app_notification_channel_id)) + .setSmallIcon(R.drawable.ic_stat_notification_logo) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.app_notification_running)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .setVibrate(null) + .setSound(null) + .setContentIntent(contentIntent) + startForeground(EMULATION_RUNNING_NOTIFICATION, builder.build()) + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + override fun onCreate() { + showRunningNotification() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) { + return START_NOT_STICKY + } + if (intent.action == ACTION_STOP) { + NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelfResult(startId) + } + return START_STICKY + } + + override fun onDestroy() = + NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION) +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/EmulationViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/EmulationViewModel.kt new file mode 100644 index 0000000000..3a5571e9bc --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/EmulationViewModel.kt @@ -0,0 +1,45 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.viewmodel + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class EmulationViewModel : ViewModel() { + val emulationStarted get() = _emulationStarted.asStateFlow() + private val _emulationStarted = MutableStateFlow(false) + + val shaderProgress get() = _shaderProgress.asStateFlow() + private val _shaderProgress = MutableStateFlow(0) + + val totalShaders get() = _totalShaders.asStateFlow() + private val _totalShaders = MutableStateFlow(0) + + val shaderMessage get() = _shaderMessage.asStateFlow() + private val _shaderMessage = MutableStateFlow("") + + fun setShaderProgress(progress: Int) { + _shaderProgress.value = progress + } + + fun setTotalShaders(max: Int) { + _totalShaders.value = max + } + + fun setShaderMessage(msg: String) { + _shaderMessage.value = msg + } + + fun updateProgress(msg: String, progress: Int, max: Int) { + setShaderMessage(msg) + setShaderProgress(progress) + setTotalShaders(max) + } + + fun setEmulationStarted(started: Boolean) { + _emulationStarted.value = started + } +} diff --git a/src/android/app/src/main/res/drawable/ic_code.xml b/src/android/app/src/main/res/drawable/ic_code.xml new file mode 100644 index 0000000000..8ef40bd2d2 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_code.xml @@ -0,0 +1,5 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_exit.xml b/src/android/app/src/main/res/drawable/ic_exit.xml new file mode 100644 index 0000000000..6ac037504b --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_exit.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_fit_screen.xml b/src/android/app/src/main/res/drawable/ic_fit_screen.xml new file mode 100644 index 0000000000..ba2ef18fcf --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_fit_screen.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_lock.xml b/src/android/app/src/main/res/drawable/ic_lock.xml new file mode 100644 index 0000000000..11918298c5 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_nfc.xml b/src/android/app/src/main/res/drawable/ic_nfc.xml new file mode 100644 index 0000000000..3dacf798b5 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_nfc.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_pause.xml b/src/android/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000000..ccaed71872 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_play.xml b/src/android/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000000..e702965caa --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_save.xml b/src/android/app/src/main/res/drawable/ic_save.xml new file mode 100644 index 0000000000..5acc2bbab8 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_save.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_splitscreen.xml b/src/android/app/src/main/res/drawable/ic_splitscreen.xml new file mode 100644 index 0000000000..b517b6f48f --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_splitscreen.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_unlocked.xml b/src/android/app/src/main/res/drawable/ic_unlocked.xml new file mode 100644 index 0000000000..40952cbc51 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_unlocked.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/layout/activity_emulation.xml b/src/android/app/src/main/res/layout/activity_emulation.xml index 5f5ff777d1..139065d3d6 100644 --- a/src/android/app/src/main/res/layout/activity_emulation.xml +++ b/src/android/app/src/main/res/layout/activity_emulation.xml @@ -1,23 +1,9 @@ - - - - - - - - - + android:layout_height="match_parent" + android:keepScreenOn="true" + app:defaultNavHost="true" /> diff --git a/src/android/app/src/main/res/layout/fragment_emulation.xml b/src/android/app/src/main/res/layout/fragment_emulation.xml index bd64d5d164..f076cc3378 100644 --- a/src/android/app/src/main/res/layout/fragment_emulation.xml +++ b/src/android/app/src/main/res/layout/fragment_emulation.xml @@ -1,46 +1,141 @@ - + tools:openDrawer="start"> - - + android:layout_height="match_parent"> - - + + - + + +