overlayJoysticks = new HashSet<>();
+
+ private boolean mIsInEditMode = false;
+ private InputOverlayDrawableButton mButtonBeingConfigured;
+ private InputOverlayDrawableDpad mDpadBeingConfigured;
+ private InputOverlayDrawableJoystick mJoystickBeingConfigured;
+
+ private SharedPreferences mPreferences;
+
+ // Stores the ID of the pointer that interacted with the 3DS touchscreen.
+ private int mTouchscreenPointerId = -1;
+
+ /**
+ * Constructor
+ *
+ * @param context The current {@link Context}.
+ * @param attrs {@link AttributeSet} for parsing XML attributes.
+ */
+ public InputOverlay(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ mPreferences = PreferenceManager.getDefaultSharedPreferences(getContext());
+ if (!mPreferences.getBoolean("OverlayInit", false)) {
+ defaultOverlay();
+ }
+
+ // Reset 3ds touchscreen pointer ID
+ mTouchscreenPointerId = -1;
+
+ // Load the controls.
+ refreshControls();
+
+ // Set the on touch listener.
+ setOnTouchListener(this);
+
+ // Force draw
+ setWillNotDraw(false);
+
+ // Request focus for the overlay so it has priority on presses.
+ requestFocus();
+ }
+
+ /**
+ * Resizes a {@link Bitmap} by a given scale factor
+ *
+ * @param context The current {@link Context}
+ * @param bitmap The {@link Bitmap} to scale.
+ * @param scale The scale factor for the bitmap.
+ * @return The scaled {@link Bitmap}
+ */
+ public static Bitmap resizeBitmap(Context context, Bitmap bitmap, float scale) {
+ // Determine the button size based on the smaller screen dimension.
+ // This makes sure the buttons are the same size in both portrait and landscape.
+ DisplayMetrics dm = context.getResources().getDisplayMetrics();
+ int minDimension = Math.min(dm.widthPixels, dm.heightPixels);
+
+ return Bitmap.createScaledBitmap(bitmap,
+ (int) (minDimension * scale),
+ (int) (minDimension * scale),
+ true);
+ }
+
+ /**
+ * Initializes an InputOverlayDrawableButton, given by resId, with all of the
+ * parameters set for it to be properly shown on the InputOverlay.
+ *
+ * This works due to the way the X and Y coordinates are stored within
+ * the {@link SharedPreferences}.
+ *
+ * In the input overlay configuration menu,
+ * once a touch event begins and then ends (ie. Organizing the buttons to one's own liking for the overlay).
+ * the X and Y coordinates of the button at the END of its touch event
+ * (when you remove your finger/stylus from the touchscreen) are then stored
+ * within a SharedPreferences instance so that those values can be retrieved here.
+ *
+ * This has a few benefits over the conventional way of storing the values
+ * (ie. within the Citra ini file).
+ *
+ * - No native calls
+ * - Keeps Android-only values inside the Android environment
+ *
+ *
+ * Technically no modifications should need to be performed on the returned
+ * InputOverlayDrawableButton. Simply add it to the HashSet of overlay items and wait
+ * for Android to call the onDraw method.
+ *
+ * @param context The current {@link Context}.
+ * @param defaultResId The resource ID of the {@link Drawable} to get the {@link Bitmap} of (Default State).
+ * @param pressedResId The resource ID of the {@link Drawable} to get the {@link Bitmap} of (Pressed State).
+ * @param buttonId Identifier for determining what type of button the initialized InputOverlayDrawableButton represents.
+ * @return An {@link InputOverlayDrawableButton} with the correct drawing bounds set.
+ */
+ private static InputOverlayDrawableButton initializeOverlayButton(Context context,
+ int defaultResId, int pressedResId, int buttonId, String orientation) {
+ // Resources handle for fetching the initial Drawable resource.
+ final Resources res = context.getResources();
+
+ // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableButton.
+ final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context);
+
+ // Decide scale based on button ID and user preference
+ float scale;
+
+ switch (buttonId) {
+ case ButtonType.BUTTON_HOME:
+ case ButtonType.BUTTON_START:
+ case ButtonType.BUTTON_SELECT:
+ scale = 0.08f;
+ break;
+ case ButtonType.TRIGGER_L:
+ case ButtonType.TRIGGER_R:
+ case ButtonType.BUTTON_ZL:
+ case ButtonType.BUTTON_ZR:
+ scale = 0.18f;
+ break;
+ default:
+ scale = 0.11f;
+ break;
+ }
+
+ scale *= (sPrefs.getInt("controlScale", 50) + 50);
+ scale /= 100;
+
+ // Initialize the InputOverlayDrawableButton.
+ final Bitmap defaultStateBitmap =
+ resizeBitmap(context, BitmapFactory.decodeResource(res, defaultResId), scale);
+ final Bitmap pressedStateBitmap =
+ resizeBitmap(context, BitmapFactory.decodeResource(res, pressedResId), scale);
+ final InputOverlayDrawableButton overlayDrawable =
+ new InputOverlayDrawableButton(res, defaultStateBitmap, pressedStateBitmap, buttonId);
+
+ // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay.
+ // These were set in the input overlay configuration menu.
+ String xKey;
+ String yKey;
+
+ xKey = buttonId + orientation + "-X";
+ yKey = buttonId + orientation + "-Y";
+
+ int drawableX = (int) sPrefs.getFloat(xKey, 0f);
+ int drawableY = (int) sPrefs.getFloat(yKey, 0f);
+
+ int width = overlayDrawable.getWidth();
+ int height = overlayDrawable.getHeight();
+
+ // Now set the bounds for the InputOverlayDrawableButton.
+ // This will dictate where on the screen (and the what the size) the InputOverlayDrawableButton will be.
+ overlayDrawable.setBounds(drawableX, drawableY, drawableX + width, drawableY + height);
+
+ // Need to set the image's position
+ overlayDrawable.setPosition(drawableX, drawableY);
+
+ return overlayDrawable;
+ }
+
+ /**
+ * Initializes an {@link InputOverlayDrawableDpad}
+ *
+ * @param context The current {@link Context}.
+ * @param defaultResId The {@link Bitmap} resource ID of the default sate.
+ * @param pressedOneDirectionResId The {@link Bitmap} resource ID of the pressed sate in one direction.
+ * @param pressedTwoDirectionsResId The {@link Bitmap} resource ID of the pressed sate in two directions.
+ * @param buttonUp Identifier for the up button.
+ * @param buttonDown Identifier for the down button.
+ * @param buttonLeft Identifier for the left button.
+ * @param buttonRight Identifier for the right button.
+ * @return the initialized {@link InputOverlayDrawableDpad}
+ */
+ private static InputOverlayDrawableDpad initializeOverlayDpad(Context context,
+ int defaultResId,
+ int pressedOneDirectionResId,
+ int pressedTwoDirectionsResId,
+ int buttonUp,
+ int buttonDown,
+ int buttonLeft,
+ int buttonRight,
+ String orientation) {
+ // Resources handle for fetching the initial Drawable resource.
+ final Resources res = context.getResources();
+
+ // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableDpad.
+ final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context);
+
+ // Decide scale based on button ID and user preference
+ float scale = 0.22f;
+
+ scale *= (sPrefs.getInt("controlScale", 50) + 50);
+ scale /= 100;
+
+ // Initialize the InputOverlayDrawableDpad.
+ final Bitmap defaultStateBitmap =
+ resizeBitmap(context, BitmapFactory.decodeResource(res, defaultResId), scale);
+ final Bitmap pressedOneDirectionStateBitmap =
+ resizeBitmap(context, BitmapFactory.decodeResource(res, pressedOneDirectionResId),
+ scale);
+ final Bitmap pressedTwoDirectionsStateBitmap =
+ resizeBitmap(context, BitmapFactory.decodeResource(res, pressedTwoDirectionsResId),
+ scale);
+ final InputOverlayDrawableDpad overlayDrawable =
+ new InputOverlayDrawableDpad(res, defaultStateBitmap,
+ pressedOneDirectionStateBitmap, pressedTwoDirectionsStateBitmap,
+ buttonUp, buttonDown, buttonLeft, buttonRight);
+
+ // The X and Y coordinates of the InputOverlayDrawableDpad on the InputOverlay.
+ // These were set in the input overlay configuration menu.
+ int drawableX = (int) sPrefs.getFloat(buttonUp + orientation + "-X", 0f);
+ int drawableY = (int) sPrefs.getFloat(buttonUp + orientation + "-Y", 0f);
+
+ int width = overlayDrawable.getWidth();
+ int height = overlayDrawable.getHeight();
+
+ // Now set the bounds for the InputOverlayDrawableDpad.
+ // This will dictate where on the screen (and the what the size) the InputOverlayDrawableDpad will be.
+ overlayDrawable.setBounds(drawableX, drawableY, drawableX + width, drawableY + height);
+
+ // Need to set the image's position
+ overlayDrawable.setPosition(drawableX, drawableY);
+
+ return overlayDrawable;
+ }
+
+ /**
+ * Initializes an {@link InputOverlayDrawableJoystick}
+ *
+ * @param context The current {@link Context}
+ * @param resOuter Resource ID for the outer image of the joystick (the static image that shows the circular bounds).
+ * @param defaultResInner Resource ID for the default inner image of the joystick (the one you actually move around).
+ * @param pressedResInner Resource ID for the pressed inner image of the joystick.
+ * @param joystick Identifier for which joystick this is.
+ * @return the initialized {@link InputOverlayDrawableJoystick}.
+ */
+ private static InputOverlayDrawableJoystick initializeOverlayJoystick(Context context,
+ int resOuter, int defaultResInner, int pressedResInner, int joystick, String orientation) {
+ // Resources handle for fetching the initial Drawable resource.
+ final Resources res = context.getResources();
+
+ // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableJoystick.
+ final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context);
+
+ // Decide scale based on user preference
+ float scale = 0.275f;
+ scale *= (sPrefs.getInt("controlScale", 50) + 50);
+ scale /= 100;
+
+ // Initialize the InputOverlayDrawableJoystick.
+ final Bitmap bitmapOuter =
+ resizeBitmap(context, BitmapFactory.decodeResource(res, resOuter), scale);
+ final Bitmap bitmapInnerDefault = BitmapFactory.decodeResource(res, defaultResInner);
+ final Bitmap bitmapInnerPressed = BitmapFactory.decodeResource(res, pressedResInner);
+
+ // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay.
+ // These were set in the input overlay configuration menu.
+ int drawableX = (int) sPrefs.getFloat(joystick + orientation + "-X", 0f);
+ int drawableY = (int) sPrefs.getFloat(joystick + orientation + "-Y", 0f);
+
+ // Decide inner scale based on joystick ID
+ float outerScale = 1.f;
+ if (joystick == ButtonType.STICK_C) {
+ outerScale = 2.f;
+ }
+
+ // Now set the bounds for the InputOverlayDrawableJoystick.
+ // This will dictate where on the screen (and the what the size) the InputOverlayDrawableJoystick will be.
+ int outerSize = bitmapOuter.getWidth();
+ Rect outerRect = new Rect(drawableX, drawableY, drawableX + (int) (outerSize / outerScale), drawableY + (int) (outerSize / outerScale));
+ Rect innerRect = new Rect(0, 0, (int) (outerSize / outerScale), (int) (outerSize / outerScale));
+
+ // Send the drawableId to the joystick so it can be referenced when saving control position.
+ final InputOverlayDrawableJoystick overlayDrawable
+ = new InputOverlayDrawableJoystick(res, bitmapOuter,
+ bitmapInnerDefault, bitmapInnerPressed,
+ outerRect, innerRect, joystick);
+
+ // Need to set the image's position
+ overlayDrawable.setPosition(drawableX, drawableY);
+
+ return overlayDrawable;
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ super.draw(canvas);
+
+ for (InputOverlayDrawableButton button : overlayButtons) {
+ button.draw(canvas);
+ }
+
+ for (InputOverlayDrawableDpad dpad : overlayDpads) {
+ dpad.draw(canvas);
+ }
+
+ for (InputOverlayDrawableJoystick joystick : overlayJoysticks) {
+ joystick.draw(canvas);
+ }
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (isInEditMode()) {
+ return onTouchWhileEditing(event);
+ }
+
+ int pointerIndex = event.getActionIndex();
+
+ if (mPreferences.getBoolean("isTouchEnabled", true)) {
+ switch (event.getAction() & MotionEvent.ACTION_MASK) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ if (NativeLibrary.onTouchEvent(event.getX(pointerIndex), event.getY(pointerIndex), true)) {
+ mTouchscreenPointerId = event.getPointerId(pointerIndex);
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP:
+ if (mTouchscreenPointerId == event.getPointerId(pointerIndex)) {
+ // We don't really care where the touch has been released. We only care whether it has been
+ // released or not.
+ NativeLibrary.onTouchEvent(0, 0, false);
+ mTouchscreenPointerId = -1;
+ }
+ break;
+ }
+
+ for (int i = 0; i < event.getPointerCount(); i++) {
+ if (mTouchscreenPointerId == event.getPointerId(i)) {
+ NativeLibrary.onTouchMoved(event.getX(i), event.getY(i));
+ }
+ }
+ }
+
+ for (InputOverlayDrawableButton button : overlayButtons) {
+ // Determine the button state to apply based on the MotionEvent action flag.
+ switch (event.getAction() & MotionEvent.ACTION_MASK) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ // If a pointer enters the bounds of a button, press that button.
+ if (button.getBounds()
+ .contains((int) event.getX(pointerIndex), (int) event.getY(pointerIndex))) {
+ button.setPressedState(true);
+ button.setTrackId(event.getPointerId(pointerIndex));
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(),
+ ButtonState.PRESSED);
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP:
+ // If a pointer ends, release the button it was pressing.
+ if (button.getTrackId() == event.getPointerId(pointerIndex)) {
+ button.setPressedState(false);
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(),
+ ButtonState.RELEASED);
+ }
+ break;
+ }
+ }
+
+ for (InputOverlayDrawableDpad dpad : overlayDpads) {
+ // Determine the button state to apply based on the MotionEvent action flag.
+ switch (event.getAction() & MotionEvent.ACTION_MASK) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ // If a pointer enters the bounds of a button, press that button.
+ if (dpad.getBounds()
+ .contains((int) event.getX(pointerIndex), (int) event.getY(pointerIndex))) {
+ dpad.setTrackId(event.getPointerId(pointerIndex));
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP:
+ // If a pointer ends, release the buttons.
+ if (dpad.getTrackId() == event.getPointerId(pointerIndex)) {
+ for (int i = 0; i < 4; i++) {
+ dpad.setState(InputOverlayDrawableDpad.STATE_DEFAULT);
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(i),
+ NativeLibrary.ButtonState.RELEASED);
+ }
+ dpad.setTrackId(-1);
+ }
+ break;
+ }
+
+ if (dpad.getTrackId() != -1) {
+ for (int i = 0; i < event.getPointerCount(); i++) {
+ if (dpad.getTrackId() == event.getPointerId(i)) {
+ float touchX = event.getX(i);
+ float touchY = event.getY(i);
+ float maxY = dpad.getBounds().bottom;
+ float maxX = dpad.getBounds().right;
+ touchX -= dpad.getBounds().centerX();
+ maxX -= dpad.getBounds().centerX();
+ touchY -= dpad.getBounds().centerY();
+ maxY -= dpad.getBounds().centerY();
+ final float AxisX = touchX / maxX;
+ final float AxisY = touchY / maxY;
+
+ boolean up = false;
+ boolean down = false;
+ boolean left = false;
+ boolean right = false;
+ if (EmulationMenuSettings.getDpadSlideEnable() ||
+ (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN ||
+ (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_POINTER_DOWN) {
+ if (AxisY < -InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(0),
+ NativeLibrary.ButtonState.PRESSED);
+ up = true;
+ } else {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(0),
+ NativeLibrary.ButtonState.RELEASED);
+ }
+ if (AxisY > InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(1),
+ NativeLibrary.ButtonState.PRESSED);
+ down = true;
+ } else {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(1),
+ NativeLibrary.ButtonState.RELEASED);
+ }
+ if (AxisX < -InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(2),
+ NativeLibrary.ButtonState.PRESSED);
+ left = true;
+ } else {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(2),
+ NativeLibrary.ButtonState.RELEASED);
+ }
+ if (AxisX > InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(3),
+ NativeLibrary.ButtonState.PRESSED);
+ right = true;
+ } else {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(3),
+ NativeLibrary.ButtonState.RELEASED);
+ }
+
+ // Set state
+ if (up) {
+ if (left)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_LEFT);
+ else if (right)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_RIGHT);
+ else
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP);
+ } else if (down) {
+ if (left)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_LEFT);
+ else if (right)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_RIGHT);
+ else
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN);
+ } else if (left) {
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_LEFT);
+ } else if (right) {
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_RIGHT);
+ } else {
+ dpad.setState(InputOverlayDrawableDpad.STATE_DEFAULT);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ for (InputOverlayDrawableJoystick joystick : overlayJoysticks) {
+ joystick.TrackEvent(event);
+ int axisID = joystick.getId();
+ float[] axises = joystick.getAxisValues();
+
+ NativeLibrary
+ .onGamePadMoveEvent(NativeLibrary.TouchScreenDevice, axisID, axises[0], axises[1]);
+ }
+
+ invalidate();
+
+ return true;
+ }
+
+ public boolean onTouchWhileEditing(MotionEvent event) {
+ int pointerIndex = event.getActionIndex();
+ int fingerPositionX = (int) event.getX(pointerIndex);
+ int fingerPositionY = (int) event.getY(pointerIndex);
+
+ String orientation =
+ getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT ?
+ "-Portrait" : "";
+
+ // Maybe combine Button and Joystick as subclasses of the same parent?
+ // Or maybe create an interface like IMoveableHUDControl?
+
+ for (InputOverlayDrawableButton button : overlayButtons) {
+ // Determine the button state to apply based on the MotionEvent action flag.
+ switch (event.getAction() & MotionEvent.ACTION_MASK) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ // If no button is being moved now, remember the currently touched button to move.
+ if (mButtonBeingConfigured == null &&
+ button.getBounds().contains(fingerPositionX, fingerPositionY)) {
+ mButtonBeingConfigured = button;
+ mButtonBeingConfigured.onConfigureTouch(event);
+ }
+ break;
+ case MotionEvent.ACTION_MOVE:
+ if (mButtonBeingConfigured != null) {
+ mButtonBeingConfigured.onConfigureTouch(event);
+ invalidate();
+ return true;
+ }
+ break;
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP:
+ if (mButtonBeingConfigured == button) {
+ // Persist button position by saving new place.
+ saveControlPosition(mButtonBeingConfigured.getId(),
+ mButtonBeingConfigured.getBounds().left,
+ mButtonBeingConfigured.getBounds().top, orientation);
+ mButtonBeingConfigured = null;
+ }
+ break;
+ }
+ }
+
+ for (InputOverlayDrawableDpad dpad : overlayDpads) {
+ // Determine the button state to apply based on the MotionEvent action flag.
+ switch (event.getAction() & MotionEvent.ACTION_MASK) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ // If no button is being moved now, remember the currently touched button to move.
+ if (mButtonBeingConfigured == null &&
+ dpad.getBounds().contains(fingerPositionX, fingerPositionY)) {
+ mDpadBeingConfigured = dpad;
+ mDpadBeingConfigured.onConfigureTouch(event);
+ }
+ break;
+ case MotionEvent.ACTION_MOVE:
+ if (mDpadBeingConfigured != null) {
+ mDpadBeingConfigured.onConfigureTouch(event);
+ invalidate();
+ return true;
+ }
+ break;
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP:
+ if (mDpadBeingConfigured == dpad) {
+ // Persist button position by saving new place.
+ saveControlPosition(mDpadBeingConfigured.getId(0),
+ mDpadBeingConfigured.getBounds().left, mDpadBeingConfigured.getBounds().top,
+ orientation);
+ mDpadBeingConfigured = null;
+ }
+ break;
+ }
+ }
+
+ for (InputOverlayDrawableJoystick joystick : overlayJoysticks) {
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ if (mJoystickBeingConfigured == null &&
+ joystick.getBounds().contains(fingerPositionX, fingerPositionY)) {
+ mJoystickBeingConfigured = joystick;
+ mJoystickBeingConfigured.onConfigureTouch(event);
+ }
+ break;
+ case MotionEvent.ACTION_MOVE:
+ if (mJoystickBeingConfigured != null) {
+ mJoystickBeingConfigured.onConfigureTouch(event);
+ invalidate();
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP:
+ if (mJoystickBeingConfigured != null) {
+ saveControlPosition(mJoystickBeingConfigured.getId(),
+ mJoystickBeingConfigured.getBounds().left,
+ mJoystickBeingConfigured.getBounds().top, orientation);
+ mJoystickBeingConfigured = null;
+ }
+ break;
+ }
+ }
+
+ return true;
+ }
+
+ private void setDpadState(InputOverlayDrawableDpad dpad, boolean up, boolean down, boolean left,
+ boolean right) {
+ if (up) {
+ if (left)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_LEFT);
+ else if (right)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_RIGHT);
+ else
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP);
+ } else if (down) {
+ if (left)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_LEFT);
+ else if (right)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_RIGHT);
+ else
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN);
+ } else if (left) {
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_LEFT);
+ } else if (right) {
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_RIGHT);
+ }
+ }
+
+ private void addOverlayControls(String orientation) {
+ if (mPreferences.getBoolean("buttonToggle0", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_a,
+ R.drawable.button_a_pressed, ButtonType.BUTTON_A, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle1", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_b,
+ R.drawable.button_b_pressed, ButtonType.BUTTON_B, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle2", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_x,
+ R.drawable.button_x_pressed, ButtonType.BUTTON_X, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle3", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_y,
+ R.drawable.button_y_pressed, ButtonType.BUTTON_Y, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle4", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_l,
+ R.drawable.button_l_pressed, ButtonType.TRIGGER_L, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle5", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_r,
+ R.drawable.button_r_pressed, ButtonType.TRIGGER_R, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle6", false)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_zl,
+ R.drawable.button_zl_pressed, ButtonType.BUTTON_ZL, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle7", false)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_zr,
+ R.drawable.button_zr_pressed, ButtonType.BUTTON_ZR, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle8", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_start,
+ R.drawable.button_start_pressed, ButtonType.BUTTON_START, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle9", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_select,
+ R.drawable.button_select_pressed, ButtonType.BUTTON_SELECT, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle10", true)) {
+ overlayDpads.add(initializeOverlayDpad(getContext(), R.drawable.dpad,
+ R.drawable.dpad_pressed_one_direction,
+ R.drawable.dpad_pressed_two_directions,
+ ButtonType.DPAD_UP, ButtonType.DPAD_DOWN,
+ ButtonType.DPAD_LEFT, ButtonType.DPAD_RIGHT, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle11", true)) {
+ overlayJoysticks.add(initializeOverlayJoystick(getContext(), R.drawable.stick_main_range,
+ R.drawable.stick_main, R.drawable.stick_main_pressed,
+ ButtonType.STICK_LEFT, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle12", false)) {
+ overlayJoysticks.add(initializeOverlayJoystick(getContext(), R.drawable.stick_c_range,
+ R.drawable.stick_c, R.drawable.stick_c_pressed, ButtonType.STICK_C, orientation));
+ }
+ }
+
+ public void refreshControls() {
+ // Remove all the overlay buttons from the HashSet.
+ overlayButtons.clear();
+ overlayDpads.clear();
+ overlayJoysticks.clear();
+
+ String orientation =
+ getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT ?
+ "-Portrait" : "";
+
+ // Add all the enabled overlay items back to the HashSet.
+ if (EmulationMenuSettings.getShowOverlay()) {
+ addOverlayControls(orientation);
+ }
+
+ invalidate();
+ }
+
+ private void saveControlPosition(int sharedPrefsId, int x, int y, String orientation) {
+ final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(getContext());
+ SharedPreferences.Editor sPrefsEditor = sPrefs.edit();
+ sPrefsEditor.putFloat(sharedPrefsId + orientation + "-X", x);
+ sPrefsEditor.putFloat(sharedPrefsId + orientation + "-Y", y);
+ sPrefsEditor.apply();
+ }
+
+ public void setIsInEditMode(boolean isInEditMode) {
+ mIsInEditMode = isInEditMode;
+ }
+
+ private void defaultOverlay() {
+ if (!mPreferences.getBoolean("OverlayInit", false)) {
+ // It's possible that a user has created their overlay before this was added
+ // Only change the overlay if the 'A' button is not in the upper corner.
+ if (mPreferences.getFloat(ButtonType.BUTTON_A + "-X", 0f) == 0f) {
+ defaultOverlayLandscape();
+ }
+ if (mPreferences.getFloat(ButtonType.BUTTON_A + "-Portrait" + "-X", 0f) == 0f) {
+ defaultOverlayPortrait();
+ }
+ }
+
+ SharedPreferences.Editor sPrefsEditor = mPreferences.edit();
+ sPrefsEditor.putBoolean("OverlayInit", true);
+ sPrefsEditor.apply();
+ }
+
+ public void resetButtonPlacement() {
+ boolean isLandscape =
+ getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
+
+ if (isLandscape) {
+ defaultOverlayLandscape();
+ } else {
+ defaultOverlayPortrait();
+ }
+
+ refreshControls();
+ }
+
+ private void defaultOverlayLandscape() {
+ SharedPreferences.Editor sPrefsEditor = mPreferences.edit();
+ // Get screen size
+ Display display = ((Activity) getContext()).getWindowManager().getDefaultDisplay();
+ DisplayMetrics outMetrics = new DisplayMetrics();
+ display.getMetrics(outMetrics);
+ float maxX = outMetrics.heightPixels;
+ float maxY = outMetrics.widthPixels;
+ // Height and width changes depending on orientation. Use the larger value for height.
+ if (maxY > maxX) {
+ float tmp = maxX;
+ maxX = maxY;
+ maxY = tmp;
+ }
+ Resources res = getResources();
+
+ // Each value is a percent from max X/Y stored as an int. Have to bring that value down
+ // to a decimal before multiplying by MAX X/Y.
+ sPrefsEditor.putFloat(ButtonType.BUTTON_A + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_A + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_B + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_B + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_X + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_X + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_Y + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_Y + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.DPAD_UP + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.DPAD_UP + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_L + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_L + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_R + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_R + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_START + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_START + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.STICK_C + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_C_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.STICK_C + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_C_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.STICK_LEFT + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.STICK_LEFT + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_Y) / 1000) * maxY));
+
+ // We want to commit right away, otherwise the overlay could load before this is saved.
+ sPrefsEditor.commit();
+ }
+
+ private void defaultOverlayPortrait() {
+ SharedPreferences.Editor sPrefsEditor = mPreferences.edit();
+ // Get screen size
+ Display display = ((Activity) getContext()).getWindowManager().getDefaultDisplay();
+ DisplayMetrics outMetrics = new DisplayMetrics();
+ display.getMetrics(outMetrics);
+ float maxX = outMetrics.heightPixels;
+ float maxY = outMetrics.widthPixels;
+ // Height and width changes depending on orientation. Use the larger value for height.
+ if (maxY < maxX) {
+ float tmp = maxX;
+ maxX = maxY;
+ maxY = tmp;
+ }
+ Resources res = getResources();
+ String portrait = "-Portrait";
+
+ // Each value is a percent from max X/Y stored as an int. Have to bring that value down
+ // to a decimal before multiplying by MAX X/Y.
+ sPrefsEditor.putFloat(ButtonType.BUTTON_A + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_A + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_B + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_B + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_X + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_X + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_Y + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_Y + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.DPAD_UP + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.DPAD_UP + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_L + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_L + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_R + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_R + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_START + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_START + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.STICK_C + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_C_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.STICK_C + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_C_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.STICK_LEFT + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.STICK_LEFT + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_PORTRAIT_Y) / 1000) * maxY));
+
+ // We want to commit right away, otherwise the overlay could load before this is saved.
+ sPrefsEditor.commit();
+ }
+
+ public boolean isInEditMode() {
+ return mIsInEditMode;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java
new file mode 100644
index 000000000..81352296c
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java
@@ -0,0 +1,122 @@
+/**
+ * Copyright 2013 Dolphin Emulator Project
+ * Licensed under GPLv2+
+ * Refer to the license.txt file included.
+ */
+
+package org.citra.citra_emu.overlay;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.BitmapDrawable;
+import android.view.MotionEvent;
+
+/**
+ * Custom {@link BitmapDrawable} that is capable
+ * of storing it's own ID.
+ */
+public final class InputOverlayDrawableButton {
+ // The ID identifying what type of button this Drawable represents.
+ private int mButtonType;
+ private int mTrackId;
+ private int mPreviousTouchX, mPreviousTouchY;
+ private int mControlPositionX, mControlPositionY;
+ private int mWidth;
+ private int mHeight;
+ private BitmapDrawable mDefaultStateBitmap;
+ private BitmapDrawable mPressedStateBitmap;
+ private boolean mPressedState = false;
+
+ /**
+ * Constructor
+ *
+ * @param res {@link Resources} instance.
+ * @param defaultStateBitmap {@link Bitmap} to use with the default state Drawable.
+ * @param pressedStateBitmap {@link Bitmap} to use with the pressed state Drawable.
+ * @param buttonType Identifier for this type of button.
+ */
+ public InputOverlayDrawableButton(Resources res, Bitmap defaultStateBitmap,
+ Bitmap pressedStateBitmap, int buttonType) {
+ mDefaultStateBitmap = new BitmapDrawable(res, defaultStateBitmap);
+ mPressedStateBitmap = new BitmapDrawable(res, pressedStateBitmap);
+ mButtonType = buttonType;
+
+ mWidth = mDefaultStateBitmap.getIntrinsicWidth();
+ mHeight = mDefaultStateBitmap.getIntrinsicHeight();
+ }
+
+ /**
+ * Gets this InputOverlayDrawableButton's button ID.
+ *
+ * @return this InputOverlayDrawableButton's button ID.
+ */
+ public int getId() {
+ return mButtonType;
+ }
+
+ public int getTrackId() {
+ return mTrackId;
+ }
+
+ public void setTrackId(int trackId) {
+ mTrackId = trackId;
+ }
+
+ public boolean onConfigureTouch(MotionEvent event) {
+ int pointerIndex = event.getActionIndex();
+ int fingerPositionX = (int) event.getX(pointerIndex);
+ int fingerPositionY = (int) event.getY(pointerIndex);
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ mPreviousTouchX = fingerPositionX;
+ mPreviousTouchY = fingerPositionY;
+ break;
+ case MotionEvent.ACTION_MOVE:
+ mControlPositionX += fingerPositionX - mPreviousTouchX;
+ mControlPositionY += fingerPositionY - mPreviousTouchY;
+ setBounds(mControlPositionX, mControlPositionY, getWidth() + mControlPositionX,
+ getHeight() + mControlPositionY);
+ mPreviousTouchX = fingerPositionX;
+ mPreviousTouchY = fingerPositionY;
+ break;
+
+ }
+ return true;
+ }
+
+ public void setPosition(int x, int y) {
+ mControlPositionX = x;
+ mControlPositionY = y;
+ }
+
+ public void draw(Canvas canvas) {
+ getCurrentStateBitmapDrawable().draw(canvas);
+ }
+
+ private BitmapDrawable getCurrentStateBitmapDrawable() {
+ return mPressedState ? mPressedStateBitmap : mDefaultStateBitmap;
+ }
+
+ public void setBounds(int left, int top, int right, int bottom) {
+ mDefaultStateBitmap.setBounds(left, top, right, bottom);
+ mPressedStateBitmap.setBounds(left, top, right, bottom);
+ }
+
+ public Rect getBounds() {
+ return mDefaultStateBitmap.getBounds();
+ }
+
+ public int getWidth() {
+ return mWidth;
+ }
+
+ public int getHeight() {
+ return mHeight;
+ }
+
+ public void setPressedState(boolean isPressed) {
+ mPressedState = isPressed;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java
new file mode 100644
index 000000000..87f3b7cd9
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java
@@ -0,0 +1,193 @@
+/**
+ * Copyright 2016 Dolphin Emulator Project
+ * Licensed under GPLv2+
+ * Refer to the license.txt file included.
+ */
+
+package org.citra.citra_emu.overlay;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.BitmapDrawable;
+import android.view.MotionEvent;
+
+/**
+ * Custom {@link BitmapDrawable} that is capable
+ * of storing it's own ID.
+ */
+public final class InputOverlayDrawableDpad {
+ public static final int STATE_DEFAULT = 0;
+ public static final int STATE_PRESSED_UP = 1;
+ public static final int STATE_PRESSED_DOWN = 2;
+ public static final int STATE_PRESSED_LEFT = 3;
+ public static final int STATE_PRESSED_RIGHT = 4;
+ public static final int STATE_PRESSED_UP_LEFT = 5;
+ public static final int STATE_PRESSED_UP_RIGHT = 6;
+ public static final int STATE_PRESSED_DOWN_LEFT = 7;
+ public static final int STATE_PRESSED_DOWN_RIGHT = 8;
+ public static final float VIRT_AXIS_DEADZONE = 0.5f;
+ // The ID identifying what type of button this Drawable represents.
+ private int[] mButtonType = new int[4];
+ private int mTrackId;
+ private int mPreviousTouchX, mPreviousTouchY;
+ private int mControlPositionX, mControlPositionY;
+ private int mWidth;
+ private int mHeight;
+ private BitmapDrawable mDefaultStateBitmap;
+ private BitmapDrawable mPressedOneDirectionStateBitmap;
+ private BitmapDrawable mPressedTwoDirectionsStateBitmap;
+ private int mPressState = STATE_DEFAULT;
+
+ /**
+ * Constructor
+ *
+ * @param res {@link Resources} instance.
+ * @param defaultStateBitmap {@link Bitmap} of the default state.
+ * @param pressedOneDirectionStateBitmap {@link Bitmap} of the pressed state in one direction.
+ * @param pressedTwoDirectionsStateBitmap {@link Bitmap} of the pressed state in two direction.
+ * @param buttonUp Identifier for the up button.
+ * @param buttonDown Identifier for the down button.
+ * @param buttonLeft Identifier for the left button.
+ * @param buttonRight Identifier for the right button.
+ */
+ public InputOverlayDrawableDpad(Resources res,
+ Bitmap defaultStateBitmap,
+ Bitmap pressedOneDirectionStateBitmap,
+ Bitmap pressedTwoDirectionsStateBitmap,
+ int buttonUp, int buttonDown,
+ int buttonLeft, int buttonRight) {
+ mDefaultStateBitmap = new BitmapDrawable(res, defaultStateBitmap);
+ mPressedOneDirectionStateBitmap = new BitmapDrawable(res, pressedOneDirectionStateBitmap);
+ mPressedTwoDirectionsStateBitmap = new BitmapDrawable(res, pressedTwoDirectionsStateBitmap);
+
+ mWidth = mDefaultStateBitmap.getIntrinsicWidth();
+ mHeight = mDefaultStateBitmap.getIntrinsicHeight();
+
+ mButtonType[0] = buttonUp;
+ mButtonType[1] = buttonDown;
+ mButtonType[2] = buttonLeft;
+ mButtonType[3] = buttonRight;
+
+ mTrackId = -1;
+ }
+
+ public void draw(Canvas canvas) {
+ int px = mControlPositionX + (getWidth() / 2);
+ int py = mControlPositionY + (getHeight() / 2);
+ switch (mPressState) {
+ case STATE_DEFAULT:
+ mDefaultStateBitmap.draw(canvas);
+ break;
+ case STATE_PRESSED_UP:
+ mPressedOneDirectionStateBitmap.draw(canvas);
+ break;
+ case STATE_PRESSED_RIGHT:
+ canvas.save();
+ canvas.rotate(90, px, py);
+ mPressedOneDirectionStateBitmap.draw(canvas);
+ canvas.restore();
+ break;
+ case STATE_PRESSED_DOWN:
+ canvas.save();
+ canvas.rotate(180, px, py);
+ mPressedOneDirectionStateBitmap.draw(canvas);
+ canvas.restore();
+ break;
+ case STATE_PRESSED_LEFT:
+ canvas.save();
+ canvas.rotate(270, px, py);
+ mPressedOneDirectionStateBitmap.draw(canvas);
+ canvas.restore();
+ break;
+ case STATE_PRESSED_UP_LEFT:
+ mPressedTwoDirectionsStateBitmap.draw(canvas);
+ break;
+ case STATE_PRESSED_UP_RIGHT:
+ canvas.save();
+ canvas.rotate(90, px, py);
+ mPressedTwoDirectionsStateBitmap.draw(canvas);
+ canvas.restore();
+ break;
+ case STATE_PRESSED_DOWN_RIGHT:
+ canvas.save();
+ canvas.rotate(180, px, py);
+ mPressedTwoDirectionsStateBitmap.draw(canvas);
+ canvas.restore();
+ break;
+ case STATE_PRESSED_DOWN_LEFT:
+ canvas.save();
+ canvas.rotate(270, px, py);
+ mPressedTwoDirectionsStateBitmap.draw(canvas);
+ canvas.restore();
+ break;
+ }
+ }
+
+ /**
+ * Gets one of the InputOverlayDrawableDpad's button IDs.
+ *
+ * @return the requested InputOverlayDrawableDpad's button ID.
+ */
+ public int getId(int direction) {
+ return mButtonType[direction];
+ }
+
+ public int getTrackId() {
+ return mTrackId;
+ }
+
+ public void setTrackId(int trackId) {
+ mTrackId = trackId;
+ }
+
+ public boolean onConfigureTouch(MotionEvent event) {
+ int pointerIndex = event.getActionIndex();
+ int fingerPositionX = (int) event.getX(pointerIndex);
+ int fingerPositionY = (int) event.getY(pointerIndex);
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ mPreviousTouchX = fingerPositionX;
+ mPreviousTouchY = fingerPositionY;
+ break;
+ case MotionEvent.ACTION_MOVE:
+ mControlPositionX += fingerPositionX - mPreviousTouchX;
+ mControlPositionY += fingerPositionY - mPreviousTouchY;
+ setBounds(mControlPositionX, mControlPositionY, getWidth() + mControlPositionX,
+ getHeight() + mControlPositionY);
+ mPreviousTouchX = fingerPositionX;
+ mPreviousTouchY = fingerPositionY;
+ break;
+
+ }
+ return true;
+ }
+
+ public void setPosition(int x, int y) {
+ mControlPositionX = x;
+ mControlPositionY = y;
+ }
+
+ public void setBounds(int left, int top, int right, int bottom) {
+ mDefaultStateBitmap.setBounds(left, top, right, bottom);
+ mPressedOneDirectionStateBitmap.setBounds(left, top, right, bottom);
+ mPressedTwoDirectionsStateBitmap.setBounds(left, top, right, bottom);
+ }
+
+ public Rect getBounds() {
+ return mDefaultStateBitmap.getBounds();
+ }
+
+ public int getWidth() {
+ return mWidth;
+ }
+
+ public int getHeight() {
+ return mHeight;
+ }
+
+ public void setState(int pressState) {
+ mPressState = pressState;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java
new file mode 100644
index 000000000..956a8b1e9
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java
@@ -0,0 +1,264 @@
+/**
+ * Copyright 2013 Dolphin Emulator Project
+ * Licensed under GPLv2+
+ * Refer to the license.txt file included.
+ */
+
+package org.citra.citra_emu.overlay;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.BitmapDrawable;
+import android.view.MotionEvent;
+
+import org.citra.citra_emu.NativeLibrary.ButtonType;
+import org.citra.citra_emu.utils.EmulationMenuSettings;
+
+/**
+ * Custom {@link BitmapDrawable} that is capable
+ * of storing it's own ID.
+ */
+public final class InputOverlayDrawableJoystick {
+ private final int[] axisIDs = {0, 0, 0, 0};
+ private final float[] axises = {0f, 0f};
+ private int trackId = -1;
+ private int mJoystickType;
+ private int mControlPositionX, mControlPositionY;
+ private int mPreviousTouchX, mPreviousTouchY;
+ private int mWidth;
+ private int mHeight;
+ private Rect mVirtBounds;
+ private Rect mOrigBounds;
+ private BitmapDrawable mOuterBitmap;
+ private BitmapDrawable mDefaultStateInnerBitmap;
+ private BitmapDrawable mPressedStateInnerBitmap;
+ private BitmapDrawable mBoundsBoxBitmap;
+ private boolean mPressedState = false;
+
+ /**
+ * Constructor
+ *
+ * @param res {@link Resources} instance.
+ * @param bitmapOuter {@link Bitmap} which represents the outer non-movable part of the joystick.
+ * @param bitmapInnerDefault {@link Bitmap} which represents the default inner movable part of the joystick.
+ * @param bitmapInnerPressed {@link Bitmap} which represents the pressed inner movable part of the joystick.
+ * @param rectOuter {@link Rect} which represents the outer joystick bounds.
+ * @param rectInner {@link Rect} which represents the inner joystick bounds.
+ * @param joystick Identifier for which joystick this is.
+ */
+ public InputOverlayDrawableJoystick(Resources res, Bitmap bitmapOuter,
+ Bitmap bitmapInnerDefault, Bitmap bitmapInnerPressed,
+ Rect rectOuter, Rect rectInner, int joystick) {
+ axisIDs[0] = joystick + 1; // Up
+ axisIDs[1] = joystick + 2; // Down
+ axisIDs[2] = joystick + 3; // Left
+ axisIDs[3] = joystick + 4; // Right
+ mJoystickType = joystick;
+
+ mOuterBitmap = new BitmapDrawable(res, bitmapOuter);
+ mDefaultStateInnerBitmap = new BitmapDrawable(res, bitmapInnerDefault);
+ mPressedStateInnerBitmap = new BitmapDrawable(res, bitmapInnerPressed);
+ mBoundsBoxBitmap = new BitmapDrawable(res, bitmapOuter);
+ mWidth = bitmapOuter.getWidth();
+ mHeight = bitmapOuter.getHeight();
+
+ setBounds(rectOuter);
+ mDefaultStateInnerBitmap.setBounds(rectInner);
+ mPressedStateInnerBitmap.setBounds(rectInner);
+ mVirtBounds = getBounds();
+ mOrigBounds = mOuterBitmap.copyBounds();
+ mBoundsBoxBitmap.setAlpha(0);
+ mBoundsBoxBitmap.setBounds(getVirtBounds());
+ SetInnerBounds();
+ }
+
+ /**
+ * Gets this InputOverlayDrawableJoystick's button ID.
+ *
+ * @return this InputOverlayDrawableJoystick's button ID.
+ */
+ public int getId() {
+ return mJoystickType;
+ }
+
+ public void draw(Canvas canvas) {
+ mOuterBitmap.draw(canvas);
+ getCurrentStateBitmapDrawable().draw(canvas);
+ mBoundsBoxBitmap.draw(canvas);
+ }
+
+ public void TrackEvent(MotionEvent event) {
+ int pointerIndex = event.getActionIndex();
+
+ switch (event.getAction() & MotionEvent.ACTION_MASK) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ if (getBounds().contains((int) event.getX(pointerIndex), (int) event.getY(pointerIndex))) {
+ mPressedState = true;
+ mOuterBitmap.setAlpha(0);
+ mBoundsBoxBitmap.setAlpha(255);
+ if (EmulationMenuSettings.getJoystickRelCenter()) {
+ getVirtBounds().offset((int) event.getX(pointerIndex) - getVirtBounds().centerX(),
+ (int) event.getY(pointerIndex) - getVirtBounds().centerY());
+ }
+ mBoundsBoxBitmap.setBounds(getVirtBounds());
+ trackId = event.getPointerId(pointerIndex);
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP:
+ if (trackId == event.getPointerId(pointerIndex)) {
+ mPressedState = false;
+ axises[0] = axises[1] = 0.0f;
+ mOuterBitmap.setAlpha(255);
+ mBoundsBoxBitmap.setAlpha(0);
+ setVirtBounds(new Rect(mOrigBounds.left, mOrigBounds.top, mOrigBounds.right,
+ mOrigBounds.bottom));
+ setBounds(new Rect(mOrigBounds.left, mOrigBounds.top, mOrigBounds.right,
+ mOrigBounds.bottom));
+ SetInnerBounds();
+ trackId = -1;
+ }
+ break;
+ }
+
+ if (trackId == -1)
+ return;
+
+ for (int i = 0; i < event.getPointerCount(); i++) {
+ if (trackId == event.getPointerId(i)) {
+ float touchX = event.getX(i);
+ float touchY = event.getY(i);
+ float maxY = getVirtBounds().bottom;
+ float maxX = getVirtBounds().right;
+ touchX -= getVirtBounds().centerX();
+ maxX -= getVirtBounds().centerX();
+ touchY -= getVirtBounds().centerY();
+ maxY -= getVirtBounds().centerY();
+ final float AxisX = touchX / maxX;
+ final float AxisY = touchY / maxY;
+
+ // Clamp the circle pad input to a circle
+ final float angle = (float) Math.atan2(AxisY, AxisX);
+ float radius = (float) Math.sqrt(AxisX * AxisX + AxisY * AxisY);
+ if(radius > 1.0f)
+ {
+ radius = 1.0f;
+ }
+ axises[0] = ((float)Math.cos(angle) * radius);
+ axises[1] = ((float)Math.sin(angle) * radius);
+ SetInnerBounds();
+ }
+ }
+ }
+
+ public boolean onConfigureTouch(MotionEvent event) {
+ int pointerIndex = event.getActionIndex();
+ int fingerPositionX = (int) event.getX(pointerIndex);
+ int fingerPositionY = (int) event.getY(pointerIndex);
+
+ int scale = 1;
+ if (mJoystickType == ButtonType.STICK_C) {
+ // C-stick is scaled down to be half the size of the circle pad
+ scale = 2;
+ }
+
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ mPreviousTouchX = fingerPositionX;
+ mPreviousTouchY = fingerPositionY;
+ break;
+ case MotionEvent.ACTION_MOVE:
+ int deltaX = fingerPositionX - mPreviousTouchX;
+ int deltaY = fingerPositionY - mPreviousTouchY;
+ mControlPositionX += deltaX;
+ mControlPositionY += deltaY;
+ setBounds(new Rect(mControlPositionX, mControlPositionY,
+ mOuterBitmap.getIntrinsicWidth() / scale + mControlPositionX,
+ mOuterBitmap.getIntrinsicHeight() / scale + mControlPositionY));
+ setVirtBounds(new Rect(mControlPositionX, mControlPositionY,
+ mOuterBitmap.getIntrinsicWidth() / scale + mControlPositionX,
+ mOuterBitmap.getIntrinsicHeight() / scale + mControlPositionY));
+ SetInnerBounds();
+ setOrigBounds(new Rect(new Rect(mControlPositionX, mControlPositionY,
+ mOuterBitmap.getIntrinsicWidth() / scale + mControlPositionX,
+ mOuterBitmap.getIntrinsicHeight() / scale + mControlPositionY)));
+ mPreviousTouchX = fingerPositionX;
+ mPreviousTouchY = fingerPositionY;
+ break;
+ }
+ return true;
+ }
+
+
+ public float[] getAxisValues() {
+ return axises;
+ }
+
+ public int[] getAxisIDs() {
+ return axisIDs;
+ }
+
+ private void SetInnerBounds() {
+ int X = getVirtBounds().centerX() + (int) ((axises[0]) * (getVirtBounds().width() / 2));
+ int Y = getVirtBounds().centerY() + (int) ((axises[1]) * (getVirtBounds().height() / 2));
+
+ if (mJoystickType == ButtonType.STICK_LEFT) {
+ X += 1;
+ Y += 1;
+ }
+
+ if (X > getVirtBounds().centerX() + (getVirtBounds().width() / 2))
+ X = getVirtBounds().centerX() + (getVirtBounds().width() / 2);
+ if (X < getVirtBounds().centerX() - (getVirtBounds().width() / 2))
+ X = getVirtBounds().centerX() - (getVirtBounds().width() / 2);
+ if (Y > getVirtBounds().centerY() + (getVirtBounds().height() / 2))
+ Y = getVirtBounds().centerY() + (getVirtBounds().height() / 2);
+ if (Y < getVirtBounds().centerY() - (getVirtBounds().height() / 2))
+ Y = getVirtBounds().centerY() - (getVirtBounds().height() / 2);
+
+ int width = mPressedStateInnerBitmap.getBounds().width() / 2;
+ int height = mPressedStateInnerBitmap.getBounds().height() / 2;
+ mDefaultStateInnerBitmap.setBounds(X - width, Y - height, X + width, Y + height);
+ mPressedStateInnerBitmap.setBounds(mDefaultStateInnerBitmap.getBounds());
+ }
+
+ public void setPosition(int x, int y) {
+ mControlPositionX = x;
+ mControlPositionY = y;
+ }
+
+ private BitmapDrawable getCurrentStateBitmapDrawable() {
+ return mPressedState ? mPressedStateInnerBitmap : mDefaultStateInnerBitmap;
+ }
+
+ public Rect getBounds() {
+ return mOuterBitmap.getBounds();
+ }
+
+ public void setBounds(Rect bounds) {
+ mOuterBitmap.setBounds(bounds);
+ }
+
+ private void setOrigBounds(Rect bounds) {
+ mOrigBounds = bounds;
+ }
+
+ private Rect getVirtBounds() {
+ return mVirtBounds;
+ }
+
+ private void setVirtBounds(Rect bounds) {
+ mVirtBounds = bounds;
+ }
+
+ public int getWidth() {
+ return mWidth;
+ }
+
+ public int getHeight() {
+ return mHeight;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java
new file mode 100644
index 000000000..96ccc08bb
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java
@@ -0,0 +1,130 @@
+package org.citra.citra_emu.ui;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+/**
+ * Implementation from:
+ * https://gist.github.com/lapastillaroja/858caf1a82791b6c1a36
+ */
+public class DividerItemDecoration extends RecyclerView.ItemDecoration {
+
+ private Drawable mDivider;
+ private boolean mShowFirstDivider = false;
+ private boolean mShowLastDivider = false;
+
+ public DividerItemDecoration(Context context, AttributeSet attrs) {
+ final TypedArray a = context
+ .obtainStyledAttributes(attrs, new int[]{android.R.attr.listDivider});
+ mDivider = a.getDrawable(0);
+ a.recycle();
+ }
+
+ public DividerItemDecoration(Context context, AttributeSet attrs, boolean showFirstDivider,
+ boolean showLastDivider) {
+ this(context, attrs);
+ mShowFirstDivider = showFirstDivider;
+ mShowLastDivider = showLastDivider;
+ }
+
+ public DividerItemDecoration(Drawable divider) {
+ mDivider = divider;
+ }
+
+ public DividerItemDecoration(Drawable divider, boolean showFirstDivider,
+ boolean showLastDivider) {
+ this(divider);
+ mShowFirstDivider = showFirstDivider;
+ mShowLastDivider = showLastDivider;
+ }
+
+ @Override
+ public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent,
+ @NonNull RecyclerView.State state) {
+ super.getItemOffsets(outRect, view, parent, state);
+ if (mDivider == null) {
+ return;
+ }
+ if (parent.getChildAdapterPosition(view) < 1) {
+ return;
+ }
+
+ if (getOrientation(parent) == LinearLayoutManager.VERTICAL) {
+ outRect.top = mDivider.getIntrinsicHeight();
+ } else {
+ outRect.left = mDivider.getIntrinsicWidth();
+ }
+ }
+
+ @Override
+ public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
+ if (mDivider == null) {
+ super.onDrawOver(c, parent, state);
+ return;
+ }
+
+ // Initialization needed to avoid compiler warning
+ int left = 0, right = 0, top = 0, bottom = 0, size;
+ int orientation = getOrientation(parent);
+ int childCount = parent.getChildCount();
+
+ if (orientation == LinearLayoutManager.VERTICAL) {
+ size = mDivider.getIntrinsicHeight();
+ left = parent.getPaddingLeft();
+ right = parent.getWidth() - parent.getPaddingRight();
+ } else { //horizontal
+ size = mDivider.getIntrinsicWidth();
+ top = parent.getPaddingTop();
+ bottom = parent.getHeight() - parent.getPaddingBottom();
+ }
+
+ for (int i = mShowFirstDivider ? 0 : 1; i < childCount; i++) {
+ View child = parent.getChildAt(i);
+ RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams();
+
+ if (orientation == LinearLayoutManager.VERTICAL) {
+ top = child.getTop() - params.topMargin;
+ bottom = top + size;
+ } else { //horizontal
+ left = child.getLeft() - params.leftMargin;
+ right = left + size;
+ }
+ mDivider.setBounds(left, top, right, bottom);
+ mDivider.draw(c);
+ }
+
+ // show last divider
+ if (mShowLastDivider && childCount > 0) {
+ View child = parent.getChildAt(childCount - 1);
+ RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams();
+ if (orientation == LinearLayoutManager.VERTICAL) {
+ top = child.getBottom() + params.bottomMargin;
+ bottom = top + size;
+ } else { // horizontal
+ left = child.getRight() + params.rightMargin;
+ right = left + size;
+ }
+ mDivider.setBounds(left, top, right, bottom);
+ mDivider.draw(c);
+ }
+ }
+
+ private int getOrientation(RecyclerView parent) {
+ if (parent.getLayoutManager() instanceof LinearLayoutManager) {
+ LinearLayoutManager layoutManager = (LinearLayoutManager) parent.getLayoutManager();
+ return layoutManager.getOrientation();
+ } else {
+ throw new IllegalStateException(
+ "DividerItemDecoration can only be used with a LinearLayoutManager.");
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java
new file mode 100644
index 000000000..402c8a4e0
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java
@@ -0,0 +1,269 @@
+package org.citra.citra_emu.ui.main;
+
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.widget.Toolbar;
+
+import org.citra.citra_emu.NativeLibrary;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.activities.EmulationActivity;
+import org.citra.citra_emu.features.settings.ui.SettingsActivity;
+import org.citra.citra_emu.model.GameProvider;
+import org.citra.citra_emu.ui.platform.PlatformGamesFragment;
+import org.citra.citra_emu.utils.AddDirectoryHelper;
+import org.citra.citra_emu.utils.BillingManager;
+import org.citra.citra_emu.utils.DirectoryInitialization;
+import org.citra.citra_emu.utils.FileBrowserHelper;
+import org.citra.citra_emu.utils.PermissionsHandler;
+import org.citra.citra_emu.utils.PicassoUtils;
+import org.citra.citra_emu.utils.StartupHandler;
+import org.citra.citra_emu.utils.ThemeUtil;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+/**
+ * The main Activity of the Lollipop style UI. Manages several PlatformGamesFragments, which
+ * individually display a grid of available games for each Fragment, in a tabbed layout.
+ */
+public final class MainActivity extends AppCompatActivity implements MainView {
+ private Toolbar mToolbar;
+ private int mFrameLayoutId;
+ private PlatformGamesFragment mPlatformGamesFragment;
+
+ private MainPresenter mPresenter = new MainPresenter(this);
+
+ // Singleton to manage user billing state
+ private static BillingManager mBillingManager;
+
+ private static MenuItem mPremiumButton;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ ThemeUtil.applyTheme();
+
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_main);
+
+ findViews();
+
+ setSupportActionBar(mToolbar);
+
+ mFrameLayoutId = R.id.games_platform_frame;
+ mPresenter.onCreate();
+
+ if (savedInstanceState == null) {
+ StartupHandler.HandleInit(this);
+ if (PermissionsHandler.hasWriteAccess(this)) {
+ mPlatformGamesFragment = new PlatformGamesFragment();
+ getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment)
+ .commit();
+ }
+ } else {
+ mPlatformGamesFragment = (PlatformGamesFragment) getSupportFragmentManager().getFragment(savedInstanceState, "mPlatformGamesFragment");
+ }
+ PicassoUtils.init();
+
+ // Setup billing manager, so we can globally query for Premium status
+ mBillingManager = new BillingManager(this);
+
+ // Dismiss previous notifications (should not happen unless a crash occurred)
+ EmulationActivity.tryDismissRunningNotification(this);
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (PermissionsHandler.hasWriteAccess(this)) {
+ if (getSupportFragmentManager() == null) {
+ return;
+ }
+ if (outState == null) {
+ return;
+ }
+ getSupportFragmentManager().putFragment(outState, "mPlatformGamesFragment", mPlatformGamesFragment);
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ mPresenter.addDirIfNeeded(new AddDirectoryHelper(this));
+ }
+
+ // TODO: Replace with a ButterKnife injection.
+ private void findViews() {
+ mToolbar = findViewById(R.id.toolbar_main);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.menu_game_grid, menu);
+ mPremiumButton = menu.findItem(R.id.button_premium);
+
+ if (mBillingManager.isPremiumCached()) {
+ // User had premium in a previous session, hide upsell option
+ setPremiumButtonVisible(false);
+ }
+
+ return true;
+ }
+
+ static public void setPremiumButtonVisible(boolean isVisible) {
+ if (mPremiumButton != null) {
+ mPremiumButton.setVisible(isVisible);
+ }
+ }
+
+ /**
+ * MainView
+ */
+
+ @Override
+ public void setVersionString(String version) {
+ mToolbar.setSubtitle(version);
+ }
+
+ @Override
+ public void refresh() {
+ getContentResolver().insert(GameProvider.URI_REFRESH, null);
+ refreshFragment();
+ }
+
+ @Override
+ public void launchSettingsActivity(String menuTag) {
+ if (PermissionsHandler.hasWriteAccess(this)) {
+ SettingsActivity.launch(this, menuTag, "");
+ } else {
+ PermissionsHandler.checkWritePermission(this);
+ }
+ }
+
+ @Override
+ public void launchFileListActivity(int request) {
+ if (PermissionsHandler.hasWriteAccess(this)) {
+ switch (request) {
+ case MainPresenter.REQUEST_ADD_DIRECTORY:
+ FileBrowserHelper.openDirectoryPicker(this,
+ MainPresenter.REQUEST_ADD_DIRECTORY,
+ R.string.select_game_folder,
+ Arrays.asList("elf", "axf", "cci", "3ds",
+ "cxi", "app", "3dsx", "cia",
+ "rar", "zip", "7z", "torrent",
+ "tar", "gz"));
+ break;
+ case MainPresenter.REQUEST_INSTALL_CIA:
+ FileBrowserHelper.openFilePicker(this, MainPresenter.REQUEST_INSTALL_CIA,
+ R.string.install_cia_title,
+ Collections.singletonList("cia"), true);
+ break;
+ }
+ } else {
+ PermissionsHandler.checkWritePermission(this);
+ }
+ }
+
+ /**
+ * @param requestCode An int describing whether the Activity that is returning did so successfully.
+ * @param resultCode An int describing what Activity is giving us this callback.
+ * @param result The information the returning Activity is providing us.
+ */
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent result) {
+ super.onActivityResult(requestCode, resultCode, result);
+ switch (requestCode) {
+ case MainPresenter.REQUEST_ADD_DIRECTORY:
+ // If the user picked a file, as opposed to just backing out.
+ if (resultCode == MainActivity.RESULT_OK) {
+ // When a new directory is picked, we currently will reset the existing games
+ // database. This effectively means that only one game directory is supported.
+ // TODO(bunnei): Consider fixing this in the future, or removing code for this.
+ getContentResolver().insert(GameProvider.URI_RESET, null);
+ // Add the new directory
+ mPresenter.onDirectorySelected(FileBrowserHelper.getSelectedDirectory(result));
+ }
+ break;
+ case MainPresenter.REQUEST_INSTALL_CIA:
+ // If the user picked a file, as opposed to just backing out.
+ if (resultCode == MainActivity.RESULT_OK) {
+ NativeLibrary.InstallCIAS(FileBrowserHelper.getSelectedFiles(result));
+ mPresenter.refeshGameList();
+ }
+ break;
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ switch (requestCode) {
+ case PermissionsHandler.REQUEST_CODE_WRITE_PERMISSION:
+ if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ DirectoryInitialization.start(this);
+
+ mPlatformGamesFragment = new PlatformGamesFragment();
+ getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment)
+ .commit();
+
+ // Immediately prompt user to select a game directory on first boot
+ if (mPresenter != null) {
+ mPresenter.launchFileListActivity(MainPresenter.REQUEST_ADD_DIRECTORY);
+ }
+ } else {
+ Toast.makeText(this, R.string.write_permission_needed, Toast.LENGTH_SHORT)
+ .show();
+ }
+ break;
+ default:
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ break;
+ }
+ }
+
+ /**
+ * Called by the framework whenever any actionbar/toolbar icon is clicked.
+ *
+ * @param item The icon that was clicked on.
+ * @return True if the event was handled, false to bubble it up to the OS.
+ */
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ return mPresenter.handleOptionSelection(item.getItemId());
+ }
+
+ private void refreshFragment() {
+ if (mPlatformGamesFragment != null) {
+ mPlatformGamesFragment.refresh();
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ EmulationActivity.tryDismissRunningNotification(this);
+ super.onDestroy();
+ }
+
+ /**
+ * @return true if Premium subscription is currently active
+ */
+ public static boolean isPremiumActive() {
+ return mBillingManager.isPremiumActive();
+ }
+
+ /**
+ * Invokes the billing flow for Premium
+ *
+ * @param callback Optional callback, called once, on completion of billing
+ */
+ public static void invokePremiumBilling(Runnable callback) {
+ mBillingManager.invokePremiumBilling(callback);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java
new file mode 100644
index 000000000..4e9994c2a
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java
@@ -0,0 +1,82 @@
+package org.citra.citra_emu.ui.main;
+
+import android.os.SystemClock;
+
+import org.citra.citra_emu.BuildConfig;
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.Settings;
+import org.citra.citra_emu.features.settings.utils.SettingsFile;
+import org.citra.citra_emu.model.GameDatabase;
+import org.citra.citra_emu.utils.AddDirectoryHelper;
+
+public final class MainPresenter {
+ public static final int REQUEST_ADD_DIRECTORY = 1;
+ public static final int REQUEST_INSTALL_CIA = 2;
+
+ private final MainView mView;
+ private String mDirToAdd;
+ private long mLastClickTime = 0;
+
+ public MainPresenter(MainView view) {
+ mView = view;
+ }
+
+ public void onCreate() {
+ String versionName = BuildConfig.VERSION_NAME;
+ mView.setVersionString(versionName);
+ refeshGameList();
+ }
+
+ public void launchFileListActivity(int request) {
+ if (mView != null) {
+ mView.launchFileListActivity(request);
+ }
+ }
+
+ public boolean handleOptionSelection(int itemId) {
+ // Double-click prevention, using threshold of 500 ms
+ if (SystemClock.elapsedRealtime() - mLastClickTime < 500) {
+ return false;
+ }
+ mLastClickTime = SystemClock.elapsedRealtime();
+
+ switch (itemId) {
+ case R.id.menu_settings_core:
+ mView.launchSettingsActivity(SettingsFile.FILE_NAME_CONFIG);
+ return true;
+
+ case R.id.button_add_directory:
+ launchFileListActivity(REQUEST_ADD_DIRECTORY);
+ return true;
+
+ case R.id.button_install_cia:
+ launchFileListActivity(REQUEST_INSTALL_CIA);
+ return true;
+
+ case R.id.button_premium:
+ mView.launchSettingsActivity(Settings.SECTION_PREMIUM);
+ return true;
+ }
+
+ return false;
+ }
+
+ public void addDirIfNeeded(AddDirectoryHelper helper) {
+ if (mDirToAdd != null) {
+ helper.addDirectory(mDirToAdd, mView::refresh);
+
+ mDirToAdd = null;
+ }
+ }
+
+ public void onDirectorySelected(String dir) {
+ mDirToAdd = dir;
+ }
+
+ public void refeshGameList() {
+ GameDatabase databaseHelper = CitraApplication.databaseHelper;
+ databaseHelper.scanLibrary(databaseHelper.getWritableDatabase());
+ mView.refresh();
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java
new file mode 100644
index 000000000..de7c04875
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java
@@ -0,0 +1,25 @@
+package org.citra.citra_emu.ui.main;
+
+/**
+ * Abstraction for the screen that shows on application launch.
+ * Implementations will differ primarily to target touch-screen
+ * or non-touch screen devices.
+ */
+public interface MainView {
+ /**
+ * Pass the view the native library's version string. Displaying
+ * it is optional.
+ *
+ * @param version A string pulled from native code.
+ */
+ void setVersionString(String version);
+
+ /**
+ * Tell the view to refresh its contents.
+ */
+ void refresh();
+
+ void launchSettingsActivity(String menuTag);
+
+ void launchFileListActivity(int request);
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java
new file mode 100644
index 000000000..9fc30796f
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java
@@ -0,0 +1,86 @@
+package org.citra.citra_emu.ui.platform;
+
+import android.database.Cursor;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.adapters.GameAdapter;
+import org.citra.citra_emu.model.GameDatabase;
+
+public final class PlatformGamesFragment extends Fragment implements PlatformGamesView {
+ private PlatformGamesPresenter mPresenter = new PlatformGamesPresenter(this);
+
+ private GameAdapter mAdapter;
+ private RecyclerView mRecyclerView;
+ private TextView mTextView;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_grid, container, false);
+
+ findViews(rootView);
+
+ mPresenter.onCreateView();
+
+ return rootView;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ int columns = getResources().getInteger(R.integer.game_grid_columns);
+ RecyclerView.LayoutManager layoutManager = new GridLayoutManager(getActivity(), columns);
+ mAdapter = new GameAdapter();
+
+ mRecyclerView.setLayoutManager(layoutManager);
+ mRecyclerView.setAdapter(mAdapter);
+ mRecyclerView.addItemDecoration(new GameAdapter.SpacesItemDecoration(ContextCompat.getDrawable(getActivity(), R.drawable.gamelist_divider), 1));
+
+ // Add swipe down to refresh gesture
+ final SwipeRefreshLayout pullToRefresh = view.findViewById(R.id.refresh_grid_games);
+ pullToRefresh.setOnRefreshListener(() -> {
+ GameDatabase databaseHelper = CitraApplication.databaseHelper;
+ databaseHelper.scanLibrary(databaseHelper.getWritableDatabase());
+ refresh();
+ pullToRefresh.setRefreshing(false);
+ });
+ }
+
+ @Override
+ public void refresh() {
+ mPresenter.refresh();
+ updateTextView();
+ }
+
+ @Override
+ public void showGames(Cursor games) {
+ if (mAdapter != null) {
+ mAdapter.swapCursor(games);
+ }
+ updateTextView();
+ }
+
+ private void updateTextView() {
+ mTextView.setVisibility(mAdapter.getItemCount() == 0 ? View.VISIBLE : View.GONE);
+ }
+
+ private void findViews(View root) {
+ mRecyclerView = root.findViewById(R.id.grid_games);
+ mTextView = root.findViewById(R.id.gamelist_empty_text);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java
new file mode 100644
index 000000000..9d8040e1b
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java
@@ -0,0 +1,42 @@
+package org.citra.citra_emu.ui.platform;
+
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.model.GameDatabase;
+import org.citra.citra_emu.utils.Log;
+
+import rx.android.schedulers.AndroidSchedulers;
+import rx.schedulers.Schedulers;
+
+public final class PlatformGamesPresenter {
+ private final PlatformGamesView mView;
+
+ public PlatformGamesPresenter(PlatformGamesView view) {
+ mView = view;
+ }
+
+ public void onCreateView() {
+ loadGames();
+ }
+
+ public void refresh() {
+ Log.debug("[PlatformGamesPresenter] : Refreshing...");
+ loadGames();
+ }
+
+ private void loadGames() {
+ Log.debug("[PlatformGamesPresenter] : Loading games...");
+
+ GameDatabase databaseHelper = CitraApplication.databaseHelper;
+
+ databaseHelper.getGames()
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(games ->
+ {
+ Log.debug("[PlatformGamesPresenter] : Load finished, swapping cursor...");
+
+ mView.showGames(games);
+ });
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java
new file mode 100644
index 000000000..4332121eb
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java
@@ -0,0 +1,21 @@
+package org.citra.citra_emu.ui.platform;
+
+import android.database.Cursor;
+
+/**
+ * Abstraction for a screen representing a single platform's games.
+ */
+public interface PlatformGamesView {
+ /**
+ * Tell the view to refresh its contents.
+ */
+ void refresh();
+
+ /**
+ * To be called when an asynchronous database read completes. Passes the
+ * result, in this case a {@link Cursor}, to the view.
+ *
+ * @param games A Cursor containing the games read from the database.
+ */
+ void showGames(Cursor games);
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java
new file mode 100644
index 000000000..886846ec5
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java
@@ -0,0 +1,5 @@
+package org.citra.citra_emu.utils;
+
+public interface Action1 {
+ void call(T t);
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java
new file mode 100644
index 000000000..7578c353f
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java
@@ -0,0 +1,38 @@
+package org.citra.citra_emu.utils;
+
+import android.content.AsyncQueryHandler;
+import android.content.ContentValues;
+import android.content.Context;
+import android.net.Uri;
+
+import org.citra.citra_emu.model.GameDatabase;
+import org.citra.citra_emu.model.GameProvider;
+
+public class AddDirectoryHelper {
+ private Context mContext;
+
+ public AddDirectoryHelper(Context context) {
+ this.mContext = context;
+ }
+
+ public void addDirectory(String dir, AddDirectoryListener addDirectoryListener) {
+ AsyncQueryHandler handler = new AsyncQueryHandler(mContext.getContentResolver()) {
+ @Override
+ protected void onInsertComplete(int token, Object cookie, Uri uri) {
+ addDirectoryListener.onDirectoryAdded();
+ }
+ };
+
+ ContentValues file = new ContentValues();
+ file.put(GameDatabase.KEY_FOLDER_PATH, dir);
+
+ handler.startInsert(0, // We don't need to identify this call to the handler
+ null, // We don't need to pass additional data to the handler
+ GameProvider.URI_FOLDER, // Tell the GameProvider we are adding a folder
+ file);
+ }
+
+ public interface AddDirectoryListener {
+ void onDirectoryAdded();
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java
new file mode 100644
index 000000000..dfbab1780
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java
@@ -0,0 +1,22 @@
+package org.citra.citra_emu.utils;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class BiMap {
+ private Map forward = new HashMap();
+ private Map backward = new HashMap();
+
+ public synchronized void add(K key, V value) {
+ forward.put(key, value);
+ backward.put(value, key);
+ }
+
+ public synchronized V getForward(K key) {
+ return forward.get(key);
+ }
+
+ public synchronized K getBackward(V key) {
+ return backward.get(key);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java
new file mode 100644
index 000000000..5dc54c235
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java
@@ -0,0 +1,215 @@
+package org.citra.citra_emu.utils;
+
+import android.app.Activity;
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+import android.widget.Toast;
+
+import com.android.billingclient.api.AcknowledgePurchaseParams;
+import com.android.billingclient.api.AcknowledgePurchaseResponseListener;
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.BillingClientStateListener;
+import com.android.billingclient.api.BillingFlowParams;
+import com.android.billingclient.api.BillingResult;
+import com.android.billingclient.api.Purchase;
+import com.android.billingclient.api.Purchase.PurchasesResult;
+import com.android.billingclient.api.PurchasesUpdatedListener;
+import com.android.billingclient.api.SkuDetails;
+import com.android.billingclient.api.SkuDetailsParams;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.utils.SettingsFile;
+import org.citra.citra_emu.ui.main.MainActivity;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class BillingManager implements PurchasesUpdatedListener {
+ private final String BILLING_SKU_PREMIUM = "citra.citra_emu.product_id.premium";
+
+ private final Activity mActivity;
+ private BillingClient mBillingClient;
+ private SkuDetails mSkuPremium;
+ private boolean mIsPremiumActive = false;
+ private boolean mIsServiceConnected = false;
+ private Runnable mUpdateBillingCallback;
+
+ private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+
+ public BillingManager(Activity activity) {
+ mActivity = activity;
+ mBillingClient = BillingClient.newBuilder(mActivity).enablePendingPurchases().setListener(this).build();
+ querySkuDetails();
+ }
+
+ static public boolean isPremiumCached() {
+ return mPreferences.getBoolean(SettingsFile.KEY_PREMIUM, false);
+ }
+
+ /**
+ * @return true if Premium subscription is currently active
+ */
+ public boolean isPremiumActive() {
+ return mIsPremiumActive;
+ }
+
+ /**
+ * Invokes the billing flow for Premium
+ *
+ * @param callback Optional callback, called once, on completion of billing
+ */
+ public void invokePremiumBilling(Runnable callback) {
+ if (mSkuPremium == null) {
+ return;
+ }
+
+ // Optional callback to refresh the UI for the caller when billing completes
+ mUpdateBillingCallback = callback;
+
+ // Invoke the billing flow
+ BillingFlowParams flowParams = BillingFlowParams.newBuilder()
+ .setSkuDetails(mSkuPremium)
+ .build();
+ mBillingClient.launchBillingFlow(mActivity, flowParams);
+ }
+
+ private void updatePremiumState(boolean isPremiumActive) {
+ mIsPremiumActive = isPremiumActive;
+
+ // Cache state for synchronous UI
+ SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putBoolean(SettingsFile.KEY_PREMIUM, isPremiumActive);
+ editor.apply();
+
+ // No need to show button in action bar if Premium is active
+ MainActivity.setPremiumButtonVisible(!isPremiumActive);
+ }
+
+ @Override
+ public void onPurchasesUpdated(BillingResult billingResult, List purchaseList) {
+ if (purchaseList == null || purchaseList.isEmpty()) {
+ // Premium is not active, or billing is unavailable
+ updatePremiumState(false);
+ return;
+ }
+
+ Purchase premiumPurchase = null;
+ for (Purchase purchase : purchaseList) {
+ if (purchase.getSku().equals(BILLING_SKU_PREMIUM)) {
+ premiumPurchase = purchase;
+ }
+ }
+
+ if (premiumPurchase != null && premiumPurchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
+ // Premium has been purchased
+ updatePremiumState(true);
+
+ // Acknowledge the purchase if it hasn't already been acknowledged.
+ if (!premiumPurchase.isAcknowledged()) {
+ AcknowledgePurchaseParams acknowledgePurchaseParams =
+ AcknowledgePurchaseParams.newBuilder()
+ .setPurchaseToken(premiumPurchase.getPurchaseToken())
+ .build();
+
+ AcknowledgePurchaseResponseListener acknowledgePurchaseResponseListener = billingResult1 -> {
+ Toast.makeText(mActivity, R.string.premium_settings_welcome, Toast.LENGTH_SHORT).show();
+ };
+ mBillingClient.acknowledgePurchase(acknowledgePurchaseParams, acknowledgePurchaseResponseListener);
+ }
+
+ if (mUpdateBillingCallback != null) {
+ try {
+ mUpdateBillingCallback.run();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ mUpdateBillingCallback = null;
+ }
+ }
+ }
+
+ private void onQuerySkuDetailsFinished(List skuDetailsList) {
+ if (skuDetailsList == null) {
+ // This can happen when no user is signed in
+ return;
+ }
+
+ if (skuDetailsList.isEmpty()) {
+ return;
+ }
+
+ mSkuPremium = skuDetailsList.get(0);
+
+ queryPurchases();
+ }
+
+ private void querySkuDetails() {
+ Runnable queryToExecute = new Runnable() {
+ @Override
+ public void run() {
+ SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder();
+ List skuList = new ArrayList<>();
+
+ skuList.add(BILLING_SKU_PREMIUM);
+ params.setSkusList(skuList).setType(BillingClient.SkuType.INAPP);
+
+ mBillingClient.querySkuDetailsAsync(params.build(),
+ (billingResult, skuDetailsList) -> onQuerySkuDetailsFinished(skuDetailsList));
+ }
+ };
+
+ executeServiceRequest(queryToExecute);
+ }
+
+ private void onQueryPurchasesFinished(PurchasesResult result) {
+ // Have we been disposed of in the meantime? If so, or bad result code, then quit
+ if (mBillingClient == null || result.getResponseCode() != BillingClient.BillingResponseCode.OK) {
+ updatePremiumState(false);
+ return;
+ }
+ // Update the UI and purchases inventory with new list of purchases
+ onPurchasesUpdated(result.getBillingResult(), result.getPurchasesList());
+ }
+
+ private void queryPurchases() {
+ Runnable queryToExecute = new Runnable() {
+ @Override
+ public void run() {
+ final PurchasesResult purchasesResult = mBillingClient.queryPurchases(BillingClient.SkuType.INAPP);
+ onQueryPurchasesFinished(purchasesResult);
+ }
+ };
+
+ executeServiceRequest(queryToExecute);
+ }
+
+ private void startServiceConnection(final Runnable executeOnFinish) {
+ mBillingClient.startConnection(new BillingClientStateListener() {
+ @Override
+ public void onBillingSetupFinished(BillingResult billingResult) {
+ if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
+ mIsServiceConnected = true;
+ }
+
+ if (executeOnFinish != null) {
+ executeOnFinish.run();
+ }
+ }
+
+ @Override
+ public void onBillingServiceDisconnected() {
+ mIsServiceConnected = false;
+ }
+ });
+ }
+
+ private void executeServiceRequest(Runnable runnable) {
+ if (mIsServiceConnected) {
+ runnable.run();
+ } else {
+ // If billing service was disconnected, we try to reconnect 1 time.
+ startServiceConnection(runnable);
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java
new file mode 100644
index 000000000..f801a05f0
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java
@@ -0,0 +1,66 @@
+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 {
+ /**
+ * Some controllers report extra button presses that can be ignored.
+ */
+ public boolean shouldKeyBeIgnored(InputDevice inputDevice, int keyCode) {
+ 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;
+ }
+
+ /**
+ * Scale an axis to be zero-centered with a proper range.
+ */
+ public float scaleAxis(InputDevice inputDevice, int axis, float value) {
+ 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;
+ }
+ } 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;
+ }
+ if (axis == MotionEvent.AXIS_GENERIC_1) {
+ // This axis is stuck at ~.5. Ignore it.
+ 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 value;
+ }
+
+ private boolean isDualShock4(InputDevice inputDevice) {
+ // Sony DualShock 4 controller
+ return inputDevice.getVendorId() == 0x54c && inputDevice.getProductId() == 0x9cc;
+ }
+
+ private boolean isXboxOneWireless(InputDevice inputDevice) {
+ // Microsoft Xbox One controller
+ return inputDevice.getVendorId() == 0x45e && inputDevice.getProductId() == 0x2e0;
+ }
+
+ private boolean isMogaPro2Hid(InputDevice inputDevice) {
+ // Moga Pro 2 HID
+ return inputDevice.getVendorId() == 0x20d6 && inputDevice.getProductId() == 0x6271;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java
new file mode 100644
index 000000000..58e552f5e
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java
@@ -0,0 +1,186 @@
+/**
+ * Copyright 2014 Dolphin Emulator Project
+ * Licensed under GPLv2+
+ * Refer to the license.txt file included.
+ */
+
+package org.citra.citra_emu.utils;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Environment;
+import android.preference.PreferenceManager;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.citra.citra_emu.NativeLibrary;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * A service that spawns its own thread in order to copy several binary and shader files
+ * from the Citra APK to the external file system.
+ */
+public final class DirectoryInitialization {
+ public static final String BROADCAST_ACTION = "org.citra.citra_emu.BROADCAST";
+
+ public static final String EXTRA_STATE = "directoryState";
+ private static volatile DirectoryInitializationState directoryState = null;
+ private static String userPath;
+ private static AtomicBoolean isCitraDirectoryInitializationRunning = new AtomicBoolean(false);
+
+ public static void start(Context context) {
+ // Can take a few seconds to run, so don't block UI thread.
+ //noinspection TrivialFunctionalExpressionUsage
+ ((Runnable) () -> init(context)).run();
+ }
+
+ private static void init(Context context) {
+ if (!isCitraDirectoryInitializationRunning.compareAndSet(false, true))
+ return;
+
+ if (directoryState != DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) {
+ if (PermissionsHandler.hasWriteAccess(context)) {
+ if (setCitraUserDirectory()) {
+ initializeInternalStorage(context);
+ NativeLibrary.CreateConfigFile();
+ directoryState = DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED;
+ } else {
+ directoryState = DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE;
+ }
+ } else {
+ directoryState = DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED;
+ }
+ }
+
+ isCitraDirectoryInitializationRunning.set(false);
+ sendBroadcastState(directoryState, context);
+ }
+
+ private static void deleteDirectoryRecursively(File file) {
+ if (file.isDirectory()) {
+ for (File child : file.listFiles())
+ deleteDirectoryRecursively(child);
+ }
+ file.delete();
+ }
+
+ public static boolean areCitraDirectoriesReady() {
+ return directoryState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED;
+ }
+
+ public static String getUserDirectory() {
+ if (directoryState == null) {
+ throw new IllegalStateException("DirectoryInitialization has to run at least once!");
+ } else if (isCitraDirectoryInitializationRunning.get()) {
+ throw new IllegalStateException(
+ "DirectoryInitialization has to finish running first!");
+ }
+ return userPath;
+ }
+
+ private static native void SetSysDirectory(String path);
+
+ private static boolean setCitraUserDirectory() {
+ if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
+ File externalPath = Environment.getExternalStorageDirectory();
+ if (externalPath != null) {
+ userPath = externalPath.getAbsolutePath() + "/citra-emu";
+ Log.debug("[DirectoryInitialization] User Dir: " + userPath);
+ // NativeLibrary.SetUserDirectory(userPath);
+ return true;
+ }
+
+ }
+
+ return false;
+ }
+
+ private static void initializeInternalStorage(Context context) {
+ File sysDirectory = new File(context.getFilesDir(), "Sys");
+
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
+ String revision = NativeLibrary.GetGitRevision();
+ if (!preferences.getString("sysDirectoryVersion", "").equals(revision)) {
+ // There is no extracted Sys directory, or there is a Sys directory from another
+ // version of Citra that might contain outdated files. Let's (re-)extract Sys.
+ deleteDirectoryRecursively(sysDirectory);
+ copyAssetFolder("Sys", sysDirectory, true, context);
+
+ SharedPreferences.Editor editor = preferences.edit();
+ editor.putString("sysDirectoryVersion", revision);
+ editor.apply();
+ }
+
+ // Let the native code know where the Sys directory is.
+ SetSysDirectory(sysDirectory.getPath());
+ }
+
+ private static void sendBroadcastState(DirectoryInitializationState state, Context context) {
+ Intent localIntent =
+ new Intent(BROADCAST_ACTION)
+ .putExtra(EXTRA_STATE, state);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(localIntent);
+ }
+
+ private static void copyAsset(String asset, File output, Boolean overwrite, Context context) {
+ Log.verbose("[DirectoryInitialization] Copying File " + asset + " to " + output);
+
+ try {
+ if (!output.exists() || overwrite) {
+ InputStream in = context.getAssets().open(asset);
+ OutputStream out = new FileOutputStream(output);
+ copyFile(in, out);
+ in.close();
+ out.close();
+ }
+ } catch (IOException e) {
+ Log.error("[DirectoryInitialization] Failed to copy asset file: " + asset +
+ e.getMessage());
+ }
+ }
+
+ private static void copyAssetFolder(String assetFolder, File outputFolder, Boolean overwrite,
+ Context context) {
+ Log.verbose("[DirectoryInitialization] Copying Folder " + assetFolder + " to " +
+ outputFolder);
+
+ try {
+ boolean createdFolder = false;
+ for (String file : context.getAssets().list(assetFolder)) {
+ if (!createdFolder) {
+ outputFolder.mkdir();
+ createdFolder = true;
+ }
+ copyAssetFolder(assetFolder + File.separator + file, new File(outputFolder, file),
+ overwrite, context);
+ copyAsset(assetFolder + File.separator + file, new File(outputFolder, file), overwrite,
+ context);
+ }
+ } catch (IOException e) {
+ Log.error("[DirectoryInitialization] Failed to copy asset folder: " + assetFolder +
+ e.getMessage());
+ }
+ }
+
+ private static void copyFile(InputStream in, OutputStream out) throws IOException {
+ byte[] buffer = new byte[1024];
+ int read;
+
+ while ((read = in.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ }
+
+ public enum DirectoryInitializationState {
+ CITRA_DIRECTORIES_INITIALIZED,
+ EXTERNAL_STORAGE_PERMISSION_NEEDED,
+ CANT_FIND_EXTERNAL_STORAGE
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java
new file mode 100644
index 000000000..5d1e951ca
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java
@@ -0,0 +1,22 @@
+package org.citra.citra_emu.utils;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState;
+
+public class DirectoryStateReceiver extends BroadcastReceiver {
+ Action1 callback;
+
+ public DirectoryStateReceiver(Action1 callback) {
+ this.callback = callback;
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ DirectoryInitializationState state = (DirectoryInitializationState) intent
+ .getSerializableExtra(DirectoryInitialization.EXTRA_STATE);
+ callback.call(state);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java
new file mode 100644
index 000000000..9664f8464
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java
@@ -0,0 +1,78 @@
+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.getAppContext());
+
+ // These must match what is defined in src/core/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 = 4;
+ public static final int LayoutOption_MobileLandscape = 5;
+
+ public static boolean getJoystickRelCenter() {
+ return mPreferences.getBoolean("EmulationMenuSettings_JoystickRelCenter", true);
+ }
+
+ public static void setJoystickRelCenter(boolean value) {
+ final SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putBoolean("EmulationMenuSettings_JoystickRelCenter", value);
+ editor.apply();
+ }
+
+ public static boolean getDpadSlideEnable() {
+ return mPreferences.getBoolean("EmulationMenuSettings_DpadSlideEnable", true);
+ }
+
+ public static void setDpadSlideEnable(boolean value) {
+ final SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putBoolean("EmulationMenuSettings_DpadSlideEnable", value);
+ editor.apply();
+ }
+
+ public static int getLandscapeScreenLayout() {
+ return mPreferences.getInt("EmulationMenuSettings_LandscapeScreenLayout", LayoutOption_MobileLandscape);
+ }
+
+ public static void setLandscapeScreenLayout(int value) {
+ final SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putInt("EmulationMenuSettings_LandscapeScreenLayout", value);
+ editor.apply();
+ }
+
+ public static boolean getShowFps() {
+ return mPreferences.getBoolean("EmulationMenuSettings_ShowFps", false);
+ }
+
+ public static void setShowFps(boolean value) {
+ final SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putBoolean("EmulationMenuSettings_ShowFps", value);
+ editor.apply();
+ }
+
+ public static boolean getSwapScreens() {
+ return mPreferences.getBoolean("EmulationMenuSettings_SwapScreens", false);
+ }
+
+ public static void setSwapScreens(boolean value) {
+ final SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putBoolean("EmulationMenuSettings_SwapScreens", value);
+ editor.apply();
+ }
+
+ public static boolean getShowOverlay() {
+ return mPreferences.getBoolean("EmulationMenuSettings_ShowOverylay", true);
+ }
+
+ public static void setShowOverlay(boolean value) {
+ final SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putBoolean("EmulationMenuSettings_ShowOverylay", value);
+ editor.apply();
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java
new file mode 100644
index 000000000..baf691f5c
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java
@@ -0,0 +1,73 @@
+package org.citra.citra_emu.utils;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Environment;
+
+import androidx.annotation.Nullable;
+import androidx.fragment.app.FragmentActivity;
+
+import com.nononsenseapps.filepicker.FilePickerActivity;
+import com.nononsenseapps.filepicker.Utils;
+
+import org.citra.citra_emu.activities.CustomFilePickerActivity;
+
+import java.io.File;
+import java.util.List;
+
+public final class FileBrowserHelper {
+ public static void openDirectoryPicker(FragmentActivity activity, int requestCode, int title, List extensions) {
+ Intent i = new Intent(activity, CustomFilePickerActivity.class);
+
+ i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false);
+ i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, false);
+ i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR);
+ i.putExtra(FilePickerActivity.EXTRA_START_PATH,
+ Environment.getExternalStorageDirectory().getPath());
+ i.putExtra(CustomFilePickerActivity.EXTRA_TITLE, title);
+ i.putExtra(CustomFilePickerActivity.EXTRA_EXTENSIONS, String.join(",", extensions));
+
+ activity.startActivityForResult(i, requestCode);
+ }
+
+ public static void openFilePicker(FragmentActivity activity, int requestCode, int title,
+ List extensions, boolean allowMultiple) {
+ Intent i = new Intent(activity, CustomFilePickerActivity.class);
+
+ i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, allowMultiple);
+ i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, false);
+ i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_FILE);
+ i.putExtra(FilePickerActivity.EXTRA_START_PATH,
+ Environment.getExternalStorageDirectory().getPath());
+ i.putExtra(CustomFilePickerActivity.EXTRA_TITLE, title);
+ i.putExtra(CustomFilePickerActivity.EXTRA_EXTENSIONS, String.join(",", extensions));
+
+ activity.startActivityForResult(i, requestCode);
+ }
+
+ @Nullable
+ public static String getSelectedDirectory(Intent result) {
+ // Use the provided utility method to parse the result
+ List files = Utils.getSelectedFilesFromResult(result);
+ if (!files.isEmpty()) {
+ File file = Utils.getFileForUri(files.get(0));
+ return file.getAbsolutePath();
+ }
+
+ return null;
+ }
+
+ @Nullable
+ public static String[] getSelectedFiles(Intent result) {
+ // Use the provided utility method to parse the result
+ List files = Utils.getSelectedFilesFromResult(result);
+ if (!files.isEmpty()) {
+ String[] paths = new String[files.size()];
+ for (int i = 0; i < files.size(); i++)
+ paths[i] = Utils.getFileForUri(files.get(i)).getAbsolutePath();
+ return paths;
+ }
+
+ return null;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java
new file mode 100644
index 000000000..f9025171b
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java
@@ -0,0 +1,37 @@
+package org.citra.citra_emu.utils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+public class FileUtil {
+ public static byte[] getBytesFromFile(File file) throws IOException {
+ final long length = file.length();
+
+ // You cannot create an array using a long type.
+ if (length > Integer.MAX_VALUE) {
+ // File is too large
+ throw new IOException("File is too large!");
+ }
+
+ byte[] bytes = new byte[(int) length];
+
+ int offset = 0;
+ int numRead;
+
+ try (InputStream is = new FileInputStream(file)) {
+ while (offset < bytes.length
+ && (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) {
+ offset += numRead;
+ }
+ }
+
+ // Ensure all the bytes have been read in
+ if (offset < bytes.length) {
+ throw new IOException("Could not completely read file " + file.getName());
+ }
+
+ return bytes;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java
new file mode 100644
index 000000000..bc256877b
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java
@@ -0,0 +1,63 @@
+/**
+ * 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_UPDATE_CURRENT);
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(this, getString(R.string.app_notification_channel_id))
+ .setSmallIcon(R.drawable.ic_stat_notification_logo)
+ .setContentTitle(getString(R.string.app_name))
+ .setContentText(getString(R.string.app_notification_running))
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setOngoing(true)
+ .setVibrate(null)
+ .setSound(null)
+ .setContentIntent(contentIntent);
+ startForeground(EMULATION_RUNNING_NOTIFICATION, builder.build());
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public void onCreate() {
+ showRunningNotification();
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ return START_STICKY;
+ }
+
+ @Override
+ public void onDestroy() {
+ NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java
new file mode 100644
index 000000000..b790c2480
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java
@@ -0,0 +1,27 @@
+package org.citra.citra_emu.utils;
+
+import android.graphics.Bitmap;
+
+import com.squareup.picasso.Picasso;
+import com.squareup.picasso.Request;
+import com.squareup.picasso.RequestHandler;
+
+import org.citra.citra_emu.NativeLibrary;
+
+import java.nio.IntBuffer;
+
+public class GameIconRequestHandler extends RequestHandler {
+ @Override
+ public boolean canHandleRequest(Request data) {
+ return "iso".equals(data.uri.getScheme());
+ }
+
+ @Override
+ public Result load(Request request, int networkPolicy) {
+ String url = request.uri.getHost() + request.uri.getPath();
+ int[] vector = NativeLibrary.GetIcon(url);
+ Bitmap bitmap = Bitmap.createBitmap(48, 48, Bitmap.Config.RGB_565);
+ bitmap.copyPixelsFromBuffer(IntBuffer.wrap(vector));
+ return new Result(bitmap, Picasso.LoadedFrom.DISK);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java
new file mode 100644
index 000000000..070d01eb1
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java
@@ -0,0 +1,39 @@
+package org.citra.citra_emu.utils;
+
+import org.citra.citra_emu.BuildConfig;
+
+/**
+ * Contains methods that call through to {@link android.util.Log}, but
+ * with the same TAG automatically provided. Also no-ops VERBOSE and DEBUG log
+ * levels in release builds.
+ */
+public final class Log {
+ private static final String TAG = "Citra Frontend";
+
+ private Log() {
+ }
+
+ public static void verbose(String message) {
+ if (BuildConfig.DEBUG) {
+ android.util.Log.v(TAG, message);
+ }
+ }
+
+ public static void debug(String message) {
+ if (BuildConfig.DEBUG) {
+ android.util.Log.d(TAG, message);
+ }
+ }
+
+ public static void info(String message) {
+ android.util.Log.i(TAG, message);
+ }
+
+ public static void warning(String message) {
+ android.util.Log.w(TAG, message);
+ }
+
+ public static void error(String message) {
+ android.util.Log.e(TAG, message);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java
new file mode 100644
index 000000000..a29e23e8d
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java
@@ -0,0 +1,35 @@
+package org.citra.citra_emu.utils;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
+
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.FragmentActivity;
+
+import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE;
+
+public class PermissionsHandler {
+ public static final int REQUEST_CODE_WRITE_PERMISSION = 500;
+
+ // We use permissions acceptance as an indicator if this is a first boot for the user.
+ public static boolean isFirstBoot(final FragmentActivity activity) {
+ return ContextCompat.checkSelfPermission(activity, WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED;
+ }
+
+ @TargetApi(Build.VERSION_CODES.M)
+ public static boolean checkWritePermission(final FragmentActivity activity) {
+ if (isFirstBoot(activity)) {
+ activity.requestPermissions(new String[]{WRITE_EXTERNAL_STORAGE},
+ REQUEST_CODE_WRITE_PERMISSION);
+ return false;
+ }
+
+ return true;
+ }
+
+ public static boolean hasWriteAccess(Context context) {
+ return ContextCompat.checkSelfPermission(context, WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java
new file mode 100644
index 000000000..892b46387
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java
@@ -0,0 +1,45 @@
+package org.citra.citra_emu.utils;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapShader;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.RectF;
+
+import com.squareup.picasso.Transformation;
+
+public class PicassoRoundedCornersTransformation implements Transformation {
+ @Override
+ public Bitmap transform(Bitmap icon) {
+ final int width = icon.getWidth();
+ final int height = icon.getHeight();
+ final Rect rect = new Rect(0, 0, width, height);
+ final int size = Math.min(width, height);
+ final int x = (width - size) / 2;
+ final int y = (height - size) / 2;
+
+ Bitmap squaredBitmap = Bitmap.createBitmap(icon, x, y, size, size);
+ if (squaredBitmap != icon) {
+ icon.recycle();
+ }
+
+ Bitmap output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(output);
+ BitmapShader shader = new BitmapShader(squaredBitmap, BitmapShader.TileMode.CLAMP, BitmapShader.TileMode.CLAMP);
+ Paint paint = new Paint();
+ paint.setAntiAlias(true);
+ paint.setShader(shader);
+
+ canvas.drawRoundRect(new RectF(rect), 10, 10, paint);
+
+ squaredBitmap.recycle();
+
+ return output;
+ }
+
+ @Override
+ public String key() {
+ return "circle";
+ }
+}
\ No newline at end of file
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java
new file mode 100644
index 000000000..c99726685
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java
@@ -0,0 +1,57 @@
+package org.citra.citra_emu.utils;
+
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.widget.ImageView;
+
+import com.squareup.picasso.Picasso;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.R;
+
+import java.io.IOException;
+
+import androidx.annotation.Nullable;
+
+public class PicassoUtils {
+ private static boolean mPicassoInitialized = false;
+
+ public static void init() {
+ if (mPicassoInitialized) {
+ return;
+ }
+ Picasso picassoInstance = new Picasso.Builder(CitraApplication.getAppContext())
+ .addRequestHandler(new GameIconRequestHandler())
+ .build();
+
+ Picasso.setSingletonInstance(picassoInstance);
+ mPicassoInitialized = true;
+ }
+
+ public static void loadGameIcon(ImageView imageView, String gamePath) {
+ Picasso
+ .get()
+ .load(Uri.parse("iso:/" + gamePath))
+ .fit()
+ .centerInside()
+ .config(Bitmap.Config.RGB_565)
+ .error(R.drawable.no_icon)
+ .transform(new PicassoRoundedCornersTransformation())
+ .into(imageView);
+ }
+
+ // Blocking call. Load image from file and crop/resize it to fit in width x height.
+ @Nullable
+ public static Bitmap LoadBitmapFromFile(String uri, int width, int height) {
+ try {
+ return Picasso.get()
+ .load(Uri.parse(uri))
+ .config(Bitmap.Config.ARGB_8888)
+ .centerCrop()
+ .resize(width, height)
+ .get();
+ } catch (IOException e) {
+ return null;
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java
new file mode 100644
index 000000000..9112bf90c
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java
@@ -0,0 +1,45 @@
+package org.citra.citra_emu.utils;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.TextUtils;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.FragmentActivity;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.activities.EmulationActivity;
+
+public final class StartupHandler {
+ private static void handlePermissionsCheck(FragmentActivity parent) {
+ // Ask the user to grant write permission if it's not already granted
+ PermissionsHandler.checkWritePermission(parent);
+
+ String start_file = "";
+ Bundle extras = parent.getIntent().getExtras();
+ if (extras != null) {
+ start_file = extras.getString("AutoStartFile");
+ }
+
+ if (!TextUtils.isEmpty(start_file)) {
+ // Start the emulation activity, send the ISO passed in and finish the main activity
+ Intent emulation_intent = new Intent(parent, EmulationActivity.class);
+ emulation_intent.putExtra("SelectedGame", start_file);
+ parent.startActivity(emulation_intent);
+ parent.finish();
+ }
+ }
+
+ public static void HandleInit(FragmentActivity parent) {
+ if (PermissionsHandler.isFirstBoot(parent)) {
+ // Prompt user with standard first boot disclaimer
+ new AlertDialog.Builder(parent)
+ .setTitle(R.string.app_name)
+ .setIcon(R.mipmap.ic_launcher)
+ .setMessage(parent.getResources().getString(R.string.app_disclaimer))
+ .setPositiveButton(android.R.string.ok, null)
+ .setOnDismissListener(dialogInterface -> handlePermissionsCheck(parent))
+ .show();
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java
new file mode 100644
index 000000000..74ef3867f
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java
@@ -0,0 +1,34 @@
+package org.citra.citra_emu.utils;
+
+import android.content.SharedPreferences;
+import android.os.Build;
+import android.preference.PreferenceManager;
+
+import androidx.appcompat.app.AppCompatDelegate;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.features.settings.utils.SettingsFile;
+
+public class ThemeUtil {
+ private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+
+ private static void applyTheme(int designValue) {
+ switch (designValue) {
+ case 0:
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
+ break;
+ case 1:
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
+ break;
+ case 2:
+ AppCompatDelegate.setDefaultNightMode(android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ?
+ AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM :
+ AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY);
+ break;
+ }
+ }
+
+ public static void applyTheme() {
+ applyTheme(mPreferences.getInt(SettingsFile.KEY_DESIGN, 0));
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java
new file mode 100644
index 000000000..50dbcbe18
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java
@@ -0,0 +1,46 @@
+package org.citra.citra_emu.viewholders;
+
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.citra.citra_emu.R;
+
+/**
+ * A simple class that stores references to views so that the GameAdapter doesn't need to
+ * keep calling findViewById(), which is expensive.
+ */
+public class GameViewHolder extends RecyclerView.ViewHolder {
+ private View itemView;
+ public ImageView imageIcon;
+ public TextView textGameTitle;
+ public TextView textCompany;
+ public TextView textFileName;
+
+ public String gameId;
+
+ // TODO Not need any of this stuff. Currently only the properties dialog needs it.
+ public String path;
+ public String title;
+ public String description;
+ public String regions;
+ public String company;
+
+ public GameViewHolder(View itemView) {
+ super(itemView);
+
+ this.itemView = itemView;
+ itemView.setTag(this);
+
+ imageIcon = itemView.findViewById(R.id.image_game_screen);
+ textGameTitle = itemView.findViewById(R.id.text_game_title);
+ textCompany = itemView.findViewById(R.id.text_company);
+ textFileName = itemView.findViewById(R.id.text_filename);
+ }
+
+ public View getItemView() {
+ return itemView;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra_emu/citra/CitraApplication.java b/src/android/app/src/main/java/org/citra_emu/citra/CitraApplication.java
deleted file mode 100644
index 10cb52783..000000000
--- a/src/android/app/src/main/java/org/citra_emu/citra/CitraApplication.java
+++ /dev/null
@@ -1,13 +0,0 @@
-// Copyright 2018 Citra Emulator Project
-// Licensed under GPLv2 or any later version
-// Refer to the license.txt file included.
-
-package org.citra_emu.citra;
-
-import android.app.Application;
-
-public class CitraApplication extends Application {
- static {
- System.loadLibrary("citra-android");
- }
-}
diff --git a/src/android/app/src/main/java/org/citra_emu/citra/LOG.java b/src/android/app/src/main/java/org/citra_emu/citra/LOG.java
deleted file mode 100644
index c52f30b68..000000000
--- a/src/android/app/src/main/java/org/citra_emu/citra/LOG.java
+++ /dev/null
@@ -1,41 +0,0 @@
-package org.citra_emu.citra;
-
-public class LOG {
-
- private interface LOG_LEVEL {
- int TRACE = 0, DEBUG = 1, INFO = 2, WARNING = 3, ERROR = 4, CRITICAL = 5;
- }
-
- public static void TRACE(String msg, Object... args) {
- LOG(LOG_LEVEL.TRACE, msg, args);
- }
-
- public static void DEBUG(String msg, Object... args) {
- LOG(LOG_LEVEL.DEBUG, msg, args);
- }
-
- public static void INFO(String msg, Object... args) {
- LOG(LOG_LEVEL.INFO, msg, args);
- }
-
- public static void WARNING(String msg, Object... args) {
- LOG(LOG_LEVEL.WARNING, msg, args);
- }
-
- public static void ERROR(String msg, Object... args) {
- LOG(LOG_LEVEL.ERROR, msg, args);
- }
-
- public static void CRITICAL(String msg, Object... args) {
- LOG(LOG_LEVEL.CRITICAL, msg, args);
- }
-
- private static void LOG(int level, String msg, Object... args) {
- StackTraceElement trace = Thread.currentThread().getStackTrace()[4];
- logEntry(level, trace.getFileName(), trace.getLineNumber(), trace.getMethodName(),
- String.format(msg, args));
- }
-
- private static native void logEntry(int level, String file_name, int line_number,
- String function, String message);
-}
diff --git a/src/android/app/src/main/java/org/citra_emu/citra/ui/main/MainActivity.java b/src/android/app/src/main/java/org/citra_emu/citra/ui/main/MainActivity.java
deleted file mode 100644
index 5b4f3d3bc..000000000
--- a/src/android/app/src/main/java/org/citra_emu/citra/ui/main/MainActivity.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright 2018 Citra Emulator Project
-// Licensed under GPLv2 or any later version
-// Refer to the license.txt file included.
-
-package org.citra_emu.citra.ui.main;
-
-import android.Manifest;
-import android.content.pm.PackageManager;
-import android.os.Bundle;
-import android.support.annotation.NonNull;
-import android.support.v7.app.AlertDialog;
-import android.support.v7.app.AppCompatActivity;
-
-import org.citra_emu.citra.R;
-import org.citra_emu.citra.utils.FileUtil;
-import org.citra_emu.citra.utils.PermissionUtil;
-
-public final class MainActivity extends AppCompatActivity {
-
- // Java enums suck
- private interface PermissionCodes { int INITIALIZE = 0; }
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_main);
-
- PermissionUtil.verifyPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE,
- PermissionCodes.INITIALIZE);
- }
-
- @Override
- public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
- @NonNull int[] grantResults) {
- switch (requestCode) {
- case PermissionCodes.INITIALIZE:
- if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
- initUserPath(FileUtil.getUserPath().toString());
- initLogging();
- } else {
- AlertDialog.Builder dialog =
- new AlertDialog.Builder(this)
- .setTitle("Permission Error")
- .setMessage("Citra requires storage permissions to function.")
- .setCancelable(false)
- .setPositiveButton("OK", (dialogInterface, which) -> {
- PermissionUtil.verifyPermission(
- MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE,
- PermissionCodes.INITIALIZE);
- });
- dialog.show();
- }
- }
- }
-
- private static native void initUserPath(String path);
- private static native void initLogging();
-}
diff --git a/src/android/app/src/main/java/org/citra_emu/citra/utils/FileUtil.java b/src/android/app/src/main/java/org/citra_emu/citra/utils/FileUtil.java
deleted file mode 100644
index 5346c5352..000000000
--- a/src/android/app/src/main/java/org/citra_emu/citra/utils/FileUtil.java
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright 2019 Citra Emulator Project
-// Licensed under GPLv2 or any later version
-// Refer to the license.txt file included.
-
-package org.citra_emu.citra.utils;
-
-import android.os.Environment;
-
-import java.io.File;
-
-public class FileUtil {
- public static File getUserPath() {
- File storage = Environment.getExternalStorageDirectory();
- File userPath = new File(storage, "citra");
- if (!userPath.isDirectory())
- userPath.mkdir();
- return userPath;
- }
-}
diff --git a/src/android/app/src/main/java/org/citra_emu/citra/utils/PermissionUtil.java b/src/android/app/src/main/java/org/citra_emu/citra/utils/PermissionUtil.java
deleted file mode 100644
index 33c8129e5..000000000
--- a/src/android/app/src/main/java/org/citra_emu/citra/utils/PermissionUtil.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright 2019 Citra Emulator Project
-// Licensed under GPLv2 or any later version
-// Refer to the license.txt file included.
-
-package org.citra_emu.citra.utils;
-
-import android.app.Activity;
-import android.content.pm.PackageManager;
-import android.support.v4.app.ActivityCompat;
-import android.support.v4.content.ContextCompat;
-
-public class PermissionUtil {
-
- /**
- * Checks a permission, if needed shows a dialog to request it
- *
- * @param activity the activity requiring the permission
- * @param permission the permission needed
- * @param requestCode supplied to the callback to determine the next action
- */
- public static void verifyPermission(Activity activity, String permission, int requestCode) {
- if (ContextCompat.checkSelfPermission(activity, permission) ==
- PackageManager.PERMISSION_GRANTED) {
- // call the callback called by requestPermissions
- activity.onRequestPermissionsResult(requestCode, new String[] {permission},
- new int[] {PackageManager.PERMISSION_GRANTED});
- return;
- }
-
- ActivityCompat.requestPermissions(activity, new String[] {permission}, requestCode);
- }
-}
diff --git a/src/android/app/src/main/jni/CMakeLists.txt b/src/android/app/src/main/jni/CMakeLists.txt
new file mode 100644
index 000000000..77b95130d
--- /dev/null
+++ b/src/android/app/src/main/jni/CMakeLists.txt
@@ -0,0 +1,34 @@
+add_library(citra-android SHARED
+ applets/mii_selector.cpp
+ applets/mii_selector.h
+ applets/swkbd.cpp
+ applets/swkbd.h
+ input_manager.cpp
+ input_manager.h
+ camera/ndk_camera.cpp
+ camera/ndk_camera.h
+ camera/still_image_camera.cpp
+ camera/still_image_camera.h
+ config.cpp
+ config.h
+ default_ini.h
+ emu_window/emu_window.cpp
+ emu_window/emu_window.h
+ game_info.cpp
+ game_info.h
+ game_settings.cpp
+ game_settings.h
+ id_cache.cpp
+ id_cache.h
+ mic.cpp
+ mic.h
+ native.cpp
+ native.h
+ ndk_motion.cpp
+ ndk_motion.h
+)
+
+target_link_libraries(citra-android PRIVATE audio_core common core input_common network)
+target_link_libraries(citra-android PRIVATE android camera2ndk EGL glad inih jnigraphics log mediandk yuv)
+
+set(CPACK_PACKAGE_EXECUTABLES ${CPACK_PACKAGE_EXECUTABLES} citra-android)
diff --git a/src/android/app/src/main/jni/applets/mii_selector.cpp b/src/android/app/src/main/jni/applets/mii_selector.cpp
new file mode 100644
index 000000000..0e8e79238
--- /dev/null
+++ b/src/android/app/src/main/jni/applets/mii_selector.cpp
@@ -0,0 +1,87 @@
+// Copyright 2020 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include "common/string_util.h"
+#include "jni/applets/mii_selector.h"
+#include "jni/id_cache.h"
+
+static jclass s_mii_selector_class;
+static jclass s_mii_selector_config_class;
+static jclass s_mii_selector_data_class;
+static jmethodID s_mii_selector_execute;
+
+namespace MiiSelector {
+
+AndroidMiiSelector::~AndroidMiiSelector() = default;
+
+void AndroidMiiSelector::Setup(const Frontend::MiiSelectorConfig& config) {
+ JNIEnv* env = IDCache::GetEnvForThread();
+ auto miis = Frontend::LoadMiis();
+
+ // Create the Java MiiSelectorConfig object
+ jobject java_config = env->AllocObject(s_mii_selector_config_class);
+ env->SetBooleanField(java_config,
+ env->GetFieldID(s_mii_selector_config_class, "enable_cancel_button", "Z"),
+ static_cast(config.enable_cancel_button));
+ env->SetObjectField(java_config,
+ env->GetFieldID(s_mii_selector_config_class, "title", "Ljava/lang/String;"),
+ env->NewStringUTF(config.title.c_str()));
+ env->SetLongField(
+ java_config,
+ env->GetFieldID(s_mii_selector_config_class, "initially_selected_mii_index", "J"),
+ static_cast(config.initially_selected_mii_index));
+
+ // List mii names
+ // The 'Standard Mii' is not included here as we need Java side to translate it
+ const jclass string_class = reinterpret_cast(env->FindClass("java/lang/String"));
+ const jobjectArray array =
+ env->NewObjectArray(static_cast(miis.size()), string_class, nullptr);
+ for (std::size_t i = 0; i < miis.size(); ++i) {
+ const auto name = Common::UTF16BufferToUTF8(miis[i].mii_name);
+ env->SetObjectArrayElement(array, static_cast(i), env->NewStringUTF(name.c_str()));
+ }
+ env->SetObjectField(
+ java_config,
+ env->GetFieldID(s_mii_selector_config_class, "mii_names", "[Ljava/lang/String;"), array);
+
+ // Invoke backend Execute method
+ jobject data =
+ env->CallStaticObjectMethod(s_mii_selector_class, s_mii_selector_execute, java_config);
+
+ const u32 return_code = static_cast(
+ env->GetLongField(data, env->GetFieldID(s_mii_selector_data_class, "return_code", "J")));
+ if (return_code == 1) {
+ Finalize(return_code, HLE::Applets::MiiData{});
+ return;
+ }
+
+ const int index = static_cast(
+ env->GetIntField(data, env->GetFieldID(s_mii_selector_data_class, "index", "I")));
+ ASSERT_MSG(index >= 0 && index <= miis.size(), "Index returned is out of bound");
+ Finalize(return_code, index == 0
+ ? HLE::Applets::MiiSelector::GetStandardMiiResult().selected_mii_data
+ : miis.at(static_cast(index - 1)));
+}
+
+void InitJNI(JNIEnv* env) {
+ s_mii_selector_class = reinterpret_cast(
+ env->NewGlobalRef(env->FindClass("org/citra/citra_emu/applets/MiiSelector")));
+ s_mii_selector_config_class = reinterpret_cast(env->NewGlobalRef(
+ env->FindClass("org/citra/citra_emu/applets/MiiSelector$MiiSelectorConfig")));
+ s_mii_selector_data_class = reinterpret_cast(env->NewGlobalRef(
+ env->FindClass("org/citra/citra_emu/applets/MiiSelector$MiiSelectorData")));
+
+ s_mii_selector_execute =
+ env->GetStaticMethodID(s_mii_selector_class, "Execute",
+ "(Lorg/citra/citra_emu/applets/MiiSelector$MiiSelectorConfig;)Lorg/"
+ "citra/citra_emu/applets/MiiSelector$MiiSelectorData;");
+}
+
+void CleanupJNI(JNIEnv* env) {
+ env->DeleteGlobalRef(s_mii_selector_class);
+ env->DeleteGlobalRef(s_mii_selector_config_class);
+ env->DeleteGlobalRef(s_mii_selector_data_class);
+}
+
+} // namespace MiiSelector
diff --git a/src/android/app/src/main/jni/applets/mii_selector.h b/src/android/app/src/main/jni/applets/mii_selector.h
new file mode 100644
index 000000000..f33d1cb8d
--- /dev/null
+++ b/src/android/app/src/main/jni/applets/mii_selector.h
@@ -0,0 +1,25 @@
+// Copyright 2020 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#pragma once
+
+#include
+#include "core/frontend/applets/mii_selector.h"
+
+namespace MiiSelector {
+
+class AndroidMiiSelector final : public Frontend::MiiSelector {
+public:
+ ~AndroidMiiSelector();
+
+ void Setup(const Frontend::MiiSelectorConfig& config) override;
+};
+
+// Should be called in JNI_Load
+void InitJNI(JNIEnv* env);
+
+// Should be called in JNI_Unload
+void CleanupJNI(JNIEnv* env);
+
+} // namespace MiiSelector
diff --git a/src/android/app/src/main/jni/applets/swkbd.cpp b/src/android/app/src/main/jni/applets/swkbd.cpp
new file mode 100644
index 000000000..062d307a6
--- /dev/null
+++ b/src/android/app/src/main/jni/applets/swkbd.cpp
@@ -0,0 +1,151 @@
+// Copyright 2020 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include