1
0
Fork 0

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
This commit is contained in:
Charles Lombardo 2023-11-30 10:38:25 -05:00 committed by GitHub
parent 0ed909e782
commit 59beeac4c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 2307 additions and 1563 deletions

View File

@ -64,9 +64,18 @@
<activity
android:name="org.citra.citra_emu.activities.EmulationActivity"
android:exported="true"
android:resizeableActivity="false"
android:theme="@style/Theme.Citra.Main"
android:launchMode="singleTop"/>
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:mimeType="application/octet-stream"
android:scheme="content" />
</intent-filter>
</activity>
<service android:name="org.citra.citra_emu.utils.ForegroundService" android:foregroundServiceType="specialUse">
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="Keep emulation running in background"/>

View File

@ -252,7 +252,7 @@ object NativeLibrary {
@Keep
@JvmStatic
fun landscapeScreenLayout(): Int = EmulationMenuSettings.getLandscapeScreenLayout()
fun landscapeScreenLayout(): Int = EmulationMenuSettings.landscapeScreenLayout
@Keep
@JvmStatic

View File

@ -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<Boolean> 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<InputDevice.MotionRange> 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 {
}
}

View File

@ -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<String>,
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<String>("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)
}
}
}

View File

@ -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)
}
/**

View File

@ -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);
}
}

View File

@ -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
)
}
}

View File

@ -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
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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)
}
}

View File

@ -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);
}

View File

@ -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());
}

View File

@ -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()
}

View File

@ -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
}
}

View File

@ -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;
}
}
}

View File

@ -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
}
}

View File

@ -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();
}
}

View File

@ -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()
}
}

View File

@ -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);
}
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M9.4,16.6L4.8,12l4.6,-4.6L8,6l-6,6 6,6 1.4,-1.4zM14.6,16.6l4.6,-4.6 -4.6,-4.6L16,6l6,6 -6,6 -1.4,-1.4z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M10.09,15.59L11.5,17l5,-5 -5,-5 -1.41,1.41L12.67,11H3v2h9.67l-2.58,2.59zM19,3H5c-1.11,0 -2,0.9 -2,2v4h2V5h14v14H5v-4H3v4c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M17,4h3c1.1,0 2,0.9 2,2v2h-2L20,6h-3L17,4zM4,8L4,6h3L7,4L4,4c-1.1,0 -2,0.9 -2,2v2h2zM20,16v2h-3v2h3c1.1,0 2,-0.9 2,-2v-2h-2zM7,18L4,18v-2L2,16v2c0,1.1 0.9,2 2,2h3v-2zM18,8L6,8v8h12L18,8z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM9,6c0,-1.66 1.34,-3 3,-3s3,1.34 3,3v2L9,8L9,6zM18,20L6,20L6,10h12v10zM12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M20,2L4,2c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM20,20L4,20L4,4h16v16zM18,6h-5c-1.1,0 -2,0.9 -2,2v2.28c-0.6,0.35 -1,0.98 -1,1.72 0,1.1 0.9,2 2,2s2,-0.9 2,-2c0,-0.74 -0.4,-1.38 -1,-1.72L13,8h3v8L8,16L8,8h2L10,6L6,6v12h12L18,6z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M8,5v14l11,-7z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M18,4v5H6V4H18M18,2H6C4.9,2 4,2.9 4,4v5c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4C20,2.9 19.1,2 18,2zM18,15v5H6v-5H18M18,13H6c-1.1,0 -2,0.9 -2,2v5c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2v-5C20,13.9 19.1,13 18,13z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6h1.9c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM18,20L6,20L6,10h12v10z" />
</vector>

View File

@ -1,23 +1,9 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/frame_content"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/frame_emulation_fragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ImageView
android:id="@+id/image_icon"
<androidx.fragment.app.FragmentContainerView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/fragment_container"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:transitionName="image_game_icon" />
<View
android:id="@+id/menu_anchor"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_gravity="top|end" />
</FrameLayout>
android:keepScreenOn="true"
app:defaultNavHost="true" />

View File

@ -1,46 +1,141 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:keepScreenOn="true"
tools:context="org.citra.citra_emu.fragments.EmulationFragment">
tools:openDrawer="start">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- This is what everything is rendered to during emulation -->
<SurfaceView
android:id="@+id/surface_emulation"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:defaultFocusHighlightEnabled="false"
android:focusable="false"
android:focusableInTouchMode="false" />
<!-- This is the onscreen input overlay -->
<org.citra.citra_emu.overlay.InputOverlay
android:id="@+id/surface_input_overlay"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:focusable="true"
android:focusableInTouchMode="true" />
<TextView
android:id="@+id/show_fps_text"
android:layout_marginStart="8dp"
android:layout_marginTop="2dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="false"
android:linksClickable="false"
android:longClickable="false"
android:shadowColor="@android:color/black"
android:textColor="@android:color/white"
android:textSize="12sp" />
android:focusableInTouchMode="true"
android:visibility="invisible" />
<Button
android:id="@+id/done_control_config"
style="@style/Widget.Material3.Button.ElevatedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="@dimen/spacing_small"
android:text="@string/emulation_done"
android:visibility="gone" />
</FrameLayout>
<com.google.android.material.card.MaterialCardView
android:id="@+id/loading_indicator"
style="?attr/materialCardViewOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:focusable="false">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/loading_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="24dp"
android:gravity="center_horizontal"
android:orientation="horizontal">
<ImageView
android:id="@+id/loading_image"
android:layout_width="64dp"
android:layout_height="64dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/no_icon" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="20dp"
android:orientation="vertical"
android:animateLayoutChanges="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@+id/loading_image"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/loading_title"
style="@style/TextAppearance.Material3.TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
tools:text="@string/games" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/loading_text"
style="@style/TextAppearance.Material3.TitleSmall"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/loading"
android:textAlignment="viewStart" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/loading_progress_indicator"
android:layout_width="192dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:indeterminate="true"
app:trackCornerRadius="8dp" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/loading_progress_text"
style="@style/TextAppearance.Material3.LabelSmall"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAlignment="viewStart"
tools:text="10/100" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/show_fps_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="left"
android:clickable="false"
android:focusable="false"
android:shadowColor="@android:color/black"
android:textColor="@android:color/white"
android:textSize="12sp"
tools:ignore="RtlHardcoded" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/in_game_menu"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
app:headerLayout="@layout/header_in_game"
app:menu="@menu/menu_in_game"
tools:visibility="gone" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.textview.MaterialTextView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/text_game_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:textColor="?attr/colorOnSurface"
android:textAlignment="viewStart"
tools:text="Super Mario 3D Land" />

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
android:title="@string/menu_emulation_amiibo">
<item
android:id="@+id/menu_emulation_amiibo_load"
android:title="@string/menu_emulation_amiibo_load" />
<item
android:id="@+id/menu_emulation_amiibo_remove"
android:title="@string/menu_emulation_amiibo_remove" />
</menu>

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_emulation_pause"
android:icon="@drawable/ic_pause"
android:title="@string/pause_emulation" />
<item
android:id="@+id/menu_emulation_savestates"
android:icon="@drawable/ic_save"
android:title="@string/savestates"
android:visible="false" />
<item
android:id="@+id/menu_overlay_options"
android:icon="@drawable/ic_controller"
android:title="@string/emulation_overlay_options" />
<item
android:id="@+id/menu_amiibo"
android:icon="@drawable/ic_nfc"
android:title="@string/menu_emulation_amiibo" />
<item
android:id="@+id/menu_landscape_screen_layout"
android:icon="@drawable/ic_fit_screen"
android:title="@string/emulation_switch_screen_layout" />
<item
android:id="@+id/menu_swap_screens"
android:icon="@drawable/ic_splitscreen"
android:title="@string/emulation_swap_screens" />
<item
android:id="@+id/menu_lock_drawer"
android:icon="@drawable/ic_unlocked"
android:title="@string/lock_drawer" />
<item
android:id="@+id/menu_cheats"
android:icon="@drawable/ic_code"
android:title="@string/cheats" />
<item
android:id="@+id/menu_settings"
android:icon="@drawable/ic_settings"
android:title="@string/preferences_settings" />
<item
android:id="@+id/menu_exit"
android:icon="@drawable/ic_exit"
android:title="@string/emulation_close_game" />
</menu>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<group android:checkableBehavior="single">
<item
android:id="@+id/menu_screen_layout_landscape"
android:title="@string/emulation_screen_layout_landscape" />
<item
android:id="@+id/menu_screen_layout_portrait"
android:title="@string/emulation_screen_layout_portrait" />
<item
android:id="@+id/menu_screen_layout_single"
android:title="@string/emulation_screen_layout_single" />
<item
android:id="@+id/menu_screen_layout_sidebyside"
android:title="@string/emulation_screen_layout_sidebyside" />
</group>
</menu>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_show_overlay"
android:title="@string/emulation_show_overlay"
android:checkable="true" />
<item
android:id="@+id/menu_show_fps"
android:title="@string/emulation_show_fps"
android:checkable="true" />
<item
android:id="@+id/menu_emulation_edit_layout"
android:title="@string/emulation_edit_layout" />
<item
android:id="@+id/menu_emulation_toggle_controls"
android:title="@string/emulation_toggle_controls" />
<item
android:id="@+id/menu_emulation_adjust_scale"
android:title="@string/emulation_control_scale" />
<group android:checkableBehavior="all">
<item
android:id="@+id/menu_emulation_joystick_rel_center"
android:checkable="true"
android:title="@string/emulation_control_joystick_rel_center"/>
<item
android:id="@+id/menu_emulation_dpad_slide_enable"
android:checkable="true"
android:title="@string/emulation_control_dpad_slide_enable" />
</group>
<item
android:id="@+id/menu_emulation_reset_overlay"
android:title="@string/emulation_touch_overlay_reset" />
</menu>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/menu_emulation_save_state"
android:title="@string/emulation_save_state" />
<item
android:id="@+id/menu_emulation_load_state"
android:title="@string/emulation_load_state" />
</menu>

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/emulation_navigation"
app:startDestination="@id/emulationFragment">
<fragment
android:id="@+id/emulationFragment"
android:name="org.citra.citra_emu.fragments.EmulationFragment"
android:label="fragment_emulation"
tools:layout="@layout/fragment_emulation" >
<argument
android:name="game"
app:argType="org.citra.citra_emu.model.Game"
app:nullable="true"
android:defaultValue="@null" />
</fragment>
<activity
android:id="@+id/cheatsActivity"
android:name="org.citra.citra_emu.features.cheats.ui.CheatsActivity"
android:label="CheatsActivity">
<argument
android:name="titleId"
app:argType="long"
android:defaultValue="-1L" />
</activity>
<action
android:id="@+id/action_global_cheatsActivity"
app:destination="@id/cheatsActivity" />
</navigation>

View File

@ -65,6 +65,11 @@
android:defaultValue="@null" />
</activity>
<action
android:id="@+id/action_global_emulationActivity"
app:destination="@id/emulationActivity"
app:launchSingleTop="true" />
<fragment
android:id="@+id/systemFilesFragment"
android:name="org.citra.citra_emu.fragments.SystemFilesFragment"

View File

@ -312,6 +312,7 @@
<string name="loader_error_encrypted">Your ROM is Encrypted</string>
<string name="loader_error_invalid_format">Invalid ROM format</string>
<string name="loader_error_file_not_found">ROM file does not exist</string>
<string name="no_game_present">No bootable game present!</string>
<!-- Emulation Menu -->
<string name="emulation_menu_help">Press Back to access the menu.</string>
@ -320,6 +321,7 @@
<string name="emulation_empty_state_slot">Slot %1$d</string>
<string name="emulation_occupied_state_slot">Slot %1$d - %2$tF %2$tR</string>
<string name="emulation_show_fps">Show FPS</string>
<string name="emulation_overlay_options">Overlay Options</string>
<string name="emulation_configure_controls">Configure Controls</string>
<string name="emulation_edit_layout">Edit Layout</string>
<string name="emulation_done">Done</string>
@ -345,6 +347,10 @@
<string name="select_amiibo">Select Amiibo File</string>
<string name="amiibo_load_error">Error Loading Amiibo</string>
<string name="amiibo_load_error_message">While loading the specified Amiibo file, an error occurred. Please check that the file is correct.</string>
<string name="pause_emulation">Pause Emulation</string>
<string name="resume_emulation">Resume Emulation</string>
<string name="lock_drawer">Lock Drawer</string>
<string name="unlock_drawer">Unlock Drawer</string>
<string name="write_permission_needed">You need to allow write access to external storage for the emulator to work</string>
<string name="load_settings">Loading Settings…</string>
@ -360,7 +366,7 @@
<string name="moving_data">Moving Data…</string>
<string name="copy_file_name">Copy file: %s</string>
<string name="copy_complete">Copy Complete</string>
<string name="savestate_warning_title">Savestates</string>
<string name="savestates">Save States</string>
<string name="savestate_warning_message">Warning: Savestates are NOT a replacement for in-game saves, and are not meant to be reliable.\n\nUse at your own risk!</string>
<!-- Software Keyboard -->